import { TestCase } from "./framework/test_case.js"; import { MapTopology, key, mapToLocal } from "../shared/hex.js"; import { GLMapRenderer } from "../client/js/map_view/webgl.js"; import { TEXTURE_DESIRED_REPEAT, blendBeach, blendTextures, isTexturedLand, landTexture, overlayNormal, terrainLayers, textureRepeat, vertexTextureWeights, } from "../client/js/map_view/textures.js"; // A flat fixture with the given terrain per coordinate, plus the renderer // helpers `_textureRing`/`_textureWeights` under test. function fixture(terrainAt, size = 5) { const topology = new MapTopology({ x: size, y: size }, false); 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 }; } } const renderer = Object.create(GLMapRenderer.prototype); return { renderer, topology, tiles, view: { seed: 7, topology, tiles, _period: 0 } }; } export class TexturesTest extends TestCase { test_grassy_land_uses_grass_and_desert_uses_sand() { for (const type of ["Forest", "Hills", "Land", "Mountain", "Tundra"]) { this.assertEqual(landTexture(type).base, "grass", `${type} base`); } this.assertEqual(landTexture("Desert").base, "sand", "desert base"); this.assertNull(landTexture("Sea")); this.assertNull(landTexture("Ice")); } test_only_terrain_with_a_tiling_texture_is_blended() { this.assertTrue(isTexturedLand({ terrainType: "Land" })); this.assertTrue(isTexturedLand({ terrainType: "Desert" })); this.assertTrue(isTexturedLand({ terrainType: "Mountain" })); this.assertTrue(isTexturedLand({ terrainType: "Tundra" })); this.assertFalse(isTexturedLand({ terrainType: "Sea" })); this.assertFalse(isTexturedLand(null)); } test_forest_and_mountains_overlay_the_base() { this.assertEqual(terrainLayers("Forest"), [ "assets/textures/forest.png", "assets/textures/grass.png", ]); this.assertEqual(terrainLayers("Hills"), ["assets/textures/grass.png"]); this.assertEqual(terrainLayers("Mountain"), [ "assets/textures/mountain.png", "assets/textures/grass.png", ]); this.assertEqual(terrainLayers("Tundra"), [ "assets/textures/mountain.png", "assets/textures/grass.png", ]); this.assertEqual(terrainLayers("Desert"), ["assets/textures/sand.png"]); this.assertEqual(terrainLayers("Land"), ["assets/textures/grass.png"]); this.assertEmpty(terrainLayers("Ice"), "the atlas terrains keep their art"); } test_only_mountains_have_a_normal_map() { this.assertEqual(overlayNormal("mountain"), "mountains_normal_map"); this.assertNull(overlayNormal("hills"), "hills have no overlay any more"); this.assertNull(overlayNormal("forest")); this.assertNull(overlayNormal("sand")); } test_the_repeat_snaps_to_a_whole_number_across_the_wrap() { const repeat = textureRepeat(1890); this.assertApprox(Math.round(1890 / repeat), 1890 / repeat, 1e-9, "a whole number of repeats"); this.assertApprox(repeat, TEXTURE_DESIRED_REPEAT, 4, "close to the desired density"); this.assertEqual(textureRepeat(0), TEXTURE_DESIRED_REPEAT, "flat maps use the desired repeat"); } test_a_shared_edge_blends_to_an_even_mix() { // (0,0) is land and (1,0) is desert; the point sits on their shared edge, // halfway between the two centres. const blend = blendTextures([ { tile: { terrainType: "Land" }, dx: -23.625, dy: -14 }, { tile: { terrainType: "Desert" }, dx: 23.625, dy: 14 }, ]); this.assertApprox(blend.grass, 0.5, 1e-9); this.assertApprox(blend.sand, 0.5, 1e-9); } test_a_tile_centre_stays_almost_purely_its_own_texture() { const blend = blendTextures([ { tile: { terrainType: "Desert" }, dx: 0, dy: 0 }, { tile: { terrainType: "Land" }, dx: 47.25, dy: 28 }, { tile: { terrainType: "Land" }, dx: 47.25, dy: -28 }, { tile: { terrainType: "Land" }, dx: 0, dy: 56 }, ]); this.assertGreater(blend.sand, 0.9, "the tile centre keeps its sand"); this.assertLess(blend.grass, 0.1, "the neighbours barely bleed in"); } test_overlays_fade_towards_grass() { const centre = blendTextures([ { tile: { terrainType: "Forest" }, dx: 0, dy: 0 }, { tile: { terrainType: "Land" }, dx: 47.25, dy: 28 }, { tile: { terrainType: "Land" }, dx: 0, dy: 56 }, ]); this.assertGreater(centre.forest, 0.9, "a forest tile keeps its overlay"); const edge = blendTextures([ { tile: { terrainType: "Forest" }, dx: -23.625, dy: -14 }, { tile: { terrainType: "Land" }, dx: 23.625, dy: 14 }, ]); this.assertApprox(edge.forest, 0.5, 1e-9, "the overlay is half strength on the shared edge"); const hills = blendTextures([{ tile: { terrainType: "Hills" }, dx: 0, dy: 0 }]); this.assertApprox(hills.grass, 1, 1e-9, "hills sit on grass"); this.assertApprox(hills.forest, 0, 1e-9, "hills have no overlay of their own"); this.assertApprox(hills.mountain, 0, 1e-9, "hills have no mountain overlay"); const mountain = blendTextures([{ tile: { terrainType: "Mountain" }, dx: 0, dy: 0 }]); this.assertApprox(mountain.mountain, 1, 1e-9, "a mountain keeps its overlay"); this.assertApprox(mountain.grass, 1, 1e-9, "mountains sit on grass"); const tundra = blendTextures([{ tile: { terrainType: "Tundra" }, dx: 0, dy: 0 }]); this.assertApprox(tundra.flat, 1, 1e-9, "tundra rides in the flat rock channel"); this.assertApprox(tundra.mountain, 0, 1e-9, "tundra does not need the height cap"); this.assertApprox(tundra.grass, 1, 1e-9, "tundra sits on grass"); } test_the_blend_is_a_shared_field_from_either_tile() { // Both tiles on a shared edge must compute the same weights, otherwise the // transition would show a seam where the two fans meet. const { renderer, tiles, view } = fixture((x) => (x >= 1 ? "Desert" : "Land")); const centreLand = mapToLocal(0, 0); const centreDesert = mapToLocal(1, 0); const ringLand = renderer._textureRing(view, 0, 0, centreLand); const ringDesert = renderer._textureRing(view, 1, 0, centreDesert); const px = (centreLand.x + centreDesert.x) / 2; const py = (centreLand.y + centreDesert.y) / 2; const fromLand = renderer._textureWeights(tiles[key(0, 0)], ringLand, px, py); const fromDesert = renderer._textureWeights(tiles[key(1, 0)], ringDesert, px, py); this.assertEqual(fromLand, fromDesert, "the shared vertex agrees"); this.assertApprox(fromLand[0], 0.5, 1e-9, "grass"); this.assertApprox(fromLand[1], 0.5, 1e-9, "sand"); } test_terrain_vertices_carry_world_space_texture_coordinates() { const { renderer, view } = fixture(() => "Land", 3); renderer.terrain = { name: "terrain" }; renderer.gl = { STATIC_DRAW: 0, DYNAMIC_DRAW: 0 }; let captured = null; renderer._upload = (pass, arr, usage, floats = 8) => { captured = arr; pass.floats = floats; }; renderer.buildTerrain(view); this.assertEqual(captured.length % 17, 0, "seventeen floats per terrain vertex"); // The undistorted world position rides along as UVs; tile (0,0)'s right // corner is the ideal lattice point (31.5, 0), independent of the warp. let found = false; for (let i = 0; i < captured.length; i += 17) { this.assertTrue(Number.isFinite(captured[i + 4]), "world x is finite"); this.assertTrue(Number.isFinite(captured[i + 5]), "world y is finite"); if (Math.abs(captured[i + 4] - 31.5) < 1e-9 && Math.abs(captured[i + 5]) < 1e-9) { found = true; } } this.assertTrue(found, "the ideal shared corner appears as a world coordinate"); } test_atlas_terrain_ignores_the_land_blend() { const atlas = vertexTextureWeights( { terrainType: "Ice" }, [{ tile: { terrainType: "Land" }, dx: 0, dy: 0 }] ); this.assertEqual(atlas, [0, 0, 1, 0, 0, 0, 0, 0], "ice stays on the atlas"); const land = vertexTextureWeights( { terrainType: "Land" }, [{ tile: { terrainType: "Land" }, dx: 0, dy: 0 }] ); this.assertEqual(land, [1, 0, 0, 0, 0, 0, 0, 0], "plains use the grass texture"); const mountain = vertexTextureWeights( { terrainType: "Mountain" }, [{ tile: { terrainType: "Mountain" }, dx: 0, dy: 0 }] ); this.assertEqual(mountain, [1, 0, 0, 0, 1, 0, 0, 0], "mountains sit on grass with their overlay"); const tundra = vertexTextureWeights( { terrainType: "Tundra" }, [{ tile: { terrainType: "Tundra" }, dx: 0, dy: 0 }] ); this.assertEqual(tundra, [1, 0, 0, 0, 0, 0, 0, 1], "tundra uses the flat rock channel"); } test_sea_is_flagged_for_the_water_shader() { const sea = vertexTextureWeights( { terrainType: "Sea" }, [{ tile: { terrainType: "Sea" }, dx: 0, dy: 0 }] ); this.assertEqual(sea, [0, 0, 1, 0, 0, 1, 0, 0], "sea rides on the atlas but is flagged as water"); this.assertFalse(isTexturedLand({ terrainType: "Sea" }), "sea is not a tiling land texture"); } test_a_coast_blends_sand_into_non_mountain_land() { const samples = [ { tile: { terrainType: "Land" }, dx: -23.625, dy: -14 }, { tile: { terrainType: "Sea" }, dx: 23.625, dy: 14 }, ]; const plains = vertexTextureWeights({ terrainType: "Land" }, samples); this.assertGreater(plains[6], 0, "the shoreline gets some sand"); this.assertLess(plains[6], 1, "the ground still shows through"); const mountain = vertexTextureWeights({ terrainType: "Mountain" }, samples); this.assertEqual(mountain[6], 0, "mountain coasts stay rocky"); const tundra = vertexTextureWeights({ terrainType: "Tundra" }, samples); this.assertEqual(tundra[6], 0, "tundra coasts stay rocky"); } test_beach_is_a_shared_field_between_land_and_sea() { const inland = blendBeach([ { tile: { terrainType: "Land" }, dx: 0, dy: 0 }, { tile: { terrainType: "Land" }, dx: 47.25, dy: 28 }, ]); this.assertEqual(inland, 0, "inland ground has no beach"); const shore = blendBeach([ { tile: { terrainType: "Land" }, dx: -23.625, dy: -14 }, { tile: { terrainType: "Sea" }, dx: 23.625, dy: 14 }, ]); this.assertApprox(shore, 0.5, 1e-9, "the shoreline is an even mix"); } }