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 处引用自包含。
This commit is contained in:
1 parent
b8eee05d78
commit
3f11426964
297 files changed
+216627
-1926
No files matched your search
@@ -0,0 +1,531 @@
|
||||
// 一个新分发目录 = 一个能直接上传 Wallpaper Engine 的作品。
|
||||
//
|
||||
// 这一层只负责"合成产物":从 wallpapers/ 的内容算出 preset.js(运行时)与 project.json(发布),
|
||||
// 并把资源铺进目录结构。校验与去重不在本文件(见 vault.ts 与 build.ts)。
|
||||
|
||||
import { join } from "node:path";
|
||||
import type { AudioChoice, ProjectTemplate, Release, Wallpaper } from "./types.ts";
|
||||
import { abs, walkFiles } from "./fs.ts";
|
||||
import { copyFileTo, copyTree, isDir, isFile, writeJson, writeText } from "./fs.ts";
|
||||
|
||||
export interface GenerateContext {
|
||||
template: ProjectTemplate;
|
||||
version: string;
|
||||
/** 默认预设 id(来自 wallpapers/meta.json):决定合集分发的 preset 下拉首项与 value。 */
|
||||
defaultPresetId: string;
|
||||
/** 分发目录的绝对路径。 */
|
||||
outDir: string;
|
||||
/** 往运行时脚本目录里额外放一个可选的 WE 模拟器入口。 */
|
||||
withSimulator: boolean;
|
||||
}
|
||||
|
||||
/** 该分发里一档壁纸落到哪个子目录(相对分发根)。 */
|
||||
export function releasePathOf(release: Release, wallpaper: Wallpaper): string {
|
||||
if (release.scope === "all") return `${wallpaper.gameId}/${wallpaper.id}`;
|
||||
if (release.scope === "game") return wallpaper.id;
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 一份音频在某分发里的相对路径(相对 preset.js 所在目录)。
|
||||
*
|
||||
* 落点键必须与 `planAudio` 算出来的一致:单档分发与共享音频都落在分发根(键为空串),
|
||||
* 合集分发落在 `audios/<壁纸id>/`。这里刻意不查落点表——查表会让人以为"表是权威",
|
||||
* 而真正的权威是 planAudio;两者不一致时应当在构建里被发现(见 check:dist)。
|
||||
*/
|
||||
export function audioPathIn(release: Release, wallpaper: Wallpaper, file: string, shared = ""): string {
|
||||
const rootPrefix = release.type === "single" ? "./" : rootPrefixOf(release, wallpaper);
|
||||
const key = shared !== "" ? shared.replace(/^\/+|\/+$/g, "") : release.type === "single" ? "" : wallpaper.id;
|
||||
return rootPrefix + "audios/" + (key ? key + "/" : "") + baseName(file);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从某档壁纸的目录回到**分发根**的相对前缀。
|
||||
*
|
||||
* 必须与 build.ts 的 copyWallpaperAssets({skipAudio}) + copyCollectionAudio 的搬移行为严格一致,
|
||||
* 否则 preset.js 会指向不存在的文件(这条路径算错过一次:多算了一层 `../`)。
|
||||
*/
|
||||
export function rootPrefixOf(release: Release, wallpaper: Wallpaper): string {
|
||||
if (release.type === "single") return "./";
|
||||
// game 合集里壁纸在 1 层下(../),all 合集里在 2 层下(../../)。
|
||||
const depth = releasePathOf(release, wallpaper).split("/").filter(Boolean).length;
|
||||
return "../".repeat(depth);
|
||||
}
|
||||
|
||||
/** 该分发里一档壁纸的目录深度(0 = 单档,1 = 游戏合集,2 = 全部合集)。 */
|
||||
export function depthOf(release: Release, wallpaper: Wallpaper): number {
|
||||
return releasePathOf(release, wallpaper).split("/").filter(Boolean).length;
|
||||
}
|
||||
|
||||
/** 一档壁纸目录内的资源(图片/骨架/atlas)在发布产物里的 URL,与 `asset()` 完全同算法。 */
|
||||
export function assetUrlIn(release: Release, wallpaper: Wallpaper, relInWallpaper: string): string {
|
||||
return `./${rootPrefixOf(release, wallpaper)}${relInWallpaper.replace(/^\.\//, "")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 同一份资源在**分发根**下的路径(不带 `./`、`../`)。
|
||||
*
|
||||
* 与 `assetUrlIn` 的区别是基准:那个以 preset.js 所在目录为基准(`../spines/x/x.webp`),
|
||||
* 这个以分发根为基准(`kv37/spines/x/x.webp`)。自包含包的资源表以**页面**为基准建键,
|
||||
* 而页面在 `<分发根>/sim/`,所以要用这个。
|
||||
*
|
||||
* 拿 `assetUrlIn` 的结果去建表会丢掉合集里的 `<壁纸id>/` 这一段:
|
||||
* `./../spines/x/x.webp` 归一化后是 `spines/x/x.webp`,而真实位置是 `kv37/spines/x/x.webp`。
|
||||
* 单档分发看不出差别(没有这一段),合集里则是"表里 152 个键,骨架却查不中"。
|
||||
*/
|
||||
export function assetRelIn(release: Release, wallpaper: Wallpaper, relInWallpaper: string): string {
|
||||
const sub = releasePathOf(release, wallpaper);
|
||||
const rel = relInWallpaper.replace(/^\.\//, "");
|
||||
return sub === "" ? rel : `${sub}/${rel}`;
|
||||
}
|
||||
|
||||
function withDotSlash(path: string): string {
|
||||
const normalized = path.replace(/\\/g, "/");
|
||||
return normalized.startsWith("./") || normalized.startsWith("../") ? normalized : "./" + normalized;
|
||||
}
|
||||
|
||||
function baseName(path: string): string {
|
||||
return path.replace(/\\/g, "/").replace(/^.*\//, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成一档壁纸的 preset.js。
|
||||
*
|
||||
* 关键:每个分发目录里的 preset.js 都是**现算**的——同一档壁纸在三种分发形状下,
|
||||
* 音频落点与相对深度都不同,所以不能一份源码拷贝到多处:
|
||||
* 单档分发 图片/骨架/音频全在本目录 → ./audios/<壁纸id>/…
|
||||
* 游戏合集 图片/骨架在本目录,音频在分发根 → ../audios/<壁纸id>/…
|
||||
* 全部合集 图片/骨架在本目录,音频在分发根 → ../../audios/<壁纸id>/…
|
||||
*
|
||||
* 落点里的 `<壁纸id>/` 一级与 copyCollectionAudio 的分目录一一对应:把不同壁纸的音源分开放,
|
||||
* 既避免**同名不同曲**的静默冲突(旧的扁平布局会因重名直接报错),也让"这份音频属于谁"一目了然。
|
||||
* 游戏级共享音频仍在合集根,不进任何壁纸子目录。
|
||||
*/
|
||||
export function generatePresetModule(
|
||||
release: Release,
|
||||
wallpaper: Wallpaper,
|
||||
sharedAudio: AudioChoice[],
|
||||
): string {
|
||||
const game = wallpaper.game;
|
||||
if (!game) throw new Error(`内部错误:${wallpaper.srcRel} 没有所属游戏`);
|
||||
/** 一份音频在该分发里的相对路径(相对 preset.js 所在目录)。 */
|
||||
const audioPath = (file: string, shared = ""): string => audioPathIn(release, wallpaper, file, shared);
|
||||
|
||||
const spineConfig = wallpaper.preset.spineConfig;
|
||||
const sceneConfig = wallpaper.preset.sceneConfig;
|
||||
if (spineConfig && sceneConfig) {
|
||||
throw new Error(`${wallpaper.srcRel}:spineConfig 与 sceneConfig 只能写一个(单骨架 / 场景二选一)`);
|
||||
}
|
||||
if (!spineConfig && !sceneConfig) {
|
||||
throw new Error(`${wallpaper.srcRel}:preset.template.json 里既没有 spineConfig 也没有 sceneConfig`);
|
||||
}
|
||||
|
||||
const lines: string[] = [
|
||||
`// 由 pnpm build 生成,请勿手工修改。`,
|
||||
`// 源:${wallpaper.srcRel}/meta.json(音频清单)+ preset.template.json(运行时配置)`,
|
||||
`//`,
|
||||
`// 资源路径用 import.meta.url 从本文件位置推导:整个分发目录可以整体搬家而不用改任何一行路径。`,
|
||||
``,
|
||||
`const base = new URL("./", import.meta.url);`,
|
||||
`const asset = (path) => new URL(path, base).href;`,
|
||||
``,
|
||||
`export default {`,
|
||||
` id: ${JSON.stringify(wallpaper.id)},`,
|
||||
` name: ${JSON.stringify(wallpaper.meta.name)},`,
|
||||
` game: ${JSON.stringify(game.id)},`,
|
||||
``,
|
||||
` backgroundImage: asset(${JSON.stringify(withDotSlash(wallpaper.preset.backgroundImage))}),`,
|
||||
];
|
||||
|
||||
if (spineConfig) {
|
||||
lines.push(` spineConfig: {`);
|
||||
for (const [key, value] of Object.entries(spineConfig)) {
|
||||
const isUrl = key === "jsonUrl" || key === "atlasUrl";
|
||||
lines.push(
|
||||
` ${key}: ${isUrl && typeof value === "string" ? `asset(${JSON.stringify(withDotSlash(value))})` : JSON.stringify(value)},`,
|
||||
);
|
||||
}
|
||||
lines.push(` },`);
|
||||
}
|
||||
|
||||
if (sceneConfig) {
|
||||
lines.push(` sceneConfig: {`);
|
||||
if (sceneConfig.ui) lines.push(` ui: ${JSON.stringify(sceneConfig.ui)},`);
|
||||
if (sceneConfig.camera) lines.push(` camera: ${JSON.stringify(sceneConfig.camera)},`);
|
||||
if (sceneConfig.timelineOffset) lines.push(` timelineOffset: ${JSON.stringify(sceneConfig.timelineOffset)},`);
|
||||
if (sceneConfig.flipY === false) lines.push(` flipY: false,`);
|
||||
lines.push(` parts: [`);
|
||||
for (const part of sceneConfig.parts) {
|
||||
const fields: string[] = [
|
||||
`kind: ${JSON.stringify(part.kind)}`,
|
||||
`id: ${JSON.stringify(part.id)}`,
|
||||
`order: ${JSON.stringify(part.order ?? 0)}`,
|
||||
`position: ${JSON.stringify(part.position)}`,
|
||||
`scale: ${JSON.stringify(part.scale)}`,
|
||||
];
|
||||
if (part.renderOrder) fields.push(`renderOrder: ${JSON.stringify(part.renderOrder)}`);
|
||||
// 三个资源字段都走 asset():与单骨架同一条路径解析规则(相对 preset.js 推导)。
|
||||
for (const key of ["image", "jsonUrl", "atlasUrl"] as const) {
|
||||
const value = part[key];
|
||||
if (typeof value === "string" && value) fields.push(`${key}: asset(${JSON.stringify(withDotSlash(value))})`);
|
||||
}
|
||||
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.width !== undefined) fields.push(`width: ${JSON.stringify(part.width)}`);
|
||||
if (part.height !== undefined) fields.push(`height: ${JSON.stringify(part.height)}`);
|
||||
if (part.center !== undefined) fields.push(`center: ${JSON.stringify(part.center)}`);
|
||||
if (part.rotation !== undefined) fields.push(`rotation: ${JSON.stringify(part.rotation)}`);
|
||||
if (part.color !== undefined) fields.push(`color: ${JSON.stringify(part.color)}`);
|
||||
lines.push(` { ${fields.join(", ")} },`);
|
||||
}
|
||||
lines.push(` ],`, ` },`);
|
||||
}
|
||||
|
||||
lines.push(``, ` audioChoices: [`);
|
||||
for (const choice of wallpaper.audioChoices) {
|
||||
lines.push(
|
||||
` { id: ${JSON.stringify(choice.id)}, name: ${JSON.stringify(choice.name)}, source: asset(${JSON.stringify(
|
||||
audioPath(choice.file),
|
||||
)}) },`,
|
||||
);
|
||||
}
|
||||
for (const choice of sharedAudio) {
|
||||
lines.push(
|
||||
` { id: ${JSON.stringify(choice.id)}, name: ${JSON.stringify(choice.name)}, source: asset(${JSON.stringify(
|
||||
audioPath(choice.file, "shared"),
|
||||
)}) },`,
|
||||
);
|
||||
}
|
||||
// 默认音源:合集分发里它也随之搬到分发根。
|
||||
//
|
||||
// 有一处**刻意的历史不一致**:当前已发布的全部合集里这一行是 asset("./audios/<文件>"),
|
||||
// 也就是指向壁纸自己目录下并不存在的 audios/。运行时不读它(`index.ts` 用 audioChoices 的
|
||||
// source 覆盖),所以它一直是死数据。这里改成与 audioChoices 同落点的**正确**路径:
|
||||
// 死数据不该被继承,且一旦将来有代码读它就立刻是错的。
|
||||
// 默认音源按 **1 起的位置** 取(meta.json 里不再手写 id):省略 = 第一个。
|
||||
const defaultChoice = wallpaper.audioChoices[wallpaper.audioDecl.defaultIndex];
|
||||
// 没有音源时写空串,**不能**写成 `asset("")`:那会解析成 preset.js 自己的 URL,
|
||||
// 运行时会把它塞进 <audio>.src(实测会去请求一个不存在的音频)。
|
||||
const defaultSource = defaultChoice ? `asset(${JSON.stringify(withDotSlash(audioPath(defaultChoice.file)))})` : `""`;
|
||||
lines.push(` ],`, ``, ` audioOptions: { source: ${defaultSource} },`, `};`, ``);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 scripts/presets.js:预设查表 + 本分发的默认预设 id。
|
||||
*
|
||||
* 注意这里生成的是**裸 JS 文本**,编译器管不到它——写错了不会有任何提示,只会在浏览器里
|
||||
* SyntaxError 白屏。所以格式必须最保守:用 `Object.fromEntries([[id, mod], …])` 这种"数组的数组",
|
||||
* 别用对象字面量的计算属性名 `[k]: v`(在数组字面量里是非法语法,实测过一次真事故)。
|
||||
*/
|
||||
export function generatePresetIndex(release: Release, defaultPresetId: string): string {
|
||||
const imports: string[] = [];
|
||||
const entries: string[] = [];
|
||||
const ordered = [...release.wallpapers].sort((a, b) => {
|
||||
if (a.id === defaultPresetId) return -1;
|
||||
if (b.id === defaultPresetId) return 1;
|
||||
return 0;
|
||||
});
|
||||
ordered.forEach((wallpaper, index) => {
|
||||
const sub = releasePathOf(release, wallpaper);
|
||||
const local = `preset${index}`;
|
||||
imports.push(`import ${local} from "../${sub ? sub + "/" : ""}preset.js";`);
|
||||
entries.push(` [${local}.id, ${local}],`);
|
||||
});
|
||||
// 单档分发里 defaultPresetId 必须等于它自己的 id,否则 index.js 会拿表里不存在的 id 去查,整页白屏。
|
||||
const fallback = ordered.some((w) => w.id === defaultPresetId) ? defaultPresetId : (ordered[0]?.id ?? "");
|
||||
return [
|
||||
`// 由 pnpm build 生成,请勿手工修改。`,
|
||||
`//`,
|
||||
`// 本分发包含的壁纸:${ordered.map((w) => `${w.id}(${w.meta.name})`).join("、")}`,
|
||||
`// defaultPresetId 是本分发在 WE 属性缺失时的回落目标。`,
|
||||
``,
|
||||
...imports,
|
||||
``,
|
||||
`const Presets = Object.fromEntries([`,
|
||||
...entries,
|
||||
`]);`,
|
||||
``,
|
||||
`export const defaultPresetId = ${JSON.stringify(fallback)};`,
|
||||
`export default Presets;`,
|
||||
``,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export interface ProjectJson {
|
||||
contentrating: string;
|
||||
description: string;
|
||||
file: string;
|
||||
general: { properties: Record<string, unknown> };
|
||||
preview?: string;
|
||||
ratingsex: string;
|
||||
ratingviolence: string;
|
||||
tags: string[];
|
||||
title: string;
|
||||
type: string;
|
||||
/** WE 的 project.json 里 version 是**数字**(VERSION 文件里是文本,生成时转换)。 */
|
||||
version: number;
|
||||
visibility: string;
|
||||
workshopid?: string;
|
||||
workshopurl?: string;
|
||||
}
|
||||
|
||||
interface ComboProperty {
|
||||
options: { label: string; value: string }[];
|
||||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按固定顺序重建一个属性对象。
|
||||
*
|
||||
* 键序不是无所谓的:WE 面板只看字段名,但 project.json 同时是**已发布产物的一部分**,
|
||||
* 保持与线上逐字节一致的键序,才能让"新旧构建的 diff"只反映真实改动,而不是序列化顺序抖动。
|
||||
* 顺序取自线上已发布的 project.json。
|
||||
*/
|
||||
function orderProperty(source: Record<string, unknown>): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const key of ["index", "options", "order", "text", "type", "value"]) {
|
||||
if (key in source) out[key] = source[key];
|
||||
}
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
if (!(key in out)) out[key] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 给属性表自动编号:`index` = 0,1,2…,`order` = 100+index,都按**属性表的书写顺序**。
|
||||
*
|
||||
* 这两串数字以前是手写在模板里的。代价在加一个属性时才显出来:后面全部要重编号
|
||||
* (上一轮加 use_custom_audio 手改了 6 处),而且单档分发删掉 preset/bgm 之后会留下空号
|
||||
* (0,1,4,5,6…)。自动编号同时解决这两件事。
|
||||
*
|
||||
* `$order` 是显式钉住:schemecolor 是 WE 的**内置**属性,线上就是 order 0 且**没有** index。
|
||||
* 钉住的属性不参与 index 序列——这样产物与线上逐字一致,diff 里只剩真实改动。
|
||||
*/
|
||||
function numberProperties(properties: Record<string, unknown>): void {
|
||||
let index = 0;
|
||||
for (const definition of Object.values(properties)) {
|
||||
if (definition === null || typeof definition !== "object") continue;
|
||||
const def = definition as Record<string, unknown>;
|
||||
const pinned = def.$order;
|
||||
delete def.$order;
|
||||
if (typeof pinned === "number") {
|
||||
def.order = pinned;
|
||||
delete def.index;
|
||||
continue;
|
||||
}
|
||||
def.index = index;
|
||||
def.order = 100 + index;
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/** 生成某分发的 project.json。字段顺序对齐已发布作品,便于人工 diff。 */
|
||||
export function generateProjectJson(release: Release, ctx: GenerateContext): ProjectJson {
|
||||
const properties: Record<string, unknown> = structuredClone(ctx.template.general.properties) as Record<string, unknown>;
|
||||
const isCollection = release.wallpapers.length > 1;
|
||||
|
||||
// 下拉顺序以**默认预设**开头。这不是美观问题:默认项决定 preset.value,
|
||||
// 也决定 WE 属性缺失时 show 的是哪一档;线上值必须逐字保持不变。
|
||||
const ordered = [...release.wallpapers].sort((a, b) => {
|
||||
if (a.id === ctx.defaultPresetId) return -1;
|
||||
if (b.id === ctx.defaultPresetId) return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
// bgm:本分发内所有壁纸音源的并集 + 合集根共享音频。运行时只让"属于当前壁纸"的选择生效,
|
||||
// 其余回落该壁纸默认音源(既有语义,见 .scratch/wallpaper-optimization/issues/05)。
|
||||
//
|
||||
// **单档分发也要算它**:一档壁纸照样可以带多首曲子(xilian 就有两首)。
|
||||
// 以前整块跟着 preset 一起删,结果 single-hsr-xilian 根本没有切换入口。
|
||||
const bgmOptions: { label: string; value: string }[] = [{ label: "随预设", value: "auto" }];
|
||||
const seen = new Set<string>(["auto"]);
|
||||
for (const wallpaper of ordered) {
|
||||
for (const choice of wallpaper.audioChoices) {
|
||||
if (seen.has(choice.id)) continue;
|
||||
seen.add(choice.id);
|
||||
bgmOptions.push({ label: choice.name, value: choice.id });
|
||||
}
|
||||
}
|
||||
for (const choice of release.sharedAudio) {
|
||||
if (seen.has(choice.id)) continue;
|
||||
seen.add(choice.id);
|
||||
bgmOptions.push({ label: choice.name, value: choice.id });
|
||||
}
|
||||
|
||||
if (isCollection) {
|
||||
const multiGame = new Set(release.wallpapers.map((w) => w.gameId)).size > 1;
|
||||
const preset = properties.preset as ComboProperty;
|
||||
preset.options = ordered.map((wallpaper) => ({
|
||||
label: multiGame
|
||||
? `${wallpaper.game?.name ?? wallpaper.gameId} - ${wallpaper.meta.name}`
|
||||
: wallpaper.meta.name,
|
||||
value: wallpaper.id,
|
||||
}));
|
||||
preset.value = ordered[0]?.id ?? "";
|
||||
properties.preset = orderProperty(properties.preset as Record<string, unknown>);
|
||||
} else {
|
||||
// 单档分发:只剩一档壁纸,"壁纸预设切换"没有意义。整条属性删掉;运行时对缺失属性是安全的。
|
||||
delete properties.preset;
|
||||
delete properties.preset_note;
|
||||
}
|
||||
|
||||
// 只剩"随预设"+ 至多一首时这个下拉是句废话(两个选项效果完全一样),删掉。
|
||||
// 这样 single-hsr-kv37(一首)没有它、single-hsr-xilian(两首)有它。
|
||||
if (bgmOptions.length <= 2) {
|
||||
delete properties.bgm;
|
||||
delete properties.bgm_note;
|
||||
} else {
|
||||
(properties.bgm as ComboProperty).options = bgmOptions;
|
||||
properties.bgm = orderProperty(properties.bgm as Record<string, unknown>);
|
||||
}
|
||||
|
||||
// 编号必须在**删属性之后**:单档分发删掉 preset/bgm 那四行,编号要跟着补上,不留空号。
|
||||
numberProperties(properties);
|
||||
|
||||
// **键序是有意义的**:上传到创意工坊的 project.json 要和线上那份逐字节对齐,
|
||||
// 否则每次构建都会产生一个"内容相同但文件不同"的 diff,无法判断是真改动还是噪声。
|
||||
//
|
||||
// 线上那份的键序是(WE 自己写出来的):
|
||||
// contentrating, description, file, general, **preview**, ratingsex, ratingviolence,
|
||||
// tags, title, type, version, visibility, workshopid, workshopurl
|
||||
// 注意 `preview` 夹在 general 与 ratingsex 之间,而 workshop* 在最后——不是"可选字段统一追加"。
|
||||
// 先前把三个可选字段都追加到末尾,字段值一个没差、键序却变了;靠逐键比对才发现。
|
||||
const out: ProjectJson = {
|
||||
contentrating: ctx.template.contentrating,
|
||||
description: release.meta.description,
|
||||
file: ctx.template.file,
|
||||
general: { properties },
|
||||
...(release.meta.preview ? { preview: release.meta.preview } : {}),
|
||||
ratingsex: ctx.template.ratingsex,
|
||||
ratingviolence: ctx.template.ratingviolence,
|
||||
tags: ctx.template.tags,
|
||||
title: release.meta.title,
|
||||
type: ctx.template.type,
|
||||
// WE 的 project.json 里 version 是数字;VERSION 文件里是文本,这里必须转回来。
|
||||
version: Number(ctx.version),
|
||||
visibility: ctx.template.visibility,
|
||||
};
|
||||
// workshopid / workshopurl 只在有值时出现(Q7):没有就整键省略,而不是留空串。
|
||||
if (release.meta.workshopid) out.workshopid = release.meta.workshopid;
|
||||
if (release.meta.workshopurl) out.workshopurl = release.meta.workshopurl;
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface AudioPlacement {
|
||||
/** 源文件绝对路径。 */
|
||||
from: string;
|
||||
/** 落点键:壁纸 id(→ `audios/<键>/<文件>`)或 `""`(→ 合集根 `audios/<文件>`)。 */
|
||||
key: string;
|
||||
/** 文件名(保留中文,是产品内容)。 */
|
||||
name: string;
|
||||
/** 相对分发根的落点。 */
|
||||
destRel: string;
|
||||
}
|
||||
|
||||
export interface AudioPlan {
|
||||
placements: AudioPlacement[];
|
||||
/** 合集根共享层的重名冲突(扁平一层,重名即歧义)。 */
|
||||
collisions: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 算出**整个分发**的音频落点。
|
||||
*
|
||||
* 这份映射是三件事的唯一真相来源,所以必须是同一个函数:
|
||||
* ① copyCollectionAudio 按它搬文件;② generatePresetModule 按它拼 preset.js 里的路径;
|
||||
* ③ `--sim` 的自包含打包按它建内联表。
|
||||
* 三处各算一遍的症状是"http 下正常、file:// 下白屏"或"静默无声"——最难在开发期发现的那类。
|
||||
*
|
||||
* 落点分两层:
|
||||
* audios/<壁纸id>/<文件> 该壁纸自己的音源(互相隔离,同名不同曲不再冲突)
|
||||
* audios/<文件> 游戏级共享音频(扁平一层,重名即歧义)
|
||||
*/
|
||||
export async function planAudio(release: Release): Promise<AudioPlan> {
|
||||
const placements: AudioPlacement[] = [];
|
||||
const collisions: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
const push = (from: string, key: string, name: string): void => {
|
||||
if (seen.has(from)) return;
|
||||
seen.add(from);
|
||||
placements.push({ from, key, name, destRel: `audios/${key ? key + "/" : ""}${name}` });
|
||||
};
|
||||
|
||||
const owner = new Map<string, string>();
|
||||
for (const wallpaper of release.wallpapers) {
|
||||
const key = release.type === "single" ? "" : wallpaper.id;
|
||||
for (const choice of wallpaper.audioChoices) {
|
||||
const name = baseName(choice.file);
|
||||
push(join(abs(wallpaper.srcRel), "audios", name), key, name);
|
||||
owner.set(name, wallpaper.srcRel);
|
||||
}
|
||||
}
|
||||
|
||||
// 游戏级共享音频(wallpapers/<游戏>/audios/):同一首曲子被该游戏多档壁纸共用时放这里。
|
||||
for (const gameId of [...new Set(release.wallpapers.map((w) => w.gameId))]) {
|
||||
const shared = abs(`wallpapers/${gameId}/audios`);
|
||||
if (!(await isDir(shared))) continue;
|
||||
for (const name of await walkFiles(shared)) {
|
||||
const previous = owner.get(name);
|
||||
if (previous !== undefined) {
|
||||
collisions.push(`${name}:${previous} 与 共享(${gameId})`);
|
||||
continue;
|
||||
}
|
||||
owner.set(name, `共享(${gameId})`);
|
||||
push(join(shared, name), "", name);
|
||||
}
|
||||
}
|
||||
|
||||
return { placements, collisions };
|
||||
}
|
||||
|
||||
/** 把一档壁纸的资源铺进分发目录(骨架、贴图、音频、预览图)。
|
||||
*
|
||||
* 合集类分发里音频统一放在**分发根的 audios/**(见 copySharedAudio),所以这里跳过 audios/,
|
||||
* 否则同一首曲子会在分发里出现两份。单档分发没有合集根,音频必须留在壁纸目录内。 */
|
||||
export async function copyWallpaperAssets(
|
||||
wallpaper: Wallpaper,
|
||||
destDir: string,
|
||||
options: { skipAudio?: boolean } = {},
|
||||
): Promise<void> {
|
||||
const from = abs(wallpaper.srcRel);
|
||||
// 目录名必须与 wallpapers/README.md 的布局一致:一具骨架一组的 `spines/`、
|
||||
// 场景图与背景的 `scene/`、音频的 `audios/`(这里改一处,所有分发的搬运都跟着走)。
|
||||
for (const segment of ["spines", "scene", "audios"]) {
|
||||
if (segment === "audios" && options.skipAudio) continue;
|
||||
if (await isDir(join(from, segment))) await copyTree(join(from, segment), join(destDir, segment));
|
||||
}
|
||||
if (wallpaper.meta.preview && (await isFile(join(from, baseName(wallpaper.meta.preview))))) {
|
||||
await copyTree(join(from, baseName(wallpaper.meta.preview)), join(destDir, baseName(wallpaper.meta.preview)));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把**整个分发**的音频铺进分发根。落点完全由 planAudio 决定(见它的注释)。
|
||||
*
|
||||
* 只有共享层允许重名冲突:那里重名是真的不知道谁是权威,直接报错而不是静默覆盖。
|
||||
*/
|
||||
export async function copyCollectionAudio(
|
||||
release: Release,
|
||||
outDir: string,
|
||||
): Promise<{ names: string[]; audioDirs: Map<string, string> }> {
|
||||
const plan = await planAudio(release);
|
||||
if (plan.collisions.length > 0) {
|
||||
throw new Error(
|
||||
`合集根共享音频文件重名,无法确定权威版本:\n - ${plan.collisions.join("\n - ")}\n` +
|
||||
`共享层是扁平的一层,重名即歧义。请改名,或把它放进各自壁纸目录(不同壁纸之间不会冲突)。`,
|
||||
);
|
||||
}
|
||||
|
||||
const audioDirs = new Map<string, string>();
|
||||
for (const item of plan.placements) {
|
||||
audioDirs.set(item.name, item.key);
|
||||
await copyFileTo(item.from, join(outDir, item.destRel));
|
||||
}
|
||||
return { names: plan.placements.map((p) => p.name).sort(), audioDirs };
|
||||
}
|
||||
|
||||
export { writeJson, writeText, baseName, withDotSlash };
|
||||
Reference in new issue
Block a user