Files
ChildHood/tools/level-builder/parsers/build_guide_parser.py
T
starmen1265 a3c38aa92a fix(level-builder): 锚点补偿+旋转修复+天空盒+解析器去重
- asset_mapping: 38种BSP资产实测锚点+pivot_offset_xy自动补偿
- scene_builder: Rotator改关键字参数修正旋转轴映射
- scene_builder: spawn前XY锚点补偿(支持Yaw旋转)
- scene_builder: 新增ExponentialHeightFog+大气效果后处理
- build_cmdline: 构建后自动添加雾+GameMode+DirectionalLight大气
- build_guide_parser: 去重+全局地面+旋转列名兼容+边界段范围修正
- BarrierWall实际尺寸修正(60×210非20×100)
- 搭建指南: 全局地面中心坐标+西边界旋转统一+院门改双开门
2026-06-08 19:05:50 +08:00

829 lines
29 KiB
Python
Raw 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.
"""搭建指南 Markdown 解析器。
解析 docs/technical/关卡-v01-搭建指南.md,输出统一的 Actor 列表。
搭建指南的表格格式(4 种变体 + 特殊表格):
- 结构体表: # / Actor名称 / 资产 / 位置 / [旋转] / [缩放] / 材质
- 布景表: # / Actor名称 / 资产 / 位置 / [缩放] / 材质 / 说明
- 交互物表: # / Actor名称 / 位置 / 触发半径 / [交互类型] / 引用
- 收集品表: # / Actor名称 / 位置 / 稀有度 / 引用
- 边界表: # / Actor名称 / 资产 / 位置 / [旋转] / [缩放] / 材质
- 光照表: # / Actor名称 / 类型 / 位置 / 参数
"""
import json
import re
import logging
from pathlib import Path
from dataclasses import dataclass, field, asdict
from typing import Optional
from parsers.markdown_table import (
parse_table,
parse_location,
parse_rotation,
parse_scale,
clean_cell,
)
logger = logging.getLogger("level_builder.parser")
# ============================================================
# 数据结构
# ============================================================
@dataclass
class ActorData:
"""单个 Actor 的完整规格。"""
actor_name: str
asset_key: str = ""
asset_type: str = "static_mesh" # static_mesh | player_start | directional_light | sky_light | point_light
category: str = "" # STR | PROP | INTR | COLL | BND | LGT | PLAY
zone_id: str = "" # Z1-Z7 | GLOBAL
location: tuple = (0.0, 0.0, 0.0)
rotation: tuple = (0.0, 0.0, 0.0)
scale: tuple = (1.0, 1.0, 1.0)
material: str = ""
folder: str = ""
note: str = ""
trigger_radius: float = 0.0
interact_type: str = ""
rarity: str = ""
source_ref: str = ""
# ============================================================
# 标题/上下文检测
# ============================================================
# 区域标题匹配:### C1. Z1 — 家(Home)
ZONE_HEADING = re.compile(
r'###\s+C\d+\.\s+(Z\d+)\s*[—\-–]\s*(.+?)(?:((.+)))?$'
)
# 分类子标题匹配:#### C1.1 结构体(Structure)
CATEGORY_HEADING = re.compile(
r'####\s+C\d+\.\d+\s+(结构体|布景|交互物|收集品|布景物)'
)
# Phase 标题
PHASE_HEADING = re.compile(r'^##\s+(二|三|四|五|六|七|八)')
# 边界区域标题
BOUNDARY_HEADING = re.compile(r'####\s+(西边界|东边界|北边界|南边界)')
# 全局物件标题
GLOBAL_HEADING = re.compile(r'###\s+G-(\d+|E\d+)\s+(.+)')
# 光照标题
LIGHT_HEADING = re.compile(r'###\s+D\d+\s+(.+)')
# 地面子标题
FLOOR_HEADING = re.compile(r'####\s+Z\d+\s+(.+?)\s+地面')
CATEGORY_MAP = {
"结构体": "STR",
"布景": "PROP",
"布景物": "PROP",
"交互物": "INTR",
"交互物标记": "INTR",
"收集品": "COLL",
"收集品标记": "COLL",
}
ZONE_ID_MAP = {
"Z1": "Z1", "Z2": "Z2", "Z3": "Z3",
"Z4": "Z4", "Z5": "Z5", "Z6": "Z6", "Z7": "Z7",
}
# ============================================================
# 核心解析逻辑
# ============================================================
class BuildGuideParser:
"""搭建指南解析器。"""
def __init__(self):
self.actors: list[ActorData] = []
self.current_zone = ""
self.current_category = ""
self.current_phase = ""
self.metadata = {}
def parse_file(self, filepath: str) -> dict:
"""解析搭建指南文件,返回完整的关卡数据。"""
path = Path(filepath)
if not path.exists():
raise FileNotFoundError(f"搭建指南文件不存在: {filepath}")
text = path.read_text(encoding="utf-8")
return self.parse_text(text)
def parse_text(self, text: str) -> dict:
"""解析搭建指南文本。"""
lines = text.split("\n")
# 解析全局参数(第〇章)
self._parse_global_info(lines)
# 解析资产映射表(第一章)— 可选,用于验证
# 逐 Phase 解析
self._parse_phase_ground(lines)
self._parse_phase_boundaries(lines)
self._parse_phase_zones(lines)
self._parse_phase_global_objects(lines)
self._parse_phase_lighting(lines)
# 添加 Player Start
self._add_player_start()
# 去重:检测同名称 Actor,保留后出现的
self._deduplicate_actors()
# 构建输出
result = {
"metadata": self.metadata,
"actors": [asdict(a) for a in self.actors],
"summary": self._build_summary(),
}
return result
# ----------------------------------------------------------
# 全局参数
# ----------------------------------------------------------
def _parse_global_info(self, lines: list[str]):
"""解析场景全局参数。"""
self.metadata = {
"source": "关卡-v01-搭建指南.md",
"scene_size": [8000, 6000],
"ground_z": 0,
"character_height": 120,
"camera_height": 100,
}
# ----------------------------------------------------------
# Phase B — 地面
# ----------------------------------------------------------
def _parse_phase_ground(self, lines: list[str]):
"""解析 Phase B 地面铺设表格。仅处理 B1 铺设地面段,不处理 B2 边界段。"""
# 找到地面铺设段起始
b_start = self._find_section_start(lines, "铺设地面")
if b_start < 0:
b_start = self._find_section_start(lines, "Phase B")
# 地面段在边界段前结束
b_end = self._find_section_start(lines, "放置边界围挡")
if b_end < 0:
b_end = self._find_section_start(lines, "Phase C")
if b_start < 0:
return
if b_end < 0:
b_end = len(lines)
section_text = "\n".join(lines[b_start:b_end])
# 地面子区域标题:#### Z1 家 地面(MI_DevGray)或 #### 全局地面(MI_DevGray)
floor_pattern = re.compile(
r'####\s+Z(\d+)\s+\S+\s+地面(?:((.+?)))?'
)
global_floor_pattern = re.compile(
r'####\s+全局地面(?:((.+?)))?'
)
current_zone = ""
current_material = ""
section_lines = lines[b_start:b_end]
i = 0
while i < len(section_lines):
line = section_lines[i].strip()
# 检测地面子区域
fm = floor_pattern.match(line)
if fm:
current_zone = f"Z{fm.group(1)}"
current_material = fm.group(2) or ""
i += 1
continue
# 检测全局地面
gm = global_floor_pattern.match(line)
if gm:
current_zone = "GLOBAL"
current_material = gm.group(1) or "MI_DevGray"
i += 1
continue
# 检测水面区域
if '水面' in line and current_zone:
current_material = "MI_DevSkyBlue"
i += 1
continue
# 检测表格
if '|' in line and '#' in line:
table_lines = []
while i < len(section_lines) and '|' in section_lines[i]:
table_lines.append(section_lines[i])
i += 1
rows = parse_table(table_lines)
if rows:
for row in rows:
actor = self._parse_floor_row(row, current_zone, current_material)
if actor:
self.actors.append(actor)
continue
i += 1
def _parse_floor_row(self, row: dict, zone: str, default_material: str) -> Optional[ActorData]:
"""解析地面表格行。"""
name = clean_cell(row.get("Actor 名称", row.get("Actor名称", "")))
if not name:
return None
asset_key = clean_cell(row.get("资产", ""))
location = parse_location(row.get("位置 (X,Y,Z)", row.get("位置", "")))
scale = parse_scale(row.get("缩放", "—"))
material = clean_cell(row.get("材质", ""))
if not material:
material = default_material
return ActorData(
actor_name=name,
asset_key=asset_key,
asset_type="static_mesh",
category="STR",
zone_id=zone,
location=location,
scale=scale,
material=material,
folder=self._get_folder(zone, "STR"),
)
# ----------------------------------------------------------
# Phase B — 边界
# ----------------------------------------------------------
def _parse_phase_boundaries(self, lines: list[str]):
"""解析 Phase B 边界围挡。"""
b_start = self._find_section_start(lines, "放置边界围挡")
b_end = self._find_section_start(lines, "Phase C")
if b_start < 0:
return
if b_end < 0:
b_end = len(lines)
section_lines = lines[b_start:b_end]
i = 0
while i < len(section_lines):
line = section_lines[i].strip()
# 边界子区域标题
bm = BOUNDARY_HEADING.match(line)
# 检测表格
if '|' in line:
table_lines = []
while i < len(section_lines) and '|' in section_lines[i]:
table_lines.append(section_lines[i])
i += 1
rows = parse_table(table_lines)
if rows:
for row in rows:
actors = self._parse_boundary_row(row)
self.actors.extend(actors)
continue
i += 1
def _parse_boundary_row(self, row: dict) -> list[ActorData]:
"""解析边界表格行,处理范围格式如 '1-15 | LVL_BND_East_01~15'。"""
name_col = row.get("Actor 名称", row.get("Actor名称", ""))
name = clean_cell(name_col)
# 处理范围格式:名称中含 ~ 或编号含 -
if '~' in name:
return self._expand_range_row(row, name)
asset_key = clean_cell(row.get("资产", ""))
location = parse_location(row.get("位置 (X,Y,Z)", row.get("位置", "")))
rotation = parse_rotation(
row.get("旋转 (Pitch,Yaw,Roll)")
or row.get("旋转 (P,Y,R)")
or row.get("旋转", "(0,0,0)")
)
scale = parse_scale(row.get("缩放", "—"))
material = clean_cell(row.get("材质", ""))
actor = ActorData(
actor_name=name,
asset_key=asset_key,
category="BND",
zone_id="GLOBAL",
location=location,
rotation=rotation,
scale=scale,
material=material,
folder=self._get_folder("GLOBAL", "BND"),
)
return [actor]
def _expand_range_row(self, row: dict, name: str) -> list[ActorData]:
"""展开范围格式行。如 'LVL_BND_East_01~15' → 15 个 Actor。"""
asset_key = clean_cell(row.get("资产", ""))
rotation = parse_rotation(
row.get("旋转 (Pitch,Yaw,Roll)")
or row.get("旋转 (P,Y,R)")
or row.get("旋转", "(0,0,0)")
)
scale = parse_scale(row.get("缩放", "—"))
material = clean_cell(row.get("材质", ""))
# 解析名称范围:LVL_BND_East_01~15
m = re.match(r'(.+?)_(\d+)~(\d+)', name)
if not m:
return []
prefix = m.group(1)
start = int(m.group(2))
end = int(m.group(3))
# 解析位置表达式 — 格式: (X, Y_expr, Z) n=start~end
# 例: (8000, 200+400n, 0) n=0~14
loc_col = row.get("位置 (X,Y,Z)", row.get("位置", ""))
loc_col = clean_cell(loc_col)
# 提取 n 的范围
n_match = re.search(r'n\s*=\s*(\d+)~(\d+)', loc_col)
n_start = int(n_match.group(1)) if n_match else 0
n_end = int(n_match.group(2)) if n_match else (end - start)
# 去掉 n=xx~yy 后缀以便解析坐标
loc_clean = re.sub(r'\s*n\s*=\s*\d+~\d+\s*', '', loc_col).strip()
# 提取三个坐标表达式
coord_match = re.match(r'\((.+?)\s*,\s*(.+?)\s*,\s*(.+?)\)', loc_clean)
if not coord_match:
return []
x_expr = coord_match.group(1).strip()
y_expr = coord_match.group(2).strip()
z_expr = coord_match.group(3).strip()
actors = []
for idx, n in enumerate(range(n_start, n_end + 1)):
x = self._eval_coord_expr(x_expr, n)
y = self._eval_coord_expr(y_expr, n)
z = self._eval_coord_expr(z_expr, n)
actor_num = start + idx
actor_name = f"{prefix}_{actor_num:02d}"
actor = ActorData(
actor_name=actor_name,
asset_key=asset_key,
category="BND",
zone_id="GLOBAL",
location=(x, y, z),
rotation=rotation,
scale=scale,
material=material,
folder=self._get_folder("GLOBAL", "BND"),
)
actors.append(actor)
return actors
def _eval_coord_expr(self, expr: str, n: int) -> float:
"""计算坐标表达式。如 '200+400n' → 200+400*n。"""
expr = expr.strip()
# 简单情况:纯数字
try:
return float(expr)
except ValueError:
pass
# 含 n 的表达式:200+400n
expr = expr.replace('n', f'*{n}')
# 处理连续运算符:*+→+, *-→-
expr = expr.replace('*+', '+').replace('*-', '-')
try:
# 安全计算简单算术
result = eval(expr) # 受限输入,仅数字和基本运算
return float(result)
except Exception:
return 0.0
# ----------------------------------------------------------
# Phase C — 逐区域搭建
# ----------------------------------------------------------
def _parse_phase_zones(self, lines: list[str]):
"""解析 Phase C 逐区域搭建。"""
c_start = self._find_section_start(lines, "逐区域搭建")
c_end = self._find_section_start(lines, "Phase C — 全局物件")
if c_start < 0:
c_start = self._find_section_start(lines, "全局物件")
if c_start < 0:
return
if c_end < 0:
c_end = len(lines)
section_lines = lines[c_start:c_end]
current_zone = ""
current_category = ""
i = 0
while i < len(section_lines):
line = section_lines[i].strip()
# 检测区域标题
zm = ZONE_HEADING.match(line)
if zm:
current_zone = zm.group(1)
i += 1
continue
# 检测分类标题
cm = CATEGORY_HEADING.match(line)
if cm:
cat_name = cm.group(1)
current_category = CATEGORY_MAP.get(cat_name, "")
i += 1
continue
# 检测结构体子标题(如 "C1.1 结构体(Structure)")
sub_match = re.match(r'####\s+C\d+\.\d+\s+(结构体|布景|布景物|交互物|交互物标记|收集品|收集品标记)', line)
if sub_match:
cat_name = sub_match.group(1)
current_category = CATEGORY_MAP.get(cat_name, "")
i += 1
continue
# 处理表格
if '|' in line:
table_lines = []
while i < len(section_lines) and '|' in section_lines[i]:
table_lines.append(section_lines[i])
i += 1
rows = parse_table(table_lines)
if rows:
for row in rows:
actor = self._parse_zone_row(row, current_zone, current_category)
if actor:
self.actors.append(actor)
continue
i += 1
def _parse_zone_row(self, row: dict, zone: str, category: str) -> Optional[ActorData]:
"""解析区域内的 Actor 行。根据列内容判断表格类型。"""
name = clean_cell(row.get("Actor 名称", row.get("Actor名称", "")))
if not name:
return None
# 判断表格类型:有"资产"列 → 结构体/布景;有"触发半径" → 交互物;有"稀有度" → 收集品
has_asset = "资产" in row and clean_cell(row.get("资产", ""))
has_trigger = "触发半径" in row or "触发半径 (cm)" in row
has_rarity = "稀有度" in row
if has_trigger and not has_asset:
# 交互物表
return self._parse_interactable_row(row, zone)
elif has_rarity and not has_asset:
# 收集品表
return self._parse_collectible_row(row, zone)
else:
# 结构体/布景表
return self._parse_structure_or_prop_row(row, zone, category)
def _parse_structure_or_prop_row(self, row: dict, zone: str, category: str) -> Optional[ActorData]:
"""解析结构体或布景表格行。"""
name = clean_cell(row.get("Actor 名称", row.get("Actor名称", "")))
asset_key = clean_cell(row.get("资产", ""))
location = parse_location(
row.get("位置 (X,Y,Z)", row.get("位置", ""))
)
# 旋转:结构体表可能有,布景表可能没有
# 兼容多种列名格式:(Pitch,Yaw,Roll)、(P,Y,R)、无后缀
rot_col = (row.get("旋转 (Pitch,Yaw,Roll)")
or row.get("旋转 (P,Y,R)")
or row.get("旋转", ""))
rotation = parse_rotation(rot_col) if rot_col else (0.0, 0.0, 0.0)
# 缩放
scale = parse_scale(row.get("缩放", "—"))
# 材质
material = clean_cell(row.get("材质", ""))
if material in ('—', '–', '-'):
material = ""
# 说明
note = clean_cell(row.get("说明", ""))
if not category:
category = "STR"
return ActorData(
actor_name=name,
asset_key=asset_key,
category=category,
zone_id=zone,
location=location,
rotation=rotation,
scale=scale,
material=material,
folder=self._get_folder(zone, category),
note=note,
)
def _parse_interactable_row(self, row: dict, zone: str) -> ActorData:
"""解析交互物行。用占位 Cube 表示。"""
name = clean_cell(row.get("Actor 名称", row.get("Actor名称", "")))
location = parse_location(
row.get("位置 (X,Y,Z)", row.get("位置", ""))
)
trigger = row.get("触发半径", row.get("触发半径 (cm)", "150"))
try:
trigger_radius = float(trigger)
except (ValueError, TypeError):
trigger_radius = 150.0
ref = clean_cell(row.get("引用", ""))
interact_type = clean_cell(row.get("交互类型", ""))
return ActorData(
actor_name=name,
asset_key="Cube",
category="INTR",
zone_id=zone,
location=location,
scale=(0.3, 0.3, 0.3),
material="MI_DevYellow",
folder=self._get_folder(zone, "INTR"),
trigger_radius=trigger_radius,
interact_type=interact_type,
source_ref=ref,
note="交互物占位标记",
)
def _parse_collectible_row(self, row: dict, zone: str) -> ActorData:
"""解析收集品行。用占位 Sphere/Cube 表示。"""
name = clean_cell(row.get("Actor 名称", row.get("Actor名称", "")))
location = parse_location(
row.get("位置 (X,Y,Z)", row.get("位置", ""))
)
rarity = clean_cell(row.get("稀有度", "普通"))
ref = clean_cell(row.get("引用", ""))
# 稀有用金色,普通用白色
material = "MI_DevYellow" if rarity == "稀有" else "MI_DevWhite"
return ActorData(
actor_name=name,
asset_key="CirclePlatform",
category="COLL",
zone_id=zone,
location=location,
scale=(0.03, 0.03, 0.03),
material=material,
folder=self._get_folder(zone, "COLL"),
rarity=rarity,
source_ref=ref,
note="收集品占位标记",
)
# ----------------------------------------------------------
# Phase C — 全局物件
# ----------------------------------------------------------
def _parse_phase_global_objects(self, lines: list[str]):
"""解析全局物件(路灯、垃圾桶等)。"""
g_start = self._find_section_start(lines, "全局物件")
g_end = self._find_section_start(lines, "Phase D")
if g_start < 0:
return
if g_end < 0:
g_end = len(lines)
section_lines = lines[g_start:g_end]
i = 0
while i < len(section_lines):
line = section_lines[i].strip()
if '|' in line:
table_lines = []
while i < len(section_lines) and '|' in section_lines[i]:
table_lines.append(section_lines[i])
i += 1
rows = parse_table(table_lines)
if rows:
for row in rows:
actor = self._parse_global_row(row)
if actor:
self.actors.append(actor)
continue
i += 1
def _parse_global_row(self, row: dict) -> Optional[ActorData]:
"""解析全局物件行。"""
name = clean_cell(row.get("Actor 名称", row.get("Actor名称", "")))
if not name:
return None
asset_key = clean_cell(row.get("资产", ""))
location = parse_location(row.get("位置 (X,Y,Z)", row.get("位置", "")))
material = clean_cell(row.get("材质", ""))
if material in ('—', '–', '-'):
material = ""
# 从 Actor 名称推断类别
category = "PROP"
if "_LGT_" in name:
category = "LGT"
elif "_PROP_" in name:
category = "PROP"
zone_id = self._infer_zone_from_name(name)
return ActorData(
actor_name=name,
asset_key=asset_key,
category=category,
zone_id=zone_id,
location=location,
material=material,
folder=self._get_folder(zone_id, category),
)
# ----------------------------------------------------------
# Phase D — 光照
# ----------------------------------------------------------
def _parse_phase_lighting(self, lines: list[str]):
"""解析光照配置。"""
d_start = self._find_section_start(lines, "Phase D")
d_end = self._find_section_start(lines, "Phase E")
if d_start < 0:
return
if d_end < 0:
d_end = len(lines)
section_lines = lines[d_start:d_end]
# 全局光源:DirectionalLight + SkyLight
self._parse_global_lights(section_lines)
# 路灯点光源
self._parse_street_lights(section_lines)
def _parse_global_lights(self, lines: list[str]):
"""解析全局光源表格。"""
text = "\n".join(lines)
# Directional Light 和 Sky Light
if "Directional Light" in text or "DirectionalLight" in text:
self.actors.append(ActorData(
actor_name="LVL_Global_DirectionalLight",
asset_key="",
asset_type="directional_light",
category="LGT",
zone_id="GLOBAL",
location=(0, 0, 500),
rotation=(-45, 315, 0),
folder=self._get_folder("GLOBAL", "LGT"),
note="色温~5500K暖白;从西南照射;强度中等",
))
if "Sky Light" in text or "SkyLight" in text:
self.actors.append(ActorData(
actor_name="LVL_Global_SkyLight",
asset_key="",
asset_type="sky_light",
category="LGT",
zone_id="GLOBAL",
location=(0, 0, 0),
folder=self._get_folder("GLOBAL", "LGT"),
note="强度低-中;环境补光",
))
def _parse_street_lights(self, lines: list[str]):
"""解析路灯点光源表格。"""
i = 0
while i < len(lines):
line = lines[i].strip()
# 检测表格起始(含 | 的行)
if '|' in line:
table_lines = []
while i < len(lines) and '|' in lines[i]:
table_lines.append(lines[i])
i += 1
# 检查表格内容是否包含 Point Light
table_text = "\n".join(table_lines)
if "Point" in table_text or "point_light" in table_text.lower():
rows = parse_table(table_lines)
for row in rows:
name = clean_cell(row.get("Actor 名称", row.get("Actor名称", "")))
loc_col = row.get("位置 (X,Y,Z)", row.get("位置", ""))
location = parse_location(loc_col)
if name:
self.actors.append(ActorData(
actor_name=name,
asset_key="",
asset_type="point_light",
category="LGT",
zone_id="Z3",
location=location,
folder=self._get_folder("GLOBAL", "LGT"),
note="暖黄色,低强度",
))
continue
i += 1
# ----------------------------------------------------------
# Player Start
# ----------------------------------------------------------
def _add_player_start(self):
"""添加 Player Start Actor。"""
self.actors.append(ActorData(
actor_name="LVL_PlayerStart",
asset_key="",
asset_type="player_start",
category="PLAY",
zone_id="GLOBAL",
location=(500, 720, 0),
rotation=(0, 0, 0),
folder=self._get_folder("GLOBAL", "PLAY"),
note="Z1 院内院门内侧约60cm,面向+Y(北)",
))
# ----------------------------------------------------------
# 去重
# ----------------------------------------------------------
def _deduplicate_actors(self):
"""去除同名称的重复 Actor,保留后出现的(后出现的通常是更完整的定义)。"""
seen = {}
unique = []
for actor in self.actors:
name = actor.actor_name
if name in seen:
# 同名但位置不同——可能是合法的同名(如 Z3 墙段),保留
prev = seen[name]
if prev.location == actor.location and prev.asset_key == actor.asset_key:
logger.warning(f"去重:移除重复 Actor '{name}' (loc={actor.location})")
continue # 跳过完全重复的
else:
# 同名但不同位置/资产——合法,保留
unique.append(actor)
else:
seen[name] = actor
unique.append(actor)
removed = len(self.actors) - len(unique)
if removed > 0:
logger.info(f"去重完成:移除 {removed} 个重复 Actor")
self.actors = unique
# ----------------------------------------------------------
# 辅助方法
# ----------------------------------------------------------
def _find_section_start(self, lines: list[str], keyword: str) -> int:
"""找到包含关键词的标题行。"""
for i, line in enumerate(lines):
if keyword in line:
return i
return -1
def _infer_zone_from_name(self, name: str) -> str:
"""从 Actor 名称推断区域 ID。"""
m = re.match(r'LVL_(Z\d+)_', name)
if m:
return m.group(1)
if 'BND' in name:
return "GLOBAL"
if 'Global' in name:
return "GLOBAL"
return "GLOBAL"
def _get_folder(self, zone_id: str, category: str) -> str:
"""获取 Outliner 文件夹路径。"""
from config.zone_config import get_folder_path
return get_folder_path(zone_id, category)
def _build_summary(self) -> dict:
"""构建解析摘要。"""
total = len(self.actors)
by_category = {}
by_zone = {}
asset_keys = set()
for a in self.actors:
by_category[a.category] = by_category.get(a.category, 0) + 1
by_zone[a.zone_id] = by_zone.get(a.zone_id, 0) + 1
if a.asset_key:
asset_keys.add(a.asset_key)
return {
"total_actors": total,
"by_category": by_category,
"by_zone": by_zone,
"unique_assets": len(asset_keys),
"asset_list": sorted(asset_keys),
}