diff --git a/Assets/Scenes/SampleScene.unity b/Assets/Scenes/SampleScene.unity
index 6bf83a8..af09848 100644
--- a/Assets/Scenes/SampleScene.unity
+++ b/Assets/Scenes/SampleScene.unity
@@ -7552,10 +7552,6 @@ PrefabInstance:
propertyPath: m_IsActive
value: 1
objectReference: {fileID: 0}
- - target: {fileID: 5804137883901333613, guid: c2044ff1352a6bb4586d6d04b59012b7, type: 3}
- propertyPath: m_Enabled
- value: 1
- objectReference: {fileID: 0}
- target: {fileID: 5866666021909216657, guid: c2044ff1352a6bb4586d6d04b59012b7, type: 3}
propertyPath: m_Avatar
value:
@@ -7568,22 +7564,6 @@ PrefabInstance:
propertyPath: m_ApplyRootMotion
value: 0
objectReference: {fileID: 0}
- - target: {fileID: 6701818411356282361, guid: c2044ff1352a6bb4586d6d04b59012b7, type: 3}
- propertyPath: m_LocalScale.x
- value: 1
- objectReference: {fileID: 0}
- - target: {fileID: 6701818411356282361, guid: c2044ff1352a6bb4586d6d04b59012b7, type: 3}
- propertyPath: m_LocalScale.y
- value: 1
- objectReference: {fileID: 0}
- - target: {fileID: 6701818411356282361, guid: c2044ff1352a6bb4586d6d04b59012b7, type: 3}
- propertyPath: m_LocalScale.z
- value: 1
- objectReference: {fileID: 0}
- - target: {fileID: 6701818411356282361, guid: c2044ff1352a6bb4586d6d04b59012b7, type: 3}
- propertyPath: m_ConstrainProportionsScale
- value: 1
- objectReference: {fileID: 0}
m_RemovedComponents: []
m_RemovedGameObjects: []
m_AddedGameObjects: []
diff --git a/Assets/Scripts/PlayerCharacterController.cs b/Assets/Scripts/PlayerCharacterController.cs
index 7847c45..3e8c33e 100644
--- a/Assets/Scripts/PlayerCharacterController.cs
+++ b/Assets/Scripts/PlayerCharacterController.cs
@@ -161,7 +161,7 @@ public partial class PlayerCharacterController : MonoBehaviour
public bool IsSprinting => _sprinting;
/// True while stuck to a ceiling (wall-climb mode with a downward-facing surface normal).
- public bool IsOnCeiling => _isWallClimbing && _wallNormal.y < CeilingNormalThreshold;
+ public bool IsOnCeiling => _isWallClimbing && _onCeiling;
///
/// True while grounded and actually moving above the run threshold (the ground-locomotion state).
@@ -215,6 +215,14 @@ public partial class PlayerCharacterController : MonoBehaviour
// than a wall (up/down model) once its normal points down past this threshold (~>120 deg
// from up); between that and wallMinAngle it is treated as a wall.
private const float CeilingNormalThreshold = -0.5f;
+ // Hysteresis band around the threshold: in the inner wall/ceiling corner the capsule touches
+ // both surfaces, so the per-frame contact normal mixes horizontal (wall) and downward (ceiling)
+ // and its y crosses -0.5 back and forth -- flipping the climb mode and snapping the body's yaw
+ // between "face the wall" and "face the move direction". Latch the class and only flip when the
+ // normal is unambiguously past one edge of the band.
+ private const float CeilingEnterThreshold = -0.6f; // wall -> ceiling only when clearly down-facing
+ private const float CeilingExitThreshold = -0.4f; // ceiling -> wall only when clearly not down-facing
+ private bool _onCeiling; // latched climb-surface class (hysteretic; see thresholds above)
private bool _isWallClimbing;
private Vector3 _wallNormal;
private Vector3 _wallNormalAccum; // summed wall normals during the latest Move
@@ -328,9 +336,12 @@ public partial class PlayerCharacterController : MonoBehaviour
UpdateWallClimbState(moveDir);
- // Latch the wall normal for this frame, then reset the per-frame contact
- // accumulators (OnControllerColliderHit refills them during Move).
- if (_wallContactThisFrame && _wallNormalAccum.sqrMagnitude > 1e-6f)
+ // Latch the wall normal from contacts ONLY while not yet climbing (the grab needs it).
+ // During a climb the probes own _wallNormal: the per-frame contact blend mixes ceiling
+ // and wall normals in an inner corner (e.g. ~(0,-0.97,-0.26)), which would fight the
+ // probed surface and flip the wall/ceiling classification frame to frame. Reset the
+ // accumulators regardless (OnControllerColliderHit refills them during Move).
+ if (!_isWallClimbing && _wallContactThisFrame && _wallNormalAccum.sqrMagnitude > 1e-6f)
_wallNormal = _wallNormalAccum.normalized;
_wallContactThisFrame = false;
_wallNormalAccum = Vector3.zero;
@@ -471,6 +482,7 @@ public partial class PlayerCharacterController : MonoBehaviour
{
_isWallClimbing = true;
_wallNormal = _wallNormalAccum.normalized;
+ _onCeiling = _wallNormal.y < CeilingNormalThreshold;
_lostContactTimer = 0f;
_verticalVelocity = 0f;
_slideSpeed = 0f;
@@ -506,7 +518,8 @@ public partial class PlayerCharacterController : MonoBehaviour
return;
}
- bool onCeiling = _wallNormal.y < CeilingNormalThreshold;
+ UpdateCeilingClass();
+ bool onCeiling = _onCeiling;
// Jump detaches. Off a wall it pushes straight up; off a ceiling it just lets go and
// drops. Re-grab stays blocked appropriately by UpdateWallClimbState's push-in test.
@@ -524,16 +537,50 @@ public partial class PlayerCharacterController : MonoBehaviour
&& ceilHeadGap <= ceilingGrabDistance)
{
_wallNormal = ceilNormal;
+ _onCeiling = true;
onCeiling = true;
_wallOffset = 0f;
}
- bool found = ProbeWall(out Vector3 probedNormal, out float gap);
- if (found)
+ // Re-measure the surface each frame, probing the kind we're committed to. On a ceiling
+ // probe straight UP (ProbeCeiling), NOT along -_wallNormal: the ceiling normal tilts a
+ // little toward the wall in an inner corner, so the -_wallNormal ray would clip the wall
+ // and demote us to "wall" — then the hand-off re-promotes next frame, flipping the body's
+ // facing ~9 deg every frame (the corner twitch). Stay latched on the ceiling until it is
+ // genuinely gone overhead; only then fall back to the wall probe.
+ bool found;
+ float gap;
+ if (onCeiling)
{
- _wallNormal = probedNormal;
- _lostContactTimer = 0f;
- onCeiling = _wallNormal.y < CeilingNormalThreshold;
+ found = ProbeCeiling(out Vector3 ceilNormal2, out float ceilGap);
+ if (found)
+ {
+ _wallNormal = ceilNormal2;
+ gap = ceilGap;
+ _lostContactTimer = 0f;
+ }
+ else
+ {
+ found = ProbeWall(out Vector3 wallNormal, out gap);
+ if (found)
+ {
+ _wallNormal = wallNormal;
+ _lostContactTimer = 0f;
+ }
+ UpdateCeilingClass();
+ onCeiling = _onCeiling;
+ }
+ }
+ else
+ {
+ found = ProbeWall(out Vector3 probedNormal, out gap);
+ if (found)
+ {
+ _wallNormal = probedNormal;
+ _lostContactTimer = 0f;
+ UpdateCeilingClass();
+ onCeiling = _onCeiling;
+ }
}
// Topped out onto a slope: the climbed face has flattened into something walkable (no
@@ -713,6 +760,23 @@ public partial class PlayerCharacterController : MonoBehaviour
}
}
+ ///
+ /// Updates the latched wall/ceiling classification with hysteresis on the surface normal's
+ /// downward tilt. In the inner corner the live normal jitters across the single threshold; the
+ /// band keeps the climb mode (and the body yaw it drives) from flipping frame to frame.
+ ///
+ private void UpdateCeilingClass()
+ {
+ if (_onCeiling)
+ {
+ if (_wallNormal.y > CeilingExitThreshold) _onCeiling = false;
+ }
+ else if (_wallNormal.y < CeilingEnterThreshold)
+ {
+ _onCeiling = true;
+ }
+ }
+
///
/// 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
@@ -1009,6 +1073,7 @@ public partial class PlayerCharacterController : MonoBehaviour
private void ExitWallClimb()
{
_isWallClimbing = false;
+ _onCeiling = false;
_lostContactTimer = 0f;
_verticalVelocity = 0f;
// Hand the current lean off to the residual so it eases home (EaseMeshOffsetToRest)