Files
Shuery 3f11426964 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 处引用自包含。
2026-10-02 01:27:02 +08:00

373 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""页面场景树(``sceneList``)的解析与"part"提取。
米哈游活动页用自研引擎(three.js 系)描述场景:顶层是一个 ``sceneList`` 数组,每个元素是一个
**场景**(``scene_main`` / ``scene_loading`` / ``scene_game`` …),场景里是一棵树。节点有两类
我们关心的负载:
* ``spine:{id:"main_nike"}`` —— 一具骨架(一具骨架 = 一个视觉元件:人物、云、箱子、光效…)
* ``geometry:{type:2,config:{...}}`` + ``material:[{uniforms:[{key:"diffuse",value:"main_sky_jpg"}]}]``
—— 一块**几何平面**,贴的是场景图。用户口中的 "geometric" 就是这个(Spine 官方没有这个概念)。
这里不用正则去"抠字段"——那正是旧实现踩过的坑(父节点的 position/scale 会从子孙子树里被误读)。
我们把整段字面量解析成对象再走树,字段天然只属于自己那一层。
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from . import jslit
__all__ = ["Scene", "Part", "find_scene_list", "parse_scene", "pick_default_scene"]
_SCENE_KEY = "sceneList"
@dataclass
class Part:
"""场景里的一个可绘制件。"""
kind: str # "spine" | "image"(有贴图的平面) | "solid"(纯色平面,不需要资源)
id: str # 骨架逻辑名,或几何平面贴的图名
node_path: str
order: int # 树序遍历序 = 绘制层级
render_order: int
position: tuple[float, float, float] # 世界位移(父 scale 已乘进来)
scale: tuple[float, float, float] # 世界缩放(逐层连乘)
local_position: tuple[float, float, float] | None
local_scale: tuple[float, float, float] | None
rotation: tuple[float, float, float] | None
geometry_type: int | None
auto_matrix: bool
geometry_size: tuple[float, float] | None = None
geometry_center: tuple[float, float] | None = None
modifiers: tuple[str, ...] = ()
# 页面在这个节点上指定的动画(spine.defaultAnimation);缺省 = 骨架第一个动画。
animation: str | None = None
skin: str | None = None
time_scale: float | None = None
def as_dict(self) -> dict[str, Any]:
out: dict[str, Any] = {
"kind": self.kind,
"id": self.id,
"path": self.node_path,
"order": self.order,
"renderOrder": self.render_order,
"position": list(self.position),
"scale": list(self.scale),
}
if self.local_position is not None:
out["localPosition"] = list(self.local_position)
if self.local_scale is not None:
out["localScale"] = list(self.local_scale)
if self.rotation is not None:
out["rotation"] = list(self.rotation)
if self.geometry_type is not None:
out["geometryType"] = self.geometry_type
if self.geometry_size is not None:
out["geometrySize"] = list(self.geometry_size)
if self.geometry_center is not None:
out["geometryCenter"] = list(self.geometry_center)
if self.auto_matrix:
out["autoMatrix"] = True
if self.modifiers:
out["modifiers"] = list(self.modifiers)
if self.animation:
out["animation"] = self.animation
if self.skin:
out["skin"] = self.skin
if self.time_scale is not None:
out["timeScale"] = self.time_scale
return out
@dataclass
class Scene:
"""一个场景及其全部可绘制件。"""
id: str
ui: tuple[int, int] | None
camera: dict[str, Any] | None
parts: list[Part] = field(default_factory=list)
node_count: int = 0
max_depth: int = 0
rotated_nodes: int = 0 # 带 rotation 的节点数(当前不参与合成,见下)
@property
def spine_ids(self) -> list[str]:
seen: list[str] = []
for p in self.parts:
if p.kind == "spine" and p.id not in seen:
seen.append(p.id)
return seen
def as_dict(self, *, page: str | None = None, game: str | None = None) -> dict[str, Any]:
out: dict[str, Any] = {
"version": 1,
"id": self.id,
"ui": list(self.ui) if self.ui else None,
"camera": self.camera,
"parts": [p.as_dict() for p in self.parts],
"stats": {
"nodes": self.node_count,
"maxDepth": self.max_depth,
"spines": len(self.spine_ids),
"images": sum(1 for p in self.parts if p.kind == "image"),
"solids": sum(1 for p in self.parts if p.kind == "solid"),
"rotatedNodes": self.rotated_nodes,
},
}
if game:
out["game"] = game
if page:
out["page"] = page
return out
def find_scene_list(text: str) -> list[dict[str, Any]]:
"""定位并解析 ``sceneList`` 数组;找不到返回空列表。
两种书写风格:压缩后的 ``sceneList:[`` 与已序列化的 ``"sceneList": [``。
"""
for pattern in (f'"{_SCENE_KEY}"', f"{_SCENE_KEY}:"):
idx = text.find(pattern)
if idx < 0:
continue
start = text.find("[", idx)
if start < 0:
continue
end = jslit.match_literal(text, start)
if end < 0:
continue
data = jslit.loads(text[start : end + 1])
if isinstance(data, list) and data:
return [s for s in data if isinstance(s, dict)]
return []
def _vec3(value: Any) -> tuple[float, float, float] | None:
if isinstance(value, (list, tuple)) and len(value) >= 3:
try:
return (float(value[0]), float(value[1]), float(value[2]))
except (TypeError, ValueError):
return None
if isinstance(value, (int, float)):
return (float(value),) * 3
return None
def _material_info(node: dict[str, Any]) -> tuple[str | None, bool]:
"""返回(diffuse 名, 是否真的用贴图)。
``defines.USE_TEXTURE == 0`` 的平面是**纯色块**(diffuse 通常写 "DEFAULT"),
页面上并不存在对应图片;把它当资源去下载只会抓到一个不存在的 URL。
"""
diffuse: str | None = None
textured = True
for material in node.get("material") or []:
if not isinstance(material, dict):
continue
defines = material.get("defines")
if isinstance(defines, dict) and defines.get("USE_TEXTURE") == 0:
textured = False
for uniform in material.get("uniforms") or []:
if isinstance(uniform, dict) and uniform.get("key") == "diffuse":
value = uniform.get("value")
if isinstance(value, str):
diffuse = value
return diffuse, textured
def _modifiers(node: dict[str, Any]) -> tuple[str, ...]:
"""节点的 modifier id 列表(``cacheContainer`` = 渲染到贴图缓冲、``CSS3DObject`` = DOM 叠层)。"""
ids: list[str] = []
for modifier in node.get("modifier") or []:
if isinstance(modifier, dict) and isinstance(modifier.get("id"), str):
ids.append(modifier["id"])
return tuple(ids)
def mesh_bbox(mesh: Any) -> tuple[tuple[float, float], tuple[float, float]] | None:
"""glTF 网格的顶点包围盒 → (尺寸, 相对节点原点的中心)。
`position.array` 是扁平的 xyz 三元组序列(实测这些网格都是四边形)。中心不为零时,
平面并不以节点原点为中心,画的时候要按它偏移。
"""
if not isinstance(mesh, dict):
return None
position = ((mesh.get("attributes") or {}).get("position") or {})
array = position.get("array")
if not isinstance(array, list) or len(array) < 9:
return None
xs: list[float] = []
ys: list[float] = []
for index, value in enumerate(array):
if not isinstance(value, (int, float)):
continue
if index % 3 == 0:
xs.append(float(value))
elif index % 3 == 1:
ys.append(float(value))
if not xs or not ys:
return None
return (
(max(xs) - min(xs), max(ys) - min(ys)),
((min(xs) + max(xs)) / 2, (min(ys) + max(ys)) / 2),
)
def parse_scene(
scene: dict[str, Any],
geometries: dict[str, Any] | None = None,
timeline: dict[str, Any] | None = None,
) -> Scene:
"""把一棵场景树摊平成 part 列表,并合成世界变换。
合成语义照抄引擎(three.js 系)的节点语义,与旧项目 v2 侧车一致:
world_position = parent_position + parent_scale ⊙ local_position
world_scale = parent_scale ⊙ local_scale
旋转暂不参与合成(旧项目同样如此)——只在 stats 里报出数量,别假装算了。
"""
scene_id = str(scene.get("id") or scene.get("name") or "scene")
config = scene.get("sceneConfig") or {}
ui = None
if isinstance(config, dict) and config.get("uiWidth") and config.get("uiHeight"):
ui = (int(config["uiWidth"]), int(config["uiHeight"]))
camera = None
cameras = scene.get("camera")
if isinstance(cameras, list) and cameras:
first = cameras[0]
if isinstance(first, dict):
camera = {
"id": first.get("id"),
"position": first.get("position"),
"rotation": first.get("rotation"),
"camera": first.get("camera"),
}
result = Scene(id=scene_id, ui=ui, camera=camera)
order = 0
def walk(node: Any, path: str, depth: int, parent_pos: tuple[float, float, float], parent_scale: tuple[float, float, float]) -> None:
nonlocal order
if not isinstance(node, dict):
return
name = str(node.get("name") or node.get("id") or "?")
node_path = f"{path}/{name}"
result.node_count += 1
result.max_depth = max(result.max_depth, depth)
# 时间线**按节点名覆盖** position/scale(源码:parsePath → getObjectByName → target[prop] = 值)。
# 静态值是入场前的状态,稳定态要用轨道末帧值。
overridden_pos = None
overridden_scale = None
if timeline:
tracks = timeline.get(scene_id) or {}
overridden_pos = (tracks.get("positions") or {}).get(name)
overridden_scale = (tracks.get("scales") or {}).get(name)
local_pos = _vec3(overridden_pos) if overridden_pos else _vec3(node.get("position"))
local_scale = _vec3(overridden_scale) if overridden_scale else _vec3(node.get("scale"))
rotation = _vec3(node.get("rotation"))
if rotation and any(abs(v) > 1e-9 for v in rotation):
result.rotated_nodes += 1
world_pos = parent_pos
if local_pos:
world_pos = tuple(parent_pos[i] + parent_scale[i] * local_pos[i] for i in range(3))
world_scale = parent_scale
if local_scale:
world_scale = tuple(parent_scale[i] * local_scale[i] for i in range(3))
spine = node.get("spine")
spine_id = spine.get("id") if isinstance(spine, dict) else None
spine_animation = None
spine_skin = None
spine_time_scale = None
if isinstance(spine, dict):
# 页面在这个节点上指定的播放方式——不读它就会去播骨架的第一个动画(常常是入场动画 in)。
if isinstance(spine.get("defaultAnimation"), str):
spine_animation = spine["defaultAnimation"]
if isinstance(spine.get("skin"), str):
spine_skin = spine["skin"]
if isinstance(spine.get("timeScale"), (int, float)):
spine_time_scale = float(spine["timeScale"])
diffuse, textured = _material_info(node)
modifiers = _modifiers(node)
geometry = node.get("geometry")
geometry_type = geometry.get("type") if isinstance(geometry, dict) else None
geometry_size = None
geometry_center = None
if isinstance(geometry, dict):
config = geometry.get("config")
if isinstance(config, dict) and config.get("width") and config.get("height"):
geometry_size = (float(config["width"]), float(config["height"]))
elif geometry.get("type") == 1 and isinstance(geometry.get("id"), str) and geometries:
box = mesh_bbox(geometries.get(geometry["id"]))
if box is not None:
geometry_size, geometry_center = box
if isinstance(spine_id, str) and spine_id:
result.parts.append(
Part(
kind="spine",
id=spine_id,
node_path=node_path,
order=order,
render_order=int(node.get("renderOrder") or 0),
position=world_pos,
scale=world_scale,
local_position=local_pos,
local_scale=local_scale,
rotation=rotation,
geometry_type=None,
auto_matrix=bool(node.get("autoMatrix")),
modifiers=modifiers,
animation=spine_animation,
skin=spine_skin,
time_scale=spine_time_scale,
)
)
order += 1
elif isinstance(diffuse, str) and diffuse and geometry is not None:
result.parts.append(
Part(
kind="image" if textured and diffuse != "DEFAULT" else "solid",
id=diffuse,
node_path=node_path,
order=order,
render_order=int(node.get("renderOrder") or 0),
position=world_pos,
scale=world_scale,
local_position=local_pos,
local_scale=local_scale,
rotation=rotation,
geometry_type=int(geometry_type) if isinstance(geometry_type, int) else None,
auto_matrix=bool(node.get("autoMatrix")),
geometry_size=geometry_size,
geometry_center=geometry_center,
modifiers=modifiers,
)
)
order += 1
for child in node.get("children") or []:
walk(child, node_path, depth + 1, world_pos, world_scale)
for child in scene.get("children") or []:
walk(child, scene_id, 1, (0.0, 0.0, 0.0), (1.0, 1.0, 1.0))
return result
def pick_default_scene(scenes: list[Scene]) -> Scene | None:
"""默认场景 = 骨架最多的那个;并列时取先出现的。
这与旧项目的规则一致("取 spine 最多的一个场景")。它只是**默认值**:
一旦 `sources.yml` 或 `selection.yml` 指定了场景,就以人/记录为准。
"""
if not scenes:
return None
return max(scenes, key=lambda s: (len(s.spine_ids), -scenes.index(s)))