From 201648a680f984631604a807dde3e38c6d48ef53 Mon Sep 17 00:00:00 2001 From: Arthur van der Staaij <32672293+avdstaaij@users.noreply.github.com> Date: Sun, 15 Aug 2021 20:42:47 +0200 Subject: [PATCH] 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. --- .../assets/jsons/Civ V - Vanilla/Units.json | 2 +- core/src/com/unciv/logic/battle/Battle.kt | 6 +- core/src/com/unciv/logic/map/MapUnit.kt | 4 ++ .../unciv/logic/map/UnitMovementAlgorithms.kt | 62 +++++++++++++++++-- 4 files changed, 66 insertions(+), 8 deletions(-) diff --git a/android/assets/jsons/Civ V - Vanilla/Units.json b/android/assets/jsons/Civ V - Vanilla/Units.json index 5fc9a8383d..dbcfebbb0b 100644 --- a/android/assets/jsons/Civ V - Vanilla/Units.json +++ b/android/assets/jsons/Civ V - Vanilla/Units.json @@ -1376,7 +1376,7 @@ "requiredTech": "Computers", "requiredResource": "Aluminum", "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" }, diff --git a/core/src/com/unciv/logic/battle/Battle.kt b/core/src/com/unciv/logic/battle/Battle.kt index e853eeec49..944f58524f 100644 --- a/core/src/com/unciv/logic/battle/Battle.kt +++ b/core/src/com/unciv/logic/battle/Battle.kt @@ -325,7 +325,11 @@ object Battle { // 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()) 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) } } diff --git a/core/src/com/unciv/logic/map/MapUnit.kt b/core/src/com/unciv/logic/map/MapUnit.kt index af2e459375..a09684c101 100644 --- a/core/src/com/unciv/logic/map/MapUnit.kt +++ b/core/src/com/unciv/logic/map/MapUnit.kt @@ -44,6 +44,9 @@ class MapUnit { @Transient var ignoresTerrainCost = false + @Transient + var ignoresZoneOfControl = false + @Transient var allTilesCosts1 = false @@ -196,6 +199,7 @@ class MapUnit { allTilesCosts1 = hasUnique("All tiles cost 1 movement") || hasUnique("All tiles costs 1") canPassThroughImpassableTiles = hasUnique("Can pass through impassable tiles") ignoresTerrainCost = hasUnique("Ignores terrain cost") + ignoresZoneOfControl = hasUnique("Ignores Zone of Control") roughTerrainPenalty = hasUnique("Rough terrain penalty") doubleMovementInCoast = hasUnique("Double movement in coast") doubleMovementInForestAndJungle = hasUnique("Double movement rate through Forest and Jungle") diff --git a/core/src/com/unciv/logic/map/UnitMovementAlgorithms.kt b/core/src/com/unciv/logic/map/UnitMovementAlgorithms.kt index dde76c7a00..0c6c2070f7 100644 --- a/core/src/com/unciv/logic/map/UnitMovementAlgorithms.kt +++ b/core/src/com/unciv/logic/map/UnitMovementAlgorithms.kt @@ -8,12 +8,16 @@ import com.unciv.logic.civilization.CivilizationInfo class UnitMovementAlgorithms(val unit:MapUnit) { // 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 (unit.civInfo.nation.disembarkCosts1 && from.isWater && to.isLand) return 1f 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 if (unit.allTilesCosts1) return 1f @@ -58,6 +62,52 @@ class UnitMovementAlgorithms(val unit:MapUnit) { 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) 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. * 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() if (unitMovement == 0f) return distanceToTiles @@ -90,7 +140,7 @@ class UnitMovementAlgorithms(val unit:MapUnit) { // cities and units goes kaput. else { - val distanceBetweenTiles = getMovementCostBetweenAdjacentTiles(tileToCheck, neighbor, unit.civInfo) + val distanceBetweenTiles = getMovementCostBetweenAdjacentTiles(tileToCheck, neighbor, unit.civInfo, considerZoneOfControl) totalDistanceToTile = distanceToTiles[tileToCheck]!!.totalDistance + distanceBetweenTiles } } 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() } - fun moveToTile(destination: TileInfo) { + fun moveToTile(destination: TileInfo, considerZoneOfControl: Boolean = true) { if (destination == unit.getTile()) return // already here! if (unit.baseUnit.movesLikeAirUnits()) { // air units move differently from all other units @@ -350,7 +400,7 @@ class UnitMovementAlgorithms(val unit:MapUnit) { return } - val distanceToTiles = getDistanceToTiles() + val distanceToTiles = getDistanceToTiles(considerZoneOfControl) val pathToDestination = distanceToTiles.getPathToTile(destination) val movableTiles = pathToDestination.takeWhile { canPassThrough(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> { var tilesToCheck = ArrayList()