Rework game start again (continents) (#5335)

Co-authored-by: Yair Morgenstern <yairm210@hotmail.com>
This commit is contained in:
SomeTroglodyte 2021-09-28 22:48:06 +02:00 committed by GitHub
parent 5f9bcd0d74
commit d3868dae62
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 140 additions and 109 deletions

View File

@ -3,21 +3,18 @@ package com.unciv.logic
import com.unciv.Constants import com.unciv.Constants
import com.unciv.UncivGame import com.unciv.UncivGame
import com.unciv.logic.civilization.* import com.unciv.logic.civilization.*
import com.unciv.logic.map.BFS
import com.unciv.logic.map.TileInfo import com.unciv.logic.map.TileInfo
import com.unciv.logic.map.TileMap import com.unciv.logic.map.TileMap
import com.unciv.logic.map.mapgenerator.MapGenerator import com.unciv.logic.map.mapgenerator.MapGenerator
import com.unciv.models.metadata.GameParameters import com.unciv.models.metadata.GameParameters
import com.unciv.models.metadata.GameSetupInfo import com.unciv.models.metadata.GameSetupInfo
import com.unciv.models.ruleset.Era
import com.unciv.models.ruleset.ModOptionsConstants import com.unciv.models.ruleset.ModOptionsConstants
import com.unciv.models.ruleset.Ruleset import com.unciv.models.ruleset.Ruleset
import com.unciv.models.ruleset.RulesetCache import com.unciv.models.ruleset.RulesetCache
import com.unciv.models.ruleset.tile.ResourceType import com.unciv.models.ruleset.tile.ResourceType
import java.util.* import java.util.*
import kotlin.collections.ArrayList
import kotlin.collections.HashMap import kotlin.collections.HashMap
import kotlin.math.max import kotlin.collections.HashSet
object GameStarter { object GameStarter {
// temporary instrumentation while tuning/debugging // temporary instrumentation while tuning/debugging
@ -86,7 +83,7 @@ object GameStarter {
if (tileMap.continentSizes.isEmpty()) // Probably saved map without continent data if (tileMap.continentSizes.isEmpty()) // Probably saved map without continent data
runAndMeasure("assignContinents") { runAndMeasure("assignContinents") {
mapGen.assignContinents(tileMap) tileMap.assignContinents()
} }
runAndMeasure("addCivStartingUnits") { runAndMeasure("addCivStartingUnits") {
@ -244,23 +241,21 @@ object GameStarter {
for (tile in tileMap.values) { for (tile in tileMap.values) {
startScores[tile] = tile.getTileStartScore() startScores[tile] = tile.getTileStartScore()
} }
val allCivs = gameInfo.civilizations.filter { !it.isBarbarian() }
val landTilesInBigEnoughGroup = getCandidateLand(allCivs.size, tileMap, startScores)
// First we get start locations for the major civs, on the second pass the city states (without predetermined starts) can squeeze in wherever // First we get start locations for the major civs, on the second pass the city states (without predetermined starts) can squeeze in wherever
// I hear copying code is good
val civNamesWithStartingLocations = tileMap.startingLocationsByNation.keys val civNamesWithStartingLocations = tileMap.startingLocationsByNation.keys
val bestCivs = gameInfo.civilizations.filter { !it.isBarbarian() && (!it.isCityState() || it.civName in civNamesWithStartingLocations) } val bestCivs = allCivs.filter { !it.isCityState() || it.civName in civNamesWithStartingLocations }
val bestLocations = getStartingLocations(bestCivs, tileMap, startScores) val bestLocations = getStartingLocations(bestCivs, tileMap, landTilesInBigEnoughGroup, startScores)
for ((civ, tile) in bestLocations) { for ((civ, tile) in bestLocations) {
if (civ.civName in civNamesWithStartingLocations) // Already have explicit starting locations // A nation can have multiple marked starting locations, of which the first pass may have chosen one
continue tileMap.removeStartingLocations(civ.civName)
// Mark the best start locations so we remember them for the second pass // Mark the best start locations so we remember them for the second pass
tileMap.addStartingLocation(civ.civName, tile) tileMap.addStartingLocation(civ.civName, tile)
} }
val startingLocations = getStartingLocations( val startingLocations = getStartingLocations(allCivs, tileMap, landTilesInBigEnoughGroup, startScores)
gameInfo.civilizations.filter { !it.isBarbarian() },
tileMap, startScores)
val settlerLikeUnits = ruleSet.units.filter { val settlerLikeUnits = ruleSet.units.filter {
it.value.uniqueObjects.any { unique -> unique.placeholderText == Constants.settlerUnique } it.value.uniqueObjects.any { unique -> unique.placeholderText == Constants.settlerUnique }
@ -349,70 +344,66 @@ object GameStarter {
} }
} }
private fun getCandidateLand(
civCount: Int,
tileMap: TileMap,
startScores: HashMap<TileInfo, Float>
): Map<TileInfo, Float> {
if (tileMap.continentSizes.isEmpty()) tileMap.assignContinents()
private fun getStartingLocations(civs: List<CivilizationInfo>, tileMap: TileMap, startScores: HashMap<TileInfo, Float>): HashMap<CivilizationInfo, TileInfo> { // We want to distribute starting locations fairly, and thus not place anybody on a small island
val landTilesInBigEnoughGroup = tileMap.landTilesInBigEnoughGroup // - unless necessary. Old code would only consider landmasses >= 20 tiles.
if (landTilesInBigEnoughGroup.isEmpty()) { // Instead, take continents until >=75% total area or everybody can get their own island
// Worst case - a pre-made map with continent data. This means we didn't re-run assignContinents, val orderedContinents = tileMap.continentSizes.asSequence().sortedByDescending { it.value }.toList()
// so we don't have a cached landTilesInBigEnoughGroup. So we need to do it the hard way. val totalArea = tileMap.continentSizes.values.sum()
var landTiles = tileMap.values var candidateArea = 0
// Games starting on snow might as well start over... val candidateContinents = HashSet<Int>()
.filter { it.isLand && !it.isImpassible() && it.baseTerrain != Constants.snow } for ((index, continentSize) in orderedContinents.withIndex()) {
while (landTiles.any()) { candidateArea += continentSize.value
val bfs = BFS(landTiles.random()) { it.isLand && !it.isImpassible() } candidateContinents.add(continentSize.key)
bfs.stepToEnd() if (candidateArea * 4 >= totalArea * 3) break
val tilesInGroup = bfs.getReachedTiles() if (index >= civCount) break
landTiles = landTiles.filter { it !in tilesInGroup }
if (tilesInGroup.size > 20) // is this a good number? I dunno, but it's easy enough to change later on
landTilesInBigEnoughGroup.addAll(tilesInGroup)
} }
return startScores.filter { it.key.getContinent() in candidateContinents }
} }
private fun getStartingLocations(
civs: List<CivilizationInfo>,
tileMap: TileMap,
landTilesInBigEnoughGroup: Map<TileInfo, Float>,
startScores: HashMap<TileInfo, Float>
): HashMap<CivilizationInfo, TileInfo> {
val civsOrderedByAvailableLocations = civs.shuffled() // Order should be random since it determines who gets best start val civsOrderedByAvailableLocations = civs.shuffled() // Order should be random since it determines who gets best start
.sortedBy { civ -> .sortedBy { civ ->
when { when {
civ.civName in tileMap.startingLocationsByNation -> 1 // harshest requirements civ.civName in tileMap.startingLocationsByNation -> 1 // harshest requirements
civ.nation.startBias.contains("Tundra") -> 2 // Tundra starts are hard to find, so let's do them first civ.nation.startBias.any { it in tileMap.naturalWonders } -> 2
civ.nation.startBias.isNotEmpty() -> 3 // less harsh civ.nation.startBias.contains("Tundra") -> 3 // Tundra starts are hard to find, so let's do them first
else -> 4 // no requirements civ.nation.startBias.isNotEmpty() -> 4 // less harsh
else -> 5 // no requirements
} }
} }
for (minimumDistanceBetweenStartingLocations in tileMap.tileMatrix.size / 4 downTo 0) { for (minimumDistanceBetweenStartingLocations in tileMap.tileMatrix.size / 6 downTo 0) {
val freeTiles = landTilesInBigEnoughGroup val freeTiles = landTilesInBigEnoughGroup.asSequence()
.filter { .filter {
HexMath.getDistanceFromEdge(it.position, tileMap.mapParameters) >= HexMath.getDistanceFromEdge(it.key.position, tileMap.mapParameters) >=
(minimumDistanceBetweenStartingLocations * 2) /3 (minimumDistanceBetweenStartingLocations * 2) / 3
}.toMutableList() }.sortedBy { it.value }
.map { it.key }
.toMutableList()
val startingLocations = HashMap<CivilizationInfo, TileInfo>() val startingLocations = HashMap<CivilizationInfo, TileInfo>()
for (civ in civsOrderedByAvailableLocations) { for (civ in civsOrderedByAvailableLocations) {
var startingLocation: TileInfo val distanceToNext = minimumDistanceBetweenStartingLocations /
val presetStartingLocation = tileMap.startingLocationsByNation[civ.civName]?.randomOrNull() // in case map editor is extended to allow alternate starting locations for a nation (if (civ.isCityState()) 2 else 1) // We allow city states to squeeze in tighter
var distanceToNext = minimumDistanceBetweenStartingLocations val presetStartingLocation = tileMap.startingLocationsByNation[civ.civName]?.randomOrNull()
val startingLocation = if (presetStartingLocation != null) presetStartingLocation
if (presetStartingLocation != null) startingLocation = presetStartingLocation
else { else {
if (freeTiles.isEmpty()) break // we failed to get all the starting tiles with this minimum distance if (freeTiles.isEmpty()) break // we failed to get all the starting tiles with this minimum distance
if (civ.isCityState()) getOneStartingLocation(civ, tileMap, freeTiles, startScores)
distanceToNext = minimumDistanceBetweenStartingLocations / 2 // We allow random city states to squeeze in tighter
freeTiles.sortBy { startScores[it] }
var preferredTiles = freeTiles.toList()
for (startBias in civ.nation.startBias) {
preferredTiles = when {
startBias.startsWith("Avoid [") -> {
val tileToAvoid = startBias.removePrefix("Avoid [").removeSuffix("]")
preferredTiles.filter { !it.matchesTerrainFilter(tileToAvoid) }
}
startBias == Constants.coast -> preferredTiles.filter { it.isCoastalTile() }
else -> preferredTiles.filter { it.matchesTerrainFilter(startBias) }
}
}
startingLocation = if (preferredTiles.isNotEmpty()) preferredTiles.last() else freeTiles.last()
} }
startingLocations[civ] = startingLocation startingLocations[civ] = startingLocation
freeTiles.removeAll(tileMap.getTilesInDistance(startingLocation.position, distanceToNext)) freeTiles.removeAll(tileMap.getTilesInDistance(startingLocation.position, distanceToNext))
@ -424,6 +415,36 @@ object GameStarter {
throw Exception("Didn't manage to get starting tiles even with distance of 1?") throw Exception("Didn't manage to get starting tiles even with distance of 1?")
} }
private fun getOneStartingLocation(
civ: CivilizationInfo,
tileMap: TileMap,
freeTiles: MutableList<TileInfo>,
startScores: HashMap<TileInfo, Float>
): TileInfo {
if (civ.nation.startBias.any { it in tileMap.naturalWonders }) {
// startPref wants Natural wonder neighbor: Rare and very likely to be outside getDistanceFromEdge
val wonderNeighbor = tileMap.values.asSequence()
.filter { it.isNaturalWonder() && it.naturalWonder!! in civ.nation.startBias }
.sortedByDescending { startScores[it] }
.firstOrNull()
if (wonderNeighbor != null) return wonderNeighbor
}
var preferredTiles = freeTiles.toList()
for (startBias in civ.nation.startBias) {
preferredTiles = when {
startBias.startsWith("Avoid [") -> {
val tileToAvoid = startBias.removePrefix("Avoid [").removeSuffix("]")
preferredTiles.filter { !it.matchesTerrainFilter(tileToAvoid) }
}
startBias == Constants.coast -> preferredTiles.filter { it.isCoastalTile() }
startBias in tileMap.naturalWonders -> preferredTiles // passthrough: already failed
else -> preferredTiles.filter { it.matchesTerrainFilter(startBias) }
}
}
return preferredTiles.lastOrNull() ?: freeTiles.last()
}
private fun addConsolationPrize(gameInfo: GameInfo, spawn: TileInfo, points: Int) { private fun addConsolationPrize(gameInfo: GameInfo, spawn: TileInfo, points: Int) {
val relevantTiles = spawn.getTilesInDistanceRange(1..2).shuffled() val relevantTiles = spawn.getTilesInDistanceRange(1..2).shuffled()
var addedPoints = 0 var addedPoints = 0

View File

@ -2,7 +2,6 @@ package com.unciv.logic.map
import com.badlogic.gdx.math.Vector2 import com.badlogic.gdx.math.Vector2
import com.unciv.Constants import com.unciv.Constants
import com.unciv.UncivGame
import com.unciv.logic.GameInfo import com.unciv.logic.GameInfo
import com.unciv.logic.HexMath import com.unciv.logic.HexMath
import com.unciv.logic.civilization.CivilizationInfo import com.unciv.logic.civilization.CivilizationInfo
@ -79,9 +78,6 @@ class TileMap {
@Transient @Transient
val startingLocationsByNation = HashMap<String,HashSet<TileInfo>>() val startingLocationsByNation = HashMap<String,HashSet<TileInfo>>()
@Transient
val landTilesInBigEnoughGroup = ArrayList<TileInfo>() // cached at map gen
//endregion //endregion
//region Constructors //region Constructors
@ -546,11 +542,42 @@ class TileMap {
// we do not clean up an empty startingLocationsByNation[nationName] set - not worth it // we do not clean up an empty startingLocationsByNation[nationName] set - not worth it
} }
/** Removes all starting positions for [nationName], maintaining the transients */
fun removeStartingLocations(nationName: String) {
if (startingLocationsByNation[nationName] == null) return
for (tile in startingLocationsByNation[nationName]!!) {
startingLocations.remove(StartingLocation(tile.position, nationName))
}
startingLocationsByNation[nationName]!!.clear()
}
/** Clears starting positions, e.g. after GameStarter is done with them. Does not clear the pseudo-improvements. */ /** Clears starting positions, e.g. after GameStarter is done with them. Does not clear the pseudo-improvements. */
fun clearStartingLocations() { fun clearStartingLocations() {
startingLocations.clear() startingLocations.clear()
startingLocationsByNation.clear() startingLocationsByNation.clear()
} }
/** Set a continent id for each tile, so we can quickly see which tiles are connected.
* Can also be called on saved maps.
* @throws Exception when any land tile already has a continent ID
*/
fun assignContinents() {
var landTiles = values.filter { it.isLand && !it.isImpassible() }
var currentContinent = 0
while (landTiles.any()) {
val bfs = BFS(landTiles.random()) { it.isLand && !it.isImpassible() }
bfs.stepToEnd()
bfs.getReachedTiles().forEach {
it.setContinent(currentContinent)
}
val continent = bfs.getReachedTiles()
continentSizes[currentContinent] = continent.size
currentContinent++
landTiles = landTiles.filter { it !in continent }
}
}
//endregion //endregion
} }

View File

@ -74,7 +74,7 @@ class MapGenerator(val ruleset: Ruleset) {
spawnIce(map) spawnIce(map)
} }
runAndMeasure("assignContinents") { runAndMeasure("assignContinents") {
assignContinents(map) map.assignContinents()
} }
runAndMeasure("NaturalWonderGenerator") { runAndMeasure("NaturalWonderGenerator") {
NaturalWonderGenerator(ruleset, randomness).spawnNaturalWonders(map) NaturalWonderGenerator(ruleset, randomness).spawnNaturalWonders(map)
@ -463,32 +463,6 @@ class MapGenerator(val ruleset: Ruleset) {
tile.terrainFeatures.add(Constants.ice) tile.terrainFeatures.add(Constants.ice)
} }
} }
/** Set a continent id for each tile, so we can quickly see which tiles are connected.
* Can also be called on saved maps.
* @throws Exception when any land tile already has a continent ID
*/
fun assignContinents(tileMap: TileMap) {
var landTiles = tileMap.values
.filter { it.isLand && !it.isImpassible()}
var currentContinent = 0
while (landTiles.any()) {
val bfs = BFS(landTiles.random()) { it.isLand && !it.isImpassible() }
bfs.stepToEnd()
bfs.getReachedTiles().forEach {
it.setContinent(currentContinent)
}
val continent = bfs.getReachedTiles()
tileMap.continentSizes[currentContinent] = continent.size
if (continent.size > 20) {
tileMap.landTilesInBigEnoughGroup.addAll(continent)
}
currentContinent++
landTiles = landTiles.filter { it !in continent }
}
}
} }
class MapGenerationRandomness { class MapGenerationRandomness {

View File

@ -54,6 +54,15 @@ class NaturalWonderGenerator(val ruleset: Ruleset, val randomness: MapGeneration
private fun Unique.getIntParam(index: Int) = params[index].toInt() private fun Unique.getIntParam(index: Int) = params[index].toInt()
private fun spawnSpecificWonder(tileMap: TileMap, wonder: Terrain): Boolean { private fun spawnSpecificWonder(tileMap: TileMap, wonder: Terrain): Boolean {
val continentsRelevant = wonder.hasUnique(UniqueType.NaturalWonderLargerLandmass) ||
wonder.hasUnique(UniqueType.NaturalWonderSmallerLandmass)
val sortedContinents = if (continentsRelevant)
tileMap.continentSizes.asSequence()
.sortedByDescending { it.value }
.map { it.key }
.toList()
else listOf()
val suitableLocations = tileMap.values.filter { tile-> val suitableLocations = tileMap.values.filter { tile->
tile.resource == null && tile.resource == null &&
wonder.occursOn.contains(tile.getLastTerrain().name) && wonder.occursOn.contains(tile.getLastTerrain().name) &&
@ -71,13 +80,12 @@ class NaturalWonderGenerator(val ruleset: Ruleset, val randomness: MapGeneration
} }
count in unique.getIntParam(0)..unique.getIntParam(1) count in unique.getIntParam(0)..unique.getIntParam(1)
} }
UniqueType.NaturalWonderLandmass -> { UniqueType.NaturalWonderSmallerLandmass -> {
val sortedContinents = tileMap.continentSizes.asSequence()
.sortedByDescending { it.value }
.map { it.key }
.toList()
tile.getContinent() !in sortedContinents.take(unique.getIntParam(0)) tile.getContinent() !in sortedContinents.take(unique.getIntParam(0))
} }
UniqueType.NaturalWonderLargerLandmass -> {
tile.getContinent() in sortedContinents.take(unique.getIntParam(0))
}
UniqueType.NaturalWonderLatitude -> { UniqueType.NaturalWonderLatitude -> {
val lower = tileMap.maxLatitude * unique.getIntParam(0) * 0.01f val lower = tileMap.maxLatitude * unique.getIntParam(0) * 0.01f
val upper = tileMap.maxLatitude * unique.getIntParam(1) * 0.01f val upper = tileMap.maxLatitude * unique.getIntParam(1) * 0.01f
@ -168,7 +176,9 @@ class NaturalWonderGenerator(val ruleset: Ruleset, val randomness: MapGeneration
private fun TileInfo.matchesWonderFilter(filter: String) = when (filter) { private fun TileInfo.matchesWonderFilter(filter: String) = when (filter) {
"Elevated" -> baseTerrain == Constants.mountain || isHill() "Elevated" -> baseTerrain == Constants.mountain || isHill()
"Water" -> isWater "Water" -> isWater
"Land" -> isLand
"Hill" -> isHill() "Hill" -> isHill()
naturalWonder -> true
in allTerrainFeatures -> getLastTerrain().name == filter in allTerrainFeatures -> getLastTerrain().name == filter
else -> baseTerrain == filter else -> baseTerrain == filter
} }

View File

@ -92,14 +92,11 @@ enum class UniqueParameterType(val parameterName:String) {
}, },
/** Used by NaturalWonderGenerator, only tests base terrain or a feature */ /** Used by NaturalWonderGenerator, only tests base terrain or a feature */
SimpleTerrain("simpleTerrain") { SimpleTerrain("simpleTerrain") {
private val knownValues = setOf("Elevated", "Water", "Land")
override fun getErrorSeverity(parameterText: String, ruleset: Ruleset): override fun getErrorSeverity(parameterText: String, ruleset: Ruleset):
UniqueType.UniqueComplianceErrorSeverity? { UniqueType.UniqueComplianceErrorSeverity? {
if (parameterText == "Elevated") return null if (parameterText in knownValues) return null
if (ruleset.terrains.values.any { if (ruleset.terrains.containsKey(parameterText)) return null
it.name == parameterText &&
(it.type.isBaseTerrain || it.type == TerrainType.TerrainFeature)
})
return null
return UniqueType.UniqueComplianceErrorSeverity.RulesetSpecific return UniqueType.UniqueComplianceErrorSeverity.RulesetSpecific
} }
}, },

View File

@ -103,14 +103,16 @@ enum class UniqueType(val text:String, vararg targets: UniqueTarget) {
CityStateMilitaryUnits("Provides military units every ≈[amount] turns", UniqueTarget.CityState), // No conditional support as of yet CityStateMilitaryUnits("Provides military units every ≈[amount] turns", UniqueTarget.CityState), // No conditional support as of yet
CityStateUniqueLuxury("Provides a unique luxury", UniqueTarget.CityState), // No conditional support as of yet CityStateUniqueLuxury("Provides a unique luxury", UniqueTarget.CityState), // No conditional support as of yet
NaturalWonderNeighborCount("Must be adjacent to [amount] [terrainFilter] tiles", UniqueTarget.Terrain), NaturalWonderNeighborCount("Must be adjacent to [amount] [simpleTerrain] tiles", UniqueTarget.Terrain),
NaturalWonderNeighborsRange("Must be adjacent to [amount] to [amount] [terrainFilter] tiles", UniqueTarget.Terrain), NaturalWonderNeighborsRange("Must be adjacent to [amount] to [amount] [simpleTerrain] tiles", UniqueTarget.Terrain),
NaturalWonderLandmass("Must not be on [amount] largest landmasses", UniqueTarget.Terrain), NaturalWonderSmallerLandmass("Must not be on [amount] largest landmasses", UniqueTarget.Terrain),
NaturalWonderLargerLandmass("Must be on [amount] largest landmasses", UniqueTarget.Terrain),
NaturalWonderLatitude("Occurs on latitudes from [amount] to [amount] percent of distance equator to pole", UniqueTarget.Terrain), NaturalWonderLatitude("Occurs on latitudes from [amount] to [amount] percent of distance equator to pole", UniqueTarget.Terrain),
NaturalWonderGroups("Occurs in groups of [amount] to [amount] tiles", UniqueTarget.Terrain), NaturalWonderGroups("Occurs in groups of [amount] to [amount] tiles", UniqueTarget.Terrain),
NaturalWonderConvertNeighbors("Neighboring tiles will convert to [baseTerrain]", UniqueTarget.Terrain), NaturalWonderConvertNeighbors("Neighboring tiles will convert to [baseTerrain]", UniqueTarget.Terrain),
// The "Except [terrainFilter]" could theoretically be implemented with a conditional // The "Except [terrainFilter]" could theoretically be implemented with a conditional
NaturalWonderConvertNeighborsExcept("Neighboring tiles except [terrainFilter] will convert to [baseTerrain]", UniqueTarget.Terrain), NaturalWonderConvertNeighborsExcept("Neighboring tiles except [baseTerrain] will convert to [baseTerrain]", UniqueTarget.Terrain),
TerrainGrantsPromotion("Grants [promotion] ([comment]) to adjacent [mapUnitFilter] units for the rest of the game", UniqueTarget.Terrain), TerrainGrantsPromotion("Grants [promotion] ([comment]) to adjacent [mapUnitFilter] units for the rest of the game", UniqueTarget.Terrain),