// 场景播放器的门:多骨架 + 贴图平面真的合成出来了,而且**先证明它会红**。 // // 不碰 `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 { 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 { 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 = { ".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"), [ ``, `scene fixture`, ``, ``, ``, `
`, ``, ``, ``, ].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((r) => server.listen(PORT, "127.0.0.1", () => r())); const BASE = `http://127.0.0.1:${PORT}/`; const readDebug = async (evaluate: (expr: string) => Promise): Promise => { 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, predicate: (d: SceneDebug | null) => boolean, tries = 40): Promise => { 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);