Regions part 1 - subdivide generated maps into regions, and use to place civs (#5556)

* json definitions

* create regions, define region types

* count terrains

* terrain qualities

* tilesInRectangle

* use even q coords

* major civ start locations

* move to separate file

* remove printlns

* unused imports

* strings

* strings

* reviews

* conditionalize qualities

* guess qualities of terrain types without explicit definitions

* guess qualities of terrain types without explicit definitions

* Update template.properties

* Update template.properties

* add HideInCivilopedia to technical uniques

* reviews
This commit is contained in:
SimonCeder
2021-11-05 06:59:48 +01:00
committed by GitHub
parent 6e6192c369
commit 09c40002f0
13 changed files with 1013 additions and 48 deletions

View File

@ -41,8 +41,12 @@ object GameStarter {
tileMap = MapSaver.loadMap(gameSetupInfo.mapFile!!)
// Don't override the map parameters - this can include if we world wrap or not!
} else runAndMeasure("generateMap") {
tileMap = mapGen.generateMap(gameSetupInfo.mapParameters)
// The mapgen needs to know what civs are in the game to generate regions, starts and resources
addCivilizations(gameSetupInfo.gameParameters, gameInfo, ruleset, existingMap = false)
tileMap = mapGen.generateMap(gameSetupInfo.mapParameters, gameInfo.civilizations)
tileMap.mapParameters = gameSetupInfo.mapParameters
// Now forget them for a moment! MapGen can silently fail to place some city states, so then we'll use the old fallback method to place those.
gameInfo.civilizations.clear()
}
runAndMeasure("addCivilizations") {
@ -52,7 +56,8 @@ object GameStarter {
addCivilizations(
gameSetupInfo.gameParameters,
gameInfo,
ruleset
ruleset,
existingMap = true
) // this is before gameInfo.setTransients, so gameInfo doesn't yet have the gameBasics
}
@ -169,7 +174,7 @@ object GameStarter {
}
}
private fun addCivilizations(newGameParameters: GameParameters, gameInfo: GameInfo, ruleset: Ruleset) {
private fun addCivilizations(newGameParameters: GameParameters, gameInfo: GameInfo, ruleset: Ruleset, existingMap: Boolean) {
val availableCivNames = Stack<String>()
// CityState or Spectator civs are not available for Random pick
availableCivNames.addAll(ruleset.nations.filter { it.value.isMajorCiv() }.keys.shuffled())
@ -183,9 +188,16 @@ object GameStarter {
gameInfo.civilizations.add(barbarianCivilization)
}
val civNamesWithStartingLocations = if(existingMap) gameInfo.tileMap.startingLocationsByNation.keys
else emptySet()
val presetMajors = Stack<String>()
presetMajors.addAll(availableCivNames.filter { it in civNamesWithStartingLocations })
for (player in newGameParameters.players.sortedBy { it.chosenCiv == "Random" }) {
val nationName = if (player.chosenCiv != "Random") player.chosenCiv
else if (presetMajors.isNotEmpty()) presetMajors.pop()
else availableCivNames.pop()
availableCivNames.remove(nationName) // In case we got it from a map preset
val playerCiv = CivilizationInfo(nationName)
for (tech in startingTechs)
@ -195,8 +207,6 @@ object GameStarter {
gameInfo.civilizations.add(playerCiv)
}
val civNamesWithStartingLocations = gameInfo.tileMap.startingLocationsByNation.keys
val availableCityStatesNames = Stack<String>()
// since we shuffle and then order by, we end up with all the City-States with starting tiles first in a random order,
// and then all the other City-States in a random order! Because the sortedBy function is stable!

View File

@ -117,6 +117,13 @@ object HexMath {
cubic2HexCoords(evenQ2CubicCoords(evenQCoord))
}
fun hex2EvenQCoords(hexCoord: Vector2): Vector2 {
return if (hexCoord == Vector2.Zero)
Vector2.Zero
else
cubic2EvenQCoords(hex2CubicCoords(hexCoord))
}
fun roundCubicCoords(cubicCoords: Vector3): Vector3 {
var rx = round(cubicCoords.x)
var ry = round(cubicCoords.y)

View File

@ -354,6 +354,23 @@ open class TileInfo {
return stats.food + stats.production + stats.gold
}
// For dividing the map into Regions to determine start locations
fun getTileFertility(checkCoasts: Boolean): Int {
val terrains = getAllTerrains()
var fertility = 0
for (terrain in terrains) {
if (terrain.hasUnique(UniqueType.OverrideFertility))
return terrain.getMatchingUniques(UniqueType.OverrideFertility).first().params[0].toInt()
else
fertility += terrain.getMatchingUniques(UniqueType.AddFertility)
.sumBy { it.params[0].toInt() }
}
if (isAdjacentToRiver()) fertility += 1
if (isAdjacentToFreshwater) fertility += 1 // meaning total +2 for river
if (checkCoasts && isCoastalTile()) fertility += 2
return fertility
}
fun getImprovementStats(improvement: TileImprovement, observingCiv: CivilizationInfo, city: CityInfo?): Stats {
val stats = improvement.cloneStats()
if (hasViewableResource(observingCiv) && tileResource.improvement == improvement.name)

View File

@ -1,5 +1,6 @@
package com.unciv.logic.map
import com.badlogic.gdx.math.Rectangle
import com.badlogic.gdx.math.Vector2
import com.unciv.Constants
import com.unciv.logic.GameInfo
@ -198,6 +199,27 @@ class TileMap {
}
}.filterNotNull()
/** @return all tiles within [rectangle], respecting world edges and wrap.
* If using even Q coordinates the rectangle will be "straight" ie parallel with rectangular map edges. */
fun getTilesInRectangle(rectangle: Rectangle, evenQ: Boolean = false): Sequence<TileInfo> =
if (rectangle.width <= 0 || rectangle.height <= 0)
sequenceOf(get(rectangle.x.toInt(), rectangle.y.toInt()))
else
sequence {
for (x in 0 until rectangle.width.toInt()) {
for (y in 0 until rectangle.height.toInt()) {
val currentX = rectangle.x + x
val currentY = rectangle.y + y
if (evenQ) {
val hexCoords = HexMath.evenQ2HexCoords(Vector2(currentX, currentY))
yield(getIfTileExistsOrNull(hexCoords.x.toInt(), hexCoords.y.toInt()))
}
else
yield(getIfTileExistsOrNull(currentX.toInt(), currentY.toInt()))
}
}
}.filterNotNull()
/** @return tile at hex coordinates ([x],[y]) or null if they are outside the map. Respects map edges and world wrap. */
fun getIfTileExistsOrNull(x: Int, y: Int): TileInfo? {
if (contains(x, y))

View File

@ -3,17 +3,15 @@ package com.unciv.logic.map.mapgenerator
import com.unciv.Constants
import com.unciv.UncivGame
import com.unciv.logic.HexMath
import com.unciv.logic.civilization.CivilizationInfo
import com.unciv.logic.map.*
import com.unciv.models.Counter
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 kotlin.math.*
import com.unciv.models.ruleset.unique.UniqueType
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.pow
import kotlin.math.sign
import kotlin.random.Random
@ -26,7 +24,7 @@ class MapGenerator(val ruleset: Ruleset) {
private var randomness = MapGenerationRandomness()
fun generateMap(mapParameters: MapParameters): TileMap {
fun generateMap(mapParameters: MapParameters, civilizations: List<CivilizationInfo> = emptyList()): TileMap {
val mapSize = mapParameters.mapSize
val mapType = mapParameters.type
@ -77,12 +75,19 @@ class MapGenerator(val ruleset: Ruleset) {
runAndMeasure("assignContinents") {
map.assignContinents(TileMap.AssignContinentsMode.Assign)
}
runAndMeasure("NaturalWonderGenerator") {
NaturalWonderGenerator(ruleset, randomness).spawnNaturalWonders(map)
}
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() })
}
runAndMeasure("NaturalWonderGenerator") {
NaturalWonderGenerator(ruleset, randomness).spawnNaturalWonders(map)
}
runAndMeasure("spreadResources") {
spreadResources(map)
}

View File

@ -0,0 +1,679 @@
package com.unciv.logic.map.mapgenerator
import com.badlogic.gdx.math.Rectangle
import com.badlogic.gdx.math.Vector2
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.TerrainType
import com.unciv.models.ruleset.unique.StateForConditionals
import com.unciv.models.ruleset.unique.UniqueType
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
class MapRegions (val ruleset: Ruleset){
companion object {
val minimumFoodForRing = mapOf(1 to 1, 2 to 4, 3 to 4)
val minimumProdForRing = mapOf(1 to 0, 2 to 0, 3 to 2)
val minimumGoodForRing = mapOf(1 to 3, 2 to 6, 3 to 8)
const val maximumJunk = 9
val firstRingFoodScores = listOf(0, 8, 14, 19, 22, 24, 25)
val firstRingProdScores = listOf(0, 10, 16, 20, 20, 12, 0)
val secondRingFoodScores = listOf(0, 2, 5, 10, 20, 25, 28, 30, 32, 34, 35)
val secondRingProdScores = listOf(0, 10, 20, 25, 30, 35)
val closeStartPenaltyForRing =
mapOf( 0 to 99, 1 to 97, 2 to 95,
3 to 92, 4 to 89, 5 to 69,
6 to 57, 7 to 24, 8 to 15 )
}
private val regions = ArrayList<Region>()
private val tileData = HashMap<Vector2, MapGenTileData>()
/** Creates [numRegions] number of balanced regions for civ starting locations. */
fun generateRegions(tileMap: TileMap, numRegions: Int) {
if (numRegions <= 0) return // Don't bother about regions, probably map editor
if (tileMap.continentSizes.isEmpty()) throw Exception("No Continents on this map!")
val totalLand = tileMap.continentSizes.values.sum().toFloat()
val largestContinent = tileMap.continentSizes.values.maxOf { it }.toFloat()
val radius = if (tileMap.mapParameters.shape == MapShape.hexagonal)
tileMap.mapParameters.mapSize.radius.toFloat()
else
(max(tileMap.mapParameters.mapSize.width / 2, tileMap.mapParameters.mapSize.height / 2)).toFloat()
// A huge box including the entire map.
val mapRect = Rectangle(-radius, -radius, radius * 2 + 1, radius * 2 + 1)
// 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) {
// 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
hugeRect.updateTiles()
divideRegion(hugeRect, numRegions)
return
}
// Continents type - distribute civs according to total fertility, then split as needed
val continents = tileMap.continentSizes.keys.toMutableList()
val civsAddedToContinent = HashMap<Int, Int>() // Continent ID, civs added
val continentFertility = HashMap<Int, Int>() // Continent ID, total fertility
// Keep track of the even-q columns each continent is at, to figure out if they wrap
val continentIsAtCol = HashMap<Int, HashSet<Int>>()
// Calculate continent fertilities and columns
for (tile in tileMap.values) {
val continent = tile.getContinent()
if (continent != -1) {
continentFertility[continent] = tile.getTileFertility(true) +
(continentFertility[continent] ?: 0)
if (continentIsAtCol[continent] == null)
continentIsAtCol[continent] = HashSet()
continentIsAtCol[continent]!!.add(HexMath.hex2EvenQCoords(tile.position).x.toInt())
}
}
// Assign regions to the best continents, giving half value for region #2 etc
for (regionToAssign in 1..numRegions) {
val bestContinent = continents
.maxByOrNull { continentFertility[it]!! / (1 + (civsAddedToContinent[it] ?: 0)) }!!
civsAddedToContinent[bestContinent] = (civsAddedToContinent[bestContinent] ?: 0) + 1
}
// Split up the continents
for (continent in civsAddedToContinent.keys) {
val continentRegion = Region(tileMap, Rectangle(mapRect), continent)
val cols = continentIsAtCol[continent]!!
// Set origin at the rightmost column which does not have a neighbor on the left
continentRegion.rect.x = cols.filter { !cols.contains(it - 1) }.maxOf { it }.toFloat()
continentRegion.rect.width = cols.count().toFloat()
if (tileMap.mapParameters.worldWrap) {
// Check if the continent is wrapping - if the leftmost col is not the one we set origin by
if (cols.minOf { it } < continentRegion.rect.x)
continentRegion.affectedByWorldWrap = true
}
continentRegion.updateTiles()
divideRegion(continentRegion, civsAddedToContinent[continent]!!)
}
}
/** Recursive function, divides a region into [numDivisions] pars of equal-ish fertility */
private fun divideRegion(region: Region, numDivisions: Int) {
if (numDivisions <= 1) {
// We're all set, save the region and return
regions.add(region)
return
}
val firstDivisions = numDivisions / 2 // Since int division rounds down, works for all numbers
val splitRegions = splitRegion(region, (100 * firstDivisions) / numDivisions)
divideRegion(splitRegions.first, firstDivisions)
divideRegion(splitRegions.second, numDivisions - firstDivisions)
}
/** Splits a region in 2, with the first having [firstPercent] of total fertility */
private fun splitRegion(regionToSplit: Region, firstPercent: Int): Pair<Region, Region> {
val targetFertility = (regionToSplit.totalFertility * firstPercent) / 100
val splitOffRegion = Region(regionToSplit.tileMap, Rectangle(regionToSplit.rect), regionToSplit.continentID)
val widerThanTall = regionToSplit.rect.width > regionToSplit.rect.height
var bestSplitPoint = 1 // will be the size of the split-off region
var closestFertility = 0
var cumulativeFertility = 0
val pointsToTry = if (widerThanTall) 1..regionToSplit.rect.width.toInt()
else 1..regionToSplit.rect.height.toInt()
for (splitPoint in pointsToTry) {
val nextRect = if (widerThanTall)
splitOffRegion.tileMap.getTilesInRectangle(Rectangle(
splitOffRegion.rect.x + splitPoint - 1, splitOffRegion.rect.y,
1f, splitOffRegion.rect.height),
evenQ = true)
else
splitOffRegion.tileMap.getTilesInRectangle(Rectangle(
splitOffRegion.rect.x, splitOffRegion.rect.y + splitPoint - 1,
splitOffRegion.rect.width, 1f),
evenQ = true)
cumulativeFertility += if (splitOffRegion.continentID == -1)
nextRect.sumOf { it.getTileFertility(false) }
else
nextRect.sumOf { if (it.getContinent() == splitOffRegion.continentID) it.getTileFertility(true) else 0 }
// Better than last try?
if (abs(cumulativeFertility - targetFertility) <= abs(closestFertility - targetFertility)) {
bestSplitPoint = splitPoint
closestFertility = cumulativeFertility
}
}
if (widerThanTall) {
splitOffRegion.rect.width = bestSplitPoint.toFloat()
regionToSplit.rect.x = splitOffRegion.rect.x + splitOffRegion.rect.width
regionToSplit.rect.width = regionToSplit.rect.width- bestSplitPoint
} else {
splitOffRegion.rect.height = bestSplitPoint.toFloat()
regionToSplit.rect.y = splitOffRegion.rect.y + splitOffRegion.rect.height
regionToSplit.rect.height = regionToSplit.rect.height - bestSplitPoint
}
splitOffRegion.updateTiles()
regionToSplit.updateTiles()
return Pair(splitOffRegion, regionToSplit)
}
fun assignRegions(tileMap: TileMap, civilizations: List<CivilizationInfo>) {
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() }
for (region in regions) {
region.countTerrains()
for (type in regionTypes) {
// Test exclusion criteria first
if (type.getMatchingUniques(UniqueType.RegionRequireFirstLessThanSecond).any {
region.getTerrainAmount(it.params[0]) >= region.getTerrainAmount(it.params[1]) } ) {
continue
}
// Test inclusion criteria
if (type.getMatchingUniques(UniqueType.RegionRequirePercentSingleType).any {
region.getTerrainAmount(it.params[1]) >= (it.params[0].toInt() * region.tiles.count()) / 100 }
|| type.getMatchingUniques(UniqueType.RegionRequirePercentTwoTypes).any {
region.getTerrainAmount(it.params[1]) + region.getTerrainAmount(it.params[2]) >= (it.params[0].toInt() * region.tiles.count()) / 100 }
) {
region.type = type.name
break
}
}
}
// Generate tile data for all tiles
for (tile in tileMap.values) {
val newData = MapGenTileData(tile, regions.firstOrNull { it.tiles.contains(tile) })
newData.evaluate(ruleset)
tileData[tile.position] = newData
}
// Sort regions by fertility so the worse regions get to pick first
val sortedRegions = regions.sortedBy { it.totalFertility }
// Find a start for each region
for (region in sortedRegions) {
findStart(region)
}
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 []") } }
.sortedByDescending { ruleset.nations[it.civName]!!.startBias.count() } // Civs with more complex avoids go first
val randomCivs = civilizations.filter { ruleset.nations[it.civName]!!.startBias.isEmpty() }.toMutableList() // We might fill this up as we go
// The rest are positive bias
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
// 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() }
.maxByOrNull { it.terrainCounts["Coastal"] ?: 0 }
if (startRegion != null) {
assignCivToRegion(civ, startRegion)
continue
}
// Else adjacent to a lake
startRegion = regions.filter { tileMap[it.startPosition!!].neighbors.any { neighbor -> neighbor.getBaseTerrain().hasUnique(UniqueType.FreshWater) } }
.maxByOrNull { it.terrainCounts["Coastal"] ?: 0 }
if (startRegion != null) {
assignCivToRegion(civ, startRegion)
continue
}
// Else adjacent to a river
startRegion = regions.filter { tileMap[it.startPosition!!].isAdjacentToRiver() }
.maxByOrNull { it.terrainCounts["Coastal"] ?: 0 }
if (startRegion != null) {
assignCivToRegion(civ, startRegion)
continue
}
// Else at least close to a river ????
startRegion = regions.filter { tileMap[it.startPosition!!].neighbors.any { neighbor -> neighbor.isAdjacentToRiver() } }
.maxByOrNull { it.terrainCounts["Coastal"] ?: 0 }
if (startRegion != null) {
assignCivToRegion(civ, startRegion)
continue
}
// Else pick a random region at the end
randomCivs.add(civ)
}
// Next do positive bias civs
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 }
.maxByOrNull { it.terrainCounts.filterKeys { terrain -> terrain in preferred }.values.sum() }
if (startRegion != null) {
assignCivToRegion(civ, 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)
} else { // Others get random starts
randomCivs.add(civ)
}
}
// 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()))
}
// 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 }
.minByOrNull { it.terrainCounts.filterKeys { terrain -> terrain in avoided }.values.sum() }
if (startRegion != null) {
assignCivToRegion(civ, startRegion)
continue
} else
randomCivs.add(civ) // else pick a random region at the end
}
// Finally assign the remaining civs randomly
for (civ in randomCivs) {
val startRegion = regions.random()
assignCivToRegion(civ, startRegion)
}
}
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
}
/** Attempts to find a good start close to the center of [region]. Calls setRegionStart with the position*/
private fun findStart(region: Region) {
// Establish center bias rects
val centerRect = getCentralRectangle(region.rect, 0.33f)
val middleRect = getCentralRectangle(region.rect, 0.67f)
// Priority: 1. Adjacent to river, 2. Adjacent to coast or fresh water, 3. Other.
// First check center rect, then middle. Only check the outer area if no good sites found
val riverTiles = HashSet<Vector2>()
val wetTiles = HashSet<Vector2>()
val dryTiles = HashSet<Vector2>()
val fallbackTiles = HashSet<Vector2>()
// First check center
val centerTiles = region.tileMap.getTilesInRectangle(centerRect, evenQ = true)
for (tile in centerTiles) {
if (tileData[tile.position]!!.isTwoFromCoast)
continue // Don't even consider tiles two from coast
if (region.continentID != -1 && region.continentID != tile.getContinent())
continue // Wrong continent
if (tile.isLand && !tile.isImpassible()) {
evaluateTileForStart(tile)
if (tile.isAdjacentToRiver())
riverTiles.add(tile.position)
else if (tile.isCoastalTile() || tile.isAdjacentToFreshwater)
wetTiles.add(tile.position)
else
dryTiles.add(tile.position)
}
}
// Did we find a good start position?
for (list in sequenceOf(riverTiles, wetTiles, dryTiles)) {
if (list.any { tileData[it]!!.isGoodStart }) {
setRegionStart(region, list
.filter { tileData[it]!!.isGoodStart }.maxByOrNull { tileData[it]!!.startScore }!!)
return
}
if (list.isNotEmpty()) // Save the best not-good-enough spots for later fallback
fallbackTiles.add(list.maxByOrNull { tileData[it]!!.startScore }!!)
}
// Now check middle donut
val middleDonut = region.tileMap.getTilesInRectangle(middleRect, evenQ = true).filterNot { it in centerTiles }
riverTiles.clear()
wetTiles.clear()
dryTiles.clear()
for (tile in middleDonut) {
if (tileData[tile.position]!!.isTwoFromCoast)
continue // Don't even consider tiles two from coast
if (region.continentID != -1 && region.continentID != tile.getContinent())
continue // Wrong continent
if (tile.isLand && !tile.isImpassible()) {
evaluateTileForStart(tile)
if (tile.isAdjacentToRiver())
riverTiles.add(tile.position)
else if (tile.isCoastalTile() || tile.isAdjacentToFreshwater)
wetTiles.add(tile.position)
else
dryTiles.add(tile.position)
}
}
// Did we find a good start position?
for (list in sequenceOf(riverTiles, wetTiles, dryTiles)) {
if (list.any { tileData[it]!!.isGoodStart }) {
setRegionStart(region, list
.filter { tileData[it]!!.isGoodStart }.maxByOrNull { tileData[it]!!.startScore }!!)
return
}
if (list.isNotEmpty()) // Save the best not-good-enough spots for later fallback
fallbackTiles.add(list.maxByOrNull { tileData[it]!!.startScore }!!)
}
// Now check the outer tiles. For these we don't care about rivers, coasts etc
val outerDonut = region.tileMap.getTilesInRectangle(region.rect, evenQ = true).filterNot { it in centerTiles || it in middleDonut}
dryTiles.clear()
for (tile in outerDonut) {
if (region.continentID != -1 && region.continentID != tile.getContinent())
continue // Wrong continent
if (tile.isLand && !tile.isImpassible()) {
evaluateTileForStart(tile)
dryTiles.add(tile.position)
}
}
// Were any of them good?
if (dryTiles.any { tileData[it]!!.isGoodStart }) {
// Find the one closest to the center
val center = region.rect.getCenter(Vector2())
setRegionStart(region,
dryTiles.filter { tileData[it]!!.isGoodStart }.minByOrNull {
(region.tileMap.getIfTileExistsOrNull(center.x.roundToInt(), center.y.roundToInt()) ?: region.tileMap.values.first())
.aerialDistanceTo(
region.tileMap.getIfTileExistsOrNull(it.x.toInt(), it.y.toInt()) ?: region.tileMap.values.first()
) }!!)
return
}
if (dryTiles.isNotEmpty())
fallbackTiles.add(dryTiles.maxByOrNull { tileData[it]!!.startScore }!!)
// Fallback time. Just pick the one with best score
val fallbackPosition = fallbackTiles.maxByOrNull { tileData[it]!!.startScore }
if (fallbackPosition != null) {
setRegionStart(region, fallbackPosition)
return
}
// Something went extremely wrong and there is somehow no place to start. Spawn some land and start there
val panicPosition = region.rect.getPosition(Vector2())
val panicTerrain = ruleset.terrains.values.first { it.type == TerrainType.Land }.name
region.tileMap[panicPosition].baseTerrain = panicTerrain
region.tileMap[panicPosition].terrainFeatures.clear()
setRegionStart(region, panicPosition)
}
/** @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 setRegionStart(region: Region, position: Vector2) {
region.startPosition = position
setCloseStartPenalty(region.tileMap[position])
}
/** @returns a scaled according to [proportion] Rectangle centered over [originalRect] */
private fun getCentralRectangle(originalRect: Rectangle, proportion: Float): Rectangle {
val scaledRect = Rectangle(originalRect)
scaledRect.width = (originalRect.width * proportion)
scaledRect.height = (originalRect.height * proportion)
scaledRect.x = originalRect.x + (originalRect.width - scaledRect.width) / 2
scaledRect.y = originalRect.y + (originalRect.height - scaledRect.height) / 2
// round values
scaledRect.x = scaledRect.x.roundToInt().toFloat()
scaledRect.y = scaledRect.y.roundToInt().toFloat()
scaledRect.width = scaledRect.width.roundToInt().toFloat()
scaledRect.height = scaledRect.height.roundToInt().toFloat()
return scaledRect
}
private fun setCloseStartPenalty(tile: TileInfo) {
for ((ring, penalty) in closeStartPenaltyForRing) {
for (outerTile in tile.getTilesAtDistance(ring).map { it.position })
tileData[outerTile]!!.addCloseStartPenalty(penalty)
}
}
/** Evaluates a tile for starting position, setting isGoodStart and startScore in
* MapGenTileData. Assumes that all tiles have corresponding MapGenTileData. */
private fun evaluateTileForStart(tile: TileInfo) {
val localData = tileData[tile.position]!!
var totalFood = 0
var totalProd = 0
var totalGood = 0
var totalJunk = 0
var totalRivers = 0
var totalScore = 0
if (tile.isCoastalTile()) totalScore += 40
// Go through all rings
for (ring in 1..3) {
// Sum up the values for this ring
for (outerTile in tile.getTilesAtDistance(ring)) {
val outerTileData = tileData[outerTile.position]!!
if (outerTileData.isJunk)
totalJunk++
else {
if (outerTileData.isFood) totalFood++
if (outerTileData.isProd) totalProd++
if (outerTileData.isGood) totalGood++
if (outerTile.isAdjacentToRiver()) totalRivers++
}
}
// Check for minimum levels. We still keep on calculating final score in case of failure
if (totalFood < minimumFoodForRing[ring]!!
|| totalProd < minimumProdForRing[ring]!!
|| totalGood < minimumGoodForRing[ring]!!) {
localData.isGoodStart = false
}
// Ring-specific scoring
when (ring) {
1 -> {
val foodScore = firstRingFoodScores[totalFood]
val prodScore = firstRingProdScores[totalProd]
totalScore += foodScore + prodScore + totalRivers
+ (totalGood * 2) - (totalJunk * 3)
}
2 -> {
val foodScore = if (totalFood > 10) secondRingFoodScores.last()
else secondRingFoodScores[totalFood]
val effectiveTotalProd = if (totalProd >= totalFood * 2) totalProd
else (totalFood + 1) / 2 // Can't use all that production without food
val prodScore = if (effectiveTotalProd > 5) secondRingProdScores.last()
else secondRingProdScores[effectiveTotalProd]
totalScore += foodScore + prodScore + totalRivers
+ (totalGood * 2) - (totalJunk * 3)
}
else -> {
totalScore += totalFood + totalProd + totalGood + totalRivers - (totalJunk * 2)
}
}
}
// Too much junk?
if (totalJunk > maximumJunk) {
localData.isGoodStart = false
}
// Finally check if this is near another start
if (localData.closeStartPenalty > 0) {
localData.isGoodStart = false
totalScore -= (totalScore * localData.closeStartPenalty) / 100
}
localData.startScore = totalScore
}
// Holds a bunch of tile info that is only interesting during map gen
class MapGenTileData(val tile: TileInfo, val region: Region?) {
var closeStartPenalty = 0
var isFood = false
var isProd = false
var isGood = false
var isJunk = false
var isTwoFromCoast = false
var isGoodStart = true
var startScore = 0
fun addCloseStartPenalty(penalty: Int) {
if (closeStartPenalty == 0)
closeStartPenalty = penalty
else {
// Multiple overlapping values - take the higher one and add 20 %
closeStartPenalty = max(closeStartPenalty, penalty)
closeStartPenalty = min(97, (closeStartPenalty * 1.2f).toInt())
}
}
fun evaluate(ruleset: Ruleset) {
// Check if we are two tiles from coast (a bad starting site)
if (!tile.isCoastalTile() && tile.neighbors.any { it.isCoastalTile() })
isTwoFromCoast = true
// Check first available out of unbuildable features, then other features, then base terrain
val terrainToCheck = if (tile.terrainFeatures.isEmpty()) tile.getBaseTerrain()
else tile.getTerrainFeatures().firstOrNull { it.unbuildable }
?: tile.getTerrainFeatures().first()
// Add all applicable qualities
for (unique in terrainToCheck.getMatchingUniques(UniqueType.HasQuality, StateForConditionals(region = region))) {
when (unique.params[0]) {
"Food" -> isFood = true
"Desirable" -> isGood = true
"Production" -> isProd = true
"Undesirable" -> isJunk = true
}
}
// Were there in fact no explicit qualities defined for any region at all? If so let's guess at qualities to preserve mod compatibility.
if (terrainToCheck.uniqueObjects.none { it.type == UniqueType.HasQuality }) {
if (tile.isWater) return // Most water type tiles have no qualities
// is it junk???
if (terrainToCheck.impassable) {
isJunk = true
return // Don't bother checking the rest, junk is junk
}
// Take possible improvements into account
val improvements = ruleset.tileImprovements.values.filter {
terrainToCheck.name in it.terrainsCanBeBuiltOn &&
it.uniqueTo == null &&
!it.hasUnique(UniqueType.GreatImprovement)
}
val maxFood = terrainToCheck.food + (improvements.maxOfOrNull { it.food } ?: 0f)
val maxProd = terrainToCheck.production + (improvements.maxOfOrNull { it.production } ?: 0f)
val bestImprovementValue = improvements.maxOfOrNull { it.food + it.production + it.gold + it.culture + it.science + it.faith } ?: 0f
val maxOverall = terrainToCheck.food + terrainToCheck.production + terrainToCheck.gold +
terrainToCheck.culture + terrainToCheck.science + terrainToCheck.faith + bestImprovementValue
if (maxFood >= 2) isFood = true
if (maxProd >= 2) isProd = true
if (maxOverall >= 3) isGood = true
}
}
}
}
class Region (val tileMap: TileMap, val rect: Rectangle, val continentID: Int = -1) {
val tiles = HashSet<TileInfo>()
val terrainCounts = HashMap<String, Int>()
var totalFertility = 0
var type = "Hybrid" // being an undefined or indeterminate type
var startPosition: Vector2? = null
var affectedByWorldWrap = false
/** Recalculates tiles and fertility */
fun updateTiles(trim: Boolean = true) {
totalFertility = 0
var minX = 99999f
var maxX = -99999f
var minY = 99999f
var maxY = -99999f
val columnHasTile = HashSet<Int>()
tiles.clear()
for (tile in tileMap.getTilesInRectangle(rect, evenQ = true).filter {
continentID == -1 || it.getContinent() == continentID } ) {
val fertility = tile.getTileFertility(continentID != -1)
if (fertility != 0) { // If fertility is 0 this is candidate for trimming
tiles.add(tile)
totalFertility += fertility
}
if (affectedByWorldWrap)
columnHasTile.add(HexMath.hex2EvenQCoords(tile.position).x.toInt())
if (trim) {
val evenQCoords = HexMath.hex2EvenQCoords(tile.position)
minX = min(minX, evenQCoords.x)
maxX = max(maxX, evenQCoords.x)
minY = min(minY, evenQCoords.y)
maxY = max(maxY, evenQCoords.y)
}
}
if (trim) {
if (affectedByWorldWrap) // Need to be more thorough with origin longitude
rect.x = columnHasTile.filter { !columnHasTile.contains(it - 1) }.maxOf { it }.toFloat()
else
rect.x = minX // ez way for non-wrapping regions
rect.y = minY
rect.height = maxY - minY + 1
if (affectedByWorldWrap && minX < rect.x) { // Thorough way
rect.width = columnHasTile.count().toFloat()
} else {
rect.width = maxX - minX + 1 // ez way
affectedByWorldWrap = false // also we're not wrapping anymore
}
}
}
/** Counts the terrains in the Region for type and start determination */
fun countTerrains() {
// Count terrains in the region
terrainCounts.clear()
for (tile in tiles) {
val terrainsToCount = if (tile.getAllTerrains().any { it.hasUnique(UniqueType.IgnoreBaseTerrainForRegion) })
tile.getTerrainFeatures().map { it.name }.asSequence()
else
tile.getAllTerrains().map { it.name }
for (terrain in terrainsToCount) {
terrainCounts[terrain] = (terrainCounts[terrain] ?: 0) + 1
}
if (tile.isCoastalTile())
terrainCounts["Coastal"] = (terrainCounts["Coastal"] ?: 0) + 1
}
}
/** Returns number terrains with [name] */
fun getTerrainAmount(name: String) = terrainCounts[name] ?: 0
}

View File

@ -6,6 +6,7 @@ import com.unciv.logic.city.CityInfo
import com.unciv.logic.civilization.CivilizationInfo
import com.unciv.logic.map.MapUnit
import com.unciv.logic.map.TileInfo
import com.unciv.logic.map.mapgenerator.Region
data class StateForConditionals(
val civInfo: CivilizationInfo? = null,
@ -16,4 +17,6 @@ data class StateForConditionals(
val theirCombatant: ICombatant? = null,
val attackedTile: TileInfo? = null,
val combatAction: CombatAction? = null,
val region: Region? = null,
)

View File

@ -105,6 +105,11 @@ class Unique(val text: String, val sourceObjectType: UniqueTarget? = null, val s
it.matchesFilter(condition.params[2], state.civInfo) &&
it.matchesFilter(condition.params[3], state.civInfo)
} in (condition.params[0].toInt())..(condition.params[1].toInt())
UniqueType.ConditionalOnWaterMaps -> state.region?.continentID == -1
UniqueType.ConditionalInRegionOfType -> state.region?.type == condition.params[0]
UniqueType.ConditionalInRegionExceptOfType -> state.region != null && state.region.type != condition.params[0]
else -> false
}
}

View File

@ -155,6 +155,27 @@ enum class UniqueParameterType(val parameterName:String) {
return UniqueType.UniqueComplianceErrorSeverity.RulesetSpecific
}
},
/** Used for region definitions, can be a terrain type with region unique, or "Hybrid" */
RegionType("regionType") {
private val knownValues = setOf("Hybrid")
override fun getErrorSeverity(parameterText: String, ruleset: Ruleset):
UniqueType.UniqueComplianceErrorSeverity? {
if (parameterText in knownValues) return null
if (ruleset.terrains[parameterText]?.hasUnique(UniqueType.RegionRequirePercentSingleType) == true ||
ruleset.terrains[parameterText]?.hasUnique(UniqueType.RegionRequirePercentTwoTypes) == true)
return null
return UniqueType.UniqueComplianceErrorSeverity.RulesetSpecific
}
},
/** Used for start placements */
TerrainQuality("terrainQuality") {
private val knownValues = setOf("Undesirable", "Food", "Desirable", "Production")
override fun getErrorSeverity(parameterText: String, ruleset: Ruleset):
UniqueType.UniqueComplianceErrorSeverity? {
if (parameterText in knownValues) return null
return UniqueType.UniqueComplianceErrorSeverity.RulesetInvariant
}
},
Promotion("promotion") {
override fun getErrorSeverity(parameterText: String, ruleset: Ruleset):
UniqueType.UniqueComplianceErrorSeverity? = when (parameterText) {

View File

@ -306,11 +306,28 @@ enum class UniqueType(val text:String, vararg targets: UniqueTarget, val flags:
BlocksLineOfSightAtSameElevation("Blocks line-of-sight from tiles at same elevation", UniqueTarget.Terrain),
VisibilityElevation("Has an elevation of [amount] for visibility calculations", UniqueTarget.Terrain),
OverrideFertility("Always Fertility [amount] for Map Generation", UniqueTarget.Terrain, flags = listOf(UniqueFlag.HideInCivilopedia)),
AddFertility("[amount] to Fertility for Map Generation", UniqueTarget.Terrain, flags = listOf(UniqueFlag.HideInCivilopedia)),
RegionRequirePercentSingleType("A Region is formed with at least [amount]% [simpleTerrain] tiles, with priority [amount]", UniqueTarget.Terrain, flags = listOf(UniqueFlag.HideInCivilopedia)),
RegionRequirePercentTwoTypes("A Region is formed with at least [amount]% [simpleTerrain] tiles and [simpleTerrain] tiles, with priority [amount]",
UniqueTarget.Terrain, flags = listOf(UniqueFlag.HideInCivilopedia)),
RegionRequireFirstLessThanSecond("A Region can not contain more [simpleTerrain] tiles than [simpleTerrain] tiles", UniqueTarget.Terrain, flags = listOf(UniqueFlag.HideInCivilopedia)),
IgnoreBaseTerrainForRegion("Base Terrain on this tile is not counted for Region determination", UniqueTarget.Terrain, flags = listOf(UniqueFlag.HideInCivilopedia)),
HasQuality("Considered [terrainQuality] when determining start locations", UniqueTarget.Terrain, flags = listOf(UniqueFlag.HideInCivilopedia)),
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)),
OverrideDepositAmountOnTileFilter("Deposits in [tileFilter] tiles always provide [amount] resources", UniqueTarget.Resource),
NoNaturalGeneration("Doesn't generate naturally", UniqueTarget.Terrain, flags = listOf(UniqueFlag.HideInCivilopedia)),
TileGenerationConditions("Occurs at temperature between [amount] and [amount] and humidity between [amount] and [amount]", UniqueTarget.Terrain, flags = listOf(UniqueFlag.HideInCivilopedia)),
OccursInChains("Occurs in chains at high elevations", UniqueTarget.Terrain, flags = listOf(UniqueFlag.HideInCivilopedia)),
OccursInGroups("Occurs in groups around high elevations", UniqueTarget.Terrain, flags = listOf(UniqueFlag.HideInCivilopedia)),
RareFeature("Rare feature", UniqueTarget.Terrain),
ResistsNukes("Resistant to nukes", UniqueTarget.Terrain),
@ -348,8 +365,8 @@ enum class UniqueType(val text:String, vararg targets: UniqueTarget, val flags:
IsAncientRuinsEquivalent("Provides a random bonus when entered", UniqueTarget.Improvement),
Unpillagable("Unpillagable", UniqueTarget.Improvement),
Indestructible("Indestructible", UniqueTarget.Improvement),
Indestructible("Indestructible", UniqueTarget.Improvement),
///////////////////////////////////////// CONDITIONALS /////////////////////////////////////////
@ -385,6 +402,11 @@ enum class UniqueType(val text:String, vararg targets: UniqueTarget, val flags:
ConditionalNeighborTiles("with [amount] to [amount] neighboring [tileFilter] tiles", UniqueTarget.Conditional),
ConditionalNeighborTilesAnd("with [amount] to [amount] neighboring [tileFilter] [tileFilter] tiles", UniqueTarget.Conditional),
/////// region conditionals
ConditionalOnWaterMaps("on water maps", UniqueTarget.Conditional),
ConditionalInRegionOfType("in [regionType] Regions", UniqueTarget.Conditional),
ConditionalInRegionExceptOfType("in all except [regionType] Regions", UniqueTarget.Conditional),
///////////////////////////////////////// TRIGGERED ONE-TIME /////////////////////////////////////////