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
@@ -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);
|
||||
Reference in new issue
Block a user