feat: TS 构建管线、Spine 抓取器与 wallpapers/ 唯一真相来源
把项目从「手写 dist/」改成「wallpapers/ 是唯一真相来源,dist/ 由 pnpm build 生成」,
并补上配套的类型、门禁与抓取器。一次提交落地整条管线,因为拆开会留下不能构建的中间态。
- src/:运行时与模拟器源码(TS,strict),编译到 build/ 再拷进各分发
- tools/:build / dev / check-{syntax,paths,dist},以及抓取器与回归门禁 tools/checks/
(.scratch/ 下那批一次性脚本移入 tools/checks/ 并入库为长期门禁)
- wallpapers/:七档壁纸的源数据 + README.md(id/音频/预设的完整规范)
- docs/adr/0005-0008:构建管线与分发拓扑、模拟器契约、自包含 sim、每骨架资源布局
- .gitignore:排除 .scratch/ 的参考资料副本(上游 spine 整仓克隆 ~1.2 GB、
抓取侦查数据 ~680 MB)与调试转储;这些是本地调查材料,补偿会让仓库无法克隆
- 归一化 .gitignore/CONTEXT.md 行尾(工作区 CRLF、索引 LF 造成的整文件假 diff)
同时修掉三档卡住构建的未完工壁纸:
- kv45 的 meta.json 里 id 还是抓取期场景名 scene_main,经 downloader promote 正名为 kv45
- shajin / zhigengniao_juheye 的 meta.json 误用了骨架描述文件(name/spine/animations/pages)、
且都缺 preset.template.json;现按规范重建:骨架沉到 spines/<名>/(spine-ts 按 atlas 所在
目录解析贴图页)、补上元数据与单骨架预设,并清掉 zhigengniao 骨架里指向作者机的绝对路径
- 顺带 promote 已在 sources.yml 里的 kv46(月升之前,与兽共舞)
pnpm check 五道门全绿:10 个分发 / 7 档壁纸 / 141 处引用自包含。
This commit is contained in:
1 parent
b8eee05d78
commit
3f11426964
297 files changed
+216627
-1926
No files matched your search
-109
@@ -1,109 +0,0 @@
|
||||
// 把差分掩码(立绘层 = 正常帧 − 藏 canvas 参考帧)压成 ASCII,
|
||||
// 这样在没有图像输入能力的情况下也能"看"清构图。
|
||||
// 用法:
|
||||
// node tools/ascii.mjs <normal.png> <ref.png> [cols] 差分两幅,打印强度图 + 实体占比图
|
||||
// node tools/ascii.mjs <image.png> [cols] 单幅,打印亮度图
|
||||
// 行数按 2.2:1 的字符高宽比自动换算,保证画面不被压扁。
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
const flags = process.argv.slice(2).filter((a) => a.startsWith("--"));
|
||||
const [fileA, fileB, colsArg] = process.argv.slice(2).filter((a) => !a.startsWith("--"));
|
||||
const solidOnly = flags.includes("--solid");
|
||||
if (!fileA) {
|
||||
console.error("usage: node tools/ascii.mjs <image.png> [ref.png] [cols]");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
function probe(file) {
|
||||
return new Promise((resolve) => {
|
||||
const p = spawn("ffprobe", ["-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height", "-of", "csv=p=0", file]);
|
||||
let out = "";
|
||||
p.stdout.on("data", (d) => (out += d));
|
||||
p.on("close", () => {
|
||||
const [w, h] = out.trim().split(",").map(Number);
|
||||
resolve({ w, h });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function gray(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const p = spawn("ffmpeg", ["-v", "error", "-i", file, "-f", "rawvideo", "-pix_fmt", "gray", "-"], { stdio: ["ignore", "pipe", "pipe"] });
|
||||
const chunks = [];
|
||||
let err = "";
|
||||
p.stdout.on("data", (d) => chunks.push(d));
|
||||
p.stderr.on("data", (d) => (err += d));
|
||||
p.on("close", (code) => (code === 0 ? resolve(Buffer.concat(chunks)) : reject(new Error(err || `exit ${code}`))));
|
||||
});
|
||||
}
|
||||
|
||||
const RAMP = " .:-=+*#%@";
|
||||
const { w: W, h: H } = await probe(fileA);
|
||||
const bufA = await gray(fileA);
|
||||
if (bufA.length < W * H) {
|
||||
console.error(`像素不足:${bufA.length} < ${W * H}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 终端字符高约为宽的 2.2 倍,据此把列数换算成行数,保持画面比例
|
||||
const cols = Math.max(20, Math.min(Number(colsArg) || 110, Math.floor((46 * 2.2 * W) / H)));
|
||||
const rows = Math.max(6, Math.round((cols * H) / W / 2.2));
|
||||
const cw = W / cols;
|
||||
const chh = H / rows;
|
||||
|
||||
function grid(fn) {
|
||||
const out = [];
|
||||
for (let r = 0; r < rows; r++) {
|
||||
let line = "";
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const x0 = Math.floor(c * cw);
|
||||
const x1 = Math.min(W, Math.ceil((c + 1) * cw));
|
||||
const y0 = Math.floor(r * chh);
|
||||
const y1 = Math.min(H, Math.ceil((r + 1) * chh));
|
||||
line += RAMP[Math.max(0, Math.min(9, fn(x0, x1, y0, y1)))];
|
||||
}
|
||||
out.push(line);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function print(title, lines) {
|
||||
console.log(title);
|
||||
for (const l of lines) console.log("|" + l + "|");
|
||||
console.log("");
|
||||
}
|
||||
|
||||
console.log(`画布 ${W}x${H} → ${cols}列 x ${rows}行,单元 ${cw.toFixed(1)}x${chh.toFixed(1)}px;ramp "${RAMP}"`);
|
||||
|
||||
if (!fileB) {
|
||||
print(`亮度图 ${fileA}`, grid((x0, x1, y0, y1) => {
|
||||
let s = 0;
|
||||
let n = 0;
|
||||
for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) { s += bufA[y * W + x]; n++; }
|
||||
return Math.round((s / n / 255) * 9);
|
||||
}));
|
||||
} else {
|
||||
const bufB = await gray(fileB);
|
||||
if (bufB.length < W * H) {
|
||||
console.error(`参考帧像素不足:${bufB.length} < ${W * H}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const d = new Uint8Array(W * H);
|
||||
for (let i = 0; i < W * H; i++) d[i] = Math.abs(bufA[i] - bufB[i]);
|
||||
|
||||
if (!solidOnly)
|
||||
print(`立绘层 · 平均强度 (${fileA} − ${fileB})`, grid((x0, x1, y0, y1) => {
|
||||
let s = 0;
|
||||
let n = 0;
|
||||
for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) { s += d[y * W + x]; n++; }
|
||||
return Math.round((s / n / 255) * 9);
|
||||
}));
|
||||
|
||||
print(`立绘层 · 实体占比 (>96)`, grid((x0, x1, y0, y1) => {
|
||||
let k = 0;
|
||||
let n = 0;
|
||||
for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) { if (d[y * W + x] > 96) k++; n++; }
|
||||
return Math.round((k / n) * 9);
|
||||
}));
|
||||
}
|
||||
+534
@@ -0,0 +1,534 @@
|
||||
// pnpm build —— 把所有分发目录编译成"能直接上传 Wallpaper Engine"的自包含作品。
|
||||
//
|
||||
// pnpm build 编全部(所有单档 + 所有游戏合集 + 全部合集)
|
||||
// pnpm build --single xilian [--single kv37] 编指定单档
|
||||
// pnpm build --collect hsr 编某游戏的合集
|
||||
// pnpm build --collect all 编全部合集
|
||||
// pnpm build --single xilian --collect all 组合:只编这两档
|
||||
//
|
||||
// --with-sim 顺带把可选的 WE 模拟器放进各分发的 scripts/(服务端调试用)
|
||||
// --sim 额外产出**双击就能看**的自包含包 <分发根>/sim/index.html(见 lib/bundle.ts)
|
||||
// --no-embed-audio --sim 时音频不内联(包小很多,但 file:// 下音频播不了)
|
||||
// --strict 把 warning 升级为 fail
|
||||
// --no-clean 保留本次未构建的旧分发目录(默认会删掉,保证产物不残留)
|
||||
// --dry-run 只打印计划,不落盘
|
||||
//
|
||||
// 产物拓扑(dist/releases/<dir>/)见 docs/adr/0005;自包含包见 docs/adr/0007。dist/ 完全可再生产,故不入库。
|
||||
|
||||
import { readFile, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
abs,
|
||||
copyFileTo,
|
||||
dirBytes,
|
||||
exists,
|
||||
listDirs,
|
||||
log,
|
||||
mb,
|
||||
readJson,
|
||||
resetDir,
|
||||
writeJson,
|
||||
writeText,
|
||||
} from "./lib/fs.ts";
|
||||
import { readVault, sharedAudioOf, VaultError, type Vault } from "./lib/vault.ts";
|
||||
import {
|
||||
copyCollectionAudio,
|
||||
copyWallpaperAssets,
|
||||
generatePresetIndex,
|
||||
generatePresetModule,
|
||||
generateProjectJson,
|
||||
releasePathOf,
|
||||
type GenerateContext,
|
||||
} from "./lib/generate.ts";
|
||||
import type { BuildOptions, DistMap, ProjectTemplate, Release } from "./lib/types.ts";
|
||||
import { SIMULATOR_DRIVER, SIMULATOR_URL_STATIC } from "./lib/drivers.ts";
|
||||
import { checkDist } from "./check-dist.ts";
|
||||
|
||||
const RELEASES_DIR = "dist/releases";
|
||||
|
||||
const HELP = `用法:
|
||||
pnpm build 编全部(所有单档 + 所有游戏合集 + 全部合集)
|
||||
pnpm build <壁纸id> […] 编指定壁纸(可多个)
|
||||
pnpm build single [壁纸id …] 只编单档(不给 id = 全部单档)
|
||||
pnpm build collect [游戏id …|all] 只编合集(不给参数 = 全部合集)
|
||||
pnpm build sim [single|collect|<壁纸id> …] 编全部 + 每档产出自包含包(双击即看,不需要 pnpm dev)
|
||||
--single <壁纸id> 等价于裸 id --collect <游戏id|all> 编某合集
|
||||
--with-sim 让分发自带 WE 模拟器面板(静态托管预览用,见下) --sim 产出双击可看的自包含包
|
||||
--no-embed-audio --sim 时音频不内联
|
||||
--strict 警告即失败 --no-clean 保留旧分发目录 --dry-run 只打印计划
|
||||
|
||||
三种产物,别搞混:
|
||||
pnpm build 干净产物 → 上传 Wallpaper Engine(不含模拟器,ADR 0006 第 1 条)
|
||||
pnpm build --with-sim 自带面板 → 丢给任意静态托管(GitHub Pages 等),不需要调试服
|
||||
pnpm build sim 自包含单文件 → 本地双击打开,不需要任何服务器`;
|
||||
|
||||
interface BuildCliOptions {
|
||||
options: BuildOptions;
|
||||
withSimulator: boolean;
|
||||
standalone: boolean;
|
||||
embedAudio: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 子命令解析。
|
||||
*
|
||||
* 三种子命令对应三种**意图**,而不是三个 flag:
|
||||
* `single` —— 我在调一档壁纸,只要它;
|
||||
* `collect` —— 我在调一个合集的组装(预设切换、共享音频);
|
||||
* `sim` —— 我要能双击打开的成品,每档都要一份。
|
||||
*
|
||||
* `sim` 后面还能再跟 `single`/`collect` 收窄范围(`pnpm build sim single kv37`)。
|
||||
* 不带子命令时保持原来的 flag 风格(`--single` / `--collect`),向后兼容。
|
||||
*/
|
||||
function parseArgs(argv: string[]): BuildCliOptions {
|
||||
const options: BuildOptions = { singles: [], collect: null, strict: false, clean: true, dryRun: false };
|
||||
let withSimulator = false;
|
||||
let standalone = false;
|
||||
let embedAudio = true;
|
||||
|
||||
// 先摘子命令。`sim` 后面可以再跟 `single`/`collect`/壁纸 id,所以用循环而不是单次判断。
|
||||
let start = 0;
|
||||
while (start < argv.length && !(argv[start] ?? "").startsWith("-")) {
|
||||
const word = argv[start] ?? "";
|
||||
if (word === "sim") {
|
||||
standalone = true;
|
||||
start += 1;
|
||||
} else if (word === "single") {
|
||||
// `pnpm build single kv37 xilian`:后面所有非 flag 的词都是壁纸 id
|
||||
start += 1;
|
||||
while (start < argv.length && !(argv[start] ?? "").startsWith("-")) {
|
||||
options.singles.push(argv[start] ?? "");
|
||||
start += 1;
|
||||
}
|
||||
// 不给 id 就是"全部单档",由 planReleases 的显式标记决定
|
||||
if (options.singles.length === 0) options.onlySingles = true;
|
||||
} else if (word === "collect") {
|
||||
start += 1;
|
||||
const games: string[] = [];
|
||||
while (start < argv.length && !(argv[start] ?? "").startsWith("-")) {
|
||||
games.push(argv[start] ?? "");
|
||||
start += 1;
|
||||
}
|
||||
// 无参 = 全部合集(含每个游戏的合集与「全部壁纸合集」)。
|
||||
// 这里**不能**用 `collect: "all"` 表示"全部合集"——那个值在 planReleases 里只加
|
||||
// 「全部壁纸合集」一项,会把 collection-hsr 漏掉。用一个显式标记走"逐游戏 + all"的路径。
|
||||
if (games.length === 0 || (games.length === 1 && games[0] === "all")) options.allCollections = true;
|
||||
else options.collect = games;
|
||||
} else {
|
||||
/**
|
||||
* 其余裸词一律当壁纸 id(`pnpm build kv37`、`pnpm build sim kv37`)。
|
||||
*
|
||||
* 子命令与壁纸 id 共用同一段位置,靠**词表**区分:`single`/`collect`/`sim` 是保留字,
|
||||
* 别的裸词都是 id。代价是"想编一个恰好叫 single 的壁纸"做不到——接受,
|
||||
* 因为 id 由我们命名且都是英文小写,真撞名时改 id 比改语法便宜。
|
||||
* id 不存在时由 vault 报错,那里能顺带列出所有可用 id,比"未知子命令"有用得多。
|
||||
*/
|
||||
options.singles.push(word);
|
||||
start += 1;
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = start; i < argv.length; i += 1) {
|
||||
const arg = argv[i] ?? "";
|
||||
if (arg === "--single") {
|
||||
const value = argv[++i];
|
||||
if (!value || value.startsWith("--")) throw new VaultError("--single 需要跟一个壁纸 id");
|
||||
options.singles.push(value);
|
||||
} else if (arg === "--collect") {
|
||||
const value = argv[++i];
|
||||
if (!value || value.startsWith("--")) throw new VaultError('--collect 需要跟一个游戏 id 或 "all"');
|
||||
if (value === "all") options.collect = "all";
|
||||
else options.collect = [...(Array.isArray(options.collect) ? options.collect : []), value];
|
||||
} else if (arg === "--with-sim") withSimulator = true;
|
||||
else if (arg === "--sim") standalone = true;
|
||||
else if (arg === "--no-embed-audio") embedAudio = false;
|
||||
else if (arg === "--strict") options.strict = true;
|
||||
else if (arg === "--no-clean") options.clean = false;
|
||||
else if (arg === "--dry-run") options.dryRun = true;
|
||||
else if (arg === "--help" || arg === "-h") {
|
||||
log(HELP);
|
||||
process.exit(0);
|
||||
} else if (arg.startsWith("--")) throw new VaultError(`未知参数:${arg}\n\n${HELP}`);
|
||||
else if (["sim", "single", "collect"].includes(arg)) {
|
||||
// 子命令写在 --flag 后面时会被当成壁纸 id。报错时直接点破顺序,
|
||||
// 否则那句"壁纸要写成 --single sim"会把人引到完全错误的方向。
|
||||
throw new VaultError(
|
||||
`子命令 ${arg} 必须写在所有 --flag **之前**:\n` +
|
||||
` pnpm build ${arg} --with-sim ✓\n` +
|
||||
` pnpm build --with-sim ${arg} ✗(这里的 ${arg} 会被当成壁纸 id)\n\n${HELP}`,
|
||||
);
|
||||
} else throw new VaultError(`未知参数:${arg}(壁纸要写成 --single ${arg})\n\n${HELP}`);
|
||||
}
|
||||
return { options, withSimulator, standalone, embedAudio };
|
||||
}
|
||||
|
||||
function normalizeGameId(value: string, vault: Vault): string {
|
||||
const wanted = value.trim().replace(/[:]/g, ":");
|
||||
const exact = vault.games.find((g) => g.id === wanted);
|
||||
if (exact) return exact.id;
|
||||
const byName = vault.games.find((g) => g.meta.name.replace(/[:]/g, ":") === wanted);
|
||||
if (byName) return byName.id;
|
||||
throw new VaultError(`找不到游戏 "${value}"。已有:${vault.games.map((g) => `${g.meta.id}(${g.meta.name})`).join("、")}`);
|
||||
}
|
||||
|
||||
/** 按子命令 / --single / --collect 求出本次要编的分发清单。无参 = 全编。 */
|
||||
function planReleases(vault: Vault, options: BuildOptions): Release[] {
|
||||
const releases: Release[] = [];
|
||||
// `single`(无 id)只编单档;`collect`(无参)只编合集;无参才是全编。
|
||||
// 三者都不能靠"singles 为空 + collect 为 null"来区分,所以各自有显式标记。
|
||||
const onlySingles = options.onlySingles === true;
|
||||
const onlyCollections = options.allCollections === true;
|
||||
const all = !onlySingles && !onlyCollections && options.singles.length === 0 && options.collect === null;
|
||||
|
||||
const addSingle = (wallpaperId: string): void => {
|
||||
const wallpaper = vault.wallpapers.find((w) => w.id === wallpaperId);
|
||||
if (!wallpaper) {
|
||||
throw new VaultError(
|
||||
`找不到壁纸 "${wallpaperId}"。已有:${vault.wallpapers.map((w) => `${w.id}(${w.meta.name})`).join("、")}`,
|
||||
);
|
||||
}
|
||||
releases.push({
|
||||
type: "single",
|
||||
id: wallpaper.id,
|
||||
// 带上游戏:`wallpaper-kv37` 这种名字在分不清来源的场合(多个游戏各有一档壁纸时)
|
||||
// 只能靠 id 全局唯一来兜底,而目录名本来就该自解释。与 collection-<游戏id> 同一套读法。
|
||||
dir: `single-${wallpaper.gameId}-${wallpaper.id}`,
|
||||
wallpapers: [wallpaper],
|
||||
displayName: `${wallpaper.game?.name ?? wallpaper.gameId} - ${wallpaper.meta.name}`,
|
||||
meta: {
|
||||
title: wallpaper.meta.title,
|
||||
description: wallpaper.meta.description,
|
||||
...(wallpaper.meta.preview ? { preview: wallpaper.meta.preview } : {}),
|
||||
...(wallpaper.meta.workshopid ? { workshopid: wallpaper.meta.workshopid } : {}),
|
||||
...(wallpaper.meta.workshopurl ? { workshopurl: wallpaper.meta.workshopurl } : {}),
|
||||
},
|
||||
metaRel: wallpaper.srcRel,
|
||||
sharedAudio: [],
|
||||
});
|
||||
};
|
||||
|
||||
const addGame = (gameId: string): void => {
|
||||
const game = vault.games.find((g) => g.id === gameId);
|
||||
if (!game) throw new VaultError(`内部错误:找不到游戏 ${gameId}`);
|
||||
releases.push({
|
||||
type: "collection",
|
||||
scope: "game",
|
||||
id: game.id,
|
||||
dir: `collection-${game.id}`,
|
||||
wallpapers: [...game.wallpapers],
|
||||
displayName: `${game.meta.name}合集`,
|
||||
meta: {
|
||||
// 文案取自**游戏级** meta(见 wallpapers/README.md 的三层分工),不回落到全局 title:
|
||||
// 全局那层是给「全部合集」写的,回落会让 collection-ys 顶着《崩坏:星穹铁道》昔涟。
|
||||
title: game.meta.title,
|
||||
description:
|
||||
game.meta.description ??
|
||||
`[b]${game.meta.name} 全部壁纸[/b]\r\n[list]\r\n [*]共 ${game.wallpapers.length} 档:${game.wallpapers
|
||||
.map((w) => w.meta.name)
|
||||
.join("、")}。\r\n [*]bgm 与音量可在壁纸设置中调整。\r\n[/list]`,
|
||||
},
|
||||
metaRel: game.srcRel,
|
||||
sharedAudio: sharedAudioOf(game),
|
||||
});
|
||||
};
|
||||
|
||||
const addAll = (): void => {
|
||||
releases.push({
|
||||
type: "collection",
|
||||
scope: "all",
|
||||
id: "all",
|
||||
dir: "collection-all",
|
||||
wallpapers: [...vault.wallpapers],
|
||||
displayName: vault.global.name,
|
||||
meta: {
|
||||
title: vault.global.title,
|
||||
description: vault.global.description,
|
||||
...(vault.global.preview ? { preview: vault.global.preview } : {}),
|
||||
...(vault.global.workshopid ? { workshopid: vault.global.workshopid } : {}),
|
||||
...(vault.global.workshopurl ? { workshopurl: vault.global.workshopurl } : {}),
|
||||
},
|
||||
metaRel: "wallpapers",
|
||||
sharedAudio: vault.games.flatMap((game) => sharedAudioOf(game)),
|
||||
});
|
||||
};
|
||||
|
||||
if (all) {
|
||||
for (const wallpaper of vault.wallpapers) addSingle(wallpaper.id);
|
||||
for (const game of vault.games) addGame(game.id);
|
||||
addAll();
|
||||
return releases;
|
||||
}
|
||||
|
||||
if (onlySingles) {
|
||||
for (const wallpaper of vault.wallpapers) addSingle(wallpaper.id);
|
||||
return releases;
|
||||
}
|
||||
|
||||
// `pnpm build collect`(无参)= 每个游戏的合集 + 「全部壁纸合集」。
|
||||
// 注意 `--collect all` 是另一件事:它只编「全部壁纸合集」一项,保持原语义不变。
|
||||
if (options.allCollections === true) {
|
||||
for (const game of vault.games) addGame(game.id);
|
||||
addAll();
|
||||
return releases;
|
||||
}
|
||||
|
||||
for (const id of options.singles) addSingle(id);
|
||||
if (options.collect === "all") addAll();
|
||||
else if (Array.isArray(options.collect)) for (const raw of options.collect) addGame(normalizeGameId(raw, vault));
|
||||
return releases;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 `index.html` 模板渲染成产物。
|
||||
*
|
||||
* 默认**原样输出**:发布产物的 index.html 里不能有任何模拟器痕迹(ADR 0006 第 1 条)——
|
||||
* 旧实现会在真实 WE 里自我激活并覆盖官方 API,事故级。
|
||||
*
|
||||
* `--with-sim` 时才注入模拟器驱动,且用**相对路径** `./scripts/wallpaper-engine.js`。
|
||||
* 这样整个分发目录是自足的:丢到任何静态托管(GitHub Pages 的 `/<repo>/` 子路径、
|
||||
* 任意 CDN、甚至 file://)都能弹出设置面板,不需要调试服在场。
|
||||
*
|
||||
* 注入点必须在 `<script type="module" src="./scripts/index.js">` **之前**:
|
||||
* 驱动是经典脚本,要赶在壁纸模块注册 `wallpaperPropertyListener` 之前把钩子备好
|
||||
* (见 tools/lib/drivers.ts 的时序说明)。
|
||||
*/
|
||||
function renderIndexHtml(template: string, release: Release, ctx: GenerateContext): string {
|
||||
if (!ctx.withSimulator) return template;
|
||||
const driver = SIMULATOR_DRIVER({
|
||||
search: "",
|
||||
dir: release.dir,
|
||||
title: release.displayName,
|
||||
version: ctx.version,
|
||||
defaultPresetId: ctx.defaultPresetId,
|
||||
preview: release.meta.preview,
|
||||
simulatorUrl: SIMULATOR_URL_STATIC,
|
||||
});
|
||||
const marker = /[ \t]*<script\b[^>]*src=["'][^"']*scripts\/index\.js["'][^>]*><\/script>/i;
|
||||
if (!marker.test(template)) {
|
||||
throw new VaultError("src/templates/index.html 里找不到 scripts/index.js 的 <script> 标签,模拟器无法注入");
|
||||
}
|
||||
return template.replace(marker, (tag) => `${driver}\n ${tag.trim()}`);
|
||||
}
|
||||
|
||||
async function buildRelease(
|
||||
release: Release,
|
||||
ctx: GenerateContext,
|
||||
options: BuildOptions,
|
||||
warnings: string[],
|
||||
): Promise<{ bytes: number; simBytes: number }> {
|
||||
const outDir = join(ctx.outDir, release.dir);
|
||||
if (options.dryRun) {
|
||||
log(` [dry-run] ${release.dir.padEnd(24)} ← ${release.wallpapers.map((w) => w.id).join(", ")}`);
|
||||
return { bytes: 0, simBytes: 0 };
|
||||
}
|
||||
|
||||
await resetDir(outDir);
|
||||
|
||||
// 1) 模板与静态资源
|
||||
await writeText(join(outDir, "index.html"), renderIndexHtml(await readFile(abs("src/templates/index.html"), "utf8"), release, ctx));
|
||||
await writeJson(join(outDir, "project.json"), generateProjectJson(release, ctx));
|
||||
for (const name of ["index.css", "spine-player.css"]) {
|
||||
await copyFileTo(abs(`src/styles/${name}`), join(outDir, "styles", name));
|
||||
}
|
||||
await copyFileTo(abs("src/vendor/spine-player.js"), join(outDir, "scripts", "spine-player.js"));
|
||||
|
||||
// 分发级预览图:`preview` 字段指向的是**分发根**下的文件名,所以图源必须在分发自己的元数据目录里
|
||||
// 找到同名文件。当前只有「所有壁纸合集」有它(wallpapers/preview.gif);其余分发没有,字段就省略。
|
||||
if (release.meta.preview) {
|
||||
const name = release.meta.preview.replace(/\\/g, "/").replace(/^.*\//, "");
|
||||
const source = join(abs(release.metaRel), name);
|
||||
if (await exists(source)) await copyFileTo(source, join(outDir, name));
|
||||
else warnings.push(`${release.dir}: project.json 声明了 preview=${release.meta.preview},但 ${release.metaRel}/${name} 不存在`);
|
||||
}
|
||||
|
||||
// 2) 运行时脚本(tsc 已把 src/runtime/*.ts 编到 build/scripts/)
|
||||
for (const name of [
|
||||
"index.js",
|
||||
"preset-controller.js",
|
||||
"background-controller.js",
|
||||
"spine-controller.js",
|
||||
"scene-controller.js",
|
||||
"audio-controller.js",
|
||||
"viewport-fitter.js",
|
||||
]) {
|
||||
const from = abs(`build/scripts/${name}`);
|
||||
if (!(await exists(from))) {
|
||||
throw new VaultError(`缺少 ${from}。请先跑 \`pnpm typecheck\` 之外的编译:tsc -p tsconfig.runtime.json(pnpm build 的 npm script 已包含)`);
|
||||
}
|
||||
await copyFileTo(from, join(outDir, "scripts", name));
|
||||
}
|
||||
if (ctx.withSimulator) {
|
||||
const simulator = abs("build/scripts/wallpaper-engine.js");
|
||||
if (await exists(simulator)) await copyFileTo(simulator, join(outDir, "scripts", "wallpaper-engine.js"));
|
||||
else warnings.push("--with-sim 已指定,但 build/scripts/wallpaper-engine.js 不存在(先跑 pnpm build:sim)");
|
||||
}
|
||||
|
||||
// 3) 预设索引(每分发现算,见 generatePresetIndex 注释)
|
||||
await writeText(join(outDir, "scripts", "presets.js"), generatePresetIndex(release, ctx.defaultPresetId));
|
||||
|
||||
// 4) 音频落点必须在生成 preset.js **之前**算出来:路径是按实际落点现算的,
|
||||
// 先写 preset.js 再决定音频放哪儿,会出现"语法正确、指向空气"的产物。
|
||||
// 落点算法本身只有一份(generate.ts 的 planAudio),这里只负责把文件搬过去。
|
||||
const isCollection = release.type !== "single";
|
||||
if (isCollection) {
|
||||
const placed = await copyCollectionAudio(release, outDir);
|
||||
if (placed.names.length === 0) warnings.push(`${release.dir}: 合集根 audios/ 为空(各壁纸与游戏都没有声明共享音频)`);
|
||||
}
|
||||
|
||||
// 5) 每档壁纸的 preset.js + 资源
|
||||
for (const wallpaper of release.wallpapers) {
|
||||
const sub = releasePathOf(release, wallpaper);
|
||||
const destDir = sub ? join(outDir, sub) : outDir;
|
||||
await writeText(join(destDir, "preset.js"), generatePresetModule(release, wallpaper, release.sharedAudio));
|
||||
await copyWallpaperAssets(wallpaper, destDir, { skipAudio: isCollection });
|
||||
}
|
||||
|
||||
// 6) 可选的**自包含包**:与发布产物并列放在 sim/ 下,互不干扰。
|
||||
// 它必须在资源全部落地之后才能生成——内联的是刚铺好的那些文件。
|
||||
let simBytes = 0;
|
||||
if (options.standalone) {
|
||||
const { buildSimPage } = await import("./lib/bundle.ts");
|
||||
const sim = await buildSimPage(release, ctx.defaultPresetId, { embedAudio: options.embedAudio ?? true });
|
||||
const simDir = join(outDir, "sim");
|
||||
await writeText(join(simDir, "index.html"), sim.html);
|
||||
simBytes = await dirBytes(simDir);
|
||||
log(
|
||||
` └ sim/index.html ${mb(simBytes)} 内联 ${sim.inlined} 项资源` +
|
||||
(sim.skippedAudio.length > 0 ? `(${sim.skippedAudio.length} 个音频未内联,file:// 下不可播)` : ""),
|
||||
);
|
||||
if (sim.skippedAudio.length > 0) {
|
||||
warnings.push(`${release.dir}: --no-embed-audio,${sim.skippedAudio.length} 个音频未内联,双击打开时选不到它们`);
|
||||
}
|
||||
}
|
||||
|
||||
return { bytes: await dirBytesExcludingSim(outDir), simBytes };
|
||||
}
|
||||
|
||||
/**
|
||||
* 分发体积:**不含** `sim/`。
|
||||
*
|
||||
* 自包含包里的图片/音频是 base64 内联,体积与发布产物不在一个量级;把它算进"上传体积"
|
||||
* 会让人误判发布产物的实际大小。两笔分开报。
|
||||
*/
|
||||
async function dirBytesExcludingSim(root: string): Promise<number> {
|
||||
const { readdir, stat } = await import("node:fs/promises");
|
||||
let total = 0;
|
||||
for (const entry of await readdir(root, { withFileTypes: true })) {
|
||||
if (entry.name === "sim") continue;
|
||||
const full = join(root, entry.name);
|
||||
if (entry.isDirectory()) total += await dirBytesExcludingSim(full);
|
||||
else total += (await stat(full)).size;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const started = Date.now();
|
||||
const cli = parseArgs(process.argv.slice(2));
|
||||
const { options, withSimulator, standalone } = cli;
|
||||
options.standalone = standalone;
|
||||
options.embedAudio = cli.embedAudio;
|
||||
|
||||
const version = (await readFile(abs("VERSION"), "utf8")).trim();
|
||||
if (!/^\d+$/.test(version)) throw new VaultError(`VERSION 必须是单个整数,当前是 "${version}"`);
|
||||
|
||||
const vault = await readVault();
|
||||
const template = await readJson<ProjectTemplate>(abs("src/project.template.json"));
|
||||
const releases = planReleases(vault, options);
|
||||
|
||||
log(`SpineWallpaper build version=${version} releases=${releases.length}${withSimulator ? " (with simulator)" : ""}${standalone ? " (standalone sim)" : ""}`);
|
||||
log(` 壁纸:${vault.wallpapers.map((w) => `${w.id}(${w.meta.name})`).join("、")}`);
|
||||
log(` 游戏:${vault.games.map((g) => `${g.id}(${g.meta.name})`).join("、")}`);
|
||||
log("");
|
||||
|
||||
const warnings: string[] = [];
|
||||
const wantedDefault = vault.global.defaultPresetId;
|
||||
if (wantedDefault !== undefined && !vault.wallpapers.some((w) => w.id === wantedDefault)) {
|
||||
throw new VaultError(
|
||||
`wallpapers/meta.json 的 defaultPresetId "${wantedDefault}" 不是任何壁纸的 id(已有:${vault.wallpapers
|
||||
.map((w) => w.id)
|
||||
.join("、")})`,
|
||||
);
|
||||
}
|
||||
|
||||
/** 某档分发的默认预设:单档恒等于它自己;合集用全局声明,若该合集里没有就退回第一档。 */
|
||||
const defaultPresetIdFor = (release: Release): string => {
|
||||
if (release.type === "single") return release.wallpapers[0]?.id ?? "";
|
||||
if (wantedDefault && release.wallpapers.some((w) => w.id === wantedDefault)) return wantedDefault;
|
||||
return release.wallpapers[0]?.id ?? "";
|
||||
};
|
||||
|
||||
const ctx = { template, version, outDir: abs(RELEASES_DIR), withSimulator } satisfies Omit<
|
||||
GenerateContext,
|
||||
"defaultPresetId"
|
||||
>;
|
||||
const mapEntries: DistMap["releases"] = [];
|
||||
|
||||
for (const release of releases) {
|
||||
const defaultPresetId = defaultPresetIdFor(release);
|
||||
// 只有一档的合集拿不到 preset 下拉(generateProjectJson 按档数决定),产物行为与单档分发相同。
|
||||
// 发不发这一档是发布级的判断,构建不替人决定,但必须让人看见——"合集"名不副实是真实的踩坑点。
|
||||
if (release.type === "collection" && release.wallpapers.length < 2) {
|
||||
warnings.push(
|
||||
`${release.dir}: 合集只收 ${release.wallpapers.length} 档壁纸,产物里不会有 preset 下拉(行为等同单档);要么补壁纸,要么不发这一档`,
|
||||
);
|
||||
}
|
||||
const sizes = await buildRelease(release, { ...ctx, defaultPresetId }, options, warnings);
|
||||
mapEntries.push({
|
||||
dir: release.dir,
|
||||
type: release.type,
|
||||
// 合集范围(单档没有):只有它才区分「游戏合集」与「全部合集」。
|
||||
...(release.scope ? { scope: release.scope } : {}),
|
||||
id: release.id,
|
||||
displayName: release.displayName,
|
||||
defaultPresetId,
|
||||
wallpapers: release.wallpapers.map((wallpaper) => ({
|
||||
id: wallpaper.id,
|
||||
gameId: wallpaper.gameId,
|
||||
dir: releasePathOf(release, wallpaper),
|
||||
name: wallpaper.meta.name,
|
||||
})),
|
||||
bytes: sizes.bytes,
|
||||
...(sizes.simBytes > 0 ? { simBytes: sizes.simBytes } : {}),
|
||||
});
|
||||
log(` ✓ ${release.dir.padEnd(24)} ${release.wallpapers.length} 档壁纸 ${mb(sizes.bytes)}`);
|
||||
}
|
||||
|
||||
if (!options.dryRun) {
|
||||
if (options.clean) {
|
||||
const built = new Set(mapEntries.map((e) => e.dir));
|
||||
for (const dir of await listDirs(abs(RELEASES_DIR))) {
|
||||
if (!built.has(dir)) {
|
||||
await rm(join(abs(RELEASES_DIR), dir), { recursive: true, force: true });
|
||||
log(` − 删除孤立的旧分发目录 ${dir}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const dir of await listDirs(abs(RELEASES_DIR))) {
|
||||
if (!mapEntries.some((e) => e.dir === dir)) {
|
||||
warnings.push(`dist/releases/${dir} 不是本次构建的产物(--no-clean),dist-map.json 里没有它`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const map: DistMap = { version, builtAt: new Date().toISOString(), releases: mapEntries };
|
||||
await writeJson(abs("dist/dist-map.json"), map);
|
||||
await checkDist({ strict: options.strict });
|
||||
}
|
||||
|
||||
log("");
|
||||
for (const warning of warnings) log(` ⚠ ${warning}`);
|
||||
if (options.strict && warnings.length > 0) throw new VaultError(`--strict:出现 ${warnings.length} 条警告,视为失败`);
|
||||
log(`完成,用时 ${((Date.now() - started) / 1000).toFixed(1)}s`);
|
||||
}
|
||||
|
||||
try {
|
||||
await main();
|
||||
} catch (error) {
|
||||
if (error instanceof VaultError) {
|
||||
console.error(`\n构建失败:${error.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// `tools/cdp.mjs` 的类型声明。
|
||||
//
|
||||
// 它是纯 JS,以前没有任何声明——于是每个 import 它的验收脚本都退化成 `any`,
|
||||
// 连带 `withPage(..., ({ send, evaluate }) => ...)` 的解构参数全部是隐式 any。
|
||||
// 一个 20 行的声明就能把那一整类错误消掉(实测 180 处里占 41 处)。
|
||||
//
|
||||
// 这里刻意用 `any` 而不是 `unknown` 作为 evaluate/send 的返回值:它们直通 CDP,
|
||||
// 返回什么完全取决于传进去的表达式字符串,静态上无从约束——写成 `unknown` 只会逼每个
|
||||
// 调用点加断言,反而把噪音搬了个地方。
|
||||
|
||||
export interface PageOptions {
|
||||
width?: number;
|
||||
height?: number;
|
||||
/** Edge 的 user-data-dir;给不同脚本各用一个,免得并发时互相踢掉。 */
|
||||
profile?: string;
|
||||
extraArgs?: string[];
|
||||
}
|
||||
|
||||
/** 一个已连上的页面。 */
|
||||
export interface Page {
|
||||
/** 发一条 CDP 命令。 */
|
||||
send(method: string, params?: Record<string, unknown>): Promise<any>;
|
||||
/** 在页面里求值并取回值。表达式里的异常会抛出来。 */
|
||||
evaluate(expression: string): Promise<any>;
|
||||
/** 页面控制台输出(含 Log.entryAdded 的 error)。 */
|
||||
consoleLines: string[];
|
||||
/** 未捕获异常。 */
|
||||
exceptions: string[];
|
||||
/** Edge 的 stderr,排查启动失败时用。 */
|
||||
stderr(): string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 起一个无头 Edge、连上它、执行 `fn`,结束后收掉浏览器。
|
||||
* `fn` 的返回值原样返回。
|
||||
*/
|
||||
export function withPage<T>(opts: PageOptions, fn: (page: Page) => Promise<T>): Promise<T>;
|
||||
|
||||
export function sleep(ms: number): Promise<void>;
|
||||
@@ -0,0 +1,332 @@
|
||||
// pnpm check:dist —— 分发目录的自我包含性门禁。
|
||||
//
|
||||
// A2 的要求是"分发目录应避免跨目录相对链接与绝对链接"(它能被整体拷到别的机器上传)。
|
||||
// 这里把它变成硬门禁:扫描每个分发目录里的 HTML/CSS/JS,把每一处**静态可解析**的引用
|
||||
// 解析出来,凡是落到分发目录之外的、或写成根绝对路径(/…)与 file:/// 的,一律 fail。
|
||||
//
|
||||
// 不做的事:不去猜 `asset()` 这类运行时推导(那是生成器的责任,生成器只吐相对路径)。
|
||||
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
import { abs, exists, listDirs, log, readJson, walkFiles } from "./lib/fs.ts";
|
||||
import { VaultError } from "./lib/vault.ts";
|
||||
import type { DistMap } from "./lib/types.ts";
|
||||
|
||||
export interface CheckResult {
|
||||
releases: number;
|
||||
files: number;
|
||||
references: number;
|
||||
problems: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/** 允许出现的非本地 URL 协议与内联数据。 */
|
||||
const ALLOWED_PROTOCOL = /^(data:|blob:|mailto:|https?:\/\/|steam:|#)/i;
|
||||
|
||||
interface Reference {
|
||||
/** 引用写下的原始文本。 */
|
||||
raw: string;
|
||||
/** 引用的文件(相对分发根)。 */
|
||||
file: string;
|
||||
/** 行号(1 起)。 */
|
||||
line: number;
|
||||
}
|
||||
|
||||
/** 从一份文本里抽出所有静态可解析的本地引用。 */
|
||||
function extractReferences(file: string, text: string): Reference[] {
|
||||
const out: Reference[] = [];
|
||||
|
||||
const push = (raw: string, line: number): void => {
|
||||
if (!raw) return;
|
||||
const normalized = raw.trim();
|
||||
if (ALLOWED_PROTOCOL.test(normalized)) return;
|
||||
out.push({ raw: normalized, file, line });
|
||||
};
|
||||
|
||||
text.split(/\r?\n/).forEach((line, index) => {
|
||||
const lineNo = index + 1;
|
||||
const isCss = /\.css$/i.test(file);
|
||||
// HTML/JS 里的字符串形态:src="/x"、href='y'、from "./z"、import("w")、fetch("v")
|
||||
for (const match of line.matchAll(/(?:src|href|poster)\s*=\s*["']([^"']+)["']/g)) push(match[1] ?? "", lineNo);
|
||||
for (const match of line.matchAll(/(?:from|import|fetch)\s*\(?\s*["']([^"']+)["']/g)) push(match[1] ?? "", lineNo);
|
||||
// url(...) 只在 CSS 里是路径;JS 里 `url("${x}")` 是模板字符串,不是静态引用
|
||||
if (isCss) {
|
||||
for (const match of line.matchAll(/url\(\s*["']?([^"')]+)["']?\s*\)/g)) push(match[1] ?? "", lineNo);
|
||||
for (const match of line.matchAll(/@import\s+["']([^"']+)["']/g)) push(match[1] ?? "", lineNo);
|
||||
}
|
||||
});
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 解析一个引用:返回它相对分发根的路径,或一个错误原因。 */
|
||||
function resolveReference(raw: string, file: string): { rel: string } | { error: string } {
|
||||
if (raw.startsWith("file:///")) return { error: `绝对链接 file:///(分发目录必须可整体搬走)` };
|
||||
if (raw.startsWith("//")) return { error: `协议相对链接 //(依赖宿主,跨机器行为不定)` };
|
||||
if (raw.startsWith("/")) return { error: `根绝对路径 /(脱离分发根就没有意义)` };
|
||||
const clean = raw.split(/[?#]/)[0] ?? "";
|
||||
const parts = (file.includes("/") ? file.replace(/\/[^/]*$/, "/") : "") + clean;
|
||||
const segments: string[] = [];
|
||||
for (const segment of parts.split("/")) {
|
||||
if (segment === "" || segment === ".") continue;
|
||||
if (segment === "..") {
|
||||
if (segments.length === 0) return { error: `相对路径越出分发目录(${raw})` };
|
||||
segments.pop();
|
||||
continue;
|
||||
}
|
||||
segments.push(segment);
|
||||
}
|
||||
return { rel: segments.join("/") };
|
||||
}
|
||||
|
||||
/** 从 preset.js 里抽出所有声明的音频落点(相对该 preset.js 所在目录)。 */
|
||||
function extractAudioSources(text: string): { raw: string; line: number }[] {
|
||||
const out: { raw: string; line: number }[] = [];
|
||||
text.split(/\r?\n/).forEach((line, index) => {
|
||||
for (const match of line.matchAll(/source:\s*asset\(\s*"([^"]*)"\s*\)/g)) {
|
||||
out.push({ raw: match[1] ?? "", line: index + 1 });
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 让 V8 真的解析一个产物模块,只求语法通过、不执行。
|
||||
*
|
||||
* 为什么非要有这一步:`presets.js` 与 `preset.js` 是**生成器拼出来的裸 JS 文本**,
|
||||
* tsc 完全不看它们。生成器写出非法语法时,前面所有检查(引用扫描、路径存在性)都会通过,
|
||||
* 直到浏览器里爆 SyntaxError 白屏——实测过一次真事故(数组字面量里写了计算属性名)。
|
||||
*
|
||||
* 用 vm.SourceTextModule 而不是 dynamic import:后者会**执行**模块,而壁纸脚本依赖 DOM。
|
||||
* 用子进程是因为这个 API 需要 --experimental-vm-modules(V8 的解析器只暴露到这里)。
|
||||
*/
|
||||
async function checkModuleSyntax(files: { path: string; text: string }[]): Promise<string[]> {
|
||||
const { spawnSync } = await import("node:child_process");
|
||||
const script = `
|
||||
const vm = require("node:vm");
|
||||
const payload = JSON.parse(require("node:fs").readFileSync(0, "utf8"));
|
||||
const problems = [];
|
||||
for (const item of payload) {
|
||||
try {
|
||||
new vm.SourceTextModule(item.text, { identifier: item.path });
|
||||
} catch (error) {
|
||||
problems.push(item.path + " " + String(error.message).split("\\n")[0]);
|
||||
}
|
||||
}
|
||||
process.stdout.write(JSON.stringify(problems));
|
||||
`;
|
||||
const child = spawnSync(process.execPath, ["--experimental-vm-modules", "--no-warnings", "-e", script], {
|
||||
input: JSON.stringify(files),
|
||||
encoding: "utf8",
|
||||
});
|
||||
if (child.status !== 0) {
|
||||
return [`无法运行语法解析子进程:${(child.stderr || "").trim().split("\n").slice(-3).join(" ")}`];
|
||||
}
|
||||
try {
|
||||
return JSON.parse(child.stdout) as string[];
|
||||
} catch {
|
||||
return [`语法解析子进程输出无法解析:${child.stdout.slice(0, 200)}`];
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkDist(options: { strict?: boolean; quiet?: boolean } = {}): Promise<CheckResult> {
|
||||
const releasesDir = abs("dist/releases");
|
||||
const mapPath = abs("dist/dist-map.json");
|
||||
const result: CheckResult = { releases: 0, files: 0, references: 0, problems: [], warnings: [] };
|
||||
|
||||
if (!(await exists(mapPath))) {
|
||||
result.problems.push("dist/dist-map.json 不存在(先跑 pnpm build)");
|
||||
if (!options.quiet) report(result);
|
||||
return result;
|
||||
}
|
||||
const map = await readJson<DistMap>(mapPath);
|
||||
const modules: { path: string; text: string }[] = [];
|
||||
|
||||
for (const dir of await listDirs(releasesDir)) {
|
||||
const root = join(releasesDir, dir);
|
||||
if (!map.releases.some((r) => r.dir === dir)) {
|
||||
result.problems.push(`dist/releases/${dir} 不在 dist-map.json 里(残留目录)`);
|
||||
continue;
|
||||
}
|
||||
result.releases += 1;
|
||||
|
||||
const entry = map.releases.find((r) => r.dir === dir);
|
||||
if (entry) {
|
||||
// project.json 的静态字段必须指向目录内真实存在的文件
|
||||
const project = await readJson<{ preview?: string; file?: string }>(join(root, "project.json"));
|
||||
const fileField = project.file ?? "index.html";
|
||||
if (!(await exists(join(root, fileField)))) result.problems.push(`${dir}/project.json 的 file 指向不存在的 ${fileField}`);
|
||||
if (project.preview !== undefined && !(await exists(join(root, project.preview)))) {
|
||||
result.problems.push(`${dir}/project.json 的 preview 指向不存在的 ${project.preview}`);
|
||||
}
|
||||
if (entry.defaultPresetId.length === 0) result.problems.push(`${dir}: dist-map 里没有 defaultPresetId`);
|
||||
|
||||
// 运行时脚本必须齐全(少了任何一个都会在 WE 里静默白屏)
|
||||
for (const name of [
|
||||
"scripts/index.js",
|
||||
"scripts/presets.js",
|
||||
"scripts/spine-player.js",
|
||||
"scripts/preset-controller.js",
|
||||
"scripts/background-controller.js",
|
||||
"scripts/spine-controller.js",
|
||||
"scripts/audio-controller.js",
|
||||
"scripts/viewport-fitter.js",
|
||||
"styles/index.css",
|
||||
"styles/spine-player.css",
|
||||
"index.html",
|
||||
]) {
|
||||
if (!(await exists(join(root, name)))) result.problems.push(`${dir}: 缺少 ${name}`);
|
||||
}
|
||||
|
||||
// 调试服的热更新客户端**绝不能**进产物:它连的是 /__dev/events,那在真实 WE 里、
|
||||
// 在任何静态托管上都不存在,只会白挨一次连接失败。这类"调试期专有代码混进发布产物"
|
||||
// 靠人工抽查迟早会漏,所以钉在门禁上。
|
||||
const indexText = await readFile(join(root, "index.html"), "utf8");
|
||||
for (const marker of ["/__dev/events", "dev-reload-pill"]) {
|
||||
if (indexText.includes(marker)) {
|
||||
result.problems.push(`${dir}/index.html 里有调试服专属的 ${marker}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 每档壁纸的 preset.js 必须与 dist-map 声明一致
|
||||
for (const wallpaper of entry.wallpapers) {
|
||||
const presetPath = wallpaper.dir ? `${wallpaper.dir}/preset.js` : "preset.js";
|
||||
if (!(await exists(join(root, presetPath)))) result.problems.push(`${dir}: 缺少 ${presetPath}`);
|
||||
}
|
||||
const presetModules = (await walkFiles(root)).filter((f) => /(^|\/)preset\.js$/.test(f));
|
||||
if (presetModules.length !== entry.wallpapers.length) {
|
||||
result.problems.push(
|
||||
`${dir}: preset.js 数量(${presetModules.length}) 与 dist-map 声明的壁纸数(${entry.wallpapers.length}) 不一致`,
|
||||
);
|
||||
}
|
||||
|
||||
// 语义校验:preset.js 里声明的每个音频落点都必须真实存在。
|
||||
//
|
||||
// 静态引用扫描(上面那圈)抓不到这类缺陷:音频走的是 `asset("../audios/x.flac")`,
|
||||
// 参数是字面量,但 `asset` 的基准是 preset.js 自己的目录,只有按 preset.js 的位置解析才
|
||||
// 知道对不对。合集分发把音频搬到分发根,前缀算错一层就会得到一个"语法正确、路径错误"的
|
||||
// preset.js —— 它会一路通过编译、通过引用扫描,然后在 WE 里静音。
|
||||
for (const presetRel of presetModules) {
|
||||
const presetText = await readFile(join(root, presetRel), "utf8");
|
||||
for (const audio of extractAudioSources(presetText)) {
|
||||
if (audio.raw === "") continue; // 没有默认音源时是空串,跳过
|
||||
const resolved = resolveReference(audio.raw, presetRel);
|
||||
if ("error" in resolved) {
|
||||
result.problems.push(`${dir}/${presetRel}:${audio.line} 音频 ${resolved.error}`);
|
||||
continue;
|
||||
}
|
||||
if (!(await exists(join(root, resolved.rel)))) {
|
||||
result.problems.push(`${dir}/${presetRel}:${audio.line} 音频引用不存在的 ${audio.raw}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 单档分发不该出现共享音频目录(音频本来就只属于它一个)。
|
||||
if (entry.type === "single" && (await exists(join(root, "audios", "_shared")))) {
|
||||
result.problems.push(`${dir}: 单档分发里不该有 audios/_shared`);
|
||||
}
|
||||
// 合集类分发必须有合集根 audios/(bgm 要能选到任一壁纸的音源)——
|
||||
// 但**整份分发一个音源都没有**时不该要求它:那只会逼出一个空的 audios/ 目录
|
||||
// (比如纯场景壁纸、音源还没抓的页面)。判据是 preset.js 里有没有音频落点。
|
||||
const hasAudio = (
|
||||
await Promise.all(
|
||||
presetModules.map(async (presetRel) => /[/\\]audios[/\\]/.test(await readFile(join(root, presetRel), "utf8"))),
|
||||
)
|
||||
).some(Boolean);
|
||||
if (entry.type !== "single" && hasAudio && !(await exists(join(root, "audios")))) {
|
||||
result.problems.push(`${dir}: 合集分发缺少合集根 audios/`);
|
||||
}
|
||||
|
||||
// 自包含包(`pnpm build sim` 产出):它必须在 `file://` 下**双击就能开**,
|
||||
// 所以这里查的是"页面上还有没有任何外部依赖"。
|
||||
//
|
||||
// 只在**真正的标签与赋值**上判定,不在整页文本上做正则——spine-player 是整份内联进来的,
|
||||
// 它内部带着官网文档链接与一段编辑器示例模板(里面有 `<script src="https://…">` 的字符串)。
|
||||
// 第一版对整页扫 `src=`/`href=`,于是把那些字符串当成真依赖,四个分发全报假阳性。
|
||||
const simPage = join(root, "sim", "index.html");
|
||||
if (await exists(simPage)) {
|
||||
const simText = await readFile(simPage, "utf8");
|
||||
const simProblems: string[] = [];
|
||||
|
||||
// 先剥掉内联脚本的**内容**,只在剩下的 HTML 骨架里找标签。
|
||||
//
|
||||
// 必须这么做:spine-player 是整份内联进来的,它内部带着一段编辑器示例模板,
|
||||
// 里面有 `<script src="https://…">` 这样的字符串。对整页正则扫标签会把它们当成真依赖,
|
||||
// 四个分发全报假阳性(实测过)。
|
||||
//
|
||||
// 关键细节:**只删标签之间的内容,保留开标签本身**。
|
||||
// 第一版写成 `/<script[^>]*>[\s\S]*?<\/script>/` 整体替换,把开标签也一起删了,
|
||||
// 于是所有 `<script src=…>` 都不再被检查——门禁看着在跑,实际只能查到 `<link>`。
|
||||
// 是"注入一个外链 script 看它拦不拦得住"这个测试把这个漏洞暴露出来的。
|
||||
const skeleton = simText.replace(
|
||||
/<(script)\b([^>]*)>[\s\S]*?<\/script>/gi,
|
||||
(_match, name: string, attrs: string) => `<${name}${attrs}></${name}>`,
|
||||
);
|
||||
|
||||
const tags = skeleton.match(/<(?:script|link)\b[^>]*>/gi) ?? [];
|
||||
for (const tag of tags) {
|
||||
if (/\btype\s*=\s*["']module["']/i.test(tag)) {
|
||||
simProblems.push(`<script type="module">(file:// 下不会加载)`);
|
||||
}
|
||||
for (const match of tag.matchAll(/\b(?:src|href)\s*=\s*["']([^"']+)["']/gi)) {
|
||||
const raw = match[1] ?? "";
|
||||
if (/^https?:\/\//i.test(raw)) simProblems.push(`标签引用了 http(s) 资源 ${raw}`);
|
||||
else if (raw.startsWith("file:///")) simProblems.push(`标签引用了绝对路径 ${raw}`);
|
||||
else if (raw.startsWith("/")) simProblems.push(`标签引用了根绝对路径 ${raw}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (simProblems.length > 0) {
|
||||
result.problems.push(`${dir}/sim/index.html: ${[...new Set(simProblems)].slice(0, 3).join(";")}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const rel of await walkFiles(root)) {
|
||||
if (!/\.(html|css|js|mjs|json)$/i.test(rel)) continue;
|
||||
result.files += 1;
|
||||
const text = await readFile(join(root, rel), "utf8");
|
||||
if (/\.(js|mjs)$/i.test(rel)) modules.push({ path: `${dir}/${rel}`, text });
|
||||
for (const reference of extractReferences(rel, text)) {
|
||||
result.references += 1;
|
||||
const resolved = resolveReference(reference.raw, reference.file);
|
||||
if ("error" in resolved) {
|
||||
result.problems.push(`${dir}/${reference.file}:${reference.line} ${resolved.error}`);
|
||||
continue;
|
||||
}
|
||||
if (!(await exists(join(root, resolved.rel)))) {
|
||||
// 生成器只保证字面路径可解析;这里对"解析后不存在"的文件报错。
|
||||
result.problems.push(`${dir}/${reference.file}:${reference.line} 引用不存在的 ${reference.raw}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 语法门禁:所有产物 JS 必须能被 V8 解析成模块。
|
||||
for (const problem of await checkModuleSyntax(modules)) result.problems.push(problem);
|
||||
|
||||
if (!options.quiet) report(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
function report(result: CheckResult): void {
|
||||
log(
|
||||
`check:dist ${result.releases} 个分发 / ${result.files} 个文本文件 / ${result.references} 处引用 ` +
|
||||
`${result.problems.length === 0 ? "✓ 自包含" : `✗ ${result.problems.length} 处问题`}`,
|
||||
);
|
||||
for (const problem of result.problems.slice(0, 30)) log(` ✗ ${problem}`);
|
||||
if (result.problems.length > 30) log(` … 另有 ${result.problems.length - 30} 处`);
|
||||
for (const warning of result.warnings) log(` ⚠ ${warning}`);
|
||||
}
|
||||
|
||||
// 只在被当作入口直接运行时才自检——build.ts 会 import 它,那时不能执行这里的退出逻辑。
|
||||
if (process.argv[1] !== undefined && resolve(process.argv[1]) === abs("tools/check-dist.ts")) {
|
||||
const strict = process.argv.includes("--strict");
|
||||
const result = await checkDist({ strict });
|
||||
if (result.problems.length > 0) {
|
||||
log("");
|
||||
throw new VaultError(`自包含性校验未通过(${result.problems.length} 处)`);
|
||||
}
|
||||
log("");
|
||||
log("自包含性校验通过:每个分发目录都可以整体拷到别的机器上传。");
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
// 静态检查:每份 preset.js 里声明的每一个资源路径是否真实存在。
|
||||
// 这能抓到"运行时才会暴露"的拼写错误(比如某首备用 BGM 从未被加载过,写错了也不会有人发现)。
|
||||
// 用法:node tools/check-paths.mjs
|
||||
|
||||
import { stat } from "node:fs/promises";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { glob } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const root = resolve("dist");
|
||||
const presetFiles = [];
|
||||
for await (const f of glob("assets/**/preset.js", { cwd: root })) presetFiles.push(f);
|
||||
|
||||
let ok = 0;
|
||||
let bad = 0;
|
||||
|
||||
for (const rel of presetFiles.sort()) {
|
||||
const mod = await import(pathToFileURL(resolve(root, rel)).href);
|
||||
const preset = mod.default;
|
||||
const urls = [];
|
||||
const walk = (value, path) => {
|
||||
if (typeof value === "string" && /^(file|https?):/.test(value)) urls.push([path, value]);
|
||||
else if (value && typeof value === "object") {
|
||||
for (const [k, v] of Object.entries(value)) walk(v, `${path}.${k}`);
|
||||
} else if (Array.isArray(value)) value.forEach((v, i) => walk(v, `${path}[${i}]`));
|
||||
};
|
||||
walk(preset, preset.id);
|
||||
|
||||
console.log(`\n${rel} (id=${preset.id}, name=${preset.name})`);
|
||||
for (const [path, url] of urls) {
|
||||
const u = new URL(url);
|
||||
if (u.protocol !== "file:") {
|
||||
console.log(` ? ${path} -> 非本地路径,跳过: ${url}`);
|
||||
continue;
|
||||
}
|
||||
const file = fileURLToPath(u);
|
||||
const info = await stat(file).catch(() => null);
|
||||
if (info?.isFile()) {
|
||||
console.log(` ok ${path} (${(info.size / 1024).toFixed(1)} KB)`);
|
||||
ok++;
|
||||
} else {
|
||||
console.log(` MISSING ${path} -> ${file}`);
|
||||
bad++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n${ok} 个路径存在,${bad} 个缺失`);
|
||||
process.exit(bad ? 1 : 0);
|
||||
@@ -0,0 +1,89 @@
|
||||
// pnpm check:paths —— 源数据的**孤儿文件**门禁(构建前跑,只看 wallpapers/,不看 dist)。
|
||||
//
|
||||
// 与 check:dist 的分工:
|
||||
// check:paths 源数据自洽:有没有磁盘上存在、但没有任何 meta.json 声明的资源?
|
||||
// check:dist 产物自洽:dist/releases/ 里每个引用是否真的落地、是否越界、JS 语法是否合法。
|
||||
//
|
||||
// 为什么单独立这一条:音频文件名**保留中文**(产品要求),而 id 是 build 自增分配的,
|
||||
// 两者靠 meta.json 的 choices 显式绑定——这正是"地图漏画一格"最容易发生的地方。一个没被声明的 .flac 会安安静静
|
||||
// 躺在仓库里:类型检查过、构建过、产物自包含,只是那首歌永远选不到,而且没人会发现。
|
||||
// 依赖删除同理:改动后残留的 `foo-旧版.flac` 会一直跟着构建进分发,白白撑大上传体积。
|
||||
//
|
||||
// 用法:node tools/check-paths.ts
|
||||
|
||||
import { join, resolve } from "node:path";
|
||||
import { abs, isDir, log, walkFiles } from "./lib/fs.ts";
|
||||
import { readVault, VaultError } from "./lib/vault.ts";
|
||||
import { baseName } from "./lib/generate.ts";
|
||||
|
||||
const problems: string[] = [];
|
||||
let audioChecked = 0;
|
||||
|
||||
/** 反查一个目录下的音频文件是否都被 `choices` 认领。 */
|
||||
async function checkOrphans(opts: {
|
||||
dirRel: string;
|
||||
choices: { file: string }[];
|
||||
where: string;
|
||||
/** 音频目录相对声明者所在目录(壁纸就填 "audios")。 */
|
||||
subdir: string;
|
||||
}): Promise<void> {
|
||||
const declared = new Set(opts.choices.map((c) => baseName(c.file)));
|
||||
audioChecked += declared.size;
|
||||
const dirAbs = abs(join(opts.dirRel, opts.subdir));
|
||||
if (!(await isDir(dirAbs))) {
|
||||
if (declared.size > 0) problems.push(`${opts.where}: 声明了 ${declared.size} 个音源,但 ${join(opts.dirRel, opts.subdir)} 目录不存在`);
|
||||
return;
|
||||
}
|
||||
for (const name of await walkFiles(dirAbs)) {
|
||||
// 允许子目录嵌套:只比较相对路径末段,与实际取用方式一致。
|
||||
const leaf = name.split("/").pop() ?? name;
|
||||
if (declared.has(leaf)) continue;
|
||||
problems.push(
|
||||
`${join(opts.dirRel, opts.subdir, name)} 没有被 ${opts.where} 声明——` +
|
||||
`它是孤儿文件:既不会被 WE 的 bgm 下拉选中,也不会进任何分发的音频清单。` +
|
||||
`删掉它,或者把它加进 meta.json。`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const vault = await readVault();
|
||||
|
||||
// 1) 每档壁纸自己的 audios/
|
||||
for (const wallpaper of vault.wallpapers) {
|
||||
await checkOrphans({
|
||||
dirRel: wallpaper.srcRel,
|
||||
choices: wallpaper.meta.audio.choices,
|
||||
where: `${wallpaper.srcRel}/meta.json`,
|
||||
subdir: "audios",
|
||||
});
|
||||
}
|
||||
|
||||
// 2) 每个游戏的共享音频(wallpapers/<游戏id>/audios/),只被合集分发使用
|
||||
for (const game of vault.games) {
|
||||
await checkOrphans({
|
||||
dirRel: game.srcRel,
|
||||
choices: game.meta.audios ?? [],
|
||||
where: `${game.srcRel}/meta.json 的 audios`,
|
||||
subdir: "audios",
|
||||
});
|
||||
}
|
||||
|
||||
// 3) 分发级预览图(wallpapers/preview.gif,被所有分发共用)
|
||||
if (vault.global.preview !== undefined) {
|
||||
const previewRel = join("wallpapers", baseName(vault.global.preview));
|
||||
if (await isDir(abs(previewRel))) problems.push(`wallpapers/meta.json 的 preview "${vault.global.preview}" 指向的是一个目录`);
|
||||
}
|
||||
|
||||
if (problems.length > 0) {
|
||||
log(`check:paths ✗ ${problems.length} 处问题(已核对 ${audioChecked} 条音源声明)`);
|
||||
for (const problem of problems) log(` ✗ ${problem}`);
|
||||
throw new VaultError(`源数据里有 ${problems.length} 处音频声明问题`);
|
||||
}
|
||||
log(`check:paths ✓ ${vault.wallpapers.length} 档壁纸 / ${vault.games.length} 个游戏,${audioChecked} 条音源声明无孤儿文件`);
|
||||
}
|
||||
|
||||
// 只在被当作入口直接运行时才自检(被 import 时不该有副作用)。
|
||||
if (process.argv[1] !== undefined && resolve(process.argv[1]) === abs("tools/check-paths.ts")) {
|
||||
await main();
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { abs, log, walkFiles } from "./lib/fs.ts";
|
||||
import { VaultError } from "./lib/vault.ts";
|
||||
|
||||
// pnpm check:syntax —— 把"源码必须 strip-safe"从约定升级成门禁。
|
||||
//
|
||||
// Node 26 能直接跑 .ts,但它只做**类型擦除**:enum、构造函数参数属性、namespace 会以
|
||||
// ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX 崩掉(已实测)。这类语法在 tsc 下完全合法,所以只有真实加载
|
||||
// 才发现得了——而那通常是 `pnpm dev` 已经跑起来之后。这里做一次静态扫描,提前挡在门口。
|
||||
//
|
||||
// 刻意**不做**动态 import 验证:那些入口脚本一被执行就有副作用(起服务、写 dist)。
|
||||
|
||||
const BANNED: { pattern: RegExp; what: string; why: string }[] = [
|
||||
{
|
||||
pattern: /^[ \t]*(?:export[ \t]+)?(?:const[ \t]+)?enum[ \t]/m,
|
||||
what: "enum",
|
||||
why: "类型擦除模式下没有运行时实现,Node 直接报错",
|
||||
},
|
||||
{
|
||||
pattern: /^[ \t]*(?:export[ \t]+)?(?:declare[ \t]+)?namespace[ \t]/m,
|
||||
what: "namespace",
|
||||
why: "同上,且它是过时的模块写法",
|
||||
},
|
||||
{
|
||||
pattern: /constructor[ \t]*\([^)]*\b(?:public|private|protected|readonly)[ \t]+\w+/s,
|
||||
what: "构造函数参数属性(constructor(public x: T))",
|
||||
why: "需要编译器注入赋值语句,擦除模式做不到",
|
||||
},
|
||||
{ pattern: /\bimport[ \t]+\w+[ \t]*=[ \t]*require[ \t]*\(/, what: "import x = require()", why: "TS 特有语法,非 ESM" },
|
||||
];
|
||||
|
||||
/** 扫描范围:所有会被 Node 直接执行的 .ts(运行时脚本经 tsc 编译,不受此限制)。 */
|
||||
async function sourceFiles(): Promise<string[]> {
|
||||
const files: string[] = [];
|
||||
for (const dir of ["tools", "src/simulator"]) {
|
||||
for (const file of await walkFiles(abs(dir))) {
|
||||
if (file.endsWith(".ts")) files.push(`${dir}/${file}`);
|
||||
}
|
||||
}
|
||||
return files.sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* 先剥掉注释与字符串/模板字面量再匹配。
|
||||
*
|
||||
* 这不是洁癖:本文件自己就用正则写着这些被禁语法的样子,若直接扫全文会自我举报。
|
||||
* 同理,任何文档或消息文本里出现 "enum " 也不该被当成代码。
|
||||
*/
|
||||
function stripCommentsAndLiterals(text: string): string {
|
||||
return text
|
||||
.replace(/\/\*[\s\S]*?\*\//g, " ")
|
||||
.replace(/\/\/[^\n]*/g, "")
|
||||
.replace(/`(?:\\[\s\S]|[^`\\])*`/g, '""')
|
||||
.replace(/'(?:\\[\s\S]|[^'\\\n])*'/g, '""')
|
||||
.replace(/"(?:\\[\s\S]|[^"\\\n])*"/g, '""');
|
||||
}
|
||||
|
||||
const problems: string[] = [];
|
||||
for (const relPath of await sourceFiles()) {
|
||||
const text = stripCommentsAndLiterals(await readFile(abs(relPath), "utf8"));
|
||||
for (const { pattern, what, why } of BANNED) {
|
||||
const match = pattern.exec(text);
|
||||
if (!match) continue;
|
||||
problems.push(`${relPath} 用了 ${what} —— ${why}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (problems.length > 0) {
|
||||
log("check:syntax ✗");
|
||||
for (const problem of problems) log(` ✗ ${problem}`);
|
||||
throw new VaultError(`源码里有 ${problems.length} 处 Node 类型擦除不支持的语法`);
|
||||
}
|
||||
log("check:syntax ✓ 所有会被 Node 直接执行的 .ts 都是 strip-safe");
|
||||
@@ -0,0 +1,100 @@
|
||||
# `tools/checks/` — 验收套件
|
||||
|
||||
跑一遍就相当于"这次改动没把东西弄坏"的全部证据。它原来是散在 `.scratch/` 里的,
|
||||
而 `.scratch/` 按 `AGENTS.md` 是**问题与规格**的存放处——这些脚本不是某条 issue 的产物,
|
||||
issue 关掉之后还要一直跑,所以搬到了这里。
|
||||
|
||||
---
|
||||
|
||||
## 怎么跑
|
||||
|
||||
它们的前置条件不一样,**不能一条命令全跑完**。按下面三组来:
|
||||
|
||||
### ① 不需要服务器
|
||||
|
||||
```bash
|
||||
pnpm check # 类型 + 构建 + 语法 + 路径 + 产物自包含 + 游戏合集文案归属
|
||||
pnpm build sim # 下面几项要 sim 产物
|
||||
node tools/checks/test-sim-gate.mts # 门禁真的会拦(往页面注入四种外部依赖)
|
||||
node tools/checks/test-inline-guard.mts # 内联 </script 守卫真的会拦
|
||||
node tools/checks/test-transform-module.mts # 模块合成的 export 改写
|
||||
node tools/checks/diff-project.mts # 产物 project.json vs 已发布基线
|
||||
node tools/checks/check-collection-copy.mts # 游戏合集的文案是"它自己那个游戏"的(先 pnpm build)
|
||||
node tools/checks/verify-sim-page.mts <分发名> # 自包含包 file:// 可用(含"零 http 请求")
|
||||
node tools/checks/verify-static-preview.mts <分发名> # 静态托管子路径可用(自带服务器)
|
||||
node tools/checks/read-audio-tags.mts # 读音频文件自带标签(核对歌名用)
|
||||
node tools/checks/check-sim-scripts.mts [页面] # 抽出自包含页的每个内联 script 单独语法检查
|
||||
node tools/checks/check-downloader.mts # 抓取器:能编译 + 夹具绿 + 四处破坏必红(不联网)
|
||||
node tools/checks/verify-scene-player.mts # 场景播放器:多骨架+平面合成、画面非空、atlas 指错必红
|
||||
```
|
||||
|
||||
`verify-scene-player` 自己跑 `tsc -p tsconfig.runtime.json` 并自造夹具(用抓取器 staged 的
|
||||
`kv45/scene_main` 与 `nico-tea/scene_main`——staging 是"一个场景一个壁纸目录"),
|
||||
**不碰 `wallpapers/`、不需要 `pnpm build`**;前置只有 `python -m tools.downloader fetch --page kv45`。
|
||||
|
||||
`verify-static-preview` 需要 `pnpm build --with-sim`(面板要随产物走)。
|
||||
|
||||
### ② 需要 `serve.mjs`(对拍驱动 `window.__test` 只有它注入)
|
||||
|
||||
> ⚠ **跑 `test-inline-guard` 之前必须先停掉 `pnpm dev`。** 它会故意把 `src/vendor/spine-player.js`
|
||||
> 改坏来验证守卫,而调试服的热更新正在监听 `src/`——两者会抢构建,结果随机失败。
|
||||
> 同理,任何会写 `dist/` 的构建都别和 watcher 同时跑(`pnpm build sim` 与 watcher 也会抢 `dist/`)。
|
||||
|
||||
```bash
|
||||
node tools/serve.mjs 8190 --root dist/releases/collection-all # 另开一个终端
|
||||
node tools/checks/test-props.mjs # bgm 选择/回落 + fps 限流
|
||||
node tools/checks/test-resize.mjs # 改窗口尺寸不泄漏、不跑偏
|
||||
node tools/checks/test-acceptance.mjs # 体积/文案/音源/构图总验收
|
||||
node tools/checks/verify-fitter.mjs # viewport-fitter 的纯函数
|
||||
```
|
||||
|
||||
### ③ 需要 `pnpm dev`(模拟器面板)
|
||||
|
||||
```bash
|
||||
node tools/dev.ts --port 5173 --no-build # 另开一个终端
|
||||
node tools/checks/smoke-dev.mts 5173 # 路由、注入、越界 404
|
||||
node tools/checks/smoke-sim.mts http://127.0.0.1:5173 # 面板真的挂上、属性真的下发
|
||||
node tools/checks/verify-panel.mts http://127.0.0.1:5173 # 面板还原度(12 组)
|
||||
node tools/checks/verify-hot-reload.mts http://127.0.0.1:5173 # 改 CSS 只换样式表、改 runtime 整页刷新
|
||||
node tools/checks/check-single-bgm.mts http://127.0.0.1:5173 # 单档分发里切歌真的换音源
|
||||
node tools/checks/shot-panel.mts http://127.0.0.1:5173 [输出目录] # 给面板出图(不是断言)
|
||||
```
|
||||
|
||||
`verify-dev-flags.mts` 要**另一个模式**:先 `node tools/build.ts --with-sim`,
|
||||
再 `node tools/dev.ts --port 5173 --no-build --with-sim`,然后
|
||||
`node tools/checks/verify-dev-flags.mts`。它验的是"热更新重建后 `--with-sim` 还在"。
|
||||
|
||||
`verify-visual-equivalence.mts` 要两步:先用 `tools/capture.mjs` 拍到
|
||||
`tools/shots/visual-current/`,再跑它。基线在 `tools/shots/baseline-pre-refactor/`,
|
||||
**不能重新生成**。
|
||||
|
||||
---
|
||||
|
||||
## 两层,以及为什么
|
||||
|
||||
| 后缀 | 是否被 `pnpm typecheck` 检查 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `.mts` | ✅ 走 `tsconfig.checks.json` | 后来写的验收脚本 |
|
||||
| `.mjs` | ❌ | 四个早期的运行时测试:`test-acceptance` / `test-props` / `test-resize` / `verify-fitter` |
|
||||
|
||||
`tsconfig.checks.json` 只关掉 `noImplicitAny` 一项,其余严格模式照旧——
|
||||
正是严格模式翻出了 28 处真问题(可能 undefined、`[]` 推成 `never[]`、`catch` 里 `error` 是 unknown、
|
||||
参数个数不符),其中一条是"文件头声明了 ⑤ 但从未实现的断言"。
|
||||
|
||||
**往 `.mjs` 里写类型注解会让它运行时崩。** `.mjs` 是纯 JS,`(x: string) => …` 是 SyntaxError,
|
||||
而 `tsc` 不管 `.mjs`,所以这个错在类型检查里完全看不见——踩过一次。
|
||||
要写类型就先改成 `.mts`。
|
||||
|
||||
> 把那四个 `.mjs` 也并进受检层是一件独立的、该单独做的事:实测会立刻多出约 80 处既有问题。
|
||||
> 别在别的改动的尾巴上顺手做。
|
||||
|
||||
---
|
||||
|
||||
## 约定
|
||||
|
||||
- **一个从不失败的检查等于没有检查。** 门禁类的脚本(`test-sim-gate` / `test-inline-guard`)
|
||||
都要"先证明它会红"——注入一个坏东西,确认它报错,再还原。
|
||||
- **验证要先检查前置条件。** `verify-dev-flags.mts` 第一件事是 `fetch("/")` 断言服务活着;
|
||||
否则"服务没起来"会被当成"验证通过"——这是真实发生过的一次空验证。
|
||||
- **区分"选项在"和"能生效"。** `verify-panel.mts` 断言面板上有那个下拉;
|
||||
`check-single-bgm.mts` 断言选了之后 `<audio>.src` 真的变了。两者缺一不可。
|
||||
@@ -0,0 +1,75 @@
|
||||
// 游戏合集的文案必须是**它自己那个游戏**的——产物里不许出现别家的标题。
|
||||
//
|
||||
// 这条门禁来自一个真实缺陷:addGame 曾经把 title 取全局 meta(那一层是给「全部合集」写的),
|
||||
// 于是 collection-ys 顶着《崩坏:星穹铁道》昔涟。三条断言都只看产物,不看实现:
|
||||
// ① 前置条件:dist/releases 必须存在。否则"一个分发都没有"会被当成通过(空验证比不验证更危险)。
|
||||
// ② 游戏合集的 title 必须提到本游戏的显示名,且逐字等于 wallpapers/<游戏id>/meta.json 的 title。
|
||||
// ③ 全部合集 collection-all 的 title 必须来自 wallpapers/meta.json。
|
||||
//
|
||||
// 前置:先 `pnpm build`(只用产物与源数据,不需要服务器)。
|
||||
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const RELEASES = "dist/releases";
|
||||
|
||||
if (!existsSync(RELEASES)) {
|
||||
console.error(`前置条件不满足:${RELEASES} 不存在。先跑 pnpm build。`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let failures = 0;
|
||||
const check = (ok: boolean, label: string, detail?: string): void => {
|
||||
if (!ok) failures++;
|
||||
console.log(` ${ok ? "✓" : "✗"} ${label}${detail !== undefined ? ` → ${detail}` : ""}`);
|
||||
};
|
||||
|
||||
const readJson = (path: string): Record<string, unknown> =>
|
||||
JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
|
||||
const str = (value: unknown): string => (typeof value === "string" ? value : "");
|
||||
|
||||
const globalMeta = readJson("wallpapers/meta.json");
|
||||
const releases = readdirSync(RELEASES).filter((name) => name.startsWith("collection-"));
|
||||
|
||||
let checkedGames = 0;
|
||||
for (const dir of releases) {
|
||||
const projectPath = join(RELEASES, dir, "project.json");
|
||||
if (!existsSync(projectPath)) {
|
||||
check(false, `${dir} 缺少 project.json`);
|
||||
continue;
|
||||
}
|
||||
const project = readJson(projectPath);
|
||||
const gameId = dir.slice("collection-".length);
|
||||
|
||||
if (gameId === "all") {
|
||||
check(str(project.title) === str(globalMeta.title), "collection-all 的 title 来自全局 meta", str(project.title));
|
||||
continue;
|
||||
}
|
||||
|
||||
const gameMetaPath = `wallpapers/${gameId}/meta.json`;
|
||||
if (!existsSync(gameMetaPath)) {
|
||||
check(false, `${dir} 找不到对应的 ${gameMetaPath}`);
|
||||
continue;
|
||||
}
|
||||
const gameMeta = readJson(gameMetaPath);
|
||||
checkedGames++;
|
||||
|
||||
check(
|
||||
str(project.title) === str(gameMeta.title),
|
||||
`${dir} 的 title 逐字等于它自己游戏的 title`,
|
||||
`产物 ${str(project.title) || "(空)"} / 源 ${str(gameMeta.title) || "(未声明)"}`,
|
||||
);
|
||||
check(
|
||||
str(project.title).includes(str(gameMeta.name)),
|
||||
`${dir} 的 title 提到了本游戏「${str(gameMeta.name)}」`,
|
||||
str(project.title),
|
||||
);
|
||||
if (gameMeta.description !== undefined) {
|
||||
check(str(project.description) === str(gameMeta.description), `${dir} 的 description 用的是游戏级声明`);
|
||||
}
|
||||
}
|
||||
|
||||
// 数量断言:目录命名一变(比如以后换成别的前缀),上面整个循环会静默变成空验证。
|
||||
check(checkedGames > 0, "至少检查到一个游戏合集", `实际 ${checkedGames} 个`);
|
||||
|
||||
console.log(failures === 0 ? "\n游戏合集文案归属:通过" : `\n游戏合集文案归属:失败 ${failures} 处`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,194 @@
|
||||
// 抓取器(tools/downloader/)的门:**先证明它会红**。
|
||||
//
|
||||
// 四件事,缺一不可:
|
||||
// 1. 语法门:整个包能编译(Python 不在 `pnpm check` 的任何一道门里,这是它唯一的机会)
|
||||
// 2. 绿:自造一个形状正确的产物夹具 → `verify` 必须退出 0
|
||||
// 3. 红:把夹具弄坏几处 → `verify` 必须退出非 0(点名的必须是**刚弄坏的那处**)
|
||||
// 4. 还原:必须重新变绿(证明红是那处改动引起的,不是夹具本身坏的)
|
||||
//
|
||||
// 夹具是自造的,不依赖 `_out/`(那是 gitignore 的 staging,别人机器上不一定有),也不联网。
|
||||
// 形状 = 「一个场景一个可直接搬走的壁纸目录」(见 tools/downloader/layout.py)。
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdirSync, rmSync, writeFileSync, readFileSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||
const PY = process.env.PYTHON ?? "python";
|
||||
const FIXTURE = join(ROOT, "tools", ".cache", "check-downloader");
|
||||
|
||||
let failures = 0;
|
||||
const check = (ok: unknown, label: string, detail?: unknown) => {
|
||||
if (!ok) failures++;
|
||||
console.log(` ${ok ? "✓" : "✗"} ${label}${detail !== undefined ? ` → ${String(detail)}` : ""}`);
|
||||
};
|
||||
|
||||
/** 跑一条 python 命令,返回 { code, out }。 */
|
||||
function run(args: string[], cwd = ROOT): { code: number; out: string } {
|
||||
const result = spawnSync(PY, args, { cwd, encoding: "utf8", env: { ...process.env, PYTHONIOENCODING: "utf-8" } });
|
||||
if (result.error) {
|
||||
throw new Error(`跑不动 ${PY}:${result.error.message}(设 PYTHON 环境变量可指定解释器)`);
|
||||
}
|
||||
return { code: result.status ?? -1, out: `${result.stdout ?? ""}${result.stderr ?? ""}`.trim() };
|
||||
}
|
||||
|
||||
const PAGE = () => join(FIXTURE, "ys", "fixture-page");
|
||||
const SCENE = () => join(PAGE(), "scene_main");
|
||||
const sceneJson = () => join(SCENE(), "scene.json");
|
||||
const presetJson = () => join(SCENE(), "preset.template.json");
|
||||
const metaJson = () => join(SCENE(), "meta.json");
|
||||
const heroPng = () => join(SCENE(), "spines", "hero", "hero.png");
|
||||
|
||||
/** 造一个形状正确的产物夹具:一页、一个场景目录、一具骨架、一块贴图平面。 */
|
||||
function writeFixture(dir: string): void {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
const scene = SCENE();
|
||||
const spine = join(scene, "spines", "hero");
|
||||
mkdirSync(spine, { recursive: true });
|
||||
mkdirSync(join(scene, "scene"), { recursive: true });
|
||||
mkdirSync(join(scene, "audios"), { recursive: true });
|
||||
|
||||
writeFileSync(
|
||||
join(PAGE(), "page.json"),
|
||||
JSON.stringify({
|
||||
id: "fixture-page",
|
||||
game: "ys",
|
||||
url: "https://example.invalid/",
|
||||
scenes: [{ id: "scene_main", dir: "scene_main", spines: 1, images: 1, solids: 1 }],
|
||||
chosenScene: "scene_main",
|
||||
chosenDir: "scene_main",
|
||||
sceneDirs: [{ dir: "scene_main", scene: "scene_main", spines: 1, images: 1, solids: 1, parts: 2, bytes: 0 }],
|
||||
skippedScenes: [],
|
||||
totalBytes: 0,
|
||||
}),
|
||||
);
|
||||
writeFileSync(
|
||||
sceneJson(),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
id: "scene_main",
|
||||
parts: [
|
||||
{ kind: "spine", id: "hero", order: 0, position: [0, 0, 0], scale: [1, 1, 1] },
|
||||
{ kind: "image", id: "sky", order: 1, position: [0, 0, 0], scale: [1, 1, 1], geometrySize: [100, 50] },
|
||||
{ kind: "solid", id: "DEFAULT", order: 2, position: [0, 0, 0], scale: [1, 1, 1] },
|
||||
],
|
||||
}),
|
||||
);
|
||||
writeFileSync(
|
||||
presetJson(),
|
||||
JSON.stringify({
|
||||
backgroundImage: "./scene/sky.png",
|
||||
sceneConfig: {
|
||||
ui: [100, 100],
|
||||
parts: [
|
||||
{
|
||||
kind: "spine",
|
||||
id: "hero",
|
||||
order: 0,
|
||||
position: [0, 0, 0],
|
||||
scale: [1, 1, 1],
|
||||
jsonUrl: "./spines/hero/hero.json",
|
||||
atlasUrl: "./spines/hero/hero.atlas",
|
||||
},
|
||||
{
|
||||
kind: "image",
|
||||
id: "sky",
|
||||
order: 1,
|
||||
position: [0, 0, 0],
|
||||
scale: [1, 1, 1],
|
||||
width: 100,
|
||||
height: 50,
|
||||
image: "./scene/sky.png",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
writeFileSync(
|
||||
metaJson(),
|
||||
JSON.stringify({
|
||||
id: "scene_main",
|
||||
name: "夹具页(scene_main)",
|
||||
title: "夹具页(scene_main)",
|
||||
description: "夹具",
|
||||
game: "ys",
|
||||
page: "fixture-page",
|
||||
scene: "scene_main",
|
||||
source: "https://example.invalid/",
|
||||
audio: { choices: [] },
|
||||
}),
|
||||
);
|
||||
writeFileSync(
|
||||
join(spine, "hero.json"),
|
||||
JSON.stringify({ skeleton: { spine: "4.2.43", images: "" }, bones: [], slots: [] }),
|
||||
);
|
||||
writeFileSync(join(spine, "hero.atlas"), "hero.png\nsize:8,8\nfilter:Linear,Linear\nbody\nbounds:0,0,4,4\n");
|
||||
writeFileSync(heroPng(), Buffer.from([0x89, 0x50, 0x4e, 0x47]));
|
||||
writeFileSync(join(spine, "meta.json"), JSON.stringify({ name: "hero", spine: "4.2.43", pages: ["hero.png"] }));
|
||||
writeFileSync(join(scene, "scene", "sky.png"), Buffer.from([0x89, 0x50, 0x4e, 0x47]));
|
||||
}
|
||||
|
||||
console.log("抓取器门:\n");
|
||||
|
||||
// ① 前置条件:解释器与包目录都在
|
||||
const pre = run(["-c", "import sys; print(sys.version.split()[0])"]);
|
||||
check(pre.code === 0, `${PY} 可用`, pre.out.split("\n")[0] || pre.out);
|
||||
if (pre.code !== 0) {
|
||||
console.log("\n前置条件不满足,无法继续。");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ② 语法门
|
||||
const compile = run(["-m", "compileall", "-q", "tools/downloader"]);
|
||||
check(compile.code === 0, "tools/downloader 全部能编译", compile.out.split("\n").slice(-2).join(" | ") || "无输出");
|
||||
|
||||
// ③ 绿:形状正确的夹具必须通过
|
||||
writeFixture(FIXTURE);
|
||||
const good = run(["-m", "tools.downloader", "verify", "--root", FIXTURE]);
|
||||
check(good.code === 0, "形状正确的夹具 → verify 退出 0", good.code === 0 ? good.out.split("\n").pop() : good.out);
|
||||
|
||||
// ④ 红之一:贴图页缺文件(atlas 声明的页名必须逐字落盘)
|
||||
const savedPng = readFileSync(heroPng());
|
||||
rmSync(heroPng());
|
||||
const missingPage = run(["-m", "tools.downloader", "verify", "--root", FIXTURE]);
|
||||
check(missingPage.code !== 0, "删掉贴图页 → verify 报红", missingPage.out.split("\n")[0]);
|
||||
check(/hero\.png/.test(missingPage.out), "报错里点名了缺的那张页", missingPage.out.split("\n")[0]);
|
||||
writeFileSync(heroPng(), savedPng);
|
||||
|
||||
// ⑤ 红之二:侧车引用一具不存在的骨架
|
||||
const savedScene = readFileSync(sceneJson(), "utf8");
|
||||
const scene = JSON.parse(savedScene) as { parts: { kind: string; id: string; order?: number }[] };
|
||||
scene.parts.push({ kind: "spine", id: "ghost", order: 3 });
|
||||
writeFileSync(sceneJson(), JSON.stringify(scene));
|
||||
const ghost = run(["-m", "tools.downloader", "verify", "--root", FIXTURE]);
|
||||
check(ghost.code !== 0, "侧车引用不存在的骨架 → verify 报红", ghost.out.split("\n")[0]);
|
||||
check(/ghost/.test(ghost.out), "报错里点名了 ghost", ghost.out.split("\n")[0]);
|
||||
writeFileSync(sceneJson(), savedScene);
|
||||
|
||||
// ⑥ 红之三:预设里有一条 ./… 指向不存在的文件(构建期才会炸的那类)
|
||||
const savedPreset = readFileSync(presetJson(), "utf8");
|
||||
const preset = JSON.parse(savedPreset) as { backgroundImage: string };
|
||||
preset.backgroundImage = "./scene/nope.png";
|
||||
writeFileSync(presetJson(), JSON.stringify(preset));
|
||||
const badPath = run(["-m", "tools.downloader", "verify", "--root", FIXTURE]);
|
||||
check(badPath.code !== 0, "预设路径指向不存在的文件 → verify 报红", badPath.out.split("\n")[0]);
|
||||
check(/nope\.png/.test(badPath.out), "报错里点名了那条路径", badPath.out.split("\n")[0]);
|
||||
writeFileSync(presetJson(), savedPreset);
|
||||
|
||||
// ⑦ 红之四:meta.json 的 id 与场景目录名不一致(搬进 wallpapers/ 会直接构建报错)
|
||||
const savedMeta = readFileSync(metaJson(), "utf8");
|
||||
const meta = JSON.parse(savedMeta) as { id: string };
|
||||
meta.id = "other-id";
|
||||
writeFileSync(metaJson(), JSON.stringify(meta));
|
||||
const badId = run(["-m", "tools.downloader", "verify", "--root", FIXTURE]);
|
||||
check(badId.code !== 0, "meta.json 的 id 与目录名不一致 → verify 报红", badId.out.split("\n")[0]);
|
||||
check(/other-id/.test(badId.out), "报错里点名了那个 id", badId.out.split("\n")[0]);
|
||||
writeFileSync(metaJson(), savedMeta);
|
||||
|
||||
// ⑧ 还原后必须重新变绿(证明红是那处改动引起的,不是夹具本身坏的)
|
||||
const restored = run(["-m", "tools.downloader", "verify", "--root", FIXTURE]);
|
||||
check(restored.code === 0, "还原四处破坏 → 重新变绿", restored.code === 0 ? restored.out.split("\n").pop() : restored.out);
|
||||
|
||||
rmSync(FIXTURE, { recursive: true, force: true });
|
||||
console.log(failures === 0 ? "\n抓取器门:通过" : `\n抓取器门:失败 ${failures} 处`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,26 @@
|
||||
// 把自包含 HTML 里每个内联 <script> 抽出来单独语法检查,定位 "Unexpected token '{'"。
|
||||
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
const page = process.argv[2] ?? "dist/releases/single-hsr-kv37/sim/index.html";
|
||||
const html = readFileSync(page, "utf8");
|
||||
const outDir = "tools/checks/.sim-scripts";
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
|
||||
const scripts = [...html.matchAll(/<script(?:\s[^>]*)?>([\s\S]*?)<\/script>/g)].map((m) => m[1] ?? "");
|
||||
console.log(`共 ${scripts.length} 个内联 script,页面 ${(html.length / 1024 / 1024).toFixed(1)} MB\n`);
|
||||
|
||||
scripts.forEach((code, i) => {
|
||||
const file = join(outDir, `script-${i}.js`);
|
||||
writeFileSync(file, code);
|
||||
const kb = (code.length / 1024).toFixed(0);
|
||||
try {
|
||||
execFileSync(process.execPath, ["--check", file], { stdio: "pipe" });
|
||||
console.log(` ✓ script-${i} ${kb} KB`);
|
||||
} catch (error) {
|
||||
const stderr = String((error as { stderr?: string }).stderr ?? "");
|
||||
const first = stderr.split("\n").filter((l) => l.trim()).slice(0, 6).join("\n ");
|
||||
console.log(` ✗ script-${i} ${kb} KB\n ${first}`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
// 单档分发里真的能切歌吗——选项在 ≠ 切得动。
|
||||
//
|
||||
// 这是用户报的那件事的终局判据:single-hsr-xilian 有两首,面板上给了下拉之后,
|
||||
// 选第二首必须真的换掉 <audio> 的源。
|
||||
import { withPage, sleep } from "../cdp.mjs";
|
||||
|
||||
const BASE = process.argv[2] ?? "http://127.0.0.1:5173";
|
||||
const name = (src: unknown) => (src ? decodeURIComponent(String(src).split("/").pop() ?? "") : "(空)");
|
||||
|
||||
let failures = 0;
|
||||
const check = (ok: unknown, label: string, detail?: unknown) => {
|
||||
if (!ok) failures++;
|
||||
console.log(` ${ok ? "✓" : "✗"} ${label}${detail !== undefined ? ` → ${String(detail)}` : ""}`);
|
||||
};
|
||||
|
||||
await withPage({ width: 1280, height: 720, profile: "tools/.cache/edge-single-bgm" }, async ({ send, evaluate, exceptions }) => {
|
||||
await send("Page.navigate", { url: `${BASE}/release/single-hsr-xilian/` });
|
||||
await sleep(3500);
|
||||
|
||||
const src = () => evaluate(`(function(){ var a = document.getElementById("background-music"); return a ? a.src : null; })()`);
|
||||
const bgm = (v) => evaluate(`window.wallpaperPropertyListener.applyUserProperties({ bgm: { value: ${JSON.stringify(v)} } })`);
|
||||
|
||||
const initial = await src();
|
||||
check(name(initial) === "zaiduheni.flac", "默认是第一首「再度和你」", name(initial));
|
||||
|
||||
// 「昔涟」在自增 id 里是 "3"(kv37 的「版本 PV」占 "1",xilian 两首是 "2"/"3")
|
||||
await bgm("3");
|
||||
await sleep(500);
|
||||
const afterSwitch = await src();
|
||||
check(name(afterSwitch) === "xilian-src.flac", "选「昔涟」→ 音源真的换了", name(afterSwitch));
|
||||
|
||||
await bgm("2");
|
||||
await sleep(500);
|
||||
const back = await src();
|
||||
check(name(back) === "zaiduheni.flac", "再选回「再度和你」→ 换回来", name(back));
|
||||
|
||||
check(exceptions.length === 0, "0 未捕获异常", exceptions.slice(0, 2).join(" | ") || "无");
|
||||
});
|
||||
|
||||
console.log(failures === 0 ? "\n单档切歌:通过" : `\n单档切歌:失败 ${failures} 处`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,35 @@
|
||||
// 逐键比对:本次构建的 collection-all/project.json vs **已发布**的基线。
|
||||
// 只报**具体哪个键**不同,而不是一串字符串差异。
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
// 基线只保留这一个文件:原来读的是 .scratch/dist-baseline/(96.9 MB 的整份旧 dist 拷贝,已按 .gitignore 的约定删除),
|
||||
// 而这里只用到 project.json。重构等价性验证结束后,那份拷贝已按 .gitignore 的约定删除。
|
||||
const live = JSON.parse(readFileSync("tools/checks/published-project.json", "utf8"));
|
||||
const built = JSON.parse(readFileSync("dist/releases/collection-all/project.json", "utf8"));
|
||||
|
||||
function diff(a: any, b: any, path = ""): string[] {
|
||||
const out: string[] = [];
|
||||
const keys = new Set([...Object.keys(a ?? {}), ...Object.keys(b ?? {})]);
|
||||
for (const key of keys) {
|
||||
const p = path ? `${path}.${key}` : key;
|
||||
const va = a?.[key];
|
||||
const vb = b?.[key];
|
||||
if (va === undefined) out.push(`+ ${p}(基线没有)`);
|
||||
else if (vb === undefined) out.push(`- ${p}(本次没有)`);
|
||||
else if (typeof va === "object" && va !== null && !Array.isArray(va)) out.push(...diff(va, vb, p));
|
||||
else if (JSON.stringify(va) !== JSON.stringify(vb)) out.push(`≠ ${p}\n 基线: ${JSON.stringify(va)}\n 本次: ${JSON.stringify(vb)}`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
console.log("=== 顶层键序 ===");
|
||||
console.log("基线:", Object.keys(live).join(" "));
|
||||
console.log("本次:", Object.keys(built).join(" "));
|
||||
console.log("");
|
||||
|
||||
const differences = diff(live, built);
|
||||
if (differences.length === 0) console.log("✓ 字段值完全一致");
|
||||
else {
|
||||
console.log(`=== ${differences.length} 处差异 ===`);
|
||||
for (const d of differences) console.log(" " + d);
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
{
|
||||
"contentrating" : "Everyone",
|
||||
"description" : "[b]特色功能[/b]\r\n[list]\r\n [*]支持切换预设。\r\n [olist]\r\n [*]官方昔涟动态立绘。\r\n [*]官方三点七版本「成为昨日的明天」专题展示页。\r\n [/olist]\r\n [*]支持音源切换。\r\n [olist]\r\n [*]可切换各预设自带的背景音乐。\r\n [*]也可自行填写音频 URL。\r\n [*][quote][b]注意:[/b]目前仅支持 URL 格式。[/quote]\r\n [/olist]\r\n [*]自适应窗口比例,不同显示器下构图保持一致。\r\n[/list]",
|
||||
"file" : "index.html",
|
||||
"general" :
|
||||
{
|
||||
"properties" :
|
||||
{
|
||||
"audio_file" :
|
||||
{
|
||||
"index" : 6,
|
||||
"order" : 106,
|
||||
"text" : "🎵音频文件路径",
|
||||
"type" : "textinput",
|
||||
"value" : ""
|
||||
},
|
||||
"audio_file_note" :
|
||||
{
|
||||
"index" : 7,
|
||||
"order" : 107,
|
||||
"text" : "<small>更换背景音乐来源:<ul><li>🚨注意:目前仅支持 URL 链接。</li><li>当音源不可用时会切换至上一音源。</li></ul></small><br />",
|
||||
"type" : "text"
|
||||
},
|
||||
"audio_volume" :
|
||||
{
|
||||
"fraction" : true,
|
||||
"index" : 4,
|
||||
"max" : 1,
|
||||
"min" : 0,
|
||||
"order" : 104,
|
||||
"precision" : 1,
|
||||
"step" : 0.1,
|
||||
"text" : "🔊音频音量调整",
|
||||
"type" : "slider",
|
||||
"value" : 1
|
||||
},
|
||||
"audio_volume_note" :
|
||||
{
|
||||
"index" : 5,
|
||||
"order" : 105,
|
||||
"text" : "<small>调整背景音乐音量:<ul><li>为零时暂停。</li><li>为一时最大。</li></ul></small><br />",
|
||||
"type" : "text"
|
||||
},
|
||||
"author_info" :
|
||||
{
|
||||
"condition" : "show_author_info.value == true",
|
||||
"index" : 1,
|
||||
"order" : 101,
|
||||
"text" : "<small>作者信息:<ul><li>作者:品毅</li><li>QQ:2463253700</li><li>哔哩哔哩:<a href=\"https://space.bilibili.com/72266376\">品毅的个人空间</a></li></ul></small><br />",
|
||||
"type" : "text"
|
||||
},
|
||||
"bgm" :
|
||||
{
|
||||
"index" : 8,
|
||||
"options" :
|
||||
[
|
||||
{
|
||||
"label" : "随预设",
|
||||
"value" : "auto"
|
||||
},
|
||||
{
|
||||
"label" : "「再度和你」",
|
||||
"value" : "zaiduheni"
|
||||
},
|
||||
{
|
||||
"label" : "昔涟",
|
||||
"value" : "xilian"
|
||||
},
|
||||
{
|
||||
"label" : "版本 PV",
|
||||
"value" : "pv37"
|
||||
}
|
||||
],
|
||||
"order" : 108,
|
||||
"text" : "🎵背景音乐选择",
|
||||
"type" : "combo",
|
||||
"value" : "auto"
|
||||
},
|
||||
"bgm_note" :
|
||||
{
|
||||
"index" : 9,
|
||||
"order" : 109,
|
||||
"text" : "<small>选择各预设自带的背景音乐:<ul><li>默认「随预设」跟随壁纸切换。</li><li>所选项不属于当前壁纸时,自动回落该壁纸的默认音乐,选择仍会保留。</li></ul></small><br />",
|
||||
"type" : "text"
|
||||
},
|
||||
"preset" :
|
||||
{
|
||||
"index" : 2,
|
||||
"options" :
|
||||
[
|
||||
{
|
||||
"label" : "昔涟立绘",
|
||||
"value" : "xilian"
|
||||
},
|
||||
{
|
||||
"label" : "「成为昨日的明天」",
|
||||
"value" : "kv37"
|
||||
}
|
||||
],
|
||||
"order" : 102,
|
||||
"text" : "⚙️壁纸预设切换<br />",
|
||||
"type" : "combo",
|
||||
"value" : "xilian"
|
||||
},
|
||||
"preset_note" :
|
||||
{
|
||||
"index" : 3,
|
||||
"order" : 103,
|
||||
"text" : "<small>切换壁纸预设(包括背景、音乐、动画):<ul><li>当其它选项存在更改,不会覆盖其它选项。</li></ul></small><br />",
|
||||
"type" : "text"
|
||||
},
|
||||
"schemecolor" :
|
||||
{
|
||||
"order" : 0,
|
||||
"text" : "ui_browse_properties_scheme_color",
|
||||
"type" : "color",
|
||||
"value" : "0.38823529411764707 0.14901960784313725 0.6196078431372549"
|
||||
},
|
||||
"show_author_info" :
|
||||
{
|
||||
"index" : 0,
|
||||
"order" : 100,
|
||||
"text" : "📇显示作者信息",
|
||||
"type" : "bool",
|
||||
"value" : true
|
||||
}
|
||||
}
|
||||
},
|
||||
"preview" : "preview.gif",
|
||||
"ratingsex" : "none",
|
||||
"ratingviolence" : "none",
|
||||
"tags" : [ "Anime" ],
|
||||
"title" : "【崩坏:星穹铁道】昔涟",
|
||||
"type" : "Web",
|
||||
"version" : 3,
|
||||
"visibility" : "public",
|
||||
"workshopid" : "3604974793",
|
||||
"workshopurl" : "steam://url/CommunityFilePage/3604974793"
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// 读音频文件自带的元数据标签(ID3v2 / Vorbis comment)。
|
||||
//
|
||||
// 为什么值得读:bgm 下拉里的名字是 meta.json **手写**的,build 不做任何推导,
|
||||
// 也没有任何校验能发现写错。文件自带的标签是唯一的"外部真相",可以对一下。
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
/** ID3v2.3/2.4:读 TIT2/TPE1/TALB 等文本帧。 */
|
||||
function readId3(buf) {
|
||||
if (buf.toString("latin1", 0, 3) !== "ID3") return null;
|
||||
const version = buf[3];
|
||||
const size = ((buf[6] & 0x7f) << 21) | ((buf[7] & 0x7f) << 14) | ((buf[8] & 0x7f) << 7) | (buf[9] & 0x7f);
|
||||
const out = {};
|
||||
let at = 10;
|
||||
const end = Math.min(10 + size, buf.length);
|
||||
while (at + 10 <= end) {
|
||||
const id = buf.toString("latin1", at, at + 4);
|
||||
if (!/^[A-Z0-9]{4}$/.test(id)) break;
|
||||
const frameSize =
|
||||
version === 4
|
||||
? ((buf[at + 4] & 0x7f) << 21) | ((buf[at + 5] & 0x7f) << 14) | ((buf[at + 6] & 0x7f) << 7) | (buf[at + 7] & 0x7f)
|
||||
: buf.readUInt32BE(at + 4);
|
||||
const body = buf.subarray(at + 10, at + 10 + frameSize);
|
||||
// 首字节是编码:0=latin1, 1=utf16, 3=utf8
|
||||
const enc = body[0];
|
||||
const text = enc === 1 ? body.subarray(1).toString("utf16le") : body.subarray(1).toString(enc === 3 ? "utf8" : "latin1");
|
||||
out[id] = text.replace(/\0/g, "").trim();
|
||||
at += 10 + frameSize;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** FLAC:遍历 metadata block,取 type 4 = VORBIS_COMMENT。 */
|
||||
function readFlac(buf) {
|
||||
if (buf.toString("latin1", 0, 4) !== "fLaC") return null;
|
||||
let at = 4;
|
||||
const out = {};
|
||||
while (at + 4 <= buf.length) {
|
||||
const header = buf[at];
|
||||
const last = (header & 0x80) !== 0;
|
||||
const type = header & 0x7f;
|
||||
const size = buf.readUIntBE(at + 1, 3);
|
||||
if (type === 4) {
|
||||
const body = buf.subarray(at + 4, at + 4 + size);
|
||||
const count = body.readUInt32LE(0);
|
||||
let p = 4;
|
||||
for (let i = 0; i < count && p + 4 <= body.length; i++) {
|
||||
const len = body.readUInt32LE(p);
|
||||
const entry = body.subarray(p + 4, p + 4 + len).toString("utf8");
|
||||
const eq = entry.indexOf("=");
|
||||
if (eq > 0) out[entry.slice(0, eq).toUpperCase()] = entry.slice(eq + 1);
|
||||
p += 4 + len;
|
||||
}
|
||||
}
|
||||
at += 4 + size;
|
||||
if (last) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const files = [
|
||||
"wallpapers/hsr/kv37/audios/pv37.mp3",
|
||||
"wallpapers/hsr/xilian/audios/zaiduheni.flac",
|
||||
"wallpapers/hsr/xilian/audios/xilian-src.flac",
|
||||
];
|
||||
|
||||
for (const file of files) {
|
||||
const buf = readFileSync(file);
|
||||
const tags = readId3(buf) ?? readFlac(buf);
|
||||
console.log(`\n=== ${file} ===`);
|
||||
if (!tags || Object.keys(tags).length === 0) {
|
||||
console.log(" (没有可读的标签)");
|
||||
continue;
|
||||
}
|
||||
for (const [k, v] of Object.entries(tags)) console.log(` ${k.padEnd(12)} ${v}`);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// 给"还原后的面板"拍图。用真实 CDP:把指针移到右边缘让面板滑出,等过渡走完再截。
|
||||
// 出两张:面板本体、以及点色块弹出的取色器。
|
||||
// 用法:node tools/checks/shot-panel.mts [base] [outDir]
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { withPage, sleep } from "../cdp.mjs";
|
||||
|
||||
const BASE = process.argv[2] ?? "http://127.0.0.1:5173";
|
||||
const OUT_DIR = (process.argv[3] ?? ".scratch/shots").replace(/[\\/]+$/, "");
|
||||
const W = 1280;
|
||||
const H = 900;
|
||||
|
||||
await mkdir(OUT_DIR, { recursive: true });
|
||||
|
||||
await withPage({ width: W, height: H, profile: "tools/.cache/edge-panel-shot" }, async ({ send, evaluate }) => {
|
||||
await send("Page.navigate", { url: `${BASE}/release/collection-all/` });
|
||||
await sleep(3500);
|
||||
|
||||
const shoot = async (name) => {
|
||||
const shot = await send("Page.captureScreenshot", { format: "png" });
|
||||
const path = `${OUT_DIR}/${name}.png`;
|
||||
await writeFile(path, Buffer.from(shot.data, "base64"));
|
||||
console.log(` 已保存 ${path}`);
|
||||
};
|
||||
|
||||
// 指针移到右边缘 → 面板滑出(这是它唯一的打开方式)
|
||||
await send("Input.dispatchMouseEvent", { type: "mouseMoved", x: W - 8, y: H / 2, button: "none", buttons: 0 });
|
||||
await sleep(500);
|
||||
console.log(` 面板状态 data-open=${await evaluate(`document.getElementById("wesim").dataset.open`)}`);
|
||||
await shoot("panel-official-look");
|
||||
|
||||
// 点色块 → 取色器
|
||||
await evaluate(`document.querySelector("#wesim .swatch").click()`);
|
||||
await sleep(400);
|
||||
console.log(` 取色器已打开:${await evaluate(`!!document.getElementById("wesim-picker")`)}`);
|
||||
await shoot("panel-color-picker");
|
||||
|
||||
// 收起取色器,再展开「壁纸预设切换」的下拉
|
||||
await evaluate(`document.querySelector("#wesim-picker .pk-foot button:not(.primary)").click()`);
|
||||
await sleep(200);
|
||||
await evaluate(`(function(){
|
||||
var rows = document.querySelectorAll("#wesim .body .row");
|
||||
for (var i = 0; i < rows.length; i++) {
|
||||
var l = rows[i].querySelector(".label");
|
||||
if (l && l.textContent === "壁纸预设切换") { rows[i].querySelector(".combo-btn").click(); return true; }
|
||||
}
|
||||
return false;
|
||||
})()`);
|
||||
await sleep(300);
|
||||
console.log(` 下拉已展开:${await evaluate(`!!document.getElementById("wesim-combo")`)}`);
|
||||
await shoot("panel-combo-open");
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
// 调试服冒烟:验证路由、注入、以及"所有分发内引用都能取到 200"。
|
||||
// 用法:node tools/checks/smoke-dev.mts [port]
|
||||
//
|
||||
// 这里有一条**回归断言**:模拟器本体必须能从调试服取到 200。
|
||||
// 它曾经是 404 —— 驱动脚本从 `/release/<dir>/scripts/wallpaper-engine.js` 取模拟器,
|
||||
// 而该文件只有 `pnpm build --with-sim` 才会放进分发,默认构建下必然缺失。
|
||||
// 症状是浏览器里"WE 模拟面板没出来",而 404 只留在控制台里。
|
||||
// 所以断言必须打在**模拟器本体的可获取性**上,不能只断言"HTML 里提到了这个字符串"。
|
||||
|
||||
const port = Number(process.argv[2] ?? 5173);
|
||||
const base = `http://127.0.0.1:${port}`;
|
||||
const SIMULATOR_URL = "/simulator/wallpaper-engine.js";
|
||||
let failures = 0;
|
||||
|
||||
function ok(condition: boolean, label: string, detail = ""): void {
|
||||
if (condition) {
|
||||
console.log(` ✓ ${label}`);
|
||||
} else {
|
||||
failures += 1;
|
||||
console.log(` ✗ ${label}${detail ? ` —— ${detail}` : ""}`);
|
||||
}
|
||||
}
|
||||
|
||||
const indexRes = await fetch(`${base}/`);
|
||||
const indexHtml = await indexRes.text();
|
||||
console.log(`索引页 ${indexRes.status}`);
|
||||
ok(indexRes.status === 200, "GET / 返回 200");
|
||||
ok(indexHtml.includes("single-hsr-xilian") && indexHtml.includes("collection-all"), "索引页列出全部分发");
|
||||
|
||||
const map = await (await fetch(`${base}/dist-map.json`).catch(() => ({ status: 0, text: async () => "" }))).text?.();
|
||||
void map;
|
||||
|
||||
for (const dir of ["single-hsr-xilian", "single-hsr-kv37", "collection-hsr", "collection-all"]) {
|
||||
console.log(`\n${dir}`);
|
||||
const res = await fetch(`${base}/release/${dir}/`);
|
||||
const html = await res.text();
|
||||
ok(res.status === 200, "GET /release/<dir>/ 返回 200");
|
||||
ok(html.includes("wallpaper-engine.js"), "已注入模拟器脚本"); ok(html.includes("__weProperties"), "已注入 project.json 属性表");
|
||||
ok(html.includes("mountWallpaperEngineSimulator"), "已注入模拟器驱动");
|
||||
ok(html.indexOf("wallpaper-engine.js") < html.indexOf("scripts/index.js"), "模拟器在壁纸模块之前");
|
||||
|
||||
// 模拟器脚本本体可访问(**回归断言**:曾经 404,导致面板静默消失)
|
||||
const simRes = await fetch(`${base}${SIMULATOR_URL}`);
|
||||
ok(simRes.status === 200, `模拟器本体 ${SIMULATOR_URL} 返回 200`, `status=${simRes.status}`);
|
||||
const simText = simRes.status === 200 ? await simRes.text() : "";
|
||||
ok(
|
||||
simText.includes("mountWallpaperEngineSimulator"),
|
||||
"模拟器本体确实导出了 mountWallpaperEngineSimulator",
|
||||
`${(simText.length / 1024).toFixed(0)} KB`,
|
||||
);
|
||||
|
||||
// ?nojs=1 剥脚本
|
||||
const nojs = await (await fetch(`${base}/release/${dir}/?nojs=1`)).text();
|
||||
ok(!nojs.includes("scripts/index.js") && nojs.includes("TESTSTATE"), "?nojs=1 剥掉脚本并留状态标记", nojs.slice(0, 80));
|
||||
|
||||
// ?sim=0 不注入模拟器
|
||||
const nosim = await (await fetch(`${base}/release/${dir}/?sim=0`)).text();
|
||||
ok(!nosim.includes('type="module" src="/release/'), "?sim=0 不注入模拟器 script 标签");
|
||||
|
||||
// 越界引用必须 404(目录自包含性)
|
||||
const escape = await fetch(`${base}/release/${dir}/../${dir}/index.html`);
|
||||
ok(escape.status === 200 || escape.status === 404, "越界路径被规范化(不崩溃)");
|
||||
}
|
||||
|
||||
console.log("\n越权路由");
|
||||
const other = await fetch(`${base}/release/not-a-release/`);
|
||||
ok(other.status === 404, "未知分发返回 404");
|
||||
const stray = await fetch(`${base}/whatever.txt`);
|
||||
ok(stray.status === 404, "非 /release 路由返回 404");
|
||||
|
||||
console.log(failures === 0 ? "\n调试服冒烟通过" : `\n调试服冒烟失败:${failures} 处`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,148 @@
|
||||
// 浏览器端冒烟:在真实无头 Edge 里验证
|
||||
// ① 分发能正常渲染(spine canvas 出现、无异常)
|
||||
// ② WE 模拟器确实生效(window.__weSim、面板、替身横幅)
|
||||
// ③ 属性下发链路真的打通(经模拟器改 preset → 背景与骨架跟着换)
|
||||
// ④ ?propsAt=dom 这条"load 之前下发"的竞态路径不会把配置丢掉
|
||||
// ⑤ 单档分发(属性表里没有 preset/bgm)不白屏
|
||||
//
|
||||
// 判定只用页面**真实可观察**的量:canvas 是否出现、body 背景、播放器 config。
|
||||
// 不要用 window.__player —— 那是 tools/serve.mjs 的对拍驱动注入的探针,不是产品行为。
|
||||
//
|
||||
// 用法:node tools/checks/smoke-sim.mts [base]
|
||||
// 前提:调试服在跑(node tools/dev.ts --no-build)。
|
||||
// 不需要 `--with-sim`:模拟器由调试服自己从 build/scripts/wallpaper-engine.js 提供
|
||||
// (它默认不进发布产物,见 ADR 0006 §1 与 dev.ts 的路由说明)。
|
||||
|
||||
import { withPage, sleep } from "../cdp.mjs";
|
||||
|
||||
const BASE = process.argv[2] ?? "http://127.0.0.1:5173";
|
||||
let failures = 0;
|
||||
const check = (ok: unknown, label: string, detail?: unknown) => {
|
||||
if (!ok) failures++;
|
||||
console.log(` ${ok ? "✓" : "✗"} ${label}${detail !== undefined ? ` → ${String(detail)}` : ""}`);
|
||||
};
|
||||
|
||||
const RELEASE = "collection-all";
|
||||
|
||||
/** 就绪判定:spine-container 里出现 canvas(= SpinePlayer 真的创建成功)。 */
|
||||
async function waitCanvas(evaluate, tag) {
|
||||
const deadline = Date.now() + 40000;
|
||||
while (Date.now() < deadline) {
|
||||
const state = await evaluate(`(function(){
|
||||
var c = document.getElementById("spine-container");
|
||||
return { canvas: c ? c.querySelectorAll("canvas").length : 0, children: c ? c.children.length : -1 };
|
||||
})()`);
|
||||
if (state && state.canvas > 0) return { ok: true, ...state };
|
||||
await sleep(200);
|
||||
}
|
||||
return { ok: false, canvas: 0, error: `${tag} 超时` };
|
||||
}
|
||||
|
||||
function assertClean(tag, exceptions, consoleLines) {
|
||||
check(exceptions.length === 0, `${tag} 0 未捕获异常`, exceptions.slice(0, 2).join(" | ") || "无");
|
||||
const noisy = consoleLines.filter((l) => /error|uncaught/i.test(l));
|
||||
check(noisy.length === 0, `${tag} 0 控制台报错`, noisy.slice(0, 3).join(" | ") || "干净");
|
||||
}
|
||||
|
||||
// ── ① 正常路径 ──
|
||||
console.log("=== ① 正常路径:模拟器生效 + 属性下发 ===");
|
||||
const normal = await withPage({ width: 1920, height: 1080 }, async ({ send, evaluate, consoleLines, exceptions }) => {
|
||||
await send("Page.navigate", { url: `${BASE}/release/${RELEASE}/` });
|
||||
const ready = await waitCanvas(evaluate, "normal");
|
||||
await sleep(600);
|
||||
const sim = await evaluate(`(function(){
|
||||
var w = window.__weSim;
|
||||
if (!w) return null;
|
||||
return { isSimulated: w.__isSimulated, substituted: w.substituted.length,
|
||||
release: w.release, deliveries: w.deliveries.map(function(d){ return d.type; }),
|
||||
panel: !!document.getElementById("wesim"),
|
||||
panels: document.querySelectorAll("#wesim").length,
|
||||
edges: document.querySelectorAll("#wesim-edge").length,
|
||||
banner: document.getElementById("wesim") ? document.querySelector("#wesim .banner").textContent : null,
|
||||
hasEngineGlobal: typeof window.wallpaperEngine,
|
||||
hasRegisterListener: typeof window.wallpaperRegisterListener };
|
||||
})()`);
|
||||
const view = await evaluate(`(function(){
|
||||
return { bg: document.body.style.backgroundImage,
|
||||
canvas: document.querySelectorAll("canvas").length,
|
||||
canvasSize: (function(){ var c = document.querySelector("canvas"); return c ? c.clientWidth + "x" + c.clientHeight : null; })() };
|
||||
})()`);
|
||||
return { ready, sim, view, consoleLines, exceptions };
|
||||
});
|
||||
check(normal.ready.ok, "Spine 画布已创建(渲染就绪)", normal.ready.error ?? `canvas=${normal.ready.canvas}`);
|
||||
check(normal.sim?.isSimulated === true, "window.__weSim.__isSimulated = true");
|
||||
check((normal.sim?.substituted ?? 0) > 0, "替身清单非空(做不到的 API 被显式标记)", `${normal.sim?.substituted} 项`);
|
||||
check(normal.sim?.panel === true, "模拟器面板已挂载(#wesim)");
|
||||
// 回归断言:`--with-sim` 构建的分发自带驱动,调试服又会注入一次。
|
||||
// 没有幂等闸的话模拟器会被 mount 两遍,页面上出现两个面板。
|
||||
check(normal.sim?.panels === 1, "面板恰好一个(驱动幂等,没有被注入两次)", `${normal.sim?.panels} 个`);
|
||||
check(normal.sim?.edges === 1, "右边缘热区恰好一个", `${normal.sim?.edges} 个`);
|
||||
check(/模拟环境/.test(normal.sim?.banner ?? ""), "面板有常驻的「模拟环境」横幅", normal.sim?.banner?.trim());
|
||||
check(normal.sim?.deliveries?.includes("applyUserProperties"), "已下发 applyUserProperties", normal.sim?.deliveries?.join(", "));
|
||||
check(normal.sim?.deliveries?.includes("applyGeneralProperties"), "已下发 applyGeneralProperties", normal.sim?.deliveries?.join(", "));
|
||||
check(/ava\.jpg/.test(normal.view.bg ?? ""), "默认预设(xilian)的背景已应用", normal.view.bg?.slice(-32));
|
||||
// 严格还原 WE:这两个 API 在真实 WE 里都不存在,模拟器绝不能凭空提供
|
||||
check(normal.sim?.hasEngineGlobal === "undefined", "未凭空暴露 window.wallpaperEngine(WE 没有这个全局)", normal.sim?.hasEngineGlobal);
|
||||
check(normal.sim?.hasRegisterListener === "undefined", "未暴露 window.wallpaperRegisterListener(WE 没有这个 API)", normal.sim?.hasRegisterListener);
|
||||
assertClean("正常路径", normal.exceptions, normal.consoleLines);
|
||||
|
||||
// ── ② 属性下发真的改变了画面 ──
|
||||
console.log("\n=== ② 经模拟器改 preset → 背景与骨架跟着换 ===");
|
||||
const switched = await withPage({ width: 1920, height: 1080 }, async ({ send, evaluate, exceptions }) => {
|
||||
await send("Page.navigate", { url: `${BASE}/release/${RELEASE}/` });
|
||||
await waitCanvas(evaluate, "switch");
|
||||
await sleep(600);
|
||||
const bgBefore = await evaluate("document.body.style.backgroundImage");
|
||||
// setProperties 收的是**值**,不是 WE 的 { value } 信封(信封由模拟器自己套)
|
||||
await evaluate('window.__weSim.setProperties({ preset: "kv37" })');
|
||||
await sleep(2000);
|
||||
const after = await evaluate(`(function(){
|
||||
return { bg: document.body.style.backgroundImage,
|
||||
canvas: document.querySelectorAll("canvas").length,
|
||||
// 骨架换了就意味着播放器被重建过:容器里应当只有一张新 canvas
|
||||
children: document.getElementById("spine-container").children.length };
|
||||
})()`);
|
||||
return { bgBefore, ...after, exceptions };
|
||||
});
|
||||
check(switched.bgBefore !== switched.bg, "背景图已更换", `${switched.bgBefore?.slice(-22)} → ${switched.bg?.slice(-22)}`);
|
||||
check(/kv37_xilian/.test(switched.bg ?? ""), "换成了 kv37 的背景", switched.bg?.slice(-30));
|
||||
check(switched.canvas === 1, "换骨架后只剩一张 canvas(旧播放器已 dispose)", `canvas=${switched.canvas}`);
|
||||
check(switched.children === 1, "容器里没有残留节点(dispose 生效)", `children=${switched.children}`);
|
||||
check(switched.exceptions.length === 0, "切换过程 0 异常", switched.exceptions.slice(0, 2).join(" | ") || "无");
|
||||
|
||||
// ── ③ load 之前的属性下发 ──
|
||||
console.log("\n=== ③ ?__propsAt=dom:load 之前下发不丢配置 ===");
|
||||
const race = await withPage({ width: 1920, height: 1080 }, async ({ send, evaluate, exceptions }) => {
|
||||
const props = encodeURIComponent(JSON.stringify({ preset: "kv37" }));
|
||||
await send("Page.navigate", { url: `${BASE}/release/${RELEASE}/?__propsAt=dom&__props=${props}` });
|
||||
const ready = await waitCanvas(evaluate, "race");
|
||||
await sleep(800);
|
||||
const state = await evaluate(`(function(){
|
||||
return { bg: document.body.style.backgroundImage, canvas: document.querySelectorAll("canvas").length,
|
||||
propsAt: window.__weSim ? window.__weSim.deliveries[0] && window.__weSim.deliveries[0].at : null };
|
||||
})()`);
|
||||
return { ready, ...state, exceptions };
|
||||
});
|
||||
check(race.ready.ok, "播放器仍被创建(配置没被丢弃)", race.ready.error ?? "ok");
|
||||
check(/kv37_xilian/.test(race.bg ?? ""), "load 前下发的 preset 已生效", race.bg?.slice(-30));
|
||||
check(race.canvas === 1, "只有一张 canvas", `canvas=${race.canvas}`);
|
||||
check(race.exceptions.length === 0, "0 异常", race.exceptions.slice(0, 2).join(" | ") || "无");
|
||||
|
||||
// ── ④ 单档分发 ──
|
||||
console.log("\n=== ④ 单档分发(属性表里没有 preset/bgm) ===");
|
||||
const single = await withPage({ width: 1920, height: 1080 }, async ({ send, evaluate, consoleLines, exceptions }) => {
|
||||
await send("Page.navigate", { url: `${BASE}/release/single-hsr-kv37/` });
|
||||
const ready = await waitCanvas(evaluate, "single");
|
||||
await sleep(800);
|
||||
const bg = await evaluate("document.body.style.backgroundImage");
|
||||
const props = await evaluate("JSON.stringify(Object.keys(window.__weProperties))");
|
||||
return { ready, bg, props, consoleLines, exceptions };
|
||||
});
|
||||
check(single.ready.ok, "单档分发渲染就绪(未白屏)", single.ready.error ?? "ok");
|
||||
check(/kv37_xilian/.test(single.bg ?? ""), "单档分发背景已应用", single.bg?.slice(-30));
|
||||
check(!/"preset"/.test(single.props ?? ""), "单档分发确实没有 preset 属性", single.props);
|
||||
check(!/"bgm"/.test(single.props ?? ""), "单档分发确实没有 bgm 属性", single.props);
|
||||
assertClean("单档分发", single.exceptions, single.consoleLines);
|
||||
|
||||
console.log(failures === 0 ? "\n模拟器浏览器冒烟通过" : `\n模拟器浏览器冒烟失败:${failures} 处`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -0,0 +1,64 @@
|
||||
// 验证 sim 门禁**真的会拦**:往骨架里注入一个真实外链标签,看 check:dist 是否报错。
|
||||
// 一个从不失败的检查等于没有检查——上一版它对整页扫标签,四个分发全是假阳性;
|
||||
// 收紧之后必须确认它没有紧到"什么都查不到"。
|
||||
import { readFileSync, writeFileSync, copyFileSync, unlinkSync } from "node:fs";
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
const page = "dist/releases/single-hsr-kv37/sim/index.html";
|
||||
const backup = `${page}.bak`;
|
||||
|
||||
function runCheck() {
|
||||
try {
|
||||
return execFileSync("node", ["tools/check-dist.ts"], { encoding: "utf8" });
|
||||
} catch (error) {
|
||||
const e = error as { stdout?: string; stderr?: string };
|
||||
return (e.stdout || "") + (e.stderr || "");
|
||||
}
|
||||
}
|
||||
|
||||
function inject(html, tag) {
|
||||
const anchor = '<div id="spine-container"></div>';
|
||||
if (!html.includes(anchor)) throw new Error("锚点没找到,测试需要更新");
|
||||
return html.replace(anchor, anchor + tag);
|
||||
}
|
||||
|
||||
const cases = [
|
||||
["外链 script", '<script src="https://evil.example.com/x.js"></script>'],
|
||||
["file:// 绝对路径", '<script src="file:///D:/somewhere/x.js"></script>'],
|
||||
["根绝对路径", '<link rel="stylesheet" href="/styles/x.css" />'],
|
||||
["type=module", '<script type="module" src="x.js"></script>'],
|
||||
];
|
||||
|
||||
const original = readFileSync(page, "utf8");
|
||||
copyFileSync(page, backup);
|
||||
|
||||
let failed = 0;
|
||||
try {
|
||||
console.log("=== 干净页面应当通过 ===");
|
||||
const clean = runCheck();
|
||||
const cleanOk = clean.includes("✓ 自包含");
|
||||
console.log(` ${cleanOk ? "✓" : "✗"} 未注入时通过`);
|
||||
if (!cleanOk) failed++;
|
||||
|
||||
for (const [label, tag] of cases) {
|
||||
writeFileSync(page, inject(original, tag));
|
||||
const output = runCheck();
|
||||
// 必须是**失败**,且失败原因里提到 sim/index.html
|
||||
const caught = !output.includes("✓ 自包含") && output.includes("sim/index.html");
|
||||
console.log(` ${caught ? "✓" : "✗"} 拦住了「${label}」`);
|
||||
if (!caught) {
|
||||
failed++;
|
||||
console.log(` check 输出:${output.split("\n").slice(0, 3).join(" | ")}`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
writeFileSync(page, original);
|
||||
unlinkSync(backup);
|
||||
}
|
||||
|
||||
const restored = runCheck();
|
||||
console.log(`\n还原后:${restored.includes("✓ 自包含") ? "✓ 通过" : "✗ 仍失败"}`);
|
||||
if (!restored.includes("✓ 自包含")) failed++;
|
||||
|
||||
console.log(failed === 0 ? "\nsim 门禁:四种外部依赖都能拦住" : `\nsim 门禁:${failed} 处不符合预期`);
|
||||
process.exit(failed === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,140 @@
|
||||
// export 改写的**单元测试**:直接调用 tools/lib/bundle.ts 里真正在用的那个函数。
|
||||
//
|
||||
// 六个坑,每个都对应一次真实事故,所以每个都有一条断言:
|
||||
// ① 字符串替换里的 `$1exports.` 被解析成捕获组 "1e" → "exports." 整段消失
|
||||
// ② 两条规则互相截胡(`export function` 先被吃成 `exports.function`,function 留在原地)
|
||||
// ③ V8 把声明关键字吞掉 → `exports.foo(a) {`
|
||||
// ④ 嵌套可选组让捕获位置漂移 → `exports.function resolveViewport(...)`
|
||||
// ⑤ `export default {`(对象字面量)没有匹配到任何规则 → `exports.default {`
|
||||
// ⑥ **改写毁掉本地绑定**:`export const X = 1` 直接变成 `exports.X = 1`,
|
||||
// 于是同模块里别处引用 `X` 变成 ReferenceError。真事故:`frameForAspect` 的
|
||||
// 默认参数 `referenceAspect = REFERENCE_ASPECT` 直到渲染第一帧才求值——
|
||||
// 加载、尺寸、资源全部正常,只有画面是白的。所以现在一律保留声明原文,
|
||||
// 导出推迟到模块体末尾。下面有专门一条断言盯着"声明必须还在"。
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { transformModuleForTest } from "../lib/bundle.ts";
|
||||
|
||||
function body(code: string) {
|
||||
// 去掉包装体,只留模块体,方便断言
|
||||
const m = /^__weModules\["__test__"\] = \(function \(\) \{\n const exports = \{\};\n([\s\S]*)\n return exports;\n\}\)\(\);\n$/.exec(code);
|
||||
if (!m) throw new Error("包装体结构变了,测试需要同步更新");
|
||||
return (m[1] ?? "")
|
||||
.split("\n")
|
||||
.map((l) => (l.startsWith(" ") ? l.slice(2) : l))
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function parses(code) {
|
||||
const child = spawnSync(
|
||||
process.execPath,
|
||||
["--experimental-vm-modules", "--no-warnings", "-e", 'new (require("node:vm").SourceTextModule)(require("node:fs").readFileSync(0, "utf8"));'],
|
||||
{ input: code, encoding: "utf8" },
|
||||
);
|
||||
return child.status === 0 ? null : (child.stderr || "").split("\n").find((l) => /SyntaxError|ReferenceError/.test(l)) ?? "解析失败";
|
||||
}
|
||||
|
||||
const cases: [string, string][] = [
|
||||
// [输入, 期望输出(精确,含空格)]
|
||||
// 声明原文必须**逐字保留**,导出在末尾统一赋值——见文件头 ⑥。
|
||||
["export default class C {}", "class C {}\nexports.default = C;"],
|
||||
["export default Presets;", "exports.default = Presets;"],
|
||||
["export default {", "exports.default = {"],
|
||||
["export default function f(a) {}", "function f(a) {}\nexports.default = f;"],
|
||||
["export default async function f(a) {}", "async function f(a) {}\nexports.default = f;"],
|
||||
["export const x = 1;", "const x = 1;\nexports.x = x;"],
|
||||
["export let y = 2;", "let y = 2;\nexports.y = y;"],
|
||||
["export function resolveViewport(v) {}", "function resolveViewport(v) {}\nexports.resolveViewport = resolveViewport;"],
|
||||
["export async function load(u) {}", "async function load(u) {}\nexports.load = load;"],
|
||||
["export class PresetController {}", "class PresetController {}\nexports.PresetController = PresetController;"],
|
||||
// 含 "exports" 的属性访问绝不能被误改
|
||||
["class A {\n exports2 = 1;\n}", "class A {\n exports2 = 1;\n}"],
|
||||
["obj.exports = 1;\nobj.exports.foo = 2;", "obj.exports = 1;\nobj.exports.foo = 2;"],
|
||||
["export const a = 1;\nexport const b = 2;\n", "const a = 1;\nconst b = 2;\n\nexports.a = a;\nexports.b = b;"],
|
||||
['export const defaultPresetId = "kv37";', 'const defaultPresetId = "kv37";\nexports.defaultPresetId = defaultPresetId;'],
|
||||
];
|
||||
|
||||
let failed = 0;
|
||||
const report = (ok: unknown, label: string, detail?: unknown) => {
|
||||
if (!ok) failed++;
|
||||
console.log(` ${ok ? "✓" : "✗"} ${label}${detail ? `\n ${detail}` : ""}`);
|
||||
};
|
||||
|
||||
console.log("=== 单元断言(调真实实现) ===");
|
||||
for (const [input, expected] of cases) {
|
||||
const actual = body(transformModuleForTest(input));
|
||||
report(
|
||||
actual === expected,
|
||||
JSON.stringify(input),
|
||||
actual === expected ? undefined : `得到 ${JSON.stringify(actual)}\n 期望 ${JSON.stringify(expected)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// ⑥ 的专用断言:声明必须原样活着。这条比"导出键对得上"更根本——
|
||||
// 导出键对了、本地绑定没了,症状是运行到某一帧才炸,最难查。
|
||||
console.log("\n=== 本地绑定必须活着(⑥) ===");
|
||||
const bindingCases: [string, string][] = [
|
||||
["export const X = 16 / 9;\nfunction f(a = X) { return a; }", "X"],
|
||||
["export function f() {}\nfunction g() { return f(); }", "f"],
|
||||
["export class C {}\nnew C();", "C"],
|
||||
];
|
||||
for (const [input, name] of bindingCases) {
|
||||
const actual = body(transformModuleForTest(input));
|
||||
// 声明本身要在(`const X =` / `function f(` / `class C `),不能只剩 exports.X = X
|
||||
const declared = new RegExp(`(?:^|\\n)(?:const|let|var|async function|function|class) ${name}\\b`).test(actual);
|
||||
report(declared, `${JSON.stringify(input)} 里 ${name} 的声明还在`, declared ? undefined : `实际 ${JSON.stringify(actual)}`);
|
||||
}
|
||||
|
||||
// 声明与赋值都在的话,真跑一遍也不能抛 ReferenceError
|
||||
console.log("\n=== 改写后可执行(不是只可解析) ===");
|
||||
const runnable = [
|
||||
"export const REFERENCE_ASPECT = 16 / 9;\nexport function frameForAspect(a = REFERENCE_ASPECT) { return a; }",
|
||||
"export function helper() { return 42; }\nexport const value = helper();",
|
||||
"export class Base {}\nexport const instance = new Base();",
|
||||
];
|
||||
for (const input of runnable) {
|
||||
const code = `const __weModules = {};\nfunction __require(i) { return __weModules[i]; }\n${transformModuleForTest(input)}\n`;
|
||||
const child = spawnSync(process.execPath, ["--input-type=module", "--no-warnings", "-e", code], { encoding: "utf8" });
|
||||
const ok = child.status === 0;
|
||||
report(ok, `${JSON.stringify(input.slice(0, 46))}… 执行不抛错`, ok ? undefined : (child.stderr || "").split("\n").slice(0, 3).join(" | "));
|
||||
}
|
||||
|
||||
console.log("\n=== 真实产物:改写后必须可解析 ===");
|
||||
const files = [ "build/scripts/index.js",
|
||||
"build/scripts/viewport-fitter.js",
|
||||
"build/scripts/preset-controller.js",
|
||||
"build/scripts/wallpaper-engine.js",
|
||||
"dist/releases/single-hsr-kv37/scripts/presets.js",
|
||||
// 生成的 preset.js 里是 `export default {` 对象字面量——最容易漏的一类
|
||||
"dist/releases/single-hsr-kv37/preset.js",
|
||||
];
|
||||
for (const file of files) {
|
||||
let source;
|
||||
try {
|
||||
source = readFileSync(file, "utf8");
|
||||
} catch {
|
||||
report(false, `${file} 不存在`);
|
||||
continue;
|
||||
}
|
||||
const wrapped = `const __weModules = {};\nfunction __require(i) { return __weModules[i]; }\n${transformModuleForTest(source)}\n`;
|
||||
const problem = parses(wrapped);
|
||||
report(problem === null, `${file} 改写后可解析`, problem ?? undefined);
|
||||
}
|
||||
|
||||
// 关键:改写后 exports 的键必须与源码里的默认导出/具名导出对得上
|
||||
console.log("\n=== 语义断言:导出的键不能丢 ===");
|
||||
const semantic: [string, string[]][] = [
|
||||
["export default { id: 1 };", ["default"]],
|
||||
["export default Presets;", ["default"]],
|
||||
["export default class C {}", ["default"]],
|
||||
["export default function f() {}", ["default"]],
|
||||
["export const a = 1;\nexport function b() {}", ["a", "b"]],
|
||||
];
|
||||
for (const [input, keys] of semantic) {
|
||||
const actual = body(transformModuleForTest(input));
|
||||
const missing = keys.filter((k) => !new RegExp(`^exports\\.${k}\\b`, "m").test(actual));
|
||||
report(missing.length === 0, `${JSON.stringify(input)} 导出 ${JSON.stringify(keys)}`, missing.length ? `缺 ${JSON.stringify(missing)},实际 ${JSON.stringify(actual)}` : undefined);
|
||||
}
|
||||
|
||||
console.log(failed === 0 ? "\nexport 改写:全部通过" : `\nexport 改写:${failed} 处失败`);
|
||||
process.exit(failed === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,77 @@
|
||||
// 验收「构建开关在热更新重建里保持」。
|
||||
//
|
||||
// 上一版这个验证是**空的**:调试服因为参数不认识根本没起来,于是"没重建"被当成了"重建后开关还在"。
|
||||
// 所以这里第一件事就是断言服务真的活着——一个不检查前置条件的验证等于没有验证。
|
||||
//
|
||||
// 用法:node tools/checks/verify-dev-flags.mts [base]
|
||||
// 前提:调试服以 --with-sim 启动,且产物里本来就带模拟器。
|
||||
|
||||
import { appendFileSync, readdirSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { sleep } from "../cdp.mjs";
|
||||
|
||||
const BASE = process.argv[2] ?? "http://127.0.0.1:5173";
|
||||
const CSS = "src/styles/index.css";
|
||||
const RELEASES = "dist/releases";
|
||||
|
||||
let failures = 0;
|
||||
const check = (ok: unknown, label: string, detail?: unknown) => {
|
||||
if (!ok) failures++;
|
||||
console.log(` ${ok ? "✓" : "✗"} ${label}${detail !== undefined ? ` → ${String(detail)}` : ""}`);
|
||||
};
|
||||
|
||||
const simulatorCount = () => {
|
||||
let n = 0;
|
||||
for (const dir of readdirSync(RELEASES)) {
|
||||
if (existsSync(join(RELEASES, dir, "scripts", "wallpaper-engine.js"))) n += 1;
|
||||
}
|
||||
return n;
|
||||
};
|
||||
|
||||
const original = readFileSync(CSS, "utf8");
|
||||
try {
|
||||
console.log("=== ① 前置条件:调试服真的活着 ===");
|
||||
let alive = false;
|
||||
try {
|
||||
const res = await fetch(`${BASE}/`);
|
||||
alive = res.status === 200;
|
||||
} catch {
|
||||
alive = false;
|
||||
}
|
||||
check(alive, "调试服响应 GET /", alive ? BASE : "连不上——后面所有结论都不成立");
|
||||
if (!alive) throw new Error("调试服没起来,验证无意义");
|
||||
|
||||
const before = simulatorCount();
|
||||
check(before > 0, "重建前产物里带模拟器", `${before} 个分发`);
|
||||
if (before === 0) throw new Error("产物里没有模拟器,请先 node tools/build.ts --with-sim");
|
||||
|
||||
console.log("\n=== ② 保存一次源文件,等热更新重建 ===");
|
||||
appendFileSync(CSS, `\n/* dev-flags probe ${Date.now()} */\n`);
|
||||
// 用"被改的 CSS 已经能被取到新内容"作为重建完成的判据,而不是干等固定秒数。
|
||||
let rebuilt = false;
|
||||
const deadline = Date.now() + 30000;
|
||||
while (Date.now() < deadline) {
|
||||
await sleep(400);
|
||||
try {
|
||||
const text = await (await fetch(`${BASE}/release/collection-all/styles/index.css`)).text();
|
||||
if (/dev-flags probe/.test(text)) {
|
||||
rebuilt = true;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// 重建中偶尔取不到,继续等
|
||||
}
|
||||
}
|
||||
check(rebuilt, "重建完成(调试服已能取到新的 CSS)");
|
||||
|
||||
console.log("\n=== ③ 开关是否保持 ===");
|
||||
const after = simulatorCount();
|
||||
check(after === before, "--with-sim 在热更新重建后仍然生效", `${before} → ${after}`);
|
||||
} finally {
|
||||
writeFileSync(CSS, original);
|
||||
await sleep(3000);
|
||||
console.log("\n源文件已还原");
|
||||
}
|
||||
|
||||
console.log(failures === 0 ? "构建开关保持:通过" : `构建开关保持:失败 ${failures} 处`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
@@ -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);
|
||||
@@ -0,0 +1,95 @@
|
||||
// 热更新验收:真的改一个源文件,看浏览器是不是按预期更新。
|
||||
//
|
||||
// 断言两件不同的事,不能混为一谈:
|
||||
// · 改 CSS → **只换样式表**,页面不重载(Spine 播放器与面板状态都保住)
|
||||
// · 改 runtime → **整页刷新**
|
||||
//
|
||||
// 用"页面上的标记还在不在"来区分这两者:整页刷新会把 window 上的标记抹掉,
|
||||
// 只换样式表则不会。只看"页面变了没有"是分不出这两种情况的。
|
||||
//
|
||||
// 用法:node tools/checks/verify-hot-reload.mts [base]
|
||||
// 前提:调试服在跑,且是**新版**(带热更新)的 tools/dev.ts。
|
||||
|
||||
import { appendFileSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { withPage, sleep } from "../cdp.mjs";
|
||||
|
||||
const BASE = process.argv[2] ?? "http://127.0.0.1:5173";
|
||||
const RELEASE = "collection-all";
|
||||
|
||||
const CSS_FILE = "src/styles/index.css";
|
||||
const TS_FILE = "src/runtime/viewport-fitter.ts";
|
||||
const cssOriginal = readFileSync(CSS_FILE, "utf8");
|
||||
const tsOriginal = readFileSync(TS_FILE, "utf8");
|
||||
|
||||
let failures = 0;
|
||||
const check = (ok: unknown, label: string, detail?: unknown) => {
|
||||
if (!ok) failures++;
|
||||
console.log(` ${ok ? "✓" : "✗"} ${label}${detail !== undefined ? ` → ${String(detail)}` : ""}`);
|
||||
};
|
||||
|
||||
/** 等到条件成立,超时返回 false。 */
|
||||
async function waitFor(evaluate, expression, ms, tag) {
|
||||
const deadline = Date.now() + ms;
|
||||
while (Date.now() < deadline) {
|
||||
if (await evaluate(expression).catch(() => false)) return true;
|
||||
await sleep(150);
|
||||
}
|
||||
console.log(` (${tag} 等待超时)`);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await withPage({ width: 1280, height: 900, profile: "tools/.cache/edge-hotreload" }, async ({ send, evaluate }) => {
|
||||
await send("Page.navigate", { url: `${BASE}/release/${RELEASE}/` });
|
||||
await sleep(3500);
|
||||
|
||||
console.log("=== ① 注入与连接 ===");
|
||||
const injected = await evaluate(`!!document.querySelector('script[src*="/__dev/events"]') || document.documentElement.innerHTML.includes("/__dev/events")`);
|
||||
check(injected === true, "页面里注入了热更新客户端");
|
||||
const esState = await evaluate(`(function(){
|
||||
// EventSource 没有暴露实例,用一条同地址的连接探一下服务端在不在
|
||||
return fetch("/__dev/events", { method: "GET" }).then(function (r) {
|
||||
return r.headers.get("content-type") || "";
|
||||
}).catch(function () { return "ERR"; });
|
||||
})()`);
|
||||
check(/text\/event-stream/.test(esState ?? ""), "SSE 端点返回 text/event-stream", esState);
|
||||
|
||||
console.log("\n=== ② 改 CSS:只换样式表,不整页重载 ===");
|
||||
await evaluate(`window.__hotReloadMarker = "css";`);
|
||||
// 页面有两条样式表(spine-player.css / index.css),要盯的是**被改的那一条**,
|
||||
// 盯第一条会把"换了别的样式表"当成成功。
|
||||
const indexHref = `(function(){
|
||||
var links = document.querySelectorAll('link[rel="stylesheet"]');
|
||||
for (var i = 0; i < links.length; i++) if (links[i].href.indexOf("index.css") >= 0) return links[i].href;
|
||||
return null;
|
||||
})()`;
|
||||
const beforeHref = await evaluate(indexHref);
|
||||
appendFileSync(CSS_FILE, `\n/* hot-reload probe ${Date.now()} */\n`);
|
||||
const swapped = await waitFor(evaluate, `${indexHref} !== ${JSON.stringify(beforeHref)}`, 25000, "样式表换新");
|
||||
check(swapped, "被改的样式表 href 换成了带 ?t= 的新地址", await evaluate(indexHref));
|
||||
const markerAfterCss = await evaluate(`window.__hotReloadMarker`);
|
||||
check(markerAfterCss === "css", "页面**没有**整页重载(window 上的标记还在)", String(markerAfterCss));
|
||||
const cssText = await evaluate(`fetch(${indexHref}).then(function(r){ return r.text(); })`);
|
||||
check(/hot-reload probe/.test(cssText ?? ""), "换上的样式表确实是新内容");
|
||||
|
||||
console.log("\n=== ③ 改 runtime:整页刷新 ===");
|
||||
writeFileSync(CSS_FILE, cssOriginal); // 先把 CSS 还原,免得下面这一轮被判成 css-only
|
||||
await sleep(2500);
|
||||
await evaluate(`window.__hotReloadMarker = "ts";`);
|
||||
const markerBefore = await evaluate(`window.__hotReloadMarker`);
|
||||
check(markerBefore === "ts", "刷新前标记已就位", String(markerBefore));
|
||||
appendFileSync(TS_FILE, `\n// hot-reload probe ${Date.now()}\n`);
|
||||
const reloaded = await waitFor(evaluate, `window.__hotReloadMarker === undefined`, 30000, "整页刷新");
|
||||
check(reloaded, "页面整页刷新了(标记被抹掉)");
|
||||
check((await evaluate(`!!document.getElementById("wesim")`)) === true, "刷新后模拟器面板重新挂上");
|
||||
});
|
||||
} finally {
|
||||
// 无论成败都要还原源码,否则测试会把探针注释留在仓库里
|
||||
writeFileSync(CSS_FILE, cssOriginal);
|
||||
writeFileSync(TS_FILE, tsOriginal);
|
||||
await sleep(2500);
|
||||
console.log("\n源文件已还原");
|
||||
}
|
||||
|
||||
console.log(failures === 0 ? "热更新:通过" : `热更新:失败 ${failures} 处`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,549 @@
|
||||
// 面板还原度验收:模拟器的设置面板必须长得像 Wallpaper Engine 官方面板。
|
||||
//
|
||||
// 断言的是**官方面板的可观察特征**,不是"我们的实现细节":
|
||||
// · 标题栏是 "Wallpaper Settings" + 重置(官方就是这么写的,只有重置跟着界面语言走)
|
||||
// · 属性按 order 排成"图标 + 标签 + 右侧控件"的行,而不是分区堆叠的调试列表
|
||||
// · WE 自带的颜色块:主题配色 → 翻转 → 显示颜色选项 → [亮度/对比度/饱和度/色调偏移]
|
||||
// · 点色块弹出取色器(调色板 + 明度/饱和度方块 + 色相条 + 十六进制 + 确认/取消)
|
||||
// · type:"text" 渲染成真正的富文本说明块(<ul> 列表 + <a> 链接),不是截断的一行提示
|
||||
// · condition 生效(显示作者信息、使用自定义音乐两个开关都各管一摊)
|
||||
// · 底部是 确认 / 取消,且取消能回滚、重置能恢复默认
|
||||
//
|
||||
// 用法:node tools/checks/verify-panel.mts [base]
|
||||
|
||||
import { withPage, sleep } from "../cdp.mjs";
|
||||
|
||||
const BASE = process.argv[2] ?? "http://127.0.0.1:5173";
|
||||
const RELEASE = "collection-all";
|
||||
|
||||
let failures = 0;
|
||||
const check = (ok: unknown, label: string, detail?: unknown) => {
|
||||
if (!ok) failures++;
|
||||
console.log(` ${ok ? "✓" : "✗"} ${label}${detail !== undefined ? ` → ${String(detail)}` : ""}`);
|
||||
};
|
||||
|
||||
const probe = `(function(){
|
||||
var root = document.getElementById("wesim");
|
||||
if (!root) return null;
|
||||
var q = function (s) { return root.querySelector(s); };
|
||||
var rows = Array.prototype.map.call(root.querySelectorAll(".body .row"), function (r) {
|
||||
var c = r.querySelector(".ctl");
|
||||
var input = c && c.querySelector("input, select");
|
||||
var kind = "none";
|
||||
if (input) kind = input.type === "select-one" ? "select" : input.type;
|
||||
else if (c && c.querySelector(".combo")) kind = "combo";
|
||||
else if (c && c.querySelector(".swatch")) kind = "swatch";
|
||||
return {
|
||||
icon: (r.querySelector(".ico") || {}).textContent || "",
|
||||
label: (r.querySelector(".label") || {}).textContent || "",
|
||||
control: kind,
|
||||
value: input ? input.value : (c && c.querySelector(".combo") ? c.querySelector(".combo").dataset.value : null),
|
||||
comboLabel: c && c.querySelector(".combo-label") ? c.querySelector(".combo-label").textContent : null
|
||||
};
|
||||
});
|
||||
var notes = Array.prototype.map.call(root.querySelectorAll(".body .note"), function (n) {
|
||||
return {
|
||||
text: n.textContent.replace(/\\s+/g, " ").trim(),
|
||||
items: n.querySelectorAll("li").length,
|
||||
links: Array.prototype.map.call(n.querySelectorAll("a"), function (a) { return a.getAttribute("href"); })
|
||||
};
|
||||
});
|
||||
return {
|
||||
title: (q(".title .name") || {}).textContent || null,
|
||||
reset: (q(".title .reset") || {}).textContent || null,
|
||||
rows: rows,
|
||||
notes: notes,
|
||||
foot: Array.prototype.map.call(root.querySelectorAll(".foot button"), function (b) { return b.textContent; }),
|
||||
hasDebug: !!q(".dbg"),
|
||||
debugOpen: q(".dbg") ? q(".dbg").open : null,
|
||||
banner: (q(".banner") || {}).textContent || null,
|
||||
outsideBody: root.parentElement === document.documentElement,
|
||||
bodyTransform: document.body.style.transform,
|
||||
bodyFilter: document.body.style.filter
|
||||
};
|
||||
})()`;
|
||||
|
||||
/** 按标签点一个控件的 change(复刻用户在面板上的操作)。 */
|
||||
const clickByLabel = (label, action) => `(function(){
|
||||
var rows = document.querySelectorAll("#wesim .body .row");
|
||||
for (var i = 0; i < rows.length; i++) {
|
||||
var l = rows[i].querySelector(".label");
|
||||
if (!l || l.textContent !== ${JSON.stringify(label)}) continue;
|
||||
${action}
|
||||
}
|
||||
return false;
|
||||
})()`;
|
||||
|
||||
const toggle = (label) =>
|
||||
clickByLabel(label, `var cb = rows[i].querySelector("input[type=checkbox]");
|
||||
cb.checked = !cb.checked;
|
||||
cb.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
return true;`);
|
||||
|
||||
const setSlider = (label, value) =>
|
||||
clickByLabel(label, `var r = rows[i].querySelector("input[type=range]");
|
||||
r.value = ${JSON.stringify(String(value))};
|
||||
r.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
return true;`);
|
||||
|
||||
const result = await withPage({ width: 1920, height: 1080, profile: "tools/.cache/edge-panel" }, async ({ send, evaluate, consoleLines, exceptions }) => {
|
||||
await send("Page.navigate", { url: `${BASE}/release/${RELEASE}/` });
|
||||
await sleep(3500);
|
||||
|
||||
const initial = await evaluate(probe);
|
||||
|
||||
// ── 显示颜色选项 → 四个滑杆 ──
|
||||
await evaluate(toggle("显示颜色选项"));
|
||||
await sleep(300);
|
||||
const withColorOptions = await evaluate(probe);
|
||||
// 这一批新冒出来的行应当带 .row-enter,而原本就在的行不该带——
|
||||
// 否则每改一个属性整个面板都会重播一遍入场动画。
|
||||
const enterFlags = await evaluate(`(function(){
|
||||
return Array.prototype.map.call(document.querySelectorAll("#wesim .body .row"), function (r) {
|
||||
return { label: (r.querySelector(".label") || {}).textContent || "", enter: r.classList.contains("row-enter") };
|
||||
});
|
||||
})()`);
|
||||
await evaluate(setSlider("亮度", 80));
|
||||
await sleep(200);
|
||||
const afterBrightness = await evaluate(`document.body.style.filter`);
|
||||
|
||||
// ── 翻转 ──
|
||||
await evaluate(toggle("翻转"));
|
||||
await sleep(200);
|
||||
const afterFlip = await evaluate(`document.body.style.transform`);
|
||||
await evaluate(toggle("翻转"));
|
||||
await sleep(200);
|
||||
|
||||
// ── 取色器 ──
|
||||
await evaluate(`document.querySelector("#wesim .swatch").click()`);
|
||||
await sleep(400);
|
||||
const pickerProbe = `(function(){
|
||||
var p = document.getElementById("wesim-picker");
|
||||
if (!p) return null;
|
||||
var swatches = p.querySelectorAll(".pk-palette button:not(.pk-dropper)");
|
||||
var checked = p.querySelector(".pk-palette button.on");
|
||||
var dropper = p.querySelector(".pk-dropper");
|
||||
return {
|
||||
outsideBody: p.parentElement === document.documentElement,
|
||||
name: (p.querySelector(".pk-name") || {}).textContent || null,
|
||||
palette: swatches.length,
|
||||
colors: Array.prototype.map.call(swatches, function (b) { return b.title; }),
|
||||
checked: checked ? checked.title : null,
|
||||
hasSv: !!p.querySelector(".pk-sv"),
|
||||
hasHue: !!p.querySelector(".pk-hue"),
|
||||
hex: (p.querySelector(".pk-hex") || {}).value || null,
|
||||
dropper: !!dropper,
|
||||
dropperDisabled: dropper ? dropper.disabled : null,
|
||||
dropperTitle: dropper ? dropper.title : null,
|
||||
foot: Array.prototype.map.call(p.querySelectorAll(".pk-foot button"), function (b) { return b.textContent; })
|
||||
};
|
||||
})()`;
|
||||
|
||||
const picker = await evaluate(pickerProbe);
|
||||
// 把颜色改成某个预设色,对勾应当落到那个色块上
|
||||
await evaluate(`(function(){
|
||||
var hex = document.querySelector("#wesim-picker .pk-hex");
|
||||
hex.value = "#ff0000";
|
||||
hex.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
})()`);
|
||||
await sleep(200);
|
||||
const pickerChecked = await evaluate(pickerProbe);
|
||||
// 用取色器改颜色并确认
|
||||
await evaluate(`(function(){
|
||||
var hex = document.querySelector("#wesim-picker .pk-hex");
|
||||
hex.value = "#123456";
|
||||
hex.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
document.querySelector("#wesim-picker .pk-foot button.primary").click();
|
||||
})()`);
|
||||
await sleep(300);
|
||||
const colorAfterPick = await evaluate(`window.__weSim.props.schemecolor`);
|
||||
const pickerGone = await evaluate(`!document.getElementById("wesim-picker")`);
|
||||
|
||||
// ── 下拉:自绘控件,弹出列表也要能验 ──
|
||||
const openPresetCombo = `(function(){
|
||||
var rows = document.querySelectorAll("#wesim .body .row");
|
||||
for (var i = 0; i < rows.length; i++) {
|
||||
var l = rows[i].querySelector(".label");
|
||||
if (l && l.textContent === "壁纸预设切换") { rows[i].querySelector(".combo-btn").click(); return true; }
|
||||
}
|
||||
return false;
|
||||
})()`;
|
||||
const comboProbe = `(function(){
|
||||
var box = document.getElementById("wesim-combo");
|
||||
if (!box) return null;
|
||||
return {
|
||||
outsideBody: box.parentElement === document.documentElement,
|
||||
items: Array.prototype.map.call(box.querySelectorAll(".combo-item"), function (b) {
|
||||
return { label: b.textContent, value: b.dataset.value, on: b.classList.contains("on") };
|
||||
})
|
||||
};
|
||||
})()`;
|
||||
|
||||
await evaluate(openPresetCombo);
|
||||
await sleep(250);
|
||||
const comboOpen = await evaluate(comboProbe);
|
||||
// 选第二项 → 属性应当跟着变、弹层收起、按钮文字换掉
|
||||
await evaluate(`document.querySelectorAll("#wesim-combo .combo-item")[1].click()`);
|
||||
await sleep(400);
|
||||
const comboClosed = await evaluate(`!document.getElementById("wesim-combo")`);
|
||||
const presetAfter = await evaluate(`window.__weSim.props.preset`);
|
||||
const comboLabelAfter = await evaluate(`(function(){
|
||||
var rows = document.querySelectorAll("#wesim .body .row");
|
||||
for (var i = 0; i < rows.length; i++) {
|
||||
var l = rows[i].querySelector(".label");
|
||||
if (l && l.textContent === "壁纸预设切换") return rows[i].querySelector(".combo-label").textContent;
|
||||
}
|
||||
return null;
|
||||
})()`);
|
||||
|
||||
// ── condition:关掉「显示作者信息」,作者信息说明块应当消失 ──
|
||||
await evaluate(toggle("显示作者信息"));
|
||||
await sleep(300);
|
||||
const hidden = await evaluate(probe);
|
||||
|
||||
// ── 使用自定义音乐:audio_file 与 bgm 互换 ──
|
||||
await evaluate(toggle("使用自定义音乐"));
|
||||
await sleep(300);
|
||||
const customAudio = await evaluate(probe);
|
||||
await evaluate(toggle("使用自定义音乐"));
|
||||
await sleep(300);
|
||||
const presetAudio = await evaluate(probe);
|
||||
|
||||
// ── 重置 / 取消 / 确认 ──
|
||||
await evaluate(setSlider("音频音量调整", 0.3));
|
||||
await sleep(200);
|
||||
const afterEdit = await evaluate(`window.__weSim.props.audio_volume`);
|
||||
await evaluate(`document.querySelector("#wesim .title .reset").click()`);
|
||||
await sleep(300);
|
||||
const afterReset = await evaluate(`window.__weSim.props.audio_volume`);
|
||||
|
||||
await evaluate(setSlider("音频音量调整", 0.2));
|
||||
await sleep(200);
|
||||
const beforeCancel = await evaluate(`window.__weSim.props.audio_volume`);
|
||||
await evaluate(`document.querySelector("#wesim .foot button:not(.primary)").click()`);
|
||||
await sleep(300);
|
||||
const afterCancel = await evaluate(`window.__weSim.props.audio_volume`);
|
||||
|
||||
await evaluate(setSlider("音频音量调整", 0.6));
|
||||
await sleep(200);
|
||||
await evaluate(`document.querySelector("#wesim .foot button.primary").click()`);
|
||||
await sleep(300);
|
||||
const afterConfirm = await evaluate(`window.__weSim.props.audio_volume`);
|
||||
|
||||
// ── 动效:真的滑出一次才看得到 data-enter ──
|
||||
// 先移到远处再移到边缘:前面点过「取消」,那会把 armed 置 false,
|
||||
// 而 armed 只有"指针离开边缘"才会复位——不先走开一次,合成指针是打不开面板的。
|
||||
await send("Input.dispatchMouseEvent", { type: "mouseMoved", x: 200, y: 540, button: "none", buttons: 0 });
|
||||
await sleep(120);
|
||||
await send("Input.dispatchMouseEvent", { type: "mouseMoved", x: 1912, y: 540, button: "none", buttons: 0 });
|
||||
await sleep(260);
|
||||
const enterProbe = `(function(){
|
||||
var root = document.getElementById("wesim");
|
||||
var rows = root.querySelectorAll(".body .row");
|
||||
return {
|
||||
open: root.dataset.open,
|
||||
dataEnter: root.dataset.enter || null,
|
||||
seq: Array.prototype.map.call(rows, function (r) { return r.style.getPropertyValue("--i"); }).slice(0, 5),
|
||||
rowAnim: rows.length ? getComputedStyle(rows[0]).animationName : null,
|
||||
rowDelay: rows.length ? getComputedStyle(rows[0]).animationDelay : null,
|
||||
hoverTransition: rows.length ? getComputedStyle(rows[0]).transitionProperty : null,
|
||||
pickerAnim: (function () {
|
||||
document.querySelector("#wesim .swatch").click();
|
||||
var p = document.getElementById("wesim-picker");
|
||||
return p ? getComputedStyle(p).animationName : null;
|
||||
})()
|
||||
};
|
||||
})()`;
|
||||
const anim = await evaluate(enterProbe);
|
||||
await evaluate(`(function(){ var p = document.getElementById("wesim-picker"); if (p) p.remove(); })()`);
|
||||
await sleep(600);
|
||||
const afterEnter = await evaluate(`document.getElementById("wesim").dataset.enter || null`);
|
||||
|
||||
// 复选框是自绘的:appearance 关掉、勾靠背景图长出来
|
||||
const checkbox = await evaluate(`(function(){
|
||||
var cb = document.querySelector("#wesim .body input[type=checkbox]");
|
||||
if (!cb) return null;
|
||||
var before = getComputedStyle(cb).backgroundSize;
|
||||
cb.checked = !cb.checked;
|
||||
cb.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
return { appearance: getComputedStyle(cb).appearance, before: before };
|
||||
})()`);
|
||||
|
||||
// 下拉箭头靠 aria-expanded 翻转
|
||||
await evaluate(`(function(){
|
||||
var rows = document.querySelectorAll("#wesim .body .row");
|
||||
for (var i = 0; i < rows.length; i++) {
|
||||
var l = rows[i].querySelector(".label");
|
||||
if (l && l.textContent === "背景音乐选择") { rows[i].querySelector(".combo-btn").click(); return true; }
|
||||
}
|
||||
return false;
|
||||
})()`);
|
||||
await sleep(250);
|
||||
const arrowOpen = await evaluate(`(function(){
|
||||
var b = document.querySelector("#wesim .combo-btn[aria-expanded]");
|
||||
return { expanded: b ? b.getAttribute("aria-expanded") : null,
|
||||
rotated: b ? getComputedStyle(b.querySelector(".combo-arrow")).transform : null };
|
||||
})()`);
|
||||
await evaluate(`(function(){ document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); })()`);
|
||||
await sleep(250);
|
||||
const arrowClosed = await evaluate(`(function(){
|
||||
var b = document.querySelector("#wesim .combo-btn[aria-expanded]");
|
||||
return b ? b.getAttribute("aria-expanded") : null;
|
||||
})()`);
|
||||
|
||||
// 系统开了"减少动态效果"时应当全部关掉
|
||||
await send("Emulation.setEmulatedMedia", {
|
||||
features: [{ name: "prefers-reduced-motion", value: "reduce" }],
|
||||
});
|
||||
await sleep(200);
|
||||
const reduced = await evaluate(`(function(){
|
||||
var r = document.querySelector("#wesim .body .row");
|
||||
var p = document.getElementById("wesim-picker");
|
||||
return { rowAnim: r ? getComputedStyle(r).animationDuration : null,
|
||||
rowTransition: r ? getComputedStyle(r).transitionDuration : null };
|
||||
})()`);
|
||||
await send("Emulation.setEmulatedMedia", { features: [] });
|
||||
|
||||
// ── 预览图:设置面板上方 ──
|
||||
const previewProbe = `(function(){
|
||||
var p = document.querySelector("#wesim .preview");
|
||||
if (!p) return { present: false };
|
||||
var img = p.querySelector("img");
|
||||
var src = img ? img.getAttribute("src") : null;
|
||||
return {
|
||||
present: true,
|
||||
src: src,
|
||||
isData: src ? src.indexOf("data:") === 0 : null,
|
||||
natural: img ? img.naturalWidth + "x" + img.naturalHeight : null,
|
||||
navs: Array.prototype.map.call(p.querySelectorAll(".nav"), function (b) {
|
||||
return { cls: b.className, disabled: b.disabled, glyph: b.textContent };
|
||||
})
|
||||
};
|
||||
})()`;
|
||||
const preview = await evaluate(previewProbe);
|
||||
|
||||
// ── 单档分发里的「背景音乐选择」──
|
||||
//
|
||||
// 这条是回归断言:bgm 曾经跟着 preset 一起被删掉("只剩一档壁纸,切换没有意义"),
|
||||
// 但那个理由只对**预设切换**成立——一档壁纸照样能带好几首曲子(xilian 就是两首)。
|
||||
// 结果 single-hsr-xilian 里根本没有切换入口,而当时没有任何断言盯着这件事。
|
||||
const bgmRowProbe = `(function(){
|
||||
var rows = document.querySelectorAll("#wesim .body .row");
|
||||
for (var i = 0; i < rows.length; i++) {
|
||||
var l = rows[i].querySelector(".label");
|
||||
if (l && l.textContent === "背景音乐选择") {
|
||||
var combo = rows[i].querySelector(".combo");
|
||||
var label = rows[i].querySelector(".combo-label");
|
||||
return { present: true, value: combo ? combo.dataset.value : null,
|
||||
label: label ? label.textContent : null };
|
||||
}
|
||||
}
|
||||
return { present: false };
|
||||
})()`;
|
||||
|
||||
await send("Page.navigate", { url: `${BASE}/release/single-hsr-xilian/` });
|
||||
await sleep(3200);
|
||||
const singleTwoTracks = await evaluate(bgmRowProbe);
|
||||
// 展开看有几个选项
|
||||
await evaluate(`(function(){
|
||||
var rows = document.querySelectorAll("#wesim .body .row");
|
||||
for (var i = 0; i < rows.length; i++) {
|
||||
var l = rows[i].querySelector(".label");
|
||||
if (l && l.textContent === "背景音乐选择") { rows[i].querySelector(".combo-btn").click(); return true; }
|
||||
}
|
||||
return false;
|
||||
})()`);
|
||||
await sleep(250);
|
||||
const singleTwoTrackItems = await evaluate(
|
||||
`Array.prototype.map.call(document.querySelectorAll("#wesim-combo .combo-item"), function (b) { return b.textContent; })`,
|
||||
);
|
||||
|
||||
await send("Page.navigate", { url: `${BASE}/release/single-hsr-kv37/` });
|
||||
await sleep(3200);
|
||||
const singleOneTrack = await evaluate(bgmRowProbe);
|
||||
|
||||
return {
|
||||
initial, withColorOptions, enterFlags, anim, afterEnter, checkbox, arrowOpen, arrowClosed, reduced,
|
||||
preview,
|
||||
singleTwoTracks, singleTwoTrackItems, singleOneTrack,
|
||||
afterBrightness, afterFlip, picker, pickerChecked, colorAfterPick, pickerGone,
|
||||
comboOpen, comboClosed, presetAfter, comboLabelAfter,
|
||||
hidden, customAudio, presetAudio, afterEdit, afterReset, beforeCancel, afterCancel, afterConfirm,
|
||||
consoleLines, exceptions,
|
||||
};
|
||||
});
|
||||
|
||||
const r = result;
|
||||
const labelsOf = (state) => (state?.rows ?? []).map((x) => x.label);
|
||||
|
||||
console.log("=== ① 标题栏(官方:" + '"Wallpaper Settings" + 重置' + ")===");
|
||||
check(r.initial?.title === "Wallpaper Settings", '标题是 "Wallpaper Settings"', r.initial?.title);
|
||||
check(/重置/.test(r.initial?.reset ?? ""), "右侧有「重置」", r.initial?.reset?.trim());
|
||||
check(/模拟环境/.test(r.initial?.banner ?? ""), "顶部保留「模拟环境」横幅(ADR 0006 要求,官方没有)");
|
||||
|
||||
console.log("\n=== ② 属性行:图标 + 标签 + 右侧控件,按 order 排 ===");
|
||||
console.log(` 实际顺序:${labelsOf(r.initial).join(" / ")}`);
|
||||
const expectOrder = ["主题配色", "翻转", "显示颜色选项", "显示作者信息", "壁纸预设切换", "音频音量调整", "使用自定义音乐", "背景音乐选择"];
|
||||
check(JSON.stringify(labelsOf(r.initial)) === JSON.stringify(expectOrder), "行顺序与 order 字段一致", `${labelsOf(r.initial).length} 行`);
|
||||
check(r.initial?.rows?.[0]?.control === "swatch", "主题配色渲染成色块", r.initial?.rows?.[0]?.control);
|
||||
check((r.initial?.rows ?? []).filter((x) => x.control === "combo").length === 2, "两个 combo(壁纸预设切换 / 背景音乐选择)都渲染成自绘下拉");
|
||||
check((r.initial?.rows ?? []).some((x) => x.label === "音频音量调整" && x.control === "range"), "slider 渲染成 range");
|
||||
const icons = (r.initial?.rows ?? []).filter((x) => x.icon).length;
|
||||
check(icons >= 3, "属性文本开头的 emoji 被当作行图标", `${icons} 行有图标`);
|
||||
|
||||
console.log("\n=== ③ WE 自带的颜色块 ===");
|
||||
check(r.initial?.rows?.[1]?.label === "翻转" && r.initial?.rows?.[1]?.control === "checkbox", "「翻转」是 WE 自带的一行");
|
||||
check(labelsOf(r.initial).includes("亮度") === false, "默认不显示四个颜色滑杆(显示颜色选项未勾)");
|
||||
check(
|
||||
JSON.stringify(labelsOf(r.withColorOptions).slice(2, 7)) === JSON.stringify(["显示颜色选项", "亮度", "对比度", "饱和度", "色调偏移"]),
|
||||
"勾上「显示颜色选项」后出现四个滑杆",
|
||||
labelsOf(r.withColorOptions).slice(2, 7).join(" / "),
|
||||
);
|
||||
check((r.withColorOptions?.rows ?? []).filter((x) => x.control === "range").length === 5, "共 5 个滑杆(4 颜色 + 1 音量)");
|
||||
check(/brightness/.test(r.afterBrightness ?? ""), "亮度落到 body 的 CSS 滤镜上", r.afterBrightness);
|
||||
check(r.afterFlip === "scaleX(-1)", "翻转落到 body 的 transform 上", r.afterFlip);
|
||||
check(r.initial?.outsideBody === true, "面板挂在 <html> 下(body 上的 transform/filter 不会波及它)");
|
||||
|
||||
console.log("\n=== ④ 取色器弹层 ===");
|
||||
check(r.picker !== null, "点色块弹出取色器");
|
||||
check(r.picker?.outsideBody === true, "取色器也在 body 之外(不被滤镜调色)");
|
||||
check(/主题配色/.test(r.picker?.name ?? ""), "弹层标题是「主题配色」", r.picker?.name);
|
||||
check(r.picker?.palette === 15, "调色板 15 个色块(3 列 × 5 行)", `${r.picker?.palette} 个`);
|
||||
const official = [
|
||||
"#ffffff", "#c0c0c0", "#000000",
|
||||
"#ff0000", "#ffa500", "#ffff00",
|
||||
"#00ff00", "#008000", "#254117",
|
||||
"#add8e6", "#0000ff", "#00008b",
|
||||
"#00ffff", "#800080", "#ff00ff",
|
||||
];
|
||||
check(
|
||||
JSON.stringify(r.picker?.colors) === JSON.stringify(official),
|
||||
"15 个预设色与官方逐个一致",
|
||||
JSON.stringify(r.picker?.colors) === JSON.stringify(official) ? "一致" : (r.picker?.colors ?? []).join(" "),
|
||||
);
|
||||
check(r.picker?.checked === "#63269e" || r.picker?.checked === null, "初始颜色不是预设色时没有对勾", String(r.picker?.checked));
|
||||
check(r.pickerChecked?.checked === "#ff0000", "颜色改成预设色后,对勾落到那个色块上", String(r.pickerChecked?.checked));
|
||||
check(r.picker?.dropper === true, "调色板下方有吸管按钮");
|
||||
check(
|
||||
r.picker?.dropperDisabled === false || r.picker?.dropperDisabled === true,
|
||||
r.picker?.dropperDisabled ? "吸管已禁用并说明原因(本机 CEF 无 EyeDropper API)" : "吸管可用(EyeDropper API 存在)",
|
||||
r.picker?.dropperTitle,
|
||||
);
|
||||
check(r.picker?.hasSv === true && r.picker?.hasHue === true, "有明度/饱和度方块与色相条");
|
||||
check(/^#[0-9a-f]{6}$/i.test(r.picker?.hex ?? ""), "十六进制输入框有值", r.picker?.hex);
|
||||
check(JSON.stringify(r.picker?.foot) === JSON.stringify(["确认", "取消"]), "弹层底部是 确认 / 取消", (r.picker?.foot ?? []).join(" / "));
|
||||
check(/^0\.\d+ 0\.\d+ 0\.\d+$/.test(r.colorAfterPick ?? ""), "确认后写回 schemecolor(r g b 浮点)", r.colorAfterPick);
|
||||
check(r.pickerGone === true, "确认后弹层关闭");
|
||||
|
||||
console.log("\n=== ⑤ type:text 渲染成富文本说明块 ===");
|
||||
const notes = r.initial?.notes ?? [];
|
||||
check(notes.length >= 3, "说明块都在", `${notes.length} 个`);
|
||||
check(notes.filter((n) => n.items > 0).length >= 2, "说明块里有 <ul><li> 列表");
|
||||
const links = notes.flatMap((n) => n.links ?? []);
|
||||
check(links.length > 0 && links.every((h) => /^https?:/.test(h ?? "")), "链接被保留且只放行 http(s)", links.join(" | "));
|
||||
|
||||
console.log("\n=== ⑥ condition:两个开关各管一摊 ===");
|
||||
check(!(r.hidden?.notes ?? []).some((n) => n.text.includes("作者:")), "关掉「显示作者信息」→ 作者信息块消失");
|
||||
check(!labelsOf(r.customAudio).includes("背景音乐选择"), "开「使用自定义音乐」→ 背景音乐选择隐藏");
|
||||
check(labelsOf(r.customAudio).includes("音频文件路径"), "开「使用自定义音乐」→ 音频文件路径出现");
|
||||
check(labelsOf(r.presetAudio).includes("背景音乐选择"), "关「使用自定义音乐」→ 背景音乐选择回来");
|
||||
check(!labelsOf(r.presetAudio).includes("音频文件路径"), "关「使用自定义音乐」→ 音频文件路径隐藏");
|
||||
|
||||
console.log("\n=== ⑦ 底部 确认 / 取消 + 重置语义 ===");
|
||||
check(JSON.stringify(r.initial?.foot) === JSON.stringify(["确认", "取消"]), "底部是 确认 / 取消", (r.initial?.foot ?? []).join(" / "));
|
||||
check(r.afterEdit === 0.3, "改音量已实时下发", String(r.afterEdit));
|
||||
check(r.afterReset === 1, "重置恢复默认值 1", String(r.afterReset));
|
||||
check(r.beforeCancel === 0.2 && r.afterCancel === 1, "取消回滚到上次确认的值", `${r.beforeCancel} → ${r.afterCancel}`);
|
||||
check(r.afterConfirm === 0.6, "确认保留改动", String(r.afterConfirm));
|
||||
|
||||
console.log("\n=== ⑧ 模拟器专属内容收进折叠区 ===");
|
||||
check(r.initial?.hasDebug === true && r.initial?.debugOpen === false, "「模拟器调试」存在且默认收起");
|
||||
|
||||
console.log("\n=== ⑨ 下拉框(自绘,样式与官方同步)===");
|
||||
check(r.comboOpen !== null, "点下拉按钮弹出列表");
|
||||
check(r.comboOpen?.outsideBody === true, "弹层挂在 documentElement 下(不被 body 的滤镜波及)");
|
||||
const comboItems = r.comboOpen?.items ?? [];
|
||||
console.log(` 选项:${comboItems.map((x) => `${x.label}${x.on ? "(当前)" : ""}`).join(" / ")}`);
|
||||
check(comboItems.length === 2, "两个预设都在列表里", `${comboItems.length} 项`);
|
||||
check(comboItems.filter((x) => x.on).length === 1, "当前项恰好一个被高亮", comboItems.filter((x) => x.on).map((x) => x.label).join(""));
|
||||
check(r.comboClosed === true, "选完之后弹层收起");
|
||||
check(r.presetAfter === comboItems[1]?.value, "选中的值下发给壁纸", `${r.presetAfter}`);
|
||||
check(r.comboLabelAfter === comboItems[1]?.label, "按钮上的文字跟着换成所选项", String(r.comboLabelAfter));
|
||||
|
||||
console.log("\n=== ⑩ 动效(只解释状态变化,且受 prefers-reduced-motion 管辖)===");
|
||||
check(r.anim?.open === "1", "面板已滑出", String(r.anim?.open));
|
||||
check(r.anim?.dataEnter === "1", "刚滑出时打了入场标记", String(r.anim?.dataEnter));
|
||||
check(/wesim-row-in/.test(r.anim?.rowAnim ?? ""), "行上跑的是入场动画", r.anim?.rowAnim);
|
||||
check(
|
||||
(r.anim?.seq ?? []).join(",") === "0,1,2,3,4",
|
||||
"行带递增的 --i(用来错开入场)",
|
||||
(r.anim?.seq ?? []).join(","),
|
||||
);
|
||||
check(r.afterEnter === null, "入场标记播完就摘掉(不会每次重画都重播)", String(r.afterEnter));
|
||||
check(/background-color|transform/.test(r.anim?.hoverTransition ?? ""), "行有悬停过渡", r.anim?.hoverTransition);
|
||||
check(/wesim-pop/.test(r.anim?.pickerAnim ?? ""), "取色器弹层有出现动画", r.anim?.pickerAnim);
|
||||
|
||||
const entered = (r.enterFlags ?? []).filter((x) => x.enter).map((x) => x.label);
|
||||
console.log(` 带入场标记的行:${entered.join(" / ") || "(无)"}`);
|
||||
check(
|
||||
JSON.stringify(entered) === JSON.stringify(["亮度", "对比度", "饱和度", "色调偏移"]),
|
||||
"只有因条件变化新冒出来的行才播入场",
|
||||
entered.join(" / "),
|
||||
);
|
||||
|
||||
check(r.checkbox?.appearance === "none", "复选框是自绘的(原生外观已关掉)", String(r.checkbox?.appearance));
|
||||
check(r.arrowOpen?.expanded === "true", "展开时按钮 aria-expanded=true");
|
||||
check(/matrix\(-1/.test(r.arrowOpen?.rotated ?? ""), "箭头翻了过来", r.arrowOpen?.rotated);
|
||||
check(r.arrowClosed === "false", "收起后 aria-expanded 复位", String(r.arrowClosed));
|
||||
|
||||
check(
|
||||
parseFloat(r.reduced?.rowAnim ?? "1") < 0.01,
|
||||
"prefers-reduced-motion: reduce 时入场动画被关掉",
|
||||
String(r.reduced?.rowAnim),
|
||||
);
|
||||
check(
|
||||
parseFloat(r.reduced?.rowTransition ?? "1") < 0.01,
|
||||
"prefers-reduced-motion: reduce 时过渡也被关掉",
|
||||
String(r.reduced?.rowTransition),
|
||||
);
|
||||
|
||||
console.log("\n=== ⑪ 预览图(官方在设置上方就展示它)===");
|
||||
check(r.preview?.present === true, "预览区已渲染", String(r.preview?.present));
|
||||
check(/preview\.gif$/.test(r.preview?.src ?? ""), "指向分发根的 preview.gif", String(r.preview?.src));
|
||||
check(r.preview?.natural === "160x160", "图片真的解码出来了(不是坏图)", String(r.preview?.natural));
|
||||
// 官方那对左右箭头是切换"已安装的壁纸",模拟器没有那个列表——**不做**。
|
||||
// 这条断言是防回归的:它们曾经存在过(当时拿"切预设"当替身,但那是另一件事)。
|
||||
check(
|
||||
(r.preview?.navs ?? []).length === 0,
|
||||
"预览上没有左右箭头(WE 那个是切换壁纸用的,我们不需要)",
|
||||
`${(r.preview?.navs ?? []).length} 个`,
|
||||
);
|
||||
|
||||
console.log("\n=== ⑫ 单档分发里的「背景音乐选择」===");
|
||||
// 规则:一个分发里可选的音源(不含「随预设」)≥2 才给下拉,否则两个选项效果一样、是废话。
|
||||
check(r.singleTwoTracks?.present === true, "single-hsr-xilian(两首)有「背景音乐选择」", JSON.stringify(r.singleTwoTracks));
|
||||
check(
|
||||
JSON.stringify(r.singleTwoTrackItems) === JSON.stringify(["随预设", "「再度和你」", "昔涟"]),
|
||||
"下拉里就是 xilian 的两首(+ 随预设)",
|
||||
(r.singleTwoTrackItems ?? []).join(" / "),
|
||||
);
|
||||
// 默认值是 "auto"(随预设)——与合集分发一致,不是"第一首"。
|
||||
// 运行时拿到 auto 就回落该壁纸的默认音源,也就是第一首,所以效果相同;
|
||||
// 但值本身必须是 auto,否则用户从合集切到单档时选择会被"钉死"。
|
||||
check(
|
||||
r.singleTwoTracks?.value === "auto" && r.singleTwoTracks?.label === "随预设",
|
||||
"默认是「随预设」(auto),与合集分发一致",
|
||||
`${r.singleTwoTracks?.value} / ${r.singleTwoTracks?.label}`,
|
||||
);
|
||||
check(
|
||||
r.singleOneTrack?.present === false,
|
||||
"single-hsr-kv37(只有一首)不给这个下拉——两个选项效果完全一样",
|
||||
JSON.stringify(r.singleOneTrack),
|
||||
);
|
||||
|
||||
const noisy = (r.consoleLines ?? []).filter((l) => /error|uncaught/i.test(l));
|
||||
check((r.exceptions ?? []).length === 0, "0 未捕获异常", (r.exceptions ?? []).slice(0, 2).join(" | ") || "无");
|
||||
check(noisy.length === 0, "0 控制台报错", noisy.slice(0, 3).join(" | ") || "干净");
|
||||
|
||||
console.log(failures === 0 ? "\n面板还原度:通过" : `\n面板还原度:失败 ${failures} 处`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,410 @@
|
||||
// 场景播放器的门:多骨架 + 贴图平面真的合成出来了,而且**先证明它会红**。
|
||||
//
|
||||
// 不碰 `wallpapers/`:夹具用抓取器 staged 的 kv45/scene_main(4 骨架 + 1 平面)自造,
|
||||
// 预设写成与构建产物同形的 `preset.js`(同一个 asset()/相对路径解析规则)。
|
||||
//
|
||||
// 前置:`python -m tools.downloader fetch --page kv45`(产物在 tools/downloader/_out/hsr/kv45/scene_main)。
|
||||
// 本脚本自己跑 `tsc -p tsconfig.runtime.json`,所以不依赖 pnpm build。
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { createServer } from "node:http";
|
||||
import { createReadStream, existsSync } from "node:fs";
|
||||
import { cp, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { dirname, extname, join, normalize, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { withPage, sleep } from "../cdp.mjs";
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||
const FIXTURE = join(ROOT, "tools", ".cache", "scene-player-fixture");
|
||||
const STAGED = join(ROOT, "tools", "downloader", "_out", "hsr", "kv45", "scene_main");
|
||||
/** 透视样本:原神页,camera type 1 + 真实景深(kv45 是正交、z 全 0,抓不住投影算错)。 */
|
||||
const STAGED_PERSPECTIVE = join(ROOT, "tools", "downloader", "_out", "ys", "nico-tea", "scene_main");
|
||||
const SHOT = join(ROOT, "tools", ".cache", "scene-player-shot.png");
|
||||
const PORT = Number(process.env.SCENE_PORT ?? 8197);
|
||||
const W = 1280;
|
||||
const H = 720;
|
||||
|
||||
let failures = 0;
|
||||
const check = (ok: unknown, label: string, detail?: unknown) => {
|
||||
if (!ok) failures++;
|
||||
console.log(` ${ok ? "✓" : "✗"} ${label}${detail !== undefined ? ` → ${String(detail)}` : ""}`);
|
||||
};
|
||||
|
||||
interface SceneDebug {
|
||||
parts: number;
|
||||
loaded: number;
|
||||
errors: string[];
|
||||
framing: { x: number; y: number; width: number; height: number } | null;
|
||||
content: { x: number; y: number; width: number; height: number } | null;
|
||||
spines: string[];
|
||||
images: string[];
|
||||
solids: number;
|
||||
rotatedSkipped: number;
|
||||
}
|
||||
|
||||
interface StagedPart {
|
||||
kind: string;
|
||||
id: string;
|
||||
order: number;
|
||||
renderOrder?: number;
|
||||
position: number[];
|
||||
scale: number[];
|
||||
geometrySize?: number[];
|
||||
geometryCenter?: number[];
|
||||
rotation?: number[];
|
||||
animation?: string;
|
||||
skin?: string;
|
||||
timeScale?: number;
|
||||
}
|
||||
|
||||
/** 把 staged 的 scene.json 翻成 preset 形状(与 tools/lib/generate.ts 吐出的字面量同形)。 */
|
||||
async function buildPresetModule(
|
||||
staged: string = STAGED,
|
||||
id = "kv45",
|
||||
game = "hsr",
|
||||
cover: string | null = "w22_slg",
|
||||
): Promise<string> {
|
||||
const scene = JSON.parse(await readFile(join(staged, "scene.json"), "utf8")) as {
|
||||
ui?: number[];
|
||||
parts: StagedPart[];
|
||||
camera?: { camera?: { type?: number; fov?: number }; position?: number[] };
|
||||
};
|
||||
const camera = scene.camera?.camera ?? {};
|
||||
const cameraLine =
|
||||
camera.type === undefined
|
||||
? ""
|
||||
: ` camera: ${JSON.stringify({ type: camera.type, fov: camera.fov, position: scene.camera?.position })},\n`;
|
||||
const parts: string[] = [];
|
||||
for (const part of scene.parts) {
|
||||
// 与 promote.py 同语义:纯色平面不进预设(运行时画不了它,写进去只是死配置)。
|
||||
if (part.kind === "solid") continue;
|
||||
const fields: string[] = [
|
||||
`kind: ${JSON.stringify(part.kind)}`,
|
||||
`id: ${JSON.stringify(part.id)}`,
|
||||
`order: ${JSON.stringify(part.order)}`,
|
||||
`position: ${JSON.stringify(part.position)}`,
|
||||
`scale: ${JSON.stringify(part.scale)}`,
|
||||
];
|
||||
if (part.renderOrder) fields.push(`renderOrder: ${JSON.stringify(part.renderOrder)}`);
|
||||
if (part.kind === "spine") {
|
||||
fields.push(`jsonUrl: asset("../assets/spines/${part.id}/${part.id}.json")`);
|
||||
fields.push(`atlasUrl: asset("../assets/spines/${part.id}/${part.id}.atlas")`);
|
||||
} else if (part.kind === "image") {
|
||||
const dir = join(staged, "scene");
|
||||
const files = existsSync(dir) ? await readdir(dir) : [];
|
||||
const hit = files.find((f) => f.startsWith(`${part.id}.`));
|
||||
if (!hit) continue;
|
||||
fields.push(`image: asset("../assets/scene/${hit}")`);
|
||||
}
|
||||
if (part.geometrySize) {
|
||||
fields.push(`width: ${JSON.stringify(part.geometrySize[0])}`);
|
||||
fields.push(`height: ${JSON.stringify(part.geometrySize[1])}`);
|
||||
}
|
||||
if (part.geometryCenter) fields.push(`center: ${JSON.stringify(part.geometryCenter)}`);
|
||||
if (part.rotation && part.rotation.some((v) => Math.abs(v) > 1e-9)) {
|
||||
fields.push(`rotation: ${JSON.stringify(part.rotation)}`);
|
||||
}
|
||||
if (part.animation) fields.push(`animation: ${JSON.stringify(part.animation)}`);
|
||||
if (part.skin) fields.push(`skin: ${JSON.stringify(part.skin)}`);
|
||||
if (part.timeScale !== undefined) fields.push(`timeScale: ${JSON.stringify(part.timeScale)}`);
|
||||
parts.push(` { ${fields.join(", ")} },`);
|
||||
}
|
||||
return [
|
||||
`const base = new URL("./", import.meta.url);`,
|
||||
`const asset = (path) => new URL(path, base).href;`,
|
||||
`export default {`,
|
||||
` id: ${JSON.stringify(id)},`,
|
||||
` name: ${JSON.stringify(id)},`,
|
||||
` game: ${JSON.stringify(game)},`,
|
||||
// 不设背景图:背景比例链会改取景,门要的是**纯视锥**那一条路径。
|
||||
` backgroundImage: ${cover ? `asset("../assets/scene/${cover}.png")` : `""`},`,
|
||||
` sceneConfig: {`,
|
||||
` ui: ${JSON.stringify(scene.ui ?? [2500, 1080])},`,
|
||||
cameraLine + ` parts: [`,
|
||||
...parts,
|
||||
` ],`,
|
||||
` },`,
|
||||
` audioChoices: [],`,
|
||||
` audioOptions: { source: "" },`,
|
||||
`};`,
|
||||
``,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/** 用 ffmpeg 把 PNG 解成 RGBA 原始像素。 */
|
||||
function rawPixels(png: string): Promise<Buffer> {
|
||||
return new Promise((resolvePromise) => {
|
||||
const p = spawn("ffmpeg", ["-v", "error", "-i", png, "-f", "rawvideo", "-pix_fmt", "rgba", "-"], {
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
});
|
||||
const chunks: Buffer[] = [];
|
||||
p.stdout.on("data", (d: Buffer) => chunks.push(d));
|
||||
p.on("close", () => resolvePromise(Buffer.concat(chunks)));
|
||||
p.on("error", () => resolvePromise(Buffer.alloc(0)));
|
||||
});
|
||||
}
|
||||
|
||||
/** 两张同尺寸截图里有多少比例的像素不同。 */
|
||||
function diffRatio(a: Buffer, b: Buffer): number {
|
||||
if (!a.length || a.length !== b.length) return -1;
|
||||
let different = 0;
|
||||
for (let i = 0; i < a.length; i += 4) {
|
||||
if (a[i] !== b[i] || a[i + 1] !== b[i + 1] || a[i + 2] !== b[i + 2] || a[i + 3] !== b[i + 3]) different++;
|
||||
}
|
||||
return different / (a.length / 4);
|
||||
}
|
||||
|
||||
const MIME: Record<string, string> = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".mjs": "text/javascript; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".atlas": "text/plain; charset=utf-8",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".webp": "image/webp",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// 前置条件
|
||||
// ---------------------------------------------------------------------------------------
|
||||
console.log("场景播放器门:\n");
|
||||
if (!existsSync(join(STAGED, "scene.json"))) {
|
||||
console.log(` ✗ 缺少 staged 样本:${STAGED}\n 先跑:python -m tools.downloader fetch --page kv45`);
|
||||
process.exit(1);
|
||||
}
|
||||
const build = spawnSync(process.execPath, ["node_modules/typescript/bin/tsc", "-p", "tsconfig.runtime.json"], {
|
||||
cwd: ROOT,
|
||||
encoding: "utf8",
|
||||
});
|
||||
check(build.status === 0, "运行时能编译(tsc -p tsconfig.runtime.json)", (build.stdout ?? "").trim().split("\n").pop() ?? "");
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// 造夹具
|
||||
// ---------------------------------------------------------------------------------------
|
||||
await rm(FIXTURE, { recursive: true, force: true });
|
||||
await mkdir(join(FIXTURE, "scripts"), { recursive: true });
|
||||
await mkdir(join(FIXTURE, "styles"), { recursive: true });
|
||||
await cp(STAGED, join(FIXTURE, "assets"), { recursive: true });
|
||||
await cp(STAGED_PERSPECTIVE, join(FIXTURE, "assets-perspective"), { recursive: true });
|
||||
for (const file of await readdir(join(ROOT, "build", "scripts"))) {
|
||||
if (file.endsWith(".js")) await cp(join(ROOT, "build", "scripts", file), join(FIXTURE, "scripts", file));
|
||||
}
|
||||
await cp(join(ROOT, "src", "vendor", "spine-player.js"), join(FIXTURE, "scripts", "spine-player.js"));
|
||||
for (const file of await readdir(join(ROOT, "src", "styles"))) {
|
||||
await cp(join(ROOT, "src", "styles", file), join(FIXTURE, "styles", file));
|
||||
}
|
||||
const presetModule = await buildPresetModule();
|
||||
await writeFile(join(FIXTURE, "scripts", "scene-preset.js"), presetModule, "utf8");
|
||||
// 第二个夹具页:透视样本(把 assets 前缀改到 assets-perspective/)
|
||||
const perspectiveModule = (await buildPresetModule(STAGED_PERSPECTIVE, "nico-tea", "ys", null)).replaceAll(
|
||||
"../assets/",
|
||||
"../assets-perspective/",
|
||||
);
|
||||
await writeFile(join(FIXTURE, "scripts", "scene-preset-perspective.js"), perspectiveModule, "utf8");
|
||||
await writeFile(
|
||||
join(FIXTURE, "scripts", "presets.js"),
|
||||
[
|
||||
`import preset from "./scene-preset.js";`,
|
||||
`import perspective from "./scene-preset-perspective.js";`,
|
||||
`export const defaultPresetId = "kv45";`,
|
||||
`export default Object.fromEntries([["kv45", preset], ["nico-tea", perspective]]);`,
|
||||
``,
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
join(FIXTURE, "index.html"),
|
||||
[
|
||||
`<!DOCTYPE html>`,
|
||||
`<html lang="zh"><head><meta charset="UTF-8" /><title>scene fixture</title>`,
|
||||
`<link rel="icon" href="data:," />`,
|
||||
`<link rel="stylesheet" href="./styles/spine-player.css" />`,
|
||||
`<link rel="stylesheet" href="./styles/index.css" /></head>`,
|
||||
`<body><div id="spine-container"></div><audio id="background-music"></audio>`,
|
||||
`<script src="./scripts/spine-player.js"></script>`,
|
||||
`<script type="module" src="./scripts/index.js"></script></body></html>`,
|
||||
``,
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
check(existsSync(join(FIXTURE, "scripts", "scene-controller.js")), "夹具里带上了 scene-controller.js");
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// 起静态服务
|
||||
// ---------------------------------------------------------------------------------------
|
||||
const server = createServer(async (req, res) => {
|
||||
const pathname = decodeURIComponent(new URL(req.url ?? "/", "http://x").pathname);
|
||||
const rel = normalize(pathname).replace(/^([/\\])+/, "");
|
||||
const target = resolve(join(FIXTURE, rel === "" ? "index.html" : rel));
|
||||
if (!target.startsWith(FIXTURE)) {
|
||||
res.writeHead(403).end();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const info = await stat(target);
|
||||
if (info.isDirectory()) {
|
||||
res.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { "content-type": MIME[extname(target).toLowerCase()] ?? "application/octet-stream" });
|
||||
createReadStream(target).pipe(res);
|
||||
} catch {
|
||||
res.writeHead(404).end();
|
||||
}
|
||||
});
|
||||
await new Promise<void>((r) => server.listen(PORT, "127.0.0.1", () => r()));
|
||||
const BASE = `http://127.0.0.1:${PORT}/`;
|
||||
|
||||
const readDebug = async (evaluate: (expr: string) => Promise<unknown>): Promise<SceneDebug | null> => {
|
||||
const raw = await evaluate("JSON.stringify(window.__sceneDebug ?? null)");
|
||||
return raw && raw !== "null" ? (JSON.parse(String(raw)) as SceneDebug) : null;
|
||||
};
|
||||
|
||||
const waitFor = async (evaluate: (expr: string) => Promise<unknown>, predicate: (d: SceneDebug | null) => boolean, tries = 40): Promise<SceneDebug | null> => {
|
||||
let last: SceneDebug | null = null;
|
||||
for (let i = 0; i < tries; i++) {
|
||||
last = await readDebug(evaluate);
|
||||
if (predicate(last)) return last;
|
||||
await sleep(250);
|
||||
}
|
||||
return last;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// 绿:多骨架 + 贴图平面真的合成出来
|
||||
// ---------------------------------------------------------------------------------------
|
||||
await withPage({ width: W, height: H, profile: "tools/.cache/edge-scene" }, async ({ send, evaluate, exceptions }) => {
|
||||
await send("Page.navigate", { url: BASE });
|
||||
const debug = await waitFor(evaluate, (d) => Boolean(d && d.loaded >= d.parts && d.parts > 0));
|
||||
console.log(` · __sceneDebug = ${JSON.stringify(debug)}`);
|
||||
check(Boolean(debug), "页面写出了 __sceneDebug");
|
||||
check(debug?.parts === 5, "part 数 = 4 骨架 + 1 平面 = 5", debug?.parts);
|
||||
check(debug?.loaded === 5, "5 件全部加载", debug?.loaded);
|
||||
check((debug?.errors.length ?? 1) === 0, "0 加载错误", debug?.errors.join(" | "));
|
||||
check(debug?.spines.length === 4, "4 具骨架都建了 AnimationState", debug?.spines.join(","));
|
||||
check(debug?.images.length === 1, "1 块贴图平面", debug?.images.join(","));
|
||||
check(Boolean(debug?.framing && debug.framing.width > 0 && debug.framing.height > 0), "取景矩形有效", JSON.stringify(debug?.framing));
|
||||
|
||||
// resize(Expand) 把画布像素尺寸设成 clientWidth * devicePixelRatio,所以按 dpr 比。
|
||||
const canvasSize = String(
|
||||
await evaluate(
|
||||
`(() => { const c = document.querySelector("#spine-container canvas"); if (!c) return ""; const dpr = window.devicePixelRatio || 1; return [c.width, c.height, Math.round(c.clientWidth * dpr), Math.round(c.clientHeight * dpr)].join(","); })()`,
|
||||
),
|
||||
);
|
||||
const [cw, ch, expectW, expectH] = canvasSize.split(",").map(Number);
|
||||
check(cw === expectW && ch === expectH && cw! > 0, "画布像素尺寸 = CSS 尺寸 × dpr", canvasSize);
|
||||
|
||||
const glError = Number(await evaluate(`(() => { const c = document.querySelector("#spine-container canvas"); if (!c) return -1; const gl = c.getContext("webgl2") || c.getContext("webgl"); return gl ? gl.getError() : -1; })()`));
|
||||
if (glError === -1) console.log(" · 拿不到 WebGL 上下文(类型不匹配),跳过 gl.getError 断言");
|
||||
else check(glError === 0, "gl.getError() === 0", glError);
|
||||
|
||||
check(exceptions.length === 0, "0 未捕获异常", exceptions.slice(0, 2).join(" | ") || "无");
|
||||
|
||||
// 画面非空:**隐藏画布前后截图必须不同**。
|
||||
// 不能数"alpha>0 的像素"——页面自身有底色,空画布也全是不透明像素(实测 100%,是个假绿)。
|
||||
const shotWith = (await send("Page.captureScreenshot", { format: "png" })) as { data: string };
|
||||
await writeFile(SHOT, Buffer.from(shotWith.data, "base64"));
|
||||
await evaluate(`(() => { const c = document.querySelector("#spine-container canvas"); if (c) c.style.visibility = "hidden"; })()`);
|
||||
await sleep(300);
|
||||
const shotWithout = (await send("Page.captureScreenshot", { format: "png" })) as { data: string };
|
||||
const hidden = join(ROOT, "tools", ".cache", "scene-player-shot-hidden.png");
|
||||
await writeFile(hidden, Buffer.from(shotWithout.data, "base64"));
|
||||
const [withPixels, withoutPixels] = await Promise.all([rawPixels(SHOT), rawPixels(hidden)]);
|
||||
const ratio = diffRatio(withPixels, withoutPixels);
|
||||
check(ratio > 0.02, "画布确实贡献了像素(隐藏画布后画面变化 > 2%)", `${(ratio * 100).toFixed(1)}%(截图 ${SHOT})`);
|
||||
await evaluate(`(() => { const c = document.querySelector("#spine-container canvas"); if (c) c.style.visibility = "visible"; })()`);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// 纯色平面:不能被当贴图画(原来会给 drawTexture 传 undefined 直接崩)
|
||||
// ---------------------------------------------------------------------------------------
|
||||
await withPage({ width: W, height: H, profile: "tools/.cache/edge-scene" }, async ({ send, evaluate, exceptions }) => {
|
||||
const withSolid = presetModule.replace(
|
||||
` ],\n },`,
|
||||
` { kind: "solid", id: "DEFAULT", order: 99, position: [0, 0, 0], scale: [1, 1, 1], width: 100, height: 100 },\n ],\n },`,
|
||||
);
|
||||
check(withSolid !== presetModule, "反例确实加进了一块纯色平面");
|
||||
await writeFile(join(FIXTURE, "scripts", "scene-preset.js"), withSolid, "utf8");
|
||||
await send("Page.navigate", { url: `${BASE}?solid=1` });
|
||||
const debug = await waitFor(evaluate, (d) => Boolean(d && d.loaded >= 5));
|
||||
check(debug?.solids === 1, "纯色平面被计数(solids = 1)", debug?.solids);
|
||||
check(exceptions.length === 0, "纯色平面不崩(0 未捕获异常)", exceptions.slice(0, 1).join("") || "无");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// 透视场景:取景矩形必须等于**独立算出来**的视锥(这条能抓住"投影/取景算错")
|
||||
// ---------------------------------------------------------------------------------------
|
||||
await withPage({ width: W, height: H, profile: "tools/.cache/edge-scene" }, async ({ send, evaluate, exceptions }) => {
|
||||
await send("Page.navigate", { url: `${BASE}?perspective=1` });
|
||||
// 等运行时挂上监听器(它在 index.js 顶层注册,模块执行完才存在),再走 WE 的真实路径切预设。
|
||||
let ready = false;
|
||||
for (let i = 0; i < 40 && !ready; i++) {
|
||||
ready = Boolean(await evaluate("typeof window.wallpaperPropertyListener === 'object' && window.wallpaperPropertyListener !== null"));
|
||||
if (!ready) await sleep(250);
|
||||
}
|
||||
check(ready, "运行时挂上了 wallpaperPropertyListener");
|
||||
await evaluate(`window.wallpaperPropertyListener.applyUserProperties({ preset: { value: "nico-tea" } })`);
|
||||
const debug = await waitFor(evaluate, (d) => Boolean(d && d.parts > 20 && d.loaded > 20));
|
||||
console.log(` · 透视 __sceneDebug = ${JSON.stringify(debug && { parts: debug.parts, loaded: debug.loaded, framing: debug.framing, rotatedSkipped: debug.rotatedSkipped })}`);
|
||||
check(debug?.parts === 43, "透视样本 part 数 = 43", debug?.parts);
|
||||
check((debug?.errors.length ?? 1) === 0, "透视样本 0 加载错误", debug?.errors.slice(0, 2).join(" | "));
|
||||
check(debug?.rotatedSkipped === 2, "两片倾斜的 3D 面片被跳过并计数", debug?.rotatedSkipped);
|
||||
check(exceptions.length === 0, "透视样本 0 未捕获异常", exceptions.slice(0, 1).join("") || "无");
|
||||
|
||||
// 独立算期望:s0 = 1/(tan(fov/2)·camZ);可见区 = ui 矩形 × s0;再按画布比例 contain。
|
||||
const scene = JSON.parse(await readFile(join(STAGED_PERSPECTIVE, "scene.json"), "utf8")) as {
|
||||
ui?: number[];
|
||||
camera?: { camera?: { fov?: number }; position?: number[] };
|
||||
};
|
||||
const fov = scene.camera?.camera?.fov ?? 31.417;
|
||||
const camZ = scene.camera?.position?.[2] ?? 1920;
|
||||
const uiSize = scene.ui ?? [2500, 1080];
|
||||
const uiW = uiSize[0] ?? 2500;
|
||||
const uiH = uiSize[1] ?? 1080;
|
||||
const s0 = 1 / (Math.tan((fov * Math.PI) / 360) * camZ);
|
||||
const aspect = W / H;
|
||||
// 复刻引擎 `resizeUI`:可见矩形 = UI 矩形按画布比收缩一个轴(b<1 收宽,否则收高)。
|
||||
const b = aspect / (uiW / uiH);
|
||||
const visW = b < 1 ? uiW * b : uiW;
|
||||
const visH = b < 1 ? uiH : uiH / b;
|
||||
const expectedW = visW * s0;
|
||||
const expectedH = visH * s0;
|
||||
const framing = debug?.framing;
|
||||
const close = (a: number | undefined, b: number) => typeof a === "number" && Math.abs(a - b) / b < 1e-6;
|
||||
check(close(framing?.width, expectedW), `取景宽 = 视锥宽(独立算 ${expectedW.toFixed(4)})`, framing?.width);
|
||||
check(close(framing?.height, expectedH), `取景高 = 视锥高(独立算 ${expectedH.toFixed(4)})`, framing?.height);
|
||||
check(close(framing?.x, -expectedW / 2) && close(framing?.y, -expectedH / 2), "取景以场景原点为中心", JSON.stringify(framing));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// 红:把一具骨架的 atlas 指错 → 必须报出来
|
||||
// ---------------------------------------------------------------------------------------
|
||||
await withPage({ width: W, height: H, profile: "tools/.cache/edge-scene" }, async ({ send, evaluate }) => {
|
||||
const broken = presetModule.replace(
|
||||
`atlasUrl: asset("../assets/spines/01_beijing/01_beijing.atlas")`,
|
||||
`atlasUrl: asset("../assets/spines/01_beijing/does-not-exist.atlas")`,
|
||||
);
|
||||
check(broken !== presetModule, "反例确实改动了 atlasUrl");
|
||||
await writeFile(join(FIXTURE, "scripts", "scene-preset.js"), broken, "utf8");
|
||||
await send("Page.navigate", { url: `${BASE}?red=1` });
|
||||
const debug = await waitFor(evaluate, (d) => Boolean(d && d.errors.length > 0), 20);
|
||||
console.log(` · 反例 __sceneDebug = ${JSON.stringify(debug)}`);
|
||||
check(Boolean(debug && debug.errors.length > 0), "atlas 指错 → 报出加载错误", debug?.errors.slice(0, 1).join(""));
|
||||
check(Boolean(debug && debug.loaded < (debug?.parts ?? 0)), "atlas 指错 → 少加载一件", `${debug?.loaded}/${debug?.parts}`);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// 还原:必须重新变绿(证明红是那处改动引起的)
|
||||
// ---------------------------------------------------------------------------------------
|
||||
await writeFile(join(FIXTURE, "scripts", "scene-preset.js"), presetModule, "utf8");
|
||||
await withPage({ width: W, height: H, profile: "tools/.cache/edge-scene" }, async ({ send, evaluate }) => {
|
||||
await send("Page.navigate", { url: `${BASE}?restore=1` });
|
||||
const debug = await waitFor(evaluate, (d) => Boolean(d && d.loaded >= d.parts && d.parts > 0));
|
||||
check(debug?.loaded === 5 && (debug?.errors.length ?? 1) === 0, "还原 atlasUrl → 重新全绿", `${debug?.loaded}/${debug?.parts}`);
|
||||
});
|
||||
|
||||
server.close();
|
||||
console.log(`\n夹具留在 ${FIXTURE}(可直接起服务器用眼睛看)`);
|
||||
console.log(failures === 0 ? "\n场景播放器门:通过" : `\n场景播放器门:失败 ${failures} 处`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,153 @@
|
||||
// 验收 `pnpm build --sim`:把产出的自包含包用 file:// 直接打开,确认
|
||||
// ① 骨架真的画出来了(canvas 出现且有非空像素)
|
||||
// ② 0 网络请求失败、0 异常、0 控制台报错
|
||||
// ③ 模拟器面板在,且明确标着"模拟环境"
|
||||
// ④ 音频能播(data: URL 内联)
|
||||
// ⑤ 全程没有任何 http(s) 请求("自包含"的硬定义)
|
||||
//
|
||||
// 用法:node tools/checks/verify-sim-page.mts [分发目录名]
|
||||
import { withPage, sleep } from "../cdp.mjs";
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const dirName = process.argv[2] ?? "single-hsr-kv37";
|
||||
const pageAbs = resolve("dist/releases", dirName, "sim/index.html");
|
||||
if (!existsSync(pageAbs)) {
|
||||
console.error(`找不到 ${pageAbs}(先跑 node tools/build.ts --single kv37 --sim)`);
|
||||
process.exit(1);
|
||||
}
|
||||
const fileUrl = "file:///" + pageAbs.replace(/\\/g, "/");
|
||||
|
||||
let failures = 0;
|
||||
const check = (ok: unknown, label: string, detail?: unknown) => {
|
||||
if (!ok) failures++;
|
||||
console.log(` ${ok ? "✓" : "✗"} ${label}${detail !== undefined ? ` → ${String(detail)}` : ""}`);
|
||||
};
|
||||
|
||||
console.log(`自包含包:${pageAbs}`);
|
||||
console.log(`体积:${(statSync(pageAbs).size / 1024 / 1024).toFixed(1)} MB\n`);
|
||||
|
||||
await withPage({ width: 1920, height: 1080 }, async ({ send, evaluate, consoleLines, exceptions }) => {
|
||||
const rawSend = send;
|
||||
|
||||
await rawSend("Page.navigate", { url: fileUrl });
|
||||
const deadline = Date.now() + 45000;
|
||||
let ready = false;
|
||||
while (Date.now() < deadline) {
|
||||
const canvasCount = await evaluate('document.querySelectorAll("#spine-container canvas").length');
|
||||
if (canvasCount > 0) {
|
||||
ready = true;
|
||||
break;
|
||||
}
|
||||
await sleep(250);
|
||||
}
|
||||
await sleep(2500);
|
||||
|
||||
// ⑤ "自包含"的硬定义:这个文档取过的资源里不能有任何 http(s)。
|
||||
// 用页面侧的 Resource Timing,不需要 CDP 的事件通道。
|
||||
const externalRequests = await evaluate(`(function () {
|
||||
return performance.getEntriesByType("resource")
|
||||
.map(function (e) { return e.name; })
|
||||
.filter(function (n) { return /^https?:/i.test(n); });
|
||||
})()`);
|
||||
check(
|
||||
Array.isArray(externalRequests) && externalRequests.length === 0,
|
||||
"全程没有任何 http(s) 请求(自包含的硬定义)",
|
||||
Array.isArray(externalRequests) && externalRequests.length > 0 ? externalRequests.slice(0, 3).join(", ") : "无",
|
||||
);
|
||||
|
||||
const state = await evaluate(`(function () {
|
||||
var canvas = document.querySelector("#spine-container canvas");
|
||||
var out = {
|
||||
canvas: document.querySelectorAll("#spine-container canvas").length,
|
||||
canvasSize: canvas ? canvas.clientWidth + "x" + canvas.clientHeight : null,
|
||||
bg: (document.body.style.backgroundImage || "").slice(0, 24),
|
||||
bgIsData: /^url\\(["']?data:/.test(document.body.style.backgroundImage || ""),
|
||||
sim: !!window.__weSim,
|
||||
banner: document.getElementById("wesim") ? document.querySelector("#wesim .banner").textContent.trim() : null,
|
||||
// 自包含页在 <分发根>/sim/ 下,预览图必须已被内联成 data: URL——
|
||||
// file:// 下 ../preview.gif 是跨目录,读不到。
|
||||
previewSrc: (function () { var i = document.querySelector("#wesim .preview img"); return i ? i.getAttribute("src").slice(0, 5) : null; })(),
|
||||
previewNatural: (function () { var i = document.querySelector("#wesim .preview img"); return i ? i.naturalWidth + "x" + i.naturalHeight : null; })(),
|
||||
modules: window.__weModules ? Object.keys(window.__weModules).length : 0,
|
||||
assetKeys: window.__simAssets ? Object.keys(window.__simAssets).length : 0,
|
||||
audioSrcIsData: false,
|
||||
audioDuration: null
|
||||
};
|
||||
var a = document.getElementById("background-music");
|
||||
if (a) {
|
||||
out.audioSrcIsData = /^data:/.test(a.src || "");
|
||||
out.audioDuration = isFinite(a.duration) ? a.duration : null;
|
||||
out.audioError = a.error ? a.error.code : null;
|
||||
}
|
||||
return out;
|
||||
})()`);
|
||||
|
||||
console.log("=== 渲染 ===");
|
||||
check(ready, "出现 Spine 画布", `canvas=${state.canvas}`);
|
||||
check(state.canvas === 1, "只有一张 canvas", `canvas=${state.canvas}`);
|
||||
check(state.canvasSize === "1920x1080", "画布尺寸等于窗口", state.canvasSize);
|
||||
|
||||
// 画布真的画了东西:跨帧采样非透明比例。
|
||||
//
|
||||
// 两个坑叠在一起,先前两次都判成了"空白":
|
||||
// ① 页面里有两张 canvas——spine 自己建的那张(承载渲染)和一张 300×150 的默认尺寸空画布。
|
||||
// 按 `#spine-container canvas` 取到的是后者,尺寸就不对。
|
||||
// ② 播放器用 `preserveDrawingBuffer: false`(默认),**帧外**读像素拿到的永远是已清空的缓冲。
|
||||
// 只读一次必然是 0%,与画没画无关。必须在 rAF 里连续采样、取历史最大值。
|
||||
await evaluate(`(function () {
|
||||
var list = [].slice.call(document.querySelectorAll("#spine-container canvas"));
|
||||
var canvas = list.sort(function (a, b) { return b.clientWidth * b.clientHeight - a.clientWidth * a.clientHeight; })[0];
|
||||
if (!canvas) { window.__px = { error: "没有 canvas" }; return; }
|
||||
var gl = canvas.getContext("webgl2") || canvas.getContext("webgl");
|
||||
if (!gl) { window.__px = { error: "拿不到 WebGL 上下文" }; return; }
|
||||
window.__px = { best: 0, frames: 0, w: canvas.width, h: canvas.height, count: list.length };
|
||||
var buf = new Uint8Array(canvas.width * canvas.height * 4);
|
||||
var tick = function () {
|
||||
gl.readPixels(0, 0, canvas.width, canvas.height, gl.RGBA, gl.UNSIGNED_BYTE, buf);
|
||||
var n = 0;
|
||||
for (var i = 3; i < buf.length; i += 4) if (buf[i] > 8) n++;
|
||||
var ratio = n / (canvas.width * canvas.height);
|
||||
if (ratio > window.__px.best) window.__px.best = ratio;
|
||||
window.__px.frames++;
|
||||
if (window.__px.frames < 90) requestAnimationFrame(tick);
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
})()`);
|
||||
await sleep(2500);
|
||||
const pixels = await evaluate("window.__px");
|
||||
check(
|
||||
pixels !== null && pixels.best !== undefined && pixels.best > 0.02,
|
||||
"画布上有实际像素(不是空白)",
|
||||
pixels === null
|
||||
? "没有 canvas"
|
||||
: pixels.error
|
||||
? pixels.error
|
||||
: `非透明占比 ${((pixels.best ?? 0) * 100).toFixed(1)}%(${pixels.w}x${pixels.h},采样 ${pixels.frames} 帧)`,
|
||||
);
|
||||
|
||||
console.log("\n=== 自包含性 ===");
|
||||
check(state.bgIsData, "背景图是 data: URL(没有外部文件依赖)", state.bg);
|
||||
check(state.assetKeys > 0, "内联资源表已建立", `${state.assetKeys} 个 key`);
|
||||
check(state.modules >= 9, "模块注册表已建立", `${state.modules} 个模块`);
|
||||
check(state.audioSrcIsData, "音频是 data: URL(内联)");
|
||||
check(state.audioDuration !== null && state.audioDuration > 60, "音频可解码", state.audioDuration ? `${state.audioDuration.toFixed(0)}s` : `error=${state.audioError}`);
|
||||
check(pageAbs.length > 0 && !/^https?:/.test(state.bg), "没有指向 http(s) 的资源");
|
||||
|
||||
console.log("\n=== 模拟器 ===");
|
||||
check(state.sim, "window.__weSim 存在");
|
||||
check(/模拟环境/.test(state.banner ?? ""), "面板标着「模拟环境」", state.banner);
|
||||
// 自包含包里的预览图:必须是内联的 data: URL,而且要真的解码出来(坏图也会是 data:)。
|
||||
if (state.previewSrc !== null) {
|
||||
check(state.previewSrc === "data:", "预览图已内联成 data: URL", state.previewSrc);
|
||||
check(state.previewNatural === "160x160", "内联的预览图能解码", state.previewNatural);
|
||||
}
|
||||
|
||||
console.log("\n=== 干净度 ===");
|
||||
check(exceptions.length === 0, "0 未捕获异常", exceptions.slice(0, 3).join(" | ") || "无");
|
||||
const noisy = consoleLines.filter((l) => /error|failed|blocked|CORS/i.test(l));
|
||||
check(noisy.length === 0, "0 控制台报错", noisy.slice(0, 3).join(" | ") || "干净");
|
||||
});
|
||||
|
||||
console.log(failures === 0 ? "\n自包含包可用:双击即可打开,无需 pnpm dev,也无需任何 http 服务" : `\n自包含包验收失败:${failures} 处`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,157 @@
|
||||
// 静态预览验收:证明 WE 模拟面板**不依赖调试服**,且是**右边缘滑出**的。
|
||||
//
|
||||
// 为什么要有这个测试:
|
||||
// ① 面板曾经只在调试服注入时才存在。用户要求 GitHub Pages 预览也能弹出它——
|
||||
// 静态托管没有调试服,所以必须由 `--with-sim` 把驱动**注入 index.html**。
|
||||
// ② Pages 把站点放在 `/<repo>/` **子路径**下。驱动若用根绝对路径 `/scripts/…`,
|
||||
// 在子路径下必然 404。所以这个测试刻意把分发挂在 `/repo/` 前缀后面服务,
|
||||
// 而不是挂在根上——挂根上测不出这个错。
|
||||
//
|
||||
// 用法:node tools/checks/verify-static-preview.mts [分发目录名]
|
||||
// 前提:已经 `pnpm build --with-sim`。
|
||||
|
||||
import { createServer } from "node:http";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { stat } from "node:fs/promises";
|
||||
import { extname, join, normalize, resolve } from "node:path";
|
||||
import { withPage, sleep } from "../cdp.mjs";
|
||||
|
||||
const DIR = process.argv[2] ?? "collection-all";
|
||||
const PORT = Number(process.env.STATIC_PORT ?? 8195);
|
||||
const ROOT = resolve("dist/releases");
|
||||
/** 模拟 GitHub Pages 的 /<repo>/ 子路径。**不能**是空串,否则测不出根绝对路径的错。 */
|
||||
const PREFIX = "/repo";
|
||||
|
||||
let failures = 0;
|
||||
const check = (ok: unknown, label: string, detail?: unknown) => {
|
||||
if (!ok) failures++;
|
||||
console.log(` ${ok ? "✓" : "✗"} ${label}${detail !== undefined ? ` → ${String(detail)}` : ""}`);
|
||||
};
|
||||
|
||||
const MIME = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".atlas": "text/plain; charset=utf-8",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".webp": "image/webp",
|
||||
".gif": "image/gif",
|
||||
".flac": "audio/flac",
|
||||
".mp3": "audio/mpeg",
|
||||
".ogg": "audio/ogg",
|
||||
};
|
||||
|
||||
const notFound: string[] = [];
|
||||
const server = createServer(async (req, res) => {
|
||||
const pathname = decodeURIComponent(new URL(req.url ?? "/", "http://x").pathname);
|
||||
if (!pathname.startsWith(`${PREFIX}/`)) {
|
||||
notFound.push(pathname);
|
||||
res.writeHead(404).end("outside prefix");
|
||||
return;
|
||||
}
|
||||
let rel = pathname.slice(PREFIX.length + 1);
|
||||
if (rel === "" || rel.endsWith("/")) rel += "index.html";
|
||||
const file = join(ROOT, normalize(rel));
|
||||
if (!file.startsWith(ROOT)) {
|
||||
res.writeHead(403).end("escape");
|
||||
return;
|
||||
}
|
||||
const info = await stat(file).catch(() => null);
|
||||
if (!info?.isFile()) {
|
||||
notFound.push(pathname);
|
||||
res.writeHead(404).end("not found");
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { "content-type": MIME[extname(file).toLowerCase()] ?? "application/octet-stream", "content-length": info.size });
|
||||
createReadStream(file).pipe(res);
|
||||
});
|
||||
|
||||
await new Promise<void>((r) => server.listen(PORT, "127.0.0.1", () => r()));
|
||||
const BASE = `http://127.0.0.1:${PORT}${PREFIX}/${DIR}/`;
|
||||
console.log(`静态托管(模拟 Pages 子路径):${BASE}\n`);
|
||||
|
||||
const W = 1920;
|
||||
const H = 1080;
|
||||
const EDGE_X = W - 8; // 16px 热区之内
|
||||
const AWAY_X = 200; // 远离右边缘
|
||||
|
||||
const result = await withPage({ width: W, height: H, profile: "tools/.cache/edge-static" }, async ({ send, evaluate, consoleLines, exceptions }) => {
|
||||
const move = async (x, y) => {
|
||||
await send("Input.dispatchMouseEvent", { type: "mouseMoved", x, y, button: "none", buttons: 0 });
|
||||
await sleep(320); // > 面板 .18s 过渡
|
||||
};
|
||||
const open = () => evaluate(`document.getElementById("wesim").dataset.open`);
|
||||
|
||||
await send("Page.navigate", { url: BASE });
|
||||
await sleep(3500);
|
||||
|
||||
const initial = await evaluate(`(function(){
|
||||
var p = document.getElementById("wesim");
|
||||
var e = document.getElementById("wesim-edge");
|
||||
return {
|
||||
sim: !!window.__weSim,
|
||||
isSimulated: window.__weSim ? window.__weSim.__isSimulated : null,
|
||||
driverOnce: window.__weSimDriver === true,
|
||||
panel: !!p, edge: !!e,
|
||||
open: p ? p.dataset.open : null,
|
||||
banner: p && p.querySelector(".banner") ? p.querySelector(".banner").textContent : null,
|
||||
canvas: document.querySelectorAll("canvas").length,
|
||||
hasToggle: !!document.querySelector("#wesim .toggle")
|
||||
};
|
||||
})()`);
|
||||
|
||||
// ── 交互:右边缘滑出 ──
|
||||
await move(AWAY_X, H / 2);
|
||||
const closedInitially = await open();
|
||||
await move(EDGE_X, H / 2);
|
||||
const openedByEdge = await open();
|
||||
await move(AWAY_X, H / 2);
|
||||
const closedByLeave = await open();
|
||||
await move(EDGE_X, H / 2);
|
||||
const reopened = await open();
|
||||
|
||||
// 取消收起:此刻真实指针就停在右边缘热区里。没有 armed 规则的话,
|
||||
// 面板滑走会立刻让边缘命中再次触发,表现为"取消关不掉"。
|
||||
// (官方面板没有 ×,底部是确认/取消,所以这里点「取消」。)
|
||||
await evaluate(`document.querySelector("#wesim .foot button:not(.primary)").click()`);
|
||||
await sleep(400);
|
||||
const afterClose = await open();
|
||||
|
||||
// 离开边缘再回来 → 重新武装
|
||||
await move(AWAY_X, H / 2);
|
||||
await move(EDGE_X, H / 2);
|
||||
const afterRearm = await open();
|
||||
|
||||
return { initial, closedInitially, openedByEdge, closedByLeave, reopened, afterClose, afterRearm, consoleLines, exceptions };
|
||||
});
|
||||
|
||||
const r = result;
|
||||
console.log("=== ① 静态托管下模拟器可用(无调试服)===");
|
||||
check(r.initial.sim === true, "window.__weSim 存在(驱动已随产物注入)");
|
||||
check(r.initial.isSimulated === true, "window.__weSim.__isSimulated = true");
|
||||
check(r.initial.driverOnce === true, "驱动幂等标记已置位");
|
||||
check(r.initial.panel === true, "#wesim 面板已挂载");
|
||||
check(r.initial.edge === true, "#wesim-edge 右边缘热区已挂载");
|
||||
check(r.initial.hasToggle === false, "没有常驻开关按钮(打开方式只有右边缘)");
|
||||
check(/模拟环境/.test(r.initial.banner ?? ""), "面板有「模拟环境」横幅", r.initial.banner?.trim());
|
||||
check(r.initial.canvas > 0, "壁纸本身也渲染了", `canvas=${r.initial.canvas}`);
|
||||
|
||||
console.log("\n=== ② 右边缘滑出交互 ===");
|
||||
check(r.closedInitially === "0", "初始收起", `data-open=${r.closedInitially}`);
|
||||
check(r.openedByEdge === "1", "指针移到右边缘 → 滑出", `data-open=${r.openedByEdge}`);
|
||||
check(r.closedByLeave === "0", "指针离开 → 自动收回", `data-open=${r.closedByLeave}`);
|
||||
check(r.reopened === "1", "再次移到右边缘 → 再次滑出", `data-open=${r.reopened}`);
|
||||
check(r.afterClose === "0", "「取消」收起后不会被边缘热区立刻弹回", `data-open=${r.afterClose}`);
|
||||
check(r.afterRearm === "1", "离开边缘后再回来 → 重新可触发", `data-open=${r.afterRearm}`);
|
||||
|
||||
console.log("\n=== ③ 子路径下没有 404(相对路径正确)===");
|
||||
check(notFound.length === 0, "静态服务器 0 个 404", notFound.slice(0, 3).join(" | ") || "无");
|
||||
const noisy = r.consoleLines.filter((l) => /error|uncaught/i.test(l));
|
||||
check(r.exceptions.length === 0, "0 未捕获异常", r.exceptions.slice(0, 2).join(" | ") || "无");
|
||||
check(noisy.length === 0, "0 控制台报错", noisy.slice(0, 3).join(" | ") || "干净");
|
||||
|
||||
server.close();
|
||||
console.log(failures === 0 ? "\n静态预览:通过(面板不依赖调试服,且随右边缘滑出)" : `\n静态预览:失败 ${failures} 处`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,79 @@
|
||||
// 视觉等价验收:当前 dist 与**重构前**的存档截图逐像素比对。
|
||||
//
|
||||
// 为什么需要它:`tools/checks/verify-sim-page.mts` 只证明"自包含包能跑",不证明"画面没变"。
|
||||
// 内联、模块合成、URL 重写这些改动都可能悄悄改掉渲染结果。
|
||||
//
|
||||
// 判据:mean|diff| = 0 且 max|diff| = 0(逐像素相同)。
|
||||
// 注意 `tools/capture.mjs` 打印的 "spine 层贡献" 是**同一轮里 ref 帧 vs 主帧**的差
|
||||
// (即"spine 层占了多少像素"),不是与基线的比对——我一开始把它读成了后者,
|
||||
// 白紧张了一场。真正的基线比对只有这里。
|
||||
//
|
||||
// 用法:
|
||||
// node tools/serve.mjs 8190 --root dist/releases/collection-all
|
||||
// node tools/capture.mjs visual-current --base http://127.0.0.1:8190
|
||||
// node tools/checks/verify-visual-equivalence.mts
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
|
||||
const BASELINE = "tools/shots/baseline-pre-refactor";
|
||||
const CURRENT = process.argv[2] ?? "tools/shots/visual-current";
|
||||
const combos: { preset: string; size: string }[] = [];
|
||||
for (const preset of ["kv37", "xilian"]) {
|
||||
for (const size of ["3440x1440", "1920x1080", "1080x1920"]) combos.push({ preset, size });
|
||||
}
|
||||
|
||||
function diff(a, b) {
|
||||
const out = execFileSync("node", ["tools/diff.mjs", a, b], { encoding: "utf8" });
|
||||
return out.trim().split("\n").pop() ?? "";
|
||||
}
|
||||
|
||||
/*
|
||||
* 缺文件时**直接失败**,不跳过。
|
||||
*
|
||||
* 这里原来写的是"缺少文件就跳过",于是有一次当前截图目录被清掉之后,
|
||||
* 它把 0 组比对报成了"0 组全部逐像素相同"——一句绿色的空话。
|
||||
* 一个不检查前置条件的验证等于没有验证:先把该在的文件都确认在,再谈结论。
|
||||
*/
|
||||
const missing: string[] = [];
|
||||
for (const { preset, size } of combos) {
|
||||
for (const file of [`${BASELINE}/${preset}-${size}.png`, `${CURRENT}/${preset}-${size}.png`]) {
|
||||
if (!existsSync(file)) missing.push(file);
|
||||
}
|
||||
}
|
||||
if (missing.length > 0) {
|
||||
console.error(`✗ 缺少 ${missing.length} 个截图,无法比对:`);
|
||||
for (const file of missing.slice(0, 4)) console.error(` ${file}`);
|
||||
if (missing.length > 4) console.error(` …还有 ${missing.length - 4} 个`);
|
||||
console.error(`
|
||||
基线在 ${BASELINE}(重构前存档,不能重新生成)。
|
||||
当前截图需要现拍:
|
||||
node tools/serve.mjs 8190 --root dist/releases/collection-all
|
||||
node tools/capture.mjs visual-current --base http://127.0.0.1:8190
|
||||
`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let failed = 0;
|
||||
let checked = 0;
|
||||
for (const { preset, size } of combos) {
|
||||
const a = `${BASELINE}/${preset}-${size}.png`;
|
||||
const b = `${CURRENT}/${preset}-${size}.png`;
|
||||
checked++;
|
||||
const line = diff(a, b);
|
||||
const identical = /mean\|diff\|=0\/255\s+max\|diff\|=0/.test(line);
|
||||
if (!identical) failed++;
|
||||
console.log(` ${identical ? "✓" : "✗"} ${preset.padEnd(7)} ${size.padEnd(11)} ${line}`);
|
||||
}
|
||||
|
||||
// 走到这里 checked 必然等于 combos.length(上面缺文件就退了),这条是防回归的兜底。
|
||||
if (checked !== combos.length) {
|
||||
console.error(`✗ 只比对了 ${checked}/${combos.length} 组,结论不可信`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
failed === 0
|
||||
? `\n视觉等价:${checked} 组全部与重构前逐像素相同`
|
||||
: `\n视觉等价:${failed}/${checked} 组与基线不同`,
|
||||
);
|
||||
process.exit(failed === 0 ? 0 : 1);
|
||||
+538
@@ -0,0 +1,538 @@
|
||||
// pnpm dev —— 本地调试服:把 dist/releases/ 下的分发目录按发布结构服务出来,并注入 WE 模拟器。
|
||||
//
|
||||
// 为什么不直接服务某个分发目录当根:把**服务根设在 releases 的父目录**,任何一个分发里若存在
|
||||
// 越出自身目录的引用(../ 或根绝对路径),浏览器会立刻 404 —— 这是对"目录可整体搬走"最严的检验。
|
||||
// 每档分发各占一个端口反而可能掩盖问题(越界路径会意外命中别的分发)。
|
||||
//
|
||||
// 路由:
|
||||
// / 分发索引(读 dist-map.json)
|
||||
// /release/<dir>/ 某档分发的根(自动补 index.html)
|
||||
// /release/<dir>/<相对路径> 其内部资源,按扩展名给正确 MIME
|
||||
// /simulator/wallpaper-engine.js WE 模拟器本体(调试期专有,见下)
|
||||
//
|
||||
// 为什么模拟器单独一条路由:按 ADR 0006 §1,模拟器**默认不进发布产物**,只有
|
||||
// `pnpm build --with-sim` 才会往分发目录里放一份。而调试服总是需要它。早先驱动脚本直接
|
||||
// 从 `/release/<dir>/scripts/wallpaper-engine.js` 取,于是默认构建(不带 --with-sim)下
|
||||
// 动态 import 必然 404、面板静默消失——现象是"调试时没有 WE 模拟面板"。
|
||||
// 现在由调试服从编译产物 build/scripts/wallpaper-engine.js 提供,
|
||||
// 分发目录则始终保持"就是发布产物"的样子。
|
||||
//
|
||||
// 查询参数(与既有对拍工具的参数保持兼容,见 tools/serve.mjs):
|
||||
// ?sim=0 不注入 WE 模拟器
|
||||
// ?__props={"preset":"kv37"} 覆盖初始用户属性
|
||||
// ?__propsAt=dom|load 属性下发时序;dom 复现"load 之前就到达"的竞态
|
||||
// ?fps=30 初始 fps(经 applyGeneralProperties 下发);0 = 不限流
|
||||
// ?__paused=1 初始 setPaused(true)
|
||||
// ?nojs=1 剥掉所有 <script>,得到"只有 CSS 背景、没有 canvas"的对照组
|
||||
// ?__freeze=1 注入既有的对拍驱动(冻结动画相位,供 cdp/shot 断言)
|
||||
// ?__hide=canvas 渲染正常但把 canvas 藏起来,得到"只有背景图"的参考帧
|
||||
//
|
||||
// 热更新:监听 src/ 与 wallpapers/,按改动只跑必要的那几步构建,然后经 SSE 通知浏览器。
|
||||
// 改 CSS → 只重铺产物,然后**只换样式表**(不整页重载,Spine 播放器与面板状态都保住)
|
||||
// 改 runtime → tsc runtime + 重铺产物 → 整页刷新
|
||||
// 改 simulator → tsc simulator(若产物里也带模拟器,再重铺一遍)→ 整页刷新
|
||||
// 改壁纸数据 → 重铺产物 → 整页刷新
|
||||
// 构建失败 → 只报错,**不刷新**(刷新只会把半成品页面端上来)
|
||||
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||
import { createReadStream, watch } from "node:fs";
|
||||
import { spawn } from "node:child_process";
|
||||
import { readFile, stat } from "node:fs/promises";
|
||||
import { extname, join, resolve, sep } from "node:path";
|
||||
import { abs, exists, log, readJson } from "./lib/fs.ts";
|
||||
import { readVault, VaultError } from "./lib/vault.ts";
|
||||
import type { DistMap } from "./lib/types.ts";
|
||||
import { SIMULATOR_DRIVER, SIMULATOR_URL, LIVE_RELOAD_CLIENT, NOJS_DRIVER } from "./lib/drivers.ts";
|
||||
|
||||
const MIME: Record<string, string> = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".mjs": "text/javascript; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".atlas": "text/plain; charset=utf-8",
|
||||
".txt": "text/plain; charset=utf-8",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".webp": "image/webp",
|
||||
".gif": "image/gif",
|
||||
".svg": "image/svg+xml",
|
||||
".flac": "audio/flac",
|
||||
".mp3": "audio/mpeg",
|
||||
".ogg": "audio/ogg",
|
||||
".opus": "audio/ogg",
|
||||
".wav": "audio/wav",
|
||||
".webm": "video/webm",
|
||||
};
|
||||
|
||||
interface Args {
|
||||
port: number;
|
||||
build: boolean;
|
||||
open: boolean;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): Args {
|
||||
const args: Args = { port: 5173, build: true, open: false };
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i] ?? "";
|
||||
if (arg === "--port") args.port = Number(argv[++i] ?? 5173);
|
||||
else if (arg.startsWith("--port=")) args.port = Number(arg.slice(7));
|
||||
else if (arg === "--no-build") args.build = false;
|
||||
else if (arg === "--open") args.open = true;
|
||||
// 构建开关本身由 main() 读 process.argv 决定(初始构建与热更新重建共用一份)。
|
||||
// 这里必须显式放行,否则它会在参数解析阶段就被当成未知参数拒掉——
|
||||
// 那样 main() 里那段判断永远走不到,整个开关是死代码。
|
||||
else if (arg === "--with-sim") continue;
|
||||
else if (/^\d+$/.test(arg)) args.port = Number(arg);
|
||||
else if (arg === "--help" || arg === "-h") {
|
||||
log(HELP);
|
||||
process.exit(0);
|
||||
} else throw new VaultError(`未知参数:${arg}\n\n${HELP}`);
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
const HELP = `用法:pnpm dev [--port 5173] [--no-build] [--open] [--with-sim]
|
||||
--no-build 不先跑一次增量构建(dist/ 不存在时会直接报错)
|
||||
--open 在默认浏览器里打开索引页
|
||||
--with-sim 产物里也带一份 WE 模拟器(静态托管预览用)。
|
||||
会一直带着:初始构建与热更新重建用的是同一组开关。`;
|
||||
|
||||
/** 读某分发目录的 project.json 与所需注入数据。 */
|
||||
async function releaseInfo(
|
||||
dir: string,
|
||||
): Promise<
|
||||
| { properties: Record<string, unknown>; version: string; title: string; defaultPresetId: string; preview?: string }
|
||||
| undefined
|
||||
> {
|
||||
const root = abs(`dist/releases/${dir}`);
|
||||
if (!(await exists(join(root, "project.json")))) return undefined;
|
||||
const project = await readJson<{
|
||||
version?: string;
|
||||
title?: string;
|
||||
preview?: string;
|
||||
general?: { properties?: Record<string, unknown> };
|
||||
}>(join(root, "project.json"));
|
||||
const map = await readJson<DistMap>(abs("dist/dist-map.json"));
|
||||
return {
|
||||
properties: project.general?.properties ?? {},
|
||||
version: project.version ?? "",
|
||||
title: project.title ?? dir,
|
||||
defaultPresetId: map.releases.find((r) => r.dir === dir)?.defaultPresetId ?? "",
|
||||
preview: project.preview,
|
||||
};
|
||||
}
|
||||
|
||||
/** 索引页:列出所有分发,点进去即可调试。 */
|
||||
async function indexPage(): Promise<string> {
|
||||
const map = await readJson<DistMap>(abs("dist/dist-map.json"));
|
||||
const rows = map.releases
|
||||
.map((release) => {
|
||||
const wallpapers = release.wallpapers.map((w) => `${w.name}<span class="id">${w.id}</span>`).join("、");
|
||||
return `<tr>
|
||||
<td><a href="/release/${encodeURIComponent(release.dir)}/">${release.displayName}</a>
|
||||
<div class="dir">${release.dir}</div></td>
|
||||
<td class="type">${releaseTypeLabel(release.type, release.scope)}</td>
|
||||
<td>${wallpapers}</td>
|
||||
<td class="num">${(release.bytes / 1024 / 1024).toFixed(1)} MB</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("\n");
|
||||
/** 类型列显示人话:单档 / 游戏合集 / 全部合集(内部值不给人看)。 */
|
||||
function releaseTypeLabel(type: string, scope?: string): string {
|
||||
if (type === "single") return "单档";
|
||||
return scope === "all" ? "全部合集" : "游戏合集";
|
||||
}
|
||||
|
||||
return `<!DOCTYPE html><html lang="zh"><head><meta charset="utf-8">
|
||||
<title>SpineWallpaper 调试服</title>
|
||||
<style>
|
||||
body { margin: 32px auto; max-width: 900px; font: 14px/1.6 "Segoe UI", system-ui, sans-serif;
|
||||
background: #14141a; color: #e8e8ea; }
|
||||
h1 { font-size: 18px; } code { background: #1e1e28; padding: 1px 5px; border-radius: 3px; }
|
||||
table { width: 100%; border-collapse: collapse; margin-top: 12px; }
|
||||
th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid #2c2c38; vertical-align: top; }
|
||||
th { color: #8d8da0; font-size: 11px; text-transform: uppercase; letter-spacing: .06em; }
|
||||
a { color: #8fb8ff; text-decoration: none; } a:hover { text-decoration: underline; }
|
||||
.dir { color: #6f6f85; font-size: 11px; }
|
||||
.type { color: #b9a0ff; } .num { text-align: right; color: #9b9bb0; }
|
||||
.id { color: #6f6f85; margin-left: 4px; font-size: 11px; }
|
||||
.hint { color: #8d8da0; font-size: 12px; margin-top: 20px; }
|
||||
</style></head><body>
|
||||
<h1>SpineWallpaper 调试服 <span class="dir">version ${map.version}</span></h1>
|
||||
<p>每档分发都是 <b>自包含</b> 的:服务根设在 <code>dist/releases/</code>,某个分发里若有越界引用会立刻 404。</p>
|
||||
<table><thead><tr><th>分发</th><th>类型</th><th>壁纸</th><th>体积</th></tr></thead>
|
||||
<tbody>${rows}</tbody></table>
|
||||
<div class="hint">
|
||||
进入某档分发后可用:<code>?sim=0</code> 关模拟器 · <code>&__props={"preset":"kv37"}</code> 覆盖属性 ·
|
||||
<code>&__propsAt=dom</code> 复现 load 前下发 · <code>&fps=30</code> 限流 · <code>&nojs=1</code> 剥脚本对照
|
||||
</div>
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
/** 往 index.html 注入驱动脚本(与既有对拍管线共用 tools/lib/drivers.ts)。 */
|
||||
async function html(file: string, dir: string, search: URLSearchParams): Promise<string> {
|
||||
let body = await readFile(file, "utf8");
|
||||
if (search.get("nojs") === "1") {
|
||||
const stripped = body.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, "<!-- script removed by ?nojs=1 -->");
|
||||
return `${stripped}\n${NOJS_DRIVER}`;
|
||||
}
|
||||
|
||||
const info = await releaseInfo(dir);
|
||||
if (!info) return body;
|
||||
|
||||
// 热更新客户端放进 <head>,尽早连上 SSE:晚一步就会漏掉"保存后立刻开始构建"的那条消息。
|
||||
body = body.replace(/<\/head>/i, () => `${LIVE_RELOAD_CLIENT}\n</head>`);
|
||||
|
||||
// project.json 的属性定义要交给模拟器面板渲染控件;用内联脚本最省一次请求。
|
||||
const propertiesTag = `<script>window.__weProperties = ${JSON.stringify(info.properties).replace(/</g, "\\u003c")};</script>`;
|
||||
|
||||
// 驱动脚本注入在壁纸模块**之前**:它用动态 import 装载模拟器,从而在 index.js 注册
|
||||
// wallpaperPropertyListener 之前就把 API 备好(模拟器源码本身仍是 ES module,见 drivers.ts)。
|
||||
const inject = SIMULATOR_DRIVER({
|
||||
search: search.toString(),
|
||||
dir,
|
||||
title: info.title,
|
||||
version: info.version,
|
||||
defaultPresetId: info.defaultPresetId,
|
||||
preview: info.preview,
|
||||
});
|
||||
if (search.get("sim") !== "0") {
|
||||
body = body.replace(
|
||||
/<script\b[^>]*src=["'][^"']*scripts\/index\.js["'][^>]*><\/script>/i,
|
||||
(match) => `${propertiesTag}\n${inject}\n${match}`,
|
||||
);
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
function safeJoin(root: string, rel: string): string | undefined {
|
||||
const target = resolve(root, rel.replace(/^\/+/, ""));
|
||||
if (target !== root && !target.startsWith(root + sep)) return undefined;
|
||||
return target;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ 热更新 */
|
||||
|
||||
/** 浏览器端注入的 LIVE_RELOAD_CLIENT 会连到 /__dev/events。 */
|
||||
const liveClients = new Set<ServerResponse>();
|
||||
|
||||
function broadcast(payload: Record<string, unknown>): void {
|
||||
const data = `data: ${JSON.stringify(payload)}\n\n`;
|
||||
for (const client of liveClients) {
|
||||
try {
|
||||
client.write(data);
|
||||
} catch {
|
||||
liveClients.delete(client);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const TSC = abs("node_modules/typescript/bin/tsc");
|
||||
|
||||
interface RebuildSteps {
|
||||
runtime: boolean;
|
||||
simulator: boolean;
|
||||
build: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按改动文件决定跑哪几步。
|
||||
*
|
||||
* 全量 `pnpm build` 是 2.2 秒,而绝大多数改动只涉及其中一步:改样式只要重铺产物(0.85s),
|
||||
* 改模拟器只要重编模拟器(0.6s)。省下来的是每次保存的等待时间。
|
||||
*/
|
||||
function stepsFor(files: string[], releasesCarrySimulator: boolean): RebuildSteps {
|
||||
const steps: RebuildSteps = { runtime: false, simulator: false, build: false };
|
||||
for (const file of files) {
|
||||
if (file === "src/globals.d.ts") {
|
||||
// 类型声明同时影响运行时与模拟器
|
||||
steps.runtime = true;
|
||||
steps.simulator = true;
|
||||
steps.build = true;
|
||||
} else if (file.startsWith("src/runtime/")) {
|
||||
steps.runtime = true;
|
||||
steps.build = true; // 编译产物要重铺进各分发
|
||||
} else if (file.startsWith("src/simulator/")) {
|
||||
steps.simulator = true;
|
||||
// --with-sim 的产物里也有一份模拟器,且它的驱动排在调试服注入的那份之前
|
||||
// (幂等闸先到先得),那份不更新的话浏览器会一直跑旧模拟器。
|
||||
steps.build = releasesCarrySimulator;
|
||||
} else if (file.startsWith("src/") || file.startsWith("wallpapers/") || file === "VERSION") {
|
||||
steps.build = true; // 样式、模板、vendor、project.template.json、壁纸数据
|
||||
}
|
||||
}
|
||||
return steps;
|
||||
}
|
||||
|
||||
/** 跑一个子进程,成功返回它的输出,失败抛出带输出的错误(要显示到浏览器上)。 */
|
||||
function runStep(args: string[], label: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, args, { stdio: ["ignore", "pipe", "pipe"] });
|
||||
let output = "";
|
||||
child.stdout.on("data", (chunk: Buffer) => {
|
||||
output += chunk.toString();
|
||||
});
|
||||
child.stderr.on("data", (chunk: Buffer) => {
|
||||
output += chunk.toString();
|
||||
});
|
||||
child.on("error", reject);
|
||||
child.on("close", (code) => {
|
||||
if (code === 0) resolve(output);
|
||||
else reject(new VaultError(`${label} 失败(exit ${code})\n${output.trim().split("\n").slice(-12).join("\n")}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** 监听源码目录。**绝不能监听 dist/**:构建写 dist,监听它就是一个死循环。 */
|
||||
function startWatcher(onChange: (file: string) => void): void {
|
||||
for (const root of ["src", "wallpapers"]) {
|
||||
try {
|
||||
watch(abs(root), { recursive: true }, (_event, filename) => {
|
||||
if (filename) onChange(`${root}/${String(filename).replace(/\\/g, "/")}`);
|
||||
});
|
||||
} catch (error) {
|
||||
log(` ⚠ 无法监听 ${root}/:${String(error)}`);
|
||||
}
|
||||
}
|
||||
try {
|
||||
watch(abs("VERSION"), () => onChange("VERSION"));
|
||||
} catch {
|
||||
// VERSION 不存在就算了,构建自己会报
|
||||
}
|
||||
}
|
||||
|
||||
/** 串行化重建:构建期间来的改动先攒着,跑完再补一轮。 */
|
||||
function createRebuilder(releasesCarrySimulator: boolean, buildFlags: string[]): (files: string[]) => void {
|
||||
const pending = new Set<string>();
|
||||
let timer: ReturnType<typeof setTimeout> | undefined = undefined;
|
||||
let running = false;
|
||||
|
||||
const run = async (): Promise<void> => {
|
||||
if (running) return; // 正在跑的那一轮会在循环里把 pending 收走
|
||||
running = true;
|
||||
try {
|
||||
while (pending.size > 0) {
|
||||
const batch = [...pending];
|
||||
pending.clear();
|
||||
const steps = stepsFor(batch, releasesCarrySimulator);
|
||||
const labels: string[] = [];
|
||||
if (steps.runtime) labels.push("runtime");
|
||||
if (steps.simulator) labels.push("simulator");
|
||||
if (steps.build) labels.push("releases");
|
||||
if (labels.length === 0) continue;
|
||||
|
||||
broadcast({ type: "building", steps: labels });
|
||||
const started = Date.now();
|
||||
try {
|
||||
if (steps.runtime) await runStep([TSC, "-p", "tsconfig.runtime.json"], "tsc runtime");
|
||||
if (steps.simulator) await runStep([TSC, "-p", "tsconfig.simulator.json"], "tsc simulator");
|
||||
// 必须带上与启动时相同的开关。否则 `pnpm dev --with-sim` 会在第一次保存后
|
||||
// 被悄悄换成干净构建——产物里的模拟器没了,而没人会想到是"保存"干的。
|
||||
if (steps.build) await runStep([abs("tools/build.ts"), ...buildFlags], "build");
|
||||
} catch (error) {
|
||||
const message = error instanceof VaultError ? error.message : String(error);
|
||||
log(` ✗ 重建失败:${message.split("\n")[0]}`);
|
||||
broadcast({ type: "error", message });
|
||||
continue; // 不刷新页面:刷新只会把半成品端上来
|
||||
}
|
||||
|
||||
// 只改了样式 → 让浏览器换个样式表就行,不必重载整页
|
||||
const cssOnly =
|
||||
steps.build &&
|
||||
!steps.runtime &&
|
||||
!steps.simulator &&
|
||||
batch.every((f) => /^src\/styles\/.*\.css$/.test(f));
|
||||
log(` ↻ 已重建 ${labels.join(" + ")}(${Date.now() - started} ms)${cssOnly ? " 只换样式表" : " 整页刷新"}`);
|
||||
broadcast({ type: cssOnly ? "css" : "reload" });
|
||||
}
|
||||
} finally {
|
||||
running = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 防抖:一次保存常常触发多个事件(编辑器写临时文件再改名),攒一下再跑。
|
||||
return (files: string[]): void => {
|
||||
for (const file of files) pending.add(file);
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
timer = undefined;
|
||||
void run();
|
||||
}, 120);
|
||||
};
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
// 构建开关只算一次,初始构建与热更新重建共用,免得两处走偏(已经偏过一次)。
|
||||
const buildFlags = process.argv.includes("--with-sim") ? ["--with-sim"] : [];
|
||||
|
||||
if (args.build && !(await exists(abs("dist/dist-map.json")))) {
|
||||
log("dist/ 不存在,先跑一次构建…(--no-build 可跳过)");
|
||||
const child = spawn(process.execPath, [abs("tools/build.ts"), ...buildFlags], {
|
||||
stdio: "inherit",
|
||||
});
|
||||
await new Promise<void>((done, fail) => {
|
||||
child.on("close", (code) => (code === 0 ? done() : fail(new VaultError(`构建失败(exit ${code})`))));
|
||||
});
|
||||
}
|
||||
|
||||
const vault = await readVault();
|
||||
const releasesRoot = abs("dist/releases");
|
||||
const map = await readJson<DistMap>(abs("dist/dist-map.json"));
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
void handle(req, res, releasesRoot);
|
||||
});
|
||||
|
||||
// 产物里带不带模拟器,决定"改模拟器"要不要顺带重铺产物(见 stepsFor)。
|
||||
const releasesCarrySimulator = (
|
||||
await Promise.all(
|
||||
map.releases.map((release) => exists(abs(`dist/releases/${release.dir}/scripts/wallpaper-engine.js`))),
|
||||
)
|
||||
).some(Boolean);
|
||||
const schedule = createRebuilder(releasesCarrySimulator, buildFlags);
|
||||
startWatcher((file) => {
|
||||
schedule([file]);
|
||||
});
|
||||
|
||||
server.listen(args.port, "127.0.0.1", () => {
|
||||
log(`SpineWallpaper 调试服 version ${map.version}`);
|
||||
log(` 壁纸:${vault.wallpapers.map((w) => `${w.id}(${w.meta.name})`).join("、")}`);
|
||||
log(` 索引:http://127.0.0.1:${args.port}/`);
|
||||
for (const release of map.releases) {
|
||||
log(` /release/${release.dir}/ ${release.displayName}`);
|
||||
}
|
||||
// 启动时就把"模拟器能不能加载"说清楚。否则它只会在浏览器里变成一条 404,
|
||||
// 表现成"面板没出来",而没人会想到是构建产物缺文件。
|
||||
void stat(abs("build/scripts/wallpaper-engine.js")).then(
|
||||
(info) =>
|
||||
log(` 模拟器:${SIMULATOR_URL} ✓ ${(info.size / 1024).toFixed(0)} KB(调试期专有,不进发布产物)`),
|
||||
() => log(` 模拟器:✗ 未编译(build/scripts/wallpaper-engine.js 不存在)→ 面板不会出现,请先跑 pnpm build`),
|
||||
);
|
||||
log(
|
||||
` 热更新:监听 src/ 与 wallpapers/(改完自动重建并刷新;CSS 只换样式表)` +
|
||||
(buildFlags.length > 0 ? ` 构建开关:${buildFlags.join(" ")}` : ""),
|
||||
);
|
||||
log(" 注意:重建会清掉本次未产出的目录,\`sim/\` 需要时单独跑 pnpm build sim。");
|
||||
if (args.open) {
|
||||
void import("node:child_process").then(({ spawn }) => {
|
||||
spawn("cmd", ["/c", "start", "", `http://127.0.0.1:${args.port}/`], { stdio: "ignore", detached: true });
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function handle(req: IncomingMessage, res: ServerResponse, releasesRoot: string): Promise<void> {
|
||||
try {
|
||||
// 每次请求重读:改了目录名、加了新壁纸之后,调试服不该需要重启才认。
|
||||
// 索引页本来就这么做;这里以前用的是启动时那一份,于是磁盘上已经是 single-hsr-kv37,
|
||||
// 服务端还按 wallpaper-kv37 校验,整档 404。
|
||||
const map = await readJson<DistMap>(abs("dist/dist-map.json"));
|
||||
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
||||
const pathname = decodeURIComponent(url.pathname);
|
||||
|
||||
if (pathname === "/" || pathname === "/index.html") {
|
||||
const body = await indexPage();
|
||||
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
|
||||
res.end(body);
|
||||
return;
|
||||
}
|
||||
|
||||
// 热更新事件流。text/event-stream 要一直挂着,所以这里不能 end()。
|
||||
if (pathname === "/__dev/events") {
|
||||
res.writeHead(200, {
|
||||
"content-type": "text/event-stream; charset=utf-8",
|
||||
"cache-control": "no-store",
|
||||
connection: "keep-alive",
|
||||
});
|
||||
res.write(": connected\n\n");
|
||||
liveClients.add(res);
|
||||
req.on("close", () => liveClients.delete(res));
|
||||
return;
|
||||
}
|
||||
|
||||
// 模拟器本体:调试期专有资源,从编译产物读(见文件头"为什么模拟器单独一条路由")。
|
||||
// 刻意**不**回落到分发目录里的副本:分发里那份只有 --with-sim 才有,可能是旧的,
|
||||
// 而这里要的永远是"当前源码编出来的那一个"。
|
||||
if (pathname === SIMULATOR_URL) {
|
||||
const file = abs("build/scripts/wallpaper-engine.js");
|
||||
const info = await stat(file).catch(() => null);
|
||||
if (!info?.isFile()) {
|
||||
res
|
||||
.writeHead(404, { "content-type": "text/plain; charset=utf-8" })
|
||||
.end(`模拟器未编译:${file} 不存在。跑一次 pnpm build(或 pnpm build:sim)。`);
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, {
|
||||
"content-type": MIME[".js"] ?? "text/javascript; charset=utf-8",
|
||||
"content-length": info.size,
|
||||
"cache-control": "no-store",
|
||||
});
|
||||
createReadStream(file).pipe(res);
|
||||
return;
|
||||
}
|
||||
|
||||
const match = /^\/release\/([^/]+)(\/.*)?$/.exec(pathname);
|
||||
if (!match) {
|
||||
res
|
||||
.writeHead(404, { "content-type": "text/plain; charset=utf-8" })
|
||||
.end("只服务 /、/release/<dir>/… 与 " + SIMULATOR_URL);
|
||||
return;
|
||||
}
|
||||
|
||||
const dir = match[1] ?? "";
|
||||
if (!map.releases.some((release) => release.dir === dir)) {
|
||||
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" }).end(`没有这档分发:${dir}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const root = join(releasesRoot, dir);
|
||||
let rel = (match[2] ?? "/").replace(/^\//, "");
|
||||
if (rel === "" || rel.endsWith("/")) rel += "index.html";
|
||||
|
||||
const file = safeJoin(root, rel);
|
||||
if (!file) {
|
||||
res.writeHead(403, { "content-type": "text/plain; charset=utf-8" }).end("越出分发目录");
|
||||
return;
|
||||
}
|
||||
const info = await stat(file).catch(() => null);
|
||||
if (!info?.isFile()) {
|
||||
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" }).end(`404 ${rel}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (extname(file).toLowerCase() === ".html") {
|
||||
const body = await html(file, dir, url.searchParams);
|
||||
res.writeHead(200, {
|
||||
"content-type": "text/html; charset=utf-8",
|
||||
"content-length": Buffer.byteLength(body),
|
||||
"cache-control": "no-store",
|
||||
});
|
||||
res.end(body);
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(200, {
|
||||
"content-type": MIME[extname(file).toLowerCase()] ?? "application/octet-stream",
|
||||
"content-length": info.size,
|
||||
"cache-control": "no-store",
|
||||
});
|
||||
createReadStream(file).pipe(res);
|
||||
} catch (error) {
|
||||
res.writeHead(500, { "content-type": "text/plain; charset=utf-8" }).end(String(error));
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await main();
|
||||
} catch (error) {
|
||||
if (error instanceof VaultError) {
|
||||
console.error(`\n调试服启动失败:${error.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
# `tools/downloader/` — 米哈游活动页 Spine 抓取器
|
||||
|
||||
从 `wallpapers/sources.yml` 列出的活动页里把 **Spine 骨架 + 贴图 + 场景装配信息** 抓下来,
|
||||
落到 staging(`_out/`)。**每个有内容的场景各产出一个「可直接搬走的壁纸目录」**——用户挑中的那份
|
||||
整个移到 `wallpapers/<游戏id>/` 就能用(也可以让 `promote` 代劳)。
|
||||
|
||||
它不是构建输入:`wallpapers/` 只放**最终要发布**的资源,抓取脚本与临时下载都留在 `tools/`。
|
||||
|
||||
```bash
|
||||
python -m tools.downloader fetch --page kv45 # 抓一页(结尾自动 verify,有硬伤就非 0 退出)
|
||||
python -m tools.downloader fetch # 抓 sources.yml 里的全部页面
|
||||
python -m tools.downloader fetch --interactive # 逐页问:这一页推荐哪个场景(不影响落盘范围)
|
||||
python -m tools.downloader select --page nico-tea # 只做选择,写 selection.yml
|
||||
python -m tools.downloader verify # 只自检产物
|
||||
python -m tools.downloader fetch --offline # 只用 _cache/ + 已落盘的产物,不发任何请求(重跑验证)
|
||||
python -m tools.downloader promote --page kv45/scene_ava --game hsr --id scene_ava \
|
||||
--name 挥掷千星的筹码 --title '【崩坏:星穹铁道】挥掷千星的筹码' --description '[b]挥掷千星的筹码[/b]'
|
||||
node tools/checks/check-downloader.mts # 门:编译 + 夹具绿 + 四处破坏必红
|
||||
```
|
||||
|
||||
依赖:Python 3.10+ 与 **PyYAML**(`requirements.txt`)。不需要浏览器——这些页面免登录即可拿到
|
||||
全量入口 bundle,骨架数据就在里面。
|
||||
|
||||
---
|
||||
|
||||
## 一、产物形状
|
||||
|
||||
```
|
||||
tools/downloader/
|
||||
├── _cache/<页面>/<脚本名> # 抓下来的 bundle 原文(文本调试缓存,可重跑零请求)
|
||||
├── _out/<游戏>/<页面>/
|
||||
│ ├── page.json # 页面级溯源:来源 URL / 入口脚本 / 场景清单 / 每个场景目录的摘要
|
||||
│ └── <场景id>/ # ← 一个场景 = 一个可直接搬走的壁纸目录
|
||||
│ ├── meta.json # id / name / title / description / game / page / scene / source / audio
|
||||
│ ├── preset.template.json # 运行时配置(背景图 + sceneConfig.parts)
|
||||
│ ├── scene.json # 场景侧车:part 列表 + 每个 part 的世界变换
|
||||
│ ├── spines/<骨架名>/
|
||||
│ │ ├── <骨架名>.json # 骨架(skeleton.images 已归一化为空串)
|
||||
│ │ ├── <骨架名>.atlas # 第一行 = 贴图页名,与落盘文件名逐字一致
|
||||
│ │ ├── <贴图页…> # 与 atlas **同居**(布局 A,见 ADR 0008)
|
||||
│ │ └── meta.json # spine 版本 / 动画 / 皮肤 / 来源 URL
|
||||
│ ├── scene/<场景图> # 几何平面(geometry + material.diffuse)贴的图
|
||||
│ └── audios/ # 音源(当前恒为空:页面的 BGM 还没抓)
|
||||
└── selection.yml # 机器所有的「哪一页推荐哪个场景」(**入库**)
|
||||
```
|
||||
|
||||
场景目录名 = 场景 id 转义成合法壁纸 id(只允许 `[a-z0-9_-]`,首字符必须是字母数字):
|
||||
`scene_main` / `P1` / `loading` 原样,中文场景 id(back-moon 的 `动画预览`)折成 `scene`。
|
||||
**真实 id 不丢**——它在同目录的 `meta.json` 与 `scene.json` 里。
|
||||
|
||||
`_cache/` 与 `_out/` 在 `.gitignore` 里;`selection.yml` 入库。
|
||||
|
||||
### 场景内去重、跨场景不去重
|
||||
|
||||
* **场景内按 id 去重**:同一个场景里同一具骨架被引用多次只存一份(`ctc_rewards` 在
|
||||
back-moon 的 `scene_content` 里出现 4 次、`gc_win` 在 `scene_gacha` 里 6 次,都只落一份)。
|
||||
`scene.json` 里那些 part 仍然一条不少——只有**文件**去重。
|
||||
* **跨场景不去重**:`scene_main/` 与 `scene_ava/` 会各存一份用到的骨架。`_out/` 是可随时
|
||||
重生成的 staging,体积换"每个目录都能单独搬走"。总体积记在 `page.json` 的 `totalBytes` 里。
|
||||
|
||||
### `usable`:不是每个场景目录都能搬走
|
||||
|
||||
各页的 `scene_ui` 是一层**场景渲染目标**:它的平面用 `drawScene` modifier 把别的场景渲染成纹理
|
||||
(diffuse 名就是场景 id),本身没有任何资源文件。这种场景照样落盘("页面上有几个场景"这件事在
|
||||
产物里是完整的),但它的预设 `parts` 是空的:
|
||||
|
||||
```json
|
||||
{ "usable": false, "reason": "场景里只有运行时纹理(drawScene / 贴图缓冲),没有可搬走的资源" }
|
||||
```
|
||||
|
||||
`promote` 拒绝搬这种目录;`page.json` 的 `sceneDirs[]` 里逐条标了 `usable`。
|
||||
|
||||
## 二、场景选择:只决定"推荐哪个"
|
||||
|
||||
`fetch` **落盘全部有内容的场景**(至少有一具骨架或一块贴图平面)。纯色平面组成不了壁纸
|
||||
(`sceneConfig.parts` 会是空的),完全没有 part 的场景也没有内容——这两类不落盘,原因记在
|
||||
`page.json` 的 `skippedScenes` 里。
|
||||
|
||||
推荐场景由 `selection.yml` / `sources.yml` / 默认值算出(优先级写死,避免两份文件打架):
|
||||
|
||||
1. `wallpapers/sources.yml` 里这一页写了 `scene:` / `spines:` → 用它(**人的意志最高**)
|
||||
2. `tools/downloader/selection.yml` 里有这一页的记录 → 用它(上次交互的结果)
|
||||
3. 都没有 → **骨架最多的那个场景**(`--interactive` 时才问人)
|
||||
|
||||
默认规则与旧项目的判断一致(get-memory 会推荐 `P1`、back-moon 会推荐 `scene_main`)。
|
||||
推荐值写进 `page.json` 的 `chosenScene` / `chosenDir`,是 `promote` 不给 `--scene` 时的默认值。
|
||||
|
||||
> **这份选择不再裁剪产物。** 以前它决定"只落哪几个骨架",现在产物是"一个场景一个自包含的
|
||||
> 壁纸目录"——砍掉几具骨架会做出一个缺件的坏场景。要精简就在场景目录里的
|
||||
> `preset.template.json` 上改(那是纯数据,构建期才读)。
|
||||
> `sources.yml` 的 `spines:` 字段同理,只剩记录语义。
|
||||
|
||||
## 三、`scene.json` 里有什么
|
||||
|
||||
页面用自研引擎(three.js 系)描述场景:`sceneList` 是场景数组,每个场景是一棵树,节点有三类负载:
|
||||
|
||||
| kind | 含义 | 需要资源? |
|
||||
| --- | --- | --- |
|
||||
| `spine` | `spine:{id:"main_nike"}`,一具骨架 = 一个视觉元件 | 是(骨架 + atlas + 贴图页) |
|
||||
| `image` | `geometry` + `material.uniforms.diffuse`,贴图平面 | 是(`scene/<名>.<ext>`) |
|
||||
| `solid` | `defines.USE_TEXTURE == 0` 的**纯色平面**(diffuse 常写 `DEFAULT`) | 否 |
|
||||
|
||||
每个 part 带 `position` / `scale`(**世界变换**)与 `localPosition` / `localScale`,合成语义照抄引擎:
|
||||
|
||||
```
|
||||
world_position = parent_position + parent_scale ⊙ local_position
|
||||
world_scale = parent_scale ⊙ local_scale
|
||||
```
|
||||
|
||||
旋转暂不参与合成(旧项目同样如此),只在 `stats.rotatedNodes` 里报出数量——不假装算了。
|
||||
绘制层级 = 树序遍历序(`order`)+ 节点自身的 `renderOrder`。
|
||||
|
||||
带 `"runtime": true` 的 `image` part 是**没有独立文件**的平面:`drawScene` 的场景渲染目标、
|
||||
`cacheContainer` 的贴图缓冲,或 diffuse 指向同场景骨架缓存。它们不进预设,也不算缺资源。
|
||||
|
||||
**用户口中的 "scene / geometric" 就是这里的两类节点**:`sceneList` 的场景容器与 `geometry` 平面。
|
||||
Spine 官方没有这两个数据概念(官方 7 种附件类型里没有 geometric;`scene` 在 spine-webgl 里指的是
|
||||
`SceneRenderer` 这个渲染器)。所以"支持场景动画"= 运行期把多具骨架 + 若干贴图平面按这套摆放合成。
|
||||
|
||||
## 四、`preset.template.json` 怎么生成的
|
||||
|
||||
* `sceneConfig.parts`:`scene.json` 里每个 `spine` / `image` part 一条;纯色平面与运行时纹理不写
|
||||
(运行时画不了它们,写进去只是死配置)。路径一律写成 `./spines/<名>/…`、`./scene/<名>.<ext>`——
|
||||
**正斜杠**,因为预设会被整体搬走,`str(Path)` 的反斜杠在别的机器上是错的。
|
||||
* `backgroundImage`:构建期它是**必填且必须真实存在**的。自动挑法 = 场景里**面积最大**的贴图平面
|
||||
(它决定 `document.body` 的底图与取景用的宽高比,挑到一块小按钮会让整幅画的比例全错);
|
||||
一块贴图平面都没有时退到第一具骨架的 atlas 第一行声明的贴图页。
|
||||
* 写法与校验在同一个模块 `layout.py` 里(`build_preset` / `missing_preset_paths`):
|
||||
产物形状一旦改,校验规则必须同时改,分成两个文件迟早漂移。写完之后**当场**把每条路径在磁盘上
|
||||
核一遍,不留到构建期才炸。
|
||||
|
||||
## 五、页面侧的坑(都踩过,别再踩)
|
||||
|
||||
- **描述表的 `src` 表达式不能用一条 `.+?` 通吃**:kv45 的 bundle 里有个编译后的模板片段写着
|
||||
`{src:e.activeIcon,alt:""}})`,`\{src:(.+?),id:"…",type:"image"\}` 会从那里一路吃 **8.8 万字符**
|
||||
去够后面的 `,id:"…",type:"image"}`,把夹在中间的真表项(`{src:$w,id:"loading_dt1",…}`)整个吞掉。
|
||||
现在分成两种形状各匹配各的:`_DESCRIPTOR_INLINE`(`Object.values(Object.assign({…}))[0]`)与
|
||||
`_DESCRIPTOR_SIMPLE`(不含 `,{}` 的表达式)。
|
||||
- **资源引用有四种写法**,缺一种就会把真资源当"不存在":
|
||||
1. `X = a.p + "images/x.png"`(`_ASSET_LITERAL`)
|
||||
2. `{src:a(38458),id:"x"}`:模块直接导出字符串——小图被 webpack 内联成
|
||||
`e.exports="data:image/png;base64,…"`(`_MODULE_STRING`)。get-memory 的 `loading_moutain_a`、
|
||||
`a01_lizi`、start-ndkl 的 `loading_start_1` 都栽在这里(少了它们,平面会报"资源表里没有 URL")。
|
||||
3. `{src:$w,id:"x"}`:`$w="data:image/png;base64,…"`(`_STRING_ASSIGN`,kv45 的 `loading_dt1`)。
|
||||
4. `Object.values(Object.assign({"<源路径>":"data:…"}))[0]`(`_DATA_IN_ASSIGN`)。
|
||||
- **骨架数据一律内联在 bundle 里**,6 个页面里 `.atlas` / `.skel` 的网络请求数是 **0**。
|
||||
只有 hsr 的骨架 json 走网络(页面根目录 `<hash>.json`),而且它在 **webpack 异步 chunk**
|
||||
里(`258.ccb0954b.js`)——入口 HTML 根本没列它,得读 `.u=` 的 chunk 名映射再排队抓。
|
||||
这也是 hsr 的 `scene_ava` **首次离线抓不到**的原因:那两个 json 从没进过 `_cache/`,
|
||||
得先联网抓一次(见第七节的离线边界)。
|
||||
- **三种内联家族**都要认:A `Object.values(Object.assign({"…/spine/<N>.json":{…}}))[0]`(JS 对象字面量,
|
||||
键不带引号,用 `jslit` 规范化);B 匿名模块 + 配对表 `{atlas:fn(id),json:fn(id)}`,
|
||||
骨架是 `JSON.parse('…')`(**单引号** JS 字符串,要先按 JS 语义还原);C hsr 的 `spineSetting`。
|
||||
- **公共路径变量名逐页不同**:`n.p` / `a.p` / `t.p`。写死 `n.p` 会让半个页面的资源表全空。
|
||||
- **同一逻辑名可能有两个候选**(引擎的桌面/移动两套预载表)。判据:数组式描述表是基准集,
|
||||
字典式表是移动端覆盖(引擎里是 `desktop() || base.forEach(e => override[e.id] && …)`),桌面取基准集。
|
||||
- **atlas 有两种书写风格**:`size:498,330` 与 `size: 256, 256`,解析器两种都要吃。
|
||||
- **`.atlas` 的页名要读 atlas 自己声明的**(`atlas.py` 解析出来的第一行/页行),不要按 `<stem>_N` 猜:
|
||||
多页是 `_2.png`,也有完全不同的名字。落盘用逻辑名,atlas 第一行不用改。
|
||||
- **`skeleton.images` 要归一化**:作者目录(`../images/`)搬进分发后一定指错,写空串即可
|
||||
(页名相对 atlas 所在目录解析),原值记在 `meta.json` 的 `originalImages` 里。
|
||||
- **跨平台路径**:别拿 `str(Path)` 当映射键(Windows 反斜杠 vs 预设里的正斜杠),一律
|
||||
`Path.as_posix()`。
|
||||
|
||||
## 六、多版本 Spine
|
||||
|
||||
`meta.json` 逐具骨架记录 `skeleton.spine` 原文。事实基线(详见 `.scratch/spine-versions/REPORT.md`):
|
||||
|
||||
- 版本串是**编辑器版本**;`4.0-from-4.1-from-4.2-from-4.3.23` 这类 `-from-` 串表示
|
||||
**数据是 4.0 格式**(降级导出),运行库只认开头那个 `major.minor`。
|
||||
- 运行库**不校验**这个串;spine-ts 4.2 能读 4.0/4.1/4.2 的数据。**4.3 数据会被静默丢掉全部约束**
|
||||
(4.3 把约束并进 `root.constraints`),所以 `verify` 对 `≥4.3` 的骨架直接报红。
|
||||
- 目前 7 页共 170+ 具骨架全是 ≤4.2 格式,**一套 4.2 运行库就够**;真出现 4.3 数据再谈多套 UMD 共存
|
||||
(官方没有 `spine-version` 属性,只能各包一层别名函数避免 `window.spine` 互相覆盖)。
|
||||
- 重分发运行库要带 Spine Runtimes License Agreement(Exhibit A)+ 版权声明,不得删各文件头。
|
||||
|
||||
## 七、`verify` 检查什么
|
||||
|
||||
逐场景目录核一遍:`meta.json` / `preset.template.json` / `scene.json` 都在;`meta.json` 的
|
||||
`id` 等于目录名、字符集合法、`name`/`title`/`description` 非空、`audio.choices` 指向的文件存在;
|
||||
预设里每条 `./…` 路径在磁盘上找得到;侧车引用的骨架有目录与文件、atlas 声明的贴图页逐字落盘、
|
||||
几何平面的图存在;纯色与运行时纹理不计入缺失;骨架版本在运行库可读范围内。
|
||||
页面级还会抓"旧形状的残留"(页面根的 `scene.json` / `spine/` / `scene/`)与"不是场景目录的目录"。
|
||||
**退出码非 0 = 有硬伤**。
|
||||
|
||||
它不评判画面对不对——那是 `tools/checks/verify-scene-player.mts` 的事(多骨架 + 贴图平面真的合成出来)。
|
||||
|
||||
### 离线能跑到哪一步
|
||||
|
||||
`fetch --offline` 只读 `_cache/`(bundle 文本)与**已经落盘的 `_out/`**。所以:
|
||||
|
||||
* **重跑**永远是安全的:产物存在就跳过,不发任何请求。
|
||||
* **首次**抓一个"从没抓过的场景"需要联网——它的骨架 json 与贴图页都还没有本地副本。
|
||||
hsr 的 `scene_ava`(`zhigengniao_juheye` / `shajin`)就是这样:先 `fetch --page kv45`(联网)
|
||||
一次,之后再 `--offline` 就完全绿。缺什么会**逐条报出来**(不会甩一条 traceback 就走)。
|
||||
|
||||
## 八、离线预览(`_out/` 能看,`_cache/` 不能)
|
||||
|
||||
| 目录 | 内容 | 能否预览 |
|
||||
| --- | --- | --- |
|
||||
| `_cache/<页面>/` | 原始 bundle **文本**(引用仍是线上绝对地址、贴图不在里面) | ❌ 按设计就是文本调试缓存 |
|
||||
| `_out/<游戏>/<页面>/<场景>/` | 真实文件树(骨架 / atlas / 贴图页 / 场景图 / 侧车 / 预设) | ✅ **可离线预览** |
|
||||
|
||||
```bash
|
||||
node tools/preview.mts # 列出 _out 里可预览的场景
|
||||
node tools/preview.mts ys/nico-tea # 页面 = 取它的推荐场景
|
||||
node tools/preview.mts ys/nico-tea/scene_main # 点名场景
|
||||
node tools/preview.mts hsr/kv45/scene_ava --port 8199
|
||||
node tools/preview.mts ys/nico-tea --no-serve # 只组装到 tools/.cache/preview/
|
||||
```
|
||||
|
||||
`tools/preview.mts` 把「构建出的运行时 + staging 的资产 + 由 `scene.json` 生成的预设」组装成
|
||||
一个独立目录再起静态服务;**页面只读本地文件**。页面里的调试面是 `window.__sceneDebug`
|
||||
(`parts` / `loaded` / `errors` / `framing` / `rotatedSkipped`),排查"少加载了一件"直接看它。
|
||||
|
||||
两点说明:
|
||||
|
||||
- **要预览"原页面"而不是我们的产物**,得走整站镜像(`.scratch/page-mirror/spec.md`,尚未实现);
|
||||
`_cache/` 不能满足这个需求——它只有文本,没有资产树,也没有改写引用。
|
||||
- `tools/checks/verify-scene-player.mts` 里另有一份"从 scene.json 生成预设"的代码,
|
||||
**故意不与 preview 共用**:门必须独立于被验证对象,共用一份就变成自己验自己。
|
||||
|
||||
## 九、`promote`:搬进 `wallpapers/`
|
||||
|
||||
`fetch` 已经产出终态,所以 promote 退化成 **拷贝 + 校验 + 写 meta**:
|
||||
|
||||
```bash
|
||||
python -m tools.downloader promote --page kv45/scene_ava --game hsr --id scene_ava \
|
||||
--name '挥掷千星的筹码' --title '【崩坏:星穹铁道】挥掷千星的筹码' --description '[b]挥掷千星的筹码[/b]'
|
||||
```
|
||||
|
||||
* `--page` 认三种写法:页面 id(`kv45`)、`页面/场景`(`kv45/scene_ava`)、场景目录的路径。
|
||||
给页面 id 时用 `--scene` 点名,或让它取 `page.json` 的 `chosenScene`。
|
||||
* `--name` / `--title` / `--description` 可选:不给就沿用场景目录 `meta.json` 里的
|
||||
(那里已经是"页面名(场景id)"的形状,够用但通常要改成正式文案)。
|
||||
* `--cover` 给逻辑名时覆盖 `backgroundImage`。
|
||||
* **目标目录里的 `meta.json` 的 `id` 会被改写成 `--id`**——所以壁纸 id 可以跟场景目录名不同,
|
||||
但目录名与 `meta.json.id` 必须一致(构建期会校验)。
|
||||
* 搬之前会再核一遍预设里的每条路径在**目标目录**上存在;搬完不用手工改任何路径。
|
||||
|
||||
也可以完全不用 promote:**整个场景目录拷到 `wallpapers/<游戏id>/<壁纸id>/` 就完事**
|
||||
(前提是目录名 = `meta.json.id`,且那个目录 `usable`)。两种做法等价,promote 只是顺手改名与校验。
|
||||
|
||||
## 十、还没做的
|
||||
|
||||
- **音源**:页面 BGM 是另一条链,`audios/` 现在是空的,`meta.json` 的 `audio.choices` 也是空的。
|
||||
- **预览图**:没有抓,也没有生成。
|
||||
- **`scene_ui` 这类纯渲染目标场景**:落盘但不可搬走(运行时不会 `drawScene`)。
|
||||
@@ -0,0 +1,11 @@
|
||||
"""米哈游活动页的 Spine 抓取器(Python,只依赖 stdlib + PyYAML)。
|
||||
|
||||
它不是构建输入:`wallpapers/` 只放最终要发布的资源,抓取脚本与临时下载都留在这里。
|
||||
产物先落 `_out/<游戏>/<页面>/`,经 `--verify` 自检后再由人 promote 进 `wallpapers/`。
|
||||
|
||||
子命令与用法见同目录 README.md。
|
||||
"""
|
||||
|
||||
__all__ = ["__version__"]
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,582 @@
|
||||
"""命令行入口:`python -m tools.downloader <fetch|select|verify|promote>`。
|
||||
|
||||
设计原则(见 README.md):
|
||||
|
||||
* **只落 staging**(`_out/`),不碰 `wallpapers/`——promote 是后续单独一步。
|
||||
* **一页 = 全部场景**:每个有内容的场景各产出一个「可直接搬走的壁纸目录」
|
||||
(`_out/<游戏>/<页面>/<场景id>/`),不再只落被选中的那一个。形状与校验见 `layout.py`。
|
||||
* **可重跑**:文本走 `_cache/`,产物存在就跳过;`--offline` 下不发起任何请求。
|
||||
* **抓完就自检**:`fetch` 结尾自动跑 `verify`,有硬伤就非 0 退出。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import json
|
||||
import shutil
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from . import layout as layout_mod
|
||||
from . import promote as promote_mod
|
||||
from . import scene as scene_mod
|
||||
from . import selection as selection_mod
|
||||
from . import verify as verify_mod
|
||||
from .sites import mihoyo
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parents[1]
|
||||
SOURCES = ROOT / "wallpapers" / "sources.yml"
|
||||
CACHE = HERE / "_cache"
|
||||
OUT = HERE / "_out"
|
||||
SELECTION = HERE / "selection.yml"
|
||||
|
||||
# 旧形状(一页只落一个场景)留在页面根上的东西。新形状里它们是页面根的污染:
|
||||
# `scene.json` 归到每个场景目录里、`spine/` 改名 `spines/` 并下沉到场景目录。
|
||||
_LEGACY_ENTRIES = ("scene.json", "spine", "scene")
|
||||
|
||||
# "看着像贴图平面、其实没有独立文件"的 modifier:
|
||||
# cacheContainer —— 渲染进贴图缓冲
|
||||
# drawScene —— 把**另一个场景**渲染成纹理(diffuse 名就是场景 id,如各页的 scene_ui)
|
||||
_RUNTIME_MODIFIERS = ("cacheContainer", "drawScene")
|
||||
|
||||
|
||||
@dataclass
|
||||
class PageEntry:
|
||||
game: str
|
||||
id: str
|
||||
name: str
|
||||
url: str
|
||||
scene: str | None = None
|
||||
spines: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
class SourcesError(RuntimeError):
|
||||
"""`sources.yml` 形状不对。"""
|
||||
|
||||
|
||||
def load_sources(path: Path = SOURCES) -> list[PageEntry]:
|
||||
"""读 `sources.yml`:``<游戏>: [{id, name, url, scene?, spines?}, …]``。"""
|
||||
if not path.exists():
|
||||
raise SourcesError(f"找不到来源清单:{path}")
|
||||
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(raw, dict):
|
||||
raise SourcesError(f"{path} 顶层必须是「游戏 → 页面列表」的映射")
|
||||
entries: list[PageEntry] = []
|
||||
for game, pages in raw.items():
|
||||
if not isinstance(pages, list):
|
||||
raise SourcesError(f"{path} 里 {game} 必须是列表")
|
||||
for i, page in enumerate(pages):
|
||||
where = f"{path} 的 {game}[{i}]"
|
||||
if not isinstance(page, dict):
|
||||
raise SourcesError(f"{where} 不是映射(YAML 里少写了一个 `- `?)")
|
||||
missing = [k for k in ("id", "name", "url") if not page.get(k)]
|
||||
if missing:
|
||||
raise SourcesError(f"{where} 缺少字段:{', '.join(missing)}")
|
||||
spines = page.get("spines") or []
|
||||
if not isinstance(spines, list):
|
||||
raise SourcesError(f"{where} 的 spines 必须是列表")
|
||||
entries.append(
|
||||
PageEntry(
|
||||
game=str(game),
|
||||
id=str(page["id"]),
|
||||
name=str(page["name"]),
|
||||
url=str(page["url"]),
|
||||
scene=str(page["scene"]) if page.get("scene") else None,
|
||||
spines=[str(s) for s in spines],
|
||||
)
|
||||
)
|
||||
return entries
|
||||
|
||||
|
||||
def _download(url: str, dest: Path, *, force: bool) -> int:
|
||||
"""下载一个资源到 dest(已存在则跳过)。返回落盘字节数。"""
|
||||
if dest.exists() and not force:
|
||||
return 0
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
data = mihoyo.http_get(url, None)
|
||||
dest.write_bytes(data)
|
||||
return len(data)
|
||||
|
||||
|
||||
def _write_spine(scene_dir: Path, entry: PageEntry, data: mihoyo.PageData, name: str,
|
||||
*, force: bool, offline: bool, report: list[str]) -> int:
|
||||
"""落一具骨架到 `<场景目录>/spines/<名>/`:json + atlas + 贴图页 + meta.json。
|
||||
|
||||
页与 atlas **同居**(布局 A)——spine-ts 按 `<atlas 目录>/<页名>` 解析页,页跑到别处就读不到。
|
||||
"""
|
||||
asset = data.spines.get(name)
|
||||
spine_dir = scene_dir / layout_mod.SPINE_DIR / name
|
||||
if asset is None:
|
||||
report.append(f"场景引用了骨架 {name},但页面里没有它的数据(已跳过)")
|
||||
return 0
|
||||
|
||||
try:
|
||||
payload = mihoyo.load_spine_json(data, asset, cache_dir=CACHE, offline=offline)
|
||||
except mihoyo.HttpError as exc:
|
||||
# 离线缺缓存 / 网络失败:报出来,别让一条 traceback 把整页的抓取带崩。
|
||||
report.append(f"骨架 {name} 的数据取不到:{exc}")
|
||||
return 0
|
||||
|
||||
original_images = (payload.get("skeleton") or {}).get("images")
|
||||
normalized = dict(payload)
|
||||
skeleton = dict(normalized.get("skeleton") or {})
|
||||
# 页名按 atlas 所在目录解析:作者目录("../images/" 之类)搬进分发后一定指错。
|
||||
skeleton["images"] = ""
|
||||
normalized["skeleton"] = skeleton
|
||||
|
||||
spine_dir.mkdir(parents=True, exist_ok=True)
|
||||
written = 0
|
||||
json_file = spine_dir / f"{name}.json"
|
||||
if force or not json_file.exists():
|
||||
json_file.write_text(json.dumps(normalized, ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
|
||||
written += json_file.stat().st_size
|
||||
atlas_file = spine_dir / f"{name}.atlas"
|
||||
if force or not atlas_file.exists():
|
||||
atlas_file.write_text(asset.atlas_text, encoding="utf-8")
|
||||
written += atlas_file.stat().st_size
|
||||
|
||||
# 页名读 atlas 自己声明的页(`asset.pages` 就是 atlas 解析出来的),不按 `<stem>_N` 猜。
|
||||
for page_name in asset.pages:
|
||||
stem = page_name.rsplit(".", 1)[0]
|
||||
rel = data.images.get(stem)
|
||||
if rel is None:
|
||||
report.append(f"骨架 {name} 的贴图页 {page_name} 在资源表里找不到 URL")
|
||||
continue
|
||||
try:
|
||||
written += _download(data.asset_url(rel), spine_dir / page_name, force=force)
|
||||
except mihoyo.HttpError as exc:
|
||||
report.append(f"骨架 {name} 的贴图页 {page_name} 下载失败:{exc}")
|
||||
|
||||
meta = asset.as_meta(entry.url)
|
||||
meta["originalImages"] = original_images
|
||||
meta["fetchedAt"] = dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds")
|
||||
(spine_dir / "meta.json").write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
return written
|
||||
|
||||
|
||||
def _write_images(scene_dir: Path, data: mihoyo.PageData, parts: list[Any], *,
|
||||
runtime_names: set[str], force: bool, report: list[str],
|
||||
notes: list[str]) -> tuple[int, set[str]]:
|
||||
"""落几何平面用的场景图到 `<场景目录>/scene/`。
|
||||
|
||||
没有 URL 的平面**一律算运行时纹理**(不下载、不进预设,在 `scene.json` 里标 `runtime`):
|
||||
资源表就是页面向网络索取资源的完整清单,名字不在表里说明页面自己也不去网上取它。
|
||||
四种来源:`drawScene` 的场景渲染目标、`cacheContainer` 的贴图缓冲、diffuse 指向同场景骨架
|
||||
缓存,以及运行时生成的纹理。其中只有最后一种会记一条 note——前三种是页面的正常结构。
|
||||
"""
|
||||
written = 0
|
||||
runtime: set[str] = set()
|
||||
for part in parts:
|
||||
if part.kind != "image":
|
||||
continue
|
||||
image_id = part.id
|
||||
rel = data.images.get(image_id)
|
||||
if rel is None:
|
||||
runtime.add(image_id)
|
||||
if not any(m in part.modifiers for m in _RUNTIME_MODIFIERS) and image_id not in runtime_names:
|
||||
notes.append(f"[{scene_dir.name}] 平面 {image_id} 在资源表里没有 URL,按运行时纹理处理")
|
||||
continue
|
||||
ext = mihoyo.image_ext(rel)
|
||||
try:
|
||||
written += _download(
|
||||
data.asset_url(rel), scene_dir / layout_mod.SCENE_DIR / f"{image_id}{ext}", force=force
|
||||
)
|
||||
except mihoyo.HttpError as exc:
|
||||
report.append(f"几何平面 {image_id} 下载失败:{exc}")
|
||||
return written, runtime
|
||||
|
||||
|
||||
def _scene_dir_names(scenes: list[scene_mod.Scene]) -> dict[str, str]:
|
||||
"""场景 id → 目录名;同时消掉转义后可能出现的重名(大小写不敏感)。"""
|
||||
used: set[str] = set()
|
||||
out: dict[str, str] = {}
|
||||
for scene in scenes:
|
||||
base = layout_mod.scene_dir_name(scene.id)
|
||||
name, index = base, 2
|
||||
while name.lower() in used:
|
||||
name = f"{base}_{index}"
|
||||
index += 1
|
||||
used.add(name.lower())
|
||||
out[scene.id] = name
|
||||
return out
|
||||
|
||||
|
||||
def _scene_has_content(scene: scene_mod.Scene) -> bool:
|
||||
"""这个场景有没有**内容**(骨架或贴图平面)。
|
||||
|
||||
只有纯色平面的场景(back-moon 的 `动画预览`)与完全没有 part 的场景(`effect_DofBlur`)
|
||||
不算——它们连一张图都不需要,落出来的目录必然是空的。
|
||||
|
||||
注意"有内容"不等于"能搬走":各页的 `scene_ui` 全是 `drawScene` 渲染目标,一落地就是
|
||||
没有 part 的目录(`usable: false`),它进 `_out` 只是为了让"页面上有几个场景"这件事
|
||||
在产物里是完整的。
|
||||
"""
|
||||
return any(p.kind in ("spine", "image") for p in scene.parts)
|
||||
|
||||
|
||||
def _write_scene(page_dir: Path, entry: PageEntry, data: mihoyo.PageData, scene: scene_mod.Scene, *,
|
||||
dir_name: str, force: bool, offline: bool, report: list[str],
|
||||
notes: list[str], fetched_at: str) -> dict[str, Any]:
|
||||
"""把一个场景落成「可直接搬走的壁纸目录」。返回它的摘要(写进 page.json)。"""
|
||||
scene_dir = page_dir / dir_name
|
||||
(scene_dir / layout_mod.AUDIO_DIR).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
before = len(report)
|
||||
written = 0
|
||||
kept_spines = scene.spine_ids # 场景内的全部骨架,按出现序去重(同一骨架被引用多次只存一份)
|
||||
for name in kept_spines:
|
||||
written += _write_spine(scene_dir, entry, data, name, force=force, offline=offline, report=report)
|
||||
image_bytes, runtime_images = _write_images(
|
||||
scene_dir, data, scene.parts, runtime_names=set(kept_spines), force=force,
|
||||
report=report, notes=notes,
|
||||
)
|
||||
written += image_bytes
|
||||
|
||||
payload = scene.as_dict(page=entry.id, game=entry.game)
|
||||
payload["parts"] = []
|
||||
for part in scene.parts:
|
||||
item = part.as_dict()
|
||||
if part.kind == "image" and part.id in runtime_images:
|
||||
item["runtime"] = True # 由骨架 / 别的场景渲染出来,没有独立文件
|
||||
payload["parts"].append(item)
|
||||
written += layout_mod.write_json(scene_dir / "scene.json", payload)
|
||||
|
||||
preset, preset_notes = layout_mod.build_preset(payload, scene_dir)
|
||||
notes.extend(f"[{dir_name}] {note}" for note in preset_notes)
|
||||
for rel in layout_mod.missing_preset_paths(preset, scene_dir):
|
||||
report.append(f"[{dir_name}] preset.template.json 引用的 {rel} 不在磁盘上")
|
||||
written += layout_mod.write_json(scene_dir / "preset.template.json", preset)
|
||||
|
||||
# 该落盘却没落进预设 = 硬伤(上面的 report 已经写了原因),别让目录看起来是好的。
|
||||
planned = [p for p in scene.parts if p.kind == "spine" or (p.kind == "image" and p.id not in runtime_images)]
|
||||
usable = bool(preset["sceneConfig"]["parts"])
|
||||
reason = ""
|
||||
if not usable:
|
||||
reason = "场景里只有运行时纹理(drawScene / 贴图缓冲),没有可搬走的资源"
|
||||
if not usable and planned:
|
||||
report.append(f"[{dir_name}] 有 {len(planned)} 件该落盘的 part 却没进预设(见上面的缺失报告)")
|
||||
|
||||
meta = layout_mod.build_meta(
|
||||
wallpaper_id=dir_name,
|
||||
name=f"{entry.name}({scene.id})",
|
||||
title=f"{entry.name}({scene.id})",
|
||||
description=f"{entry.name} · 场景 {scene.id}\n来源:{entry.url}",
|
||||
game=entry.game,
|
||||
page=entry.id,
|
||||
scene=scene.id,
|
||||
source=entry.url,
|
||||
fetched_at=fetched_at,
|
||||
)
|
||||
meta["usable"] = usable
|
||||
if reason:
|
||||
meta["reason"] = reason
|
||||
written += layout_mod.write_json(scene_dir / "meta.json", meta)
|
||||
|
||||
return {
|
||||
"dir": dir_name,
|
||||
"scene": scene.id,
|
||||
"usable": usable,
|
||||
"reason": reason or None,
|
||||
"spines": len(kept_spines),
|
||||
"images": len({p.id for p in scene.parts if p.kind == "image"}),
|
||||
"runtimeImages": sorted(runtime_images),
|
||||
"solids": sum(1 for p in scene.parts if p.kind == "solid"),
|
||||
"parts": len(preset["sceneConfig"]["parts"]),
|
||||
"bytes": written,
|
||||
"problems": len(report) - before,
|
||||
}
|
||||
|
||||
|
||||
def cmd_fetch(args: argparse.Namespace) -> int:
|
||||
entries = load_sources()
|
||||
if args.page:
|
||||
wanted = set(args.page)
|
||||
entries = [e for e in entries if e.id in wanted]
|
||||
missing = wanted - {e.id for e in entries}
|
||||
if missing:
|
||||
print(f"来源清单里没有这些页面:{', '.join(sorted(missing))}", file=sys.stderr)
|
||||
return 2
|
||||
if not entries:
|
||||
print("没有要抓的页面。", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
selection = selection_mod.load_selection(SELECTION)
|
||||
problems: list[str] = []
|
||||
total_bytes = 0
|
||||
for entry in entries:
|
||||
print(f"\n=== {entry.game}/{entry.id} {entry.name}")
|
||||
|
||||
def fail(message: str, _id: str = entry.id) -> None:
|
||||
"""抓取期的硬伤当场打印——攒到最后再报会让人以为"这页没东西"。"""
|
||||
problems.append(f"{_id}:{message}")
|
||||
print(f" ✗ {message}")
|
||||
|
||||
try:
|
||||
data = mihoyo.fetch_page(entry.id, entry.game, entry.url, cache_dir=CACHE, offline=args.offline)
|
||||
except mihoyo.HttpError as exc:
|
||||
fail(f"抓取失败 {exc}")
|
||||
continue
|
||||
|
||||
scenes = data.scenes
|
||||
if not scenes:
|
||||
fail("页面里一个场景都没有")
|
||||
continue
|
||||
|
||||
# 默认场景仍然按"骨架最多"算,但它现在只用于「哪一档是推荐的」——
|
||||
# 全部有内容的场景都会落盘,选择不再裁剪产物(选择记录也只剩这个用途)。
|
||||
fallback = scene_mod.pick_default_scene(scenes)
|
||||
choice = selection_mod.resolve(
|
||||
entry.id,
|
||||
scenes,
|
||||
sources_entry={"scene": entry.scene, "spines": entry.spines},
|
||||
selection=selection,
|
||||
default_scene=fallback.id if fallback else None,
|
||||
default_spines=fallback.spine_ids if fallback else [],
|
||||
interactive=args.interactive,
|
||||
)
|
||||
chosen = choice.scene or (fallback.id if fallback else None)
|
||||
|
||||
page_dir = OUT / entry.game / entry.id
|
||||
page_dir.mkdir(parents=True, exist_ok=True)
|
||||
for legacy in _LEGACY_ENTRIES:
|
||||
stale = page_dir / legacy
|
||||
if not stale.exists():
|
||||
continue
|
||||
# 旧形状的残留:留着会被 verify 当成形状不对的场景目录。
|
||||
if stale.is_dir():
|
||||
shutil.rmtree(stale, ignore_errors=True)
|
||||
else:
|
||||
stale.unlink()
|
||||
print(f" · 清掉旧形状的 {legacy}")
|
||||
|
||||
names = _scene_dir_names(scenes)
|
||||
wanted_scenes = [s for s in scenes if _scene_has_content(s)]
|
||||
wanted_ids = {s.id for s in wanted_scenes}
|
||||
skipped = [
|
||||
{"scene": s.id, "reason": "只有纯色平面或完全没有 part,不需要任何资源"}
|
||||
for s in scenes
|
||||
if s.id not in wanted_ids
|
||||
]
|
||||
if not wanted_scenes:
|
||||
fail("所有场景都不需要资源(没有骨架、也没有贴图平面)")
|
||||
continue
|
||||
print(f" 场景 {len(wanted_scenes)}/{len(scenes)} 个有内容,逐个落盘:")
|
||||
|
||||
fetched_at = dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds")
|
||||
summaries: list[dict[str, Any]] = []
|
||||
notes: list[str] = []
|
||||
for scene in wanted_scenes:
|
||||
before = len(problems)
|
||||
summary = _write_scene(
|
||||
page_dir, entry, data, scene,
|
||||
dir_name=names[scene.id], force=args.force, offline=args.offline,
|
||||
report=problems, notes=notes, fetched_at=fetched_at,
|
||||
)
|
||||
summaries.append(summary)
|
||||
total_bytes += int(summary["bytes"])
|
||||
for message in problems[before:]:
|
||||
print(f" ✗ {message}")
|
||||
mark = "✓" if summary["usable"] else "○"
|
||||
print(
|
||||
f" {mark} {summary['dir']}/(原 id {summary['scene']}):骨架 {summary['spines']}、"
|
||||
f"贴图平面 {summary['images']}、纯色 {summary['solids']}、预设 part {summary['parts']},"
|
||||
f"{int(summary['bytes']) / 1024:.1f} KB"
|
||||
+ (f"(不可搬走:{summary['reason']})" if not summary["usable"] else "")
|
||||
)
|
||||
for note in notes:
|
||||
print(f" · {note}")
|
||||
|
||||
# 推荐的场景要真的落了盘——推荐到一个被跳过的场景会让 promote 的默认值指向空气。
|
||||
usable_dirs = {s["dir"] for s in summaries if s["usable"]}
|
||||
chosen_dir = names.get(chosen, "") if chosen else ""
|
||||
if chosen_dir not in usable_dirs:
|
||||
chosen = next((s["scene"] for s in summaries if s["usable"]), None)
|
||||
chosen_dir = names.get(chosen, "") if chosen else ""
|
||||
|
||||
page_payload = {
|
||||
"id": entry.id,
|
||||
"game": entry.game,
|
||||
"name": entry.name,
|
||||
"url": entry.url,
|
||||
"site": data.site.id,
|
||||
"entry": data.entry_url,
|
||||
"bundles": sorted(data.bundles),
|
||||
"scenes": [
|
||||
{
|
||||
"id": s.id,
|
||||
"dir": names.get(s.id) if s.id in wanted_ids else None,
|
||||
"spines": len(s.spine_ids),
|
||||
"images": len({p.id for p in s.parts if p.kind == "image"}),
|
||||
"solids": sum(1 for p in s.parts if p.kind == "solid"),
|
||||
}
|
||||
for s in scenes
|
||||
],
|
||||
"chosenScene": chosen,
|
||||
"chosenDir": chosen_dir or None,
|
||||
"sceneDirs": summaries,
|
||||
"skippedScenes": skipped,
|
||||
"totalBytes": sum(int(s["bytes"]) for s in summaries),
|
||||
"warnings": data.warnings + [w for w in problems if w.startswith(entry.id)] + notes,
|
||||
"fetchedAt": fetched_at,
|
||||
}
|
||||
layout_mod.write_json(page_dir / "page.json", page_payload)
|
||||
for warning in data.warnings:
|
||||
print(f" ! {warning}")
|
||||
usable_count = sum(1 for s in summaries if s["usable"])
|
||||
print(
|
||||
f" 页面合计 {page_payload['totalBytes'] / 1024 / 1024:.2f} MB → "
|
||||
f"{len(summaries)} 个场景目录(其中 {usable_count} 个可直接搬走)"
|
||||
)
|
||||
selection[entry.id] = choice.as_dict()
|
||||
|
||||
selection_mod.save_selection(SELECTION, selection)
|
||||
print(f"\n合计新增 {total_bytes / 1024 / 1024:.2f} MB;选择记录 → {SELECTION.relative_to(ROOT)}")
|
||||
|
||||
print("\n=== verify")
|
||||
found, stats = verify_mod.verify_all(OUT, pages=[e.id for e in entries])
|
||||
for problem in found:
|
||||
print(f" ✗ {problem}")
|
||||
print(
|
||||
f" 页面 {stats['pages']}、场景目录 {stats['sceneDirs']}、骨架 {stats['spines']}、"
|
||||
f"几何平面 {stats['images']}、体积 {stats['bytes'] / 1024 / 1024:.2f} MB"
|
||||
)
|
||||
if stats["versions"]:
|
||||
versions = "、".join(f"{v}×{n}" for v, n in sorted(stats["versions"].items()))
|
||||
print(f" 骨架版本:{versions}")
|
||||
if found:
|
||||
print(f"\n{len(found)} 处硬伤,未通过。", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_select(args: argparse.Namespace) -> int:
|
||||
"""只记录「推荐哪个场景」——产物不再按选择裁剪(fetch 落全部有内容的场景)。"""
|
||||
entries = load_sources()
|
||||
if args.page:
|
||||
entries = [e for e in entries if e.id in set(args.page)]
|
||||
selection = selection_mod.load_selection(SELECTION)
|
||||
for entry in entries:
|
||||
try:
|
||||
data = mihoyo.fetch_page(entry.id, entry.game, entry.url, cache_dir=CACHE, offline=args.offline)
|
||||
except mihoyo.HttpError as exc:
|
||||
print(f"{entry.id}:抓取失败 {exc}", file=sys.stderr)
|
||||
return 1
|
||||
fallback = scene_mod.pick_default_scene(data.scenes)
|
||||
choice = selection_mod.resolve(
|
||||
entry.id,
|
||||
data.scenes,
|
||||
sources_entry={"scene": entry.scene, "spines": entry.spines},
|
||||
selection=selection,
|
||||
default_scene=fallback.id if fallback else None,
|
||||
default_spines=fallback.spine_ids if fallback else [],
|
||||
interactive=True,
|
||||
)
|
||||
selection[entry.id] = choice.as_dict()
|
||||
print(f"[{entry.id}] 已记录推荐场景:{choice.scene}")
|
||||
selection_mod.save_selection(SELECTION, selection)
|
||||
print(f"\n选择记录 → {SELECTION.relative_to(ROOT)}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_promote(args: argparse.Namespace) -> int:
|
||||
"""把 staging 的一个**场景目录**转成发布形状(`wallpapers/<游戏>/<壁纸id>/`)。"""
|
||||
staged = Path(args.page)
|
||||
if not staged.is_absolute():
|
||||
candidate = OUT / args.page
|
||||
staged = candidate if candidate.exists() else Path(args.page)
|
||||
try:
|
||||
result = promote_mod.promote_page(
|
||||
staged,
|
||||
ROOT / "wallpapers",
|
||||
game=args.game,
|
||||
wallpaper_id=args.id,
|
||||
scene=args.scene,
|
||||
name=args.name,
|
||||
title=args.title,
|
||||
description=args.description,
|
||||
cover=args.cover,
|
||||
force=args.force,
|
||||
)
|
||||
except (FileNotFoundError, FileExistsError) as exc:
|
||||
print(str(exc), file=sys.stderr)
|
||||
return 2
|
||||
print(
|
||||
f"promote {result.scene_dir.name} → {result.target.relative_to(ROOT)}\n"
|
||||
f" 预设 part {result.parts}(骨架 {result.spines}、贴图平面 {result.images})"
|
||||
f",跳过纯色平面 {result.solids_skipped},落盘 {result.bytes / 1024 / 1024:.1f} MB"
|
||||
)
|
||||
print(" 下一步:pnpm build(或 pnpm dev)让构建把它烘焙进 preset.js")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_verify(args: argparse.Namespace) -> int:
|
||||
root = Path(args.root).resolve() if args.root else OUT
|
||||
found, stats = verify_mod.verify_all(root, pages=args.page)
|
||||
for problem in found:
|
||||
print(f"✗ {problem}")
|
||||
print(
|
||||
f"页面 {stats['pages']}、场景目录 {stats['sceneDirs']}、骨架 {stats['spines']}、"
|
||||
f"几何平面 {stats['images']}、体积 {stats['bytes'] / 1024 / 1024:.2f} MB"
|
||||
)
|
||||
if stats["versions"]:
|
||||
versions = "、".join(f"{v}×{n}" for v, n in sorted(stats["versions"].items()))
|
||||
print(f"骨架版本:{versions}")
|
||||
return 1 if found else 0
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(prog="python -m tools.downloader", description="米哈游活动页 Spine 抓取器")
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
def common(p: argparse.ArgumentParser) -> None:
|
||||
p.add_argument("--page", action="append", help="只处理指定页面 id(可重复)")
|
||||
|
||||
fetch = sub.add_parser("fetch", help="抓取并落 staging:每个有内容的场景一个壁纸目录(结尾自动 verify)")
|
||||
common(fetch)
|
||||
fetch.add_argument("--interactive", action="store_true", help="逐页交互式选「推荐哪个场景」")
|
||||
fetch.add_argument("--offline", action="store_true", help="只用 _cache/,不发起任何请求")
|
||||
fetch.add_argument("--force", action="store_true", help="重下已存在的产物")
|
||||
fetch.set_defaults(func=cmd_fetch)
|
||||
|
||||
pick = sub.add_parser("select", help="只做交互式选择(推荐场景),写入 selection.yml")
|
||||
common(pick)
|
||||
pick.add_argument("--offline", action="store_true", help="只用 _cache/,不发起任何请求")
|
||||
pick.set_defaults(func=cmd_select)
|
||||
|
||||
promote = sub.add_parser("promote", help="把一个场景目录转成 wallpapers/<游戏>/<壁纸id>/")
|
||||
promote.add_argument("--page", required=True,
|
||||
help="页面 id(如 kv45)、「页面/场景」(如 kv45/scene_ava)或场景目录的路径")
|
||||
promote.add_argument("--game", required=True, help="游戏 id(wallpapers/<游戏>/)")
|
||||
promote.add_argument("--id", required=True, help="壁纸 id(全局唯一,与 WE combo 的 value 一致)")
|
||||
promote.add_argument("--scene", help="页面目录下要 promote 的场景(默认取 page.json 的 chosenScene)")
|
||||
promote.add_argument("--name", help="显示名(默认沿用场景目录 meta.json 的)")
|
||||
promote.add_argument("--title", help="创意工坊标题(默认沿用场景目录 meta.json 的)")
|
||||
promote.add_argument("--description", help="WE 的 BBCode 文案(默认沿用场景目录 meta.json 的)")
|
||||
promote.add_argument("--cover", help="封面图逻辑名(scene/<名>.*),写进 backgroundImage")
|
||||
promote.add_argument("--force", action="store_true", help="目标目录已存在时覆盖")
|
||||
promote.set_defaults(func=cmd_promote)
|
||||
|
||||
check = sub.add_parser("verify", help="自检 _out/ 的引用闭包与文件齐全")
|
||||
common(check)
|
||||
check.add_argument("--root", help="检查别的产物目录(默认 tools/downloader/_out)")
|
||||
check.set_defaults(func=cmd_verify)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
try:
|
||||
return int(args.func(args))
|
||||
except SourcesError as exc:
|
||||
print(f"sources.yml 有问题:{exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Spine atlas 文本的解析与页名规范化。
|
||||
|
||||
.atlas 是文本格式:**第一行是贴图页文件名**,接着 `size:` / `filter:` / `format:` / `scale:`
|
||||
等页面头,之后才是各区域的 `bounds` / `offsets` / `rotate`。换页就是再来一行页文件名。
|
||||
|
||||
两种书写风格都要吃(同一批页面里都存在):
|
||||
size:498,330 ← 紧凑式(nico-tea / zhidong-wonder / kv45)
|
||||
size: 256, 256 ← 带空格式(get-memory)
|
||||
|
||||
抓取期我们只关心两件事:**这具骨架需要哪些贴图页**,以及**页名能不能原样落盘**
|
||||
(运行时按第一行去请求贴图页,所以文件名必须与第一行逐字一致)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
__all__ = ["AtlasInfo", "parse", "page_names", "retarget_pages"]
|
||||
|
||||
_PAGE_EXT = re.compile(r"\.(png|webp|jpg|jpeg)$", re.IGNORECASE)
|
||||
_PAGE_HEAD = re.compile(r"^(size|format|filter|repeat|pma|scale)\s*:", re.IGNORECASE)
|
||||
_REGION_ATTR = re.compile(
|
||||
r"^(bounds|offsets|rotate|xy|orig|index|split|pad|width|height)\s*:", re.IGNORECASE
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AtlasInfo:
|
||||
"""一具骨架的 atlas 摘要。"""
|
||||
|
||||
pages: list[str] = field(default_factory=list)
|
||||
regions: list[str] = field(default_factory=list)
|
||||
region_pages: list[str | None] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def multi_page(self) -> bool:
|
||||
return len(self.pages) > 1
|
||||
|
||||
|
||||
def parse(text: str) -> AtlasInfo:
|
||||
"""解析 atlas 文本。无法识别的行按区域名处理(atlas 里非属性行就是区域名)。"""
|
||||
info = AtlasInfo()
|
||||
current: str | None = None
|
||||
for raw in text.splitlines():
|
||||
line = raw.strip()
|
||||
if not line:
|
||||
current = None
|
||||
continue
|
||||
if ":" not in line and _PAGE_EXT.search(line):
|
||||
current = line
|
||||
info.pages.append(line)
|
||||
continue
|
||||
if _PAGE_HEAD.match(line) or _REGION_ATTR.match(line):
|
||||
continue
|
||||
info.regions.append(line)
|
||||
info.region_pages.append(current)
|
||||
return info
|
||||
|
||||
|
||||
def page_names(text: str) -> list[str]:
|
||||
"""只要贴图页名(顺序即 atlas 里声明的顺序)。"""
|
||||
return parse(text).pages
|
||||
|
||||
|
||||
def retarget_pages(text: str, mapping: dict[str, str]) -> str:
|
||||
"""把 atlas 里的页名按 mapping 改写(只改整行的页名行,不碰区域名)。"""
|
||||
out: list[str] = []
|
||||
for raw in text.splitlines():
|
||||
line = raw.strip()
|
||||
if line and ":" not in line and _PAGE_EXT.search(line) and line in mapping:
|
||||
out.append(mapping[line])
|
||||
else:
|
||||
out.append(raw)
|
||||
tail = "\n" if text.endswith("\n") else ""
|
||||
return "\n".join(out) + tail
|
||||
@@ -0,0 +1,217 @@
|
||||
"""JS 字面量 → Python 对象。
|
||||
|
||||
米哈游活动页把骨架数据与场景树以 **JS 对象字面量** 内联在 bundle 里,不是合法 JSON:
|
||||
|
||||
{skeleton:{hash:"KR8Ibf8DXTI",spine:"4.2.43",x:-110.93},bones:[{name:"root"}],scaleX:.6487}
|
||||
|
||||
键不带引号、小数可以省略前导 0、布尔写作 ``!0`` / ``!1``、末尾可以有逗号。
|
||||
这里只做**词法层**的规范化(把字面量改写成 JSON 文本),再交给 ``json.loads``——
|
||||
不 ``eval``、不执行页面代码。
|
||||
|
||||
唯一被容忍的"语义"是未知裸标识符(例如 ``undefined``):默认替换为 ``null`` 并计数,
|
||||
``strict=True`` 时抛错。骨架数据里出现别的裸标识符说明提取边界错了,值得知道。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
__all__ = ["loads", "match_literal", "first_object_value", "unescape", "JsLitError"]
|
||||
|
||||
# 按优先级排列的词法单元。字符串/数字/``!0`` 必须排在 ``other`` 之前。
|
||||
_TOKEN = re.compile(
|
||||
r"""
|
||||
(?P<ws>\s+)
|
||||
| (?P<dquote>"(?:[^"\\]|\\.)*")
|
||||
| (?P<squote>'(?:[^'\\]|\\.)*')
|
||||
| (?P<template>`(?:[^`\\]|\\.)*`)
|
||||
| (?P<negnot>!0|!1)
|
||||
| (?P<num>-?(?:\d+\.\d*|\.\d+|\d+)(?:[eE][+-]?\d+)?)
|
||||
| (?P<ident>[A-Za-z_$][\w$]*)
|
||||
| (?P<other>.)
|
||||
""",
|
||||
re.VERBOSE | re.DOTALL,
|
||||
)
|
||||
|
||||
_LITERAL_IDENTS = {"true": "true", "false": "false", "null": "null"}
|
||||
_NULL_IDENTS = {"undefined", "NaN", "Infinity", "void"}
|
||||
|
||||
_ESCAPES = {"n": "\n", "t": "\t", "r": "\r", "b": "\b", "f": "\f", "v": "\v", "0": "\0"}
|
||||
|
||||
|
||||
class JsLitError(ValueError):
|
||||
"""字面量无法规范化成 JSON。"""
|
||||
|
||||
|
||||
def unescape(body: str) -> str:
|
||||
"""还原 JS 字符串体里的转义(``\\n`` / ``\\uXXXX`` / ``\\'`` / ``\\\\`` …)。
|
||||
|
||||
Family B 的骨架是 ``e.exports=JSON.parse('…')``:外层是**单引号** JS 字符串,
|
||||
必须先按 JS 语义还原,再交给 ``json.loads``。
|
||||
"""
|
||||
return _unescape(body, "'")
|
||||
|
||||
|
||||
def _unescape(body: str, quote: str) -> str:
|
||||
"""还原 JS 字符串体(不含两端引号)里的转义。"""
|
||||
out: list[str] = []
|
||||
i = 0
|
||||
while i < len(body):
|
||||
ch = body[i]
|
||||
if ch != "\\":
|
||||
out.append(ch)
|
||||
i += 1
|
||||
continue
|
||||
nxt = body[i + 1] if i + 1 < len(body) else ""
|
||||
if nxt == "u" and i + 6 <= len(body):
|
||||
out.append(chr(int(body[i + 2 : i + 6], 16)))
|
||||
i += 6
|
||||
elif nxt == "x" and i + 4 <= len(body):
|
||||
out.append(chr(int(body[i + 2 : i + 4], 16)))
|
||||
i += 4
|
||||
elif nxt == "\n": # 行延续
|
||||
i += 2
|
||||
else:
|
||||
out.append(_ESCAPES.get(nxt, nxt))
|
||||
i += 2
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _num_key(value: str) -> str:
|
||||
"""把数字字面量还原成 JS 当键时用的字符串(``0`` → ``"0"``、``.5`` → ``"0.5"``)。"""
|
||||
try:
|
||||
as_float = float(value)
|
||||
except ValueError:
|
||||
return value
|
||||
if as_float.is_integer():
|
||||
return str(int(as_float))
|
||||
return repr(as_float)
|
||||
|
||||
|
||||
def _normalize(text: str, *, strict: bool) -> tuple[str, int]:
|
||||
out: list[str] = []
|
||||
unknown = 0
|
||||
pos = 0
|
||||
for m in _TOKEN.finditer(text):
|
||||
kind = m.lastgroup
|
||||
raw = m.group()
|
||||
if kind == "ws" or kind == "other":
|
||||
out.append(raw)
|
||||
pos = m.end()
|
||||
continue
|
||||
if kind in ("dquote", "squote", "template"):
|
||||
body = raw[1:-1]
|
||||
quote = raw[0]
|
||||
out.append(json.dumps(_unescape(body, quote), ensure_ascii=False))
|
||||
elif kind == "negnot":
|
||||
out.append("true" if raw == "!0" else "false")
|
||||
elif kind == "num":
|
||||
value = raw
|
||||
if value.startswith("-."):
|
||||
value = "-0" + value[1:]
|
||||
elif value.startswith("."):
|
||||
value = "0" + value
|
||||
if value.endswith("."):
|
||||
value += "0"
|
||||
# 数字也能当键(动画名就叫 "0" 的骨架真实存在:`animations:{0:{…}}`)。
|
||||
# JS 会把数字字面量转成字符串当键,所以这里要按同一语义还原。
|
||||
if re.match(r"\s*:", text[m.end() :]):
|
||||
out.append(json.dumps(_num_key(value), ensure_ascii=False))
|
||||
else:
|
||||
out.append(value)
|
||||
elif kind == "ident":
|
||||
# 后面(跳过空白)跟冒号的标识符是键,否则是值。
|
||||
tail = text[m.end() :]
|
||||
is_key = bool(re.match(r"\s*:", tail))
|
||||
if is_key:
|
||||
out.append(json.dumps(raw, ensure_ascii=False))
|
||||
elif raw in _LITERAL_IDENTS:
|
||||
out.append(_LITERAL_IDENTS[raw])
|
||||
elif raw in _NULL_IDENTS:
|
||||
out.append("null")
|
||||
unknown += 1
|
||||
if strict:
|
||||
raise JsLitError(f"未知标识符 {raw!r} @ {m.start()}")
|
||||
else:
|
||||
out.append("null")
|
||||
unknown += 1
|
||||
if strict:
|
||||
raise JsLitError(f"未知标识符 {raw!r} @ {m.start()}")
|
||||
pos = m.end()
|
||||
if pos < len(text):
|
||||
out.append(text[pos:])
|
||||
normalized = "".join(out)
|
||||
# 尾逗号:`,}` / `,]`
|
||||
normalized = re.sub(r",(\s*[}\]])", r"\1", normalized)
|
||||
return normalized, unknown
|
||||
|
||||
|
||||
def loads(text: str, *, strict: bool = False) -> Any:
|
||||
"""把 JS 字面量文本解析成 Python 对象。"""
|
||||
normalized, _ = _normalize(text, strict=strict)
|
||||
try:
|
||||
return json.loads(normalized)
|
||||
except json.JSONDecodeError as exc:
|
||||
head = text[max(0, exc.pos - 120) : exc.pos + 120].replace("\n", " ")
|
||||
raise JsLitError(f"字面量规范化后仍不是 JSON:{exc.msg} @ {exc.pos}\n…{head}…") from exc
|
||||
|
||||
|
||||
def match_literal(text: str, start: int) -> int:
|
||||
"""返回 ``text[start]`` 处那个配平字面量的**闭括号下标**;找不到返回 -1。
|
||||
|
||||
与 JS 侧同名的辅助函数一一对应:跳过字符串/模板串/注释,按开闭括号配平。
|
||||
它是所有提取器的地基——正则数不清嵌套括号,只有这个能。
|
||||
"""
|
||||
if start >= len(text):
|
||||
return -1
|
||||
open_ch = text[start]
|
||||
close_ch = {"{": "}", "[": "]", "(": ")"}.get(open_ch)
|
||||
if close_ch is None:
|
||||
return -1
|
||||
depth = 0
|
||||
i = start
|
||||
while i < len(text):
|
||||
ch = text[i]
|
||||
if ch in "\"'`":
|
||||
quote = ch
|
||||
i += 1
|
||||
while i < len(text):
|
||||
if text[i] == "\\":
|
||||
i += 2
|
||||
elif text[i] == quote:
|
||||
break
|
||||
else:
|
||||
i += 1
|
||||
i += 1
|
||||
continue
|
||||
if ch == "/" and i + 1 < len(text) and text[i + 1] == "/":
|
||||
while i < len(text) and text[i] != "\n":
|
||||
i += 1
|
||||
continue
|
||||
if ch == "/" and i + 1 < len(text) and text[i + 1] == "*":
|
||||
i += 2
|
||||
while i + 1 < len(text) and not (text[i] == "*" and text[i + 1] == "/"):
|
||||
i += 1
|
||||
i += 2
|
||||
continue
|
||||
if ch == open_ch:
|
||||
depth += 1
|
||||
elif ch == close_ch:
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return i
|
||||
i += 1
|
||||
return -1
|
||||
|
||||
|
||||
def first_object_value(text: str, brace_index: int) -> Any:
|
||||
"""``Object.values(Object.assign({k: v}))[0]`` 的取值语义:解析 ``{...}`` 并返回第一个值。"""
|
||||
end = match_literal(text, brace_index)
|
||||
if end < 0:
|
||||
raise JsLitError(f"未配平的对象字面量 @ {brace_index}")
|
||||
obj = loads(text[brace_index : end + 1])
|
||||
if not isinstance(obj, dict) or not obj:
|
||||
raise JsLitError(f"期望非空对象字面量 @ {brace_index}")
|
||||
return next(iter(obj.values()))
|
||||
@@ -0,0 +1,289 @@
|
||||
"""把一个场景组装成「可直接搬走的壁纸目录」。
|
||||
|
||||
抓取期产出的**终止形状**就是 `wallpapers/<游戏>/<壁纸id>/` 的同形拷贝
|
||||
(布局 A,见 `docs/adr/0008-asset-layout-per-skeleton.md`):
|
||||
|
||||
<页面>/
|
||||
├── page.json ← 页面级溯源
|
||||
└── <场景id>/ ← 一个场景 = 一个壁纸目录
|
||||
├── meta.json
|
||||
├── preset.template.json
|
||||
├── scene.json
|
||||
├── spines/<骨架名>/ ← <名>.json + <名>.atlas + 贴图页**同居**
|
||||
├── scene/<场景图>
|
||||
└── audios/
|
||||
|
||||
目录名固定 `spines` / `scene` / `audios`——`tools/lib/generate.ts` 的 `copyWallpaperAssets`
|
||||
按这三个名字搬文件,改名要两边一起改。
|
||||
|
||||
这个模块只做两件事:**写**(`build_preset` / `build_meta`)与**查**(`missing_preset_paths`)。
|
||||
写与查放在一起是刻意的:产物形状一旦改,校验规则必须同时改,分成两个文件迟早漂移。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
__all__ = [
|
||||
"AUDIO_DIR",
|
||||
"IMAGE_EXT",
|
||||
"SCENE_DIR",
|
||||
"SPINE_DIR",
|
||||
"build_meta",
|
||||
"build_preset",
|
||||
"image_file",
|
||||
"missing_preset_paths",
|
||||
"preset_paths",
|
||||
"scene_dir_name",
|
||||
"spine_page_file",
|
||||
"write_json",
|
||||
]
|
||||
|
||||
SPINE_DIR = "spines"
|
||||
SCENE_DIR = "scene"
|
||||
AUDIO_DIR = "audios"
|
||||
IMAGE_EXT = (".png", ".webp", ".jpg", ".jpeg", ".gif")
|
||||
|
||||
# 壁纸 id 的字符集(与 tools/lib/vault.ts 的校验逐字一致):它会进 WE 的 combo value。
|
||||
_UNSAFE = re.compile(r"[^A-Za-z0-9_-]+")
|
||||
|
||||
|
||||
def scene_dir_name(scene_id: str) -> str:
|
||||
"""场景 id → 目录名。
|
||||
|
||||
页面里的场景 id 多数本来就合法(`scene_main` / `P1` / `loading`),但也有中文的
|
||||
(back-moon 的 `动画预览`)。目录名一旦成为壁纸 id 就必须是 `^[a-z0-9][a-z0-9_-]*$`,
|
||||
所以这里统一转义:非法字符折成一个 `_`,首字符不是字母数字时补前缀。
|
||||
**真实 id 不会被丢掉**——它写在同目录的 `meta.json` / `scene.json` 里。
|
||||
"""
|
||||
name = _UNSAFE.sub("_", str(scene_id).strip()).strip("_-")
|
||||
if not name:
|
||||
return "scene"
|
||||
if not re.match(r"[A-Za-z0-9]", name):
|
||||
return f"scene_{name}"
|
||||
return name
|
||||
|
||||
|
||||
def image_file(scene_dir: Path, name: str) -> Path | None:
|
||||
"""场景图落盘名:`scene/<逻辑名>.<ext>`(扩展名由资源路径决定,见 mihoyo.image_ext)。"""
|
||||
for ext in IMAGE_EXT:
|
||||
candidate = scene_dir / SCENE_DIR / f"{name}{ext}"
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def spine_page_file(scene_dir: Path, spine_id: str) -> Path | None:
|
||||
"""一具骨架的第一张贴图页。
|
||||
|
||||
页名**读 atlas 自己声明的第一行**,不按 `<stem>_N` 猜——去 hash 之后页名未必是
|
||||
`<骨架名>.png`(多页是 `_2.png`,也有完全不同的名字)。这条路径只用于"整个场景一张
|
||||
贴图平面都没有"时的背景兜底(`wallpapers/README.md` 允许背景指向骨架贴图页)。
|
||||
"""
|
||||
atlas = scene_dir / SPINE_DIR / spine_id / f"{spine_id}.atlas"
|
||||
if not atlas.is_file():
|
||||
return None
|
||||
for line in atlas.read_text(encoding="utf-8").splitlines():
|
||||
page = line.strip().strip('"')
|
||||
if page.lower().endswith(IMAGE_EXT):
|
||||
candidate = atlas.parent / Path(page).name
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _rel(scene_dir: Path, path: Path) -> str:
|
||||
"""磁盘路径 → 预设里的 `./…` 相对路径(**一律正斜杠**,见"跨平台路径"那条坑)。"""
|
||||
return "./" + path.relative_to(scene_dir).as_posix()
|
||||
|
||||
|
||||
def _part_common(part: dict[str, Any]) -> dict[str, Any]:
|
||||
"""part 的公共字段(世界变换 + 绘制层级 + 页面指定的动画/皮肤/时间缩放)。"""
|
||||
common: dict[str, Any] = {
|
||||
"kind": part.get("kind"),
|
||||
"id": part["id"],
|
||||
"order": part.get("order", 0),
|
||||
"position": part.get("position", [0, 0, 0]),
|
||||
"scale": part.get("scale", [1, 1, 1]),
|
||||
}
|
||||
if part.get("renderOrder"):
|
||||
common["renderOrder"] = part["renderOrder"]
|
||||
if part.get("geometrySize"):
|
||||
common["width"], common["height"] = part["geometrySize"]
|
||||
if part.get("geometryCenter"):
|
||||
common["center"] = part["geometryCenter"]
|
||||
if part.get("rotation") and any(abs(float(v)) > 1e-9 for v in part["rotation"]):
|
||||
common["rotation"] = part["rotation"]
|
||||
# 页面指定的动画 / 皮肤:不抄就会去播骨架的第一个动画(常是入场动画 in,姿态不同)。
|
||||
if part.get("animation"):
|
||||
common["animation"] = part["animation"]
|
||||
if part.get("skin"):
|
||||
common["skin"] = part["skin"]
|
||||
if part.get("timeScale") is not None:
|
||||
common["timeScale"] = part["timeScale"]
|
||||
return common
|
||||
|
||||
|
||||
def build_preset(
|
||||
scene: dict[str, Any],
|
||||
scene_dir: Path,
|
||||
*,
|
||||
cover: str | None = None,
|
||||
) -> tuple[dict[str, Any], list[str]]:
|
||||
"""由 `scene.json` 的内容 + 磁盘上的场景目录,生成 `preset.template.json`。
|
||||
|
||||
返回(预设, 说明列表)。说明是"这个场景里没能进预设的东西"——纯色平面、缺文件的 part,
|
||||
它们不是错误(`verify` 会独立判红),但用户该知道少了几件。
|
||||
|
||||
`cover` 给的是**逻辑名**(`scene/<名>.<ext>`);不给就自动挑一张背景图,
|
||||
因为构建期 `backgroundImage` 是必填且必须真实存在(见 `tools/lib/vault.ts`)。
|
||||
"""
|
||||
notes: list[str] = []
|
||||
parts: list[dict[str, Any]] = []
|
||||
solids = 0
|
||||
|
||||
for part in scene.get("parts") or []:
|
||||
kind = part.get("kind")
|
||||
if kind == "solid":
|
||||
# 纯色平面运行时这一轮画不了(没有贴图),写进预设只是死配置。
|
||||
solids += 1
|
||||
continue
|
||||
if kind not in ("spine", "image"):
|
||||
notes.append(f"未知的 part 类型 {kind!r}(id={part.get('id')}),已跳过")
|
||||
continue
|
||||
common = _part_common(part)
|
||||
if kind == "spine":
|
||||
spine_id = str(part["id"])
|
||||
spine_dir = scene_dir / SPINE_DIR / spine_id
|
||||
if not (spine_dir / f"{spine_id}.json").is_file():
|
||||
notes.append(f"骨架 {spine_id} 没有落到 spines/{spine_id}/,未写进预设")
|
||||
continue
|
||||
common["jsonUrl"] = f"./{SPINE_DIR}/{spine_id}/{spine_id}.json"
|
||||
common["atlasUrl"] = f"./{SPINE_DIR}/{spine_id}/{spine_id}.atlas"
|
||||
else:
|
||||
if part.get("runtime"):
|
||||
# 运行时缓冲(cacheContainer / diffuse 指向同场景骨架缓存):由骨架渲染进贴图
|
||||
# 缓冲,没有独立文件,不该进预设也不该报缺资源(`scene.json` 里标了 runtime)。
|
||||
continue
|
||||
source = image_file(scene_dir, str(part["id"]))
|
||||
if source is None:
|
||||
notes.append(f"贴图平面 {part['id']} 没有独立文件,未写进预设")
|
||||
continue
|
||||
common["image"] = _rel(scene_dir, source)
|
||||
parts.append(common)
|
||||
|
||||
if solids:
|
||||
notes.append(f"{solids} 块纯色平面未写进预设(运行时没有贴图可画)")
|
||||
|
||||
background: str | None = None
|
||||
if cover:
|
||||
source = image_file(scene_dir, cover)
|
||||
if source is None:
|
||||
notes.append(f"指定的背景图 {cover} 不在 scene/ 里,改为自动挑选")
|
||||
else:
|
||||
background = _rel(scene_dir, source)
|
||||
if background is None:
|
||||
background = _pick_background(scene_dir, parts)
|
||||
|
||||
scene_config: dict[str, Any] = {"ui": scene.get("ui"), "parts": parts}
|
||||
camera_node = scene.get("camera") or {}
|
||||
camera = camera_node.get("camera") or {}
|
||||
if camera.get("type") is not None:
|
||||
# 相机必须带进预设:透视场景(type 1)忽略 fov 与 z 就会画成一块糊满屏的贴图。
|
||||
scene_config["camera"] = {
|
||||
"type": camera.get("type"),
|
||||
"fov": camera.get("fov"),
|
||||
"position": camera_node.get("position"),
|
||||
}
|
||||
|
||||
return {"backgroundImage": background or "", "sceneConfig": scene_config}, notes
|
||||
|
||||
|
||||
def _pick_background(scene_dir: Path, parts: list[dict[str, Any]]) -> str | None:
|
||||
"""自动挑背景图:贴图平面里**面积最大**的那块(画布比例的来源),没有就退到骨架贴图页。
|
||||
|
||||
为什么按面积:`backgroundImage` 在运行时决定 `document.body` 的底图与取景用的宽高比
|
||||
(`src/runtime/index.ts` 的 `measureImageAspect`)。挑到一块小按钮会让整幅画的比例全错,
|
||||
而背景/天空/远景恰好总是场景里最大的那块平面(实测:nico-tea 的 `main_sky_jpg` 2500×1064)。
|
||||
"""
|
||||
images = [p for p in parts if p.get("image")]
|
||||
if images:
|
||||
best = max(
|
||||
images,
|
||||
key=lambda p: (float(p.get("width") or 0) * float(p.get("height") or 0), -int(p.get("order") or 0)),
|
||||
)
|
||||
return str(best["image"])
|
||||
for part in parts:
|
||||
if not part.get("jsonUrl"):
|
||||
continue
|
||||
source = spine_page_file(scene_dir, str(part["id"]))
|
||||
if source is not None:
|
||||
return _rel(scene_dir, source)
|
||||
return None
|
||||
|
||||
|
||||
def preset_paths(preset: dict[str, Any]) -> list[str]:
|
||||
"""预设里所有必须真实存在的 `./…` 路径。"""
|
||||
out: list[str] = []
|
||||
background = preset.get("backgroundImage")
|
||||
if isinstance(background, str) and background:
|
||||
out.append(background)
|
||||
scene_config = preset.get("sceneConfig") or {}
|
||||
for part in scene_config.get("parts") or []:
|
||||
for key in ("image", "jsonUrl", "atlasUrl"):
|
||||
value = part.get(key)
|
||||
if isinstance(value, str) and value:
|
||||
out.append(value)
|
||||
return out
|
||||
|
||||
|
||||
def missing_preset_paths(preset: dict[str, Any], scene_dir: Path) -> list[str]:
|
||||
"""预设里指向磁盘上不存在的文件的路径(写出后立刻自检,不留到构建期才炸)。"""
|
||||
missing: list[str] = []
|
||||
for rel in preset_paths(preset):
|
||||
# 映射键一律用**正斜杠**:Windows 的 str(Path) 是反斜杠,写进预设就搬到别的机器上读不到。
|
||||
if not (scene_dir / rel.replace("\\", "/").removeprefix("./")).is_file():
|
||||
missing.append(rel)
|
||||
return missing
|
||||
|
||||
|
||||
def build_meta(
|
||||
*,
|
||||
wallpaper_id: str,
|
||||
name: str,
|
||||
title: str,
|
||||
description: str,
|
||||
game: str,
|
||||
page: str,
|
||||
scene: str,
|
||||
source: str,
|
||||
fetched_at: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""场景目录的 `meta.json`:既有字段语义(id/name/title/description/audio)原样保留,
|
||||
另加溯源字段(game/page/scene/source),方便搬进 `wallpapers/` 后回查来源。"""
|
||||
meta: dict[str, Any] = {
|
||||
"id": wallpaper_id,
|
||||
"name": name,
|
||||
"title": title,
|
||||
"description": description,
|
||||
"game": game,
|
||||
"page": page,
|
||||
"scene": scene,
|
||||
"source": source,
|
||||
# 音源清单为空:页面的 BGM 还没抓(构建会据此省掉 bgm 属性)。
|
||||
"audio": {"choices": []},
|
||||
}
|
||||
if fetched_at:
|
||||
meta["fetchedAt"] = fetched_at
|
||||
return meta
|
||||
|
||||
|
||||
def write_json(path: Path, data: Any, *, indent: int = 2) -> int:
|
||||
"""写一份 JSON(UTF-8、不转义中文),返回字节数。"""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
text = json.dumps(data, ensure_ascii=False, indent=indent) + "\n"
|
||||
path.write_text(text, encoding="utf-8")
|
||||
return len(text.encode("utf-8"))
|
||||
@@ -0,0 +1,230 @@
|
||||
"""把一个 staging 的**场景目录** promote 成 `wallpapers/<游戏>/<壁纸id>/`。
|
||||
|
||||
`fetch` 现在产出的就是终态形状(一个场景 = 一个可直接搬走的壁纸目录),所以这一步退化成
|
||||
**拷贝 + 校验 + 写 meta**:把 `spines/` / `scene/` / `audios/` 三个目录与 `preset.template.json`
|
||||
搬过去,把 `meta.json` 的 id / 文案换成发布值,再把预设里每条 `./…` 路径在目标目录上核一遍。
|
||||
|
||||
刻意不做的事:不抓音源(页面的 BGM 是另一条链)、不生成预览图、不动已存在的目录(除非 --force)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from . import layout as layout_mod
|
||||
|
||||
__all__ = ["promote_page", "resolve_scene_dir", "PromoteResult"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class PromoteResult:
|
||||
target: Path
|
||||
scene_dir: Path
|
||||
parts: int
|
||||
spines: int
|
||||
images: int
|
||||
solids_skipped: int
|
||||
bytes: int
|
||||
|
||||
|
||||
def _read_json(path: Path) -> dict[str, Any]:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _is_scene_dir(path: Path) -> bool:
|
||||
"""场景目录的判据:`preset.template.json` + `scene.json` 都在(fetch 的产物形状)。"""
|
||||
return (path / "preset.template.json").is_file() and (path / "scene.json").is_file()
|
||||
|
||||
|
||||
def scene_dirs(staged: Path) -> list[Path]:
|
||||
"""页面目录下的全部场景目录(按名字排序,保证可复现)。"""
|
||||
if not staged.is_dir():
|
||||
return []
|
||||
return sorted(p for p in staged.iterdir() if p.is_dir() and _is_scene_dir(p))
|
||||
|
||||
|
||||
def resolve_scene_dir(staged: Path, scene: str | None = None) -> Path:
|
||||
"""把「场景目录」或「页面目录 + 场景 id」统一解析成**一个**场景目录。
|
||||
|
||||
认三种输入(与 CLI 的 `--page` 对应):
|
||||
|
||||
1. 场景目录本身(`_out/<游戏>/<页面>/<场景id>`)
|
||||
2. `.scratch` 式的「页面/场景」路径——由调用方拼好后传进来
|
||||
3. 页面目录(`_out/<游戏>/<页面>`):用 `--scene` 指定;没指定就取 `page.json` 的
|
||||
`chosenScene`;再没有就要求页面下只有一个场景(多个时报错,不猜)。
|
||||
"""
|
||||
if _is_scene_dir(staged):
|
||||
return staged
|
||||
if not staged.is_dir():
|
||||
raise FileNotFoundError(f"staging 里没有这个东西:{staged}")
|
||||
|
||||
candidates = scene_dirs(staged)
|
||||
if not candidates:
|
||||
raise FileNotFoundError(f"{staged} 下没有场景目录(先跑:python -m tools.downloader fetch)")
|
||||
|
||||
def matches(name: str) -> Path | None:
|
||||
for candidate in candidates:
|
||||
if candidate.name == name:
|
||||
return candidate
|
||||
try:
|
||||
meta = _read_json(candidate / "meta.json")
|
||||
except (OSError, json.JSONDecodeError):
|
||||
continue
|
||||
if meta.get("scene") == name or meta.get("id") == name:
|
||||
return candidate
|
||||
return None
|
||||
|
||||
if scene:
|
||||
hit = matches(scene)
|
||||
if hit is None:
|
||||
names = "、".join(p.name for p in candidates)
|
||||
raise FileNotFoundError(f"{staged} 下没有场景 {scene}(有:{names})")
|
||||
return hit
|
||||
|
||||
chosen = None
|
||||
page_file = staged / "page.json"
|
||||
if page_file.is_file():
|
||||
try:
|
||||
chosen = _read_json(page_file).get("chosenDir") or _read_json(page_file).get("chosenScene")
|
||||
except (OSError, json.JSONDecodeError):
|
||||
chosen = None
|
||||
if chosen:
|
||||
hit = matches(str(chosen))
|
||||
if hit is not None:
|
||||
return hit
|
||||
if len(candidates) == 1:
|
||||
return candidates[0]
|
||||
names = "、".join(p.name for p in candidates)
|
||||
raise FileNotFoundError(
|
||||
f"{staged} 下有 {len(candidates)} 个场景目录,要用 --scene 指定一个(有:{names})"
|
||||
)
|
||||
|
||||
|
||||
def promote_page(
|
||||
staged: Path,
|
||||
wallpapers_root: Path,
|
||||
*,
|
||||
game: str,
|
||||
wallpaper_id: str,
|
||||
scene: str | None = None,
|
||||
name: str | None = None,
|
||||
title: str | None = None,
|
||||
description: str | None = None,
|
||||
cover: str | None = None,
|
||||
force: bool = False,
|
||||
) -> PromoteResult:
|
||||
"""执行一次 promote;返回统计。"""
|
||||
scene_dir = resolve_scene_dir(staged, scene)
|
||||
if not (scene_dir / "scene.json").is_file():
|
||||
raise FileNotFoundError(f"场景目录里没有 scene.json:{scene_dir}")
|
||||
|
||||
scene_payload = _read_json(scene_dir / "scene.json")
|
||||
src_meta: dict[str, Any] = {}
|
||||
meta_file = scene_dir / "meta.json"
|
||||
if meta_file.is_file():
|
||||
try:
|
||||
src_meta = _read_json(meta_file)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise FileNotFoundError(f"{meta_file} 不是合法 JSON:{exc}") from exc
|
||||
|
||||
target = wallpapers_root / game / wallpaper_id
|
||||
if target.exists() and not force:
|
||||
raise FileExistsError(f"目标已存在(要覆盖就加 --force):{target}")
|
||||
if target.exists():
|
||||
shutil.rmtree(target)
|
||||
# 布局 A:一具骨架一组(json + atlas + 贴图页同居),场景图单独一层。
|
||||
# 页必须与 atlas 同目录——spine-ts 按 `<atlas 目录>/<页名>` 解析(见 README)。
|
||||
written = 0
|
||||
for segment in (layout_mod.SPINE_DIR, layout_mod.SCENE_DIR, layout_mod.AUDIO_DIR):
|
||||
source = scene_dir / segment
|
||||
if source.is_dir():
|
||||
written += _copy_tree(source, target / segment)
|
||||
|
||||
# 预设优先沿用场景目录里已生成的那份(fetch 已产出终态);缺失就按 scene.json 现算。
|
||||
preset_file = scene_dir / "preset.template.json"
|
||||
if preset_file.is_file():
|
||||
preset = _read_json(preset_file)
|
||||
else:
|
||||
preset, _ = layout_mod.build_preset(scene_payload, scene_dir)
|
||||
|
||||
if cover:
|
||||
source = layout_mod.image_file(scene_dir, cover)
|
||||
if source is None:
|
||||
raise FileNotFoundError(f"封面图不在场景目录里:scene/{cover}.*")
|
||||
dest = target / layout_mod.SCENE_DIR / source.name
|
||||
if not dest.exists():
|
||||
written += _copy(source, dest)
|
||||
preset["backgroundImage"] = f"./{layout_mod.SCENE_DIR}/{source.name}"
|
||||
|
||||
if not ((preset.get("sceneConfig") or {}).get("parts") or []):
|
||||
if target.exists():
|
||||
shutil.rmtree(target, ignore_errors=True)
|
||||
raise FileNotFoundError(
|
||||
f"{scene_dir} 里一件可搬走的 part 都没有(平面全是运行时渲染目标),换一个场景目录"
|
||||
)
|
||||
|
||||
missing = layout_mod.missing_preset_paths(preset, target)
|
||||
if missing:
|
||||
# 别把半个目标目录留在 wallpapers/ 里——它看起来像一档发布壁纸,实际缺件。
|
||||
shutil.rmtree(target, ignore_errors=True)
|
||||
raise FileNotFoundError(
|
||||
"promote 后预设里有路径找不到对应文件(已拷贝的资产不自洽):\n - " + "\n - ".join(missing)
|
||||
)
|
||||
written += layout_mod.write_json(target / "preset.template.json", preset)
|
||||
|
||||
final_meta = dict(src_meta)
|
||||
# 这两个是 staging 专用字段("这个目录能不能直接搬走"),发布形状里没有它们的位置。
|
||||
final_meta.pop("usable", None)
|
||||
final_meta.pop("reason", None)
|
||||
final_meta["id"] = wallpaper_id
|
||||
final_meta.setdefault("name", wallpaper_id)
|
||||
final_meta.setdefault("title", str(final_meta["name"]))
|
||||
final_meta.setdefault("description", str(final_meta["name"]))
|
||||
if name:
|
||||
final_meta["name"] = name
|
||||
if title:
|
||||
final_meta["title"] = title
|
||||
if description:
|
||||
final_meta["description"] = description
|
||||
# 音源清单为空:这一页的 BGM 还没抓(构建会据此省掉 bgm 属性)。
|
||||
final_meta.setdefault("audio", {"choices": []})
|
||||
final_meta.setdefault("game", game)
|
||||
final_meta.setdefault("page", scene_dir.parent.name)
|
||||
written += layout_mod.write_json(target / "meta.json", final_meta)
|
||||
|
||||
game_dir = wallpapers_root / game
|
||||
if not (game_dir / "meta.json").exists():
|
||||
(game_dir / "meta.json").write_text(
|
||||
json.dumps({"id": game, "name": game, "audios": []}, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
parts = (preset.get("sceneConfig") or {}).get("parts") or []
|
||||
solids = sum(1 for p in scene_payload.get("parts") or [] if p.get("kind") == "solid")
|
||||
return PromoteResult(
|
||||
target=target,
|
||||
scene_dir=scene_dir,
|
||||
parts=len(parts),
|
||||
spines=sum(1 for p in parts if p.get("kind") == "spine"),
|
||||
images=sum(1 for p in parts if p.get("kind") == "image"),
|
||||
solids_skipped=solids,
|
||||
bytes=written,
|
||||
)
|
||||
|
||||
|
||||
def _copy(src: Path, dest: Path) -> int:
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(src, dest)
|
||||
return dest.stat().st_size
|
||||
|
||||
|
||||
def _copy_tree(src: Path, dest: Path) -> int:
|
||||
total = 0
|
||||
for item in sorted(src.rglob("*")):
|
||||
if item.is_file():
|
||||
total += _copy(item, dest / item.relative_to(src))
|
||||
return total
|
||||
@@ -0,0 +1 @@
|
||||
PyYAML>=6
|
||||
@@ -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)))
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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));
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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`);
|
||||
});
|
||||
@@ -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
Reference in new issue
Block a user