Connect roads automation (#10631)

* Start on road connect feature.

* Rough UI and tile highlighting

- Highlight visible tiles for selected unit red
-- Maybe change this to all explored tiles
- Move action firing inside WorldMapHolder
- Set begin and end tiles

* Serialize Vector2 instead of Tile

* Add road icon

* Much better UI handling

- Tile highlights go away after choosing a tile
- Added restrictions to allowed tile destination choices.
    - Explored
    - Land
    - Passable
- Added two-tap button

* Refactor part of `onTileClicked` for readability

* Band-aid fix null pointer error

* Add RoadConnection icon

* Tentatively working connect road feature

* AStar search implementation

* AStar connect road automation

* Fix worker getting stuck in city tiles

* Heuristic should be between tiles

* Add heuristic to road connect, remove maxSize limit

* Fix predicates

* Cancel automation when worker is force moved off path

* Change valid/highlighted tiles to be friendly or neutral

* Put log back the way it was

* Fix behavior when kicked off path

* Worker no longer wastes movement points

* Workers will progress multiple tiles at a time towards the next build destination.

* Respect civs with certain tiles as roads

* Refractor ForceAutomateRoadConnection -> AutomateRoadConnection

* Connect road UI button only shows for units with UniqueType.BuildImprovements

* Connect road UI button only show when road tech is unlocked

* Add wagon sound

* Fix destination icon, add KeyboardBinding to 'c'

* UI highlight connect road path tiles orange

* Downsample wagon.mp3

* Apply migration patch, idiomatic sequence processing

* Add notifications on success and failure

* Extract movement cost function to be reusable

* Refactor road pathfinding into MapPathing.kt

* Make pathing calls more general for future extendability

* Add UI road connection tile path preview

* Keep road path highlighting when routing to a city tile

* Adjust road pathing cost function

* Path includes pillaged roads

* Repair pillaged roads along path

* Valid road path tiles now include all passable tiles (open borders)
This commit is contained in:
Will Allen
2023-12-07 01:15:12 -06:00
committed by GitHub
parent 4a570bcd4f
commit 8363078371
19 changed files with 730 additions and 125 deletions

View File

@ -34,6 +34,9 @@ object CivilianUnitAutomation {
if (unit.hasUnique(UniqueType.FoundCity))
return SpecificUnitAutomation.automateSettlerActions(unit, tilesWhereWeWillBeCaptured)
if(unit.isAutomatingRoadConnection())
return unit.civ.getWorkerAutomation().automateConnectRoad(unit, tilesWhereWeWillBeCaptured)
if (unit.cache.hasUniqueToBuildImprovements)
return unit.civ.getWorkerAutomation().automateWorkerAction(unit, tilesWhereWeWillBeCaptured)

View File

@ -10,8 +10,10 @@ import com.unciv.logic.automation.unit.UnitAutomation.wander
import com.unciv.logic.city.City
import com.unciv.logic.civilization.Civilization
import com.unciv.logic.civilization.NotificationCategory
import com.unciv.logic.civilization.NotificationIcon
import com.unciv.logic.map.BFS
import com.unciv.logic.map.HexMath
import com.unciv.logic.map.MapPathing
import com.unciv.logic.map.mapunit.MapUnit
import com.unciv.logic.map.tile.RoadStatus
import com.unciv.logic.map.tile.Tile
@ -55,6 +57,9 @@ class WorkerAutomation(
RoadStatus.None
else civInfo.tech.getBestRoadAvailable()
/** Same as above, but ignores the option */
private val actualBestRoadAvailable: RoadStatus = civInfo.tech.getBestRoadAvailable()
/** Civ-wide list of unconnected Cities, sorted by closest to capital first */
private val citiesThatNeedConnecting: List<City> by lazy {
val result = civInfo.cities.asSequence()
@ -115,6 +120,132 @@ class WorkerAutomation(
///////////////////////////////////////// Methods /////////////////////////////////////////
/**
* Automate the process of connecting a road between two points.
* Current thoughts:
* Will be a special case of MapUnit.automated property
* Unit has new attributes startTile endTile
* - We will progress towards the end path sequentially, taking absolute least distance w/o regard for movement cost
* - Cancel upon risk of capture
* - Cancel upon blocked
* - End automation upon finish
*/
// TODO: Caching
// TODO: Hide the automate road button if road is not unlocked
fun automateConnectRoad(unit: MapUnit, tilesWhereWeWillBeCaptured: Set<Tile>){
if (actualBestRoadAvailable == RoadStatus.None) return
var currentTile = unit.getTile()
/** Reset side effects from automation, return worker to non-automated state*/
fun stopAndCleanAutomation(){
unit.automated = false
unit.action = null
unit.automatedRoadConnectionDestination = null
unit.automatedRoadConnectionPath = null
currentTile.stopWorkingOnImprovement()
}
/** Conditions for whether it is acceptable to build a road on this tile */
fun shouldBuildRoadOnTile(tile: Tile): Boolean {
return !tile.isCityCenter() // Can't build road on city tiles
// Special case for civs that treat forest/jungles as roads (inside their territory). We shouldn't build if railroads aren't unlocked.
&& !(tile.hasConnection(unit.civ) && actualBestRoadAvailable == RoadStatus.Road)
// Build (upgrade) if possible
&& tile.roadStatus != actualBestRoadAvailable
// Build if the road is pillaged
|| tile.roadIsPillaged
}
val destinationTile = unit.civ.gameInfo.tileMap[unit.automatedRoadConnectionDestination!!]
var pathToDest: List<Vector2>? = unit.automatedRoadConnectionPath
// The path does not exist, create it
if (pathToDest == null) {
val foundPath: List<Tile>? = MapPathing.getRoadPath(unit, currentTile, destinationTile)
if (foundPath == null) {
Log.debug("WorkerAutomation: ${unit.label()} -> connect road failed")
stopAndCleanAutomation()
unit.civ.addNotification("Connect road failed!", currentTile.position, NotificationCategory.Units, NotificationIcon.Construction)
return
} else {
pathToDest = foundPath // Convert to a list of positions for serialization
.map { it.position }
unit.automatedRoadConnectionPath = pathToDest
debug("WorkerAutomation: ${unit.label()} -> found connect road path to destination tile: %s, %s", destinationTile, pathToDest)
}
}
val currTileIndex = pathToDest.indexOf(currentTile.position)
// The worker was somehow moved off its path, cancel the action
if (currTileIndex == -1) {
Log.debug("${unit.label()} -> was moved off its connect road path. Operation cancelled.")
stopAndCleanAutomation()
unit.civ.addNotification("Connect road cancelled!", currentTile.position, NotificationCategory.Units, unit.name)
return
}
if (unit.currentMovement > 0) {
/* Can not build a road on this tile, try to move on.
* The worker should search for the next furthest tile in the path that:
* - It can move to
* - Can be improved/upgraded
* */
if (!shouldBuildRoadOnTile(currentTile)) {
when {
currTileIndex < pathToDest.size - 1 -> { // Try to move to the next tile in the path
val tileMap = unit.civ.gameInfo.tileMap
var nextTile: Tile = currentTile
// Create a new list with tiles where the index is greater than currTileIndex
val futureTiles = pathToDest.asSequence()
.dropWhile { it != unit.currentTile.position }
.drop(1)
.map { tileMap[it] }
for (futureTile in futureTiles){ // Find the furthest tile we can reach in this turn, move to, and does not have a road
if (unit.movement.canReachInCurrentTurn(futureTile) && unit.movement.canMoveTo(futureTile)) { // We can at least move to this tile
nextTile = futureTile
if (shouldBuildRoadOnTile(futureTile)) {
break // Stop on this tile
}
}
}
unit.movement.moveToTile(nextTile)
currentTile = unit.getTile()
}
currTileIndex == pathToDest.size - 1 -> { // The last tile in the path is unbuildable or has a road.
stopAndCleanAutomation()
unit.civ.addNotification("Connect road completed!", currentTile.position, NotificationCategory.Units, unit.name)
return
}
}
}
}
// We need to check current movement again after we've (potentially) moved
if (unit.currentMovement > 0) {
// Repair pillaged roads first
if(currentTile.roadStatus != RoadStatus.None && currentTile.roadIsPillaged){
currentTile.setRepaired()
return
}
if (shouldBuildRoadOnTile(currentTile) && currentTile.improvementInProgress != actualBestRoadAvailable.name) {
val improvement = actualBestRoadAvailable.improvement(ruleSet)!!
currentTile.startWorkingOnImprovement(improvement, civInfo, unit)
return
}
}
}
/**
* Automate one Worker - decide what to do and where, move, start or continue work.
*/

View File

@ -0,0 +1,185 @@
package com.unciv.logic.map
import com.unciv.logic.map.tile.Tile
import java.util.PriorityQueue
data class TilePriority(val tile: Tile, val priority: Float)
/**
* AStar is an implementation of the A* search algorithm, commonly used for finding the shortest path
* in a weighted graph.
*
* The algorithm maintains a priority queue of paths while exploring the graph, expanding paths in
* order of their estimated total cost from the start node to the goal node, factoring in both the
* cost so far and an estimated cost (heuristic) to the goal.
*
* @param startingPoint The initial tile where the search begins.
* @param predicate A function that determines if a tile should be considered for further exploration.
* For instance, it might return `true` for passable tiles and `false` for obstacles.
* @param cost A function that takes two tiles (fromTile, toTile) as input and returns the cost
* of moving from 'fromTile' to 'toTile' as a Float. This allows for flexible cost
* calculations based on different criteria, such as distance, terrain, or other
* custom logic defined by the user.
* @param heuristic A function that estimates the cost from a given tile to the goal. For the A*
* algorithm to guarantee the shortest path, this heuristic must be admissible,
* meaning it should never overestimate the actual cost to reach the goal.
* You can set this to `{ tile -> 0 }` for Djikstra's algorithm.
*
* Usage Example:
* ```
* val unit: MapUnit = ...
* val aStarSearch = AStar(startTile,
* { tile -> tile.isPassable },
* { from: Tile, to: Tile -> MovementCost.getMovementCostBetweenAdjacentTiles(unit, from, to)},
* { tile -> <custom heuristic> })
*
* val path = aStarSearch.findPath(goalTile)
* ```
*/
class AStar(
val startingPoint: Tile,
private val predicate : (Tile) -> Boolean,
private val cost: (Tile, Tile) -> Float,
private val heuristic : (Tile, Tile) -> Float,
) {
/** Maximum number of tiles to search */
var maxSize = Int.MAX_VALUE
/** Cache for storing the costs */
private val costCache = mutableMapOf<Pair<Tile,Tile>, Float>()
/**
* Retrieves the cost of moving to a given tile, utilizing a cache to improve efficiency.
* If the cost for a tile is not already cached, it computes the cost using the provided cost function and stores it in the cache.
*
* @param from The source tile.
* @param to The destination tile.
* @return The cost of moving between the tiles.
*/
private fun getCost(from: Tile, to: Tile): Float {
return costCache.getOrPut(Pair(from, to)) { cost(from, to) }
}
/**
* Comparator for the priority queue used in the A* algorithm.
* It compares two `TilePriority` objects based on their priority value,
* ensuring that tiles with lower estimated total costs are given precedence in the queue.
*/
private val tilePriorityComparator = Comparator<TilePriority> { tp1, tp2 ->
tp1.priority.compareTo(tp2.priority)
}
/**
* Frontier priority queue for managing the tiles to be checked.
* Tiles are ordered based on their priority, determined by the cumulative cost so far and the heuristic estimate to the goal.
*/
private val tilesToCheck = PriorityQueue(27, tilePriorityComparator)
/**
* A map where each tile reached during the search points to its parent tile.
* This map is used to reconstruct the path once the destination is reached.
*/
private val tilesReached = HashMap<Tile, Tile>()
/**
* A map holding the cumulative cost to reach each tile.
* This is used to calculate the most efficient path to a tile during the search process.
*/
private val cumulativeTileCost = HashMap<Tile, Float>()
init {
tilesToCheck.add(TilePriority(startingPoint, 0f))
tilesReached[startingPoint] = startingPoint
cumulativeTileCost[startingPoint] = 0f
}
/**
* Continues the search process until there are no more tiles left to check.
*/
fun stepToEnd() {
while (!hasEnded())
nextStep()
}
/**
* Continues the search process until either the specified destination is reached or there are no more tiles left to check.
*
* @param destination The destination tile to reach.
* @return This AStar instance, allowing for method chaining.
*/
fun stepUntilDestination(destination: Tile): AStar {
while (!tilesReached.containsKey(destination) && !hasEnded())
nextStep()
return this
}
/**
* Processes one step in the A* algorithm, expanding the search from the current tile to its neighbors.
* It updates the search structures accordingly, considering both the cost so far and the heuristic estimate.
*
* If the maximum size is reached or no more tiles are available, this method will do nothing.
*/
fun nextStep() {
if (tilesReached.size >= maxSize) { tilesToCheck.clear(); return }
val currentTile = tilesToCheck.poll()?.tile ?: return
for (neighbor in currentTile.neighbors) {
val newCost: Float = cumulativeTileCost[currentTile]!! + getCost(currentTile, neighbor)
if (predicate(neighbor) &&
(!cumulativeTileCost.containsKey(neighbor)
|| newCost < (cumulativeTileCost[neighbor] ?: Float.MAX_VALUE))
){
cumulativeTileCost[neighbor] = newCost
val priority: Float = newCost + heuristic(currentTile, neighbor)
tilesToCheck.add(TilePriority(neighbor, priority))
tilesReached[neighbor] = currentTile
}
}
}
/**
* Constructs a sequence representing the path from the given destination tile back to the starting point.
* If the destination has not been reached, the sequence will be empty.
*
* @param destination The destination tile to trace the path to.
* @return A sequence of tiles representing the path from the destination to the starting point.
*/
fun getPathTo(destination: Tile): Sequence<Tile> = sequence {
var currentNode = destination
while (true) {
val parent = tilesReached[currentNode] ?: break // destination is not in our path
yield(currentNode)
if (currentNode == startingPoint) break
currentNode = parent
}
}
/**
* Checks if there are no more tiles to be checked in the search.
*
* @return True if the search has ended, otherwise false.
*/
fun hasEnded() = tilesToCheck.isEmpty()
/**
* Determines if a specific tile has been reached during the search.
*
* @param tile The tile to check.
* @return True if the tile has been reached, otherwise false.
*/
fun hasReachedTile(tile: Tile) = tilesReached.containsKey(tile)
/**
* Retrieves all tiles that have been reached so far in the search.
*
* @return A set of tiles that have been reached.
*/
fun getReachedTiles(): MutableSet<Tile> = tilesReached.keys
/**
* Provides the number of tiles that have been reached so far in the search.
*
* @return The count of tiles reached.
*/
fun size() = tilesReached.size
}

View File

@ -0,0 +1,107 @@
package com.unciv.logic.map
import com.unciv.logic.map.mapunit.MapUnit
import com.unciv.logic.map.tile.RoadStatus
import com.unciv.logic.map.tile.Tile
import com.unciv.utils.Log
//TODO: Eventually, all path generation in the game should be moved into here.
object MapPathing {
/**
* We prefer the worker to prioritize paths connected by existing roads. If a tile has a road, but the civ has the ability
* to upgrade it to a railroad, we consider it to be a railroad for pathing since it will be upgraded.
* Otherwise, we set every tile to have equal value since building a road on any of them makes the original movement cost irrelevant.
*/
private fun roadPreferredMovementCost(unit: MapUnit, from: Tile, to: Tile): Float{
// hasRoadConnection accounts for civs that treat jungle/forest as roads
// Ignore road over river penalties.
val areConnectedByRoad = from.hasRoadConnection(unit.civ, mustBeUnpillaged = false) && to.hasRoadConnection(unit.civ, mustBeUnpillaged = false)
if (areConnectedByRoad){
// If the civ has railroad technology, consider roads as railroads since they will be upgraded
if (unit.civ.tech.getBestRoadAvailable() == RoadStatus.Railroad){
return RoadStatus.Railroad.movement
}else{
return unit.civ.tech.movementSpeedOnRoads
}
}
val areConnectedByRailroad = from.hasRailroadConnection(mustBeUnpillaged = false) && to.hasRailroadConnection(mustBeUnpillaged = false)
if (areConnectedByRailroad)
return RoadStatus.Railroad.movement
return 1f
}
fun isValidRoadPathTile(unit: MapUnit, tile: Tile): Boolean {
return tile.isLand
&& !tile.isImpassible()
&& unit.civ.hasExplored(tile)
&& tile.canCivPassThrough(unit.civ)
}
/**
* Calculates the path for a road construction between two tiles.
*
* This function uses the A* search algorithm to find an optimal path for road construction between two specified tiles.
*
* @param unit The unit that will construct the road.
* @param startTile The starting tile of the path.
* @param endTile The destination tile of the path.
* @return A sequence of tiles representing the path from startTile to endTile, or null if no valid path is found.
*/
fun getRoadPath(unit: MapUnit, startTile: Tile, endTile: Tile): List<Tile>?{
return getPath(unit,
startTile,
endTile,
::isValidRoadPathTile,
::roadPreferredMovementCost,
{_, _, _ -> 0f}
)
}
/**
* Calculates the path between two tiles.
*
* This function uses the A* search algorithm to find an optimal path two specified tiles on a game map.
*
* @param unit The unit for which the path is being calculated.
* @param startTile The tile from which the pathfinding begins.
* @param endTile The destination tile for the pathfinding.
* @param predicate A function that takes a MapUnit and a Tile, returning a Boolean. This function is used to determine whether a tile can be traversed by the unit.
* @param cost A function that calculates the cost of moving from one tile to another.
* It takes a MapUnit, a 'from' Tile, and a 'to' Tile, returning a Float value representing the cost.
* @param heuristic A function that estimates the cost from a given tile to the end tile.
* It takes a MapUnit, a 'from' Tile, and a 'to' Tile, returning a Float value representing the heuristic cost estimate.
* @return A list of tiles representing the path from the startTile to the endTile. Returns null if no valid path is found.
*/
private fun getPath(unit: MapUnit,
startTile: Tile,
endTile: Tile,
predicate: (MapUnit, Tile) -> Boolean,
cost: (MapUnit, Tile, Tile) -> Float,
heuristic: (MapUnit, Tile, Tile) -> Float): List<Tile>? {
val astar = AStar(startTile,
{ tile -> predicate(unit, tile) },
{ from, to -> cost(unit, from, to)},
{ from, to -> heuristic(unit, from, to) })
while (true) {
if (astar.hasEnded()) {
// We failed to find a path
Log.debug("getRoadPath failed at AStar search size ${astar.size()}")
return null
}
if (!astar.hasReachedTile(endTile)) {
astar.nextStep()
continue
}
// Found a path.
return astar.getPathTo(endTile)
.toList()
.reversed()
}
}
}

View File

@ -103,9 +103,14 @@ class MapUnit : IsPartOfGameInfoSerialization {
var currentMovement: Float = 0f
var health: Int = 100
var action: String? = null // work, automation, fortifying, I dunno what.
// work, automation, fortifying, ...
// Connect roads implies automated is true. It is specified by the action type.
var action: String? = null
var automated: Boolean = false
var automatedRoadConnectionDestination: Vector2? = null
var automatedRoadConnectionPath: List<Vector2>? = null
@Transient
var showAdditionalActions: Boolean = false
@ -180,6 +185,8 @@ class MapUnit : IsPartOfGameInfoSerialization {
toReturn.health = health
toReturn.action = action
toReturn.automated = automated
toReturn.automatedRoadConnectionDestination = automatedRoadConnectionDestination
toReturn.automatedRoadConnectionPath = automatedRoadConnectionPath
toReturn.attacksThisTurn = attacksThisTurn
toReturn.turnsFortified = turnsFortified
toReturn.promotions = promotions.clone()
@ -381,6 +388,8 @@ class MapUnit : IsPartOfGameInfoSerialization {
fun isMoving() = action?.startsWith("moveTo") == true
fun isAutomated() = automated
fun isAutomatingRoadConnection() = action == UnitActionType.ConnectRoad.value
fun isExploring() = action == UnitActionType.Explore.value
fun isPreparingParadrop() = action == UnitActionType.Paradrop.value
fun isPreparingAirSweep() = action == UnitActionType.AirSweep.value

View File

@ -246,7 +246,7 @@ class UnitMovement(val unit: MapUnit) {
return getShortestPath(destination).any()
}
private fun canReachInCurrentTurn(destination: Tile): Boolean {
fun canReachInCurrentTurn(destination: Tile): Boolean {
if (unit.cache.cannotMove) return destination == unit.getTile()
if (unit.baseUnit.movesLikeAirUnits())
return unit.currentTile.aerialDistanceTo(destination) <= unit.getMaxMovementForAirUnits()

View File

@ -671,7 +671,19 @@ open class Tile : IsPartOfGameInfoSerialization {
}
fun hasConnection(civInfo: Civilization) =
getUnpillagedRoad() != RoadStatus.None || forestOrJungleAreRoads(civInfo)
getUnpillagedRoad() != RoadStatus.None || forestOrJungleAreRoads(civInfo)
fun hasRoadConnection(civInfo: Civilization, mustBeUnpillaged: Boolean) =
if (mustBeUnpillaged)
(getUnpillagedRoad() == RoadStatus.Road) || forestOrJungleAreRoads(civInfo)
else
roadStatus == RoadStatus.Road || forestOrJungleAreRoads(civInfo)
fun hasRailroadConnection(mustBeUnpillaged: Boolean) =
if (mustBeUnpillaged)
getUnpillagedRoad() == RoadStatus.Railroad
else
roadStatus == RoadStatus.Railroad
private fun forestOrJungleAreRoads(civInfo: Civilization) =

View File

@ -99,6 +99,8 @@ enum class UnitActionType(
{ ImageGetter.getUnitActionPortrait("Swap") }, false),
Automate("Automate",
{ ImageGetter.getUnitActionPortrait("Automate") }),
ConnectRoad("Connect road",
{ ImageGetter.getUnitActionPortrait("RoadConnection") }),
StopAutomation("Stop automation",
{ ImageGetter.getUnitActionPortrait("Stop") }, false),
StopMovement("Stop movement",

View File

@ -88,6 +88,7 @@ enum class KeyboardBinding(
// here as it will not be guaranteed to already be fully initialized.
SwapUnits(Category.UnitActions,"Swap units", 'y'),
Automate(Category.UnitActions, 'm'),
ConnectRoad(Category.UnitActions, "Connect road", 'c'),
StopAutomation(Category.UnitActions,"Stop automation", 'm'),
StopMovement(Category.UnitActions,"Stop movement", '.'),
ShowUnitDestination(Category.UnitActions, "Show unit destination", 'j'),

View File

@ -15,17 +15,20 @@ import com.badlogic.gdx.utils.Align
import com.unciv.Constants
import com.unciv.UncivGame
import com.unciv.logic.automation.unit.CityLocationTileRanker
import com.unciv.logic.automation.unit.UnitAutomation
import com.unciv.logic.battle.AttackableTile
import com.unciv.logic.battle.Battle
import com.unciv.logic.battle.MapUnitCombatant
import com.unciv.logic.battle.TargetHelper
import com.unciv.logic.city.City
import com.unciv.logic.civilization.Civilization
import com.unciv.logic.map.MapPathing
import com.unciv.logic.map.TileMap
import com.unciv.logic.map.mapunit.MapUnit
import com.unciv.logic.map.mapunit.movement.UnitMovement
import com.unciv.logic.map.tile.Tile
import com.unciv.models.UncivSound
import com.unciv.models.UnitActionType
import com.unciv.models.ruleset.unique.LocalUniqueCache
import com.unciv.models.ruleset.unique.UniqueType
import com.unciv.ui.audio.SoundPlayer
@ -39,6 +42,7 @@ import com.unciv.ui.components.extensions.surroundWithCircle
import com.unciv.ui.components.extensions.toLabel
import com.unciv.ui.components.input.ActivationTypes
import com.unciv.ui.components.input.KeyCharAndCode
import com.unciv.ui.components.input.KeyboardBinding
import com.unciv.ui.components.input.keyShortcuts
import com.unciv.ui.components.input.onActivation
import com.unciv.ui.components.input.onClick
@ -70,6 +74,8 @@ class WorldMapHolder(
private val unitMovementPaths: HashMap<MapUnit, ArrayList<Tile>> = HashMap()
private val unitConnectRoadPaths: HashMap<MapUnit, List<Tile>> = HashMap()
private lateinit var tileGroupMap: TileGroupMap<WorldTileGroup>
lateinit var currentTileSetStrings: TileSetStrings
@ -112,6 +118,10 @@ class WorldMapHolder(
// Contains the data required to draw a "swap with" button
class SwapWithButtonDto(val unit: MapUnit, val tile: Tile) : ButtonDto
// Contains the data required to draw a "connect road" button
class ConnectRoadButtonDto(val unit: MapUnit, val tile: Tile) : ButtonDto
internal fun addTiles() {
val tileSetStrings = TileSetStrings()
currentTileSetStrings = tileSetStrings
@ -153,38 +163,46 @@ class WorldMapHolder(
removeUnitActionOverlay()
selectedTile = tile
unitMovementPaths.clear()
unitConnectRoadPaths.clear()
val unitTable = worldScreen.bottomUnitTable
val previousSelectedUnits = unitTable.selectedUnits.toList() // create copy
val previousSelectedCity = unitTable.selectedCity
val previousSelectedUnitIsSwapping = unitTable.selectedUnitIsSwapping
val previousSelectedUnitIsConnectingRoad = unitTable.selectedUnitIsConnectingRoad
unitTable.tileSelected(tile)
val newSelectedUnit = unitTable.selectedUnit
if (previousSelectedCity != null && tile != previousSelectedCity.getCenterTile())
tileGroups[previousSelectedCity.getCenterTile()]!!.layerCityButton.moveUp()
if (previousSelectedUnits.isNotEmpty() && previousSelectedUnits.any { it.getTile() != tile }
&& worldScreen.isPlayersTurn
&& (
if (previousSelectedUnitIsSwapping)
previousSelectedUnits.first().movement.canUnitSwapTo(tile)
else
previousSelectedUnits.any {
it.movement.canMoveTo(tile) ||
it.movement.isUnknownTileWeShouldAssumeToBePassable(tile) && !it.baseUnit.movesLikeAirUnits()
}
) && previousSelectedUnits.any { !it.isPreparingAirSweep()}) {
if (previousSelectedUnitIsSwapping) {
addTileOverlaysWithUnitSwapping(previousSelectedUnits.first(), tile)
}
else {
// this can take a long time, because of the unit-to-tile calculation needed, so we put it in a different thread
addTileOverlaysWithUnitMovement(previousSelectedUnits, tile)
if (previousSelectedUnits.isNotEmpty()) {
val isTileDifferent = previousSelectedUnits.any { it.getTile() != tile }
val isPlayerTurn = worldScreen.isPlayersTurn
val existsUnitNotPreparingAirSweep = previousSelectedUnits.any { !it.isPreparingAirSweep() }
// Todo: valid tiles for actions should be handled internally, not here.
val canPerformActionsOnTile = if (previousSelectedUnitIsSwapping) {
previousSelectedUnits.first().movement.canUnitSwapTo(tile)
} else if(previousSelectedUnitIsConnectingRoad) {
true
} else {
previousSelectedUnits.any {
it.movement.canMoveTo(tile) ||
(it.movement.isUnknownTileWeShouldAssumeToBePassable(tile) && !it.baseUnit.movesLikeAirUnits())
}
}
} else addTileOverlays(tile) // no unit movement but display the units in the tile etc.
if (isTileDifferent && isPlayerTurn && canPerformActionsOnTile && existsUnitNotPreparingAirSweep) {
when {
previousSelectedUnitIsSwapping -> addTileOverlaysWithUnitSwapping(previousSelectedUnits.first(), tile)
previousSelectedUnitIsConnectingRoad -> addTileOverlaysWithUnitRoadConnecting(previousSelectedUnits.first(), tile)
else -> addTileOverlaysWithUnitMovement(previousSelectedUnits, tile) // Long-running task
}
}
} else {
addTileOverlays(tile) // no unit movement but display the units in the tile etc.
}
if (newSelectedUnit == null || newSelectedUnit.isCivilian()) {
val unitsInTile = selectedTile!!.getUnits()
@ -204,6 +222,7 @@ class WorldMapHolder(
removeUnitActionOverlay()
selectedTile = tile
unitMovementPaths.clear()
unitConnectRoadPaths.clear()
if (!worldScreen.canChangeState) return
// Concurrency might open up a race condition window - if worldScreen.shouldUpdate is on too
@ -327,6 +346,24 @@ class WorldMapHolder(
removeUnitActionOverlay()
}
private fun connectRoadToTargetTile(selectedUnit: MapUnit, targetTile: Tile) {
selectedUnit.automatedRoadConnectionDestination = targetTile.position
selectedUnit.automatedRoadConnectionPath = null
selectedUnit.action = UnitActionType.ConnectRoad.value
selectedUnit.automated = true
UnitAutomation.automateUnitMoves(selectedUnit)
SoundPlayer.play(UncivSound("wagon"))
worldScreen.shouldUpdate = true
removeUnitActionOverlay()
// Make highlighting go away
worldScreen.bottomUnitTable.selectedUnitIsConnectingRoad = false
}
private fun addTileOverlaysWithUnitMovement(selectedUnits: List<MapUnit>, tile: Tile) {
Concurrency.run("TurnsToGetThere") {
/** LibGdx sometimes has these weird errors when you try to edit the UI layout from 2 separate threads.
@ -397,6 +434,27 @@ class WorldMapHolder(
worldScreen.shouldUpdate = true
}
private fun addTileOverlaysWithUnitRoadConnecting(selectedUnit: MapUnit, tile: Tile){
Concurrency.run("ConnectRoad") {
val validTile = tile.isLand &&
!tile.isImpassible() &&
selectedUnit.civ.hasExplored(tile)
if (validTile) {
val roadPath: List<Tile>? = MapPathing.getRoadPath(selectedUnit, selectedUnit.currentTile, tile)
launchOnGLThread {
if (roadPath == null) { // give the regular tile overlays with no road connection
addTileOverlays(tile)
worldScreen.shouldUpdate = true
return@launchOnGLThread
}
unitConnectRoadPaths[selectedUnit] = roadPath
val connectRoadButtonDto = ConnectRoadButtonDto(selectedUnit, tile)
addTileOverlays(tile, connectRoadButtonDto)
worldScreen.shouldUpdate = true
}
}
}
}
private fun addTileOverlays(tile: Tile, buttonDto: ButtonDto? = null) {
val table = Table().apply { defaults().pad(10f) }
if (buttonDto != null && worldScreen.canChangeState)
@ -404,6 +462,7 @@ class WorldMapHolder(
when (buttonDto) {
is MoveHereButtonDto -> getMoveHereButton(buttonDto)
is SwapWithButtonDto -> getSwapWithButton(buttonDto)
is ConnectRoadButtonDto -> getConnectRoadButton(buttonDto)
else -> null
}
)
@ -495,6 +554,26 @@ class WorldMapHolder(
return swapWithButton
}
private fun getConnectRoadButton(dto: ConnectRoadButtonDto): Group {
val connectRoadButton = Group().apply { width = buttonSize;height = buttonSize; }
connectRoadButton.addActor(ImageGetter.getUnitActionPortrait("RoadConnection", buttonSize * 0.8f).apply {
center(connectRoadButton)
}
)
val unitIcon = UnitGroup(dto.unit, smallerCircleSizes)
unitIcon.y = buttonSize - unitIcon.height
connectRoadButton.addActor(unitIcon)
connectRoadButton.onActivation(UncivSound.Silent) {
connectRoadToTargetTile(dto.unit, dto.tile)
}
connectRoadButton.keyShortcuts.add(KeyboardBinding.ConnectRoad)
return connectRoadButton
}
fun addOverlayOnTileGroup(group: TileGroup, actor: Actor) {
@ -575,6 +654,10 @@ class WorldMapHolder(
unitTable.selectedCity != null -> {
val city = unitTable.selectedCity!!
updateBombardableTilesForSelectedCity(city)
// We still want to show road paths to the selected city if they are present
if (unitTable.selectedUnitIsConnectingRoad){
updateTilesForSelectedUnit(unitTable.selectedUnits[0])
}
}
unitTable.selectedUnit != null -> {
for (unit in unitTable.selectedUnits) {
@ -617,6 +700,7 @@ class WorldMapHolder(
}
}
// Z-Layer: 0
// Highlight suitable tiles in swapping-mode
if (worldScreen.bottomUnitTable.selectedUnitIsSwapping) {
val unitSwappableTiles = unit.movement.getUnitSwappableTiles()
@ -625,7 +709,29 @@ class WorldMapHolder(
tileGroups[tile]!!.layerOverlay.showHighlight(swapUnitsTileOverlayColor,
if (UncivGame.Current.settings.singleTapMove) 0.7f else 0.3f)
}
// In swapping-mode don't want to show other overlays
// In swapping-mode we don't want to show other overlays
return
}
// Z-Layer: 0
// Highlight suitable tiles in road connecting mode
if (worldScreen.bottomUnitTable.selectedUnitIsConnectingRoad){
val validTiles = unit.civ.gameInfo.tileMap.tileList.filter {
MapPathing.isValidRoadPathTile(unit, it)
}
unit.civ.gameInfo.civilizations
val connectRoadTileOverlayColor = Color.RED
for (tile in validTiles) {
tileGroups[tile]!!.layerOverlay.showHighlight(connectRoadTileOverlayColor, 0.3f)
}
if (unitConnectRoadPaths.containsKey(unit)) {
for (tile in unitConnectRoadPaths[unit]!!) {
tileGroups[tile]!!.layerOverlay.showHighlight(Color.ORANGE, 0.8f)
}
}
// In road connecting mode we don't want to show other overlays
return
}
@ -636,6 +742,7 @@ class WorldMapHolder(
val nukeBlastRadius = if (unit.baseUnit.isNuclearWeapon() && selectedTile != null && selectedTile != unit.getTile())
unit.getNukeBlastRadius() else -1
// Z-Layer: 1
// Highlight tiles within movement range
for (tile in tilesInMoveRange) {
val group = tileGroups[tile]!!
@ -663,6 +770,7 @@ class WorldMapHolder(
}
// Z-Layer: 2
// Add back in the red markers for Air Unit Attack range since they can't move, but can still attack
if (unit.cache.cannotMove && isAirUnit && !unit.isPreparingAirSweep()) {
val tilesInAttackRange = unit.getTile().getTilesInDistanceRange(IntRange(1, unit.getRange()))
@ -672,6 +780,7 @@ class WorldMapHolder(
}
}
// Z-Layer: 3
// Movement paths
if (unitMovementPaths.containsKey(unit)) {
for (tile in unitMovementPaths[unit]!!) {
@ -679,11 +788,29 @@ class WorldMapHolder(
}
}
// Z-Layer: 4
// Highlight road path for workers currently connecting roads
if (unit.isAutomatingRoadConnection()) {
val currTileIndex = unit.automatedRoadConnectionPath!!.indexOf(unit.currentTile.position)
if (currTileIndex != -1) {
val futureTiles = unit.automatedRoadConnectionPath!!.filterIndexed { index, _ ->
index > currTileIndex
}.map{tilePos ->
tileMap[tilePos]
}
for (tile in futureTiles){
tileGroups[tile]!!.layerOverlay.showHighlight(Color.ORANGE, if (UncivGame.Current.settings.singleTapMove) 0.7f else 0.3f)
}
}
}
// Z-Layer: 5
// Highlight movement destination tile
if (unit.isMoving()) {
tileGroups[unit.getMovementDestination()]!!.layerOverlay.showHighlight(Color.WHITE, 0.7f)
}
// Z-Layer: 6
// Highlight attackable tiles
if (unit.isMilitary()) {
@ -711,6 +838,7 @@ class WorldMapHolder(
}
}
// Z-Layer: 7
// Highlight best tiles for city founding
if (unit.hasUnique(UniqueType.FoundCity)
&& UncivGame.Current.settings.showSettlersSuggestedCityLocations) {

View File

@ -46,6 +46,9 @@ class UnitTable(val worldScreen: WorldScreen) : Table() {
// Whether the (first) selected unit is in unit-swapping mode
var selectedUnitIsSwapping = false
// Whether the (first) selected unit is in road-connecting mode
var selectedUnitIsConnectingRoad = false
/** Sending no unit clears the selected units entirely */
fun selectUnit(unit: MapUnit?=null, append:Boolean=false) {
if (!append) selectedUnits.clear()
@ -55,6 +58,7 @@ class UnitTable(val worldScreen: WorldScreen) : Table() {
unit.actionsOnDeselect()
}
selectedUnitIsSwapping = false
selectedUnitIsConnectingRoad = false
}
var selectedCity : City? = null
@ -292,7 +296,13 @@ class UnitTable(val worldScreen: WorldScreen) : Table() {
}
fun citySelected(city: City) : Boolean {
selectUnit()
// If the last selected unit connecting a road, keep it selected. Otherwise, clear.
if(selectedUnitIsConnectingRoad){
selectUnit(selectedUnits[0])
selectedUnitIsConnectingRoad = true // selectUnit resets this
}else{
selectUnit()
}
if (city == selectedCity) return false
selectedCity = city
selectedUnitHasChanged = true

View File

@ -39,6 +39,7 @@ object UnitActions {
UnitActionType.SetUp to UnitActionsFromUniques::getSetupActions,
UnitActionType.FoundCity to UnitActionsFromUniques::getFoundCityActions,
UnitActionType.ConstructImprovement to UnitActionsFromUniques::getBuildingImprovementsActions,
UnitActionType.ConnectRoad to UnitActionsFromUniques::getConnectRoadActions,
UnitActionType.Repair to UnitActionsFromUniques::getRepairActions,
UnitActionType.HurryResearch to UnitActionsGreatPerson::getHurryResearchActions,
UnitActionType.HurryWonder to UnitActionsGreatPerson::getHurryWonderActions,
@ -302,7 +303,6 @@ object UnitActions {
return
if (unit.isAutomated()) return
actionList += UnitAction(UnitActionType.Automate,
isCurrentAction = unit.isAutomated(),
action = {

View File

@ -7,6 +7,7 @@ import com.unciv.logic.civilization.Civilization
import com.unciv.logic.civilization.PlayerType
import com.unciv.logic.civilization.diplomacy.DiplomacyFlags
import com.unciv.logic.map.mapunit.MapUnit
import com.unciv.logic.map.tile.RoadStatus
import com.unciv.logic.map.tile.Tile
import com.unciv.models.Counter
import com.unciv.models.UncivSound
@ -274,6 +275,20 @@ object UnitActionsFromUniques {
return finalActions
}
fun getConnectRoadActions(unit: MapUnit, tile: Tile) = sequence {
if (!unit.hasUnique(UniqueType.BuildImprovements)) return@sequence
if (unit.civ.tech.getBestRoadAvailable() == RoadStatus.None) return@sequence
val worldScreen = GUI.getWorldScreen()
yield(UnitAction(UnitActionType.ConnectRoad,
isCurrentAction = unit.isAutomatingRoadConnection(),
action = {
worldScreen.bottomUnitTable.selectedUnitIsConnectingRoad =
!worldScreen.bottomUnitTable.selectedUnitIsConnectingRoad
worldScreen.shouldUpdate = true
}
)
)
}.asIterable()
fun getTransformActions(
unit: MapUnit, tile: Tile