291 lines
12 KiB
C#
291 lines
12 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);
|
|
}
|
|
|
|
// 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
|