# framework/ui 资源归属·皮肤合成·构建流水线 Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 建立 `framework/ui` 的资源归属骨架、override 皮肤校验与构建期合成工具链,使每个子游戏能独立构建出「只含自己皮肤、零冗余、不含他game资源」的包。 **Architecture:** 皮肤在**构建期**合成——`build-game` 把子游戏工程实体化到临时工作区(framework junction 变真实拷贝),把 `game/override/**` 按同名路径覆盖到 `framework/ui/**`(只换文件内容、保留 `.meta` 以维持 UUID),再调 Cocos CLI 构建。框架真源全程只读。校验逻辑抽成可单测的纯函数,CLI 只做参数解析与输出。 **Tech Stack:** Node.js 20.13.1(ESM `.mjs`)、`node:test` + `node:assert` 内置测试、`node:fs`/`node:path`/`node:crypto` 标准库,工具层零第三方依赖;框架侧 TypeScript,测试用 `tsx` + `node:test` 跑在 `framework-tests/`。 **Spec:** `docs/superpowers/specs/2026-08-27-ui-asset-and-skin-design.md` ## Global Constraints - **服务器零改动**(CLAUDE.md 第一准则):本计划不触碰协议。协议字段一律引用 `docs/protocol/`,不在代码或注释内联复制。 - **数据源权威唯一、下游不兜底**(CLAUDE.md 第二准则):可覆盖资源清单**不另建文件**,`framework/ui/` 的文件树即清单;override 匹配不上一律报错中止,不静默跳过。**唯一例外**:`theme` 的缺省值集中在框架的 `DEFAULT_THEME`(spec §4.3)。 - **框架真源全程只读**:皮肤合成只在临时工程副本内进行,绝不写 `YouleNexus/assets/framework`(spec §5.1 ①)。 - **临时工程不得置于 `games/` 下**:固定用 `cocoscreator_projects/build-workspace/`,否则 `listGameProjects()` 会误识别(spec §5.1 ③)。 - **发布链路不依赖 junction**:实体化直接从 `YouleNexus/assets/framework` 读真源拷贝(spec §5.1 ②)。 - **Cocos 版本全 monorepo 统一** `3.8.8`。 - **Node 20.13.1 不支持 `--test` glob**,测试文件须显式枚举(现有 `scripts/run-framework-tests.mjs` 即为此而设)。 - 所有工具命令的工作目录均为 `G:/Works/YouleGamesCocosCreator/cocoscreator_projects`(下称 monorepo 根),除非另行说明。 --- ## 目录与文件结构(本计划建立) ``` cocoscreator_projects/ ├─ .gitignore 相关三处修改(Task 1) ├─ YouleNexus/assets/framework/ui/ # 资源归属骨架(Task 1)+ theme(Task 7) │ ├─ README.md 资源归属规则(spec §2 落地文档) │ ├─ atlas-common/ atlas-login/ atlas-hall/ atlas-room/ │ ├─ standalone/ spine/ audio/ font/ │ └─ theme/ │ ├─ types.ts ThemeConfig / ThemeColors / ThemeFonts / ThemeLayout │ ├─ default-theme.ts DEFAULT_THEME(缺省值唯一来源) │ ├─ resolve-theme.ts resolveTheme() │ └─ theme-provider.ts ThemeProvider ├─ templates/game-seed/.gitignore # 新建(Task 1) ├─ framework-tests/ui/theme.test.ts # Task 7 └─ scripts/ ├─ lib/ │ ├─ paths.mjs # 扩充:FRAMEWORK_UI_DIR / gameOverrideDir / BUILD_WORKSPACE │ ├─ skin.mjs # 资源清单 + override 校验(Task 3) │ └─ materialize.mjs # 实体化 + 皮肤合成(Task 5) ├─ check-skin.mjs # 独立校验 CLI(Task 4) ├─ build-game.mjs # 构建流水线 CLI(Task 6) └─ test/ ├─ skin.test.mjs # Task 3 ├─ check-skin.test.mjs # Task 4 ├─ materialize.test.mjs # Task 5 └─ build-game.test.mjs # Task 6 ``` --- ### Task 1: `framework/ui` 资源归属骨架 + 修复 `.gitignore` 吞目录 **Files:** - Create: `cocoscreator_projects/YouleNexus/assets/framework/ui/README.md` - Create: `cocoscreator_projects/YouleNexus/assets/framework/ui/{atlas-common,atlas-login,atlas-hall,atlas-room,standalone,spine,audio,font}/.gitkeep` - Create: `cocoscreator_projects/templates/game-seed/.gitignore` - Modify: `.gitignore`(仓库根) - Modify: `cocoscreator_projects/YouleNexus/.gitignore` **Interfaces:** - Consumes: 无(首个任务) - Produces: `framework/ui/` 八个资源目录,被 Task 3 的 `listAssets()` 与 Task 5 的 `composeSkin()` 扫描;`ui/README.md` 是资源归属规则的落地文档。 > **背景(已实测)**:根 `.gitignore:26` 的 `**/native/` 与 `YouleNexus/.gitignore:10` 的裸 `native`,都会静默吞掉 `assets/framework/**/native/` 下的任何文件;`local/` `profiles/` 同理。`sdk/native/` 正是后续原生桥接的落点。修复策略:**per-project `.gitignore` 负责本工程生成物(锚定路径),根 `.gitignore` 只负责仓库级条目**,职责单一(第二准则精神)。 - [ ] **Step 1: 建立 `framework/ui` 八个资源目录** 在 monorepo 根执行: ```bash cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects for d in atlas-common atlas-login atlas-hall atlas-room standalone spine audio font; do mkdir -p "YouleNexus/assets/framework/ui/$d" touch "YouleNexus/assets/framework/ui/$d/.gitkeep" done ``` - [ ] **Step 2: 写资源归属规则文档** 创建 `cocoscreator_projects/YouleNexus/assets/framework/ui/README.md`: ```markdown # framework/ui — 平台界面资源(框架真源) > 完整设计见 `docs/superpowers/specs/2026-08-27-ui-asset-and-skin-design.md`。 > 本文件是该 spec §2「资源归属规则」的落地速查,改动前先读 spec。 ## 唯一判定标准 **凡是放进 `framework/` 的,都会出现在每一个子游戏的包里。** ## 四条规则 1. **框架带完整一套默认皮肤**,不是占位图。依据:旧项目 444 个可比对平台资源中 73.9% 在子游戏间完全相同;框架不带默认,这部分就要在每个子游戏各存一份且改了不会传播。 2. **只放「所有子游戏都需要」的平台界面资源**。判定问句:*下一个子游戏还需要它吗?* 「部分子游戏共用」**不构成**进框架的理由——放进去等于让其余子游戏白背包体。 3. **目录划分 = 图集划分**。Cocos 的 Auto Atlas 只对同目录散图生效,故目录结构是性能设计: | 目录 | 用途 | |---|---| | `atlas-common/` | 全程常驻:按钮、面板底、图标、通用弹窗 | | `atlas-login/` / `atlas-hall/` / `atlas-room/` | 各场景专用,按加载时机切分 | | `standalone/` | 超图集尺寸上限的大图、整屏背景(不进图集) | | `spine/` | Spine 骨骼动画,**每个资源一个子目录**,含骨骼数据/atlas/贴图 | | `audio/` / `font/` | 平台音效 / 字体 | 4. **图片资源统一用 PNG**。非 PNG 图片不做尺寸校验(`scripts/lib/skin.mjs`),会削弱保护。 ## 子游戏怎么换肤 在 `games//assets/game/override/` 下放**同路径同名**文件即可,例如 `override/atlas-hall/btn_start.png` 覆盖 `framework/ui/atlas-hall/btn_start.png`。 - 只换图片内容,**不要带 `.meta`**(UUID 在 meta 里,换了会断引用) - 因此**替换图必须与默认图同尺寸**;确需改尺寸时才连 `.meta` 一起提供(例外通道,合成脚本会覆写 uuid) - Spine 必须**整套**覆盖,缺件报错;Spine 贴图豁免同尺寸约束 - 自检:`npm run check-skin ` **自定义 Prefab 不走 override**,放 `game/prefabs/`(组件替换通道,另见后续 spec)。 ``` - [ ] **Step 3: 修 `YouleNexus/.gitignore` —— 锚定工程生成物** 把 `cocoscreator_projects/YouleNexus/.gitignore` 中这段: ``` library/ temp/ local/ build/ profiles/ native ``` 替换为(加前导 `/` 锚定到工程根,加尾随 `/` 限定目录): ``` /library/ /temp/ /local/ /build/ /profiles/ /native/ ``` 其余条目(`node_modules/`、`.vscode/`、`.idea/`)保持不变。 - [ ] **Step 4: 给种子工程补 `.gitignore`** 创建 `cocoscreator_projects/templates/game-seed/.gitignore`(`new-game` 复制种子时会一并带给每个子游戏工程): ``` # Cocos Creator 工程生成物(锚定到本工程根,勿用无锚点模式——会吞掉 assets 下同名目录) /library/ /temp/ /local/ /build/ /profiles/ /native/ node_modules/ .vscode/ .idea/ ``` - [ ] **Step 5: 修根 `.gitignore` —— 移交职责并加 build-workspace** 把仓库根 `.gitignore` 中这段: ``` # ---------- Cocos Creator 工程生成物(可由编辑器重建,勿入库) ---------- # 适用于 cocoscreator_projects/ 下所有工程 **/library/ **/temp/ **/local/ **/build/ **/native/ **/profiles/ ``` 替换为: ``` # ---------- Cocos Creator 工程生成物 ---------- # 由各工程自己的 .gitignore 用锚定路径负责(YouleNexus/.gitignore、templates/game-seed/.gitignore)。 # 此处**不得**再写 **/library/ 这类无锚点模式——它会连 assets/framework 下的 # native/ local/ profiles/ 等业务目录一起吞掉(已踩过,见 plan 2026-08-27 Task 1)。 # 构建流水线的临时工程(build-game 产生,可随时重建) cocoscreator_projects/build-workspace/ ``` - [ ] **Step 6: 验证 gitignore 行为** Run: ```bash cd G:/Works/YouleGamesCocosCreator echo "--- 应【不】被忽略(业务目录)---" git check-ignore -v cocoscreator_projects/YouleNexus/assets/framework/sdk/native/bridge.ts || echo "OK: native 未被吞" git check-ignore -v cocoscreator_projects/YouleNexus/assets/framework/ui/local/x.png || echo "OK: local 未被吞" git check-ignore -v cocoscreator_projects/YouleNexus/assets/framework/config/profiles/y.ts || echo "OK: profiles 未被吞" echo "--- 应【被】忽略(工程生成物)---" git check-ignore cocoscreator_projects/YouleNexus/library/x && echo "OK: library 已忽略" git check-ignore cocoscreator_projects/build-workspace/demo/x && echo "OK: build-workspace 已忽略" ``` Expected: 前三行各打印 `OK: … 未被吞`(`git check-ignore` 无匹配时退出码 1,故 `||` 分支生效);后两行各打印 `OK: … 已忽略`。 - [ ] **Step 7: 验证新建子游戏工程继承了 .gitignore** Run: ```bash cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects node scripts/new-game.mjs gitignore-probe && \ test -f games/gitignore-probe/.gitignore && echo "OK: 种子 .gitignore 已随克隆带入" && \ cd G:/Works/YouleGamesCocosCreator && \ git check-ignore cocoscreator_projects/games/gitignore-probe/library/x && echo "OK: 新工程 library 已忽略" ; \ node -e "require('fs').rmSync('cocoscreator_projects/games/gitignore-probe', { recursive: true, force: true })" ``` Expected: 打印 `OK: 种子 .gitignore 已随克隆带入` 与 `OK: 新工程 library 已忽略`;探针工程已删除。 - [ ] **Step 8: Commit** ```bash cd G:/Works/YouleGamesCocosCreator git add cocoscreator_projects/YouleNexus/assets/framework/ui \ cocoscreator_projects/YouleNexus/.gitignore \ cocoscreator_projects/templates/game-seed/.gitignore \ .gitignore git commit -m "feat(ui): 资源归属骨架 + 修复 .gitignore 无锚点模式吞业务目录 framework/ui 建八个资源目录(按加载时机=图集划分)并落地归属规则文档。 根 .gitignore 的 **/native/ 与 YouleNexus 的裸 native 会静默吞掉 assets/framework/**/native/ 等业务目录; 改为各工程 .gitignore 用锚定 路径负责生成物, 根只管仓库级条目。种子补 .gitignore 使新子游戏继承。 Co-Authored-By: Claude Opus 5 (1M context) " ``` --- ### Task 2: 承重假设实测(Auto Atlas / meta+尺寸 / Spine) > **非 TDD 的验证任务,但必须排在工具开发之前。** spec §6 的三条待实测项是整个构建期合成方案的承重点;若 Auto Atlas 不在构建期打包,Task 3–6 的设计需推倒重来。先验证,再建在上面。 > > **前置条件**:需要 Cocos Creator 3.8.8 打开 `YouleNexus` 且 funplay-cocos-mcp 服务可用(`curl http://127.0.0.1:8765/health`)。所有编辑器操作**必须**走 `mcp__funplay_cocos__*` 工具,**绝不允许手改** `.meta`/`.pac`(CLAUDE.md)。MCP 连不上时提示用户去编辑器启用扩展,不得退而求其次手改文件。 **Files:** - Create: `cocoscreator_projects/YouleNexus/assets/framework/ui/atlas-hall/probe_a.png`(验证素材,程序生成) - Create: `cocoscreator_projects/YouleNexus/assets/framework/ui/atlas-hall/probe_b.png`(验证素材,程序生成) - Modify: `docs/superpowers/specs/2026-08-27-ui-asset-and-skin-design.md` §6(回填实测结论) **Interfaces:** - Consumes: Task 1 的 `framework/ui/atlas-hall/` 目录 - Produces: spec §6 表格的「实测结论」列;若任一项失败,则**触发 spec 中对应的备选方案**并回到 brainstorming 修订设计,不得带着未验证假设继续 Task 3。 - [ ] **Step 1: 生成两张验证用 PNG(同尺寸)** Run(零依赖生成 64×64 纯色 PNG): ```bash cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects node -e ' const fs=require("fs"),zlib=require("zlib"); function crc(b){let c=~0;for(const x of b){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(crc(td));return Buffer.concat([len,td,c]);} function png(w,h,rgb){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>>1)^(0xEDB88320&-(c&1));}return ~c>>>0;} function chunk(t,d){const l=Buffer.alloc(4);l.writeUInt32BE(d.length);const td=Buffer.concat([Buffer.from(t,"ascii"),d]);const c=Buffer.alloc(4);c.writeUInt32BE(crc(td));return Buffer.concat([l,td,c]);} function png(w,h,rgb){const i=Buffer.alloc(13);i.writeUInt32BE(w,0);i.writeUInt32BE(h,4);i[8]=8;i[9]=2; const raw=Buffer.alloc(h*(1+w*3));for(let y=0;y **已核实:本仓库没有任何可用 Spine 素材**——`projects/*/assets/spine/` 全部为空。故本步需要外部素材。 若手头有一套 Spine 导出资源(骨骼数据 + `.atlas` + 贴图),放入 `framework/ui/spine/probe/`,用 MCP 导入后整套替换内容并检查引用是否自洽; **若暂无素材**:在 spec §6 表格第 4 行记录「⏸ 阻塞:仓库无 Spine 素材,待第一套真实 Spine 资源到位后验证」,并在 `framework/ui/README.md` 的 Spine 段落加一行提示。**不得**因未验证而假定其可行。 - [ ] **Step 7: 清理验证素材** Run: ```bash cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects rm -f YouleNexus/assets/framework/ui/atlas-hall/probe_*.png YouleNexus/assets/framework/ui/atlas-hall/probe_*.png.meta ``` 然后 MCP `cocos_project → refresh_assets` 让编辑器同步删除。 > `.pac` 自动图集资源**保留**——它是 `atlas-hall` 的正式配置,Task 5 之后的构建都要用。 - [ ] **Step 8: 回填 spec 并 Commit** 把 Step 3/5/6 的结论写进 spec §6 表格(新增一列「实测结论」,注明日期)。 ```bash cd G:/Works/YouleGamesCocosCreator git add docs/superpowers/specs/2026-08-27-ui-asset-and-skin-design.md \ cocoscreator_projects/YouleNexus/assets/framework/ui git commit -m "test(ui): 实测构建期合成的三条承重假设并回填 spec Auto Atlas 构建期打包 / meta 保留 uuid 后尺寸变化的表现 / Spine 整套替换。 结论写入 spec §6, 未验证项如实标注阻塞, 不假定可行。 Co-Authored-By: Claude Opus 5 (1M context) " ``` --- ### Task 3: `lib/skin.mjs` —— 资源清单与 override 校验 **Files:** - Modify: `cocoscreator_projects/scripts/lib/paths.mjs` - Create: `cocoscreator_projects/scripts/test/skin.test.mjs` - Create: `cocoscreator_projects/scripts/lib/skin.mjs` **Interfaces:** - Consumes: Task 1 的 `framework/ui/` 目录结构;`paths.mjs` 现有的 `HOST_PROJECT`/`GAMES_DIR`/`listGameProjects` - Produces: - `paths.mjs`: `FRAMEWORK_UI_DIR: string`、`BUILD_WORKSPACE: string`、`gameOverrideDir(gameDir: string): string` - `skin.mjs`: `listAssets(dir: string): string[]`(相对路径,正斜杠分隔,排除 `.meta`/`.gitkeep`)、`readPngSize(file: string): {width:number,height:number}|null`、`validateOverrides(frameworkUiDir: string, overrideDir: string): {matched: string[], errors: Array<{path:string,code:string,message:string}>}` - 错误码:`'orphan'` | `'size-mismatch'` | `'spine-incomplete'` - [ ] **Step 1: 扩充 `paths.mjs`** 在 `cocoscreator_projects/scripts/lib/paths.mjs` 的常量区(`SEED_DIR` 之后)追加: ```js export const FRAMEWORK_UI_DIR = join(FRAMEWORK_SRC, 'ui'); export const BUILD_WORKSPACE = join(ROOT, 'build-workspace'); // 子游戏的皮肤覆盖目录 export function gameOverrideDir(projectDir) { return join(projectDir, 'assets', 'game', 'override'); } ``` - [ ] **Step 2: 写失败测试** 创建 `cocoscreator_projects/scripts/test/skin.test.mjs`: ```js import { test } from 'node:test'; import assert from 'node:assert/strict'; import { join } from 'node:path'; import { mkdirSync, writeFileSync, rmSync, mkdtempSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { deflateSync } from 'node:zlib'; import { listAssets, readPngSize, validateOverrides } from '../lib/skin.mjs'; // ---- 测试辅助:零依赖生成指定尺寸的 PNG ---- 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) { 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)); 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)), ]); } function writeFile(dir, rel, content) { const p = join(dir, ...rel.split('/')); mkdirSync(join(p, '..'), { recursive: true }); writeFileSync(p, content); return p; } function png(dir, rel, w = 64, h = 64) { return writeFile(dir, rel, makePng(w, h)); } function makeDirs() { const root = mkdtempSync(join(tmpdir(), 'youle-skin-')); const fw = join(root, 'ui'); const ov = join(root, 'override'); mkdirSync(fw, { recursive: true }); mkdirSync(ov, { recursive: true }); return { root, fw, ov }; } function cleanup(root) { rmSync(root, { recursive: true, force: true }); } // ---- listAssets ---- test('listAssets 返回正斜杠相对路径,排除 .meta 与 .gitkeep', () => { const d = makeDirs(); try { png(d.fw, 'atlas-hall/btn.png'); writeFile(d.fw, 'atlas-hall/btn.png.meta', '{}'); writeFile(d.fw, 'audio/.gitkeep', ''); writeFile(d.fw, 'audio/click.wav', 'x'); assert.deepEqual(listAssets(d.fw).sort(), ['atlas-hall/btn.png', 'audio/click.wav']); } finally { cleanup(d.root); } }); test('listAssets 对不存在的目录返回空数组', () => { assert.deepEqual(listAssets('/no/such/dir'), []); }); // ---- readPngSize ---- test('readPngSize 读出宽高;非 PNG 返回 null', () => { const d = makeDirs(); try { const p = png(d.fw, 'a.png', 128, 64); assert.deepEqual(readPngSize(p), { width: 128, height: 64 }); const w = writeFile(d.fw, 'b.wav', 'not a png'); assert.equal(readPngSize(w), null); } finally { cleanup(d.root); } }); // ---- validateOverrides:正常命中 ---- test('同名同尺寸图片:命中,无错误', () => { const d = makeDirs(); try { png(d.fw, 'atlas-hall/btn.png', 64, 64); png(d.ov, 'atlas-hall/btn.png', 64, 64); const r = validateOverrides(d.fw, d.ov); assert.deepEqual(r.errors, []); assert.deepEqual(r.matched, ['atlas-hall/btn.png']); } finally { cleanup(d.root); } }); test('框架有、子游戏未覆盖:不是错误', () => { const d = makeDirs(); try { png(d.fw, 'atlas-hall/a.png'); png(d.fw, 'atlas-hall/b.png'); png(d.ov, 'atlas-hall/a.png'); const r = validateOverrides(d.fw, d.ov); assert.deepEqual(r.errors, []); assert.deepEqual(r.matched, ['atlas-hall/a.png']); } finally { cleanup(d.root); } }); // ---- validateOverrides:孤儿 ---- test('override 在框架里找不到对应路径 → orphan', () => { const d = makeDirs(); try { png(d.fw, 'atlas-hall/btn.png'); png(d.ov, 'atlas-hall/typo.png'); const r = validateOverrides(d.fw, d.ov); assert.equal(r.errors.length, 1); assert.equal(r.errors[0].code, 'orphan'); assert.equal(r.errors[0].path, 'atlas-hall/typo.png'); } finally { cleanup(d.root); } }); test('同名不同扩展名 → orphan,且提示框架里的实际扩展名', () => { const d = makeDirs(); try { png(d.fw, 'atlas-hall/btn.png'); writeFile(d.ov, 'atlas-hall/btn.jpg', 'x'); const r = validateOverrides(d.fw, d.ov); assert.equal(r.errors[0].code, 'orphan'); assert.match(r.errors[0].message, /btn\.png/); } finally { cleanup(d.root); } }); // ---- validateOverrides:尺寸 ---- test('图片尺寸不同且未带 meta → size-mismatch', () => { const d = makeDirs(); try { png(d.fw, 'atlas-hall/btn.png', 64, 64); png(d.ov, 'atlas-hall/btn.png', 128, 128); const r = validateOverrides(d.fw, d.ov); assert.equal(r.errors.length, 1); assert.equal(r.errors[0].code, 'size-mismatch'); assert.match(r.errors[0].message, /64x64/); assert.match(r.errors[0].message, /128x128/); } finally { cleanup(d.root); } }); test('图片尺寸不同但自带 meta(例外通道)→ 通过', () => { const d = makeDirs(); try { png(d.fw, 'atlas-hall/btn.png', 64, 64); png(d.ov, 'atlas-hall/btn.png', 128, 128); writeFile(d.ov, 'atlas-hall/btn.png.meta', '{"ver":"1.0.0"}'); const r = validateOverrides(d.fw, d.ov); assert.deepEqual(r.errors, []); assert.deepEqual(r.matched, ['atlas-hall/btn.png']); } finally { cleanup(d.root); } }); test('非图片资源(音效)不做尺寸校验', () => { const d = makeDirs(); try { writeFile(d.fw, 'audio/click.wav', 'aaaa'); writeFile(d.ov, 'audio/click.wav', 'bbbbbbbbbbbb'); const r = validateOverrides(d.fw, d.ov); assert.deepEqual(r.errors, []); } finally { cleanup(d.root); } }); // ---- validateOverrides:Spine ---- test('Spine 整套覆盖 → 通过,且贴图豁免尺寸校验', () => { const d = makeDirs(); try { writeFile(d.fw, 'spine/hero/hero.json', '{}'); writeFile(d.fw, 'spine/hero/hero.atlas', 'atlas'); png(d.fw, 'spine/hero/hero.png', 64, 64); writeFile(d.ov, 'spine/hero/hero.json', '{"v":2}'); writeFile(d.ov, 'spine/hero/hero.atlas', 'atlas2'); png(d.ov, 'spine/hero/hero.png', 256, 256); // 尺寸不同也应通过 const r = validateOverrides(d.fw, d.ov); assert.deepEqual(r.errors, []); assert.equal(r.matched.length, 3); } finally { cleanup(d.root); } }); test('Spine 缺件 → spine-incomplete,并列出缺少的文件', () => { const d = makeDirs(); try { writeFile(d.fw, 'spine/hero/hero.json', '{}'); writeFile(d.fw, 'spine/hero/hero.atlas', 'atlas'); png(d.fw, 'spine/hero/hero.png', 64, 64); png(d.ov, 'spine/hero/hero.png', 64, 64); // 只换贴图 const r = validateOverrides(d.fw, d.ov); assert.equal(r.errors.length, 1); assert.equal(r.errors[0].code, 'spine-incomplete'); assert.match(r.errors[0].message, /hero\.json/); assert.match(r.errors[0].message, /hero\.atlas/); } finally { cleanup(d.root); } }); ``` - [ ] **Step 3: 运行测试确认失败** Run: `cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects && node --test scripts/test/skin.test.mjs` Expected: FAIL — 报 `Cannot find module '../lib/skin.mjs'` - [ ] **Step 4: 实现 `lib/skin.mjs`** 创建 `cocoscreator_projects/scripts/lib/skin.mjs`: ```js import { readdirSync, statSync, readFileSync, existsSync } from 'node:fs'; import { join, posix, sep, extname, relative } from 'node:path'; const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.webp']); const SPINE_ROOT = 'spine'; // framework/ui/spine/<资源名>/ 为一套 Spine const SKIPPED = new Set(['.gitkeep']); /** 列出目录下所有资源的相对路径(正斜杠),排除 .meta 与 .gitkeep。 */ export function listAssets(dir) { if (!existsSync(dir)) return []; const out = []; (function walk(cur) { for (const name of readdirSync(cur)) { const p = join(cur, name); if (statSync(p).isDirectory()) { walk(p); continue; } if (name.endsWith('.meta') || SKIPPED.has(name)) continue; out.push(relative(dir, p).split(sep).join(posix.sep)); } })(dir); return out; } /** 读 PNG 宽高;非 PNG 或读取失败返回 null。 */ export function readPngSize(file) { try { const b = readFileSync(file); if (b.length < 24 || b.toString('ascii', 1, 4) !== 'PNG') return null; return { width: b.readUInt32BE(16), height: b.readUInt32BE(20) }; } catch { return null; } } const isImage = (rel) => IMAGE_EXTS.has(extname(rel).toLowerCase()); const isSpine = (rel) => rel.split(posix.sep)[0] === SPINE_ROOT; /** Spine 资源的分组键:spine/<资源名> */ const spineGroup = (rel) => rel.split(posix.sep).slice(0, 2).join(posix.sep); /** * 校验 override 是否合法。规则见 spec §3.2。 * 返回 { matched: 相对路径[], errors: {path, code, message}[] }。 */ export function validateOverrides(frameworkUiDir, overrideDir) { const fwList = listAssets(frameworkUiDir); const fwSet = new Set(fwList); const ovList = listAssets(overrideDir); const ovSet = new Set(ovList); const matched = []; const errors = []; // 1) 逐个 override 校验:孤儿 / 尺寸 for (const rel of ovList) { if (!fwSet.has(rel)) { const base = rel.slice(0, rel.length - extname(rel).length); const alt = fwList.find((f) => f.slice(0, f.length - extname(f).length) === base); errors.push({ path: rel, code: 'orphan', message: alt ? `框架里没有 ${rel},但存在同名的 ${alt}——扩展名对不上` : `框架里没有对应资源:${rel}`, }); continue; } // Spine 贴图豁免尺寸校验(区域坐标在 .atlas 内,整套替换时自洽) // 注意:override 是否自带 .meta 必须用 existsSync 查磁盘——listAssets 排除了 .meta, // 故 ovSet 里永远没有 .meta 项,用 ovSet.has 会让例外通道永不生效。 const metaPath = join(overrideDir, ...`${rel}.meta`.split(posix.sep)); const needSizeCheck = isImage(rel) && !isSpine(rel) && !existsSync(metaPath); if (needSizeCheck) { const a = readPngSize(join(frameworkUiDir, ...rel.split(posix.sep))); const b = readPngSize(join(overrideDir, ...rel.split(posix.sep))); if (a && b && (a.width !== b.width || a.height !== b.height)) { errors.push({ path: rel, code: 'size-mismatch', message: `尺寸不一致:框架 ${a.width}x${a.height},override ${b.width}x${b.height}。` + `请改为同尺寸,或连 .meta 一起提供(例外通道)`, }); continue; } } matched.push(rel); } // 2) Spine 整套完整性:override 动了某套,就必须整套都在 const touchedGroups = new Set(ovList.filter(isSpine).map(spineGroup)); for (const g of touchedGroups) { const required = fwList.filter((f) => isSpine(f) && spineGroup(f) === g); const missing = required.filter((f) => !ovSet.has(f)); if (missing.length) { errors.push({ path: g, code: 'spine-incomplete', message: `Spine 必须整套覆盖,缺少:${missing.join('、')}`, }); } } return { matched, errors }; } ``` - [ ] **Step 5: 运行测试确认通过** Run: `cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects && node --test scripts/test/skin.test.mjs` Expected: PASS — 12 tests passed - [ ] **Step 6: Commit** ```bash cd G:/Works/YouleGamesCocosCreator git add cocoscreator_projects/scripts/lib/skin.mjs \ cocoscreator_projects/scripts/lib/paths.mjs \ cocoscreator_projects/scripts/test/skin.test.mjs git commit -m "feat(tools): skin.mjs 资源清单与 override 校验 框架文件树即可覆盖清单(不另建清单文件)。孤儿/尺寸不一致/Spine 缺件 一律报错中止, 不静默跳过。Spine 贴图豁免尺寸校验(坐标在 .atlas 内)。 Co-Authored-By: Claude Opus 5 (1M context) " ``` --- ### Task 4: `check-skin.mjs` —— 独立校验 CLI **Files:** - Create: `cocoscreator_projects/scripts/test/check-skin.test.mjs` - Create: `cocoscreator_projects/scripts/check-skin.mjs` - Modify: `cocoscreator_projects/package.json` **Interfaces:** - Consumes: `skin.mjs` 的 `validateOverrides`;`paths.mjs` 的 `FRAMEWORK_UI_DIR`/`GAMES_DIR`/`gameOverrideDir` - Produces: `checkSkin(gameName: string, opts?: {gamesDir?: string, frameworkUiDir?: string}): {matched: string[], errors: Array<{path:string,code:string,message:string}>, frameworkTotal: number}`;CLI 有错时退出码 1 - [ ] **Step 1: 写失败测试** 创建 `cocoscreator_projects/scripts/test/check-skin.test.mjs`: ```js import { test } from 'node:test'; import assert from 'node:assert/strict'; import { join } from 'node:path'; import { mkdirSync, writeFileSync, rmSync, mkdtempSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { deflateSync } from 'node:zlib'; import { checkSkin } from '../check-skin.mjs'; 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) { 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)); 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)), ]); } function put(dir, rel, content) { const p = join(dir, ...rel.split('/')); mkdirSync(join(p, '..'), { recursive: true }); writeFileSync(p, content); } function scaffold() { const root = mkdtempSync(join(tmpdir(), 'youle-checkskin-')); const frameworkUiDir = join(root, 'ui'); const gamesDir = join(root, 'games'); mkdirSync(frameworkUiDir, { recursive: true }); mkdirSync(join(gamesDir, 'demo', 'assets', 'game', 'override'), { recursive: true }); put(frameworkUiDir, 'atlas-hall/btn.png', makePng(64, 64)); put(frameworkUiDir, 'atlas-hall/bg.png', makePng(64, 64)); return { root, frameworkUiDir, gamesDir, overrideDir: join(gamesDir, 'demo', 'assets', 'game', 'override') }; } test('checkSkin 统计命中数与框架资源总数', () => { const s = scaffold(); try { put(s.overrideDir, 'atlas-hall/btn.png', makePng(64, 64)); const r = checkSkin('demo', { gamesDir: s.gamesDir, frameworkUiDir: s.frameworkUiDir }); assert.deepEqual(r.errors, []); assert.deepEqual(r.matched, ['atlas-hall/btn.png']); assert.equal(r.frameworkTotal, 2); } finally { rmSync(s.root, { recursive: true, force: true }); } }); test('checkSkin 报出孤儿 override', () => { const s = scaffold(); try { put(s.overrideDir, 'atlas-hall/nope.png', makePng(64, 64)); const r = checkSkin('demo', { gamesDir: s.gamesDir, frameworkUiDir: s.frameworkUiDir }); assert.equal(r.errors.length, 1); assert.equal(r.errors[0].code, 'orphan'); } finally { rmSync(s.root, { recursive: true, force: true }); } }); test('checkSkin 对不存在的子游戏抛错', () => { const s = scaffold(); try { assert.throws( () => checkSkin('nosuch', { gamesDir: s.gamesDir, frameworkUiDir: s.frameworkUiDir }), /找不到子游戏工程/, ); } finally { rmSync(s.root, { recursive: true, force: true }); } }); test('checkSkin 对没有 override 目录的子游戏返回零命中而非报错', () => { const s = scaffold(); try { rmSync(s.overrideDir, { recursive: true, force: true }); const r = checkSkin('demo', { gamesDir: s.gamesDir, frameworkUiDir: s.frameworkUiDir }); assert.deepEqual(r.matched, []); assert.deepEqual(r.errors, []); } finally { rmSync(s.root, { recursive: true, force: true }); } }); ``` - [ ] **Step 2: 运行测试确认失败** Run: `cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects && node --test scripts/test/check-skin.test.mjs` Expected: FAIL — 报 `Cannot find module '../check-skin.mjs'` - [ ] **Step 3: 实现 `check-skin.mjs`** 创建 `cocoscreator_projects/scripts/check-skin.mjs`: ```js #!/usr/bin/env node import { join } from 'node:path'; import { existsSync } from 'node:fs'; import { pathToFileURL } from 'node:url'; import { FRAMEWORK_UI_DIR, GAMES_DIR, gameOverrideDir } from './lib/paths.mjs'; import { listAssets, validateOverrides } from './lib/skin.mjs'; /** 校验某子游戏的皮肤覆盖。opts 可注入路径用于测试。 */ export function checkSkin(gameName, opts = {}) { const gamesDir = opts.gamesDir ?? GAMES_DIR; const frameworkUiDir = opts.frameworkUiDir ?? FRAMEWORK_UI_DIR; const projectDir = join(gamesDir, gameName); if (!existsSync(projectDir)) { throw new Error(`找不到子游戏工程:${projectDir}`); } // override 目录不存在 = 该子游戏不换肤,合法 const { matched, errors } = validateOverrides(frameworkUiDir, gameOverrideDir(projectDir)); return { matched, errors, frameworkTotal: listAssets(frameworkUiDir).length }; } // CLI 入口:有错误则退出码 1 if (import.meta.url === pathToFileURL(process.argv[1]).href) { const name = process.argv[2]; if (!name) { console.error('用法: npm run check-skin '); process.exit(1); } const { matched, errors, frameworkTotal } = checkSkin(name); console.log(`皮肤命中 ${matched.length} 处,框架可覆盖资源共 ${frameworkTotal} 个`); if (errors.length === 0) { console.log('OK: override 全部合法。'); process.exit(0); } console.error(`\n发现 ${errors.length} 处问题(子游戏 ${name}):`); for (const e of errors) { console.error(` [${e.code}] ${e.path}\n ${e.message}`); } process.exit(1); } ``` - [ ] **Step 4: 运行测试确认通过** Run: `cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects && node --test scripts/test/check-skin.test.mjs` Expected: PASS — 4 tests passed - [ ] **Step 5: 注册 npm script** 在 `cocoscreator_projects/package.json` 的 `scripts` 中,`"bump-cocos"` 那行之后加一行: ```json "check-skin": "node scripts/check-skin.mjs", ``` - [ ] **Step 6: Commit** ```bash cd G:/Works/YouleGamesCocosCreator git add cocoscreator_projects/scripts/check-skin.mjs \ cocoscreator_projects/scripts/test/check-skin.test.mjs \ cocoscreator_projects/package.json git commit -m "feat(tools): check-skin 独立校验 CLI 脱离构建即可自检 override, 秒级反馈; 有错退出码 1 并逐条指出 子游戏名+文件路径+原因(第二准则: 错误早暴露、可定位)。 Co-Authored-By: Claude Opus 5 (1M context) " ``` --- ### Task 5: `lib/materialize.mjs` —— 实体化与皮肤合成 **Files:** - Create: `cocoscreator_projects/scripts/test/materialize.test.mjs` - Create: `cocoscreator_projects/scripts/lib/materialize.mjs` **Interfaces:** - Consumes: `skin.mjs` 的 `validateOverrides`/`listAssets` - Produces: - `materialize(gameDir: string, frameworkSrc: string, destDir: string): void` —— 拷贝子游戏工程到 destDir,其中 `assets/framework` 由 junction 变真实目录拷贝,跳过缓存目录 - `composeSkin(destDir: string): {matched: string[], frameworkTotal: number}` —— 把 `assets/game/override/**` 合成到 `assets/framework/ui/**`;校验不过则 `throw SkinValidationError` - `class SkinValidationError extends Error`(含 `errors` 属性) - [ ] **Step 1: 写失败测试** 创建 `cocoscreator_projects/scripts/test/materialize.test.mjs`: ```js 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 { materialize, composeSkin, SkinValidationError } from '../lib/materialize.mjs'; const LINK_TYPE = platform === 'win32' ? 'junction' : 'dir'; function put(dir, rel, content) { const p = join(dir, ...rel.split('/')); mkdirSync(join(p, '..'), { recursive: true }); writeFileSync(p, content); } function read(dir, rel) { return readFileSync(join(dir, ...rel.split('/')), 'utf8'); } /** 造一个「框架真源 + 挂 junction 的子游戏工程」的临时环境 */ function scaffold() { const root = mkdtempSync(join(tmpdir(), 'youle-mat-')); const frameworkSrc = join(root, 'nexus', 'assets', 'framework'); mkdirSync(frameworkSrc, { recursive: true }); put(frameworkSrc, 'ui/atlas-hall/btn.png', 'DEFAULT-BTN'); put(frameworkSrc, 'ui/atlas-hall/btn.png.meta', '{"uuid":"fw-btn-uuid"}'); put(frameworkSrc, 'ui/atlas-hall/bg.png', 'DEFAULT-BG'); put(frameworkSrc, 'core/net.ts', 'export const x=1;'); const gameDir = join(root, 'games', 'demo'); mkdirSync(join(gameDir, 'assets'), { recursive: true }); writeFileSync(join(gameDir, 'package.json'), JSON.stringify({ name: 'demo', creator: { version: '3.8.8' } })); symlinkSync(frameworkSrc, join(gameDir, 'assets', 'framework'), LINK_TYPE); put(gameDir, 'assets/game/theme.ts', 'export const theme={};'); mkdirSync(join(gameDir, 'library'), { recursive: true }); // 缓存目录,应被跳过 writeFileSync(join(gameDir, 'library', 'junk'), 'x'); return { root, frameworkSrc, gameDir, dest: join(root, 'workspace', 'demo') }; } function cleanup(root) { rmSync(root, { recursive: true, force: true }); } test('materialize 把 junction 变成真实目录拷贝,并跳过缓存目录', () => { const s = scaffold(); try { materialize(s.gameDir, s.frameworkSrc, s.dest); assert.equal(read(s.dest, 'assets/framework/ui/atlas-hall/btn.png'), 'DEFAULT-BTN'); assert.equal(read(s.dest, 'assets/game/theme.ts'), 'export const theme={};'); assert.ok(!existsSync(join(s.dest, 'library')), 'library 应被跳过'); } finally { cleanup(s.root); } }); test('materialize 只跳过工程根的缓存目录,不吞 assets 下的同名业务目录', () => { const s = scaffold(); try { put(s.gameDir, 'assets/game/native/bridge.ts', 'BRIDGE'); materialize(s.gameDir, s.frameworkSrc, s.dest); assert.equal(read(s.dest, 'assets/game/native/bridge.ts'), 'BRIDGE', 'assets 下的 native 是业务目录,必须保留'); assert.ok(!existsSync(join(s.dest, 'library')), '工程根的 library 仍应被跳过'); } finally { cleanup(s.root); } }); test('materialize 后写临时工程不影响框架真源', () => { const s = scaffold(); try { materialize(s.gameDir, s.frameworkSrc, s.dest); writeFileSync(join(s.dest, 'assets', 'framework', 'ui', 'atlas-hall', 'btn.png'), 'MUTATED'); assert.equal(read(s.frameworkSrc, 'ui/atlas-hall/btn.png'), 'DEFAULT-BTN', '真源必须只读'); } finally { cleanup(s.root); } }); test('materialize 拒绝覆盖已存在的目标目录', () => { const s = scaffold(); try { materialize(s.gameDir, s.frameworkSrc, s.dest); assert.throws(() => materialize(s.gameDir, s.frameworkSrc, s.dest), /已存在/); } finally { cleanup(s.root); } }); test('composeSkin 用 override 覆盖框架资源内容,保留框架 .meta', () => { const s = scaffold(); try { put(s.gameDir, 'assets/game/override/atlas-hall/btn.png', 'SKINNED-BTN'); materialize(s.gameDir, s.frameworkSrc, s.dest); const r = composeSkin(s.dest); assert.deepEqual(r.matched, ['atlas-hall/btn.png']); assert.equal(r.frameworkTotal, 2); assert.equal(read(s.dest, 'assets/framework/ui/atlas-hall/btn.png'), 'SKINNED-BTN'); assert.equal(read(s.dest, 'assets/framework/ui/atlas-hall/btn.png.meta'), '{"uuid":"fw-btn-uuid"}'); assert.equal(read(s.dest, 'assets/framework/ui/atlas-hall/bg.png'), 'DEFAULT-BG', '未覆盖的保持默认'); } finally { cleanup(s.root); } }); test('composeSkin 例外通道:override 自带 meta 时,uuid 被改回框架的值', () => { const s = scaffold(); try { put(s.gameDir, 'assets/game/override/atlas-hall/btn.png', 'SKINNED-BTN'); put(s.gameDir, 'assets/game/override/atlas-hall/btn.png.meta', '{"uuid":"game-own-uuid","ver":"1.0.0"}'); materialize(s.gameDir, s.frameworkSrc, s.dest); composeSkin(s.dest); const meta = JSON.parse(read(s.dest, 'assets/framework/ui/atlas-hall/btn.png.meta')); assert.equal(meta.uuid, 'fw-btn-uuid', 'uuid 必须沿用框架的'); assert.equal(meta.ver, '1.0.0', 'override meta 的其余字段保留'); } finally { cleanup(s.root); } }); test('composeSkin 校验不过时抛 SkinValidationError 且不写任何文件', () => { const s = scaffold(); try { put(s.gameDir, 'assets/game/override/atlas-hall/typo.png', 'X'); materialize(s.gameDir, s.frameworkSrc, s.dest); assert.throws(() => composeSkin(s.dest), SkinValidationError); assert.equal(read(s.dest, 'assets/framework/ui/atlas-hall/btn.png'), 'DEFAULT-BTN', '失败时不得部分写入'); } finally { cleanup(s.root); } }); ``` - [ ] **Step 2: 运行测试确认失败** Run: `cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects && node --test scripts/test/materialize.test.mjs` Expected: FAIL — 报 `Cannot find module '../lib/materialize.mjs'` - [ ] **Step 3: 实现 `lib/materialize.mjs`** 创建 `cocoscreator_projects/scripts/lib/materialize.mjs`: ```js import { cpSync, mkdirSync, existsSync, readFileSync, writeFileSync, copyFileSync } from 'node:fs'; import { join, posix, dirname, relative, sep } from 'node:path'; import { listAssets, validateOverrides } from './skin.mjs'; /** * 工程缓存目录:实体化时跳过。 * **只匹配工程根的直接子目录**——绝不可按目录名在任意层级匹配,否则会连 * `assets/game/native/` 这类业务目录一起吞掉(与 .gitignore 无锚点模式同一类错误,见 Task 1)。 */ const CACHE_DIRS = new Set(['library', 'temp', 'local', 'build', 'native', 'profiles', 'node_modules']); export class SkinValidationError extends Error { constructor(errors) { super(`皮肤覆盖校验未通过(${errors.length} 处问题)`); this.name = 'SkinValidationError'; this.errors = errors; } } /** * 把子游戏工程实体化到 destDir: * `assets/framework` 由 junction 变成对 frameworkSrc 的真实目录拷贝;跳过缓存目录。 * **frameworkSrc 全程只读**(spec §5.1 ①)。 */ export function materialize(gameDir, frameworkSrc, destDir) { if (existsSync(destDir)) throw new Error(`目标已存在:${destDir}`); mkdirSync(destDir, { recursive: true }); // 1) 拷工程本体,跳过缓存目录与 assets/framework(后者单独从真源拷) const frameworkLink = join(gameDir, 'assets', 'framework'); cpSync(gameDir, destDir, { recursive: true, dereference: true, filter: (src) => { if (src === frameworkLink) return false; const rel = relative(gameDir, src); if (!rel) return true; // gameDir 自身 return !CACHE_DIRS.has(rel.split(sep)[0]); // 只看第一段:锚定到工程根 }, }); // 2) 从真源实体化 framework(不依赖 junction 存在,spec §5.1 ②) cpSync(frameworkSrc, join(destDir, 'assets', 'framework'), { recursive: true, dereference: true }); } /** * 把临时工程内的 `assets/game/override/**` 合成到 `assets/framework/ui/**`。 * 先整体校验,不通过则抛错且**不写任何文件**(避免半成品)。 */ export function composeSkin(destDir) { const frameworkUiDir = join(destDir, 'assets', 'framework', 'ui'); const overrideDir = join(destDir, 'assets', 'game', 'override'); const { matched, errors } = validateOverrides(frameworkUiDir, overrideDir); if (errors.length) throw new SkinValidationError(errors); for (const rel of matched) { const parts = rel.split(posix.sep); const from = join(overrideDir, ...parts); const to = join(frameworkUiDir, ...parts); mkdirSync(dirname(to), { recursive: true }); copyFileSync(from, to); // 例外通道:override 自带 meta 时,把 uuid 改回框架原值以保持引用 const ovMeta = `${from}.meta`; const fwMeta = `${to}.meta`; if (existsSync(ovMeta) && existsSync(fwMeta)) { const fwUuid = JSON.parse(readFileSync(fwMeta, 'utf8')).uuid; const merged = JSON.parse(readFileSync(ovMeta, 'utf8')); merged.uuid = fwUuid; writeFileSync(fwMeta, `${JSON.stringify(merged, null, 2)}\n`); } } return { matched, frameworkTotal: listAssets(frameworkUiDir).length }; } ``` - [ ] **Step 4: 运行测试确认通过** Run: `cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects && node --test scripts/test/materialize.test.mjs` Expected: PASS — 7 tests passed - [ ] **Step 5: Commit** ```bash cd G:/Works/YouleGamesCocosCreator git add cocoscreator_projects/scripts/lib/materialize.mjs \ cocoscreator_projects/scripts/test/materialize.test.mjs git commit -m "feat(tools): materialize 实体化与皮肤合成 junction 变真实拷贝(发布链路不依赖 junction), 框架真源全程只读。 合成先整体校验再写, 不产生半成品; 例外通道把 override meta 的 uuid 改回框架原值以保持 Prefab 引用不断。 Co-Authored-By: Claude Opus 5 (1M context) " ``` --- ### Task 6: `build-game.mjs` —— 构建流水线 CLI **Files:** - Create: `cocoscreator_projects/scripts/test/build-game.test.mjs` - Create: `cocoscreator_projects/scripts/build-game.mjs` - Modify: `cocoscreator_projects/package.json` **Interfaces:** - Consumes: `materialize.mjs` 的 `materialize`/`composeSkin`;`paths.mjs` 的 `HOST_PROJECT`/`GAMES_DIR`/`FRAMEWORK_SRC`/`BUILD_WORKSPACE`/`readCreatorVersion` - Produces: `buildGame(name: string, opts?: {gamesDir?, frameworkSrc?, hostProject?, workspace?, platform?, runBuild?: (dir: string, platform: string) => void, frameworkCommit?: string, now?: string}): {workspaceDir: string, skin: {matched: number, frameworkTotal: number}, buildInfo: object}` > `runBuild` 是**依赖注入点**:生产实现调 Cocos CLI,测试注入 fake,使流水线除"真构建"外全部可单测。 - [ ] **Step 1: 写失败测试** 创建 `cocoscreator_projects/scripts/test/build-game.test.mjs`: ```js 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 { buildGame } from '../build-game.mjs'; const LINK_TYPE = platform === 'win32' ? 'junction' : 'dir'; 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.equal(readFileSync(join(r.workspaceDir, 'assets', 'framework', 'ui', 'atlas-hall', 'btn.png'), 'utf8'), '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); } }); ``` - [ ] **Step 2: 运行测试确认失败** Run: `cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects && node --test scripts/test/build-game.test.mjs` Expected: FAIL — 报 `Cannot find module '../build-game.mjs'` - [ ] **Step 3: 实现 `build-game.mjs`** 创建 `cocoscreator_projects/scripts/build-game.mjs`: ```js #!/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); } } ``` - [ ] **Step 4: 运行测试确认通过** Run: `cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects && node --test scripts/test/build-game.test.mjs` Expected: PASS — 5 tests passed - [ ] **Step 5: 注册 npm script** 在 `cocoscreator_projects/package.json` 的 `scripts` 中,`"check-skin"` 那行之后加一行: ```json "build-game": "node scripts/build-game.mjs", ``` - [ ] **Step 6: Commit** ```bash cd G:/Works/YouleGamesCocosCreator git add cocoscreator_projects/scripts/build-game.mjs \ cocoscreator_projects/scripts/test/build-game.test.mjs \ cocoscreator_projects/package.json git commit -m "feat(tools): build-game 构建流水线 版本校验 → 实体化 → 皮肤合成 → 构建 → build-info。任一校验不过 即中止且不触发构建。runBuild 为注入点, 除真构建外全流程可单测。 Cocos 可执行路径只从 COCOS_CREATOR 环境变量取, 缺失显式报错。 Co-Authored-By: Claude Opus 5 (1M context) " ``` --- ### Task 7: `framework/ui/theme` —— 主题契约 **Files:** - Create: `cocoscreator_projects/YouleNexus/assets/framework/ui/theme/types.ts` - Create: `cocoscreator_projects/YouleNexus/assets/framework/ui/theme/default-theme.ts` - Create: `cocoscreator_projects/YouleNexus/assets/framework/ui/theme/resolve-theme.ts` - Create: `cocoscreator_projects/YouleNexus/assets/framework/ui/theme/theme-provider.ts` - Create: `cocoscreator_projects/framework-tests/ui/theme.test.ts` **Interfaces:** - Consumes: 无(框架内自足) - Produces: - `ThemeColors` / `ThemeFonts` / `ThemeLayout`(全字段必填的解析后类型) - `ThemeConfig`(各组 `Partial`,供子游戏声明) - `ResolvedTheme = { colors: ThemeColors; fonts: ThemeFonts; layout: ThemeLayout }` - `DEFAULT_THEME: ResolvedTheme` - `resolveTheme(game?: ThemeConfig): ResolvedTheme` - `ThemeProvider.init(t: ResolvedTheme): void` / `ThemeProvider.get(): ResolvedTheme`(未 init 即读则抛错) > **起步字段集**从旧项目实际参数倒推(`Game_Config.Info` 的 `myPosition` / `Mainnickname` / `Infonickname`)。spec §4.4 明确规定**不预先穷举**,字段随框架 UI 抽象逐项浮现;加字段是非破坏性的。 - [ ] **Step 1: 写失败测试** 创建 `cocoscreator_projects/framework-tests/ui/theme.test.ts`: ```ts import { test } from 'node:test'; import assert from 'node:assert/strict'; import { DEFAULT_THEME } from '../../YouleNexus/assets/framework/ui/theme/default-theme.ts'; import { resolveTheme } from '../../YouleNexus/assets/framework/ui/theme/resolve-theme.ts'; import { ThemeProvider } from '../../YouleNexus/assets/framework/ui/theme/theme-provider.ts'; test('resolveTheme 不传配置时返回默认主题', () => { const t = resolveTheme(); assert.deepEqual(t, DEFAULT_THEME); }); test('resolveTheme 只覆盖给出的字段,其余取默认', () => { const t = resolveTheme({ colors: { primary: '#C8102E' } }); assert.equal(t.colors.primary, '#C8102E'); assert.equal(t.colors.accent, DEFAULT_THEME.colors.accent); assert.equal(t.fonts.sizeBody, DEFAULT_THEME.fonts.sizeBody); assert.equal(t.layout.myInfoAnchorX, DEFAULT_THEME.layout.myInfoAnchorX); }); test('resolveTheme 分组独立合并,互不影响', () => { const t = resolveTheme({ layout: { myInfoAnchorX: 130 } }); assert.equal(t.layout.myInfoAnchorX, 130); assert.equal(t.layout.nicknameMaxChars, DEFAULT_THEME.layout.nicknameMaxChars); assert.deepEqual(t.colors, DEFAULT_THEME.colors); }); test('resolveTheme 不修改 DEFAULT_THEME 本身', () => { const before = DEFAULT_THEME.colors.primary; resolveTheme({ colors: { primary: '#000000' } }); assert.equal(DEFAULT_THEME.colors.primary, before); }); test('ThemeProvider 未 init 就读取会抛错(第二准则:不兜底)', () => { ThemeProvider.reset(); assert.throws(() => ThemeProvider.get(), /未初始化/); }); test('ThemeProvider init 后可读回同一份主题', () => { ThemeProvider.reset(); const t = resolveTheme({ colors: { primary: '#123456' } }); ThemeProvider.init(t); assert.equal(ThemeProvider.get().colors.primary, '#123456'); }); ``` - [ ] **Step 2: 运行测试确认失败** Run: `cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects && npm run test:framework` Expected: FAIL — 报找不到 `.../ui/theme/default-theme.ts` - [ ] **Step 3: 实现四个 theme 文件** 创建 `.../framework/ui/theme/types.ts`: ```ts /** * 主题契约。见 spec `2026-08-27-ui-asset-and-skin-design.md` §4。 * 字段集不预先穷举——随框架 UI 抽象逐项浮现;加字段非破坏性,删字段/改语义为 breaking change。 */ export interface ThemeColors { primary: string; accent: string; textPrimary: string; textSecondary: string; disabled: string; warning: string; } export interface ThemeFonts { /** 字体资源逻辑路径;null = 用引擎默认字体 */ familyPath: string | null; sizeTitle: number; sizeBody: number; sizeCaption: number; } export interface ThemeLayout { /** 自己信息的对齐点 x(旧 Game_Config.Info.myPosition) */ myInfoAnchorX: number; /** 主界面昵称最大字符数(旧 Game_Config.Info.Mainnickname) */ nicknameMaxChars: number; /** 个人信息页昵称最大字符数(旧 Game_Config.Info.Infonickname) */ infoNicknameMaxChars: number; } /** 子游戏声明用:各组均可只写要改的字段 */ export interface ThemeConfig { colors?: Partial; fonts?: Partial; layout?: Partial; } /** 合并后的完整主题,框架内部消费 */ export interface ResolvedTheme { colors: ThemeColors; fonts: ThemeFonts; layout: ThemeLayout; } ``` 创建 `.../framework/ui/theme/default-theme.ts`: ```ts import type { ResolvedTheme } from './types.ts'; /** * 主题缺省值的**唯一来源**(spec §4.3)。 * 这是 CLAUDE.md 第二准则明文列出的唯一例外——缺省集中在来源处,下游子游戏零兜底。 * 不得以此为先例:协议数据缺失仍须显式暴露,不得 ?? 抹平。 */ export const DEFAULT_THEME: ResolvedTheme = { colors: { primary: '#1E7A46', accent: '#F2B233', textPrimary: '#FFFFFF', textSecondary: '#C8D6CE', disabled: '#8A8A8A', warning: '#D94F3D', }, fonts: { familyPath: null, sizeTitle: 32, sizeBody: 24, sizeCaption: 20, }, layout: { myInfoAnchorX: 130, nicknameMaxChars: 8, infoNicknameMaxChars: 16, }, }; ``` 创建 `.../framework/ui/theme/resolve-theme.ts`: ```ts import type { ResolvedTheme, ThemeConfig } from './types.ts'; import { DEFAULT_THEME } from './default-theme.ts'; /** 把子游戏的部分主题配置合并到默认主题上。分组独立合并,不改动 DEFAULT_THEME。 */ export function resolveTheme(game: ThemeConfig = {}): ResolvedTheme { return { colors: { ...DEFAULT_THEME.colors, ...game.colors }, fonts: { ...DEFAULT_THEME.fonts, ...game.fonts }, layout: { ...DEFAULT_THEME.layout, ...game.layout }, }; } ``` 创建 `.../framework/ui/theme/theme-provider.ts`: ```ts import type { ResolvedTheme } from './types.ts'; let current: ResolvedTheme | null = null; /** * 主题持有者。皮肤在构建期即定死,运行时不变,故为静态读取、**不做响应式**(spec §4.5)。 * 子游戏在启动时调 init 注册自己的主题——框架不 import 子游戏(零耦合规则)。 */ export const ThemeProvider = { init(theme: ResolvedTheme): void { current = theme; }, get(): ResolvedTheme { if (!current) { throw new Error('ThemeProvider 未初始化:请在启动时调用 ThemeProvider.init(resolveTheme(theme))'); } return current; }, /** 仅供测试复位 */ reset(): void { current = null; }, }; ``` - [ ] **Step 4: 运行测试与类型检查确认通过** Run: ```bash cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects npm run test:framework && npm run typecheck:framework ``` Expected: 框架测试全部通过(原 84 个 + 本任务新增 6 个 = 90 个),`tsc --noEmit` 无输出。 - [ ] **Step 5: 让编辑器导入新脚本并确认无编译错误** 用 MCP 刷新并等待编译: ``` mcp__funplay_cocos__refresh_assets mcp__funplay_cocos__run_script_diagnostics → 等待编译 mcp__funplay_cocos__search_project_logs → query=error, regex=true ``` Expected: 编译完成且无 error 级日志;`ui/theme/` 下四个 `.ts` 各生成 `.meta`。 - [ ] **Step 6: Commit** ```bash cd G:/Works/YouleGamesCocosCreator git add cocoscreator_projects/YouleNexus/assets/framework/ui/theme \ cocoscreator_projects/framework-tests/ui/theme.test.ts git commit -m "feat(ui): theme 契约(类型+默认值+合并+Provider) 子游戏只写要改的字段, 缺省集中在 DEFAULT_THEME 一处(spec §4.3, 第二准则的唯一例外)。ThemeProvider 未 init 即读抛错, 不兜底。 皮肤构建期定死, 故静态读取不做响应式。 Co-Authored-By: Claude Opus 5 (1M context) " ``` --- ### Task 8: 端到端冒烟 + `library` 缓存实测 + 文档 **Files:** - Modify: `cocoscreator_projects/scripts/README.md` - Modify: `docs/superpowers/specs/2026-08-27-ui-asset-and-skin-design.md` §6(回填待实测项 3) **Interfaces:** - Consumes: Task 1–7 的全部产出 - Produces: 工具链文档;spec §6 第 3 行的实测结论 - [ ] **Step 1: 运行全部测试** Run: ```bash cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects npm test && npm run test:framework && npm run typecheck:framework ``` Expected: 工具链测试 26 + 本计划新增(skin 12 + check-skin 4 + materialize 7 + build-game 5 = 28)= 54 个通过;框架测试 90 个通过;类型检查无输出。 - [ ] **Step 2: 端到端冒烟(造子游戏 → 放皮肤 → 校验 → 实体化合成)** Run: ```bash cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects node scripts/new-game.mjs skin-e2e # 放一张与框架同尺寸的替换图(框架 atlas-hall 下须已有资源;若为空则先跳过本步并记录) node -e ' const fs=require("fs"),path=require("path"); const fw="YouleNexus/assets/framework/ui/atlas-hall"; const pngs=fs.existsSync(fw)?fs.readdirSync(fw).filter(f=>f.endsWith(".png")):[]; if(!pngs.length){console.log("SKIP: framework/ui/atlas-hall 下暂无 PNG,冒烟只验证零覆盖路径");process.exit(0);} const dst="games/skin-e2e/assets/game/override/atlas-hall"; fs.mkdirSync(dst,{recursive:true}); fs.copyFileSync(path.join(fw,pngs[0]),path.join(dst,pngs[0])); console.log("已放置替换图:",pngs[0]); ' node scripts/check-skin.mjs skin-e2e ``` Expected: `check-skin` 打印命中统计并输出 `OK: override 全部合法。`,退出码 0。 - [ ] **Step 3: 验证校验会真的拦住错误** Run: ```bash cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects mkdir -p games/skin-e2e/assets/game/override/atlas-hall echo "not a real asset" > games/skin-e2e/assets/game/override/atlas-hall/__typo__.png node scripts/check-skin.mjs skin-e2e; echo "退出码=$?" rm -f games/skin-e2e/assets/game/override/atlas-hall/__typo__.png ``` Expected: 打印 `[orphan] atlas-hall/__typo__.png` 与定位信息,`退出码=1`。 - [ ] **Step 4: 实测 `library` 缓存复用(待实测项 3)** > 需要 `COCOS_CREATOR` 环境变量指向 Cocos Creator 3.8.8 可执行文件。 Run(连续两次真实构建,比较耗时): ```bash cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects time node scripts/build-game.mjs skin-e2e --platform web-mobile time node scripts/build-game.mjs skin-e2e --platform web-mobile ``` Expected 与判定:记录两次耗时。 - 若第二次显著快于第一次 → `library` 复用有效,**但注意当前 `buildGame` 每次 `rmSync` 工作区,故复用不成立**;此时需评估「保留 library、只重刷 assets」的改法,把结论记入 spec §6 第 3 行,并作为后续 plan 的独立任务(本计划不改实现)。 - 若两次耗时相当 → 记录「全量导入耗时 T」,确认缓存优化的必要性与优先级。 **如实记录,不得因未测而假定。** 若环境无 `COCOS_CREATOR`,记为「⏸ 阻塞:缺构建环境」。 - [ ] **Step 5: 清理冒烟产物** Run: ```bash cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects node -e "const {rmSync}=require('fs'); rmSync('games/skin-e2e',{recursive:true,force:true}); rmSync('build-workspace/skin-e2e',{recursive:true,force:true})" cd G:/Works/YouleGamesCocosCreator && git status --porcelain cocoscreator_projects/games ``` Expected: `games/` 下无残留(`git status` 该路径无输出)。 - [ ] **Step 6: 更新工具链 README** 在 `cocoscreator_projects/scripts/README.md` 的命令表格中追加两行: ```markdown | `npm run check-skin ` | 校验子游戏 `assets/game/override/` 是否合法(孤儿/尺寸不一致/Spine 缺件),不构建,秒级反馈 | | `npm run build-game [-- --platform android]` | 完整构建流水线:版本校验 → 实体化到 `build-workspace/` → 皮肤合成 → Cocos CLI 构建 → 写 `build-info.json` | ``` 并在「关键约束」小节追加: ```markdown - **皮肤在构建期合成**,不是运行时覆盖。子游戏把同名替换资源放 `assets/game/override/`, 路径须与 `framework/ui/` 逐字对应;只换内容、不带 `.meta`(尺寸须一致),确需改尺寸才走 带 meta 的例外通道。详见 spec `2026-08-27-ui-asset-and-skin-design.md`。 - **框架真源全程只读**:合成只发生在 `build-workspace/` 的临时工程副本里。 - **发布链路不依赖 junction**:`build-game` 直接从 `YouleNexus/assets/framework` 拷贝真源, CI clone 后无需先跑 `setup-links`。 - 构建需环境变量 `COCOS_CREATOR` 指向 Cocos Creator 可执行文件(唯一来源,勿在脚本硬编码)。 ``` - [ ] **Step 7: Commit** ```bash cd G:/Works/YouleGamesCocosCreator git add cocoscreator_projects/scripts/README.md \ docs/superpowers/specs/2026-08-27-ui-asset-and-skin-design.md git commit -m "docs(tools): 皮肤与构建流水线文档, 回填 library 缓存实测结论 Co-Authored-By: Claude Opus 5 (1M context) " ``` --- ## 验收标准(本计划完成定义) 1. `npm test` 全绿(26 原有 + 28 新增 = 54 用例);`npm run test:framework` 全绿(84 原有 + 6 新增 = 90 用例);`npm run typecheck:framework` 无输出。 2. `git check-ignore` 验证:`assets/framework/**/native|local|profiles/` 下的文件**不再**被忽略;各工程 `library/`、`build-workspace/` 仍被忽略;`new-game` 生成的工程继承种子 `.gitignore`。 3. `framework/ui/` 八个资源目录与 `README.md`(资源归属规则)入库。 4. `npm run check-skin ` 对合法 override 退出码 0,对孤儿/尺寸不一致/Spine 缺件退出码 1 且逐条可定位。 5. `npm run build-game ` 在版本不一致或皮肤校验失败时中止且**不触发构建**;成功时产出 `build-info.json`(含 `frameworkCommit` 与皮肤命中统计)。 6. 框架真源在任何构建后均未被修改(`git status` 对 `YouleNexus/assets/framework` 干净)。 7. `theme` 契约四文件入库,编辑器编译无 error。 8. spec §6 的四条待实测项各有明确结论(已验证 / 已失败并触发备选 / ⏸ 阻塞并说明原因),**无一条停留在未验证却被默认可行的状态**。 ## 后续计划衔接 - **组件替换 spec**(`SeatView` 契约、约定节点名、注册机制、按人数布局配置)——其前置是 `RoomStore` 完整建模(platform Store 第二切片)。 - **framework/ui 界面实现**——本计划只建资源骨架与工具链,未做任何 Prefab/场景;`theme` 的字段集将在界面实现时逐项扩充(spec §4.4)。 - 若 Task 8 Step 4 表明 `library` 全量导入耗时不可接受,另立任务实现「保留 library、只重刷 assets」的增量工作区策略。