Replace ceiling Y offset with per-pose CharacterController capsule

Each PlayerPose now carries its own collider center Y, radius and height,
eased and routed to the CharacterController via a SetCapsule single-writer.
This lets crawl poses (Ball/Star) shrink the capsule to fit under furniture.
The ceiling Y offset is removed; poses without authored sizes fall back to
the base capsule, so existing poses keep their collision.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 4.8 committed 2026-06-27 09:55:10 +02:00
1 parent 13de058819
commit 52f9ace9e0
6 files changed
+144 -59

No files matched your search

+83 -30
View File
@@ -2,6 +2,7 @@ using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Animations;
using UnityEngine.Playables;
using UnityEngine.Serialization;
#if ENABLE_INPUT_SYSTEM
using UnityEngine.InputSystem;
#endif
@@ -22,15 +23,16 @@ using UnityEngine.InputSystem;
/// <see cref="AnimationMixerPlayable"/> on an <see cref="AnimationPlayableOutput"/>) that drives the
/// Animator directly. Switching clip — between poses, or between idle/walk/run within a pose —
/// crossfades the outgoing and incoming clips over <see cref="poseBlendDuration"/> 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.
/// A separate per-pose camera y-offset (plus that same ceiling offset) is likewise eased and routed to the
/// controller's camera target, framing each pose for the ThirdPersonCamera without moving the body.
/// Each pose also carries its own CharacterController capsule (center Y / radius / height): the active
/// pose's dimensions are eased toward and written through the controller so a low pose (ball, star) can
/// crawl under furniture the default standing capsule is too tall for. A per-pose camera y-offset is
/// likewise eased and routed to the controller's camera target, framing each pose for the
/// ThirdPersonCamera without moving the body.
/// </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.
// Run after PlayerCharacterController (default order 0) so the sprint / locomotion state polled below is
// this frame's, and the per-pose capsule / camera offset resolve without a one-frame seam.
[DefaultExecutionOrder(100)]
public partial class PlayerPoseController : MonoBehaviour
{
@@ -56,9 +58,16 @@ public partial class PlayerPoseController : MonoBehaviour
[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.")]
[Tooltip("Approach speed (local units/sec) for easing the per-pose camera-target y-offset toward its " +
"target while cycling poses. 0 = instant.")]
[Min(0f)]
[SerializeField] private float ceilingOffsetSpeed = 4f;
[FormerlySerializedAs("ceilingOffsetSpeed")]
[SerializeField] private float cameraOffsetSpeed = 4f;
[Tooltip("Blend speed (0..1 per second) for resizing the CharacterController capsule toward the active " +
"pose's dimensions when the pose changes. 0 = instant snap; higher = snappier resize.")]
[Min(0f)]
[SerializeField] private float capsuleBlendSpeed = 8f;
[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 " +
@@ -85,8 +94,16 @@ public partial class PlayerPoseController : MonoBehaviour
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 (mesh + camera)
private float _cameraPoseOffset; // eased per-pose camera-target offset; the ceiling offset is added on top when routed to the controller
private float _cameraPoseOffset; // eased per-pose camera-target offset routed to the controller's camera target
// Capsule blend: the CharacterController dimensions are eased toward the active pose's via a single
// normalized blend (so the capsule stays a valid shape throughout) and written through the controller.
// Snapped on the first frame so the player spawns at its pose size with no resize blend.
private bool _capsuleStarted;
private float _capFromCenterY, _capFromRadius, _capFromHeight;
private float _capToCenterY, _capToRadius, _capToHeight;
private float _capCenterY, _capRadius, _capHeight;
private float _capBlend;
// 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
@@ -94,7 +111,7 @@ public partial class PlayerPoseController : MonoBehaviour
// 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.
// so it is the root of this subtree, and resetting it would wipe the wall-lean / ground raise.
private Transform[] _restBones;
private Vector3[] _restPos;
private Quaternion[] _restRot;
@@ -144,38 +161,37 @@ public partial class PlayerPoseController : MonoBehaviour
}
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);
// CharacterController capsule: each pose carries its own center Y / radius / height so a low pose
// (ball, star) can crawl where the default standing capsule is too tall. Eased toward the active
// pose's dimensions when the pose changes so the collider resizes smoothly instead of popping; a
// pose that leaves them unset (height/radius <= 0) keeps the default capsule.
bool hasCapsule = pose && pose.ControllerHeight > 0f && pose.ControllerRadius > 0f;
float capCenterY = hasCapsule ? pose.ControllerCenterY : _player.BaseCapsuleCenterY;
float capRadius = hasCapsule ? pose.ControllerRadius : _player.BaseCapsuleRadius;
float capHeight = hasCapsule ? pose.ControllerHeight : _player.BaseCapsuleHeight;
ApplyCapsule(capCenterY, capRadius, capHeight);
// Camera target: a per-pose framing offset (applied in any state) plus the same ceiling offset,
// routed to the ThirdPersonCamera's follow target only (never the body). The pose part eases like
// the ceiling offset so cycling poses glides the camera; the ceiling part reuses the value above so
// camera and model stay in lockstep on the ceiling.
// Camera target: a per-pose framing offset (applied in any state), eased so cycling poses glides
// the camera, routed to the ThirdPersonCamera's follow target only (never the body).
float cameraPoseTarget = pose ? pose.CameraTargetYOffset : 0f;
_cameraPoseOffset = ceilingOffsetSpeed > 0f
? Mathf.MoveTowards(_cameraPoseOffset, cameraPoseTarget, ceilingOffsetSpeed * Time.deltaTime)
_cameraPoseOffset = cameraOffsetSpeed > 0f
? Mathf.MoveTowards(_cameraPoseOffset, cameraPoseTarget, cameraOffsetSpeed * Time.deltaTime)
: cameraPoseTarget;
_player.SetCameraTargetLocalYOffset(_cameraPoseOffset + _ceilingOffset);
_player.SetCameraTargetLocalYOffset(_cameraPoseOffset);
}
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.
// next idle frame. Also clear the camera offset and restore the default capsule we were holding.
ReleasePose();
_ceilingOffset = 0f;
_cameraPoseOffset = 0f;
if (_player)
{
_player.SetPoseLocalYOffset(0f);
_player.SetCameraTargetLocalYOffset(0f);
_player.SetCapsule(_player.BaseCapsuleCenterY, _player.BaseCapsuleRadius, _player.BaseCapsuleHeight);
}
_capsuleStarted = false; // re-snap to the active pose's capsule if this is re-enabled
}
/// <summary>Advances to the next pose, wrapping at the end of the list.</summary>
@@ -200,6 +216,43 @@ public partial class PlayerPoseController : MonoBehaviour
return poses[Mathf.Clamp(_currentIndex, 0, poses.Count - 1)];
}
/// <summary>
/// Eases the CharacterController capsule toward the given target dimensions and writes them through
/// the controller. A single normalized blend drives all three values together, so the capsule stays a
/// valid shape (and its floor contact moves linearly) throughout the resize. The first call snaps, so
/// the player spawns at its pose size without a resize blend; <see cref="capsuleBlendSpeed"/> 0 also snaps.
/// </summary>
private void ApplyCapsule(float centerY, float radius, float height)
{
if (!_capsuleStarted)
{
_capsuleStarted = true;
_capFromCenterY = _capToCenterY = _capCenterY = centerY;
_capFromRadius = _capToRadius = _capRadius = radius;
_capFromHeight = _capToHeight = _capHeight = height;
_capBlend = 1f;
}
else if (centerY != _capToCenterY || radius != _capToRadius || height != _capToHeight)
{
// New target (pose changed): ease from wherever the capsule currently is.
_capFromCenterY = _capCenterY;
_capFromRadius = _capRadius;
_capFromHeight = _capHeight;
_capToCenterY = centerY;
_capToRadius = radius;
_capToHeight = height;
_capBlend = 0f;
}
_capBlend = capsuleBlendSpeed > 0f
? Mathf.MoveTowards(_capBlend, 1f, capsuleBlendSpeed * Time.deltaTime)
: 1f;
_capCenterY = Mathf.Lerp(_capFromCenterY, _capToCenterY, _capBlend);
_capRadius = Mathf.Lerp(_capFromRadius, _capToRadius, _capBlend);
_capHeight = Mathf.Lerp(_capFromHeight, _capToHeight, _capBlend);
_player.SetCapsule(_capCenterY, _capRadius, _capHeight);
}
private bool CycleKeyPressed()
{
#if ENABLE_INPUT_SYSTEM
@@ -354,7 +407,7 @@ public partial class PlayerPoseController : MonoBehaviour
}
// 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
// touches it: the wall-lean / ground raise (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