#!/usr/bin/env python3 """将角色 XPbr / 烘焙产物安装到 Bundles/Characters。 人物:Characters/Human/{ActorName}/ 不传 --kind 时落到 Characters/{ActorName}/(兼容 MaleExplorer)。 优先识别 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 # Pipeline SetupCharacter 生成 概念图留在 Doc/Design/Character/,不要拷进 Bundles。 兼容旧五通道烘焙目录(_color/_normal/_ao/_metallic/_roughness)。 用法: python Tools/install_character_bundles.py -a CaoCao --kind Human -b Tools/output/caocao_game/caocao_xpbr -c Doc/Design/Character/CaoCao/caocao.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 _safe_seg(name: str, label: str) -> str: text = (name or "").strip() if not text or any(ch in text for ch in r'\/:*?"<>|'): raise ValueError(f"非法 {label}: {name!r}") return text def install( *, actor: str, bake_dir: Path, kind: str | None = None, concept: Path | None = None, dry_run: bool = False, ) -> dict: actor = _safe_seg(actor, "ActorName") kind = _safe_seg(kind, "Kind") if kind else None bake_dir = bake_dir.expanduser().resolve() if not bake_dir.is_dir(): raise FileNotFoundError(f"资源目录不存在: {bake_dir}") dest = _repo_root() / "AIClient" / "Assets" / "Bundles" / "Characters" if kind: dest = dest / kind dest = dest / 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, "kind": kind, "mode": mode, "dest": str(dest.relative_to(_repo_root())).replace("\\", "/"), "files": copied, "hint": ( "Unity Pipeline: unity command run_script --file AgentScripts/SetupCharacter.cs " f"--entry SetupCharacter.Run --args {json.dumps([kind or '', actor])}" ";并登记 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="角色目录名 / 寻址名,如 CaoCao") p.add_argument( "--kind", "-k", default=None, help="人物用 Human,落到 Characters/{Kind}/{Actor};省略则为 Characters/{Actor}", ) 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), kind=args.kind, 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())