using UnityEngine; namespace Ashwild.Player { /// /// 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. /// [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 /// /// 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. /// 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 /// /// Caches parameter hashes (0 for blank names, which are then skipped) and defaults the /// orientation reference so local move direction works without extra wiring. /// 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; } /// /// 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. /// 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); } /// /// Unsubscribes — mirrors OnEnable exactly. /// 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; } /// /// 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. /// 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 /// /// Caches the planar (horizontal) velocity that Update turns into blend-tree floats. /// 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); /// /// 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. /// private void HandleSprintChanged(bool sprinting) { isSprinting = sprinting; SetBool(sprintingId, sprinting); } private void HandleJumped() => SetTrigger(jumpId); /// /// 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. /// private void HandleAttackSwung(int variant) { SetInt(attackIndexId, variant + 1); SetTrigger(attackId); } /// /// A landing stun begins on a hard touchdown — the cue for a landing animation. /// 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 /// /// 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. /// private float ComputeLocomotionLevel() { if (planarVelocity.magnitude < moveThreshold) return IdleLevel; return isSprinting ? RunLevel : WalkLevel; } /// /// Hashes a parameter name, returning 0 for a blank name so the setters can skip it. /// 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 } }