Add Air Sweep (#7484)

* Add Air Sweep Unique
Enable Dogfighting Promotion
Add Air Sweep mode button and crosshair overlay

* Adding Air Sweep Battle Table

* Add airSweep to Battle
Remove double XP
While in AirSweep can't select other units on tile

* initial airsweep code

* Implement airSweep

* BattleTable indicates tile you're AirSweeping

* some notifications

* Clean up notifications.
Add icons

* Revert game.atlas and game.png

* Fix selection properly

* Update Vanilla UnitPromotions.json

* Better handling of movement use
comment cleanup

* missing credit

* Proper code so that Seas units also deal no damage
Adding Tutorials!

* Remove Intercept Bonus Damage/Protection

* Remove chance of Interceptor missing

* Battle Table a bit more consistent

* Defender also gets Air Sweep Modifiers

* Defender doesn't get bonus

* Remove unused getInterceptBonus
Combine logic

* Show damage in notifications for Air Sweep

* Randomize intercepting Civ and prioritize Air Units

* Remove debug code

* Updated atlas

* Clean up Uniques

* Object-ify DamageDealt for ease of reference

* code clean up

Co-authored-by: itanasi <spellman23@gmail.com>
This commit is contained in:
itanasi
2022-07-27 12:16:53 -07:00
committed by GitHub
parent 026ba380cc
commit af6ab8e4e5
26 changed files with 1327 additions and 1025 deletions

View File

@ -72,11 +72,18 @@ object BattleHelper {
.asSequence()
for (tile in tilesInAttackRange) {
if (tile in tilesWithEnemies) attackableTiles += AttackableTile(reachableTile, tile, movementLeft)
if (tile in tilesWithEnemies) attackableTiles += AttackableTile(
reachableTile,
tile,
movementLeft
)
else if (tile in tilesWithoutEnemies) continue // avoid checking the same empty tile multiple times
else if (checkTile(unit, tile, tilesToCheck)) {
tilesWithEnemies += tile
attackableTiles += AttackableTile(reachableTile, tile, movementLeft)
} else if (unit.isPreparingAirSweep()){
tilesWithEnemies += tile
attackableTiles += AttackableTile(reachableTile, tile, movementLeft)
} else {
tilesWithoutEnemies += tile
}

View File

@ -13,6 +13,7 @@ import com.unciv.logic.civilization.PlayerType
import com.unciv.logic.civilization.PopupAlert
import com.unciv.logic.civilization.diplomacy.DiplomaticModifiers
import com.unciv.logic.civilization.diplomacy.DiplomaticStatus
import com.unciv.logic.map.MapUnit
import com.unciv.logic.map.RoadStatus
import com.unciv.logic.map.TileInfo
import com.unciv.models.AttackableTile
@ -305,10 +306,13 @@ object Battle {
return true
}
private fun takeDamage(attacker: ICombatant, defender: ICombatant) {
private data class DamageDealt(val attackerDealt: Int, val defenderDealt: Int) {}
private fun takeDamage(attacker: ICombatant, defender: ICombatant): DamageDealt {
var potentialDamageToDefender = BattleDamage.calculateDamageToDefender(attacker, defender)
var potentialDamageToAttacker = BattleDamage.calculateDamageToAttacker(attacker, defender)
val attackerHealthBefore = attacker.getHealth()
val defenderHealthBefore = defender.getHealth()
if (defender is MapUnitCombatant && defender.unit.isCivilian() && attacker.isMelee()) {
@ -332,7 +336,11 @@ object Battle {
}
}
plunderFromDamage(attacker, defender, defenderHealthBefore - defender.getHealth())
val defenderDamageDealt = attackerHealthBefore - attacker.getHealth()
val attackerDamageDealt = defenderHealthBefore - defender.getHealth()
plunderFromDamage(attacker, defender, attackerDamageDealt)
return DamageDealt(attackerDamageDealt, defenderDamageDealt)
}
private fun plunderFromDamage(
@ -810,6 +818,153 @@ object Battle {
if (targetedCity.population.population < 1) targetedCity.population.setPopulation(1)
}
// Should draw an Interception if available on the tile from any Civ
// Land Units deal 0 damage, and no XP for either party
// Air Interceptors do Air Combat as if Melee (mutual damage) but using Ranged Strength. 5XP to both
// But does not use the Interception mechanic bonuses/promotions
// Counts as an Attack for both units
// Will always draw out an Interceptor's attack (they cannot miss)
// This means the combat against Air Units will execute and always deal damage
// Random Civ at War will Intercept, prioritizing Air Units,
// sorted by highest Intercept chance (same as regular Intercept)
fun airSweep(attacker: MapUnitCombatant, attackedTile: TileInfo) {
// Air Sweep counts as an attack, even if nothing else happens
attacker.unit.attacksThisTurn++
// copied and modified from reduceAttackerMovementPointsAndAttacks()
// use up movement
if (attacker.unit.hasUnique(UniqueType.CanMoveAfterAttacking) || attacker.unit.maxAttacksPerTurn() > attacker.unit.attacksThisTurn) {
// if it was a melee attack and we won, then the unit ALREADY got movement points deducted,
// for the movement to the enemy's tile!
// and if it's an air unit, it only has 1 movement anyway, so...
if (!attacker.unit.baseUnit.movesLikeAirUnits())
attacker.unit.useMovementPoints(1f)
} else attacker.unit.currentMovement = 0f
val attackerName = attacker.getName()
// Make giant sequence of all potential Interceptors from all Civs isAtWarWith()
var potentialInterceptors = sequence<MapUnit> { }
for (interceptingCiv in UncivGame.Current.gameInfo!!.civilizations
.filter {attacker.getCivInfo().isAtWarWith(it)}) {
potentialInterceptors += interceptingCiv.getCivUnits()
.filter { it.canIntercept(attackedTile) }
}
// first priority, only Air Units
if (potentialInterceptors.any { it.baseUnit.isAirUnit() })
potentialInterceptors = potentialInterceptors.filter { it.baseUnit.isAirUnit() }
// Pick highest chance interceptor
for (interceptor in potentialInterceptors
.shuffled() // randomize Civ
.sortedByDescending { it.interceptChance() }) {
// No chance of Interceptor to miss (unlike regular Interception). Always want to deal damage
val interceptingCiv = interceptor.civInfo
val interceptorName = interceptor.name
// pairs of LocationAction for Notification
val locations = LocationAction(
interceptor.currentTile.position,
attacker.unit.currentTile.position
)
val locationsAttackerUnknown =
LocationAction(interceptor.currentTile.position, attackedTile.position)
val locationsInterceptorUnknown =
LocationAction(attackedTile.position, attacker.unit.currentTile.position)
interceptor.attacksThisTurn++ // even if you miss, you took the shot
val damageDealt: DamageDealt
if (!interceptor.baseUnit.isAirUnit()) {
// Deal no damage (moddable in future?) and no XP
val attackerText =
"Our [$attackerName] (0) was attacked by an intercepting [$interceptorName] (0)"
val interceptorText =
"Our [$interceptorName] (0) intercepted and attacked an enemy [$attackerName] (0)"
attacker.getCivInfo().addNotification(
attackerText, locations,
attackerName, NotificationIcon.War, interceptorName
)
interceptingCiv.addNotification(
interceptorText, locations,
interceptorName, NotificationIcon.War, attackerName
)
attacker.unit.action = null
return
} else {
// Damage if Air v Air should work similar to Melee
damageDealt = takeDamage(attacker, MapUnitCombatant(interceptor))
// 5 XP to both
addXp(MapUnitCombatant(interceptor), 5, attacker)
addXp(attacker, 5, MapUnitCombatant(interceptor))
}
if (attacker.isDefeated()) {
if (interceptor.getTile() in attacker.getCivInfo().viewableTiles) {
val attackerText =
"Our [$attackerName] (${damageDealt.attackerDealt}) was destroyed by an intercepting [$interceptorName] (${damageDealt.defenderDealt})"
attacker.getCivInfo().addNotification(
attackerText, locations,
attackerName, NotificationIcon.War, interceptorName
)
} else {
val attackerText =
"Our [$attackerName] (${damageDealt.attackerDealt}) was destroyed by an unknown interceptor"
attacker.getCivInfo().addNotification(
attackerText, locationsInterceptorUnknown,
attackerName, NotificationIcon.War, NotificationIcon.Question
)
}
val interceptorText =
"Our [$interceptorName] (${damageDealt.defenderDealt}) intercepted and destroyed an enemy [$attackerName] (${damageDealt.attackerDealt})"
interceptingCiv.addNotification(
interceptorText, locations,
interceptorName, NotificationIcon.War, attackerName
)
} else if (MapUnitCombatant(interceptor).isDefeated()) {
val attackerText =
"Our [$attackerName] (${damageDealt.attackerDealt}) destroyed an intercepting [$interceptorName] (${damageDealt.defenderDealt})"
attacker.getCivInfo().addNotification(
attackerText, locations,
attackerName, NotificationIcon.War, interceptorName
)
if (attacker.getTile() in interceptingCiv.viewableTiles) {
val interceptorText =
"Our [$interceptorName] (${damageDealt.defenderDealt}) intercepted and was destroyed by an enemy [$attackerName](${damageDealt.attackerDealt}) "
interceptingCiv.addNotification(
interceptorText, locations,
interceptorName, NotificationIcon.War, attackerName
)
} else {
val interceptorText =
"Our [$interceptorName] (${damageDealt.defenderDealt}) intercepted and was destroyed by an unknown enemy"
interceptingCiv.addNotification(
interceptorText, locationsAttackerUnknown,
interceptorName, NotificationIcon.War, NotificationIcon.Question
)
}
} else {
val attackerText =
"Our [$attackerName] (${damageDealt.attackerDealt}) was attacked by an intercepting [$interceptorName] (${damageDealt.defenderDealt})"
val interceptorText =
"Our [$interceptorName] (${damageDealt.defenderDealt}) intercepted and attacked an enemy [$attackerName] (${damageDealt.attackerDealt})"
attacker.getCivInfo().addNotification(
attackerText, locations,
attackerName, NotificationIcon.War, interceptorName
)
interceptingCiv.addNotification(
interceptorText, locations,
interceptorName, NotificationIcon.War, attackerName
)
}
attacker.unit.action = null
return
}
// No Interceptions available
val attackerText = "Nothing tried to intercept our [$attackerName]"
attacker.getCivInfo().addNotification(attackerText, attackerName)
attacker.unit.action = null
}
private fun tryInterceptAirAttack(attacker: MapUnitCombatant, attackedTile: TileInfo, interceptingCiv: CivilizationInfo, defender: ICombatant?) {
if (attacker.unit.hasUnique(UniqueType.CannotBeIntercepted, StateForConditionals(attacker.getCivInfo(), ourCombatant = attacker, theirCombatant = defender, attackedTile = attackedTile)))
return

View File

@ -122,6 +122,9 @@ object BattleDamage {
if (attacker.unit.type.isWaterUnit() && attacker.isMelee() && !defender.getTile().isWater
&& !attacker.unit.hasUnique(UniqueType.AttackAcrossCoast) && !defender.isCity())
modifiers["Landing"] = -50
// Air unit attacking with Air Sweep
if (attacker.unit.isPreparingAirSweep())
modifiers.add(getAirSweepAttackModifiers(attacker))
if (attacker.isMelee()) {
val numberOfAttackersSurroundingDefender = defender.getTile().neighbors.count {
@ -155,6 +158,20 @@ object BattleDamage {
return modifiers
}
fun getAirSweepAttackModifiers(
attacker: ICombatant
): Counter<String> {
val modifiers = Counter<String>()
if (attacker is MapUnitCombatant) {
for (unique in attacker.unit.getUniques().filter{it.isOfType(UniqueType.StrengthWhenAirsweep)}) {
modifiers.add(getModifierStringFromUnique(unique), unique.params[0].toInt())
}
}
return modifiers
}
fun getDefenceModifiers(attacker: ICombatant, defender: ICombatant): Counter<String> {
val modifiers = getGeneralModifiers(defender, attacker, CombatAction.Defend)
val tile = defender.getTile()

View File

@ -56,4 +56,5 @@ class MapUnitCombatant(val unit: MapUnit) : ICombatant {
fun hasUnique(uniqueType: UniqueType, conditionalState: StateForConditionals? = null): Boolean =
if (conditionalState == null) unit.hasUnique(uniqueType)
else unit.hasUnique(uniqueType, conditionalState)
}

View File

@ -35,6 +35,7 @@ object NotificationIcon {
const val Scout = "UnitIcons/Scout"
const val Ruins = "ImprovementIcons/Ancient ruins"
const val Barbarians = "ImprovementIcons/Barbarian encampment"
const val Question = "OtherIcons/Question"
}
/**

View File

@ -440,6 +440,7 @@ class MapUnit : IsPartOfGameInfoSerialization {
fun isAutomated() = action == UnitActionType.Automate.value
fun isExploring() = action == UnitActionType.Explore.value
fun isPreparingParadrop() = action == UnitActionType.Paradrop.value
fun isPreparingAirSweep() = action == UnitActionType.AirSweep.value
fun isSetUpForSiege() = action == UnitActionType.SetUp.value
/** For display in Unit Overview */
@ -858,7 +859,7 @@ class MapUnit : IsPartOfGameInfoSerialization {
action = null // wake up when healed
}
if (isPreparingParadrop())
if (isPreparingParadrop() || isPreparingAirSweep())
action = null
if (hasUnique(UniqueType.ReligiousUnit)
@ -1302,7 +1303,7 @@ class MapUnit : IsPartOfGameInfoSerialization {
fun actionsOnDeselect() {
showAdditionalActions = false
if (isPreparingParadrop()) action = null
if (isPreparingParadrop() || isPreparingAirSweep()) action = null
}
fun getForceEvaluation(): Int {

View File

@ -101,6 +101,8 @@ enum class UnitActionType(
{ ImageGetter.getImage("OtherIcons/Pillage") }, 'p'),
Paradrop("Paradrop",
{ ImageGetter.getUnitIcon("Paratrooper") }, 'p'),
AirSweep("Air Sweep",
{ ImageGetter.getImage("OtherIcons/AirSweep") }, 'a'),
SetUp("Set up",
{ ImageGetter.getUnitIcon("Catapult") }, 't', UncivSound.Setup),
FoundCity("Found city",

View File

@ -430,6 +430,8 @@ enum class UniqueType(val text: String, vararg targets: UniqueTarget, val flags:
ExtraInterceptionsPerTurn("[amount] extra interceptions may be made per turn", UniqueTarget.Unit),
CannotBeIntercepted("Cannot be intercepted", UniqueTarget.Unit),
CannotInterceptUnits("Cannot intercept [mapUnitFilter] units", UniqueTarget.Unit),
CanAirsweep("Can perform Air Sweep", UniqueTarget.Unit),
StrengthWhenAirsweep("[relativeAmount]% Strength when performing Air Sweep", UniqueTarget.Unit),
UnitMaintenanceDiscount("[relativeAmount]% maintenance costs", UniqueTarget.Unit, UniqueTarget.Global),
UnitUpgradeCost("[relativeAmount]% Gold cost of upgrading", UniqueTarget.Unit, UniqueTarget.Global),

View File

@ -190,7 +190,7 @@ class WorldMapHolder(
it.movement.canMoveTo(tileInfo) ||
it.movement.isUnknownTileWeShouldAssumeToBePassable(tileInfo) && !it.baseUnit.movesLikeAirUnits()
}
)) {
) && previousSelectedUnits.any { !it.isPreparingAirSweep()}) {
if (previousSelectedUnitIsSwapping) {
addTileOverlaysWithUnitSwapping(previousSelectedUnits.first(), tileInfo)
}
@ -647,7 +647,7 @@ class WorldMapHolder(
for (tile in tilesInMoveRange) {
for (tileToColor in tileGroups[tile]!!) {
if (isAirUnit)
if (isAirUnit && !unit.isPreparingAirSweep()) {
if (tile.aerialDistanceTo(unit.getTile()) <= unit.getRange()) {
// The tile is within attack range
tileToColor.showHighlight(Color.RED, 0.3f)
@ -655,6 +655,7 @@ class WorldMapHolder(
// The tile is within move range
tileToColor.showHighlight(Color.BLUE, 0.3f)
}
}
if (unit.movement.canMoveTo(tile) ||
unit.movement.isUnknownTileWeShouldAssumeToBePassable(tile) && !unit.baseUnit.movesLikeAirUnits())
tileToColor.showHighlight(moveTileOverlayColor,

View File

@ -60,6 +60,10 @@ class BattleTable(val worldScreen: WorldScreen): Table() {
val selectedTile = worldScreen.mapHolder.selectedTile
?: return hide() // no selected tile
simulateNuke(attacker, selectedTile)
} else if (attacker is MapUnitCombatant && attacker.unit.isPreparingAirSweep()) {
val selectedTile = worldScreen.mapHolder.selectedTile
?: return hide() // no selected tile
simulateAirsweep(attacker, selectedTile)
} else {
val defender = tryGetDefender() ?: return hide()
if (attacker is CityCombatant && defender is CityCombatant) return hide()
@ -113,6 +117,21 @@ class BattleTable(val worldScreen: WorldScreen): Table() {
if (combatant is MapUnitCombatant) UnitGroup(combatant.unit,25f)
else ImageGetter.getNationIndicator(combatant.getCivInfo().nation, 25f)
private val quarterScreen = worldScreen.stage.width / 4
private fun getModifierTable(key: String, value: Int) = Table().apply {
val description = if (key.startsWith("vs "))
("vs [" + key.drop(3) + "]").tr()
else key.tr()
val percentage = (if (value > 0) "+" else "") + value + "%"
val upOrDownLabel = if (value > 0f) "".toLabel(Color.GREEN)
else "".toLabel(Color.RED)
add(upOrDownLabel)
val modifierLabel = "$percentage $description".toLabel(fontSize = 14).apply { wrap = true }
add(modifierLabel).width(quarterScreen - upOrDownLabel.minWidth)
}
private fun simulateBattle(attacker: ICombatant, defender: ICombatant){
clear()
@ -139,21 +158,6 @@ class BattleTable(val worldScreen: WorldScreen): Table() {
add(attacker.getAttackingStrength().toString() + attackIcon)
add(defender.getDefendingStrength(attacker.isRanged()).toString() + defenceIcon).row()
val quarterScreen = worldScreen.stage.width / 4
fun getModifierTable(key: String, value: Int) = Table().apply {
val description = if (key.startsWith("vs "))
("vs [" + key.drop(3) + "]").tr()
else key.tr()
val percentage = (if (value > 0) "+" else "") + value + "%"
val upOrDownLabel = if (value > 0f) "".toLabel(Color.GREEN)
else "".toLabel(Color.RED)
add(upOrDownLabel)
val modifierLabel = "$percentage $description".toLabel(fontSize = 14).apply { wrap = true }
add(modifierLabel).width(quarterScreen - upOrDownLabel.minWidth)
}
val attackerModifiers =
BattleDamage.getAttackModifiers(attacker, defender).map {
getModifierTable(it.key, it.value)
@ -323,4 +327,63 @@ class BattleTable(val worldScreen: WorldScreen): Table() {
setPosition(worldScreen.stage.width / 2 - width / 2, 5f)
}
private fun simulateAirsweep(attacker: MapUnitCombatant, targetTile: TileInfo)
{
clear()
val attackerNameWrapper = Table()
val attackerLabel = attacker.getName().toLabel()
attackerNameWrapper.add(getIcon(attacker)).padRight(5f)
attackerNameWrapper.add(attackerLabel)
add(attackerNameWrapper)
val canAttack = attacker.canAttack()
val defenderLabel = Label("???", skin)
add(defenderLabel).row()
addSeparator().pad(0f)
val attackIcon = Fonts.rangedStrength
add(attacker.getAttackingStrength().toString() + attackIcon)
add("???$attackIcon").row()
val attackerModifiers =
BattleDamage.getAirSweepAttackModifiers(attacker).map {
getModifierTable(it.key, it.value)
}
for (modifier in attackerModifiers) {
add(modifier)
add()
row().pad(2f)
}
add(getHealthBar(attacker.getHealth(), attacker.getMaxHealth(), 0))
add(getHealthBar(attacker.getMaxHealth(), attacker.getMaxHealth(), 0))
row().pad(5f)
val attackButton = "Air Sweep".toTextButton().apply { color = Color.RED }
val canReach = attacker.unit.currentTile.getTilesInDistance(attacker.unit.getRange()).contains(targetTile)
if (!worldScreen.isPlayersTurn || !attacker.canAttack() || !canReach || !canAttack) {
attackButton.disable()
attackButton.label.color = Color.GRAY
}
else {
attackButton.onClick(attacker.getAttackSound()) {
Battle.airSweep(attacker, targetTile)
worldScreen.mapHolder.removeUnitActionOverlay() // the overlay was one of attacking
worldScreen.shouldUpdate = true
}
}
add(attackButton).colspan(2)
pack()
setPosition(worldScreen.stage.width / 2 - width / 2, 5f)
}
}

View File

@ -57,6 +57,7 @@ object UnitActions {
addUnitUpgradeAction(unit, actionList)
addPillageAction(unit, actionList, worldScreen)
addParadropAction(unit, actionList)
addAirSweepAction(unit, actionList)
addSetupAction(unit, actionList)
addFoundCityAction(unit, actionList, tile)
addBuildingImprovementsAction(unit, actionList, tile, worldScreen, unitTable)
@ -267,6 +268,21 @@ object UnitActions {
})
}
private fun addAirSweepAction(unit: MapUnit, actionList: ArrayList<UnitAction>) {
val airsweepUniques =
unit.getMatchingUniques(UniqueType.CanAirsweep)
if (!airsweepUniques.any()) return
actionList += UnitAction(UnitActionType.AirSweep,
isCurrentAction = unit.isPreparingAirSweep(),
action = {
if (unit.isPreparingAirSweep()) unit.action = null
else unit.action = UnitActionType.AirSweep.value
}.takeIf {
unit.canAttack()
}
)
}
private fun addPillageAction(unit: MapUnit, actionList: ArrayList<UnitAction>, worldScreen: WorldScreen) {
val pillageAction = getPillageAction(unit)
?: return

View File

@ -255,6 +255,8 @@ class UnitTable(val worldScreen: WorldScreen) : Table(){
// Do not select a different unit or city center if we click on it to swap our current unit to it
if (selectedUnitIsSwapping && selectedUnit != null && selectedUnit!!.movement.canUnitSwapTo(selectedTile)) return
// Do no select a different unit while in Air Sweep mode
if (selectedUnit != null && selectedUnit!!.isPreparingAirSweep()) return
when {
forceSelectUnit != null ->