Added unit escorting formation!!! (#11057)

* Added escort button

* Added basic escort movement

* Improved escort movement

* Swapping breaks escorting

* Added stop escorting button

* Added link icon to unit

* getDistanceToTiles() now automatically includes escorting

* Multi-turn movement with different units works somewhat

* Escorting units persist to escort across saves

* Escorting units are only idle if their partner unit is idle as well

* Fixed multi-turn escort movement where one unit has more movement points left over

* Added basic tests

* Added a test for formation idle units

* Added some basic movement tests

* Added some canMoveTo tests

* getDistanceToTiles only caches when includeEscort is true

* added getDistanceToTiles test

* An entire commit to remove one line of white space just for you! And yes, there are no semi-colons;

* Added translations

* Added more stopEscorting() calls when the unit is removed

* Added extra comments and refactoring

* Refactored removeAllTilesNotInSet to use a mutableIterator

* Refactored code based on review

* Refactored removing tiles in PathsToTilesWithinTurn that aren't in another PathsToTilesWithinTurn
This commit is contained in:
Oskar Niesen
2024-02-24 14:39:04 -06:00
committed by GitHub
parent 54201c381c
commit 04083de766
7 changed files with 330 additions and 10 deletions

View File

@ -1107,6 +1107,8 @@ Sleep =
Sleep until healed =
Moving =
Set up =
Escort formation =
Stop Escort formation =
Paradrop =
Air Sweep =
Add in capital =

View File

@ -60,6 +60,8 @@ class MapUnit : IsPartOfGameInfoSerialization {
// Connect roads implies automated is true. It is specified by the action type.
var action: String? = null
var automated: Boolean = false
// We can infer who we are escorting based on our tile
var escorting: Boolean = false
var automatedRoadConnectionDestination: Vector2? = null
var automatedRoadConnectionPath: List<Vector2>? = null
@ -177,6 +179,7 @@ class MapUnit : IsPartOfGameInfoSerialization {
toReturn.health = health
toReturn.action = action
toReturn.automated = automated
toReturn.escorting = escorting
toReturn.automatedRoadConnectionDestination = automatedRoadConnectionDestination
toReturn.automatedRoadConnectionPath = automatedRoadConnectionPath
toReturn.attacksThisTurn = attacksThisTurn
@ -231,13 +234,18 @@ class MapUnit : IsPartOfGameInfoSerialization {
fun isPreparingAirSweep() = action == UnitActionType.AirSweep.value
fun isSetUpForSiege() = action == UnitActionType.SetUp.value
fun isIdle(): Boolean {
/**
* @param includeOtherEscortUnit determines whether or not this method will also check if it's other escort unit is idle if it has one
* Leave it as default unless you know what [isIdle] does.
*/
fun isIdle(includeOtherEscortUnit: Boolean = true): Boolean {
if (currentMovement == 0f) return false
val tile = getTile()
if (tile.improvementInProgress != null &&
canBuildImprovement(tile.getTileImprovementInProgress()!!) &&
!tile.isMarkedForCreatesOneImprovement()
) return false
if (includeOtherEscortUnit && isEscorting() && !getOtherEscortUnit()!!.isIdle(false)) return false
return !(isFortified() || isExploring() || isSleeping() || isAutomated() || isMoving())
}
@ -568,6 +576,20 @@ class MapUnit : IsPartOfGameInfoSerialization {
return power
}
fun getOtherEscortUnit(): MapUnit? {
if (isCivilian()) return getTile().militaryUnit
if (isMilitary()) return getTile().civilianUnit
return null
}
fun isEscorting(): Boolean {
if (escorting) {
if (getOtherEscortUnit() != null) return true
escorting = false
}
return false
}
fun threatensCiv(civInfo: Civilization): Boolean {
if (getTile().getOwner() == civInfo)
return true
@ -687,6 +709,7 @@ class MapUnit : IsPartOfGameInfoSerialization {
fun doAction() {
if (action == null) return
if (currentMovement == 0f) return // We've already done stuff this turn, and can't do any more stuff
if (isEscorting() && getOtherEscortUnit()!!.currentMovement == 0f) return
val enemyUnitsInWalkingDistance = movement.getDistanceToTiles().keys
.filter { it.militaryUnit != null && civ.isAtWarWith(it.militaryUnit!!.civ) }
@ -730,6 +753,7 @@ class MapUnit : IsPartOfGameInfoSerialization {
}
fun destroy(destroyTransportedUnit: Boolean = true) {
stopEscorting()
val currentPosition = Vector2(getTile().position)
civ.attacksSinceTurnStart.addAll(attacksSinceTurnStart.asSequence().map { Civilization.HistoricalAttackMemory(this.name, currentPosition, it) })
currentMovement = 0f
@ -746,6 +770,7 @@ class MapUnit : IsPartOfGameInfoSerialization {
}
fun gift(recipient: Civilization) {
stopEscorting()
civ.units.removeUnit(this)
civ.cache.updateViewableTiles()
// all transported units should be gift as well
@ -849,6 +874,22 @@ class MapUnit : IsPartOfGameInfoSerialization {
moveThroughTile(tile)
}
fun startEscorting() {
if (getOtherEscortUnit() != null) {
escorting = true
getOtherEscortUnit()!!.escorting = true
} else {
escorting = false
}
movement.clearPathfindingCache()
}
fun stopEscorting() {
getOtherEscortUnit()?.escorting = false
escorting = false
movement.clearPathfindingCache()
}
private fun clearEncampment(tile: Tile) {
tile.removeImprovement()

View File

@ -238,8 +238,13 @@ class UnitMovement(val unit: MapUnit) {
* @return The tile that we reached this turn
*/
fun headTowards(destination: Tile): Tile {
val escortUnit = if (unit.isEscorting()) unit.getOtherEscortUnit() else null
val startTile = unit.getTile()
val destinationTileThisTurn = getTileToMoveToThisTurn(destination)
moveToTile(destinationTileThisTurn)
if (startTile != unit.getTile() && escortUnit != null) {
escortUnit.movement.headTowards(unit.getTile())
}
return unit.currentTile
}
@ -261,7 +266,11 @@ class UnitMovement(val unit: MapUnit) {
return getDistanceToTiles().containsKey(destination)
}
fun getReachableTilesInCurrentTurn(): Sequence<Tile> {
/**
* @param includeOtherEscortUnit determines whether or not this method will also check its the other escort unit if it has one
* Leave it as default unless you know what [getReachableTilesInCurrentTurn] does.
*/
fun getReachableTilesInCurrentTurn(includeOtherEscortUnit: Boolean = true): Sequence<Tile> {
return when {
unit.cache.cannotMove -> sequenceOf(unit.getTile())
unit.baseUnit.movesLikeAirUnits() ->
@ -269,8 +278,11 @@ class UnitMovement(val unit: MapUnit) {
unit.isPreparingParadrop() ->
unit.getTile().getTilesInDistance(unit.cache.paradropRange)
.filter { unit.movement.canParadropOn(it) }
else ->
unit.movement.getDistanceToTiles().keys.asSequence()
includeOtherEscortUnit && unit.isEscorting() -> {
val otherUnitTiles = unit.getOtherEscortUnit()!!.movement.getReachableTilesInCurrentTurn(false).toSet()
unit.movement.getDistanceToTiles().filter { otherUnitTiles.contains(it.key) }.keys.asSequence()
}
else -> unit.movement.getDistanceToTiles().keys.asSequence()
}
}
@ -322,6 +334,7 @@ class UnitMovement(val unit: MapUnit) {
* CAN DESTROY THE UNIT.
*/
fun teleportToClosestMoveableTile() {
unit.stopEscorting()
if (unit.isTransported) return // handled when carrying unit is teleported
var allowedTile: Tile? = null
var distance = 0
@ -370,6 +383,7 @@ class UnitMovement(val unit: MapUnit) {
fun moveToTile(destination: Tile, considerZoneOfControl: Boolean = true) {
if (destination == unit.getTile() || unit.isDestroyed) return // already here (or dead)!
// Reset closestEnemy chache
val escortUnit = if (unit.isEscorting()) unit.getOtherEscortUnit()!! else null
if (unit.baseUnit.movesLikeAirUnits()) { // air units move differently from all other units
if (unit.action != UnitActionType.Automate.value) unit.action = null
@ -477,6 +491,10 @@ class UnitMovement(val unit: MapUnit) {
payload.isTransported = true // restore the flag to not leave the payload in the city
payload.mostRecentMoveType = UnitMovementMemoryType.UnitMoved
}
if (escortUnit != null) {
escortUnit.movement.moveToTile(finalTileReached)
unit.startEscorting() // Need to re-apply this
}
// Unit maintenance changed
if (unit.canGarrison()
@ -498,6 +516,7 @@ class UnitMovement(val unit: MapUnit) {
* Precondition: this unit can swap-move to the given tile, as determined by canUnitSwapTo
*/
fun swapMoveToTile(destination: Tile) {
unit.stopEscorting()
val otherUnit = (
if (unit.isCivilian())
destination.civilianUnit
@ -548,8 +567,10 @@ class UnitMovement(val unit: MapUnit) {
/**
* Designates whether we can enter the tile - without attacking
* DOES NOT designate whether we can reach that tile in the current turn
* @param includeOtherEscortUnit determines whether or not this method will also check if the other escort unit [canMoveTo] if it has one.
* Leave it as default unless you know what [canMoveTo] does.
*/
fun canMoveTo(tile: Tile, assumeCanPassThrough: Boolean = false, canSwap: Boolean = false): Boolean {
fun canMoveTo(tile: Tile, assumeCanPassThrough: Boolean = false, canSwap: Boolean = false, includeOtherEscortUnit: Boolean = true): Boolean {
if (unit.baseUnit.movesLikeAirUnits())
return canAirUnitMoveTo(tile, unit)
@ -560,6 +581,10 @@ class UnitMovement(val unit: MapUnit) {
if (isCityCenterCannotEnter(tile))
return false
if (includeOtherEscortUnit && unit.isEscorting()
&& !unit.getOtherEscortUnit()!!.movement.canMoveTo(tile, assumeCanPassThrough,canSwap, includeOtherEscortUnit = false))
return false
return if (unit.isCivilian())
(tile.civilianUnit == null || (canSwap && tile.civilianUnit!!.owner == unit.owner))
&& (tile.militaryUnit == null || tile.militaryUnit!!.owner == unit.owner)
@ -600,8 +625,10 @@ class UnitMovement(val unit: MapUnit) {
* This is the most called function in the entire game,
* so multiple callees of this function have been optimized,
* because optimization on this function results in massive benefits!
* @param includeOtherEscortUnit determines whether or not this method will also check if the other escort unit [canPassThrough] if it has one.
* Leave it as default unless you know what [canPassThrough] does.
*/
fun canPassThrough(tile: Tile): Boolean {
fun canPassThrough(tile: Tile, includeOtherEscortUnit: Boolean = true): Boolean {
if (tile.isImpassible()) {
// special exception - ice tiles are technically impassible, but some units can move through them anyway
// helicopters can pass through impassable tiles like mountains
@ -649,15 +676,21 @@ class UnitMovement(val unit: MapUnit) {
if (unit.civ.isAtWarWith(firstUnit.civ))
return false
}
if (includeOtherEscortUnit && unit.isEscorting() && !unit.getOtherEscortUnit()!!.movement.canPassThrough(tile,false))
return false
return true
}
/**
* @param includeOtherEscortUnit determines whether or not this method will also check if the other escort units [getDistanceToTiles] if it has one.
* Leave it as default unless you know what [getDistanceToTiles] does.
*/
fun getDistanceToTiles(
considerZoneOfControl: Boolean = true,
passThroughCache: HashMap<Tile, Boolean> = HashMap(),
movementCostCache: HashMap<Pair<Tile, Tile>, Float> = HashMap())
movementCostCache: HashMap<Pair<Tile, Tile>, Float> = HashMap(),
includeOtherEscortUnit: Boolean = true)
: PathsToTilesWithinTurn {
val cacheResults = pathfindingCache.getDistanceToTiles(considerZoneOfControl)
if (cacheResults != null) {
@ -671,7 +704,17 @@ class UnitMovement(val unit: MapUnit) {
passThroughCache,
movementCostCache
)
if (includeOtherEscortUnit) {
// Only save to cache only if we are the original call and not the subsequent escort unit call
pathfindingCache.setDistanceToTiles(considerZoneOfControl, distanceToTiles)
if (unit.isEscorting()) {
// We should only be able to move to tiles that our escort can also move to
val escortDistanceToTiles = unit.getOtherEscortUnit()!!.movement
.getDistanceToTiles(considerZoneOfControl, includeOtherEscortUnit = false)
distanceToTiles.keys.removeIf { !escortDistanceToTiles.containsKey(it) }
}
}
return distanceToTiles
}

View File

@ -97,6 +97,10 @@ enum class UnitActionType(
/** UI "page" preference, 0-based - Dynamic overrides to this are in `UnitActions.actionTypeToPageGetter` */
val defaultPage: Int
) {
StopEscortFormation("Stop Escort formation",
{ ImageGetter.getImage("OtherIcons/Stop") }, false, defaultPage = 1),
EscortFormation("Escort formation",
{ ImageGetter.getImage("OtherIcons/Link") }, false, defaultPage = 1),
SwapUnits("Swap units",
{ ImageGetter.getUnitActionPortrait("Swap") }, false, defaultPage = 1),
Automate("Automate",

View File

@ -177,6 +177,7 @@ class UnitGroup(val unit: MapUnit, val size: Float) : Group() {
private fun getActionImage(): Image? {
return when {
unit.isSleeping() -> ImageGetter.getImage("UnitActionIcons/Sleep")
unit.isEscorting() -> ImageGetter.getImage("OtherIcons/Link")
unit.isMoving() -> ImageGetter.getImage("UnitActionIcons/MoveTo")
unit.isExploring() -> ImageGetter.getImage("UnitActionIcons/Explore")
unit.getTile().improvementInProgress!=null && unit.canBuildImprovement(unit.getTile().getTileImprovementInProgress()!!) ->

View File

@ -158,11 +158,42 @@ object UnitActions {
GUI.getMap().setCenterPosition(unit.getMovementDestination().position, true)
})
}
addEscortAction(unit)
addSwapAction(unit)
addDisbandAction(unit)
}
private suspend fun SequenceScope<UnitAction>.addEscortAction(unit: MapUnit) {
// Air units cannot escort
if (unit.baseUnit.movesLikeAirUnits()) return
val worldScreen = GUI.getWorldScreen()
val selectedUnits = worldScreen.bottomUnitTable.selectedUnits
if (selectedUnits.size == 2) {
// We can still create a formation in the case that we have two units selected
// and they are on the same tile. We still have to manualy confirm they are on the same tile here.
val tile = selectedUnits.first().getTile()
if (selectedUnits.last().getTile() != tile) return
if (selectedUnits.any { it.baseUnit.movesLikeAirUnits() }) return
} else if (selectedUnits.size != 1) {
return
}
if (unit.getOtherEscortUnit() == null) return
if (!unit.isEscorting()) {
yield(UnitAction(
type = UnitActionType.EscortFormation,
action = {
unit.startEscorting()
}))
} else {
yield(UnitAction(
type = UnitActionType.StopEscortFormation,
action = {
unit.stopEscorting()
}))
}
}
private suspend fun SequenceScope<UnitAction>.addSwapAction(unit: MapUnit) {
// Air units cannot swap
if (unit.baseUnit.movesLikeAirUnits()) return

View File

@ -0,0 +1,198 @@
package com.unciv.logic.map
import com.badlogic.gdx.math.Vector2
import com.unciv.Constants
import com.unciv.logic.civilization.Civilization
import com.unciv.testing.GdxTestRunner
import com.unciv.testing.TestGame
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(GdxTestRunner::class)
internal class UnitFormationTests {
private lateinit var civInfo: Civilization
val testGame = TestGame()
fun setUp(size: Int, baseTerrain: String = Constants.desert) {
testGame.makeHexagonalMap(size)
civInfo = testGame.addCiv()
}
@Test
fun `basic formation functionality civilian`() {
setUp(1)
val centerTile = testGame.getTile(Vector2(0f,0f))
val civilianUnit = testGame.addUnit("Worker", civInfo, centerTile)
val militaryUnit = testGame.addUnit("Warrior", civInfo, centerTile)
assertTrue(civilianUnit.getOtherEscortUnit() != null)
assertFalse(civilianUnit.isEscorting())
civilianUnit.startEscorting()
assertTrue(civilianUnit.isEscorting())
assertTrue(militaryUnit.isEscorting())
assertTrue(civilianUnit.getOtherEscortUnit() != null)
civilianUnit.stopEscorting()
assertFalse(civilianUnit.isEscorting())
assertFalse(militaryUnit.isEscorting())
assertTrue(civilianUnit.getOtherEscortUnit() != null)
}
@Test
fun `basic formation functionality military`() {
setUp(1)
val centerTile = testGame.getTile(Vector2(0f,0f))
val civilianUnit = testGame.addUnit("Worker", civInfo, centerTile)
val militaryUnit = testGame.addUnit("Warrior", civInfo, centerTile)
assertTrue(militaryUnit.getOtherEscortUnit() != null)
assertFalse(militaryUnit.isEscorting())
militaryUnit.startEscorting()
assertTrue(militaryUnit.isEscorting())
assertTrue(civilianUnit.isEscorting())
assertTrue(militaryUnit.getOtherEscortUnit() != null)
militaryUnit.stopEscorting()
assertFalse(militaryUnit.isEscorting())
assertFalse(civilianUnit.isEscorting())
assertTrue(militaryUnit.getOtherEscortUnit() != null)
}
@Test
fun `basic formation not available functionality`() {
setUp(1)
val centerTile = testGame.getTile(Vector2(0f,0f))
val civilianUnit = testGame.addUnit("Worker", civInfo, centerTile)
assertFalse(civilianUnit.getOtherEscortUnit() != null)
assertFalse(civilianUnit.isEscorting())
civilianUnit.startEscorting()
assertFalse(civilianUnit.isEscorting())
civilianUnit.destroy()
val militaryUnit = testGame.addUnit("Warrior", civInfo, centerTile)
assertFalse(militaryUnit.getOtherEscortUnit() != null)
assertFalse(militaryUnit.isEscorting())
militaryUnit.startEscorting()
assertFalse(militaryUnit.isEscorting())
}
@Test
fun `formation idle units`() {
setUp(1)
val centerTile = testGame.getTile(Vector2(0f,0f))
val civilianUnit = testGame.addUnit("Worker", civInfo, centerTile)
val militaryUnit = testGame.addUnit("Warrior", civInfo, centerTile)
civilianUnit.startEscorting()
assertTrue(civilianUnit.isIdle())
assertTrue(militaryUnit.isIdle())
civilianUnit.currentMovement = 0f
assertFalse(civilianUnit.isIdle())
assertFalse(militaryUnit.isIdle())
civilianUnit.currentMovement = 2f
civInfo.tech.techsResearched.add(testGame.ruleset.tileImprovements["Farm"]!!.techRequired!!)
centerTile.startWorkingOnImprovement(testGame.ruleset.tileImprovements["Farm"]!!, civInfo, civilianUnit)
assertFalse(civilianUnit.isIdle())
assertFalse(militaryUnit.isIdle())
}
@Test
fun `formation movement` () {
setUp(3)
val centerTile = testGame.getTile(Vector2(0f,0f))
val civilianUnit = testGame.addUnit("Worker", civInfo, centerTile)
val militaryUnit = testGame.addUnit("Warrior", civInfo, centerTile)
civilianUnit.startEscorting()
val targetTile = testGame.getTile(Vector2(0f,2f))
civilianUnit.movement.moveToTile(targetTile)
assert(civilianUnit.getTile() == targetTile)
assert(militaryUnit.getTile() == targetTile)
assertTrue(civilianUnit.isEscorting())
assertTrue(militaryUnit.isEscorting())
}
@Test
fun `stop formation movement` () {
setUp(3)
val centerTile = testGame.getTile(Vector2(0f,0f))
val civilianUnit = testGame.addUnit("Worker", civInfo, centerTile)
val militaryUnit = testGame.addUnit("Warrior", civInfo, centerTile)
civilianUnit.startEscorting()
civilianUnit.stopEscorting()
val targetTile = testGame.getTile(Vector2(0f,2f))
civilianUnit.movement.moveToTile(targetTile)
assert(civilianUnit.getTile() == targetTile)
assert(militaryUnit.getTile() == centerTile)
assertFalse(civilianUnit.isEscorting())
assertFalse(militaryUnit.isEscorting())
}
@Test
fun `formation canMoveTo` () {
setUp(3)
val centerTile = testGame.getTile(Vector2(0f,0f))
val civilianUnit = testGame.addUnit("Worker", civInfo, centerTile)
val militaryUnit = testGame.addUnit("Warrior", civInfo, centerTile)
val targetTile = testGame.getTile(Vector2(0f,2f))
val blockingCivilianUnit = testGame.addUnit("Worker", civInfo, targetTile)
assertFalse(civilianUnit.movement.canMoveTo(targetTile))
assertTrue(militaryUnit.movement.canMoveTo(targetTile))
civilianUnit.startEscorting()
assertFalse(militaryUnit.movement.canMoveTo(targetTile))
}
@Test
fun `formation canMoveTo water` () {
setUp(3, "Ocean")
val centerTile = testGame.getTile(Vector2(0f,0f))
centerTile.baseTerrain = "Coast"
centerTile.isWater = true
centerTile.isLand = false
civInfo.tech.embarkedUnitsCanEnterOcean = true
civInfo.tech.addTechnology("Astronomy")
val civilianUnit = testGame.addUnit("Work Boats", civInfo, centerTile) // Can enter ocean
val militaryUnit = testGame.addUnit("Trireme", civInfo, centerTile) // Can't enter ocean
val targetTile = testGame.getTile(Vector2(0f,1f))
targetTile.isWater = true
targetTile.isLand = false
targetTile.isOcean = true
assertFalse(militaryUnit.movement.canMoveTo(targetTile))
assertTrue(civilianUnit.movement.canMoveTo(targetTile))
civilianUnit.startEscorting()
assertFalse(civilianUnit.movement.canMoveTo(targetTile))
}
@Test
fun `formation head towards with faster units` () {
setUp(5)
val centerTile = testGame.getTile(Vector2(0f,0f))
val civilianUnit = testGame.addUnit("Worker", civInfo, centerTile)
val militaryUnit = testGame.addUnit("Horseman", civInfo, centerTile) // 4 movement
civilianUnit.startEscorting()
val targetTile = testGame.getTile(Vector2(0f,4f))
val excpectedTile = testGame.getTile(Vector2(0f,2f))
militaryUnit.movement.headTowards(targetTile)
assert(civilianUnit.getTile() == excpectedTile)
assert(militaryUnit.getTile() == excpectedTile)
assertTrue(civilianUnit.isEscorting())
assertTrue(militaryUnit.isEscorting())
assertTrue(militaryUnit.currentMovement == 2f)
assertFalse("The unit should not be idle if it's escort has no movement points",militaryUnit.isIdle())
}
@Test
fun `getDistanceToTiles when in formation`() {
setUp(5)
val centerTile = testGame.getTile(Vector2(0f,0f))
val civilianUnit = testGame.addUnit("Worker", civInfo, centerTile)
val militaryUnit = testGame.addUnit("Horseman", civInfo, centerTile) // 4 movement
civilianUnit.startEscorting()
var civilianDistanceToTiles = civilianUnit.movement.getDistanceToTiles()
assertFalse(militaryUnit.movement.getDistanceToTiles().any { !civilianDistanceToTiles.contains(it.key) })
// Test again with caching
civilianUnit.stopEscorting()
militaryUnit.movement.getDistanceToTiles()
civilianUnit.movement.getDistanceToTiles()
civilianUnit.startEscorting()
civilianDistanceToTiles = civilianUnit.movement.getDistanceToTiles()
assertFalse(militaryUnit.movement.getDistanceToTiles().any { !civilianDistanceToTiles.contains(it.key) })
}
}