Regions part 2 - City state placements, start normalization (#5663)

* start position normalization

* assignLuxuries

* City states placement

* city state normalization

* don't consider tiny islands

* also modify the other json since they are duplicated now
This commit is contained in:
SimonCeder 2021-11-11 11:11:48 +01:00 committed by GitHub
parent bc5ea2d90a
commit e4f686964e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 685 additions and 85 deletions

View File

@ -292,7 +292,8 @@
"gold": 2,
"improvement": "Quarry",
"improvementStats": {"production": 1},
"uniques": ["[+15]% Production when constructing [All] wonders [in all cities]"]
"uniques": ["[+15]% Production when constructing [All] wonders [in all cities]",
"Special placement during map generation"]
},
{
"name": "Whales",

View File

@ -292,7 +292,8 @@
"gold": 2,
"improvement": "Quarry",
"improvementStats": {"production": 1},
"uniques": ["[+15]% Production when constructing [All] wonders [in all cities]"]
"uniques": ["[+15]% Production when constructing [All] wonders [in all cities]",
"Special placement during map generation"]
},
{
"name": "Whales",

View File

@ -225,15 +225,7 @@ object GameStarter {
!it.value.hasUnique(UniqueType.CityStateDeprecated)
}.keys
.shuffled()
.sortedByDescending { it in civNamesWithStartingLocations } )
val allMercantileResources = ruleset.tileResources.values.filter {
it.hasUnique(UniqueType.CityStateOnlyResource) }.map { it.name }
val unusedMercantileResources = Stack<String>()
unusedMercantileResources.addAll(allMercantileResources.shuffled())
.sortedBy { it in civNamesWithStartingLocations } ) // pop() gets the last item, so sort ascending
var addedCityStates = 0
// Keep trying to add city states until we reach the target number.
@ -286,11 +278,6 @@ object GameStarter {
for (civ in gameInfo.civilizations.filter { !it.isBarbarian() && !it.isSpectator() }) {
val startingLocation = startingLocations[civ]!!
if(civ.isMajorCiv() && startScores[startingLocation]!! < 45) {
// An unusually bad spawning location
addConsolationPrize(gameInfo, startingLocation, 45 - startingLocation.getTileStartScore().toInt())
}
if(civ.isCityState())
addCityStateLuxury(gameInfo, startingLocation)
@ -465,29 +452,6 @@ object GameStarter {
return preferredTiles.lastOrNull() ?: freeTiles.last()
}
private fun addConsolationPrize(gameInfo: GameInfo, spawn: TileInfo, points: Int) {
val relevantTiles = spawn.getTilesInDistanceRange(1..2).shuffled()
var addedPoints = 0
var addedBonuses = 0
for (tile in relevantTiles) {
if (addedPoints >= points || addedBonuses >= 4) // At some point enough is enough
break
if (tile.resource != null || tile.baseTerrain == Constants.snow) // Snow is quite irredeemable
continue
val bonusToAdd = gameInfo.ruleSet.tileResources.values
.filter { it.terrainsCanBeFoundOn.contains(tile.getLastTerrain().name) && it.resourceType == ResourceType.Bonus }
.randomOrNull()
if (bonusToAdd != null) {
tile.resource = bonusToAdd.name
addedPoints += (bonusToAdd.food + bonusToAdd.production + bonusToAdd.gold + 1).toInt() // +1 because resources can be improved
addedBonuses++
}
}
}
private fun addCityStateLuxury(gameInfo: GameInfo, spawn: TileInfo) {
// Every city state should have at least one luxury to trade
val relevantTiles = spawn.getTilesInDistance(2).shuffled()

View File

@ -237,9 +237,9 @@ open class TileInfo {
return workingCity != null && workingCity.lockedTiles.contains(position)
}
fun getTileStats(observingCiv: CivilizationInfo): Stats = getTileStats(getCity(), observingCiv)
fun getTileStats(observingCiv: CivilizationInfo?): Stats = getTileStats(getCity(), observingCiv)
fun getTileStats(city: CityInfo?, observingCiv: CivilizationInfo): Stats {
fun getTileStats(city: CityInfo?, observingCiv: CivilizationInfo?): Stats {
var stats = getBaseTerrain().cloneStats()
for (terrainFeatureBase in getTerrainFeatures()) {
@ -288,23 +288,24 @@ open class TileInfo {
stats.add(unique.stats)
}
// resource base
if (hasViewableResource(observingCiv)) stats.add(tileResource)
val improvement = getTileImprovement()
if (improvement != null)
stats.add(getImprovementStats(improvement, observingCiv, city))
if (isCityCenter()) {
if (stats.food < 2) stats.food = 2f
if (stats.production < 1) stats.production = 1f
}
if (isAdjacentToRiver()) stats.gold++
if (stats.gold != 0f && observingCiv.goldenAges.isGoldenAge())
stats.gold++
if (observingCiv != null) {
// resource base
if (hasViewableResource(observingCiv)) stats.add(tileResource)
val improvement = getTileImprovement()
if (improvement != null)
stats.add(getImprovementStats(improvement, observingCiv, city))
if (isCityCenter()) {
if (stats.food < 2) stats.food = 2f
if (stats.production < 1) stats.production = 1f
}
if (stats.gold != 0f && observingCiv.goldenAges.isGoldenAge())
stats.gold++
}
for ((stat, value) in stats)
if (value < 0f) stats[stat] = 0f

View File

@ -78,12 +78,18 @@ class MapGenerator(val ruleset: Ruleset) {
runAndMeasure("RiverGenerator") {
RiverGenerator(map, randomness).spawnRivers()
}
val regions = MapRegions(ruleset)
runAndMeasure("generateRegions") {
regions.generateRegions(map, civilizations.count { ruleset.nations[it.civName]!!.isMajorCiv() })
}
runAndMeasure("assignRegions") {
regions.assignRegions(map, civilizations.filter { ruleset.nations[it.civName]!!.isMajorCiv() })
// Region based map generation - not used when generating maps in worldbuilder
if (civilizations.isNotEmpty()) {
val regions = MapRegions(ruleset)
runAndMeasure("generateRegions") {
regions.generateRegions(map, civilizations.count { ruleset.nations[it.civName]!!.isMajorCiv() })
}
runAndMeasure("assignRegions") {
regions.assignRegions(map, civilizations.filter { ruleset.nations[it.civName]!!.isMajorCiv() })
}
runAndMeasure("placeResourcesAndMinorCivs") {
regions.placeResourcesAndMinorCivs(map, civilizations.filter { ruleset.nations[it.civName]!!.isCityState() })
}
}
runAndMeasure("NaturalWonderGenerator") {
NaturalWonderGenerator(ruleset, randomness).spawnNaturalWonders(map)
@ -169,8 +175,10 @@ class MapGenerator(val ruleset: Ruleset) {
private fun spreadResources(tileMap: TileMap) {
val mapRadius = tileMap.mapParameters.mapSize.radius
for (tile in tileMap.values)
tile.resource = null
// Commenting this out for now not to interfere with start normalization - will be restored when
// region-based resource placement is implemented, then this function will be map editor only.
/*for (tile in tileMap.values)
tile.resource = null*/
spreadStrategicResources(tileMap, mapRadius)
spreadResources(tileMap, mapRadius, ResourceType.Luxury)

View File

@ -2,21 +2,24 @@ package com.unciv.logic.map.mapgenerator
import com.badlogic.gdx.math.Rectangle
import com.badlogic.gdx.math.Vector2
import com.unciv.Constants
import com.unciv.logic.HexMath
import com.unciv.logic.civilization.CivilizationInfo
import com.unciv.logic.map.MapShape
import com.unciv.logic.map.TileInfo
import com.unciv.logic.map.TileMap
import com.unciv.models.ruleset.Ruleset
import com.unciv.models.ruleset.tile.ResourceType
import com.unciv.models.ruleset.tile.Terrain
import com.unciv.models.ruleset.tile.TerrainType
import com.unciv.models.ruleset.tile.TileResource
import com.unciv.models.ruleset.unique.StateForConditionals
import com.unciv.models.ruleset.unique.UniqueType
import com.unciv.models.stats.Stat
import com.unciv.models.translations.equalsPlaceholderText
import com.unciv.models.translations.getPlaceholderParameters
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
import com.unciv.ui.utils.randomWeighted
import kotlin.math.*
class MapRegions (val ruleset: Ruleset){
companion object {
@ -37,7 +40,10 @@ class MapRegions (val ruleset: Ruleset){
}
private val regions = ArrayList<Region>()
private var usingArchipelagoRegions = false
private val tileData = HashMap<Vector2, MapGenTileData>()
private val cityStateLuxuries = ArrayList<String>()
private val randomLuxuries = ArrayList<String>()
/** Creates [numRegions] number of balanced regions for civ starting locations. */
fun generateRegions(tileMap: TileMap, numRegions: Int) {
@ -56,6 +62,7 @@ class MapRegions (val ruleset: Ruleset){
// Lots of small islands - just split ut the map in rectangles while ignoring Continents
// 25% is chosen as limit so Four Corners maps don't fall in this category
if (largestContinent / totalLand < 0.25f) {
usingArchipelagoRegions = true
// Make a huge rectangle covering the entire map
val hugeRect = Region(tileMap, mapRect, -1) // -1 meaning ignore continent data
hugeRect.affectedByWorldWrap = false // Might as well start at the seam
@ -178,12 +185,8 @@ class MapRegions (val ruleset: Ruleset){
if (civilizations.isEmpty()) return
// first assign region types
val regionTypes = ruleset.terrains.values.filter { it.hasUnique(UniqueType.RegionRequirePercentSingleType) ||
it.hasUnique(UniqueType.RegionRequirePercentTwoTypes) }
.sortedBy { if (it.hasUnique(UniqueType.RegionRequirePercentSingleType))
it.getMatchingUniques(UniqueType.RegionRequirePercentSingleType).first().params[2].toInt()
else
it.getMatchingUniques(UniqueType.RegionRequirePercentTwoTypes).first().params[3].toInt() }
val regionTypes = ruleset.terrains.values.filter { getRegionPriority(it) != null }
.sortedBy { getRegionPriority(it) }
for (region in regions) {
region.countTerrains()
@ -219,6 +222,10 @@ class MapRegions (val ruleset: Ruleset){
for (region in sortedRegions) {
findStart(region)
}
// Normalize starts
for (region in regions) {
normalizeStart(tileMap[region.startPosition!!], minorCiv = false)
}
val coastBiasCivs = civilizations.filter { ruleset.nations[it.civName]!!.startBias.contains("Coast") }
val negativeBiasCivs = civilizations.filter { ruleset.nations[it.civName]!!.startBias.any { bias -> bias.equalsPlaceholderText("Avoid []") } }
@ -228,35 +235,40 @@ class MapRegions (val ruleset: Ruleset){
val positiveBiasCivs = civilizations.filterNot { it in coastBiasCivs || it in negativeBiasCivs || it in randomCivs }
.sortedBy { ruleset.nations[it.civName]!!.startBias.count() } // civs with only one desired region go first
val positiveBiasFallbackCivs = ArrayList<CivilizationInfo>() // Civs who couln't get their desired region at first pass
val unpickedRegions = regions.toMutableList()
// First assign coast bias civs
for (civ in coastBiasCivs) {
// Try to find a coastal start, preferably a really coastal one
var startRegion = regions.filter { tileMap[it.startPosition!!].isCoastalTile() }
var startRegion = unpickedRegions.filter { tileMap[it.startPosition!!].isCoastalTile() }
.maxByOrNull { it.terrainCounts["Coastal"] ?: 0 }
if (startRegion != null) {
assignCivToRegion(civ, startRegion)
unpickedRegions.remove(startRegion)
continue
}
// Else adjacent to a lake
startRegion = regions.filter { tileMap[it.startPosition!!].neighbors.any { neighbor -> neighbor.getBaseTerrain().hasUnique(UniqueType.FreshWater) } }
startRegion = unpickedRegions.filter { tileMap[it.startPosition!!].neighbors.any { neighbor -> neighbor.getBaseTerrain().hasUnique(UniqueType.FreshWater) } }
.maxByOrNull { it.terrainCounts["Coastal"] ?: 0 }
if (startRegion != null) {
assignCivToRegion(civ, startRegion)
unpickedRegions.remove(startRegion)
continue
}
// Else adjacent to a river
startRegion = regions.filter { tileMap[it.startPosition!!].isAdjacentToRiver() }
startRegion = unpickedRegions.filter { tileMap[it.startPosition!!].isAdjacentToRiver() }
.maxByOrNull { it.terrainCounts["Coastal"] ?: 0 }
if (startRegion != null) {
assignCivToRegion(civ, startRegion)
unpickedRegions.remove(startRegion)
continue
}
// Else at least close to a river ????
startRegion = regions.filter { tileMap[it.startPosition!!].neighbors.any { neighbor -> neighbor.isAdjacentToRiver() } }
startRegion = unpickedRegions.filter { tileMap[it.startPosition!!].neighbors.any { neighbor -> neighbor.isAdjacentToRiver() } }
.maxByOrNull { it.terrainCounts["Coastal"] ?: 0 }
if (startRegion != null) {
assignCivToRegion(civ, startRegion)
unpickedRegions.remove(startRegion)
continue
}
// Else pick a random region at the end
@ -267,10 +279,11 @@ class MapRegions (val ruleset: Ruleset){
for (civ in positiveBiasCivs) {
// Try to find a start that matches any of the desired regions, ideally with lots of desired terrain
val preferred = ruleset.nations[civ.civName]!!.startBias
val startRegion = regions.filter { it.type in preferred }
val startRegion = unpickedRegions.filter { it.type in preferred }
.maxByOrNull { it.terrainCounts.filterKeys { terrain -> terrain in preferred }.values.sum() }
if (startRegion != null) {
assignCivToRegion(civ, startRegion)
unpickedRegions.remove(startRegion)
continue
} else if (ruleset.nations[civ.civName]!!.startBias.count() == 1) { // Civs with a single bias (only) get to look for a fallback region
positiveBiasFallbackCivs.add(civ)
@ -281,17 +294,20 @@ class MapRegions (val ruleset: Ruleset){
// Do a second pass for fallback civs, choosing the region most similar to the desired type
for (civ in positiveBiasFallbackCivs) {
assignCivToRegion(civ, getFallbackRegion(ruleset.nations[civ.civName]!!.startBias.first()))
val startRegion = getFallbackRegion(ruleset.nations[civ.civName]!!.startBias.first(), unpickedRegions)
assignCivToRegion(civ, startRegion)
unpickedRegions.remove(startRegion)
}
// Next do negative bias ones (ie "Avoid []")
for (civ in negativeBiasCivs) {
val avoided = ruleset.nations[civ.civName]!!.startBias.map { it.getPlaceholderParameters()[0] }
// Try to find a region not of the avoided types, secondary sort by least number of undesired terrains
val startRegion = regions.filterNot { it.type in avoided }
val startRegion = unpickedRegions.filterNot { it.type in avoided }
.minByOrNull { it.terrainCounts.filterKeys { terrain -> terrain in avoided }.values.sum() }
if (startRegion != null) {
assignCivToRegion(civ, startRegion)
unpickedRegions.remove(startRegion)
continue
} else
randomCivs.add(civ) // else pick a random region at the end
@ -299,14 +315,38 @@ class MapRegions (val ruleset: Ruleset){
// Finally assign the remaining civs randomly
for (civ in randomCivs) {
val startRegion = regions.random()
val startRegion = unpickedRegions.random()
assignCivToRegion(civ, startRegion)
unpickedRegions.remove(startRegion)
}
}
private fun getRegionPriority(terrain: Terrain?): Int? {
if (terrain == null) // ie "hybrid"
return 99999 // a big number
if (!terrain.hasUnique(UniqueType.RegionRequirePercentSingleType) &&
!terrain.hasUnique(UniqueType.RegionRequirePercentTwoTypes))
return null
else
return if (terrain.hasUnique(UniqueType.RegionRequirePercentSingleType))
terrain.getMatchingUniques(UniqueType.RegionRequirePercentSingleType).first().params[2].toInt()
else
terrain.getMatchingUniques(UniqueType.RegionRequirePercentTwoTypes).first().params[3].toInt()
}
private fun assignCivToRegion(civInfo: CivilizationInfo, region: Region) {
region.tileMap.addStartingLocation(civInfo.civName, region.tileMap[region.startPosition!!])
regions.remove(region) // This region can no longer be picked
val tile = region.tileMap[region.startPosition!!]
region.tileMap.addStartingLocation(civInfo.civName, tile)
// Place impacts to keep city states etc at appropriate distance
placeImpact(ImpactType.MinorCiv,tile, 6)
/* lets leave these commented until resource placement is actually implemented
placeImpact(ImpactType.Luxury, tile, 3)
placeImpact(ImpactType.Strategic,tile, 0)
placeImpact(ImpactType.Bonus, tile, 3)
placeImpact(ImpactType.Fish, tile, 3)
placeImpact(ImpactType.NaturalWonder, tile, 4)
*/
}
/** Attempts to find a good start close to the center of [region]. Calls setRegionStart with the position*/
@ -422,9 +462,214 @@ class MapRegions (val ruleset: Ruleset){
setRegionStart(region, panicPosition)
}
/** Attempts to improve the start on [startTile] as needed to make it decent.
* Relies on startPosition having been set previously.
* Assumes unchanged baseline values ie citizens eat 2 food each, similar production costs
* If [minorCiv] is true, different weightings will be used. */
private fun normalizeStart(startTile: TileInfo, minorCiv: Boolean) {
// Remove ice-like features adjacent to start
for (tile in startTile.neighbors) {
val lastTerrain = tile.getTerrainFeatures().lastOrNull { it.impassable }
if (lastTerrain != null) {
tile.terrainFeatures.remove(lastTerrain.name)
}
}
// evaluate production potential
val innerProduction = startTile.neighbors.sumOf { getPotentialYield(it, Stat.Production).toInt() }
val outerProduction = startTile.getTilesAtDistance(2).sumOf { getPotentialYield(it, Stat.Production).toInt() }
// for very early production we ideally want tiles that also give food
val earlyProduction = startTile.getTilesInDistanceRange(1..2).sumOf {
if (getPotentialYield(it, Stat.Food, unimproved = true) > 0f) getPotentialYield(it, Stat.Production, unimproved = true).toInt()
else 0 }
// If terrible, try adding a hill to a dry flat tile
if (innerProduction == 0 || (innerProduction < 2 && outerProduction < 8) || (minorCiv && innerProduction < 4)) {
val hillSpot = startTile.neighbors
.filter { it.isLand && it.terrainFeatures.isEmpty() && !it.isAdjacentToFreshwater }
.toList().randomOrNull()
val hillEquivalent = ruleset.terrains.values
.firstOrNull { it.type == TerrainType.TerrainFeature && it.production >= 2 && !it.hasUnique(UniqueType.RareFeature) }?.name
if (hillSpot != null && hillEquivalent != null) {
hillSpot.terrainFeatures.add(hillEquivalent)
}
}
// TODO: Strategic Balance Resources
// If bad early production, add a small strategic resource to SECOND ring (not for minors)
if (!minorCiv && innerProduction < 3 && earlyProduction < 6) {
val lastEraNumber = ruleset.eras.values.maxOf { it.eraNumber }
val earlyEras = ruleset.eras.filterValues { it.eraNumber <= lastEraNumber / 3 }
val validResources = ruleset.tileResources.values.filter {
it.resourceType == ResourceType.Strategic &&
(it.revealedBy == null ||
ruleset.technologies[it.revealedBy]!!.era() in earlyEras)
}
if (validResources.isNotEmpty()) {
for (tile in startTile.getTilesAtDistance(2).shuffled()) {
val resourceToPlace = validResources.filter { tile.getLastTerrain().name in it.terrainsCanBeFoundOn }.randomOrNull()
if (resourceToPlace != null) {
tile.setTileResource(resourceToPlace, majorDeposit = false)
break
}
}
}
}
// Now evaluate food situation
// Food²/4 because excess food is really good and lets us work other tiles or run specialists!
// 2F is worth 1, 3F is worth 2, 4F is worth 4, 5F is worth 6 and so on
val innerFood = startTile.neighbors.sumOf { (getPotentialYield(it, Stat.Food).pow(2) / 4).toInt() }
val outerFood = startTile.getTilesAtDistance(2).sumOf { (getPotentialYield(it, Stat.Food).pow(2) / 4).toInt() }
val totalFood = innerFood + outerFood
// we want at least some two-food tiles to keep growing
val innerNativeTwoFood = startTile.neighbors.count { getPotentialYield(it, Stat.Food, unimproved = true) >= 2f }
val outerNativeTwoFood = startTile.getTilesAtDistance(2).count { getPotentialYield(it, Stat.Food, unimproved = true) >= 2f }
val totalNativeTwoFood = innerNativeTwoFood + outerNativeTwoFood
// Determine number of needed bonuses. Different weightings for minor and major civs.
var bonusesNeeded = if (minorCiv) {
when { // From 2 to 0
totalFood < 12 || innerFood < 4 -> 2
totalFood < 16 || innerFood < 9 -> 1
else -> 0
}
} else {
when { // From 5 to 0
innerFood == 0 && totalFood < 4 -> 5
totalFood < 6 -> 4
totalFood < 8 ||
(totalFood < 12 && innerFood < 5) -> 3
(totalFood < 17 && innerFood < 9) ||
totalNativeTwoFood < 2 -> 2
(totalFood < 24 && innerFood < 11) ||
totalNativeTwoFood == 2 ||
innerNativeTwoFood == 0 ||
totalFood < 20 -> 1
else -> 0
}
}
// TODO: Legendary start? +2
// Attempt to place one grassland at a plains-only spot (nor for minors)
if (!minorCiv && bonusesNeeded < 3 && totalNativeTwoFood == 0) {
val twoFoodTerrain = ruleset.terrains.values.firstOrNull { it.type == TerrainType.Land && it.food >= 2 }?.name
val candidateInnerSpots = startTile.neighbors
.filter { it.isLand && !it.isImpassible() && it.terrainFeatures.isEmpty() && it.resource == null }
val candidateOuterSpots = startTile.getTilesAtDistance(2)
.filter { it.isLand && !it.isImpassible() && it.terrainFeatures.isEmpty() && it.resource == null }
val spot = candidateInnerSpots.shuffled().firstOrNull() ?: candidateOuterSpots.shuffled().firstOrNull()
if (twoFoodTerrain != null && spot != null) {
spot.baseTerrain = twoFoodTerrain
} else
bonusesNeeded = 3 // Irredeemable plains situation
}
val oasisEquivalent = ruleset.terrains.values.firstOrNull {
it.type == TerrainType.TerrainFeature &&
it.hasUnique(UniqueType.RareFeature) &&
it.food >= 2 &&
it.food + it.production + it.gold >= 3 &&
it.occursOn.any { base -> ruleset.terrains[base]!!.type == TerrainType.Land }
}
var canPlaceOasis = oasisEquivalent != null // One oasis per start is enough. Don't bother finding a place if there is no good oasis equivalent
var placedInFirst = 0 // Attempt to put first 2 in inner ring and next 3 in second ring
var placedInSecond = 0
val rangeForBonuses = if (minorCiv) 2 else 3
// Start with list of candidate plots sorted in ring order 1,2,3
val candidatePlots = startTile.getTilesInDistanceRange(1..rangeForBonuses)
.filter { it.resource == null && oasisEquivalent !in it.getTerrainFeatures() }
.shuffled().sortedBy { it.aerialDistanceTo(startTile) }.toMutableList()
// Place food bonuses (and oases) as able
while (bonusesNeeded > 0 && candidatePlots.isNotEmpty()) {
val plot = candidatePlots.first()
candidatePlots.remove(plot) // remove the plot as it has now been tried, whether successfully or not
val validBonuses = ruleset.tileResources.values.filter {
it.resourceType == ResourceType.Bonus &&
it.food >= 1 &&
plot.getLastTerrain().name in it.terrainsCanBeFoundOn
}
val goodPlotForOasis = canPlaceOasis && plot.getLastTerrain().name in oasisEquivalent!!.occursOn
if (validBonuses.isNotEmpty() || goodPlotForOasis) {
if (goodPlotForOasis) {
plot.terrainFeatures.add(oasisEquivalent!!.name)
canPlaceOasis = false
} else {
plot.setTileResource(validBonuses.random())
}
if (plot.aerialDistanceTo(startTile) == 1) {
placedInFirst++
if (placedInFirst == 2) // Resort the list in ring order 2,3,1
candidatePlots.sortBy { abs(it.aerialDistanceTo(startTile) * 10 - 22 ) }
} else if (plot.aerialDistanceTo(startTile) == 2) {
placedInSecond++
if (placedInSecond == 3) // Resort the list in ring order 3,1,2
candidatePlots.sortByDescending { abs(it.aerialDistanceTo(startTile) * 10 - 17) }
}
bonusesNeeded--
}
}
// Minor civs are done, go on with grassiness checks for major civs
if (minorCiv) return
// Check for very grass-heavy starts that might still need some stone to help with production
val grassTypePlots = startTile.getTilesInDistanceRange(1..2).filter {
it.isLand &&
getPotentialYield(it, Stat.Food, unimproved = true) >= 2f && // Food neutral natively
getPotentialYield(it, Stat.Production) == 0f // Production can't even be improved
}.toMutableList()
val plainsTypePlots = startTile.getTilesInDistanceRange(1..2).filter {
it.isLand &&
getPotentialYield(it, Stat.Food) >= 2f && // Something that can be improved to food neutral
getPotentialYield(it, Stat.Production, unimproved = true) >= 1f // Some production natively
}.toList()
var stoneNeeded = when {
grassTypePlots.count() >= 9 && plainsTypePlots.isEmpty() -> 2
grassTypePlots.count() >= 6 && plainsTypePlots.count() <= 4 -> 1
else -> 0
}
val stoneTypeBonuses = ruleset.tileResources.values.filter { it.resourceType == ResourceType.Bonus && it.production > 0 }
if(stoneTypeBonuses.isNotEmpty()) {
while (stoneNeeded > 0 && grassTypePlots.isNotEmpty()) {
val plot = grassTypePlots.random()
grassTypePlots.remove(plot)
if (plot.resource != null) continue
val bonusToPlace = stoneTypeBonuses.filter { plot.getLastTerrain().name in it.terrainsCanBeFoundOn }.randomOrNull()
if (bonusToPlace != null) {
plot.resource = bonusToPlace.name
stoneNeeded--
}
}
}
}
private fun getPotentialYield(tile: TileInfo, stat: Stat, unimproved: Boolean = false): Float {
val baseYield = tile.getTileStats(null)[stat]
if (unimproved) return baseYield
val bestImprovementYield = tile.tileMap.ruleset!!.tileImprovements.values
.filter { !it.hasUnique(UniqueType.GreatImprovement) &&
it.uniqueTo == null &&
tile.getLastTerrain().name in it.terrainsCanBeBuiltOn }
.maxOfOrNull { it[stat] }
return baseYield + (bestImprovementYield ?: 0f)
}
/** @returns the region most similar to a region of [type] */
private fun getFallbackRegion(type: String): Region {
return regions.maxByOrNull { it.terrainCounts[type] ?: 0 }!!
private fun getFallbackRegion(type: String, candidates: List<Region>): Region {
return candidates.maxByOrNull { it.terrainCounts[type] ?: 0 }!!
}
private fun setRegionStart(region: Region, position: Vector2) {
@ -528,9 +773,386 @@ class MapRegions (val ruleset: Ruleset){
localData.startScore = totalScore
}
fun placeResourcesAndMinorCivs(tileMap: TileMap, minorCivs: List<CivilizationInfo>) {
assignLuxuries()
placeMinorCivs(tileMap, minorCivs)
// TODO: place luxuries
// TODO: place strategic and bonus resources
}
/** Assigns a luxury to each region. No luxury can be assigned to too many regions.
* Some luxuries are earmarked for city states. The rest are randomly distributed or
* don't occur att all in the map */
private fun assignLuxuries() {
// If there are any weightings defined in json, assume they are complete. If there are none, use flat weightings instead
val fallbackWeightings = ruleset.tileResources.values.none {
it.resourceType == ResourceType.Luxury &&
(it.hasUnique(UniqueType.LuxuryWeighting) || it.hasUnique(UniqueType.LuxuryWeightingForCityStates)) }
val maxRegionsWithLuxury = if (regions.count() > 12) 3 else 2
val targetCityStateLuxuries = 3 // was probably intended to be "if (tileData.size > 5000) 4 else 3"
val disabledPercent = 100 - min(tileData.size.toFloat().pow(0.2f) * 16, 100f).toInt() // Approximately
val targetDisabledLuxuries = (ruleset.tileResources.values
.count { it.resourceType == ResourceType.Luxury } * disabledPercent) / 100
val amountRegionsWithLuxury = HashMap<String, Int>()
// Init map
ruleset.tileResources.values
.forEach { amountRegionsWithLuxury[it.name] = 0 }
for (region in regions.sortedBy { getRegionPriority(ruleset.terrains[it.type]) } ) {
var candidateLuxuries = ruleset.tileResources.values.filter {
it.resourceType == ResourceType.Luxury &&
amountRegionsWithLuxury[it.name]!! < maxRegionsWithLuxury &&
// Check that it has a weight for this region type
(fallbackWeightings ||
it.getMatchingUniques(UniqueType.LuxuryWeighting).any { unique -> unique.params[0] == region.type } ) &&
// Check that there is enough coast if it is a water based resource
((region.terrainCounts["Coastal"] ?: 0) >= 12 ||
it.terrainsCanBeFoundOn.any { terrain -> ruleset.terrains[terrain]!!.type != TerrainType.Water } )
}
// If we couldn't find any options, pick from all luxuries. First try to not pick water luxuries on land regions
if (candidateLuxuries.isEmpty()) {
candidateLuxuries = ruleset.tileResources.values.filter {
it.resourceType == ResourceType.Luxury &&
amountRegionsWithLuxury[it.name]!! < maxRegionsWithLuxury &&
// Ignore weightings for this pass
// Check that there is enough coast if it is a water based resource
((region.terrainCounts["Coastal"] ?: 0) >= 12 ||
it.terrainsCanBeFoundOn.any { terrain -> ruleset.terrains[terrain]!!.type != TerrainType.Water })
}
}
// If there are still no candidates, ignore water restrictions
if (candidateLuxuries.isEmpty()) {
candidateLuxuries = ruleset.tileResources.values.filter {
it.resourceType == ResourceType.Luxury &&
amountRegionsWithLuxury[it.name]!! < maxRegionsWithLuxury
// Ignore weightings and water for this pass
}
}
// If there are still no candidates (mad modders???) just skip this region
if (candidateLuxuries.isEmpty()) continue
// Pick a luxury at random. Weight is reduced if the luxury has been picked before
val modifiedWeights = candidateLuxuries.map {
val weightingUnique = it.getMatchingUniques(UniqueType.LuxuryWeighting)
.filter { unique -> unique.params[0] == region.type }.firstOrNull()
if (weightingUnique == null)
1f / (1f + amountRegionsWithLuxury[it.name]!!)
else
weightingUnique.params[1].toFloat() / (1f + amountRegionsWithLuxury[it.name]!!)
}
region.luxury = candidateLuxuries.randomWeighted(modifiedWeights).name
amountRegionsWithLuxury[region.luxury!!] = amountRegionsWithLuxury[region.luxury]!! + 1
}
// Assign luxuries to City States
for (i in 1..targetCityStateLuxuries) {
val candidateLuxuries = ruleset.tileResources.values.filter {
it.resourceType == ResourceType.Luxury &&
amountRegionsWithLuxury[it.name] == 0 &&
(fallbackWeightings || it.hasUnique(UniqueType.LuxuryWeightingForCityStates))
}
if (candidateLuxuries.isEmpty()) continue
val weights = candidateLuxuries.map {
val weightingUnique = it.getMatchingUniques(UniqueType.LuxuryWeightingForCityStates).firstOrNull()
if (weightingUnique == null)
1f
else
weightingUnique.params[0].toFloat()
}
val luxury = candidateLuxuries.randomWeighted(weights).name
cityStateLuxuries.add(luxury)
amountRegionsWithLuxury[luxury] = 1
}
// Assign some resources as random placement. Marble is never random.
val remainingLuxuries = ruleset.tileResources.values.filter {
it.resourceType == ResourceType.Luxury &&
amountRegionsWithLuxury[it.name] == 0 &&
!it.hasUnique(UniqueType.LuxurySpecialPlacement)
}.map { it.name }.shuffled()
randomLuxuries.addAll(remainingLuxuries.drop(targetDisabledLuxuries))
}
/** Assigns [civs] to regions or "uninhabited" land and places them. Depends on
* assignLuxuries having been called previously.
* Note: can silently fail to place all city states if there is too little room.
* Currently our GameStarter fills out with random city states, Civ V behavior is to
* forget about the discarded city states entirely. */
private fun placeMinorCivs(tileMap: TileMap, civs: List<CivilizationInfo>) {
if (civs.isEmpty()) return
// Some but not all city states are assigned to regions directly. Determine the CS density.
val minorCivRatio = civs.count().toFloat() / regions.count()
val minorCivPerRegion = when {
minorCivRatio > 14f -> 10 // lol
minorCivRatio > 11f -> 8
minorCivRatio > 8f -> 6
minorCivRatio > 5.7f -> 4
minorCivRatio > 4.35f -> 3
minorCivRatio > 2.7f -> 2
minorCivRatio > 1.35f -> 1
else -> 0
}
val unassignedCivs = civs.shuffled().toMutableList()
if (minorCivPerRegion > 0) {
regions.forEach {
val civsToAssign = unassignedCivs.take(minorCivPerRegion)
it.assignedMinorCivs.addAll(civsToAssign)
unassignedCivs.removeAll(civsToAssign)
}
}
// Some city states are assigned to "uninhabited" continents - unless it's an archipelago type map
// (Because then every continent will have been assigned to a region anyway)
val uninhabitedCoastal = ArrayList<TileInfo>()
val uninhabitedHinterland = ArrayList<TileInfo>()
val uninhabitedContinents = tileMap.continentSizes.filter {
it.value >= 4 && // Don't bother with tiny islands
regions.none { region -> region.continentID == it.key }
}.keys
val civAssignedToUninhabited = ArrayList<CivilizationInfo>()
var numUninhabitedTiles = 0
var numInhabitedTiles = 0
if (!usingArchipelagoRegions) {
// Go through the entire map to build the data
for (tile in tileMap.values) {
if (!canPlaceMinorCiv(tile)) continue
val continent = tile.getContinent()
if (continent in uninhabitedContinents) {
if(tile.isCoastalTile())
uninhabitedCoastal.add(tile)
else
uninhabitedHinterland.add(tile)
numUninhabitedTiles++
} else
numInhabitedTiles++
}
// Determine how many minor civs to put on uninhabited continents.
val maxByUninhabited = (3 * civs.count() * numUninhabitedTiles) / (numInhabitedTiles + numUninhabitedTiles)
val maxByRatio = (civs.count() + 1) / 2
val targetForUninhabited = min(maxByRatio, maxByUninhabited)
val civsToAssign = unassignedCivs.take(targetForUninhabited)
unassignedCivs.removeAll(civsToAssign)
civAssignedToUninhabited.addAll(civsToAssign)
}
// If there are still unassigned minor civs, assign extra ones to regions that share their
// luxury type with two others, as compensation. Because starting close to a city state is good??
if (unassignedCivs.isNotEmpty()) {
val regionsWithCommonLuxuries = regions.filter {
regions.count { other -> other.luxury == it.luxury } >= 3
}
// assign one civ each to regions with common luxuries if there are enough to go around
if (regionsWithCommonLuxuries.count() > 0 &&
regionsWithCommonLuxuries.count() <= unassignedCivs.count()) {
regionsWithCommonLuxuries.forEach {
val civToAssign = unassignedCivs.first()
unassignedCivs.remove(civToAssign)
it.assignedMinorCivs.add(civToAssign)
}
}
}
// Still unassigned civs??
if (unassignedCivs.isNotEmpty()) {
// Add one extra to each region as long as there are enough to go around
while (unassignedCivs.count() >= regions.count()) {
regions.forEach {
val civToAssign = unassignedCivs.first()
unassignedCivs.remove(civToAssign)
it.assignedMinorCivs.add(civToAssign)
}
}
// STILL unassigned civs??
if (unassignedCivs.isNotEmpty()) {
// At this point there is at least for sure less remaining city states than regions
// Sort regions by fertility and put extra city states in the worst ones.
val worstRegions = regions.sortedBy { it.totalFertility }.take(unassignedCivs.count())
worstRegions.forEach {
val civToAssign = unassignedCivs.first()
unassignedCivs.remove(civToAssign)
it.assignedMinorCivs.add(civToAssign)
}
}
}
// All minor civs are assigned - now place them
// First place the "uninhabited continent" ones, preferring coastal starts
tryPlaceMinorCivsInTiles(civAssignedToUninhabited, tileMap, uninhabitedCoastal)
tryPlaceMinorCivsInTiles(civAssignedToUninhabited, tileMap, uninhabitedHinterland)
// Fallback to a random region for civs that couldn't be placed in the wilderness
for (unplacedCiv in civAssignedToUninhabited) {
regions.random().assignedMinorCivs.add(unplacedCiv)
}
// Fallback lists for minor civs that can't be placed with any other method
val fallbackTiles = ArrayList<TileInfo>()
val fallbackMinors = ArrayList<CivilizationInfo>()
// Now place the ones assigned to specific regions.
for (region in regions) {
// Check the outer edges of the region, working inwards
val section = Rectangle(region.rect)
val unprocessedTiles = ArrayList<TileInfo>()
val regionCoastal = ArrayList<TileInfo>()
val regionHinterland = ArrayList<TileInfo>()
while (section.width >= 4 && section.height >= 4 && region.assignedMinorCivs.isNotEmpty()) {
// Clear the tile lists
unprocessedTiles.clear()
regionCoastal.clear()
regionHinterland.clear()
if (section.height > section.width) {
// Check top and bottom
unprocessedTiles.addAll(
tileMap.getTilesInRectangle(
Rectangle(section.x, section.y, section.width, 1f),
evenQ = true)
)
unprocessedTiles.addAll(
tileMap.getTilesInRectangle(
Rectangle(section.x, section.y + section.height - 1, section.width, 1f),
evenQ = true)
)
// Narrow the remaining section
section.y += 1
section.height -= 2
} else {
// Check left and right
unprocessedTiles.addAll(
tileMap.getTilesInRectangle(
Rectangle(section.x, section.y, 1f, section.height),
evenQ = true)
)
unprocessedTiles.addAll(
tileMap.getTilesInRectangle(
Rectangle(section.x + section.width - 1, section.y, 1f, section.height),
evenQ = true)
)
// Narrow the remaining section
section.x += 1
section.width -= 2
}
// Now process the tiles
for (tile in unprocessedTiles) {
if (!canPlaceMinorCiv(tile)) continue
if (!usingArchipelagoRegions && tile.getContinent() != region.continentID) continue
if(tile.isCoastalTile())
regionCoastal.add(tile)
else
regionHinterland.add(tile)
}
// Now attempt to place as many minor civs as possible, trying coastal tiles first
tryPlaceMinorCivsInTiles(region.assignedMinorCivs, tileMap, regionCoastal)
tryPlaceMinorCivsInTiles(region.assignedMinorCivs, tileMap, regionHinterland)
}
// In case we went through the entire region without finding spots for all assigned civs
if(region.assignedMinorCivs.isNotEmpty()) {
fallbackMinors.addAll(region.assignedMinorCivs)
} else {
// If we did find spots for all civs, there might be more eligible tiles left in the region
// Add them to the fallback list
fallbackTiles.addAll(regionCoastal)
fallbackTiles.addAll(regionHinterland)
fallbackTiles.addAll(tileMap.getTilesInRectangle(section, evenQ = true)
.filter { canPlaceMinorCiv(it) }
)
}
}
// Finally attempt to place the fallback lists - the rest will be silently discarded
if (fallbackMinors.isNotEmpty()) {
// Throw in the uninhabited lists as well
fallbackTiles.addAll(uninhabitedCoastal)
fallbackTiles.addAll(uninhabitedHinterland)
tryPlaceMinorCivsInTiles(fallbackMinors, tileMap, fallbackTiles)
}
}
/** Attempts to randomly place civs from [civsToPlace] in tiles from [tileList]. Assumes that
* [tileList] is pre-vetted and only contains habitable land tiles.
* Will modify both [civsToPlace] and [tileList] as it goes! */
private fun tryPlaceMinorCivsInTiles(civsToPlace: MutableList<CivilizationInfo>, tileMap: TileMap, tileList: MutableList<TileInfo>) {
while (tileList.isNotEmpty() && civsToPlace.isNotEmpty()) {
val chosenTile = tileList.random()
tileList.remove(chosenTile)
val data = tileData[chosenTile.position]!!
// If the randomly chosen tile is too close to a player or a city state, discard it
if (data.impacts.containsKey(ImpactType.MinorCiv))
continue
// Otherwise, go ahead and place the minor civ
val civToAdd = civsToPlace.first()
civsToPlace.remove(civToAdd)
placeMinorCiv(civToAdd, tileMap, chosenTile)
}
}
private fun canPlaceMinorCiv(tile: TileInfo) = !tile.isWater && !tile.isImpassible() &&
!tileData[tile.position]!!.isJunk &&
tile.getBaseTerrain().getMatchingUniques(UniqueType.HasQuality).none { it.params[0] == "Undesirable" } && // So we don't get snow hills
tile.neighbors.count() == 6 // Avoid map edges
private fun placeMinorCiv(civ: CivilizationInfo, tileMap: TileMap, tile: TileInfo) {
tileMap.addStartingLocation(civ.civName, tile)
placeImpact(ImpactType.MinorCiv,tile, 4)
/* lets leave these commented until resource placement is actually implemented
placeImpact(ImpactType.Luxury, tile, 3)
placeImpact(ImpactType.Strategic,tile, 0)
placeImpact(ImpactType.Bonus, tile, 3)
placeImpact(ImpactType.Fish, tile, 3)
placeImpact(ImpactType.Marble, tile, 4) */
normalizeStart(tile, minorCiv = true)
}
/** Adds numbers to tileData in a similar way to closeStartPenalty, but for different types */
private fun placeImpact(type: ImpactType, tile: TileInfo, radius: Int) {
// Epicenter
if (type == ImpactType.Fish || type == ImpactType.Marble)
tileData[tile.position]!!.impacts[type] = 1 // These use different values
else
tileData[tile.position]!!.impacts[type] = 99
if (radius <= 0) return
for (ring in 1..radius) {
val ringValue = radius - ring + 1
for (outerTile in tile.getTilesAtDistance(ring)) {
val data = tileData[outerTile.position]!!
when (type) {
ImpactType.Marble,
ImpactType.MinorCiv -> data.impacts[type] = 1
ImpactType.Fish -> {
if (data.impacts.containsKey(type))
data.impacts[type] = min(10, max(ringValue, data.impacts[type]!!) + 1)
else
data.impacts[type] = ringValue
}
else -> {
if (data.impacts.containsKey(type))
data.impacts[type] = min(50, max(ringValue, data.impacts[type]!!) + 2)
else
data.impacts[type] = ringValue
}
}
}
}
}
enum class ImpactType {
Strategic,
Luxury,
Bonus,
Fish,
MinorCiv,
NaturalWonder,
Marble,
}
// Holds a bunch of tile info that is only interesting during map gen
class MapGenTileData(val tile: TileInfo, val region: Region?) {
var closeStartPenalty = 0
val impacts = HashMap<ImpactType, Int>()
var isFood = false
var isProd = false
var isGood = false
@ -606,7 +1228,9 @@ class Region (val tileMap: TileMap, val rect: Rectangle, val continentID: Int =
val terrainCounts = HashMap<String, Int>()
var totalFertility = 0
var type = "Hybrid" // being an undefined or indeterminate type
var luxury: String? = null
var startPosition: Vector2? = null
val assignedMinorCivs = ArrayList<CivilizationInfo>()
var affectedByWorldWrap = false

View File

@ -320,6 +320,7 @@ enum class UniqueType(val text:String, vararg targets: UniqueTarget, val flags:
LuxuryWeighting("Appears in [regionType] regions with weight [amount]", UniqueTarget.Resource, flags = listOf(UniqueFlag.HideInCivilopedia)),
LuxuryWeightingForCityStates("Appears near City States with weight [amount]", UniqueTarget.Resource, flags = listOf(UniqueFlag.HideInCivilopedia)),
LuxurySpecialPlacement("Special placement during map generation", UniqueTarget.Resource, flags = listOf(UniqueFlag.HideInCivilopedia)),
OverrideDepositAmountOnTileFilter("Deposits in [tileFilter] tiles always provide [amount] resources", UniqueTarget.Resource),