"""搭建指南 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() # 构建输出 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 地面铺设表格。""" text = "\n".join(lines) # 找到 Phase B 区域 b_start = self._find_section_start(lines, "Phase B") 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) floor_pattern = re.compile( r'####\s+Z(\d+)\s+\S+\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 # 检测水面区域 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)", 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)", 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("位置", "")) ) # 旋转:结构体表可能有,布景表可能没有 rot_col = row.get("旋转 (Pitch,Yaw,Roll)", 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 _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), }