From 88034e6d02bb74df5a14db70aaa40cd4abae4d3b Mon Sep 17 00:00:00 2001 From: SomeTroglodyte <63000004+SomeTroglodyte@users.noreply.github.com> Date: Sun, 28 Jan 2024 10:05:50 +0100 Subject: [PATCH] Mods can use the Hills and mountains distribution uniques on Land or Feature terrains (#11020) * Refactor and rewrite raiseMountainsAndHills to allow hill and mountain uniques on land+feature terrain types * Optimize chooseSpreadOutLocations * Optimize MapLandmassGenerator's retries for water proportion / large continent count --- core/src/com/unciv/Constants.kt | 3 - .../map/mapgenerator/MapElevationGenerator.kt | 212 ++++++++++++++++++ .../mapgenerator/MapGenerationRandomness.kt | 42 ++-- .../logic/map/mapgenerator/MapGenerator.kt | 161 +------------ .../map/mapgenerator/MapLandmassGenerator.kt | 138 +++++++----- 5 files changed, 325 insertions(+), 231 deletions(-) create mode 100644 core/src/com/unciv/logic/map/mapgenerator/MapElevationGenerator.kt diff --git a/core/src/com/unciv/Constants.kt b/core/src/com/unciv/Constants.kt index ec9cab529a..7f2526bb28 100644 --- a/core/src/com/unciv/Constants.kt +++ b/core/src/com/unciv/Constants.kt @@ -83,9 +83,6 @@ object Constants { const val embarked = "Embarked" const val wounded = "Wounded" - - const val rising = "Rising" - const val lowering = "Lowering" const val remove = "Remove " const val repair = "Repair" diff --git a/core/src/com/unciv/logic/map/mapgenerator/MapElevationGenerator.kt b/core/src/com/unciv/logic/map/mapgenerator/MapElevationGenerator.kt new file mode 100644 index 0000000000..dd8e52de68 --- /dev/null +++ b/core/src/com/unciv/logic/map/mapgenerator/MapElevationGenerator.kt @@ -0,0 +1,212 @@ +package com.unciv.logic.map.mapgenerator + +import com.unciv.logic.map.MapParameters +import com.unciv.logic.map.TileMap +import com.unciv.logic.map.tile.Tile +import com.unciv.models.ruleset.Ruleset +import com.unciv.models.ruleset.tile.TerrainType +import com.unciv.models.ruleset.unique.UniqueType +import com.unciv.utils.Log +import kotlin.math.abs +import kotlin.math.pow +import kotlin.math.sign + +class MapElevationGenerator( + private val tileMap: TileMap, + private val ruleset: Ruleset, + private val randomness: MapGenerationRandomness +) { + companion object { + private const val rising = "~Raising~" + private const val lowering = "~Lowering~" + } + + private val flat = ruleset.terrains.values.firstOrNull { + !it.impassable && it.type == TerrainType.Land && !it.hasUnique(UniqueType.RoughTerrain) + }?.name + private val hillMutator: ITileMutator + private val mountainMutator: ITileMutator + private val dummyMutator by lazy { TileDummyMutator() } + + init { + mountainMutator = getTileMutator(UniqueType.OccursInChains, flat) + hillMutator = getTileMutator(UniqueType.OccursInGroups, flat) + } + + private fun getTileMutator(type: UniqueType, flat: String?): ITileMutator { + if (flat == null) return dummyMutator + val terrain = ruleset.terrains.values.firstOrNull { it.hasUnique(type) } + ?: return dummyMutator + return if (terrain.type == TerrainType.TerrainFeature) + TileFeatureMutator(terrain.name) + else TileBaseMutator(flat, terrain.name) + } + + /** + * [MapParameters.elevationExponent] favors high elevation + */ + fun raiseMountainsAndHills() { + if (flat == null) { + Log.debug("Ruleset seems to contain no flat terrain - can't generate heightmap") + return + } + + val elevationSeed = randomness.RNG.nextInt().toDouble() + val exponent = 1.0 - tileMap.mapParameters.elevationExponent.toDouble() + fun Double.powSigned(exponent: Double) = abs(this).pow(exponent) * sign(this) + + tileMap.setTransients(ruleset) + + for (tile in tileMap.values) { + if (tile.isWater) continue + val elevation = randomness.getPerlinNoise(tile, elevationSeed, scale = 2.0).powSigned(exponent) + tile.baseTerrain = flat // in case both mutators are TileFeatureMutator + hillMutator.setElevated(tile, elevation > 0.5 && elevation <= 0.7) + mountainMutator.setElevated(tile, elevation > 0.7) + tile.setTerrainTransients() + } + + cellularMountainRanges() + cellularHills() + } + + private fun cellularMountainRanges() { + if (mountainMutator is TileDummyMutator) return + Log.debug("Mountain-like generation for %s", mountainMutator.name) + + val targetMountains = mountainMutator.count(tileMap.values) * 2 + val impassableTerrains = ruleset.terrains.values.filter { it.impassable }.map { it.name }.toSet() + + for (i in 1..5) { + var totalMountains = mountainMutator.count(tileMap.values) + + for (tile in tileMap.values) { + if (tile.isWater) continue + val adjacentMountains = mountainMutator.count(tile.neighbors) + val adjacentImpassible = tile.neighbors.count { it.baseTerrain in impassableTerrains } + + if (adjacentMountains == 0 && mountainMutator.isElevated(tile)) { + if (randomness.RNG.nextInt(until = 4) == 0) + tile.addTerrainFeature(lowering) + } else if (adjacentMountains == 1) { + if (randomness.RNG.nextInt(until = 10) == 0) + tile.addTerrainFeature(rising) + } else if (adjacentImpassible == 3) { + if (randomness.RNG.nextInt(until = 2) == 0) + tile.addTerrainFeature(lowering) + } else if (adjacentImpassible > 3) { + tile.addTerrainFeature(lowering) + } + } + + for (tile in tileMap.values) { + if (tile.isWater) continue + if (tile.terrainFeatures.contains(rising)) { + tile.removeTerrainFeature(rising) + if (totalMountains >= targetMountains) continue + if (!mountainMutator.isElevated(tile)) totalMountains++ + hillMutator.lower(tile) + mountainMutator.raise(tile) + } + if (tile.terrainFeatures.contains(lowering)) { + tile.removeTerrainFeature(lowering) + if (totalMountains * 2 <= targetMountains) continue + if (mountainMutator.isElevated(tile)) totalMountains-- + mountainMutator.lower(tile) + hillMutator.raise(tile) + } + } + } + } + + private fun cellularHills() { + if (hillMutator is TileDummyMutator) return + Log.debug("Hill-like generation for %s", hillMutator.name) + + val targetHills = hillMutator.count(tileMap.values) + + for (i in 1..5) { + var totalHills = hillMutator.count(tileMap.values) + + for (tile in tileMap.values) { + if (tile.isWater || mountainMutator.isElevated(tile)) continue + val adjacentMountains = mountainMutator.count(tile.neighbors) + val adjacentHills = hillMutator.count(tile.neighbors) + + if (adjacentHills <= 1 && adjacentMountains == 0 && randomness.RNG.nextInt(until = 2) == 0) { + tile.addTerrainFeature(lowering) + } else if (adjacentHills > 3 && adjacentMountains == 0 && randomness.RNG.nextInt(until = 2) == 0) { + tile.addTerrainFeature(lowering) + } else if (adjacentHills + adjacentMountains in 2..3 && randomness.RNG.nextInt(until = 2) == 0) { + tile.addTerrainFeature(rising) + } + + } + + for (tile in tileMap.values) { + if (tile.isWater || mountainMutator.isElevated(tile)) continue + if (tile.terrainFeatures.contains(rising)) { + tile.removeTerrainFeature(rising) + if (totalHills > targetHills && i != 1) continue + if (!hillMutator.isElevated(tile)) { + hillMutator.raise(tile) + totalHills++ + } + } + if (tile.terrainFeatures.contains(lowering)) { + tile.removeTerrainFeature(lowering) + if (totalHills >= targetHills * 0.9f || i == 1) { + if (hillMutator.isElevated(tile)) { + hillMutator.lower(tile) + totalHills-- + } + } + } + } + } + } + + private interface ITileMutator { + val name: String // logging only + fun lower(tile: Tile) + fun raise(tile: Tile) + fun isElevated(tile: Tile): Boolean + fun setElevated(tile: Tile, value: Boolean) = if (value) raise(tile) else lower(tile) + fun count(tiles: Iterable) = tiles.count { isElevated(it) } + fun count(tiles: Sequence) = tiles.count { isElevated(it) } + } + + private class TileDummyMutator : ITileMutator { + override val name get() = "" + override fun lower(tile: Tile) {} + override fun raise(tile: Tile) {} + override fun isElevated(tile: Tile) = false + } + + private class TileBaseMutator( + private val flat: String, + private val elevated: String + ) : ITileMutator { + override val name get() = elevated + override fun lower(tile: Tile) { + tile.baseTerrain = flat + } + override fun raise(tile: Tile) { + tile.baseTerrain = elevated + } + override fun isElevated(tile: Tile) = tile.baseTerrain == elevated + } + + private class TileFeatureMutator( + val elevated: String + ) : ITileMutator { + override val name get() = elevated + override fun lower(tile: Tile) { + tile.removeTerrainFeature(elevated) + } + override fun raise(tile: Tile) { + tile.addTerrainFeature(elevated) + } + override fun isElevated(tile: Tile) = elevated in tile.terrainFeatures + } +} diff --git a/core/src/com/unciv/logic/map/mapgenerator/MapGenerationRandomness.kt b/core/src/com/unciv/logic/map/mapgenerator/MapGenerationRandomness.kt index 4b3a586600..eb0072e58d 100644 --- a/core/src/com/unciv/logic/map/mapgenerator/MapGenerationRandomness.kt +++ b/core/src/com/unciv/logic/map/mapgenerator/MapGenerationRandomness.kt @@ -2,10 +2,9 @@ package com.unciv.logic.map.mapgenerator import com.unciv.logic.map.HexMath import com.unciv.logic.map.tile.Tile -import com.unciv.utils.Log import com.unciv.utils.debug -import kotlin.math.max import kotlin.math.pow +import kotlin.math.roundToInt import kotlin.random.Random class MapGenerationRandomness { @@ -47,39 +46,54 @@ class MapGenerationRandomness { // The `if` means if we need to fill 60% or more of the available tiles, no sense starting with minimum distance 2. val sparsityFactor = (HexMath.getHexagonalRadiusForArea(suitableTiles.size) / mapRadius).pow(0.333f) val initialDistance = if (number == 1 || number * 5 >= suitableTiles.size * 3) 1 - else max(1, (mapRadius * 0.666f / HexMath.getHexagonalRadiusForArea(number).pow(0.9f) * sparsityFactor + 0.5).toInt()) + else (mapRadius * 0.666f / HexMath.getHexagonalRadiusForArea(number).pow(0.9f) * sparsityFactor).roundToInt().coerceAtLeast(1) // If possible, we want to equalize the base terrains upon which // the resources are found, so we save how many have been // found for each base terrain and try to get one from the lowest val baseTerrainsToChosenTiles = HashMap() - for (tileInfo in suitableTiles) { - if (tileInfo.baseTerrain !in baseTerrainsToChosenTiles) - baseTerrainsToChosenTiles[tileInfo.baseTerrain] = 0 + // Once we have a preference to choose from a specific base terrain, we want quick lookup of the available candidates + val suitableTilesGrouped = LinkedHashMap>(8) // 8 is > number of base terrains in vanilla + // Prefill both with all existing base terrains as keys, and group suitableTiles into base terrain buckets + for (tile in suitableTiles) { + val terrain = tile.baseTerrain + if (terrain !in baseTerrainsToChosenTiles) + baseTerrainsToChosenTiles[terrain] = 0 + suitableTilesGrouped.getOrPut(terrain) { mutableSetOf() }.add(tile) + } + + fun LinkedHashMap>.deepClone(): LinkedHashMap> { + // map { it.key to it.value.toMutableSet() }.toMap() is marginally less efficient + val result = LinkedHashMap>(size) + for ((key, value) in this) + result[key] = value.toMutableSet() + return result } for (distanceBetweenResources in initialDistance downTo 1) { - var availableTiles = suitableTiles + val availableTiles = suitableTilesGrouped.deepClone() val chosenTiles = ArrayList(number) for (terrain in baseTerrainsToChosenTiles.keys) baseTerrainsToChosenTiles[terrain] = 0 for (i in 1..number) { - if (availableTiles.isEmpty()) break val orderedKeys = baseTerrainsToChosenTiles.entries - .sortedBy { it.value }.map { it.key } + .sortedBy { it.value }.map { it.key } val firstKeyWithTilesLeft = orderedKeys - .first { availableTiles.any { tile -> tile.baseTerrain == it} } - val chosenTile = availableTiles.filter { it.baseTerrain == firstKeyWithTilesLeft }.random(RNG) - availableTiles = availableTiles.filter { it.aerialDistanceTo(chosenTile) > distanceBetweenResources } + .firstOrNull { availableTiles[it]!!.isNotEmpty() } + ?: break + val chosenTile = availableTiles[firstKeyWithTilesLeft]!!.random(RNG) + val closeTiles = chosenTile.getTilesInDistance(distanceBetweenResources).toSet() + for (availableSet in availableTiles.values) + availableSet.removeAll(closeTiles) chosenTiles.add(chosenTile) baseTerrainsToChosenTiles[firstKeyWithTilesLeft] = baseTerrainsToChosenTiles[firstKeyWithTilesLeft]!! + 1 } if (chosenTiles.size == number || distanceBetweenResources == 1) { // Either we got them all, or we're not going to get anything better - if (Log.shouldLog() && distanceBetweenResources < initialDistance) - debug("chooseSpreadOutLocations: distance $distanceBetweenResources < initial $initialDistance") + if (distanceBetweenResources < initialDistance) + debug("chooseSpreadOutLocations: distance %d < initial %d", distanceBetweenResources, initialDistance) return chosenTiles } } diff --git a/core/src/com/unciv/logic/map/mapgenerator/MapGenerator.kt b/core/src/com/unciv/logic/map/mapgenerator/MapGenerator.kt index c281842665..7f4ebe6516 100644 --- a/core/src/com/unciv/logic/map/mapgenerator/MapGenerator.kt +++ b/core/src/com/unciv/logic/map/mapgenerator/MapGenerator.kt @@ -116,10 +116,10 @@ class MapGenerator(val ruleset: Ruleset, private val coroutineScope: CoroutineSc if (consoleTimings) debug("\nMapGenerator run with parameters %s", mapParameters) runAndMeasure("MapLandmassGenerator") { - MapLandmassGenerator(ruleset, randomness).generateLand(map) + MapLandmassGenerator(map, ruleset, randomness).generateLand() } runAndMeasure("raiseMountainsAndHills") { - raiseMountainsAndHills(map) + MapElevationGenerator(map, ruleset, randomness).raiseMountainsAndHills() } runAndMeasure("applyHumidityAndTemperature") { applyHumidityAndTemperature(map) @@ -190,8 +190,8 @@ class MapGenerator(val ruleset: Ruleset, private val coroutineScope: CoroutineSc when (step) { MapGeneratorSteps.None -> Unit MapGeneratorSteps.All -> throw IllegalArgumentException("MapGeneratorSteps.All cannot be used in generateSingleStep") - MapGeneratorSteps.Landmass -> MapLandmassGenerator(ruleset, randomness).generateLand(map) - MapGeneratorSteps.Elevation -> raiseMountainsAndHills(map) + MapGeneratorSteps.Landmass -> MapLandmassGenerator(map, ruleset, randomness).generateLand() + MapGeneratorSteps.Elevation -> MapElevationGenerator(map, ruleset, randomness).raiseMountainsAndHills() MapGeneratorSteps.HumidityAndTemperature -> applyHumidityAndTemperature(map) MapGeneratorSteps.LakesAndCoast -> spawnLakesAndCoasts(map) MapGeneratorSteps.Vegetation -> spawnVegetation(map) @@ -379,158 +379,6 @@ class MapGenerator(val ruleset: Ruleset, private val coroutineScope: CoroutineSc } - /** - * [MapParameters.elevationExponent] favors high elevation - */ - private fun raiseMountainsAndHills(tileMap: TileMap) { - val mountain = ruleset.terrains.values.firstOrNull { it.hasUnique(UniqueType.OccursInChains) }?.name - val hill = ruleset.terrains.values.firstOrNull { it.hasUnique(UniqueType.OccursInGroups) }?.name - val flat = ruleset.terrains.values.firstOrNull { - !it.impassable && it.type == TerrainType.Land && !it.hasUnique(UniqueType.RoughTerrain) - }?.name - - if (flat == null) { - debug("Ruleset seems to contain no flat terrain - can't generate heightmap") - return - } - - if (mountain != null) - debug("Mountain-like generation for %s", mountain) - if (hill != null) - debug("Hill-like generation for %s", mountain) - - val elevationSeed = randomness.RNG.nextInt().toDouble() - tileMap.setTransients(ruleset) - for (tile in tileMap.values.asSequence().filter { !it.isWater }) { - var elevation = randomness.getPerlinNoise(tile, elevationSeed, scale = 2.0) - elevation = abs(elevation).pow(1.0 - tileMap.mapParameters.elevationExponent.toDouble()) * elevation.sign - - when { - elevation <= 0.5 -> { - tile.baseTerrain = flat - if (hill != null && tile.terrainFeatures.contains(hill)) { - tile.removeTerrainFeature(hill) - } - } - elevation <= 0.7 && hill != null -> { - tile.addTerrainFeature(hill) - tile.baseTerrain = flat - } - elevation <= 0.7 && hill == null -> tile.baseTerrain = flat // otherwise would be hills become mountains - elevation > 0.7 && mountain != null -> { - tile.baseTerrain = mountain - if (hill != null && tile.terrainFeatures.contains(hill)) { - tile.removeTerrainFeature(hill) - } - } - else -> { - tile.baseTerrain = flat - if (hill != null && tile.terrainFeatures.contains(hill)) { - tile.removeTerrainFeature(hill) - } - } - } - tile.setTerrainTransients() - } - - if (mountain != null) - cellularMountainRanges(tileMap, mountain, hill, flat) - if (hill != null) - cellularHills(tileMap, mountain, hill) - } - - private fun cellularMountainRanges(tileMap: TileMap, mountain: String, hill: String?, flat: String) { - val targetMountains = tileMap.values.count { it.baseTerrain == mountain } * 2 - - for (i in 1..5) { - var totalMountains = tileMap.values.count { it.baseTerrain == mountain } - - for (tile in tileMap.values.filter { !it.isWater }) { - val adjacentMountains = - tile.neighbors.count { it.baseTerrain == mountain } - val adjacentImpassible = - tile.neighbors.count { ruleset.terrains[it.baseTerrain]?.impassable == true } - - if (adjacentMountains == 0 && tile.baseTerrain == mountain) { - if (randomness.RNG.nextInt(until = 4) == 0) - tile.addTerrainFeature(Constants.lowering) - } else if (adjacentMountains == 1) { - if (randomness.RNG.nextInt(until = 10) == 0) - tile.addTerrainFeature(Constants.rising) - } else if (adjacentImpassible == 3) { - if (randomness.RNG.nextInt(until = 2) == 0) - tile.addTerrainFeature(Constants.lowering) - } else if (adjacentImpassible > 3) { - tile.addTerrainFeature(Constants.lowering) - } - } - - for (tile in tileMap.values.filter { !it.isWater }) { - if (tile.terrainFeatures.contains(Constants.rising)) { - tile.removeTerrainFeature(Constants.rising) - if (totalMountains >= targetMountains) continue - if (hill != null) - tile.removeTerrainFeature(hill) - tile.baseTerrain = mountain - totalMountains++ - } - if (tile.terrainFeatures.contains(Constants.lowering)) { - tile.removeTerrainFeature(Constants.lowering) - if (totalMountains <= targetMountains * 0.5f) continue - if (tile.baseTerrain == mountain) { - if (hill != null && !tile.terrainFeatures.contains(hill)) - tile.addTerrainFeature(hill) - totalMountains-- - } - tile.baseTerrain = flat - } - } - } - } - - private fun cellularHills(tileMap: TileMap, mountain: String?, hill: String) { - val targetHills = tileMap.values.count { it.terrainFeatures.contains(hill) } - - for (i in 1..5) { - var totalHills = tileMap.values.count { it.terrainFeatures.contains(hill) } - - for (tile in tileMap.values.asSequence().filter { !it.isWater && (mountain == null || it.baseTerrain != mountain) }) { - val adjacentMountains = if (mountain == null) 0 else - tile.neighbors.count { it.baseTerrain == mountain } - val adjacentHills = - tile.neighbors.count { it.terrainFeatures.contains(hill) } - - if (adjacentHills <= 1 && adjacentMountains == 0 && randomness.RNG.nextInt(until = 2) == 0) { - tile.addTerrainFeature(Constants.lowering) - } else if (adjacentHills > 3 && adjacentMountains == 0 && randomness.RNG.nextInt(until = 2) == 0) { - tile.addTerrainFeature(Constants.lowering) - } else if (adjacentHills + adjacentMountains in 2..3 && randomness.RNG.nextInt(until = 2) == 0) { - tile.addTerrainFeature(Constants.rising) - } - - } - - for (tile in tileMap.values.asSequence().filter { !it.isWater && (mountain == null || it.baseTerrain != mountain) }) { - if (tile.terrainFeatures.contains(Constants.rising)) { - tile.removeTerrainFeature(Constants.rising) - if (totalHills > targetHills && i != 1) continue - if (!tile.terrainFeatures.contains(hill)) { - tile.addTerrainFeature(hill) - totalHills++ - } - } - if (tile.terrainFeatures.contains(Constants.lowering)) { - tile.removeTerrainFeature(Constants.lowering) - if (totalHills >= targetHills * 0.9f || i == 1) { - if (tile.terrainFeatures.contains(hill)) - tile.removeTerrainFeature(hill) - totalHills-- - } - } - } - } - } - /** * [MapParameters.tilesPerBiomeArea] to set biomes size * [MapParameters.temperatureExtremeness] to favor very high and very low temperatures @@ -931,4 +779,3 @@ class MapGenerator(val ruleset: Ruleset, private val coroutineScope: CoroutineSc } } } - diff --git a/core/src/com/unciv/logic/map/mapgenerator/MapLandmassGenerator.kt b/core/src/com/unciv/logic/map/mapgenerator/MapLandmassGenerator.kt index 4115e60692..36d2abe9d3 100644 --- a/core/src/com/unciv/logic/map/mapgenerator/MapLandmassGenerator.kt +++ b/core/src/com/unciv/logic/map/mapgenerator/MapLandmassGenerator.kt @@ -14,8 +14,12 @@ import kotlin.math.min import kotlin.math.pow import kotlin.math.sqrt -class MapLandmassGenerator(val ruleset: Ruleset, val randomness: MapGenerationRandomness) { - //region _Fields +class MapLandmassGenerator( + private val tileMap: TileMap, + ruleset: Ruleset, + private val randomness: MapGenerationRandomness +) { + //region Fields private val landTerrainName = getInitializationTerrain(ruleset, TerrainType.Land) private val waterTerrainName: String = try { getInitializationTerrain(ruleset, TerrainType.Water) @@ -33,7 +37,7 @@ class MapLandmassGenerator(val ruleset: Ruleset, val randomness: MapGenerationRa ?: throw Exception("Cannot create map - no $type terrains found!") } - fun generateLand(tileMap: TileMap) { + fun generateLand() { // This is to accommodate land-only mods if (landOnlyMod) { for (tile in tileMap.values) @@ -44,25 +48,25 @@ class MapLandmassGenerator(val ruleset: Ruleset, val randomness: MapGenerationRa waterThreshold = tileMap.mapParameters.waterThreshold.toDouble() when (tileMap.mapParameters.type) { - MapType.pangaea -> createPangaea(tileMap) - MapType.innerSea -> createInnerSea(tileMap) - MapType.continentAndIslands -> createContinentAndIslands(tileMap) - MapType.twoContinents -> createTwoContinents(tileMap) - MapType.threeContinents -> createThreeContinents(tileMap) - MapType.fourCorners -> createFourCorners(tileMap) - MapType.archipelago -> createArchipelago(tileMap) - MapType.perlin -> createPerlin(tileMap) - MapType.fractal -> createFractal(tileMap) - MapType.lakes -> createLakes(tileMap) - MapType.smallContinents -> createSmallContinents(tileMap) + MapType.pangaea -> createPangaea() + MapType.innerSea -> createInnerSea() + MapType.continentAndIslands -> createContinentAndIslands() + MapType.twoContinents -> createTwoContinents() + MapType.threeContinents -> createThreeContinents() + MapType.fourCorners -> createFourCorners() + MapType.archipelago -> createArchipelago() + MapType.perlin -> createPerlin() + MapType.fractal -> createFractal() + MapType.lakes -> createLakes() + MapType.smallContinents -> createSmallContinents() } if (tileMap.mapParameters.shape === MapShape.flatEarth) { - generateFlatEarthExtraWater(tileMap) + generateFlatEarthExtraWater() } } - private fun generateFlatEarthExtraWater(tileMap: TileMap) { + private fun generateFlatEarthExtraWater() { for (tile in tileMap.values) { val isCenterTile = tile.latitude == 0f && tile.longitude == 0f val isEdgeTile = tile.neighbors.count() < 6 @@ -92,7 +96,27 @@ class MapLandmassGenerator(val ruleset: Ruleset, val randomness: MapGenerationRa tile.baseTerrain = if (elevation < waterThreshold) waterTerrainName else landTerrainName } - private fun createPerlin(tileMap: TileMap) { + /** Repeat [function] until [predicate] is `true`, lowering [waterThreshold] on each retry, preventing an endless loop. + * The [predicate] receives the proportion of water on the map, the default accepts <= 70%. + */ + private fun retryLoweringWaterLevel(predicate: (waterPercent: Float) -> Boolean = { it <= 0.7f }, function: () -> Unit) { + var retries = 0 + while (++retries <= 30) { // 28 is enough to go from +1 to -1 with only the retries acceleration below + function() + val waterPercent = tileMap.values.count { it.baseTerrain == waterTerrainName }.toFloat() / tileMap.values.size + if (waterThreshold < -1f || predicate(waterPercent)) break + + // lower water table to reduce water percentage, with empiric base step and acceleration + // (tweaked to acceptable performance on huge maps - but feel free to improve) + waterThreshold -= 0.02 * + (waterPercent / 0.7f).coerceAtLeast(1f) * + retries.toFloat().pow(0.5f) + //Log.debug("retry %d with waterPercent=%f, waterThreshold=%f", retries, waterPercent, waterThreshold) + } + } + + //region Type-specific generators + private fun createPerlin() { val elevationSeed = randomness.RNG.nextInt().toDouble() for (tile in tileMap.values) { val elevation = randomness.getPerlinNoise(tile, elevationSeed) @@ -100,8 +124,8 @@ class MapLandmassGenerator(val ruleset: Ruleset, val randomness: MapGenerationRa } } - private fun createFractal(tileMap: TileMap) { - do { + private fun createFractal() { + retryLoweringWaterLevel { val elevationSeed = randomness.RNG.nextInt().toDouble() for (tile in tileMap.values) { val maxdim = max(tileMap.maxLatitude, tileMap.maxLongitude) @@ -112,15 +136,14 @@ class MapLandmassGenerator(val ruleset: Ruleset, val randomness: MapGenerationRa var elevation = randomness.getPerlinNoise(tile, elevationSeed, persistence=0.8, lacunarity=1.5, scale=ratio*30.0) - elevation += getOceanEdgesTransform(tile, tileMap) + elevation += getOceanEdgesTransform(tile) spawnLandOrWater(tile, elevation) } - waterThreshold -= 0.01 - } while (tileMap.values.count { it.baseTerrain == waterTerrainName } > tileMap.values.size * 0.7f) // Over 70% water + } } - private fun createLakes(tileMap: TileMap) { + private fun createLakes() { val elevationSeed = randomness.RNG.nextInt().toDouble() for (tile in tileMap.values) { val elevation = 0.3 - getRidgedPerlinNoise(tile, elevationSeed, persistence=0.7, lacunarity=1.5) @@ -129,20 +152,19 @@ class MapLandmassGenerator(val ruleset: Ruleset, val randomness: MapGenerationRa } } - private fun createSmallContinents(tileMap: TileMap) { + private fun createSmallContinents() { val elevationSeed = randomness.RNG.nextInt().toDouble() waterThreshold += 0.25 - do { + retryLoweringWaterLevel { for (tile in tileMap.values) { var elevation = getRidgedPerlinNoise(tile, elevationSeed, scale = 22.0) - elevation += getOceanEdgesTransform(tile, tileMap) + elevation += getOceanEdgesTransform(tile) spawnLandOrWater(tile, elevation) } - waterThreshold -= 0.01 - } while (tileMap.values.count { it.baseTerrain == waterTerrainName } > tileMap.values.size * 0.7f) // Over 70% + } } - private fun createArchipelago(tileMap: TileMap) { + private fun createArchipelago() { val elevationSeed = randomness.RNG.nextInt().toDouble() waterThreshold += 0.25 for (tile in tileMap.values) { @@ -151,38 +173,36 @@ class MapLandmassGenerator(val ruleset: Ruleset, val randomness: MapGenerationRa } } - private fun createPangaea(tileMap: TileMap) { - val largeContinentThreshold = (tileMap.values.size / 4).coerceAtMost(25) - var retryCount = 200 // A bit much but when relevant (tiny map) an iteration is relatively cheap - - while(--retryCount >= 0) { + private fun createPangaea() { + val largeContinentThreshold = 25 + .coerceAtMost(tileMap.values.size / 4) // Or really tiny maps will exhaust retries + .coerceAtLeast(tileMap.values.size.toFloat().pow(0.333f).toInt()) // kicks in on really large maps: 130k tiles -> 50 + retryLoweringWaterLevel(predicate = { waterPercent -> + val largeContinents = tileMap.continentSizes.values.count { it > largeContinentThreshold } + largeContinents == 1 && waterPercent <= 0.7f + }) { val elevationSeed = randomness.RNG.nextInt().toDouble() for (tile in tileMap.values) { var elevation = randomness.getPerlinNoise(tile, elevationSeed) - elevation = elevation * (3 / 4f) + getEllipticContinent(tile, tileMap) / 4 + elevation = elevation * (3 / 4f) + getEllipticContinent(tile) / 4 spawnLandOrWater(tile, elevation) tile.setTerrainTransients() // necessary for assignContinents } - - tileMap.assignContinents(TileMap.AssignContinentsMode.Reassign) - if ( tileMap.continentSizes.values.count { it > largeContinentThreshold } == 1 // Only one large continent - && tileMap.values.count { it.baseTerrain == waterTerrainName } <= tileMap.values.size * 0.7f // And at most 70% water - ) break - waterThreshold -= 0.01 + tileMap.assignContinents(TileMap.AssignContinentsMode.Reassign) // to support largeContinents above } tileMap.assignContinents(TileMap.AssignContinentsMode.Clear) } - private fun createInnerSea(tileMap: TileMap) { + private fun createInnerSea() { val elevationSeed = randomness.RNG.nextInt().toDouble() for (tile in tileMap.values) { var elevation = randomness.getPerlinNoise(tile, elevationSeed) - elevation -= getEllipticContinent(tile, tileMap, 0.6) * 0.3 + elevation -= getEllipticContinent(tile, 0.6) * 0.3 spawnLandOrWater(tile, elevation) } } - private fun createContinentAndIslands(tileMap: TileMap) { + private fun createContinentAndIslands() { val isNorth = randomness.RNG.nextDouble() < 0.5 val isLatitude = if (tileMap.mapParameters.shape === MapShape.hexagonal || tileMap.mapParameters.shape === MapShape.flatEarth) randomness.RNG.nextDouble() > 0.5f @@ -193,12 +213,12 @@ class MapLandmassGenerator(val ruleset: Ruleset, val randomness: MapGenerationRa val elevationSeed = randomness.RNG.nextInt().toDouble() for (tile in tileMap.values) { var elevation = randomness.getPerlinNoise(tile, elevationSeed) - elevation = (elevation + getContinentAndIslandsTransform(tile, tileMap, isNorth, isLatitude)) / 2.0 + elevation = (elevation + getContinentAndIslandsTransform(tile, isNorth, isLatitude)) / 2.0 spawnLandOrWater(tile, elevation) } } - private fun createTwoContinents(tileMap: TileMap) { + private fun createTwoContinents() { val isLatitude = if (tileMap.mapParameters.shape === MapShape.hexagonal || tileMap.mapParameters.shape === MapShape.flatEarth) randomness.RNG.nextDouble() > 0.5f else if (tileMap.mapParameters.mapSize.height > tileMap.mapParameters.mapSize.width) true @@ -208,12 +228,12 @@ class MapLandmassGenerator(val ruleset: Ruleset, val randomness: MapGenerationRa val elevationSeed = randomness.RNG.nextInt().toDouble() for (tile in tileMap.values) { var elevation = randomness.getPerlinNoise(tile, elevationSeed) - elevation = (elevation + getTwoContinentsTransform(tile, tileMap, isLatitude)) / 2.0 + elevation = (elevation + getTwoContinentsTransform(tile, isLatitude)) / 2.0 spawnLandOrWater(tile, elevation) } } - private fun createThreeContinents(tileMap: TileMap) { + private fun createThreeContinents() { val isNorth = randomness.RNG.nextDouble() < 0.5 // On flat earth maps we can randomly do East or West instead of North or South val isEastWest = tileMap.mapParameters.shape === MapShape.flatEarth && randomness.RNG.nextDouble() > 0.5 @@ -221,25 +241,28 @@ class MapLandmassGenerator(val ruleset: Ruleset, val randomness: MapGenerationRa val elevationSeed = randomness.RNG.nextInt().toDouble() for (tile in tileMap.values) { var elevation = randomness.getPerlinNoise(tile, elevationSeed) - elevation = (elevation + getThreeContinentsTransform(tile, tileMap, isNorth, isEastWest)) / 2.0 + elevation = (elevation + getThreeContinentsTransform(tile, isNorth, isEastWest)) / 2.0 spawnLandOrWater(tile, elevation) } } - private fun createFourCorners(tileMap: TileMap) { + private fun createFourCorners() { val elevationSeed = randomness.RNG.nextInt().toDouble() for (tile in tileMap.values) { var elevation = randomness.getPerlinNoise(tile, elevationSeed) - elevation = elevation/2 + getFourCornersTransform(tile, tileMap)/2 + elevation = elevation / 2 + getFourCornersTransform(tile) / 2 spawnLandOrWater(tile, elevation) } } + //endregion + //region Shaping helpers + /** * Create an elevation map that favors a central elliptic continent spanning over 85% - 95% of * the map size. */ - private fun getEllipticContinent(tile: Tile, tileMap: TileMap, percentOfMap: Double = 0.85): Double { + private fun getEllipticContinent(tile: Tile, percentOfMap: Double = 0.85): Double { val randomScale = randomness.RNG.nextDouble() val ratio = percentOfMap + 0.1 * randomness.RNG.nextDouble() @@ -253,7 +276,7 @@ class MapLandmassGenerator(val ruleset: Ruleset, val randomness: MapGenerationRa return min(0.3, 1.0 - (5.0 * distanceFactor * distanceFactor + randomScale) / 3.0) } - private fun getContinentAndIslandsTransform(tile: Tile, tileMap: TileMap, isNorth: Boolean, isLatitude: Boolean): Double { + private fun getContinentAndIslandsTransform(tile: Tile, isNorth: Boolean, isLatitude: Boolean): Double { // The idea here is to create a water area separating the two land areas. // So what we do it create a line of water in the middle - where latitude or longitude is close to 0. val randomScale = randomness.RNG.nextDouble() @@ -283,7 +306,7 @@ class MapLandmassGenerator(val ruleset: Ruleset, val randomness: MapGenerationRa return min(0.2, -1.0 + (5.0 * factor.pow(0.6f) + randomScale) / 3.0) } - private fun getTwoContinentsTransform(tile: Tile, tileMap: TileMap, isLatitude: Boolean): Double { + private fun getTwoContinentsTransform(tile: Tile, isLatitude: Boolean): Double { // The idea here is to create a water area separating the two land areas. // So what we do it create a line of water in the middle - where latitude or longitude is close to 0. val randomScale = randomness.RNG.nextDouble() @@ -303,7 +326,7 @@ class MapLandmassGenerator(val ruleset: Ruleset, val randomness: MapGenerationRa return min(0.2, -1.0 + (5.0 * factor.pow(0.6f) + randomScale) / 3.0) } - private fun getThreeContinentsTransform(tile: Tile, tileMap: TileMap, isNorth: Boolean, isEastWest: Boolean): Double { + private fun getThreeContinentsTransform(tile: Tile, isNorth: Boolean, isEastWest: Boolean): Double { // The idea here is to create a water area separating the three land areas. // So what we do it create a line of water in the middle - where latitude or longitude is close to 0. val randomScale = randomness.RNG.nextDouble() @@ -339,7 +362,7 @@ class MapLandmassGenerator(val ruleset: Ruleset, val randomness: MapGenerationRa return min(0.2, -1.0 + (5.0 * factor.pow(0.5f) + randomScale) / 3.0) } - private fun getFourCornersTransform(tile: Tile, tileMap: TileMap): Double { + private fun getFourCornersTransform(tile: Tile): Double { // The idea here is to create a water area separating the four land areas. // So what we do it create a line of water in the middle - where latitude or longitude is close to 0. val randomScale = randomness.RNG.nextDouble() @@ -364,7 +387,7 @@ class MapLandmassGenerator(val ruleset: Ruleset, val randomness: MapGenerationRa return 1.0 - (5.0 * shouldBeWater*shouldBeWater + randomScale) / 3.0 } - private fun getOceanEdgesTransform(tile: Tile, tileMap: TileMap): Double { + private fun getOceanEdgesTransform(tile: Tile): Double { // The idea is to reduce elevation at the border of the map, so that we have mostly ocean there. val maxX = tileMap.maxLongitude val maxY = tileMap.maxLatitude @@ -425,4 +448,5 @@ class MapLandmassGenerator(val ruleset: Ruleset, val randomness: MapGenerationRa val worldCoords = HexMath.hex2WorldCoords(tile.position) return Perlin.ridgedNoise3d(worldCoords.x.toDouble(), worldCoords.y.toDouble(), seed, nOctaves, persistence, lacunarity, scale) } + //endregion }