// 一个新分发目录 = 一个能直接上传 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, // 运行时会把它塞进