Files
Shuery 3f11426964 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 处引用自包含。
2026-10-02 01:27:02 +08:00

271 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 预览 `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`);
});