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,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