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 处引用自包含。
This commit is contained in:
Shuery committed 2026-10-02 01:27:02 +08:00
1 parent b8eee05d78
commit 3f11426964
297 files changed
+216627 -1926

No files matched your search

+248
View File
@@ -0,0 +1,248 @@
# `tools/downloader/` — 米哈游活动页 Spine 抓取器
从 `wallpapers/sources.yml` 列出的活动页里把 **Spine 骨架 + 贴图 + 场景装配信息** 抓下来,
落到 staging(`_out/`)。**每个有内容的场景各产出一个「可直接搬走的壁纸目录」**——用户挑中的那份
整个移到 `wallpapers/<游戏id>/` 就能用(也可以让 `promote` 代劳)。
它不是构建输入:`wallpapers/` 只放**最终要发布**的资源,抓取脚本与临时下载都留在 `tools/`。
```bash
python -m tools.downloader fetch --page kv45 # 抓一页(结尾自动 verify,有硬伤就非 0 退出)
python -m tools.downloader fetch # 抓 sources.yml 里的全部页面
python -m tools.downloader fetch --interactive # 逐页问:这一页推荐哪个场景(不影响落盘范围)
python -m tools.downloader select --page nico-tea # 只做选择,写 selection.yml
python -m tools.downloader verify # 只自检产物
python -m tools.downloader fetch --offline # 只用 _cache/ + 已落盘的产物,不发任何请求(重跑验证)
python -m tools.downloader promote --page kv45/scene_ava --game hsr --id scene_ava \
--name 挥掷千星的筹码 --title '【崩坏:星穹铁道】挥掷千星的筹码' --description '[b]挥掷千星的筹码[/b]'
node tools/checks/check-downloader.mts # 门:编译 + 夹具绿 + 四处破坏必红
```
依赖:Python 3.10+ 与 **PyYAML**(`requirements.txt`)。不需要浏览器——这些页面免登录即可拿到
全量入口 bundle,骨架数据就在里面。
---
## 一、产物形状
```
tools/downloader/
├── _cache/<页面>/<脚本名> # 抓下来的 bundle 原文(文本调试缓存,可重跑零请求)
├── _out/<游戏>/<页面>/
│ ├── page.json # 页面级溯源:来源 URL / 入口脚本 / 场景清单 / 每个场景目录的摘要
│ └── <场景id>/ # ← 一个场景 = 一个可直接搬走的壁纸目录
│ ├── meta.json # id / name / title / description / game / page / scene / source / audio
│ ├── preset.template.json # 运行时配置(背景图 + sceneConfig.parts)
│ ├── scene.json # 场景侧车:part 列表 + 每个 part 的世界变换
│ ├── spines/<骨架名>/
│ │ ├── <骨架名>.json # 骨架(skeleton.images 已归一化为空串)
│ │ ├── <骨架名>.atlas # 第一行 = 贴图页名,与落盘文件名逐字一致
│ │ ├── <贴图页…> # 与 atlas **同居**(布局 A,见 ADR 0008)
│ │ └── meta.json # spine 版本 / 动画 / 皮肤 / 来源 URL
│ ├── scene/<场景图> # 几何平面(geometry + material.diffuse)贴的图
│ └── audios/ # 音源(当前恒为空:页面的 BGM 还没抓)
└── selection.yml # 机器所有的「哪一页推荐哪个场景」(**入库**)
```
场景目录名 = 场景 id 转义成合法壁纸 id(只允许 `[a-z0-9_-]`,首字符必须是字母数字):
`scene_main` / `P1` / `loading` 原样,中文场景 id(back-moon 的 `动画预览`)折成 `scene`。
**真实 id 不丢**——它在同目录的 `meta.json` 与 `scene.json` 里。
`_cache/` 与 `_out/` 在 `.gitignore` 里;`selection.yml` 入库。
### 场景内去重、跨场景不去重
* **场景内按 id 去重**:同一个场景里同一具骨架被引用多次只存一份(`ctc_rewards` 在
back-moon 的 `scene_content` 里出现 4 次、`gc_win` 在 `scene_gacha` 里 6 次,都只落一份)。
`scene.json` 里那些 part 仍然一条不少——只有**文件**去重。
* **跨场景不去重**:`scene_main/` 与 `scene_ava/` 会各存一份用到的骨架。`_out/` 是可随时
重生成的 staging,体积换"每个目录都能单独搬走"。总体积记在 `page.json` 的 `totalBytes` 里。
### `usable`:不是每个场景目录都能搬走
各页的 `scene_ui` 是一层**场景渲染目标**:它的平面用 `drawScene` modifier 把别的场景渲染成纹理
(diffuse 名就是场景 id),本身没有任何资源文件。这种场景照样落盘("页面上有几个场景"这件事在
产物里是完整的),但它的预设 `parts` 是空的:
```json
{ "usable": false, "reason": "场景里只有运行时纹理(drawScene / 贴图缓冲),没有可搬走的资源" }
```
`promote` 拒绝搬这种目录;`page.json` 的 `sceneDirs[]` 里逐条标了 `usable`。
## 二、场景选择:只决定"推荐哪个"
`fetch` **落盘全部有内容的场景**(至少有一具骨架或一块贴图平面)。纯色平面组成不了壁纸
(`sceneConfig.parts` 会是空的),完全没有 part 的场景也没有内容——这两类不落盘,原因记在
`page.json` 的 `skippedScenes` 里。
推荐场景由 `selection.yml` / `sources.yml` / 默认值算出(优先级写死,避免两份文件打架):
1. `wallpapers/sources.yml` 里这一页写了 `scene:` / `spines:` → 用它(**人的意志最高**)
2. `tools/downloader/selection.yml` 里有这一页的记录 → 用它(上次交互的结果)
3. 都没有 → **骨架最多的那个场景**(`--interactive` 时才问人)
默认规则与旧项目的判断一致(get-memory 会推荐 `P1`、back-moon 会推荐 `scene_main`)。
推荐值写进 `page.json` 的 `chosenScene` / `chosenDir`,是 `promote` 不给 `--scene` 时的默认值。
> **这份选择不再裁剪产物。** 以前它决定"只落哪几个骨架",现在产物是"一个场景一个自包含的
> 壁纸目录"——砍掉几具骨架会做出一个缺件的坏场景。要精简就在场景目录里的
> `preset.template.json` 上改(那是纯数据,构建期才读)。
> `sources.yml` 的 `spines:` 字段同理,只剩记录语义。
## 三、`scene.json` 里有什么
页面用自研引擎(three.js 系)描述场景:`sceneList` 是场景数组,每个场景是一棵树,节点有三类负载:
| kind | 含义 | 需要资源? |
| --- | --- | --- |
| `spine` | `spine:{id:"main_nike"}`,一具骨架 = 一个视觉元件 | 是(骨架 + atlas + 贴图页) |
| `image` | `geometry` + `material.uniforms.diffuse`,贴图平面 | 是(`scene/<名>.<ext>`) |
| `solid` | `defines.USE_TEXTURE == 0` 的**纯色平面**(diffuse 常写 `DEFAULT`) | 否 |
每个 part 带 `position` / `scale`(**世界变换**)与 `localPosition` / `localScale`,合成语义照抄引擎:
```
world_position = parent_position + parent_scale ⊙ local_position
world_scale = parent_scale ⊙ local_scale
```
旋转暂不参与合成(旧项目同样如此),只在 `stats.rotatedNodes` 里报出数量——不假装算了。
绘制层级 = 树序遍历序(`order`)+ 节点自身的 `renderOrder`。
带 `"runtime": true` 的 `image` part 是**没有独立文件**的平面:`drawScene` 的场景渲染目标、
`cacheContainer` 的贴图缓冲,或 diffuse 指向同场景骨架缓存。它们不进预设,也不算缺资源。
**用户口中的 "scene / geometric" 就是这里的两类节点**:`sceneList` 的场景容器与 `geometry` 平面。
Spine 官方没有这两个数据概念(官方 7 种附件类型里没有 geometric;`scene` 在 spine-webgl 里指的是
`SceneRenderer` 这个渲染器)。所以"支持场景动画"= 运行期把多具骨架 + 若干贴图平面按这套摆放合成。
## 四、`preset.template.json` 怎么生成的
* `sceneConfig.parts`:`scene.json` 里每个 `spine` / `image` part 一条;纯色平面与运行时纹理不写
(运行时画不了它们,写进去只是死配置)。路径一律写成 `./spines/<名>/…`、`./scene/<名>.<ext>`——
**正斜杠**,因为预设会被整体搬走,`str(Path)` 的反斜杠在别的机器上是错的。
* `backgroundImage`:构建期它是**必填且必须真实存在**的。自动挑法 = 场景里**面积最大**的贴图平面
(它决定 `document.body` 的底图与取景用的宽高比,挑到一块小按钮会让整幅画的比例全错);
一块贴图平面都没有时退到第一具骨架的 atlas 第一行声明的贴图页。
* 写法与校验在同一个模块 `layout.py` 里(`build_preset` / `missing_preset_paths`):
产物形状一旦改,校验规则必须同时改,分成两个文件迟早漂移。写完之后**当场**把每条路径在磁盘上
核一遍,不留到构建期才炸。
## 五、页面侧的坑(都踩过,别再踩)
- **描述表的 `src` 表达式不能用一条 `.+?` 通吃**:kv45 的 bundle 里有个编译后的模板片段写着
`{src:e.activeIcon,alt:""}})`,`\{src:(.+?),id:"…",type:"image"\}` 会从那里一路吃 **8.8 万字符**
去够后面的 `,id:"…",type:"image"}`,把夹在中间的真表项(`{src:$w,id:"loading_dt1",…}`)整个吞掉。
现在分成两种形状各匹配各的:`_DESCRIPTOR_INLINE`(`Object.values(Object.assign({…}))[0]`)与
`_DESCRIPTOR_SIMPLE`(不含 `,{}` 的表达式)。
- **资源引用有四种写法**,缺一种就会把真资源当"不存在":
1. `X = a.p + "images/x.png"`(`_ASSET_LITERAL`)
2. `{src:a(38458),id:"x"}`:模块直接导出字符串——小图被 webpack 内联成
`e.exports="data:image/png;base64,…"`(`_MODULE_STRING`)。get-memory 的 `loading_moutain_a`、
`a01_lizi`、start-ndkl 的 `loading_start_1` 都栽在这里(少了它们,平面会报"资源表里没有 URL")。
3. `{src:$w,id:"x"}`:`$w="data:image/png;base64,…"`(`_STRING_ASSIGN`,kv45 的 `loading_dt1`)。
4. `Object.values(Object.assign({"<源路径>":"data:…"}))[0]`(`_DATA_IN_ASSIGN`)。
- **骨架数据一律内联在 bundle 里**,6 个页面里 `.atlas` / `.skel` 的网络请求数是 **0**。
只有 hsr 的骨架 json 走网络(页面根目录 `<hash>.json`),而且它在 **webpack 异步 chunk**
里(`258.ccb0954b.js`)——入口 HTML 根本没列它,得读 `.u=` 的 chunk 名映射再排队抓。
这也是 hsr 的 `scene_ava` **首次离线抓不到**的原因:那两个 json 从没进过 `_cache/`,
得先联网抓一次(见第七节的离线边界)。
- **三种内联家族**都要认:A `Object.values(Object.assign({"…/spine/<N>.json":{…}}))[0]`(JS 对象字面量,
键不带引号,用 `jslit` 规范化);B 匿名模块 + 配对表 `{atlas:fn(id),json:fn(id)}`,
骨架是 `JSON.parse('…')`(**单引号** JS 字符串,要先按 JS 语义还原);C hsr 的 `spineSetting`。
- **公共路径变量名逐页不同**:`n.p` / `a.p` / `t.p`。写死 `n.p` 会让半个页面的资源表全空。
- **同一逻辑名可能有两个候选**(引擎的桌面/移动两套预载表)。判据:数组式描述表是基准集,
字典式表是移动端覆盖(引擎里是 `desktop() || base.forEach(e => override[e.id] && …)`),桌面取基准集。
- **atlas 有两种书写风格**:`size:498,330` 与 `size: 256, 256`,解析器两种都要吃。
- **`.atlas` 的页名要读 atlas 自己声明的**(`atlas.py` 解析出来的第一行/页行),不要按 `<stem>_N` 猜:
多页是 `_2.png`,也有完全不同的名字。落盘用逻辑名,atlas 第一行不用改。
- **`skeleton.images` 要归一化**:作者目录(`../images/`)搬进分发后一定指错,写空串即可
(页名相对 atlas 所在目录解析),原值记在 `meta.json` 的 `originalImages` 里。
- **跨平台路径**:别拿 `str(Path)` 当映射键(Windows 反斜杠 vs 预设里的正斜杠),一律
`Path.as_posix()`。
## 六、多版本 Spine
`meta.json` 逐具骨架记录 `skeleton.spine` 原文。事实基线(详见 `.scratch/spine-versions/REPORT.md`):
- 版本串是**编辑器版本**;`4.0-from-4.1-from-4.2-from-4.3.23` 这类 `-from-` 串表示
**数据是 4.0 格式**(降级导出),运行库只认开头那个 `major.minor`。
- 运行库**不校验**这个串;spine-ts 4.2 能读 4.0/4.1/4.2 的数据。**4.3 数据会被静默丢掉全部约束**
(4.3 把约束并进 `root.constraints`),所以 `verify` 对 `≥4.3` 的骨架直接报红。
- 目前 7 页共 170+ 具骨架全是 ≤4.2 格式,**一套 4.2 运行库就够**;真出现 4.3 数据再谈多套 UMD 共存
(官方没有 `spine-version` 属性,只能各包一层别名函数避免 `window.spine` 互相覆盖)。
- 重分发运行库要带 Spine Runtimes License Agreement(Exhibit A)+ 版权声明,不得删各文件头。
## 七、`verify` 检查什么
逐场景目录核一遍:`meta.json` / `preset.template.json` / `scene.json` 都在;`meta.json` 的
`id` 等于目录名、字符集合法、`name`/`title`/`description` 非空、`audio.choices` 指向的文件存在;
预设里每条 `./…` 路径在磁盘上找得到;侧车引用的骨架有目录与文件、atlas 声明的贴图页逐字落盘、
几何平面的图存在;纯色与运行时纹理不计入缺失;骨架版本在运行库可读范围内。
页面级还会抓"旧形状的残留"(页面根的 `scene.json` / `spine/` / `scene/`)与"不是场景目录的目录"。
**退出码非 0 = 有硬伤**。
它不评判画面对不对——那是 `tools/checks/verify-scene-player.mts` 的事(多骨架 + 贴图平面真的合成出来)。
### 离线能跑到哪一步
`fetch --offline` 只读 `_cache/`(bundle 文本)与**已经落盘的 `_out/`**。所以:
* **重跑**永远是安全的:产物存在就跳过,不发任何请求。
* **首次**抓一个"从没抓过的场景"需要联网——它的骨架 json 与贴图页都还没有本地副本。
hsr 的 `scene_ava`(`zhigengniao_juheye` / `shajin`)就是这样:先 `fetch --page kv45`(联网)
一次,之后再 `--offline` 就完全绿。缺什么会**逐条报出来**(不会甩一条 traceback 就走)。
## 八、离线预览(`_out/` 能看,`_cache/` 不能)
| 目录 | 内容 | 能否预览 |
| --- | --- | --- |
| `_cache/<页面>/` | 原始 bundle **文本**(引用仍是线上绝对地址、贴图不在里面) | ❌ 按设计就是文本调试缓存 |
| `_out/<游戏>/<页面>/<场景>/` | 真实文件树(骨架 / atlas / 贴图页 / 场景图 / 侧车 / 预设) | ✅ **可离线预览** |
```bash
node tools/preview.mts # 列出 _out 里可预览的场景
node tools/preview.mts ys/nico-tea # 页面 = 取它的推荐场景
node tools/preview.mts ys/nico-tea/scene_main # 点名场景
node tools/preview.mts hsr/kv45/scene_ava --port 8199
node tools/preview.mts ys/nico-tea --no-serve # 只组装到 tools/.cache/preview/
```
`tools/preview.mts` 把「构建出的运行时 + staging 的资产 + 由 `scene.json` 生成的预设」组装成
一个独立目录再起静态服务;**页面只读本地文件**。页面里的调试面是 `window.__sceneDebug`
(`parts` / `loaded` / `errors` / `framing` / `rotatedSkipped`),排查"少加载了一件"直接看它。
两点说明:
- **要预览"原页面"而不是我们的产物**,得走整站镜像(`.scratch/page-mirror/spec.md`,尚未实现);
`_cache/` 不能满足这个需求——它只有文本,没有资产树,也没有改写引用。
- `tools/checks/verify-scene-player.mts` 里另有一份"从 scene.json 生成预设"的代码,
**故意不与 preview 共用**:门必须独立于被验证对象,共用一份就变成自己验自己。
## 九、`promote`:搬进 `wallpapers/`
`fetch` 已经产出终态,所以 promote 退化成 **拷贝 + 校验 + 写 meta**:
```bash
python -m tools.downloader promote --page kv45/scene_ava --game hsr --id scene_ava \
--name '挥掷千星的筹码' --title '【崩坏:星穹铁道】挥掷千星的筹码' --description '[b]挥掷千星的筹码[/b]'
```
* `--page` 认三种写法:页面 id(`kv45`)、`页面/场景`(`kv45/scene_ava`)、场景目录的路径。
给页面 id 时用 `--scene` 点名,或让它取 `page.json` 的 `chosenScene`。
* `--name` / `--title` / `--description` 可选:不给就沿用场景目录 `meta.json` 里的
(那里已经是"页面名(场景id)"的形状,够用但通常要改成正式文案)。
* `--cover` 给逻辑名时覆盖 `backgroundImage`。
* **目标目录里的 `meta.json` 的 `id` 会被改写成 `--id`**——所以壁纸 id 可以跟场景目录名不同,
但目录名与 `meta.json.id` 必须一致(构建期会校验)。
* 搬之前会再核一遍预设里的每条路径在**目标目录**上存在;搬完不用手工改任何路径。
也可以完全不用 promote:**整个场景目录拷到 `wallpapers/<游戏id>/<壁纸id>/` 就完事**
(前提是目录名 = `meta.json.id`,且那个目录 `usable`)。两种做法等价,promote 只是顺手改名与校验。
## 十、还没做的
- **音源**:页面 BGM 是另一条链,`audios/` 现在是空的,`meta.json` 的 `audio.choices` 也是空的。
- **预览图**:没有抓,也没有生成。
- **`scene_ui` 这类纯渲染目标场景**:落盘但不可搬走(运行时不会 `drawScene`)。