/** * 关卡数据库 — 由 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; } export interface LevelsDbIndex { version: number; mode: 'sharded'; shardSize?: number; levelIdBase?: number; generatedAt?: string; source?: string; total: number; min: number; max: number; stats?: LevelDatabaseFile['stats']; shards: Array<{ min: number; max: number; file: string; count?: number }>; } declare global { interface Window { /** 主站 scratch-gui 注入:/unity/levels-database.json */ __tfrhLevelsDatabaseUrl?: string; /** loader 预注入的 JSON(可选,legacy 全量) */ __tfrhLevelsDatabaseJson?: LevelDatabaseFile; /** 分片索引 URL / 预注入 */ __tfrhLevelsDbIndexUrl?: string; __tfrhLevelsDbIndex?: LevelsDbIndex; /** loader 按关拉取分片 */ __tfrhEnsureLevelDbShard?: (levelId: number) => Promise; } } let fileCache: LevelDatabaseFile | null = null; let indexCache: LevelsDbIndex | null = null; let levelsMap: Record = {}; let sortedIds: number[] = []; let loadPromise: Promise | null = null; const loadedShardKeys = new Set(); const shardLoadPromises = new Map>(); 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, merge = false) { if (!merge) { fileCache = data; levelsMap = {}; } else if (!fileCache) { fileCache = { version: data.version || 1, levels: {}, }; } for (const [k, cfg] of Object.entries(data.levels ?? {})) { const id = parseInt(k, 10); if (!Number.isNaN(id)) { levelsMap[id] = { ...cfg, levelID: id }; if (fileCache?.levels) { fileCache.levels[String(id)] = levelsMap[id]; } } } rebuildIndex(); } function ingestIndex(data: LevelsDbIndex) { indexCache = data; if (!fileCache) { fileCache = { version: data.version, generatedAt: data.generatedAt, source: data.source, levelIdBase: data.levelIdBase, stats: data.stats, levels: {}, }; } } function findShardForLevel(levelId: number): LevelsDbIndex['shards'][0] | null { if (!indexCache?.shards?.length) return null; for (const shard of indexCache.shards) { if (levelId >= shard.min && levelId <= shard.max) return shard; } return null; } function shardKey(shard: { file: string }) { return shard.file; } function validateIndex(index: LevelsDbIndex): void { if (index.total < 100 || index.min < LEVEL_ID_BASE) { throw new Error( `关卡库索引过旧 (${index.total} 关),请重新 package-for-project`, ); } } function resolveRemoteUrl(): string | null { if (typeof window === 'undefined') return null; const url = window.__tfrhLevelsDatabaseUrl; return url?.trim() || null; } function validateIngested(): void { if (indexCache) { validateIndex(indexCache); return; } 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 indexFetchCandidates(): string[] { const out: string[] = []; if (typeof window !== 'undefined') { const configured = window.__tfrhLevelsDbIndexUrl?.trim(); if (configured && /^https?:\/\//i.test(configured)) { out.push(configured); } } if (canUseHttpFetch()) { out.push(new URL('levels-db-index.json', window.location.href).href); out.push(new URL('/unity/levels-db-index.json', window.location.origin).href); } return [...new Set(out)]; } async function fetchJsonWithBrotli(url: string): Promise { const brUrl = url.replace(/\.json(\?.*)?$/i, '.json.br$1'); try { const brRes = await fetch(brUrl); if (brRes.ok && typeof DecompressionStream !== 'undefined') { const text = await new Response(await brRes.arrayBuffer()) .pipeThrough(new DecompressionStream('brotli')) .text(); return JSON.parse(text); } } catch { /* fallback */ } const res = await fetch(url, { credentials: 'same-origin' }); if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`); return res.json(); } async function loadIndexFromNetwork(): Promise { const candidates = indexFetchCandidates(); if (!candidates.length) { throw new Error('[LevelDatabase] 无法 fetch 关卡库索引'); } const errors: unknown[] = []; for (const url of candidates) { try { const json = await fetchJsonWithBrotli(url) as LevelsDbIndex; if (json?.mode !== 'sharded' || !json.shards?.length) { throw new Error('非分片索引'); } ingestIndex(json); validateIngested(); console.log( `[LevelDatabase] 已加载索引 ${json.total} 关 (${json.min}–${json.max})`, url, ); return; } catch (e) { errors.push(e); } } throw new Error( `[LevelDatabase] 无法加载关卡库索引: ${candidates.join(', ')}; ` + errors.map((e) => String(e)).join('; '), ); } function resolveShardUrl(shardFile: string): string { const rel = shardFile.replace(/^\/+/, ''); if (canUseHttpFetch()) { return new URL(`/unity/StreamingAssets/aa/${rel}`, window.location.origin).href; } return rel; } async function loadShardFromNetwork(shard: LevelsDbIndex['shards'][0]): Promise { const rel = shard.file.replace(/^\/+/, ''); const url = resolveShardUrl(rel); const json = await fetchJsonWithBrotli(url) as LevelDatabaseFile; ingestFile(json, true); console.log( `[LevelDatabase] 已加载分片 ${shard.min}–${shard.max} (+${Object.keys(json.levels ?? {}).length} 关)`, ); } async function loadShardNow(shard: LevelsDbIndex['shards'][0]): Promise { const key = shardKey(shard); if (loadedShardKeys.has(key)) return; const pending = shardLoadPromises.get(key); if (pending) { await pending; return; } const promise = (async () => { if (typeof window !== 'undefined' && window.__tfrhEnsureLevelDbShard) { await window.__tfrhEnsureLevelDbShard(shard.min); const json = window.__tfrhLevelsDatabaseJson; if (json?.levels) { const partial: LevelDatabaseFile = { version: json.version || 2, levels: {} }; for (const [k, cfg] of Object.entries(json.levels)) { const id = parseInt(k, 10); if (!Number.isNaN(id) && id >= shard.min && id <= shard.max) { partial.levels[k] = cfg; } } if (Object.keys(partial.levels).length) { ingestFile(partial, true); loadedShardKeys.add(key); console.log( `[LevelDatabase] 已合并分片 ${shard.min}–${shard.max} (+${Object.keys(partial.levels).length} 关)`, ); return; } } } await loadShardFromNetwork(shard); loadedShardKeys.add(key); })(); shardLoadPromises.set(key, promise); try { await promise; } finally { shardLoadPromises.delete(key); } } /** 进关前确保该关所在分片已加载 */ export async function ensureLevelShardLoaded(levelId: number): Promise { if (!indexCache) return; if (levelId in levelsMap) return; const shard = findShardForLevel(levelId); if (!shard) { console.warn(`[LevelDatabase] 关卡 ${levelId} 不在索引范围`); return; } await loadShardNow(shard); } function loadFromRemote(url: string): Promise { 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; }) .then((json) => { ingestFile(json); validateIngested(); console.log( `[LevelDatabase] 已加载 ${sortedIds.length} 关 (` + `${sortedIds[0] ?? '?'}–${sortedIds[sortedIds.length - 1] ?? '?'})`, abs, ); }); } async function loadFromNetwork(): Promise { 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 { 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 { 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 启动时调用;分片模式仅拉 index) */ export function loadLevelDatabase(): Promise { if (fileCache || indexCache) return Promise.resolve(); if (loadPromise) return loadPromise; loadPromise = (async () => { try { if (typeof window !== 'undefined' && window.__tfrhLevelsDbIndex) { ingestIndex(window.__tfrhLevelsDbIndex); validateIngested(); console.log(`[LevelDatabase] 已注入索引 ${indexCache!.total} 关`); return; } 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); } } try { await loadIndexFromNetwork(); return; } catch (e) { console.warn('[LevelDatabase] 分片索引不可用,回退全量库', e); } await loadFromNetwork(); } catch (e) { loadPromise = null; throw e; } })(); return loadPromise; } export function isLevelDatabaseReady(): boolean { return fileCache !== null || indexCache !== null; } export function isLevelDatabaseSharded(): boolean { return indexCache !== null; } // --- 查 --- export function getLevelConfig(levelID: number): LevelConfig | null { return levelsMap[levelID] ?? null; } /** 是否在 Cocos 导出的关卡库中 */ export function hasLevel(levelID: number): boolean { if (levelID in levelsMap) return true; if (indexCache && levelID >= indexCache.min && levelID <= indexCache.max) { return true; } return false; } export function getLevelIds(): number[] { return [...sortedIds]; } export function getLevelCount(): number { return sortedIds.length; } export const MIN_LEVEL_ID = (): number => indexCache?.min ?? sortedIds[0] ?? LEVEL_ID_BASE; export const MAX_LEVEL_ID = (): number => indexCache?.max ?? sortedIds[sortedIds.length - 1] ?? LEVEL_ID_BASE; export function nextLevelId(cur: number): number { if (sortedIds.length) { const i = sortedIds.indexOf(cur); if (i < 0) return sortedIds[0] ?? cur; return sortedIds[(i + 1) % sortedIds.length]; } const max = MAX_LEVEL_ID(); const min = MIN_LEVEL_ID(); if (cur < min) return min; if (cur >= max) return min; return cur + 1; } export function prevLevelId(cur: number): number { if (sortedIds.length) { const i = sortedIds.indexOf(cur); if (i < 0) return sortedIds[0] ?? cur; return sortedIds[(i - 1 + sortedIds.length) % sortedIds.length]; } const max = MAX_LEVEL_ID(); const min = MIN_LEVEL_ID(); if (cur <= min) return max; return cur - 1; } // --- 增改(运行时 / 编辑器脚本) --- 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); }