"""把一个场景组装成「可直接搬走的壁纸目录」。 抓取期产出的**终止形状**就是 `wallpapers/<游戏>/<壁纸id>/` 的同形拷贝 (布局 A,见 `docs/adr/0008-asset-layout-per-skeleton.md`): <页面>/ ├── page.json ← 页面级溯源 └── <场景id>/ ← 一个场景 = 一个壁纸目录 ├── meta.json ├── preset.template.json ├── scene.json ├── spines/<骨架名>/ ← <名>.json + <名>.atlas + 贴图页**同居** ├── scene/<场景图> └── audios/ 目录名固定 `spines` / `scene` / `audios`——`tools/lib/generate.ts` 的 `copyWallpaperAssets` 按这三个名字搬文件,改名要两边一起改。 这个模块只做两件事:**写**(`build_preset` / `build_meta`)与**查**(`missing_preset_paths`)。 写与查放在一起是刻意的:产物形状一旦改,校验规则必须同时改,分成两个文件迟早漂移。 """ from __future__ import annotations import json import re from pathlib import Path from typing import Any __all__ = [ "AUDIO_DIR", "IMAGE_EXT", "SCENE_DIR", "SPINE_DIR", "build_meta", "build_preset", "image_file", "missing_preset_paths", "preset_paths", "scene_dir_name", "spine_page_file", "write_json", ] SPINE_DIR = "spines" SCENE_DIR = "scene" AUDIO_DIR = "audios" IMAGE_EXT = (".png", ".webp", ".jpg", ".jpeg", ".gif") # 壁纸 id 的字符集(与 tools/lib/vault.ts 的校验逐字一致):它会进 WE 的 combo value。 _UNSAFE = re.compile(r"[^A-Za-z0-9_-]+") def scene_dir_name(scene_id: str) -> str: """场景 id → 目录名。 页面里的场景 id 多数本来就合法(`scene_main` / `P1` / `loading`),但也有中文的 (back-moon 的 `动画预览`)。目录名一旦成为壁纸 id 就必须是 `^[a-z0-9][a-z0-9_-]*$`, 所以这里统一转义:非法字符折成一个 `_`,首字符不是字母数字时补前缀。 **真实 id 不会被丢掉**——它写在同目录的 `meta.json` / `scene.json` 里。 """ name = _UNSAFE.sub("_", str(scene_id).strip()).strip("_-") if not name: return "scene" if not re.match(r"[A-Za-z0-9]", name): return f"scene_{name}" return name def image_file(scene_dir: Path, name: str) -> Path | None: """场景图落盘名:`scene/<逻辑名>.`(扩展名由资源路径决定,见 mihoyo.image_ext)。""" for ext in IMAGE_EXT: candidate = scene_dir / SCENE_DIR / f"{name}{ext}" if candidate.is_file(): return candidate return None def spine_page_file(scene_dir: Path, spine_id: str) -> Path | None: """一具骨架的第一张贴图页。 页名**读 atlas 自己声明的第一行**,不按 `_N` 猜——去 hash 之后页名未必是 `<骨架名>.png`(多页是 `_2.png`,也有完全不同的名字)。这条路径只用于"整个场景一张 贴图平面都没有"时的背景兜底(`wallpapers/README.md` 允许背景指向骨架贴图页)。 """ atlas = scene_dir / SPINE_DIR / spine_id / f"{spine_id}.atlas" if not atlas.is_file(): return None for line in atlas.read_text(encoding="utf-8").splitlines(): page = line.strip().strip('"') if page.lower().endswith(IMAGE_EXT): candidate = atlas.parent / Path(page).name if candidate.is_file(): return candidate return None def _rel(scene_dir: Path, path: Path) -> str: """磁盘路径 → 预设里的 `./…` 相对路径(**一律正斜杠**,见"跨平台路径"那条坑)。""" return "./" + path.relative_to(scene_dir).as_posix() def _part_common(part: dict[str, Any]) -> dict[str, Any]: """part 的公共字段(世界变换 + 绘制层级 + 页面指定的动画/皮肤/时间缩放)。""" common: dict[str, Any] = { "kind": part.get("kind"), "id": part["id"], "order": part.get("order", 0), "position": part.get("position", [0, 0, 0]), "scale": part.get("scale", [1, 1, 1]), } if part.get("renderOrder"): common["renderOrder"] = part["renderOrder"] if part.get("geometrySize"): common["width"], common["height"] = part["geometrySize"] if part.get("geometryCenter"): common["center"] = part["geometryCenter"] if part.get("rotation") and any(abs(float(v)) > 1e-9 for v in part["rotation"]): common["rotation"] = part["rotation"] # 页面指定的动画 / 皮肤:不抄就会去播骨架的第一个动画(常是入场动画 in,姿态不同)。 if part.get("animation"): common["animation"] = part["animation"] if part.get("skin"): common["skin"] = part["skin"] if part.get("timeScale") is not None: common["timeScale"] = part["timeScale"] return common def build_preset( scene: dict[str, Any], scene_dir: Path, *, cover: str | None = None, ) -> tuple[dict[str, Any], list[str]]: """由 `scene.json` 的内容 + 磁盘上的场景目录,生成 `preset.template.json`。 返回(预设, 说明列表)。说明是"这个场景里没能进预设的东西"——纯色平面、缺文件的 part, 它们不是错误(`verify` 会独立判红),但用户该知道少了几件。 `cover` 给的是**逻辑名**(`scene/<名>.`);不给就自动挑一张背景图, 因为构建期 `backgroundImage` 是必填且必须真实存在(见 `tools/lib/vault.ts`)。 """ notes: list[str] = [] parts: list[dict[str, Any]] = [] solids = 0 for part in scene.get("parts") or []: kind = part.get("kind") if kind == "solid": # 纯色平面运行时这一轮画不了(没有贴图),写进预设只是死配置。 solids += 1 continue if kind not in ("spine", "image"): notes.append(f"未知的 part 类型 {kind!r}(id={part.get('id')}),已跳过") continue common = _part_common(part) if kind == "spine": spine_id = str(part["id"]) spine_dir = scene_dir / SPINE_DIR / spine_id if not (spine_dir / f"{spine_id}.json").is_file(): notes.append(f"骨架 {spine_id} 没有落到 spines/{spine_id}/,未写进预设") continue common["jsonUrl"] = f"./{SPINE_DIR}/{spine_id}/{spine_id}.json" common["atlasUrl"] = f"./{SPINE_DIR}/{spine_id}/{spine_id}.atlas" else: if part.get("runtime"): # 运行时缓冲(cacheContainer / diffuse 指向同场景骨架缓存):由骨架渲染进贴图 # 缓冲,没有独立文件,不该进预设也不该报缺资源(`scene.json` 里标了 runtime)。 continue source = image_file(scene_dir, str(part["id"])) if source is None: notes.append(f"贴图平面 {part['id']} 没有独立文件,未写进预设") continue common["image"] = _rel(scene_dir, source) parts.append(common) if solids: notes.append(f"{solids} 块纯色平面未写进预设(运行时没有贴图可画)") background: str | None = None if cover: source = image_file(scene_dir, cover) if source is None: notes.append(f"指定的背景图 {cover} 不在 scene/ 里,改为自动挑选") else: background = _rel(scene_dir, source) if background is None: background = _pick_background(scene_dir, parts) scene_config: dict[str, Any] = {"ui": scene.get("ui"), "parts": parts} camera_node = scene.get("camera") or {} camera = camera_node.get("camera") or {} if camera.get("type") is not None: # 相机必须带进预设:透视场景(type 1)忽略 fov 与 z 就会画成一块糊满屏的贴图。 scene_config["camera"] = { "type": camera.get("type"), "fov": camera.get("fov"), "position": camera_node.get("position"), } return {"backgroundImage": background or "", "sceneConfig": scene_config}, notes def _pick_background(scene_dir: Path, parts: list[dict[str, Any]]) -> str | None: """自动挑背景图:贴图平面里**面积最大**的那块(画布比例的来源),没有就退到骨架贴图页。 为什么按面积:`backgroundImage` 在运行时决定 `document.body` 的底图与取景用的宽高比 (`src/runtime/index.ts` 的 `measureImageAspect`)。挑到一块小按钮会让整幅画的比例全错, 而背景/天空/远景恰好总是场景里最大的那块平面(实测:nico-tea 的 `main_sky_jpg` 2500×1064)。 """ images = [p for p in parts if p.get("image")] if images: best = max( images, key=lambda p: (float(p.get("width") or 0) * float(p.get("height") or 0), -int(p.get("order") or 0)), ) return str(best["image"]) for part in parts: if not part.get("jsonUrl"): continue source = spine_page_file(scene_dir, str(part["id"])) if source is not None: return _rel(scene_dir, source) return None def preset_paths(preset: dict[str, Any]) -> list[str]: """预设里所有必须真实存在的 `./…` 路径。""" out: list[str] = [] background = preset.get("backgroundImage") if isinstance(background, str) and background: out.append(background) scene_config = preset.get("sceneConfig") or {} for part in scene_config.get("parts") or []: for key in ("image", "jsonUrl", "atlasUrl"): value = part.get(key) if isinstance(value, str) and value: out.append(value) return out def missing_preset_paths(preset: dict[str, Any], scene_dir: Path) -> list[str]: """预设里指向磁盘上不存在的文件的路径(写出后立刻自检,不留到构建期才炸)。""" missing: list[str] = [] for rel in preset_paths(preset): # 映射键一律用**正斜杠**:Windows 的 str(Path) 是反斜杠,写进预设就搬到别的机器上读不到。 if not (scene_dir / rel.replace("\\", "/").removeprefix("./")).is_file(): missing.append(rel) return missing def build_meta( *, wallpaper_id: str, name: str, title: str, description: str, game: str, page: str, scene: str, source: str, fetched_at: str | None = None, ) -> dict[str, Any]: """场景目录的 `meta.json`:既有字段语义(id/name/title/description/audio)原样保留, 另加溯源字段(game/page/scene/source),方便搬进 `wallpapers/` 后回查来源。""" meta: dict[str, Any] = { "id": wallpaper_id, "name": name, "title": title, "description": description, "game": game, "page": page, "scene": scene, "source": source, # 音源清单为空:页面的 BGM 还没抓(构建会据此省掉 bgm 属性)。 "audio": {"choices": []}, } if fetched_at: meta["fetchedAt"] = fetched_at return meta def write_json(path: Path, data: Any, *, indent: int = 2) -> int: """写一份 JSON(UTF-8、不转义中文),返回字节数。""" path.parent.mkdir(parents=True, exist_ok=True) text = json.dumps(data, ensure_ascii=False, indent=indent) + "\n" path.write_text(text, encoding="utf-8") return len(text.encode("utf-8"))