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

10 KiB
Raw Permalink Blame History

规格:场景播放器(一档壁纸 = 一整页场景)

⚠ 本文写于 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

单骨架(现状,不动)

{
  "backgroundImage": "./images/ava.jpg",
  "spineConfig": { "jsonUrl": "./effects/x.json", "atlasUrl": "./images/x.atlas",
                   "animation": "idle", "viewport": { "padLeft": "-25%" } }
}

场景(新)

{
  "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 / 音源清单)?