From a4424d2ab131fac3fbd656c9bc6b781ee3d51590 Mon Sep 17 00:00:00 2001 From: OptimizedForDensity <105244635+OptimizedForDensity@users.noreply.github.com> Date: Mon, 8 Aug 2022 10:13:27 -0400 Subject: [PATCH] Several pathfinding optimizations (#7523) * Slight pathfinding optimization * Cache canReach() * More optimizations * Use hashset instead of two arraylists --- core/src/com/unciv/logic/map/MapUnit.kt | 2 + .../unciv/logic/map/UnitMovementAlgorithms.kt | 133 +++++++++++++++--- 2 files changed, 113 insertions(+), 22 deletions(-) diff --git a/core/src/com/unciv/logic/map/MapUnit.kt b/core/src/com/unciv/logic/map/MapUnit.kt index d89a41c2c0..600c6b4dd4 100644 --- a/core/src/com/unciv/logic/map/MapUnit.kt +++ b/core/src/com/unciv/logic/map/MapUnit.kt @@ -840,6 +840,7 @@ class MapUnit : IsPartOfGameInfoSerialization { } fun endTurn() { + movement.clearPathfindingCache() if (currentMovement > 0 && getTile().improvementInProgress != null && canBuildImprovement(getTile().getTileImprovementInProgress()!!) @@ -886,6 +887,7 @@ class MapUnit : IsPartOfGameInfoSerialization { } fun startTurn() { + movement.clearPathfindingCache() currentMovement = getMaxMovement().toFloat() attacksThisTurn = 0 due = true diff --git a/core/src/com/unciv/logic/map/UnitMovementAlgorithms.kt b/core/src/com/unciv/logic/map/UnitMovementAlgorithms.kt index ed228ae21b..2bcd25fe3b 100644 --- a/core/src/com/unciv/logic/map/UnitMovementAlgorithms.kt +++ b/core/src/com/unciv/logic/map/UnitMovementAlgorithms.kt @@ -9,6 +9,8 @@ import com.unciv.models.ruleset.unique.UniqueType class UnitMovementAlgorithms(val unit: MapUnit) { + private val pathfindingCache = PathfindingCache(unit) + // This function is called ALL THE TIME and should be as time-optimal as possible! private fun getMovementCostBetweenAdjacentTiles( from: TileInfo, @@ -193,9 +195,23 @@ class UnitMovementAlgorithms(val unit: MapUnit) { val damageFreePath = getShortestPath(destination, true) if (damageFreePath.isNotEmpty()) return damageFreePath } + if (unit.baseUnit.isWaterUnit() + && destination.neighbors.none { isUnknownTileWeShouldAssumeToBePassable(it) || it.isWater }) { + // edge case where this unit is a boat and all of the tiles around the destination are + // explored and known to be land so we know a priori that no path exists + pathfindingCache.setShortestPathCache(destination, listOf()) + return listOf() + } + val cachedPath = pathfindingCache.getShortestPathCache(destination) + if (cachedPath.isNotEmpty()) + return cachedPath val currentTile = unit.getTile() - if (currentTile.position == destination) return listOf(currentTile) // edge case that's needed, so that workers will know that they can reach their own tile. *sigh* + if (currentTile.position == destination) { + // edge case that's needed, so that workers will know that they can reach their own tile. *sigh* + pathfindingCache.setShortestPathCache(destination, listOf(currentTile)) + return listOf(currentTile) + } var tilesToCheck = listOf(currentTile) val movementTreeParents = HashMap() // contains a map of "you can get from X to Y in that turn" @@ -204,7 +220,6 @@ class UnitMovementAlgorithms(val unit: MapUnit) { var movementThisTurn = unit.currentMovement var distance = 1 val newTilesToCheck = ArrayList() - val distanceToDestination = HashMap() var considerZoneOfControl = true // only for first distance! val visitedTiles: HashSet = hashSetOf(currentTile) while (true) { @@ -213,47 +228,50 @@ class UnitMovementAlgorithms(val unit: MapUnit) { considerZoneOfControl = false // by then units would have moved around, we don't need to consider untenable futures when it harms performance! } newTilesToCheck.clear() - distanceToDestination.clear() for (tileToCheck in tilesToCheck) { - val distanceToTilesThisTurn = getDistanceToTilesWithinTurn(tileToCheck.position, movementThisTurn, considerZoneOfControl, visitedTiles) + val distanceToTilesThisTurn = if (distance == 1) { + getDistanceToTiles(considerZoneOfControl) // check cache + } + else { + getDistanceToTilesWithinTurn(tileToCheck.position, movementThisTurn, considerZoneOfControl, visitedTiles) + } for (reachableTile in distanceToTilesThisTurn.keys) { // Avoid damaging terrain on first pass if (avoidDamagingTerrain && unit.getDamageFromTerrain(reachableTile) > 0) continue if (reachableTile == destination) { - distanceToDestination[tileToCheck] = distanceToTilesThisTurn[reachableTile]!!.totalDistance - break + val path = mutableListOf(destination) + // Traverse the tree upwards to get the list of tiles leading to the destination + var intermediateTile = tileToCheck + while (intermediateTile != currentTile) { + path.add(intermediateTile) + intermediateTile = movementTreeParents[intermediateTile]!! + } + path.reverse() // and reverse in order to get the list in chronological order + pathfindingCache.setShortestPathCache(destination, path) + return path } else { if (movementTreeParents.containsKey(reachableTile)) continue // We cannot be faster than anything existing... if (!isUnknownTileWeShouldAssumeToBePassable(reachableTile) && - !canMoveTo(reachableTile)) continue // This is a tile that we can't actually enter - either an intermediary tile containing our unit, or an enemy unit/city + !canMoveTo(reachableTile)) continue // This is a tile that we can't actually enter - either an intermediary tile containing our unit, or an enemy unit/city movementTreeParents[reachableTile] = tileToCheck newTilesToCheck.add(reachableTile) } } } - if (distanceToDestination.isNotEmpty()) { - val path = mutableListOf(destination) // Traverse the tree upwards to get the list of tiles leading to the destination, - // Get the tile from which the distance to the final tile in least - - // this is so that when we finally get there, we'll have as many movement points as possible - var intermediateTile = distanceToDestination.minByOrNull { it.value }!!.key - while (intermediateTile != currentTile) { - path.add(intermediateTile) - intermediateTile = movementTreeParents[intermediateTile]!! - } - path.reverse() // and reverse in order to get the list in chronological order - return path + if (newTilesToCheck.isEmpty()) { + // there is NO PATH (eg blocked by enemy units) + pathfindingCache.setShortestPathCache(destination, emptyList()) + return emptyList() } - if (newTilesToCheck.isEmpty()) return emptyList() // there is NO PATH (eg blocked by enemy units) - // add newTilesToCheck to visitedTiles so we do not path over these tiles in a later iteration visitedTiles.addAll(newTilesToCheck) // no need to check tiles that are surrounded by reachable tiles, only need to check the edgemost tiles. // Because anything we can reach from intermediate tiles, can be more easily reached by the edgemost tiles, // since we'll have to pass through an edgemost tile in order to reach the destination anyway - tilesToCheck = newTilesToCheck.filterNot { tile -> tile.neighbors.all { it in newTilesToCheck || it in tilesToCheck } } + tilesToCheck = newTilesToCheck.filterNot { tile -> tile.neighbors.all { it in visitedTiles } } distance++ } @@ -690,7 +708,15 @@ class UnitMovementAlgorithms(val unit: MapUnit) { } - fun getDistanceToTiles(considerZoneOfControl: Boolean = true): PathsToTilesWithinTurn = getDistanceToTilesWithinTurn(unit.currentTile.position, unit.currentMovement, considerZoneOfControl) + fun getDistanceToTiles(considerZoneOfControl: Boolean = true): PathsToTilesWithinTurn { + val cacheResults = pathfindingCache.getDistanceToTiles(considerZoneOfControl) + if (cacheResults != null) { + return cacheResults + } + val distanceToTiles = getDistanceToTilesWithinTurn(unit.currentTile.position, unit.currentMovement, considerZoneOfControl) + pathfindingCache.setDistanceToTiles(considerZoneOfControl, distanceToTiles) + return distanceToTiles + } fun getAerialPathsToCities(): HashMap> { var tilesToCheck = ArrayList() @@ -747,6 +773,69 @@ class UnitMovementAlgorithms(val unit: MapUnit) { return bfs.getReachedTiles() } + fun clearPathfindingCache() = pathfindingCache.clear() + +} + +/** + * Cache for the results of [UnitMovementAlgorithms.getDistanceToTiles] accounting for zone of control. + * [UnitMovementAlgorithms.getDistanceToTiles] is called in numerous places for AI pathfinding so + * being able to skip redundant calculations helps out over a long game (especially with high level + * AI or a big map). Same thing with [UnitMovementAlgorithms.getShortestPath] which is called in + * [UnitMovementAlgorithms.canReach] and in [UnitMovementAlgorithms.headTowards]. Often, the AI will + * see if it can reach a tile using canReach then if it can, it will headTowards it. We can cache + * the result since otherwise this is a redundant calculation that will find the same path. + */ +class PathfindingCache(private val unit: MapUnit) { + private var shortestPathCache = listOf() + private var destination: TileInfo? = null + private val distanceToTilesCache = mutableMapOf() + private var movement = -1f + private var currentTile: TileInfo? = null + + /** Check if the caches are valid (only checking if the unit has moved or consumed movement points; + * the isPlayerCivilization check is performed in the functions because we want isValid() == false + * to have a specific behavior) */ + private fun isValid(): Boolean = (movement == unit.currentMovement) && (unit.getTile() == currentTile) + + fun getShortestPathCache(destination: TileInfo): List { + if (unit.civInfo.isPlayerCivilization()) return listOf() + if (isValid() && this.destination == destination) { + return shortestPathCache + } + return listOf() + } + + fun setShortestPathCache(destination: TileInfo, newShortestPath: List) { + if (unit.civInfo.isPlayerCivilization()) return + if (isValid()) { + shortestPathCache = newShortestPath + this.destination = destination + } + } + + fun getDistanceToTiles(zoneOfControl: Boolean): PathsToTilesWithinTurn? { + if (unit.civInfo.isPlayerCivilization()) return null + if (isValid()) + return distanceToTilesCache[zoneOfControl] + return null + } + + fun setDistanceToTiles(zoneOfControl: Boolean, paths: PathsToTilesWithinTurn) { + if (unit.civInfo.isPlayerCivilization()) return + if (!isValid()) { + clear() // we want to reset the entire cache at this point + } + distanceToTilesCache[zoneOfControl] = paths + } + + fun clear() { + distanceToTilesCache.clear() + movement = unit.currentMovement + currentTile = unit.getTile() + destination = null + shortestPathCache = listOf() + } } class PathsToTilesWithinTurn : LinkedHashMap() {