Add ceiling rotation drag and refine ground/wall pose offset controls

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 4.8 committed 2026-06-26 19:34:51 +02:00
1 parent 080b2a1c39
commit f652cdeca0
6 files changed
+407 -203

No files matched your search

+107 -40
View File
@@ -23,6 +23,11 @@ using UnityEngine.InputSystem;
/// horizontal plane camera-relative (like on the ground). Jump (or reaching a ceiling edge)
/// lets go and drops.
///
/// On the ground the same mouse scheme fine-tunes a pose without moving the capsule: holding the
/// left mouse button and dragging up/down raises/lowers the visual mesh, holding the right mouse
/// button and dragging left/right turns the body. The raise eases back while walking and holds while
/// standing; the turn holds until movement re-aims the body. Both pause the camera orbit while held.
///
/// Named PlayerCharacterController to avoid a clash with Unity.VisualScripting's
/// example "PlayerController".
/// </summary>
@@ -32,10 +37,10 @@ public partial class PlayerCharacterController : MonoBehaviour
{
[Header("Movement")]
[Tooltip("Horizontal movement speed (units/second).")]
public float moveSpeed = 4f;
public float moveSpeed = 2f;
[Tooltip("Speed multiplier applied to ground movement and wall/ceiling climbing while the sprint key (Left Shift) is held.")]
public float sprintSpeedMultiplier = 1.8f;
public float sprintSpeedMultiplier = 3.2f;
[Tooltip("How quickly ground movement ramps up to the target speed (units/second²). Lower = more gradual start.")]
public float acceleration = 30f;
@@ -49,22 +54,19 @@ public partial class PlayerCharacterController : MonoBehaviour
[Tooltip("Camera used for camera-relative movement. Empty = Camera.main.")]
public Transform cameraTransform;
[Tooltip("Animator bool parameter driven true while running on the ground (false in air and while wall/ceiling-climbing).")]
public string runningParameter = "Running";
[Header("Rotation")]
[Tooltip("Smoothly turn the character to face its movement direction.")]
public bool rotateTowardMovement = true;
[Tooltip("Turn speed in degrees per second.")]
public float rotationSpeed = 720f;
public float rotationSpeed = 540f;
[Header("Jump & Gravity")]
[Tooltip("Peak jump height in units.")]
public float jumpHeight = 1.2f;
[Tooltip("Gravity in units per second squared (negative = downward).")]
public float gravity = -19.62f;
public float gravity = -14.715f;
[Tooltip("Small downward velocity kept while grounded for reliable ground contact.")]
public float groundedStickVelocity = -2f;
@@ -89,7 +91,7 @@ public partial class PlayerCharacterController : MonoBehaviour
[Tooltip("Minimum surface angle from 'up' (degrees) that counts as a climbable wall/ceiling rather than a slope.")]
[Range(50f, 180f)]
public float wallMinAngle = 80f;
public float wallMinAngle = 60f;
[Tooltip("How directly the move input must point into a wall to grab it (0 = any inward nudge, 1 = head-on only). Stops mere contact (e.g. standing at a wall's base) from grabbing.")]
[Range(0f, 1f)]
@@ -101,16 +103,16 @@ public partial class PlayerCharacterController : MonoBehaviour
[Tooltip("Speed multiplier for the mantle (pulling up over the top edge), independent of the climb speed. Multiplies moveSpeed; 1 = full walk speed, >1 = snappier pull-up.")]
[Min(0f)]
public float wallMantleSpeedMultiplier = 0.5f;
public float wallMantleSpeedMultiplier = 1.5f;
[Tooltip("Farthest the mesh can lean out from the wall via the mouse-drag offset (units).")]
public float wallMaxOffset = .2f;
public float wallMaxOffset = 0.1f;
[Tooltip("Farthest the mesh can sink INTO the wall via the mouse-drag offset (units). Only the visual model + its paint collider move; the CharacterController capsule stays put, so this isn't blocked by the wall collider.")]
public float wallMaxInset = .2f;
public float wallMaxInset = 0.2f;
[Tooltip("Units the lean-out offset changes per pixel of vertical mouse drag (left button held).")]
public float wallOffsetSensitivity = 0.01f;
public float wallOffsetSensitivity = 0.001f;
[Tooltip("Degrees the body turns per pixel of horizontal mouse drag (left button held) while climbing.")]
public float wallRotateSensitivity = 0.2f;
@@ -128,7 +130,7 @@ public partial class PlayerCharacterController : MonoBehaviour
public float ceilingGrabDistance = 0.3f;
[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 = 1.2f;
public float wallTopOutBoost = 0.3f;
[Tooltip("Camera orbit input; paused while dragging the wall-climb offset. Auto-found if empty.")]
[SerializeField] private ThirdPersonOrbitInput cameraOrbit;
@@ -172,17 +174,25 @@ public partial class PlayerCharacterController : MonoBehaviour
/// </summary>
public bool AnimatorOverridden { get; set; }
/// <summary>
/// The visual-model transform moved by the wall-lean / ceiling offset; its localPosition is owned
/// solely by <see cref="SetMeshLean"/>. Exposed so <see cref="PlayerPoseController"/> can exclude it
/// from its per-frame rest reset — with the humanoid rig the Animator sits on this same node, so it
/// lands in the reset subtree and would otherwise be snapped back to base every frame, wiping the offset.
/// </summary>
public Transform MeshOffsetTarget => meshOffsetTarget;
// 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
private CharacterController _controller;
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 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).
private Vector3 _groundNormal = Vector3.up;
@@ -219,7 +229,6 @@ public partial class PlayerCharacterController : MonoBehaviour
void Awake()
{
_controller = GetComponent<CharacterController>();
_runningHash = Animator.StringToHash(runningParameter);
if (!cameraTransform && Camera.main)
cameraTransform = Camera.main.transform;
if (!cameraOrbit)
@@ -318,10 +327,20 @@ public partial class PlayerCharacterController : MonoBehaviour
return;
}
// Ground pose-adjust (mouse): left button + vertical drag raises/lowers the mesh, right
// button + horizontal drag turns the body -- the wall scheme mirrored to the ground. Either
// pauses the camera orbit so the same drag isn't also spent orbiting. Gated to player control
// so it never fights painting (paint mode owns the left button).
bool groundYawDrag = false;
if (ControlEnabled)
groundYawDrag = HandleGroundPoseAdjust(h, v, Time.deltaTime);
else
_offsetDragging = false;
if (cameraOrbit)
cameraOrbit.LookSuppressed = false;
cameraOrbit.LookSuppressed = _offsetDragging;
// Glide the leftover wall lean back to the authored pose (set when leaving the wall).
// Glide the leftover wall lean home and re-apply the composed mesh offset (residual lean +
// ground raise + pose y-offset) in this frame's single write of meshOffsetTarget.
EaseMeshOffsetToRest(Time.deltaTime);
// A ground normal steeper than the slope limit is too steep to stand on
@@ -371,7 +390,8 @@ public partial class PlayerCharacterController : MonoBehaviour
if (!_groundNormalUpdated)
_groundNormal = Vector3.up;
if (rotateTowardMovement && moveDir.sqrMagnitude > 0.0001f)
// Face the movement direction, unless the right-button turn is actively steering the body.
if (rotateTowardMovement && !groundYawDrag && moveDir.sqrMagnitude > 0.0001f)
{
Quaternion target = Quaternion.LookRotation(moveDir, Vector3.up);
transform.rotation = Quaternion.RotateTowards(
@@ -431,6 +451,7 @@ public partial class PlayerCharacterController : MonoBehaviour
_verticalVelocity = 0f;
_slideSpeed = 0f;
_wallOffset = 0f;
_groundOffset = 0f; // drop any ground pose-adjust raise so it doesn't ride onto the wall
_meshOffsetResidual = 0f; // fresh grab: the climb path owns the mesh, drop any leftover ease
_wallYaw = 0f;
_wallStrafeSign = 0;
@@ -446,8 +467,9 @@ public partial class PlayerCharacterController : MonoBehaviour
/// <see cref="_wallOffset"/> (capsule stays put), and the right
/// mouse button + horizontal drag turns the body via <see cref="_wallYaw"/>. On a
/// CEILING: the body stays upright and moves WASD camera-relative in the horizontal plane
/// (like on the ground), facing the move direction; the lean-out drag pulls down off the
/// ceiling. Surface normal and gap are re-measured each frame; reaching a wall's top edge
/// (like on the ground), facing the move direction unless the right mouse button + horizontal
/// drag turns it; the lean-out drag pulls down off the ceiling. Surface normal and gap are
/// re-measured each frame; reaching a wall's top edge
/// mantles, climbing a wall up into an overhang hands off to the ceiling.
/// </summary>
private void ApplyWallClimbMovement(float h, float v, Vector3 moveDir, bool jumpPressed, bool sprinting, float dt)
@@ -565,9 +587,10 @@ public partial class PlayerCharacterController : MonoBehaviour
// Left button + vertical drag sets the lean offset (mouse up = out from the surface, mouse
// down = into it; the offset is signed and moves only the visual mesh, so it can sink into
// the wall where the capsule can't); right button + horizontal drag turns the body (wall
// only). The offset eases back to zero while moving and never detaches, the turn is held
// until leaving the wall. Either drag pauses the camera orbit.
// the wall where the capsule can't); right button + horizontal drag turns the body. The
// offset eases back to zero while moving and never detaches. On a WALL the turn is a held
// yaw offset from the wall normal (kept until leaving); on a CEILING the body is free like
// on the ground, so the drag rotates the transform directly. Either drag pauses the orbit.
bool offsetDrag = found && IsLeftMouseHeld();
bool yawDrag = found && IsRightMouseHeld();
if (offsetDrag || yawDrag)
@@ -577,7 +600,12 @@ public partial class PlayerCharacterController : MonoBehaviour
if (offsetDrag)
_wallOffset = Mathf.Clamp(_wallOffset - drag.y * wallOffsetSensitivity, -wallMaxInset, wallMaxOffset);
if (yawDrag)
_wallYaw += drag.x * wallRotateSensitivity;
{
if (onCeiling)
transform.Rotate(0f, drag.x * wallRotateSensitivity, 0f, Space.World);
else
_wallYaw += drag.x * wallRotateSensitivity;
}
}
if (!offsetDrag && hasMoveInput)
{
@@ -632,14 +660,18 @@ public partial class PlayerCharacterController : MonoBehaviour
{
if (onCeiling)
{
// Stay upright and face the horizontal move direction, like on the ground.
Vector3 dir = alongSurface;
dir.y = 0f;
if (dir.sqrMagnitude > 1e-4f)
// Stay upright and face the horizontal move direction, like on the ground -- unless
// the right-button turn is actively steering the body (then the manual yaw holds).
if (!yawDrag)
{
Quaternion target = Quaternion.LookRotation(dir.normalized, Vector3.up);
transform.rotation = Quaternion.RotateTowards(
transform.rotation, target, rotationSpeed * dt);
Vector3 dir = alongSurface;
dir.y = 0f;
if (dir.sqrMagnitude > 1e-4f)
{
Quaternion target = Quaternion.LookRotation(dir.normalized, Vector3.up);
transform.rotation = Quaternion.RotateTowards(
transform.rotation, target, rotationSpeed * dt);
}
}
}
else
@@ -651,9 +683,7 @@ public partial class PlayerCharacterController : MonoBehaviour
if (faceWall.sqrMagnitude > 0.0001f)
{
Vector3 dir = Quaternion.AngleAxis(_wallYaw, Vector3.up) * faceWall.normalized;
Quaternion target = Quaternion.LookRotation(dir, Vector3.up);
transform.rotation = Quaternion.RotateTowards(
transform.rotation, target, rotationSpeed * dt);
transform.rotation = Quaternion.LookRotation(dir, Vector3.up);
}
}
}
@@ -678,21 +708,58 @@ public partial class PlayerCharacterController : MonoBehaviour
Vector3 worldOffset = dir * offset;
meshOffsetTarget.localPosition = _meshBaseLocalPos
+ transform.InverseTransformVector(worldOffset)
+ Vector3.up * _poseYOffset; // active pose's ceiling y-offset (0 except while hanging on a ceiling)
+ Vector3.up * (_poseYOffset + _groundOffset); // ceiling pose y-offset + ground mouse raise (both 0 unless in use)
}
/// <summary>
/// Eases the leftover lean back to the authored pose after leaving the wall, so the model glides
/// home instead of snapping. Runs every non-climbing frame at the same rate as the in-climb
/// return; the direction is the wall normal latched at exit (a fresh grab clears the residual).
/// Re-applies the resting mesh offset every non-climbing frame in a single write of
/// <see cref="meshOffsetTarget"/>: eases the leftover wall lean back toward the authored pose
/// (latched exit normal) while composing in the ground mouse raise and any pose y-offset. Writing
/// unconditionally (even at rest) keeps this the sole owner of the offset transform, so a held
/// ground raise stays applied after the residual has reached zero. Offset 0 restores the pose.
/// </summary>
private void EaseMeshOffsetToRest(float dt)
{
if (_meshOffsetResidual == 0f) return;
_meshOffsetResidual = Mathf.MoveTowards(_meshOffsetResidual, 0f, wallOffsetReturnSpeed * dt);
if (_meshOffsetResidual != 0f)
_meshOffsetResidual = Mathf.MoveTowards(_meshOffsetResidual, 0f, wallOffsetReturnSpeed * dt);
SetMeshLean(_meshOffsetExitNormal, _meshOffsetResidual);
}
/// <summary>
/// Ground pose-adjust via the mouse, mirroring the wall-climb scheme: holding the LEFT button and
/// dragging up/down raises/lowers the visual mesh vertically (<see cref="_groundOffset"/>, capsule
/// untouched), holding the RIGHT button and dragging left/right turns the body (world-up yaw). The
/// raise eases back to the authored height while moving and holds while standing or dragging; the
/// turn persists until movement re-aims the body. Returns true while a right-button turn is active
/// so the caller skips auto-rotate-toward-movement that frame. Call site gates on ControlEnabled so
/// paint mode keeps the left button for painting.
/// </summary>
private bool HandleGroundPoseAdjust(float h, float v, float dt)
{
_offsetDragging = false;
bool offsetDrag = IsLeftMouseHeld();
bool yawDrag = IsRightMouseHeld();
bool hasMoveInput = (h * h + v * v) > 1e-4f;
if (offsetDrag || yawDrag)
{
_offsetDragging = true;
Vector2 drag = ReadMouseDelta();
if (offsetDrag)
_groundOffset = Mathf.Clamp(
_groundOffset + drag.y * wallOffsetSensitivity, -wallMaxInset, wallMaxOffset);
if (yawDrag)
transform.Rotate(0f, drag.x * wallRotateSensitivity, 0f, Space.World);
}
// The raise eases home only while actually moving; standing still holds it for posing.
if (!offsetDrag && hasMoveInput)
_groundOffset = Mathf.MoveTowards(_groundOffset, 0f, wallOffsetReturnSpeed * dt);
return yawDrag;
}
/// <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
+17 -2
View File
@@ -89,8 +89,9 @@ public partial class PlayerPoseController : MonoBehaviour
// 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.
// 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;
@@ -336,6 +337,20 @@ public partial class PlayerPoseController : MonoBehaviour
_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;
}
}
/// <summary>Restores the captured rest pose so an empty or partial pose clip fully defines the body.</summary>