Adds level prefabs, theme assets, audio, extensions, and deployment scripts for the Unity WebGL migration. Co-authored-by: Cursor <cursoragent@cursor.com>
283 lines
8.9 KiB
TypeScript
283 lines
8.9 KiB
TypeScript
/**
|
||
* 关卡数据库 — 由 Cocos 工程导出(level-prefabs LevelMapData + 编辑器 spawns)
|
||
*
|
||
* - 编辑器 ▶ 预览:从 assets/level-data/ 加载(不在 resources bundle,fetch 不可用)
|
||
* - 主站 / Web 运行:fetch /unity/levels-database.json
|
||
*/
|
||
import { assetManager, JsonAsset } from 'cc';
|
||
import { PREVIEW } from 'cc/env';
|
||
import { LevelConfig } from './LevelTypes';
|
||
import { LEVEL_ID_BASE } from './LevelIds';
|
||
|
||
/** assets/level-data/levels-database.json(与 .meta 一致,勿打进 resources) */
|
||
const EDITOR_DB_UUID = '114420ca-c8e3-4204-a040-18282ef5e964';
|
||
|
||
export interface LevelDatabaseFile {
|
||
version: number;
|
||
generatedAt?: string;
|
||
source?: string;
|
||
levelIdBase?: number;
|
||
stats?: { total: number; withPrefabTilemap?: number; withBoundaryRing?: number };
|
||
levels: Record<string, LevelConfig>;
|
||
}
|
||
|
||
declare global {
|
||
interface Window {
|
||
/** 主站 scratch-gui 注入:/unity/levels-database.json */
|
||
__tfrhLevelsDatabaseUrl?: string;
|
||
/** loader 预注入的 JSON(可选) */
|
||
__tfrhLevelsDatabaseJson?: LevelDatabaseFile;
|
||
}
|
||
}
|
||
|
||
let fileCache: LevelDatabaseFile | null = null;
|
||
let levelsMap: Record<number, LevelConfig> = {};
|
||
let sortedIds: number[] = [];
|
||
let loadPromise: Promise<void> | null = null;
|
||
|
||
function rebuildIndex() {
|
||
sortedIds = Object.keys(levelsMap)
|
||
.map((k) => parseInt(k, 10))
|
||
.filter((n) => !Number.isNaN(n))
|
||
.sort((a, b) => a - b);
|
||
}
|
||
|
||
function ingestFile(data: LevelDatabaseFile) {
|
||
fileCache = data;
|
||
levelsMap = {};
|
||
for (const [k, cfg] of Object.entries(data.levels ?? {})) {
|
||
const id = parseInt(k, 10);
|
||
if (!Number.isNaN(id)) {
|
||
levelsMap[id] = { ...cfg, levelID: id };
|
||
}
|
||
}
|
||
rebuildIndex();
|
||
}
|
||
|
||
function resolveRemoteUrl(): string | null {
|
||
if (typeof window === 'undefined') return null;
|
||
const url = window.__tfrhLevelsDatabaseUrl;
|
||
return url?.trim() || null;
|
||
}
|
||
|
||
function validateIngested(): void {
|
||
const total = sortedIds.length;
|
||
const minId = sortedIds[0] ?? 0;
|
||
if (total < 100 || minId < LEVEL_ID_BASE) {
|
||
throw new Error(
|
||
`关卡库过旧 (${total} 关),请用主站导出的 levels-database.json;`
|
||
+ '运行 bash tools/sync-level-db.sh 后重新 package-for-project',
|
||
);
|
||
}
|
||
}
|
||
|
||
function canUseHttpFetch(): boolean {
|
||
if (typeof window === 'undefined') return false;
|
||
const proto = window.location.protocol;
|
||
return proto === 'http:' || proto === 'https:';
|
||
}
|
||
|
||
function fetchCandidates(): string[] {
|
||
const out: string[] = [];
|
||
const configured = resolveRemoteUrl();
|
||
if (configured && /^https?:\/\//i.test(configured)) {
|
||
out.push(configured);
|
||
}
|
||
if (canUseHttpFetch()) {
|
||
out.push(new URL('levels-database.json', window.location.href).href);
|
||
out.push(new URL('/unity/levels-database.json', window.location.origin).href);
|
||
}
|
||
return [...new Set(out)];
|
||
}
|
||
|
||
function loadFromRemote(url: string): Promise<void> {
|
||
const abs = /^https?:\/\//i.test(url)
|
||
? url
|
||
: new URL(url, typeof window !== 'undefined' ? window.location.href : 'http://localhost/').href;
|
||
return fetch(abs, { credentials: 'same-origin' })
|
||
.then((res) => {
|
||
if (!res.ok) throw new Error(`HTTP ${res.status} for ${abs}`);
|
||
return res.json() as Promise<LevelDatabaseFile>;
|
||
})
|
||
.then((json) => {
|
||
ingestFile(json);
|
||
validateIngested();
|
||
console.log(
|
||
`[LevelDatabase] 已加载 ${sortedIds.length} 关 (`
|
||
+ `${sortedIds[0] ?? '?'}–${sortedIds[sortedIds.length - 1] ?? '?'})`,
|
||
abs,
|
||
);
|
||
});
|
||
}
|
||
|
||
async function loadFromNetwork(): Promise<void> {
|
||
const candidates = fetchCandidates();
|
||
if (!candidates.length) {
|
||
throw new Error('[LevelDatabase] 当前环境无法 fetch,且无预注入关卡库');
|
||
}
|
||
const errors: unknown[] = [];
|
||
for (const url of candidates) {
|
||
try {
|
||
await loadFromRemote(url);
|
||
return;
|
||
} catch (e) {
|
||
errors.push(e);
|
||
}
|
||
}
|
||
const detail = errors.map((e) => String(e)).join('; ');
|
||
throw new Error(
|
||
`[LevelDatabase] 无法加载关卡库,已尝试: ${candidates.join(', ')}; ${detail}`,
|
||
);
|
||
}
|
||
|
||
/** 编辑器预览:packages:// 协议下用 assetManager 读工程内 JSON(不进 resources bundle) */
|
||
function loadFromEditorAsset(): Promise<void> {
|
||
return new Promise((resolve, reject) => {
|
||
assetManager.loadAny({ uuid: EDITOR_DB_UUID }, (err: Error | null, asset: JsonAsset) => {
|
||
if (err || !asset?.json) {
|
||
reject(err ?? new Error('assets/level-data/levels-database.json 未找到'));
|
||
return;
|
||
}
|
||
ingestFile(asset.json as LevelDatabaseFile);
|
||
validateIngested();
|
||
console.log(
|
||
`[LevelDatabase] 已加载编辑器关卡库 ${sortedIds.length} 关 (`
|
||
+ `${sortedIds[0] ?? '?'}–${sortedIds[sortedIds.length - 1] ?? '?'})`,
|
||
);
|
||
resolve();
|
||
});
|
||
});
|
||
}
|
||
|
||
async function waitForInjection(maxMs = 2500): Promise<LevelDatabaseFile | null> {
|
||
if (typeof window === 'undefined') return null;
|
||
const step = 50;
|
||
const tries = Math.ceil(maxMs / step);
|
||
for (let i = 0; i < tries; i++) {
|
||
const json = window.__tfrhLevelsDatabaseJson;
|
||
if (json) return json;
|
||
await new Promise((r) => setTimeout(r, step));
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** 异步加载(AppBootstrap 启动时调用) */
|
||
export function loadLevelDatabase(): Promise<void> {
|
||
if (fileCache) return Promise.resolve();
|
||
if (loadPromise) return loadPromise;
|
||
loadPromise = (async () => {
|
||
try {
|
||
if (typeof window !== 'undefined' && window.__tfrhLevelsDatabaseJson) {
|
||
ingestFile(window.__tfrhLevelsDatabaseJson);
|
||
validateIngested();
|
||
console.log(`[LevelDatabase] 已注入 ${sortedIds.length} 关`);
|
||
return;
|
||
}
|
||
|
||
const injected = PREVIEW ? await waitForInjection(500) : null;
|
||
if (injected) {
|
||
ingestFile(injected);
|
||
validateIngested();
|
||
console.log(`[LevelDatabase] 已注入 ${sortedIds.length} 关`);
|
||
return;
|
||
}
|
||
|
||
if (!canUseHttpFetch()) {
|
||
await loadFromEditorAsset();
|
||
return;
|
||
}
|
||
|
||
if (PREVIEW) {
|
||
try {
|
||
await loadFromEditorAsset();
|
||
return;
|
||
} catch (e) {
|
||
console.warn('[LevelDatabase] 编辑器资源加载失败,尝试 fetch', e);
|
||
}
|
||
}
|
||
|
||
await loadFromNetwork();
|
||
} catch (e) {
|
||
loadPromise = null;
|
||
throw e;
|
||
}
|
||
})();
|
||
return loadPromise;
|
||
}
|
||
|
||
export function isLevelDatabaseReady(): boolean {
|
||
return fileCache !== null;
|
||
}
|
||
|
||
// --- 查 ---
|
||
export function getLevelConfig(levelID: number): LevelConfig | null {
|
||
return levelsMap[levelID] ?? null;
|
||
}
|
||
|
||
/** 是否在 Cocos 导出的关卡库中 */
|
||
export function hasLevel(levelID: number): boolean {
|
||
return levelID in levelsMap;
|
||
}
|
||
|
||
export function getLevelIds(): number[] {
|
||
return [...sortedIds];
|
||
}
|
||
|
||
export function getLevelCount(): number {
|
||
return sortedIds.length;
|
||
}
|
||
|
||
export const MIN_LEVEL_ID = (): number => sortedIds[0] ?? LEVEL_ID_BASE;
|
||
export const MAX_LEVEL_ID = (): number => sortedIds[sortedIds.length - 1] ?? LEVEL_ID_BASE;
|
||
|
||
export function nextLevelId(cur: number): number {
|
||
const i = sortedIds.indexOf(cur);
|
||
if (i < 0) return sortedIds[0] ?? cur;
|
||
return sortedIds[(i + 1) % sortedIds.length];
|
||
}
|
||
|
||
export function prevLevelId(cur: number): number {
|
||
const i = sortedIds.indexOf(cur);
|
||
if (i < 0) return sortedIds[0] ?? cur;
|
||
return sortedIds[(i - 1 + sortedIds.length) % sortedIds.length];
|
||
}
|
||
|
||
// --- 增改(运行时 / 编辑器脚本) ---
|
||
export function setLevel(config: LevelConfig): void {
|
||
levelsMap[config.levelID] = { ...config };
|
||
if (fileCache) {
|
||
fileCache.levels[String(config.levelID)] = levelsMap[config.levelID];
|
||
}
|
||
rebuildIndex();
|
||
}
|
||
|
||
export function addLevel(config: LevelConfig): void {
|
||
setLevel(config);
|
||
}
|
||
|
||
/** 删 */
|
||
export function removeLevel(levelID: number): boolean {
|
||
if (!(levelID in levelsMap)) return false;
|
||
delete levelsMap[levelID];
|
||
if (fileCache?.levels) {
|
||
delete fileCache.levels[String(levelID)];
|
||
}
|
||
rebuildIndex();
|
||
return true;
|
||
}
|
||
|
||
/** 导出当前内存数据(可写回 JSON 文件) */
|
||
export function exportDatabaseJson(): string {
|
||
const payload: LevelDatabaseFile = fileCache ?? {
|
||
version: 1,
|
||
generatedAt: new Date().toISOString(),
|
||
source: 'runtime',
|
||
levels: {},
|
||
};
|
||
payload.levels = Object.fromEntries(
|
||
sortedIds.map((id) => [String(id), levelsMap[id]]),
|
||
);
|
||
payload.stats = { total: sortedIds.length };
|
||
return JSON.stringify(payload, null, 2);
|
||
}
|