From 41b29256fe890afddfd0d1069389f76c6bce96ba Mon Sep 17 00:00:00 2001 From: SomeTroglodyte <63000004+SomeTroglodyte@users.noreply.github.com> Date: Sat, 9 Mar 2024 22:02:18 +0100 Subject: [PATCH] River terraform (#11256) * Allow terraformers to place Rivers * Someone said Ruleset is not a Set * Turn setConnectedByRiver into a public Tile API * Follow review suggestions --- .../logic/map/mapgenerator/MapGenerator.kt | 29 +++---- .../logic/map/mapgenerator/RiverGenerator.kt | 77 +++++++++++++++++++ core/src/com/unciv/logic/map/tile/Tile.kt | 60 +++++++++++++-- .../ruleset/unique/UniqueTriggerActivation.kt | 31 +++++--- 4 files changed, 166 insertions(+), 31 deletions(-) diff --git a/core/src/com/unciv/logic/map/mapgenerator/MapGenerator.kt b/core/src/com/unciv/logic/map/mapgenerator/MapGenerator.kt index 7f4ebe6516..33bd5939d3 100644 --- a/core/src/com/unciv/logic/map/mapgenerator/MapGenerator.kt +++ b/core/src/com/unciv/logic/map/mapgenerator/MapGenerator.kt @@ -220,20 +220,23 @@ class MapGenerator(val ruleset: Ruleset, private val coroutineScope: CoroutineSc debug("MapGenerator.%s took %s.%sms", text, delta/1000000L, (delta/10000L).rem(100)) } - fun convertTerrains(tiles: Iterable) { - for (tile in tiles) { - val conversionUnique = - tile.getBaseTerrain().getMatchingUniques(UniqueType.ChangesTerrain) - .firstOrNull { tile.isAdjacentTo(it.params[1]) } - ?: continue - val terrain = ruleset.terrains[conversionUnique.params[0]] ?: continue + fun convertTerrains(tiles: Iterable) = Helpers.convertTerrains(ruleset, tiles) + object Helpers { + fun convertTerrains(ruleset: Ruleset, tiles: Iterable) { + for (tile in tiles) { + val conversionUnique = + tile.getBaseTerrain().getMatchingUniques(UniqueType.ChangesTerrain) + .firstOrNull { tile.isAdjacentTo(it.params[1]) } + ?: continue + val terrain = ruleset.terrains[conversionUnique.params[0]] ?: continue - if (terrain.type == TerrainType.TerrainFeature) { - if (!terrain.occursOn.contains(tile.lastTerrain.name)) continue - tile.addTerrainFeature(terrain.name) - } else - tile.baseTerrain = terrain.name - tile.setTerrainTransients() + if (terrain.type != TerrainType.TerrainFeature) + tile.baseTerrain = terrain.name + else if (!terrain.occursOn.contains(tile.lastTerrain.name)) continue + else + tile.addTerrainFeature(terrain.name) + tile.setTerrainTransients() + } } } diff --git a/core/src/com/unciv/logic/map/mapgenerator/RiverGenerator.kt b/core/src/com/unciv/logic/map/mapgenerator/RiverGenerator.kt index f2e6a40502..9070996ee3 100644 --- a/core/src/com/unciv/logic/map/mapgenerator/RiverGenerator.kt +++ b/core/src/com/unciv/logic/map/mapgenerator/RiverGenerator.kt @@ -5,6 +5,7 @@ import com.unciv.Constants import com.unciv.logic.map.TileMap import com.unciv.logic.map.tile.Tile import com.unciv.models.ruleset.Ruleset +import com.unciv.models.ruleset.unique.UniqueType import com.unciv.utils.debug import kotlin.math.roundToInt @@ -189,4 +190,80 @@ class RiverGenerator( } }.count { it } } + + companion object { + /** [UniqueType.OneTimeChangeTerrain] tries to place a "River" feature. + * + * Operates on *edges* - while [spawnRiver] hops from [vertex][RiverCoordinate] to vertex! + * Placed here to make comparison easier, even though the implementation has nothing else in common. + * @return success - one edge of [tile] has a new river + */ + fun continueRiverOn(tile: Tile): Boolean { + if (!tile.isLand) return false + val tileMap = tile.tileMap + + /** Helper to prioritize a tile edge for river placement - accesses [tile] as closure, + * and considers the edge common with [otherTile] in direction [clockPosition]. + * + * Will consider two additional tiles - those that are neighbor to both [tile] and [otherTile], + * and four other edges - those connecting to "our" edge. + */ + class NeighborData(val otherTile: Tile) { + val clockPosition = tileMap.getNeighborTileClockPosition(tile, otherTile) + // Accesses `tile` as closure + val isConnectedByRiver = tile.isConnectedByRiver(otherTile) + val edgeLeadsToSea: Boolean + val connectedRiverCount: Int + val verticesFormYCount: Int + + init { + // Similar: private fun Tile.getLeftSharedNeighbor in TileLayerBorders + val leftSharedNeighbor = tileMap.getClockPositionNeighborTile(tile, (clockPosition - 2) % 12) + val rightSharedNeighbor = tileMap.getClockPositionNeighborTile(tile, (clockPosition + 2) % 12) + edgeLeadsToSea = leftSharedNeighbor?.isWater == true || rightSharedNeighbor?.isWater == true + connectedRiverCount = sequence { + yield(leftSharedNeighbor?.isConnectedByRiver(tile)) + yield(leftSharedNeighbor?.isConnectedByRiver(otherTile)) + yield(rightSharedNeighbor?.isConnectedByRiver(tile)) + yield(rightSharedNeighbor?.isConnectedByRiver(otherTile)) + }.count { it == true } + verticesFormYCount = sequence { + yield(leftSharedNeighbor?.run { isConnectedByRiver(tile) && isConnectedByRiver(otherTile) }) + yield(rightSharedNeighbor?.run { isConnectedByRiver(tile) && isConnectedByRiver(otherTile) }) + }.count { it == true } + } + + fun getPriority(edgeToSeaPriority: Int) = + // choose a priority - only order matters, not magnitude + when { + isConnectedByRiver -> -9 // ensures this isn't chosen, otherwise "cannot place another river" would have bailed + edgeLeadsToSea -> edgeToSeaPriority + connectedRiverCount - 3 * verticesFormYCount + // Just 6 possible cases left: + // * Connect two bends = -2 + // * Connect a bend to nothing = -1 // debatable! + // * Connect nothing = 0 + // * Connect a bend with an open end = 1 + // * Connect to one open end = 2 + // * Connect two open ends = 3 (make that 4 to simplify) + verticesFormYCount == 2 -> -2 + verticesFormYCount == 1 -> connectedRiverCount * 2 - 5 + else -> connectedRiverCount * 2 + } + } + + // Collect data (includes tiles we already have a river edge with - need the stats) + val viableNeighbors = tile.neighbors.filter { it.isLand }.map { NeighborData(it) }.toList() + if (viableNeighbors.all { it.isConnectedByRiver }) return false // cannot place another river + + // Greatly encourage connecting to sea unless the tile already has a river to sea, in which case slightly discourage another one + val edgeToSeaPriority = if (viableNeighbors.none { it.isConnectedByRiver && it.edgeLeadsToSea }) 9 else -1 + + val choice = viableNeighbors + .groupBy { it.getPriority(edgeToSeaPriority) } // Assign and group by priorities + .maxBy { it.key }.value // Get the List with best priority - can't be empty + .random() + + return tile.setConnectedByRiver(choice.otherTile, newValue = true, convertTerrains = true) + } + } } diff --git a/core/src/com/unciv/logic/map/tile/Tile.kt b/core/src/com/unciv/logic/map/tile/Tile.kt index 3241639b37..340eda5642 100644 --- a/core/src/com/unciv/logic/map/tile/Tile.kt +++ b/core/src/com/unciv/logic/map/tile/Tile.kt @@ -12,6 +12,7 @@ import com.unciv.logic.map.HexMath import com.unciv.logic.map.MapParameters import com.unciv.logic.map.MapResources import com.unciv.logic.map.TileMap +import com.unciv.logic.map.mapgenerator.MapGenerator import com.unciv.logic.map.mapunit.MapUnit import com.unciv.logic.map.mapunit.movement.UnitMovement import com.unciv.models.ruleset.Ruleset @@ -644,13 +645,58 @@ class Tile : IsPartOfGameInfoSerialization { } } - @delegate:Transient - private val isAdjacentToRiverLazy by lazy { - // These are so if you add a river at the bottom of the map (no neighboring tile to be connected to) - // that tile is still considered adjacent to river - hasBottomLeftRiver || hasBottomRiver || hasBottomRightRiver - || neighbors.any { isConnectedByRiver(it) } } - fun isAdjacentToRiver() = isAdjacentToRiverLazy + @Transient + private var isAdjacentToRiver = false + @Transient + private var isAdjacentToRiverKnown = false + fun isAdjacentToRiver(): Boolean { + if (!isAdjacentToRiverKnown) { + isAdjacentToRiver = + // These are so if you add a river at the bottom of the map (no neighboring tile to be connected to) + // that tile is still considered adjacent to river + hasBottomLeftRiver || hasBottomRiver || hasBottomRightRiver + || neighbors.any { isConnectedByRiver(it) } + isAdjacentToRiverKnown = true + } + return isAdjacentToRiver + } + + /** Allows resetting the cached value [isAdjacentToRiver] will return + * @param isKnownTrue Set this to indicate you need to update the cache due to **adding** a river edge + * (removing would need to look at other edges, and that is what isAdjacentToRiver will do) + */ + private fun resetAdjacentToRiverTransient(isKnownTrue: Boolean = false) { + isAdjacentToRiver = isKnownTrue + isAdjacentToRiverKnown = isKnownTrue + } + + /** + * Sets the "has river" state of one edge of this Tile. Works for all six directions. + * @param otherTile The neighbor tile in the direction the river we wish to change is (If it's not a neighbor, this does nothing). + * @param newValue The new river edge state: `true` to create a river, `false` to remove one. + * @param convertTerrains If true, calls MapGenerator's convertTerrains to apply UniqueType.ChangesTerrain effects. + * @return The state did change (`false`: the edge already had the `newValue`) + */ + fun setConnectedByRiver(otherTile: Tile, newValue: Boolean, convertTerrains: Boolean = false): Boolean { + //todo synergy potential with [MapEditorEditRiversTab]? + val field = when (tileMap.getNeighborTileClockPosition(this, otherTile)) { + 2 -> otherTile::hasBottomLeftRiver // we're to the bottom-left of it + 4 -> ::hasBottomRightRiver // we're to the top-left of it + 6 -> ::hasBottomRiver // we're directly above it + 8 -> ::hasBottomLeftRiver // we're to the top-right of it + 10 -> otherTile::hasBottomRightRiver // we're to the bottom-right of it + 12 -> otherTile::hasBottomRiver // we're directly below it + else -> return false + } + if (field.get() == newValue) return false + field.set(newValue) + val affectedTiles = listOf(this, otherTile) + for (tile in affectedTiles) + tile.resetAdjacentToRiverTransient(newValue) + if (convertTerrains) + MapGenerator.Helpers.convertTerrains(ruleset, affectedTiles) + return true + } /** * @returns whether units of [civInfo] can pass through this tile, considering only civ-wide filters. diff --git a/core/src/com/unciv/models/ruleset/unique/UniqueTriggerActivation.kt b/core/src/com/unciv/models/ruleset/unique/UniqueTriggerActivation.kt index 6268ad45e7..83edca0b94 100644 --- a/core/src/com/unciv/models/ruleset/unique/UniqueTriggerActivation.kt +++ b/core/src/com/unciv/models/ruleset/unique/UniqueTriggerActivation.kt @@ -18,6 +18,7 @@ import com.unciv.logic.civilization.PopupAlert import com.unciv.logic.civilization.TechAction import com.unciv.logic.civilization.managers.ReligionState import com.unciv.logic.map.mapgenerator.NaturalWonderGenerator +import com.unciv.logic.map.mapgenerator.RiverGenerator import com.unciv.logic.map.mapunit.MapUnit import com.unciv.logic.map.tile.Tile import com.unciv.models.UpgradeUnitAction @@ -99,7 +100,7 @@ object UniqueTriggerActivation { val tileBasedRandom = if (tile != null) Random(tile.position.toString().hashCode()) else Random(-550) // Very random indeed - val ruleSet = civInfo.gameInfo.ruleset + val ruleset = civInfo.gameInfo.ruleset when (unique.type) { UniqueType.TriggerEvent -> { @@ -115,7 +116,7 @@ object UniqueTriggerActivation { UniqueType.OneTimeFreeUnit -> { val unitName = unique.params[0] - val baseUnit = ruleSet.units[unitName] ?: return null + val baseUnit = ruleset.units[unitName] ?: return null val civUnit = civInfo.getEquivalentUnit(baseUnit) if (civUnit.isCityFounder() && civInfo.isOneCityChallenger()) return null @@ -156,7 +157,7 @@ object UniqueTriggerActivation { UniqueType.OneTimeAmountFreeUnits -> { val unitName = unique.params[1] - val baseUnit = ruleSet.units[unitName] ?: return null + val baseUnit = ruleset.units[unitName] ?: return null val civUnit = civInfo.getEquivalentUnit(baseUnit) if (civUnit.isCityFounder() && civInfo.isOneCityChallenger()) return null @@ -212,7 +213,7 @@ object UniqueTriggerActivation { UniqueType.OneTimeFreeUnitRuins -> { var civUnit = civInfo.getEquivalentUnit(unique.params[0]) if ( civUnit.isCityFounder() && civInfo.isOneCityChallenger()) { - val replacementUnit = ruleSet.units.values + val replacementUnit = ruleset.units.values .firstOrNull { it.getMatchingUniques(UniqueType.BuildImprovements) .any { unique -> unique.params[0] == "Land" } @@ -383,7 +384,7 @@ object UniqueTriggerActivation { } } UniqueType.OneTimeFreeTechRuins -> { - val researchableTechsFromThatEra = ruleSet.technologies.values + val researchableTechsFromThatEra = ruleset.technologies.values .filter { (it.column!!.era == unique.params[1] || unique.params[1] == "any era") && civInfo.tech.canBeResearched(it.name) @@ -445,7 +446,7 @@ object UniqueTriggerActivation { UniqueType.OneTimeProvideResources -> { val resourceName = unique.params[1] - val resource = ruleSet.tileResources[resourceName] ?: return null + val resource = ruleset.tileResources[resourceName] ?: return null if (!resource.isStockpiled()) return null return { @@ -464,7 +465,7 @@ object UniqueTriggerActivation { UniqueType.OneTimeConsumeResources -> { val resourceName = unique.params[1] - val resource = ruleSet.tileResources[resourceName] ?: return null + val resource = ruleset.tileResources[resourceName] ?: return null if (!resource.isStockpiled()) return null return { @@ -498,7 +499,7 @@ object UniqueTriggerActivation { val unitsToPromote = civInfo.units.getCivUnits().filter { it.matchesFilter(filter) } .filter { unitToPromote -> - ruleSet.unitPromotions.values.any { + ruleset.unitPromotions.values.any { it.name == promotion && unitToPromote.type.name in it.unitTypes } }.toList() @@ -828,7 +829,7 @@ object UniqueTriggerActivation { } } UniqueType.FreeSpecificBuildings ->{ - val building = ruleSet.buildings[unique.params[0]] ?: return null + val building = ruleset.buildings[unique.params[0]] ?: return null return { civInfo.civConstructions.addFreeBuildings(building, unique.params[1].toInt()) true @@ -946,7 +947,9 @@ object UniqueTriggerActivation { UniqueType.OneTimeChangeTerrain -> { if (tile == null) return null - val terrain = ruleSet.terrains[unique.params[0]] ?: return null + val terrain = ruleset.terrains[unique.params[0]] ?: return null + if (terrain.name == Constants.river) + return getOneTimeChangeRiverTriggerFunction(tile) if (terrain.type == TerrainType.TerrainFeature && !terrain.occursOn.contains(tile.lastTerrain.name)) return null if (tile.terrainFeatures.contains(terrain.name)) return null @@ -959,7 +962,7 @@ object UniqueTriggerActivation { TerrainType.TerrainFeature -> tile.addTerrainFeature(terrain.name) TerrainType.NaturalWonder -> NaturalWonderGenerator.placeNaturalWonder(terrain, tile) } - TileInfoNormalizer.normalizeToRuleset(tile, ruleSet) + TileInfoNormalizer.normalizeToRuleset(tile, ruleset) tile.getUnits().filter { !it.movement.canPassThrough(tile) }.toList() .forEach { it.movement.teleportToClosestMoveableTile() } true @@ -980,4 +983,10 @@ object UniqueTriggerActivation { } else null } + + private fun getOneTimeChangeRiverTriggerFunction(tile: Tile): (()->Boolean)? { + if (tile.neighbors.none { it.isLand && !tile.isConnectedByRiver(it) }) + return null // no place for another river + return { RiverGenerator.continueRiverOn(tile) } + } }