Files
Battle-for-Tismo/tests/heightmap_test.js
T

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");
}
}