Add pose-switching system with continuous hold and sprint reset

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>
This commit is contained in:
adminandClaude Opus 4.8 committed 2026-06-26 08:21:28 +02:00
1 parent d867c617df
commit c41fb398e4
18 files changed
+649 -465

No files matched your search

+52 -4
View File
@@ -147,6 +147,26 @@ public partial class PlayerCharacterController : MonoBehaviour
/// <summary>True while in wall-climb mode.</summary>
public bool IsWallClimbing => _isWallClimbing;
/// <summary>
/// True while the player is idle (under control and essentially still): standing on the floor
/// below the run threshold, or attached to a wall/ceiling with no climb input. Snapshotted at the
/// end of Update so a sibling reads a stable, order-independent value.
/// </summary>
public bool IsIdle => _isIdle;
/// <summary>True while the sprint key is held, in any state (ground, air, wall, or ceiling).</summary>
public bool IsSprinting => _sprinting;
/// <summary>True while stuck to a ceiling (wall-climb mode with a downward-facing surface normal).</summary>
public bool IsOnCeiling => _isWallClimbing && _wallNormal.y < CeilingNormalThreshold;
/// <summary>
/// Set by <see cref="PlayerPoseController"/> while a pose Playable graph owns the Animator output.
/// While set, the run-bool is not poked: it would be moot (the graph overrides it) and Unity would
/// log "Animator is not playing an AnimatorController" every frame.
/// </summary>
public bool AnimatorOverridden { get; set; }
// Below this horizontal speed (m/s, squared) the character counts as idle for the run animation.
private const float RunningSpeedThresholdSqr = 0.04f; // (0.2 m/s)^2
@@ -154,6 +174,9 @@ public partial class PlayerCharacterController : MonoBehaviour
private int _runningHash;
private float _verticalVelocity;
private Vector3 _horizontalVelocity; // smoothed horizontal move velocity (accel/decel ramp)
private bool _sprinting; // sprint key state this frame (exposed via IsSprinting)
private bool _isIdle; // snapshot: player essentially still this frame (exposed via IsIdle)
private float _poseYOffset; // local-up model offset from the active pose (ceiling only); folded into SetMeshLean
// Ground contact normal from the latest Move (for slope sliding).
private Vector3 _groundNormal = Vector3.up;
@@ -241,6 +264,8 @@ public partial class PlayerCharacterController : MonoBehaviour
#endif
}
_sprinting = sprinting;
Vector3 inputDir = new Vector3(h, 0f, v);
if (inputDir.sqrMagnitude > 1f)
inputDir.Normalize();
@@ -282,6 +307,7 @@ public partial class PlayerCharacterController : MonoBehaviour
if (cameraOrbit)
cameraOrbit.LookSuppressed = _offsetDragging;
_horizontalVelocity = Vector3.zero; // drop ground momentum so resuming starts from rest
_isIdle = ControlEnabled && (h * h + v * v) <= 1e-4f; // attached & no climb input = holding a pose
SetAnimatorRunning(false); // climbing is its own pose, not the ground run
return;
}
@@ -350,7 +376,9 @@ public partial class PlayerCharacterController : MonoBehaviour
// Real horizontal speed, so pushing into a wall (blocked) reads as idle, not foot-sliding.
Vector3 horizontalVel = _controller.velocity;
horizontalVel.y = 0f;
SetAnimatorRunning(_controller.isGrounded && horizontalVel.sqrMagnitude > RunningSpeedThresholdSqr);
bool movingOnGround = _controller.isGrounded && horizontalVel.sqrMagnitude > RunningSpeedThresholdSqr;
SetAnimatorRunning(movingOnGround);
_isIdle = ControlEnabled && _controller.isGrounded && !movingOnGround; // standing still on the floor
}
/// <summary>
@@ -642,7 +670,9 @@ public partial class PlayerCharacterController : MonoBehaviour
{
if (!meshOffsetTarget) return;
Vector3 worldOffset = dir * offset;
meshOffsetTarget.localPosition = _meshBaseLocalPos + transform.InverseTransformVector(worldOffset);
meshOffsetTarget.localPosition = _meshBaseLocalPos
+ transform.InverseTransformVector(worldOffset)
+ Vector3.up * _poseYOffset; // active pose's ceiling y-offset (0 except while hanging on a ceiling)
}
/// <summary>
@@ -657,6 +687,24 @@ public partial class PlayerCharacterController : MonoBehaviour
SetMeshLean(_meshOffsetExitNormal, _meshOffsetResidual);
}
/// <summary>
/// Sets a local-up offset applied to the visual model only, composed into the wall-lean's single
/// write of <see cref="meshOffsetTarget"/>. Used by <see cref="PlayerPoseController"/> to hang a
/// ceiling pose at the right height; 0 = none. Re-applies immediately so a change shows this frame.
/// </summary>
public void SetPoseLocalYOffset(float y)
{
if (_poseYOffset == y) return;
_poseYOffset = y;
if (!meshOffsetTarget) return;
// While climbing the lean is the live _wallOffset along _wallNormal; otherwise re-apply along
// whatever residual lean is easing home (both 0 when there's none, restoring the authored pose).
if (_isWallClimbing)
SetMeshLean(_wallNormal, _wallOffset);
else
SetMeshLean(_meshOffsetExitNormal, _meshOffsetResidual);
}
/// <summary>
/// Raycasts from the character center toward the surface (along -_wallNormal) to find the
/// current normal and the gap (surface clearance) to it. The capsule's support distance
@@ -897,10 +945,10 @@ public partial class PlayerCharacterController : MonoBehaviour
#endif
}
/// <summary>Drives the animator's ground-run bool (null-safe if no animator is assigned).</summary>
/// <summary>Drives the animator's ground-run bool (null-safe; skipped while a pose graph overrides the Animator).</summary>
private void SetAnimatorRunning(bool running)
{
if (animator)
if (animator && !AnimatorOverridden)
animator.SetBool(_runningHash, running);
}
+28
View File
@@ -0,0 +1,28 @@
using UnityEngine;
/// <summary>
/// A selectable player pose: an <see cref="AnimationClip"/> held while the player is idle, plus a
/// y-offset applied to the visual model only while stuck to the ceiling (so the pose hangs at the
/// right height). The first ScriptableObject in this project.
///
/// Named PlayerPose (not Pose) to avoid shadowing the built-in UnityEngine.Pose struct, mirroring
/// why the controller is PlayerCharacterController and not just "PlayerController".
/// </summary>
[CreateAssetMenu(fileName = "NewPose", menuName = "Chameleon/Pose")]
public class PlayerPose : ScriptableObject
{
[Tooltip("Clip held while the player is idle in this pose (looped if the clip itself loops, " +
"otherwise frozen on its last frame). Authored for the paintman generic rig; clips for " +
"other hierarchies will not retarget.")]
[SerializeField] private AnimationClip clip;
[Tooltip("Local-up offset added to the visual model ONLY while stuck to the ceiling, so the pose " +
"hangs at the right height. Ignored on the ground and on walls. Meters.")]
[SerializeField] private float ceilingYOffset;
/// <summary>Idle clip for this pose (may be null).</summary>
public AnimationClip Clip => clip;
/// <summary>Local-up offset applied to the model only while on the ceiling.</summary>
public float CeilingYOffset => ceilingYOffset;
}
+2
View File
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e3e6fee2893b9d848b8b1ccca743c14f
@@ -0,0 +1,122 @@
using System;
using System.IO;
using UnityEngine;
using UnityEngine.SceneManagement;
public partial class PlayerPoseController
{
// --- Persistence: the selected pose index ---
//
// Saved on quit / play-stop and restored on the next start, so the player keeps the pose they
// had selected. Mirrors the project's other X.Persistence.cs partials (small JSON state under
// the shared MeshPaint folder).
[Header("Persistence")]
[Tooltip("Save the selected pose index on quit / play-stop and restore it on the next start. " +
"Off = the selection starts at the default pose.")]
public bool persistPose = true;
[Tooltip("Save file identifier. Empty = derived from the active scene + object name. Set " +
"explicitly to keep a save stable across object renames.")]
public string poseSaveId = "";
private const int PoseFileVersion = 1;
private bool _poseSavedOnTeardown; // guards a double save across OnApplicationQuit + OnDestroy
private static string PoseSaveDir => Path.Combine(Application.persistentDataPath, "MeshPaint");
private string PoseSavePath()
{
string key = string.IsNullOrEmpty(poseSaveId)
? SanitizePoseKey(SceneManager.GetActiveScene().name + "_" + gameObject.name)
: SanitizePoseKey(poseSaveId);
return Path.Combine(PoseSaveDir, key + ".pose");
}
private static string SanitizePoseKey(string s)
{
foreach (char c in Path.GetInvalidFileNameChars()) s = s.Replace(c, '_');
return s.Replace(' ', '_');
}
[Serializable]
private class PoseState
{
public int version = PoseFileVersion;
public int index;
}
void Start() => RestorePoseState();
private void OnApplicationQuit() => SavePoseOnTeardown();
private void OnDestroy() => SavePoseOnTeardown();
/// <summary>Saves once during teardown (OnApplicationQuit and OnDestroy both call this; only the first writes).</summary>
private void SavePoseOnTeardown()
{
if (_poseSavedOnTeardown) return;
_poseSavedOnTeardown = true;
SavePoseState();
}
/// <summary>Writes the current pose index to disk.</summary>
public void SavePoseState()
{
if (!persistPose) return;
try
{
var state = new PoseState { index = _currentIndex };
Directory.CreateDirectory(PoseSaveDir);
File.WriteAllText(PoseSavePath(), JsonUtility.ToJson(state, true));
}
catch (Exception e)
{
Debug.LogWarning($"[PlayerPoseController] Saving pose state failed: {e.Message}", this);
}
}
/// <summary>Restores the saved pose index, or does nothing if there is no save.</summary>
private void RestorePoseState()
{
if (!persistPose) return;
var state = LoadPoseState();
if (state == null) return;
SetPoseIndex(state.index);
}
private PoseState LoadPoseState()
{
string path = PoseSavePath();
if (!File.Exists(path)) return null;
try
{
var state = JsonUtility.FromJson<PoseState>(File.ReadAllText(path));
if (state == null || state.version != PoseFileVersion) return null;
return state;
}
catch (Exception e)
{
Debug.LogWarning($"[PlayerPoseController] Loading pose state failed: {e.Message}", this);
return null;
}
}
/// <summary>Deletes the saved pose state (next start uses the default pose). Editor convenience.</summary>
[ContextMenu("Clear Saved Pose State")]
public void ClearSavedPoseState()
{
try
{
string path = PoseSavePath();
if (File.Exists(path)) File.Delete(path);
Debug.Log($"[PlayerPoseController] Cleared saved pose state: {path}", this);
}
catch (Exception e)
{
Debug.LogWarning($"[PlayerPoseController] Clearing saved pose state failed: {e.Message}", this);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 314a46cf262f78345a83e4bbe1c939f2
+246
View File
@@ -0,0 +1,246 @@
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;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 324ce9a23e6a5144096a195460dd567f