fix(physics): #32 L.2c precipice edge-slide context
Port the first retail precipice-slide slice from named retail/ACE: terrain and BSP walkable hits now preserve polygon vertices, failed step-down edges back-probe to rediscover the walkable polygon, and edge-slide can run precipice/cliff slide instead of only hard-stopping. Adds pseudocode anchors plus regression coverage for terrain polygon context and loaded-terrain boundary edge-slide. Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
parent
1ec40f2a4f
commit
261322b48e
10 changed files with 559 additions and 60 deletions
|
|
@ -189,19 +189,22 @@ step-down boundaries, retail often slides along the boundary. acdream still
|
|||
hard-blocks or accepts too much in several of these cases.
|
||||
|
||||
**Root cause / status:** Tracked under Phase L.2c. Wall-adjacent
|
||||
`step_up_slide` now feels acceptable in live testing. L.2c plumbing now passes
|
||||
the retail-default `EdgeSlide` flag into local and remote movement and logs
|
||||
failed step-down edge cases behind `ACDREAM_DUMP_EDGE_SLIDE=1`. Remaining gap:
|
||||
preserve walkable polygon context for `precipice_slide` and finish
|
||||
`cliff_slide` / `NegPolyHit` dispatch. Named retail anchors include
|
||||
`CTransition::edge_slide`, `CTransition::cliff_slide`,
|
||||
`SPHEREPATH::precipice_slide`, and `SPHEREPATH::step_up_slide`.
|
||||
`step_up_slide` now feels acceptable in live testing. Local/remote movement
|
||||
passes the retail-default `EdgeSlide` flag. The first precipice-slide slice now
|
||||
preserves terrain/BSP walkable polygon vertices and runs the retail back-probe
|
||||
before `SPHEREPATH::precipice_slide`; `ACDREAM_DUMP_EDGE_SLIDE=1` now reports
|
||||
whether a failed step-down had polygon context. Remaining gaps: real-DAT
|
||||
building-edge fixtures, fuller `cliff_slide` coverage, and `NegPolyHit`
|
||||
dispatch. Named retail anchors include `CTransition::edge_slide`,
|
||||
`CTransition::cliff_slide`, `SPHEREPATH::precipice_slide`, and
|
||||
`SPHEREPATH::step_up_slide`.
|
||||
|
||||
**Files:** `src/AcDream.Core/Physics/TransitionTypes.cs`,
|
||||
`src/AcDream.Core/Physics/BSPQuery.cs`,
|
||||
`tests/AcDream.Core.Tests/`.
|
||||
|
||||
**Research:** `docs/plans/2026-04-29-movement-collision-conformance.md`.
|
||||
**Research:** `docs/plans/2026-04-29-movement-collision-conformance.md`,
|
||||
`docs/research/2026-04-30-precipice-slide-pseudocode.md`.
|
||||
|
||||
**Acceptance:** Synthetic and real-DAT tests cover wall-slide, roof-edge slide,
|
||||
cliff/precipice slide, failed step-up/step-down, and the jump-clears-edge case.
|
||||
|
|
|
|||
|
|
@ -118,6 +118,13 @@ precipices.
|
|||
edge, walkable, and collision rules; jumping clears `OnWalkable` and only
|
||||
succeeds when the airborne path actually clears geometry.
|
||||
|
||||
Current shipped slice (2026-04-30): wall-adjacent `step_up_slide` feels
|
||||
acceptable in live testing; player/remote movers pass `EdgeSlide`; terrain and
|
||||
BSP step-down/find-walkable now preserve walkable polygon vertices; failed
|
||||
step-down edge cases perform the retail back-probe before
|
||||
`SPHEREPATH::precipice_slide`. Remaining L.2c work is real-DAT building-edge
|
||||
fixtures, fuller `cliff_slide` coverage, and `NegPolyHit` dispatch.
|
||||
|
||||
### L.2d - Shape Fidelity: Sphere / CylSphere / Building Objects
|
||||
|
||||
Goal: object collisions use retail shape semantics, not one simplified
|
||||
|
|
|
|||
110
docs/research/2026-04-30-precipice-slide-pseudocode.md
Normal file
110
docs/research/2026-04-30-precipice-slide-pseudocode.md
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
# Precipice Slide Pseudocode
|
||||
|
||||
Date: 2026-04-30
|
||||
|
||||
Phase: L.2c - Movement & Collision Conformance
|
||||
|
||||
## Retail Anchors
|
||||
|
||||
- Named retail: `CTransition::edge_slide`, `acclient_2013_pseudo_c.txt:273001`
|
||||
- Named retail: `CTransition::cliff_slide`, `acclient_2013_pseudo_c.txt:272397`
|
||||
- Named retail: `SPHEREPATH::precipice_slide`, `acclient_2013_pseudo_c.txt:274316`
|
||||
- ACE cross-check: `Transition.EdgeSlide`, `Transition.CliffSlide`,
|
||||
`SpherePath.PrecipiceSlide`
|
||||
- ACE cross-check: `Polygon.find_crossed_edge`
|
||||
|
||||
## Edge-Slide Flow
|
||||
|
||||
When a grounded mover has contact state but the next candidate position has no
|
||||
walkable surface within step-down reach, retail does not immediately accept the
|
||||
fall or hard-stop. It enters `CTransition::edge_slide`.
|
||||
|
||||
```text
|
||||
edge_slide(transitionState, stepDownHeight, walkableZ):
|
||||
if object is not OnWalkable or EdgeSlide is disabled:
|
||||
clear walkable
|
||||
restore candidate check position
|
||||
clear current contact plane
|
||||
mark cell array valid
|
||||
transitionState = OK
|
||||
return handled
|
||||
|
||||
if current collision has a contact plane below walkableZ:
|
||||
transitionState = cliff_slide(contact plane)
|
||||
clear walkable and restore candidate check position
|
||||
clear current contact plane
|
||||
return not-final
|
||||
|
||||
if sphere_path.walkable exists:
|
||||
transitionState = precipice_slide()
|
||||
clear current contact plane and restore candidate check position
|
||||
return transitionState == Collided
|
||||
|
||||
if current collision has any contact plane:
|
||||
clear walkable
|
||||
restore candidate check position
|
||||
clear current contact plane
|
||||
transitionState = OK
|
||||
return handled
|
||||
|
||||
move CheckPos back from failed candidate to the current sphere center
|
||||
step_down(stepDownHeight, walkableZ) to rediscover the walkable polygon
|
||||
clear current contact plane
|
||||
restore the failed candidate check position
|
||||
|
||||
if a walkable polygon was discovered:
|
||||
set walkable_check_pos from the candidate sphere in walkable space
|
||||
transitionState = precipice_slide()
|
||||
return transitionState == Collided
|
||||
|
||||
clear walkable
|
||||
mark cell array valid
|
||||
transitionState = Collided
|
||||
return handled
|
||||
```
|
||||
|
||||
## Precipice Slide
|
||||
|
||||
`SPHEREPATH::precipice_slide` is the edge-normal half of edge-slide. The crucial
|
||||
input is the walkable polygon that the mover just left; without that polygon,
|
||||
there is no crossed edge to slide along.
|
||||
|
||||
```text
|
||||
precipice_slide():
|
||||
normal = zero
|
||||
found = walkable.find_crossed_edge(walkable_check_pos, walkable_up, normal)
|
||||
|
||||
if not found:
|
||||
clear walkable
|
||||
return Collided
|
||||
|
||||
clear walkable
|
||||
step_up = false
|
||||
|
||||
normal = walkable_pos.frame.LocalToGlobalVec(normal)
|
||||
|
||||
blockOffset = LandDefs.GetBlockOffset(curr cell, check cell)
|
||||
movementOffset = global_sphere.center - global_curr_center.center + blockOffset
|
||||
|
||||
if dot(normal, movementOffset) > 0:
|
||||
normal = -normal
|
||||
|
||||
return global_sphere.slide_sphere(transition, normal, global_curr_center.center)
|
||||
```
|
||||
|
||||
## Porting Notes
|
||||
|
||||
acdream already had the `Polygon.find_crossed_edge` math inside `BSPQuery`, but
|
||||
the live diagnostic showed `walkableValid=False` at the failed step-down edge
|
||||
branch. The port must therefore preserve or rediscover the walkable polygon,
|
||||
not just pass the `EdgeSlide` flag.
|
||||
|
||||
For the first L.2c slice:
|
||||
|
||||
- terrain supplies the exact current triangle vertices alongside its plane;
|
||||
- BSP step-down/find-walkable records world-space polygon vertices when the
|
||||
caller supplies the object's world origin;
|
||||
- the failed step-down edge branch performs the retail back-probe to current
|
||||
position before calling precipice slide;
|
||||
- `CELLARRAY`, full `cell_bsp` ownership, and cross-cell building portals remain
|
||||
L.2e work.
|
||||
|
|
@ -62,5 +62,13 @@ InputDispatcher / PlayerMovementController
|
|||
- 2026-04-30: L.2c edge-slide plumbing. User live-tested wall-adjacent slide as
|
||||
acceptable. Local player and remote dead-reckoning now pass retail-default
|
||||
`ObjectInfoState.EdgeSlide`; `ACDREAM_DUMP_EDGE_SLIDE=1` logs failed
|
||||
step-down edge cases so the next slice can distinguish missing walkable
|
||||
polygon context from cliff-slide/NegPolyHit gaps.
|
||||
step-down edge cases and now reports whether walkable polygon context is
|
||||
present before cliff/precipice handling.
|
||||
- 2026-04-30: L.2c precipice-slide context. Named retail
|
||||
`SPHEREPATH::precipice_slide` and ACE `Polygon.find_crossed_edge` are now
|
||||
captured in `docs/research/2026-04-30-precipice-slide-pseudocode.md`.
|
||||
Terrain supplies exact walkable triangle vertices, BSP step-down/find-walkable
|
||||
stores world-space walkable vertices for static object tops, and failed
|
||||
step-down edge cases run the retail back-probe before precipice slide.
|
||||
`cliff_slide` has a first port, but `NegPolyHit`, `CELLARRAY`, full
|
||||
`cell_bsp`, and real-DAT building portal conformance remain open L.2 work.
|
||||
|
|
|
|||
|
|
@ -377,30 +377,33 @@ public static class BSPQuery
|
|||
///
|
||||
/// <para>ACE: Polygon.cs find_crossed_edge.</para>
|
||||
/// </summary>
|
||||
private static bool FindCrossedEdge(
|
||||
ResolvedPolygon poly,
|
||||
CollisionSphere sphere,
|
||||
Vector3 up,
|
||||
ref Vector3 normal)
|
||||
internal static bool FindCrossedEdge(
|
||||
Plane polyPlane,
|
||||
ReadOnlySpan<Vector3> verts,
|
||||
Vector3 sphereCenter,
|
||||
Vector3 up,
|
||||
out Vector3 normal)
|
||||
{
|
||||
float angleUp = Vector3.Dot(poly.Plane.Normal, up);
|
||||
normal = Vector3.Zero;
|
||||
|
||||
float angleUp = Vector3.Dot(polyPlane.Normal, up);
|
||||
if (MathF.Abs(angleUp) < PhysicsGlobals.EPSILON) return false;
|
||||
|
||||
float angle = (Vector3.Dot(poly.Plane.Normal, sphere.Center) + poly.Plane.D) / angleUp;
|
||||
var center = sphere.Center - up * angle;
|
||||
float angle = (Vector3.Dot(polyPlane.Normal, sphereCenter) + polyPlane.D) / angleUp;
|
||||
var center = sphereCenter - up * angle;
|
||||
|
||||
int n = poly.Vertices.Length;
|
||||
int n = verts.Length;
|
||||
int prevIdx = n - 1;
|
||||
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
var v = poly.Vertices[i];
|
||||
var lv = poly.Vertices[prevIdx];
|
||||
var v = verts[i];
|
||||
var lv = verts[prevIdx];
|
||||
prevIdx = i;
|
||||
|
||||
var edge = v - lv;
|
||||
var disp = center - lv;
|
||||
var cross = Vector3.Cross(poly.Plane.Normal, edge);
|
||||
var cross = Vector3.Cross(polyPlane.Normal, edge);
|
||||
|
||||
if (Vector3.Dot(disp, cross) < 0f)
|
||||
{
|
||||
|
|
@ -412,6 +415,47 @@ public static class BSPQuery
|
|||
return false;
|
||||
}
|
||||
|
||||
private static bool FindCrossedEdge(
|
||||
ResolvedPolygon poly,
|
||||
CollisionSphere sphere,
|
||||
Vector3 up,
|
||||
ref Vector3 normal)
|
||||
{
|
||||
if (!FindCrossedEdge(poly.Plane, poly.Vertices, sphere.Center, up, out var crossedNormal))
|
||||
return false;
|
||||
|
||||
normal = crossedNormal;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static Vector3 TransformNormal(Vector3 normal, Quaternion localToWorld)
|
||||
{
|
||||
var worldNormal = Vector3.Transform(normal, localToWorld);
|
||||
return worldNormal.LengthSquared() > PhysicsGlobals.EpsilonSq
|
||||
? Vector3.Normalize(worldNormal)
|
||||
: Vector3.UnitZ;
|
||||
}
|
||||
|
||||
private static Vector3[] TransformVertices(
|
||||
ReadOnlySpan<Vector3> vertices,
|
||||
Quaternion localToWorld,
|
||||
float scale,
|
||||
Vector3 worldOrigin)
|
||||
{
|
||||
var result = new Vector3[vertices.Length];
|
||||
for (int i = 0; i < vertices.Length; i++)
|
||||
result[i] = Vector3.Transform(vertices[i] * scale, localToWorld) + worldOrigin;
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Plane BuildWorldPlane(Vector3 worldNormal, ReadOnlySpan<Vector3> worldVertices)
|
||||
{
|
||||
float d = worldVertices.Length > 0
|
||||
? -Vector3.Dot(worldNormal, worldVertices[0])
|
||||
: 0f;
|
||||
return new Plane(worldNormal, d);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// adjust_to_placement_poly
|
||||
// ACE: Polygon.cs adjust_to_placement_poly
|
||||
|
|
@ -1037,7 +1081,8 @@ public static class BSPQuery
|
|||
CollisionSphere checkPos,
|
||||
Vector3 up,
|
||||
float scale,
|
||||
Quaternion localToWorld = default)
|
||||
Quaternion localToWorld = default,
|
||||
Vector3 worldOrigin = default)
|
||||
{
|
||||
if (localToWorld == default) localToWorld = Quaternion.Identity;
|
||||
|
||||
|
|
@ -1061,14 +1106,12 @@ public static class BSPQuery
|
|||
var offset = Vector3.Transform(adjusted, localToWorld) * scale;
|
||||
path.AddOffsetToCheckPos(offset);
|
||||
|
||||
var worldNormal = Vector3.Transform(polyHit.Plane.Normal, localToWorld);
|
||||
collisions.SetContactPlane(
|
||||
new Plane(worldNormal, polyHit.Plane.D * scale),
|
||||
path.CheckCellId, false);
|
||||
var worldNormal = TransformNormal(polyHit.Plane.Normal, localToWorld);
|
||||
var worldVertices = TransformVertices(polyHit.Vertices, localToWorld, scale, worldOrigin);
|
||||
var worldPlane = BuildWorldPlane(worldNormal, worldVertices);
|
||||
collisions.SetContactPlane(worldPlane, path.CheckCellId, false);
|
||||
|
||||
path.WalkableValid = true;
|
||||
path.WalkablePlane = new Plane(worldNormal, polyHit.Plane.D * scale);
|
||||
path.WalkableAllowance = PhysicsGlobals.FloorZ;
|
||||
path.SetWalkable(worldPlane, worldVertices, Vector3.UnitZ);
|
||||
|
||||
return TransitionState.Adjusted;
|
||||
}
|
||||
|
|
@ -1359,7 +1402,8 @@ public static class BSPQuery
|
|||
Vector3 localSpaceZ,
|
||||
float scale,
|
||||
Quaternion localToWorld = default,
|
||||
PhysicsEngine? engine = null)
|
||||
PhysicsEngine? engine = null,
|
||||
Vector3 worldOrigin = default)
|
||||
{
|
||||
if (root is null) return TransitionState.OK;
|
||||
// Default quaternion (0,0,0,0) → treat as identity
|
||||
|
|
@ -1410,7 +1454,7 @@ public static class BSPQuery
|
|||
// ----------------------------------------------------------------
|
||||
if (path.StepDown)
|
||||
{
|
||||
return StepSphereDown(root, resolved, transition, sphere0, localSpaceZ, scale, localToWorld);
|
||||
return StepSphereDown(root, resolved, transition, sphere0, localSpaceZ, scale, localToWorld, worldOrigin);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
|
|
@ -1433,14 +1477,12 @@ public static class BSPQuery
|
|||
var worldOffset = L2W(localOffset) * scale;
|
||||
path.AddOffsetToCheckPos(worldOffset);
|
||||
|
||||
var worldNormal = L2W(hitPoly.Plane.Normal);
|
||||
collisions.SetContactPlane(
|
||||
new Plane(worldNormal, hitPoly.Plane.D * scale),
|
||||
path.CheckCellId, false);
|
||||
var worldNormal = TransformNormal(hitPoly.Plane.Normal, localToWorld);
|
||||
var worldVertices = TransformVertices(hitPoly.Vertices, localToWorld, scale, worldOrigin);
|
||||
var worldPlane = BuildWorldPlane(worldNormal, worldVertices);
|
||||
collisions.SetContactPlane(worldPlane, path.CheckCellId, false);
|
||||
|
||||
path.WalkableValid = true;
|
||||
path.WalkablePlane = new Plane(worldNormal, hitPoly.Plane.D * scale);
|
||||
path.WalkableAllowance = PhysicsGlobals.FloorZ;
|
||||
path.SetWalkable(worldPlane, worldVertices, Vector3.UnitZ);
|
||||
|
||||
return TransitionState.Adjusted;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,13 @@ using System.Numerics;
|
|||
|
||||
namespace AcDream.Core.Physics;
|
||||
|
||||
internal readonly record struct TerrainWalkableSample(
|
||||
System.Numerics.Plane Plane,
|
||||
Vector3[] Vertices,
|
||||
float WaterDepth,
|
||||
bool IsWater,
|
||||
uint CellId);
|
||||
|
||||
/// <summary>
|
||||
/// Top-level physics resolver that combines <see cref="TerrainSurface"/> and
|
||||
/// <see cref="CellSurface"/> to resolve entity movement with step-height
|
||||
|
|
@ -162,6 +169,51 @@ public sealed class PhysicsEngine
|
|||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sample the outdoor terrain walkable triangle at the given world-space
|
||||
/// XY position. This carries the same plane as <see cref="SampleTerrainPlane"/>
|
||||
/// plus world-space triangle vertices for retail precipice-slide.
|
||||
/// </summary>
|
||||
internal TerrainWalkableSample? SampleTerrainWalkable(float worldX, float worldY)
|
||||
{
|
||||
foreach (var kvp in _landblocks)
|
||||
{
|
||||
var lb = kvp.Value;
|
||||
float localX = worldX - lb.WorldOffsetX;
|
||||
float localY = worldY - lb.WorldOffsetY;
|
||||
if (localX >= 0f && localX < 192f && localY >= 0f && localY < 192f)
|
||||
{
|
||||
var sample = lb.Terrain.SampleSurfacePolygon(localX, localY);
|
||||
var vertices = new Vector3[sample.Vertices.Length];
|
||||
for (int i = 0; i < sample.Vertices.Length; i++)
|
||||
{
|
||||
var v = sample.Vertices[i];
|
||||
vertices[i] = new Vector3(
|
||||
v.X + lb.WorldOffsetX,
|
||||
v.Y + lb.WorldOffsetY,
|
||||
v.Z);
|
||||
}
|
||||
|
||||
var normal = sample.Normal;
|
||||
float d = -Vector3.Dot(normal, vertices[0]);
|
||||
var plane = new System.Numerics.Plane(normal, d);
|
||||
|
||||
float waterDepth = lb.Terrain.SampleWaterDepth(localX, localY);
|
||||
bool isWater = waterDepth >= 0.45f;
|
||||
uint lowCellId = lb.Terrain.ComputeOutdoorCellId(localX, localY);
|
||||
uint fullCellId = (kvp.Key & 0xFFFF0000u) | lowCellId;
|
||||
|
||||
return new TerrainWalkableSample(
|
||||
plane,
|
||||
vertices,
|
||||
waterDepth,
|
||||
isWater,
|
||||
fullCellId);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the outdoor cell id that owns a world-space position.
|
||||
/// Indoor ids are preserved because EnvCell ownership still comes from
|
||||
|
|
|
|||
|
|
@ -1,7 +1,13 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
|
||||
namespace AcDream.Core.Physics;
|
||||
|
||||
public readonly record struct TerrainSurfacePolygon(
|
||||
float Z,
|
||||
Vector3 Normal,
|
||||
Vector3[] Vertices);
|
||||
|
||||
/// <summary>
|
||||
/// Outdoor terrain height resolver for a single landblock. Performs
|
||||
/// per-triangle barycentric Z interpolation matching the visual terrain
|
||||
|
|
@ -250,6 +256,72 @@ public sealed class TerrainSurface
|
|||
return (z, normal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sample the terrain triangle at (localX, localY), including the three
|
||||
/// local-space vertices that bound the sampled point. Edge-slide needs
|
||||
/// these vertices so the retail crossed-edge test can identify which edge
|
||||
/// the sphere left when a step-down probe fails.
|
||||
/// </summary>
|
||||
public TerrainSurfacePolygon SampleSurfacePolygon(float localX, float localY)
|
||||
{
|
||||
float fx = Math.Clamp(localX / CellSize, 0f, CellsPerSide - 0.001f);
|
||||
float fy = Math.Clamp(localY / CellSize, 0f, CellsPerSide - 0.001f);
|
||||
int cx = Math.Clamp((int)fx, 0, CellsPerSide - 1);
|
||||
int cy = Math.Clamp((int)fy, 0, CellsPerSide - 1);
|
||||
|
||||
float tx = fx - cx;
|
||||
float ty = fy - cy;
|
||||
|
||||
float hBL = _z[cx, cy ];
|
||||
float hBR = _z[cx + 1, cy ];
|
||||
float hTR = _z[cx + 1, cy + 1];
|
||||
float hTL = _z[cx, cy + 1];
|
||||
|
||||
bool splitSWtoNE = IsSplitSWtoNE(_landblockX, (uint)cx, _landblockY, (uint)cy);
|
||||
|
||||
Vector3 bl = new(cx * CellSize, cy * CellSize, hBL);
|
||||
Vector3 br = new((cx + 1) * CellSize, cy * CellSize, hBR);
|
||||
Vector3 tr = new((cx + 1) * CellSize, (cy + 1) * CellSize, hTR);
|
||||
Vector3 tl = new(cx * CellSize, (cy + 1) * CellSize, hTL);
|
||||
|
||||
float z;
|
||||
Vector3[] vertices;
|
||||
|
||||
if (splitSWtoNE)
|
||||
{
|
||||
if (tx > ty)
|
||||
{
|
||||
z = hBL + (hBR - hBL) * tx + (hTR - hBR) * ty;
|
||||
vertices = new[] { bl, br, tr };
|
||||
}
|
||||
else
|
||||
{
|
||||
z = hBL + (hTR - hTL) * tx + (hTL - hBL) * ty;
|
||||
vertices = new[] { bl, tr, tl };
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (tx + ty <= 1f)
|
||||
{
|
||||
z = hBL + (hBR - hBL) * tx + (hTL - hBL) * ty;
|
||||
vertices = new[] { bl, br, tl };
|
||||
}
|
||||
else
|
||||
{
|
||||
z = hTR + (hTL - hTR) * (1f - tx) + (hBR - hTR) * (1f - ty);
|
||||
vertices = new[] { br, tr, tl };
|
||||
}
|
||||
}
|
||||
|
||||
var normal = Vector3.Normalize(
|
||||
Vector3.Cross(vertices[1] - vertices[0], vertices[2] - vertices[0]));
|
||||
if (normal.Z < 0f)
|
||||
normal = -normal;
|
||||
|
||||
return new TerrainSurfacePolygon(z, normal, vertices);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail per-point water depth in meters — the amount the character's
|
||||
/// feet are allowed to sink below the contact plane before the
|
||||
|
|
|
|||
|
|
@ -188,7 +188,10 @@ public sealed class SpherePath
|
|||
// Walkable tracking
|
||||
public bool WalkableValid;
|
||||
public Plane WalkablePlane;
|
||||
public Vector3[]? WalkableVertices;
|
||||
public Vector3 WalkableUp = Vector3.UnitZ;
|
||||
public float WalkableAllowance = PhysicsGlobals.FloorZ;
|
||||
public bool HasWalkablePolygon => WalkableValid && WalkableVertices is { Length: >= 3 };
|
||||
|
||||
// Backup for restore
|
||||
public Vector3 BackupCheckPos;
|
||||
|
|
@ -246,6 +249,21 @@ public sealed class SpherePath
|
|||
WalkInterp = 1.0f;
|
||||
}
|
||||
|
||||
public void SetWalkable(Plane plane, Vector3[] vertices, Vector3 up)
|
||||
{
|
||||
WalkableValid = true;
|
||||
WalkablePlane = plane;
|
||||
WalkableVertices = (Vector3[])vertices.Clone();
|
||||
WalkableUp = up;
|
||||
WalkableAllowance = PhysicsGlobals.FloorZ;
|
||||
}
|
||||
|
||||
public void ClearWalkable()
|
||||
{
|
||||
WalkableValid = false;
|
||||
WalkableVertices = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Slide fallback when step-up fails. Clears the contact-plane state that
|
||||
/// caused the step-up attempt and runs the full sphere-slide computation
|
||||
|
|
@ -273,6 +291,40 @@ public sealed class SpherePath
|
|||
return transition.SlideSphereInternal(StepUpNormal, GlobalCurrCenter[0].Origin);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Slide along the edge of the walkable polygon the mover just left.
|
||||
/// Retail anchor: <c>SPHEREPATH::precipice_slide</c>
|
||||
/// (<c>acclient_2013_pseudo_c.txt:274316</c>).
|
||||
/// </summary>
|
||||
public TransitionState PrecipiceSlide(Transition transition)
|
||||
{
|
||||
if (!HasWalkablePolygon || WalkableVertices is null)
|
||||
{
|
||||
ClearWalkable();
|
||||
return TransitionState.Collided;
|
||||
}
|
||||
|
||||
if (!BSPQuery.FindCrossedEdge(
|
||||
WalkablePlane,
|
||||
WalkableVertices,
|
||||
GlobalSphere[0].Origin,
|
||||
WalkableUp,
|
||||
out var collisionNormal))
|
||||
{
|
||||
ClearWalkable();
|
||||
return TransitionState.Collided;
|
||||
}
|
||||
|
||||
ClearWalkable();
|
||||
StepUp = false;
|
||||
|
||||
var offset = GlobalSphere[0].Origin - GlobalCurrCenter[0].Origin;
|
||||
if (Vector3.Dot(collisionNormal, offset) > 0f)
|
||||
collisionNormal = -collisionNormal;
|
||||
|
||||
return transition.SlideSphereInternal(collisionNormal, GlobalCurrCenter[0].Origin);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initialize the path for a simple point-to-point movement.
|
||||
/// </summary>
|
||||
|
|
@ -583,14 +635,14 @@ public sealed class Transition
|
|||
else if (!reset)
|
||||
{
|
||||
// Placement accepted — return current state.
|
||||
sp.WalkableValid = false;
|
||||
sp.ClearWalkable();
|
||||
return placeState;
|
||||
}
|
||||
}
|
||||
else
|
||||
reset = true;
|
||||
|
||||
sp.WalkableValid = false;
|
||||
sp.ClearWalkable();
|
||||
|
||||
if (reset)
|
||||
{
|
||||
|
|
@ -653,7 +705,7 @@ public sealed class Transition
|
|||
{
|
||||
if (DoStepDown(stepDownHeight, zVal, engine, runPlacement: false))
|
||||
{
|
||||
sp.WalkableValid = false;
|
||||
sp.ClearWalkable();
|
||||
return TransitionState.OK;
|
||||
}
|
||||
}
|
||||
|
|
@ -663,7 +715,7 @@ public sealed class Transition
|
|||
if (DoStepDown(stepDownHeight, zVal, engine, runPlacement: false)
|
||||
|| DoStepDown(stepDownHeight, zVal, engine, runPlacement: false))
|
||||
{
|
||||
sp.WalkableValid = false;
|
||||
sp.ClearWalkable();
|
||||
return TransitionState.OK;
|
||||
}
|
||||
}
|
||||
|
|
@ -681,8 +733,7 @@ public sealed class Transition
|
|||
// we are missing precipice context, a steep contact plane, or
|
||||
// merely the EdgeSlide flag.
|
||||
DumpEdgeSlideStepDownFailed(stepDownHeight, zVal);
|
||||
sp.RestoreCheckPos();
|
||||
return TransitionState.Collided;
|
||||
return EdgeSlideAfterStepDownFailed(engine, stepDownHeight, zVal);
|
||||
}
|
||||
|
||||
return TransitionState.OK;
|
||||
|
|
@ -693,6 +744,105 @@ public sealed class Transition
|
|||
return TransitionState.Slid;
|
||||
}
|
||||
|
||||
private TransitionState EdgeSlideAfterStepDownFailed(
|
||||
PhysicsEngine engine,
|
||||
float stepDownHeight,
|
||||
float zVal)
|
||||
{
|
||||
var sp = SpherePath;
|
||||
var ci = CollisionInfo;
|
||||
var oi = ObjectInfo;
|
||||
|
||||
// Retail lets non-EdgeSlide movers continue over the boundary. Player
|
||||
// movement carries EdgeSlide, so the local avatar takes the slide path.
|
||||
if (!oi.OnWalkable || !oi.EdgeSlide)
|
||||
{
|
||||
sp.ClearWalkable();
|
||||
sp.RestoreCheckPos();
|
||||
ci.ContactPlaneValid = false;
|
||||
ci.ContactPlaneIsWater = false;
|
||||
return TransitionState.OK;
|
||||
}
|
||||
|
||||
if (ci.ContactPlaneValid && ci.ContactPlane.Normal.Z < zVal)
|
||||
{
|
||||
var cliffPlane = ci.ContactPlane;
|
||||
sp.ClearWalkable();
|
||||
sp.RestoreCheckPos();
|
||||
ci.ContactPlaneValid = false;
|
||||
ci.ContactPlaneIsWater = false;
|
||||
return CliffSlide(cliffPlane);
|
||||
}
|
||||
|
||||
if (sp.HasWalkablePolygon)
|
||||
{
|
||||
ci.ContactPlaneValid = false;
|
||||
ci.ContactPlaneIsWater = false;
|
||||
return sp.PrecipiceSlide(this);
|
||||
}
|
||||
|
||||
if (ci.ContactPlaneValid)
|
||||
{
|
||||
sp.ClearWalkable();
|
||||
sp.RestoreCheckPos();
|
||||
ci.ContactPlaneValid = false;
|
||||
ci.ContactPlaneIsWater = false;
|
||||
return TransitionState.OK;
|
||||
}
|
||||
|
||||
// Retail back-probes from the current sphere center to rediscover the
|
||||
// walkable polygon we just left, then restores the failed candidate and
|
||||
// runs precipice_slide against that polygon.
|
||||
Vector3 backToCurrent = sp.GlobalCurrCenter[0].Origin - sp.GlobalSphere[0].Origin;
|
||||
sp.AddOffsetToCheckPos(backToCurrent);
|
||||
|
||||
_ = DoStepDown(stepDownHeight, zVal, engine, runPlacement: false);
|
||||
|
||||
ci.ContactPlaneValid = false;
|
||||
ci.ContactPlaneIsWater = false;
|
||||
sp.RestoreCheckPos();
|
||||
|
||||
if (sp.HasWalkablePolygon)
|
||||
return sp.PrecipiceSlide(this);
|
||||
|
||||
sp.ClearWalkable();
|
||||
return TransitionState.Collided;
|
||||
}
|
||||
|
||||
private TransitionState CliffSlide(Plane contactPlane)
|
||||
{
|
||||
var sp = SpherePath;
|
||||
var ci = CollisionInfo;
|
||||
|
||||
if (!ci.LastKnownContactPlaneValid)
|
||||
return TransitionState.OK;
|
||||
|
||||
Vector3 contactNormal = Vector3.Cross(contactPlane.Normal, ci.LastKnownContactPlane.Normal);
|
||||
contactNormal.Z = 0f;
|
||||
|
||||
Vector3 collideNormal = new(-contactNormal.Y, contactNormal.X, 0f);
|
||||
if (collideNormal.LengthSquared() < PhysicsGlobals.EpsilonSq)
|
||||
return TransitionState.OK;
|
||||
|
||||
collideNormal = Vector3.Normalize(collideNormal);
|
||||
|
||||
Vector3 offset = sp.GlobalSphere[0].Origin - sp.GlobalCurrCenter[0].Origin;
|
||||
float angle = Vector3.Dot(collideNormal, offset);
|
||||
|
||||
if (angle <= 0f)
|
||||
{
|
||||
sp.AddOffsetToCheckPos(collideNormal * angle);
|
||||
ci.SetCollisionNormal(collideNormal);
|
||||
}
|
||||
else
|
||||
{
|
||||
sp.AddOffsetToCheckPos(collideNormal * -angle);
|
||||
ci.SetCollisionNormal(-collideNormal);
|
||||
}
|
||||
|
||||
return TransitionState.Adjusted;
|
||||
}
|
||||
|
||||
private void DumpEdgeSlideStepDownFailed(float stepDownHeight, float zVal)
|
||||
{
|
||||
if (!DumpEdgeSlideEnabled) return;
|
||||
|
|
@ -703,7 +853,7 @@ public sealed class Transition
|
|||
|
||||
Console.WriteLine(
|
||||
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} 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} stepDown={stepDownHeight:F3} zVal={zVal:F3}"));
|
||||
}
|
||||
|
||||
private static string Fmt(Vector3 value) =>
|
||||
|
|
@ -800,10 +950,10 @@ public sealed class Transition
|
|||
//
|
||||
// ACE reference: Landblock.GetZ (Landblock.cs:125-137) calls
|
||||
// find_terrain_poly and uses walkable.Plane — the actual triangle's
|
||||
// plane, not a reconstructed flat one. SampleTerrainPlane returns
|
||||
// the same thing analytically from the triangle's corner heights.
|
||||
var planeOpt = engine.SampleTerrainPlane(footCenter.X, footCenter.Y);
|
||||
if (planeOpt is null)
|
||||
// plane, not a reconstructed flat one. SampleTerrainWalkable returns
|
||||
// that plane plus the triangle vertices needed by precipice slide.
|
||||
var terrainWalkable = engine.SampleTerrainWalkable(footCenter.X, footCenter.Y);
|
||||
if (terrainWalkable is null)
|
||||
return TransitionState.OK; // no terrain loaded here — allow pass-through
|
||||
|
||||
// Per-point water depth: 0.9 on fully water cells, 0.45 on partial-
|
||||
|
|
@ -813,12 +963,11 @@ public sealed class Transition
|
|||
// contact plane before the push-up fires. In retail, this is what
|
||||
// makes characters appear submerged in water — there is NO separate
|
||||
// water surface mesh; the character just sits lower than terrain.
|
||||
float waterDepth = engine.SampleWaterDepth(footCenter.X, footCenter.Y);
|
||||
bool isWater = waterDepth >= 0.45f;
|
||||
|
||||
return ValidateWalkable(footCenter, sphereRadius, planeOpt.Value,
|
||||
isWater, waterDepth,
|
||||
cellId: sp.CheckCellId);
|
||||
return ValidateWalkable(footCenter, sphereRadius, terrainWalkable.Value.Plane,
|
||||
terrainWalkable.Value.IsWater,
|
||||
terrainWalkable.Value.WaterDepth,
|
||||
cellId: terrainWalkable.Value.CellId,
|
||||
walkableVertices: terrainWalkable.Value.Vertices);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -829,12 +978,19 @@ public sealed class Transition
|
|||
/// </summary>
|
||||
private TransitionState ValidateWalkable(Vector3 sphereCenter, float sphereRadius,
|
||||
System.Numerics.Plane contactPlane,
|
||||
bool isWater, float waterDepth, uint cellId)
|
||||
bool isWater, float waterDepth, uint cellId,
|
||||
Vector3[]? walkableVertices = null)
|
||||
{
|
||||
var sp = SpherePath;
|
||||
var ci = CollisionInfo;
|
||||
var oi = ObjectInfo;
|
||||
|
||||
void CacheWalkableContext()
|
||||
{
|
||||
if (walkableVertices is not null && contactPlane.Normal.Z >= PhysicsGlobals.FloorZ)
|
||||
sp.SetWalkable(contactPlane, walkableVertices, Vector3.UnitZ);
|
||||
}
|
||||
|
||||
// Low point of the sphere.
|
||||
var lowPoint = sphereCenter - new Vector3(0f, 0f, sphereRadius);
|
||||
|
||||
|
|
@ -857,7 +1013,10 @@ public sealed class Transition
|
|||
// Resting on surface: record contact plane.
|
||||
bool walkableNormal = contactPlane.Normal.Z >= sp.WalkableAllowance;
|
||||
if (sp.StepDown || !oi.OnWalkable || walkableNormal)
|
||||
{
|
||||
ci.SetContactPlane(contactPlane, cellId, isWater);
|
||||
CacheWalkableContext();
|
||||
}
|
||||
|
||||
if (!oi.Contact && !sp.StepDown)
|
||||
{
|
||||
|
|
@ -879,6 +1038,7 @@ public sealed class Transition
|
|||
if (sp.StepDown || !oi.OnWalkable || walkable)
|
||||
{
|
||||
ci.SetContactPlane(contactPlane, cellId, isWater);
|
||||
CacheWalkableContext();
|
||||
|
||||
if (sp.StepDown)
|
||||
{
|
||||
|
|
@ -1020,7 +1180,8 @@ public sealed class Transition
|
|||
localSpaceZ,
|
||||
obj.Scale, // scale for local→world offsets
|
||||
obj.Rotation, // local→world rotation
|
||||
engine); // engine needed for Path 5 step-up
|
||||
engine,
|
||||
worldOrigin: obj.Position);
|
||||
}
|
||||
else
|
||||
{
|
||||
|
|
@ -1499,8 +1660,8 @@ public sealed class Transition
|
|||
|
||||
bool stepDown = DoStepDown(stepDownHeight, zLandingValue, engine);
|
||||
|
||||
sp.StepUp = false;
|
||||
sp.WalkableValid = false;
|
||||
sp.StepUp = false;
|
||||
sp.ClearWalkable();
|
||||
|
||||
// L.2.3f: log the result + landing plane if step-up succeeded.
|
||||
// This is the actual surface the player ended up on, which may
|
||||
|
|
|
|||
|
|
@ -231,6 +231,36 @@ public class PhysicsEngineTests
|
|||
Assert.Equal(0x0025u, result.CellId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveWithTransition_EdgeSlideStopsAtLoadedTerrainBoundary()
|
||||
{
|
||||
var engine = MakeFlatEngine(terrainZ: 50f);
|
||||
var body = new PhysicsBody
|
||||
{
|
||||
Position = new Vector3(191.25f, 96f, 50f),
|
||||
TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable,
|
||||
ContactPlaneValid = true,
|
||||
ContactPlane = new Plane(Vector3.UnitZ, -50f),
|
||||
ContactPlaneCellId = 0x003Du,
|
||||
};
|
||||
|
||||
var result = engine.ResolveWithTransition(
|
||||
currentPos: new Vector3(191.25f, 96f, 50f),
|
||||
targetPos: new Vector3(193f, 96f, 50f),
|
||||
cellId: 0x003Du,
|
||||
sphereRadius: 0.5f,
|
||||
sphereHeight: 1.2f,
|
||||
stepUpHeight: 0.4f,
|
||||
stepDownHeight: 0.4f,
|
||||
isOnGround: true,
|
||||
body: body,
|
||||
moverFlags: ObjectInfoState.EdgeSlide);
|
||||
|
||||
Assert.True(result.IsOnGround);
|
||||
Assert.InRange(result.Position.X, 190.75f, 192.0001f);
|
||||
Assert.Equal(50f, result.Position.Z, precision: 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveWithTransition_LandblockBoundary_UpdatesFullOutdoorCellId()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -67,6 +67,20 @@ public class TerrainSurfaceTests
|
|||
Assert.Equal(42f, surface.SampleZ(300f, 300f));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SampleSurfacePolygon_ReturnsContainingTriangleVertices()
|
||||
{
|
||||
var heights = FlatHeightmap(50);
|
||||
var surface = new TerrainSurface(heights, LinearHeightTable(), landblockX: 0, landblockY: 0);
|
||||
|
||||
var sample = surface.SampleSurfacePolygon(2f, 2f);
|
||||
|
||||
Assert.Equal(3, sample.Vertices.Length);
|
||||
Assert.All(sample.Vertices, v => Assert.Equal(50f, v.Z));
|
||||
Assert.Equal(1f, sample.Normal.Z, precision: 3);
|
||||
Assert.Contains(sample.Vertices, v => v.X == 0f && v.Y == 0f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeOutdoorCellId_Origin_ReturnsFirst()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue