refactor: 资源按 游戏/壁纸 分层,预设拆进各自壁纸目录
- assets/<游戏>/<壁纸>/{effects,images,audios}/,每档壁纸自包含
- 每档壁纸自带 preset.js:用 import.meta.url 推导自身资源路径,目录可整体搬走;
目录名用显示名(含「」),代码键用稳定 id(xilian/kv37,与 WE 属性值绑定)
- preset-store.js -> presets.js:静态 import 两份预设并建 id 索引
- 新增 CONTEXT.md 术语表与 ADR-0001/0002
- 新增 check-paths / compare / heatmap 三个验证工具;serve.mjs 改为每帧强制动画相位归零
验证:check-paths 11/11 路径存在(含此前零引用的 昔涟.flac);浏览器 0 个真实 404;
与基线 worktree 同冻结设置对拍,9 对跨版本比较中 4 对逐像素完全相同(差异 0),
其余 ≤0.19/255,远低于基线自身的双模态抖动(2.27/255)。
This commit is contained in:
1 parent
399f99edd7
commit
09ff322fe8
29 files changed
+388
-127
No files matched your search
@@ -0,0 +1,49 @@
|
||||
// 静态检查:每份 preset.js 里声明的每一个资源路径是否真实存在。
|
||||
// 这能抓到"运行时才会暴露"的拼写错误(比如某首备用 BGM 从未被加载过,写错了也不会有人发现)。
|
||||
// 用法:node tools/check-paths.mjs
|
||||
|
||||
import { stat } from "node:fs/promises";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { glob } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const root = resolve("dist");
|
||||
const presetFiles = [];
|
||||
for await (const f of glob("assets/**/preset.js", { cwd: root })) presetFiles.push(f);
|
||||
|
||||
let ok = 0;
|
||||
let bad = 0;
|
||||
|
||||
for (const rel of presetFiles.sort()) {
|
||||
const mod = await import(pathToFileURL(resolve(root, rel)).href);
|
||||
const preset = mod.default;
|
||||
const urls = [];
|
||||
const walk = (value, path) => {
|
||||
if (typeof value === "string" && /^(file|https?):/.test(value)) urls.push([path, value]);
|
||||
else if (value && typeof value === "object") {
|
||||
for (const [k, v] of Object.entries(value)) walk(v, `${path}.${k}`);
|
||||
} else if (Array.isArray(value)) value.forEach((v, i) => walk(v, `${path}[${i}]`));
|
||||
};
|
||||
walk(preset, preset.id);
|
||||
|
||||
console.log(`\n${rel} (id=${preset.id}, name=${preset.name})`);
|
||||
for (const [path, url] of urls) {
|
||||
const u = new URL(url);
|
||||
if (u.protocol !== "file:") {
|
||||
console.log(` ? ${path} -> 非本地路径,跳过: ${url}`);
|
||||
continue;
|
||||
}
|
||||
const file = fileURLToPath(u);
|
||||
const info = await stat(file).catch(() => null);
|
||||
if (info?.isFile()) {
|
||||
console.log(` ok ${path} (${(info.size / 1024).toFixed(1)} KB)`);
|
||||
ok++;
|
||||
} else {
|
||||
console.log(` MISSING ${path} -> ${file}`);
|
||||
bad++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n${ok} 个路径存在,${bad} 个缺失`);
|
||||
process.exit(bad ? 1 : 0);
|
||||
@@ -0,0 +1,58 @@
|
||||
// 对拍比较:把两批截图逐对做差值,给出"改完到底有没有动到画面"的判定。
|
||||
// 用法:node tools/compare.mjs <A目录> <B目录> [--tag A] [--tag B] [--threshold 1.0]
|
||||
// 例: node tools/compare.mjs before after
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { readdir } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const [dirA, dirB] = argv;
|
||||
if (!dirA || !dirB) {
|
||||
console.error("usage: node tools/compare.mjs <dirA> <dirB> [--threshold 1.0]");
|
||||
process.exit(2);
|
||||
}
|
||||
const opt = (name, dflt) => {
|
||||
const i = argv.indexOf(`--${name}`);
|
||||
return i >= 0 ? argv[i + 1] : dflt;
|
||||
};
|
||||
const threshold = Number(opt("threshold", "1.0"));
|
||||
|
||||
function run(cmd, args) {
|
||||
return new Promise((resolve) => {
|
||||
const p = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
|
||||
let out = "";
|
||||
p.stdout.on("data", (d) => (out += d));
|
||||
p.stderr.on("data", (d) => (out += d));
|
||||
p.on("close", () => resolve(out));
|
||||
});
|
||||
}
|
||||
|
||||
const A = resolve("tools/shots", dirA);
|
||||
const B = resolve("tools/shots", dirB);
|
||||
const files = (await readdir(A)).filter((f) => f.endsWith(".png") && !f.endsWith(".ref.png")).sort();
|
||||
|
||||
let worst = 0;
|
||||
let worstName = "";
|
||||
let failed = 0;
|
||||
|
||||
for (const f of files) {
|
||||
const a = resolve(A, f);
|
||||
const b = resolve(B, f);
|
||||
const out = await run(process.execPath, ["tools/diff.mjs", a, b]);
|
||||
const m = out.match(/mean\|diff\|=([\d.]+)/);
|
||||
const mean = m ? Number(m[1]) : NaN;
|
||||
const verdict = Number.isNaN(mean) ? "MISSING" : mean < threshold ? "same" : "CHANGED";
|
||||
if (Number.isNaN(mean)) failed++;
|
||||
else if (mean > worst) {
|
||||
worst = mean;
|
||||
worstName = f;
|
||||
}
|
||||
console.log(`${f.padEnd(28)} mean|diff|=${String(mean).padStart(7)} ${verdict}`);
|
||||
}
|
||||
|
||||
console.log(`\n${dirA} → ${dirB}:${files.length} 对;最大差异 ${worst.toFixed(3)}/255(${worstName || "无"});判定阈值 ${threshold}`);
|
||||
if (failed) {
|
||||
console.log(`${failed} 对缺少对照文件`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// 把两张图的差异降采样成 ASCII 热力图,好在纯文本里看出"差异落在画面哪里"。
|
||||
// 用法:node tools/heatmap.mjs <a.png> <b.png> [cols] [rows]
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
const [a, b, cols = "48", rows = "20"] = process.argv.slice(2);
|
||||
if (!a || !b) {
|
||||
console.error("usage: node tools/heatmap.mjs <a.png> <b.png> [cols] [rows]");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const W = Number(cols);
|
||||
const H = Number(rows);
|
||||
const RAMP = " .:-=+*#%@";
|
||||
|
||||
const buf = await new Promise((resolve) => {
|
||||
const p = spawn(
|
||||
"ffmpeg",
|
||||
[
|
||||
"-hide_banner", "-loglevel", "error",
|
||||
"-i", a, "-i", b,
|
||||
"-lavfi", `blend=all_mode=difference,format=gray,scale=${W}:${H}:flags=area`,
|
||||
"-f", "rawvideo", "-pix_fmt", "gray", "-",
|
||||
],
|
||||
{ stdio: ["ignore", "pipe", "pipe"] },
|
||||
);
|
||||
const chunks = [];
|
||||
p.stdout.on("data", (d) => chunks.push(d));
|
||||
p.on("close", () => resolve(Buffer.concat(chunks)));
|
||||
});
|
||||
|
||||
if (buf.length < W * H) {
|
||||
console.error(`ffmpeg 只返回 ${buf.length} 字节,期望 ${W * H}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`${a} vs ${b} (${W}x${H} 降采样,越亮=差异越大)`);
|
||||
console.log(" +" + "-".repeat(W) + "+");
|
||||
for (let y = 0; y < H; y++) {
|
||||
let line = "";
|
||||
for (let x = 0; x < W; x++) {
|
||||
const v = buf[y * W + x];
|
||||
line += RAMP[Math.min(RAMP.length - 1, Math.round((v / 255) * (RAMP.length - 1) * 2))];
|
||||
}
|
||||
console.log(String(y).padStart(3) + " |" + line + "|");
|
||||
}
|
||||
console.log(" +" + "-".repeat(W) + "+");
|
||||
@@ -84,6 +84,16 @@ const DRIVER = (q) => `<script>
|
||||
var origDraw = proto.drawFrame;
|
||||
proto.drawFrame = function () {
|
||||
state.drawCalls = (state.drawCalls || 0) + 1;
|
||||
// 冻结只做一次是不够的:相位可能在成功回调前后被推进过一帧。这里每帧都强制归零,
|
||||
// 保证"同一份代码的任何两次截图"是同一相位,对拍才有意义。
|
||||
if (freeze) {
|
||||
try {
|
||||
var cur = this.animationState && this.animationState.getCurrent(0);
|
||||
if (cur) cur.trackTime = 0;
|
||||
if (this.skeleton) this.skeleton.updateWorldTransform(2);
|
||||
this.viewportTransitionStart = performance.now() - 1e6;
|
||||
} catch (e) { state.error = "freeze-frame: " + e; }
|
||||
}
|
||||
return origDraw.apply(this, arguments);
|
||||
};
|
||||
}
|
||||
@@ -94,6 +104,9 @@ const DRIVER = (q) => `<script>
|
||||
var cur = p.animationState && p.animationState.getCurrent(0);
|
||||
if (cur) cur.trackTime = 0;
|
||||
if (p.skeleton) p.skeleton.updateWorldTransform(2);
|
||||
// 视口过渡(transitionTime 默认 0.25s)是时间相关的插值,会把"冻结"变得不彻底:
|
||||
// 把它推到足够久以前,transitionAlpha >= 1,直接使用 currentViewport。
|
||||
p.viewportTransitionStart = performance.now() - 1e6;
|
||||
state.info = {
|
||||
animations: p.skeleton ? p.skeleton.data.animations.map(function (a) { return a.name; }) : [],
|
||||
skin: p.skeleton ? p.skeleton.data.skins.map(function (s) { return s.name; }) : [],
|
||||
|
||||
Reference in new issue
Block a user