SpineWallpaper

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

Node pnpm TypeScript 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。

🧩 特性

  • 🎞 保留作者原始观感: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) 调试服、对拍与面板验收
# 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。

🧪 本地调试

flowchart TD
    Q{"这次要做什么?"} --> A["改 runtime / 模拟器源码"]
    Q --> B["验证渲染是否逐像素一致"]
    Q --> C["发给别人看 / 静态托管"]
    Q --> D["上传创意工坊"]
    Q --> E["离线分享,双击即看"]
    A --> A1["node tools/dev.ts --port 5173<br/>热更新 + WE 模拟面板"]
    B --> B1["node tools/serve.mjs 8190 --root dist/releases/collection-all<br/>+ 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/ 之前先读它。

🗂 目录结构

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)

🏗 架构与数据流

flowchart LR
    subgraph SRC["源数据 · wallpapers/(唯一真相)"]
        W["壁纸目录<br/>spines/ · scene/ · audios/<br/>meta.json · preset.template.json"]
    end
    subgraph FETCH["抓取器(可选)"]
        DL["tools/downloader<br/>fetch → staging → promote"]
    end
    subgraph BUILD["pnpm build"]
        V["lib/vault.ts<br/>读取 + 校验"]
        G["lib/generate.ts<br/>合成 preset.js / project.json"]
        B["lib/bundle.ts<br/>自包含包"]
    end
    subgraph OUT["dist/releases/"]
        S["single-游戏-壁纸"]
        C["collection-游戏 / collection-all"]
        SIM["分发根/sim/index.html"]
    end
    WE["Wallpaper Engine<br/>浏览器 / 静态托管"]
    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,决策见 ADR 0003。

Important

应用方式是原地改写 config.viewport 的字段后调 player.setViewport(动画名), 绝不整体替换该对象——setViewport() 会无条件读 config.viewport.animations[name], 换掉整个对象会在 spine-player.js 第 14973 行崩溃。维护时机挂在 config.frame 回调上。

✅ 质量门禁

pnpm check 是最小闭环(类型 + 构建 + 语法 + 路径 + 产物自包含 + 合集文案归属)。其余脚本前置条件不同, 没有一条命令能全跑完;完整清单与逐条调用方式见 tools/checks/README.md。

# ① 不需要服务器
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 代劳。它不是构建输入:

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。

🚧 已知约束

  • 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 优先无损 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 资源按「游戏 / 壁纸」分层,目录用 id、显示名只进元数据
0002 预设用 ES module(preset.js)而不是 JSON,路径相对自身推导
0003 立绘层取景跟随背景图的 cover 缩放(16:9 为参考比例)
0004 音频用无损,不按体积取舍
0005 构建管线与分发拓扑:id 化目录、不产生任何链接、合集层级
0006 模拟器契约:还原 WE 的 API 面、不自检、替身显式化
0007 自包含调试包的产出方式与两个基准
0008 资源布局按「一具骨架一组」(spines/ + scene/),取代 0001 / 0005 的按类型分层

🤝 参与与反馈

这是作者个人的壁纸作品仓库,但欢迎反馈问题与建议:

  • 🐛 问题与规格按仓库约定写在 .scratch/<feature>/ 下(见 docs/agents/issue-tracker.md), 而不是只留在聊天里。
  • 🧭 改代码前:先读 CONTEXT.md(术语)与相关 ADR(决策), 再读 AGENTS.md(本仓库给 agent 的约定)。
  • ✅ 提交前:pnpm check 必须全绿;新增门禁要先证明它会红。
  • 📦 改 wallpapers/ 前:先读 wallpapers/README.md。
  • 🎨 改面板 / 模拟器前:先看 ADR 0006 与 tools/checks/verify-panel.mts。

📄 许可证

仓库当前没有声明开源许可证(package.json 为 private),代码与素材默认保留所有权利; 若要复用,请先联系作者。第三方组件遵循其各自授权:

  • Spine 运行时(src/vendor/spine-player.js):spine-ts,授权随上游 Spine Runtimes License。
  • 壁纸中的立绘、场景与音频素材版权归米哈游 / 相关权利人所有,本仓库仅作个人壁纸作品使用。

🙏 致谢

  • Spine / spine-ts:骨架动画运行时。
  • Wallpaper Engine:宿主与属性面板 API。
  • 米哈游活动页:立绘与场景的原始来源(各档来源 URL 见 wallpapers/sources.yml 与各壁纸的 preset.template.json)。
S
Description
No description provided
Readme
243 MiB
0 Stars 1 Watchers 0 Forks
Languages
TypeScript 66.3%
Python 20.6%
JavaScript 8.6%
CSS 4.3%
PowerShell 0.1%
Other 0.1%