233 lines
11 KiB
JavaScript
233 lines
11 KiB
JavaScript
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_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");
|
|
}
|
|
}
|