6f8b599629
- 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>
424 lines
15 KiB
Python
424 lines
15 KiB
Python
"""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]
|