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
}
}