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 处引用自包含。
This commit is contained in:
Shuery committed 2026-10-02 01:27:02 +08:00
1 parent b8eee05d78
commit 3f11426964
297 files changed
+216627 -1926

No files matched your search

@@ -0,0 +1,77 @@
"""补救:按**当前实际布局**重写各档壁纸预设里的资源路径。
背景:migrate-walls.py 的映射键在 Windows 下是反斜杠,而预设里是 `./effects/x.json` 这种正斜杠,
查表全落空 → 文件搬了、预设没改。这里不靠任何映射,直接**在壁纸目录里找**该文件现在在哪:
./effects/<名>.json → spines/<名>/<名>.json
./images/<…> → spines/<骨架>/<文件>(若该文件是某 atlas 声明的页或骨架自己的贴图)
否则 → scene/<文件>
./images/scene/<文件> → scene/<文件>
每个改写都要求目标**真实存在**,否则报错而不是写一个坏路径。
"""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
WALLS = ROOT / "wallpapers"
APPLY = "--apply" in sys.argv
PAGE_EXT = re.compile(r"\.(png|webp|jpg|jpeg)$", re.IGNORECASE)
def atlas_pages(text: str) -> list[str]:
return [l.strip() for l in text.splitlines() if l.strip() and ":" not in l.strip() and PAGE_EXT.search(l.strip())]
def owner_of(wall: Path, basename: str) -> str | None:
"""这个文件现在归哪个目录(spines/<名> 还是 scene)。"""
for atlas in (wall / "spines").rglob("*.atlas"):
if basename in atlas_pages(atlas.read_text(encoding="utf-8")):
return f"spines/{atlas.parent.name}"
for spine_dir in (wall / "spines").iterdir() if (wall / "spines").is_dir() else []:
if (spine_dir / basename).exists():
return f"spines/{spine_dir.name}"
if (wall / "scene" / basename).exists():
return "scene"
return None
failed: list[str] = []
for wall in sorted(p.parent for p in WALLS.rglob("preset.template.json")):
preset_path = wall / "preset.template.json"
preset = json.loads(preset_path.read_text(encoding="utf-8"))
changed: list[str] = []
def rewrite(value: object) -> object:
if isinstance(value, str) and value.startswith("./") and "/" in value[2:]:
basename = value.rsplit("/", 1)[-1]
owner = owner_of(wall, basename)
if owner and value != f"./{owner}/{basename}":
changed.append(f"{value} → ./{owner}/{basename}")
return f"./{owner}/{basename}"
if owner is None:
failed.append(f"{wall.relative_to(ROOT)}: {value} 在磁盘上找不到")
if isinstance(value, dict):
return {k: rewrite(v) for k, v in value.items()}
if isinstance(value, list):
return [rewrite(v) for v in value]
return value
new_preset = rewrite(preset)
print(f"\n== {wall.relative_to(ROOT)}")
for c in changed:
print(" " + c)
if APPLY and changed:
preset_path.write_text(json.dumps(new_preset, ensure_ascii=False, indent="\t") + "\n", encoding="utf-8")
if failed:
print("\n✗ 以下路径找不到对应文件(未改写):")
for f in failed:
print(" " + f)
sys.exit(1)
print(f"\n{'已重写' if APPLY else '将重写'} {len(changed) if False else ''}{'(dry-run)' if not APPLY else ''}")
@@ -0,0 +1,31 @@
# 01 — CLI 范围选择:裸 id、`single`、`collect`、`sim`
Status: resolved
Type: task
## 问题
ADR 0005 写的是 `pnpm build name` / `pnpm build single` / `pnpm build collect`,但 `tools/build.ts` 只认 `--single` / `--collect`;`package.json` 的 `build:all` 跑的是不存在的 `--all`。
## 答案
`parseArgs` 改成"先摘子命令、再解析 flag":
| 输入 | 范围 |
| --- | --- |
| `pnpm build` | 全编(2 单档 + 1 游戏合集 + 全部合集) |
| `pnpm build kv37` / `pnpm build kv37 xilian` | 指定壁纸 |
| `pnpm build single` | 只编全部单档 |
| `pnpm build single kv37` | 指定单档 |
| `pnpm build collect` | 全部合集(`collection-hsr` + `collection-all`) |
| `pnpm build collect hsr` | 指定游戏合集 |
| `pnpm build sim …` | 同 `pnpm build` 的范围,但每档都产出自包含包 |
子命令与壁纸 id 共用同一段位置,靠词表区分(`single`/`collect`/`sim` 是保留字,其余裸词是 id)。
**两个坑**:
- `pnpm build collect`(无参)不能用 `collect: "all"` 表示——那个值只加「全部壁纸合集」一项,会把 `collection-hsr` 漏掉。改用显式标记 `allCollections`。
- "不给 id" 与 "没选任何范围" 必须分开:`single` 无参 = 只编单档,无参 = 全编。靠 `onlySingles` / `allCollections` 显式标记,不靠"数组为空"推断。
`build:all` 改成 `pnpm build`(它就是全编,不需要额外 flag)。
@@ -0,0 +1,30 @@
# 02 — 两个根:页面根与模块根必须分开算
Status: resolved
Type: task
## 问题
合集分发的自包含页音频报 `ERR_FILE_NOT_FOUND`,spine 骨架报 `rawDataURIs 缺键`。单档分发全绿。
## 诊断
自包含页在 `<分发根>/sim/index.html`,`preset.js` 在 `<分发根>/<壁纸id>/preset.js`。两个位置对应**两个不同的相对基准**:
- **页面根**:资源相对分发根寻址,相对页面恒为 `"../"`,**与合集深度无关**。
- **模块根**:`preset.js` 的 `import.meta.url` 是它自己所在目录,相对页面是 `"../<壁纸id>/"`。
曾经两者都写成 `"../".repeat(depth)`:`collection-all`(深度 2)资源根变 `"../../"`(跑到 `dist/releases/`),合集 `preset.js` 把 `./audios/…` 少解析一层。
同时资源表的建键基准也错了:用 `assetUrlIn`(相对 preset.js)建键会丢掉合集里的 `<壁纸id>/` 这一段,运行时按分发根相对路径查不中。
## 答案
- `assetRoot` 固定为 `"../"`,不再乘深度。
- `transformModule` 按 `moduleDir` 把 `new URL("./", __importMetaUrl)` 改写成 `new URL("../<moduleDir>/", document.baseURI)`。
- 新增 `generate.ts` 的 `assetRelIn(release, wallpaper, rel)`(分发根相对),资源表改用它;`assetUrlIn` 保留给发布产物。
- 音频键用 `urlKey` 剥掉 `../` 前缀即可(`audioPathIn` 已经是分发根相对形式),**不再**拼 `<壁纸id>/`——两个基准不同,别合并。
## 教训
这两个错误都长成"表和模块看起来都对,只有某个资源不对",极易误判成那个资源自己的内联逻辑。**单档分发两种错误都不出现**,只测单档会一路绿灯——所以验收必须覆盖四档。
@@ -0,0 +1,30 @@
# 03 — 自包含包的门禁必须是会失败的检查
Status: resolved
Type: task
## 问题
"自包含"是自包含包的全部意义,不能靠"我记得内联了"。需要在 `check:dist` 里对每个 `sim/index.html` 断言没有外部依赖。
## 答案
三项断言:没有 `<script type="module">`、标签上没有 `http(s)://`、没有 `file:///` 与根绝对路径。
**两个必须处理的细节**:
1. **扫描前剥掉内联脚本的内容。** spine-player 是整份内联的,内部带着编辑器示例模板,含 `<script src="https://…">` 字符串;直接扫整页会把它们当依赖,四个分发全报假阳性。
2. **剥除时只删标签之间的内容,保留开标签本身。** 第一版写成整体替换,把开标签一起删了,于是所有 `<script src=…>` 都不再被检查——门禁看着在跑,实际只查得到 `<link>`。
## 验证
`tools/checks/test-sim-gate.mts` 往页面注入四种外部依赖(外链 script、`file:///`、根绝对路径、`type=module`),确认每种都被拦下,且干净页面通过。
第 2 个细节就是这个测试发现的:**一个从不失败的检查等于没有检查**,所以门禁本身要有测试。
## 附带:内联脚本的 `</script` 守卫
同一类问题还有一处:内联脚本体里若出现 `</script`,会**提前终止** script 元素,后面的代码变成页面文本。当前所有被内联的源码都不含这个串,但这是个沉默的陷阱,所以 `buildSimPage` 加了一道构建期断言(`tools/checks/test-inline-guard.mts` 验证它有效)。
**守卫自身也要测,而且第一版就写错了**:它把 driver 也一起查了,而 driver 本来就是一个完整的 `<script>…</script>` 块、收尾标签是它自己的,于是干净源码立刻构建失败。守卫只查纯 JS 体(spine-player、模拟器段、运行时段)。
@@ -0,0 +1,23 @@
# 04 — project.json 键序:与已发布产物逐字节对齐
Status: resolved
Type: task
## 问题
重构后 `collection-all/project.json` 字段值完全一致,但顶层键序变了:
```
已发布: … general, preview, ratingsex, … visibility, workshopid, workshopurl
本次: … general, ratingsex, … visibility, preview, workshopid, workshopurl
```
原因:三个可选字段(`preview`/`workshopid`/`workshopurl`)统一被追加到对象末尾,而线上那份的 `preview` 夹在 `general` 与 `ratingsex` 之间。
## 答案
`preview` 用条件展开放进对象字面量的正确位置;`workshopid`/`workshopurl` 保持在末尾追加(它们本来就在最后)。
**为什么较真**:上传到创意工坊的 `project.json` 要和线上那份对齐,否则每次构建都产生一个"内容相同、文件不同"的 diff,无法判断是真改动还是噪声。
**发现方式**:先按字符串比对,只看到"首个差异 @ 353",定位不到键;写成逐键递归比对(`tools/checks/diff-project.mts`)后立刻显示"字段值完全一致、键序不同"。
@@ -0,0 +1,26 @@
# 05 — 模块合成:保声明的 export 改写
Status: resolved
Type: task
## 问题
`file://` 下不能用 ES module,所以发布版的 ES module 要在自包含包里合成经典脚本。第一版把 `export const X = 1` 改写成 `exports.X = 1`,**删掉了模块内的局部绑定**。
症状极具误导性:构建、加载、尺寸检查全部正常,只在第一帧渲染时抛 `ReferenceError: REFERENCE_ASPECT is not defined`——因为默认参数 `referenceAspect = REFERENCE_ASPECT` 到那一刻才求值。
## 答案
改写**保留声明**,把导出追加到模块体末尾:
| 源码 | 产物 |
| --- | --- |
| `export const X = 1` | `const X = 1` … 末尾 `exports.X = X;` |
| `export function f() {}` | `function f() {}` … 末尾 `exports.f = f;` |
| `export default {…}` | `exports.default = {…}` |
收尾有一道 `if (/^export\b/m.test(out)) throw` 兜底,任何漏改的 `export` 都会在构建期报错而不是留到浏览器。
## 影响
`tools/checks/test-transform-module.mts` 里 **9 条断言编码的是旧的错误输出**,必须一并改写。新增两组断言:一组确认本地绑定还活着,一组把改写后的代码**真的跑一遍**(不只是解析),确保产物可执行。
@@ -0,0 +1,24 @@
# 06 — 像素验收:必须在 rAF 内跨帧采样
Status: resolved
Type: task
## 问题
`verify-sim-page.mts` 报告"非透明占比 0.0%",但页面看上去是画出来的。
## 诊断(两个叠加原因)
1. **取错了 canvas。** 页面里除了 spine 的 canvas,还有一个 300×150 的默认 canvas,检查取的是后者。
2. **在 `requestAnimationFrame` 之外读像素。** spine 用 `preserveDrawingBuffer: false`,渲染缓冲在合成后被清空;rAF 外调 `gl.readPixels` 永远读到清空后的缓冲。
## 答案
- 取**面积最大**的 canvas。
- 注入一个跨 90 帧的 rAF 采样器,累计 `window.__px.best`(各帧最大值),最后读这个值。
修好后 kv37 是 100.0%、合集类是 77.9%(骨架没铺满整屏,合理)。
## 教训
"渲染没发生"和"我没读到"在像素检查里长得一模一样。`preserveDrawingBuffer: false` 这个设置让所有单次采样都恒为 0——看起来像"壁纸白屏",实际是测量方法错了。
@@ -0,0 +1,23 @@
# 07 — 视觉等价验收:把"画面没变"变成可执行的断言
Status: resolved
Type: task
## 问题
`verify-sim-page.mts` 只证明"自包含包能跑",不证明"画面没变"。内联、模块合成、URL 重写这些改动都可能悄悄改掉渲染结果。
## 答案
`tools/checks/verify-visual-equivalence.mts`:当前 dist 的截图与**重构前**的存档截图(`tools/shots/baseline-pre-refactor/`,6 组)逐像素比对,判据 `mean|diff| = 0 且 max|diff| = 0`。结果:**6 组全部逐像素相同**。
配套方法:`node tools/capture.mjs <tag> --base <url>` 抓图,`node tools/diff.mjs a b` 比对。基线帧必须靠 `--base` 指向**正确的分发根**——指向调试服首页会抓到一张空白页(45 KB vs 2165 KB),差异看起来像"整体回归",实际只是抓错了页面。
## 一个误读
`tools/capture.mjs` 打印的 `spine 层贡献: psnr=18.9 …` 是**同一轮里 ref 帧 vs 主帧**的差("spine 层占了多少像素"),**不是**与基线的比对。我把它读成了后者,一度以为 spine 层与重构前不一致,白查了半天。
真正的判定只有 `verify-visual-equivalence.mts`。查证过程留下了两个有用的结论:
- **捕获是确定性的**:同一份代码两次捕获逐像素相同(`?__freeze=1` 把 `trackTime` 归零并每帧强制,`drawCalls=1437`、`trackTime=0`)。
- **背景层(ref 帧)逐像素相同**,所以差异(如果有)只可能来自 spine 层。
@@ -0,0 +1,72 @@
# 08 — 面板改为右边缘滑出,并让它能在静态托管上工作
Status: resolved
Type: task
## 需求(用户原话)
> 模拟的壁纸测试面板是在鼠标移至页面右侧边缘时弹出的,且并不能仅有调试服提供,未来上传 GitHub 是 pages 预览也需要能够弹出壁纸设置面板
两件事:
1. 面板的打开方式是**鼠标移到页面右侧边缘**——不是常驻按钮。
2. 面板**不能只有调试服能给**。未来上 GitHub Pages 的预览也要能弹出设置面板。
## ① 交互:右边缘滑出
原来的实现是右上角一个常驻的「WE 模拟器」按钮。改成 16px 右边缘热区 + 全高抽屉,与 player 配置页同一套约定:
| 行为 | 结果 |
| --- | --- |
| 指针进入右边缘 16px | 面板滑出 |
| 指针离开面板/边缘 | 自动收回 |
| 点 × 收起 | 收起,且**必须先离开边缘**才会再次自动滑出 |
| 点热区 | 开合(触屏没有 hover,点击是兜底) |
`#wesim-edge` 里的把手只是"这里能滑出东西"的可见提示,`pointer-events: none`,不参与命中。
### 踩的坑:不能用 mouseenter / mouseleave 判定
第一版用 `edge.mouseenter` 打开、`root.mouseleave` 收回。测试立刻发现"指针离开 → 自动收回"永远是 `data-open=1`。
原因:面板是用 `transform` 从右侧滑进来的,**出现时指针已经在它下面**,浏览器不会为它补发 `mouseenter`——于是 `root` 从来没被标记为 hover,它后面的 `mouseleave` 自然也不会来。
改成在 `mousemove` 里按坐标判定,结果只取决于指针位置,与浏览器是否补发事件无关:
```js
const nearEdge = event.clientX >= window.innerWidth - EDGE_WIDTH;
const overPanel = opened && event.clientX >= window.innerWidth - PANEL_WIDTH;
```
`PANEL_WIDTH` / `EDGE_WIDTH` 与 CSS 共用同一组常量(CSS 里用模板插值),避免"改了一处忘了另一处"。
`armed` 是"× 收起后需先离开边缘"那条规则的状态位。没有它,点 × 之后指针仍在热区里,下一次 `mousemove` 就把面板弹回来,表现为"× 根本关不掉"。
## ② 静态托管:把注入写进 index.html
| 场景 | 谁注入 | 模拟器本体从哪来 | 产物 |
| --- | --- | --- | --- |
| `pnpm dev` | 调试服按路由注入 | 调试服自己的 `/simulator/wallpaper-engine.js` | 分发目录保持干净 |
| 静态托管(Pages) | 构建期写进 `index.html` | 分发自带的 `./scripts/wallpaper-engine.js` | `pnpm build --with-sim` |
| 上传 Wallpaper Engine | 没有人注入 | 不存在 | `pnpm build`(默认) |
要点:
- 静态地址**必须相对**(`./scripts/...`)。Pages 把站点放在 `/<repo>/` 子路径下,根绝对路径会 404。
- 驱动带幂等闸 `window.__weSimDriver`:`--with-sim` 构建的分发自带驱动,调试服又会注入一次,没有闸就会 mount 两遍、出现两个面板。
- 默认产物里**既没有模拟器文件,也没有任何注入痕迹**——它必须与用户从创意工坊下载到的东西完全一致(ADR 0006 第 1 条)。
## 验证
`tools/checks/verify-static-preview.mts`:**刻意把分发挂在 `/repo/` 前缀后面服务**,而不是挂在根上——挂根上测不出相对路径的错。断言分三组:
- 静态托管下 `window.__weSim` 存在、`#wesim` 与 `#wesim-edge` 已挂载、没有常驻按钮、横幅在、壁纸本身也渲染
- 右边缘滑出六态:初始收起 → 滑出 → 离开收回 → 再滑出 → × 收起不被弹回 → 离开边缘后重新可触发
- 静态服务器 **0 个 404**、0 异常、0 控制台报错
四档分发全部通过。另有 `tools/checks/smoke-sim.mts` 的"面板恰好一个"断言守住双重注入。
## 顺带发现
- 合集那两档的自包含包是 128.7 MB,**超过 GitHub 单文件 100 MB 上限**。所以 Pages 只能走"分发目录 + 构建期注入"这条路,不能直接发布 `sim/index.html`。
- `src/vendor/spine-player.js.bak` 是内联守卫测试留下的残留,已删。
@@ -0,0 +1,66 @@
# 09 — 模拟器设置面板还原 Wallpaper Engine 官方面板
Status: resolved
Type: task
## 需求(用户原话)
> wallpaper engine模拟设置面板严格还原官方面板
附官方面板截图(WE 2.8 里本项目这档壁纸的 Wallpaper Settings)。
## 官方面板的结构(从截图读出)
- 标题栏:`Wallpaper Settings` + 右侧 `⟳ 重置`
- 属性一行一个:**图标 + 标签 + 右侧控件**
- `type:"text"` 的属性渲染成**富文本说明块**(`<ul><li>` 列表 + `<a>` 链接),缩进在相关行下方
- 颜色属性之后有一条分隔线
- 底部:`确认` / `取消`
## 改了什么
| 官方特征 | 旧实现 | 现在 |
| --- | --- | --- |
| 标题 `Wallpaper Settings` + `重置` | 无标题栏,右上角一个「WE 模拟器」开关 | 照官方 |
| 图标 + 标签 + 右侧控件 | 两栏 grid,无图标 | 照官方(`text` 开头的 emoji 当图标) |
| 按 `order` 排序 | 按对象键序(属性表不按 order 写就错位) | 按 `order`,回退 `index` |
| `type:"text"` 富文本说明块 | 截断成 90 字的一行提示,列表和链接全丢 | 白名单复制 `<ul>/<li>/<a>/<small>/<br>` |
| `condition` 生效 | 完全忽略 | `键.value == 字面量` 求值 |
| 颜色属性 = 色块 + 「显示颜色选项」 | 一个裸文本框 | 色块;勾选后换成取色器 |
| 底部 确认 / 取消 | 无 | 确认 = 立新基线;取消 = 回滚到基线 |
| `重置` | 无 | 恢复 `project.json` 默认值 |
`fps` / `setPaused` / 替身清单是 WE 面板里没有的调试开关,收进底部可折叠的「模拟器调试」,默认收起——不干扰"像不像官方"的判断。
顶部「⚠ 模拟环境」横幅**保留**:ADR 0006 要求做不到的 API 必须显式标记为替身。这是刻意不像官方的三处之一(另两处:折叠的调试区、面板只是贴右边缘的抽屉而非 WE 主窗口)。
## 三个只有真跑起来才会发现的问题
1. **`schemecolor` 的 `text` 是 i18n 键,不是文案**。它是 `ui_browse_properties_scheme_color`,官方面板显示「主题配色」。直接当标签用,面板上会赫然出现一串下划线键名。加了 `WE_I18N` 映射 + 去前缀的兜底。
2. **`condition` 必须求值,否则面板会多出不该有的行**。`author_info` 的 `condition` 是 `show_author_info.value == true`;不求值就永远显示,关掉开关也赖着不走。认不出来的表达式**返回 true**(宁可多一行,也不要莫名少一行)。
3. **`<script>` 不能进活文档**。说明块是作者写的 HTML,用 `DOMParser` 解析到惰性文档再按白名单复制,而不是 `innerHTML`;`<a>` 只放行 `http(s)`。
## 验证
`tools/checks/verify-panel.mts`(真实 CDP)断言的是**官方面板的可观察特征**,不是我们的实现细节:
- 标题就是 `Wallpaper Settings`、有「重置」、横幅仍在
- 七行顺序逐字比对:主题配色 / 显示颜色选项 / 显示作者信息 / 壁纸预设切换 / 音频音量调整 / 音频文件路径 / 背景音乐选择
- 控件类型:color→色块、bool→checkbox、combo→select、slider→range
- 五个说明块都有 `<ul><li>`;作者信息的 bilibili 链接被保留且只放行 http(s)
- condition:关掉「显示作者信息」→ 作者信息块消失、开关本身还在
- 重置恢复默认 1;取消回滚;确认保留
- 0 异常、0 控制台报错
`tools/checks/shot-panel.mts` 负责出图(把指针移到右边缘让面板滑出再截),产物 `.scratch/shots/panel-official-look.png`。
## 与截图对不上的两处(不是 bug)
截图的属性集与当前源码**不一致**,说明它来自另一个版本:
- 截图有「**翻转**」,当前 `project.json` 的属性表里没有这一项。它可能是 WE 的内置行,也可能是旧版本作者自己声明的属性。
- 截图**没有**「背景音乐选择」(`bgm`),而当前属性表里有——`bgm` 是后加的。
面板是按**当前属性表**渲染的,所以它以属性表为准。
@@ -0,0 +1,80 @@
# 10 — 颜色选项块、取色器、翻转,以及音频两条路互斥
Status: resolved
Type: task
## 需求(用户原话)
> 翻转是wallpaper engine自带的属性,我们也要加上,背景音乐选择我们可以选择在 project.json 中添加一个 使用自定义音乐 框选项,为真时展示 音频文件路径,隐藏 背景音乐选择,反之则相反。另外补充一下这些控件的细节
附三张官方面板细节图:取色器弹层、显示颜色选项展开后的四个滑杆、壁纸预设切换的下拉。
## ① 翻转:WE 自带的属性
`翻转` **不在** `project.json` 的属性表里——它是 WE 对网页壁纸自带的一行。同理的还有
`显示颜色选项` 与它展开后的 `亮度 / 对比度 / 饱和度 / 色调偏移`。面板现在把它们长在
**color 属性所在的位置**上,顺序天然与官方一致:
```
主题配色(作者的 color 属性) → 翻转 → 显示颜色选项 → [亮度/对比度/饱和度/色调偏移] → ───
```
四个滑杆都是 0–100、默认 50(= 不改变),只在「显示颜色选项」勾上时出现。
**效果落在 `body` 上**:翻转 = `transform: scaleX(-1)`,颜色选项 = CSS `filter`
(`brightness/contrast/saturate/hue-rotate`)。作用在 body 才能连背景图一起处理,
只改 `#spine-container` 会漏掉背景。
由此带来一个必须记住的副作用:**面板与取色器挂到 `<html>` 下,不能挂在 body 里**。
body 上一旦有 `transform` / `filter`,就会给 `position: fixed` 的后代造出新的包含块,
面板会跟着壁纸一起被翻转、被调色。
## ② 使用自定义音乐:两条路互斥
`project.json` 新增 `use_custom_audio`(bool,默认 false),并把两个音源属性做成互斥条件:
| 属性 | condition |
| --- | --- |
| `audio_file` / `audio_file_note` | `use_custom_audio.value == true` |
| `bgm` / `bgm_note` | `use_custom_audio.value == false` |
运行时同步改掉音源优先级:原来是无条件「自填 URL > bgm > 预设默认」,现在是
```ts
const picked = state.useCustomAudio ? state.audioSource : bgmSource(preset);
const source = picked || preset.audioOptions.source;
```
两条都空时仍回落本壁纸默认音源——否则开关一打开就彻底没声音了。
### 这会让发布产物变化,是有意为之
`general.properties` 因此与线上已发布版本(workshopid 3604974793 / version 3)不再逐字段相同。
`tools/checks/diff-project.mts` 会报 13 处差异,逐条核对过,**全部**来自这次新增:
`use_custom_audio`、四处 `condition`、以及随之顺延的 `index`/`order`。顶层键序未变,没有夹带别的改动。
(ADR 0005 里"构建产物必须与线上逐字段一致"那条约束,到此完成了它的历史使命——它是为了防止
重构过程中**意外**改动产物,不是禁止有意的功能更新。下次上传就是一个新版本。)
## ③ 控件细节
**取色器弹层**(点「主题配色」的色块弹出):调色板 3×5 + 明度/饱和度方块 + 色相条 +
十六进制输入 + 确认/取消。挂在 `<html>` 下,理由同上。开在色块**左侧**——面板本身贴右边缘,
往右开就出屏了。
**下拉**:用 `color-scheme: dark` 让原生 select 的弹出层也走深色,与官方那个深色高亮当前项的
下拉一致,没有自己造一套下拉控件。
## 验证
`tools/checks/verify-panel.mts` 扩到 8 组断言,全部通过:
- 行序逐字比对:主题配色 / 翻转 / 显示颜色选项 / 显示作者信息 / 壁纸预设切换 / 音频音量调整 / 使用自定义音乐 / 背景音乐选择
- 勾「显示颜色选项」→ 出现四个滑杆(共 5 个 range);亮度落到 `body.style.filter`、翻转落到 `body.style.transform`
- 面板与取色器都在 `document.documentElement` 下(不会被 body 的滤镜波及)
- 取色器:15 个色块、有 SV 方块与色相条、十六进制有值、底部确认/取消;确认后写回 `schemecolor`(`r g b` 浮点)并关闭
- `使用自定义音乐` 开 → 音频文件路径出现、背景音乐选择消失;关 → 反过来
- 重置 / 取消 / 确认三种语义
其余回归全绿:`pnpm check`、调试服与浏览器冒烟、四档自包含包 `file://`、静态托管预览、
6 组渲染与重构前逐像素相同、预设引用解析一致。
@@ -0,0 +1,74 @@
# 11 — 取色器的调色板、对勾与吸管
Status: resolved
Type: task
## 需求(用户原话)
> 吸管是手动从当前屏幕的任一点取色,勾选是选择指定的预设颜色,补充各个预设颜色信息图片,最后一张图是使用吸管取色时,会自动在最后一行给出两个邻近颜色,另外最后一行第一个是当前吸管所在的颜色。关于颜色选择的其他信息,可查找官方文档查看
附 17 张图:15 个预设色各一张(对勾 + 十六进制值),以及吸管取色时的一张。
## 官方文档查证
[User Properties](https://docs.wallpaperengine.io/en/web/customization/properties.html) 确认了取值格式:
"The color property will return three numeric values, separated by a space character (`1.0 0.1 0.25` for example)"
——与 `colorToCss` / `cssToColor` 的实现一致。
[Display Conditions](https://docs.wallpaperengine.io/en/web/customization/displaycondition.html) 确认了 condition 的写法就是
`showclock.value == true`,与 `conditionMet` 的实现一致(文档称其为 "JavaScript-compatible `if` condition")。
**但文档没有描述取色器的界面**(调色板、吸管、对勾都不在文档里)。所以那 17 张截图就是规格。
## ① 调色板:15 个预设色的准确值
原来是我按视觉估的,好几个不对。对着截图逐个改成官方值:
| 行 | 列 1 | 列 2 | 列 3 |
| --- | --- | --- | --- |
| 1 | `#ffffff` | `#c0c0c0` | `#000000` |
| 2 | `#ff0000` | `#ffa500` | `#ffff00` |
| 3 | `#00ff00` | `#008000` | `#254117` |
| 4 | `#add8e6` | `#0000ff` | `#00008b` |
| 5 | `#00ffff` | `#800080` | `#ff00ff` |
原来错的地方:`#c8c8c8`→`#c0c0c0`、`#ff8000`→`#ffa500`、`#004000`→`#254117`、
`#000080`→`#00008b`,以及整行 `#c0c0ff/#8000ff/#ff00ff` → `#00ffff/#800080/#ff00ff`。
## ② 对勾 = 当前颜色命中了某个预设
当前颜色与某个预设色完全相等时,那个色块上加对勾。对勾颜色按色块亮度选黑或白
(`luminance < 0.5` 用白),否则深色块上根本看不见。
## ③ 吸管 = 从屏幕任意位置取色
用浏览器原生的 **EyeDropper API**(`new EyeDropper().open()`)。WE 是原生程序,自带屏幕取色;
浏览器里这是**唯一**的官方途径。本机 CEF 146 **支持**它(测试实测 `dropper.disabled === false`)。
不支持时按钮禁用并把原因写进 `title`,而不是静默失效。
取到的颜色作为新的一行加在调色板末尾,第一个就是取到的颜色,后面跟两个邻近的预设色。
## 一处推断,需要你确认
"两个邻近颜色"我按 **RGB 欧氏距离最近的两个预设色**实现。但你那张图给的是
`#3c3c3c` → 邻近显示 `#ffffff` 与 `#000000`,而按 RGB 距离算,离 `#3c3c3c` 最近的是
`#000000` 与 `#c0c0c0`,**对不上**。所以这条规则我猜错了,图省事写成了最近邻。
图里是白与黑,看起来更像"亮度轴的两端"或别的规则。等你说明真实规则再改。
另:WE 的吸管在**移动过程中**就能实时预览当前颜色(原生程序可以持续采样屏幕);
浏览器的 EyeDropper 是一次性模态选择,没有实时回调,所以那一行是在**取色完成后**才出现。
## 验证
`tools/checks/verify-panel.mts` 新增断言,全部通过:
- 15 个预设色与官方逐个一致(逐字比对数组)
- 初始颜色 `#63269e` 不是预设色 → 没有对勾
- 把颜色改成 `#ff0000` → 对勾落到那个色块上
- 吸管按钮存在;可用/禁用两种状态都算通过,但会把实际状态与原因打出来
其余回归全绿:`pnpm check`、调试服与浏览器冒烟、四档自包含包 `file://`、静态托管预览、
6 组渲染与重构前逐像素相同。
出图:`tools/checks/shot-panel.mts` 现在出两张——面板本体与取色器。
@@ -0,0 +1,73 @@
# 12 — pnpm dev 热更新
Status: resolved
Type: task
## 需求
> pnpm dev 开发模式支持热更新
## 做法
监听 `src/` 与 `wallpapers/`,按改动**只跑必要的那几步**构建,然后经 SSE 通知浏览器。
| 改动 | 跑什么 | 浏览器怎么更新 | 实测 |
| --- | --- | --- | --- |
| `src/styles/*.css` | 重铺产物 | **只换样式表** | 850 ms |
| `src/runtime/*` | tsc runtime + 重铺产物 | 整页刷新 | 1.1 s |
| `src/simulator/*` | tsc simulator(产物里也带模拟器时再重铺) | 整页刷新 | 600 ms |
| `wallpapers/**`、模板、vendor、`VERSION` | 重铺产物 | 整页刷新 | 850 ms |
| 构建失败 | — | **只报错,不刷新** | — |
全量 `pnpm build` 是 2.2 秒;按改动拆开之后,改样式只要 0.85 秒,改模拟器 0.6 秒。
### 为什么 CSS 单独一条路
整页刷新要重新下载 3.4 MB 骨架、重建 Spine 播放器、面板状态全丢。改样式时这些全是白费——
换一下 `<link>` 的 `href`(加个 `?t=` 时间戳)就够了。实测确认:换完样式表,
`window` 上的标记**还在**,也就是页面确实没有重载。
### 为什么构建失败不刷新
刷新会把一个半成品页面端上来,而错误信息在控制台里——很容易被当成"改了没生效"。
现在构建失败只在左下角显示一条红药丸,页面保持原样。
### 为什么状态药丸挂在 `<html>` 下
与模拟器面板同一个理由:`body` 上会被打 `transform`/`filter`(翻转、颜色选项),
挂 `body` 里会跟着壁纸一起被镜像、被调色。
## 两个必须记住的约束
1. **绝不能监听 `dist/`**。构建写 `dist/`,监听它就是一个死循环。只监听 `src/`、`wallpapers/`、`VERSION`。
2. **`--with-sim` 产物里也有一份模拟器**,而且它的驱动排在调试服注入的那份**之前**(幂等闸先到先得)。
所以那种构建下,改模拟器必须连产物一起重铺,否则浏览器会一直跑旧模拟器。
调试服启动时会探一次产物里有没有模拟器,据此决定要不要多跑那一步。
## 调试期代码不许进产物
热更新客户端连的是 `/__dev/events`——那在真实 WE 里、在任何静态托管上都不存在。
它由 `tools/dev.ts` 的 `html()` 注入,`tools/build.ts` 的 `renderIndexHtml` 完全不碰它。
这条**钉进了 `check:dist`**,不再靠人工抽查。实测过它会拦:
```
✗ collection-all/index.html 里有调试服专属的 /__dev/events
```
(一个从不失败的检查等于没有检查——所以注入标记确认它会红,再还原。)
三种产物都确认过干净:默认产物 0 处、`--with-sim` 产物 0 处、自包含包 0 处。
## 验证
`tools/checks/verify-hot-reload.mts`:真的改源文件,用"页面上的标记还在不在"区分两种更新方式
(只看"页面变了没有"是分不出"整页刷新"和"只换样式表"的):
- 页面注入了热更新客户端;`/__dev/events` 返回 `text/event-stream`
- 改 CSS → 被改的那条样式表 href 换成带 `?t=` 的新地址、**标记还在**(没整页重载)、换上的确实是新内容
- 改 runtime → **标记被抹掉**(整页刷新)、刷新后面板重新挂上
- 测完还原源文件,并确认仓库里没有探针注释残留
其余回归全绿:`pnpm check`、调试服与浏览器冒烟、面板还原度、四档自包含包 `file://`、
静态托管预览、6 组渲染与重构前逐像素相同。
@@ -0,0 +1,70 @@
# 13 — 下拉框自绘,以及热更新暴露出的两个调试服 bug
Status: resolved
Type: task
## 需求
> 把下拉选项框的样式同步一下
上一轮我说过:"官方那个深色高亮当前项的下拉,用 `color-scheme: dark` 让原生 select 的弹出层走深色就够了……如果你觉得弹出层样式还是差得多,我再换成自绘的。" —— 现在换成自绘了。
## 为什么必须自绘
原生 `<select>` 的**弹出列表由操作系统绘制**,CSS 完全够不着。`color-scheme: dark` 只能把它整体变暗,
做不出官方那种"当前项高亮 + 悬停高亮"的列表——那需要控制每一行。
自绘之后闭合态与展开态都归自己管:
- 闭合态:`#2a2a2e` 底、`#3a3a40` 边、当前项文字 + 右侧 `▼`
- 展开态:`#232327` 底、当前项 `#3c3c46` 高亮、悬停 `#34343a`
- 键盘 Esc 收起、点外面收起、下方放不下就翻到上方
弹层挂在 `documentElement` 下(同面板与取色器):挂在面板里会被 `paintProps` 重画时销毁,
挂在 body 里会被翻转/颜色选项的 `transform`/`filter` 波及。
## 顺带修掉的两个调试服 bug
做这一轮验证时撞出来的,都不是新引入的,是**一直都在**:
### ① `pnpm dev --with-sim` 根本起不来
`parseArgs` 把 `--with-sim` 当未知参数拒掉,而 `main()` 里那段
`process.argv.includes("--with-sim")` 永远走不到——**这个开关一直是死代码**。
现在显式放行并写进 HELP。
### ② 热更新重建会丢掉 `--with-sim`
初始构建带了 `--with-sim`,重建那一行没带。于是 `pnpm dev --with-sim` 在**第一次保存后**
会被悄悄换成干净构建——产物里的模拟器没了,而没人会想到是"保存"干的。
现在两处共用同一份 `buildFlags`。
## 一次差点混过去的空验证
第 ② 条我第一次是这么"验证"的:起调试服、改个 CSS、数一下模拟器还在不在 —— 结果是 4,看起来修好了。
其实**调试服因为参数不认识根本没起来**(就是 bug ①)。没有重建,当然还是 4。
> 一个不检查前置条件的验证,等于没有验证。
重写成 `tools/checks/verify-dev-flags.mts`:第一件事就是 `fetch("/")` 断言服务活着,不活就直接抛;
第二件事用"调试服已经能取到新 CSS"作为重建完成的判据,而不是干等固定秒数。
并且**证明它会红**:不带 `--with-sim` 启动、产物里先放好模拟器,跑同一个脚本 →
`✗ --with-sim 在热更新重建后仍然生效 → 4 → 0`。红了才说明它真的在测这件事。
## 验证
- `tools/checks/verify-panel.mts` 第 ⑨ 组:点开下拉 → 两个预设都在、当前项恰好一个高亮、
弹层在 `documentElement` 下、选完收起、值下发给壁纸(`preset=kv37`)、按钮文字跟着换
- `tools/checks/verify-dev-flags.mts`:见上(含变红验证)
- 其余回归全绿:`pnpm check`、调试服与浏览器冒烟、热更新、四档自包含包 `file://`、
静态托管预览、发布产物差异仅来自 `use_custom_audio`、6 组渲染与重构前逐像素相同
出图:`tools/checks/shot-panel.mts` 现在出三张——面板、取色器、下拉展开。
## 一处没查清的抖动
同一批里 `verify-panel` 曾失败过一次(连续跑两次 smoke 之后),单独跑与再跑两次都通过,
错误输出被 `Select-Object -Last 1` 截掉了,没留下现场。**不确定是测试自身的问题还是环境抖动**,
记在这里而不是当作没发生。再出现的话先保留完整输出。
@@ -0,0 +1,83 @@
# 14 — 面板动效
Status: resolved
Type: task
## 需求
> 给这个壁纸设置添加一些细腻的动画吧
## 一条原则
**只做解释状态变化的动画**——东西从哪来、去哪了、刚才是哪个变了。
面板是常驻 UI。装饰性的循环动画(呼吸、闪烁、渐变流动)只会让人分心,而且这个面板
每改一个属性就会重画一次,任何"一直在跑"的东西都会变成噪音。所以整套动效里
**没有一条 `@keyframes` 是持续运行的**。
## 加了什么
| 位置 | 动效 | 它在解释什么 |
| --- | --- | --- |
| 抽屉 | 曲线改成 `cubic-bezier(.22,.61,.36,1)` | 纯 `ease` 结尾会急停 |
| 面板刚滑出 | 逐行错开上移淡入(`--i` × 16ms) | 这些东西是"刚出来"的 |
| 条件新出现的行 | 同样的入场,不错开 | 它是被勾出来的 |
| 行悬停 | 底色高亮 + 图标放大 1.16 | 指针现在在哪一行 |
| 复选框 | 自绘,勾靠 `background-size` 从 0 长出来 | 刚才是哪个开关变了 |
| 下拉箭头 | 展开时翻 180° | 列表开着 |
| 下拉/取色器弹层 | 缩放 + 上移出现 | 它从按钮那儿长出来 |
| 色块 | 悬停放大;确认后弹一下 | 颜色变了但位置没变,不弹容易看不出 |
| 重置 | 悬停时图标转半圈 | 这是"复位" |
| 确认/取消 | 悬停提亮、按下下沉 1px | 按到了 |
| 右边缘把手 | 悬停淡入并左移 2px | 这里能滑出东西 |
## 两个容易做错的地方
### 入场不能每次重画都重播
`paintProps()` 是 `host.textContent = ""` 然后整块重建。如果入场动画挂在
`#wesim[data-open="1"] .row` 这种选择器上,那么**每改一个属性**——拖一下音量、勾一个开关——
整个面板都会重播一遍动画,看起来像闪屏。
所以:
- **刚滑出**那一下:给根节点打 `data-enter="1"`,520ms 后摘掉。带令牌,连着开关几次
只有最后一次的定时器能摘。
- **条件新出现的行**:`paintProps` 记着上一次可见的属性键,只有新冒出来的才加 `.row-enter`。
那 4 个颜色滑杆是个例外:它们不是属性表里的键(是 WE 内置的),走不到按 key 认新的那套,
所以单独用 `colorOptionsShown` 判一次"上一次它们不在"。
### 复选框不能用 `::after` 画勾
`<input>` 是替换元素,**伪元素在它上面不生效**。所以勾是一张内联 SVG 背景图,
靠 `background-size: 0 0 → 11px 11px` 长出来(`background-size` 是可过渡属性)。
## prefers-reduced-motion
整套动效在 `@media (prefers-reduced-motion: reduce)` 里被关掉。这不是可选项:
前庭功能敏感的人会因为界面动而难受,而这套动效对可用性没有任何贡献。
## 验证
`tools/checks/verify-panel.mts` 第 ⑩ 组,全部通过:
- 滑出后 `data-enter="1"`、行上跑的是 `wesim-row-in`、`--i` 是 0,1,2,3,4
- 播完 `data-enter` 被摘掉(不会每次重画都重播)
- 勾「显示颜色选项」后,**只有**亮度/对比度/饱和度/色调偏移四行带 `.row-enter`
- 复选框 `appearance: none`(确认是自绘的)
- 展开下拉 → `aria-expanded="true"` 且箭头 `matrix(-1,0,0,-1,0,0)`(翻过来了);Esc 后复位
- `Emulation.setEmulatedMedia` 模拟 reduce → 动画与过渡时长都降到 ~0
## 测出来的三个真问题
1. 颜色滑杆走不到 `isNew(key)` 那套,永远不带入场标记(上面已说)。
2. **点开下拉时先把 `aria-expanded` 设成 true,紧接着 `openComboFor` 内部的 `closeCombo`
又把它清掉** —— 顺序反了,箭头永远不翻。改成 `openComboFor` 之后再设。
3. 测试自身:动效那一段先移到远处再移到边缘。前面点过「取消」会把 `armed` 置 false,
而它只有"指针离开边缘"才复位——不先走开一次,合成指针根本打不开面板。
## 回归
全绿:`pnpm check`、调试服与浏览器冒烟、热更新、四档自包含包 `file://`、静态托管预览、
发布产物里动效代码 0 处(`wesim-row-in` 计数为 0)、6 组渲染与重构前逐像素相同。
@@ -0,0 +1,63 @@
# 15 — single 分发目录名改成 `single-<游戏id>-<壁纸id>`
Status: resolved
Type: task
## 需求
> single 的发布目录结构调整:single-game-name
## 改动
| | 旧 | 新 |
| --- | --- | --- |
| 单档 | `wallpaper-<壁纸id>` | `single-<游戏id>-<壁纸id>` |
| 游戏合集 | `collection-<游戏id>` | 不变 |
| 全部合集 | `collection-all` | 不变 |
实际产物:
```
dist/releases/single-hsr-kv37
dist/releases/single-hsr-xilian
dist/releases/collection-hsr
dist/releases/collection-all
```
一处代码改动(`tools/build.ts`):
```ts
dir: `single-${wallpaper.gameId}-${wallpaper.id}`,
```
**为什么用 id 而不是 `meta.name`**:`kv37` 的 `meta.name` 是 `「成为昨天的明天」`。
目录名带中文与 `「」` 与项目既有约定("目录名与选择键一律用 id,不用显示名,避免中文路径")冲突,
而 `wallpapers/<游戏>/<壁纸>` 这两级目录本来就是 id。所以 `game` = `gameId`、`name` = 壁纸 id。
## 改名本身是小事,暴露出的东西是大事
改完重建,`pnpm check` 全绿,但 `smoke-dev` 报了 12 处失败、`smoke-sim` 直接抛异常。
**根因:调试服在启动时把 `dist-map.json` 读进内存,之后一直用那一份。**
磁盘上已经是 `single-hsr-kv37`,服务端还按 `wallpaper-kv37` 校验 → 整档 404。
这不是改名带来的新问题,是**一直存在**的:加一档新壁纸、加一个游戏、甚至改个分发目录名,
都必须重启调试服才认。索引页(`indexPage()`)本来就是每次重读,只有 `handle()` 用了启动时那份——
两处不一致才是真正的问题。
现在 `handle()` 每次请求重读 `dist-map.json`。这个文件很小,调试服也不在乎这点开销。
## 顺带
`.scratch/` 里 11 个脚本硬编码了旧目录名,全部更新;`CONTEXT.md` 与 ADR 0005 / 0007 同步。
确认过**没有任何代码按目录名前缀反推类型**(那样改名会静默失效),类型一律走 `dist-map.json` 的 `type` 字段。
## 验证
- `pnpm check`:4 个分发全部按新名构建,`check:dist` 通过
- 调试服:冒烟、浏览器冒烟、面板还原度、热更新全过(重启后)
- 四档自包含包 `file://` 全过(新目录名)
- 静态托管预览全过
- `test-sim-gate` / `test-inline-guard` / `test-transform-module` 全过
- 发布产物差异仍**只**来自 `use_custom_audio`;6 组渲染与重构前逐像素相同
- `single-hsr-kv37` 的目录结构确认:`index.html` / `project.json` / `preset.js` / `scripts/` / `styles/` / `images/` / `effects/` / `audios/`
@@ -0,0 +1,71 @@
# 16 — 项目目录整理
Status: resolved
Type: task
## 需求
> 整理一下当前的项目目录,该删的删,该挪得挪
## 删了什么
| 删掉的 | 大小 | 理由 |
| --- | --- | --- |
| `tools/.cache/` | 471 MB | Edge CDP profile 缓存,每次跑对拍都会重建 |
| `tools/shots/*`(留 `baseline-pre-refactor`) | 457 MB | 历史对拍截图,唯一还在用的是那份重构前基线 |
| `.scratch/dist-baseline/` | 96.9 MB | `.gitignore` 里写明"验证完即删"的临时判据 |
| `.scratch/sim-failed/` | 51.8 MB | `assertParses` 失败时的模块转储 |
| `.scratch/fileproto/` | 38.0 MB | file:// 协议探针产物 |
| `.scratch/sim-scripts/` | 15.2 MB | 从自包含页抽出的内联脚本 |
| `.scratch/timing/`、`.scratch/probe/` | 2.7 MB | 探针产物 |
| `.scratch/tmp.txt` | — | 临时文件 |
| `.scratch/compare-presets.mts` | — | 重构等价性一次性对拍,基线已删 |
| `tools/ascii.mjs`、`frame.mjs`、`subject.mjs` | — | 零引用(且 git 里有,可找回) |
| `wallpapers/audios/` | 空 | 0 条目、无代码引用 |
释放约 **1.11 GB**(1870 MB → 737 MB)。注意 `tools/.cache` 会在下次对拍时重新长出来——
这正说明它是纯缓存。
## 挪了什么
- `wallpaper-source.txt`(根目录)→ `wallpapers/sources.txt`。它是壁纸的**来源 URL 清单**,
放在根目录是散落的,归到它所描述的数据旁边。
- 已发布基线从 `.scratch/dist-baseline/project.json`(96.9 MB 目录里的一个小文件)
→ `.scratch/published-project.json`(3895 字节)。`diff-project.mts` 跟着改。
## 没删什么(以及为什么)
- `masters/`(75 MB):`.gitignore` 写明"无损音频母带:只在本地留存"。
- `tools/shots/baseline-pre-refactor/`(30 MB):**不能重新生成**(是重构前拍的),
而 `verify-visual-equivalence.mts` 靠它判"渲染有没有被改坏"。
- `dist/`(388.8 MB):可再生产物,但调试服正在用它,删了要重建才有得看。
- `tools/{capture,cdp,diff,serve,shot,compare,heatmap,rawdiff,atlas-pages}.mjs`:都有引用。
## 顺手修掉一个"空验证"
清理时发现 `verify-visual-equivalence.mts` 有个真问题:
```js
if (!existsSync(a) || !existsSync(b)) { console.log("缺少文件,跳过"); continue }
...
failed === 0 ? `视觉等价:${checked} 组全部与重构前逐像素相同`
```
我删掉了它的"当前截图"目录之后,它把 **0 组比对报成了"0 组全部逐像素相同"**——一句绿色的空话。
> 一个不检查前置条件的验证等于没有验证。
改成:缺文件就**直接失败**并打印出该跑哪两条命令;结尾再兜一道 `checked !== combos.length`。
**证明它会红**:删掉截图后跑,得到
`✗ 缺少 6 个截图,无法比对:…` 与明确的补救命令;补拍之后再跑,得到真正的
`视觉等价:6 组全部与重构前逐像素相同`。
## 验证
整理后全量回归,全绿:
- `pnpm check`:四个分发、`check:dist` / `check:syntax` / `check:paths` 全过
- 调试服冒烟、浏览器冒烟、面板还原度(10 组)、热更新、开发模式构建开关
- 四档自包含包 `file://`、静态托管预览、sim 门禁、内联守卫、模块改写
- `diff-project`(用精简后的基线)、`verify-visual-equivalence`(6 组逐像素相同)
@@ -0,0 +1,105 @@
# 17 — 自动生成的 id 改成自增
Status: resolved
Type: task
## 需求
> 自动生成的 json 相关的 id 采取自增的形式,如 audios
两处(用户确认「两者都要」):
| | 旧 | 新 |
| --- | --- | --- |
| `project.json` 属性的 `index` / `order` | 手写在 `src/project.template.json` | build 按属性表书写顺序自动编号 |
| 音源 id(`meta.json` 的 `audio.choices[].id`) | 手写字符串(`zaiduheni` / `xilian` / `pv37`) | build 自增分配(`"1"` / `"2"` / `"3"`) |
## ① index / order
以前每个属性都要写 `"index": 4, "order": 104`。代价在**加一个属性**时才显出来:
后面全部要重编号——上一轮加 `use_custom_audio` 手改了 6 处,而且在产物 diff 里
刷出 8 行纯噪音,把真正的改动淹掉了。
现在 `numberProperties()` 按属性表的书写顺序编号:
```
index = 0,1,2… order = 100+index
```
**必须在删属性之后编号**:单档分发会删掉 `preset`/`preset_note`/`bgm`/`bgm_note` 四行,
以前删完就留下空号(0,1,4,5,6,7,8)。现在自动补上,单档是 0…6 连续。
`schemecolor` 是唯一例外:它是 WE 的**内置**属性,线上就是 `order 0` 且**没有** `index`。
用 `"$order": 0` 显式钉住,钉住的属性不参与 index 序列——这样 `collection-all` 的产物
与线上逐字一致,diff 里只剩真实改动。
## ② 音源 id
`meta.json` 里不再写 id:
```json
"audio": {
"choices": [
{ "name": "「再度和你」", "file": "audios/zaiduheni.flac" },
{ "name": "昔涟", "file": "audios/xilian-src.flac" }
]
}
```
### 一个必须解决的约束:id 要**全项目**唯一
第一版我按「每档壁纸各从 1 开始」写,**这是错的**。`generate.ts` 里:
```ts
bgmOptions.push({ label: choice.name, value: choice.id });
```
`bgm` 下拉会把一个分发里**所有**壁纸的音源平铺进同一个 combo。两档壁纸都叫 `"1"` 的话,
选中的到底是哪一个就无从分辨了。
所以用一个**全项目计数器**,分配顺序固定为:
```
游戏 → 该游戏的共享音频 → 各壁纸的音源(按 id 排序)
```
遍历的是**整棵资源库**而不是本次要构建的分发子集——否则同一个音源在不同分发里会拿到不同的
id,用户从合集切到单档时 bgm 选择就失效了。实测:xilian 的两个音源在 `collection-all` 与
`single-hsr-xilian` 里都是 `"2"` / `"3"`。
`default` 也跟着改:从「id 字符串」变成「**1 起的位置**」,省略 = 第一个。换算成 0 起的
`defaultIndex` 只做一处(`vault.ts` 的 `readAudioDecl`)。
### 代价(用户已确认接受)
音源 id 会进 `project.json` 的 `bgm` combo value。改成数字后,**老用户已保存的 bgm 选择会失效**
(和壁纸预设 id 同一个问题)。运行时对这种情况是安全的:`bgmSource()` 找不到匹配就回落到
本壁纸默认音源。另外在音源清单中间插入一条会让后面的 id 整体后移——加音源请往后加。
## 顺手修掉一个「一直红着」的测试
改完跑 `test-props.mjs`,2 项失败:
```
✗ bgm 选「昔涟」后填自定义 URL → 期望 custom-track.mp3,实得 xilian-src.flac
✗ 切壁纸不得清掉自定义 URL → 期望 custom-track.mp3,实得 pv37.mp3
```
**这不是这次改动造成的**:上一轮加 `use_custom_audio` 时,`audio_file` 被改成只在开关打开时
才生效,而当时我**没有重跑这个测试**——它从那时起就一直是红的。`test-acceptance.mjs` 同理。
两处都补上开关,并顺手把「开关关着时填 URL 不生效」加成一条断言——那正是这个开关的核心语义。
> 改了语义却没跑对应的测试,等于没测。这次是靠改另一个东西时才撞出来的。
## 验证
全量回归,全绿:
- `pnpm check`:四个分发、`check:dist` / `check:syntax` / `check:paths`("3 条音源声明无孤儿文件")
- `test-props.mjs`(bgm 选择/回落、fps 限流)、`test-resize.mjs`、`test-acceptance.mjs`、`verify-fitter.mjs`
- 调试服冒烟、浏览器冒烟、面板还原度(10 组)、热更新
- 四档自包含包 `file://`、静态托管预览、sim 门禁、内联守卫、模块改写
- `verify-visual-equivalence`:6 组与重构前逐像素相同
- `diff-project`:差异 13 → **14**,新增的那一条是 `bgm.options`(value 从名字变数字),
正是本次有意的改动;`index`/`order` 相关**没有**新增差异
@@ -0,0 +1,91 @@
# 18 — 设置面板上方的预览图
Status: resolved
Type: task
## 需求
> wallpaper engine 的设置面板上方是会展示 preview.gif 的,你确认一下之前给的截图中的样式,还原一下
## 先量,再写
用户第一张截图还在 attachments 里,我直接把它读回来、裁出预览区放大 3 倍看,并**逐像素采样**取几何与配色——
而不是凭记忆估:
| 量到的东西 | 值 |
| --- | --- |
| 面板底色 | `#222222` |
| 预览区左右内缩 | 各 20px(内容宽 298,高 237 ≈ 5:4) |
| 箭头按钮 | 36×26 的 `#3c3c3c` 方块 |
| 箭头字形 | `#4082f5` 的 `‹` `›`(**蓝色**,不是白色) |
| 箭头位置 | 贴预览的左右边缘、距预览底 26px |
| 预览图本体 | `preview.gif` 是 **160×160**(方形),面板里按 5:4 裁切显示 |
## 左右箭头:量到了,但最后**去掉了**
我一开始照着截图把那对箭头也做了出来,用"切换本分发的预设"当最近的对应。用户随后指出:
> 预览图上的上一个、下一个按钮在 wallpaper engine 中是切换壁纸用的,我们不需要,直接把那两按钮去掉
判断是对的,而且理由比"不需要"更硬:
- WE 那对箭头切的是**已安装的壁纸列表**,模拟器里**没有那个列表**——所谓"最近的对应"其实是**另一件事**。
- 面板上本来就有「壁纸预设切换」下拉,两个箭头只是重复。
- 更糟的是它会**误导**:看起来像在切壁纸,实际切的是预设。
所以 CSS 与 DOM 一起删掉,并在 `verify-panel.mts` 里留了一条**防回归断言**(`navs.length === 0`),
免得以后又照着截图把它加回来。
那个 3 倍放大的裁剪图留在 `.scratch/shots/ref-preview-area.png`,以后要再核对就用它。
(注意:那张图里**有**箭头——它是官方原样,不是我们的目标形态。)
## 打通链路
预览图**不被 preset.js 引用**,所以它不在原有的资源收集范围里,得单独接:
1. `project.json` 的 `preview` → 驱动(`SIMULATOR_DRIVER`)多带一个 `preview` 字段
2. 三条注入路径都要传:调试服(`dev.ts`)、静态构建(`build.ts` 的 `renderIndexHtml`)、自包含包(`bundle.ts`)
3. 面板渲染预览区;URL 走与运行时**同一套**解析(`__simAssetRoot` + `__simSwap`)
4. `collectAssets` 把预览图也内联成 `data:` URL
**第 2 步我漏了自包含包那一处**,于是自包含页的预览区整块不出现。补上后才对。
### 为什么自包含包必须内联它
自包含页在 `<分发根>/sim/` 下,而预览图在 `<分发根>/preview.gif`。
file:// 下 `../preview.gif` 是**跨目录**读取,浏览器直接拒绝——所以只能内联成 `data:` URL。
这和别的资源是同一个理由,不是额外讲究。
## 三个分支都验过
| 分发 | 有 preview | 结果 |
| --- | --- | --- |
| `collection-all` | ✓ | 预览区在,图片解码出 160×160,箭头可用 |
| `single-hsr-xilian` | ✓ | 同上 |
| `single-hsr-kv37` | ✗ | **整块不出现**(与发布产物一致:project.json 里没有 preview) |
| `collection-hsr` | ✗ | 同上 |
自包含包里:`data:` URL + 能解码。静态托管(`--with-sim`)下:子路径里 **0 个 404**。
## 左右箭头(已去掉,见上)
官方那两个箭头是切换"已安装的壁纸"。~~这里切**本分发的预设**——语义最近的对应:
读 `preset` combo 的 options,点一下切到上一个/下一个,单档分发(连 `preset` 属性都没有)两个都禁用。~~
## 验证
`tools/checks/verify-panel.mts` 第 ⑪ 组 + `tools/checks/verify-sim-page.mts` 的新断言,全绿:
- 预览区已渲染、`src` 指向分发根的 `preview.gif`、`naturalWidth` 真的是 160×160(**不是坏图**)
- **预览上没有左右箭头**(防回归:它们曾经存在过)
- 自包含页:`src` 是 `data:` 且能解码
其余回归全绿:`pnpm check`、调试服与浏览器冒烟、面板还原度、热更新、开发模式构建开关、
`test-props` / `test-resize` / `test-acceptance` / `verify-fitter`、sim 门禁、内联守卫、模块改写、
6 组渲染逐像素相同、`diff-project` 仍是 14 处(预览改动**没有**动发布产物)。
## 两个环境注意
- **内联守卫测试会改 `src/vendor/spine-player.js`**,而调试服的热更新在监听 `src/`——
两者会抢构建,跑那个测试时要先停调试服。同理 `pnpm build sim` 与 watcher 也会抢 `dist/`。
- `pnpm build:sim` 只编译模拟器(tsc),**不产出 sim 页**;改完面板要跑 `pnpm build sim` 才会重新内联。
@@ -0,0 +1,91 @@
# 19 — 单档分发里没有「背景音乐选择」
Status: resolved
Type: bug
## 现象
> 昔涟内有两首背景音乐,怎么没有看到切换选项?
`wallpapers/hsr/xilian/meta.json` 里确实声明了两首(「再度和你」/「昔涟」),
但 `single-hsr-xilian` 的 `project.json` 里**根本没有 `bgm` 属性**,所以那档分发里没有切换入口。
## 根因
`tools/lib/generate.ts` 里:
```ts
if (!isCollection) {
// 单档分发:只剩一档壁纸,"壁纸预设切换"没有意义。整条属性删掉
delete properties.preset;
delete properties.preset_note;
delete properties.bgm; // ← 问题在这一行
delete properties.bgm_note;
}
```
那句注释对**预设切换**成立——只剩一档壁纸,切预设确实没意义。
但它对**背景音乐**不成立:**一档壁纸照样可以带好几首曲子**。两件事被一起删了。
而且 `bgm` 的选项只在 `else`(合集)分支里算,所以即使只删掉 delete,下拉也会是空的。
## 改法
把 `bgmOptions` 的计算提到分支**之外**(单档合集都要算),再按内容决定留不留:
```ts
if (isCollection) {
// preset 的选项照旧
} else {
delete properties.preset;
delete properties.preset_note;
}
// 只剩"随预设"+ 至多一首时这个下拉是句废话(两个选项效果完全一样),删掉。
if (bgmOptions.length <= 2) {
delete properties.bgm;
delete properties.bgm_note;
} else {
(properties.bgm as ComboProperty).options = bgmOptions;
properties.bgm = orderProperty(...);
}
```
结果:
| 分发 | 音源数 | bgm |
| --- | --- | --- |
| `single-hsr-xilian` | 2 | **有**:随预设 / 「再度和你」 / 昔涟 |
| `single-hsr-kv37` | 1 | 没有(两个选项效果一样,是废话) |
| `collection-hsr` / `collection-all` | 3 | 有(不变) |
`collection-all` 的产物**逐字段未变**:`diff-project` 仍是 14 处差异,与本次无关。
## 为什么之前没人发现
**没有任何断言盯着"用户能不能切歌"这件事。** `test-props.mjs` 验的是 bgm 的**语义**
(选择、跨壁纸回落、优先级),它跑在 `collection-all` 上——那里 bgm 一直是好的。
`test-acceptance.mjs` 也只断言合集分发"bgm 含「随预设」档"。
所以这是一个**只影响单档分发、且只在用户实际去看的时候才暴露**的缺陷。
## 验证
新增两层,都盯着"用户能不能切":
- `tools/checks/verify-panel.mts` 第 ⑫ 组(浏览器,端到端):
- `single-hsr-xilian` 有「背景音乐选择」,下拉里就是 `随预设 / 「再度和你」 / 昔涟`
- 默认值是 `auto`(随预设)——与合集分发一致,不是"第一首"
- `single-hsr-kv37` **没有**这个下拉(只有一首)
- `tools/checks/check-single-bgm.mts`(**选项在 ≠ 切得动**):
在 `single-hsr-xilian` 里选「昔涟」→ `<audio>.src` 真的变成 `xilian-src.flac`;再选回来 → 变回去。
其余回归全绿:`pnpm check`、调试服与浏览器冒烟、四档自包含包、静态托管预览、sim 门禁、
内联守卫、`test-props` / `test-resize` / `test-acceptance` / `verify-fitter`、
6 组渲染逐像素相同。
## 一点反思
「单档分发只剩一档壁纸,所以切换类的东西都没意义」是个**看起来对、实际不对**的推理:
它把"壁纸维度的切换"和"音源维度的切换"混成了一个。加属性时如果顺手问一句
"这条属性删掉之后,哪个用户可见的功能会消失",就能避免。
@@ -0,0 +1,65 @@
# 20 — 游戏合集的文案取了全局 meta(collection-ys 顶着星穹铁道的标题)
Status: resolved
Type: bug
## 现象
`dist/releases/collection-ys/project.json` 的 `title` 是「【崩坏:星穹铁道】昔涟」,
而这一档收的是原神的《魔女尼可的茶会》。同批还发现 `collection-hsr` 也在用全局标题
(只是它恰好含「崩坏:星穹铁道」,所以肉眼看不出来)。
## 根因
`tools/build.ts` 的 `addGame()`:
```ts
meta: { title: vault.global.title, ... }
```
全局 `wallpapers/meta.json` 那层是给「**全部**合集」(`collection-all`)写的,
而 `wallpapers/README.md` 的三层分工写的是「**游戏**给「该游戏合集」写文案」——
**代码与规范不一致**,规范是对的。
顺带记一条同批发现、但本次没动的:`ys` 目前只有 1 档壁纸,`generateProjectJson`
按 `wallpapers.length > 1` 决定要不要 `preset`,所以 `collection-ys` 是一个**没有预设下拉的合集**,
产物行为与 `single-ys-nico-tea` 相同。
## 改动
| 文件 | 改动 |
| --- | --- |
| `tools/lib/types.ts` | `GameMeta` 增加**必填** `title` 与可选 `description` |
| `tools/lib/vault.ts` | 校验 `title` 非空;`description` 要么不写、要么非空 |
| `tools/build.ts` | `addGame()` 的文案取自**游戏级** meta,不回落全局;`description` 缺省仍按「共 N 档」生成 |
| `tools/build.ts` | 新增警告:`scope: "game"` 且 <2 档的合集("合集"名不副实) |
| `wallpapers/hsr/meta.json`、`wallpapers/ys/meta.json` | 补 `title` |
| `wallpapers/README.md` | 游戏 meta 字段表补 `title`(必填)/ `description` |
| `tools/checks/check-collection-copy.mts` | 新门禁:游戏合集的标题必须是"它自己那个游戏"的 |
## 没做(留给发布决策)
**一档的合集发不发。** `collection-ys` 现在会打警告但仍会产出。要不要在构建里跳过它
(或要求游戏 ≥2 档才生成合集),是发布范围的决定,没有替用户定;`--strict` 下这条警告会
让构建失败,需要时用它兜住。
## 验证
**先证明两条新门禁都会红。**
① 产物门禁(改动前的产物):
node tools/checks/check-collection-copy.mts
✓ collection-all 的 title 来自全局 meta → 【崩坏:星穹铁道】昔涟
✗ collection-hsr 的 title 逐字等于它自己游戏的 title → 产物 【崩坏:星穹铁道】昔涟 / 源 (未声明)
✗ collection-ys 的 title 提到了本游戏「原神」 → 【崩坏:星穹铁道】昔涟
游戏合集文案归属:失败 3 处
② 源数据门禁(临时摘掉 `wallpapers/ys/meta.json` 的 `title`,已按原字节还原):
node tools/build.ts --dry-run
构建失败:wallpapers/ys: 缺少 title(游戏合集的创意工坊标题)
**改完之后**:门禁 `通过`;`pnpm check` 全绿(typecheck / build / syntax / paths / dist 自包含);
`diff-project` 仍是那 15 处工作区演进差异,**没有一处与 title/description 有关**——
`collection-all` 的已发布文案逐字段未动。
@@ -0,0 +1,51 @@
# 21 — 资源布局迁移(ADR 0008)的漏网之鱼
Status: resolved
Type: bug
## 现象
ADR 0008 把 `wallpapers/` 的资源从**按类型分**(`effects/` + `images/`)改成**按骨架分**
(`spines/<骨架名>/` + `scene/`)。迁移完成后,四处没跟上:
- `wallpapers/hsr/kv37/spines/kv37/kv37.json` 与
`wallpapers/hsr/xilian/spines/xilian/xilian.json` 里作者的目录字段还指着旧布局:
`"images": "../images/"`(kv37 另有一处 `"audio": "../audios"`)。
ys 的 16 具骨架(下载器 `promote.py` 产出)已经是空串 / 没有该键——**只有 hsr 这两具手改过的存量**。
- `wallpapers/README.md` 的目录树(第 17-34 行)与第六节是新布局,但**第五节的 preset 示例**
(单骨架与场景各一处)和**第七节「加一档新壁纸」第 4 步**还写着 `./images/`、`./effects/`。
另外「加一个新游戏」还漏了上一轮才变成必填的 `title`。
- 代码注释里的旧目录举例:`tools/lib/bundle.ts`(4 处)、`tools/lib/generate.ts` 的 `assetRelIn`
注释、`src/runtime/index.ts` 的基准说明。
- ADR 0001 第 7 行与 ADR 0005 第 43 行仍断言"三层同名目录**不变**"——被 ADR 0008 取代后,
没有任何地方声明这次取代。
## 影响
`images` / `audio` 这两个字段**今天的运行时并不读**:播放器调的是 `loadTextureAtlas(config.atlasUrl)`,
**单参** ⇒ `pathPrefix` 为空,贴图页按 atlas 所在目录解析;`skeleton.images` 只进 `skeletonData.imagesPath`。
所以这不是渲染故障,而是**数据与规范不一致**;真正的危险是以后有人用上共享贴图或事件音,
它会静默指向一个不存在的路径。文档与注释那两半则是纯粹的误导。
## 改动
| 文件 | 改动 |
| --- | --- |
| `wallpapers/hsr/{kv37,xilian}/spines/*/*.json` | `images` / `audio` 归一化成空串 |
| `wallpapers/README.md` | 第五、七节示例改新布局;第六节补"骨架 json 作者目录字段留空串"规则;「加新游戏」补 `title` |
| `tools/lib/bundle.ts`、`tools/lib/generate.ts`、`src/runtime/index.ts` | 注释里的旧目录举例更新 |
| `docs/adr/0001`、`docs/adr/0005` | 就地标注"已被 ADR 0008 取代"(沿用 ADR 0002 的删除线惯例) |
| `docs/adr/0008` | 顶部补"取代 ADR 0001 第 7 行 / ADR 0005 第 43 行" |
## 验证
`pnpm build` 六个分发全过;`pnpm check` 全绿(含 `check-collection-copy`);
`dist/releases` 里 `../images/` **零命中**——旧前缀没有跟着产物上传
(`../audios/`、`../../audios/` 在合集里命中是**设计如此**:合集 preset.js 在下一层,音频在分发根,见 ADR 0005)。
## 顺带发现(本次没动)
`wallpapers/ys/nico-tea/spines/<骨架名>/meta.json` 是下载器写的**逐骨架来源记录**
(spine 版本 / 动画 / 皮肤 / 贴图页 / 来源 URL,见 `tools/downloader/README.md`)。
`copyWallpaperAssets` 按目录整体搬 `spines/`,所以这些记录会**跟着分发一起上传**(hsr 的骨架没有)。
来源 URL 留在发布物里是否合适,是产品决定;要清掉就得让搬移跳过 `spines/**/meta.json`。
+124
View File
@@ -0,0 +1,124 @@
"""一次性迁移:把 `wallpapers/<游戏>/<壁纸>/` 从「按文件类型分组」改成「一具骨架一组」。
旧:
effects/<名>.json ← 骨架数据
images/<名>.atlas images/<名>*.webp ← atlas 与页(扁平)
images/<名>/<名>.atlas + <名>.png ← 另一档用的「一骨架一目录」
images/scene/<图> ← 场景图
images/ava.jpg ← 背景图
新:
spines/<名>/<名>.json | <名>.atlas | <页…>
scene/<图> ← 场景图与背景图
audios/… ← 不动
规则(页的判定用 **atlas 自己声明的页名**,与运行时同一份真相,不做名字启发式):
1. 每个 `<atlas>` 的 stem 建 `spines/<stem>/`,把该 atlas 与它声明的页搬进去;
2. `effects/*.json` → `spines/<stem>/<stem>.json`;
3. `images/` 里剩下的文件(含 `images/scene/*`)→ `scene/`;
4. 按实际搬动结果重写 `preset.template.json` 里所有 `./…` 路径。
用法:
python .scratch/migrate-walls.py # dry-run,只打印
python .scratch/migrate-walls.py --apply # 真搬 + 改预设
"""
from __future__ import annotations
import json
import re
import shutil
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
WALLS = ROOT / "wallpapers"
APPLY = "--apply" in sys.argv
PAGE_EXT = re.compile(r"\.(png|webp|jpg|jpeg)$", re.IGNORECASE)
def atlas_pages(text: str) -> list[str]:
"""取 atlas 声明的页名(非属性行、以图片扩展名结尾的行)。"""
pages = []
for raw in text.splitlines():
line = raw.strip()
if line and ":" not in line and PAGE_EXT.search(line):
pages.append(line)
return pages
def migrate(wall: Path) -> dict[str, str]:
"""搬一档壁纸,返回 {旧相对路径: 新相对路径}(相对壁纸目录,不带 ./)。"""
moves: dict[str, str] = {}
# 1) atlas 及其声明的页
atlases = list((wall / "images").rglob("*.atlas")) + list((wall / "spines").rglob("*.atlas"))
for atlas in atlases:
stem = atlas.stem
dest_dir = wall / "spines" / stem
pages = atlas_pages(atlas.read_text(encoding="utf-8"))
moves[str(atlas.relative_to(wall))] = f"spines/{stem}/{atlas.name}"
for page in pages:
src = atlas.parent / page
if src.exists():
moves[str(src.relative_to(wall))] = f"spines/{stem}/{page}"
# 2) 骨架 json
for js in (wall / "effects").glob("*.json") if (wall / "effects").is_dir() else []:
stem = js.stem
moves[str(js.relative_to(wall))] = f"spines/{stem}/{js.name}"
# 3) images/ 下剩下的(含 images/scene/*)→ scene/
if (wall / "images").is_dir():
for path in sorted((wall / "images").rglob("*")):
if path.is_dir():
continue
rel = str(path.relative_to(wall))
if rel in moves:
continue
moves[rel] = f"scene/{path.name}"
# 4) 重写预设(按映射精确替换 ./ 路径)
preset_path = wall / "preset.template.json"
preset = json.loads(preset_path.read_text(encoding="utf-8"))
replaced: list[str] = []
def rewrite(value: object) -> object:
if isinstance(value, str) and value.startswith("./"):
old = value[2:]
new = moves.get(old)
if new:
replaced.append(f"{value} → ./{new}")
return f"./{new}"
if isinstance(value, dict):
return {k: rewrite(v) for k, v in value.items()}
if isinstance(value, list):
return [rewrite(v) for v in value]
return value
new_preset = rewrite(preset)
print(f"\n== {wall.relative_to(ROOT)}")
for old, new in sorted(moves.items()):
print(f" {old:52} → {new}")
for line in replaced:
print(f" 预设: {line}")
if APPLY:
for old, new in moves.items():
src, dst = wall / old, wall / new
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(src), str(dst))
# 清掉空目录
for empty in ["effects", "images"]:
d = wall / empty
if d.is_dir():
shutil.rmtree(d, ignore_errors=True)
preset_path.write_text(json.dumps(new_preset, ensure_ascii=False, indent="\t") + "\n", encoding="utf-8")
return moves
total = 0
for wall in sorted(p for p in WALLS.rglob("*") if (p / "preset.template.json").is_file()):
total += len(migrate(wall))
print(f"\n{'已搬动' if APPLY else '将搬动'} {total} 个文件;{'(dry-run,加 --apply 才真搬)' if not APPLY else ''}")
+42
View File
@@ -0,0 +1,42 @@
# 自包含模拟包(`pnpm build sim`)
## 问题
`pnpm dev` 需要本地 HTTP 服务才能看壁纸效果。要给别人看一版效果、要在没装依赖的机器上确认某档壁纸的样子、要归档某一版画面,都得先把服务起起来。需要一种**双击就能打开**的产物:不依赖 `pnpm dev`,不依赖任何服务器,也不依赖网络。
同时,构建入口本身也需要整理:`pnpm build name` / `single` / `collect` 在 ADR 0005 里已经写进文档,但 CLI 只认 `--single` / `--collect`;`package.json` 的 `build:all` 还引用了一个不存在的 `--all`。
## 目标
1. `pnpm build sim` 产出自包含包,`file://` 双击可开,四档分发(两个单档、两个合集)都可用。
2. `pnpm build` / `pnpm build <壁纸id>` / `pnpm build single` / `pnpm build collect` 四种范围选择都能用,且各自语义明确。
3. 自包含包的"无外部依赖"是**门禁**,不是口头承诺。
4. 已发布的 `collection-all` 产物逐字段、逐键序不变(workshopid 3604974793 / version 3)。
## 非目标
- 不做"打包成 exe / 单文件应用"——一个 HTML 文件就是交付形态。
- 不改运行时行为、不改已发布产物的资源落点。
- 不做 Service Worker / 本地代理绕过 `file://` 限制。
## 约束
- `file://` 下 `origin` 为 `null`:ES module、`fetch`、`XHR` 全部被 CORS 拒绝;只有内联 `<script>` 与 `data:` URL 可靠。
- 发布产物仍须是 ES module(它走 HTTP),自包含包是**同一批源码的另一种装载形态**,不是另一套代码。
- 音频默认内联(`xilian-src.flac` 37.7 MB → base64 50.3 MB),可用 `--no-embed-audio` 关闭。
## 验收
- `tools/checks/verify-sim-page.mts <分发目录名>`:CDP 以 `file://` 打开,断言画布有实际像素、0 异常、0 控制台报错、音频是 `data:` 且可解码、无 `http(s)` 请求、模拟器面板在。
- `tools/checks/test-sim-gate.mts`:向页面注入四种外部依赖,确认 `check:dist` 每种都拦得住。
- `tools/checks/diff-project.mts`:`collection-all/project.json` 与已发布基线(`.scratch/published-project.json`)逐字段比对、键序比对。
- ~~`.scratch/compare-presets.mts`~~:所有资源引用解析到同一份内容(SHA-1)。
它是**重构等价性**的一次性对拍,基线是整份旧 dist 拷贝(96.9 MB)。重构结束后基线已按
`.gitignore` 的约定删除,脚本一并移除。引用完整性现在由 `check:paths` + `check:dist` 常态覆盖
(每处引用都能解析、音源声明无孤儿文件),不再依赖旧基线。
## 相关 ADR
- ADR 0005:分发拓扑与构建入口(本工作把文档里的 `pnpm build name` 真正实现)
- ADR 0006:模拟器契约(自包含包复用同一份模拟器与驱动生成器)
- ADR 0007:自包含包本身(两个根、表的两族键、门禁的剥除细节)