Wall-climb mesh lean + separate climb/mantle/sprint speeds; reworked rig

PlayerCharacterController:
- Wall lean now moves only the visual model + its paint collider (auto-resolved
  mesh root parenting both renderer and bones), not the CharacterController
  capsule, so the mesh can sink into the wall (wallMaxInset) as well as lean out
  (wallMaxOffset); capsule stays flush.
- Lean eases back to the authored pose when leaving the wall instead of snapping.
- Sprint multiplier now also applies while wall/ceiling climbing.
- Separate wallMantleSpeedMultiplier for pulling up over the top edge,
  independent of the climb speed.

Assets:
- Re-exported paintman_UV.FBX and added Paintman.blend: rescaled armature to
  mesh, merged seam duplicate vertices, fresh automatic weights, inner-thigh
  weight fix (no webbing on leg spread).
- Updated anims, animator controller, font SDF and scene.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 4.8 committed 2026-06-25 20:57:15 +02:00
1 parent cda1373979
commit d867c617df
10 files changed
+15698 -2821

No files matched your search

+95 -22
View File
@@ -9,10 +9,11 @@ using UnityEngine.InputSystem;
///
/// Wall-climb (entered by pushing the move input into a wall): W/S climb straight up/down the
/// wall at a constant speed, A/D strafe left/right relative to the camera, and the body always
/// faces the wall. Holding the left mouse button and dragging up/down leans out from / back to
/// the wall; holding the right mouse button and dragging left/right turns the body. Either pauses
/// the camera orbit; the lean-out offset eases back to zero while moving, the turn is held until
/// leaving the wall.
/// faces the wall. Holding the left mouse button and dragging up/down leans the visual mesh out
/// of or into the wall (only the model + its paint collider move; the capsule stays put, so the
/// lean can sink into the wall); holding the right mouse button and dragging left/right turns the
/// body. Either pauses the camera orbit; the lean offset eases back to zero while moving, the turn
/// is held until leaving the wall.
/// On leaving, the body eases back to its movement direction. Jumping detaches and works
/// normally; re-grab is blocked while rising and resumes once falling and pushing into a wall.
/// Reaching the top edge mantles the character over onto the surface.
@@ -33,7 +34,7 @@ public partial class PlayerCharacterController : MonoBehaviour
[Tooltip("Horizontal movement speed (units/second).")]
public float moveSpeed = 4f;
[Tooltip("Speed multiplier applied to ground movement while the sprint key (Left Shift) is held.")]
[Tooltip("Speed multiplier applied to ground movement and wall/ceiling climbing while the sprint key (Left Shift) is held.")]
public float sprintSpeedMultiplier = 1.8f;
[Tooltip("How quickly ground movement ramps up to the target speed (units/second²). Lower = more gradual start.")]
@@ -100,8 +101,15 @@ public partial class PlayerCharacterController : MonoBehaviour
[Range(0f, 1f)]
public float wallClimbSpeedMultiplier = 0.5f;
[Tooltip("Farthest the character can lean out from the wall via the mouse-drag offset (units).")]
public float wallMaxOffset = 1.2f;
[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;
[Tooltip("Farthest the mesh can lean out from the wall via the mouse-drag offset (units).")]
public float wallMaxOffset = .2f;
[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;
[Tooltip("Units the lean-out offset changes per pixel of vertical mouse drag (left button held).")]
public float wallOffsetSensitivity = 0.01f;
@@ -127,6 +135,9 @@ public partial class PlayerCharacterController : MonoBehaviour
[Tooltip("Camera orbit input; paused while dragging the wall-climb offset. Auto-found if empty.")]
[SerializeField] private ThirdPersonOrbitInput cameraOrbit;
[Tooltip("Visual model root moved by the wall-climb lean offset (into/out of the wall) while the capsule stays put. Must parent both the skinned mesh and its bones. Auto-resolved from the SkinnedMeshRenderer if empty.")]
[SerializeField] private Transform meshOffsetTarget;
/// <summary>
/// When false, movement/jump input is ignored but gravity and ground contact
/// keep running. Toggled by the GameModeManager.
@@ -158,11 +169,14 @@ public partial class PlayerCharacterController : MonoBehaviour
private Vector3 _wallNormalAccum; // summed wall normals during the latest Move
private bool _wallContactThisFrame;
private float _lostContactTimer;
private float _wallOffset; // current lean-out distance from the wall (mouse drag)
private float _wallOffset; // signed mesh lean (mouse drag): + = out from the wall, - = into it; moves only the model, not the capsule
private float _wallYaw; // body yaw offset from facing the wall (mouse drag); held until leaving
private int _wallStrafeSign; // latched sideways direction (camera picks it once per strafe); 0 = unlatched
private bool _offsetDragging; // true while the offset/rotation is being set (pauses camera orbit)
private bool _climbLeftGround; // true once the capsule has left the ground while climbing (enables ground-detach)
private Vector3 _meshBaseLocalPos; // meshOffsetTarget's authored local position; the lean offset is applied relative to this
private float _meshOffsetResidual; // leftover mesh lean still easing back to 0 after leaving the wall (no longer climbing)
private Vector3 _meshOffsetExitNormal; // wall normal latched at exit; the residual eases along it (live _wallNormal may shift on stray contact)
private readonly RaycastHit[] _wallProbe = new RaycastHit[8];
// Mantle (top-out) state.
@@ -181,6 +195,21 @@ public partial class PlayerCharacterController : MonoBehaviour
cameraTransform = Camera.main.transform;
if (!cameraOrbit)
cameraOrbit = FindFirstObjectByType<ThirdPersonOrbitInput>();
// Resolve the model root to offset: walk up from the skinned mesh to the topmost node still
// under this transform, so it parents BOTH the renderer and its bones. Offsetting only the
// renderer would move just the baked paint collider, not the bone-driven visible mesh.
if (!meshOffsetTarget)
{
var smr = GetComponentInChildren<SkinnedMeshRenderer>();
if (smr)
{
Transform t = smr.transform;
while (t.parent && t.parent != transform) t = t.parent;
meshOffsetTarget = t;
}
}
if (meshOffsetTarget) _meshBaseLocalPos = meshOffsetTarget.localPosition;
}
void Update()
@@ -247,7 +276,7 @@ public partial class PlayerCharacterController : MonoBehaviour
// Frozen on the wall while control is off (e.g. paint mode): keep the
// grip, no movement or gravity, so switching modes never drops us.
if (ControlEnabled)
ApplyWallClimbMovement(h, v, moveDir, jumpPressed, Time.deltaTime);
ApplyWallClimbMovement(h, v, moveDir, jumpPressed, sprinting, Time.deltaTime);
else
_offsetDragging = false;
if (cameraOrbit)
@@ -260,6 +289,9 @@ public partial class PlayerCharacterController : MonoBehaviour
if (cameraOrbit)
cameraOrbit.LookSuppressed = false;
// Glide the leftover wall lean back to the authored pose (set when leaving the wall).
EaseMeshOffsetToRest(Time.deltaTime);
// A ground normal steeper than the slope limit is too steep to stand on
// (also covers balancing on a sharp box edge).
float slopeAngle = Vector3.Angle(_groundNormal, Vector3.up);
@@ -365,6 +397,7 @@ public partial class PlayerCharacterController : MonoBehaviour
_verticalVelocity = 0f;
_slideSpeed = 0f;
_wallOffset = 0f;
_meshOffsetResidual = 0f; // fresh grab: the climb path owns the mesh, drop any leftover ease
_wallYaw = 0f;
_wallStrafeSign = 0;
_climbLeftGround = false;
@@ -375,14 +408,15 @@ public partial class PlayerCharacterController : MonoBehaviour
/// Movement while climbing, for both walls and ceilings (distinguished by the surface
/// normal). On a WALL: W/S climb straight up/down at a constant speed (camera pitch never
/// tilts them), A/D strafe left/right relative to the camera, the body faces the wall, the
/// left mouse button + vertical drag leans out via <see cref="_wallOffset"/>, and the right
/// left mouse button + vertical drag leans the mesh out of / into the wall via
/// <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
/// 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, float dt)
private void ApplyWallClimbMovement(float h, float v, Vector3 moveDir, bool jumpPressed, bool sprinting, float dt)
{
_offsetDragging = false;
@@ -495,10 +529,11 @@ public partial class PlayerCharacterController : MonoBehaviour
}
}
// Left button + vertical drag sets the lean-out offset (mouse down = away from the
// surface; down off a ceiling); 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.
// 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.
bool offsetDrag = found && IsLeftMouseHeld();
bool yawDrag = found && IsRightMouseHeld();
if (offsetDrag || yawDrag)
@@ -506,7 +541,7 @@ public partial class PlayerCharacterController : MonoBehaviour
_offsetDragging = true;
Vector2 drag = ReadMouseDelta();
if (offsetDrag)
_wallOffset = Mathf.Clamp(_wallOffset - drag.y * wallOffsetSensitivity, 0f, wallMaxOffset);
_wallOffset = Mathf.Clamp(_wallOffset - drag.y * wallOffsetSensitivity, -wallMaxInset, wallMaxOffset);
if (yawDrag)
_wallYaw += drag.x * wallRotateSensitivity;
}
@@ -514,19 +549,21 @@ public partial class PlayerCharacterController : MonoBehaviour
{
_wallOffset = Mathf.MoveTowards(_wallOffset, 0f, wallOffsetReturnSpeed * dt);
}
ApplyMeshOffset(_wallOffset);
float climbSpeed = moveSpeed * wallClimbSpeedMultiplier;
if (sprinting) climbSpeed *= sprintSpeedMultiplier;
Vector3 velocity = Vector3.zero;
if (alongSurface.sqrMagnitude > 1e-6f)
velocity += alongSurface.normalized * climbSpeed;
if (found)
{
// Smoothly approach the chosen standoff. Target one skin-width beyond the offset so
// the capsule rests just outside the surface (offset 0 = flush): the controller's
// depenetration never fights back, so there's no buzz and no quantizing deadzone is
// needed. Exponential follow keeps it framerate-stable; clamp guards probe spikes.
// Smoothly hold the capsule one skin-width off the surface (flush): the lean offset no
// longer moves the capsule (only the visual mesh), so it always rests against the wall.
// The controller's depenetration never fights back, so there's no buzz and no quantizing
// deadzone is needed. Exponential follow keeps it framerate-stable; clamp guards spikes.
const float maxPerpSpeed = 8f;
float gapError = (_wallOffset + _controller.skinWidth) - gap;
float gapError = _controller.skinWidth - gap;
float follow = 1f - Mathf.Exp(-wallOffsetFollow * dt);
velocity += _wallNormal * Mathf.Clamp(gapError * follow / dt, -maxPerpSpeed, maxPerpSpeed);
}
@@ -588,6 +625,38 @@ public partial class PlayerCharacterController : MonoBehaviour
}
}
/// <summary>
/// Offsets only the visual model (mesh + its baked paint collider) along the wall normal by the
/// given signed distance (+ = out from the wall, - = into it), relative to the model's authored
/// local position. The CharacterController capsule is untouched, so the lean can sink into the
/// wall where the capsule would be blocked. Applied in the player's local space so it tracks the
/// body yaw; offset 0 restores the authored pose.
/// </summary>
private void ApplyMeshOffset(float offset) => SetMeshLean(_wallNormal, offset);
/// <summary>
/// Places the visual model at the given signed lean along <paramref name="dir"/> (world space),
/// relative to its authored local pose. Local-space conversion tracks the live body yaw.
/// </summary>
private void SetMeshLean(Vector3 dir, float offset)
{
if (!meshOffsetTarget) return;
Vector3 worldOffset = dir * offset;
meshOffsetTarget.localPosition = _meshBaseLocalPos + transform.InverseTransformVector(worldOffset);
}
/// <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).
/// </summary>
private void EaseMeshOffsetToRest(float dt)
{
if (_meshOffsetResidual == 0f) return;
_meshOffsetResidual = Mathf.MoveTowards(_meshOffsetResidual, 0f, wallOffsetReturnSpeed * dt);
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
@@ -681,7 +750,7 @@ public partial class PlayerCharacterController : MonoBehaviour
private void MantleOverEdge(float dt)
{
_mantleTimer += dt;
float speed = moveSpeed * wallClimbSpeedMultiplier;
float speed = moveSpeed * wallMantleSpeedMultiplier;
Vector3 forward = -_wallNormal;
forward.y = 0f;
@@ -786,6 +855,10 @@ public partial class PlayerCharacterController : MonoBehaviour
_isWallClimbing = false;
_lostContactTimer = 0f;
_verticalVelocity = 0f;
// Hand the current lean off to the residual so it eases home (EaseMeshOffsetToRest)
// instead of snapping; latch the normal since the live one may shift after detaching.
_meshOffsetResidual = _wallOffset;
_meshOffsetExitNormal = _wallNormal;
_wallOffset = 0f;
_wallYaw = 0f;
_wallStrafeSign = 0;