# SpineWallpaper
**把米哈游活动页里的 Spine 立绘与整页场景,做成一份可以直接上传 Wallpaper Engine 的网页壁纸。**








## 📚 目录
- [✨ 项目简介](#-项目简介)
- [🧩 特性](#-特性)
- [🚀 快速开始](#-快速开始)
- [📦 三种产物](#-三种产物)
- [🧪 本地调试](#-本地调试)
- [🛠 命令与配置](#-命令与配置)
- [🗂 目录结构](#-目录结构)
- [🏗 架构与数据流](#-架构与数据流)
- [🔬 取景算法](#-取景算法)
- [✅ 质量门禁](#-质量门禁)
- [📥 资源抓取](#-资源抓取)
- [🚧 已知约束](#-已知约束)
- [🧠 决策记录](#-决策记录)
- [🤝 参与与反馈](#-参与与反馈)
- [📄 许可证](#-许可证)
- [🙏 致谢](#-致谢)
## ✨ 项目简介
这是一个 **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`)。