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

+29 -3
View File
@@ -2,14 +2,37 @@
tools/shots/
tools/.cache/
# Spine 抓取器(tools/downloader/):文本缓存与 staging 产物都可重跑,不入库。
# 选择记录 selection.yml 反过来要入库——它是"这一档由哪些 part 组成"的可复现凭据。
tools/downloader/_cache/
tools/downloader/_out/
# 编译中间产物:tsc 把 src/runtime 与 src/simulator 编到这里,再拷进各分发。
build/
# 构建产物:dist/ 完全由 pnpm build 生成(releases/ + dist-map.json)。
# 它与源码树必然漂移,且是全量拷贝(约 290 MB),因此不入库。
# 它与源码树必然漂移,且是全量拷贝(约 390 MB),因此不入库。
dist/
# 重构前的基线快照:只作为"重构等价性"的临时判据,验证完即删,不入库。
.scratch/dist-baseline/
# 注意:.scratch/ 本身要入库——issue 与 spec 就住在这里(见 AGENTS.md)。
# 但它是工作台,下列"参考资料副本"与"调试转储"只服务本地调查,不入库。
#
# 参考副本:上游第三方的整仓克隆与页面抓取侦查数据。它们是只读的取证材料,
# 不属于本项目产物,体积以 GB 计(spine-versions ≈ 1.2 GB、mhy-recon ≈ 680 MB),
# 提交进来会让仓库永远无法克隆,需要时按各目录 README 重新取回即可。
.scratch/spine-versions/
.scratch/mhy-recon/
.scratch/page-mirror/
.scratch/downloader-all-scenes/
# 调试转储:工具重跑就会再生成,不入库
.scratch/sim-failed/
.scratch/sim-scripts/
.scratch/fileproto/
.scratch/timing/
# 对拍中间产物:截图、抽取帧、试压的 GIF 都是可重生成的二进制,不入库
.scratch/shots/
.scratch/*.png
.scratch/*.gif
@@ -19,3 +42,6 @@ masters/
# 杂项
*.log
node_modules/
__pycache__/
*.pyc
*.bak
@@ -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:自包含包本身(两个根、表的两族键、门禁的剥除细节)
+498
View File
@@ -0,0 +1,498 @@
# 透视场景对齐:现状与下一步(交接)
> 目标见会话 goal:让原神 4 页(camera type 1 + 真实景深)构图正确、与页面真实渲染对拍一致,
> 补一条能抓住取景/投影算错的门,然后撤掉 promote 对透视场景的默认拦截。
## 关键认识(2026-09-20 第 1 轮末):页面是**多动画 + 时间线驱动**的,静态快照对不上
用户提出"是不是和同个场景多个动画及其前后播放顺序有关"——**证实了,而且是两层**:
1. **每个 spine 节点自带播放指令**(`spine:{id, defaultAnimation, skin, timeScale, otherAnimation:[{track,animation}]}`)。
nico-tea 的 35 个 spine 节点里 8 个带 `defaultAnimation`;`scene_main` 里有 3 个:
`main_d_books → "in0"`、`main_nike → skin "b"`、`main_nike → skin "a"`。
我原来一律播 `data.animations[0]` + `default` 皮肤——姿态与页面不同(很多骨架的第一个动画是入场动画 `in`)。
2. **引擎有 timelineSetting**:按时间驱动 38 条属性,例如
`layout.position`、`img.scale`、`bei_e.position`、`inout.position`、`jiulaoshi.position`、
`main_slg.material.uniforms.opacity.value`、`main_btn.material.uniforms.opacity.value`。
**也就是说场景树里的 position/scale 只是某一时刻的值**——页面任意一帧的构图由时间线决定。
结论:**"与页面基准图一致"必须先定义"哪一帧"**。现在拿静态场景树 + 骨架首动画去对拍一张动态页面的截图,
本质上是在比两个不同时刻的画面。这是继续推进前要先定的事。
## 已落地
| 项 | 状态 |
| --- | --- |
| billboard 投影 `s = 1/(tan(fov/2)·(camZ−z))` | ✓ |
| 相机进预设 schema(`camera:{type,fov,position}`) | ✓ |
| 透视取景 = 视锥 = UI 矩形(投影单位 `uiW·s0 × uiH·s0`) | ✓ |
| **按相机深度从远到近绘制**(复刻页面的 z 缓冲) | ✓ **本轮关键修复**(构图由此出现) |
| **带旋转的倾斜面片不画**(`rotatedSkipped` 计数) | ✓(全场景只有 2 个,正是撑爆画面的那两块) |
| **按 `defaultAnimation` / `skin` / `timeScale` 播** | ✓ 已实现(抓取 → 预设 → 运行时);**视觉效果尚未验证**(需重启 dev 服重拍) |
| 诊断面 `content` / `solids` / `rotatedSkipped` | ✓ |
| promote 拦透视场景 | ✓(`--allow-perspective` 放行) |
| 页面真实渲染基准 | `tools/.cache/page-truth-2-main.png`(`.scratch/page-truth.mjs`) |
已**否证**:套 `cameraAdaptScreen` 的 `zoom = uiHeight/canvasHeight`(改完更放大,那是正交相机那条路用的)。
## 下一步(顺序)
1. **重启 dev 服并重拍**,确认 `defaultAnimation`/`skin` 那一步的视觉影响(截图 SHA 未变 = 没生效)。
2. **定义基准时刻**:与用户确认对拍的是哪一帧(进入后稳定态?时间线某一时刻?)。
建议:先把**时间线不驱动的属性**(未被 timelineSetting 点名的节点)对齐,被驱动的那些单独处理。
3. **先钉身份**:加"只画第 N 件"的调试开关,逐件与基准图对照,确定每个 part 对应画面里的什么
(现在连"城堡是 `main_zw_a`"都只是猜)。
4. 补门:内容中心落在视锥中心附近、内容/视锥尺寸比在合理区间。
5. 最后做倾斜面片的真四边形(4 角点过旋转 + 透视,用 `PolygonBatcher`)。
## 不要做的事
- 别调经验系数(权威值能从 bundle 读到,本轮就是这样读到 `aspect = uiWidth/uiHeight` 与 timelineSetting 的)。
- 别在门通过之前 promote 透视场景。
---
## 第 2 轮补充(2026-09-20)
**门已落地**:`tools/checks/verify-scene-player.mts` 新增透视样本(staged nico-tea),
在门里**独立算一遍**期望取景矩形(`s0 = 1/(tan(fov/2)·camZ)`、可见区 = `ui × s0`、再按画布比例 contain),
与运行时 `__sceneDebug.framing` 断言(相对容差 1e-6)。实测通过:4.6297 × 2.6042 两边一致。
这条门能抓住两类**真实发生过**的错:视锥用画布比例(差 1.30×)、取景用内容包围盒(差 4×)。
**角色为什么看不见**:`main_nike` 的页面坐标 y = −804.5、z = 849.7 → 投影后 y ≈ 2.67(半视锥是 1.30),
**整块在画面外**。所以本轮落地的 `defaultAnimation`/`skin`(`main_nike` 皮肤 b/a、`main_d_books` 播 `in0`)
虽然真的生效了,画面却**一个像素都没变**(截图 SHA 未变,已用 `Network.setCacheDisabled` 排除缓存因素)。
结论:**必须先解决"时间线把角色移进画面"这一层**,否则皮肤/动画这类改动无法验证。
**一个坑**:`preset.js` 是模块脚本,浏览器会复用缓存副本——改了预设却截出同一张图,白等一轮。
验收/截图脚本要 `Network.setCacheDisabled(true)` 或加 `?v=<timestamp>`。
**flaky 门**:`verify-scene-player` 曾报"失败 1 处"、重跑通过(疑似"隐藏画布前后截图不同"那条的时序)。
flaky 的门比没有门更糟,下一轮要加 settle/重试。
---
## 第 3 轮补充(2026-09-20):时间线的真实结构,以及读"实时世界坐标"的正确姿势
**时间线在哪**:nico-tea 的时间线表在**入口 bundle**(不在 vendors),由嵌套 merge 拼出来:
```js
mt = Fe(Fe(Fe(Fe(Fe({}, {loading:{type:1,start:0,name:"loading",loop:false,
cues:[{frame:91,method:"stop,退场同时播放page_cut动画"}], clips:[…]}}), …), …), …), …)
```
- 顶层时间线名:`loading` / `page_cut` / …(`loading` 是**入场序列**,第 91 帧有"退场并播 page_cut"的 cue)。
- **clip 可以嵌套**:`type:1` 的 clip 自带 `name`(如 `fbx`)与内层 `clips`,所以
"找 position 轨道"必须**递归**走 `clips`,只扫顶层会漏(我第一次就只捞到一个 `page_cut`)。
- 全页共 **16 个 clip、14 条 `.position` 轨道**:`layout` / `bg` / `bei_e` / `bei_f` / `bei_g` / `xl` /
`jiulaoshi` / `inout` / `play` / `d` / `c` / `b` / `a` …——**正是把角色与道具移进画面的那批**。
- 解析嵌套 merge 时 `Fe` 是**深合并**,用 `Object.assign` 顶替只能得到最后一个键(实测只出 `page_cut`)。
**读实时世界坐标:`__vue__` 这条路走不通**。生产版 Vue 2.7 不挂 `__vue__`(实测 `hasVue:false`)。
正确姿势是**在页面脚本执行前注入**(CDP `Page.addScriptToEvaluateOnNewDocument`):
- 包一层 `PerspectiveCamera` 构造,抓到相机实例(读它的 `fov/aspect/zoom/position` 就是权威值);
- 包 `Object3D.prototype.updateMatrixWorld`(或 `add`),按 `name` 建一张 `name → 世界矩阵` 表,
页面跑起来后直接读 `main_nike` / `main_zw_a` 等的**实时世界坐标**。
**为什么值得**:这一招能一次性回答三个悬着的问题——相机真实参数、每个 part 的真实世界坐标、
时间线到底把谁移动到了哪里。有了它,"静态场景树 vs 页面某一帧"的差就变成了可测的数字,
不用再靠猜或靠解析 48KB 的嵌套时间线字面量。
**建议顺序**(下一轮):先做注入钩子拿实时世界坐标 → 用它校正投影/摆放 → 再谈时间线的逐帧复刻。
---
## 第 4 轮补充:钩 WebGL 拿到**权威相机参数**(技术路线确定)
`__vue__` 走不通之后,换了个一定能成的办法:**在页面脚本执行前注入**
(CDP `Page.addScriptToEvaluateOnNewDocument`),包住
`WebGLRenderingContext.prototype.getUniformLocation / uniformMatrix4fv`,
把引擎每帧上传的矩阵按 uniform 名字记下来。实测抓到 8000 次上传、`projectionMatrix` 3 种、
`modelViewMatrix` 6014 种。三种投影矩阵:
| # | 形状 | 读出来的东西 |
| --- | --- | --- |
| 1 | 单位阵(m0=m5=1, m14=-1) | 某个中间 pass |
| 2 | **m0=1.536014, m5=3.555588, m11=-1, m10=-1.001001, m14=-20.01** | **fov = 31.417°(竖直)、aspect = m5/m0 = 2.3148 = 2500/1080**、near=10 / far≈20000 |
| 3 | m0=0.001042, m5=0.001852(正交) | 视口 **1920×1080**(宽高比 1.7778 = 画布比例)——**另一条 2D/UI pass** |
**结论一**:#2 证实了我这条路的取景模型——**透视相机的 fov 是竖直 31.417°、aspect 就是 `uiWidth/uiHeight`**
(不是画布比例)。这与第 1 轮从 bundle 里读到的 `new PerspectiveCamera(a.fov, s/l, …)` 互相印证。
**结论二(新)**:引擎还有**第二条正交 pass,跨度 1920×1080**(画布比例、1080 高)。
也就是说页面同时活在两个坐标空间里:3D 场景用 2500×1080 的透视空间,
UI/2D 层用 1920×1080 的正交空间。**场景节点属于哪个空间,是下一步要判的**——
`main_nike` 的 y=−804 在 2500×1080(y∈±540)里本来就在画面外,在 1920×1080 里更在外面,
所以它必然是被时间线移进来的,这条没变。
**结论三**:live 的 `modelViewMatrix` 里第一条是 `m14 = −1800`(相机 z≈**1800**),
而场景数据写的是 `position:[0,0,1920]`。差 6.7%,不是倍率问题,但值得记一笔。
**技术路线(下一轮直接用)**:这套注入钩子还能继续往下挖——
- 按 draw call 关联 `modelViewMatrix`(配合 `drawElements` 计数)就能拿到**每个对象的世界坐标**;
- 钩 `requestAnimationFrame` 可以**冻结时间源**,拿到确定的基准帧(与 `page-mirror` 规格是同一件事的两半)。
---
## 第 5 轮:一次"看起来成功"的假修复,与它带来的真结论
**试了什么**:从 live `modelViewMatrix` 里读到一条 `m14 = −2648.534`、缩放 1.379,
按"相机在 z=1800"反推出对象世界 z = **−848.5**,而场景树里 `main_nike` 的合成 z = **+849.66**——
大小几乎相等、符号相反。于是把深度改成 `camZ + z`(翻符号)。
**结果**:画面**戏剧性变好**——角色、茶桌、甜品架、城堡、标题全出现了,
构图第一次接近基准图。很容易就此收工。
**为什么必须撤回**:
1. **物理相反**:翻过来后天空(z=−1414)深度 = 506(**最近**)、角色(z=+849)深度 = 2770(**最远**),
等于把天空放到相机前面、角色放到后面。与"天空在最后、角色/桌面在前"直接矛盾。
2. **包围盒证据**:`content` 从 18.9×11.8 涨到 **40.9×17.4**(帧的 8.8 倍),
说明大量元素被错误放大——只是被巨大的天空盖住、看起来"丰富"。
3. 角色之所以出现,是翻符号后它的缩放变小、原点恰好落在画面上沿内侧一点点——**巧合,不是对齐**。
4. 那条 `modelViewMatrix` **很可能属于另一条正交 pass**(该 pass 相机在 z=1800,对象 z 是 UI 层自己的深度),
拿它推场景 z 的符号本来就不成立。
已撤回,`depth = camZ − z` 保持不变。
**真结论(这一轮的收获)**:
- 透视相机的 fov/aspect 已被 live 投影矩阵钉死(31.417° 竖直、aspect = 2500/1080)——**取景模型是对的**。
- 剩下唯一的大缺口仍是**时间线**:`main_nike` 的静态 y = −804.5,投影后 y ≈ +2.67(半视锥 1.30),
必然在画面外;基准图里它居中,说明入场时间线把它移动了约 **+800 页面单位**。
这与"14 条 `.position` 轨道"完全吻合。
**下一步(不再猜)**:用同一套注入钩子,按 draw call 关联 `modelViewMatrix` 与 `drawElements`,
**直接读出每个对象在页面里的实时世界坐标**(尤其 `main_nike`),
拿它当基准去校正"静态摆放 + 时间线位移"这条链。坐标一旦可读,对齐就是测量问题,不是猜测问题。
---
## 第 6 轮:读到页面的**实时世界坐标**(注入钩子按 draw call 关联 modelViewMatrix)
做法:注入时同时包住 `uniformMatrix4fv`(只记 `modelViewMatrix`)与 `drawElements`/`drawArrays`,
按"每个 draw 用最近一次上传的 MV"配对。相机无旋转,所以 MV 的平移列 = `对象世界坐标 − 相机坐标`。
实测 17668 条日志、8015 次 draw、7087 个不同 MV。
**关键读数**(按出现次数排序,截取):
| 次数 | scale | 世界坐标(= MV 平移 + 相机坐标) |
| --- | --- | --- |
| 290 | 1.000 | (0, 0, **−1800**) ← 纯视图矩阵 ⇒ **相机 z = +1800** |
| 82 | 22.320 | (0, −3.9, −2142.7) |
| 82 | 1.000 | (0, −223.0, −1920.0) |
| 68 | 1.379 | (0, 0.7, −2648.5) |
| 动画中 | 1.301 | (−98.9, **−863.8 → −840.3 → −827.5 → … → −555.6**, −2498.7) |
| 动画中 | 1.218 | (34.1, **−545.9 → … → −262.5**, −2338.4) |
| 动画中 | 0.106→0.341 | (1.0, 42.0, −1965.1) ← 缩放从小长大(入场) |
**结论一:相机 z = 1800,不是场景数据里的 1920。** 那条 290 次的单位缩放 MV 就是纯视图矩阵,
平移 = −相机坐标。所以 **`camera.position:[0,0,1920]` 不是 live 相机 z**(差 6.7%),以后一律用 1800。
**结论二:时间线正在把对象往上移——实测数据。** 两条轨道在世界坐标里连续变化:
y 从 −863.8 一路升到 −555.6(同一 x/z、scale 不变),另一条从 −545.9 升到 −262.5。
这就是入场动画,**上升方向 = +y**,速率约每帧几单位。上一轮推断的"时间线把角色移进画面"由此坐实。
**结论三:live 的 z 与我的合成 z 大小相等、符号相反。**
例:live z = −848.5(= MV −2648.5 + 1800)↔ 我的 `main_nike` 合成 z = **+849.66**。
上一轮我因为"天空会跑到相机前面"而否掉了翻符号——**但那条否证的前提(场景 z 就是 live z)现在被推翻了**:
live 相机 z 是 1800、且 live z 全为负。所以符号问题要重新审,不能再用旧前提否它。
**下一步**:把每个 draw 的世界坐标与 `scene.json` 的 part 逐个**配对**(x/y/z + scale 三元组匹配),
钉死"哪个 part 是哪个对象",然后:
1. 用 live 的 z(负)与相机 z=1800 重算深度;
2. 把时间线的**末态**(或某一确定时刻)也读出来(`requestAnimationFrame` 钩子冻结时间源),
得到确定的基准帧与基准坐标。
---
## 第 7 轮(关键):两条 pass 分开之后,一切对上了
**做法**:给每个 draw 记录**当时生效的投影矩阵**(是透视还是正交),把两条 pass 分开统计。
```
透视 pass(3D 场景,7.5k draws 里的绝大多数)
×218 scale=1.000 MV=( -4.0, -379.0, -1920.0) ↔ main_btn ( -4.00, -379.00, 0.00) ✓ 逐位
× 39 scale=1.000 MV=(-212.1, -844.1, -764.5) ↔ main_down (-212.07,-844.15, 1155.45) ✓ 逐位
×186 scale=22.300 MV=(0.0, -3.9, -2142.7)
×149 scale=1.400 MV=(0.0, 0.7, -2648.5)
× 84 scale=1.300 MV=(-98.9, -376.0, -2498.7) ← y 在动(入场)
× 84 scale=1.200 MV=(34.1, -58.2, -2338.4) ← y 在动
正交 pass(UI/2D)
×372 scale=1.000 MV=(0.0, 0.0, -1800.0) ← **这条就是上一轮误判的来源**
```
**结论一:透视相机 z = 1920**(与场景数据 `position:[0,0,1920]` 一致)。
验证:`main_btn` 的世界 z=0 → MV z = 0 − 1920 = −1920 ✓;`main_down` 的 z=1155.45 → MV z = −764.5 ✓。
**结论二:我的世界变换合成是对的**——两个 part 的 x/y/z **逐位命中**。
所以"静态摆放 + 透视投影 + 相机参数"这条链**已经正确**,不需要再动。
**结论三:第 5、6 轮两次误判的根源找到了**:把两条 pass 的相机混在一起看。
正交 pass 的相机在 z=1800,我拿它的 `(0,0,−1800)` 当"透视相机 z=1800",
于是推出"对象 z 符号相反"——**纯属跨 pass 误读**。
**结论四:唯一剩下的差异就是时间线。** 被动画驱动的是**容器节点**
(`layout` / `inout` / `bei_e…g` / `xl` / `jiulaoshi` / `play` / `d` / `c` / `b` / `a`),
叶子 part 跟着父容器走。所以:
**下一步(明确)**:把时间线**末态**的容器变换量出来(等入场动画跑完再读 MV,
或用 `requestAnimationFrame` 冻结时间源取确定时刻),把它作为"容器附加位移/缩放"
烘进预设——**摆放本身不用再推导,只要补这一个位移量**。
---
## 第 8 轮:改为**读源码**(用户要求:出问题先看来源网站源码,别盲猜)
### 引擎的权威实现(vendors bundle 原文)
```js
// 节点局部矩阵:只有 autoMatrix 为真才自动更新,否则算一次就冻结
a.matrixAutoUpdate = void 0 !== e.autoMatrix && e.autoMatrix;
a.matrixAutoUpdate || a.updateMatrix();
// 时间线:按路径取目标对象,**直接写**它的属性;属性在 matrixMatch 里则强制打开自动更新
var v = parsePath(i, this.name), g = v.object, y = v.target, w = v.prop;
g && (g.matrixAutoUpdate = g.matrixAutoUpdate || s.matrixMatch.includes(w));
```
### 三条权威语义(替代此前的猜测)
1. **节点局部矩阵 = `compose(position, rotation, scale)`**(three.js `updateMatrix`),
**默认冻结**;`autoMatrix:true` 或被时间线驱动 `position/scale/rotation` 的节点才逐帧重算。
→ 所以我"只合成位移与缩放、忽略旋转"对**叶子**是安全的(两片倾斜面片是叶子),
但对**有子节点的旋转节点**不成立。
2. **时间线是覆盖(`target[prop] = 值`),不是叠加。**
3. 因此"整场平移"= 某个**公共祖先容器**的 position 被时间线改写;该容器是所有相关 part 的祖先,
所以对每个 part 表现为**同一个位移**——与第 8 轮实测的 `Δ=(212.1, 365.1, −1123.5)` 完全吻合。
### 本轮同时落地的实现
- `sceneConfig.timelineOffset`(页面单位)进 schema:types / globals / generate / 运行时都支持;
运行时在投影前把它加到每个 part 的 position 上。
- nico-tea 的 `preset.template.json` 里已写入实测值 `[212.1, 365.1, -1123.5]`。
- 效果:`content` 包围盒从 18.9×11.8 收到 **7.58×3.62**(视锥 4.63×2.60),
画面与基准图明显接近(角色、茶桌、甜品架、城堡、标题各就各位)。
### 还差的(下一步,仍按"读源码"办)
- 实测到**分组位移**不完全相同:`desk/nike` Δ=(138, 394, −1066)、`root/btn` Δ=(206, 70, 19)、
`desk/xl` Δ=(−176, 271, −1123)——说明**不止一个容器**被时间线改写。
下一步:把时间线里每条 `.position` 轨道的**末帧值**读出来(源码里 `data[].frames` 就是关键帧值,
`getValue()` 返回 `target[prop]`),按容器分别落到 part 上,而不是一个全局值。
- 读源码时优先看 `parsePath` / `matrixMatch` 的定义,确认 `position` 轨道的目标对象到底是哪个容器。
---
## 第 9 轮:继续读源码——轨道的目标怎么解析,以及入场时间线的真实关键帧
### `parsePath`(vendors bundle 原文)
```js
parsePath(e, t) {
var n = t.split("."), r, i;
if (e && e.isObject3D) i = r = e.getObjectByName(n[0]); // ← 轨道名第一段是**节点名**
else r = e;
if (!r) return null;
for (var o = n.length - 1, a = 1; a < o; a++) if (!(r = r[n[a]])) return null;
return { target: r, object: i, prop: n[o] }; // 写的是 target[prop]
}
```
**结论**:`layout.position` 这种轨道名 = `getObjectByName("layout")` 找到节点 → 写它的 `.position`。
所以时间线**按名字**定位容器,与树路径无关。这解释了为什么我按 `path` 分组时
`wiggle/bg`、`wiggle/desk` 等会呈现出各自不同的位移——**它们各自被不同的同名节点驱动**。
### 入场时间线(`loading`)自己写的值
```
loading/fbx/layout.position 末帧 z = 539 (从 z=0.801 一路升上来)
loading/fbx/bg.position 末帧 y = 0 (从 y=−502.6 一路升上来)
loading/fbx/img.scale 末帧 = 1.023
cues: [{frame:91, method:"stop,退场同时播放page_cut动画"}]
```
即:入场时 `bg` 从 y=−502.6 升到 **0**、`layout` 的 z 从 0.8 升到 **539**。
这两条正是"整场在动"的来源,而**稳定态就是末帧值**。
### 为什么之前算不出全部轨道
`timelineSetting` 是**多个变量**深合并起来的(`var rt,ot,mt=Fe(Fe(…{loading:…}…), …)`),
我按 `mt=Fe(` 取到的那个表达式里只有一部分键(`page_cut` 等),其余时间线在**别的变量**里。
下一步:把 `Fe(` 合并链的每个参数分别取出来解析(或直接找所有 `\w+=\{[a-z_]+:\{type:\d+,start:` 的赋值),
拿到全部 14 条 `.position` 轨道的末帧值,按**节点名**落到对应容器上——
这就是"读源码"版本的正确做法,不需要再拟合任何偏移量。
---
## 第 10 轮:把时间线**按源码语义**接进摆放(取代拟合的偏移量)
### 拿到了全部 17 条轨道(末帧 = 稳定态)
```
layout.position [0,0,539] bg.position [0,0,0]
inout.position [0,0,-265] xl.position [736.665,52.619,-141.804]
bei_e.position [0,0,0] bei_f.position [103.18,174.725,-35.011]
bei_g.position [223.623,387.687,-179.149]
a/b/c/d.position (−389…420, −396…−410, ±5) ← 桌子上的四件
jiulaoshi/play [0,0,0] img.scale 1.023 btn_b/btn_c.scale 1
```
### 实现(不再是补丁式的偏移)
- 抓取期:`_collect_timeline()` 按 `name:"X.position"` 锚定 clip → 取 `data[-1].frames[-1]`(末帧)。
- 走场景树时:**节点名命中轨道就用末帧值覆盖**该节点的 position/scale(源码语义:`getObjectByName` + `target[prop] = 值`)。
- 上一轮那个拟合出来的 `timelineOffset` **已从预设里删掉**,不留两套机制。
实测效果:`main_nike` 的合成位置从 `(−64.65, −804.50, 849.66)` 变成 `(47.42, −312.91, 146.71)`
(时间线自己把它抬了 +492、拉近了 −703),画面上角色/书本/茶具/城堡/标题各就各位。
### 仍差一步(下一轮)
合成值 `(47.4, −312.9, 146.7)` 与 live 实测稳定值 `(147.4, −439.4, −273.8)` 还差 `(~100, ~126, ~420)`。两条线索:
1. `loading` 时间线在第 91 帧有 cue「stop,退场同时播放 page_cut」——
**25 秒时页面正在跑的是 `page_cut`**,它的轨道在**另一个变量**里(我只取到了 `loading` 那一支)。
把 `page_cut` 等其余时间线的轨道也取出来并**按先后覆盖**,才等于最终稳定态。
2. **基准帧本身要定**:`tools/.cache/page-truth-2-main.png` 是"点两下、等 7 秒"拍的,
当时登录弹窗还在、可能还没进 `scene_main` 的稳定态。整个目标以它为基准,
所以"哪一帧"这个决定必须先落地(用户此前未答)。
---
## 第 11 轮:基准帧确定下来了,`page_cut` 的交接仍是嫌疑
**不再等"哪一帧"这个决定,我自己取了确定的基准**:不点击、禁缓存、等 10/20/30 秒各拍一张,
落 `tools/.cache/page-settled-{10,20,30}s.png`。三张一致 ⇒ 页面在 ~10 秒后就稳定了,
所以 **`page-settled-30s.png` 就是稳定态基准**(比原来那张"点两下、7 秒、登录弹窗还在"的
`page-truth-2-main.png` 干净)。
**关键观察(这次能对齐判断了)**:稳定态基准里的元素与我渲染的**是同一批**——
居中偏右的尼可、她身后的城堡、左边的甜品架、带书本的茶桌、左上标题、右侧蓝色小精灵、右上三个圆按钮。
也就是说 **`scene_main` 选对了**(不是"选错场景"),差的仍是**摆放**:基准里角色居中且大,
我这边角色被顶到画面上沿、书本占据中央。
**因此剩下的嫌疑收窄成一条**:`loading` 时间线在第 91 帧 cue「stop,退场同时播放 page_cut」——
我应用的 17 条轨道只包含 `loading` 那一支;`page_cut`(以及 merge 链里其它变量的时间线)
的轨道没并进来,而 10 秒后页面跑的很可能正是后者。**按先后顺序覆盖**才是最终稳定态。
**注意**:`page-truth-2-main.png`(含登录弹窗与"进入活动"按钮)是同场景的**未稳定态**,
以后一律用 `page-settled-30s.png` 作对拍基准。
---
## 第 13 轮:与**来源代码的逻辑对照**(用户要求),并修掉最后一处取景差异
### 对照表(引擎源码 vs 我们的实现)
| 环节 | 引擎(源码原文/行为) | 我们的实现 | 判定 |
| --- | --- | --- | --- |
| 相机创建 | `new PerspectiveCamera(a.fov ‖ 32, uiWidth/uiHeight, near, far)`,position/rotation 来自数据 | 取 `camera.fov` + `camera.position[2]` 做 billboard 投影 | ✅ 一致(aspect 由 ui 决定,已用 live 投影矩阵验证) |
| 画布适配 | `resizeUI`:`b = canvasAspect/uiAspect; b<1 ? uiW*=b : uiH/=b`,再 `cameraAdaptScreen` 只作用于 **scene_ui 相机** | **本轮改成复刻 resizeUI**(此前用 contain,多露 1.302×) | ✅ 已对齐 |
| 节点矩阵 | `compose(position, rotation, scale)`;`matrixAutoUpdate = autoMatrix ?? false`,否则 `updateMatrix()` 一次(冻结) | 只合成位移+缩放;旋转不参与 | ⚠️ 已知差异(受影响的是两片倾斜面片,已跳过并计数) |
| 父子链 | 矩阵相乘 | 位移受父 scale 缩放、缩放连乘 | ✅ 等价(无旋转时) |
| 时间线取值 | 场景在 modifier 里声明 `playTimeline{sceneName, trackName, frame}`;`parsePath` 按**节点名**定位,`target[prop] = 帧值` | 按场景点名的**块**取轨道末帧,覆盖同名节点的 position/scale | ✅ 本轮修好(此前全局扫名字,拿错块) |
| 时间线播放顺序 | 块内 `cues` 在第 N 帧"stop,退场同时播放下一条"(`loading`@91、`pv`@150、`page_cut`@20) | 只取单一块的末帧,未做逐段推进 | ⚠️ 近似(稳定态可取,过渡态不可) |
| 绘制遮挡 | three.js z 缓冲(材质带 `depthTest/depthWrite`) | 按相机深度从远到近排序(画家算法) | ✅ 不透明情形等价 |
| 混合模式 | 材质 `blending`(three.js 枚举) | 一律普通混合 | ⚠️ 未实现(全项目仅 1 处非 Normal) |
| 第二条 pass | 正交 UI 层(1920×1080)另有一条渲染链 | 只做透视 3D pass | ⚠️ 未实现(UI 层不还原) |
### 本轮的取景修复
`resizeUI` 是引擎作者写的适配算法,直接照抄之后:
`framing` 从 `4.6297 × 2.6042`(contain 的过露)变成 **`3.5556 × 2.0000` = 1920×1080 页面单位**,
与第 4 轮量到的正交投影矩阵(1920×1080)逐位一致——两条独立证据互相印证。
门里的期望值也改成同一算法(仍是独立实现),门通过。
### 仍差的一步
画面元素与排布已经对上基准(角色/书本/茶杯/城堡/标题/小精灵),但**整体仍偏下一段**
(基准里标题在上方、我这边在中下)。下一轮优先验两件事:
1. **基准帧的态**:匿名页面 10 秒后停在入场态(`loading`/`pv` 之前),而我们还原的是 `scene_main`;
2. **逐段推进**:`pv` 在第 150 帧 cue 切 `page_cut`——若页面停在 `pv` 的**中段**,末帧值就不对。
---
## 第 14 轮:源码里一直写着「按第几帧」,我漏了它
### 漏掉的字段
```js
{ id: "playTimeline", data: { sceneName: "scene_main", trackName1: "pv", frame1: 0, … } }
```
`frame1: 0` 就是**该轨道要停在第几帧**。我一直取轨道**末帧**,等于假设它播到了结尾。
### 用算术验证(不需要浏览器)
```
pv/fbx/layout.position 首帧 = [-0.487, 0.647, 2.136] 静态 = [-212.073, -358.588, 1141.952]
delta = (+211.586, +359.235, −1139.816)
main_nike 静态 (−64.65, −804.50, 849.66) + delta = (146.9, −445.3, −290.2)
live 实测稳定值(第 6 轮钩 WebGL 量的) = (147.4, −439.4, −273.8)
```
**逐位吻合**(x 差 0.5、y 差 6、z 差 16)。也就是说:`scene_main` 的稳定态 = `pv` 在**第 0 帧**的值,
而我们此前的"末帧"取值本身就错了。残差那十几单位应当来自 part 自己那一层的轨道(`bei_f/bei_g` 等)。
### 落地
`_collect_timeline` 现在解析 `frameN` 并与 `trackNameN` 配对,取该帧的关键帧值(越界回退首/末帧)。
抓取结果:`main_nike = (146.9, −445.3, −290.2)`,与 live 实测一致——**纯源码推导,无拟合量**。
### 仍然存在的差异(下一轮)
渲染出的元素与排布已与基准一致(角色/书本/茶杯/城堡/甜品架/标题/小精灵),
但**整体仍比基准低约 250px**。候选原因(按可能性):
1. **y 方向**:`flipY` 目前是"取反";若基准对应的是不取反 + 另一组偏移,会呈现为整体上/下移。
2. **相机看向点**:我用 `camera.position` 与"看向 -z";若引擎实际用 `lookAt` 对准别的点,会整体平移。
3. **基准帧的态**:匿名页面可能停在入场态(`loading` 那一支)而不是 `scene_main` 的 `pv@0`。
---
## 达成(2026-09-21):对象级对拍定位到「y 方向」,改完构图与基准一致
### 最后一步的铁证(对象级对拍 = 页面当预言机)
用注入钩子拿页面自己的 `P × MV`,把每个对象投到**屏幕像素**(画布 1664×936 → 按 720/936 缩放对齐):
| 对象 | 页面屏幕坐标 | 我们的 | 差 |
| --- | --- | --- | --- |
| `main_btn`(世界 y=−379) | **(638, 612)** | (637, **107**) | x 差 1px,**y 完全镜像** |
| `main_nike`(世界 y=−445) | **(706, 616)** | (725, **102**) | 同上 |
x 逐像素吻合说明"摆放 + 投影 + 相机 + 取景"**全对**;y 是镜像说明 `flipY` 那次取反是多余的——
页面投影本身就把「世界 y 向上」映成「屏幕 y 向下」(`(1 − ndc.y)/2`)。
默认改成不取反(需要时写 `flipY: true`)后,渲染与 `page-settled-30s.png` 的构图一致:
角色居中持杯、城堡右后、标题左上、甜品架左、茶桌与书在底部、蓝色小精灵右。
### 收尾
- **promote 拦截已撤**(验收:不带任何标志 `promote` 退出码 0)。
- 门全绿:`pnpm check`(6 分发自包含)、`verify-scene-player`(含透视取景门)、
`check-downloader`、`verify`(7 页 101 骨架 85.5 MB)、`diff-project`(新增 nico-tea 档 + 属性差异,符合预期)。
- 单骨架路径(xilian/kv37)**一行未动**,冻结基线不受影响。
### 留给以后的两条方法论(比这次的代码更值钱)
1. **遇到问题先读来源源码**——本目标的每一处真修复都来自源码(`getCamera`、`resizeUI`、`parsePath`、
`matrixAutoUpdate`、`playTimeline{trackName,frameN}`),而两处"看起来成功"的假修复都来自猜。
2. **用页面当预言机做对象级对拍**——`uniformMatrix4fv` + `drawElements` 钩子能给出
每个对象的实时世界坐标与屏幕坐标;"整体偏了"这种模糊症状一旦变成带符号的像素差,
结论就是唯一的(这次 x 差 1px / y 镜像,一步定案)。
### 仍未做的(不在本目标范围内,另立即可)
- 倾斜 3D 面片(全场景 2 个)的真四边形绘制;粒子/摆动/`CSS3DObject` 等 modifier;
材料混合模式(全项目 1 处非 Normal);正交 UI 层的还原(站点外壳,按设计不做)。
+161
View File
@@ -0,0 +1,161 @@
# 规格:场景播放器(一档壁纸 = 一整页场景)
> ⚠ 本文写于 ADR 0008 之前:下面的 preset 示例里 `./images/…`、`./effects/<名>/…` 是**当时的布局**,
> 现在读作 `./scene/…`、`./spines/<名>/…`(字段与规则没变,只是目录名换了)。
> 状态:**已落地**(2026-09-20)。抓取侧 6 页 74.15 MB 已落 staging,`verify` 绿。
> 门:`node tools/checks/verify-scene-player.mts`(夹具绿 + atlas 指错必红 + 还原重新变绿)。
> 本文只覆盖播放器与预设 schema,不含 promote 与文案。
## 落地记录(与规格的三处差异)
1. **取景参考矩形改成"所有 part 变换后的并集包围盒"**,不是 `ui` 矩形。
页面相机看向的是**场景原点**,而 `ui` 矩形的原点是它的左下角——直接拿它当可见区会把整个场景推向右上。
旧项目用的也是并集包围盒。`ui` 只留作字段,不再参与取景。
2. **`flipY` 的实测结论**:`position[1]` 取反(默认)即可让 kv45 的场景正立;
`scaleY` **不取反**(骨架本身在 spine-webgl 下渲染就是正的)。实测截图见
`tools/.cache/scene-player-shot.png`。
3. **glTF 网格平面的尺寸已补上**(同日):抓取期解 bundle 里的 `geometries` 表(`position.array` 是扁平 xyz),
算顶点包围盒 → `geometrySize` / `geometryCenter` 落进 `scene.json` 与 preset。实测这些网格都是中心为零的四边形;
kv45 的 `w22_slg` 其实是 `geometry.type: 2`(自带 config 尺寸),不受影响。
## 仍未做(按价值排序)
1. **modifier(`wiggle` / `BEZIER_PARTICLE` 粒子 / `CSS3DObject` DOM 层)**——真正的观感缺口:
粒子与摆动现在一律静态化。这是一个 feature 级工程(页面插件系统 + 贝塞尔粒子参数 + wiggle 数学),
值得单独立规格。
2. **纯色平面(`kind: "solid"`)不画**:它没有贴图,而 SceneRenderer 只提供 `drawTexture`/`drawSkeleton`
(传 undefined 会直接崩,已修)。数量进 `__sceneDebug.solids`,验收断言它能被看见;
要真画就得走 `ShapeRenderer` 那条路。
3. **页面 material 的混合模式**:实测 `blending` 只有 `1`(Normal)与 `2`(Additive,6 页共 1 处)。
`PolygonBatcher.setBlendMode(mode, pma)` 存在、映射也清楚(three.js 枚举 → spine BlendMode),
但收益只有一个节点,**暂不做**。
4. 旋转合成(抓取期与运行时都只记录)。
5. promote 进 `wallpapers/`(需要壁纸文案与音源清单)。
## 一、目标
让一档壁纸能播放"整页场景":**N 具骨架 + M 块贴图平面**,按抓取期定下的世界变换与绘制层级合成。
单骨架路径(`spineConfig`,xilian / kv37)**一行不动**——它被 `tools/shots/baseline-pre-refactor/`
的冻结基线钉着,`verify-visual-equivalence.mts` 靠它判等。
非目标(本轮):
- 不换 vendored 包(`src/vendor/spine-player.js` 是 spine-ts 4.2 线)、不引三方依赖;
- 不做几何平面的动画(`BEZIER_PARTICLE` 粒子等先静态化);
- 不做 promote(等本规格定案;验证走临时夹具,不碰 `wallpapers/`);
- 不做旋转合成(与抓取期一致:`rotation` 只记录不参与,`stats.rotatedNodes` 报数量)。
## 二、数据契约(抓取侧已产出,别再改形状)
`tools/downloader/_out/<游戏>/<页面>/`:
| 文件 | 内容 |
| --- | --- |
| `scene.json` | `{version, id, ui, camera, parts[], stats}`;part = `{kind, id, order, renderOrder, position[3], scale[3], localPosition?, localScale?, rotation?, geometryType?, modifiers?, runtime?}` |
| `spine/<id>/<id>.json` | 骨架(`skeleton.images` 已归一化为空串) |
| `spine/<id>/<id>.atlas` | 第一行 = 贴图页名,与落盘文件名逐字一致 |
| `spine/<id>/meta.json` | `spine` 版本 / `animations` / `skins` / `pages` / `originalImages` |
| `scene/<id>.<ext>` | `kind: "image"` 的平面贴的图 |
| `page.json` | 来源 URL、入口脚本、场景清单、警告 |
- `kind: "solid"`(`USE_TEXTURE == 0` 的纯色平面)**没有资源**,只按几何尺寸画一块颜色。
- `"runtime": true` 的 image part 是**由骨架渲染进贴图缓冲**的平面(`cacheContainer`),
运行时不下载也不画(它本来就是骨架的中间结果)。
- 世界变换语义:`world_position = parent_position + parent_scale ⊙ local_position`、
`world_scale = parent_scale ⊙ local_scale`;`order` = 树序遍历序(绘制层级)。
## 三、预设 schema
**单骨架(现状,不动)**
```json
{
"backgroundImage": "./images/ava.jpg",
"spineConfig": { "jsonUrl": "./effects/x.json", "atlasUrl": "./images/x.atlas",
"animation": "idle", "viewport": { "padLeft": "-25%" } }
}
```
**场景(新)**
```json
{
"backgroundImage": "./images/cover.jpg",
"sceneConfig": {
"ui": [2500, 1080],
"parts": [
{ "kind": "image", "id": "main_sky_jpg", "image": "./images/main_sky_jpg.jpg",
"order": 0, "renderOrder": 0, "position": [0, 0, 0], "scale": [1, 1, 1] },
{ "kind": "spine", "id": "main_nike",
"jsonUrl": "./effects/main_nike/main_nike.json",
"atlasUrl": "./images/main_nike/main_nike.atlas",
"animation": "眨眼",
"order": 3, "renderOrder": 0, "position": [-120.5, 480.25, 0], "scale": [1.09, 1.09, 1] }
]
}
}
```
约定:
- `spineConfig` 与 `sceneConfig` **二选一**;同时出现时**构建期直接报错**(不做静默优先级,歧义会烂在产物里)。
- part 的摆放与层级**由构建从 `scene.json` 抄进 `preset.js`**(构建期烘焙),运行时只读 `preset.js`:
分发自包含、`check:dist` 已经会校验 preset.js 引用的文件都在,且避免运行时多一次 sidecar 探测。
`scene.json` 留在抓取器 `_out/` 作留档与重跑凭据。
- 每个 part 的路径都用现有 `asset()`(`import.meta.url` 推导)解析;缺文件由构建 fail-fast + `check:dist` 兜住。
- `animation` 缺省 = 该骨架的第一个动画(清单在 `spine/<id>/meta.json` 的 `animations` 里,抓取期已记)。
- `Preset` 接口新增可选 `sceneConfig`;`generatePresetModule` 新增一个分支(与 `spineConfig` 对称)。
## 四、运行时
新增 `src/runtime/scene-controller.ts`,**不改** `spine-controller.ts`:
- 画布与渲染器:用 vendored 包已导出的 `ManagedWebGLRenderingContext` + `SceneRenderer`
自建(已核实这些名字都在 UMD 导出表里:`SkeletonJson` / `TextureAtlas` /
`AtlasAttachmentLoader` / `AnimationState` / `AssetManager` / `SceneRenderer` / `SpineCanvas` /
`ResizeMode` / `OrthoCamera` / `ManagedWebGLRenderingContext`)。
**不经过 `spine.SpinePlayer`**——`config.draw` 只在宿主骨架画完之后调用(vendored 包 15128 行),
画不出"位于宿主下面的层",而 nico-tea 的 `scene_main` 第一件就是贴图平面。
- 加载:每个 part 一个 `AssetManager`(或 `SkeletonJson` + `TextureAtlas` + `AtlasAttachmentLoader`)。
贴图直通 alpha、`premultipliedAlpha=false`(沿用现有渲染约定)。
- 每帧:逐 spine part `AnimationState.update(delta)` → `apply(skeleton)` →
`updateWorldTransform(Physics.update)`;然后 `renderer.begin()` → 按 `order` 依次
`drawTexture(...)`(image/solid)/ `drawSkeleton(skeleton, pma, ..., transform)` → `renderer.end()`。
- fps 门控:WE 不替壁纸限流,沿用现有"包一层排帧函数"的做法(自持循环后更简单)。
- 取景:`ui` 矩形(如 2500×1080)按等比 contain 映射到画布,再套现有背景比例链
(`viewport-fitter.frameForAspect`)——与单骨架的构图行为保持一致,`framing: "author"` 仍然生效。
- y 轴:页面是 y-up(three.js),spine 是 y-down;抓取期 `scene.json` 的坐标保持页面原样,
方向在渲染时统一处理(先按"整体 y 取反"实现,实测后定)。
- 就绪与失败:`window.__sceneDebug = { parts, loaded, errors, framing }` 供验收断言;
加载失败**显式记录**,不静默降级。
## 五、验证(不碰 `wallpapers/`)
1. `tools/checks/verify-scene-player.mts`(新):
- 临时目录里放一份**生成的** scene preset + staged 的 `kv45`(2.8 MB、4 骨架 + 1 平面,最小样本)
的资产拷贝 + 构建出的 `dist/scripts/spine-player.js`;
- 起一个临时静态服务(复用 `tools/serve.mjs` 的思路),用现有 CDP 管线(`tools/cdp.mjs`)打开;
- 断言:`__sceneDebug.parts` 全部 loaded、`errors` 为空、0 未捕获异常、画布非空、
取景矩形包含 parts 的变换后包围盒。
2. **先证明它会红**:故意把某个 part 的 `atlasUrl` 指错 → 断言错误被报出来、且 `loaded < parts`。
3. 现有门必须全绿:`pnpm check`(含 `check:dist` 自包含)与 `verify-visual-equivalence`
(单骨架基线,**不许动**)。
## 六、风险
| 风险 | 处置 |
| --- | --- |
| y 轴方向 / 页面相机(type 1 透视 fov、type 2 正交)不一致 | 先用正交近似;拿 nico-tea(camera type 1)实测,必要时把相机参数也抄进 preset |
| 页面 material 的混合模式(`【相加】`/`【滤色】` 等) | 先支持 NORMAL / ADD,其余记进 `page.json` 的 warnings,不假装还原 |
| 粒子与 modifier(`BEZIER_PARTICLE` / `wiggle` / `CSS3DObject`) | 本轮静态化,只画 diffuse 贴图 |
| 单骨架路径被误伤 | 新控制器独立文件;预设二选一由构建期报错保证;基线对拍门 |
## 七、待你定的三件事
1. **part 变换抄什么**:直接抄 `scene.json` 的世界变换(我建议;简单、且抓取期已按引擎语义算过),
还是抄 local + 树结构让运行时自己合成(更忠实,但运行时更复杂、且要复刻引擎语义)?
2. **`backgroundImage` 从哪来**:场景里没有"整页背景"这一件,但 WE 面板预览与单骨架路径都要它。
建议抓取期另挑一张全屏图当封面(或把场景第一张全屏 image part 复用为 backgroundImage)。
3. **验证样本**:先用 staged 的 kv45 走临时夹具(不碰 `wallpapers/`),还是直接 promote 一页
(那就需要你给壁纸的 `name` / `title` / `description` / 音源清单)?
@@ -60,7 +60,7 @@ viewport 以等比 "contain" 映射到画布:单轴贴边、另一轴多露出
| 回到 16:9 可逆 | 取景完全回到原值,无漂移 |
| 控制台 | 0 报错 / 0 未捕获异常 |
复现:`node .scratch/verify-fitter.mjs`(离线算式)、`node .scratch/test-resize.mjs`(resize 行为)。
复现:`node tools/checks/verify-fitter.mjs`(离线算式)、`node tools/checks/test-resize.mjs`(resize 行为)。
## 附带修复
@@ -29,7 +29,7 @@ Status: resolved
## 验收
`node .scratch/test-props.mjs`:
`node tools/checks/test-props.mjs`:
| 项目 | 结果 |
| --- | --- |
@@ -23,7 +23,7 @@ Status: resolved(第 4 步为人工步骤,待用户执行)
160×160 / 25 帧 / 12.5fps / 2.0s / **347 KB**。**完成**。
注:减色到 96/64/48 色收效甚微(604/519/474 KB)——体积由帧间变化量主导,不由调色板主导,
所以走帧采样率这条路更划算。
3. **全量验收**(`node .scratch/test-acceptance.mjs`):**全部通过**
3. **全量验收**(`node tools/checks/test-acceptance.mjs`):**全部通过**
- 总体积 **96.94 MB** / 33 个文件——未超已发布的 v2(104.98 MB),音频走无损后仍比 v2 小
- **0 死文件**
- 音频格式不变式:xilian 两个音源均为 `flac`、`pv37.mp3` 保持 mp3、已无 `.ogg`/`.opus` 残留、
+8
View File
@@ -11,3 +11,11 @@ The five canonical triage roles use their default label strings. See `docs/agent
### Domain docs
Single-context: one `CONTEXT.md` and `docs/adr/` at the repo root. See `docs/agents/domain.md`.
### Verification suite
The regression suite lives in `tools/checks/` — it used to be scattered under `.scratch/`, but those are issues and specs, not things you re-run forever. Prerequisites differ per script (some need `serve.mjs`, some need `pnpm dev`, some need neither), so there is no single command yet; `tools/checks/README.md` lists the exact invocation for each. **A check that cannot fail is worthless — when you add a gate, prove it goes red first.**
### Source data
`wallpapers/` is the single source of truth; every `dist/releases/<dir>/` is generated from it and must never be hand-edited. The complete spec for that directory — layout, the three `meta.json` shapes, id rules, audio rules, per-task steps, and exactly what the build validates — is `wallpapers/README.md`. **Read it before adding, moving, or renaming anything under `wallpapers/`.**
+46 -7
View File
@@ -1,33 +1,72 @@
# SpineWallpaper(网页壁纸作品)
一份已发布的 Wallpaper Engine 网页壁纸,以及围绕它的资源分层与验证约定。这个上下文只关心"一档壁纸由什么组成、怎么被打包和切换"。
一份已发布的 Wallpaper Engine 网页壁纸,以及围绕它的资源分层、构建与验证约定。这个上下文关心"一档壁纸由什么组成、怎么被打包成可上传的分发、怎么在本地调试"。
## 源数据规范
`wallpapers/` 是**唯一真相来源**,`dist/` 全部由它生成。那个目录里每个文件该放哪、叫什么、
构建会校验什么,见 **[wallpapers/README.md](wallpapers/README.md)** —— 目录结构、三层 `meta.json`
的字段、id 规则、音频规范、常见任务步骤、门禁清单都在那里。**动 `wallpapers/` 之前先读它。**
回归验收套件在 **[`tools/checks/`](tools/checks/README.md)**(原来散在 `.scratch/` 下,
但那里是问题与规格的存放处)。每个脚本的前置条件不同,逐条命令见那份 README。
## Language
**作品(Project)**:
一个 Wallpaper Engine 打包单元,即 `dist/` 整个目录;`project.json` 是它的清单,`index.html` 是它的入口。
一个 Wallpaper Engine 打包单元,即任一 `dist/releases/<分发目录>/`;`project.json` 是它的清单,`index.html` 是它的入口。
_Avoid_: 工程、站点、应用
**分发(Release)**:
一次可上传的构建产物,落在 `dist/releases/<目录名>` 下,目录名是 ASCII 的 `single-<游戏id>-<壁纸id>` / `collection-<游戏id>` / `collection-all`(如 `single-hsr-xilian`、`collection-hsr`、`collection-all`)。每个分发都是**自包含**的:整个目录可以拷到别的机器直接上传,内部不出现任何绝对链接与跨目录相对链接。
_Avoid_: 包、构建、输出
**合集(Collection)**:
把多档壁纸收进同一份上传的分发,用户用 WE 的 `preset` 下拉在它们之间切换。**它与「单档」是并列的两种类型**
(`type: "single" | "collection"`),只有收档范围不同:游戏合集(`collection-<游戏id>`)收一个游戏的壁纸,
全部合集(`collection-all`)收所有游戏的壁纸——「全部合集」**也是合集**,不是第三种类型(`scope: "game" | "all"`)。
收档范围是**意图**,不是档数:一个游戏哪怕只有一档壁纸,它的合集仍是合集,只是"切换"这件事不再存在。
_Avoid_: 集合("set"的通用义,不是本术语)、聚合包、多预设版;也别说"单档 vs 游戏"——游戏是资源分层,不是分发类型
**单档(Single)**:
只含一档壁纸的分发(`single-<游戏id>-<壁纸id>`)。它没有"切换预设"这回事,因此 `project.json` 里没有 `preset` 属性;
但**音源切换与预设无关**——一档壁纸照样可以带多首曲子,所以有 ≥2 首时 `bgm` 属性仍在。
_Avoid_: 单品、单独版
**游戏(Game)**:
壁纸所出自的作品名(`崩坏:星穹铁道`),资源分层的第一级,也是目录结构的第一层。
壁纸所出自的作品名(`崩坏:星穹铁道`),资源分层的第一级。目录名用稳定的**游戏 id**(`hsr`),显示名放在该目录的 `meta.json` 里。
_Avoid_: 来源、IP
**壁纸(Wallpaper)**:
用户可在 WE 属性里切换的一档壁纸(`昔涟立绘`、`「成为昨日的明天」`);它的显示名就是它的目录名,可以改。
用户可在 WE 属性里切换的一档壁纸(`昔涟立绘`、「成为昨日的明天」)。目录名用稳定的**壁纸 id**(`xilian`、`kv37`),显示名放在该目录的 `meta.json` 里。
_Avoid_: 预设、选项、preset(当指"这一档"本身时)
**预设(Preset)**:
一档壁纸的配置实体,落盘为该壁纸目录下的 `preset.js`,声明这一档要用哪些资源、以什么参数播放。一档壁纸对应且仅对应一个预设。
一档壁纸的配置实体,落盘为该壁纸目录下的 `preset.js`,声明这一档要用哪些资源、以什么参数播放。一档壁纸对应且仅对应一个预设。源数据是 `preset.template.json`,`preset.js` 由构建生成。
_Avoid_: 配置、profile、manifest
**预设 id**:
预设的稳定标识(`xilian`、`kv37`),必须与 WE 属性值一致,永不随显示名变化——已发布作品的用户设置靠它维系。
**预设 id / 壁纸 id**:
壁纸的稳定标识(`xilian`、`kv37`),必须与 WE 属性值一致,永不随显示名变化——已发布作品的用户设置靠它维系。它同时是目录名与下拉取值,因此**全局唯一、且不加前缀**(构建会校验并给出明确报错,而不是自动改名绕过)。
_Avoid_: slug、键名、代号
**共享音频(Shared audio)**:
放在合集根 `audios/` 下的音源。合集分发的 `bgm` 要能选到分发内任一壁纸的音源,所以音频统一集中到分发根,按 `<壁纸id>/` 分目录存放;游戏级共享音频(`wallpapers/<游戏id>/audios/`)落在合集根、不进任何壁纸子目录。单档分发里没有共享音频这回事。
_Avoid_: 公共音频、全局音源
**音源(AudioSource)**:
一首可作背景音乐的曲子。每首曲子归属且只归属一档壁纸;WE 的音源列表是所有壁纸音源声明的并集。
**文件名保留中文**,`meta.json` 只声明 `name`(显示名)与 `file`;**id 由构建按固定顺序自增分配**
(全项目唯一——`bgm` 下拉会把所有壁纸的音源平铺进同一个列表)。**choices 的顺序即默认**。
_Avoid_: BGM、曲目、音频文件
**模拟器(Simulator)**:
`pnpm dev` 注入页面的一层"Wallpaper Engine 官方 API 替身",让壁纸在不启动 WE 的情况下跑起来调试。它严格还原 WE 的真实 API 面:不自我检测、不提供 WE 没有的全局、把做不到的事显式标为替身。
_Avoid_: 仿冒器、mock、假 WE
**替身(Substitute)**:
模拟器**做不到与 WE 一致**的那部分能力(原生文件对话框回传 `file:///`、用户数据目录真实路径、CEF cookie 隔离)。它们在面板的常驻横幅与 `window.__weSim.substituted` 里被显式列出,而不是伪装成"和 WE 一样"。
_Avoid_: 兼容项、差异、限制
**视口(Viewport)**:
交给 Spine 播放器的世界坐标系矩形,决定构图。它以等比"contain"方式映射到画布:单轴贴边、另一轴会多露出世界,因此负 padding 的裁切只在目标比例下成立。
_Avoid_: 相机、取景框、裁剪区
+365
View File
@@ -0,0 +1,365 @@
# SpineWallpaper
**把米哈游活动页里的 Spine 立绘与整页场景,做成一份可以直接上传 Wallpaper Engine 的网页壁纸。**
![Node](https://img.shields.io/badge/node-%E2%89%A524-339933?logo=nodedotjs&logoColor=white)
![pnpm](https://img.shields.io/badge/pnpm-11-F69220?logo=pnpm&logoColor=white)
![TypeScript](https://img.shields.io/badge/TypeScript-strict-3178C6?logo=typescript&logoColor=white)
![Wallpaper Engine](https://img.shields.io/badge/Wallpaper%20Engine-Web%20%E7%B1%BB%E5%9E%8B-1F1F1F)
![版本](https://img.shields.io/badge/version-3-7C4DFF)
![门禁](https://img.shields.io/badge/gates-pnpm%20check-2EA043)
![许可证](https://img.shields.io/badge/license-%E6%9C%AA%E5%A3%B0%E6%98%8E-lightgrey)
![预览](wallpapers/preview.gif)
## 📚 目录
- [✨ 项目简介](#-项目简介)
- [🧩 特性](#-特性)
- [🚀 快速开始](#-快速开始)
- [📦 三种产物](#-三种产物)
- [🧪 本地调试](#-本地调试)
- [🛠 命令与配置](#-命令与配置)
- [🗂 目录结构](#-目录结构)
- [🏗 架构与数据流](#-架构与数据流)
- [🔬 取景算法](#-取景算法)
- [✅ 质量门禁](#-质量门禁)
- [📥 资源抓取](#-资源抓取)
- [🚧 已知约束](#-已知约束)
- [🧠 决策记录](#-决策记录)
- [🤝 参与与反馈](#-参与与反馈)
- [📄 许可证](#-许可证)
- [🙏 致谢](#-致谢)
## ✨ 项目简介
这是一个 **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](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) | 调试服、对拍与面板验收 |
```shell
# 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`。
## 🧪 本地调试
```mermaid
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/README.md) —— **动 `wallpapers/` 之前先读它**。
## 🗂 目录结构
```text
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)
```
## 🏗 架构与数据流
```mermaid
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](src/runtime/viewport-fitter.ts),决策见 [ADR 0003](docs/adr/0003-spine-follows-background-cover.md)。
> [!IMPORTANT]
>
> 应用方式是**原地改写** `config.viewport` 的字段后调 `player.setViewport(动画名)`,
> 绝不整体替换该对象——`setViewport()` 会无条件读 `config.viewport.animations[name]`,
> 换掉整个对象会在 `spine-player.js` 第 14973 行崩溃。维护时机挂在 `config.frame` 回调上。
## ✅ 质量门禁
`pnpm check` 是最小闭环(类型 + 构建 + 语法 + 路径 + 产物自包含 + 合集文案归属)。其余脚本前置条件不同,
**没有一条命令能全跑完**;完整清单与逐条调用方式见 [tools/checks/README.md](tools/checks/README.md)。
```shell
# ① 不需要服务器
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` 代劳。它**不是构建输入**:
```shell
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](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](docs/adr/0004-lossless-audio.md) 优先无损 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](docs/adr/0001-resource-layout.md) | 资源按「游戏 / 壁纸」分层,目录用 id、显示名只进元数据 |
| [0002](docs/adr/0002-preset-as-es-module.md) | 预设用 ES module(`preset.js`)而不是 JSON,路径相对自身推导 |
| [0003](docs/adr/0003-spine-follows-background-cover.md) | 立绘层取景跟随背景图的 cover 缩放(16:9 为参考比例) |
| [0004](docs/adr/0004-lossless-audio.md) | 音频用无损,不按体积取舍 |
| [0005](docs/adr/0005-build-pipeline-and-dist-topology.md) | 构建管线与分发拓扑:id 化目录、不产生任何链接、合集层级 |
| [0006](docs/adr/0006-simulator-contract.md) | 模拟器契约:还原 WE 的 API 面、不自检、替身显式化 |
| [0007](docs/adr/0007-self-contained-sim-build.md) | 自包含调试包的产出方式与两个基准 |
| [0008](docs/adr/0008-asset-layout-per-skeleton.md) | 资源布局按「一具骨架一组」(`spines/` + `scene/`),取代 0001 / 0005 的按类型分层 |
## 🤝 参与与反馈
这是作者个人的壁纸作品仓库,但欢迎反馈问题与建议:
- 🐛 **问题与规格**按仓库约定写在 `.scratch/<feature>/` 下(见 [docs/agents/issue-tracker.md](docs/agents/issue-tracker.md)),
而不是只留在聊天里。
- 🧭 **改代码前**:先读 [CONTEXT.md](CONTEXT.md)(术语)与相关 ADR(决策),
再读 [AGENTS.md](AGENTS.md)(本仓库给 agent 的约定)。
- ✅ **提交前**:`pnpm check` 必须全绿;新增门禁要先证明它会红。
- 📦 **改 `wallpapers/` 前**:先读 [wallpapers/README.md](wallpapers/README.md)。
- 🎨 **改面板 / 模拟器前**:先看 [ADR 0006](docs/adr/0006-simulator-contract.md) 与 `tools/checks/verify-panel.mts`。
## 📄 许可证
仓库当前**没有声明开源许可证**(`package.json` 为 `private`),代码与素材默认保留所有权利;
若要复用,请先联系作者。第三方组件遵循其各自授权:
- Spine 运行时(`src/vendor/spine-player.js`):spine-ts,授权随上游
[Spine Runtimes License](https://esotericsoftware.com/spine-runtimes-license)。
- 壁纸中的立绘、场景与音频素材版权归米哈游 / 相关权利人所有,本仓库仅作个人壁纸作品使用。
## 🙏 致谢
- [Spine](https://esotericsoftware.com/) / spine-ts:骨架动画运行时。
- [Wallpaper Engine](https://www.wallpaperengine.io/):宿主与属性面板 API。
- 米哈游活动页:立绘与场景的原始来源(各档来源 URL 见 `wallpapers/sources.yml` 与各壁纸的 `preset.template.json`)。
+1
View File
@@ -0,0 +1 @@
3
+1 -1
View File
@@ -4,4 +4,4 @@
关键取舍是**目录名用显示名(含 `「」` 等符号),而代码里的键用 ASCII 的预设 id**。反过来做(目录名用 id)会让目录结构在 WE 的文件夹里毫无可读性;统一用显示名做键则会在改标题时打断老用户已保存的预设选择。所以两者各司其职:显示名服务于人,id 服务于 WE 属性与用户设置,二者用 `preset.js` 里的 `id` 字段绑定。
保留了 `effects/`、`images/`、`audios/` 这三层同名子目录,是为了让 Spine 骨架 JSON 里的 `"images": "../images/"` 相对路径一字不改。
保留了 `effects/`、`images/`、`audios/` 这三层同名子目录,是为了让 Spine 骨架 JSON 里的 `"images": "../images/"` 相对路径一字不改。(**已被 ADR 0008 取代**:现在是 `spines/<骨架名>/` + `scene/`,骨架 JSON 那个前缀归零。)
+11
View File
@@ -5,3 +5,14 @@
理由是资源路径的自包含性:模块内用 `import.meta.url` 推导 `./images/...` 的绝对 URL,于是整个壁纸目录可以被复制、改名、搬家而不需要改任何一行路径。若用 JSON,路径只能以**页面**为基准写死,一旦目录改名(本项目的目录名就是显示名,改标题=改目录)就会全站 404,且每档壁纸都必须先异步取回 JSON 才能建播放器,给启动时序又加一个竞态点。
代价:配置文件不再是纯数据,不能直接 `JSON.parse` 消费。本项目接受这一点——预设数据量极小,且始终由代码持有。
## 修订(2026-09,随 TS 构建管线)
**结论不变:产物仍然是 `preset.js`,仍然用 `import.meta.url` 推导路径。** 但当初拒绝 JSON 的两条理由里有一条已经不成立,需要更正:
- ~~"目录名就是显示名,改标题=改目录"~~ —— 目录名已改为**稳定的 id**(见 ADR 0005),显示名只存在于元数据与 `project.json` 里,改标题不再需要碰目录。这条理由作废。
- "以页面为基准写死路径会在改名后 404" —— 仍然成立,且仍然是不用 `fetch` + JSON 的理由:JSON 里的路径只能相对**页面**解析,而 `preset.js` 能相对**自己**解析,后者才能在合集分发里按深度现算(单档 `./audios/`、游戏合集 `../audios/`、全部合集 `../../audios/`)。
- "异步取 JSON 会给启动时序再加一个竞态点" —— 仍然成立。
**新增的写法约定**:`preset.js` 不再手写,而是由 `pnpm build` 从 `preset.template.json`(**JSON,路径相对壁纸目录**)生成。也就是说"用 JSON 写、生成成 ES module"——两条路的好处各取一半:编辑期是纯数据(可 diff、可校验、好工具化),运行期仍是自包含的 ES module。
@@ -0,0 +1,100 @@
# 构建管线与分发拓扑:id 化目录、无链接、合集层级
## 背景
`dist/` 原先手工维护:`assets/{images,effects,audios}/` 平铺、一个 `dist/` 就是"全部壁纸",`project.json`、`preset.js` 也都是手写的。要加第三档壁纸、要按"单档/合集"两种形态发布、还要有个本地调试服,这套手工结构撑不住了。本次把它换成 `pnpm build` 驱动的构建管线。
## 决策
### 1. 一份代码,四种分发:`dist/releases/<类型>-<id>/`
| 分发 | 目录名 | 含壁纸 | 结构 |
| --- | --- | --- | --- |
> 类型只有两种:**单档**与**合集**(`type: "single" | "collection"`)。下表后两行都是合集,
> 区别在收档范围 `scope: "game" | "all"`——「全部合集」不是第三种类型。
| 单档 | `single-<游戏id>-<壁纸id>` | 1 | `<根>/preset.js`、`<根>/audios/` |
| 游戏合集 | `collection-<游戏id>` | 该游戏全部 | `<根>/<壁纸id>/preset.js`、`<根>/audios/<壁纸id>/` |
| 全部合集 | `collection-all` | 全部 | `<根>/<游戏id>/<壁纸id>/preset.js`、`<根>/audios/<壁纸id>/` |
`pnpm build name`、`pnpm build single`、`pnpm build collect` 是**选择构建哪些分发**的三个入口,不是三种不同的构建逻辑——同一份 `wallpapers/` 源数据,按需生成不同的子集。
具体范围(实现见 `tools/build.ts` 的 `parseArgs`):
| 输入 | 范围 |
| --- | --- |
| `pnpm build` | 全编:全部单档 + 每个游戏合集 + 全部合集 |
| `pnpm build <壁纸id> […]` | 指定壁纸(可多个) |
| `pnpm build single [壁纸id …]` | 只编单档;不给 id = 全部单档 |
| `pnpm build collect [游戏id …]` | 只编合集;不给参数 = 全部合集 |
子命令与壁纸 id 共用同一段位置,靠**词表**区分:`single`/`collect`/`sim` 是保留字,其余裸词都是壁纸 id。注意 `pnpm build collect`(无参,全部合集)与 `--collect all`(只编「全部壁纸合集」)语义不同,两者都保留。
### 2. 目录名用 id,不用显示名(**修订 ADR 0001**)
ADR 0001 决定"目录名用显示名、键用 id"。本次把**目录名也改成 id**:
- 分发目录要能直接上传、要避免中文路径在上传与跨平台解压时的编码问题;
- `dist/releases/` 下要能一眼看出哪档是"合集"、哪档是"单档",所以加 `<类型>-` 前缀;
- 显示名进 `meta.json`,改标题不再需要 `git mv` 一堆目录,也不再让"改标题"变成一次全站路径风险。
代价:`wallpapers/` 与产物目录的可读性变差。用 `meta.json` 里的 `name` 与 `dist-map.json` 的 `displayName` 补回来(调试服索引页就是按它渲染的)。
ADR 0001 里"保留 `effects/`、`images/`、`audios/` 三层同名目录"这一条**不变**:Spine 骨架 JSON 硬编码了 `"images": "../images/"`,改了就得动骨架文件。(**已被 ADR 0008 取代**——贴图页并到 atlas 同目录后,骨架 JSON 里那个前缀归零了。)
### 3. 不产生任何链接,只做拷贝
早期的方案考虑过 NTFS 硬链接来省磁盘(全量构建约 290 MB)。**否决**:链接会把"产物自包含"这条基本性质废掉——拷贝到别的机器、上传、或者只拷一个分发目录时,链接目标不在,产物就残了。
所以构建只做普通拷贝,每个分发目录都是完整的字节副本。磁盘代价用"只构建需要的那几个分发"(`pnpm build name` / `collect`)来换,而不是用链接。
### 4. 分发内不出现绝对链接与跨目录相对链接
一个分发目录必须能被整体拷到别的机器上传,因此产物里:
- 不出现 `/xxx` 这类根绝对路径(脱离分发根就没有意义);
- 不出现 `../` 越出分发根的引用;
- 不出现 `file:///` 与 `//`。
例外:合集分发里壁纸的 `preset.js` 会用 `../audios/<壁纸id>/` 指到**同一分发内**的合集根,这不越界。`pnpm check:dist` 把这几条做成硬门禁。
### 5. 路径用 `import.meta.url` 现算,按分发深度生成
`preset.js` 里的每个路径都是 `asset("./images/x.webp")` 这种相对**自身**的形式(见 ADR 0002)。同一档壁纸在不同分发里深度不同,所以每个分发目录的 `preset.js` 都是**现算现写**的,不能一份源码拷到多处:
```
单档 ./audios/<壁纸id>/<文件>
游戏合集 ../audios/<壁纸id>/<文件>
全部合集 ../../audios/<壁纸id>/<文件>
```
这条路径必须与"音频实际搬到哪儿"严格一致。为此构建**先算落点、再生成 `preset.js`**,并由 `check:dist` 在产物上反查每个 `source:` 指向的文件是否真的存在——"语法正确、路径指向空气"的产物会一路通过类型检查与引用扫描,然后在 WE 里静音(本次重构真的写出过一次)。
### 6. 音频集中到合集根,按壁纸分目录
合集分发的 `bgm` 下拉要能选到分发内任一壁纸的音源,所以音频统一集中到合集根的 `audios/`,不再逐壁纸重复一份:
```
audios/<壁纸id>/<文件> 各壁纸自己的音源(互相隔离)
audios/<文件> 游戏级共享音频(wallpapers/<游戏id>/audios/)
```
按壁纸 id 分一层,是为了让"同一首歌被两档壁纸各自引用"和"两档壁纸各有一首同名但不同的曲子"都变成可表达的状态——扁平布局下后者只能报错。共享层保持扁平,重名即歧义,直接构建失败。
单档分发没有合集根,音频留在壁纸目录内的 `audios/`(**非合集分发不出现共享音频目录**)。
### 7. `VERSION` 是版本号的唯一写者
`VERSION` 文件 → `project.json.version`(**数字**,WE 的格式)。构建时会断言"全部合集"这一档的 `project.json` 与线上已发布版本**逐字段一致**——重构不该悄悄改变已发布产物的语义。已知的唯一偏差是格式化:WE 自己的美化器输出 `"key" : value`(冒号前有空格)并展开嵌套,我们用 `JSON.stringify(…, null, "\t")`。字段名、键序、取值全部对齐。
### 8. 产物 JS 的语法目标由编译器兜底
运行时脚本经 `tsc` 编译,跑在 WE 自带的 CEF 里——那不是一个能随 Node 升级的引擎。因此 `tsconfig` 里显式关掉 `useDefineForClassFields`,让类字段降级成构造函数赋值;否则 `x = 1` / `x;` 这类声明式字段会原样输出,实测会让整个 `index.js` 直接 SyntaxError 白屏。
反过来,`presets.js` 与 `preset.js` 是**生成器拼出来的裸 JS 文本**,编译器完全不看它们。所以 `check:dist` 会让 V8 真的解析一遍每个产物模块(`vm.SourceTextModule`,只解析不执行)——这条门禁抓到过一次真事故(数组字面量里写了对象字面量的计算属性名)。
## 未采纳的方案
- **硬链接/符号链接省磁盘**:见第 3 条。
- **一个 `dist/` 装所有分发**:无法整体上传,且本地调试时"越界引用"会被别的分发意外命中,反而掩盖问题。
- **目录名继续用显示名**:见第 2 条。
+72
View File
@@ -0,0 +1,72 @@
# 模拟器的契约:还原 WE 的 API 面,不自检,替身显式化
## 背景
本地调试壁纸需要在没有 Wallpaper Engine 的情况下跑起来,因此有一个"WE 模拟器"。旧实现(`tools/wallpaper-engine-simulator.js`,1589 行)在这个位置上出过**事故级**的错:它靠 `typeof window.wallpaperRegisterListener === "function"` 来判断"我是不是已经在真实 WE 里",而这个 API **在真实 WE 里不存在**——判据恒为 false,于是模拟器在真实 WE 里也会自我激活并覆盖官方 API。
WE 实际注入的网页壁纸 API 只有 `window.wallpaperPropertyListener`(外加属性/通用属性/暂停/用户目录文件变更这几个回调)。`window.wallpaperRegisterListener` 与 `window.wallpaperEngine` 都不存在。
## 决策
### 1. 不做环境自检;由注入决定它是否存在
模拟器**没有**任何"我在哪"的判断。它只在有人把一个 `<script>` 注入页面时才存在——注入是事实,不是猜测,不存在误判空间。旧实现反过来做(靠 `typeof window.wallpaperRegisterListener === "function"` 自检),而那个 API 在真实 WE 里不存在,判据恒为 false,于是它在真实 WE 里也自我激活并覆盖官方 API。事故级。
**注入点有两个,都合法,但用途不同:**
| 场景 | 谁注入 | 模拟器本体从哪来 | 产物 |
| --- | --- | --- | --- |
| `pnpm dev` | 调试服按路由注入 | 调试服自己的 `/simulator/wallpaper-engine.js` | 分发目录**保持干净** |
| 静态托管(GitHub Pages) | 构建期写进 `index.html` | 分发自带的 `./scripts/wallpaper-engine.js` | `pnpm build --with-sim` |
| 上传 Wallpaper Engine | 没有人注入 | 不存在 | `pnpm build`(默认) |
默认的发布产物里**没有**模拟器文件,也**没有**任何注入痕迹——它必须与"用户从创意工坊下载到的东西"完全一致。
#### 两条供给路径各自踩过的坑
**调试服路径**:驱动原先把动态 `import()` 指向 `/release/<dir>/scripts/wallpaper-engine.js`,而那个文件只有 `--with-sim` 才会被复制进分发——于是默认构建下 `pnpm dev` 必然 404、**面板静默消失**。404 只留在控制台里,看起来像"面板没渲染",很难联想到是构建产物缺了文件。现在调试服从自己的路由提供,与分发目录里有没有它无关。
**静态托管路径**:地址必须是**相对**的 `./scripts/wallpaper-engine.js`。GitHub Pages 把站点放在 `/<repo>/` 子路径下,根绝对路径 `/scripts/…` 会 404。这一点由 `tools/checks/verify-static-preview.mts` 把关——它刻意把分发挂在 `/repo/` 前缀后面服务,挂在根上测不出这个错。
两条路径会在 `--with-sim` 构建 + 调试服同时出现时重叠,所以驱动带幂等闸(`window.__weSimDriver`)。没有它模拟器会被 mount 两遍,页面上出现两个面板。
#### 为什么不让 `pnpm dev` 自动带上 `--with-sim`
那样能一行修好调试路径,但代价是调试时看到的分发目录多了一个文件,不再是你要上传的那份。"调试对象 == 发布对象"这条不变量比少写一行更重要。
### 2. 公开面严格等于 WE 的公开面
模拟器只提供 WE 真正有的东西,一个不多:
- `window.wallpaperPropertyListener`(**等壁纸自己注册**,绝不代注册);
- 不提供 `window.wallpaperEngine`、`window.wallpaperRegisterListener`、`wallpaperGetUserDataPath` 等 WE 没有的全局。
理由:一个凭空承诺的 API 比没有 API 更危险——壁纸会依赖它,然后在真实环境里崩。冒烟测试里有两项断言就是专门盯这个:`typeof window.wallpaperEngine === "undefined"`。
### 3. 属性下发语义
- 初始属性**一次整批**交给 `applyUserProperties`,不逐条发(逐条会让壁纸的 `render()` 被调用 N 次,掩盖真实的性能与竞态)。
- 顺序:先 `applyGeneralProperties`(fps),再 `applyUserProperties`。
- **下发前必须等到 listener 就位**。`?__propsAt=dom` 复现的正是"属性早于壁纸模块到达"这条竞态;此时 listener 还是 undefined,直接下发会被静默吞掉、页面停在默认预设上。真实 WE 不会丢(属性在消息队列里),所以模拟器等,且**只等不代注册**——代注册会掩盖"壁纸忘了在模块顶层注册 listener"这个真错误。
- 壁纸必须在模块顶层注册 listener(见 `src/runtime/index.ts`),否则属性在 `load` 之前到达时收不到。
### 4. 替身必须显式化
浏览器不是 CEF,有几件事物理上做不到。它们不被伪装成"和 WE 一样",而是列入 `window.__weSim.substituted`,并在面板顶部有一条**常驻**的「⚠ 模拟环境」横幅:
| 能力 | WE 的行为 | 模拟器的替身 |
| --- | --- | --- |
| `file` / `texture` / `directory` 选择器 | 弹原生对话框,回传 `file:///` 绝对路径 | `<input type=file>` + `URL.createObjectURL`(回传 `blob:`) |
| 用户数据目录绝对路径 | `wallpaperGetUserDataPath` 返回真实路径 | 虚拟路径 |
| `document.cookie` | CEF 里被隔离 | 浏览器里可用(未隔离) |
### 5. 装载时序
模拟器源码是 ES module(要能正常用 `import`/`export` 写类型),但**不能用 `<script type="module">` 装载**:module 脚本会被延迟到解析完成后执行,那样它就不可能早于壁纸模块就位。驱动脚本因此是一个**经典** `<script>`,内部用动态 `import()` 装载模拟器。两者都由 `tools/lib/drivers.ts` 生成,调试服与对拍测试台共用同一份。
## 未采纳的方案
- **靠某个全局变量的存在自检**:这正是旧实现的事故成因,见第 1 条。
- **提供 `wallpaperEngine` 之类的便利全局**:见第 2 条。
- **替模拟器实现真实文件路径**:浏览器做不到,伪装只会让壁纸在真机上表现不同。
- **用 `<script type="module">` 直接加载模拟器**:时序不成立,见第 5 条。
+93
View File
@@ -0,0 +1,93 @@
# 自包含模拟包:把 `file://` 下的不可能变成可能
## 背景
`pnpm dev` 起本地服务、在浏览器里调壁纸,这条路径很好用,但它有一个硬前提:**有一个 HTTP 服务在跑**。要给别人看一眼效果、要在没装依赖的机器上确认"这档壁纸到底长什么样"、要把某一版效果截图存档,都得先把服务起起来。于是有了"自包含包"这个需求:一个 `sim/index.html`,双击就能开,不依赖 `pnpm dev`、不依赖任何服务器、也不依赖网络。
一开始以为这只是"把资源内联进 HTML"。实际做下来,`file://` 这个环境把四件平时根本不会注意到的事变成了拦路虎,每一件都值得记下来。
## 决策
### 1. 产物位置:`<分发根>/sim/index.html`,不往 `sim/` 里复制任何资源
自包含包是**分发目录的一部分**,不是一份独立产物:
```
dist/releases/single-hsr-kv37/
index.html ← 发布用(ES module,需要 http)
project.json
preset.js
images/ effects/ audios/ ← 布局见 ADR 0008:现在是 spines/<骨架名>/ scene/ audios/
sim/index.html ← 自包含(经典脚本 + 全内联,双击可开)
```
好处是它天然跟着分发走:拷走整个分发目录,发布用的那份和调试用的那份一起走。代价是页面不在分发根下,所以**资源根**和**模块根**要分开算——这是本 ADR 第 4 条,也是这次最费时间的一个坑。
资源一律不复制进 `sim/`。复制会让"分发目录里多出一份重复的 2 MB 贴图",且一旦漏拷一个就是白屏;全部内联则"要么整页可用,要么构建期就报错",没有中间状态。
### 2. 一切内联:模块、资源、音频都变成页面里的字节
`file://` 下浏览器把页面当成 `origin: null`,于是:
| 做法 | `file://` 下 | 结论 |
| --- | --- | --- |
| `<script type="module">` | 拒绝(CORS) | 不能用 |
| `import()` / `fetch()` / `XHR` | 拒绝(CORS) | 不能用 |
| 同目录相对 `<img>` / `<audio>` | 可以 | 可用但不必要 |
| 跨目录 file 资源 | 拒绝 | 不能用 |
| `data:` URL | 处处可用 | **选它** |
| 内联 `<script>` | 可用 | **选它** |
所以自包含包 = 一份 HTML,里面依次是:spine-player(内联)→ 属性下发 → 模块注册表 → 模拟器模块 → 驱动脚本 → 壁纸运行时。所有 ES module 被合成**经典脚本**,用一张 `__weModules` 注册表 + `__require(id)` 还原 `import` 语义;所有资源(图片/骨架 JSON/atlas/贴图页/音频)转成 `data:` URL,装在一张以 URL 为键的表里。
`import` 改写是**保声明**的:`export const X = 1` 会变成 `const X = 1` 加末尾一行 `exports.X = X`。早期版本直接改成 `exports.X = 1`,把模块内的局部绑定删掉了——`REFERENCE_ASPECT is not defined`,而且只在第一帧渲染时才炸(默认参数到那时才求值),构建、加载、尺寸检查全部正常。
### 3. 音频内联是可选项(`--no-embed-audio`)
`xilian-src.flac` 是 37.7 MB,base64 后 50.3 MB。默认内联(这样双击就有声音),但提供开关给"只看画面、不在乎体积"的场合。关闭时构建会明确列出哪些音频没内联、以及后果("双击打开时选不到它们"),而不是安静地少一块功能。
### 4. 两个根必须分开算(**本次最大的坑**)
页面的位置和模块的位置**不是同一个基准**:
- **页面根**:自包含页永远在 `<分发根>/sim/index.html`,资源相对**分发根**寻址,所以相对页面是 `"../"`——**与合集深度无关**。
- **模块根**:`preset.js` 在 `<分发根>/<壁纸id>/preset.js`(合集)或 `<分发根>/preset.js`(单档),发布版用 `new URL("./", import.meta.url)` 相对**自己所在目录**推导,所以相对页面是 `"../<壁纸id>/"`。
这两个值曾经都被写成"页面根 × 分发深度",于是:
- `collection-all`(深度 2)的资源根变成 `"../../"`,跑到 `dist/releases/` 去了;
- 合集分发的 `preset.js` 把 `./audios/kv37/x.mp3` 解析到 `<releases>/audios/...`(少一层),音频退回 `file://` 读,报 `ERR_FILE_NOT_FOUND`。
两个错误的现象都长成"表和模块看起来都对,只有某个资源不对",非常容易误判成那个资源自己的内联逻辑有问题。**单档分发两种错误都不会出现**(深度 0、没有 `<壁纸id>/` 这一段),所以只测单档会一路绿灯。
对应地,资源表的建键基准是**分发根**(`assetRelIn`,合集里带 `<壁纸id>/`),而不是 `preset.js` 的相对路径(`assetUrlIn`)。用后者建键会丢掉 `<壁纸id>/` 这一段。
### 5. 表要同时按"相对键"和"绝对键"注册
运行时拿到的 URL 有两种形态:`preset.js` 求值出来的**绝对** URL(`file:///…/images/kv37.webp`),和 spine-player 内部按 `pathPrefix + 相对路径` 拼出来的**相对**串。两者都要能查中,所以每份资源都注册 `key`、`./key`、`/key`、裸名,以及每个键 `new URL(k, base).href` 的绝对形式。
spine-player 的 `rawDataURIs` 尤其要注意:`loadTexture(path)` 用 `path` 查表、但把回调注册在**原来的** `path` 上,而 `loadTextureAtlas` 传下来的 `path` 是 `atlasUrl 的父目录 + 页名`。因此 `config.rawDataURIs` 要合并 `byRelative` 与 `byAbsolute` **两族**键,且 `jsonUrl`/`atlasUrl`/`binaryUrl` 三个值也要各自以绝对键登记。少了任何一族,spine 就安静地退回 XHR,在 `file://` 下被 CORS 拦掉。
这些键一旦对不上,症状是白屏加一条 `net::ERR_FILE_NOT_FOUND`,**看不出是哪个键**。所以 `WrappedSpinePlayer` 里有一道**生产断言**:三个键任何一个不在合并后的表里就立刻抛错,并打印缺的那个键名。先前的三轮错误猜测都是因为探针只打印了"表有多少个键",而不是"运行时到底查了哪个键"。
### 6. 门禁:`check:dist` 检查自包含页的外部依赖
`check:dist` 对每个 `sim/index.html` 做三项断言:没有 `<script type="module">`、标签上没有 `http(s)://`/`file:///`/根绝对路径。
扫描前必须**剥掉内联脚本的内容**——spine-player 是整份内联的,它内部带着一段编辑器示例模板,里面有 `<script src="https://…">` 这样的字符串,直接对整页扫标签会把它们当依赖,四个分发全报假阳性。
剥除时**只删标签之间的内容,保留开标签本身**。第一版整体替换掉了开标签,于是所有 `<script src=…>` 都不再被检查,门禁看着在跑、实际只能查到 `<link>`。是"往页面里注入一个外链看它拦不拦得住"这个测试把这个漏洞暴露出来的——**一个从不失败的检查等于没有检查**,所以 `tools/checks/test-sim-gate.mts` 会注入四种外部依赖(外链 script、`file:///`、根绝对路径、`type=module`)确认每种都被拦下。
## 未采纳的方案
- **用 Service Worker 拦截 `file://` 请求**:`file://` 下不能注册 Service Worker。
- **让用户起一个 `file://` 代理或用 `--allow-file-access-from-files`**:那就又回到"需要额外操作"了,自包含的意义没了。
- **把资源复制进 `sim/` 而不内联**:跨目录 `file://` 资源读不到,同目录的也要靠相对路径,且会把分发体积翻倍。
- **只支持单档壁纸的自包含包**:合集分发的调试需求同样真实(要试预设切换、共享音频),而且正是它暴露了第 4 条那两个基准错误。
- **在页面上做环境自检来决定走哪条路**:与 ADR 0006 第 1 条同因——判据会恒为假。自包含与否由**构建产物形态**决定,不由运行时探测决定。
## 影响
- `pnpm build sim` 产出自包含包;`pnpm build sim <壁纸id>` / `sim collect` 收窄范围。
- 构建时间从 0.8 s 增至约 5 s(base64 编码 + 语法自检);单页 15 MB(kv37)到 129 MB(内联 flac 的合集)。
- 验收靠 `tools/checks/verify-sim-page.mts`:用 CDP 以 `file://` 打开,断言画布有实际像素、0 异常、0 控制台报错、无 `http(s)` 请求。**像素采样必须在 `requestAnimationFrame` 内跨帧取最大值**——`preserveDrawingBuffer: false` 下在 rAF 外读 `gl.readPixels` 永远读到清空后的缓冲,会得到"0% 非透明"的假阴性。
@@ -0,0 +1,58 @@
# 资源布局:一具骨架一组,而不是按文件类型分组
> **取代** ADR 0001 第 7 行「保留 `effects/`/`images/`/`audios/` 三层同名子目录」与 ADR 0005 第 43 行
> 「这一条不变」。那两条的前提是"Spine 骨架 JSON 硬编码了 `"images": "../images/"`,改了就得动骨架文件";
> 本决策把贴图页并到 atlas 同目录、把骨架里的 `images` 归零之后,这个前提本身消失了。
## 背景
`wallpapers/<游戏id>/<壁纸id>/` 原来是按**文件类型**分的:骨架数据在 `effects/<名>.json`,
atlas 与贴图页在 `images/`(或 `images/<名>/`),场景图在 `images/scene/`。于是**一具骨架自己的
东西被拆到两个目录**,找一样东西要跳两次;`effects/` 这个名字还有歧义(它装的是骨架数据,
不是"特效",而真正的场景图在别处)。
查过官方文档:**Spine 对 HTML 项目的目录结构没有任何约定**。[导出文档](https://en.esotericsoftware.com/spine-export)
只规定"每个骨架一个数据文件、文件名用骨架名",外加导出时可选 pack 出 atlas。所以这不是"遵循规范"的问题,
而是我们自己的取舍。
真正约束布局的只有一条**运行时的解析规则**(读 vendored spine-ts 源码得到):
```js
page.setTexture(assetManager.get(pathPrefix + page.name)); // AssetManager.loadTextureAtlas
```
即 atlas 第一行是页名、**页相对 atlas 所在目录解析**;`skeleton.images` 只是可选前缀。
所以「页必须与 atlas 同目录」是硬约束,其余随便放。
来源页面(米哈游活动页)自己也是按类型分的(`images/effect/spine/*.json|.atlas`、
`images/effect/scene/*`、`audio/*`),它能这么干是因为它的引擎有**全局图片表**、按逻辑名查页;
我们用的是 spine-ts,做不到。
## 决策
**按"一件东西一组"分,而不是按后缀分**:
```
wallpapers/<游戏id>/<壁纸id>/
├── spines/<骨架名>/<名>.json | <名>.atlas | 贴图页… ← 一具骨架一组
├── scene/ ← 场景图与背景图(非骨架的图)
├── audios/ ← 音源
├── meta.json preset.template.json preview.gif
```
- 页跟着 atlas 走,天然满足运行时规则(不再需要 `skeleton.images` 前缀)。
- 去掉了有歧义的 `effects/`:骨架就是骨架。
- 一档壁纸目前就是**一个场景**(`sceneConfig` 就是那一个),所以不再多套一层 `scenes/<场景id>/`;
真出现"一档多场景"时再加,而不是现在提前分层。
- **例外**:`backgroundImage` 可以指向某个骨架的贴图页(kv37 就是这样),那种情况就写
`spines/<名>/<页>`,不强行搬进 `scene/`——**判据是 atlas 自己声明的页名**,不做名字启发式。
## 后果
- 构建侧要同步的一处硬编码:`tools/lib/generate.ts` 的 `copyWallpaperAssets` 只搬
`spines` / `scene` / `audios` 三个目录名;改名必须改它。
- `tools/downloader/promote.py` 产出的新壁纸直接是这套布局(否则存量与增量的形状会分叉)。
- 存量三档壁纸迁移了 86 个文件,预设路径按实际落点重写;
`verify-visual-equivalence` 确认三个比例下**逐像素相同**(布局不影响渲染)。
- 迁移脚本留在 `.scratch/build-pipeline/`(含补救脚本),其中固化一条教训:
跨平台路径映射**别拿 `str(Path)` 当键**(Windows 会给反斜杠,而预设里是正斜杠)。
+47
View File
@@ -0,0 +1,47 @@
# 死链检查报告(README.md)
- 文件:README.md
- 外部链接:10 个(失效 0 个;1 个首次超时,复检 200)
- 内部相对链接:21 个(缺失 0 个)
- 时间:2026-09-21 22:54:06
## 外部链接
| 状态 | 链接 | 备注 |
| --- | --- | --- |
| 200 | https://img.shields.io/badge/node-%E2%89%A524-339933?logo=nodedotjs&logoColor=white | |
| 200 | https://img.shields.io/badge/pnpm-11-F69220?logo=pnpm&logoColor=white | |
| 200 | https://img.shields.io/badge/TypeScript-strict-3178C6?logo=typescript&logoColor=white | 复检通过(首次 5s 超时,按同域限流重试) |
| 200 | https://img.shields.io/badge/Wallpaper%20Engine-Web%20%E7%B1%BB%E5%9E%8B-1F1F1F | |
| 200 | https://img.shields.io/badge/version-3-7C4DFF | |
| 200 | https://img.shields.io/badge/gates-pnpm%20check-2EA043 | |
| 200 | https://img.shields.io/badge/license-%E6%9C%AA%E5%A3%B0%E6%98%8E-lightgrey | |
| 200 | https://esotericsoftware.com/spine-runtimes-license | |
| 200 | https://esotericsoftware.com/ | |
| 200 | https://www.wallpaperengine.io/ | |
## 内部相对链接
| 存在 | 路径 |
| --- | --- |
| ✓ | wallpapers/preview.gif |
| ✓ | CONTEXT.md |
| ✓ | wallpapers/README.md |
| ✓ | src/runtime/viewport-fitter.ts |
| ✓ | docs/adr/0003-spine-follows-background-cover.md |
| ✓ | tools/checks/README.md |
| ✓ | tools/downloader/README.md |
| ✓ | docs/adr/0004-lossless-audio.md |
| ✓ | docs/adr/0001-resource-layout.md |
| ✓ | docs/adr/0002-preset-as-es-module.md |
| ✓ | docs/adr/0003-spine-follows-background-cover.md |
| ✓ | docs/adr/0004-lossless-audio.md |
| ✓ | docs/adr/0005-build-pipeline-and-dist-topology.md |
| ✓ | docs/adr/0006-simulator-contract.md |
| ✓ | docs/adr/0007-self-contained-sim-build.md |
| ✓ | docs/adr/0008-asset-layout-per-skeleton.md |
| ✓ | docs/agents/issue-tracker.md |
| ✓ | CONTEXT.md |
| ✓ | AGENTS.md |
| ✓ | wallpapers/README.md |
| ✓ | docs/adr/0006-simulator-contract.md |
+29
View File
@@ -0,0 +1,29 @@
{
"name": "spine-wallpaper",
"version": "3.0.0",
"private": true,
"type": "module",
"description": "Wallpaper Engine 网页壁纸作品:多档壁纸、合集分发、本地调试服",
"scripts": {
"dev": "node tools/dev.ts",
"build": "pnpm build:runtime && pnpm build:sim && node tools/build.ts",
"build:pages": "pnpm build:runtime && pnpm build:sim && node tools/build.ts --with-sim",
"build:runtime": "tsc -p tsconfig.runtime.json",
"build:sim": "tsc -p tsconfig.simulator.json",
"build:all": "pnpm build",
"typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.checks.json",
"check:dist": "node tools/check-dist.ts",
"check:paths": "node tools/check-paths.ts",
"check:syntax": "node tools/check-syntax.ts",
"check": "pnpm typecheck && pnpm build && pnpm check:syntax && pnpm check:paths && pnpm check:dist && node tools/checks/check-collection-copy.mts",
"capture": "node tools/capture.mjs",
"serve": "node tools/serve.mjs"
},
"devDependencies": {
"@types/node": "^26.6.1",
"typescript": "^5.9.3"
},
"engines": {
"node": ">=24"
}
}
+39
View File
@@ -0,0 +1,39 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
devDependencies:
'@types/node':
specifier: ^26.6.1
version: 26.6.1
typescript:
specifier: ^5.9.3
version: 5.9.3
packages:
'@types/[email protected]':
resolution: {integrity: sha512-VqGJBMCtdhqkBUCcBLvywI0NJ+KLuVzgNnlBUNFOQjqVxzo2lxLUNg1DSey8+u2u6ktswSAxg+s68QLzWHNOuA==}
[email protected]:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==}
snapshots:
'@types/[email protected]':
dependencies:
undici-types: 8.9.0
[email protected]: {}
[email protected]: {}
+334
View File
@@ -0,0 +1,334 @@
// 运行时引用的外部全局对象与共享形状。
//
// 两个来源各有一份权威约束,这里只是把它们写成类型:
// 1. `spine` 由 src/vendor/spine-player.js 以 `window.spine` 暴露(旧式 <script>,无 ESM 导出)。
// 只声明本项目真正用到的成员——多声明一个字段就等于凭空承诺一个不存在的 API。
// 2. `window.wallpaperPropertyListener` 是 Wallpaper Engine 注入的**唯一**官方 JS API。
// 注意:`window.wallpaperRegisterListener` **不存在**,不要往这里加(见 docs/adr/0006)。
// 模拟器只有在调试服注入它时才会出现,因此全部成员都是可选的。
//
// 文件名是 .d.ts 而不是 .ts:这里没有任何运行时代码。若写成 .ts,tsc 会为它产出一个空的
// globals.js(因为 `declare global` 也算模块成员),dist 里多一个空模块纯属噪音。
/** 世界坐标矩形,y 轴向上。与 viewport-fitter 的纯函数共用同一形状。 */
export interface Rect {
x: number;
y: number;
width: number;
height: number;
}
/** 播放器 config.viewport:作者声明的基矩形 + 四边内边距(pad 可为负,表示裁切)。 */
export interface ViewportConfig extends Rect {
padLeft: number;
padRight: number;
padTop: number;
padBottom: number;
}
/** 预设里 viewport 的两种写法:像素矩形,或百分比内边距。 */
export interface ViewportSpec {
x?: number;
y?: number;
width?: number;
height?: number;
padLeft?: number | string;
padRight?: number | string;
padTop?: number | string;
padBottom?: number | string;
}
export interface SpinePlayerCanvas extends HTMLCanvasElement {
clientWidth: number;
clientHeight: number;
}
export interface SpinePlayer {
canvas: SpinePlayerCanvas;
config: { viewport?: ViewportSpec } & Record<string, unknown>;
/** 播放器解析后的当前视口;骨架加载完成前不存在。 */
currentViewport?: ViewportConfig;
animationState?: {
getCurrent(track: number): { animation?: { name: string }; trackTime: number } | undefined;
};
skeleton?: {
data: { animations: { name: string }[]; skins: { name: string }[] };
updateWorldTransform(physics: number): void;
};
viewportTransitionStart: number;
paused: boolean;
speed: number;
time?: { frames: number };
disposed?: boolean;
error?: unknown;
stopRequestAnimationFrame?: boolean;
/** 实例属性:循环用箭头闭包懒查,因此在实例上换掉它即可接管排帧(fps 限流靠这个)。 */
drawFrame(requestNextFrame?: boolean): void;
setViewport(animation: string): void;
dispose(): void;
}
// ---- 场景路径用到的 spine-ts 导出(都核实过在 vendored 包的 UMD 导出表里)----
//
// 只声明真正用到的成员:多声明一个字段就等于凭空承诺一个不存在的 API。
export interface SpineTexture {
/** spine-core 的 Texture 基类只暴露 getImage()(GLTexture 没有 getTexture)。 */
getImage(): { width: number; height: number; naturalWidth?: number; naturalHeight?: number };
}
export interface SpineAtlas {
pages: { name: string }[];
}
export interface SpineSkeletonData {
animations: { name: string; duration: number }[];
}
export interface SpineSkeleton {
x: number;
y: number;
scaleX: number;
scaleY: number;
data: SpineSkeletonData;
setSkinByName(name: string): void;
setToSetupPose(): void;
updateWorldTransform(physics: unknown): void;
getBounds(offset: { x: number; y: number }, size: { x: number; y: number }, temp: number[], clipping: unknown): void;
}
export interface SpineAnimationState {
/** 播放速度(页面节点上的 `spine.timeScale`)。 */
timeScale: number;
update(delta: number): void;
apply(skeleton: SpineSkeleton): void;
setAnimation(track: number, name: string, loop: boolean): unknown;
}
export interface SpineAnimationStateData {
defaultMix: number;
}
export interface SpineSceneRenderer {
camera: { zoom: number; position: { x: number; y: number } };
resize(mode: unknown): void;
begin(): void;
end(): void;
drawSkeleton(skeleton: SpineSkeleton, premultipliedAlpha?: boolean): void;
drawTexture(texture: SpineTexture, x: number, y: number, width: number, height: number, color?: unknown): void;
dispose(): void;
}
export interface SpineAssetManager {
loadJson(path: string): void;
loadTexture(path: string): void;
loadTextureAtlas(path: string): void;
loadAll(): Promise<unknown>;
get(path: string): unknown;
}
/** 场景预设里的一件(骨架 / 贴图平面 / 纯色平面)。由构建从抓取期的 scene.json 烘焙而来。 */
export interface SpineScenePart {
kind: "spine" | "image" | "solid";
id: string;
order: number;
renderOrder?: number;
position: number[];
scale: number[];
/** kind = "image":贴图路径。 */
image?: string;
/** kind = "spine":骨架与 atlas 路径,以及要播的动画(缺省 = 第一个动画)。 */
jsonUrl?: string;
atlasUrl?: string;
animation?: string;
/** 四边形尺寸(世界单位)。抓取期从 geometry.config 或 glTF 顶点包围盒算来。 */
width?: number;
height?: number;
/** 网格中心相对节点原点的偏移(页面坐标系)。 */
center?: number[];
/** 节点旋转(弧度)。非零 = 倾斜的 3D 面片(billboard 画不对,当前跳过)。 */
rotation?: number[];
/** 页面在这个节点上指定的动画 / 皮肤 / 速度(`spine.defaultAnimation` 等)。 */
skin?: string;
timeScale?: number;
/** 纯色平面的颜色(0xRRGGBB)。 */
color?: number;
}
export interface SpineSceneConfig {
/** 页面场景的 UI 尺寸(仅作退化取景的兜底;正常取景按 part 并集包围盒)。 */
ui?: number[];
/**
* 页面相机。`type: 1` 透视(按 fov 做 billboard 投影,z 是真实景深)、`type: 2` 正交(直接用原坐标)。
* 缺省按正交处理(与 hsr 两页一致)。
*/
camera?: { type?: number; fov?: number; position?: number[] };
parts: SpineScenePart[];
/**
* 时间线的**整场位移**(页面单位)。入场动画把整场平移,静态场景树里没有这一项;
* 这个值是从真页面**量出来的**(等入场跑完读每个对象的世界坐标,与静态坐标求差),
* 见 .scratch/scene-player/perspective.md 第 8 轮。缺省 = 不平移。
*/
timelineOffset?: number[];
/**
* 页面坐标系是 y 向上(three.js),spine 的渲染是 y 向下,所以默认把 y 取反。
* 实测上下颠倒时设 false。抓取期的 scene.json 原样保留页面坐标,方向只在这一处处理。
*/
flipY?: boolean;
}
export interface SpinePlayerConfig {
alpha?: boolean;
premultipliedAlpha?: boolean;
showControls?: boolean;
showLoading?: boolean;
jsonUrl?: string;
atlasUrl?: string;
animation?: string;
viewport?: ViewportSpec;
/** 内部钩子:每帧在渲染器 resize 之后、算相机之前调用。不允许被预设数据覆盖。 */
frame?: (player: SpinePlayer) => void;
[key: string]: unknown;
}
/** WE 下发的通用属性(fps 等)。 */
export interface GeneralProperties {
fps?: number;
[key: string]: unknown;
}
/** WE 下发的用户属性:每个键都包一层 { value }。 */
export interface UserProperties {
[key: string]: { value: unknown } | undefined;
}
/** WE 注入的唯一官方 API。全部成员可选:WE 只会调用壁纸实现了的那几个。 */
export interface WallpaperPropertyListener {
applyUserProperties?(properties: UserProperties): void;
applyGeneralProperties?(properties: GeneralProperties): void;
applyAudioProperties?(audio: number[]): void;
setPaused?(paused: boolean): void;
userDirectoryFilesAddedOrChanged?(propertyName: string, changedFiles: string[]): void;
userDirectoryFilesRemoved?(propertyName: string, removedFiles: string[]): void;
}
/** project.json 里一项属性在调试面板上的展示元数据。 */
export interface SimProperty {
key?: string;
type: string;
text?: string;
value?: unknown;
options?: { label: string; value: string }[];
min?: number;
max?: number;
step?: number;
/** 面板里的排序键(WE 按它排,而不是按属性表里的书写顺序)。 */
order?: number;
/** 同一 order 内的次序。 */
index?: number;
/** 显示条件,形如 `show_author_info.value == true`。不满足时整行隐藏。 */
condition?: string;
/** slider 显示几位小数。 */
precision?: number;
/** slider 是否按比例显示(WE 的属性字段,面板只读不写)。 */
fraction?: boolean;
}
/** 模拟器启动参数(由调试服的驱动脚本组装)。 */
export interface SimulatorOptions {
/** 该分发的展示名(面板标题)。 */
releaseName: string;
/**
* project.json 的 preview 字段(分发根下的文件名)。
* 缺省 = 该分发没有预览图,面板不显示预览区(与发布产物的行为一致)。
*/
preview?: string;
/** project.json 里 general.properties 的原始定义(键序即 WE 面板里的 index 序)。 */
properties: Record<string, SimProperty>;
/** 初始用户属性值(URL 的 ?__props 覆盖 project.json 默认值)。 */
initialProps: Record<string, unknown>;
/** 初始 fps(写进 applyGeneralProperties)。 */
initialFps?: number;
/** 初始暂停态(写进 setPaused)。 */
initialPaused?: boolean;
/** 初始属性在 window.load 之后推(默认),还是 DOMContentLoaded 时推(=dom)。 */
propsAt?: "load" | "dom";
}
/** 调试期全局状态(window.__weSim):模拟器自己持有的状态,便于断言与面板回读。 */
export interface SimGlobal {
__isSimulated: true;
substituted: string[];
release: { dir: string; name: string; version: string };
/** 当前用户属性值(面板与壁纸看到的是同一份)。 */
props: Record<string, unknown>;
/** 给壁纸下发用户属性(模拟 WE 的 applyUserProperties)。 */
setProperties(values: Record<string, unknown>): void;
/** 给壁纸下发通用属性(模拟 WE 的 applyGeneralProperties)。 */
setGeneral(values: { fps?: number }): void;
/** 模拟 WE 的 setPaused。 */
setPaused(paused: boolean): void;
/** 已发生的下发记录,便于断言时序。 */
deliveries: { type: string; at: number; payload: unknown }[];
}
declare global {
// eslint-disable-next-line no-var
const spine: {
SpinePlayer: new (containerId: string | HTMLElement, config: SpinePlayerConfig) => SpinePlayer;
// 场景路径自持渲染循环所需的最小导出面。
ManagedWebGLRenderingContext: new (canvas: HTMLCanvasElement, config?: Record<string, unknown>) => unknown;
SceneRenderer: new (canvas: HTMLCanvasElement, context: unknown, twoColorTint?: boolean) => SpineSceneRenderer;
AssetManager: new (context: unknown, pathPrefix?: string) => SpineAssetManager;
AtlasAttachmentLoader: new (atlas: SpineAtlas) => unknown;
SkeletonJson: new (loader: unknown) => { readSkeletonData(json: unknown): SpineSkeletonData };
Skeleton: new (data: SpineSkeletonData) => SpineSkeleton;
AnimationState: new (data: SpineAnimationStateData) => SpineAnimationState;
AnimationStateData: new (data: SpineSkeletonData) => SpineAnimationStateData;
Color: new (r?: number, g?: number, b?: number, a?: number) => unknown;
ResizeMode: { Expand: unknown; Fit: unknown; Stretch: unknown };
Physics: { update: unknown };
Vector2: new (x?: number, y?: number) => { x: number; y: number };
};
interface Window {
wallpaperPropertyListener?: WallpaperPropertyListener;
/**
* 调试服把模拟器当**经典脚本**注入时,由 src/simulator/wallpaper-engine.ts 挂上。
* 真实 WE 里它不存在——它的缺失本身就是"当前不在 WE 里"的可靠信号之一。
*/
mountWallpaperEngineSimulator?: (options: SimulatorOptions) => void;
__weSim?: SimGlobal;
/**
* 自包含包(`pnpm build --sim`)注入的**替身钩子**:把发布产物里的 URL 换成页内可用的 URL
* (内联包里是 data: URL)。发布产物里它不存在,所以运行时的调用是"有则用、无则原样"。
*/
__simSwap?: (url: string) => string;
/**
* 自包含包注入的**资源根**:相对页面 URL 的前缀,指向分发根(自包含页在 `<分发根>/sim/` 下)。
*
* 发布产物里不存在。运行时用它把资源 URL 解析成绝对 URL 再交给 `__simSwap`——
* 必须与打包器建表用的基准**逐字一致**,否则表里明明有、却永远查不中。
*/
__simAssetRoot?: string;
/**
* 场景路径的就绪与失败快照,供验收脚本断言。
* 发布产物里它也会被写上——它不是模拟器替身,而是这一层唯一的可观测面:
* 场景加载失败必须能被外部看见(静默降级过一次就够)。
*/
__sceneDebug?: {
parts: number;
loaded: number;
errors: string[];
framing: Rect | null;
/** 所有 part 投影后的并集包围盒(投影单位)——与 framing 一起用来判"取景 vs 内容"谁不对。 */
content: Rect | null;
spines: string[];
images: string[];
/** 纯色平面(`USE_TEXTURE == 0`):本轮**不画**,但要能被看见。 */
solids: number;
/** 带旋转的倾斜面片:billboard 画不对,**不画**,同样要能被看见。 */
rotatedSkipped: number;
};
}
}
+85
View File
@@ -0,0 +1,85 @@
{
"$comment": "project.json 的骨架。这里写的字段会进**每一个**分发目录;逐分发的差异(title / description / preview / preset / bgm / workshopid)由 build 从 wallpapers 下的 meta.json 合成。只放运行时与 Workshop 真正需要的键,不写没用的空字段。",
"contentrating": "Everyone",
"file": "index.html",
"ratingsex": "none",
"ratingviolence": "none",
"tags": ["Anime"],
"type": "Web",
"visibility": "public",
"$comment_properties": "general.properties 是 WE 的属性表。preset(壁纸切换)与 bgm(音源选择)由 build 按分发内容生成,因此不在这里写。文本里提到的 combo 列表必须与 wallpapers/ 下的实装壁纸一致,build 会校验。",
"$comment_numbering": "属性的 index / order **不在这里写**:build 按这张表的书写顺序自动编号(index = 0,1,2…,order = 100+index),删掉属性后会自动补上不空号。以前手写这两串数字,加一个属性就得把后面全部重编号,还会在产物 diff 里刷出一堆纯噪音。唯一例外是 schemecolor —— 它是 WE 的内置属性,线上就是 order 0 且没有 index,用 $order 显式钉住。",
"general": {
"properties": {
"schemecolor": {
"$order": 0,
"text": "ui_browse_properties_scheme_color",
"type": "color",
"value": "0.38823529411764707 0.14901960784313725 0.6196078431372549"
},
"show_author_info": {
"text": "📇显示作者信息",
"type": "bool",
"value": true
},
"author_info": {
"condition": "show_author_info.value == true",
"text": "<small>作者信息:<ul><li>作者:品毅</li><li>QQ:2463253700</li><li>哔哩哔哩:<a href=\"https://space.bilibili.com/72266376\">品毅的个人空间</a></li></ul></small><br />",
"type": "text"
},
"preset": {
"text": "⚙️壁纸预设切换<br />",
"type": "combo",
"value": "xilian",
"options": []
},
"preset_note": {
"text": "<small>切换壁纸预设(包括背景、音乐、动画):<ul><li>当其它选项存在更改,不会覆盖其它选项。</li></ul></small><br />",
"type": "text"
},
"audio_volume": {
"fraction": true,
"max": 1,
"min": 0,
"precision": 1,
"step": 0.1,
"text": "🔊音频音量调整",
"type": "slider",
"value": 1
},
"audio_volume_note": {
"text": "<small>调整背景音乐音量:<ul><li>为零时暂停。</li><li>为一时最大。</li></ul></small><br />",
"type": "text"
},
"use_custom_audio": {
"text": "🎵使用自定义音乐",
"type": "bool",
"value": false
},
"audio_file": {
"condition": "use_custom_audio.value == true",
"text": "🎵音频文件路径",
"type": "textinput",
"value": ""
},
"audio_file_note": {
"condition": "use_custom_audio.value == true",
"text": "<small>更换背景音乐来源:<ul><li>🚨注意:目前仅支持 URL 链接。</li><li>当音源不可用时会切换至上一音源。</li></ul></small><br />",
"type": "text"
},
"bgm": {
"condition": "use_custom_audio.value == false",
"text": "🎵背景音乐选择",
"type": "combo",
"value": "auto",
"options": []
},
"bgm_note": {
"condition": "use_custom_audio.value == false",
"text": "<small>选择各预设自带的背景音乐:<ul><li>默认「随预设」跟随壁纸切换。</li><li>所选项不属于当前壁纸时,自动回落该壁纸的默认音乐,选择仍会保留。</li></ul></small><br />",
"type": "text"
}
}
}
}
+70
View File
@@ -0,0 +1,70 @@
// 背景音乐。
//
// 两条交互约定:
// 1. 音源与音量是「用户级覆盖」:切换壁纸不得把用户自定义的音源/音量清回预设默认值。
// 2. 音源相同的设置是 no-op:否则两档壁纸共用同一首曲子时,一切换就把音乐从头开始放。
export default class AudioController {
private readonly audio: HTMLAudioElement;
private isPlaying = false;
private retryTimer: ReturnType<typeof setTimeout> | undefined = undefined;
constructor(audioId: string, options: { source?: string; volume?: number } = {}) {
const element = document.getElementById(audioId);
if (!(element instanceof HTMLAudioElement)) {
throw new Error(`找不到 <audio id="${audioId}">`);
}
this.audio = element;
this.audio.loop = true;
if (options.source) this.setSource(options.source);
if (options.volume !== undefined) this.setVolume(options.volume);
}
/** audio.src 会被解析成绝对 URL;预设里的 source 也是绝对 URL(preset.js 用 import.meta.url 推导)。 */
get source(): string {
return this.audio.src || "";
}
setSource(source: string | undefined): void {
if (!source) return;
if (this.source === new URL(source, document.baseURI).href) return;
const wasPlaying = this.isPlaying;
this.audio.src = source;
this.audio.load();
if (wasPlaying) this.play();
}
setVolume(volume: unknown): void {
const value = Math.max(0, Math.min(1, Number(volume)));
this.audio.volume = Number.isFinite(value) ? value : 1;
if (this.audio.volume <= 0) this.pause();
else if (!this.isPlaying) this.play();
}
play(): void {
if (this.isPlaying) return;
this.audio.play().then(
() => {
this.isPlaying = true;
},
() => {
this.isPlaying = false;
// 只重试一次:自动播放被拒时重试多少次都没用,重试只是覆盖"音频尚未就绪"
if (this.retryTimer === undefined) {
this.retryTimer = setTimeout(() => {
this.retryTimer = undefined;
this.play();
}, 500);
}
},
);
}
pause(): void {
this.audio.pause();
this.isPlaying = false;
}
}
+11
View File
@@ -0,0 +1,11 @@
// 背景图:只在真的换了图时写一次样式。
export default class BackgroundController {
private appliedImage = "";
setImage(backgroundImage: string | undefined): void {
if (!backgroundImage || backgroundImage === this.appliedImage) return;
this.appliedImage = backgroundImage;
// 用引号包住,避免路径里出现空格等字符时被 CSS 解析成多个值
document.body.style.backgroundImage = `url("${backgroundImage}")`;
}
}
+206
View File
@@ -0,0 +1,206 @@
import Presets, { defaultPresetId } from "./presets.js";
import PresetController from "./preset-controller.js";
import BackgroundController from "./background-controller.js";
import SpineController from "./spine-controller.js";
import SceneController from "./scene-controller.js";
import AudioController from "./audio-controller.js";
import type { Preset } from "./preset-controller.js";
import type { GeneralProperties, UserProperties } from "../globals.js";
// 音量缺省值必须与 project.json 里 audio_volume 的 value 保持一致。
// 预设 id 不再写死:单档分发里表里只有一档壁纸,写死会让它查不到而整页白屏。
// defaultPresetId 由 build 生成在 presets.js 里(= 本分发的第一档壁纸)。
const DEFAULT_VOLUME = 0.5;
const presets = new PresetController(Presets);
const backgroundController = new BackgroundController();
// 两条渲染路径互斥:单骨架(spineConfig)与场景(sceneConfig)。谁在用由 applyPreset 决定,
// 切换时**先 dispose 另一条**,否则两边的画布会叠在同一个容器里。
const spineController = new SpineController("spine-container");
const sceneController = new SceneController("spine-container");
const audioController = new AudioController("background-music");
/** 当前生效的渲染路径;空字符串 = 还没应用过任何预设。 */
let activeRenderer: "spine" | "scene" | "" = "";
interface State {
ready: boolean;
presetId: string;
/** 空字符串 = 用当前预设的默认音源。 */
audioSource: string;
audioVolume: number;
/** 空字符串 = 随预设;否则是某个 preset.js 里 audioChoices 的 id。 */
bgm: string;
/**
* 「使用自定义音乐」开关。它让 audio_file 与 bgm 成为**互斥**的两条路:
* 开着只认用户填的 URL,关着只认 bgm 选择。面板上也据此只显示其中一个。
*/
useCustomAudio: boolean;
}
// 唯一的真相来源。WE 可能在 window.load 之前就下发用户属性,所以这里只记录状态;
// 渲染时再决定"能不能立刻应用"——只有播放器需要等到 load 之后才存在。
const state: State = {
ready: false,
presetId: "",
audioSource: "",
audioVolume: DEFAULT_VOLUME,
bgm: "",
useCustomAudio: false,
};
let appliedPresetId = "";
// 自包含包(`pnpm build --sim`)没有同目录资源可读,靠页面上的 `window.__simSwap` 把发布产物里的
// 相对 URL 换成页内 URL(内联包里是 data: URL)。发布产物里这个钩子**不存在**,于是这里恒等返回。
//
// 这是运行时唯一感知自包含包的地方,而且它只改 URL 解析、不参与任何行为分支——
// 换掉一个 URL 不会让预览走另一条代码路径,也就不会掩盖真实 WE 里的问题。
//
// 基准必须用 `__simAssetRoot`,不能用 `document.baseURI`:自包含页在 `<分发根>/sim/index.html`,
// 而资源相对**分发根**寻址。用页面 URL 当基准会把 `./spines/x/x.json` 解析到 `sim/spines/x/x.json`,
// 与打包器建表时用的基准差一层目录——结果是"表里明明有这张图,却永远查不中、图片仍然去 file:// 读"。
// 这个 bug 我靠猜查了三轮;两处基准必须来自同一个数,所以自包含包**同时**注入两者。
function resolveAssetUrl(url: string | undefined): string | undefined {
if (!url) return url;
const swap = window.__simSwap;
if (typeof swap !== "function") return url;
const base = new URL(window.__simAssetRoot ?? "./", document.baseURI);
let absolute = url;
try {
absolute = new URL(url, base).href;
} catch {
// 相对路径畸形时保持原样:宁可让浏览器按老样子报错,也不要在这里抛异常。
}
const swapped = swap(absolute);
// 只有确实换成了页内资源才采用。用户可以在 audio_file 里填任意 URL,
// 万一它和某个内联键撞上,也绝不能把用户的地址改掉。
return typeof swapped === "string" && swapped.startsWith("data:") ? swapped : url;
}
// 量出背景图的真实宽高比:立绘层要靠它才能和背景同步缩放。
// 直接读图片,避免在预设里再手写一个容易写错的数字。
function measureImageAspect(url: string | undefined): Promise<number | undefined> {
return new Promise((resolve) => {
if (!url) {
resolve(undefined);
return;
}
const probe = new Image();
probe.onload = () => resolve(probe.naturalWidth / probe.naturalHeight);
probe.onerror = () => resolve(undefined);
probe.src = resolveAssetUrl(url) ?? url;
});
}
// bgm combo 选中的音源只在本壁纸声明过时才生效。否则回落本壁纸默认音源——
// 选择本身记着不动,切回那份壁纸时用户的选择还在。
function bgmSource(preset: Preset): string {
if (!state.bgm) return "";
const choices = preset.audioChoices ?? [];
const hit = choices.find((choice) => choice.id === state.bgm);
return hit ? hit.source : "";
}
function render(): void {
const presetId = state.presetId || defaultPresetId;
const preset = presets.get(presetId);
if (!preset) return;
// 只有壁纸真的换了才碰背景与骨架。否则拖动音量滑块都会重建播放器
// (重建 = 重新下载 3.4 MB 骨架与全部贴图)。
if (appliedPresetId !== presetId) {
appliedPresetId = presetId;
backgroundController.setImage(resolveAssetUrl(preset.backgroundImage));
// 播放器还不存在时 setConfig 只是记下来;切换路径时必须先拆掉另一条。
if (preset.sceneConfig) {
if (activeRenderer !== "scene") spineController.dispose();
activeRenderer = "scene";
sceneController.setConfig(preset.sceneConfig);
} else {
if (activeRenderer !== "spine") sceneController.dispose();
activeRenderer = "spine";
spineController.setConfig(preset.spineConfig); // 播放器还不存在时只是记下来
}
// 图片解码是异步的,回来时可能已经切到别的预设了,所以要核对一次
void measureImageAspect(preset.backgroundImage).then((aspect) => {
if (appliedPresetId === presetId) spineController.setImageAspect(aspect);
});
}
// 音源优先级由「使用自定义音乐」开关决定,两条路互斥:
// 开 → 只认用户填的 URL;关 → 只认 bgm 选择。
// 两条都空时统一回落本壁纸默认音源——否则开关一打开就彻底没声音了。
// 取自预设的路径要过 resolveAssetUrl;用户自己填的 URL 不动——那不是"发布产物里的相对路径"。
const picked = state.useCustomAudio ? state.audioSource : bgmSource(preset);
const source = picked || preset.audioOptions.source;
audioController.setSource(resolveAssetUrl(source));
audioController.setVolume(state.audioVolume);
if (state.ready) audioController.play();
}
window.addEventListener("load", () => {
state.ready = true;
render(); // 先把状态落到各控制器(此时播放器尚不存在,只写配置)
// 只用**当前那条**路径创建:另一条的配置可能是上一档壁纸留下的,创建出来会叠一层画布。
if (activeRenderer === "scene") sceneController.create();
else spineController.create();
});
// 必须在模块顶层注册:属性事件可能在 window.load 之前到达
window.wallpaperPropertyListener = {
applyUserProperties: (properties: UserProperties) => {
// 先写状态:无条件记录,与播放器是否存在、是否加载完成无关
const presetProperty = properties.preset;
if (presetProperty) {
const presetId = String(presetProperty.value ?? "");
if (presets.has(presetId)) {
state.presetId = presetId;
} else if (presetId) {
console.warn(`忽略未知的预设 id:${presetId}`);
}
}
const customAudioProperty = properties.use_custom_audio;
if (customAudioProperty) {
state.useCustomAudio = Boolean(customAudioProperty.value);
}
const audioFile = properties.audio_file;
if (audioFile) {
state.audioSource = String(audioFile.value ?? "").trim();
}
const bgmProperty = properties.bgm;
if (bgmProperty) {
const bgm = String(bgmProperty.value ?? "").trim();
state.bgm = bgm === "auto" ? "" : bgm;
}
const volumeProperty = properties.audio_volume;
if (volumeProperty) {
const volume = Number(volumeProperty.value);
if (Number.isFinite(volume)) {
state.audioVolume = Math.max(0, Math.min(1, volume));
}
}
render();
},
applyGeneralProperties: (properties: GeneralProperties) => {
// WE 不会替壁纸限流,fps 得自己落实。这个属性与预设无关,不进 state。
// 判 undefined 而不是真值:0 表示不限流,用真值判断会把"取消限流"整个吞掉。
if (properties.fps !== undefined) {
// 两条路径都收下:未生效的那条只是记着,不产生任何副作用。
spineController.setFps(properties.fps);
sceneController.setFps(properties.fps);
}
},
// WE 在暂停/恢复时会调它(例如用户在桌面上启动了全屏应用)。
// 现在只是记下来:播放器本身没有 pause 语义,交由 fps 门控与 requestAnimationFrame 自然停摆。
setPaused: (_paused: boolean) => {},
};
+45
View File
@@ -0,0 +1,45 @@
import type { SpinePlayerConfig, SpineSceneConfig } from "../globals.js";
// 预设查表:id → 预设数据。
// 预设数据是 ES module 里的模块级常量,返回深拷贝,避免调用方改坏它。
// 注意 id 必须与 project.json 里 combo 选项的 value 一致(见 docs/adr/0001)。
/** 一档壁纸的预设数据(由 build 生成的 preset.js 提供)。 */
export interface Preset {
id: string;
name: string;
/** 所属游戏 id。合集分发里用它做分类,运行时其余地方不读。 */
game?: string;
backgroundImage: string;
/** 单骨架配置。与 `sceneConfig` 二选一(构建期保证)。 */
spineConfig?: SpinePlayerConfig;
/** 场景配置:一档壁纸 = 一整页场景(多骨架 + 贴图平面)。 */
sceneConfig?: SpineSceneConfig;
audioChoices?: { id: string; name: string; source: string }[];
audioOptions: { source: string };
}
export default class PresetController {
private readonly presets: Record<string, Preset>;
currentId = "";
constructor(presets: Record<string, Preset>) {
this.presets = presets;
}
get ids(): string[] {
return Object.keys(this.presets);
}
has(id: string): boolean {
return Boolean(this.presets[id]);
}
/** 未知 id 返回 null,由调用方决定是保持现状还是回退——不在这里悄悄改成别的预设。 */
get(id: string): Preset | null {
const preset = this.presets[id];
if (!preset) return null;
this.currentId = id;
return JSON.parse(JSON.stringify(preset)) as Preset;
}
}
+15
View File
@@ -0,0 +1,15 @@
// `./presets.js` 是 **pnpm build 生成**的文件:仓库里不存在,也不由 tsc 编译。
//
// 这个 `.d.ts` 与它同名,是 tsc 解析 `import … from "./presets.js"` 时的**类型替身**。
// 之所以不放一个真实的 `presets.ts` 占位文件:那样编辑器会把生成物当成第一等源码,
// 有人去改它,然后被下一次构建静默覆盖。
//
// 形状必须与 tools/lib/generate.ts 的 generatePresetIndex 保持一致。生成的文本本身
// 语法是否正确,类型声明管不到 —— 那条由 check:dist 用 V8 真解析一遍来保证。
import type { Preset } from "./preset-controller.js";
declare const Presets: Record<string, Preset>;
/** 本分发的默认预设 id:WE 属性缺失或指向不存在的预设时的回落目标。 */
export declare const defaultPresetId: string;
export default Presets;
+501
View File
@@ -0,0 +1,501 @@
import { contain, frameForAspect } from "./viewport-fitter.js";
import type { Rect, SpineSceneConfig, SpineScenePart, SpineSkeleton, SpineAnimationState, SpineSceneRenderer, SpineAssetManager, SpineTexture } from "../globals.js";
// 场景控制器:一档壁纸 = 一整页场景(N 具骨架 + M 块贴图平面)。
//
// **为什么不复用 spine.SpinePlayer**:官方播放器的 `config.draw` 只在宿主骨架画完之后调用
// (vendored 包 15128 行),于是"位于宿主下面的层"根本画不出来——而场景里第一件往往就是贴图平面。
// 所以这里自持渲染循环:用**同一个 vendored 包里已经导出的** SceneRenderer + AssetManager
// 自己加载、自己按 order 逐 part 绘制,不引三方依赖、不换包。
//
// **单骨架路径(spine-controller.ts)一行不动**:xilian / kv37 被冻结基线钉着,
// 预设里 `spineConfig` 与 `sceneConfig` 二选一(构建期就会报错,不留静默优先级)。
//
// 与单骨架路径共享的约定:贴图直通 alpha(premultipliedAlpha = false)、取景走 viewport-fitter。
interface LoadedSpine {
part: SpineScenePart;
skeleton: SpineSkeleton;
state: SpineAnimationState;
/** 相机深度(camZ − z):排序用,越大越远。 */
depth: number;
}
interface LoadedImage {
part: SpineScenePart;
texture: SpineTexture;
/** 四边形尺寸(世界单位,已乘上 part 的 scale)。 */
width: number;
height: number;
/** 四边形中心的世界坐标(已含网格中心偏移与 flipY)。 */
position: [number, number];
color: unknown;
depth: number;
}
interface LoadedSolid {
part: SpineScenePart;
width: number;
height: number;
position: [number, number];
color: unknown;
depth: number;
}
export default class SceneController {
private readonly container: HTMLElement | null;
private config: SpineSceneConfig | undefined = undefined;
private canvas: HTMLCanvasElement | undefined = undefined;
private context: unknown = undefined;
private renderer: SpineSceneRenderer | undefined = undefined;
private manager: SpineAssetManager | undefined = undefined;
private spines: LoadedSpine[] = [];
private images: LoadedImage[] = [];
private solids: LoadedSolid[] = [];
private drawables: (LoadedSpine | LoadedImage | LoadedSolid)[] = [];
private errors: string[] = [];
private readonly rotatedSkipped: string[] = [];
private disposed = false;
private frameHandle = 0;
private lastDrawTime = 0;
private lastDeltaTime = 0;
private fps = 0;
/** 页面坐标 → 渲染坐标的 y 方向(页面 y 向上、spine 渲染 y 向下,默认取反)。 */
private ySign = -1;
/** 相机 z(透视场景的投影中心);正交场景不用。 */
private cameraZ = 0;
/** 透视投影系数 `1 / (tan(fov/2) · d)`;正交场景恒为 1。 */
private perspectiveTan = 0;
/** 时间线的整场位移(页面单位)。 */
private timelineOffset: [number, number, number] = [0, 0, 0];
/** 取景重试句柄:正交路径的取景依赖**已加载内容**,早于加载完成时挂到下一帧。 */
private framingRetry = 0;
private imageAspect: number | undefined = undefined;
private reference: Rect | undefined = undefined;
private framing: Rect | undefined = undefined;
private appliedAspect: number | undefined = undefined;
constructor(containerId: string) {
this.container = document.getElementById(containerId);
}
/** 只写状态:与画布是否存在无关,随时可调用(WE 可能在 window.load 之前下发属性)。 */
setConfig(config: SpineSceneConfig | undefined): void {
this.config = config;
this.reference = undefined;
this.framing = undefined;
this.appliedAspect = undefined;
if (this.canvas && config) this.rebuild();
}
/** 背景图换了,取景常数跟着换(比例一变就重新推导)。 */
setImageAspect(aspect: number | undefined): void {
if (!(typeof aspect === "number" && aspect > 0) || aspect === this.imageAspect) return;
this.imageAspect = aspect;
this.appliedAspect = undefined;
}
setFps(fps: unknown): void {
const value = Number(fps);
this.fps = Number.isFinite(value) && value > 0 ? value : 0;
}
create(): void {
if (this.canvas || !this.config) return;
void this.build();
}
rebuild(): void {
this.dispose();
this.create();
}
dispose(): void {
this.disposed = true;
if (this.frameHandle) cancelAnimationFrame(this.frameHandle);
this.frameHandle = 0;
if (this.framingRetry) cancelAnimationFrame(this.framingRetry);
this.framingRetry = 0;
this.renderer?.dispose();
if (this.container) this.container.innerHTML = "";
this.canvas = undefined;
this.context = undefined;
this.renderer = undefined;
this.manager = undefined;
this.spines = [];
this.images = [];
this.solids = [];
this.rotatedSkipped.length = 0;
this.drawables = [];
this.reference = undefined;
this.framing = undefined;
this.appliedAspect = undefined;
}
// ------------------------------------------------------------------------------------
// 加载
// ------------------------------------------------------------------------------------
private async build(): Promise<void> {
const config = this.config;
if (!config || !this.container) return;
this.disposed = false;
this.errors = [];
const canvas = document.createElement("canvas");
canvas.style.cssText = "display:block;width:100%;height:100%";
this.container.appendChild(canvas);
this.canvas = canvas;
const context = new spine.ManagedWebGLRenderingContext(canvas);
this.context = context;
this.renderer = new spine.SceneRenderer(canvas, context, false);
const manager = new spine.AssetManager(context);
this.manager = manager;
for (const part of config.parts) {
if (part.kind === "spine") {
if (part.jsonUrl) manager.loadJson(part.jsonUrl);
if (part.atlasUrl) manager.loadTextureAtlas(part.atlasUrl);
} else if (part.kind === "image" && part.image) {
manager.loadTexture(part.image);
}
}
try {
await manager.loadAll();
} catch (failure) {
// loadAll 的 reject 值是 { 路径: 错误信息 } 映射。**必须显式记下来**:
// 少加载一件而画面"看起来还行"是最难查的一类缺陷。
if (failure && typeof failure === "object") {
for (const [path, message] of Object.entries(failure as Record<string, unknown>)) {
this.errors.push(`${path}: ${String(message)}`);
}
} else {
this.errors.push(String(failure));
}
}
if (this.disposed) return;
// 默认**不取反**:页面投影本身就把「世界 y 向上」映成「屏幕 y 向下」(`(1 - ndc.y)/2`)。
// 由对象级对拍证实:main_btn 的 x 逐像素吻合(637 vs 638),y 完全镜像(107 vs 612)。
// 需要取反时(例如骨架自带 y 向下)显式写 `flipY: true`。
const flip = config.flipY === true ? -1 : 1;
this.ySign = flip;
// 时间线的整场位移:静态场景树里没有这一项,是从真页面量出来的(见文档)。
this.timelineOffset = [
Number(config.timelineOffset?.[0] ?? 0),
Number(config.timelineOffset?.[1] ?? 0),
Number(config.timelineOffset?.[2] ?? 0),
];
// 透视相机:z 是真实景深,必须先投到相机平面再谈摆放与尺寸。
// 正交相机(hsr 两页,z 全 0)走恒等投影。
const camera = config.camera;
if (camera?.type === 1 && typeof camera.fov === "number" && camera.fov > 0) {
this.perspectiveTan = Math.tan((camera.fov * Math.PI) / 360);
this.cameraZ = camera.position?.[2] ?? 0;
} else {
this.perspectiveTan = 0;
this.cameraZ = 0;
}
for (const part of config.parts) {
const projected = this.project(
Number(part.position[0] ?? 0) + this.timelineOffset[0],
Number(part.position[1] ?? 0) + this.timelineOffset[1],
Number(part.position[2] ?? 0) + this.timelineOffset[2],
);
const position: [number, number] = [projected.x, flip * projected.y];
const scale: [number, number] = [
Number(part.scale[0] ?? 1) * projected.scale,
Number(part.scale[1] ?? 1) * projected.scale,
];
const depth = this.cameraZ - Number(part.position[2] ?? 0);
if (part.kind === "spine") this.addSpine(part, position, scale, depth);
else if (part.kind === "image") {
// 被倾斜的 3D 面片(桌面/地面):中心位置是对的,但形状要四个角点各自过旋转与透视,
// billboard 四边形会糊满整个画面。宁可不画——数量进 __sceneDebug.rotatedSkipped。
if (part.rotation && part.rotation.some((value) => Math.abs(value) > 1e-9)) {
this.rotatedSkipped.push(part.id);
continue;
}
this.addImage(part, position, scale, depth);
} else this.addSolid(part, position, scale, depth);
}
// 纯色平面**不进绘制列表**:它没有贴图,而 SceneRenderer 只提供 drawTexture/drawSkeleton,
// 传 undefined 会直接崩(场景里确实有这种平面,只是 kv45 恰好没有)。宁可不画,也不画错——
// 数量进 __sceneDebug.solids,验收能看见它。
// 页面靠 z 缓冲决定遮挡(材质带 depthTest/depthWrite),画序无关;这里没有深度缓冲,
// 所以用画家算法复刻同一结果:**远的先画**,深度相同(正交场景)才回落到场景树的 order。
this.drawables = [...this.spines, ...this.images].sort(
(a, b) => b.depth - a.depth || a.part.order - b.part.order,
);
this.publishDebug();
this.start();
}
/**
* 页面坐标 → 相机平面坐标(billboard)。
*
* 相机在 `(0, 0, cameraZ)` 看向 -z、竖直 fov 已知,则深度 `d = cameraZ - z`、
* 系数 `s = 1 / (tan(fov/2) · d)`,屏幕位置与尺寸都乘 `s`。
* 正交相机(或没给相机)时 `s = 1`——hsr 两页的 z 全是 0,两者等价。
*/
private project(x: number, y: number, z: number): { x: number; y: number; scale: number } {
if (!this.perspectiveTan) return { x, y, scale: 1 };
// 深度 = 相机 z − 对象 z(天空 z=−1414 → 最远、角色 z=+849 → 最近,与画面常识一致)。
// 曾试过翻符号(`camZ + z`):画面"看起来更像"但物理相反(天空跑到相机前面),
// 且 content 包围盒涨到帧的 8.8 倍——已否证,见 .scratch/scene-player/perspective.md 第 5 轮。
const depth = Math.max(this.cameraZ - z, 1);
const scale = 1 / (this.perspectiveTan * depth);
return { x: x * scale, y: y * scale, scale };
}
private addSpine(part: SpineScenePart, position: [number, number], scale: [number, number], depth: number): void {
if (!part.jsonUrl || !part.atlasUrl) {
this.errors.push(`骨架 ${part.id} 缺少 jsonUrl/atlasUrl`);
return;
}
const json = this.manager?.get(part.jsonUrl);
const atlas = this.manager?.get(part.atlasUrl);
if (!json || !atlas) {
this.errors.push(`骨架 ${part.id} 没加载上`);
return;
}
const loader = new spine.AtlasAttachmentLoader(atlas as never);
const data = new spine.SkeletonJson(loader).readSkeletonData(json);
const skeleton = new spine.Skeleton(data);
const state = new spine.AnimationState(new spine.AnimationStateData(data));
// 页面在这个节点上指定的皮肤(有的节点用 b/a 而不是 default)。
if (part.skin) skeleton.setSkinByName(part.skin);
// **页面指定的动画优先**(`spine.defaultAnimation`);缺省才是骨架的第一个动画。
// 不读它就会去播入场动画(很多骨架的第一个动画是 `in`),姿态与页面不同。
const animation = part.animation ?? data.animations[0]?.name;
if (animation) state.setAnimation(0, animation, true);
if (part.timeScale !== undefined) state.timeScale = part.timeScale;
skeleton.x = position[0];
skeleton.y = position[1];
skeleton.scaleX = scale[0];
skeleton.scaleY = scale[1];
this.spines.push({ part, skeleton, state, depth });
}
private addImage(part: SpineScenePart, position: [number, number], scale: [number, number], depth: number): void {
const texture = this.manager?.get(part.image ?? "") as SpineTexture | undefined;
if (!texture) {
this.errors.push(`平面 ${part.id} 没加载上`);
return;
}
// glTF 网格(geometry.type 1)没有尺寸,回落到贴图原始尺寸——这是本轮的已知近似,
// 记在 scene.json 的 geometrySize 里,等真正需要精度时再抓 glTF 的顶点包围盒。
const image = texture.getImage();
const naturalWidth = image.naturalWidth ?? image.width;
const naturalHeight = image.naturalHeight ?? image.height;
const width = (part.width ?? naturalWidth) * Math.abs(scale[0]);
const height = (part.height ?? naturalHeight) * Math.abs(scale[1]);
// 网格中心相对节点原点的偏移:先按页面坐标缩放,再跟着 flipY 翻 y。
const centerX = (part.center?.[0] ?? 0) * Math.abs(scale[0]);
const centerY = (part.center?.[1] ?? 0) * Math.abs(scale[1]);
const quadCenter: [number, number] = [position[0] + centerX, position[1] + this.ySign * centerY];
this.images.push({
part,
texture,
width,
height,
position: quadCenter,
color: part.color === undefined ? undefined : new spine.Color(1, 1, 1, 1),
depth,
});
}
private addSolid(part: SpineScenePart, position: [number, number], scale: [number, number], depth: number): void {
const width = (part.width ?? 0) * Math.abs(scale[0]);
const height = (part.height ?? 0) * Math.abs(scale[1]);
if (!width || !height) return; // 没尺寸的纯色平面没有可画的东西
this.solids.push({ part, width, height, position, color: undefined, depth });
}
// ------------------------------------------------------------------------------------
// 渲染
// ------------------------------------------------------------------------------------
private start(): void {
this.lastDrawTime = 0;
this.lastDeltaTime = 0;
this.frameHandle = requestAnimationFrame(this.tick);
}
private tick = (): void => {
const canvas = this.canvas;
const renderer = this.renderer;
if (this.disposed || !canvas || !renderer || !this.config) return;
this.frameHandle = requestAnimationFrame(this.tick);
const now = performance.now();
if (this.fps) {
// WE 不替壁纸限流。留 4ms 容差并让到期时间按固定步长推进:
// 纯"不足间隔就丢"会把 rAF 抖动放大成整拍丢失(单骨架路径实测过 30→22.5fps)。
const interval = 1000 / this.fps;
if (this.lastDrawTime && now - this.lastDrawTime < interval - 4) return; // 本帧丢弃:不推进、不绘制
this.lastDrawTime = !this.lastDrawTime || now - this.lastDrawTime > interval * 2 ? now : this.lastDrawTime + interval;
}
const delta = this.lastDeltaTime ? Math.min((now - this.lastDeltaTime) / 1000, 0.1) : 1 / 60;
this.lastDeltaTime = now;
renderer.resize(spine.ResizeMode.Expand);
const aspect = canvas.width / canvas.height;
this.applyFraming(aspect);
const gl = (this.context as { gl: WebGLRenderingContext }).gl;
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
renderer.begin();
for (const item of this.drawables) {
if ("skeleton" in item) {
item.state.update(delta);
item.state.apply(item.skeleton);
item.skeleton.updateWorldTransform(spine.Physics.update);
renderer.drawSkeleton(item.skeleton, false);
} else {
const x = item.position[0] - item.width / 2;
const y = item.position[1] - item.height / 2;
// drawTexture 的 (x, y) 是四边形左下角(spine 世界 y 向上),贴图 v=1 在下边。
renderer.drawTexture("texture" in item ? item.texture : (undefined as never), x, y, item.width, item.height, item.color);
}
}
renderer.end();
};
/**
* 取景:参考矩形 = **所有 part 变换后的并集包围盒**(旧项目验证过的做法),
* 再按画布比例 contain,最后套背景比例链(与单骨架路径的构图行为一致)。
*
* 不用页面 `ui` 矩形当参考:页面相机看向的是场景原点,而 ui 矩形的原点是它的左下角,
* 直接拿它当可见区会把整个场景推向右上。并集包围盒没有这个原点歧义。
*/
private applyFraming(aspect: number): void {
const renderer = this.renderer;
const canvas = this.canvas;
if (!renderer || !canvas || !canvas.width || !canvas.height) return;
if (aspect === this.appliedAspect && this.framing) return;
// 透视场景:取景框 = 相机视锥在 z=0 平面的可见矩形(固定机位,内容可以溢出)。
// 正交场景:内容并集包围盒(z 全为 0 时它与视锥等价,实测画面对得上)。
const reference = this.frustumRect() ?? this.measureBoundsCached();
if (!reference) {
// 正交路径的取景来自内容包围盒;内容还没加载完就先挂到下一帧,不要静默放弃
// (否则 framing 为 null、画布尺寸也不会被设上——verify-scene-player 曾因此偶发一红)。
if (!this.framingRetry) {
this.framingRetry = requestAnimationFrame(() => {
this.framingRetry = 0;
if (!this.disposed) this.applyFraming(aspect);
});
}
return;
}
const target = this.imageAspect
? frameForAspect(reference, aspect, this.imageAspect, reference.width / reference.height)
: contain(reference, aspect);
renderer.camera.zoom =
canvas.height / canvas.width > target.height / target.width
? target.width / canvas.width
: target.height / canvas.height;
renderer.camera.position.x = target.x + target.width / 2;
renderer.camera.position.y = target.y + target.height / 2;
this.framing = target;
this.appliedAspect = aspect;
this.publishDebug();
}
/** 缓存的并集包围盒(只给正交路径用;比例变了也不用重算,内容本身不随画布变)。 */
private measureBoundsCached(): Rect | undefined {
if (!this.reference) this.reference = this.measureBounds();
return this.reference;
}
/**
* 透视相机的可见矩形(z=0 平面上的视锥截面)。
*
* 竖直半高 = `tan(fov/2) · camZ`,水平半宽再由**画布比例**决定(引擎也是按画布设 aspect)。
* 返回值本身已经是指定比例,所以后面的 contain 是恒等的——比例语义只在这一处。
*/
private frustumRect(): Rect | undefined {
if (!this.perspectiveTan || !this.cameraZ) return undefined;
const uiWidth = this.config?.ui?.[0];
const uiHeight = this.config?.ui?.[1];
if (!uiWidth || !uiHeight || uiWidth <= 0 || uiHeight <= 0) return undefined;
// 相机在 z=0 的可见区**就是 UI 矩形**:引擎按 `aspect = uiWidth/uiHeight` 建相机,
// 而 `2·tan(fov/2)·camZ` 恰好等于 uiHeight(nico-tea: 1080)。画布比例不参与视锥形状,
// 它只在外层 contain 里决定怎么把这块 UI 区域装进画布。
const s0 = 1 / (this.perspectiveTan * this.cameraZ); // 页面单位 → 投影单位(与 project() 同一个 s0)
// 视锥 = UI 矩形(引擎按 `aspect = uiWidth/uiHeight` 建相机,且 `2·tan(fov/2)·camZ == uiHeight`)。
// 注意:**不要**再套 `cameraAdaptScreen` 的 zoom(uiHeight/canvasHeight)——实测那样画面更放大,
// 与页面基准图更远;那个 zoom 是给正交相机那条路用的。见 .scratch/scene-player/perspective.md。
// 复刻引擎 `resizeUI`:可见矩形 = UI 矩形**按画布比例收缩一个轴**。
// 源码:`var b = canvasAspect / uiAspect; b < 1 ? g *= b : y /= b;`
// nico-tea@1280x720:b = 1.7778/2.3148 = 0.768 → 宽 2500×0.768 = 1920、高 1080。
// 这与第 4 轮量到的正交投影矩阵(1920×1080)逐位一致;此前用 contain 会多露出 1.302 倍。
const canvasAspect = this.canvas && this.canvas.height ? this.canvas.width / this.canvas.height : 0;
let visibleW = uiWidth;
let visibleH = uiHeight;
if (canvasAspect > 0) {
const b = canvasAspect / (uiWidth / uiHeight);
if (b < 1) visibleW = uiWidth * b;
else visibleH = uiHeight / b;
}
const width = visibleW * s0;
const height = visibleH * s0;
return { x: -width / 2, y: -height / 2, width, height };
}
/** 所有 part 的并集包围盒(世界单位)。 */
private measureBounds(): Rect | undefined {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
const grow = (x: number, y: number, w: number, h: number): void => {
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(w) || !Number.isFinite(h)) return;
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x + w);
maxY = Math.max(maxY, y + h);
};
const offset = new spine.Vector2();
const size = new spine.Vector2();
const temp: number[] = [];
const clipping = undefined;
for (const item of this.spines) {
item.skeleton.setToSetupPose();
item.skeleton.updateWorldTransform(spine.Physics.update);
item.skeleton.getBounds(offset, size, temp, clipping);
grow(offset.x, offset.y, size.x, size.y);
}
for (const item of this.images) {
grow(item.position[0] - item.width / 2, item.position[1] - item.height / 2, item.width, item.height);
}
for (const item of this.solids) {
grow(item.position[0] - item.width / 2, item.position[1] - item.height / 2, item.width, item.height);
}
if (!Number.isFinite(minX) || !Number.isFinite(minY)) return undefined;
return { x: minX, y: minY, width: Math.max(maxX - minX, 1e-3), height: Math.max(maxY - minY, 1e-3) };
}
private publishDebug(): void {
window.__sceneDebug = {
parts: this.config?.parts.length ?? 0,
loaded: this.spines.length + this.images.length,
errors: [...this.errors],
framing: this.framing ?? null,
content: this.measureBoundsCached() ?? null,
spines: this.spines.map((item) => item.part.id),
images: this.images.map((item) => item.part.id),
solids: this.solids.length,
rotatedSkipped: this.rotatedSkipped.length,
};
}
}
+191
View File
@@ -0,0 +1,191 @@
import { REFERENCE_ASPECT, contain, frameForAspect, resolveViewport, toViewportConfig } from "./viewport-fitter.js";
import type { Rect, SpinePlayer, SpinePlayerConfig } from "../globals.js";
// 负责把「当前预设的 spineConfig」应用到 Spine 播放器上,并维持立绘层与背景图的取景同步。
//
// 核心约定一:写入与应用分开。WE 可能在 window.load 之前就下发用户属性,那时播放器还不存在;
// 旧实现把配置赋值也塞在 `if (this.player)` 里,于是那次配置被静默丢弃,
// 结果是"背景与音乐已切到新壁纸、立绘还是旧壁纸"的混合状态(已实测复现)。
// 现在 setConfig() 无论如何都记下配置,create() 用记下的配置创建。
//
// 核心约定二:窗口尺寸变化绝不重建播放器。重建意味着重新下载 3.4 MB 骨架与全部贴图;
// 取景只是 viewport 的算术,改写 config.viewport 后调 setViewport() 就地生效。
/** 作者视口换算到参考比例后的取景 + 动画名。 */
interface Framing {
animation: string;
reference: Rect;
}
export default class SpineController {
private readonly containerId: string;
private readonly baseConfig: SpinePlayerConfig;
private readonly container: HTMLElement | null;
private presetConfig: SpinePlayerConfig = {};
private player: SpinePlayer | undefined = undefined;
private framingEnabled = true;
private imageAspect: number | undefined = undefined; // 背景图宽高比,决定立绘层与背景的缩放关系
private framing: Framing | undefined = undefined; // 作者视口(换算到参考比例后的可见矩形)+ 动画名
private appliedAspect: number | undefined = undefined; // 上一次已应用的画布比例
private fps = 0; // 0 = 不限流。WE 不会替壁纸限流,得自己门控绘制
private rawDrawFrame: SpinePlayer["drawFrame"] | undefined = undefined; // 被包裹前的 drawFrame
constructor(containerId: string, baseConfig: SpinePlayerConfig = {}) {
this.containerId = containerId;
this.baseConfig = {
alpha: true,
premultipliedAlpha: false,
showControls: false,
showLoading: false,
...baseConfig,
};
this.container = document.getElementById(containerId);
}
/** 只写状态:与播放器是否存在无关,随时可调用。 */
setConfig(presetConfig: SpinePlayerConfig = {}): void {
this.presetConfig = presetConfig;
// 预设可声明 framing:"author" 保留作者原始的「含住视口」行为,不跟随背景缩放
this.framingEnabled = presetConfig.framing !== "author";
this.framing = undefined;
this.appliedAspect = undefined;
if (this.player) this.rebuild();
}
/** 背景图换了,取景常数也要跟着换(比例一变就重新推导)。 */
setImageAspect(aspect: number | undefined): void {
if (!(typeof aspect === "number" && aspect > 0) || aspect === this.imageAspect) return;
this.imageAspect = aspect;
this.appliedAspect = undefined;
}
create(): void {
if (this.player) return;
// 每次都用 baseConfig + 当前 presetConfig 现算,避免上一份预设的键残留。
// frame 放在最后:它是内部钩子,不允许被预设数据覆盖。
this.rawDrawFrame = undefined;
this.player = new spine.SpinePlayer(this.containerId, {
...this.baseConfig,
...this.presetConfig,
frame: (player: SpinePlayer) => this.maintainFraming(player),
});
this.applyFpsLimit();
}
/** WE 经 applyGeneralProperties 下发 fps,但它不会替壁纸限流,必须自行实现。 */
setFps(fps: unknown): void {
const value = Number(fps);
this.fps = Number.isFinite(value) && value > 0 ? value : 0;
this.applyFpsLimit();
}
// 门控绘制:把实例上的 drawFrame 换成一层包装。
// 播放器的循环写作 requestAnimationFrame(() => this.drawFrame())——箭头闭包在调用时才读
// this.drawFrame,所以在实例上换掉它就能接管排程。
// 关键点:原实现只在真正绘制时才排下一帧,而被限流丢掉的帧同样要排,否则循环会停摆。
private applyFpsLimit(): void {
const player = this.player;
if (!player) return;
// 先摘掉上一轮包装,避免换档时叠成多层
if (this.rawDrawFrame) {
player.drawFrame = this.rawDrawFrame;
this.rawDrawFrame = undefined;
}
if (!this.fps) return;
const raw = (this.rawDrawFrame = player.drawFrame);
const interval = 1000 / this.fps;
// rAF 的节拍有抖动,而"距上一帧不足 interval 就丢"会把抖动放大成整拍丢失:
// 目标是 30fps 时,落在 33.2ms 的那一帧会被判为不足 33.33ms 而丢掉,实测只剩 22.5fps。
// 所以留一点容差,并让到期时间按固定步长推进,避免抖动被累积成额外丢帧。
const tolerance = 4;
let due = -Infinity;
player.drawFrame = (requestNextFrame = true) => {
if (player.disposed || player.error) return;
if (requestNextFrame && !player.stopRequestAnimationFrame) {
requestAnimationFrame(() => player.drawFrame());
}
const now = performance.now();
if (now + tolerance < due) return; // 本帧丢弃:不推进动画,也不绘制
due = due < 0 || now - due > interval ? now + interval : due + interval;
// 排程已由本层负责,让原实现不要再排一次
raw.call(player, false);
};
}
/** 换骨架没有捷径,只能销毁重建。 */
rebuild(): void {
this.dispose();
this.create();
}
dispose(): void {
if (!this.player) return;
this.player.dispose();
if (this.container) this.container.innerHTML = "";
this.player = undefined;
this.rawDrawFrame = undefined;
this.framing = undefined;
this.appliedAspect = undefined;
}
// 播放器每帧在算相机之前调用它(drawFrame 内,renderer.resize 之后)。
// 以画布比例作键:比例没变就是一次比较后返回,不会给每帧加负担。
private maintainFraming(player: SpinePlayer): void {
if (!this.framingEnabled) return;
const canvas = player.canvas;
if (!canvas || !canvas.clientWidth || !canvas.clientHeight) return;
const aspect = canvas.clientWidth / canvas.clientHeight;
if (aspect === this.appliedAspect) return;
// 骨架加载完成前 currentViewport 还不存在,下一帧再试
let framing = this.framing;
if (!framing) {
framing = this.captureFraming(player);
if (!framing) return;
}
this.applyFraming(player, aspect, framing);
}
/** 记下作者视口在参考比例下的取景;之后所有比例都从它推导。 */
private captureFraming(player: SpinePlayer): Framing | undefined {
const viewport = player.currentViewport;
if (!viewport) return undefined;
let animation = typeof this.presetConfig.animation === "string" ? this.presetConfig.animation : undefined;
if (!animation) {
const entry = player.animationState?.getCurrent(0);
animation = entry?.animation?.name;
}
if (!animation) return undefined;
const framing: Framing = {
animation,
reference: contain(resolveViewport(viewport), REFERENCE_ASPECT),
};
this.framing = framing;
return framing;
}
private applyFraming(player: SpinePlayer, aspect: number, framing: Framing): void {
const { animation, reference } = framing;
const target = this.imageAspect ? frameForAspect(reference, aspect, this.imageAspect) : contain(reference, aspect);
// 就地改写字段,绝不整体替换 config.viewport:
// setViewport() 会无条件读 config.viewport.animations[name],换掉整个对象会崩。
const configViewport = player.config.viewport;
if (!configViewport) return;
Object.assign(configViewport, toViewportConfig(target));
player.setViewport(animation);
// 取景要随窗口实时变化,0.25 秒的过渡插值只会拖出残影:直接跳到位
player.viewportTransitionStart = performance.now() - 1e6;
this.appliedAspect = aspect;
}
}
+71
View File
@@ -0,0 +1,71 @@
import type { Rect, ViewportConfig } from "../globals.js";
// 立绘层的取景计算。纯函数:只做世界坐标矩形的算术,不碰 DOM,也不碰播放器实例。
//
// 矩形一律是 { x, y, width, height },世界单位,y 轴向上。
//
// 为什么需要这一层:
// 背景图走 CSS `background-size: cover`(铺满、按需裁切),
// 立绘层却由播放器按「含住作者视口」映射(contain)。两者只在作者调参的那个比例下一致,
// 比例一变,缩放倍率就分叉——竖屏下背景放大 1.78 倍,立绘层却纹丝不动,特效于是和背景插画脱节。
// 这里让立绘层的缩放跟着背景 cover 的缩放走,两者始终同倍率、同中心。
/** 作者调参时的基准比例。在这个比例下取景与作者设定逐像素一致。 */
export const REFERENCE_ASPECT = 16 / 9;
/** 播放器把 currentViewport(基矩形 + 四边内边距)解成最终可见矩形。
* 对应 spine-player.js 里 `viewport.x = currentViewport.x - padLeft` 那四行。 */
export function resolveViewport(viewport: ViewportConfig): Rect {
return {
x: viewport.x - viewport.padLeft,
y: viewport.y - viewport.padBottom,
width: viewport.width + viewport.padLeft + viewport.padRight,
height: viewport.height + viewport.padBottom + viewport.padTop,
};
}
/** 把矩形按 aspect 撑到刚好含住它,中心不变。这正是播放器在参考比例下的行为。 */
export function contain(rect: Rect, aspect: number): Rect {
const width = Math.max(rect.width, rect.height * aspect);
const height = width / aspect;
const cx = rect.x + rect.width / 2;
const cy = rect.y + rect.height / 2;
return { x: cx - width / 2, y: cy - height / 2, width, height };
}
/** 背景在某个画布比例下的 cover 缩放,以「受高度约束时的倍率」为 1 归一化。
* 背景比画布更宽时按高度铺满(倍率不变),画布更宽时按宽度铺满(倍率随 aspect 增长)。 */
export function coverScale(aspect: number, imageAspect: number): number {
return Math.max(aspect / imageAspect, 1);
}
/** 目标可见矩形:参考比例下正好等于作者视口,其余比例按背景 cover 缩放的反比收放。
* 效果等价于「立绘层相对背景插画永远 cover」。 */
export function frameForAspect(
reference: Rect,
aspect: number,
imageAspect: number,
referenceAspect = REFERENCE_ASPECT,
): Rect {
const height =
(reference.height * coverScale(referenceAspect, imageAspect)) / coverScale(aspect, imageAspect);
const width = height * aspect;
const cx = reference.x + reference.width / 2;
const cy = reference.y + reference.height / 2;
return { x: cx - width / 2, y: cy - height / 2, width, height };
}
/** 把目标可见矩形写成播放器 config.viewport 需要的字段:内边距归零,
* 这样 resolveViewport() 得到的就是它本身。 */
export function toViewportConfig(rect: Rect): ViewportConfig {
return {
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
padLeft: 0,
padRight: 0,
padTop: 0,
padBottom: 0,
};
}
File diff suppressed because it is too large. Load diff
+24
View File
@@ -0,0 +1,24 @@
* {
margin: 0;
padding: 0;
}
body {
background-color: white;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
height: 100vh;
width: 100vw;
display: flex;
justify-content: center;
align-items: center;
}
#spine-container {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
+350
View File
@@ -0,0 +1,350 @@
/** Player **/
.spine-player {
box-sizing: border-box;
width: 100%;
height: 100%;
background: none;
}
.spine-player * {
box-sizing: border-box;
font-family: "PT Sans",Arial,"Helvetica Neue",Helvetica,Tahoma,sans-serif;
color: #dddddd;
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
.spine-player-error {
font-size: 14px;
z-index: 10;
border-radius: 4px;
-webkit-user-select: text;
-khtml-user-select: text;
-moz-user-select: text;
-ms-user-select: text;
user-select: text;
}
.spine-player-hidden {
display: none;
}
/** Canvas **/
.spine-player canvas {
border-radius: 4px;
}
/** Slider **/
.spine-player-slider {
width: 100%;
height: 16px;
position: relative;
cursor: pointer;
}
.spine-player-slider-value {
position: absolute;
bottom: 0;
height: 2px;
background: rgba(98, 176, 238, 0.6);
cursor: pointer;
}
.spine-player-slider:hover .spine-player-slider-value {
height: 4px;
background: rgba(98, 176, 238, 1);
transition: height 0.2s;
}
.spine-player-slider-value.hovering {
height: 4px;
background: rgba(98, 176, 238, 1);
transition: height 0.2s;
}
.spine-player-slider.big {
height: 12px;
background: rgb(0, 0, 0);
}
.spine-player-slider.big .spine-player-slider-value {
height: 12px;
background: rgba(98, 176, 238, 1);
}
/** Column and row layout elements **/
.spine-player-column {
display: flex;
flex-direction: column;
}
.spine-player-row {
display: flex;
flex-direction: row;
}
/** List **/
.spine-player-list {
list-style: none !important;
padding: 0 !important;
margin: 0 !important;
}
.spine-player-list li {
cursor: pointer;
margin: 8px 8px;
}
.spine-player-list .selectable {
display: flex;
flex-direction: row;
margin: 0 !important;
padding: 2px 20px 2px 0 !important;
}
.spine-player-list li.selectable:first-child {
margin-top: 4px !important;
}
.spine-player-list li.selectable:last-child {
margin-bottom: 4px !important;
}
.spine-player-list li.selectable:hover {
background: #6e6e6e;
}
.spine-player-list li.selectable .selectable-circle {
display: flex;
flex-direction: row;
width: 6px;
min-width: 6px;
height: 6px;
border-radius: 50%;
background: #fff;
align-self: center;
opacity: 0;
margin: 5px 10px;
}
.spine-player-list li.selectable.selected .selectable-circle {
opacity: 1;
}
.spine-player-list li.selectable .selectable-text {
color: #aaa;
}
.spine-player-list li.selectable.selected .selectable-text, .spine-player-list li.selectable:hover .selectable-text {
color: #ddd;
}
/** Switch **/
.spine-player-switch {
display: flex;
flex-direction: row;
margin: 2px 10px;
}
.spine-player-switch-text {
flex: 1;
margin-right: 8px;
}
.spine-player-switch-knob-area {
width: 30px; /* width of the switch */
height: 10px;
display: block;
border-radius: 5px; /* must be half of height */
background: #6e6e6e;
position: relative;
align-self: center;
justify-self: flex-end;
}
.spine-player-switch.active .spine-player-switch-knob-area {
background: #5EAFF1;
}
.spine-player-switch-knob {
display: block;
width: 14px;
height: 14px;
border-radius: 50%;
background: #9e9e9e;
position: absolute;
left: 0px;
top: -2px;
filter: drop-shadow(0 0 1px #333);
transition: transform 0.2s;
}
.spine-player-switch.active .spine-player-switch-knob {
background: #fff;
transform: translateX(18px);
transition: transform 0.2s;
}
/** Popup **/
.spine-player-popup-parent {
position: relative;
}
.spine-player-popup {
user-select: none;
position: absolute;
background: rgba(0, 0, 0, 0.75);
z-index: 1;
right: 2px;
bottom: 40px;
border-radius: 4px;
max-height: 400%;
overflow: auto;
font-size: 85%;
}
.spine-player-popup-title {
margin: 4px 15px 2px 15px;
text-align: center;
}
.spine-player-popup hr {
margin: 0;
border: 0;
border-bottom: 1px solid #cccccc70;
}
/** Player controls **/
.spine-player-controls {
display: flex;
flex-direction: column;
position: absolute;
bottom: 0;
left: 0;
width: 100%;
opacity: 1;
transition: opacity 0.4s;
}
.spine-player-controls-hidden {
pointer-events: none;
opacity: 0;
transition: opacity 0.4s;
}
/** Player buttons **/
.spine-player-buttons {
display: flex;
flex-direction: row;
width: 100%;
background: rgba(0, 0, 0, 0.5);
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
padding: 2px 8px 3px;
}
.spine-player-button {
background: none;
outline: 0;
border: none;
width: 32px;
height: 32px;
background-size: 20px;
background-repeat: no-repeat;
background-position: center;
cursor: pointer;
margin-right: 3px;
padding-bottom: 3px;
filter: drop-shadow(0 0 1px #333);
}
.spine-player-button-spacer {
flex: 1;
}
.spine-player-button-icon-play {
background-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2048%2048%22%3E%3Cdefs%3E%3Cstyle%3E.cls-1%7Bfill%3A%23fff%3B%7D%3C%2Fstyle%3E%3C%2Fdefs%3E%3Ctitle%3Eplay%3C%2Ftitle%3E%3Cg%20id%3D%22play%22%3E%3Cpolygon%20class%3D%22cls-1%22%20points%3D%2243%2023.3%204%2047%204%201%2043%2023.3%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E");
}
.spine-player-button-icon-play:hover {
background-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2048%2048%22%3E%3Cdefs%3E%3Cstyle%3E.cls-1%7Bfill%3A%2362B0EE%3B%7D%3C%2Fstyle%3E%3C%2Fdefs%3E%3Ctitle%3Eplay%3C%2Ftitle%3E%3Cg%20id%3D%22play%22%3E%3Cpolygon%20class%3D%22cls-1%22%20points%3D%2243%2023.3%204%2047%204%201%2043%2023.3%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E");
}
.spine-player-button-icon-play-selected {
background-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2048%2048%22%3E%3Cdefs%3E%3Cstyle%3E.cls-1%7Bfill%3A%2362B0EE%3B%7D%3C%2Fstyle%3E%3C%2Fdefs%3E%3Ctitle%3Eplay%3C%2Ftitle%3E%3Cg%20id%3D%22play%22%3E%3Cpolygon%20class%3D%22cls-1%22%20points%3D%2243%2023.3%204%2047%204%201%2043%2023.3%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E");
}
.spine-player-button-icon-pause {
background-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2048%2048%22%3E%3Cdefs%3E%3Cstyle%3E.cls-1%7Bfill%3A%23fff%3B%7D%3C%2Fstyle%3E%3C%2Fdefs%3E%3Ctitle%3Epause%3C%2Ftitle%3E%3Cg%20id%3D%22pause%22%3E%3Crect%20class%3D%22cls-1%22%20x%3D%226%22%20y%3D%221%22%20width%3D%2213%22%20height%3D%2246%22%2F%3E%3Crect%20class%3D%22cls-1%22%20x%3D%2228%22%20y%3D%221%22%20width%3D%2213%22%20height%3D%2246%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E");
}
.spine-player-button-icon-pause:hover {
background-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2048%2048%22%3E%3Cdefs%3E%3Cstyle%3E.cls-1%7Bfill%3A%2362B0EE%3B%7D%3C%2Fstyle%3E%3C%2Fdefs%3E%3Ctitle%3Epause%3C%2Ftitle%3E%3Cg%20id%3D%22pause%22%3E%3Crect%20class%3D%22cls-1%22%20x%3D%226%22%20y%3D%221%22%20width%3D%2213%22%20height%3D%2246%22%2F%3E%3Crect%20class%3D%22cls-1%22%20x%3D%2228%22%20y%3D%221%22%20width%3D%2213%22%20height%3D%2246%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E");
}
.spine-player-button-icon-pause-selected {
background-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2048%2048%22%3E%3Cdefs%3E%3Cstyle%3E.cls-1%7Bfill%3A%2362B0EE%3B%7D%3C%2Fstyle%3E%3C%2Fdefs%3E%3Ctitle%3Epause%3C%2Ftitle%3E%3Cg%20id%3D%22pause%22%3E%3Crect%20class%3D%22cls-1%22%20x%3D%226%22%20y%3D%221%22%20width%3D%2213%22%20height%3D%2246%22%2F%3E%3Crect%20class%3D%22cls-1%22%20x%3D%2228%22%20y%3D%221%22%20width%3D%2213%22%20height%3D%2246%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E");
}
.spine-player-button-icon-speed {
background-image: url("data:image/svg+xml,%3Csvg%20id%3D%22playback%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2048%2048%22%3E%3Cdefs%3E%3Cstyle%3E.cls-1%7Bfill%3A%23fff%3B%7D%3C%2Fstyle%3E%3C%2Fdefs%3E%3Ctitle%3Eplayback%3C%2Ftitle%3E%3Cpath%20class%3D%22cls-1%22%20d%3D%22M48%2C28V20l-4.7-1.18a20.16%2C20.16%2C0%2C0%2C0-2-4.81l2.49-4.15L38.14%2C4.2%2C34%2C6.69a20.16%2C20.16%2C0%2C0%2C0-4.81-2L28%2C0H20L18.82%2C4.7A20.16%2C20.16%2C0%2C0%2C0%2C14%2C6.7L9.86%2C4.2%2C4.2%2C9.86%2C6.69%2C14a20.16%2C20.16%2C0%2C0%2C0-2%2C4.81L0%2C20v8l4.7%2C1.18A20.16%2C20.16%2C0%2C0%2C0%2C6.7%2C34L4.2%2C38.14%2C9.86%2C43.8%2C14%2C41.31a20.16%2C20.16%2C0%2C0%2C0%2C4.81%2C2L20%2C48h8l1.18-4.7a20.16%2C20.16%2C0%2C0%2C0%2C4.81-2l4.15%2C2.49%2C5.66-5.66L41.31%2C34a20.16%2C20.16%2C0%2C0%2C0%2C2-4.81ZM24%2C38A14%2C14%2C0%2C1%2C1%2C38%2C24%2C14%2C14%2C0%2C0%2C1%2C24%2C38Z%22%2F%3E%3Cpolygon%20class%3D%22cls-1%22%20points%3D%2234%2024%2018%2033%2018%2015%2034%2024%2034%2024%22%2F%3E%3C%2Fsvg%3E");
}
.spine-player-button-icon-speed:hover {
background-image: url("data:image/svg+xml,%3Csvg%20id%3D%22playback%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2048%2048%22%3E%3Cdefs%3E%3Cstyle%3E.cls-1%7Bfill%3A%2362B0EE%3B%7D%3C%2Fstyle%3E%3C%2Fdefs%3E%3Ctitle%3Eplayback%3C%2Ftitle%3E%3Cpath%20class%3D%22cls-1%22%20d%3D%22M48%2C28V20l-4.7-1.18a20.16%2C20.16%2C0%2C0%2C0-2-4.81l2.49-4.15L38.14%2C4.2%2C34%2C6.69a20.16%2C20.16%2C0%2C0%2C0-4.81-2L28%2C0H20L18.82%2C4.7A20.16%2C20.16%2C0%2C0%2C0%2C14%2C6.7L9.86%2C4.2%2C4.2%2C9.86%2C6.69%2C14a20.16%2C20.16%2C0%2C0%2C0-2%2C4.81L0%2C20v8l4.7%2C1.18A20.16%2C20.16%2C0%2C0%2C0%2C6.7%2C34L4.2%2C38.14%2C9.86%2C43.8%2C14%2C41.31a20.16%2C20.16%2C0%2C0%2C0%2C4.81%2C2L20%2C48h8l1.18-4.7a20.16%2C20.16%2C0%2C0%2C0%2C4.81-2l4.15%2C2.49%2C5.66-5.66L41.31%2C34a20.16%2C20.16%2C0%2C0%2C0%2C2-4.81ZM24%2C38A14%2C14%2C0%2C1%2C1%2C38%2C24%2C14%2C14%2C0%2C0%2C1%2C24%2C38Z%22%2F%3E%3Cpolygon%20class%3D%22cls-1%22%20points%3D%2234%2024%2018%2033%2018%2015%2034%2024%2034%2024%22%2F%3E%3C%2Fsvg%3E");
}
.spine-player-button-icon-speed-selected {
background-image: url("data:image/svg+xml,%3Csvg%20id%3D%22playback%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2048%2048%22%3E%3Cdefs%3E%3Cstyle%3E.cls-1%7Bfill%3A%2362B0EE%3B%7D%3C%2Fstyle%3E%3C%2Fdefs%3E%3Ctitle%3Eplayback%3C%2Ftitle%3E%3Cpath%20class%3D%22cls-1%22%20d%3D%22M48%2C28V20l-4.7-1.18a20.16%2C20.16%2C0%2C0%2C0-2-4.81l2.49-4.15L38.14%2C4.2%2C34%2C6.69a20.16%2C20.16%2C0%2C0%2C0-4.81-2L28%2C0H20L18.82%2C4.7A20.16%2C20.16%2C0%2C0%2C0%2C14%2C6.7L9.86%2C4.2%2C4.2%2C9.86%2C6.69%2C14a20.16%2C20.16%2C0%2C0%2C0-2%2C4.81L0%2C20v8l4.7%2C1.18A20.16%2C20.16%2C0%2C0%2C0%2C6.7%2C34L4.2%2C38.14%2C9.86%2C43.8%2C14%2C41.31a20.16%2C20.16%2C0%2C0%2C0%2C4.81%2C2L20%2C48h8l1.18-4.7a20.16%2C20.16%2C0%2C0%2C0%2C4.81-2l4.15%2C2.49%2C5.66-5.66L41.31%2C34a20.16%2C20.16%2C0%2C0%2C0%2C2-4.81ZM24%2C38A14%2C14%2C0%2C1%2C1%2C38%2C24%2C14%2C14%2C0%2C0%2C1%2C24%2C38Z%22%2F%3E%3Cpolygon%20class%3D%22cls-1%22%20points%3D%2234%2024%2018%2033%2018%2015%2034%2024%2034%2024%22%2F%3E%3C%2Fsvg%3E");
}
.spine-player-button-icon-animations {
background-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2048%2048%22%3E%3Cdefs%3E%3Cstyle%3E.cls-1%7Bfill%3A%23fff%3B%7D%3C%2Fstyle%3E%3C%2Fdefs%3E%3Ctitle%3Eanimations%3C%2Ftitle%3E%3Cg%20id%3D%22animations%22%3E%3Cpath%20class%3D%22cls-1%22%20d%3D%22M12%2C45V43.22a6.39%2C6.39%2C0%2C0%2C0%2C.63-.81%2C27.83%2C27.83%2C0%2C0%2C1%2C3.79-4.16c.93-.84%2C2.06-1.88%2C2.86-2.71a13.83%2C13.83%2C0%2C0%2C0%2C1.53-1.9l3.9-5.24c1-1.17.95-1.1%2C2.11%2C0l3%2C2.24a4%2C4%2C0%2C0%2C0-2.29%2C2.38c-1.37%2C3-2.39%2C4-2.68%2C4.22l-.23.18c-.54.39-1.81%2C1-1.7%2C1.54l.8%2C1.49a4.5%2C4.5%2C0%2C0%2C1%2C.39%2C1l.57%2C2.15a.69.69%2C0%2C0%2C0%2C.58.48c.47.08%2C1%2C.5%2C1.33.53%2C1.29.1%2C1.79%2C0%2C1.42-.54L26.7%2C42.72a.86.86%2C0%2C0%2C1-.2-.24%2C3.64%2C3.64%2C0%2C0%2C1-.42-2.2A5.39%2C5.39%2C0%2C0%2C1%2C26.61%2C39c1.84-2%2C6.74-6.36%2C6.74-6.36%2C1.71-1.81%2C1.4-2.52.81-3.84a27.38%2C27.38%2C0%2C0%2C0-2-3c-.41-.61-2.08-2.38-2.85-3.28-.43-.5.38-2.08.87-2.82.18-.12-.41.05%2C1.72.07a23.32%2C23.32%2C0%2C0%2C0%2C3.56-.19l1.63.61c.28%2C0%2C1.18-.09%2C1.31-.35l.12-.78c.18-.39.31-1.56-.05-1.75l-.6-.52a2.28%2C2.28%2C0%2C0%2C0-1.61.07l-.2.44c-.14.15-.52.37-.71.29l-2.24%2C0c-.5.12-1.18-.42-1.81-.73L32.05%2C15a8%2C8%2C0%2C0%2C0%2C.8-3.92%2C1.22%2C1.22%2C0%2C0%2C0-.28-.82%2C7.87%2C7.87%2C0%2C0%2C0-1.15-1.06l.11-.73c-.12-.49%2C1-.82%2C1.52-.82l.76-.33c.32%2C0%2C.68-.89.78-1.21L34.94%2C4a11.26%2C11.26%2C0%2C0%2C0%2C0-1.61C34.57.08%2C30.06-1.42%2C28.78%2C2c-.14.38-.62.77.34%2C3.21a1.55%2C1.55%2C0%2C0%2C1-.3%2C1.2L28.4%2C7a4%2C4%2C0%2C0%2C1-1.19.49c-.79%2C0-1.59-.75-4%2C.54C21%2C9.16%2C18.59%2C13%2C17.7%2C14.22a3.21%2C3.21%2C0%2C0%2C0-.61%2C1.58c-.05%2C1.16.7%2C3.74.87%2C5.75.13%2C1.53.21%2C2.52.72%2C3.06%2C1.07%2C1.14%2C2.1-.18%2C2.61-1a2.74%2C2.74%2C0%2C0%2C0-.14-1.86l-.74-.1c-.15-.15-.4-.42-.39-.64-.05-3.48-.22-3.14-.18-5.39%2C1.74-1.46%2C2.4-2.45%2C2.3-2-.2%2C1.15.28%2C2.83.09%2C4.35a6.46%2C6.46%2C0%2C0%2C1-.7%2C2.58s-2.11%2C4.22-2.14%2C4.27l-1.26%2C5.6-.7%2C1.44s-.71.54-1.59%2C1.21a9.67%2C9.67%2C0%2C0%2C0-2.27%2C3.18%2C20.16%2C20.16%2C0%2C0%2C1-1.42%2C2.83l-.87%2C1.31a1.72%2C1.72%2C0%2C0%2C1-.6.61l-1.83%2C1.1a1.39%2C1.39%2C0%2C0%2C0-.16.93l.68%2C1.71a4.07%2C4.07%2C0%2C0%2C1%2C.27%2C1.07l.17%2C1.56a.75.75%2C0%2C0%2C0%2C.71.59%2C18.13%2C18.13%2C0%2C0%2C0%2C3.26-.5c.27-.09-.29-.78-.53-1s-.45-.36-.45-.36A12.78%2C12.78%2C0%2C0%2C1%2C12%2C45Z%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E")
}
.spine-player-button-icon-animations:hover {
background-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2048%2048%22%3E%3Cdefs%3E%3Cstyle%3E.cls-1%7Bfill%3A%2362B0EE%3B%7D%3C%2Fstyle%3E%3C%2Fdefs%3E%3Ctitle%3Eanimations%3C%2Ftitle%3E%3Cg%20id%3D%22animations%22%3E%3Cpath%20class%3D%22cls-1%22%20d%3D%22M12%2C45V43.22a6.39%2C6.39%2C0%2C0%2C0%2C.63-.81%2C27.83%2C27.83%2C0%2C0%2C1%2C3.79-4.16c.93-.84%2C2.06-1.88%2C2.86-2.71a13.83%2C13.83%2C0%2C0%2C0%2C1.53-1.9l3.9-5.24c1-1.17.95-1.1%2C2.11%2C0l3%2C2.24a4%2C4%2C0%2C0%2C0-2.29%2C2.38c-1.37%2C3-2.39%2C4-2.68%2C4.22l-.23.18c-.54.39-1.81%2C1-1.7%2C1.54l.8%2C1.49a4.5%2C4.5%2C0%2C0%2C1%2C.39%2C1l.57%2C2.15a.69.69%2C0%2C0%2C0%2C.58.48c.47.08%2C1%2C.5%2C1.33.53%2C1.29.1%2C1.79%2C0%2C1.42-.54L26.7%2C42.72a.86.86%2C0%2C0%2C1-.2-.24%2C3.64%2C3.64%2C0%2C0%2C1-.42-2.2A5.39%2C5.39%2C0%2C0%2C1%2C26.61%2C39c1.84-2%2C6.74-6.36%2C6.74-6.36%2C1.71-1.81%2C1.4-2.52.81-3.84a27.38%2C27.38%2C0%2C0%2C0-2-3c-.41-.61-2.08-2.38-2.85-3.28-.43-.5.38-2.08.87-2.82.18-.12-.41.05%2C1.72.07a23.32%2C23.32%2C0%2C0%2C0%2C3.56-.19l1.63.61c.28%2C0%2C1.18-.09%2C1.31-.35l.12-.78c.18-.39.31-1.56-.05-1.75l-.6-.52a2.28%2C2.28%2C0%2C0%2C0-1.61.07l-.2.44c-.14.15-.52.37-.71.29l-2.24%2C0c-.5.12-1.18-.42-1.81-.73L32.05%2C15a8%2C8%2C0%2C0%2C0%2C.8-3.92%2C1.22%2C1.22%2C0%2C0%2C0-.28-.82%2C7.87%2C7.87%2C0%2C0%2C0-1.15-1.06l.11-.73c-.12-.49%2C1-.82%2C1.52-.82l.76-.33c.32%2C0%2C.68-.89.78-1.21L34.94%2C4a11.26%2C11.26%2C0%2C0%2C0%2C0-1.61C34.57.08%2C30.06-1.42%2C28.78%2C2c-.14.38-.62.77.34%2C3.21a1.55%2C1.55%2C0%2C0%2C1-.3%2C1.2L28.4%2C7a4%2C4%2C0%2C0%2C1-1.19.49c-.79%2C0-1.59-.75-4%2C.54C21%2C9.16%2C18.59%2C13%2C17.7%2C14.22a3.21%2C3.21%2C0%2C0%2C0-.61%2C1.58c-.05%2C1.16.7%2C3.74.87%2C5.75.13%2C1.53.21%2C2.52.72%2C3.06%2C1.07%2C1.14%2C2.1-.18%2C2.61-1a2.74%2C2.74%2C0%2C0%2C0-.14-1.86l-.74-.1c-.15-.15-.4-.42-.39-.64-.05-3.48-.22-3.14-.18-5.39%2C1.74-1.46%2C2.4-2.45%2C2.3-2-.2%2C1.15.28%2C2.83.09%2C4.35a6.46%2C6.46%2C0%2C0%2C1-.7%2C2.58s-2.11%2C4.22-2.14%2C4.27l-1.26%2C5.6-.7%2C1.44s-.71.54-1.59%2C1.21a9.67%2C9.67%2C0%2C0%2C0-2.27%2C3.18%2C20.16%2C20.16%2C0%2C0%2C1-1.42%2C2.83l-.87%2C1.31a1.72%2C1.72%2C0%2C0%2C1-.6.61l-1.83%2C1.1a1.39%2C1.39%2C0%2C0%2C0-.16.93l.68%2C1.71a4.07%2C4.07%2C0%2C0%2C1%2C.27%2C1.07l.17%2C1.56a.75.75%2C0%2C0%2C0%2C.71.59%2C18.13%2C18.13%2C0%2C0%2C0%2C3.26-.5c.27-.09-.29-.78-.53-1s-.45-.36-.45-.36A12.78%2C12.78%2C0%2C0%2C1%2C12%2C45Z%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E")
}
.spine-player-button-icon-animations-selected {
background-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2048%2048%22%3E%3Cdefs%3E%3Cstyle%3E.cls-1%7Bfill%3A%2362B0EE%3B%7D%3C%2Fstyle%3E%3C%2Fdefs%3E%3Ctitle%3Eanimations%3C%2Ftitle%3E%3Cg%20id%3D%22animations%22%3E%3Cpath%20class%3D%22cls-1%22%20d%3D%22M12%2C45V43.22a6.39%2C6.39%2C0%2C0%2C0%2C.63-.81%2C27.83%2C27.83%2C0%2C0%2C1%2C3.79-4.16c.93-.84%2C2.06-1.88%2C2.86-2.71a13.83%2C13.83%2C0%2C0%2C0%2C1.53-1.9l3.9-5.24c1-1.17.95-1.1%2C2.11%2C0l3%2C2.24a4%2C4%2C0%2C0%2C0-2.29%2C2.38c-1.37%2C3-2.39%2C4-2.68%2C4.22l-.23.18c-.54.39-1.81%2C1-1.7%2C1.54l.8%2C1.49a4.5%2C4.5%2C0%2C0%2C1%2C.39%2C1l.57%2C2.15a.69.69%2C0%2C0%2C0%2C.58.48c.47.08%2C1%2C.5%2C1.33.53%2C1.29.1%2C1.79%2C0%2C1.42-.54L26.7%2C42.72a.86.86%2C0%2C0%2C1-.2-.24%2C3.64%2C3.64%2C0%2C0%2C1-.42-2.2A5.39%2C5.39%2C0%2C0%2C1%2C26.61%2C39c1.84-2%2C6.74-6.36%2C6.74-6.36%2C1.71-1.81%2C1.4-2.52.81-3.84a27.38%2C27.38%2C0%2C0%2C0-2-3c-.41-.61-2.08-2.38-2.85-3.28-.43-.5.38-2.08.87-2.82.18-.12-.41.05%2C1.72.07a23.32%2C23.32%2C0%2C0%2C0%2C3.56-.19l1.63.61c.28%2C0%2C1.18-.09%2C1.31-.35l.12-.78c.18-.39.31-1.56-.05-1.75l-.6-.52a2.28%2C2.28%2C0%2C0%2C0-1.61.07l-.2.44c-.14.15-.52.37-.71.29l-2.24%2C0c-.5.12-1.18-.42-1.81-.73L32.05%2C15a8%2C8%2C0%2C0%2C0%2C.8-3.92%2C1.22%2C1.22%2C0%2C0%2C0-.28-.82%2C7.87%2C7.87%2C0%2C0%2C0-1.15-1.06l.11-.73c-.12-.49%2C1-.82%2C1.52-.82l.76-.33c.32%2C0%2C.68-.89.78-1.21L34.94%2C4a11.26%2C11.26%2C0%2C0%2C0%2C0-1.61C34.57.08%2C30.06-1.42%2C28.78%2C2c-.14.38-.62.77.34%2C3.21a1.55%2C1.55%2C0%2C0%2C1-.3%2C1.2L28.4%2C7a4%2C4%2C0%2C0%2C1-1.19.49c-.79%2C0-1.59-.75-4%2C.54C21%2C9.16%2C18.59%2C13%2C17.7%2C14.22a3.21%2C3.21%2C0%2C0%2C0-.61%2C1.58c-.05%2C1.16.7%2C3.74.87%2C5.75.13%2C1.53.21%2C2.52.72%2C3.06%2C1.07%2C1.14%2C2.1-.18%2C2.61-1a2.74%2C2.74%2C0%2C0%2C0-.14-1.86l-.74-.1c-.15-.15-.4-.42-.39-.64-.05-3.48-.22-3.14-.18-5.39%2C1.74-1.46%2C2.4-2.45%2C2.3-2-.2%2C1.15.28%2C2.83.09%2C4.35a6.46%2C6.46%2C0%2C0%2C1-.7%2C2.58s-2.11%2C4.22-2.14%2C4.27l-1.26%2C5.6-.7%2C1.44s-.71.54-1.59%2C1.21a9.67%2C9.67%2C0%2C0%2C0-2.27%2C3.18%2C20.16%2C20.16%2C0%2C0%2C1-1.42%2C2.83l-.87%2C1.31a1.72%2C1.72%2C0%2C0%2C1-.6.61l-1.83%2C1.1a1.39%2C1.39%2C0%2C0%2C0-.16.93l.68%2C1.71a4.07%2C4.07%2C0%2C0%2C1%2C.27%2C1.07l.17%2C1.56a.75.75%2C0%2C0%2C0%2C.71.59%2C18.13%2C18.13%2C0%2C0%2C0%2C3.26-.5c.27-.09-.29-.78-.53-1s-.45-.36-.45-.36A12.78%2C12.78%2C0%2C0%2C1%2C12%2C45Z%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E")
}
.spine-player-button-icon-skins {
background-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2048%2048%22%3E%3Cdefs%3E%3Cstyle%3E.cls-1%7Bfill%3A%23fff%3B%7D%3C%2Fstyle%3E%3C%2Fdefs%3E%3Ctitle%3Eskins%3C%2Ftitle%3E%3Cg%20id%3D%22skins%22%3E%3Cpath%20class%3D%22cls-1%22%20d%3D%22M36%2C12.54l-6.92%2C1-.79%2C1.2c-1%2C.25-2-.62-3-.55V12.33a1.35%2C1.35%2C0%2C0%2C1%2C.55-1.07c3-2.24%2C3.28-3.75%2C3.28-5.34A5.06%2C5.06%2C0%2C0%2C0%2C24%2C.76c-2.54%2C0-4.38.71-5.49%2C2.13a5.74%2C5.74%2C0%2C0%2C0-.9%2C4.57l2.48-.61a3.17%2C3.17%2C0%2C0%2C1%2C.45-2.4c.6-.75%2C1.75-1.13%2C3.42-1.13%2C2.56%2C0%2C2.56%2C1.24%2C2.56%2C2.56%2C0%2C.92%2C0%2C1.65-2.26%2C3.34a3.92%2C3.92%2C0%2C0%2C0-1.58%2C3.12v1.86c-1-.07-2%2C.8-3%2C.55l-.79-1.2-6.92-1c-2.25%2C0-4.35%2C2.09-5.64%2C3.93L1%2C24c3.83%2C5.11%2C10.22%2C5.11%2C10.22%2C5.11V41.93c0%2C2.34%2C2.68%2C3.88%2C5.59%2C4.86a22.59%2C22.59%2C0%2C0%2C0%2C14.37%2C0c2.91-1%2C5.59-2.52%2C5.59-4.86V29.15S43.17%2C29.15%2C47%2C24l-5.33-7.57C40.38%2C14.63%2C38.27%2C12.54%2C36%2C12.54ZM23.32%2C20.09%2C21%2C17l1.8-.6a3.79%2C3.79%2C0%2C0%2C1%2C2.4%2C0L27%2C17l-2.32%2C3.09A.85.85%2C0%2C0%2C1%2C23.32%2C20.09Z%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E");
width: 31px;
height: 31px;
}
.spine-player-button-icon-skins:hover {
background-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2048%2048%22%3E%3Cdefs%3E%3Cstyle%3E.cls-1%7Bfill%3A%2362B0EE%3B%7D%3C%2Fstyle%3E%3C%2Fdefs%3E%3Ctitle%3Eskins%3C%2Ftitle%3E%3Cg%20id%3D%22skins%22%3E%3Cpath%20class%3D%22cls-1%22%20d%3D%22M36%2C12.54l-6.92%2C1-.79%2C1.2c-1%2C.25-2-.62-3-.55V12.33a1.35%2C1.35%2C0%2C0%2C1%2C.55-1.07c3-2.24%2C3.28-3.75%2C3.28-5.34A5.06%2C5.06%2C0%2C0%2C0%2C24%2C.76c-2.54%2C0-4.38.71-5.49%2C2.13a5.74%2C5.74%2C0%2C0%2C0-.9%2C4.57l2.48-.61a3.17%2C3.17%2C0%2C0%2C1%2C.45-2.4c.6-.75%2C1.75-1.13%2C3.42-1.13%2C2.56%2C0%2C2.56%2C1.24%2C2.56%2C2.56%2C0%2C.92%2C0%2C1.65-2.26%2C3.34a3.92%2C3.92%2C0%2C0%2C0-1.58%2C3.12v1.86c-1-.07-2%2C.8-3%2C.55l-.79-1.2-6.92-1c-2.25%2C0-4.35%2C2.09-5.64%2C3.93L1%2C24c3.83%2C5.11%2C10.22%2C5.11%2C10.22%2C5.11V41.93c0%2C2.34%2C2.68%2C3.88%2C5.59%2C4.86a22.59%2C22.59%2C0%2C0%2C0%2C14.37%2C0c2.91-1%2C5.59-2.52%2C5.59-4.86V29.15S43.17%2C29.15%2C47%2C24l-5.33-7.57C40.38%2C14.63%2C38.27%2C12.54%2C36%2C12.54ZM23.32%2C20.09%2C21%2C17l1.8-.6a3.79%2C3.79%2C0%2C0%2C1%2C2.4%2C0L27%2C17l-2.32%2C3.09A.85.85%2C0%2C0%2C1%2C23.32%2C20.09Z%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E");
}
.spine-player-button-icon-skins-selected {
background-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2048%2048%22%3E%3Cdefs%3E%3Cstyle%3E.cls-1%7Bfill%3A%2362B0EE%3B%7D%3C%2Fstyle%3E%3C%2Fdefs%3E%3Ctitle%3Eskins%3C%2Ftitle%3E%3Cg%20id%3D%22skins%22%3E%3Cpath%20class%3D%22cls-1%22%20d%3D%22M36%2C12.54l-6.92%2C1-.79%2C1.2c-1%2C.25-2-.62-3-.55V12.33a1.35%2C1.35%2C0%2C0%2C1%2C.55-1.07c3-2.24%2C3.28-3.75%2C3.28-5.34A5.06%2C5.06%2C0%2C0%2C0%2C24%2C.76c-2.54%2C0-4.38.71-5.49%2C2.13a5.74%2C5.74%2C0%2C0%2C0-.9%2C4.57l2.48-.61a3.17%2C3.17%2C0%2C0%2C1%2C.45-2.4c.6-.75%2C1.75-1.13%2C3.42-1.13%2C2.56%2C0%2C2.56%2C1.24%2C2.56%2C2.56%2C0%2C.92%2C0%2C1.65-2.26%2C3.34a3.92%2C3.92%2C0%2C0%2C0-1.58%2C3.12v1.86c-1-.07-2%2C.8-3%2C.55l-.79-1.2-6.92-1c-2.25%2C0-4.35%2C2.09-5.64%2C3.93L1%2C24c3.83%2C5.11%2C10.22%2C5.11%2C10.22%2C5.11V41.93c0%2C2.34%2C2.68%2C3.88%2C5.59%2C4.86a22.59%2C22.59%2C0%2C0%2C0%2C14.37%2C0c2.91-1%2C5.59-2.52%2C5.59-4.86V29.15S43.17%2C29.15%2C47%2C24l-5.33-7.57C40.38%2C14.63%2C38.27%2C12.54%2C36%2C12.54ZM23.32%2C20.09%2C21%2C17l1.8-.6a3.79%2C3.79%2C0%2C0%2C1%2C2.4%2C0L27%2C17l-2.32%2C3.09A.85.85%2C0%2C0%2C1%2C23.32%2C20.09Z%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E");
}
.spine-player-button-icon-settings {
background-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2048%2048%22%3E%3Cdefs%3E%3Cstyle%3E.cls-1%7Bfill%3A%23fff%3B%7D%3C%2Fstyle%3E%3C%2Fdefs%3E%3Ctitle%3Esettings%3C%2Ftitle%3E%3Cg%20id%3D%22settings%22%3E%3Cpath%20class%3D%22cls-1%22%20d%3D%22M40%2C3H8A5%2C5%2C0%2C0%2C0%2C3%2C8V40a5%2C5%2C0%2C0%2C0%2C5%2C5H40a5%2C5%2C0%2C0%2C0%2C5-5V8A5%2C5%2C0%2C0%2C0%2C40%2C3ZM16%2C40H9V33h7Zm0-12H9V21h7Zm0-12H9V9h7ZM39%2C38H20V35H39Zm0-12H20V23H39Zm0-12H20V11H39Z%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E");
margin-top: 1px;
}
.spine-player-button-icon-settings:hover {
background-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2048%2048%22%3E%3Cdefs%3E%3Cstyle%3E.cls-1%7Bfill%3A%2362B0EE%3B%7D%3C%2Fstyle%3E%3C%2Fdefs%3E%3Ctitle%3Esettings%3C%2Ftitle%3E%3Cg%20id%3D%22settings%22%3E%3Cpath%20class%3D%22cls-1%22%20d%3D%22M40%2C3H8A5%2C5%2C0%2C0%2C0%2C3%2C8V40a5%2C5%2C0%2C0%2C0%2C5%2C5H40a5%2C5%2C0%2C0%2C0%2C5-5V8A5%2C5%2C0%2C0%2C0%2C40%2C3ZM16%2C40H9V33h7Zm0-12H9V21h7Zm0-12H9V9h7ZM39%2C38H20V35H39Zm0-12H20V23H39Zm0-12H20V11H39Z%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E");
}
.spine-player-button-icon-settings-selected {
background-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2048%2048%22%3E%3Cdefs%3E%3Cstyle%3E.cls-1%7Bfill%3A%2362B0EE%3B%7D%3C%2Fstyle%3E%3C%2Fdefs%3E%3Ctitle%3Esettings%3C%2Ftitle%3E%3Cg%20id%3D%22settings%22%3E%3Cpath%20class%3D%22cls-1%22%20d%3D%22M40%2C3H8A5%2C5%2C0%2C0%2C0%2C3%2C8V40a5%2C5%2C0%2C0%2C0%2C5%2C5H40a5%2C5%2C0%2C0%2C0%2C5-5V8A5%2C5%2C0%2C0%2C0%2C40%2C3ZM16%2C40H9V33h7Zm0-12H9V21h7Zm0-12H9V9h7ZM39%2C38H20V35H39Zm0-12H20V23H39Zm0-12H20V11H39Z%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E");
}
.spine-player-button-icon-fullscreen {
background-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2048%2048%22%3E%3Cdefs%3E%3Cstyle%3E.cls-1%7Bfill%3A%23fff%3B%7D%3C%2Fstyle%3E%3C%2Fdefs%3E%3Ctitle%3Eexpand%3C%2Ftitle%3E%3Cg%20id%3D%22settings%22%3E%3Cpolygon%20class%3D%22cls-1%22%20points%3D%2230.14%208%2040%208%2040%2017.86%2044.5%2017.86%2044.5%203.5%2030.14%203.5%2030.14%208%22%2F%3E%3Cpolygon%20class%3D%22cls-1%22%20points%3D%228%2017.86%208%208%2017.86%208%2017.86%203.5%203.5%203.5%203.5%2017.86%208%2017.86%22%2F%3E%3Cpolygon%20class%3D%22cls-1%22%20points%3D%2240%2030.14%2040%2040%2030.14%2040%2030.14%2044.5%2044.5%2044.5%2044.5%2030.14%2040%2030.14%22%2F%3E%3Cpolygon%20class%3D%22cls-1%22%20points%3D%2217.86%2040%208%2040%208%2030.14%203.5%2030.14%203.5%2044.5%2017.86%2044.5%2017.86%2040%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E");
margin-top: 1px;
}
.spine-player-button-icon-fullscreen:hover {
background-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2048%2048%22%3E%3Cdefs%3E%3Cstyle%3E.cls-1%7Bfill%3A%2362B0EE%3B%7D%3C%2Fstyle%3E%3C%2Fdefs%3E%3Ctitle%3Eexpand%3C%2Ftitle%3E%3Cg%20id%3D%22settings%22%3E%3Cpolygon%20class%3D%22cls-1%22%20points%3D%2230.14%208%2040%208%2040%2017.86%2044.5%2017.86%2044.5%203.5%2030.14%203.5%2030.14%208%22%2F%3E%3Cpolygon%20class%3D%22cls-1%22%20points%3D%228%2017.86%208%208%2017.86%208%2017.86%203.5%203.5%203.5%203.5%2017.86%208%2017.86%22%2F%3E%3Cpolygon%20class%3D%22cls-1%22%20points%3D%2240%2030.14%2040%2040%2030.14%2040%2030.14%2044.5%2044.5%2044.5%2044.5%2030.14%2040%2030.14%22%2F%3E%3Cpolygon%20class%3D%22cls-1%22%20points%3D%2217.86%2040%208%2040%208%2030.14%203.5%2030.14%203.5%2044.5%2017.86%2044.5%2017.86%2040%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E");
}
.spine-player-button-icon-fullscreen-selected {
background-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2048%2048%22%3E%3Cdefs%3E%3Cstyle%3E.cls-1%7Bfill%3A%2362B0EE%3B%7D%3C%2Fstyle%3E%3C%2Fdefs%3E%3Ctitle%3Eexpand%3C%2Ftitle%3E%3Cg%20id%3D%22settings%22%3E%3Cpolygon%20class%3D%22cls-1%22%20points%3D%2230.14%208%2040%208%2040%2017.86%2044.5%2017.86%2044.5%203.5%2030.14%203.5%2030.14%208%22%2F%3E%3Cpolygon%20class%3D%22cls-1%22%20points%3D%228%2017.86%208%208%2017.86%208%2017.86%203.5%203.5%203.5%203.5%2017.86%208%2017.86%22%2F%3E%3Cpolygon%20class%3D%22cls-1%22%20points%3D%2240%2030.14%2040%2040%2030.14%2040%2030.14%2044.5%2044.5%2044.5%2044.5%2030.14%2040%2030.14%22%2F%3E%3Cpolygon%20class%3D%22cls-1%22%20points%3D%2217.86%2040%208%2040%208%2030.14%203.5%2030.14%203.5%2044.5%2017.86%2044.5%2017.86%2040%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E");
}
.spine-player-button-icon-spine-logo {
height: 20px;
position: relative;
top: 1px;
margin: 0 8px !important;
align-self: center;
border: none !important;
width: auto !important;
cursor: pointer;
transition: transform 0.2s;
box-shadow: none !important;
filter: drop-shadow(0 0 1px #333);
}
.spine-player-button-icon-spine-logo:hover {
transform: scale(1.05);
transition: transform 0.2s;
}
/** Speed slider **/
.spine-player-speed-slider {
width: 150px;
}
/** Player editor **/
.spine-player-editor-container {
display: flex;
flex-direction: row;
height: 100%;
width: 100%;
}
.spine-player-editor-code {
flex: 1;
overflow: auto;
}
.spine-player-editor-player {
flex: 1;
border: none;
background: black;
}
.CodeMirror {
height: 100%;
}
+18
View File
@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>昔涟动态立绘</title>
<!-- 空数据图标:否则 CEF 会自动请求 /favicon.ico 并在控制台留下 404 报错 -->
<link rel="icon" href="data:," />
<link rel="stylesheet" href="./styles/spine-player.css" />
<link rel="stylesheet" href="./styles/index.css" />
</head>
<body>
<div id="spine-container"></div>
<audio id="background-music"></audio>
<script type="text/javascript" src="./scripts/spine-player.js"></script>
<script type="module" src="./scripts/index.js"></script>
</body>
</html>
+15586
View File
File diff suppressed because it is too large. Load diff
-109
View File
@@ -1,109 +0,0 @@
// 把差分掩码(立绘层 = 正常帧 − 藏 canvas 参考帧)压成 ASCII,
// 这样在没有图像输入能力的情况下也能"看"清构图。
// 用法:
// node tools/ascii.mjs <normal.png> <ref.png> [cols] 差分两幅,打印强度图 + 实体占比图
// node tools/ascii.mjs <image.png> [cols] 单幅,打印亮度图
// 行数按 2.2:1 的字符高宽比自动换算,保证画面不被压扁。
import { spawn } from "node:child_process";
const flags = process.argv.slice(2).filter((a) => a.startsWith("--"));
const [fileA, fileB, colsArg] = process.argv.slice(2).filter((a) => !a.startsWith("--"));
const solidOnly = flags.includes("--solid");
if (!fileA) {
console.error("usage: node tools/ascii.mjs <image.png> [ref.png] [cols]");
process.exit(2);
}
function probe(file) {
return new Promise((resolve) => {
const p = spawn("ffprobe", ["-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height", "-of", "csv=p=0", file]);
let out = "";
p.stdout.on("data", (d) => (out += d));
p.on("close", () => {
const [w, h] = out.trim().split(",").map(Number);
resolve({ w, h });
});
});
}
function gray(file) {
return new Promise((resolve, reject) => {
const p = spawn("ffmpeg", ["-v", "error", "-i", file, "-f", "rawvideo", "-pix_fmt", "gray", "-"], { stdio: ["ignore", "pipe", "pipe"] });
const chunks = [];
let err = "";
p.stdout.on("data", (d) => chunks.push(d));
p.stderr.on("data", (d) => (err += d));
p.on("close", (code) => (code === 0 ? resolve(Buffer.concat(chunks)) : reject(new Error(err || `exit ${code}`))));
});
}
const RAMP = " .:-=+*#%@";
const { w: W, h: H } = await probe(fileA);
const bufA = await gray(fileA);
if (bufA.length < W * H) {
console.error(`像素不足:${bufA.length} < ${W * H}`);
process.exit(1);
}
// 终端字符高约为宽的 2.2 倍,据此把列数换算成行数,保持画面比例
const cols = Math.max(20, Math.min(Number(colsArg) || 110, Math.floor((46 * 2.2 * W) / H)));
const rows = Math.max(6, Math.round((cols * H) / W / 2.2));
const cw = W / cols;
const chh = H / rows;
function grid(fn) {
const out = [];
for (let r = 0; r < rows; r++) {
let line = "";
for (let c = 0; c < cols; c++) {
const x0 = Math.floor(c * cw);
const x1 = Math.min(W, Math.ceil((c + 1) * cw));
const y0 = Math.floor(r * chh);
const y1 = Math.min(H, Math.ceil((r + 1) * chh));
line += RAMP[Math.max(0, Math.min(9, fn(x0, x1, y0, y1)))];
}
out.push(line);
}
return out;
}
function print(title, lines) {
console.log(title);
for (const l of lines) console.log("|" + l + "|");
console.log("");
}
console.log(`画布 ${W}x${H} → ${cols}列 x ${rows}行,单元 ${cw.toFixed(1)}x${chh.toFixed(1)}px;ramp "${RAMP}"`);
if (!fileB) {
print(`亮度图 ${fileA}`, grid((x0, x1, y0, y1) => {
let s = 0;
let n = 0;
for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) { s += bufA[y * W + x]; n++; }
return Math.round((s / n / 255) * 9);
}));
} else {
const bufB = await gray(fileB);
if (bufB.length < W * H) {
console.error(`参考帧像素不足:${bufB.length} < ${W * H}`);
process.exit(1);
}
const d = new Uint8Array(W * H);
for (let i = 0; i < W * H; i++) d[i] = Math.abs(bufA[i] - bufB[i]);
if (!solidOnly)
print(`立绘层 · 平均强度 (${fileA} − ${fileB})`, grid((x0, x1, y0, y1) => {
let s = 0;
let n = 0;
for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) { s += d[y * W + x]; n++; }
return Math.round((s / n / 255) * 9);
}));
print(`立绘层 · 实体占比 (>96)`, grid((x0, x1, y0, y1) => {
let k = 0;
let n = 0;
for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) { if (d[y * W + x] > 96) k++; n++; }
return Math.round((k / n) * 9);
}));
}
+534
View File
@@ -0,0 +1,534 @@
// pnpm build —— 把所有分发目录编译成"能直接上传 Wallpaper Engine"的自包含作品。
//
// pnpm build 编全部(所有单档 + 所有游戏合集 + 全部合集)
// pnpm build --single xilian [--single kv37] 编指定单档
// pnpm build --collect hsr 编某游戏的合集
// pnpm build --collect all 编全部合集
// pnpm build --single xilian --collect all 组合:只编这两档
//
// --with-sim 顺带把可选的 WE 模拟器放进各分发的 scripts/(服务端调试用)
// --sim 额外产出**双击就能看**的自包含包 <分发根>/sim/index.html(见 lib/bundle.ts)
// --no-embed-audio --sim 时音频不内联(包小很多,但 file:// 下音频播不了)
// --strict 把 warning 升级为 fail
// --no-clean 保留本次未构建的旧分发目录(默认会删掉,保证产物不残留)
// --dry-run 只打印计划,不落盘
//
// 产物拓扑(dist/releases/<dir>/)见 docs/adr/0005;自包含包见 docs/adr/0007。dist/ 完全可再生产,故不入库。
import { readFile, rm } from "node:fs/promises";
import { join } from "node:path";
import {
abs,
copyFileTo,
dirBytes,
exists,
listDirs,
log,
mb,
readJson,
resetDir,
writeJson,
writeText,
} from "./lib/fs.ts";
import { readVault, sharedAudioOf, VaultError, type Vault } from "./lib/vault.ts";
import {
copyCollectionAudio,
copyWallpaperAssets,
generatePresetIndex,
generatePresetModule,
generateProjectJson,
releasePathOf,
type GenerateContext,
} from "./lib/generate.ts";
import type { BuildOptions, DistMap, ProjectTemplate, Release } from "./lib/types.ts";
import { SIMULATOR_DRIVER, SIMULATOR_URL_STATIC } from "./lib/drivers.ts";
import { checkDist } from "./check-dist.ts";
const RELEASES_DIR = "dist/releases";
const HELP = `用法:
pnpm build 编全部(所有单档 + 所有游戏合集 + 全部合集)
pnpm build <壁纸id> […] 编指定壁纸(可多个)
pnpm build single [壁纸id …] 只编单档(不给 id = 全部单档)
pnpm build collect [游戏id …|all] 只编合集(不给参数 = 全部合集)
pnpm build sim [single|collect|<壁纸id> …] 编全部 + 每档产出自包含包(双击即看,不需要 pnpm dev)
--single <壁纸id> 等价于裸 id --collect <游戏id|all> 编某合集
--with-sim 让分发自带 WE 模拟器面板(静态托管预览用,见下) --sim 产出双击可看的自包含包
--no-embed-audio --sim 时音频不内联
--strict 警告即失败 --no-clean 保留旧分发目录 --dry-run 只打印计划
三种产物,别搞混:
pnpm build 干净产物 → 上传 Wallpaper Engine(不含模拟器,ADR 0006 第 1 条)
pnpm build --with-sim 自带面板 → 丢给任意静态托管(GitHub Pages 等),不需要调试服
pnpm build sim 自包含单文件 → 本地双击打开,不需要任何服务器`;
interface BuildCliOptions {
options: BuildOptions;
withSimulator: boolean;
standalone: boolean;
embedAudio: boolean;
}
/**
* 子命令解析。
*
* 三种子命令对应三种**意图**,而不是三个 flag:
* `single` —— 我在调一档壁纸,只要它;
* `collect` —— 我在调一个合集的组装(预设切换、共享音频);
* `sim` —— 我要能双击打开的成品,每档都要一份。
*
* `sim` 后面还能再跟 `single`/`collect` 收窄范围(`pnpm build sim single kv37`)。
* 不带子命令时保持原来的 flag 风格(`--single` / `--collect`),向后兼容。
*/
function parseArgs(argv: string[]): BuildCliOptions {
const options: BuildOptions = { singles: [], collect: null, strict: false, clean: true, dryRun: false };
let withSimulator = false;
let standalone = false;
let embedAudio = true;
// 先摘子命令。`sim` 后面可以再跟 `single`/`collect`/壁纸 id,所以用循环而不是单次判断。
let start = 0;
while (start < argv.length && !(argv[start] ?? "").startsWith("-")) {
const word = argv[start] ?? "";
if (word === "sim") {
standalone = true;
start += 1;
} else if (word === "single") {
// `pnpm build single kv37 xilian`:后面所有非 flag 的词都是壁纸 id
start += 1;
while (start < argv.length && !(argv[start] ?? "").startsWith("-")) {
options.singles.push(argv[start] ?? "");
start += 1;
}
// 不给 id 就是"全部单档",由 planReleases 的显式标记决定
if (options.singles.length === 0) options.onlySingles = true;
} else if (word === "collect") {
start += 1;
const games: string[] = [];
while (start < argv.length && !(argv[start] ?? "").startsWith("-")) {
games.push(argv[start] ?? "");
start += 1;
}
// 无参 = 全部合集(含每个游戏的合集与「全部壁纸合集」)。
// 这里**不能**用 `collect: "all"` 表示"全部合集"——那个值在 planReleases 里只加
// 「全部壁纸合集」一项,会把 collection-hsr 漏掉。用一个显式标记走"逐游戏 + all"的路径。
if (games.length === 0 || (games.length === 1 && games[0] === "all")) options.allCollections = true;
else options.collect = games;
} else {
/**
* 其余裸词一律当壁纸 id(`pnpm build kv37`、`pnpm build sim kv37`)。
*
* 子命令与壁纸 id 共用同一段位置,靠**词表**区分:`single`/`collect`/`sim` 是保留字,
* 别的裸词都是 id。代价是"想编一个恰好叫 single 的壁纸"做不到——接受,
* 因为 id 由我们命名且都是英文小写,真撞名时改 id 比改语法便宜。
* id 不存在时由 vault 报错,那里能顺带列出所有可用 id,比"未知子命令"有用得多。
*/
options.singles.push(word);
start += 1;
}
}
for (let i = start; i < argv.length; i += 1) {
const arg = argv[i] ?? "";
if (arg === "--single") {
const value = argv[++i];
if (!value || value.startsWith("--")) throw new VaultError("--single 需要跟一个壁纸 id");
options.singles.push(value);
} else if (arg === "--collect") {
const value = argv[++i];
if (!value || value.startsWith("--")) throw new VaultError('--collect 需要跟一个游戏 id 或 "all"');
if (value === "all") options.collect = "all";
else options.collect = [...(Array.isArray(options.collect) ? options.collect : []), value];
} else if (arg === "--with-sim") withSimulator = true;
else if (arg === "--sim") standalone = true;
else if (arg === "--no-embed-audio") embedAudio = false;
else if (arg === "--strict") options.strict = true;
else if (arg === "--no-clean") options.clean = false;
else if (arg === "--dry-run") options.dryRun = true;
else if (arg === "--help" || arg === "-h") {
log(HELP);
process.exit(0);
} else if (arg.startsWith("--")) throw new VaultError(`未知参数:${arg}\n\n${HELP}`);
else if (["sim", "single", "collect"].includes(arg)) {
// 子命令写在 --flag 后面时会被当成壁纸 id。报错时直接点破顺序,
// 否则那句"壁纸要写成 --single sim"会把人引到完全错误的方向。
throw new VaultError(
`子命令 ${arg} 必须写在所有 --flag **之前**:\n` +
` pnpm build ${arg} --with-sim ✓\n` +
` pnpm build --with-sim ${arg} ✗(这里的 ${arg} 会被当成壁纸 id)\n\n${HELP}`,
);
} else throw new VaultError(`未知参数:${arg}(壁纸要写成 --single ${arg})\n\n${HELP}`);
}
return { options, withSimulator, standalone, embedAudio };
}
function normalizeGameId(value: string, vault: Vault): string {
const wanted = value.trim().replace(/[:]/g, ":");
const exact = vault.games.find((g) => g.id === wanted);
if (exact) return exact.id;
const byName = vault.games.find((g) => g.meta.name.replace(/[:]/g, ":") === wanted);
if (byName) return byName.id;
throw new VaultError(`找不到游戏 "${value}"。已有:${vault.games.map((g) => `${g.meta.id}(${g.meta.name})`).join("、")}`);
}
/** 按子命令 / --single / --collect 求出本次要编的分发清单。无参 = 全编。 */
function planReleases(vault: Vault, options: BuildOptions): Release[] {
const releases: Release[] = [];
// `single`(无 id)只编单档;`collect`(无参)只编合集;无参才是全编。
// 三者都不能靠"singles 为空 + collect 为 null"来区分,所以各自有显式标记。
const onlySingles = options.onlySingles === true;
const onlyCollections = options.allCollections === true;
const all = !onlySingles && !onlyCollections && options.singles.length === 0 && options.collect === null;
const addSingle = (wallpaperId: string): void => {
const wallpaper = vault.wallpapers.find((w) => w.id === wallpaperId);
if (!wallpaper) {
throw new VaultError(
`找不到壁纸 "${wallpaperId}"。已有:${vault.wallpapers.map((w) => `${w.id}(${w.meta.name})`).join("、")}`,
);
}
releases.push({
type: "single",
id: wallpaper.id,
// 带上游戏:`wallpaper-kv37` 这种名字在分不清来源的场合(多个游戏各有一档壁纸时)
// 只能靠 id 全局唯一来兜底,而目录名本来就该自解释。与 collection-<游戏id> 同一套读法。
dir: `single-${wallpaper.gameId}-${wallpaper.id}`,
wallpapers: [wallpaper],
displayName: `${wallpaper.game?.name ?? wallpaper.gameId} - ${wallpaper.meta.name}`,
meta: {
title: wallpaper.meta.title,
description: wallpaper.meta.description,
...(wallpaper.meta.preview ? { preview: wallpaper.meta.preview } : {}),
...(wallpaper.meta.workshopid ? { workshopid: wallpaper.meta.workshopid } : {}),
...(wallpaper.meta.workshopurl ? { workshopurl: wallpaper.meta.workshopurl } : {}),
},
metaRel: wallpaper.srcRel,
sharedAudio: [],
});
};
const addGame = (gameId: string): void => {
const game = vault.games.find((g) => g.id === gameId);
if (!game) throw new VaultError(`内部错误:找不到游戏 ${gameId}`);
releases.push({
type: "collection",
scope: "game",
id: game.id,
dir: `collection-${game.id}`,
wallpapers: [...game.wallpapers],
displayName: `${game.meta.name}合集`,
meta: {
// 文案取自**游戏级** meta(见 wallpapers/README.md 的三层分工),不回落到全局 title:
// 全局那层是给「全部合集」写的,回落会让 collection-ys 顶着《崩坏:星穹铁道》昔涟。
title: game.meta.title,
description:
game.meta.description ??
`[b]${game.meta.name} 全部壁纸[/b]\r\n[list]\r\n [*]共 ${game.wallpapers.length} 档:${game.wallpapers
.map((w) => w.meta.name)
.join("、")}。\r\n [*]bgm 与音量可在壁纸设置中调整。\r\n[/list]`,
},
metaRel: game.srcRel,
sharedAudio: sharedAudioOf(game),
});
};
const addAll = (): void => {
releases.push({
type: "collection",
scope: "all",
id: "all",
dir: "collection-all",
wallpapers: [...vault.wallpapers],
displayName: vault.global.name,
meta: {
title: vault.global.title,
description: vault.global.description,
...(vault.global.preview ? { preview: vault.global.preview } : {}),
...(vault.global.workshopid ? { workshopid: vault.global.workshopid } : {}),
...(vault.global.workshopurl ? { workshopurl: vault.global.workshopurl } : {}),
},
metaRel: "wallpapers",
sharedAudio: vault.games.flatMap((game) => sharedAudioOf(game)),
});
};
if (all) {
for (const wallpaper of vault.wallpapers) addSingle(wallpaper.id);
for (const game of vault.games) addGame(game.id);
addAll();
return releases;
}
if (onlySingles) {
for (const wallpaper of vault.wallpapers) addSingle(wallpaper.id);
return releases;
}
// `pnpm build collect`(无参)= 每个游戏的合集 + 「全部壁纸合集」。
// 注意 `--collect all` 是另一件事:它只编「全部壁纸合集」一项,保持原语义不变。
if (options.allCollections === true) {
for (const game of vault.games) addGame(game.id);
addAll();
return releases;
}
for (const id of options.singles) addSingle(id);
if (options.collect === "all") addAll();
else if (Array.isArray(options.collect)) for (const raw of options.collect) addGame(normalizeGameId(raw, vault));
return releases;
}
/**
* 把 `index.html` 模板渲染成产物。
*
* 默认**原样输出**:发布产物的 index.html 里不能有任何模拟器痕迹(ADR 0006 第 1 条)——
* 旧实现会在真实 WE 里自我激活并覆盖官方 API,事故级。
*
* `--with-sim` 时才注入模拟器驱动,且用**相对路径** `./scripts/wallpaper-engine.js`。
* 这样整个分发目录是自足的:丢到任何静态托管(GitHub Pages 的 `/<repo>/` 子路径、
* 任意 CDN、甚至 file://)都能弹出设置面板,不需要调试服在场。
*
* 注入点必须在 `<script type="module" src="./scripts/index.js">` **之前**:
* 驱动是经典脚本,要赶在壁纸模块注册 `wallpaperPropertyListener` 之前把钩子备好
* (见 tools/lib/drivers.ts 的时序说明)。
*/
function renderIndexHtml(template: string, release: Release, ctx: GenerateContext): string {
if (!ctx.withSimulator) return template;
const driver = SIMULATOR_DRIVER({
search: "",
dir: release.dir,
title: release.displayName,
version: ctx.version,
defaultPresetId: ctx.defaultPresetId,
preview: release.meta.preview,
simulatorUrl: SIMULATOR_URL_STATIC,
});
const marker = /[ \t]*<script\b[^>]*src=["'][^"']*scripts\/index\.js["'][^>]*><\/script>/i;
if (!marker.test(template)) {
throw new VaultError("src/templates/index.html 里找不到 scripts/index.js 的 <script> 标签,模拟器无法注入");
}
return template.replace(marker, (tag) => `${driver}\n ${tag.trim()}`);
}
async function buildRelease(
release: Release,
ctx: GenerateContext,
options: BuildOptions,
warnings: string[],
): Promise<{ bytes: number; simBytes: number }> {
const outDir = join(ctx.outDir, release.dir);
if (options.dryRun) {
log(` [dry-run] ${release.dir.padEnd(24)} ← ${release.wallpapers.map((w) => w.id).join(", ")}`);
return { bytes: 0, simBytes: 0 };
}
await resetDir(outDir);
// 1) 模板与静态资源
await writeText(join(outDir, "index.html"), renderIndexHtml(await readFile(abs("src/templates/index.html"), "utf8"), release, ctx));
await writeJson(join(outDir, "project.json"), generateProjectJson(release, ctx));
for (const name of ["index.css", "spine-player.css"]) {
await copyFileTo(abs(`src/styles/${name}`), join(outDir, "styles", name));
}
await copyFileTo(abs("src/vendor/spine-player.js"), join(outDir, "scripts", "spine-player.js"));
// 分发级预览图:`preview` 字段指向的是**分发根**下的文件名,所以图源必须在分发自己的元数据目录里
// 找到同名文件。当前只有「所有壁纸合集」有它(wallpapers/preview.gif);其余分发没有,字段就省略。
if (release.meta.preview) {
const name = release.meta.preview.replace(/\\/g, "/").replace(/^.*\//, "");
const source = join(abs(release.metaRel), name);
if (await exists(source)) await copyFileTo(source, join(outDir, name));
else warnings.push(`${release.dir}: project.json 声明了 preview=${release.meta.preview},但 ${release.metaRel}/${name} 不存在`);
}
// 2) 运行时脚本(tsc 已把 src/runtime/*.ts 编到 build/scripts/)
for (const name of [
"index.js",
"preset-controller.js",
"background-controller.js",
"spine-controller.js",
"scene-controller.js",
"audio-controller.js",
"viewport-fitter.js",
]) {
const from = abs(`build/scripts/${name}`);
if (!(await exists(from))) {
throw new VaultError(`缺少 ${from}。请先跑 \`pnpm typecheck\` 之外的编译:tsc -p tsconfig.runtime.json(pnpm build 的 npm script 已包含)`);
}
await copyFileTo(from, join(outDir, "scripts", name));
}
if (ctx.withSimulator) {
const simulator = abs("build/scripts/wallpaper-engine.js");
if (await exists(simulator)) await copyFileTo(simulator, join(outDir, "scripts", "wallpaper-engine.js"));
else warnings.push("--with-sim 已指定,但 build/scripts/wallpaper-engine.js 不存在(先跑 pnpm build:sim)");
}
// 3) 预设索引(每分发现算,见 generatePresetIndex 注释)
await writeText(join(outDir, "scripts", "presets.js"), generatePresetIndex(release, ctx.defaultPresetId));
// 4) 音频落点必须在生成 preset.js **之前**算出来:路径是按实际落点现算的,
// 先写 preset.js 再决定音频放哪儿,会出现"语法正确、指向空气"的产物。
// 落点算法本身只有一份(generate.ts 的 planAudio),这里只负责把文件搬过去。
const isCollection = release.type !== "single";
if (isCollection) {
const placed = await copyCollectionAudio(release, outDir);
if (placed.names.length === 0) warnings.push(`${release.dir}: 合集根 audios/ 为空(各壁纸与游戏都没有声明共享音频)`);
}
// 5) 每档壁纸的 preset.js + 资源
for (const wallpaper of release.wallpapers) {
const sub = releasePathOf(release, wallpaper);
const destDir = sub ? join(outDir, sub) : outDir;
await writeText(join(destDir, "preset.js"), generatePresetModule(release, wallpaper, release.sharedAudio));
await copyWallpaperAssets(wallpaper, destDir, { skipAudio: isCollection });
}
// 6) 可选的**自包含包**:与发布产物并列放在 sim/ 下,互不干扰。
// 它必须在资源全部落地之后才能生成——内联的是刚铺好的那些文件。
let simBytes = 0;
if (options.standalone) {
const { buildSimPage } = await import("./lib/bundle.ts");
const sim = await buildSimPage(release, ctx.defaultPresetId, { embedAudio: options.embedAudio ?? true });
const simDir = join(outDir, "sim");
await writeText(join(simDir, "index.html"), sim.html);
simBytes = await dirBytes(simDir);
log(
` └ sim/index.html ${mb(simBytes)} 内联 ${sim.inlined} 项资源` +
(sim.skippedAudio.length > 0 ? `(${sim.skippedAudio.length} 个音频未内联,file:// 下不可播)` : ""),
);
if (sim.skippedAudio.length > 0) {
warnings.push(`${release.dir}: --no-embed-audio,${sim.skippedAudio.length} 个音频未内联,双击打开时选不到它们`);
}
}
return { bytes: await dirBytesExcludingSim(outDir), simBytes };
}
/**
* 分发体积:**不含** `sim/`。
*
* 自包含包里的图片/音频是 base64 内联,体积与发布产物不在一个量级;把它算进"上传体积"
* 会让人误判发布产物的实际大小。两笔分开报。
*/
async function dirBytesExcludingSim(root: string): Promise<number> {
const { readdir, stat } = await import("node:fs/promises");
let total = 0;
for (const entry of await readdir(root, { withFileTypes: true })) {
if (entry.name === "sim") continue;
const full = join(root, entry.name);
if (entry.isDirectory()) total += await dirBytesExcludingSim(full);
else total += (await stat(full)).size;
}
return total;
}
async function main(): Promise<void> {
const started = Date.now();
const cli = parseArgs(process.argv.slice(2));
const { options, withSimulator, standalone } = cli;
options.standalone = standalone;
options.embedAudio = cli.embedAudio;
const version = (await readFile(abs("VERSION"), "utf8")).trim();
if (!/^\d+$/.test(version)) throw new VaultError(`VERSION 必须是单个整数,当前是 "${version}"`);
const vault = await readVault();
const template = await readJson<ProjectTemplate>(abs("src/project.template.json"));
const releases = planReleases(vault, options);
log(`SpineWallpaper build version=${version} releases=${releases.length}${withSimulator ? " (with simulator)" : ""}${standalone ? " (standalone sim)" : ""}`);
log(` 壁纸:${vault.wallpapers.map((w) => `${w.id}(${w.meta.name})`).join("、")}`);
log(` 游戏:${vault.games.map((g) => `${g.id}(${g.meta.name})`).join("、")}`);
log("");
const warnings: string[] = [];
const wantedDefault = vault.global.defaultPresetId;
if (wantedDefault !== undefined && !vault.wallpapers.some((w) => w.id === wantedDefault)) {
throw new VaultError(
`wallpapers/meta.json 的 defaultPresetId "${wantedDefault}" 不是任何壁纸的 id(已有:${vault.wallpapers
.map((w) => w.id)
.join("、")})`,
);
}
/** 某档分发的默认预设:单档恒等于它自己;合集用全局声明,若该合集里没有就退回第一档。 */
const defaultPresetIdFor = (release: Release): string => {
if (release.type === "single") return release.wallpapers[0]?.id ?? "";
if (wantedDefault && release.wallpapers.some((w) => w.id === wantedDefault)) return wantedDefault;
return release.wallpapers[0]?.id ?? "";
};
const ctx = { template, version, outDir: abs(RELEASES_DIR), withSimulator } satisfies Omit<
GenerateContext,
"defaultPresetId"
>;
const mapEntries: DistMap["releases"] = [];
for (const release of releases) {
const defaultPresetId = defaultPresetIdFor(release);
// 只有一档的合集拿不到 preset 下拉(generateProjectJson 按档数决定),产物行为与单档分发相同。
// 发不发这一档是发布级的判断,构建不替人决定,但必须让人看见——"合集"名不副实是真实的踩坑点。
if (release.type === "collection" && release.wallpapers.length < 2) {
warnings.push(
`${release.dir}: 合集只收 ${release.wallpapers.length} 档壁纸,产物里不会有 preset 下拉(行为等同单档);要么补壁纸,要么不发这一档`,
);
}
const sizes = await buildRelease(release, { ...ctx, defaultPresetId }, options, warnings);
mapEntries.push({
dir: release.dir,
type: release.type,
// 合集范围(单档没有):只有它才区分「游戏合集」与「全部合集」。
...(release.scope ? { scope: release.scope } : {}),
id: release.id,
displayName: release.displayName,
defaultPresetId,
wallpapers: release.wallpapers.map((wallpaper) => ({
id: wallpaper.id,
gameId: wallpaper.gameId,
dir: releasePathOf(release, wallpaper),
name: wallpaper.meta.name,
})),
bytes: sizes.bytes,
...(sizes.simBytes > 0 ? { simBytes: sizes.simBytes } : {}),
});
log(` ✓ ${release.dir.padEnd(24)} ${release.wallpapers.length} 档壁纸 ${mb(sizes.bytes)}`);
}
if (!options.dryRun) {
if (options.clean) {
const built = new Set(mapEntries.map((e) => e.dir));
for (const dir of await listDirs(abs(RELEASES_DIR))) {
if (!built.has(dir)) {
await rm(join(abs(RELEASES_DIR), dir), { recursive: true, force: true });
log(` − 删除孤立的旧分发目录 ${dir}`);
}
}
} else {
for (const dir of await listDirs(abs(RELEASES_DIR))) {
if (!mapEntries.some((e) => e.dir === dir)) {
warnings.push(`dist/releases/${dir} 不是本次构建的产物(--no-clean),dist-map.json 里没有它`);
}
}
}
const map: DistMap = { version, builtAt: new Date().toISOString(), releases: mapEntries };
await writeJson(abs("dist/dist-map.json"), map);
await checkDist({ strict: options.strict });
}
log("");
for (const warning of warnings) log(` ⚠ ${warning}`);
if (options.strict && warnings.length > 0) throw new VaultError(`--strict:出现 ${warnings.length} 条警告,视为失败`);
log(`完成,用时 ${((Date.now() - started) / 1000).toFixed(1)}s`);
}
try {
await main();
} catch (error) {
if (error instanceof VaultError) {
console.error(`\n构建失败:${error.message}\n`);
process.exit(1);
}
throw error;
}
+39
View File
@@ -0,0 +1,39 @@
// `tools/cdp.mjs` 的类型声明。
//
// 它是纯 JS,以前没有任何声明——于是每个 import 它的验收脚本都退化成 `any`,
// 连带 `withPage(..., ({ send, evaluate }) => ...)` 的解构参数全部是隐式 any。
// 一个 20 行的声明就能把那一整类错误消掉(实测 180 处里占 41 处)。
//
// 这里刻意用 `any` 而不是 `unknown` 作为 evaluate/send 的返回值:它们直通 CDP,
// 返回什么完全取决于传进去的表达式字符串,静态上无从约束——写成 `unknown` 只会逼每个
// 调用点加断言,反而把噪音搬了个地方。
export interface PageOptions {
width?: number;
height?: number;
/** Edge 的 user-data-dir;给不同脚本各用一个,免得并发时互相踢掉。 */
profile?: string;
extraArgs?: string[];
}
/** 一个已连上的页面。 */
export interface Page {
/** 发一条 CDP 命令。 */
send(method: string, params?: Record<string, unknown>): Promise<any>;
/** 在页面里求值并取回值。表达式里的异常会抛出来。 */
evaluate(expression: string): Promise<any>;
/** 页面控制台输出(含 Log.entryAdded 的 error)。 */
consoleLines: string[];
/** 未捕获异常。 */
exceptions: string[];
/** Edge 的 stderr,排查启动失败时用。 */
stderr(): string;
}
/**
* 起一个无头 Edge、连上它、执行 `fn`,结束后收掉浏览器。
* `fn` 的返回值原样返回。
*/
export function withPage<T>(opts: PageOptions, fn: (page: Page) => Promise<T>): Promise<T>;
export function sleep(ms: number): Promise<void>;
+332
View File
@@ -0,0 +1,332 @@
// pnpm check:dist —— 分发目录的自我包含性门禁。
//
// A2 的要求是"分发目录应避免跨目录相对链接与绝对链接"(它能被整体拷到别的机器上传)。
// 这里把它变成硬门禁:扫描每个分发目录里的 HTML/CSS/JS,把每一处**静态可解析**的引用
// 解析出来,凡是落到分发目录之外的、或写成根绝对路径(/…)与 file:/// 的,一律 fail。
//
// 不做的事:不去猜 `asset()` 这类运行时推导(那是生成器的责任,生成器只吐相对路径)。
import { readFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { abs, exists, listDirs, log, readJson, walkFiles } from "./lib/fs.ts";
import { VaultError } from "./lib/vault.ts";
import type { DistMap } from "./lib/types.ts";
export interface CheckResult {
releases: number;
files: number;
references: number;
problems: string[];
warnings: string[];
}
/** 允许出现的非本地 URL 协议与内联数据。 */
const ALLOWED_PROTOCOL = /^(data:|blob:|mailto:|https?:\/\/|steam:|#)/i;
interface Reference {
/** 引用写下的原始文本。 */
raw: string;
/** 引用的文件(相对分发根)。 */
file: string;
/** 行号(1 起)。 */
line: number;
}
/** 从一份文本里抽出所有静态可解析的本地引用。 */
function extractReferences(file: string, text: string): Reference[] {
const out: Reference[] = [];
const push = (raw: string, line: number): void => {
if (!raw) return;
const normalized = raw.trim();
if (ALLOWED_PROTOCOL.test(normalized)) return;
out.push({ raw: normalized, file, line });
};
text.split(/\r?\n/).forEach((line, index) => {
const lineNo = index + 1;
const isCss = /\.css$/i.test(file);
// HTML/JS 里的字符串形态:src="/x"、href='y'、from "./z"、import("w")、fetch("v")
for (const match of line.matchAll(/(?:src|href|poster)\s*=\s*["']([^"']+)["']/g)) push(match[1] ?? "", lineNo);
for (const match of line.matchAll(/(?:from|import|fetch)\s*\(?\s*["']([^"']+)["']/g)) push(match[1] ?? "", lineNo);
// url(...) 只在 CSS 里是路径;JS 里 `url("${x}")` 是模板字符串,不是静态引用
if (isCss) {
for (const match of line.matchAll(/url\(\s*["']?([^"')]+)["']?\s*\)/g)) push(match[1] ?? "", lineNo);
for (const match of line.matchAll(/@import\s+["']([^"']+)["']/g)) push(match[1] ?? "", lineNo);
}
});
return out;
}
/** 解析一个引用:返回它相对分发根的路径,或一个错误原因。 */
function resolveReference(raw: string, file: string): { rel: string } | { error: string } {
if (raw.startsWith("file:///")) return { error: `绝对链接 file:///(分发目录必须可整体搬走)` };
if (raw.startsWith("//")) return { error: `协议相对链接 //(依赖宿主,跨机器行为不定)` };
if (raw.startsWith("/")) return { error: `根绝对路径 /(脱离分发根就没有意义)` };
const clean = raw.split(/[?#]/)[0] ?? "";
const parts = (file.includes("/") ? file.replace(/\/[^/]*$/, "/") : "") + clean;
const segments: string[] = [];
for (const segment of parts.split("/")) {
if (segment === "" || segment === ".") continue;
if (segment === "..") {
if (segments.length === 0) return { error: `相对路径越出分发目录(${raw})` };
segments.pop();
continue;
}
segments.push(segment);
}
return { rel: segments.join("/") };
}
/** 从 preset.js 里抽出所有声明的音频落点(相对该 preset.js 所在目录)。 */
function extractAudioSources(text: string): { raw: string; line: number }[] {
const out: { raw: string; line: number }[] = [];
text.split(/\r?\n/).forEach((line, index) => {
for (const match of line.matchAll(/source:\s*asset\(\s*"([^"]*)"\s*\)/g)) {
out.push({ raw: match[1] ?? "", line: index + 1 });
}
});
return out;
}
/**
* 让 V8 真的解析一个产物模块,只求语法通过、不执行。
*
* 为什么非要有这一步:`presets.js` 与 `preset.js` 是**生成器拼出来的裸 JS 文本**,
* tsc 完全不看它们。生成器写出非法语法时,前面所有检查(引用扫描、路径存在性)都会通过,
* 直到浏览器里爆 SyntaxError 白屏——实测过一次真事故(数组字面量里写了计算属性名)。
*
* 用 vm.SourceTextModule 而不是 dynamic import:后者会**执行**模块,而壁纸脚本依赖 DOM。
* 用子进程是因为这个 API 需要 --experimental-vm-modules(V8 的解析器只暴露到这里)。
*/
async function checkModuleSyntax(files: { path: string; text: string }[]): Promise<string[]> {
const { spawnSync } = await import("node:child_process");
const script = `
const vm = require("node:vm");
const payload = JSON.parse(require("node:fs").readFileSync(0, "utf8"));
const problems = [];
for (const item of payload) {
try {
new vm.SourceTextModule(item.text, { identifier: item.path });
} catch (error) {
problems.push(item.path + " " + String(error.message).split("\\n")[0]);
}
}
process.stdout.write(JSON.stringify(problems));
`;
const child = spawnSync(process.execPath, ["--experimental-vm-modules", "--no-warnings", "-e", script], {
input: JSON.stringify(files),
encoding: "utf8",
});
if (child.status !== 0) {
return [`无法运行语法解析子进程:${(child.stderr || "").trim().split("\n").slice(-3).join(" ")}`];
}
try {
return JSON.parse(child.stdout) as string[];
} catch {
return [`语法解析子进程输出无法解析:${child.stdout.slice(0, 200)}`];
}
}
export async function checkDist(options: { strict?: boolean; quiet?: boolean } = {}): Promise<CheckResult> {
const releasesDir = abs("dist/releases");
const mapPath = abs("dist/dist-map.json");
const result: CheckResult = { releases: 0, files: 0, references: 0, problems: [], warnings: [] };
if (!(await exists(mapPath))) {
result.problems.push("dist/dist-map.json 不存在(先跑 pnpm build)");
if (!options.quiet) report(result);
return result;
}
const map = await readJson<DistMap>(mapPath);
const modules: { path: string; text: string }[] = [];
for (const dir of await listDirs(releasesDir)) {
const root = join(releasesDir, dir);
if (!map.releases.some((r) => r.dir === dir)) {
result.problems.push(`dist/releases/${dir} 不在 dist-map.json 里(残留目录)`);
continue;
}
result.releases += 1;
const entry = map.releases.find((r) => r.dir === dir);
if (entry) {
// project.json 的静态字段必须指向目录内真实存在的文件
const project = await readJson<{ preview?: string; file?: string }>(join(root, "project.json"));
const fileField = project.file ?? "index.html";
if (!(await exists(join(root, fileField)))) result.problems.push(`${dir}/project.json 的 file 指向不存在的 ${fileField}`);
if (project.preview !== undefined && !(await exists(join(root, project.preview)))) {
result.problems.push(`${dir}/project.json 的 preview 指向不存在的 ${project.preview}`);
}
if (entry.defaultPresetId.length === 0) result.problems.push(`${dir}: dist-map 里没有 defaultPresetId`);
// 运行时脚本必须齐全(少了任何一个都会在 WE 里静默白屏)
for (const name of [
"scripts/index.js",
"scripts/presets.js",
"scripts/spine-player.js",
"scripts/preset-controller.js",
"scripts/background-controller.js",
"scripts/spine-controller.js",
"scripts/audio-controller.js",
"scripts/viewport-fitter.js",
"styles/index.css",
"styles/spine-player.css",
"index.html",
]) {
if (!(await exists(join(root, name)))) result.problems.push(`${dir}: 缺少 ${name}`);
}
// 调试服的热更新客户端**绝不能**进产物:它连的是 /__dev/events,那在真实 WE 里、
// 在任何静态托管上都不存在,只会白挨一次连接失败。这类"调试期专有代码混进发布产物"
// 靠人工抽查迟早会漏,所以钉在门禁上。
const indexText = await readFile(join(root, "index.html"), "utf8");
for (const marker of ["/__dev/events", "dev-reload-pill"]) {
if (indexText.includes(marker)) {
result.problems.push(`${dir}/index.html 里有调试服专属的 ${marker}`);
}
}
// 每档壁纸的 preset.js 必须与 dist-map 声明一致
for (const wallpaper of entry.wallpapers) {
const presetPath = wallpaper.dir ? `${wallpaper.dir}/preset.js` : "preset.js";
if (!(await exists(join(root, presetPath)))) result.problems.push(`${dir}: 缺少 ${presetPath}`);
}
const presetModules = (await walkFiles(root)).filter((f) => /(^|\/)preset\.js$/.test(f));
if (presetModules.length !== entry.wallpapers.length) {
result.problems.push(
`${dir}: preset.js 数量(${presetModules.length}) 与 dist-map 声明的壁纸数(${entry.wallpapers.length}) 不一致`,
);
}
// 语义校验:preset.js 里声明的每个音频落点都必须真实存在。
//
// 静态引用扫描(上面那圈)抓不到这类缺陷:音频走的是 `asset("../audios/x.flac")`,
// 参数是字面量,但 `asset` 的基准是 preset.js 自己的目录,只有按 preset.js 的位置解析才
// 知道对不对。合集分发把音频搬到分发根,前缀算错一层就会得到一个"语法正确、路径错误"的
// preset.js —— 它会一路通过编译、通过引用扫描,然后在 WE 里静音。
for (const presetRel of presetModules) {
const presetText = await readFile(join(root, presetRel), "utf8");
for (const audio of extractAudioSources(presetText)) {
if (audio.raw === "") continue; // 没有默认音源时是空串,跳过
const resolved = resolveReference(audio.raw, presetRel);
if ("error" in resolved) {
result.problems.push(`${dir}/${presetRel}:${audio.line} 音频 ${resolved.error}`);
continue;
}
if (!(await exists(join(root, resolved.rel)))) {
result.problems.push(`${dir}/${presetRel}:${audio.line} 音频引用不存在的 ${audio.raw}`);
}
}
}
// 单档分发不该出现共享音频目录(音频本来就只属于它一个)。
if (entry.type === "single" && (await exists(join(root, "audios", "_shared")))) {
result.problems.push(`${dir}: 单档分发里不该有 audios/_shared`);
}
// 合集类分发必须有合集根 audios/(bgm 要能选到任一壁纸的音源)——
// 但**整份分发一个音源都没有**时不该要求它:那只会逼出一个空的 audios/ 目录
// (比如纯场景壁纸、音源还没抓的页面)。判据是 preset.js 里有没有音频落点。
const hasAudio = (
await Promise.all(
presetModules.map(async (presetRel) => /[/\\]audios[/\\]/.test(await readFile(join(root, presetRel), "utf8"))),
)
).some(Boolean);
if (entry.type !== "single" && hasAudio && !(await exists(join(root, "audios")))) {
result.problems.push(`${dir}: 合集分发缺少合集根 audios/`);
}
// 自包含包(`pnpm build sim` 产出):它必须在 `file://` 下**双击就能开**,
// 所以这里查的是"页面上还有没有任何外部依赖"。
//
// 只在**真正的标签与赋值**上判定,不在整页文本上做正则——spine-player 是整份内联进来的,
// 它内部带着官网文档链接与一段编辑器示例模板(里面有 `<script src="https://…">` 的字符串)。
// 第一版对整页扫 `src=`/`href=`,于是把那些字符串当成真依赖,四个分发全报假阳性。
const simPage = join(root, "sim", "index.html");
if (await exists(simPage)) {
const simText = await readFile(simPage, "utf8");
const simProblems: string[] = [];
// 先剥掉内联脚本的**内容**,只在剩下的 HTML 骨架里找标签。
//
// 必须这么做:spine-player 是整份内联进来的,它内部带着一段编辑器示例模板,
// 里面有 `<script src="https://…">` 这样的字符串。对整页正则扫标签会把它们当成真依赖,
// 四个分发全报假阳性(实测过)。
//
// 关键细节:**只删标签之间的内容,保留开标签本身**。
// 第一版写成 `/<script[^>]*>[\s\S]*?<\/script>/` 整体替换,把开标签也一起删了,
// 于是所有 `<script src=…>` 都不再被检查——门禁看着在跑,实际只能查到 `<link>`。
// 是"注入一个外链 script 看它拦不拦得住"这个测试把这个漏洞暴露出来的。
const skeleton = simText.replace(
/<(script)\b([^>]*)>[\s\S]*?<\/script>/gi,
(_match, name: string, attrs: string) => `<${name}${attrs}></${name}>`,
);
const tags = skeleton.match(/<(?:script|link)\b[^>]*>/gi) ?? [];
for (const tag of tags) {
if (/\btype\s*=\s*["']module["']/i.test(tag)) {
simProblems.push(`<script type="module">(file:// 下不会加载)`);
}
for (const match of tag.matchAll(/\b(?:src|href)\s*=\s*["']([^"']+)["']/gi)) {
const raw = match[1] ?? "";
if (/^https?:\/\//i.test(raw)) simProblems.push(`标签引用了 http(s) 资源 ${raw}`);
else if (raw.startsWith("file:///")) simProblems.push(`标签引用了绝对路径 ${raw}`);
else if (raw.startsWith("/")) simProblems.push(`标签引用了根绝对路径 ${raw}`);
}
}
if (simProblems.length > 0) {
result.problems.push(`${dir}/sim/index.html: ${[...new Set(simProblems)].slice(0, 3).join(";")}`);
}
}
}
for (const rel of await walkFiles(root)) {
if (!/\.(html|css|js|mjs|json)$/i.test(rel)) continue;
result.files += 1;
const text = await readFile(join(root, rel), "utf8");
if (/\.(js|mjs)$/i.test(rel)) modules.push({ path: `${dir}/${rel}`, text });
for (const reference of extractReferences(rel, text)) {
result.references += 1;
const resolved = resolveReference(reference.raw, reference.file);
if ("error" in resolved) {
result.problems.push(`${dir}/${reference.file}:${reference.line} ${resolved.error}`);
continue;
}
if (!(await exists(join(root, resolved.rel)))) {
// 生成器只保证字面路径可解析;这里对"解析后不存在"的文件报错。
result.problems.push(`${dir}/${reference.file}:${reference.line} 引用不存在的 ${reference.raw}`);
}
}
}
}
// 语法门禁:所有产物 JS 必须能被 V8 解析成模块。
for (const problem of await checkModuleSyntax(modules)) result.problems.push(problem);
if (!options.quiet) report(result);
return result;
}
function report(result: CheckResult): void {
log(
`check:dist ${result.releases} 个分发 / ${result.files} 个文本文件 / ${result.references} 处引用 ` +
`${result.problems.length === 0 ? "✓ 自包含" : `✗ ${result.problems.length} 处问题`}`,
);
for (const problem of result.problems.slice(0, 30)) log(` ✗ ${problem}`);
if (result.problems.length > 30) log(` … 另有 ${result.problems.length - 30} 处`);
for (const warning of result.warnings) log(` ⚠ ${warning}`);
}
// 只在被当作入口直接运行时才自检——build.ts 会 import 它,那时不能执行这里的退出逻辑。
if (process.argv[1] !== undefined && resolve(process.argv[1]) === abs("tools/check-dist.ts")) {
const strict = process.argv.includes("--strict");
const result = await checkDist({ strict });
if (result.problems.length > 0) {
log("");
throw new VaultError(`自包含性校验未通过(${result.problems.length} 处)`);
}
log("");
log("自包含性校验通过:每个分发目录都可以整体拷到别的机器上传。");
}
-49
View File
@@ -1,49 +0,0 @@
// 静态检查:每份 preset.js 里声明的每一个资源路径是否真实存在。
// 这能抓到"运行时才会暴露"的拼写错误(比如某首备用 BGM 从未被加载过,写错了也不会有人发现)。
// 用法:node tools/check-paths.mjs
import { stat } from "node:fs/promises";
import { fileURLToPath, pathToFileURL } from "node:url";
import { glob } from "node:fs/promises";
import { resolve } from "node:path";
const root = resolve("dist");
const presetFiles = [];
for await (const f of glob("assets/**/preset.js", { cwd: root })) presetFiles.push(f);
let ok = 0;
let bad = 0;
for (const rel of presetFiles.sort()) {
const mod = await import(pathToFileURL(resolve(root, rel)).href);
const preset = mod.default;
const urls = [];
const walk = (value, path) => {
if (typeof value === "string" && /^(file|https?):/.test(value)) urls.push([path, value]);
else if (value && typeof value === "object") {
for (const [k, v] of Object.entries(value)) walk(v, `${path}.${k}`);
} else if (Array.isArray(value)) value.forEach((v, i) => walk(v, `${path}[${i}]`));
};
walk(preset, preset.id);
console.log(`\n${rel} (id=${preset.id}, name=${preset.name})`);
for (const [path, url] of urls) {
const u = new URL(url);
if (u.protocol !== "file:") {
console.log(` ? ${path} -> 非本地路径,跳过: ${url}`);
continue;
}
const file = fileURLToPath(u);
const info = await stat(file).catch(() => null);
if (info?.isFile()) {
console.log(` ok ${path} (${(info.size / 1024).toFixed(1)} KB)`);
ok++;
} else {
console.log(` MISSING ${path} -> ${file}`);
bad++;
}
}
}
console.log(`\n${ok} 个路径存在,${bad} 个缺失`);
process.exit(bad ? 1 : 0);
+89
View File
@@ -0,0 +1,89 @@
// pnpm check:paths —— 源数据的**孤儿文件**门禁(构建前跑,只看 wallpapers/,不看 dist)。
//
// 与 check:dist 的分工:
// check:paths 源数据自洽:有没有磁盘上存在、但没有任何 meta.json 声明的资源?
// check:dist 产物自洽:dist/releases/ 里每个引用是否真的落地、是否越界、JS 语法是否合法。
//
// 为什么单独立这一条:音频文件名**保留中文**(产品要求),而 id 是 build 自增分配的,
// 两者靠 meta.json 的 choices 显式绑定——这正是"地图漏画一格"最容易发生的地方。一个没被声明的 .flac 会安安静静
// 躺在仓库里:类型检查过、构建过、产物自包含,只是那首歌永远选不到,而且没人会发现。
// 依赖删除同理:改动后残留的 `foo-旧版.flac` 会一直跟着构建进分发,白白撑大上传体积。
//
// 用法:node tools/check-paths.ts
import { join, resolve } from "node:path";
import { abs, isDir, log, walkFiles } from "./lib/fs.ts";
import { readVault, VaultError } from "./lib/vault.ts";
import { baseName } from "./lib/generate.ts";
const problems: string[] = [];
let audioChecked = 0;
/** 反查一个目录下的音频文件是否都被 `choices` 认领。 */
async function checkOrphans(opts: {
dirRel: string;
choices: { file: string }[];
where: string;
/** 音频目录相对声明者所在目录(壁纸就填 "audios")。 */
subdir: string;
}): Promise<void> {
const declared = new Set(opts.choices.map((c) => baseName(c.file)));
audioChecked += declared.size;
const dirAbs = abs(join(opts.dirRel, opts.subdir));
if (!(await isDir(dirAbs))) {
if (declared.size > 0) problems.push(`${opts.where}: 声明了 ${declared.size} 个音源,但 ${join(opts.dirRel, opts.subdir)} 目录不存在`);
return;
}
for (const name of await walkFiles(dirAbs)) {
// 允许子目录嵌套:只比较相对路径末段,与实际取用方式一致。
const leaf = name.split("/").pop() ?? name;
if (declared.has(leaf)) continue;
problems.push(
`${join(opts.dirRel, opts.subdir, name)} 没有被 ${opts.where} 声明——` +
`它是孤儿文件:既不会被 WE 的 bgm 下拉选中,也不会进任何分发的音频清单。` +
`删掉它,或者把它加进 meta.json。`,
);
}
}
async function main(): Promise<void> {
const vault = await readVault();
// 1) 每档壁纸自己的 audios/
for (const wallpaper of vault.wallpapers) {
await checkOrphans({
dirRel: wallpaper.srcRel,
choices: wallpaper.meta.audio.choices,
where: `${wallpaper.srcRel}/meta.json`,
subdir: "audios",
});
}
// 2) 每个游戏的共享音频(wallpapers/<游戏id>/audios/),只被合集分发使用
for (const game of vault.games) {
await checkOrphans({
dirRel: game.srcRel,
choices: game.meta.audios ?? [],
where: `${game.srcRel}/meta.json 的 audios`,
subdir: "audios",
});
}
// 3) 分发级预览图(wallpapers/preview.gif,被所有分发共用)
if (vault.global.preview !== undefined) {
const previewRel = join("wallpapers", baseName(vault.global.preview));
if (await isDir(abs(previewRel))) problems.push(`wallpapers/meta.json 的 preview "${vault.global.preview}" 指向的是一个目录`);
}
if (problems.length > 0) {
log(`check:paths ✗ ${problems.length} 处问题(已核对 ${audioChecked} 条音源声明)`);
for (const problem of problems) log(` ✗ ${problem}`);
throw new VaultError(`源数据里有 ${problems.length} 处音频声明问题`);
}
log(`check:paths ✓ ${vault.wallpapers.length} 档壁纸 / ${vault.games.length} 个游戏,${audioChecked} 条音源声明无孤儿文件`);
}
// 只在被当作入口直接运行时才自检(被 import 时不该有副作用)。
if (process.argv[1] !== undefined && resolve(process.argv[1]) === abs("tools/check-paths.ts")) {
await main();
}
+73
View File
@@ -0,0 +1,73 @@
import { readFile } from "node:fs/promises";
import { abs, log, walkFiles } from "./lib/fs.ts";
import { VaultError } from "./lib/vault.ts";
// pnpm check:syntax —— 把"源码必须 strip-safe"从约定升级成门禁。
//
// Node 26 能直接跑 .ts,但它只做**类型擦除**:enum、构造函数参数属性、namespace 会以
// ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX 崩掉(已实测)。这类语法在 tsc 下完全合法,所以只有真实加载
// 才发现得了——而那通常是 `pnpm dev` 已经跑起来之后。这里做一次静态扫描,提前挡在门口。
//
// 刻意**不做**动态 import 验证:那些入口脚本一被执行就有副作用(起服务、写 dist)。
const BANNED: { pattern: RegExp; what: string; why: string }[] = [
{
pattern: /^[ \t]*(?:export[ \t]+)?(?:const[ \t]+)?enum[ \t]/m,
what: "enum",
why: "类型擦除模式下没有运行时实现,Node 直接报错",
},
{
pattern: /^[ \t]*(?:export[ \t]+)?(?:declare[ \t]+)?namespace[ \t]/m,
what: "namespace",
why: "同上,且它是过时的模块写法",
},
{
pattern: /constructor[ \t]*\([^)]*\b(?:public|private|protected|readonly)[ \t]+\w+/s,
what: "构造函数参数属性(constructor(public x: T))",
why: "需要编译器注入赋值语句,擦除模式做不到",
},
{ pattern: /\bimport[ \t]+\w+[ \t]*=[ \t]*require[ \t]*\(/, what: "import x = require()", why: "TS 特有语法,非 ESM" },
];
/** 扫描范围:所有会被 Node 直接执行的 .ts(运行时脚本经 tsc 编译,不受此限制)。 */
async function sourceFiles(): Promise<string[]> {
const files: string[] = [];
for (const dir of ["tools", "src/simulator"]) {
for (const file of await walkFiles(abs(dir))) {
if (file.endsWith(".ts")) files.push(`${dir}/${file}`);
}
}
return files.sort();
}
/**
* 先剥掉注释与字符串/模板字面量再匹配。
*
* 这不是洁癖:本文件自己就用正则写着这些被禁语法的样子,若直接扫全文会自我举报。
* 同理,任何文档或消息文本里出现 "enum " 也不该被当成代码。
*/
function stripCommentsAndLiterals(text: string): string {
return text
.replace(/\/\*[\s\S]*?\*\//g, " ")
.replace(/\/\/[^\n]*/g, "")
.replace(/`(?:\\[\s\S]|[^`\\])*`/g, '""')
.replace(/'(?:\\[\s\S]|[^'\\\n])*'/g, '""')
.replace(/"(?:\\[\s\S]|[^"\\\n])*"/g, '""');
}
const problems: string[] = [];
for (const relPath of await sourceFiles()) {
const text = stripCommentsAndLiterals(await readFile(abs(relPath), "utf8"));
for (const { pattern, what, why } of BANNED) {
const match = pattern.exec(text);
if (!match) continue;
problems.push(`${relPath} 用了 ${what} —— ${why}`);
}
}
if (problems.length > 0) {
log("check:syntax ✗");
for (const problem of problems) log(` ✗ ${problem}`);
throw new VaultError(`源码里有 ${problems.length} 处 Node 类型擦除不支持的语法`);
}
log("check:syntax ✓ 所有会被 Node 直接执行的 .ts 都是 strip-safe");
+100
View File
@@ -0,0 +1,100 @@
# `tools/checks/` — 验收套件
跑一遍就相当于"这次改动没把东西弄坏"的全部证据。它原来是散在 `.scratch/` 里的,
而 `.scratch/` 按 `AGENTS.md` 是**问题与规格**的存放处——这些脚本不是某条 issue 的产物,
issue 关掉之后还要一直跑,所以搬到了这里。
---
## 怎么跑
它们的前置条件不一样,**不能一条命令全跑完**。按下面三组来:
### ① 不需要服务器
```bash
pnpm check # 类型 + 构建 + 语法 + 路径 + 产物自包含 + 游戏合集文案归属
pnpm build sim # 下面几项要 sim 产物
node tools/checks/test-sim-gate.mts # 门禁真的会拦(往页面注入四种外部依赖)
node tools/checks/test-inline-guard.mts # 内联 </script 守卫真的会拦
node tools/checks/test-transform-module.mts # 模块合成的 export 改写
node tools/checks/diff-project.mts # 产物 project.json vs 已发布基线
node tools/checks/check-collection-copy.mts # 游戏合集的文案是"它自己那个游戏"的(先 pnpm build)
node tools/checks/verify-sim-page.mts <分发名> # 自包含包 file:// 可用(含"零 http 请求")
node tools/checks/verify-static-preview.mts <分发名> # 静态托管子路径可用(自带服务器)
node tools/checks/read-audio-tags.mts # 读音频文件自带标签(核对歌名用)
node tools/checks/check-sim-scripts.mts [页面] # 抽出自包含页的每个内联 script 单独语法检查
node tools/checks/check-downloader.mts # 抓取器:能编译 + 夹具绿 + 四处破坏必红(不联网)
node tools/checks/verify-scene-player.mts # 场景播放器:多骨架+平面合成、画面非空、atlas 指错必红
```
`verify-scene-player` 自己跑 `tsc -p tsconfig.runtime.json` 并自造夹具(用抓取器 staged 的
`kv45/scene_main` 与 `nico-tea/scene_main`——staging 是"一个场景一个壁纸目录"),
**不碰 `wallpapers/`、不需要 `pnpm build`**;前置只有 `python -m tools.downloader fetch --page kv45`。
`verify-static-preview` 需要 `pnpm build --with-sim`(面板要随产物走)。
### ② 需要 `serve.mjs`(对拍驱动 `window.__test` 只有它注入)
> ⚠ **跑 `test-inline-guard` 之前必须先停掉 `pnpm dev`。** 它会故意把 `src/vendor/spine-player.js`
> 改坏来验证守卫,而调试服的热更新正在监听 `src/`——两者会抢构建,结果随机失败。
> 同理,任何会写 `dist/` 的构建都别和 watcher 同时跑(`pnpm build sim` 与 watcher 也会抢 `dist/`)。
```bash
node tools/serve.mjs 8190 --root dist/releases/collection-all # 另开一个终端
node tools/checks/test-props.mjs # bgm 选择/回落 + fps 限流
node tools/checks/test-resize.mjs # 改窗口尺寸不泄漏、不跑偏
node tools/checks/test-acceptance.mjs # 体积/文案/音源/构图总验收
node tools/checks/verify-fitter.mjs # viewport-fitter 的纯函数
```
### ③ 需要 `pnpm dev`(模拟器面板)
```bash
node tools/dev.ts --port 5173 --no-build # 另开一个终端
node tools/checks/smoke-dev.mts 5173 # 路由、注入、越界 404
node tools/checks/smoke-sim.mts http://127.0.0.1:5173 # 面板真的挂上、属性真的下发
node tools/checks/verify-panel.mts http://127.0.0.1:5173 # 面板还原度(12 组)
node tools/checks/verify-hot-reload.mts http://127.0.0.1:5173 # 改 CSS 只换样式表、改 runtime 整页刷新
node tools/checks/check-single-bgm.mts http://127.0.0.1:5173 # 单档分发里切歌真的换音源
node tools/checks/shot-panel.mts http://127.0.0.1:5173 [输出目录] # 给面板出图(不是断言)
```
`verify-dev-flags.mts` 要**另一个模式**:先 `node tools/build.ts --with-sim`,
再 `node tools/dev.ts --port 5173 --no-build --with-sim`,然后
`node tools/checks/verify-dev-flags.mts`。它验的是"热更新重建后 `--with-sim` 还在"。
`verify-visual-equivalence.mts` 要两步:先用 `tools/capture.mjs` 拍到
`tools/shots/visual-current/`,再跑它。基线在 `tools/shots/baseline-pre-refactor/`,
**不能重新生成**。
---
## 两层,以及为什么
| 后缀 | 是否被 `pnpm typecheck` 检查 | 说明 |
| --- | --- | --- |
| `.mts` | ✅ 走 `tsconfig.checks.json` | 后来写的验收脚本 |
| `.mjs` | ❌ | 四个早期的运行时测试:`test-acceptance` / `test-props` / `test-resize` / `verify-fitter` |
`tsconfig.checks.json` 只关掉 `noImplicitAny` 一项,其余严格模式照旧——
正是严格模式翻出了 28 处真问题(可能 undefined、`[]` 推成 `never[]`、`catch` 里 `error` 是 unknown、
参数个数不符),其中一条是"文件头声明了 ⑤ 但从未实现的断言"。
**往 `.mjs` 里写类型注解会让它运行时崩。** `.mjs` 是纯 JS,`(x: string) => …` 是 SyntaxError,
而 `tsc` 不管 `.mjs`,所以这个错在类型检查里完全看不见——踩过一次。
要写类型就先改成 `.mts`。
> 把那四个 `.mjs` 也并进受检层是一件独立的、该单独做的事:实测会立刻多出约 80 处既有问题。
> 别在别的改动的尾巴上顺手做。
---
## 约定
- **一个从不失败的检查等于没有检查。** 门禁类的脚本(`test-sim-gate` / `test-inline-guard`)
都要"先证明它会红"——注入一个坏东西,确认它报错,再还原。
- **验证要先检查前置条件。** `verify-dev-flags.mts` 第一件事是 `fetch("/")` 断言服务活着;
否则"服务没起来"会被当成"验证通过"——这是真实发生过的一次空验证。
- **区分"选项在"和"能生效"。** `verify-panel.mts` 断言面板上有那个下拉;
`check-single-bgm.mts` 断言选了之后 `<audio>.src` 真的变了。两者缺一不可。
+75
View File
@@ -0,0 +1,75 @@
// 游戏合集的文案必须是**它自己那个游戏**的——产物里不许出现别家的标题。
//
// 这条门禁来自一个真实缺陷:addGame 曾经把 title 取全局 meta(那一层是给「全部合集」写的),
// 于是 collection-ys 顶着《崩坏:星穹铁道》昔涟。三条断言都只看产物,不看实现:
// ① 前置条件:dist/releases 必须存在。否则"一个分发都没有"会被当成通过(空验证比不验证更危险)。
// ② 游戏合集的 title 必须提到本游戏的显示名,且逐字等于 wallpapers/<游戏id>/meta.json 的 title。
// ③ 全部合集 collection-all 的 title 必须来自 wallpapers/meta.json。
//
// 前置:先 `pnpm build`(只用产物与源数据,不需要服务器)。
import { existsSync, readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
const RELEASES = "dist/releases";
if (!existsSync(RELEASES)) {
console.error(`前置条件不满足:${RELEASES} 不存在。先跑 pnpm build。`);
process.exit(1);
}
let failures = 0;
const check = (ok: boolean, label: string, detail?: string): void => {
if (!ok) failures++;
console.log(` ${ok ? "✓" : "✗"} ${label}${detail !== undefined ? ` → ${detail}` : ""}`);
};
const readJson = (path: string): Record<string, unknown> =>
JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
const str = (value: unknown): string => (typeof value === "string" ? value : "");
const globalMeta = readJson("wallpapers/meta.json");
const releases = readdirSync(RELEASES).filter((name) => name.startsWith("collection-"));
let checkedGames = 0;
for (const dir of releases) {
const projectPath = join(RELEASES, dir, "project.json");
if (!existsSync(projectPath)) {
check(false, `${dir} 缺少 project.json`);
continue;
}
const project = readJson(projectPath);
const gameId = dir.slice("collection-".length);
if (gameId === "all") {
check(str(project.title) === str(globalMeta.title), "collection-all 的 title 来自全局 meta", str(project.title));
continue;
}
const gameMetaPath = `wallpapers/${gameId}/meta.json`;
if (!existsSync(gameMetaPath)) {
check(false, `${dir} 找不到对应的 ${gameMetaPath}`);
continue;
}
const gameMeta = readJson(gameMetaPath);
checkedGames++;
check(
str(project.title) === str(gameMeta.title),
`${dir} 的 title 逐字等于它自己游戏的 title`,
`产物 ${str(project.title) || "(空)"} / 源 ${str(gameMeta.title) || "(未声明)"}`,
);
check(
str(project.title).includes(str(gameMeta.name)),
`${dir} 的 title 提到了本游戏「${str(gameMeta.name)}」`,
str(project.title),
);
if (gameMeta.description !== undefined) {
check(str(project.description) === str(gameMeta.description), `${dir} 的 description 用的是游戏级声明`);
}
}
// 数量断言:目录命名一变(比如以后换成别的前缀),上面整个循环会静默变成空验证。
check(checkedGames > 0, "至少检查到一个游戏合集", `实际 ${checkedGames} 个`);
console.log(failures === 0 ? "\n游戏合集文案归属:通过" : `\n游戏合集文案归属:失败 ${failures} 处`);
process.exit(failures === 0 ? 0 : 1);
+194
View File
@@ -0,0 +1,194 @@
// 抓取器(tools/downloader/)的门:**先证明它会红**。
//
// 四件事,缺一不可:
// 1. 语法门:整个包能编译(Python 不在 `pnpm check` 的任何一道门里,这是它唯一的机会)
// 2. 绿:自造一个形状正确的产物夹具 → `verify` 必须退出 0
// 3. 红:把夹具弄坏几处 → `verify` 必须退出非 0(点名的必须是**刚弄坏的那处**)
// 4. 还原:必须重新变绿(证明红是那处改动引起的,不是夹具本身坏的)
//
// 夹具是自造的,不依赖 `_out/`(那是 gitignore 的 staging,别人机器上不一定有),也不联网。
// 形状 = 「一个场景一个可直接搬走的壁纸目录」(见 tools/downloader/layout.py)。
import { spawnSync } from "node:child_process";
import { mkdirSync, rmSync, writeFileSync, readFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
const PY = process.env.PYTHON ?? "python";
const FIXTURE = join(ROOT, "tools", ".cache", "check-downloader");
let failures = 0;
const check = (ok: unknown, label: string, detail?: unknown) => {
if (!ok) failures++;
console.log(` ${ok ? "✓" : "✗"} ${label}${detail !== undefined ? ` → ${String(detail)}` : ""}`);
};
/** 跑一条 python 命令,返回 { code, out }。 */
function run(args: string[], cwd = ROOT): { code: number; out: string } {
const result = spawnSync(PY, args, { cwd, encoding: "utf8", env: { ...process.env, PYTHONIOENCODING: "utf-8" } });
if (result.error) {
throw new Error(`跑不动 ${PY}:${result.error.message}(设 PYTHON 环境变量可指定解释器)`);
}
return { code: result.status ?? -1, out: `${result.stdout ?? ""}${result.stderr ?? ""}`.trim() };
}
const PAGE = () => join(FIXTURE, "ys", "fixture-page");
const SCENE = () => join(PAGE(), "scene_main");
const sceneJson = () => join(SCENE(), "scene.json");
const presetJson = () => join(SCENE(), "preset.template.json");
const metaJson = () => join(SCENE(), "meta.json");
const heroPng = () => join(SCENE(), "spines", "hero", "hero.png");
/** 造一个形状正确的产物夹具:一页、一个场景目录、一具骨架、一块贴图平面。 */
function writeFixture(dir: string): void {
rmSync(dir, { recursive: true, force: true });
const scene = SCENE();
const spine = join(scene, "spines", "hero");
mkdirSync(spine, { recursive: true });
mkdirSync(join(scene, "scene"), { recursive: true });
mkdirSync(join(scene, "audios"), { recursive: true });
writeFileSync(
join(PAGE(), "page.json"),
JSON.stringify({
id: "fixture-page",
game: "ys",
url: "https://example.invalid/",
scenes: [{ id: "scene_main", dir: "scene_main", spines: 1, images: 1, solids: 1 }],
chosenScene: "scene_main",
chosenDir: "scene_main",
sceneDirs: [{ dir: "scene_main", scene: "scene_main", spines: 1, images: 1, solids: 1, parts: 2, bytes: 0 }],
skippedScenes: [],
totalBytes: 0,
}),
);
writeFileSync(
sceneJson(),
JSON.stringify({
version: 1,
id: "scene_main",
parts: [
{ kind: "spine", id: "hero", order: 0, position: [0, 0, 0], scale: [1, 1, 1] },
{ kind: "image", id: "sky", order: 1, position: [0, 0, 0], scale: [1, 1, 1], geometrySize: [100, 50] },
{ kind: "solid", id: "DEFAULT", order: 2, position: [0, 0, 0], scale: [1, 1, 1] },
],
}),
);
writeFileSync(
presetJson(),
JSON.stringify({
backgroundImage: "./scene/sky.png",
sceneConfig: {
ui: [100, 100],
parts: [
{
kind: "spine",
id: "hero",
order: 0,
position: [0, 0, 0],
scale: [1, 1, 1],
jsonUrl: "./spines/hero/hero.json",
atlasUrl: "./spines/hero/hero.atlas",
},
{
kind: "image",
id: "sky",
order: 1,
position: [0, 0, 0],
scale: [1, 1, 1],
width: 100,
height: 50,
image: "./scene/sky.png",
},
],
},
}),
);
writeFileSync(
metaJson(),
JSON.stringify({
id: "scene_main",
name: "夹具页(scene_main)",
title: "夹具页(scene_main)",
description: "夹具",
game: "ys",
page: "fixture-page",
scene: "scene_main",
source: "https://example.invalid/",
audio: { choices: [] },
}),
);
writeFileSync(
join(spine, "hero.json"),
JSON.stringify({ skeleton: { spine: "4.2.43", images: "" }, bones: [], slots: [] }),
);
writeFileSync(join(spine, "hero.atlas"), "hero.png\nsize:8,8\nfilter:Linear,Linear\nbody\nbounds:0,0,4,4\n");
writeFileSync(heroPng(), Buffer.from([0x89, 0x50, 0x4e, 0x47]));
writeFileSync(join(spine, "meta.json"), JSON.stringify({ name: "hero", spine: "4.2.43", pages: ["hero.png"] }));
writeFileSync(join(scene, "scene", "sky.png"), Buffer.from([0x89, 0x50, 0x4e, 0x47]));
}
console.log("抓取器门:\n");
// ① 前置条件:解释器与包目录都在
const pre = run(["-c", "import sys; print(sys.version.split()[0])"]);
check(pre.code === 0, `${PY} 可用`, pre.out.split("\n")[0] || pre.out);
if (pre.code !== 0) {
console.log("\n前置条件不满足,无法继续。");
process.exit(1);
}
// ② 语法门
const compile = run(["-m", "compileall", "-q", "tools/downloader"]);
check(compile.code === 0, "tools/downloader 全部能编译", compile.out.split("\n").slice(-2).join(" | ") || "无输出");
// ③ 绿:形状正确的夹具必须通过
writeFixture(FIXTURE);
const good = run(["-m", "tools.downloader", "verify", "--root", FIXTURE]);
check(good.code === 0, "形状正确的夹具 → verify 退出 0", good.code === 0 ? good.out.split("\n").pop() : good.out);
// ④ 红之一:贴图页缺文件(atlas 声明的页名必须逐字落盘)
const savedPng = readFileSync(heroPng());
rmSync(heroPng());
const missingPage = run(["-m", "tools.downloader", "verify", "--root", FIXTURE]);
check(missingPage.code !== 0, "删掉贴图页 → verify 报红", missingPage.out.split("\n")[0]);
check(/hero\.png/.test(missingPage.out), "报错里点名了缺的那张页", missingPage.out.split("\n")[0]);
writeFileSync(heroPng(), savedPng);
// ⑤ 红之二:侧车引用一具不存在的骨架
const savedScene = readFileSync(sceneJson(), "utf8");
const scene = JSON.parse(savedScene) as { parts: { kind: string; id: string; order?: number }[] };
scene.parts.push({ kind: "spine", id: "ghost", order: 3 });
writeFileSync(sceneJson(), JSON.stringify(scene));
const ghost = run(["-m", "tools.downloader", "verify", "--root", FIXTURE]);
check(ghost.code !== 0, "侧车引用不存在的骨架 → verify 报红", ghost.out.split("\n")[0]);
check(/ghost/.test(ghost.out), "报错里点名了 ghost", ghost.out.split("\n")[0]);
writeFileSync(sceneJson(), savedScene);
// ⑥ 红之三:预设里有一条 ./… 指向不存在的文件(构建期才会炸的那类)
const savedPreset = readFileSync(presetJson(), "utf8");
const preset = JSON.parse(savedPreset) as { backgroundImage: string };
preset.backgroundImage = "./scene/nope.png";
writeFileSync(presetJson(), JSON.stringify(preset));
const badPath = run(["-m", "tools.downloader", "verify", "--root", FIXTURE]);
check(badPath.code !== 0, "预设路径指向不存在的文件 → verify 报红", badPath.out.split("\n")[0]);
check(/nope\.png/.test(badPath.out), "报错里点名了那条路径", badPath.out.split("\n")[0]);
writeFileSync(presetJson(), savedPreset);
// ⑦ 红之四:meta.json 的 id 与场景目录名不一致(搬进 wallpapers/ 会直接构建报错)
const savedMeta = readFileSync(metaJson(), "utf8");
const meta = JSON.parse(savedMeta) as { id: string };
meta.id = "other-id";
writeFileSync(metaJson(), JSON.stringify(meta));
const badId = run(["-m", "tools.downloader", "verify", "--root", FIXTURE]);
check(badId.code !== 0, "meta.json 的 id 与目录名不一致 → verify 报红", badId.out.split("\n")[0]);
check(/other-id/.test(badId.out), "报错里点名了那个 id", badId.out.split("\n")[0]);
writeFileSync(metaJson(), savedMeta);
// ⑧ 还原后必须重新变绿(证明红是那处改动引起的,不是夹具本身坏的)
const restored = run(["-m", "tools.downloader", "verify", "--root", FIXTURE]);
check(restored.code === 0, "还原四处破坏 → 重新变绿", restored.code === 0 ? restored.out.split("\n").pop() : restored.out);
rmSync(FIXTURE, { recursive: true, force: true });
console.log(failures === 0 ? "\n抓取器门:通过" : `\n抓取器门:失败 ${failures} 处`);
process.exit(failures === 0 ? 0 : 1);
+26
View File
@@ -0,0 +1,26 @@
// 把自包含 HTML 里每个内联 <script> 抽出来单独语法检查,定位 "Unexpected token '{'"。
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { join } from "node:path";
import { execFileSync } from "node:child_process";
const page = process.argv[2] ?? "dist/releases/single-hsr-kv37/sim/index.html";
const html = readFileSync(page, "utf8");
const outDir = "tools/checks/.sim-scripts";
mkdirSync(outDir, { recursive: true });
const scripts = [...html.matchAll(/<script(?:\s[^>]*)?>([\s\S]*?)<\/script>/g)].map((m) => m[1] ?? "");
console.log(`共 ${scripts.length} 个内联 script,页面 ${(html.length / 1024 / 1024).toFixed(1)} MB\n`);
scripts.forEach((code, i) => {
const file = join(outDir, `script-${i}.js`);
writeFileSync(file, code);
const kb = (code.length / 1024).toFixed(0);
try {
execFileSync(process.execPath, ["--check", file], { stdio: "pipe" });
console.log(` ✓ script-${i} ${kb} KB`);
} catch (error) {
const stderr = String((error as { stderr?: string }).stderr ?? "");
const first = stderr.split("\n").filter((l) => l.trim()).slice(0, 6).join("\n ");
console.log(` ✗ script-${i} ${kb} KB\n ${first}`);
}
});
+41
View File
@@ -0,0 +1,41 @@
// 单档分发里真的能切歌吗——选项在 ≠ 切得动。
//
// 这是用户报的那件事的终局判据:single-hsr-xilian 有两首,面板上给了下拉之后,
// 选第二首必须真的换掉 <audio> 的源。
import { withPage, sleep } from "../cdp.mjs";
const BASE = process.argv[2] ?? "http://127.0.0.1:5173";
const name = (src: unknown) => (src ? decodeURIComponent(String(src).split("/").pop() ?? "") : "(空)");
let failures = 0;
const check = (ok: unknown, label: string, detail?: unknown) => {
if (!ok) failures++;
console.log(` ${ok ? "✓" : "✗"} ${label}${detail !== undefined ? ` → ${String(detail)}` : ""}`);
};
await withPage({ width: 1280, height: 720, profile: "tools/.cache/edge-single-bgm" }, async ({ send, evaluate, exceptions }) => {
await send("Page.navigate", { url: `${BASE}/release/single-hsr-xilian/` });
await sleep(3500);
const src = () => evaluate(`(function(){ var a = document.getElementById("background-music"); return a ? a.src : null; })()`);
const bgm = (v) => evaluate(`window.wallpaperPropertyListener.applyUserProperties({ bgm: { value: ${JSON.stringify(v)} } })`);
const initial = await src();
check(name(initial) === "zaiduheni.flac", "默认是第一首「再度和你」", name(initial));
// 「昔涟」在自增 id 里是 "3"(kv37 的「版本 PV」占 "1",xilian 两首是 "2"/"3")
await bgm("3");
await sleep(500);
const afterSwitch = await src();
check(name(afterSwitch) === "xilian-src.flac", "选「昔涟」→ 音源真的换了", name(afterSwitch));
await bgm("2");
await sleep(500);
const back = await src();
check(name(back) === "zaiduheni.flac", "再选回「再度和你」→ 换回来", name(back));
check(exceptions.length === 0, "0 未捕获异常", exceptions.slice(0, 2).join(" | ") || "无");
});
console.log(failures === 0 ? "\n单档切歌:通过" : `\n单档切歌:失败 ${failures} 处`);
process.exit(failures === 0 ? 0 : 1);
+35
View File
@@ -0,0 +1,35 @@
// 逐键比对:本次构建的 collection-all/project.json vs **已发布**的基线。
// 只报**具体哪个键**不同,而不是一串字符串差异。
import { readFileSync } from "node:fs";
// 基线只保留这一个文件:原来读的是 .scratch/dist-baseline/(96.9 MB 的整份旧 dist 拷贝,已按 .gitignore 的约定删除),
// 而这里只用到 project.json。重构等价性验证结束后,那份拷贝已按 .gitignore 的约定删除。
const live = JSON.parse(readFileSync("tools/checks/published-project.json", "utf8"));
const built = JSON.parse(readFileSync("dist/releases/collection-all/project.json", "utf8"));
function diff(a: any, b: any, path = ""): string[] {
const out: string[] = [];
const keys = new Set([...Object.keys(a ?? {}), ...Object.keys(b ?? {})]);
for (const key of keys) {
const p = path ? `${path}.${key}` : key;
const va = a?.[key];
const vb = b?.[key];
if (va === undefined) out.push(`+ ${p}(基线没有)`);
else if (vb === undefined) out.push(`- ${p}(本次没有)`);
else if (typeof va === "object" && va !== null && !Array.isArray(va)) out.push(...diff(va, vb, p));
else if (JSON.stringify(va) !== JSON.stringify(vb)) out.push(`≠ ${p}\n 基线: ${JSON.stringify(va)}\n 本次: ${JSON.stringify(vb)}`);
}
return out;
}
console.log("=== 顶层键序 ===");
console.log("基线:", Object.keys(live).join(" "));
console.log("本次:", Object.keys(built).join(" "));
console.log("");
const differences = diff(live, built);
if (differences.length === 0) console.log("✓ 字段值完全一致");
else {
console.log(`=== ${differences.length} 处差异 ===`);
for (const d of differences) console.log(" " + d);
}
File renamed without changes.
+139
View File
@@ -0,0 +1,139 @@
{
"contentrating" : "Everyone",
"description" : "[b]特色功能[/b]\r\n[list]\r\n [*]支持切换预设。\r\n [olist]\r\n [*]官方昔涟动态立绘。\r\n [*]官方三点七版本「成为昨日的明天」专题展示页。\r\n [/olist]\r\n [*]支持音源切换。\r\n [olist]\r\n [*]可切换各预设自带的背景音乐。\r\n [*]也可自行填写音频 URL。\r\n [*][quote][b]注意:[/b]目前仅支持 URL 格式。[/quote]\r\n [/olist]\r\n [*]自适应窗口比例,不同显示器下构图保持一致。\r\n[/list]",
"file" : "index.html",
"general" :
{
"properties" :
{
"audio_file" :
{
"index" : 6,
"order" : 106,
"text" : "🎵音频文件路径",
"type" : "textinput",
"value" : ""
},
"audio_file_note" :
{
"index" : 7,
"order" : 107,
"text" : "<small>更换背景音乐来源:<ul><li>🚨注意:目前仅支持 URL 链接。</li><li>当音源不可用时会切换至上一音源。</li></ul></small><br />",
"type" : "text"
},
"audio_volume" :
{
"fraction" : true,
"index" : 4,
"max" : 1,
"min" : 0,
"order" : 104,
"precision" : 1,
"step" : 0.1,
"text" : "🔊音频音量调整",
"type" : "slider",
"value" : 1
},
"audio_volume_note" :
{
"index" : 5,
"order" : 105,
"text" : "<small>调整背景音乐音量:<ul><li>为零时暂停。</li><li>为一时最大。</li></ul></small><br />",
"type" : "text"
},
"author_info" :
{
"condition" : "show_author_info.value == true",
"index" : 1,
"order" : 101,
"text" : "<small>作者信息:<ul><li>作者:品毅</li><li>QQ:2463253700</li><li>哔哩哔哩:<a href=\"https://space.bilibili.com/72266376\">品毅的个人空间</a></li></ul></small><br />",
"type" : "text"
},
"bgm" :
{
"index" : 8,
"options" :
[
{
"label" : "随预设",
"value" : "auto"
},
{
"label" : "「再度和你」",
"value" : "zaiduheni"
},
{
"label" : "昔涟",
"value" : "xilian"
},
{
"label" : "版本 PV",
"value" : "pv37"
}
],
"order" : 108,
"text" : "🎵背景音乐选择",
"type" : "combo",
"value" : "auto"
},
"bgm_note" :
{
"index" : 9,
"order" : 109,
"text" : "<small>选择各预设自带的背景音乐:<ul><li>默认「随预设」跟随壁纸切换。</li><li>所选项不属于当前壁纸时,自动回落该壁纸的默认音乐,选择仍会保留。</li></ul></small><br />",
"type" : "text"
},
"preset" :
{
"index" : 2,
"options" :
[
{
"label" : "昔涟立绘",
"value" : "xilian"
},
{
"label" : "「成为昨日的明天」",
"value" : "kv37"
}
],
"order" : 102,
"text" : "⚙️壁纸预设切换<br />",
"type" : "combo",
"value" : "xilian"
},
"preset_note" :
{
"index" : 3,
"order" : 103,
"text" : "<small>切换壁纸预设(包括背景、音乐、动画):<ul><li>当其它选项存在更改,不会覆盖其它选项。</li></ul></small><br />",
"type" : "text"
},
"schemecolor" :
{
"order" : 0,
"text" : "ui_browse_properties_scheme_color",
"type" : "color",
"value" : "0.38823529411764707 0.14901960784313725 0.6196078431372549"
},
"show_author_info" :
{
"index" : 0,
"order" : 100,
"text" : "📇显示作者信息",
"type" : "bool",
"value" : true
}
}
},
"preview" : "preview.gif",
"ratingsex" : "none",
"ratingviolence" : "none",
"tags" : [ "Anime" ],
"title" : "【崩坏:星穹铁道】昔涟",
"type" : "Web",
"version" : 3,
"visibility" : "public",
"workshopid" : "3604974793",
"workshopurl" : "steam://url/CommunityFilePage/3604974793"
}
+75
View File
@@ -0,0 +1,75 @@
// 读音频文件自带的元数据标签(ID3v2 / Vorbis comment)。
//
// 为什么值得读:bgm 下拉里的名字是 meta.json **手写**的,build 不做任何推导,
// 也没有任何校验能发现写错。文件自带的标签是唯一的"外部真相",可以对一下。
import { readFileSync } from "node:fs";
/** ID3v2.3/2.4:读 TIT2/TPE1/TALB 等文本帧。 */
function readId3(buf) {
if (buf.toString("latin1", 0, 3) !== "ID3") return null;
const version = buf[3];
const size = ((buf[6] & 0x7f) << 21) | ((buf[7] & 0x7f) << 14) | ((buf[8] & 0x7f) << 7) | (buf[9] & 0x7f);
const out = {};
let at = 10;
const end = Math.min(10 + size, buf.length);
while (at + 10 <= end) {
const id = buf.toString("latin1", at, at + 4);
if (!/^[A-Z0-9]{4}$/.test(id)) break;
const frameSize =
version === 4
? ((buf[at + 4] & 0x7f) << 21) | ((buf[at + 5] & 0x7f) << 14) | ((buf[at + 6] & 0x7f) << 7) | (buf[at + 7] & 0x7f)
: buf.readUInt32BE(at + 4);
const body = buf.subarray(at + 10, at + 10 + frameSize);
// 首字节是编码:0=latin1, 1=utf16, 3=utf8
const enc = body[0];
const text = enc === 1 ? body.subarray(1).toString("utf16le") : body.subarray(1).toString(enc === 3 ? "utf8" : "latin1");
out[id] = text.replace(/\0/g, "").trim();
at += 10 + frameSize;
}
return out;
}
/** FLAC:遍历 metadata block,取 type 4 = VORBIS_COMMENT。 */
function readFlac(buf) {
if (buf.toString("latin1", 0, 4) !== "fLaC") return null;
let at = 4;
const out = {};
while (at + 4 <= buf.length) {
const header = buf[at];
const last = (header & 0x80) !== 0;
const type = header & 0x7f;
const size = buf.readUIntBE(at + 1, 3);
if (type === 4) {
const body = buf.subarray(at + 4, at + 4 + size);
const count = body.readUInt32LE(0);
let p = 4;
for (let i = 0; i < count && p + 4 <= body.length; i++) {
const len = body.readUInt32LE(p);
const entry = body.subarray(p + 4, p + 4 + len).toString("utf8");
const eq = entry.indexOf("=");
if (eq > 0) out[entry.slice(0, eq).toUpperCase()] = entry.slice(eq + 1);
p += 4 + len;
}
}
at += 4 + size;
if (last) break;
}
return out;
}
const files = [
"wallpapers/hsr/kv37/audios/pv37.mp3",
"wallpapers/hsr/xilian/audios/zaiduheni.flac",
"wallpapers/hsr/xilian/audios/xilian-src.flac",
];
for (const file of files) {
const buf = readFileSync(file);
const tags = readId3(buf) ?? readFlac(buf);
console.log(`\n=== ${file} ===`);
if (!tags || Object.keys(tags).length === 0) {
console.log(" (没有可读的标签)");
continue;
}
for (const [k, v] of Object.entries(tags)) console.log(` ${k.padEnd(12)} ${v}`);
}
+51
View File
@@ -0,0 +1,51 @@
// 给"还原后的面板"拍图。用真实 CDP:把指针移到右边缘让面板滑出,等过渡走完再截。
// 出两张:面板本体、以及点色块弹出的取色器。
// 用法:node tools/checks/shot-panel.mts [base] [outDir]
import { mkdir, writeFile } from "node:fs/promises";
import { withPage, sleep } from "../cdp.mjs";
const BASE = process.argv[2] ?? "http://127.0.0.1:5173";
const OUT_DIR = (process.argv[3] ?? ".scratch/shots").replace(/[\\/]+$/, "");
const W = 1280;
const H = 900;
await mkdir(OUT_DIR, { recursive: true });
await withPage({ width: W, height: H, profile: "tools/.cache/edge-panel-shot" }, async ({ send, evaluate }) => {
await send("Page.navigate", { url: `${BASE}/release/collection-all/` });
await sleep(3500);
const shoot = async (name) => {
const shot = await send("Page.captureScreenshot", { format: "png" });
const path = `${OUT_DIR}/${name}.png`;
await writeFile(path, Buffer.from(shot.data, "base64"));
console.log(` 已保存 ${path}`);
};
// 指针移到右边缘 → 面板滑出(这是它唯一的打开方式)
await send("Input.dispatchMouseEvent", { type: "mouseMoved", x: W - 8, y: H / 2, button: "none", buttons: 0 });
await sleep(500);
console.log(` 面板状态 data-open=${await evaluate(`document.getElementById("wesim").dataset.open`)}`);
await shoot("panel-official-look");
// 点色块 → 取色器
await evaluate(`document.querySelector("#wesim .swatch").click()`);
await sleep(400);
console.log(` 取色器已打开:${await evaluate(`!!document.getElementById("wesim-picker")`)}`);
await shoot("panel-color-picker");
// 收起取色器,再展开「壁纸预设切换」的下拉
await evaluate(`document.querySelector("#wesim-picker .pk-foot button:not(.primary)").click()`);
await sleep(200);
await evaluate(`(function(){
var rows = document.querySelectorAll("#wesim .body .row");
for (var i = 0; i < rows.length; i++) {
var l = rows[i].querySelector(".label");
if (l && l.textContent === "壁纸预设切换") { rows[i].querySelector(".combo-btn").click(); return true; }
}
return false;
})()`);
await sleep(300);
console.log(` 下拉已展开:${await evaluate(`!!document.getElementById("wesim-combo")`)}`);
await shoot("panel-combo-open");
});
+72
View File
@@ -0,0 +1,72 @@
// 调试服冒烟:验证路由、注入、以及"所有分发内引用都能取到 200"。
// 用法:node tools/checks/smoke-dev.mts [port]
//
// 这里有一条**回归断言**:模拟器本体必须能从调试服取到 200。
// 它曾经是 404 —— 驱动脚本从 `/release/<dir>/scripts/wallpaper-engine.js` 取模拟器,
// 而该文件只有 `pnpm build --with-sim` 才会放进分发,默认构建下必然缺失。
// 症状是浏览器里"WE 模拟面板没出来",而 404 只留在控制台里。
// 所以断言必须打在**模拟器本体的可获取性**上,不能只断言"HTML 里提到了这个字符串"。
const port = Number(process.argv[2] ?? 5173);
const base = `http://127.0.0.1:${port}`;
const SIMULATOR_URL = "/simulator/wallpaper-engine.js";
let failures = 0;
function ok(condition: boolean, label: string, detail = ""): void {
if (condition) {
console.log(` ✓ ${label}`);
} else {
failures += 1;
console.log(` ✗ ${label}${detail ? ` —— ${detail}` : ""}`);
}
}
const indexRes = await fetch(`${base}/`);
const indexHtml = await indexRes.text();
console.log(`索引页 ${indexRes.status}`);
ok(indexRes.status === 200, "GET / 返回 200");
ok(indexHtml.includes("single-hsr-xilian") && indexHtml.includes("collection-all"), "索引页列出全部分发");
const map = await (await fetch(`${base}/dist-map.json`).catch(() => ({ status: 0, text: async () => "" }))).text?.();
void map;
for (const dir of ["single-hsr-xilian", "single-hsr-kv37", "collection-hsr", "collection-all"]) {
console.log(`\n${dir}`);
const res = await fetch(`${base}/release/${dir}/`);
const html = await res.text();
ok(res.status === 200, "GET /release/<dir>/ 返回 200");
ok(html.includes("wallpaper-engine.js"), "已注入模拟器脚本"); ok(html.includes("__weProperties"), "已注入 project.json 属性表");
ok(html.includes("mountWallpaperEngineSimulator"), "已注入模拟器驱动");
ok(html.indexOf("wallpaper-engine.js") < html.indexOf("scripts/index.js"), "模拟器在壁纸模块之前");
// 模拟器脚本本体可访问(**回归断言**:曾经 404,导致面板静默消失)
const simRes = await fetch(`${base}${SIMULATOR_URL}`);
ok(simRes.status === 200, `模拟器本体 ${SIMULATOR_URL} 返回 200`, `status=${simRes.status}`);
const simText = simRes.status === 200 ? await simRes.text() : "";
ok(
simText.includes("mountWallpaperEngineSimulator"),
"模拟器本体确实导出了 mountWallpaperEngineSimulator",
`${(simText.length / 1024).toFixed(0)} KB`,
);
// ?nojs=1 剥脚本
const nojs = await (await fetch(`${base}/release/${dir}/?nojs=1`)).text();
ok(!nojs.includes("scripts/index.js") && nojs.includes("TESTSTATE"), "?nojs=1 剥掉脚本并留状态标记", nojs.slice(0, 80));
// ?sim=0 不注入模拟器
const nosim = await (await fetch(`${base}/release/${dir}/?sim=0`)).text();
ok(!nosim.includes('type="module" src="/release/'), "?sim=0 不注入模拟器 script 标签");
// 越界引用必须 404(目录自包含性)
const escape = await fetch(`${base}/release/${dir}/../${dir}/index.html`);
ok(escape.status === 200 || escape.status === 404, "越界路径被规范化(不崩溃)");
}
console.log("\n越权路由");
const other = await fetch(`${base}/release/not-a-release/`);
ok(other.status === 404, "未知分发返回 404");
const stray = await fetch(`${base}/whatever.txt`);
ok(stray.status === 404, "非 /release 路由返回 404");
console.log(failures === 0 ? "\n调试服冒烟通过" : `\n调试服冒烟失败:${failures} 处`);
process.exit(failures === 0 ? 0 : 1);
+148
View File
@@ -0,0 +1,148 @@
// 浏览器端冒烟:在真实无头 Edge 里验证
// ① 分发能正常渲染(spine canvas 出现、无异常)
// ② WE 模拟器确实生效(window.__weSim、面板、替身横幅)
// ③ 属性下发链路真的打通(经模拟器改 preset → 背景与骨架跟着换)
// ④ ?propsAt=dom 这条"load 之前下发"的竞态路径不会把配置丢掉
// ⑤ 单档分发(属性表里没有 preset/bgm)不白屏
//
// 判定只用页面**真实可观察**的量:canvas 是否出现、body 背景、播放器 config。
// 不要用 window.__player —— 那是 tools/serve.mjs 的对拍驱动注入的探针,不是产品行为。
//
// 用法:node tools/checks/smoke-sim.mts [base]
// 前提:调试服在跑(node tools/dev.ts --no-build)。
// 不需要 `--with-sim`:模拟器由调试服自己从 build/scripts/wallpaper-engine.js 提供
// (它默认不进发布产物,见 ADR 0006 §1 与 dev.ts 的路由说明)。
import { withPage, sleep } from "../cdp.mjs";
const BASE = process.argv[2] ?? "http://127.0.0.1:5173";
let failures = 0;
const check = (ok: unknown, label: string, detail?: unknown) => {
if (!ok) failures++;
console.log(` ${ok ? "✓" : "✗"} ${label}${detail !== undefined ? ` → ${String(detail)}` : ""}`);
};
const RELEASE = "collection-all";
/** 就绪判定:spine-container 里出现 canvas(= SpinePlayer 真的创建成功)。 */
async function waitCanvas(evaluate, tag) {
const deadline = Date.now() + 40000;
while (Date.now() < deadline) {
const state = await evaluate(`(function(){
var c = document.getElementById("spine-container");
return { canvas: c ? c.querySelectorAll("canvas").length : 0, children: c ? c.children.length : -1 };
})()`);
if (state && state.canvas > 0) return { ok: true, ...state };
await sleep(200);
}
return { ok: false, canvas: 0, error: `${tag} 超时` };
}
function assertClean(tag, exceptions, consoleLines) {
check(exceptions.length === 0, `${tag} 0 未捕获异常`, exceptions.slice(0, 2).join(" | ") || "无");
const noisy = consoleLines.filter((l) => /error|uncaught/i.test(l));
check(noisy.length === 0, `${tag} 0 控制台报错`, noisy.slice(0, 3).join(" | ") || "干净");
}
// ── ① 正常路径 ──
console.log("=== ① 正常路径:模拟器生效 + 属性下发 ===");
const normal = await withPage({ width: 1920, height: 1080 }, async ({ send, evaluate, consoleLines, exceptions }) => {
await send("Page.navigate", { url: `${BASE}/release/${RELEASE}/` });
const ready = await waitCanvas(evaluate, "normal");
await sleep(600);
const sim = await evaluate(`(function(){
var w = window.__weSim;
if (!w) return null;
return { isSimulated: w.__isSimulated, substituted: w.substituted.length,
release: w.release, deliveries: w.deliveries.map(function(d){ return d.type; }),
panel: !!document.getElementById("wesim"),
panels: document.querySelectorAll("#wesim").length,
edges: document.querySelectorAll("#wesim-edge").length,
banner: document.getElementById("wesim") ? document.querySelector("#wesim .banner").textContent : null,
hasEngineGlobal: typeof window.wallpaperEngine,
hasRegisterListener: typeof window.wallpaperRegisterListener };
})()`);
const view = await evaluate(`(function(){
return { bg: document.body.style.backgroundImage,
canvas: document.querySelectorAll("canvas").length,
canvasSize: (function(){ var c = document.querySelector("canvas"); return c ? c.clientWidth + "x" + c.clientHeight : null; })() };
})()`);
return { ready, sim, view, consoleLines, exceptions };
});
check(normal.ready.ok, "Spine 画布已创建(渲染就绪)", normal.ready.error ?? `canvas=${normal.ready.canvas}`);
check(normal.sim?.isSimulated === true, "window.__weSim.__isSimulated = true");
check((normal.sim?.substituted ?? 0) > 0, "替身清单非空(做不到的 API 被显式标记)", `${normal.sim?.substituted} 项`);
check(normal.sim?.panel === true, "模拟器面板已挂载(#wesim)");
// 回归断言:`--with-sim` 构建的分发自带驱动,调试服又会注入一次。
// 没有幂等闸的话模拟器会被 mount 两遍,页面上出现两个面板。
check(normal.sim?.panels === 1, "面板恰好一个(驱动幂等,没有被注入两次)", `${normal.sim?.panels} 个`);
check(normal.sim?.edges === 1, "右边缘热区恰好一个", `${normal.sim?.edges} 个`);
check(/模拟环境/.test(normal.sim?.banner ?? ""), "面板有常驻的「模拟环境」横幅", normal.sim?.banner?.trim());
check(normal.sim?.deliveries?.includes("applyUserProperties"), "已下发 applyUserProperties", normal.sim?.deliveries?.join(", "));
check(normal.sim?.deliveries?.includes("applyGeneralProperties"), "已下发 applyGeneralProperties", normal.sim?.deliveries?.join(", "));
check(/ava\.jpg/.test(normal.view.bg ?? ""), "默认预设(xilian)的背景已应用", normal.view.bg?.slice(-32));
// 严格还原 WE:这两个 API 在真实 WE 里都不存在,模拟器绝不能凭空提供
check(normal.sim?.hasEngineGlobal === "undefined", "未凭空暴露 window.wallpaperEngine(WE 没有这个全局)", normal.sim?.hasEngineGlobal);
check(normal.sim?.hasRegisterListener === "undefined", "未暴露 window.wallpaperRegisterListener(WE 没有这个 API)", normal.sim?.hasRegisterListener);
assertClean("正常路径", normal.exceptions, normal.consoleLines);
// ── ② 属性下发真的改变了画面 ──
console.log("\n=== ② 经模拟器改 preset → 背景与骨架跟着换 ===");
const switched = await withPage({ width: 1920, height: 1080 }, async ({ send, evaluate, exceptions }) => {
await send("Page.navigate", { url: `${BASE}/release/${RELEASE}/` });
await waitCanvas(evaluate, "switch");
await sleep(600);
const bgBefore = await evaluate("document.body.style.backgroundImage");
// setProperties 收的是**值**,不是 WE 的 { value } 信封(信封由模拟器自己套)
await evaluate('window.__weSim.setProperties({ preset: "kv37" })');
await sleep(2000);
const after = await evaluate(`(function(){
return { bg: document.body.style.backgroundImage,
canvas: document.querySelectorAll("canvas").length,
// 骨架换了就意味着播放器被重建过:容器里应当只有一张新 canvas
children: document.getElementById("spine-container").children.length };
})()`);
return { bgBefore, ...after, exceptions };
});
check(switched.bgBefore !== switched.bg, "背景图已更换", `${switched.bgBefore?.slice(-22)} → ${switched.bg?.slice(-22)}`);
check(/kv37_xilian/.test(switched.bg ?? ""), "换成了 kv37 的背景", switched.bg?.slice(-30));
check(switched.canvas === 1, "换骨架后只剩一张 canvas(旧播放器已 dispose)", `canvas=${switched.canvas}`);
check(switched.children === 1, "容器里没有残留节点(dispose 生效)", `children=${switched.children}`);
check(switched.exceptions.length === 0, "切换过程 0 异常", switched.exceptions.slice(0, 2).join(" | ") || "无");
// ── ③ load 之前的属性下发 ──
console.log("\n=== ③ ?__propsAt=dom:load 之前下发不丢配置 ===");
const race = await withPage({ width: 1920, height: 1080 }, async ({ send, evaluate, exceptions }) => {
const props = encodeURIComponent(JSON.stringify({ preset: "kv37" }));
await send("Page.navigate", { url: `${BASE}/release/${RELEASE}/?__propsAt=dom&__props=${props}` });
const ready = await waitCanvas(evaluate, "race");
await sleep(800);
const state = await evaluate(`(function(){
return { bg: document.body.style.backgroundImage, canvas: document.querySelectorAll("canvas").length,
propsAt: window.__weSim ? window.__weSim.deliveries[0] && window.__weSim.deliveries[0].at : null };
})()`);
return { ready, ...state, exceptions };
});
check(race.ready.ok, "播放器仍被创建(配置没被丢弃)", race.ready.error ?? "ok");
check(/kv37_xilian/.test(race.bg ?? ""), "load 前下发的 preset 已生效", race.bg?.slice(-30));
check(race.canvas === 1, "只有一张 canvas", `canvas=${race.canvas}`);
check(race.exceptions.length === 0, "0 异常", race.exceptions.slice(0, 2).join(" | ") || "无");
// ── ④ 单档分发 ──
console.log("\n=== ④ 单档分发(属性表里没有 preset/bgm) ===");
const single = await withPage({ width: 1920, height: 1080 }, async ({ send, evaluate, consoleLines, exceptions }) => {
await send("Page.navigate", { url: `${BASE}/release/single-hsr-kv37/` });
const ready = await waitCanvas(evaluate, "single");
await sleep(800);
const bg = await evaluate("document.body.style.backgroundImage");
const props = await evaluate("JSON.stringify(Object.keys(window.__weProperties))");
return { ready, bg, props, consoleLines, exceptions };
});
check(single.ready.ok, "单档分发渲染就绪(未白屏)", single.ready.error ?? "ok");
check(/kv37_xilian/.test(single.bg ?? ""), "单档分发背景已应用", single.bg?.slice(-30));
check(!/"preset"/.test(single.props ?? ""), "单档分发确实没有 preset 属性", single.props);
check(!/"bgm"/.test(single.props ?? ""), "单档分发确实没有 bgm 属性", single.props);
assertClean("单档分发", single.exceptions, single.consoleLines);
console.log(failures === 0 ? "\n模拟器浏览器冒烟通过" : `\n模拟器浏览器冒烟失败:${failures} 处`);
process.exit(failures === 0 ? 0 : 1);
@@ -4,12 +4,12 @@
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { basename, extname, join } from "node:path";
import { withPage, sleep } from "../tools/cdp.mjs";
import { withPage, sleep } from "../cdp.mjs";
let failures = 0;
const check = (ok, msg, detail) => {
if (!ok) failures++;
console.log(` ${ok ? "✓" : "✗"} ${msg}${detail !== undefined ? ` → ${detail}` : ""}`);
console.log(` ${ok ? "✓" : "✗"} ${msg}${detail !== undefined ? ` → ${String(detail)}` : ""}`);
};
// ── ① 体积与死文件 ──
@@ -19,7 +19,8 @@ const walk = (dir) =>
const p = join(dir, e.name);
return e.isDirectory() ? walk(p) : [p];
});
const files = walk("dist");
const RELEASE = "dist/releases/collection-all";
const files = walk(RELEASE);
const total = files.reduce((s, f) => s + statSync(f).size, 0);
// 用户已明确豁免体积(音频走无损、壁纸在本地),基准改为"不超过已通过审核的 v2"。
// 这是真正有意义的界:v2 以 104.98 MB 发布过,所以这个体积不会带来新的上传风险。
@@ -46,7 +47,7 @@ check(dead.length === 0, "0 个死文件", dead.length ? dead.join(", ") : "无
// ── ② project.json ──
console.log("\n=== ② project.json ===");
const project = JSON.parse(readFileSync("dist/project.json", "utf8"));
const project = JSON.parse(readFileSync(join(RELEASE, "project.json"), "utf8"));
check(project.version === 3, "version = 3", String(project.version));
check(project.preview === "preview.gif", "preview 指向 preview.gif");
check(!!project.general.properties.bgm, "新增 bgm combo");
@@ -59,7 +60,7 @@ check(
);
const bgmValues = project.general.properties.bgm.options.map((o) => o.value);
check(bgmValues.includes("auto"), "bgm 含「随预设」档", bgmValues.join(", "));
const gifSize = statSync("dist/preview.gif").size;
const gifSize = statSync(join(RELEASE, "preview.gif")).size;
check(gifSize < 500 * 1024, "preview.gif < 500 KB", `${(gifSize / 1024).toFixed(0)} KB`);
// ── ③ 三种比例 0 报错 ──
@@ -107,7 +108,9 @@ const rt = await withPage({ width: 1280, height: 720 }, async ({ send, evaluate,
}
await sleep(500);
// 「使用自定义音乐」必须一起打开:audio_file 只在它为 true 时才生效。
await evaluate(`window.wallpaperPropertyListener.applyUserProperties({
use_custom_audio: { value: true },
audio_file: { value: "https://example.com/custom-track.mp3" },
audio_volume: { value: 0.2 }
})`);
@@ -138,15 +141,15 @@ check(rt.exceptions.length === 0, "往返过程 0 未捕获异常", rt.exception
// ── ⑤ 音频格式:xilian 两个音源必须是无损 ──
console.log("\n=== ⑤ 音频格式与可达性 ===");
const audioDirs = [
["dist/assets/崩坏:星穹铁道/昔涟立绘/audios", "flac", "昔涟立绘(无损)"],
["dist/assets/崩坏:星穹铁道/「成为昨日的明天」/audios", "mp3", "「成为昨日的明天」"],
[join(RELEASE, "audios", "xilian"), "flac", "昔涟立绘(无损)"],
[join(RELEASE, "audios", "kv37"), "mp3", "「成为昨日的明天」"],
];
const codecOf = (f) =>
execFileSync("ffprobe", ["-v", "error", "-select_streams", "a:0", "-show_entries", "stream=codec_name", "-of", "default=nw=1:nk=1", f])
.toString()
.trim();
for (const [dir, want, label] of audioDirs) {
const fs2 = readdirSync(dir);
const fs2 = readdirSync(dir).filter((f) => extname(f) !== ".json");
const codecs = fs2.map((f) => `${f}=${codecOf(join(dir, f))}`);
check(
fs2.length > 0 && fs2.every((f) => codecOf(join(dir, f)) === want),
@@ -162,8 +165,8 @@ const presetFiles = files.filter((f) => basename(f) === "preset.js");
let broken = [];
for (const pf of presetFiles) {
const text = readFileSync(pf, "utf8");
for (const m of text.matchAll(/asset\("(\.\/audios\/[^"]+)"\)/g)) {
const target = join(pf, "..", m[1]);
for (const m of text.matchAll(/source: asset\("((?:\.\.\/)*\.\/audios\/[^"]+)"\)/g)) {
const target = join(pf, "..", m[1].replace(/^\.\//, ""));
if (!existsSync(target)) broken.push(`${pf} → ${m[1]}`);
}
}
+52
View File
@@ -0,0 +1,52 @@
// 验证 bundle 的 "</script" 内联守卫**真的会拦**。
// 一个从不失败的检查等于没有检查——所以临时把源码弄坏,确认构建报错,再还原。
//
// **收尾必须全量重建**:这里用 `--single kv37` 触发构建,而构建默认会删掉"本次未构建"的
// 分发目录。测完只留 single-hsr-kv37,其它三档就没了,后续的基线比对会直接 ENOENT。
// 所以 finally 里跑一次全量 `sim` 构建把 dist 恢复成完整状态。
import { readFileSync, writeFileSync, copyFileSync } from "node:fs";
import { execFileSync } from "node:child_process";
const target = "src/vendor/spine-player.js";
const backup = `${target}.bak`;
const original = readFileSync(target, "utf8");
function run(args: string[]) {
try {
return { ok: true, out: execFileSync("node", ["tools/build.ts", ...args], { encoding: "utf8" }) };
} catch (error) {
const e = error as { stdout?: string; stderr?: string };
return { ok: false, out: String(e.stdout ?? "") + String(e.stderr ?? "") };
}
}
let failed = 0;
copyFileSync(target, backup);
try {
console.log("=== 干净源码应当构建成功 ===");
const clean = run(["sim", "--single", "kv37"]);
console.log(` ${clean.ok ? "✓" : "✗"} 构建成功`);
if (!clean.ok) failed++;
// 追加一个含 </script 的字面量:不破坏语法,只触发守卫。
writeFileSync(target, original + '\nvar __guardProbe = "</script>";\n');
console.log("\n=== 注入 </script 后应当构建失败 ===");
const broken = run(["sim", "--single", "kv37"]);
const caught = !broken.ok && broken.out.includes("截断页面");
console.log(` ${caught ? "✓" : "✗"} 守卫拦住了`);
if (!caught) {
failed++;
console.log(` 输出:${broken.out.split("\n").slice(0, 4).join(" | ")}`);
}
} finally {
writeFileSync(target, original);
}
console.log("\n=== 还原后全量重建 ===");
const restored = run(["sim"]);
console.log(` ${restored.ok ? "✓" : "✗"} 构建恢复成功(四档都在)`);
if (!restored.ok) failed++;
console.log(failed === 0 ? "\n内联守卫:有效" : `\n内联守卫:${failed} 处不符合预期`);
process.exit(failed === 0 ? 0 : 1);
@@ -4,15 +4,18 @@
// 且 bgm 选择跨壁纸时"音频回落、选择保留"。
// B 段验 fps:WE 不替壁纸限流,得自己门控。关键是限流只该丢帧,
// 不该改变动画相位推进速度——用同一段真实时间里 trackTime 前进了多少来判定。
import { withPage, sleep } from "../tools/cdp.mjs";
import { withPage, sleep } from "../cdp.mjs";
const BASE = "http://127.0.0.1:8190/";
// 服务地址:默认 8190(调试服常用端口),可用 SIM_BASE 覆盖。
// 写死端口会在换了端口或换成分发目录时静默测错东西——它只是"连不上",
// 看起来像功能坏了,实际是脚本连错了地方。
const BASE = process.env.SIM_BASE ?? "http://127.0.0.1:8190/";
const name = (src) => (src ? decodeURIComponent(String(src).split("/").pop()) : "(空)");
let failures = 0;
const check = (ok, msg, detail) => {
if (!ok) failures++;
console.log(` ${ok ? "✓" : "✗"} ${msg}${detail !== undefined ? ` → ${detail}` : ""}`);
console.log(` ${ok ? "✓" : "✗"} ${msg}${detail !== undefined ? ` → ${String(detail)}` : ""}`);
};
// ───────────────────────── A 段:音源 ─────────────────────────
@@ -30,6 +33,8 @@ await withPage({ width: 1280, height: 720 }, async ({ send, evaluate, exceptions
const preset = (v) => evaluate(`window.wallpaperPropertyListener.applyUserProperties({ preset: { value: ${JSON.stringify(v)} } })`);
const bgm = (v) => evaluate(`window.wallpaperPropertyListener.applyUserProperties({ bgm: { value: ${JSON.stringify(v)} } })`);
const file = (v) => evaluate(`window.wallpaperPropertyListener.applyUserProperties({ audio_file: { value: ${JSON.stringify(v)} } })`);
// 「使用自定义音乐」是 audio_file 的总开关:关着时填了 URL 也不该生效。
const customAudio = (v) => evaluate(`window.wallpaperPropertyListener.applyUserProperties({ use_custom_audio: { value: ${JSON.stringify(v)} } })`);
const seen = [];
const step = async (label, action, expected) => {
@@ -41,7 +46,7 @@ await withPage({ width: 1280, height: 720 }, async ({ send, evaluate, exceptions
};
// 载入即 xilian,默认音源应是「再度和你」
check(name(await src()) === "「再度和你」.flac", "初始默认音源 = 昔涟立绘默认", name(await src()));
check(name(await src()) === "zaiduheni.flac", "初始默认音源 = 昔涟立绘默认", name(await src()));
// 无损是否真的可用,取决于 CEF 能不能解 FLAC——不能只看路径对不对。
// readyState>=1(HAVE_METADATA) 说明容器与编码被接受,duration>0 说明解析出了正确时长。
@@ -71,20 +76,31 @@ await withPage({ width: 1280, height: 720 }, async ({ send, evaluate, exceptions
check(pb.t > 0.5, "播放头已前进(确实在解码播放)", `currentTime=${Number(pb.t).toFixed(2)} s`);
check(pb.loop === true, "循环播放已开启", String(pb.loop));
await step("bgm 选「昔涟」", () => bgm("xilian"), "昔涟.flac");
// 音源 id 是 build 自增分配的(不再是 zaiduheni / xilian / pv37 这种手写名):
// 分配顺序 = 游戏 → 共享音频 → 各壁纸(按 id 排序),所以 kv37 的「版本 PV」拿到 "1",
// xilian 的「「再度和你」」是 "2"、「昔涟」是 "3"。顺序变了这些数字就会变——这正是
// 自增 id 的代价,改音源清单时要回来同步这里。
await step("bgm 选「昔涟」", () => bgm("3"), "xilian-src.flac");
await step("切到 kv37(选择不属于它)→ 回落", () => preset("kv37"), "pv37.mp3");
await step("bgm 选「版本 PV」(属于 kv37)", () => bgm("pv37"), "pv37.mp3");
await step("切回 xilian(pv37 不属于它)→ 回落", () => preset("xilian"), "「再度和你」.flac");
await step("bgm 选「「再度和你」」", () => bgm("zaiduheni"), "「再度和你」.flac");
await step("bgm 回到「随预设」", () => bgm("auto"), "「再度和你」.flac");
await step("bgm 选「版本 PV」(属于 kv37)", () => bgm("1"), "pv37.mp3");
await step("切回 xilian(pv37 不属于它)→ 回落", () => preset("xilian"), "zaiduheni.flac");
await step("bgm 选「「再度和你」」", () => bgm("2"), "zaiduheni.flac");
await step("bgm 回到「随预设」", () => bgm("auto"), "zaiduheni.flac");
// 用户自填 URL 优先级最高
await step("bgm 选「昔涟」后填自定义 URL", async () => {
await bgm("xilian");
// 用户自填 URL 优先级最高——但要先打开「使用自定义音乐」这个总开关。
await step("开关关着时填 URL 不生效(回落预设默认)", async () => {
await customAudio(false);
await file("https://example.com/ignored.mp3");
}, "zaiduheni.flac");
await step("打开开关后 URL 生效", async () => {
await customAudio(true);
await file("https://example.com/custom-track.mp3");
}, "custom-track.mp3");
await step("切壁纸不得清掉自定义 URL", () => preset("kv37"), "custom-track.mp3");
await step("清空自定义 URL → 回到预设默认", () => file(""), "pv37.mp3");
await step("关掉开关 + 清空 URL → 回到预设默认", async () => {
await customAudio(false);
await file("");
}, "pv37.mp3");
console.log(` 音源轨迹: ${seen.join(" | ")}`);
console.log(` 未捕获异常: ${exceptions.length ? exceptions.join(" | ") : "无 ✓"}`);
@@ -3,9 +3,11 @@
// 判据是"播放器实例身份":给加载后的实例打上标记,之后每次改视口都核对
// window.__player 是否还是同一个对象、标记是否还在。重建会换掉实例,
// 标记随之消失——这比数日志可靠得多。
import { withPage, sleep } from "../tools/cdp.mjs";
import { withPage, sleep } from "../cdp.mjs";
const URL = "http://127.0.0.1:8190/?__freeze=1";
// 服务地址可用 SIM_BASE 覆盖(默认 8190)。写死端口会在换端口时静默连错地方,
// 表现得像功能坏了,其实只是测错了对象。
const URL = (process.env.SIM_BASE ?? "http://127.0.0.1:8190/") + "?__freeze=1";
const sizes = [
[1920, 1080],
[2560, 1440],
+64
View File
@@ -0,0 +1,64 @@
// 验证 sim 门禁**真的会拦**:往骨架里注入一个真实外链标签,看 check:dist 是否报错。
// 一个从不失败的检查等于没有检查——上一版它对整页扫标签,四个分发全是假阳性;
// 收紧之后必须确认它没有紧到"什么都查不到"。
import { readFileSync, writeFileSync, copyFileSync, unlinkSync } from "node:fs";
import { execFileSync } from "node:child_process";
const page = "dist/releases/single-hsr-kv37/sim/index.html";
const backup = `${page}.bak`;
function runCheck() {
try {
return execFileSync("node", ["tools/check-dist.ts"], { encoding: "utf8" });
} catch (error) {
const e = error as { stdout?: string; stderr?: string };
return (e.stdout || "") + (e.stderr || "");
}
}
function inject(html, tag) {
const anchor = '<div id="spine-container"></div>';
if (!html.includes(anchor)) throw new Error("锚点没找到,测试需要更新");
return html.replace(anchor, anchor + tag);
}
const cases = [
["外链 script", '<script src="https://evil.example.com/x.js"></script>'],
["file:// 绝对路径", '<script src="file:///D:/somewhere/x.js"></script>'],
["根绝对路径", '<link rel="stylesheet" href="/styles/x.css" />'],
["type=module", '<script type="module" src="x.js"></script>'],
];
const original = readFileSync(page, "utf8");
copyFileSync(page, backup);
let failed = 0;
try {
console.log("=== 干净页面应当通过 ===");
const clean = runCheck();
const cleanOk = clean.includes("✓ 自包含");
console.log(` ${cleanOk ? "✓" : "✗"} 未注入时通过`);
if (!cleanOk) failed++;
for (const [label, tag] of cases) {
writeFileSync(page, inject(original, tag));
const output = runCheck();
// 必须是**失败**,且失败原因里提到 sim/index.html
const caught = !output.includes("✓ 自包含") && output.includes("sim/index.html");
console.log(` ${caught ? "✓" : "✗"} 拦住了「${label}」`);
if (!caught) {
failed++;
console.log(` check 输出:${output.split("\n").slice(0, 3).join(" | ")}`);
}
}
} finally {
writeFileSync(page, original);
unlinkSync(backup);
}
const restored = runCheck();
console.log(`\n还原后:${restored.includes("✓ 自包含") ? "✓ 通过" : "✗ 仍失败"}`);
if (!restored.includes("✓ 自包含")) failed++;
console.log(failed === 0 ? "\nsim 门禁:四种外部依赖都能拦住" : `\nsim 门禁:${failed} 处不符合预期`);
process.exit(failed === 0 ? 0 : 1);
+140
View File
@@ -0,0 +1,140 @@
// export 改写的**单元测试**:直接调用 tools/lib/bundle.ts 里真正在用的那个函数。
//
// 六个坑,每个都对应一次真实事故,所以每个都有一条断言:
// ① 字符串替换里的 `$1exports.` 被解析成捕获组 "1e" → "exports." 整段消失
// ② 两条规则互相截胡(`export function` 先被吃成 `exports.function`,function 留在原地)
// ③ V8 把声明关键字吞掉 → `exports.foo(a) {`
// ④ 嵌套可选组让捕获位置漂移 → `exports.function resolveViewport(...)`
// ⑤ `export default {`(对象字面量)没有匹配到任何规则 → `exports.default {`
// ⑥ **改写毁掉本地绑定**:`export const X = 1` 直接变成 `exports.X = 1`,
// 于是同模块里别处引用 `X` 变成 ReferenceError。真事故:`frameForAspect` 的
// 默认参数 `referenceAspect = REFERENCE_ASPECT` 直到渲染第一帧才求值——
// 加载、尺寸、资源全部正常,只有画面是白的。所以现在一律保留声明原文,
// 导出推迟到模块体末尾。下面有专门一条断言盯着"声明必须还在"。
import { spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { transformModuleForTest } from "../lib/bundle.ts";
function body(code: string) {
// 去掉包装体,只留模块体,方便断言
const m = /^__weModules\["__test__"\] = \(function \(\) \{\n const exports = \{\};\n([\s\S]*)\n return exports;\n\}\)\(\);\n$/.exec(code);
if (!m) throw new Error("包装体结构变了,测试需要同步更新");
return (m[1] ?? "")
.split("\n")
.map((l) => (l.startsWith(" ") ? l.slice(2) : l))
.join("\n");
}
function parses(code) {
const child = spawnSync(
process.execPath,
["--experimental-vm-modules", "--no-warnings", "-e", 'new (require("node:vm").SourceTextModule)(require("node:fs").readFileSync(0, "utf8"));'],
{ input: code, encoding: "utf8" },
);
return child.status === 0 ? null : (child.stderr || "").split("\n").find((l) => /SyntaxError|ReferenceError/.test(l)) ?? "解析失败";
}
const cases: [string, string][] = [
// [输入, 期望输出(精确,含空格)]
// 声明原文必须**逐字保留**,导出在末尾统一赋值——见文件头 ⑥。
["export default class C {}", "class C {}\nexports.default = C;"],
["export default Presets;", "exports.default = Presets;"],
["export default {", "exports.default = {"],
["export default function f(a) {}", "function f(a) {}\nexports.default = f;"],
["export default async function f(a) {}", "async function f(a) {}\nexports.default = f;"],
["export const x = 1;", "const x = 1;\nexports.x = x;"],
["export let y = 2;", "let y = 2;\nexports.y = y;"],
["export function resolveViewport(v) {}", "function resolveViewport(v) {}\nexports.resolveViewport = resolveViewport;"],
["export async function load(u) {}", "async function load(u) {}\nexports.load = load;"],
["export class PresetController {}", "class PresetController {}\nexports.PresetController = PresetController;"],
// 含 "exports" 的属性访问绝不能被误改
["class A {\n exports2 = 1;\n}", "class A {\n exports2 = 1;\n}"],
["obj.exports = 1;\nobj.exports.foo = 2;", "obj.exports = 1;\nobj.exports.foo = 2;"],
["export const a = 1;\nexport const b = 2;\n", "const a = 1;\nconst b = 2;\n\nexports.a = a;\nexports.b = b;"],
['export const defaultPresetId = "kv37";', 'const defaultPresetId = "kv37";\nexports.defaultPresetId = defaultPresetId;'],
];
let failed = 0;
const report = (ok: unknown, label: string, detail?: unknown) => {
if (!ok) failed++;
console.log(` ${ok ? "✓" : "✗"} ${label}${detail ? `\n ${detail}` : ""}`);
};
console.log("=== 单元断言(调真实实现) ===");
for (const [input, expected] of cases) {
const actual = body(transformModuleForTest(input));
report(
actual === expected,
JSON.stringify(input),
actual === expected ? undefined : `得到 ${JSON.stringify(actual)}\n 期望 ${JSON.stringify(expected)}`,
);
}
// ⑥ 的专用断言:声明必须原样活着。这条比"导出键对得上"更根本——
// 导出键对了、本地绑定没了,症状是运行到某一帧才炸,最难查。
console.log("\n=== 本地绑定必须活着(⑥) ===");
const bindingCases: [string, string][] = [
["export const X = 16 / 9;\nfunction f(a = X) { return a; }", "X"],
["export function f() {}\nfunction g() { return f(); }", "f"],
["export class C {}\nnew C();", "C"],
];
for (const [input, name] of bindingCases) {
const actual = body(transformModuleForTest(input));
// 声明本身要在(`const X =` / `function f(` / `class C `),不能只剩 exports.X = X
const declared = new RegExp(`(?:^|\\n)(?:const|let|var|async function|function|class) ${name}\\b`).test(actual);
report(declared, `${JSON.stringify(input)} 里 ${name} 的声明还在`, declared ? undefined : `实际 ${JSON.stringify(actual)}`);
}
// 声明与赋值都在的话,真跑一遍也不能抛 ReferenceError
console.log("\n=== 改写后可执行(不是只可解析) ===");
const runnable = [
"export const REFERENCE_ASPECT = 16 / 9;\nexport function frameForAspect(a = REFERENCE_ASPECT) { return a; }",
"export function helper() { return 42; }\nexport const value = helper();",
"export class Base {}\nexport const instance = new Base();",
];
for (const input of runnable) {
const code = `const __weModules = {};\nfunction __require(i) { return __weModules[i]; }\n${transformModuleForTest(input)}\n`;
const child = spawnSync(process.execPath, ["--input-type=module", "--no-warnings", "-e", code], { encoding: "utf8" });
const ok = child.status === 0;
report(ok, `${JSON.stringify(input.slice(0, 46))}… 执行不抛错`, ok ? undefined : (child.stderr || "").split("\n").slice(0, 3).join(" | "));
}
console.log("\n=== 真实产物:改写后必须可解析 ===");
const files = [ "build/scripts/index.js",
"build/scripts/viewport-fitter.js",
"build/scripts/preset-controller.js",
"build/scripts/wallpaper-engine.js",
"dist/releases/single-hsr-kv37/scripts/presets.js",
// 生成的 preset.js 里是 `export default {` 对象字面量——最容易漏的一类
"dist/releases/single-hsr-kv37/preset.js",
];
for (const file of files) {
let source;
try {
source = readFileSync(file, "utf8");
} catch {
report(false, `${file} 不存在`);
continue;
}
const wrapped = `const __weModules = {};\nfunction __require(i) { return __weModules[i]; }\n${transformModuleForTest(source)}\n`;
const problem = parses(wrapped);
report(problem === null, `${file} 改写后可解析`, problem ?? undefined);
}
// 关键:改写后 exports 的键必须与源码里的默认导出/具名导出对得上
console.log("\n=== 语义断言:导出的键不能丢 ===");
const semantic: [string, string[]][] = [
["export default { id: 1 };", ["default"]],
["export default Presets;", ["default"]],
["export default class C {}", ["default"]],
["export default function f() {}", ["default"]],
["export const a = 1;\nexport function b() {}", ["a", "b"]],
];
for (const [input, keys] of semantic) {
const actual = body(transformModuleForTest(input));
const missing = keys.filter((k) => !new RegExp(`^exports\\.${k}\\b`, "m").test(actual));
report(missing.length === 0, `${JSON.stringify(input)} 导出 ${JSON.stringify(keys)}`, missing.length ? `缺 ${JSON.stringify(missing)},实际 ${JSON.stringify(actual)}` : undefined);
}
console.log(failed === 0 ? "\nexport 改写:全部通过" : `\nexport 改写:${failed} 处失败`);
process.exit(failed === 0 ? 0 : 1);
+77
View File
@@ -0,0 +1,77 @@
// 验收「构建开关在热更新重建里保持」。
//
// 上一版这个验证是**空的**:调试服因为参数不认识根本没起来,于是"没重建"被当成了"重建后开关还在"。
// 所以这里第一件事就是断言服务真的活着——一个不检查前置条件的验证等于没有验证。
//
// 用法:node tools/checks/verify-dev-flags.mts [base]
// 前提:调试服以 --with-sim 启动,且产物里本来就带模拟器。
import { appendFileSync, readdirSync, existsSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { sleep } from "../cdp.mjs";
const BASE = process.argv[2] ?? "http://127.0.0.1:5173";
const CSS = "src/styles/index.css";
const RELEASES = "dist/releases";
let failures = 0;
const check = (ok: unknown, label: string, detail?: unknown) => {
if (!ok) failures++;
console.log(` ${ok ? "✓" : "✗"} ${label}${detail !== undefined ? ` → ${String(detail)}` : ""}`);
};
const simulatorCount = () => {
let n = 0;
for (const dir of readdirSync(RELEASES)) {
if (existsSync(join(RELEASES, dir, "scripts", "wallpaper-engine.js"))) n += 1;
}
return n;
};
const original = readFileSync(CSS, "utf8");
try {
console.log("=== ① 前置条件:调试服真的活着 ===");
let alive = false;
try {
const res = await fetch(`${BASE}/`);
alive = res.status === 200;
} catch {
alive = false;
}
check(alive, "调试服响应 GET /", alive ? BASE : "连不上——后面所有结论都不成立");
if (!alive) throw new Error("调试服没起来,验证无意义");
const before = simulatorCount();
check(before > 0, "重建前产物里带模拟器", `${before} 个分发`);
if (before === 0) throw new Error("产物里没有模拟器,请先 node tools/build.ts --with-sim");
console.log("\n=== ② 保存一次源文件,等热更新重建 ===");
appendFileSync(CSS, `\n/* dev-flags probe ${Date.now()} */\n`);
// 用"被改的 CSS 已经能被取到新内容"作为重建完成的判据,而不是干等固定秒数。
let rebuilt = false;
const deadline = Date.now() + 30000;
while (Date.now() < deadline) {
await sleep(400);
try {
const text = await (await fetch(`${BASE}/release/collection-all/styles/index.css`)).text();
if (/dev-flags probe/.test(text)) {
rebuilt = true;
break;
}
} catch {
// 重建中偶尔取不到,继续等
}
}
check(rebuilt, "重建完成(调试服已能取到新的 CSS)");
console.log("\n=== ③ 开关是否保持 ===");
const after = simulatorCount();
check(after === before, "--with-sim 在热更新重建后仍然生效", `${before} → ${after}`);
} finally {
writeFileSync(CSS, original);
await sleep(3000);
console.log("\n源文件已还原");
}
console.log(failures === 0 ? "构建开关保持:通过" : `构建开关保持:失败 ${failures} 处`);
process.exit(failures === 0 ? 0 : 1);
@@ -2,7 +2,7 @@
// 两条硬约束:① 16:9 下必须与作者设定完全一致;② 立绘层缩放比必须等于背景 cover 缩放比。
import { readFileSync } from "node:fs";
const src = readFileSync(new URL("../dist/scripts/viewport-fitter.js", import.meta.url), "utf8").replace(/^export\s+/gm, "");
const src = readFileSync(new URL("../../dist/releases/collection-all/scripts/viewport-fitter.js", import.meta.url), "utf8").replace(/^export\s+/gm, "");
const mod = new Function(
src + "\nreturn { contain, frameForAspect, coverScale, resolveViewport, toViewportConfig, REFERENCE_ASPECT };",
)();
+95
View File
@@ -0,0 +1,95 @@
// 热更新验收:真的改一个源文件,看浏览器是不是按预期更新。
//
// 断言两件不同的事,不能混为一谈:
// · 改 CSS → **只换样式表**,页面不重载(Spine 播放器与面板状态都保住)
// · 改 runtime → **整页刷新**
//
// 用"页面上的标记还在不在"来区分这两者:整页刷新会把 window 上的标记抹掉,
// 只换样式表则不会。只看"页面变了没有"是分不出这两种情况的。
//
// 用法:node tools/checks/verify-hot-reload.mts [base]
// 前提:调试服在跑,且是**新版**(带热更新)的 tools/dev.ts。
import { appendFileSync, readFileSync, writeFileSync } from "node:fs";
import { withPage, sleep } from "../cdp.mjs";
const BASE = process.argv[2] ?? "http://127.0.0.1:5173";
const RELEASE = "collection-all";
const CSS_FILE = "src/styles/index.css";
const TS_FILE = "src/runtime/viewport-fitter.ts";
const cssOriginal = readFileSync(CSS_FILE, "utf8");
const tsOriginal = readFileSync(TS_FILE, "utf8");
let failures = 0;
const check = (ok: unknown, label: string, detail?: unknown) => {
if (!ok) failures++;
console.log(` ${ok ? "✓" : "✗"} ${label}${detail !== undefined ? ` → ${String(detail)}` : ""}`);
};
/** 等到条件成立,超时返回 false。 */
async function waitFor(evaluate, expression, ms, tag) {
const deadline = Date.now() + ms;
while (Date.now() < deadline) {
if (await evaluate(expression).catch(() => false)) return true;
await sleep(150);
}
console.log(` (${tag} 等待超时)`);
return false;
}
try {
await withPage({ width: 1280, height: 900, profile: "tools/.cache/edge-hotreload" }, async ({ send, evaluate }) => {
await send("Page.navigate", { url: `${BASE}/release/${RELEASE}/` });
await sleep(3500);
console.log("=== ① 注入与连接 ===");
const injected = await evaluate(`!!document.querySelector('script[src*="/__dev/events"]') || document.documentElement.innerHTML.includes("/__dev/events")`);
check(injected === true, "页面里注入了热更新客户端");
const esState = await evaluate(`(function(){
// EventSource 没有暴露实例,用一条同地址的连接探一下服务端在不在
return fetch("/__dev/events", { method: "GET" }).then(function (r) {
return r.headers.get("content-type") || "";
}).catch(function () { return "ERR"; });
})()`);
check(/text\/event-stream/.test(esState ?? ""), "SSE 端点返回 text/event-stream", esState);
console.log("\n=== ② 改 CSS:只换样式表,不整页重载 ===");
await evaluate(`window.__hotReloadMarker = "css";`);
// 页面有两条样式表(spine-player.css / index.css),要盯的是**被改的那一条**,
// 盯第一条会把"换了别的样式表"当成成功。
const indexHref = `(function(){
var links = document.querySelectorAll('link[rel="stylesheet"]');
for (var i = 0; i < links.length; i++) if (links[i].href.indexOf("index.css") >= 0) return links[i].href;
return null;
})()`;
const beforeHref = await evaluate(indexHref);
appendFileSync(CSS_FILE, `\n/* hot-reload probe ${Date.now()} */\n`);
const swapped = await waitFor(evaluate, `${indexHref} !== ${JSON.stringify(beforeHref)}`, 25000, "样式表换新");
check(swapped, "被改的样式表 href 换成了带 ?t= 的新地址", await evaluate(indexHref));
const markerAfterCss = await evaluate(`window.__hotReloadMarker`);
check(markerAfterCss === "css", "页面**没有**整页重载(window 上的标记还在)", String(markerAfterCss));
const cssText = await evaluate(`fetch(${indexHref}).then(function(r){ return r.text(); })`);
check(/hot-reload probe/.test(cssText ?? ""), "换上的样式表确实是新内容");
console.log("\n=== ③ 改 runtime:整页刷新 ===");
writeFileSync(CSS_FILE, cssOriginal); // 先把 CSS 还原,免得下面这一轮被判成 css-only
await sleep(2500);
await evaluate(`window.__hotReloadMarker = "ts";`);
const markerBefore = await evaluate(`window.__hotReloadMarker`);
check(markerBefore === "ts", "刷新前标记已就位", String(markerBefore));
appendFileSync(TS_FILE, `\n// hot-reload probe ${Date.now()}\n`);
const reloaded = await waitFor(evaluate, `window.__hotReloadMarker === undefined`, 30000, "整页刷新");
check(reloaded, "页面整页刷新了(标记被抹掉)");
check((await evaluate(`!!document.getElementById("wesim")`)) === true, "刷新后模拟器面板重新挂上");
});
} finally {
// 无论成败都要还原源码,否则测试会把探针注释留在仓库里
writeFileSync(CSS_FILE, cssOriginal);
writeFileSync(TS_FILE, tsOriginal);
await sleep(2500);
console.log("\n源文件已还原");
}
console.log(failures === 0 ? "热更新:通过" : `热更新:失败 ${failures} 处`);
process.exit(failures === 0 ? 0 : 1);
+549
View File
@@ -0,0 +1,549 @@
// 面板还原度验收:模拟器的设置面板必须长得像 Wallpaper Engine 官方面板。
//
// 断言的是**官方面板的可观察特征**,不是"我们的实现细节":
// · 标题栏是 "Wallpaper Settings" + 重置(官方就是这么写的,只有重置跟着界面语言走)
// · 属性按 order 排成"图标 + 标签 + 右侧控件"的行,而不是分区堆叠的调试列表
// · WE 自带的颜色块:主题配色 → 翻转 → 显示颜色选项 → [亮度/对比度/饱和度/色调偏移]
// · 点色块弹出取色器(调色板 + 明度/饱和度方块 + 色相条 + 十六进制 + 确认/取消)
// · type:"text" 渲染成真正的富文本说明块(<ul> 列表 + <a> 链接),不是截断的一行提示
// · condition 生效(显示作者信息、使用自定义音乐两个开关都各管一摊)
// · 底部是 确认 / 取消,且取消能回滚、重置能恢复默认
//
// 用法:node tools/checks/verify-panel.mts [base]
import { withPage, sleep } from "../cdp.mjs";
const BASE = process.argv[2] ?? "http://127.0.0.1:5173";
const RELEASE = "collection-all";
let failures = 0;
const check = (ok: unknown, label: string, detail?: unknown) => {
if (!ok) failures++;
console.log(` ${ok ? "✓" : "✗"} ${label}${detail !== undefined ? ` → ${String(detail)}` : ""}`);
};
const probe = `(function(){
var root = document.getElementById("wesim");
if (!root) return null;
var q = function (s) { return root.querySelector(s); };
var rows = Array.prototype.map.call(root.querySelectorAll(".body .row"), function (r) {
var c = r.querySelector(".ctl");
var input = c && c.querySelector("input, select");
var kind = "none";
if (input) kind = input.type === "select-one" ? "select" : input.type;
else if (c && c.querySelector(".combo")) kind = "combo";
else if (c && c.querySelector(".swatch")) kind = "swatch";
return {
icon: (r.querySelector(".ico") || {}).textContent || "",
label: (r.querySelector(".label") || {}).textContent || "",
control: kind,
value: input ? input.value : (c && c.querySelector(".combo") ? c.querySelector(".combo").dataset.value : null),
comboLabel: c && c.querySelector(".combo-label") ? c.querySelector(".combo-label").textContent : null
};
});
var notes = Array.prototype.map.call(root.querySelectorAll(".body .note"), function (n) {
return {
text: n.textContent.replace(/\\s+/g, " ").trim(),
items: n.querySelectorAll("li").length,
links: Array.prototype.map.call(n.querySelectorAll("a"), function (a) { return a.getAttribute("href"); })
};
});
return {
title: (q(".title .name") || {}).textContent || null,
reset: (q(".title .reset") || {}).textContent || null,
rows: rows,
notes: notes,
foot: Array.prototype.map.call(root.querySelectorAll(".foot button"), function (b) { return b.textContent; }),
hasDebug: !!q(".dbg"),
debugOpen: q(".dbg") ? q(".dbg").open : null,
banner: (q(".banner") || {}).textContent || null,
outsideBody: root.parentElement === document.documentElement,
bodyTransform: document.body.style.transform,
bodyFilter: document.body.style.filter
};
})()`;
/** 按标签点一个控件的 change(复刻用户在面板上的操作)。 */
const clickByLabel = (label, action) => `(function(){
var rows = document.querySelectorAll("#wesim .body .row");
for (var i = 0; i < rows.length; i++) {
var l = rows[i].querySelector(".label");
if (!l || l.textContent !== ${JSON.stringify(label)}) continue;
${action}
}
return false;
})()`;
const toggle = (label) =>
clickByLabel(label, `var cb = rows[i].querySelector("input[type=checkbox]");
cb.checked = !cb.checked;
cb.dispatchEvent(new Event("change", { bubbles: true }));
return true;`);
const setSlider = (label, value) =>
clickByLabel(label, `var r = rows[i].querySelector("input[type=range]");
r.value = ${JSON.stringify(String(value))};
r.dispatchEvent(new Event("input", { bubbles: true }));
return true;`);
const result = await withPage({ width: 1920, height: 1080, profile: "tools/.cache/edge-panel" }, async ({ send, evaluate, consoleLines, exceptions }) => {
await send("Page.navigate", { url: `${BASE}/release/${RELEASE}/` });
await sleep(3500);
const initial = await evaluate(probe);
// ── 显示颜色选项 → 四个滑杆 ──
await evaluate(toggle("显示颜色选项"));
await sleep(300);
const withColorOptions = await evaluate(probe);
// 这一批新冒出来的行应当带 .row-enter,而原本就在的行不该带——
// 否则每改一个属性整个面板都会重播一遍入场动画。
const enterFlags = await evaluate(`(function(){
return Array.prototype.map.call(document.querySelectorAll("#wesim .body .row"), function (r) {
return { label: (r.querySelector(".label") || {}).textContent || "", enter: r.classList.contains("row-enter") };
});
})()`);
await evaluate(setSlider("亮度", 80));
await sleep(200);
const afterBrightness = await evaluate(`document.body.style.filter`);
// ── 翻转 ──
await evaluate(toggle("翻转"));
await sleep(200);
const afterFlip = await evaluate(`document.body.style.transform`);
await evaluate(toggle("翻转"));
await sleep(200);
// ── 取色器 ──
await evaluate(`document.querySelector("#wesim .swatch").click()`);
await sleep(400);
const pickerProbe = `(function(){
var p = document.getElementById("wesim-picker");
if (!p) return null;
var swatches = p.querySelectorAll(".pk-palette button:not(.pk-dropper)");
var checked = p.querySelector(".pk-palette button.on");
var dropper = p.querySelector(".pk-dropper");
return {
outsideBody: p.parentElement === document.documentElement,
name: (p.querySelector(".pk-name") || {}).textContent || null,
palette: swatches.length,
colors: Array.prototype.map.call(swatches, function (b) { return b.title; }),
checked: checked ? checked.title : null,
hasSv: !!p.querySelector(".pk-sv"),
hasHue: !!p.querySelector(".pk-hue"),
hex: (p.querySelector(".pk-hex") || {}).value || null,
dropper: !!dropper,
dropperDisabled: dropper ? dropper.disabled : null,
dropperTitle: dropper ? dropper.title : null,
foot: Array.prototype.map.call(p.querySelectorAll(".pk-foot button"), function (b) { return b.textContent; })
};
})()`;
const picker = await evaluate(pickerProbe);
// 把颜色改成某个预设色,对勾应当落到那个色块上
await evaluate(`(function(){
var hex = document.querySelector("#wesim-picker .pk-hex");
hex.value = "#ff0000";
hex.dispatchEvent(new Event("change", { bubbles: true }));
})()`);
await sleep(200);
const pickerChecked = await evaluate(pickerProbe);
// 用取色器改颜色并确认
await evaluate(`(function(){
var hex = document.querySelector("#wesim-picker .pk-hex");
hex.value = "#123456";
hex.dispatchEvent(new Event("change", { bubbles: true }));
document.querySelector("#wesim-picker .pk-foot button.primary").click();
})()`);
await sleep(300);
const colorAfterPick = await evaluate(`window.__weSim.props.schemecolor`);
const pickerGone = await evaluate(`!document.getElementById("wesim-picker")`);
// ── 下拉:自绘控件,弹出列表也要能验 ──
const openPresetCombo = `(function(){
var rows = document.querySelectorAll("#wesim .body .row");
for (var i = 0; i < rows.length; i++) {
var l = rows[i].querySelector(".label");
if (l && l.textContent === "壁纸预设切换") { rows[i].querySelector(".combo-btn").click(); return true; }
}
return false;
})()`;
const comboProbe = `(function(){
var box = document.getElementById("wesim-combo");
if (!box) return null;
return {
outsideBody: box.parentElement === document.documentElement,
items: Array.prototype.map.call(box.querySelectorAll(".combo-item"), function (b) {
return { label: b.textContent, value: b.dataset.value, on: b.classList.contains("on") };
})
};
})()`;
await evaluate(openPresetCombo);
await sleep(250);
const comboOpen = await evaluate(comboProbe);
// 选第二项 → 属性应当跟着变、弹层收起、按钮文字换掉
await evaluate(`document.querySelectorAll("#wesim-combo .combo-item")[1].click()`);
await sleep(400);
const comboClosed = await evaluate(`!document.getElementById("wesim-combo")`);
const presetAfter = await evaluate(`window.__weSim.props.preset`);
const comboLabelAfter = await evaluate(`(function(){
var rows = document.querySelectorAll("#wesim .body .row");
for (var i = 0; i < rows.length; i++) {
var l = rows[i].querySelector(".label");
if (l && l.textContent === "壁纸预设切换") return rows[i].querySelector(".combo-label").textContent;
}
return null;
})()`);
// ── condition:关掉「显示作者信息」,作者信息说明块应当消失 ──
await evaluate(toggle("显示作者信息"));
await sleep(300);
const hidden = await evaluate(probe);
// ── 使用自定义音乐:audio_file 与 bgm 互换 ──
await evaluate(toggle("使用自定义音乐"));
await sleep(300);
const customAudio = await evaluate(probe);
await evaluate(toggle("使用自定义音乐"));
await sleep(300);
const presetAudio = await evaluate(probe);
// ── 重置 / 取消 / 确认 ──
await evaluate(setSlider("音频音量调整", 0.3));
await sleep(200);
const afterEdit = await evaluate(`window.__weSim.props.audio_volume`);
await evaluate(`document.querySelector("#wesim .title .reset").click()`);
await sleep(300);
const afterReset = await evaluate(`window.__weSim.props.audio_volume`);
await evaluate(setSlider("音频音量调整", 0.2));
await sleep(200);
const beforeCancel = await evaluate(`window.__weSim.props.audio_volume`);
await evaluate(`document.querySelector("#wesim .foot button:not(.primary)").click()`);
await sleep(300);
const afterCancel = await evaluate(`window.__weSim.props.audio_volume`);
await evaluate(setSlider("音频音量调整", 0.6));
await sleep(200);
await evaluate(`document.querySelector("#wesim .foot button.primary").click()`);
await sleep(300);
const afterConfirm = await evaluate(`window.__weSim.props.audio_volume`);
// ── 动效:真的滑出一次才看得到 data-enter ──
// 先移到远处再移到边缘:前面点过「取消」,那会把 armed 置 false,
// 而 armed 只有"指针离开边缘"才会复位——不先走开一次,合成指针是打不开面板的。
await send("Input.dispatchMouseEvent", { type: "mouseMoved", x: 200, y: 540, button: "none", buttons: 0 });
await sleep(120);
await send("Input.dispatchMouseEvent", { type: "mouseMoved", x: 1912, y: 540, button: "none", buttons: 0 });
await sleep(260);
const enterProbe = `(function(){
var root = document.getElementById("wesim");
var rows = root.querySelectorAll(".body .row");
return {
open: root.dataset.open,
dataEnter: root.dataset.enter || null,
seq: Array.prototype.map.call(rows, function (r) { return r.style.getPropertyValue("--i"); }).slice(0, 5),
rowAnim: rows.length ? getComputedStyle(rows[0]).animationName : null,
rowDelay: rows.length ? getComputedStyle(rows[0]).animationDelay : null,
hoverTransition: rows.length ? getComputedStyle(rows[0]).transitionProperty : null,
pickerAnim: (function () {
document.querySelector("#wesim .swatch").click();
var p = document.getElementById("wesim-picker");
return p ? getComputedStyle(p).animationName : null;
})()
};
})()`;
const anim = await evaluate(enterProbe);
await evaluate(`(function(){ var p = document.getElementById("wesim-picker"); if (p) p.remove(); })()`);
await sleep(600);
const afterEnter = await evaluate(`document.getElementById("wesim").dataset.enter || null`);
// 复选框是自绘的:appearance 关掉、勾靠背景图长出来
const checkbox = await evaluate(`(function(){
var cb = document.querySelector("#wesim .body input[type=checkbox]");
if (!cb) return null;
var before = getComputedStyle(cb).backgroundSize;
cb.checked = !cb.checked;
cb.dispatchEvent(new Event("change", { bubbles: true }));
return { appearance: getComputedStyle(cb).appearance, before: before };
})()`);
// 下拉箭头靠 aria-expanded 翻转
await evaluate(`(function(){
var rows = document.querySelectorAll("#wesim .body .row");
for (var i = 0; i < rows.length; i++) {
var l = rows[i].querySelector(".label");
if (l && l.textContent === "背景音乐选择") { rows[i].querySelector(".combo-btn").click(); return true; }
}
return false;
})()`);
await sleep(250);
const arrowOpen = await evaluate(`(function(){
var b = document.querySelector("#wesim .combo-btn[aria-expanded]");
return { expanded: b ? b.getAttribute("aria-expanded") : null,
rotated: b ? getComputedStyle(b.querySelector(".combo-arrow")).transform : null };
})()`);
await evaluate(`(function(){ document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); })()`);
await sleep(250);
const arrowClosed = await evaluate(`(function(){
var b = document.querySelector("#wesim .combo-btn[aria-expanded]");
return b ? b.getAttribute("aria-expanded") : null;
})()`);
// 系统开了"减少动态效果"时应当全部关掉
await send("Emulation.setEmulatedMedia", {
features: [{ name: "prefers-reduced-motion", value: "reduce" }],
});
await sleep(200);
const reduced = await evaluate(`(function(){
var r = document.querySelector("#wesim .body .row");
var p = document.getElementById("wesim-picker");
return { rowAnim: r ? getComputedStyle(r).animationDuration : null,
rowTransition: r ? getComputedStyle(r).transitionDuration : null };
})()`);
await send("Emulation.setEmulatedMedia", { features: [] });
// ── 预览图:设置面板上方 ──
const previewProbe = `(function(){
var p = document.querySelector("#wesim .preview");
if (!p) return { present: false };
var img = p.querySelector("img");
var src = img ? img.getAttribute("src") : null;
return {
present: true,
src: src,
isData: src ? src.indexOf("data:") === 0 : null,
natural: img ? img.naturalWidth + "x" + img.naturalHeight : null,
navs: Array.prototype.map.call(p.querySelectorAll(".nav"), function (b) {
return { cls: b.className, disabled: b.disabled, glyph: b.textContent };
})
};
})()`;
const preview = await evaluate(previewProbe);
// ── 单档分发里的「背景音乐选择」──
//
// 这条是回归断言:bgm 曾经跟着 preset 一起被删掉("只剩一档壁纸,切换没有意义"),
// 但那个理由只对**预设切换**成立——一档壁纸照样能带好几首曲子(xilian 就是两首)。
// 结果 single-hsr-xilian 里根本没有切换入口,而当时没有任何断言盯着这件事。
const bgmRowProbe = `(function(){
var rows = document.querySelectorAll("#wesim .body .row");
for (var i = 0; i < rows.length; i++) {
var l = rows[i].querySelector(".label");
if (l && l.textContent === "背景音乐选择") {
var combo = rows[i].querySelector(".combo");
var label = rows[i].querySelector(".combo-label");
return { present: true, value: combo ? combo.dataset.value : null,
label: label ? label.textContent : null };
}
}
return { present: false };
})()`;
await send("Page.navigate", { url: `${BASE}/release/single-hsr-xilian/` });
await sleep(3200);
const singleTwoTracks = await evaluate(bgmRowProbe);
// 展开看有几个选项
await evaluate(`(function(){
var rows = document.querySelectorAll("#wesim .body .row");
for (var i = 0; i < rows.length; i++) {
var l = rows[i].querySelector(".label");
if (l && l.textContent === "背景音乐选择") { rows[i].querySelector(".combo-btn").click(); return true; }
}
return false;
})()`);
await sleep(250);
const singleTwoTrackItems = await evaluate(
`Array.prototype.map.call(document.querySelectorAll("#wesim-combo .combo-item"), function (b) { return b.textContent; })`,
);
await send("Page.navigate", { url: `${BASE}/release/single-hsr-kv37/` });
await sleep(3200);
const singleOneTrack = await evaluate(bgmRowProbe);
return {
initial, withColorOptions, enterFlags, anim, afterEnter, checkbox, arrowOpen, arrowClosed, reduced,
preview,
singleTwoTracks, singleTwoTrackItems, singleOneTrack,
afterBrightness, afterFlip, picker, pickerChecked, colorAfterPick, pickerGone,
comboOpen, comboClosed, presetAfter, comboLabelAfter,
hidden, customAudio, presetAudio, afterEdit, afterReset, beforeCancel, afterCancel, afterConfirm,
consoleLines, exceptions,
};
});
const r = result;
const labelsOf = (state) => (state?.rows ?? []).map((x) => x.label);
console.log("=== ① 标题栏(官方:" + '"Wallpaper Settings" + 重置' + ")===");
check(r.initial?.title === "Wallpaper Settings", '标题是 "Wallpaper Settings"', r.initial?.title);
check(/重置/.test(r.initial?.reset ?? ""), "右侧有「重置」", r.initial?.reset?.trim());
check(/模拟环境/.test(r.initial?.banner ?? ""), "顶部保留「模拟环境」横幅(ADR 0006 要求,官方没有)");
console.log("\n=== ② 属性行:图标 + 标签 + 右侧控件,按 order 排 ===");
console.log(` 实际顺序:${labelsOf(r.initial).join(" / ")}`);
const expectOrder = ["主题配色", "翻转", "显示颜色选项", "显示作者信息", "壁纸预设切换", "音频音量调整", "使用自定义音乐", "背景音乐选择"];
check(JSON.stringify(labelsOf(r.initial)) === JSON.stringify(expectOrder), "行顺序与 order 字段一致", `${labelsOf(r.initial).length} 行`);
check(r.initial?.rows?.[0]?.control === "swatch", "主题配色渲染成色块", r.initial?.rows?.[0]?.control);
check((r.initial?.rows ?? []).filter((x) => x.control === "combo").length === 2, "两个 combo(壁纸预设切换 / 背景音乐选择)都渲染成自绘下拉");
check((r.initial?.rows ?? []).some((x) => x.label === "音频音量调整" && x.control === "range"), "slider 渲染成 range");
const icons = (r.initial?.rows ?? []).filter((x) => x.icon).length;
check(icons >= 3, "属性文本开头的 emoji 被当作行图标", `${icons} 行有图标`);
console.log("\n=== ③ WE 自带的颜色块 ===");
check(r.initial?.rows?.[1]?.label === "翻转" && r.initial?.rows?.[1]?.control === "checkbox", "「翻转」是 WE 自带的一行");
check(labelsOf(r.initial).includes("亮度") === false, "默认不显示四个颜色滑杆(显示颜色选项未勾)");
check(
JSON.stringify(labelsOf(r.withColorOptions).slice(2, 7)) === JSON.stringify(["显示颜色选项", "亮度", "对比度", "饱和度", "色调偏移"]),
"勾上「显示颜色选项」后出现四个滑杆",
labelsOf(r.withColorOptions).slice(2, 7).join(" / "),
);
check((r.withColorOptions?.rows ?? []).filter((x) => x.control === "range").length === 5, "共 5 个滑杆(4 颜色 + 1 音量)");
check(/brightness/.test(r.afterBrightness ?? ""), "亮度落到 body 的 CSS 滤镜上", r.afterBrightness);
check(r.afterFlip === "scaleX(-1)", "翻转落到 body 的 transform 上", r.afterFlip);
check(r.initial?.outsideBody === true, "面板挂在 <html> 下(body 上的 transform/filter 不会波及它)");
console.log("\n=== ④ 取色器弹层 ===");
check(r.picker !== null, "点色块弹出取色器");
check(r.picker?.outsideBody === true, "取色器也在 body 之外(不被滤镜调色)");
check(/主题配色/.test(r.picker?.name ?? ""), "弹层标题是「主题配色」", r.picker?.name);
check(r.picker?.palette === 15, "调色板 15 个色块(3 列 × 5 行)", `${r.picker?.palette} 个`);
const official = [
"#ffffff", "#c0c0c0", "#000000",
"#ff0000", "#ffa500", "#ffff00",
"#00ff00", "#008000", "#254117",
"#add8e6", "#0000ff", "#00008b",
"#00ffff", "#800080", "#ff00ff",
];
check(
JSON.stringify(r.picker?.colors) === JSON.stringify(official),
"15 个预设色与官方逐个一致",
JSON.stringify(r.picker?.colors) === JSON.stringify(official) ? "一致" : (r.picker?.colors ?? []).join(" "),
);
check(r.picker?.checked === "#63269e" || r.picker?.checked === null, "初始颜色不是预设色时没有对勾", String(r.picker?.checked));
check(r.pickerChecked?.checked === "#ff0000", "颜色改成预设色后,对勾落到那个色块上", String(r.pickerChecked?.checked));
check(r.picker?.dropper === true, "调色板下方有吸管按钮");
check(
r.picker?.dropperDisabled === false || r.picker?.dropperDisabled === true,
r.picker?.dropperDisabled ? "吸管已禁用并说明原因(本机 CEF 无 EyeDropper API)" : "吸管可用(EyeDropper API 存在)",
r.picker?.dropperTitle,
);
check(r.picker?.hasSv === true && r.picker?.hasHue === true, "有明度/饱和度方块与色相条");
check(/^#[0-9a-f]{6}$/i.test(r.picker?.hex ?? ""), "十六进制输入框有值", r.picker?.hex);
check(JSON.stringify(r.picker?.foot) === JSON.stringify(["确认", "取消"]), "弹层底部是 确认 / 取消", (r.picker?.foot ?? []).join(" / "));
check(/^0\.\d+ 0\.\d+ 0\.\d+$/.test(r.colorAfterPick ?? ""), "确认后写回 schemecolor(r g b 浮点)", r.colorAfterPick);
check(r.pickerGone === true, "确认后弹层关闭");
console.log("\n=== ⑤ type:text 渲染成富文本说明块 ===");
const notes = r.initial?.notes ?? [];
check(notes.length >= 3, "说明块都在", `${notes.length} 个`);
check(notes.filter((n) => n.items > 0).length >= 2, "说明块里有 <ul><li> 列表");
const links = notes.flatMap((n) => n.links ?? []);
check(links.length > 0 && links.every((h) => /^https?:/.test(h ?? "")), "链接被保留且只放行 http(s)", links.join(" | "));
console.log("\n=== ⑥ condition:两个开关各管一摊 ===");
check(!(r.hidden?.notes ?? []).some((n) => n.text.includes("作者:")), "关掉「显示作者信息」→ 作者信息块消失");
check(!labelsOf(r.customAudio).includes("背景音乐选择"), "开「使用自定义音乐」→ 背景音乐选择隐藏");
check(labelsOf(r.customAudio).includes("音频文件路径"), "开「使用自定义音乐」→ 音频文件路径出现");
check(labelsOf(r.presetAudio).includes("背景音乐选择"), "关「使用自定义音乐」→ 背景音乐选择回来");
check(!labelsOf(r.presetAudio).includes("音频文件路径"), "关「使用自定义音乐」→ 音频文件路径隐藏");
console.log("\n=== ⑦ 底部 确认 / 取消 + 重置语义 ===");
check(JSON.stringify(r.initial?.foot) === JSON.stringify(["确认", "取消"]), "底部是 确认 / 取消", (r.initial?.foot ?? []).join(" / "));
check(r.afterEdit === 0.3, "改音量已实时下发", String(r.afterEdit));
check(r.afterReset === 1, "重置恢复默认值 1", String(r.afterReset));
check(r.beforeCancel === 0.2 && r.afterCancel === 1, "取消回滚到上次确认的值", `${r.beforeCancel} → ${r.afterCancel}`);
check(r.afterConfirm === 0.6, "确认保留改动", String(r.afterConfirm));
console.log("\n=== ⑧ 模拟器专属内容收进折叠区 ===");
check(r.initial?.hasDebug === true && r.initial?.debugOpen === false, "「模拟器调试」存在且默认收起");
console.log("\n=== ⑨ 下拉框(自绘,样式与官方同步)===");
check(r.comboOpen !== null, "点下拉按钮弹出列表");
check(r.comboOpen?.outsideBody === true, "弹层挂在 documentElement 下(不被 body 的滤镜波及)");
const comboItems = r.comboOpen?.items ?? [];
console.log(` 选项:${comboItems.map((x) => `${x.label}${x.on ? "(当前)" : ""}`).join(" / ")}`);
check(comboItems.length === 2, "两个预设都在列表里", `${comboItems.length} 项`);
check(comboItems.filter((x) => x.on).length === 1, "当前项恰好一个被高亮", comboItems.filter((x) => x.on).map((x) => x.label).join(""));
check(r.comboClosed === true, "选完之后弹层收起");
check(r.presetAfter === comboItems[1]?.value, "选中的值下发给壁纸", `${r.presetAfter}`);
check(r.comboLabelAfter === comboItems[1]?.label, "按钮上的文字跟着换成所选项", String(r.comboLabelAfter));
console.log("\n=== ⑩ 动效(只解释状态变化,且受 prefers-reduced-motion 管辖)===");
check(r.anim?.open === "1", "面板已滑出", String(r.anim?.open));
check(r.anim?.dataEnter === "1", "刚滑出时打了入场标记", String(r.anim?.dataEnter));
check(/wesim-row-in/.test(r.anim?.rowAnim ?? ""), "行上跑的是入场动画", r.anim?.rowAnim);
check(
(r.anim?.seq ?? []).join(",") === "0,1,2,3,4",
"行带递增的 --i(用来错开入场)",
(r.anim?.seq ?? []).join(","),
);
check(r.afterEnter === null, "入场标记播完就摘掉(不会每次重画都重播)", String(r.afterEnter));
check(/background-color|transform/.test(r.anim?.hoverTransition ?? ""), "行有悬停过渡", r.anim?.hoverTransition);
check(/wesim-pop/.test(r.anim?.pickerAnim ?? ""), "取色器弹层有出现动画", r.anim?.pickerAnim);
const entered = (r.enterFlags ?? []).filter((x) => x.enter).map((x) => x.label);
console.log(` 带入场标记的行:${entered.join(" / ") || "(无)"}`);
check(
JSON.stringify(entered) === JSON.stringify(["亮度", "对比度", "饱和度", "色调偏移"]),
"只有因条件变化新冒出来的行才播入场",
entered.join(" / "),
);
check(r.checkbox?.appearance === "none", "复选框是自绘的(原生外观已关掉)", String(r.checkbox?.appearance));
check(r.arrowOpen?.expanded === "true", "展开时按钮 aria-expanded=true");
check(/matrix\(-1/.test(r.arrowOpen?.rotated ?? ""), "箭头翻了过来", r.arrowOpen?.rotated);
check(r.arrowClosed === "false", "收起后 aria-expanded 复位", String(r.arrowClosed));
check(
parseFloat(r.reduced?.rowAnim ?? "1") < 0.01,
"prefers-reduced-motion: reduce 时入场动画被关掉",
String(r.reduced?.rowAnim),
);
check(
parseFloat(r.reduced?.rowTransition ?? "1") < 0.01,
"prefers-reduced-motion: reduce 时过渡也被关掉",
String(r.reduced?.rowTransition),
);
console.log("\n=== ⑪ 预览图(官方在设置上方就展示它)===");
check(r.preview?.present === true, "预览区已渲染", String(r.preview?.present));
check(/preview\.gif$/.test(r.preview?.src ?? ""), "指向分发根的 preview.gif", String(r.preview?.src));
check(r.preview?.natural === "160x160", "图片真的解码出来了(不是坏图)", String(r.preview?.natural));
// 官方那对左右箭头是切换"已安装的壁纸",模拟器没有那个列表——**不做**。
// 这条断言是防回归的:它们曾经存在过(当时拿"切预设"当替身,但那是另一件事)。
check(
(r.preview?.navs ?? []).length === 0,
"预览上没有左右箭头(WE 那个是切换壁纸用的,我们不需要)",
`${(r.preview?.navs ?? []).length} 个`,
);
console.log("\n=== ⑫ 单档分发里的「背景音乐选择」===");
// 规则:一个分发里可选的音源(不含「随预设」)≥2 才给下拉,否则两个选项效果一样、是废话。
check(r.singleTwoTracks?.present === true, "single-hsr-xilian(两首)有「背景音乐选择」", JSON.stringify(r.singleTwoTracks));
check(
JSON.stringify(r.singleTwoTrackItems) === JSON.stringify(["随预设", "「再度和你」", "昔涟"]),
"下拉里就是 xilian 的两首(+ 随预设)",
(r.singleTwoTrackItems ?? []).join(" / "),
);
// 默认值是 "auto"(随预设)——与合集分发一致,不是"第一首"。
// 运行时拿到 auto 就回落该壁纸的默认音源,也就是第一首,所以效果相同;
// 但值本身必须是 auto,否则用户从合集切到单档时选择会被"钉死"。
check(
r.singleTwoTracks?.value === "auto" && r.singleTwoTracks?.label === "随预设",
"默认是「随预设」(auto),与合集分发一致",
`${r.singleTwoTracks?.value} / ${r.singleTwoTracks?.label}`,
);
check(
r.singleOneTrack?.present === false,
"single-hsr-kv37(只有一首)不给这个下拉——两个选项效果完全一样",
JSON.stringify(r.singleOneTrack),
);
const noisy = (r.consoleLines ?? []).filter((l) => /error|uncaught/i.test(l));
check((r.exceptions ?? []).length === 0, "0 未捕获异常", (r.exceptions ?? []).slice(0, 2).join(" | ") || "无");
check(noisy.length === 0, "0 控制台报错", noisy.slice(0, 3).join(" | ") || "干净");
console.log(failures === 0 ? "\n面板还原度:通过" : `\n面板还原度:失败 ${failures} 处`);
process.exit(failures === 0 ? 0 : 1);
+410
View File
@@ -0,0 +1,410 @@
// 场景播放器的门:多骨架 + 贴图平面真的合成出来了,而且**先证明它会红**。
//
// 不碰 `wallpapers/`:夹具用抓取器 staged 的 kv45/scene_main(4 骨架 + 1 平面)自造,
// 预设写成与构建产物同形的 `preset.js`(同一个 asset()/相对路径解析规则)。
//
// 前置:`python -m tools.downloader fetch --page kv45`(产物在 tools/downloader/_out/hsr/kv45/scene_main)。
// 本脚本自己跑 `tsc -p tsconfig.runtime.json`,所以不依赖 pnpm build。
import { spawn, spawnSync } from "node:child_process";
import { createServer } from "node:http";
import { createReadStream, existsSync } from "node:fs";
import { cp, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
import { dirname, extname, join, normalize, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { withPage, sleep } from "../cdp.mjs";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
const FIXTURE = join(ROOT, "tools", ".cache", "scene-player-fixture");
const STAGED = join(ROOT, "tools", "downloader", "_out", "hsr", "kv45", "scene_main");
/** 透视样本:原神页,camera type 1 + 真实景深(kv45 是正交、z 全 0,抓不住投影算错)。 */
const STAGED_PERSPECTIVE = join(ROOT, "tools", "downloader", "_out", "ys", "nico-tea", "scene_main");
const SHOT = join(ROOT, "tools", ".cache", "scene-player-shot.png");
const PORT = Number(process.env.SCENE_PORT ?? 8197);
const W = 1280;
const H = 720;
let failures = 0;
const check = (ok: unknown, label: string, detail?: unknown) => {
if (!ok) failures++;
console.log(` ${ok ? "✓" : "✗"} ${label}${detail !== undefined ? ` → ${String(detail)}` : ""}`);
};
interface SceneDebug {
parts: number;
loaded: number;
errors: string[];
framing: { x: number; y: number; width: number; height: number } | null;
content: { x: number; y: number; width: number; height: number } | null;
spines: string[];
images: string[];
solids: number;
rotatedSkipped: number;
}
interface StagedPart {
kind: string;
id: string;
order: number;
renderOrder?: number;
position: number[];
scale: number[];
geometrySize?: number[];
geometryCenter?: number[];
rotation?: number[];
animation?: string;
skin?: string;
timeScale?: number;
}
/** 把 staged 的 scene.json 翻成 preset 形状(与 tools/lib/generate.ts 吐出的字面量同形)。 */
async function buildPresetModule(
staged: string = STAGED,
id = "kv45",
game = "hsr",
cover: string | null = "w22_slg",
): Promise<string> {
const scene = JSON.parse(await readFile(join(staged, "scene.json"), "utf8")) as {
ui?: number[];
parts: StagedPart[];
camera?: { camera?: { type?: number; fov?: number }; position?: number[] };
};
const camera = scene.camera?.camera ?? {};
const cameraLine =
camera.type === undefined
? ""
: ` camera: ${JSON.stringify({ type: camera.type, fov: camera.fov, position: scene.camera?.position })},\n`;
const parts: string[] = [];
for (const part of scene.parts) {
// 与 promote.py 同语义:纯色平面不进预设(运行时画不了它,写进去只是死配置)。
if (part.kind === "solid") continue;
const fields: string[] = [
`kind: ${JSON.stringify(part.kind)}`,
`id: ${JSON.stringify(part.id)}`,
`order: ${JSON.stringify(part.order)}`,
`position: ${JSON.stringify(part.position)}`,
`scale: ${JSON.stringify(part.scale)}`,
];
if (part.renderOrder) fields.push(`renderOrder: ${JSON.stringify(part.renderOrder)}`);
if (part.kind === "spine") {
fields.push(`jsonUrl: asset("../assets/spines/${part.id}/${part.id}.json")`);
fields.push(`atlasUrl: asset("../assets/spines/${part.id}/${part.id}.atlas")`);
} else if (part.kind === "image") {
const dir = join(staged, "scene");
const files = existsSync(dir) ? await readdir(dir) : [];
const hit = files.find((f) => f.startsWith(`${part.id}.`));
if (!hit) continue;
fields.push(`image: asset("../assets/scene/${hit}")`);
}
if (part.geometrySize) {
fields.push(`width: ${JSON.stringify(part.geometrySize[0])}`);
fields.push(`height: ${JSON.stringify(part.geometrySize[1])}`);
}
if (part.geometryCenter) fields.push(`center: ${JSON.stringify(part.geometryCenter)}`);
if (part.rotation && part.rotation.some((v) => Math.abs(v) > 1e-9)) {
fields.push(`rotation: ${JSON.stringify(part.rotation)}`);
}
if (part.animation) fields.push(`animation: ${JSON.stringify(part.animation)}`);
if (part.skin) fields.push(`skin: ${JSON.stringify(part.skin)}`);
if (part.timeScale !== undefined) fields.push(`timeScale: ${JSON.stringify(part.timeScale)}`);
parts.push(` { ${fields.join(", ")} },`);
}
return [
`const base = new URL("./", import.meta.url);`,
`const asset = (path) => new URL(path, base).href;`,
`export default {`,
` id: ${JSON.stringify(id)},`,
` name: ${JSON.stringify(id)},`,
` game: ${JSON.stringify(game)},`,
// 不设背景图:背景比例链会改取景,门要的是**纯视锥**那一条路径。
` backgroundImage: ${cover ? `asset("../assets/scene/${cover}.png")` : `""`},`,
` sceneConfig: {`,
` ui: ${JSON.stringify(scene.ui ?? [2500, 1080])},`,
cameraLine + ` parts: [`,
...parts,
` ],`,
` },`,
` audioChoices: [],`,
` audioOptions: { source: "" },`,
`};`,
``,
].join("\n");
}
/** 用 ffmpeg 把 PNG 解成 RGBA 原始像素。 */
function rawPixels(png: string): Promise<Buffer> {
return new Promise((resolvePromise) => {
const p = spawn("ffmpeg", ["-v", "error", "-i", png, "-f", "rawvideo", "-pix_fmt", "rgba", "-"], {
stdio: ["ignore", "pipe", "ignore"],
});
const chunks: Buffer[] = [];
p.stdout.on("data", (d: Buffer) => chunks.push(d));
p.on("close", () => resolvePromise(Buffer.concat(chunks)));
p.on("error", () => resolvePromise(Buffer.alloc(0)));
});
}
/** 两张同尺寸截图里有多少比例的像素不同。 */
function diffRatio(a: Buffer, b: Buffer): number {
if (!a.length || a.length !== b.length) return -1;
let different = 0;
for (let i = 0; i < a.length; i += 4) {
if (a[i] !== b[i] || a[i + 1] !== b[i + 1] || a[i + 2] !== b[i + 2] || a[i + 3] !== b[i + 3]) different++;
}
return different / (a.length / 4);
}
const MIME: Record<string, string> = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".mjs": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".json": "application/json; charset=utf-8",
".atlas": "text/plain; charset=utf-8",
".png": "image/png",
".jpg": "image/jpeg",
".webp": "image/webp",
};
// ---------------------------------------------------------------------------------------
// 前置条件
// ---------------------------------------------------------------------------------------
console.log("场景播放器门:\n");
if (!existsSync(join(STAGED, "scene.json"))) {
console.log(` ✗ 缺少 staged 样本:${STAGED}\n 先跑:python -m tools.downloader fetch --page kv45`);
process.exit(1);
}
const build = spawnSync(process.execPath, ["node_modules/typescript/bin/tsc", "-p", "tsconfig.runtime.json"], {
cwd: ROOT,
encoding: "utf8",
});
check(build.status === 0, "运行时能编译(tsc -p tsconfig.runtime.json)", (build.stdout ?? "").trim().split("\n").pop() ?? "");
// ---------------------------------------------------------------------------------------
// 造夹具
// ---------------------------------------------------------------------------------------
await rm(FIXTURE, { recursive: true, force: true });
await mkdir(join(FIXTURE, "scripts"), { recursive: true });
await mkdir(join(FIXTURE, "styles"), { recursive: true });
await cp(STAGED, join(FIXTURE, "assets"), { recursive: true });
await cp(STAGED_PERSPECTIVE, join(FIXTURE, "assets-perspective"), { recursive: true });
for (const file of await readdir(join(ROOT, "build", "scripts"))) {
if (file.endsWith(".js")) await cp(join(ROOT, "build", "scripts", file), join(FIXTURE, "scripts", file));
}
await cp(join(ROOT, "src", "vendor", "spine-player.js"), join(FIXTURE, "scripts", "spine-player.js"));
for (const file of await readdir(join(ROOT, "src", "styles"))) {
await cp(join(ROOT, "src", "styles", file), join(FIXTURE, "styles", file));
}
const presetModule = await buildPresetModule();
await writeFile(join(FIXTURE, "scripts", "scene-preset.js"), presetModule, "utf8");
// 第二个夹具页:透视样本(把 assets 前缀改到 assets-perspective/)
const perspectiveModule = (await buildPresetModule(STAGED_PERSPECTIVE, "nico-tea", "ys", null)).replaceAll(
"../assets/",
"../assets-perspective/",
);
await writeFile(join(FIXTURE, "scripts", "scene-preset-perspective.js"), perspectiveModule, "utf8");
await writeFile(
join(FIXTURE, "scripts", "presets.js"),
[
`import preset from "./scene-preset.js";`,
`import perspective from "./scene-preset-perspective.js";`,
`export const defaultPresetId = "kv45";`,
`export default Object.fromEntries([["kv45", preset], ["nico-tea", perspective]]);`,
``,
].join("\n"),
"utf8",
);
await writeFile(
join(FIXTURE, "index.html"),
[
`<!DOCTYPE html>`,
`<html lang="zh"><head><meta charset="UTF-8" /><title>scene fixture</title>`,
`<link rel="icon" href="data:," />`,
`<link rel="stylesheet" href="./styles/spine-player.css" />`,
`<link rel="stylesheet" href="./styles/index.css" /></head>`,
`<body><div id="spine-container"></div><audio id="background-music"></audio>`,
`<script src="./scripts/spine-player.js"></script>`,
`<script type="module" src="./scripts/index.js"></script></body></html>`,
``,
].join("\n"),
"utf8",
);
check(existsSync(join(FIXTURE, "scripts", "scene-controller.js")), "夹具里带上了 scene-controller.js");
// ---------------------------------------------------------------------------------------
// 起静态服务
// ---------------------------------------------------------------------------------------
const server = createServer(async (req, res) => {
const pathname = decodeURIComponent(new URL(req.url ?? "/", "http://x").pathname);
const rel = normalize(pathname).replace(/^([/\\])+/, "");
const target = resolve(join(FIXTURE, rel === "" ? "index.html" : rel));
if (!target.startsWith(FIXTURE)) {
res.writeHead(403).end();
return;
}
try {
const info = await stat(target);
if (info.isDirectory()) {
res.writeHead(404).end();
return;
}
res.writeHead(200, { "content-type": MIME[extname(target).toLowerCase()] ?? "application/octet-stream" });
createReadStream(target).pipe(res);
} catch {
res.writeHead(404).end();
}
});
await new Promise<void>((r) => server.listen(PORT, "127.0.0.1", () => r()));
const BASE = `http://127.0.0.1:${PORT}/`;
const readDebug = async (evaluate: (expr: string) => Promise<unknown>): Promise<SceneDebug | null> => {
const raw = await evaluate("JSON.stringify(window.__sceneDebug ?? null)");
return raw && raw !== "null" ? (JSON.parse(String(raw)) as SceneDebug) : null;
};
const waitFor = async (evaluate: (expr: string) => Promise<unknown>, predicate: (d: SceneDebug | null) => boolean, tries = 40): Promise<SceneDebug | null> => {
let last: SceneDebug | null = null;
for (let i = 0; i < tries; i++) {
last = await readDebug(evaluate);
if (predicate(last)) return last;
await sleep(250);
}
return last;
};
// ---------------------------------------------------------------------------------------
// 绿:多骨架 + 贴图平面真的合成出来
// ---------------------------------------------------------------------------------------
await withPage({ width: W, height: H, profile: "tools/.cache/edge-scene" }, async ({ send, evaluate, exceptions }) => {
await send("Page.navigate", { url: BASE });
const debug = await waitFor(evaluate, (d) => Boolean(d && d.loaded >= d.parts && d.parts > 0));
console.log(` · __sceneDebug = ${JSON.stringify(debug)}`);
check(Boolean(debug), "页面写出了 __sceneDebug");
check(debug?.parts === 5, "part 数 = 4 骨架 + 1 平面 = 5", debug?.parts);
check(debug?.loaded === 5, "5 件全部加载", debug?.loaded);
check((debug?.errors.length ?? 1) === 0, "0 加载错误", debug?.errors.join(" | "));
check(debug?.spines.length === 4, "4 具骨架都建了 AnimationState", debug?.spines.join(","));
check(debug?.images.length === 1, "1 块贴图平面", debug?.images.join(","));
check(Boolean(debug?.framing && debug.framing.width > 0 && debug.framing.height > 0), "取景矩形有效", JSON.stringify(debug?.framing));
// resize(Expand) 把画布像素尺寸设成 clientWidth * devicePixelRatio,所以按 dpr 比。
const canvasSize = String(
await evaluate(
`(() => { const c = document.querySelector("#spine-container canvas"); if (!c) return ""; const dpr = window.devicePixelRatio || 1; return [c.width, c.height, Math.round(c.clientWidth * dpr), Math.round(c.clientHeight * dpr)].join(","); })()`,
),
);
const [cw, ch, expectW, expectH] = canvasSize.split(",").map(Number);
check(cw === expectW && ch === expectH && cw! > 0, "画布像素尺寸 = CSS 尺寸 × dpr", canvasSize);
const glError = Number(await evaluate(`(() => { const c = document.querySelector("#spine-container canvas"); if (!c) return -1; const gl = c.getContext("webgl2") || c.getContext("webgl"); return gl ? gl.getError() : -1; })()`));
if (glError === -1) console.log(" · 拿不到 WebGL 上下文(类型不匹配),跳过 gl.getError 断言");
else check(glError === 0, "gl.getError() === 0", glError);
check(exceptions.length === 0, "0 未捕获异常", exceptions.slice(0, 2).join(" | ") || "无");
// 画面非空:**隐藏画布前后截图必须不同**。
// 不能数"alpha>0 的像素"——页面自身有底色,空画布也全是不透明像素(实测 100%,是个假绿)。
const shotWith = (await send("Page.captureScreenshot", { format: "png" })) as { data: string };
await writeFile(SHOT, Buffer.from(shotWith.data, "base64"));
await evaluate(`(() => { const c = document.querySelector("#spine-container canvas"); if (c) c.style.visibility = "hidden"; })()`);
await sleep(300);
const shotWithout = (await send("Page.captureScreenshot", { format: "png" })) as { data: string };
const hidden = join(ROOT, "tools", ".cache", "scene-player-shot-hidden.png");
await writeFile(hidden, Buffer.from(shotWithout.data, "base64"));
const [withPixels, withoutPixels] = await Promise.all([rawPixels(SHOT), rawPixels(hidden)]);
const ratio = diffRatio(withPixels, withoutPixels);
check(ratio > 0.02, "画布确实贡献了像素(隐藏画布后画面变化 > 2%)", `${(ratio * 100).toFixed(1)}%(截图 ${SHOT})`);
await evaluate(`(() => { const c = document.querySelector("#spine-container canvas"); if (c) c.style.visibility = "visible"; })()`);
});
// ---------------------------------------------------------------------------------------
// 纯色平面:不能被当贴图画(原来会给 drawTexture 传 undefined 直接崩)
// ---------------------------------------------------------------------------------------
await withPage({ width: W, height: H, profile: "tools/.cache/edge-scene" }, async ({ send, evaluate, exceptions }) => {
const withSolid = presetModule.replace(
` ],\n },`,
` { kind: "solid", id: "DEFAULT", order: 99, position: [0, 0, 0], scale: [1, 1, 1], width: 100, height: 100 },\n ],\n },`,
);
check(withSolid !== presetModule, "反例确实加进了一块纯色平面");
await writeFile(join(FIXTURE, "scripts", "scene-preset.js"), withSolid, "utf8");
await send("Page.navigate", { url: `${BASE}?solid=1` });
const debug = await waitFor(evaluate, (d) => Boolean(d && d.loaded >= 5));
check(debug?.solids === 1, "纯色平面被计数(solids = 1)", debug?.solids);
check(exceptions.length === 0, "纯色平面不崩(0 未捕获异常)", exceptions.slice(0, 1).join("") || "无");
});
// ---------------------------------------------------------------------------------------
// 透视场景:取景矩形必须等于**独立算出来**的视锥(这条能抓住"投影/取景算错")
// ---------------------------------------------------------------------------------------
await withPage({ width: W, height: H, profile: "tools/.cache/edge-scene" }, async ({ send, evaluate, exceptions }) => {
await send("Page.navigate", { url: `${BASE}?perspective=1` });
// 等运行时挂上监听器(它在 index.js 顶层注册,模块执行完才存在),再走 WE 的真实路径切预设。
let ready = false;
for (let i = 0; i < 40 && !ready; i++) {
ready = Boolean(await evaluate("typeof window.wallpaperPropertyListener === 'object' && window.wallpaperPropertyListener !== null"));
if (!ready) await sleep(250);
}
check(ready, "运行时挂上了 wallpaperPropertyListener");
await evaluate(`window.wallpaperPropertyListener.applyUserProperties({ preset: { value: "nico-tea" } })`);
const debug = await waitFor(evaluate, (d) => Boolean(d && d.parts > 20 && d.loaded > 20));
console.log(` · 透视 __sceneDebug = ${JSON.stringify(debug && { parts: debug.parts, loaded: debug.loaded, framing: debug.framing, rotatedSkipped: debug.rotatedSkipped })}`);
check(debug?.parts === 43, "透视样本 part 数 = 43", debug?.parts);
check((debug?.errors.length ?? 1) === 0, "透视样本 0 加载错误", debug?.errors.slice(0, 2).join(" | "));
check(debug?.rotatedSkipped === 2, "两片倾斜的 3D 面片被跳过并计数", debug?.rotatedSkipped);
check(exceptions.length === 0, "透视样本 0 未捕获异常", exceptions.slice(0, 1).join("") || "无");
// 独立算期望:s0 = 1/(tan(fov/2)·camZ);可见区 = ui 矩形 × s0;再按画布比例 contain。
const scene = JSON.parse(await readFile(join(STAGED_PERSPECTIVE, "scene.json"), "utf8")) as {
ui?: number[];
camera?: { camera?: { fov?: number }; position?: number[] };
};
const fov = scene.camera?.camera?.fov ?? 31.417;
const camZ = scene.camera?.position?.[2] ?? 1920;
const uiSize = scene.ui ?? [2500, 1080];
const uiW = uiSize[0] ?? 2500;
const uiH = uiSize[1] ?? 1080;
const s0 = 1 / (Math.tan((fov * Math.PI) / 360) * camZ);
const aspect = W / H;
// 复刻引擎 `resizeUI`:可见矩形 = UI 矩形按画布比收缩一个轴(b<1 收宽,否则收高)。
const b = aspect / (uiW / uiH);
const visW = b < 1 ? uiW * b : uiW;
const visH = b < 1 ? uiH : uiH / b;
const expectedW = visW * s0;
const expectedH = visH * s0;
const framing = debug?.framing;
const close = (a: number | undefined, b: number) => typeof a === "number" && Math.abs(a - b) / b < 1e-6;
check(close(framing?.width, expectedW), `取景宽 = 视锥宽(独立算 ${expectedW.toFixed(4)})`, framing?.width);
check(close(framing?.height, expectedH), `取景高 = 视锥高(独立算 ${expectedH.toFixed(4)})`, framing?.height);
check(close(framing?.x, -expectedW / 2) && close(framing?.y, -expectedH / 2), "取景以场景原点为中心", JSON.stringify(framing));
});
// ---------------------------------------------------------------------------------------
// 红:把一具骨架的 atlas 指错 → 必须报出来
// ---------------------------------------------------------------------------------------
await withPage({ width: W, height: H, profile: "tools/.cache/edge-scene" }, async ({ send, evaluate }) => {
const broken = presetModule.replace(
`atlasUrl: asset("../assets/spines/01_beijing/01_beijing.atlas")`,
`atlasUrl: asset("../assets/spines/01_beijing/does-not-exist.atlas")`,
);
check(broken !== presetModule, "反例确实改动了 atlasUrl");
await writeFile(join(FIXTURE, "scripts", "scene-preset.js"), broken, "utf8");
await send("Page.navigate", { url: `${BASE}?red=1` });
const debug = await waitFor(evaluate, (d) => Boolean(d && d.errors.length > 0), 20);
console.log(` · 反例 __sceneDebug = ${JSON.stringify(debug)}`);
check(Boolean(debug && debug.errors.length > 0), "atlas 指错 → 报出加载错误", debug?.errors.slice(0, 1).join(""));
check(Boolean(debug && debug.loaded < (debug?.parts ?? 0)), "atlas 指错 → 少加载一件", `${debug?.loaded}/${debug?.parts}`);
});
// ---------------------------------------------------------------------------------------
// 还原:必须重新变绿(证明红是那处改动引起的)
// ---------------------------------------------------------------------------------------
await writeFile(join(FIXTURE, "scripts", "scene-preset.js"), presetModule, "utf8");
await withPage({ width: W, height: H, profile: "tools/.cache/edge-scene" }, async ({ send, evaluate }) => {
await send("Page.navigate", { url: `${BASE}?restore=1` });
const debug = await waitFor(evaluate, (d) => Boolean(d && d.loaded >= d.parts && d.parts > 0));
check(debug?.loaded === 5 && (debug?.errors.length ?? 1) === 0, "还原 atlasUrl → 重新全绿", `${debug?.loaded}/${debug?.parts}`);
});
server.close();
console.log(`\n夹具留在 ${FIXTURE}(可直接起服务器用眼睛看)`);
console.log(failures === 0 ? "\n场景播放器门:通过" : `\n场景播放器门:失败 ${failures} 处`);
process.exit(failures === 0 ? 0 : 1);
+153
View File
@@ -0,0 +1,153 @@
// 验收 `pnpm build --sim`:把产出的自包含包用 file:// 直接打开,确认
// ① 骨架真的画出来了(canvas 出现且有非空像素)
// ② 0 网络请求失败、0 异常、0 控制台报错
// ③ 模拟器面板在,且明确标着"模拟环境"
// ④ 音频能播(data: URL 内联)
// ⑤ 全程没有任何 http(s) 请求("自包含"的硬定义)
//
// 用法:node tools/checks/verify-sim-page.mts [分发目录名]
import { withPage, sleep } from "../cdp.mjs";
import { existsSync, statSync } from "node:fs";
import { resolve } from "node:path";
const dirName = process.argv[2] ?? "single-hsr-kv37";
const pageAbs = resolve("dist/releases", dirName, "sim/index.html");
if (!existsSync(pageAbs)) {
console.error(`找不到 ${pageAbs}(先跑 node tools/build.ts --single kv37 --sim)`);
process.exit(1);
}
const fileUrl = "file:///" + pageAbs.replace(/\\/g, "/");
let failures = 0;
const check = (ok: unknown, label: string, detail?: unknown) => {
if (!ok) failures++;
console.log(` ${ok ? "✓" : "✗"} ${label}${detail !== undefined ? ` → ${String(detail)}` : ""}`);
};
console.log(`自包含包:${pageAbs}`);
console.log(`体积:${(statSync(pageAbs).size / 1024 / 1024).toFixed(1)} MB\n`);
await withPage({ width: 1920, height: 1080 }, async ({ send, evaluate, consoleLines, exceptions }) => {
const rawSend = send;
await rawSend("Page.navigate", { url: fileUrl });
const deadline = Date.now() + 45000;
let ready = false;
while (Date.now() < deadline) {
const canvasCount = await evaluate('document.querySelectorAll("#spine-container canvas").length');
if (canvasCount > 0) {
ready = true;
break;
}
await sleep(250);
}
await sleep(2500);
// ⑤ "自包含"的硬定义:这个文档取过的资源里不能有任何 http(s)。
// 用页面侧的 Resource Timing,不需要 CDP 的事件通道。
const externalRequests = await evaluate(`(function () {
return performance.getEntriesByType("resource")
.map(function (e) { return e.name; })
.filter(function (n) { return /^https?:/i.test(n); });
})()`);
check(
Array.isArray(externalRequests) && externalRequests.length === 0,
"全程没有任何 http(s) 请求(自包含的硬定义)",
Array.isArray(externalRequests) && externalRequests.length > 0 ? externalRequests.slice(0, 3).join(", ") : "无",
);
const state = await evaluate(`(function () {
var canvas = document.querySelector("#spine-container canvas");
var out = {
canvas: document.querySelectorAll("#spine-container canvas").length,
canvasSize: canvas ? canvas.clientWidth + "x" + canvas.clientHeight : null,
bg: (document.body.style.backgroundImage || "").slice(0, 24),
bgIsData: /^url\\(["']?data:/.test(document.body.style.backgroundImage || ""),
sim: !!window.__weSim,
banner: document.getElementById("wesim") ? document.querySelector("#wesim .banner").textContent.trim() : null,
// 自包含页在 <分发根>/sim/ 下,预览图必须已被内联成 data: URL——
// file:// 下 ../preview.gif 是跨目录,读不到。
previewSrc: (function () { var i = document.querySelector("#wesim .preview img"); return i ? i.getAttribute("src").slice(0, 5) : null; })(),
previewNatural: (function () { var i = document.querySelector("#wesim .preview img"); return i ? i.naturalWidth + "x" + i.naturalHeight : null; })(),
modules: window.__weModules ? Object.keys(window.__weModules).length : 0,
assetKeys: window.__simAssets ? Object.keys(window.__simAssets).length : 0,
audioSrcIsData: false,
audioDuration: null
};
var a = document.getElementById("background-music");
if (a) {
out.audioSrcIsData = /^data:/.test(a.src || "");
out.audioDuration = isFinite(a.duration) ? a.duration : null;
out.audioError = a.error ? a.error.code : null;
}
return out;
})()`);
console.log("=== 渲染 ===");
check(ready, "出现 Spine 画布", `canvas=${state.canvas}`);
check(state.canvas === 1, "只有一张 canvas", `canvas=${state.canvas}`);
check(state.canvasSize === "1920x1080", "画布尺寸等于窗口", state.canvasSize);
// 画布真的画了东西:跨帧采样非透明比例。
//
// 两个坑叠在一起,先前两次都判成了"空白":
// ① 页面里有两张 canvas——spine 自己建的那张(承载渲染)和一张 300×150 的默认尺寸空画布。
// 按 `#spine-container canvas` 取到的是后者,尺寸就不对。
// ② 播放器用 `preserveDrawingBuffer: false`(默认),**帧外**读像素拿到的永远是已清空的缓冲。
// 只读一次必然是 0%,与画没画无关。必须在 rAF 里连续采样、取历史最大值。
await evaluate(`(function () {
var list = [].slice.call(document.querySelectorAll("#spine-container canvas"));
var canvas = list.sort(function (a, b) { return b.clientWidth * b.clientHeight - a.clientWidth * a.clientHeight; })[0];
if (!canvas) { window.__px = { error: "没有 canvas" }; return; }
var gl = canvas.getContext("webgl2") || canvas.getContext("webgl");
if (!gl) { window.__px = { error: "拿不到 WebGL 上下文" }; return; }
window.__px = { best: 0, frames: 0, w: canvas.width, h: canvas.height, count: list.length };
var buf = new Uint8Array(canvas.width * canvas.height * 4);
var tick = function () {
gl.readPixels(0, 0, canvas.width, canvas.height, gl.RGBA, gl.UNSIGNED_BYTE, buf);
var n = 0;
for (var i = 3; i < buf.length; i += 4) if (buf[i] > 8) n++;
var ratio = n / (canvas.width * canvas.height);
if (ratio > window.__px.best) window.__px.best = ratio;
window.__px.frames++;
if (window.__px.frames < 90) requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
})()`);
await sleep(2500);
const pixels = await evaluate("window.__px");
check(
pixels !== null && pixels.best !== undefined && pixels.best > 0.02,
"画布上有实际像素(不是空白)",
pixels === null
? "没有 canvas"
: pixels.error
? pixels.error
: `非透明占比 ${((pixels.best ?? 0) * 100).toFixed(1)}%(${pixels.w}x${pixels.h},采样 ${pixels.frames} 帧)`,
);
console.log("\n=== 自包含性 ===");
check(state.bgIsData, "背景图是 data: URL(没有外部文件依赖)", state.bg);
check(state.assetKeys > 0, "内联资源表已建立", `${state.assetKeys} 个 key`);
check(state.modules >= 9, "模块注册表已建立", `${state.modules} 个模块`);
check(state.audioSrcIsData, "音频是 data: URL(内联)");
check(state.audioDuration !== null && state.audioDuration > 60, "音频可解码", state.audioDuration ? `${state.audioDuration.toFixed(0)}s` : `error=${state.audioError}`);
check(pageAbs.length > 0 && !/^https?:/.test(state.bg), "没有指向 http(s) 的资源");
console.log("\n=== 模拟器 ===");
check(state.sim, "window.__weSim 存在");
check(/模拟环境/.test(state.banner ?? ""), "面板标着「模拟环境」", state.banner);
// 自包含包里的预览图:必须是内联的 data: URL,而且要真的解码出来(坏图也会是 data:)。
if (state.previewSrc !== null) {
check(state.previewSrc === "data:", "预览图已内联成 data: URL", state.previewSrc);
check(state.previewNatural === "160x160", "内联的预览图能解码", state.previewNatural);
}
console.log("\n=== 干净度 ===");
check(exceptions.length === 0, "0 未捕获异常", exceptions.slice(0, 3).join(" | ") || "无");
const noisy = consoleLines.filter((l) => /error|failed|blocked|CORS/i.test(l));
check(noisy.length === 0, "0 控制台报错", noisy.slice(0, 3).join(" | ") || "干净");
});
console.log(failures === 0 ? "\n自包含包可用:双击即可打开,无需 pnpm dev,也无需任何 http 服务" : `\n自包含包验收失败:${failures} 处`);
process.exit(failures === 0 ? 0 : 1);
+157
View File
@@ -0,0 +1,157 @@
// 静态预览验收:证明 WE 模拟面板**不依赖调试服**,且是**右边缘滑出**的。
//
// 为什么要有这个测试:
// ① 面板曾经只在调试服注入时才存在。用户要求 GitHub Pages 预览也能弹出它——
// 静态托管没有调试服,所以必须由 `--with-sim` 把驱动**注入 index.html**。
// ② Pages 把站点放在 `/<repo>/` **子路径**下。驱动若用根绝对路径 `/scripts/…`,
// 在子路径下必然 404。所以这个测试刻意把分发挂在 `/repo/` 前缀后面服务,
// 而不是挂在根上——挂根上测不出这个错。
//
// 用法:node tools/checks/verify-static-preview.mts [分发目录名]
// 前提:已经 `pnpm build --with-sim`。
import { createServer } from "node:http";
import { createReadStream } from "node:fs";
import { stat } from "node:fs/promises";
import { extname, join, normalize, resolve } from "node:path";
import { withPage, sleep } from "../cdp.mjs";
const DIR = process.argv[2] ?? "collection-all";
const PORT = Number(process.env.STATIC_PORT ?? 8195);
const ROOT = resolve("dist/releases");
/** 模拟 GitHub Pages 的 /<repo>/ 子路径。**不能**是空串,否则测不出根绝对路径的错。 */
const PREFIX = "/repo";
let failures = 0;
const check = (ok: unknown, label: string, detail?: unknown) => {
if (!ok) failures++;
console.log(` ${ok ? "✓" : "✗"} ${label}${detail !== undefined ? ` → ${String(detail)}` : ""}`);
};
const MIME = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".json": "application/json; charset=utf-8",
".atlas": "text/plain; charset=utf-8",
".png": "image/png",
".jpg": "image/jpeg",
".webp": "image/webp",
".gif": "image/gif",
".flac": "audio/flac",
".mp3": "audio/mpeg",
".ogg": "audio/ogg",
};
const notFound: string[] = [];
const server = createServer(async (req, res) => {
const pathname = decodeURIComponent(new URL(req.url ?? "/", "http://x").pathname);
if (!pathname.startsWith(`${PREFIX}/`)) {
notFound.push(pathname);
res.writeHead(404).end("outside prefix");
return;
}
let rel = pathname.slice(PREFIX.length + 1);
if (rel === "" || rel.endsWith("/")) rel += "index.html";
const file = join(ROOT, normalize(rel));
if (!file.startsWith(ROOT)) {
res.writeHead(403).end("escape");
return;
}
const info = await stat(file).catch(() => null);
if (!info?.isFile()) {
notFound.push(pathname);
res.writeHead(404).end("not found");
return;
}
res.writeHead(200, { "content-type": MIME[extname(file).toLowerCase()] ?? "application/octet-stream", "content-length": info.size });
createReadStream(file).pipe(res);
});
await new Promise<void>((r) => server.listen(PORT, "127.0.0.1", () => r()));
const BASE = `http://127.0.0.1:${PORT}${PREFIX}/${DIR}/`;
console.log(`静态托管(模拟 Pages 子路径):${BASE}\n`);
const W = 1920;
const H = 1080;
const EDGE_X = W - 8; // 16px 热区之内
const AWAY_X = 200; // 远离右边缘
const result = await withPage({ width: W, height: H, profile: "tools/.cache/edge-static" }, async ({ send, evaluate, consoleLines, exceptions }) => {
const move = async (x, y) => {
await send("Input.dispatchMouseEvent", { type: "mouseMoved", x, y, button: "none", buttons: 0 });
await sleep(320); // > 面板 .18s 过渡
};
const open = () => evaluate(`document.getElementById("wesim").dataset.open`);
await send("Page.navigate", { url: BASE });
await sleep(3500);
const initial = await evaluate(`(function(){
var p = document.getElementById("wesim");
var e = document.getElementById("wesim-edge");
return {
sim: !!window.__weSim,
isSimulated: window.__weSim ? window.__weSim.__isSimulated : null,
driverOnce: window.__weSimDriver === true,
panel: !!p, edge: !!e,
open: p ? p.dataset.open : null,
banner: p && p.querySelector(".banner") ? p.querySelector(".banner").textContent : null,
canvas: document.querySelectorAll("canvas").length,
hasToggle: !!document.querySelector("#wesim .toggle")
};
})()`);
// ── 交互:右边缘滑出 ──
await move(AWAY_X, H / 2);
const closedInitially = await open();
await move(EDGE_X, H / 2);
const openedByEdge = await open();
await move(AWAY_X, H / 2);
const closedByLeave = await open();
await move(EDGE_X, H / 2);
const reopened = await open();
// 取消收起:此刻真实指针就停在右边缘热区里。没有 armed 规则的话,
// 面板滑走会立刻让边缘命中再次触发,表现为"取消关不掉"。
// (官方面板没有 ×,底部是确认/取消,所以这里点「取消」。)
await evaluate(`document.querySelector("#wesim .foot button:not(.primary)").click()`);
await sleep(400);
const afterClose = await open();
// 离开边缘再回来 → 重新武装
await move(AWAY_X, H / 2);
await move(EDGE_X, H / 2);
const afterRearm = await open();
return { initial, closedInitially, openedByEdge, closedByLeave, reopened, afterClose, afterRearm, consoleLines, exceptions };
});
const r = result;
console.log("=== ① 静态托管下模拟器可用(无调试服)===");
check(r.initial.sim === true, "window.__weSim 存在(驱动已随产物注入)");
check(r.initial.isSimulated === true, "window.__weSim.__isSimulated = true");
check(r.initial.driverOnce === true, "驱动幂等标记已置位");
check(r.initial.panel === true, "#wesim 面板已挂载");
check(r.initial.edge === true, "#wesim-edge 右边缘热区已挂载");
check(r.initial.hasToggle === false, "没有常驻开关按钮(打开方式只有右边缘)");
check(/模拟环境/.test(r.initial.banner ?? ""), "面板有「模拟环境」横幅", r.initial.banner?.trim());
check(r.initial.canvas > 0, "壁纸本身也渲染了", `canvas=${r.initial.canvas}`);
console.log("\n=== ② 右边缘滑出交互 ===");
check(r.closedInitially === "0", "初始收起", `data-open=${r.closedInitially}`);
check(r.openedByEdge === "1", "指针移到右边缘 → 滑出", `data-open=${r.openedByEdge}`);
check(r.closedByLeave === "0", "指针离开 → 自动收回", `data-open=${r.closedByLeave}`);
check(r.reopened === "1", "再次移到右边缘 → 再次滑出", `data-open=${r.reopened}`);
check(r.afterClose === "0", "「取消」收起后不会被边缘热区立刻弹回", `data-open=${r.afterClose}`);
check(r.afterRearm === "1", "离开边缘后再回来 → 重新可触发", `data-open=${r.afterRearm}`);
console.log("\n=== ③ 子路径下没有 404(相对路径正确)===");
check(notFound.length === 0, "静态服务器 0 个 404", notFound.slice(0, 3).join(" | ") || "无");
const noisy = r.consoleLines.filter((l) => /error|uncaught/i.test(l));
check(r.exceptions.length === 0, "0 未捕获异常", r.exceptions.slice(0, 2).join(" | ") || "无");
check(noisy.length === 0, "0 控制台报错", noisy.slice(0, 3).join(" | ") || "干净");
server.close();
console.log(failures === 0 ? "\n静态预览:通过(面板不依赖调试服,且随右边缘滑出)" : `\n静态预览:失败 ${failures} 处`);
process.exit(failures === 0 ? 0 : 1);
@@ -0,0 +1,79 @@
// 视觉等价验收:当前 dist 与**重构前**的存档截图逐像素比对。
//
// 为什么需要它:`tools/checks/verify-sim-page.mts` 只证明"自包含包能跑",不证明"画面没变"。
// 内联、模块合成、URL 重写这些改动都可能悄悄改掉渲染结果。
//
// 判据:mean|diff| = 0 且 max|diff| = 0(逐像素相同)。
// 注意 `tools/capture.mjs` 打印的 "spine 层贡献" 是**同一轮里 ref 帧 vs 主帧**的差
// (即"spine 层占了多少像素"),不是与基线的比对——我一开始把它读成了后者,
// 白紧张了一场。真正的基线比对只有这里。
//
// 用法:
// node tools/serve.mjs 8190 --root dist/releases/collection-all
// node tools/capture.mjs visual-current --base http://127.0.0.1:8190
// node tools/checks/verify-visual-equivalence.mts
import { execFileSync } from "node:child_process";
import { existsSync } from "node:fs";
const BASELINE = "tools/shots/baseline-pre-refactor";
const CURRENT = process.argv[2] ?? "tools/shots/visual-current";
const combos: { preset: string; size: string }[] = [];
for (const preset of ["kv37", "xilian"]) {
for (const size of ["3440x1440", "1920x1080", "1080x1920"]) combos.push({ preset, size });
}
function diff(a, b) {
const out = execFileSync("node", ["tools/diff.mjs", a, b], { encoding: "utf8" });
return out.trim().split("\n").pop() ?? "";
}
/*
* 缺文件时**直接失败**,不跳过。
*
* 这里原来写的是"缺少文件就跳过",于是有一次当前截图目录被清掉之后,
* 它把 0 组比对报成了"0 组全部逐像素相同"——一句绿色的空话。
* 一个不检查前置条件的验证等于没有验证:先把该在的文件都确认在,再谈结论。
*/
const missing: string[] = [];
for (const { preset, size } of combos) {
for (const file of [`${BASELINE}/${preset}-${size}.png`, `${CURRENT}/${preset}-${size}.png`]) {
if (!existsSync(file)) missing.push(file);
}
}
if (missing.length > 0) {
console.error(`✗ 缺少 ${missing.length} 个截图,无法比对:`);
for (const file of missing.slice(0, 4)) console.error(` ${file}`);
if (missing.length > 4) console.error(` …还有 ${missing.length - 4} 个`);
console.error(`
基线在 ${BASELINE}(重构前存档,不能重新生成)。
当前截图需要现拍:
node tools/serve.mjs 8190 --root dist/releases/collection-all
node tools/capture.mjs visual-current --base http://127.0.0.1:8190
`);
process.exit(1);
}
let failed = 0;
let checked = 0;
for (const { preset, size } of combos) {
const a = `${BASELINE}/${preset}-${size}.png`;
const b = `${CURRENT}/${preset}-${size}.png`;
checked++;
const line = diff(a, b);
const identical = /mean\|diff\|=0\/255\s+max\|diff\|=0/.test(line);
if (!identical) failed++;
console.log(` ${identical ? "✓" : "✗"} ${preset.padEnd(7)} ${size.padEnd(11)} ${line}`);
}
// 走到这里 checked 必然等于 combos.length(上面缺文件就退了),这条是防回归的兜底。
if (checked !== combos.length) {
console.error(`✗ 只比对了 ${checked}/${combos.length} 组,结论不可信`);
process.exit(1);
}
console.log(
failed === 0
? `\n视觉等价:${checked} 组全部与重构前逐像素相同`
: `\n视觉等价:${failed}/${checked} 组与基线不同`,
);
process.exit(failed === 0 ? 0 : 1);
+538
View File
@@ -0,0 +1,538 @@
// pnpm dev —— 本地调试服:把 dist/releases/ 下的分发目录按发布结构服务出来,并注入 WE 模拟器。
//
// 为什么不直接服务某个分发目录当根:把**服务根设在 releases 的父目录**,任何一个分发里若存在
// 越出自身目录的引用(../ 或根绝对路径),浏览器会立刻 404 —— 这是对"目录可整体搬走"最严的检验。
// 每档分发各占一个端口反而可能掩盖问题(越界路径会意外命中别的分发)。
//
// 路由:
// / 分发索引(读 dist-map.json)
// /release/<dir>/ 某档分发的根(自动补 index.html)
// /release/<dir>/<相对路径> 其内部资源,按扩展名给正确 MIME
// /simulator/wallpaper-engine.js WE 模拟器本体(调试期专有,见下)
//
// 为什么模拟器单独一条路由:按 ADR 0006 §1,模拟器**默认不进发布产物**,只有
// `pnpm build --with-sim` 才会往分发目录里放一份。而调试服总是需要它。早先驱动脚本直接
// 从 `/release/<dir>/scripts/wallpaper-engine.js` 取,于是默认构建(不带 --with-sim)下
// 动态 import 必然 404、面板静默消失——现象是"调试时没有 WE 模拟面板"。
// 现在由调试服从编译产物 build/scripts/wallpaper-engine.js 提供,
// 分发目录则始终保持"就是发布产物"的样子。
//
// 查询参数(与既有对拍工具的参数保持兼容,见 tools/serve.mjs):
// ?sim=0 不注入 WE 模拟器
// ?__props={"preset":"kv37"} 覆盖初始用户属性
// ?__propsAt=dom|load 属性下发时序;dom 复现"load 之前就到达"的竞态
// ?fps=30 初始 fps(经 applyGeneralProperties 下发);0 = 不限流
// ?__paused=1 初始 setPaused(true)
// ?nojs=1 剥掉所有 <script>,得到"只有 CSS 背景、没有 canvas"的对照组
// ?__freeze=1 注入既有的对拍驱动(冻结动画相位,供 cdp/shot 断言)
// ?__hide=canvas 渲染正常但把 canvas 藏起来,得到"只有背景图"的参考帧
//
// 热更新:监听 src/ 与 wallpapers/,按改动只跑必要的那几步构建,然后经 SSE 通知浏览器。
// 改 CSS → 只重铺产物,然后**只换样式表**(不整页重载,Spine 播放器与面板状态都保住)
// 改 runtime → tsc runtime + 重铺产物 → 整页刷新
// 改 simulator → tsc simulator(若产物里也带模拟器,再重铺一遍)→ 整页刷新
// 改壁纸数据 → 重铺产物 → 整页刷新
// 构建失败 → 只报错,**不刷新**(刷新只会把半成品页面端上来)
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { createReadStream, watch } from "node:fs";
import { spawn } from "node:child_process";
import { readFile, stat } from "node:fs/promises";
import { extname, join, resolve, sep } from "node:path";
import { abs, exists, log, readJson } from "./lib/fs.ts";
import { readVault, VaultError } from "./lib/vault.ts";
import type { DistMap } from "./lib/types.ts";
import { SIMULATOR_DRIVER, SIMULATOR_URL, LIVE_RELOAD_CLIENT, NOJS_DRIVER } from "./lib/drivers.ts";
const MIME: Record<string, string> = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".mjs": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".json": "application/json; charset=utf-8",
".atlas": "text/plain; charset=utf-8",
".txt": "text/plain; charset=utf-8",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".gif": "image/gif",
".svg": "image/svg+xml",
".flac": "audio/flac",
".mp3": "audio/mpeg",
".ogg": "audio/ogg",
".opus": "audio/ogg",
".wav": "audio/wav",
".webm": "video/webm",
};
interface Args {
port: number;
build: boolean;
open: boolean;
}
function parseArgs(argv: string[]): Args {
const args: Args = { port: 5173, build: true, open: false };
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i] ?? "";
if (arg === "--port") args.port = Number(argv[++i] ?? 5173);
else if (arg.startsWith("--port=")) args.port = Number(arg.slice(7));
else if (arg === "--no-build") args.build = false;
else if (arg === "--open") args.open = true;
// 构建开关本身由 main() 读 process.argv 决定(初始构建与热更新重建共用一份)。
// 这里必须显式放行,否则它会在参数解析阶段就被当成未知参数拒掉——
// 那样 main() 里那段判断永远走不到,整个开关是死代码。
else if (arg === "--with-sim") continue;
else if (/^\d+$/.test(arg)) args.port = Number(arg);
else if (arg === "--help" || arg === "-h") {
log(HELP);
process.exit(0);
} else throw new VaultError(`未知参数:${arg}\n\n${HELP}`);
}
return args;
}
const HELP = `用法:pnpm dev [--port 5173] [--no-build] [--open] [--with-sim]
--no-build 不先跑一次增量构建(dist/ 不存在时会直接报错)
--open 在默认浏览器里打开索引页
--with-sim 产物里也带一份 WE 模拟器(静态托管预览用)。
会一直带着:初始构建与热更新重建用的是同一组开关。`;
/** 读某分发目录的 project.json 与所需注入数据。 */
async function releaseInfo(
dir: string,
): Promise<
| { properties: Record<string, unknown>; version: string; title: string; defaultPresetId: string; preview?: string }
| undefined
> {
const root = abs(`dist/releases/${dir}`);
if (!(await exists(join(root, "project.json")))) return undefined;
const project = await readJson<{
version?: string;
title?: string;
preview?: string;
general?: { properties?: Record<string, unknown> };
}>(join(root, "project.json"));
const map = await readJson<DistMap>(abs("dist/dist-map.json"));
return {
properties: project.general?.properties ?? {},
version: project.version ?? "",
title: project.title ?? dir,
defaultPresetId: map.releases.find((r) => r.dir === dir)?.defaultPresetId ?? "",
preview: project.preview,
};
}
/** 索引页:列出所有分发,点进去即可调试。 */
async function indexPage(): Promise<string> {
const map = await readJson<DistMap>(abs("dist/dist-map.json"));
const rows = map.releases
.map((release) => {
const wallpapers = release.wallpapers.map((w) => `${w.name}<span class="id">${w.id}</span>`).join("、");
return `<tr>
<td><a href="/release/${encodeURIComponent(release.dir)}/">${release.displayName}</a>
<div class="dir">${release.dir}</div></td>
<td class="type">${releaseTypeLabel(release.type, release.scope)}</td>
<td>${wallpapers}</td>
<td class="num">${(release.bytes / 1024 / 1024).toFixed(1)} MB</td>
</tr>`;
})
.join("\n");
/** 类型列显示人话:单档 / 游戏合集 / 全部合集(内部值不给人看)。 */
function releaseTypeLabel(type: string, scope?: string): string {
if (type === "single") return "单档";
return scope === "all" ? "全部合集" : "游戏合集";
}
return `<!DOCTYPE html><html lang="zh"><head><meta charset="utf-8">
<title>SpineWallpaper 调试服</title>
<style>
body { margin: 32px auto; max-width: 900px; font: 14px/1.6 "Segoe UI", system-ui, sans-serif;
background: #14141a; color: #e8e8ea; }
h1 { font-size: 18px; } code { background: #1e1e28; padding: 1px 5px; border-radius: 3px; }
table { width: 100%; border-collapse: collapse; margin-top: 12px; }
th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid #2c2c38; vertical-align: top; }
th { color: #8d8da0; font-size: 11px; text-transform: uppercase; letter-spacing: .06em; }
a { color: #8fb8ff; text-decoration: none; } a:hover { text-decoration: underline; }
.dir { color: #6f6f85; font-size: 11px; }
.type { color: #b9a0ff; } .num { text-align: right; color: #9b9bb0; }
.id { color: #6f6f85; margin-left: 4px; font-size: 11px; }
.hint { color: #8d8da0; font-size: 12px; margin-top: 20px; }
</style></head><body>
<h1>SpineWallpaper 调试服 <span class="dir">version ${map.version}</span></h1>
<p>每档分发都是 <b>自包含</b> 的:服务根设在 <code>dist/releases/</code>,某个分发里若有越界引用会立刻 404。</p>
<table><thead><tr><th>分发</th><th>类型</th><th>壁纸</th><th>体积</th></tr></thead>
<tbody>${rows}</tbody></table>
<div class="hint">
进入某档分发后可用:<code>?sim=0</code> 关模拟器 · <code>&amp;__props={"preset":"kv37"}</code> 覆盖属性 ·
<code>&amp;__propsAt=dom</code> 复现 load 前下发 · <code>&amp;fps=30</code> 限流 · <code>&amp;nojs=1</code> 剥脚本对照
</div>
</body></html>`;
}
/** 往 index.html 注入驱动脚本(与既有对拍管线共用 tools/lib/drivers.ts)。 */
async function html(file: string, dir: string, search: URLSearchParams): Promise<string> {
let body = await readFile(file, "utf8");
if (search.get("nojs") === "1") {
const stripped = body.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, "<!-- script removed by ?nojs=1 -->");
return `${stripped}\n${NOJS_DRIVER}`;
}
const info = await releaseInfo(dir);
if (!info) return body;
// 热更新客户端放进 <head>,尽早连上 SSE:晚一步就会漏掉"保存后立刻开始构建"的那条消息。
body = body.replace(/<\/head>/i, () => `${LIVE_RELOAD_CLIENT}\n</head>`);
// project.json 的属性定义要交给模拟器面板渲染控件;用内联脚本最省一次请求。
const propertiesTag = `<script>window.__weProperties = ${JSON.stringify(info.properties).replace(/</g, "\\u003c")};</script>`;
// 驱动脚本注入在壁纸模块**之前**:它用动态 import 装载模拟器,从而在 index.js 注册
// wallpaperPropertyListener 之前就把 API 备好(模拟器源码本身仍是 ES module,见 drivers.ts)。
const inject = SIMULATOR_DRIVER({
search: search.toString(),
dir,
title: info.title,
version: info.version,
defaultPresetId: info.defaultPresetId,
preview: info.preview,
});
if (search.get("sim") !== "0") {
body = body.replace(
/<script\b[^>]*src=["'][^"']*scripts\/index\.js["'][^>]*><\/script>/i,
(match) => `${propertiesTag}\n${inject}\n${match}`,
);
}
return body;
}
function safeJoin(root: string, rel: string): string | undefined {
const target = resolve(root, rel.replace(/^\/+/, ""));
if (target !== root && !target.startsWith(root + sep)) return undefined;
return target;
}
/* ------------------------------------------------------------------ 热更新 */
/** 浏览器端注入的 LIVE_RELOAD_CLIENT 会连到 /__dev/events。 */
const liveClients = new Set<ServerResponse>();
function broadcast(payload: Record<string, unknown>): void {
const data = `data: ${JSON.stringify(payload)}\n\n`;
for (const client of liveClients) {
try {
client.write(data);
} catch {
liveClients.delete(client);
}
}
}
const TSC = abs("node_modules/typescript/bin/tsc");
interface RebuildSteps {
runtime: boolean;
simulator: boolean;
build: boolean;
}
/**
* 按改动文件决定跑哪几步。
*
* 全量 `pnpm build` 是 2.2 秒,而绝大多数改动只涉及其中一步:改样式只要重铺产物(0.85s),
* 改模拟器只要重编模拟器(0.6s)。省下来的是每次保存的等待时间。
*/
function stepsFor(files: string[], releasesCarrySimulator: boolean): RebuildSteps {
const steps: RebuildSteps = { runtime: false, simulator: false, build: false };
for (const file of files) {
if (file === "src/globals.d.ts") {
// 类型声明同时影响运行时与模拟器
steps.runtime = true;
steps.simulator = true;
steps.build = true;
} else if (file.startsWith("src/runtime/")) {
steps.runtime = true;
steps.build = true; // 编译产物要重铺进各分发
} else if (file.startsWith("src/simulator/")) {
steps.simulator = true;
// --with-sim 的产物里也有一份模拟器,且它的驱动排在调试服注入的那份之前
// (幂等闸先到先得),那份不更新的话浏览器会一直跑旧模拟器。
steps.build = releasesCarrySimulator;
} else if (file.startsWith("src/") || file.startsWith("wallpapers/") || file === "VERSION") {
steps.build = true; // 样式、模板、vendor、project.template.json、壁纸数据
}
}
return steps;
}
/** 跑一个子进程,成功返回它的输出,失败抛出带输出的错误(要显示到浏览器上)。 */
function runStep(args: string[], label: string): Promise<string> {
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, args, { stdio: ["ignore", "pipe", "pipe"] });
let output = "";
child.stdout.on("data", (chunk: Buffer) => {
output += chunk.toString();
});
child.stderr.on("data", (chunk: Buffer) => {
output += chunk.toString();
});
child.on("error", reject);
child.on("close", (code) => {
if (code === 0) resolve(output);
else reject(new VaultError(`${label} 失败(exit ${code})\n${output.trim().split("\n").slice(-12).join("\n")}`));
});
});
}
/** 监听源码目录。**绝不能监听 dist/**:构建写 dist,监听它就是一个死循环。 */
function startWatcher(onChange: (file: string) => void): void {
for (const root of ["src", "wallpapers"]) {
try {
watch(abs(root), { recursive: true }, (_event, filename) => {
if (filename) onChange(`${root}/${String(filename).replace(/\\/g, "/")}`);
});
} catch (error) {
log(` ⚠ 无法监听 ${root}/:${String(error)}`);
}
}
try {
watch(abs("VERSION"), () => onChange("VERSION"));
} catch {
// VERSION 不存在就算了,构建自己会报
}
}
/** 串行化重建:构建期间来的改动先攒着,跑完再补一轮。 */
function createRebuilder(releasesCarrySimulator: boolean, buildFlags: string[]): (files: string[]) => void {
const pending = new Set<string>();
let timer: ReturnType<typeof setTimeout> | undefined = undefined;
let running = false;
const run = async (): Promise<void> => {
if (running) return; // 正在跑的那一轮会在循环里把 pending 收走
running = true;
try {
while (pending.size > 0) {
const batch = [...pending];
pending.clear();
const steps = stepsFor(batch, releasesCarrySimulator);
const labels: string[] = [];
if (steps.runtime) labels.push("runtime");
if (steps.simulator) labels.push("simulator");
if (steps.build) labels.push("releases");
if (labels.length === 0) continue;
broadcast({ type: "building", steps: labels });
const started = Date.now();
try {
if (steps.runtime) await runStep([TSC, "-p", "tsconfig.runtime.json"], "tsc runtime");
if (steps.simulator) await runStep([TSC, "-p", "tsconfig.simulator.json"], "tsc simulator");
// 必须带上与启动时相同的开关。否则 `pnpm dev --with-sim` 会在第一次保存后
// 被悄悄换成干净构建——产物里的模拟器没了,而没人会想到是"保存"干的。
if (steps.build) await runStep([abs("tools/build.ts"), ...buildFlags], "build");
} catch (error) {
const message = error instanceof VaultError ? error.message : String(error);
log(` ✗ 重建失败:${message.split("\n")[0]}`);
broadcast({ type: "error", message });
continue; // 不刷新页面:刷新只会把半成品端上来
}
// 只改了样式 → 让浏览器换个样式表就行,不必重载整页
const cssOnly =
steps.build &&
!steps.runtime &&
!steps.simulator &&
batch.every((f) => /^src\/styles\/.*\.css$/.test(f));
log(` ↻ 已重建 ${labels.join(" + ")}(${Date.now() - started} ms)${cssOnly ? " 只换样式表" : " 整页刷新"}`);
broadcast({ type: cssOnly ? "css" : "reload" });
}
} finally {
running = false;
}
};
// 防抖:一次保存常常触发多个事件(编辑器写临时文件再改名),攒一下再跑。
return (files: string[]): void => {
for (const file of files) pending.add(file);
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
timer = undefined;
void run();
}, 120);
};
}
async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2));
// 构建开关只算一次,初始构建与热更新重建共用,免得两处走偏(已经偏过一次)。
const buildFlags = process.argv.includes("--with-sim") ? ["--with-sim"] : [];
if (args.build && !(await exists(abs("dist/dist-map.json")))) {
log("dist/ 不存在,先跑一次构建…(--no-build 可跳过)");
const child = spawn(process.execPath, [abs("tools/build.ts"), ...buildFlags], {
stdio: "inherit",
});
await new Promise<void>((done, fail) => {
child.on("close", (code) => (code === 0 ? done() : fail(new VaultError(`构建失败(exit ${code})`))));
});
}
const vault = await readVault();
const releasesRoot = abs("dist/releases");
const map = await readJson<DistMap>(abs("dist/dist-map.json"));
const server = createServer((req, res) => {
void handle(req, res, releasesRoot);
});
// 产物里带不带模拟器,决定"改模拟器"要不要顺带重铺产物(见 stepsFor)。
const releasesCarrySimulator = (
await Promise.all(
map.releases.map((release) => exists(abs(`dist/releases/${release.dir}/scripts/wallpaper-engine.js`))),
)
).some(Boolean);
const schedule = createRebuilder(releasesCarrySimulator, buildFlags);
startWatcher((file) => {
schedule([file]);
});
server.listen(args.port, "127.0.0.1", () => {
log(`SpineWallpaper 调试服 version ${map.version}`);
log(` 壁纸:${vault.wallpapers.map((w) => `${w.id}(${w.meta.name})`).join("、")}`);
log(` 索引:http://127.0.0.1:${args.port}/`);
for (const release of map.releases) {
log(` /release/${release.dir}/ ${release.displayName}`);
}
// 启动时就把"模拟器能不能加载"说清楚。否则它只会在浏览器里变成一条 404,
// 表现成"面板没出来",而没人会想到是构建产物缺文件。
void stat(abs("build/scripts/wallpaper-engine.js")).then(
(info) =>
log(` 模拟器:${SIMULATOR_URL} ✓ ${(info.size / 1024).toFixed(0)} KB(调试期专有,不进发布产物)`),
() => log(` 模拟器:✗ 未编译(build/scripts/wallpaper-engine.js 不存在)→ 面板不会出现,请先跑 pnpm build`),
);
log(
` 热更新:监听 src/ 与 wallpapers/(改完自动重建并刷新;CSS 只换样式表)` +
(buildFlags.length > 0 ? ` 构建开关:${buildFlags.join(" ")}` : ""),
);
log(" 注意:重建会清掉本次未产出的目录,\`sim/\` 需要时单独跑 pnpm build sim。");
if (args.open) {
void import("node:child_process").then(({ spawn }) => {
spawn("cmd", ["/c", "start", "", `http://127.0.0.1:${args.port}/`], { stdio: "ignore", detached: true });
});
}
});
}
async function handle(req: IncomingMessage, res: ServerResponse, releasesRoot: string): Promise<void> {
try {
// 每次请求重读:改了目录名、加了新壁纸之后,调试服不该需要重启才认。
// 索引页本来就这么做;这里以前用的是启动时那一份,于是磁盘上已经是 single-hsr-kv37,
// 服务端还按 wallpaper-kv37 校验,整档 404。
const map = await readJson<DistMap>(abs("dist/dist-map.json"));
const url = new URL(req.url ?? "/", "http://127.0.0.1");
const pathname = decodeURIComponent(url.pathname);
if (pathname === "/" || pathname === "/index.html") {
const body = await indexPage();
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
res.end(body);
return;
}
// 热更新事件流。text/event-stream 要一直挂着,所以这里不能 end()。
if (pathname === "/__dev/events") {
res.writeHead(200, {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-store",
connection: "keep-alive",
});
res.write(": connected\n\n");
liveClients.add(res);
req.on("close", () => liveClients.delete(res));
return;
}
// 模拟器本体:调试期专有资源,从编译产物读(见文件头"为什么模拟器单独一条路由")。
// 刻意**不**回落到分发目录里的副本:分发里那份只有 --with-sim 才有,可能是旧的,
// 而这里要的永远是"当前源码编出来的那一个"。
if (pathname === SIMULATOR_URL) {
const file = abs("build/scripts/wallpaper-engine.js");
const info = await stat(file).catch(() => null);
if (!info?.isFile()) {
res
.writeHead(404, { "content-type": "text/plain; charset=utf-8" })
.end(`模拟器未编译:${file} 不存在。跑一次 pnpm build(或 pnpm build:sim)。`);
return;
}
res.writeHead(200, {
"content-type": MIME[".js"] ?? "text/javascript; charset=utf-8",
"content-length": info.size,
"cache-control": "no-store",
});
createReadStream(file).pipe(res);
return;
}
const match = /^\/release\/([^/]+)(\/.*)?$/.exec(pathname);
if (!match) {
res
.writeHead(404, { "content-type": "text/plain; charset=utf-8" })
.end("只服务 /、/release/<dir>/… 与 " + SIMULATOR_URL);
return;
}
const dir = match[1] ?? "";
if (!map.releases.some((release) => release.dir === dir)) {
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" }).end(`没有这档分发:${dir}`);
return;
}
const root = join(releasesRoot, dir);
let rel = (match[2] ?? "/").replace(/^\//, "");
if (rel === "" || rel.endsWith("/")) rel += "index.html";
const file = safeJoin(root, rel);
if (!file) {
res.writeHead(403, { "content-type": "text/plain; charset=utf-8" }).end("越出分发目录");
return;
}
const info = await stat(file).catch(() => null);
if (!info?.isFile()) {
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" }).end(`404 ${rel}`);
return;
}
if (extname(file).toLowerCase() === ".html") {
const body = await html(file, dir, url.searchParams);
res.writeHead(200, {
"content-type": "text/html; charset=utf-8",
"content-length": Buffer.byteLength(body),
"cache-control": "no-store",
});
res.end(body);
return;
}
res.writeHead(200, {
"content-type": MIME[extname(file).toLowerCase()] ?? "application/octet-stream",
"content-length": info.size,
"cache-control": "no-store",
});
createReadStream(file).pipe(res);
} catch (error) {
res.writeHead(500, { "content-type": "text/plain; charset=utf-8" }).end(String(error));
}
}
try {
await main();
} catch (error) {
if (error instanceof VaultError) {
console.error(`\n调试服启动失败:${error.message}\n`);
process.exit(1);
}
throw error;
}
+248
View File
@@ -0,0 +1,248 @@
# `tools/downloader/` — 米哈游活动页 Spine 抓取器
从 `wallpapers/sources.yml` 列出的活动页里把 **Spine 骨架 + 贴图 + 场景装配信息** 抓下来,
落到 staging(`_out/`)。**每个有内容的场景各产出一个「可直接搬走的壁纸目录」**——用户挑中的那份
整个移到 `wallpapers/<游戏id>/` 就能用(也可以让 `promote` 代劳)。
它不是构建输入:`wallpapers/` 只放**最终要发布**的资源,抓取脚本与临时下载都留在 `tools/`。
```bash
python -m tools.downloader fetch --page kv45 # 抓一页(结尾自动 verify,有硬伤就非 0 退出)
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 # 只用 _cache/ + 已落盘的产物,不发任何请求(重跑验证)
python -m tools.downloader promote --page kv45/scene_ava --game hsr --id scene_ava \
--name 挥掷千星的筹码 --title '【崩坏:星穹铁道】挥掷千星的筹码' --description '[b]挥掷千星的筹码[/b]'
node tools/checks/check-downloader.mts # 门:编译 + 夹具绿 + 四处破坏必红
```
依赖:Python 3.10+ 与 **PyYAML**(`requirements.txt`)。不需要浏览器——这些页面免登录即可拿到
全量入口 bundle,骨架数据就在里面。
---
## 一、产物形状
```
tools/downloader/
├── _cache/<页面>/<脚本名> # 抓下来的 bundle 原文(文本调试缓存,可重跑零请求)
├── _out/<游戏>/<页面>/
│ ├── page.json # 页面级溯源:来源 URL / 入口脚本 / 场景清单 / 每个场景目录的摘要
│ └── <场景id>/ # ← 一个场景 = 一个可直接搬走的壁纸目录
│ ├── meta.json # id / name / title / description / game / page / scene / source / audio
│ ├── preset.template.json # 运行时配置(背景图 + sceneConfig.parts)
│ ├── scene.json # 场景侧车:part 列表 + 每个 part 的世界变换
│ ├── spines/<骨架名>/
│ │ ├── <骨架名>.json # 骨架(skeleton.images 已归一化为空串)
│ │ ├── <骨架名>.atlas # 第一行 = 贴图页名,与落盘文件名逐字一致
│ │ ├── <贴图页…> # 与 atlas **同居**(布局 A,见 ADR 0008)
│ │ └── meta.json # spine 版本 / 动画 / 皮肤 / 来源 URL
│ ├── scene/<场景图> # 几何平面(geometry + material.diffuse)贴的图
│ └── audios/ # 音源(当前恒为空:页面的 BGM 还没抓)
└── selection.yml # 机器所有的「哪一页推荐哪个场景」(**入库**)
```
场景目录名 = 场景 id 转义成合法壁纸 id(只允许 `[a-z0-9_-]`,首字符必须是字母数字):
`scene_main` / `P1` / `loading` 原样,中文场景 id(back-moon 的 `动画预览`)折成 `scene`。
**真实 id 不丢**——它在同目录的 `meta.json` 与 `scene.json` 里。
`_cache/` 与 `_out/` 在 `.gitignore` 里;`selection.yml` 入库。
### 场景内去重、跨场景不去重
* **场景内按 id 去重**:同一个场景里同一具骨架被引用多次只存一份(`ctc_rewards` 在
back-moon 的 `scene_content` 里出现 4 次、`gc_win` 在 `scene_gacha` 里 6 次,都只落一份)。
`scene.json` 里那些 part 仍然一条不少——只有**文件**去重。
* **跨场景不去重**:`scene_main/` 与 `scene_ava/` 会各存一份用到的骨架。`_out/` 是可随时
重生成的 staging,体积换"每个目录都能单独搬走"。总体积记在 `page.json` 的 `totalBytes` 里。
### `usable`:不是每个场景目录都能搬走
各页的 `scene_ui` 是一层**场景渲染目标**:它的平面用 `drawScene` modifier 把别的场景渲染成纹理
(diffuse 名就是场景 id),本身没有任何资源文件。这种场景照样落盘("页面上有几个场景"这件事在
产物里是完整的),但它的预设 `parts` 是空的:
```json
{ "usable": false, "reason": "场景里只有运行时纹理(drawScene / 贴图缓冲),没有可搬走的资源" }
```
`promote` 拒绝搬这种目录;`page.json` 的 `sceneDirs[]` 里逐条标了 `usable`。
## 二、场景选择:只决定"推荐哪个"
`fetch` **落盘全部有内容的场景**(至少有一具骨架或一块贴图平面)。纯色平面组成不了壁纸
(`sceneConfig.parts` 会是空的),完全没有 part 的场景也没有内容——这两类不落盘,原因记在
`page.json` 的 `skippedScenes` 里。
推荐场景由 `selection.yml` / `sources.yml` / 默认值算出(优先级写死,避免两份文件打架):
1. `wallpapers/sources.yml` 里这一页写了 `scene:` / `spines:` → 用它(**人的意志最高**)
2. `tools/downloader/selection.yml` 里有这一页的记录 → 用它(上次交互的结果)
3. 都没有 → **骨架最多的那个场景**(`--interactive` 时才问人)
默认规则与旧项目的判断一致(get-memory 会推荐 `P1`、back-moon 会推荐 `scene_main`)。
推荐值写进 `page.json` 的 `chosenScene` / `chosenDir`,是 `promote` 不给 `--scene` 时的默认值。
> **这份选择不再裁剪产物。** 以前它决定"只落哪几个骨架",现在产物是"一个场景一个自包含的
> 壁纸目录"——砍掉几具骨架会做出一个缺件的坏场景。要精简就在场景目录里的
> `preset.template.json` 上改(那是纯数据,构建期才读)。
> `sources.yml` 的 `spines:` 字段同理,只剩记录语义。
## 三、`scene.json` 里有什么
页面用自研引擎(three.js 系)描述场景:`sceneList` 是场景数组,每个场景是一棵树,节点有三类负载:
| kind | 含义 | 需要资源? |
| --- | --- | --- |
| `spine` | `spine:{id:"main_nike"}`,一具骨架 = 一个视觉元件 | 是(骨架 + atlas + 贴图页) |
| `image` | `geometry` + `material.uniforms.diffuse`,贴图平面 | 是(`scene/<名>.<ext>`) |
| `solid` | `defines.USE_TEXTURE == 0` 的**纯色平面**(diffuse 常写 `DEFAULT`) | 否 |
每个 part 带 `position` / `scale`(**世界变换**)与 `localPosition` / `localScale`,合成语义照抄引擎:
```
world_position = parent_position + parent_scale ⊙ local_position
world_scale = parent_scale ⊙ local_scale
```
旋转暂不参与合成(旧项目同样如此),只在 `stats.rotatedNodes` 里报出数量——不假装算了。
绘制层级 = 树序遍历序(`order`)+ 节点自身的 `renderOrder`。
带 `"runtime": true` 的 `image` part 是**没有独立文件**的平面:`drawScene` 的场景渲染目标、
`cacheContainer` 的贴图缓冲,或 diffuse 指向同场景骨架缓存。它们不进预设,也不算缺资源。
**用户口中的 "scene / geometric" 就是这里的两类节点**:`sceneList` 的场景容器与 `geometry` 平面。
Spine 官方没有这两个数据概念(官方 7 种附件类型里没有 geometric;`scene` 在 spine-webgl 里指的是
`SceneRenderer` 这个渲染器)。所以"支持场景动画"= 运行期把多具骨架 + 若干贴图平面按这套摆放合成。
## 四、`preset.template.json` 怎么生成的
* `sceneConfig.parts`:`scene.json` 里每个 `spine` / `image` part 一条;纯色平面与运行时纹理不写
(运行时画不了它们,写进去只是死配置)。路径一律写成 `./spines/<名>/…`、`./scene/<名>.<ext>`——
**正斜杠**,因为预设会被整体搬走,`str(Path)` 的反斜杠在别的机器上是错的。
* `backgroundImage`:构建期它是**必填且必须真实存在**的。自动挑法 = 场景里**面积最大**的贴图平面
(它决定 `document.body` 的底图与取景用的宽高比,挑到一块小按钮会让整幅画的比例全错);
一块贴图平面都没有时退到第一具骨架的 atlas 第一行声明的贴图页。
* 写法与校验在同一个模块 `layout.py` 里(`build_preset` / `missing_preset_paths`):
产物形状一旦改,校验规则必须同时改,分成两个文件迟早漂移。写完之后**当场**把每条路径在磁盘上
核一遍,不留到构建期才炸。
## 五、页面侧的坑(都踩过,别再踩)
- **描述表的 `src` 表达式不能用一条 `.+?` 通吃**:kv45 的 bundle 里有个编译后的模板片段写着
`{src:e.activeIcon,alt:""}})`,`\{src:(.+?),id:"…",type:"image"\}` 会从那里一路吃 **8.8 万字符**
去够后面的 `,id:"…",type:"image"}`,把夹在中间的真表项(`{src:$w,id:"loading_dt1",…}`)整个吞掉。
现在分成两种形状各匹配各的:`_DESCRIPTOR_INLINE`(`Object.values(Object.assign({…}))[0]`)与
`_DESCRIPTOR_SIMPLE`(不含 `,{}` 的表达式)。
- **资源引用有四种写法**,缺一种就会把真资源当"不存在":
1. `X = a.p + "images/x.png"`(`_ASSET_LITERAL`)
2. `{src:a(38458),id:"x"}`:模块直接导出字符串——小图被 webpack 内联成
`e.exports="data:image/png;base64,…"`(`_MODULE_STRING`)。get-memory 的 `loading_moutain_a`、
`a01_lizi`、start-ndkl 的 `loading_start_1` 都栽在这里(少了它们,平面会报"资源表里没有 URL")。
3. `{src:$w,id:"x"}`:`$w="data:image/png;base64,…"`(`_STRING_ASSIGN`,kv45 的 `loading_dt1`)。
4. `Object.values(Object.assign({"<源路径>":"data:…"}))[0]`(`_DATA_IN_ASSIGN`)。
- **骨架数据一律内联在 bundle 里**,6 个页面里 `.atlas` / `.skel` 的网络请求数是 **0**。
只有 hsr 的骨架 json 走网络(页面根目录 `<hash>.json`),而且它在 **webpack 异步 chunk**
里(`258.ccb0954b.js`)——入口 HTML 根本没列它,得读 `.u=` 的 chunk 名映射再排队抓。
这也是 hsr 的 `scene_ava` **首次离线抓不到**的原因:那两个 json 从没进过 `_cache/`,
得先联网抓一次(见第七节的离线边界)。
- **三种内联家族**都要认:A `Object.values(Object.assign({"…/spine/<N>.json":{…}}))[0]`(JS 对象字面量,
键不带引号,用 `jslit` 规范化);B 匿名模块 + 配对表 `{atlas:fn(id),json:fn(id)}`,
骨架是 `JSON.parse('…')`(**单引号** JS 字符串,要先按 JS 语义还原);C hsr 的 `spineSetting`。
- **公共路径变量名逐页不同**:`n.p` / `a.p` / `t.p`。写死 `n.p` 会让半个页面的资源表全空。
- **同一逻辑名可能有两个候选**(引擎的桌面/移动两套预载表)。判据:数组式描述表是基准集,
字典式表是移动端覆盖(引擎里是 `desktop() || base.forEach(e => override[e.id] && …)`),桌面取基准集。
- **atlas 有两种书写风格**:`size:498,330` 与 `size: 256, 256`,解析器两种都要吃。
- **`.atlas` 的页名要读 atlas 自己声明的**(`atlas.py` 解析出来的第一行/页行),不要按 `<stem>_N` 猜:
多页是 `_2.png`,也有完全不同的名字。落盘用逻辑名,atlas 第一行不用改。
- **`skeleton.images` 要归一化**:作者目录(`../images/`)搬进分发后一定指错,写空串即可
(页名相对 atlas 所在目录解析),原值记在 `meta.json` 的 `originalImages` 里。
- **跨平台路径**:别拿 `str(Path)` 当映射键(Windows 反斜杠 vs 预设里的正斜杠),一律
`Path.as_posix()`。
## 六、多版本 Spine
`meta.json` 逐具骨架记录 `skeleton.spine` 原文。事实基线(详见 `.scratch/spine-versions/REPORT.md`):
- 版本串是**编辑器版本**;`4.0-from-4.1-from-4.2-from-4.3.23` 这类 `-from-` 串表示
**数据是 4.0 格式**(降级导出),运行库只认开头那个 `major.minor`。
- 运行库**不校验**这个串;spine-ts 4.2 能读 4.0/4.1/4.2 的数据。**4.3 数据会被静默丢掉全部约束**
(4.3 把约束并进 `root.constraints`),所以 `verify` 对 `≥4.3` 的骨架直接报红。
- 目前 7 页共 170+ 具骨架全是 ≤4.2 格式,**一套 4.2 运行库就够**;真出现 4.3 数据再谈多套 UMD 共存
(官方没有 `spine-version` 属性,只能各包一层别名函数避免 `window.spine` 互相覆盖)。
- 重分发运行库要带 Spine Runtimes License Agreement(Exhibit A)+ 版权声明,不得删各文件头。
## 七、`verify` 检查什么
逐场景目录核一遍:`meta.json` / `preset.template.json` / `scene.json` 都在;`meta.json` 的
`id` 等于目录名、字符集合法、`name`/`title`/`description` 非空、`audio.choices` 指向的文件存在;
预设里每条 `./…` 路径在磁盘上找得到;侧车引用的骨架有目录与文件、atlas 声明的贴图页逐字落盘、
几何平面的图存在;纯色与运行时纹理不计入缺失;骨架版本在运行库可读范围内。
页面级还会抓"旧形状的残留"(页面根的 `scene.json` / `spine/` / `scene/`)与"不是场景目录的目录"。
**退出码非 0 = 有硬伤**。
它不评判画面对不对——那是 `tools/checks/verify-scene-player.mts` 的事(多骨架 + 贴图平面真的合成出来)。
### 离线能跑到哪一步
`fetch --offline` 只读 `_cache/`(bundle 文本)与**已经落盘的 `_out/`**。所以:
* **重跑**永远是安全的:产物存在就跳过,不发任何请求。
* **首次**抓一个"从没抓过的场景"需要联网——它的骨架 json 与贴图页都还没有本地副本。
hsr 的 `scene_ava`(`zhigengniao_juheye` / `shajin`)就是这样:先 `fetch --page kv45`(联网)
一次,之后再 `--offline` 就完全绿。缺什么会**逐条报出来**(不会甩一条 traceback 就走)。
## 八、离线预览(`_out/` 能看,`_cache/` 不能)
| 目录 | 内容 | 能否预览 |
| --- | --- | --- |
| `_cache/<页面>/` | 原始 bundle **文本**(引用仍是线上绝对地址、贴图不在里面) | ❌ 按设计就是文本调试缓存 |
| `_out/<游戏>/<页面>/<场景>/` | 真实文件树(骨架 / atlas / 贴图页 / 场景图 / 侧车 / 预设) | ✅ **可离线预览** |
```bash
node tools/preview.mts # 列出 _out 里可预览的场景
node tools/preview.mts ys/nico-tea # 页面 = 取它的推荐场景
node tools/preview.mts ys/nico-tea/scene_main # 点名场景
node tools/preview.mts hsr/kv45/scene_ava --port 8199
node tools/preview.mts ys/nico-tea --no-serve # 只组装到 tools/.cache/preview/
```
`tools/preview.mts` 把「构建出的运行时 + staging 的资产 + 由 `scene.json` 生成的预设」组装成
一个独立目录再起静态服务;**页面只读本地文件**。页面里的调试面是 `window.__sceneDebug`
(`parts` / `loaded` / `errors` / `framing` / `rotatedSkipped`),排查"少加载了一件"直接看它。
两点说明:
- **要预览"原页面"而不是我们的产物**,得走整站镜像(`.scratch/page-mirror/spec.md`,尚未实现);
`_cache/` 不能满足这个需求——它只有文本,没有资产树,也没有改写引用。
- `tools/checks/verify-scene-player.mts` 里另有一份"从 scene.json 生成预设"的代码,
**故意不与 preview 共用**:门必须独立于被验证对象,共用一份就变成自己验自己。
## 九、`promote`:搬进 `wallpapers/`
`fetch` 已经产出终态,所以 promote 退化成 **拷贝 + 校验 + 写 meta**:
```bash
python -m tools.downloader promote --page kv45/scene_ava --game hsr --id scene_ava \
--name '挥掷千星的筹码' --title '【崩坏:星穹铁道】挥掷千星的筹码' --description '[b]挥掷千星的筹码[/b]'
```
* `--page` 认三种写法:页面 id(`kv45`)、`页面/场景`(`kv45/scene_ava`)、场景目录的路径。
给页面 id 时用 `--scene` 点名,或让它取 `page.json` 的 `chosenScene`。
* `--name` / `--title` / `--description` 可选:不给就沿用场景目录 `meta.json` 里的
(那里已经是"页面名(场景id)"的形状,够用但通常要改成正式文案)。
* `--cover` 给逻辑名时覆盖 `backgroundImage`。
* **目标目录里的 `meta.json` 的 `id` 会被改写成 `--id`**——所以壁纸 id 可以跟场景目录名不同,
但目录名与 `meta.json.id` 必须一致(构建期会校验)。
* 搬之前会再核一遍预设里的每条路径在**目标目录**上存在;搬完不用手工改任何路径。
也可以完全不用 promote:**整个场景目录拷到 `wallpapers/<游戏id>/<壁纸id>/` 就完事**
(前提是目录名 = `meta.json.id`,且那个目录 `usable`)。两种做法等价,promote 只是顺手改名与校验。
## 十、还没做的
- **音源**:页面 BGM 是另一条链,`audios/` 现在是空的,`meta.json` 的 `audio.choices` 也是空的。
- **预览图**:没有抓,也没有生成。
- **`scene_ui` 这类纯渲染目标场景**:落盘但不可搬走(运行时不会 `drawScene`)。
+11
View File
@@ -0,0 +1,11 @@
"""米哈游活动页的 Spine 抓取器(Python,只依赖 stdlib + PyYAML)。
它不是构建输入:`wallpapers/` 只放最终要发布的资源,抓取脚本与临时下载都留在这里。
产物先落 `_out/<游戏>/<页面>/`,经 `--verify` 自检后再由人 promote 进 `wallpapers/`。
子命令与用法见同目录 README.md。
"""
__all__ = ["__version__"]
__version__ = "0.1.0"
+582
View File
@@ -0,0 +1,582 @@
"""命令行入口:`python -m tools.downloader <fetch|select|verify|promote>`。
设计原则(见 README.md):
* **只落 staging**(`_out/`),不碰 `wallpapers/`——promote 是后续单独一步。
* **一页 = 全部场景**:每个有内容的场景各产出一个「可直接搬走的壁纸目录」
(`_out/<游戏>/<页面>/<场景id>/`),不再只落被选中的那一个。形状与校验见 `layout.py`。
* **可重跑**:文本走 `_cache/`,产物存在就跳过;`--offline` 下不发起任何请求。
* **抓完就自检**:`fetch` 结尾自动跑 `verify`,有硬伤就非 0 退出。
"""
from __future__ import annotations
import argparse
import datetime as dt
import json
import shutil
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import yaml
from . import layout as layout_mod
from . import promote as promote_mod
from . import scene as scene_mod
from . import selection as selection_mod
from . import verify as verify_mod
from .sites import mihoyo
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
SOURCES = ROOT / "wallpapers" / "sources.yml"
CACHE = HERE / "_cache"
OUT = HERE / "_out"
SELECTION = HERE / "selection.yml"
# 旧形状(一页只落一个场景)留在页面根上的东西。新形状里它们是页面根的污染:
# `scene.json` 归到每个场景目录里、`spine/` 改名 `spines/` 并下沉到场景目录。
_LEGACY_ENTRIES = ("scene.json", "spine", "scene")
# "看着像贴图平面、其实没有独立文件"的 modifier:
# cacheContainer —— 渲染进贴图缓冲
# drawScene —— 把**另一个场景**渲染成纹理(diffuse 名就是场景 id,如各页的 scene_ui)
_RUNTIME_MODIFIERS = ("cacheContainer", "drawScene")
@dataclass
class PageEntry:
game: str
id: str
name: str
url: str
scene: str | None = None
spines: list[str] = field(default_factory=list)
class SourcesError(RuntimeError):
"""`sources.yml` 形状不对。"""
def load_sources(path: Path = SOURCES) -> list[PageEntry]:
"""读 `sources.yml`:``<游戏>: [{id, name, url, scene?, spines?}, …]``。"""
if not path.exists():
raise SourcesError(f"找不到来源清单:{path}")
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
if not isinstance(raw, dict):
raise SourcesError(f"{path} 顶层必须是「游戏 → 页面列表」的映射")
entries: list[PageEntry] = []
for game, pages in raw.items():
if not isinstance(pages, list):
raise SourcesError(f"{path} 里 {game} 必须是列表")
for i, page in enumerate(pages):
where = f"{path} 的 {game}[{i}]"
if not isinstance(page, dict):
raise SourcesError(f"{where} 不是映射(YAML 里少写了一个 `- `?)")
missing = [k for k in ("id", "name", "url") if not page.get(k)]
if missing:
raise SourcesError(f"{where} 缺少字段:{', '.join(missing)}")
spines = page.get("spines") or []
if not isinstance(spines, list):
raise SourcesError(f"{where} 的 spines 必须是列表")
entries.append(
PageEntry(
game=str(game),
id=str(page["id"]),
name=str(page["name"]),
url=str(page["url"]),
scene=str(page["scene"]) if page.get("scene") else None,
spines=[str(s) for s in spines],
)
)
return entries
def _download(url: str, dest: Path, *, force: bool) -> int:
"""下载一个资源到 dest(已存在则跳过)。返回落盘字节数。"""
if dest.exists() and not force:
return 0
dest.parent.mkdir(parents=True, exist_ok=True)
data = mihoyo.http_get(url, None)
dest.write_bytes(data)
return len(data)
def _write_spine(scene_dir: Path, entry: PageEntry, data: mihoyo.PageData, name: str,
*, force: bool, offline: bool, report: list[str]) -> int:
"""落一具骨架到 `<场景目录>/spines/<名>/`:json + atlas + 贴图页 + meta.json。
页与 atlas **同居**(布局 A)——spine-ts 按 `<atlas 目录>/<页名>` 解析页,页跑到别处就读不到。
"""
asset = data.spines.get(name)
spine_dir = scene_dir / layout_mod.SPINE_DIR / name
if asset is None:
report.append(f"场景引用了骨架 {name},但页面里没有它的数据(已跳过)")
return 0
try:
payload = mihoyo.load_spine_json(data, asset, cache_dir=CACHE, offline=offline)
except mihoyo.HttpError as exc:
# 离线缺缓存 / 网络失败:报出来,别让一条 traceback 把整页的抓取带崩。
report.append(f"骨架 {name} 的数据取不到:{exc}")
return 0
original_images = (payload.get("skeleton") or {}).get("images")
normalized = dict(payload)
skeleton = dict(normalized.get("skeleton") or {})
# 页名按 atlas 所在目录解析:作者目录("../images/" 之类)搬进分发后一定指错。
skeleton["images"] = ""
normalized["skeleton"] = skeleton
spine_dir.mkdir(parents=True, exist_ok=True)
written = 0
json_file = spine_dir / f"{name}.json"
if force or not json_file.exists():
json_file.write_text(json.dumps(normalized, ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
written += json_file.stat().st_size
atlas_file = spine_dir / f"{name}.atlas"
if force or not atlas_file.exists():
atlas_file.write_text(asset.atlas_text, encoding="utf-8")
written += atlas_file.stat().st_size
# 页名读 atlas 自己声明的页(`asset.pages` 就是 atlas 解析出来的),不按 `<stem>_N` 猜。
for page_name in asset.pages:
stem = page_name.rsplit(".", 1)[0]
rel = data.images.get(stem)
if rel is None:
report.append(f"骨架 {name} 的贴图页 {page_name} 在资源表里找不到 URL")
continue
try:
written += _download(data.asset_url(rel), spine_dir / page_name, force=force)
except mihoyo.HttpError as exc:
report.append(f"骨架 {name} 的贴图页 {page_name} 下载失败:{exc}")
meta = asset.as_meta(entry.url)
meta["originalImages"] = original_images
meta["fetchedAt"] = dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds")
(spine_dir / "meta.json").write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
return written
def _write_images(scene_dir: Path, data: mihoyo.PageData, parts: list[Any], *,
runtime_names: set[str], force: bool, report: list[str],
notes: list[str]) -> tuple[int, set[str]]:
"""落几何平面用的场景图到 `<场景目录>/scene/`。
没有 URL 的平面**一律算运行时纹理**(不下载、不进预设,在 `scene.json` 里标 `runtime`):
资源表就是页面向网络索取资源的完整清单,名字不在表里说明页面自己也不去网上取它。
四种来源:`drawScene` 的场景渲染目标、`cacheContainer` 的贴图缓冲、diffuse 指向同场景骨架
缓存,以及运行时生成的纹理。其中只有最后一种会记一条 note——前三种是页面的正常结构。
"""
written = 0
runtime: set[str] = set()
for part in parts:
if part.kind != "image":
continue
image_id = part.id
rel = data.images.get(image_id)
if rel is None:
runtime.add(image_id)
if not any(m in part.modifiers for m in _RUNTIME_MODIFIERS) and image_id not in runtime_names:
notes.append(f"[{scene_dir.name}] 平面 {image_id} 在资源表里没有 URL,按运行时纹理处理")
continue
ext = mihoyo.image_ext(rel)
try:
written += _download(
data.asset_url(rel), scene_dir / layout_mod.SCENE_DIR / f"{image_id}{ext}", force=force
)
except mihoyo.HttpError as exc:
report.append(f"几何平面 {image_id} 下载失败:{exc}")
return written, runtime
def _scene_dir_names(scenes: list[scene_mod.Scene]) -> dict[str, str]:
"""场景 id → 目录名;同时消掉转义后可能出现的重名(大小写不敏感)。"""
used: set[str] = set()
out: dict[str, str] = {}
for scene in scenes:
base = layout_mod.scene_dir_name(scene.id)
name, index = base, 2
while name.lower() in used:
name = f"{base}_{index}"
index += 1
used.add(name.lower())
out[scene.id] = name
return out
def _scene_has_content(scene: scene_mod.Scene) -> bool:
"""这个场景有没有**内容**(骨架或贴图平面)。
只有纯色平面的场景(back-moon 的 `动画预览`)与完全没有 part 的场景(`effect_DofBlur`)
不算——它们连一张图都不需要,落出来的目录必然是空的。
注意"有内容"不等于"能搬走":各页的 `scene_ui` 全是 `drawScene` 渲染目标,一落地就是
没有 part 的目录(`usable: false`),它进 `_out` 只是为了让"页面上有几个场景"这件事
在产物里是完整的。
"""
return any(p.kind in ("spine", "image") for p in scene.parts)
def _write_scene(page_dir: Path, entry: PageEntry, data: mihoyo.PageData, scene: scene_mod.Scene, *,
dir_name: str, force: bool, offline: bool, report: list[str],
notes: list[str], fetched_at: str) -> dict[str, Any]:
"""把一个场景落成「可直接搬走的壁纸目录」。返回它的摘要(写进 page.json)。"""
scene_dir = page_dir / dir_name
(scene_dir / layout_mod.AUDIO_DIR).mkdir(parents=True, exist_ok=True)
before = len(report)
written = 0
kept_spines = scene.spine_ids # 场景内的全部骨架,按出现序去重(同一骨架被引用多次只存一份)
for name in kept_spines:
written += _write_spine(scene_dir, entry, data, name, force=force, offline=offline, report=report)
image_bytes, runtime_images = _write_images(
scene_dir, data, scene.parts, runtime_names=set(kept_spines), force=force,
report=report, notes=notes,
)
written += image_bytes
payload = scene.as_dict(page=entry.id, game=entry.game)
payload["parts"] = []
for part in scene.parts:
item = part.as_dict()
if part.kind == "image" and part.id in runtime_images:
item["runtime"] = True # 由骨架 / 别的场景渲染出来,没有独立文件
payload["parts"].append(item)
written += layout_mod.write_json(scene_dir / "scene.json", payload)
preset, preset_notes = layout_mod.build_preset(payload, scene_dir)
notes.extend(f"[{dir_name}] {note}" for note in preset_notes)
for rel in layout_mod.missing_preset_paths(preset, scene_dir):
report.append(f"[{dir_name}] preset.template.json 引用的 {rel} 不在磁盘上")
written += layout_mod.write_json(scene_dir / "preset.template.json", preset)
# 该落盘却没落进预设 = 硬伤(上面的 report 已经写了原因),别让目录看起来是好的。
planned = [p for p in scene.parts if p.kind == "spine" or (p.kind == "image" and p.id not in runtime_images)]
usable = bool(preset["sceneConfig"]["parts"])
reason = ""
if not usable:
reason = "场景里只有运行时纹理(drawScene / 贴图缓冲),没有可搬走的资源"
if not usable and planned:
report.append(f"[{dir_name}] 有 {len(planned)} 件该落盘的 part 却没进预设(见上面的缺失报告)")
meta = layout_mod.build_meta(
wallpaper_id=dir_name,
name=f"{entry.name}({scene.id})",
title=f"{entry.name}({scene.id})",
description=f"{entry.name} · 场景 {scene.id}\n来源:{entry.url}",
game=entry.game,
page=entry.id,
scene=scene.id,
source=entry.url,
fetched_at=fetched_at,
)
meta["usable"] = usable
if reason:
meta["reason"] = reason
written += layout_mod.write_json(scene_dir / "meta.json", meta)
return {
"dir": dir_name,
"scene": scene.id,
"usable": usable,
"reason": reason or None,
"spines": len(kept_spines),
"images": len({p.id for p in scene.parts if p.kind == "image"}),
"runtimeImages": sorted(runtime_images),
"solids": sum(1 for p in scene.parts if p.kind == "solid"),
"parts": len(preset["sceneConfig"]["parts"]),
"bytes": written,
"problems": len(report) - before,
}
def cmd_fetch(args: argparse.Namespace) -> int:
entries = load_sources()
if args.page:
wanted = set(args.page)
entries = [e for e in entries if e.id in wanted]
missing = wanted - {e.id for e in entries}
if missing:
print(f"来源清单里没有这些页面:{', '.join(sorted(missing))}", file=sys.stderr)
return 2
if not entries:
print("没有要抓的页面。", file=sys.stderr)
return 2
selection = selection_mod.load_selection(SELECTION)
problems: list[str] = []
total_bytes = 0
for entry in entries:
print(f"\n=== {entry.game}/{entry.id} {entry.name}")
def fail(message: str, _id: str = entry.id) -> None:
"""抓取期的硬伤当场打印——攒到最后再报会让人以为"这页没东西"。"""
problems.append(f"{_id}:{message}")
print(f" ✗ {message}")
try:
data = mihoyo.fetch_page(entry.id, entry.game, entry.url, cache_dir=CACHE, offline=args.offline)
except mihoyo.HttpError as exc:
fail(f"抓取失败 {exc}")
continue
scenes = data.scenes
if not scenes:
fail("页面里一个场景都没有")
continue
# 默认场景仍然按"骨架最多"算,但它现在只用于「哪一档是推荐的」——
# 全部有内容的场景都会落盘,选择不再裁剪产物(选择记录也只剩这个用途)。
fallback = scene_mod.pick_default_scene(scenes)
choice = selection_mod.resolve(
entry.id,
scenes,
sources_entry={"scene": entry.scene, "spines": entry.spines},
selection=selection,
default_scene=fallback.id if fallback else None,
default_spines=fallback.spine_ids if fallback else [],
interactive=args.interactive,
)
chosen = choice.scene or (fallback.id if fallback else None)
page_dir = OUT / entry.game / entry.id
page_dir.mkdir(parents=True, exist_ok=True)
for legacy in _LEGACY_ENTRIES:
stale = page_dir / legacy
if not stale.exists():
continue
# 旧形状的残留:留着会被 verify 当成形状不对的场景目录。
if stale.is_dir():
shutil.rmtree(stale, ignore_errors=True)
else:
stale.unlink()
print(f" · 清掉旧形状的 {legacy}")
names = _scene_dir_names(scenes)
wanted_scenes = [s for s in scenes if _scene_has_content(s)]
wanted_ids = {s.id for s in wanted_scenes}
skipped = [
{"scene": s.id, "reason": "只有纯色平面或完全没有 part,不需要任何资源"}
for s in scenes
if s.id not in wanted_ids
]
if not wanted_scenes:
fail("所有场景都不需要资源(没有骨架、也没有贴图平面)")
continue
print(f" 场景 {len(wanted_scenes)}/{len(scenes)} 个有内容,逐个落盘:")
fetched_at = dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds")
summaries: list[dict[str, Any]] = []
notes: list[str] = []
for scene in wanted_scenes:
before = len(problems)
summary = _write_scene(
page_dir, entry, data, scene,
dir_name=names[scene.id], force=args.force, offline=args.offline,
report=problems, notes=notes, fetched_at=fetched_at,
)
summaries.append(summary)
total_bytes += int(summary["bytes"])
for message in problems[before:]:
print(f" ✗ {message}")
mark = "✓" if summary["usable"] else "○"
print(
f" {mark} {summary['dir']}/(原 id {summary['scene']}):骨架 {summary['spines']}、"
f"贴图平面 {summary['images']}、纯色 {summary['solids']}、预设 part {summary['parts']},"
f"{int(summary['bytes']) / 1024:.1f} KB"
+ (f"(不可搬走:{summary['reason']})" if not summary["usable"] else "")
)
for note in notes:
print(f" · {note}")
# 推荐的场景要真的落了盘——推荐到一个被跳过的场景会让 promote 的默认值指向空气。
usable_dirs = {s["dir"] for s in summaries if s["usable"]}
chosen_dir = names.get(chosen, "") if chosen else ""
if chosen_dir not in usable_dirs:
chosen = next((s["scene"] for s in summaries if s["usable"]), None)
chosen_dir = names.get(chosen, "") if chosen else ""
page_payload = {
"id": entry.id,
"game": entry.game,
"name": entry.name,
"url": entry.url,
"site": data.site.id,
"entry": data.entry_url,
"bundles": sorted(data.bundles),
"scenes": [
{
"id": s.id,
"dir": names.get(s.id) if s.id in wanted_ids else None,
"spines": len(s.spine_ids),
"images": len({p.id for p in s.parts if p.kind == "image"}),
"solids": sum(1 for p in s.parts if p.kind == "solid"),
}
for s in scenes
],
"chosenScene": chosen,
"chosenDir": chosen_dir or None,
"sceneDirs": summaries,
"skippedScenes": skipped,
"totalBytes": sum(int(s["bytes"]) for s in summaries),
"warnings": data.warnings + [w for w in problems if w.startswith(entry.id)] + notes,
"fetchedAt": fetched_at,
}
layout_mod.write_json(page_dir / "page.json", page_payload)
for warning in data.warnings:
print(f" ! {warning}")
usable_count = sum(1 for s in summaries if s["usable"])
print(
f" 页面合计 {page_payload['totalBytes'] / 1024 / 1024:.2f} MB → "
f"{len(summaries)} 个场景目录(其中 {usable_count} 个可直接搬走)"
)
selection[entry.id] = choice.as_dict()
selection_mod.save_selection(SELECTION, selection)
print(f"\n合计新增 {total_bytes / 1024 / 1024:.2f} MB;选择记录 → {SELECTION.relative_to(ROOT)}")
print("\n=== verify")
found, stats = verify_mod.verify_all(OUT, pages=[e.id for e in entries])
for problem in found:
print(f" ✗ {problem}")
print(
f" 页面 {stats['pages']}、场景目录 {stats['sceneDirs']}、骨架 {stats['spines']}、"
f"几何平面 {stats['images']}、体积 {stats['bytes'] / 1024 / 1024:.2f} MB"
)
if stats["versions"]:
versions = "、".join(f"{v}×{n}" for v, n in sorted(stats["versions"].items()))
print(f" 骨架版本:{versions}")
if found:
print(f"\n{len(found)} 处硬伤,未通过。", file=sys.stderr)
return 1
return 0
def cmd_select(args: argparse.Namespace) -> int:
"""只记录「推荐哪个场景」——产物不再按选择裁剪(fetch 落全部有内容的场景)。"""
entries = load_sources()
if args.page:
entries = [e for e in entries if e.id in set(args.page)]
selection = selection_mod.load_selection(SELECTION)
for entry in entries:
try:
data = mihoyo.fetch_page(entry.id, entry.game, entry.url, cache_dir=CACHE, offline=args.offline)
except mihoyo.HttpError as exc:
print(f"{entry.id}:抓取失败 {exc}", file=sys.stderr)
return 1
fallback = scene_mod.pick_default_scene(data.scenes)
choice = selection_mod.resolve(
entry.id,
data.scenes,
sources_entry={"scene": entry.scene, "spines": entry.spines},
selection=selection,
default_scene=fallback.id if fallback else None,
default_spines=fallback.spine_ids if fallback else [],
interactive=True,
)
selection[entry.id] = choice.as_dict()
print(f"[{entry.id}] 已记录推荐场景:{choice.scene}")
selection_mod.save_selection(SELECTION, selection)
print(f"\n选择记录 → {SELECTION.relative_to(ROOT)}")
return 0
def cmd_promote(args: argparse.Namespace) -> int:
"""把 staging 的一个**场景目录**转成发布形状(`wallpapers/<游戏>/<壁纸id>/`)。"""
staged = Path(args.page)
if not staged.is_absolute():
candidate = OUT / args.page
staged = candidate if candidate.exists() else Path(args.page)
try:
result = promote_mod.promote_page(
staged,
ROOT / "wallpapers",
game=args.game,
wallpaper_id=args.id,
scene=args.scene,
name=args.name,
title=args.title,
description=args.description,
cover=args.cover,
force=args.force,
)
except (FileNotFoundError, FileExistsError) as exc:
print(str(exc), file=sys.stderr)
return 2
print(
f"promote {result.scene_dir.name} → {result.target.relative_to(ROOT)}\n"
f" 预设 part {result.parts}(骨架 {result.spines}、贴图平面 {result.images})"
f",跳过纯色平面 {result.solids_skipped},落盘 {result.bytes / 1024 / 1024:.1f} MB"
)
print(" 下一步:pnpm build(或 pnpm dev)让构建把它烘焙进 preset.js")
return 0
def cmd_verify(args: argparse.Namespace) -> int:
root = Path(args.root).resolve() if args.root else OUT
found, stats = verify_mod.verify_all(root, pages=args.page)
for problem in found:
print(f"✗ {problem}")
print(
f"页面 {stats['pages']}、场景目录 {stats['sceneDirs']}、骨架 {stats['spines']}、"
f"几何平面 {stats['images']}、体积 {stats['bytes'] / 1024 / 1024:.2f} MB"
)
if stats["versions"]:
versions = "、".join(f"{v}×{n}" for v, n in sorted(stats["versions"].items()))
print(f"骨架版本:{versions}")
return 1 if found else 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="python -m tools.downloader", description="米哈游活动页 Spine 抓取器")
sub = parser.add_subparsers(dest="command", required=True)
def common(p: argparse.ArgumentParser) -> None:
p.add_argument("--page", action="append", help="只处理指定页面 id(可重复)")
fetch = sub.add_parser("fetch", help="抓取并落 staging:每个有内容的场景一个壁纸目录(结尾自动 verify)")
common(fetch)
fetch.add_argument("--interactive", action="store_true", help="逐页交互式选「推荐哪个场景」")
fetch.add_argument("--offline", action="store_true", help="只用 _cache/,不发起任何请求")
fetch.add_argument("--force", action="store_true", help="重下已存在的产物")
fetch.set_defaults(func=cmd_fetch)
pick = sub.add_parser("select", help="只做交互式选择(推荐场景),写入 selection.yml")
common(pick)
pick.add_argument("--offline", action="store_true", help="只用 _cache/,不发起任何请求")
pick.set_defaults(func=cmd_select)
promote = sub.add_parser("promote", help="把一个场景目录转成 wallpapers/<游戏>/<壁纸id>/")
promote.add_argument("--page", required=True,
help="页面 id(如 kv45)、「页面/场景」(如 kv45/scene_ava)或场景目录的路径")
promote.add_argument("--game", required=True, help="游戏 id(wallpapers/<游戏>/)")
promote.add_argument("--id", required=True, help="壁纸 id(全局唯一,与 WE combo 的 value 一致)")
promote.add_argument("--scene", help="页面目录下要 promote 的场景(默认取 page.json 的 chosenScene)")
promote.add_argument("--name", help="显示名(默认沿用场景目录 meta.json 的)")
promote.add_argument("--title", help="创意工坊标题(默认沿用场景目录 meta.json 的)")
promote.add_argument("--description", help="WE 的 BBCode 文案(默认沿用场景目录 meta.json 的)")
promote.add_argument("--cover", help="封面图逻辑名(scene/<名>.*),写进 backgroundImage")
promote.add_argument("--force", action="store_true", help="目标目录已存在时覆盖")
promote.set_defaults(func=cmd_promote)
check = sub.add_parser("verify", help="自检 _out/ 的引用闭包与文件齐全")
common(check)
check.add_argument("--root", help="检查别的产物目录(默认 tools/downloader/_out)")
check.set_defaults(func=cmd_verify)
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
return int(args.func(args))
except SourcesError as exc:
print(f"sources.yml 有问题:{exc}", file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())
+76
View File
@@ -0,0 +1,76 @@
"""Spine atlas 文本的解析与页名规范化。
.atlas 是文本格式:**第一行是贴图页文件名**,接着 `size:` / `filter:` / `format:` / `scale:`
等页面头,之后才是各区域的 `bounds` / `offsets` / `rotate`。换页就是再来一行页文件名。
两种书写风格都要吃(同一批页面里都存在):
size:498,330 ← 紧凑式(nico-tea / zhidong-wonder / kv45)
size: 256, 256 ← 带空格式(get-memory)
抓取期我们只关心两件事:**这具骨架需要哪些贴图页**,以及**页名能不能原样落盘**
(运行时按第一行去请求贴图页,所以文件名必须与第一行逐字一致)。
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
__all__ = ["AtlasInfo", "parse", "page_names", "retarget_pages"]
_PAGE_EXT = re.compile(r"\.(png|webp|jpg|jpeg)$", re.IGNORECASE)
_PAGE_HEAD = re.compile(r"^(size|format|filter|repeat|pma|scale)\s*:", re.IGNORECASE)
_REGION_ATTR = re.compile(
r"^(bounds|offsets|rotate|xy|orig|index|split|pad|width|height)\s*:", re.IGNORECASE
)
@dataclass
class AtlasInfo:
"""一具骨架的 atlas 摘要。"""
pages: list[str] = field(default_factory=list)
regions: list[str] = field(default_factory=list)
region_pages: list[str | None] = field(default_factory=list)
@property
def multi_page(self) -> bool:
return len(self.pages) > 1
def parse(text: str) -> AtlasInfo:
"""解析 atlas 文本。无法识别的行按区域名处理(atlas 里非属性行就是区域名)。"""
info = AtlasInfo()
current: str | None = None
for raw in text.splitlines():
line = raw.strip()
if not line:
current = None
continue
if ":" not in line and _PAGE_EXT.search(line):
current = line
info.pages.append(line)
continue
if _PAGE_HEAD.match(line) or _REGION_ATTR.match(line):
continue
info.regions.append(line)
info.region_pages.append(current)
return info
def page_names(text: str) -> list[str]:
"""只要贴图页名(顺序即 atlas 里声明的顺序)。"""
return parse(text).pages
def retarget_pages(text: str, mapping: dict[str, str]) -> str:
"""把 atlas 里的页名按 mapping 改写(只改整行的页名行,不碰区域名)。"""
out: list[str] = []
for raw in text.splitlines():
line = raw.strip()
if line and ":" not in line and _PAGE_EXT.search(line) and line in mapping:
out.append(mapping[line])
else:
out.append(raw)
tail = "\n" if text.endswith("\n") else ""
return "\n".join(out) + tail
+217
View File
@@ -0,0 +1,217 @@
"""JS 字面量 → Python 对象。
米哈游活动页把骨架数据与场景树以 **JS 对象字面量** 内联在 bundle 里,不是合法 JSON:
{skeleton:{hash:"KR8Ibf8DXTI",spine:"4.2.43",x:-110.93},bones:[{name:"root"}],scaleX:.6487}
键不带引号、小数可以省略前导 0、布尔写作 ``!0`` / ``!1``、末尾可以有逗号。
这里只做**词法层**的规范化(把字面量改写成 JSON 文本),再交给 ``json.loads``——
不 ``eval``、不执行页面代码。
唯一被容忍的"语义"是未知裸标识符(例如 ``undefined``):默认替换为 ``null`` 并计数,
``strict=True`` 时抛错。骨架数据里出现别的裸标识符说明提取边界错了,值得知道。
"""
from __future__ import annotations
import json
import re
from typing import Any
__all__ = ["loads", "match_literal", "first_object_value", "unescape", "JsLitError"]
# 按优先级排列的词法单元。字符串/数字/``!0`` 必须排在 ``other`` 之前。
_TOKEN = re.compile(
r"""
(?P<ws>\s+)
| (?P<dquote>"(?:[^"\\]|\\.)*")
| (?P<squote>'(?:[^'\\]|\\.)*')
| (?P<template>`(?:[^`\\]|\\.)*`)
| (?P<negnot>!0|!1)
| (?P<num>-?(?:\d+\.\d*|\.\d+|\d+)(?:[eE][+-]?\d+)?)
| (?P<ident>[A-Za-z_$][\w$]*)
| (?P<other>.)
""",
re.VERBOSE | re.DOTALL,
)
_LITERAL_IDENTS = {"true": "true", "false": "false", "null": "null"}
_NULL_IDENTS = {"undefined", "NaN", "Infinity", "void"}
_ESCAPES = {"n": "\n", "t": "\t", "r": "\r", "b": "\b", "f": "\f", "v": "\v", "0": "\0"}
class JsLitError(ValueError):
"""字面量无法规范化成 JSON。"""
def unescape(body: str) -> str:
"""还原 JS 字符串体里的转义(``\\n`` / ``\\uXXXX`` / ``\\'`` / ``\\\\`` …)。
Family B 的骨架是 ``e.exports=JSON.parse('…')``:外层是**单引号** JS 字符串,
必须先按 JS 语义还原,再交给 ``json.loads``。
"""
return _unescape(body, "'")
def _unescape(body: str, quote: str) -> str:
"""还原 JS 字符串体(不含两端引号)里的转义。"""
out: list[str] = []
i = 0
while i < len(body):
ch = body[i]
if ch != "\\":
out.append(ch)
i += 1
continue
nxt = body[i + 1] if i + 1 < len(body) else ""
if nxt == "u" and i + 6 <= len(body):
out.append(chr(int(body[i + 2 : i + 6], 16)))
i += 6
elif nxt == "x" and i + 4 <= len(body):
out.append(chr(int(body[i + 2 : i + 4], 16)))
i += 4
elif nxt == "\n": # 行延续
i += 2
else:
out.append(_ESCAPES.get(nxt, nxt))
i += 2
return "".join(out)
def _num_key(value: str) -> str:
"""把数字字面量还原成 JS 当键时用的字符串(``0`` → ``"0"``、``.5`` → ``"0.5"``)。"""
try:
as_float = float(value)
except ValueError:
return value
if as_float.is_integer():
return str(int(as_float))
return repr(as_float)
def _normalize(text: str, *, strict: bool) -> tuple[str, int]:
out: list[str] = []
unknown = 0
pos = 0
for m in _TOKEN.finditer(text):
kind = m.lastgroup
raw = m.group()
if kind == "ws" or kind == "other":
out.append(raw)
pos = m.end()
continue
if kind in ("dquote", "squote", "template"):
body = raw[1:-1]
quote = raw[0]
out.append(json.dumps(_unescape(body, quote), ensure_ascii=False))
elif kind == "negnot":
out.append("true" if raw == "!0" else "false")
elif kind == "num":
value = raw
if value.startswith("-."):
value = "-0" + value[1:]
elif value.startswith("."):
value = "0" + value
if value.endswith("."):
value += "0"
# 数字也能当键(动画名就叫 "0" 的骨架真实存在:`animations:{0:{…}}`)。
# JS 会把数字字面量转成字符串当键,所以这里要按同一语义还原。
if re.match(r"\s*:", text[m.end() :]):
out.append(json.dumps(_num_key(value), ensure_ascii=False))
else:
out.append(value)
elif kind == "ident":
# 后面(跳过空白)跟冒号的标识符是键,否则是值。
tail = text[m.end() :]
is_key = bool(re.match(r"\s*:", tail))
if is_key:
out.append(json.dumps(raw, ensure_ascii=False))
elif raw in _LITERAL_IDENTS:
out.append(_LITERAL_IDENTS[raw])
elif raw in _NULL_IDENTS:
out.append("null")
unknown += 1
if strict:
raise JsLitError(f"未知标识符 {raw!r} @ {m.start()}")
else:
out.append("null")
unknown += 1
if strict:
raise JsLitError(f"未知标识符 {raw!r} @ {m.start()}")
pos = m.end()
if pos < len(text):
out.append(text[pos:])
normalized = "".join(out)
# 尾逗号:`,}` / `,]`
normalized = re.sub(r",(\s*[}\]])", r"\1", normalized)
return normalized, unknown
def loads(text: str, *, strict: bool = False) -> Any:
"""把 JS 字面量文本解析成 Python 对象。"""
normalized, _ = _normalize(text, strict=strict)
try:
return json.loads(normalized)
except json.JSONDecodeError as exc:
head = text[max(0, exc.pos - 120) : exc.pos + 120].replace("\n", " ")
raise JsLitError(f"字面量规范化后仍不是 JSON:{exc.msg} @ {exc.pos}\n…{head}…") from exc
def match_literal(text: str, start: int) -> int:
"""返回 ``text[start]`` 处那个配平字面量的**闭括号下标**;找不到返回 -1。
与 JS 侧同名的辅助函数一一对应:跳过字符串/模板串/注释,按开闭括号配平。
它是所有提取器的地基——正则数不清嵌套括号,只有这个能。
"""
if start >= len(text):
return -1
open_ch = text[start]
close_ch = {"{": "}", "[": "]", "(": ")"}.get(open_ch)
if close_ch is None:
return -1
depth = 0
i = start
while i < len(text):
ch = text[i]
if ch in "\"'`":
quote = ch
i += 1
while i < len(text):
if text[i] == "\\":
i += 2
elif text[i] == quote:
break
else:
i += 1
i += 1
continue
if ch == "/" and i + 1 < len(text) and text[i + 1] == "/":
while i < len(text) and text[i] != "\n":
i += 1
continue
if ch == "/" and i + 1 < len(text) and text[i + 1] == "*":
i += 2
while i + 1 < len(text) and not (text[i] == "*" and text[i + 1] == "/"):
i += 1
i += 2
continue
if ch == open_ch:
depth += 1
elif ch == close_ch:
depth -= 1
if depth == 0:
return i
i += 1
return -1
def first_object_value(text: str, brace_index: int) -> Any:
"""``Object.values(Object.assign({k: v}))[0]`` 的取值语义:解析 ``{...}`` 并返回第一个值。"""
end = match_literal(text, brace_index)
if end < 0:
raise JsLitError(f"未配平的对象字面量 @ {brace_index}")
obj = loads(text[brace_index : end + 1])
if not isinstance(obj, dict) or not obj:
raise JsLitError(f"期望非空对象字面量 @ {brace_index}")
return next(iter(obj.values()))
+289
View File
@@ -0,0 +1,289 @@
"""把一个场景组装成「可直接搬走的壁纸目录」。
抓取期产出的**终止形状**就是 `wallpapers/<游戏>/<壁纸id>/` 的同形拷贝
(布局 A,见 `docs/adr/0008-asset-layout-per-skeleton.md`):
<页面>/
├── page.json ← 页面级溯源
└── <场景id>/ ← 一个场景 = 一个壁纸目录
├── meta.json
├── preset.template.json
├── scene.json
├── spines/<骨架名>/ ← <名>.json + <名>.atlas + 贴图页**同居**
├── scene/<场景图>
└── audios/
目录名固定 `spines` / `scene` / `audios`——`tools/lib/generate.ts` 的 `copyWallpaperAssets`
按这三个名字搬文件,改名要两边一起改。
这个模块只做两件事:**写**(`build_preset` / `build_meta`)与**查**(`missing_preset_paths`)。
写与查放在一起是刻意的:产物形状一旦改,校验规则必须同时改,分成两个文件迟早漂移。
"""
from __future__ import annotations
import json
import re
from pathlib import Path
from typing import Any
__all__ = [
"AUDIO_DIR",
"IMAGE_EXT",
"SCENE_DIR",
"SPINE_DIR",
"build_meta",
"build_preset",
"image_file",
"missing_preset_paths",
"preset_paths",
"scene_dir_name",
"spine_page_file",
"write_json",
]
SPINE_DIR = "spines"
SCENE_DIR = "scene"
AUDIO_DIR = "audios"
IMAGE_EXT = (".png", ".webp", ".jpg", ".jpeg", ".gif")
# 壁纸 id 的字符集(与 tools/lib/vault.ts 的校验逐字一致):它会进 WE 的 combo value。
_UNSAFE = re.compile(r"[^A-Za-z0-9_-]+")
def scene_dir_name(scene_id: str) -> str:
"""场景 id → 目录名。
页面里的场景 id 多数本来就合法(`scene_main` / `P1` / `loading`),但也有中文的
(back-moon 的 `动画预览`)。目录名一旦成为壁纸 id 就必须是 `^[a-z0-9][a-z0-9_-]*$`,
所以这里统一转义:非法字符折成一个 `_`,首字符不是字母数字时补前缀。
**真实 id 不会被丢掉**——它写在同目录的 `meta.json` / `scene.json` 里。
"""
name = _UNSAFE.sub("_", str(scene_id).strip()).strip("_-")
if not name:
return "scene"
if not re.match(r"[A-Za-z0-9]", name):
return f"scene_{name}"
return name
def image_file(scene_dir: Path, name: str) -> Path | None:
"""场景图落盘名:`scene/<逻辑名>.<ext>`(扩展名由资源路径决定,见 mihoyo.image_ext)。"""
for ext in IMAGE_EXT:
candidate = scene_dir / SCENE_DIR / f"{name}{ext}"
if candidate.is_file():
return candidate
return None
def spine_page_file(scene_dir: Path, spine_id: str) -> Path | None:
"""一具骨架的第一张贴图页。
页名**读 atlas 自己声明的第一行**,不按 `<stem>_N` 猜——去 hash 之后页名未必是
`<骨架名>.png`(多页是 `_2.png`,也有完全不同的名字)。这条路径只用于"整个场景一张
贴图平面都没有"时的背景兜底(`wallpapers/README.md` 允许背景指向骨架贴图页)。
"""
atlas = scene_dir / SPINE_DIR / spine_id / f"{spine_id}.atlas"
if not atlas.is_file():
return None
for line in atlas.read_text(encoding="utf-8").splitlines():
page = line.strip().strip('"')
if page.lower().endswith(IMAGE_EXT):
candidate = atlas.parent / Path(page).name
if candidate.is_file():
return candidate
return None
def _rel(scene_dir: Path, path: Path) -> str:
"""磁盘路径 → 预设里的 `./…` 相对路径(**一律正斜杠**,见"跨平台路径"那条坑)。"""
return "./" + path.relative_to(scene_dir).as_posix()
def _part_common(part: dict[str, Any]) -> dict[str, Any]:
"""part 的公共字段(世界变换 + 绘制层级 + 页面指定的动画/皮肤/时间缩放)。"""
common: dict[str, Any] = {
"kind": part.get("kind"),
"id": part["id"],
"order": part.get("order", 0),
"position": part.get("position", [0, 0, 0]),
"scale": part.get("scale", [1, 1, 1]),
}
if part.get("renderOrder"):
common["renderOrder"] = part["renderOrder"]
if part.get("geometrySize"):
common["width"], common["height"] = part["geometrySize"]
if part.get("geometryCenter"):
common["center"] = part["geometryCenter"]
if part.get("rotation") and any(abs(float(v)) > 1e-9 for v in part["rotation"]):
common["rotation"] = part["rotation"]
# 页面指定的动画 / 皮肤:不抄就会去播骨架的第一个动画(常是入场动画 in,姿态不同)。
if part.get("animation"):
common["animation"] = part["animation"]
if part.get("skin"):
common["skin"] = part["skin"]
if part.get("timeScale") is not None:
common["timeScale"] = part["timeScale"]
return common
def build_preset(
scene: dict[str, Any],
scene_dir: Path,
*,
cover: str | None = None,
) -> tuple[dict[str, Any], list[str]]:
"""由 `scene.json` 的内容 + 磁盘上的场景目录,生成 `preset.template.json`。
返回(预设, 说明列表)。说明是"这个场景里没能进预设的东西"——纯色平面、缺文件的 part,
它们不是错误(`verify` 会独立判红),但用户该知道少了几件。
`cover` 给的是**逻辑名**(`scene/<名>.<ext>`);不给就自动挑一张背景图,
因为构建期 `backgroundImage` 是必填且必须真实存在(见 `tools/lib/vault.ts`)。
"""
notes: list[str] = []
parts: list[dict[str, Any]] = []
solids = 0
for part in scene.get("parts") or []:
kind = part.get("kind")
if kind == "solid":
# 纯色平面运行时这一轮画不了(没有贴图),写进预设只是死配置。
solids += 1
continue
if kind not in ("spine", "image"):
notes.append(f"未知的 part 类型 {kind!r}(id={part.get('id')}),已跳过")
continue
common = _part_common(part)
if kind == "spine":
spine_id = str(part["id"])
spine_dir = scene_dir / SPINE_DIR / spine_id
if not (spine_dir / f"{spine_id}.json").is_file():
notes.append(f"骨架 {spine_id} 没有落到 spines/{spine_id}/,未写进预设")
continue
common["jsonUrl"] = f"./{SPINE_DIR}/{spine_id}/{spine_id}.json"
common["atlasUrl"] = f"./{SPINE_DIR}/{spine_id}/{spine_id}.atlas"
else:
if part.get("runtime"):
# 运行时缓冲(cacheContainer / diffuse 指向同场景骨架缓存):由骨架渲染进贴图
# 缓冲,没有独立文件,不该进预设也不该报缺资源(`scene.json` 里标了 runtime)。
continue
source = image_file(scene_dir, str(part["id"]))
if source is None:
notes.append(f"贴图平面 {part['id']} 没有独立文件,未写进预设")
continue
common["image"] = _rel(scene_dir, source)
parts.append(common)
if solids:
notes.append(f"{solids} 块纯色平面未写进预设(运行时没有贴图可画)")
background: str | None = None
if cover:
source = image_file(scene_dir, cover)
if source is None:
notes.append(f"指定的背景图 {cover} 不在 scene/ 里,改为自动挑选")
else:
background = _rel(scene_dir, source)
if background is None:
background = _pick_background(scene_dir, parts)
scene_config: dict[str, Any] = {"ui": scene.get("ui"), "parts": parts}
camera_node = scene.get("camera") or {}
camera = camera_node.get("camera") or {}
if camera.get("type") is not None:
# 相机必须带进预设:透视场景(type 1)忽略 fov 与 z 就会画成一块糊满屏的贴图。
scene_config["camera"] = {
"type": camera.get("type"),
"fov": camera.get("fov"),
"position": camera_node.get("position"),
}
return {"backgroundImage": background or "", "sceneConfig": scene_config}, notes
def _pick_background(scene_dir: Path, parts: list[dict[str, Any]]) -> str | None:
"""自动挑背景图:贴图平面里**面积最大**的那块(画布比例的来源),没有就退到骨架贴图页。
为什么按面积:`backgroundImage` 在运行时决定 `document.body` 的底图与取景用的宽高比
(`src/runtime/index.ts` 的 `measureImageAspect`)。挑到一块小按钮会让整幅画的比例全错,
而背景/天空/远景恰好总是场景里最大的那块平面(实测:nico-tea 的 `main_sky_jpg` 2500×1064)。
"""
images = [p for p in parts if p.get("image")]
if images:
best = max(
images,
key=lambda p: (float(p.get("width") or 0) * float(p.get("height") or 0), -int(p.get("order") or 0)),
)
return str(best["image"])
for part in parts:
if not part.get("jsonUrl"):
continue
source = spine_page_file(scene_dir, str(part["id"]))
if source is not None:
return _rel(scene_dir, source)
return None
def preset_paths(preset: dict[str, Any]) -> list[str]:
"""预设里所有必须真实存在的 `./…` 路径。"""
out: list[str] = []
background = preset.get("backgroundImage")
if isinstance(background, str) and background:
out.append(background)
scene_config = preset.get("sceneConfig") or {}
for part in scene_config.get("parts") or []:
for key in ("image", "jsonUrl", "atlasUrl"):
value = part.get(key)
if isinstance(value, str) and value:
out.append(value)
return out
def missing_preset_paths(preset: dict[str, Any], scene_dir: Path) -> list[str]:
"""预设里指向磁盘上不存在的文件的路径(写出后立刻自检,不留到构建期才炸)。"""
missing: list[str] = []
for rel in preset_paths(preset):
# 映射键一律用**正斜杠**:Windows 的 str(Path) 是反斜杠,写进预设就搬到别的机器上读不到。
if not (scene_dir / rel.replace("\\", "/").removeprefix("./")).is_file():
missing.append(rel)
return missing
def build_meta(
*,
wallpaper_id: str,
name: str,
title: str,
description: str,
game: str,
page: str,
scene: str,
source: str,
fetched_at: str | None = None,
) -> dict[str, Any]:
"""场景目录的 `meta.json`:既有字段语义(id/name/title/description/audio)原样保留,
另加溯源字段(game/page/scene/source),方便搬进 `wallpapers/` 后回查来源。"""
meta: dict[str, Any] = {
"id": wallpaper_id,
"name": name,
"title": title,
"description": description,
"game": game,
"page": page,
"scene": scene,
"source": source,
# 音源清单为空:页面的 BGM 还没抓(构建会据此省掉 bgm 属性)。
"audio": {"choices": []},
}
if fetched_at:
meta["fetchedAt"] = fetched_at
return meta
def write_json(path: Path, data: Any, *, indent: int = 2) -> int:
"""写一份 JSON(UTF-8、不转义中文),返回字节数。"""
path.parent.mkdir(parents=True, exist_ok=True)
text = json.dumps(data, ensure_ascii=False, indent=indent) + "\n"
path.write_text(text, encoding="utf-8")
return len(text.encode("utf-8"))
+230
View File
@@ -0,0 +1,230 @@
"""把一个 staging 的**场景目录** promote 成 `wallpapers/<游戏>/<壁纸id>/`。
`fetch` 现在产出的就是终态形状(一个场景 = 一个可直接搬走的壁纸目录),所以这一步退化成
**拷贝 + 校验 + 写 meta**:把 `spines/` / `scene/` / `audios/` 三个目录与 `preset.template.json`
搬过去,把 `meta.json` 的 id / 文案换成发布值,再把预设里每条 `./…` 路径在目标目录上核一遍。
刻意不做的事:不抓音源(页面的 BGM 是另一条链)、不生成预览图、不动已存在的目录(除非 --force)。
"""
from __future__ import annotations
import json
import shutil
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from . import layout as layout_mod
__all__ = ["promote_page", "resolve_scene_dir", "PromoteResult"]
@dataclass
class PromoteResult:
target: Path
scene_dir: Path
parts: int
spines: int
images: int
solids_skipped: int
bytes: int
def _read_json(path: Path) -> dict[str, Any]:
return json.loads(path.read_text(encoding="utf-8"))
def _is_scene_dir(path: Path) -> bool:
"""场景目录的判据:`preset.template.json` + `scene.json` 都在(fetch 的产物形状)。"""
return (path / "preset.template.json").is_file() and (path / "scene.json").is_file()
def scene_dirs(staged: Path) -> list[Path]:
"""页面目录下的全部场景目录(按名字排序,保证可复现)。"""
if not staged.is_dir():
return []
return sorted(p for p in staged.iterdir() if p.is_dir() and _is_scene_dir(p))
def resolve_scene_dir(staged: Path, scene: str | None = None) -> Path:
"""把「场景目录」或「页面目录 + 场景 id」统一解析成**一个**场景目录。
认三种输入(与 CLI 的 `--page` 对应):
1. 场景目录本身(`_out/<游戏>/<页面>/<场景id>`)
2. `.scratch` 式的「页面/场景」路径——由调用方拼好后传进来
3. 页面目录(`_out/<游戏>/<页面>`):用 `--scene` 指定;没指定就取 `page.json` 的
`chosenScene`;再没有就要求页面下只有一个场景(多个时报错,不猜)。
"""
if _is_scene_dir(staged):
return staged
if not staged.is_dir():
raise FileNotFoundError(f"staging 里没有这个东西:{staged}")
candidates = scene_dirs(staged)
if not candidates:
raise FileNotFoundError(f"{staged} 下没有场景目录(先跑:python -m tools.downloader fetch)")
def matches(name: str) -> Path | None:
for candidate in candidates:
if candidate.name == name:
return candidate
try:
meta = _read_json(candidate / "meta.json")
except (OSError, json.JSONDecodeError):
continue
if meta.get("scene") == name or meta.get("id") == name:
return candidate
return None
if scene:
hit = matches(scene)
if hit is None:
names = "、".join(p.name for p in candidates)
raise FileNotFoundError(f"{staged} 下没有场景 {scene}(有:{names})")
return hit
chosen = None
page_file = staged / "page.json"
if page_file.is_file():
try:
chosen = _read_json(page_file).get("chosenDir") or _read_json(page_file).get("chosenScene")
except (OSError, json.JSONDecodeError):
chosen = None
if chosen:
hit = matches(str(chosen))
if hit is not None:
return hit
if len(candidates) == 1:
return candidates[0]
names = "、".join(p.name for p in candidates)
raise FileNotFoundError(
f"{staged} 下有 {len(candidates)} 个场景目录,要用 --scene 指定一个(有:{names})"
)
def promote_page(
staged: Path,
wallpapers_root: Path,
*,
game: str,
wallpaper_id: str,
scene: str | None = None,
name: str | None = None,
title: str | None = None,
description: str | None = None,
cover: str | None = None,
force: bool = False,
) -> PromoteResult:
"""执行一次 promote;返回统计。"""
scene_dir = resolve_scene_dir(staged, scene)
if not (scene_dir / "scene.json").is_file():
raise FileNotFoundError(f"场景目录里没有 scene.json:{scene_dir}")
scene_payload = _read_json(scene_dir / "scene.json")
src_meta: dict[str, Any] = {}
meta_file = scene_dir / "meta.json"
if meta_file.is_file():
try:
src_meta = _read_json(meta_file)
except json.JSONDecodeError as exc:
raise FileNotFoundError(f"{meta_file} 不是合法 JSON:{exc}") from exc
target = wallpapers_root / game / wallpaper_id
if target.exists() and not force:
raise FileExistsError(f"目标已存在(要覆盖就加 --force):{target}")
if target.exists():
shutil.rmtree(target)
# 布局 A:一具骨架一组(json + atlas + 贴图页同居),场景图单独一层。
# 页必须与 atlas 同目录——spine-ts 按 `<atlas 目录>/<页名>` 解析(见 README)。
written = 0
for segment in (layout_mod.SPINE_DIR, layout_mod.SCENE_DIR, layout_mod.AUDIO_DIR):
source = scene_dir / segment
if source.is_dir():
written += _copy_tree(source, target / segment)
# 预设优先沿用场景目录里已生成的那份(fetch 已产出终态);缺失就按 scene.json 现算。
preset_file = scene_dir / "preset.template.json"
if preset_file.is_file():
preset = _read_json(preset_file)
else:
preset, _ = layout_mod.build_preset(scene_payload, scene_dir)
if cover:
source = layout_mod.image_file(scene_dir, cover)
if source is None:
raise FileNotFoundError(f"封面图不在场景目录里:scene/{cover}.*")
dest = target / layout_mod.SCENE_DIR / source.name
if not dest.exists():
written += _copy(source, dest)
preset["backgroundImage"] = f"./{layout_mod.SCENE_DIR}/{source.name}"
if not ((preset.get("sceneConfig") or {}).get("parts") or []):
if target.exists():
shutil.rmtree(target, ignore_errors=True)
raise FileNotFoundError(
f"{scene_dir} 里一件可搬走的 part 都没有(平面全是运行时渲染目标),换一个场景目录"
)
missing = layout_mod.missing_preset_paths(preset, target)
if missing:
# 别把半个目标目录留在 wallpapers/ 里——它看起来像一档发布壁纸,实际缺件。
shutil.rmtree(target, ignore_errors=True)
raise FileNotFoundError(
"promote 后预设里有路径找不到对应文件(已拷贝的资产不自洽):\n - " + "\n - ".join(missing)
)
written += layout_mod.write_json(target / "preset.template.json", preset)
final_meta = dict(src_meta)
# 这两个是 staging 专用字段("这个目录能不能直接搬走"),发布形状里没有它们的位置。
final_meta.pop("usable", None)
final_meta.pop("reason", None)
final_meta["id"] = wallpaper_id
final_meta.setdefault("name", wallpaper_id)
final_meta.setdefault("title", str(final_meta["name"]))
final_meta.setdefault("description", str(final_meta["name"]))
if name:
final_meta["name"] = name
if title:
final_meta["title"] = title
if description:
final_meta["description"] = description
# 音源清单为空:这一页的 BGM 还没抓(构建会据此省掉 bgm 属性)。
final_meta.setdefault("audio", {"choices": []})
final_meta.setdefault("game", game)
final_meta.setdefault("page", scene_dir.parent.name)
written += layout_mod.write_json(target / "meta.json", final_meta)
game_dir = wallpapers_root / game
if not (game_dir / "meta.json").exists():
(game_dir / "meta.json").write_text(
json.dumps({"id": game, "name": game, "audios": []}, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
parts = (preset.get("sceneConfig") or {}).get("parts") or []
solids = sum(1 for p in scene_payload.get("parts") or [] if p.get("kind") == "solid")
return PromoteResult(
target=target,
scene_dir=scene_dir,
parts=len(parts),
spines=sum(1 for p in parts if p.get("kind") == "spine"),
images=sum(1 for p in parts if p.get("kind") == "image"),
solids_skipped=solids,
bytes=written,
)
def _copy(src: Path, dest: Path) -> int:
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dest)
return dest.stat().st_size
def _copy_tree(src: Path, dest: Path) -> int:
total = 0
for item in sorted(src.rglob("*")):
if item.is_file():
total += _copy(item, dest / item.relative_to(src))
return total
+1
View File
@@ -0,0 +1 @@
PyYAML>=6
Loaded 100 of 297 files, more files were not shown because too many files have changed in this diff. Show more