把项目从「手写 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 处引用自包含。
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里的页名。
七、常见任务
加一档新壁纸
- 建目录
wallpapers/<游戏id>/<新id>/(id 必须全局唯一) - 放
meta.json:id与目录名一致,填name/title/description - 放
preset.template.json,路径都写./开头 - 把骨架、atlas、贴图页放进
spines/<骨架名>/;场景图与背景图放进scene/(规则见第六节) - 有音源就放进
audios/并在meta.json的audio.choices里声明 - 有预览图就放
preview.gif并在meta.json里写"preview": "preview.gif" 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 清单,不参与构建——删掉不影响产物。