You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
352 lines
13 KiB
352 lines
13 KiB
|
3 weeks ago
|
/**
|
||
|
|
* 构建进程(独立 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<string, string>();
|
||
|
|
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<string, unknown> = {};
|
||
|
|
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<string>();
|
||
|
|
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<string>();
|
||
|
|
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<string> {
|
||
|
|
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;
|
||
|
|
}
|