主要改动: - 切到 funplay-cocos-mcp v0.5.1 (用户级配置, 项目级 .mcp.json 删除) - 仓库文档/CLAUDE.md/.gitignore 等清理过时 cocos-mcp-server 引用 - memory 文件同步: cocos-mcp-setup/path/blocker/spriteframe-uuid/prefab-persist 等加 funplay 实测警告 - memory 新建 funplay-cocos-mcp-pending-verification.md (后已被实测覆盖) - spec/plan/data: - docs/superpowers/specs/2026-09-02-legacy-layer-migration-design.md - docs/superpowers/plans/2026-09-02-legacy-layer-migration.md - docs/superpowers/data/layer-spirit-summary.json - YouleNexus: profiles.ts / defaults.ts / PlayerInfoView.prefab / scene 改动 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1831 lines
74 KiB
Markdown
1831 lines
74 KiB
Markdown
# 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 已忽略" ; \
|
||
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) <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### 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<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__funplay_cocos__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__funplay_cocos__write_file → 创建 atlas-hall/atlas-hall.pac(自动图集)
|
||
mcp__funplay_cocos__run_project_preview → 构建 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 入口(argv[1] 守卫与仓库其余脚本一致:无脚本名时不进 CLI 分支)
|
||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||
const name = process.argv[2];
|
||
if (!name) {
|
||
console.error('用法: npm run build-game <game> [-- --platform android]');
|
||
process.exit(1);
|
||
}
|
||
const pIdx = process.argv.indexOf('--platform');
|
||
const targetPlatform = pIdx > -1 ? process.argv[pIdx + 1] : undefined;
|
||
try {
|
||
const r = buildGame(name, targetPlatform ? { platform: targetPlatform } : {});
|
||
console.log(`皮肤命中 ${r.skin.matched} 处,框架可覆盖资源共 ${r.skin.frameworkTotal} 个`);
|
||
console.log(`构建完成:${r.workspaceDir}`);
|
||
} catch (e) {
|
||
if (e instanceof SkinValidationError) {
|
||
console.error(`\n${e.message}(子游戏 ${name}):`);
|
||
for (const err of e.errors) console.error(` [${err.code}] ${err.path}\n ${err.message}`);
|
||
} else {
|
||
console.error(e.message);
|
||
}
|
||
process.exit(1);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **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__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) <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
|
||
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 <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」的增量工作区策略。
|