feat(tools): 白模关卡自动生成Python脚本——Level Builder

两阶段流水线:搭建指南Markdown → JSON → UE5 Actor
- 解析器:4种表格变体+范围展开+坐标表达式计算
- 配置:37种BSP资产映射+13种材质+7区域定义
- 验证:265个Actor全部通过,0错误
- Builder:UE5 Python API自动放置Actor+材质+Outliner
- 报告:自动生成搭建报告Markdown

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 23:03:45 +08:00
parent 8011cb6472
commit 0f96b79dba
74 changed files with 10468 additions and 109 deletions
@@ -0,0 +1,256 @@
"""UE5 场景构建器。
在 UE5 编辑器的 Python 环境中运行。读取 JSON 中间数据,
使用 unreal Python API 创建 Actor、设置 Transform/材质/Outliner 文件夹。
使用方式:
在 UE5 编辑器 Output Log 中切换到 Python,执行:
exec(open(r"path/to/scene_builder.py").read())
build_level(r"path/to/level_v01.json")
"""
import json
import logging
import sys
from pathlib import Path
# 尝试导入 unreal,失败时给出提示
try:
import unreal
HAS_UNREAL = True
except ImportError:
HAS_UNREAL = False
# 将项目根目录加入 sys.path 以支持 config 导入
_SCRIPT_DIR = Path(__file__).resolve().parent
_TOOLS_DIR = _SCRIPT_DIR.parent.parent.parent
if str(_TOOLS_DIR) not in sys.path:
sys.path.insert(0, str(_TOOLS_DIR))
from tools.level_builder.config.asset_mapping import MESH_ASSETS
from tools.level_builder.config.material_mapping import MATERIAL_PATHS
logger = logging.getLogger("level_builder.builder")
def build_level(json_path: str, clear_existing: bool = True, zones: list[str] = None):
"""从 JSON 数据构建 UE5 关卡。
Args:
json_path: 中间 JSON 文件路径。
clear_existing: 是否清除已有的 LVL_* Actor。
zones: 仅构建指定区域(如 ["Z1", "Z3"]),None 表示全部。
"""
if not HAS_UNREAL:
print("[错误] 未检测到 unreal 模块。此脚本必须在 UE5 编辑器 Python 环境中运行。")
return
# 加载数据
data = _load_json(json_path)
if not data:
return
actors = data.get("actors", [])
print(f"[LevelBuilder] 加载 {len(actors)} 个 Actor 定义")
# 可选:清除已有 LVL_* Actor
if clear_existing:
_clear_existing_actors()
# 过滤区域
if zones:
actors = [a for a in actors if a.get("zone_id") in zones or a.get("zone_id") == "GLOBAL"]
print(f"[LevelBuilder] 过滤为 {len(actors)} 个 Actor(区域: {zones})")
# 获取编辑器子系统
editor_subsystem = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
if not editor_subsystem:
print("[错误] 无法获取 EditorActorSubsystem")
return
# 统计
success = 0
failed = 0
build_log = []
for i, actor_def in enumerate(actors):
name = actor_def.get("actor_name", f"Actor_{i}")
asset_type = actor_def.get("asset_type", "static_mesh")
try:
if asset_type == "player_start":
actor = _spawn_player_start(editor_subsystem, actor_def)
elif asset_type == "directional_light":
actor = _spawn_directional_light(editor_subsystem, actor_def)
elif asset_type == "sky_light":
actor = _spawn_sky_light(editor_subsystem, actor_def)
elif asset_type == "point_light":
actor = _spawn_point_light(editor_subsystem, actor_def)
else:
actor = _spawn_mesh_actor(editor_subsystem, actor_def)
if actor:
success += 1
build_log.append({"name": name, "status": "ok", **actor_def})
else:
failed += 1
build_log.append({"name": name, "status": "failed", "reason": "spawn returned None"})
except Exception as e:
failed += 1
error_msg = str(e)
build_log.append({"name": name, "status": "error", "reason": error_msg})
print(f" [错误] {name}: {error_msg}")
# 进度
if (i + 1) % 20 == 0:
print(f" 进度: {i + 1}/{len(actors)} ({success} 成功, {failed} 失败)")
print(f"\n[LevelBuilder] 构建完成!")
print(f" 成功: {success}")
print(f" 失败: {failed}")
print(f" 总计: {len(actors)}")
# 保存构建日志
log_path = Path(json_path).parent / "build_log.json"
with open(log_path, "w", encoding="utf-8") as f:
json.dump(build_log, f, ensure_ascii=False, indent=2)
print(f" 日志: {log_path}")
return build_log
def _load_json(path: str) -> dict:
"""加载 JSON 文件。"""
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception as e:
print(f"[错误] 无法加载 JSON: {e}")
return None
def _clear_existing_actors():
"""清除场景中所有 LVL_ 前缀的 Actor。"""
editor_subsystem = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
all_actors = unreal.EditorLevelLibrary.get_all_level_actors()
lvl_actors = [a for a in all_actors if a.get_actor_label().startswith("LVL_")]
if lvl_actors:
print(f"[LevelBuilder] 清除 {len(lvl_actors)} 个已有 LVL_* Actor...")
for a in lvl_actors:
unreal.EditorLevelLibrary.destroy_actor(a)
else:
print("[LevelBuilder] 无已有 LVL_* Actor 需清除")
def _spawn_mesh_actor(editor_subsystem, actor_def: dict):
"""生成 StaticMesh Actor。"""
asset_key = actor_def.get("asset_key", "")
if not asset_key or asset_key not in MESH_ASSETS:
print(f" [警告] {actor_def['actor_name']}: 资产 '{asset_key}' 未找到,使用默认 Cube")
asset_key = "Cube"
mesh_path = MESH_ASSETS[asset_key]["path"]
static_mesh = unreal.load_asset(mesh_path)
if not static_mesh:
print(f" [警告] {actor_def['actor_name']}: 无法加载资产 '{mesh_path}'")
return None
# 生成
loc = actor_def.get("location", [0, 0, 0])
actor = editor_subsystem.spawn_actor_from_object(
static_mesh,
unreal.Vector(loc[0], loc[1], loc[2])
)
if not actor:
return None
# 设置 Transform
rot = actor_def.get("rotation", [0, 0, 0])
actor.set_actor_rotation(unreal.Rotator(rot[0], rot[1], rot[2]))
scale = actor_def.get("scale", [1, 1, 1])
actor.set_actor_scale3d(unreal.Vector(scale[0], scale[1], scale[2]))
# 设置材质
material_name = actor_def.get("material", "")
if material_name and material_name in MATERIAL_PATHS:
mat_path = MATERIAL_PATHS[material_name]
material = unreal.load_asset(mat_path)
if material:
mesh_comp = actor.get_component_by_class(unreal.StaticMeshComponent)
if mesh_comp:
mesh_comp.set_material(0, material)
# 设置名称和文件夹
actor.set_actor_label(actor_def["actor_name"])
folder = actor_def.get("folder", "")
if folder:
actor.set_folder_path(folder)
return actor
def _spawn_player_start(editor_subsystem, actor_def: dict):
"""生成 PlayerStart Actor。"""
loc = actor_def.get("location", [0, 0, 0])
actor = editor_subsystem.spawn_actor_from_class(
unreal.PlayerStart,
unreal.Vector(loc[0], loc[1], loc[2])
)
if actor:
rot = actor_def.get("rotation", [0, 0, 0])
actor.set_actor_rotation(unreal.Rotator(rot[0], rot[1], rot[2]))
actor.set_actor_label(actor_def["actor_name"])
folder = actor_def.get("folder", "")
if folder:
actor.set_folder_path(folder)
return actor
def _spawn_directional_light(editor_subsystem, actor_def: dict):
"""生成 DirectionalLight Actor。"""
loc = actor_def.get("location", [0, 0, 500])
actor = editor_subsystem.spawn_actor_from_class(
unreal.DirectionalLight,
unreal.Vector(loc[0], loc[1], loc[2])
)
if actor:
rot = actor_def.get("rotation", [-45, 315, 0])
actor.set_actor_rotation(unreal.Rotator(rot[0], rot[1], rot[2]))
actor.set_actor_label(actor_def["actor_name"])
folder = actor_def.get("folder", "")
if folder:
actor.set_folder_path(folder)
return actor
def _spawn_sky_light(editor_subsystem, actor_def: dict):
"""生成 SkyLight Actor。"""
loc = actor_def.get("location", [0, 0, 0])
actor = editor_subsystem.spawn_actor_from_class(
unreal.SkyLight,
unreal.Vector(loc[0], loc[1], loc[2])
)
if actor:
actor.set_actor_label(actor_def["actor_name"])
folder = actor_def.get("folder", "")
if folder:
actor.set_folder_path(folder)
return actor
def _spawn_point_light(editor_subsystem, actor_def: dict):
"""生成 PointLight Actor。"""
loc = actor_def.get("location", [0, 0, 0])
actor = editor_subsystem.spawn_actor_from_class(
unreal.PointLight,
unreal.Vector(loc[0], loc[1], loc[2])
)
if actor:
actor.set_actor_label(actor_def["actor_name"])
folder = actor_def.get("folder", "")
if folder:
actor.set_folder_path(folder)
return actor