diff --git a/core/src/com/unciv/logic/map/mapunit/movement/MovementCost.kt b/core/src/com/unciv/logic/map/mapunit/movement/MovementCost.kt new file mode 100644 index 0000000000..47f549ce39 --- /dev/null +++ b/core/src/com/unciv/logic/map/mapunit/movement/MovementCost.kt @@ -0,0 +1,176 @@ +package com.unciv.logic.map.mapunit.movement + +import com.unciv.Constants +import com.unciv.logic.civilization.Civilization +import com.unciv.logic.map.mapunit.MapUnit +import com.unciv.logic.map.mapunit.MapUnitCache +import com.unciv.logic.map.tile.RoadStatus +import com.unciv.logic.map.tile.Tile +import com.unciv.models.ruleset.unique.StateForConditionals +import com.unciv.models.ruleset.unique.UniqueType + +object MovementCost { + + // This function is called ALL THE TIME and should be as time-optimal as possible! + fun getMovementCostBetweenAdjacentTiles( + unit: MapUnit, + from: Tile, + to: Tile, + considerZoneOfControl: Boolean = true + ): Float { + val civ = unit.civ + + if (unit.cache.cannotMove) return 100f + + if (from.isLand != to.isLand && unit.baseUnit.isLandUnit() && !unit.cache.canMoveOnWater) + return if (from.isWater && to.isLand) unit.cache.costToDisembark ?: 100f + else unit.cache.costToEmbark ?: 100f + + // If the movement is affected by a Zone of Control, all movement points are expended + if (considerZoneOfControl && isMovementAffectedByZoneOfControl(unit, from, to)) + return 100f + + // land units will still spend all movement points to embark even with this unique + if (unit.cache.allTilesCosts1) + return 1f + + val toOwner = to.getOwner() + + val extraCost = if ( + toOwner != null && + toOwner.hasActiveEnemyMovementPenalty && + civ.isAtWarWith(toOwner) + ) getEnemyMovementPenalty(toOwner, unit) else 0f + + if (from.getUnpillagedRoad() == RoadStatus.Railroad && to.getUnpillagedRoad() == RoadStatus.Railroad) + return RoadStatus.Railroad.movement + extraCost + + // Each of these two function calls `hasUnique(UniqueType.CityStateTerritoryAlwaysFriendly)` + // when entering territory of a city state + val areConnectedByRoad = from.hasConnection(civ) && to.hasConnection(civ) + + // You might think "wait doesn't isAdjacentToRiver() call isConnectedByRiver() anyway, why have those checks?" + // The answer is that the isAdjacentToRiver values are CACHED per tile, but the isConnectedByRiver are not - this is an efficiency optimization + val areConnectedByRiver = + from.isAdjacentToRiver() && to.isAdjacentToRiver() && from.isConnectedByRiver(to) + + if (areConnectedByRoad && (!areConnectedByRiver || civ.tech.roadsConnectAcrossRivers)) + return unit.civ.tech.movementSpeedOnRoads + extraCost + + if (unit.cache.ignoresTerrainCost) return 1f + extraCost + if (areConnectedByRiver) return 100f // Rivers take the entire turn to cross + + val terrainCost = to.lastTerrain.movementCost.toFloat() + + if (unit.cache.noTerrainMovementUniques) + return terrainCost + extraCost + + val stateForConditionals = StateForConditionals(unit.civ, unit = unit, tile = to) + fun matchesTerrainTarget( + doubleMovement: MapUnitCache.DoubleMovement, + target: MapUnitCache.DoubleMovementTerrainTarget + ): Boolean { + if (doubleMovement.terrainTarget != target) return false + if (doubleMovement.unique.conditionals.isNotEmpty()) { + if (!doubleMovement.unique.conditionalsApply(stateForConditionals)) return false + } + + return true + } + + fun matchesTerrainTarget( + terrainName: String, + target: MapUnitCache.DoubleMovementTerrainTarget + ): Boolean { + val doubleMovement = unit.cache.doubleMovementInTerrain[terrainName] ?: return false + return matchesTerrainTarget(doubleMovement, target) + } + + + if (to.terrainFeatures.any { matchesTerrainTarget(it, MapUnitCache.DoubleMovementTerrainTarget.Feature) }) + return terrainCost * 0.5f + extraCost + + if (unit.cache.roughTerrainPenalty && to.isRoughTerrain()) + return 100f // units that have to spend all movement in rough terrain, have to spend all movement in rough terrain + // Placement of this 'if' based on testing, see #4232 + + if (civ.nation.ignoreHillMovementCost && to.isHill()) + return 1f + extraCost // usually hills take 2 movements, so here it is 1 + + if (unit.cache.noBaseTerrainOrHillDoubleMovementUniques) + return terrainCost + extraCost + + if (matchesTerrainTarget(to.baseTerrain, MapUnitCache.DoubleMovementTerrainTarget.Base)) + return terrainCost * 0.5f + extraCost + if (matchesTerrainTarget(Constants.hill, MapUnitCache.DoubleMovementTerrainTarget.Hill) + && to.isHill()) + return terrainCost * 0.5f + extraCost + + if (unit.cache.noFilteredDoubleMovementUniques) + return terrainCost + extraCost + if (unit.cache.doubleMovementInTerrain.any { + matchesTerrainTarget(it.value, MapUnitCache.DoubleMovementTerrainTarget.Filter) + && to.matchesFilter(it.key) + }) + return terrainCost * 0.5f + extraCost + + return terrainCost + extraCost // no road or other movement cost reduction + } + + private fun getEnemyMovementPenalty(civInfo:Civilization, enemyUnit: MapUnit): Float { + if (civInfo.enemyMovementPenaltyUniques != null && civInfo.enemyMovementPenaltyUniques!!.any()) { + return civInfo.enemyMovementPenaltyUniques!!.sumOf { + if (it.type!! == UniqueType.EnemyUnitsSpendExtraMovement + && enemyUnit.matchesFilter(it.params[0])) + it.params[1].toInt() + else 0 + }.toFloat() + } + return 0f // should not reach this point + } + + + /** Returns whether the movement between the adjacent tiles [from] and [to] is affected by Zone of Control */ + private fun isMovementAffectedByZoneOfControl(unit: MapUnit, from: Tile, to: Tile): 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. + val tilesExertingZoneOfControl = getTilesExertingZoneOfControl(unit, from) + if (tilesExertingZoneOfControl.none { 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.cache.ignoresZoneOfControl) + return false + return true + } + + private fun getTilesExertingZoneOfControl(unit: MapUnit, tile: Tile) = sequence { + for (neighbor in tile.neighbors) { + if (neighbor.isCityCenter() && unit.civ.isAtWarWith(neighbor.getOwner()!!)) { + yield(neighbor) + } + else if (neighbor.militaryUnit != null && unit.civ.isAtWarWith(neighbor.militaryUnit!!.civ)) { + if (neighbor.militaryUnit!!.type.isWaterUnit() || (unit.type.isLandUnit() && !neighbor.militaryUnit!!.isEmbarked())) + yield(neighbor) + } + } + } + +} diff --git a/core/src/com/unciv/logic/map/mapunit/movement/UnitMovement.kt b/core/src/com/unciv/logic/map/mapunit/movement/UnitMovement.kt index dc56c738d9..3ed2e26a7b 100644 --- a/core/src/com/unciv/logic/map/mapunit/movement/UnitMovement.kt +++ b/core/src/com/unciv/logic/map/mapunit/movement/UnitMovement.kt @@ -2,15 +2,11 @@ package com.unciv.logic.map.mapunit.movement import com.badlogic.gdx.math.Vector2 import com.unciv.Constants -import com.unciv.logic.civilization.Civilization import com.unciv.logic.map.BFS import com.unciv.logic.map.HexMath.getDistance import com.unciv.logic.map.mapunit.MapUnit -import com.unciv.logic.map.mapunit.MapUnitCache -import com.unciv.logic.map.tile.RoadStatus import com.unciv.logic.map.tile.Tile import com.unciv.models.UnitActionType -import com.unciv.models.ruleset.unique.StateForConditionals import com.unciv.models.ruleset.unique.UniqueType import com.unciv.ui.components.UnitMovementMemoryType @@ -18,164 +14,6 @@ class UnitMovement(val unit: MapUnit) { private val pathfindingCache = PathfindingCache(unit) - private fun getEnemyMovementPenalty(civInfo:Civilization, enemyUnit: MapUnit): Float { - if (civInfo.enemyMovementPenaltyUniques != null && civInfo.enemyMovementPenaltyUniques!!.any()) { - return civInfo.enemyMovementPenaltyUniques!!.sumOf { - if (it.type!! == UniqueType.EnemyUnitsSpendExtraMovement - && enemyUnit.matchesFilter(it.params[0])) - it.params[1].toInt() - else 0 - }.toFloat() - } - return 0f // should not reach this point - } - - // This function is called ALL THE TIME and should be as time-optimal as possible! - private fun getMovementCostBetweenAdjacentTiles( - from: Tile, - to: Tile, - civInfo: Civilization, - considerZoneOfControl: Boolean = true - ): Float { - if (unit.cache.cannotMove) return 100f - - if (from.isLand != to.isLand && unit.baseUnit.isLandUnit() && !unit.cache.canMoveOnWater) - return if (from.isWater && to.isLand) unit.cache.costToDisembark ?: 100f - else unit.cache.costToEmbark ?: 100f - - // 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.cache.allTilesCosts1) - return 1f - - val toOwner = to.getOwner() - val extraCost = if ( - toOwner != null && - toOwner.hasActiveEnemyMovementPenalty && - civInfo.isAtWarWith(toOwner) - ) getEnemyMovementPenalty(toOwner, unit) else 0f - - if (from.getUnpillagedRoad() == RoadStatus.Railroad && to.getUnpillagedRoad() == RoadStatus.Railroad) - return RoadStatus.Railroad.movement + extraCost - - // Each of these two function calls `hasUnique(UniqueType.CityStateTerritoryAlwaysFriendly)` - // when entering territory of a city state - val areConnectedByRoad = from.hasConnection(civInfo) && to.hasConnection(civInfo) - - // You might think "wait doesn't isAdjacentToRiver() call isConnectedByRiver() anyway, why have those checks?" - // The answer is that the isAdjacentToRiver values are CACHED per tile, but the isConnectedByRiver are not - this is an efficiency optimization - val areConnectedByRiver = - from.isAdjacentToRiver() && to.isAdjacentToRiver() && from.isConnectedByRiver(to) - - if (areConnectedByRoad && (!areConnectedByRiver || civInfo.tech.roadsConnectAcrossRivers)) - return unit.civ.tech.movementSpeedOnRoads + extraCost - - if (unit.cache.ignoresTerrainCost) return 1f + extraCost - if (areConnectedByRiver) return 100f // Rivers take the entire turn to cross - - val terrainCost = to.lastTerrain.movementCost.toFloat() - - if (unit.cache.noTerrainMovementUniques) - return terrainCost + extraCost - - val stateForConditionals = StateForConditionals(unit.civ, unit = unit, tile = to) - fun matchesTerrainTarget( - doubleMovement: MapUnitCache.DoubleMovement, - target: MapUnitCache.DoubleMovementTerrainTarget - ): Boolean { - if (doubleMovement.terrainTarget != target) return false - if (doubleMovement.unique.conditionals.isNotEmpty()) { - if (!doubleMovement.unique.conditionalsApply(stateForConditionals)) return false - } - - return true - } - - fun matchesTerrainTarget( - terrainName: String, - target: MapUnitCache.DoubleMovementTerrainTarget - ): Boolean { - val doubleMovement = unit.cache.doubleMovementInTerrain[terrainName] ?: return false - return matchesTerrainTarget(doubleMovement, target) - } - - - if (to.terrainFeatures.any { matchesTerrainTarget(it, MapUnitCache.DoubleMovementTerrainTarget.Feature) }) - return terrainCost * 0.5f + extraCost - - if (unit.cache.roughTerrainPenalty && to.isRoughTerrain()) - return 100f // units that have to spend all movement in rough terrain, have to spend all movement in rough terrain - // Placement of this 'if' based on testing, see #4232 - - if (civInfo.nation.ignoreHillMovementCost && to.isHill()) - return 1f + extraCost // usually hills take 2 movements, so here it is 1 - - if (unit.cache.noBaseTerrainOrHillDoubleMovementUniques) - return terrainCost + extraCost - - if (matchesTerrainTarget(to.baseTerrain, MapUnitCache.DoubleMovementTerrainTarget.Base)) - return terrainCost * 0.5f + extraCost - if (matchesTerrainTarget(Constants.hill, MapUnitCache.DoubleMovementTerrainTarget.Hill) - && to.isHill()) - return terrainCost * 0.5f + extraCost - - if (unit.cache.noFilteredDoubleMovementUniques) - return terrainCost + extraCost - if (unit.cache.doubleMovementInTerrain.any { - matchesTerrainTarget(it.value, MapUnitCache.DoubleMovementTerrainTarget.Filter) - && to.matchesFilter(it.key) - }) - return terrainCost * 0.5f + extraCost - - return terrainCost + extraCost // no road or other movement cost reduction - } - - private fun getTilesExertingZoneOfControl(tile: Tile, civInfo: Civilization) = sequence { - for (neighbor in tile.neighbors) { - if (neighbor.isCityCenter() && civInfo.isAtWarWith(neighbor.getOwner()!!)) { - yield(neighbor) - } - else if (neighbor.militaryUnit != null && civInfo.isAtWarWith(neighbor.militaryUnit!!.civ)) { - if (neighbor.militaryUnit!!.type.isWaterUnit() || (unit.type.isLandUnit() && !neighbor.militaryUnit!!.isEmbarked())) - yield(neighbor) - } - } - } - - /** Returns whether the movement between the adjacent tiles [from] and [to] is affected by Zone of Control */ - private fun isMovementAffectedByZoneOfControl(from: Tile, to: Tile, civInfo: Civilization): 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. - val tilesExertingZoneOfControl = getTilesExertingZoneOfControl(from, civInfo) - if (tilesExertingZoneOfControl.none { 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.cache.ignoresZoneOfControl) - return false - return true - } - class ParentTileAndTotalDistance(val tile:Tile, val parentTile: Tile, val totalDistance: Float) fun isUnknownTileWeShouldAssumeToBePassable(tile: Tile) = !unit.civ.hasExplored(tile) @@ -220,7 +58,7 @@ class UnitMovement(val unit: MapUnit) { val key = Pair(tileToCheck, neighbor) val movementCost = movementCostCache.getOrPut(key) { - getMovementCostBetweenAdjacentTiles(tileToCheck, neighbor, unit.civ, considerZoneOfControl) + MovementCost.getMovementCostBetweenAdjacentTiles(unit, tileToCheck, neighbor, considerZoneOfControl) } distanceToTiles[tileToCheck]!!.totalDistance + movementCost } @@ -596,7 +434,7 @@ class UnitMovement(val unit: MapUnit) { // This fixes a bug where tiles in the fog of war would always only cost 1 mp if (!unit.civ.gameInfo.gameParameters.godMode) - passingMovementSpent += getMovementCostBetweenAdjacentTiles(previousTile, tile, unit.civ) + passingMovementSpent += MovementCost.getMovementCostBetweenAdjacentTiles(unit, previousTile, tile) // In case something goes wrong, cache the last tile we were able to end on // We can assume we can pass through this tile, as we would have broken earlier