Merge remote-tracking branch 'origin/feat/inport-props' into feat/craft-discovery
# 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
This commit is contained in:
@@ -0,0 +1,344 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user