feat(physics): Transition.FindTransitionalPosition core algorithm

Port FindTransitionalPosition, TransitionalInsert, FindEnvCollisions,
AdjustOffset, DoStepDown, ValidateTransition from transition_pseudocode.md.
Outdoor terrain collision with step-down ground contact. Indoor BSP and
object collision deferred to subsequent tasks.

Also adds PhysicsEngine.SampleTerrainZ() which dispatches the terrain Z
query to the right registered landblock by world-space XY position.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-13 23:52:45 +02:00
parent 9ea8ae5191
commit e08a06ac5b
3 changed files with 836 additions and 3 deletions

View file

@ -47,6 +47,24 @@ public sealed class PhysicsEngine
/// </summary>
public void RemoveLandblock(uint landblockId) => _landblocks.Remove(landblockId);
/// <summary>
/// Sample the outdoor terrain Z at the given world-space XY position.
/// Searches all registered landblocks; returns null if no landblock covers the position.
/// Used by Transition.FindEnvCollisions for terrain collision resolution.
/// </summary>
public float? SampleTerrainZ(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)
return lb.Terrain.SampleZ(localX, localY);
}
return null;
}
/// <summary>
/// Resolve an entity's movement from <paramref name="currentPos"/> by
/// applying <paramref name="delta"/> (XY only) and computing the correct Z

View file

@ -258,7 +258,15 @@ public static class PhysicsGlobals
/// <summary>
/// The main collision transition orchestrator.
/// ACE: Transition. Decompiled: CTransition.
/// Stub class — algorithm methods added in Task 6b-6d.
///
/// Task 6b implements outdoor terrain collision:
/// FindTransitionalPosition → step subdivision loop
/// TransitionalInsert → per-step collision check
/// FindEnvCollisions → terrain Z query + ValidateWalkable
/// AdjustOffset → contact-plane / sliding-normal projection
/// StepDown → ground-contact maintenance on downhill movement
///
/// Indoor BSP (Task 6c) and object collision (Task 7) are deferred.
/// </summary>
public sealed class Transition
{
@ -266,6 +274,502 @@ public sealed class Transition
public SpherePath SpherePath = new();
public CollisionInfo CollisionInfo = new();
// Will be populated in Task 6b:
// public TransitionState FindTransitionalPosition(PhysicsEngine engine, PhysicsDataCache cache) { ... }
// -----------------------------------------------------------------------
// Public entry point
// -----------------------------------------------------------------------
/// <summary>
/// Move the sphere path from BeginPos to EndPos, resolving terrain
/// collisions at each sub-step. Returns true when the final position
/// is valid (TransitionState == OK).
///
/// Ported from pseudocode section 2 (FindTransitionalPosition).
/// ACE: Transition.FindTransitionalPosition().
/// </summary>
public bool FindTransitionalPosition(PhysicsEngine engine)
{
var sp = SpherePath;
// No starting cell → cannot move.
if (sp.CurCellId == 0)
return false;
// ------------------------------------------------------------------
// Step subdivision: each sub-step travels at most one sphere radius
// to prevent tunnelling through thin surfaces.
// ------------------------------------------------------------------
Vector3 offset = sp.EndPos - sp.BeginPos;
float dist = offset.Length();
float radius = sp.LocalSphere[0].Radius;
// Guard: zero-radius sphere would cause a div-by-zero.
if (radius <= PhysicsGlobals.EPSILON)
return false;
float step = dist / radius;
int numSteps;
Vector3 offsetPerStep;
if (step > 1.0f)
{
numSteps = (int)MathF.Ceiling(step);
offsetPerStep = offset * (1f / numSteps);
}
else if (offset != Vector3.Zero)
{
numSteps = 1;
offsetPerStep = offset;
}
else
{
numSteps = 0;
offsetPerStep = Vector3.Zero;
}
// Retail safety cap (30 steps). Sight objects bypass this.
if (numSteps > PhysicsGlobals.MaxTransitionSteps)
return false;
// Apply free rotation if requested.
if (ObjectInfo.FreeRotate)
sp.CurOrientation = sp.EndOrientation;
sp.SetCheckPos(sp.CurPos, sp.CurCellId);
// Zero-step case: just validate current cell membership.
if (numSteps <= 0)
{
if (!ObjectInfo.FreeRotate)
sp.CurOrientation = sp.EndOrientation;
return true;
}
// ------------------------------------------------------------------
// Main stepping loop
// ------------------------------------------------------------------
var transitionState = TransitionState.OK;
for (int i = 0; i < numSteps; i++)
{
// Reset per-step collision state.
CollisionInfo.SlidingNormalValid = false;
CollisionInfo.ContactPlaneValid = false;
CollisionInfo.ContactPlaneIsWater = false;
// Project the step offset through any existing contact / slide plane.
sp.GlobalOffset = AdjustOffset(offsetPerStep);
// Abort if adjusted offset is negligible (we're stuck against a wall).
if (sp.GlobalOffset.LengthSquared() < PhysicsGlobals.EpsilonSq)
return i != 0 && transitionState == TransitionState.OK;
// Interpolate orientation (non-free-rotate path).
if (!ObjectInfo.FreeRotate)
{
float delta = (i + 1f) / numSteps;
sp.CheckOrientation = Quaternion.Slerp(sp.BeginOrientation, sp.EndOrientation, delta);
}
// Apply the offset, then check collisions.
sp.AddOffsetToCheckPos(sp.GlobalOffset);
var result = TransitionalInsert(3, engine);
transitionState = ValidateTransition(result);
// PathClipped objects stop at the first collision.
if (CollisionInfo.CollisionNormalValid && ObjectInfo.PathClipped)
break;
}
return transitionState == TransitionState.OK;
}
// -----------------------------------------------------------------------
// Per-step collision check
// -----------------------------------------------------------------------
/// <summary>
/// Check collisions at the current CheckPos, apply step-down as needed.
/// Ported from pseudocode section 3 (TransitionalInsert).
/// ACE: Transition.TransitionalInsert(int num_insertion_attempts).
/// </summary>
private TransitionState TransitionalInsert(int maxAttempts, PhysicsEngine engine)
{
if (SpherePath.CheckCellId == 0) return TransitionState.OK;
if (maxAttempts <= 0) return TransitionState.Invalid;
var sp = SpherePath;
var ci = CollisionInfo;
var oi = ObjectInfo;
TransitionState transitState = TransitionState.OK;
for (int attempt = 0; attempt < maxAttempts; attempt++)
{
// Phase 1: check collisions in the current cell.
transitState = FindEnvCollisions(engine);
switch (transitState)
{
case TransitionState.OK:
// Outdoor path: no neighboring cell enumeration needed for MVP.
break;
case TransitionState.Collided:
return TransitionState.Collided;
case TransitionState.Adjusted:
sp.NegPolyHit = false;
break;
case TransitionState.Slid:
ci.ContactPlaneValid = false;
ci.ContactPlaneIsWater = false;
sp.NegPolyHit = false;
break;
}
// Phase 2: post-collision response.
if (transitState == TransitionState.OK)
{
// Handle step-down when in contact but no ground plane found.
if (!ci.ContactPlaneValid && oi.Contact && !sp.StepDown
&& sp.CheckCellId != 0 && oi.StepDown)
{
float zVal = PhysicsGlobals.LandingZ;
float stepDownHeight = oi.StepDownHeight;
sp.WalkableAllowance = zVal;
sp.SaveCheckPos();
float radsum = sp.GlobalSphere[0].Radius * 2f;
if (radsum >= stepDownHeight)
{
if (DoStepDown(stepDownHeight, zVal, engine))
{
sp.WalkableValid = false;
return TransitionState.OK;
}
}
else
{
stepDownHeight *= 0.5f;
if (DoStepDown(stepDownHeight, zVal, engine)
|| DoStepDown(stepDownHeight, zVal, engine))
{
sp.WalkableValid = false;
return TransitionState.OK;
}
}
// Step-down failed: stay at current position.
sp.RestoreCheckPos();
return TransitionState.OK;
}
else
{
return TransitionState.OK;
}
}
}
return transitState;
}
// -----------------------------------------------------------------------
// Environment collision — outdoor terrain
// -----------------------------------------------------------------------
/// <summary>
/// Query the outdoor terrain at CheckPos and apply ValidateWalkable logic.
/// Indoor BSP collision is deferred to Task 6c.
///
/// Ported from pseudocode section 4 (LandCell.FindEnvCollisions + ValidateWalkable).
/// ACE: LandCell.FindEnvCollisions / ObjectInfo.ValidateWalkable.
/// </summary>
private TransitionState FindEnvCollisions(PhysicsEngine engine)
{
var sp = SpherePath;
var ci = CollisionInfo;
// Sample terrain Z at the foot sphere's world position.
Vector3 footCenter = sp.GlobalSphere[0].Origin;
float sphereRadius = sp.GlobalSphere[0].Radius;
float? terrainZ = engine.SampleTerrainZ(footCenter.X, footCenter.Y);
if (terrainZ is null)
return TransitionState.OK; // no terrain loaded here — allow pass-through
// Build the terrain contact plane (flat ground: Normal = +Z, D = -terrainZ).
// For sloped terrain we'd need the surface normal from the triangle; for MVP
// we use the vertical plane which matches flat terrain exactly and gives
// conservative results on slopes (terrain Z is already interpolated correctly).
var contactPlane = new System.Numerics.Plane(
new Vector3(0f, 0f, 1f), -terrainZ.Value);
return ValidateWalkable(footCenter, sphereRadius, contactPlane, isWater: false,
cellId: sp.CheckCellId);
}
/// <summary>
/// Determine the collision response for a sphere against a walkable surface plane.
///
/// Ported from pseudocode section 4 (ValidateWalkable, normal-object path).
/// ACE: ObjectInfo.ValidateWalkable().
/// </summary>
private TransitionState ValidateWalkable(Vector3 sphereCenter, float sphereRadius,
System.Numerics.Plane contactPlane,
bool isWater, uint cellId)
{
var sp = SpherePath;
var ci = CollisionInfo;
var oi = ObjectInfo;
// Low point of the sphere.
var lowPoint = sphereCenter - new Vector3(0f, 0f, sphereRadius);
// Signed distance: positive = above, negative = below.
// Plane convention: dot(N, p) + D.
float dist = Vector3.Dot(lowPoint, contactPlane.Normal) + contactPlane.D;
// ── Above or touching the surface ────────────────────────────────
if (dist >= -PhysicsGlobals.EPSILON)
{
if (dist <= PhysicsGlobals.EPSILON)
{
// Resting on surface: record contact plane.
bool walkableNormal = contactPlane.Normal.Z >= sp.WalkableAllowance;
if (sp.StepDown || !oi.OnWalkable || walkableNormal)
ci.SetContactPlane(contactPlane, cellId, isWater);
if (!oi.Contact && !sp.StepDown)
{
ci.SetCollisionNormal(contactPlane.Normal);
ci.CollidedWithEnvironment = true;
}
}
return TransitionState.OK;
}
// ── Below the surface ─────────────────────────────────────────────
if (sp.CheckWalkable) return TransitionState.Collided; // walkable probe fails
// zDist: how far we need to push up along Z to clear the surface.
// contactPlane.Normal.Z is 1 for flat ground, so this is just dist.
float zDist = dist / contactPlane.Normal.Z;
bool walkable = contactPlane.Normal.Z >= sp.WalkableAllowance;
if (sp.StepDown || !oi.OnWalkable || walkable)
{
ci.SetContactPlane(contactPlane, cellId, isWater);
if (sp.StepDown)
{
// Validate step-down interpolation factor.
float interp = (1f - (-1f / (sp.StepDownAmt * sp.WalkInterp)) * zDist) * sp.WalkInterp;
if (interp >= sp.WalkInterp || interp < -0.1f)
return TransitionState.Collided;
sp.WalkInterp = interp;
}
// Push the sphere up out of the terrain.
sp.AddOffsetToCheckPos(new Vector3(0f, 0f, -zDist));
}
if (!oi.Contact && !sp.StepDown)
{
ci.SetCollisionNormal(contactPlane.Normal);
ci.CollidedWithEnvironment = true;
}
return TransitionState.Adjusted;
}
// -----------------------------------------------------------------------
// Offset adjustment (contact-plane + slide-plane projection)
// -----------------------------------------------------------------------
/// <summary>
/// Project the per-step movement offset to avoid pushing into the contact
/// surface or slide plane.
///
/// Ported from pseudocode section 6 (AdjustOffset).
/// ACE: Transition.AdjustOffset(Vector3 offset).
/// </summary>
private Vector3 AdjustOffset(Vector3 offset)
{
var ci = CollisionInfo;
Vector3 result = offset;
bool checkSlide = false;
// Check if we should apply sliding.
float slidingAngle = Vector3.Dot(result, ci.SlidingNormal);
if (ci.SlidingNormalValid)
{
if (slidingAngle < 0f)
checkSlide = true;
else
ci.SlidingNormalValid = false;
}
// No contact plane — simple slide projection.
if (!ci.ContactPlaneValid)
{
if (checkSlide)
result -= ci.SlidingNormal * slidingAngle;
return result;
}
// Have a contact plane — project movement onto the contact surface.
float collisionAngle = Vector3.Dot(result, ci.ContactPlane.Normal);
Vector3 slideOffset = Vector3.Cross(ci.ContactPlane.Normal, ci.SlidingNormal);
if (checkSlide)
{
// Project movement along the crease between contact and slide planes.
float slideLen = slideOffset.Length();
if (slideLen < PhysicsGlobals.EPSILON)
result = Vector3.Zero;
else
{
slideOffset /= slideLen;
result = Vector3.Dot(slideOffset, result) * slideOffset;
}
}
else if (collisionAngle <= 0f)
{
// Moving into the contact plane: remove component into the plane.
result -= ci.ContactPlane.Normal * collisionAngle;
}
else
{
// Moving away from contact plane: snap to plane surface.
// SnapToPlane: remove any component that would violate the plane.
result -= ci.ContactPlane.Normal * (collisionAngle - 0f);
}
return result;
}
// -----------------------------------------------------------------------
// Step-down
// -----------------------------------------------------------------------
/// <summary>
/// Probe downward by stepDownHeight and snap to a walkable surface if found.
/// Returns true if a walkable surface was contacted.
///
/// Ported from pseudocode section 5 (StepDown).
/// ACE: Transition.StepDown(float stepDownHeight, float zVal).
/// </summary>
private bool DoStepDown(float stepDownHeight, float walkableZ, PhysicsEngine engine)
{
var sp = SpherePath;
sp.NegPolyHit = false;
sp.StepDown = true;
sp.StepDownAmt = stepDownHeight;
sp.WalkInterp = 1.0f;
// If NOT in step-up mode, apply the downward offset.
if (!sp.StepUp)
{
sp.AddOffsetToCheckPos(new Vector3(0f, 0f, -stepDownHeight));
}
// Run collision detection with the step-down flag active.
var transitState = TransitionalInsert(5, engine);
sp.StepDown = false;
// Accept step-down if:
// 1. Collision detection returned OK
// 2. A valid contact plane was found
// 3. The contact plane is walkable (Normal.Z >= walkableZ)
if (transitState == TransitionState.OK
&& CollisionInfo.ContactPlaneValid
&& CollisionInfo.ContactPlane.Normal.Z >= walkableZ)
{
return true;
}
return false;
}
// -----------------------------------------------------------------------
// Post-step validation
// -----------------------------------------------------------------------
/// <summary>
/// Accept or revert the current step, update state flags, and propagate
/// the sliding normal.
///
/// Ported from pseudocode section 7 (ValidateTransition).
/// ACE: Transition.ValidateTransition().
/// </summary>
private TransitionState ValidateTransition(TransitionState transitionState)
{
var sp = SpherePath;
var ci = CollisionInfo;
var oi = ObjectInfo;
if (transitionState == TransitionState.OK && sp.CheckPos != sp.CurPos)
{
// Movement succeeded: accept the new position.
sp.CurPos = sp.CheckPos;
sp.CurCellId = sp.CheckCellId;
sp.CurOrientation = sp.CheckOrientation;
// Cache the current-center spheres at the new position.
for (int i = 0; i < sp.NumSphere; i++)
{
sp.GlobalCurrCenter[i].Origin = sp.LocalSphere[i].Origin + sp.CurPos;
sp.GlobalCurrCenter[i].Radius = sp.LocalSphere[i].Radius;
}
sp.SetCheckPos(sp.CurPos, sp.CurCellId);
// moved = true (FramesStationaryFall deferred to full physics port)
}
else if (transitionState == TransitionState.OK)
{
// No movement (same position): accept as-is.
sp.SetCheckPos(sp.CurPos, sp.CurCellId);
}
else if (transitionState != TransitionState.Invalid)
{
// Collision/slide/adjusted: revert to current position.
if (!ci.CollisionNormalValid)
ci.SetCollisionNormal(Vector3.UnitZ); // default: push up
sp.SetCheckPos(sp.CurPos, sp.CurCellId);
transitionState = TransitionState.OK;
}
// Update sliding normal from collision normal.
if (ci.CollisionNormalValid)
ci.SetSlidingNormal(ci.CollisionNormal);
// Preserve contact plane for next step.
ci.LastKnownContactPlaneValid = ci.ContactPlaneValid;
if (ci.ContactPlaneValid)
{
ci.LastKnownContactPlane = ci.ContactPlane;
ci.LastKnownContactPlaneCellId = ci.ContactPlaneCellId;
ci.LastKnownContactPlaneIsWater = ci.ContactPlaneIsWater;
oi.State |= ObjectInfoState.Contact;
if (ci.ContactPlane.Normal.Z >= PhysicsGlobals.LandingZ)
oi.State |= ObjectInfoState.OnWalkable;
else
oi.State &= ~ObjectInfoState.OnWalkable;
}
else
{
oi.State &= ~(ObjectInfoState.Contact | ObjectInfoState.OnWalkable);
}
return transitionState;
}
}

View file

@ -0,0 +1,311 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// Tests for Transition.FindTransitionalPosition (Task 6b).
/// Uses a real PhysicsEngine with simple synthetic TerrainSurfaces so we
/// can exercise the terrain-collision path without mocking internals.
/// </summary>
public class TransitionTests
{
// -----------------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------------
private static float[] LinearHeightTable()
{
var t = new float[256];
for (int i = 0; i < 256; i++) t[i] = i * 1.0f;
return t;
}
/// <summary>
/// Build a flat terrain with every cell at <paramref name="terrainZ"/>.
/// All 81 height entries reference index (int)terrainZ.
/// </summary>
private static TerrainSurface FlatTerrain(float terrainZ)
{
int idx = Math.Clamp((int)terrainZ, 0, 255);
var heights = new byte[81];
Array.Fill(heights, (byte)idx);
return new TerrainSurface(heights, LinearHeightTable());
}
/// <summary>
/// Build a terrain with a linear slope: height increases by 1 for every
/// step in the +X direction (landblock-local X/24 ≈ cell index).
/// </summary>
private static TerrainSurface SlopedTerrain(float baseZ, float risePerCell)
{
var heights = new byte[81];
for (int x = 0; x < 9; x++)
for (int y = 0; y < 9; y++)
{
float z = baseZ + x * risePerCell;
int idx = Math.Clamp((int)z, 0, 255);
heights[x * 9 + y] = (byte)idx;
}
return new TerrainSurface(heights, LinearHeightTable());
}
private static PhysicsEngine MakeEngine(TerrainSurface terrain)
{
var engine = new PhysicsEngine();
engine.AddLandblock(0xA9B4FFFFu, terrain,
Array.Empty<CellSurface>(), Array.Empty<PortalPlane>(),
worldOffsetX: 0f, worldOffsetY: 0f);
return engine;
}
/// <summary>
/// Build a Transition set up for a simple one-sphere character
/// moving from <paramref name="from"/> to <paramref name="to"/>.
/// </summary>
private static Transition MakeTransition(
Vector3 from, Vector3 to,
float sphereRadius = 0.5f,
uint cellId = 0x0001)
{
var t = new Transition();
t.SpherePath.InitPath(from, to, cellId, sphereRadius);
t.ObjectInfo.State = ObjectInfoState.None; // not Contact / OnWalkable yet
return t;
}
// -----------------------------------------------------------------------
// Tests
// -----------------------------------------------------------------------
[Fact]
public void FindTransitionalPosition_FlatTerrain_MovesFullDistance()
{
// Arrange: flat terrain at Z=10, sphere starts at Z=10 (sitting on ground).
const float groundZ = 10f;
var terrain = FlatTerrain(groundZ);
var engine = MakeEngine(terrain);
Vector3 from = new(50f, 50f, groundZ);
Vector3 to = new(55f, 50f, groundZ); // 5 units forward
var transition = MakeTransition(from, to);
// Act
bool ok = transition.FindTransitionalPosition(engine);
// Assert: transition succeeded and position advanced toward the target.
Assert.True(ok);
Assert.True(transition.SpherePath.CurPos.X > from.X,
"Sphere should have moved in +X");
Assert.InRange(transition.SpherePath.CurPos.X, from.X + 1f, to.X + 0.1f);
Assert.InRange(transition.SpherePath.CurPos.Z, groundZ - 0.1f, groundZ + 0.1f);
}
[Fact]
public void FindTransitionalPosition_NullBeginCell_ReturnsFalse()
{
// Arrange: CheckCellId == 0 means "no cell" → must return false.
var terrain = FlatTerrain(0f);
var engine = MakeEngine(terrain);
Vector3 from = new(50f, 50f, 0f);
Vector3 to = new(55f, 50f, 0f);
var transition = MakeTransition(from, to, cellId: 0); // <-- invalid cell
// Act
bool ok = transition.FindTransitionalPosition(engine);
// Assert
Assert.False(ok, "No beginning cell should abort immediately");
}
[Fact]
public void FindTransitionalPosition_NoTerrain_AllowsPassThrough()
{
// Arrange: engine has no landblocks → SampleTerrainZ returns null.
var engine = new PhysicsEngine();
var transition = MakeTransition(new(50f, 50f, 0f), new(55f, 50f, 0f));
// Act — should not throw; terrain Z is unknown so movement is accepted.
bool ok = transition.FindTransitionalPosition(engine);
// OK is fine here — no terrain means no collision, position accepted.
Assert.True(ok);
}
[Fact]
public void FindTransitionalPosition_ZeroMovement_ReturnsTrueWithUnchangedPosition()
{
// Arrange: from == to — zero-step case.
var terrain = FlatTerrain(5f);
var engine = MakeEngine(terrain);
var start = new Vector3(96f, 96f, 5f);
var transition = MakeTransition(start, start);
// Act
bool ok = transition.FindTransitionalPosition(engine);
// Assert
Assert.True(ok);
Assert.Equal(start, transition.SpherePath.CurPos);
}
[Fact]
public void FindTransitionalPosition_SphereAboveTerrain_SnapsTerrain()
{
// Arrange: sphere starts 3 units above flat terrain at Z=0.
// After one step the collision system should push it back onto terrain.
var terrain = FlatTerrain(0f);
var engine = MakeEngine(terrain);
var from = new Vector3(50f, 50f, 3f); // floating above terrain
var to = new Vector3(51f, 50f, 3f);
var transition = MakeTransition(from, to);
// Seed as "in contact" so step-down path fires.
transition.ObjectInfo.State = ObjectInfoState.Contact | ObjectInfoState.OnWalkable;
// Act
bool ok = transition.FindTransitionalPosition(engine);
// Assert: transition returned; sphere should be at or near terrain Z.
Assert.True(ok);
// The Z of CurPos should reflect terrain resolution (could be 0 or clamped).
// We just verify it's ≤ from.Z (gravity pulled it down or it stayed).
Assert.True(transition.SpherePath.CurPos.Z <= from.Z + 0.1f,
$"Expected Z <= {from.Z + 0.1f}, got {transition.SpherePath.CurPos.Z}");
}
[Fact]
public void FindTransitionalPosition_IntoHill_AdjustsOrStops()
{
// Arrange: sloped terrain rises 5 units per cell (~0.6 units per unit of X).
// A sphere with step-height 0.01 should find its movement adjusted.
var terrain = SlopedTerrain(baseZ: 0f, risePerCell: 5f);
var engine = MakeEngine(terrain);
float radius = 0.5f;
var from = new Vector3(12f, 96f, 0f + radius); // foot on terrain
var to = new Vector3(30f, 96f, 0f + radius); // moving up the slope
var transition = MakeTransition(from, to, sphereRadius: radius);
// Act — must not throw.
bool ok = transition.FindTransitionalPosition(engine);
// Assert: result is either blocked (false) or adjusted to a valid Z.
// The important invariant is we didn't crash or return a position
// far below the terrain.
if (ok)
{
float terrainAtFinal = terrain.SampleZ(
transition.SpherePath.CurPos.X, transition.SpherePath.CurPos.Y);
Assert.True(
transition.SpherePath.CurPos.Z >= terrainAtFinal - 0.1f,
$"Sphere went below terrain: posZ={transition.SpherePath.CurPos.Z}, terrainZ={terrainAtFinal}");
}
// ok == false is also acceptable (movement was too steep and blocked).
}
[Fact]
public void StepDown_MaintainsGroundContact()
{
// Arrange: flat terrain at Z=10. The sphere starts in contact with the
// surface and moves horizontally. Because the terrain stays flat the
// Contact flag should persist and no step-down is needed.
// Movement distance is kept < MaxTransitionSteps * radius to avoid the
// retail 30-step safety cap. With radius=1.0 and 15 units: 15 steps < 30.
const float groundZ = 10f;
var terrain = FlatTerrain(groundZ);
var engine = MakeEngine(terrain);
float radius = 1.0f; // larger radius → fewer steps needed for same distance
var from = new Vector3(50f, 96f, groundZ + radius); // foot on terrain
var to = new Vector3(65f, 96f, groundZ + radius); // 15 units → 15 steps
var transition = MakeTransition(from, to, sphereRadius: radius);
transition.ObjectInfo.State = ObjectInfoState.Contact | ObjectInfoState.OnWalkable;
// Act
bool ok = transition.FindTransitionalPosition(engine);
// Assert: movement accepted and sphere stayed on the surface.
Assert.True(ok);
float finalBottom = transition.SpherePath.CurPos.Z - radius;
Assert.True(
finalBottom >= groundZ - PhysicsGlobals.EPSILON,
$"Sphere fell below terrain: bottom={finalBottom:F4}, terrainZ={groundZ}");
Assert.True(
transition.SpherePath.CurPos.X > from.X,
"Sphere should have advanced in +X");
}
[Fact]
public void AdjustOffset_ContactPlanePresent_RemovesIntoPlaneComponent()
{
// White-box check: once a contact plane has been established, the
// AdjustOffset method should prevent the sphere from re-entering the
// surface on subsequent steps.
//
// We verify this by running two successive FindTransitionalPosition calls:
// first to land the sphere on terrain, then to confirm lateral movement
// does not push the sphere below terrain.
var terrain = FlatTerrain(10f);
var engine = MakeEngine(terrain);
const float groundZ = 10f;
const float radius = 0.5f;
// First transition: move from above onto terrain (sphere sits on ground).
var from1 = new Vector3(50f, 50f, groundZ + radius);
var to1 = new Vector3(51f, 50f, groundZ + radius);
var t1 = MakeTransition(from1, to1, radius);
bool ok1 = t1.FindTransitionalPosition(engine);
Assert.True(ok1);
// Second transition: continue moving laterally from the landed position.
var from2 = t1.SpherePath.CurPos;
var to2 = from2 + new Vector3(2f, 0f, 0f);
var t2 = MakeTransition(from2, to2, radius);
// Seed as on-walkable (as if we just landed).
t2.ObjectInfo.State = ObjectInfoState.Contact | ObjectInfoState.OnWalkable;
bool ok2 = t2.FindTransitionalPosition(engine);
Assert.True(ok2);
float bottom = t2.SpherePath.CurPos.Z - radius;
Assert.True(bottom >= groundZ - PhysicsGlobals.EPSILON,
$"Sphere bottom {bottom:F4} should be >= terrain {groundZ}");
}
[Fact]
public void SampleTerrainZ_FindsCorrectLandblock()
{
// Ensure SampleTerrainZ dispatches to the right landblock.
var engine = new PhysicsEngine();
var terrain1 = FlatTerrain(10f);
var terrain2 = FlatTerrain(20f);
// Two landblocks side by side (each covers [0,192) in world space).
engine.AddLandblock(0xAAAA0000u, terrain1,
Array.Empty<CellSurface>(), Array.Empty<PortalPlane>(),
worldOffsetX: 0f, worldOffsetY: 0f);
engine.AddLandblock(0xAAAB0000u, terrain2,
Array.Empty<CellSurface>(), Array.Empty<PortalPlane>(),
worldOffsetX: 192f, worldOffsetY: 0f);
float? z1 = engine.SampleTerrainZ(96f, 96f); // inside lb1
float? z2 = engine.SampleTerrainZ(288f, 96f); // inside lb2
Assert.NotNull(z1);
Assert.NotNull(z2);
Assert.Equal(10f, z1!.Value, precision: 0);
Assert.Equal(20f, z2!.Value, precision: 0);
}
}