diff --git a/.claude/skills/level-builder/SKILL.md b/.claude/skills/level-builder/SKILL.md index fd72413..58184b5 100644 --- a/.claude/skills/level-builder/SKILL.md +++ b/.claude/skills/level-builder/SKILL.md @@ -87,35 +87,53 @@ python main.py validate -i output/level_v01.json **生成文件**: `output/validation_result.json` -### 第三步:UE5 编辑器内构建(需要 UE5 编辑器运行) +### 第三步:UE5 构建 -在 UE5 编辑器 Python 控制台中执行: +有两种方式,**推荐使用命令行方式**(全自动,无需手动操作编辑器): +#### 方式 A:命令行一键构建(推荐) + +```bat +REM 从项目根目录运行 +tools\level-builder\build_level.bat ``` -方式 A — Output Log Python 控制台: - exec(open(r"tools/level-builder/main.py").read()) - main(["build", "-i", "output/level_v01.json"]) -方式 B — 仅构建指定区域: - main(["build", "-i", "output/level_v01.json", "--zones", "Z1,Z3"]) - -方式 C — 增量构建(不清除已有 Actor): - main(["build", "-i", "output/level_v01.json", "--no-clear"]) +**高级用法**: +```bat +build_level.bat --json output/v02.json --map LVL_SuburbanV02 # 指定 JSON 和关卡名 +build_level.bat --no-quit # 构建后保留编辑器 +build_level.bat --no-null-rhi # 显示编辑器窗口(调试) +build_level.bat --dry-run # 只打印命令不执行 ``` +**环境变量**: +- `UE5_ROOT` — UE5 安装目录(未设置时自动检测 5.7/5.6 常见路径) + **预期输出**: ``` [LevelBuilder] 加载 265 个 Actor 定义 -[LevelBuilder] 清除 0 个已有 LVL_* Actor... - 进度: 20/265 (20 成功, 0 失败) - ... -[LevelBuilder] 构建完成! - 成功: 265 - 失败: 0 - 日志: output/build_log.json +[LevelBuilder] SUCCESS: 265 | FAILED: 0 | TOTAL: 265 +[LevelBuilder] ✅ 关卡已保存: /Game/Maps/LVL_SuburbanV01 ``` -**生成文件**: `output/build_log.json` +**生成文件**: +- `Content/Maps/LVL_SuburbanV01.umap` — UE5 关卡文件 +- `tools/level-builder/output/build_log.json` — 构建日志 + +#### 方式 B:编辑器内手动执行 + +在 UE5 编辑器 Python 控制台中: + +``` +方式 B1 — Output Log Python 控制台: + exec(open(r"tools/level-builder/build_in_editor.py").read()) + +方式 B2 — File > Execute Python Script > 选择 build_in_editor.py + +方式 B3 — CLI 方式(仅构建指定区域): + exec(open(r"tools/level-builder/main.py").read()) + main(["build", "-i", "output/level_v01.json", "--zones", "Z1,Z3"]) +``` ### 第四步:生成搭建报告(离线,Claude 可执行) diff --git a/tools/level-builder/build_cmdline.py b/tools/level-builder/build_cmdline.py new file mode 100644 index 0000000..e5d7e76 --- /dev/null +++ b/tools/level-builder/build_cmdline.py @@ -0,0 +1,183 @@ +"""UE5 命令行白模关卡构建脚本。 + +由 build_level.bat 通过 UE5 的 -ExecutePythonScript 参数调用。 +在 UE5 编辑器 Python 环境中运行,无需手动打开编辑器。 + +用法: + build_level.bat # 默认配置 + build_level.bat --json output/v02.json # 指定 JSON + build_level.bat --no-quit # 构建后不退出编辑器 + +环境变量(由 build_level.bat 设置): + LB_JSON_PATH — JSON 数据文件路径(相对于项目根目录) + LB_MAP_NAME — 目标关卡资产名 + LB_MAP_PACKAGE — 关卡包路径(如 /Game/Maps) + LB_NO_QUIT — 设为 "1" 不退出编辑器 +""" + +import json +import os +import sys +import time +from pathlib import Path + +# ---- 从环境变量读取配置 ---- +json_rel = os.environ.get("LB_JSON_PATH", "tools/level-builder/output/level_v01.json") +map_name = os.environ.get("LB_MAP_NAME", "LVL_SuburbanV01") +map_package = os.environ.get("LB_MAP_PACKAGE", "/Game/Maps") +no_quit = os.environ.get("LB_NO_QUIT", "0") == "1" +map_asset_path = f"{map_package}/{map_name}" + +# ---- 检查 unreal 模块 ---- +try: + import unreal +except ImportError: + print("[LevelBuilder] FATAL: unreal 模块不可用,必须在 UE5 环境中运行") + sys.exit(1) + +# ---- 项目根目录 ---- +# UE5 -ExecutePythonScript 模式下 CWD 是引擎目录,不是项目目录 +# 所以必须通过环境变量传入项目路径 +_project_root_str = os.environ.get("LB_PROJECT_ROOT", "") +if _project_root_str: + PROJECT_ROOT = Path(_project_root_str) +else: + # Fallback: 尝试从 .uproject 文件定位 + _cwd = Path(os.getcwd()) + if (_cwd / "ChildHood.uproject").exists(): + PROJECT_ROOT = _cwd + else: + # 最后尝试 unreal API + try: + PROJECT_ROOT = Path(unreal.Paths.project_dir()).parent + except Exception: + PROJECT_ROOT = _cwd + +json_path = PROJECT_ROOT / json_rel + +print(f"[LevelBuilder] ==========================================") +print(f"[LevelBuilder] ChildHood Level Builder — Command Line") +print(f"[LevelBuilder] ==========================================") +print(f"[LevelBuilder] Project: {PROJECT_ROOT}") +print(f"[LevelBuilder] JSON: {json_rel}") +print(f"[LevelBuilder] Map: {map_asset_path}") +print(f"[LevelBuilder]") + + +# ---- 等待编辑器子系统就绪 ---- +def wait_for_editor(timeout=90): + """轮询等待 EditorActorSubsystem 可用。""" + print("[LevelBuilder] 等待编辑器初始化...") + start = time.time() + while time.time() - start < timeout: + try: + subsys = unreal.get_editor_subsystem(unreal.EditorActorSubsystem) + if subsys is not None: + elapsed = time.time() - start + print(f"[LevelBuilder] 编辑器就绪 ({elapsed:.1f}s)") + return True + except Exception: + pass + time.sleep(1) + print(f"[LevelBuilder] FATAL: 编辑器初始化超时 ({timeout}s)") + return False + + +if not wait_for_editor(): + sys.exit(1) + + +# ---- 设置 sys.path ---- +lb_dir = str(PROJECT_ROOT / "tools" / "level-builder") +for p in [str(PROJECT_ROOT), lb_dir]: + if p not in sys.path: + sys.path.insert(0, p) + + +# ---- 确保目标关卡存在 ---- +def ensure_map(asset_path): + """创建或加载目标关卡。成功返回 True。""" + # 已存在 → 加载 + if unreal.EditorAssetLibrary.does_asset_exist(asset_path): + print(f"[LevelBuilder] 加载已有关卡: {asset_path}") + unreal.EditorLevelLibrary.load_level(asset_path) + return True + + print(f"[LevelBuilder] 创建新关卡: {asset_path}") + + # 方法 1: new_level(asset_path) — UE5.7 EditorLevelLibrary 直接创建 + try: + unreal.EditorLevelLibrary.new_level(asset_path) + print("[LevelBuilder] ✅ 关卡创建成功 (new_level)") + return True + except Exception as e: + print(f"[LevelBuilder] new_level 失败: {e}") + + # 方法 2: AssetTools + WorldFactory(某些版本可用) + try: + asset_tools = unreal.get_editor_subsystem(unreal.AssetToolsSubsystem) + factory = unreal.WorldFactory() + new_asset = asset_tools.create_asset( + asset_name=map_name, + package_path=map_package, + asset_class=None, + factory=factory, + ) + if new_asset: + unreal.EditorLevelLibrary.load_level(asset_path) + print("[LevelBuilder] ✅ 关卡创建成功 (WorldFactory)") + return True + except Exception as e: + print(f"[LevelBuilder] WorldFactory 失败: {e}") + + print("[LevelBuilder] FATAL: 无法创建关卡") + return False + + +if not ensure_map(map_asset_path): + sys.exit(1) + + +# ---- 验证 JSON ---- +if not json_path.exists(): + print(f"[LevelBuilder] FATAL: JSON 文件不存在: {json_path}") + sys.exit(1) + + +# ---- 导入并执行构建 ---- +from builder.scene_builder import build_level + +print(f"[LevelBuilder] 开始构建...") +build_log = build_level(str(json_path), clear_existing=True) + + +# ---- 保存关卡 ---- +print("[LevelBuilder] 保存关卡...") +try: + unreal.EditorLevelLibrary.save_current_level() + print(f"[LevelBuilder] ✅ 关卡已保存: {map_asset_path}") +except Exception as e: + print(f"[LevelBuilder] ⚠️ 保存失败: {e}") + + +# ---- 构建结果汇总 ---- +if build_log: + ok = sum(1 for b in build_log if b.get("status") == "ok") + fail = len(build_log) - ok + print(f"") + print(f"[LevelBuilder] {'=' * 40}") + print(f"[LevelBuilder] SUCCESS: {ok} | FAILED: {fail} | TOTAL: {len(build_log)}") + print(f"[LevelBuilder] {'=' * 40}") + if fail > 0: + print(f"[LevelBuilder] 失败列表:") + for b in build_log: + if b.get("status") != "ok": + print(f" ❌ {b.get('name', '?')}: {b.get('reason', '未知')}") + + +# ---- 退出编辑器 ---- +if not no_quit: + print("[LevelBuilder] 退出编辑器...") + unreal.SystemLibrary.execute_console_command(None, "quit") + +print("[LevelBuilder] Done.") diff --git a/tools/level-builder/build_level.bat b/tools/level-builder/build_level.bat new file mode 100644 index 0000000..4b08778 --- /dev/null +++ b/tools/level-builder/build_level.bat @@ -0,0 +1,192 @@ +@echo off +setlocal enabledelayedexpansion + +REM === ChildHood Level Builder - Command Line Build === +REM +REM Usage: +REM build_level.bat (default config) +REM build_level.bat --json output/v02.json (specify JSON) +REM build_level.bat --map LVL_SuburbanV02 (specify map name) +REM build_level.bat --no-quit (keep editor open) +REM build_level.bat --no-null-rhi (show editor window) +REM build_level.bat --dry-run (print command only) + +set "SCRIPT_DIR=%~dp0" +set "PROJECT_DIR=%SCRIPT_DIR%..\.." +for %%i in ("%PROJECT_DIR%\.") do set "PROJECT_ROOT=%%~fi" +set "UPROJECT=%PROJECT_ROOT%\ChildHood.uproject" + +REM ---- Defaults ---- +set "JSON_REL=tools/level-builder/output/level_v01.json" +set "MAP_NAME=LVL_SuburbanV01" +set "MAP_PACKAGE=/Game/Maps" +set "NO_QUIT=0" +set "NO_NULL_RHI=0" +set "DRY_RUN=0" + +REM ---- Parse Arguments ---- +:parse_args +if "%~1"=="" goto done_args +if /i "%~1"=="--json" ( + set "JSON_REL=%~2" + shift + shift + goto parse_args +) +if /i "%~1"=="--map" ( + set "MAP_NAME=%~2" + shift + shift + goto parse_args +) +if /i "%~1"=="--package" ( + set "MAP_PACKAGE=%~2" + shift + shift + goto parse_args +) +if /i "%~1"=="--no-quit" ( + set "NO_QUIT=1" + shift + goto parse_args +) +if /i "%~1"=="--no-null-rhi" ( + set "NO_NULL_RHI=1" + shift + goto parse_args +) +if /i "%~1"=="--dry-run" ( + set "DRY_RUN=1" + shift + goto parse_args +) +if /i "%~1"=="--help" goto show_help +if /i "%~1"=="-h" goto show_help +echo [WARN] Unknown argument: %~1 +shift +goto parse_args +:done_args + +REM ---- Find UE5 Editor ---- +set "UE_EDITOR=" +if defined UE5_ROOT ( + for %%b in (UnrealEditor.exe UE5Editor.exe) do ( + if exist "%UE5_ROOT%\Engine\Binaries\Win64\%%b" ( + set "UE_EDITOR=%UE5_ROOT%\Engine\Binaries\Win64\%%b" + ) + ) +) + +if "%UE_EDITOR%"=="" ( + for %%p in ( + "C:\Program Files\Epic Games\UE_5.7" + "C:\Program Files (x86)\Epic Games\UE_5.7" + "D:\Program Files\Epic Games\UE_5.7" + "E:\Program Files\Epic Games\UE_5.7" + "C:\Program Files\Epic Games\UE_5.6" + "C:\Program Files (x86)\Epic Games\UE_5.6" + "D:\Program Files\Epic Games\UE_5.6" + "E:\Program Files\Epic Games\UE_5.6" + ) do ( + if not defined UE_EDITOR ( + for %%b in (UnrealEditor.exe UE5Editor.exe) do ( + if exist "%%~p\Engine\Binaries\Win64\%%b" ( + set "UE_EDITOR=%%~p\Engine\Binaries\Win64\%%b" + ) + ) + ) + ) +) + +if "%UE_EDITOR%"=="" ( + echo. + echo [ERROR] UE5 Editor not found! + echo Set UE5_ROOT to your UE5 install directory, e.g.: + echo set UE5_ROOT=C:\Program Files\Epic Games\UE_5.7 + echo. + exit /b 1 +) + +REM ---- Path conversion (UE5 uses forward slashes) ---- +set "SCRIPT_PATH=%SCRIPT_DIR%build_cmdline.py" +set "SCRIPT_PATH=!SCRIPT_PATH:\=/!" +set "UPROJECT_FWD=!UPROJECT:\=/!" + +REM ---- Build command line ---- +set "CMD_ARGS=-unattended -NoSound" +if "!NO_NULL_RHI!"=="0" set "CMD_ARGS=!CMD_ARGS! -NullRHI" +set "CMD_ARGS=!CMD_ARGS! -ExecutePythonScript=!SCRIPT_PATH!" + +REM ---- Pass config to Python via env vars ---- +set "LB_PROJECT_ROOT=!PROJECT_ROOT!" +set "LB_JSON_PATH=%JSON_REL%" +set "LB_MAP_NAME=%MAP_NAME%" +set "LB_MAP_PACKAGE=%MAP_PACKAGE%" +if "!NO_QUIT!"=="1" set "LB_NO_QUIT=1" + +REM ---- Print info ---- +echo. +echo ============================================ +echo ChildHood Level Builder - Command Line +echo ============================================ +echo UE5: !UE_EDITOR! +echo Project: !UPROJECT_FWD! +echo JSON: %JSON_REL% +echo Map: %MAP_PACKAGE%/%MAP_NAME% +echo NullRHI: %NO_NULL_RHI% +echo ============================================ +echo. + +REM ---- Dry Run ---- +if "%DRY_RUN%"=="1" ( + echo [DRY RUN] Command: + echo. + echo "!UE_EDITOR!" "!UPROJECT_FWD!" !CMD_ARGS! + echo. + exit /b 0 +) + +REM ---- Check JSON exists ---- +set "JSON_ABS=%PROJECT_ROOT%\%JSON_REL%" +set "JSON_ABS=!JSON_ABS:/=\!" +if not exist "!JSON_ABS!" ( + echo [ERROR] JSON not found: !JSON_ABS! + echo Run first: python tools/level-builder/main.py parse + exit /b 1 +) + +REM ---- Execute ---- +echo [LevelBuilder] Launching UE5 Editor (command line mode)... +echo [LevelBuilder] Editor will auto-save and quit after build. +echo. + +"!UE_EDITOR!" "!UPROJECT_FWD!" !CMD_ARGS! + +set "EXIT_CODE=!ERRORLEVEL!" +echo. +if "!EXIT_CODE!"=="0" ( + echo [LevelBuilder] Build complete. Editor exited. +) else ( + echo [LevelBuilder] Editor exit code: !EXIT_CODE! +) +exit /b !EXIT_CODE! + +:show_help +echo. +echo ChildHood Level Builder - Command Line Build +echo. +echo Usage: build_level.bat [options] +echo. +echo Options: +echo --json PATH JSON data file (relative to project root) +echo --map NAME Target map asset name (default: LVL_SuburbanV01) +echo --package PATH Map package path (default: /Game/Maps) +echo --no-quit Keep editor open after build +echo --no-null-rhi Show editor window (for debugging) +echo --dry-run Print command without executing +echo --help, -h Show this help +echo. +echo Environment: +echo UE5_ROOT UE5 install directory +echo. +exit /b 0 diff --git a/tools/level-builder/builder/scene_builder.py b/tools/level-builder/builder/scene_builder.py index 80d0c29..b3edfb0 100644 --- a/tools/level-builder/builder/scene_builder.py +++ b/tools/level-builder/builder/scene_builder.py @@ -21,14 +21,15 @@ try: 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)) +# 将 level-builder 根目录加入 sys.path 以支持 config 导入 +# 注意: 目录名含连字符 (level-builder),不能用 tools.level_builder 形式导入 +_SCRIPT_DIR = Path(__file__).resolve().parent # builder/ +_LB_DIR = _SCRIPT_DIR.parent # tools/level-builder/ +if str(_LB_DIR) not in sys.path: + sys.path.insert(0, str(_LB_DIR)) -from tools.level_builder.config.asset_mapping import MESH_ASSETS -from tools.level_builder.config.material_mapping import MATERIAL_PATHS +from config.asset_mapping import MESH_ASSETS +from config.material_mapping import MATERIAL_PATHS logger = logging.getLogger("level_builder.builder") @@ -168,7 +169,7 @@ def _spawn_mesh_actor(editor_subsystem, actor_def: dict): # 设置 Transform rot = actor_def.get("rotation", [0, 0, 0]) - actor.set_actor_rotation(unreal.Rotator(rot[0], rot[1], rot[2])) + actor.set_actor_rotation(unreal.Rotator(rot[0], rot[1], rot[2]), False) scale = actor_def.get("scale", [1, 1, 1]) actor.set_actor_scale3d(unreal.Vector(scale[0], scale[1], scale[2])) @@ -201,7 +202,7 @@ def _spawn_player_start(editor_subsystem, actor_def: dict): ) 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_rotation(unreal.Rotator(rot[0], rot[1], rot[2]), False) actor.set_actor_label(actor_def["actor_name"]) folder = actor_def.get("folder", "") if folder: @@ -218,7 +219,7 @@ def _spawn_directional_light(editor_subsystem, actor_def: dict): ) 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_rotation(unreal.Rotator(rot[0], rot[1], rot[2]), False) actor.set_actor_label(actor_def["actor_name"]) folder = actor_def.get("folder", "") if folder: