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
+596
View File
@@ -0,0 +1,596 @@
"""搭建指南 Markdown 生成器。
将 ActorSpec 列表转换为搭建指南 Markdown 文档。
输出格式精确匹配 build_guide_parser.py 的解析预期。
搭建指南结构(8章):
〇、全局信息(场景参数、路径前缀、命名规范、Outliner 结构)
一、BSP 资产映射表
二、Phase B 地面与边界
三、Phase C 逐区域搭建
四、Phase C 全局物件
五、Phase D 光照
六、Phase E 验证清单
"""
import logging
from dataclasses import asdict
from pathlib import Path
from typing import Optional
from execution_parser import (
ExecutionPlanData, ZoneInfo, StructureItem, PropItem,
CompositeItem, GlobalObject, BoundaryItem, LightItem,
)
from asset_expander import AssetExpander, ActorSpec
logger = logging.getLogger("execution_to_guide.writer")
# ============================================================
# 搭建指南生成器
# ============================================================
class GuideWriter:
"""搭建指南 Markdown 生成器。"""
def __init__(self, plan_data: ExecutionPlanData):
"""
Args:
plan_data: 解析后的执行案数据
"""
self.plan = plan_data
self.expander = AssetExpander(plan_data.zones)
self.all_actors: list[ActorSpec] = []
self.asset_refs: dict[str, dict] = {} # 用到的资产 key → info
def generate(self) -> str:
"""生成完整的搭建指南 Markdown。"""
sections = []
# 〇、全局信息
sections.append(self._write_global_info())
# 一、BSP 资产映射表
sections.append(self._write_asset_mapping())
# 二、Phase B — 地面与边界
sections.append(self._write_ground_and_boundary())
# 三、Phase C — 逐区域搭建
sections.append(self._write_zone_builds())
# 四、Phase C — 全局物件
sections.append(self._write_global_objects())
# 五、Phase D — 光照
sections.append(self._write_lighting())
# 六、Phase E — 验证清单
sections.append(self._write_checklist())
return "\n\n".join(sections)
def write_to_file(self, filepath: str):
"""生成并写入文件。"""
content = self.generate()
path = Path(filepath)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
logger.info(f"搭建指南已写入: {filepath}")
# ----------------------------------------------------------
# 〇、全局信息
# ----------------------------------------------------------
def _write_global_info(self) -> str:
"""生成全局信息章节。"""
w, d = self.plan.scene_size
lines = [
f"# v{self.plan.version} 白模搭建操作指南 — {self.plan.scene_name}",
"",
f"> 本文档由 execution-to-guide 工具自动生成,供 level-builder 搭建白模关卡使用。",
f"> 基于: 关卡-v{self.plan.version}-{self.plan.scene_name}-执行案.md",
"",
"---",
"",
"## 〇、全局信息",
"",
"### 场景参数",
"",
"| 参数 | 值 |",
"|------|-----|",
f"| 场景总尺寸 | {w}cm × {d}cm({w // 100}m × {d // 100}m) |",
f"| 地面高度基准 | Z = {int(self.plan.ground_z)} |",
f"| 主角身高 | ~{int(self.plan.character_height)}cm |",
f"| 摄像机高度 | ~{int(self.plan.camera_height)}cm |",
f"| 时间设定 | {self.plan.time_setting} |",
"| 坐标原点 | 场景西南角地平面 |",
"",
"### UE5 路径前缀",
"",
"- **Mesh**: `Content/BlockingStarterPack/Meshes/`",
"- **Material**: `Content/BlockingStarterPack/Materials/`",
"- **Material (Basics)**: `Content/BlockingStarterPack/Materials/Blocking/Basics/`",
"",
"### 命名规范",
"",
"```",
"LVL_<ZoneID>_<Category>_<Description>",
"Category: STR | PROP | COMP | BND | LGT",
"```",
"",
"### Outliner 文件夹结构",
"",
"```",
f"LVL_{self._level_slug()}/",
]
for zone in self.plan.zones:
zone_slug = f"{zone.zone_id}_{zone.zone_name}"
lines.append(f" {zone_slug}/")
lines.append(" Structure/ Props/")
lines.append(" Global/")
lines.append(" Boundaries/ Lighting/ PlayerStart/")
lines.append("```")
return "\n".join(lines)
# ----------------------------------------------------------
# 一、BSP 资产映射表
# ----------------------------------------------------------
def _write_asset_mapping(self) -> str:
"""收集用到的所有资产,生成映射表。"""
# 先展开所有 Actor 以收集资产引用
self._collect_all_actors()
lines = [
"---",
"",
"## 一、BSP 资产映射表",
"",
]
# 按类别分组
categories = {}
for key, info in self.asset_refs.items():
cat = info.get("category", "other")
if cat not in categories:
categories[cat] = []
categories[cat].append((key, info))
# 类别标题映射
cat_titles = {
"wall": "建筑 — 墙体",
"wall_door": "建筑 — 门墙",
"wall_window": "建筑 — 窗墙",
"floor": "建筑 — 地板",
"door": "建筑 — 门",
"window": "建筑 — 窗户",
"barrier": "建筑 — 围挡",
"fence": "建筑 — 围栏",
"beam": "建筑 — 横梁",
"building": "建筑 — 大型建筑",
"furniture": "家具",
"industrial": "工业/装饰",
"vehicle": "载具",
"primitive": "基础体",
"vegetation": "植被",
"light_fixture": "灯具",
"decoration": "装饰",
"other": "其他",
}
for cat_key in ["wall", "wall_door", "wall_window", "floor", "door", "window",
"barrier", "fence", "beam", "building", "furniture",
"industrial", "vehicle", "vegetation", "light_fixture",
"decoration", "other"]:
if cat_key not in categories:
continue
items = categories[cat_key]
title = cat_titles.get(cat_key, cat_key)
lines.append(f"### {title}")
lines.append("")
lines.append("| 设计文档引用 | UE5 资产路径 | 实际尺寸 (cm) |")
lines.append("|-------------|-------------|--------------|")
for key, info in items:
path_short = info.get("path", "")
# 缩短路径显示
if path_short.startswith("/Game/BlockingStarterPack/Meshes/"):
path_short = path_short.replace("/Game/BlockingStarterPack/Meshes/", "")
path_short = path_short.split(".")[0] # 去掉 .SM_xxx 后缀
bounds = info.get("actual_bounds", ((0, 0, 0), (0, 0, 0)))
size_str = self._format_bounds_size(bounds)
lines.append(f"| {key} | `{path_short}` | {size_str} |")
lines.append("")
return "\n".join(lines)
# ----------------------------------------------------------
# 二、Phase B — 地面与边界
# ----------------------------------------------------------
def _write_ground_and_boundary(self) -> str:
"""生成地面铺设和边界围挡。"""
lines = [
"---",
"",
"## 二、Phase B — 地面与边界",
"",
"### B1. 铺设地面",
"",
]
# 全局地面
w, d = self.plan.scene_size
lines.append("#### 全局地面(MI_DevGray)")
lines.append("")
lines.append("| # | Actor 名称 | 资产 | 位置 (X,Y,Z) | 缩放 | 材质 |")
lines.append("|---|-----------|------|-------------|------|------|")
# 用 Floor_1000x1000 铺满
tiles_x = max(1, w // 1000)
tiles_y = max(1, d // 1000)
seq = 1
for tx in range(tiles_x):
for ty in range(tiles_y):
cx = tx * 1000 + 500
cy = ty * 1000 + 500
lines.append(
f"| {seq} | LVL_Global_STR_GroundFloor_{seq:02d} | Floor_1000x1000 | "
f"({cx},{cy},{int(self.plan.ground_z)}) | — | MI_DevGray |"
)
seq += 1
lines.append("")
# 边界围挡
lines.append("### B2. 放置边界围挡")
lines.append("")
for boundary in self.plan.boundaries:
seg = boundary.segment
lines.append(f"#### {seg}")
lines.append("")
lines.append("| # | Actor 名称 | 资产 | 位置 (X,Y,Z) | 旋转 (P,Y,R) | 缩放 | 材质 |")
lines.append("|---|-----------|------|-------------|-------------|------|------|")
# 根据边界方向生成围挡
bnd_actors = self._generate_boundary_actors(boundary)
for i, actor in enumerate(bnd_actors):
rot_str = self._fmt_rotation(actor.rotation)
scale_str = self._fmt_scale(actor.scale)
lines.append(
f"| {i + 1} | {actor.actor_name} | {actor.asset_key} | "
f"{self._fmt_location(actor.location)} | {rot_str} | {scale_str} | "
f"{actor.material} |"
)
lines.append("")
return "\n".join(lines)
def _generate_boundary_actors(self, boundary: BoundaryItem) -> list[ActorSpec]:
"""根据边界描述生成围挡 Actor。"""
w, d = self.plan.scene_size
actors = []
# 确定边界的起止坐标和方向
if "西" in boundary.segment:
# X=0, 沿 Y 轴
x, y_start, y_end = 0, 0, d
yaw = 90.0
elif "东" in boundary.segment:
x, y_start, y_end = w, 0, d
yaw = 90.0
elif "南" in boundary.segment:
x_start, x_end, y = 0, w, 0
yaw = 0.0
elif "北" in boundary.segment:
x_start, x_end, y = 0, w, d
yaw = 0.0
else:
return []
# 使用 BarrierWall_600 或 Wall_400x300
asset_key = "BarrierWall_600"
wall_len = 600 # cm
seq = 1
if "西" in boundary.segment or "东" in boundary.segment:
length = y_end - y_start
count = max(1, int(length / wall_len))
for i in range(count):
y_pos = y_start + i * wall_len + wall_len // 2
z_pos = 105 # 半高
actors.append(ActorSpec(
actor_name=f"LVL_Global_BND_{boundary.segment}_{seq:02d}",
asset_key=asset_key,
asset_path="",
location=(x, y_pos, z_pos),
rotation=(0.0, yaw, 0.0),
scale=(1.0, 1.0, 1.0),
material=boundary.tag_color or "MI_DevBrown",
material_path="",
category="BND",
zone_id="GLOBAL",
folder="LVL_Level/Global/Boundaries",
))
seq += 1
else:
length = x_end - x_start
count = max(1, int(length / wall_len))
for i in range(count):
x_pos = x_start + i * wall_len + wall_len // 2
y_pos = y
z_pos = 105
actors.append(ActorSpec(
actor_name=f"LVL_Global_BND_{boundary.segment}_{seq:02d}",
asset_key=asset_key,
asset_path="",
location=(x_pos, y_pos, z_pos),
rotation=(0.0, yaw, 0.0),
scale=(1.0, 1.0, 1.0),
material=boundary.tag_color or "MI_DevBrown",
material_path="",
category="BND",
zone_id="GLOBAL",
folder="LVL_Level/Global/Boundaries",
))
seq += 1
return actors
# ----------------------------------------------------------
# 三、Phase C — 逐区域搭建
# ----------------------------------------------------------
def _write_zone_builds(self) -> str:
"""生成逐区域搭建章节。"""
lines = [
"---",
"",
"## 三、Phase C — 逐区域搭建",
"",
]
for idx, zone in enumerate(self.plan.zones, 1):
lines.append(f"### C{idx}. {zone.zone_id} — {zone.zone_name}")
lines.append("")
# 结构体
structures = self.plan.zone_structures.get(zone.zone_id, [])
if structures:
lines.append(f"#### C{idx}.1 结构体(Structure)")
lines.append("")
lines.append("| # | Actor 名称 | 资产 | 位置 (X,Y,Z) | 旋转 (P,Y,R) | 缩放 | 材质 |")
lines.append("|---|-----------|------|-------------|-------------|------|------|")
for item in structures:
actors = self.expander.expand_structure(item, zone.zone_id)
for actor in actors:
rot_str = self._fmt_rotation(actor.rotation)
scale_str = self._fmt_scale(actor.scale)
lines.append(
f"| | {actor.actor_name} | {actor.asset_key} | "
f"{self._fmt_location(actor.location)} | {rot_str} | {scale_str} | "
f"{actor.material} |"
)
self._track_asset(actor)
lines.append("")
# 布景
props = self.plan.zone_props.get(zone.zone_id, [])
if props:
lines.append(f"#### C{idx}.2 布景物(Props)")
lines.append("")
lines.append("| # | Actor 名称 | 资产 | 位置 (X,Y,Z) | 缩放 | 材质 | 说明 |")
lines.append("|---|-----------|------|-------------|------|------|------|")
for item in props:
actors = self.expander.expand_prop(item, zone.zone_id)
for actor in actors:
scale_str = self._fmt_scale(actor.scale)
lines.append(
f"| | {actor.actor_name} | {actor.asset_key} | "
f"{self._fmt_location(actor.location)} | {scale_str} | "
f"{actor.material} | {actor.note} |"
)
self._track_asset(actor)
lines.append("")
# 组合资产(如有)
composites = self.plan.zone_composites.get(zone.zone_id, [])
if composites:
lines.append(f"#### C{idx}.3 组合资产(Composite)")
lines.append("")
lines.append("| # | Actor 名称 | Primitives | 位置 (X,Y,Z) | 缩放 | 材质 | 说明 |")
lines.append("|---|-----------|-----------|-------------|------|------|------|")
for comp in composites:
lines.append(
f"| | ⚠️ {comp.name} | {comp.build_plan} | "
f"(待展开) | — | — | {comp.note} |"
)
lines.append("")
return "\n".join(lines)
# ----------------------------------------------------------
# 四、全局物件
# ----------------------------------------------------------
def _write_global_objects(self) -> str:
"""生成全局物件章节。"""
lines = [
"---",
"",
"## 四、Phase C — 全局物件",
"",
]
if not self.plan.global_objects:
lines.append("> 无全局物件。")
return "\n".join(lines)
lines.append("| # | Actor 名称 | 资产 | 位置 (X,Y,Z) | 缩放 | 材质 | 说明 |")
lines.append("|---|-----------|------|-------------|------|------|------|")
for item in self.plan.global_objects:
actors = self.expander.expand_global(item)
for actor in actors:
scale_str = self._fmt_scale(actor.scale)
lines.append(
f"| | {actor.actor_name} | {actor.asset_key} | "
f"{self._fmt_location(actor.location)} | {scale_str} | "
f"{actor.material} | {actor.note} |"
)
self._track_asset(actor)
return "\n".join(lines)
# ----------------------------------------------------------
# 五、光照
# ----------------------------------------------------------
def _write_lighting(self) -> str:
"""生成光照章节。"""
lines = [
"---",
"",
"## 五、Phase D — 光照",
"",
]
for light in self.plan.lights:
if "定向" in light.light_type or "Directional" in light.light_type:
lines.append("### D1. 定向光(Directional Light)")
lines.append("")
lines.append("| 类型 | 位置/方向 | 参数 |")
lines.append("|------|---------|------|")
lines.append(f"| Directional Light | {light.position} | {light.params} |")
lines.append(f"> {light.note}")
lines.append("")
elif "天空" in light.light_type or "Sky" in light.light_type:
lines.append("### D2. 天空光(Sky Light)")
lines.append("")
lines.append("| 类型 | 位置/方向 | 参数 |")
lines.append("|------|---------|------|")
lines.append(f"| Sky Light | 全局 | {light.params} |")
lines.append(f"> {light.note}")
lines.append("")
elif "路灯" in light.light_type or "点光" in light.light_type:
lines.append("### D3. 路灯点光源")
lines.append("")
lines.append(f"> {light.note},数量: {light.count}")
lines.append("")
return "\n".join(lines)
# ----------------------------------------------------------
# 六、验证清单
# ----------------------------------------------------------
def _write_checklist(self) -> str:
"""生成验证清单章节。"""
total = len(self.all_actors)
zones = len(self.plan.zones)
lines = [
"---",
"",
"## 六、Phase E — 验证清单",
"",
"| # | 检查项 | 状态 |",
"|---|--------|------|",
f"| 1 | 场景尺寸 {self.plan.scene_size[0]}×{self.plan.scene_size[1]}cm 正确 | ⬜ |",
f"| 2 | {zones} 个区域均已搭建 | ⬜ |",
"| 3 | 所有边界围挡无缺口 | ⬜ |",
"| 4 | 玩家起点可到达 | ⬜ |",
"| 5 | 所有区域可通行 | ⬜ |",
"| 6 | 建筑入口与流线对齐 | ⬜ |",
"| 7 | 光照方向与场景一致 | ⬜ |",
f"| 8 | Actor 总数: {total} | ⬜ |",
"",
"---",
"",
f"> 搭建指南生成完成。共 {zones} 个区域,{total} 个 Actor。",
]
return "\n".join(lines)
# ----------------------------------------------------------
# 辅助方法
# ----------------------------------------------------------
def _collect_all_actors(self):
"""收集所有区域的 Actor(用于资产映射表)。"""
if self.all_actors:
return # 已收集
# 结构体
for zone_id, items in self.plan.zone_structures.items():
for item in items:
actors = self.expander.expand_structure(item, zone_id)
self.all_actors.extend(actors)
for a in actors:
self._track_asset(a)
# 布景
for zone_id, items in self.plan.zone_props.items():
for item in items:
actors = self.expander.expand_prop(item, zone_id)
self.all_actors.extend(actors)
for a in actors:
self._track_asset(a)
# 全局物件
for item in self.plan.global_objects:
actors = self.expander.expand_global(item)
self.all_actors.extend(actors)
for a in actors:
self._track_asset(a)
def _track_asset(self, actor: ActorSpec):
"""跟踪用到的资产。"""
if actor.asset_key and actor.asset_key not in self.asset_refs:
try:
from config.asset_mapping import MESH_ASSETS
info = MESH_ASSETS.get(actor.asset_key, {})
except ImportError:
info = {}
self.asset_refs[actor.asset_key] = info
def _level_slug(self) -> str:
"""生成关卡标识符。"""
name = self.plan.scene_name.replace(" ", "")
return name
@staticmethod
def _fmt_location(loc: tuple) -> str:
"""格式化位置。"""
return f"({int(loc[0])},{int(loc[1])},{int(loc[2])})"
@staticmethod
def _fmt_rotation(rot: tuple) -> str:
"""格式化旋转。"""
if rot == (0, 0, 0):
return "—"
return f"({int(rot[0])},{int(rot[1])},{int(rot[2])})"
@staticmethod
def _fmt_scale(scale: tuple) -> str:
"""格式化缩放。"""
if scale == (1, 1, 1):
return "—"
return f"({scale[0]:.2f},{scale[1]:.2f},{scale[2]:.2f})"
@staticmethod
def _format_bounds_size(bounds) -> str:
"""格式化包围盒尺寸。"""
if not bounds or len(bounds) < 2:
return "—"
mn, mx = bounds[0], bounds[1]
w = mx[0] - mn[0] if len(mx) > 0 else 0
d = mx[1] - mn[1] if len(mx) > 1 else 0
h = mx[2] - mn[2] if len(mx) > 2 else 0
return f"{int(w)}×{int(d)}×{int(h)}"