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

424 lines
15 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.
"""BSP 资产建议展开器。
将执行案中的"BSP 资产建议"字符串拆分为可放置的 Actor 规格。
核心转换:
- "Wall_400x300 × 2" → 2 个 Actor,间隔 400cm
- "Wall_400x300 + Wall_200x300" → 2 种资产各 1 个
- "Cube 缩放(0.3,0.3,1.6)" → 1 个 Cube,带缩放参数
- 区域原点 + 相对偏移 → 绝对坐标
- 构件名称/方位 → 旋转推断
同时复用 level-builder 的 asset_mapping / material_mapping / substitution_rules。
"""
import re
import math
import logging
from dataclasses import dataclass
from typing import Optional
# 允许从包内或直接运行
from pathlib import Path
try:
from config.asset_mapping import MESH_ASSETS, MESH_ROOT, get_pivot_compensation
from config.material_mapping import MATERIAL_PATHS
from config.substitution_rules import SUBSTITUTION_RULES
except ImportError:
import sys
_lb = str(Path(__file__).resolve().parent.parent / "level-builder")
if _lb not in sys.path:
sys.path.insert(0, _lb)
from config.asset_mapping import MESH_ASSETS, MESH_ROOT, get_pivot_compensation
from config.material_mapping import MATERIAL_PATHS
from config.substitution_rules import SUBSTITUTION_RULES
logger = logging.getLogger("execution_to_guide.expander")
# ============================================================
# 数据结构
# ============================================================
@dataclass
class ActorSpec:
"""展开后的单个 Actor 规格。"""
actor_name: str # LVL_Z1_STR_SouthWall_01
asset_key: str # Wall_400x300
asset_path: str # UE5 资产路径
location: tuple # (X, Y, Z) 绝对坐标 cm
rotation: tuple # (Pitch, Yaw, Roll) 度
scale: tuple # (X, Y, Z) 缩放
material: str # MI_DevRed
material_path: str # UE5 材质路径
category: str # STR | PROP | COMP
zone_id: str # Z1 | GLOBAL
folder: str # Outliner 文件夹路径
note: str = ""
# ============================================================
# 方向→旋转映射
# ============================================================
# 从构件名称中的方位词推断旋转
# UE5: Yaw 绕 Z 轴旋转,沿 X 正方向为 0°
# 南墙(沿 X 轴)= Yaw 0°,东墙(沿 Y 轴)= Yaw 90°
DIRECTION_YAW = {
"南": 0.0, # 沿 X 轴,面朝南(-Y)
"东": 90.0, # 沿 Y 轴,面朝东(+X)
"西": -90.0, # 沿 Y 轴,面朝西(-X)
"北": 180.0, # 沿 X 轴,面朝北(+Y)
"左": 90.0,
"右": -90.0,
"上": 0.0,
"下": 0.0,
}
def infer_rotation_from_name(name: str) -> tuple:
"""从构件名称推断旋转。
查找名称中的方位词(南/东/西/北),返回对应的 (Pitch, Yaw, Roll)。
如果没有方位词,返回 (0, 0, 0)。
"""
for direction, yaw in DIRECTION_YAW.items():
if direction in name:
return (0.0, yaw, 0.0)
return (0.0, 0.0, 0.0)
def infer_rotation_from_offset(offset: tuple, zone_size: tuple) -> tuple:
"""从偏移位置推断旋转(备用方案)。
根据物件在区域中的位置判断朝向:
- 偏移 Y ≈ 0 → 南墙,Y ≈ max → 北墙
- 偏移 X ≈ 0 → 西墙,X ≈ max → 东墙
"""
dx, dy, dz = offset
w, d = zone_size[0], zone_size[1] if len(zone_size) > 1 else 0
if w > 0 and d > 0:
# 判断更接近哪条边
x_ratio = dx / w if w else 0
y_ratio = dy / d if d else 0
if y_ratio < 0.1:
return (0.0, 0.0, 0.0) # 南
elif y_ratio > 0.9:
return (0.0, 180.0, 0.0) # 北
elif x_ratio < 0.1:
return (0.0, -90.0, 0.0) # 西
elif x_ratio > 0.9:
return (0.0, 90.0, 0.0) # 东
return (0.0, 0.0, 0.0)
# ============================================================
# BSP 建议解析
# ============================================================
def parse_bsp_suggestion(bsp_text: str) -> list[dict]:
"""解析 BSP 资产建议字符串为资产列表。
支持的格式:
- "Wall_400x300" → [{"key": "Wall_400x300", "count": 1}]
- "Wall_400x300 × 2" → [{"key": "Wall_400x300", "count": 2}]
- "Wall_400x300 × 2 + Wall_200x300" → [{"key": "Wall_400x300", "count": 2}, {"key": "Wall_200x300", "count": 1}]
- "Cube 缩放(0.3,0.3,1.6)" → [{"key": "Cube", "count": 1, "scale": (0.3,0.3,1.6)}]
- "Car01 缩放50%" → [{"key": "Car01", "count": 1, "scale": (0.5,0.5,0.5)}]
- "Floor_1000x1000 缩放(8,6,1)" → [{"key": "Floor_1000x1000", "count": 1, "scale": (8,6,1)}]
- "Primitives 组合" → [{"key": "_composite", "count": 1}]
- "BarrierWall_600" → [{"key": "BarrierWall_600", "count": 1}]
Returns:
list of dicts, each with:
key: asset key string
count: int
scale: optional tuple (X, Y, Z)
"""
if not bsp_text or not bsp_text.strip():
return []
bsp_text = bsp_text.strip()
results = []
# 按 + 分割多个资产段
segments = re.split(r'\s*\+\s*', bsp_text)
for seg in segments:
seg = seg.strip()
if not seg:
continue
# 检测 Primitives 组合标记
if "Primitives 组合" in seg or "组合" in seg and not re.search(r'[A-Z]', seg):
results.append({"key": "_composite", "count": 1})
continue
# 提取资产 key
key_match = re.match(r'([A-Za-z_]\w*(?:\d+x\d+)?)', seg)
if not key_match:
continue
asset_key = key_match.group(1)
remaining = seg[key_match.end():]
# 提取数量:× 2 或 *2 或 x2
count = 1
count_match = re.search(r'[×x*]\s*(\d+)', remaining)
if count_match:
count = int(count_match.group(1))
# 提取缩放:缩放(X,Y,Z) 或 缩放NN%
scale = None
scale_match = re.search(r'缩放\s*\(([\d.]+)\s*[,,]\s*([\d.]+)\s*[,,]\s*([\d.]+)\)', remaining)
if scale_match:
scale = (float(scale_match.group(1)), float(scale_match.group(2)), float(scale_match.group(3)))
else:
pct_match = re.search(r'缩放\s*(\d+)%', remaining)
if pct_match:
s = int(pct_match.group(1)) / 100.0
scale = (s, s, s)
results.append({"key": asset_key, "count": count, "scale": scale})
return results
# ============================================================
# 资产展开
# ============================================================
class AssetExpander:
"""将执行案条目展开为 Actor 规格。"""
def __init__(self, zones: list):
"""
Args:
zones: list[ZoneInfo] 区域信息列表
"""
self.zones = {z.zone_id: z for z in zones}
self.actor_counters = {} # zone_id -> {category: counter}
def expand_structure(self, item, zone_id: str) -> list[ActorSpec]:
"""展开结构体条目。
Args:
item: StructureItem
zone_id: 区域 ID
Returns:
展开后的 ActorSpec 列表
"""
zone = self.zones.get(zone_id)
if not zone:
logger.warning(f"未找到区域 {zone_id},跳过结构体 {item.item_id}")
return []
return self._expand_item(
item_id=item.item_id,
name=item.name,
size=item.size,
offset=item.offset,
bsp_suggestion=item.bsp_suggestion,
tag_color=item.tag_color,
zone_id=zone_id,
zone=zone,
category="STR",
note="",
)
def expand_prop(self, item, zone_id: str) -> list[ActorSpec]:
"""展开布景物条目。"""
zone = self.zones.get(zone_id)
if not zone:
logger.warning(f"未找到区域 {zone_id},跳过布景 {item.item_id}")
return []
return self._expand_item(
item_id=item.item_id,
name=item.name,
size=item.size,
offset=item.offset,
bsp_suggestion=item.bsp_suggestion,
tag_color="",
zone_id=zone_id,
zone=zone,
category="PROP",
note=item.note,
)
def expand_global(self, item) -> list[ActorSpec]:
"""展开全局物件条目。全局物件使用绝对坐标,不需要区域原点。"""
parts = parse_bsp_suggestion(item.bsp_suggestion)
if not parts:
# 尝试 substitution_rules
sub = SUBSTITUTION_RULES.get(item.name)
if sub:
parts = [{"key": sub["asset"], "count": item.count, "scale": sub.get("scale")}]
else:
parts = [{"key": "Cube", "count": item.count, "scale": None}]
actors = []
for i in range(item.count):
for part in parts:
asset_key = part["key"]
if asset_key == "_composite":
continue
actor_name = f"LVL_Global_PROP_{self._slugify(item.name)}"
if item.count > 1:
actor_name += f"_{i + 1:02d}"
elif len(parts) > 1:
actor_name += f"_{parts.index(part) + 1:02d}"
scale = part.get("scale")
if scale is None:
scale = (1.0, 1.0, 1.0)
asset_path = MESH_ASSETS.get(asset_key, {}).get("path", "")
material = ""
mat_path = ""
actors.append(ActorSpec(
actor_name=actor_name,
asset_key=asset_key,
asset_path=asset_path,
location=item.location,
rotation=(0.0, 0.0, 0.0),
scale=scale,
material=material,
material_path=mat_path,
category="PROP",
zone_id="GLOBAL",
folder=self._get_folder("GLOBAL", "PROP"),
note=item.note,
))
return actors
def _expand_item(self, item_id: str, name: str, size: tuple, offset: tuple,
bsp_suggestion: str, tag_color: str, zone_id: str,
zone, category: str, note: str) -> list[ActorSpec]:
"""通用展开逻辑。"""
parts = parse_bsp_suggestion(bsp_suggestion)
if not parts:
# 尝试 substitution_rules
sub = SUBSTITUTION_RULES.get(name)
if sub:
parts = [{"key": sub["asset"], "count": 1, "scale": sub.get("scale")}]
if not tag_color and sub.get("material"):
tag_color = sub["material"]
else:
parts = [{"key": "Cube", "count": 1, "scale": None}]
actors = []
# 跟踪资产段的累积偏移(用于 × N 的资产沿主轴排列)
cum_offset_x = 0.0
for part in parts:
asset_key = part["key"]
count = part.get("count", 1)
part_scale = part.get("scale")
for idx in range(count):
# 计算绝对位置
abs_x = zone.origin[0] + offset[0] + cum_offset_x
abs_y = zone.origin[1] + offset[1]
abs_z = zone.origin[2] + offset[2]
# 确定缩放
if part_scale:
scale = part_scale
else:
scale = (1.0, 1.0, 1.0)
# 确定旋转
rotation = infer_rotation_from_name(name)
# 确定资产路径
asset_info = MESH_ASSETS.get(asset_key, {})
asset_path = asset_info.get("path", "")
# 确定材质
material = tag_color
mat_path = MATERIAL_PATHS.get(material, "") if material else ""
# 生成 Actor 名称
actor_name = self._make_actor_name(zone_id, category, name, len(actors) + 1)
actors.append(ActorSpec(
actor_name=actor_name,
asset_key=asset_key,
asset_path=asset_path,
location=(abs_x, abs_y, abs_z),
rotation=rotation,
scale=scale,
material=material,
material_path=mat_path,
category=category,
zone_id=zone_id,
folder=self._get_folder(zone_id, category),
note=note,
))
# 累积偏移:沿资产主轴方向排列
if asset_info:
default_size = asset_info.get("default_size", (0, 0, 0))
if default_size and default_size[0] > 0:
cum_offset_x += default_size[0] * scale[0]
elif size and size[0] > 0 and count > 1:
cum_offset_x += size[0] / count
return actors
def _make_actor_name(self, zone_id: str, category: str, name: str, seq: int) -> str:
"""生成 Actor 名称:LVL_<ZoneID>_<CAT>_<Slug>_<Seq>"""
slug = self._slugify(name)
return f"LVL_{zone_id}_{category}_{slug}_{seq:02d}"
def _slugify(self, name: str) -> str:
"""将中文名称转为 Actor 名称用的英文缩写。"""
# 中文方位→英文
_DIR_MAP = {"南": "South", "北": "North", "东": "East", "西": "West",
"左": "Left", "右": "Right", "上": "Top", "下": "Bottom"}
_TYPE_MAP = {
"墙": "Wall", "围": "Wall", "地": "Floor", "顶": "Roof",
"门": "Door", "窗": "Window", "栏": "Rail", "柱": "Pillar",
"梁": "Beam", "台": "Counter", "架": "Shelf", "柜": "Cabinet",
"桌": "Table", "椅": "Chair", "床": "Bed", "箱": "Box",
"树": "Tree", "灯": "Lamp", "车": "Car", "桶": "Barrel",
}
result = ""
for ch in name:
if ch in _DIR_MAP:
result += _DIR_MAP[ch]
elif ch in _TYPE_MAP:
result += _TYPE_MAP[ch]
elif ch.isalnum():
result += ch
# 跳过其他中文字符
return result if result else "Item"
def _get_folder(self, zone_id: str, category: str) -> str:
"""获取 Outliner 文件夹路径。"""
try:
from config.zone_config import get_folder_path
return get_folder_path(zone_id, category)
except ImportError:
zone = self.zones.get(zone_id)
zone_name = zone.zone_name if zone else "Unknown"
return f"LVL_Level/{zone_id}_{zone_name}/{category}"
def _next_seq(self, zone_id: str, category: str) -> int:
"""获取下一个序号。"""
key = f"{zone_id}_{category}"
if key not in self.actor_counters:
self.actor_counters[key] = 0
self.actor_counters[key] += 1
return self.actor_counters[key]