# 规格:场景播放器(一档壁纸 = 一整页场景) > ⚠ 本文写于 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//.json` | 骨架(`skeleton.images` 已归一化为空串) | | `spine//.atlas` | 第一行 = 贴图页名,与落盘文件名逐字一致 | | `spine//meta.json` | `spine` 版本 / `animations` / `skins` / `pages` / `originalImages` | | `scene/.` | `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//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` / 音源清单)?