把项目从「手写 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 处引用自包含。
290 lines
11 KiB
Python
290 lines
11 KiB
Python
"""把一个场景组装成「可直接搬走的壁纸目录」。
|
||
|
||
抓取期产出的**终止形状**就是 `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/<逻辑名>.<ext>`(扩展名由资源路径决定,见 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 自己声明的第一行**,不按 `<stem>_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/<名>.<ext>`);不给就自动挑一张背景图,
|
||
因为构建期 `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"))
|