diff --git a/client/js/map_view/heightmap.js b/client/js/map_view/heightmap.js index 72dd1dd..f7ac79a 100644 --- a/client/js/map_view/heightmap.js +++ b/client/js/map_view/heightmap.js @@ -73,6 +73,7 @@ const TERRAIN_HEIGHT = { Forest: { floor: 0.08, relief: 0.16 }, Land: { floor: 0.04, relief: 0.12 }, Desert: { floor: 0.02, relief: 0.08 }, + Tundra: { floor: 0.0, relief: 0.0 }, Ice: { floor: 0.0, relief: 0.04 }, Sea: { floor: 0.0, relief: 0.0 }, }; diff --git a/client/js/map_view/textures.js b/client/js/map_view/textures.js index dec9308..95b88ed 100644 --- a/client/js/map_view/textures.js +++ b/client/js/map_view/textures.js @@ -53,6 +53,7 @@ const LAND_TEXTURES = { Land: { base: TEXTURE_GRASS }, Desert: { base: TEXTURE_SAND }, Mountain: { base: TEXTURE_GRASS, overlay: TEXTURE_MOUNTAIN }, + Tundra: { base: TEXTURE_GRASS, overlay: TEXTURE_MOUNTAIN }, }; // Blend radius of the land kernel. A neighbour's centre is one edge-width away @@ -121,28 +122,33 @@ function beachFalloff(distance) { // // `samples` is a list of { tile, dx, dy }: `tile` is the tile data and (dx, dy) // is the offset from the point to that tile's centre. The result carries the -// grass/sand base split (summing to 1) and the opacity of each overlay, or null -// when no textured-land tile is close enough. The value depends only on the -// point and its surroundings, never on which tile asked, so shared vertices -// agree. +// grass/sand base split (summing to 1), the opacity of each overlay and the +// share that comes from tundra, or null when no textured-land tile is close +// enough. Tundra shares the mountain art but stays flat, so its share rides in +// `flat` and the shader skips the height cap and the relief normal for it. The +// value depends only on the point and its surroundings, never on which tile +// asked, so shared vertices agree. export function blendTextures(samples) { let grass = 0; let sand = 0; + let flat = 0; let total = 0; const overlays = {}; for (const name of OVERLAY_TEXTURES) overlays[name] = 0; for (const sample of samples) { - const texture = sample.tile && LAND_TEXTURES[sample.tile.terrainType]; + const tile = sample.tile; + const texture = tile && LAND_TEXTURES[tile.terrainType]; if (!texture) continue; const weight = falloff(Math.hypot(sample.dx, sample.dy)); if (weight <= 0) continue; total += weight; if (texture.base === TEXTURE_SAND) sand += weight; else grass += weight; - if (texture.overlay) overlays[texture.overlay] += weight; + if (tile.terrainType === "Tundra") flat += weight; + else if (texture.overlay) overlays[texture.overlay] += weight; } if (total <= 0) return null; - const blend = { grass: grass / total, sand: sand / total }; + const blend = { grass: grass / total, sand: sand / total, flat: flat / total }; for (const name of OVERLAY_TEXTURES) blend[name] = overlays[name] / total; return blend; } @@ -166,16 +172,18 @@ export function blendBeach(samples) { } // Per-vertex texture weights, packed for the shader as -// [grass, sand, atlas, forest, mountain, water, beach]. Atlas terrains are -// fully atlas; textured land is fully texture, with the base split, the overlay -// opacities and the coastal sand coming from the neighbouring-tile blend. -// Mountain coasts stay rocky, so they get no beach. +// [grass, sand, atlas, forest, mountain, water, beach, flat]. Atlas terrains +// are fully atlas; textured land is fully texture, with the base split, the +// overlay opacities, the coastal sand and the flat tundra share coming from the +// neighbouring-tile blend. Mountain and tundra coasts stay rocky, so they get +// no beach. export function vertexTextureWeights(tile, samples) { const water = isWaterTile(tile) ? 1 : 0; - if (!isTexturedLand(tile)) return [0, 0, 1, 0, 0, water, 0]; + if (!isTexturedLand(tile)) return [0, 0, 1, 0, 0, water, 0, 0]; const blend = blendTextures(samples); - if (!blend) return [0, 0, 1, 0, 0, water, 0]; - const beach = tile.terrainType === "Mountain" + if (!blend) return [0, 0, 1, 0, 0, water, 0, 0]; + const rocky = tile.terrainType === "Mountain" || tile.terrainType === "Tundra"; + const beach = rocky ? 0 : Math.min(1, blendBeach(samples) * BEACH_STRENGTH); return [ @@ -186,5 +194,6 @@ export function vertexTextureWeights(tile, samples) { blend[TEXTURE_MOUNTAIN], water, beach, + blend.flat, ]; } diff --git a/client/js/map_view/webgl.js b/client/js/map_view/webgl.js index 6266394..08a5597 100644 --- a/client/js/map_view/webgl.js +++ b/client/js/map_view/webgl.js @@ -228,10 +228,13 @@ void main() { // Mountain rock only caps the higher ground: the lower slopes reveal the // grass base, so the texture follows the relief instead of filling the // whole mountain tile. The terrain-type weight still decides where the - // range is at all. + // range is at all. Tundra reuses the mountain art but is never raised, so + // its own weight (vOverlay.w) shows the texture at full strength while the + // relief normal below stays flat. float rock = vOverlay.y * smoothstep(uRockLow, uRockHigh, vHeight); + float mountainMix = max(rock, vOverlay.w); base = mix(base, forest.rgb, forest.a * vOverlay.x); - base = mix(base, mountain.rgb, mountain.a * rock); + base = mix(base, mountain.rgb, mountain.a * mountainMix); // Coastal sand fades the sand texture into non-mountain shores. base = mix(base, sand, vOverlay.z); // Sea tiles are replaced by the animated water; the flag is 0 elsewhere. @@ -1083,7 +1086,7 @@ export class GLMapRenderer { buffer[offset + 11] = w[3]; buffer[offset + 12] = w[4]; buffer[offset + 13] = w[6]; - buffer[offset + 14] = 0; + buffer[offset + 14] = w[7]; buffer[offset + 15] = vertex.dx; buffer[offset + 16] = vertex.dy; offset += TERRAIN_FLOATS; diff --git a/shared/data/map_config.js b/shared/data/map_config.js index 6efc580..726e7f6 100644 --- a/shared/data/map_config.js +++ b/shared/data/map_config.js @@ -10,6 +10,18 @@ export const MAP_CONFIG = { falloffStart: 0.55, falloffStrength: 0.6, iceLatitude: 0.9, + tundraLatitude: 0.66, + // The polar ice and tundra edges are bent by a finer noise field so the + // sheets are jagged rather than straight bands. `iceRoughness` is how far (in + // latitude) the edges may wander; the equatorward core beyond that bound is + // always ice (and always tundra below it). + iceNoise: { + frequency: 0.13, + octaves: 2, + lacunarity: 2.0, + gain: 0.5, + }, + iceRoughness: 0.06, noise: { frequency: 0.02, octaves: 1, @@ -32,7 +44,7 @@ export const MAP_CONFIG = { terrainBiomes: [ { terrainType: "Mountain", coverage: 0.03, size: 10 }, { terrainType: "Hills", coverage: 0.1, size: 26 }, - { terrainType: "Forest", coverage: 0.16, size: 40 }, + { terrainType: "Forest", coverage: 0.22, size: 30 }, { terrainType: "Desert", coverage: 0.08, size: 48 }, ], capitalMargin: 2, diff --git a/shared/data/terrain.js b/shared/data/terrain.js index 7fdf9e0..bf4cf07 100644 --- a/shared/data/terrain.js +++ b/shared/data/terrain.js @@ -12,6 +12,7 @@ export const TERRAIN_TILES = [ { col: 4, terrainType: "Desert", terrainClass: "Land", populationMultiplier: 0.05, gdpMultiplier: 0.2, movementCostMultiplier: 3.0, defenseBonus: 0, minElevation: -0.2 }, { col: 5, terrainType: "Sea", terrainClass: "Sea", populationMultiplier: 0, gdpMultiplier: 0, movementCostMultiplier: 0, defenseBonus: 0, minElevation: 0 }, { col: 6, terrainType: "Mountain", terrainClass: "Land", populationMultiplier: 0.1, gdpMultiplier: 0.8, movementCostMultiplier: 5.0, defenseBonus: 1.0, minElevation: 0.34 }, + { col: 6, terrainType: "Tundra", terrainClass: "Land", populationMultiplier: 0.05, gdpMultiplier: 0.2, movementCostMultiplier: 2.0, defenseBonus: 0, minElevation: 0 }, ]; // A city standing on a tile adds this fraction on top of the terrain's own diff --git a/shared/map_generator.js b/shared/map_generator.js index d1c8fca..214fc2e 100644 --- a/shared/map_generator.js +++ b/shared/map_generator.js @@ -56,7 +56,11 @@ export class MapGenerator { const coast = this._sampleNoise(noise, column, coords, wrapped, radius, this.config.coastNoise); elevation += (this.config.coastRoughness || 0) * coast; } - const tile = this._chooseTile(coords, elevation); + let iceNoise = 0; + if (this.config.iceNoise) { + iceNoise = this._sampleNoise(noise, column, coords, wrapped, radius, this.config.iceNoise); + } + const tile = this._chooseTile(coords, elevation, iceNoise); this.tiles[key(coords.x, coords.y)] = tile; if (tile.terrainClass === "Land") this.landCells.push(coords); } @@ -87,20 +91,34 @@ export class MapGenerator { return value - fade * this.config.falloffStrength; } - _isPolar(coords) { + _isPolar(coords, iceNoise = 0) { const half = this.config.mapSize.y / 2; if (half <= 0) return false; - return Math.abs(coords.y) / half >= this.config.iceLatitude; + const jitter = Math.min(1, Math.max(-1, iceNoise)); + const latitude = this.config.iceLatitude + (this.config.iceRoughness || 0) * jitter; + return Math.abs(coords.y) / half >= latitude; } - _chooseTile(coords, elevation) { + _chooseTile(coords, elevation, iceNoise) { const palette = this._palette; - if (palette.ice && this._isPolar(coords)) return palette.ice; + if (palette.ice && this._isPolar(coords, iceNoise)) return palette.ice; if (elevation < this.config.seaLevel) return palette.sea; - // Land starts as plains; the terrain clusters are painted over it later. + // Tundra fringes the ice sheets; land starts as plains otherwise, and the + // terrain clusters are painted over it later. + if (palette.tundra && this._isTundra(coords, iceNoise)) return palette.tundra; return palette.plains; } + // The tundra band just outside the ice, its edge wandering on the same noise + // as the ice so the two meet in a ragged line. + _isTundra(coords, iceNoise = 0) { + const half = this.config.mapSize.y / 2; + if (half <= 0) return false; + const jitter = Math.min(1, Math.max(-1, iceNoise)); + const latitude = this.config.tundraLatitude + (this.config.iceRoughness || 0) * jitter; + return Math.abs(coords.y) / half >= latitude; + } + // Third pass: scatter clusters of each biome over the land. Clusters are // seeded from random land tiles and grown one tile at a time into an // irregular blob, in priority order (earlier entries win overlaps) and @@ -119,7 +137,8 @@ export class MapGenerator { Math.round((biome.coverage * this.landCells.length) / size) ); for (let i = 0; i < count; i++) { - const center = this.landCells[rng.range(0, this.landCells.length - 1)]; + const center = this._pickPaintableCenter(rng); + if (!center) continue; // Vary the size so the patches do not all look the same. const grown = Math.max(1, Math.round(size * (0.5 + rng.float()))); this._growCluster(center, grown, tile, rng); @@ -127,6 +146,17 @@ export class MapGenerator { } } + // Draws a cluster centre, retrying a few times when the drawn tile has already + // been claimed by a higher-priority biome, so a low-coverage biome is not + // silently dropped just because its one seed landed on a forest. + _pickPaintableCenter(rng) { + for (let attempt = 0; attempt < 8; attempt++) { + const center = this.landCells[rng.range(0, this.landCells.length - 1)]; + if (this._isPaintable(center)) return center; + } + return null; + } + // Grows one irregular blob of `size` tiles from `center`. At every step it // paints a random tile on the blob's edge, so the outline wanders instead of // closing into a hexagon, while the interior fills in rather than leaving @@ -538,5 +568,6 @@ function buildPalette() { const plains = byType.Land || landTiles[0] || null; const sea = TERRAIN_TILES.find((t) => t.terrainClass === "Sea") || null; const ice = TERRAIN_TILES.find((t) => t.terrainClass === "Ice") || null; - return { plains, byType, sea, ice }; + const tundra = byType.Tundra || null; + return { plains, byType, sea, ice, tundra }; } diff --git a/tests/data_test.js b/tests/data_test.js index 90e7dab..522b5d5 100644 --- a/tests/data_test.js +++ b/tests/data_test.js @@ -6,6 +6,7 @@ import { GOVERNMENTS, TECHNOLOGIES, TRAINABLE_UNIT_IDS, + TERRAIN_TILES, protoUnitById, buildingById, governmentById, @@ -151,4 +152,14 @@ export class DataTest extends TestCase { this.assertApprox(effectValue(effect, 3), 30); this.assertEqual(effectValue(effect, 0), 0); } + + test_tundra_is_rocky_but_flat_and_unproductive() { + const tundra = TERRAIN_TILES.find((t) => t.terrainType === "Tundra"); + this.assertNotNull(tundra, "tundra exists"); + this.assertEqual(tundra.terrainClass, "Land", "tundra is land"); + this.assertEqual(tundra.defenseBonus, 0, "tundra stays flat"); + this.assertApprox(tundra.populationMultiplier, 0.05, 1e-9); + this.assertApprox(tundra.gdpMultiplier, 0.2, 1e-9); + this.assertApprox(tundra.movementCostMultiplier, 2.0, 1e-9); + } } diff --git a/tests/heightmap_test.js b/tests/heightmap_test.js index 429e4d1..08fbe58 100644 --- a/tests/heightmap_test.js +++ b/tests/heightmap_test.js @@ -51,6 +51,15 @@ export class HeightmapTest extends TestCase { 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. diff --git a/tests/map_generator_test.js b/tests/map_generator_test.js index 8a2d7c9..1c8fbbf 100644 --- a/tests/map_generator_test.js +++ b/tests/map_generator_test.js @@ -153,15 +153,24 @@ export class MapGeneratorTest extends TestCase { const cfg = config({ iceLatitude: 0.5 }); const generator = generate(cfg); const half = cfg.mapSize.y / 2; + const core = cfg.iceLatitude + (cfg.iceRoughness || 0); for (const k in generator.tiles) { const i = k.indexOf(","); const y = Number(k.slice(i + 1)); - if (Math.abs(y) / half >= cfg.iceLatitude) { + if (Math.abs(y) / half >= core) { this.assertEqual(generator.tiles[k].terrainType, "Ice", `tile ${k}`); } } } + test_ice_edge_wanders_with_noise() { + const cfg = config({ iceLatitude: 0.5, iceRoughness: 0.2 }); + const generator = generate(cfg); + const coords = { x: 0, y: 6 }; + this.assertTrue(generator._isPolar(coords, -1), "the edge can reach south"); + this.assertFalse(generator._isPolar(coords, 1), "the edge can pull north"); + } + test_equator_is_not_ice() { const generator = generate(); for (const k in generator.tiles) { @@ -171,6 +180,21 @@ export class MapGeneratorTest extends TestCase { } } + test_tundra_fringes_the_poles() { + const cfg = config(); + const generator = generate(cfg); + const half = cfg.mapSize.y / 2; + const inner = cfg.tundraLatitude - (cfg.iceRoughness || 0); + let tundra = 0; + for (const k in generator.tiles) { + if (generator.tiles[k].terrainType !== "Tundra") continue; + tundra += 1; + const y = Number(k.slice(k.indexOf(",") + 1)); + this.assertGreaterOrEqual(Math.abs(y) / half, inner, `tundra at ${k} is polar`); + } + this.assertGreater(tundra, 0, "there is tundra near the poles"); + } + test_falloff_lowers_the_edges() { const cfg = config({ falloffEnabled: true, falloffStart: 0, falloffStrength: 1 }); const generator = generate(cfg); @@ -213,7 +237,7 @@ export class MapGeneratorTest extends TestCase { for (const coords of generator.landCells) { terrainTypes.add(generator.tiles[key(coords.x, coords.y)].terrainType); } - for (const type of ["Land", "Forest", "Hills", "Mountain", "Desert"]) { + for (const type of ["Land", "Forest", "Hills", "Mountain", "Desert", "Tundra"]) { this.assertTrue(terrainTypes.has(type), `${type} appears`); } } @@ -221,7 +245,11 @@ export class MapGeneratorTest extends TestCase { test_biome_clusters_stay_small() { const generator = generate(); for (const biome of MAP_CONFIG.terrainBiomes) { - const largest = Math.round(biome.size * 1.5); + // Same-type patches may grow into each other, so a connected body is + // bounded by the total area the biome was asked to cover, not by one + // patch. A biome still cannot run away and swallow the whole map. + const target = Math.round((biome.coverage || 0) * generator.landCells.length); + const largest = Math.max(Math.round(biome.size * 1.5), target); const sizes = componentSizes( generator, (tile) => tile.terrainType === biome.terrainType diff --git a/tests/textures_test.js b/tests/textures_test.js index b8ffd2b..727a68b 100644 --- a/tests/textures_test.js +++ b/tests/textures_test.js @@ -29,7 +29,7 @@ function fixture(terrainAt, size = 5) { export class TexturesTest extends TestCase { test_grassy_land_uses_grass_and_desert_uses_sand() { - for (const type of ["Forest", "Hills", "Land", "Mountain"]) { + 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"); @@ -41,6 +41,7 @@ export class TexturesTest extends TestCase { 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)); } @@ -55,6 +56,10 @@ export class TexturesTest extends TestCase { "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"); @@ -116,6 +121,10 @@ export class TexturesTest extends TestCase { 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() { @@ -164,17 +173,22 @@ export class TexturesTest extends TestCase { { terrainType: "Ice" }, [{ tile: { terrainType: "Land" }, dx: 0, dy: 0 }] ); - this.assertEqual(atlas, [0, 0, 1, 0, 0, 0, 0], "ice stays on the atlas"); + 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], "plains use the grass texture"); + 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], "mountains sit on grass with their overlay"); + 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() { @@ -182,7 +196,7 @@ export class TexturesTest extends TestCase { { terrainType: "Sea" }, [{ tile: { terrainType: "Sea" }, dx: 0, dy: 0 }] ); - this.assertEqual(sea, [0, 0, 1, 0, 0, 1, 0], "sea rides on the atlas but is flagged as water"); + 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"); } @@ -196,6 +210,8 @@ export class TexturesTest extends TestCase { 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() {