"""搭建指南 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___", "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)}"