Fix ChangesTerrain unique for base terrains (#10043)

* Fix UniqueType.ChangesTerrain not working for base terrain parameter

* Fix spawnRiver resultingTiles to include all affected tiles on both sides of the River

* Fix terrain conversion for rivers from Map Editor partial generation / paint from-to

* forEach linting

* Instrumentation for generateSingleStep

* forEach linting

* Remove lazies
This commit is contained in:
SomeTroglodyte 2023-09-04 13:36:52 +02:00 committed by GitHub
parent 8b9d0af4bf
commit d758da4d11
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 151 additions and 111 deletions

View File

@ -33,6 +33,13 @@ import kotlin.math.ulp
import kotlin.random.Random
/** Map generator, used by new game, map editor and main menu background
*
* Class instance only keeps [ruleset] and [coroutineScope] for easier access, input and output are through methods, namely [generateMap] and [generateSingleStep].
*
* @param ruleset The Ruleset supplying terrain and resource definitions
* @param coroutineScope Enables early abort if this returns `isActive == false`
*/
class MapGenerator(val ruleset: Ruleset, private val coroutineScope: CoroutineScope? = null) {
companion object {
@ -138,7 +145,7 @@ class MapGenerator(val ruleset: Ruleset, private val coroutineScope: CoroutineSc
runAndMeasure("RiverGenerator") {
RiverGenerator(map, randomness, ruleset).spawnRivers()
}
convertTerrains(map, ruleset)
convertTerrains(map.values)
// Region based map generation - not used when generating maps in map editor
if (civilizations.isNotEmpty()) {
@ -182,24 +189,27 @@ class MapGenerator(val ruleset: Ruleset, private val coroutineScope: CoroutineSc
randomness.seedRNG(map.mapParameters.seed)
when(step) {
MapGeneratorSteps.None -> return
MapGeneratorSteps.All -> throw IllegalArgumentException("MapGeneratorSteps.All cannot be used in generateSingleStep")
MapGeneratorSteps.Landmass -> MapLandmassGenerator(ruleset, randomness).generateLand(map)
MapGeneratorSteps.Elevation -> raiseMountainsAndHills(map)
MapGeneratorSteps.HumidityAndTemperature -> applyHumidityAndTemperature(map)
MapGeneratorSteps.LakesAndCoast -> spawnLakesAndCoasts(map)
MapGeneratorSteps.Vegetation -> spawnVegetation(map)
MapGeneratorSteps.RareFeatures -> spawnRareFeatures(map)
MapGeneratorSteps.Ice -> spawnIce(map)
MapGeneratorSteps.Continents -> map.assignContinents(TileMap.AssignContinentsMode.Reassign)
MapGeneratorSteps.NaturalWonders -> NaturalWonderGenerator(ruleset, randomness).spawnNaturalWonders(map)
MapGeneratorSteps.Rivers -> {
RiverGenerator(map, randomness, ruleset).spawnRivers()
convertTerrains(map, ruleset)
runAndMeasure("SingleStep $step") {
when (step) {
MapGeneratorSteps.None -> Unit
MapGeneratorSteps.All -> throw IllegalArgumentException("MapGeneratorSteps.All cannot be used in generateSingleStep")
MapGeneratorSteps.Landmass -> MapLandmassGenerator(ruleset, randomness).generateLand(map)
MapGeneratorSteps.Elevation -> raiseMountainsAndHills(map)
MapGeneratorSteps.HumidityAndTemperature -> applyHumidityAndTemperature(map)
MapGeneratorSteps.LakesAndCoast -> spawnLakesAndCoasts(map)
MapGeneratorSteps.Vegetation -> spawnVegetation(map)
MapGeneratorSteps.RareFeatures -> spawnRareFeatures(map)
MapGeneratorSteps.Ice -> spawnIce(map)
MapGeneratorSteps.Continents -> map.assignContinents(TileMap.AssignContinentsMode.Reassign)
MapGeneratorSteps.NaturalWonders -> NaturalWonderGenerator(ruleset, randomness).spawnNaturalWonders(map)
MapGeneratorSteps.Rivers -> {
val resultingTiles = mutableSetOf<Tile>()
RiverGenerator(map, randomness, ruleset).spawnRivers(resultingTiles)
convertTerrains(resultingTiles)
}
MapGeneratorSteps.Resources -> spreadResources(map)
MapGeneratorSteps.AncientRuins -> spreadAncientRuins(map)
}
MapGeneratorSteps.Resources -> spreadResources(map)
MapGeneratorSteps.AncientRuins -> spreadAncientRuins(map)
}
}
@ -213,18 +223,19 @@ class MapGenerator(val ruleset: Ruleset, private val coroutineScope: CoroutineSc
debug("MapGenerator.%s took %s.%sms", text, delta/1000000L, (delta/10000L).rem(100))
}
private fun convertTerrains(map: TileMap, ruleset: Ruleset) {
for (tile in map.values) {
fun convertTerrains(tiles: Iterable<Tile>) {
for (tile in tiles) {
val conversionUnique =
tile.getBaseTerrain().getMatchingUniques(UniqueType.ChangesTerrain)
.firstOrNull { tile.isAdjacentTo(it.params[1]) }
?: continue
val terrain = ruleset.terrains[conversionUnique.params[0]] ?: continue
if (!terrain.occursOn.contains(tile.lastTerrain.name)) continue
if (terrain.type == TerrainType.TerrainFeature)
if (terrain.type == TerrainType.TerrainFeature) {
if (!terrain.occursOn.contains(tile.lastTerrain.name)) continue
tile.addTerrainFeature(terrain.name)
else tile.baseTerrain = terrain.name
} else
tile.baseTerrain = terrain.name
tile.setTerrainTransients()
}
}

View File

@ -2,10 +2,10 @@ package com.unciv.logic.map.mapgenerator
import com.badlogic.gdx.math.Vector2
import com.unciv.Constants
import com.unciv.utils.debug
import com.unciv.logic.map.tile.Tile
import com.unciv.logic.map.TileMap
import com.unciv.logic.map.tile.Tile
import com.unciv.models.ruleset.Ruleset
import com.unciv.utils.debug
import kotlin.math.roundToInt
class RiverGenerator(
@ -17,7 +17,7 @@ class RiverGenerator(
private val minRiverLength = ruleset.modOptions.constants.minRiverLength
private val maxRiverLength = ruleset.modOptions.constants.maxRiverLength
fun spawnRivers() {
fun spawnRivers(resultingTiles: MutableSet<Tile>? = null) {
if (tileMap.values.none { it.isWater }) return
val numberOfRivers = (tileMap.values.count { it.isLand } * riverCountMultiplier).roundToInt()
@ -33,7 +33,7 @@ class RiverGenerator(
val mapRadius = tileMap.mapParameters.mapSize.radius
val riverStarts =
randomness.chooseSpreadOutLocations(numberOfRivers, optionalTiles, mapRadius)
for (tile in riverStarts) spawnRiver(tile)
for (tile in riverStarts) spawnRiver(tile, resultingTiles)
}
private fun Tile.isFarEnoughFromWater(): Boolean {
@ -52,71 +52,47 @@ class RiverGenerator(
return null
}
private fun spawnRiver(initialPosition: Tile) {
private fun spawnRiver(initialPosition: Tile, resultingTiles: MutableSet<Tile>?) {
val endPosition = getClosestWaterTile(initialPosition)
?: error("No water found for river destination")
spawnRiver(initialPosition, endPosition)
spawnRiver(initialPosition, endPosition, resultingTiles)
}
fun spawnRiver(initialPosition: Tile, endPosition: Tile, resultingTiles: MutableSet<Tile>? = null) {
/** Spawns a river from [initialPosition] to [endPosition].
* If [resultingTiles] is supplied, it will contain all affected tiles, for map editor. */
fun spawnRiver(initialPosition: Tile, endPosition: Tile, resultingTiles: MutableSet<Tile>?) {
// Recommendation: Draw a bunch of hexagons on paper before trying to understand this, it's super helpful!
var riverCoordinate = RiverCoordinate(initialPosition.position,
var riverCoordinate = RiverCoordinate(tileMap, initialPosition.position,
RiverCoordinate.BottomRightOrLeft.values().random(randomness.RNG))
repeat(maxRiverLength) { // Arbitrary max on river length, otherwise this will go in circles - rarely
val riverCoordinateTile = tileMap[riverCoordinate.position]
resultingTiles?.add(riverCoordinateTile)
if (riverCoordinate.getAdjacentTiles(tileMap).any { it.isWater }) return
val possibleCoordinates = riverCoordinate.getAdjacentPositions(tileMap)
if (riverCoordinate.getAdjacentTiles().any { it.isWater }) return
val possibleCoordinates = riverCoordinate.getAdjacentPositions()
if (possibleCoordinates.none()) return // end of the line
val newCoordinate = possibleCoordinates
.groupBy { newCoordinate ->
newCoordinate.getAdjacentTiles(tileMap).map { it.aerialDistanceTo(endPosition) }
newCoordinate.getAdjacentTiles().map { it.aerialDistanceTo(endPosition) }
.minOrNull()!!
}
.minByOrNull { it.key }!!
.component2().random(randomness.RNG)
// set new rivers in place
if (newCoordinate.position == riverCoordinate.position) // same tile, switched right-to-left
riverCoordinateTile.hasBottomRiver = true
else if (riverCoordinate.bottomRightOrLeft == RiverCoordinate.BottomRightOrLeft.BottomRight) {
if (newCoordinate.getAdjacentTiles(tileMap).contains(riverCoordinateTile)) // moved from our 5 O'Clock to our 3 O'Clock
riverCoordinateTile.hasBottomRightRiver = true
else // moved from our 5 O'Clock down in the 5 O'Clock direction - this is the 8 O'Clock river of the tile to our 4 O'Clock!
tileMap[newCoordinate.position].hasBottomLeftRiver = true
} else { // riverCoordinate.bottomRightOrLeft==RiverCoordinate.BottomRightOrLeft.Left
if (newCoordinate.getAdjacentTiles(tileMap).contains(riverCoordinateTile)) // moved from our 7 O'Clock to our 9 O'Clock
riverCoordinateTile.hasBottomLeftRiver = true
else // moved from our 7 O'Clock down in the 7 O'Clock direction
tileMap[newCoordinate.position].hasBottomRightRiver = true
}
// set one new river edge in place
riverCoordinate.paintTo(newCoordinate, resultingTiles)
// Move on
riverCoordinate = newCoordinate
}
debug("River reached max length!")
}
/*
fun numberOfConnectedRivers(riverCoordinate: RiverCoordinate): Int {
var sum = 0
if (tileMap.contains(riverCoordinate.position) && tileMap[riverCoordinate.position].hasBottomRiver) sum += 1
if (riverCoordinate.bottomRightOrLeft == RiverCoordinate.BottomRightOrLeft.BottomLeft) {
if (tileMap.contains(riverCoordinate.position) && tileMap[riverCoordinate.position].hasBottomLeftRiver) sum += 1
val bottomLeftTilePosition = riverCoordinate.position.cpy().add(0f, -1f)
if (tileMap.contains(bottomLeftTilePosition) && tileMap[bottomLeftTilePosition].hasBottomRightRiver) sum += 1
} else {
if (tileMap.contains(riverCoordinate.position) && tileMap[riverCoordinate.position].hasBottomRightRiver) sum += 1
val bottomLeftTilePosition = riverCoordinate.position.cpy().add(-1f, 0f)
if (tileMap.contains(bottomLeftTilePosition) && tileMap[bottomLeftTilePosition].hasBottomLeftRiver) sum += 1
}
return sum
}
*/
/** Describes a _Vertex_ on our hexagonal grid via a neighboring hex and clock direction, normalized
* such that always the north-most hex and one of the two clock directions 5 / 7 o'clock are used. */
class RiverCoordinate(val position: Vector2, val bottomRightOrLeft: BottomRightOrLeft) {
class RiverCoordinate(
private val tileMap: TileMap,
private val position: Vector2,
private val bottomRightOrLeft: BottomRightOrLeft
) {
enum class BottomRightOrLeft {
/** 7 O'Clock of the tile */
BottomLeft,
@ -125,43 +101,92 @@ class RiverGenerator(
BottomRight
}
private val x = position.x.toInt()
private val y = position.y.toInt()
// Depending on the tile instance, some of the following will never be used. Tested with lazies: ~2% slower
private val myTile = tileMap[position]
private val myTopLeft = tileMap.getIfTileExistsOrNull(x + 1, y)
private val myBottomLeft = tileMap.getIfTileExistsOrNull(x, y - 1)
private val myTopRight = tileMap.getIfTileExistsOrNull(x, y + 1)
private val myBottomRight = tileMap.getIfTileExistsOrNull(x - 1, y)
private val myBottomCenter = tileMap.getIfTileExistsOrNull(x - 1, y - 1)
/** Lists the three neighboring vertices which have their anchor hex on the map
* (yes some positions on the map's outer border will be included, some not) */
fun getAdjacentPositions(tileMap: TileMap): Sequence<RiverCoordinate> = sequence {
fun getAdjacentPositions(): Sequence<RiverCoordinate> = sequence {
// What's nice is that adjacents are always the OPPOSITE in terms of right-left - rights are adjacent to only lefts, and vice-versa
// This means that a lot of obviously-wrong assignments are simple to spot
val x = position.x.toInt()
val y = position.y.toInt()
if (bottomRightOrLeft == BottomRightOrLeft.BottomLeft) {
yield(RiverCoordinate(position, BottomRightOrLeft.BottomRight)) // same tile, other side
val myTopLeft = tileMap.getIfTileExistsOrNull(x + 1, y)
yield(RiverCoordinate(tileMap, position, BottomRightOrLeft.BottomRight)) // same tile, other side
if (myTopLeft != null)
yield(RiverCoordinate(myTopLeft.position, BottomRightOrLeft.BottomRight)) // tile to MY top-left, take its bottom right corner
val myBottomLeft = tileMap.getIfTileExistsOrNull(x, y - 1)
yield(RiverCoordinate(tileMap, myTopLeft!!.position, BottomRightOrLeft.BottomRight)) // tile to MY top-left, take its bottom right corner
if (myBottomLeft != null)
yield(RiverCoordinate(myBottomLeft.position, BottomRightOrLeft.BottomRight)) // Tile to MY bottom-left, take its bottom right
yield(RiverCoordinate(tileMap, myBottomLeft!!.position, BottomRightOrLeft.BottomRight)) // Tile to MY bottom-left, take its bottom right
} else {
yield(RiverCoordinate(position, BottomRightOrLeft.BottomLeft)) // same tile, other side
val myTopRight = tileMap.getIfTileExistsOrNull(x, y + 1)
yield(RiverCoordinate(tileMap, position, BottomRightOrLeft.BottomLeft)) // same tile, other side
if (myTopRight != null)
yield(RiverCoordinate(myTopRight.position, BottomRightOrLeft.BottomLeft)) // tile to MY top-right, take its bottom left
val myBottomRight = tileMap.getIfTileExistsOrNull(x - 1, y)
yield(RiverCoordinate(tileMap, myTopRight!!.position, BottomRightOrLeft.BottomLeft)) // tile to MY top-right, take its bottom left
if (myBottomRight != null)
yield(RiverCoordinate(myBottomRight.position, BottomRightOrLeft.BottomLeft)) // tile to MY bottom-right, take its bottom left
yield(RiverCoordinate(tileMap, myBottomRight!!.position, BottomRightOrLeft.BottomLeft)) // tile to MY bottom-right, take its bottom left
}
}
/** Lists the three neighboring hexes to this vertex which are on the map */
fun getAdjacentTiles(tileMap: TileMap): Sequence<Tile> = sequence {
val x = position.x.toInt()
val y = position.y.toInt()
yield(tileMap[x, y])
val below = tileMap.getIfTileExistsOrNull(x - 1, y - 1) // tile directly below us,
if (below != null) yield(below)
val leftOrRight = if (bottomRightOrLeft == BottomRightOrLeft.BottomLeft)
tileMap.getIfTileExistsOrNull(x, y - 1) // tile to our bottom-left
else tileMap.getIfTileExistsOrNull(x - 1, y) // tile to our bottom-right
if (leftOrRight != null) yield(leftOrRight)
fun getAdjacentTiles(): Sequence<Tile> = sequence {
yield(myTile)
myBottomCenter?.let { yield(it) } // tile directly below us,
if (bottomRightOrLeft == BottomRightOrLeft.BottomLeft)
myBottomLeft?.let { yield(it) } // tile to our bottom-left
else
myBottomRight?.let { yield(it) } // tile to our bottom-right
}
fun paintTo(newCoordinate: RiverCoordinate, resultingTiles: MutableSet<Tile>?) {
if (newCoordinate.position == position) // same tile, switched right-to-left
paintBottom(resultingTiles)
else if (bottomRightOrLeft == BottomRightOrLeft.BottomRight) {
if (newCoordinate.getAdjacentTiles().contains(myTile)) // moved from our 5 O'Clock to our 3 O'Clock
paintBottomRight(resultingTiles)
else // moved from our 5 O'Clock down in the 5 O'Clock direction - this is the 8 O'Clock river of the tile to our 4 O'Clock!
newCoordinate.paintBottomLeft(resultingTiles)
} else { // bottomRightOrLeft == BottomRightOrLeft.BottomLeft
if (newCoordinate.getAdjacentTiles().contains(myTile)) // moved from our 7 O'Clock to our 9 O'Clock
paintBottomLeft(resultingTiles)
else // moved from our 7 O'Clock down in the 7 O'Clock direction
newCoordinate.paintBottomRight(resultingTiles)
}
}
private fun paintBottom(resultingTiles: MutableSet<Tile>?) {
myTile.hasBottomRiver = true
if (resultingTiles == null) return
resultingTiles.add(myTile)
myBottomCenter?.let { resultingTiles.add(it) }
}
private fun paintBottomLeft(resultingTiles: MutableSet<Tile>?) {
myTile.hasBottomLeftRiver = true
if (resultingTiles == null) return
resultingTiles.add(myTile)
myBottomLeft?.let { resultingTiles.add(it) }
}
private fun paintBottomRight(resultingTiles: MutableSet<Tile>?) {
myTile.hasBottomRightRiver = true
if (resultingTiles == null) return
resultingTiles.add(myTile)
myBottomRight?.let { resultingTiles.add(it) }
}
/** Count edges with a river from this vertex */
@Suppress("unused") // Keep as how-to just in case
fun numberOfConnectedRivers(): Int = sequence {
yield(myTile.hasBottomRiver)
if (bottomRightOrLeft == BottomRightOrLeft.BottomLeft) {
yield(myTile.hasBottomLeftRiver)
yield(myBottomLeft?.hasBottomRightRiver == true)
} else {
yield(myTile.hasBottomRightRiver)
yield(myBottomRight?.hasBottomLeftRiver == true)
}
}.count { it }
}
}

View File

@ -21,17 +21,17 @@ import com.unciv.models.metadata.BaseRuleset
import com.unciv.models.metadata.GameSetupInfo
import com.unciv.models.ruleset.Ruleset
import com.unciv.models.ruleset.RulesetCache
import com.unciv.ui.images.ImageGetter
import com.unciv.ui.screens.mapeditorscreen.tabs.MapEditorOptionsTab
import com.unciv.ui.popups.ConfirmPopup
import com.unciv.ui.components.tilegroups.TileGroup
import com.unciv.ui.screens.basescreen.BaseScreen
import com.unciv.ui.components.input.KeyCharAndCode
import com.unciv.ui.components.input.KeyShortcutDispatcherVeto
import com.unciv.ui.components.input.KeyboardPanningListener
import com.unciv.ui.components.tilegroups.TileGroup
import com.unciv.ui.images.ImageGetter
import com.unciv.ui.images.ImageWithCustomSize
import com.unciv.ui.popups.ConfirmPopup
import com.unciv.ui.popups.ToastPopup
import com.unciv.ui.screens.basescreen.BaseScreen
import com.unciv.ui.screens.basescreen.RecreateOnResize
import com.unciv.ui.screens.mapeditorscreen.tabs.MapEditorOptionsTab
import com.unciv.ui.screens.worldscreen.ZoomButtonPair
import com.unciv.utils.Concurrency
import com.unciv.utils.Dispatcher
@ -47,7 +47,6 @@ import kotlinx.coroutines.Job
//todo Synergy with Civilopedia for drawing loose tiles / terrain icons
//todo left-align everything so a half-open drawer is more useful
//todo combined brush
//todo New function `convertTerrains` is auto-run after rivers the right decision for step-wise generation? Will paintRiverFromTo need the same? Will painting manually need the conversion?
//todo Tooltips for Edit items with info on placeability? Place this info as Brush description? In Expander?
//todo Civilopedia links from edit items by right-click/long-tap?
//todo Mod tab change base ruleset - disableAllCheckboxes - instead some intelligence to leave those mods on that stay compatible?

View File

@ -7,16 +7,17 @@ import com.badlogic.gdx.scenes.scene2d.ui.Cell
import com.badlogic.gdx.scenes.scene2d.ui.Table
import com.unciv.logic.map.BFS
import com.unciv.logic.map.mapgenerator.MapGenerationRandomness
import com.unciv.logic.map.mapgenerator.MapGenerator
import com.unciv.logic.map.mapgenerator.RiverGenerator
import com.unciv.logic.map.tile.Tile
import com.unciv.models.ruleset.Ruleset
import com.unciv.models.translations.tr
import com.unciv.ui.components.input.KeyCharAndCode
import com.unciv.ui.components.TabbedPager
import com.unciv.ui.components.UncivSlider
import com.unciv.ui.components.extensions.addSeparator
import com.unciv.ui.components.input.keyShortcuts
import com.unciv.ui.components.extensions.toLabel
import com.unciv.ui.components.input.KeyCharAndCode
import com.unciv.ui.components.input.keyShortcuts
import com.unciv.ui.images.ImageGetter
import com.unciv.ui.popups.ToastPopup
import com.unciv.ui.screens.basescreen.BaseScreen
@ -211,7 +212,7 @@ class MapEditorEditTab(
riverEndTile = tile
if (riverStartTile != null) return paintRiverFromTo()
}
tilesToHighlight.forEach { editorScreen.highlightTile(it, Color.BLUE) }
for (tileToHighlight in tilesToHighlight) editorScreen.highlightTile(tileToHighlight, Color.BLUE)
}
private fun paintRiverFromTo() {
val resultingTiles = mutableSetOf<Tile>()
@ -219,6 +220,7 @@ class MapEditorEditTab(
try {
val riverGenerator = RiverGenerator(editorScreen.tileMap, randomness, ruleset)
riverGenerator.spawnRiver(riverStartTile!!, riverEndTile!!, resultingTiles)
MapGenerator(ruleset).convertTerrains(resultingTiles)
} catch (ex: Exception) {
Log.error("Exception while generating rivers", ex)
ToastPopup("River generation failed!", editorScreen)
@ -226,7 +228,7 @@ class MapEditorEditTab(
riverStartTile = null
riverEndTile = null
editorScreen.isDirty = true
resultingTiles.forEach { editorScreen.updateAndHighlight(it, Color.SKY) }
for (tile in resultingTiles) editorScreen.updateAndHighlight(tile, Color.SKY)
}
internal fun paintTilesWithBrush(tile: Tile) {
@ -238,12 +240,12 @@ class MapEditorEditTab(
} else {
tile.getTilesInDistance(brushSize - 1)
}
tiles.forEach {
for (tileToPaint in tiles) {
when (brushHandlerType) {
BrushHandlerType.Direct -> directPaintTile(it)
BrushHandlerType.Tile -> paintTile(it)
BrushHandlerType.Road -> roadPaintTile(it)
BrushHandlerType.River -> riverPaintTile(it)
BrushHandlerType.Direct -> directPaintTile(tileToPaint)
BrushHandlerType.Tile -> paintTile(tileToPaint)
BrushHandlerType.Road -> roadPaintTile(tileToPaint)
BrushHandlerType.River -> riverPaintTile(tileToPaint)
else -> {} // other cases can't reach here
}
}
@ -256,19 +258,22 @@ class MapEditorEditTab(
editorScreen.updateAndHighlight(tile)
}
/** Used for rivers - same as directPaintTile but may need to update 10,12 and 2 o'clock neighbor tiles too */
/** Used for rivers - same as [directPaintTile] but may need to update 10,12 and 2 o'clock neighbor tiles too
*
* Note: Unlike [paintRiverFromTo] this does **not** call [MapGenerator.convertTerrains] to allow more freedom.
*/
private fun riverPaintTile(tile: Tile) {
directPaintTile(tile)
tile.neighbors.forEach {
if (it.position.x > tile.position.x || it.position.y > tile.position.y)
editorScreen.updateTile(it)
for (neighbor in tile.neighbors) {
if (neighbor.position.x > tile.position.x || neighbor.position.y > tile.position.y)
editorScreen.updateTile(neighbor)
}
}
// Used for roads - same as paintTile but all neighbors need TileGroup.update too
private fun roadPaintTile(tile: Tile) {
if (!paintTile(tile)) return
tile.neighbors.forEach { editorScreen.updateTile(it) }
for (neighbor in tile.neighbors) editorScreen.updateTile(neighbor)
}
/** apply brush to a single tile */