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

311 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# `wallpapers/` 目录规范
这里是**唯一真相来源**。`dist/` 下的每一个分发目录都由它生成,不要手工改产物——
`dist/` 是 `pnpm build` 的产物、已 gitignore、随时可以整份删掉重来。
改完这里的东西,跑:
```bash
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`(= 「所有壁纸合集」)
```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`
```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`
```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`(相对声明者目录的路径)**。
```json
{ "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`(运行时配置)
```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 块贴图平面按抓取期定下的摆放与层级合成。
```json
{
"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 清单,**不参与构建**——删掉不影响产物。