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
@@ -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:自包含包本身(两个根、表的两族键、门禁的剥除细节)
|
||||
@@ -1,26 +0,0 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
Set-Location D:\Workspace\Temp\SpineWallpaper
|
||||
|
||||
$sizes = @(
|
||||
@(3440, 1440, "219"),
|
||||
@(1920, 1080, "169"),
|
||||
@(1080, 1920, "916")
|
||||
)
|
||||
$props = @{
|
||||
"xilian" = ""
|
||||
"kv37" = "&__props=%7B%22preset%22%3A%22kv37%22%7D"
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Force -Path tools\shots\fit | Out-Null
|
||||
|
||||
foreach ($p in @("xilian", "kv37")) {
|
||||
foreach ($s in $sizes) {
|
||||
$w = $s[0]; $h = $s[1]; $tag = $s[2]
|
||||
$url = "http://127.0.0.1:8190/?__freeze=1" + $props[$p]
|
||||
Write-Host "=== $p $tag ($w x $h) ==="
|
||||
node tools/shot.mjs "tools/shots/fit/${p}_${tag}.png" $url $w $h 2>&1 | Select-Object -First 1
|
||||
node tools/shot.mjs "tools/shots/fit/${p}_${tag}_ref.png" "$url&__hide=canvas" $w $h 2>&1 | Select-Object -First 1
|
||||
node tools/subject.mjs "tools/shots/fit/${p}_${tag}.png" "tools/shots/fit/${p}_${tag}_ref.png"
|
||||
Write-Host ""
|
||||
}
|
||||
}
|
||||
@@ -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` / 音源清单)?
|
||||
@@ -1,173 +0,0 @@
|
||||
// Issue 06 全量验收。
|
||||
// ① dist 体积与死文件;② project.json 与代码一致;③ 三种比例 0 控制台报错;
|
||||
// ④ 预设往返不丢用户级音源/音量设置。
|
||||
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";
|
||||
|
||||
let failures = 0;
|
||||
const check = (ok, msg, detail) => {
|
||||
if (!ok) failures++;
|
||||
console.log(` ${ok ? "✓" : "✗"} ${msg}${detail !== undefined ? ` → ${detail}` : ""}`);
|
||||
};
|
||||
|
||||
// ── ① 体积与死文件 ──
|
||||
console.log("=== ① dist 体积与死文件 ===");
|
||||
const walk = (dir) =>
|
||||
readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
|
||||
const p = join(dir, e.name);
|
||||
return e.isDirectory() ? walk(p) : [p];
|
||||
});
|
||||
const files = walk("dist");
|
||||
const total = files.reduce((s, f) => s + statSync(f).size, 0);
|
||||
// 用户已明确豁免体积(音频走无损、壁纸在本地),基准改为"不超过已通过审核的 v2"。
|
||||
// 这是真正有意义的界:v2 以 104.98 MB 发布过,所以这个体积不会带来新的上传风险。
|
||||
const V2_MB = 104.98;
|
||||
check(total <= V2_MB * 1024 * 1024, `总体积不超过已发布的 v2(${V2_MB} MB)`, `${(total / 1048576).toFixed(2)} MB / ${files.length} 个文件`);
|
||||
|
||||
const TEXT = new Set([".html", ".js", ".json", ".txt", ".css", ".atlas"]);
|
||||
const texts = files.filter((f) => TEXT.has(extname(f)));
|
||||
const contents = new Map(texts.map((f) => [f, readFileSync(f, "utf8")]));
|
||||
// 这四个是 WE 的元数据/入口/说明,本来就不被页面引用
|
||||
const ALLOWED = new Set(["project.json", "README.txt", "index.html", "preview.gif"]);
|
||||
// 关键:比对时要把文件**自身**的内容排除掉。否则一个文件只要在自己开头写上自己的名字
|
||||
// (比如注释里的 "// wallpaper-engine.js")就会被判为"被引用",死文件因此漏网。
|
||||
const dead = files.filter((f) => {
|
||||
const n = basename(f);
|
||||
if (ALLOWED.has(n)) return false;
|
||||
const others = texts
|
||||
.filter((t) => t !== f)
|
||||
.map((t) => contents.get(t))
|
||||
.join("\n");
|
||||
return !others.includes(n);
|
||||
});
|
||||
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"));
|
||||
check(project.version === 3, "version = 3", String(project.version));
|
||||
check(project.preview === "preview.gif", "preview 指向 preview.gif");
|
||||
check(!!project.general.properties.bgm, "新增 bgm combo");
|
||||
check(project.general.properties.preset.value === "xilian", "preset 默认值未变");
|
||||
const presetValues = project.general.properties.preset.options.map((o) => o.value).sort();
|
||||
check(
|
||||
JSON.stringify(presetValues) === JSON.stringify(["kv37", "xilian"]),
|
||||
"preset 的 combo 取值未变(用户已保存的设置依赖它)",
|
||||
presetValues.join(", "),
|
||||
);
|
||||
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;
|
||||
check(gifSize < 500 * 1024, "preview.gif < 500 KB", `${(gifSize / 1024).toFixed(0)} KB`);
|
||||
|
||||
// ── ③ 三种比例 0 报错 ──
|
||||
console.log("\n=== ③ 21:9 / 16:9 / 9:16 控制台与异常 ===");
|
||||
for (const [w, h, tag] of [
|
||||
[3440, 1440, "21:9"],
|
||||
[1920, 1080, "16:9"],
|
||||
[1080, 1920, "9:16"],
|
||||
]) {
|
||||
const r = await withPage({ width: w, height: h }, async ({ send, evaluate, consoleLines, exceptions }) => {
|
||||
await send("Page.navigate", { url: "http://127.0.0.1:8190/?__freeze=1" });
|
||||
const deadline = Date.now() + 30000;
|
||||
let ready = false;
|
||||
while (Date.now() < deadline) {
|
||||
if (await evaluate('!!(window.__test && (window.__test.successAt || window.__test.error))')) {
|
||||
ready = true;
|
||||
break;
|
||||
}
|
||||
await sleep(150);
|
||||
}
|
||||
await sleep(500);
|
||||
const state = await evaluate(`(function(){
|
||||
var p = window.__player, v = p.currentViewport;
|
||||
return { w: p.canvas.clientWidth, h: p.canvas.clientHeight,
|
||||
vw: +v.width.toFixed(1), vh: +v.height.toFixed(1),
|
||||
err: window.__test.error || null };
|
||||
})()`);
|
||||
return { ready, state, consoleLines, exceptions };
|
||||
});
|
||||
const noisy = r.consoleLines.filter((l) => /error|uncaught|fail/i.test(l));
|
||||
const aspectOk = r.state && Math.abs(r.state.vw / r.state.vh - r.state.w / r.state.h) < 0.002;
|
||||
check(r.ready && !r.state?.err, `${tag} 播放器就绪`, r.state?.err ?? "无错");
|
||||
check(aspectOk, `${tag} 可见矩形比例与画布一致`, r.state ? `${r.state.vw}×${r.state.vh}` : "—");
|
||||
check(noisy.length === 0 && r.exceptions.length === 0, `${tag} 0 控制台报错 / 0 异常`, noisy.concat(r.exceptions).slice(0, 3).join(" | ") || "干净");
|
||||
}
|
||||
|
||||
// ── ④ 预设往返不丢音频设置 ──
|
||||
console.log("\n=== ④ 预设往返 10 次,音频设置不得丢失 ===");
|
||||
const rt = await withPage({ width: 1280, height: 720 }, async ({ send, evaluate, exceptions }) => {
|
||||
await send("Page.navigate", { url: "http://127.0.0.1:8190/?__freeze=1" });
|
||||
const deadline = Date.now() + 30000;
|
||||
while (Date.now() < deadline) {
|
||||
if (await evaluate('!!(window.__test && (window.__test.successAt || window.__test.error))')) break;
|
||||
await sleep(150);
|
||||
}
|
||||
await sleep(500);
|
||||
|
||||
await evaluate(`window.wallpaperPropertyListener.applyUserProperties({
|
||||
audio_file: { value: "https://example.com/custom-track.mp3" },
|
||||
audio_volume: { value: 0.2 }
|
||||
})`);
|
||||
await sleep(300);
|
||||
|
||||
const rows = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const id = i % 2 === 0 ? "kv37" : "xilian";
|
||||
await evaluate(`window.wallpaperPropertyListener.applyUserProperties({ preset: { value: "${id}" } })`);
|
||||
await sleep(320);
|
||||
rows.push(
|
||||
await evaluate(`(function(){
|
||||
var a = document.getElementById("background-music");
|
||||
return { id: "${id}", src: a.src.split("/").pop(), vol: a.volume,
|
||||
draws: window.__test.drawCalls };
|
||||
})()`),
|
||||
);
|
||||
}
|
||||
return { rows, exceptions };
|
||||
});
|
||||
const srcOk = rt.rows.every((r) => r.src === "custom-track.mp3");
|
||||
const volOk = rt.rows.every((r) => Math.abs(r.vol - 0.2) < 0.001);
|
||||
check(srcOk, "10 次切换后自定义音源仍生效", [...new Set(rt.rows.map((r) => r.src))].join(", "));
|
||||
check(volOk, "10 次切换后音量仍为 0.2", [...new Set(rt.rows.map((r) => r.vol))].join(", "));
|
||||
check(rt.rows[rt.rows.length - 1].draws > rt.rows[0].draws, "切换后仍在持续绘制", `${rt.rows[0].draws} → ${rt.rows[rt.rows.length - 1].draws}`);
|
||||
check(rt.exceptions.length === 0, "往返过程 0 未捕获异常", rt.exceptions.join(" | ") || "无");
|
||||
|
||||
// ── ⑤ 音频格式:xilian 两个音源必须是无损 ──
|
||||
console.log("\n=== ⑤ 音频格式与可达性 ===");
|
||||
const audioDirs = [
|
||||
["dist/assets/崩坏:星穹铁道/昔涟立绘/audios", "flac", "昔涟立绘(无损)"],
|
||||
["dist/assets/崩坏:星穹铁道/「成为昨日的明天」/audios", "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 codecs = fs2.map((f) => `${f}=${codecOf(join(dir, f))}`);
|
||||
check(
|
||||
fs2.length > 0 && fs2.every((f) => codecOf(join(dir, f)) === want),
|
||||
`${label} 音源全为 ${want}`,
|
||||
codecs.join(", "),
|
||||
);
|
||||
}
|
||||
// 有损的 .ogg 必须已经清干净,否则就是残留
|
||||
const stale = files.filter((f) => /\.(ogg|opus)$/.test(f));
|
||||
check(stale.length === 0, "已无残留的有损音源(.ogg/.opus)", stale.length ? stale.join(", ") : "干净");
|
||||
// preset.js 引用的每个音源都必须真实存在——引用错一个字符就是 404 + 立即静音
|
||||
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]);
|
||||
if (!existsSync(target)) broken.push(`${pf} → ${m[1]}`);
|
||||
}
|
||||
}
|
||||
check(broken.length === 0, "preset.js 引用的音源文件全部存在", broken.length ? broken.join(" | ") : `${presetFiles.length} 份预设,路径全部可达`);
|
||||
|
||||
console.log(`\n结论:${failures === 0 ? "全部通过 ✓" : `${failures} 项失败 ✗`}`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
@@ -1,165 +0,0 @@
|
||||
// Issue 05 验收:bgm 音源 combo 与 fps 限流。
|
||||
//
|
||||
// A 段验音源优先级:用户自填 URL > bgm 选择(仅当本壁纸声明过)> 本壁纸默认音源;
|
||||
// 且 bgm 选择跨壁纸时"音频回落、选择保留"。
|
||||
// B 段验 fps:WE 不替壁纸限流,得自己门控。关键是限流只该丢帧,
|
||||
// 不该改变动画相位推进速度——用同一段真实时间里 trackTime 前进了多少来判定。
|
||||
import { withPage, sleep } from "../tools/cdp.mjs";
|
||||
|
||||
const 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}` : ""}`);
|
||||
};
|
||||
|
||||
// ───────────────────────── A 段:音源 ─────────────────────────
|
||||
console.log("=== A 段:bgm 音源选择与回落 ===");
|
||||
await withPage({ width: 1280, height: 720 }, async ({ send, evaluate, exceptions }) => {
|
||||
await send("Page.navigate", { url: BASE + "?__freeze=1" });
|
||||
const deadline = Date.now() + 30000;
|
||||
while (Date.now() < deadline) {
|
||||
if (await evaluate('!!(window.__test && (window.__test.successAt || window.__test.error))')) break;
|
||||
await sleep(150);
|
||||
}
|
||||
await sleep(600);
|
||||
|
||||
const src = () => evaluate('document.getElementById("background-music").src');
|
||||
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)} } })`);
|
||||
|
||||
const seen = [];
|
||||
const step = async (label, action, expected) => {
|
||||
await action();
|
||||
await sleep(350);
|
||||
const got = name(await src());
|
||||
seen.push(`${label}=${got}`);
|
||||
check(got === expected, label, `期望 ${expected},实得 ${got}`);
|
||||
};
|
||||
|
||||
// 载入即 xilian,默认音源应是「再度和你」
|
||||
check(name(await src()) === "「再度和你」.flac", "初始默认音源 = 昔涟立绘默认", name(await src()));
|
||||
|
||||
// 无损是否真的可用,取决于 CEF 能不能解 FLAC——不能只看路径对不对。
|
||||
// readyState>=1(HAVE_METADATA) 说明容器与编码被接受,duration>0 说明解析出了正确时长。
|
||||
const healthProbe = `(function(){
|
||||
var a = document.getElementById("background-music");
|
||||
return { rs: a.readyState, dur: a.duration, net: a.networkState,
|
||||
err: a.error ? a.error.code : null, flac: a.canPlayType("audio/flac") };
|
||||
})()`;
|
||||
let h = null;
|
||||
for (let i = 0; i < 40; i++) {
|
||||
h = await evaluate(healthProbe);
|
||||
if (h.rs >= 1 || h.err !== null) break;
|
||||
await sleep(250);
|
||||
}
|
||||
check(h.flac === "probably" || h.flac === "maybe", "CEF 支持 audio/flac", h.flac || "(不支持)");
|
||||
check(h.err === null, "音频无解码错误", h.err === null ? "无" : `MediaError.code=${h.err}`);
|
||||
check(h.rs >= 1, "FLAC 已解析出元数据(readyState≥1)", `readyState=${h.rs}`);
|
||||
check(h.dur > 100 && h.dur < 300, "解析出的时长符合源文件", `${h.dur?.toFixed(1)} 秒`);
|
||||
|
||||
// 元数据能解析 ≠ 真的在放。等一会儿看播放头有没有前进——这才是"无损可用"的终局证据。
|
||||
await sleep(1500);
|
||||
const pb = await evaluate(`(function(){
|
||||
var a = document.getElementById("background-music");
|
||||
return { paused: a.paused, t: a.currentTime, loop: a.loop, vol: a.volume };
|
||||
})()`);
|
||||
check(!pb.paused, "音频处于播放状态", pb.paused ? "仍暂停" : "播放中");
|
||||
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");
|
||||
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");
|
||||
|
||||
// 用户自填 URL 优先级最高
|
||||
await step("bgm 选「昔涟」后填自定义 URL", async () => {
|
||||
await bgm("xilian");
|
||||
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");
|
||||
|
||||
console.log(` 音源轨迹: ${seen.join(" | ")}`);
|
||||
console.log(` 未捕获异常: ${exceptions.length ? exceptions.join(" | ") : "无 ✓"}`);
|
||||
if (exceptions.length) failures++;
|
||||
});
|
||||
|
||||
// ───────────────────────── B 段:fps ─────────────────────────
|
||||
console.log("\n=== B 段:fps 限流 ===");
|
||||
await withPage({ width: 1280, height: 720 }, async ({ send, evaluate, exceptions }) => {
|
||||
// 空 __props 只用来触发测试驱动注入(不带它就不会注入),既不冻结也不改属性
|
||||
await send("Page.navigate", { url: BASE + "?__props=%7B%7D" });
|
||||
const deadline = Date.now() + 30000;
|
||||
while (Date.now() < deadline) {
|
||||
if (await evaluate('!!(window.__test && (window.__test.successAt || window.__test.error))')) break;
|
||||
await sleep(150);
|
||||
}
|
||||
await sleep(1200); // 让动画先跑起来,避开启动瞬态
|
||||
|
||||
const probe = `(function(){
|
||||
var p = window.__player;
|
||||
var e = p.animationState.getCurrent(0);
|
||||
return { trackTime: e.trackTime, duration: e.animation.duration,
|
||||
draws: window.__test.drawCalls, t: performance.now() };
|
||||
})()`;
|
||||
|
||||
const sample = async (seconds) => {
|
||||
const a = await evaluate(probe);
|
||||
await sleep(seconds * 1000);
|
||||
const b = await evaluate(probe);
|
||||
const wall = (b.t - a.t) / 1000;
|
||||
let phase = b.trackTime - a.trackTime;
|
||||
if (phase < 0) phase += b.duration; // 动画正好绕回
|
||||
return { wall, draws: b.draws - a.draws, fps: (b.draws - a.draws) / wall, phaseRate: phase / wall };
|
||||
};
|
||||
|
||||
const setFps = async (v) => {
|
||||
await evaluate(`window.wallpaperPropertyListener.applyGeneralProperties({ fps: ${v} })`);
|
||||
await sleep(400);
|
||||
};
|
||||
|
||||
const results = [];
|
||||
const run = async (label, target) => {
|
||||
const r = await sample(2.0);
|
||||
const row = { label, target, ...r };
|
||||
results.push(row);
|
||||
console.log(
|
||||
` ${label.padEnd(16)} 实测 ${r.fps.toFixed(1)} fps` +
|
||||
(target ? `(目标 ${target})` : "(不限流)") +
|
||||
` 动画相位推进 ${r.phaseRate.toFixed(3)} 倍速`,
|
||||
);
|
||||
return row;
|
||||
};
|
||||
|
||||
const base = await run("不限流", 0);
|
||||
await setFps(30);
|
||||
const f30 = await run("限 30 fps", 30);
|
||||
await setFps(20);
|
||||
const f20 = await run("限 20 fps", 20);
|
||||
await setFps(0);
|
||||
const back = await run("恢复不限流", 0);
|
||||
|
||||
check(Math.abs(f30.fps - 30) / 30 < 0.25, "限 30 fps 生效", `实测 ${f30.fps.toFixed(1)}`);
|
||||
check(Math.abs(f20.fps - 20) / 20 < 0.25, "限 20 fps 生效", `实测 ${f20.fps.toFixed(1)}`);
|
||||
check(f30.fps < base.fps * 0.75, "限流确实减少了绘制次数", `${base.fps.toFixed(1)} → ${f30.fps.toFixed(1)}`);
|
||||
check(back.fps > f30.fps * 1.5, "取消限流后帧率恢复", `${f30.fps.toFixed(1)} → ${back.fps.toFixed(1)}`);
|
||||
for (const r of [f30, f20, back]) {
|
||||
check(
|
||||
Math.abs(r.phaseRate - base.phaseRate) / base.phaseRate < 0.1,
|
||||
`动画相位推进速度未受 ${r.label} 影响`,
|
||||
`${r.phaseRate.toFixed(3)} vs 基准 ${base.phaseRate.toFixed(3)}`,
|
||||
);
|
||||
}
|
||||
console.log(` 未捕获异常: ${exceptions.length ? exceptions.join(" | ") : "无 ✓"}`);
|
||||
if (exceptions.length) failures++;
|
||||
});
|
||||
|
||||
console.log(`\n结论:${failures === 0 ? "全部通过 ✓" : `${failures} 项失败 ✗`}`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
@@ -1,126 +0,0 @@
|
||||
// 验收测试:改窗口尺寸不得重建播放器,且取景要跟着画布比例走。
|
||||
//
|
||||
// 判据是"播放器实例身份":给加载后的实例打上标记,之后每次改视口都核对
|
||||
// window.__player 是否还是同一个对象、标记是否还在。重建会换掉实例,
|
||||
// 标记随之消失——这比数日志可靠得多。
|
||||
import { withPage, sleep } from "../tools/cdp.mjs";
|
||||
|
||||
const URL = "http://127.0.0.1:8190/?__freeze=1";
|
||||
const sizes = [
|
||||
[1920, 1080],
|
||||
[2560, 1440],
|
||||
[3440, 1440],
|
||||
[1080, 1920],
|
||||
[1600, 900],
|
||||
[2560, 1600],
|
||||
[3840, 2160],
|
||||
[1280, 720],
|
||||
[1920, 1080],
|
||||
];
|
||||
const stress = [];
|
||||
for (let i = 0; i < 20; i++) stress.push(i % 2 ? [1080, 1920] : [1920, 1080]);
|
||||
|
||||
const out = await withPage(
|
||||
{ width: 1920, height: 1080, extraArgs: ["--enable-precise-memory-info", "--js-flags=--expose-gc"] },
|
||||
async ({ send, evaluate, consoleLines, exceptions }) => {
|
||||
await send("Page.navigate", { url: URL });
|
||||
const deadline = Date.now() + 30000;
|
||||
while (Date.now() < deadline) {
|
||||
if (await evaluate('!!(window.__test && (window.__test.successAt || window.__test.error))')) break;
|
||||
await sleep(150);
|
||||
}
|
||||
await sleep(600);
|
||||
|
||||
const stamp = await evaluate(`(function(){
|
||||
window.__first = window.__player;
|
||||
if (window.__player) window.__player.__probe = 'A';
|
||||
return { hasPlayer: !!window.__player, error: window.__test ? window.__test.error : null };
|
||||
})()`);
|
||||
|
||||
const rows = [];
|
||||
const measure = async (w, h, tag) => {
|
||||
await send("Emulation.setDeviceMetricsOverride", {
|
||||
width: w,
|
||||
height: h,
|
||||
deviceScaleFactor: 1,
|
||||
mobile: false,
|
||||
});
|
||||
await sleep(420);
|
||||
// 先强制回收再读堆,否则量到的是 V8 的延迟回收节奏而不是真实占用
|
||||
await evaluate('(typeof gc === "function") ? (gc(), true) : false');
|
||||
const r = await evaluate(`(function(){
|
||||
var p = window.__player;
|
||||
if (!p) return null;
|
||||
var v = p.currentViewport;
|
||||
return {
|
||||
same: p === window.__first,
|
||||
probe: p.__probe,
|
||||
css: [p.canvas.clientWidth, p.canvas.clientHeight],
|
||||
resolved: [ +v.x.toFixed(2), +v.y.toFixed(2), +v.width.toFixed(2), +v.height.toFixed(2) ],
|
||||
pad: [v.padLeft, v.padRight, v.padTop, v.padBottom],
|
||||
childViews: window.__player.config.viewport.animations ? Object.keys(window.__player.config.viewport.animations).length : -1,
|
||||
heap: performance.memory ? performance.memory.usedJSHeapSize : 0,
|
||||
drawCalls: window.__test ? window.__test.drawCalls : 0
|
||||
};
|
||||
})()`);
|
||||
rows.push({ tag, w, h, ...(r ?? { same: false, probe: null, css: null, resolved: null, pad: null, heap: 0, drawCalls: 0 }) });
|
||||
};
|
||||
|
||||
for (const [w, h] of sizes) await measure(w, h, `${w}x${h}`);
|
||||
const heapBeforeStress = rows[rows.length - 1].heap;
|
||||
for (const [w, h] of stress) await measure(w, h, `压力 ${w}x${h}`);
|
||||
const heapAfterStress = rows[rows.length - 1].heap;
|
||||
|
||||
return { stamp, rows, heapBeforeStress, heapAfterStress, consoleLines, exceptions };
|
||||
},
|
||||
);
|
||||
|
||||
const { stamp, rows, heapBeforeStress, heapAfterStress, consoleLines, exceptions } = out;
|
||||
|
||||
let failures = 0;
|
||||
const fail = (msg) => {
|
||||
failures++;
|
||||
console.log(` ✗ ${msg}`);
|
||||
};
|
||||
|
||||
console.log(`播放器就绪: ${stamp.hasPlayer ? "是" : "否"} 驱动报错: ${stamp.error ?? "无"}`);
|
||||
console.log("");
|
||||
console.log("画布尺寸 实例未重建 内边距 可见矩形 比例核对");
|
||||
for (const r of rows) {
|
||||
if (r.tag.startsWith("压力")) continue;
|
||||
const a = r.w / r.h;
|
||||
const ra = r.resolved ? r.resolved[2] / r.resolved[3] : NaN;
|
||||
const ratioOk = Math.abs(ra - a) < 0.002;
|
||||
const padOk = r.pad && r.pad.every((p) => p === 0);
|
||||
const rect = r.resolved ? r.resolved.map((n) => String(n).padStart(9)).join(" ") : "—";
|
||||
console.log(
|
||||
`${r.tag.padEnd(14)} ${String(r.same).padEnd(10)} ${(padOk ? "全 0 ✓" : JSON.stringify(r.pad)).padEnd(10)} ${rect} ${ratioOk ? "✓" : `✗ 期望 ${a.toFixed(4)} 实得 ${ra.toFixed(4)}`}`,
|
||||
);
|
||||
if (!r.same) fail(`${r.tag} 重建了播放器(标记丢失: ${r.probe})`);
|
||||
if (!padOk) fail(`${r.tag} 内边距没有归零`);
|
||||
if (!ratioOk) fail(`${r.tag} 可见矩形比例与画布不符`);
|
||||
}
|
||||
|
||||
// 首尾同为 1920x1080:取景必须回到完全相同的值,证明重算可逆、无漂移
|
||||
const first = rows.find((r) => r.tag === "1920x1080");
|
||||
const last = [...rows].reverse().find((r) => r.tag === "1920x1080");
|
||||
const reversible = first.resolved.every((n, i) => Math.abs(n - last.resolved[i]) < 0.01);
|
||||
if (!reversible) fail(`回到 16:9 后取景漂移:${first.resolved} → ${last.resolved}`);
|
||||
console.log(`\n回到 16:9 取景可逆: ${reversible ? "✓" : "✗"} ${first.resolved} → ${last.resolved}`);
|
||||
|
||||
const growth = heapAfterStress - heapBeforeStress;
|
||||
console.log(`连续 20 次 resize 堆内存: ${(heapBeforeStress / 1048576).toFixed(1)} MB → ${(heapAfterStress / 1048576).toFixed(1)} MB(增长 ${(growth / 1048576).toFixed(2)} MB)`);
|
||||
if (growth > 4 * 1048576) fail(`连续 resize 内存增长过大:${(growth / 1048576).toFixed(2)} MB`);
|
||||
|
||||
const drawCalls = rows[rows.length - 1].drawCalls;
|
||||
console.log(`累计绘制帧数: ${drawCalls}`);
|
||||
if (!(drawCalls > 0)) fail("没有绘制任何帧");
|
||||
|
||||
const noisy = consoleLines.filter((l) => /error|warn|uncaught|fail/i.test(l));
|
||||
console.log(`控制台异常: ${noisy.length ? noisy.slice(0, 5).join(" | ") : "无 ✓"}`);
|
||||
if (noisy.length) fail("控制台有报错/警告");
|
||||
console.log(`未捕获异常: ${exceptions.length ? exceptions.slice(0, 4).join(" | ") : "无 ✓"}`);
|
||||
if (exceptions.length) fail("有未捕获异常");
|
||||
|
||||
console.log(`\n结论:${failures === 0 ? "全部通过 ✓" : `${failures} 项失败 ✗`}`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
@@ -1,72 +0,0 @@
|
||||
// 取景公式的离线验证:不依赖浏览器,直接对 viewport-fitter.js 求值。
|
||||
// 两条硬约束:① 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 mod = new Function(
|
||||
src + "\nreturn { contain, frameForAspect, coverScale, resolveViewport, toViewportConfig, REFERENCE_ASPECT };",
|
||||
)();
|
||||
|
||||
const rect = (x, y, width, height) => ({ x, y, width, height });
|
||||
const fmt = (r) =>
|
||||
`x=${r.x.toFixed(2)} y=${r.y.toFixed(2)} w=${r.width.toFixed(2)} h=${r.height.toFixed(2)} 比例=${(r.width / r.height).toFixed(4)}`;
|
||||
|
||||
const cases = [
|
||||
{
|
||||
name: "xilian(昔涟立绘)",
|
||||
R: rect(-2181.72, 1442.39, 4226.57, 3403.35),
|
||||
imgW: 2048,
|
||||
imgH: 1080,
|
||||
},
|
||||
{
|
||||
name: "kv37(「成为昨日的明天」)",
|
||||
R: rect(-1419.8, -512.4, 2457.6, 1228.8),
|
||||
imgW: 2560,
|
||||
imgH: 1440,
|
||||
},
|
||||
];
|
||||
const canvases = [
|
||||
[3440, 1440, "21:9"],
|
||||
[1920, 1080, "16:9"],
|
||||
[2560, 1600, "16:10"],
|
||||
[1080, 1920, "9:16"],
|
||||
];
|
||||
|
||||
// 背景走 CSS background-size:cover,它在画布上真实的像素缩放就是这两个候选里的较大者。
|
||||
const bgScale = (w, h, imgW, imgH) => Math.max(w / imgW, h / imgH);
|
||||
|
||||
let failures = 0;
|
||||
for (const c of cases) {
|
||||
const imgAspect = c.imgW / c.imgH;
|
||||
const V0 = mod.contain(c.R, mod.REFERENCE_ASPECT);
|
||||
console.log(`\n=== ${c.name} 背景图 ${c.imgW}x${c.imgH}(比例 ${imgAspect.toFixed(4)}) ===`);
|
||||
console.log(`作者视口 R : ${fmt(c.R)}`);
|
||||
console.log(`参考取景 V0 : ${fmt(V0)}`);
|
||||
|
||||
// 基准取 16:9 1080p:立绘与背景都以此为准比较"被放大了多少倍"
|
||||
const refCanvas = [1920, 1080];
|
||||
const spinePx0 = refCanvas[1] / V0.height;
|
||||
const bgPx0 = bgScale(refCanvas[0], refCanvas[1], c.imgW, c.imgH);
|
||||
|
||||
for (const [w, h, tag] of canvases) {
|
||||
const aspect = w / h;
|
||||
const V = mod.frameForAspect(V0, aspect, imgAspect);
|
||||
const spineRatio = h / V.height / spinePx0;
|
||||
const bgRatio = bgScale(w, h, c.imgW, c.imgH) / bgPx0;
|
||||
const synced = Math.abs(spineRatio - bgRatio) < 1e-6;
|
||||
if (!synced) failures++;
|
||||
console.log(
|
||||
` ${tag.padEnd(5)} ${String(w).padStart(4)}x${String(h).padEnd(4)} ${fmt(V)}` +
|
||||
` 立绘 x${spineRatio.toFixed(4)} 背景 x${bgRatio.toFixed(4)} ${synced ? "同步 ✓" : "不同步 ✗"}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 参考比例下必须与作者设定逐像素一致,且必须与拟合前的真实渲染值吻合
|
||||
const Vref = mod.frameForAspect(V0, mod.REFERENCE_ASPECT, imgAspect);
|
||||
const untouched = ["x", "y", "width", "height"].every((k) => Math.abs(Vref[k] - V0[k]) < 1e-9);
|
||||
if (!untouched) failures++;
|
||||
console.log(` 参考比例回归: ${untouched ? "与作者设定完全一致 ✓" : "被改动 ✗"}`);
|
||||
}
|
||||
|
||||
console.log(`\n结论:${failures === 0 ? "全部通过 ✓" : `${failures} 项失败 ✗`}`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
@@ -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` 残留、
|
||||
|
||||
Reference in new issue
Block a user