diff --git a/src/AcDream.Core/Physics/TerrainSurface.cs b/src/AcDream.Core/Physics/TerrainSurface.cs new file mode 100644 index 0000000..b1bc4cf --- /dev/null +++ b/src/AcDream.Core/Physics/TerrainSurface.cs @@ -0,0 +1,79 @@ +using System; + +namespace AcDream.Core.Physics; + +/// +/// Outdoor terrain height resolver for a single landblock. Performs +/// bilinear interpolation of the 9×9 heightmap grid to produce the +/// ground Z at any (localX, localY) within the 192×192 landblock +/// footprint. Also computes the outdoor cell ID for AC's position +/// encoding. +/// +/// +/// Algorithm ported from GameWindow.SampleTerrainZ (which was inlined +/// and not reusable). The heightmap is indexed x-major: +/// heights[x * 9 + y]; each byte is a lookup into +/// (256-entry float array from +/// Region.LandDefs.LandHeightTable). +/// +/// +public sealed class TerrainSurface +{ + private const int HeightmapSide = 9; + private const float CellSize = 24f; + private const int CellsPerSide = 8; // 192 / 24 + + private readonly byte[] _heights; + private readonly float[] _heightTable; + + public TerrainSurface(byte[] heights, float[] heightTable) + { + ArgumentNullException.ThrowIfNull(heights); + ArgumentNullException.ThrowIfNull(heightTable); + if (heights.Length < 81) + throw new ArgumentException("heights must have 81 entries", nameof(heights)); + if (heightTable.Length < 256) + throw new ArgumentException("heightTable must have 256 entries", nameof(heightTable)); + + _heights = heights; + _heightTable = heightTable; + } + + /// + /// Bilinear-interpolated terrain Z at (localX, localY) in + /// landblock-local coordinates (0..192 range). + /// + public float SampleZ(float localX, float localY) + { + float fx = Math.Clamp(localX / CellSize, 0f, HeightmapSide - 1f); + float fy = Math.Clamp(localY / CellSize, 0f, HeightmapSide - 1f); + + int x0 = Math.Min((int)fx, HeightmapSide - 2); + int y0 = Math.Min((int)fy, HeightmapSide - 2); + int x1 = x0 + 1; + int y1 = y0 + 1; + float tx = fx - x0; + float ty = fy - y0; + + float h00 = _heightTable[_heights[x0 * HeightmapSide + y0]]; + float h10 = _heightTable[_heights[x1 * HeightmapSide + y0]]; + float h01 = _heightTable[_heights[x0 * HeightmapSide + y1]]; + float h11 = _heightTable[_heights[x1 * HeightmapSide + y1]]; + + float hx0 = h00 * (1 - tx) + h10 * tx; + float hx1 = h01 * (1 - tx) + h11 * tx; + return hx0 * (1 - ty) + hx1 * ty; + } + + /// + /// Compute the outdoor cell ID for the given landblock-local position. + /// Outdoor cells are an 8×8 grid of 24×24-unit cells numbered + /// 0x0001..0x0040. Cell (0,0) at position (0,0) is 0x0001. + /// + public uint ComputeOutdoorCellId(float localX, float localY) + { + int cx = Math.Clamp((int)(localX / CellSize), 0, CellsPerSide - 1); + int cy = Math.Clamp((int)(localY / CellSize), 0, CellsPerSide - 1); + return (uint)(1 + cx * CellsPerSide + cy); + } +} diff --git a/tests/AcDream.Core.Tests/Physics/TerrainSurfaceTests.cs b/tests/AcDream.Core.Tests/Physics/TerrainSurfaceTests.cs new file mode 100644 index 0000000..fb70cd4 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/TerrainSurfaceTests.cs @@ -0,0 +1,96 @@ +using System.Numerics; +using AcDream.Core.Physics; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +public class TerrainSurfaceTests +{ + // A height table where index N maps to N * 1.0f (linear). + // Makes test assertions predictable: height byte 10 → Z = 10.0. + private static float[] LinearHeightTable() + { + var table = new float[256]; + for (int i = 0; i < 256; i++) table[i] = i * 1.0f; + return table; + } + + // A flat heightmap where every vertex is height byte 50. + private static byte[] FlatHeightmap(byte value = 50) + { + var heights = new byte[81]; + Array.Fill(heights, value); + return heights; + } + + [Fact] + public void SampleZ_FlatTerrain_ReturnsSameValueEverywhere() + { + var surface = new TerrainSurface(FlatHeightmap(50), LinearHeightTable()); + + Assert.Equal(50f, surface.SampleZ(0f, 0f)); + Assert.Equal(50f, surface.SampleZ(96f, 96f)); + Assert.Equal(50f, surface.SampleZ(191f, 191f)); + } + + [Fact] + public void SampleZ_SlopeAlongX_InterpolatesLinearly() + { + // Heights increase along X: column 0 = byte 10, column 8 = byte 90. + // Each column step is (90-10)/8 = 10 bytes. + var heights = new byte[81]; + for (int x = 0; x < 9; x++) + for (int y = 0; y < 9; y++) + heights[x * 9 + y] = (byte)(10 + x * 10); + + var surface = new TerrainSurface(heights, LinearHeightTable()); + + // At x=0 (vertex 0): Z = 10 + Assert.Equal(10f, surface.SampleZ(0f, 96f), precision: 1); + // At x=96 (midpoint, vertex 4): Z = 50 + Assert.Equal(50f, surface.SampleZ(96f, 96f), precision: 1); + // At x=192 (vertex 8): Z = 90 + Assert.Equal(90f, surface.SampleZ(192f, 96f), precision: 1); + // At x=48 (between vertex 2 and 3): Z = 30 + 0.5 * 10 = 35 + // vertex 2 = byte 30, vertex 3 = byte 40, midpoint = 35 + Assert.Equal(35f, surface.SampleZ(60f, 96f), precision: 1); + } + + [Fact] + public void SampleZ_ClampsOutOfBounds() + { + var surface = new TerrainSurface(FlatHeightmap(42), LinearHeightTable()); + + // Negative coordinates clamp to 0 + Assert.Equal(42f, surface.SampleZ(-10f, -10f)); + // Beyond 192 clamps to boundary + Assert.Equal(42f, surface.SampleZ(300f, 300f)); + } + + [Fact] + public void ComputeOutdoorCellId_Origin_ReturnsFirst() + { + var surface = new TerrainSurface(FlatHeightmap(), LinearHeightTable()); + + // Cell (0,0) at position (0,0) → cell ID 0x0001 + Assert.Equal(0x0001u, surface.ComputeOutdoorCellId(0f, 0f)); + } + + [Fact] + public void ComputeOutdoorCellId_SecondColumn_ReturnsCorrect() + { + var surface = new TerrainSurface(FlatHeightmap(), LinearHeightTable()); + + // 24 units in X = cell (1, 0) → cell ID 0x0001 + 1*8 = 0x0009 + Assert.Equal(0x0009u, surface.ComputeOutdoorCellId(24f, 0f)); + } + + [Fact] + public void ComputeOutdoorCellId_LastCell_Returns0x0040() + { + var surface = new TerrainSurface(FlatHeightmap(), LinearHeightTable()); + + // Cell (7,7) at position (191,191) → 0x0001 + 7*8 + 7 = 0x0040 + Assert.Equal(0x0040u, surface.ComputeOutdoorCellId(191f, 191f)); + } +}