Complete Cocos Creator port with level bundles, themes, and tooling.

Adds level prefabs, theme assets, audio, extensions, and deployment scripts for the Unity WebGL migration.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-16 15:30:58 +08:00
parent cba5105908
commit d393302388
6248 changed files with 17322729 additions and 11036 deletions

377
tools/package-for-cdn.js Normal file
View File

@@ -0,0 +1,377 @@
#!/usr/bin/env node
/**
* 将 Cocos Web 构建产物整理为与 Unity 参考包完全一致的目录/文件名:
* /Users/liuyufei/tfrh/竞赛/mstest5
*
* node tools/package-for-cdn.js <cocosBuildDir> <outDir>
*/
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const zlib = require('zlib');
const { execSync } = require('child_process');
const {
patchPreloadSettings,
printPackageReport,
minifyLevelsDatabase,
brotliCompressFile,
formatBytes,
} = require('./package-optimize');
const { listRuntimeFiles, assertRuntimePack } = require('./runtime-pack');
const { splitLevelBundles } = require('./split-level-bundles');
const buildDir = path.resolve(process.argv[2]);
const outDir = path.resolve(process.argv[3]);
const unityRef = path.resolve(process.argv[4] || '/Users/liuyufei/tfrh/竞赛/mstest5');
const PRODUCT = 'mstest5';
function parseCatalogBundles(catalogPath) {
const catalog = JSON.parse(fs.readFileSync(catalogPath, 'utf8'));
const names = [...new Set(
(catalog.m_InternalIds || [])
.filter((id) => String(id).includes('.bundle'))
.map((id) => String(id).split('/').pop()),
)];
const scenesAll = names.find((n) => n.includes('scenes_all'));
const assetsAll = names.find((n) => n.includes('assets_all'));
const levelsAll = names.find((n) => n.includes('levels_all'));
const shaders = names.find((n) => n.includes('unitybuiltinshaders'));
if (!scenesAll || !assetsAll) {
throw new Error(`catalog.json missing scenes/assets bundle: ${names.join(', ')}`);
}
return { shaders, scenesAll, assetsAll, levelsAll, all: names };
}
/** 复制 catalog 后剥离历史 levels_all 条目,再写入本次 bundle */
function patchCatalogAddLevelsBundle(catalogPath, bundleFileName) {
const catalog = JSON.parse(fs.readFileSync(catalogPath, 'utf8'));
const entry = `{UnityEngine.AddressableAssets.Addressables.RuntimePath}/WebGL/${bundleFileName}`;
const ids = (catalog.m_InternalIds || []).filter((id) => !String(id).includes('levels_all'));
ids.push(entry);
catalog.m_InternalIds = ids;
fs.writeFileSync(catalogPath, JSON.stringify(catalog), 'utf8');
}
/** 从 catalog 移除 levels_all 条目(仅 MERGE_LEVELS=1 合并包时使用) */
function patchCatalogRemoveLevelsBundle(catalogPath) {
const catalog = JSON.parse(fs.readFileSync(catalogPath, 'utf8'));
catalog.m_InternalIds = (catalog.m_InternalIds || []).filter((id) => !String(id).includes('levels_all'));
fs.writeFileSync(catalogPath, JSON.stringify(catalog), 'utf8');
}
function copyAssetsDirExcept(srcAssetsDir, dstAssetsDir, skipNames) {
const skip = new Set(skipNames || []);
if (!fs.existsSync(srcAssetsDir)) return;
mkdirp(dstAssetsDir);
for (const ent of fs.readdirSync(srcAssetsDir, { withFileTypes: true })) {
if (skip.has(ent.name)) continue;
const sp = path.join(srcAssetsDir, ent.name);
const dp = path.join(dstAssetsDir, ent.name);
if (ent.isDirectory()) copyDir(sp, dp);
else copyFile(sp, dp);
}
}
function hashFileMd5(filePath) {
return crypto.createHash('md5').update(fs.readFileSync(filePath)).digest('hex');
}
function attachLevelsDatabase(outDir) {
const levelsDbSrc = path.join(__dirname, '../assets/level-data/levels-database.json');
if (!fs.existsSync(levelsDbSrc)) {
console.warn('>>> 警告: 未找到 levels-database.json');
return;
}
const levelsDbDst = path.join(outDir, 'levels-database.json');
const { before, after } = minifyLevelsDatabase(levelsDbSrc, levelsDbDst);
console.log(`>>> levels-database.json 压缩: ${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)}`);
}
if (!buildDir || !outDir) {
console.error('Usage: package-for-cdn.js <cocosBuildDir> <outDir> [unityRefDir]');
process.exit(1);
}
function rmrf(p) {
if (fs.existsSync(p)) fs.rmSync(p, { recursive: true, force: true });
}
function mkdirp(p) { fs.mkdirSync(p, { recursive: true }); }
function copyFile(s, d) { mkdirp(path.dirname(d)); fs.copyFileSync(s, d); }
function copyDir(s, d) {
if (!fs.existsSync(s)) return;
mkdirp(d);
for (const ent of fs.readdirSync(s, { withFileTypes: true })) {
const sp = path.join(s, ent.name);
const dp = path.join(d, ent.name);
if (ent.isDirectory()) copyDir(sp, dp);
else copyFile(sp, dp);
}
}
function zipDir(srcDir, outFile) {
const absOut = path.resolve(outFile);
mkdirp(path.dirname(absOut));
if (fs.existsSync(absOut)) fs.unlinkSync(absOut);
execSync(`cd "${path.resolve(srcDir)}" && zip -0 -q -r "${absOut}" .`, { stdio: 'pipe' });
}
function patchText(file, fn) {
const t = fs.readFileSync(file, 'utf8');
fs.writeFileSync(file, fn(t), 'utf8');
}
function brotliStub(dst, bytes) {
const buf = zlib.brotliCompressSync(bytes || Buffer.from([0]));
fs.writeFileSync(dst, buf);
}
/**
* 分包模式下:须先 loadLevelPrefab 再 VisualAssets.preload否则 resources 占满后 level-prefabs 加载失败。
* 对 Cocos 构建产物 assets/main/index.js 做稳定字符串 patch无需立即重编工程
*/
function patchMainIndexForSplitLoad(mainIndexPath) {
if (!fs.existsSync(mainIndexPath)) {
console.warn('>>> 跳过 main/index 分包 patch无 assets/main/index.js');
return;
}
let s = fs.readFileSync(mainIndexPath, 'utf8');
const rules = [
{
desc: 'AppBootstrap: 首关前不 preload UI 贴图',
old: 'yield h.preload(),yield O.preload()',
neu: 'yield O.preload()',
},
{
desc: 'AppBootstrap: 进关再 loadLevel不在 bootstrap 拉首关)',
old: 'yield F.createNewLevel(F.initialLevelID),S.purgeScene(c),F.scheduleOnce((function(){return S.purgeScene(c)}),0),F.scheduleOnce((function(){return S.purgeScene(c)}),.15),F.markReady()',
neu: 'F.markReady()',
},
{
desc: 'GameController: 使用 loader 全局 loadLevelPrefab hook',
old: 'var l,r=yield z(i);yield H.preload(this.uiStyle)',
neu: 'var l,r=yield(globalThis.__tfrhLoadLevelPrefab||z)(i);yield H.preload(this.uiStyle)',
},
{
desc: 'LevelPrefabLoader: 去掉裸 LevelN 路径',
old: 'return[].concat(new Set([r,n,"level-prefabs/"+n]))',
neu: 'return[].concat(new Set([r,"level-prefabs/"+n]))',
},
{
desc: 'LevelPrefabLoader: 进关前 ensureLevelPack',
old: 'function*(e){yield a();var r=v(e);',
neu: 'function*(e){yield a();var _h=globalThis.__tfrhEnsureLevelPack,_m=/Level(\\d+)\\s*$/.exec(e.trim()),_lid=_m?parseInt(_m[1],10):void 0;if(_lid!==void 0&&typeof _h==="function")yield _h(_lid);var r=v(e);',
},
{
desc: 'LevelPrefabLoader: ensureLevelPack 后清空 bundle 缓存 (新构建 w/L/s/f)',
old: 'yield u(),yield L(e);var r=v(e),n=null;try{var l=yield c();return yield d((function(e){return p(l,e)}),r)}catch(e){n=e,console.warn("[LevelPrefabLoader] level-prefabs bundle 加载失败",e)}var t=yield y(e);if(null!=t&&t.isValid)return t;throw console.error(\'[LevelPrefabLoader] 关卡预制体未找到。请确认 bundle-level-prefabs 已标记为 Asset Bundle "level-prefabs",并运行: python3 tools/bake_cocos_level_prefabs.py\',n),n instanceof Error?n:new Error("missing prefab: "+e)',
neu: 'yield u(),yield L(e);s=null,f=null;var r=v(e);try{var l=yield c();return yield d((function(e){return p(l,e)}),r)}catch(e){throw console.error("[LevelPrefabLoader] 关卡预制体未找到",e),e||new Error("missing prefab: "+e)}',
},
{
desc: 'LevelPrefabLoader: ensureLevelPack 后清空 bundle 缓存 (旧构建 g/w/c/f)',
old: 'yield a(),yield w(e);var r=b(e);try{var n=yield p();return yield y((function(e){return d(n,e)}),r)}catch(e){throw console.error("[LevelPrefabLoader] 关卡预制体未找到",e),e||new Error("missing prefab")}',
neu: 'yield a(),yield w(e);c=null,f=null;var r=b(e);try{var n=yield p();return yield y((function(e){return d(n,e)}),r)}catch(e){throw console.error("[LevelPrefabLoader] 关卡预制体未找到",e),e||new Error("missing prefab")}',
},
{
desc: 'LevelPrefabLoader: 禁用 resources 回退',
old: '}catch(e){console.warn("[LevelPrefabLoader] level-prefabs bundle 不可用,尝试 resources",e)}var l=yield L(e);if(null!=l&&l.isValid)return l;try{return yield y(v,r)}catch(e){throw console.error(\'[LevelPrefabLoader] 关卡预制体未找到。请确认 bundle-level-prefabs 已标记为 Asset Bundle "level-prefabs",并运行: python3 tools/bake_cocos_level_prefabs.py\',e),e}',
neu: '}catch(e){throw console.error("[LevelPrefabLoader] 关卡预制体未找到",e),e||new Error("missing prefab")}',
},
];
let n = 0;
for (const { desc, old, neu } of rules) {
if (!s.includes(old)) continue;
s = s.replace(old, neu);
n += 1;
console.log(`>>> main/index.js patch: ${desc}`);
}
if (n === 0) {
console.warn('>>> 警告: main/index.js 未匹配分包 patch请确认 Cocos 已构建或更新 patch 规则)');
}
fs.writeFileSync(mainIndexPath, s, 'utf8');
}
if (!fs.existsSync(path.join(buildDir, 'index.html'))) {
console.error('Missing Cocos build:', buildDir);
process.exit(1);
}
if (!fs.existsSync(path.join(unityRef, 'index.html'))) {
console.error('Missing Unity reference index.html:', unityRef);
console.error('需要完整 Unity 参考包(如 ~/tfrh/竞赛/mstest5static/unity 扁平包不可用。');
process.exit(1);
}
if (!fs.existsSync(path.join(unityRef, 'StreamingAssets/aa/catalog.json'))) {
console.error('Missing Unity reference catalog:', unityRef);
process.exit(1);
}
const tmp = path.join(outDir, '..', '.pack-tmp-' + Date.now());
rmrf(outDir);
mkdirp(tmp);
console.log('>>> Unity 参考:', unityRef);
console.log('>>> Cocos 构建:', buildDir);
console.log('>>> 输出(运行时包):', outDir);
// —— 1. StreamingAssets 元数据(不含 index.html / TemplateData与 CDN 上传一致)——
mkdirp(outDir);
const aaDir = path.join(outDir, 'StreamingAssets', 'aa');
const webglDir = path.join(aaDir, 'WebGL');
mkdirp(webglDir);
copyFile(path.join(unityRef, 'StreamingAssets/aa/settings.json'), path.join(aaDir, 'settings.json'));
copyFile(path.join(unityRef, 'StreamingAssets/aa/catalog.json'), path.join(aaDir, 'catalog.json'));
copyDir(path.join(unityRef, 'StreamingAssets/aa/AddressablesLink'), path.join(aaDir, 'AddressablesLink'));
const BUNDLE = parseCatalogBundles(path.join(aaDir, 'catalog.json'));
console.log('>>> catalog bundles:', BUNDLE.all.join(', '));
if (BUNDLE.shaders) {
copyFile(
path.join(unityRef, 'StreamingAssets/aa/WebGL', BUNDLE.shaders),
path.join(webglDir, BUNDLE.shaders),
);
}
// —— 2. 打 scenes bundleCocos 运行时 JS——
const scenesStage = path.join(tmp, 'scenes');
mkdirp(scenesStage);
for (const f of ['index.js', 'application.js']) {
copyFile(path.join(buildDir, f), path.join(scenesStage, f));
}
copyDir(path.join(buildDir, 'src'), path.join(scenesStage, 'src'));
copyDir(path.join(buildDir, 'cocos-js'), path.join(scenesStage, 'cocos-js'));
copyFile(
path.join(path.dirname(__filename), '../web-template/cocos-bridge.js'),
path.join(scenesStage, 'cocos-bridge.js'),
);
patchText(path.join(scenesStage, 'application.js'), (t) =>
t.replace(/this\.settingsPath\s*=\s*'[^']*'/, "this.settingsPath = 'src/settings.json'"),
);
// 保持 assets.server 为空,由 loader fetch shim / 本地解压目录提供资源
patchText(path.join(scenesStage, 'src', 'settings.json'), (t) => {
const j = JSON.parse(t);
j.assets = j.assets || {};
j.assets.server = '';
patchPreloadSettings(j, { preloadResources: false, preloadLevelPrefabs: false });
return JSON.stringify(j);
});
zipDir(scenesStage, path.join(webglDir, BUNDLE.scenesAll));
// —— 3a. assets_all核心资源不含 level-prefabs——
// 默认分包MERGE_LEVELS=1 时合并 level-prefabs 进 assets_all不推荐
const MERGE_LEVELS = process.env.MERGE_LEVELS === '1';
const assetsCoreStage = path.join(tmp, 'assets-core');
mkdirp(assetsCoreStage);
if (MERGE_LEVELS) {
copyDir(path.join(buildDir, 'assets'), path.join(assetsCoreStage, 'assets'));
patchCatalogRemoveLevelsBundle(path.join(aaDir, 'catalog.json'));
console.log('>>> MERGE_LEVELS=1: level-prefabs 已并入 assets_all');
} else {
copyAssetsDirExcept(
path.join(buildDir, 'assets'),
path.join(assetsCoreStage, 'assets'),
['level-prefabs'],
);
// level-prefabs 壳config + index进首屏各关 import 单独按需包
const lpShellSrc = path.join(buildDir, 'assets', 'level-prefabs');
const lpShellDst = path.join(assetsCoreStage, 'assets', 'level-prefabs');
mkdirp(lpShellDst);
for (const f of ['config.json', 'index.js']) {
const sp = path.join(lpShellSrc, f);
if (fs.existsSync(sp)) copyFile(sp, path.join(lpShellDst, f));
}
patchMainIndexForSplitLoad(path.join(assetsCoreStage, 'assets', 'main', 'index.js'));
console.log('>>> 分包模式: assets_all 含 level-prefabs 壳,不含关卡 import');
}
const nativeWasm = path.join(buildDir, 'cocos-js', 'assets');
if (fs.existsSync(nativeWasm)) {
copyDir(nativeWasm, path.join(assetsCoreStage, 'assets'));
}
copyFile(path.join(scenesStage, 'src', 'settings.json'), path.join(assetsCoreStage, 'src', 'settings.json'));
if (fs.existsSync(path.join(scenesStage, 'src', 'effect.bin'))) {
copyFile(path.join(scenesStage, 'src', 'effect.bin'), path.join(assetsCoreStage, 'src', 'effect.bin'));
}
const assetsCoreZip = path.join(webglDir, BUNDLE.assetsAll);
zipDir(assetsCoreStage, assetsCoreZip);
console.log(`>>> assets_core (assets_all): ${formatBytes(fs.statSync(assetsCoreZip).size)}`);
// —— 3b. 每关独立 bundle + levels-manifest.json ——
const levelPrefabsSrc = path.join(buildDir, 'assets', 'level-prefabs');
let levelPackStats = null;
if (!MERGE_LEVELS) {
if (!fs.existsSync(levelPrefabsSrc)) {
console.error('>>> 错误: 分包模式需要 build/assets/level-prefabs');
console.error('>>> 请确认 bundle-level-prefabs 已配置并在 Cocos 中重新构建 Web Desktop');
process.exit(1);
}
patchCatalogRemoveLevelsBundle(path.join(aaDir, 'catalog.json'));
const manifestPath = path.join(aaDir, 'levels-manifest.json');
levelPackStats = splitLevelBundles(levelPrefabsSrc, webglDir, manifestPath, tmp);
}
attachLevelsDatabase(outDir);
// —— 4. Build/ 仅 4 个文件(与 Unity 同名)——
const buildOut = path.join(outDir, 'Build');
mkdirp(buildOut);
const loaderTpl = fs.readFileSync(
path.join(path.dirname(__filename), '../web-template/mstest5-cocos-loader.js'),
'utf8',
);
fs.writeFileSync(
path.join(buildOut, `${PRODUCT}.loader.js`),
loaderTpl.replace(/__PRODUCT_NAME__/g, PRODUCT),
'utf8',
);
// .br 占位index.html config 需要文件存在;实际由自定义 loader 启动 Cocos
brotliStub(path.join(buildOut, `${PRODUCT}.data.br`));
brotliStub(path.join(buildOut, `${PRODUCT}.framework.js.br`));
brotliStub(path.join(buildOut, `${PRODUCT}.wasm.br`));
rmrf(tmp);
// —— 5. 独立调试页(可选,不进 static/unity 也不上传 OSS——
const standaloneDir = path.join(path.dirname(outDir), 'standalone-player');
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']]) {
const src = path.join(outDir, item);
const dst = path.join(standaloneDir, item);
if (!fs.existsSync(src)) continue;
if (fs.statSync(src).isDirectory()) copyDir(src, dst);
else copyFile(src, dst);
}
console.log('>>> 独立调试页:', standaloneDir);
assertRuntimePack(outDir, { requireLevelsBundle: false, requireLevelsManifest: !MERGE_LEVELS });
const runtimeFiles = listRuntimeFiles(outDir);
console.log('\n>>> 运行时包文件(本地 static/unity = OSS unitycdndir:');
for (const { rel } of runtimeFiles) console.log(' ', rel);
const requiredBundles = [
path.join(webglDir, BUNDLE.scenesAll),
path.join(webglDir, BUNDLE.assetsAll),
];
if (BUNDLE.shaders) requiredBundles.push(path.join(webglDir, BUNDLE.shaders));
for (const p of requiredBundles) {
if (!fs.existsSync(p) || fs.statSync(p).size < 100) {
console.error('>>> bundle 无效:', p);
process.exit(1);
}
}
const preloadNote = MERGE_LEVELS
? 'scenes ∥ assets_all 合并包 (MERGE_LEVELS=1)'
: `scenes ∥ assets_core 首屏;关卡包进关按需 (${levelPackStats ? levelPackStats.packed : '?'} 关)`;
console.log('\n完成。运行时包 → 本地 import-to-unity.sh / OSS unitycdndir同一目录');
printPackageReport(outDir, { preloadNote });