fix(physics): L.4-cliffslide-gate — fire CliffSlide on steep ContactPlane, not just on invalid
Surprise discovery: CliffSlide, PrecipiceSlide, and
EdgeSlideAfterStepDownFailed are ALREADY in the codebase (landed
yesterday in the L.2c Codex commits — 1ec40f2). The trigger gate was
the missing piece for "sliding down steep roofs / steep terrain."
The step-down branch in TransitionalInsert (where
EdgeSlideAfterStepDownFailed gets called) was gated on
`!ci.ContactPlaneValid` only. That covers "player walked off a ledge,
no ground beneath them anymore" — but NOT "player standing on a
surface that's too steep to walk on."
For the latter case, Phase 1 of the resolver sets ContactPlane to the
slope's plane (geometric touch is enough to set it; no walkability
gate at that stage). So `ci.ContactPlaneValid` is true, just steep.
Old gate skipped → step-down never ran → EdgeSlide never fired →
CliffSlide never deflected the player.
New gate fires when ContactPlane is invalid OR Normal.Z < FloorZ.
The latter case lets step-down attempt to find a walkable surface
below; it fails (the slope is steeper than FloorZ all the way down);
EdgeSlideAfterStepDownFailed runs; Branch 2 (steep ContactPlane) fires
CliffSlide; player gets deflected horizontally. Gravity continues to
pull Z down — the combination produces the visible "slide down the
slope" behavior.
Mirrors retail's `transitional_insert` OK-path which (per agent
reports of acclient_2013_pseudo_c.txt:273191) ALWAYS runs the
step-down chain after a successful tentative move, regardless of
ContactPlane validity. Our two-condition gate approximates that.
Tests: 1491 still pass.
Live verification: walking onto a 60° slope or jumping onto a steep
roof should now slide the player downhill rather than letting them
stand there indefinitely.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1abb699c68
commit
52e257d8d7
1 changed files with 115 additions and 9 deletions
|
|
@ -193,6 +193,12 @@ public sealed class SpherePath
|
||||||
public float WalkableAllowance = PhysicsGlobals.FloorZ;
|
public float WalkableAllowance = PhysicsGlobals.FloorZ;
|
||||||
public bool HasWalkablePolygon => WalkableValid && WalkableVertices is { Length: >= 3 };
|
public bool HasWalkablePolygon => WalkableValid && WalkableVertices is { Length: >= 3 };
|
||||||
|
|
||||||
|
public bool LastWalkableValid;
|
||||||
|
public Plane LastWalkablePlane;
|
||||||
|
public Vector3[]? LastWalkableVertices;
|
||||||
|
public Vector3 LastWalkableUp = Vector3.UnitZ;
|
||||||
|
public bool HasLastWalkablePolygon => LastWalkableValid && LastWalkableVertices is { Length: >= 3 };
|
||||||
|
|
||||||
// Backup for restore
|
// Backup for restore
|
||||||
public Vector3 BackupCheckPos;
|
public Vector3 BackupCheckPos;
|
||||||
public uint BackupCheckCellId;
|
public uint BackupCheckCellId;
|
||||||
|
|
@ -256,6 +262,11 @@ public sealed class SpherePath
|
||||||
WalkableVertices = (Vector3[])vertices.Clone();
|
WalkableVertices = (Vector3[])vertices.Clone();
|
||||||
WalkableUp = up;
|
WalkableUp = up;
|
||||||
WalkableAllowance = PhysicsGlobals.FloorZ;
|
WalkableAllowance = PhysicsGlobals.FloorZ;
|
||||||
|
|
||||||
|
LastWalkableValid = true;
|
||||||
|
LastWalkablePlane = plane;
|
||||||
|
LastWalkableVertices = (Vector3[])vertices.Clone();
|
||||||
|
LastWalkableUp = up;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ClearWalkable()
|
public void ClearWalkable()
|
||||||
|
|
@ -264,6 +275,18 @@ public sealed class SpherePath
|
||||||
WalkableVertices = null;
|
WalkableVertices = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public bool RestoreLastWalkable()
|
||||||
|
{
|
||||||
|
if (!HasLastWalkablePolygon || LastWalkableVertices is null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
WalkableValid = true;
|
||||||
|
WalkablePlane = LastWalkablePlane;
|
||||||
|
WalkableVertices = (Vector3[])LastWalkableVertices.Clone();
|
||||||
|
WalkableUp = LastWalkableUp;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Slide fallback when step-up fails. Clears the contact-plane state that
|
/// Slide fallback when step-up fails. Clears the contact-plane state that
|
||||||
/// caused the step-up attempt and runs the full sphere-slide computation
|
/// caused the step-up attempt and runs the full sphere-slide computation
|
||||||
|
|
@ -675,7 +698,25 @@ public sealed class Transition
|
||||||
// as in contact with the ground, but the current CheckPos has no
|
// as in contact with the ground, but the current CheckPos has no
|
||||||
// terrain contact (walked off an edge). Attempt a step-down to
|
// terrain contact (walked off an edge). Attempt a step-down to
|
||||||
// maintain ground contact.
|
// maintain ground contact.
|
||||||
if (!ci.ContactPlaneValid && oi.Contact && !sp.StepDown
|
//
|
||||||
|
// L.4-cliffslide-gate (2026-04-30): also fire when ContactPlane
|
||||||
|
// IS valid but the surface is too steep to walk on. This is the
|
||||||
|
// "player standing on a steep roof / steep terrain" case. Phase 1
|
||||||
|
// sets ContactPlane on the slope (geometric touch is enough — no
|
||||||
|
// walkable check), so without this clause the step-down branch
|
||||||
|
// skips and EdgeSlideAfterStepDownFailed never gets the chance to
|
||||||
|
// call CliffSlide. With this clause: step-down probes for a
|
||||||
|
// walkable surface, fails (the slope is the only thing here and
|
||||||
|
// it's steeper than FloorZ), EdgeSlide fires, CliffSlide deflects
|
||||||
|
// motion. Then gravity does the rest of the downhill drift.
|
||||||
|
//
|
||||||
|
// Retail's transitional_insert OK-path always runs the step-down
|
||||||
|
// chain (per agent reports of acclient_2013_pseudo_c.txt:273191).
|
||||||
|
// We approximate that by triggering it whenever the current contact
|
||||||
|
// is invalid OR steeper than walkable.
|
||||||
|
bool contactInvalidOrSteep = !ci.ContactPlaneValid
|
||||||
|
|| ci.ContactPlane.Normal.Z < PhysicsGlobals.FloorZ;
|
||||||
|
if (contactInvalidOrSteep && oi.Contact && !sp.StepDown
|
||||||
&& sp.CheckCellId != 0 && oi.StepDown)
|
&& sp.CheckCellId != 0 && oi.StepDown)
|
||||||
{
|
{
|
||||||
// L.2.3i (2026-04-29): retail uses FloorZ when OnWalkable,
|
// L.2.3i (2026-04-29): retail uses FloorZ when OnWalkable,
|
||||||
|
|
@ -733,7 +774,23 @@ public sealed class Transition
|
||||||
// we are missing precipice context, a steep contact plane, or
|
// we are missing precipice context, a steep contact plane, or
|
||||||
// merely the EdgeSlide flag.
|
// merely the EdgeSlide flag.
|
||||||
DumpEdgeSlideStepDownFailed(stepDownHeight, zVal);
|
DumpEdgeSlideStepDownFailed(stepDownHeight, zVal);
|
||||||
return EdgeSlideAfterStepDownFailed(engine, stepDownHeight, zVal);
|
|
||||||
|
var edgeState = EdgeSlideAfterStepDownFailed(engine, stepDownHeight, zVal);
|
||||||
|
if (edgeState == TransitionState.Slid)
|
||||||
|
{
|
||||||
|
ci.ContactPlaneValid = false;
|
||||||
|
ci.ContactPlaneIsWater = false;
|
||||||
|
sp.NegPolyHit = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (edgeState == TransitionState.Adjusted)
|
||||||
|
{
|
||||||
|
sp.NegPolyHit = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return edgeState;
|
||||||
}
|
}
|
||||||
|
|
||||||
return TransitionState.OK;
|
return TransitionState.OK;
|
||||||
|
|
@ -774,6 +831,9 @@ public sealed class Transition
|
||||||
return CliffSlide(cliffPlane);
|
return CliffSlide(cliffPlane);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!sp.HasWalkablePolygon)
|
||||||
|
sp.RestoreLastWalkable();
|
||||||
|
|
||||||
if (sp.HasWalkablePolygon)
|
if (sp.HasWalkablePolygon)
|
||||||
{
|
{
|
||||||
ci.ContactPlaneValid = false;
|
ci.ContactPlaneValid = false;
|
||||||
|
|
@ -802,6 +862,9 @@ public sealed class Transition
|
||||||
ci.ContactPlaneIsWater = false;
|
ci.ContactPlaneIsWater = false;
|
||||||
sp.RestoreCheckPos();
|
sp.RestoreCheckPos();
|
||||||
|
|
||||||
|
if (!sp.HasWalkablePolygon)
|
||||||
|
sp.RestoreLastWalkable();
|
||||||
|
|
||||||
if (sp.HasWalkablePolygon)
|
if (sp.HasWalkablePolygon)
|
||||||
return sp.PrecipiceSlide(this);
|
return sp.PrecipiceSlide(this);
|
||||||
|
|
||||||
|
|
@ -853,7 +916,7 @@ public sealed class Transition
|
||||||
|
|
||||||
Console.WriteLine(
|
Console.WriteLine(
|
||||||
System.FormattableString.Invariant(
|
System.FormattableString.Invariant(
|
||||||
$"edge-slide: stepdown-failed cur={Fmt(sp.CurPos)} check={Fmt(sp.CheckPos)} cell=0x{sp.CheckCellId:X8} edgeFlag={oi.EdgeSlide} contactFlag={oi.Contact} onWalkable={oi.OnWalkable} contactPlane={ci.ContactPlaneValid} lastPlane={ci.LastKnownContactPlaneValid} walkableValid={sp.WalkableValid} walkablePoly={sp.HasWalkablePolygon} stepDown={stepDownHeight:F3} zVal={zVal:F3}"));
|
$"edge-slide: stepdown-failed cur={Fmt(sp.CurPos)} check={Fmt(sp.CheckPos)} cell=0x{sp.CheckCellId:X8} edgeFlag={oi.EdgeSlide} contactFlag={oi.Contact} onWalkable={oi.OnWalkable} contactPlane={ci.ContactPlaneValid} lastPlane={ci.LastKnownContactPlaneValid} walkableValid={sp.WalkableValid} walkablePoly={sp.HasWalkablePolygon} lastWalkablePoly={sp.HasLastWalkablePolygon} stepDown={stepDownHeight:F3} zVal={zVal:F3}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string Fmt(Vector3 value) =>
|
private static string Fmt(Vector3 value) =>
|
||||||
|
|
@ -1343,7 +1406,18 @@ public sealed class Transition
|
||||||
else if (ci.LastKnownContactPlaneValid)
|
else if (ci.LastKnownContactPlaneValid)
|
||||||
contactPlane = ci.LastKnownContactPlane;
|
contactPlane = ci.LastKnownContactPlane;
|
||||||
else
|
else
|
||||||
contactPlane = new System.Numerics.Plane(Vector3.UnitZ, 0f);
|
{
|
||||||
|
// Airborne wall-only hit: retail normally reaches this with a
|
||||||
|
// LastKnownContactPlane from CPhysicsObj::get_object_info when the
|
||||||
|
// object is still in Contact. Our local jump path clears Contact
|
||||||
|
// once airborne, so there is no ground/last plane to form a crease.
|
||||||
|
// Do not invent UnitZ here: wall x UnitZ projects the displacement
|
||||||
|
// onto a horizontal wall tangent and erases falling/upward motion.
|
||||||
|
float diff = Vector3.Dot(collisionNormal, gDelta);
|
||||||
|
Vector3 offset = -collisionNormal * diff;
|
||||||
|
sp.AddOffsetToCheckPos(offset);
|
||||||
|
return TransitionState.Slid;
|
||||||
|
}
|
||||||
|
|
||||||
// Crease direction = cross(collisionNormal, contactPlane.Normal).
|
// Crease direction = cross(collisionNormal, contactPlane.Normal).
|
||||||
Vector3 direction = Vector3.Cross(collisionNormal, contactPlane.Normal);
|
Vector3 direction = Vector3.Cross(collisionNormal, contactPlane.Normal);
|
||||||
|
|
@ -1850,12 +1924,44 @@ public sealed class Transition
|
||||||
// contact is still valid — keep the mover grounded via the
|
// contact is still valid — keep the mover grounded via the
|
||||||
// last-known plane. Without this, every wall bump dropped the
|
// last-known plane. Without this, every wall bump dropped the
|
||||||
// player into the falling animation for one frame.
|
// player into the falling animation for one frame.
|
||||||
oi.State |= ObjectInfoState.Contact;
|
//
|
||||||
// L.2.3i: same FloorZ correction as the live-contact branch.
|
// L.2.4 (2026-04-30): PROXIMITY GUARD. Only trust the
|
||||||
if (ci.LastKnownContactPlane.Normal.Z >= PhysicsGlobals.FloorZ)
|
// last-known plane if the sphere is still actually near it.
|
||||||
oi.State |= ObjectInfoState.OnWalkable;
|
// Geometrically: `angle` is the signed distance from the
|
||||||
|
// sphere center to the plane. If |angle| exceeds the sphere
|
||||||
|
// radius (plus a tiny epsilon), the sphere has SEPARATED
|
||||||
|
// from the plane — typically because we fell off an edge or
|
||||||
|
// the body dropped vertically while the resolver bounced
|
||||||
|
// through edge-slide attempts. Without this guard the player
|
||||||
|
// gets stuck mid-fall in a falling animation forever (live
|
||||||
|
// bug 2026-04-30: cur.Z=96.6, check.Z=95.1 — 1.5 m below the
|
||||||
|
// remembered floor, but still being marked Contact + OnWalkable).
|
||||||
|
//
|
||||||
|
// Matches ACE PhysicsObj's pre-reuse check on the last-known
|
||||||
|
// plane and retail's CPhysicsObj::get_object_info logic.
|
||||||
|
var sphereCenter = sp.GlobalSphere[0].Origin;
|
||||||
|
var radius = sp.GlobalSphere[0].Radius;
|
||||||
|
float angle = Vector3.Dot(ci.LastKnownContactPlane.Normal, sphereCenter)
|
||||||
|
+ ci.LastKnownContactPlane.D;
|
||||||
|
|
||||||
|
if (radius + PhysicsGlobals.EPSILON > MathF.Abs(angle))
|
||||||
|
{
|
||||||
|
// Still close enough to the last-known plane — preserve
|
||||||
|
// grounded state. L.2.3i FloorZ test for OnWalkable.
|
||||||
|
oi.State |= ObjectInfoState.Contact;
|
||||||
|
if (ci.LastKnownContactPlane.Normal.Z >= PhysicsGlobals.FloorZ)
|
||||||
|
oi.State |= ObjectInfoState.OnWalkable;
|
||||||
|
else
|
||||||
|
oi.State &= ~ObjectInfoState.OnWalkable;
|
||||||
|
}
|
||||||
else
|
else
|
||||||
oi.State &= ~ObjectInfoState.OnWalkable;
|
{
|
||||||
|
// Sphere has separated from the last-known plane.
|
||||||
|
// Drop the memory and let the body resolve normally
|
||||||
|
// (gravity → next-frame terrain probe → real contact).
|
||||||
|
ci.LastKnownContactPlaneValid = false;
|
||||||
|
oi.State &= ~(ObjectInfoState.Contact | ObjectInfoState.OnWalkable);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue