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:
1 parent
b8eee05d78
commit
3f11426964
297 files changed
+216627
-1926
No files matched your search
+29
-3
@@ -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`。
|
||||
@@ -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 ''}")
|
||||
@@ -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:自包含包本身(两个根、表的两族键、门禁的剥除细节)
|
||||
@@ -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 层的还原(站点外壳,按设计不做)。
|
||||
@@ -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` 残留、
|
||||
|
||||
@@ -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
@@ -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_: 相机、取景框、裁剪区
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
# SpineWallpaper
|
||||
|
||||
**把米哈游活动页里的 Spine 立绘与整页场景,做成一份可以直接上传 Wallpaper Engine 的网页壁纸。**
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||

|
||||
|
||||
## 📚 目录
|
||||
|
||||
- [✨ 项目简介](#-项目简介)
|
||||
- [🧩 特性](#-特性)
|
||||
- [🚀 快速开始](#-快速开始)
|
||||
- [📦 三种产物](#-三种产物)
|
||||
- [🧪 本地调试](#-本地调试)
|
||||
- [🛠 命令与配置](#-命令与配置)
|
||||
- [🗂 目录结构](#-目录结构)
|
||||
- [🏗 架构与数据流](#-架构与数据流)
|
||||
- [🔬 取景算法](#-取景算法)
|
||||
- [✅ 质量门禁](#-质量门禁)
|
||||
- [📥 资源抓取](#-资源抓取)
|
||||
- [🚧 已知约束](#-已知约束)
|
||||
- [🧠 决策记录](#-决策记录)
|
||||
- [🤝 参与与反馈](#-参与与反馈)
|
||||
- [📄 许可证](#-许可证)
|
||||
- [🙏 致谢](#-致谢)
|
||||
|
||||
## ✨ 项目简介
|
||||
|
||||
这是一个 **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`)。
|
||||
@@ -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 那个前缀归零。)
|
||||
@@ -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 条。
|
||||
@@ -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 条。
|
||||
@@ -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 会给反斜杠,而预设里是正斜杠)。
|
||||
@@ -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 |
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+39
@@ -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]: {}
|
||||
Vendored
+334
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}")`;
|
||||
}
|
||||
}
|
||||
@@ -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) => {},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Vendored
+15
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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%;
|
||||
}
|
||||
@@ -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%;
|
||||
}
|
||||
@@ -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>
|
||||
Vendored
+15586
File diff suppressed because it is too large.
Load diff
-109
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
@@ -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("自包含性校验通过:每个分发目录都可以整体拷到别的机器上传。");
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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");
|
||||
@@ -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` 真的变了。两者缺一不可。
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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}`);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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.
@@ -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"
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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]}`);
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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 };",
|
||||
)();
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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
@@ -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>&__props={"preset":"kv37"}</code> 覆盖属性 ·
|
||||
<code>&__propsAt=dom</code> 复现 load 前下发 · <code>&fps=30</code> 限流 · <code>&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;
|
||||
}
|
||||
@@ -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`)。
|
||||
@@ -0,0 +1,11 @@
|
||||
"""米哈游活动页的 Spine 抓取器(Python,只依赖 stdlib + PyYAML)。
|
||||
|
||||
它不是构建输入:`wallpapers/` 只放最终要发布的资源,抓取脚本与临时下载都留在这里。
|
||||
产物先落 `_out/<游戏>/<页面>/`,经 `--verify` 自检后再由人 promote 进 `wallpapers/`。
|
||||
|
||||
子命令与用法见同目录 README.md。
|
||||
"""
|
||||
|
||||
__all__ = ["__version__"]
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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()))
|
||||
@@ -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"))
|
||||
@@ -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
|
||||
@@ -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
Reference in new issue
Block a user