Zone of Control (#4085)

* Implemented Zone of Control

* Implemented "move after attacking" ZoC exception

Units that can move after attacking are not affected by zone of control
if they move because of defeating a unit.

* Implemented all missing special ZoC cases

As described in:
https://forums.civfanatics.com/resources/understanding-the-zone-of-control-vanilla.25582/

* Slightly optimized ZoC logic

* Modified the "possible optimization" comment

Added the knowledge gained from SomeTroglodyte's tests.

* Added "Ignores Zone of Control" unique

Implemented the unique and gave it to the Helicopter Gunship.
This commit is contained in:
Arthur van der Staaij
2021-08-15 20:42:47 +02:00
committed by GitHub
parent 377cce3348
commit 201648a680
4 changed files with 66 additions and 8 deletions

View File

@ -1376,7 +1376,7 @@
"requiredTech": "Computers", "requiredTech": "Computers",
"requiredResource": "Aluminum", "requiredResource": "Aluminum",
"uniques": ["+[100]% Strength vs [Armored]", "No defensive terrain bonus", "Can move after attacking", "uniques": ["+[100]% Strength vs [Armored]", "No defensive terrain bonus", "Can move after attacking",
"All tiles cost 1 movement", "Unable to capture cities"], "All tiles cost 1 movement", "Ignores Zone of Control", "Unable to capture cities"],
"attackSound": "machinegun" "attackSound": "machinegun"
}, },

View File

@ -325,7 +325,11 @@ object Battle {
// we destroyed an enemy military unit and there was a civilian unit in the same tile as well // we destroyed an enemy military unit and there was a civilian unit in the same tile as well
if (attackedTile.civilianUnit != null && attackedTile.civilianUnit!!.civInfo != attacker.getCivInfo()) if (attackedTile.civilianUnit != null && attackedTile.civilianUnit!!.civInfo != attacker.getCivInfo())
captureCivilianUnit(attacker, MapUnitCombatant(attackedTile.civilianUnit!!)) captureCivilianUnit(attacker, MapUnitCombatant(attackedTile.civilianUnit!!))
attacker.unit.movement.moveToTile(attackedTile) // Units that can move after attacking are not affected by zone of control if the
// movement is caused by killing a unit. Effectively, this means that attack movements
// are exempt from zone of control, since units that cannot move after attacking already
// lose all remaining movement points anyway.
attacker.unit.movement.moveToTile(attackedTile, considerZoneOfControl = false)
} }
} }

View File

@ -44,6 +44,9 @@ class MapUnit {
@Transient @Transient
var ignoresTerrainCost = false var ignoresTerrainCost = false
@Transient
var ignoresZoneOfControl = false
@Transient @Transient
var allTilesCosts1 = false var allTilesCosts1 = false
@ -196,6 +199,7 @@ class MapUnit {
allTilesCosts1 = hasUnique("All tiles cost 1 movement") || hasUnique("All tiles costs 1") allTilesCosts1 = hasUnique("All tiles cost 1 movement") || hasUnique("All tiles costs 1")
canPassThroughImpassableTiles = hasUnique("Can pass through impassable tiles") canPassThroughImpassableTiles = hasUnique("Can pass through impassable tiles")
ignoresTerrainCost = hasUnique("Ignores terrain cost") ignoresTerrainCost = hasUnique("Ignores terrain cost")
ignoresZoneOfControl = hasUnique("Ignores Zone of Control")
roughTerrainPenalty = hasUnique("Rough terrain penalty") roughTerrainPenalty = hasUnique("Rough terrain penalty")
doubleMovementInCoast = hasUnique("Double movement in coast") doubleMovementInCoast = hasUnique("Double movement in coast")
doubleMovementInForestAndJungle = hasUnique("Double movement rate through Forest and Jungle") doubleMovementInForestAndJungle = hasUnique("Double movement rate through Forest and Jungle")

View File

@ -8,12 +8,16 @@ import com.unciv.logic.civilization.CivilizationInfo
class UnitMovementAlgorithms(val unit:MapUnit) { class UnitMovementAlgorithms(val unit:MapUnit) {
// This function is called ALL THE TIME and should be as time-optimal as possible! // This function is called ALL THE TIME and should be as time-optimal as possible!
fun getMovementCostBetweenAdjacentTiles(from: TileInfo, to: TileInfo, civInfo: CivilizationInfo): Float { fun getMovementCostBetweenAdjacentTiles(from: TileInfo, to: TileInfo, civInfo: CivilizationInfo, considerZoneOfControl: Boolean = true): Float {
if (from.isLand != to.isLand && unit.baseUnit.isLandUnit()) if (from.isLand != to.isLand && unit.baseUnit.isLandUnit())
if (unit.civInfo.nation.disembarkCosts1 && from.isWater && to.isLand) return 1f if (unit.civInfo.nation.disembarkCosts1 && from.isWater && to.isLand) return 1f
else return 100f // this is embarkment or disembarkment, and will take the entire turn else return 100f // this is embarkment or disembarkment, and will take the entire turn
// If the movement is affected by a Zone of Control, all movement points are expended
if (considerZoneOfControl && isMovementAffectedByZoneOfControl(from, to, civInfo))
return 100f
// land units will still spend all movement points to embark even with this unique // land units will still spend all movement points to embark even with this unique
if (unit.allTilesCosts1) if (unit.allTilesCosts1)
return 1f return 1f
@ -58,6 +62,52 @@ class UnitMovementAlgorithms(val unit:MapUnit) {
return to.getLastTerrain().movementCost.toFloat() + extraCost // no road return to.getLastTerrain().movementCost.toFloat() + extraCost // no road
} }
/** Returns whether the movement between the adjacent tiles [from] and [to] is affected by Zone of Control */
private fun isMovementAffectedByZoneOfControl(from: TileInfo, to: TileInfo, civInfo: CivilizationInfo): Boolean {
// Sources:
// - https://civilization.fandom.com/wiki/Zone_of_control_(Civ5)
// - https://forums.civfanatics.com/resources/understanding-the-zone-of-control-vanilla.25582/
//
// Enemy military units exert a Zone of Control over the tiles surrounding them. Moving from
// one tile in the ZoC of an enemy unit to another tile in the same unit's ZoC expends all
// movement points. Land units only exert a ZoC against land units. Sea units exert a ZoC
// against both land and sea units. Cities exert a ZoC as well, and it also affects both
// land and sea units. Embarked land units do not exert a ZoC. Finally, units that can move
// after attacking are not affected by zone of control if the movement is caused by killing
// a unit. This last case is handled in the movement-after-attacking code instead of here.
// We only need to check the two shared neighbors of [from] and [to]: the way of getting
// these two tiles can perhaps be optimized. Using a hex-math-based "commonAdjacentTiles"
// function is surprisingly less efficient than the current neighbor-intersection approach.
// See #4085 for more details.
if (from.neighbors.none{
(
(
it.isCityCenter() &&
civInfo.isAtWarWith(it.getOwner()!!)
)
||
(
it.militaryUnit != null &&
civInfo.isAtWarWith(it.militaryUnit!!.civInfo) &&
(it.militaryUnit!!.type.isWaterUnit() || (!it.militaryUnit!!.isEmbarked() && unit.type.isLandUnit()))
)
)
&&
to.neighbors.contains(it)
})
return false
// Even though this is a very fast check, we perform it last. This is because very few units
// ignore zone of control, so the previous check has a much higher chance of yielding an
// early "false". If this function is going to return "true", the order doesn't matter
// anyway.
if (unit.ignoresZoneOfControl)
return false
return true
}
class ParentTileAndTotalDistance(val parentTile: TileInfo, val totalDistance: Float) class ParentTileAndTotalDistance(val parentTile: TileInfo, val totalDistance: Float)
fun isUnknownTileWeShouldAssumeToBePassable(tileInfo: TileInfo) = !unit.civInfo.exploredTiles.contains(tileInfo.position) fun isUnknownTileWeShouldAssumeToBePassable(tileInfo: TileInfo) = !unit.civInfo.exploredTiles.contains(tileInfo.position)
@ -66,7 +116,7 @@ class UnitMovementAlgorithms(val unit:MapUnit) {
* Does not consider if tiles can actually be entered, use canMoveTo for that. * Does not consider if tiles can actually be entered, use canMoveTo for that.
* If a tile can be reached within the turn, but it cannot be passed through, the total distance to it is set to unitMovement * If a tile can be reached within the turn, but it cannot be passed through, the total distance to it is set to unitMovement
*/ */
fun getDistanceToTilesWithinTurn(origin: Vector2, unitMovement: Float): PathsToTilesWithinTurn { fun getDistanceToTilesWithinTurn(origin: Vector2, unitMovement: Float, considerZoneOfControl: Boolean = true): PathsToTilesWithinTurn {
val distanceToTiles = PathsToTilesWithinTurn() val distanceToTiles = PathsToTilesWithinTurn()
if (unitMovement == 0f) return distanceToTiles if (unitMovement == 0f) return distanceToTiles
@ -90,7 +140,7 @@ class UnitMovementAlgorithms(val unit:MapUnit) {
// cities and units goes kaput. // cities and units goes kaput.
else { else {
val distanceBetweenTiles = getMovementCostBetweenAdjacentTiles(tileToCheck, neighbor, unit.civInfo) val distanceBetweenTiles = getMovementCostBetweenAdjacentTiles(tileToCheck, neighbor, unit.civInfo, considerZoneOfControl)
totalDistanceToTile = distanceToTiles[tileToCheck]!!.totalDistance + distanceBetweenTiles totalDistanceToTile = distanceToTiles[tileToCheck]!!.totalDistance + distanceBetweenTiles
} }
} else totalDistanceToTile = distanceToTiles[tileToCheck]!!.totalDistance + 1f // If we don't know then we just guess it to be 1. } else totalDistanceToTile = distanceToTiles[tileToCheck]!!.totalDistance + 1f // If we don't know then we just guess it to be 1.
@ -323,7 +373,7 @@ class UnitMovementAlgorithms(val unit:MapUnit) {
else unit.destroy() else unit.destroy()
} }
fun moveToTile(destination: TileInfo) { fun moveToTile(destination: TileInfo, considerZoneOfControl: Boolean = true) {
if (destination == unit.getTile()) return // already here! if (destination == unit.getTile()) return // already here!
if (unit.baseUnit.movesLikeAirUnits()) { // air units move differently from all other units if (unit.baseUnit.movesLikeAirUnits()) { // air units move differently from all other units
@ -350,7 +400,7 @@ class UnitMovementAlgorithms(val unit:MapUnit) {
return return
} }
val distanceToTiles = getDistanceToTiles() val distanceToTiles = getDistanceToTiles(considerZoneOfControl)
val pathToDestination = distanceToTiles.getPathToTile(destination) val pathToDestination = distanceToTiles.getPathToTile(destination)
val movableTiles = pathToDestination.takeWhile { canPassThrough(it) } val movableTiles = pathToDestination.takeWhile { canPassThrough(it) }
val lastReachableTile = movableTiles.lastOrNull { canMoveTo(it) } val lastReachableTile = movableTiles.lastOrNull { canMoveTo(it) }
@ -509,7 +559,7 @@ class UnitMovementAlgorithms(val unit:MapUnit) {
} }
fun getDistanceToTiles(): PathsToTilesWithinTurn = getDistanceToTilesWithinTurn(unit.currentTile.position, unit.currentMovement) fun getDistanceToTiles(considerZoneOfControl: Boolean = true): PathsToTilesWithinTurn = getDistanceToTilesWithinTurn(unit.currentTile.position, unit.currentMovement, considerZoneOfControl)
fun getAerialPathsToCities(): HashMap<TileInfo, ArrayList<TileInfo>> { fun getAerialPathsToCities(): HashMap<TileInfo, ArrayList<TileInfo>> {
var tilesToCheck = ArrayList<TileInfo>() var tilesToCheck = ArrayList<TileInfo>()