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
This commit is contained in:
SomeTroglodyte 2024-03-09 22:02:18 +01:00 committed by GitHub
parent 44528d26d0
commit 41b29256fe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 166 additions and 31 deletions

View File

@ -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<Tile>) {
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<Tile>) = Helpers.convertTerrains(ruleset, tiles)
object Helpers {
fun convertTerrains(ruleset: Ruleset, tiles: Iterable<Tile>) {
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()
}
}
}

View File

@ -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)
}
}
}

View File

@ -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.

View File

@ -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) }
}
}