Files
AIGame/AIClient/AgentScripts/SetupAnimalAnimator.cs
T

296 lines
13 KiB
C#

#if UNITY_EDITOR
using System.IO;
using System.Linq;
using UnityEditor;
using UnityEditor.Animations;
using UnityEngine;
/// <summary>
/// 非人角色:同源TokenRig + UniMate三动作 → Generic资产;敌方入口还执行离线收尾与验证。
/// unity command run_script --file AgentScripts/SetupAnimalAnimator.cs --entry SetupAnimalAnimator.SetupRockBall
/// </summary>
public static class SetupAnimalAnimator
{
const string ArtPackageHint = "Assets/Bundles/Characters";
public static string SetupRockBall()
{
return Run("Animal", "RockBall", 0.55f);
}
public static string SetupCampGuide()
{
return Run("Human", "CampGuide", 1.55f);
}
// First validate one gait on the matching rig before accepting more actions.
public static string SetupRockBallWalk()
{
return Run("Animal", "RockBall", 0.55f, true);
}
/// <summary>
/// kind/actor/heightMeters — 从 Bundles/.../{actor}/Rigged/{actor}_Rigged.fbx 与 Anims/ 建 Prefab。
/// </summary>
public static string Run(string kind, string actor, float heightMeters = 0.55f, bool walkOnly = false)
{
if (string.IsNullOrWhiteSpace(actor))
throw new System.ArgumentException("actor required");
var root = string.IsNullOrWhiteSpace(kind)
? $"Assets/Bundles/Characters/{actor}"
: $"Assets/Bundles/Characters/{kind}/{actor}";
var riggedDir = $"{root}/Rigged";
var animDir = $"{root}/Anims";
var fbxPath = $"{riggedDir}/{actor}_Rigged.fbx";
if (!File.Exists(fbxPath))
throw new FileNotFoundException("missing rigged fbx", fbxPath);
AssetDatabase.Refresh();
ConfigureModelImporter(fbxPath, isCharacter: true, sourceAvatar: null);
var character = AssetDatabase.LoadAssetAtPath<GameObject>(fbxPath);
var avatar = character != null ? character.GetComponent<Animator>()?.avatar : null;
if (avatar == null)
{
// Avatar is on the imported model sub-asset.
foreach (var obj in AssetDatabase.LoadAllAssetsAtPath(fbxPath))
if (obj is Avatar av) { avatar = av; break; }
}
foreach (var clipName in walkOnly ? new[] { "Walk" } : new[] { "Idle", "Walk", "Attack" })
{
var animPath = $"{animDir}/{actor}_{clipName}.fbx";
if (File.Exists(animPath))
{
ConfigureModelImporter(animPath, isCharacter: false, sourceAvatar: avatar);
ValidateSkeleton(character, AssetDatabase.LoadAssetAtPath<GameObject>(animPath), animPath);
}
}
AssetDatabase.Refresh();
var controllerPath = $"{root}/{actor}_Animator.controller";
var controller = EnsureController(controllerPath, animDir, actor, walkOnly);
var prefabPath = EnsurePrefab(root, actor, fbxPath, controller, heightMeters);
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
return $"rigged={fbxPath} controller={controllerPath} prefab={prefabPath} collect={ArtPackageHint}";
}
static void ValidateSkeleton(GameObject rig, GameObject animation, string path)
{
var expected = rig.GetComponentsInChildren<Transform>(true)
.Where(t => t.name.StartsWith("bone_"))
.Select(t => AnimationUtility.CalculateTransformPath(t, rig.transform)).OrderBy(p => p).ToArray();
var actual = animation.GetComponentsInChildren<Transform>(true)
.Where(t => t.name.StartsWith("bone_"))
.Select(t => AnimationUtility.CalculateTransformPath(t, animation.transform)).OrderBy(p => p).ToArray();
if (expected.Length == 0 || !expected.SequenceEqual(actual))
throw new System.InvalidOperationException($"Skeleton mismatch: rig={expected.Length} animation={actual.Length}: {path}. Use animation generated from this exact rig.");
}
static void ConfigureModelImporter(string path, bool isCharacter, Avatar sourceAvatar)
{
var importer = AssetImporter.GetAtPath(path) as ModelImporter;
if (importer == null) return;
importer.animationType = ModelImporterAnimationType.Generic;
if (isCharacter)
{
importer.avatarSetup = ModelImporterAvatarSetup.CreateFromThisModel;
importer.importAnimation = false;
importer.materialImportMode = ModelImporterMaterialImportMode.ImportViaMaterialDescription;
}
else
{
importer.avatarSetup = ModelImporterAvatarSetup.CopyFromOther;
if (sourceAvatar != null) importer.sourceAvatar = sourceAvatar;
importer.importAnimation = true;
importer.materialImportMode = ModelImporterMaterialImportMode.None;
}
importer.globalScale = 1f;
importer.useFileScale = false;
importer.bakeAxisConversion = true;
importer.SaveAndReimport();
}
static AnimatorController EnsureController(string controllerPath, string animDir, string actor, bool walkOnly)
{
var controller = AssetDatabase.LoadAssetAtPath<AnimatorController>(controllerPath);
if (controller == null)
{
controller = AnimatorController.CreateAnimatorControllerAtPath(controllerPath);
}
// Clear default layer states and rebuild Idle/Walk/Attack.
var sm = controller.layers[0].stateMachine;
foreach (var s in sm.states.ToArray())
sm.RemoveState(s.state);
foreach (var t in sm.anyStateTransitions.ToArray())
sm.RemoveAnyStateTransition(t);
foreach (var p in controller.parameters.ToArray())
controller.RemoveParameter(p);
controller.AddParameter("Motion", AnimatorControllerParameterType.Int);
if (!walkOnly) controller.AddParameter("Attack", AnimatorControllerParameterType.Trigger);
if (walkOnly)
{
var parameters = controller.parameters;
parameters[0].defaultInt = 1;
controller.parameters = parameters;
}
var clipDir = $"{Path.GetDirectoryName(controllerPath).Replace('\\', '/')}/Clips";
Directory.CreateDirectory(Path.GetFullPath(Path.Combine(Directory.GetParent(Application.dataPath).FullName, clipDir)));
var idleClip = walkOnly ? null : ExtractNamedClip($"{animDir}/{actor}_Idle.fbx", "Idle", clipDir);
var walkClip = ExtractNamedClip($"{animDir}/{actor}_Walk.fbx", "Walk", clipDir);
var attackClip = walkOnly ? null : ExtractNamedClip($"{animDir}/{actor}_Attack.fbx", "Attack", clipDir);
if (walkOnly)
{
if (walkClip == null) throw new FileNotFoundException("Walk clip missing under " + animDir);
var walkState = sm.AddState("Walk");
walkState.motion = walkClip;
sm.defaultState = walkState;
EditorUtility.SetDirty(controller);
return controller;
}
if (idleClip == null) throw new FileNotFoundException("Idle clip missing under " + animDir);
var idle = sm.AddState("Idle");
idle.motion = idleClip;
sm.defaultState = idle;
AnimatorState walk = null;
if (walkClip != null)
{
walk = sm.AddState("Walk");
walk.motion = walkClip;
var toWalk = idle.AddTransition(walk);
toWalk.hasExitTime = false;
toWalk.duration = 0.1f;
toWalk.AddCondition(AnimatorConditionMode.Equals, 1, "Motion");
var backIdle = walk.AddTransition(idle);
backIdle.hasExitTime = false;
backIdle.duration = 0.1f;
backIdle.AddCondition(AnimatorConditionMode.Equals, 0, "Motion");
}
if (attackClip != null)
{
var attack = sm.AddState("Attack");
attack.motion = attackClip;
foreach(var from in walk == null ? new[] { idle } : new[] { idle, walk })
{
var toAtk = from.AddTransition(attack);
toAtk.hasExitTime = false;
toAtk.duration = 0.05f;
toAtk.AddCondition(AnimatorConditionMode.If, 0, "Attack");
}
var atkBack = attack.AddTransition(idle);
atkBack.hasExitTime = true;
atkBack.exitTime = 0.98f;
atkBack.duration = 0.05f;
atkBack.AddCondition(AnimatorConditionMode.Equals, 0, "Motion");
if(walk != null)
{
var resume = attack.AddTransition(walk);
resume.hasExitTime = true;
resume.exitTime = 0.98f;
resume.duration = 0.05f;
resume.AddCondition(AnimatorConditionMode.Equals, 1, "Motion");
}
}
EditorUtility.SetDirty(controller);
return controller;
}
static AnimationClip ExtractNamedClip(string modelPath, string logicalName, string clipDir)
{
if (!File.Exists(modelPath)) return null;
var assets = AssetDatabase.LoadAllAssetsAtPath(modelPath);
var src = assets.OfType<AnimationClip>().FirstOrDefault(c => !c.name.StartsWith("__preview__"));
if (src == null) return null;
var destPath = $"{clipDir}/{logicalName}.anim";
var copy = Object.Instantiate(src);
copy.name = logicalName;
var settings = AnimationUtility.GetAnimationClipSettings(copy);
settings.loopTime = logicalName != "Attack";
AnimationUtility.SetAnimationClipSettings(copy, settings);
var existing = AssetDatabase.LoadAssetAtPath<AnimationClip>(destPath);
if (existing == null) AssetDatabase.CreateAsset(copy, destPath);
else
{
EditorUtility.CopySerialized(copy, existing);
Object.DestroyImmediate(copy);
EditorUtility.SetDirty(existing);
}
return AssetDatabase.LoadAssetAtPath<AnimationClip>(destPath);
}
static string EnsurePrefab(
string root,
string actor,
string fbxPath,
RuntimeAnimatorController controller,
float heightMeters)
{
var prefabPath = $"{root}/{actor}_Rigged.prefab";
var modelRoot = AssetDatabase.LoadAssetAtPath<GameObject>(fbxPath);
if (modelRoot == null) throw new FileNotFoundException("fbx not loaded", fbxPath);
// 外壳根节点供验收场景摆位(会写 localPosition);缩放/贴地放在子节点 Model。
var instance = new GameObject(actor + "_Rigged");
var model = (GameObject)PrefabUtility.InstantiatePrefab(modelRoot, instance.transform);
model.name = "Model";
model.transform.localPosition = Vector3.zero;
model.transform.localRotation = Quaternion.identity;
model.transform.localScale = Vector3.one;
// SkinnedMeshRenderer.bounds is a conservative animation/culling box,
// not actual rest geometry. Bake at unit model scale before sizing.
var bounds = BindBounds(model);
if (bounds.size.y > 0.01f)
{
float scale = heightMeters / bounds.size.y;
model.transform.localScale = Vector3.one * scale;
model.transform.localPosition = -new Vector3(bounds.center.x, bounds.min.y, bounds.center.z) * scale;
}
// 挂回已验收静态模的 XPbr(TokenRig 导出材质通常较差)
var mat = AssetDatabase.LoadAssetAtPath<Material>($"{root}/{actor}.mat");
if (mat != null)
{
foreach (var renderer in model.GetComponentsInChildren<Renderer>(true))
renderer.sharedMaterial = mat;
}
var animator = model.GetComponent<Animator>();
if (animator == null) animator = model.AddComponent<Animator>();
animator.runtimeAnimatorController = controller;
animator.applyRootMotion = false;
animator.cullingMode = AnimatorCullingMode.AlwaysAnimate;
PrefabUtility.SaveAsPrefabAsset(instance, prefabPath);
Object.DestroyImmediate(instance);
return prefabPath;
}
static Bounds BindBounds(GameObject model)
{
Bounds bounds = default;
bool has = false;
foreach (var skin in model.GetComponentsInChildren<SkinnedMeshRenderer>())
{
var mesh = new Mesh();
skin.BakeMesh(mesh);
foreach (var v in mesh.vertices)
{
var p = model.transform.InverseTransformPoint(skin.transform.TransformPoint(v));
if (!has) { bounds = new Bounds(p, Vector3.zero); has = true; }
else bounds.Encapsulate(p);
}
Object.DestroyImmediate(mesh);
}
if (!has) throw new System.InvalidOperationException("No skinned bind geometry in " + model.name);
return bounds;
}
}
#endif