Files
youle_cocos/docs/superpowers/plans/2026-08-27-ui-asset-and-skin.md
T
joywayerandClaude Opus 5 000f9e4290 fix(plan): 修正 Task 3 给定代码里恒为 false 的例外通道判断
计划里写的 !ovSet.has(`${rel}.meta`) 恒为 false —— ovSet 由 listAssets 构建,
而 listAssets 明确排除 .meta, 故「override 自带 meta 改尺寸」的例外通道永不生效。
改为 existsSync 查磁盘。该 bug 由 Task 3 的 TDD RED 信号暴露, 实现者已在代码中修正,
此处同步计划文档避免重跑时重新引入。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 21:26:39 +08:00

1831 lines
74 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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/<name>/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 <name>`
**自定义 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 已忽略" ; \
rm -rf cocoscreator_projects/games/gitignore-probe
```
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) <noreply@anthropic.com>"
```
---
### Task 2: 承重假设实测(Auto Atlas / meta+尺寸 / Spine)
> **非 TDD 的验证任务,但必须排在工具开发之前。** spec §6 的三条待实测项是整个构建期合成方案的承重点;若 Auto Atlas 不在构建期打包,Task 3–6 的设计需推倒重来。先验证,再建在上面。
>
> **前置条件**:需要 Cocos Creator 3.8.8 打开 `YouleNexus` 且 cocos-creator-mcp 服务可用。所有编辑器操作**必须**走 `mcp__cocos-creator-mcp__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<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",zlib.deflateSync(raw)),chunk("IEND",Buffer.alloc(0))]);}
const dir="YouleNexus/assets/framework/ui/atlas-hall/";
fs.writeFileSync(dir+"probe_a.png",png(64,64,[220,40,40]));
fs.writeFileSync(dir+"probe_b.png",png(64,64,[40,120,220]));
console.log("生成完成");
'
```
Expected: 打印 `生成完成`,`atlas-hall/` 下出现两个 64×64 PNG。
- [ ] **Step 2: 让编辑器导入并确认生成 `.meta`**
用 MCP 刷新资源,然后确认两张图各自生成了 `.meta`:
```
mcp__cocos-creator-mcp__cocos_project → action: refresh_assets
```
Run: `ls YouleNexus/assets/framework/ui/atlas-hall/`
Expected: 出现 `probe_a.png.meta` 与 `probe_b.png.meta`。
记录两个 meta 里的 `uuid` 值备用(Step 5 比对)。
- [ ] **Step 3: 建自动图集并构建,验证「Auto Atlas 在构建期打包」(待实测项 1)**
用 MCP 在 `atlas-hall/` 下创建自动图集资源(`.pac`),然后构建一次 web-mobile:
```
mcp__cocos-creator-mcp__cocos_asset → 创建 atlas-hall/atlas-hall.pac(自动图集)
mcp__cocos-creator-mcp__cocos_builder → 构建 platform=web-mobile
```
Expected(判定标准):构建产物中 `probe_a` 与 `probe_b` **被合并进同一张图集贴图**,而非各自独立的 64×64 PNG。
**结论回填**:在 spec §6 表格第 1 行记录「✅ 已验证 / ❌ 失败」。
**失败则**:按 spec §6 备选——改为「图集整体作为覆盖单位」,停止本计划,回 brainstorming 修订 §2/§3。
- [ ] **Step 4: 验证「保留 meta、替换内容」引用不断(待实测项 2 前半)**
把 `probe_a.png` 内容换成另一张**同尺寸**图(不动 `.meta`),刷新后确认 uuid 未变:
```bash
cd G:/Works/YouleGamesCocosCreator/cocoscreator_projects
node -e '
const fs=require("fs");
const p="YouleNexus/assets/framework/ui/atlas-hall/probe_a.png";
fs.copyFileSync("YouleNexus/assets/framework/ui/atlas-hall/probe_b.png",p);
console.log("已用 probe_b 内容覆盖 probe_a(同尺寸)");
'
```
然后 MCP `cocos_project → refresh_assets`,再读 `probe_a.png.meta`。
Expected: `uuid` 与 Step 2 记录的一致(未重新生成)。
- [ ] **Step 5: 验证「尺寸变化 + 保留 meta」的实际表现(待实测项 2 后半)**
把 `probe_a.png` 换成 **128×128**(尺寸变化),保留原 `.meta`,刷新后检查:
```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(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<h;y++){const o=y*(1+w*3);raw[o]=0;for(let x=0;x<w;x++){raw[o+1+x*3]=rgb[0];raw[o+2+x*3]=rgb[1];raw[o+3+x*3]=rgb[2];}}
return Buffer.concat([Buffer.from([137,80,78,71,13,10,26,10]),chunk("IHDR",i),chunk("IDAT",zlib.deflateSync(raw)),chunk("IEND",Buffer.alloc(0))]);}
fs.writeFileSync("YouleNexus/assets/framework/ui/atlas-hall/probe_a.png",png(128,128,[40,200,80]));
console.log("已换成 128x128");
'
```
MCP `refresh_assets` 后检查 `probe_a.png.meta`:`uuid` 是否保持、`subMetas` 下 spriteFrame 的 `rect`/`originalSize` 是否被自动更新为 128×128。
Expected(判定标准):`uuid` 保持不变。
- 若 `rect`/`originalSize` **被自动更新** → 记录「尺寸变化安全」,Task 3 的 `size-mismatch` 可降级为 warning。
- 若**未更新**(仍是 64×64)→ 确认 spec §3.2 的「尺寸不同即报错 + 例外通道」是必需的,保持 Task 3 的设计。
**结论回填**:spec §6 表格第 2 行。
- [ ] **Step 6: Spine 整套替换验证(待实测项 4)—— 素材阻塞时如实记录**
> **已核实:本仓库没有任何可用 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) <noreply@anthropic.com>"
```
---
### 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) <noreply@anthropic.com>"
```
---
### 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 <game>');
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) <noreply@anthropic.com>"
```
---
### 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) <noreply@anthropic.com>"
```
---
### 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 入口
if (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);
}
}
```
- [ ] **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) <noreply@anthropic.com>"
```
---
### 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<ThemeColors>;
fonts?: Partial<ThemeFonts>;
layout?: Partial<ThemeLayout>;
}
/** 合并后的完整主题,框架内部消费 */
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__cocos-creator-mcp__cocos_project → action: refresh_assets
mcp__cocos-creator-mcp__cocos_debug → action: wait_compile
mcp__cocos-creator-mcp__cocos_debug → action: get_console_logs, level: error
```
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) <noreply@anthropic.com>"
```
---
### 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
rm -rf games/skin-e2e build-workspace/skin-e2e
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 <name>` | 校验子游戏 `assets/game/override/` 是否合法(孤儿/尺寸不一致/Spine 缺件),不构建,秒级反馈 |
| `npm run build-game <name> [-- --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) <noreply@anthropic.com>"
```
---
## 验收标准(本计划完成定义)
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 <name>` 对合法 override 退出码 0,对孤儿/尺寸不一致/Spine 缺件退出码 1 且逐条可定位。
5. `npm run build-game <name>` 在版本不一致或皮肤校验失败时中止且**不触发构建**;成功时产出 `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」的增量工作区策略。