#!/usr/bin/env node /** * 将 Cocos Web 构建产物整理为与 Unity 参考包完全一致的目录/文件名: * /Users/liuyufei/tfrh/竞赛/mstest5 * * node tools/package-for-cdn.js */ 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 [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/竞赛/mstest5),static/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 bundle(Cocos 运行时 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 });