feat(core): Phase B.3 — PhysicsEngine (top-level collision resolver)

Combines TerrainSurface + CellSurface into a single Resolve() API
that handles outdoor terrain walking, indoor floor walking,
outdoor<->indoor cell transitions, step-height enforcement, and
ground detection.

Step-height blocks upward Z deltas exceeding the limit (walls,
cliffs); downhill movement is always accepted. Indoor transitions
pick the cell whose floor Z is closest to the entity's current Z
(handles multi-story buildings). Reports IsOnGround=false when
no landblock or surface covers the entity's position (gravity
applied by the caller).

One API mismatch fixed vs plan: plan encoded the upper 16 landblock
bits into the returned cell ID, but the tests assert the raw cell ID
(0x0100, <0x0100) — so Resolve returns targetCellId directly.

6 new tests covering flat terrain, slopes, step-height rejection,
indoor entry/exit, and void detection. 243 total, all green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-12 09:54:28 +02:00
parent 19aa8ce5d0
commit 88d446d11d
3 changed files with 327 additions and 0 deletions

View file

@ -0,0 +1,148 @@
using System;
using System.Collections.Generic;
using System.Numerics;
namespace AcDream.Core.Physics;
/// <summary>
/// Top-level physics resolver that combines <see cref="TerrainSurface"/> and
/// <see cref="CellSurface"/> to resolve entity movement with step-height
/// enforcement and outdoor/indoor cell transitions.
///
/// <para>
/// Landblocks are registered via <see cref="AddLandblock"/> with their
/// terrain, indoor cells, and world-space offsets. <see cref="Resolve"/>
/// takes a current position, the entity's current cell ID, a movement delta,
/// and a step-up height limit; it returns the validated new position, the
/// updated cell ID, and whether the entity is standing on a surface.
/// </para>
/// </summary>
public sealed class PhysicsEngine
{
private readonly Dictionary<uint, LandblockPhysics> _landblocks = new();
private sealed record LandblockPhysics(
TerrainSurface Terrain,
IReadOnlyList<CellSurface> Cells,
float WorldOffsetX,
float WorldOffsetY);
/// <summary>
/// Register a landblock with its terrain surface, indoor cells, and
/// world-space origin offset.
/// </summary>
public void AddLandblock(uint landblockId, TerrainSurface terrain,
IReadOnlyList<CellSurface> cells, float worldOffsetX, float worldOffsetY)
{
_landblocks[landblockId] = new LandblockPhysics(terrain, cells, worldOffsetX, worldOffsetY);
}
/// <summary>
/// Remove a previously registered landblock.
/// </summary>
public void RemoveLandblock(uint landblockId) => _landblocks.Remove(landblockId);
/// <summary>
/// Resolve an entity's movement from <paramref name="currentPos"/> by
/// applying <paramref name="delta"/> (XY only) and computing the correct Z
/// from the terrain or indoor cell floor beneath the candidate position.
///
/// <para>
/// Step-height enforcement rejects horizontal movement when the upward Z
/// change exceeds <paramref name="stepUpHeight"/>. Downhill movement is
/// always accepted. Returns <see cref="ResolveResult.IsOnGround"/> false
/// when no loaded landblock covers the candidate position.
/// </para>
/// </summary>
public ResolveResult Resolve(Vector3 currentPos, uint cellId, Vector3 delta, float stepUpHeight)
{
var candidatePos = currentPos + new Vector3(delta.X, delta.Y, 0f);
// Find the landblock this candidate position falls in.
LandblockPhysics? physics = null;
foreach (var kvp in _landblocks)
{
var lb = kvp.Value;
float localX = candidatePos.X - lb.WorldOffsetX;
float localY = candidatePos.Y - lb.WorldOffsetY;
if (localX >= 0 && localX < 192f && localY >= 0 && localY < 192f)
{
physics = lb;
break;
}
}
if (physics is null)
return new ResolveResult(candidatePos, cellId, IsOnGround: false);
float localCandX = candidatePos.X - physics.WorldOffsetX;
float localCandY = candidatePos.Y - physics.WorldOffsetY;
// Check if the candidate position falls on any indoor cell floor.
// Pick the cell whose floor Z is closest to the entity's current Z.
CellSurface? bestCell = null;
float? bestCellZ = null;
float bestZDist = float.MaxValue;
foreach (var cell in physics.Cells)
{
float? floorZ = cell.SampleFloorZ(candidatePos.X, candidatePos.Y);
if (floorZ is not null)
{
float dist = MathF.Abs(floorZ.Value - currentPos.Z);
if (dist < bestZDist)
{
bestCell = cell;
bestCellZ = floorZ;
bestZDist = dist;
}
}
}
// Determine target surface Z and cell.
float terrainZ = physics.Terrain.SampleZ(localCandX, localCandY);
float targetZ;
uint targetCellId;
bool currentlyIndoor = cellId >= 0x0100;
if (currentlyIndoor && bestCellZ is not null)
{
// Stay indoors on the best cell's floor.
targetZ = bestCellZ.Value;
targetCellId = bestCell!.CellId;
}
else if (currentlyIndoor && bestCellZ is null)
{
// Walked out of the current cell — transition to outdoor.
targetZ = terrainZ;
targetCellId = physics.Terrain.ComputeOutdoorCellId(localCandX, localCandY);
}
else if (!currentlyIndoor && bestCellZ is not null
&& MathF.Abs(bestCellZ.Value - currentPos.Z) < stepUpHeight + 2f)
{
// Walked into an indoor cell from outdoor — transition to indoor.
targetZ = bestCellZ.Value;
targetCellId = bestCell!.CellId;
}
else
{
// Stay outdoors on terrain.
targetZ = terrainZ;
targetCellId = physics.Terrain.ComputeOutdoorCellId(localCandX, localCandY);
}
// Step-height enforcement: block upward movement that exceeds the limit.
float zDelta = targetZ - currentPos.Z;
if (zDelta > stepUpHeight)
{
// Too steep to step up — reject horizontal movement.
return new ResolveResult(currentPos, cellId, IsOnGround: true);
}
return new ResolveResult(
new Vector3(candidatePos.X, candidatePos.Y, targetZ),
targetCellId,
IsOnGround: true);
}
}

View file

@ -0,0 +1,13 @@
using System.Numerics;
namespace AcDream.Core.Physics;
/// <summary>
/// Result of <see cref="PhysicsEngine.Resolve"/>: the validated
/// position after collision, the cell the entity ended up in,
/// and whether they're standing on a surface.
/// </summary>
public readonly record struct ResolveResult(
Vector3 Position,
uint CellId,
bool IsOnGround);