feat: TS 构建管线、Spine 抓取器与 wallpapers/ 唯一真相来源

把项目从「手写 dist/」改成「wallpapers/ 是唯一真相来源,dist/ 由 pnpm build 生成」,
并补上配套的类型、门禁与抓取器。一次提交落地整条管线,因为拆开会留下不能构建的中间态。

- src/:运行时与模拟器源码(TS,strict),编译到 build/ 再拷进各分发
- tools/:build / dev / check-{syntax,paths,dist},以及抓取器与回归门禁 tools/checks/
  (.scratch/ 下那批一次性脚本移入 tools/checks/ 并入库为长期门禁)
- wallpapers/:七档壁纸的源数据 + README.md(id/音频/预设的完整规范)
- docs/adr/0005-0008:构建管线与分发拓扑、模拟器契约、自包含 sim、每骨架资源布局
- .gitignore:排除 .scratch/ 的参考资料副本(上游 spine 整仓克隆 ~1.2 GB、
  抓取侦查数据 ~680 MB)与调试转储;这些是本地调查材料,补偿会让仓库无法克隆
- 归一化 .gitignore/CONTEXT.md 行尾(工作区 CRLF、索引 LF 造成的整文件假 diff)

同时修掉三档卡住构建的未完工壁纸:
- kv45 的 meta.json 里 id 还是抓取期场景名 scene_main,经 downloader promote 正名为 kv45
- shajin / zhigengniao_juheye 的 meta.json 误用了骨架描述文件(name/spine/animations/pages)、
  且都缺 preset.template.json;现按规范重建:骨架沉到 spines/<名>/(spine-ts 按 atlas 所在
  目录解析贴图页)、补上元数据与单骨架预设,并清掉 zhigengniao 骨架里指向作者机的绝对路径
- 顺带 promote 已在 sources.yml 里的 kv46(月升之前,与兽共舞)

pnpm check 五道门全绿:10 个分发 / 7 档壁纸 / 141 处引用自包含。
This commit is contained in:
Shuery committed 2026-10-02 01:27:02 +08:00
1 parent b8eee05d78
commit 3f11426964
297 files changed
+216627 -1926

No files matched your search

-109
View File
@@ -1,109 +0,0 @@
// 把差分掩码(立绘层 = 正常帧 − 藏 canvas 参考帧)压成 ASCII,
// 这样在没有图像输入能力的情况下也能"看"清构图。
// 用法:
// node tools/ascii.mjs <normal.png> <ref.png> [cols] 差分两幅,打印强度图 + 实体占比图
// node tools/ascii.mjs <image.png> [cols] 单幅,打印亮度图
// 行数按 2.2:1 的字符高宽比自动换算,保证画面不被压扁。
import { spawn } from "node:child_process";
const flags = process.argv.slice(2).filter((a) => a.startsWith("--"));
const [fileA, fileB, colsArg] = process.argv.slice(2).filter((a) => !a.startsWith("--"));
const solidOnly = flags.includes("--solid");
if (!fileA) {
console.error("usage: node tools/ascii.mjs <image.png> [ref.png] [cols]");
process.exit(2);
}
function probe(file) {
return new Promise((resolve) => {
const p = spawn("ffprobe", ["-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height", "-of", "csv=p=0", file]);
let out = "";
p.stdout.on("data", (d) => (out += d));
p.on("close", () => {
const [w, h] = out.trim().split(",").map(Number);
resolve({ w, h });
});
});
}
function gray(file) {
return new Promise((resolve, reject) => {
const p = spawn("ffmpeg", ["-v", "error", "-i", file, "-f", "rawvideo", "-pix_fmt", "gray", "-"], { stdio: ["ignore", "pipe", "pipe"] });
const chunks = [];
let err = "";
p.stdout.on("data", (d) => chunks.push(d));
p.stderr.on("data", (d) => (err += d));
p.on("close", (code) => (code === 0 ? resolve(Buffer.concat(chunks)) : reject(new Error(err || `exit ${code}`))));
});
}
const RAMP = " .:-=+*#%@";
const { w: W, h: H } = await probe(fileA);
const bufA = await gray(fileA);
if (bufA.length < W * H) {
console.error(`像素不足:${bufA.length} < ${W * H}`);
process.exit(1);
}
// 终端字符高约为宽的 2.2 倍,据此把列数换算成行数,保持画面比例
const cols = Math.max(20, Math.min(Number(colsArg) || 110, Math.floor((46 * 2.2 * W) / H)));
const rows = Math.max(6, Math.round((cols * H) / W / 2.2));
const cw = W / cols;
const chh = H / rows;
function grid(fn) {
const out = [];
for (let r = 0; r < rows; r++) {
let line = "";
for (let c = 0; c < cols; c++) {
const x0 = Math.floor(c * cw);
const x1 = Math.min(W, Math.ceil((c + 1) * cw));
const y0 = Math.floor(r * chh);
const y1 = Math.min(H, Math.ceil((r + 1) * chh));
line += RAMP[Math.max(0, Math.min(9, fn(x0, x1, y0, y1)))];
}
out.push(line);
}
return out;
}
function print(title, lines) {
console.log(title);
for (const l of lines) console.log("|" + l + "|");
console.log("");
}
console.log(`画布 ${W}x${H} → ${cols}列 x ${rows}行,单元 ${cw.toFixed(1)}x${chh.toFixed(1)}px;ramp "${RAMP}"`);
if (!fileB) {
print(`亮度图 ${fileA}`, grid((x0, x1, y0, y1) => {
let s = 0;
let n = 0;
for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) { s += bufA[y * W + x]; n++; }
return Math.round((s / n / 255) * 9);
}));
} else {
const bufB = await gray(fileB);
if (bufB.length < W * H) {
console.error(`参考帧像素不足:${bufB.length} < ${W * H}`);
process.exit(1);
}
const d = new Uint8Array(W * H);
for (let i = 0; i < W * H; i++) d[i] = Math.abs(bufA[i] - bufB[i]);
if (!solidOnly)
print(`立绘层 · 平均强度 (${fileA} − ${fileB})`, grid((x0, x1, y0, y1) => {
let s = 0;
let n = 0;
for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) { s += d[y * W + x]; n++; }
return Math.round((s / n / 255) * 9);
}));
print(`立绘层 · 实体占比 (>96)`, grid((x0, x1, y0, y1) => {
let k = 0;
let n = 0;
for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) { if (d[y * W + x] > 96) k++; n++; }
return Math.round((k / n) * 9);
}));
}
+534
View File
@@ -0,0 +1,534 @@
// pnpm build —— 把所有分发目录编译成"能直接上传 Wallpaper Engine"的自包含作品。
//
// pnpm build 编全部(所有单档 + 所有游戏合集 + 全部合集)
// pnpm build --single xilian [--single kv37] 编指定单档
// pnpm build --collect hsr 编某游戏的合集
// pnpm build --collect all 编全部合集
// pnpm build --single xilian --collect all 组合:只编这两档
//
// --with-sim 顺带把可选的 WE 模拟器放进各分发的 scripts/(服务端调试用)
// --sim 额外产出**双击就能看**的自包含包 <分发根>/sim/index.html(见 lib/bundle.ts)
// --no-embed-audio --sim 时音频不内联(包小很多,但 file:// 下音频播不了)
// --strict 把 warning 升级为 fail
// --no-clean 保留本次未构建的旧分发目录(默认会删掉,保证产物不残留)
// --dry-run 只打印计划,不落盘
//
// 产物拓扑(dist/releases/<dir>/)见 docs/adr/0005;自包含包见 docs/adr/0007。dist/ 完全可再生产,故不入库。
import { readFile, rm } from "node:fs/promises";
import { join } from "node:path";
import {
abs,
copyFileTo,
dirBytes,
exists,
listDirs,
log,
mb,
readJson,
resetDir,
writeJson,
writeText,
} from "./lib/fs.ts";
import { readVault, sharedAudioOf, VaultError, type Vault } from "./lib/vault.ts";
import {
copyCollectionAudio,
copyWallpaperAssets,
generatePresetIndex,
generatePresetModule,
generateProjectJson,
releasePathOf,
type GenerateContext,
} from "./lib/generate.ts";
import type { BuildOptions, DistMap, ProjectTemplate, Release } from "./lib/types.ts";
import { SIMULATOR_DRIVER, SIMULATOR_URL_STATIC } from "./lib/drivers.ts";
import { checkDist } from "./check-dist.ts";
const RELEASES_DIR = "dist/releases";
const HELP = `用法:
pnpm build 编全部(所有单档 + 所有游戏合集 + 全部合集)
pnpm build <壁纸id> […] 编指定壁纸(可多个)
pnpm build single [壁纸id …] 只编单档(不给 id = 全部单档)
pnpm build collect [游戏id …|all] 只编合集(不给参数 = 全部合集)
pnpm build sim [single|collect|<壁纸id> …] 编全部 + 每档产出自包含包(双击即看,不需要 pnpm dev)
--single <壁纸id> 等价于裸 id --collect <游戏id|all> 编某合集
--with-sim 让分发自带 WE 模拟器面板(静态托管预览用,见下) --sim 产出双击可看的自包含包
--no-embed-audio --sim 时音频不内联
--strict 警告即失败 --no-clean 保留旧分发目录 --dry-run 只打印计划
三种产物,别搞混:
pnpm build 干净产物 → 上传 Wallpaper Engine(不含模拟器,ADR 0006 第 1 条)
pnpm build --with-sim 自带面板 → 丢给任意静态托管(GitHub Pages 等),不需要调试服
pnpm build sim 自包含单文件 → 本地双击打开,不需要任何服务器`;
interface BuildCliOptions {
options: BuildOptions;
withSimulator: boolean;
standalone: boolean;
embedAudio: boolean;
}
/**
* 子命令解析。
*
* 三种子命令对应三种**意图**,而不是三个 flag:
* `single` —— 我在调一档壁纸,只要它;
* `collect` —— 我在调一个合集的组装(预设切换、共享音频);
* `sim` —— 我要能双击打开的成品,每档都要一份。
*
* `sim` 后面还能再跟 `single`/`collect` 收窄范围(`pnpm build sim single kv37`)。
* 不带子命令时保持原来的 flag 风格(`--single` / `--collect`),向后兼容。
*/
function parseArgs(argv: string[]): BuildCliOptions {
const options: BuildOptions = { singles: [], collect: null, strict: false, clean: true, dryRun: false };
let withSimulator = false;
let standalone = false;
let embedAudio = true;
// 先摘子命令。`sim` 后面可以再跟 `single`/`collect`/壁纸 id,所以用循环而不是单次判断。
let start = 0;
while (start < argv.length && !(argv[start] ?? "").startsWith("-")) {
const word = argv[start] ?? "";
if (word === "sim") {
standalone = true;
start += 1;
} else if (word === "single") {
// `pnpm build single kv37 xilian`:后面所有非 flag 的词都是壁纸 id
start += 1;
while (start < argv.length && !(argv[start] ?? "").startsWith("-")) {
options.singles.push(argv[start] ?? "");
start += 1;
}
// 不给 id 就是"全部单档",由 planReleases 的显式标记决定
if (options.singles.length === 0) options.onlySingles = true;
} else if (word === "collect") {
start += 1;
const games: string[] = [];
while (start < argv.length && !(argv[start] ?? "").startsWith("-")) {
games.push(argv[start] ?? "");
start += 1;
}
// 无参 = 全部合集(含每个游戏的合集与「全部壁纸合集」)。
// 这里**不能**用 `collect: "all"` 表示"全部合集"——那个值在 planReleases 里只加
// 「全部壁纸合集」一项,会把 collection-hsr 漏掉。用一个显式标记走"逐游戏 + all"的路径。
if (games.length === 0 || (games.length === 1 && games[0] === "all")) options.allCollections = true;
else options.collect = games;
} else {
/**
* 其余裸词一律当壁纸 id(`pnpm build kv37`、`pnpm build sim kv37`)。
*
* 子命令与壁纸 id 共用同一段位置,靠**词表**区分:`single`/`collect`/`sim` 是保留字,
* 别的裸词都是 id。代价是"想编一个恰好叫 single 的壁纸"做不到——接受,
* 因为 id 由我们命名且都是英文小写,真撞名时改 id 比改语法便宜。
* id 不存在时由 vault 报错,那里能顺带列出所有可用 id,比"未知子命令"有用得多。
*/
options.singles.push(word);
start += 1;
}
}
for (let i = start; i < argv.length; i += 1) {
const arg = argv[i] ?? "";
if (arg === "--single") {
const value = argv[++i];
if (!value || value.startsWith("--")) throw new VaultError("--single 需要跟一个壁纸 id");
options.singles.push(value);
} else if (arg === "--collect") {
const value = argv[++i];
if (!value || value.startsWith("--")) throw new VaultError('--collect 需要跟一个游戏 id 或 "all"');
if (value === "all") options.collect = "all";
else options.collect = [...(Array.isArray(options.collect) ? options.collect : []), value];
} else if (arg === "--with-sim") withSimulator = true;
else if (arg === "--sim") standalone = true;
else if (arg === "--no-embed-audio") embedAudio = false;
else if (arg === "--strict") options.strict = true;
else if (arg === "--no-clean") options.clean = false;
else if (arg === "--dry-run") options.dryRun = true;
else if (arg === "--help" || arg === "-h") {
log(HELP);
process.exit(0);
} else if (arg.startsWith("--")) throw new VaultError(`未知参数:${arg}\n\n${HELP}`);
else if (["sim", "single", "collect"].includes(arg)) {
// 子命令写在 --flag 后面时会被当成壁纸 id。报错时直接点破顺序,
// 否则那句"壁纸要写成 --single sim"会把人引到完全错误的方向。
throw new VaultError(
`子命令 ${arg} 必须写在所有 --flag **之前**:\n` +
` pnpm build ${arg} --with-sim ✓\n` +
` pnpm build --with-sim ${arg} ✗(这里的 ${arg} 会被当成壁纸 id)\n\n${HELP}`,
);
} else throw new VaultError(`未知参数:${arg}(壁纸要写成 --single ${arg})\n\n${HELP}`);
}
return { options, withSimulator, standalone, embedAudio };
}
function normalizeGameId(value: string, vault: Vault): string {
const wanted = value.trim().replace(/[:]/g, ":");
const exact = vault.games.find((g) => g.id === wanted);
if (exact) return exact.id;
const byName = vault.games.find((g) => g.meta.name.replace(/[:]/g, ":") === wanted);
if (byName) return byName.id;
throw new VaultError(`找不到游戏 "${value}"。已有:${vault.games.map((g) => `${g.meta.id}(${g.meta.name})`).join("、")}`);
}
/** 按子命令 / --single / --collect 求出本次要编的分发清单。无参 = 全编。 */
function planReleases(vault: Vault, options: BuildOptions): Release[] {
const releases: Release[] = [];
// `single`(无 id)只编单档;`collect`(无参)只编合集;无参才是全编。
// 三者都不能靠"singles 为空 + collect 为 null"来区分,所以各自有显式标记。
const onlySingles = options.onlySingles === true;
const onlyCollections = options.allCollections === true;
const all = !onlySingles && !onlyCollections && options.singles.length === 0 && options.collect === null;
const addSingle = (wallpaperId: string): void => {
const wallpaper = vault.wallpapers.find((w) => w.id === wallpaperId);
if (!wallpaper) {
throw new VaultError(
`找不到壁纸 "${wallpaperId}"。已有:${vault.wallpapers.map((w) => `${w.id}(${w.meta.name})`).join("、")}`,
);
}
releases.push({
type: "single",
id: wallpaper.id,
// 带上游戏:`wallpaper-kv37` 这种名字在分不清来源的场合(多个游戏各有一档壁纸时)
// 只能靠 id 全局唯一来兜底,而目录名本来就该自解释。与 collection-<游戏id> 同一套读法。
dir: `single-${wallpaper.gameId}-${wallpaper.id}`,
wallpapers: [wallpaper],
displayName: `${wallpaper.game?.name ?? wallpaper.gameId} - ${wallpaper.meta.name}`,
meta: {
title: wallpaper.meta.title,
description: wallpaper.meta.description,
...(wallpaper.meta.preview ? { preview: wallpaper.meta.preview } : {}),
...(wallpaper.meta.workshopid ? { workshopid: wallpaper.meta.workshopid } : {}),
...(wallpaper.meta.workshopurl ? { workshopurl: wallpaper.meta.workshopurl } : {}),
},
metaRel: wallpaper.srcRel,
sharedAudio: [],
});
};
const addGame = (gameId: string): void => {
const game = vault.games.find((g) => g.id === gameId);
if (!game) throw new VaultError(`内部错误:找不到游戏 ${gameId}`);
releases.push({
type: "collection",
scope: "game",
id: game.id,
dir: `collection-${game.id}`,
wallpapers: [...game.wallpapers],
displayName: `${game.meta.name}合集`,
meta: {
// 文案取自**游戏级** meta(见 wallpapers/README.md 的三层分工),不回落到全局 title:
// 全局那层是给「全部合集」写的,回落会让 collection-ys 顶着《崩坏:星穹铁道》昔涟。
title: game.meta.title,
description:
game.meta.description ??
`[b]${game.meta.name} 全部壁纸[/b]\r\n[list]\r\n [*]共 ${game.wallpapers.length} 档:${game.wallpapers
.map((w) => w.meta.name)
.join("、")}。\r\n [*]bgm 与音量可在壁纸设置中调整。\r\n[/list]`,
},
metaRel: game.srcRel,
sharedAudio: sharedAudioOf(game),
});
};
const addAll = (): void => {
releases.push({
type: "collection",
scope: "all",
id: "all",
dir: "collection-all",
wallpapers: [...vault.wallpapers],
displayName: vault.global.name,
meta: {
title: vault.global.title,
description: vault.global.description,
...(vault.global.preview ? { preview: vault.global.preview } : {}),
...(vault.global.workshopid ? { workshopid: vault.global.workshopid } : {}),
...(vault.global.workshopurl ? { workshopurl: vault.global.workshopurl } : {}),
},
metaRel: "wallpapers",
sharedAudio: vault.games.flatMap((game) => sharedAudioOf(game)),
});
};
if (all) {
for (const wallpaper of vault.wallpapers) addSingle(wallpaper.id);
for (const game of vault.games) addGame(game.id);
addAll();
return releases;
}
if (onlySingles) {
for (const wallpaper of vault.wallpapers) addSingle(wallpaper.id);
return releases;
}
// `pnpm build collect`(无参)= 每个游戏的合集 + 「全部壁纸合集」。
// 注意 `--collect all` 是另一件事:它只编「全部壁纸合集」一项,保持原语义不变。
if (options.allCollections === true) {
for (const game of vault.games) addGame(game.id);
addAll();
return releases;
}
for (const id of options.singles) addSingle(id);
if (options.collect === "all") addAll();
else if (Array.isArray(options.collect)) for (const raw of options.collect) addGame(normalizeGameId(raw, vault));
return releases;
}
/**
* 把 `index.html` 模板渲染成产物。
*
* 默认**原样输出**:发布产物的 index.html 里不能有任何模拟器痕迹(ADR 0006 第 1 条)——
* 旧实现会在真实 WE 里自我激活并覆盖官方 API,事故级。
*
* `--with-sim` 时才注入模拟器驱动,且用**相对路径** `./scripts/wallpaper-engine.js`。
* 这样整个分发目录是自足的:丢到任何静态托管(GitHub Pages 的 `/<repo>/` 子路径、
* 任意 CDN、甚至 file://)都能弹出设置面板,不需要调试服在场。
*
* 注入点必须在 `<script type="module" src="./scripts/index.js">` **之前**:
* 驱动是经典脚本,要赶在壁纸模块注册 `wallpaperPropertyListener` 之前把钩子备好
* (见 tools/lib/drivers.ts 的时序说明)。
*/
function renderIndexHtml(template: string, release: Release, ctx: GenerateContext): string {
if (!ctx.withSimulator) return template;
const driver = SIMULATOR_DRIVER({
search: "",
dir: release.dir,
title: release.displayName,
version: ctx.version,
defaultPresetId: ctx.defaultPresetId,
preview: release.meta.preview,
simulatorUrl: SIMULATOR_URL_STATIC,
});
const marker = /[ \t]*<script\b[^>]*src=["'][^"']*scripts\/index\.js["'][^>]*><\/script>/i;
if (!marker.test(template)) {
throw new VaultError("src/templates/index.html 里找不到 scripts/index.js 的 <script> 标签,模拟器无法注入");
}
return template.replace(marker, (tag) => `${driver}\n ${tag.trim()}`);
}
async function buildRelease(
release: Release,
ctx: GenerateContext,
options: BuildOptions,
warnings: string[],
): Promise<{ bytes: number; simBytes: number }> {
const outDir = join(ctx.outDir, release.dir);
if (options.dryRun) {
log(` [dry-run] ${release.dir.padEnd(24)} ← ${release.wallpapers.map((w) => w.id).join(", ")}`);
return { bytes: 0, simBytes: 0 };
}
await resetDir(outDir);
// 1) 模板与静态资源
await writeText(join(outDir, "index.html"), renderIndexHtml(await readFile(abs("src/templates/index.html"), "utf8"), release, ctx));
await writeJson(join(outDir, "project.json"), generateProjectJson(release, ctx));
for (const name of ["index.css", "spine-player.css"]) {
await copyFileTo(abs(`src/styles/${name}`), join(outDir, "styles", name));
}
await copyFileTo(abs("src/vendor/spine-player.js"), join(outDir, "scripts", "spine-player.js"));
// 分发级预览图:`preview` 字段指向的是**分发根**下的文件名,所以图源必须在分发自己的元数据目录里
// 找到同名文件。当前只有「所有壁纸合集」有它(wallpapers/preview.gif);其余分发没有,字段就省略。
if (release.meta.preview) {
const name = release.meta.preview.replace(/\\/g, "/").replace(/^.*\//, "");
const source = join(abs(release.metaRel), name);
if (await exists(source)) await copyFileTo(source, join(outDir, name));
else warnings.push(`${release.dir}: project.json 声明了 preview=${release.meta.preview},但 ${release.metaRel}/${name} 不存在`);
}
// 2) 运行时脚本(tsc 已把 src/runtime/*.ts 编到 build/scripts/)
for (const name of [
"index.js",
"preset-controller.js",
"background-controller.js",
"spine-controller.js",
"scene-controller.js",
"audio-controller.js",
"viewport-fitter.js",
]) {
const from = abs(`build/scripts/${name}`);
if (!(await exists(from))) {
throw new VaultError(`缺少 ${from}。请先跑 \`pnpm typecheck\` 之外的编译:tsc -p tsconfig.runtime.json(pnpm build 的 npm script 已包含)`);
}
await copyFileTo(from, join(outDir, "scripts", name));
}
if (ctx.withSimulator) {
const simulator = abs("build/scripts/wallpaper-engine.js");
if (await exists(simulator)) await copyFileTo(simulator, join(outDir, "scripts", "wallpaper-engine.js"));
else warnings.push("--with-sim 已指定,但 build/scripts/wallpaper-engine.js 不存在(先跑 pnpm build:sim)");
}
// 3) 预设索引(每分发现算,见 generatePresetIndex 注释)
await writeText(join(outDir, "scripts", "presets.js"), generatePresetIndex(release, ctx.defaultPresetId));
// 4) 音频落点必须在生成 preset.js **之前**算出来:路径是按实际落点现算的,
// 先写 preset.js 再决定音频放哪儿,会出现"语法正确、指向空气"的产物。
// 落点算法本身只有一份(generate.ts 的 planAudio),这里只负责把文件搬过去。
const isCollection = release.type !== "single";
if (isCollection) {
const placed = await copyCollectionAudio(release, outDir);
if (placed.names.length === 0) warnings.push(`${release.dir}: 合集根 audios/ 为空(各壁纸与游戏都没有声明共享音频)`);
}
// 5) 每档壁纸的 preset.js + 资源
for (const wallpaper of release.wallpapers) {
const sub = releasePathOf(release, wallpaper);
const destDir = sub ? join(outDir, sub) : outDir;
await writeText(join(destDir, "preset.js"), generatePresetModule(release, wallpaper, release.sharedAudio));
await copyWallpaperAssets(wallpaper, destDir, { skipAudio: isCollection });
}
// 6) 可选的**自包含包**:与发布产物并列放在 sim/ 下,互不干扰。
// 它必须在资源全部落地之后才能生成——内联的是刚铺好的那些文件。
let simBytes = 0;
if (options.standalone) {
const { buildSimPage } = await import("./lib/bundle.ts");
const sim = await buildSimPage(release, ctx.defaultPresetId, { embedAudio: options.embedAudio ?? true });
const simDir = join(outDir, "sim");
await writeText(join(simDir, "index.html"), sim.html);
simBytes = await dirBytes(simDir);
log(
` └ sim/index.html ${mb(simBytes)} 内联 ${sim.inlined} 项资源` +
(sim.skippedAudio.length > 0 ? `(${sim.skippedAudio.length} 个音频未内联,file:// 下不可播)` : ""),
);
if (sim.skippedAudio.length > 0) {
warnings.push(`${release.dir}: --no-embed-audio,${sim.skippedAudio.length} 个音频未内联,双击打开时选不到它们`);
}
}
return { bytes: await dirBytesExcludingSim(outDir), simBytes };
}
/**
* 分发体积:**不含** `sim/`。
*
* 自包含包里的图片/音频是 base64 内联,体积与发布产物不在一个量级;把它算进"上传体积"
* 会让人误判发布产物的实际大小。两笔分开报。
*/
async function dirBytesExcludingSim(root: string): Promise<number> {
const { readdir, stat } = await import("node:fs/promises");
let total = 0;
for (const entry of await readdir(root, { withFileTypes: true })) {
if (entry.name === "sim") continue;
const full = join(root, entry.name);
if (entry.isDirectory()) total += await dirBytesExcludingSim(full);
else total += (await stat(full)).size;
}
return total;
}
async function main(): Promise<void> {
const started = Date.now();
const cli = parseArgs(process.argv.slice(2));
const { options, withSimulator, standalone } = cli;
options.standalone = standalone;
options.embedAudio = cli.embedAudio;
const version = (await readFile(abs("VERSION"), "utf8")).trim();
if (!/^\d+$/.test(version)) throw new VaultError(`VERSION 必须是单个整数,当前是 "${version}"`);
const vault = await readVault();
const template = await readJson<ProjectTemplate>(abs("src/project.template.json"));
const releases = planReleases(vault, options);
log(`SpineWallpaper build version=${version} releases=${releases.length}${withSimulator ? " (with simulator)" : ""}${standalone ? " (standalone sim)" : ""}`);
log(` 壁纸:${vault.wallpapers.map((w) => `${w.id}(${w.meta.name})`).join("、")}`);
log(` 游戏:${vault.games.map((g) => `${g.id}(${g.meta.name})`).join("、")}`);
log("");
const warnings: string[] = [];
const wantedDefault = vault.global.defaultPresetId;
if (wantedDefault !== undefined && !vault.wallpapers.some((w) => w.id === wantedDefault)) {
throw new VaultError(
`wallpapers/meta.json 的 defaultPresetId "${wantedDefault}" 不是任何壁纸的 id(已有:${vault.wallpapers
.map((w) => w.id)
.join("、")})`,
);
}
/** 某档分发的默认预设:单档恒等于它自己;合集用全局声明,若该合集里没有就退回第一档。 */
const defaultPresetIdFor = (release: Release): string => {
if (release.type === "single") return release.wallpapers[0]?.id ?? "";
if (wantedDefault && release.wallpapers.some((w) => w.id === wantedDefault)) return wantedDefault;
return release.wallpapers[0]?.id ?? "";
};
const ctx = { template, version, outDir: abs(RELEASES_DIR), withSimulator } satisfies Omit<
GenerateContext,
"defaultPresetId"
>;
const mapEntries: DistMap["releases"] = [];
for (const release of releases) {
const defaultPresetId = defaultPresetIdFor(release);
// 只有一档的合集拿不到 preset 下拉(generateProjectJson 按档数决定),产物行为与单档分发相同。
// 发不发这一档是发布级的判断,构建不替人决定,但必须让人看见——"合集"名不副实是真实的踩坑点。
if (release.type === "collection" && release.wallpapers.length < 2) {
warnings.push(
`${release.dir}: 合集只收 ${release.wallpapers.length} 档壁纸,产物里不会有 preset 下拉(行为等同单档);要么补壁纸,要么不发这一档`,
);
}
const sizes = await buildRelease(release, { ...ctx, defaultPresetId }, options, warnings);
mapEntries.push({
dir: release.dir,
type: release.type,
// 合集范围(单档没有):只有它才区分「游戏合集」与「全部合集」。
...(release.scope ? { scope: release.scope } : {}),
id: release.id,
displayName: release.displayName,
defaultPresetId,
wallpapers: release.wallpapers.map((wallpaper) => ({
id: wallpaper.id,
gameId: wallpaper.gameId,
dir: releasePathOf(release, wallpaper),
name: wallpaper.meta.name,
})),
bytes: sizes.bytes,
...(sizes.simBytes > 0 ? { simBytes: sizes.simBytes } : {}),
});
log(` ✓ ${release.dir.padEnd(24)} ${release.wallpapers.length} 档壁纸 ${mb(sizes.bytes)}`);
}
if (!options.dryRun) {
if (options.clean) {
const built = new Set(mapEntries.map((e) => e.dir));
for (const dir of await listDirs(abs(RELEASES_DIR))) {
if (!built.has(dir)) {
await rm(join(abs(RELEASES_DIR), dir), { recursive: true, force: true });
log(` − 删除孤立的旧分发目录 ${dir}`);
}
}
} else {
for (const dir of await listDirs(abs(RELEASES_DIR))) {
if (!mapEntries.some((e) => e.dir === dir)) {
warnings.push(`dist/releases/${dir} 不是本次构建的产物(--no-clean),dist-map.json 里没有它`);
}
}
}
const map: DistMap = { version, builtAt: new Date().toISOString(), releases: mapEntries };
await writeJson(abs("dist/dist-map.json"), map);
await checkDist({ strict: options.strict });
}
log("");
for (const warning of warnings) log(` ⚠ ${warning}`);
if (options.strict && warnings.length > 0) throw new VaultError(`--strict:出现 ${warnings.length} 条警告,视为失败`);
log(`完成,用时 ${((Date.now() - started) / 1000).toFixed(1)}s`);
}
try {
await main();
} catch (error) {
if (error instanceof VaultError) {
console.error(`\n构建失败:${error.message}\n`);
process.exit(1);
}
throw error;
}
+39
View File
@@ -0,0 +1,39 @@
// `tools/cdp.mjs` 的类型声明。
//
// 它是纯 JS,以前没有任何声明——于是每个 import 它的验收脚本都退化成 `any`,
// 连带 `withPage(..., ({ send, evaluate }) => ...)` 的解构参数全部是隐式 any。
// 一个 20 行的声明就能把那一整类错误消掉(实测 180 处里占 41 处)。
//
// 这里刻意用 `any` 而不是 `unknown` 作为 evaluate/send 的返回值:它们直通 CDP,
// 返回什么完全取决于传进去的表达式字符串,静态上无从约束——写成 `unknown` 只会逼每个
// 调用点加断言,反而把噪音搬了个地方。
export interface PageOptions {
width?: number;
height?: number;
/** Edge 的 user-data-dir;给不同脚本各用一个,免得并发时互相踢掉。 */
profile?: string;
extraArgs?: string[];
}
/** 一个已连上的页面。 */
export interface Page {
/** 发一条 CDP 命令。 */
send(method: string, params?: Record<string, unknown>): Promise<any>;
/** 在页面里求值并取回值。表达式里的异常会抛出来。 */
evaluate(expression: string): Promise<any>;
/** 页面控制台输出(含 Log.entryAdded 的 error)。 */
consoleLines: string[];
/** 未捕获异常。 */
exceptions: string[];
/** Edge 的 stderr,排查启动失败时用。 */
stderr(): string;
}
/**
* 起一个无头 Edge、连上它、执行 `fn`,结束后收掉浏览器。
* `fn` 的返回值原样返回。
*/
export function withPage<T>(opts: PageOptions, fn: (page: Page) => Promise<T>): Promise<T>;
export function sleep(ms: number): Promise<void>;
+332
View File
@@ -0,0 +1,332 @@
// pnpm check:dist —— 分发目录的自我包含性门禁。
//
// A2 的要求是"分发目录应避免跨目录相对链接与绝对链接"(它能被整体拷到别的机器上传)。
// 这里把它变成硬门禁:扫描每个分发目录里的 HTML/CSS/JS,把每一处**静态可解析**的引用
// 解析出来,凡是落到分发目录之外的、或写成根绝对路径(/…)与 file:/// 的,一律 fail。
//
// 不做的事:不去猜 `asset()` 这类运行时推导(那是生成器的责任,生成器只吐相对路径)。
import { readFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { abs, exists, listDirs, log, readJson, walkFiles } from "./lib/fs.ts";
import { VaultError } from "./lib/vault.ts";
import type { DistMap } from "./lib/types.ts";
export interface CheckResult {
releases: number;
files: number;
references: number;
problems: string[];
warnings: string[];
}
/** 允许出现的非本地 URL 协议与内联数据。 */
const ALLOWED_PROTOCOL = /^(data:|blob:|mailto:|https?:\/\/|steam:|#)/i;
interface Reference {
/** 引用写下的原始文本。 */
raw: string;
/** 引用的文件(相对分发根)。 */
file: string;
/** 行号(1 起)。 */
line: number;
}
/** 从一份文本里抽出所有静态可解析的本地引用。 */
function extractReferences(file: string, text: string): Reference[] {
const out: Reference[] = [];
const push = (raw: string, line: number): void => {
if (!raw) return;
const normalized = raw.trim();
if (ALLOWED_PROTOCOL.test(normalized)) return;
out.push({ raw: normalized, file, line });
};
text.split(/\r?\n/).forEach((line, index) => {
const lineNo = index + 1;
const isCss = /\.css$/i.test(file);
// HTML/JS 里的字符串形态:src="/x"、href='y'、from "./z"、import("w")、fetch("v")
for (const match of line.matchAll(/(?:src|href|poster)\s*=\s*["']([^"']+)["']/g)) push(match[1] ?? "", lineNo);
for (const match of line.matchAll(/(?:from|import|fetch)\s*\(?\s*["']([^"']+)["']/g)) push(match[1] ?? "", lineNo);
// url(...) 只在 CSS 里是路径;JS 里 `url("${x}")` 是模板字符串,不是静态引用
if (isCss) {
for (const match of line.matchAll(/url\(\s*["']?([^"')]+)["']?\s*\)/g)) push(match[1] ?? "", lineNo);
for (const match of line.matchAll(/@import\s+["']([^"']+)["']/g)) push(match[1] ?? "", lineNo);
}
});
return out;
}
/** 解析一个引用:返回它相对分发根的路径,或一个错误原因。 */
function resolveReference(raw: string, file: string): { rel: string } | { error: string } {
if (raw.startsWith("file:///")) return { error: `绝对链接 file:///(分发目录必须可整体搬走)` };
if (raw.startsWith("//")) return { error: `协议相对链接 //(依赖宿主,跨机器行为不定)` };
if (raw.startsWith("/")) return { error: `根绝对路径 /(脱离分发根就没有意义)` };
const clean = raw.split(/[?#]/)[0] ?? "";
const parts = (file.includes("/") ? file.replace(/\/[^/]*$/, "/") : "") + clean;
const segments: string[] = [];
for (const segment of parts.split("/")) {
if (segment === "" || segment === ".") continue;
if (segment === "..") {
if (segments.length === 0) return { error: `相对路径越出分发目录(${raw})` };
segments.pop();
continue;
}
segments.push(segment);
}
return { rel: segments.join("/") };
}
/** 从 preset.js 里抽出所有声明的音频落点(相对该 preset.js 所在目录)。 */
function extractAudioSources(text: string): { raw: string; line: number }[] {
const out: { raw: string; line: number }[] = [];
text.split(/\r?\n/).forEach((line, index) => {
for (const match of line.matchAll(/source:\s*asset\(\s*"([^"]*)"\s*\)/g)) {
out.push({ raw: match[1] ?? "", line: index + 1 });
}
});
return out;
}
/**
* 让 V8 真的解析一个产物模块,只求语法通过、不执行。
*
* 为什么非要有这一步:`presets.js` 与 `preset.js` 是**生成器拼出来的裸 JS 文本**,
* tsc 完全不看它们。生成器写出非法语法时,前面所有检查(引用扫描、路径存在性)都会通过,
* 直到浏览器里爆 SyntaxError 白屏——实测过一次真事故(数组字面量里写了计算属性名)。
*
* 用 vm.SourceTextModule 而不是 dynamic import:后者会**执行**模块,而壁纸脚本依赖 DOM。
* 用子进程是因为这个 API 需要 --experimental-vm-modules(V8 的解析器只暴露到这里)。
*/
async function checkModuleSyntax(files: { path: string; text: string }[]): Promise<string[]> {
const { spawnSync } = await import("node:child_process");
const script = `
const vm = require("node:vm");
const payload = JSON.parse(require("node:fs").readFileSync(0, "utf8"));
const problems = [];
for (const item of payload) {
try {
new vm.SourceTextModule(item.text, { identifier: item.path });
} catch (error) {
problems.push(item.path + " " + String(error.message).split("\\n")[0]);
}
}
process.stdout.write(JSON.stringify(problems));
`;
const child = spawnSync(process.execPath, ["--experimental-vm-modules", "--no-warnings", "-e", script], {
input: JSON.stringify(files),
encoding: "utf8",
});
if (child.status !== 0) {
return [`无法运行语法解析子进程:${(child.stderr || "").trim().split("\n").slice(-3).join(" ")}`];
}
try {
return JSON.parse(child.stdout) as string[];
} catch {
return [`语法解析子进程输出无法解析:${child.stdout.slice(0, 200)}`];
}
}
export async function checkDist(options: { strict?: boolean; quiet?: boolean } = {}): Promise<CheckResult> {
const releasesDir = abs("dist/releases");
const mapPath = abs("dist/dist-map.json");
const result: CheckResult = { releases: 0, files: 0, references: 0, problems: [], warnings: [] };
if (!(await exists(mapPath))) {
result.problems.push("dist/dist-map.json 不存在(先跑 pnpm build)");
if (!options.quiet) report(result);
return result;
}
const map = await readJson<DistMap>(mapPath);
const modules: { path: string; text: string }[] = [];
for (const dir of await listDirs(releasesDir)) {
const root = join(releasesDir, dir);
if (!map.releases.some((r) => r.dir === dir)) {
result.problems.push(`dist/releases/${dir} 不在 dist-map.json 里(残留目录)`);
continue;
}
result.releases += 1;
const entry = map.releases.find((r) => r.dir === dir);
if (entry) {
// project.json 的静态字段必须指向目录内真实存在的文件
const project = await readJson<{ preview?: string; file?: string }>(join(root, "project.json"));
const fileField = project.file ?? "index.html";
if (!(await exists(join(root, fileField)))) result.problems.push(`${dir}/project.json 的 file 指向不存在的 ${fileField}`);
if (project.preview !== undefined && !(await exists(join(root, project.preview)))) {
result.problems.push(`${dir}/project.json 的 preview 指向不存在的 ${project.preview}`);
}
if (entry.defaultPresetId.length === 0) result.problems.push(`${dir}: dist-map 里没有 defaultPresetId`);
// 运行时脚本必须齐全(少了任何一个都会在 WE 里静默白屏)
for (const name of [
"scripts/index.js",
"scripts/presets.js",
"scripts/spine-player.js",
"scripts/preset-controller.js",
"scripts/background-controller.js",
"scripts/spine-controller.js",
"scripts/audio-controller.js",
"scripts/viewport-fitter.js",
"styles/index.css",
"styles/spine-player.css",
"index.html",
]) {
if (!(await exists(join(root, name)))) result.problems.push(`${dir}: 缺少 ${name}`);
}
// 调试服的热更新客户端**绝不能**进产物:它连的是 /__dev/events,那在真实 WE 里、
// 在任何静态托管上都不存在,只会白挨一次连接失败。这类"调试期专有代码混进发布产物"
// 靠人工抽查迟早会漏,所以钉在门禁上。
const indexText = await readFile(join(root, "index.html"), "utf8");
for (const marker of ["/__dev/events", "dev-reload-pill"]) {
if (indexText.includes(marker)) {
result.problems.push(`${dir}/index.html 里有调试服专属的 ${marker}`);
}
}
// 每档壁纸的 preset.js 必须与 dist-map 声明一致
for (const wallpaper of entry.wallpapers) {
const presetPath = wallpaper.dir ? `${wallpaper.dir}/preset.js` : "preset.js";
if (!(await exists(join(root, presetPath)))) result.problems.push(`${dir}: 缺少 ${presetPath}`);
}
const presetModules = (await walkFiles(root)).filter((f) => /(^|\/)preset\.js$/.test(f));
if (presetModules.length !== entry.wallpapers.length) {
result.problems.push(
`${dir}: preset.js 数量(${presetModules.length}) 与 dist-map 声明的壁纸数(${entry.wallpapers.length}) 不一致`,
);
}
// 语义校验:preset.js 里声明的每个音频落点都必须真实存在。
//
// 静态引用扫描(上面那圈)抓不到这类缺陷:音频走的是 `asset("../audios/x.flac")`,
// 参数是字面量,但 `asset` 的基准是 preset.js 自己的目录,只有按 preset.js 的位置解析才
// 知道对不对。合集分发把音频搬到分发根,前缀算错一层就会得到一个"语法正确、路径错误"的
// preset.js —— 它会一路通过编译、通过引用扫描,然后在 WE 里静音。
for (const presetRel of presetModules) {
const presetText = await readFile(join(root, presetRel), "utf8");
for (const audio of extractAudioSources(presetText)) {
if (audio.raw === "") continue; // 没有默认音源时是空串,跳过
const resolved = resolveReference(audio.raw, presetRel);
if ("error" in resolved) {
result.problems.push(`${dir}/${presetRel}:${audio.line} 音频 ${resolved.error}`);
continue;
}
if (!(await exists(join(root, resolved.rel)))) {
result.problems.push(`${dir}/${presetRel}:${audio.line} 音频引用不存在的 ${audio.raw}`);
}
}
}
// 单档分发不该出现共享音频目录(音频本来就只属于它一个)。
if (entry.type === "single" && (await exists(join(root, "audios", "_shared")))) {
result.problems.push(`${dir}: 单档分发里不该有 audios/_shared`);
}
// 合集类分发必须有合集根 audios/(bgm 要能选到任一壁纸的音源)——
// 但**整份分发一个音源都没有**时不该要求它:那只会逼出一个空的 audios/ 目录
// (比如纯场景壁纸、音源还没抓的页面)。判据是 preset.js 里有没有音频落点。
const hasAudio = (
await Promise.all(
presetModules.map(async (presetRel) => /[/\\]audios[/\\]/.test(await readFile(join(root, presetRel), "utf8"))),
)
).some(Boolean);
if (entry.type !== "single" && hasAudio && !(await exists(join(root, "audios")))) {
result.problems.push(`${dir}: 合集分发缺少合集根 audios/`);
}
// 自包含包(`pnpm build sim` 产出):它必须在 `file://` 下**双击就能开**,
// 所以这里查的是"页面上还有没有任何外部依赖"。
//
// 只在**真正的标签与赋值**上判定,不在整页文本上做正则——spine-player 是整份内联进来的,
// 它内部带着官网文档链接与一段编辑器示例模板(里面有 `<script src="https://…">` 的字符串)。
// 第一版对整页扫 `src=`/`href=`,于是把那些字符串当成真依赖,四个分发全报假阳性。
const simPage = join(root, "sim", "index.html");
if (await exists(simPage)) {
const simText = await readFile(simPage, "utf8");
const simProblems: string[] = [];
// 先剥掉内联脚本的**内容**,只在剩下的 HTML 骨架里找标签。
//
// 必须这么做:spine-player 是整份内联进来的,它内部带着一段编辑器示例模板,
// 里面有 `<script src="https://…">` 这样的字符串。对整页正则扫标签会把它们当成真依赖,
// 四个分发全报假阳性(实测过)。
//
// 关键细节:**只删标签之间的内容,保留开标签本身**。
// 第一版写成 `/<script[^>]*>[\s\S]*?<\/script>/` 整体替换,把开标签也一起删了,
// 于是所有 `<script src=…>` 都不再被检查——门禁看着在跑,实际只能查到 `<link>`。
// 是"注入一个外链 script 看它拦不拦得住"这个测试把这个漏洞暴露出来的。
const skeleton = simText.replace(
/<(script)\b([^>]*)>[\s\S]*?<\/script>/gi,
(_match, name: string, attrs: string) => `<${name}${attrs}></${name}>`,
);
const tags = skeleton.match(/<(?:script|link)\b[^>]*>/gi) ?? [];
for (const tag of tags) {
if (/\btype\s*=\s*["']module["']/i.test(tag)) {
simProblems.push(`<script type="module">(file:// 下不会加载)`);
}
for (const match of tag.matchAll(/\b(?:src|href)\s*=\s*["']([^"']+)["']/gi)) {
const raw = match[1] ?? "";
if (/^https?:\/\//i.test(raw)) simProblems.push(`标签引用了 http(s) 资源 ${raw}`);
else if (raw.startsWith("file:///")) simProblems.push(`标签引用了绝对路径 ${raw}`);
else if (raw.startsWith("/")) simProblems.push(`标签引用了根绝对路径 ${raw}`);
}
}
if (simProblems.length > 0) {
result.problems.push(`${dir}/sim/index.html: ${[...new Set(simProblems)].slice(0, 3).join(";")}`);
}
}
}
for (const rel of await walkFiles(root)) {
if (!/\.(html|css|js|mjs|json)$/i.test(rel)) continue;
result.files += 1;
const text = await readFile(join(root, rel), "utf8");
if (/\.(js|mjs)$/i.test(rel)) modules.push({ path: `${dir}/${rel}`, text });
for (const reference of extractReferences(rel, text)) {
result.references += 1;
const resolved = resolveReference(reference.raw, reference.file);
if ("error" in resolved) {
result.problems.push(`${dir}/${reference.file}:${reference.line} ${resolved.error}`);
continue;
}
if (!(await exists(join(root, resolved.rel)))) {
// 生成器只保证字面路径可解析;这里对"解析后不存在"的文件报错。
result.problems.push(`${dir}/${reference.file}:${reference.line} 引用不存在的 ${reference.raw}`);
}
}
}
}
// 语法门禁:所有产物 JS 必须能被 V8 解析成模块。
for (const problem of await checkModuleSyntax(modules)) result.problems.push(problem);
if (!options.quiet) report(result);
return result;
}
function report(result: CheckResult): void {
log(
`check:dist ${result.releases} 个分发 / ${result.files} 个文本文件 / ${result.references} 处引用 ` +
`${result.problems.length === 0 ? "✓ 自包含" : `✗ ${result.problems.length} 处问题`}`,
);
for (const problem of result.problems.slice(0, 30)) log(` ✗ ${problem}`);
if (result.problems.length > 30) log(` … 另有 ${result.problems.length - 30} 处`);
for (const warning of result.warnings) log(` ⚠ ${warning}`);
}
// 只在被当作入口直接运行时才自检——build.ts 会 import 它,那时不能执行这里的退出逻辑。
if (process.argv[1] !== undefined && resolve(process.argv[1]) === abs("tools/check-dist.ts")) {
const strict = process.argv.includes("--strict");
const result = await checkDist({ strict });
if (result.problems.length > 0) {
log("");
throw new VaultError(`自包含性校验未通过(${result.problems.length} 处)`);
}
log("");
log("自包含性校验通过:每个分发目录都可以整体拷到别的机器上传。");
}
-49
View File
@@ -1,49 +0,0 @@
// 静态检查:每份 preset.js 里声明的每一个资源路径是否真实存在。
// 这能抓到"运行时才会暴露"的拼写错误(比如某首备用 BGM 从未被加载过,写错了也不会有人发现)。
// 用法:node tools/check-paths.mjs
import { stat } from "node:fs/promises";
import { fileURLToPath, pathToFileURL } from "node:url";
import { glob } from "node:fs/promises";
import { resolve } from "node:path";
const root = resolve("dist");
const presetFiles = [];
for await (const f of glob("assets/**/preset.js", { cwd: root })) presetFiles.push(f);
let ok = 0;
let bad = 0;
for (const rel of presetFiles.sort()) {
const mod = await import(pathToFileURL(resolve(root, rel)).href);
const preset = mod.default;
const urls = [];
const walk = (value, path) => {
if (typeof value === "string" && /^(file|https?):/.test(value)) urls.push([path, value]);
else if (value && typeof value === "object") {
for (const [k, v] of Object.entries(value)) walk(v, `${path}.${k}`);
} else if (Array.isArray(value)) value.forEach((v, i) => walk(v, `${path}[${i}]`));
};
walk(preset, preset.id);
console.log(`\n${rel} (id=${preset.id}, name=${preset.name})`);
for (const [path, url] of urls) {
const u = new URL(url);
if (u.protocol !== "file:") {
console.log(` ? ${path} -> 非本地路径,跳过: ${url}`);
continue;
}
const file = fileURLToPath(u);
const info = await stat(file).catch(() => null);
if (info?.isFile()) {
console.log(` ok ${path} (${(info.size / 1024).toFixed(1)} KB)`);
ok++;
} else {
console.log(` MISSING ${path} -> ${file}`);
bad++;
}
}
}
console.log(`\n${ok} 个路径存在,${bad} 个缺失`);
process.exit(bad ? 1 : 0);
+89
View File
@@ -0,0 +1,89 @@
// pnpm check:paths —— 源数据的**孤儿文件**门禁(构建前跑,只看 wallpapers/,不看 dist)。
//
// 与 check:dist 的分工:
// check:paths 源数据自洽:有没有磁盘上存在、但没有任何 meta.json 声明的资源?
// check:dist 产物自洽:dist/releases/ 里每个引用是否真的落地、是否越界、JS 语法是否合法。
//
// 为什么单独立这一条:音频文件名**保留中文**(产品要求),而 id 是 build 自增分配的,
// 两者靠 meta.json 的 choices 显式绑定——这正是"地图漏画一格"最容易发生的地方。一个没被声明的 .flac 会安安静静
// 躺在仓库里:类型检查过、构建过、产物自包含,只是那首歌永远选不到,而且没人会发现。
// 依赖删除同理:改动后残留的 `foo-旧版.flac` 会一直跟着构建进分发,白白撑大上传体积。
//
// 用法:node tools/check-paths.ts
import { join, resolve } from "node:path";
import { abs, isDir, log, walkFiles } from "./lib/fs.ts";
import { readVault, VaultError } from "./lib/vault.ts";
import { baseName } from "./lib/generate.ts";
const problems: string[] = [];
let audioChecked = 0;
/** 反查一个目录下的音频文件是否都被 `choices` 认领。 */
async function checkOrphans(opts: {
dirRel: string;
choices: { file: string }[];
where: string;
/** 音频目录相对声明者所在目录(壁纸就填 "audios")。 */
subdir: string;
}): Promise<void> {
const declared = new Set(opts.choices.map((c) => baseName(c.file)));
audioChecked += declared.size;
const dirAbs = abs(join(opts.dirRel, opts.subdir));
if (!(await isDir(dirAbs))) {
if (declared.size > 0) problems.push(`${opts.where}: 声明了 ${declared.size} 个音源,但 ${join(opts.dirRel, opts.subdir)} 目录不存在`);
return;
}
for (const name of await walkFiles(dirAbs)) {
// 允许子目录嵌套:只比较相对路径末段,与实际取用方式一致。
const leaf = name.split("/").pop() ?? name;
if (declared.has(leaf)) continue;
problems.push(
`${join(opts.dirRel, opts.subdir, name)} 没有被 ${opts.where} 声明——` +
`它是孤儿文件:既不会被 WE 的 bgm 下拉选中,也不会进任何分发的音频清单。` +
`删掉它,或者把它加进 meta.json。`,
);
}
}
async function main(): Promise<void> {
const vault = await readVault();
// 1) 每档壁纸自己的 audios/
for (const wallpaper of vault.wallpapers) {
await checkOrphans({
dirRel: wallpaper.srcRel,
choices: wallpaper.meta.audio.choices,
where: `${wallpaper.srcRel}/meta.json`,
subdir: "audios",
});
}
// 2) 每个游戏的共享音频(wallpapers/<游戏id>/audios/),只被合集分发使用
for (const game of vault.games) {
await checkOrphans({
dirRel: game.srcRel,
choices: game.meta.audios ?? [],
where: `${game.srcRel}/meta.json 的 audios`,
subdir: "audios",
});
}
// 3) 分发级预览图(wallpapers/preview.gif,被所有分发共用)
if (vault.global.preview !== undefined) {
const previewRel = join("wallpapers", baseName(vault.global.preview));
if (await isDir(abs(previewRel))) problems.push(`wallpapers/meta.json 的 preview "${vault.global.preview}" 指向的是一个目录`);
}
if (problems.length > 0) {
log(`check:paths ✗ ${problems.length} 处问题(已核对 ${audioChecked} 条音源声明)`);
for (const problem of problems) log(` ✗ ${problem}`);
throw new VaultError(`源数据里有 ${problems.length} 处音频声明问题`);
}
log(`check:paths ✓ ${vault.wallpapers.length} 档壁纸 / ${vault.games.length} 个游戏,${audioChecked} 条音源声明无孤儿文件`);
}
// 只在被当作入口直接运行时才自检(被 import 时不该有副作用)。
if (process.argv[1] !== undefined && resolve(process.argv[1]) === abs("tools/check-paths.ts")) {
await main();
}
+73
View File
@@ -0,0 +1,73 @@
import { readFile } from "node:fs/promises";
import { abs, log, walkFiles } from "./lib/fs.ts";
import { VaultError } from "./lib/vault.ts";
// pnpm check:syntax —— 把"源码必须 strip-safe"从约定升级成门禁。
//
// Node 26 能直接跑 .ts,但它只做**类型擦除**:enum、构造函数参数属性、namespace 会以
// ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX 崩掉(已实测)。这类语法在 tsc 下完全合法,所以只有真实加载
// 才发现得了——而那通常是 `pnpm dev` 已经跑起来之后。这里做一次静态扫描,提前挡在门口。
//
// 刻意**不做**动态 import 验证:那些入口脚本一被执行就有副作用(起服务、写 dist)。
const BANNED: { pattern: RegExp; what: string; why: string }[] = [
{
pattern: /^[ \t]*(?:export[ \t]+)?(?:const[ \t]+)?enum[ \t]/m,
what: "enum",
why: "类型擦除模式下没有运行时实现,Node 直接报错",
},
{
pattern: /^[ \t]*(?:export[ \t]+)?(?:declare[ \t]+)?namespace[ \t]/m,
what: "namespace",
why: "同上,且它是过时的模块写法",
},
{
pattern: /constructor[ \t]*\([^)]*\b(?:public|private|protected|readonly)[ \t]+\w+/s,
what: "构造函数参数属性(constructor(public x: T))",
why: "需要编译器注入赋值语句,擦除模式做不到",
},
{ pattern: /\bimport[ \t]+\w+[ \t]*=[ \t]*require[ \t]*\(/, what: "import x = require()", why: "TS 特有语法,非 ESM" },
];
/** 扫描范围:所有会被 Node 直接执行的 .ts(运行时脚本经 tsc 编译,不受此限制)。 */
async function sourceFiles(): Promise<string[]> {
const files: string[] = [];
for (const dir of ["tools", "src/simulator"]) {
for (const file of await walkFiles(abs(dir))) {
if (file.endsWith(".ts")) files.push(`${dir}/${file}`);
}
}
return files.sort();
}
/**
* 先剥掉注释与字符串/模板字面量再匹配。
*
* 这不是洁癖:本文件自己就用正则写着这些被禁语法的样子,若直接扫全文会自我举报。
* 同理,任何文档或消息文本里出现 "enum " 也不该被当成代码。
*/
function stripCommentsAndLiterals(text: string): string {
return text
.replace(/\/\*[\s\S]*?\*\//g, " ")
.replace(/\/\/[^\n]*/g, "")
.replace(/`(?:\\[\s\S]|[^`\\])*`/g, '""')
.replace(/'(?:\\[\s\S]|[^'\\\n])*'/g, '""')
.replace(/"(?:\\[\s\S]|[^"\\\n])*"/g, '""');
}
const problems: string[] = [];
for (const relPath of await sourceFiles()) {
const text = stripCommentsAndLiterals(await readFile(abs(relPath), "utf8"));
for (const { pattern, what, why } of BANNED) {
const match = pattern.exec(text);
if (!match) continue;
problems.push(`${relPath} 用了 ${what} —— ${why}`);
}
}
if (problems.length > 0) {
log("check:syntax ✗");
for (const problem of problems) log(` ✗ ${problem}`);
throw new VaultError(`源码里有 ${problems.length} 处 Node 类型擦除不支持的语法`);
}
log("check:syntax ✓ 所有会被 Node 直接执行的 .ts 都是 strip-safe");
+100
View File
@@ -0,0 +1,100 @@
# `tools/checks/` — 验收套件
跑一遍就相当于"这次改动没把东西弄坏"的全部证据。它原来是散在 `.scratch/` 里的,
而 `.scratch/` 按 `AGENTS.md` 是**问题与规格**的存放处——这些脚本不是某条 issue 的产物,
issue 关掉之后还要一直跑,所以搬到了这里。
---
## 怎么跑
它们的前置条件不一样,**不能一条命令全跑完**。按下面三组来:
### ① 不需要服务器
```bash
pnpm check # 类型 + 构建 + 语法 + 路径 + 产物自包含 + 游戏合集文案归属
pnpm build sim # 下面几项要 sim 产物
node tools/checks/test-sim-gate.mts # 门禁真的会拦(往页面注入四种外部依赖)
node tools/checks/test-inline-guard.mts # 内联 </script 守卫真的会拦
node tools/checks/test-transform-module.mts # 模块合成的 export 改写
node tools/checks/diff-project.mts # 产物 project.json vs 已发布基线
node tools/checks/check-collection-copy.mts # 游戏合集的文案是"它自己那个游戏"的(先 pnpm build)
node tools/checks/verify-sim-page.mts <分发名> # 自包含包 file:// 可用(含"零 http 请求")
node tools/checks/verify-static-preview.mts <分发名> # 静态托管子路径可用(自带服务器)
node tools/checks/read-audio-tags.mts # 读音频文件自带标签(核对歌名用)
node tools/checks/check-sim-scripts.mts [页面] # 抽出自包含页的每个内联 script 单独语法检查
node tools/checks/check-downloader.mts # 抓取器:能编译 + 夹具绿 + 四处破坏必红(不联网)
node tools/checks/verify-scene-player.mts # 场景播放器:多骨架+平面合成、画面非空、atlas 指错必红
```
`verify-scene-player` 自己跑 `tsc -p tsconfig.runtime.json` 并自造夹具(用抓取器 staged 的
`kv45/scene_main` 与 `nico-tea/scene_main`——staging 是"一个场景一个壁纸目录"),
**不碰 `wallpapers/`、不需要 `pnpm build`**;前置只有 `python -m tools.downloader fetch --page kv45`。
`verify-static-preview` 需要 `pnpm build --with-sim`(面板要随产物走)。
### ② 需要 `serve.mjs`(对拍驱动 `window.__test` 只有它注入)
> ⚠ **跑 `test-inline-guard` 之前必须先停掉 `pnpm dev`。** 它会故意把 `src/vendor/spine-player.js`
> 改坏来验证守卫,而调试服的热更新正在监听 `src/`——两者会抢构建,结果随机失败。
> 同理,任何会写 `dist/` 的构建都别和 watcher 同时跑(`pnpm build sim` 与 watcher 也会抢 `dist/`)。
```bash
node tools/serve.mjs 8190 --root dist/releases/collection-all # 另开一个终端
node tools/checks/test-props.mjs # bgm 选择/回落 + fps 限流
node tools/checks/test-resize.mjs # 改窗口尺寸不泄漏、不跑偏
node tools/checks/test-acceptance.mjs # 体积/文案/音源/构图总验收
node tools/checks/verify-fitter.mjs # viewport-fitter 的纯函数
```
### ③ 需要 `pnpm dev`(模拟器面板)
```bash
node tools/dev.ts --port 5173 --no-build # 另开一个终端
node tools/checks/smoke-dev.mts 5173 # 路由、注入、越界 404
node tools/checks/smoke-sim.mts http://127.0.0.1:5173 # 面板真的挂上、属性真的下发
node tools/checks/verify-panel.mts http://127.0.0.1:5173 # 面板还原度(12 组)
node tools/checks/verify-hot-reload.mts http://127.0.0.1:5173 # 改 CSS 只换样式表、改 runtime 整页刷新
node tools/checks/check-single-bgm.mts http://127.0.0.1:5173 # 单档分发里切歌真的换音源
node tools/checks/shot-panel.mts http://127.0.0.1:5173 [输出目录] # 给面板出图(不是断言)
```
`verify-dev-flags.mts` 要**另一个模式**:先 `node tools/build.ts --with-sim`,
再 `node tools/dev.ts --port 5173 --no-build --with-sim`,然后
`node tools/checks/verify-dev-flags.mts`。它验的是"热更新重建后 `--with-sim` 还在"。
`verify-visual-equivalence.mts` 要两步:先用 `tools/capture.mjs` 拍到
`tools/shots/visual-current/`,再跑它。基线在 `tools/shots/baseline-pre-refactor/`,
**不能重新生成**。
---
## 两层,以及为什么
| 后缀 | 是否被 `pnpm typecheck` 检查 | 说明 |
| --- | --- | --- |
| `.mts` | ✅ 走 `tsconfig.checks.json` | 后来写的验收脚本 |
| `.mjs` | ❌ | 四个早期的运行时测试:`test-acceptance` / `test-props` / `test-resize` / `verify-fitter` |
`tsconfig.checks.json` 只关掉 `noImplicitAny` 一项,其余严格模式照旧——
正是严格模式翻出了 28 处真问题(可能 undefined、`[]` 推成 `never[]`、`catch` 里 `error` 是 unknown、
参数个数不符),其中一条是"文件头声明了 ⑤ 但从未实现的断言"。
**往 `.mjs` 里写类型注解会让它运行时崩。** `.mjs` 是纯 JS,`(x: string) => …` 是 SyntaxError,
而 `tsc` 不管 `.mjs`,所以这个错在类型检查里完全看不见——踩过一次。
要写类型就先改成 `.mts`。
> 把那四个 `.mjs` 也并进受检层是一件独立的、该单独做的事:实测会立刻多出约 80 处既有问题。
> 别在别的改动的尾巴上顺手做。
---
## 约定
- **一个从不失败的检查等于没有检查。** 门禁类的脚本(`test-sim-gate` / `test-inline-guard`)
都要"先证明它会红"——注入一个坏东西,确认它报错,再还原。
- **验证要先检查前置条件。** `verify-dev-flags.mts` 第一件事是 `fetch("/")` 断言服务活着;
否则"服务没起来"会被当成"验证通过"——这是真实发生过的一次空验证。
- **区分"选项在"和"能生效"。** `verify-panel.mts` 断言面板上有那个下拉;
`check-single-bgm.mts` 断言选了之后 `<audio>.src` 真的变了。两者缺一不可。
+75
View File
@@ -0,0 +1,75 @@
// 游戏合集的文案必须是**它自己那个游戏**的——产物里不许出现别家的标题。
//
// 这条门禁来自一个真实缺陷:addGame 曾经把 title 取全局 meta(那一层是给「全部合集」写的),
// 于是 collection-ys 顶着《崩坏:星穹铁道》昔涟。三条断言都只看产物,不看实现:
// ① 前置条件:dist/releases 必须存在。否则"一个分发都没有"会被当成通过(空验证比不验证更危险)。
// ② 游戏合集的 title 必须提到本游戏的显示名,且逐字等于 wallpapers/<游戏id>/meta.json 的 title。
// ③ 全部合集 collection-all 的 title 必须来自 wallpapers/meta.json。
//
// 前置:先 `pnpm build`(只用产物与源数据,不需要服务器)。
import { existsSync, readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
const RELEASES = "dist/releases";
if (!existsSync(RELEASES)) {
console.error(`前置条件不满足:${RELEASES} 不存在。先跑 pnpm build。`);
process.exit(1);
}
let failures = 0;
const check = (ok: boolean, label: string, detail?: string): void => {
if (!ok) failures++;
console.log(` ${ok ? "✓" : "✗"} ${label}${detail !== undefined ? ` → ${detail}` : ""}`);
};
const readJson = (path: string): Record<string, unknown> =>
JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
const str = (value: unknown): string => (typeof value === "string" ? value : "");
const globalMeta = readJson("wallpapers/meta.json");
const releases = readdirSync(RELEASES).filter((name) => name.startsWith("collection-"));
let checkedGames = 0;
for (const dir of releases) {
const projectPath = join(RELEASES, dir, "project.json");
if (!existsSync(projectPath)) {
check(false, `${dir} 缺少 project.json`);
continue;
}
const project = readJson(projectPath);
const gameId = dir.slice("collection-".length);
if (gameId === "all") {
check(str(project.title) === str(globalMeta.title), "collection-all 的 title 来自全局 meta", str(project.title));
continue;
}
const gameMetaPath = `wallpapers/${gameId}/meta.json`;
if (!existsSync(gameMetaPath)) {
check(false, `${dir} 找不到对应的 ${gameMetaPath}`);
continue;
}
const gameMeta = readJson(gameMetaPath);
checkedGames++;
check(
str(project.title) === str(gameMeta.title),
`${dir} 的 title 逐字等于它自己游戏的 title`,
`产物 ${str(project.title) || "(空)"} / 源 ${str(gameMeta.title) || "(未声明)"}`,
);
check(
str(project.title).includes(str(gameMeta.name)),
`${dir} 的 title 提到了本游戏「${str(gameMeta.name)}」`,
str(project.title),
);
if (gameMeta.description !== undefined) {
check(str(project.description) === str(gameMeta.description), `${dir} 的 description 用的是游戏级声明`);
}
}
// 数量断言:目录命名一变(比如以后换成别的前缀),上面整个循环会静默变成空验证。
check(checkedGames > 0, "至少检查到一个游戏合集", `实际 ${checkedGames} 个`);
console.log(failures === 0 ? "\n游戏合集文案归属:通过" : `\n游戏合集文案归属:失败 ${failures} 处`);
process.exit(failures === 0 ? 0 : 1);
+194
View File
@@ -0,0 +1,194 @@
// 抓取器(tools/downloader/)的门:**先证明它会红**。
//
// 四件事,缺一不可:
// 1. 语法门:整个包能编译(Python 不在 `pnpm check` 的任何一道门里,这是它唯一的机会)
// 2. 绿:自造一个形状正确的产物夹具 → `verify` 必须退出 0
// 3. 红:把夹具弄坏几处 → `verify` 必须退出非 0(点名的必须是**刚弄坏的那处**)
// 4. 还原:必须重新变绿(证明红是那处改动引起的,不是夹具本身坏的)
//
// 夹具是自造的,不依赖 `_out/`(那是 gitignore 的 staging,别人机器上不一定有),也不联网。
// 形状 = 「一个场景一个可直接搬走的壁纸目录」(见 tools/downloader/layout.py)。
import { spawnSync } from "node:child_process";
import { mkdirSync, rmSync, writeFileSync, readFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
const PY = process.env.PYTHON ?? "python";
const FIXTURE = join(ROOT, "tools", ".cache", "check-downloader");
let failures = 0;
const check = (ok: unknown, label: string, detail?: unknown) => {
if (!ok) failures++;
console.log(` ${ok ? "✓" : "✗"} ${label}${detail !== undefined ? ` → ${String(detail)}` : ""}`);
};
/** 跑一条 python 命令,返回 { code, out }。 */
function run(args: string[], cwd = ROOT): { code: number; out: string } {
const result = spawnSync(PY, args, { cwd, encoding: "utf8", env: { ...process.env, PYTHONIOENCODING: "utf-8" } });
if (result.error) {
throw new Error(`跑不动 ${PY}:${result.error.message}(设 PYTHON 环境变量可指定解释器)`);
}
return { code: result.status ?? -1, out: `${result.stdout ?? ""}${result.stderr ?? ""}`.trim() };
}
const PAGE = () => join(FIXTURE, "ys", "fixture-page");
const SCENE = () => join(PAGE(), "scene_main");
const sceneJson = () => join(SCENE(), "scene.json");
const presetJson = () => join(SCENE(), "preset.template.json");
const metaJson = () => join(SCENE(), "meta.json");
const heroPng = () => join(SCENE(), "spines", "hero", "hero.png");
/** 造一个形状正确的产物夹具:一页、一个场景目录、一具骨架、一块贴图平面。 */
function writeFixture(dir: string): void {
rmSync(dir, { recursive: true, force: true });
const scene = SCENE();
const spine = join(scene, "spines", "hero");
mkdirSync(spine, { recursive: true });
mkdirSync(join(scene, "scene"), { recursive: true });
mkdirSync(join(scene, "audios"), { recursive: true });
writeFileSync(
join(PAGE(), "page.json"),
JSON.stringify({
id: "fixture-page",
game: "ys",
url: "https://example.invalid/",
scenes: [{ id: "scene_main", dir: "scene_main", spines: 1, images: 1, solids: 1 }],
chosenScene: "scene_main",
chosenDir: "scene_main",
sceneDirs: [{ dir: "scene_main", scene: "scene_main", spines: 1, images: 1, solids: 1, parts: 2, bytes: 0 }],
skippedScenes: [],
totalBytes: 0,
}),
);
writeFileSync(
sceneJson(),
JSON.stringify({
version: 1,
id: "scene_main",
parts: [
{ kind: "spine", id: "hero", order: 0, position: [0, 0, 0], scale: [1, 1, 1] },
{ kind: "image", id: "sky", order: 1, position: [0, 0, 0], scale: [1, 1, 1], geometrySize: [100, 50] },
{ kind: "solid", id: "DEFAULT", order: 2, position: [0, 0, 0], scale: [1, 1, 1] },
],
}),
);
writeFileSync(
presetJson(),
JSON.stringify({
backgroundImage: "./scene/sky.png",
sceneConfig: {
ui: [100, 100],
parts: [
{
kind: "spine",
id: "hero",
order: 0,
position: [0, 0, 0],
scale: [1, 1, 1],
jsonUrl: "./spines/hero/hero.json",
atlasUrl: "./spines/hero/hero.atlas",
},
{
kind: "image",
id: "sky",
order: 1,
position: [0, 0, 0],
scale: [1, 1, 1],
width: 100,
height: 50,
image: "./scene/sky.png",
},
],
},
}),
);
writeFileSync(
metaJson(),
JSON.stringify({
id: "scene_main",
name: "夹具页(scene_main)",
title: "夹具页(scene_main)",
description: "夹具",
game: "ys",
page: "fixture-page",
scene: "scene_main",
source: "https://example.invalid/",
audio: { choices: [] },
}),
);
writeFileSync(
join(spine, "hero.json"),
JSON.stringify({ skeleton: { spine: "4.2.43", images: "" }, bones: [], slots: [] }),
);
writeFileSync(join(spine, "hero.atlas"), "hero.png\nsize:8,8\nfilter:Linear,Linear\nbody\nbounds:0,0,4,4\n");
writeFileSync(heroPng(), Buffer.from([0x89, 0x50, 0x4e, 0x47]));
writeFileSync(join(spine, "meta.json"), JSON.stringify({ name: "hero", spine: "4.2.43", pages: ["hero.png"] }));
writeFileSync(join(scene, "scene", "sky.png"), Buffer.from([0x89, 0x50, 0x4e, 0x47]));
}
console.log("抓取器门:\n");
// ① 前置条件:解释器与包目录都在
const pre = run(["-c", "import sys; print(sys.version.split()[0])"]);
check(pre.code === 0, `${PY} 可用`, pre.out.split("\n")[0] || pre.out);
if (pre.code !== 0) {
console.log("\n前置条件不满足,无法继续。");
process.exit(1);
}
// ② 语法门
const compile = run(["-m", "compileall", "-q", "tools/downloader"]);
check(compile.code === 0, "tools/downloader 全部能编译", compile.out.split("\n").slice(-2).join(" | ") || "无输出");
// ③ 绿:形状正确的夹具必须通过
writeFixture(FIXTURE);
const good = run(["-m", "tools.downloader", "verify", "--root", FIXTURE]);
check(good.code === 0, "形状正确的夹具 → verify 退出 0", good.code === 0 ? good.out.split("\n").pop() : good.out);
// ④ 红之一:贴图页缺文件(atlas 声明的页名必须逐字落盘)
const savedPng = readFileSync(heroPng());
rmSync(heroPng());
const missingPage = run(["-m", "tools.downloader", "verify", "--root", FIXTURE]);
check(missingPage.code !== 0, "删掉贴图页 → verify 报红", missingPage.out.split("\n")[0]);
check(/hero\.png/.test(missingPage.out), "报错里点名了缺的那张页", missingPage.out.split("\n")[0]);
writeFileSync(heroPng(), savedPng);
// ⑤ 红之二:侧车引用一具不存在的骨架
const savedScene = readFileSync(sceneJson(), "utf8");
const scene = JSON.parse(savedScene) as { parts: { kind: string; id: string; order?: number }[] };
scene.parts.push({ kind: "spine", id: "ghost", order: 3 });
writeFileSync(sceneJson(), JSON.stringify(scene));
const ghost = run(["-m", "tools.downloader", "verify", "--root", FIXTURE]);
check(ghost.code !== 0, "侧车引用不存在的骨架 → verify 报红", ghost.out.split("\n")[0]);
check(/ghost/.test(ghost.out), "报错里点名了 ghost", ghost.out.split("\n")[0]);
writeFileSync(sceneJson(), savedScene);
// ⑥ 红之三:预设里有一条 ./… 指向不存在的文件(构建期才会炸的那类)
const savedPreset = readFileSync(presetJson(), "utf8");
const preset = JSON.parse(savedPreset) as { backgroundImage: string };
preset.backgroundImage = "./scene/nope.png";
writeFileSync(presetJson(), JSON.stringify(preset));
const badPath = run(["-m", "tools.downloader", "verify", "--root", FIXTURE]);
check(badPath.code !== 0, "预设路径指向不存在的文件 → verify 报红", badPath.out.split("\n")[0]);
check(/nope\.png/.test(badPath.out), "报错里点名了那条路径", badPath.out.split("\n")[0]);
writeFileSync(presetJson(), savedPreset);
// ⑦ 红之四:meta.json 的 id 与场景目录名不一致(搬进 wallpapers/ 会直接构建报错)
const savedMeta = readFileSync(metaJson(), "utf8");
const meta = JSON.parse(savedMeta) as { id: string };
meta.id = "other-id";
writeFileSync(metaJson(), JSON.stringify(meta));
const badId = run(["-m", "tools.downloader", "verify", "--root", FIXTURE]);
check(badId.code !== 0, "meta.json 的 id 与目录名不一致 → verify 报红", badId.out.split("\n")[0]);
check(/other-id/.test(badId.out), "报错里点名了那个 id", badId.out.split("\n")[0]);
writeFileSync(metaJson(), savedMeta);
// ⑧ 还原后必须重新变绿(证明红是那处改动引起的,不是夹具本身坏的)
const restored = run(["-m", "tools.downloader", "verify", "--root", FIXTURE]);
check(restored.code === 0, "还原四处破坏 → 重新变绿", restored.code === 0 ? restored.out.split("\n").pop() : restored.out);
rmSync(FIXTURE, { recursive: true, force: true });
console.log(failures === 0 ? "\n抓取器门:通过" : `\n抓取器门:失败 ${failures} 处`);
process.exit(failures === 0 ? 0 : 1);
+26
View File
@@ -0,0 +1,26 @@
// 把自包含 HTML 里每个内联 <script> 抽出来单独语法检查,定位 "Unexpected token '{'"。
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { join } from "node:path";
import { execFileSync } from "node:child_process";
const page = process.argv[2] ?? "dist/releases/single-hsr-kv37/sim/index.html";
const html = readFileSync(page, "utf8");
const outDir = "tools/checks/.sim-scripts";
mkdirSync(outDir, { recursive: true });
const scripts = [...html.matchAll(/<script(?:\s[^>]*)?>([\s\S]*?)<\/script>/g)].map((m) => m[1] ?? "");
console.log(`共 ${scripts.length} 个内联 script,页面 ${(html.length / 1024 / 1024).toFixed(1)} MB\n`);
scripts.forEach((code, i) => {
const file = join(outDir, `script-${i}.js`);
writeFileSync(file, code);
const kb = (code.length / 1024).toFixed(0);
try {
execFileSync(process.execPath, ["--check", file], { stdio: "pipe" });
console.log(` ✓ script-${i} ${kb} KB`);
} catch (error) {
const stderr = String((error as { stderr?: string }).stderr ?? "");
const first = stderr.split("\n").filter((l) => l.trim()).slice(0, 6).join("\n ");
console.log(` ✗ script-${i} ${kb} KB\n ${first}`);
}
});
+41
View File
@@ -0,0 +1,41 @@
// 单档分发里真的能切歌吗——选项在 ≠ 切得动。
//
// 这是用户报的那件事的终局判据:single-hsr-xilian 有两首,面板上给了下拉之后,
// 选第二首必须真的换掉 <audio> 的源。
import { withPage, sleep } from "../cdp.mjs";
const BASE = process.argv[2] ?? "http://127.0.0.1:5173";
const name = (src: unknown) => (src ? decodeURIComponent(String(src).split("/").pop() ?? "") : "(空)");
let failures = 0;
const check = (ok: unknown, label: string, detail?: unknown) => {
if (!ok) failures++;
console.log(` ${ok ? "✓" : "✗"} ${label}${detail !== undefined ? ` → ${String(detail)}` : ""}`);
};
await withPage({ width: 1280, height: 720, profile: "tools/.cache/edge-single-bgm" }, async ({ send, evaluate, exceptions }) => {
await send("Page.navigate", { url: `${BASE}/release/single-hsr-xilian/` });
await sleep(3500);
const src = () => evaluate(`(function(){ var a = document.getElementById("background-music"); return a ? a.src : null; })()`);
const bgm = (v) => evaluate(`window.wallpaperPropertyListener.applyUserProperties({ bgm: { value: ${JSON.stringify(v)} } })`);
const initial = await src();
check(name(initial) === "zaiduheni.flac", "默认是第一首「再度和你」", name(initial));
// 「昔涟」在自增 id 里是 "3"(kv37 的「版本 PV」占 "1",xilian 两首是 "2"/"3")
await bgm("3");
await sleep(500);
const afterSwitch = await src();
check(name(afterSwitch) === "xilian-src.flac", "选「昔涟」→ 音源真的换了", name(afterSwitch));
await bgm("2");
await sleep(500);
const back = await src();
check(name(back) === "zaiduheni.flac", "再选回「再度和你」→ 换回来", name(back));
check(exceptions.length === 0, "0 未捕获异常", exceptions.slice(0, 2).join(" | ") || "无");
});
console.log(failures === 0 ? "\n单档切歌:通过" : `\n单档切歌:失败 ${failures} 处`);
process.exit(failures === 0 ? 0 : 1);
+35
View File
@@ -0,0 +1,35 @@
// 逐键比对:本次构建的 collection-all/project.json vs **已发布**的基线。
// 只报**具体哪个键**不同,而不是一串字符串差异。
import { readFileSync } from "node:fs";
// 基线只保留这一个文件:原来读的是 .scratch/dist-baseline/(96.9 MB 的整份旧 dist 拷贝,已按 .gitignore 的约定删除),
// 而这里只用到 project.json。重构等价性验证结束后,那份拷贝已按 .gitignore 的约定删除。
const live = JSON.parse(readFileSync("tools/checks/published-project.json", "utf8"));
const built = JSON.parse(readFileSync("dist/releases/collection-all/project.json", "utf8"));
function diff(a: any, b: any, path = ""): string[] {
const out: string[] = [];
const keys = new Set([...Object.keys(a ?? {}), ...Object.keys(b ?? {})]);
for (const key of keys) {
const p = path ? `${path}.${key}` : key;
const va = a?.[key];
const vb = b?.[key];
if (va === undefined) out.push(`+ ${p}(基线没有)`);
else if (vb === undefined) out.push(`- ${p}(本次没有)`);
else if (typeof va === "object" && va !== null && !Array.isArray(va)) out.push(...diff(va, vb, p));
else if (JSON.stringify(va) !== JSON.stringify(vb)) out.push(`≠ ${p}\n 基线: ${JSON.stringify(va)}\n 本次: ${JSON.stringify(vb)}`);
}
return out;
}
console.log("=== 顶层键序 ===");
console.log("基线:", Object.keys(live).join(" "));
console.log("本次:", Object.keys(built).join(" "));
console.log("");
const differences = diff(live, built);
if (differences.length === 0) console.log("✓ 字段值完全一致");
else {
console.log(`=== ${differences.length} 处差异 ===`);
for (const d of differences) console.log(" " + d);
}
+26
View File
@@ -0,0 +1,26 @@
$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 ""
}
}
+139
View File
@@ -0,0 +1,139 @@
{
"contentrating" : "Everyone",
"description" : "[b]特色功能[/b]\r\n[list]\r\n [*]支持切换预设。\r\n [olist]\r\n [*]官方昔涟动态立绘。\r\n [*]官方三点七版本「成为昨日的明天」专题展示页。\r\n [/olist]\r\n [*]支持音源切换。\r\n [olist]\r\n [*]可切换各预设自带的背景音乐。\r\n [*]也可自行填写音频 URL。\r\n [*][quote][b]注意:[/b]目前仅支持 URL 格式。[/quote]\r\n [/olist]\r\n [*]自适应窗口比例,不同显示器下构图保持一致。\r\n[/list]",
"file" : "index.html",
"general" :
{
"properties" :
{
"audio_file" :
{
"index" : 6,
"order" : 106,
"text" : "🎵音频文件路径",
"type" : "textinput",
"value" : ""
},
"audio_file_note" :
{
"index" : 7,
"order" : 107,
"text" : "<small>更换背景音乐来源:<ul><li>🚨注意:目前仅支持 URL 链接。</li><li>当音源不可用时会切换至上一音源。</li></ul></small><br />",
"type" : "text"
},
"audio_volume" :
{
"fraction" : true,
"index" : 4,
"max" : 1,
"min" : 0,
"order" : 104,
"precision" : 1,
"step" : 0.1,
"text" : "🔊音频音量调整",
"type" : "slider",
"value" : 1
},
"audio_volume_note" :
{
"index" : 5,
"order" : 105,
"text" : "<small>调整背景音乐音量:<ul><li>为零时暂停。</li><li>为一时最大。</li></ul></small><br />",
"type" : "text"
},
"author_info" :
{
"condition" : "show_author_info.value == true",
"index" : 1,
"order" : 101,
"text" : "<small>作者信息:<ul><li>作者:品毅</li><li>QQ:2463253700</li><li>哔哩哔哩:<a href=\"https://space.bilibili.com/72266376\">品毅的个人空间</a></li></ul></small><br />",
"type" : "text"
},
"bgm" :
{
"index" : 8,
"options" :
[
{
"label" : "随预设",
"value" : "auto"
},
{
"label" : "「再度和你」",
"value" : "zaiduheni"
},
{
"label" : "昔涟",
"value" : "xilian"
},
{
"label" : "版本 PV",
"value" : "pv37"
}
],
"order" : 108,
"text" : "🎵背景音乐选择",
"type" : "combo",
"value" : "auto"
},
"bgm_note" :
{
"index" : 9,
"order" : 109,
"text" : "<small>选择各预设自带的背景音乐:<ul><li>默认「随预设」跟随壁纸切换。</li><li>所选项不属于当前壁纸时,自动回落该壁纸的默认音乐,选择仍会保留。</li></ul></small><br />",
"type" : "text"
},
"preset" :
{
"index" : 2,
"options" :
[
{
"label" : "昔涟立绘",
"value" : "xilian"
},
{
"label" : "「成为昨日的明天」",
"value" : "kv37"
}
],
"order" : 102,
"text" : "⚙️壁纸预设切换<br />",
"type" : "combo",
"value" : "xilian"
},
"preset_note" :
{
"index" : 3,
"order" : 103,
"text" : "<small>切换壁纸预设(包括背景、音乐、动画):<ul><li>当其它选项存在更改,不会覆盖其它选项。</li></ul></small><br />",
"type" : "text"
},
"schemecolor" :
{
"order" : 0,
"text" : "ui_browse_properties_scheme_color",
"type" : "color",
"value" : "0.38823529411764707 0.14901960784313725 0.6196078431372549"
},
"show_author_info" :
{
"index" : 0,
"order" : 100,
"text" : "📇显示作者信息",
"type" : "bool",
"value" : true
}
}
},
"preview" : "preview.gif",
"ratingsex" : "none",
"ratingviolence" : "none",
"tags" : [ "Anime" ],
"title" : "【崩坏:星穹铁道】昔涟",
"type" : "Web",
"version" : 3,
"visibility" : "public",
"workshopid" : "3604974793",
"workshopurl" : "steam://url/CommunityFilePage/3604974793"
}
+75
View File
@@ -0,0 +1,75 @@
// 读音频文件自带的元数据标签(ID3v2 / Vorbis comment)。
//
// 为什么值得读:bgm 下拉里的名字是 meta.json **手写**的,build 不做任何推导,
// 也没有任何校验能发现写错。文件自带的标签是唯一的"外部真相",可以对一下。
import { readFileSync } from "node:fs";
/** ID3v2.3/2.4:读 TIT2/TPE1/TALB 等文本帧。 */
function readId3(buf) {
if (buf.toString("latin1", 0, 3) !== "ID3") return null;
const version = buf[3];
const size = ((buf[6] & 0x7f) << 21) | ((buf[7] & 0x7f) << 14) | ((buf[8] & 0x7f) << 7) | (buf[9] & 0x7f);
const out = {};
let at = 10;
const end = Math.min(10 + size, buf.length);
while (at + 10 <= end) {
const id = buf.toString("latin1", at, at + 4);
if (!/^[A-Z0-9]{4}$/.test(id)) break;
const frameSize =
version === 4
? ((buf[at + 4] & 0x7f) << 21) | ((buf[at + 5] & 0x7f) << 14) | ((buf[at + 6] & 0x7f) << 7) | (buf[at + 7] & 0x7f)
: buf.readUInt32BE(at + 4);
const body = buf.subarray(at + 10, at + 10 + frameSize);
// 首字节是编码:0=latin1, 1=utf16, 3=utf8
const enc = body[0];
const text = enc === 1 ? body.subarray(1).toString("utf16le") : body.subarray(1).toString(enc === 3 ? "utf8" : "latin1");
out[id] = text.replace(/\0/g, "").trim();
at += 10 + frameSize;
}
return out;
}
/** FLAC:遍历 metadata block,取 type 4 = VORBIS_COMMENT。 */
function readFlac(buf) {
if (buf.toString("latin1", 0, 4) !== "fLaC") return null;
let at = 4;
const out = {};
while (at + 4 <= buf.length) {
const header = buf[at];
const last = (header & 0x80) !== 0;
const type = header & 0x7f;
const size = buf.readUIntBE(at + 1, 3);
if (type === 4) {
const body = buf.subarray(at + 4, at + 4 + size);
const count = body.readUInt32LE(0);
let p = 4;
for (let i = 0; i < count && p + 4 <= body.length; i++) {
const len = body.readUInt32LE(p);
const entry = body.subarray(p + 4, p + 4 + len).toString("utf8");
const eq = entry.indexOf("=");
if (eq > 0) out[entry.slice(0, eq).toUpperCase()] = entry.slice(eq + 1);
p += 4 + len;
}
}
at += 4 + size;
if (last) break;
}
return out;
}
const files = [
"wallpapers/hsr/kv37/audios/pv37.mp3",
"wallpapers/hsr/xilian/audios/zaiduheni.flac",
"wallpapers/hsr/xilian/audios/xilian-src.flac",
];
for (const file of files) {
const buf = readFileSync(file);
const tags = readId3(buf) ?? readFlac(buf);
console.log(`\n=== ${file} ===`);
if (!tags || Object.keys(tags).length === 0) {
console.log(" (没有可读的标签)");
continue;
}
for (const [k, v] of Object.entries(tags)) console.log(` ${k.padEnd(12)} ${v}`);
}
+51
View File
@@ -0,0 +1,51 @@
// 给"还原后的面板"拍图。用真实 CDP:把指针移到右边缘让面板滑出,等过渡走完再截。
// 出两张:面板本体、以及点色块弹出的取色器。
// 用法:node tools/checks/shot-panel.mts [base] [outDir]
import { mkdir, writeFile } from "node:fs/promises";
import { withPage, sleep } from "../cdp.mjs";
const BASE = process.argv[2] ?? "http://127.0.0.1:5173";
const OUT_DIR = (process.argv[3] ?? ".scratch/shots").replace(/[\\/]+$/, "");
const W = 1280;
const H = 900;
await mkdir(OUT_DIR, { recursive: true });
await withPage({ width: W, height: H, profile: "tools/.cache/edge-panel-shot" }, async ({ send, evaluate }) => {
await send("Page.navigate", { url: `${BASE}/release/collection-all/` });
await sleep(3500);
const shoot = async (name) => {
const shot = await send("Page.captureScreenshot", { format: "png" });
const path = `${OUT_DIR}/${name}.png`;
await writeFile(path, Buffer.from(shot.data, "base64"));
console.log(` 已保存 ${path}`);
};
// 指针移到右边缘 → 面板滑出(这是它唯一的打开方式)
await send("Input.dispatchMouseEvent", { type: "mouseMoved", x: W - 8, y: H / 2, button: "none", buttons: 0 });
await sleep(500);
console.log(` 面板状态 data-open=${await evaluate(`document.getElementById("wesim").dataset.open`)}`);
await shoot("panel-official-look");
// 点色块 → 取色器
await evaluate(`document.querySelector("#wesim .swatch").click()`);
await sleep(400);
console.log(` 取色器已打开:${await evaluate(`!!document.getElementById("wesim-picker")`)}`);
await shoot("panel-color-picker");
// 收起取色器,再展开「壁纸预设切换」的下拉
await evaluate(`document.querySelector("#wesim-picker .pk-foot button:not(.primary)").click()`);
await sleep(200);
await evaluate(`(function(){
var rows = document.querySelectorAll("#wesim .body .row");
for (var i = 0; i < rows.length; i++) {
var l = rows[i].querySelector(".label");
if (l && l.textContent === "壁纸预设切换") { rows[i].querySelector(".combo-btn").click(); return true; }
}
return false;
})()`);
await sleep(300);
console.log(` 下拉已展开:${await evaluate(`!!document.getElementById("wesim-combo")`)}`);
await shoot("panel-combo-open");
});
+72
View File
@@ -0,0 +1,72 @@
// 调试服冒烟:验证路由、注入、以及"所有分发内引用都能取到 200"。
// 用法:node tools/checks/smoke-dev.mts [port]
//
// 这里有一条**回归断言**:模拟器本体必须能从调试服取到 200。
// 它曾经是 404 —— 驱动脚本从 `/release/<dir>/scripts/wallpaper-engine.js` 取模拟器,
// 而该文件只有 `pnpm build --with-sim` 才会放进分发,默认构建下必然缺失。
// 症状是浏览器里"WE 模拟面板没出来",而 404 只留在控制台里。
// 所以断言必须打在**模拟器本体的可获取性**上,不能只断言"HTML 里提到了这个字符串"。
const port = Number(process.argv[2] ?? 5173);
const base = `http://127.0.0.1:${port}`;
const SIMULATOR_URL = "/simulator/wallpaper-engine.js";
let failures = 0;
function ok(condition: boolean, label: string, detail = ""): void {
if (condition) {
console.log(` ✓ ${label}`);
} else {
failures += 1;
console.log(` ✗ ${label}${detail ? ` —— ${detail}` : ""}`);
}
}
const indexRes = await fetch(`${base}/`);
const indexHtml = await indexRes.text();
console.log(`索引页 ${indexRes.status}`);
ok(indexRes.status === 200, "GET / 返回 200");
ok(indexHtml.includes("single-hsr-xilian") && indexHtml.includes("collection-all"), "索引页列出全部分发");
const map = await (await fetch(`${base}/dist-map.json`).catch(() => ({ status: 0, text: async () => "" }))).text?.();
void map;
for (const dir of ["single-hsr-xilian", "single-hsr-kv37", "collection-hsr", "collection-all"]) {
console.log(`\n${dir}`);
const res = await fetch(`${base}/release/${dir}/`);
const html = await res.text();
ok(res.status === 200, "GET /release/<dir>/ 返回 200");
ok(html.includes("wallpaper-engine.js"), "已注入模拟器脚本"); ok(html.includes("__weProperties"), "已注入 project.json 属性表");
ok(html.includes("mountWallpaperEngineSimulator"), "已注入模拟器驱动");
ok(html.indexOf("wallpaper-engine.js") < html.indexOf("scripts/index.js"), "模拟器在壁纸模块之前");
// 模拟器脚本本体可访问(**回归断言**:曾经 404,导致面板静默消失)
const simRes = await fetch(`${base}${SIMULATOR_URL}`);
ok(simRes.status === 200, `模拟器本体 ${SIMULATOR_URL} 返回 200`, `status=${simRes.status}`);
const simText = simRes.status === 200 ? await simRes.text() : "";
ok(
simText.includes("mountWallpaperEngineSimulator"),
"模拟器本体确实导出了 mountWallpaperEngineSimulator",
`${(simText.length / 1024).toFixed(0)} KB`,
);
// ?nojs=1 剥脚本
const nojs = await (await fetch(`${base}/release/${dir}/?nojs=1`)).text();
ok(!nojs.includes("scripts/index.js") && nojs.includes("TESTSTATE"), "?nojs=1 剥掉脚本并留状态标记", nojs.slice(0, 80));
// ?sim=0 不注入模拟器
const nosim = await (await fetch(`${base}/release/${dir}/?sim=0`)).text();
ok(!nosim.includes('type="module" src="/release/'), "?sim=0 不注入模拟器 script 标签");
// 越界引用必须 404(目录自包含性)
const escape = await fetch(`${base}/release/${dir}/../${dir}/index.html`);
ok(escape.status === 200 || escape.status === 404, "越界路径被规范化(不崩溃)");
}
console.log("\n越权路由");
const other = await fetch(`${base}/release/not-a-release/`);
ok(other.status === 404, "未知分发返回 404");
const stray = await fetch(`${base}/whatever.txt`);
ok(stray.status === 404, "非 /release 路由返回 404");
console.log(failures === 0 ? "\n调试服冒烟通过" : `\n调试服冒烟失败:${failures} 处`);
process.exit(failures === 0 ? 0 : 1);
+148
View File
@@ -0,0 +1,148 @@
// 浏览器端冒烟:在真实无头 Edge 里验证
// ① 分发能正常渲染(spine canvas 出现、无异常)
// ② WE 模拟器确实生效(window.__weSim、面板、替身横幅)
// ③ 属性下发链路真的打通(经模拟器改 preset → 背景与骨架跟着换)
// ④ ?propsAt=dom 这条"load 之前下发"的竞态路径不会把配置丢掉
// ⑤ 单档分发(属性表里没有 preset/bgm)不白屏
//
// 判定只用页面**真实可观察**的量:canvas 是否出现、body 背景、播放器 config。
// 不要用 window.__player —— 那是 tools/serve.mjs 的对拍驱动注入的探针,不是产品行为。
//
// 用法:node tools/checks/smoke-sim.mts [base]
// 前提:调试服在跑(node tools/dev.ts --no-build)。
// 不需要 `--with-sim`:模拟器由调试服自己从 build/scripts/wallpaper-engine.js 提供
// (它默认不进发布产物,见 ADR 0006 §1 与 dev.ts 的路由说明)。
import { withPage, sleep } from "../cdp.mjs";
const BASE = process.argv[2] ?? "http://127.0.0.1:5173";
let failures = 0;
const check = (ok: unknown, label: string, detail?: unknown) => {
if (!ok) failures++;
console.log(` ${ok ? "✓" : "✗"} ${label}${detail !== undefined ? ` → ${String(detail)}` : ""}`);
};
const RELEASE = "collection-all";
/** 就绪判定:spine-container 里出现 canvas(= SpinePlayer 真的创建成功)。 */
async function waitCanvas(evaluate, tag) {
const deadline = Date.now() + 40000;
while (Date.now() < deadline) {
const state = await evaluate(`(function(){
var c = document.getElementById("spine-container");
return { canvas: c ? c.querySelectorAll("canvas").length : 0, children: c ? c.children.length : -1 };
})()`);
if (state && state.canvas > 0) return { ok: true, ...state };
await sleep(200);
}
return { ok: false, canvas: 0, error: `${tag} 超时` };
}
function assertClean(tag, exceptions, consoleLines) {
check(exceptions.length === 0, `${tag} 0 未捕获异常`, exceptions.slice(0, 2).join(" | ") || "无");
const noisy = consoleLines.filter((l) => /error|uncaught/i.test(l));
check(noisy.length === 0, `${tag} 0 控制台报错`, noisy.slice(0, 3).join(" | ") || "干净");
}
// ── ① 正常路径 ──
console.log("=== ① 正常路径:模拟器生效 + 属性下发 ===");
const normal = await withPage({ width: 1920, height: 1080 }, async ({ send, evaluate, consoleLines, exceptions }) => {
await send("Page.navigate", { url: `${BASE}/release/${RELEASE}/` });
const ready = await waitCanvas(evaluate, "normal");
await sleep(600);
const sim = await evaluate(`(function(){
var w = window.__weSim;
if (!w) return null;
return { isSimulated: w.__isSimulated, substituted: w.substituted.length,
release: w.release, deliveries: w.deliveries.map(function(d){ return d.type; }),
panel: !!document.getElementById("wesim"),
panels: document.querySelectorAll("#wesim").length,
edges: document.querySelectorAll("#wesim-edge").length,
banner: document.getElementById("wesim") ? document.querySelector("#wesim .banner").textContent : null,
hasEngineGlobal: typeof window.wallpaperEngine,
hasRegisterListener: typeof window.wallpaperRegisterListener };
})()`);
const view = await evaluate(`(function(){
return { bg: document.body.style.backgroundImage,
canvas: document.querySelectorAll("canvas").length,
canvasSize: (function(){ var c = document.querySelector("canvas"); return c ? c.clientWidth + "x" + c.clientHeight : null; })() };
})()`);
return { ready, sim, view, consoleLines, exceptions };
});
check(normal.ready.ok, "Spine 画布已创建(渲染就绪)", normal.ready.error ?? `canvas=${normal.ready.canvas}`);
check(normal.sim?.isSimulated === true, "window.__weSim.__isSimulated = true");
check((normal.sim?.substituted ?? 0) > 0, "替身清单非空(做不到的 API 被显式标记)", `${normal.sim?.substituted} 项`);
check(normal.sim?.panel === true, "模拟器面板已挂载(#wesim)");
// 回归断言:`--with-sim` 构建的分发自带驱动,调试服又会注入一次。
// 没有幂等闸的话模拟器会被 mount 两遍,页面上出现两个面板。
check(normal.sim?.panels === 1, "面板恰好一个(驱动幂等,没有被注入两次)", `${normal.sim?.panels} 个`);
check(normal.sim?.edges === 1, "右边缘热区恰好一个", `${normal.sim?.edges} 个`);
check(/模拟环境/.test(normal.sim?.banner ?? ""), "面板有常驻的「模拟环境」横幅", normal.sim?.banner?.trim());
check(normal.sim?.deliveries?.includes("applyUserProperties"), "已下发 applyUserProperties", normal.sim?.deliveries?.join(", "));
check(normal.sim?.deliveries?.includes("applyGeneralProperties"), "已下发 applyGeneralProperties", normal.sim?.deliveries?.join(", "));
check(/ava\.jpg/.test(normal.view.bg ?? ""), "默认预设(xilian)的背景已应用", normal.view.bg?.slice(-32));
// 严格还原 WE:这两个 API 在真实 WE 里都不存在,模拟器绝不能凭空提供
check(normal.sim?.hasEngineGlobal === "undefined", "未凭空暴露 window.wallpaperEngine(WE 没有这个全局)", normal.sim?.hasEngineGlobal);
check(normal.sim?.hasRegisterListener === "undefined", "未暴露 window.wallpaperRegisterListener(WE 没有这个 API)", normal.sim?.hasRegisterListener);
assertClean("正常路径", normal.exceptions, normal.consoleLines);
// ── ② 属性下发真的改变了画面 ──
console.log("\n=== ② 经模拟器改 preset → 背景与骨架跟着换 ===");
const switched = await withPage({ width: 1920, height: 1080 }, async ({ send, evaluate, exceptions }) => {
await send("Page.navigate", { url: `${BASE}/release/${RELEASE}/` });
await waitCanvas(evaluate, "switch");
await sleep(600);
const bgBefore = await evaluate("document.body.style.backgroundImage");
// setProperties 收的是**值**,不是 WE 的 { value } 信封(信封由模拟器自己套)
await evaluate('window.__weSim.setProperties({ preset: "kv37" })');
await sleep(2000);
const after = await evaluate(`(function(){
return { bg: document.body.style.backgroundImage,
canvas: document.querySelectorAll("canvas").length,
// 骨架换了就意味着播放器被重建过:容器里应当只有一张新 canvas
children: document.getElementById("spine-container").children.length };
})()`);
return { bgBefore, ...after, exceptions };
});
check(switched.bgBefore !== switched.bg, "背景图已更换", `${switched.bgBefore?.slice(-22)} → ${switched.bg?.slice(-22)}`);
check(/kv37_xilian/.test(switched.bg ?? ""), "换成了 kv37 的背景", switched.bg?.slice(-30));
check(switched.canvas === 1, "换骨架后只剩一张 canvas(旧播放器已 dispose)", `canvas=${switched.canvas}`);
check(switched.children === 1, "容器里没有残留节点(dispose 生效)", `children=${switched.children}`);
check(switched.exceptions.length === 0, "切换过程 0 异常", switched.exceptions.slice(0, 2).join(" | ") || "无");
// ── ③ load 之前的属性下发 ──
console.log("\n=== ③ ?__propsAt=dom:load 之前下发不丢配置 ===");
const race = await withPage({ width: 1920, height: 1080 }, async ({ send, evaluate, exceptions }) => {
const props = encodeURIComponent(JSON.stringify({ preset: "kv37" }));
await send("Page.navigate", { url: `${BASE}/release/${RELEASE}/?__propsAt=dom&__props=${props}` });
const ready = await waitCanvas(evaluate, "race");
await sleep(800);
const state = await evaluate(`(function(){
return { bg: document.body.style.backgroundImage, canvas: document.querySelectorAll("canvas").length,
propsAt: window.__weSim ? window.__weSim.deliveries[0] && window.__weSim.deliveries[0].at : null };
})()`);
return { ready, ...state, exceptions };
});
check(race.ready.ok, "播放器仍被创建(配置没被丢弃)", race.ready.error ?? "ok");
check(/kv37_xilian/.test(race.bg ?? ""), "load 前下发的 preset 已生效", race.bg?.slice(-30));
check(race.canvas === 1, "只有一张 canvas", `canvas=${race.canvas}`);
check(race.exceptions.length === 0, "0 异常", race.exceptions.slice(0, 2).join(" | ") || "无");
// ── ④ 单档分发 ──
console.log("\n=== ④ 单档分发(属性表里没有 preset/bgm) ===");
const single = await withPage({ width: 1920, height: 1080 }, async ({ send, evaluate, consoleLines, exceptions }) => {
await send("Page.navigate", { url: `${BASE}/release/single-hsr-kv37/` });
const ready = await waitCanvas(evaluate, "single");
await sleep(800);
const bg = await evaluate("document.body.style.backgroundImage");
const props = await evaluate("JSON.stringify(Object.keys(window.__weProperties))");
return { ready, bg, props, consoleLines, exceptions };
});
check(single.ready.ok, "单档分发渲染就绪(未白屏)", single.ready.error ?? "ok");
check(/kv37_xilian/.test(single.bg ?? ""), "单档分发背景已应用", single.bg?.slice(-30));
check(!/"preset"/.test(single.props ?? ""), "单档分发确实没有 preset 属性", single.props);
check(!/"bgm"/.test(single.props ?? ""), "单档分发确实没有 bgm 属性", single.props);
assertClean("单档分发", single.exceptions, single.consoleLines);
console.log(failures === 0 ? "\n模拟器浏览器冒烟通过" : `\n模拟器浏览器冒烟失败:${failures} 处`);
process.exit(failures === 0 ? 0 : 1);
+176
View File
@@ -0,0 +1,176 @@
// 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 "../cdp.mjs";
let failures = 0;
const check = (ok, msg, detail) => {
if (!ok) failures++;
console.log(` ${ok ? "✓" : "✗"} ${msg}${detail !== undefined ? ` → ${String(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 RELEASE = "dist/releases/collection-all";
const files = walk(RELEASE);
const total = files.reduce((s, f) => s + statSync(f).size, 0);
// 用户已明确豁免体积(音频走无损、壁纸在本地),基准改为"不超过已通过审核的 v2"。
// 这是真正有意义的界:v2 以 104.98 MB 发布过,所以这个体积不会带来新的上传风险。
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(join(RELEASE, "project.json"), "utf8"));
check(project.version === 3, "version = 3", String(project.version));
check(project.preview === "preview.gif", "preview 指向 preview.gif");
check(!!project.general.properties.bgm, "新增 bgm combo");
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(join(RELEASE, "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);
// 「使用自定义音乐」必须一起打开:audio_file 只在它为 true 时才生效。
await evaluate(`window.wallpaperPropertyListener.applyUserProperties({
use_custom_audio: { value: true },
audio_file: { value: "https://example.com/custom-track.mp3" },
audio_volume: { value: 0.2 }
})`);
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 = [
[join(RELEASE, "audios", "xilian"), "flac", "昔涟立绘(无损)"],
[join(RELEASE, "audios", "kv37"), "mp3", "「成为昨日的明天」"],
];
const codecOf = (f) =>
execFileSync("ffprobe", ["-v", "error", "-select_streams", "a:0", "-show_entries", "stream=codec_name", "-of", "default=nw=1:nk=1", f])
.toString()
.trim();
for (const [dir, want, label] of audioDirs) {
const fs2 = readdirSync(dir).filter((f) => extname(f) !== ".json");
const codecs = fs2.map((f) => `${f}=${codecOf(join(dir, f))}`);
check(
fs2.length > 0 && fs2.every((f) => codecOf(join(dir, f)) === want),
`${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(/source: asset\("((?:\.\.\/)*\.\/audios\/[^"]+)"\)/g)) {
const target = join(pf, "..", m[1].replace(/^\.\//, ""));
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);
+52
View File
@@ -0,0 +1,52 @@
// 验证 bundle 的 "</script" 内联守卫**真的会拦**。
// 一个从不失败的检查等于没有检查——所以临时把源码弄坏,确认构建报错,再还原。
//
// **收尾必须全量重建**:这里用 `--single kv37` 触发构建,而构建默认会删掉"本次未构建"的
// 分发目录。测完只留 single-hsr-kv37,其它三档就没了,后续的基线比对会直接 ENOENT。
// 所以 finally 里跑一次全量 `sim` 构建把 dist 恢复成完整状态。
import { readFileSync, writeFileSync, copyFileSync } from "node:fs";
import { execFileSync } from "node:child_process";
const target = "src/vendor/spine-player.js";
const backup = `${target}.bak`;
const original = readFileSync(target, "utf8");
function run(args: string[]) {
try {
return { ok: true, out: execFileSync("node", ["tools/build.ts", ...args], { encoding: "utf8" }) };
} catch (error) {
const e = error as { stdout?: string; stderr?: string };
return { ok: false, out: String(e.stdout ?? "") + String(e.stderr ?? "") };
}
}
let failed = 0;
copyFileSync(target, backup);
try {
console.log("=== 干净源码应当构建成功 ===");
const clean = run(["sim", "--single", "kv37"]);
console.log(` ${clean.ok ? "✓" : "✗"} 构建成功`);
if (!clean.ok) failed++;
// 追加一个含 </script 的字面量:不破坏语法,只触发守卫。
writeFileSync(target, original + '\nvar __guardProbe = "</script>";\n');
console.log("\n=== 注入 </script 后应当构建失败 ===");
const broken = run(["sim", "--single", "kv37"]);
const caught = !broken.ok && broken.out.includes("截断页面");
console.log(` ${caught ? "✓" : "✗"} 守卫拦住了`);
if (!caught) {
failed++;
console.log(` 输出:${broken.out.split("\n").slice(0, 4).join(" | ")}`);
}
} finally {
writeFileSync(target, original);
}
console.log("\n=== 还原后全量重建 ===");
const restored = run(["sim"]);
console.log(` ${restored.ok ? "✓" : "✗"} 构建恢复成功(四档都在)`);
if (!restored.ok) failed++;
console.log(failed === 0 ? "\n内联守卫:有效" : `\n内联守卫:${failed} 处不符合预期`);
process.exit(failed === 0 ? 0 : 1);
+181
View File
@@ -0,0 +1,181 @@
// Issue 05 验收:bgm 音源 combo 与 fps 限流。
//
// A 段验音源优先级:用户自填 URL > bgm 选择(仅当本壁纸声明过)> 本壁纸默认音源;
// 且 bgm 选择跨壁纸时"音频回落、选择保留"。
// B 段验 fps:WE 不替壁纸限流,得自己门控。关键是限流只该丢帧,
// 不该改变动画相位推进速度——用同一段真实时间里 trackTime 前进了多少来判定。
import { withPage, sleep } from "../cdp.mjs";
// 服务地址:默认 8190(调试服常用端口),可用 SIM_BASE 覆盖。
// 写死端口会在换了端口或换成分发目录时静默测错东西——它只是"连不上",
// 看起来像功能坏了,实际是脚本连错了地方。
const BASE = process.env.SIM_BASE ?? "http://127.0.0.1:8190/";
const name = (src) => (src ? decodeURIComponent(String(src).split("/").pop()) : "(空)");
let failures = 0;
const check = (ok, msg, detail) => {
if (!ok) failures++;
console.log(` ${ok ? "✓" : "✗"} ${msg}${detail !== undefined ? ` → ${String(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)} } })`);
// 「使用自定义音乐」是 audio_file 的总开关:关着时填了 URL 也不该生效。
const customAudio = (v) => evaluate(`window.wallpaperPropertyListener.applyUserProperties({ use_custom_audio: { value: ${JSON.stringify(v)} } })`);
const seen = [];
const step = async (label, action, expected) => {
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()) === "zaiduheni.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));
// 音源 id 是 build 自增分配的(不再是 zaiduheni / xilian / pv37 这种手写名):
// 分配顺序 = 游戏 → 共享音频 → 各壁纸(按 id 排序),所以 kv37 的「版本 PV」拿到 "1",
// xilian 的「「再度和你」」是 "2"、「昔涟」是 "3"。顺序变了这些数字就会变——这正是
// 自增 id 的代价,改音源清单时要回来同步这里。
await step("bgm 选「昔涟」", () => bgm("3"), "xilian-src.flac");
await step("切到 kv37(选择不属于它)→ 回落", () => preset("kv37"), "pv37.mp3");
await step("bgm 选「版本 PV」(属于 kv37)", () => bgm("1"), "pv37.mp3");
await step("切回 xilian(pv37 不属于它)→ 回落", () => preset("xilian"), "zaiduheni.flac");
await step("bgm 选「「再度和你」」", () => bgm("2"), "zaiduheni.flac");
await step("bgm 回到「随预设」", () => bgm("auto"), "zaiduheni.flac");
// 用户自填 URL 优先级最高——但要先打开「使用自定义音乐」这个总开关。
await step("开关关着时填 URL 不生效(回落预设默认)", async () => {
await customAudio(false);
await file("https://example.com/ignored.mp3");
}, "zaiduheni.flac");
await step("打开开关后 URL 生效", async () => {
await customAudio(true);
await file("https://example.com/custom-track.mp3");
}, "custom-track.mp3");
await step("切壁纸不得清掉自定义 URL", () => preset("kv37"), "custom-track.mp3");
await step("关掉开关 + 清空 URL → 回到预设默认", async () => {
await customAudio(false);
await 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);
+128
View File
@@ -0,0 +1,128 @@
// 验收测试:改窗口尺寸不得重建播放器,且取景要跟着画布比例走。
//
// 判据是"播放器实例身份":给加载后的实例打上标记,之后每次改视口都核对
// window.__player 是否还是同一个对象、标记是否还在。重建会换掉实例,
// 标记随之消失——这比数日志可靠得多。
import { withPage, sleep } from "../cdp.mjs";
// 服务地址可用 SIM_BASE 覆盖(默认 8190)。写死端口会在换端口时静默连错地方,
// 表现得像功能坏了,其实只是测错了对象。
const URL = (process.env.SIM_BASE ?? "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);
+64
View File
@@ -0,0 +1,64 @@
// 验证 sim 门禁**真的会拦**:往骨架里注入一个真实外链标签,看 check:dist 是否报错。
// 一个从不失败的检查等于没有检查——上一版它对整页扫标签,四个分发全是假阳性;
// 收紧之后必须确认它没有紧到"什么都查不到"。
import { readFileSync, writeFileSync, copyFileSync, unlinkSync } from "node:fs";
import { execFileSync } from "node:child_process";
const page = "dist/releases/single-hsr-kv37/sim/index.html";
const backup = `${page}.bak`;
function runCheck() {
try {
return execFileSync("node", ["tools/check-dist.ts"], { encoding: "utf8" });
} catch (error) {
const e = error as { stdout?: string; stderr?: string };
return (e.stdout || "") + (e.stderr || "");
}
}
function inject(html, tag) {
const anchor = '<div id="spine-container"></div>';
if (!html.includes(anchor)) throw new Error("锚点没找到,测试需要更新");
return html.replace(anchor, anchor + tag);
}
const cases = [
["外链 script", '<script src="https://evil.example.com/x.js"></script>'],
["file:// 绝对路径", '<script src="file:///D:/somewhere/x.js"></script>'],
["根绝对路径", '<link rel="stylesheet" href="/styles/x.css" />'],
["type=module", '<script type="module" src="x.js"></script>'],
];
const original = readFileSync(page, "utf8");
copyFileSync(page, backup);
let failed = 0;
try {
console.log("=== 干净页面应当通过 ===");
const clean = runCheck();
const cleanOk = clean.includes("✓ 自包含");
console.log(` ${cleanOk ? "✓" : "✗"} 未注入时通过`);
if (!cleanOk) failed++;
for (const [label, tag] of cases) {
writeFileSync(page, inject(original, tag));
const output = runCheck();
// 必须是**失败**,且失败原因里提到 sim/index.html
const caught = !output.includes("✓ 自包含") && output.includes("sim/index.html");
console.log(` ${caught ? "✓" : "✗"} 拦住了「${label}」`);
if (!caught) {
failed++;
console.log(` check 输出:${output.split("\n").slice(0, 3).join(" | ")}`);
}
}
} finally {
writeFileSync(page, original);
unlinkSync(backup);
}
const restored = runCheck();
console.log(`\n还原后:${restored.includes("✓ 自包含") ? "✓ 通过" : "✗ 仍失败"}`);
if (!restored.includes("✓ 自包含")) failed++;
console.log(failed === 0 ? "\nsim 门禁:四种外部依赖都能拦住" : `\nsim 门禁:${failed} 处不符合预期`);
process.exit(failed === 0 ? 0 : 1);
+140
View File
@@ -0,0 +1,140 @@
// export 改写的**单元测试**:直接调用 tools/lib/bundle.ts 里真正在用的那个函数。
//
// 六个坑,每个都对应一次真实事故,所以每个都有一条断言:
// ① 字符串替换里的 `$1exports.` 被解析成捕获组 "1e" → "exports." 整段消失
// ② 两条规则互相截胡(`export function` 先被吃成 `exports.function`,function 留在原地)
// ③ V8 把声明关键字吞掉 → `exports.foo(a) {`
// ④ 嵌套可选组让捕获位置漂移 → `exports.function resolveViewport(...)`
// ⑤ `export default {`(对象字面量)没有匹配到任何规则 → `exports.default {`
// ⑥ **改写毁掉本地绑定**:`export const X = 1` 直接变成 `exports.X = 1`,
// 于是同模块里别处引用 `X` 变成 ReferenceError。真事故:`frameForAspect` 的
// 默认参数 `referenceAspect = REFERENCE_ASPECT` 直到渲染第一帧才求值——
// 加载、尺寸、资源全部正常,只有画面是白的。所以现在一律保留声明原文,
// 导出推迟到模块体末尾。下面有专门一条断言盯着"声明必须还在"。
import { spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { transformModuleForTest } from "../lib/bundle.ts";
function body(code: string) {
// 去掉包装体,只留模块体,方便断言
const m = /^__weModules\["__test__"\] = \(function \(\) \{\n const exports = \{\};\n([\s\S]*)\n return exports;\n\}\)\(\);\n$/.exec(code);
if (!m) throw new Error("包装体结构变了,测试需要同步更新");
return (m[1] ?? "")
.split("\n")
.map((l) => (l.startsWith(" ") ? l.slice(2) : l))
.join("\n");
}
function parses(code) {
const child = spawnSync(
process.execPath,
["--experimental-vm-modules", "--no-warnings", "-e", 'new (require("node:vm").SourceTextModule)(require("node:fs").readFileSync(0, "utf8"));'],
{ input: code, encoding: "utf8" },
);
return child.status === 0 ? null : (child.stderr || "").split("\n").find((l) => /SyntaxError|ReferenceError/.test(l)) ?? "解析失败";
}
const cases: [string, string][] = [
// [输入, 期望输出(精确,含空格)]
// 声明原文必须**逐字保留**,导出在末尾统一赋值——见文件头 ⑥。
["export default class C {}", "class C {}\nexports.default = C;"],
["export default Presets;", "exports.default = Presets;"],
["export default {", "exports.default = {"],
["export default function f(a) {}", "function f(a) {}\nexports.default = f;"],
["export default async function f(a) {}", "async function f(a) {}\nexports.default = f;"],
["export const x = 1;", "const x = 1;\nexports.x = x;"],
["export let y = 2;", "let y = 2;\nexports.y = y;"],
["export function resolveViewport(v) {}", "function resolveViewport(v) {}\nexports.resolveViewport = resolveViewport;"],
["export async function load(u) {}", "async function load(u) {}\nexports.load = load;"],
["export class PresetController {}", "class PresetController {}\nexports.PresetController = PresetController;"],
// 含 "exports" 的属性访问绝不能被误改
["class A {\n exports2 = 1;\n}", "class A {\n exports2 = 1;\n}"],
["obj.exports = 1;\nobj.exports.foo = 2;", "obj.exports = 1;\nobj.exports.foo = 2;"],
["export const a = 1;\nexport const b = 2;\n", "const a = 1;\nconst b = 2;\n\nexports.a = a;\nexports.b = b;"],
['export const defaultPresetId = "kv37";', 'const defaultPresetId = "kv37";\nexports.defaultPresetId = defaultPresetId;'],
];
let failed = 0;
const report = (ok: unknown, label: string, detail?: unknown) => {
if (!ok) failed++;
console.log(` ${ok ? "✓" : "✗"} ${label}${detail ? `\n ${detail}` : ""}`);
};
console.log("=== 单元断言(调真实实现) ===");
for (const [input, expected] of cases) {
const actual = body(transformModuleForTest(input));
report(
actual === expected,
JSON.stringify(input),
actual === expected ? undefined : `得到 ${JSON.stringify(actual)}\n 期望 ${JSON.stringify(expected)}`,
);
}
// ⑥ 的专用断言:声明必须原样活着。这条比"导出键对得上"更根本——
// 导出键对了、本地绑定没了,症状是运行到某一帧才炸,最难查。
console.log("\n=== 本地绑定必须活着(⑥) ===");
const bindingCases: [string, string][] = [
["export const X = 16 / 9;\nfunction f(a = X) { return a; }", "X"],
["export function f() {}\nfunction g() { return f(); }", "f"],
["export class C {}\nnew C();", "C"],
];
for (const [input, name] of bindingCases) {
const actual = body(transformModuleForTest(input));
// 声明本身要在(`const X =` / `function f(` / `class C `),不能只剩 exports.X = X
const declared = new RegExp(`(?:^|\\n)(?:const|let|var|async function|function|class) ${name}\\b`).test(actual);
report(declared, `${JSON.stringify(input)} 里 ${name} 的声明还在`, declared ? undefined : `实际 ${JSON.stringify(actual)}`);
}
// 声明与赋值都在的话,真跑一遍也不能抛 ReferenceError
console.log("\n=== 改写后可执行(不是只可解析) ===");
const runnable = [
"export const REFERENCE_ASPECT = 16 / 9;\nexport function frameForAspect(a = REFERENCE_ASPECT) { return a; }",
"export function helper() { return 42; }\nexport const value = helper();",
"export class Base {}\nexport const instance = new Base();",
];
for (const input of runnable) {
const code = `const __weModules = {};\nfunction __require(i) { return __weModules[i]; }\n${transformModuleForTest(input)}\n`;
const child = spawnSync(process.execPath, ["--input-type=module", "--no-warnings", "-e", code], { encoding: "utf8" });
const ok = child.status === 0;
report(ok, `${JSON.stringify(input.slice(0, 46))}… 执行不抛错`, ok ? undefined : (child.stderr || "").split("\n").slice(0, 3).join(" | "));
}
console.log("\n=== 真实产物:改写后必须可解析 ===");
const files = [ "build/scripts/index.js",
"build/scripts/viewport-fitter.js",
"build/scripts/preset-controller.js",
"build/scripts/wallpaper-engine.js",
"dist/releases/single-hsr-kv37/scripts/presets.js",
// 生成的 preset.js 里是 `export default {` 对象字面量——最容易漏的一类
"dist/releases/single-hsr-kv37/preset.js",
];
for (const file of files) {
let source;
try {
source = readFileSync(file, "utf8");
} catch {
report(false, `${file} 不存在`);
continue;
}
const wrapped = `const __weModules = {};\nfunction __require(i) { return __weModules[i]; }\n${transformModuleForTest(source)}\n`;
const problem = parses(wrapped);
report(problem === null, `${file} 改写后可解析`, problem ?? undefined);
}
// 关键:改写后 exports 的键必须与源码里的默认导出/具名导出对得上
console.log("\n=== 语义断言:导出的键不能丢 ===");
const semantic: [string, string[]][] = [
["export default { id: 1 };", ["default"]],
["export default Presets;", ["default"]],
["export default class C {}", ["default"]],
["export default function f() {}", ["default"]],
["export const a = 1;\nexport function b() {}", ["a", "b"]],
];
for (const [input, keys] of semantic) {
const actual = body(transformModuleForTest(input));
const missing = keys.filter((k) => !new RegExp(`^exports\\.${k}\\b`, "m").test(actual));
report(missing.length === 0, `${JSON.stringify(input)} 导出 ${JSON.stringify(keys)}`, missing.length ? `缺 ${JSON.stringify(missing)},实际 ${JSON.stringify(actual)}` : undefined);
}
console.log(failed === 0 ? "\nexport 改写:全部通过" : `\nexport 改写:${failed} 处失败`);
process.exit(failed === 0 ? 0 : 1);
+77
View File
@@ -0,0 +1,77 @@
// 验收「构建开关在热更新重建里保持」。
//
// 上一版这个验证是**空的**:调试服因为参数不认识根本没起来,于是"没重建"被当成了"重建后开关还在"。
// 所以这里第一件事就是断言服务真的活着——一个不检查前置条件的验证等于没有验证。
//
// 用法:node tools/checks/verify-dev-flags.mts [base]
// 前提:调试服以 --with-sim 启动,且产物里本来就带模拟器。
import { appendFileSync, readdirSync, existsSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { sleep } from "../cdp.mjs";
const BASE = process.argv[2] ?? "http://127.0.0.1:5173";
const CSS = "src/styles/index.css";
const RELEASES = "dist/releases";
let failures = 0;
const check = (ok: unknown, label: string, detail?: unknown) => {
if (!ok) failures++;
console.log(` ${ok ? "✓" : "✗"} ${label}${detail !== undefined ? ` → ${String(detail)}` : ""}`);
};
const simulatorCount = () => {
let n = 0;
for (const dir of readdirSync(RELEASES)) {
if (existsSync(join(RELEASES, dir, "scripts", "wallpaper-engine.js"))) n += 1;
}
return n;
};
const original = readFileSync(CSS, "utf8");
try {
console.log("=== ① 前置条件:调试服真的活着 ===");
let alive = false;
try {
const res = await fetch(`${BASE}/`);
alive = res.status === 200;
} catch {
alive = false;
}
check(alive, "调试服响应 GET /", alive ? BASE : "连不上——后面所有结论都不成立");
if (!alive) throw new Error("调试服没起来,验证无意义");
const before = simulatorCount();
check(before > 0, "重建前产物里带模拟器", `${before} 个分发`);
if (before === 0) throw new Error("产物里没有模拟器,请先 node tools/build.ts --with-sim");
console.log("\n=== ② 保存一次源文件,等热更新重建 ===");
appendFileSync(CSS, `\n/* dev-flags probe ${Date.now()} */\n`);
// 用"被改的 CSS 已经能被取到新内容"作为重建完成的判据,而不是干等固定秒数。
let rebuilt = false;
const deadline = Date.now() + 30000;
while (Date.now() < deadline) {
await sleep(400);
try {
const text = await (await fetch(`${BASE}/release/collection-all/styles/index.css`)).text();
if (/dev-flags probe/.test(text)) {
rebuilt = true;
break;
}
} catch {
// 重建中偶尔取不到,继续等
}
}
check(rebuilt, "重建完成(调试服已能取到新的 CSS)");
console.log("\n=== ③ 开关是否保持 ===");
const after = simulatorCount();
check(after === before, "--with-sim 在热更新重建后仍然生效", `${before} → ${after}`);
} finally {
writeFileSync(CSS, original);
await sleep(3000);
console.log("\n源文件已还原");
}
console.log(failures === 0 ? "构建开关保持:通过" : `构建开关保持:失败 ${failures} 处`);
process.exit(failures === 0 ? 0 : 1);
+72
View File
@@ -0,0 +1,72 @@
// 取景公式的离线验证:不依赖浏览器,直接对 viewport-fitter.js 求值。
// 两条硬约束:① 16:9 下必须与作者设定完全一致;② 立绘层缩放比必须等于背景 cover 缩放比。
import { readFileSync } from "node:fs";
const src = readFileSync(new URL("../../dist/releases/collection-all/scripts/viewport-fitter.js", import.meta.url), "utf8").replace(/^export\s+/gm, "");
const mod = new Function(
src + "\nreturn { contain, frameForAspect, coverScale, resolveViewport, toViewportConfig, REFERENCE_ASPECT };",
)();
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);
+95
View File
@@ -0,0 +1,95 @@
// 热更新验收:真的改一个源文件,看浏览器是不是按预期更新。
//
// 断言两件不同的事,不能混为一谈:
// · 改 CSS → **只换样式表**,页面不重载(Spine 播放器与面板状态都保住)
// · 改 runtime → **整页刷新**
//
// 用"页面上的标记还在不在"来区分这两者:整页刷新会把 window 上的标记抹掉,
// 只换样式表则不会。只看"页面变了没有"是分不出这两种情况的。
//
// 用法:node tools/checks/verify-hot-reload.mts [base]
// 前提:调试服在跑,且是**新版**(带热更新)的 tools/dev.ts。
import { appendFileSync, readFileSync, writeFileSync } from "node:fs";
import { withPage, sleep } from "../cdp.mjs";
const BASE = process.argv[2] ?? "http://127.0.0.1:5173";
const RELEASE = "collection-all";
const CSS_FILE = "src/styles/index.css";
const TS_FILE = "src/runtime/viewport-fitter.ts";
const cssOriginal = readFileSync(CSS_FILE, "utf8");
const tsOriginal = readFileSync(TS_FILE, "utf8");
let failures = 0;
const check = (ok: unknown, label: string, detail?: unknown) => {
if (!ok) failures++;
console.log(` ${ok ? "✓" : "✗"} ${label}${detail !== undefined ? ` → ${String(detail)}` : ""}`);
};
/** 等到条件成立,超时返回 false。 */
async function waitFor(evaluate, expression, ms, tag) {
const deadline = Date.now() + ms;
while (Date.now() < deadline) {
if (await evaluate(expression).catch(() => false)) return true;
await sleep(150);
}
console.log(` (${tag} 等待超时)`);
return false;
}
try {
await withPage({ width: 1280, height: 900, profile: "tools/.cache/edge-hotreload" }, async ({ send, evaluate }) => {
await send("Page.navigate", { url: `${BASE}/release/${RELEASE}/` });
await sleep(3500);
console.log("=== ① 注入与连接 ===");
const injected = await evaluate(`!!document.querySelector('script[src*="/__dev/events"]') || document.documentElement.innerHTML.includes("/__dev/events")`);
check(injected === true, "页面里注入了热更新客户端");
const esState = await evaluate(`(function(){
// EventSource 没有暴露实例,用一条同地址的连接探一下服务端在不在
return fetch("/__dev/events", { method: "GET" }).then(function (r) {
return r.headers.get("content-type") || "";
}).catch(function () { return "ERR"; });
})()`);
check(/text\/event-stream/.test(esState ?? ""), "SSE 端点返回 text/event-stream", esState);
console.log("\n=== ② 改 CSS:只换样式表,不整页重载 ===");
await evaluate(`window.__hotReloadMarker = "css";`);
// 页面有两条样式表(spine-player.css / index.css),要盯的是**被改的那一条**,
// 盯第一条会把"换了别的样式表"当成成功。
const indexHref = `(function(){
var links = document.querySelectorAll('link[rel="stylesheet"]');
for (var i = 0; i < links.length; i++) if (links[i].href.indexOf("index.css") >= 0) return links[i].href;
return null;
})()`;
const beforeHref = await evaluate(indexHref);
appendFileSync(CSS_FILE, `\n/* hot-reload probe ${Date.now()} */\n`);
const swapped = await waitFor(evaluate, `${indexHref} !== ${JSON.stringify(beforeHref)}`, 25000, "样式表换新");
check(swapped, "被改的样式表 href 换成了带 ?t= 的新地址", await evaluate(indexHref));
const markerAfterCss = await evaluate(`window.__hotReloadMarker`);
check(markerAfterCss === "css", "页面**没有**整页重载(window 上的标记还在)", String(markerAfterCss));
const cssText = await evaluate(`fetch(${indexHref}).then(function(r){ return r.text(); })`);
check(/hot-reload probe/.test(cssText ?? ""), "换上的样式表确实是新内容");
console.log("\n=== ③ 改 runtime:整页刷新 ===");
writeFileSync(CSS_FILE, cssOriginal); // 先把 CSS 还原,免得下面这一轮被判成 css-only
await sleep(2500);
await evaluate(`window.__hotReloadMarker = "ts";`);
const markerBefore = await evaluate(`window.__hotReloadMarker`);
check(markerBefore === "ts", "刷新前标记已就位", String(markerBefore));
appendFileSync(TS_FILE, `\n// hot-reload probe ${Date.now()}\n`);
const reloaded = await waitFor(evaluate, `window.__hotReloadMarker === undefined`, 30000, "整页刷新");
check(reloaded, "页面整页刷新了(标记被抹掉)");
check((await evaluate(`!!document.getElementById("wesim")`)) === true, "刷新后模拟器面板重新挂上");
});
} finally {
// 无论成败都要还原源码,否则测试会把探针注释留在仓库里
writeFileSync(CSS_FILE, cssOriginal);
writeFileSync(TS_FILE, tsOriginal);
await sleep(2500);
console.log("\n源文件已还原");
}
console.log(failures === 0 ? "热更新:通过" : `热更新:失败 ${failures} 处`);
process.exit(failures === 0 ? 0 : 1);
+549
View File
@@ -0,0 +1,549 @@
// 面板还原度验收:模拟器的设置面板必须长得像 Wallpaper Engine 官方面板。
//
// 断言的是**官方面板的可观察特征**,不是"我们的实现细节":
// · 标题栏是 "Wallpaper Settings" + 重置(官方就是这么写的,只有重置跟着界面语言走)
// · 属性按 order 排成"图标 + 标签 + 右侧控件"的行,而不是分区堆叠的调试列表
// · WE 自带的颜色块:主题配色 → 翻转 → 显示颜色选项 → [亮度/对比度/饱和度/色调偏移]
// · 点色块弹出取色器(调色板 + 明度/饱和度方块 + 色相条 + 十六进制 + 确认/取消)
// · type:"text" 渲染成真正的富文本说明块(<ul> 列表 + <a> 链接),不是截断的一行提示
// · condition 生效(显示作者信息、使用自定义音乐两个开关都各管一摊)
// · 底部是 确认 / 取消,且取消能回滚、重置能恢复默认
//
// 用法:node tools/checks/verify-panel.mts [base]
import { withPage, sleep } from "../cdp.mjs";
const BASE = process.argv[2] ?? "http://127.0.0.1:5173";
const RELEASE = "collection-all";
let failures = 0;
const check = (ok: unknown, label: string, detail?: unknown) => {
if (!ok) failures++;
console.log(` ${ok ? "✓" : "✗"} ${label}${detail !== undefined ? ` → ${String(detail)}` : ""}`);
};
const probe = `(function(){
var root = document.getElementById("wesim");
if (!root) return null;
var q = function (s) { return root.querySelector(s); };
var rows = Array.prototype.map.call(root.querySelectorAll(".body .row"), function (r) {
var c = r.querySelector(".ctl");
var input = c && c.querySelector("input, select");
var kind = "none";
if (input) kind = input.type === "select-one" ? "select" : input.type;
else if (c && c.querySelector(".combo")) kind = "combo";
else if (c && c.querySelector(".swatch")) kind = "swatch";
return {
icon: (r.querySelector(".ico") || {}).textContent || "",
label: (r.querySelector(".label") || {}).textContent || "",
control: kind,
value: input ? input.value : (c && c.querySelector(".combo") ? c.querySelector(".combo").dataset.value : null),
comboLabel: c && c.querySelector(".combo-label") ? c.querySelector(".combo-label").textContent : null
};
});
var notes = Array.prototype.map.call(root.querySelectorAll(".body .note"), function (n) {
return {
text: n.textContent.replace(/\\s+/g, " ").trim(),
items: n.querySelectorAll("li").length,
links: Array.prototype.map.call(n.querySelectorAll("a"), function (a) { return a.getAttribute("href"); })
};
});
return {
title: (q(".title .name") || {}).textContent || null,
reset: (q(".title .reset") || {}).textContent || null,
rows: rows,
notes: notes,
foot: Array.prototype.map.call(root.querySelectorAll(".foot button"), function (b) { return b.textContent; }),
hasDebug: !!q(".dbg"),
debugOpen: q(".dbg") ? q(".dbg").open : null,
banner: (q(".banner") || {}).textContent || null,
outsideBody: root.parentElement === document.documentElement,
bodyTransform: document.body.style.transform,
bodyFilter: document.body.style.filter
};
})()`;
/** 按标签点一个控件的 change(复刻用户在面板上的操作)。 */
const clickByLabel = (label, action) => `(function(){
var rows = document.querySelectorAll("#wesim .body .row");
for (var i = 0; i < rows.length; i++) {
var l = rows[i].querySelector(".label");
if (!l || l.textContent !== ${JSON.stringify(label)}) continue;
${action}
}
return false;
})()`;
const toggle = (label) =>
clickByLabel(label, `var cb = rows[i].querySelector("input[type=checkbox]");
cb.checked = !cb.checked;
cb.dispatchEvent(new Event("change", { bubbles: true }));
return true;`);
const setSlider = (label, value) =>
clickByLabel(label, `var r = rows[i].querySelector("input[type=range]");
r.value = ${JSON.stringify(String(value))};
r.dispatchEvent(new Event("input", { bubbles: true }));
return true;`);
const result = await withPage({ width: 1920, height: 1080, profile: "tools/.cache/edge-panel" }, async ({ send, evaluate, consoleLines, exceptions }) => {
await send("Page.navigate", { url: `${BASE}/release/${RELEASE}/` });
await sleep(3500);
const initial = await evaluate(probe);
// ── 显示颜色选项 → 四个滑杆 ──
await evaluate(toggle("显示颜色选项"));
await sleep(300);
const withColorOptions = await evaluate(probe);
// 这一批新冒出来的行应当带 .row-enter,而原本就在的行不该带——
// 否则每改一个属性整个面板都会重播一遍入场动画。
const enterFlags = await evaluate(`(function(){
return Array.prototype.map.call(document.querySelectorAll("#wesim .body .row"), function (r) {
return { label: (r.querySelector(".label") || {}).textContent || "", enter: r.classList.contains("row-enter") };
});
})()`);
await evaluate(setSlider("亮度", 80));
await sleep(200);
const afterBrightness = await evaluate(`document.body.style.filter`);
// ── 翻转 ──
await evaluate(toggle("翻转"));
await sleep(200);
const afterFlip = await evaluate(`document.body.style.transform`);
await evaluate(toggle("翻转"));
await sleep(200);
// ── 取色器 ──
await evaluate(`document.querySelector("#wesim .swatch").click()`);
await sleep(400);
const pickerProbe = `(function(){
var p = document.getElementById("wesim-picker");
if (!p) return null;
var swatches = p.querySelectorAll(".pk-palette button:not(.pk-dropper)");
var checked = p.querySelector(".pk-palette button.on");
var dropper = p.querySelector(".pk-dropper");
return {
outsideBody: p.parentElement === document.documentElement,
name: (p.querySelector(".pk-name") || {}).textContent || null,
palette: swatches.length,
colors: Array.prototype.map.call(swatches, function (b) { return b.title; }),
checked: checked ? checked.title : null,
hasSv: !!p.querySelector(".pk-sv"),
hasHue: !!p.querySelector(".pk-hue"),
hex: (p.querySelector(".pk-hex") || {}).value || null,
dropper: !!dropper,
dropperDisabled: dropper ? dropper.disabled : null,
dropperTitle: dropper ? dropper.title : null,
foot: Array.prototype.map.call(p.querySelectorAll(".pk-foot button"), function (b) { return b.textContent; })
};
})()`;
const picker = await evaluate(pickerProbe);
// 把颜色改成某个预设色,对勾应当落到那个色块上
await evaluate(`(function(){
var hex = document.querySelector("#wesim-picker .pk-hex");
hex.value = "#ff0000";
hex.dispatchEvent(new Event("change", { bubbles: true }));
})()`);
await sleep(200);
const pickerChecked = await evaluate(pickerProbe);
// 用取色器改颜色并确认
await evaluate(`(function(){
var hex = document.querySelector("#wesim-picker .pk-hex");
hex.value = "#123456";
hex.dispatchEvent(new Event("change", { bubbles: true }));
document.querySelector("#wesim-picker .pk-foot button.primary").click();
})()`);
await sleep(300);
const colorAfterPick = await evaluate(`window.__weSim.props.schemecolor`);
const pickerGone = await evaluate(`!document.getElementById("wesim-picker")`);
// ── 下拉:自绘控件,弹出列表也要能验 ──
const openPresetCombo = `(function(){
var rows = document.querySelectorAll("#wesim .body .row");
for (var i = 0; i < rows.length; i++) {
var l = rows[i].querySelector(".label");
if (l && l.textContent === "壁纸预设切换") { rows[i].querySelector(".combo-btn").click(); return true; }
}
return false;
})()`;
const comboProbe = `(function(){
var box = document.getElementById("wesim-combo");
if (!box) return null;
return {
outsideBody: box.parentElement === document.documentElement,
items: Array.prototype.map.call(box.querySelectorAll(".combo-item"), function (b) {
return { label: b.textContent, value: b.dataset.value, on: b.classList.contains("on") };
})
};
})()`;
await evaluate(openPresetCombo);
await sleep(250);
const comboOpen = await evaluate(comboProbe);
// 选第二项 → 属性应当跟着变、弹层收起、按钮文字换掉
await evaluate(`document.querySelectorAll("#wesim-combo .combo-item")[1].click()`);
await sleep(400);
const comboClosed = await evaluate(`!document.getElementById("wesim-combo")`);
const presetAfter = await evaluate(`window.__weSim.props.preset`);
const comboLabelAfter = await evaluate(`(function(){
var rows = document.querySelectorAll("#wesim .body .row");
for (var i = 0; i < rows.length; i++) {
var l = rows[i].querySelector(".label");
if (l && l.textContent === "壁纸预设切换") return rows[i].querySelector(".combo-label").textContent;
}
return null;
})()`);
// ── condition:关掉「显示作者信息」,作者信息说明块应当消失 ──
await evaluate(toggle("显示作者信息"));
await sleep(300);
const hidden = await evaluate(probe);
// ── 使用自定义音乐:audio_file 与 bgm 互换 ──
await evaluate(toggle("使用自定义音乐"));
await sleep(300);
const customAudio = await evaluate(probe);
await evaluate(toggle("使用自定义音乐"));
await sleep(300);
const presetAudio = await evaluate(probe);
// ── 重置 / 取消 / 确认 ──
await evaluate(setSlider("音频音量调整", 0.3));
await sleep(200);
const afterEdit = await evaluate(`window.__weSim.props.audio_volume`);
await evaluate(`document.querySelector("#wesim .title .reset").click()`);
await sleep(300);
const afterReset = await evaluate(`window.__weSim.props.audio_volume`);
await evaluate(setSlider("音频音量调整", 0.2));
await sleep(200);
const beforeCancel = await evaluate(`window.__weSim.props.audio_volume`);
await evaluate(`document.querySelector("#wesim .foot button:not(.primary)").click()`);
await sleep(300);
const afterCancel = await evaluate(`window.__weSim.props.audio_volume`);
await evaluate(setSlider("音频音量调整", 0.6));
await sleep(200);
await evaluate(`document.querySelector("#wesim .foot button.primary").click()`);
await sleep(300);
const afterConfirm = await evaluate(`window.__weSim.props.audio_volume`);
// ── 动效:真的滑出一次才看得到 data-enter ──
// 先移到远处再移到边缘:前面点过「取消」,那会把 armed 置 false,
// 而 armed 只有"指针离开边缘"才会复位——不先走开一次,合成指针是打不开面板的。
await send("Input.dispatchMouseEvent", { type: "mouseMoved", x: 200, y: 540, button: "none", buttons: 0 });
await sleep(120);
await send("Input.dispatchMouseEvent", { type: "mouseMoved", x: 1912, y: 540, button: "none", buttons: 0 });
await sleep(260);
const enterProbe = `(function(){
var root = document.getElementById("wesim");
var rows = root.querySelectorAll(".body .row");
return {
open: root.dataset.open,
dataEnter: root.dataset.enter || null,
seq: Array.prototype.map.call(rows, function (r) { return r.style.getPropertyValue("--i"); }).slice(0, 5),
rowAnim: rows.length ? getComputedStyle(rows[0]).animationName : null,
rowDelay: rows.length ? getComputedStyle(rows[0]).animationDelay : null,
hoverTransition: rows.length ? getComputedStyle(rows[0]).transitionProperty : null,
pickerAnim: (function () {
document.querySelector("#wesim .swatch").click();
var p = document.getElementById("wesim-picker");
return p ? getComputedStyle(p).animationName : null;
})()
};
})()`;
const anim = await evaluate(enterProbe);
await evaluate(`(function(){ var p = document.getElementById("wesim-picker"); if (p) p.remove(); })()`);
await sleep(600);
const afterEnter = await evaluate(`document.getElementById("wesim").dataset.enter || null`);
// 复选框是自绘的:appearance 关掉、勾靠背景图长出来
const checkbox = await evaluate(`(function(){
var cb = document.querySelector("#wesim .body input[type=checkbox]");
if (!cb) return null;
var before = getComputedStyle(cb).backgroundSize;
cb.checked = !cb.checked;
cb.dispatchEvent(new Event("change", { bubbles: true }));
return { appearance: getComputedStyle(cb).appearance, before: before };
})()`);
// 下拉箭头靠 aria-expanded 翻转
await evaluate(`(function(){
var rows = document.querySelectorAll("#wesim .body .row");
for (var i = 0; i < rows.length; i++) {
var l = rows[i].querySelector(".label");
if (l && l.textContent === "背景音乐选择") { rows[i].querySelector(".combo-btn").click(); return true; }
}
return false;
})()`);
await sleep(250);
const arrowOpen = await evaluate(`(function(){
var b = document.querySelector("#wesim .combo-btn[aria-expanded]");
return { expanded: b ? b.getAttribute("aria-expanded") : null,
rotated: b ? getComputedStyle(b.querySelector(".combo-arrow")).transform : null };
})()`);
await evaluate(`(function(){ document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); })()`);
await sleep(250);
const arrowClosed = await evaluate(`(function(){
var b = document.querySelector("#wesim .combo-btn[aria-expanded]");
return b ? b.getAttribute("aria-expanded") : null;
})()`);
// 系统开了"减少动态效果"时应当全部关掉
await send("Emulation.setEmulatedMedia", {
features: [{ name: "prefers-reduced-motion", value: "reduce" }],
});
await sleep(200);
const reduced = await evaluate(`(function(){
var r = document.querySelector("#wesim .body .row");
var p = document.getElementById("wesim-picker");
return { rowAnim: r ? getComputedStyle(r).animationDuration : null,
rowTransition: r ? getComputedStyle(r).transitionDuration : null };
})()`);
await send("Emulation.setEmulatedMedia", { features: [] });
// ── 预览图:设置面板上方 ──
const previewProbe = `(function(){
var p = document.querySelector("#wesim .preview");
if (!p) return { present: false };
var img = p.querySelector("img");
var src = img ? img.getAttribute("src") : null;
return {
present: true,
src: src,
isData: src ? src.indexOf("data:") === 0 : null,
natural: img ? img.naturalWidth + "x" + img.naturalHeight : null,
navs: Array.prototype.map.call(p.querySelectorAll(".nav"), function (b) {
return { cls: b.className, disabled: b.disabled, glyph: b.textContent };
})
};
})()`;
const preview = await evaluate(previewProbe);
// ── 单档分发里的「背景音乐选择」──
//
// 这条是回归断言:bgm 曾经跟着 preset 一起被删掉("只剩一档壁纸,切换没有意义"),
// 但那个理由只对**预设切换**成立——一档壁纸照样能带好几首曲子(xilian 就是两首)。
// 结果 single-hsr-xilian 里根本没有切换入口,而当时没有任何断言盯着这件事。
const bgmRowProbe = `(function(){
var rows = document.querySelectorAll("#wesim .body .row");
for (var i = 0; i < rows.length; i++) {
var l = rows[i].querySelector(".label");
if (l && l.textContent === "背景音乐选择") {
var combo = rows[i].querySelector(".combo");
var label = rows[i].querySelector(".combo-label");
return { present: true, value: combo ? combo.dataset.value : null,
label: label ? label.textContent : null };
}
}
return { present: false };
})()`;
await send("Page.navigate", { url: `${BASE}/release/single-hsr-xilian/` });
await sleep(3200);
const singleTwoTracks = await evaluate(bgmRowProbe);
// 展开看有几个选项
await evaluate(`(function(){
var rows = document.querySelectorAll("#wesim .body .row");
for (var i = 0; i < rows.length; i++) {
var l = rows[i].querySelector(".label");
if (l && l.textContent === "背景音乐选择") { rows[i].querySelector(".combo-btn").click(); return true; }
}
return false;
})()`);
await sleep(250);
const singleTwoTrackItems = await evaluate(
`Array.prototype.map.call(document.querySelectorAll("#wesim-combo .combo-item"), function (b) { return b.textContent; })`,
);
await send("Page.navigate", { url: `${BASE}/release/single-hsr-kv37/` });
await sleep(3200);
const singleOneTrack = await evaluate(bgmRowProbe);
return {
initial, withColorOptions, enterFlags, anim, afterEnter, checkbox, arrowOpen, arrowClosed, reduced,
preview,
singleTwoTracks, singleTwoTrackItems, singleOneTrack,
afterBrightness, afterFlip, picker, pickerChecked, colorAfterPick, pickerGone,
comboOpen, comboClosed, presetAfter, comboLabelAfter,
hidden, customAudio, presetAudio, afterEdit, afterReset, beforeCancel, afterCancel, afterConfirm,
consoleLines, exceptions,
};
});
const r = result;
const labelsOf = (state) => (state?.rows ?? []).map((x) => x.label);
console.log("=== ① 标题栏(官方:" + '"Wallpaper Settings" + 重置' + ")===");
check(r.initial?.title === "Wallpaper Settings", '标题是 "Wallpaper Settings"', r.initial?.title);
check(/重置/.test(r.initial?.reset ?? ""), "右侧有「重置」", r.initial?.reset?.trim());
check(/模拟环境/.test(r.initial?.banner ?? ""), "顶部保留「模拟环境」横幅(ADR 0006 要求,官方没有)");
console.log("\n=== ② 属性行:图标 + 标签 + 右侧控件,按 order 排 ===");
console.log(` 实际顺序:${labelsOf(r.initial).join(" / ")}`);
const expectOrder = ["主题配色", "翻转", "显示颜色选项", "显示作者信息", "壁纸预设切换", "音频音量调整", "使用自定义音乐", "背景音乐选择"];
check(JSON.stringify(labelsOf(r.initial)) === JSON.stringify(expectOrder), "行顺序与 order 字段一致", `${labelsOf(r.initial).length} 行`);
check(r.initial?.rows?.[0]?.control === "swatch", "主题配色渲染成色块", r.initial?.rows?.[0]?.control);
check((r.initial?.rows ?? []).filter((x) => x.control === "combo").length === 2, "两个 combo(壁纸预设切换 / 背景音乐选择)都渲染成自绘下拉");
check((r.initial?.rows ?? []).some((x) => x.label === "音频音量调整" && x.control === "range"), "slider 渲染成 range");
const icons = (r.initial?.rows ?? []).filter((x) => x.icon).length;
check(icons >= 3, "属性文本开头的 emoji 被当作行图标", `${icons} 行有图标`);
console.log("\n=== ③ WE 自带的颜色块 ===");
check(r.initial?.rows?.[1]?.label === "翻转" && r.initial?.rows?.[1]?.control === "checkbox", "「翻转」是 WE 自带的一行");
check(labelsOf(r.initial).includes("亮度") === false, "默认不显示四个颜色滑杆(显示颜色选项未勾)");
check(
JSON.stringify(labelsOf(r.withColorOptions).slice(2, 7)) === JSON.stringify(["显示颜色选项", "亮度", "对比度", "饱和度", "色调偏移"]),
"勾上「显示颜色选项」后出现四个滑杆",
labelsOf(r.withColorOptions).slice(2, 7).join(" / "),
);
check((r.withColorOptions?.rows ?? []).filter((x) => x.control === "range").length === 5, "共 5 个滑杆(4 颜色 + 1 音量)");
check(/brightness/.test(r.afterBrightness ?? ""), "亮度落到 body 的 CSS 滤镜上", r.afterBrightness);
check(r.afterFlip === "scaleX(-1)", "翻转落到 body 的 transform 上", r.afterFlip);
check(r.initial?.outsideBody === true, "面板挂在 <html> 下(body 上的 transform/filter 不会波及它)");
console.log("\n=== ④ 取色器弹层 ===");
check(r.picker !== null, "点色块弹出取色器");
check(r.picker?.outsideBody === true, "取色器也在 body 之外(不被滤镜调色)");
check(/主题配色/.test(r.picker?.name ?? ""), "弹层标题是「主题配色」", r.picker?.name);
check(r.picker?.palette === 15, "调色板 15 个色块(3 列 × 5 行)", `${r.picker?.palette} 个`);
const official = [
"#ffffff", "#c0c0c0", "#000000",
"#ff0000", "#ffa500", "#ffff00",
"#00ff00", "#008000", "#254117",
"#add8e6", "#0000ff", "#00008b",
"#00ffff", "#800080", "#ff00ff",
];
check(
JSON.stringify(r.picker?.colors) === JSON.stringify(official),
"15 个预设色与官方逐个一致",
JSON.stringify(r.picker?.colors) === JSON.stringify(official) ? "一致" : (r.picker?.colors ?? []).join(" "),
);
check(r.picker?.checked === "#63269e" || r.picker?.checked === null, "初始颜色不是预设色时没有对勾", String(r.picker?.checked));
check(r.pickerChecked?.checked === "#ff0000", "颜色改成预设色后,对勾落到那个色块上", String(r.pickerChecked?.checked));
check(r.picker?.dropper === true, "调色板下方有吸管按钮");
check(
r.picker?.dropperDisabled === false || r.picker?.dropperDisabled === true,
r.picker?.dropperDisabled ? "吸管已禁用并说明原因(本机 CEF 无 EyeDropper API)" : "吸管可用(EyeDropper API 存在)",
r.picker?.dropperTitle,
);
check(r.picker?.hasSv === true && r.picker?.hasHue === true, "有明度/饱和度方块与色相条");
check(/^#[0-9a-f]{6}$/i.test(r.picker?.hex ?? ""), "十六进制输入框有值", r.picker?.hex);
check(JSON.stringify(r.picker?.foot) === JSON.stringify(["确认", "取消"]), "弹层底部是 确认 / 取消", (r.picker?.foot ?? []).join(" / "));
check(/^0\.\d+ 0\.\d+ 0\.\d+$/.test(r.colorAfterPick ?? ""), "确认后写回 schemecolor(r g b 浮点)", r.colorAfterPick);
check(r.pickerGone === true, "确认后弹层关闭");
console.log("\n=== ⑤ type:text 渲染成富文本说明块 ===");
const notes = r.initial?.notes ?? [];
check(notes.length >= 3, "说明块都在", `${notes.length} 个`);
check(notes.filter((n) => n.items > 0).length >= 2, "说明块里有 <ul><li> 列表");
const links = notes.flatMap((n) => n.links ?? []);
check(links.length > 0 && links.every((h) => /^https?:/.test(h ?? "")), "链接被保留且只放行 http(s)", links.join(" | "));
console.log("\n=== ⑥ condition:两个开关各管一摊 ===");
check(!(r.hidden?.notes ?? []).some((n) => n.text.includes("作者:")), "关掉「显示作者信息」→ 作者信息块消失");
check(!labelsOf(r.customAudio).includes("背景音乐选择"), "开「使用自定义音乐」→ 背景音乐选择隐藏");
check(labelsOf(r.customAudio).includes("音频文件路径"), "开「使用自定义音乐」→ 音频文件路径出现");
check(labelsOf(r.presetAudio).includes("背景音乐选择"), "关「使用自定义音乐」→ 背景音乐选择回来");
check(!labelsOf(r.presetAudio).includes("音频文件路径"), "关「使用自定义音乐」→ 音频文件路径隐藏");
console.log("\n=== ⑦ 底部 确认 / 取消 + 重置语义 ===");
check(JSON.stringify(r.initial?.foot) === JSON.stringify(["确认", "取消"]), "底部是 确认 / 取消", (r.initial?.foot ?? []).join(" / "));
check(r.afterEdit === 0.3, "改音量已实时下发", String(r.afterEdit));
check(r.afterReset === 1, "重置恢复默认值 1", String(r.afterReset));
check(r.beforeCancel === 0.2 && r.afterCancel === 1, "取消回滚到上次确认的值", `${r.beforeCancel} → ${r.afterCancel}`);
check(r.afterConfirm === 0.6, "确认保留改动", String(r.afterConfirm));
console.log("\n=== ⑧ 模拟器专属内容收进折叠区 ===");
check(r.initial?.hasDebug === true && r.initial?.debugOpen === false, "「模拟器调试」存在且默认收起");
console.log("\n=== ⑨ 下拉框(自绘,样式与官方同步)===");
check(r.comboOpen !== null, "点下拉按钮弹出列表");
check(r.comboOpen?.outsideBody === true, "弹层挂在 documentElement 下(不被 body 的滤镜波及)");
const comboItems = r.comboOpen?.items ?? [];
console.log(` 选项:${comboItems.map((x) => `${x.label}${x.on ? "(当前)" : ""}`).join(" / ")}`);
check(comboItems.length === 2, "两个预设都在列表里", `${comboItems.length} 项`);
check(comboItems.filter((x) => x.on).length === 1, "当前项恰好一个被高亮", comboItems.filter((x) => x.on).map((x) => x.label).join(""));
check(r.comboClosed === true, "选完之后弹层收起");
check(r.presetAfter === comboItems[1]?.value, "选中的值下发给壁纸", `${r.presetAfter}`);
check(r.comboLabelAfter === comboItems[1]?.label, "按钮上的文字跟着换成所选项", String(r.comboLabelAfter));
console.log("\n=== ⑩ 动效(只解释状态变化,且受 prefers-reduced-motion 管辖)===");
check(r.anim?.open === "1", "面板已滑出", String(r.anim?.open));
check(r.anim?.dataEnter === "1", "刚滑出时打了入场标记", String(r.anim?.dataEnter));
check(/wesim-row-in/.test(r.anim?.rowAnim ?? ""), "行上跑的是入场动画", r.anim?.rowAnim);
check(
(r.anim?.seq ?? []).join(",") === "0,1,2,3,4",
"行带递增的 --i(用来错开入场)",
(r.anim?.seq ?? []).join(","),
);
check(r.afterEnter === null, "入场标记播完就摘掉(不会每次重画都重播)", String(r.afterEnter));
check(/background-color|transform/.test(r.anim?.hoverTransition ?? ""), "行有悬停过渡", r.anim?.hoverTransition);
check(/wesim-pop/.test(r.anim?.pickerAnim ?? ""), "取色器弹层有出现动画", r.anim?.pickerAnim);
const entered = (r.enterFlags ?? []).filter((x) => x.enter).map((x) => x.label);
console.log(` 带入场标记的行:${entered.join(" / ") || "(无)"}`);
check(
JSON.stringify(entered) === JSON.stringify(["亮度", "对比度", "饱和度", "色调偏移"]),
"只有因条件变化新冒出来的行才播入场",
entered.join(" / "),
);
check(r.checkbox?.appearance === "none", "复选框是自绘的(原生外观已关掉)", String(r.checkbox?.appearance));
check(r.arrowOpen?.expanded === "true", "展开时按钮 aria-expanded=true");
check(/matrix\(-1/.test(r.arrowOpen?.rotated ?? ""), "箭头翻了过来", r.arrowOpen?.rotated);
check(r.arrowClosed === "false", "收起后 aria-expanded 复位", String(r.arrowClosed));
check(
parseFloat(r.reduced?.rowAnim ?? "1") < 0.01,
"prefers-reduced-motion: reduce 时入场动画被关掉",
String(r.reduced?.rowAnim),
);
check(
parseFloat(r.reduced?.rowTransition ?? "1") < 0.01,
"prefers-reduced-motion: reduce 时过渡也被关掉",
String(r.reduced?.rowTransition),
);
console.log("\n=== ⑪ 预览图(官方在设置上方就展示它)===");
check(r.preview?.present === true, "预览区已渲染", String(r.preview?.present));
check(/preview\.gif$/.test(r.preview?.src ?? ""), "指向分发根的 preview.gif", String(r.preview?.src));
check(r.preview?.natural === "160x160", "图片真的解码出来了(不是坏图)", String(r.preview?.natural));
// 官方那对左右箭头是切换"已安装的壁纸",模拟器没有那个列表——**不做**。
// 这条断言是防回归的:它们曾经存在过(当时拿"切预设"当替身,但那是另一件事)。
check(
(r.preview?.navs ?? []).length === 0,
"预览上没有左右箭头(WE 那个是切换壁纸用的,我们不需要)",
`${(r.preview?.navs ?? []).length} 个`,
);
console.log("\n=== ⑫ 单档分发里的「背景音乐选择」===");
// 规则:一个分发里可选的音源(不含「随预设」)≥2 才给下拉,否则两个选项效果一样、是废话。
check(r.singleTwoTracks?.present === true, "single-hsr-xilian(两首)有「背景音乐选择」", JSON.stringify(r.singleTwoTracks));
check(
JSON.stringify(r.singleTwoTrackItems) === JSON.stringify(["随预设", "「再度和你」", "昔涟"]),
"下拉里就是 xilian 的两首(+ 随预设)",
(r.singleTwoTrackItems ?? []).join(" / "),
);
// 默认值是 "auto"(随预设)——与合集分发一致,不是"第一首"。
// 运行时拿到 auto 就回落该壁纸的默认音源,也就是第一首,所以效果相同;
// 但值本身必须是 auto,否则用户从合集切到单档时选择会被"钉死"。
check(
r.singleTwoTracks?.value === "auto" && r.singleTwoTracks?.label === "随预设",
"默认是「随预设」(auto),与合集分发一致",
`${r.singleTwoTracks?.value} / ${r.singleTwoTracks?.label}`,
);
check(
r.singleOneTrack?.present === false,
"single-hsr-kv37(只有一首)不给这个下拉——两个选项效果完全一样",
JSON.stringify(r.singleOneTrack),
);
const noisy = (r.consoleLines ?? []).filter((l) => /error|uncaught/i.test(l));
check((r.exceptions ?? []).length === 0, "0 未捕获异常", (r.exceptions ?? []).slice(0, 2).join(" | ") || "无");
check(noisy.length === 0, "0 控制台报错", noisy.slice(0, 3).join(" | ") || "干净");
console.log(failures === 0 ? "\n面板还原度:通过" : `\n面板还原度:失败 ${failures} 处`);
process.exit(failures === 0 ? 0 : 1);
+410
View File
@@ -0,0 +1,410 @@
// 场景播放器的门:多骨架 + 贴图平面真的合成出来了,而且**先证明它会红**。
//
// 不碰 `wallpapers/`:夹具用抓取器 staged 的 kv45/scene_main(4 骨架 + 1 平面)自造,
// 预设写成与构建产物同形的 `preset.js`(同一个 asset()/相对路径解析规则)。
//
// 前置:`python -m tools.downloader fetch --page kv45`(产物在 tools/downloader/_out/hsr/kv45/scene_main)。
// 本脚本自己跑 `tsc -p tsconfig.runtime.json`,所以不依赖 pnpm build。
import { spawn, spawnSync } from "node:child_process";
import { createServer } from "node:http";
import { createReadStream, existsSync } from "node:fs";
import { cp, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
import { dirname, extname, join, normalize, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { withPage, sleep } from "../cdp.mjs";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
const FIXTURE = join(ROOT, "tools", ".cache", "scene-player-fixture");
const STAGED = join(ROOT, "tools", "downloader", "_out", "hsr", "kv45", "scene_main");
/** 透视样本:原神页,camera type 1 + 真实景深(kv45 是正交、z 全 0,抓不住投影算错)。 */
const STAGED_PERSPECTIVE = join(ROOT, "tools", "downloader", "_out", "ys", "nico-tea", "scene_main");
const SHOT = join(ROOT, "tools", ".cache", "scene-player-shot.png");
const PORT = Number(process.env.SCENE_PORT ?? 8197);
const W = 1280;
const H = 720;
let failures = 0;
const check = (ok: unknown, label: string, detail?: unknown) => {
if (!ok) failures++;
console.log(` ${ok ? "✓" : "✗"} ${label}${detail !== undefined ? ` → ${String(detail)}` : ""}`);
};
interface SceneDebug {
parts: number;
loaded: number;
errors: string[];
framing: { x: number; y: number; width: number; height: number } | null;
content: { x: number; y: number; width: number; height: number } | null;
spines: string[];
images: string[];
solids: number;
rotatedSkipped: number;
}
interface StagedPart {
kind: string;
id: string;
order: number;
renderOrder?: number;
position: number[];
scale: number[];
geometrySize?: number[];
geometryCenter?: number[];
rotation?: number[];
animation?: string;
skin?: string;
timeScale?: number;
}
/** 把 staged 的 scene.json 翻成 preset 形状(与 tools/lib/generate.ts 吐出的字面量同形)。 */
async function buildPresetModule(
staged: string = STAGED,
id = "kv45",
game = "hsr",
cover: string | null = "w22_slg",
): Promise<string> {
const scene = JSON.parse(await readFile(join(staged, "scene.json"), "utf8")) as {
ui?: number[];
parts: StagedPart[];
camera?: { camera?: { type?: number; fov?: number }; position?: number[] };
};
const camera = scene.camera?.camera ?? {};
const cameraLine =
camera.type === undefined
? ""
: ` camera: ${JSON.stringify({ type: camera.type, fov: camera.fov, position: scene.camera?.position })},\n`;
const parts: string[] = [];
for (const part of scene.parts) {
// 与 promote.py 同语义:纯色平面不进预设(运行时画不了它,写进去只是死配置)。
if (part.kind === "solid") continue;
const fields: string[] = [
`kind: ${JSON.stringify(part.kind)}`,
`id: ${JSON.stringify(part.id)}`,
`order: ${JSON.stringify(part.order)}`,
`position: ${JSON.stringify(part.position)}`,
`scale: ${JSON.stringify(part.scale)}`,
];
if (part.renderOrder) fields.push(`renderOrder: ${JSON.stringify(part.renderOrder)}`);
if (part.kind === "spine") {
fields.push(`jsonUrl: asset("../assets/spines/${part.id}/${part.id}.json")`);
fields.push(`atlasUrl: asset("../assets/spines/${part.id}/${part.id}.atlas")`);
} else if (part.kind === "image") {
const dir = join(staged, "scene");
const files = existsSync(dir) ? await readdir(dir) : [];
const hit = files.find((f) => f.startsWith(`${part.id}.`));
if (!hit) continue;
fields.push(`image: asset("../assets/scene/${hit}")`);
}
if (part.geometrySize) {
fields.push(`width: ${JSON.stringify(part.geometrySize[0])}`);
fields.push(`height: ${JSON.stringify(part.geometrySize[1])}`);
}
if (part.geometryCenter) fields.push(`center: ${JSON.stringify(part.geometryCenter)}`);
if (part.rotation && part.rotation.some((v) => Math.abs(v) > 1e-9)) {
fields.push(`rotation: ${JSON.stringify(part.rotation)}`);
}
if (part.animation) fields.push(`animation: ${JSON.stringify(part.animation)}`);
if (part.skin) fields.push(`skin: ${JSON.stringify(part.skin)}`);
if (part.timeScale !== undefined) fields.push(`timeScale: ${JSON.stringify(part.timeScale)}`);
parts.push(` { ${fields.join(", ")} },`);
}
return [
`const base = new URL("./", import.meta.url);`,
`const asset = (path) => new URL(path, base).href;`,
`export default {`,
` id: ${JSON.stringify(id)},`,
` name: ${JSON.stringify(id)},`,
` game: ${JSON.stringify(game)},`,
// 不设背景图:背景比例链会改取景,门要的是**纯视锥**那一条路径。
` backgroundImage: ${cover ? `asset("../assets/scene/${cover}.png")` : `""`},`,
` sceneConfig: {`,
` ui: ${JSON.stringify(scene.ui ?? [2500, 1080])},`,
cameraLine + ` parts: [`,
...parts,
` ],`,
` },`,
` audioChoices: [],`,
` audioOptions: { source: "" },`,
`};`,
``,
].join("\n");
}
/** 用 ffmpeg 把 PNG 解成 RGBA 原始像素。 */
function rawPixels(png: string): Promise<Buffer> {
return new Promise((resolvePromise) => {
const p = spawn("ffmpeg", ["-v", "error", "-i", png, "-f", "rawvideo", "-pix_fmt", "rgba", "-"], {
stdio: ["ignore", "pipe", "ignore"],
});
const chunks: Buffer[] = [];
p.stdout.on("data", (d: Buffer) => chunks.push(d));
p.on("close", () => resolvePromise(Buffer.concat(chunks)));
p.on("error", () => resolvePromise(Buffer.alloc(0)));
});
}
/** 两张同尺寸截图里有多少比例的像素不同。 */
function diffRatio(a: Buffer, b: Buffer): number {
if (!a.length || a.length !== b.length) return -1;
let different = 0;
for (let i = 0; i < a.length; i += 4) {
if (a[i] !== b[i] || a[i + 1] !== b[i + 1] || a[i + 2] !== b[i + 2] || a[i + 3] !== b[i + 3]) different++;
}
return different / (a.length / 4);
}
const MIME: Record<string, string> = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".mjs": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".json": "application/json; charset=utf-8",
".atlas": "text/plain; charset=utf-8",
".png": "image/png",
".jpg": "image/jpeg",
".webp": "image/webp",
};
// ---------------------------------------------------------------------------------------
// 前置条件
// ---------------------------------------------------------------------------------------
console.log("场景播放器门:\n");
if (!existsSync(join(STAGED, "scene.json"))) {
console.log(` ✗ 缺少 staged 样本:${STAGED}\n 先跑:python -m tools.downloader fetch --page kv45`);
process.exit(1);
}
const build = spawnSync(process.execPath, ["node_modules/typescript/bin/tsc", "-p", "tsconfig.runtime.json"], {
cwd: ROOT,
encoding: "utf8",
});
check(build.status === 0, "运行时能编译(tsc -p tsconfig.runtime.json)", (build.stdout ?? "").trim().split("\n").pop() ?? "");
// ---------------------------------------------------------------------------------------
// 造夹具
// ---------------------------------------------------------------------------------------
await rm(FIXTURE, { recursive: true, force: true });
await mkdir(join(FIXTURE, "scripts"), { recursive: true });
await mkdir(join(FIXTURE, "styles"), { recursive: true });
await cp(STAGED, join(FIXTURE, "assets"), { recursive: true });
await cp(STAGED_PERSPECTIVE, join(FIXTURE, "assets-perspective"), { recursive: true });
for (const file of await readdir(join(ROOT, "build", "scripts"))) {
if (file.endsWith(".js")) await cp(join(ROOT, "build", "scripts", file), join(FIXTURE, "scripts", file));
}
await cp(join(ROOT, "src", "vendor", "spine-player.js"), join(FIXTURE, "scripts", "spine-player.js"));
for (const file of await readdir(join(ROOT, "src", "styles"))) {
await cp(join(ROOT, "src", "styles", file), join(FIXTURE, "styles", file));
}
const presetModule = await buildPresetModule();
await writeFile(join(FIXTURE, "scripts", "scene-preset.js"), presetModule, "utf8");
// 第二个夹具页:透视样本(把 assets 前缀改到 assets-perspective/)
const perspectiveModule = (await buildPresetModule(STAGED_PERSPECTIVE, "nico-tea", "ys", null)).replaceAll(
"../assets/",
"../assets-perspective/",
);
await writeFile(join(FIXTURE, "scripts", "scene-preset-perspective.js"), perspectiveModule, "utf8");
await writeFile(
join(FIXTURE, "scripts", "presets.js"),
[
`import preset from "./scene-preset.js";`,
`import perspective from "./scene-preset-perspective.js";`,
`export const defaultPresetId = "kv45";`,
`export default Object.fromEntries([["kv45", preset], ["nico-tea", perspective]]);`,
``,
].join("\n"),
"utf8",
);
await writeFile(
join(FIXTURE, "index.html"),
[
`<!DOCTYPE html>`,
`<html lang="zh"><head><meta charset="UTF-8" /><title>scene fixture</title>`,
`<link rel="icon" href="data:," />`,
`<link rel="stylesheet" href="./styles/spine-player.css" />`,
`<link rel="stylesheet" href="./styles/index.css" /></head>`,
`<body><div id="spine-container"></div><audio id="background-music"></audio>`,
`<script src="./scripts/spine-player.js"></script>`,
`<script type="module" src="./scripts/index.js"></script></body></html>`,
``,
].join("\n"),
"utf8",
);
check(existsSync(join(FIXTURE, "scripts", "scene-controller.js")), "夹具里带上了 scene-controller.js");
// ---------------------------------------------------------------------------------------
// 起静态服务
// ---------------------------------------------------------------------------------------
const server = createServer(async (req, res) => {
const pathname = decodeURIComponent(new URL(req.url ?? "/", "http://x").pathname);
const rel = normalize(pathname).replace(/^([/\\])+/, "");
const target = resolve(join(FIXTURE, rel === "" ? "index.html" : rel));
if (!target.startsWith(FIXTURE)) {
res.writeHead(403).end();
return;
}
try {
const info = await stat(target);
if (info.isDirectory()) {
res.writeHead(404).end();
return;
}
res.writeHead(200, { "content-type": MIME[extname(target).toLowerCase()] ?? "application/octet-stream" });
createReadStream(target).pipe(res);
} catch {
res.writeHead(404).end();
}
});
await new Promise<void>((r) => server.listen(PORT, "127.0.0.1", () => r()));
const BASE = `http://127.0.0.1:${PORT}/`;
const readDebug = async (evaluate: (expr: string) => Promise<unknown>): Promise<SceneDebug | null> => {
const raw = await evaluate("JSON.stringify(window.__sceneDebug ?? null)");
return raw && raw !== "null" ? (JSON.parse(String(raw)) as SceneDebug) : null;
};
const waitFor = async (evaluate: (expr: string) => Promise<unknown>, predicate: (d: SceneDebug | null) => boolean, tries = 40): Promise<SceneDebug | null> => {
let last: SceneDebug | null = null;
for (let i = 0; i < tries; i++) {
last = await readDebug(evaluate);
if (predicate(last)) return last;
await sleep(250);
}
return last;
};
// ---------------------------------------------------------------------------------------
// 绿:多骨架 + 贴图平面真的合成出来
// ---------------------------------------------------------------------------------------
await withPage({ width: W, height: H, profile: "tools/.cache/edge-scene" }, async ({ send, evaluate, exceptions }) => {
await send("Page.navigate", { url: BASE });
const debug = await waitFor(evaluate, (d) => Boolean(d && d.loaded >= d.parts && d.parts > 0));
console.log(` · __sceneDebug = ${JSON.stringify(debug)}`);
check(Boolean(debug), "页面写出了 __sceneDebug");
check(debug?.parts === 5, "part 数 = 4 骨架 + 1 平面 = 5", debug?.parts);
check(debug?.loaded === 5, "5 件全部加载", debug?.loaded);
check((debug?.errors.length ?? 1) === 0, "0 加载错误", debug?.errors.join(" | "));
check(debug?.spines.length === 4, "4 具骨架都建了 AnimationState", debug?.spines.join(","));
check(debug?.images.length === 1, "1 块贴图平面", debug?.images.join(","));
check(Boolean(debug?.framing && debug.framing.width > 0 && debug.framing.height > 0), "取景矩形有效", JSON.stringify(debug?.framing));
// resize(Expand) 把画布像素尺寸设成 clientWidth * devicePixelRatio,所以按 dpr 比。
const canvasSize = String(
await evaluate(
`(() => { const c = document.querySelector("#spine-container canvas"); if (!c) return ""; const dpr = window.devicePixelRatio || 1; return [c.width, c.height, Math.round(c.clientWidth * dpr), Math.round(c.clientHeight * dpr)].join(","); })()`,
),
);
const [cw, ch, expectW, expectH] = canvasSize.split(",").map(Number);
check(cw === expectW && ch === expectH && cw! > 0, "画布像素尺寸 = CSS 尺寸 × dpr", canvasSize);
const glError = Number(await evaluate(`(() => { const c = document.querySelector("#spine-container canvas"); if (!c) return -1; const gl = c.getContext("webgl2") || c.getContext("webgl"); return gl ? gl.getError() : -1; })()`));
if (glError === -1) console.log(" · 拿不到 WebGL 上下文(类型不匹配),跳过 gl.getError 断言");
else check(glError === 0, "gl.getError() === 0", glError);
check(exceptions.length === 0, "0 未捕获异常", exceptions.slice(0, 2).join(" | ") || "无");
// 画面非空:**隐藏画布前后截图必须不同**。
// 不能数"alpha>0 的像素"——页面自身有底色,空画布也全是不透明像素(实测 100%,是个假绿)。
const shotWith = (await send("Page.captureScreenshot", { format: "png" })) as { data: string };
await writeFile(SHOT, Buffer.from(shotWith.data, "base64"));
await evaluate(`(() => { const c = document.querySelector("#spine-container canvas"); if (c) c.style.visibility = "hidden"; })()`);
await sleep(300);
const shotWithout = (await send("Page.captureScreenshot", { format: "png" })) as { data: string };
const hidden = join(ROOT, "tools", ".cache", "scene-player-shot-hidden.png");
await writeFile(hidden, Buffer.from(shotWithout.data, "base64"));
const [withPixels, withoutPixels] = await Promise.all([rawPixels(SHOT), rawPixels(hidden)]);
const ratio = diffRatio(withPixels, withoutPixels);
check(ratio > 0.02, "画布确实贡献了像素(隐藏画布后画面变化 > 2%)", `${(ratio * 100).toFixed(1)}%(截图 ${SHOT})`);
await evaluate(`(() => { const c = document.querySelector("#spine-container canvas"); if (c) c.style.visibility = "visible"; })()`);
});
// ---------------------------------------------------------------------------------------
// 纯色平面:不能被当贴图画(原来会给 drawTexture 传 undefined 直接崩)
// ---------------------------------------------------------------------------------------
await withPage({ width: W, height: H, profile: "tools/.cache/edge-scene" }, async ({ send, evaluate, exceptions }) => {
const withSolid = presetModule.replace(
` ],\n },`,
` { kind: "solid", id: "DEFAULT", order: 99, position: [0, 0, 0], scale: [1, 1, 1], width: 100, height: 100 },\n ],\n },`,
);
check(withSolid !== presetModule, "反例确实加进了一块纯色平面");
await writeFile(join(FIXTURE, "scripts", "scene-preset.js"), withSolid, "utf8");
await send("Page.navigate", { url: `${BASE}?solid=1` });
const debug = await waitFor(evaluate, (d) => Boolean(d && d.loaded >= 5));
check(debug?.solids === 1, "纯色平面被计数(solids = 1)", debug?.solids);
check(exceptions.length === 0, "纯色平面不崩(0 未捕获异常)", exceptions.slice(0, 1).join("") || "无");
});
// ---------------------------------------------------------------------------------------
// 透视场景:取景矩形必须等于**独立算出来**的视锥(这条能抓住"投影/取景算错")
// ---------------------------------------------------------------------------------------
await withPage({ width: W, height: H, profile: "tools/.cache/edge-scene" }, async ({ send, evaluate, exceptions }) => {
await send("Page.navigate", { url: `${BASE}?perspective=1` });
// 等运行时挂上监听器(它在 index.js 顶层注册,模块执行完才存在),再走 WE 的真实路径切预设。
let ready = false;
for (let i = 0; i < 40 && !ready; i++) {
ready = Boolean(await evaluate("typeof window.wallpaperPropertyListener === 'object' && window.wallpaperPropertyListener !== null"));
if (!ready) await sleep(250);
}
check(ready, "运行时挂上了 wallpaperPropertyListener");
await evaluate(`window.wallpaperPropertyListener.applyUserProperties({ preset: { value: "nico-tea" } })`);
const debug = await waitFor(evaluate, (d) => Boolean(d && d.parts > 20 && d.loaded > 20));
console.log(` · 透视 __sceneDebug = ${JSON.stringify(debug && { parts: debug.parts, loaded: debug.loaded, framing: debug.framing, rotatedSkipped: debug.rotatedSkipped })}`);
check(debug?.parts === 43, "透视样本 part 数 = 43", debug?.parts);
check((debug?.errors.length ?? 1) === 0, "透视样本 0 加载错误", debug?.errors.slice(0, 2).join(" | "));
check(debug?.rotatedSkipped === 2, "两片倾斜的 3D 面片被跳过并计数", debug?.rotatedSkipped);
check(exceptions.length === 0, "透视样本 0 未捕获异常", exceptions.slice(0, 1).join("") || "无");
// 独立算期望:s0 = 1/(tan(fov/2)·camZ);可见区 = ui 矩形 × s0;再按画布比例 contain。
const scene = JSON.parse(await readFile(join(STAGED_PERSPECTIVE, "scene.json"), "utf8")) as {
ui?: number[];
camera?: { camera?: { fov?: number }; position?: number[] };
};
const fov = scene.camera?.camera?.fov ?? 31.417;
const camZ = scene.camera?.position?.[2] ?? 1920;
const uiSize = scene.ui ?? [2500, 1080];
const uiW = uiSize[0] ?? 2500;
const uiH = uiSize[1] ?? 1080;
const s0 = 1 / (Math.tan((fov * Math.PI) / 360) * camZ);
const aspect = W / H;
// 复刻引擎 `resizeUI`:可见矩形 = UI 矩形按画布比收缩一个轴(b<1 收宽,否则收高)。
const b = aspect / (uiW / uiH);
const visW = b < 1 ? uiW * b : uiW;
const visH = b < 1 ? uiH : uiH / b;
const expectedW = visW * s0;
const expectedH = visH * s0;
const framing = debug?.framing;
const close = (a: number | undefined, b: number) => typeof a === "number" && Math.abs(a - b) / b < 1e-6;
check(close(framing?.width, expectedW), `取景宽 = 视锥宽(独立算 ${expectedW.toFixed(4)})`, framing?.width);
check(close(framing?.height, expectedH), `取景高 = 视锥高(独立算 ${expectedH.toFixed(4)})`, framing?.height);
check(close(framing?.x, -expectedW / 2) && close(framing?.y, -expectedH / 2), "取景以场景原点为中心", JSON.stringify(framing));
});
// ---------------------------------------------------------------------------------------
// 红:把一具骨架的 atlas 指错 → 必须报出来
// ---------------------------------------------------------------------------------------
await withPage({ width: W, height: H, profile: "tools/.cache/edge-scene" }, async ({ send, evaluate }) => {
const broken = presetModule.replace(
`atlasUrl: asset("../assets/spines/01_beijing/01_beijing.atlas")`,
`atlasUrl: asset("../assets/spines/01_beijing/does-not-exist.atlas")`,
);
check(broken !== presetModule, "反例确实改动了 atlasUrl");
await writeFile(join(FIXTURE, "scripts", "scene-preset.js"), broken, "utf8");
await send("Page.navigate", { url: `${BASE}?red=1` });
const debug = await waitFor(evaluate, (d) => Boolean(d && d.errors.length > 0), 20);
console.log(` · 反例 __sceneDebug = ${JSON.stringify(debug)}`);
check(Boolean(debug && debug.errors.length > 0), "atlas 指错 → 报出加载错误", debug?.errors.slice(0, 1).join(""));
check(Boolean(debug && debug.loaded < (debug?.parts ?? 0)), "atlas 指错 → 少加载一件", `${debug?.loaded}/${debug?.parts}`);
});
// ---------------------------------------------------------------------------------------
// 还原:必须重新变绿(证明红是那处改动引起的)
// ---------------------------------------------------------------------------------------
await writeFile(join(FIXTURE, "scripts", "scene-preset.js"), presetModule, "utf8");
await withPage({ width: W, height: H, profile: "tools/.cache/edge-scene" }, async ({ send, evaluate }) => {
await send("Page.navigate", { url: `${BASE}?restore=1` });
const debug = await waitFor(evaluate, (d) => Boolean(d && d.loaded >= d.parts && d.parts > 0));
check(debug?.loaded === 5 && (debug?.errors.length ?? 1) === 0, "还原 atlasUrl → 重新全绿", `${debug?.loaded}/${debug?.parts}`);
});
server.close();
console.log(`\n夹具留在 ${FIXTURE}(可直接起服务器用眼睛看)`);
console.log(failures === 0 ? "\n场景播放器门:通过" : `\n场景播放器门:失败 ${failures} 处`);
process.exit(failures === 0 ? 0 : 1);
+153
View File
@@ -0,0 +1,153 @@
// 验收 `pnpm build --sim`:把产出的自包含包用 file:// 直接打开,确认
// ① 骨架真的画出来了(canvas 出现且有非空像素)
// ② 0 网络请求失败、0 异常、0 控制台报错
// ③ 模拟器面板在,且明确标着"模拟环境"
// ④ 音频能播(data: URL 内联)
// ⑤ 全程没有任何 http(s) 请求("自包含"的硬定义)
//
// 用法:node tools/checks/verify-sim-page.mts [分发目录名]
import { withPage, sleep } from "../cdp.mjs";
import { existsSync, statSync } from "node:fs";
import { resolve } from "node:path";
const dirName = process.argv[2] ?? "single-hsr-kv37";
const pageAbs = resolve("dist/releases", dirName, "sim/index.html");
if (!existsSync(pageAbs)) {
console.error(`找不到 ${pageAbs}(先跑 node tools/build.ts --single kv37 --sim)`);
process.exit(1);
}
const fileUrl = "file:///" + pageAbs.replace(/\\/g, "/");
let failures = 0;
const check = (ok: unknown, label: string, detail?: unknown) => {
if (!ok) failures++;
console.log(` ${ok ? "✓" : "✗"} ${label}${detail !== undefined ? ` → ${String(detail)}` : ""}`);
};
console.log(`自包含包:${pageAbs}`);
console.log(`体积:${(statSync(pageAbs).size / 1024 / 1024).toFixed(1)} MB\n`);
await withPage({ width: 1920, height: 1080 }, async ({ send, evaluate, consoleLines, exceptions }) => {
const rawSend = send;
await rawSend("Page.navigate", { url: fileUrl });
const deadline = Date.now() + 45000;
let ready = false;
while (Date.now() < deadline) {
const canvasCount = await evaluate('document.querySelectorAll("#spine-container canvas").length');
if (canvasCount > 0) {
ready = true;
break;
}
await sleep(250);
}
await sleep(2500);
// ⑤ "自包含"的硬定义:这个文档取过的资源里不能有任何 http(s)。
// 用页面侧的 Resource Timing,不需要 CDP 的事件通道。
const externalRequests = await evaluate(`(function () {
return performance.getEntriesByType("resource")
.map(function (e) { return e.name; })
.filter(function (n) { return /^https?:/i.test(n); });
})()`);
check(
Array.isArray(externalRequests) && externalRequests.length === 0,
"全程没有任何 http(s) 请求(自包含的硬定义)",
Array.isArray(externalRequests) && externalRequests.length > 0 ? externalRequests.slice(0, 3).join(", ") : "无",
);
const state = await evaluate(`(function () {
var canvas = document.querySelector("#spine-container canvas");
var out = {
canvas: document.querySelectorAll("#spine-container canvas").length,
canvasSize: canvas ? canvas.clientWidth + "x" + canvas.clientHeight : null,
bg: (document.body.style.backgroundImage || "").slice(0, 24),
bgIsData: /^url\\(["']?data:/.test(document.body.style.backgroundImage || ""),
sim: !!window.__weSim,
banner: document.getElementById("wesim") ? document.querySelector("#wesim .banner").textContent.trim() : null,
// 自包含页在 <分发根>/sim/ 下,预览图必须已被内联成 data: URL——
// file:// 下 ../preview.gif 是跨目录,读不到。
previewSrc: (function () { var i = document.querySelector("#wesim .preview img"); return i ? i.getAttribute("src").slice(0, 5) : null; })(),
previewNatural: (function () { var i = document.querySelector("#wesim .preview img"); return i ? i.naturalWidth + "x" + i.naturalHeight : null; })(),
modules: window.__weModules ? Object.keys(window.__weModules).length : 0,
assetKeys: window.__simAssets ? Object.keys(window.__simAssets).length : 0,
audioSrcIsData: false,
audioDuration: null
};
var a = document.getElementById("background-music");
if (a) {
out.audioSrcIsData = /^data:/.test(a.src || "");
out.audioDuration = isFinite(a.duration) ? a.duration : null;
out.audioError = a.error ? a.error.code : null;
}
return out;
})()`);
console.log("=== 渲染 ===");
check(ready, "出现 Spine 画布", `canvas=${state.canvas}`);
check(state.canvas === 1, "只有一张 canvas", `canvas=${state.canvas}`);
check(state.canvasSize === "1920x1080", "画布尺寸等于窗口", state.canvasSize);
// 画布真的画了东西:跨帧采样非透明比例。
//
// 两个坑叠在一起,先前两次都判成了"空白":
// ① 页面里有两张 canvas——spine 自己建的那张(承载渲染)和一张 300×150 的默认尺寸空画布。
// 按 `#spine-container canvas` 取到的是后者,尺寸就不对。
// ② 播放器用 `preserveDrawingBuffer: false`(默认),**帧外**读像素拿到的永远是已清空的缓冲。
// 只读一次必然是 0%,与画没画无关。必须在 rAF 里连续采样、取历史最大值。
await evaluate(`(function () {
var list = [].slice.call(document.querySelectorAll("#spine-container canvas"));
var canvas = list.sort(function (a, b) { return b.clientWidth * b.clientHeight - a.clientWidth * a.clientHeight; })[0];
if (!canvas) { window.__px = { error: "没有 canvas" }; return; }
var gl = canvas.getContext("webgl2") || canvas.getContext("webgl");
if (!gl) { window.__px = { error: "拿不到 WebGL 上下文" }; return; }
window.__px = { best: 0, frames: 0, w: canvas.width, h: canvas.height, count: list.length };
var buf = new Uint8Array(canvas.width * canvas.height * 4);
var tick = function () {
gl.readPixels(0, 0, canvas.width, canvas.height, gl.RGBA, gl.UNSIGNED_BYTE, buf);
var n = 0;
for (var i = 3; i < buf.length; i += 4) if (buf[i] > 8) n++;
var ratio = n / (canvas.width * canvas.height);
if (ratio > window.__px.best) window.__px.best = ratio;
window.__px.frames++;
if (window.__px.frames < 90) requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
})()`);
await sleep(2500);
const pixels = await evaluate("window.__px");
check(
pixels !== null && pixels.best !== undefined && pixels.best > 0.02,
"画布上有实际像素(不是空白)",
pixels === null
? "没有 canvas"
: pixels.error
? pixels.error
: `非透明占比 ${((pixels.best ?? 0) * 100).toFixed(1)}%(${pixels.w}x${pixels.h},采样 ${pixels.frames} 帧)`,
);
console.log("\n=== 自包含性 ===");
check(state.bgIsData, "背景图是 data: URL(没有外部文件依赖)", state.bg);
check(state.assetKeys > 0, "内联资源表已建立", `${state.assetKeys} 个 key`);
check(state.modules >= 9, "模块注册表已建立", `${state.modules} 个模块`);
check(state.audioSrcIsData, "音频是 data: URL(内联)");
check(state.audioDuration !== null && state.audioDuration > 60, "音频可解码", state.audioDuration ? `${state.audioDuration.toFixed(0)}s` : `error=${state.audioError}`);
check(pageAbs.length > 0 && !/^https?:/.test(state.bg), "没有指向 http(s) 的资源");
console.log("\n=== 模拟器 ===");
check(state.sim, "window.__weSim 存在");
check(/模拟环境/.test(state.banner ?? ""), "面板标着「模拟环境」", state.banner);
// 自包含包里的预览图:必须是内联的 data: URL,而且要真的解码出来(坏图也会是 data:)。
if (state.previewSrc !== null) {
check(state.previewSrc === "data:", "预览图已内联成 data: URL", state.previewSrc);
check(state.previewNatural === "160x160", "内联的预览图能解码", state.previewNatural);
}
console.log("\n=== 干净度 ===");
check(exceptions.length === 0, "0 未捕获异常", exceptions.slice(0, 3).join(" | ") || "无");
const noisy = consoleLines.filter((l) => /error|failed|blocked|CORS/i.test(l));
check(noisy.length === 0, "0 控制台报错", noisy.slice(0, 3).join(" | ") || "干净");
});
console.log(failures === 0 ? "\n自包含包可用:双击即可打开,无需 pnpm dev,也无需任何 http 服务" : `\n自包含包验收失败:${failures} 处`);
process.exit(failures === 0 ? 0 : 1);
+157
View File
@@ -0,0 +1,157 @@
// 静态预览验收:证明 WE 模拟面板**不依赖调试服**,且是**右边缘滑出**的。
//
// 为什么要有这个测试:
// ① 面板曾经只在调试服注入时才存在。用户要求 GitHub Pages 预览也能弹出它——
// 静态托管没有调试服,所以必须由 `--with-sim` 把驱动**注入 index.html**。
// ② Pages 把站点放在 `/<repo>/` **子路径**下。驱动若用根绝对路径 `/scripts/…`,
// 在子路径下必然 404。所以这个测试刻意把分发挂在 `/repo/` 前缀后面服务,
// 而不是挂在根上——挂根上测不出这个错。
//
// 用法:node tools/checks/verify-static-preview.mts [分发目录名]
// 前提:已经 `pnpm build --with-sim`。
import { createServer } from "node:http";
import { createReadStream } from "node:fs";
import { stat } from "node:fs/promises";
import { extname, join, normalize, resolve } from "node:path";
import { withPage, sleep } from "../cdp.mjs";
const DIR = process.argv[2] ?? "collection-all";
const PORT = Number(process.env.STATIC_PORT ?? 8195);
const ROOT = resolve("dist/releases");
/** 模拟 GitHub Pages 的 /<repo>/ 子路径。**不能**是空串,否则测不出根绝对路径的错。 */
const PREFIX = "/repo";
let failures = 0;
const check = (ok: unknown, label: string, detail?: unknown) => {
if (!ok) failures++;
console.log(` ${ok ? "✓" : "✗"} ${label}${detail !== undefined ? ` → ${String(detail)}` : ""}`);
};
const MIME = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".json": "application/json; charset=utf-8",
".atlas": "text/plain; charset=utf-8",
".png": "image/png",
".jpg": "image/jpeg",
".webp": "image/webp",
".gif": "image/gif",
".flac": "audio/flac",
".mp3": "audio/mpeg",
".ogg": "audio/ogg",
};
const notFound: string[] = [];
const server = createServer(async (req, res) => {
const pathname = decodeURIComponent(new URL(req.url ?? "/", "http://x").pathname);
if (!pathname.startsWith(`${PREFIX}/`)) {
notFound.push(pathname);
res.writeHead(404).end("outside prefix");
return;
}
let rel = pathname.slice(PREFIX.length + 1);
if (rel === "" || rel.endsWith("/")) rel += "index.html";
const file = join(ROOT, normalize(rel));
if (!file.startsWith(ROOT)) {
res.writeHead(403).end("escape");
return;
}
const info = await stat(file).catch(() => null);
if (!info?.isFile()) {
notFound.push(pathname);
res.writeHead(404).end("not found");
return;
}
res.writeHead(200, { "content-type": MIME[extname(file).toLowerCase()] ?? "application/octet-stream", "content-length": info.size });
createReadStream(file).pipe(res);
});
await new Promise<void>((r) => server.listen(PORT, "127.0.0.1", () => r()));
const BASE = `http://127.0.0.1:${PORT}${PREFIX}/${DIR}/`;
console.log(`静态托管(模拟 Pages 子路径):${BASE}\n`);
const W = 1920;
const H = 1080;
const EDGE_X = W - 8; // 16px 热区之内
const AWAY_X = 200; // 远离右边缘
const result = await withPage({ width: W, height: H, profile: "tools/.cache/edge-static" }, async ({ send, evaluate, consoleLines, exceptions }) => {
const move = async (x, y) => {
await send("Input.dispatchMouseEvent", { type: "mouseMoved", x, y, button: "none", buttons: 0 });
await sleep(320); // > 面板 .18s 过渡
};
const open = () => evaluate(`document.getElementById("wesim").dataset.open`);
await send("Page.navigate", { url: BASE });
await sleep(3500);
const initial = await evaluate(`(function(){
var p = document.getElementById("wesim");
var e = document.getElementById("wesim-edge");
return {
sim: !!window.__weSim,
isSimulated: window.__weSim ? window.__weSim.__isSimulated : null,
driverOnce: window.__weSimDriver === true,
panel: !!p, edge: !!e,
open: p ? p.dataset.open : null,
banner: p && p.querySelector(".banner") ? p.querySelector(".banner").textContent : null,
canvas: document.querySelectorAll("canvas").length,
hasToggle: !!document.querySelector("#wesim .toggle")
};
})()`);
// ── 交互:右边缘滑出 ──
await move(AWAY_X, H / 2);
const closedInitially = await open();
await move(EDGE_X, H / 2);
const openedByEdge = await open();
await move(AWAY_X, H / 2);
const closedByLeave = await open();
await move(EDGE_X, H / 2);
const reopened = await open();
// 取消收起:此刻真实指针就停在右边缘热区里。没有 armed 规则的话,
// 面板滑走会立刻让边缘命中再次触发,表现为"取消关不掉"。
// (官方面板没有 ×,底部是确认/取消,所以这里点「取消」。)
await evaluate(`document.querySelector("#wesim .foot button:not(.primary)").click()`);
await sleep(400);
const afterClose = await open();
// 离开边缘再回来 → 重新武装
await move(AWAY_X, H / 2);
await move(EDGE_X, H / 2);
const afterRearm = await open();
return { initial, closedInitially, openedByEdge, closedByLeave, reopened, afterClose, afterRearm, consoleLines, exceptions };
});
const r = result;
console.log("=== ① 静态托管下模拟器可用(无调试服)===");
check(r.initial.sim === true, "window.__weSim 存在(驱动已随产物注入)");
check(r.initial.isSimulated === true, "window.__weSim.__isSimulated = true");
check(r.initial.driverOnce === true, "驱动幂等标记已置位");
check(r.initial.panel === true, "#wesim 面板已挂载");
check(r.initial.edge === true, "#wesim-edge 右边缘热区已挂载");
check(r.initial.hasToggle === false, "没有常驻开关按钮(打开方式只有右边缘)");
check(/模拟环境/.test(r.initial.banner ?? ""), "面板有「模拟环境」横幅", r.initial.banner?.trim());
check(r.initial.canvas > 0, "壁纸本身也渲染了", `canvas=${r.initial.canvas}`);
console.log("\n=== ② 右边缘滑出交互 ===");
check(r.closedInitially === "0", "初始收起", `data-open=${r.closedInitially}`);
check(r.openedByEdge === "1", "指针移到右边缘 → 滑出", `data-open=${r.openedByEdge}`);
check(r.closedByLeave === "0", "指针离开 → 自动收回", `data-open=${r.closedByLeave}`);
check(r.reopened === "1", "再次移到右边缘 → 再次滑出", `data-open=${r.reopened}`);
check(r.afterClose === "0", "「取消」收起后不会被边缘热区立刻弹回", `data-open=${r.afterClose}`);
check(r.afterRearm === "1", "离开边缘后再回来 → 重新可触发", `data-open=${r.afterRearm}`);
console.log("\n=== ③ 子路径下没有 404(相对路径正确)===");
check(notFound.length === 0, "静态服务器 0 个 404", notFound.slice(0, 3).join(" | ") || "无");
const noisy = r.consoleLines.filter((l) => /error|uncaught/i.test(l));
check(r.exceptions.length === 0, "0 未捕获异常", r.exceptions.slice(0, 2).join(" | ") || "无");
check(noisy.length === 0, "0 控制台报错", noisy.slice(0, 3).join(" | ") || "干净");
server.close();
console.log(failures === 0 ? "\n静态预览:通过(面板不依赖调试服,且随右边缘滑出)" : `\n静态预览:失败 ${failures} 处`);
process.exit(failures === 0 ? 0 : 1);
@@ -0,0 +1,79 @@
// 视觉等价验收:当前 dist 与**重构前**的存档截图逐像素比对。
//
// 为什么需要它:`tools/checks/verify-sim-page.mts` 只证明"自包含包能跑",不证明"画面没变"。
// 内联、模块合成、URL 重写这些改动都可能悄悄改掉渲染结果。
//
// 判据:mean|diff| = 0 且 max|diff| = 0(逐像素相同)。
// 注意 `tools/capture.mjs` 打印的 "spine 层贡献" 是**同一轮里 ref 帧 vs 主帧**的差
// (即"spine 层占了多少像素"),不是与基线的比对——我一开始把它读成了后者,
// 白紧张了一场。真正的基线比对只有这里。
//
// 用法:
// node tools/serve.mjs 8190 --root dist/releases/collection-all
// node tools/capture.mjs visual-current --base http://127.0.0.1:8190
// node tools/checks/verify-visual-equivalence.mts
import { execFileSync } from "node:child_process";
import { existsSync } from "node:fs";
const BASELINE = "tools/shots/baseline-pre-refactor";
const CURRENT = process.argv[2] ?? "tools/shots/visual-current";
const combos: { preset: string; size: string }[] = [];
for (const preset of ["kv37", "xilian"]) {
for (const size of ["3440x1440", "1920x1080", "1080x1920"]) combos.push({ preset, size });
}
function diff(a, b) {
const out = execFileSync("node", ["tools/diff.mjs", a, b], { encoding: "utf8" });
return out.trim().split("\n").pop() ?? "";
}
/*
* 缺文件时**直接失败**,不跳过。
*
* 这里原来写的是"缺少文件就跳过",于是有一次当前截图目录被清掉之后,
* 它把 0 组比对报成了"0 组全部逐像素相同"——一句绿色的空话。
* 一个不检查前置条件的验证等于没有验证:先把该在的文件都确认在,再谈结论。
*/
const missing: string[] = [];
for (const { preset, size } of combos) {
for (const file of [`${BASELINE}/${preset}-${size}.png`, `${CURRENT}/${preset}-${size}.png`]) {
if (!existsSync(file)) missing.push(file);
}
}
if (missing.length > 0) {
console.error(`✗ 缺少 ${missing.length} 个截图,无法比对:`);
for (const file of missing.slice(0, 4)) console.error(` ${file}`);
if (missing.length > 4) console.error(` …还有 ${missing.length - 4} 个`);
console.error(`
基线在 ${BASELINE}(重构前存档,不能重新生成)。
当前截图需要现拍:
node tools/serve.mjs 8190 --root dist/releases/collection-all
node tools/capture.mjs visual-current --base http://127.0.0.1:8190
`);
process.exit(1);
}
let failed = 0;
let checked = 0;
for (const { preset, size } of combos) {
const a = `${BASELINE}/${preset}-${size}.png`;
const b = `${CURRENT}/${preset}-${size}.png`;
checked++;
const line = diff(a, b);
const identical = /mean\|diff\|=0\/255\s+max\|diff\|=0/.test(line);
if (!identical) failed++;
console.log(` ${identical ? "✓" : "✗"} ${preset.padEnd(7)} ${size.padEnd(11)} ${line}`);
}
// 走到这里 checked 必然等于 combos.length(上面缺文件就退了),这条是防回归的兜底。
if (checked !== combos.length) {
console.error(`✗ 只比对了 ${checked}/${combos.length} 组,结论不可信`);
process.exit(1);
}
console.log(
failed === 0
? `\n视觉等价:${checked} 组全部与重构前逐像素相同`
: `\n视觉等价:${failed}/${checked} 组与基线不同`,
);
process.exit(failed === 0 ? 0 : 1);
+538
View File
@@ -0,0 +1,538 @@
// pnpm dev —— 本地调试服:把 dist/releases/ 下的分发目录按发布结构服务出来,并注入 WE 模拟器。
//
// 为什么不直接服务某个分发目录当根:把**服务根设在 releases 的父目录**,任何一个分发里若存在
// 越出自身目录的引用(../ 或根绝对路径),浏览器会立刻 404 —— 这是对"目录可整体搬走"最严的检验。
// 每档分发各占一个端口反而可能掩盖问题(越界路径会意外命中别的分发)。
//
// 路由:
// / 分发索引(读 dist-map.json)
// /release/<dir>/ 某档分发的根(自动补 index.html)
// /release/<dir>/<相对路径> 其内部资源,按扩展名给正确 MIME
// /simulator/wallpaper-engine.js WE 模拟器本体(调试期专有,见下)
//
// 为什么模拟器单独一条路由:按 ADR 0006 §1,模拟器**默认不进发布产物**,只有
// `pnpm build --with-sim` 才会往分发目录里放一份。而调试服总是需要它。早先驱动脚本直接
// 从 `/release/<dir>/scripts/wallpaper-engine.js` 取,于是默认构建(不带 --with-sim)下
// 动态 import 必然 404、面板静默消失——现象是"调试时没有 WE 模拟面板"。
// 现在由调试服从编译产物 build/scripts/wallpaper-engine.js 提供,
// 分发目录则始终保持"就是发布产物"的样子。
//
// 查询参数(与既有对拍工具的参数保持兼容,见 tools/serve.mjs):
// ?sim=0 不注入 WE 模拟器
// ?__props={"preset":"kv37"} 覆盖初始用户属性
// ?__propsAt=dom|load 属性下发时序;dom 复现"load 之前就到达"的竞态
// ?fps=30 初始 fps(经 applyGeneralProperties 下发);0 = 不限流
// ?__paused=1 初始 setPaused(true)
// ?nojs=1 剥掉所有 <script>,得到"只有 CSS 背景、没有 canvas"的对照组
// ?__freeze=1 注入既有的对拍驱动(冻结动画相位,供 cdp/shot 断言)
// ?__hide=canvas 渲染正常但把 canvas 藏起来,得到"只有背景图"的参考帧
//
// 热更新:监听 src/ 与 wallpapers/,按改动只跑必要的那几步构建,然后经 SSE 通知浏览器。
// 改 CSS → 只重铺产物,然后**只换样式表**(不整页重载,Spine 播放器与面板状态都保住)
// 改 runtime → tsc runtime + 重铺产物 → 整页刷新
// 改 simulator → tsc simulator(若产物里也带模拟器,再重铺一遍)→ 整页刷新
// 改壁纸数据 → 重铺产物 → 整页刷新
// 构建失败 → 只报错,**不刷新**(刷新只会把半成品页面端上来)
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { createReadStream, watch } from "node:fs";
import { spawn } from "node:child_process";
import { readFile, stat } from "node:fs/promises";
import { extname, join, resolve, sep } from "node:path";
import { abs, exists, log, readJson } from "./lib/fs.ts";
import { readVault, VaultError } from "./lib/vault.ts";
import type { DistMap } from "./lib/types.ts";
import { SIMULATOR_DRIVER, SIMULATOR_URL, LIVE_RELOAD_CLIENT, NOJS_DRIVER } from "./lib/drivers.ts";
const MIME: Record<string, string> = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".mjs": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".json": "application/json; charset=utf-8",
".atlas": "text/plain; charset=utf-8",
".txt": "text/plain; charset=utf-8",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".gif": "image/gif",
".svg": "image/svg+xml",
".flac": "audio/flac",
".mp3": "audio/mpeg",
".ogg": "audio/ogg",
".opus": "audio/ogg",
".wav": "audio/wav",
".webm": "video/webm",
};
interface Args {
port: number;
build: boolean;
open: boolean;
}
function parseArgs(argv: string[]): Args {
const args: Args = { port: 5173, build: true, open: false };
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i] ?? "";
if (arg === "--port") args.port = Number(argv[++i] ?? 5173);
else if (arg.startsWith("--port=")) args.port = Number(arg.slice(7));
else if (arg === "--no-build") args.build = false;
else if (arg === "--open") args.open = true;
// 构建开关本身由 main() 读 process.argv 决定(初始构建与热更新重建共用一份)。
// 这里必须显式放行,否则它会在参数解析阶段就被当成未知参数拒掉——
// 那样 main() 里那段判断永远走不到,整个开关是死代码。
else if (arg === "--with-sim") continue;
else if (/^\d+$/.test(arg)) args.port = Number(arg);
else if (arg === "--help" || arg === "-h") {
log(HELP);
process.exit(0);
} else throw new VaultError(`未知参数:${arg}\n\n${HELP}`);
}
return args;
}
const HELP = `用法:pnpm dev [--port 5173] [--no-build] [--open] [--with-sim]
--no-build 不先跑一次增量构建(dist/ 不存在时会直接报错)
--open 在默认浏览器里打开索引页
--with-sim 产物里也带一份 WE 模拟器(静态托管预览用)。
会一直带着:初始构建与热更新重建用的是同一组开关。`;
/** 读某分发目录的 project.json 与所需注入数据。 */
async function releaseInfo(
dir: string,
): Promise<
| { properties: Record<string, unknown>; version: string; title: string; defaultPresetId: string; preview?: string }
| undefined
> {
const root = abs(`dist/releases/${dir}`);
if (!(await exists(join(root, "project.json")))) return undefined;
const project = await readJson<{
version?: string;
title?: string;
preview?: string;
general?: { properties?: Record<string, unknown> };
}>(join(root, "project.json"));
const map = await readJson<DistMap>(abs("dist/dist-map.json"));
return {
properties: project.general?.properties ?? {},
version: project.version ?? "",
title: project.title ?? dir,
defaultPresetId: map.releases.find((r) => r.dir === dir)?.defaultPresetId ?? "",
preview: project.preview,
};
}
/** 索引页:列出所有分发,点进去即可调试。 */
async function indexPage(): Promise<string> {
const map = await readJson<DistMap>(abs("dist/dist-map.json"));
const rows = map.releases
.map((release) => {
const wallpapers = release.wallpapers.map((w) => `${w.name}<span class="id">${w.id}</span>`).join("、");
return `<tr>
<td><a href="/release/${encodeURIComponent(release.dir)}/">${release.displayName}</a>
<div class="dir">${release.dir}</div></td>
<td class="type">${releaseTypeLabel(release.type, release.scope)}</td>
<td>${wallpapers}</td>
<td class="num">${(release.bytes / 1024 / 1024).toFixed(1)} MB</td>
</tr>`;
})
.join("\n");
/** 类型列显示人话:单档 / 游戏合集 / 全部合集(内部值不给人看)。 */
function releaseTypeLabel(type: string, scope?: string): string {
if (type === "single") return "单档";
return scope === "all" ? "全部合集" : "游戏合集";
}
return `<!DOCTYPE html><html lang="zh"><head><meta charset="utf-8">
<title>SpineWallpaper 调试服</title>
<style>
body { margin: 32px auto; max-width: 900px; font: 14px/1.6 "Segoe UI", system-ui, sans-serif;
background: #14141a; color: #e8e8ea; }
h1 { font-size: 18px; } code { background: #1e1e28; padding: 1px 5px; border-radius: 3px; }
table { width: 100%; border-collapse: collapse; margin-top: 12px; }
th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid #2c2c38; vertical-align: top; }
th { color: #8d8da0; font-size: 11px; text-transform: uppercase; letter-spacing: .06em; }
a { color: #8fb8ff; text-decoration: none; } a:hover { text-decoration: underline; }
.dir { color: #6f6f85; font-size: 11px; }
.type { color: #b9a0ff; } .num { text-align: right; color: #9b9bb0; }
.id { color: #6f6f85; margin-left: 4px; font-size: 11px; }
.hint { color: #8d8da0; font-size: 12px; margin-top: 20px; }
</style></head><body>
<h1>SpineWallpaper 调试服 <span class="dir">version ${map.version}</span></h1>
<p>每档分发都是 <b>自包含</b> 的:服务根设在 <code>dist/releases/</code>,某个分发里若有越界引用会立刻 404。</p>
<table><thead><tr><th>分发</th><th>类型</th><th>壁纸</th><th>体积</th></tr></thead>
<tbody>${rows}</tbody></table>
<div class="hint">
进入某档分发后可用:<code>?sim=0</code> 关模拟器 · <code>&amp;__props={"preset":"kv37"}</code> 覆盖属性 ·
<code>&amp;__propsAt=dom</code> 复现 load 前下发 · <code>&amp;fps=30</code> 限流 · <code>&amp;nojs=1</code> 剥脚本对照
</div>
</body></html>`;
}
/** 往 index.html 注入驱动脚本(与既有对拍管线共用 tools/lib/drivers.ts)。 */
async function html(file: string, dir: string, search: URLSearchParams): Promise<string> {
let body = await readFile(file, "utf8");
if (search.get("nojs") === "1") {
const stripped = body.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, "<!-- script removed by ?nojs=1 -->");
return `${stripped}\n${NOJS_DRIVER}`;
}
const info = await releaseInfo(dir);
if (!info) return body;
// 热更新客户端放进 <head>,尽早连上 SSE:晚一步就会漏掉"保存后立刻开始构建"的那条消息。
body = body.replace(/<\/head>/i, () => `${LIVE_RELOAD_CLIENT}\n</head>`);
// project.json 的属性定义要交给模拟器面板渲染控件;用内联脚本最省一次请求。
const propertiesTag = `<script>window.__weProperties = ${JSON.stringify(info.properties).replace(/</g, "\\u003c")};</script>`;
// 驱动脚本注入在壁纸模块**之前**:它用动态 import 装载模拟器,从而在 index.js 注册
// wallpaperPropertyListener 之前就把 API 备好(模拟器源码本身仍是 ES module,见 drivers.ts)。
const inject = SIMULATOR_DRIVER({
search: search.toString(),
dir,
title: info.title,
version: info.version,
defaultPresetId: info.defaultPresetId,
preview: info.preview,
});
if (search.get("sim") !== "0") {
body = body.replace(
/<script\b[^>]*src=["'][^"']*scripts\/index\.js["'][^>]*><\/script>/i,
(match) => `${propertiesTag}\n${inject}\n${match}`,
);
}
return body;
}
function safeJoin(root: string, rel: string): string | undefined {
const target = resolve(root, rel.replace(/^\/+/, ""));
if (target !== root && !target.startsWith(root + sep)) return undefined;
return target;
}
/* ------------------------------------------------------------------ 热更新 */
/** 浏览器端注入的 LIVE_RELOAD_CLIENT 会连到 /__dev/events。 */
const liveClients = new Set<ServerResponse>();
function broadcast(payload: Record<string, unknown>): void {
const data = `data: ${JSON.stringify(payload)}\n\n`;
for (const client of liveClients) {
try {
client.write(data);
} catch {
liveClients.delete(client);
}
}
}
const TSC = abs("node_modules/typescript/bin/tsc");
interface RebuildSteps {
runtime: boolean;
simulator: boolean;
build: boolean;
}
/**
* 按改动文件决定跑哪几步。
*
* 全量 `pnpm build` 是 2.2 秒,而绝大多数改动只涉及其中一步:改样式只要重铺产物(0.85s),
* 改模拟器只要重编模拟器(0.6s)。省下来的是每次保存的等待时间。
*/
function stepsFor(files: string[], releasesCarrySimulator: boolean): RebuildSteps {
const steps: RebuildSteps = { runtime: false, simulator: false, build: false };
for (const file of files) {
if (file === "src/globals.d.ts") {
// 类型声明同时影响运行时与模拟器
steps.runtime = true;
steps.simulator = true;
steps.build = true;
} else if (file.startsWith("src/runtime/")) {
steps.runtime = true;
steps.build = true; // 编译产物要重铺进各分发
} else if (file.startsWith("src/simulator/")) {
steps.simulator = true;
// --with-sim 的产物里也有一份模拟器,且它的驱动排在调试服注入的那份之前
// (幂等闸先到先得),那份不更新的话浏览器会一直跑旧模拟器。
steps.build = releasesCarrySimulator;
} else if (file.startsWith("src/") || file.startsWith("wallpapers/") || file === "VERSION") {
steps.build = true; // 样式、模板、vendor、project.template.json、壁纸数据
}
}
return steps;
}
/** 跑一个子进程,成功返回它的输出,失败抛出带输出的错误(要显示到浏览器上)。 */
function runStep(args: string[], label: string): Promise<string> {
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, args, { stdio: ["ignore", "pipe", "pipe"] });
let output = "";
child.stdout.on("data", (chunk: Buffer) => {
output += chunk.toString();
});
child.stderr.on("data", (chunk: Buffer) => {
output += chunk.toString();
});
child.on("error", reject);
child.on("close", (code) => {
if (code === 0) resolve(output);
else reject(new VaultError(`${label} 失败(exit ${code})\n${output.trim().split("\n").slice(-12).join("\n")}`));
});
});
}
/** 监听源码目录。**绝不能监听 dist/**:构建写 dist,监听它就是一个死循环。 */
function startWatcher(onChange: (file: string) => void): void {
for (const root of ["src", "wallpapers"]) {
try {
watch(abs(root), { recursive: true }, (_event, filename) => {
if (filename) onChange(`${root}/${String(filename).replace(/\\/g, "/")}`);
});
} catch (error) {
log(` ⚠ 无法监听 ${root}/:${String(error)}`);
}
}
try {
watch(abs("VERSION"), () => onChange("VERSION"));
} catch {
// VERSION 不存在就算了,构建自己会报
}
}
/** 串行化重建:构建期间来的改动先攒着,跑完再补一轮。 */
function createRebuilder(releasesCarrySimulator: boolean, buildFlags: string[]): (files: string[]) => void {
const pending = new Set<string>();
let timer: ReturnType<typeof setTimeout> | undefined = undefined;
let running = false;
const run = async (): Promise<void> => {
if (running) return; // 正在跑的那一轮会在循环里把 pending 收走
running = true;
try {
while (pending.size > 0) {
const batch = [...pending];
pending.clear();
const steps = stepsFor(batch, releasesCarrySimulator);
const labels: string[] = [];
if (steps.runtime) labels.push("runtime");
if (steps.simulator) labels.push("simulator");
if (steps.build) labels.push("releases");
if (labels.length === 0) continue;
broadcast({ type: "building", steps: labels });
const started = Date.now();
try {
if (steps.runtime) await runStep([TSC, "-p", "tsconfig.runtime.json"], "tsc runtime");
if (steps.simulator) await runStep([TSC, "-p", "tsconfig.simulator.json"], "tsc simulator");
// 必须带上与启动时相同的开关。否则 `pnpm dev --with-sim` 会在第一次保存后
// 被悄悄换成干净构建——产物里的模拟器没了,而没人会想到是"保存"干的。
if (steps.build) await runStep([abs("tools/build.ts"), ...buildFlags], "build");
} catch (error) {
const message = error instanceof VaultError ? error.message : String(error);
log(` ✗ 重建失败:${message.split("\n")[0]}`);
broadcast({ type: "error", message });
continue; // 不刷新页面:刷新只会把半成品端上来
}
// 只改了样式 → 让浏览器换个样式表就行,不必重载整页
const cssOnly =
steps.build &&
!steps.runtime &&
!steps.simulator &&
batch.every((f) => /^src\/styles\/.*\.css$/.test(f));
log(` ↻ 已重建 ${labels.join(" + ")}(${Date.now() - started} ms)${cssOnly ? " 只换样式表" : " 整页刷新"}`);
broadcast({ type: cssOnly ? "css" : "reload" });
}
} finally {
running = false;
}
};
// 防抖:一次保存常常触发多个事件(编辑器写临时文件再改名),攒一下再跑。
return (files: string[]): void => {
for (const file of files) pending.add(file);
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
timer = undefined;
void run();
}, 120);
};
}
async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2));
// 构建开关只算一次,初始构建与热更新重建共用,免得两处走偏(已经偏过一次)。
const buildFlags = process.argv.includes("--with-sim") ? ["--with-sim"] : [];
if (args.build && !(await exists(abs("dist/dist-map.json")))) {
log("dist/ 不存在,先跑一次构建…(--no-build 可跳过)");
const child = spawn(process.execPath, [abs("tools/build.ts"), ...buildFlags], {
stdio: "inherit",
});
await new Promise<void>((done, fail) => {
child.on("close", (code) => (code === 0 ? done() : fail(new VaultError(`构建失败(exit ${code})`))));
});
}
const vault = await readVault();
const releasesRoot = abs("dist/releases");
const map = await readJson<DistMap>(abs("dist/dist-map.json"));
const server = createServer((req, res) => {
void handle(req, res, releasesRoot);
});
// 产物里带不带模拟器,决定"改模拟器"要不要顺带重铺产物(见 stepsFor)。
const releasesCarrySimulator = (
await Promise.all(
map.releases.map((release) => exists(abs(`dist/releases/${release.dir}/scripts/wallpaper-engine.js`))),
)
).some(Boolean);
const schedule = createRebuilder(releasesCarrySimulator, buildFlags);
startWatcher((file) => {
schedule([file]);
});
server.listen(args.port, "127.0.0.1", () => {
log(`SpineWallpaper 调试服 version ${map.version}`);
log(` 壁纸:${vault.wallpapers.map((w) => `${w.id}(${w.meta.name})`).join("、")}`);
log(` 索引:http://127.0.0.1:${args.port}/`);
for (const release of map.releases) {
log(` /release/${release.dir}/ ${release.displayName}`);
}
// 启动时就把"模拟器能不能加载"说清楚。否则它只会在浏览器里变成一条 404,
// 表现成"面板没出来",而没人会想到是构建产物缺文件。
void stat(abs("build/scripts/wallpaper-engine.js")).then(
(info) =>
log(` 模拟器:${SIMULATOR_URL} ✓ ${(info.size / 1024).toFixed(0)} KB(调试期专有,不进发布产物)`),
() => log(` 模拟器:✗ 未编译(build/scripts/wallpaper-engine.js 不存在)→ 面板不会出现,请先跑 pnpm build`),
);
log(
` 热更新:监听 src/ 与 wallpapers/(改完自动重建并刷新;CSS 只换样式表)` +
(buildFlags.length > 0 ? ` 构建开关:${buildFlags.join(" ")}` : ""),
);
log(" 注意:重建会清掉本次未产出的目录,\`sim/\` 需要时单独跑 pnpm build sim。");
if (args.open) {
void import("node:child_process").then(({ spawn }) => {
spawn("cmd", ["/c", "start", "", `http://127.0.0.1:${args.port}/`], { stdio: "ignore", detached: true });
});
}
});
}
async function handle(req: IncomingMessage, res: ServerResponse, releasesRoot: string): Promise<void> {
try {
// 每次请求重读:改了目录名、加了新壁纸之后,调试服不该需要重启才认。
// 索引页本来就这么做;这里以前用的是启动时那一份,于是磁盘上已经是 single-hsr-kv37,
// 服务端还按 wallpaper-kv37 校验,整档 404。
const map = await readJson<DistMap>(abs("dist/dist-map.json"));
const url = new URL(req.url ?? "/", "http://127.0.0.1");
const pathname = decodeURIComponent(url.pathname);
if (pathname === "/" || pathname === "/index.html") {
const body = await indexPage();
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
res.end(body);
return;
}
// 热更新事件流。text/event-stream 要一直挂着,所以这里不能 end()。
if (pathname === "/__dev/events") {
res.writeHead(200, {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-store",
connection: "keep-alive",
});
res.write(": connected\n\n");
liveClients.add(res);
req.on("close", () => liveClients.delete(res));
return;
}
// 模拟器本体:调试期专有资源,从编译产物读(见文件头"为什么模拟器单独一条路由")。
// 刻意**不**回落到分发目录里的副本:分发里那份只有 --with-sim 才有,可能是旧的,
// 而这里要的永远是"当前源码编出来的那一个"。
if (pathname === SIMULATOR_URL) {
const file = abs("build/scripts/wallpaper-engine.js");
const info = await stat(file).catch(() => null);
if (!info?.isFile()) {
res
.writeHead(404, { "content-type": "text/plain; charset=utf-8" })
.end(`模拟器未编译:${file} 不存在。跑一次 pnpm build(或 pnpm build:sim)。`);
return;
}
res.writeHead(200, {
"content-type": MIME[".js"] ?? "text/javascript; charset=utf-8",
"content-length": info.size,
"cache-control": "no-store",
});
createReadStream(file).pipe(res);
return;
}
const match = /^\/release\/([^/]+)(\/.*)?$/.exec(pathname);
if (!match) {
res
.writeHead(404, { "content-type": "text/plain; charset=utf-8" })
.end("只服务 /、/release/<dir>/… 与 " + SIMULATOR_URL);
return;
}
const dir = match[1] ?? "";
if (!map.releases.some((release) => release.dir === dir)) {
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" }).end(`没有这档分发:${dir}`);
return;
}
const root = join(releasesRoot, dir);
let rel = (match[2] ?? "/").replace(/^\//, "");
if (rel === "" || rel.endsWith("/")) rel += "index.html";
const file = safeJoin(root, rel);
if (!file) {
res.writeHead(403, { "content-type": "text/plain; charset=utf-8" }).end("越出分发目录");
return;
}
const info = await stat(file).catch(() => null);
if (!info?.isFile()) {
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" }).end(`404 ${rel}`);
return;
}
if (extname(file).toLowerCase() === ".html") {
const body = await html(file, dir, url.searchParams);
res.writeHead(200, {
"content-type": "text/html; charset=utf-8",
"content-length": Buffer.byteLength(body),
"cache-control": "no-store",
});
res.end(body);
return;
}
res.writeHead(200, {
"content-type": MIME[extname(file).toLowerCase()] ?? "application/octet-stream",
"content-length": info.size,
"cache-control": "no-store",
});
createReadStream(file).pipe(res);
} catch (error) {
res.writeHead(500, { "content-type": "text/plain; charset=utf-8" }).end(String(error));
}
}
try {
await main();
} catch (error) {
if (error instanceof VaultError) {
console.error(`\n调试服启动失败:${error.message}\n`);
process.exit(1);
}
throw error;
}
+248
View File
@@ -0,0 +1,248 @@
# `tools/downloader/` — 米哈游活动页 Spine 抓取器
从 `wallpapers/sources.yml` 列出的活动页里把 **Spine 骨架 + 贴图 + 场景装配信息** 抓下来,
落到 staging(`_out/`)。**每个有内容的场景各产出一个「可直接搬走的壁纸目录」**——用户挑中的那份
整个移到 `wallpapers/<游戏id>/` 就能用(也可以让 `promote` 代劳)。
它不是构建输入:`wallpapers/` 只放**最终要发布**的资源,抓取脚本与临时下载都留在 `tools/`。
```bash
python -m tools.downloader fetch --page kv45 # 抓一页(结尾自动 verify,有硬伤就非 0 退出)
python -m tools.downloader fetch # 抓 sources.yml 里的全部页面
python -m tools.downloader fetch --interactive # 逐页问:这一页推荐哪个场景(不影响落盘范围)
python -m tools.downloader select --page nico-tea # 只做选择,写 selection.yml
python -m tools.downloader verify # 只自检产物
python -m tools.downloader fetch --offline # 只用 _cache/ + 已落盘的产物,不发任何请求(重跑验证)
python -m tools.downloader promote --page kv45/scene_ava --game hsr --id scene_ava \
--name 挥掷千星的筹码 --title '【崩坏:星穹铁道】挥掷千星的筹码' --description '[b]挥掷千星的筹码[/b]'
node tools/checks/check-downloader.mts # 门:编译 + 夹具绿 + 四处破坏必红
```
依赖:Python 3.10+ 与 **PyYAML**(`requirements.txt`)。不需要浏览器——这些页面免登录即可拿到
全量入口 bundle,骨架数据就在里面。
---
## 一、产物形状
```
tools/downloader/
├── _cache/<页面>/<脚本名> # 抓下来的 bundle 原文(文本调试缓存,可重跑零请求)
├── _out/<游戏>/<页面>/
│ ├── page.json # 页面级溯源:来源 URL / 入口脚本 / 场景清单 / 每个场景目录的摘要
│ └── <场景id>/ # ← 一个场景 = 一个可直接搬走的壁纸目录
│ ├── meta.json # id / name / title / description / game / page / scene / source / audio
│ ├── preset.template.json # 运行时配置(背景图 + sceneConfig.parts)
│ ├── scene.json # 场景侧车:part 列表 + 每个 part 的世界变换
│ ├── spines/<骨架名>/
│ │ ├── <骨架名>.json # 骨架(skeleton.images 已归一化为空串)
│ │ ├── <骨架名>.atlas # 第一行 = 贴图页名,与落盘文件名逐字一致
│ │ ├── <贴图页…> # 与 atlas **同居**(布局 A,见 ADR 0008)
│ │ └── meta.json # spine 版本 / 动画 / 皮肤 / 来源 URL
│ ├── scene/<场景图> # 几何平面(geometry + material.diffuse)贴的图
│ └── audios/ # 音源(当前恒为空:页面的 BGM 还没抓)
└── selection.yml # 机器所有的「哪一页推荐哪个场景」(**入库**)
```
场景目录名 = 场景 id 转义成合法壁纸 id(只允许 `[a-z0-9_-]`,首字符必须是字母数字):
`scene_main` / `P1` / `loading` 原样,中文场景 id(back-moon 的 `动画预览`)折成 `scene`。
**真实 id 不丢**——它在同目录的 `meta.json` 与 `scene.json` 里。
`_cache/` 与 `_out/` 在 `.gitignore` 里;`selection.yml` 入库。
### 场景内去重、跨场景不去重
* **场景内按 id 去重**:同一个场景里同一具骨架被引用多次只存一份(`ctc_rewards` 在
back-moon 的 `scene_content` 里出现 4 次、`gc_win` 在 `scene_gacha` 里 6 次,都只落一份)。
`scene.json` 里那些 part 仍然一条不少——只有**文件**去重。
* **跨场景不去重**:`scene_main/` 与 `scene_ava/` 会各存一份用到的骨架。`_out/` 是可随时
重生成的 staging,体积换"每个目录都能单独搬走"。总体积记在 `page.json` 的 `totalBytes` 里。
### `usable`:不是每个场景目录都能搬走
各页的 `scene_ui` 是一层**场景渲染目标**:它的平面用 `drawScene` modifier 把别的场景渲染成纹理
(diffuse 名就是场景 id),本身没有任何资源文件。这种场景照样落盘("页面上有几个场景"这件事在
产物里是完整的),但它的预设 `parts` 是空的:
```json
{ "usable": false, "reason": "场景里只有运行时纹理(drawScene / 贴图缓冲),没有可搬走的资源" }
```
`promote` 拒绝搬这种目录;`page.json` 的 `sceneDirs[]` 里逐条标了 `usable`。
## 二、场景选择:只决定"推荐哪个"
`fetch` **落盘全部有内容的场景**(至少有一具骨架或一块贴图平面)。纯色平面组成不了壁纸
(`sceneConfig.parts` 会是空的),完全没有 part 的场景也没有内容——这两类不落盘,原因记在
`page.json` 的 `skippedScenes` 里。
推荐场景由 `selection.yml` / `sources.yml` / 默认值算出(优先级写死,避免两份文件打架):
1. `wallpapers/sources.yml` 里这一页写了 `scene:` / `spines:` → 用它(**人的意志最高**)
2. `tools/downloader/selection.yml` 里有这一页的记录 → 用它(上次交互的结果)
3. 都没有 → **骨架最多的那个场景**(`--interactive` 时才问人)
默认规则与旧项目的判断一致(get-memory 会推荐 `P1`、back-moon 会推荐 `scene_main`)。
推荐值写进 `page.json` 的 `chosenScene` / `chosenDir`,是 `promote` 不给 `--scene` 时的默认值。
> **这份选择不再裁剪产物。** 以前它决定"只落哪几个骨架",现在产物是"一个场景一个自包含的
> 壁纸目录"——砍掉几具骨架会做出一个缺件的坏场景。要精简就在场景目录里的
> `preset.template.json` 上改(那是纯数据,构建期才读)。
> `sources.yml` 的 `spines:` 字段同理,只剩记录语义。
## 三、`scene.json` 里有什么
页面用自研引擎(three.js 系)描述场景:`sceneList` 是场景数组,每个场景是一棵树,节点有三类负载:
| kind | 含义 | 需要资源? |
| --- | --- | --- |
| `spine` | `spine:{id:"main_nike"}`,一具骨架 = 一个视觉元件 | 是(骨架 + atlas + 贴图页) |
| `image` | `geometry` + `material.uniforms.diffuse`,贴图平面 | 是(`scene/<名>.<ext>`) |
| `solid` | `defines.USE_TEXTURE == 0` 的**纯色平面**(diffuse 常写 `DEFAULT`) | 否 |
每个 part 带 `position` / `scale`(**世界变换**)与 `localPosition` / `localScale`,合成语义照抄引擎:
```
world_position = parent_position + parent_scale ⊙ local_position
world_scale = parent_scale ⊙ local_scale
```
旋转暂不参与合成(旧项目同样如此),只在 `stats.rotatedNodes` 里报出数量——不假装算了。
绘制层级 = 树序遍历序(`order`)+ 节点自身的 `renderOrder`。
带 `"runtime": true` 的 `image` part 是**没有独立文件**的平面:`drawScene` 的场景渲染目标、
`cacheContainer` 的贴图缓冲,或 diffuse 指向同场景骨架缓存。它们不进预设,也不算缺资源。
**用户口中的 "scene / geometric" 就是这里的两类节点**:`sceneList` 的场景容器与 `geometry` 平面。
Spine 官方没有这两个数据概念(官方 7 种附件类型里没有 geometric;`scene` 在 spine-webgl 里指的是
`SceneRenderer` 这个渲染器)。所以"支持场景动画"= 运行期把多具骨架 + 若干贴图平面按这套摆放合成。
## 四、`preset.template.json` 怎么生成的
* `sceneConfig.parts`:`scene.json` 里每个 `spine` / `image` part 一条;纯色平面与运行时纹理不写
(运行时画不了它们,写进去只是死配置)。路径一律写成 `./spines/<名>/…`、`./scene/<名>.<ext>`——
**正斜杠**,因为预设会被整体搬走,`str(Path)` 的反斜杠在别的机器上是错的。
* `backgroundImage`:构建期它是**必填且必须真实存在**的。自动挑法 = 场景里**面积最大**的贴图平面
(它决定 `document.body` 的底图与取景用的宽高比,挑到一块小按钮会让整幅画的比例全错);
一块贴图平面都没有时退到第一具骨架的 atlas 第一行声明的贴图页。
* 写法与校验在同一个模块 `layout.py` 里(`build_preset` / `missing_preset_paths`):
产物形状一旦改,校验规则必须同时改,分成两个文件迟早漂移。写完之后**当场**把每条路径在磁盘上
核一遍,不留到构建期才炸。
## 五、页面侧的坑(都踩过,别再踩)
- **描述表的 `src` 表达式不能用一条 `.+?` 通吃**:kv45 的 bundle 里有个编译后的模板片段写着
`{src:e.activeIcon,alt:""}})`,`\{src:(.+?),id:"…",type:"image"\}` 会从那里一路吃 **8.8 万字符**
去够后面的 `,id:"…",type:"image"}`,把夹在中间的真表项(`{src:$w,id:"loading_dt1",…}`)整个吞掉。
现在分成两种形状各匹配各的:`_DESCRIPTOR_INLINE`(`Object.values(Object.assign({…}))[0]`)与
`_DESCRIPTOR_SIMPLE`(不含 `,{}` 的表达式)。
- **资源引用有四种写法**,缺一种就会把真资源当"不存在":
1. `X = a.p + "images/x.png"`(`_ASSET_LITERAL`)
2. `{src:a(38458),id:"x"}`:模块直接导出字符串——小图被 webpack 内联成
`e.exports="data:image/png;base64,…"`(`_MODULE_STRING`)。get-memory 的 `loading_moutain_a`、
`a01_lizi`、start-ndkl 的 `loading_start_1` 都栽在这里(少了它们,平面会报"资源表里没有 URL")。
3. `{src:$w,id:"x"}`:`$w="data:image/png;base64,…"`(`_STRING_ASSIGN`,kv45 的 `loading_dt1`)。
4. `Object.values(Object.assign({"<源路径>":"data:…"}))[0]`(`_DATA_IN_ASSIGN`)。
- **骨架数据一律内联在 bundle 里**,6 个页面里 `.atlas` / `.skel` 的网络请求数是 **0**。
只有 hsr 的骨架 json 走网络(页面根目录 `<hash>.json`),而且它在 **webpack 异步 chunk**
里(`258.ccb0954b.js`)——入口 HTML 根本没列它,得读 `.u=` 的 chunk 名映射再排队抓。
这也是 hsr 的 `scene_ava` **首次离线抓不到**的原因:那两个 json 从没进过 `_cache/`,
得先联网抓一次(见第七节的离线边界)。
- **三种内联家族**都要认:A `Object.values(Object.assign({"…/spine/<N>.json":{…}}))[0]`(JS 对象字面量,
键不带引号,用 `jslit` 规范化);B 匿名模块 + 配对表 `{atlas:fn(id),json:fn(id)}`,
骨架是 `JSON.parse('…')`(**单引号** JS 字符串,要先按 JS 语义还原);C hsr 的 `spineSetting`。
- **公共路径变量名逐页不同**:`n.p` / `a.p` / `t.p`。写死 `n.p` 会让半个页面的资源表全空。
- **同一逻辑名可能有两个候选**(引擎的桌面/移动两套预载表)。判据:数组式描述表是基准集,
字典式表是移动端覆盖(引擎里是 `desktop() || base.forEach(e => override[e.id] && …)`),桌面取基准集。
- **atlas 有两种书写风格**:`size:498,330` 与 `size: 256, 256`,解析器两种都要吃。
- **`.atlas` 的页名要读 atlas 自己声明的**(`atlas.py` 解析出来的第一行/页行),不要按 `<stem>_N` 猜:
多页是 `_2.png`,也有完全不同的名字。落盘用逻辑名,atlas 第一行不用改。
- **`skeleton.images` 要归一化**:作者目录(`../images/`)搬进分发后一定指错,写空串即可
(页名相对 atlas 所在目录解析),原值记在 `meta.json` 的 `originalImages` 里。
- **跨平台路径**:别拿 `str(Path)` 当映射键(Windows 反斜杠 vs 预设里的正斜杠),一律
`Path.as_posix()`。
## 六、多版本 Spine
`meta.json` 逐具骨架记录 `skeleton.spine` 原文。事实基线(详见 `.scratch/spine-versions/REPORT.md`):
- 版本串是**编辑器版本**;`4.0-from-4.1-from-4.2-from-4.3.23` 这类 `-from-` 串表示
**数据是 4.0 格式**(降级导出),运行库只认开头那个 `major.minor`。
- 运行库**不校验**这个串;spine-ts 4.2 能读 4.0/4.1/4.2 的数据。**4.3 数据会被静默丢掉全部约束**
(4.3 把约束并进 `root.constraints`),所以 `verify` 对 `≥4.3` 的骨架直接报红。
- 目前 7 页共 170+ 具骨架全是 ≤4.2 格式,**一套 4.2 运行库就够**;真出现 4.3 数据再谈多套 UMD 共存
(官方没有 `spine-version` 属性,只能各包一层别名函数避免 `window.spine` 互相覆盖)。
- 重分发运行库要带 Spine Runtimes License Agreement(Exhibit A)+ 版权声明,不得删各文件头。
## 七、`verify` 检查什么
逐场景目录核一遍:`meta.json` / `preset.template.json` / `scene.json` 都在;`meta.json` 的
`id` 等于目录名、字符集合法、`name`/`title`/`description` 非空、`audio.choices` 指向的文件存在;
预设里每条 `./…` 路径在磁盘上找得到;侧车引用的骨架有目录与文件、atlas 声明的贴图页逐字落盘、
几何平面的图存在;纯色与运行时纹理不计入缺失;骨架版本在运行库可读范围内。
页面级还会抓"旧形状的残留"(页面根的 `scene.json` / `spine/` / `scene/`)与"不是场景目录的目录"。
**退出码非 0 = 有硬伤**。
它不评判画面对不对——那是 `tools/checks/verify-scene-player.mts` 的事(多骨架 + 贴图平面真的合成出来)。
### 离线能跑到哪一步
`fetch --offline` 只读 `_cache/`(bundle 文本)与**已经落盘的 `_out/`**。所以:
* **重跑**永远是安全的:产物存在就跳过,不发任何请求。
* **首次**抓一个"从没抓过的场景"需要联网——它的骨架 json 与贴图页都还没有本地副本。
hsr 的 `scene_ava`(`zhigengniao_juheye` / `shajin`)就是这样:先 `fetch --page kv45`(联网)
一次,之后再 `--offline` 就完全绿。缺什么会**逐条报出来**(不会甩一条 traceback 就走)。
## 八、离线预览(`_out/` 能看,`_cache/` 不能)
| 目录 | 内容 | 能否预览 |
| --- | --- | --- |
| `_cache/<页面>/` | 原始 bundle **文本**(引用仍是线上绝对地址、贴图不在里面) | ❌ 按设计就是文本调试缓存 |
| `_out/<游戏>/<页面>/<场景>/` | 真实文件树(骨架 / atlas / 贴图页 / 场景图 / 侧车 / 预设) | ✅ **可离线预览** |
```bash
node tools/preview.mts # 列出 _out 里可预览的场景
node tools/preview.mts ys/nico-tea # 页面 = 取它的推荐场景
node tools/preview.mts ys/nico-tea/scene_main # 点名场景
node tools/preview.mts hsr/kv45/scene_ava --port 8199
node tools/preview.mts ys/nico-tea --no-serve # 只组装到 tools/.cache/preview/
```
`tools/preview.mts` 把「构建出的运行时 + staging 的资产 + 由 `scene.json` 生成的预设」组装成
一个独立目录再起静态服务;**页面只读本地文件**。页面里的调试面是 `window.__sceneDebug`
(`parts` / `loaded` / `errors` / `framing` / `rotatedSkipped`),排查"少加载了一件"直接看它。
两点说明:
- **要预览"原页面"而不是我们的产物**,得走整站镜像(`.scratch/page-mirror/spec.md`,尚未实现);
`_cache/` 不能满足这个需求——它只有文本,没有资产树,也没有改写引用。
- `tools/checks/verify-scene-player.mts` 里另有一份"从 scene.json 生成预设"的代码,
**故意不与 preview 共用**:门必须独立于被验证对象,共用一份就变成自己验自己。
## 九、`promote`:搬进 `wallpapers/`
`fetch` 已经产出终态,所以 promote 退化成 **拷贝 + 校验 + 写 meta**:
```bash
python -m tools.downloader promote --page kv45/scene_ava --game hsr --id scene_ava \
--name '挥掷千星的筹码' --title '【崩坏:星穹铁道】挥掷千星的筹码' --description '[b]挥掷千星的筹码[/b]'
```
* `--page` 认三种写法:页面 id(`kv45`)、`页面/场景`(`kv45/scene_ava`)、场景目录的路径。
给页面 id 时用 `--scene` 点名,或让它取 `page.json` 的 `chosenScene`。
* `--name` / `--title` / `--description` 可选:不给就沿用场景目录 `meta.json` 里的
(那里已经是"页面名(场景id)"的形状,够用但通常要改成正式文案)。
* `--cover` 给逻辑名时覆盖 `backgroundImage`。
* **目标目录里的 `meta.json` 的 `id` 会被改写成 `--id`**——所以壁纸 id 可以跟场景目录名不同,
但目录名与 `meta.json.id` 必须一致(构建期会校验)。
* 搬之前会再核一遍预设里的每条路径在**目标目录**上存在;搬完不用手工改任何路径。
也可以完全不用 promote:**整个场景目录拷到 `wallpapers/<游戏id>/<壁纸id>/` 就完事**
(前提是目录名 = `meta.json.id`,且那个目录 `usable`)。两种做法等价,promote 只是顺手改名与校验。
## 十、还没做的
- **音源**:页面 BGM 是另一条链,`audios/` 现在是空的,`meta.json` 的 `audio.choices` 也是空的。
- **预览图**:没有抓,也没有生成。
- **`scene_ui` 这类纯渲染目标场景**:落盘但不可搬走(运行时不会 `drawScene`)。
+11
View File
@@ -0,0 +1,11 @@
"""米哈游活动页的 Spine 抓取器(Python,只依赖 stdlib + PyYAML)。
它不是构建输入:`wallpapers/` 只放最终要发布的资源,抓取脚本与临时下载都留在这里。
产物先落 `_out/<游戏>/<页面>/`,经 `--verify` 自检后再由人 promote 进 `wallpapers/`。
子命令与用法见同目录 README.md。
"""
__all__ = ["__version__"]
__version__ = "0.1.0"
+582
View File
@@ -0,0 +1,582 @@
"""命令行入口:`python -m tools.downloader <fetch|select|verify|promote>`。
设计原则(见 README.md):
* **只落 staging**(`_out/`),不碰 `wallpapers/`——promote 是后续单独一步。
* **一页 = 全部场景**:每个有内容的场景各产出一个「可直接搬走的壁纸目录」
(`_out/<游戏>/<页面>/<场景id>/`),不再只落被选中的那一个。形状与校验见 `layout.py`。
* **可重跑**:文本走 `_cache/`,产物存在就跳过;`--offline` 下不发起任何请求。
* **抓完就自检**:`fetch` 结尾自动跑 `verify`,有硬伤就非 0 退出。
"""
from __future__ import annotations
import argparse
import datetime as dt
import json
import shutil
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import yaml
from . import layout as layout_mod
from . import promote as promote_mod
from . import scene as scene_mod
from . import selection as selection_mod
from . import verify as verify_mod
from .sites import mihoyo
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
SOURCES = ROOT / "wallpapers" / "sources.yml"
CACHE = HERE / "_cache"
OUT = HERE / "_out"
SELECTION = HERE / "selection.yml"
# 旧形状(一页只落一个场景)留在页面根上的东西。新形状里它们是页面根的污染:
# `scene.json` 归到每个场景目录里、`spine/` 改名 `spines/` 并下沉到场景目录。
_LEGACY_ENTRIES = ("scene.json", "spine", "scene")
# "看着像贴图平面、其实没有独立文件"的 modifier:
# cacheContainer —— 渲染进贴图缓冲
# drawScene —— 把**另一个场景**渲染成纹理(diffuse 名就是场景 id,如各页的 scene_ui)
_RUNTIME_MODIFIERS = ("cacheContainer", "drawScene")
@dataclass
class PageEntry:
game: str
id: str
name: str
url: str
scene: str | None = None
spines: list[str] = field(default_factory=list)
class SourcesError(RuntimeError):
"""`sources.yml` 形状不对。"""
def load_sources(path: Path = SOURCES) -> list[PageEntry]:
"""读 `sources.yml`:``<游戏>: [{id, name, url, scene?, spines?}, …]``。"""
if not path.exists():
raise SourcesError(f"找不到来源清单:{path}")
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
if not isinstance(raw, dict):
raise SourcesError(f"{path} 顶层必须是「游戏 → 页面列表」的映射")
entries: list[PageEntry] = []
for game, pages in raw.items():
if not isinstance(pages, list):
raise SourcesError(f"{path} 里 {game} 必须是列表")
for i, page in enumerate(pages):
where = f"{path} 的 {game}[{i}]"
if not isinstance(page, dict):
raise SourcesError(f"{where} 不是映射(YAML 里少写了一个 `- `?)")
missing = [k for k in ("id", "name", "url") if not page.get(k)]
if missing:
raise SourcesError(f"{where} 缺少字段:{', '.join(missing)}")
spines = page.get("spines") or []
if not isinstance(spines, list):
raise SourcesError(f"{where} 的 spines 必须是列表")
entries.append(
PageEntry(
game=str(game),
id=str(page["id"]),
name=str(page["name"]),
url=str(page["url"]),
scene=str(page["scene"]) if page.get("scene") else None,
spines=[str(s) for s in spines],
)
)
return entries
def _download(url: str, dest: Path, *, force: bool) -> int:
"""下载一个资源到 dest(已存在则跳过)。返回落盘字节数。"""
if dest.exists() and not force:
return 0
dest.parent.mkdir(parents=True, exist_ok=True)
data = mihoyo.http_get(url, None)
dest.write_bytes(data)
return len(data)
def _write_spine(scene_dir: Path, entry: PageEntry, data: mihoyo.PageData, name: str,
*, force: bool, offline: bool, report: list[str]) -> int:
"""落一具骨架到 `<场景目录>/spines/<名>/`:json + atlas + 贴图页 + meta.json。
页与 atlas **同居**(布局 A)——spine-ts 按 `<atlas 目录>/<页名>` 解析页,页跑到别处就读不到。
"""
asset = data.spines.get(name)
spine_dir = scene_dir / layout_mod.SPINE_DIR / name
if asset is None:
report.append(f"场景引用了骨架 {name},但页面里没有它的数据(已跳过)")
return 0
try:
payload = mihoyo.load_spine_json(data, asset, cache_dir=CACHE, offline=offline)
except mihoyo.HttpError as exc:
# 离线缺缓存 / 网络失败:报出来,别让一条 traceback 把整页的抓取带崩。
report.append(f"骨架 {name} 的数据取不到:{exc}")
return 0
original_images = (payload.get("skeleton") or {}).get("images")
normalized = dict(payload)
skeleton = dict(normalized.get("skeleton") or {})
# 页名按 atlas 所在目录解析:作者目录("../images/" 之类)搬进分发后一定指错。
skeleton["images"] = ""
normalized["skeleton"] = skeleton
spine_dir.mkdir(parents=True, exist_ok=True)
written = 0
json_file = spine_dir / f"{name}.json"
if force or not json_file.exists():
json_file.write_text(json.dumps(normalized, ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
written += json_file.stat().st_size
atlas_file = spine_dir / f"{name}.atlas"
if force or not atlas_file.exists():
atlas_file.write_text(asset.atlas_text, encoding="utf-8")
written += atlas_file.stat().st_size
# 页名读 atlas 自己声明的页(`asset.pages` 就是 atlas 解析出来的),不按 `<stem>_N` 猜。
for page_name in asset.pages:
stem = page_name.rsplit(".", 1)[0]
rel = data.images.get(stem)
if rel is None:
report.append(f"骨架 {name} 的贴图页 {page_name} 在资源表里找不到 URL")
continue
try:
written += _download(data.asset_url(rel), spine_dir / page_name, force=force)
except mihoyo.HttpError as exc:
report.append(f"骨架 {name} 的贴图页 {page_name} 下载失败:{exc}")
meta = asset.as_meta(entry.url)
meta["originalImages"] = original_images
meta["fetchedAt"] = dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds")
(spine_dir / "meta.json").write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
return written
def _write_images(scene_dir: Path, data: mihoyo.PageData, parts: list[Any], *,
runtime_names: set[str], force: bool, report: list[str],
notes: list[str]) -> tuple[int, set[str]]:
"""落几何平面用的场景图到 `<场景目录>/scene/`。
没有 URL 的平面**一律算运行时纹理**(不下载、不进预设,在 `scene.json` 里标 `runtime`):
资源表就是页面向网络索取资源的完整清单,名字不在表里说明页面自己也不去网上取它。
四种来源:`drawScene` 的场景渲染目标、`cacheContainer` 的贴图缓冲、diffuse 指向同场景骨架
缓存,以及运行时生成的纹理。其中只有最后一种会记一条 note——前三种是页面的正常结构。
"""
written = 0
runtime: set[str] = set()
for part in parts:
if part.kind != "image":
continue
image_id = part.id
rel = data.images.get(image_id)
if rel is None:
runtime.add(image_id)
if not any(m in part.modifiers for m in _RUNTIME_MODIFIERS) and image_id not in runtime_names:
notes.append(f"[{scene_dir.name}] 平面 {image_id} 在资源表里没有 URL,按运行时纹理处理")
continue
ext = mihoyo.image_ext(rel)
try:
written += _download(
data.asset_url(rel), scene_dir / layout_mod.SCENE_DIR / f"{image_id}{ext}", force=force
)
except mihoyo.HttpError as exc:
report.append(f"几何平面 {image_id} 下载失败:{exc}")
return written, runtime
def _scene_dir_names(scenes: list[scene_mod.Scene]) -> dict[str, str]:
"""场景 id → 目录名;同时消掉转义后可能出现的重名(大小写不敏感)。"""
used: set[str] = set()
out: dict[str, str] = {}
for scene in scenes:
base = layout_mod.scene_dir_name(scene.id)
name, index = base, 2
while name.lower() in used:
name = f"{base}_{index}"
index += 1
used.add(name.lower())
out[scene.id] = name
return out
def _scene_has_content(scene: scene_mod.Scene) -> bool:
"""这个场景有没有**内容**(骨架或贴图平面)。
只有纯色平面的场景(back-moon 的 `动画预览`)与完全没有 part 的场景(`effect_DofBlur`)
不算——它们连一张图都不需要,落出来的目录必然是空的。
注意"有内容"不等于"能搬走":各页的 `scene_ui` 全是 `drawScene` 渲染目标,一落地就是
没有 part 的目录(`usable: false`),它进 `_out` 只是为了让"页面上有几个场景"这件事
在产物里是完整的。
"""
return any(p.kind in ("spine", "image") for p in scene.parts)
def _write_scene(page_dir: Path, entry: PageEntry, data: mihoyo.PageData, scene: scene_mod.Scene, *,
dir_name: str, force: bool, offline: bool, report: list[str],
notes: list[str], fetched_at: str) -> dict[str, Any]:
"""把一个场景落成「可直接搬走的壁纸目录」。返回它的摘要(写进 page.json)。"""
scene_dir = page_dir / dir_name
(scene_dir / layout_mod.AUDIO_DIR).mkdir(parents=True, exist_ok=True)
before = len(report)
written = 0
kept_spines = scene.spine_ids # 场景内的全部骨架,按出现序去重(同一骨架被引用多次只存一份)
for name in kept_spines:
written += _write_spine(scene_dir, entry, data, name, force=force, offline=offline, report=report)
image_bytes, runtime_images = _write_images(
scene_dir, data, scene.parts, runtime_names=set(kept_spines), force=force,
report=report, notes=notes,
)
written += image_bytes
payload = scene.as_dict(page=entry.id, game=entry.game)
payload["parts"] = []
for part in scene.parts:
item = part.as_dict()
if part.kind == "image" and part.id in runtime_images:
item["runtime"] = True # 由骨架 / 别的场景渲染出来,没有独立文件
payload["parts"].append(item)
written += layout_mod.write_json(scene_dir / "scene.json", payload)
preset, preset_notes = layout_mod.build_preset(payload, scene_dir)
notes.extend(f"[{dir_name}] {note}" for note in preset_notes)
for rel in layout_mod.missing_preset_paths(preset, scene_dir):
report.append(f"[{dir_name}] preset.template.json 引用的 {rel} 不在磁盘上")
written += layout_mod.write_json(scene_dir / "preset.template.json", preset)
# 该落盘却没落进预设 = 硬伤(上面的 report 已经写了原因),别让目录看起来是好的。
planned = [p for p in scene.parts if p.kind == "spine" or (p.kind == "image" and p.id not in runtime_images)]
usable = bool(preset["sceneConfig"]["parts"])
reason = ""
if not usable:
reason = "场景里只有运行时纹理(drawScene / 贴图缓冲),没有可搬走的资源"
if not usable and planned:
report.append(f"[{dir_name}] 有 {len(planned)} 件该落盘的 part 却没进预设(见上面的缺失报告)")
meta = layout_mod.build_meta(
wallpaper_id=dir_name,
name=f"{entry.name}({scene.id})",
title=f"{entry.name}({scene.id})",
description=f"{entry.name} · 场景 {scene.id}\n来源:{entry.url}",
game=entry.game,
page=entry.id,
scene=scene.id,
source=entry.url,
fetched_at=fetched_at,
)
meta["usable"] = usable
if reason:
meta["reason"] = reason
written += layout_mod.write_json(scene_dir / "meta.json", meta)
return {
"dir": dir_name,
"scene": scene.id,
"usable": usable,
"reason": reason or None,
"spines": len(kept_spines),
"images": len({p.id for p in scene.parts if p.kind == "image"}),
"runtimeImages": sorted(runtime_images),
"solids": sum(1 for p in scene.parts if p.kind == "solid"),
"parts": len(preset["sceneConfig"]["parts"]),
"bytes": written,
"problems": len(report) - before,
}
def cmd_fetch(args: argparse.Namespace) -> int:
entries = load_sources()
if args.page:
wanted = set(args.page)
entries = [e for e in entries if e.id in wanted]
missing = wanted - {e.id for e in entries}
if missing:
print(f"来源清单里没有这些页面:{', '.join(sorted(missing))}", file=sys.stderr)
return 2
if not entries:
print("没有要抓的页面。", file=sys.stderr)
return 2
selection = selection_mod.load_selection(SELECTION)
problems: list[str] = []
total_bytes = 0
for entry in entries:
print(f"\n=== {entry.game}/{entry.id} {entry.name}")
def fail(message: str, _id: str = entry.id) -> None:
"""抓取期的硬伤当场打印——攒到最后再报会让人以为"这页没东西"。"""
problems.append(f"{_id}:{message}")
print(f" ✗ {message}")
try:
data = mihoyo.fetch_page(entry.id, entry.game, entry.url, cache_dir=CACHE, offline=args.offline)
except mihoyo.HttpError as exc:
fail(f"抓取失败 {exc}")
continue
scenes = data.scenes
if not scenes:
fail("页面里一个场景都没有")
continue
# 默认场景仍然按"骨架最多"算,但它现在只用于「哪一档是推荐的」——
# 全部有内容的场景都会落盘,选择不再裁剪产物(选择记录也只剩这个用途)。
fallback = scene_mod.pick_default_scene(scenes)
choice = selection_mod.resolve(
entry.id,
scenes,
sources_entry={"scene": entry.scene, "spines": entry.spines},
selection=selection,
default_scene=fallback.id if fallback else None,
default_spines=fallback.spine_ids if fallback else [],
interactive=args.interactive,
)
chosen = choice.scene or (fallback.id if fallback else None)
page_dir = OUT / entry.game / entry.id
page_dir.mkdir(parents=True, exist_ok=True)
for legacy in _LEGACY_ENTRIES:
stale = page_dir / legacy
if not stale.exists():
continue
# 旧形状的残留:留着会被 verify 当成形状不对的场景目录。
if stale.is_dir():
shutil.rmtree(stale, ignore_errors=True)
else:
stale.unlink()
print(f" · 清掉旧形状的 {legacy}")
names = _scene_dir_names(scenes)
wanted_scenes = [s for s in scenes if _scene_has_content(s)]
wanted_ids = {s.id for s in wanted_scenes}
skipped = [
{"scene": s.id, "reason": "只有纯色平面或完全没有 part,不需要任何资源"}
for s in scenes
if s.id not in wanted_ids
]
if not wanted_scenes:
fail("所有场景都不需要资源(没有骨架、也没有贴图平面)")
continue
print(f" 场景 {len(wanted_scenes)}/{len(scenes)} 个有内容,逐个落盘:")
fetched_at = dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds")
summaries: list[dict[str, Any]] = []
notes: list[str] = []
for scene in wanted_scenes:
before = len(problems)
summary = _write_scene(
page_dir, entry, data, scene,
dir_name=names[scene.id], force=args.force, offline=args.offline,
report=problems, notes=notes, fetched_at=fetched_at,
)
summaries.append(summary)
total_bytes += int(summary["bytes"])
for message in problems[before:]:
print(f" ✗ {message}")
mark = "✓" if summary["usable"] else "○"
print(
f" {mark} {summary['dir']}/(原 id {summary['scene']}):骨架 {summary['spines']}、"
f"贴图平面 {summary['images']}、纯色 {summary['solids']}、预设 part {summary['parts']},"
f"{int(summary['bytes']) / 1024:.1f} KB"
+ (f"(不可搬走:{summary['reason']})" if not summary["usable"] else "")
)
for note in notes:
print(f" · {note}")
# 推荐的场景要真的落了盘——推荐到一个被跳过的场景会让 promote 的默认值指向空气。
usable_dirs = {s["dir"] for s in summaries if s["usable"]}
chosen_dir = names.get(chosen, "") if chosen else ""
if chosen_dir not in usable_dirs:
chosen = next((s["scene"] for s in summaries if s["usable"]), None)
chosen_dir = names.get(chosen, "") if chosen else ""
page_payload = {
"id": entry.id,
"game": entry.game,
"name": entry.name,
"url": entry.url,
"site": data.site.id,
"entry": data.entry_url,
"bundles": sorted(data.bundles),
"scenes": [
{
"id": s.id,
"dir": names.get(s.id) if s.id in wanted_ids else None,
"spines": len(s.spine_ids),
"images": len({p.id for p in s.parts if p.kind == "image"}),
"solids": sum(1 for p in s.parts if p.kind == "solid"),
}
for s in scenes
],
"chosenScene": chosen,
"chosenDir": chosen_dir or None,
"sceneDirs": summaries,
"skippedScenes": skipped,
"totalBytes": sum(int(s["bytes"]) for s in summaries),
"warnings": data.warnings + [w for w in problems if w.startswith(entry.id)] + notes,
"fetchedAt": fetched_at,
}
layout_mod.write_json(page_dir / "page.json", page_payload)
for warning in data.warnings:
print(f" ! {warning}")
usable_count = sum(1 for s in summaries if s["usable"])
print(
f" 页面合计 {page_payload['totalBytes'] / 1024 / 1024:.2f} MB → "
f"{len(summaries)} 个场景目录(其中 {usable_count} 个可直接搬走)"
)
selection[entry.id] = choice.as_dict()
selection_mod.save_selection(SELECTION, selection)
print(f"\n合计新增 {total_bytes / 1024 / 1024:.2f} MB;选择记录 → {SELECTION.relative_to(ROOT)}")
print("\n=== verify")
found, stats = verify_mod.verify_all(OUT, pages=[e.id for e in entries])
for problem in found:
print(f" ✗ {problem}")
print(
f" 页面 {stats['pages']}、场景目录 {stats['sceneDirs']}、骨架 {stats['spines']}、"
f"几何平面 {stats['images']}、体积 {stats['bytes'] / 1024 / 1024:.2f} MB"
)
if stats["versions"]:
versions = "、".join(f"{v}×{n}" for v, n in sorted(stats["versions"].items()))
print(f" 骨架版本:{versions}")
if found:
print(f"\n{len(found)} 处硬伤,未通过。", file=sys.stderr)
return 1
return 0
def cmd_select(args: argparse.Namespace) -> int:
"""只记录「推荐哪个场景」——产物不再按选择裁剪(fetch 落全部有内容的场景)。"""
entries = load_sources()
if args.page:
entries = [e for e in entries if e.id in set(args.page)]
selection = selection_mod.load_selection(SELECTION)
for entry in entries:
try:
data = mihoyo.fetch_page(entry.id, entry.game, entry.url, cache_dir=CACHE, offline=args.offline)
except mihoyo.HttpError as exc:
print(f"{entry.id}:抓取失败 {exc}", file=sys.stderr)
return 1
fallback = scene_mod.pick_default_scene(data.scenes)
choice = selection_mod.resolve(
entry.id,
data.scenes,
sources_entry={"scene": entry.scene, "spines": entry.spines},
selection=selection,
default_scene=fallback.id if fallback else None,
default_spines=fallback.spine_ids if fallback else [],
interactive=True,
)
selection[entry.id] = choice.as_dict()
print(f"[{entry.id}] 已记录推荐场景:{choice.scene}")
selection_mod.save_selection(SELECTION, selection)
print(f"\n选择记录 → {SELECTION.relative_to(ROOT)}")
return 0
def cmd_promote(args: argparse.Namespace) -> int:
"""把 staging 的一个**场景目录**转成发布形状(`wallpapers/<游戏>/<壁纸id>/`)。"""
staged = Path(args.page)
if not staged.is_absolute():
candidate = OUT / args.page
staged = candidate if candidate.exists() else Path(args.page)
try:
result = promote_mod.promote_page(
staged,
ROOT / "wallpapers",
game=args.game,
wallpaper_id=args.id,
scene=args.scene,
name=args.name,
title=args.title,
description=args.description,
cover=args.cover,
force=args.force,
)
except (FileNotFoundError, FileExistsError) as exc:
print(str(exc), file=sys.stderr)
return 2
print(
f"promote {result.scene_dir.name} → {result.target.relative_to(ROOT)}\n"
f" 预设 part {result.parts}(骨架 {result.spines}、贴图平面 {result.images})"
f",跳过纯色平面 {result.solids_skipped},落盘 {result.bytes / 1024 / 1024:.1f} MB"
)
print(" 下一步:pnpm build(或 pnpm dev)让构建把它烘焙进 preset.js")
return 0
def cmd_verify(args: argparse.Namespace) -> int:
root = Path(args.root).resolve() if args.root else OUT
found, stats = verify_mod.verify_all(root, pages=args.page)
for problem in found:
print(f"✗ {problem}")
print(
f"页面 {stats['pages']}、场景目录 {stats['sceneDirs']}、骨架 {stats['spines']}、"
f"几何平面 {stats['images']}、体积 {stats['bytes'] / 1024 / 1024:.2f} MB"
)
if stats["versions"]:
versions = "、".join(f"{v}×{n}" for v, n in sorted(stats["versions"].items()))
print(f"骨架版本:{versions}")
return 1 if found else 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="python -m tools.downloader", description="米哈游活动页 Spine 抓取器")
sub = parser.add_subparsers(dest="command", required=True)
def common(p: argparse.ArgumentParser) -> None:
p.add_argument("--page", action="append", help="只处理指定页面 id(可重复)")
fetch = sub.add_parser("fetch", help="抓取并落 staging:每个有内容的场景一个壁纸目录(结尾自动 verify)")
common(fetch)
fetch.add_argument("--interactive", action="store_true", help="逐页交互式选「推荐哪个场景」")
fetch.add_argument("--offline", action="store_true", help="只用 _cache/,不发起任何请求")
fetch.add_argument("--force", action="store_true", help="重下已存在的产物")
fetch.set_defaults(func=cmd_fetch)
pick = sub.add_parser("select", help="只做交互式选择(推荐场景),写入 selection.yml")
common(pick)
pick.add_argument("--offline", action="store_true", help="只用 _cache/,不发起任何请求")
pick.set_defaults(func=cmd_select)
promote = sub.add_parser("promote", help="把一个场景目录转成 wallpapers/<游戏>/<壁纸id>/")
promote.add_argument("--page", required=True,
help="页面 id(如 kv45)、「页面/场景」(如 kv45/scene_ava)或场景目录的路径")
promote.add_argument("--game", required=True, help="游戏 id(wallpapers/<游戏>/)")
promote.add_argument("--id", required=True, help="壁纸 id(全局唯一,与 WE combo 的 value 一致)")
promote.add_argument("--scene", help="页面目录下要 promote 的场景(默认取 page.json 的 chosenScene)")
promote.add_argument("--name", help="显示名(默认沿用场景目录 meta.json 的)")
promote.add_argument("--title", help="创意工坊标题(默认沿用场景目录 meta.json 的)")
promote.add_argument("--description", help="WE 的 BBCode 文案(默认沿用场景目录 meta.json 的)")
promote.add_argument("--cover", help="封面图逻辑名(scene/<名>.*),写进 backgroundImage")
promote.add_argument("--force", action="store_true", help="目标目录已存在时覆盖")
promote.set_defaults(func=cmd_promote)
check = sub.add_parser("verify", help="自检 _out/ 的引用闭包与文件齐全")
common(check)
check.add_argument("--root", help="检查别的产物目录(默认 tools/downloader/_out)")
check.set_defaults(func=cmd_verify)
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
return int(args.func(args))
except SourcesError as exc:
print(f"sources.yml 有问题:{exc}", file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())
+76
View File
@@ -0,0 +1,76 @@
"""Spine atlas 文本的解析与页名规范化。
.atlas 是文本格式:**第一行是贴图页文件名**,接着 `size:` / `filter:` / `format:` / `scale:`
等页面头,之后才是各区域的 `bounds` / `offsets` / `rotate`。换页就是再来一行页文件名。
两种书写风格都要吃(同一批页面里都存在):
size:498,330 ← 紧凑式(nico-tea / zhidong-wonder / kv45)
size: 256, 256 ← 带空格式(get-memory)
抓取期我们只关心两件事:**这具骨架需要哪些贴图页**,以及**页名能不能原样落盘**
(运行时按第一行去请求贴图页,所以文件名必须与第一行逐字一致)。
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
__all__ = ["AtlasInfo", "parse", "page_names", "retarget_pages"]
_PAGE_EXT = re.compile(r"\.(png|webp|jpg|jpeg)$", re.IGNORECASE)
_PAGE_HEAD = re.compile(r"^(size|format|filter|repeat|pma|scale)\s*:", re.IGNORECASE)
_REGION_ATTR = re.compile(
r"^(bounds|offsets|rotate|xy|orig|index|split|pad|width|height)\s*:", re.IGNORECASE
)
@dataclass
class AtlasInfo:
"""一具骨架的 atlas 摘要。"""
pages: list[str] = field(default_factory=list)
regions: list[str] = field(default_factory=list)
region_pages: list[str | None] = field(default_factory=list)
@property
def multi_page(self) -> bool:
return len(self.pages) > 1
def parse(text: str) -> AtlasInfo:
"""解析 atlas 文本。无法识别的行按区域名处理(atlas 里非属性行就是区域名)。"""
info = AtlasInfo()
current: str | None = None
for raw in text.splitlines():
line = raw.strip()
if not line:
current = None
continue
if ":" not in line and _PAGE_EXT.search(line):
current = line
info.pages.append(line)
continue
if _PAGE_HEAD.match(line) or _REGION_ATTR.match(line):
continue
info.regions.append(line)
info.region_pages.append(current)
return info
def page_names(text: str) -> list[str]:
"""只要贴图页名(顺序即 atlas 里声明的顺序)。"""
return parse(text).pages
def retarget_pages(text: str, mapping: dict[str, str]) -> str:
"""把 atlas 里的页名按 mapping 改写(只改整行的页名行,不碰区域名)。"""
out: list[str] = []
for raw in text.splitlines():
line = raw.strip()
if line and ":" not in line and _PAGE_EXT.search(line) and line in mapping:
out.append(mapping[line])
else:
out.append(raw)
tail = "\n" if text.endswith("\n") else ""
return "\n".join(out) + tail
+217
View File
@@ -0,0 +1,217 @@
"""JS 字面量 → Python 对象。
米哈游活动页把骨架数据与场景树以 **JS 对象字面量** 内联在 bundle 里,不是合法 JSON:
{skeleton:{hash:"KR8Ibf8DXTI",spine:"4.2.43",x:-110.93},bones:[{name:"root"}],scaleX:.6487}
键不带引号、小数可以省略前导 0、布尔写作 ``!0`` / ``!1``、末尾可以有逗号。
这里只做**词法层**的规范化(把字面量改写成 JSON 文本),再交给 ``json.loads``——
不 ``eval``、不执行页面代码。
唯一被容忍的"语义"是未知裸标识符(例如 ``undefined``):默认替换为 ``null`` 并计数,
``strict=True`` 时抛错。骨架数据里出现别的裸标识符说明提取边界错了,值得知道。
"""
from __future__ import annotations
import json
import re
from typing import Any
__all__ = ["loads", "match_literal", "first_object_value", "unescape", "JsLitError"]
# 按优先级排列的词法单元。字符串/数字/``!0`` 必须排在 ``other`` 之前。
_TOKEN = re.compile(
r"""
(?P<ws>\s+)
| (?P<dquote>"(?:[^"\\]|\\.)*")
| (?P<squote>'(?:[^'\\]|\\.)*')
| (?P<template>`(?:[^`\\]|\\.)*`)
| (?P<negnot>!0|!1)
| (?P<num>-?(?:\d+\.\d*|\.\d+|\d+)(?:[eE][+-]?\d+)?)
| (?P<ident>[A-Za-z_$][\w$]*)
| (?P<other>.)
""",
re.VERBOSE | re.DOTALL,
)
_LITERAL_IDENTS = {"true": "true", "false": "false", "null": "null"}
_NULL_IDENTS = {"undefined", "NaN", "Infinity", "void"}
_ESCAPES = {"n": "\n", "t": "\t", "r": "\r", "b": "\b", "f": "\f", "v": "\v", "0": "\0"}
class JsLitError(ValueError):
"""字面量无法规范化成 JSON。"""
def unescape(body: str) -> str:
"""还原 JS 字符串体里的转义(``\\n`` / ``\\uXXXX`` / ``\\'`` / ``\\\\`` …)。
Family B 的骨架是 ``e.exports=JSON.parse('…')``:外层是**单引号** JS 字符串,
必须先按 JS 语义还原,再交给 ``json.loads``。
"""
return _unescape(body, "'")
def _unescape(body: str, quote: str) -> str:
"""还原 JS 字符串体(不含两端引号)里的转义。"""
out: list[str] = []
i = 0
while i < len(body):
ch = body[i]
if ch != "\\":
out.append(ch)
i += 1
continue
nxt = body[i + 1] if i + 1 < len(body) else ""
if nxt == "u" and i + 6 <= len(body):
out.append(chr(int(body[i + 2 : i + 6], 16)))
i += 6
elif nxt == "x" and i + 4 <= len(body):
out.append(chr(int(body[i + 2 : i + 4], 16)))
i += 4
elif nxt == "\n": # 行延续
i += 2
else:
out.append(_ESCAPES.get(nxt, nxt))
i += 2
return "".join(out)
def _num_key(value: str) -> str:
"""把数字字面量还原成 JS 当键时用的字符串(``0`` → ``"0"``、``.5`` → ``"0.5"``)。"""
try:
as_float = float(value)
except ValueError:
return value
if as_float.is_integer():
return str(int(as_float))
return repr(as_float)
def _normalize(text: str, *, strict: bool) -> tuple[str, int]:
out: list[str] = []
unknown = 0
pos = 0
for m in _TOKEN.finditer(text):
kind = m.lastgroup
raw = m.group()
if kind == "ws" or kind == "other":
out.append(raw)
pos = m.end()
continue
if kind in ("dquote", "squote", "template"):
body = raw[1:-1]
quote = raw[0]
out.append(json.dumps(_unescape(body, quote), ensure_ascii=False))
elif kind == "negnot":
out.append("true" if raw == "!0" else "false")
elif kind == "num":
value = raw
if value.startswith("-."):
value = "-0" + value[1:]
elif value.startswith("."):
value = "0" + value
if value.endswith("."):
value += "0"
# 数字也能当键(动画名就叫 "0" 的骨架真实存在:`animations:{0:{…}}`)。
# JS 会把数字字面量转成字符串当键,所以这里要按同一语义还原。
if re.match(r"\s*:", text[m.end() :]):
out.append(json.dumps(_num_key(value), ensure_ascii=False))
else:
out.append(value)
elif kind == "ident":
# 后面(跳过空白)跟冒号的标识符是键,否则是值。
tail = text[m.end() :]
is_key = bool(re.match(r"\s*:", tail))
if is_key:
out.append(json.dumps(raw, ensure_ascii=False))
elif raw in _LITERAL_IDENTS:
out.append(_LITERAL_IDENTS[raw])
elif raw in _NULL_IDENTS:
out.append("null")
unknown += 1
if strict:
raise JsLitError(f"未知标识符 {raw!r} @ {m.start()}")
else:
out.append("null")
unknown += 1
if strict:
raise JsLitError(f"未知标识符 {raw!r} @ {m.start()}")
pos = m.end()
if pos < len(text):
out.append(text[pos:])
normalized = "".join(out)
# 尾逗号:`,}` / `,]`
normalized = re.sub(r",(\s*[}\]])", r"\1", normalized)
return normalized, unknown
def loads(text: str, *, strict: bool = False) -> Any:
"""把 JS 字面量文本解析成 Python 对象。"""
normalized, _ = _normalize(text, strict=strict)
try:
return json.loads(normalized)
except json.JSONDecodeError as exc:
head = text[max(0, exc.pos - 120) : exc.pos + 120].replace("\n", " ")
raise JsLitError(f"字面量规范化后仍不是 JSON:{exc.msg} @ {exc.pos}\n…{head}…") from exc
def match_literal(text: str, start: int) -> int:
"""返回 ``text[start]`` 处那个配平字面量的**闭括号下标**;找不到返回 -1。
与 JS 侧同名的辅助函数一一对应:跳过字符串/模板串/注释,按开闭括号配平。
它是所有提取器的地基——正则数不清嵌套括号,只有这个能。
"""
if start >= len(text):
return -1
open_ch = text[start]
close_ch = {"{": "}", "[": "]", "(": ")"}.get(open_ch)
if close_ch is None:
return -1
depth = 0
i = start
while i < len(text):
ch = text[i]
if ch in "\"'`":
quote = ch
i += 1
while i < len(text):
if text[i] == "\\":
i += 2
elif text[i] == quote:
break
else:
i += 1
i += 1
continue
if ch == "/" and i + 1 < len(text) and text[i + 1] == "/":
while i < len(text) and text[i] != "\n":
i += 1
continue
if ch == "/" and i + 1 < len(text) and text[i + 1] == "*":
i += 2
while i + 1 < len(text) and not (text[i] == "*" and text[i + 1] == "/"):
i += 1
i += 2
continue
if ch == open_ch:
depth += 1
elif ch == close_ch:
depth -= 1
if depth == 0:
return i
i += 1
return -1
def first_object_value(text: str, brace_index: int) -> Any:
"""``Object.values(Object.assign({k: v}))[0]`` 的取值语义:解析 ``{...}`` 并返回第一个值。"""
end = match_literal(text, brace_index)
if end < 0:
raise JsLitError(f"未配平的对象字面量 @ {brace_index}")
obj = loads(text[brace_index : end + 1])
if not isinstance(obj, dict) or not obj:
raise JsLitError(f"期望非空对象字面量 @ {brace_index}")
return next(iter(obj.values()))
+289
View File
@@ -0,0 +1,289 @@
"""把一个场景组装成「可直接搬走的壁纸目录」。
抓取期产出的**终止形状**就是 `wallpapers/<游戏>/<壁纸id>/` 的同形拷贝
(布局 A,见 `docs/adr/0008-asset-layout-per-skeleton.md`):
<页面>/
├── page.json ← 页面级溯源
└── <场景id>/ ← 一个场景 = 一个壁纸目录
├── meta.json
├── preset.template.json
├── scene.json
├── spines/<骨架名>/ ← <名>.json + <名>.atlas + 贴图页**同居**
├── scene/<场景图>
└── audios/
目录名固定 `spines` / `scene` / `audios`——`tools/lib/generate.ts` 的 `copyWallpaperAssets`
按这三个名字搬文件,改名要两边一起改。
这个模块只做两件事:**写**(`build_preset` / `build_meta`)与**查**(`missing_preset_paths`)。
写与查放在一起是刻意的:产物形状一旦改,校验规则必须同时改,分成两个文件迟早漂移。
"""
from __future__ import annotations
import json
import re
from pathlib import Path
from typing import Any
__all__ = [
"AUDIO_DIR",
"IMAGE_EXT",
"SCENE_DIR",
"SPINE_DIR",
"build_meta",
"build_preset",
"image_file",
"missing_preset_paths",
"preset_paths",
"scene_dir_name",
"spine_page_file",
"write_json",
]
SPINE_DIR = "spines"
SCENE_DIR = "scene"
AUDIO_DIR = "audios"
IMAGE_EXT = (".png", ".webp", ".jpg", ".jpeg", ".gif")
# 壁纸 id 的字符集(与 tools/lib/vault.ts 的校验逐字一致):它会进 WE 的 combo value。
_UNSAFE = re.compile(r"[^A-Za-z0-9_-]+")
def scene_dir_name(scene_id: str) -> str:
"""场景 id → 目录名。
页面里的场景 id 多数本来就合法(`scene_main` / `P1` / `loading`),但也有中文的
(back-moon 的 `动画预览`)。目录名一旦成为壁纸 id 就必须是 `^[a-z0-9][a-z0-9_-]*$`,
所以这里统一转义:非法字符折成一个 `_`,首字符不是字母数字时补前缀。
**真实 id 不会被丢掉**——它写在同目录的 `meta.json` / `scene.json` 里。
"""
name = _UNSAFE.sub("_", str(scene_id).strip()).strip("_-")
if not name:
return "scene"
if not re.match(r"[A-Za-z0-9]", name):
return f"scene_{name}"
return name
def image_file(scene_dir: Path, name: str) -> Path | None:
"""场景图落盘名:`scene/<逻辑名>.<ext>`(扩展名由资源路径决定,见 mihoyo.image_ext)。"""
for ext in IMAGE_EXT:
candidate = scene_dir / SCENE_DIR / f"{name}{ext}"
if candidate.is_file():
return candidate
return None
def spine_page_file(scene_dir: Path, spine_id: str) -> Path | None:
"""一具骨架的第一张贴图页。
页名**读 atlas 自己声明的第一行**,不按 `<stem>_N` 猜——去 hash 之后页名未必是
`<骨架名>.png`(多页是 `_2.png`,也有完全不同的名字)。这条路径只用于"整个场景一张
贴图平面都没有"时的背景兜底(`wallpapers/README.md` 允许背景指向骨架贴图页)。
"""
atlas = scene_dir / SPINE_DIR / spine_id / f"{spine_id}.atlas"
if not atlas.is_file():
return None
for line in atlas.read_text(encoding="utf-8").splitlines():
page = line.strip().strip('"')
if page.lower().endswith(IMAGE_EXT):
candidate = atlas.parent / Path(page).name
if candidate.is_file():
return candidate
return None
def _rel(scene_dir: Path, path: Path) -> str:
"""磁盘路径 → 预设里的 `./…` 相对路径(**一律正斜杠**,见"跨平台路径"那条坑)。"""
return "./" + path.relative_to(scene_dir).as_posix()
def _part_common(part: dict[str, Any]) -> dict[str, Any]:
"""part 的公共字段(世界变换 + 绘制层级 + 页面指定的动画/皮肤/时间缩放)。"""
common: dict[str, Any] = {
"kind": part.get("kind"),
"id": part["id"],
"order": part.get("order", 0),
"position": part.get("position", [0, 0, 0]),
"scale": part.get("scale", [1, 1, 1]),
}
if part.get("renderOrder"):
common["renderOrder"] = part["renderOrder"]
if part.get("geometrySize"):
common["width"], common["height"] = part["geometrySize"]
if part.get("geometryCenter"):
common["center"] = part["geometryCenter"]
if part.get("rotation") and any(abs(float(v)) > 1e-9 for v in part["rotation"]):
common["rotation"] = part["rotation"]
# 页面指定的动画 / 皮肤:不抄就会去播骨架的第一个动画(常是入场动画 in,姿态不同)。
if part.get("animation"):
common["animation"] = part["animation"]
if part.get("skin"):
common["skin"] = part["skin"]
if part.get("timeScale") is not None:
common["timeScale"] = part["timeScale"]
return common
def build_preset(
scene: dict[str, Any],
scene_dir: Path,
*,
cover: str | None = None,
) -> tuple[dict[str, Any], list[str]]:
"""由 `scene.json` 的内容 + 磁盘上的场景目录,生成 `preset.template.json`。
返回(预设, 说明列表)。说明是"这个场景里没能进预设的东西"——纯色平面、缺文件的 part,
它们不是错误(`verify` 会独立判红),但用户该知道少了几件。
`cover` 给的是**逻辑名**(`scene/<名>.<ext>`);不给就自动挑一张背景图,
因为构建期 `backgroundImage` 是必填且必须真实存在(见 `tools/lib/vault.ts`)。
"""
notes: list[str] = []
parts: list[dict[str, Any]] = []
solids = 0
for part in scene.get("parts") or []:
kind = part.get("kind")
if kind == "solid":
# 纯色平面运行时这一轮画不了(没有贴图),写进预设只是死配置。
solids += 1
continue
if kind not in ("spine", "image"):
notes.append(f"未知的 part 类型 {kind!r}(id={part.get('id')}),已跳过")
continue
common = _part_common(part)
if kind == "spine":
spine_id = str(part["id"])
spine_dir = scene_dir / SPINE_DIR / spine_id
if not (spine_dir / f"{spine_id}.json").is_file():
notes.append(f"骨架 {spine_id} 没有落到 spines/{spine_id}/,未写进预设")
continue
common["jsonUrl"] = f"./{SPINE_DIR}/{spine_id}/{spine_id}.json"
common["atlasUrl"] = f"./{SPINE_DIR}/{spine_id}/{spine_id}.atlas"
else:
if part.get("runtime"):
# 运行时缓冲(cacheContainer / diffuse 指向同场景骨架缓存):由骨架渲染进贴图
# 缓冲,没有独立文件,不该进预设也不该报缺资源(`scene.json` 里标了 runtime)。
continue
source = image_file(scene_dir, str(part["id"]))
if source is None:
notes.append(f"贴图平面 {part['id']} 没有独立文件,未写进预设")
continue
common["image"] = _rel(scene_dir, source)
parts.append(common)
if solids:
notes.append(f"{solids} 块纯色平面未写进预设(运行时没有贴图可画)")
background: str | None = None
if cover:
source = image_file(scene_dir, cover)
if source is None:
notes.append(f"指定的背景图 {cover} 不在 scene/ 里,改为自动挑选")
else:
background = _rel(scene_dir, source)
if background is None:
background = _pick_background(scene_dir, parts)
scene_config: dict[str, Any] = {"ui": scene.get("ui"), "parts": parts}
camera_node = scene.get("camera") or {}
camera = camera_node.get("camera") or {}
if camera.get("type") is not None:
# 相机必须带进预设:透视场景(type 1)忽略 fov 与 z 就会画成一块糊满屏的贴图。
scene_config["camera"] = {
"type": camera.get("type"),
"fov": camera.get("fov"),
"position": camera_node.get("position"),
}
return {"backgroundImage": background or "", "sceneConfig": scene_config}, notes
def _pick_background(scene_dir: Path, parts: list[dict[str, Any]]) -> str | None:
"""自动挑背景图:贴图平面里**面积最大**的那块(画布比例的来源),没有就退到骨架贴图页。
为什么按面积:`backgroundImage` 在运行时决定 `document.body` 的底图与取景用的宽高比
(`src/runtime/index.ts` 的 `measureImageAspect`)。挑到一块小按钮会让整幅画的比例全错,
而背景/天空/远景恰好总是场景里最大的那块平面(实测:nico-tea 的 `main_sky_jpg` 2500×1064)。
"""
images = [p for p in parts if p.get("image")]
if images:
best = max(
images,
key=lambda p: (float(p.get("width") or 0) * float(p.get("height") or 0), -int(p.get("order") or 0)),
)
return str(best["image"])
for part in parts:
if not part.get("jsonUrl"):
continue
source = spine_page_file(scene_dir, str(part["id"]))
if source is not None:
return _rel(scene_dir, source)
return None
def preset_paths(preset: dict[str, Any]) -> list[str]:
"""预设里所有必须真实存在的 `./…` 路径。"""
out: list[str] = []
background = preset.get("backgroundImage")
if isinstance(background, str) and background:
out.append(background)
scene_config = preset.get("sceneConfig") or {}
for part in scene_config.get("parts") or []:
for key in ("image", "jsonUrl", "atlasUrl"):
value = part.get(key)
if isinstance(value, str) and value:
out.append(value)
return out
def missing_preset_paths(preset: dict[str, Any], scene_dir: Path) -> list[str]:
"""预设里指向磁盘上不存在的文件的路径(写出后立刻自检,不留到构建期才炸)。"""
missing: list[str] = []
for rel in preset_paths(preset):
# 映射键一律用**正斜杠**:Windows 的 str(Path) 是反斜杠,写进预设就搬到别的机器上读不到。
if not (scene_dir / rel.replace("\\", "/").removeprefix("./")).is_file():
missing.append(rel)
return missing
def build_meta(
*,
wallpaper_id: str,
name: str,
title: str,
description: str,
game: str,
page: str,
scene: str,
source: str,
fetched_at: str | None = None,
) -> dict[str, Any]:
"""场景目录的 `meta.json`:既有字段语义(id/name/title/description/audio)原样保留,
另加溯源字段(game/page/scene/source),方便搬进 `wallpapers/` 后回查来源。"""
meta: dict[str, Any] = {
"id": wallpaper_id,
"name": name,
"title": title,
"description": description,
"game": game,
"page": page,
"scene": scene,
"source": source,
# 音源清单为空:页面的 BGM 还没抓(构建会据此省掉 bgm 属性)。
"audio": {"choices": []},
}
if fetched_at:
meta["fetchedAt"] = fetched_at
return meta
def write_json(path: Path, data: Any, *, indent: int = 2) -> int:
"""写一份 JSON(UTF-8、不转义中文),返回字节数。"""
path.parent.mkdir(parents=True, exist_ok=True)
text = json.dumps(data, ensure_ascii=False, indent=indent) + "\n"
path.write_text(text, encoding="utf-8")
return len(text.encode("utf-8"))
+230
View File
@@ -0,0 +1,230 @@
"""把一个 staging 的**场景目录** promote 成 `wallpapers/<游戏>/<壁纸id>/`。
`fetch` 现在产出的就是终态形状(一个场景 = 一个可直接搬走的壁纸目录),所以这一步退化成
**拷贝 + 校验 + 写 meta**:把 `spines/` / `scene/` / `audios/` 三个目录与 `preset.template.json`
搬过去,把 `meta.json` 的 id / 文案换成发布值,再把预设里每条 `./…` 路径在目标目录上核一遍。
刻意不做的事:不抓音源(页面的 BGM 是另一条链)、不生成预览图、不动已存在的目录(除非 --force)。
"""
from __future__ import annotations
import json
import shutil
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from . import layout as layout_mod
__all__ = ["promote_page", "resolve_scene_dir", "PromoteResult"]
@dataclass
class PromoteResult:
target: Path
scene_dir: Path
parts: int
spines: int
images: int
solids_skipped: int
bytes: int
def _read_json(path: Path) -> dict[str, Any]:
return json.loads(path.read_text(encoding="utf-8"))
def _is_scene_dir(path: Path) -> bool:
"""场景目录的判据:`preset.template.json` + `scene.json` 都在(fetch 的产物形状)。"""
return (path / "preset.template.json").is_file() and (path / "scene.json").is_file()
def scene_dirs(staged: Path) -> list[Path]:
"""页面目录下的全部场景目录(按名字排序,保证可复现)。"""
if not staged.is_dir():
return []
return sorted(p for p in staged.iterdir() if p.is_dir() and _is_scene_dir(p))
def resolve_scene_dir(staged: Path, scene: str | None = None) -> Path:
"""把「场景目录」或「页面目录 + 场景 id」统一解析成**一个**场景目录。
认三种输入(与 CLI 的 `--page` 对应):
1. 场景目录本身(`_out/<游戏>/<页面>/<场景id>`)
2. `.scratch` 式的「页面/场景」路径——由调用方拼好后传进来
3. 页面目录(`_out/<游戏>/<页面>`):用 `--scene` 指定;没指定就取 `page.json` 的
`chosenScene`;再没有就要求页面下只有一个场景(多个时报错,不猜)。
"""
if _is_scene_dir(staged):
return staged
if not staged.is_dir():
raise FileNotFoundError(f"staging 里没有这个东西:{staged}")
candidates = scene_dirs(staged)
if not candidates:
raise FileNotFoundError(f"{staged} 下没有场景目录(先跑:python -m tools.downloader fetch)")
def matches(name: str) -> Path | None:
for candidate in candidates:
if candidate.name == name:
return candidate
try:
meta = _read_json(candidate / "meta.json")
except (OSError, json.JSONDecodeError):
continue
if meta.get("scene") == name or meta.get("id") == name:
return candidate
return None
if scene:
hit = matches(scene)
if hit is None:
names = "、".join(p.name for p in candidates)
raise FileNotFoundError(f"{staged} 下没有场景 {scene}(有:{names})")
return hit
chosen = None
page_file = staged / "page.json"
if page_file.is_file():
try:
chosen = _read_json(page_file).get("chosenDir") or _read_json(page_file).get("chosenScene")
except (OSError, json.JSONDecodeError):
chosen = None
if chosen:
hit = matches(str(chosen))
if hit is not None:
return hit
if len(candidates) == 1:
return candidates[0]
names = "、".join(p.name for p in candidates)
raise FileNotFoundError(
f"{staged} 下有 {len(candidates)} 个场景目录,要用 --scene 指定一个(有:{names})"
)
def promote_page(
staged: Path,
wallpapers_root: Path,
*,
game: str,
wallpaper_id: str,
scene: str | None = None,
name: str | None = None,
title: str | None = None,
description: str | None = None,
cover: str | None = None,
force: bool = False,
) -> PromoteResult:
"""执行一次 promote;返回统计。"""
scene_dir = resolve_scene_dir(staged, scene)
if not (scene_dir / "scene.json").is_file():
raise FileNotFoundError(f"场景目录里没有 scene.json:{scene_dir}")
scene_payload = _read_json(scene_dir / "scene.json")
src_meta: dict[str, Any] = {}
meta_file = scene_dir / "meta.json"
if meta_file.is_file():
try:
src_meta = _read_json(meta_file)
except json.JSONDecodeError as exc:
raise FileNotFoundError(f"{meta_file} 不是合法 JSON:{exc}") from exc
target = wallpapers_root / game / wallpaper_id
if target.exists() and not force:
raise FileExistsError(f"目标已存在(要覆盖就加 --force):{target}")
if target.exists():
shutil.rmtree(target)
# 布局 A:一具骨架一组(json + atlas + 贴图页同居),场景图单独一层。
# 页必须与 atlas 同目录——spine-ts 按 `<atlas 目录>/<页名>` 解析(见 README)。
written = 0
for segment in (layout_mod.SPINE_DIR, layout_mod.SCENE_DIR, layout_mod.AUDIO_DIR):
source = scene_dir / segment
if source.is_dir():
written += _copy_tree(source, target / segment)
# 预设优先沿用场景目录里已生成的那份(fetch 已产出终态);缺失就按 scene.json 现算。
preset_file = scene_dir / "preset.template.json"
if preset_file.is_file():
preset = _read_json(preset_file)
else:
preset, _ = layout_mod.build_preset(scene_payload, scene_dir)
if cover:
source = layout_mod.image_file(scene_dir, cover)
if source is None:
raise FileNotFoundError(f"封面图不在场景目录里:scene/{cover}.*")
dest = target / layout_mod.SCENE_DIR / source.name
if not dest.exists():
written += _copy(source, dest)
preset["backgroundImage"] = f"./{layout_mod.SCENE_DIR}/{source.name}"
if not ((preset.get("sceneConfig") or {}).get("parts") or []):
if target.exists():
shutil.rmtree(target, ignore_errors=True)
raise FileNotFoundError(
f"{scene_dir} 里一件可搬走的 part 都没有(平面全是运行时渲染目标),换一个场景目录"
)
missing = layout_mod.missing_preset_paths(preset, target)
if missing:
# 别把半个目标目录留在 wallpapers/ 里——它看起来像一档发布壁纸,实际缺件。
shutil.rmtree(target, ignore_errors=True)
raise FileNotFoundError(
"promote 后预设里有路径找不到对应文件(已拷贝的资产不自洽):\n - " + "\n - ".join(missing)
)
written += layout_mod.write_json(target / "preset.template.json", preset)
final_meta = dict(src_meta)
# 这两个是 staging 专用字段("这个目录能不能直接搬走"),发布形状里没有它们的位置。
final_meta.pop("usable", None)
final_meta.pop("reason", None)
final_meta["id"] = wallpaper_id
final_meta.setdefault("name", wallpaper_id)
final_meta.setdefault("title", str(final_meta["name"]))
final_meta.setdefault("description", str(final_meta["name"]))
if name:
final_meta["name"] = name
if title:
final_meta["title"] = title
if description:
final_meta["description"] = description
# 音源清单为空:这一页的 BGM 还没抓(构建会据此省掉 bgm 属性)。
final_meta.setdefault("audio", {"choices": []})
final_meta.setdefault("game", game)
final_meta.setdefault("page", scene_dir.parent.name)
written += layout_mod.write_json(target / "meta.json", final_meta)
game_dir = wallpapers_root / game
if not (game_dir / "meta.json").exists():
(game_dir / "meta.json").write_text(
json.dumps({"id": game, "name": game, "audios": []}, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
parts = (preset.get("sceneConfig") or {}).get("parts") or []
solids = sum(1 for p in scene_payload.get("parts") or [] if p.get("kind") == "solid")
return PromoteResult(
target=target,
scene_dir=scene_dir,
parts=len(parts),
spines=sum(1 for p in parts if p.get("kind") == "spine"),
images=sum(1 for p in parts if p.get("kind") == "image"),
solids_skipped=solids,
bytes=written,
)
def _copy(src: Path, dest: Path) -> int:
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dest)
return dest.stat().st_size
def _copy_tree(src: Path, dest: Path) -> int:
total = 0
for item in sorted(src.rglob("*")):
if item.is_file():
total += _copy(item, dest / item.relative_to(src))
return total
+1
View File
@@ -0,0 +1 @@
PyYAML>=6
+372
View File
@@ -0,0 +1,372 @@
"""页面场景树(``sceneList``)的解析与"part"提取。
米哈游活动页用自研引擎(three.js 系)描述场景:顶层是一个 ``sceneList`` 数组,每个元素是一个
**场景**(``scene_main`` / ``scene_loading`` / ``scene_game`` …),场景里是一棵树。节点有两类
我们关心的负载:
* ``spine:{id:"main_nike"}`` —— 一具骨架(一具骨架 = 一个视觉元件:人物、云、箱子、光效…)
* ``geometry:{type:2,config:{...}}`` + ``material:[{uniforms:[{key:"diffuse",value:"main_sky_jpg"}]}]``
—— 一块**几何平面**,贴的是场景图。用户口中的 "geometric" 就是这个(Spine 官方没有这个概念)。
这里不用正则去"抠字段"——那正是旧实现踩过的坑(父节点的 position/scale 会从子孙子树里被误读)。
我们把整段字面量解析成对象再走树,字段天然只属于自己那一层。
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from . import jslit
__all__ = ["Scene", "Part", "find_scene_list", "parse_scene", "pick_default_scene"]
_SCENE_KEY = "sceneList"
@dataclass
class Part:
"""场景里的一个可绘制件。"""
kind: str # "spine" | "image"(有贴图的平面) | "solid"(纯色平面,不需要资源)
id: str # 骨架逻辑名,或几何平面贴的图名
node_path: str
order: int # 树序遍历序 = 绘制层级
render_order: int
position: tuple[float, float, float] # 世界位移(父 scale 已乘进来)
scale: tuple[float, float, float] # 世界缩放(逐层连乘)
local_position: tuple[float, float, float] | None
local_scale: tuple[float, float, float] | None
rotation: tuple[float, float, float] | None
geometry_type: int | None
auto_matrix: bool
geometry_size: tuple[float, float] | None = None
geometry_center: tuple[float, float] | None = None
modifiers: tuple[str, ...] = ()
# 页面在这个节点上指定的动画(spine.defaultAnimation);缺省 = 骨架第一个动画。
animation: str | None = None
skin: str | None = None
time_scale: float | None = None
def as_dict(self) -> dict[str, Any]:
out: dict[str, Any] = {
"kind": self.kind,
"id": self.id,
"path": self.node_path,
"order": self.order,
"renderOrder": self.render_order,
"position": list(self.position),
"scale": list(self.scale),
}
if self.local_position is not None:
out["localPosition"] = list(self.local_position)
if self.local_scale is not None:
out["localScale"] = list(self.local_scale)
if self.rotation is not None:
out["rotation"] = list(self.rotation)
if self.geometry_type is not None:
out["geometryType"] = self.geometry_type
if self.geometry_size is not None:
out["geometrySize"] = list(self.geometry_size)
if self.geometry_center is not None:
out["geometryCenter"] = list(self.geometry_center)
if self.auto_matrix:
out["autoMatrix"] = True
if self.modifiers:
out["modifiers"] = list(self.modifiers)
if self.animation:
out["animation"] = self.animation
if self.skin:
out["skin"] = self.skin
if self.time_scale is not None:
out["timeScale"] = self.time_scale
return out
@dataclass
class Scene:
"""一个场景及其全部可绘制件。"""
id: str
ui: tuple[int, int] | None
camera: dict[str, Any] | None
parts: list[Part] = field(default_factory=list)
node_count: int = 0
max_depth: int = 0
rotated_nodes: int = 0 # 带 rotation 的节点数(当前不参与合成,见下)
@property
def spine_ids(self) -> list[str]:
seen: list[str] = []
for p in self.parts:
if p.kind == "spine" and p.id not in seen:
seen.append(p.id)
return seen
def as_dict(self, *, page: str | None = None, game: str | None = None) -> dict[str, Any]:
out: dict[str, Any] = {
"version": 1,
"id": self.id,
"ui": list(self.ui) if self.ui else None,
"camera": self.camera,
"parts": [p.as_dict() for p in self.parts],
"stats": {
"nodes": self.node_count,
"maxDepth": self.max_depth,
"spines": len(self.spine_ids),
"images": sum(1 for p in self.parts if p.kind == "image"),
"solids": sum(1 for p in self.parts if p.kind == "solid"),
"rotatedNodes": self.rotated_nodes,
},
}
if game:
out["game"] = game
if page:
out["page"] = page
return out
def find_scene_list(text: str) -> list[dict[str, Any]]:
"""定位并解析 ``sceneList`` 数组;找不到返回空列表。
两种书写风格:压缩后的 ``sceneList:[`` 与已序列化的 ``"sceneList": [``。
"""
for pattern in (f'"{_SCENE_KEY}"', f"{_SCENE_KEY}:"):
idx = text.find(pattern)
if idx < 0:
continue
start = text.find("[", idx)
if start < 0:
continue
end = jslit.match_literal(text, start)
if end < 0:
continue
data = jslit.loads(text[start : end + 1])
if isinstance(data, list) and data:
return [s for s in data if isinstance(s, dict)]
return []
def _vec3(value: Any) -> tuple[float, float, float] | None:
if isinstance(value, (list, tuple)) and len(value) >= 3:
try:
return (float(value[0]), float(value[1]), float(value[2]))
except (TypeError, ValueError):
return None
if isinstance(value, (int, float)):
return (float(value),) * 3
return None
def _material_info(node: dict[str, Any]) -> tuple[str | None, bool]:
"""返回(diffuse 名, 是否真的用贴图)。
``defines.USE_TEXTURE == 0`` 的平面是**纯色块**(diffuse 通常写 "DEFAULT"),
页面上并不存在对应图片;把它当资源去下载只会抓到一个不存在的 URL。
"""
diffuse: str | None = None
textured = True
for material in node.get("material") or []:
if not isinstance(material, dict):
continue
defines = material.get("defines")
if isinstance(defines, dict) and defines.get("USE_TEXTURE") == 0:
textured = False
for uniform in material.get("uniforms") or []:
if isinstance(uniform, dict) and uniform.get("key") == "diffuse":
value = uniform.get("value")
if isinstance(value, str):
diffuse = value
return diffuse, textured
def _modifiers(node: dict[str, Any]) -> tuple[str, ...]:
"""节点的 modifier id 列表(``cacheContainer`` = 渲染到贴图缓冲、``CSS3DObject`` = DOM 叠层)。"""
ids: list[str] = []
for modifier in node.get("modifier") or []:
if isinstance(modifier, dict) and isinstance(modifier.get("id"), str):
ids.append(modifier["id"])
return tuple(ids)
def mesh_bbox(mesh: Any) -> tuple[tuple[float, float], tuple[float, float]] | None:
"""glTF 网格的顶点包围盒 → (尺寸, 相对节点原点的中心)。
`position.array` 是扁平的 xyz 三元组序列(实测这些网格都是四边形)。中心不为零时,
平面并不以节点原点为中心,画的时候要按它偏移。
"""
if not isinstance(mesh, dict):
return None
position = ((mesh.get("attributes") or {}).get("position") or {})
array = position.get("array")
if not isinstance(array, list) or len(array) < 9:
return None
xs: list[float] = []
ys: list[float] = []
for index, value in enumerate(array):
if not isinstance(value, (int, float)):
continue
if index % 3 == 0:
xs.append(float(value))
elif index % 3 == 1:
ys.append(float(value))
if not xs or not ys:
return None
return (
(max(xs) - min(xs), max(ys) - min(ys)),
((min(xs) + max(xs)) / 2, (min(ys) + max(ys)) / 2),
)
def parse_scene(
scene: dict[str, Any],
geometries: dict[str, Any] | None = None,
timeline: dict[str, Any] | None = None,
) -> Scene:
"""把一棵场景树摊平成 part 列表,并合成世界变换。
合成语义照抄引擎(three.js 系)的节点语义,与旧项目 v2 侧车一致:
world_position = parent_position + parent_scale ⊙ local_position
world_scale = parent_scale ⊙ local_scale
旋转暂不参与合成(旧项目同样如此)——只在 stats 里报出数量,别假装算了。
"""
scene_id = str(scene.get("id") or scene.get("name") or "scene")
config = scene.get("sceneConfig") or {}
ui = None
if isinstance(config, dict) and config.get("uiWidth") and config.get("uiHeight"):
ui = (int(config["uiWidth"]), int(config["uiHeight"]))
camera = None
cameras = scene.get("camera")
if isinstance(cameras, list) and cameras:
first = cameras[0]
if isinstance(first, dict):
camera = {
"id": first.get("id"),
"position": first.get("position"),
"rotation": first.get("rotation"),
"camera": first.get("camera"),
}
result = Scene(id=scene_id, ui=ui, camera=camera)
order = 0
def walk(node: Any, path: str, depth: int, parent_pos: tuple[float, float, float], parent_scale: tuple[float, float, float]) -> None:
nonlocal order
if not isinstance(node, dict):
return
name = str(node.get("name") or node.get("id") or "?")
node_path = f"{path}/{name}"
result.node_count += 1
result.max_depth = max(result.max_depth, depth)
# 时间线**按节点名覆盖** position/scale(源码:parsePath → getObjectByName → target[prop] = 值)。
# 静态值是入场前的状态,稳定态要用轨道末帧值。
overridden_pos = None
overridden_scale = None
if timeline:
tracks = timeline.get(scene_id) or {}
overridden_pos = (tracks.get("positions") or {}).get(name)
overridden_scale = (tracks.get("scales") or {}).get(name)
local_pos = _vec3(overridden_pos) if overridden_pos else _vec3(node.get("position"))
local_scale = _vec3(overridden_scale) if overridden_scale else _vec3(node.get("scale"))
rotation = _vec3(node.get("rotation"))
if rotation and any(abs(v) > 1e-9 for v in rotation):
result.rotated_nodes += 1
world_pos = parent_pos
if local_pos:
world_pos = tuple(parent_pos[i] + parent_scale[i] * local_pos[i] for i in range(3))
world_scale = parent_scale
if local_scale:
world_scale = tuple(parent_scale[i] * local_scale[i] for i in range(3))
spine = node.get("spine")
spine_id = spine.get("id") if isinstance(spine, dict) else None
spine_animation = None
spine_skin = None
spine_time_scale = None
if isinstance(spine, dict):
# 页面在这个节点上指定的播放方式——不读它就会去播骨架的第一个动画(常常是入场动画 in)。
if isinstance(spine.get("defaultAnimation"), str):
spine_animation = spine["defaultAnimation"]
if isinstance(spine.get("skin"), str):
spine_skin = spine["skin"]
if isinstance(spine.get("timeScale"), (int, float)):
spine_time_scale = float(spine["timeScale"])
diffuse, textured = _material_info(node)
modifiers = _modifiers(node)
geometry = node.get("geometry")
geometry_type = geometry.get("type") if isinstance(geometry, dict) else None
geometry_size = None
geometry_center = None
if isinstance(geometry, dict):
config = geometry.get("config")
if isinstance(config, dict) and config.get("width") and config.get("height"):
geometry_size = (float(config["width"]), float(config["height"]))
elif geometry.get("type") == 1 and isinstance(geometry.get("id"), str) and geometries:
box = mesh_bbox(geometries.get(geometry["id"]))
if box is not None:
geometry_size, geometry_center = box
if isinstance(spine_id, str) and spine_id:
result.parts.append(
Part(
kind="spine",
id=spine_id,
node_path=node_path,
order=order,
render_order=int(node.get("renderOrder") or 0),
position=world_pos,
scale=world_scale,
local_position=local_pos,
local_scale=local_scale,
rotation=rotation,
geometry_type=None,
auto_matrix=bool(node.get("autoMatrix")),
modifiers=modifiers,
animation=spine_animation,
skin=spine_skin,
time_scale=spine_time_scale,
)
)
order += 1
elif isinstance(diffuse, str) and diffuse and geometry is not None:
result.parts.append(
Part(
kind="image" if textured and diffuse != "DEFAULT" else "solid",
id=diffuse,
node_path=node_path,
order=order,
render_order=int(node.get("renderOrder") or 0),
position=world_pos,
scale=world_scale,
local_position=local_pos,
local_scale=local_scale,
rotation=rotation,
geometry_type=int(geometry_type) if isinstance(geometry_type, int) else None,
auto_matrix=bool(node.get("autoMatrix")),
geometry_size=geometry_size,
geometry_center=geometry_center,
modifiers=modifiers,
)
)
order += 1
for child in node.get("children") or []:
walk(child, node_path, depth + 1, world_pos, world_scale)
for child in scene.get("children") or []:
walk(child, scene_id, 1, (0.0, 0.0, 0.0), (1.0, 1.0, 1.0))
return result
def pick_default_scene(scenes: list[Scene]) -> Scene | None:
"""默认场景 = 骨架最多的那个;并列时取先出现的。
这与旧项目的规则一致("取 spine 最多的一个场景")。它只是**默认值**:
一旦 `sources.yml` 或 `selection.yml` 指定了场景,就以人/记录为准。
"""
if not scenes:
return None
return max(scenes, key=lambda s: (len(s.spine_ids), -scenes.index(s)))
+165
View File
@@ -0,0 +1,165 @@
"""场景的选择:人写的 `sources.yml` 优先,其次机器写的 `selection.yml`,最后才问人。
为什么要第二份文件:`sources.yml` 是人手写的(注释、顺序、措辞都是人的),用 PyYAML 回写会把
这些全吃掉。所以机器只写自己那一份 `selection.yml`,人写的文件永远不被机器改写。
优先级(明确写死,避免两份文件打架):
1. `sources.yml` 里这一页写了 `scene:` / `spines:` → 用它(人的意志最高)
2. `selection.yml` 里有这一页的记录 → 用它(上次交互的结果)
3. 都没有 → 默认取骨架最多的场景 + 该场景全部骨架;
开了 `--interactive` 才问人
**这份选择不再裁剪产物**:`fetch` 会把页面里每个有资源的场景都落成一个壁纸目录(见
`tools/downloader/README.md`)。选择只决定"哪一档是推荐的"——写进 `page.json` 的
`chosenScene` / `chosenDir`,当 `promote` 没给 `--scene` 时的默认值。记录里的 `spines` /
`images` 字段保留只为兼容旧文件,不再有任何裁剪语义。
"""
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Iterable, Sequence
import yaml
__all__ = ["Choice", "load_selection", "save_selection", "resolve", "ask"]
_HEADER = (
"# 场景/骨架选择记录(由 `python -m tools.downloader select` 写入,机器所有)。\n"
"# 人写的来源清单是 wallpapers/sources.yml;这里只记「哪一页推荐哪个场景」。\n"
"# 入库是为了换台机器重跑能复现同一份推荐值。\n"
"# 注意:fetch 会落盘**全部**有资源的场景,这份记录不再裁剪产物。\n"
"# 优先级:sources.yml 的显式声明 > 本文件 > 默认值(骨架最多的场景)。\n"
)
@dataclass
class Choice:
"""一页的选择结果。"""
page: str
scene: str
spines: list[str] = field(default_factory=list)
source: str = "default" # default | sources | selection | interactive
images: list[str] = field(default_factory=list)
def as_dict(self) -> dict[str, Any]:
return {"scene": self.scene, "spines": list(self.spines), "images": list(self.images)}
def load_selection(path: Path) -> dict[str, Any]:
if not path.exists():
return {}
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
return data if isinstance(data, dict) else {}
def save_selection(path: Path, selection: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
body = yaml.safe_dump(selection, allow_unicode=True, sort_keys=True, default_flow_style=False)
path.write_text(_HEADER + body, encoding="utf-8")
def resolve(
page: str,
scenes: Sequence[Any],
*,
sources_entry: dict[str, Any] | None = None,
selection: dict[str, Any] | None = None,
default_scene: str | None = None,
default_spines: Iterable[str] = (),
interactive: bool = False,
input_fn: Any = input,
output_fn: Any = print,
) -> Choice:
"""按优先级算出一页要抓的场景与骨架。"""
selection = selection or {}
record = selection.get(page) or {}
scene = None
spines: list[str] = []
source = "default"
if sources_entry and sources_entry.get("scene"):
scene = str(sources_entry["scene"])
source = "sources"
if sources_entry and sources_entry.get("spines"):
spines = [str(s) for s in sources_entry["spines"]]
source = "sources"
if scene is None and record.get("scene"):
scene = str(record["scene"])
source = "selection"
if not spines and record.get("spines"):
spines = [str(s) for s in record["spines"]]
if source == "default":
source = "selection"
if scene is None:
scene = default_scene
if not spines:
spines = list(default_spines)
# 记录里的场景若一个骨架都没有,它就是个**选不出壁纸**的记录(交互误选、或页面改版了)。
# 这种情况退回默认并标明来源,别让人拿到一个必然报红的产物。
if scenes and scene and scene != default_scene:
chosen_scene = next((s for s in scenes if s.id == scene), None)
if chosen_scene is not None and not chosen_scene.spine_ids and default_scene:
source = f"{source}(无效→默认)"
scene = default_scene
spines = list(default_spines)
if interactive and scenes:
chosen = ask(page, scenes, scene=scene, spines=spines, input_fn=input_fn, output_fn=output_fn)
if chosen is not None:
scene, spines = chosen
source = "interactive"
return Choice(page=page, scene=scene or "", spines=spines, source=source)
def ask(
page: str,
scenes: Sequence[Any],
*,
scene: str | None,
spines: Sequence[str],
input_fn: Any = input,
output_fn: Any = print,
) -> tuple[str, list[str]] | None:
"""交互式选择:只选**推荐场景**。
为什么不再多选骨架:产物现在是"一个场景一个自包含的壁纸目录",砍掉几具骨架会让这个场景的
`sceneConfig` 缺件——那是坏壁纸,不是精简。要裁就在预设里裁(`preset.template.json` 是纯数据)。
"""
output_fn(f"\n[{page}] 检测到 {len(scenes)} 个场景(**全部**会落盘,这里选的是推荐值):")
for i, s in enumerate(scenes, 1):
mark = " ← 推荐" if s.id == scene else ""
has_assets = any(p.kind in ("spine", "image") for p in s.parts)
note = "" if has_assets else "(无资源,不会落盘)"
output_fn(
f" {i}) {s.id} 骨架 {len(s.spine_ids)} 个,图片 "
f"{sum(1 for p in s.parts if p.kind == 'image')} 个{note}{mark}"
)
raw = input_fn("推荐场景编号(回车取推荐值):").strip()
if raw:
try:
index = int(raw) - 1
if not 0 <= index < len(scenes):
output_fn("编号超出范围,取推荐值。")
else:
scene = scenes[index].id
except ValueError:
output_fn("不是数字,取推荐值。")
target = next((s for s in scenes if s.id == scene), None)
if target is None:
return None
if not any(p.kind in ("spine", "image") for p in target.parts):
# 一件资源都没有的场景落不成壁纸(`fetch` 会跳过它)。与其让人推荐一个必然没有产物的
# 场景,不如当场退回默认并说明原因。
output_fn(f"[{page}] {scene} 里没有任何需要资源的 part,退回默认场景。")
return None
return scene, list(spines)
+125
View File
@@ -0,0 +1,125 @@
# 场景/骨架选择记录(由 `python -m tools.downloader select` 写入,机器所有)。
# 人写的来源清单是 wallpapers/sources.yml;这里只记「哪一页推荐哪个场景」。
# 入库是为了换台机器重跑能复现同一份推荐值。
# 注意:fetch 会落盘**全部**有资源的场景,这份记录不再裁剪产物。
# 优先级:sources.yml 的显式声明 > 本文件 > 默认值(骨架最多的场景)。
back-moon:
images: []
scene: scene_main
spines:
- kv_dahua_la
- kv_dahua_lb
- kv_dahua_b
- kv_dahua_c
- kv_dahua_d
- kv_dahua_e
- kv_dahua_f
- kv_dahua_g
- kv_dahua_h
- kv_dahua_i
- kv_dahua_k
- kv_dahua_l
- kv_qj_xhua_a
- kv_qj_xhua_b
- kv_fc_xhua_b
- kv_fc_xhua_c
- kv_fc_xhua_d
- kv_fc_xhua_e
- kv_fc_xhua_f
- kv_fc_xhua_g
- kv_fc_xhua_h
- kv_fc_xhua_i
- kv_xhua_c
- kv_yezhi_a
- kv_yc_hua_a
- kv_zc_hua_b
- kv_zc_hua_c
- kv_zc_hua_d
- kv_zc_hua_e
- kv_yc_hua_b
- kv_xf_hua_a
- kv_shaonv
get-memory:
images: []
scene: P1
spines:
- loading_pass
- p1_icon
- p2_huiyi_d
- p2_huiyi_c
- p2_huiyi_b
- p2_huiyi_a
- p1_traveler
- p1_starline
- p1_huiyi_line
- p2_huiyi_line
- p1_floor
- p1_leaf
- p1_moon_stone
- p1_girl
- p1_moon
- p1_star
- p1_montain
kv45:
images: []
scene: scene_main
spines:
- 01_beijing
- 02_shajin
- 03_zhigengniao
- 04_qianjing
kv46:
images: []
scene: scene_main
spines:
- jh01_46kv_bg
- jh02_46kv_npc
- jh03_46kv_boss
- jh04_46kv_an
- jh05_46kv_zhengzhu
nico-tea:
images: []
scene: scene_main
spines:
- main_beizi_e
- main_beizi_f
- main_beizi_g
- main_beizi_d
- main_d_book_xfl
- main_d_books
- main_xinlin
- main_d_hua
- main_d_dianxin
- main_nike
- main_tree_a
- main_tree_b
- main_cao_g
- main_cao_h
- main_cao_f
start-ndkl:
images: []
scene: scene_main
spines:
- 7FEI_BG
- 6AI_BG7
- 6AI_BG6
- 6AI_BG5
- 6AI_BG4
- 6AI_BG3
- 6AI_BG2
- 6AI_BG1
- 5GUODU
- 4FEI_REN
- 3LA_BG
- 2AI_REN
- 1LA_REN
- 0FG
zhidong-wonder:
images: []
scene: scene_draw
spines:
- d_box_light
- box_click
- box
- d_tips_bg
- d_snow_icon
+23
View File
@@ -0,0 +1,23 @@
"""站点适配层。
一个站点适配器只做三件事:**发现入口 bundle**、**从 bundle 里把资源清单抠出来**、**把逻辑名解析成
可下载的 URL**。通用管线(选择场景、落盘、自检)不认站点,只认这里吐出的数据结构——将来接别的
网站,新增一个同形状的模块即可。
"""
from __future__ import annotations
from . import mihoyo
__all__ = ["mihoyo", "for_url", "SITE_MODULES"]
SITE_MODULES = [mihoyo]
def for_url(url: str) -> mihoyo.Site | None:
"""按 URL 选站点适配器;没有匹配的返回 None。"""
for module in SITE_MODULES:
site = module.match(url)
if site is not None:
return site
return None
+730
View File
@@ -0,0 +1,730 @@
"""米哈游活动页适配器。
事实基线(2026-09-20 对 6 个页面的实爬与逆向,卷宗见 .scratch/mhy-recon/REPORT.md):
* **骨架数据一律内联在入口 bundle 里**——6 个页面里 `.atlas` / `.skel` 的网络请求数是 0。
只有 hsr 的 kv45 例外:它的 skeleton json 走网络(页面根目录 `<hash>.json`),atlas 仍是内联。
* 内联有三种家族:
- **A**(nico-tea / back-moon / zhidong-wonder):
``Object.values(Object.assign({"…/spine/<N>.json":{…}}))[0]``,值是 **JS 对象字面量**(键不带引号)。
- **B**(get-memory / start-ndkl):atlas 与骨架都是匿名 webpack 模块,靠配对表
``"<N>":{atlas:<fn>(<id>),json:<fn>(<id>)}`` 关联。
- **C**(kv45):``spineSetting`` 表里 ``{atlas:"<文本>", json:<模块变量>}``。
* **贴图页走网络**,URL 由 bundle 里的资源表给出,形如
``images/<逻辑名>.<hash>..png``(ys)与 ``assets/images/<逻辑名>.<hash>.<hash>.png``(hsr)。
逻辑名就是 atlas 第一行的页名——**去 hash 就是把它换回逻辑名**。
* 同一逻辑名可能有多个候选(引擎有桌面/移动两套预载表)。判据:**数组式描述表是基准集,
字典式表是移动端覆盖**(引擎里是 `desktop() || base.forEach(e => override[e.id] && …)`),
桌面取基准集。
"""
from __future__ import annotations
import json
import re
import urllib.error
import urllib.request
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from .. import atlas as atlas_mod
from .. import jslit
from .. import scene as scene_mod
__all__ = ["Site", "PageData", "SpineAsset", "match", "fetch_page", "HttpError"]
_DATA_URL = re.compile(r"^data:(?P<mime>[^;,]+)(?P<b64>;base64)?,(?P<body>.*)$", re.DOTALL)
_MIME_EXT = {"image/png": ".png", "image/jpeg": ".jpg", "image/webp": ".webp", "image/gif": ".gif"}
def decode_data_url(url: str) -> bytes:
"""解内联 data: URL(webpack 把小于阈值的图直接塞进 bundle)。"""
import base64
m = _DATA_URL.match(url)
if m is None:
raise HttpError(f"不是合法的 data: URL:{url[:40]}…")
body = m.group("body")
if m.group("b64"):
return base64.b64decode(body)
from urllib.parse import unquote_to_bytes
return unquote_to_bytes(body)
def image_ext(rel: str) -> str:
"""资源路径 → 落盘扩展名(data: URL 走 MIME 判定)。"""
if rel.startswith("data:"):
mime = rel[5:].split(";", 1)[0].split(",", 1)[0]
return _MIME_EXT.get(mime, ".png")
ext = Path(rel.split("?")[0]).suffix.lower()
return ext if ext in (".png", ".webp", ".jpg", ".jpeg", ".gif") else ".png"
_UA = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/146.0.0.0 Safari/537.36"
)
_TIMEOUT = 30
class HttpError(RuntimeError):
"""抓取失败(离线缺缓存、HTTP 非 200、网络异常)。"""
@dataclass(frozen=True)
class Site:
"""站点标识。"""
id: str
game: str
host: str
path_prefix: str
@dataclass
class SpineAsset:
"""一具骨架的抓取素材。"""
name: str
atlas_text: str
pages: list[str]
version: str | None = None
animations: list[str] = field(default_factory=list)
skins: list[str] = field(default_factory=list)
json_data: dict[str, Any] | None = None # 内联家族
json_rel: str | None = None # 走网络时相对页面目录的路径
json_text: str | None = None # 已下载/待下载的原文
def as_meta(self, source: str) -> dict[str, Any]:
return {
"name": self.name,
"spine": self.version,
"animations": self.animations,
"skins": self.skins,
"pages": self.pages,
"source": source,
}
@dataclass
class PageData:
"""一个页面的全部抓取素材。"""
id: str
game: str
url: str
site: Site
entry_url: str | None = None
bundles: dict[str, str] = field(default_factory=dict)
spines: dict[str, SpineAsset] = field(default_factory=dict)
scenes: list[scene_mod.Scene] = field(default_factory=list)
images: dict[str, str] = field(default_factory=dict) # 逻辑名 → 相对页面目录的路径
geometries: dict[str, Any] = field(default_factory=dict) # glTF 网格哈希 → 网格数据
timeline: dict[str, dict[str, dict[str, list[float]]]] = field(default_factory=dict) # 场景 → 轨道末帧
warnings: list[str] = field(default_factory=list)
@property
def base_url(self) -> str:
return self.url.rsplit("/", 1)[0] + "/"
def asset_url(self, rel: str) -> str:
"""相对路径 → 绝对 URL;内联 data: URL 原样返回。"""
if rel.startswith("data:"):
return rel
return self.base_url + rel.lstrip("/")
# --------------------------------------------------------------------------------------
# HTTP + 缓存
# --------------------------------------------------------------------------------------
def _cache_path(cache_dir: Path, page_id: str, rel: str) -> Path:
safe = rel.replace("/", "__").split("?")[0]
return cache_dir / page_id / safe
def http_get(url: str, cache_dir: Path | None, *, page_id: str = "_", rel: str | None = None,
offline: bool = False, binary: bool = False) -> bytes:
"""取一个 URL,带磁盘缓存。``offline=True`` 时只用缓存,缺了就报错。"""
if url.startswith("data:"):
return decode_data_url(url)
path = _cache_path(cache_dir, page_id, rel or url) if cache_dir else None
if path is not None and path.exists():
return path.read_bytes()
if offline:
raise HttpError(f"离线模式但缓存缺失:{url}")
req = urllib.request.Request(url, headers={"User-Agent": _UA, "Referer": url})
try:
with urllib.request.urlopen(req, timeout=_TIMEOUT) as resp:
if resp.status != 200:
raise HttpError(f"HTTP {resp.status}:{url}")
data = resp.read()
except urllib.error.HTTPError as exc:
raise HttpError(f"HTTP {exc.code}:{url}") from exc
except urllib.error.URLError as exc:
raise HttpError(f"网络错误:{url}({exc.reason})") from exc
if path is not None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
return data
def http_get_text(url: str, cache_dir: Path | None, **kw: Any) -> str:
return http_get(url, cache_dir, **kw).decode("utf-8", errors="replace")
# --------------------------------------------------------------------------------------
# 通用解析辅助
# --------------------------------------------------------------------------------------
_STRING = re.compile(r'"(?:[^"\\]|\\.)*"')
_PUBLIC_PATH = r"[A-Za-z_$][\w$]*\.p"
_ASSET_LITERAL = re.compile(r'([A-Za-z_$][\w$]*)\s*=\s*' + _PUBLIC_PATH + r'\s*\+\s*"([^"]+)"')
_MODULE_LITERAL = re.compile(
r'(\d+):function\([^)]*\)\s*\{[^}]*?e\.exports\s*=\s*' + _PUBLIC_PATH + r'\s*\+\s*"([^"]+)"'
)
# 模块直接导出**字符串**:小图被 webpack 内联成 `e.exports="data:image/png;base64,\u2026"`,也有
# `e.exports="images/x.png"` 这种不带公共路径前缀的写法。描述表里写 `src:a(38458)` 时只能靠
# 这张表还原——不认它就会把"内联的图"当成"根本没引用"(实测 get-memory 的 loading_moutain_a 与
# a01_lizi、start-ndkl 的 loading_start_1 都栽在这里,它们是真资源,不是运行时残留)。
_MODULE_STRING = re.compile(r'(\d+)\s*:\s*function\s*\([^)]*\)\s*\{[^}]*?e\.exports\s*=\s*"((?:[^"\\]|\\.)*)"')
# `src:a(38458)`:直接调 require 函数、参数是模块号。
_REQUIRE_CALL = re.compile(r"\s*[A-Za-z_$][\w$]*\s*\(\s*(\d+)\s*\)\s*")
# `$w="data:image/png;base64,\u2026"`:变量直接存字符串字面量。描述表里写 `{src:$w,…}` 时靠它还原
# (kv45 的 loading_dt1 就是这样,不认它就会被当成"缺资源"直接判红)。
_STRING_ASSIGN = re.compile(r'([A-Za-z_$][\w$]*)\s*=\s*"((?:[^"\\]|\\.)*)"')
_REQUIRE_ID = re.compile(r'([A-Za-z_$][\w$]*)\s*=\s*n\((\d+)\)')
_REQUIRE_URL_ID = re.compile(r'([A-Za-z_$][\w$]*)\s*=\s*new URL\(n\((\d+)\)')
_REQUIRE_NS = re.compile(r'([A-Za-z_$][\w$]*)\s*=\s*n\.n\(([A-Za-z_$][\w$]*)\)')
# 描述表有两种 src 形状,分开匹配——一条 `.+?` 通吃会让它跨过整段 bundle 去够后面那条
# `,id:"…",type:"image"`:kv45 的 `{src:e.activeIcon,alt:""}` 就是这么把
# `{src:$w,id:"loading_dt1",type:"image"}` 整个吞掉的(真表项永远收集不到)。
# inline:`Object.values(Object.assign({"<路径>":"data:…"}))[0]`(webpack 内联的小图)
# simple:`a(38458)` / `$w` / `a.p+"images/x.png"` 这类不含 `,` `{` `}` 的表达式
_ASSIGN_EXPR = r'Object\.values\(Object\.assign\(\{[^{}]*\}\)\)\[0\]'
_DESCRIPTOR_INLINE = re.compile(r'\{src:(' + _ASSIGN_EXPR + r'),id:"([^"]+)",type:"image"\}')
_DESCRIPTOR_SIMPLE = re.compile(r'\{src:([^,{}]+),id:"([^"]+)",type:"image"\}')
# webpack 会把小图内联:`Object.values(Object.assign({"<源路径>":"data:image/png;base64,…"}))[0]`
_DATA_IN_ASSIGN = re.compile(r'Object\.values\(Object\.assign\(\{"[^"]*":("(?:[^"\\]|\\.)*")\}\)\)')
_OVERRIDE_TABLE = re.compile(r'([A-Za-z_$][\w$]*)\s*=\s*\{\s*"([^"]+)"\s*:\s*([A-Za-z_$][\w$]*)\(\)')
def _unescape_js_string(raw: str) -> str:
"""把 ``"…"``(含两端引号)还原成文本。"""
return json.loads(raw)
def _literal_of(expr: str) -> str | None:
"""从 ``Object.values(Object.assign({"<源路径>":<VAR>}))[0]`` 这类表达式里取出变量名。"""
m = re.search(r'Object\.values\(Object\.assign\(\{[^:]*:([A-Za-z_$][\w$]*)\}\)\)', expr)
if m:
return m.group(1)
m = re.fullmatch(r"([A-Za-z_$][\w$]*)\(\)", expr.strip())
if m:
return m.group(1)
m = re.fullmatch(r"([A-Za-z_$][\w$]*)", expr.strip())
if m:
return m.group(1)
return None
class _AssetResolver:
"""把 bundle 里的变量/模块引用解析成真正的资源路径。
压缩后的变量名在**模块作用域**内唯一,所以回退查找要限定在同一个 webpack 模块里;
这里用"从引用处往前找最近的模块头"作为边界,避免跨模块误配。
"""
def __init__(self, text: str) -> None:
self.text = text
self.var_to_literal = {m.group(1): m.group(2) for m in _ASSET_LITERAL.finditer(text)}
self.module_to_literal = {m.group(1): m.group(2) for m in _MODULE_LITERAL.finditer(text)}
self.module_to_string: dict[str, str] = {}
for m in _MODULE_STRING.finditer(text):
try:
# 捕获组里没有引号,而 _unescape_js_string 要的是含引号的原文。
value = _unescape_js_string('"' + m.group(2) + '"')
except json.JSONDecodeError:
continue
# 只认"看起来是资源"的字符串导出:atlas 文本与内联 JSON 也是字符串导出,
# 把它们当路径只会造出一条永远下载失败的假引用。
if value.startswith("data:") or value.lower().endswith((".png", ".webp", ".jpg", ".jpeg", ".gif")):
self.module_to_string.setdefault(m.group(1), value)
self.var_to_string: dict[str, str] = {}
for m in _STRING_ASSIGN.finditer(text):
raw = m.group(2)
if not (raw.startswith("data:") or raw.lower().endswith((".png", ".webp", ".jpg", ".jpeg", ".gif"))):
continue
try:
self.var_to_string.setdefault(m.group(1), _unescape_js_string('"' + raw + '"'))
except json.JSONDecodeError:
continue
self.var_to_module = {m.group(1): m.group(2) for m in _REQUIRE_ID.finditer(text)}
self.var_to_module.update({m.group(1): m.group(2) for m in _REQUIRE_URL_ID.finditer(text)})
self.ns_to_var = {m.group(1): m.group(2) for m in _REQUIRE_NS.finditer(text)}
def resolve_var(self, var: str) -> str | None:
if var in self.var_to_literal:
return self.var_to_literal[var]
if var in self.var_to_string:
return self.var_to_string[var]
target = self.ns_to_var.get(var, var)
if target in self.var_to_literal:
return self.var_to_literal[target]
module_id = self.var_to_module.get(target)
if module_id is not None:
return self.module_to_literal.get(module_id)
return None
def resolve_expr(self, expr: str) -> str | None:
inline = _DATA_IN_ASSIGN.search(expr)
if inline is not None:
try:
return _unescape_js_string(inline.group(1))
except json.JSONDecodeError:
return None
# `src:a(38458)` 这种直接调 require 的写法:模块导出可能是 `p+"\u2026"`,也可能是内联 data:。
call = _REQUIRE_CALL.fullmatch(expr)
if call is not None:
module_id = call.group(1)
return self.module_to_literal.get(module_id) or self.module_to_string.get(module_id)
var = _literal_of(expr)
if var is None:
return None
return self.resolve_var(var)
def _collect_geometries(text: str) -> dict[str, Any]:
"""收集内联的 glTF 网格表(`geometries:{<哈希>:{type,attributes:{position:{array}}}}`)。
场景节点里 `geometry:{type:1,id:"<哈希>"}` 的尺寸不写在场景树上,只能来这里算顶点包围盒。
键有时带引号(JSON.parse 的模块)有时不带(JS 字面量),所以两种都要认。
"""
out: dict[str, Any] = {}
for m in re.finditer(r"geometries", text):
brace = text.find("{", m.end())
if brace < 0 or brace - m.end() > 4:
continue
end = jslit.match_literal(text, brace)
if end < 0:
continue
try:
data = jslit.loads(text[brace : end + 1])
except jslit.JsLitError:
continue
if isinstance(data, dict):
for key, value in data.items():
if isinstance(value, dict) and key not in out:
out[key] = value
return out
_TRACK = re.compile(r'name:"([\w.]+)\.(position|scale)"')
_CLIP_START = "{type:"
# 场景自己在 modifier 里点名播哪条轨道:playTimeline{sceneName, trackName1..6, frame1..6}
_PLAY_TIMELINE = re.compile(r'playTimeline",data:\{sceneName:"([\w-]+)",([^}]*)\}')
_TRACK_ENTRY = re.compile(r'trackName(\d+):"([\w-]*)"')
_FRAME_ENTRY = re.compile(r"frame(\d+):(\d+)")
def _block_tracks(
text: str, block: str, frame: int | None = None
) -> dict[str, dict[str, list[float]]]:
"""取某个**块**(如 `pv`)里 position/scale 轨道在**指定帧**的值。
`frame=None` 时取末帧(块的结束状态);给了帧就取该帧(场景声明的 `frame1`)。
为什么按块取:同名节点(layout/bg/img)在 `loading` 与 `pv` 里各有自己的关键帧,
全局扫名字会把**入场块**的值套到主场景上(实测差得很远:layout.z 539 vs 439,x/y 也不同)。
源码依据:`parsePath` 把 `a.b` 解析成 `getObjectByName("a")` 再写 `.b`——轨道名第一段是节点名,值是**覆盖**。
"""
positions: dict[str, list[float]] = {}
scales: dict[str, list[float]] = {}
anchor = text.find(f"{{{block}:{{type:1,")
if anchor < 0:
return {"positions": positions, "scales": scales}
end = jslit.match_literal(text, anchor)
if end < 0:
return {"positions": positions, "scales": scales}
for m in _TRACK.finditer(text, anchor, end):
name, prop = m.group(1), m.group(2)
back = text.rfind(_CLIP_START, anchor, m.start())
if back < 0 or m.start() - back > 4000:
continue
clip_end = jslit.match_literal(text, back)
if clip_end < 0 or clip_end > end:
continue
try:
clip = jslit.loads(text[back : clip_end + 1])
except jslit.JsLitError:
continue
if not isinstance(clip, dict) or clip.get("name") != f"{name}.{prop}":
continue
data = clip.get("data") or []
if not isinstance(data, list) or not data:
continue
frames = (data[-1] or {}).get("frames") or []
if not isinstance(frames, list) or not frames:
continue
# 帧号语义:块的 clip 从 start 起算,`frames` 是整个区间的关键帧序列。
# 场景声明的帧是「从这条轨道第几帧开始」,直接按该下标取(越界则回退到首/末)。
value = frames[frame] if frame is not None and 0 <= frame < len(frames) else frames[-1]
if not isinstance(value, list):
continue
target = positions if prop == "position" else scales
target.setdefault(name, [float(v) for v in value if isinstance(v, (int, float))])
return {"positions": positions, "scales": scales}
def _collect_timeline(text: str) -> dict[str, dict[str, dict[str, list[float]]]]:
"""按场景归并:`{场景id: {positions: {...}, scales: {...}}}`。
场景播哪条轨道由它自己的 `playTimeline` modifier 声明;只取那条轨道的末帧(= 稳定态)。
"""
per_scene: dict[str, dict[str, dict[str, list[float]]]] = {}
cache: dict[str, dict[str, dict[str, list[float]]]] = {}
for m in _PLAY_TIMELINE.finditer(text):
scene, body = m.group(1), m.group(2)
bucket = per_scene.setdefault(scene, {"positions": {}, "scales": {}})
frames_by_slot = {entry.group(1): int(entry.group(2)) for entry in _FRAME_ENTRY.finditer(body)}
for entry in _TRACK_ENTRY.finditer(body):
track = entry.group(2)
if not track:
continue
frame = frames_by_slot.get(entry.group(1))
key = f"{track}@{frame}"
if key not in cache:
cache[key] = _block_tracks(text, track, frame)
for kind in ("positions", "scales"):
for node, vec in cache[key][kind].items():
bucket[kind].setdefault(node, vec)
return per_scene
def _collect_images(text: str) -> dict[str, str]:
"""从 bundle 的预载描述表里取「逻辑名 → 资源路径」。
数组式描述表(``[{src:…,id:"x",type:"image"}]``)是基准集,先收;字典式
(``{"x":fn()}``)是引擎在非桌面端才套用的覆盖集,只在没有基准候选时兜底。
"""
resolver = _AssetResolver(text)
images: dict[str, str] = {}
for pattern in (_DESCRIPTOR_INLINE, _DESCRIPTOR_SIMPLE):
for m in pattern.finditer(text):
expr, name = m.group(1), m.group(2)
rel = resolver.resolve_expr(expr)
if rel and name not in images:
images[name] = rel
for m in _OVERRIDE_TABLE.finditer(text):
var, name = m.group(3), m.group(2)
rel = resolver.resolve_var(var)
if rel and name not in images:
images[name] = rel
# 兜底:描述表没覆盖到的名字,直接用 `n.p+"images/<逻辑名>.<hash>..png"` 字面量。
# ys 页 99 个字面量与 99 个逻辑名一一对应(无重名),这条兜底因此是确定的;
# hsr 有桌面/移动两套候选,兜底只取先出现的那个。
for m in _ASSET_LITERAL.finditer(text):
rel = m.group(2)
if not re.match(r"^(assets/)?images/", rel):
continue
stem = rel.rsplit("/", 1)[-1].split(".")[0]
images.setdefault(stem, rel)
return images
# --------------------------------------------------------------------------------------
# 骨架提取:三个内联家族
# --------------------------------------------------------------------------------------
_A_JSON = re.compile(r'Object\.values\(Object\.assign\(\{"((?:\.\./\.\.|/)[^"]*?/spine/[\w.-]+\.json)":')
_A_ATLAS = re.compile(r'Object\.values\(Object\.assign\(\{"((?:\.\./\.\.|/)[^"]*?/spine/[\w.-]+\.atlas)":')
_B_ATLAS_MODULE = re.compile(r'(\d+):function\(([\w,\s]*)\)\{"?(?:use strict"?;)?e\.exports=("(?:[^"\\]|\\.)*")\}')
_B_JSON_MODULE = re.compile(r'(\d+):function\(([\w,\s]*)\)\{"?(?:use strict"?;)?e\.exports=JSON\.parse\(')
_B_PAIR = re.compile(r'"?([\w\u4e00-\u9fa5@-]+)"?:\{atlas:([A-Za-z_$][\w$]*)\((\d+)\),json:\2\((\d+)\)\}')
_C_SPINE = re.compile(r'"?([\w@-]+)"?:\{atlas:"((?:[^"\\]|\\.)*)",json:([A-Za-z_$][\w$]*)\}')
def _summary(data: dict[str, Any]) -> tuple[str | None, list[str], list[str]]:
skeleton = data.get("skeleton") or {}
version = skeleton.get("spine") if isinstance(skeleton, dict) else None
animations = sorted((data.get("animations") or {}).keys()) if isinstance(data.get("animations"), dict) else []
raw_skins = data.get("skins")
if isinstance(raw_skins, list):
skins = [s.get("name") if isinstance(s, dict) else s for s in raw_skins]
elif isinstance(raw_skins, dict):
skins = list(raw_skins.keys())
else:
skins = []
return version, animations, [s for s in skins if isinstance(s, str)]
def _extract_family_a(text: str) -> dict[str, SpineAsset]:
assets: dict[str, SpineAsset] = {}
for m in _A_JSON.finditer(text):
name = m.group(1).rsplit("/", 1)[-1][: -len(".json")]
brace = text.find("{", m.end())
if brace < 0:
continue
end = jslit.match_literal(text, brace)
if end < 0:
continue
data = jslit.loads(text[brace : end + 1])
if not isinstance(data, dict):
continue
version, animations, skins = _summary(data)
assets[name] = SpineAsset(
name=name,
atlas_text="",
pages=[],
version=version,
animations=animations,
skins=skins,
json_data=data,
)
for m in _A_ATLAS.finditer(text):
name = m.group(1).rsplit("/", 1)[-1][: -len(".atlas")]
quote = m.end() # 正则正好停在值字符串的左引号上
if quote >= len(text) or text[quote] != '"':
continue
end = quote + 1
while end < len(text):
if text[end] == "\\":
end += 2
elif text[end] == '"':
break
else:
end += 1
atlas_text = _unescape_js_string(text[quote : end + 1])
info = atlas_mod.parse(atlas_text)
asset = assets.get(name)
if asset is None:
continue
asset.atlas_text = atlas_text
asset.pages = info.pages
return {k: v for k, v in assets.items() if v.atlas_text}
def _extract_family_b(text: str) -> dict[str, SpineAsset]:
atlas_modules: dict[str, str] = {}
for m in _B_ATLAS_MODULE.finditer(text):
try:
value = _unescape_js_string(m.group(3))
except json.JSONDecodeError:
continue
first = (value.splitlines() or [""])[0]
if re.search(r"\.(png|webp|jpg|jpeg)\s*$", first, re.IGNORECASE) and re.search(
r"^\s*(size|filter|format)\s*:", value, re.IGNORECASE | re.MULTILINE
):
atlas_modules[m.group(1)] = value
json_modules: dict[str, dict[str, Any]] = {}
for m in _B_JSON_MODULE.finditer(text):
paren = m.end() - 1
end = jslit.match_literal(text, paren)
if end < 0:
continue
raw = text[paren + 1 : end].strip()
if len(raw) >= 2 and raw[0] == "'" and raw[-1] == "'":
raw = jslit.unescape(raw[1:-1])
try:
data = json.loads(raw)
except json.JSONDecodeError:
continue
if isinstance(data, dict) and (data.get("skeleton") or {}).get("spine"):
json_modules[m.group(1)] = data
assets: dict[str, SpineAsset] = {}
for m in _B_PAIR.finditer(text):
name, atlas_id, json_id = m.group(1), m.group(3), m.group(4)
if name in assets:
continue
data = json_modules.get(json_id)
atlas_text = atlas_modules.get(atlas_id, "")
if data is None or not atlas_text:
continue
version, animations, skins = _summary(data)
assets[name] = SpineAsset(
name=name,
atlas_text=atlas_text,
pages=atlas_mod.parse(atlas_text).pages,
version=version,
animations=animations,
skins=skins,
json_data=data,
)
return assets
def _extract_family_c(text: str) -> dict[str, SpineAsset]:
"""kv45:``{atlas:"<文本>", json:<模块变量>}``,json 走网络。"""
resolver = _AssetResolver(text)
assets: dict[str, SpineAsset] = {}
for m in _C_SPINE.finditer(text):
name, raw_atlas, var = m.group(1), m.group(2), m.group(3)
if name in assets:
continue
try:
atlas_text = _unescape_js_string(f'"{raw_atlas}"')
except json.JSONDecodeError:
continue
rel = resolver.resolve_var(var)
if not rel:
continue
assets[name] = SpineAsset(
name=name,
atlas_text=atlas_text,
pages=atlas_mod.parse(atlas_text).pages,
json_rel=rel,
)
return assets
# --------------------------------------------------------------------------------------
# 页面抓取
# --------------------------------------------------------------------------------------
_SCRIPT_SRC = re.compile(r'<script[^>]+src="([^"]+)"', re.IGNORECASE)
_YS_PATH = re.compile(r"/(ys|bh3|hkrpg)/event/")
_HSR_PATH = re.compile(r"/puzzle/hkrpg/")
def match(url: str) -> Site | None:
"""认页面:ys 活动页与 hsr 的 puzzle 页各一个站点标识。"""
if _HSR_PATH.search(url):
return Site(id="mihoyo-hsr-puzzle", game="hsr", host="act.mihoyo.com", path_prefix="/puzzle/hkrpg/")
if _YS_PATH.search(url):
return Site(id="mihoyo-ys-event", game="ys", host="act.mihoyo.com", path_prefix="/ys/event/")
if "act.mihoyo.com" in url:
return Site(id="mihoyo-act", game="mihoyo", host="act.mihoyo.com", path_prefix="/")
return None
def _absolute(base: str, rel: str) -> str:
if rel.startswith("http://") or rel.startswith("https://"):
return rel
if rel.startswith("//"):
return "https:" + rel
if rel.startswith("/"):
return "https://act.mihoyo.com" + rel
return base + rel.lstrip("./")
def _discover_scripts(html: str, base: str) -> list[str]:
return [_absolute(base, src) for src in _SCRIPT_SRC.findall(html)]
_CHUNK_FN = re.compile(r"\.u\s*=\s*function\s*\([^)]*\)\s*\{")
_CHUNK_PAIR = re.compile(r'(\d+)\s*:\s*"([^"]*)"')
def discover_chunk_urls(text: str, base: str) -> list[str]:
"""从 webpack 运行时里解出异步 chunk 的 URL。
形如 ``s.u=function(e){return({416:"lib.pc",833:"lib.m"}[e]||e)+"."+{258:"ccb0954b",…}[e]+".js"}``:
取函数体里所有 ``id:"值"``,按值的样子分成**名字表**(`lib.pc` 这类含点的)与**哈希表**(纯十六进制),
再拼成 ``<名字|id>.<哈希>.js``。
"""
urls: list[str] = []
for m in _CHUNK_FN.finditer(text):
end = jslit.match_literal(text, m.end() - 1)
if end < 0:
continue
body = text[m.end() : end]
names: dict[str, str] = {}
hashes: dict[str, str] = {}
for pair in _CHUNK_PAIR.finditer(body):
chunk_id, value = pair.group(1), pair.group(2)
if re.fullmatch(r"[0-9a-f]{6,}", value):
hashes[chunk_id] = value
elif value:
names[chunk_id] = value
for chunk_id, digest in hashes.items():
urls.append(_absolute(base, f"{names.get(chunk_id, chunk_id)}.{digest}.js"))
return urls
def fetch_page(page_id: str, game: str, url: str, *, cache_dir: Path | None = None,
offline: bool = False, log: Any = print) -> PageData:
"""抓一个页面并解析出骨架、场景、图片表。"""
site = match(url)
if site is None:
raise HttpError(f"没有匹配的站点适配器:{url}")
data = PageData(id=page_id, game=game, url=url, site=site)
base = url.rsplit("/", 1)[0] + "/"
html = http_get_text(url, cache_dir, page_id=page_id, rel="index.html", offline=offline)
scripts = _discover_scripts(html, base)
if not scripts:
raise HttpError(f"页面里没有找到任何 <script src>:{url}")
# hsr 的骨架表在 258.*.js 这类 chunk 里,ys 在入口 index_*.js 里;两者都扫一遍更稳。
ordered = sorted(scripts, key=lambda s: (0 if re.search(r"/(index|258)\.", s) else 1, s))
# 队列而不是 for:hsr 的骨架表在 webpack 异步 chunk 里,入口 HTML 根本没列它,
# 只能边扫边把 `.u=` 映射出来的 chunk 追加进队列。
queue = list(ordered)
seen: set[str] = set()
while queue:
script_url = queue.pop(0)
if script_url in seen:
continue
seen.add(script_url)
name = script_url.rsplit("/", 1)[-1]
try:
text = http_get_text(script_url, cache_dir, page_id=page_id, rel=name, offline=offline)
except HttpError as exc:
data.warnings.append(f"跳过 {name}:{exc}")
continue
data.bundles[name] = text
# 网格表要先收:场景树里的 type-1 平面靠它算尺寸与中心。
for key, mesh in _collect_geometries(text).items():
data.geometries.setdefault(key, mesh)
for scene_id, tracks in _collect_timeline(text).items():
bucket = data.timeline.setdefault(scene_id, {"positions": {}, "scales": {}})
for kind in ("positions", "scales"):
for node, vec in tracks[kind].items():
bucket[kind].setdefault(node, vec)
if not data.scenes:
data.scenes = [
scene_mod.parse_scene(s, data.geometries, data.timeline) for s in scene_mod.find_scene_list(text)
]
for key, rel in _collect_images(text).items():
data.images.setdefault(key, rel)
if not data.spines:
spines = _extract_family_a(text) or _extract_family_b(text) or _extract_family_c(text)
if spines:
data.spines = spines
data.entry_url = script_url
if data.spines and data.scenes and data.images:
break
for chunk_url in discover_chunk_urls(text, base):
if chunk_url not in seen:
queue.append(chunk_url)
if not data.spines:
data.warnings.append("没有从任何 bundle 里提取到骨架(页面结构可能变了)")
if not data.scenes:
data.warnings.append("没有找到 sceneList(场景树)")
return data
def load_spine_json(data: PageData, asset: SpineAsset, *, cache_dir: Path | None = None,
offline: bool = False) -> dict[str, Any]:
"""取骨架数据:内联家族直接用内存里的对象,kv45 去下载 ``<hash>.json``。"""
if asset.json_data is not None:
return asset.json_data
if asset.json_rel is None:
raise HttpError(f"骨架 {asset.name} 既没有内联数据也没有 json 路径")
if asset.json_text is None:
asset.json_text = http_get_text(
data.asset_url(asset.json_rel), cache_dir, page_id=data.id, rel=asset.json_rel, offline=offline
)
parsed = json.loads(asset.json_text)
version, animations, skins = _summary(parsed)
asset.version = version
asset.animations = animations
asset.skins = skins
return parsed
+233
View File
@@ -0,0 +1,233 @@
"""`_out/` 的自检:产物是不是真的自洽。
抓取最怕的不是"下不动",是"下了一半还长得像成功":侧车引用的骨架漏了一个、atlas 声明的贴图页
没落地、页名与文件名对不上(运行时就会去请求一个不存在的页)、预设里的 `./…` 路径找不到文件。
所以每次 `fetch` 结束都会跑一遍,`verify` 子命令也能单独跑;**退出码非 0 = 有硬伤**。
形状(一个场景 = 一个可直接搬走的壁纸目录,见 `layout.py`)::
<root>/<游戏>/<页面>/page.json
<root>/<游戏>/<页面>/<场景id>/meta.json | preset.template.json | scene.json
<root>/<游戏>/<页面>/<场景id>/spines/<名>/… scene/… audios/
约定:这里只做"引用闭包 + 文件齐全 + 页名一致 + 版本可读 + 预设路径存在"五件事,不评判画面对不对。
"""
from __future__ import annotations
import json
import re
from pathlib import Path
from typing import Any
from . import atlas as atlas_mod
from . import layout as layout_mod
__all__ = ["verify_page", "verify_all", "verify_scene_dir", "SUPPORTED_RUNTIME"]
# 本仓库 vendored 的播放器是 spine-ts 4.2 线:4.0/4.1/4.2 的数据都能读,
# 4.3 起把约束并进 root.constraints,4.2 解析器会静默丢掉全部约束——所以要显式报出来。
SUPPORTED_RUNTIME = (4, 2)
_EXT = (".png", ".webp", ".jpg", ".jpeg")
_ID_OK = re.compile(r"^[a-z0-9][a-z0-9_-]*$", re.IGNORECASE)
def _read_json(path: Path) -> Any:
return json.loads(path.read_text(encoding="utf-8"))
def _runtime_of(version: str | None) -> tuple[int, int] | None:
if not version:
return None
m = re.match(r"(\d+)\.(\d+)", version)
if not m:
return None
return (int(m.group(1)), int(m.group(2)))
def _is_scene_dir(path: Path) -> bool:
"""场景目录 = 有 `preset.template.json`。判据不能只看目录名——目录名是转义过的场景 id。"""
return (path / "preset.template.json").is_file()
def verify_scene_dir(scene_dir: Path) -> tuple[list[str], dict[str, Any]]:
"""检查一个**场景目录**(= 一份可直接搬走的壁纸目录);返回(问题列表, 统计)。"""
problems: list[str] = []
stats: dict[str, Any] = {"spines": 0, "images": 0, "versions": {}, "bytes": 0}
for name in ("meta.json", "preset.template.json", "scene.json"):
if not (scene_dir / name).is_file():
problems.append(f"缺少 {name}")
try:
scene = _read_json(scene_dir / "scene.json")
except (OSError, json.JSONDecodeError) as exc:
return [*problems, f"scene.json 读不出来:{exc}"], stats
parts = scene.get("parts") or []
if not parts:
problems.append("scene.json 里一个 part 都没有(场景选择可能失败)")
# meta.json:这些字段是构建期 fail-fast 校验的同一批(tools/lib/vault.ts),这里提前红。
meta_file = scene_dir / "meta.json"
if meta_file.is_file():
try:
meta = _read_json(meta_file)
except json.JSONDecodeError as exc:
problems.append(f"meta.json 不是合法 JSON:{exc}")
meta = None
if isinstance(meta, dict):
if meta.get("id") != scene_dir.name:
problems.append(f"meta.json 的 id {meta.get('id')!r} 与目录名 {scene_dir.name!r} 不一致")
if not _ID_OK.match(str(meta.get("id") or "")):
problems.append(f"meta.json 的 id {meta.get('id')!r} 含非法字符(只能是字母数字与 _-)")
for key in ("name", "title", "description"):
if not meta.get(key):
problems.append(f"meta.json 缺 {key}")
audio = meta.get("audio")
if not isinstance(audio, dict) or not isinstance(audio.get("choices"), list):
problems.append("meta.json 的 audio.choices 不是列表")
else:
for index, choice in enumerate(audio["choices"]):
rel = (choice or {}).get("file") if isinstance(choice, dict) else None
if not rel:
problems.append(f"meta.json 的 audio.choices[{index}] 缺 file")
elif not (scene_dir / str(rel).replace("\\", "/").removeprefix("./")).is_file():
problems.append(f"meta.json 声明了音源 {rel},但磁盘上没有这个文件")
preset_file = scene_dir / "preset.template.json"
if preset_file.is_file():
try:
preset = _read_json(preset_file)
except json.JSONDecodeError as exc:
problems.append(f"preset.template.json 不是合法 JSON:{exc}")
preset = None
if isinstance(preset, dict):
scene_config = preset.get("sceneConfig")
spine_config = preset.get("spineConfig")
if bool(scene_config) == bool(spine_config):
problems.append("preset.template.json 必须二选一地写 sceneConfig 或 spineConfig")
# 全是运行时纹理的场景(各页的 scene_ui:drawScene 渲染目标)本来就凑不出 part,
# 那种目录只是"留档不可搬走";但 scene.json 里有该落盘的 part 而预设漏了,是硬伤。
drawable = [
p for p in parts
if p.get("kind") == "spine" or (p.get("kind") == "image" and not p.get("runtime"))
]
if isinstance(scene_config, dict) and not (scene_config.get("parts") or []) and drawable:
problems.append("preset.template.json 的 sceneConfig.parts 是空的,但 scene.json 里有可落盘的 part")
for rel in layout_mod.missing_preset_paths(preset, scene_dir):
problems.append(f"preset.template.json 引用的 {rel} 不在磁盘上")
for part in parts:
kind, pid = part.get("kind"), part.get("id")
if kind == "spine":
stats["spines"] += 1
spine_dir = scene_dir / layout_mod.SPINE_DIR / str(pid)
json_file = spine_dir / f"{pid}.json"
atlas_file = spine_dir / f"{pid}.atlas"
spine_meta = spine_dir / "meta.json"
for path in (json_file, atlas_file, spine_meta):
if not path.exists():
problems.append(f"骨架 {pid} 缺少 {path.name}")
if not atlas_file.exists():
continue
atlas_text = atlas_file.read_text(encoding="utf-8")
info = atlas_mod.parse(atlas_text)
if not info.pages:
problems.append(f"骨架 {pid} 的 atlas 里没有贴图页行")
# 页名**读 atlas 自己声明的**,与落盘文件名逐字比对——按 `<stem>_N` 猜会漏掉多页。
for page_name in info.pages:
if not (spine_dir / page_name).exists():
problems.append(f"骨架 {pid} 缺少贴图页 {page_name}")
if spine_meta.exists():
try:
meta = _read_json(spine_meta)
except json.JSONDecodeError as exc:
problems.append(f"骨架 {pid} 的 meta.json 不是合法 JSON:{exc}")
continue
version = meta.get("spine")
runtime = _runtime_of(version)
if version:
stats["versions"][str(version)] = stats["versions"].get(str(version), 0) + 1
if runtime and runtime > SUPPORTED_RUNTIME:
problems.append(
f"骨架 {pid} 是 {version} 格式,超出 vendored 播放器(4.2 线)能读的范围"
)
elif kind == "image":
if part.get("runtime"):
continue # 由骨架渲染进贴图缓冲的平面没有独立文件,不是缺失
stats["images"] += 1
if layout_mod.image_file(scene_dir, str(pid)) is None:
problems.append(f"几何平面的贴图缺失:scene/{pid}.*")
for path in scene_dir.rglob("*"):
if path.is_file():
stats["bytes"] += path.stat().st_size
return problems, stats
def verify_page(page_dir: Path) -> tuple[list[str], dict[str, Any]]:
"""检查一个页面目录(它下面每个场景目录各是一份壁纸目录)。"""
problems: list[str] = []
stats: dict[str, Any] = {"sceneDirs": 0, "spines": 0, "images": 0, "versions": {}, "bytes": 0}
page_file = page_dir / "page.json"
if not page_file.exists():
return [f"缺少 page.json:{page_dir}"], stats
try:
_read_json(page_file)
except json.JSONDecodeError as exc:
return [f"page.json 不是合法 JSON:{exc}"], stats
# 旧形状(一页一个场景)的残留:页面根的 scene.json / spine/ / scene/ 现在都不该存在。
for legacy in ("scene.json", "spine", "scene"):
if (page_dir / legacy).exists():
problems.append(f"页面根上还留着旧形状的 {legacy}(新形状里它属于每个场景目录)")
scenes = sorted(p for p in page_dir.iterdir() if p.is_dir() and _is_scene_dir(p))
strays = sorted(
p.name for p in page_dir.iterdir() if p.is_dir() and not _is_scene_dir(p) and p.name not in ("spine", "scene")
)
if not scenes:
problems.append("页面下没有任何场景目录(先跑 fetch)")
for name in strays:
problems.append(f"页面下的 {name}/ 不是场景目录(缺 preset.template.json)")
for scene_dir in scenes:
scene_problems, scene_stats = verify_scene_dir(scene_dir)
problems.extend(f"[{scene_dir.name}] {p}" for p in scene_problems)
stats["sceneDirs"] += 1
stats["spines"] += scene_stats["spines"]
stats["images"] += scene_stats["images"]
stats["bytes"] += scene_stats["bytes"]
for version, count in scene_stats["versions"].items():
stats["versions"][version] = stats["versions"].get(version, 0) + count
return problems, stats
def verify_all(root: Path, pages: list[str] | None = None) -> tuple[list[str], dict[str, Any]]:
"""检查 `_out/` 下的全部(或指定)页面。"""
problems: list[str] = []
total: dict[str, Any] = {"pages": 0, "sceneDirs": 0, "spines": 0, "images": 0, "bytes": 0, "versions": {}}
if not root.exists():
return [f"产物目录不存在:{root}"], total
candidates = []
for game_dir in sorted(p for p in root.iterdir() if p.is_dir()):
for page_dir in sorted(p for p in game_dir.iterdir() if p.is_dir()):
if pages and page_dir.name not in pages:
continue
candidates.append(page_dir)
if not candidates:
problems.append(f"没有任何页面产物:{root}")
for page_dir in candidates:
page_problems, stats = verify_page(page_dir)
problems.extend(f"[{page_dir.parent.name}/{page_dir.name}] {p}" for p in page_problems)
total["pages"] += 1
total["sceneDirs"] += stats["sceneDirs"]
total["spines"] += stats["spines"]
total["images"] += stats["images"]
total["bytes"] += stats["bytes"]
for version, count in stats["versions"].items():
total["versions"][version] = total["versions"].get(version, 0) + count
return problems, total
-54
View File
@@ -1,54 +0,0 @@
// 量出一个预设在某分辨率下的取景几何,用来决定"该给什么视口"。
// 输出:作者声明的取景矩形、播放器扫描出的骨架边界、当前相机实际可见的世界矩形。
// 用法:node tools/frame.mjs <port> <presetId> <W> <H>
import { withPage, sleep } from "./cdp.mjs";
const [port, preset, w, h] = process.argv.slice(2);
if (!port || !preset || !w || !h) {
console.error("usage: node tools/frame.mjs <port> <presetId> <W> <H>");
process.exit(2);
}
const EXPR = `(function () {
var p = window.__player;
if (!p || !p.skeleton) return { error: "player/skeleton 未就绪" };
var cv = p.currentViewport;
var cam = p.sceneRenderer.camera;
var cw = p.canvas.width, ch = p.canvas.height;
// 用播放器自己的投影取真实可见世界矩形,不靠对 zoom 语义的猜测
var tl = cam.screenToWorld(new spine.Vector3(0, 0, 0), cw, ch);
var br = cam.screenToWorld(new spine.Vector3(cw, ch, 0), cw, ch);
var offset = new spine.Vector2(), size = new spine.Vector2();
p.skeleton.getBounds(offset, size, new Array(2));
var vis = { x: tl.x, y: br.y, w: br.x - tl.x, h: tl.y - br.y };
return {
canvas: { w: cw, h: ch, aspect: +(cw / ch).toFixed(4) },
base: { x: +cv.x.toFixed(2), y: +cv.y.toFixed(2), w: +cv.width.toFixed(2), h: +cv.height.toFixed(2) },
pads: { l: +cv.padLeft.toFixed(2), r: +cv.padRight.toFixed(2), b: +cv.padBottom.toFixed(2), t: +cv.padTop.toFixed(2) },
resolved: { x: +(cv.x - cv.padLeft).toFixed(2), y: +(cv.y - cv.padBottom).toFixed(2), w: +(cv.width + cv.padLeft + cv.padRight).toFixed(2), h: +(cv.height + cv.padBottom + cv.padTop).toFixed(2) },
visible: { x: +vis.x.toFixed(2), y: +vis.y.toFixed(2), w: +vis.w.toFixed(2), h: +vis.h.toFixed(2), aspect: +(vis.w / vis.h).toFixed(4) },
skeletonBounds: { x: +offset.x.toFixed(2), y: +offset.y.toFixed(2), w: +size.x.toFixed(2), h: +size.y.toFixed(2) },
skeletonInView: {
coversView: (offset.x <= vis.x && offset.y <= vis.y && offset.x + size.x >= vis.x + vis.w && offset.y + size.y >= vis.y + vis.h),
overflowLeft: +(vis.x - offset.x).toFixed(2),
overflowRight: +((offset.x + size.x) - (vis.x + vis.w)).toFixed(2),
overflowTop: +((offset.y + size.y) - (vis.y + vis.h)).toFixed(2),
overflowBottom: +(vis.y - offset.y).toFixed(2),
},
};
})()`;
const result = await withPage({ width: Number(w), height: Number(h), profile: "tools/.cache/edge-frame" }, async ({ send, evaluate }) => {
const url = `http://127.0.0.1:${port}/index.html?__props=${encodeURIComponent(JSON.stringify({ preset }))}&__freeze=1`;
await send("Page.navigate", { url });
for (let i = 0; i < 200; i++) {
if (await evaluate("!!(window.__player && window.__player.skeleton)")) break;
await sleep(150);
}
await sleep(400);
return evaluate(EXPR);
});
console.log(`${preset} @ ${w}x${h}`);
console.log(JSON.stringify(result, null, 2));
+820
View File
@@ -0,0 +1,820 @@
// `pnpm build --sim`:把一档分发再打成一个**双击就能看的自包含包**。
//
// ## 为什么必须单独做一次打包
//
// 发布产物是"给 Wallpaper Engine 的 CEF 用"的:那里是 http 语义,支持 ES module。而"双击打开"
// 走 `file://`,浏览器对它另有一套限制。实测(无头 Edge,与用户双击的行为一致):
//
// file:// 下 结果
// ES module(import / 动态 import) ✗ 被 CORS 拦(origin: null)
// fetch() / XMLHttpRequest() ✗ 同上
// 内联 <script> ✓
// 同目录相对路径的 <img> / <audio> ✓
// 跨目录的 <img> / <audio> ✗ "Not allowed to load local resource"
// data: URL ✓ 到处可用
//
// 于是自包含包要满足三件事:① ES module 合成一个经典脚本;② 资源走 data: URL;③ 不发起任何
// XHR/fetch。第 ② 条顺带解决"跨目录",因为 data: URL 没有目录。
//
// ## 骨架与音频怎么内联
//
// 不去重写 preset 的内容,而是**保留** `asset()` 生成的绝对 URL,再把同一批 URL 映射成 data: URL:
//
// - 骨架 JSON / atlas:spine-player 自带的 `config.rawDataURIs` 就是为这件事设计的通道,
// 它的 downloadText / downloadBinary 会优先查这张表,命中就完全不发请求。
// - 贴图页(atlas 里的 `page.image`):spine-player 用 `pathPrefix + page.name`(pathPrefix 为空,
// 也就是裸文件名)查同一张表,然后交给 <img>,而 <img> 接受 data: URL。
// - 背景图:CSS `url("data:…")`,直接可用。
// - 音频:`<audio src="data:…">`。外链音频要么跨目录被拒、要么得放成一堆散文件而破坏"自包含",
// 所以默认内联;--no-embed-audio 可关掉(包小很多,但音频在 file:// 下不可播,面板会标注)。
//
// ## 产物
//
// `<分发根>/sim/index.html` 一个文件(默认全内联)。它只在 `--sim` 时生成,与发布产物互不干扰。
import { readFile } from "node:fs/promises";
import { mkdirSync, writeFileSync } from "node:fs";
import child_process from "node:child_process";
import { dirname, join } from "node:path";
import { abs, isFile } from "./fs.ts";
import { assetRelIn, audioPathIn, baseName, planAudio, releasePathOf } from "./generate.ts";
import { SIMULATOR_DRIVER } from "./drivers.ts";
import type { Release } from "./types.ts";
/** 运行时模块的依赖序(叶子在前)。手写而非拓扑排序:这份清单是封闭的,写死更好读也更好排错。 */
const RUNTIME_MODULES = [
"viewport-fitter.js",
"preset-controller.js",
"background-controller.js",
"audio-controller.js",
"spine-controller.js",
] as const;
const MIME: Record<string, string> = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".gif": "image/gif",
".svg": "image/svg+xml",
".atlas": "text/plain",
".json": "application/json",
".flac": "audio/flac",
".mp3": "audio/mpeg",
".wav": "audio/wav",
".ogg": "audio/ogg",
};
export interface BundleOptions {
/** 内联音频(data: URL)。关掉时音频在 file:// 下不可播,但包体积小得多。 */
embedAudio: boolean;
}
interface ModuleDef {
id: string;
source: string;
}
/**
* 把一份 tsc/生成器产出的 ES module 源码改成"往 window.__weModules 注册"的经典脚本。
*
* import 会被**真的改写**成注册表查表,而不是靠一份手写清单去推断加载顺序:
*
* import Presets, { defaultPresetId } from "./presets.js";
* → const __m_scripts_presets_js = __require("scripts/presets.js");
* const Presets = __m_scripts_presets_js.default;
* const defaultPresetId = __m_scripts_presets_js.defaultPresetId;
*
* 好处是依赖变成**可校验**的:任何没登记进 __weModules 的 id 都会在求值时立刻抛
* `找不到模块`,而不是静默 undefined 到浏览器里才炸。加载顺序 = 注册顺序,与 ES module
* 的求值顺序一致(依赖先注册)。
*
* `moduleDir` 是这份源码"自认为"所在的目录(相对分发根),用来把相对 specifier 解析成
* 绝对模块 id;`known` 是本次打包会注册的 id 清单,解析不出来就直接失败。
*/
function transformModule(id: string, source: string, moduleDir: string, known: Set<string>, requireKnown = true): ModuleDef {
if (/(^|\n)export\s*\{/.test(source)) {
throw new Error(`bundle: ${id} 里出现了不支持的 "export {" 写法,请扩展 transformModule`);
}
const resolveId = (specifier: string): string => {
const target = resolveSpecifier(moduleDir, specifier);
// requireKnown=false 只给测试用:单测要能对着一份"带 import 的源码"验证 export 改写,
// 而那份源码当然不在真实打包清单里。生产路径永远为 true。
if (requireKnown && !known.has(target)) {
throw new Error(
`bundle: ${id} 里 import 了 ${specifier}(解析为 ${target}),但它不在本次打包的模块清单里。` +
`新增运行时模块时要把它加进 buildSimPage 的注册列表。`,
);
}
return target;
};
let out = source.replace(
/(^|\n)\s*import\s+([\s\S]*?)\s+from\s+(['"])([^'"]+)\3\s*;?/g,
(_match: string, lead: string, clause: string, _quote: string, specifier: string) => {
const target = resolveId(specifier);
const varName = `__m_${target.replace(/[^\w]/g, "_")}`;
const lines = [`${lead}const ${varName} = __require(${JSON.stringify(target)});`];
const braces = /\{([^}]*)\}/.exec(clause);
const bare = clause.replace(/\{[^}]*\}/, "").replace(/,/g, " ").trim();
if (bare) lines.push(`const ${bare} = ${varName}.default;`);
if (braces?.[1]) {
for (const name of braces[1].split(",").map((n) => n.trim()).filter(Boolean)) {
lines.push(`const ${name} = ${varName}[${JSON.stringify(name)}];`);
}
}
return lines.join("\n");
},
);
// `import.meta.url` 在经典脚本里是**语法错误**("Cannot use 'import.meta' outside a module"),
// 不是运行时 undefined——所以不能靠 `if (import.meta)` 之类的守卫,必须整段换掉。
//
// 发布产物里 preset.js 用 `new URL("./", import.meta.url)` 让分发目录能整体搬家。
// 自包含包不能照抄这个语义:`import.meta.url` 是**文件** URL,`new URL("./", 文件URL)`
// 会把文件名那一段摘掉(→ 发布根),而自包含页在 `<发布根>/sim/index.html`,
// 它的所有资源都在 `<发布根>/sim/` 下。照抄的结果是预设里写死成
// `file:///<发布根>/spines/kv37/kv37.atlas`——**指向发布根而不是 sim**,
// 而 spine 把「atlasUrl 的父目录」当贴图页基准,于是每一个贴图页都去读一个不存在的路径。
// 症状极难定位:内联表里明明有这张图的 data: URL,图片却还是发 file:// 请求被 CORS 拦。
//
// 所以注入 __simAssetRoot(页内写死为 "./sim/"),把"资源根"与"页面 URL"解耦。
// 见下方 emit 处对 __importMetaUrl 的定义与二次替换。
const usesImportMeta = out.includes("import.meta");
out = out.replace(/\bimport\.meta\.url\b/g, "__importMetaUrl");
// ── export → exports.* 改写 ────────────────────────────────────────────────
//
// 这里**故意不用**"一条正则吃下所有形态"的写法。嵌套可选组会让捕获位置随分支漂移:
// `export function f` 里的 "function" 会落到"名字"位上,而 `export default class C` 又会落到
// 另一处,结果 `exports.function resolveViewport(...)` 这种产物就出来了——语法错、
// 但错得"很像对的",排查起来非常费时。改成两趟按行首扫描,逻辑读得出来、也测得出来。
//
// 前提:tsc 与生成器产出的 export 一律顶格(行首无缩进),这个前提有断言兜底。
//
// **核心约束:本地绑定必须原样活着。** 早先的写法是把 `export const X = 1` 直接改成
// `exports.X = 1`,看着等价,其实把模块内的 `X` 一起删了——同模块别处引用 `X` 就变成
// ReferenceError(真事故:`frameForAspect(..., referenceAspect = REFERENCE_ASPECT)`
// 的默认参数在运行到那一帧时才求值,于是"加载全对、渲染第一帧炸")。
// `export function f` 同理:`exports.f = function f(){}` 里的名字只在该函数体内可见,
// 外层作用域并没有 `f`。
//
// 所以一律**保留声明原文**,把导出推迟到模块体末尾统一赋值。声明都执行完了再赋值,
// const/let 也不会踩 TDZ。
const exportAssignments: string[] = [];
// 声明式:`export [default] [async] function|class 名字`
out = out.replace(
/^export (default )?(async function|function|class) (\w+)/gm,
(_match: string, isDefault: string | undefined, keyword: string, name: string) => {
exportAssignments.push(`exports.${isDefault !== undefined ? "default" : name} = ${name};`);
return `${keyword} ${name}`;
},
);
// 变量声明:`export const|let|var 名字`。` = 1` 那一段原样留着。
// 这里**不用** `(const|let|var)?` 这种可选组:失败时正则引擎会回溯,把已匹配的部分一起丢掉。
out = out.replace(/^export (const|let|var) (\w+)/gm, (_match: string, keyword: string, name: string) => {
exportAssignments.push(`exports.${name} = ${name};`);
return `${keyword} ${name}`;
});
// `export default <表达式>`:只把前缀换成赋值,右边一个字符都不碰。
// 这样 `export default {`(生成的 preset.js)、`export default Presets;` 用同一条规则就够。
// 声明式那两条已经先跑过,这里剩下的只可能是表达式。
out = out.replace(/^export default /gm, `exports.default = `);
const left = /^export\b/m.exec(out);
if (left) throw new Error(`bundle: ${id} 里仍残留未识别的 export 写法:${JSON.stringify(left[0])}`);
// 发布产物里 preset.js 用 `new URL("./", import.meta.url)` 让分发目录能整体搬家。
// 自包含包不能照抄这个语义,而且**有两个不同的"根",必须分开**:
//
// 页面根 —— 自包含页永远在 `<分发根>/sim/index.html`,所以相对页面是 `"../"`。
// 内联表的建键基准与运行时的 resolveAssetUrl 用这个。
// 模块根 —— preset.js 在 `<分发根>/<壁纸id>/preset.js`(合集)或 `<分发根>/preset.js`(单档),
// 它的 `import.meta.url` 基准是**自己所在目录**。相对页面是 `"../<壁纸id>/"`。
//
// 早先把两者都当成"页面根 × 分发深度",单档看不出差别(深度 0),合集就错了:
// preset 把 `./audios/kv37/x.mp3` 解析到 `<releases>/audios/...`(少一层),
// 于是音频退回 file:// 读、报 ERR_FILE_NOT_FOUND。而资源表本身是对的——
// 所以现象是"表和模块都对,只有音频不对",非常容易误判成音频内联逻辑的问题。
const moduleDirPart = moduleDir === "" ? "" : `${moduleDir}/`;
const rebased = out.replaceAll(
`new URL("./", __importMetaUrl)`,
`new URL(${JSON.stringify(`../${moduleDirPart}`)}, document.baseURI)`,
);
// 用了 import.meta.url 但没匹配上"资源根"这条固定写法时保持 document.baseURI——本页共址的
// 资源那样也解析得对。真正的兜底不在这里:`transformModuleForTest` 里有一条断言,
// 要求任何 `new URL("./", import.meta.url)` 都必须被这次替换吃掉,否则测试直接红。
return {
id,
source:
`__weModules[${JSON.stringify(id)}] = (function () {\n` +
` const exports = {};\n` +
(usesImportMeta ? ` const __importMetaUrl = document.baseURI;\n` : "") +
`${indent(rebased)}\n` +
exportAssignments.map((line) => ` ${line}`).join("\n") +
(exportAssignments.length > 0 ? "\n" : "") +
` return exports;\n})();\n`,
};
}
/**
* 仅供测试:把一份 ES module 源码改写成经典脚本的**模块体**(不含包装)。
*
* 导出它是为了让"export 改写"能有一份跑真实代码的单元测试。这条逻辑踩过五次坑,每次的症状
* 都是"语法错得很像对的",靠改一处跑一遍全量构建根本收敛不了。测试里必须调**这一个**函数,
* 不能在测试里复刻一份——复刻的那份永远是对的,真实的那个永远在坏。
*/
export function transformModuleForTest(source: string): string {
return transformModule("__test__", source, "", new Set(), false).source;
}
/** 把 `moduleDir` 下的相对 specifier 解析成分发根的相对模块 id。 */function resolveSpecifier(moduleDir: string, specifier: string): string {
const joined = moduleDir === "" ? specifier : `${moduleDir}/${specifier}`;
const parts: string[] = [];
for (const segment of joined.split("/")) {
if (segment === "" || segment === ".") continue;
if (segment === "..") parts.pop();
else parts.push(segment);
}
return parts.join("/");
}
function indent(text: string): string {
return text
.split("\n")
.map((line) => (line.length > 0 ? " " + line : line))
.join("\n");
}
async function toDataUrl(fileAbs: string): Promise<string> {
const bytes = await readFile(fileAbs);
const ext = fileAbs.slice(fileAbs.lastIndexOf(".")).toLowerCase();
const mime = MIME[ext];
if (!mime) throw new Error(`bundle: 不知道 ${ext} 该用什么 MIME(文件 ${fileAbs})`);
// 刻意不换行:spine-player 用 atob() 解码,atob 不接受 base64 里夹换行。
return `data:${mime};base64,${bytes.toString("base64")}`;
}
export interface SimAssets {
/** 内联资源:规范化后的 key → data: URL。 */
byUrl: Map<string, string>;
/** 按裸文件名登记的 key(spine-player 查贴图页就是这么查的)。 */
byName: Map<string, string>;
/** 内联资源原始字节数。 */
bytes: number;
/** --no-embed-audio 时被跳过的音频。 */
skippedAudio: string[];
/** 登记的资源条数(去重后)。 */
count: number;
}
/**
* 模块 id = **文件自身的路径**(相对分发根,统一不带开头的 `./`),不是 import 里写的 specifier。
*
* 这一点必须搞清楚:`presets.js` 里写的是 `import preset0 from "../hsr/xilian/preset.js"`,
* 而 `index.js` 里写的是 `import Presets from "./presets.js"`。按 specifier 当 id 会得到
* "`../hsr/xilian/preset.js`" 与 "`./presets.js`" 这种互相撞不到一起的键;按文件路径算则
* 两者都归一成 `scripts/...` 或 `hsr/xilian/preset.js`,依赖图才是对的。
*/
function urlKey(path: string): string {
return path.replace(/^(\.\.?\/)+/, "");
}
/**
* 把"相对 preset.js"的路径换算成"相对分发根"的路径。
*
* 音频这条**不需要**再拼壁纸目录:`audioPathIn` 已经写成 `../audios/<壁纸id>/x.mp3`
* 的形式,剥掉开头的 `../` 正好就是分发根相对路径。与资源那条不同——
* 资源的 `assetUrlIn` 是 `./spines/x/x.webp`(相对壁纸目录),所以要多补一段 `<壁纸id>/`。
* 两者基准不同,别合并。
*/
function distRelOf(presetRelative: string): string {
return urlKey(presetRelative);
}
function releaseOutRel(release: Release): string {
return `dist/releases/${release.dir}`;
}
/**
* 收集并内联资源。
*
* 表的键一律是**分发根相对路径**(合集里带 `<壁纸id>/` 这一段)。理由:自包含页在
* `<分发根>/sim/` 下,页面把 `new URL(键, "../")` 解析成绝对 URL,运行时拿到的绝对 URL
* 正好就是这个键解析后的结果。用 preset 相对路径(`../spines/x/x.webp`)建键会丢掉合集里的
* `<壁纸id>/`,症状是"表里有 152 个键、骨架却查不中"——单档分发没有这一段,所以看不出来。
*/
async function collectAssets(release: Release, options: BundleOptions): Promise<SimAssets> {
const byUrl = new Map<string, string>();
const byName = new Map<string, string>();
const skippedAudio: string[] = [];
let bytes = 0;
// 音频落点来自 generate.ts 的 planAudio —— **同一份算法**,两边各写一份必然漂移
const audioPlan = await planAudio(release);
if (audioPlan.collisions.length > 0) {
throw new Error(`bundle: ${release.dir} 的共享音频重名:${audioPlan.collisions.join(";")}`);
}
/**
* 登记一份内联资源。
*
* `bytes` 用来避免同一份文件被多次计入体积统计(贴图页会被登记成"带目录 + 裸名"两个键)。
*/
const seen = new Set<string>();
const add = async (fileAbs: string, url: string, options2: { bytes?: boolean } = {}): Promise<void> => {
const data = await toDataUrl(fileAbs);
const key = urlKey(url);
byUrl.set(key, data);
const name = baseName(key);
if (!byName.has(name)) byName.set(name, data);
if (options2.bytes === false || seen.has(fileAbs)) return;
seen.add(fileAbs);
bytes += (await readFile(fileAbs)).length;
};
for (const wallpaper of release.wallpapers) {
/**
* 资源从**源目录**读,不从 dist 读——`copyWallpaperAssets` 只搬 `spines/`、`scene/`、
* `audios/` 三个目录,而预设里的路径是 `./scene/photo.webp`、`./spines/<名>/<名>.atlas`
* 这种**相对壁纸目录**的形式,要按同一条规则剥掉开头的 `./` 再 join,直接拼会拼错。
* 解析方式与 vault.ts 的校验完全一致。
*/
const dirAbs = abs(wallpaper.srcRel);
const resolveIn = (baseDir: string, relPath: string): string => join(baseDir, relPath.replace(/^\.\//, ""));
const bgRel = wallpaper.preset.backgroundImage;
await add(resolveIn(dirAbs, bgRel), assetRelIn(release, wallpaper, bgRel));
/** 登记一具骨架:json + atlas + atlas 里引用的贴图页。 */
const addSpine = async (jsonRel: string, atlasRel: string): Promise<void> => {
await add(resolveIn(dirAbs, jsonRel), assetRelIn(release, wallpaper, jsonRel));
const atlasAbs = resolveIn(dirAbs, atlasRel);
const atlasRelInRelease = assetRelIn(release, wallpaper, atlasRel);
await add(atlasAbs, atlasRelInRelease);
// atlas 里引用的贴图页:相对 **atlas 自己所在目录**解析,spine-player 也是这么找的。
const atlasDir = dirname(atlasAbs);
const atlasText = await readFile(atlasAbs, "utf8");
// spine-player 的做法是 `atlasUrl 的父目录 + page.name`,所以这里用同一份算法先算出
// atlas 在分发里的目录前缀,再拼页名——与运行时逐字一致,不自己拼目录名。
const atlasPrefix = atlasRelInRelease.slice(0, atlasRelInRelease.lastIndexOf("/") + 1);
for (const line of atlasText.split("\n").map((l) => l.trim())) {
if (!/\.(png|jpe?g|webp)$/i.test(line)) continue;
const pageName = baseName(line);
const pageAbs = join(atlasDir, pageName);
if (!(await isFile(pageAbs))) continue;
await add(pageAbs, atlasPrefix + line.replace(/^\.\//, ""));
// 裸名也登记一份:atlas 里写的就是裸名时,spine-player 在某些路径下会直接拿它查表。
await add(pageAbs, pageName, { bytes: false });
}
};
const singleSpine = wallpaper.preset.spineConfig;
if (singleSpine) await addSpine(singleSpine.jsonUrl, singleSpine.atlasUrl);
// 场景预设:每个 part 的资源都要登记(骨架同单骨架那条路,贴图平面就是一张图)。
const scene = wallpaper.preset.sceneConfig;
if (scene) {
for (const part of scene.parts) {
if (part.kind === "spine" && part.jsonUrl && part.atlasUrl) await addSpine(part.jsonUrl, part.atlasUrl);
else if (part.kind === "image" && part.image) {
await add(resolveIn(dirAbs, part.image), assetRelIn(release, wallpaper, part.image));
}
}
}
// 音频落点来自 generate.ts 的 planAudio —— **同一份算法**,两边各写一份必然漂移。
// audioPathIn 给的是"相对 preset.js"的路径,这里换成"相对分发根"的键。
for (const item of audioPlan.placements) {
if (item.key === "" && release.type !== "single") continue; // 共享音频在下面单独处理
if (!item.from.startsWith(join(abs(wallpaper.srcRel), "audios"))) continue;
if (!options.embedAudio) {
skippedAudio.push(item.destRel);
continue;
}
if (!(await isFile(item.from))) throw new Error(`bundle: ${release.dir} 里找不到音频源 ${item.from}`);
await add(item.from, distRelOf(audioPathIn(release, wallpaper, item.name)));
}
}
// 分发级预览图(project.json 的 preview 指向分发根下的文件名)。
//
// 它**不被 preset.js 引用**,所以不在上面那圈里;但面板要在设置上方显示它。
// 而且自包含页在 `<分发根>/sim/` 下,`../preview.gif` 在 file:// 下是跨目录,
// 读不到——必须内联成 data: URL,和别的资源一个待遇。
if (release.meta.preview) {
const name = release.meta.preview.replace(/\\/g, "/").replace(/^.*\//, "");
const previewAbs = join(abs(release.metaRel), name);
if (await isFile(previewAbs)) await add(previewAbs, name);
}
// 游戏级共享音频:落在合集根 audios/<文件>
const context = release.wallpapers[0];
if (context === undefined) throw new Error(`bundle: ${release.dir} 没有任何壁纸`);
for (const item of audioPlan.placements) {
if (item.key !== "" || release.type === "single") continue;
if (!options.embedAudio) {
skippedAudio.push(item.destRel);
continue;
}
if (!(await isFile(item.from))) throw new Error(`bundle: ${release.dir} 里找不到共享音频 ${item.from}`);
await add(item.from, distRelOf(audioPathIn(release, context, item.name, "shared")));
}
return { byUrl, byName, bytes, skippedAudio, count: byUrl.size };
}
export interface SimBuildResult {
html: string;
assetBytes: number;
skippedAudio: string[];
/** rawDataURIs 的条目数(供构建日志显示)。 */
inlined: number;
}
/**
* 内联资源表。
*
* 两组数据,分工必须分清(这里错了三次,每次症状都不一样):
*
* `rawDataUris`(骨架 JSON / atlas):spine-player 是**查值**,键是它自己解析后的 URL。
* 所以我们要按"以本页为基准解析后的绝对 URL"登记。
* `loadTexture`(贴图页 / 背景图):spine-player 是**查键再当 data: 用**——
* 它先 `rawDataUris[path] || path` 拿到字符串,再赋给 `image.src`,且回调是以**入参**收尾的。
* 所以这里必须把路径真的换成 data: URL,否则就变成"图片从 file:// 读(被 CORS 拦),
* 而回调登记在另一个键上"(第一版就是这么错的)。
*
* 返回值里键表与值数组分开:一张 2 MB 的图会被登记成十几个键(相对/绝对/裸名),
* 值必须去重,否则页面会凭空胖 50%(52 MB → 78 MB,实测)。
*/
function simAssetTable(assets: SimAssets): { keyToIndex: Record<string, number>; values: string[] } {
const keyToIndex: Record<string, number> = {};
const values: string[] = [];
const indexOf = new Map<string, number>();
const add = (key: string, data: string): void => {
if (keyToIndex[key] !== undefined) return;
let index = indexOf.get(data);
if (index === undefined) {
index = values.length;
values.push(data);
indexOf.set(data, index);
}
keyToIndex[key] = index;
};
for (const [key, data] of assets.byUrl) {
add(key, data);
add(`./${key}`, data);
add(`/${key}`, data);
add(baseName(key), data);
}
for (const [name, data] of assets.byName) {
add(name, data);
add(`./${name}`, data);
}
return { keyToIndex, values };
}
/**
* 拼出来的 JS 必须能被 V8 解析——**构建期就检查**,不要等浏览器。
*
* 这里拼的是生成器产出的裸 JS 文本(正则改写 + 字符串模板),tsc 完全不看它们。踩过多次真事故:
* 正则把类方法里的 "export " 前缀改坏、`exports.default` 少了等号、`function` 关键字被吞。
* 全都是"其他检查全绿、只有浏览器白屏",所以这一步是必须的。失败时把出问题的那几行连同
* 完整产物写到 .scratch/sim-failed/ 下——只有行号和"Unexpected token"根本不够定位。
*/
function assertParses(label: string, code: string): void {
const { spawnSync } = child_process;
const child = spawnSync(
process.execPath,
[
"--experimental-vm-modules",
"--no-warnings",
"-e",
`new (require("node:vm").SourceTextModule)(require("node:fs").readFileSync(0, "utf8"));`,
],
{ input: code, encoding: "utf8" },
);
if (child.status === 0) return;
// 产物落盘,方便直接看坏在哪儿
const outDir = ".scratch/sim-failed";
mkdirSync(outDir, { recursive: true });
const file = join(outDir, `${label}.js`);
writeFileSync(file, code, "utf8");
const lines = code.split("\n");
const lineNo = Number(/vm:module\(\d+\):(\d+)/.exec(child.stderr ?? "")?.[1] ?? 0);
const context =
lineNo > 0
? lines
.slice(Math.max(0, lineNo - 4), lineNo + 2)
.map((l, i) => ` ${Math.max(0, lineNo - 3) + i}: ${l}`)
.join("\n")
: "";
const reason = (child.stderr ?? "").split("\n").find((l) => /SyntaxError/.test(l)) ?? "(V8 未给出原因)";
throw new Error(`bundle: ${label} 拼接出来的 JS 无法解析:${reason}\n产物已写入 ${file}\n${context}`);
}
export async function buildSimPage(release: Release, defaultPresetId: string, options: BundleOptions): Promise<SimBuildResult> {
const assets = await collectAssets(release, options);
// 先把所有会被注册的模块 id 列出来,再逐个改写。改写时才能校验 import 都指得到东西。
//
// id 是**文件相对分发根的路径**,与 generatePresetIndex 生成的 import 逐字对应:
// 单档分发 preset.js (presets.js 在 scripts/ 下,写 `../preset.js` → 归一成 preset.js)
// 合集分发 <壁纸id>/preset.js (presets.js 写 `../<壁纸id>/preset.js` → 归一成 <壁纸id>/preset.js)
// 注意合集里**没有** scripts/ 这一段:preset.js 与 scripts/ 平级。写成 `scripts/<id>/preset.js`
// 会让 presets.js 的 import 校验直接失败("不在本次打包的模块清单里")。
const presetIds = release.wallpapers.map((w) => {
const sub = releasePathOf(release, w);
return sub === "" ? "preset.js" : `${sub}/preset.js`;
});
const known = new Set<string>([
...RUNTIME_MODULES.map((name) => `scripts/${name}`),
...presetIds,
"scripts/presets.js",
"scripts/index.js",
"sim",
]);
const modules: ModuleDef[] = [];
for (const name of RUNTIME_MODULES) {
modules.push(transformModule(`scripts/${name}`, await readFile(abs(`build/scripts/${name}`), "utf8"), "scripts", known));
}
for (const [index, wallpaper] of release.wallpapers.entries()) {
const sub = releasePathOf(release, wallpaper);
// 模块体所在目录 = id 的目录部分。合集里是 `<壁纸id>`(与 scripts/ 平级),单档里是分发根("")。
const moduleDir = sub;
modules.push(
transformModule(
presetIds[index] ?? "",
await readFile(abs(join(releaseOutRel(release), sub, "preset.js")), "utf8"),
moduleDir,
known,
),
);
}
modules.push(
transformModule(
"scripts/presets.js",
await readFile(abs(join(releaseOutRel(release), "scripts", "presets.js")), "utf8"),
"scripts",
known,
),
);
modules.push(
transformModule("scripts/index.js", await readFile(abs("build/scripts/index.js"), "utf8"), "scripts", known),
);
modules.push(transformModule("sim", await readFile(abs("build/scripts/wallpaper-engine.js"), "utf8"), "", known));
const simModule = modules[modules.length - 1] as ModuleDef;
// 模拟器单独成段注入:它要排在驱动脚本**之前**、壁纸运行时**之前**(见下面的 <script> 顺序注释)。
// 它的模块包装体是个 IIFE,函数不会自动变成全局,所以要在这里把它挂到 window 上——
// 真实 WE 里并不存在这个全局(模拟器也不该凭空提供别的),它只是**本页内**的接线。
simModule.source +=
`\nif (window.mountWallpaperEngineSimulator === undefined) {\n` +
` window.mountWallpaperEngineSimulator = __weModules["sim"].mountWallpaperEngineSimulator;\n` +
`}\n`;
modules.pop();
const project = JSON.parse(await readFile(abs(join(releaseOutRel(release), "project.json")), "utf8")) as {
general?: { properties?: Record<string, unknown> };
title?: string;
version?: number;
};
const css = await Promise.all(
["spine-player.css", "index.css"].map(async (name) => `/* ${name} */\n${await readFile(abs(`src/styles/${name}`), "utf8")}`),
);
const bootstrap = [
`// ── 模块注册表 ────────────────────────────────────────────────────────────`,
`// 发布产物用的是真正的 ES module;file:// 下浏览器根本不加载它们,所以这里把同一批模块`,
`// 合成经典脚本,用最小注册表补回 import 的语义。`,
`//`,
`// 这段在页面上出现两次(模拟器段之前、运行时段之前):两段脚本都需要注册表,而顺序又必须是`,
`// "注册表 → 模拟器 → 驱动 → 壁纸运行时"。所以做成幂等的,重复执行没有副作用。`,
`//`,
`// 加载顺序 = 注册顺序,与 ES module 的求值顺序一致(依赖先注册)。依赖在这里是**可校验**的:`,
`// 任何没登记过的 id 都会立刻抛错,而不是静默 undefined 到浏览器里才炸。`,
`if (!window.__weModules) window.__weModules = {};`,
`function __require(id) {`,
` var m = window.__weModules[id];`,
` if (!m) throw new Error("[自包含包] 找不到模块 " + id + "(打包清单与源码的 import 不一致)");`,
` return m;`,
`}`,
``,
].join("\n");
const table = simAssetTable(assets);
/**
* 页面资源根:自包含页固定在 `<分发根>/sim/index.html`,资源相对**分发根**寻址,
* 所以永远是 `"../"`——**与合集深度无关**。
*
* 这里踩过一次:写成 `"../".repeat(depth)`,单档(深度 0)与游戏合集(深度 1)碰巧都对,
* 全部合集(深度 2)就变成 `"../../"`,资源根跑到 `dist/releases/` 去了。
* 症状是"表里 152 个键、骨架却查不中"——页面 URL 与分发深度是两件不相干的事。
*
* 模块内的 `import.meta.url` 基准另算(见 transformModule 的 moduleDir 参数)。
*/
const assetRoot = "../";
const script = [
`"use strict";`,
// 必须**先于模块体**定义:模块体里的 `new URL(__simAssetRoot, document.baseURI)` 在导入期就会求值。
`const __simAssetRoot = ${JSON.stringify(assetRoot)};`,
bootstrap,
...modules.map((m) => m.source),
``,
`// ── 内联资源表 & 自包含包装 ───────────────────────────────────────────────`,
`// 这里有两张表,**分工不能混**(混过三次,每次症状都不一样):`,
`// resolve() → 给"把 URL 换成 data: URL"用:贴图页/背景图必须真换,`,
`// spine-player 的 loadTexture 是"查键再当 data: 用"。`,
`// raw() → 给 rawDataURIs 用:骨架 JSON/atlas 是"查值",键是 spine-player`,
`// 解析后的绝对 URL,值就是 data: URL。`,
`// 值数组单独存一份并按下标引用:同一张 2 MB 的图有十几个键别名(相对/绝对/裸名),`,
`// 直接展开会让页面凭空胖 50%(52 MB → 78 MB,实测过)。`,
`(function () {`,
` var VALUES = ${JSON.stringify(table.values)};`,
` var KEYS = ${JSON.stringify(table.keyToIndex)};`,
` // 资源根:与运行时 resolveAssetUrl 用的是**同一个字符串**(见 buildSimPage 的 assetRoot)。`,
` // 两处只要差一层目录,表里就有键而查不中——图片会安静地退回 file:// 再被 CORS 拦。`,
` var assetRoot = __simAssetRoot;`,
` var base = new URL(assetRoot, document.baseURI);`,
` // byRelative 以构建期登记的键为键(形如 ./spines/kv37/kv37.webp),`,
` // byAbsolute 额外补上"以资源根为基准解析后的绝对 URL",因为运行时两种形态都会出现。`,
` var byRelative = {};`,
` var byAbsolute = {};`,
` for (var k in KEYS) {`,
` var data = VALUES[KEYS[k]];`,
` byRelative[k] = data;`,
` byAbsolute[k] = data;`,
` try { byAbsolute[new URL(k, base).href] = data; } catch (e) { /* 畸形相对路径忽略 */ }`,
` }`,
` var strip = function (url) { return String(url).replace(/^(\\.\\.?\\/)+/, ""); };`,
` var raw = function (url) {`,
` if (typeof url !== "string" || url === "") return undefined;`,
` return byAbsolute[url] || byAbsolute["./" + strip(url)] || byAbsolute[strip(url)];`,
` };`,
` var resolve = function (url) {`,
` if (typeof url !== "string" || url === "") return url;`,
` if (url.slice(0, 5) === "data:") return url;`,
` return raw(url) || url;`,
` };`,
` window.__simAssets = byAbsolute;`,
` window.__simSwap = resolve;`,
` window.__simAssetRoot = assetRoot;`,
``,
` // spine-player 用 __export 把 SpinePlayer 定义成**不可配置的 getter**,所以既不能给它赋值、`,
` // 也不能 defineProperty 覆盖(实测 descriptor: configurable=false)。但 window.spine 本身是`,
` // 可写的普通全局,所以换掉整个 spine 对象:代理继承自真的那个,只覆盖 SpinePlayer 一个键。`,
` // 这样 spine-player 内部拿到的还是自己的真实对象,只有页面上 new spine.SpinePlayer(...) 走包装。`,
` var RealSpine = window.spine;`,
` var OriginalSpinePlayer = RealSpine.SpinePlayer;`,
` // 调用方(spine-controller)传进来的 jsonUrl/atlasUrl 是**相对路径**,由 spine-player 自己解析。`,
` // 这里按页面基准算出它们将被解析成什么,再登记到 rawDataURIs 里——键必须与那个结果逐字相同。`,
` var absolute = function (value) {`,
` if (typeof value !== "string" || value === "") return undefined;`,
` try { return new URL(value, base).href; } catch (e) { return undefined; }`,
` };`,
` var WrappedSpinePlayer = function (container, config) {`,
` if (config && typeof config === "object") {`,
` // 整张表先进去,**两种键都要**:`,
` // byRelative —— loadTexture(贴图页/背景图)拿 spine 自己解析出的相对路径当键查;`,
` // byAbsolute —— 骨架 JSON/atlas 是查值,spine 用解析后的绝对 URL 当键查。`,
` // 只并 byRelative 会漏掉贴图页的绝对键;只并 registerRaw 的三个 URL 也会漏——`,
` // 贴图页根本不在那三个 URL 里,它们的绝对键只有 byAbsolute 有。`,
` var merged = {};`,
` for (var j in byRelative) merged[j] = byRelative[j];`,
` for (var j3 in byAbsolute) merged[j3] = byAbsolute[j3];`,
` for (var j2 in config.rawDataURIs || {}) merged[j2] = config.rawDataURIs[j2];`,
` // 骨架 JSON / atlas 是**查值**,键是 spine-player 解析后的绝对 URL,所以要另按绝对 URL 登记。`,
` var registerRaw = function (value) {`,
` var target = absolute(value);`,
` var data = target === undefined ? undefined : raw(value);`,
` if (target !== undefined && data !== undefined) merged[target] = data;`,
` };`,
` registerRaw(config.jsonUrl);`,
` registerRaw(config.atlasUrl);`,
` registerRaw(config.binaryUrl);`,
` config.rawDataURIs = merged;`,
` // 断言:骨架与 atlas 解析后的绝对 URL 必须在表里。`,
` // 少了任意一个,spine 都会安静地退回 XHR,在 file:// 下被 CORS 拦成一片白屏——`,
` // 现场只剩"图片加载失败",看不出是建表基准错了还是查表姿势错了。`,
` for (var checkIdx = 0; checkIdx < 3; checkIdx++) {`,
` var checkValue = [config.jsonUrl, config.atlasUrl, config.binaryUrl][checkIdx];`,
` var checkKey = absolute(checkValue);`,
` if (checkKey !== undefined && merged[checkKey] === undefined) {`,
` throw new Error("[自包含包] rawDataURIs 缺键:" + checkKey);`,
` }`,
` }`,
` // 关键:**不改 config 里的 URL**。改了就会把回调键与 textureUrl 键拆成两个,`,
` // 于是图片去 file:// 读(被 CORS 拦)、回调永远不触发。`,
` if (config.backgroundImage && config.backgroundImage.url) {`,
` config.backgroundImage.url = resolve(config.backgroundImage.url);`,
` }`,
` }`,
` var player = new OriginalSpinePlayer(container, config);`,
` return player;`,
` };`,
` WrappedSpinePlayer.prototype = OriginalSpinePlayer.prototype;`,
` var spineProxy = Object.create(RealSpine);`,
` Object.defineProperty(spineProxy, "SpinePlayer", {`,
` value: WrappedSpinePlayer,`,
` writable: true,`,
` configurable: true,`,
` enumerable: true,`,
` });`,
` window.spine = spineProxy;`,
` if (window.spine.SpinePlayer !== WrappedSpinePlayer) {`,
` throw new Error("[自包含包] 替换 spine.SpinePlayer 失败:spine-player 的全局结构变了");`,
` }`,
`})();`,
].join("\n");
const driver = SIMULATOR_DRIVER({
search: "",
dir: `${release.dir} · 自包含包`,
title: project.title ?? release.dir,
version: String(project.version ?? ""),
defaultPresetId,
// 预览图由 collectAssets 内联成 data: URL,这里只把文件名交给面板(面板自己走 __simSwap)。
preview: release.meta.preview,
// 不写这一条就会被动态 import 到 /release/… 上去——file:// 下必被 CORS 拦。
simulatorMode: "global",
});
// 解析门禁:这两段是拼出来的裸 JS,必须先过 V8 才能写盘。
assertParses("模拟器段", simModule.source);
assertParses("运行时段", script);
/**
* 内联脚本里出现 `</script` 会**提前终止**这个 script 元素,后面的代码就变成页面文本——
* 症状是页面半死不活、控制台报一堆莫名其妙的语法错,而错误位置与真正的原因毫无关系。
*
* 这里选择"构建期报错"而不是"自动转义":转义要区分字符串内/外,改错了会静默改变语义;
* 而当前所有被内联的源码都不含这个串,一旦将来出现,报错比猜更安全。
*
* **只查纯 JS 体**,不查 driver:driver 本身就是一个完整的 `<script>…</script>` 块,
* 它的收尾标签是它自己的,不是需要转义的内容。第一版把 driver 也一起查了,
* 结果干净源码立刻构建失败——守卫自身写错,比没有守卫更糟。
*/
for (const [label, code] of [
["spine-player", await readFile(abs("src/vendor/spine-player.js"), "utf8")],
["模拟器段", simModule.source],
["运行时段", script],
] as const) {
if (/<\/script/i.test(code)) {
throw new Error(`bundle: ${release.dir} 的${label}里含 "</script",内联后会截断页面`);
}
}
const html = `<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>${project.title ?? release.displayName}(自包含调试包)</title>
<link rel="icon" href="data:," />
<style>
${css.map(indent).join("\n")}
</style>
</head>
<body>
<div id="spine-container"></div>
<audio id="background-music"></audio>
<!--
脚本顺序是**语义要求**,不是排版问题:
1. spine-player:壁纸运行时要 new spine.SpinePlayer
2. 属性表 + 模拟器 + 驱动:API 必须在壁纸模块注册 wallpaperPropertyListener **之前**就绪,
否则"属性早于模块到达"这条竞态(?__propsAt=dom)复现不出来
3. 壁纸运行时(模块注册表 + 启动):它必须在最后,注册了 listener 才会收到第 2 步的下发
自包含包里没有模块系统,所以这里顺序即一切。
-->
<script>
${indent(await readFile(abs("src/vendor/spine-player.js"), "utf8"))}
</script>
<script>window.__weProperties = ${JSON.stringify(project.general?.properties ?? {}).replace(/</g, "\\u003c")};</script>
<!-- 模拟器(经典脚本版,内联):只提供 WE 的官方 API,绝不自己注册 listener -->
<script>
"use strict";
${indent(bootstrap)}
${indent(simModule.source)}
</script>
${driver}
<!-- 壁纸运行时:与发布产物同一批模块,合成一个经典脚本 -->
<script>
${indent(script)}
</script>
</body>
</html>
`;
return { html, assetBytes: assets.bytes, skippedAudio: assets.skippedAudio, inlined: assets.count };
}
+189
View File
@@ -0,0 +1,189 @@
// 调试服注入到 index.html 里的驱动脚本。
//
// 为什么单独一个文件:调试服(pnpm dev)与对拍工具(tools/serve.mjs)都往页面里注入脚本,
// 两边的注入点与语义必须一致,否则"调试服里好的、对拍里坏"这类分歧会持续存在。
//
// 这里生成的都是**经典脚本**(非 module):它们在 index.html 的 <script type="module"> 之前执行,
// 因此能在壁纸模块注册 window.wallpaperPropertyListener 之前就把钩子准备好。
/**
* 调试服提供模拟器的固定路径(`simulatorMode: "url"` 的默认值)。
*
* 放在调试服自己的命名空间下,而不是某个分发目录里:模拟器默认不进发布产物(ADR 0006 第 1 条),
* 但调试服总是需要它。两边共用一个常量,避免"驱动里写死一个、服务器里写死另一个"再次错位。
*
* **静态托管(GitHub Pages)不能用它**:那是根绝对路径,Pages 会把它解析到域名根,
* 而不是仓库子路径。静态构建必须传 `simulatorUrl: "./scripts/wallpaper-engine.js"`。
*/
export const SIMULATOR_URL = "/simulator/wallpaper-engine.js";
/**
* 静态构建(`pnpm build --with-sim`)里注入的模拟器地址。
*
* 相对路径是**必须**的:GitHub Pages 把站点放在 `/<repo>/` 子路径下,
* 根绝对路径 `/scripts/…` 会 404。相对路径也让同一个分发目录放到任何位置都能用。
*/
export const SIMULATOR_URL_STATIC = "./scripts/wallpaper-engine.js";
export interface SimulatorDriverOptions {
/** 原始查询串(用于把 __props / fps / __paused 等参数交给模拟器)。 */
search: string;
/** 分发目录名。 */
dir: string;
/** project.json 的 title。 */
title: string;
/** project.json 的 version。 */
version: string;
/** 本分发的默认预设 id。 */
defaultPresetId: string;
/** project.json 的 preview 字段(分发根下的文件名)。缺省 = 该分发没有预览图。 */
preview?: string;
/**
* 装载方式:
* "url"(默认) 经典脚本里的动态 import,从调试服自己的 `/simulator/wallpaper-engine.js` 取。
* "global" 直接调 `window.mountWallpaperEngineSimulator`,模拟器已由前面一个
* <script> 内联在同一页里。
*
* 自包含包(`pnpm build --sim`)必须用 "global":file:// 下动态 import 会被 CORS 拦掉。
*
* 为什么 "url" 不指向 `/release/<dir>/scripts/…`:模拟器是**调试期专有**资源,
* 按 ADR 0006 §1 默认不进发布产物,只有 `pnpm build --with-sim` 才会往分发里放一份。
* 而调试服**总是**需要它。指向分发内部会让"没加 --with-sim 的默认构建"下
* 动态 import 404、面板静默消失——正是这里踩过的坑。改由调试服提供,
* 分发目录则永远保持"就是发布产物"的样子。
*/
simulatorMode?: "url" | "global";
/**
* `simulatorMode: "url"` 时从哪儿取模拟器本体。默认 `SIMULATOR_URL`(调试服的绝对路由)。
* 静态构建传 `SIMULATOR_URL_STATIC`。
*/
simulatorUrl?: string;
}
/**
* 模拟器驱动:解析 URL 参数 → 装载模拟器 → 调 mountWallpaperEngineSimulator。
*
* 模拟器本身是 ES module(`pnpm build:sim` 产出 scripts/wallpaper-engine.js)。这里刻意**不**用
* `<script type="module">`:module 脚本会被延迟到解析完成之后执行,那样它就不可能早于壁纸模块
* 就位,而"属性在 load 之前到达"这一时序要求 API 必须在 index.js 注册 listener 之前就绪。
* 用经典脚本里的动态 import 装载,既满足时序,又能让模拟器源码正常使用 import/export。
*/
export function SIMULATOR_DRIVER(options: SimulatorDriverOptions): string {
const config = JSON.stringify({
search: options.search,
dir: options.dir,
title: options.title,
version: options.version,
defaultPresetId: options.defaultPresetId,
preview: options.preview,
});
const load =
options.simulatorMode === "global"
? `var mount = window.mountWallpaperEngineSimulator;
if (typeof mount !== "function") { console.error("[WE 模拟器] 内联的模拟器没有挂上 window.mountWallpaperEngineSimulator"); return; }
mount(createOptions());`
: `var moduleUrl = ${JSON.stringify(options.simulatorUrl ?? SIMULATOR_URL)};
import(moduleUrl).then(function (mod) {
mod.mountWallpaperEngineSimulator(createOptions());
}, function (error) {
console.error("[WE 模拟器] 加载失败:", error);
});`;
return `<script>
(function () {
// 幂等:--with-sim 构建的分发**自带**驱动,调试服又会再注入一次。
// 没有这道闸,模拟器会被 mount 两遍,页面上出现两个面板、属性也下发两次。
if (window.__weSimDriver) return;
window.__weSimDriver = true;
var cfg = ${config};
var q = new URLSearchParams(cfg.search || location.search);
var props = {};
try { props = JSON.parse(q.get("__props") || "{}"); } catch (e) { props = {}; }
// project.json 的默认值作为底,URL 参数覆盖它
var defaults = {};
var defs = window.__weProperties || null;
if (defs) for (var k in defs) if (defs[k] && "value" in defs[k]) defaults[k] = defs[k].value;
var fpsRaw = q.get("fps");
function createOptions() {
var opts = {
releaseName: cfg.title + " · " + cfg.dir,
preview: cfg.preview,
properties: defs || {},
initialProps: Object.assign({}, defaults, props),
initialFps: fpsRaw === null ? 0 : Number(fpsRaw) || 0,
initialPaused: q.get("__paused") === "1",
propsAt: q.get("__propsAt") === "dom" ? "dom" : "load"
};
return opts;
}
function afterMount() {
if (!window.__weSim) return;
window.__weSim.release.version = cfg.version;
window.__weSim.release.dir = cfg.dir;
}
${load}
afterMount();
})();
</script>`;
}
/**
* ?nojs=1 的收尾脚本:剥掉所有 <script> 之后,页面上什么都不剩,连"是否加载成功"都无从判断。
* 这里补一个小小的状态标记,让 cdp/shot 的断言仍然能读到 readyState。
*/
export const NOJS_DRIVER = `<script>
document.title = "TESTSTATE " + JSON.stringify({ mode: "nojs", readyState: document.readyState, scripts: 0 });
</script>`;
/**
* 热更新客户端(只有调试服注入,发布产物里没有)。
*
* 连调试服的 SSE,按消息决定怎么更新:
* building → 左下角出现一个小药丸,说明正在重建(不然保存后有 1 秒左右毫无反馈)
* css → **只换样式表**,不整页重载:保住已经加载好的 Spine 播放器与面板状态
* reload → 整页刷新
* error → 显示错误并且**不刷新**:构建失败时刷新只会把一个半成品页面端上来
*
* 药丸挂在 documentElement 下,理由与模拟器面板相同:body 上会被打 transform/filter
* (翻转、颜色选项),挂 body 里会跟着壁纸一起被镜像、被调色。
*/
export const LIVE_RELOAD_CLIENT = `<script>
(function () {
if (!window.EventSource) return;
var pill = null;
function show(text, tone) {
if (!pill) {
pill = document.createElement("div");
pill.id = "dev-reload-pill";
pill.style.cssText = "position:fixed;left:12px;bottom:12px;z-index:2147483647;" +
"font:12px/1.5 'Segoe UI',system-ui,sans-serif;padding:6px 12px;border-radius:999px;" +
"border:1px solid #3a3a40;box-shadow:0 4px 14px rgba(0,0,0,.5);max-width:62vw;white-space:pre-wrap;";
document.documentElement.append(pill);
}
pill.style.display = "block";
pill.style.background = tone === "error" ? "#5a1f1f" : "#2a2a2e";
pill.style.color = tone === "error" ? "#ffb4b4" : "#e6e6e8";
pill.textContent = text;
}
function hide() { if (pill) pill.style.display = "none"; }
var es = new EventSource("/__dev/events");
es.onmessage = function (event) {
var msg;
try { msg = JSON.parse(event.data); } catch (e) { return; }
if (msg.type === "building") { show("⟳ 重新构建中… " + (msg.steps || []).join(" → ")); return; }
if (msg.type === "error") { show("✗ 构建失败(页面没有刷新)\\n" + msg.message, "error"); return; }
if (msg.type === "css") {
var links = document.querySelectorAll('link[rel="stylesheet"]');
for (var i = 0; i < links.length; i++) {
links[i].href = links[i].href.split("?")[0] + "?t=" + Date.now();
}
hide();
return;
}
if (msg.type === "reload") location.reload();
};
// 调试服重启时连接会断,EventSource 自己会重连,这里不需要做任何事。
es.onerror = function () {};
})();
</script>`;
+129
View File
@@ -0,0 +1,129 @@
// 文件系统与会话基元。刻意不引任何第三方依赖:构建脚本要能在一台只有 Node 的机器上跑起来。
import { createHash } from "node:crypto";
import { cp, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { dirname, join, relative, resolve, sep } from "node:path";
/** 仓库根(tools/lib/fs.ts → ../..)。所有路径都相对它解析,脚本可从任意 cwd 调用。 */
export const repoRoot = resolve(import.meta.dirname, "..", "..");
/** 把仓库相对路径解析成绝对路径。 */
export function abs(rel: string): string {
return join(repoRoot, rel);
}
/** 统一成以 / 分隔的仓库相对路径,便于打印与写进 dist-map.json。 */
export function rel(absPath: string): string {
return relative(repoRoot, absPath).split(sep).join("/");
}
export async function readJson<T>(path: string): Promise<T> {
return JSON.parse(await readFile(path, "utf8")) as T;
}
export async function writeJson(path: string, value: unknown): Promise<void> {
await mkdir(dirname(path), { recursive: true });
await writeFile(path, JSON.stringify(value, null, "\t") + "\n", "utf8");
}
export async function writeText(path: string, value: string): Promise<void> {
await mkdir(dirname(path), { recursive: true });
await writeFile(path, value.endsWith("\n") ? value : value + "\n", "utf8");
}
export async function exists(path: string): Promise<boolean> {
return existsSync(path);
}
export async function isDir(path: string): Promise<boolean> {
try {
return (await stat(path)).isDirectory();
} catch {
return false;
}
}
export async function isFile(path: string): Promise<boolean> {
try {
return (await stat(path)).isFile();
} catch {
return false;
}
}
/** 列目录,只返回目录名,跳过以 . 开头的条目与显式排除的名字。 */
export async function listDirs(path: string, exclude: string[] = []): Promise<string[]> {
if (!(await isDir(path))) return [];
const entries = await readdir(path, { withFileTypes: true });
return entries
.filter((e) => e.isDirectory() && !e.name.startsWith(".") && !exclude.includes(e.name))
.map((e) => e.name)
.sort();
}
/**
* 拷贝目录树。
*
* 刻意用真拷贝而不是链接(hardlink / junction):分发目录要能被整体搬到别的机器上传,
* 链接会让"目录可整体搬走"这条不变式在某些工具下失效(见 docs/adr/0005)。
*/
export async function copyTree(src: string, dest: string): Promise<void> {
if (!(await exists(src))) return;
await mkdir(dirname(dest), { recursive: true });
await cp(src, dest, { recursive: true, force: true });
}
/** 拷贝单个文件,自动建父目录。 */
export async function copyFileTo(src: string, dest: string): Promise<void> {
await mkdir(dirname(dest), { recursive: true });
await cp(src, dest, { force: true });
}
/** 目录内所有文件的相对路径(以 / 分隔)。 */
export async function walkFiles(root: string, base = root): Promise<string[]> {
if (!(await isDir(base))) return [];
const out: string[] = [];
for (const entry of await readdir(base, { withFileTypes: true })) {
const full = join(base, entry.name);
if (entry.isDirectory()) out.push(...(await walkFiles(root, full)));
else if (entry.isFile()) out.push(relative(root, full).split(sep).join("/"));
}
return out.sort();
}
/** 目录内所有文件的总字节数。 */
export async function dirBytes(root: string): Promise<number> {
if (!(await isDir(root))) return 0;
let total = 0;
for (const f of await walkFiles(root)) total += (await stat(join(root, f))).size;
return total;
}
export async function sizeOf(path: string): Promise<number> {
try {
return (await stat(path)).size;
} catch {
return 0;
}
}
/** 一个长任务的耗时统计与人性化打印。 */
export function mb(bytes: number): string {
return (bytes / 1024 / 1024).toFixed(1) + " MB";
}
export function sha1(text: string): string {
return createHash("sha1").update(text).digest("hex");
}
/** 清空目录后重建(用于"分发目录绝不残留脏文件"这条保证)。 */
export async function resetDir(path: string): Promise<void> {
await rm(path, { recursive: true, force: true });
await mkdir(path, { recursive: true });
}
/** 单行进度输出,避免几十个分发目录刷屏。 */
export function log(message: string): void {
process.stdout.write(message + "\n");
}
+531
View File
@@ -0,0 +1,531 @@
// 一个新分发目录 = 一个能直接上传 Wallpaper Engine 的作品。
//
// 这一层只负责"合成产物":从 wallpapers/ 的内容算出 preset.js(运行时)与 project.json(发布),
// 并把资源铺进目录结构。校验与去重不在本文件(见 vault.ts 与 build.ts)。
import { join } from "node:path";
import type { AudioChoice, ProjectTemplate, Release, Wallpaper } from "./types.ts";
import { abs, walkFiles } from "./fs.ts";
import { copyFileTo, copyTree, isDir, isFile, writeJson, writeText } from "./fs.ts";
export interface GenerateContext {
template: ProjectTemplate;
version: string;
/** 默认预设 id(来自 wallpapers/meta.json):决定合集分发的 preset 下拉首项与 value。 */
defaultPresetId: string;
/** 分发目录的绝对路径。 */
outDir: string;
/** 往运行时脚本目录里额外放一个可选的 WE 模拟器入口。 */
withSimulator: boolean;
}
/** 该分发里一档壁纸落到哪个子目录(相对分发根)。 */
export function releasePathOf(release: Release, wallpaper: Wallpaper): string {
if (release.scope === "all") return `${wallpaper.gameId}/${wallpaper.id}`;
if (release.scope === "game") return wallpaper.id;
return "";
}
/**
* 一份音频在某分发里的相对路径(相对 preset.js 所在目录)。
*
* 落点键必须与 `planAudio` 算出来的一致:单档分发与共享音频都落在分发根(键为空串),
* 合集分发落在 `audios/<壁纸id>/`。这里刻意不查落点表——查表会让人以为"表是权威",
* 而真正的权威是 planAudio;两者不一致时应当在构建里被发现(见 check:dist)。
*/
export function audioPathIn(release: Release, wallpaper: Wallpaper, file: string, shared = ""): string {
const rootPrefix = release.type === "single" ? "./" : rootPrefixOf(release, wallpaper);
const key = shared !== "" ? shared.replace(/^\/+|\/+$/g, "") : release.type === "single" ? "" : wallpaper.id;
return rootPrefix + "audios/" + (key ? key + "/" : "") + baseName(file);
}
/**
* 从某档壁纸的目录回到**分发根**的相对前缀。
*
* 必须与 build.ts 的 copyWallpaperAssets({skipAudio}) + copyCollectionAudio 的搬移行为严格一致,
* 否则 preset.js 会指向不存在的文件(这条路径算错过一次:多算了一层 `../`)。
*/
export function rootPrefixOf(release: Release, wallpaper: Wallpaper): string {
if (release.type === "single") return "./";
// game 合集里壁纸在 1 层下(../),all 合集里在 2 层下(../../)。
const depth = releasePathOf(release, wallpaper).split("/").filter(Boolean).length;
return "../".repeat(depth);
}
/** 该分发里一档壁纸的目录深度(0 = 单档,1 = 游戏合集,2 = 全部合集)。 */
export function depthOf(release: Release, wallpaper: Wallpaper): number {
return releasePathOf(release, wallpaper).split("/").filter(Boolean).length;
}
/** 一档壁纸目录内的资源(图片/骨架/atlas)在发布产物里的 URL,与 `asset()` 完全同算法。 */
export function assetUrlIn(release: Release, wallpaper: Wallpaper, relInWallpaper: string): string {
return `./${rootPrefixOf(release, wallpaper)}${relInWallpaper.replace(/^\.\//, "")}`;
}
/**
* 同一份资源在**分发根**下的路径(不带 `./`、`../`)。
*
* 与 `assetUrlIn` 的区别是基准:那个以 preset.js 所在目录为基准(`../spines/x/x.webp`),
* 这个以分发根为基准(`kv37/spines/x/x.webp`)。自包含包的资源表以**页面**为基准建键,
* 而页面在 `<分发根>/sim/`,所以要用这个。
*
* 拿 `assetUrlIn` 的结果去建表会丢掉合集里的 `<壁纸id>/` 这一段:
* `./../spines/x/x.webp` 归一化后是 `spines/x/x.webp`,而真实位置是 `kv37/spines/x/x.webp`。
* 单档分发看不出差别(没有这一段),合集里则是"表里 152 个键,骨架却查不中"。
*/
export function assetRelIn(release: Release, wallpaper: Wallpaper, relInWallpaper: string): string {
const sub = releasePathOf(release, wallpaper);
const rel = relInWallpaper.replace(/^\.\//, "");
return sub === "" ? rel : `${sub}/${rel}`;
}
function withDotSlash(path: string): string {
const normalized = path.replace(/\\/g, "/");
return normalized.startsWith("./") || normalized.startsWith("../") ? normalized : "./" + normalized;
}
function baseName(path: string): string {
return path.replace(/\\/g, "/").replace(/^.*\//, "");
}
/**
* 生成一档壁纸的 preset.js。
*
* 关键:每个分发目录里的 preset.js 都是**现算**的——同一档壁纸在三种分发形状下,
* 音频落点与相对深度都不同,所以不能一份源码拷贝到多处:
* 单档分发 图片/骨架/音频全在本目录 → ./audios/<壁纸id>/…
* 游戏合集 图片/骨架在本目录,音频在分发根 → ../audios/<壁纸id>/…
* 全部合集 图片/骨架在本目录,音频在分发根 → ../../audios/<壁纸id>/…
*
* 落点里的 `<壁纸id>/` 一级与 copyCollectionAudio 的分目录一一对应:把不同壁纸的音源分开放,
* 既避免**同名不同曲**的静默冲突(旧的扁平布局会因重名直接报错),也让"这份音频属于谁"一目了然。
* 游戏级共享音频仍在合集根,不进任何壁纸子目录。
*/
export function generatePresetModule(
release: Release,
wallpaper: Wallpaper,
sharedAudio: AudioChoice[],
): string {
const game = wallpaper.game;
if (!game) throw new Error(`内部错误:${wallpaper.srcRel} 没有所属游戏`);
/** 一份音频在该分发里的相对路径(相对 preset.js 所在目录)。 */
const audioPath = (file: string, shared = ""): string => audioPathIn(release, wallpaper, file, shared);
const spineConfig = wallpaper.preset.spineConfig;
const sceneConfig = wallpaper.preset.sceneConfig;
if (spineConfig && sceneConfig) {
throw new Error(`${wallpaper.srcRel}:spineConfig 与 sceneConfig 只能写一个(单骨架 / 场景二选一)`);
}
if (!spineConfig && !sceneConfig) {
throw new Error(`${wallpaper.srcRel}:preset.template.json 里既没有 spineConfig 也没有 sceneConfig`);
}
const lines: string[] = [
`// 由 pnpm build 生成,请勿手工修改。`,
`// 源:${wallpaper.srcRel}/meta.json(音频清单)+ preset.template.json(运行时配置)`,
`//`,
`// 资源路径用 import.meta.url 从本文件位置推导:整个分发目录可以整体搬家而不用改任何一行路径。`,
``,
`const base = new URL("./", import.meta.url);`,
`const asset = (path) => new URL(path, base).href;`,
``,
`export default {`,
` id: ${JSON.stringify(wallpaper.id)},`,
` name: ${JSON.stringify(wallpaper.meta.name)},`,
` game: ${JSON.stringify(game.id)},`,
``,
` backgroundImage: asset(${JSON.stringify(withDotSlash(wallpaper.preset.backgroundImage))}),`,
];
if (spineConfig) {
lines.push(` spineConfig: {`);
for (const [key, value] of Object.entries(spineConfig)) {
const isUrl = key === "jsonUrl" || key === "atlasUrl";
lines.push(
` ${key}: ${isUrl && typeof value === "string" ? `asset(${JSON.stringify(withDotSlash(value))})` : JSON.stringify(value)},`,
);
}
lines.push(` },`);
}
if (sceneConfig) {
lines.push(` sceneConfig: {`);
if (sceneConfig.ui) lines.push(` ui: ${JSON.stringify(sceneConfig.ui)},`);
if (sceneConfig.camera) lines.push(` camera: ${JSON.stringify(sceneConfig.camera)},`);
if (sceneConfig.timelineOffset) lines.push(` timelineOffset: ${JSON.stringify(sceneConfig.timelineOffset)},`);
if (sceneConfig.flipY === false) lines.push(` flipY: false,`);
lines.push(` parts: [`);
for (const part of sceneConfig.parts) {
const fields: string[] = [
`kind: ${JSON.stringify(part.kind)}`,
`id: ${JSON.stringify(part.id)}`,
`order: ${JSON.stringify(part.order ?? 0)}`,
`position: ${JSON.stringify(part.position)}`,
`scale: ${JSON.stringify(part.scale)}`,
];
if (part.renderOrder) fields.push(`renderOrder: ${JSON.stringify(part.renderOrder)}`);
// 三个资源字段都走 asset():与单骨架同一条路径解析规则(相对 preset.js 推导)。
for (const key of ["image", "jsonUrl", "atlasUrl"] as const) {
const value = part[key];
if (typeof value === "string" && value) fields.push(`${key}: asset(${JSON.stringify(withDotSlash(value))})`);
}
if (part.animation) fields.push(`animation: ${JSON.stringify(part.animation)}`);
if (part.skin) fields.push(`skin: ${JSON.stringify(part.skin)}`);
if (part.timeScale !== undefined) fields.push(`timeScale: ${JSON.stringify(part.timeScale)}`);
if (part.width !== undefined) fields.push(`width: ${JSON.stringify(part.width)}`);
if (part.height !== undefined) fields.push(`height: ${JSON.stringify(part.height)}`);
if (part.center !== undefined) fields.push(`center: ${JSON.stringify(part.center)}`);
if (part.rotation !== undefined) fields.push(`rotation: ${JSON.stringify(part.rotation)}`);
if (part.color !== undefined) fields.push(`color: ${JSON.stringify(part.color)}`);
lines.push(` { ${fields.join(", ")} },`);
}
lines.push(` ],`, ` },`);
}
lines.push(``, ` audioChoices: [`);
for (const choice of wallpaper.audioChoices) {
lines.push(
` { id: ${JSON.stringify(choice.id)}, name: ${JSON.stringify(choice.name)}, source: asset(${JSON.stringify(
audioPath(choice.file),
)}) },`,
);
}
for (const choice of sharedAudio) {
lines.push(
` { id: ${JSON.stringify(choice.id)}, name: ${JSON.stringify(choice.name)}, source: asset(${JSON.stringify(
audioPath(choice.file, "shared"),
)}) },`,
);
}
// 默认音源:合集分发里它也随之搬到分发根。
//
// 有一处**刻意的历史不一致**:当前已发布的全部合集里这一行是 asset("./audios/<文件>"),
// 也就是指向壁纸自己目录下并不存在的 audios/。运行时不读它(`index.ts` 用 audioChoices 的
// source 覆盖),所以它一直是死数据。这里改成与 audioChoices 同落点的**正确**路径:
// 死数据不该被继承,且一旦将来有代码读它就立刻是错的。
// 默认音源按 **1 起的位置** 取(meta.json 里不再手写 id):省略 = 第一个。
const defaultChoice = wallpaper.audioChoices[wallpaper.audioDecl.defaultIndex];
// 没有音源时写空串,**不能**写成 `asset("")`:那会解析成 preset.js 自己的 URL,
// 运行时会把它塞进 <audio>.src(实测会去请求一个不存在的音频)。
const defaultSource = defaultChoice ? `asset(${JSON.stringify(withDotSlash(audioPath(defaultChoice.file)))})` : `""`;
lines.push(` ],`, ``, ` audioOptions: { source: ${defaultSource} },`, `};`, ``);
return lines.join("\n");
}
/**
* 生成 scripts/presets.js:预设查表 + 本分发的默认预设 id。
*
* 注意这里生成的是**裸 JS 文本**,编译器管不到它——写错了不会有任何提示,只会在浏览器里
* SyntaxError 白屏。所以格式必须最保守:用 `Object.fromEntries([[id, mod], …])` 这种"数组的数组",
* 别用对象字面量的计算属性名 `[k]: v`(在数组字面量里是非法语法,实测过一次真事故)。
*/
export function generatePresetIndex(release: Release, defaultPresetId: string): string {
const imports: string[] = [];
const entries: string[] = [];
const ordered = [...release.wallpapers].sort((a, b) => {
if (a.id === defaultPresetId) return -1;
if (b.id === defaultPresetId) return 1;
return 0;
});
ordered.forEach((wallpaper, index) => {
const sub = releasePathOf(release, wallpaper);
const local = `preset${index}`;
imports.push(`import ${local} from "../${sub ? sub + "/" : ""}preset.js";`);
entries.push(` [${local}.id, ${local}],`);
});
// 单档分发里 defaultPresetId 必须等于它自己的 id,否则 index.js 会拿表里不存在的 id 去查,整页白屏。
const fallback = ordered.some((w) => w.id === defaultPresetId) ? defaultPresetId : (ordered[0]?.id ?? "");
return [
`// 由 pnpm build 生成,请勿手工修改。`,
`//`,
`// 本分发包含的壁纸:${ordered.map((w) => `${w.id}(${w.meta.name})`).join("、")}`,
`// defaultPresetId 是本分发在 WE 属性缺失时的回落目标。`,
``,
...imports,
``,
`const Presets = Object.fromEntries([`,
...entries,
`]);`,
``,
`export const defaultPresetId = ${JSON.stringify(fallback)};`,
`export default Presets;`,
``,
].join("\n");
}
export interface ProjectJson {
contentrating: string;
description: string;
file: string;
general: { properties: Record<string, unknown> };
preview?: string;
ratingsex: string;
ratingviolence: string;
tags: string[];
title: string;
type: string;
/** WE 的 project.json 里 version 是**数字**(VERSION 文件里是文本,生成时转换)。 */
version: number;
visibility: string;
workshopid?: string;
workshopurl?: string;
}
interface ComboProperty {
options: { label: string; value: string }[];
value: string;
}
/**
* 按固定顺序重建一个属性对象。
*
* 键序不是无所谓的:WE 面板只看字段名,但 project.json 同时是**已发布产物的一部分**,
* 保持与线上逐字节一致的键序,才能让"新旧构建的 diff"只反映真实改动,而不是序列化顺序抖动。
* 顺序取自线上已发布的 project.json。
*/
function orderProperty(source: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const key of ["index", "options", "order", "text", "type", "value"]) {
if (key in source) out[key] = source[key];
}
for (const [key, value] of Object.entries(source)) {
if (!(key in out)) out[key] = value;
}
return out;
}
/**
* 给属性表自动编号:`index` = 0,1,2…,`order` = 100+index,都按**属性表的书写顺序**。
*
* 这两串数字以前是手写在模板里的。代价在加一个属性时才显出来:后面全部要重编号
* (上一轮加 use_custom_audio 手改了 6 处),而且单档分发删掉 preset/bgm 之后会留下空号
* (0,1,4,5,6…)。自动编号同时解决这两件事。
*
* `$order` 是显式钉住:schemecolor 是 WE 的**内置**属性,线上就是 order 0 且**没有** index。
* 钉住的属性不参与 index 序列——这样产物与线上逐字一致,diff 里只剩真实改动。
*/
function numberProperties(properties: Record<string, unknown>): void {
let index = 0;
for (const definition of Object.values(properties)) {
if (definition === null || typeof definition !== "object") continue;
const def = definition as Record<string, unknown>;
const pinned = def.$order;
delete def.$order;
if (typeof pinned === "number") {
def.order = pinned;
delete def.index;
continue;
}
def.index = index;
def.order = 100 + index;
index += 1;
}
}
/** 生成某分发的 project.json。字段顺序对齐已发布作品,便于人工 diff。 */
export function generateProjectJson(release: Release, ctx: GenerateContext): ProjectJson {
const properties: Record<string, unknown> = structuredClone(ctx.template.general.properties) as Record<string, unknown>;
const isCollection = release.wallpapers.length > 1;
// 下拉顺序以**默认预设**开头。这不是美观问题:默认项决定 preset.value,
// 也决定 WE 属性缺失时 show 的是哪一档;线上值必须逐字保持不变。
const ordered = [...release.wallpapers].sort((a, b) => {
if (a.id === ctx.defaultPresetId) return -1;
if (b.id === ctx.defaultPresetId) return 1;
return 0;
});
// bgm:本分发内所有壁纸音源的并集 + 合集根共享音频。运行时只让"属于当前壁纸"的选择生效,
// 其余回落该壁纸默认音源(既有语义,见 .scratch/wallpaper-optimization/issues/05)。
//
// **单档分发也要算它**:一档壁纸照样可以带多首曲子(xilian 就有两首)。
// 以前整块跟着 preset 一起删,结果 single-hsr-xilian 根本没有切换入口。
const bgmOptions: { label: string; value: string }[] = [{ label: "随预设", value: "auto" }];
const seen = new Set<string>(["auto"]);
for (const wallpaper of ordered) {
for (const choice of wallpaper.audioChoices) {
if (seen.has(choice.id)) continue;
seen.add(choice.id);
bgmOptions.push({ label: choice.name, value: choice.id });
}
}
for (const choice of release.sharedAudio) {
if (seen.has(choice.id)) continue;
seen.add(choice.id);
bgmOptions.push({ label: choice.name, value: choice.id });
}
if (isCollection) {
const multiGame = new Set(release.wallpapers.map((w) => w.gameId)).size > 1;
const preset = properties.preset as ComboProperty;
preset.options = ordered.map((wallpaper) => ({
label: multiGame
? `${wallpaper.game?.name ?? wallpaper.gameId} - ${wallpaper.meta.name}`
: wallpaper.meta.name,
value: wallpaper.id,
}));
preset.value = ordered[0]?.id ?? "";
properties.preset = orderProperty(properties.preset as Record<string, unknown>);
} else {
// 单档分发:只剩一档壁纸,"壁纸预设切换"没有意义。整条属性删掉;运行时对缺失属性是安全的。
delete properties.preset;
delete properties.preset_note;
}
// 只剩"随预设"+ 至多一首时这个下拉是句废话(两个选项效果完全一样),删掉。
// 这样 single-hsr-kv37(一首)没有它、single-hsr-xilian(两首)有它。
if (bgmOptions.length <= 2) {
delete properties.bgm;
delete properties.bgm_note;
} else {
(properties.bgm as ComboProperty).options = bgmOptions;
properties.bgm = orderProperty(properties.bgm as Record<string, unknown>);
}
// 编号必须在**删属性之后**:单档分发删掉 preset/bgm 那四行,编号要跟着补上,不留空号。
numberProperties(properties);
// **键序是有意义的**:上传到创意工坊的 project.json 要和线上那份逐字节对齐,
// 否则每次构建都会产生一个"内容相同但文件不同"的 diff,无法判断是真改动还是噪声。
//
// 线上那份的键序是(WE 自己写出来的):
// contentrating, description, file, general, **preview**, ratingsex, ratingviolence,
// tags, title, type, version, visibility, workshopid, workshopurl
// 注意 `preview` 夹在 general 与 ratingsex 之间,而 workshop* 在最后——不是"可选字段统一追加"。
// 先前把三个可选字段都追加到末尾,字段值一个没差、键序却变了;靠逐键比对才发现。
const out: ProjectJson = {
contentrating: ctx.template.contentrating,
description: release.meta.description,
file: ctx.template.file,
general: { properties },
...(release.meta.preview ? { preview: release.meta.preview } : {}),
ratingsex: ctx.template.ratingsex,
ratingviolence: ctx.template.ratingviolence,
tags: ctx.template.tags,
title: release.meta.title,
type: ctx.template.type,
// WE 的 project.json 里 version 是数字;VERSION 文件里是文本,这里必须转回来。
version: Number(ctx.version),
visibility: ctx.template.visibility,
};
// workshopid / workshopurl 只在有值时出现(Q7):没有就整键省略,而不是留空串。
if (release.meta.workshopid) out.workshopid = release.meta.workshopid;
if (release.meta.workshopurl) out.workshopurl = release.meta.workshopurl;
return out;
}
export interface AudioPlacement {
/** 源文件绝对路径。 */
from: string;
/** 落点键:壁纸 id(→ `audios/<键>/<文件>`)或 `""`(→ 合集根 `audios/<文件>`)。 */
key: string;
/** 文件名(保留中文,是产品内容)。 */
name: string;
/** 相对分发根的落点。 */
destRel: string;
}
export interface AudioPlan {
placements: AudioPlacement[];
/** 合集根共享层的重名冲突(扁平一层,重名即歧义)。 */
collisions: string[];
}
/**
* 算出**整个分发**的音频落点。
*
* 这份映射是三件事的唯一真相来源,所以必须是同一个函数:
* ① copyCollectionAudio 按它搬文件;② generatePresetModule 按它拼 preset.js 里的路径;
* ③ `--sim` 的自包含打包按它建内联表。
* 三处各算一遍的症状是"http 下正常、file:// 下白屏"或"静默无声"——最难在开发期发现的那类。
*
* 落点分两层:
* audios/<壁纸id>/<文件> 该壁纸自己的音源(互相隔离,同名不同曲不再冲突)
* audios/<文件> 游戏级共享音频(扁平一层,重名即歧义)
*/
export async function planAudio(release: Release): Promise<AudioPlan> {
const placements: AudioPlacement[] = [];
const collisions: string[] = [];
const seen = new Set<string>();
const push = (from: string, key: string, name: string): void => {
if (seen.has(from)) return;
seen.add(from);
placements.push({ from, key, name, destRel: `audios/${key ? key + "/" : ""}${name}` });
};
const owner = new Map<string, string>();
for (const wallpaper of release.wallpapers) {
const key = release.type === "single" ? "" : wallpaper.id;
for (const choice of wallpaper.audioChoices) {
const name = baseName(choice.file);
push(join(abs(wallpaper.srcRel), "audios", name), key, name);
owner.set(name, wallpaper.srcRel);
}
}
// 游戏级共享音频(wallpapers/<游戏>/audios/):同一首曲子被该游戏多档壁纸共用时放这里。
for (const gameId of [...new Set(release.wallpapers.map((w) => w.gameId))]) {
const shared = abs(`wallpapers/${gameId}/audios`);
if (!(await isDir(shared))) continue;
for (const name of await walkFiles(shared)) {
const previous = owner.get(name);
if (previous !== undefined) {
collisions.push(`${name}:${previous} 与 共享(${gameId})`);
continue;
}
owner.set(name, `共享(${gameId})`);
push(join(shared, name), "", name);
}
}
return { placements, collisions };
}
/** 把一档壁纸的资源铺进分发目录(骨架、贴图、音频、预览图)。
*
* 合集类分发里音频统一放在**分发根的 audios/**(见 copySharedAudio),所以这里跳过 audios/,
* 否则同一首曲子会在分发里出现两份。单档分发没有合集根,音频必须留在壁纸目录内。 */
export async function copyWallpaperAssets(
wallpaper: Wallpaper,
destDir: string,
options: { skipAudio?: boolean } = {},
): Promise<void> {
const from = abs(wallpaper.srcRel);
// 目录名必须与 wallpapers/README.md 的布局一致:一具骨架一组的 `spines/`、
// 场景图与背景的 `scene/`、音频的 `audios/`(这里改一处,所有分发的搬运都跟着走)。
for (const segment of ["spines", "scene", "audios"]) {
if (segment === "audios" && options.skipAudio) continue;
if (await isDir(join(from, segment))) await copyTree(join(from, segment), join(destDir, segment));
}
if (wallpaper.meta.preview && (await isFile(join(from, baseName(wallpaper.meta.preview))))) {
await copyTree(join(from, baseName(wallpaper.meta.preview)), join(destDir, baseName(wallpaper.meta.preview)));
}
}
/**
* 把**整个分发**的音频铺进分发根。落点完全由 planAudio 决定(见它的注释)。
*
* 只有共享层允许重名冲突:那里重名是真的不知道谁是权威,直接报错而不是静默覆盖。
*/
export async function copyCollectionAudio(
release: Release,
outDir: string,
): Promise<{ names: string[]; audioDirs: Map<string, string> }> {
const plan = await planAudio(release);
if (plan.collisions.length > 0) {
throw new Error(
`合集根共享音频文件重名,无法确定权威版本:\n - ${plan.collisions.join("\n - ")}\n` +
`共享层是扁平的一层,重名即歧义。请改名,或把它放进各自壁纸目录(不同壁纸之间不会冲突)。`,
);
}
const audioDirs = new Map<string, string>();
for (const item of plan.placements) {
audioDirs.set(item.name, item.key);
await copyFileTo(item.from, join(outDir, item.destRel));
}
return { names: plan.placements.map((p) => p.name).sort(), audioDirs };
}
export { writeJson, writeText, baseName, withDotSlash };
+309
View File
@@ -0,0 +1,309 @@
// 构建期共享类型:schema 本身即文档。
//
// 三层元数据 + 两层运行时配置的关系:
//
// wallpapers/meta.json ← 「所有壁纸合集」这一档的发布文案
// wallpapers/<gameId>/meta.json ← 游戏显示名 + 该游戏的共享音频
// wallpapers/<gameId>/<wallpaperId>/
// meta.json ← 壁纸的发布文案与音频清单(运行时用不到的)
// preset.template.json ← 壁纸的运行时配置(播放器真正要读的)
//
// build 把后两者合成两份产物:preset.js(运行时)与 project.json(发布)。
import type { ViewportSpec } from "../../src/globals.d.ts";
/** WE 属性的合法类型。build 只生成 combo,其余类型由模板手写。 */
export type PropertyType =
| "bool"
| "combo"
| "slider"
| "textinput"
| "color"
| "file"
| "directory"
| "texture"
| "group"
| "usershortcut"
| "text";
export interface PropertyOption {
label: string;
value: string;
}
export interface PropertyDef {
type: PropertyType;
text?: string;
value?: unknown;
order?: number;
index?: number;
options?: PropertyOption[];
condition?: string;
min?: number;
max?: number;
step?: number;
precision?: number;
fraction?: boolean;
}
/** project.json 的骨架(src/project.template.json)。 */
export interface ProjectTemplate {
contentrating: string;
file: string;
ratingsex: string;
ratingviolence: string;
tags: string[];
type: string;
visibility: string;
general: { properties: Record<string, PropertyDef> };
}
/**
* meta.json 里**手写**的一条音源声明:只有显示名与文件路径。
*
* id 不在这里写——它由 build 自增分配(见 AudioChoice)。以前是手写字符串
* (zaiduheni / xilian / pv37),加一条就得现编一个不重名的 id。
*/
export interface AudioChoiceSpec {
/** 显示名(中文原样保留,是产品的一部分)。 */
name: string;
/** 相对**本壁纸目录**或**合集根**的文件路径(不含 ./ 前缀也可)。 */
file: string;
}
/** meta.json 里的一份音频声明。 */
export interface AudioDecl {
/** 默认音源的位置,**1 起**;省略 = 第一个。 */
default?: number;
choices: AudioChoiceSpec[];
}
/** build 解析后的音源:id 已经分配好,是 WE combo 的 value。 */
export interface AudioChoice {
/**
* 自增 id,**全项目唯一**("1"、"2"、"3"…)。
*
* 为什么必须全局唯一而不是每档壁纸各从 1 开始:`bgm` 下拉会把一个分发里**所有**壁纸的
* 音源平铺进同一个 combo,两档壁纸都叫 "1" 的话,选中的到底是哪一个就无从分辨了。
* 分配顺序固定为 游戏 → (该游戏的共享音频 → 各壁纸的音源),所以同一个音源在任何分发里
* 拿到的 id 都一样。
*/
id: string;
name: string;
file: string;
}
/** 壁纸级元数据(wallpapers/<gameId>/<wallpaperId>/meta.json)。 */
export interface WallpaperMeta {
id: string;
name: string;
title: string;
description: string;
/** 预览图,相对本目录。缺省时 build 省略 project.json 的 preview 并 warning。 */
preview?: string;
/** 该档壁纸可选音源(本壁纸目录内的目录)。 */
audio: AudioDecl;
/** 上传 Workshop 后由 WE 生成;此处缺省即不写字段。 */
workshopid?: string;
workshopurl?: string;
}
/** 游戏级元数据(wallpapers/<gameId>/meta.json)。 */
export interface GameMeta {
id: string;
name: string;
/**
* 游戏合集的创意工坊标题。**必填**:这一层是「游戏合集」文案的唯一来源,
* 不许回落全局 meta——那层是给「全部合集」写的(回落过,见 .scratch/build-pipeline/issues/20)。
*/
title: string;
/** 游戏合集的文案;缺省时 build 用「共 N 档」的模板自动生成。 */
description?: string;
/** 该游戏的共享音频;只在「合集」类分发里被使用。 */
audios: AudioChoiceSpec[];
}
/** 全局元数据(wallpapers/meta.json)= 「所有壁纸合集」这一档。 */
export interface GlobalMeta {
name: string;
title: string;
description: string;
/** 默认预设 id:决定 preset 下拉的第一项与 project.json 的 preset.value。 */
defaultPresetId?: string;
/** 分发级预览图(相对 wallpapers/ 的文件名)。缺省时 project.json 省略 preview。 */
preview?: string;
workshopid?: string;
workshopurl?: string;
}
/** 壁纸级运行时配置(preset.template.json)。只放播放器真正要读的东西。 */
export interface PresetTemplate {
/** 背景图,相对本目录。 */
backgroundImage: string;
/**
* 单骨架路径的配置。与 `sceneConfig` **二选一**:
* 两个都写或都不写,构建期直接报错——不留静默优先级,歧义会烂在产物里。
*/
spineConfig?: {
jsonUrl: string;
atlasUrl: string;
animation?: string;
viewport?: ViewportSpec;
/** "author" = 保留作者原始取景,不跟随背景缩放。 */
framing?: string;
[key: string]: unknown;
};
/**
* 场景路径:N 具骨架 + M 块贴图平面,按抓取期 `scene.json` 的世界变换与绘制层级合成。
* 这些字段由抓取器的 `scene.json` 抄来(构建期烘焙),运行时不再读侧车。
*/
sceneConfig?: {
/** 页面场景的 UI 尺寸,仅作退化取景的兜底。 */
ui?: number[];
/**
* 页面相机(抓取期从场景树抄来)。
* `type: 1` = 透视:按 fov 与相机 z 做 billboard 投影(z 是真实景深,不能忽略);
* `type: 2` = 正交:各 part 直接按原坐标画(z 全为 0 的那些页面)。
*/
camera?: { type?: number; fov?: number; position?: number[] };
/** 页面是 y 向上、spine 渲染是 y 向下:默认把 y 取反;实测颠倒时设 false。 */
flipY?: boolean;
/** 时间线的整场位移(页面单位),从真页面量出来;见 .scratch/scene-player/perspective.md。 */
timelineOffset?: number[];
parts: {
kind: "spine" | "image" | "solid";
id: string;
order: number;
renderOrder?: number;
position: number[];
scale: number[];
image?: string;
jsonUrl?: string;
atlasUrl?: string;
animation?: string;
width?: number;
height?: number;
/** 网格中心相对节点原点的偏移(页面坐标系);四边形不以原点为中心时用它。 */
center?: number[];
/** 节点旋转(弧度)。非零 = 被倾斜的 3D 面片,运行时当前**不画**它。 */
rotation?: number[];
/** 页面指定的皮肤(`spine.skin`);`animation` 字段本来就在上面。 */
skin?: string;
timeScale?: number;
color?: number;
}[];
};
[key: string]: unknown;
}
/** 内存里的一份壁纸(已读盘、已校验)。 */
export interface Wallpaper {
id: string;
gameId: string;
/** 相对仓库根的源目录,如 wallpapers/hsr/xilian。 */
srcRel: string;
meta: WallpaperMeta;
preset: PresetTemplate;
game: GameMeta;
/**
* meta.json 里的原始音频声明(id 还没分配)。
* `defaultIndex` 已经是 **0 起**的数组下标,把"1 起"的换算收在 vault 里一处。
*/
audioDecl: { defaultIndex: number; choices: AudioChoiceSpec[] };
/** 该档壁纸的音频清单:id 已由 build 分配,文件路径已解析成相对**分发内壁纸目录**的形式。 */
audioChoices: AudioChoice[];
}
/** 内存里的一份游戏。 */
export interface Game {
id: string;
meta: GameMeta;
/** 相对仓库根的源目录,如 wallpapers/hsr。 */
srcRel: string;
wallpapers: Wallpaper[];
/** meta.audios 分配 id 之后的结果(读盘时还没有 id,见 readVault)。 */
sharedAudio: AudioChoice[];
}
/**
* 分发类型:**单档**与**合集**两种并列。
*
* 「游戏合集」与「全部合集」都是合集,区别只在 `CollectionScope`——它们不是第三种类型。
* (术语见 CONTEXT.md:单档 / 合集。)
*/
export type ReleaseType = "single" | "collection";
/** 合集的收档范围:一个游戏,还是所有游戏。只有 `type === "collection"` 时有意义。 */
export type CollectionScope = "game" | "all";
/** 一次构建里的一个分发目标。 */
export interface Release {
type: ReleaseType;
/** 合集的收档范围(单档分发没有它)。 */
scope?: CollectionScope;
/** 稳定的分发 id:单档用壁纸 id,游戏合集用游戏 id,全部合集用 "all"。 */
id: string;
/** 目录名(ASCII,带类型前缀,避免"合集"与"单档"混淆)。 */
dir: string;
/** 该分发包含的壁纸(single 恰含一个)。 */
wallpapers: Wallpaper[];
/** 展示名,写进 dist-map.json 供 dev 索引页使用。 */
displayName: string;
/** 该分发的发布文案(单档分发直接取壁纸自己的 meta)。 */
meta: { title: string; description: string; preview?: string; workshopid?: string; workshopurl?: string };
/** 上面那份 meta 所在的目录(相对仓库根):preview 指向的文件从它旁边取。 */
metaRel: string;
/**
* 该分发"合集根共享音频"清单(相对合集根的文件名)。
* 单档分发恒为空——非合集分发不出现共享音频。
*/
sharedAudio: AudioChoice[];
}
/** dist-map.json:分发清单,也是 dev 服与校验脚本的唯一索引。 */
export interface DistMap {
version: string;
builtAt: string;
releases: {
dir: string;
type: ReleaseType;
/** 合集的收档范围(单档分发省略)。 */
scope?: CollectionScope;
id: string;
displayName: string;
/** 该分发里每档壁纸的 id 与其在目录内的相对路径。 */
wallpapers: { id: string; gameId: string; dir: string; name: string }[];
/** 该分发的默认预设 id(写进 scripts/presets.js)。 */
defaultPresetId: string;
/** 发布产物字节数(**不含** sim/)。 */
bytes: number;
/** 自包含包 sim/ 的字节数;没跑 --sim 时不存在。 */
simBytes?: number;
}[];
}
export interface BuildOptions {
singles: string[];
collect: "all" | string[] | null;
/**
* `pnpm build single`(不给 id)= 只编全部单档,不编合集。
*
* 与"什么都不选"(= 全编)必须区分开:单档子命令的语义是"范围=单档",
* 而不是"没指定所以全都要"。用一个显式标记而不是靠 singles 为空来判断。
*/
onlySingles?: boolean;
/**
* `pnpm build collect`(不给参数)= 全部合集:每个游戏的合集 + 「全部壁纸合集」。
*
* 与 `collect: "all"` 区分开:后者是 `--collect all`,只编「全部壁纸合集」一项。
* 这两个语义确实容易混,但都是既有行为,改动其中一个会悄悄改变发布范围。
*/
allCollections?: boolean;
strict: boolean;
clean: boolean;
dryRun: boolean;
/** 额外产出双击可看的自包含包(<分发根>/sim/index.html)。 */
standalone?: boolean;
/** 自包含包里是否内联音频。 */
embedAudio?: boolean;
}
+252
View File
@@ -0,0 +1,252 @@
// 把 wallpapers/ 读成内存里的领域对象,并在读的过程中把所有不变量校验掉。
//
// 校验失败一律 fail-fast:宁可 build 报错,也不要产出一个"能上传但跑不对"的分发目录。
import { join } from "node:path";
import { abs, isFile, listDirs, readJson } from "./fs.ts";
import type {
AudioChoice,
AudioChoiceSpec,
Game,
GameMeta,
GlobalMeta,
Wallpaper,
WallpaperMeta,
PresetTemplate,
} from "./types.ts";
/** CEF(WE 内置的 Chromium 146)实际能解的音频扩展名白名单。
* 实测其 FFmpeg 构建含 vorbis/libopus/flac/mp3/pcm_*,**不含 aac**,所以 m4a 直接拒。 */
const AUDIO_EXT = new Set([".flac", ".mp3", ".ogg", ".opus", ".wav"]);
/** 游戏目录与壁纸目录里的保留名:不允许被当作 id 使用。 */
const RESERVED = new Set(["audios", "meta.json", "preview.gif", "preset.template.json"]);
export class VaultError extends Error {}
function fail(message: string): never {
throw new VaultError(message);
}
function assert(condition: unknown, message: string): asserts condition {
if (!condition) fail(message);
}
/** 音频文件必须是白名单内的扩展名,且不许出现在上级目录(分发时会被搬走)。 */
function checkAudioFile(file: string, where: string): string {
const normalized = file.replace(/^\.\//, "");
assert(!normalized.startsWith("../"), `${where}: 音频 ${file} 不得引用上级目录(build 会把它放到分发目录内部)`);
assert(!normalized.includes("\\"), `${where}: 音频路径 ${file} 必须用 / 分隔`);
const dot = normalized.lastIndexOf(".");
assert(dot > 0, `${where}: 音频 ${file} 没有扩展名`);
const ext = normalized.slice(dot).toLowerCase();
assert(
AUDIO_EXT.has(ext),
`${where}: 音频 ${file} 的扩展名 ${ext} 不在 CEF 白名单内(${[...AUDIO_EXT].join(" ")});m4a/aac 在 WE 里解不了`,
);
return normalized;
}
/**
* 读一份音频声明:只校验显示名与文件,**不校验 id**——id 已经不由人写。
* 也接受裸数组(游戏级共享音频就是数组)。
*
* `default` 是 **1 起**的位置,这里换算成 0 起的 `defaultIndex`:
* 换算只做这一处,下游拿到的永远是能直接下标的东西。
*/
function readAudioDecl(
audio: { default?: number; choices?: AudioChoiceSpec[] } | AudioChoiceSpec[] | undefined,
where: string,
): { defaultIndex: number; choices: AudioChoiceSpec[] } {
const normalized = Array.isArray(audio) ? { choices: audio } : audio;
if (!normalized) fail(`${where}: 缺少 audio 声明`);
assert(Array.isArray(normalized.choices), `${where}: audio.choices 必须是数组`);
const choices: AudioChoiceSpec[] = normalized.choices.map((choice, i) => {
assert(typeof choice.name === "string" && choice.name.length > 0, `${where}: 第 ${i + 1} 条音源缺少显示名`);
return { name: choice.name, file: checkAudioFile(choice.file, where) };
});
let defaultIndex = 0;
if (normalized.default !== undefined) {
assert(Number.isInteger(normalized.default), `${where}: audio.default 必须是整数位置(1 起)`);
assert(
normalized.default >= 1 && normalized.default <= choices.length,
`${where}: audio.default ${normalized.default} 超出范围(共 ${choices.length} 条,位置从 1 起)`,
);
defaultIndex = normalized.default - 1;
}
return { defaultIndex, choices };
}
/**
* 音源 id 的分配器。
*
* **必须是全项目一个计数器**,不能每档壁纸各从 1 开始:`bgm` 下拉会把一个分发里所有壁纸的
* 音源平铺进同一个 combo,两档都叫 "1" 的话选中的到底是哪个就无从分辨。
* 分配顺序固定(游戏 → 该游戏的共享音频 → 各壁纸的音源),所以同一个音源在任何分发里
* 拿到的 id 都一样。
*/
function createAudioIdAllocator(): () => string {
let next = 0;
return () => String((next += 1));
}
/** 读一份壁纸(含其所属游戏与全局元数据)。 */
async function readWallpaper(gameId: string, wallpaperId: string, game: GameMeta): Promise<Wallpaper> {
const srcRel = `wallpapers/${gameId}/${wallpaperId}`;
const srcAbs = abs(srcRel);
const where = srcRel;
const metaPath = join(srcAbs, "meta.json");
const presetPath = join(srcAbs, "preset.template.json");
assert(await isFile(metaPath), `${where}: 缺少 meta.json`);
assert(await isFile(presetPath), `${where}: 缺少 preset.template.json`);
const meta = await readJson<WallpaperMeta>(metaPath);
const preset = await readJson<PresetTemplate>(presetPath);
assert(meta.id === wallpaperId, `${where}: meta.json 的 id "${meta.id}" 与目录名 "${wallpaperId}" 不一致`);
assert(!RESERVED.has(meta.id), `${where}: id "${meta.id}" 是保留名`);
assert(/^[a-z0-9][a-z0-9_-]*$/i.test(meta.id), `${where}: id "${meta.id}" 只能用字母数字与 _-(它会进 WE 的 combo value)`);
assert(typeof meta.name === "string" && meta.name.length > 0, `${where}: 缺少显示名 name`);
assert(typeof meta.title === "string" && meta.title.length > 0, `${where}: 缺少 title(会进 project.json)`);
assert(typeof meta.description === "string" && meta.description.length > 0, `${where}: 缺少 description`);
if (meta.preview !== undefined) {
assert(await isFile(join(srcAbs, meta.preview)), `${where}: preview "${meta.preview}" 不存在`);
}
const audioDecl = readAudioDecl(meta.audio, where);
for (const choice of audioDecl.choices) {
assert(await isFile(join(srcAbs, choice.file)), `${where}: 音源 "${choice.name}" 指向的 ${choice.file} 不存在`);
}
// 运行时配置里的资源路径也要存在——这是"运行时才会暴露的拼写错误"的唯一静态防线。
const spineConfig = preset.spineConfig;
const sceneConfig = preset.sceneConfig;
assert(
Boolean(spineConfig) !== Boolean(sceneConfig),
`${where}: preset.template.json 必须**二选一**地写 spineConfig(单骨架)或 sceneConfig(场景)`,
);
const resourcePaths: [string, string | undefined][] = [["backgroundImage", preset.backgroundImage]];
if (spineConfig) {
resourcePaths.push(["spineConfig.jsonUrl", spineConfig.jsonUrl], ["spineConfig.atlasUrl", spineConfig.atlasUrl]);
}
if (sceneConfig) {
assert(Array.isArray(sceneConfig.parts) && sceneConfig.parts.length > 0, `${where}: sceneConfig.parts 不能为空`);
sceneConfig.parts.forEach((part, index) => {
const at = `sceneConfig.parts[${index}](${part.kind} ${part.id})`;
assert(
part.kind === "spine" || part.kind === "image" || part.kind === "solid",
`${where}: ${at} 的 kind 只能是 spine/image/solid`,
);
assert(typeof part.order === "number", `${where}: ${at} 缺少 order(绘制层级)`);
if (part.kind === "spine") {
resourcePaths.push([`${at}.jsonUrl`, part.jsonUrl], [`${at}.atlasUrl`, part.atlasUrl]);
} else if (part.kind === "image") {
resourcePaths.push([`${at}.image`, part.image]);
}
});
}
for (const [key, value] of resourcePaths) {
assert(typeof value === "string" && value.length > 0, `${where}: preset.template.json 的 ${key} 缺失`);
const relPath = value.replace(/^\.\//, "");
assert(await isFile(join(srcAbs, relPath)), `${where}: preset.template.json 的 ${key} 指向的 ${relPath} 不存在`);
}
// audioChoices 先留空:id 要等所有游戏都读完才能按固定顺序分配(见 readVault)。
return { id: wallpaperId, gameId, srcRel, meta, preset, game, audioDecl, audioChoices: [] };
}
/** 读一个游戏目录。 */
async function readGame(gameId: string): Promise<Game> {
const srcRel = `wallpapers/${gameId}`;
const meta = await readJson<GameMeta>(join(abs(srcRel), "meta.json"));
assert(meta.id === gameId, `${srcRel}: meta.json 的 id "${meta.id}" 与目录名 "${gameId}" 不一致`);
assert(/^[a-z0-9][a-z0-9_-]*$/i.test(meta.id), `${srcRel}: 游戏 id 只能用字母数字与 _-`);
assert(typeof meta.name === "string" && meta.name.length > 0, `${srcRel}: 缺少显示名 name`);
// 游戏合集的文案就来自这一层,**不回落全局 meta**——全局那层是给「全部合集」写的,回落正是
// collection-ys 顶着《崩坏:星穹铁道》昔涟的原因(见 .scratch/build-pipeline/issues/20)。
assert(typeof meta.title === "string" && meta.title.length > 0, `${srcRel}: 缺少 title(游戏合集的创意工坊标题)`);
assert(
meta.description === undefined || (typeof meta.description === "string" && meta.description.length > 0),
`${srcRel}: description 要么不写,要么非空(缺省时由构建按「共 N 档」生成)`,
);
// 游戏级共享音频:只在「合集」类分发里被使用,允许为空(本仓库当前就是空的)。
readAudioDecl(meta.audios ?? [], `${srcRel} 的共享音频`);
const wallpaperIds = await listDirs(abs(srcRel), ["audios"]);
assert(wallpaperIds.length > 0, `${srcRel}: 没有任何壁纸目录`);
const wallpapers = [];
for (const id of wallpaperIds) wallpapers.push(await readWallpaper(gameId, id, meta));
// sharedAudio 同样等 readVault 分配 id。
return { id: gameId, meta, srcRel, wallpapers, sharedAudio: [] };
}
export interface Vault {
global: GlobalMeta;
games: Game[];
/** 所有壁纸,按 游戏 → 壁纸 的声明顺序。 */
wallpapers: Wallpaper[];
}
/** 读取整棵资源库,并校验全局唯一性。 */
export async function readVault(): Promise<Vault> {
const global = await readJson<GlobalMeta>(abs("wallpapers/meta.json"));
assert(typeof global.name === "string" && global.name.length > 0, "wallpapers/meta.json: 缺少 name");
assert(typeof global.title === "string" && global.title.length > 0, "wallpapers/meta.json: 缺少 title");
assert(typeof global.description === "string" && global.description.length > 0, "wallpapers/meta.json: 缺少 description");
const gameIds = await listDirs(abs("wallpapers"), ["audios", "meta.json", "preview.gif"]);
assert(gameIds.length > 0, "wallpapers/: 没有任何游戏目录");
const games: Game[] = [];
for (const id of gameIds) games.push(await readGame(id));
// 全局唯一性:WE 的 combo value 是平铺的,跨游戏撞 id 会让后一档静默覆盖前一档。
const byId = new Map<string, string>();
const wallpapers: Wallpaper[] = [];
for (const game of games) {
for (const wallpaper of game.wallpapers) {
const previous = byId.get(wallpaper.id);
assert(
previous === undefined,
`壁纸 id "${wallpaper.id}" 在 ${previous} 与 ${wallpaper.srcRel} 中重复。` +
`id 必须是全局唯一的 WE combo value(老用户设置靠它,不能自动加前缀绕过),请改其中一个。`,
);
byId.set(wallpaper.id, wallpaper.srcRel);
wallpapers.push(wallpaper);
}
}
// 到这里所有游戏都读完了,再统一分配音源 id。
//
// 顺序固定为「游戏 → 该游戏的共享音频 → 各壁纸的音源」,且遍历的是**整棵资源库**
// 而不是本次要构建的分发子集——否则同一个音源在不同分发里会拿到不同的 id,
// 用户从合集切到单档时 bgm 选择就失效了。
const nextAudioId = createAudioIdAllocator();
for (const game of games) {
game.sharedAudio = (game.meta.audios ?? []).map((choice) => ({
id: nextAudioId(),
name: choice.name,
file: checkAudioFile(choice.file, `${game.srcRel} (共享音频)`),
}));
for (const wallpaper of game.wallpapers) {
wallpaper.audioChoices = wallpaper.audioDecl.choices.map((choice) => ({
id: nextAudioId(),
name: choice.name,
file: choice.file,
}));
}
}
return { global, games, wallpapers };
}
/** 该游戏在合集根共享音频里声明的音源(id 已在 readVault 里分配)。 */
export function sharedAudioOf(game: Game): AudioChoice[] {
return game.sharedAudio;
}
+270
View File
@@ -0,0 +1,270 @@
// 预览 `tools/downloader/_out/` 里的 staging:把一个**场景目录**组装成能跑的页面并起服务。
//
// 为什么需要它:`_cache/` 只是**文本**缓存(原始 bundle 文本,引用仍是线上绝对地址、贴图不在里面),
// 不可预览;`_out/` 是真实文件树,但 `serve.mjs` 只会按 URL 逐个取文件——没有索引、也不会画场景。
//
// staging 的形状是「一个场景 = 一个可直接搬走的壁纸目录」(见 tools/downloader/README.md),
// 所以预览的粒度也是场景,不是页面。
//
// 用法:
// node tools/preview.mts # 列出 _out 里有哪些场景可预览
// node tools/preview.mts ys/nico-tea # 页面 = 取它的推荐场景
// node tools/preview.mts ys/nico-tea/scene_main # 点名场景
// node tools/preview.mts hsr/kv45/scene_ava --port 8199
// node tools/preview.mts ys/nico-tea --no-serve # 只组装到 tools/.cache/preview/
//
// 与 tools/checks/verify-scene-player.mts 的关系:那边也有一份「从 scene.json 生成预设」的代码,
// **故意不共用**——门必须独立于被验证对象,共用一份就变成"自己验自己"。
import { createServer } from "node:http";
import { createReadStream, existsSync } from "node:fs";
import { cp, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
import { spawnSync } from "node:child_process";
import { dirname, extname, join, normalize, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const OUT = join(ROOT, "tools", "downloader", "_out");
const PREVIEW = join(ROOT, "tools", ".cache", "preview");
const args = process.argv.slice(2);
const portFlag = args.indexOf("--port");
const port = portFlag >= 0 ? Number(args[portFlag + 1]) : 8196;
const noServe = args.includes("--no-serve");
const target = args.find((a) => !a.startsWith("--") && !/^\d+$/.test(a));
const MIME: Record<string, string> = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".json": "application/json; charset=utf-8",
".atlas": "text/plain; charset=utf-8",
".png": "image/png",
".jpg": "image/jpeg",
".webp": "image/webp",
};
interface Part {
kind: string;
id: string;
order: number;
renderOrder?: number;
position: number[];
scale: number[];
rotation?: number[];
geometrySize?: number[];
geometryCenter?: number[];
animation?: string;
skin?: string;
timeScale?: number;
}
/** 场景目录的判据:有 `preset.template.json`(目录名是转义过的场景 id,不能靠名字认)。 */
const isSceneDir = (dir: string): boolean => existsSync(join(dir, "preset.template.json"));
/** 列出 _out 下所有「游戏/页面/场景」。 */
async function listScenes(): Promise<string[]> {
if (!existsSync(OUT)) return [];
const out: string[] = [];
for (const game of await readdir(OUT, { withFileTypes: true })) {
if (!game.isDirectory()) continue;
for (const page of await readdir(join(OUT, game.name), { withFileTypes: true })) {
if (!page.isDirectory()) continue;
for (const scene of await readdir(join(OUT, game.name, page.name), { withFileTypes: true })) {
if (!scene.isDirectory()) continue;
if (isSceneDir(join(OUT, game.name, page.name, scene.name))) {
out.push(`${game.name}/${page.name}/${scene.name}`);
}
}
}
}
return out.sort();
}
/** 把「游戏/页面」或「游戏/页面/场景」解析成具体的场景目录路径。 */
async function resolveScene(rel: string): Promise<string | null> {
const parts = rel.split("/").filter(Boolean);
if (parts.length >= 3) {
const dir = join(OUT, ...parts);
return isSceneDir(dir) ? dir : null;
}
if (parts.length !== 2) return null;
const pageDir = join(OUT, ...parts);
if (!existsSync(pageDir)) return null;
const scenes = (await readdir(pageDir, { withFileTypes: true }))
.filter((entry) => entry.isDirectory() && isSceneDir(join(pageDir, entry.name)))
.map((entry) => entry.name)
.sort();
if (scenes.length === 0) return null;
let chosen = scenes[0]!;
const pageFile = join(pageDir, "page.json");
if (existsSync(pageFile)) {
const page = JSON.parse(await readFile(pageFile, "utf8")) as { chosenDir?: string; chosenScene?: string };
const wanted = page.chosenDir ?? page.chosenScene;
if (wanted && scenes.includes(wanted)) chosen = wanted;
}
return join(pageDir, chosen);
}
if (!target) {
const scenes = await listScenes();
console.log(`_out 下有 ${scenes.length} 个场景可预览:\n`);
for (const s of scenes) console.log(` node tools/preview.mts ${s}`);
console.log(`\n(「游戏/页面」会取该页的推荐场景;_cache/ 是文本调试缓存,不可预览)`);
process.exit(0);
}
const staged = await resolveScene(target);
if (staged === null) {
console.error(`没有这个场景目录:${target}(用 node tools/preview.mts 看有哪些)`);
process.exit(2);
}
const rel = staged.slice(OUT.length + 1).replace(/\\/g, "/");
// 1) 运行时要有产物:缺了就编一次
if (!existsSync(join(ROOT, "build", "scripts", "scene-controller.js"))) {
console.log("build/scripts/scene-controller.js 不存在,先编译运行时…");
const build = spawnSync(process.execPath, ["node_modules/typescript/bin/tsc", "-p", "tsconfig.runtime.json"], {
cwd: ROOT,
encoding: "utf8",
});
if (build.status !== 0) {
console.error("编译失败:", (build.stdout ?? "") + (build.stderr ?? ""));
process.exit(1);
}
}
// 2) 组装:运行时脚本 + 样式 + staging 资产 + 由 scene.json 生成的预设
const dir = join(PREVIEW, rel.replace(/\//g, "__"));
await rm(dir, { recursive: true, force: true });
await mkdir(join(dir, "scripts"), { recursive: true });
await mkdir(join(dir, "styles"), { recursive: true });
await cp(staged, join(dir, "assets"), { recursive: true });
for (const f of await readdir(join(ROOT, "build", "scripts"))) {
if (f.endsWith(".js")) await cp(join(ROOT, "build", "scripts", f), join(dir, "scripts", f));
}
await cp(join(ROOT, "src", "vendor", "spine-player.js"), join(dir, "scripts", "spine-player.js"));
for (const f of await readdir(join(ROOT, "src", "styles"))) {
await cp(join(ROOT, "src", "styles", f), join(dir, "styles", f));
}
const scene = JSON.parse(await readFile(join(staged, "scene.json"), "utf8")) as {
id: string;
ui?: number[];
camera?: { camera?: { type?: number; fov?: number }; position?: number[] };
parts: Part[];
};
const parts: string[] = [];
for (const part of scene.parts) {
if (part.kind === "solid") continue; // 运行时画不了纯色平面(没有贴图),别写进预设
const fields: string[] = [
`kind: ${JSON.stringify(part.kind)}`,
`id: ${JSON.stringify(part.id)}`,
`order: ${JSON.stringify(part.order)}`,
`position: ${JSON.stringify(part.position)}`,
`scale: ${JSON.stringify(part.scale)}`,
];
if (part.renderOrder) fields.push(`renderOrder: ${JSON.stringify(part.renderOrder)}`);
if (part.rotation && part.rotation.some((v) => Math.abs(v) > 1e-9)) {
fields.push(`rotation: ${JSON.stringify(part.rotation)}`);
}
if (part.geometrySize) {
fields.push(`width: ${JSON.stringify(part.geometrySize[0])}`);
fields.push(`height: ${JSON.stringify(part.geometrySize[1])}`);
}
if (part.geometryCenter) fields.push(`center: ${JSON.stringify(part.geometryCenter)}`);
if (part.animation) fields.push(`animation: ${JSON.stringify(part.animation)}`);
if (part.skin) fields.push(`skin: ${JSON.stringify(part.skin)}`);
if (part.timeScale !== undefined) fields.push(`timeScale: ${JSON.stringify(part.timeScale)}`);
if (part.kind === "spine") {
fields.push(`jsonUrl: asset("../assets/spines/${part.id}/${part.id}.json")`);
fields.push(`atlasUrl: asset("../assets/spines/${part.id}/${part.id}.atlas")`);
} else {
const files = existsSync(join(staged, "scene")) ? await readdir(join(staged, "scene")) : [];
const hit = files.find((f) => f.startsWith(`${part.id}.`));
if (!hit) continue;
fields.push(`image: asset("../assets/scene/${hit}")`);
}
parts.push(` { ${fields.join(", ")} },`);
}
const camera = scene.camera?.camera ?? {};
const cameraLine =
camera.type === undefined
? ""
: ` camera: ${JSON.stringify({ type: camera.type, fov: camera.fov, position: scene.camera?.position })},\n`;
await writeFile(
join(dir, "scripts", "scene-preset.js"),
[
`const base = new URL("./", import.meta.url);`,
`const asset = (path) => new URL(path, base).href;`,
`export default {`,
` id: "preview",`,
` name: ${JSON.stringify(rel)},`,
` game: "preview",`,
` backgroundImage: "",`,
` sceneConfig: {`,
` ui: ${JSON.stringify(scene.ui ?? [2500, 1080])},`,
cameraLine + ` parts: [`,
...parts,
` ],`,
` },`,
` audioChoices: [],`,
` audioOptions: { source: "" },`,
`};`,
``,
].join("\n"),
"utf8",
);
await writeFile(
join(dir, "scripts", "presets.js"),
`import preset from "./scene-preset.js";\nexport const defaultPresetId = "preview";\nexport default Object.fromEntries([["preview", preset]]);\n`,
"utf8",
);
await writeFile(
join(dir, "index.html"),
[
`<!DOCTYPE html>`,
`<html lang="zh"><head><meta charset="UTF-8" /><title>preview ${rel}</title>`,
`<link rel="icon" href="data:," />`,
`<link rel="stylesheet" href="./styles/spine-player.css" />`,
`<link rel="stylesheet" href="./styles/index.css" /></head>`,
`<body><div id="spine-container"></div><audio id="background-music"></audio>`,
`<script src="./scripts/spine-player.js"></script>`,
`<script type="module" src="./scripts/index.js"></script></body></html>`,
``,
].join("\n"),
"utf8",
);
console.log(
`已组装 ${rel} → ${dir}\n 骨架 ${parts.filter((p) => p.includes('"spine"')).length} 件、` +
`平面 ${parts.filter((p) => p.includes('"image"')).length} 件`,
);
if (noServe) process.exit(0);
// 3) 起服务(MIME 要认 .atlas,否则骨架读不到 atlas 文本)
const server = createServer(async (req, res) => {
const pathname = decodeURIComponent(new URL(req.url ?? "/", "http://x").pathname);
const path = normalize(pathname).replace(/^([/\\])+/, "");
const file = resolve(join(dir, path === "" ? "index.html" : path));
if (!file.startsWith(dir)) {
res.writeHead(403).end();
return;
}
try {
const info = await stat(file);
if (info.isDirectory()) {
res.writeHead(404).end();
return;
}
res.writeHead(200, { "content-type": MIME[extname(file).toLowerCase()] ?? "application/octet-stream" });
createReadStream(file).pipe(res);
} catch {
res.writeHead(404).end();
}
});
server.listen(port, "127.0.0.1", () => {
console.log(`\n预览: http://127.0.0.1:${port}/`);
console.log(`调试信息在页面里: window.__sceneDebug(parts / loaded / errors / framing / rotatedSkipped)`);
console.log(`停止: Ctrl+C\n`);
});
-85
View File
@@ -1,85 +0,0 @@
// 量出「立绘在屏幕上的实际足迹」:正常帧与藏 canvas 参考帧的差值掩码。
// 不依赖对视口/相机数学的任何建模——直接测渲染结果。
// 输出:足迹包围盒、四条边是否被画布切掉、覆盖面积占比、足迹底边距画布底部的距离。
// 用法:node tools/subject.mjs <normal.png> <ref.png> [threshold] [edgeBand]
import { spawn } from "node:child_process";
const [normal, ref, threshold = "8", edgeBand = "2"] = process.argv.slice(2);
if (!normal || !ref) {
console.error("usage: node tools/subject.mjs <normal.png> <ref.png> [threshold] [edgeBand]");
process.exit(2);
}
const T = Number(threshold);
const BAND = Number(edgeBand);
function probe(file) {
return new Promise((resolve) => {
const p = spawn("ffprobe", ["-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height", "-of", "csv=p=0", file]);
let out = "";
p.stdout.on("data", (d) => (out += d));
p.on("close", () => {
const [w, h] = out.trim().split(",").map(Number);
resolve({ w, h });
});
});
}
const { w: W, h: H } = await probe(normal);
const buf = await new Promise((resolve, reject) => {
const p = spawn("ffmpeg", ["-v", "error", "-i", normal, "-i", ref, "-lavfi", "blend=all_mode=difference,format=gray", "-f", "rawvideo", "-pix_fmt", "gray", "-"], {
stdio: ["ignore", "pipe", "pipe"],
});
const chunks = [];
let err = "";
p.stdout.on("data", (d) => chunks.push(d));
p.stderr.on("data", (d) => (err += d));
p.on("close", (code) => (code === 0 ? resolve(Buffer.concat(chunks)) : reject(new Error(err || `exit ${code}`))));
});
if (buf.length < W * H) {
console.error(`差分流只返回 ${buf.length} 字节,期望 ${W * H}`);
process.exit(1);
}
let minX = W, maxX = -1, minY = H, maxY = -1, count = 0;
const edge = { left: 0, right: 0, top: 0, bottom: 0 };
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
if (buf[y * W + x] <= T) continue;
count++;
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
if (x < BAND) edge.left++;
if (x >= W - BAND) edge.right++;
if (y < BAND) edge.top++;
if (y >= H - BAND) edge.bottom++;
}
}
if (!count) {
console.log(`${normal} 没有超过阈值 ${T} 的像素——立绘层是空的?`);
process.exit(1);
}
const pct = (n) => ((n / (W * H)) * 100).toFixed(3) + "%";
console.log(`${normal} (${W}x${H}, 阈值 ${T})`);
console.log(` 足迹包围盒 x:[${minX}, ${maxX}] y:[${minY}, ${maxY}] 尺寸 ${maxX - minX + 1}x${maxY - minY + 1}`);
console.log(` 覆盖面积 ${count} 像素 (${pct(count)})`);
console.log(` 底部余量 画布底 ${H} - 足迹底 ${maxY} = ${H - 1 - maxY} px`);
console.log(` 顶部余量 ${minY} px`);
console.log(` 左右余量 左 ${minX} px / 右 ${W - 1 - maxX} px`);
console.log(
` 贴边情况 ${[
edge.left ? `左 ${edge.left}` : "左 -",
edge.right ? `右 ${edge.right}` : "右 -",
edge.top ? `上 ${edge.top}` : "上 -",
edge.bottom ? `下 ${edge.bottom}` : "下 -",
].join(" ")} (${BAND}px 带内)`,
);
const cut = edge.left || edge.right || edge.top || edge.bottom;
console.log(` 判定 ${cut ? "被画布边缘切到" : "四边都未触边"}`);
File diff suppressed because it is too large. Load diff