#if UNITY_EDITOR using System.IO; using System.Linq; using UnityEditor; using UnityEditor.Animations; using UnityEngine; /// /// 非人角色:同源TokenRig + UniMate三动作 → Generic资产;敌方入口还执行离线收尾与验证。 /// unity command run_script --file AgentScripts/SetupAnimalAnimator.cs --entry SetupAnimalAnimator.SetupRockBall /// 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); } /// /// kind/actor/heightMeters — 从 Bundles/.../{actor}/Rigged/{actor}_Rigged.fbx 与 Anims/ 建 Prefab。 /// 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(fbxPath); var avatar = character != null ? character.GetComponent()?.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(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(true) .Where(t => t.name.StartsWith("bone_")) .Select(t => AnimationUtility.CalculateTransformPath(t, rig.transform)).OrderBy(p => p).ToArray(); var actual = animation.GetComponentsInChildren(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(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().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(destPath); if (existing == null) AssetDatabase.CreateAsset(copy, destPath); else { EditorUtility.CopySerialized(copy, existing); Object.DestroyImmediate(copy); EditorUtility.SetDirty(existing); } return AssetDatabase.LoadAssetAtPath(destPath); } static string EnsurePrefab( string root, string actor, string fbxPath, RuntimeAnimatorController controller, float heightMeters) { var prefabPath = $"{root}/{actor}_Rigged.prefab"; var modelRoot = AssetDatabase.LoadAssetAtPath(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($"{root}/{actor}.mat"); if (mat != null) { foreach (var renderer in model.GetComponentsInChildren(true)) renderer.sharedMaterial = mat; } var animator = model.GetComponent(); if (animator == null) animator = model.AddComponent(); 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()) { 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