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:
377
tools/package-for-cdn.js
Normal file
377
tools/package-for-cdn.js
Normal 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/竞赛/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 });
|
||||
Reference in New Issue
Block a user