Files
cocos/assets/scripts/level/LevelDatabase.ts
2026-06-18 14:07:38 +08:00

521 lines
17 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 关卡数据库 — 由 Cocos 工程导出level-prefabs LevelMapData + 编辑器 spawns
*
* - 编辑器 ▶ 预览:从 assets/level-data/ 加载(不在 resources bundlefetch 不可用)
* - 主站 / 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);
}