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
+211
View File
@@ -0,0 +1,211 @@
"""BSP 资产设计名 → UE5 内容路径映射。
数据来源: docs/technical/关卡-v01-搭建指南.md 第一章 (BSP 资产映射表)
资产根路径: Content/BlockingStarterPack/Meshes/
路径格式: /Game/<ContentSubPath>/<AssetName>.<AssetName>
"""
# 资产路径前缀
MESH_ROOT = "/Game/BlockingStarterPack/Meshes"
MATERIAL_ROOT = "/Game/BlockingStarterPack/Materials"
MESH_ASSETS = {
# ---- 建筑 — 墙体 ----
"Wall_400x300": {
"path": f"{MESH_ROOT}/Architecture/Walls/SM_Wall_400x300.SM_Wall_400x300",
"default_size": (400, 20, 300),
"category": "wall",
},
"Wall_200x300": {
"path": f"{MESH_ROOT}/Architecture/Walls/SM_Wall_200x300.SM_Wall_200x300",
"default_size": (200, 20, 300),
"category": "wall",
},
"Wall_100x300": {
"path": f"{MESH_ROOT}/Architecture/Walls/SM_Wall_100x300.SM_Wall_100x300",
"default_size": (100, 20, 300),
"category": "wall",
},
"Wall_400x100": {
"path": f"{MESH_ROOT}/Architecture/Walls/SM_Wall_400x100.SM_Wall_400x100",
"default_size": (400, 20, 100),
"category": "wall",
},
"WallDoor_200x300": {
"path": f"{MESH_ROOT}/Architecture/Walls/SM_WallDoor_200x300.SM_WallDoor_200x300",
"default_size": (200, 20, 300),
"category": "wall_door",
},
"WallDoorGarage_400x300": {
"path": f"{MESH_ROOT}/Architecture/Walls/SM_WallDoorGarage_400x300.SM_WallDoorGarage_400x300",
"default_size": (400, 20, 300),
"category": "wall_door",
},
"WallWindow_400x300": {
"path": f"{MESH_ROOT}/Architecture/Walls/SM_WallWindow_400x300.SM_WallWindow_400x300",
"default_size": (400, 20, 300),
"category": "wall_window",
},
"WallWindowSmall_200x300": {
"path": f"{MESH_ROOT}/Architecture/Walls/SM_WallWindowSmall_200x300.SM_WallWindowSmall_200x300",
"default_size": (200, 20, 300),
"category": "wall_window",
},
# ---- 建筑 — 地板 ----
"Floor_1000x1000": {
"path": f"{MESH_ROOT}/Architecture/Floors/SM_Floor_1000x1000.SM_Floor_1000x1000",
"default_size": (1000, 1000, 10),
"category": "floor",
},
"Floor_300x400": {
"path": f"{MESH_ROOT}/Architecture/Floors/SM_Floor_300x400.SM_Floor_300x400",
"default_size": (300, 400, 10),
"category": "floor",
},
"Floor_200x300": {
"path": f"{MESH_ROOT}/Architecture/Floors/SM_Floor_200x300.SM_Floor_200x300",
"default_size": (200, 300, 10),
"category": "floor",
},
# ---- 建筑 — 门 ----
"RotativeDoor_01": {
"path": f"{MESH_ROOT}/Architecture/Doors/SM_RotativeDoor_01.SM_RotativeDoor_01",
"default_size": None,
"category": "door",
},
"GarageDoorOpen_01": {
"path": f"{MESH_ROOT}/Architecture/Doors/SM_GarageDoorOpen_01.SM_GarageDoorOpen_01",
"default_size": None,
"category": "door",
},
# ---- 建筑 — 窗户 ----
"FakeWindowsFrame": {
"path": f"{MESH_ROOT}/Architecture/WindowsSet/SM_FakeWindowsFrame.SM_FakeWindowsFrame",
"default_size": None,
"category": "window",
},
# ---- 建筑 — 围栏/围挡 ----
"BarrierWall_600": {
"path": f"{MESH_ROOT}/Architecture/Barriers/SM_BarrierWall_600.SM_BarrierWall_600",
"default_size": (600, 20, 100),
"category": "barrier",
},
"BarrierWall_200": {
"path": f"{MESH_ROOT}/Architecture/Barriers/SM_BarrierWall_200.SM_BarrierWall_200",
"default_size": (200, 20, 100),
"category": "barrier",
},
"Fence_Small_400": {
"path": f"{MESH_ROOT}/Industrials/SM_Fence_Small_400.SM_Fence_Small_400",
"default_size": (400, 5, 100),
"category": "fence",
},
# ---- 建筑 — 横梁 ----
"Beam_300": {
"path": f"{MESH_ROOT}/Architecture/Beams/SM_Beam_300.SM_Beam_300",
"default_size": None,
"category": "beam",
},
# ---- 建筑 — 大型建筑 ----
"Building_01": {
"path": f"{MESH_ROOT}/BigBuildings/SM_Building_01.SM_Building_01",
"default_size": None,
"category": "building",
},
# ---- 家具 ----
"SimpleTable_01": {
"path": f"{MESH_ROOT}/Furnitures/SM_SimpleTable_01.SM_SimpleTable_01",
"default_size": None,
"category": "furniture",
},
"SimpleTable_02": {
"path": f"{MESH_ROOT}/Furnitures/SM_SimpleTable_02.SM_SimpleTable_02",
"default_size": None,
"category": "furniture",
},
"BedSingle": {
"path": f"{MESH_ROOT}/Bedroom/SM_BedSingle.SM_BedSingle",
"default_size": None,
"category": "furniture",
},
"Monitor_01": {
"path": f"{MESH_ROOT}/Furnitures/SM_Monitor_01.SM_Monitor_01",
"default_size": None,
"category": "furniture",
},
# ---- 工业/装饰 ----
"Barrel": {
"path": f"{MESH_ROOT}/Industrials/SM_Barrel.SM_Barrel",
"default_size": None,
"category": "industrial",
},
"WoodenBox_Small": {
"path": f"{MESH_ROOT}/Industrials/SM_WoodenBox_Small.SM_WoodenBox_Small",
"default_size": None,
"category": "industrial",
},
"WoodenBox_Normal": {
"path": f"{MESH_ROOT}/Industrials/SM_WoodenBox_Normal.SM_WoodenBox_Normal",
"default_size": None,
"category": "industrial",
},
"WoodenPallet_01": {
"path": f"{MESH_ROOT}/Industrials/SM_WoodenPallet_01.SM_WoodenPallet_01",
"default_size": None,
"category": "industrial",
},
"Bucket_01": {
"path": f"{MESH_ROOT}/Decorations/SM_Bucket_01.SM_Bucket_01",
"default_size": None,
"category": "industrial",
},
"TrashCans_01": {
"path": f"{MESH_ROOT}/Trashs/SM_TrashCans_01.SM_TrashCans_01",
"default_size": None,
"category": "industrial",
},
"Container_Normal": {
"path": f"{MESH_ROOT}/Industrials/SM_Container_Normal.SM_Container_Normal",
"default_size": None,
"category": "industrial",
},
# ---- 载具 ----
"Car01": {
"path": f"{MESH_ROOT}/Vehicles/SM_Car01.SM_Car01",
"default_size": None,
"category": "vehicle",
},
# ---- 基础体 ----
"Cube": {
"path": f"{MESH_ROOT}/Primitives/SM_Cube.SM_Cube",
"default_size": (100, 100, 100),
"category": "primitive",
},
"Cylinder": {
"path": f"{MESH_ROOT}/Primitives/SM_Cylinder.SM_Cylinder",
"default_size": (100, 100, 100),
"category": "primitive",
},
"CirclePlatform": {
"path": f"{MESH_ROOT}/Extra/SM_CirclePlatform.SM_CirclePlatform",
"default_size": None,
"category": "primitive",
},
# ---- 其他 ----
"Tree_01": {
"path": f"{MESH_ROOT}/Vegetations/SM_Tree_01.SM_Tree_01",
"default_size": None,
"category": "vegetation",
},
"RoadLamp_01": {
"path": f"{MESH_ROOT}/Lights/SM_RoadLamp_01.SM_RoadLamp_01",
"default_size": None,
"category": "light_fixture",
},
"DisplayPanels_400x200": {
"path": f"{MESH_ROOT}/DisplayPanels/SM_DisplayPanels_400x200.SM_DisplayPanels_400x200",
"default_size": None,
"category": "decoration",
},
}
@@ -0,0 +1,25 @@
"""标识色材质路径映射。
数据来源: docs/technical/关卡-v01-搭建指南.md §1.13
"""
MATERIAL_PATHS = {
# ---- Basics(标识色) ----
"MI_DevRed": "/Game/BlockingStarterPack/Materials/Blocking/Basics/MI_DevRed.MI_DevRed",
"MI_DevOrange": "/Game/BlockingStarterPack/Materials/Blocking/Basics/MI_DevOrange.MI_DevOrange",
"MI_DevGray": "/Game/BlockingStarterPack/Materials/Blocking/Basics/MI_DevGray.MI_DevGray",
"MI_DevBrown": "/Game/BlockingStarterPack/Materials/Blocking/Basics/MI_DevBrown.MI_DevBrown",
"MI_DevWhite": "/Game/BlockingStarterPack/Materials/Blocking/Basics/MI_DevWhite.MI_DevWhite",
"MI_DevBlue": "/Game/BlockingStarterPack/Materials/Blocking/Basics/MI_DevBlue.MI_DevBlue",
"MI_DevGreen": "/Game/BlockingStarterPack/Materials/Blocking/Basics/MI_DevGreen.MI_DevGreen",
"MI_DevSkyBlue": "/Game/BlockingStarterPack/Materials/Blocking/Basics/MI_DevSkyBlue.MI_DevSkyBlue",
"MI_DevYellow": "/Game/BlockingStarterPack/Materials/Blocking/Basics/MI_DevYellow.MI_DevYellow",
"MI_DevFoliage": "/Game/BlockingStarterPack/Materials/Blocking/Basics/MI_DevFoliage.MI_DevFoliage",
"MI_DevPink": "/Game/BlockingStarterPack/Materials/Blocking/Basics/MI_DevPink.MI_DevPink",
# ---- Glass ----
"MI_DevGlassWhite": "/Game/BlockingStarterPack/Materials/Glass/MI_DevGlassWhite.MI_DevGlassWhite",
"MI_DevGlassBlue": "/Game/BlockingStarterPack/Materials/Glass/MI_DevGlassBlue.MI_DevGlassBlue",
}
# 材质在 Content/ 目录下的文件系统路径前缀(用于离线文件存在性验证)
MATERIAL_FS_PREFIX = "Content/BlockingStarterPack/Materials"
@@ -0,0 +1,36 @@
"""Primitives 替代规则。
数据来源: docs/technical/关卡-v01-搭建报告.md 第四节(缩放记录)
当搭建指南中直接指定了资产和缩放时,无需查此表。
此表用于未来执行案解析器,将语义化物件名映射到 Primitives 替代方案。
"""
SUBSTITUTION_RULES = {
# ---- 家具/家电 ----
"冰箱": {"asset": "Cube", "scale": (0.3, 0.3, 1.6), "material": "MI_DevWhite"},
"冰柜": {"asset": "Cube", "scale": (1.0, 0.6, 0.85), "material": "MI_DevWhite"},
"电视柜": {"asset": "SimpleTable_02", "scale": (0.85, 0.75, 0.67)},
# ---- 建筑细节 ----
"门垫": {"asset": "Cube", "scale": (0.6, 0.4, 0.02), "material": "MI_DevGray"},
"日历": {"asset": "Cube", "scale": (0.3, 0.02, 0.4), "material": "MI_DevRed"},
"招牌": {"asset": "Cube", "scale": (3.0, 0.05, 0.4), "material": "MI_DevBlue"},
"涂鸦": {"asset": "Cube", "scale": (1.2, 0.05, 0.8), "material": "MI_DevYellow"},
# ---- 玻璃 ----
"玻璃柜台": {"asset": "Cube", "scale": (2.0, 0.6, 0.9), "material": "MI_DevGlassWhite"},
"积水坑": {"asset": "Cube", "scale": (1.2, 0.8, 0.01), "material": "MI_DevGlassBlue"},
# ---- 街道/城郊物件 ----
"电话亭": {"asset": "Cube", "scale": (0.8, 0.8, 2.2), "material": "MI_DevGray"},
"电线杆": {"asset": "Cylinder", "scale": (0.04, 0.04, 12.0), "material": "MI_DevGray"},
"晾衣杆_柱": {"asset": "Cylinder", "scale": (0.05, 0.05, 3.6), "material": "MI_DevGray"},
"芦苇": {"asset": "Cylinder", "scale": (0.08, 0.08, 2.0), "material": "MI_DevFoliage"},
"花盆": {"asset": "Cylinder", "scale": (0.2, 0.2, 0.25), "material": "MI_DevGreen"},
# ---- 水塘 ----
"荷叶": {"asset": "CirclePlatform", "scale": (0.06, 0.06, 0.01), "material": "MI_DevGreen"},
"青蛙": {"asset": "Cube", "scale": (0.05, 0.05, 0.04), "material": "MI_DevGreen"},
"下水道盖": {"asset": "CirclePlatform", "scale": (0.12, 0.12, 0.02), "material": "MI_DevGray"},
"弹珠圈": {"asset": "CirclePlatform", "scale": (0.16, 0.16, 0.01), "material": "MI_DevGray"},
# ---- 自然 ----
"青苔": {"asset": "Cube", "scale": (0.6, 0.4, 0.01), "material": "MI_DevFoliage"},
"蜘蛛网": {"asset": "Cube", "scale": (0.3, 0.01, 0.3), "material": "MI_DevWhite"},
}
+142
View File
@@ -0,0 +1,142 @@
"""区域定义、Outliner 文件夹结构、Actor 命名规范。
数据来源:
- docs/design/prototype/关卡-v01-城郊小街-执行案.md §二
- docs/technical/关卡-v01-搭建指南.md §〇
"""
# ---- 场景全局参数 ----
SCENE_PARAMS = {
"scene_size": (8000, 6000), # cm
"ground_z": 0,
"character_height": 120, # cm
"camera_height": 100, # cm
"time_of_day": "15:00",
"origin_corner": "西南角地平面",
}
# ---- 区域定义 ----
ZONES = {
"Z1": {
"name": "家",
"name_en": "Home",
"origin": (0, 0, 0),
"bounds": (1000, 800),
"color_material": "MI_DevRed",
},
"Z2": {
"name": "小卖部",
"name_en": "CornerShop",
"origin": (1000, 200, 0),
"bounds": (600, 600),
"color_material": "MI_DevOrange",
},
"Z3": {
"name": "主街道",
"name_en": "MainStreet",
"origin": (0, 800, 0),
"bounds": (5000, 500),
"color_material": "MI_DevGray",
},
"Z4": {
"name": "空地",
"name_en": "VacantLot",
"origin": (3500, 1300, 0),
"bounds": (1500, 1200),
"color_material": "MI_DevBrown",
},
"Z5": {
"name": "巷子",
"name_en": "Alley",
"origin": (2300, 1300, 0),
"bounds": (300, 2000),
"color_material": "MI_DevWhite",
},
"Z6": {
"name": "水塘边",
"name_en": "Pond",
"origin": (1600, 3300, 0),
"bounds": (1500, 1400),
"color_material": "MI_DevSkyBlue",
},
"Z7": {
"name": "老居民楼",
"name_en": "OldBuilding",
"origin": (5800, 200, 0),
"bounds": (1200, 1800),
"color_material": "MI_DevGray",
},
}
# ---- Outliner 文件夹结构 ----
LEVEL_NAME = "LVL_SuburbanV01"
ZONE_FOLDERS = {
"Z1": f"{LEVEL_NAME}/Z1_家",
"Z2": f"{LEVEL_NAME}/Z2_小卖部",
"Z3": f"{LEVEL_NAME}/Z3_主街道",
"Z4": f"{LEVEL_NAME}/Z4_空地",
"Z5": f"{LEVEL_NAME}/Z5_巷子",
"Z6": f"{LEVEL_NAME}/Z6_水塘边",
"Z7": f"{LEVEL_NAME}/Z7_老居民楼",
"GLOBAL": f"{LEVEL_NAME}/Global",
}
CATEGORY_FOLDERS = {
"STR": "Structure",
"PROP": "Props",
"INTR": "Interactables",
"COLL": "Collectibles",
"BND": "Boundaries",
"LGT": "Lighting",
}
GLOBAL_SUBFOLDERS = {
"BND": f"{LEVEL_NAME}/Global/Boundaries",
"LGT": f"{LEVEL_NAME}/Global/Lighting",
"PLAY": f"{LEVEL_NAME}/Global/PlayerStart",
}
def get_folder_path(zone_id: str, category: str) -> str:
"""根据区域 ID 和类别获取 Outliner 文件夹路径。"""
if zone_id == "GLOBAL":
return GLOBAL_SUBFOLDERS.get(category, f"{LEVEL_NAME}/Global")
base = ZONE_FOLDERS.get(zone_id, f"{LEVEL_NAME}/Global")
sub = CATEGORY_FOLDERS.get(category, "")
if sub:
return f"{base}/{sub}"
return base
# ---- Actor 命名模板 ----
# LVL_<ZoneID>_<Category>_<Description>
# 已在搭建指南中给出完整名称,脚本直接使用。
# ---- 搭建顺序 ----
BUILD_ORDER = ["Z1", "Z3", "Z2", "Z4", "Z5", "Z6", "Z7"]
# ---- Player Start ----
PLAYER_START = {
"location": (500, 720, 0),
"rotation": (0, 0, 0), # 面向 +Y(北)
}
# ---- 光照 ----
LIGHTING = {
"directional_light": {
"location": (0, 0, 500),
"rotation": (-45, 45, 0), # 从西南照射,仰角45°
"intensity": 3.0,
"color": (1.0, 0.95, 0.85), # 暖白
},
"sky_light": {
"intensity": 0.5,
},
"street_lights": [
{"location": (1500, 1050, 0), "intensity": 0.3},
{"location": (3000, 1050, 0), "intensity": 0.3},
{"location": (4500, 1050, 0), "intensity": 0.3},
],
}
+199
View File
@@ -0,0 +1,199 @@
"""Level Builder CLI — 白模关卡自动生成工具。
用法:
# Stage 1: 解析搭建指南 → JSON
python main.py parse --input ../../docs/technical/关卡-v01-搭建指南.md --output output/level_v01.json
# Stage 1.5: 验证
python main.py validate --input output/level_v01.json [--project-root ../..]
# Stage 2: UE5 编辑器内构建(需在编辑器 Python 环境中运行)
# 方式 A: 在 Output Log Python 控制台:
# exec(open(r"tools/level-builder/main.py").read())
# main(["build", "--input", "output/level_v01.json"])
# 方式 B: File > Execute Python Script
# Stage 3: 生成报告
python main.py report --input output/level_v01.json [--build-log output/build_log.json] --output report.md
"""
import argparse
import json
import logging
import sys
from pathlib import Path
# 将项目 tools 目录加入 sys.path
_TOOLS_DIR = Path(__file__).resolve().parent
_LEVEL_BUILDER_DIR = _TOOLS_DIR
_PROJECT_ROOT = _TOOLS_DIR.parent.parent
if str(_TOOLS_DIR) not in sys.path:
sys.path.insert(0, str(_TOOLS_DIR))
if str(_PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(_PROJECT_ROOT))
# 配置日志
logging.basicConfig(
level=logging.INFO,
format="[%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger("level_builder")
def cmd_parse(args):
"""解析搭建指南 Markdown → JSON。"""
from parsers.build_guide_parser import BuildGuideParser
input_path = args.input
output_path = args.output or "output/level_data.json"
logger.info(f"解析搭建指南: {input_path}")
parser = BuildGuideParser()
data = parser.parse_file(input_path)
# 写入 JSON
out_path = Path(output_path)
out_path.parent.mkdir(parents=True, exist_ok=True)
with open(out_path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
summary = data.get("summary", {})
logger.info(f"解析完成: {summary.get('total_actors', 0)} 个 Actor")
logger.info(f" 按类别: {summary.get('by_category', {})}")
logger.info(f" 按区域: {summary.get('by_zone', {})}")
logger.info(f" 唯一资产数: {summary.get('unique_assets', 0)}")
logger.info(f"输出: {out_path.resolve()}")
def cmd_validate(args):
"""验证 JSON 数据中的资产引用。"""
from validation.asset_validator import validate_actors
input_path = args.input
project_root = args.project_root or str(_PROJECT_ROOT)
logger.info(f"验证: {input_path}")
with open(input_path, "r", encoding="utf-8") as f:
data = json.load(f)
actors = data.get("actors", [])
result = validate_actors(actors, project_root)
if result.is_valid:
logger.info("✅ 验证通过!")
else:
logger.error(f"❌ 验证失败: {len(result.errors)} 个错误")
for err in result.errors:
logger.error(f" {err}")
if result.warnings:
for warn in result.warnings:
logger.warning(f" {warn}")
logger.info(f"有效 Actor: {result.valid_actors}/{result.total_actors}")
# 保存验证结果
output_path = Path(input_path).parent / "validation_result.json"
with open(output_path, "w", encoding="utf-8") as f:
json.dump({
"is_valid": result.is_valid,
"total": result.total_actors,
"valid": result.valid_actors,
"errors": result.errors,
"warnings": result.warnings,
"missing_assets": sorted(result.missing_assets),
"missing_materials": sorted(result.missing_materials),
}, f, ensure_ascii=False, indent=2)
logger.info(f"验证结果: {output_path}")
def cmd_build(args):
"""在 UE5 编辑器中构建关卡。"""
try:
from builder.scene_builder import build_level
except ImportError:
print("[错误] 无法导入 builder 模块。确保在 UE5 编辑器 Python 环境中运行。")
print(" 在 Output Log 中切换到 Python,然后执行:")
print(f' exec(open(r"{Path(__file__).resolve()}").read())')
print(f' build_level(r"{Path(args.input).resolve()}")')
return
zones = args.zones.split(",") if args.zones else None
build_level(args.input, clear_existing=args.clear, zones=zones)
def cmd_report(args):
"""生成搭建报告。"""
from report.report_generator import generate_report
input_path = args.input
output_path = args.output or "output/搭建报告.md"
build_log_path = args.build_log
logger.info(f"生成报告: {input_path}")
with open(input_path, "r", encoding="utf-8") as f:
data = json.load(f)
build_log = None
if build_log_path:
try:
with open(build_log_path, "r", encoding="utf-8") as f:
build_log = json.load(f)
except FileNotFoundError:
logger.warning(f"构建日志不存在: {build_log_path},生成无状态报告")
report = generate_report(data, build_log, output_path)
logger.info(f"报告已生成: {output_path}")
def main(argv=None):
"""CLI 入口。"""
parser = argparse.ArgumentParser(
description="ChildHood 白模关卡自动生成工具",
prog="level-builder",
)
subparsers = parser.add_subparsers(dest="command", help="子命令")
# parse
p_parse = subparsers.add_parser("parse", help="解析搭建指南 Markdown → JSON")
p_parse.add_argument("--input", "-i", required=True, help="搭建指南 Markdown 文件路径")
p_parse.add_argument("--output", "-o", default="output/level_data.json", help="输出 JSON 路径")
# validate
p_validate = subparsers.add_parser("validate", help="验证 JSON 数据")
p_validate.add_argument("--input", "-i", required=True, help="JSON 文件路径")
p_validate.add_argument("--project-root", "-r", help="项目根目录(用于文件存在性检查)")
# build
p_build = subparsers.add_parser("build", help="在 UE5 中构建关卡")
p_build.add_argument("--input", "-i", required=True, help="JSON 文件路径")
p_build.add_argument("--clear", action="store_true", default=True, help="清除已有 LVL_* Actor")
p_build.add_argument("--no-clear", dest="clear", action="store_false", help="不清除已有 Actor")
p_build.add_argument("--zones", "-z", help="仅构建指定区域,逗号分隔(如 Z1,Z3)")
# report
p_report = subparsers.add_parser("report", help="生成搭建报告")
p_report.add_argument("--input", "-i", required=True, help="JSON 文件路径")
p_report.add_argument("--build-log", "-l", help="构建日志 JSON 路径")
p_report.add_argument("--output", "-o", default="output/搭建报告.md", help="报告输出路径")
args = parser.parse_args(argv)
if args.command == "parse":
cmd_parse(args)
elif args.command == "validate":
cmd_validate(args)
elif args.command == "build":
cmd_build(args)
elif args.command == "report":
cmd_report(args)
else:
parser.print_help()
if __name__ == "__main__":
main()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,9 @@
{
"is_valid": true,
"total": 265,
"valid": 265,
"errors": [],
"warnings": [],
"missing_assets": [],
"missing_materials": []
}
@@ -0,0 +1,455 @@
# 白模搭建报告 — 自动生成
> 生成时间: 2026-06-07 23:03
> 数据来源: 关卡-v01-搭建指南.md
> 场景尺寸: 8000cm × 6000cm
## 一、搭建概要
- **总 Actor 数**: 265
- 结构体 (STR): 123
- 布景 (PROP): 53
- 交互物 (INTR): 10
- 收集品 (COLL): 9
- 边界 (BND): 61
- 光源 (LGT): 8
- PlayerStart (PLAY): 1
## 二、逐区域对照
### Z1 家
共 40 个 Actor
| # | Actor 名称 | 资产 | 位置 | 缩放 | 材质 | 状态 |
|---|-----------|------|------|------|------|------|
| 1 | LVL_Z1_STR_Floor | Floor_1000x1000 | (500,400,0) | (1.00,0.80,1.00) | MI_DevGray | ✅ |
| 2 | LVL_Z1_STR_SouthWall_01 | Wall_400x300 | (200,0,0) | — | MI_DevRed | ✅ |
| 3 | LVL_Z1_STR_SouthWall_02 | Wall_400x300 | (600,0,0) | — | MI_DevRed | ✅ |
| 4 | LVL_Z1_STR_WestWall_01 | Wall_400x300 | (0,200,0) | — | MI_DevRed | ✅ |
| 5 | LVL_Z1_STR_WestWall_02 | Wall_400x300 | (0,600,0) | — | MI_DevRed | ✅ |
| 6 | LVL_Z1_STR_EastWall_01 | Wall_400x300 | (980,200,0) | — | MI_DevRed | ✅ |
| 7 | LVL_Z1_STR_EastWall_02 | Wall_400x300 | (980,600,0) | — | MI_DevRed | ✅ |
| 8 | LVL_Z1_STR_NorthWall_West | Wall_400x300 | (200,780,0) | — | MI_DevRed | ✅ |
| 9 | LVL_Z1_STR_NorthWall_Gate | WallDoor_200x300 | (500,780,0) | — | MI_DevRed | ✅ |
| 10 | LVL_Z1_STR_NorthWall_East | Wall_400x300 | (800,780,0) | — | MI_DevRed | ✅ |
| 11 | LVL_Z1_STR_GateDoor | RotativeDoor_01 | (500,780,0) | — | MI_DevGreen | ✅ |
| 12 | LVL_Z1_STR_HouseSouthWall | WallWindow_400x300 | (400,30,0) | — | MI_DevWhite | ✅ |
| 13 | LVL_Z1_STR_HouseSouthWin | WallWindowSmall_200x300 | (600,30,0) | — | MI_DevWhite | ✅ |
| 14 | LVL_Z1_STR_HouseWestWall | Wall_400x300 | (200,220,0) | — | MI_DevWhite | ✅ |
| 15 | LVL_Z1_STR_HouseWestTop | Wall_400x100 | (200,220,300) | — | MI_DevWhite | ✅ |
| 16 | LVL_Z1_STR_HouseEastWall | Wall_400x300 | (780,220,0) | — | MI_DevWhite | ✅ |
| 17 | LVL_Z1_STR_HouseEastTop | Wall_400x100 | (780,220,300) | — | MI_DevWhite | ✅ |
| 18 | LVL_Z1_STR_HouseNorthWall | WallDoor_200x300 | (400,410,0) | — | MI_DevWhite | ✅ |
| 19 | LVL_Z1_STR_HouseNorthFill | Wall_400x300 | (600,410,0) | — | MI_DevWhite | ✅ |
| 20 | LVL_Z1_STR_HouseRoof_01 | Floor_300x400 | (350,230,280) | — | MI_DevBrown | ✅ |
| 21 | LVL_Z1_STR_HouseRoof_02 | Floor_300x400 | (650,230,280) | — | MI_DevBrown | ✅ |
| 22 | LVL_Z1_STR_CourtyardFloor | Floor_1000x1000 | (500,400,0) | (1.00,0.80,1.00) | MI_DevGray | ✅ |
| 23 | LVL_Z1_PROP_OldBicycle | Car01 | (100,650,0) | (0.20,0.20,0.20) | — | ✅ |
| 24 | LVL_Z1_PROP_ClothesPole_Beam | Beam_300 | (500,600,180) | — | MI_DevWhite | ✅ |
| 25 | LVL_Z1_PROP_ClothesPole_Pole1 | Cylinder | (380,600,0) | (0.05,0.05,3.60) | MI_DevGray | ✅ |
| 26 | LVL_Z1_PROP_ClothesPole_Pole2 | Cylinder | (620,600,0) | (0.05,0.05,3.60) | MI_DevGray | ✅ |
| 27 | LVL_Z1_PROP_JunkPile_Box1 | WoodenBox_Small | (850,100,0) | — | — | ✅ |
| 28 | LVL_Z1_PROP_JunkPile_Box2 | WoodenBox_Small | (870,120,35) | — | — | ✅ |
| 29 | LVL_Z1_PROP_JunkPile_Bucket | Bucket_01 | (890,80,0) | — | — | ✅ |
| 30 | LVL_Z1_PROP_DoorMat | Cube | (480,740,0) | (0.60,0.40,0.02) | MI_DevGray | ✅ |
| 31 | LVL_Z1_PROP_Fridge | Barrel | (720,80,0) | (0.30,0.30,1.60) | MI_DevWhite | ✅ |
| 32 | LVL_Z1_PROP_TV | Monitor_01 | (320,80,45) | — | MI_DevGray | ✅ |
| 33 | LVL_Z1_PROP_Desk | SimpleTable_01 | (300,70,0) | — | — | ✅ |
| 34 | LVL_Z1_PROP_Bed | BedSingle | (350,250,0) | — | — | ✅ |
| 35 | LVL_Z1_PROP_Calendar | Cube | (500,40,180) | (0.30,0.02,0.40) | MI_DevRed | ✅ |
| 36 | LVL_Z1_PROP_TVStand | SimpleTable_02 | (320,55,0) | (0.85,0.75,0.67) | — | ✅ |
| 37 | LVL_Z1_INTR_01_Fridge | Cube | (720,80,80) | (0.30,0.30,0.30) | MI_DevYellow | ✅ |
| 38 | LVL_Z1_INTR_02_Gate | Cube | (500,780,0) | (0.30,0.30,0.30) | MI_DevYellow | ✅ |
| 39 | LVL_Z1_INTR_06_TV | Cube | (320,80,70) | (0.30,0.30,0.30) | MI_DevYellow | ✅ |
| 40 | LVL_Z1_COLL_10_CatEyeMarble | CirclePlatform | (870,140,0) | (0.03,0.03,0.03) | MI_DevYellow | ✅ |
### Z2 小卖部
共 26 个 Actor
| # | Actor 名称 | 资产 | 位置 | 缩放 | 材质 | 状态 |
|---|-----------|------|------|------|------|------|
| 1 | LVL_Z2_STR_Floor | Floor_1000x1000 | (1300,500,0) | (0.60,0.60,1.00) | MI_DevGray | ✅ |
| 2 | LVL_Z2_STR_SouthWall | Wall_400x300 | (1200,200,0) | — | MI_DevOrange | ✅ |
| 3 | LVL_Z2_STR_SouthWall2 | Wall_200x300 | (1500,200,0) | — | MI_DevOrange | ✅ |
| 4 | LVL_Z2_STR_WestWall | Wall_400x300 | (1000,400,0) | — | MI_DevOrange | ✅ |
| 5 | LVL_Z2_STR_WestWallTop | Wall_400x100 | (1000,400,300) | — | MI_DevOrange | ✅ |
| 6 | LVL_Z2_STR_EastWall | Wall_400x300 | (1580,400,0) | — | MI_DevOrange | ✅ |
| 7 | LVL_Z2_STR_EastWallTop | Wall_400x100 | (1580,400,300) | — | MI_DevOrange | ✅ |
| 8 | LVL_Z2_STR_NorthWall_West | Wall_200x300 | (1100,580,0) | — | MI_DevOrange | ✅ |
| 9 | LVL_Z2_STR_NorthWall_Garage | WallDoorGarage_400x300 | (1300,580,0) | — | MI_DevOrange | ✅ |
| 10 | LVL_Z2_STR_NorthWall_East | Wall_200x300 | (1500,580,0) | — | MI_DevOrange | ✅ |
| 11 | LVL_Z2_STR_GarageDoor | GarageDoorOpen_01 | (1300,580,0) | — | MI_DevGray | ✅ |
| 12 | LVL_Z2_STR_Roof_01 | Floor_300x400 | (1250,400,280) | — | MI_DevBrown | ✅ |
| 13 | LVL_Z2_STR_Roof_02 | Floor_300x400 | (1350,400,280) | — | MI_DevBrown | ✅ |
| 14 | LVL_Z2_STR_Awning_01 | Floor_200x300 | (1100,700,260) | — | MI_DevWhite | ✅ |
| 15 | LVL_Z2_STR_Awning_02 | Floor_200x300 | (1400,700,260) | — | MI_DevWhite | ✅ |
| 16 | LVL_Z2_PROP_Counter | Cube | (1200,500,0) | (2.00,0.60,0.90) | MI_DevGlassWhite | ✅ |
| 17 | LVL_Z2_PROP_ShelfEast_01 | Fence_Small_400 | (1520,300,0) | (1.00,1.00,1.80) | MI_DevGray | ✅ |
| 18 | LVL_Z2_PROP_ShelfEast_02 | Fence_Small_400 | (1520,300,180) | (1.00,1.00,1.80) | MI_DevGray | ✅ |
| 19 | LVL_Z2_PROP_ShelfSouth | Fence_Small_400 | (1100,230,0) | (1.00,1.00,1.80) | MI_DevGray | ✅ |
| 20 | LVL_Z2_PROP_Freezer | Container_Normal | (1050,400,0) | (0.25,0.15,0.40) | MI_DevWhite | ✅ |
| 21 | LVL_Z2_PROP_SmallTV | Monitor_01 | (1350,540,90) | (0.60,0.60,0.60) | MI_DevGray | ✅ |
| 22 | LVL_Z2_PROP_Signboard | Cube | (1150,610,280) | (3.00,0.05,0.40) | MI_DevBlue | ✅ |
| 23 | LVL_Z2_PROP_CardRack | Cube | (1400,550,0) | (0.40,0.20,0.30) | MI_DevGray | ✅ |
| 24 | LVL_Z2_INTR_05_ShopOwner | Cube | (1300,480,0) | (0.30,0.30,0.30) | MI_DevYellow | ✅ |
| 25 | LVL_Z2_INTR_07_SnackShelf | Cube | (1520,350,90) | (0.30,0.30,0.30) | MI_DevYellow | ✅ |
| 26 | LVL_Z2_COLL_09_BouncyBall | CirclePlatform | (1300,700,0) | (0.03,0.03,0.03) | MI_DevWhite | ✅ |
### Z3 主街道
共 42 个 Actor
| # | Actor 名称 | 资产 | 位置 | 缩放 | 材质 | 状态 |
|---|-----------|------|------|------|------|------|
| 1 | LVL_Z3_STR_Floor_01 | Floor_1000x1000 | (500,1050,0) | (1.00,0.50,1.00) | MI_DevGray | ✅ |
| 2 | LVL_Z3_STR_Floor_02 | Floor_1000x1000 | (1500,1050,0) | (1.00,0.50,1.00) | MI_DevGray | ✅ |
| 3 | LVL_Z3_STR_Floor_03 | Floor_1000x1000 | (2500,1050,0) | (1.00,0.50,1.00) | MI_DevGray | ✅ |
| 4 | LVL_Z3_STR_Floor_04 | Floor_1000x1000 | (3500,1050,0) | (1.00,0.50,1.00) | MI_DevGray | ✅ |
| 5 | LVL_Z3_STR_Floor_05 | Floor_1000x1000 | (4500,1050,0) | (1.00,0.50,1.00) | MI_DevGray | ✅ |
| 6 | LVL_Z3_STR_SouthWall_C1 | Wall_400x300 | (1800,800,0) | — | MI_DevGray | ✅ |
| 7 | LVL_Z3_STR_SouthWall_C2 | Wall_400x300 | (2200,800,0) | — | MI_DevGray | ✅ |
| 8 | LVL_Z3_STR_SouthWall_C3 | Wall_400x300 | (2600,800,0) | — | MI_DevGray | ✅ |
| 9 | LVL_Z3_STR_SouthWall_C4 | Wall_400x300 | (3000,800,0) | — | MI_DevGray | ✅ |
| 10 | LVL_Z3_STR_SouthWall_C5 | Wall_400x300 | (3400,800,0) | — | MI_DevGray | ✅ |
| 11 | LVL_Z3_STR_SouthWall_C6 | Wall_400x300 | (3800,800,0) | — | MI_DevGray | ✅ |
| 12 | LVL_Z3_STR_SouthWall_D1 | Wall_400x300 | (4200,800,0) | — | MI_DevGray | ✅ |
| 13 | LVL_Z3_STR_SouthWall_D2 | Wall_400x300 | (4600,800,0) | — | MI_DevGray | ✅ |
| 14 | LVL_Z3_STR_NorthWall_A1 | Wall_400x300 | (200,1300,0) | — | MI_DevGray | ✅ |
| 15 | LVL_Z3_STR_NorthWall_A2 | Wall_400x300 | (600,1300,0) | — | MI_DevGray | ✅ |
| 16 | LVL_Z3_STR_NorthWall_A3 | Wall_400x300 | (1000,1300,0) | — | MI_DevGray | ✅ |
| 17 | LVL_Z3_STR_NorthWall_A4 | Wall_400x300 | (1400,1300,0) | — | MI_DevGray | ✅ |
| 18 | LVL_Z3_STR_NorthWall_A5 | Wall_400x300 | (1800,1300,0) | — | MI_DevGray | ✅ |
| 19 | LVL_Z3_STR_NorthWall_A6 | Wall_200x300 | (2100,1300,0) | — | MI_DevGray | ✅ |
| 20 | LVL_Z3_STR_NorthWall_C1 | Wall_400x300 | (2800,1300,0) | — | MI_DevGray | ✅ |
| 21 | LVL_Z3_STR_NorthWall_C2 | Wall_400x300 | (3200,1300,0) | — | MI_DevGray | ✅ |
| 22 | LVL_Z3_STR_NorthWall_C3 | Wall_100x300 | (3450,1300,0) | — | MI_DevGray | ✅ |
| 23 | LVL_Z3_PROP_PhoneBooth | Cube | (1800,380,0) | (0.80,0.80,2.20) | MI_DevGray | ✅ |
| 24 | LVL_Z3_PROP_Bicycle | Car01 | (2800,880,0) | (0.20,0.20,0.20) | — | ✅ |
| 25 | LVL_Z3_PROP_ClothesPole | Beam_300 | (1200,1250,180) | — | MI_DevWhite | ✅ |
| 26 | LVL_Z3_PROP_ClothesPolePole | Cylinder | (1200,1250,0) | (0.05,0.05,3.60) | MI_DevGray | ✅ |
| 27 | LVL_Z3_PROP_SewerCover | CirclePlatform | (800,1050,0) | (0.12,0.12,0.02) | MI_DevGray | ✅ |
| 28 | LVL_Z3_PROP_Graffiti | Cube | (3200,1270,100) | (1.20,0.05,0.80) | MI_DevYellow | ✅ |
| 29 | LVL_Z3 PROP_AdWall | DisplayPanels_400x200 | (3800,1280,0) | — | MI_DevGray | ✅ |
| 30 | LVL_Z3_PROP_TrashCan | TrashCans_01 | (4200,850,0) | — | MI_DevGreen | ✅ |
| 31 | LVL_Z3_PROP_PoleWest | Cylinder | (1000,1050,0) | (0.04,0.04,12.00) | MI_DevGray | ✅ |
| 32 | LVL_Z3_PROP_PoleEast | Cylinder | (3500,1050,0) | (0.04,0.04,12.00) | MI_DevGray | ✅ |
| 33 | LVL_Z3_INTR_04_PhoneBooth | Cube | (1800,380,110) | (0.30,0.30,0.30) | MI_DevYellow | ✅ |
| 34 | LVL_Z3_INTR_09_Graffiti | Cube | (3200,1270,140) | (0.30,0.30,0.30) | MI_DevYellow | ✅ |
| 35 | LVL_Z3_COLL_08_IceFlowerMarble | CirclePlatform | (820,1060,0) | (0.03,0.03,0.03) | MI_DevWhite | ✅ |
| 36 | LVL_Z3_LGT_StreetLight_01 | RoadLamp_01 | (1500,1050,0) | — | — | ✅ |
| 37 | LVL_Z3_LGT_StreetLight_02 | RoadLamp_01 | (3000,1050,0) | — | — | ✅ |
| 38 | LVL_Z3_LGT_StreetLight_03 | RoadLamp_01 | (4500,1050,0) | — | — | ✅ |
| 39 | LVL_Z3_PROP_TrashCan_02 | TrashCans_01 | (2000,850,0) | — | MI_DevGreen | ✅ |
| 40 | LVL_Z3_LGT_PointLight_01 | | (1500,1050,400) | — | — | ✅ |
| 41 | LVL_Z3_LGT_PointLight_02 | | (3000,1050,400) | — | — | ✅ |
| 42 | LVL_Z3_LGT_PointLight_03 | | (4500,1050,400) | — | — | ✅ |
### Z4 空地
共 20 个 Actor
| # | Actor 名称 | 资产 | 位置 | 缩放 | 材质 | 状态 |
|---|-----------|------|------|------|------|------|
| 1 | LVL_Z4_STR_Floor_01 | Floor_1000x1000 | (4000,1800,10) | (1.50,1.20,1.00) | MI_DevBrown | ✅ |
| 2 | LVL_Z4_STR_EastBound_01 | BarrierWall_600 | (4980,1600,0) | — | MI_DevBlue | ✅ |
| 3 | LVL_Z4_STR_EastBound_02 | BarrierWall_600 | (4980,2200,0) | — | MI_DevBlue | ✅ |
| 4 | LVL_Z4_STR_NorthBound_01 | BarrierWall_600 | (3800,2480,0) | — | MI_DevBlue | ✅ |
| 5 | LVL_Z4_STR_NorthBound_02 | BarrierWall_600 | (4400,2480,0) | — | MI_DevBlue | ✅ |
| 6 | LVL_Z4_STR_NorthBound_03 | BarrierWall_200 | (4700,2480,0) | — | MI_DevBlue | ✅ |
| 7 | LVL_Z4_STR_WestLowWall_01 | BarrierWall_200 | (3500,1500,0) | — | MI_DevGray | ✅ |
| 8 | LVL_Z4_STR_WestLowWall_02 | BarrierWall_200 | (3500,1900,0) | — | MI_DevGray | ✅ |
| 9 | LVL_Z4_STR_WestLowWall_03 | BarrierWall_200 | (3500,2300,0) | — | MI_DevGray | ✅ |
| 10 | LVL_Z4_PROP_BigTree | Tree_01 | (4250,1900,10) | (1.20,1.20,1.20) | MI_DevFoliage | ✅ |
| 11 | LVL_Z4_PROP_Bricks_01~05 | WoodenBox_Small | (0,0,0) | — | — | ✅ |
| 12 | LVL_Z4_PROP_Puddle | Cube | (4600,1500,5) | (1.20,0.80,0.01) | MI_DevGlassBlue | ✅ |
| 13 | LVL_Z4_PROP_MarbleCircle | CirclePlatform | (4100,1800,12) | (0.16,0.16,0.01) | MI_DevGray | ✅ |
| 14 | LVL_Z4_PROP_Debris_01 | WoodenPallet_01 | (4800,2300,10) | — | — | ✅ |
| 15 | LVL_Z4_PROP_Debris_02 | WoodenBox_Normal | (4850,2250,10) | — | — | ✅ |
| 16 | LVL_Z4_INTR_03_BlueMarble | Cube | (3900,1700,10) | (0.30,0.30,0.30) | MI_DevYellow | ✅ |
| 17 | LVL_Z4_INTR_08_SmallStone | Cube | (4550,1550,10) | (0.30,0.30,0.30) | MI_DevYellow | ✅ |
| 18 | LVL_Z4_COLL_01_BlueMarble | CirclePlatform | (3900,1700,10) | (0.03,0.03,0.03) | MI_DevWhite | ✅ |
| 19 | LVL_Z4_COLL_03_SmallStone | CirclePlatform | (4550,1550,10) | (0.03,0.03,0.03) | MI_DevWhite | ✅ |
| 20 | LVL_Z4_COLL_04_LuZhishenCard | CirclePlatform | (3720,1610,10) | (0.03,0.03,0.03) | MI_DevYellow | ✅ |
### Z5 巷子
共 19 个 Actor
| # | Actor 名称 | 资产 | 位置 | 缩放 | 材质 | 状态 |
|---|-----------|------|------|------|------|------|
| 1 | LVL_Z5_STR_Floor_01 | Floor_1000x1000 | (2450,1800,0) | (0.30,1.00,1.00) | MI_DevGray | ✅ |
| 2 | LVL_Z5_STR_Floor_02 | Floor_1000x1000 | (2450,2800,0) | (0.30,1.00,1.00) | MI_DevGray | ✅ |
| 3 | LVL_Z5_STR_WestWall_01 | Wall_400x300 | (2300,1500,0) | — | MI_DevWhite | ✅ |
| 4 | LVL_Z5_STR_WestWall_02 | Wall_400x300 | (2300,1900,0) | — | MI_DevWhite | ✅ |
| 5 | LVL_Z5_STR_WestWall_03 | Wall_400x300 | (2300,2300,0) | — | MI_DevWhite | ✅ |
| 6 | LVL_Z5_STR_WestWall_04 | Wall_400x300 | (2300,2700,0) | — | MI_DevWhite | ✅ |
| 7 | LVL_Z5_STR_WestWall_05 | Wall_400x300 | (2300,3100,0) | — | MI_DevWhite | ✅ |
| 8 | LVL_Z5_STR_EastWall_01 | Wall_400x300 | (2580,1500,0) | — | MI_DevWhite | ✅ |
| 9 | LVL_Z5_STR_EastWall_02 | Wall_400x300 | (2580,1900,0) | — | MI_DevWhite | ✅ |
| 10 | LVL_Z5_STR_EastWall_03 | Wall_400x300 | (2580,2300,0) | — | MI_DevWhite | ✅ |
| 11 | LVL_Z5_STR_EastWall_04 | Wall_400x300 | (2580,2700,0) | — | MI_DevWhite | ✅ |
| 12 | LVL_Z5_STR_EastWall_05 | Wall_400x300 | (2580,3100,0) | — | MI_DevWhite | ✅ |
| 13 | LVL_Z5_PROP_WasteBox | WoodenBox_Small | (2380,2900,0) | — | — | ✅ |
| 14 | LVL_Z5_PROP_SpiderWeb | Cube | (2305,2100,250) | (0.30,0.30,0.01) | MI_DevWhite | ✅ |
| 15 | LVL_Z5_PROP_OldWindow | FakeWindowsFrame | (2310,2100,150) | — | MI_DevGray | ✅ |
| 16 | LVL_Z5_PROP_FlowerPot | Cylinder | (2340,2100,150) | (0.20,0.20,0.25) | MI_DevGreen | ✅ |
| 17 | LVL_Z5_PROP_Moss | Cube | (2400,2700,0) | (0.60,0.40,0.01) | MI_DevFoliage | ✅ |
| 18 | LVL_Z5_INTR_10_WasteBox | Cube | (2380,2900,25) | (0.30,0.30,0.30) | MI_DevYellow | ✅ |
| 19 | LVL_Z5_COLL_02_RedMarble | CirclePlatform | (2500,3000,0) | (0.03,0.03,0.03) | MI_DevWhite | ✅ |
### Z6 水塘边
共 16 个 Actor
| # | Actor 名称 | 资产 | 位置 | 缩放 | 材质 | 状态 |
|---|-----------|------|------|------|------|------|
| 1 | LVL_Z6_STR_GroundSouth | Floor_1000x1000 | (2350,3475,0) | (1.50,0.35,1.00) | MI_DevBrown | ✅ |
| 2 | LVL_Z6_STR_GroundEast | Floor_1000x1000 | (3075,4000,0) | (0.15,0.80,1.00) | MI_DevBrown | ✅ |
| 3 | LVL_Z6_STR_GroundWest | Floor_1000x1000 | (1675,4000,0) | (0.15,0.80,1.00) | MI_DevBrown | ✅ |
| 4 | LVL_Z6_STR_Water | Floor_1000x1000 | (2350,4000,-20) | (1.20,0.80,0.50) | MI_DevSkyBlue | ✅ |
| 5 | LVL_Z6_STR_EastBound_01 | BarrierWall_600 | (3080,3600,0) | — | MI_DevBlue | ✅ |
| 6 | LVL_Z6_STR_EastBound_02 | BarrierWall_600 | (3080,4200,0) | — | MI_DevBlue | ✅ |
| 7 | LVL_Z6_STR_NorthBound_01 | BarrierWall_600 | (1900,4680,0) | — | MI_DevBlue | ✅ |
| 8 | LVL_Z6_STR_NorthBound_02 | BarrierWall_600 | (2500,4680,0) | — | MI_DevBlue | ✅ |
| 9 | LVL_Z6_STR_NorthBound_03 | BarrierWall_200 | (2800,4680,0) | — | MI_DevBlue | ✅ |
| 10 | LVL_Z6_PROP_Reeds_S1~5 | Cylinder | (1800,3600,0) | (0.08,0.08,2.00) | MI_DevFoliage | ✅ |
| 11 | LVL_Z6_PROP_Reeds_S2_1~5 | Cylinder | (2700,3600,0) | (0.08,0.08,2.00) | MI_DevFoliage | ✅ |
| 12 | LVL_Z6_PROP_Reeds_E_1~3 | Cylinder | (2900,4100,0) | (0.08,0.08,1.60) | MI_DevFoliage | ✅ |
| 13 | LVL_Z6_PROP_LilyPad_1~4 | CirclePlatform | (2200,3900,-18) | (0.06,0.06,0.01) | MI_DevGreen | ✅ |
| 14 | LVL_Z6_PROP_Frog | Cube | (2100,3680,0) | (0.05,0.05,0.04) | MI_DevGreen | ✅ |
| 15 | LVL_Z6_COLL_05_CandyWrapper | CirclePlatform | (2000,3500,0) | (0.03,0.03,0.03) | MI_DevWhite | ✅ |
| 16 | LVL_Z6_COLL_06_Eraser | CirclePlatform | (2500,3450,0) | (0.03,0.03,0.03) | MI_DevWhite | ✅ |
### Z7 老居民楼
共 38 个 Actor
| # | Actor 名称 | 资产 | 位置 | 缩放 | 材质 | 状态 |
|---|-----------|------|------|------|------|------|
| 1 | LVL_BND_West_01 | Wall_400x300 | (0,200,0) | — | MI_DevRed | ✅ |
| 2 | LVL_BND_West_02 | Wall_400x300 | (0,600,0) | — | MI_DevRed | ✅ |
| 3 | LVL_BND_West_03 | Wall_400x300 | (0,1000,0) | — | MI_DevRed | ✅ |
| 4 | LVL_BND_West_04 | Wall_400x300 | (0,1400,0) | — | MI_DevRed | ✅ |
| 5 | LVL_BND_West_05 | Wall_400x300 | (0,1800,0) | — | MI_DevRed | ✅ |
| 6 | LVL_BND_West_06 | Wall_400x300 | (0,2200,0) | — | MI_DevRed | ✅ |
| 7 | LVL_BND_West_07 | Wall_400x300 | (0,2600,0) | — | MI_DevRed | ✅ |
| 8 | LVL_BND_West_08 | Wall_400x300 | (0,3000,0) | — | MI_DevRed | ✅ |
| 9 | LVL_BND_West_09 | Wall_400x300 | (0,3400,0) | — | MI_DevRed | ✅ |
| 10 | LVL_BND_West_10 | Wall_400x300 | (0,3800,0) | — | MI_DevRed | ✅ |
| 11 | LVL_BND_West_11 | Wall_400x300 | (0,4200,0) | — | MI_DevRed | ✅ |
| 12 | LVL_BND_West_12 | Wall_400x300 | (0,4600,0) | — | MI_DevRed | ✅ |
| 13 | LVL_BND_West_13 | Wall_400x300 | (0,5000,0) | — | MI_DevRed | ✅ |
| 14 | LVL_BND_West_14 | Wall_400x300 | (0,5400,0) | — | MI_DevRed | ✅ |
| 15 | LVL_BND_West_15 | Wall_400x300 | (0,5800,0) | — | MI_DevRed | ✅ |
| 16 | LVL_BND_East_01~15 | BarrierWall_600 | (0,0,0) | — | MI_DevBlue | ✅ |
| 17 | LVL_BND_North_01~20 | Wall_400x300 | (0,0,0) | — | MI_DevBlue | ✅ |
| 18 | LVL_BND_South_01 | BarrierWall_600 | (1900,0,0) | — | MI_DevBlue | ✅ |
| 19 | LVL_BND_South_02 | BarrierWall_600 | (2500,0,0) | — | MI_DevBlue | ✅ |
| 20 | LVL_BND_South_03 | BarrierWall_600 | (3100,0,0) | — | MI_DevBlue | ✅ |
| 21 | LVL_BND_South_04 | BarrierWall_600 | (3700,0,0) | — | MI_DevBlue | ✅ |
| 22 | LVL_BND_South_05 | BarrierWall_600 | (4300,0,0) | — | MI_DevBlue | ✅ |
| 23 | LVL_BND_South_06 | BarrierWall_600 | (4900,0,0) | — | MI_DevBlue | ✅ |
| 24 | LVL_BND_South_07 | BarrierWall_600 | (5500,0,0) | — | MI_DevBlue | ✅ |
| 25 | LVL_BND_South_08 | BarrierWall_600 | (6100,0,0) | — | MI_DevBlue | ✅ |
| 26 | LVL_BND_South_09 | BarrierWall_600 | (6700,0,0) | — | MI_DevBlue | ✅ |
| 27 | LVL_BND_South_10 | BarrierWall_600 | (7300,0,0) | — | MI_DevBlue | ✅ |
| 28 | LVL_BND_South_11 | BarrierWall_600 | (7900,0,0) | — | MI_DevBlue | ✅ |
| 29 | LVL_Z7_STR_Building | Building_01 | (6400,800,0) | — | MI_DevGray | ✅ |
| 30 | LVL_Z7_STR_CollisionFront | Wall_400x300 | (6200,150,0) | — | MI_DevGray | ✅ |
| 31 | LVL_Z7_STR_CollisionFront2 | Wall_400x300 | (6600,150,0) | — | MI_DevGray | ✅ |
| 32 | LVL_Z7_STR_CollisionFront3 | Wall_400x300 | (7000,150,0) | — | MI_DevGray | ✅ |
| 33 | LVL_Z7_STR_CollisionSide | Wall_400x300 | (5750,400,0) | (0.00,90.00,0.00) | MI_DevGray | ✅ |
| 34 | LVL_Z7_STR_CollisionSide2 | Wall_400x300 | (5750,800,0) | (0.00,90.00,0.00) | MI_DevGray | ✅ |
| 35 | LVL_Z7_PROP_ClothesPole_01 | Beam_300 | (6100,190,800) | — | MI_DevWhite | ✅ |
| 36 | LVL_Z7_PROP_ClothesPole_02 | Beam_300 | (6500,190,1200) | — | MI_DevWhite | ✅ |
| 37 | LVL_Z7_PROP_ClothesPole_03 | Beam_300 | (6900,190,1600) | — | MI_DevWhite | ✅ |
| 38 | LVL_Z7_PROP_TrashCan | TrashCans_01 | (6000,140,0) | — | MI_DevGreen | ✅ |
### GLOBAL 全局
共 64 个 Actor
| # | Actor 名称 | 资产 | 位置 | 缩放 | 材质 | 状态 |
|---|-----------|------|------|------|------|------|
| 1 | LVL_BND_West_01 | Wall_400x300 | (0,200,0) | — | MI_DevRed | ✅ |
| 2 | LVL_BND_West_02 | Wall_400x300 | (0,600,0) | — | MI_DevRed | ✅ |
| 3 | LVL_BND_West_03 | Wall_400x300 | (0,1000,0) | — | MI_DevRed | ✅ |
| 4 | LVL_BND_West_04 | Wall_400x300 | (0,1400,0) | — | MI_DevRed | ✅ |
| 5 | LVL_BND_West_05 | Wall_400x300 | (0,1800,0) | — | MI_DevRed | ✅ |
| 6 | LVL_BND_West_06 | Wall_400x300 | (0,2200,0) | — | MI_DevRed | ✅ |
| 7 | LVL_BND_West_07 | Wall_400x300 | (0,2600,0) | — | MI_DevRed | ✅ |
| 8 | LVL_BND_West_08 | Wall_400x300 | (0,3000,0) | — | MI_DevRed | ✅ |
| 9 | LVL_BND_West_09 | Wall_400x300 | (0,3400,0) | — | MI_DevRed | ✅ |
| 10 | LVL_BND_West_10 | Wall_400x300 | (0,3800,0) | — | MI_DevRed | ✅ |
| 11 | LVL_BND_West_11 | Wall_400x300 | (0,4200,0) | — | MI_DevRed | ✅ |
| 12 | LVL_BND_West_12 | Wall_400x300 | (0,4600,0) | — | MI_DevRed | ✅ |
| 13 | LVL_BND_West_13 | Wall_400x300 | (0,5000,0) | — | MI_DevRed | ✅ |
| 14 | LVL_BND_West_14 | Wall_400x300 | (0,5400,0) | — | MI_DevRed | ✅ |
| 15 | LVL_BND_West_15 | Wall_400x300 | (0,5800,0) | — | MI_DevRed | ✅ |
| 16 | LVL_BND_East_01 | BarrierWall_600 | (8000,200,0) | — | MI_DevBlue | ✅ |
| 17 | LVL_BND_East_02 | BarrierWall_600 | (8000,600,0) | — | MI_DevBlue | ✅ |
| 18 | LVL_BND_East_03 | BarrierWall_600 | (8000,1000,0) | — | MI_DevBlue | ✅ |
| 19 | LVL_BND_East_04 | BarrierWall_600 | (8000,1400,0) | — | MI_DevBlue | ✅ |
| 20 | LVL_BND_East_05 | BarrierWall_600 | (8000,1800,0) | — | MI_DevBlue | ✅ |
| 21 | LVL_BND_East_06 | BarrierWall_600 | (8000,2200,0) | — | MI_DevBlue | ✅ |
| 22 | LVL_BND_East_07 | BarrierWall_600 | (8000,2600,0) | — | MI_DevBlue | ✅ |
| 23 | LVL_BND_East_08 | BarrierWall_600 | (8000,3000,0) | — | MI_DevBlue | ✅ |
| 24 | LVL_BND_East_09 | BarrierWall_600 | (8000,3400,0) | — | MI_DevBlue | ✅ |
| 25 | LVL_BND_East_10 | BarrierWall_600 | (8000,3800,0) | — | MI_DevBlue | ✅ |
| 26 | LVL_BND_East_11 | BarrierWall_600 | (8000,4200,0) | — | MI_DevBlue | ✅ |
| 27 | LVL_BND_East_12 | BarrierWall_600 | (8000,4600,0) | — | MI_DevBlue | ✅ |
| 28 | LVL_BND_East_13 | BarrierWall_600 | (8000,5000,0) | — | MI_DevBlue | ✅ |
| 29 | LVL_BND_East_14 | BarrierWall_600 | (8000,5400,0) | — | MI_DevBlue | ✅ |
| 30 | LVL_BND_East_15 | BarrierWall_600 | (8000,5800,0) | — | MI_DevBlue | ✅ |
| 31 | LVL_BND_North_01 | Wall_400x300 | (200,6000,0) | — | MI_DevBlue | ✅ |
| 32 | LVL_BND_North_02 | Wall_400x300 | (600,6000,0) | — | MI_DevBlue | ✅ |
| 33 | LVL_BND_North_03 | Wall_400x300 | (1000,6000,0) | — | MI_DevBlue | ✅ |
| 34 | LVL_BND_North_04 | Wall_400x300 | (1400,6000,0) | — | MI_DevBlue | ✅ |
| 35 | LVL_BND_North_05 | Wall_400x300 | (1800,6000,0) | — | MI_DevBlue | ✅ |
| 36 | LVL_BND_North_06 | Wall_400x300 | (2200,6000,0) | — | MI_DevBlue | ✅ |
| 37 | LVL_BND_North_07 | Wall_400x300 | (2600,6000,0) | — | MI_DevBlue | ✅ |
| 38 | LVL_BND_North_08 | Wall_400x300 | (3000,6000,0) | — | MI_DevBlue | ✅ |
| 39 | LVL_BND_North_09 | Wall_400x300 | (3400,6000,0) | — | MI_DevBlue | ✅ |
| 40 | LVL_BND_North_10 | Wall_400x300 | (3800,6000,0) | — | MI_DevBlue | ✅ |
| 41 | LVL_BND_North_11 | Wall_400x300 | (4200,6000,0) | — | MI_DevBlue | ✅ |
| 42 | LVL_BND_North_12 | Wall_400x300 | (4600,6000,0) | — | MI_DevBlue | ✅ |
| 43 | LVL_BND_North_13 | Wall_400x300 | (5000,6000,0) | — | MI_DevBlue | ✅ |
| 44 | LVL_BND_North_14 | Wall_400x300 | (5400,6000,0) | — | MI_DevBlue | ✅ |
| 45 | LVL_BND_North_15 | Wall_400x300 | (5800,6000,0) | — | MI_DevBlue | ✅ |
| 46 | LVL_BND_North_16 | Wall_400x300 | (6200,6000,0) | — | MI_DevBlue | ✅ |
| 47 | LVL_BND_North_17 | Wall_400x300 | (6600,6000,0) | — | MI_DevBlue | ✅ |
| 48 | LVL_BND_North_18 | Wall_400x300 | (7000,6000,0) | — | MI_DevBlue | ✅ |
| 49 | LVL_BND_North_19 | Wall_400x300 | (7400,6000,0) | — | MI_DevBlue | ✅ |
| 50 | LVL_BND_North_20 | Wall_400x300 | (7800,6000,0) | — | MI_DevBlue | ✅ |
| 51 | LVL_BND_South_01 | BarrierWall_600 | (1900,0,0) | — | MI_DevBlue | ✅ |
| 52 | LVL_BND_South_02 | BarrierWall_600 | (2500,0,0) | — | MI_DevBlue | ✅ |
| 53 | LVL_BND_South_03 | BarrierWall_600 | (3100,0,0) | — | MI_DevBlue | ✅ |
| 54 | LVL_BND_South_04 | BarrierWall_600 | (3700,0,0) | — | MI_DevBlue | ✅ |
| 55 | LVL_BND_South_05 | BarrierWall_600 | (4300,0,0) | — | MI_DevBlue | ✅ |
| 56 | LVL_BND_South_06 | BarrierWall_600 | (4900,0,0) | — | MI_DevBlue | ✅ |
| 57 | LVL_BND_South_07 | BarrierWall_600 | (5500,0,0) | — | MI_DevBlue | ✅ |
| 58 | LVL_BND_South_08 | BarrierWall_600 | (6100,0,0) | — | MI_DevBlue | ✅ |
| 59 | LVL_BND_South_09 | BarrierWall_600 | (6700,0,0) | — | MI_DevBlue | ✅ |
| 60 | LVL_BND_South_10 | BarrierWall_600 | (7300,0,0) | — | MI_DevBlue | ✅ |
| 61 | LVL_BND_South_11 | BarrierWall_600 | (7900,0,0) | — | MI_DevBlue | ✅ |
| 62 | LVL_Global_DirectionalLight | | (0,0,500) | — | — | ✅ |
| 63 | LVL_Global_SkyLight | | (0,0,0) | — | — | ✅ |
| 64 | LVL_PlayerStart | | (500,720,0) | — | — | ✅ |
## 三、资产使用统计
| 资产名 | 使用次数 |
|--------|---------|
| Wall_400x300 | 95 |
| BarrierWall_600 | 46 |
| Cube | 21 |
| Floor_1000x1000 | 15 |
| CirclePlatform | 12 |
| Cylinder | 9 |
| Beam_300 | 5 |
| BarrierWall_200 | 5 |
| Wall_400x100 | 4 |
| Floor_300x400 | 4 |
| WoodenBox_Small | 4 |
| Wall_200x300 | 4 |
| TrashCans_01 | 3 |
| Fence_Small_400 | 3 |
| RoadLamp_01 | 3 |
| WallDoor_200x300 | 2 |
| Car01 | 2 |
| Monitor_01 | 2 |
| Floor_200x300 | 2 |
| RotativeDoor_01 | 1 |
| WallWindow_400x300 | 1 |
| WallWindowSmall_200x300 | 1 |
| Bucket_01 | 1 |
| Barrel | 1 |
| SimpleTable_01 | 1 |
| BedSingle | 1 |
| SimpleTable_02 | 1 |
| Wall_100x300 | 1 |
| DisplayPanels_400x200 | 1 |
| WallDoorGarage_400x300 | 1 |
| GarageDoorOpen_01 | 1 |
| Container_Normal | 1 |
| Tree_01 | 1 |
| WoodenPallet_01 | 1 |
| WoodenBox_Normal | 1 |
| FakeWindowsFrame | 1 |
| Building_01 | 1 |
## 四、缩放记录
| Actor 名称 | 资产 | 缩放 | 实际尺寸 (cm) |
|-----------|------|------|-------------|
| LVL_Z1_STR_Floor | Floor_1000x1000 | (1.00,0.80,1.00) | 1000×800×10 |
| LVL_Z2_STR_Floor | Floor_1000x1000 | (0.60,0.60,1.00) | 600×600×10 |
| LVL_Z3_STR_Floor_01 | Floor_1000x1000 | (1.00,0.50,1.00) | 1000×500×10 |
| LVL_Z3_STR_Floor_02 | Floor_1000x1000 | (1.00,0.50,1.00) | 1000×500×10 |
| LVL_Z3_STR_Floor_03 | Floor_1000x1000 | (1.00,0.50,1.00) | 1000×500×10 |
| LVL_Z3_STR_Floor_04 | Floor_1000x1000 | (1.00,0.50,1.00) | 1000×500×10 |
| LVL_Z3_STR_Floor_05 | Floor_1000x1000 | (1.00,0.50,1.00) | 1000×500×10 |
| LVL_Z4_STR_Floor_01 | Floor_1000x1000 | (1.50,1.20,1.00) | 1500×1200×10 |
| LVL_Z5_STR_Floor_01 | Floor_1000x1000 | (0.30,1.00,1.00) | 300×1000×10 |
| LVL_Z5_STR_Floor_02 | Floor_1000x1000 | (0.30,1.00,1.00) | 300×1000×10 |
| LVL_Z6_STR_GroundSouth | Floor_1000x1000 | (1.50,0.35,1.00) | 1500×350×10 |
| LVL_Z6_STR_GroundEast | Floor_1000x1000 | (0.15,0.80,1.00) | 150×800×10 |
| LVL_Z6_STR_GroundWest | Floor_1000x1000 | (0.15,0.80,1.00) | 150×800×10 |
| LVL_Z6_STR_Water | Floor_1000x1000 | (1.20,0.80,0.50) | 1200×800×5 |
| LVL_Z1_STR_CourtyardFloor | Floor_1000x1000 | (1.00,0.80,1.00) | 1000×800×10 |
| LVL_Z1_PROP_OldBicycle | Car01 | (0.20,0.20,0.20) | (默认尺寸未知) |
| LVL_Z1_PROP_ClothesPole_Pole1 | Cylinder | (0.05,0.05,3.60) | 5×5×360 |
| LVL_Z1_PROP_ClothesPole_Pole2 | Cylinder | (0.05,0.05,3.60) | 5×5×360 |
| LVL_Z1_PROP_DoorMat | Cube | (0.60,0.40,0.02) | 60×40×2 |
| LVL_Z1_PROP_Fridge | Barrel | (0.30,0.30,1.60) | (默认尺寸未知) |
| LVL_Z1_PROP_Calendar | Cube | (0.30,0.02,0.40) | 30×2×40 |
| LVL_Z1_PROP_TVStand | SimpleTable_02 | (0.85,0.75,0.67) | (默认尺寸未知) |
| LVL_Z1_INTR_01_Fridge | Cube | (0.30,0.30,0.30) | 30×30×30 |
| LVL_Z1_INTR_02_Gate | Cube | (0.30,0.30,0.30) | 30×30×30 |
| LVL_Z1_INTR_06_TV | Cube | (0.30,0.30,0.30) | 30×30×30 |
| LVL_Z1_COLL_10_CatEyeMarble | CirclePlatform | (0.03,0.03,0.03) | (默认尺寸未知) |
| LVL_Z3_PROP_PhoneBooth | Cube | (0.80,0.80,2.20) | 80×80×220 |
| LVL_Z3_PROP_Bicycle | Car01 | (0.20,0.20,0.20) | (默认尺寸未知) |
| LVL_Z3_PROP_ClothesPolePole | Cylinder | (0.05,0.05,3.60) | 5×5×360 |
| LVL_Z3_PROP_SewerCover | CirclePlatform | (0.12,0.12,0.02) | (默认尺寸未知) |
| LVL_Z3_PROP_Graffiti | Cube | (1.20,0.05,0.80) | 120×5×80 |
| LVL_Z3_PROP_PoleWest | Cylinder | (0.04,0.04,12.00) | 4×4×1200 |
| LVL_Z3_PROP_PoleEast | Cylinder | (0.04,0.04,12.00) | 4×4×1200 |
| LVL_Z3_INTR_04_PhoneBooth | Cube | (0.30,0.30,0.30) | 30×30×30 |
| LVL_Z3_INTR_09_Graffiti | Cube | (0.30,0.30,0.30) | 30×30×30 |
| LVL_Z3_COLL_08_IceFlowerMarble | CirclePlatform | (0.03,0.03,0.03) | (默认尺寸未知) |
| LVL_Z2_PROP_Counter | Cube | (2.00,0.60,0.90) | 200×60×90 |
| LVL_Z2_PROP_ShelfEast_01 | Fence_Small_400 | (1.00,1.00,1.80) | 400×5×180 |
| LVL_Z2_PROP_ShelfEast_02 | Fence_Small_400 | (1.00,1.00,1.80) | 400×5×180 |
| LVL_Z2_PROP_ShelfSouth | Fence_Small_400 | (1.00,1.00,1.80) | 400×5×180 |
| LVL_Z2_PROP_Freezer | Container_Normal | (0.25,0.15,0.40) | (默认尺寸未知) |
| LVL_Z2_PROP_SmallTV | Monitor_01 | (0.60,0.60,0.60) | (默认尺寸未知) |
| LVL_Z2_PROP_Signboard | Cube | (3.00,0.05,0.40) | 300×5×40 |
| LVL_Z2_PROP_CardRack | Cube | (0.40,0.20,0.30) | 40×20×30 |
| LVL_Z2_INTR_05_ShopOwner | Cube | (0.30,0.30,0.30) | 30×30×30 |
| LVL_Z2_INTR_07_SnackShelf | Cube | (0.30,0.30,0.30) | 30×30×30 |
| LVL_Z2_COLL_09_BouncyBall | CirclePlatform | (0.03,0.03,0.03) | (默认尺寸未知) |
| LVL_Z4_PROP_BigTree | Tree_01 | (1.20,1.20,1.20) | (默认尺寸未知) |
| LVL_Z4_PROP_Puddle | Cube | (1.20,0.80,0.01) | 120×80×1 |
| LVL_Z4_PROP_MarbleCircle | CirclePlatform | (0.16,0.16,0.01) | (默认尺寸未知) |
| LVL_Z4_INTR_03_BlueMarble | Cube | (0.30,0.30,0.30) | 30×30×30 |
| LVL_Z4_INTR_08_SmallStone | Cube | (0.30,0.30,0.30) | 30×30×30 |
| LVL_Z4_COLL_01_BlueMarble | CirclePlatform | (0.03,0.03,0.03) | (默认尺寸未知) |
| LVL_Z4_COLL_03_SmallStone | CirclePlatform | (0.03,0.03,0.03) | (默认尺寸未知) |
| LVL_Z4_COLL_04_LuZhishenCard | CirclePlatform | (0.03,0.03,0.03) | (默认尺寸未知) |
| LVL_Z5_PROP_SpiderWeb | Cube | (0.30,0.30,0.01) | 30×30×1 |
| LVL_Z5_PROP_FlowerPot | Cylinder | (0.20,0.20,0.25) | 20×20×25 |
| LVL_Z5_PROP_Moss | Cube | (0.60,0.40,0.01) | 60×40×1 |
| LVL_Z5_INTR_10_WasteBox | Cube | (0.30,0.30,0.30) | 30×30×30 |
| LVL_Z5_COLL_02_RedMarble | CirclePlatform | (0.03,0.03,0.03) | (默认尺寸未知) |
| LVL_Z6_PROP_Reeds_S1~5 | Cylinder | (0.08,0.08,2.00) | 8×8×200 |
| LVL_Z6_PROP_Reeds_S2_1~5 | Cylinder | (0.08,0.08,2.00) | 8×8×200 |
| LVL_Z6_PROP_Reeds_E_1~3 | Cylinder | (0.08,0.08,1.60) | 8×8×160 |
| LVL_Z6_PROP_LilyPad_1~4 | CirclePlatform | (0.06,0.06,0.01) | (默认尺寸未知) |
| LVL_Z6_PROP_Frog | Cube | (0.05,0.05,0.04) | 5×5×4 |
| LVL_Z6_COLL_05_CandyWrapper | CirclePlatform | (0.03,0.03,0.03) | (默认尺寸未知) |
| LVL_Z6_COLL_06_Eraser | CirclePlatform | (0.03,0.03,0.03) | (默认尺寸未知) |
| LVL_Z7_STR_CollisionSide | Wall_400x300 | (0.00,90.00,0.00) | 0×1800×0 |
| LVL_Z7_STR_CollisionSide2 | Wall_400x300 | (0.00,90.00,0.00) | 0×1800×0 |
@@ -0,0 +1,772 @@
"""搭建指南 Markdown 解析器。
解析 docs/technical/关卡-v01-搭建指南.md,输出统一的 Actor 列表。
搭建指南的表格格式(4 种变体 + 特殊表格):
- 结构体表: # / Actor名称 / 资产 / 位置 / [旋转] / [缩放] / 材质
- 布景表: # / Actor名称 / 资产 / 位置 / [缩放] / 材质 / 说明
- 交互物表: # / Actor名称 / 位置 / 触发半径 / [交互类型] / 引用
- 收集品表: # / Actor名称 / 位置 / 稀有度 / 引用
- 边界表: # / Actor名称 / 资产 / 位置 / [旋转] / [缩放] / 材质
- 光照表: # / Actor名称 / 类型 / 位置 / 参数
"""
import json
import re
import logging
from pathlib import Path
from dataclasses import dataclass, field, asdict
from typing import Optional
from parsers.markdown_table import (
parse_table,
parse_location,
parse_rotation,
parse_scale,
clean_cell,
)
logger = logging.getLogger("level_builder.parser")
# ============================================================
# 数据结构
# ============================================================
@dataclass
class ActorData:
"""单个 Actor 的完整规格。"""
actor_name: str
asset_key: str = ""
asset_type: str = "static_mesh" # static_mesh | player_start | directional_light | sky_light | point_light
category: str = "" # STR | PROP | INTR | COLL | BND | LGT | PLAY
zone_id: str = "" # Z1-Z7 | GLOBAL
location: tuple = (0.0, 0.0, 0.0)
rotation: tuple = (0.0, 0.0, 0.0)
scale: tuple = (1.0, 1.0, 1.0)
material: str = ""
folder: str = ""
note: str = ""
trigger_radius: float = 0.0
interact_type: str = ""
rarity: str = ""
source_ref: str = ""
# ============================================================
# 标题/上下文检测
# ============================================================
# 区域标题匹配:### C1. Z1 — 家(Home)
ZONE_HEADING = re.compile(
r'###\s+C\d+\.\s+(Z\d+)\s*[—\-–]\s*(.+?)(?:((.+)))?$'
)
# 分类子标题匹配:#### C1.1 结构体(Structure)
CATEGORY_HEADING = re.compile(
r'####\s+C\d+\.\d+\s+(结构体|布景|交互物|收集品|布景物)'
)
# Phase 标题
PHASE_HEADING = re.compile(r'^##\s+(二|三|四|五|六|七|八)')
# 边界区域标题
BOUNDARY_HEADING = re.compile(r'####\s+(西边界|东边界|北边界|南边界)')
# 全局物件标题
GLOBAL_HEADING = re.compile(r'###\s+G-(\d+|E\d+)\s+(.+)')
# 光照标题
LIGHT_HEADING = re.compile(r'###\s+D\d+\s+(.+)')
# 地面子标题
FLOOR_HEADING = re.compile(r'####\s+Z\d+\s+(.+?)\s+地面')
CATEGORY_MAP = {
"结构体": "STR",
"布景": "PROP",
"布景物": "PROP",
"交互物": "INTR",
"交互物标记": "INTR",
"收集品": "COLL",
"收集品标记": "COLL",
}
ZONE_ID_MAP = {
"Z1": "Z1", "Z2": "Z2", "Z3": "Z3",
"Z4": "Z4", "Z5": "Z5", "Z6": "Z6", "Z7": "Z7",
}
# ============================================================
# 核心解析逻辑
# ============================================================
class BuildGuideParser:
"""搭建指南解析器。"""
def __init__(self):
self.actors: list[ActorData] = []
self.current_zone = ""
self.current_category = ""
self.current_phase = ""
self.metadata = {}
def parse_file(self, filepath: str) -> dict:
"""解析搭建指南文件,返回完整的关卡数据。"""
path = Path(filepath)
if not path.exists():
raise FileNotFoundError(f"搭建指南文件不存在: {filepath}")
text = path.read_text(encoding="utf-8")
return self.parse_text(text)
def parse_text(self, text: str) -> dict:
"""解析搭建指南文本。"""
lines = text.split("\n")
# 解析全局参数(第〇章)
self._parse_global_info(lines)
# 解析资产映射表(第一章)— 可选,用于验证
# 逐 Phase 解析
self._parse_phase_ground(lines)
self._parse_phase_boundaries(lines)
self._parse_phase_zones(lines)
self._parse_phase_global_objects(lines)
self._parse_phase_lighting(lines)
# 添加 Player Start
self._add_player_start()
# 构建输出
result = {
"metadata": self.metadata,
"actors": [asdict(a) for a in self.actors],
"summary": self._build_summary(),
}
return result
# ----------------------------------------------------------
# 全局参数
# ----------------------------------------------------------
def _parse_global_info(self, lines: list[str]):
"""解析场景全局参数。"""
self.metadata = {
"source": "关卡-v01-搭建指南.md",
"scene_size": [8000, 6000],
"ground_z": 0,
"character_height": 120,
"camera_height": 100,
}
# ----------------------------------------------------------
# Phase B — 地面
# ----------------------------------------------------------
def _parse_phase_ground(self, lines: list[str]):
"""解析 Phase B 地面铺设表格。"""
text = "\n".join(lines)
# 找到 Phase B 区域
b_start = self._find_section_start(lines, "Phase B")
b_end = self._find_section_start(lines, "Phase C")
if b_start < 0:
return
if b_end < 0:
b_end = len(lines)
section_text = "\n".join(lines[b_start:b_end])
# 地面子区域标题:#### Z1 家 地面(MI_DevGray)
floor_pattern = re.compile(
r'####\s+Z(\d+)\s+\S+\s+地面(?:((.+?)))?'
)
current_zone = ""
current_material = ""
section_lines = lines[b_start:b_end]
i = 0
while i < len(section_lines):
line = section_lines[i].strip()
# 检测地面子区域
fm = floor_pattern.match(line)
if fm:
current_zone = f"Z{fm.group(1)}"
current_material = fm.group(2) or ""
i += 1
continue
# 检测水面区域
if '水面' in line and current_zone:
current_material = "MI_DevSkyBlue"
i += 1
continue
# 检测表格
if '|' in line and '#' in line:
table_lines = []
while i < len(section_lines) and '|' in section_lines[i]:
table_lines.append(section_lines[i])
i += 1
rows = parse_table(table_lines)
if rows:
for row in rows:
actor = self._parse_floor_row(row, current_zone, current_material)
if actor:
self.actors.append(actor)
continue
i += 1
def _parse_floor_row(self, row: dict, zone: str, default_material: str) -> Optional[ActorData]:
"""解析地面表格行。"""
name = clean_cell(row.get("Actor 名称", row.get("Actor名称", "")))
if not name:
return None
asset_key = clean_cell(row.get("资产", ""))
location = parse_location(row.get("位置 (X,Y,Z)", row.get("位置", "")))
scale = parse_scale(row.get("缩放", "—"))
material = clean_cell(row.get("材质", ""))
if not material:
material = default_material
return ActorData(
actor_name=name,
asset_key=asset_key,
asset_type="static_mesh",
category="STR",
zone_id=zone,
location=location,
scale=scale,
material=material,
folder=self._get_folder(zone, "STR"),
)
# ----------------------------------------------------------
# Phase B — 边界
# ----------------------------------------------------------
def _parse_phase_boundaries(self, lines: list[str]):
"""解析 Phase B 边界围挡。"""
b_start = self._find_section_start(lines, "放置边界围挡")
b_end = self._find_section_start(lines, "Phase C")
if b_start < 0:
return
if b_end < 0:
b_end = len(lines)
section_lines = lines[b_start:b_end]
i = 0
while i < len(section_lines):
line = section_lines[i].strip()
# 边界子区域标题
bm = BOUNDARY_HEADING.match(line)
# 检测表格
if '|' in line:
table_lines = []
while i < len(section_lines) and '|' in section_lines[i]:
table_lines.append(section_lines[i])
i += 1
rows = parse_table(table_lines)
if rows:
for row in rows:
actors = self._parse_boundary_row(row)
self.actors.extend(actors)
continue
i += 1
def _parse_boundary_row(self, row: dict) -> list[ActorData]:
"""解析边界表格行,处理范围格式如 '1-15 | LVL_BND_East_01~15'。"""
name_col = row.get("Actor 名称", row.get("Actor名称", ""))
name = clean_cell(name_col)
# 处理范围格式:名称中含 ~ 或编号含 -
if '~' in name:
return self._expand_range_row(row, name)
asset_key = clean_cell(row.get("资产", ""))
location = parse_location(row.get("位置 (X,Y,Z)", row.get("位置", "")))
rotation = parse_rotation(row.get("旋转 (Pitch,Yaw,Roll)", row.get("旋转", "(0,0,0)")))
scale = parse_scale(row.get("缩放", "—"))
material = clean_cell(row.get("材质", ""))
actor = ActorData(
actor_name=name,
asset_key=asset_key,
category="BND",
zone_id="GLOBAL",
location=location,
rotation=rotation,
scale=scale,
material=material,
folder=self._get_folder("GLOBAL", "BND"),
)
return [actor]
def _expand_range_row(self, row: dict, name: str) -> list[ActorData]:
"""展开范围格式行。如 'LVL_BND_East_01~15' → 15 个 Actor。"""
asset_key = clean_cell(row.get("资产", ""))
rotation = parse_rotation(row.get("旋转 (Pitch,Yaw,Roll)", row.get("旋转", "(0,0,0)")))
scale = parse_scale(row.get("缩放", "—"))
material = clean_cell(row.get("材质", ""))
# 解析名称范围:LVL_BND_East_01~15
m = re.match(r'(.+?)_(\d+)~(\d+)', name)
if not m:
return []
prefix = m.group(1)
start = int(m.group(2))
end = int(m.group(3))
# 解析位置表达式 — 格式: (X, Y_expr, Z) n=start~end
# 例: (8000, 200+400n, 0) n=0~14
loc_col = row.get("位置 (X,Y,Z)", row.get("位置", ""))
loc_col = clean_cell(loc_col)
# 提取 n 的范围
n_match = re.search(r'n\s*=\s*(\d+)~(\d+)', loc_col)
n_start = int(n_match.group(1)) if n_match else 0
n_end = int(n_match.group(2)) if n_match else (end - start)
# 去掉 n=xx~yy 后缀以便解析坐标
loc_clean = re.sub(r'\s*n\s*=\s*\d+~\d+\s*', '', loc_col).strip()
# 提取三个坐标表达式
coord_match = re.match(r'\((.+?)\s*,\s*(.+?)\s*,\s*(.+?)\)', loc_clean)
if not coord_match:
return []
x_expr = coord_match.group(1).strip()
y_expr = coord_match.group(2).strip()
z_expr = coord_match.group(3).strip()
actors = []
for idx, n in enumerate(range(n_start, n_end + 1)):
x = self._eval_coord_expr(x_expr, n)
y = self._eval_coord_expr(y_expr, n)
z = self._eval_coord_expr(z_expr, n)
actor_num = start + idx
actor_name = f"{prefix}_{actor_num:02d}"
actor = ActorData(
actor_name=actor_name,
asset_key=asset_key,
category="BND",
zone_id="GLOBAL",
location=(x, y, z),
rotation=rotation,
scale=scale,
material=material,
folder=self._get_folder("GLOBAL", "BND"),
)
actors.append(actor)
return actors
def _eval_coord_expr(self, expr: str, n: int) -> float:
"""计算坐标表达式。如 '200+400n' → 200+400*n。"""
expr = expr.strip()
# 简单情况:纯数字
try:
return float(expr)
except ValueError:
pass
# 含 n 的表达式:200+400n
expr = expr.replace('n', f'*{n}')
# 处理连续运算符:*+→+, *-→-
expr = expr.replace('*+', '+').replace('*-', '-')
try:
# 安全计算简单算术
result = eval(expr) # 受限输入,仅数字和基本运算
return float(result)
except Exception:
return 0.0
# ----------------------------------------------------------
# Phase C — 逐区域搭建
# ----------------------------------------------------------
def _parse_phase_zones(self, lines: list[str]):
"""解析 Phase C 逐区域搭建。"""
c_start = self._find_section_start(lines, "逐区域搭建")
c_end = self._find_section_start(lines, "Phase C — 全局物件")
if c_start < 0:
c_start = self._find_section_start(lines, "全局物件")
if c_start < 0:
return
if c_end < 0:
c_end = len(lines)
section_lines = lines[c_start:c_end]
current_zone = ""
current_category = ""
i = 0
while i < len(section_lines):
line = section_lines[i].strip()
# 检测区域标题
zm = ZONE_HEADING.match(line)
if zm:
current_zone = zm.group(1)
i += 1
continue
# 检测分类标题
cm = CATEGORY_HEADING.match(line)
if cm:
cat_name = cm.group(1)
current_category = CATEGORY_MAP.get(cat_name, "")
i += 1
continue
# 检测结构体子标题(如 "C1.1 结构体(Structure)")
sub_match = re.match(r'####\s+C\d+\.\d+\s+(结构体|布景|布景物|交互物|交互物标记|收集品|收集品标记)', line)
if sub_match:
cat_name = sub_match.group(1)
current_category = CATEGORY_MAP.get(cat_name, "")
i += 1
continue
# 处理表格
if '|' in line:
table_lines = []
while i < len(section_lines) and '|' in section_lines[i]:
table_lines.append(section_lines[i])
i += 1
rows = parse_table(table_lines)
if rows:
for row in rows:
actor = self._parse_zone_row(row, current_zone, current_category)
if actor:
self.actors.append(actor)
continue
i += 1
def _parse_zone_row(self, row: dict, zone: str, category: str) -> Optional[ActorData]:
"""解析区域内的 Actor 行。根据列内容判断表格类型。"""
name = clean_cell(row.get("Actor 名称", row.get("Actor名称", "")))
if not name:
return None
# 判断表格类型:有"资产"列 → 结构体/布景;有"触发半径" → 交互物;有"稀有度" → 收集品
has_asset = "资产" in row and clean_cell(row.get("资产", ""))
has_trigger = "触发半径" in row or "触发半径 (cm)" in row
has_rarity = "稀有度" in row
if has_trigger and not has_asset:
# 交互物表
return self._parse_interactable_row(row, zone)
elif has_rarity and not has_asset:
# 收集品表
return self._parse_collectible_row(row, zone)
else:
# 结构体/布景表
return self._parse_structure_or_prop_row(row, zone, category)
def _parse_structure_or_prop_row(self, row: dict, zone: str, category: str) -> Optional[ActorData]:
"""解析结构体或布景表格行。"""
name = clean_cell(row.get("Actor 名称", row.get("Actor名称", "")))
asset_key = clean_cell(row.get("资产", ""))
location = parse_location(
row.get("位置 (X,Y,Z)", row.get("位置", ""))
)
# 旋转:结构体表可能有,布景表可能没有
rot_col = row.get("旋转 (Pitch,Yaw,Roll)", row.get("旋转", ""))
rotation = parse_rotation(rot_col) if rot_col else (0.0, 0.0, 0.0)
# 缩放
scale = parse_scale(row.get("缩放", "—"))
# 材质
material = clean_cell(row.get("材质", ""))
if material in ('—', '–', '-'):
material = ""
# 说明
note = clean_cell(row.get("说明", ""))
if not category:
category = "STR"
return ActorData(
actor_name=name,
asset_key=asset_key,
category=category,
zone_id=zone,
location=location,
rotation=rotation,
scale=scale,
material=material,
folder=self._get_folder(zone, category),
note=note,
)
def _parse_interactable_row(self, row: dict, zone: str) -> ActorData:
"""解析交互物行。用占位 Cube 表示。"""
name = clean_cell(row.get("Actor 名称", row.get("Actor名称", "")))
location = parse_location(
row.get("位置 (X,Y,Z)", row.get("位置", ""))
)
trigger = row.get("触发半径", row.get("触发半径 (cm)", "150"))
try:
trigger_radius = float(trigger)
except (ValueError, TypeError):
trigger_radius = 150.0
ref = clean_cell(row.get("引用", ""))
interact_type = clean_cell(row.get("交互类型", ""))
return ActorData(
actor_name=name,
asset_key="Cube",
category="INTR",
zone_id=zone,
location=location,
scale=(0.3, 0.3, 0.3),
material="MI_DevYellow",
folder=self._get_folder(zone, "INTR"),
trigger_radius=trigger_radius,
interact_type=interact_type,
source_ref=ref,
note="交互物占位标记",
)
def _parse_collectible_row(self, row: dict, zone: str) -> ActorData:
"""解析收集品行。用占位 Sphere/Cube 表示。"""
name = clean_cell(row.get("Actor 名称", row.get("Actor名称", "")))
location = parse_location(
row.get("位置 (X,Y,Z)", row.get("位置", ""))
)
rarity = clean_cell(row.get("稀有度", "普通"))
ref = clean_cell(row.get("引用", ""))
# 稀有用金色,普通用白色
material = "MI_DevYellow" if rarity == "稀有" else "MI_DevWhite"
return ActorData(
actor_name=name,
asset_key="CirclePlatform",
category="COLL",
zone_id=zone,
location=location,
scale=(0.03, 0.03, 0.03),
material=material,
folder=self._get_folder(zone, "COLL"),
rarity=rarity,
source_ref=ref,
note="收集品占位标记",
)
# ----------------------------------------------------------
# Phase C — 全局物件
# ----------------------------------------------------------
def _parse_phase_global_objects(self, lines: list[str]):
"""解析全局物件(路灯、垃圾桶等)。"""
g_start = self._find_section_start(lines, "全局物件")
g_end = self._find_section_start(lines, "Phase D")
if g_start < 0:
return
if g_end < 0:
g_end = len(lines)
section_lines = lines[g_start:g_end]
i = 0
while i < len(section_lines):
line = section_lines[i].strip()
if '|' in line:
table_lines = []
while i < len(section_lines) and '|' in section_lines[i]:
table_lines.append(section_lines[i])
i += 1
rows = parse_table(table_lines)
if rows:
for row in rows:
actor = self._parse_global_row(row)
if actor:
self.actors.append(actor)
continue
i += 1
def _parse_global_row(self, row: dict) -> Optional[ActorData]:
"""解析全局物件行。"""
name = clean_cell(row.get("Actor 名称", row.get("Actor名称", "")))
if not name:
return None
asset_key = clean_cell(row.get("资产", ""))
location = parse_location(row.get("位置 (X,Y,Z)", row.get("位置", "")))
material = clean_cell(row.get("材质", ""))
if material in ('—', '–', '-'):
material = ""
# 从 Actor 名称推断类别
category = "PROP"
if "_LGT_" in name:
category = "LGT"
elif "_PROP_" in name:
category = "PROP"
zone_id = self._infer_zone_from_name(name)
return ActorData(
actor_name=name,
asset_key=asset_key,
category=category,
zone_id=zone_id,
location=location,
material=material,
folder=self._get_folder(zone_id, category),
)
# ----------------------------------------------------------
# Phase D — 光照
# ----------------------------------------------------------
def _parse_phase_lighting(self, lines: list[str]):
"""解析光照配置。"""
d_start = self._find_section_start(lines, "Phase D")
d_end = self._find_section_start(lines, "Phase E")
if d_start < 0:
return
if d_end < 0:
d_end = len(lines)
section_lines = lines[d_start:d_end]
# 全局光源:DirectionalLight + SkyLight
self._parse_global_lights(section_lines)
# 路灯点光源
self._parse_street_lights(section_lines)
def _parse_global_lights(self, lines: list[str]):
"""解析全局光源表格。"""
text = "\n".join(lines)
# Directional Light 和 Sky Light
if "Directional Light" in text or "DirectionalLight" in text:
self.actors.append(ActorData(
actor_name="LVL_Global_DirectionalLight",
asset_key="",
asset_type="directional_light",
category="LGT",
zone_id="GLOBAL",
location=(0, 0, 500),
rotation=(-45, 315, 0),
folder=self._get_folder("GLOBAL", "LGT"),
note="色温~5500K暖白;从西南照射;强度中等",
))
if "Sky Light" in text or "SkyLight" in text:
self.actors.append(ActorData(
actor_name="LVL_Global_SkyLight",
asset_key="",
asset_type="sky_light",
category="LGT",
zone_id="GLOBAL",
location=(0, 0, 0),
folder=self._get_folder("GLOBAL", "LGT"),
note="强度低-中;环境补光",
))
def _parse_street_lights(self, lines: list[str]):
"""解析路灯点光源表格。"""
i = 0
while i < len(lines):
line = lines[i].strip()
# 检测表格起始(含 | 的行)
if '|' in line:
table_lines = []
while i < len(lines) and '|' in lines[i]:
table_lines.append(lines[i])
i += 1
# 检查表格内容是否包含 Point Light
table_text = "\n".join(table_lines)
if "Point" in table_text or "point_light" in table_text.lower():
rows = parse_table(table_lines)
for row in rows:
name = clean_cell(row.get("Actor 名称", row.get("Actor名称", "")))
loc_col = row.get("位置 (X,Y,Z)", row.get("位置", ""))
location = parse_location(loc_col)
if name:
self.actors.append(ActorData(
actor_name=name,
asset_key="",
asset_type="point_light",
category="LGT",
zone_id="Z3",
location=location,
folder=self._get_folder("GLOBAL", "LGT"),
note="暖黄色,低强度",
))
continue
i += 1
# ----------------------------------------------------------
# Player Start
# ----------------------------------------------------------
def _add_player_start(self):
"""添加 Player Start Actor。"""
self.actors.append(ActorData(
actor_name="LVL_PlayerStart",
asset_key="",
asset_type="player_start",
category="PLAY",
zone_id="GLOBAL",
location=(500, 720, 0),
rotation=(0, 0, 0),
folder=self._get_folder("GLOBAL", "PLAY"),
note="Z1 院内院门内侧约60cm,面向+Y(北)",
))
# ----------------------------------------------------------
# 辅助方法
# ----------------------------------------------------------
def _find_section_start(self, lines: list[str], keyword: str) -> int:
"""找到包含关键词的标题行。"""
for i, line in enumerate(lines):
if keyword in line:
return i
return -1
def _infer_zone_from_name(self, name: str) -> str:
"""从 Actor 名称推断区域 ID。"""
m = re.match(r'LVL_(Z\d+)_', name)
if m:
return m.group(1)
if 'BND' in name:
return "GLOBAL"
if 'Global' in name:
return "GLOBAL"
return "GLOBAL"
def _get_folder(self, zone_id: str, category: str) -> str:
"""获取 Outliner 文件夹路径。"""
from config.zone_config import get_folder_path
return get_folder_path(zone_id, category)
def _build_summary(self) -> dict:
"""构建解析摘要。"""
total = len(self.actors)
by_category = {}
by_zone = {}
asset_keys = set()
for a in self.actors:
by_category[a.category] = by_category.get(a.category, 0) + 1
by_zone[a.zone_id] = by_zone.get(a.zone_id, 0) + 1
if a.asset_key:
asset_keys.add(a.asset_key)
return {
"total_actors": total,
"by_category": by_category,
"by_zone": by_zone,
"unique_assets": len(asset_keys),
"asset_list": sorted(asset_keys),
}
@@ -0,0 +1,186 @@
"""通用 Markdown 管道表格解析器。
将 Markdown 表格文本解析为 list[dict],每个字典代表一行数据。
支持中文列名,处理列数不一致的情况。
"""
import re
from typing import Optional
def parse_table(lines: list[str]) -> list[dict]:
"""解析单个 Markdown 表格。
Args:
lines: 表格的文本行列表(包含表头、分隔行和数据行)。
Returns:
列表,每个元素是一行数据的字典,key 为表头文本。
返回空列表如果输入不是有效表格。
"""
if not lines:
return []
# 找到分隔行(包含 |---| 的行)
sep_idx = -1
for i, line in enumerate(lines):
if re.match(r'\s*\|[\s\-:]+\|', line):
sep_idx = i
break
if sep_idx < 1:
return [] # 没有有效的表头+分隔行
# 提取表头
header_line = lines[sep_idx - 1]
headers = _split_row(header_line)
if not headers:
return []
# 提取数据行(分隔行之后的所有 | 开头的行)
data_rows = []
for line in lines[sep_idx + 1:]:
stripped = line.strip()
if not stripped or not stripped.startswith('|'):
break # 表格结束
cells = _split_row(stripped)
data_rows.append(cells)
# 构建结果
result = []
for row in data_rows:
entry = {}
for j, header in enumerate(headers):
entry[header] = row[j] if j < len(row) else ""
result.append(entry)
return result
def parse_all_tables(text: str) -> list[tuple[list[str], list[dict]]]:
"""解析文本中的所有 Markdown 表格。
Args:
text: 完整的 Markdown 文本。
Returns:
列表,每个元素为 (header, rows) 元组。
header 为表头列表,rows 为 list[dict]。
"""
results = []
lines = text.split('\n')
i = 0
while i < len(lines):
# 寻找潜在的表格起始行(包含 | 的行)
if '|' in lines[i]:
# 收集连续的表格行
table_lines = []
while i < len(lines) and '|' in lines[i]:
table_lines.append(lines[i].strip())
i += 1
# 尝试解析
rows = parse_table(table_lines)
if rows:
# 提取表头
for tl in table_lines:
if re.match(r'\s*\|[\s\-:]+\|', tl):
sep_idx = table_lines.index(tl)
if sep_idx > 0:
headers = _split_row(table_lines[sep_idx - 1])
results.append((headers, rows))
break
continue
i += 1
return results
def find_tables_in_section(text: str, section_start: int, section_end: int) -> list[list[dict]]:
"""在文本的指定范围内找到所有表格。
Args:
text: 完整文本。
section_start: 区域起始行号。
section_end: 区域结束行号。
Returns:
表格列表,每个表格是 list[dict]。
"""
lines = text.split('\n')
section_lines = lines[section_start:section_end]
tables = []
i = 0
while i < len(section_lines):
if '|' in section_lines[i]:
# 收集连续表格行
table_lines = []
while i < len(section_lines) and '|' in section_lines[i]:
table_lines.append(section_lines[i])
i += 1
rows = parse_table(table_lines)
if rows:
tables.append(rows)
continue
i += 1
return tables
def _split_row(line: str) -> list[str]:
"""将 | 分隔的行拆分为单元格列表,去除首尾空格。"""
# 去除首尾空白
line = line.strip()
# 按管道符分割
if not line.startswith('|'):
return []
if not line.endswith('|'):
# 有些行末尾可能没有 |
parts = line.split('|')
else:
parts = line.split('|')
# 去除首尾空元素(管道符分割产生的)
cells = [c.strip() for c in parts]
# 去掉首尾空字符串
while cells and cells[0] == '':
cells.pop(0)
while cells and cells[-1] == '':
cells.pop()
return cells
def parse_location(text: str) -> tuple[float, float, float]:
"""解析位置字符串 '(X,Y,Z)' 为三元组。"""
if not text:
return (0.0, 0.0, 0.0)
m = re.search(r'\(([-\d.]+)\s*[,,]\s*([-\d.]+)\s*[,,]\s*([-\d.]+)\)', text)
if m:
return (float(m.group(1)), float(m.group(2)), float(m.group(3)))
return (0.0, 0.0, 0.0)
def parse_rotation(text: str) -> tuple[float, float, float]:
"""解析旋转字符串 '(P,Y,R)' 为三元组(度数)。"""
return parse_location(text)
def parse_scale(text: str) -> tuple[float, float, float]:
"""解析缩放字符串 '(X,Y,Z)' 为三元组。'—' 或空返回 (1,1,1)。"""
if not text or text.strip() in ('—', '–', '-', '无', ''):
return (1.0, 1.0, 1.0)
return parse_location(text)
def clean_cell(text: str) -> str:
"""清理单元格文本:去首尾空白、去反引号。"""
if not text:
return ""
text = text.strip()
# 去除 Markdown 反引号包裹
if text.startswith('`') and text.endswith('`'):
text = text[1:-1]
return text.strip()
@@ -0,0 +1,159 @@
"""搭建报告生成器。
从解析数据和构建日志生成 Markdown 格式的搭建报告,
格式与 docs/technical/关卡-v01-搭建报告.md 一致。
"""
import json
from pathlib import Path
from datetime import datetime
from typing import Optional
def generate_report(
level_data: dict,
build_log: Optional[list[dict]] = None,
output_path: str = "",
) -> str:
"""生成搭建报告 Markdown。
Args:
level_data: 解析器输出的关卡数据(含 metadata/actors/summary)。
build_log: 构建日志(含每个 Actor 的成功/失败状态)。可选。
output_path: 报告输出路径。为空则只返回字符串。
Returns:
Markdown 格式的搭建报告文本。
"""
actors = level_data.get("actors", [])
summary = level_data.get("summary", {})
metadata = level_data.get("metadata", {})
lines = []
lines.append("# 白模搭建报告 — 自动生成")
lines.append("")
lines.append(f"> 生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M')}")
lines.append(f"> 数据来源: {metadata.get('source', '未知')}")
lines.append(f"> 场景尺寸: {metadata.get('scene_size', ['?','?'])[0]}cm × {metadata.get('scene_size', ['?','?'])[1]}cm")
lines.append("")
# ---- 搭建概要 ----
lines.append("## 一、搭建概要")
lines.append("")
total = summary.get("total_actors", len(actors))
by_category = summary.get("by_category", {})
lines.append(f"- **总 Actor 数**: {total}")
for cat in ["STR", "PROP", "INTR", "COLL", "BND", "LGT", "PLAY"]:
count = by_category.get(cat, 0)
if count > 0:
cat_name = {"STR": "结构体", "PROP": "布景", "INTR": "交互物",
"COLL": "收集品", "BND": "边界", "LGT": "光源", "PLAY": "PlayerStart"}
lines.append(f"- {cat_name.get(cat, cat)} ({cat}): {count}")
if build_log:
ok = sum(1 for b in build_log if b.get("status") == "ok")
failed = sum(1 for b in build_log if b.get("status") != "ok")
lines.append(f"- 构建成功: {ok}")
lines.append(f"- 构建失败: {failed}")
lines.append("")
# ---- 逐区域对照 ----
lines.append("## 二、逐区域对照")
lines.append("")
by_zone = summary.get("by_zone", {})
zone_names = {
"Z1": "家", "Z2": "小卖部", "Z3": "主街道",
"Z4": "空地", "Z5": "巷子", "Z6": "水塘边", "Z7": "老居民楼",
"GLOBAL": "全局",
}
for zone_id in ["Z1", "Z2", "Z3", "Z4", "Z5", "Z6", "Z7", "GLOBAL"]:
zone_actors = [a for a in actors if a.get("zone_id") == zone_id]
if not zone_actors:
continue
zone_name = zone_names.get(zone_id, zone_id)
lines.append(f"### {zone_id} {zone_name}")
lines.append("")
lines.append(f"共 {len(zone_actors)} 个 Actor")
lines.append("")
lines.append("| # | Actor 名称 | 资产 | 位置 | 缩放 | 材质 | 状态 |")
lines.append("|---|-----------|------|------|------|------|------|")
for j, a in enumerate(zone_actors):
name = a.get("actor_name", "")
asset = a.get("asset_key", "—")
loc = a.get("location", [0, 0, 0])
loc_str = f"({loc[0]:.0f},{loc[1]:.0f},{loc[2]:.0f})"
scale = a.get("scale", [1, 1, 1])
if scale == [1, 1, 1] or scale == (1, 1, 1):
scale_str = "—"
else:
scale_str = f"({scale[0]:.2f},{scale[1]:.2f},{scale[2]:.2f})"
mat = a.get("material", "—") or "—"
# 状态
status = "✅"
if build_log:
log_entry = next((b for b in build_log if b.get("name") == name), None)
if log_entry:
status = "✅" if log_entry.get("status") == "ok" else f"❌ {log_entry.get('reason', '')}"
lines.append(f"| {j+1} | {name} | {asset} | {loc_str} | {scale_str} | {mat} | {status} |")
lines.append("")
# ---- 资产使用统计 ----
lines.append("## 三、资产使用统计")
lines.append("")
asset_usage = {}
for a in actors:
key = a.get("asset_key", "")
if key:
asset_usage[key] = asset_usage.get(key, 0) + 1
lines.append("| 资产名 | 使用次数 |")
lines.append("|--------|---------|")
for key in sorted(asset_usage.keys(), key=lambda k: -asset_usage[k]):
lines.append(f"| {key} | {asset_usage[key]} |")
lines.append("")
# ---- 缩放记录 ----
lines.append("## 四、缩放记录")
lines.append("")
lines.append("| Actor 名称 | 资产 | 缩放 | 实际尺寸 (cm) |")
lines.append("|-----------|------|------|-------------|")
for a in actors:
scale = a.get("scale", [1, 1, 1])
if scale != [1, 1, 1] and scale != (1, 1, 1):
name = a.get("actor_name", "")
asset_key = a.get("asset_key", "")
# 计算实际尺寸
from config.asset_mapping import MESH_ASSETS
default_size = MESH_ASSETS.get(asset_key, {}).get("default_size", (100, 100, 100))
if default_size:
actual = (
f"{default_size[0]*scale[0]:.0f}×"
f"{default_size[1]*scale[1]:.0f}×"
f"{default_size[2]*scale[2]:.0f}"
)
else:
actual = "(默认尺寸未知)"
lines.append(f"| {name} | {asset_key} | ({scale[0]:.2f},{scale[1]:.2f},{scale[2]:.2f}) | {actual} |")
lines.append("")
report = "\n".join(lines)
# 写入文件
if output_path:
Path(output_path).write_text(report, encoding="utf-8")
print(f"报告已写入: {output_path}")
return report
@@ -0,0 +1,103 @@
"""离线资产验证模块。
检查解析数据中的资产引用是否有效:
1. asset_key 是否在 asset_mapping 中定义
2. 材质名是否在 material_mapping 中定义
3. Content/ 目录下 .uasset 文件是否存在(可选)
"""
import logging
from pathlib import Path
from dataclasses import dataclass
from config.asset_mapping import MESH_ASSETS
from config.material_mapping import MATERIAL_PATHS
logger = logging.getLogger("level_builder.validator")
@dataclass
class ValidationResult:
"""验证结果。"""
total_actors: int = 0
valid_actors: int = 0
warnings: list[str] = None
errors: list[str] = None
missing_assets: set[str] = None
missing_materials: set[str] = None
def __post_init__(self):
if self.warnings is None:
self.warnings = []
if self.errors is None:
self.errors = []
if self.missing_assets is None:
self.missing_assets = set()
if self.missing_materials is None:
self.missing_materials = set()
@property
def is_valid(self) -> bool:
return len(self.errors) == 0
def validate_actors(actors: list[dict], project_root: str = "") -> ValidationResult:
"""验证 Actor 列表中的资产引用。
Args:
actors: Actor 数据列表(来自解析器的 JSON 输出)。
project_root: 项目根目录路径(用于文件存在性检查)。
Returns:
ValidationResult 包含所有验证信息。
"""
result = ValidationResult(total_actors=len(actors))
for i, actor in enumerate(actors):
name = actor.get("actor_name", f"<unnamed #{i}>")
asset_key = actor.get("asset_key", "")
material = actor.get("material", "")
asset_type = actor.get("asset_type", "static_mesh")
# 跳过非 mesh 类型(光照、PlayerStart 等)
if asset_type != "static_mesh":
result.valid_actors += 1
continue
# 检查 asset_key
if not asset_key:
result.warnings.append(f"{name}: 无 asset_key(可能是光照/特殊 Actor)")
result.valid_actors += 1
continue
if asset_key not in MESH_ASSETS:
result.errors.append(f"{name}: 未知资产 '{asset_key}'")
result.missing_assets.add(asset_key)
continue
# 检查材质
if material and material not in MATERIAL_PATHS:
result.warnings.append(f"{name}: 未知材质 '{material}'")
result.missing_materials.add(material)
# 检查 .uasset 文件存在性(如果提供了项目根目录)
if project_root:
mesh_info = MESH_ASSETS[asset_key]
asset_path = mesh_info["path"]
# /Game/BlockingStarterPack/Meshes/.../SM_Xxx.SM_Xxx → Content/BlockingStarterPack/Meshes/.../SM_Xxx.uasset
fs_path = asset_path.replace("/Game/", "Content/").split(".")[0] + ".uasset"
full_path = Path(project_root) / fs_path
if not full_path.exists():
result.warnings.append(f"{name}: 资产文件不存在 '{fs_path}'")
result.valid_actors += 1
# 汇总日志
if result.missing_assets:
logger.error(f"缺失资产定义: {sorted(result.missing_assets)}")
if result.missing_materials:
logger.warning(f"缺失材质定义: {sorted(result.missing_materials)}")
logger.info(f"验证完成: {result.valid_actors}/{result.total_actors} 有效, "
f"{len(result.errors)} 错误, {len(result.warnings)} 警告")
return result