Files
SpineWallpaper/wallpapers
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
..

wallpapers/ 目录规范

这里是唯一真相来源。dist/ 下的每一个分发目录都由它生成,不要手工改产物—— dist/ 是 pnpm build 的产物、已 gitignore、随时可以整份删掉重来。

改完这里的东西,跑:

pnpm dev          # 本地调试(带热更新,保存即生效)
pnpm check        # 类型 + 构建 + 语法 + 路径 + 产物自包含,五道门

一、目录结构

wallpapers/
├── meta.json                    ← 全局元数据 = 「所有壁纸合集」这一档分发的文案
├── preview.gif                  ← 全局预览图(可选;被 collection-all 用)
├── sources.txt                  ← 来源 URL 清单(**不是构建输入**,纯留档)
│
└── <游戏id>/                     ← 例:hsr。目录名必须等于它 meta.json 里的 id
    ├── meta.json                ← 游戏级元数据(文案 + 显示名 + 共享音频声明)
    ├── audios/                  ← 游戏级**共享**音频(可选,只在合集分发里出现)
    │
    └── <壁纸id>/                 ← 例:xilian / kv37。目录名必须等于 meta.json 里的 id
        ├── meta.json            ← 壁纸级元数据(文案 + 音源清单)
        ├── preset.template.json ← 运行时配置(背景图 / 骨架 / 取景)
        ├── preview.gif          ← 本壁纸的预览图(可选)
        ├── spines/<骨架名>/      ← **一具骨架一组**:<名>.json + <名>.atlas + 贴图页
        ├── scene/               ← 场景图与背景图(非骨架的图)
        └── audios/              ← 音源文件

三层的 meta.json 各自管一段:全局给「全部合集」写文案,游戏给「该游戏合集」写文案并声明共享音频,壁纸给单档分发写文案并声明自己的音源。


二、id 与命名规则(最容易踩的一条)

规则 说明
目录名 = id wallpapers/hsr/xilian/ 的 meta.json 里 "id" 必须也是 xilian,否则构建直接报错
字符集 只能用字母、数字、_、-,且不能以 -/_ 开头:/^[a-z0-9][a-z0-9_-]*$/i
不能是保留名 audios、meta.json、preview.gif、preset.template.json 不能当 id
壁纸 id 全局唯一 跨游戏也不能重名——它是 WE combo 的 value,撞了会让后一档静默覆盖前一档
永远不要改已发布的 id 老用户的设置按 id 保存。改了 = 他们的选择失效。显示名(name)随便改,id 不能动

id 一律 ASCII,不要中文。 显示名(name / title / description)保留中文,那是产品内容。


三、meta.json 字段

1. 全局 wallpapers/meta.json(= 「所有壁纸合集」)

{
  "name": "所有壁纸合集",
  "title": "【崩坏:星穹铁道】昔涟",
  "description": "…WE 的 BBCode 文案…",
  "defaultPresetId": "xilian",
  "preview": "preview.gif",
  "workshopid": "3604974793",
  "workshopurl": "steam://url/CommunityFilePage/3604974793"
}
字段 必填 作用
name ✓ 调试服索引页上的显示名
title ✓ 写进 project.json 的 title(创意工坊标题)
description ✓ 写进 project.json,支持 WE 的 BBCode
defaultPresetId 决定 preset 下拉的第一项与 preset.value。改它 = 改所有用户的默认观感,不是重构
preview 相对 wallpapers/ 的文件名。缺省 → project.json 整个省略 preview 字段
workshopid / workshopurl 上传后由 WE 生成,没上传就别写——缺省时字段整个不出现

2. 游戏 wallpapers/<游戏id>/meta.json

{
  "id": "hsr",
  "name": "崩坏:星穹铁道",
  "title": "【崩坏:星穹铁道】壁纸合集",
  "audios": []
}
字段 必填 作用
id ✓ 必须等于目录名
name ✓ 显示名(多游戏时用来给 preset 下拉加前缀)
title ✓ 游戏合集的创意工坊标题。游戏合集的文案只认这一层——不回落到全局 meta.json(那层是给「全部合集」写的,回落会让 collection-ys 顶着《崩坏:星穹铁道》昔涟,见 issues/20)
description 游戏合集的文案,WE 的 BBCode。缺省时构建用「共 N 档:…」的模板自动生成
audios 游戏级共享音频:整个游戏所有壁纸都能选到的曲子。只在「合集」类分发里出现,单档分发不会带上它。形状与壁纸的 audio.choices 相同({name, file}),file 相对 wallpapers/<游戏id>/

3. 壁纸 wallpapers/<游戏id>/<壁纸id>/meta.json

{
  "id": "xilian",
  "name": "昔涟立绘",
  "title": "【崩坏:星穹铁道】昔涟",
  "description": "…WE 的 BBCode 文案…",
  "preview": "preview.gif",
  "audio": {
    "choices": [
      { "name": "「再度和你」", "file": "audios/zaiduheni.flac" },
      { "name": "昔涟",        "file": "audios/xilian-src.flac" }
    ]
  }
}
字段 必填 作用
id ✓ 必须等于目录名,且全局唯一
name ✓ 显示名。单档分发里它就是 title 的来源之一,也是 preset 下拉的选项文字
title / description ✓ 单档分发的 project.json 文案
preview 相对本目录的文件名。声明了就必须真实存在,否则报错
audio.choices ✓ 音源清单,见下
audio.default 默认音源的位置,从 1 起。省略 = 第一条
workshopid / workshopurl 同全局:没上传就别写

四、音频规范

choices 里写什么

每条只有两个字段:name(显示名,中文原样) 和 file(相对声明者目录的路径)。

{ "name": "「再度和你」", "file": "audios/zaiduheni.flac" }
  • 不要写 id:id 由构建按固定顺序自增分配("1"、"2"、"3"…),全项目唯一。 之所以必须全项目唯一,是因为 bgm 下拉会把一个分发里所有壁纸的音源平铺进同一个列表, 两档都叫 "1" 就分不清选的是哪个了。
  • 顺序即默认:第一条就是这档壁纸的默认音乐。要换默认,把那条挪到第一位 (或显式写 "default": 2)。

文件放哪、什么格式

位置 谁用
wallpapers/<游戏>/<壁纸>/audios/ 该壁纸自己的音源,单档与合集分发都会带上
wallpapers/<游戏>/audios/ 游戏级共享音源,只在合集分发里出现

扩展名白名单(WE 内置的 Chromium 146 实测能解的):

.flac  .mp3  .ogg  .opus  .wav

.m4a / .aac 会被直接拒——CEF 的 FFmpeg 构建里没有 aac 解码器。 文件名可以保留中文,但路径里不能有 ..(音频会被搬进分发目录内部)也不能用 \。

孤儿检查

audios/ 下的每个文件都必须在 choices 里被声明。 一个没声明的 .flac 会安静地躺在仓库里: 类型检查过、构建过、产物自包含,只是那首歌永远选不到——所以 pnpm check 会把它揪出来。 反过来,choices 里声明了但目录不存在也会报错。


五、preset.template.json(运行时配置)

{
  "backgroundImage": "./scene/ava.jpg",
  "spineConfig": {
    "jsonUrl": "./spines/xilian/xilian.json",
    "atlasUrl": "./spines/xilian/xilian.atlas",
    "animation": "idle",
    "viewport": { "padLeft": "-25%", "padRight": "-28%", "padTop": "-30%", "padBottom": "-23%" }
  }
}
  • 路径一律写成相对本目录(以 ./ 开头)。构建会把它解析成 import.meta.url 推导出的绝对 URL, 于是整个分发目录可以整体搬走而不用改任何一行路径。
  • backgroundImage、spineConfig.jsonUrl、spineConfig.atlasUrl 三个都必须真实存在,构建会检查。
  • viewport 的 pad 可以用百分比或像素,可以是负数(表示裁切)。
  • framing: "author" = 保留作者原始取景,不跟随背景缩放。

场景壁纸(sceneConfig)

一档壁纸也可以是一整页场景:N 具骨架 + M 块贴图平面按抓取期定下的摆放与层级合成。

{
  "backgroundImage": "./scene/cover.jpg",
  "sceneConfig": {
    "ui": [2500, 1080],
    "parts": [
      { "kind": "image", "id": "main_sky_jpg", "image": "./scene/main_sky_jpg.jpg",
        "order": 0, "position": [0, 0, 0], "scale": [1, 1, 1] },
      { "kind": "spine", "id": "main_nike",
        "jsonUrl": "./spines/main_nike/main_nike.json",
        "atlasUrl": "./spines/main_nike/main_nike.atlas",
        "animation": "眨眼",
        "order": 3, "position": [-120.5, 480.25, 0], "scale": [1.09, 1.09, 1] }
    ]
  }
}
规则 说明
二选一 spineConfig(单骨架)与 sceneConfig(场景)只能写一个;都写或都不写,构建期直接报错
kind spine(骨架)/ image(贴图平面)/ solid(纯色平面,只给 width/height)
order 绘制层级,抓取期由场景树遍历序给出
position / scale 世界变换(抓取期已按引擎语义逐层合成),页面坐标系(y 向上);运行时按 flipY 处理方向
animation 缺省 = 该骨架的第一个动画
路径 与单骨架同一条规则(./ 开头、相对本目录),构建会解析成 import.meta.url 推导的绝对 URL

这些字段由抓取器从页面场景树抄来(见 tools/downloader/README.md),构建期烘焙进 preset.js, 运行时不再读侧车。当前已知近似:glTF 网格(geometry.type: 1)没有尺寸,平面回落贴图原始尺寸。

tools/downloader 现在每个场景直接产出一份终态的壁纸目录(_out/<游戏>/<页面>/<场景id>/): 形状与本节完全一致(meta.json + preset.template.json + spines/ + scene/ + audios/), 整个目录拷进 wallpapers/<游戏id>/ 即可(目录名要等于 meta.json.id), 或者用 python -m tools.downloader promote 改名 + 校验后搬进来。

这个文件里不放显示名、文案、音源清单——那些都在 meta.json。 两份数据在构建时合成 preset.js(运行时)与 project.json(发布)。


六、图片与骨架

  • 一具骨架的东西放在一起:spines/<骨架名>/ 里放它的 .json、.atlas 与全部贴图页。 这不是风格问题——spine-ts 的 AssetManager 按 <atlas 所在目录>/<页名> 解析贴图页 (页名来自 atlas 第一行),页跑到别处就读不到。
  • 骨架 .json 里作者的目录字段要留空串:skeleton.images(以及 skeleton.audio)指的是导出机的 目录(../images/、../audios),搬进分发后一定指错。现在运行时不读它们(页按 atlas 所在目录解析), 但留着就是一颗定时炸弹:以后真用到共享贴图或事件音时会静默指向不存在的路径。下载器产出的骨架 (promote.py)已经归一化,存量骨架要自己补——迁移时就漏了 hsr 那两具。
  • 场景图与背景图放 scene/。注意一个例外:backgroundImage 也可以指向某个骨架的贴图页 (kv37 就是这样,它用 atlas 里的 kv37_xilian.webp 当背景),那种情况路径写进 spines/<名>/ 即可。
  • 目录名固定(spines / scene / audios),构建按它们搬文件——改名要同步改 tools/lib/generate.ts 的 copyWallpaperAssets。
  • .atlas 是文本文件:第一行是贴图页的文件名,接着 size: / filter: / scale:, 之后才是各区域的 bounds / offsets / rotate。 改名贴图页时 atlas 第一行要一起改,否则运行时会去请求一个不存在的页。
  • 大图建议转 WebP(本项目现用)。atlas 页要整组一起转,并同步改 .atlas 里的页名。

七、常见任务

加一档新壁纸

  1. 建目录 wallpapers/<游戏id>/<新id>/(id 必须全局唯一)
  2. 放 meta.json:id 与目录名一致,填 name / title / description
  3. 放 preset.template.json,路径都写 ./ 开头
  4. 把骨架、atlas、贴图页放进 spines/<骨架名>/;场景图与背景图放进 scene/(规则见第六节)
  5. 有音源就放进 audios/ 并在 meta.json 的 audio.choices 里声明
  6. 有预览图就放 preview.gif 并在 meta.json 里写 "preview": "preview.gif"
  7. pnpm check

加一个新游戏

同上,外加 wallpapers/<游戏id>/meta.json(id + name + title——title 是「该游戏合集」的创意工坊标题,必填,见第三节)。 「该游戏合集」这一档分发的目录名与文案会自动从它生成。

加一首音乐

把文件放进对应的 audios/,在 meta.json 的 choices 末尾追加一条 {name, file}。 追加到末尾,不要插在中间——id 是自增的,插在中间会让后面所有音源的 id 后移, 老用户已保存的 bgm 选择会指向别的曲子。

换默认音乐

把 choices 里那条挪到第一位(或写 "default": N)。

加/换预览图

壁纸级:放 <壁纸id>/preview.gif 并写 "preview": "preview.gif"。 全局(「所有合集」那一档):放 wallpapers/preview.gif,在 wallpapers/meta.json 里写 "preview": "preview.gif"。


八、pnpm check 会替你检查什么

门 内容
typecheck tsc
build 四个分发全部构建成功
check:syntax 会被 Node 直接执行的 .ts 都是 strip-safe
check:paths 音频孤儿:每个 audios/ 文件都被声明、每条声明都指向存在的文件
check:dist 产物自包含:分发目录里没有绝对链接、没有跨目录相对链接、引用的文件都在

构建期还会 fail-fast 校验:id 与目录名一致、id 字符集、保留名、壁纸 id 全局唯一、 preview 存在、preset.template.json 三个路径存在、音频扩展名在白名单内。


九、不要放在这里的东西

东西 该去哪
无损母带(几十 MB 的原始 flac) masters/(已 gitignore,只在本地留存)
手写的 id / index / order 不用写。音源 id 由构建自增分配;属性的 index/order 也由构建按属性表顺序编号
构建产物(dist/ 的拷贝) 不要。dist/ 完全由 pnpm build 生成
抓取脚本、临时下载 别处。这里只放最终要发布的资源

wallpapers/sources.txt 是留档用的来源 URL 清单,不参与构建——删掉不影响产物。