Files
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

162 lines
10 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 规格:场景播放器(一档壁纸 = 一整页场景)
> ⚠ 本文写于 ADR 0008 之前:下面的 preset 示例里 `./images/…`、`./effects/<名>/…` 是**当时的布局**,
> 现在读作 `./scene/…`、`./spines/<名>/…`(字段与规则没变,只是目录名换了)。
> 状态:**已落地**(2026-09-20)。抓取侧 6 页 74.15 MB 已落 staging,`verify` 绿。
> 门:`node tools/checks/verify-scene-player.mts`(夹具绿 + atlas 指错必红 + 还原重新变绿)。
> 本文只覆盖播放器与预设 schema,不含 promote 与文案。
## 落地记录(与规格的三处差异)
1. **取景参考矩形改成"所有 part 变换后的并集包围盒"**,不是 `ui` 矩形。
页面相机看向的是**场景原点**,而 `ui` 矩形的原点是它的左下角——直接拿它当可见区会把整个场景推向右上。
旧项目用的也是并集包围盒。`ui` 只留作字段,不再参与取景。
2. **`flipY` 的实测结论**:`position[1]` 取反(默认)即可让 kv45 的场景正立;
`scaleY` **不取反**(骨架本身在 spine-webgl 下渲染就是正的)。实测截图见
`tools/.cache/scene-player-shot.png`。
3. **glTF 网格平面的尺寸已补上**(同日):抓取期解 bundle 里的 `geometries` 表(`position.array` 是扁平 xyz),
算顶点包围盒 → `geometrySize` / `geometryCenter` 落进 `scene.json` 与 preset。实测这些网格都是中心为零的四边形;
kv45 的 `w22_slg` 其实是 `geometry.type: 2`(自带 config 尺寸),不受影响。
## 仍未做(按价值排序)
1. **modifier(`wiggle` / `BEZIER_PARTICLE` 粒子 / `CSS3DObject` DOM 层)**——真正的观感缺口:
粒子与摆动现在一律静态化。这是一个 feature 级工程(页面插件系统 + 贝塞尔粒子参数 + wiggle 数学),
值得单独立规格。
2. **纯色平面(`kind: "solid"`)不画**:它没有贴图,而 SceneRenderer 只提供 `drawTexture`/`drawSkeleton`
(传 undefined 会直接崩,已修)。数量进 `__sceneDebug.solids`,验收断言它能被看见;
要真画就得走 `ShapeRenderer` 那条路。
3. **页面 material 的混合模式**:实测 `blending` 只有 `1`(Normal)与 `2`(Additive,6 页共 1 处)。
`PolygonBatcher.setBlendMode(mode, pma)` 存在、映射也清楚(three.js 枚举 → spine BlendMode),
但收益只有一个节点,**暂不做**。
4. 旋转合成(抓取期与运行时都只记录)。
5. promote 进 `wallpapers/`(需要壁纸文案与音源清单)。
## 一、目标
让一档壁纸能播放"整页场景":**N 具骨架 + M 块贴图平面**,按抓取期定下的世界变换与绘制层级合成。
单骨架路径(`spineConfig`,xilian / kv37)**一行不动**——它被 `tools/shots/baseline-pre-refactor/`
的冻结基线钉着,`verify-visual-equivalence.mts` 靠它判等。
非目标(本轮):
- 不换 vendored 包(`src/vendor/spine-player.js` 是 spine-ts 4.2 线)、不引三方依赖;
- 不做几何平面的动画(`BEZIER_PARTICLE` 粒子等先静态化);
- 不做 promote(等本规格定案;验证走临时夹具,不碰 `wallpapers/`);
- 不做旋转合成(与抓取期一致:`rotation` 只记录不参与,`stats.rotatedNodes` 报数量)。
## 二、数据契约(抓取侧已产出,别再改形状)
`tools/downloader/_out/<游戏>/<页面>/`:
| 文件 | 内容 |
| --- | --- |
| `scene.json` | `{version, id, ui, camera, parts[], stats}`;part = `{kind, id, order, renderOrder, position[3], scale[3], localPosition?, localScale?, rotation?, geometryType?, modifiers?, runtime?}` |
| `spine/<id>/<id>.json` | 骨架(`skeleton.images` 已归一化为空串) |
| `spine/<id>/<id>.atlas` | 第一行 = 贴图页名,与落盘文件名逐字一致 |
| `spine/<id>/meta.json` | `spine` 版本 / `animations` / `skins` / `pages` / `originalImages` |
| `scene/<id>.<ext>` | `kind: "image"` 的平面贴的图 |
| `page.json` | 来源 URL、入口脚本、场景清单、警告 |
- `kind: "solid"`(`USE_TEXTURE == 0` 的纯色平面)**没有资源**,只按几何尺寸画一块颜色。
- `"runtime": true` 的 image part 是**由骨架渲染进贴图缓冲**的平面(`cacheContainer`),
运行时不下载也不画(它本来就是骨架的中间结果)。
- 世界变换语义:`world_position = parent_position + parent_scale ⊙ local_position`、
`world_scale = parent_scale ⊙ local_scale`;`order` = 树序遍历序(绘制层级)。
## 三、预设 schema
**单骨架(现状,不动)**
```json
{
"backgroundImage": "./images/ava.jpg",
"spineConfig": { "jsonUrl": "./effects/x.json", "atlasUrl": "./images/x.atlas",
"animation": "idle", "viewport": { "padLeft": "-25%" } }
}
```
**场景(新)**
```json
{
"backgroundImage": "./images/cover.jpg",
"sceneConfig": {
"ui": [2500, 1080],
"parts": [
{ "kind": "image", "id": "main_sky_jpg", "image": "./images/main_sky_jpg.jpg",
"order": 0, "renderOrder": 0, "position": [0, 0, 0], "scale": [1, 1, 1] },
{ "kind": "spine", "id": "main_nike",
"jsonUrl": "./effects/main_nike/main_nike.json",
"atlasUrl": "./images/main_nike/main_nike.atlas",
"animation": "眨眼",
"order": 3, "renderOrder": 0, "position": [-120.5, 480.25, 0], "scale": [1.09, 1.09, 1] }
]
}
}
```
约定:
- `spineConfig` 与 `sceneConfig` **二选一**;同时出现时**构建期直接报错**(不做静默优先级,歧义会烂在产物里)。
- part 的摆放与层级**由构建从 `scene.json` 抄进 `preset.js`**(构建期烘焙),运行时只读 `preset.js`:
分发自包含、`check:dist` 已经会校验 preset.js 引用的文件都在,且避免运行时多一次 sidecar 探测。
`scene.json` 留在抓取器 `_out/` 作留档与重跑凭据。
- 每个 part 的路径都用现有 `asset()`(`import.meta.url` 推导)解析;缺文件由构建 fail-fast + `check:dist` 兜住。
- `animation` 缺省 = 该骨架的第一个动画(清单在 `spine/<id>/meta.json` 的 `animations` 里,抓取期已记)。
- `Preset` 接口新增可选 `sceneConfig`;`generatePresetModule` 新增一个分支(与 `spineConfig` 对称)。
## 四、运行时
新增 `src/runtime/scene-controller.ts`,**不改** `spine-controller.ts`:
- 画布与渲染器:用 vendored 包已导出的 `ManagedWebGLRenderingContext` + `SceneRenderer`
自建(已核实这些名字都在 UMD 导出表里:`SkeletonJson` / `TextureAtlas` /
`AtlasAttachmentLoader` / `AnimationState` / `AssetManager` / `SceneRenderer` / `SpineCanvas` /
`ResizeMode` / `OrthoCamera` / `ManagedWebGLRenderingContext`)。
**不经过 `spine.SpinePlayer`**——`config.draw` 只在宿主骨架画完之后调用(vendored 包 15128 行),
画不出"位于宿主下面的层",而 nico-tea 的 `scene_main` 第一件就是贴图平面。
- 加载:每个 part 一个 `AssetManager`(或 `SkeletonJson` + `TextureAtlas` + `AtlasAttachmentLoader`)。
贴图直通 alpha、`premultipliedAlpha=false`(沿用现有渲染约定)。
- 每帧:逐 spine part `AnimationState.update(delta)` → `apply(skeleton)` →
`updateWorldTransform(Physics.update)`;然后 `renderer.begin()` → 按 `order` 依次
`drawTexture(...)`(image/solid)/ `drawSkeleton(skeleton, pma, ..., transform)` → `renderer.end()`。
- fps 门控:WE 不替壁纸限流,沿用现有"包一层排帧函数"的做法(自持循环后更简单)。
- 取景:`ui` 矩形(如 2500×1080)按等比 contain 映射到画布,再套现有背景比例链
(`viewport-fitter.frameForAspect`)——与单骨架的构图行为保持一致,`framing: "author"` 仍然生效。
- y 轴:页面是 y-up(three.js),spine 是 y-down;抓取期 `scene.json` 的坐标保持页面原样,
方向在渲染时统一处理(先按"整体 y 取反"实现,实测后定)。
- 就绪与失败:`window.__sceneDebug = { parts, loaded, errors, framing }` 供验收断言;
加载失败**显式记录**,不静默降级。
## 五、验证(不碰 `wallpapers/`)
1. `tools/checks/verify-scene-player.mts`(新):
- 临时目录里放一份**生成的** scene preset + staged 的 `kv45`(2.8 MB、4 骨架 + 1 平面,最小样本)
的资产拷贝 + 构建出的 `dist/scripts/spine-player.js`;
- 起一个临时静态服务(复用 `tools/serve.mjs` 的思路),用现有 CDP 管线(`tools/cdp.mjs`)打开;
- 断言:`__sceneDebug.parts` 全部 loaded、`errors` 为空、0 未捕获异常、画布非空、
取景矩形包含 parts 的变换后包围盒。
2. **先证明它会红**:故意把某个 part 的 `atlasUrl` 指错 → 断言错误被报出来、且 `loaded < parts`。
3. 现有门必须全绿:`pnpm check`(含 `check:dist` 自包含)与 `verify-visual-equivalence`
(单骨架基线,**不许动**)。
## 六、风险
| 风险 | 处置 |
| --- | --- |
| y 轴方向 / 页面相机(type 1 透视 fov、type 2 正交)不一致 | 先用正交近似;拿 nico-tea(camera type 1)实测,必要时把相机参数也抄进 preset |
| 页面 material 的混合模式(`【相加】`/`【滤色】` 等) | 先支持 NORMAL / ADD,其余记进 `page.json` 的 warnings,不假装还原 |
| 粒子与 modifier(`BEZIER_PARTICLE` / `wiggle` / `CSS3DObject`) | 本轮静态化,只画 diffuse 贴图 |
| 单骨架路径被误伤 | 新控制器独立文件;预设二选一由构建期报错保证;基线对拍门 |
## 七、待你定的三件事
1. **part 变换抄什么**:直接抄 `scene.json` 的世界变换(我建议;简单、且抓取期已按引擎语义算过),
还是抄 local + 树结构让运行时自己合成(更忠实,但运行时更复杂、且要复刻引擎语义)?
2. **`backgroundImage` 从哪来**:场景里没有"整页背景"这一件,但 WE 面板预览与单骨架路径都要它。
建议抓取期另挑一张全屏图当封面(或把场景第一张全屏 image part 复用为 backgroundImage)。
3. **验证样本**:先用 staged 的 kv45 走临时夹具(不碰 `wallpapers/`),还是直接 promote 一页
(那就需要你给壁纸的 `name` / `title` / `description` / 音源清单)?