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:
Shuery committed 2026-10-02 01:27:02 +08:00
1 parent b8eee05d78
commit 3f11426964
297 files changed
+216627 -1926

No files matched your search

+372
View File
@@ -0,0 +1,372 @@
"""页面场景树(``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)))