"""关卡执行案 Markdown 解析器。 解析 docs/design/关卡-vXX-<名称>-执行案.md,提取结构化数据供后续转换为搭建指南。 执行案的表格格式(简化版,无交互物/收集品): - 区域总览表: 区域编号 / 区域名称 / 绝对坐标范围 (X,Y) / 尺寸 / 地面材质建议 / 标识色 - 交通流线表: 流线路径 / 宽度 / 说明 - 结构体表: 编号 / 构件名称 / 尺寸 / 相对原点偏移 / BSP 资产建议 / 标识色 - 布景表: 编号 / 物件名称 / 尺寸 / 相对原点偏移 / BSP 资产建议 / 说明 - 组合资产表: 编号 / 物件名称 / 期望尺寸 / 推荐拼搭方案 / 说明 - 全局物件表: 编号 / 物件名称 / 数量 / 放置位置 / BSP 资产建议 / 说明 - 边界表: 边界段 / 位置范围 / 遮挡方式 / BSP 资产建议 / 标识色 - 光照表: 光源类型 / 数量 / 位置/方向 / 参数建议 / 说明 """ import re import logging from pathlib import Path from dataclasses import dataclass, field from typing import Optional # 允许从包内或直接运行 try: from parsers.markdown_table import parse_table, clean_cell except ImportError: import sys sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "level-builder")) from parsers.markdown_table import parse_table, clean_cell logger = logging.getLogger("execution_to_guide.parser") # ============================================================ # 数据结构 # ============================================================ @dataclass class ZoneInfo: """区域信息。""" zone_id: str # Z1, Z2, ... zone_name: str # 家、小卖部、... origin: tuple # (X, Y, Z) 区域西南角绝对坐标 bounds_x: tuple # (X_min, X_max) bounds_y: tuple # (Y_min, Y_max) size_m: tuple # (宽m, 深m) ground_material: str = "" # 地面材质建议 tag_color: str = "" # 标识色 MI_DevXxx height: float = 0 # 建筑高度(cm) wall_height: float = 250 # 围墙高度 door_width: float = 120 # 门洞宽度 door_height: float = 220 # 门洞高度 indoor_clear_height: float = 280 # 室内净高 eaves_height: float = 300 # 屋檐高度 @dataclass class StructureItem: """结构体条目(墙/地/顶/门/窗等建筑构件)。""" item_id: str # Z1-S01 name: str # 南围墙 size: tuple # (宽, 深, 高) cm offset: tuple # (ΔX, ΔY, ΔZ) 相对区域原点 bsp_suggestion: str # Wall_400x300 x 2 tag_color: str = "" # MI_DevRed @dataclass class PropItem: """布景物条目(家具/道具/装饰)。""" item_id: str # Z1-P01 name: str # 旧自行车 size: tuple # (宽, 深, 高) cm(约) offset: tuple # (ΔX, ΔY, ΔZ) 相对区域原点 bsp_suggestion: str # Car01 缩放50% note: str = "" # 靠墙停放 @dataclass class CompositeItem: """组合资产条目(需 Primitives 拼搭)。""" item_id: str # Z1-C01 name: str # 旧收音机 target_size: tuple # (宽, 深, 高) cm(约) build_plan: str # Cube(0.2,0.15,0.12) + Cylinder天线 note: str = "" # 放在柜台上 @dataclass class GlobalObject: """全局物件。""" item_id: str # G-01 / G-E01 name: str count: int location: tuple # (X, Y, Z) 绝对坐标 bsp_suggestion: str note: str = "" @dataclass class BoundaryItem: """场景边界。""" segment: str # 西边界/南边界/东边界/北边界 location_range: str block_method: str # 围墙/自然遮挡 bsp_suggestion: str tag_color: str = "" @dataclass class LightItem: """光照建议。""" light_type: str # 定向光/天空光/路灯 count: int position: str # 位置/方向描述 params: str # 参数建议 note: str = "" @dataclass class TrafficFlow: """交通流线。""" path: str # Z1 → Z3 家门 → 街道 width: float # cm note: str = "" @dataclass class ExecutionPlanData: """执行案解析后的完整数据。""" version: str = "" scene_name: str = "" scene_size: tuple = (0, 0) # (宽cm, 深cm) ground_z: float = 0 character_height: float = 120 camera_height: float = 100 time_setting: str = "" zones: list = field(default_factory=list) # list[ZoneInfo] flows: list = field(default_factory=list) # list[TrafficFlow] zone_structures: dict = field(default_factory=dict) # zone_id -> list[StructureItem] zone_props: dict = field(default_factory=dict) # zone_id -> list[PropItem] zone_composites: dict = field(default_factory=dict) # zone_id -> list[CompositeItem] global_objects: list = field(default_factory=list) # list[GlobalObject] boundaries: list = field(default_factory=list) # list[BoundaryItem] lights: list = field(default_factory=list) # list[LightItem] # ============================================================ # 解析逻辑 # ============================================================ class ExecutionPlanParser: """关卡执行案解析器。""" def __init__(self): self.data = ExecutionPlanData() def parse_file(self, filepath: str) -> ExecutionPlanData: """解析执行案文件。""" 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) -> ExecutionPlanData: """解析执行案文本。""" lines = text.split("\n") # 解析标题 → 版本和场景名 self._parse_title(lines) # 一、场景参数 self._parse_scene_params(lines) # 二、区域坐标布局 self._parse_zone_layout(lines) # 三、逐区域搭建指南 self._parse_zone_details(lines) # 四、全局物件清单 self._parse_global_objects(lines) # 五、场景边界与遮挡 self._parse_boundaries(lines) # 六、光照建议 self._parse_lighting(lines) return self.data # ---------------------------------------------------------- # 标题 # ---------------------------------------------------------- def _parse_title(self, lines: list[str]): """从标题提取版本号和场景名。如:# v0.1 关卡执行案 — 城郊小街""" for line in lines: m = re.match(r'#\s+v([\d.]+)\s+关卡执行案\s*[—\-–]\s*(.+)', line) if m: self.data.version = m.group(1) self.data.scene_name = m.group(2).strip() break # ---------------------------------------------------------- # 一、场景参数 # ---------------------------------------------------------- def _parse_scene_params(self, lines: list[str]): """解析场景参数表。""" section_start = self._find_section(lines, "一、场景参数") section_end = self._find_section(lines, "二、区域坐标布局") if section_start < 0: return if section_end < 0: section_end = len(lines) section_lines = lines[section_start:section_end] tables = self._extract_tables(section_lines) if not tables: return for row in tables[0]: param = clean_cell(row.get("参数", "")) value = clean_cell(row.get("值", "")) if not param: continue if "场景总尺寸" in param: size_match = re.search(r'(\d+)\s*cm\s*[x×]\s*(\d+)\s*cm', value) if size_match: self.data.scene_size = (int(size_match.group(1)), int(size_match.group(2))) elif "地面高度基准" in param: z_match = re.search(r'Z?\s*=\s*(-?\d+)', value) if z_match: self.data.ground_z = float(z_match.group(1)) elif "主角身高" in param: h_match = re.search(r'~?(\d+)', value) if h_match: self.data.character_height = float(h_match.group(1)) elif "视角高度" in param: h_match = re.search(r'~?(\d+)', value) if h_match: self.data.camera_height = float(h_match.group(1)) elif "时间设定" in param: self.data.time_setting = value # ---------------------------------------------------------- # 二、区域坐标布局 # ---------------------------------------------------------- def _parse_zone_layout(self, lines: list[str]): """解析区域总览表和交通流线表。""" section_start = self._find_section(lines, "二、区域坐标布局") section_end = self._find_section(lines, "三、逐区域搭建指南") if section_start < 0: return if section_end < 0: section_end = len(lines) section_lines = lines[section_start:section_end] tables = self._extract_tables(section_lines) # 第一个表格 = 区域总览表 if tables: for row in tables[0]: zone = self._parse_zone_row(row) if zone: self.data.zones.append(zone) # 第二个表格 = 交通流线(如果有) if len(tables) > 1: for row in tables[1]: path = clean_cell(row.get("流线路径", "")) if not path: continue width_str = clean_cell(row.get("宽度", "200")) w_match = re.search(r'(\d+)', width_str) width = float(w_match.group(1)) if w_match else 200.0 note = clean_cell(row.get("说明", "")) self.data.flows.append(TrafficFlow( path=path, width=width, note=note )) def _parse_zone_row(self, row: dict) -> Optional[ZoneInfo]: """解析区域总览表的一行。""" zone_id = clean_cell(row.get("区域编号", "")) zone_name = clean_cell(row.get("区域名称", "")) coord_str = clean_cell(row.get("绝对坐标范围 (X,Y)", row.get("绝对坐标范围", ""))) if not zone_id or not zone_name: return None if not re.match(r'Z\d+', zone_id): return None # 解析坐标范围:(X1,Y1)→(X2,Y2) coord_match = re.match( r'\((\d+)\s*,\s*(\d+)\)\s*[→\-–]+\s*\((\d+)\s*,\s*(\d+)\)', coord_str ) if not coord_match: return None x_min, y_min = int(coord_match.group(1)), int(coord_match.group(2)) x_max, y_max = int(coord_match.group(3)), int(coord_match.group(4)) # 解析尺寸 size_str = clean_cell(row.get("尺寸 (宽x深)", row.get("尺寸", ""))) size_match = re.search(r'(\d+)\s*m?\s*[x×]\s*(\d+)\s*m?', size_str) size_m = (float(size_match.group(1)), float(size_match.group(2))) if size_match else (0, 0) ground_mat = clean_cell(row.get("地面材质建议", "")) tag_color = clean_cell(row.get("标识色", "")) return ZoneInfo( zone_id=zone_id, zone_name=zone_name, origin=(x_min, y_min, 0), bounds_x=(x_min, x_max), bounds_y=(y_min, y_max), size_m=size_m, ground_material=ground_mat, tag_color=tag_color, ) # ---------------------------------------------------------- # 三、逐区域搭建指南 # ---------------------------------------------------------- def _parse_zone_details(self, lines: list[str]): """解析逐区域搭建指南(结构体/布景/组合资产)。""" section_start = self._find_section(lines, "三、逐区域搭建指南") section_end = self._find_section(lines, "四、全局物件清单") if section_start < 0: return if section_end < 0: section_end = len(lines) section_lines = lines[section_start:section_end] current_zone = "" current_zone_info = None i = 0 while i < len(section_lines): line = section_lines[i].strip() # 检测区域标题:### Z1:家(Home)或 ### Z1: 家 或 ### Z1:家 zm = re.match(r'###\s+(Z\d+)\s*[::]\s*(.+?)(?:\s*[((].+[)])?\s*$', line) if zm: current_zone = zm.group(1) current_zone_info = self._find_zone(current_zone) i += 1 continue # 检测区域绝对原点行:**区域绝对原点**:(X, Y, Z) if "区域绝对原点" in line and current_zone: origin_match = re.search(r'\((\d+)\s*,\s*(\d+)\s*,\s*(\d+)\)', line) if origin_match and current_zone_info: current_zone_info.origin = ( int(origin_match.group(1)), int(origin_match.group(2)), int(origin_match.group(3)), ) # 检测区域参数子标题 if "区域参数" in line: i += 1 i = self._skip_blank(section_lines, i) rows = self._read_table(section_lines, i) if rows: i = self._advance_past_table(section_lines, i) if current_zone_info: self._parse_zone_params(rows, current_zone_info) continue # 检测结构体子标题 if "结构体清单" in line: i += 1 i = self._skip_blank(section_lines, i) rows = self._read_table(section_lines, i) if rows and current_zone: items = [self._parse_structure_row(r) for r in rows] items = [it for it in items if it] self.data.zone_structures[current_zone] = items i = self._advance_past_table(section_lines, i) continue # 检测布景物子标题 if "布景物清单" in line: i += 1 i = self._skip_blank(section_lines, i) rows = self._read_table(section_lines, i) if rows and current_zone: items = [self._parse_prop_row(r) for r in rows] items = [it for it in items if it] self.data.zone_props[current_zone] = items i = self._advance_past_table(section_lines, i) continue # 检测组合资产子标题 if "组合资产标记" in line: i += 1 i = self._skip_blank(section_lines, i) rows = self._read_table(section_lines, i) if rows and current_zone: items = [self._parse_composite_row(r) for r in rows] items = [it for it in items if it] self.data.zone_composites[current_zone] = items i = self._advance_past_table(section_lines, i) continue i += 1 def _parse_zone_params(self, rows: list[dict], zone: ZoneInfo): """解析区域参数表。""" for row in rows: param = clean_cell(row.get("参数", "")) value = clean_cell(row.get("值", "")) if "围墙" in param or "外墙高度" in param: m = re.search(r'(\d+)', value) if m: zone.wall_height = float(m.group(1)) elif "门洞宽度" in param: m = re.search(r'(\d+)', value) if m: zone.door_width = float(m.group(1)) elif "门洞高度" in param: m = re.search(r'(\d+)', value) if m: zone.door_height = float(m.group(1)) elif "室内净高" in param: m = re.search(r'(\d+)', value) if m: zone.indoor_clear_height = float(m.group(1)) elif "屋檐高度" in param: m = re.search(r'(\d+)', value) if m: zone.eaves_height = float(m.group(1)) def _parse_structure_row(self, row: dict) -> Optional[StructureItem]: """解析结构体表行。""" item_id = clean_cell(row.get("编号", "")) name = clean_cell(row.get("构件名称", "")) if not item_id or not name: return None size_str = clean_cell(row.get("尺寸 (宽x深x高, cm)", row.get("尺寸", ""))) size = self._parse_size(size_str) offset_str = clean_cell(row.get("相对原点偏移 (ΔX,ΔY,ΔZ)", row.get("相对原点偏移", ""))) offset = self._parse_xyz(offset_str) bsp = clean_cell(row.get("BSP 资产建议", "")) tag = clean_cell(row.get("标识色", "")) return StructureItem( item_id=item_id, name=name, size=size, offset=offset, bsp_suggestion=bsp, tag_color=tag ) def _parse_prop_row(self, row: dict) -> Optional[PropItem]: """解析布景表行。""" item_id = clean_cell(row.get("编号", "")) name = clean_cell(row.get("物件名称", "")) if not item_id or not name: return None size_str = clean_cell(row.get("尺寸 (约, cm)", row.get("尺寸", ""))) size = self._parse_size(size_str) offset_str = clean_cell(row.get("相对原点偏移 (ΔX,ΔY,ΔZ)", row.get("相对原点偏移", ""))) offset = self._parse_xyz(offset_str) bsp = clean_cell(row.get("BSP 资产建议", "")) note = clean_cell(row.get("说明", "")) return PropItem( item_id=item_id, name=name, size=size, offset=offset, bsp_suggestion=bsp, note=note ) def _parse_composite_row(self, row: dict) -> Optional[CompositeItem]: """解析组合资产表行。""" item_id = clean_cell(row.get("编号", "")) name = clean_cell(row.get("物件名称", "")) if not item_id or not name: return None size_str = clean_cell(row.get("期望尺寸 (约, cm)", row.get("期望尺寸", ""))) size = self._parse_size(size_str) plan = clean_cell(row.get("推荐拼搭方案", "")) note = clean_cell(row.get("说明", "")) return CompositeItem( item_id=item_id, name=name, target_size=size, build_plan=plan, note=note ) # ---------------------------------------------------------- # 四、全局物件清单 # ---------------------------------------------------------- def _parse_global_objects(self, lines: list[str]): """解析全局物件清单。""" section_start = self._find_section(lines, "四、全局物件清单") section_end = self._find_section(lines, "五、场景边界") if section_start < 0: return if section_end < 0: section_end = len(lines) section_lines = lines[section_start:section_end] tables = self._extract_tables(section_lines) for table in tables: for row in table: item_id = clean_cell(row.get("编号", "")) name = clean_cell(row.get("物件名称", "")) if not item_id or not name: continue count_str = clean_cell(row.get("数量", "1")) c_match = re.search(r'(\d+)', count_str) count = int(c_match.group(1)) if c_match else 1 loc_str = clean_cell(row.get("放置位置 (绝对坐标 X,Y,Z)", row.get("放置位置", ""))) location = self._parse_xyz(loc_str) bsp = clean_cell(row.get("BSP 资产建议", "")) note = clean_cell(row.get("说明", "")) self.data.global_objects.append(GlobalObject( item_id=item_id, name=name, count=count, location=location, bsp_suggestion=bsp, note=note )) # ---------------------------------------------------------- # 五、场景边界与遮挡 # ---------------------------------------------------------- def _parse_boundaries(self, lines: list[str]): """解析场景边界表。""" section_start = self._find_section(lines, "五、场景边界") section_end = self._find_section(lines, "六、光照建议") if section_start < 0: return if section_end < 0: section_end = len(lines) section_lines = lines[section_start:section_end] tables = self._extract_tables(section_lines) if tables: for row in tables[0]: segment = clean_cell(row.get("边界段", "")) if not segment: continue loc_range = clean_cell(row.get("位置范围", "")) method = clean_cell(row.get("遮挡方式", "")) bsp = clean_cell(row.get("BSP 资产建议", "")) tag = clean_cell(row.get("标识色", "")) self.data.boundaries.append(BoundaryItem( segment=segment, location_range=loc_range, block_method=method, bsp_suggestion=bsp, tag_color=tag )) # ---------------------------------------------------------- # 六、光照建议 # ---------------------------------------------------------- def _parse_lighting(self, lines: list[str]): """解析光照建议表。""" section_start = self._find_section(lines, "六、光照建议") if section_start < 0: return section_lines = lines[section_start:] tables = self._extract_tables(section_lines) if tables: for row in tables[0]: light_type = clean_cell(row.get("光源类型", "")) if not light_type: continue count_str = clean_cell(row.get("数量", "1")) c_match = re.search(r'(\d+)', count_str) count = int(c_match.group(1)) if c_match else 1 position = clean_cell(row.get("位置/方向", "")) params = clean_cell(row.get("参数建议", "")) note = clean_cell(row.get("说明", "")) self.data.lights.append(LightItem( light_type=light_type, count=count, position=position, params=params, note=note )) # ---------------------------------------------------------- # 辅助方法 # ---------------------------------------------------------- def _find_section(self, lines: list[str], keyword: str) -> int: """找到包含关键词的章节标题行。""" for i, line in enumerate(lines): if keyword in line: return i return -1 def _find_zone(self, zone_id: str) -> Optional[ZoneInfo]: """查找区域信息。""" for z in self.data.zones: if z.zone_id == zone_id: return z return None def _extract_tables(self, lines: list[str]) -> list[list[dict]]: """从行列表中提取所有表格。""" tables = [] i = 0 while i < len(lines): if '|' in lines[i]: table_lines = [] while i < len(lines) and '|' in lines[i]: table_lines.append(lines[i]) i += 1 rows = parse_table(table_lines) if rows: tables.append(rows) continue i += 1 return tables def _skip_blank(self, lines: list[str], i: int) -> int: """跳过空行,返回第一个非空行的索引。""" while i < len(lines) and not lines[i].strip(): i += 1 return i def _read_table(self, lines: list[str], i: int) -> list[dict]: """从第 i 行开始读取一个 Markdown 表格。""" table_lines = [] while i < len(lines) and '|' in lines[i]: table_lines.append(lines[i]) i += 1 if not table_lines: return [] return parse_table(table_lines) def _advance_past_table(self, lines: list[str], i: int) -> int: """将索引推进到表格之后。""" while i < len(lines) and '|' in lines[i]: i += 1 return i def _parse_xyz(self, text: str) -> tuple: """解析 (X,Y,Z) 或 (ΔX,ΔY,ΔZ) 格式。""" if not text: return (0, 0, 0) m = re.search(r'\(([-\d.]+)\s*[,,]\s*([-\d.]+)\s*[,,]\s*([-\d.]+)\)', text) if m: return (float(m.group(1)), float(m.group(2)), float(m.group(3))) return (0, 0, 0) def _parse_size(self, text: str) -> tuple: """解析 尺寸格式:宽x深x高。支持 '800x20x250' 或 '800×20×250'。""" if not text: return (0, 0, 0) m = re.search(r'(\d+(?:\.\d+)?)\s*[x×]\s*(\d+(?:\.\d+)?)\s*[x×]\s*(\d+(?:\.\d+)?)', text) if m: return (float(m.group(1)), float(m.group(2)), float(m.group(3))) return (0, 0, 0)