diff --git a/Assets/Scenes/SampleScene.unity b/Assets/Scenes/SampleScene.unity
index c5c1312..a1eab6c 100644
--- a/Assets/Scenes/SampleScene.unity
+++ b/Assets/Scenes/SampleScene.unity
@@ -153,8 +153,8 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::ThirdPersonOrbitInput
lookMode: 0
- horizontalSensitivity: 0.2
- verticalSensitivity: 0.15
+ horizontalSensitivity: 0.1
+ verticalSensitivity: 0.075
invertY: 0
enableZoom: 1
zoomSpeed: 0.5
@@ -1174,10 +1174,10 @@ CharacterController:
m_Enabled: 1
serializedVersion: 3
m_Height: 1.35
- m_Radius: 0.2
+ m_Radius: 0.1
m_SlopeLimit: 45
m_StepOffset: 0.3
- m_SkinWidth: 0.02
+ m_SkinWidth: 0.01
m_MinMoveDistance: 0.001
m_Center: {x: 0, y: 0.675, z: 0}
--- !u!114 &1141039642
@@ -1198,12 +1198,20 @@ MonoBehaviour:
rotateTowardMovement: 1
rotationSpeed: 720
jumpHeight: 1.2
- gravity: -19.62
+ gravity: -14.715
groundedStickVelocity: -2
slideDownSteepSlopes: 1
slideAcceleration: 20
maxSlideSpeed: 8
slideControl: 0.3
+ enableWallClimb: 1
+ wallClimbActivationTime: 0
+ wallMinAngle: 80
+ wallClimbSpeedMultiplier: 0.5
+ wallApproachSpeed: 0.5
+ wallContactDistance: 0.05
+ wallDetachDistance: 0.25
+ wallExitGrace: 0.15
--- !u!1 &1516578026
GameObject:
m_ObjectHideFlags: 0
@@ -1253,6 +1261,118 @@ Transform:
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
+--- !u!1 &1680244256
+GameObject:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ serializedVersion: 6
+ m_Component:
+ - component: {fileID: 1680244260}
+ - component: {fileID: 1680244259}
+ - component: {fileID: 1680244258}
+ - component: {fileID: 1680244257}
+ m_Layer: 0
+ m_Name: Cube (2)
+ m_TagString: Untagged
+ m_Icon: {fileID: 0}
+ m_NavMeshLayer: 0
+ m_StaticEditorFlags: 0
+ m_IsActive: 1
+--- !u!65 &1680244257
+BoxCollider:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 1680244256}
+ m_Material: {fileID: 0}
+ m_IncludeLayers:
+ serializedVersion: 2
+ m_Bits: 0
+ m_ExcludeLayers:
+ serializedVersion: 2
+ m_Bits: 0
+ m_LayerOverridePriority: 0
+ m_IsTrigger: 0
+ m_ProvidesContacts: 0
+ m_Enabled: 1
+ serializedVersion: 3
+ m_Size: {x: 1, y: 1, z: 1}
+ m_Center: {x: 0, y: 0, z: 0}
+--- !u!23 &1680244258
+MeshRenderer:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 1680244256}
+ m_Enabled: 1
+ m_CastShadows: 1
+ m_ReceiveShadows: 1
+ m_DynamicOccludee: 1
+ m_StaticShadowCaster: 0
+ m_MotionVectors: 1
+ m_LightProbeUsage: 1
+ m_ReflectionProbeUsage: 1
+ m_RayTracingMode: 2
+ m_RayTraceProcedural: 0
+ m_RayTracingAccelStructBuildFlagsOverride: 0
+ m_RayTracingAccelStructBuildFlags: 1
+ m_SmallMeshCulling: 1
+ m_ForceMeshLod: -1
+ m_MeshLodSelectionBias: 0
+ m_RenderingLayerMask: 1
+ m_RendererPriority: 0
+ m_Materials:
+ - {fileID: 2100000, guid: 31321ba15b8f8eb4c954353edc038b1d, type: 2}
+ m_StaticBatchInfo:
+ firstSubMesh: 0
+ subMeshCount: 0
+ m_StaticBatchRoot: {fileID: 0}
+ m_ProbeAnchor: {fileID: 0}
+ m_LightProbeVolumeOverride: {fileID: 0}
+ m_ScaleInLightmap: 1
+ m_ReceiveGI: 1
+ m_PreserveUVs: 0
+ m_IgnoreNormalsForChartDetection: 0
+ m_ImportantGI: 0
+ m_StitchLightmapSeams: 1
+ m_SelectedEditorRenderState: 3
+ m_MinimumChartSize: 4
+ m_AutoUVMaxDistance: 0.5
+ m_AutoUVMaxAngle: 89
+ m_LightmapParameters: {fileID: 0}
+ m_GlobalIlluminationMeshLod: 0
+ m_SortingLayerID: 0
+ m_SortingLayer: 0
+ m_SortingOrder: 0
+ m_MaskInteraction: 0
+ m_AdditionalVertexStreams: {fileID: 0}
+--- !u!33 &1680244259
+MeshFilter:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 1680244256}
+ m_Mesh: {fileID: 10202, guid: 0000000000000000e000000000000000, type: 0}
+--- !u!4 &1680244260
+Transform:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 1680244256}
+ serializedVersion: 2
+ m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
+ m_LocalPosition: {x: 0.5, y: 2.5, z: 5}
+ m_LocalScale: {x: 7, y: 7, z: 1}
+ m_ConstrainProportionsScale: 0
+ m_Children: []
+ m_Father: {fileID: 0}
+ m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &1774267801
GameObject:
m_ObjectHideFlags: 0
@@ -1348,6 +1468,7 @@ SceneRoots:
- {fileID: 1141039639}
- {fileID: 721670973}
- {fileID: 963088882}
+ - {fileID: 1680244260}
- {fileID: 200502122}
- {fileID: 172071436}
- {fileID: 1516578028}
diff --git a/Assets/Scripts/PlayerCharacterController.cs b/Assets/Scripts/PlayerCharacterController.cs
index 8c3522b..677b829 100644
--- a/Assets/Scripts/PlayerCharacterController.cs
+++ b/Assets/Scripts/PlayerCharacterController.cs
@@ -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;
+
///
/// 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
///
public bool ControlEnabled { get; set; } = true;
+ /// True while the character is in wall-climb mode (stuck to a wall/ceiling).
+ 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();
@@ -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
}
///
- /// Collects the ground contact normal during .
- /// 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
+ /// (raycast-based).
+ ///
+ 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).
+ }
+
+ ///
+ /// 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.
+ ///
+ 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();
+ }
+
+ ///
+ /// 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.
+ ///
+ 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;
+ }
+
+ ///
+ /// 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.
+ ///
+ 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();
+ }
+ }
+
+ ///
+ /// Casts a horizontal ray toward the wall from a point
+ /// 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.
+ ///
+ 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;
+ }
+
+ /// Leaves wall-climb mode and resets vertical velocity so the player falls cleanly.
+ private void ExitWallClimb()
+ {
+ _isWallClimbing = false;
+ _lostContactTimer = 0f;
+ _verticalVelocity = 0f;
+ _mantling = false;
+ _mantleClearedFeet = false;
+ _mantleOverDist = 0f;
+ _mantleTimer = 0f;
+ }
+
+ ///
+ /// Collects contact normals during .
+ /// Wall/ceiling contacts (angle at or above ) 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.
///
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;