// 预览 `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 = { ".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 { 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 { 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"), [ ``, `preview ${rel}`, ``, ``, ``, `
`, ``, ``, ``, ].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`); });