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:
parent
9ea8ae5191
commit
e08a06ac5b
3 changed files with 836 additions and 3 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue