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:
Shuery committed 2026-09-19 19:26:46 +08:00
1 parent 399f99edd7
commit 09ff322fe8
29 files changed
+303 -42

No files matched your search

+37
View File
@@ -0,0 +1,37 @@
# SpineWallpaper(网页壁纸作品)
一份已发布的 Wallpaper Engine 网页壁纸,以及围绕它的资源分层与验证约定。这个上下文只关心"一档壁纸由什么组成、怎么被打包和切换"。
## Language
**作品(Project)**:
一个 Wallpaper Engine 打包单元,即 `dist/` 整个目录;`project.json` 是它的清单,`index.html` 是它的入口。
_Avoid_: 工程、站点、应用
**游戏(Game)**:
壁纸所出自的作品名(`崩坏:星穹铁道`),资源分层的第一级,也是目录结构的第一层。
_Avoid_: 来源、IP
**壁纸(Wallpaper)**:
用户可在 WE 属性里切换的一档壁纸(`昔涟立绘`、`「成为昨日的明天」`);它的显示名就是它的目录名,可以改。
_Avoid_: 预设、选项、preset(当指"这一档"本身时)
**预设(Preset)**:
一档壁纸的配置实体,落盘为该壁纸目录下的 `preset.js`,声明这一档要用哪些资源、以什么参数播放。一档壁纸对应且仅对应一个预设。
_Avoid_: 配置、profile、manifest
**预设 id**:
预设的稳定标识(`xilian`、`kv37`),必须与 WE 属性值一致,永不随显示名变化——已发布作品的用户设置靠它维系。
_Avoid_: slug、键名、代号
**音源(AudioSource)**:
一首可作背景音乐的曲子。每首曲子归属且只归属一档壁纸;WE 的音源列表是所有壁纸音源声明的并集。
_Avoid_: BGM、曲目、音频文件
**视口(Viewport)**:
交给 Spine 播放器的世界坐标系矩形,决定构图。它以等比"contain"方式映射到画布:单轴贴边、另一轴会多露出世界,因此负 padding 的裁切只在目标比例下成立。
_Avoid_: 相机、取景框、裁剪区
**对拍(AB capture)**:
同一壁纸、同一分辨率、同一动画相位下的改动前后像素比对。噪声底约 0.7/255;低于 1 视为无差异。
_Avoid_: 截图测试、视觉回归
@@ -0,0 +1,34 @@
// 「成为昨日的明天」—— 官方 3.7 版本专题展示页
// 这份预设自带全部资源,路径由 import.meta.url 从本文件位置推导,
// 因此整个目录可以整体搬走或改名,而不用改这里的任何一行。
const base = new URL("./", import.meta.url);
const asset = (path) => new URL(path, base).href;
export default {
id: "kv37", // 与 project.json 中 combo 选项的 value 一致,勿改
name: "「成为昨日的明天」",
game: "崩坏:星穹铁道",
backgroundImage: asset("./images/kv37_xilian.png"),
spineConfig: {
jsonUrl: asset("./effects/kv37.json"),
atlasUrl: asset("./images/kv37.atlas"),
animation: "animation",
viewport: {
x: -1215,
y: -410,
width: 2048,
height: 1024,
},
},
audioOptions: {
source: asset("./audios/pv37.mp3"),
volume: 0.5,
},
// 本壁纸可选的音源(第 6 步会接进 project.json 的 bgm 属性)
audioChoices: [{ id: "pv37", name: "版本 PV", source: asset("./audios/pv37.mp3") }],
};
File renamed without changes.
@@ -0,0 +1,37 @@
// 昔涟立绘 —— 官方昔涟动态立绘
// 这份预设自带全部资源,路径由 import.meta.url 从本文件位置推导,
// 因此整个目录可以整体搬走或改名,而不用改这里的任何一行。
const base = new URL("./", import.meta.url);
const asset = (path) => new URL(path, base).href;
export default {
id: "xilian", // 与 project.json 中 combo 选项的 value 一致,勿改
name: "昔涟立绘",
game: "崩坏:星穹铁道",
backgroundImage: asset("./images/ava.jpg"),
spineConfig: {
jsonUrl: asset("./effects/xilian.json"),
atlasUrl: asset("./images/xilian.atlas"),
animation: "idle",
viewport: {
padLeft: "-25%",
padRight: "-28%",
padTop: "-30%",
padBottom: "-23%",
},
},
audioOptions: {
source: asset("./audios/「再度和你」.flac"),
volume: 0.5,
},
// 本壁纸可选的音源(第 6 步会接进 project.json 的 bgm 属性)
audioChoices: [
{ id: "zaiduheni", name: "再度和你", source: asset("./audios/「再度和你」.flac") },
{ id: "xilian", name: "昔涟", source: asset("./audios/昔涟.flac") },
],
};
+2 -2
View File
@@ -1,11 +1,11 @@
import PresetStore from "./preset-store.js"; import Presets from "./presets.js";
import PresetController from "./preset-controller.js"; import PresetController from "./preset-controller.js";
import BackgroundController from "./background-controller.js"; import BackgroundController from "./background-controller.js";
import SpineController from "./spine-controller.js"; import SpineController from "./spine-controller.js";
import AudioController from "./audio-controller.js"; import AudioController from "./audio-controller.js";
let presetController = new PresetController(PresetStore); let presetController = new PresetController(Presets);
let { backgroundImage, spineConfig, audioOptions } = let { backgroundImage, spineConfig, audioOptions } =
presetController.set("xilian"); presetController.set("xilian");
-40
View File
@@ -1,40 +0,0 @@
const PresetStore = {
kv37: {
backgroundImage: "./assets/images/kv37_xilian.png",
spineConfig: {
jsonUrl: "./assets/effects/kv37.json",
atlasUrl: "./assets/images/kv37.atlas",
animation: "animation",
viewport: {
x: -1215,
y: -410,
width: 2048,
height: 1024,
},
},
audioOptions: {
source: "./assets/audios/pv37.mp3",
volume: 0.5,
},
},
xilian: {
backgroundImage: "./assets/images/ava.jpg",
spineConfig: {
jsonUrl: "./assets/effects/xilian.json",
atlasUrl: "./assets/images/xilian.atlas",
animation: "idle",
viewport: {
padLeft: "-25%",
padRight: "-28%",
padTop: "-30%",
padBottom: "-23%",
},
},
audioOptions: {
source: "./assets/audios/「再度和你」.flac",
volume: 0.5,
},
},
};
export default PresetStore;
+12
View File
@@ -0,0 +1,12 @@
// 预设索引:把每份壁纸自己的 preset.js 汇总成 id → 预设 的查表。
//
// id 必须与 project.json 里 combo 选项的 value 一致(xilian / kv37),
// 改了会让老用户已保存的预设选择失效,所以 id 永远跟着 WE 属性走,
// 而目录名(游戏名/壁纸显示名)是给人看的、可以改。
import xilian from "../assets/崩坏:星穹铁道/昔涟立绘/preset.js";
import kv37 from "../assets/崩坏:星穹铁道/「成为昨日的明天」/preset.js";
const Presets = Object.fromEntries([xilian, kv37].map((preset) => [preset.id, preset]));
export default Presets;
+7
View File
@@ -0,0 +1,7 @@
# 资源按 游戏/壁纸 分层,显示名做目录、id 做键
已发布作品(workshopid 3604974793)原先把两套壁纸的资源混在 `assets/{images,effects,audios}/` 三个平铺目录里,加第三档就必须靠文件名前缀认领。我们改为每档壁纸自包含:`assets/<游戏>/<壁纸>/{effects,images,audios}/`,并让每档壁纸自带一份 `preset.js`。
关键取舍是**目录名用显示名(含 `「」` 等符号),而代码里的键用 ASCII 的预设 id**。反过来做(目录名用 id)会让目录结构在 WE 的文件夹里毫无可读性;统一用显示名做键则会在改标题时打断老用户已保存的预设选择。所以两者各司其职:显示名服务于人,id 服务于 WE 属性与用户设置,二者用 `preset.js` 里的 `id` 字段绑定。
保留了 `effects/`、`images/`、`audios/` 这三层同名子目录,是为了让 Spine 骨架 JSON 里的 `"images": "../images/"` 相对路径一字不改。
+7
View File
@@ -0,0 +1,7 @@
# 预设用 ES module 而不是 JSON
每档壁纸的 `preset.js` 是 `export default {...}` 的 ES module,而不是由 `fetch` 拉取的 `preset.json`。
理由是资源路径的自包含性:模块内用 `import.meta.url` 推导 `./images/...` 的绝对 URL,于是整个壁纸目录可以被复制、改名、搬家而不需要改任何一行路径。若用 JSON,路径只能以**页面**为基准写死,一旦目录改名(本项目的目录名就是显示名,改标题=改目录)就会全站 404,且每档壁纸都必须先异步取回 JSON 才能建播放器,给启动时序又加一个竞态点。
代价:配置文件不再是纯数据,不能直接 `JSON.parse` 消费。本项目接受这一点——预设数据量极小,且始终由代码持有。
+49
View File
@@ -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);
+58
View File
@@ -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);
}
+47
View File
@@ -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) + "+");
+13
View File
@@ -84,6 +84,16 @@ const DRIVER = (q) => `<script>
var origDraw = proto.drawFrame; var origDraw = proto.drawFrame;
proto.drawFrame = function () { proto.drawFrame = function () {
state.drawCalls = (state.drawCalls || 0) + 1; 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); return origDraw.apply(this, arguments);
}; };
} }
@@ -94,6 +104,9 @@ const DRIVER = (q) => `<script>
var cur = p.animationState && p.animationState.getCurrent(0); var cur = p.animationState && p.animationState.getCurrent(0);
if (cur) cur.trackTime = 0; if (cur) cur.trackTime = 0;
if (p.skeleton) p.skeleton.updateWorldTransform(2); if (p.skeleton) p.skeleton.updateWorldTransform(2);
// 视口过渡(transitionTime 默认 0.25s)是时间相关的插值,会把"冻结"变得不彻底:
// 把它推到足够久以前,transitionAlpha >= 1,直接使用 currentViewport。
p.viewportTransitionStart = performance.now() - 1e6;
state.info = { state.info = {
animations: p.skeleton ? p.skeleton.data.animations.map(function (a) { return a.name; }) : [], 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; }) : [], skin: p.skeleton ? p.skeleton.data.skins.map(function (s) { return s.name; }) : [],