把项目从「手写 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 处引用自包含。
411 lines
21 KiB
TypeScript
411 lines
21 KiB
TypeScript
// 场景播放器的门:多骨架 + 贴图平面真的合成出来了,而且**先证明它会红**。
|
||
//
|
||
// 不碰 `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);
|