using System.Collections.Generic; using UnityEngine; using UnityEngine.Animations; using UnityEngine.Playables; #if ENABLE_INPUT_SYSTEM using UnityEngine.InputSystem; #endif /// /// Sits next to the and holds a list of s /// plus the active index. The pose system owns all character animation — the Animator is left empty — /// so it drives locomotion itself: standing still (or airborne / on a wall / on the ceiling) holds the /// active pose's idle clip, walking on the ground plays that pose's optional walk clip, and sprinting /// on the ground plays its optional run clip. Either locomotion clip is optional; when a pose leaves /// one null, the idle pose is simply held in that state. /// /// 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 selection is held continuously, so cycling or the /// sprint reset shows up immediately instead of only once the player comes to a stop. /// /// Clips are played through a tiny (a two-input /// on an ) that drives the /// Animator directly. Switching clip — between poses, or between idle/walk/run within a 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] // Run after PlayerCharacterController (default order 0) so the ceiling / sprint state polled below is // this frame's, and the ceiling offset resolves without a one-frame seam. [DefaultExecutionOrder(100)] public partial class PlayerPoseController : MonoBehaviour { [Header("Poses")] [Tooltip("Selectable poses. Index 0 is the default pose that sprinting resets to.")] [SerializeField] private List poses = new(); [Tooltip("Index the selection resets to while sprinting in any state (the default/standard pose).")] [Min(0)] [SerializeField] private int defaultPoseIndex = 0; [Header("Input")] #if ENABLE_INPUT_SYSTEM [Tooltip("Key that cycles to the next pose (wraps at the end of the list).")] [SerializeField] private Key cycleKey = Key.C; #else [Tooltip("Key that cycles to the next pose (wraps at the end of the list).")] [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; [Tooltip("Apply Mecanim foot IK to the played humanoid clips. Off (default) plays each pose exactly as " + "authored. On bends the knees to plant the feet on the IK goal, which makes a straight-knee idle " + "render visibly bent even when the clip's Lower Leg Stretch muscle is fully straight.")] [SerializeField] private bool applyFootIK = false; 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 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 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 // doesn't key a bone (an empty clip like the T-pose, or a partial one) would otherwise leave that // bone frozen on the previous animation. Restoring this before each pose start makes every pose // define the full body. The controller's model-offset transform (meshOffsetTarget) is explicitly // dropped from this set in CaptureRestPose — with the humanoid rig the Animator sits on that node, // so it is the root of this subtree, and resetting it would wipe the wall-lean / ceiling offset. private Transform[] _restBones; private Vector3[] _restPos; private Quaternion[] _restRot; private Vector3[] _restScale; /// Index of the currently selected pose. public int CurrentIndex => _currentIndex; void Awake() { _player = GetComponent(); _animator = _player ? _player.GetComponentInChildren() : null; _graphName = $"{name}-Pose"; if (_animator) CaptureRestPose(); else Debug.LogWarning("[PlayerPoseController] No Animator on the PlayerCharacterController; poses cannot play.", this); } void Update() { if (CycleKeyPressed()) NextPose(); // Sprinting in any state (ground, air, wall, ceiling) snaps the selection back to the default // pose. Held continuously below, so the reset shows up immediately, not only once back on the floor. if (_player.IsSprinting) _currentIndex = WrapIndex(defaultPoseIndex); PlayerPose pose = ActivePose(); // Pick the clip for the current locomotion state. Standing still, airborne, on a wall or on the // ceiling holds the idle pose; moving on the ground plays the run clip while sprinting or the // walk clip otherwise. Both locomotion clips are optional — a null one falls back to the idle // pose, so an unset walk clip keeps the held-pose behaviour. PlayPose crossfades on any change // and hands the rig back only when there is no clip at all (empty list / null idle clip). AnimationClip clip = pose ? pose.Clip : null; float speed = pose ? pose.ClipSpeed : 1f; if (pose && _player.IsMovingOnGround) { AnimationClip locomotion = _player.IsSprinting ? pose.RunClip : pose.WalkClip; if (locomotion) { clip = locomotion; speed = _player.IsSprinting ? pose.RunClipSpeed : pose.WalkClipSpeed; } } PlayPose(clip, speed); // 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() { // 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); } } /// Advances to the next pose, wrapping at the end of the list. public void NextPose() => SetPoseIndex(_currentIndex + 1); /// Steps back to the previous pose, wrapping at the start of the list. public void PreviousPose() => SetPoseIndex(_currentIndex - 1); /// Selects a pose by index, wrapping into range so cycling never goes out of bounds. public void SetPoseIndex(int index) => _currentIndex = WrapIndex(index); private int WrapIndex(int index) { int count = poses.Count; if (count == 0) return 0; return ((index % count) + count) % count; } private PlayerPose ActivePose() { if (poses.Count == 0) return null; return poses[Mathf.Clamp(_currentIndex, 0, poses.Count - 1)]; } private bool CycleKeyPressed() { #if ENABLE_INPUT_SYSTEM var keyboard = Keyboard.current; return keyboard != null && keyboard[cycleKey].wasPressedThisFrame; #else return Input.GetKeyDown(cycleKey); #endif } /// /// 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, applies to the target playback, 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, float speed) { if (!clip || !_animator) { ReleasePose(); return; } EnsureGraph(); if (!(_posePlaying && _toClip == clip)) BeginTransition(clip); AdvanceBlend(Time.deltaTime); // Set every frame (not just on clip change) so tuning a pose's speed slider live in the inspector // takes effect immediately and re-selecting the same clip keeps its speed. The outgoing pose on // input 0 keeps the speed it carried, so a crossfade fades it out at its own pace. if (_toPlayable.IsValid()) PlayableExtensions.SetSpeed(_toPlayable, speed); // 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(); if (!_graph.IsPlaying()) _graph.Play(); _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); // Off by default: foot IK plants the foot on its goal and bends the knee to reach it, so a straight-knee // pose renders bent (SampleAnimation, which the authoring tools check against, never applies it). The // outgoing pose on input 0 is a former _toPlayable, so it already carries this setting. _toPlayable.SetApplyFootIK(applyFootIK); _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 /// at rest (the paintman rig's rest pose is its T-pose). /// private void CaptureRestPose() { _restBones = _animator.transform.GetComponentsInChildren(true); int n = _restBones.Length; _restPos = new Vector3[n]; _restRot = new Quaternion[n]; _restScale = new Vector3[n]; for (int i = 0; i < n; i++) { Transform t = _restBones[i]; _restPos[i] = t.localPosition; _restRot[i] = t.localRotation; _restScale[i] = t.localScale; } // Drop the model-offset transform from the reset set so the per-frame ResetToRestPose never // touches it: the wall-lean / ceiling offset (PlayerCharacterController.SetMeshLean) is its sole // writer. With the humanoid rig the Animator shares this node, putting it in the captured subtree; // resetting it each frame is exactly what was wiping the offsets. Nulling the slot makes // ResetToRestPose's null-guard skip it. _player is resolved (and meshOffsetTarget too, since the // controller's Awake runs first at its lower execution order) before this runs. Transform offsetTarget = _player ? _player.MeshOffsetTarget : null; if (offsetTarget) { int idx = System.Array.IndexOf(_restBones, offsetTarget); if (idx >= 0) _restBones[idx] = null; } } /// Restores the captured rest pose so an empty or partial pose clip fully defines the body. private void ResetToRestPose() { if (_restBones == null) return; for (int i = 0; i < _restBones.Length; i++) { Transform t = _restBones[i]; if (!t) continue; t.localPosition = _restPos[i]; t.localRotation = _restRot[i]; t.localScale = _restScale[i]; } } /// Destroys the graph so the Animator Controller (run/idle) drives the rig again. private void ReleasePose() { if (!_posePlaying) return; if (_graph.IsValid()) _graph.Destroy(); _graph = default; _output = default; _mixer = default; _fromPlayable = default; _toPlayable = default; _toClip = null; _blend = 0f; _blending = false; _posePlaying = false; } }