Add wall-climb mantle and wall-relative controls

- Make wall-climb controls wall-relative: W approaches/climbs up,
  S moves away and detaches, A/D strafe along the wall
- Add slow approach speed for moving toward/away from the wall
- Remove wall-climb activation time (activate on contact)
- Add mantle/top-out over the top edge so the player pulls onto the
  surface instead of dropping and re-grabbing (fixes edge flicker)
- Add re-grab cooldown after top-out

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 4.8 committed 2026-06-22 16:39:15 +02:00
1 parent dcb1fc42a6
commit fd67936d61
2 files changed
+456 -10

No files matched your search

+330 -5
View File
@@ -11,6 +11,13 @@ using UnityEngine.InputSystem;
/// natural with the fixed scene camera. Gravity and jumping are integrated
/// manually because CharacterController does not apply physics on its own.
///
/// Touching a wall or ceiling enters a wall-climb mode with
/// wall-relative controls: W moves slowly toward the wall and climbs up once
/// fully against it, S backs slowly away and detaches once far enough, and A/D
/// strafe left/right along the wall. Gravity and jumping are ignored while
/// climbing. Climbing up past the top edge mantles the character over onto the
/// surface instead of dropping back down.
///
/// The script reads input through the new Input System (Keyboard.current) when it
/// is enabled, and falls back to the legacy Input Manager otherwise. The project
/// currently runs the new Input System only.
@@ -71,6 +78,40 @@ public class PlayerCharacterController : MonoBehaviour
[Range(0f, 1f)]
public float slideControl = 0.3f;
[Header("Wall Climb")]
[Tooltip("If ON, the character enters a wall-climb mode after touching a " +
"wall or ceiling for a short time: it sticks to the surface, moves " +
"at reduced speed and ignores gravity and jumping until contact is lost.")]
public bool enableWallClimb = true;
[Tooltip("Minimum surface angle from 'up' (in degrees) for a contact to " +
"count as a climbable wall or ceiling. Surfaces between the slope " +
"limit and this angle stay (sliding) slopes; at or above this they " +
"are treated as walls/ceilings.")]
[Range(50f, 180f)]
public float wallMinAngle = 80f;
[Tooltip("Speed multiplier for climbing up (W when against the wall) and " +
"strafing left/right (A/D) while wall-climbing (0.5 = half speed).")]
[Range(0f, 1f)]
public float wallClimbSpeedMultiplier = 0.5f;
[Tooltip("Speed (units per second) for moving toward (W) or away from (S) the " +
"wall while climbing. Deliberately slow so approaching/detaching is gentle.")]
public float wallApproachSpeed = 0.5f;
[Tooltip("Gap (in units) below which the character counts as fully against " +
"the wall, so pressing forward (W) climbs up instead of moving closer.")]
public float wallContactDistance = 0.05f;
[Tooltip("Gap (in units) from the wall at which holding back (S) leaves " +
"wall-climb mode.")]
public float wallDetachDistance = 0.25f;
[Tooltip("Grace period (seconds) of lost contact tolerated before leaving " +
"wall-climb mode (prevents flicker on bumpy geometry/edges).")]
public float wallExitGrace = 0.15f;
/// <summary>
/// When false, movement/jump input is ignored (the player stands still), but
/// gravity and ground contact keep running so the character stays grounded.
@@ -78,6 +119,9 @@ public class PlayerCharacterController : MonoBehaviour
/// </summary>
public bool ControlEnabled { get; set; } = true;
/// <summary>True while the character is in wall-climb mode (stuck to a wall/ceiling).</summary>
public bool IsWallClimbing => _isWallClimbing;
private CharacterController _controller;
private float _verticalVelocity;
@@ -86,6 +130,22 @@ public class PlayerCharacterController : MonoBehaviour
private bool _groundNormalUpdated;
private float _slideSpeed;
// Wall-climb state.
private bool _isWallClimbing;
private Vector3 _wallNormal; // surface normal of the climbed wall/ceiling
private Vector3 _wallNormalAccum; // summed wall normals during the latest Move
private bool _wallContactThisFrame; // any wall/ceiling touched during the latest Move
private float _lostContactTimer; // continuous lost-contact time while climbing
private readonly RaycastHit[] _wallProbe = new RaycastHit[8]; // buffer for the wall-distance probe
// Mantle (top-out) state: climbing up past the wall's top edge pulls the
// character up and over onto the surface instead of dropping back down.
private bool _mantling; // a mantle is currently in progress
private bool _mantleClearedFeet; // phase 1 done: whole capsule is above the edge
private float _mantleOverDist; // phase 2 forward distance moved over the top
private float _mantleTimer; // safety cap so a mantle can never run forever
private float _reentryCooldown; // blocks re-grabbing the wall right after topping out
void Awake()
{
_controller = GetComponent<CharacterController>();
@@ -142,6 +202,27 @@ public class PlayerCharacterController : MonoBehaviour
moveDir = inputDir;
}
// --- Wall-climb state machine ---------------------------------------
// Evaluates the wall/ceiling contact gathered during the previous Move,
// then enters/leaves wall-climb mode accordingly.
UpdateWallClimbState();
// Latch the wall normal for this frame's movement, then reset the
// per-frame contact accumulators (OnControllerColliderHit refills them
// during the Move calls below / above).
if (_wallContactThisFrame && _wallNormalAccum.sqrMagnitude > 1e-6f)
_wallNormal = _wallNormalAccum.normalized;
_wallContactThisFrame = false;
_wallNormalAccum = Vector3.zero;
if (_isWallClimbing)
{
// While climbing: wall-relative WASD, no gravity, no jump, no slope
// sliding, no forced alignment. Done for this frame.
ApplyWallClimbMovement(h, v, Time.deltaTime);
return;
}
// --- Evaluate the ground slope (for sliding) ------------------------
// The ground normal is gathered in OnControllerColliderHit during the
// previous Move. If it is steeper than the controller's Slope Limit, the
@@ -203,14 +284,258 @@ public class PlayerCharacterController : MonoBehaviour
}
/// <summary>
/// Collects the ground contact normal during <see cref="CharacterController.Move"/>.
/// Walls and ceilings (normal pointing sideways or down) are ignored; among the
/// upward-facing contacts the flattest one is kept, which represents the surface
/// the character is actually standing on.
/// Drives entry into wall-climb mode: any wall/ceiling contact enters the mode
/// immediately (or bails out when the feature/control is disabled). Leaving the
/// mode while climbing is handled in <see cref="ApplyWallClimbMovement"/>
/// (raycast-based).
/// </summary>
private void UpdateWallClimbState()
{
// Cool down the post-mantle re-grab lock (runs whether climbing or not).
if (_reentryCooldown > 0f)
_reentryCooldown -= Time.deltaTime;
if (!enableWallClimb || !ControlEnabled)
{
if (_isWallClimbing)
ExitWallClimb();
return;
}
if (!_isWallClimbing && _wallContactThisFrame && _reentryCooldown <= 0f)
{
// Any wall/ceiling contact enters wall-climb mode immediately. The
// cooldown above prevents instantly re-grabbing the edge we just
// mantled over (which would otherwise cause a climb/drop flicker).
_isWallClimbing = true;
_lostContactTimer = 0f;
_verticalVelocity = 0f;
_slideSpeed = 0f;
}
// While climbing, exit is decided in ApplyWallClimbMovement (intentional
// back-off with S, or losing the wall after the grace period).
}
/// <summary>
/// Moves the character along the climbed surface using wall-relative WASD:
/// W moves slowly toward the wall and, once fully against it, climbs up;
/// S moves slowly away and detaches once far enough; A/D strafe left/right
/// along the wall. The current wall normal and gap are measured each frame by
/// a raycast toward the surface. No gravity, no jump, no forced alignment.
/// </summary>
private void ApplyWallClimbMovement(float h, float v, float dt)
{
// A mantle (top-out) overrides normal climbing until it finishes.
if (_mantling)
{
MantleOverEdge(dt);
return;
}
// Probe the wall ahead to refresh its normal and our distance to it.
bool found = ProbeWall(out Vector3 probedNormal, out float gap);
if (found)
{
_wallNormal = probedNormal;
_lostContactTimer = 0f;
}
else
{
// The body-center ray lost the wall. If we were climbing up (W) and the
// wall still exists down at the feet, we have reached the top edge:
// mantle up and over onto the surface instead of dropping.
if (v > 0f && WallInFront(_controller.height * 0.5f))
{
_mantling = true;
_mantleClearedFeet = false;
_mantleOverDist = 0f;
_mantleTimer = 0f;
MantleOverEdge(dt);
return;
}
// Otherwise the wall is genuinely gone: leave after the grace period.
_lostContactTimer += dt;
gap = wallDetachDistance + 1f; // treat as "far" so it cannot block exit
if (_lostContactTimer >= wallExitGrace)
{
ExitWallClimb();
return;
}
}
// Build a basis on the wall plane: "up along the wall" and "right".
Vector3 climbUp = Vector3.ProjectOnPlane(Vector3.up, _wallNormal);
if (climbUp.sqrMagnitude < 1e-6f) // degenerate on a flat ceiling: fall back to facing
climbUp = Vector3.ProjectOnPlane(transform.forward, _wallNormal);
climbUp.Normalize();
Vector3 right = Vector3.Cross(_wallNormal, climbUp).normalized;
float climbSpeed = moveSpeed * wallClimbSpeedMultiplier;
// A/D always strafe left/right along the wall.
Vector3 velocity = right * (h * climbSpeed);
bool detach = false;
if (v > 0f)
{
// W: move closer to the wall while there is a gap, otherwise climb up.
if (gap > wallContactDistance)
velocity += -_wallNormal * (v * wallApproachSpeed); // slow approach
else
velocity += climbUp * (v * climbSpeed); // climb up
}
else if (v < 0f)
{
// S: slowly back away from the wall; detach once far enough.
velocity += -_wallNormal * (v * wallApproachSpeed); // v < 0 => away from wall
if (gap >= wallDetachDistance)
detach = true;
}
_controller.Move(velocity * dt);
if (detach)
ExitWallClimb();
}
/// <summary>
/// Casts a ray from the character center toward the wall (opposite the wall
/// normal) to find the current surface normal and the gap between the
/// controller's side and the wall. The player's own colliders (controller and
/// the paintable mesh collider) are skipped. Returns false if no wall is found
/// within reach.
/// </summary>
private bool ProbeWall(out Vector3 normal, out float gap)
{
normal = _wallNormal;
gap = 0f;
Vector3 origin = transform.position + _controller.center;
float radius = _controller.radius;
float maxDist = radius + wallDetachDistance + 0.5f;
int count = Physics.RaycastNonAlloc(
origin, -_wallNormal, _wallProbe, maxDist, ~0, QueryTriggerInteraction.Ignore);
float best = float.MaxValue;
bool found = false;
for (int i = 0; i < count; i++)
{
// Skip our own hierarchy (the CharacterController and the child mesh collider).
if (_wallProbe[i].collider.transform.IsChildOf(transform))
continue;
if (_wallProbe[i].distance < best)
{
best = _wallProbe[i].distance;
normal = _wallProbe[i].normal;
found = true;
}
}
if (found)
gap = Mathf.Max(0f, best - radius);
return found;
}
/// <summary>
/// Pulls the character up and over the wall's top edge onto the surface.
/// Phase 1 rises along the wall face until the whole capsule is above the
/// edge; phase 2 moves forward over the top, after which wall-climb is left
/// and gravity settles the character onto the surface. A short re-grab
/// cooldown is armed so the freshly passed edge does not re-trigger a climb.
/// </summary>
private void MantleOverEdge(float dt)
{
_mantleTimer += dt;
float speed = moveSpeed * wallClimbSpeedMultiplier;
// Forward direction over the edge: horizontal, opposite the wall normal.
Vector3 forward = -_wallNormal;
forward.y = 0f;
forward = forward.sqrMagnitude > 1e-6f ? forward.normalized : transform.forward;
if (!_mantleClearedFeet)
{
// Phase 1: climb straight up until no wall is left in front of the feet.
_controller.Move(Vector3.up * (speed * dt));
if (!WallInFront(_controller.height * 0.5f))
_mantleClearedFeet = true;
}
else
{
// Phase 2: move forward onto the top surface, then finish.
_controller.Move(forward * (speed * dt));
_mantleOverDist += speed * dt;
if (_mantleOverDist >= _controller.radius * 2f + 0.15f || _controller.isGrounded)
{
_reentryCooldown = 0.3f;
ExitWallClimb();
return;
}
}
// Safety: never mantle indefinitely (e.g. if blocked by other geometry).
if (_mantleTimer >= 2f)
{
_reentryCooldown = 0.3f;
ExitWallClimb();
}
}
/// <summary>
/// Casts a horizontal ray toward the wall from a point <paramref name="downOffset"/>
/// below the controller center, ignoring our own colliders. Used to tell a top
/// edge (wall still present lower down) from the wall being truly gone.
/// </summary>
private bool WallInFront(float downOffset)
{
Vector3 origin = transform.position + _controller.center - Vector3.up * downOffset;
float maxDist = _controller.radius + wallDetachDistance + 0.5f;
int count = Physics.RaycastNonAlloc(
origin, -_wallNormal, _wallProbe, maxDist, ~0, QueryTriggerInteraction.Ignore);
for (int i = 0; i < count; i++)
{
if (_wallProbe[i].collider.transform.IsChildOf(transform))
continue;
return true;
}
return false;
}
/// <summary>Leaves wall-climb mode and resets vertical velocity so the player falls cleanly.</summary>
private void ExitWallClimb()
{
_isWallClimbing = false;
_lostContactTimer = 0f;
_verticalVelocity = 0f;
_mantling = false;
_mantleClearedFeet = false;
_mantleOverDist = 0f;
_mantleTimer = 0f;
}
/// <summary>
/// Collects contact normals during <see cref="CharacterController.Move"/>.
/// Wall/ceiling contacts (angle at or above <see cref="wallMinAngle"/>) feed
/// the wall-climb detector; among the upward-facing contacts the flattest one
/// is kept as the ground normal the character is actually standing on.
/// </summary>
private void OnControllerColliderHit(ControllerColliderHit hit)
{
// Ignore near-vertical walls and ceilings.
// Detect walls/ceilings for wall-climbing (steep enough to not be a slope).
if (enableWallClimb)
{
float angle = Vector3.Angle(hit.normal, Vector3.up);
if (angle >= wallMinAngle)
{
_wallContactThisFrame = true;
_wallNormalAccum += hit.normal;
}
}
// Ignore near-vertical walls and ceilings for ground-normal collection.
if (hit.normal.y <= 0.05f)
return;