# SpineWallpaper **把米哈游活动页里的 Spine 立绘与整页场景,做成一份可以直接上传 Wallpaper Engine 的网页壁纸。** ![Node](https://img.shields.io/badge/node-%E2%89%A524-339933?logo=nodedotjs&logoColor=white) ![pnpm](https://img.shields.io/badge/pnpm-11-F69220?logo=pnpm&logoColor=white) ![TypeScript](https://img.shields.io/badge/TypeScript-strict-3178C6?logo=typescript&logoColor=white) ![Wallpaper Engine](https://img.shields.io/badge/Wallpaper%20Engine-Web%20%E7%B1%BB%E5%9E%8B-1F1F1F) ![版本](https://img.shields.io/badge/version-3-7C4DFF) ![门禁](https://img.shields.io/badge/gates-pnpm%20check-2EA043) ![许可证](https://img.shields.io/badge/license-%E6%9C%AA%E5%A3%B0%E6%98%8E-lightgrey) ![预览](wallpapers/preview.gif) ## 📚 目录 - [✨ 项目简介](#-项目简介) - [🧩 特性](#-特性) - [🚀 快速开始](#-快速开始) - [📦 三种产物](#-三种产物) - [🧪 本地调试](#-本地调试) - [🛠 命令与配置](#-命令与配置) - [🗂 目录结构](#-目录结构) - [🏗 架构与数据流](#-架构与数据流) - [🔬 取景算法](#-取景算法) - [✅ 质量门禁](#-质量门禁) - [📥 资源抓取](#-资源抓取) - [🚧 已知约束](#-已知约束) - [🧠 决策记录](#-决策记录) - [🤝 参与与反馈](#-参与与反馈) - [📄 许可证](#-许可证) - [🙏 致谢](#-致谢) ## ✨ 项目简介 这是一个 **Wallpaper Engine 网页壁纸(Web 类型)作品 + 围绕它的构建与验证管线**。源数据只有一份 (`wallpapers/`),其余全部由 `pnpm build` 生成:单档分发、游戏合集、全部合集,各自都能整体拷到别的机器直接上传。 当前收录 3 档壁纸、2 个游戏,共 6 个分发: | 分发目录 | 内容 | 体积 | | -------------------- | --------------------------------------------------------- | -------- | | `single-hsr-xilian` | 昔涟立绘(单骨架) | 86.0 MB | | `single-hsr-kv37` | 「成为昨日的明天」专题页(单骨架 + 显式像素视口) | 11.6 MB | | `single-ys-nico-tea` | 魔女尼可的茶会(多骨架 + 场景合成) | 9.7 MB | | `collection-hsr` | 上面前两档的切换合集 | 97.0 MB | | `collection-ys` | 原神这一档 | 9.7 MB | | `collection-all` | 全部壁纸合集(**已发布**的那一档,创意工坊 `3604974793`) | 106.3 MB | > [!NOTE] > > `dist/` 是纯产物目录(全量约 390 MB),已 gitignore;真相永远在 `wallpapers/` 与 `src/`。 > 术语(作品 / 分发 / 合集 / 单档 / 预设 / 视口 / 对拍……)见 [CONTEXT.md](CONTEXT.md)。 ## 🧩 特性 - 🎞 **保留作者原始观感**:Spine 骨架按原始世界单位渲染,不做矩形归一化;16:9 下与作者取景逐像素一致。 - 🖼 **跨比例构图同步**:立绘层的缩放跟着背景图的 `cover` 走,竖屏 / 带鱼屏不会出现「特效与插画脱节」。 - 🔀 **多预设切换**:合集分发用 WE 的 `preset` 下拉在壁纸之间切换,音源、背景、骨架整套跟随。 - 🎵 **音源可切换**:每档壁纸自带音源清单(无损 FLAC 优先),另有「随预设」与自定义 URL 两种模式。 - 🔊 **音量 / 作者信息 / 主题配色**:全部走 WE 原生属性表,样式与官方面板对齐。 - 🧪 **可在没有 WE 的机器上调试**:内置一层严格还原官方 API 面的 WE 模拟器,另可产出双击即看的自包含包。 - 🧱 **自包含产物**:每个分发目录都是完整的字节副本,不出现任何绝对链接与跨目录相对链接。 - 📐 **带门禁**:从源数据校验、产物自包含、逐像素对拍到面板还原度,共 24 个验收脚本(见 [质量门禁](#-质量门禁))。 ## 🚀 快速开始 环境要求: | 依赖 | 版本 | 用途 | | ------- | ------------------------------ | --------------------------------------------------------- | | Node.js | ≥ 24(开发用 26) | 构建、调试服、验收脚本 | | pnpm | 11.x | 包管理与任务入口 | | Python | 3.10+ 与 PyYAML | **只有**资源抓取(`tools/downloader/`)需要,不需要浏览器 | | 浏览器 | Chromium 内核(Edge / Chrome) | 调试服、对拍与面板验收 | ```shell # 1) 装依赖(只有 typescript 与 @types/node) pnpm install # 2) 全量编译六个分发到 dist/releases/ pnpm build # 3) 起调试服(默认注入 WE 模拟器),浏览器打开 /release/<分发目录>/ node tools/dev.ts --port 5173 # 4) 跑一遍全部质量门禁(类型 + 构建 + 语法 + 路径 + 产物自包含 + 文案归属) pnpm check ``` > [!TIP] > > 只想看某几档、不想等全量拷贝时,用 `pnpm build <壁纸id>` / `pnpm build collect hsr` / > `pnpm build single`;产物目录默认会清掉本次未构建的旧分发,保留用 `--no-clean`。 ## 📦 三种产物 「产物形态」是这个项目最容易混的地方,`pnpm build` 的三档开关分别对应三种用途: | 用途 | 命令 | 产物 | 关键性质 | | ------------------------------ | ----------------------- | ---------------------------------- | -------------------------------------------------- | | 📤 上传创意工坊 | `pnpm build` | `dist/releases/<分发目录>/` | 干净,**没有**任何模拟器痕迹 | | 🌐 静态托管(如 GitHub Pages) | `pnpm build --with-sim` | 同上,`index.html` 里多一段驱动 | 地址必须是**相对**路径(子路径下根绝对路径会 404) | | 🖱 双击即看 / 离线分享 | `pnpm build sim` | 额外产出 `<分发根>/sim/index.html` | 资源全内联(单页 15 MB~129 MB),`file://` 可用 | 类型只有两种:**单档**与**合集**,区别在收档范围(`scope: "game" | "all"`)—— 「全部合集」也是合集,不是第三种类型。 > [!WARNING] > > 自包含包会因为体积被 GitHub 的单文件上限挡住(合集约 128.7 MB > 100 MB), > 所以静态托管走的是「分发目录 + 构建期注入」,而不是直接发布 `sim/index.html`。 ## 🧪 本地调试 ```mermaid flowchart TD Q{"这次要做什么?"} --> A["改 runtime / 模拟器源码"] Q --> B["验证渲染是否逐像素一致"] Q --> C["发给别人看 / 静态托管"] Q --> D["上传创意工坊"] Q --> E["离线分享,双击即看"] A --> A1["node tools/dev.ts --port 5173
热更新 + WE 模拟面板"] B --> B1["node tools/serve.mjs 8190 --root dist/releases/collection-all
+ tools/capture.mjs + verify-visual-equivalence"] C --> C1["pnpm build --with-sim"] D --> D1["pnpm build(默认产物,无注入痕迹)"] E --> E1["pnpm build sim"] ``` 模拟器只在需要时出现,**永不进发布产物**:调试服按路由注入、静态托管构建期注入、上传 WE 用默认产物零注入。 > [!IMPORTANT] > > 调试服监听 `src/` 与 `wallpapers/`,改 CSS 只换样式表、改 runtime 整页刷新。 > **跑 `test-inline-guard` 前必须先停掉调试服**——它会故意改坏 `src/vendor/` 来验证守卫,两者会抢构建。 ## 🛠 命令与配置 ### npm scripts | 命令 | 作用 | | ------------------ | --------------------------------------------------------------------- | | `pnpm dev` | 调试服(SSE 热更新 + 按路由注入 WE 模拟器) | | `pnpm build` | 全编:所有单档 + 每个游戏合集 + 全部合集 | | `pnpm build:pages` | 静态托管用产物(`--with-sim`) | | `pnpm typecheck` | `tsc` 全量类型检查(含验收脚本) | | `pnpm check` | 门禁总入口:类型 → 构建 → 语法 → 路径 → 产物自包含 → 合集文案归属 | | `pnpm serve` | 静态服务器(对拍用),`node tools/serve.mjs <端口> --root <分发目录>` | | `pnpm capture` | 按 CDP 真实时间拍摄渲染图 | ### 构建开关 | 开关 | 作用 | | ------------------ | ----------------------------------------------------- | | `--with-sim` | 让分发自带模拟器驱动(静态托管预览用) | | `--sim` | 额外产出双击可看的自包含包 | | `--no-embed-audio` | `--sim` 时音频不内联(包更小,但 `file://` 下播不了) | | `--strict` | 把 warning 升级为 failure | | `--no-clean` | 保留本次未构建的旧分发目录 | | `--dry-run` | 只打印计划,不落盘 | ### 源数据配置 - `wallpapers/<游戏id>/<壁纸id>/meta.json`:显示名、发布文案(BBCode)、预览图、音源清单。 - `wallpapers/<游戏id>/<壁纸id>/preset.template.json`:背景图、骨架 / 场景、动画名、取景。 - `wallpapers/<游戏id>/meta.json`:游戏显示名、共享音频、`title`(该游戏合集的标题,**必填**)。 - `wallpapers/meta.json`:全局文案与 `defaultPresetId`(决定 `preset` 下拉首项)。 - `wallpapers/sources.yml`:抓取目标页清单(**不是构建输入**)。 - `VERSION`:写进 `project.json.version`(WE 要求数字)。 完整字段表与目录规则见 [wallpapers/README.md](wallpapers/README.md) —— **动 `wallpapers/` 之前先读它**。 ## 🗂 目录结构 ```text SpineWallpaper/ ├── wallpapers/ ← 唯一真相来源(构建的全部输入) │ ├── meta.json ← 全局文案(= collection-all 那一档) │ ├── preview.gif │ └── <游戏id>/ │ ├── meta.json ← 游戏显示名 / 共享音频 / 合集标题 │ └── <壁纸id>/ │ ├── meta.json ← 发布文案 + 音源清单 │ ├── preset.template.json ← 运行时配置(背景 / 骨架 / 取景) │ ├── spines/<骨架名>/ ← 一具骨架一组:json + atlas + 贴图页 │ ├── scene/ ← 场景图与背景图 │ ├── audios/ ← 音源 │ └── preview.gif ├── src/ │ ├── runtime/ ← 运行时(preset / 背景 / 骨架 / 场景 / 音频 / 取景) │ ├── simulator/ ← WE 模拟器(严格还原官方 API 面) │ ├── styles/ templates/ ← 页面骨架与样式 │ ├── vendor/spine-player.js ← spine-ts 运行时副本 │ └── project.template.json ← project.json 的骨架 ├── tools/ │ ├── build.ts dev.ts serve.mjs capture.mjs │ ├── lib/{vault,generate,bundle,types,fs,drivers}.ts │ ├── checks/ ← 验收套件(24 个脚本) │ └── downloader/ ← 米哈游活动页 Spine 抓取器(Python) ├── docs/adr/0001–0008 ← 架构决策记录 ├── CONTEXT.md ← 领域术语表 └── dist/, build/ ← 产物与编译中间物(gitignore) ``` ## 🏗 架构与数据流 ```mermaid flowchart LR subgraph SRC["源数据 · wallpapers/(唯一真相)"] W["壁纸目录
spines/ · scene/ · audios/
meta.json · preset.template.json"] end subgraph FETCH["抓取器(可选)"] DL["tools/downloader
fetch → staging → promote"] end subgraph BUILD["pnpm build"] V["lib/vault.ts
读取 + 校验"] G["lib/generate.ts
合成 preset.js / project.json"] B["lib/bundle.ts
自包含包"] end subgraph OUT["dist/releases/"] S["single-游戏-壁纸"] C["collection-游戏 / collection-all"] SIM["分发根/sim/index.html"] end WE["Wallpaper Engine
浏览器 / 静态托管"] DL -->|promote 后再进源数据| W W --> V --> G G --> S G --> C G --> B --> SIM S --> WE C --> WE SIM -->|file:// 双击| WE ``` 构建只做**拷贝**,不做链接:早先考虑过 NTFS 硬链接省磁盘,被否掉了——链接会让「产物自包含」这条 基本性质失效,拷到别的机器或只拷一个分发目录时,链接目标不在产物就残了。 ## 🔬 取景算法 背景图走 CSS `background-size: cover`(铺满、按需裁切),而播放器默认按「含住作者视口」(contain) 映射立绘层——两者只在作者调参的那个比例(16:9)下一致。项目让立绘层跟着背景的缩放走: $$ \operatorname{coverScale}(a,\ a_{\text{img}}) = \max\!\left(\frac{a}{a_{\text{img}}},\ 1\right) $$ $$ V_h = V_0.h \times \frac{\operatorname{coverScale}(16/9,\ a_{\text{img}})}{\operatorname{coverScale}(a,\ a_{\text{img}})}, \qquad V_w = V_h \cdot a $$ 其中 $a$ 是当前画布宽高比,$a_{\text{img}}$ 是背景图自身的宽高比(运行时读 `naturalWidth / naturalHeight`, 不在预设里重复写容易写错的数字),$V_0$ 是作者视口换算到 16:9 后的可见矩形。中心不变, 等价于「立绘层相对背景插画永远 cover」。实现是纯函数,见 [viewport-fitter.ts](src/runtime/viewport-fitter.ts),决策见 [ADR 0003](docs/adr/0003-spine-follows-background-cover.md)。 > [!IMPORTANT] > > 应用方式是**原地改写** `config.viewport` 的字段后调 `player.setViewport(动画名)`, > 绝不整体替换该对象——`setViewport()` 会无条件读 `config.viewport.animations[name]`, > 换掉整个对象会在 `spine-player.js` 第 14973 行崩溃。维护时机挂在 `config.frame` 回调上。 ## ✅ 质量门禁 `pnpm check` 是最小闭环(类型 + 构建 + 语法 + 路径 + 产物自包含 + 合集文案归属)。其余脚本前置条件不同, **没有一条命令能全跑完**;完整清单与逐条调用方式见 [tools/checks/README.md](tools/checks/README.md)。 ```shell # ① 不需要服务器 pnpm check pnpm build sim node tools/checks/diff-project.mts # 产物 project.json vs 已发布基线 node tools/checks/check-collection-copy.mts # 游戏合集的文案必须是"它自己那个游戏"的 node tools/checks/verify-sim-page.mts <分发名> # 自包含包 file:// 可用(含"零 http 请求") # ② 需要 tools/serve.mjs(对拍驱动 window.__test 只有它注入) node tools/serve.mjs 8190 --root dist/releases/collection-all # 另开一个终端 node tools/checks/test-props.mjs test-resize.mjs test-acceptance.mjs # ③ 需要 pnpm dev(模拟器面板) node tools/dev.ts --port 5173 --no-build node tools/checks/verify-panel.mts http://127.0.0.1:5173 # 面板还原度(12 组) node tools/checks/check-single-bgm.mts http://127.0.0.1:5173 # 单档里切歌真的换音源 ``` > [!CAUTION] > > **一个从不失败的检查等于没有检查。** 门禁类脚本必须先证明它会红(注入一个坏东西、确认报错、再还原)。 > 写 `.mjs` 时不要加类型注解——它是纯 JS,`tsc` 不管它,注解会在运行时抛 SyntaxError。 > 另外:不要与调试服同时跑会写 `dist/` 的构建,watcher 会和你抢。 ## 📥 资源抓取 `tools/downloader/`(Python)从 `wallpapers/sources.yml` 列出的活动页抓 Spine 骨架、贴图与场景装配信息到 staging(`_out/`)。**每个有内容的场景各产出一个可直接搬走的壁纸目录**——整个目录挪进 `wallpapers/<游戏id>/` 就能用,也可以让 `promote` 代劳。它**不是构建输入**: ```shell python -m tools.downloader fetch --page kv45 # 抓一页的全部场景(结尾自动 verify) 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 # 只用缓存 + 已落盘产物重跑,不发任何请求 python -m tools.downloader promote --page kv45/scene_ava --game hsr --id scene_ava … # 搬进 wallpapers/ node tools/checks/check-downloader.mts # 门:编译 + 夹具绿 + 四处破坏必红 ``` 场景装配信息写成 `scene.json` 侧车(在每个场景目录内),**构建期烘焙进 `preset.js`**,运行时不再读侧车。 详见 [tools/downloader/README.md](tools/downloader/README.md)。 ## 🚧 已知约束 - **WE 运行时(本机 2.8.0.485 / CEF 146)**:官方只注入 `window.wallpaperPropertyListener` (`applyUserProperties` / `applyGeneralProperties` / `setPaused` / `userDirectoryFiles*`), `window.wallpaperRegisterListener` **不存在**——按它自检会在真实 WE 里误判。属性事件可能在 `window.load` 之前到达,注册必须在模块顶层。 - **音频编码**:CEF 的 FFmpeg 白名单含 flac / vorbis / opus / mp3 / pcm,**不含 aac**,所以 m4a 大概率不可用; 项目按 [ADR 0004](docs/adr/0004-lossless-audio.md) 优先无损 FLAC,不按体积取舍。 - **帧率**:WE 不限流 `requestAnimationFrame`,用户的 fps 上限经 `applyGeneralProperties` 下发,由壁纸自己实现。 - **源码语法**:Node 的 strip-only TS 模式不支持 `enum`、构造函数参数属性、`namespace`,源码里禁用这三种写法 (`pnpm check:syntax` 会拦);类型检查交给 `tsc`。 - **第三方体积**:`src/vendor/spine-player.js` 是 spine-ts 运行时的打包副本,修改前请先确认上游授权。 - **无托管 CI**:`package.json` 是 `private`,没有 `.github/workflows`;门禁全部在本地跑(见上节)。 ## 🧠 决策记录 | ADR | 决策 | | --------------------------------------------------------- | --------------------------------------------------------------------------------- | | [0001](docs/adr/0001-resource-layout.md) | 资源按「游戏 / 壁纸」分层,目录用 id、显示名只进元数据 | | [0002](docs/adr/0002-preset-as-es-module.md) | 预设用 ES module(`preset.js`)而不是 JSON,路径相对自身推导 | | [0003](docs/adr/0003-spine-follows-background-cover.md) | 立绘层取景跟随背景图的 cover 缩放(16:9 为参考比例) | | [0004](docs/adr/0004-lossless-audio.md) | 音频用无损,不按体积取舍 | | [0005](docs/adr/0005-build-pipeline-and-dist-topology.md) | 构建管线与分发拓扑:id 化目录、不产生任何链接、合集层级 | | [0006](docs/adr/0006-simulator-contract.md) | 模拟器契约:还原 WE 的 API 面、不自检、替身显式化 | | [0007](docs/adr/0007-self-contained-sim-build.md) | 自包含调试包的产出方式与两个基准 | | [0008](docs/adr/0008-asset-layout-per-skeleton.md) | 资源布局按「一具骨架一组」(`spines/` + `scene/`),取代 0001 / 0005 的按类型分层 | ## 🤝 参与与反馈 这是作者个人的壁纸作品仓库,但欢迎反馈问题与建议: - 🐛 **问题与规格**按仓库约定写在 `.scratch//` 下(见 [docs/agents/issue-tracker.md](docs/agents/issue-tracker.md)), 而不是只留在聊天里。 - 🧭 **改代码前**:先读 [CONTEXT.md](CONTEXT.md)(术语)与相关 ADR(决策), 再读 [AGENTS.md](AGENTS.md)(本仓库给 agent 的约定)。 - ✅ **提交前**:`pnpm check` 必须全绿;新增门禁要先证明它会红。 - 📦 **改 `wallpapers/` 前**:先读 [wallpapers/README.md](wallpapers/README.md)。 - 🎨 **改面板 / 模拟器前**:先看 [ADR 0006](docs/adr/0006-simulator-contract.md) 与 `tools/checks/verify-panel.mts`。 ## 📄 许可证 仓库当前**没有声明开源许可证**(`package.json` 为 `private`),代码与素材默认保留所有权利; 若要复用,请先联系作者。第三方组件遵循其各自授权: - Spine 运行时(`src/vendor/spine-player.js`):spine-ts,授权随上游 [Spine Runtimes License](https://esotericsoftware.com/spine-runtimes-license)。 - 壁纸中的立绘、场景与音频素材版权归米哈游 / 相关权利人所有,本仓库仅作个人壁纸作品使用。 ## 🙏 致谢 - [Spine](https://esotericsoftware.com/) / spine-ts:骨架动画运行时。 - [Wallpaper Engine](https://www.wallpaperengine.io/):宿主与属性面板 API。 - 米哈游活动页:立绘与场景的原始来源(各档来源 URL 见 `wallpapers/sources.yml` 与各壁纸的 `preset.template.json`)。