197 lines
6.2 KiB
Python
197 lines
6.2 KiB
Python
#!/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())
|