feat(tools): build-game 构建流水线

版本校验 → 实体化 → 皮肤合成 → 构建 → build-info。任一校验不过
即中止且不触发构建。runBuild 为注入点, 除真构建外全流程可单测。
Cocos 可执行路径只从 COCOS_CREATOR 环境变量取, 缺失显式报错。

同时补入 check-skin.mjs 的 process.argv[1] 守卫,与仓库其余脚本一致。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-27 22:08:48 +08:00
co-authored by Claude Opus 5
parent dcb8b1bd78
commit 134f4c877c
4 changed files with 236 additions and 1 deletions
@@ -0,0 +1,97 @@
#!/usr/bin/env node
import { join } from 'node:path';
import { existsSync, rmSync, writeFileSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { pathToFileURL } from 'node:url';
import { HOST_PROJECT, GAMES_DIR, FRAMEWORK_SRC, BUILD_WORKSPACE, readCreatorVersion } from './lib/paths.mjs';
import { materialize, composeSkin, SkinValidationError } from './lib/materialize.mjs';
/** 生产用构建实现:调 Cocos CLI。路径只从环境变量取,缺失即显式报错(第二准则)。 */
function runCocosBuild(projectDir, platform) {
const exe = process.env.COCOS_CREATOR;
if (!exe) {
throw new Error(
'未设置环境变量 COCOS_CREATOR(Cocos Creator 可执行文件路径)。' +
'这是构建的唯一来源,请在环境中配置,不要在脚本里硬编码。',
);
}
execFileSync(exe, ['--project', projectDir, '--build', `platform=${platform}`], { stdio: 'inherit' });
}
/**
* 构建一款子游戏:版本校验 → 实体化 → 皮肤合成 → 构建 → 写 build-info。
* opts 可注入路径与 runBuild 用于测试。
*/
export function buildGame(name, opts = {}) {
const gamesDir = opts.gamesDir ?? GAMES_DIR;
const frameworkSrc = opts.frameworkSrc ?? FRAMEWORK_SRC;
const hostProject = opts.hostProject ?? HOST_PROJECT;
const workspace = opts.workspace ?? BUILD_WORKSPACE;
const targetPlatform = opts.platform ?? 'android';
const runBuild = opts.runBuild ?? runCocosBuild;
const gameDir = join(gamesDir, name);
if (!existsSync(gameDir)) throw new Error(`找不到子游戏工程:${gameDir}`);
// 1) 引擎版本一致性(junction 共享资源,版本分叉会损坏真源)
const expected = readCreatorVersion(hostProject);
const actual = readCreatorVersion(gameDir);
if (actual !== expected) {
throw new Error(`引擎版本不一致:${name} 为 ${actual},宿主为 ${expected}`);
}
// 2) 实体化到干净的工作区
const workspaceDir = join(workspace, name);
rmSync(workspaceDir, { recursive: true, force: true });
materialize(gameDir, frameworkSrc, workspaceDir);
// 3) 皮肤合成(校验不过会抛 SkinValidationError,构建不会被调用)
const skin = composeSkin(workspaceDir);
// 4) 构建
runBuild(workspaceDir, targetPlatform);
// 5) 产物追溯
const buildInfo = {
game: name,
frameworkCommit: opts.frameworkCommit ?? readFrameworkCommit(),
builtAt: opts.now ?? new Date().toISOString(),
platform: targetPlatform,
skin: { matched: skin.matched.length, frameworkTotal: skin.frameworkTotal },
};
writeFileSync(join(workspaceDir, 'build-info.json'), `${JSON.stringify(buildInfo, null, 2)}\n`);
return { workspaceDir, skin: buildInfo.skin, buildInfo };
}
function readFrameworkCommit() {
try {
return execFileSync('git', ['rev-parse', '--short', 'HEAD'], { encoding: 'utf8' }).trim();
} catch {
return null; // 非 git 环境:如实记 null,不编造
}
}
// CLI 入口(argv[1] 守卫与仓库其余脚本一致:无脚本名时不进 CLI 分支)
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const name = process.argv[2];
if (!name) {
console.error('用法: npm run build-game <game> [-- --platform android]');
process.exit(1);
}
const pIdx = process.argv.indexOf('--platform');
const targetPlatform = pIdx > -1 ? process.argv[pIdx + 1] : undefined;
try {
const r = buildGame(name, targetPlatform ? { platform: targetPlatform } : {});
console.log(`皮肤命中 ${r.skin.matched} 处,框架可覆盖资源共 ${r.skin.frameworkTotal} 个`);
console.log(`构建完成:${r.workspaceDir}`);
} catch (e) {
if (e instanceof SkinValidationError) {
console.error(`\n${e.message}(子游戏 ${name}):`);
for (const err of e.errors) console.error(` [${err.code}] ${err.path}\n ${err.message}`);
} else {
console.error(e.message);
}
process.exit(1);
}
}
+1 -1
View File
@@ -20,7 +20,7 @@ export function checkSkin(gameName, opts = {}) {
}
// CLI 入口:有错误则退出码 1
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const name = process.argv[2];
if (!name) {
console.error('用法: npm run check-skin <game>');
@@ -0,0 +1,137 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { join } from 'node:path';
import { mkdirSync, writeFileSync, readFileSync, existsSync, rmSync, mkdtempSync, symlinkSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { platform } from 'node:process';
import { deflateSync } from 'node:zlib';
import { buildGame } from '../build-game.mjs';
const LINK_TYPE = platform === 'win32' ? 'junction' : 'dir';
function crc32(buf) {
let c = ~0;
for (const x of buf) { c ^= x; for (let i = 0; i < 8; i++) c = (c >>> 1) ^ (0xEDB88320 & -(c & 1)); }
return ~c >>> 0;
}
function chunk(type, data) {
const len = Buffer.alloc(4); len.writeUInt32BE(data.length);
const td = Buffer.concat([Buffer.from(type, 'ascii'), data]);
const c = Buffer.alloc(4); c.writeUInt32BE(crc32(td));
return Buffer.concat([len, td, c]);
}
function makePng(w, h, rgb = [0, 0, 0]) {
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4); ihdr[8] = 8; ihdr[9] = 2;
const raw = Buffer.alloc(h * (1 + w * 3));
for (let y = 0; y < h; y++) {
const off = y * (1 + w * 3);
raw[off] = 0;
for (let x = 0; x < w; x++) {
raw[off + 1 + x * 3] = rgb[0]; raw[off + 2 + x * 3] = rgb[1]; raw[off + 3 + x * 3] = rgb[2];
}
}
return Buffer.concat([
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
chunk('IHDR', ihdr), chunk('IDAT', deflateSync(raw)), chunk('IEND', Buffer.alloc(0)),
]);
}
const DEFAULT_BTN = makePng(64, 64, [1, 2, 3]);
const SKINNED = makePng(64, 64, [1, 2, 3]);
function put(dir, rel, content) {
const p = join(dir, ...rel.split('/'));
mkdirSync(join(p, '..'), { recursive: true });
writeFileSync(p, content);
}
function scaffold(gameVersion = '3.8.8') {
const root = mkdtempSync(join(tmpdir(), 'youle-build-'));
const hostProject = join(root, 'nexus');
const frameworkSrc = join(hostProject, 'assets', 'framework');
mkdirSync(frameworkSrc, { recursive: true });
writeFileSync(join(hostProject, 'package.json'), JSON.stringify({ name: 'YouleNexus', creator: { version: '3.8.8' } }));
put(frameworkSrc, 'ui/atlas-hall/btn.png', DEFAULT_BTN);
const gamesDir = join(root, 'games');
const gameDir = join(gamesDir, 'demo');
mkdirSync(join(gameDir, 'assets'), { recursive: true });
writeFileSync(join(gameDir, 'package.json'), JSON.stringify({ name: 'demo', creator: { version: gameVersion } }));
symlinkSync(frameworkSrc, join(gameDir, 'assets', 'framework'), LINK_TYPE);
return { root, hostProject, frameworkSrc, gamesDir, gameDir, workspace: join(root, 'build-workspace') };
}
function cleanup(root) { rmSync(root, { recursive: true, force: true }); }
function baseOpts(s, extra = {}) {
return {
gamesDir: s.gamesDir, frameworkSrc: s.frameworkSrc, hostProject: s.hostProject,
workspace: s.workspace, platform: 'web-mobile',
frameworkCommit: 'abc1234', now: '2026-08-27T00:00:00.000Z',
runBuild: () => {},
...extra,
};
}
test('buildGame 走通全流程:实体化 → 合成 → 调构建 → 写 build-info', () => {
const s = scaffold();
try {
put(s.gameDir, 'assets/game/override/atlas-hall/btn.png', SKINNED);
const calls = [];
const r = buildGame('demo', baseOpts(s, { runBuild: (dir, p) => calls.push([dir, p]) }));
assert.ok(readFileSync(join(r.workspaceDir, 'assets', 'framework', 'ui', 'atlas-hall', 'btn.png')).equals(SKINNED));
assert.equal(calls.length, 1);
assert.equal(calls[0][0], r.workspaceDir);
assert.equal(calls[0][1], 'web-mobile');
const info = JSON.parse(readFileSync(join(r.workspaceDir, 'build-info.json'), 'utf8'));
assert.equal(info.game, 'demo');
assert.equal(info.frameworkCommit, 'abc1234');
assert.equal(info.platform, 'web-mobile');
assert.deepEqual(info.skin, { matched: 1, frameworkTotal: 1 });
} finally { cleanup(s.root); }
});
test('buildGame 在引擎版本不一致时中止,且不调用构建', () => {
const s = scaffold('3.8.6');
try {
let called = false;
assert.throws(
() => buildGame('demo', baseOpts(s, { runBuild: () => { called = true; } })),
/引擎版本不一致/,
);
assert.equal(called, false);
} finally { cleanup(s.root); }
});
test('buildGame 在皮肤校验失败时中止,且不调用构建', () => {
const s = scaffold();
try {
put(s.gameDir, 'assets/game/override/atlas-hall/typo.png', 'X');
let called = false;
assert.throws(
() => buildGame('demo', baseOpts(s, { runBuild: () => { called = true; } })),
/皮肤覆盖校验未通过/,
);
assert.equal(called, false);
} finally { cleanup(s.root); }
});
test('buildGame 重复构建:清掉旧工作区后重建(幂等)', () => {
const s = scaffold();
try {
const opts = baseOpts(s);
buildGame('demo', opts);
const r2 = buildGame('demo', opts); // 不应因目标已存在而抛错
assert.ok(existsSync(join(r2.workspaceDir, 'build-info.json')));
} finally { cleanup(s.root); }
});
test('buildGame 对不存在的子游戏抛错', () => {
const s = scaffold();
try {
assert.throws(() => buildGame('nosuch', baseOpts(s)), /找不到子游戏工程/);
} finally { cleanup(s.root); }
});