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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

View File

@ -622,245 +622,245 @@ BuildingIcons/Research Lab
index: -1
BuildingIcons/Satrap's Court
rotate: false
xy: 1486, 970
xy: 1594, 1084
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
BuildingIcons/Seaport
rotate: false
xy: 1810, 1294
xy: 1270, 646
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
BuildingIcons/Shrine
rotate: false
xy: 1594, 976
xy: 1702, 1078
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
BuildingIcons/Sistine Chapel
rotate: false
xy: 1810, 1186
xy: 1378, 646
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
BuildingIcons/Solar Plant
rotate: false
xy: 1702, 970
xy: 1810, 1078
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
BuildingIcons/Spaceship Factory
rotate: false
xy: 1810, 1078
xy: 1486, 646
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
BuildingIcons/Stable
rotate: false
xy: 1594, 760
xy: 1702, 862
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
BuildingIcons/Stadium
rotate: false
xy: 1702, 862
xy: 1810, 970
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
BuildingIcons/Statue of Liberty
rotate: false
xy: 1594, 652
xy: 1702, 754
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
BuildingIcons/Statue of Zeus
rotate: false
xy: 1702, 754
xy: 1810, 862
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
BuildingIcons/Stele
rotate: false
xy: 1810, 754
xy: 1162, 538
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
BuildingIcons/Stock Exchange
rotate: false
xy: 1162, 538
xy: 1270, 538
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
BuildingIcons/Stone Works
rotate: false
xy: 1270, 538
xy: 1378, 538
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
BuildingIcons/Stonehenge
rotate: false
xy: 1378, 538
xy: 1486, 538
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
BuildingIcons/Sydney Opera House
rotate: false
xy: 1702, 538
xy: 1810, 538
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
BuildingIcons/Taj Mahal
rotate: false
xy: 1810, 538
xy: 730, 430
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
BuildingIcons/Temple
rotate: false
xy: 838, 430
xy: 946, 431
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
BuildingIcons/Temple of Artemis
rotate: false
xy: 946, 431
xy: 1054, 436
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
BuildingIcons/Terracotta Army
rotate: false
xy: 1162, 430
xy: 1270, 430
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
BuildingIcons/The Great Library
rotate: false
xy: 1270, 430
xy: 1378, 430
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
BuildingIcons/The Great Lighthouse
rotate: false
xy: 1378, 430
xy: 1486, 430
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
BuildingIcons/The Louvre
rotate: false
xy: 1486, 430
xy: 1594, 436
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
BuildingIcons/The Oracle
rotate: false
xy: 1594, 436
xy: 1702, 430
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
BuildingIcons/The Pyramids
rotate: false
xy: 1702, 430
xy: 1810, 430
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
BuildingIcons/Theatre
rotate: false
xy: 1810, 430
xy: 1940, 1618
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
BuildingIcons/United Nations
rotate: false
xy: 1918, 1185
xy: 1918, 1077
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
BuildingIcons/University
rotate: false
xy: 1918, 1077
xy: 1918, 969
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
BuildingIcons/Utopia Project
rotate: false
xy: 1918, 969
xy: 1918, 861
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
BuildingIcons/Walls
rotate: false
xy: 1918, 429
xy: 760, 322
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
BuildingIcons/Walls of Babylon
rotate: false
xy: 760, 322
xy: 760, 214
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
BuildingIcons/Wat
rotate: false
xy: 868, 214
xy: 868, 106
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
BuildingIcons/Water Mill
rotate: false
xy: 868, 106
xy: 976, 323
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
BuildingIcons/Windmill
rotate: false
xy: 976, 323
xy: 976, 215
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
BuildingIcons/Workshop
rotate: false
xy: 1084, 322
xy: 1084, 214
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
MayaCalendar/0
rotate: false
xy: 1351, 372
xy: 2014, 1934
size: 25, 50
orig: 25, 50
offset: 0, 0
@ -874,35 +874,35 @@ MayaCalendar/1
index: -1
MayaCalendar/10
rotate: false
xy: 1316, 256
xy: 1300, 314
size: 21, 50
orig: 21, 50
offset: 0, 0
index: -1
MayaCalendar/11
rotate: false
xy: 1308, 372
xy: 924, 48
size: 35, 50
orig: 35, 50
offset: 0, 0
index: -1
MayaCalendar/12
rotate: false
xy: 924, 48
xy: 1464, 372
size: 35, 50
orig: 35, 50
offset: 0, 0
index: -1
MayaCalendar/13
rotate: false
xy: 1240, 256
xy: 1507, 372
size: 35, 50
orig: 35, 50
offset: 0, 0
index: -1
MayaCalendar/14
rotate: false
xy: 1298, 314
xy: 1550, 372
size: 35, 50
orig: 35, 50
offset: 0, 0
@ -923,21 +923,21 @@ MayaCalendar/16
index: -1
MayaCalendar/17
rotate: false
xy: 876, 48
xy: 1250, 264
size: 40, 50
orig: 40, 50
offset: 0, 0
index: -1
MayaCalendar/18
rotate: false
xy: 1192, 256
xy: 876, 48
size: 40, 50
orig: 40, 50
offset: 0, 0
index: -1
MayaCalendar/19
rotate: false
xy: 1250, 314
xy: 1416, 372
size: 40, 50
orig: 40, 50
offset: 0, 0
@ -951,14 +951,14 @@ MayaCalendar/2
index: -1
MayaCalendar/3
rotate: false
xy: 1374, 314
xy: 1329, 314
size: 13, 50
orig: 13, 50
offset: 0, 0
index: -1
MayaCalendar/4
rotate: false
xy: 2014, 1876
xy: 2026, 1394
size: 13, 50
orig: 13, 50
offset: 0, 0
@ -972,28 +972,28 @@ MayaCalendar/5
index: -1
MayaCalendar/6
rotate: false
xy: 1283, 256
xy: 2014, 1876
size: 25, 50
orig: 25, 50
offset: 0, 0
index: -1
MayaCalendar/7
rotate: false
xy: 1341, 314
xy: 1192, 206
size: 25, 50
orig: 25, 50
offset: 0, 0
index: -1
MayaCalendar/8
rotate: false
xy: 2014, 1934
xy: 1225, 206
size: 25, 50
orig: 25, 50
offset: 0, 0
index: -1
MayaCalendar/9
rotate: false
xy: 1384, 372
xy: 1258, 206
size: 25, 50
orig: 25, 50
offset: 0, 0
@ -1007,21 +1007,21 @@ MayaCalendar/Baktun
index: -1
MayaCalendar/Katun
rotate: false
xy: 1084, 156
xy: 1192, 264
size: 50, 50
orig: 50, 50
offset: 0, 0
index: -1
MayaCalendar/Maya
rotate: false
xy: 818, 48
xy: 1300, 372
size: 50, 50
orig: 50, 50
offset: 0, 0
index: -1
MayaCalendar/Tun
rotate: false
xy: 1250, 372
xy: 1358, 372
size: 50, 50
orig: 50, 50
offset: 0, 0
@ -1063,21 +1063,21 @@ OtherIcons/ConvertScience
index: -1
OtherIcons/WLTK 1
rotate: false
xy: 1918, 861
xy: 1918, 753
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
OtherIcons/WLTK 2
rotate: false
xy: 1918, 753
xy: 1918, 645
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
OtherIcons/WLTK LR
rotate: false
xy: 1918, 645
xy: 1918, 537
size: 100, 100
orig: 100, 100
offset: 0, 0
@ -1182,7 +1182,7 @@ UnitActionIcons/FoundCity
index: -1
UnitActionIcons/HideMore
rotate: false
xy: 1192, 372
xy: 1084, 156
size: 50, 50
orig: 50, 50
offset: 0, 0
@ -1196,14 +1196,14 @@ UnitActionIcons/HurryResearch
index: -1
UnitActionIcons/MoveTo
rotate: false
xy: 1192, 314
xy: 818, 48
size: 50, 50
orig: 50, 50
offset: 0, 0
index: -1
UnitActionIcons/ShowMore
rotate: false
xy: 1192, 314
xy: 818, 48
size: 50, 50
orig: 50, 50
offset: 0, 0
@ -1250,16 +1250,23 @@ UnitActionIcons/RemoveHeresy
orig: 100, 100
offset: 0, 0
index: -1
UnitActionIcons/RoadConnection
rotate: false
xy: 1594, 1192
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
UnitActionIcons/SetUp
rotate: false
xy: 1270, 646
xy: 1378, 754
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
UnitActionIcons/Sleep
rotate: false
xy: 1486, 754
xy: 1594, 868
size: 100, 100
orig: 100, 100
offset: 0, 0
@ -1273,35 +1280,28 @@ UnitActionIcons/Star
index: -1
UnitActionIcons/StartGoldenAge
rotate: false
xy: 1810, 970
xy: 1594, 652
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
UnitActionIcons/Stop
rotate: false
xy: 1486, 538
xy: 1594, 544
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
UnitActionIcons/StopMove
rotate: false
xy: 1486, 538
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
UnitActionIcons/ShowUnitDestination
rotate: false
xy: 980, 0
xy: 1594, 544
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
UnitActionIcons/Swap
rotate: false
xy: 1702, 646
xy: 1810, 646
size: 100, 100
orig: 100, 100
offset: 0, 0
@ -1315,7 +1315,7 @@ UnitActionIcons/Transform
index: -1
UnitActionIcons/Wait
rotate: false
xy: 1918, 537
xy: 1918, 429
size: 100, 100
orig: 100, 100
offset: 0, 0
@ -2071,203 +2071,203 @@ UnitIcons/Rifleman
index: -1
UnitIcons/Rocket Artillery
rotate: false
xy: 1594, 1192
xy: 1702, 1294
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
UnitIcons/SS Booster
rotate: false
xy: 1702, 1294
xy: 1810, 1402
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
UnitIcons/SS Cockpit
rotate: false
xy: 1810, 1402
xy: 1162, 646
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
UnitIcons/SS Engine
rotate: false
xy: 1162, 646
xy: 1270, 754
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
UnitIcons/SS Stasis Chamber
rotate: false
xy: 1270, 754
xy: 1378, 862
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
UnitIcons/Samurai
rotate: false
xy: 1378, 862
xy: 1486, 970
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
UnitIcons/Scout
rotate: false
xy: 1594, 1084
xy: 1702, 1186
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
UnitIcons/Sea Beggar
rotate: false
xy: 1702, 1186
xy: 1810, 1294
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
UnitIcons/Settler
rotate: false
xy: 1378, 754
xy: 1486, 862
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
UnitIcons/Ship of the Line
rotate: false
xy: 1486, 862
xy: 1594, 976
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
UnitIcons/Sipahi
rotate: false
xy: 1702, 1078
xy: 1810, 1186
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
UnitIcons/Skirmisher
rotate: false
xy: 1378, 646
xy: 1486, 754
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
UnitIcons/Slinger
rotate: false
xy: 1594, 868
xy: 1702, 970
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
UnitIcons/Spearman
rotate: false
xy: 1486, 646
xy: 1594, 760
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
UnitIcons/Stealth Bomber
rotate: false
xy: 1810, 862
xy: 1810, 754
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
UnitIcons/Submarine
rotate: false
xy: 1594, 544
xy: 1702, 646
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
UnitIcons/Swordsman
rotate: false
xy: 1810, 646
xy: 1702, 538
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
UnitIcons/Tank
rotate: false
xy: 730, 430
xy: 838, 430
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
UnitIcons/Tercio
rotate: false
xy: 1054, 436
xy: 1162, 430
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
UnitIcons/Trebuchet
rotate: false
xy: 1940, 1618
xy: 1940, 1510
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
UnitIcons/Triplane
rotate: false
xy: 1940, 1510
xy: 1918, 1402
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
UnitIcons/Trireme
rotate: false
xy: 1918, 1401
xy: 1918, 1293
size: 100, 101
orig: 100, 101
offset: 0, 0
index: -1
UnitIcons/Turtle Ship
rotate: false
xy: 1918, 1293
xy: 1918, 1185
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
UnitIcons/War Chariot
rotate: false
xy: 760, 214
xy: 760, 106
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
UnitIcons/War Elephant
rotate: false
xy: 760, 106
xy: 868, 322
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
UnitIcons/Warrior
rotate: false
xy: 868, 322
xy: 868, 214
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
UnitIcons/Work Boats
rotate: false
xy: 976, 215
xy: 976, 107
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
UnitIcons/Worker
rotate: false
xy: 976, 107
xy: 1084, 322
size: 100, 100
orig: 100, 100
offset: 0, 0
index: -1
UnitIcons/Zero
rotate: false
xy: 1084, 214
xy: 1192, 322
size: 100, 100
orig: 100, 100
offset: 0, 0

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1021 KiB

After

Width:  |  Height:  |  Size: 904 KiB

Binary file not shown.

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

View File

@ -665,6 +665,7 @@ Unless otherwise specified, all the following are from [the Noun Project](https:
- [Circle](https://thenounproject.com/term/circle/1841891/) By Aybige
- [Arrow](https://thenounproject.com/term/arrow/18123/) By Joe Mortell for movement
- [Swap](https://thenounproject.com/search/?q=swap&i=1259600) By iconomania for swapping units
- [Road](https://thenounproject.com/icon/road-224428/) By Gábor István Karaba for connect road automation
- [Connection](https://thenounproject.com/search/?q=connection&i=1521886) By Travis Avery
- [Skull](https://thenounproject.com/search/?q=Skull&i=1030702) By Vladimir Belochkin for disbanding units
- [Crosshair](https://thenounproject.com/search/?q=crosshairs&i=916030) By Bakunetsu Kaito for selecting enemies to attack
@ -770,6 +771,7 @@ Sounds are from FreeSound.org unless otherwise noted and are either Creative Com
- [uzzi_full_single](https://freesound.org/people/Deganoth/sounds/348685/) By Deganoth as 'shot' for bullet attacks
- [Grenade Launcher 2](https://soundbible.com/2140-Grenade-Launcher-2.html) By Daniel Simon as city bombard sound (CC Attribution 3.0 license)
- [Woosh](https://soundbible.com/2068-Woosh.html) by Mark DiAngelo as 'slider' sound (CC Attribution 3.0 license)
- [Large wooden wagon](https://freesound.org/people/craigsmith/sounds/675230/) by Craig Smith as 'connect road' sound
- [Tornado-Siren-II](https://soundbible.com/1937-Tornado-Siren-II.html) by Delilah as part of 'nuke' sound (CC Attribution 3.0 license)
- [Explosion-Ultra-Bass](https://soundbible.com/1807-Explosion-Ultra-Bass.html) by Mark DiAngelo as part of 'nuke' sound (CC Attribution 3.0 license)
- [Short Choir](https://freesound.org/people/Breviceps/sounds/444491/) by Breviceps as 'choir' for free great person pick