521 lines
17 KiB
TypeScript
521 lines
17 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>;
|
||
}
|
||
|
||
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<void>;
|
||
}
|
||
}
|
||
|
||
let fileCache: LevelDatabaseFile | null = null;
|
||
let indexCache: LevelsDbIndex | null = null;
|
||
let levelsMap: Record<number, LevelConfig> = {};
|
||
let sortedIds: number[] = [];
|
||
let loadPromise: Promise<void> | null = null;
|
||
const loadedShardKeys = new Set<string>();
|
||
const shardLoadPromises = new Map<string, Promise<void>>();
|
||
|
||
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<unknown> {
|
||
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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<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 启动时调用;分片模式仅拉 index) */
|
||
export function loadLevelDatabase(): Promise<void> {
|
||
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);
|
||
}
|