Files
SpineWallpaper/docs/adr/0005-build-pipeline-and-dist-topology.md
Shuery 3f11426964 feat: TS 构建管线、Spine 抓取器与 wallpapers/ 唯一真相来源
把项目从「手写 dist/」改成「wallpapers/ 是唯一真相来源,dist/ 由 pnpm build 生成」,
并补上配套的类型、门禁与抓取器。一次提交落地整条管线,因为拆开会留下不能构建的中间态。

- src/:运行时与模拟器源码(TS,strict),编译到 build/ 再拷进各分发
- tools/:build / dev / check-{syntax,paths,dist},以及抓取器与回归门禁 tools/checks/
  (.scratch/ 下那批一次性脚本移入 tools/checks/ 并入库为长期门禁)
- wallpapers/:七档壁纸的源数据 + README.md(id/音频/预设的完整规范)
- docs/adr/0005-0008:构建管线与分发拓扑、模拟器契约、自包含 sim、每骨架资源布局
- .gitignore:排除 .scratch/ 的参考资料副本(上游 spine 整仓克隆 ~1.2 GB、
  抓取侦查数据 ~680 MB)与调试转储;这些是本地调查材料,补偿会让仓库无法克隆
- 归一化 .gitignore/CONTEXT.md 行尾(工作区 CRLF、索引 LF 造成的整文件假 diff)

同时修掉三档卡住构建的未完工壁纸:
- kv45 的 meta.json 里 id 还是抓取期场景名 scene_main,经 downloader promote 正名为 kv45
- shajin / zhigengniao_juheye 的 meta.json 误用了骨架描述文件(name/spine/animations/pages)、
  且都缺 preset.template.json;现按规范重建:骨架沉到 spines/<名>/(spine-ts 按 atlas 所在
  目录解析贴图页)、补上元数据与单骨架预设,并清掉 zhigengniao 骨架里指向作者机的绝对路径
- 顺带 promote 已在 sources.yml 里的 kv46(月升之前,与兽共舞)

pnpm check 五道门全绿:10 个分发 / 7 档壁纸 / 141 处引用自包含。
2026-10-02 01:27:02 +08:00

7.1 KiB
Raw Permalink Blame History

构建管线与分发拓扑:id 化目录、无链接、合集层级

背景

dist/ 原先手工维护:assets/{images,effects,audios}/ 平铺、一个 dist/ 就是"全部壁纸",project.json、preset.js 也都是手写的。要加第三档壁纸、要按"单档/合集"两种形态发布、还要有个本地调试服,这套手工结构撑不住了。本次把它换成 pnpm build 驱动的构建管线。

决策

1. 一份代码,四种分发:dist/releases/<类型>-<id>/

分发 目录名 含壁纸 结构

类型只有两种:单档与合集(type: "single" | "collection")。下表后两行都是合集, 区别在收档范围 scope: "game" | "all"——「全部合集」不是第三种类型。

| 单档 | single-<游戏id>-<壁纸id> | 1 | <根>/preset.js、<根>/audios/ | | 游戏合集 | collection-<游戏id> | 该游戏全部 | <根>/<壁纸id>/preset.js、<根>/audios/<壁纸id>/ | | 全部合集 | collection-all | 全部 | <根>/<游戏id>/<壁纸id>/preset.js、<根>/audios/<壁纸id>/ |

pnpm build name、pnpm build single、pnpm build collect 是选择构建哪些分发的三个入口,不是三种不同的构建逻辑——同一份 wallpapers/ 源数据,按需生成不同的子集。

具体范围(实现见 tools/build.ts 的 parseArgs):

输入 范围
pnpm build 全编:全部单档 + 每个游戏合集 + 全部合集
pnpm build <壁纸id> […] 指定壁纸(可多个)
pnpm build single [壁纸id …] 只编单档;不给 id = 全部单档
pnpm build collect [游戏id …] 只编合集;不给参数 = 全部合集

子命令与壁纸 id 共用同一段位置,靠词表区分:single/collect/sim 是保留字,其余裸词都是壁纸 id。注意 pnpm build collect(无参,全部合集)与 --collect all(只编「全部壁纸合集」)语义不同,两者都保留。

2. 目录名用 id,不用显示名(修订 ADR 0001)

ADR 0001 决定"目录名用显示名、键用 id"。本次把目录名也改成 id:

  • 分发目录要能直接上传、要避免中文路径在上传与跨平台解压时的编码问题;
  • dist/releases/ 下要能一眼看出哪档是"合集"、哪档是"单档",所以加 <类型>- 前缀;
  • 显示名进 meta.json,改标题不再需要 git mv 一堆目录,也不再让"改标题"变成一次全站路径风险。

代价:wallpapers/ 与产物目录的可读性变差。用 meta.json 里的 name 与 dist-map.json 的 displayName 补回来(调试服索引页就是按它渲染的)。

ADR 0001 里"保留 effects/、images/、audios/ 三层同名目录"这一条不变:Spine 骨架 JSON 硬编码了 "images": "../images/",改了就得动骨架文件。(已被 ADR 0008 取代——贴图页并到 atlas 同目录后,骨架 JSON 里那个前缀归零了。)

3. 不产生任何链接,只做拷贝

早期的方案考虑过 NTFS 硬链接来省磁盘(全量构建约 290 MB)。否决:链接会把"产物自包含"这条基本性质废掉——拷贝到别的机器、上传、或者只拷一个分发目录时,链接目标不在,产物就残了。

所以构建只做普通拷贝,每个分发目录都是完整的字节副本。磁盘代价用"只构建需要的那几个分发"(pnpm build name / collect)来换,而不是用链接。

4. 分发内不出现绝对链接与跨目录相对链接

一个分发目录必须能被整体拷到别的机器上传,因此产物里:

  • 不出现 /xxx 这类根绝对路径(脱离分发根就没有意义);
  • 不出现 ../ 越出分发根的引用;
  • 不出现 file:/// 与 //。

例外:合集分发里壁纸的 preset.js 会用 ../audios/<壁纸id>/ 指到同一分发内的合集根,这不越界。pnpm check:dist 把这几条做成硬门禁。

5. 路径用 import.meta.url 现算,按分发深度生成

preset.js 里的每个路径都是 asset("./images/x.webp") 这种相对自身的形式(见 ADR 0002)。同一档壁纸在不同分发里深度不同,所以每个分发目录的 preset.js 都是现算现写的,不能一份源码拷到多处:

单档        ./audios/<壁纸id>/<文件>
游戏合集    ../audios/<壁纸id>/<文件>
全部合集    ../../audios/<壁纸id>/<文件>

这条路径必须与"音频实际搬到哪儿"严格一致。为此构建先算落点、再生成 preset.js,并由 check:dist 在产物上反查每个 source: 指向的文件是否真的存在——"语法正确、路径指向空气"的产物会一路通过类型检查与引用扫描,然后在 WE 里静音(本次重构真的写出过一次)。

6. 音频集中到合集根,按壁纸分目录

合集分发的 bgm 下拉要能选到分发内任一壁纸的音源,所以音频统一集中到合集根的 audios/,不再逐壁纸重复一份:

audios/<壁纸id>/<文件>     各壁纸自己的音源(互相隔离)
audios/<文件>              游戏级共享音频(wallpapers/<游戏id>/audios/)

按壁纸 id 分一层,是为了让"同一首歌被两档壁纸各自引用"和"两档壁纸各有一首同名但不同的曲子"都变成可表达的状态——扁平布局下后者只能报错。共享层保持扁平,重名即歧义,直接构建失败。

单档分发没有合集根,音频留在壁纸目录内的 audios/(非合集分发不出现共享音频目录)。

7. VERSION 是版本号的唯一写者

VERSION 文件 → project.json.version(数字,WE 的格式)。构建时会断言"全部合集"这一档的 project.json 与线上已发布版本逐字段一致——重构不该悄悄改变已发布产物的语义。已知的唯一偏差是格式化:WE 自己的美化器输出 "key" : value(冒号前有空格)并展开嵌套,我们用 JSON.stringify(…, null, "\t")。字段名、键序、取值全部对齐。

8. 产物 JS 的语法目标由编译器兜底

运行时脚本经 tsc 编译,跑在 WE 自带的 CEF 里——那不是一个能随 Node 升级的引擎。因此 tsconfig 里显式关掉 useDefineForClassFields,让类字段降级成构造函数赋值;否则 x = 1 / x; 这类声明式字段会原样输出,实测会让整个 index.js 直接 SyntaxError 白屏。

反过来,presets.js 与 preset.js 是生成器拼出来的裸 JS 文本,编译器完全不看它们。所以 check:dist 会让 V8 真的解析一遍每个产物模块(vm.SourceTextModule,只解析不执行)——这条门禁抓到过一次真事故(数组字面量里写了对象字面量的计算属性名)。

未采纳的方案

  • 硬链接/符号链接省磁盘:见第 3 条。
  • 一个 dist/ 装所有分发:无法整体上传,且本地调试时"越界引用"会被别的分发意外命中,反而掩盖问题。
  • 目录名继续用显示名:见第 2 条。