From 134f4c877c2da03e4da4e8647d16a7b00fe4104a Mon Sep 17 00:00:00 2001 From: Joywayer Date: Thu, 27 Aug 2026 22:08:48 +0800 Subject: [PATCH] =?UTF-8?q?feat(tools):=20build-game=20=E6=9E=84=E5=BB=BA?= =?UTF-8?q?=E6=B5=81=E6=B0=B4=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 版本校验 → 实体化 → 皮肤合成 → 构建 → build-info。任一校验不过 即中止且不触发构建。runBuild 为注入点, 除真构建外全流程可单测。 Cocos 可执行路径只从 COCOS_CREATOR 环境变量取, 缺失显式报错。 同时补入 check-skin.mjs 的 process.argv[1] 守卫,与仓库其余脚本一致。 Co-Authored-By: Claude Opus 5 (1M context) --- cocoscreator_projects/package.json | 1 + cocoscreator_projects/scripts/build-game.mjs | 97 +++++++++++++ cocoscreator_projects/scripts/check-skin.mjs | 2 +- .../scripts/test/build-game.test.mjs | 137 ++++++++++++++++++ 4 files changed, 236 insertions(+), 1 deletion(-) create mode 100644 cocoscreator_projects/scripts/build-game.mjs create mode 100644 cocoscreator_projects/scripts/test/build-game.test.mjs diff --git a/cocoscreator_projects/package.json b/cocoscreator_projects/package.json index d72f704..d1bdab9 100644 --- a/cocoscreator_projects/package.json +++ b/cocoscreator_projects/package.json @@ -10,6 +10,7 @@ "check-cocos": "node scripts/check-cocos-version.mjs", "bump-cocos": "node scripts/bump-cocos.mjs", "check-skin": "node scripts/check-skin.mjs", + "build-game": "node scripts/build-game.mjs", "test:framework": "node scripts/run-framework-tests.mjs", "typecheck:framework": "tsc -p tsconfig.framework.json --noEmit" }, diff --git a/cocoscreator_projects/scripts/build-game.mjs b/cocoscreator_projects/scripts/build-game.mjs new file mode 100644 index 0000000..6fd147b --- /dev/null +++ b/cocoscreator_projects/scripts/build-game.mjs @@ -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 [-- --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); + } +} diff --git a/cocoscreator_projects/scripts/check-skin.mjs b/cocoscreator_projects/scripts/check-skin.mjs index 5656b36..dab2786 100644 --- a/cocoscreator_projects/scripts/check-skin.mjs +++ b/cocoscreator_projects/scripts/check-skin.mjs @@ -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 '); diff --git a/cocoscreator_projects/scripts/test/build-game.test.mjs b/cocoscreator_projects/scripts/test/build-game.test.mjs new file mode 100644 index 0000000..0544076 --- /dev/null +++ b/cocoscreator_projects/scripts/test/build-game.test.mjs @@ -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); } +});