diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs new file mode 100644 index 0000000..3ddf887 --- /dev/null +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -0,0 +1,148 @@ +using System; +using System.Collections.Generic; +using System.Numerics; + +namespace AcDream.Core.Physics; + +/// +/// Top-level physics resolver that combines and +/// to resolve entity movement with step-height +/// enforcement and outdoor/indoor cell transitions. +/// +/// +/// Landblocks are registered via with their +/// terrain, indoor cells, and world-space offsets. +/// 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. +/// +/// +public sealed class PhysicsEngine +{ + private readonly Dictionary _landblocks = new(); + + private sealed record LandblockPhysics( + TerrainSurface Terrain, + IReadOnlyList Cells, + float WorldOffsetX, + float WorldOffsetY); + + /// + /// Register a landblock with its terrain surface, indoor cells, and + /// world-space origin offset. + /// + public void AddLandblock(uint landblockId, TerrainSurface terrain, + IReadOnlyList cells, float worldOffsetX, float worldOffsetY) + { + _landblocks[landblockId] = new LandblockPhysics(terrain, cells, worldOffsetX, worldOffsetY); + } + + /// + /// Remove a previously registered landblock. + /// + public void RemoveLandblock(uint landblockId) => _landblocks.Remove(landblockId); + + /// + /// Resolve an entity's movement from by + /// applying (XY only) and computing the correct Z + /// from the terrain or indoor cell floor beneath the candidate position. + /// + /// + /// Step-height enforcement rejects horizontal movement when the upward Z + /// change exceeds . Downhill movement is + /// always accepted. Returns false + /// when no loaded landblock covers the candidate position. + /// + /// + 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); + } +} diff --git a/src/AcDream.Core/Physics/ResolveResult.cs b/src/AcDream.Core/Physics/ResolveResult.cs new file mode 100644 index 0000000..cc7fef8 --- /dev/null +++ b/src/AcDream.Core/Physics/ResolveResult.cs @@ -0,0 +1,13 @@ +using System.Numerics; + +namespace AcDream.Core.Physics; + +/// +/// Result of : the validated +/// position after collision, the cell the entity ended up in, +/// and whether they're standing on a surface. +/// +public readonly record struct ResolveResult( + Vector3 Position, + uint CellId, + bool IsOnGround); diff --git a/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs b/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs new file mode 100644 index 0000000..10b92b8 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs @@ -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(), + 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(), + 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(), + 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 + { + [0] = new(40f, 40f, 55f), + [1] = new(60f, 40f, 55f), + [2] = new(60f, 60f, 55f), + [3] = new(40f, 60f, 55f), + }; + var cellPolys = new List> { 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 + { + [0] = new(40f, 40f, 55f), + [1] = new(60f, 40f, 55f), + [2] = new(60f, 60f, 55f), + [3] = new(40f, 60f, 55f), + }; + var cellPolys = new List> { 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); + } +}