Task 4 评审指出 check-skin.mjs 缺该守卫, 与仓库其余四个脚本 (bump-cocos/check-cocos-version/new-game/setup-links) 的既有约定不一致。 plan 里 Task 6 的 build-game.mjs 是同样写法, 先修正避免复制该不一致。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
74 KiB
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 不支持
--testglob,测试文件须显式枚举(现有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 根执行:
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:
# 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:
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:
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
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):
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 未变:
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,刷新后检查:
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:
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 表格(新增一列「实测结论」,注明日期)。
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): stringskin.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 之后)追加:
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:
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:
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
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:
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:
#!/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" 那行之后加一行:
"check-skin": "node scripts/check-skin.mjs",
- Step 6: Commit
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 SkinValidationErrorclass SkinValidationError extends Error(含errors属性)
-
Step 1: 写失败测试
创建 cocoscreator_projects/scripts/test/materialize.test.mjs:
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:
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
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:
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:
#!/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" 那行之后加一行:
"build-game": "node scripts/build-game.mjs",
- Step 6: Commit
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: ResolvedThemeresolveTheme(game?: ThemeConfig): ResolvedThemeThemeProvider.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:
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:
/**
* 主题契约。见 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:
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:
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:
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:
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
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:
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:
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:
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(连续两次真实构建,比较耗时):
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:
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 的命令表格中追加两行:
| `npm run check-skin <name>` | 校验子游戏 `assets/game/override/` 是否合法(孤儿/尺寸不一致/Spine 缺件),不构建,秒级反馈 |
| `npm run build-game <name> [-- --platform android]` | 完整构建流水线:版本校验 → 实体化到 `build-workspace/` → 皮肤合成 → Cocos CLI 构建 → 写 `build-info.json` |
并在「关键约束」小节追加:
- **皮肤在构建期合成**,不是运行时覆盖。子游戏把同名替换资源放 `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
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>"
验收标准(本计划完成定义)
npm test全绿(26 原有 + 28 新增 = 54 用例);npm run test:framework全绿(84 原有 + 6 新增 = 90 用例);npm run typecheck:framework无输出。git check-ignore验证:assets/framework/**/native|local|profiles/下的文件不再被忽略;各工程library/、build-workspace/仍被忽略;new-game生成的工程继承种子.gitignore。framework/ui/八个资源目录与README.md(资源归属规则)入库。npm run check-skin <name>对合法 override 退出码 0,对孤儿/尺寸不一致/Spine 缺件退出码 1 且逐条可定位。npm run build-game <name>在版本不一致或皮肤校验失败时中止且不触发构建;成功时产出build-info.json(含frameworkCommit与皮肤命中统计)。- 框架真源在任何构建后均未被修改(
git status对YouleNexus/assets/framework干净)。 theme契约四文件入库,编辑器编译无 error。- spec §6 的四条待实测项各有明确结论(已验证 / 已失败并触发备选 / ⏸ 阻塞并说明原因),无一条停留在未验证却被默认可行的状态。
后续计划衔接
- 组件替换 spec(
SeatView契约、约定节点名、注册机制、按人数布局配置)——其前置是RoomStore完整建模(platform Store 第二切片)。 - framework/ui 界面实现——本计划只建资源骨架与工具链,未做任何 Prefab/场景;
theme的字段集将在界面实现时逐项扩充(spec §4.4)。 - 若 Task 8 Step 4 表明
library全量导入耗时不可接受,另立任务实现「保留 library、只重刷 assets」的增量工作区策略。