New PlayerPose ScriptableObject (AnimationClip + ceiling Y-offset) and PlayerPoseController that drives poses via a PlayableGraph, overriding the Animator controller. Poses are held continuously (idle, moving, on wall/ ceiling) so cycle and sprint changes show immediately. Sprinting in any state resets to pose 0. Non-keyed bones fall back to a captured rest pose so empty/partial clips apply fully. Ceiling Y-offset is folded into the controller's single mesh-offset writer. PlayerCharacterController gains IsIdle / IsSprinting / IsOnCeiling / AnimatorOverridden accessors and SetPoseLocalYOffset; SetAnimatorRunning is guarded while a pose owns the Animator. Removed obsolete Paintman.controller. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
247 lines
9.7 KiB
C#
247 lines
9.7 KiB
C#
using System.Collections.Generic;
|
|
using UnityEngine;
|
|
using UnityEngine.Animations;
|
|
using UnityEngine.Playables;
|
|
#if ENABLE_INPUT_SYSTEM
|
|
using UnityEngine.InputSystem;
|
|
#endif
|
|
|
|
/// <summary>
|
|
/// Sits next to the <see cref="PlayerCharacterController"/> and holds a list of <see cref="PlayerPose"/>s
|
|
/// plus the active index. The active pose is held continuously — while idle, walking, and sprinting
|
|
/// alike — so cycling to the next pose, or the sprint reset to the default pose, shows up
|
|
/// 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 <see cref="PlayableGraph"/> (an
|
|
/// <see cref="AnimationClipPlayable"/> on an <see cref="AnimationPlayableOutput"/>) 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.
|
|
/// </summary>
|
|
[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<PlayerPose> 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
|
|
|
|
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.
|
|
private PlayableGraph _graph;
|
|
private AnimationPlayableOutput _output;
|
|
private AnimationClipPlayable _clipPlayable;
|
|
private AnimationClip _activeClip;
|
|
private bool _posePlaying;
|
|
|
|
// 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. meshOffsetTarget is an ancestor of the Animator, so it is never in this
|
|
// set and the controller's lean/ceiling offset stays untouched.
|
|
private Transform[] _restBones;
|
|
private Vector3[] _restPos;
|
|
private Quaternion[] _restRot;
|
|
private Vector3[] _restScale;
|
|
|
|
/// <summary>Index of the currently selected pose.</summary>
|
|
public int CurrentIndex => _currentIndex;
|
|
|
|
void Awake()
|
|
{
|
|
_player = GetComponent<PlayerCharacterController>();
|
|
_animator = _player ? _player.animator : 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();
|
|
|
|
// Hold the pose continuously — idle, walking, and sprinting alike — so a selection change
|
|
// (cycling, or the sprint reset to the default pose above) shows immediately instead of only
|
|
// once the player stops. PlayPose hands the rig back to the controller only when there is no
|
|
// clip to play (empty list / null clip).
|
|
PlayPose(pose ? pose.Clip : null);
|
|
|
|
// Tell the controller the pose graph owns the Animator output so it stops poking the run-bool
|
|
// (moot while overridden, and it would warn every frame). Read next frame, when this matches
|
|
// 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);
|
|
}
|
|
|
|
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();
|
|
if (_player)
|
|
{
|
|
_player.SetPoseLocalYOffset(0f);
|
|
_player.AnimatorOverridden = false;
|
|
}
|
|
}
|
|
|
|
/// <summary>Advances to the next pose, wrapping at the end of the list.</summary>
|
|
public void NextPose() => SetPoseIndex(_currentIndex + 1);
|
|
|
|
/// <summary>Steps back to the previous pose, wrapping at the start of the list.</summary>
|
|
public void PreviousPose() => SetPoseIndex(_currentIndex - 1);
|
|
|
|
/// <summary>Selects a pose by index, wrapping into range so cycling never goes out of bounds.</summary>
|
|
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
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private void PlayPose(AnimationClip clip)
|
|
{
|
|
if (!clip || !_animator)
|
|
{
|
|
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);
|
|
|
|
// 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.
|
|
ResetToRestPose();
|
|
|
|
_clipPlayable = AnimationClipPlayable.Create(_graph, clip);
|
|
_output.SetSourcePlayable(_clipPlayable);
|
|
if (!_graph.IsPlaying())
|
|
_graph.Play();
|
|
|
|
_activeClip = clip;
|
|
_posePlaying = true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
private void CaptureRestPose()
|
|
{
|
|
_restBones = _animator.transform.GetComponentsInChildren<Transform>(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;
|
|
}
|
|
}
|
|
|
|
/// <summary>Restores the captured rest pose so an empty or partial pose clip fully defines the body.</summary>
|
|
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];
|
|
}
|
|
}
|
|
|
|
/// <summary>Destroys the graph so the Animator Controller (run/idle) drives the rig again.</summary>
|
|
private void ReleasePose()
|
|
{
|
|
if (!_posePlaying)
|
|
return;
|
|
if (_graph.IsValid())
|
|
_graph.Destroy();
|
|
_graph = default;
|
|
_output = default;
|
|
_clipPlayable = default;
|
|
_activeClip = null;
|
|
_posePlaying = false;
|
|
}
|
|
}
|