78bfdf2828
# Conflicts: # Assets/External/Animated PBR Chest Demo/Materials/WoodChest.mat # Packages/com.distantlands.cozy.core/Content/Integration/Import for BiRP.unitypackage.meta # Packages/com.distantlands.cozy.core/Content/Integration/Import for HDRP.unitypackage.meta # Packages/com.distantlands.cozy.core/Content/Integration/Import for URP.unitypackage.meta
345 lines
16 KiB
C#
345 lines
16 KiB
C#
using System.Collections.Generic;
|
|
using UnityEditor;
|
|
using UnityEditor.Animations;
|
|
using UnityEngine;
|
|
using Ashwild.Player;
|
|
|
|
namespace Ashwild.EditorTools
|
|
{
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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";
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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";
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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
|
|
|
|
/// <summary>
|
|
/// Builds (or rebuilds) the first-person arms controller and the placeholder clips it references.
|
|
/// </summary>
|
|
[MenuItem("Tools/Ashwild/Build Player Arms Controller")]
|
|
public static void BuildArmsController()
|
|
{
|
|
EnsureFolders();
|
|
|
|
Dictionary<string, AnimationClip> placeholders = new Dictionary<string, AnimationClip>();
|
|
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
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private static void BuildLocomotionLayer(AnimatorController controller, Dictionary<string, AnimationClip> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private static void BuildActionLayer(AnimatorController controller, Dictionary<string, AnimationClip> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private static void AddOneShot(AnimatorStateMachine machine, AnimatorState none, string slot,
|
|
Dictionary<string, AnimationClip> 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
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Declares every parameter PlayerAnimatorDriver writes. Names are the ones the driver defaults
|
|
/// to, so a freshly added driver works with no inspector edits.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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]);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Loads the controller at a path, creating it on first run.
|
|
/// </summary>
|
|
private static AnimatorController GetOrCreateController(string path)
|
|
{
|
|
AnimatorController existing = AssetDatabase.LoadAssetAtPath<AnimatorController>(path);
|
|
return existing != null ? existing : AnimatorController.CreateAnimatorControllerAtPath(path);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private static AnimationClip GetOrCreatePlaceholder(string slot)
|
|
{
|
|
string path = $"{SlotsFolder}/{slot}.anim";
|
|
AnimationClip existing = AssetDatabase.LoadAssetAtPath<AnimationClip>(path);
|
|
if (existing != null) return existing;
|
|
|
|
AnimationClip clip = new AnimationClip { name = slot };
|
|
AssetDatabase.CreateAsset(clip, path);
|
|
return clip;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Makes sure the target folders exist before anything is written into them.
|
|
/// </summary>
|
|
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
|
|
}
|
|
}
|