// pnpm build —— 把所有分发目录编译成"能直接上传 Wallpaper Engine"的自包含作品。 // // pnpm build 编全部(所有单档 + 所有游戏合集 + 全部合集) // pnpm build --single xilian [--single kv37] 编指定单档 // pnpm build --collect hsr 编某游戏的合集 // pnpm build --collect all 编全部合集 // pnpm build --single xilian --collect all 组合:只编这两档 // // --with-sim 顺带把可选的 WE 模拟器放进各分发的 scripts/(服务端调试用) // --sim 额外产出**双击就能看**的自包含包 <分发根>/sim/index.html(见 lib/bundle.ts) // --no-embed-audio --sim 时音频不内联(包小很多,但 file:// 下音频播不了) // --strict 把 warning 升级为 fail // --no-clean 保留本次未构建的旧分发目录(默认会删掉,保证产物不残留) // --dry-run 只打印计划,不落盘 // // 产物拓扑(dist/releases//)见 docs/adr/0005;自包含包见 docs/adr/0007。dist/ 完全可再生产,故不入库。 import { readFile, rm } from "node:fs/promises"; import { join } from "node:path"; import { abs, copyFileTo, dirBytes, exists, listDirs, log, mb, readJson, resetDir, writeJson, writeText, } from "./lib/fs.ts"; import { readVault, sharedAudioOf, VaultError, type Vault } from "./lib/vault.ts"; import { copyCollectionAudio, copyWallpaperAssets, generatePresetIndex, generatePresetModule, generateProjectJson, releasePathOf, type GenerateContext, } from "./lib/generate.ts"; import type { BuildOptions, DistMap, ProjectTemplate, Release } from "./lib/types.ts"; import { SIMULATOR_DRIVER, SIMULATOR_URL_STATIC } from "./lib/drivers.ts"; import { checkDist } from "./check-dist.ts"; const RELEASES_DIR = "dist/releases"; const HELP = `用法: pnpm build 编全部(所有单档 + 所有游戏合集 + 全部合集) pnpm build <壁纸id> […] 编指定壁纸(可多个) pnpm build single [壁纸id …] 只编单档(不给 id = 全部单档) pnpm build collect [游戏id …|all] 只编合集(不给参数 = 全部合集) pnpm build sim [single|collect|<壁纸id> …] 编全部 + 每档产出自包含包(双击即看,不需要 pnpm dev) --single <壁纸id> 等价于裸 id --collect <游戏id|all> 编某合集 --with-sim 让分发自带 WE 模拟器面板(静态托管预览用,见下) --sim 产出双击可看的自包含包 --no-embed-audio --sim 时音频不内联 --strict 警告即失败 --no-clean 保留旧分发目录 --dry-run 只打印计划 三种产物,别搞混: pnpm build 干净产物 → 上传 Wallpaper Engine(不含模拟器,ADR 0006 第 1 条) pnpm build --with-sim 自带面板 → 丢给任意静态托管(GitHub Pages 等),不需要调试服 pnpm build sim 自包含单文件 → 本地双击打开,不需要任何服务器`; interface BuildCliOptions { options: BuildOptions; withSimulator: boolean; standalone: boolean; embedAudio: boolean; } /** * 子命令解析。 * * 三种子命令对应三种**意图**,而不是三个 flag: * `single` —— 我在调一档壁纸,只要它; * `collect` —— 我在调一个合集的组装(预设切换、共享音频); * `sim` —— 我要能双击打开的成品,每档都要一份。 * * `sim` 后面还能再跟 `single`/`collect` 收窄范围(`pnpm build sim single kv37`)。 * 不带子命令时保持原来的 flag 风格(`--single` / `--collect`),向后兼容。 */ function parseArgs(argv: string[]): BuildCliOptions { const options: BuildOptions = { singles: [], collect: null, strict: false, clean: true, dryRun: false }; let withSimulator = false; let standalone = false; let embedAudio = true; // 先摘子命令。`sim` 后面可以再跟 `single`/`collect`/壁纸 id,所以用循环而不是单次判断。 let start = 0; while (start < argv.length && !(argv[start] ?? "").startsWith("-")) { const word = argv[start] ?? ""; if (word === "sim") { standalone = true; start += 1; } else if (word === "single") { // `pnpm build single kv37 xilian`:后面所有非 flag 的词都是壁纸 id start += 1; while (start < argv.length && !(argv[start] ?? "").startsWith("-")) { options.singles.push(argv[start] ?? ""); start += 1; } // 不给 id 就是"全部单档",由 planReleases 的显式标记决定 if (options.singles.length === 0) options.onlySingles = true; } else if (word === "collect") { start += 1; const games: string[] = []; while (start < argv.length && !(argv[start] ?? "").startsWith("-")) { games.push(argv[start] ?? ""); start += 1; } // 无参 = 全部合集(含每个游戏的合集与「全部壁纸合集」)。 // 这里**不能**用 `collect: "all"` 表示"全部合集"——那个值在 planReleases 里只加 // 「全部壁纸合集」一项,会把 collection-hsr 漏掉。用一个显式标记走"逐游戏 + all"的路径。 if (games.length === 0 || (games.length === 1 && games[0] === "all")) options.allCollections = true; else options.collect = games; } else { /** * 其余裸词一律当壁纸 id(`pnpm build kv37`、`pnpm build sim kv37`)。 * * 子命令与壁纸 id 共用同一段位置,靠**词表**区分:`single`/`collect`/`sim` 是保留字, * 别的裸词都是 id。代价是"想编一个恰好叫 single 的壁纸"做不到——接受, * 因为 id 由我们命名且都是英文小写,真撞名时改 id 比改语法便宜。 * id 不存在时由 vault 报错,那里能顺带列出所有可用 id,比"未知子命令"有用得多。 */ options.singles.push(word); start += 1; } } for (let i = start; i < argv.length; i += 1) { const arg = argv[i] ?? ""; if (arg === "--single") { const value = argv[++i]; if (!value || value.startsWith("--")) throw new VaultError("--single 需要跟一个壁纸 id"); options.singles.push(value); } else if (arg === "--collect") { const value = argv[++i]; if (!value || value.startsWith("--")) throw new VaultError('--collect 需要跟一个游戏 id 或 "all"'); if (value === "all") options.collect = "all"; else options.collect = [...(Array.isArray(options.collect) ? options.collect : []), value]; } else if (arg === "--with-sim") withSimulator = true; else if (arg === "--sim") standalone = true; else if (arg === "--no-embed-audio") embedAudio = false; else if (arg === "--strict") options.strict = true; else if (arg === "--no-clean") options.clean = false; else if (arg === "--dry-run") options.dryRun = true; else if (arg === "--help" || arg === "-h") { log(HELP); process.exit(0); } else if (arg.startsWith("--")) throw new VaultError(`未知参数:${arg}\n\n${HELP}`); else if (["sim", "single", "collect"].includes(arg)) { // 子命令写在 --flag 后面时会被当成壁纸 id。报错时直接点破顺序, // 否则那句"壁纸要写成 --single sim"会把人引到完全错误的方向。 throw new VaultError( `子命令 ${arg} 必须写在所有 --flag **之前**:\n` + ` pnpm build ${arg} --with-sim ✓\n` + ` pnpm build --with-sim ${arg} ✗(这里的 ${arg} 会被当成壁纸 id)\n\n${HELP}`, ); } else throw new VaultError(`未知参数:${arg}(壁纸要写成 --single ${arg})\n\n${HELP}`); } return { options, withSimulator, standalone, embedAudio }; } function normalizeGameId(value: string, vault: Vault): string { const wanted = value.trim().replace(/[:]/g, ":"); const exact = vault.games.find((g) => g.id === wanted); if (exact) return exact.id; const byName = vault.games.find((g) => g.meta.name.replace(/[:]/g, ":") === wanted); if (byName) return byName.id; throw new VaultError(`找不到游戏 "${value}"。已有:${vault.games.map((g) => `${g.meta.id}(${g.meta.name})`).join("、")}`); } /** 按子命令 / --single / --collect 求出本次要编的分发清单。无参 = 全编。 */ function planReleases(vault: Vault, options: BuildOptions): Release[] { const releases: Release[] = []; // `single`(无 id)只编单档;`collect`(无参)只编合集;无参才是全编。 // 三者都不能靠"singles 为空 + collect 为 null"来区分,所以各自有显式标记。 const onlySingles = options.onlySingles === true; const onlyCollections = options.allCollections === true; const all = !onlySingles && !onlyCollections && options.singles.length === 0 && options.collect === null; const addSingle = (wallpaperId: string): void => { const wallpaper = vault.wallpapers.find((w) => w.id === wallpaperId); if (!wallpaper) { throw new VaultError( `找不到壁纸 "${wallpaperId}"。已有:${vault.wallpapers.map((w) => `${w.id}(${w.meta.name})`).join("、")}`, ); } releases.push({ type: "single", id: wallpaper.id, // 带上游戏:`wallpaper-kv37` 这种名字在分不清来源的场合(多个游戏各有一档壁纸时) // 只能靠 id 全局唯一来兜底,而目录名本来就该自解释。与 collection-<游戏id> 同一套读法。 dir: `single-${wallpaper.gameId}-${wallpaper.id}`, wallpapers: [wallpaper], displayName: `${wallpaper.game?.name ?? wallpaper.gameId} - ${wallpaper.meta.name}`, meta: { title: wallpaper.meta.title, description: wallpaper.meta.description, ...(wallpaper.meta.preview ? { preview: wallpaper.meta.preview } : {}), ...(wallpaper.meta.workshopid ? { workshopid: wallpaper.meta.workshopid } : {}), ...(wallpaper.meta.workshopurl ? { workshopurl: wallpaper.meta.workshopurl } : {}), }, metaRel: wallpaper.srcRel, sharedAudio: [], }); }; const addGame = (gameId: string): void => { const game = vault.games.find((g) => g.id === gameId); if (!game) throw new VaultError(`内部错误:找不到游戏 ${gameId}`); releases.push({ type: "collection", scope: "game", id: game.id, dir: `collection-${game.id}`, wallpapers: [...game.wallpapers], displayName: `${game.meta.name}合集`, meta: { // 文案取自**游戏级** meta(见 wallpapers/README.md 的三层分工),不回落到全局 title: // 全局那层是给「全部合集」写的,回落会让 collection-ys 顶着《崩坏:星穹铁道》昔涟。 title: game.meta.title, description: game.meta.description ?? `[b]${game.meta.name} 全部壁纸[/b]\r\n[list]\r\n [*]共 ${game.wallpapers.length} 档:${game.wallpapers .map((w) => w.meta.name) .join("、")}。\r\n [*]bgm 与音量可在壁纸设置中调整。\r\n[/list]`, }, metaRel: game.srcRel, sharedAudio: sharedAudioOf(game), }); }; const addAll = (): void => { releases.push({ type: "collection", scope: "all", id: "all", dir: "collection-all", wallpapers: [...vault.wallpapers], displayName: vault.global.name, meta: { title: vault.global.title, description: vault.global.description, ...(vault.global.preview ? { preview: vault.global.preview } : {}), ...(vault.global.workshopid ? { workshopid: vault.global.workshopid } : {}), ...(vault.global.workshopurl ? { workshopurl: vault.global.workshopurl } : {}), }, metaRel: "wallpapers", sharedAudio: vault.games.flatMap((game) => sharedAudioOf(game)), }); }; if (all) { for (const wallpaper of vault.wallpapers) addSingle(wallpaper.id); for (const game of vault.games) addGame(game.id); addAll(); return releases; } if (onlySingles) { for (const wallpaper of vault.wallpapers) addSingle(wallpaper.id); return releases; } // `pnpm build collect`(无参)= 每个游戏的合集 + 「全部壁纸合集」。 // 注意 `--collect all` 是另一件事:它只编「全部壁纸合集」一项,保持原语义不变。 if (options.allCollections === true) { for (const game of vault.games) addGame(game.id); addAll(); return releases; } for (const id of options.singles) addSingle(id); if (options.collect === "all") addAll(); else if (Array.isArray(options.collect)) for (const raw of options.collect) addGame(normalizeGameId(raw, vault)); return releases; } /** * 把 `index.html` 模板渲染成产物。 * * 默认**原样输出**:发布产物的 index.html 里不能有任何模拟器痕迹(ADR 0006 第 1 条)—— * 旧实现会在真实 WE 里自我激活并覆盖官方 API,事故级。 * * `--with-sim` 时才注入模拟器驱动,且用**相对路径** `./scripts/wallpaper-engine.js`。 * 这样整个分发目录是自足的:丢到任何静态托管(GitHub Pages 的 `//` 子路径、 * 任意 CDN、甚至 file://)都能弹出设置面板,不需要调试服在场。 * * 注入点必须在 `