import { TestCase } from "./framework/test_case.js"; import { MapTopology, key, mapToLocal, HEX_W, HEX_H } from "../shared/hex.js"; import { CORNERS, EDGE_SEGMENTS, distortedBoundary } from "../client/js/map_view/distortion.js"; import { HEIGHT_LIFT, MOUNTAIN_ROCK_LOW, MOUNTAIN_ROCK_HIGH, TERRAIN_LODS, CAMERA_TILT, terrainLod } from "../client/js/map_view/constants.js"; import { HeightField } from "../client/js/map_view/heightmap.js"; function fixture(terrainAt, size = 5, cylindrical = false) { const topology = new MapTopology({ x: size, y: size }, cylindrical); const tiles = {}; for (let y = topology.originY; y < topology.originY + size; y++) { for (let x = topology.originX; x < topology.originX + size; x++) { tiles[key(x, y)] = { terrainType: terrainAt(x, y), col: 0 }; } } return { seed: 90210, topology, tiles, _period: 0 }; } export class HeightmapTest extends TestCase { test_is_deterministic_for_a_seed() { const view = fixture(() => "Hills"); const first = new HeightField(view); const second = new HeightField(view); for (let x = -2; x <= 2; x++) { for (let y = -2; y <= 2; y++) { this.assertEqual(first.tileHeight(x, y), second.tileHeight(x, y), "tile height"); } } const point = mapToLocal(0, 0); this.assertApprox(first.at(point.x, point.y), second.at(point.x, point.y), 1e-12, "point height"); const other = new HeightField({ ...view, seed: 1234 }); this.assertNotEqual(first.tileHeight(1, 0), other.tileHeight(1, 0), "a new seed reshapes the relief"); } test_terrain_type_raises_the_relief() { const field = new HeightField(fixture((x) => (x === 0 ? "Mountain" : "Land"))); const mountain = field.tileHeight(0, 0); const land = field.tileHeight(1, 0); this.assertGreater(mountain, land, "mountains sit above the plains"); this.assertGreater(mountain, 0.5, "a mountain is more than half height"); this.assertLess(land, 0.2, "plains stay low"); } test_the_sea_stays_flat() { const field = new HeightField(fixture(() => "Sea")); for (let x = -2; x <= 2; x++) { for (let y = -2; y <= 2; y++) { this.assertEqual(field.tileHeight(x, y), 0, "the sea never rises"); } } const point = mapToLocal(0, 0); this.assertEqual(field.at(point.x, point.y), 0, "points over the sea stay at zero"); } test_tundra_stays_flat_despite_its_rocky_texture() { const field = new HeightField(fixture(() => "Tundra")); for (let x = -2; x <= 2; x++) { for (let y = -2; y <= 2; y++) { this.assertEqual(field.tileHeight(x, y), 0, "tundra never rises"); } } } test_the_open_sea_stays_flat_beside_a_high_coast() { // The distance blend would pull a coastal sea point up towards the mountain // next to it; the sea mask pins it to exactly zero instead. const field = new HeightField(fixture((x) => (x === 0 ? "Mountain" : "Sea"))); const seaCentre = mapToLocal(1, 0); const sea = field.sample(field.ring(1, 0), seaCentre.x, seaCentre.y); this.assertEqual(sea.h, 0, "the open sea sits at zero"); this.assertEqual(sea.dx, 0, "the sea has no slope"); this.assertEqual(sea.dy, 0, "the sea has no slope"); // The mountain on the far side of the coast keeps its relief. const landCentre = mapToLocal(0, 0); this.assertGreater(field.at(landCentre.x, landCentre.y), 0.3, "the land still rises"); } test_the_coast_stays_watertight_where_land_meets_sea() { // The mask is a function of the point alone, so the land fan and the sea // fan that share a corner must agree even though only the land is drawn. const field = new HeightField(fixture((x) => (x === 0 ? "Mountain" : "Sea"))); const centre = mapToLocal(0, 0); const corner = { x: centre.x + CORNERS[0].x, y: centre.y + CORNERS[0].y }; const fromLand = field.blend(field.ring(0, 0), corner.x, corner.y); const fromSea = field.blend(field.ring(1, 0), corner.x, corner.y); this.assertApprox(fromLand, fromSea, 1e-9, "both fans agree at the coast"); } test_sea_at_classifies_the_nearest_tile() { const field = new HeightField(fixture((x) => (x === 0 ? "Land" : "Sea"))); const land = mapToLocal(0, 0); const sea = mapToLocal(1, 0); this.assertFalse(field.seaAt(land.x, land.y), "a land centre is not sea"); this.assertTrue(field.seaAt(sea.x, sea.y), "a sea centre is sea"); } test_heights_are_normalised_below_one() { // With the tallest terrain everywhere the blend can only average down, so // every sample must stay within the unit the lift is calibrated for. const field = new HeightField(fixture(() => "Mountain")); for (let x = -3; x <= 3; x += 0.5) { for (let y = -3; y <= 3; y += 0.5) { const height = field.at(x, y); this.assertTrue(Number.isFinite(height), "finite height"); this.assertTrue(height >= 0 && height <= 1, `height ${height} is inside [0, 1]`); } } } test_displacement_never_exceeds_one_tile() { // A height-1 feature is lifted HEIGHT_LIFT before the zoom; the ground gap // to the northern neighbour is HEX_H * sin(elevation). Keeping the lift at // or under that is the "must not exceed one" bound on the maximum height. this.assertTrue( HEIGHT_LIFT <= HEX_H * CAMERA_TILT, `the lift ${HEIGHT_LIFT} exceeds one tile spacing` ); } test_shared_corners_agree_so_the_relief_stays_watertight() { // Tile (0,0)'s right corner is tile (1,0)'s top-left corner. Both tiles must // lift it identically or the mesh would tear along their shared edge. const field = new HeightField(fixture((x) => (x >= 1 ? "Mountain" : "Land"))); const centreA = mapToLocal(0, 0); const centreB = mapToLocal(1, 0); const corner = { x: centreA.x + CORNERS[0].x, y: centreA.y + CORNERS[0].y }; const fromA = field.blend(field.ring(0, 0), corner.x, corner.y); const fromB = field.blend(field.ring(1, 0), corner.x, corner.y); this.assertApprox(fromA, fromB, 1e-9, "the shared corner is lifted identically"); } test_the_wavy_shared_edge_stays_watertight() { // Every sampled point of the shared edge, including the ones that stray off // the ideal hexagon, must read the same from both tiles' rings. const field = new HeightField(fixture((x) => (x === 0 ? "Mountain" : "Land"))); const ringA = field.ring(0, 0); const ringB = field.ring(1, 0); const boundary = distortedBoundary(2024, mapToLocal(0, 0), 0); for (let j = 0; j < EDGE_SEGMENTS; j++) { const point = boundary[5 * EDGE_SEGMENTS + j]; this.assertApprox( field.blend(ringA, point.x, point.y), field.blend(ringB, point.x, point.y), 1e-9, "the wavy edge is watertight" ); } } test_the_gradient_points_uphill_and_stays_watertight() { const field = new HeightField(fixture((x) => (x >= 1 ? "Mountain" : "Land"))); const centreA = mapToLocal(0, 0); const centreB = mapToLocal(1, 0); // The midpoint of the two centres is the middle of their shared edge, away // from the ambiguous corner where the nearest edge flips. const edge = { x: (centreA.x + centreB.x) / 2, y: (centreA.y + centreB.y) / 2 }; const plain = field.sample(field.ring(0, 0), edge.x, edge.y); const mountain = field.sample(field.ring(1, 0), edge.x, edge.y); // The two fans meet at the same height on the shared edge. this.assertApprox(plain.h, mountain.h, 1e-9, "the shared edge is watertight"); this.assertApprox(plain.h, field.blend(field.ring(0, 0), edge.x, edge.y), 1e-9, "sample reports the same height as blend"); // Both fans share one smooth normal, pointing uphill, so the shading does // not snap to a different direction at the tile edge. this.assertApprox(plain.dx, mountain.dx, 1e-9, "both fans share one normal"); this.assertApprox(plain.dy, mountain.dy, 1e-9, "both fans share one normal"); this.assertGreater(mountain.dx, 0, "the shared normal points up the slope"); } test_a_mountain_rises_inside_its_hexagon_not_in_its_neighbour() { const field = new HeightField(fixture((x) => (x === 0 ? "Mountain" : "Land"))); const mountainCentre = mapToLocal(0, 0); const plainCentre = mapToLocal(1, 0); const peak = field.at(mountainCentre.x, mountainCentre.y); const plain = field.at(plainCentre.x, plainCentre.y); this.assertGreater(peak, plain + 0.25, "the mountain is much taller"); // The shared edge (the midpoint of the two centres) stays near the plain // rather than halfway up the mountain. const edge = field.at( (mountainCentre.x + plainCentre.x) / 2, (mountainCentre.y + plainCentre.y) / 2 ); this.assertLess(edge, plain + 0.2, "the edge stays near the plain"); } test_a_mountain_slopes_smoothly_without_a_spike() { const field = new HeightField(fixture((x) => (x === 0 ? "Mountain" : "Land"))); const centre = mapToLocal(0, 0); const edge = { x: (centre.x + mapToLocal(1, 0).x) / 2, y: (centre.y + mapToLocal(1, 0).y) / 2 }; const mid = { x: (centre.x + edge.x) / 2, y: (centre.y + edge.y) / 2 }; const peak = field.at(centre.x, centre.y); const plain = field.at(edge.x, edge.y); // Halfway down the tile the height is about halfway between the plain and // the peak: the subdivision of this should read as a slope, not a spike. this.assertApprox(field.at(mid.x, mid.y), (peak + plain) / 2, 0.08, "the slope is halfway up at the halfway point"); } test_flat_sea_has_no_gradient() { const field = new HeightField(fixture(() => "Sea")); const centre = mapToLocal(0, 0); const sample = field.sample(field.ring(0, 0), centre.x + 6, centre.y - 4); this.assertEqual(sample.dx, 0); this.assertEqual(sample.dy, 0); } test_a_cylindrical_field_wraps_seamlessly() { const view = fixture(() => "Hills", 8, true); const field = new HeightField(view); const width = view.topology.width; for (let y = -3; y <= 3; y++) { this.assertEqual( field.tileHeight(view.topology.originX + width, y), field.tileHeight(view.topology.originX, y), "the seam joins itself" ); } } test_the_mountain_snow_line_band_is_ordered() { // smoothstep needs low < high, and the band has to sit inside the [0, 1] // height range or the cap would never appear (or always would). this.assertGreater(MOUNTAIN_ROCK_HIGH, MOUNTAIN_ROCK_LOW, "the cap band is ordered"); this.assertGreaterOrEqual(MOUNTAIN_ROCK_LOW, 0, "the band starts inside the range"); this.assertLess(MOUNTAIN_ROCK_HIGH, 1, "the band ends inside the range"); } test_level_of_detail_coarsens_with_zoom() { this.assertEqual(terrainLod(1.5), 0, "close in uses the detailed mesh"); this.assertEqual(terrainLod(0.4), 1, "mid zoom uses the middle mesh"); this.assertEqual(terrainLod(0.1), TERRAIN_LODS.length - 1, "far out uses the coarsest"); for (let i = 1; i < TERRAIN_LODS.length; i++) { this.assertLess( TERRAIN_LODS[i].segments, TERRAIN_LODS[i - 1].segments, "each level is coarser than the one before" ); } this.assertEqual(TERRAIN_LODS[0].segments, EDGE_SEGMENTS, "the closest level keeps the wavy edge"); this.assertEqual(HEX_W, 63, "sanity"); } }