diff --git a/assets/scripts/AppBootstrap.ts b/assets/scripts/AppBootstrap.ts index 53fbe2e..33ff456 100644 --- a/assets/scripts/AppBootstrap.ts +++ b/assets/scripts/AppBootstrap.ts @@ -7,12 +7,8 @@ import { ViewController } from './controller/ViewController'; import { LineGridRenderer } from './gameplay/LineGridRenderer'; import { UIMain } from './ui/UIMain'; import { loadLevelDatabase, refreshLevelIdBounds, LEVEL_ID_BASE } from './level/LevelRegistry'; -import { loadThemeDatabase } from './theme/ThemeRegistry'; -import { ensureResourcesBundle } from './core/ResourcesBundle'; -import { loadTileDisplayMeta } from './visual/TileDisplayMeta'; import { ThemeBackground } from './theme/ThemeBackground'; import { GridSnapHelper } from './level/GridSnapHelper'; -import { GameAudio } from './audio/GameAudio'; import { GameplayDebugBar } from './ui/GameplayDebugBar'; import { CELL_PIXEL, CAMERA_ORTHO_HALF, DESIGN_WIDTH, DESIGN_HEIGHT, @@ -55,12 +51,8 @@ export class AppBootstrap extends Component { console.log('[AppBootstrap] 开始初始化…'); applyEmbeddedDesignResolution(); view.on('canvas-resize', this.onCanvasResize, this); - await ensureResourcesBundle(); - await loadThemeDatabase(); - await loadTileDisplayMeta(); await loadLevelDatabase(); refreshLevelIdBounds(); - await GameAudio.preload(); const scene = director.getScene()!; find('UICanvas', scene)?.destroy(); @@ -108,7 +100,7 @@ export class AppBootstrap extends Component { // 关卡预制体由 SwitchLevel → createNewLevel 按需加载(loader 进关再下 levels_all) ctl.markReady(); ctl.onBootstrapReady(); - console.log('[AppBootstrap] 引擎已就绪;SwitchLevel 进关时再加载关卡预制体'); + console.log('[AppBootstrap] 引擎已就绪;SwitchLevel 进关时再加载资源/关卡库分片/预制体'); } private ensureGameRoot(scene: Node, size: { width: number; height: number }, mainCam: Camera): Node { diff --git a/assets/scripts/GameController.ts b/assets/scripts/GameController.ts index dfcfb5a..517e5b1 100644 --- a/assets/scripts/GameController.ts +++ b/assets/scripts/GameController.ts @@ -14,6 +14,7 @@ import { } from './core/Define'; import { EventManager, EventType } from './core/EventManager'; import { ExternalLevelInfo, JsBridge } from './bridge/JsBridge'; +import { ensureRuntimeAssetsForLevel } from './core/RuntimePack'; import { hasLevel, getMaxLevelId, getMinLevelId, LEVEL_ID_BASE, resolveLevelConfig, nextLevelId, prevLevelId, @@ -25,7 +26,7 @@ import { } from './level/EntitySpawnPlacement'; import { EntityVisualOptions } from './visual/EntityTextureResolver'; import { getLevelPrefabResourcePath } from './level/LevelPrefabPaths'; -import { loadLevelPrefab } from './level/LevelPrefabLoader'; +import { loadLevelPrefab as loadLevelPrefabImpl } from './level/LevelPrefabLoader'; import { PlayerController } from './controller/PlayerController'; import { VehicleController } from './controller/VehicleController'; import { PropController } from './controller/PropController'; @@ -52,6 +53,13 @@ interface GridEntry { node: Node; } +/** Web 主站:优先走 loader 注入的 loadLevelPrefab(按关下载 + 内存 bundle) */ +function loadLevelPrefabForRuntime(path: string) { + const hook = (globalThis as { __tfrhLoadLevelPrefab?: typeof loadLevelPrefabImpl }).__tfrhLoadLevelPrefab; + if (typeof hook === 'function') return hook(path); + return loadLevelPrefabImpl(path); +} + /** * 主站唯一入口组件(原 GameManager + GameController 合并)。 * Inspector:填写 inputLevel,预览 ▶ 后点 SwitchLevel(对齐 Unity TestGame2)。 @@ -793,6 +801,13 @@ export class GameController extends Component { async createNewLevel(levelID: number) { if (this.creating) return; this.creating = true; + try { + await ensureRuntimeAssetsForLevel(levelID); + } catch (e) { + console.error('[GameController] 运行时资源加载失败', e); + this.creating = false; + return; + } const config = resolveLevelConfig(levelID); if (!config || !this.mainLevelEntrance) { this.creating = false; @@ -809,7 +824,7 @@ export class GameController extends Component { const path = getLevelPrefabResourcePath(levelID, config); try { - const prefab = await loadLevelPrefab(path); + const prefab = await loadLevelPrefabForRuntime(path); await VisualAssets.preload(this.uiStyle); // 在 instantiate 前无法拦截;先禁用 prefab 内 GridSnapHelper 的 showGrid GridSnapHelper.stripBeforePlayFromPrefab(prefab); diff --git a/assets/scripts/core/RuntimePack.ts b/assets/scripts/core/RuntimePack.ts new file mode 100644 index 0000000..c9e5f14 --- /dev/null +++ b/assets/scripts/core/RuntimePack.ts @@ -0,0 +1,40 @@ +/** + * 进关前按需加载:assets_all、主题/贴图元数据、关卡库分片。 + * 由 loader 注入 __tfrhEnsureAssetsCore / __tfrhEnsureLevelDbShard。 + */ +import { ensureResourcesBundle } from './ResourcesBundle'; +import { loadThemeDatabase } from '../theme/ThemeRegistry'; +import { loadTileDisplayMeta } from '../visual/TileDisplayMeta'; +import { ensureLevelShardLoaded } from '../level/LevelDatabase'; + +declare global { + interface Window { + __tfrhEnsureAssetsCore?: (onProgress?: (frac: number) => void) => Promise; + __tfrhEnsureLevelDbShard?: (levelId: number) => Promise; + __tfrhEnsureLevelPack?: (levelId: number) => Promise; + } +} + +let runtimePrimed = false; + +/** 首关 SwitchLevel 前拉齐运行时依赖(bootstrap 不再预载) */ +export async function ensureRuntimeAssetsForLevel(levelId: number): Promise { + if (typeof window !== 'undefined') { + if (window.__tfrhEnsureAssetsCore) { + await window.__tfrhEnsureAssetsCore(); + } + if (window.__tfrhEnsureLevelDbShard) { + await window.__tfrhEnsureLevelDbShard(levelId); + } + if (window.__tfrhEnsureLevelPack) { + await window.__tfrhEnsureLevelPack(levelId); + } + } + await ensureResourcesBundle(); + if (!runtimePrimed) { + await loadThemeDatabase(); + await loadTileDisplayMeta(); + runtimePrimed = true; + } + await ensureLevelShardLoaded(levelId); +} diff --git a/assets/scripts/core/RuntimePack.ts.meta b/assets/scripts/core/RuntimePack.ts.meta new file mode 100644 index 0000000..e4e1ef9 --- /dev/null +++ b/assets/scripts/core/RuntimePack.ts.meta @@ -0,0 +1,9 @@ +{ + "ver": "4.0.24", + "importer": "typescript", + "imported": true, + "uuid": "2de0b14b-7d3b-4ac4-963c-f7b352db60c9", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/scripts/level/LevelDatabase.ts b/assets/scripts/level/LevelDatabase.ts index c6cd21a..07eb664 100644 --- a/assets/scripts/level/LevelDatabase.ts +++ b/assets/scripts/level/LevelDatabase.ts @@ -21,19 +21,41 @@ export interface LevelDatabaseFile { 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(可选) */ + /** 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) @@ -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 { + 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 @@ -161,12 +364,19 @@ async function waitForInjection(maxMs = 2500): Promise return null; } -/** 异步加载(AppBootstrap 启动时调用) */ +/** 异步加载(AppBootstrap 启动时调用;分片模式仅拉 index) */ export function loadLevelDatabase(): Promise { - 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 { } } + try { + await loadIndexFromNetwork(); + return; + } catch (e) { + console.warn('[LevelDatabase] 分片索引不可用,回退全量库', e); + } + await loadFromNetwork(); } catch (e) { loadPromise = null; @@ -206,7 +423,11 @@ export function loadLevelDatabase(): Promise { } 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; } // --- 增改(运行时 / 编辑器脚本) --- diff --git a/tools/package-for-cdn.js b/tools/package-for-cdn.js index c081f11..ffde82b 100644 --- a/tools/package-for-cdn.js +++ b/tools/package-for-cdn.js @@ -12,13 +12,16 @@ const zlib = require('zlib'); const { execSync } = require('child_process'); const { patchPreloadSettings, + patchSplashSettings, printPackageReport, minifyLevelsDatabase, brotliCompressFile, + brotliCompressWebglBundles, formatBytes, } = require('./package-optimize'); const { listRuntimeFiles, assertRuntimePack } = require('./runtime-pack'); const { splitLevelBundles } = require('./split-level-bundles'); +const { splitLevelsDatabase } = require('./split-levels-database'); const buildDir = path.resolve(process.argv[2]); const outDir = path.resolve(process.argv[3]); @@ -83,9 +86,11 @@ function attachLevelsDatabase(outDir) { console.warn('>>> 警告: 未找到 levels-database.json'); return; } + splitLevelsDatabase(levelsDbSrc, outDir); + const levelsDbDst = path.join(outDir, 'levels-database.json'); const { before, after } = minifyLevelsDatabase(levelsDbSrc, levelsDbDst); - console.log(`>>> levels-database.json 压缩: ${formatBytes(before)} → ${formatBytes(after)}`); + console.log(`>>> levels-database.json (legacy 回退): ${formatBytes(before)} → ${formatBytes(after)}`); const { raw, br } = brotliCompressFile(levelsDbDst, path.join(outDir, 'levels-database.json.br')); console.log(`>>> levels-database.json.br: ${formatBytes(raw)} → ${formatBytes(br)}`); } @@ -139,6 +144,56 @@ function patchMainIndexForSplitLoad(mainIndexPath) { } let s = fs.readFileSync(mainIndexPath, 'utf8'); const rules = [ + { + desc: 'AppBootstrap: 首屏只 loadLevelDatabase + bounds', + old: 'yield I(),yield _(),yield E(),yield A(),L(),yield D.preload();', + neu: 'yield A(),L();', + }, + { + desc: 'GameController: 进关前按需拉 assets / 关卡库分片', + old: 'if(!this.creating){this.creating=!0;var t=R(e);', + neu: 'if(!this.creating){this.creating=!0;try{if(globalThis.__tfrhEnsureAssetsCore)yield globalThis.__tfrhEnsureAssetsCore();yield I(),yield _(),yield E();if(globalThis.__tfrhEnsureLevelDbShard)yield globalThis.__tfrhEnsureLevelDbShard(e);}catch(err){console.error("[GameController] 运行时资源加载失败",err),this.creating=!1;return}var t=R(e);', + }, + { + desc: 'LevelDatabase: 分片索引模式 isReady', + old: 'isLevelDatabaseReady:function(){return null!==s}', + neu: 'isLevelDatabaseReady:function(){return null!==s||("undefined"!=typeof window&&!!window.__tfrhLevelsDbIndex)}', + }, + { + desc: 'LevelDatabase: 启动读 index 而非全量库', + old: 'try{if("undefined"!=typeof window&&window.__tfrhLevelsDatabaseJson)', + neu: 'try{if("undefined"!=typeof window&&window.__tfrhLevelsDbIndex){s={version:window.__tfrhLevelsDbIndex.version||2,levels:{}},console.log("[LevelDatabase] 已加载索引 "+window.__tfrhLevelsDbIndex.total+" 关");return}if("undefined"!=typeof window&&window.__tfrhLevelsDatabaseJson)', + }, + { + desc: 'LevelDatabase: validate 接受 index', + old: 'function w(){var e=c.length,n=null!=(t=c[0])?t:0;if(e<100||n=n.min&&e<=n.max}', + }, + { + desc: 'LevelDatabase: getLevelConfig 读 loader 分片', + old: 'getLevelConfig:function(e){var n;return null!=(n=f[e])?n:null}', + neu: 'getLevelConfig:function(e){var n;if(null!=(n=f[e]))return n;var t="undefined"!=typeof window&&window.__tfrhLevelsDatabaseJson&&window.__tfrhLevelsDatabaseJson.levels;return t&&t[String(e)]||null}', + }, + { + desc: 'GameController: 使用 loader 全局 loadLevelPrefab hook (新构建 K)', + old: 'var l,r=yield K(i);', + neu: 'var l,r=yield(globalThis.__tfrhLoadLevelPrefab||K)(i);', + }, + { + desc: 'GameController: 使用 loader 全局 loadLevelPrefab hook (旧构建 z)', + old: 'var l,r=yield z(i);', + neu: 'var l,r=yield(globalThis.__tfrhLoadLevelPrefab||z)(i);', + }, { desc: 'AppBootstrap: 首关前不 preload UI 贴图', old: 'yield h.preload(),yield O.preload()', @@ -256,10 +311,14 @@ patchText(path.join(scenesStage, 'src', 'settings.json'), (t) => { j.assets = j.assets || {}; j.assets.server = ''; patchPreloadSettings(j, { preloadResources: false, preloadLevelPrefabs: false }); + patchSplashSettings(j, { stripSplashAssets: true }); return JSON.stringify(j); }); zipDir(scenesStage, path.join(webglDir, BUNDLE.scenesAll)); +const scenesZip = path.join(webglDir, BUNDLE.scenesAll); +const scenesBr = brotliCompressFile(scenesZip, scenesZip + '.br'); +console.log(`>>> ${BUNDLE.scenesAll}.br: ${formatBytes(scenesBr.raw)} → ${formatBytes(scenesBr.br)}`); // —— 3a. assets_all:核心资源(不含 level-prefabs)—— // 默认分包;MERGE_LEVELS=1 时合并 level-prefabs 进 assets_all(不推荐) @@ -299,6 +358,8 @@ if (fs.existsSync(path.join(scenesStage, 'src', 'effect.bin'))) { const assetsCoreZip = path.join(webglDir, BUNDLE.assetsAll); zipDir(assetsCoreStage, assetsCoreZip); console.log(`>>> assets_core (assets_all): ${formatBytes(fs.statSync(assetsCoreZip).size)}`); +const assetsBr = brotliCompressFile(assetsCoreZip, assetsCoreZip + '.br'); +console.log(`>>> ${BUNDLE.assetsAll}.br: ${formatBytes(assetsBr.raw)} → ${formatBytes(assetsBr.br)}`); // —— 3b. 每关独立 bundle + levels-manifest.json —— const levelPrefabsSrc = path.join(buildDir, 'assets', 'level-prefabs'); @@ -312,6 +373,8 @@ if (!MERGE_LEVELS) { patchCatalogRemoveLevelsBundle(path.join(aaDir, 'catalog.json')); const manifestPath = path.join(aaDir, 'levels-manifest.json'); levelPackStats = splitLevelBundles(levelPrefabsSrc, webglDir, manifestPath, tmp); + const brStats = brotliCompressWebglBundles(webglDir); + console.log(`>>> bundle Brotli: ${brStats.count} 个, 节省 ${formatBytes(brStats.saved)}`); } attachLevelsDatabase(outDir); @@ -343,7 +406,14 @@ rmrf(standaloneDir); mkdirp(standaloneDir); copyFile(path.join(unityRef, 'index.html'), path.join(standaloneDir, 'index.html')); copyDir(path.join(unityRef, 'TemplateData'), path.join(standaloneDir, 'TemplateData')); -for (const item of ['Build', 'StreamingAssets', ...['levels-database.json', 'levels-database.json.br']]) { +for (const item of [ + 'Build', + 'StreamingAssets', + 'levels-database.json', + 'levels-database.json.br', + 'levels-db-index.json', + 'levels-db-index.json.br', +]) { const src = path.join(outDir, item); const dst = path.join(standaloneDir, item); if (!fs.existsSync(src)) continue; @@ -372,6 +442,6 @@ for (const p of requiredBundles) { const preloadNote = MERGE_LEVELS ? 'scenes ∥ assets_all 合并包 (MERGE_LEVELS=1)' - : `scenes ∥ assets_core 首屏;关卡包进关按需 (${levelPackStats ? levelPackStats.packed : '?'} 关)`; + : `scenes ∥ assets_core 首屏(引擎启动必需);关卡库分片 + 关卡 bundle 进关按需 (${levelPackStats ? levelPackStats.packed : '?'} 关)`; console.log('\n完成。运行时包 → 本地 import-to-unity.sh / OSS unitycdndir(同一目录)'); printPackageReport(outDir, { preloadNote }); diff --git a/tools/package-for-project.js b/tools/package-for-project.js index 1c66d14..e9e2174 100644 --- a/tools/package-for-project.js +++ b/tools/package-for-project.js @@ -25,6 +25,7 @@ const { minifyLevelsDatabase, brotliCompressFile, patchPreloadSettings, + patchSplashSettings, printPackageReport, formatBytes, } = require('./package-optimize'); @@ -201,6 +202,7 @@ function patchSettingsForFrontend(settingsFile) { preloadResources: opts.preloadResources, preloadLevelPrefabs: opts.preloadLevelPrefabs, }); + patchSplashSettings(j, { stripSplashAssets: true }); if (j.rendering) { j.rendering.effectSettingsPath = `${UNITY_BASE}src/effect.bin`; } diff --git a/tools/package-for-project.sh b/tools/package-for-project.sh index 737c9ad..a74d1e0 100755 --- a/tools/package-for-project.sh +++ b/tools/package-for-project.sh @@ -32,10 +32,11 @@ usage() { echo " --skip-manifest 不生成 deploy/ 清单" >&2 echo " --zip 额外生成 build/mstest5-runtime.zip" >&2 echo "" >&2 - echo "默认分包: assets_all 首屏含 level-prefabs 壳;每关独立 bundle 进关按需" >&2 + echo "默认分包: scenes 首屏;assets_all / 关卡库分片 / 每关 bundle 进关按需" >&2 echo " MERGE_LEVELS=1 合并 level-prefabs 进 assets_all(不推荐)" >&2 echo "运行时包结构(本地 static/unity = OSS unitycdndir):" >&2 - echo " Build/ StreamingAssets/ levels-database.json(.br)" >&2 + echo " Build/ StreamingAssets/ levels-db-index.json(.br) levels-database.json(.br)" >&2 + echo " 首屏: scenes_all + assets_all;进关: 关卡库分片 + 关卡 bundle" >&2 echo "" >&2 echo "步骤 2: scratch-gui/static/unity/import-to-unity.sh" >&2 } diff --git a/tools/package-optimize.js b/tools/package-optimize.js index 5a52f21..f9e56b6 100644 --- a/tools/package-optimize.js +++ b/tools/package-optimize.js @@ -3,8 +3,10 @@ * 供 package-for-project.js / package-for-cdn.js 共用。 */ const fs = require('fs'); +const os = require('os'); const path = require('path'); const zlib = require('zlib'); +const { execSync } = require('child_process'); function formatBytes(n) { if (n >= 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`; @@ -46,6 +48,23 @@ function brotliCompressFile(srcPath, dstPath, quality = 9) { return { raw: input.length, br: out.length }; } +/** 为 WebGL 目录下所有 .bundle 生成 .bundle.br */ +function brotliCompressWebglBundles(webglDir, opts = {}) { + if (!fs.existsSync(webglDir)) return { count: 0, saved: 0 }; + let count = 0; + let saved = 0; + for (const name of fs.readdirSync(webglDir)) { + if (!name.endsWith('.bundle') || name.endsWith('.bundle.br')) continue; + const src = path.join(webglDir, name); + if (!fs.statSync(src).isFile()) continue; + const { raw, br } = brotliCompressFile(src, src + '.br', opts.quality); + count += 1; + saved += raw - br; + console.log(`>>> ${name}.br: ${formatBytes(raw)} → ${formatBytes(br)}`); + } + return { count, saved }; +} + /** * 调整预加载 bundle:默认只预加载 main,resources / level-prefabs 按需加载。 * @param {object} opts @@ -68,6 +87,50 @@ function patchPreloadSettings(settingsObj, opts = {}) { return settingsObj; } +/** + * 关闭 Cocos 启动闪屏("Created with Cocos")。 + * totalTime <= 0 时引擎跳过闪屏等待,首屏可快约 2s;不影响关卡切换与网络下载。 + * @param {object} opts + * @param {boolean} [opts.disableSplash=true] + * @param {boolean} [opts.stripSplashAssets=false] 去掉内嵌 logo/background,略减 scenes 包体积 + */ +function patchSplashSettings(settingsObj, opts = {}) { + const disableSplash = opts.disableSplash !== false; + if (!disableSplash) return settingsObj; + + settingsObj.splashScreen = settingsObj.splashScreen || {}; + settingsObj.splashScreen.totalTime = 0; + + if (opts.stripSplashAssets) { + delete settingsObj.splashScreen.logo; + delete settingsObj.splashScreen.background; + } + return settingsObj; +} + +/** + * 就地修改 zip bundle 内的 settings.json(必须用 zip -0 store,loader 不支持 deflate)。 + */ +function patchSplashInZipBundle(zipPath, opts = {}) { + const absZip = path.resolve(zipPath); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'cocos-splash-')); + try { + execSync(`unzip -q -o "${absZip}" -d "${tmp}"`, { stdio: 'pipe' }); + const settingsPath = path.join(tmp, 'src', 'settings.json'); + if (!fs.existsSync(settingsPath)) return false; + const j = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + patchSplashSettings(j, opts); + fs.writeFileSync(settingsPath, JSON.stringify(j)); + const newZip = absZip + '.new'; + if (fs.existsSync(newZip)) fs.unlinkSync(newZip); + execSync(`cd "${tmp}" && zip -0 -q -r "${newZip}" .`, { stdio: 'pipe' }); + fs.renameSync(newZip, absZip); + return true; + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +} + function printPackageReport(outDir, opts = {}) { const lines = ['\n>>> 包体积报告:']; const entries = [ @@ -101,6 +164,9 @@ module.exports = { fileSize, minifyLevelsDatabase, brotliCompressFile, + brotliCompressWebglBundles, patchPreloadSettings, + patchSplashSettings, + patchSplashInZipBundle, printPackageReport, }; diff --git a/tools/runtime-pack.js b/tools/runtime-pack.js index 238847b..bc6dee3 100644 --- a/tools/runtime-pack.js +++ b/tools/runtime-pack.js @@ -6,13 +6,21 @@ * StreamingAssets/ * levels-database.json * levels-database.json.br + * levels-db-index.json + * levels-db-index.json.br + * StreamingAssets/aa/levels-db/ * * 不含 index.html / TemplateData(仅 standalone-player 独立调试页使用) */ const fs = require('fs'); const path = require('path'); -const RUNTIME_ROOT_FILES = ['levels-database.json', 'levels-database.json.br']; +const RUNTIME_ROOT_FILES = [ + 'levels-database.json', + 'levels-database.json.br', + 'levels-db-index.json', + 'levels-db-index.json.br', +]; const RUNTIME_DIRS = ['Build', 'StreamingAssets']; function walkFiles(root, bucket, prefix = '') { @@ -62,6 +70,9 @@ function assertRuntimePack(packDir, opts) { if (!fs.existsSync(catalog)) { throw new Error(`缺少运行时包: ${catalog}`); } + if (!fs.existsSync(path.join(packDir, 'levels-db-index.json'))) { + throw new Error(`缺少运行时包: ${path.join(packDir, 'levels-db-index.json')}`); + } if (!fs.existsSync(path.join(packDir, 'levels-database.json'))) { throw new Error(`缺少运行时包: ${path.join(packDir, 'levels-database.json')}`); } diff --git a/tools/split-levels-database.js b/tools/split-levels-database.js new file mode 100644 index 0000000..a23c64f --- /dev/null +++ b/tools/split-levels-database.js @@ -0,0 +1,94 @@ +#!/usr/bin/env node +/** + * 将 levels-database.json 拆为 index + 分片,首屏只下 index(~几 KB)。 + * + * 输出: + * /levels-db-index.json (+ .br) + * /StreamingAssets/aa/levels-db/-.json (+ .br) + */ +const fs = require('fs'); +const path = require('path'); +const { brotliCompressFile, formatBytes } = require('./package-optimize'); + +const DEFAULT_SHARD_SIZE = 100; + +function mkdirp(p) { fs.mkdirSync(p, { recursive: true }); } + +/** + * @param {string} srcPath assets/level-data/levels-database.json + * @param {string} outDir 运行时包根目录 + * @param {{ shardSize?: number }} opts + */ +function splitLevelsDatabase(srcPath, outDir, opts = {}) { + const shardSize = opts.shardSize || DEFAULT_SHARD_SIZE; + const raw = JSON.parse(fs.readFileSync(srcPath, 'utf8')); + const levels = raw.levels || {}; + const ids = Object.keys(levels) + .map((k) => parseInt(k, 10)) + .filter((n) => !Number.isNaN(n)) + .sort((a, b) => a - b); + + if (ids.length < 1) { + throw new Error(`关卡库为空: ${srcPath}`); + } + + const min = ids[0]; + const max = ids[ids.length - 1]; + const shardDir = path.join(outDir, 'StreamingAssets', 'aa', 'levels-db'); + mkdirp(shardDir); + + const shards = []; + for (let start = min; start <= max; start += shardSize) { + const end = Math.min(start + shardSize - 1, max); + const slice = {}; + for (const id of ids) { + if (id >= start && id <= end) { + slice[String(id)] = levels[String(id)]; + } + } + if (!Object.keys(slice).length) continue; + + const fileName = `${start}-${end}.json`; + const relFile = `levels-db/${fileName}`; + const absPath = path.join(shardDir, fileName); + fs.writeFileSync(absPath, JSON.stringify({ levels: slice }), 'utf8'); + const br = brotliCompressFile(absPath, absPath + '.br'); + console.log(`>>> levels-db/${fileName}: ${formatBytes(br.raw)} → .br ${formatBytes(br.br)}`); + + shards.push({ min: start, max: end, file: relFile, count: Object.keys(slice).length }); + } + + const index = { + version: 2, + mode: 'sharded', + shardSize, + levelIdBase: raw.levelIdBase, + generatedAt: raw.generatedAt || new Date().toISOString(), + source: raw.source || 'split-levels-database', + total: ids.length, + min, + max, + stats: raw.stats, + shards, + }; + + const indexPath = path.join(outDir, 'levels-db-index.json'); + fs.writeFileSync(indexPath, JSON.stringify(index), 'utf8'); + const indexBr = brotliCompressFile(indexPath, indexPath + '.br'); + console.log(`>>> levels-db-index.json: ${formatBytes(indexBr.raw)} → .br ${formatBytes(indexBr.br)}`); + console.log(`>>> 关卡库分片: ${shards.length} 片, ${ids.length} 关 (${min}–${max})`); + + return { index, shards, total: ids.length }; +} + +module.exports = { splitLevelsDatabase, DEFAULT_SHARD_SIZE }; + +if (require.main === module) { + const src = path.resolve(process.argv[2]); + const out = path.resolve(process.argv[3]); + if (!src || !out) { + console.error('Usage: split-levels-database.js '); + process.exit(1); + } + splitLevelsDatabase(src, out); +} diff --git a/web-template/mstest5-cocos-loader.js b/web-template/mstest5-cocos-loader.js index 11b6648..3c403f8 100644 --- a/web-template/mstest5-cocos-loader.js +++ b/web-template/mstest5-cocos-loader.js @@ -10,6 +10,11 @@ var streamingBase = ''; var bundleManifest = null; var levelsManifest = null; + var levelsManifestPromise = null; + var assetsCoreLoaded = false; + var assetsCorePromise = null; + var loadedLevelDbShards = new Set(); + var levelDbShardPromises = new Map(); var levelPackPromises = new Map(); var loadedLevelIds = new Set(); var levelPrefabsBundlePromise = null; @@ -54,9 +59,8 @@ return; } var existing = am.getBundle('level-prefabs'); - if (existing) { - if (onComplete) onComplete(null, existing); - return existing; + if (existing && typeof am.removeBundle === 'function') { + try { am.removeBundle(existing); } catch (e) { /* ignore */ } } if (levelPrefabsBundlePromise) { levelPrefabsBundlePromise.then(function (bundle) { @@ -131,6 +135,40 @@ } } + /** Cocos bundle 常请求 import/NN/uuid.json,zip 内路径带 assets/.../import/ 前缀(NN 为 uuid 前 2 位 hex) */ + var IMPORT_SHARD = '[0-9a-fA-F]{2}'; + function resolveImportFileKey(pathname) { + if (!pathname) return null; + var p = String(pathname).replace(/\\/g, '/').replace(/^\/+/, ''); + var shardRe = new RegExp('^unity/assets/(level-prefabs|resources|main|internal)/(' + IMPORT_SHARD + ')/(.+)$', 'i'); + p = p.replace(shardRe, function (_, bundle, dir, file) { + return 'assets/' + bundle + '/import/' + dir + '/' + file; + }); + if (files.has(p)) return p; + var bundleImport = new RegExp('^assets/(level-prefabs|resources|main|internal)/(' + IMPORT_SHARD + ')/([^/]+)$', 'i').exec(p); + if (bundleImport) { + var withImport = 'assets/' + bundleImport[1] + '/import/' + bundleImport[2] + '/' + bundleImport[3]; + if (files.has(withImport)) return withImport; + } + var short = new RegExp('^(' + IMPORT_SHARD + ')/(.+)$', 'i').exec(p); + if (short) { + var bases = [ + 'assets/level-prefabs/import/', + 'assets/resources/import/', + 'assets/main/import/', + 'assets/internal/import/', + 'assets/resources/native/', + 'assets/main/native/', + 'assets/internal/native/', + ]; + for (var i = 0; i < bases.length; i++) { + var key = bases[i] + short[1] + '/' + short[2]; + if (files.has(key)) return key; + } + } + return null; + } + function lookupFile(pathname) { var keys = [pathname, pathname.replace(/^\.?\//, '')]; var parts = pathname.split('/'); @@ -141,6 +179,8 @@ for (var i = 0; i < keys.length; i++) { var k = keys[i]; if (files.has(k)) return files.get(k); + var importKey = resolveImportFileKey(k); + if (importKey && files.has(importKey)) return files.get(importKey); var idx = k.indexOf('StreamingAssets/aa/WebGL/'); if (idx >= 0 && files.has(k.slice(idx + 'StreamingAssets/aa/WebGL/'.length))) { return files.get(k.slice(idx + 'StreamingAssets/aa/WebGL/'.length)); @@ -151,6 +191,8 @@ } if (k.indexOf('assets/') === 0 && files.has(k)) return files.get(k); } + var fallbackKey = resolveImportFileKey(pathname); + if (fallbackKey && files.has(fallbackKey)) return files.get(fallbackKey); return null; } @@ -221,10 +263,20 @@ var u = new URL(String(raw), global.location.href); var p = u.pathname; if (files.size > 0) { + var importKey = resolveImportFileKey(p) || resolveImportFileKey(p.replace(/^\/+/, '')); + if (importKey) { + var importVirt = virtualUrlForBundleFile(importKey); + if (importVirt) return importVirt; + } var rel = relPathFromUrlPath(p); if (rel) { var virt = virtualUrlForBundleFile(rel); if (virt) return virt; + importKey = resolveImportFileKey(rel); + if (importKey) { + importVirt = virtualUrlForBundleFile(importKey); + if (importVirt) return importVirt; + } } } if (p.indexOf(getRootPathname()) === 0) return u.href; @@ -366,6 +418,12 @@ proto.__tfrhLevelPrefabScript = true; } + function needsAssetsCore(pathname) { + var p = String(pathname || ''); + if (needsLevelPrefabs(p)) return false; + return p.indexOf('assets/') >= 0 || p.indexOf('/assets/') >= 0; + } + function installFetchShim() { global.fetch = function (input, init) { input = rewriteEmbeddedUrl(input); @@ -378,6 +436,19 @@ headers: { 'Content-Type': mimeForPath(p) }, })); } + if (needsAssetsCore(p)) { + return ensureAssetsCoreLoaded().then(function () { + var hit2 = lookupFile(p); + if (hit2) { + var body2 = /\.(m?js|json)$/i.test(p) ? bytesToText(hit2) : hit2; + return new Response(body2, { + status: 200, + headers: { 'Content-Type': mimeForPath(p) }, + }); + } + return origFetch(input, init); + }); + } if (needsLevelPrefabs(p)) { return ensureLevelPackForPath(p).then(function () { var hit2 = lookupFile(p); @@ -407,6 +478,18 @@ var origSend = xhr.send; xhr.send = function () { var hit = lookupFile(toPath(reqUrl)); + if (!hit && needsAssetsCore(reqUrl)) { + ensureAssetsCoreLoaded().then(function () { + hit = lookupFile(toPath(reqUrl)); + if (!hit) return origSend.apply(xhr, arguments); + var isText = /\.(m?js|json|txt|xml|atlas|tmx|tsx|vsh|fsh|fnt|plist)$/i.test(toPath(reqUrl)) + || xhr.responseType === 'json' + || xhr.responseType === 'text' + || !xhr.responseType; + deliverXhrHit(xhr, isText ? bytesToText(hit) : null, isText ? null : hit); + }).catch(function () { origSend.apply(xhr, arguments); }); + return; + } if (!hit && needsLevelPrefabs(reqUrl)) { ensureLevelPackForPath(reqUrl).then(function () { hit = lookupFile(toPath(reqUrl)); @@ -510,32 +593,36 @@ if (onProgress) pending.then(function () { onProgress(1); }).catch(function () {}); return pending; } - if (!levelsManifest || !levelsManifest.levels || !levelsManifest.levels[levelId]) { - return Promise.reject(new Error('关卡包未在 manifest 中: Level' + levelId)); - } - if (!streamingBase) return Promise.reject(new Error('streamingBase 未初始化')); - var entry = levelsManifest.levels[levelId]; - var url = joinUrl(streamingBase, 'aa/WebGL/' + entry.bundle); - dispatchLevelsBundleProgress(0); - var promise = fetchBinaryWithProgress( - url, - function (frac) { - dispatchLevelsBundleProgress(frac); - if (onProgress) onProgress(frac); - }, - 0, - 1, - ).then(function (buf) { - mergeZipIntoFiles(unzip(buf)); - var ok = (entry.files || []).every(function (rel) { return files.has(rel); }); - if (!ok) { - throw new Error('关卡包解压不完整: Level' + levelId); - } - loadedLevelIds.add(levelId); - return invalidateLevelPrefabsBundle(); + var promise = ensureAssetsCoreLoaded().then(function () { + return ensureLevelsManifestLoaded(); }).then(function () { - dispatchLevelsBundleProgress(1); - if (onProgress) onProgress(1); + if (!levelsManifest || !levelsManifest.levels || !levelsManifest.levels[levelId]) { + return Promise.reject(new Error('关卡包未在 manifest 中: Level' + levelId)); + } + if (!streamingBase) return Promise.reject(new Error('streamingBase 未初始化')); + var entry = levelsManifest.levels[levelId]; + var url = joinUrl(streamingBase, 'aa/WebGL/' + entry.bundle); + dispatchLevelsBundleProgress(0); + return fetchBinaryWithProgress( + url, + function (frac) { + dispatchLevelsBundleProgress(frac); + if (onProgress) onProgress(frac); + }, + 0, + 1, + ).then(function (buf) { + mergeZipIntoFiles(unzip(buf)); + var ok = (entry.files || []).every(function (rel) { return files.has(rel); }); + if (!ok) { + throw new Error('关卡包解压不完整: Level' + levelId); + } + loadedLevelIds.add(levelId); + return invalidateLevelPrefabsBundle(); + }).then(function () { + dispatchLevelsBundleProgress(1); + if (onProgress) onProgress(1); + }); }).catch(function (e) { levelPackPromises.delete(levelId); throw e; @@ -721,6 +808,7 @@ var raw = String(path || '').trim(); var m = /Level(\d+)/.exec(raw); var lid = m ? parseInt(m[1], 10) : NaN; + var loadPath = raw.indexOf('level-prefabs/') === 0 ? raw : ('level-prefabs/' + raw.replace(/^level-prefabs\//, '')); var prep = (typeof global.__tfrhEnsureLevelPack === 'function' && !Number.isNaN(lid)) ? global.__tfrhEnsureLevelPack(lid) : Promise.resolve(); @@ -728,17 +816,35 @@ return prep.then(function () { return invalidateLevelPrefabsBundle(); }).then(function () { + return ensureLevelsManifestLoaded(); + }).then(function (manifest) { + var entry = manifest && manifest.levels && manifest.levels[String(lid)]; + var uuid = entry && entry.uuid; return new Promise(function (resolve, reject) { - cc.assetManager.loadBundle('level-prefabs', function (err, bundle) { - if (err || !bundle) { - reject(err || new Error('level-prefabs bundle unavailable')); - return; - } - bundle.load(raw, Prefab, function (e, asset) { - if (e || !asset) reject(e || new Error('missing prefab: ' + raw)); - else resolve(asset); + function finish(err, asset) { + if (!err && asset) resolve(asset); + else reject(err || new Error('missing prefab: ' + loadPath)); + } + function tryBundleLoad() { + cc.assetManager.loadBundle('level-prefabs', function (err, bundle) { + if (err || !bundle) { + reject(err || new Error('level-prefabs bundle unavailable')); + return; + } + bundle.load(loadPath, Prefab, finish); }); - }); + } + if (uuid) { + cc.assetManager.loadAny({ uuid: uuid, type: Prefab }, function (err, asset) { + if (!err && asset) { + resolve(asset); + return; + } + tryBundleLoad(); + }); + return; + } + tryBundleLoad(); }); }); } @@ -1060,26 +1166,83 @@ return out; } + function isZipBytes(bytes) { + return bytes && bytes.length >= 2 && bytes[0] === 0x50 && bytes[1] === 0x4b; + } + + function isJsonText(text) { + if (!text) return false; + var c = text.charCodeAt(0); + return c === 0x7b || c === 0x5b; + } + + function decodeBrotliToBytes(ab) { + if (typeof DecompressionStream !== 'undefined') { + return new Response(ab).pipeThrough(new DecompressionStream('brotli')) + .arrayBuffer() + .then(function (buf) { return new Uint8Array(buf); }); + } + return Promise.reject(new Error('浏览器不支持 Brotli 解压')); + } + + /** OSS 若对 .br 文件再设 Content-Encoding:br,fetch 已解压成 zip/json,勿二次 Brotli */ + function normalizeBinaryPayload(bytes, expectBrotli) { + if (!expectBrotli || isZipBytes(bytes)) { + return Promise.resolve(bytes); + } + var slice = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); + return decodeBrotliToBytes(slice).catch(function () { + if (isZipBytes(bytes)) return bytes; + return Promise.reject(new Error('Brotli 解压失败')); + }); + } + function fetchBinary(url, onProgress) { - return origFetch(url).then(function (res) { - if (!res.ok) throw new Error('HTTP ' + res.status + ' for ' + url); - if (onProgress) onProgress(0.3); - return res.arrayBuffer(); - }).then(function (ab) { - if (onProgress) onProgress(0.6); - return new Uint8Array(ab); + return fetchBinaryWithProgress(url, onProgress, 0, 1); + } + + function decodeBrotliToText(ab) { + if (typeof DecompressionStream !== 'undefined') { + return new Response(ab).pipeThrough(new DecompressionStream('brotli')) + .arrayBuffer() + .then(function (buf) { return new TextDecoder().decode(buf); }); + } + return Promise.reject(new Error('浏览器不支持 Brotli 解压')); + } + + function normalizeJsonPayload(ab, expectBrotli) { + var text = new TextDecoder().decode(ab); + if (!expectBrotli || isJsonText(text)) return Promise.resolve(text); + return decodeBrotliToText(ab).catch(function () { + text = new TextDecoder().decode(ab); + if (isJsonText(text)) return text; + return Promise.reject(new Error('Brotli JSON 解压失败')); }); } function fetchBinaryWithProgress(url, onProgress, weightStart, weightEnd) { weightStart = weightStart == null ? 0 : weightStart; weightEnd = weightEnd == null ? 1 : weightEnd; - return origFetch(url).then(function (res) { + var brUrl = String(url).replace(/\.bundle(\?.*)?$/i, '.bundle.br$1'); + if (!/\.br(\?|$)/i.test(brUrl) && brUrl.indexOf('.bundle') >= 0) { + /* brUrl already set */ + } else if (/\.json(\?|$)/i.test(url)) { + brUrl = url.replace(/\.json(\?.*)?$/i, '.json.br$1'); + } else if (!/\.br(\?|$)/i.test(url)) { + brUrl = url + '.br'; + } else { + brUrl = url; + } + + function readResponse(res, isBrotli) { if (!res.ok) throw new Error('HTTP ' + res.status + ' for ' + url); if (!res.body || !res.body.getReader) { return res.arrayBuffer().then(function (ab) { - if (onProgress) onProgress(weightEnd); - return new Uint8Array(ab); + var bytes = new Uint8Array(ab); + return normalizeBinaryPayload(bytes, isBrotli).then(function (out) { + if (onProgress) onProgress(weightEnd); + return out; + }); }); } var reader = res.body.getReader(); @@ -1089,13 +1252,19 @@ function pump() { return reader.read().then(function (result) { if (result.done) { - if (onProgress) onProgress(weightEnd); var out = new Uint8Array(received); var off = 0; for (var i = 0; i < chunks.length; i++) { out.set(chunks[i], off); off += chunks[i].length; } + if (isBrotli) { + return normalizeBinaryPayload(out, true).then(function (decoded) { + if (onProgress) onProgress(weightEnd); + return decoded; + }); + } + if (onProgress) onProgress(weightEnd); return out; } chunks.push(result.value); @@ -1108,11 +1277,45 @@ }); } return pump(); + } + + return origFetch(brUrl).then(function (res) { + if (res.ok) return readResponse(res, true); + return origFetch(url).then(function (plain) { return readResponse(plain, false); }); + }).catch(function () { + return origFetch(url).then(function (plain) { return readResponse(plain, false); }); }); } + function ensureAssetsCoreLoaded(onProgress) { + if (assetsCoreLoaded) return Promise.resolve(); + if (assetsCorePromise) return assetsCorePromise; + if (!bundleManifest || !bundleManifest.assetsUrl) { + return Promise.reject(new Error('assets_all manifest 未就绪')); + } + assetsCorePromise = fetchBinaryWithProgress( + bundleManifest.assetsUrl, + onProgress || function () {}, + 0, + 1, + ).then(function (buf) { + mergeZipIntoFiles(unzip(buf)); + patchSettingsScriptPackages(); + assetsCoreLoaded = true; + }).catch(function (e) { + assetsCorePromise = null; + throw e; + }); + return assetsCorePromise; + } + + global.__tfrhEnsureAssetsCore = function (onProgress) { + return ensureAssetsCoreLoaded(onProgress); + }; + function loadStartupBundles(manifest, onProgress) { onProgress(0.05); + /* internal/main 等引擎启动即需,assets_all 须与 scenes 并行首屏加载 */ var scenesP = fetchBinaryWithProgress(manifest.scenesUrl, onProgress, 0.05, 0.42); var assetsP = fetchBinaryWithProgress(manifest.assetsUrl, onProgress, 0.42, 0.58); return Promise.all([scenesP, assetsP]).then(function (bufs) { @@ -1120,6 +1323,7 @@ assertCocosRuntimeLoaded(); mergeZipIntoFiles(unzip(bufs[1])); patchSettingsScriptPackages(); + assetsCoreLoaded = true; onProgress(0.6); }); } @@ -1190,6 +1394,58 @@ }); } + function ensureLevelsManifestLoaded() { + if (levelsManifest) return Promise.resolve(levelsManifest); + if (levelsManifestPromise) return levelsManifestPromise; + if (!streamingBase) return Promise.reject(new Error('streamingBase 未初始化')); + levelsManifestPromise = loadLevelsManifest(streamingBase).then(function (lm) { + levelsManifest = lm; + return lm; + }); + return levelsManifestPromise; + } + + function findLevelDbShard(index, levelId) { + if (!index || !index.shards) return null; + var id = Number(levelId); + for (var i = 0; i < index.shards.length; i++) { + var s = index.shards[i]; + if (id >= s.min && id <= s.max) return s; + } + return null; + } + + function ensureLevelDbShardLoaded(levelId) { + var index = global.__tfrhLevelsDbIndex; + if (!index || index.mode !== 'sharded') return Promise.resolve(); + var shard = findLevelDbShard(index, levelId); + if (!shard) return Promise.resolve(); + var key = shard.file; + if (loadedLevelDbShards.has(key)) return Promise.resolve(); + if (levelDbShardPromises.has(key)) return levelDbShardPromises.get(key); + if (!streamingBase) return Promise.reject(new Error('streamingBase 未初始化')); + var url = joinUrl(streamingBase, 'aa/' + key.replace(/^\/+/, '')); + var promise = fetchLevelsDatabaseJson(url).then(function (json) { + loadedLevelDbShards.add(key); + if (!global.__tfrhLevelsDatabaseJson) { + global.__tfrhLevelsDatabaseJson = { version: index.version || 2, levels: {} }; + } + var merged = global.__tfrhLevelsDatabaseJson.levels || {}; + var incoming = (json && json.levels) || {}; + Object.keys(incoming).forEach(function (k) { merged[k] = incoming[k]; }); + global.__tfrhLevelsDatabaseJson.levels = merged; + }).catch(function (e) { + levelDbShardPromises.delete(key); + throw e; + }); + levelDbShardPromises.set(key, promise); + return promise; + } + + global.__tfrhEnsureLevelDbShard = function (levelId) { + return ensureLevelDbShardLoaded(levelId); + }; + function loadBundleManifest(config) { var base = resolveStreamingBase(config); var catalogUrl = joinUrl(base, 'aa/catalog.json'); @@ -1198,16 +1454,13 @@ return res.json(); }).then(function (catalog) { var bundles = parseCatalogBundles(catalog); - return loadLevelsManifest(base).then(function (lm) { - levelsManifest = lm; - var manifest = { - scenesUrl: joinUrl(base, 'aa/WebGL/' + bundles.scenes), - assetsUrl: joinUrl(base, 'aa/WebGL/' + bundles.assets), - bundleNames: bundles, - }; - bundleManifest = manifest; - return manifest; - }); + var manifest = { + scenesUrl: joinUrl(base, 'aa/WebGL/' + bundles.scenes), + assetsUrl: joinUrl(base, 'aa/WebGL/' + bundles.assets), + bundleNames: bundles, + }; + bundleManifest = manifest; + return manifest; }); } @@ -1268,36 +1521,33 @@ packageRoot = getPackageRoot(); installEmbeddedPathFix(); - function resolveLevelsDatabaseUrl(config) { - if (global.__tfrhLevelsDatabaseUrl) return global.__tfrhLevelsDatabaseUrl; + function resolveLevelsDbBase(config) { var sa = config && config.streamingAssetsUrl; if (sa && /^https?:\/\//i.test(sa)) { - var cdnRoot = String(sa).replace(/\/StreamingAssets\/?$/i, ''); - return joinUrl(cdnRoot, 'levels-database.json'); + return String(sa).replace(/\/StreamingAssets\/?$/i, ''); } - return joinUrl(packageRoot || getPackageRoot(), 'levels-database.json'); + return packageRoot || getPackageRoot(); } - function decodeBrotliToText(ab) { - if (typeof DecompressionStream !== 'undefined') { - return new Response(ab).pipeThrough(new DecompressionStream('brotli')) - .arrayBuffer() - .then(function (buf) { return new TextDecoder().decode(buf); }); - } - return Promise.reject(new Error('浏览器不支持 Brotli 解压')); + function resolveLevelsDatabaseUrl(config) { + if (global.__tfrhLevelsDatabaseUrl) return global.__tfrhLevelsDatabaseUrl; + return joinUrl(resolveLevelsDbBase(config), 'levels-database.json'); + } + + function resolveLevelsDbIndexUrl(config) { + if (global.__tfrhLevelsDbIndexUrl) return global.__tfrhLevelsDbIndexUrl; + return joinUrl(resolveLevelsDbBase(config), 'levels-db-index.json'); } function fetchLevelsDatabaseJson(url) { var brUrl = url.replace(/\.json(\?.*)?$/i, '.json.br$1'); return origFetch(brUrl).then(function (res) { if (!res.ok) throw new Error('HTTP ' + res.status); - var enc = (res.headers.get('content-encoding') || '').toLowerCase(); - if (enc === 'br' || /\.br(\?|$)/i.test(brUrl)) { - return res.arrayBuffer().then(decodeBrotliToText).then(function (text) { + return res.arrayBuffer().then(function (ab) { + return normalizeJsonPayload(ab, true).then(function (text) { return JSON.parse(text); }); - } - return res.json(); + }); }).catch(function () { return origFetch(url).then(function (res) { if (!res.ok) throw new Error('HTTP ' + res.status); @@ -1306,14 +1556,20 @@ }); } - function prefetchMainSiteLevelDb(config) { - var url = resolveLevelsDatabaseUrl(config); - global.__tfrhLevelsDatabaseUrl = url; - if (!url || global.__tfrhLevelsDatabaseJson) return Promise.resolve(); - return fetchLevelsDatabaseJson(url).then(function (json) { - global.__tfrhLevelsDatabaseJson = json; + function prefetchLevelDbIndex(config) { + var indexUrl = resolveLevelsDbIndexUrl(config); + var legacyUrl = resolveLevelsDatabaseUrl(config); + global.__tfrhLevelsDbIndexUrl = indexUrl; + global.__tfrhLevelsDatabaseUrl = legacyUrl; + if (global.__tfrhLevelsDbIndex) return Promise.resolve(); + return fetchLevelsDatabaseJson(indexUrl).then(function (json) { + if (json && json.mode === 'sharded') { + global.__tfrhLevelsDbIndex = json; + return; + } + throw new Error('非分片索引'); }).catch(function (e) { - console.warn('[mstest5] 关卡库预取失败', url, e); + console.warn('[mstest5] 关卡库索引预取失败,启动时将由 LevelDatabase 回退', indexUrl, e); }); } @@ -1339,7 +1595,7 @@ onProgress = onProgress || function () {}; onProgress(0.02); resolveStreamingBase(config); - return prefetchMainSiteLevelDb(config).then(function () { + return prefetchLevelDbIndex(config).then(function () { return shouldUseBundlePackFlow(config); }).then(function (useBundles) { if (!useBundles) {