feat(tools): 新增execution-to-guide执行案转搭建指南工具

- execution_parser.py: 解析关卡执行案Markdown提取区域/结构体/布景/边界/光照
- asset_expander.py: BSP资产建议展开+绝对坐标计算+旋转推断
- guide_writer.py: 生成精确匹配build_guide_parser格式的搭建指南
- composite_writer.py: 组合资产清单+JSON模板生成
- converter.py: 主入口CLI工具
- 更新level-execution-template.md: 移除交互物/收集品章节,新增组合资产标记

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 00:44:46 +08:00
parent f86ffb511e
commit 6f8b599629
9 changed files with 2731 additions and 29 deletions
@@ -0,0 +1,662 @@
"""关卡执行案 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)