841 lines
30 KiB
Python
841 lines
30 KiB
Python
"""搭建指南 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("位置", "")))
|
||
|
||
# 旋转(可选)
|
||
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 = ""
|
||
|
||
# 从 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,
|
||
rotation=rotation,
|
||
scale=scale,
|
||
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),
|
||
}
|