using System.Collections.Generic; using UnityEditor; using UnityEditor.Animations; using UnityEngine; using Ashwild.Player; namespace Ashwild.EditorTools { /// /// Editor tool that builds the master Animator Controller the player rigs run on. The controller is /// authored once and never edited per weapon: every state points at an empty placeholder clip whose /// *name* is the slot key, and PlayerAnimationSetBinder swaps those clips at runtime for whatever the /// held item authors. /// /// Generating it rather than clicking it together is not a convenience — the contract between the /// controller and PlayerAnimationSet is a set of exact strings (parameter names, clip names), and a /// single typo there fails silently: the parameter simply never moves and the rig stands still with /// no error to explain why. Encoding the contract in code makes it impossible to get wrong, and lets /// the controller be rebuilt from scratch after any manual experiment. /// /// Placeholder clips are deliberately empty and stored as their own assets rather than reusing a real /// animation, so the master controller depends on no FBX and the slot list stays explicit. A slot no /// set ever fills therefore plays nothing, which reads as an obvious gap instead of a wrong pose. /// /// Safe to re-run: it rewrites the controller in place, keeping the asset's GUID so every Animator /// already pointing at it stays wired. /// public static class PlayerAnimatorControllerBuilder { #region Constants private const string AnimationsFolder = "Assets/GAME/Animations/Arms"; private const string SlotsFolder = AnimationsFolder + "/Slots"; private const string ArmsControllerPath = AnimationsFolder + "/PlayerArms.controller"; /// /// Blend thresholds for the ground tree. They are the gait levels PlayerAnimatorDriver reports — /// 0 idle, 1 walk, 2 run — not speeds in metres per second, so retuning how fast the player moves /// never desynchronises the animation. /// /// RunClipSpeed exists only while walk and run share one authored clip: with the same motion in /// both slots, playing the run entry faster is the only thing that distinguishes sprinting from /// walking. Drop it back to 1 as soon as a real run animation fills the Run slot. /// private const float WalkThreshold = 1f; private const float RunThreshold = 2f; private const float RunClipSpeed = 1.5f; private const string SpeedParam = "Speed"; private const string GroundedParam = "Grounded"; private const string CrouchingParam = "Crouching"; private const string SprintingParam = "Sprinting"; private const string AttackIndexParam = "AttackIndex"; private const string JumpParam = "Jump"; private const string LandParam = "Land"; private const string AttackParam = "Attack"; private const string GrabParam = "Grab"; private const string EquipParam = "Equip"; private const string UnequipParam = "Unequip"; /// /// Every clip slot the controller declares, in the order a reader should meet them. These strings /// are the keys PlayerAnimationSet is queried with — they must match its slot constants exactly. /// private static readonly string[] Slots = { PlayerAnimationSet.SlotIdle, PlayerAnimationSet.SlotWalk, PlayerAnimationSet.SlotRun, PlayerAnimationSet.SlotJumpStart, PlayerAnimationSet.SlotJumpLoop, PlayerAnimationSet.SlotJumpLand, PlayerAnimationSet.SlotGrab, PlayerAnimationSet.SlotEquip, PlayerAnimationSet.SlotUnequip, PlayerAnimationSet.SlotAttackPrefix + "1", }; #endregion #region Menu /// /// Builds (or rebuilds) the first-person arms controller and the placeholder clips it references. /// [MenuItem("Tools/Ashwild/Build Player Arms Controller")] public static void BuildArmsController() { EnsureFolders(); Dictionary placeholders = new Dictionary(); foreach (string slot in Slots) placeholders[slot] = GetOrCreatePlaceholder(slot); AnimatorController controller = GetOrCreateController(ArmsControllerPath); ClearController(controller); AddParameters(controller); BuildLocomotionLayer(controller, placeholders); BuildActionLayer(controller, placeholders); EditorUtility.SetDirty(controller); AssetDatabase.SaveAssets(); AssetDatabase.Refresh(); Debug.Log($"[PlayerAnimatorControllerBuilder] Built {ArmsControllerPath} with {Slots.Length} clip slots. " + "Assign it to the arms Animator, then point PlayerAnimationSetBinder at your Arms_NoItem set.", controller); Selection.activeObject = controller; } #endregion #region Layers /// /// Base layer: the movement the player is always doing. A 1D blend tree covers ground movement so /// idle and run ease into each other instead of snapping, and the jump chain is a straight line /// (start → loop → land) driven by the Grounded flag rather than by timers, so a fall the player /// never jumped into still enters the loop from Any State. /// private static void BuildLocomotionLayer(AnimatorController controller, Dictionary clips) { AnimatorControllerLayer[] layers = controller.layers; layers[0].name = "Locomotion"; controller.layers = layers; AnimatorStateMachine machine = controller.layers[0].stateMachine; BlendTree tree; AnimatorState locomotion = controller.CreateBlendTreeInController("Locomotion", out tree, 0); tree.blendType = BlendTreeType.Simple1D; tree.blendParameter = SpeedParam; tree.useAutomaticThresholds = false; tree.AddChild(clips[PlayerAnimationSet.SlotIdle], 0f); tree.AddChild(clips[PlayerAnimationSet.SlotWalk], WalkThreshold); tree.AddChild(clips[PlayerAnimationSet.SlotRun], RunThreshold); SetChildSpeed(tree, 2, RunClipSpeed); AnimatorState jumpStart = machine.AddState(PlayerAnimationSet.SlotJumpStart); jumpStart.motion = clips[PlayerAnimationSet.SlotJumpStart]; AnimatorState jumpLoop = machine.AddState(PlayerAnimationSet.SlotJumpLoop); jumpLoop.motion = clips[PlayerAnimationSet.SlotJumpLoop]; AnimatorState jumpLand = machine.AddState(PlayerAnimationSet.SlotJumpLand); jumpLand.motion = clips[PlayerAnimationSet.SlotJumpLand]; machine.defaultState = locomotion; AnimatorStateTransition toJump = locomotion.AddTransition(jumpStart); toJump.hasExitTime = false; toJump.duration = 0.05f; toJump.AddCondition(AnimatorConditionMode.If, 0f, JumpParam); AnimatorStateTransition startToLoop = jumpStart.AddTransition(jumpLoop); startToLoop.hasExitTime = true; startToLoop.exitTime = 0.8f; startToLoop.duration = 0.1f; AnimatorStateTransition anyToLoop = machine.AddAnyStateTransition(jumpLoop); anyToLoop.hasExitTime = false; anyToLoop.duration = 0.15f; anyToLoop.canTransitionToSelf = false; anyToLoop.AddCondition(AnimatorConditionMode.IfNot, 0f, GroundedParam); AnimatorStateTransition loopToLand = jumpLoop.AddTransition(jumpLand); loopToLand.hasExitTime = false; loopToLand.duration = 0.1f; loopToLand.AddCondition(AnimatorConditionMode.If, 0f, GroundedParam); AnimatorStateTransition landToLocomotion = jumpLand.AddTransition(locomotion); landToLocomotion.hasExitTime = true; landToLocomotion.exitTime = 0.7f; landToLocomotion.duration = 0.15f; } /// /// Action layer: the one-shots that play *over* whatever the legs are doing. It sits on an empty /// default state at full weight, so it contributes nothing until an action fires and the player /// keeps running normally underneath. Every action returns to that empty state on exit time, /// which is what lets a swing interrupt itself cleanly on the next click. /// private static void BuildActionLayer(AnimatorController controller, Dictionary clips) { controller.AddLayer("Action"); AnimatorControllerLayer[] layers = controller.layers; AnimatorControllerLayer action = layers[1]; action.defaultWeight = 1f; action.blendingMode = AnimatorLayerBlendingMode.Override; controller.layers = layers; AnimatorStateMachine machine = action.stateMachine; AnimatorState none = machine.AddState("None"); machine.defaultState = none; AddOneShot(machine, none, PlayerAnimationSet.SlotGrab, clips, GrabParam); AddOneShot(machine, none, PlayerAnimationSet.SlotEquip, clips, EquipParam); AddOneShot(machine, none, PlayerAnimationSet.SlotUnequip, clips, UnequipParam); AddOneShot(machine, none, PlayerAnimationSet.SlotAttackPrefix + "1", clips, AttackParam); } /// /// Wires one action: entered from Any State on its trigger so it can fire at any moment (and /// re-fire while already playing, which a combo needs), and released back to the empty state on /// exit time so the layer stops contributing as soon as the action is over. /// private static void AddOneShot(AnimatorStateMachine machine, AnimatorState none, string slot, Dictionary clips, string trigger) { AnimatorState state = machine.AddState(slot); state.motion = clips[slot]; AnimatorStateTransition enter = machine.AddAnyStateTransition(state); enter.hasExitTime = false; enter.duration = 0.05f; enter.canTransitionToSelf = true; enter.AddCondition(AnimatorConditionMode.If, 0f, trigger); AnimatorStateTransition exit = state.AddTransition(none); exit.hasExitTime = true; exit.exitTime = 0.9f; exit.duration = 0.1f; } #endregion #region Internal Helpers /// /// Sets one blend-tree child's playback rate. The children array must be reassigned wholesale /// because BlendTree.children hands back a copy — mutating the returned struct in place silently /// does nothing. /// private static void SetChildSpeed(BlendTree tree, int index, float speed) { ChildMotion[] children = tree.children; if (index < 0 || index >= children.Length) return; children[index].timeScale = speed; tree.children = children; } /// /// Declares every parameter PlayerAnimatorDriver writes. Names are the ones the driver defaults /// to, so a freshly added driver works with no inspector edits. /// private static void AddParameters(AnimatorController controller) { controller.AddParameter(SpeedParam, AnimatorControllerParameterType.Float); controller.AddParameter(GroundedParam, AnimatorControllerParameterType.Bool); controller.AddParameter(CrouchingParam, AnimatorControllerParameterType.Bool); controller.AddParameter(SprintingParam, AnimatorControllerParameterType.Bool); controller.AddParameter(AttackIndexParam, AnimatorControllerParameterType.Int); controller.AddParameter(JumpParam, AnimatorControllerParameterType.Trigger); controller.AddParameter(LandParam, AnimatorControllerParameterType.Trigger); controller.AddParameter(AttackParam, AnimatorControllerParameterType.Trigger); controller.AddParameter(GrabParam, AnimatorControllerParameterType.Trigger); controller.AddParameter(EquipParam, AnimatorControllerParameterType.Trigger); controller.AddParameter(UnequipParam, AnimatorControllerParameterType.Trigger); SetDefaultBool(controller, GroundedParam, true); } /// /// Seeds a bool's authored default so the rig starts in a sane pose on the very first frame, /// before the driver has pushed anything — a player spawning "not grounded" would otherwise flash /// the fall loop. /// private static void SetDefaultBool(AnimatorController controller, string paramName, bool value) { AnimatorControllerParameter[] parameters = controller.parameters; for (int i = 0; i < parameters.Length; i++) { if (parameters[i].name != paramName) continue; parameters[i].defaultBool = value; break; } controller.parameters = parameters; } /// /// Empties an existing controller so a rebuild never stacks duplicate states or parameters on top /// of the previous run. The asset itself is kept so its GUID — and every Animator reference to /// it — survives. /// private static void ClearController(AnimatorController controller) { for (int i = controller.layers.Length - 1; i > 0; i--) controller.RemoveLayer(i); while (controller.parameters.Length > 0) controller.RemoveParameter(0); AnimatorStateMachine machine = controller.layers[0].stateMachine; for (int i = machine.states.Length - 1; i >= 0; i--) machine.RemoveState(machine.states[i].state); for (int i = machine.anyStateTransitions.Length - 1; i >= 0; i--) machine.RemoveAnyStateTransition(machine.anyStateTransitions[i]); } /// /// Loads the controller at a path, creating it on first run. /// private static AnimatorController GetOrCreateController(string path) { AnimatorController existing = AssetDatabase.LoadAssetAtPath(path); return existing != null ? existing : AnimatorController.CreateAnimatorControllerAtPath(path); } /// /// Loads (or creates) the empty clip that stands in for a slot. Its name is the slot key the /// binder matches sets against, which is the whole reason these exist as named assets. /// private static AnimationClip GetOrCreatePlaceholder(string slot) { string path = $"{SlotsFolder}/{slot}.anim"; AnimationClip existing = AssetDatabase.LoadAssetAtPath(path); if (existing != null) return existing; AnimationClip clip = new AnimationClip { name = slot }; AssetDatabase.CreateAsset(clip, path); return clip; } /// /// Makes sure the target folders exist before anything is written into them. /// private static void EnsureFolders() { if (!AssetDatabase.IsValidFolder(AnimationsFolder)) { Debug.LogError($"[PlayerAnimatorControllerBuilder] '{AnimationsFolder}' does not exist — create it first."); return; } if (!AssetDatabase.IsValidFolder(SlotsFolder)) AssetDatabase.CreateFolder(AnimationsFolder, "Slots"); } #endregion } }