Skip to content

Commit

Permalink
Map generation and start locations (#4588)
Browse files Browse the repository at this point in the history
* cellular automata for mountains and hills

* cellular automata for mountains and hills

* tweaks

* spawn location algorithm

* consolation prizes

* improve city state spawns

* AI settle in place if possible

* make heightmap generation mod agnostic
  • Loading branch information
SimonCeder authored Jul 21, 2021
1 parent 08954c1 commit 2971e6b
Show file tree
Hide file tree
Showing 6 changed files with 249 additions and 19 deletions.
5 changes: 3 additions & 2 deletions android/assets/jsons/Civ V - Vanilla/Terrains.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@
"impassable": true,
"defenceBonus": 0.25,
"RGB": [120, 120, 120],
"uniques":["Rough terrain", "Has an elevation of [4] for visibility calculations"]
"uniques":["Rough terrain", "Has an elevation of [4] for visibility calculations", "Occurs in chains at high elevations"]
},
{
"name": "Snow",
Expand All @@ -83,7 +83,8 @@
"RGB": [105,125,72],
"occursOn": ["Tundra","Plains","Grassland","Desert","Snow"],
"uniques": ["Rough terrain", "[+5] Strength for cities built on this terrain",
"[+1] Sight for [Land] units", "Has an elevation of [2] for visibility calculations"]
"[+1] Sight for [Land] units", "Has an elevation of [2] for visibility calculations",
"Occurs in groups around high elevations"]
},
{
"name": "Forest",
Expand Down
3 changes: 3 additions & 0 deletions core/src/com/unciv/Constants.kt
Original file line number Diff line number Diff line change
Expand Up @@ -75,4 +75,7 @@ object Constants {
const val barbarians = "Barbarians"
const val spectator = "Spectator"
const val custom = "Custom"

const val rising = "Rising"
const val lowering = "Lowering"
}
102 changes: 92 additions & 10 deletions core/src/com/unciv/logic/GameStarter.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import com.unciv.models.metadata.GameParameters
import com.unciv.models.ruleset.Era
import com.unciv.models.ruleset.Ruleset
import com.unciv.models.ruleset.RulesetCache
import com.unciv.models.ruleset.tile.ResourceType
import com.unciv.ui.newgamescreen.GameSetupInfo
import java.util.*
import kotlin.NoSuchElementException
Expand Down Expand Up @@ -180,18 +181,43 @@ object GameStarter {
val startingEra = gameInfo.gameParameters.startingEra
var startingUnits: MutableList<String>
var eraUnitReplacement: String


// 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 cityStatesWithStartingLocations =
gameInfo.tileMap.values
.filter { it.improvement != null && it.improvement!!.startsWith("StartingLocation ") }
.map { it.improvement!!.replace("StartingLocation ", "") }
val bestCivs = gameInfo.civilizations.filter { !it.isBarbarian() && (!it.isCityState() || it.civName in cityStatesWithStartingLocations) }
val bestLocations = getStartingLocations(bestCivs, gameInfo.tileMap)
for (civ in bestCivs)
{
if (civ.isCityState()) // Already have explicit starting locations
continue

// Mark the best start locations so we remember them for the second pass
bestLocations[civ]!!.improvement = "StartingLocation " + civ.civName
}

val startingLocations = getStartingLocations(
gameInfo.civilizations.filter { !it.isBarbarian() },
gameInfo.tileMap)

val settlerLikeUnits = ruleSet.units.filter {
it.value.uniqueObjects.any { it.placeholderText == Constants.settlerUnique }
}

// no starting units for Barbarians and Spectators
for (civ in gameInfo.civilizations.filter { !it.isBarbarian() && !it.isSpectator() }) {
val startingLocation = startingLocations[civ]!!

if(civ.isMajorCiv() && startingLocation.getTileStartScore() < 45) {
// An unusually bad spawning location
addConsolationPrize(gameInfo, startingLocation, 45 - startingLocation.getTileStartScore().toInt())
}
if(civ.isCityState())
addCityStateLuxury(gameInfo, startingLocation)

for (tile in startingLocation.getTilesInDistance(3))
if (tile.improvement == Constants.ancientRuins)
tile.improvement = null // Remove ancient ruins in immediate vicinity
Expand Down Expand Up @@ -296,27 +322,36 @@ object GameStarter {
val tilesWithStartingLocations = tileMap.values
.filter { it.improvement != null && it.improvement!!.startsWith("StartingLocation ") }

val civsOrderedByAvailableLocations = civs.sortedBy { civ ->

val civsOrderedByAvailableLocations = civs.shuffled() // Order should be random since it determines who gets best start
.sortedBy { civ ->
when {
tilesWithStartingLocations.any { it.improvement == "StartingLocation " + civ.civName } -> 1 // harshest requirements
civ.nation.startBias.isNotEmpty() -> 2 // less harsh
else -> 3
civ.nation.startBias.contains("Tundra") -> 2 // Tundra starts are hard to find, so let's do them first
civ.nation.startBias.isNotEmpty() -> 3 // less harsh
else -> 4
} // no requirements
}

for (minimumDistanceBetweenStartingLocations in tileMap.tileMatrix.size / 3 downTo 0) {
for (minimumDistanceBetweenStartingLocations in tileMap.tileMatrix.size / 4 downTo 0) {
val freeTiles = landTilesInBigEnoughGroup
.filter { vectorIsAtLeastNTilesAwayFromEdge(it.position, minimumDistanceBetweenStartingLocations, tileMap) }
.filter { vectorIsAtLeastNTilesAwayFromEdge(it.position, (minimumDistanceBetweenStartingLocations * 2) /3, tileMap) }
.toMutableList()

val startingLocations = HashMap<CivilizationInfo, TileInfo>()

for (civ in civsOrderedByAvailableLocations) {
var startingLocation: TileInfo
val presetStartingLocation = tilesWithStartingLocations.firstOrNull { it.improvement == "StartingLocation " + civ.civName }
var distanceToNext = minimumDistanceBetweenStartingLocations

if (presetStartingLocation != null) startingLocation = presetStartingLocation
else {
if (freeTiles.isEmpty()) break // we failed to get all the starting tiles with this minimum distance
if (civ.isCityState())
distanceToNext = minimumDistanceBetweenStartingLocations / 2 // We allow random city states to squeeze in tighter

freeTiles.sortBy { it.getTileStartScore() }

var preferredTiles = freeTiles.toList()

for (startBias in civ.nation.startBias) {
Expand All @@ -327,10 +362,10 @@ object GameStarter {
else preferredTiles = preferredTiles.filter { it.matchesTerrainFilter(startBias) }
}

startingLocation = if (preferredTiles.isNotEmpty()) preferredTiles.random() else freeTiles.random()
startingLocation = if (preferredTiles.isNotEmpty()) preferredTiles.last() else freeTiles.last()
}
startingLocations[civ] = startingLocation
freeTiles.removeAll(tileMap.getTilesInDistance(startingLocation.position, minimumDistanceBetweenStartingLocations))
freeTiles.removeAll(tileMap.getTilesInDistance(startingLocation.position, distanceToNext))
}
if (startingLocations.size < civs.size) continue // let's try again with less minimum distance!

Expand All @@ -339,6 +374,53 @@ object GameStarter {
throw Exception("Didn't manage to get starting tiles even with distance of 1?")
}

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()

for (tile in relevantTiles) {
if(tile.resource != null && tile.getTileResource().resourceType == ResourceType.Luxury)
return // At least one luxury; all set
}

for (tile in relevantTiles) {
// Add a luxury to the first eligible tile
if (tile.resource != null)
continue

val luxuryToAdd = gameInfo.ruleSet.tileResources.values
.filter { it.terrainsCanBeFoundOn.contains(tile.getLastTerrain().name) && it.resourceType == ResourceType.Luxury }
.randomOrNull()
if (luxuryToAdd != null) {
tile.resource = luxuryToAdd.name
return
}
}
}

private fun vectorIsAtLeastNTilesAwayFromEdge(vector: Vector2, n: Int, tileMap: TileMap): Boolean {
// Since all maps are HEXAGONAL, the easiest way of checking if a tile is n steps away from the
// edge is checking the distance to the CENTER POINT
Expand Down
8 changes: 8 additions & 0 deletions core/src/com/unciv/logic/automation/SpecificUnitAutomation.kt
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,14 @@ object SpecificUnitAutomation {
}

fun automateSettlerActions(unit: MapUnit) {
if (unit.civInfo.gameInfo.turns == 0) { // Special case, we want AI to settle in place on turn 1.
val foundCityAction = UnitActions.getFoundCityAction(unit, unit.getTile())
if(foundCityAction?.action != null) {
foundCityAction.action.invoke()
return
}
}

if (unit.getTile().militaryUnit == null) return // Don't move until you're accompanied by a military unit

val tilesNearCities = unit.civInfo.gameInfo.getCities().asSequence()
Expand Down
35 changes: 35 additions & 0 deletions core/src/com/unciv/logic/map/TileInfo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,41 @@ open class TileInfo {
return stats
}

fun getTileStartScore(): Float {
var sum = 0f
for (tile in getTilesInDistance(2)) {
if (tile == this)
continue
sum += tile.getTileStartYield()
if (tile in neighbors)
sum += tile.getTileStartYield()
}

if (isHill())
sum -= 2
if (isAdjacentToRiver())
sum += 2
if (neighbors.any { it.baseTerrain == Constants.mountain })
sum += 2

return sum
}

private fun getTileStartYield(): Float {
var stats = getBaseTerrain().clone()

for (terrainFeatureBase in getTerrainFeatures()) {
if (terrainFeatureBase.overrideStats)
stats = terrainFeatureBase.clone()
else
stats.add(terrainFeatureBase)
}
if (resource != null) stats.add(getTileResource())
if (stats.production < 0) stats.production = 0f

return stats.food + stats.production + stats.gold
}

fun getImprovementStats(improvement: TileImprovement, observingCiv: CivilizationInfo, city: CityInfo?): Stats {
val stats = improvement.clone() // clones the stats of the improvement, not the improvement itself
if (hasViewableResource(observingCiv) && getTileResource().improvement == improvement.name)
Expand Down
115 changes: 108 additions & 7 deletions core/src/com/unciv/logic/map/mapgenerator/MapGenerator.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,7 @@ import com.unciv.models.Counter
import com.unciv.models.ruleset.Ruleset
import com.unciv.models.ruleset.tile.ResourceType
import com.unciv.models.ruleset.tile.TerrainType
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.pow
import kotlin.math.sign
import kotlin.math.*
import kotlin.random.Random


Expand Down Expand Up @@ -179,19 +176,123 @@ class MapGenerator(val ruleset: Ruleset) {
* [MapParameters.elevationExponent] favors high elevation
*/
private fun raiseMountainsAndHills(tileMap: TileMap) {
val mountain = ruleset.terrains.values.filter { it.uniques.contains("Occurs in chains at high elevations") }.firstOrNull()?.name
val hill = ruleset.terrains.values.filter { it.uniques.contains("Occurs in groups around high elevations") }.firstOrNull()?.name
val flat = ruleset.terrains.values.filter { !it.impassable && it.type == TerrainType.Land && !it.uniques.contains("Rough Terrain") }.firstOrNull()?.name

if (flat == null) {
println("Ruleset seems to contain no flat terrain - can't generate heightmap")
return
}

if (mountain != null)
println("Mountainlike generation for " + mountain)
if (hill != null)
println("Hill-like generation for " + hill)

val elevationSeed = randomness.RNG.nextInt().toDouble()
tileMap.setTransients(ruleset)
for (tile in tileMap.values.filter { !it.isWater }) {
var elevation = randomness.getPerlinNoise(tile, elevationSeed, scale = 2.0)
elevation = abs(elevation).pow(1.0 - tileMap.mapParameters.elevationExponent.toDouble()) * elevation.sign

when {
elevation <= 0.5 -> tile.baseTerrain = Constants.plains
elevation <= 0.7 -> tile.terrainFeatures.add(Constants.hill)
elevation <= 1.0 -> tile.baseTerrain = Constants.mountain
elevation <= 0.5 -> tile.baseTerrain = flat
elevation <= 0.7 && hill != null -> tile.terrainFeatures.add(hill)
elevation <= 0.7 && hill == null -> tile.baseTerrain = flat // otherwise would be hills become mountains
elevation <= 1.0 && mountain != null -> tile.baseTerrain = mountain
}
tile.setTerrainTransients()
}

if (mountain != null)
cellularMountainRanges(tileMap, mountain, hill, flat)
if (hill != null)
cellularHills(tileMap, mountain, hill)
}

private fun cellularMountainRanges(tileMap: TileMap, mountain: String, hill: String?, flat: String) {
val targetMountains = tileMap.values.count { it.baseTerrain == mountain } * 2

for (i in 1..5) {
var totalMountains = tileMap.values.count { it.baseTerrain == mountain }

for (tile in tileMap.values.filter { !it.isWater }) {
val adjacentMountains =
tile.neighbors.count { it.baseTerrain == mountain }
val adjacentImpassible =
tile.neighbors.count { ruleset.terrains[it.baseTerrain]?.impassable == true }

if (adjacentMountains == 0 && tile.baseTerrain == mountain) {
if (randomness.RNG.nextInt(until = 4) == 0)
tile.terrainFeatures.add(Constants.lowering)
} else if (adjacentMountains == 1) {
if (randomness.RNG.nextInt(until = 10) == 0)
tile.terrainFeatures.add(Constants.rising)
} else if (adjacentImpassible == 3) {
if (randomness.RNG.nextInt(until = 2) == 0)
tile.terrainFeatures.add(Constants.lowering)
} else if (adjacentImpassible > 3) {
tile.terrainFeatures.add(Constants.lowering)
}
}

for (tile in tileMap.values.filter { !it.isWater }) {
if (tile.terrainFeatures.remove(Constants.rising) && totalMountains < targetMountains) {
if (hill != null)
tile.terrainFeatures.remove(hill)
tile.baseTerrain = mountain
totalMountains++
}
if (tile.terrainFeatures.remove(Constants.lowering) && totalMountains > targetMountains * 0.5f) {
if (tile.baseTerrain == mountain) {
if (hill != null && !tile.terrainFeatures.contains(hill))
tile.terrainFeatures.add(hill)
totalMountains--
}
tile.baseTerrain = flat
}
}
}
}

private fun cellularHills(tileMap: TileMap, mountain: String?, hill: String) {
val targetHills = tileMap.values.count { it.terrainFeatures.contains(hill) }

for (i in 1..5) {
var totalHills = tileMap.values.count { it.terrainFeatures.contains(hill) }

for (tile in tileMap.values.filter { !it.isWater && (mountain == null || it.baseTerrain != mountain) }) {
val adjacentMountains = if (mountain == null) 0 else
tile.neighbors.count { it.baseTerrain == mountain }
val adjacentHills =
tile.neighbors.count { it.terrainFeatures.contains(hill) }

if (adjacentHills <= 1 && adjacentMountains == 0 && randomness.RNG.nextInt(until = 2) == 0) {
tile.terrainFeatures.add(Constants.lowering)
} else if (adjacentHills > 3 && adjacentMountains == 0 && randomness.RNG.nextInt(until = 2) == 0) {
tile.terrainFeatures.add(Constants.lowering)
} else if (adjacentHills + adjacentMountains in 2..3 && randomness.RNG.nextInt(until = 2) == 0) {
tile.terrainFeatures.add(Constants.rising)
}

}

for (tile in tileMap.values.filter { !it.isWater && (mountain == null || it.baseTerrain != mountain) }) {
if (tile.terrainFeatures.remove(Constants.rising) && (totalHills <= targetHills || i == 1) ) {
if (!tile.terrainFeatures.contains(hill)) {
tile.terrainFeatures.add(hill)
totalHills++
}
}
if (tile.terrainFeatures.remove(Constants.lowering) && (totalHills >= targetHills * 0.9f || i == 1)) {
if (tile.terrainFeatures.contains(hill)) {
tile.terrainFeatures.remove(hill)
totalHills--
}
}
}
}
}

/**
Expand Down

0 comments on commit 2971e6b

Please sign in to comment.