diff --git a/Assets/Scripts/PlayerPoseController.cs b/Assets/Scripts/PlayerPoseController.cs index 25df199..d0cfe55 100644 --- a/Assets/Scripts/PlayerPoseController.cs +++ b/Assets/Scripts/PlayerPoseController.cs @@ -13,12 +13,13 @@ using UnityEngine.InputSystem; /// immediately instead of only once the player comes to a stop. One key cycles to the next pose /// (wrapping), and sprinting in any state (ground, air, wall, ceiling) snaps the selection back to the default pose. /// -/// The pose clip is played through a tiny (an -/// on an ) that overrides +/// The pose clip is played through a tiny (a two-input +/// on an ) that overrides /// the Animator Controller's run/idle animation for as long as a pose is selected (it only hands the -/// rig back when the list is empty / the clip is null). The per-pose ceiling y-offset is routed -/// through the controller (the single writer of the model-offset transform) so it composes with the -/// wall-lean instead of fighting it. +/// rig back when the list is empty / the clip is null). Switching pose crossfades the outgoing and +/// incoming clips over instead of snapping. The per-pose ceiling +/// y-offset is eased toward its target and routed through the controller (the single writer of the +/// model-offset transform) so it composes with the wall-lean instead of fighting it. /// [RequireComponent(typeof(PlayerCharacterController))] [DisallowMultipleComponent] @@ -44,18 +45,36 @@ public partial class PlayerPoseController : MonoBehaviour [SerializeField] private KeyCode cycleKey = KeyCode.C; #endif + [Header("Transitions")] + [Tooltip("Seconds to crossfade from the previous pose to a newly selected one. 0 = instant snap.")] + [Min(0f)] + [SerializeField] private float poseBlendDuration = 0.2f; + + [Tooltip("Approach speed (local units/sec) for easing the ceiling y-offset toward its target. 0 = instant.")] + [Min(0f)] + [SerializeField] private float ceilingOffsetSpeed = 4f; + private PlayerCharacterController _player; private Animator _animator; private string _graphName; // cached so building the graph allocates no string from the Update path private int _currentIndex; - // Playables: a held clip that overrides the Animator Controller while idle. + // Playables: a 2-input AnimationMixerPlayable that overrides the Animator Controller. Input 1 holds + // the current ("to") pose; input 0 holds the outgoing ("from") pose during a crossfade and is empty + // once the blend completes. Blending both inputs over a per-frame rest baseline (see below) makes + // poses interpolate instead of snapping; an empty/partial "to" clip still resolves to rest. private PlayableGraph _graph; private AnimationPlayableOutput _output; - private AnimationClipPlayable _clipPlayable; - private AnimationClip _activeClip; + private AnimationMixerPlayable _mixer; + private AnimationClipPlayable _fromPlayable; + private AnimationClipPlayable _toPlayable; + private AnimationClip _toClip; // the clip currently targeted (held on input 1) private bool _posePlaying; + private float _blend; // 0..1 weight of the "to" input; 1 = fully on the target pose + private bool _blending; + + private float _ceilingOffset; // eased local-up offset currently fed to the controller // Rest pose snapshot of the whole Animator subtree, taken in Awake before the Animator first // evaluates. An AnimationClipPlayable only writes the bones its curves cover, so a clip that @@ -105,10 +124,14 @@ public partial class PlayerPoseController : MonoBehaviour // the bound-graph state (both only change here). _player.AnimatorOverridden = _posePlaying; - // Ceiling y-offset: only while stuck to the ceiling, routed through the controller so it - // shares the wall-lean's single write of the model transform. - float ceilingOffset = (pose && _player.IsOnCeiling) ? pose.CeilingYOffset : 0f; - _player.SetPoseLocalYOffset(ceilingOffset); + // Ceiling y-offset: only while stuck to the ceiling. Ease toward the target instead of + // snapping, so entering and leaving the ceiling (and switching ceiling poses) glides. Routed + // through the controller so it shares the wall-lean's single write of the model transform. + float ceilingTarget = (pose && _player.IsOnCeiling) ? pose.CeilingYOffset : 0f; + _ceilingOffset = ceilingOffsetSpeed > 0f + ? Mathf.MoveTowards(_ceilingOffset, ceilingTarget, ceilingOffsetSpeed * Time.deltaTime) + : ceilingTarget; + _player.SetPoseLocalYOffset(_ceilingOffset); } void OnDisable() @@ -116,6 +139,7 @@ public partial class PlayerPoseController : MonoBehaviour // Hand the rig back to the Animator Controller and never leak a graph; rebuilt lazily on the // next idle frame. Also clear any ceiling offset we were holding. ReleasePose(); + _ceilingOffset = 0f; if (_player) { _player.SetPoseLocalYOffset(0f); @@ -156,9 +180,11 @@ public partial class PlayerPoseController : MonoBehaviour } /// - /// Plays and holds the given clip through the Playables graph, overriding the Animator - /// Controller. No-op while the same clip is already held, so idle re-apply never restarts it. - /// A null clip (or no animator) releases back to the controller. + /// Plays and holds the given clip, overriding the Animator Controller. Switching to a different + /// clip crossfades from the current pose over (the first pose, or a + /// zero duration, appears instantly). Called every frame: it starts a transition on a clip change, + /// advances any in-flight blend, and re-snaps un-keyed bones to rest before the graph evaluates. A + /// null clip (or no animator) releases back to the controller. /// private void PlayPose(AnimationClip clip) { @@ -167,32 +193,104 @@ public partial class PlayerPoseController : MonoBehaviour ReleasePose(); return; } - if (_posePlaying && _activeClip == clip) - return; - if (!_graph.IsValid()) - { - _graph = PlayableGraph.Create(_graphName); - _graph.SetTimeUpdateMode(DirectorUpdateMode.GameTime); - _output = AnimationPlayableOutput.Create(_graph, "Pose", _animator); - } - if (_clipPlayable.IsValid()) - _graph.DestroyPlayable(_clipPlayable); + EnsureGraph(); - // Snap un-keyed bones to rest first, so a partial or empty clip (e.g. the T-pose) defines the - // whole body instead of inheriting the previous animation's pose. The clip then overrides the - // bones it does key when the graph evaluates later this frame. + if (!(_posePlaying && _toClip == clip)) + BeginTransition(clip); + + AdvanceBlend(Time.deltaTime); + + // Snap un-keyed bones to rest every frame, so each mixer input blends relative to rest: a + // partial or empty clip (e.g. the T-pose) defines the whole body, and a crossfade interpolates + // both poses against the same baseline. The graph overrides the keyed bones, weighted, when it + // evaluates later this frame. ResetToRestPose(); - _clipPlayable = AnimationClipPlayable.Create(_graph, clip); - _output.SetSourcePlayable(_clipPlayable); if (!_graph.IsPlaying()) _graph.Play(); - _activeClip = clip; _posePlaying = true; } + /// Builds the graph (a 2-input mixer feeding the Animator output) lazily if it isn't alive yet. + private void EnsureGraph() + { + if (_graph.IsValid()) + return; + _graph = PlayableGraph.Create(_graphName); + _graph.SetTimeUpdateMode(DirectorUpdateMode.GameTime); + _mixer = AnimationMixerPlayable.Create(_graph, 2); + _output = AnimationPlayableOutput.Create(_graph, "Pose", _animator); + _output.SetSourcePlayable(_mixer); + } + + /// + /// Starts a crossfade to : the in-flight target becomes the outgoing pose on + /// input 0 (carrying its playback time), the new clip goes on input 1, and the blend ramps up from + /// 0. The first pose (or a zero duration) snaps in at full weight with no fade-from-nothing. + /// + private void BeginTransition(AnimationClip clip) + { + bool hadPose = _posePlaying && _toPlayable.IsValid(); + + // Drop the previous outgoing pose (already faded out, or none) before reusing input 0. + if (_fromPlayable.IsValid()) + { + _mixer.DisconnectInput(0); + _graph.DestroyPlayable(_fromPlayable); + _fromPlayable = default; + } + + if (hadPose) + { + // Move the current target from input 1 to input 0 so it becomes the outgoing pose without a + // recreate (its animation time carries over, no jump). + _mixer.DisconnectInput(1); + _fromPlayable = _toPlayable; + _mixer.ConnectInput(0, _fromPlayable, 0); + } + + _toPlayable = AnimationClipPlayable.Create(_graph, clip); + _mixer.ConnectInput(1, _toPlayable, 0); + _toClip = clip; + + _blend = (hadPose && poseBlendDuration > 0f) ? 0f : 1f; + _blending = _blend < 1f; + ApplyBlendWeights(); + } + + /// Advances an in-flight crossfade and drops the outgoing pose once it has fully faded. + private void AdvanceBlend(float dt) + { + if (!_blending) + return; + _blend = poseBlendDuration > 0f ? Mathf.MoveTowards(_blend, 1f, dt / poseBlendDuration) : 1f; + if (_blend >= 1f) + { + _blend = 1f; + _blending = false; + if (_fromPlayable.IsValid()) + { + _mixer.DisconnectInput(0); + _graph.DestroyPlayable(_fromPlayable); + _fromPlayable = default; + } + } + ApplyBlendWeights(); + } + + /// Pushes the current blend onto the mixer inputs (smoothstepped while a "from" pose exists). + private void ApplyBlendWeights() + { + if (!_mixer.IsValid()) + return; + bool hasFrom = _fromPlayable.IsValid(); + float w = hasFrom ? Mathf.SmoothStep(0f, 1f, _blend) : 1f; + _mixer.SetInputWeight(0, hasFrom ? 1f - w : 0f); + _mixer.SetInputWeight(1, _toPlayable.IsValid() ? w : 0f); + } + /// /// Snapshots every transform a pose clip can address (the Animator's whole subtree) in its /// authored rest pose. Taken in Awake before the Animator first evaluates, so it captures the rig @@ -239,8 +337,12 @@ public partial class PlayerPoseController : MonoBehaviour _graph.Destroy(); _graph = default; _output = default; - _clipPlayable = default; - _activeClip = null; + _mixer = default; + _fromPlayable = default; + _toPlayable = default; + _toClip = null; + _blend = 0f; + _blending = false; _posePlaying = false; } }