Files
AIGame/Tools/install_character_bundles.py
T
udxandCursor 9793d8581d Add TownSquare scene assets, resource production docs, and model pipeline updates.
Bring in plaza props/prefabs, ZhaoYun source GLB, design originals, and Hunyuan/Blender tooling used to produce them.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-28 14:28:46 +08:00

175 lines
5.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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
{ActorName}.prefab # Unity 菜单生成
概念图留在 Doc/Design/Character/,不要拷进 Bundles。
兼容旧五通道烘焙目录(_color/_normal/_ao/_metallic/_roughness)。
用法:
python Tools/install_character_bundles.py -a MaleExplorer -b Tools/output/actor_game/actor_xpbr
"""
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("\\", "/"))
if concept is not None:
concept = concept.expanduser().resolve()
if not concept.is_file():
raise FileNotFoundError(f"概念图不存在: {concept}")
mode = "xpbr" if "base" in sources else "legacy_pbr"
result = {
"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"
),
}
if concept is not None:
result["design"] = str(concept.relative_to(_repo_root())).replace("\\", "/")
return result
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="设计原稿路径(仅校验存在,不拷进 Bundles;应放在 Doc/Design/)",
)
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())