From cb01bbc1769eb364d4d33b91cbb091188f33ac29 Mon Sep 17 00:00:00 2001 From: Yair Morgenstern Date: Sun, 7 Jan 2024 16:08:30 +0200 Subject: [PATCH] Experimental pathfinding! (#10883) * Experimental pathfinding - still not good enough for main branch * Fixed strange pathfinding and impassible tiles * Fixed weirdest bug in the history of weird bugs - remove() on an object did not remove the object * Avoid damaging and enemy tiles for new pathfinding * Add option for activating experimental movement --- .../jsons/translations/template.properties | 1 + .../map/mapunit/movement/UnitMovement.kt | 136 ++++++++++++++++++ .../com/unciv/models/metadata/GameSettings.kt | 1 + .../unciv/ui/popups/options/GameplayTab.kt | 3 +- .../screens/devconsole/DevConsoleCommand.kt | 4 +- 5 files changed, 142 insertions(+), 3 deletions(-) diff --git a/android/assets/jsons/translations/template.properties b/android/assets/jsons/translations/template.properties index f561776835..7b481d21dd 100644 --- a/android/assets/jsons/translations/template.properties +++ b/android/assets/jsons/translations/template.properties @@ -785,6 +785,7 @@ Automated units can upgrade = Automated units choose promotions = Order trade offers by amount = Ask for confirmation when pressing next turn = +EXPERIMENTAL movement - use at your own risk! = Notifications log max turns = ## Language tab diff --git a/core/src/com/unciv/logic/map/mapunit/movement/UnitMovement.kt b/core/src/com/unciv/logic/map/mapunit/movement/UnitMovement.kt index a2fe4032ae..af14b6c40a 100644 --- a/core/src/com/unciv/logic/map/mapunit/movement/UnitMovement.kt +++ b/core/src/com/unciv/logic/map/mapunit/movement/UnitMovement.kt @@ -2,6 +2,7 @@ package com.unciv.logic.map.mapunit.movement import com.badlogic.gdx.math.Vector2 import com.unciv.Constants +import com.unciv.UncivGame import com.unciv.logic.map.BFS import com.unciv.logic.map.HexMath.getDistance import com.unciv.logic.map.mapunit.MapUnit @@ -9,6 +10,13 @@ import com.unciv.logic.map.tile.Tile import com.unciv.models.UnitActionType import com.unciv.models.ruleset.unique.UniqueType import com.unciv.ui.components.UnitMovementMemoryType +import java.util.TreeSet + + +fun List.toBackwardsCompatiblePath(): List { + val backwardsCompatiblePath = this.reversed().filter { it.totalCost.movementLeft == 0f || it == this.last() } + return backwardsCompatiblePath.map { it.tile } +} class UnitMovement(val unit: MapUnit) { @@ -82,11 +90,139 @@ class UnitMovement(val unit: MapUnit) { return distanceToTiles } + + data class MovementStep(val previousStep: MovementStep?, val tile: Tile, val movementCost:Float, val totalCost: MovementStepTotalCost) + + + data class MovementStepTotalCost(/** Turn 0 means the initial turn */ val turn: Int, val movementLeft: Float):Comparable { + override operator fun compareTo(other: MovementStepTotalCost) = + compareValuesBy(this, other, {it.turn}, {-it.movementLeft}) + } + + /** Problem and solution documented at https://yairm210.medium.com/multi-turn-pathfinding-7136bd0bdaf0 */ + fun getShortestPathNew(destination: Tile, considerZoneOfControl: Boolean = true, + /** For allowing optional avoid of damaging tiles, tiles outside borders, etc */ shouldAvoidTile: (Tile) -> Boolean = {false}, + maxTurns: Int = 25, + ): List { + if (unit.cache.cannotMove) return listOf() + + val startingTile = unit.getTile() + val initialStep = MovementStep(null, startingTile, 0f, MovementStepTotalCost(0, unit.currentMovement)) + + if (startingTile.position == destination) { + // edge case that's needed, so that workers will know that they can reach their own tile. *sigh* + pathfindingCache.setShortestPathCache(destination, listOf(startingTile)) + return listOf(initialStep) + } + + val tileToBestStep = HashMap() // contains a map of "you can get from X to Y in that turn" + tileToBestStep[startingTile] = initialStep + + val tilesToCheck = TreeSet { t: Tile, t2: Tile -> + val tStep = tileToBestStep[t]!! + val t2Step = tileToBestStep[t2]!! + // This last comparitor is REQUIRED otherwise the tree will think that tiles the same distance away are the same and will throw the second one away! + compareValuesBy(tStep, t2Step, {it.totalCost}, {it.tile.aerialDistanceTo(destination)}, {it.tile.position.hashCode()}) + } + + tilesToCheck.add(startingTile) + val canEndTurnInCache = HashMap() + val unitMaxMovement = unit.getMaxMovement().toFloat() + val movementCostCache = HashMap, Float>() + val shouldAvoidTileCache = HashMap() + val canPassThroughCache = HashMap() + + while (tilesToCheck.isNotEmpty()){ + val currentTileToCheck = tilesToCheck.pollFirst()!! + + val currentTileStep = tileToBestStep[currentTileToCheck]!! + + for (neighbor in currentTileToCheck.neighbors){ + if (shouldAvoidTileCache.getOrPut(neighbor){ shouldAvoidTile(neighbor) }) continue + val currentBestStepToNeighbor = tileToBestStep[neighbor] + // If this tile can't beat the current best then no point checking + if (currentBestStepToNeighbor!=null && (currentBestStepToNeighbor.totalCost < currentTileStep.totalCost)) + continue + + if (!canPassThroughCache.getOrPut(neighbor){ canPassThrough(neighbor) }) continue + + val movementBetweenTiles: Float = if (!neighbor.isExplored(unit.civ)) 1f // If we don't know then we just guess it to be 1. + else movementCostCache.getOrPut(currentTileToCheck to neighbor) { + MovementCost.getMovementCostBetweenAdjacentTiles(unit, currentTileToCheck, neighbor, considerZoneOfControl) + } + + val newStep = getNextStep(currentTileStep, neighbor, movementBetweenTiles, canEndTurnInCache, unitMaxMovement) + ?: continue + + + if (neighbor == destination){ + val entirePath = arrayListOf() + var currentStep = newStep + // We do NOT include the origin tile in this list + while (currentStep.previousStep != null){ + entirePath.add(currentStep) + currentStep = currentStep.previousStep!! + } + return entirePath.reversed() + } + + if (currentBestStepToNeighbor == null || + newStep.totalCost < currentBestStepToNeighbor.totalCost) { // We have a winner! + tileToBestStep[neighbor] = newStep + if (newStep.totalCost.movementLeft == 0f && newStep.totalCost.turn == maxTurns) continue // don't schedule further expansion + tilesToCheck.add(neighbor) + } + } + } + + return listOf() // no path + } + + fun getNextStep(currentTileStep:MovementStep, neighbor:Tile, movementBetweenTiles:Float, + canEndTurnInCache:HashMap, unitMaxMovement:Float):MovementStep? { + + fun canEndTurnIn(tile:Tile) = canEndTurnInCache.getOrPut(tile) { unit.movement.canMoveTo(tile) } + + fun Float.normalizeMovementLeft() = if (this > Constants.minimumMovementEpsilon) this else 0f + + val currentTile = currentTileStep.tile + + // We can move directly + if (currentTileStep.totalCost.movementLeft != 0f || canEndTurnIn(currentTile)) { + val newTotalCost = if (currentTileStep.totalCost.movementLeft == 0f) MovementStepTotalCost( + currentTileStep.totalCost.turn + 1, + (unitMaxMovement - movementBetweenTiles).normalizeMovementLeft() + ) + else MovementStepTotalCost(currentTileStep.totalCost.turn, (currentTileStep.totalCost.movementLeft - movementBetweenTiles).normalizeMovementLeft()) + return MovementStep(currentTileStep, neighbor, movementBetweenTiles, newTotalCost) + } + + // Backtracking nonsense - we CANNOT end the turn on the previous tile, AND we have no movement left, that means we need to do some alternate history + val previousStep = currentTileStep.previousStep ?: return null + if (previousStep.totalCost.movementLeft == 0f) return null // We backtracked until a previous end-of-turn and couldn't find any intermediate tiles + if (!canEndTurnIn(previousStep.tile)) return null + // We found somewhere we could have rested - let's do some alternate history! + val currentTileAlternateHistory = MovementStep(currentTileStep.previousStep, currentTile, currentTileStep.movementCost, + MovementStepTotalCost(currentTileStep.previousStep.totalCost.turn+1, (unitMaxMovement - currentTileStep.movementCost).normalizeMovementLeft())) + if (currentTileAlternateHistory.totalCost.movementLeft == 0f) return null // We tried, and even if we rest there we STILL have to stop at current tile... :/ + val newTotalCost = MovementStepTotalCost(currentTileAlternateHistory.totalCost.turn, (currentTileAlternateHistory.totalCost.movementLeft-currentTileStep.movementCost).normalizeMovementLeft()) + return MovementStep(currentTileAlternateHistory, neighbor, movementBetweenTiles, newTotalCost) + } + /** * Does not consider if the [destination] tile can actually be entered, use [canMoveTo] for that. * Returns an empty list if there's no way to get to the destination. */ fun getShortestPath(destination: Tile, avoidDamagingTerrain: Boolean = false): List { + if (UncivGame.Current.settings.experimentalMovement) { + fun shouldAvoidEnemyTile(tile:Tile) = unit.isCivilian() && unit.isAutomated() && tile.isEnemyTerritory(unit.civ) + if (avoidDamagingTerrain){ + val shortestPathWithoutDamagingTiles = getShortestPathNew(destination, + shouldAvoidTile = { shouldAvoidEnemyTile(it) || unit.getDamageFromTerrain(it) > 0 }) + if (shortestPathWithoutDamagingTiles.isNotEmpty()) return shortestPathWithoutDamagingTiles.toBackwardsCompatiblePath() + } + return getShortestPathNew(destination, shouldAvoidTile = ::shouldAvoidEnemyTile).toBackwardsCompatiblePath() + } if (unit.cache.cannotMove) return listOf() // First try and find a path without damaging terrain diff --git a/core/src/com/unciv/models/metadata/GameSettings.kt b/core/src/com/unciv/models/metadata/GameSettings.kt index 1255e2a739..19af82b360 100644 --- a/core/src/com/unciv/models/metadata/GameSettings.kt +++ b/core/src/com/unciv/models/metadata/GameSettings.kt @@ -32,6 +32,7 @@ class GameSettings { var showUnitMovements: Boolean = false var showSettlersSuggestedCityLocations: Boolean = true + var experimentalMovement: Boolean = false var checkForDueUnits: Boolean = true var autoUnitCycle: Boolean = true var singleTapMove: Boolean = false diff --git a/core/src/com/unciv/ui/popups/options/GameplayTab.kt b/core/src/com/unciv/ui/popups/options/GameplayTab.kt index 2096d7497f..ca961509fd 100644 --- a/core/src/com/unciv/ui/popups/options/GameplayTab.kt +++ b/core/src/com/unciv/ui/popups/options/GameplayTab.kt @@ -4,8 +4,8 @@ import com.badlogic.gdx.scenes.scene2d.ui.Table import com.unciv.GUI import com.unciv.logic.civilization.PlayerType import com.unciv.models.metadata.GameSettings -import com.unciv.ui.components.widgets.UncivSlider import com.unciv.ui.components.extensions.toLabel +import com.unciv.ui.components.widgets.UncivSlider import com.unciv.ui.screens.basescreen.BaseScreen fun gameplayTab( @@ -53,6 +53,7 @@ fun gameplayTab( ) { settings.automatedUnitsChoosePromotions = it } optionsPopup.addCheckbox(this, "Order trade offers by amount", settings.orderTradeOffersByAmount) { settings.orderTradeOffersByAmount = it } optionsPopup.addCheckbox(this, "Ask for confirmation when pressing next turn", settings.confirmNextTurn) { settings.confirmNextTurn = it } + optionsPopup.addCheckbox(this, "EXPERIMENTAL movement - use at your own risk!", settings.experimentalMovement, true) { settings.experimentalMovement = it } addNotificationLogMaxTurnsSlider(this, settings, optionsPopup.selectBoxMinWidth) } diff --git a/core/src/com/unciv/ui/screens/devconsole/DevConsoleCommand.kt b/core/src/com/unciv/ui/screens/devconsole/DevConsoleCommand.kt index 8041fac255..fe2a147184 100644 --- a/core/src/com/unciv/ui/screens/devconsole/DevConsoleCommand.kt +++ b/core/src/com/unciv/ui/screens/devconsole/DevConsoleCommand.kt @@ -67,7 +67,7 @@ class ConsoleAction(val format: String, val action: (console: DevConsolePopup, p return getAutocompleteString(lastParam, options) } - fun validateFormat(format: String, params:List){ + private fun validateFormat(format: String, params:List){ val allParams = format.split(" ") val requiredParamsAmount = allParams.count { it.startsWith('<') } val optionalParamsAmount = allParams.count { it.startsWith('[') } @@ -110,7 +110,7 @@ class ConsoleUnitCommands : ConsoleCommandNode { "add" to ConsoleAction("unit add ") { console, params -> val selectedTile = console.getSelectedTile() val civ = console.getCivByName(params[0]) - val baseUnit = console.gameInfo.ruleset.units.values.firstOrNull { it.name.toCliInput() == params[1] } + val baseUnit = console.gameInfo.ruleset.units.values.firstOrNull { it.name.toCliInput() == params[1].toCliInput() } ?: throw ConsoleErrorException("Unknown unit") civ.units.placeUnitNearTile(selectedTile.position, baseUnit) DevConsoleResponse.OK