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

@@ -7,12 +7,8 @@ import { ViewController } from './controller/ViewController';
import { LineGridRenderer } from './gameplay/LineGridRenderer'; import { LineGridRenderer } from './gameplay/LineGridRenderer';
import { UIMain } from './ui/UIMain'; import { UIMain } from './ui/UIMain';
import { loadLevelDatabase, refreshLevelIdBounds, LEVEL_ID_BASE } from './level/LevelRegistry'; 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 { ThemeBackground } from './theme/ThemeBackground';
import { GridSnapHelper } from './level/GridSnapHelper'; import { GridSnapHelper } from './level/GridSnapHelper';
import { GameAudio } from './audio/GameAudio';
import { GameplayDebugBar } from './ui/GameplayDebugBar'; import { GameplayDebugBar } from './ui/GameplayDebugBar';
import { import {
CELL_PIXEL, CAMERA_ORTHO_HALF, DESIGN_WIDTH, DESIGN_HEIGHT, CELL_PIXEL, CAMERA_ORTHO_HALF, DESIGN_WIDTH, DESIGN_HEIGHT,
@@ -55,12 +51,8 @@ export class AppBootstrap extends Component {
console.log('[AppBootstrap] 开始初始化…'); console.log('[AppBootstrap] 开始初始化…');
applyEmbeddedDesignResolution(); applyEmbeddedDesignResolution();
view.on('canvas-resize', this.onCanvasResize, this); view.on('canvas-resize', this.onCanvasResize, this);
await ensureResourcesBundle();
await loadThemeDatabase();
await loadTileDisplayMeta();
await loadLevelDatabase(); await loadLevelDatabase();
refreshLevelIdBounds(); refreshLevelIdBounds();
await GameAudio.preload();
const scene = director.getScene()!; const scene = director.getScene()!;
find('UICanvas', scene)?.destroy(); find('UICanvas', scene)?.destroy();
@@ -108,7 +100,7 @@ export class AppBootstrap extends Component {
// 关卡预制体由 SwitchLevel → createNewLevel 按需加载loader 进关再下 levels_all // 关卡预制体由 SwitchLevel → createNewLevel 按需加载loader 进关再下 levels_all
ctl.markReady(); ctl.markReady();
ctl.onBootstrapReady(); ctl.onBootstrapReady();
console.log('[AppBootstrap] 引擎已就绪SwitchLevel 进关时再加载关卡预制体'); console.log('[AppBootstrap] 引擎已就绪SwitchLevel 进关时再加载资源/关卡库分片/预制体');
} }
private ensureGameRoot(scene: Node, size: { width: number; height: number }, mainCam: Camera): Node { private ensureGameRoot(scene: Node, size: { width: number; height: number }, mainCam: Camera): Node {

View File

@@ -14,6 +14,7 @@ import {
} from './core/Define'; } from './core/Define';
import { EventManager, EventType } from './core/EventManager'; import { EventManager, EventType } from './core/EventManager';
import { ExternalLevelInfo, JsBridge } from './bridge/JsBridge'; import { ExternalLevelInfo, JsBridge } from './bridge/JsBridge';
import { ensureRuntimeAssetsForLevel } from './core/RuntimePack';
import { import {
hasLevel, getMaxLevelId, getMinLevelId, LEVEL_ID_BASE, resolveLevelConfig, hasLevel, getMaxLevelId, getMinLevelId, LEVEL_ID_BASE, resolveLevelConfig,
nextLevelId, prevLevelId, nextLevelId, prevLevelId,
@@ -25,7 +26,7 @@ import {
} from './level/EntitySpawnPlacement'; } from './level/EntitySpawnPlacement';
import { EntityVisualOptions } from './visual/EntityTextureResolver'; import { EntityVisualOptions } from './visual/EntityTextureResolver';
import { getLevelPrefabResourcePath } from './level/LevelPrefabPaths'; import { getLevelPrefabResourcePath } from './level/LevelPrefabPaths';
import { loadLevelPrefab } from './level/LevelPrefabLoader'; import { loadLevelPrefab as loadLevelPrefabImpl } from './level/LevelPrefabLoader';
import { PlayerController } from './controller/PlayerController'; import { PlayerController } from './controller/PlayerController';
import { VehicleController } from './controller/VehicleController'; import { VehicleController } from './controller/VehicleController';
import { PropController } from './controller/PropController'; import { PropController } from './controller/PropController';
@@ -52,6 +53,13 @@ interface GridEntry {
node: Node; 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 合并)。 * 主站唯一入口组件(原 GameManager + GameController 合并)。
* Inspector填写 inputLevel预览 ▶ 后点 SwitchLevel对齐 Unity TestGame2 * Inspector填写 inputLevel预览 ▶ 后点 SwitchLevel对齐 Unity TestGame2
@@ -793,6 +801,13 @@ export class GameController extends Component {
async createNewLevel(levelID: number) { async createNewLevel(levelID: number) {
if (this.creating) return; if (this.creating) return;
this.creating = true; this.creating = true;
try {
await ensureRuntimeAssetsForLevel(levelID);
} catch (e) {
console.error('[GameController] 运行时资源加载失败', e);
this.creating = false;
return;
}
const config = resolveLevelConfig(levelID); const config = resolveLevelConfig(levelID);
if (!config || !this.mainLevelEntrance) { if (!config || !this.mainLevelEntrance) {
this.creating = false; this.creating = false;
@@ -809,7 +824,7 @@ export class GameController extends Component {
const path = getLevelPrefabResourcePath(levelID, config); const path = getLevelPrefabResourcePath(levelID, config);
try { try {
const prefab = await loadLevelPrefab(path); const prefab = await loadLevelPrefabForRuntime(path);
await VisualAssets.preload(this.uiStyle); await VisualAssets.preload(this.uiStyle);
// 在 instantiate 前无法拦截;先禁用 prefab 内 GridSnapHelper 的 showGrid // 在 instantiate 前无法拦截;先禁用 prefab 内 GridSnapHelper 的 showGrid
GridSnapHelper.stripBeforePlayFromPrefab(prefab); GridSnapHelper.stripBeforePlayFromPrefab(prefab);

View File

@@ -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<void>;
__tfrhEnsureLevelDbShard?: (levelId: number) => Promise<void>;
__tfrhEnsureLevelPack?: (levelId: number) => Promise<void>;
}
}
let runtimePrimed = false;
/** 首关 SwitchLevel 前拉齐运行时依赖bootstrap 不再预载) */
export async function ensureRuntimeAssetsForLevel(levelId: number): Promise<void> {
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);
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "2de0b14b-7d3b-4ac4-963c-f7b352db60c9",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -21,19 +21,41 @@ export interface LevelDatabaseFile {
levels: Record<string, LevelConfig>; 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 { declare global {
interface Window { interface Window {
/** 主站 scratch-gui 注入:/unity/levels-database.json */ /** 主站 scratch-gui 注入:/unity/levels-database.json */
__tfrhLevelsDatabaseUrl?: string; __tfrhLevelsDatabaseUrl?: string;
/** loader 预注入的 JSON可选 */ /** loader 预注入的 JSON可选legacy 全量 */
__tfrhLevelsDatabaseJson?: LevelDatabaseFile; __tfrhLevelsDatabaseJson?: LevelDatabaseFile;
/** 分片索引 URL / 预注入 */
__tfrhLevelsDbIndexUrl?: string;
__tfrhLevelsDbIndex?: LevelsDbIndex;
/** loader 按关拉取分片 */
__tfrhEnsureLevelDbShard?: (levelId: number) => Promise<void>;
} }
} }
let fileCache: LevelDatabaseFile | null = null; let fileCache: LevelDatabaseFile | null = null;
let indexCache: LevelsDbIndex | null = null;
let levelsMap: Record<number, LevelConfig> = {}; let levelsMap: Record<number, LevelConfig> = {};
let sortedIds: number[] = []; let sortedIds: number[] = [];
let loadPromise: Promise<void> | null = null; let loadPromise: Promise<void> | null = null;
const loadedShardKeys = new Set<string>();
const shardLoadPromises = new Map<string, Promise<void>>();
function rebuildIndex() { function rebuildIndex() {
sortedIds = Object.keys(levelsMap) sortedIds = Object.keys(levelsMap)
@@ -42,18 +64,62 @@ function rebuildIndex() {
.sort((a, b) => a - b); .sort((a, b) => a - b);
} }
function ingestFile(data: LevelDatabaseFile) { function ingestFile(data: LevelDatabaseFile, merge = false) {
fileCache = data; if (!merge) {
levelsMap = {}; fileCache = data;
levelsMap = {};
} else if (!fileCache) {
fileCache = {
version: data.version || 1,
levels: {},
};
}
for (const [k, cfg] of Object.entries(data.levels ?? {})) { for (const [k, cfg] of Object.entries(data.levels ?? {})) {
const id = parseInt(k, 10); const id = parseInt(k, 10);
if (!Number.isNaN(id)) { if (!Number.isNaN(id)) {
levelsMap[id] = { ...cfg, levelID: id }; levelsMap[id] = { ...cfg, levelID: id };
if (fileCache?.levels) {
fileCache.levels[String(id)] = levelsMap[id];
}
} }
} }
rebuildIndex(); 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 { function resolveRemoteUrl(): string | null {
if (typeof window === 'undefined') return null; if (typeof window === 'undefined') return null;
const url = window.__tfrhLevelsDatabaseUrl; const url = window.__tfrhLevelsDatabaseUrl;
@@ -61,6 +127,10 @@ function resolveRemoteUrl(): string | null {
} }
function validateIngested(): void { function validateIngested(): void {
if (indexCache) {
validateIndex(indexCache);
return;
}
const total = sortedIds.length; const total = sortedIds.length;
const minId = sortedIds[0] ?? 0; const minId = sortedIds[0] ?? 0;
if (total < 100 || minId < LEVEL_ID_BASE) { if (total < 100 || minId < LEVEL_ID_BASE) {
@@ -90,6 +160,139 @@ function fetchCandidates(): string[] {
return [...new Set(out)]; 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> { function loadFromRemote(url: string): Promise<void> {
const abs = /^https?:\/\//i.test(url) const abs = /^https?:\/\//i.test(url)
? url ? url
@@ -161,12 +364,19 @@ async function waitForInjection(maxMs = 2500): Promise<LevelDatabaseFile | null>
return null; return null;
} }
/** 异步加载AppBootstrap 启动时调用) */ /** 异步加载AppBootstrap 启动时调用;分片模式仅拉 index */
export function loadLevelDatabase(): Promise<void> { export function loadLevelDatabase(): Promise<void> {
if (fileCache) return Promise.resolve(); if (fileCache || indexCache) return Promise.resolve();
if (loadPromise) return loadPromise; if (loadPromise) return loadPromise;
loadPromise = (async () => { loadPromise = (async () => {
try { try {
if (typeof window !== 'undefined' && window.__tfrhLevelsDbIndex) {
ingestIndex(window.__tfrhLevelsDbIndex);
validateIngested();
console.log(`[LevelDatabase] 已注入索引 ${indexCache!.total}`);
return;
}
if (typeof window !== 'undefined' && window.__tfrhLevelsDatabaseJson) { if (typeof window !== 'undefined' && window.__tfrhLevelsDatabaseJson) {
ingestFile(window.__tfrhLevelsDatabaseJson); ingestFile(window.__tfrhLevelsDatabaseJson);
validateIngested(); validateIngested();
@@ -196,6 +406,13 @@ export function loadLevelDatabase(): Promise<void> {
} }
} }
try {
await loadIndexFromNetwork();
return;
} catch (e) {
console.warn('[LevelDatabase] 分片索引不可用,回退全量库', e);
}
await loadFromNetwork(); await loadFromNetwork();
} catch (e) { } catch (e) {
loadPromise = null; loadPromise = null;
@@ -206,7 +423,11 @@ export function loadLevelDatabase(): Promise<void> {
} }
export function isLevelDatabaseReady(): boolean { 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 导出的关卡库中 */ /** 是否在 Cocos 导出的关卡库中 */
export function hasLevel(levelID: number): boolean { 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[] { export function getLevelIds(): number[] {
@@ -227,19 +452,32 @@ export function getLevelCount(): number {
return sortedIds.length; return sortedIds.length;
} }
export const MIN_LEVEL_ID = (): number => sortedIds[0] ?? LEVEL_ID_BASE; export const MIN_LEVEL_ID = (): number => indexCache?.min ?? sortedIds[0] ?? LEVEL_ID_BASE;
export const MAX_LEVEL_ID = (): number => sortedIds[sortedIds.length - 1] ?? LEVEL_ID_BASE; export const MAX_LEVEL_ID = (): number => indexCache?.max ?? sortedIds[sortedIds.length - 1] ?? LEVEL_ID_BASE;
export function nextLevelId(cur: number): number { export function nextLevelId(cur: number): number {
const i = sortedIds.indexOf(cur); if (sortedIds.length) {
if (i < 0) return sortedIds[0] ?? cur; const i = sortedIds.indexOf(cur);
return sortedIds[(i + 1) % sortedIds.length]; 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 { export function prevLevelId(cur: number): number {
const i = sortedIds.indexOf(cur); if (sortedIds.length) {
if (i < 0) return sortedIds[0] ?? cur; const i = sortedIds.indexOf(cur);
return sortedIds[(i - 1 + sortedIds.length) % sortedIds.length]; 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;
} }
// --- 增改(运行时 / 编辑器脚本) --- // --- 增改(运行时 / 编辑器脚本) ---

View File

@@ -12,13 +12,16 @@ const zlib = require('zlib');
const { execSync } = require('child_process'); const { execSync } = require('child_process');
const { const {
patchPreloadSettings, patchPreloadSettings,
patchSplashSettings,
printPackageReport, printPackageReport,
minifyLevelsDatabase, minifyLevelsDatabase,
brotliCompressFile, brotliCompressFile,
brotliCompressWebglBundles,
formatBytes, formatBytes,
} = require('./package-optimize'); } = require('./package-optimize');
const { listRuntimeFiles, assertRuntimePack } = require('./runtime-pack'); const { listRuntimeFiles, assertRuntimePack } = require('./runtime-pack');
const { splitLevelBundles } = require('./split-level-bundles'); const { splitLevelBundles } = require('./split-level-bundles');
const { splitLevelsDatabase } = require('./split-levels-database');
const buildDir = path.resolve(process.argv[2]); const buildDir = path.resolve(process.argv[2]);
const outDir = path.resolve(process.argv[3]); const outDir = path.resolve(process.argv[3]);
@@ -83,9 +86,11 @@ function attachLevelsDatabase(outDir) {
console.warn('>>> 警告: 未找到 levels-database.json'); console.warn('>>> 警告: 未找到 levels-database.json');
return; return;
} }
splitLevelsDatabase(levelsDbSrc, outDir);
const levelsDbDst = path.join(outDir, 'levels-database.json'); const levelsDbDst = path.join(outDir, 'levels-database.json');
const { before, after } = minifyLevelsDatabase(levelsDbSrc, levelsDbDst); 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')); const { raw, br } = brotliCompressFile(levelsDbDst, path.join(outDir, 'levels-database.json.br'));
console.log(`>>> levels-database.json.br: ${formatBytes(raw)}${formatBytes(br)}`); console.log(`>>> levels-database.json.br: ${formatBytes(raw)}${formatBytes(br)}`);
} }
@@ -139,6 +144,56 @@ function patchMainIndexForSplitLoad(mainIndexPath) {
} }
let s = fs.readFileSync(mainIndexPath, 'utf8'); let s = fs.readFileSync(mainIndexPath, 'utf8');
const rules = [ 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<a)throw new Error',
neu: 'function w(){if("undefined"!=typeof window&&window.__tfrhLevelsDbIndex){var e=window.__tfrhLevelsDbIndex;if(e.total<100||e.min<a)throw new Error("关卡库索引过旧 ("+e.total+" 关)");return}var e=c.length,n=null!=(t=c[0])?t:0;if(e<100||n<a)throw new Error',
},
{
desc: 'LevelRegistry: min/max 读 index',
old: 'function f(){return c()?d():t}function E(){return c()?I():t}',
neu: 'function f(){var e="undefined"!=typeof window&&window.__tfrhLevelsDbIndex;return e?e.min:c()?d():t}function E(){var e="undefined"!=typeof window&&window.__tfrhLevelsDbIndex;return e?e.max:c()?I():t}',
},
{
desc: 'LevelRegistry: hasLevel 读 index 范围',
old: 'hasLevel:function(e){return v(e)}',
neu: 'hasLevel:function(e){if(v(e))return!0;var n="undefined"!=typeof window&&window.__tfrhLevelsDbIndex;return!!n&&e>=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 贴图', desc: 'AppBootstrap: 首关前不 preload UI 贴图',
old: 'yield h.preload(),yield O.preload()', 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 = j.assets || {};
j.assets.server = ''; j.assets.server = '';
patchPreloadSettings(j, { preloadResources: false, preloadLevelPrefabs: false }); patchPreloadSettings(j, { preloadResources: false, preloadLevelPrefabs: false });
patchSplashSettings(j, { stripSplashAssets: true });
return JSON.stringify(j); return JSON.stringify(j);
}); });
zipDir(scenesStage, path.join(webglDir, BUNDLE.scenesAll)); 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—— // —— 3a. assets_all核心资源不含 level-prefabs——
// 默认分包MERGE_LEVELS=1 时合并 level-prefabs 进 assets_all不推荐 // 默认分包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); const assetsCoreZip = path.join(webglDir, BUNDLE.assetsAll);
zipDir(assetsCoreStage, assetsCoreZip); zipDir(assetsCoreStage, assetsCoreZip);
console.log(`>>> assets_core (assets_all): ${formatBytes(fs.statSync(assetsCoreZip).size)}`); 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 —— // —— 3b. 每关独立 bundle + levels-manifest.json ——
const levelPrefabsSrc = path.join(buildDir, 'assets', 'level-prefabs'); const levelPrefabsSrc = path.join(buildDir, 'assets', 'level-prefabs');
@@ -312,6 +373,8 @@ if (!MERGE_LEVELS) {
patchCatalogRemoveLevelsBundle(path.join(aaDir, 'catalog.json')); patchCatalogRemoveLevelsBundle(path.join(aaDir, 'catalog.json'));
const manifestPath = path.join(aaDir, 'levels-manifest.json'); const manifestPath = path.join(aaDir, 'levels-manifest.json');
levelPackStats = splitLevelBundles(levelPrefabsSrc, webglDir, manifestPath, tmp); levelPackStats = splitLevelBundles(levelPrefabsSrc, webglDir, manifestPath, tmp);
const brStats = brotliCompressWebglBundles(webglDir);
console.log(`>>> bundle Brotli: ${brStats.count} 个, 节省 ${formatBytes(brStats.saved)}`);
} }
attachLevelsDatabase(outDir); attachLevelsDatabase(outDir);
@@ -343,7 +406,14 @@ rmrf(standaloneDir);
mkdirp(standaloneDir); mkdirp(standaloneDir);
copyFile(path.join(unityRef, 'index.html'), path.join(standaloneDir, 'index.html')); copyFile(path.join(unityRef, 'index.html'), path.join(standaloneDir, 'index.html'));
copyDir(path.join(unityRef, 'TemplateData'), path.join(standaloneDir, 'TemplateData')); 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 src = path.join(outDir, item);
const dst = path.join(standaloneDir, item); const dst = path.join(standaloneDir, item);
if (!fs.existsSync(src)) continue; if (!fs.existsSync(src)) continue;
@@ -372,6 +442,6 @@ for (const p of requiredBundles) {
const preloadNote = MERGE_LEVELS const preloadNote = MERGE_LEVELS
? 'scenes ∥ assets_all 合并包 (MERGE_LEVELS=1)' ? '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同一目录'); console.log('\n完成。运行时包 → 本地 import-to-unity.sh / OSS unitycdndir同一目录');
printPackageReport(outDir, { preloadNote }); printPackageReport(outDir, { preloadNote });

View File

@@ -25,6 +25,7 @@ const {
minifyLevelsDatabase, minifyLevelsDatabase,
brotliCompressFile, brotliCompressFile,
patchPreloadSettings, patchPreloadSettings,
patchSplashSettings,
printPackageReport, printPackageReport,
formatBytes, formatBytes,
} = require('./package-optimize'); } = require('./package-optimize');
@@ -201,6 +202,7 @@ function patchSettingsForFrontend(settingsFile) {
preloadResources: opts.preloadResources, preloadResources: opts.preloadResources,
preloadLevelPrefabs: opts.preloadLevelPrefabs, preloadLevelPrefabs: opts.preloadLevelPrefabs,
}); });
patchSplashSettings(j, { stripSplashAssets: true });
if (j.rendering) { if (j.rendering) {
j.rendering.effectSettingsPath = `${UNITY_BASE}src/effect.bin`; j.rendering.effectSettingsPath = `${UNITY_BASE}src/effect.bin`;
} }

View File

@@ -32,10 +32,11 @@ usage() {
echo " --skip-manifest 不生成 deploy/ 清单" >&2 echo " --skip-manifest 不生成 deploy/ 清单" >&2
echo " --zip 额外生成 build/mstest5-runtime.zip" >&2 echo " --zip 额外生成 build/mstest5-runtime.zip" >&2
echo "" >&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 " MERGE_LEVELS=1 合并 level-prefabs 进 assets_all不推荐" >&2
echo "运行时包结构(本地 static/unity = OSS unitycdndir:" >&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
echo "步骤 2: scratch-gui/static/unity/import-to-unity.sh" >&2 echo "步骤 2: scratch-gui/static/unity/import-to-unity.sh" >&2
} }

View File

@@ -3,8 +3,10 @@
* 供 package-for-project.js / package-for-cdn.js 共用。 * 供 package-for-project.js / package-for-cdn.js 共用。
*/ */
const fs = require('fs'); const fs = require('fs');
const os = require('os');
const path = require('path'); const path = require('path');
const zlib = require('zlib'); const zlib = require('zlib');
const { execSync } = require('child_process');
function formatBytes(n) { function formatBytes(n) {
if (n >= 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`; 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 }; 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默认只预加载 mainresources / level-prefabs 按需加载。 * 调整预加载 bundle默认只预加载 mainresources / level-prefabs 按需加载。
* @param {object} opts * @param {object} opts
@@ -68,6 +87,50 @@ function patchPreloadSettings(settingsObj, opts = {}) {
return settingsObj; 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 storeloader 不支持 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 = {}) { function printPackageReport(outDir, opts = {}) {
const lines = ['\n>>> 包体积报告:']; const lines = ['\n>>> 包体积报告:'];
const entries = [ const entries = [
@@ -101,6 +164,9 @@ module.exports = {
fileSize, fileSize,
minifyLevelsDatabase, minifyLevelsDatabase,
brotliCompressFile, brotliCompressFile,
brotliCompressWebglBundles,
patchPreloadSettings, patchPreloadSettings,
patchSplashSettings,
patchSplashInZipBundle,
printPackageReport, printPackageReport,
}; };

View File

@@ -6,13 +6,21 @@
* StreamingAssets/ * StreamingAssets/
* levels-database.json * levels-database.json
* levels-database.json.br * levels-database.json.br
* levels-db-index.json
* levels-db-index.json.br
* StreamingAssets/aa/levels-db/
* *
* 不含 index.html / TemplateData仅 standalone-player 独立调试页使用) * 不含 index.html / TemplateData仅 standalone-player 独立调试页使用)
*/ */
const fs = require('fs'); const fs = require('fs');
const path = require('path'); 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']; const RUNTIME_DIRS = ['Build', 'StreamingAssets'];
function walkFiles(root, bucket, prefix = '') { function walkFiles(root, bucket, prefix = '') {
@@ -62,6 +70,9 @@ function assertRuntimePack(packDir, opts) {
if (!fs.existsSync(catalog)) { if (!fs.existsSync(catalog)) {
throw new Error(`缺少运行时包: ${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'))) { if (!fs.existsSync(path.join(packDir, 'levels-database.json'))) {
throw new Error(`缺少运行时包: ${path.join(packDir, 'levels-database.json')}`); throw new Error(`缺少运行时包: ${path.join(packDir, 'levels-database.json')}`);
} }

View File

@@ -0,0 +1,94 @@
#!/usr/bin/env node
/**
* 将 levels-database.json 拆为 index + 分片,首屏只下 index~几 KB
*
* 输出:
* <outDir>/levels-db-index.json (+ .br)
* <outDir>/StreamingAssets/aa/levels-db/<min>-<max>.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 <levels-database.json> <outDir>');
process.exit(1);
}
splitLevelsDatabase(src, out);
}

View File

@@ -10,6 +10,11 @@
var streamingBase = ''; var streamingBase = '';
var bundleManifest = null; var bundleManifest = null;
var levelsManifest = 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 levelPackPromises = new Map();
var loadedLevelIds = new Set(); var loadedLevelIds = new Set();
var levelPrefabsBundlePromise = null; var levelPrefabsBundlePromise = null;
@@ -54,9 +59,8 @@
return; return;
} }
var existing = am.getBundle('level-prefabs'); var existing = am.getBundle('level-prefabs');
if (existing) { if (existing && typeof am.removeBundle === 'function') {
if (onComplete) onComplete(null, existing); try { am.removeBundle(existing); } catch (e) { /* ignore */ }
return existing;
} }
if (levelPrefabsBundlePromise) { if (levelPrefabsBundlePromise) {
levelPrefabsBundlePromise.then(function (bundle) { levelPrefabsBundlePromise.then(function (bundle) {
@@ -131,6 +135,40 @@
} }
} }
/** Cocos bundle 常请求 import/NN/uuid.jsonzip 内路径带 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) { function lookupFile(pathname) {
var keys = [pathname, pathname.replace(/^\.?\//, '')]; var keys = [pathname, pathname.replace(/^\.?\//, '')];
var parts = pathname.split('/'); var parts = pathname.split('/');
@@ -141,6 +179,8 @@
for (var i = 0; i < keys.length; i++) { for (var i = 0; i < keys.length; i++) {
var k = keys[i]; var k = keys[i];
if (files.has(k)) return files.get(k); 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/'); var idx = k.indexOf('StreamingAssets/aa/WebGL/');
if (idx >= 0 && files.has(k.slice(idx + 'StreamingAssets/aa/WebGL/'.length))) { if (idx >= 0 && files.has(k.slice(idx + 'StreamingAssets/aa/WebGL/'.length))) {
return files.get(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); 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; return null;
} }
@@ -221,10 +263,20 @@
var u = new URL(String(raw), global.location.href); var u = new URL(String(raw), global.location.href);
var p = u.pathname; var p = u.pathname;
if (files.size > 0) { 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); var rel = relPathFromUrlPath(p);
if (rel) { if (rel) {
var virt = virtualUrlForBundleFile(rel); var virt = virtualUrlForBundleFile(rel);
if (virt) return virt; if (virt) return virt;
importKey = resolveImportFileKey(rel);
if (importKey) {
importVirt = virtualUrlForBundleFile(importKey);
if (importVirt) return importVirt;
}
} }
} }
if (p.indexOf(getRootPathname()) === 0) return u.href; if (p.indexOf(getRootPathname()) === 0) return u.href;
@@ -366,6 +418,12 @@
proto.__tfrhLevelPrefabScript = true; 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() { function installFetchShim() {
global.fetch = function (input, init) { global.fetch = function (input, init) {
input = rewriteEmbeddedUrl(input); input = rewriteEmbeddedUrl(input);
@@ -378,6 +436,19 @@
headers: { 'Content-Type': mimeForPath(p) }, 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)) { if (needsLevelPrefabs(p)) {
return ensureLevelPackForPath(p).then(function () { return ensureLevelPackForPath(p).then(function () {
var hit2 = lookupFile(p); var hit2 = lookupFile(p);
@@ -407,6 +478,18 @@
var origSend = xhr.send; var origSend = xhr.send;
xhr.send = function () { xhr.send = function () {
var hit = lookupFile(toPath(reqUrl)); 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)) { if (!hit && needsLevelPrefabs(reqUrl)) {
ensureLevelPackForPath(reqUrl).then(function () { ensureLevelPackForPath(reqUrl).then(function () {
hit = lookupFile(toPath(reqUrl)); hit = lookupFile(toPath(reqUrl));
@@ -510,32 +593,36 @@
if (onProgress) pending.then(function () { onProgress(1); }).catch(function () {}); if (onProgress) pending.then(function () { onProgress(1); }).catch(function () {});
return pending; return pending;
} }
if (!levelsManifest || !levelsManifest.levels || !levelsManifest.levels[levelId]) { var promise = ensureAssetsCoreLoaded().then(function () {
return Promise.reject(new Error('关卡包未在 manifest 中: Level' + levelId)); return ensureLevelsManifestLoaded();
}
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();
}).then(function () { }).then(function () {
dispatchLevelsBundleProgress(1); if (!levelsManifest || !levelsManifest.levels || !levelsManifest.levels[levelId]) {
if (onProgress) onProgress(1); 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) { }).catch(function (e) {
levelPackPromises.delete(levelId); levelPackPromises.delete(levelId);
throw e; throw e;
@@ -721,6 +808,7 @@
var raw = String(path || '').trim(); var raw = String(path || '').trim();
var m = /Level(\d+)/.exec(raw); var m = /Level(\d+)/.exec(raw);
var lid = m ? parseInt(m[1], 10) : NaN; 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)) var prep = (typeof global.__tfrhEnsureLevelPack === 'function' && !Number.isNaN(lid))
? global.__tfrhEnsureLevelPack(lid) ? global.__tfrhEnsureLevelPack(lid)
: Promise.resolve(); : Promise.resolve();
@@ -728,17 +816,35 @@
return prep.then(function () { return prep.then(function () {
return invalidateLevelPrefabsBundle(); return invalidateLevelPrefabsBundle();
}).then(function () { }).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) { return new Promise(function (resolve, reject) {
cc.assetManager.loadBundle('level-prefabs', function (err, bundle) { function finish(err, asset) {
if (err || !bundle) { if (!err && asset) resolve(asset);
reject(err || new Error('level-prefabs bundle unavailable')); else reject(err || new Error('missing prefab: ' + loadPath));
return; }
} function tryBundleLoad() {
bundle.load(raw, Prefab, function (e, asset) { cc.assetManager.loadBundle('level-prefabs', function (err, bundle) {
if (e || !asset) reject(e || new Error('missing prefab: ' + raw)); if (err || !bundle) {
else resolve(asset); 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; 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:brfetch 已解压成 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) { function fetchBinary(url, onProgress) {
return origFetch(url).then(function (res) { return fetchBinaryWithProgress(url, onProgress, 0, 1);
if (!res.ok) throw new Error('HTTP ' + res.status + ' for ' + url); }
if (onProgress) onProgress(0.3);
return res.arrayBuffer(); function decodeBrotliToText(ab) {
}).then(function (ab) { if (typeof DecompressionStream !== 'undefined') {
if (onProgress) onProgress(0.6); return new Response(ab).pipeThrough(new DecompressionStream('brotli'))
return new Uint8Array(ab); .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) { function fetchBinaryWithProgress(url, onProgress, weightStart, weightEnd) {
weightStart = weightStart == null ? 0 : weightStart; weightStart = weightStart == null ? 0 : weightStart;
weightEnd = weightEnd == null ? 1 : weightEnd; 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.ok) throw new Error('HTTP ' + res.status + ' for ' + url);
if (!res.body || !res.body.getReader) { if (!res.body || !res.body.getReader) {
return res.arrayBuffer().then(function (ab) { return res.arrayBuffer().then(function (ab) {
if (onProgress) onProgress(weightEnd); var bytes = new Uint8Array(ab);
return new Uint8Array(ab); return normalizeBinaryPayload(bytes, isBrotli).then(function (out) {
if (onProgress) onProgress(weightEnd);
return out;
});
}); });
} }
var reader = res.body.getReader(); var reader = res.body.getReader();
@@ -1089,13 +1252,19 @@
function pump() { function pump() {
return reader.read().then(function (result) { return reader.read().then(function (result) {
if (result.done) { if (result.done) {
if (onProgress) onProgress(weightEnd);
var out = new Uint8Array(received); var out = new Uint8Array(received);
var off = 0; var off = 0;
for (var i = 0; i < chunks.length; i++) { for (var i = 0; i < chunks.length; i++) {
out.set(chunks[i], off); out.set(chunks[i], off);
off += chunks[i].length; 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; return out;
} }
chunks.push(result.value); chunks.push(result.value);
@@ -1108,11 +1277,45 @@
}); });
} }
return pump(); 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) { function loadStartupBundles(manifest, onProgress) {
onProgress(0.05); onProgress(0.05);
/* internal/main 等引擎启动即需assets_all 须与 scenes 并行首屏加载 */
var scenesP = fetchBinaryWithProgress(manifest.scenesUrl, onProgress, 0.05, 0.42); var scenesP = fetchBinaryWithProgress(manifest.scenesUrl, onProgress, 0.05, 0.42);
var assetsP = fetchBinaryWithProgress(manifest.assetsUrl, onProgress, 0.42, 0.58); var assetsP = fetchBinaryWithProgress(manifest.assetsUrl, onProgress, 0.42, 0.58);
return Promise.all([scenesP, assetsP]).then(function (bufs) { return Promise.all([scenesP, assetsP]).then(function (bufs) {
@@ -1120,6 +1323,7 @@
assertCocosRuntimeLoaded(); assertCocosRuntimeLoaded();
mergeZipIntoFiles(unzip(bufs[1])); mergeZipIntoFiles(unzip(bufs[1]));
patchSettingsScriptPackages(); patchSettingsScriptPackages();
assetsCoreLoaded = true;
onProgress(0.6); 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) { function loadBundleManifest(config) {
var base = resolveStreamingBase(config); var base = resolveStreamingBase(config);
var catalogUrl = joinUrl(base, 'aa/catalog.json'); var catalogUrl = joinUrl(base, 'aa/catalog.json');
@@ -1198,16 +1454,13 @@
return res.json(); return res.json();
}).then(function (catalog) { }).then(function (catalog) {
var bundles = parseCatalogBundles(catalog); var bundles = parseCatalogBundles(catalog);
return loadLevelsManifest(base).then(function (lm) { var manifest = {
levelsManifest = lm; scenesUrl: joinUrl(base, 'aa/WebGL/' + bundles.scenes),
var manifest = { assetsUrl: joinUrl(base, 'aa/WebGL/' + bundles.assets),
scenesUrl: joinUrl(base, 'aa/WebGL/' + bundles.scenes), bundleNames: bundles,
assetsUrl: joinUrl(base, 'aa/WebGL/' + bundles.assets), };
bundleNames: bundles, bundleManifest = manifest;
}; return manifest;
bundleManifest = manifest;
return manifest;
});
}); });
} }
@@ -1268,36 +1521,33 @@
packageRoot = getPackageRoot(); packageRoot = getPackageRoot();
installEmbeddedPathFix(); installEmbeddedPathFix();
function resolveLevelsDatabaseUrl(config) { function resolveLevelsDbBase(config) {
if (global.__tfrhLevelsDatabaseUrl) return global.__tfrhLevelsDatabaseUrl;
var sa = config && config.streamingAssetsUrl; var sa = config && config.streamingAssetsUrl;
if (sa && /^https?:\/\//i.test(sa)) { if (sa && /^https?:\/\//i.test(sa)) {
var cdnRoot = String(sa).replace(/\/StreamingAssets\/?$/i, ''); return String(sa).replace(/\/StreamingAssets\/?$/i, '');
return joinUrl(cdnRoot, 'levels-database.json');
} }
return joinUrl(packageRoot || getPackageRoot(), 'levels-database.json'); return packageRoot || getPackageRoot();
} }
function decodeBrotliToText(ab) { function resolveLevelsDatabaseUrl(config) {
if (typeof DecompressionStream !== 'undefined') { if (global.__tfrhLevelsDatabaseUrl) return global.__tfrhLevelsDatabaseUrl;
return new Response(ab).pipeThrough(new DecompressionStream('brotli')) return joinUrl(resolveLevelsDbBase(config), 'levels-database.json');
.arrayBuffer() }
.then(function (buf) { return new TextDecoder().decode(buf); });
} function resolveLevelsDbIndexUrl(config) {
return Promise.reject(new Error('浏览器不支持 Brotli 解压')); if (global.__tfrhLevelsDbIndexUrl) return global.__tfrhLevelsDbIndexUrl;
return joinUrl(resolveLevelsDbBase(config), 'levels-db-index.json');
} }
function fetchLevelsDatabaseJson(url) { function fetchLevelsDatabaseJson(url) {
var brUrl = url.replace(/\.json(\?.*)?$/i, '.json.br$1'); var brUrl = url.replace(/\.json(\?.*)?$/i, '.json.br$1');
return origFetch(brUrl).then(function (res) { return origFetch(brUrl).then(function (res) {
if (!res.ok) throw new Error('HTTP ' + res.status); if (!res.ok) throw new Error('HTTP ' + res.status);
var enc = (res.headers.get('content-encoding') || '').toLowerCase(); return res.arrayBuffer().then(function (ab) {
if (enc === 'br' || /\.br(\?|$)/i.test(brUrl)) { return normalizeJsonPayload(ab, true).then(function (text) {
return res.arrayBuffer().then(decodeBrotliToText).then(function (text) {
return JSON.parse(text); return JSON.parse(text);
}); });
} });
return res.json();
}).catch(function () { }).catch(function () {
return origFetch(url).then(function (res) { return origFetch(url).then(function (res) {
if (!res.ok) throw new Error('HTTP ' + res.status); if (!res.ok) throw new Error('HTTP ' + res.status);
@@ -1306,14 +1556,20 @@
}); });
} }
function prefetchMainSiteLevelDb(config) { function prefetchLevelDbIndex(config) {
var url = resolveLevelsDatabaseUrl(config); var indexUrl = resolveLevelsDbIndexUrl(config);
global.__tfrhLevelsDatabaseUrl = url; var legacyUrl = resolveLevelsDatabaseUrl(config);
if (!url || global.__tfrhLevelsDatabaseJson) return Promise.resolve(); global.__tfrhLevelsDbIndexUrl = indexUrl;
return fetchLevelsDatabaseJson(url).then(function (json) { global.__tfrhLevelsDatabaseUrl = legacyUrl;
global.__tfrhLevelsDatabaseJson = json; 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) { }).catch(function (e) {
console.warn('[mstest5] 关卡库预取失败', url, e); console.warn('[mstest5] 关卡库索引预取失败,启动时将由 LevelDatabase 回退', indexUrl, e);
}); });
} }
@@ -1339,7 +1595,7 @@
onProgress = onProgress || function () {}; onProgress = onProgress || function () {};
onProgress(0.02); onProgress(0.02);
resolveStreamingBase(config); resolveStreamingBase(config);
return prefetchMainSiteLevelDb(config).then(function () { return prefetchLevelDbIndex(config).then(function () {
return shouldUseBundlePackFlow(config); return shouldUseBundlePackFlow(config);
}).then(function (useBundles) { }).then(function (useBundles) {
if (!useBundles) { if (!useBundles) {