Files
Emberwild/Assets/GAME/Script/Player/Animation/PlayerAnimatorDriver.cs
Mathew 78bfdf2828 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
2026-07-25 19:42:26 +02:00

265 lines
11 KiB
C#

using UnityEngine;
namespace Ashwild.Player
{
/// <summary>
/// Drives a humanoid Animator from the local player's PlayerEvents bus so a visible rig
/// reflects what the player is doing. It is a pure consumer of the bus — it never simulates
/// anything itself — which lets the exact same component drive two independent rigs from one
/// shared source of truth: the owner's first-person arms (seen only by the owner, never
/// networked) and his third-person body (replicated to other players by a sibling FishNet
/// NetworkAnimator). Because both rigs read the same events at the same frame, the arms the
/// owner sees and the body everyone else sees stay in lockstep by construction.
/// This is owner-only local simulation: add every instance to
/// PlayerNetworkController.ownerOnlyBehaviours so remote puppets never run it — a remote body
/// is animated by its replicated NetworkAnimator instead. Every parameter name is optional;
/// leave a field blank to skip that parameter, so a minimal arms rig can ignore the full
/// locomotion blend that the third-person body uses.
/// </summary>
[DisallowMultipleComponent]
public class PlayerAnimatorDriver : MonoBehaviour
{
#region Serialized Fields
[Header("Target")]
[SerializeField] private Animator animator;
[Tooltip("Transform whose facing defines local move direction. Defaults to the Animator's transform when left empty.")]
[SerializeField] private Transform orientation;
[Header("Float parameters (leave blank to skip)")]
[SerializeField] private string speedParam = "Speed";
[SerializeField] private string moveXParam = "MoveX";
[SerializeField] private string moveYParam = "MoveY";
[Tooltip("Planar speed (m/s) above which the player counts as moving rather than idle.")]
[SerializeField] private float moveThreshold = 0.15f;
[Tooltip("Damping applied to float parameters for smooth blends.")]
[SerializeField] private float floatDampTime = 0.1f;
[Header("Bool parameters (leave blank to skip)")]
[SerializeField] private string groundedParam = "Grounded";
[SerializeField] private string crouchingParam = "Crouching";
[SerializeField] private string sprintingParam = "Sprinting";
[Header("Int parameters (leave blank to skip)")]
[Tooltip("Selects which swing variant the attack state plays — 1-based, matching the Attack1/Attack2… slots.")]
[SerializeField] private string attackIndexParam = "AttackIndex";
[Header("Trigger parameters (leave blank to skip)")]
[SerializeField] private string jumpParam = "Jump";
[SerializeField] private string attackParam = "Attack";
[SerializeField] private string landParam = "Land";
[SerializeField] private string grabParam = "Grab";
[SerializeField] private string equipParam = "Equip";
[SerializeField] private string unequipParam = "Unequip";
#endregion
#region Locomotion Levels
/// <summary>
/// The discrete gait the Speed parameter reports, and the thresholds the blend tree is built on.
/// Deliberately not a measured velocity: a metre-per-second reading makes the animation hostage to
/// movement tuning (retune sprintSpeed and every rig silently blends wrong), and it lies whenever
/// the player's actual speed drops without his intent changing — pushing uphill, against a wall,
/// through a slowing effect — where a sprint would visibly decay into a walk. Reporting intent
/// instead keeps the gait stable, and the parameter damping still eases each change.
/// </summary>
private const float IdleLevel = 0f;
private const float WalkLevel = 1f;
private const float RunLevel = 2f;
#endregion
#region State
private Vector3 planarVelocity;
private bool isSprinting;
private int speedId, moveXId, moveYId;
private int groundedId, crouchingId, sprintingId;
private int attackIndexId;
private int jumpId, attackId, landId;
private int grabId, equipId, unequipId;
#endregion
#region Unity Lifecycle
/// <summary>
/// Caches parameter hashes (0 for blank names, which are then skipped) and defaults the
/// orientation reference so local move direction works without extra wiring.
/// </summary>
private void Awake()
{
speedId = Hash(speedParam);
moveXId = Hash(moveXParam);
moveYId = Hash(moveYParam);
groundedId = Hash(groundedParam);
crouchingId = Hash(crouchingParam);
sprintingId = Hash(sprintingParam);
attackIndexId = Hash(attackIndexParam);
jumpId = Hash(jumpParam);
attackId = Hash(attackParam);
landId = Hash(landParam);
grabId = Hash(grabParam);
equipId = Hash(equipParam);
unequipId = Hash(unequipParam);
if (orientation == null && animator != null)
orientation = animator.transform;
}
/// <summary>
/// Subscribes to the locomotion/tool events that map onto animation, and seeds the bool
/// parameters from the bus's current state so a freshly enabled rig starts in the right pose.
/// </summary>
private void OnEnable()
{
PlayerEvents.VelocityChanged += HandleVelocityChanged;
PlayerEvents.GroundedChanged += HandleGroundedChanged;
PlayerEvents.CrouchChanged += HandleCrouchChanged;
PlayerEvents.SprintChanged += HandleSprintChanged;
PlayerEvents.Jumped += HandleJumped;
PlayerEvents.AttackSwung += HandleAttackSwung;
PlayerEvents.LandingStunStarted += HandleLandingStunStarted;
PlayerEvents.GrabPerformed += HandleGrabPerformed;
PlayerEvents.EquipStarted += HandleEquipStarted;
PlayerEvents.UnequipStarted += HandleUnequipStarted;
isSprinting = PlayerEvents.IsSprinting;
SetBool(groundedId, PlayerEvents.IsGrounded);
SetBool(crouchingId, PlayerEvents.IsCrouching);
SetBool(sprintingId, isSprinting);
}
/// <summary>
/// Unsubscribes — mirrors OnEnable exactly.
/// </summary>
private void OnDisable()
{
PlayerEvents.VelocityChanged -= HandleVelocityChanged;
PlayerEvents.GroundedChanged -= HandleGroundedChanged;
PlayerEvents.CrouchChanged -= HandleCrouchChanged;
PlayerEvents.SprintChanged -= HandleSprintChanged;
PlayerEvents.Jumped -= HandleJumped;
PlayerEvents.AttackSwung -= HandleAttackSwung;
PlayerEvents.LandingStunStarted -= HandleLandingStunStarted;
PlayerEvents.GrabPerformed -= HandleGrabPerformed;
PlayerEvents.EquipStarted -= HandleEquipStarted;
PlayerEvents.UnequipStarted -= HandleUnequipStarted;
}
/// <summary>
/// Pushes the smoothed movement floats every frame. The gait itself is a step function, but the
/// damping on the way to the Animator is what turns idle → walk → run into a blend instead of a
/// snap, so the discrete parameter still reads as continuous motion.
/// </summary>
private void Update()
{
if (animator == null) return;
float level = ComputeLocomotionLevel();
SetFloat(speedId, level);
if (moveXId != 0 || moveYId != 0)
{
Vector3 direction = planarVelocity.sqrMagnitude > 0.0001f ? planarVelocity.normalized : Vector3.zero;
Vector3 local = orientation != null
? orientation.InverseTransformDirection(direction)
: direction;
SetFloat(moveXId, local.x * level);
SetFloat(moveYId, local.z * level);
}
}
#endregion
#region Event Handlers
/// <summary>
/// Caches the planar (horizontal) velocity that Update turns into blend-tree floats.
/// </summary>
private void HandleVelocityChanged(Vector3 velocity)
{
planarVelocity = new Vector3(velocity.x, 0f, velocity.z);
}
private void HandleGroundedChanged(bool grounded, float impactSpeed) => SetBool(groundedId, grounded);
private void HandleCrouchChanged(bool crouching) => SetBool(crouchingId, crouching);
/// <summary>
/// Caches the sprint intent as well as setting the bool, because the gait reported through Speed
/// is chosen from the player's intent rather than from how fast he happens to be travelling.
/// </summary>
private void HandleSprintChanged(bool sprinting)
{
isSprinting = sprinting;
SetBool(sprintingId, sprinting);
}
private void HandleJumped() => SetTrigger(jumpId);
/// <summary>
/// Selects the combo variant before firing the swing, so the attack state reads an index that is
/// already correct on the frame it is entered. The parameter is 1-based to match the Attack1/
/// Attack2… clip slots a designer sees in the controller.
/// </summary>
private void HandleAttackSwung(int variant)
{
SetInt(attackIndexId, variant + 1);
SetTrigger(attackId);
}
/// <summary>
/// A landing stun begins on a hard touchdown — the cue for a landing animation.
/// </summary>
private void HandleLandingStunStarted(float duration) => SetTrigger(landId);
private void HandleGrabPerformed() => SetTrigger(grabId);
private void HandleEquipStarted() => SetTrigger(equipId);
private void HandleUnequipStarted() => SetTrigger(unequipId);
#endregion
#region Internal Helpers
/// <summary>
/// Picks the gait to report. Velocity is consulted only to answer "is he moving at all" — the
/// distinction between walking and running comes from the sprint intent, never from a speed
/// reading, so the two never disagree. Crouching is not a level of its own: it is a separate bool
/// so a crouch tree can reuse the same idle/walk distinction underneath it.
/// </summary>
private float ComputeLocomotionLevel()
{
if (planarVelocity.magnitude < moveThreshold) return IdleLevel;
return isSprinting ? RunLevel : WalkLevel;
}
/// <summary>
/// Hashes a parameter name, returning 0 for a blank name so the setters can skip it.
/// </summary>
private int Hash(string paramName) => string.IsNullOrEmpty(paramName) ? 0 : Animator.StringToHash(paramName);
private void SetFloat(int id, float value)
{
if (id != 0) animator.SetFloat(id, value, floatDampTime, Time.deltaTime);
}
private void SetBool(int id, bool value)
{
if (id != 0 && animator != null) animator.SetBool(id, value);
}
private void SetInt(int id, int value)
{
if (id != 0 && animator != null) animator.SetInteger(id, value);
}
private void SetTrigger(int id)
{
if (id != 0 && animator != null) animator.SetTrigger(id);
}
#endregion
}
}