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.
|
hard-blocks or accepts too much in several of these cases.
|
||||||
|
|
||||||
**Root cause / status:** Tracked under Phase L.2c. Wall-adjacent
|
**Root cause / status:** Tracked under Phase L.2c. Wall-adjacent
|
||||||
`step_up_slide` now feels acceptable in live testing. L.2c plumbing now passes
|
`step_up_slide` now feels acceptable in live testing. Local/remote movement
|
||||||
the retail-default `EdgeSlide` flag into local and remote movement and logs
|
passes the retail-default `EdgeSlide` flag. The first precipice-slide slice now
|
||||||
failed step-down edge cases behind `ACDREAM_DUMP_EDGE_SLIDE=1`. Remaining gap:
|
preserves terrain/BSP walkable polygon vertices and runs the retail back-probe
|
||||||
preserve walkable polygon context for `precipice_slide` and finish
|
before `SPHEREPATH::precipice_slide`; `ACDREAM_DUMP_EDGE_SLIDE=1` now reports
|
||||||
`cliff_slide` / `NegPolyHit` dispatch. Named retail anchors include
|
whether a failed step-down had polygon context. Remaining gaps: real-DAT
|
||||||
`CTransition::edge_slide`, `CTransition::cliff_slide`,
|
building-edge fixtures, fuller `cliff_slide` coverage, and `NegPolyHit`
|
||||||
`SPHEREPATH::precipice_slide`, and `SPHEREPATH::step_up_slide`.
|
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`,
|
**Files:** `src/AcDream.Core/Physics/TransitionTypes.cs`,
|
||||||
`src/AcDream.Core/Physics/BSPQuery.cs`,
|
`src/AcDream.Core/Physics/BSPQuery.cs`,
|
||||||
`tests/AcDream.Core.Tests/`.
|
`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,
|
**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.
|
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
|
edge, walkable, and collision rules; jumping clears `OnWalkable` and only
|
||||||
succeeds when the airborne path actually clears geometry.
|
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
|
### L.2d - Shape Fidelity: Sphere / CylSphere / Building Objects
|
||||||
|
|
||||||
Goal: object collisions use retail shape semantics, not one simplified
|
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
|
- 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
|
acceptable. Local player and remote dead-reckoning now pass retail-default
|
||||||
`ObjectInfoState.EdgeSlide`; `ACDREAM_DUMP_EDGE_SLIDE=1` logs failed
|
`ObjectInfoState.EdgeSlide`; `ACDREAM_DUMP_EDGE_SLIDE=1` logs failed
|
||||||
step-down edge cases so the next slice can distinguish missing walkable
|
step-down edge cases and now reports whether walkable polygon context is
|
||||||
polygon context from cliff-slide/NegPolyHit gaps.
|
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>
|
/// <para>ACE: Polygon.cs find_crossed_edge.</para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static bool FindCrossedEdge(
|
internal static bool FindCrossedEdge(
|
||||||
ResolvedPolygon poly,
|
Plane polyPlane,
|
||||||
CollisionSphere sphere,
|
ReadOnlySpan<Vector3> verts,
|
||||||
|
Vector3 sphereCenter,
|
||||||
Vector3 up,
|
Vector3 up,
|
||||||
ref Vector3 normal)
|
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;
|
if (MathF.Abs(angleUp) < PhysicsGlobals.EPSILON) return false;
|
||||||
|
|
||||||
float angle = (Vector3.Dot(poly.Plane.Normal, sphere.Center) + poly.Plane.D) / angleUp;
|
float angle = (Vector3.Dot(polyPlane.Normal, sphereCenter) + polyPlane.D) / angleUp;
|
||||||
var center = sphere.Center - up * angle;
|
var center = sphereCenter - up * angle;
|
||||||
|
|
||||||
int n = poly.Vertices.Length;
|
int n = verts.Length;
|
||||||
int prevIdx = n - 1;
|
int prevIdx = n - 1;
|
||||||
|
|
||||||
for (int i = 0; i < n; i++)
|
for (int i = 0; i < n; i++)
|
||||||
{
|
{
|
||||||
var v = poly.Vertices[i];
|
var v = verts[i];
|
||||||
var lv = poly.Vertices[prevIdx];
|
var lv = verts[prevIdx];
|
||||||
prevIdx = i;
|
prevIdx = i;
|
||||||
|
|
||||||
var edge = v - lv;
|
var edge = v - lv;
|
||||||
var disp = center - 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)
|
if (Vector3.Dot(disp, cross) < 0f)
|
||||||
{
|
{
|
||||||
|
|
@ -412,6 +415,47 @@ public static class BSPQuery
|
||||||
return false;
|
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
|
// adjust_to_placement_poly
|
||||||
// ACE: Polygon.cs adjust_to_placement_poly
|
// ACE: Polygon.cs adjust_to_placement_poly
|
||||||
|
|
@ -1037,7 +1081,8 @@ public static class BSPQuery
|
||||||
CollisionSphere checkPos,
|
CollisionSphere checkPos,
|
||||||
Vector3 up,
|
Vector3 up,
|
||||||
float scale,
|
float scale,
|
||||||
Quaternion localToWorld = default)
|
Quaternion localToWorld = default,
|
||||||
|
Vector3 worldOrigin = default)
|
||||||
{
|
{
|
||||||
if (localToWorld == default) localToWorld = Quaternion.Identity;
|
if (localToWorld == default) localToWorld = Quaternion.Identity;
|
||||||
|
|
||||||
|
|
@ -1061,14 +1106,12 @@ public static class BSPQuery
|
||||||
var offset = Vector3.Transform(adjusted, localToWorld) * scale;
|
var offset = Vector3.Transform(adjusted, localToWorld) * scale;
|
||||||
path.AddOffsetToCheckPos(offset);
|
path.AddOffsetToCheckPos(offset);
|
||||||
|
|
||||||
var worldNormal = Vector3.Transform(polyHit.Plane.Normal, localToWorld);
|
var worldNormal = TransformNormal(polyHit.Plane.Normal, localToWorld);
|
||||||
collisions.SetContactPlane(
|
var worldVertices = TransformVertices(polyHit.Vertices, localToWorld, scale, worldOrigin);
|
||||||
new Plane(worldNormal, polyHit.Plane.D * scale),
|
var worldPlane = BuildWorldPlane(worldNormal, worldVertices);
|
||||||
path.CheckCellId, false);
|
collisions.SetContactPlane(worldPlane, path.CheckCellId, false);
|
||||||
|
|
||||||
path.WalkableValid = true;
|
path.SetWalkable(worldPlane, worldVertices, Vector3.UnitZ);
|
||||||
path.WalkablePlane = new Plane(worldNormal, polyHit.Plane.D * scale);
|
|
||||||
path.WalkableAllowance = PhysicsGlobals.FloorZ;
|
|
||||||
|
|
||||||
return TransitionState.Adjusted;
|
return TransitionState.Adjusted;
|
||||||
}
|
}
|
||||||
|
|
@ -1359,7 +1402,8 @@ public static class BSPQuery
|
||||||
Vector3 localSpaceZ,
|
Vector3 localSpaceZ,
|
||||||
float scale,
|
float scale,
|
||||||
Quaternion localToWorld = default,
|
Quaternion localToWorld = default,
|
||||||
PhysicsEngine? engine = null)
|
PhysicsEngine? engine = null,
|
||||||
|
Vector3 worldOrigin = default)
|
||||||
{
|
{
|
||||||
if (root is null) return TransitionState.OK;
|
if (root is null) return TransitionState.OK;
|
||||||
// Default quaternion (0,0,0,0) → treat as identity
|
// Default quaternion (0,0,0,0) → treat as identity
|
||||||
|
|
@ -1410,7 +1454,7 @@ public static class BSPQuery
|
||||||
// ----------------------------------------------------------------
|
// ----------------------------------------------------------------
|
||||||
if (path.StepDown)
|
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;
|
var worldOffset = L2W(localOffset) * scale;
|
||||||
path.AddOffsetToCheckPos(worldOffset);
|
path.AddOffsetToCheckPos(worldOffset);
|
||||||
|
|
||||||
var worldNormal = L2W(hitPoly.Plane.Normal);
|
var worldNormal = TransformNormal(hitPoly.Plane.Normal, localToWorld);
|
||||||
collisions.SetContactPlane(
|
var worldVertices = TransformVertices(hitPoly.Vertices, localToWorld, scale, worldOrigin);
|
||||||
new Plane(worldNormal, hitPoly.Plane.D * scale),
|
var worldPlane = BuildWorldPlane(worldNormal, worldVertices);
|
||||||
path.CheckCellId, false);
|
collisions.SetContactPlane(worldPlane, path.CheckCellId, false);
|
||||||
|
|
||||||
path.WalkableValid = true;
|
path.SetWalkable(worldPlane, worldVertices, Vector3.UnitZ);
|
||||||
path.WalkablePlane = new Plane(worldNormal, hitPoly.Plane.D * scale);
|
|
||||||
path.WalkableAllowance = PhysicsGlobals.FloorZ;
|
|
||||||
|
|
||||||
return TransitionState.Adjusted;
|
return TransitionState.Adjusted;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,13 @@ using System.Numerics;
|
||||||
|
|
||||||
namespace AcDream.Core.Physics;
|
namespace AcDream.Core.Physics;
|
||||||
|
|
||||||
|
internal readonly record struct TerrainWalkableSample(
|
||||||
|
System.Numerics.Plane Plane,
|
||||||
|
Vector3[] Vertices,
|
||||||
|
float WaterDepth,
|
||||||
|
bool IsWater,
|
||||||
|
uint CellId);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Top-level physics resolver that combines <see cref="TerrainSurface"/> and
|
/// Top-level physics resolver that combines <see cref="TerrainSurface"/> and
|
||||||
/// <see cref="CellSurface"/> to resolve entity movement with step-height
|
/// <see cref="CellSurface"/> to resolve entity movement with step-height
|
||||||
|
|
@ -162,6 +169,51 @@ public sealed class PhysicsEngine
|
||||||
return null;
|
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>
|
/// <summary>
|
||||||
/// Resolve the outdoor cell id that owns a world-space position.
|
/// Resolve the outdoor cell id that owns a world-space position.
|
||||||
/// Indoor ids are preserved because EnvCell ownership still comes from
|
/// Indoor ids are preserved because EnvCell ownership still comes from
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,13 @@
|
||||||
using System;
|
using System;
|
||||||
|
using System.Numerics;
|
||||||
|
|
||||||
namespace AcDream.Core.Physics;
|
namespace AcDream.Core.Physics;
|
||||||
|
|
||||||
|
public readonly record struct TerrainSurfacePolygon(
|
||||||
|
float Z,
|
||||||
|
Vector3 Normal,
|
||||||
|
Vector3[] Vertices);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Outdoor terrain height resolver for a single landblock. Performs
|
/// Outdoor terrain height resolver for a single landblock. Performs
|
||||||
/// per-triangle barycentric Z interpolation matching the visual terrain
|
/// per-triangle barycentric Z interpolation matching the visual terrain
|
||||||
|
|
@ -250,6 +256,72 @@ public sealed class TerrainSurface
|
||||||
return (z, normal);
|
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>
|
/// <summary>
|
||||||
/// Retail per-point water depth in meters — the amount the character's
|
/// Retail per-point water depth in meters — the amount the character's
|
||||||
/// feet are allowed to sink below the contact plane before the
|
/// feet are allowed to sink below the contact plane before the
|
||||||
|
|
|
||||||
|
|
@ -188,7 +188,10 @@ public sealed class SpherePath
|
||||||
// Walkable tracking
|
// Walkable tracking
|
||||||
public bool WalkableValid;
|
public bool WalkableValid;
|
||||||
public Plane WalkablePlane;
|
public Plane WalkablePlane;
|
||||||
|
public Vector3[]? WalkableVertices;
|
||||||
|
public Vector3 WalkableUp = Vector3.UnitZ;
|
||||||
public float WalkableAllowance = PhysicsGlobals.FloorZ;
|
public float WalkableAllowance = PhysicsGlobals.FloorZ;
|
||||||
|
public bool HasWalkablePolygon => WalkableValid && WalkableVertices is { Length: >= 3 };
|
||||||
|
|
||||||
// Backup for restore
|
// Backup for restore
|
||||||
public Vector3 BackupCheckPos;
|
public Vector3 BackupCheckPos;
|
||||||
|
|
@ -246,6 +249,21 @@ public sealed class SpherePath
|
||||||
WalkInterp = 1.0f;
|
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>
|
/// <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
|
||||||
|
|
@ -273,6 +291,40 @@ public sealed class SpherePath
|
||||||
return transition.SlideSphereInternal(StepUpNormal, GlobalCurrCenter[0].Origin);
|
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>
|
/// <summary>
|
||||||
/// Initialize the path for a simple point-to-point movement.
|
/// Initialize the path for a simple point-to-point movement.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
@ -583,14 +635,14 @@ public sealed class Transition
|
||||||
else if (!reset)
|
else if (!reset)
|
||||||
{
|
{
|
||||||
// Placement accepted — return current state.
|
// Placement accepted — return current state.
|
||||||
sp.WalkableValid = false;
|
sp.ClearWalkable();
|
||||||
return placeState;
|
return placeState;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
reset = true;
|
reset = true;
|
||||||
|
|
||||||
sp.WalkableValid = false;
|
sp.ClearWalkable();
|
||||||
|
|
||||||
if (reset)
|
if (reset)
|
||||||
{
|
{
|
||||||
|
|
@ -653,7 +705,7 @@ public sealed class Transition
|
||||||
{
|
{
|
||||||
if (DoStepDown(stepDownHeight, zVal, engine, runPlacement: false))
|
if (DoStepDown(stepDownHeight, zVal, engine, runPlacement: false))
|
||||||
{
|
{
|
||||||
sp.WalkableValid = false;
|
sp.ClearWalkable();
|
||||||
return TransitionState.OK;
|
return TransitionState.OK;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -663,7 +715,7 @@ public sealed class Transition
|
||||||
if (DoStepDown(stepDownHeight, zVal, engine, runPlacement: false)
|
if (DoStepDown(stepDownHeight, zVal, engine, runPlacement: false)
|
||||||
|| DoStepDown(stepDownHeight, zVal, engine, runPlacement: false))
|
|| DoStepDown(stepDownHeight, zVal, engine, runPlacement: false))
|
||||||
{
|
{
|
||||||
sp.WalkableValid = false;
|
sp.ClearWalkable();
|
||||||
return TransitionState.OK;
|
return TransitionState.OK;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -681,8 +733,7 @@ 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);
|
||||||
sp.RestoreCheckPos();
|
return EdgeSlideAfterStepDownFailed(engine, stepDownHeight, zVal);
|
||||||
return TransitionState.Collided;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return TransitionState.OK;
|
return TransitionState.OK;
|
||||||
|
|
@ -693,6 +744,105 @@ public sealed class Transition
|
||||||
return TransitionState.Slid;
|
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)
|
private void DumpEdgeSlideStepDownFailed(float stepDownHeight, float zVal)
|
||||||
{
|
{
|
||||||
if (!DumpEdgeSlideEnabled) return;
|
if (!DumpEdgeSlideEnabled) return;
|
||||||
|
|
@ -703,7 +853,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} 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) =>
|
private static string Fmt(Vector3 value) =>
|
||||||
|
|
@ -800,10 +950,10 @@ public sealed class Transition
|
||||||
//
|
//
|
||||||
// ACE reference: Landblock.GetZ (Landblock.cs:125-137) calls
|
// ACE reference: Landblock.GetZ (Landblock.cs:125-137) calls
|
||||||
// find_terrain_poly and uses walkable.Plane — the actual triangle's
|
// find_terrain_poly and uses walkable.Plane — the actual triangle's
|
||||||
// plane, not a reconstructed flat one. SampleTerrainPlane returns
|
// plane, not a reconstructed flat one. SampleTerrainWalkable returns
|
||||||
// the same thing analytically from the triangle's corner heights.
|
// that plane plus the triangle vertices needed by precipice slide.
|
||||||
var planeOpt = engine.SampleTerrainPlane(footCenter.X, footCenter.Y);
|
var terrainWalkable = engine.SampleTerrainWalkable(footCenter.X, footCenter.Y);
|
||||||
if (planeOpt is null)
|
if (terrainWalkable is null)
|
||||||
return TransitionState.OK; // no terrain loaded here — allow pass-through
|
return TransitionState.OK; // no terrain loaded here — allow pass-through
|
||||||
|
|
||||||
// Per-point water depth: 0.9 on fully water cells, 0.45 on partial-
|
// 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
|
// contact plane before the push-up fires. In retail, this is what
|
||||||
// makes characters appear submerged in water — there is NO separate
|
// makes characters appear submerged in water — there is NO separate
|
||||||
// water surface mesh; the character just sits lower than terrain.
|
// water surface mesh; the character just sits lower than terrain.
|
||||||
float waterDepth = engine.SampleWaterDepth(footCenter.X, footCenter.Y);
|
return ValidateWalkable(footCenter, sphereRadius, terrainWalkable.Value.Plane,
|
||||||
bool isWater = waterDepth >= 0.45f;
|
terrainWalkable.Value.IsWater,
|
||||||
|
terrainWalkable.Value.WaterDepth,
|
||||||
return ValidateWalkable(footCenter, sphereRadius, planeOpt.Value,
|
cellId: terrainWalkable.Value.CellId,
|
||||||
isWater, waterDepth,
|
walkableVertices: terrainWalkable.Value.Vertices);
|
||||||
cellId: sp.CheckCellId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -829,12 +978,19 @@ public sealed class Transition
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private TransitionState ValidateWalkable(Vector3 sphereCenter, float sphereRadius,
|
private TransitionState ValidateWalkable(Vector3 sphereCenter, float sphereRadius,
|
||||||
System.Numerics.Plane contactPlane,
|
System.Numerics.Plane contactPlane,
|
||||||
bool isWater, float waterDepth, uint cellId)
|
bool isWater, float waterDepth, uint cellId,
|
||||||
|
Vector3[]? walkableVertices = null)
|
||||||
{
|
{
|
||||||
var sp = SpherePath;
|
var sp = SpherePath;
|
||||||
var ci = CollisionInfo;
|
var ci = CollisionInfo;
|
||||||
var oi = ObjectInfo;
|
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.
|
// Low point of the sphere.
|
||||||
var lowPoint = sphereCenter - new Vector3(0f, 0f, sphereRadius);
|
var lowPoint = sphereCenter - new Vector3(0f, 0f, sphereRadius);
|
||||||
|
|
||||||
|
|
@ -857,7 +1013,10 @@ public sealed class Transition
|
||||||
// Resting on surface: record contact plane.
|
// Resting on surface: record contact plane.
|
||||||
bool walkableNormal = contactPlane.Normal.Z >= sp.WalkableAllowance;
|
bool walkableNormal = contactPlane.Normal.Z >= sp.WalkableAllowance;
|
||||||
if (sp.StepDown || !oi.OnWalkable || walkableNormal)
|
if (sp.StepDown || !oi.OnWalkable || walkableNormal)
|
||||||
|
{
|
||||||
ci.SetContactPlane(contactPlane, cellId, isWater);
|
ci.SetContactPlane(contactPlane, cellId, isWater);
|
||||||
|
CacheWalkableContext();
|
||||||
|
}
|
||||||
|
|
||||||
if (!oi.Contact && !sp.StepDown)
|
if (!oi.Contact && !sp.StepDown)
|
||||||
{
|
{
|
||||||
|
|
@ -879,6 +1038,7 @@ public sealed class Transition
|
||||||
if (sp.StepDown || !oi.OnWalkable || walkable)
|
if (sp.StepDown || !oi.OnWalkable || walkable)
|
||||||
{
|
{
|
||||||
ci.SetContactPlane(contactPlane, cellId, isWater);
|
ci.SetContactPlane(contactPlane, cellId, isWater);
|
||||||
|
CacheWalkableContext();
|
||||||
|
|
||||||
if (sp.StepDown)
|
if (sp.StepDown)
|
||||||
{
|
{
|
||||||
|
|
@ -1020,7 +1180,8 @@ public sealed class Transition
|
||||||
localSpaceZ,
|
localSpaceZ,
|
||||||
obj.Scale, // scale for local→world offsets
|
obj.Scale, // scale for local→world offsets
|
||||||
obj.Rotation, // local→world rotation
|
obj.Rotation, // local→world rotation
|
||||||
engine); // engine needed for Path 5 step-up
|
engine,
|
||||||
|
worldOrigin: obj.Position);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|
@ -1500,7 +1661,7 @@ public sealed class Transition
|
||||||
bool stepDown = DoStepDown(stepDownHeight, zLandingValue, engine);
|
bool stepDown = DoStepDown(stepDownHeight, zLandingValue, engine);
|
||||||
|
|
||||||
sp.StepUp = false;
|
sp.StepUp = false;
|
||||||
sp.WalkableValid = false;
|
sp.ClearWalkable();
|
||||||
|
|
||||||
// L.2.3f: log the result + landing plane if step-up succeeded.
|
// L.2.3f: log the result + landing plane if step-up succeeded.
|
||||||
// This is the actual surface the player ended up on, which may
|
// This is the actual surface the player ended up on, which may
|
||||||
|
|
|
||||||
|
|
@ -231,6 +231,36 @@ public class PhysicsEngineTests
|
||||||
Assert.Equal(0x0025u, result.CellId);
|
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]
|
[Fact]
|
||||||
public void ResolveWithTransition_LandblockBoundary_UpdatesFullOutdoorCellId()
|
public void ResolveWithTransition_LandblockBoundary_UpdatesFullOutdoorCellId()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,20 @@ public class TerrainSurfaceTests
|
||||||
Assert.Equal(42f, surface.SampleZ(300f, 300f));
|
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]
|
[Fact]
|
||||||
public void ComputeOutdoorCellId_Origin_ReturnsFirst()
|
public void ComputeOutdoorCellId_Origin_ReturnsFirst()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue