#!/usr/bin/env node /** * 项目运行包:扁平结构,直接供 scratch-gui static/unity 本地加载。 * * node tools/package-for-project.js [options] * * 选项: * --minify-db 压缩 levels-database.json(默认开启) * --no-minify-db 保留格式化 JSON * --brotli-db 额外生成 levels-database.json.br * --preload-resources 启动时预加载 resources 包(默认关闭,按需加载) * --preload-levels 启动时预加载 level-prefabs 包(默认关闭) * --report 打印体积报告(默认开启) * --no-report * * 输出结构: * Build/mstest5.loader.js + 占位 .br * index.js, application.js, cocos-bridge.js * cocos-js/, src/, assets/ */ const fs = require('fs'); const path = require('path'); const zlib = require('zlib'); const { minifyLevelsDatabase, brotliCompressFile, patchPreloadSettings, patchSplashSettings, printPackageReport, formatBytes, } = require('./package-optimize'); const argv = process.argv.slice(2); const positional = []; const flags = new Set(); for (const a of argv) { if (a.startsWith('--')) flags.add(a); else positional.push(a); } const buildDir = path.resolve(positional[0] || ''); const outDir = path.resolve(positional[1] || ''); const opts = { minifyDb: !flags.has('--no-minify-db'), brotliDb: flags.has('--brotli-db'), preloadResources: flags.has('--preload-resources'), preloadLevelPrefabs: flags.has('--preload-levels'), report: !flags.has('--no-report'), }; const PRODUCT = 'mstest5'; const UNITY_BASE = '/unity/'; const templateDir = path.join(__dirname, '../web-template'); if (!buildDir || !outDir) { console.error('Usage: package-for-project.js [--minify-db] [--brotli-db] ...'); 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 patchText(file, fn) { const t = fs.readFileSync(file, 'utf8'); fs.writeFileSync(file, fn(t), 'utf8'); } function brotliStub(dst) { fs.writeFileSync(dst, zlib.brotliCompressSync(Buffer.from([0]))); } const required = ['index.js', 'application.js', 'src', 'cocos-js', 'assets']; for (const r of required) { if (!fs.existsSync(path.join(buildDir, r))) { console.error('Missing in Cocos build:', r, 'at', buildDir); process.exit(1); } } console.log('>>> Cocos 构建:', buildDir); console.log('>>> 输出(项目运行包):', outDir); console.log('>>> 优化:', JSON.stringify({ minifyDb: opts.minifyDb, brotliDb: opts.brotliDb, preloadResources: opts.preloadResources, preloadLevelPrefabs: opts.preloadLevelPrefabs, })); mkdirp(outDir); for (const item of [ 'Build', 'index.js', 'application.js', 'cocos-bridge.js', 'cocos-js', 'src', 'assets', 'StreamingAssets', 'TemplateData', 'index.html', 'levels-database.json', 'levels-database.json.br', ]) { rmrf(path.join(outDir, item)); } for (const item of ['index.js', 'application.js']) { copyFile(path.join(buildDir, item), path.join(outDir, item)); } copyDir(path.join(buildDir, 'src'), path.join(outDir, 'src')); copyDir(path.join(buildDir, 'cocos-js'), path.join(outDir, 'cocos-js')); copyDir(path.join(buildDir, 'assets'), path.join(outDir, 'assets')); const EMBEDDED_RESOLUTION_PATCH = '!(function(){v.resizeWithBrowserSize(!0);v.setDesignResolutionSize(G,H,C.FIXED_WIDTH)})()'; function patchMainIndexRuntime(t) { return t .replace(/return\[\]\.concat\(l\)\},entityFlipX/g, 'return Array.from(l)},entityFlipX') .replace(/return\[\]\.concat\(n\)\},e\.ensureUILayerTree/g, 'return Array.from(n)},e.ensureUILayerTree') .replace(/e\("applyEmbeddedDesignResolution",\(function\(\)\{[^}]+\}\)\)/g, 'e("applyEmbeddedDesignResolution",(function(){t.resizeWithBrowserSize(!0),t.setDesignResolutionSize(s,o,n.FIXED_WIDTH)}))') .replace(/v\.setDesignResolutionSize\(G,H,C\.(?:SHOW_ALL|FIXED_WIDTH|FIXED_HEIGHT)\)/g, EMBEDDED_RESOLUTION_PATCH) .replace(/setDesignResolutionSize\(G,H,C\.(?:SHOW_ALL|FIXED_WIDTH|FIXED_HEIGHT)\)/g, EMBEDDED_RESOLUTION_PATCH) .replace( /e\.syncMapDataComponent=function\(e,n\)\{var r,t,o=e\.getComponent\(p\);o&&\(o\.levelID=n\.levelID,o\.theme=T\(n\),o\.groundJson=JSON\.stringify\(null!=\(r=n\.ground\)\?r:\{\}\),o\.borderJson=JSON\.stringify\(null!=\(t=n\.border\)\?t:\{\}\)\)\}/, 'e.syncMapDataComponent=function(e,n){var r,t,o=e.getComponent(p);if(!o)return;o.levelID=n.levelID;var i=n.theme&&String(n.theme).trim();o.theme=i?T(n):o.theme||T(n);r=n.ground;r&&Object.keys(r).length&&(o.groundJson=JSON.stringify(r));t=n.border;t&&Object.keys(t).length&&(o.borderJson=JSON.stringify(t))}', ) .replace( /e\.prepare=function\(\)\{var e=r\(\(function\*\(e,n\)\{e\.name="Level_"\+n\.levelID,f\.purgeRuntimeGrids\(e\),this\.ensureUILayerTree\(e\);var r=T\(n\),t=m\(e,n\);/, 'e.prepare=function(){var e=r((function*(e,n){e.name="Level_"+n.levelID,f.purgeRuntimeGrids(e),this.ensureUILayerTree(e);var _md=e.getComponent(p);if(_md){try{var _g=JSON.parse(_md.groundJson||"{}");(!n.ground||!Object.keys(n.ground).length)&&Object.keys(_g).length&&(n.ground=_g);var _b=JSON.parse(_md.borderJson||"{}");(!n.border||!Object.keys(n.border).length)&&Object.keys(_b).length&&(n.border=_b)}catch(_e){}(!n.theme||!String(n.theme).trim())&&_md.theme&&(n.theme=_md.theme)}var r=T(n),t=m(e,n);', ) .replace( /function p\(e\)\{var r=e\.children\.filter\(\(function\(e\)\{return e\.isValid&&y\.test\(e\.name\)\}\)\);/, 'function p(e){if(!e||!e.isValid||!e.children)return;var r=e.children.filter((function(e){return e.isValid&&y.test(e.name)}));', ) .replace( /this\.applyMapDataFromConfig\(n\),yield J\.prepare\(o,n\);var s=n\.theme\|\|"silu";/, 'yield J.prepare(o,n),this.applyMapDataFromConfig(n),this.curConfig=n;var s=n.theme||"silu";', ) .replace( /c\.moveTowards\(i,e,this\.targetPosition,this\.moveSpeed\*t\)/g, 'c.moveTowards(i,e,this.targetPosition,this.moveSpeed*t*((null!=(S=globalThis.__tfrhGameSpeed)&&S>0)?S:1))', ) .replace( /if\(!\(t<=0\)\)if\(this\.frameTimer\+=e,!\(this\.frameTimer0)?z:1),!(this.frameTimer patchMainIndexRuntime(t)); } copyFile( path.join(templateDir, 'cocos-bridge.js'), path.join(outDir, 'cocos-bridge.js'), ); const levelsDbSrc = path.join(__dirname, '../assets/level-data/levels-database.json'); const levelsDbDst = path.join(outDir, 'levels-database.json'); if (fs.existsSync(levelsDbSrc)) { if (opts.minifyDb) { const { before, after } = minifyLevelsDatabase(levelsDbSrc, levelsDbDst); console.log(`>>> levels-database.json 压缩: ${formatBytes(before)} → ${formatBytes(after)}`); } else { copyFile(levelsDbSrc, levelsDbDst); } if (opts.brotliDb) { 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(Cocos 关卡库)'); } else { console.warn('>>> 警告: 未找到 levels-database.json,请先运行 tools/sync-level-db.sh'); } patchText(path.join(outDir, 'application.js'), (t) => t.replace(/this\.settingsPath\s*=\s*'[^']*'/, `this.settingsPath = '${UNITY_BASE}src/settings.json'`), ); function patchSettingsForFrontend(settingsFile) { patchText(settingsFile, (t) => { const j = JSON.parse(t); j.assets = j.assets || {}; j.assets.server = UNITY_BASE; patchPreloadSettings(j, { preloadResources: opts.preloadResources, preloadLevelPrefabs: opts.preloadLevelPrefabs, }); patchSplashSettings(j, { stripSplashAssets: true }); if (j.rendering) { j.rendering.effectSettingsPath = `${UNITY_BASE}src/effect.bin`; } if (j.scripting && Array.isArray(j.scripting.scriptPackages)) { j.scripting.scriptPackages = j.scripting.scriptPackages.map((p) => { const rel = String(p).replace(/^\.\.\//, '').replace(/^\//, ''); return `${UNITY_BASE}${rel}`; }); } return JSON.stringify(j); }); } const settingsPath = path.join(outDir, 'src', 'settings.json'); if (fs.existsSync(settingsPath)) { patchSettingsForFrontend(settingsPath); const preload = JSON.parse(fs.readFileSync(settingsPath, 'utf8')).assets?.preloadBundles || []; console.log('>>> 预加载 bundles:', preload.map((b) => b.bundle).join(', ') || '(none)'); } const importMapPath = path.join(outDir, 'src', 'import-map.json'); if (fs.existsSync(importMapPath)) { patchText(importMapPath, (t) => { const j = JSON.parse(t); j.imports = j.imports || {}; if (j.imports.cc) j.imports.cc = `${UNITY_BASE}cocos-js/cc.js`; return JSON.stringify(j); }); } const buildOut = path.join(outDir, 'Build'); mkdirp(buildOut); const loaderTpl = fs.readFileSync(path.join(templateDir, 'mstest5-cocos-loader.js'), 'utf8'); fs.writeFileSync( path.join(buildOut, `${PRODUCT}.loader.js`), loaderTpl.replace(/__PRODUCT_NAME__/g, PRODUCT), 'utf8', ); for (const stub of ['data', 'framework.js', 'wasm']) { brotliStub(path.join(buildOut, `${PRODUCT}.${stub}.br`)); } if (opts.report) { const preloadNote = [ 'main', opts.preloadResources ? 'resources' : null, opts.preloadLevelPrefabs ? 'level-prefabs' : null, ].filter(Boolean).join(', '); printPackageReport(outDir, { preloadNote }); const hasLevelBundle = fs.existsSync(path.join(outDir, 'assets', 'level-prefabs')); if (!hasLevelBundle) { console.log('\n>>> 提示: 尚未拆分 level-prefabs 分包。运行 migrate-level-prefab-bundle.sh 后重建可显著减小 resources 体积。'); } } console.log('\n>>> 项目运行包文件:'); for (const rel of [ 'Build/mstest5.loader.js', 'index.js', 'application.js', 'cocos-bridge.js', 'cocos-js/', 'src/', 'assets/', 'levels-database.json', ]) { console.log(' ', rel); } console.log('\n完成。flat 包与 CDN 不一致,生产请用: bash tools/package-for-project.sh'); console.log(' ~/tfrh/001code/001code-html/scratch-gui/static/unity/import-to-unity.sh', outDir);