diff --git a/Assets/Poses/Pose_Ball.asset b/Assets/Poses/Pose_Ball.asset index 698b2d6..426028f 100644 --- a/Assets/Poses/Pose_Ball.asset +++ b/Assets/Poses/Pose_Ball.asset @@ -18,5 +18,7 @@ MonoBehaviour: walkClipSpeed: 1 runClip: {fileID: 0} runClipSpeed: 1 - ceilingYOffset: 0.84 + controllerCenterY: 0.25 + controllerRadius: 0.15 + controllerHeight: 0.5 cameraTargetYOffset: -0.5 diff --git a/Assets/Poses/Pose_Star.asset b/Assets/Poses/Pose_Star.asset index a405109..d473c34 100644 --- a/Assets/Poses/Pose_Star.asset +++ b/Assets/Poses/Pose_Star.asset @@ -18,5 +18,7 @@ MonoBehaviour: walkClipSpeed: 1 runClip: {fileID: 0} runClipSpeed: 1 - ceilingYOffset: 1 + controllerCenterY: 0.12 + controllerRadius: 0.1 + controllerHeight: 0.3 cameraTargetYOffset: -0.55 diff --git a/Assets/Scenes/SampleScene.unity b/Assets/Scenes/SampleScene.unity index c216439..6bf83a8 100644 --- a/Assets/Scenes/SampleScene.unity +++ b/Assets/Scenes/SampleScene.unity @@ -4633,7 +4633,7 @@ MonoBehaviour: wallOffsetReturnSpeed: 2 wallOffsetFollow: 12 wallExitGrace: 0.15 - ceilingGrabDistance: 0.3 + ceilingGrabDistance: 0.02 wallTopOutBoost: 0.3 cameraOrbit: {fileID: 0} meshOffsetTarget: {fileID: 0} @@ -4660,7 +4660,8 @@ MonoBehaviour: defaultPoseIndex: 0 cycleKey: 17 poseBlendDuration: 0.2 - ceilingOffsetSpeed: 4 + cameraOffsetSpeed: 4 + capsuleBlendSpeed: 8 applyFootIK: 0 --- !u!114 &1141039644 MonoBehaviour: diff --git a/Assets/Scripts/PlayerCharacterController.cs b/Assets/Scripts/PlayerCharacterController.cs index 42d5aad..7847c45 100644 --- a/Assets/Scripts/PlayerCharacterController.cs +++ b/Assets/Scripts/PlayerCharacterController.cs @@ -127,7 +127,7 @@ public partial class PlayerCharacterController : MonoBehaviour public float wallExitGrace = 0.15f; [Tooltip("Head-to-ceiling distance (units) at which climbing a wall up into an overhang hands off to the ceiling.")] - public float ceilingGrabDistance = 0.3f; + public float ceilingGrabDistance = 0.02f; [Tooltip("Upward hop when topping out over a wall/slope edge, so holding W carries you onto the ledge instead of dropping back onto the face and re-grabbing. In meters of jump height; 0 = no hop.")] public float wallTopOutBoost = 0.3f; @@ -185,6 +185,16 @@ public partial class PlayerCharacterController : MonoBehaviour /// public Transform MeshOffsetTarget => meshOffsetTarget; + /// Authored CharacterController capsule center Y, captured in Awake. The per-pose capsule + /// (driven by ) eases back to this when no pose overrides it. + public float BaseCapsuleCenterY { get; private set; } + + /// Authored CharacterController capsule radius, captured in Awake (see ). + public float BaseCapsuleRadius { get; private set; } + + /// Authored CharacterController capsule height, captured in Awake (see ). + public float BaseCapsuleHeight { get; private 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 @@ -194,7 +204,6 @@ public partial class PlayerCharacterController : MonoBehaviour private bool _sprinting; // sprint key state this frame (exposed via IsSprinting) private bool _isIdle; // snapshot: player essentially still this frame (exposed via IsIdle) private bool _movingOnGround; // snapshot: grounded & moving above the run threshold (exposed via IsMovingOnGround) - private float _poseYOffset; // local-up model offset from the active pose (ceiling only); folded into SetMeshLean private float _groundOffset; // signed vertical mesh raise from the ground mouse pose-adjust (+ up / - down); folded into SetMeshLean // Ground contact normal from the latest Move (for slope sliding). @@ -234,6 +243,9 @@ public partial class PlayerCharacterController : MonoBehaviour void Awake() { _controller = GetComponent(); + BaseCapsuleCenterY = _controller.center.y; + BaseCapsuleRadius = _controller.radius; + BaseCapsuleHeight = _controller.height; if (!cameraTransform && Camera.main) cameraTransform = Camera.main.transform; if (!cameraOrbit) @@ -720,7 +732,7 @@ public partial class PlayerCharacterController : MonoBehaviour Vector3 worldOffset = dir * offset; meshOffsetTarget.localPosition = _meshBaseLocalPos + transform.InverseTransformVector(worldOffset) - + Vector3.up * (_poseYOffset + _groundOffset); // ceiling pose y-offset + ground mouse raise (both 0 unless in use) + + Vector3.up * _groundOffset; // ground mouse raise (0 unless in use) } /// @@ -773,21 +785,19 @@ public partial class PlayerCharacterController : MonoBehaviour } /// - /// Sets a local-up offset applied to the visual model only, composed into the wall-lean's single - /// write of . Used by to hang a - /// ceiling pose at the right height; 0 = none. Re-applies immediately so a change shows this frame. + /// Resizes the CharacterController capsule (center Y, radius, height) to the active pose's dimensions, + /// driven by so a low pose can fit where the default standing capsule + /// is too tall. Only the collision capsule changes — the visual model and camera target are untouched. + /// Cached so an unchanged value skips the write (a resize re-bakes the capsule internally). /// - public void SetPoseLocalYOffset(float y) + public void SetCapsule(float centerY, float radius, float height) { - 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); + Vector3 c = _controller.center; + if (c.y == centerY && _controller.radius == radius && _controller.height == height) return; + c.y = centerY; + _controller.center = c; + _controller.radius = radius; + _controller.height = height; } /// diff --git a/Assets/Scripts/PlayerPose.cs b/Assets/Scripts/PlayerPose.cs index fd1e68d..382c54a 100644 --- a/Assets/Scripts/PlayerPose.cs +++ b/Assets/Scripts/PlayerPose.cs @@ -3,8 +3,9 @@ using UnityEngine; /// /// A selectable player pose: an idle held while the player stands still, /// plus optional walk and run clips that the pose system plays itself (the Animator stays empty) while -/// moving on the ground, and 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. +/// moving on the ground, its own CharacterController capsule dimensions (so a low pose like a ball can +/// crawl under furniture the default standing capsule is too tall for), and a camera-target y-offset that +/// frames the pose without moving the body. 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". @@ -37,14 +38,24 @@ public class PlayerPose : ScriptableObject [Range(0.1f, 3f)] [SerializeField] private float runClipSpeed = 1f; - [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; + [Header("Collision")] + [Tooltip("CharacterController capsule center Y while this pose is active (meters, local space). Lower " + + "this together with Height so a short pose still rests its feet on the floor.")] + [SerializeField] private float controllerCenterY = 0.66f; + + [Tooltip("CharacterController capsule radius while this pose is active (meters). Unity clamps it to " + + "half the height.")] + [Min(0.01f)] + [SerializeField] private float controllerRadius = 0.1f; + + [Tooltip("CharacterController capsule height while this pose is active (meters). Lower it (with Center " + + "Y) so a ball/star pose can crawl under a table or couch the default capsule is too tall for.")] + [Min(0.01f)] + [SerializeField] private float controllerHeight = 1.3f; [Tooltip("Local-up offset applied to the camera target (the ThirdPersonCamera's follow/look target) " + "while this pose is active, in ANY state. Frames the pose by raising/lowering what the camera " + - "tracks, without moving the body. The ceiling offset is added on top of this while on the " + - "ceiling. Meters.")] + "tracks, without moving the body. Meters.")] [SerializeField] private float cameraTargetYOffset; /// Idle clip held while the player stands still in this pose (may be null). @@ -65,8 +76,14 @@ public class PlayerPose : ScriptableObject /// Playback speed multiplier for the run clip (1 = authored speed). public float RunClipSpeed => runClipSpeed; - /// Local-up offset applied to the model only while on the ceiling. - public float CeilingYOffset => ceilingYOffset; + /// CharacterController capsule center Y applied while this pose is active. + public float ControllerCenterY => controllerCenterY; + + /// CharacterController capsule radius applied while this pose is active. + public float ControllerRadius => controllerRadius; + + /// CharacterController capsule height applied while this pose is active. + public float ControllerHeight => controllerHeight; /// Local-up offset applied to the camera target while this pose is active (any state). public float CameraTargetYOffset => cameraTargetYOffset; diff --git a/Assets/Scripts/PlayerPoseController.cs b/Assets/Scripts/PlayerPoseController.cs index 39d166f..0f66ce8 100644 --- a/Assets/Scripts/PlayerPoseController.cs +++ b/Assets/Scripts/PlayerPoseController.cs @@ -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; /// 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. -/// 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. /// [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 } /// Advances to the next pose, wrapping at the end of the list. @@ -200,6 +216,43 @@ public partial class PlayerPoseController : MonoBehaviour return poses[Mathf.Clamp(_currentIndex, 0, poses.Count - 1)]; } + /// + /// 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; 0 also snaps. + /// + 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