feat(tools): 白模关卡自动生成Python脚本——Level Builder
两阶段流水线:搭建指南Markdown → JSON → UE5 Actor - 解析器:4种表格变体+范围展开+坐标表达式计算 - 配置:37种BSP资产映射+13种材质+7区域定义 - 验证:265个Actor全部通过,0错误 - Builder:UE5 Python API自动放置Actor+材质+Outliner - 报告:自动生成搭建报告Markdown Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,772 @@
|
||||
"""搭建指南 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),
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
"""通用 Markdown 管道表格解析器。
|
||||
|
||||
将 Markdown 表格文本解析为 list[dict],每个字典代表一行数据。
|
||||
支持中文列名,处理列数不一致的情况。
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def parse_table(lines: list[str]) -> list[dict]:
|
||||
"""解析单个 Markdown 表格。
|
||||
|
||||
Args:
|
||||
lines: 表格的文本行列表(包含表头、分隔行和数据行)。
|
||||
|
||||
Returns:
|
||||
列表,每个元素是一行数据的字典,key 为表头文本。
|
||||
返回空列表如果输入不是有效表格。
|
||||
"""
|
||||
if not lines:
|
||||
return []
|
||||
|
||||
# 找到分隔行(包含 |---| 的行)
|
||||
sep_idx = -1
|
||||
for i, line in enumerate(lines):
|
||||
if re.match(r'\s*\|[\s\-:]+\|', line):
|
||||
sep_idx = i
|
||||
break
|
||||
|
||||
if sep_idx < 1:
|
||||
return [] # 没有有效的表头+分隔行
|
||||
|
||||
# 提取表头
|
||||
header_line = lines[sep_idx - 1]
|
||||
headers = _split_row(header_line)
|
||||
if not headers:
|
||||
return []
|
||||
|
||||
# 提取数据行(分隔行之后的所有 | 开头的行)
|
||||
data_rows = []
|
||||
for line in lines[sep_idx + 1:]:
|
||||
stripped = line.strip()
|
||||
if not stripped or not stripped.startswith('|'):
|
||||
break # 表格结束
|
||||
cells = _split_row(stripped)
|
||||
data_rows.append(cells)
|
||||
|
||||
# 构建结果
|
||||
result = []
|
||||
for row in data_rows:
|
||||
entry = {}
|
||||
for j, header in enumerate(headers):
|
||||
entry[header] = row[j] if j < len(row) else ""
|
||||
result.append(entry)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def parse_all_tables(text: str) -> list[tuple[list[str], list[dict]]]:
|
||||
"""解析文本中的所有 Markdown 表格。
|
||||
|
||||
Args:
|
||||
text: 完整的 Markdown 文本。
|
||||
|
||||
Returns:
|
||||
列表,每个元素为 (header, rows) 元组。
|
||||
header 为表头列表,rows 为 list[dict]。
|
||||
"""
|
||||
results = []
|
||||
lines = text.split('\n')
|
||||
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].strip())
|
||||
i += 1
|
||||
|
||||
# 尝试解析
|
||||
rows = parse_table(table_lines)
|
||||
if rows:
|
||||
# 提取表头
|
||||
for tl in table_lines:
|
||||
if re.match(r'\s*\|[\s\-:]+\|', tl):
|
||||
sep_idx = table_lines.index(tl)
|
||||
if sep_idx > 0:
|
||||
headers = _split_row(table_lines[sep_idx - 1])
|
||||
results.append((headers, rows))
|
||||
break
|
||||
continue
|
||||
i += 1
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def find_tables_in_section(text: str, section_start: int, section_end: int) -> list[list[dict]]:
|
||||
"""在文本的指定范围内找到所有表格。
|
||||
|
||||
Args:
|
||||
text: 完整文本。
|
||||
section_start: 区域起始行号。
|
||||
section_end: 区域结束行号。
|
||||
|
||||
Returns:
|
||||
表格列表,每个表格是 list[dict]。
|
||||
"""
|
||||
lines = text.split('\n')
|
||||
section_lines = lines[section_start:section_end]
|
||||
tables = []
|
||||
|
||||
i = 0
|
||||
while i < len(section_lines):
|
||||
if '|' in section_lines[i]:
|
||||
# 收集连续表格行
|
||||
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:
|
||||
tables.append(rows)
|
||||
continue
|
||||
i += 1
|
||||
|
||||
return tables
|
||||
|
||||
|
||||
def _split_row(line: str) -> list[str]:
|
||||
"""将 | 分隔的行拆分为单元格列表,去除首尾空格。"""
|
||||
# 去除首尾空白
|
||||
line = line.strip()
|
||||
# 按管道符分割
|
||||
if not line.startswith('|'):
|
||||
return []
|
||||
if not line.endswith('|'):
|
||||
# 有些行末尾可能没有 |
|
||||
parts = line.split('|')
|
||||
else:
|
||||
parts = line.split('|')
|
||||
|
||||
# 去除首尾空元素(管道符分割产生的)
|
||||
cells = [c.strip() for c in parts]
|
||||
# 去掉首尾空字符串
|
||||
while cells and cells[0] == '':
|
||||
cells.pop(0)
|
||||
while cells and cells[-1] == '':
|
||||
cells.pop()
|
||||
|
||||
return cells
|
||||
|
||||
|
||||
def parse_location(text: str) -> tuple[float, float, float]:
|
||||
"""解析位置字符串 '(X,Y,Z)' 为三元组。"""
|
||||
if not text:
|
||||
return (0.0, 0.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.0, 0.0)
|
||||
|
||||
|
||||
def parse_rotation(text: str) -> tuple[float, float, float]:
|
||||
"""解析旋转字符串 '(P,Y,R)' 为三元组(度数)。"""
|
||||
return parse_location(text)
|
||||
|
||||
|
||||
def parse_scale(text: str) -> tuple[float, float, float]:
|
||||
"""解析缩放字符串 '(X,Y,Z)' 为三元组。'—' 或空返回 (1,1,1)。"""
|
||||
if not text or text.strip() in ('—', '–', '-', '无', ''):
|
||||
return (1.0, 1.0, 1.0)
|
||||
return parse_location(text)
|
||||
|
||||
|
||||
def clean_cell(text: str) -> str:
|
||||
"""清理单元格文本:去首尾空白、去反引号。"""
|
||||
if not text:
|
||||
return ""
|
||||
text = text.strip()
|
||||
# 去除 Markdown 反引号包裹
|
||||
if text.startswith('`') and text.endswith('`'):
|
||||
text = text[1:-1]
|
||||
return text.strip()
|
||||
Reference in New Issue
Block a user