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:
parent
19aa8ce5d0
commit
88d446d11d
3 changed files with 327 additions and 0 deletions
148
src/AcDream.Core/Physics/PhysicsEngine.cs
Normal file
148
src/AcDream.Core/Physics/PhysicsEngine.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
13
src/AcDream.Core/Physics/ResolveResult.cs
Normal file
13
src/AcDream.Core/Physics/ResolveResult.cs
Normal 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);
|
||||
166
tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs
Normal file
166
tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Physics;
|
||||
|
||||
public class PhysicsEngineTests
|
||||
{
|
||||
private static float[] LinearHeightTable()
|
||||
{
|
||||
var table = new float[256];
|
||||
for (int i = 0; i < 256; i++) table[i] = i * 1.0f;
|
||||
return table;
|
||||
}
|
||||
|
||||
private static byte[] FlatHeightmap(byte value = 50)
|
||||
{
|
||||
var heights = new byte[81];
|
||||
Array.Fill(heights, value);
|
||||
return heights;
|
||||
}
|
||||
|
||||
private PhysicsEngine MakeFlatEngine(float terrainZ = 50f)
|
||||
{
|
||||
var engine = new PhysicsEngine();
|
||||
var terrain = new TerrainSurface(FlatHeightmap((byte)terrainZ), LinearHeightTable());
|
||||
engine.AddLandblock(0xA9B4FFFFu, terrain, Array.Empty<CellSurface>(),
|
||||
worldOffsetX: 0f, worldOffsetY: 0f);
|
||||
return engine;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_FlatTerrain_ZMatchesTerrain()
|
||||
{
|
||||
var engine = MakeFlatEngine(terrainZ: 50f);
|
||||
|
||||
var result = engine.Resolve(
|
||||
new Vector3(96f, 96f, 50f), cellId: 0x0001, delta: new Vector3(1f, 0f, 0f),
|
||||
stepUpHeight: 2f);
|
||||
|
||||
Assert.Equal(50f, result.Position.Z, precision: 1);
|
||||
Assert.True(result.IsOnGround);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_WalkUpSmallSlope_Accepted()
|
||||
{
|
||||
// Heights slope from 50 to 52 across X — small enough for step height.
|
||||
var heights = new byte[81];
|
||||
for (int x = 0; x < 9; x++)
|
||||
for (int y = 0; y < 9; y++)
|
||||
heights[x * 9 + y] = (byte)(50 + x / 4); // gentle slope
|
||||
|
||||
var engine = new PhysicsEngine();
|
||||
var terrain = new TerrainSurface(heights, LinearHeightTable());
|
||||
engine.AddLandblock(0xA9B4FFFFu, terrain, Array.Empty<CellSurface>(),
|
||||
worldOffsetX: 0f, worldOffsetY: 0f);
|
||||
|
||||
var result = engine.Resolve(
|
||||
new Vector3(48f, 96f, 50f), cellId: 0x0001, delta: new Vector3(48f, 0f, 0f),
|
||||
stepUpHeight: 5f);
|
||||
|
||||
Assert.True(result.IsOnGround);
|
||||
Assert.True(result.Position.Z >= 50f); // moved uphill
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_StepUpExceedsHeight_MovementBlocked()
|
||||
{
|
||||
// Heights jump sharply: left half = 50, right half = 100.
|
||||
var heights = new byte[81];
|
||||
for (int x = 0; x < 9; x++)
|
||||
for (int y = 0; y < 9; y++)
|
||||
heights[x * 9 + y] = (byte)(x < 5 ? 50 : 100);
|
||||
|
||||
var engine = new PhysicsEngine();
|
||||
var terrain = new TerrainSurface(heights, LinearHeightTable());
|
||||
engine.AddLandblock(0xA9B4FFFFu, terrain, Array.Empty<CellSurface>(),
|
||||
worldOffsetX: 0f, worldOffsetY: 0f);
|
||||
|
||||
// Try to walk from the low side to the high side.
|
||||
var result = engine.Resolve(
|
||||
new Vector3(96f, 96f, 50f), cellId: 0x0001, delta: new Vector3(48f, 0f, 0f),
|
||||
stepUpHeight: 2f);
|
||||
|
||||
// Movement should be blocked — Z delta (50→100) exceeds step height (2).
|
||||
Assert.Equal(96f, result.Position.X, precision: 1); // didn't move
|
||||
Assert.True(result.IsOnGround);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_EnterIndoorCell_TransitionsToCell()
|
||||
{
|
||||
var engine = new PhysicsEngine();
|
||||
var terrain = new TerrainSurface(FlatHeightmap(50), LinearHeightTable());
|
||||
|
||||
// Indoor cell with a floor at Z=55 covering (40..60, 40..60).
|
||||
var cellVerts = new Dictionary<ushort, Vector3>
|
||||
{
|
||||
[0] = new(40f, 40f, 55f),
|
||||
[1] = new(60f, 40f, 55f),
|
||||
[2] = new(60f, 60f, 55f),
|
||||
[3] = new(40f, 60f, 55f),
|
||||
};
|
||||
var cellPolys = new List<List<short>> { new() { 0, 1, 2, 3 } };
|
||||
var cell = new CellSurface(0x0100, cellVerts, cellPolys);
|
||||
|
||||
engine.AddLandblock(0xA9B4FFFFu, terrain, new[] { cell },
|
||||
worldOffsetX: 0f, worldOffsetY: 0f);
|
||||
|
||||
// Walk from outdoor (30, 50) into the cell's floor area (50, 50).
|
||||
var result = engine.Resolve(
|
||||
new Vector3(30f, 50f, 50f), cellId: 0x0001, delta: new Vector3(20f, 0f, 0f),
|
||||
stepUpHeight: 10f);
|
||||
|
||||
// Should transition to the indoor cell and snap to its floor Z.
|
||||
Assert.Equal(0x0100u, result.CellId);
|
||||
Assert.Equal(55f, result.Position.Z, precision: 1);
|
||||
Assert.True(result.IsOnGround);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_LeaveIndoorCell_TransitionsToOutdoor()
|
||||
{
|
||||
var engine = new PhysicsEngine();
|
||||
var terrain = new TerrainSurface(FlatHeightmap(50), LinearHeightTable());
|
||||
|
||||
var cellVerts = new Dictionary<ushort, Vector3>
|
||||
{
|
||||
[0] = new(40f, 40f, 55f),
|
||||
[1] = new(60f, 40f, 55f),
|
||||
[2] = new(60f, 60f, 55f),
|
||||
[3] = new(40f, 60f, 55f),
|
||||
};
|
||||
var cellPolys = new List<List<short>> { new() { 0, 1, 2, 3 } };
|
||||
var cell = new CellSurface(0x0100, cellVerts, cellPolys);
|
||||
|
||||
engine.AddLandblock(0xA9B4FFFFu, terrain, new[] { cell },
|
||||
worldOffsetX: 0f, worldOffsetY: 0f);
|
||||
|
||||
// Start inside the cell, walk out.
|
||||
var result = engine.Resolve(
|
||||
new Vector3(50f, 50f, 55f), cellId: 0x0100, delta: new Vector3(-20f, 0f, 0f),
|
||||
stepUpHeight: 10f);
|
||||
|
||||
// Should transition back to outdoor.
|
||||
Assert.True(result.CellId < 0x0100u);
|
||||
Assert.Equal(50f, result.Position.Z, precision: 1);
|
||||
Assert.True(result.IsOnGround);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_NoSurfaceUnderEntity_NotOnGround()
|
||||
{
|
||||
var engine = new PhysicsEngine();
|
||||
// No landblocks loaded — entity is floating in void.
|
||||
|
||||
var result = engine.Resolve(
|
||||
new Vector3(0f, 0f, 100f), cellId: 0x0001, delta: Vector3.Zero,
|
||||
stepUpHeight: 2f);
|
||||
|
||||
Assert.False(result.IsOnGround);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue