/** * 构建进程(独立 worker)加载的钩子脚本;custom-builder 的 configs.*.hooks 指向本文件。 * custom-builder.ts 会 re-export 本文件的钩子,供编辑器校验通过。 */ import fs from 'fs'; import path from 'path'; import lz4 from 'lz4js'; import { minify } from 'terser'; import { createMembershipSet, membershipAdd, membershipToArray, } from './MembershipSet'; export const throwError = true; function readJson(filePath: string) { const raw = fs.readFileSync(filePath, 'utf8'); return JSON.parse(raw); } function resolveConfigNameVersion(projectPath: string) { const sdkConfigPath = path.join(projectPath, 'assets', 'script', 'sdk', 'sdkConfig.ts'); const sdkConfigContent = fs.readFileSync(sdkConfigPath, 'utf8'); const importRegex = /import\s+\{\s*(\w+)\s*\}\s+from\s+["']\.\/config\/([^"']+)["'];?/g; const importMap = new Map(); let m: RegExpExecArray | null = null; while ((m = importRegex.exec(sdkConfigContent))) { importMap.set(m[1], m[2]); } const configAssign = sdkConfigContent.match(/static\s+readonly\s+config\s*:\s*IConfigData\s*=\s*(\w+)\s*;/); const symbol = configAssign?.[1] ?? 'DefaultConfig'; const relConfig = importMap.get(symbol) ?? 'DefaultConfig'; const configPath = path.join(projectPath, 'assets', 'script', 'sdk', 'config', `${relConfig}.ts`); const configContent = fs.readFileSync(configPath, 'utf8'); const name = configContent.match(/name\s*:\s*["']([^"']+)["']/)?.[1] ?? 'config'; const vsion = configContent.match(/vsion\s*:\s*["']([^"']+)["']/)?.[1] ?? '0.0.0'; return { name, vsion }; } function encodeForRuntime(jsonObj: any) { const jsonString = JSON.stringify(jsonObj); const uint8 = new TextEncoder().encode(jsonString); const compressed = Uint8Array.from(lz4.compress(uint8)); const binary = Buffer.from(compressed).toString('latin1'); const base64 = Buffer.from(binary, 'latin1').toString('base64'); return Buffer.from(base64, 'latin1'); } const HOT_UPDATE_TABLE_FILES: { tableKey: string; fileName: string }[] = [ { tableKey: 'Monster', fileName: 'Monster.json' }, { tableKey: 'Const', fileName: 'Const.json' }, { tableKey: 'Rank', fileName: 'Rank.json' }, { tableKey: 'Item', fileName: 'Item.json' }, { tableKey: 'Level', fileName: 'Level.json' }, { tableKey: 'Skill', fileName: 'Skill.json' }, { tableKey: 'Battlebuff', fileName: 'Battlebuff.json' }, { tableKey: 'Weapon', fileName: 'Weapon.json' }, { tableKey: 'Text', fileName: 'Text.json' }, { tableKey: 'Hero', fileName: 'Hero.json' }, { tableKey: 'Headicon', fileName: 'Headicon.json' }, ]; function generateConfigBin() { const projectPath = Editor.Project.path; const configDir = path.join(projectPath, 'assets', 'bundles', 'first', 'config'); const mergeJson: Record = {}; for (const { tableKey, fileName } of HOT_UPDATE_TABLE_FILES) { const fp = path.join(configDir, fileName); if (!fs.existsSync(fp)) { console.warn(`[tools] skip bin generate: missing ${fileName}`); return; } mergeJson[tableKey] = readJson(fp); } const { name, vsion } = resolveConfigNameVersion(projectPath); const safeName = `${name}${vsion}`.replace(/[<>:"/\\|?*]+/g, '_'); const outDir = path.join(projectPath, 'bin'); const outFile = path.join(outDir, `${safeName}.bin`); fs.mkdirSync(outDir, { recursive: true }); fs.writeFileSync(outFile, encodeForRuntime(mergeJson)); console.log(`[tools] generated config bin: ${outFile}`); } function collectJsFiles(dir: string, acc: string[]) { if (!fs.existsSync(dir)) return; const st = fs.statSync(dir); if (!st.isDirectory()) return; const entries = fs.readdirSync(dir, { withFileTypes: true }); for (const e of entries) { const full = path.join(dir, e.name); if (e.isDirectory()) { collectJsFiles(full, acc); } else if (e.isFile() && e.name.endsWith('.js')) { acc.push(full); } } } function collectStripRoots(options: any, result: any): string[] { const roots = createMembershipSet(); const add = (p: unknown) => { if (p == null || p === '') return; const s = path.normalize(String(p).replace(/\//g, path.sep)); try { if (fs.existsSync(s)) { const st = fs.statSync(s); if (st.isDirectory()) membershipAdd(roots, s); } } catch { /* ignore */ } }; add(result?.dest); add(result?.paths?.dir); add(result?.paths?.assets); add(result?.paths?.bundleScripts); add(result?.paths?.subpackages); const projectPath = Editor.Project.path; if (projectPath && options?.buildPath && options?.outputName) { let bp = String(options.buildPath); if (bp.includes('project:')) { bp = path.join(projectPath, bp.replace(/^project:\/\//, '').replace(/^project:/, '')); } add(path.join(bp, options.outputName)); } return membershipToArray(roots); } function collectAllJsFiles(roots: string[]): string[] { const files = createMembershipSet(); for (const root of roots) { const acc: string[] = []; collectJsFiles(root, acc); for (const f of acc) membershipAdd(files, path.normalize(f)); } return membershipToArray(files); } async function minifyStripConsole(src: string): Promise { const base = { compress: { drop_console: true, drop_debugger: true, passes: 2, }, mangle: false, format: { comments: 'some' as const }, ecma: 2020 as const, }; let out = await minify(src, base); if (out.code != null) return out.code; out = await minify(src, { ...base, module: true }); if (out.code != null) return out.code; throw new Error('terser returned empty'); } async function stripConsoleFromBuildOutput(roots: string[]) { const files = collectAllJsFiles(roots); if (files.length === 0) { console.warn('[tools] stripConsole: no .js under roots', roots); return; } let ok = 0; let fail = 0; for (const file of files) { try { const src = fs.readFileSync(file, 'utf8'); const code = await minifyStripConsole(src); fs.writeFileSync(file, code, 'utf8'); ok++; } catch (err) { fail++; console.warn(`[tools] stripConsole skip (terser error): ${file}`, err); } } console.log(`[tools] stripConsole done: ok=${ok}, fail=${fail}, roots=${roots.join('; ')}`); } function shouldStripConsole(options: any): boolean { const v = options?.packages?.tools?.stripConsole; if (v === true) return true; if (v === 'true' || v === 1) return true; return false; } /** Cocos engine-adapter 中 tt.createOffscreenVideo 鸿蒙暂不支持,构建后替换为带环境判断的调用 */ function patchEngineAdapterHarmony(engineAdapterPath: string) { if (!fs.existsSync(engineAdapterPath)) { return; } const needle = 'tt.createOffscreenVideo()'; let src = fs.readFileSync(engineAdapterPath, 'utf8'); if (!src.includes(needle)) { console.warn(`[tools] harmony patch skip: ${needle} not found in ${engineAdapterPath}`); return; } const replacement = '(function(t){try{var i=t.getSystemInfoSync(),p=String(i.platform||"").toLowerCase(),s=String(i.system||"").toLowerCase();' + 'if(p==="openharmony"||s.indexOf("openharmony")>=0)return null;var f=t["create"+"Offscreen"+"Video"];' + 'return typeof f==="function"?f.call(t):null}catch(e){return null}})(tt)'; src = src.replace(needle, replacement); fs.writeFileSync(engineAdapterPath, src, 'utf8'); console.log(`[tools] harmony patch applied: ${engineAdapterPath}`); } function patchHarmonyEngineAdapter(roots: string[]) { for (const root of roots) { patchEngineAdapterHarmony(path.join(root, 'engine-adapter.js')); } } const WECHAT_ONLY_FALLBACK_FILES = ['Sdk30rWechat.ts', 'Sdk30rWechat.ts.meta']; function getBuildPlatform(options: any): string { return String(options?.platform ?? options?.buildPlatform ?? ''); } function wechatOnlySdkPaths(projectPath: string) { const srcDir = path.join(projectPath, 'tools', 'wechat-only'); const destDir = path.join(projectPath, 'assets', 'script', 'sdk'); return { srcDir, destDir }; } /** wechat-only 目录下需复制的文件(跳过说明文档) */ function listWechatOnlyFiles(srcDir: string): string[] { if (!fs.existsSync(srcDir)) return []; const names = fs.readdirSync(srcDir); const out: string[] = []; for (let i = 0; i < names.length; i++) { const name = names[i]; if (name.endsWith('.md')) continue; const full = path.join(srcDir, name); if (fs.statSync(full).isFile()) out.push(name); } return out; } /** 从 assets 删除微信独占脚本,避免残留进抖音/快手包 */ function removeWechatOnlySdkFromAssets(srcDir: string, destDir: string, platform: string) { const names = listWechatOnlyFiles(srcDir); for (let i = 0; i < WECHAT_ONLY_FALLBACK_FILES.length; i++) { const n = WECHAT_ONLY_FALLBACK_FILES[i]; if (names.indexOf(n) < 0) names.push(n); } let removed = 0; for (let i = 0; i < names.length; i++) { const dest = path.join(destDir, names[i]); if (fs.existsSync(dest)) { fs.unlinkSync(dest); removed++; } } if (removed > 0) { console.log(`[tools] removed wechat-only 30r sdk from assets (${platform || 'unknown platform'})`); } } /** * 仅纯微信 wechatgame:把 tools/wechat-only 拷进 assets。 * 抖音/快手/B 站(勾选导出 biligame)不拷,避免 30r 进包。 */ function syncWechatOnlySdk(options: any, mode: 'before' | 'after') { const projectPath = Editor.Project.path; if (!projectPath) return; const { srcDir, destDir } = wechatOnlySdkPaths(projectPath); const platform = getBuildPlatform(options); const isWechat = platform === 'wechatgame'; const isBiliGame = !!options?.packages?.['biligame-builder']?.isBiliGame; if (mode === 'after' || !isWechat || isBiliGame) { removeWechatOnlySdkFromAssets(srcDir, destDir, isBiliGame ? 'biligame' : platform); return; } const files = listWechatOnlyFiles(srcDir); if (files.length === 0) { console.warn('[tools] wechat-only sdk missing:', srcDir); return; } fs.mkdirSync(destDir, { recursive: true }); for (let i = 0; i < files.length; i++) { const name = files[i]; fs.copyFileSync(path.join(srcDir, name), path.join(destDir, name)); } console.log('[tools] copied wechat-only 30r sdk into assets/script/sdk'); } export function onBeforeBuild(options: any, _result: any) { try { syncWechatOnlySdk(options, 'before'); } catch (err) { console.error('[tools] onBeforeBuild syncWechatOnlySdk failed:', err); } try { console.log('[tools] onBeforeBuild: generate config bin'); generateConfigBin(); } catch (err) { console.error('[tools] onBeforeBuild generate config bin failed:', err); } return true; } export async function onAfterBuild(options: any, result: any) { try { // 产物已编译进包,assets 里的拷贝必须删掉,避免下次抖音/快手构建误打入 syncWechatOnlySdk(options, 'after'); } catch (err) { console.error('[tools] onAfterBuild syncWechatOnlySdk failed:', err); } try { if (shouldStripConsole(options)) { const roots = collectStripRoots(options, result); if (roots.length === 0) { console.warn( '[tools] stripConsole 已勾选但未能解析构建输出目录(检查 result.dest / paths / buildPath+outputName)', ); } else { console.log('[tools] onAfterBuild stripConsole →', roots.join(' | ')); await stripConsoleFromBuildOutput(roots); } } } catch (err) { console.error('[tools] onAfterBuild stripConsole failed:', err); } try { if (getBuildPlatform(options) === 'bytedance-mini-game') { const roots = collectStripRoots(options, result); patchHarmonyEngineAdapter(roots); } } catch (err) { console.error('[tools] onAfterBuild harmony patch failed:', err); } try { console.log('[tools] onAfterBuild: generate config bin'); generateConfigBin(); } catch (err) { console.error('[tools] onAfterBuild generate config bin failed:', err); } return true; }