Files
AIGame/Tools/install_character_bundles.py
T
udxandCursor 6550b8df4b Add AIServer, character art pipeline, and Art package boot integration.
Bring in MaleExplorer assets/shaders, model tooling and Hunyuan updates, plus boot/YooAsset Art package support.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-21 17:06:59 +08:00

172 lines
5.6 KiB
Python

#!/usr/bin/env python3
"""将角色 XPbr / 烘焙产物安装到 AIClient/Assets/Bundles/Characters/{ActorName}/。
优先识别 XPbr 产物(推荐):
{ActorName}.fbx
{ActorName}_base.png # RGB albedo, A=AO/透明
{ActorName}_mix.png # RG sphere-normal, B=metallic, A=roughness
{ActorName}.mat # Test/XPbr
concept.png # 可选
{ActorName}.prefab # Unity 菜单生成
兼容旧五通道烘焙目录(_color/_normal/_ao/_metallic/_roughness)。
用法:
python Tools/install_character_bundles.py -a MaleExplorer -b Tools/output/actor_game/actor_xpbr -c ref.png
"""
from __future__ import annotations
import argparse
import json
import shutil
import sys
from pathlib import Path
def _repo_root() -> Path:
return Path(__file__).resolve().parent.parent
def _pick_first(files: list[Path], predicate) -> Path | None:
for path in files:
if path.is_file() and predicate(path.name.lower()):
return path
return None
def install(
*,
actor: str,
bake_dir: Path,
concept: Path | None = None,
dry_run: bool = False,
) -> dict:
actor = actor.strip()
if not actor or any(ch in actor for ch in r'\/:*?"<>|'):
raise ValueError(f"非法 ActorName: {actor!r}")
bake_dir = bake_dir.expanduser().resolve()
if not bake_dir.is_dir():
raise FileNotFoundError(f"资源目录不存在: {bake_dir}")
dest = _repo_root() / "AIClient" / "Assets" / "Bundles" / "Characters" / actor
files = list(bake_dir.iterdir())
sources: dict[str, Path] = {}
fbx = _pick_first(files, lambda n: n.endswith(".fbx"))
if fbx:
sources["fbx"] = fbx
# XPbr 优先
base = _pick_first(files, lambda n: n.endswith("_base.png") or n.endswith("_basemap.png"))
mix = _pick_first(files, lambda n: n.endswith("_mix.png") or n.endswith("_mixmap.png"))
mat = _pick_first(files, lambda n: n.endswith(".mat"))
if base:
sources["base"] = base
if mix:
sources["mix"] = mix
if mat:
sources["mat"] = mat
# 旧五通道兜底
if "base" not in sources:
color = _pick_first(files, lambda n: "_color" in n and n.endswith(".png"))
if color:
sources["color"] = color
for key, token in (
("normal", "_normal"),
("ao", "_ao"),
("metallic", "_metallic"),
("roughness", "_roughness"),
):
hit = _pick_first(files, lambda n, t=token: t in n and n.endswith(".png"))
if hit:
sources[key] = hit
if "fbx" not in sources:
raise FileNotFoundError(f"目录缺少 FBX: {bake_dir}")
if "base" not in sources and "color" not in sources:
raise FileNotFoundError(
f"目录缺少 XPbr _base.png 或旧版 _color.png: {bake_dir}"
)
name_map = {
"fbx": f"{actor}.fbx",
"base": f"{actor}_base.png",
"mix": f"{actor}_mix.png",
"mat": f"{actor}.mat",
"color": f"{actor}_color.png",
"normal": f"{actor}_normal.png",
"ao": f"{actor}_ao.png",
"metallic": f"{actor}_metallic.png",
"roughness": f"{actor}_roughness.png",
}
copied: list[str] = []
if not dry_run:
dest.mkdir(parents=True, exist_ok=True)
for key, src in sources.items():
target = dest / name_map[key]
if not dry_run:
shutil.copy2(src, target)
# 同步 .meta(GUID 复用,避免破坏引用)
meta = Path(str(src) + ".meta")
if meta.is_file():
shutil.copy2(meta, Path(str(target) + ".meta"))
copied.append(str(target.relative_to(_repo_root())).replace("\\", "/"))
concept_rel = None
if concept is not None:
concept = concept.expanduser().resolve()
if not concept.is_file():
raise FileNotFoundError(f"概念图不存在: {concept}")
target = dest / "concept.png"
if not dry_run:
shutil.copy2(concept, target)
concept_rel = str(target.relative_to(_repo_root())).replace("\\", "/")
copied.append(concept_rel)
mode = "xpbr" if "base" in sources else "legacy_pbr"
return {
"ok": True,
"actor": actor,
"mode": mode,
"dest": str(dest.relative_to(_repo_root())).replace("\\", "/"),
"files": copied,
"hint": (
f"在 Unity 执行菜单 AIGame/M3/Setup Character Prefab + Art Collector "
f"生成挂 XPbr 的 {actor}.prefab;并登记 Doc/ResDesc_Model.md"
),
}
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(description="安装角色资源到 Bundles/Characters/{Actor}")
p.add_argument("--actor", "-a", required=True, help="角色目录名 / 寻址名,如 MaleExplorer")
p.add_argument(
"--bake-dir",
"-b",
required=True,
help="model_lowpoly_bake 的 *_xpbr 目录,或旧五通道 bake 子目录",
)
p.add_argument("--concept", "-c", default=None, help="可选概念图,复制为 concept.png")
p.add_argument("--dry-run", action="store_true")
args = p.parse_args(argv)
try:
result = install(
actor=args.actor,
bake_dir=Path(args.bake_dir),
concept=Path(args.concept) if args.concept else None,
dry_run=args.dry_run,
)
except Exception as exc: # noqa: BLE001
print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False), flush=True)
return 1
print(json.dumps(result, ensure_ascii=False, indent=2), flush=True)
return 0
if __name__ == "__main__":
sys.exit(main())