diff --git a/android/assets/jsons/translations/template.properties b/android/assets/jsons/translations/template.properties index d848be5e80..d7f9a4807f 100644 --- a/android/assets/jsons/translations/template.properties +++ b/android/assets/jsons/translations/template.properties @@ -377,6 +377,7 @@ Time = Map Shape = Hexagonal = +Flat Earth Hexagonal = Rectangular = Height = Width = diff --git a/core/src/com/unciv/logic/map/MapParameters.kt b/core/src/com/unciv/logic/map/MapParameters.kt index 464edd0558..76a81e5606 100644 --- a/core/src/com/unciv/logic/map/MapParameters.kt +++ b/core/src/com/unciv/logic/map/MapParameters.kt @@ -124,6 +124,7 @@ class MapSizeNew : IsPartOfGameInfoSerialization { object MapShape : IsPartOfGameInfoSerialization { const val hexagonal = "Hexagonal" + const val flatEarth = "Flat Earth Hexagonal" const val rectangular = "Rectangular" } @@ -229,12 +230,12 @@ class MapParameters : IsPartOfGameInfoSerialization { } fun getArea() = when { - shape == MapShape.hexagonal -> getNumberOfTilesInHexagon(mapSize.radius) + shape == MapShape.hexagonal || shape == MapShape.flatEarth -> getNumberOfTilesInHexagon(mapSize.radius) worldWrap && mapSize.width % 2 != 0 -> (mapSize.width - 1) * mapSize.height else -> mapSize.width * mapSize.height } fun displayMapDimensions() = mapSize.run { - (if (shape == MapShape.hexagonal) "R$radius" else "${width}x$height") + + (if (shape == MapShape.hexagonal || shape == MapShape.flatEarth) "R$radius" else "${width}x$height") + (if (worldWrap) "w" else "") } @@ -266,7 +267,7 @@ class MapParameters : IsPartOfGameInfoSerialization { }.joinToString("") fun numberOfTiles() = - if (shape == MapShape.hexagonal) { + if (shape == MapShape.hexagonal || shape == MapShape.flatEarth) { 1 + 3 * mapSize.radius * (mapSize.radius - 1) } else { mapSize.width * mapSize.height diff --git a/core/src/com/unciv/logic/map/mapgenerator/MapGenerator.kt b/core/src/com/unciv/logic/map/mapgenerator/MapGenerator.kt index 1386f03e6a..a13534703c 100644 --- a/core/src/com/unciv/logic/map/mapgenerator/MapGenerator.kt +++ b/core/src/com/unciv/logic/map/mapgenerator/MapGenerator.kt @@ -26,6 +26,7 @@ import kotlin.math.max import kotlin.math.pow import kotlin.math.roundToInt import kotlin.math.sign +import kotlin.math.sqrt import kotlin.math.ulp import kotlin.random.Random @@ -35,6 +36,14 @@ class MapGenerator(val ruleset: Ruleset) { private const val consoleTimings = false } + private val landTerrainName = + MapLandmassGenerator.getInitializationTerrain(ruleset, TerrainType.Land) + private val waterTerrainName: String = try { + MapLandmassGenerator.getInitializationTerrain(ruleset, TerrainType.Water) + } catch (_: Exception) { + landTerrainName + } + private var randomness = MapGenerationRandomness() private val firstLandTerrain = ruleset.terrains.values.first { it.type==TerrainType.Land } @@ -503,9 +512,19 @@ class MapGenerator(val ruleset: Ruleset) { val humidityRandom = randomness.getPerlinNoise(tile, humiditySeed, scale = scale, nOctaves = 1) val humidity = ((humidityRandom + 1.0) / 2.0 + humidityShift).coerceIn(0.0..1.0) + val expectedTemperature = if (tileMap.mapParameters.shape === MapShape.flatEarth) { + // Flat Earth uses radius because North is center of map + val radius = getTileRadius(tile, tileMap) + val radiusTemperature = getTemperatureAtRadius(radius) + radiusTemperature + } else { + // Globe Earth uses latitude because North is top of map + val latitudeTemperature = 1.0 - 2.0 * abs(tile.latitude) / tileMap.maxLatitude + latitudeTemperature + } + val randomTemperature = randomness.getPerlinNoise(tile, temperatureSeed, scale = scale, nOctaves = 1) - val latitudeTemperature = 1.0 - 2.0 * abs(tile.latitude) / tileMap.maxLatitude - var temperature = (5.0 * latitudeTemperature + randomTemperature) / 6.0 + var temperature = (5.0 * expectedTemperature + randomTemperature) / 6.0 temperature = abs(temperature).pow(1.0 - temperatureExtremeness) * temperature.sign temperature = (temperature + temperatureShift).coerceIn(-1.0..1.0) @@ -538,6 +557,65 @@ class MapGenerator(val ruleset: Ruleset) { } } + private fun getTileRadius(tile: TileInfo, tileMap: TileMap): Float { + val latitudeRatio = abs(tile.latitude) / tileMap.maxLatitude + val longitudeRatio = abs(tile.longitude) / tileMap.maxLongitude + return sqrt(latitudeRatio.pow(2) + longitudeRatio.pow(2)) + } + + private fun getTemperatureAtRadius(radius: Float): Double { + /* + Radius is in the range of 0.0 to 1.0 + Temperature is in the range of -1.0 to 1.0 + + Radius of 0.0 (arctic) is -1.0 (cold) + Radius of 0.25 (mid North) is 0.0 (temperate) + Radius of 0.5 (equator) is 1.0 (hot) + Radius of 0.75 (mid South) is 0.0 (temperate) + Radius of 1.0 (antarctic) is -1.0 (cold) + + Scale the radius range to the temperature range + */ + return when { + /* + North Zone + Starts cold at arctic and gets hotter as it goes South to equator + x1 is set to 0.05 instead of 0.0 to offset the ice in the center of the map + */ + radius < 0.5 -> scaleToRange(0.05, 0.5, -1.0, 1.0, radius) + + /* + South Zone + Starts hot at equator and gets colder as it goes South to antarctic + x2 is set to 0.95 instead of 1.0 to offset the ice on the edges of the map + */ + radius > 0.5 -> scaleToRange(0.5, 0.95, 1.0, -1.0, radius) + + /* + Equator + Always hot + radius == 0.5 + */ + else -> 1.0 + } + } + + /** + * @x1 start of the original range + * @x2 end of the original range + * @y1 start of the new range + * @y2 end of the new range + * @value value to be scaled from the original range to the new range + * + * @returns value in new scale + * special thanks to @letstalkaboutdune for the math + */ + private fun scaleToRange(x1: Double, x2: Double, y1: Double, y2: Double, value: Float): Double { + val gain = (y2 - y1) / (x2 - x1) + val offset = y2 - (gain * x2) + return (gain * value) + offset + } + /** * [MapParameters.vegetationRichness] is the threshold for vegetation spawn */ @@ -596,6 +674,11 @@ class MapGenerator(val ruleset: Ruleset) { -1f, ruleset.modOptions.constants.spawnIceBelowTemperature, 0f, 1f)) }.toList() + + if (tileMap.mapParameters.shape === MapShape.flatEarth) { + spawnFlatEarthIceWalls(tileMap, iceEquivalents) + } + if (iceEquivalents.isEmpty()) return tileMap.setTransients(ruleset) @@ -621,6 +704,145 @@ class MapGenerator(val ruleset: Ruleset) { } } } + + private fun spawnFlatEarthIceWalls(tileMap: TileMap, iceEquivalents: List) { + val iceCandidates = iceEquivalents.filter { + it.matches(-1.0, 1.0) + }.map { + it.terrain.name + } + val iceTerrainName = + when (iceCandidates.size) { + 1 -> iceCandidates.first() + !in 0..1 -> iceCandidates.random(randomness.RNG) + else -> null + } + + val snowCandidates = ruleset.terrains.values.asSequence().filter { + it.type == TerrainType.Land + }.flatMap { terrain -> + val conditions = terrain.getGenerationConditions() + if (conditions.any()) conditions + else sequenceOf( + TerrainOccursRange( + terrain, + -1f, ruleset.modOptions.constants.spawnIceBelowTemperature, + 0f, 1f + ) + ) + }.toList().filter { + it.matches(-1.0, 1.0) + }.map { + it.terrain.name + } + val snowTerrainName = + when (snowCandidates.size) { + 1 -> snowCandidates.first() + !in 0..1 -> snowCandidates.random(randomness.RNG) + else -> null + } + + val mountainTerrainName = + ruleset.terrains.values.firstOrNull { it.hasUnique(UniqueType.OccursInChains) }?.name + + val bestArcticTileName = when { + iceTerrainName != null -> iceTerrainName + snowTerrainName != null -> snowTerrainName + mountainTerrainName != null -> mountainTerrainName + else -> null + } + + val arcticTileNameList = + arrayOf(iceTerrainName, snowTerrainName, mountainTerrainName).filterNotNull() + + // Skip the tile loop if nothing can be done in it + if (bestArcticTileName == null && arcticTileNameList.isEmpty()) return + + // Flat Earth needs a 1 tile wide perimeter of ice/mountain/snow and a 2 radius cluster of ice in the center. + for (tile in tileMap.values) { + val isCenterTile = tile.latitude == 0f && tile.longitude == 0f + val isEdgeTile = tile.neighbors.count() < 6 + + // Make center tiles ice or snow or mountain depending on availability + if (isCenterTile && bestArcticTileName != null) { + spawnFlatEarthCenterIceWall(tile, bestArcticTileName, iceTerrainName) + } + + // Make edge tiles randomly ice or snow or mountain if available + if (isEdgeTile && arcticTileNameList.isNotEmpty()) { + spawnFlatEarthEdgeIceWall(tile, arcticTileNameList, iceTerrainName, mountainTerrainName) + } + } + } + + private fun spawnFlatEarthCenterIceWall(tile: TileInfo, bestArcticTileName: String, iceTerrainName: String?) { + // Spawn ice on center tile + if (bestArcticTileName == iceTerrainName) { + tile.baseTerrain = waterTerrainName + tile.addTerrainFeature(iceTerrainName) + } else { + tile.baseTerrain = bestArcticTileName + } + + // Spawn circle of ice around center tile + for (neighbor in tile.neighbors) { + if (bestArcticTileName == iceTerrainName) { + neighbor.baseTerrain = waterTerrainName + neighbor.addTerrainFeature(iceTerrainName) + } else { + neighbor.baseTerrain = bestArcticTileName + } + + // Spawn partial circle of ice around circle of ice + for (neighbor2 in neighbor.neighbors) { + if (randomness.RNG.nextDouble() < 0.75) { + // Do nothing most of the time at random. + } else if (bestArcticTileName == iceTerrainName) { + neighbor2.baseTerrain = waterTerrainName + neighbor2.addTerrainFeature(iceTerrainName) + } else { + neighbor2.baseTerrain = bestArcticTileName + } + } + } + } + + private fun spawnFlatEarthEdgeIceWall(tile: TileInfo, arcticTileNameList: List, iceTerrainName: String?, mountainTerrainName: String?) { + // Select one of the arctic tiles at random + val arcticTileName = when (arcticTileNameList.size) { + 1 -> arcticTileNameList.first() + else -> arcticTileNameList.random(randomness.RNG) + } + + // Spawn arctic tiles on edge tile + if (arcticTileName == iceTerrainName) { + tile.baseTerrain = waterTerrainName + tile.addTerrainFeature(iceTerrainName) + } else if (iceTerrainName != null && arcticTileName != mountainTerrainName) { + tile.baseTerrain = arcticTileName + tile.addTerrainFeature(iceTerrainName) + } else { + tile.baseTerrain = arcticTileName + } + + // Spawn partial circle of arctic tiles next to the edge + for (neighbor in tile.neighbors) { + val neighborIsEdgeTile = neighbor.neighbors.count() < 6 + if (neighborIsEdgeTile) { + // Do not redo edge tile. It is already done. + } else if (randomness.RNG.nextDouble() < 0.75) { + // Do nothing most of the time at random. + } else if (arcticTileName == iceTerrainName) { + neighbor.baseTerrain = waterTerrainName + neighbor.addTerrainFeature(iceTerrainName) + } else if (iceTerrainName != null && arcticTileName != mountainTerrainName) { + neighbor.baseTerrain = arcticTileName + neighbor.addTerrainFeature(iceTerrainName) + } else { + neighbor.baseTerrain = arcticTileName + } + } + } } class MapGenerationRandomness { diff --git a/core/src/com/unciv/logic/map/mapgenerator/MapLandmassGenerator.kt b/core/src/com/unciv/logic/map/mapgenerator/MapLandmassGenerator.kt index 1b146c4b47..dfacd5fe35 100644 --- a/core/src/com/unciv/logic/map/mapgenerator/MapLandmassGenerator.kt +++ b/core/src/com/unciv/logic/map/mapgenerator/MapLandmassGenerator.kt @@ -48,6 +48,36 @@ class MapLandmassGenerator(val ruleset: Ruleset, val randomness: MapGenerationRa MapType.archipelago -> createArchipelago(tileMap) MapType.default -> createPerlin(tileMap) } + + if (tileMap.mapParameters.shape === MapShape.flatEarth) { + generateFlatEarthExtraWater(tileMap) + } + } + + private fun generateFlatEarthExtraWater(tileMap: TileMap) { + for (tile in tileMap.values) { + val isCenterTile = tile.latitude == 0f && tile.longitude == 0f + val isEdgeTile = tile.neighbors.count() < 6 + + if (!isCenterTile && !isEdgeTile) continue + + /* + Flat Earth needs a 3 tile wide water perimeter and a 4 tile radius water center. + This helps map generators to not place important things there which would be destroyed + when the ice walls are placed there. + */ + tile.baseTerrain = waterTerrainName + for (neighbor in tile.neighbors) { + neighbor.baseTerrain = waterTerrainName + for (neighbor2 in neighbor.neighbors) { + neighbor2.baseTerrain = waterTerrainName + if (!isCenterTile) continue + for (neighbor3 in neighbor2.neighbors) { + neighbor3.baseTerrain = waterTerrainName + } + } + } + } } private fun spawnLandOrWater(tile: TileInfo, elevation: Double) { @@ -109,7 +139,7 @@ class MapLandmassGenerator(val ruleset: Ruleset, val randomness: MapGenerationRa private fun createTwoContinents(tileMap: TileMap) { val isLatitude = - if (tileMap.mapParameters.shape === MapShape.hexagonal) randomness.RNG.nextDouble() > 0.5f + if (tileMap.mapParameters.shape === MapShape.hexagonal || tileMap.mapParameters.shape === MapShape.flatEarth) randomness.RNG.nextDouble() > 0.5f else if (tileMap.mapParameters.mapSize.height > tileMap.mapParameters.mapSize.width) true else if (tileMap.mapParameters.mapSize.width > tileMap.mapParameters.mapSize.height) false else randomness.RNG.nextDouble() > 0.5f @@ -123,12 +153,14 @@ class MapLandmassGenerator(val ruleset: Ruleset, val randomness: MapGenerationRa } private fun createThreeContinents(tileMap: TileMap) { - val isNorth = randomness.RNG.nextDouble() < 0.5f + val isNorth = randomness.RNG.nextDouble() < 0.5 + // On flat earth maps we can randomly do East or West instead of North or South + val isEastWest = tileMap.mapParameters.shape === MapShape.flatEarth && randomness.RNG.nextDouble() > 0.5 val elevationSeed = randomness.RNG.nextInt().toDouble() for (tile in tileMap.values) { var elevation = randomness.getPerlinNoise(tile, elevationSeed) - elevation = (elevation + getThreeContinentsTransform(tile, tileMap, isNorth)) / 2.0 + elevation = (elevation + getThreeContinentsTransform(tile, tileMap, isNorth, isEastWest)) / 2.0 spawnLandOrWater(tile, elevation) } } @@ -180,16 +212,25 @@ class MapLandmassGenerator(val ruleset: Ruleset, val randomness: MapGenerationRa return min(0.2, -1.0 + (5.0 * factor.pow(0.6f) + randomScale) / 3.0) } - private fun getThreeContinentsTransform(tileInfo: TileInfo, tileMap: TileMap, isNorth: Boolean): Double { + private fun getThreeContinentsTransform(tileInfo: TileInfo, tileMap: TileMap, isNorth: Boolean, isEastWest: Boolean): Double { // The idea here is to create a water area separating the two four areas. // So what we do it create a line of water in the middle - where longitude is close to 0. val randomScale = randomness.RNG.nextDouble() var longitudeFactor = abs(tileInfo.longitude) / tileMap.maxLongitude var latitudeFactor = abs(tileInfo.latitude) / tileMap.maxLatitude + // 3rd continent should use only half the map width, or if flat earth, only a third + val sizeReductionFactor = if (tileMap.mapParameters.shape === MapShape.flatEarth) 3f else 2f + // We then pick one side to be merged into one centered continent instead of two cornered. - if (isNorth && tileInfo.latitude < 0 || !isNorth && tileInfo.latitude > 0) - longitudeFactor = max(0f, tileMap.maxLongitude - abs(tileInfo.longitude * 2f)) / tileMap.maxLongitude + if (isEastWest) { + // In EastWest mode North represents West + if (isNorth && tileInfo.longitude < 0 || !isNorth && tileInfo.longitude > 0) + latitudeFactor = max(0f, tileMap.maxLatitude - abs(tileInfo.latitude * sizeReductionFactor)) / tileMap.maxLatitude + } else { + if (isNorth && tileInfo.latitude < 0 || !isNorth && tileInfo.latitude > 0) + longitudeFactor = max(0f, tileMap.maxLongitude - abs(tileInfo.longitude * sizeReductionFactor)) / tileMap.maxLongitude + } // If this is a world wrap, we want it to be separated on both sides - // so we make the actual strip of water thinner, but we put it both in the middle of the map and on the edges of the map diff --git a/core/src/com/unciv/logic/map/mapgenerator/MapRegions.kt b/core/src/com/unciv/logic/map/mapgenerator/MapRegions.kt index 3b25c9c6a1..419a75b01a 100644 --- a/core/src/com/unciv/logic/map/mapgenerator/MapRegions.kt +++ b/core/src/com/unciv/logic/map/mapgenerator/MapRegions.kt @@ -76,7 +76,7 @@ class MapRegions (val ruleset: Ruleset){ val totalLand = tileMap.continentSizes.values.sum().toFloat() val largestContinent = tileMap.continentSizes.values.maxOf { it }.toFloat() - val radius = if (tileMap.mapParameters.shape == MapShape.hexagonal) + val radius = if (tileMap.mapParameters.shape == MapShape.hexagonal || tileMap.mapParameters.shape == MapShape.flatEarth) tileMap.mapParameters.mapSize.radius.toFloat() else (max(tileMap.mapParameters.mapSize.width / 2, tileMap.mapParameters.mapSize.height / 2)).toFloat() diff --git a/core/src/com/unciv/ui/mapeditor/MapEditorScreen.kt b/core/src/com/unciv/ui/mapeditor/MapEditorScreen.kt index 7d5584a994..af55e01065 100644 --- a/core/src/com/unciv/ui/mapeditor/MapEditorScreen.kt +++ b/core/src/com/unciv/ui/mapeditor/MapEditorScreen.kt @@ -230,7 +230,7 @@ class MapEditorScreen(map: TileMap? = null): BaseScreen(), RecreateOnResize { ToastPopup(message, this@MapEditorScreen, 4000L ) } - if (params.shape == MapShape.hexagonal) { + if (params.shape == MapShape.hexagonal || params.shape == MapShape.flatEarth) { params.mapSize = MapSizeNew(HexMath.getHexagonalRadiusForArea(areaFromTiles).toInt()) return } diff --git a/core/src/com/unciv/ui/newgamescreen/MapParametersTable.kt b/core/src/com/unciv/ui/newgamescreen/MapParametersTable.kt index 1c6ad444fd..2894b068e2 100644 --- a/core/src/com/unciv/ui/newgamescreen/MapParametersTable.kt +++ b/core/src/com/unciv/ui/newgamescreen/MapParametersTable.kt @@ -76,6 +76,7 @@ class MapParametersTable( private fun addMapShapeSelectBox() { val mapShapes = listOfNotNull( MapShape.hexagonal, + MapShape.flatEarth, MapShape.rectangular ) val mapShapeSelectBox = @@ -176,7 +177,7 @@ class MapParametersTable( private fun updateWorldSizeTable() { customWorldSizeTable.clear() - if (mapParameters.shape == MapShape.hexagonal && worldSizeSelectBox.selected.value == MapSize.custom) + if ((mapParameters.shape == MapShape.hexagonal || mapParameters.shape == MapShape.flatEarth) && worldSizeSelectBox.selected.value == MapSize.custom) customWorldSizeTable.add(hexagonalSizeTable).grow().row() else if (mapParameters.shape == MapShape.rectangular && worldSizeSelectBox.selected.value == MapSize.custom) customWorldSizeTable.add(rectangularSizeTable).grow().row()