把项目从「手写 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 处引用自包含。
535 lines
25 KiB
TypeScript
535 lines
25 KiB
TypeScript
// 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/<dir>/)见 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 的 `/<repo>/` 子路径、
|
||
* 任意 CDN、甚至 file://)都能弹出设置面板,不需要调试服在场。
|
||
*
|
||
* 注入点必须在 `<script type="module" src="./scripts/index.js">` **之前**:
|
||
* 驱动是经典脚本,要赶在壁纸模块注册 `wallpaperPropertyListener` 之前把钩子备好
|
||
* (见 tools/lib/drivers.ts 的时序说明)。
|
||
*/
|
||
function renderIndexHtml(template: string, release: Release, ctx: GenerateContext): string {
|
||
if (!ctx.withSimulator) return template;
|
||
const driver = SIMULATOR_DRIVER({
|
||
search: "",
|
||
dir: release.dir,
|
||
title: release.displayName,
|
||
version: ctx.version,
|
||
defaultPresetId: ctx.defaultPresetId,
|
||
preview: release.meta.preview,
|
||
simulatorUrl: SIMULATOR_URL_STATIC,
|
||
});
|
||
const marker = /[ \t]*<script\b[^>]*src=["'][^"']*scripts\/index\.js["'][^>]*><\/script>/i;
|
||
if (!marker.test(template)) {
|
||
throw new VaultError("src/templates/index.html 里找不到 scripts/index.js 的 <script> 标签,模拟器无法注入");
|
||
}
|
||
return template.replace(marker, (tag) => `${driver}\n ${tag.trim()}`);
|
||
}
|
||
|
||
async function buildRelease(
|
||
release: Release,
|
||
ctx: GenerateContext,
|
||
options: BuildOptions,
|
||
warnings: string[],
|
||
): Promise<{ bytes: number; simBytes: number }> {
|
||
const outDir = join(ctx.outDir, release.dir);
|
||
if (options.dryRun) {
|
||
log(` [dry-run] ${release.dir.padEnd(24)} ← ${release.wallpapers.map((w) => w.id).join(", ")}`);
|
||
return { bytes: 0, simBytes: 0 };
|
||
}
|
||
|
||
await resetDir(outDir);
|
||
|
||
// 1) 模板与静态资源
|
||
await writeText(join(outDir, "index.html"), renderIndexHtml(await readFile(abs("src/templates/index.html"), "utf8"), release, ctx));
|
||
await writeJson(join(outDir, "project.json"), generateProjectJson(release, ctx));
|
||
for (const name of ["index.css", "spine-player.css"]) {
|
||
await copyFileTo(abs(`src/styles/${name}`), join(outDir, "styles", name));
|
||
}
|
||
await copyFileTo(abs("src/vendor/spine-player.js"), join(outDir, "scripts", "spine-player.js"));
|
||
|
||
// 分发级预览图:`preview` 字段指向的是**分发根**下的文件名,所以图源必须在分发自己的元数据目录里
|
||
// 找到同名文件。当前只有「所有壁纸合集」有它(wallpapers/preview.gif);其余分发没有,字段就省略。
|
||
if (release.meta.preview) {
|
||
const name = release.meta.preview.replace(/\\/g, "/").replace(/^.*\//, "");
|
||
const source = join(abs(release.metaRel), name);
|
||
if (await exists(source)) await copyFileTo(source, join(outDir, name));
|
||
else warnings.push(`${release.dir}: project.json 声明了 preview=${release.meta.preview},但 ${release.metaRel}/${name} 不存在`);
|
||
}
|
||
|
||
// 2) 运行时脚本(tsc 已把 src/runtime/*.ts 编到 build/scripts/)
|
||
for (const name of [
|
||
"index.js",
|
||
"preset-controller.js",
|
||
"background-controller.js",
|
||
"spine-controller.js",
|
||
"scene-controller.js",
|
||
"audio-controller.js",
|
||
"viewport-fitter.js",
|
||
]) {
|
||
const from = abs(`build/scripts/${name}`);
|
||
if (!(await exists(from))) {
|
||
throw new VaultError(`缺少 ${from}。请先跑 \`pnpm typecheck\` 之外的编译:tsc -p tsconfig.runtime.json(pnpm build 的 npm script 已包含)`);
|
||
}
|
||
await copyFileTo(from, join(outDir, "scripts", name));
|
||
}
|
||
if (ctx.withSimulator) {
|
||
const simulator = abs("build/scripts/wallpaper-engine.js");
|
||
if (await exists(simulator)) await copyFileTo(simulator, join(outDir, "scripts", "wallpaper-engine.js"));
|
||
else warnings.push("--with-sim 已指定,但 build/scripts/wallpaper-engine.js 不存在(先跑 pnpm build:sim)");
|
||
}
|
||
|
||
// 3) 预设索引(每分发现算,见 generatePresetIndex 注释)
|
||
await writeText(join(outDir, "scripts", "presets.js"), generatePresetIndex(release, ctx.defaultPresetId));
|
||
|
||
// 4) 音频落点必须在生成 preset.js **之前**算出来:路径是按实际落点现算的,
|
||
// 先写 preset.js 再决定音频放哪儿,会出现"语法正确、指向空气"的产物。
|
||
// 落点算法本身只有一份(generate.ts 的 planAudio),这里只负责把文件搬过去。
|
||
const isCollection = release.type !== "single";
|
||
if (isCollection) {
|
||
const placed = await copyCollectionAudio(release, outDir);
|
||
if (placed.names.length === 0) warnings.push(`${release.dir}: 合集根 audios/ 为空(各壁纸与游戏都没有声明共享音频)`);
|
||
}
|
||
|
||
// 5) 每档壁纸的 preset.js + 资源
|
||
for (const wallpaper of release.wallpapers) {
|
||
const sub = releasePathOf(release, wallpaper);
|
||
const destDir = sub ? join(outDir, sub) : outDir;
|
||
await writeText(join(destDir, "preset.js"), generatePresetModule(release, wallpaper, release.sharedAudio));
|
||
await copyWallpaperAssets(wallpaper, destDir, { skipAudio: isCollection });
|
||
}
|
||
|
||
// 6) 可选的**自包含包**:与发布产物并列放在 sim/ 下,互不干扰。
|
||
// 它必须在资源全部落地之后才能生成——内联的是刚铺好的那些文件。
|
||
let simBytes = 0;
|
||
if (options.standalone) {
|
||
const { buildSimPage } = await import("./lib/bundle.ts");
|
||
const sim = await buildSimPage(release, ctx.defaultPresetId, { embedAudio: options.embedAudio ?? true });
|
||
const simDir = join(outDir, "sim");
|
||
await writeText(join(simDir, "index.html"), sim.html);
|
||
simBytes = await dirBytes(simDir);
|
||
log(
|
||
` └ sim/index.html ${mb(simBytes)} 内联 ${sim.inlined} 项资源` +
|
||
(sim.skippedAudio.length > 0 ? `(${sim.skippedAudio.length} 个音频未内联,file:// 下不可播)` : ""),
|
||
);
|
||
if (sim.skippedAudio.length > 0) {
|
||
warnings.push(`${release.dir}: --no-embed-audio,${sim.skippedAudio.length} 个音频未内联,双击打开时选不到它们`);
|
||
}
|
||
}
|
||
|
||
return { bytes: await dirBytesExcludingSim(outDir), simBytes };
|
||
}
|
||
|
||
/**
|
||
* 分发体积:**不含** `sim/`。
|
||
*
|
||
* 自包含包里的图片/音频是 base64 内联,体积与发布产物不在一个量级;把它算进"上传体积"
|
||
* 会让人误判发布产物的实际大小。两笔分开报。
|
||
*/
|
||
async function dirBytesExcludingSim(root: string): Promise<number> {
|
||
const { readdir, stat } = await import("node:fs/promises");
|
||
let total = 0;
|
||
for (const entry of await readdir(root, { withFileTypes: true })) {
|
||
if (entry.name === "sim") continue;
|
||
const full = join(root, entry.name);
|
||
if (entry.isDirectory()) total += await dirBytesExcludingSim(full);
|
||
else total += (await stat(full)).size;
|
||
}
|
||
return total;
|
||
}
|
||
|
||
async function main(): Promise<void> {
|
||
const started = Date.now();
|
||
const cli = parseArgs(process.argv.slice(2));
|
||
const { options, withSimulator, standalone } = cli;
|
||
options.standalone = standalone;
|
||
options.embedAudio = cli.embedAudio;
|
||
|
||
const version = (await readFile(abs("VERSION"), "utf8")).trim();
|
||
if (!/^\d+$/.test(version)) throw new VaultError(`VERSION 必须是单个整数,当前是 "${version}"`);
|
||
|
||
const vault = await readVault();
|
||
const template = await readJson<ProjectTemplate>(abs("src/project.template.json"));
|
||
const releases = planReleases(vault, options);
|
||
|
||
log(`SpineWallpaper build version=${version} releases=${releases.length}${withSimulator ? " (with simulator)" : ""}${standalone ? " (standalone sim)" : ""}`);
|
||
log(` 壁纸:${vault.wallpapers.map((w) => `${w.id}(${w.meta.name})`).join("、")}`);
|
||
log(` 游戏:${vault.games.map((g) => `${g.id}(${g.meta.name})`).join("、")}`);
|
||
log("");
|
||
|
||
const warnings: string[] = [];
|
||
const wantedDefault = vault.global.defaultPresetId;
|
||
if (wantedDefault !== undefined && !vault.wallpapers.some((w) => w.id === wantedDefault)) {
|
||
throw new VaultError(
|
||
`wallpapers/meta.json 的 defaultPresetId "${wantedDefault}" 不是任何壁纸的 id(已有:${vault.wallpapers
|
||
.map((w) => w.id)
|
||
.join("、")})`,
|
||
);
|
||
}
|
||
|
||
/** 某档分发的默认预设:单档恒等于它自己;合集用全局声明,若该合集里没有就退回第一档。 */
|
||
const defaultPresetIdFor = (release: Release): string => {
|
||
if (release.type === "single") return release.wallpapers[0]?.id ?? "";
|
||
if (wantedDefault && release.wallpapers.some((w) => w.id === wantedDefault)) return wantedDefault;
|
||
return release.wallpapers[0]?.id ?? "";
|
||
};
|
||
|
||
const ctx = { template, version, outDir: abs(RELEASES_DIR), withSimulator } satisfies Omit<
|
||
GenerateContext,
|
||
"defaultPresetId"
|
||
>;
|
||
const mapEntries: DistMap["releases"] = [];
|
||
|
||
for (const release of releases) {
|
||
const defaultPresetId = defaultPresetIdFor(release);
|
||
// 只有一档的合集拿不到 preset 下拉(generateProjectJson 按档数决定),产物行为与单档分发相同。
|
||
// 发不发这一档是发布级的判断,构建不替人决定,但必须让人看见——"合集"名不副实是真实的踩坑点。
|
||
if (release.type === "collection" && release.wallpapers.length < 2) {
|
||
warnings.push(
|
||
`${release.dir}: 合集只收 ${release.wallpapers.length} 档壁纸,产物里不会有 preset 下拉(行为等同单档);要么补壁纸,要么不发这一档`,
|
||
);
|
||
}
|
||
const sizes = await buildRelease(release, { ...ctx, defaultPresetId }, options, warnings);
|
||
mapEntries.push({
|
||
dir: release.dir,
|
||
type: release.type,
|
||
// 合集范围(单档没有):只有它才区分「游戏合集」与「全部合集」。
|
||
...(release.scope ? { scope: release.scope } : {}),
|
||
id: release.id,
|
||
displayName: release.displayName,
|
||
defaultPresetId,
|
||
wallpapers: release.wallpapers.map((wallpaper) => ({
|
||
id: wallpaper.id,
|
||
gameId: wallpaper.gameId,
|
||
dir: releasePathOf(release, wallpaper),
|
||
name: wallpaper.meta.name,
|
||
})),
|
||
bytes: sizes.bytes,
|
||
...(sizes.simBytes > 0 ? { simBytes: sizes.simBytes } : {}),
|
||
});
|
||
log(` ✓ ${release.dir.padEnd(24)} ${release.wallpapers.length} 档壁纸 ${mb(sizes.bytes)}`);
|
||
}
|
||
|
||
if (!options.dryRun) {
|
||
if (options.clean) {
|
||
const built = new Set(mapEntries.map((e) => e.dir));
|
||
for (const dir of await listDirs(abs(RELEASES_DIR))) {
|
||
if (!built.has(dir)) {
|
||
await rm(join(abs(RELEASES_DIR), dir), { recursive: true, force: true });
|
||
log(` − 删除孤立的旧分发目录 ${dir}`);
|
||
}
|
||
}
|
||
} else {
|
||
for (const dir of await listDirs(abs(RELEASES_DIR))) {
|
||
if (!mapEntries.some((e) => e.dir === dir)) {
|
||
warnings.push(`dist/releases/${dir} 不是本次构建的产物(--no-clean),dist-map.json 里没有它`);
|
||
}
|
||
}
|
||
}
|
||
|
||
const map: DistMap = { version, builtAt: new Date().toISOString(), releases: mapEntries };
|
||
await writeJson(abs("dist/dist-map.json"), map);
|
||
await checkDist({ strict: options.strict });
|
||
}
|
||
|
||
log("");
|
||
for (const warning of warnings) log(` ⚠ ${warning}`);
|
||
if (options.strict && warnings.length > 0) throw new VaultError(`--strict:出现 ${warnings.length} 条警告,视为失败`);
|
||
log(`完成,用时 ${((Date.now() - started) / 1000).toFixed(1)}s`);
|
||
}
|
||
|
||
try {
|
||
await main();
|
||
} catch (error) {
|
||
if (error instanceof VaultError) {
|
||
console.error(`\n构建失败:${error.message}\n`);
|
||
process.exit(1);
|
||
}
|
||
throw error;
|
||
}
|
||
|