Files
starmen1265 6f8b599629 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>
2026-06-11 00:44:46 +08:00

663 lines
25 KiB
Python
Raw Permalink 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/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)