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

597 lines
22 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 生成器。
将 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)}"