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)