把项目从「手写 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 处引用自包含。
373 lines
15 KiB
Python
373 lines
15 KiB
Python
"""页面场景树(``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)))
|