no message

This commit is contained in:
2026-06-18 14:07:38 +08:00
parent d393302388
commit 18990deb2d
12 changed files with 910 additions and 116 deletions

View File

@@ -21,19 +21,41 @@ export interface LevelDatabaseFile {
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可选 */
/** 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)
@@ -42,18 +64,62 @@ function rebuildIndex() {
.sort((a, b) => a - b);
}
function ingestFile(data: LevelDatabaseFile) {
fileCache = data;
levelsMap = {};
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;
@@ -61,6 +127,10 @@ function resolveRemoteUrl(): string | 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) {
@@ -90,6 +160,139 @@ function fetchCandidates(): string[] {
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
@@ -161,12 +364,19 @@ async function waitForInjection(maxMs = 2500): Promise<LevelDatabaseFile | null>
return null;
}
/** 异步加载AppBootstrap 启动时调用) */
/** 异步加载AppBootstrap 启动时调用;分片模式仅拉 index */
export function loadLevelDatabase(): Promise<void> {
if (fileCache) return Promise.resolve();
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();
@@ -196,6 +406,13 @@ export function loadLevelDatabase(): Promise<void> {
}
}
try {
await loadIndexFromNetwork();
return;
} catch (e) {
console.warn('[LevelDatabase] 分片索引不可用,回退全量库', e);
}
await loadFromNetwork();
} catch (e) {
loadPromise = null;
@@ -206,7 +423,11 @@ export function loadLevelDatabase(): Promise<void> {
}
export function isLevelDatabaseReady(): boolean {
return fileCache !== null;
return fileCache !== null || indexCache !== null;
}
export function isLevelDatabaseSharded(): boolean {
return indexCache !== null;
}
// --- 查 ---
@@ -216,7 +437,11 @@ export function getLevelConfig(levelID: number): LevelConfig | null {
/** 是否在 Cocos 导出的关卡库中 */
export function hasLevel(levelID: number): boolean {
return levelID in levelsMap;
if (levelID in levelsMap) return true;
if (indexCache && levelID >= indexCache.min && levelID <= indexCache.max) {
return true;
}
return false;
}
export function getLevelIds(): number[] {
@@ -227,19 +452,32 @@ 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 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 {
const i = sortedIds.indexOf(cur);
if (i < 0) return sortedIds[0] ?? cur;
return sortedIds[(i + 1) % sortedIds.length];
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 {
const i = sortedIds.indexOf(cur);
if (i < 0) return sortedIds[0] ?? cur;
return sortedIds[(i - 1 + sortedIds.length) % sortedIds.length];
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;
}
// --- 增改(运行时 / 编辑器脚本) ---