From ae13f32f72e0aafd84807f9249db7f085293814a Mon Sep 17 00:00:00 2001 From: SomeTroglodyte <63000004+SomeTroglodyte@users.noreply.github.com> Date: Sun, 14 May 2023 20:52:15 +0200 Subject: [PATCH] Animate battle damage numbers (#9387) * Animated battle damage numbers 001 * Animated battle damage numbers 002 * Animated battle damage numbers 003 * Animated battle damage numbers 004 --- core/src/com/unciv/logic/battle/Battle.kt | 131 +++++++++------- .../com/unciv/logic/battle/BattleDamage.kt | 2 +- .../com/unciv/ui/components/UncivTooltip.kt | 11 +- .../ui/screens/worldscreen/WorldMapHolder.kt | 6 +- .../worldscreen/bottombar/BattleTable.kt | 27 +--- .../bottombar/BattleTableHelpers.kt | 146 ++++++++++++++---- 6 files changed, 210 insertions(+), 113 deletions(-) diff --git a/core/src/com/unciv/logic/battle/Battle.kt b/core/src/com/unciv/logic/battle/Battle.kt index 966f995a08..d97cde2f9b 100644 --- a/core/src/com/unciv/logic/battle/Battle.kt +++ b/core/src/com/unciv/logic/battle/Battle.kt @@ -40,6 +40,8 @@ object Battle { /** * Moves [attacker] to [attackableTile], handles siege setup then attacks if still possible * (by calling [attack] or [NUKE]). Does _not_ play the attack sound! + * + * Currently not used by UI, only by automation via [BattleHelper.tryAttackNearbyEnemy][com.unciv.logic.automation.unit.BattleHelper.tryAttackNearbyEnemy] */ fun moveAndAttack(attacker: ICombatant, attackableTile: AttackableTile) { if (!movePreparingAttack(attacker, attackableTile)) return @@ -83,14 +85,16 @@ object Battle { /** * This is meant to be called only after all prerequisite checks have been done. */ - fun attackOrNuke(attacker: ICombatant, attackableTile: AttackableTile) { - if (attacker is MapUnitCombatant && attacker.unit.baseUnit.isNuclearWeapon()) + fun attackOrNuke(attacker: ICombatant, attackableTile: AttackableTile): DamageDealt { + return if (attacker is MapUnitCombatant && attacker.unit.baseUnit.isNuclearWeapon()) { NUKE(attacker, attackableTile.tileToAttack) - else + DamageDealt.None + } else { attack(attacker, getMapCombatantOfTile(attackableTile.tileToAttack)!!) + } } - fun attack(attacker: ICombatant, defender: ICombatant) { + fun attack(attacker: ICombatant, defender: ICombatant): DamageDealt { debug("%s %s attacked %s %s", attacker.getCivInfo().civName, attacker.getName(), defender.getCivInfo().civName, defender.getName()) val attackedTile = defender.getTile() if (attacker is MapUnitCombatant) { @@ -103,10 +107,11 @@ object Battle { )) } + val interceptDamage: DamageDealt if (attacker is MapUnitCombatant && attacker.unit.baseUnit.isAirUnit()) { - tryInterceptAirAttack(attacker, attackedTile, defender.getCivInfo(), defender) - if (attacker.isDefeated()) return - } + interceptDamage = tryInterceptAirAttack(attacker, attackedTile, defender.getCivInfo(), defender) + if (attacker.isDefeated()) return interceptDamage + } else interceptDamage = DamageDealt.None // Withdraw from melee ability if (attacker is MapUnitCombatant && attacker.isMelee() && defender is MapUnitCombatant) { @@ -114,7 +119,8 @@ object Battle { val combinedProbabilityToStayPut = withdrawUniques.fold(100) { probabilityToStayPut, unique -> probabilityToStayPut * (100-unique.params[0].toInt()) / 100 } val baseWithdrawChance = 100 - combinedProbabilityToStayPut // If a mod allows multiple withdraw properties, they stack multiplicatively - if (baseWithdrawChance != 0 && doWithdrawFromMeleeAbility(attacker, defender, baseWithdrawChance)) return + if (baseWithdrawChance != 0 && doWithdrawFromMeleeAbility(attacker, defender, baseWithdrawChance)) + return DamageDealt.None } val isAlreadyDefeatedCity = defender is CityCombatant && defender.isDefeated() @@ -158,7 +164,6 @@ object Battle { UniqueTriggerActivation.triggerUnitwideUnique(unique, ourUnit.unit, triggerNotificationText = "due to our [${ourUnit.getName()}] defeating a [${enemy.getName()}]") } - // Add culture when defeating a barbarian when Honor policy is adopted, gold from enemy killed when honor is complete // or any enemy military unit with Sacrificial captives unique (can be either attacker or defender!) if (defender.isDefeated() && defender is MapUnitCombatant && !defender.unit.isCivilian()) { @@ -197,6 +202,8 @@ object Battle { .firstOrNull { it.text == "Your city [${attacker.getName()}] can bombard the enemy!" } attacker.getCivInfo().notifications.remove(cityCanBombardNotification) } + + return damageDealt + interceptDamage } private fun triggerDefeatUniques(ourUnit: MapUnitCombatant, enemy: ICombatant, attackedTile: Tile){ @@ -340,7 +347,17 @@ object Battle { return true } - private data class DamageDealt(val attackerDealt: Int, val defenderDealt: Int) + /** Holder for battle result - actual damage. + * @param attackerDealt Damage done by attacker to defender + * @param defenderDealt Damage done by defender to attacker + */ + data class DamageDealt(val attackerDealt: Int, val defenderDealt: Int) { + operator fun plus(other: DamageDealt) = + DamageDealt(attackerDealt + other.attackerDealt, defenderDealt + other.defenderDealt) + companion object { + val None = DamageDealt(0, 0) + } + } private fun takeDamage(attacker: ICombatant, defender: ICombatant): DamageDealt { var potentialDamageToDefender = BattleDamage.calculateDamageToDefender(attacker, defender) @@ -1014,62 +1031,70 @@ object Battle { attacker.unit.action = null } - private fun tryInterceptAirAttack(attacker: MapUnitCombatant, attackedTile: Tile, interceptingCiv: Civilization, defender: ICombatant?) { + private fun tryInterceptAirAttack( + attacker: MapUnitCombatant, + attackedTile: Tile, + interceptingCiv: Civilization, + defender: ICombatant? + ): DamageDealt { if (attacker.unit.hasUnique(UniqueType.CannotBeIntercepted, StateForConditionals(attacker.getCivInfo(), ourCombatant = attacker, theirCombatant = defender, attackedTile = attackedTile))) - return + return DamageDealt.None + // Pick highest chance interceptor - for (interceptor in interceptingCiv.units.getCivUnits() + val interceptor = interceptingCiv.units.getCivUnits() .filter { it.canIntercept(attackedTile) } .sortedByDescending { it.interceptChance() } - ) { - // Can't intercept if we have a unique preventing it - val conditionalState = StateForConditionals(interceptingCiv, ourCombatant = MapUnitCombatant(interceptor), theirCombatant = attacker, combatAction = CombatAction.Intercept, attackedTile = attackedTile) - if (interceptor.getMatchingUniques(UniqueType.CannotInterceptUnits, conditionalState) - .any { attacker.matchesCategory(it.params[0]) } - ) continue + .firstOrNull { unit -> + // Can't intercept if we have a unique preventing it + val conditionalState = StateForConditionals(interceptingCiv, ourCombatant = MapUnitCombatant(unit), theirCombatant = attacker, combatAction = CombatAction.Intercept, attackedTile = attackedTile) + unit.getMatchingUniques(UniqueType.CannotInterceptUnits, conditionalState) + .none { attacker.matchesCategory(it.params[0]) } + // Defender can't intercept either + && unit != (defender as? MapUnitCombatant)?.unit + } + ?: return DamageDealt.None - // Defender can't intercept either - if (defender != null && defender is MapUnitCombatant && interceptor == defender.unit) continue - interceptor.attacksThisTurn++ // even if you miss, you took the shot - // Does Intercept happen? If not, exit - if (Random.Default.nextFloat() > interceptor.interceptChance() / 100f) return + interceptor.attacksThisTurn++ // even if you miss, you took the shot + // Does Intercept happen? If not, exit + if (Random.Default.nextFloat() > interceptor.interceptChance() / 100f) + return DamageDealt.None - var damage = BattleDamage.calculateDamageToDefender( - MapUnitCombatant(interceptor), - attacker - ) + var damage = BattleDamage.calculateDamageToDefender( + MapUnitCombatant(interceptor), + attacker + ) - var damageFactor = 1f + interceptor.interceptDamagePercentBonus().toFloat() / 100f - damageFactor *= attacker.unit.receivedInterceptDamageFactor() + var damageFactor = 1f + interceptor.interceptDamagePercentBonus().toFloat() / 100f + damageFactor *= attacker.unit.receivedInterceptDamageFactor() - damage = (damage.toFloat() * damageFactor).toInt() + damage = (damage.toFloat() * damageFactor).toInt().coerceAtMost(attacker.unit.health) - attacker.takeDamage(damage) - if (damage > 0) - addXp(MapUnitCombatant(interceptor), 2, attacker) + attacker.takeDamage(damage) + if (damage > 0) + addXp(MapUnitCombatant(interceptor), 2, attacker) - val attackerName = attacker.getName() - val interceptorName = interceptor.name - val locations = LocationAction(interceptor.currentTile.position, attacker.unit.currentTile.position) + val attackerName = attacker.getName() + val interceptorName = interceptor.name + val locations = LocationAction(interceptor.currentTile.position, attacker.unit.currentTile.position) - val attackerText = if (!attacker.isDefeated()) - "Our [$attackerName] ([-$damage] HP) was attacked by an intercepting [$interceptorName] ([-0] HP)" - else if (interceptor.getTile() in attacker.getCivInfo().viewableTiles) - "Our [$attackerName] ([-$damage] HP) was destroyed by an intercepting [$interceptorName] ([-0] HP)" - else "Our [$attackerName] ([-$damage] HP) was destroyed by an unknown interceptor" + val attackerText = if (!attacker.isDefeated()) + "Our [$attackerName] ([-$damage] HP) was attacked by an intercepting [$interceptorName] ([-0] HP)" + else if (interceptor.getTile() in attacker.getCivInfo().viewableTiles) + "Our [$attackerName] ([-$damage] HP) was destroyed by an intercepting [$interceptorName] ([-0] HP)" + else "Our [$attackerName] ([-$damage] HP) was destroyed by an unknown interceptor" - attacker.getCivInfo().addNotification( - attackerText, interceptor.currentTile.position, NotificationCategory.War, - attackerName, NotificationIcon.War, interceptorName - ) + attacker.getCivInfo().addNotification( + attackerText, interceptor.currentTile.position, NotificationCategory.War, + attackerName, NotificationIcon.War, interceptorName + ) - val interceptorText = if (attacker.isDefeated()) - "Our [$interceptorName] ([-0] HP) intercepted and destroyed an enemy [$attackerName] ([-$damage] HP)" - else "Our [$interceptorName] ([-0] HP) intercepted and attacked an enemy [$attackerName] ([-$damage] HP)" - interceptingCiv.addNotification(interceptorText, locations, NotificationCategory.War, - interceptorName, NotificationIcon.War, attackerName) - return - } + val interceptorText = if (attacker.isDefeated()) + "Our [$interceptorName] ([-0] HP) intercepted and destroyed an enemy [$attackerName] ([-$damage] HP)" + else "Our [$interceptorName] ([-0] HP) intercepted and attacked an enemy [$attackerName] ([-$damage] HP)" + interceptingCiv.addNotification(interceptorText, locations, NotificationCategory.War, + interceptorName, NotificationIcon.War, attackerName) + + return DamageDealt(0, damage) } private fun doWithdrawFromMeleeAbility(attacker: ICombatant, defender: ICombatant, baseWithdrawChance: Int): Boolean { diff --git a/core/src/com/unciv/logic/battle/BattleDamage.kt b/core/src/com/unciv/logic/battle/BattleDamage.kt index 491e5bdfff..b0a554e617 100644 --- a/core/src/com/unciv/logic/battle/BattleDamage.kt +++ b/core/src/com/unciv/logic/battle/BattleDamage.kt @@ -261,7 +261,7 @@ object BattleDamage { defender: ICombatant, tileToAttackFrom: Tile = defender.getTile(), /** Between 0 and 1. Defaults to turn and location-based random to avoid save scumming */ - randomnessFactor: Float = Random(attacker.getCivInfo().gameInfo.turns * attacker.getTile().position.hashCode().toLong()).nextFloat() + randomnessFactor: Float = Random(defender.getCivInfo().gameInfo.turns * defender.getTile().position.hashCode().toLong()).nextFloat() , ): Int { if (defender.isCivilian()) return 40 diff --git a/core/src/com/unciv/ui/components/UncivTooltip.kt b/core/src/com/unciv/ui/components/UncivTooltip.kt index 1e9fb1340e..2dd16a6203 100644 --- a/core/src/com/unciv/ui/components/UncivTooltip.kt +++ b/core/src/com/unciv/ui/components/UncivTooltip.kt @@ -96,8 +96,8 @@ class UncivTooltip ( state = TipState.Showing container.addAction(Actions.sequence( Actions.parallel( - Actions.fadeIn(UncivSlider.tipAnimationDuration, Interpolation.fade), - Actions.scaleTo(1f, 1f, 0.2f, Interpolation.fade) + Actions.fadeIn(tipAnimationDuration, Interpolation.fade), + Actions.scaleTo(1f, 1f, tipAnimationDuration, Interpolation.fade) ), Actions.run { if (state == TipState.Showing) state = TipState.Shown } )) @@ -117,8 +117,8 @@ class UncivTooltip ( state = TipState.Hiding container.addAction(Actions.sequence( Actions.parallel( - Actions.alpha(0.2f, 0.2f, Interpolation.fade), - Actions.scaleTo(0.05f, 0.05f, 0.2f, Interpolation.fade) + Actions.alpha(0.2f, tipAnimationDuration, Interpolation.fade), + Actions.scaleTo(0.05f, 0.05f, tipAnimationDuration, Interpolation.fade) ), Actions.removeActor(), Actions.run { if (state == TipState.Hiding) state = TipState.Hidden } @@ -164,6 +164,9 @@ class UncivTooltip ( //endregion companion object { + /** Duration of the fade/zoom-in/out animations */ + const val tipAnimationDuration = 0.2f + /** * Add a [Label]-based Tooltip with a rounded-corner background to a [Table] or other [Group]. * diff --git a/core/src/com/unciv/ui/screens/worldscreen/WorldMapHolder.kt b/core/src/com/unciv/ui/screens/worldscreen/WorldMapHolder.kt index 6cfeac164d..64ed335332 100644 --- a/core/src/com/unciv/ui/screens/worldscreen/WorldMapHolder.kt +++ b/core/src/com/unciv/ui/screens/worldscreen/WorldMapHolder.kt @@ -53,6 +53,7 @@ import com.unciv.ui.components.tilegroups.WorldTileGroup import com.unciv.ui.images.ImageGetter import com.unciv.ui.screens.basescreen.BaseScreen import com.unciv.ui.screens.basescreen.UncivStage +import com.unciv.ui.screens.worldscreen.bottombar.BattleTableHelpers.battleAnimation import com.unciv.utils.Log import com.unciv.utils.Concurrency import com.unciv.utils.launchOnGLThread @@ -229,6 +230,7 @@ class WorldMapHolder( } /** If we are in unit-swapping mode and didn't find a swap partner, we don't want to move or attack */ } else { + // This seems inefficient as the tileToAttack is already known - but the method also calculates tileToAttackFrom val attackableTile = BattleHelper .getAttackableEnemies(unit, unit.movement.getDistanceToTiles()) .firstOrNull { it.tileToAttack == tile } @@ -237,7 +239,9 @@ class WorldMapHolder( val attacker = MapUnitCombatant(unit) if (!Battle.movePreparingAttack(attacker, attackableTile)) return SoundPlayer.play(attacker.getAttackSound()) - Battle.attackOrNuke(attacker, attackableTile) + val (damageToDefender, damageToAttacker) = Battle.attackOrNuke(attacker, attackableTile) + if (attackableTile.combatant != null) + worldScreen.battleAnimation(attacker, damageToAttacker, attackableTile.combatant, damageToDefender) localShouldUpdate = true } else if (unit.movement.canReach(tile)) { /** ****** Right-click Move ****** */ diff --git a/core/src/com/unciv/ui/screens/worldscreen/bottombar/BattleTable.kt b/core/src/com/unciv/ui/screens/worldscreen/bottombar/BattleTable.kt index 0b21c7b0e1..c32d7556d5 100644 --- a/core/src/com/unciv/ui/screens/worldscreen/bottombar/BattleTable.kt +++ b/core/src/com/unciv/ui/screens/worldscreen/bottombar/BattleTable.kt @@ -201,28 +201,9 @@ class BattleTable(val worldScreen: WorldScreen): Table() { val maxDamageToDefender = BattleDamage.calculateDamageToDefender(attacker, defender, tileToAttackFrom, 1f) val minDamageToDefender = BattleDamage.calculateDamageToDefender(attacker, defender, tileToAttackFrom, 0f) - var expectedDamageToDefenderForHealthbar = (maxDamageToDefender + minDamageToDefender) / 2 val maxDamageToAttacker = BattleDamage.calculateDamageToAttacker(attacker, defender, tileToAttackFrom, 1f) val minDamageToAttacker = BattleDamage.calculateDamageToAttacker(attacker, defender, tileToAttackFrom, 0f) - var expectedDamageToAttackerForHealthbar = (maxDamageToAttacker + minDamageToAttacker) / 2 - - if (expectedDamageToAttackerForHealthbar > attacker.getHealth() && expectedDamageToDefenderForHealthbar > defender.getHealth()) { - // when damage exceeds health, we don't want to show negative health numbers - // Also if both parties are supposed to die it's not indicative of who is more likely to win - // So we "normalize" the damages until one dies - if (expectedDamageToDefenderForHealthbar * attacker.getHealth() > expectedDamageToAttackerForHealthbar * defender.getHealth()) { // defender dies quicker ie first - // Both damages *= (defender.health/damageToDefender) - expectedDamageToDefenderForHealthbar = defender.getHealth() - expectedDamageToAttackerForHealthbar *= (defender.getHealth() / expectedDamageToDefenderForHealthbar.toFloat()).toInt() - } else { // attacker dies first - // Both damages *= (attacker.health/damageToAttacker) - expectedDamageToAttackerForHealthbar = attacker.getHealth() - expectedDamageToDefenderForHealthbar *= (attacker.getHealth() / expectedDamageToAttackerForHealthbar.toFloat()).toInt() - } - } - else if (expectedDamageToAttackerForHealthbar > attacker.getHealth()) expectedDamageToAttackerForHealthbar = attacker.getHealth() - else if (expectedDamageToDefenderForHealthbar > defender.getHealth()) expectedDamageToDefenderForHealthbar = defender.getHealth() if (attacker.isMelee() && (defender.isCivilian() || defender is CityCombatant && defender.isDefeated())) { @@ -281,7 +262,7 @@ class BattleTable(val worldScreen: WorldScreen): Table() { attackButton.label.color = Color.GRAY } else { attackButton.onClick(UncivSound.Silent) { // onAttackButtonClicked will do the sound - onAttackButtonClicked(attacker, defender, attackableTile, expectedDamageToAttackerForHealthbar, expectedDamageToDefenderForHealthbar) + onAttackButtonClicked(attacker, defender, attackableTile) } } @@ -295,9 +276,7 @@ class BattleTable(val worldScreen: WorldScreen): Table() { private fun onAttackButtonClicked( attacker: ICombatant, defender: ICombatant, - attackableTile: AttackableTile, - damageToAttacker: Int, - damageToDefender: Int + attackableTile: AttackableTile ) { val canStillAttack = Battle.movePreparingAttack(attacker, attackableTile) worldScreen.mapHolder.removeUnitActionOverlay() // the overlay was one of attacking @@ -309,7 +288,7 @@ class BattleTable(val worldScreen: WorldScreen): Table() { if (!canStillAttack) return SoundPlayer.play(attacker.getAttackSound()) - Battle.attackOrNuke(attacker, attackableTile) + val (damageToDefender, damageToAttacker) = Battle.attackOrNuke(attacker, attackableTile) worldScreen.battleAnimation(attacker, damageToAttacker, defender, damageToDefender) } diff --git a/core/src/com/unciv/ui/screens/worldscreen/bottombar/BattleTableHelpers.kt b/core/src/com/unciv/ui/screens/worldscreen/bottombar/BattleTableHelpers.kt index 850bce50c5..b6b699a2b3 100644 --- a/core/src/com/unciv/ui/screens/worldscreen/bottombar/BattleTableHelpers.kt +++ b/core/src/com/unciv/ui/screens/worldscreen/bottombar/BattleTableHelpers.kt @@ -5,40 +5,68 @@ import com.badlogic.gdx.math.Interpolation import com.badlogic.gdx.math.Vector2 import com.badlogic.gdx.scenes.scene2d.Actor import com.badlogic.gdx.scenes.scene2d.Group +import com.badlogic.gdx.scenes.scene2d.Touchable import com.badlogic.gdx.scenes.scene2d.actions.Actions import com.badlogic.gdx.scenes.scene2d.actions.FloatAction import com.badlogic.gdx.scenes.scene2d.actions.RelativeTemporalAction import com.badlogic.gdx.scenes.scene2d.actions.RepeatAction import com.badlogic.gdx.scenes.scene2d.actions.SequenceAction +import com.badlogic.gdx.scenes.scene2d.actions.TemporalAction import com.badlogic.gdx.scenes.scene2d.ui.Image +import com.badlogic.gdx.scenes.scene2d.ui.Stack import com.badlogic.gdx.scenes.scene2d.ui.Table +import com.badlogic.gdx.scenes.scene2d.ui.WidgetGroup +import com.badlogic.gdx.utils.Align import com.unciv.UncivGame import com.unciv.logic.battle.ICombatant import com.unciv.logic.battle.MapUnitCombatant import com.unciv.logic.map.HexMath +import com.unciv.ui.components.extensions.toLabel import com.unciv.ui.components.tilegroups.TileSetStrings import com.unciv.ui.images.ImageGetter import com.unciv.ui.screens.worldscreen.WorldScreen -object BattleTableHelpers { - class FlashRedAction(start:Float, end:Float, private val actorsToOriginalColors:Map) : FloatAction(start, end, 0.2f, Interpolation.sine){ +object BattleTableHelpers { + /** Duration of the red-tint transition, used once per direction */ + private const val flashRedDuration = 0.2f + /** Duration of the attacker displacement, used once per direction */ + private const val moveActorsDuration = 0.3f + /** Max distance of the attacker displacement, in world coords */ + private const val moveActorsDisplacement = 10f + /** If a mod provides attack animations (e.g. swinging a sword), they're played with this duration per frame */ + private const val attackAnimationFrameDuration = 0.1f + /** Duration a damage number label is visible */ + private const val damageLabelDuration = 1.2f + /** Size of a damage number label - currently fixed independednt of map zoom */ + private const val damageLabelFontSize = 40 + /** Total distance a damage number label is displaced upwards during that time in world coords */ + private const val damageLabelDisplacement = 90f + + + class FlashRedAction( + start: Float, end: Float, + private val actorsToOriginalColors: Map + ) : FloatAction(start, end, flashRedDuration, Interpolation.sine) { private fun updateRedPercent(percent: Float) { for ((actor, color) in actorsToOriginalColors) - actor.color = color.cpy().lerp(Color.RED, start+percent*(end-start)) + actor.color = color.cpy().lerp(Color.RED, start + percent * (end - start)) } override fun update(percent: Float) = updateRedPercent(percent) } - class MoveActorsAction(private val actorsToMove:List, private val movementVector: Vector2) : RelativeTemporalAction(){ + class MoveActorsAction( + private val actorsToMove: List, + private val movementVector: Vector2 + ) : RelativeTemporalAction() { init { - duration = 0.3f + duration = moveActorsDuration interpolation = Interpolation.sine } override fun updateRelative(percentDelta: Float) { - for (actor in actorsToMove){ + for (actor in actorsToMove) { actor.moveBy(movementVector.x * percentDelta, movementVector.y * percentDelta) } } @@ -46,21 +74,21 @@ object BattleTableHelpers { class AttackAnimationAction( - val attacker: ICombatant, - val defenderActors: List, - val currentTileSetStrings: TileSetStrings - ): SequenceAction(){ + private val attacker: ICombatant, + defenderActors: List, + private val currentTileSetStrings: TileSetStrings + ): SequenceAction() { init { if (defenderActors.any()) { val attackAnimationLocation = getAttackAnimationLocation() - if (attackAnimationLocation != null){ + if (attackAnimationLocation != null) { var i = 1 - while (ImageGetter.imageExists(attackAnimationLocation+i)){ - val image = ImageGetter.getImage(attackAnimationLocation+i) + while (ImageGetter.imageExists(attackAnimationLocation + i)){ + val image = ImageGetter.getImage(attackAnimationLocation + i) addAction(Actions.run { defenderActors.first().parent.addActor(image) }) - addAction(Actions.delay(0.1f)) + addAction(Actions.delay(attackAnimationFrameDuration)) addAction(Actions.removeActor(image)) i++ } @@ -68,25 +96,55 @@ object BattleTableHelpers { } } - private fun getAttackAnimationLocation(): String?{ + private fun getAttackAnimationLocation(): String? { + fun TileSetStrings.getLocation(name: String) = getString(unitsLocation, name, "-attack-") + if (attacker is MapUnitCombatant) { - val unitSpecificAttackAnimationLocation = - currentTileSetStrings.getString( - currentTileSetStrings.unitsLocation, - attacker.getUnitType().name, - "-attack-" - ) - if (ImageGetter.imageExists(unitSpecificAttackAnimationLocation+"1")) return unitSpecificAttackAnimationLocation + val unitSpecificAttackAnimationLocation = currentTileSetStrings.getLocation(attacker.getName()) + if (ImageGetter.imageExists(unitSpecificAttackAnimationLocation + "1")) + return unitSpecificAttackAnimationLocation } - val unitTypeAttackAnimationLocation = - currentTileSetStrings.getString(currentTileSetStrings.unitsLocation, attacker.getUnitType().name, "-attack-") - - if (ImageGetter.imageExists(unitTypeAttackAnimationLocation+"1")) return unitTypeAttackAnimationLocation + val unitTypeAttackAnimationLocation = currentTileSetStrings.getLocation(attacker.getUnitType().name) + if (ImageGetter.imageExists(unitTypeAttackAnimationLocation + "1")) + return unitTypeAttackAnimationLocation return null } } + + /** The animation for the Damage labels */ + private class DamageLabelAnimation(actor: WidgetGroup) : TemporalAction(damageLabelDuration) { + val startX = actor.x + val startY = actor.y + + /* A tested version with smooth scale-out in addition to the alpha fade + val width = actor.width + val height = actor.height + init { + actor.isTransform = true + } + override fun update(percent: Float) { + actor.color.a = Interpolation.fade.apply(1f - percent) + val scale = Interpolation.smooth.apply(1f - percent) + actor.setScale(scale) + val x = startX + (1f - scale) * width / 2 + val y = startY + (1f - scale) * height / 2 + + Interpolation.smooth.apply(percent) * damageLabelDisplacement + actor.setPosition(x, y) + } + */ + + override fun update(percent: Float) { + actor.color.a = Interpolation.fade.apply(1f - percent) + actor.setPosition(startX, startY + percent * damageLabelDisplacement) + } + override fun end() { + actor.remove() + } + } + + fun WorldScreen.battleAnimation( attacker: ICombatant, damageToAttacker: Int, defender: ICombatant, damageToDefender: Int @@ -97,8 +155,7 @@ object BattleTableHelpers { if (combatant.isCity()) { val icon = tileGroup.layerMisc.improvementIcon if (icon != null) yield (icon) - } - else { + } else if (!combatant.isAirUnit()) { val slot = if (combatant.isCivilian()) 0 else 1 yieldAll((tileGroup.layerUnitArt.getChild(slot) as Group).children) } @@ -108,18 +165,28 @@ object BattleTableHelpers { sequence { if (damageToDefender != 0) yieldAll(getMapActorsForCombatant(defender)) if (damageToAttacker != 0) yieldAll(getMapActorsForCombatant(attacker)) - }.mapTo(arrayListOf()) { it to it.color.cpy() }.toMap() + }.associateWith { it.color.cpy() } val actorsToMove = getMapActorsForCombatant(attacker).toList() val attackVectorHexCoords = defender.getTile().position.cpy().sub(attacker.getTile().position) val attackVectorWorldCoords = HexMath.hex2WorldCoords(attackVectorHexCoords) .nor() // normalize vector to length of "1" - .scl(10f) // we want 10 pixel movement + .scl(moveActorsDisplacement) + + val attackerGroup = mapHolder.tileGroups[attacker.getTile()]!! + val defenderGroup = mapHolder.tileGroups[defender.getTile()]!! + val hideDefenderDamage = defender.isDefeated() && + attacker.getTile().position == defender.getTile().position stage.addAction( Actions.sequence( MoveActorsAction(actorsToMove, attackVectorWorldCoords), + Actions.run { + createDamageLabel(damageToAttacker, attackerGroup) + if (!hideDefenderDamage) + createDamageLabel(damageToDefender, defenderGroup) + }, Actions.parallel( // While the unit is moving back to its normal position, we flash the damages on both units MoveActorsAction(actorsToMove, attackVectorWorldCoords.cpy().scl(-1f)), AttackAnimationAction(attacker, @@ -137,8 +204,27 @@ object BattleTableHelpers { ) ) )) + } + private fun createDamageLabel(damage: Int, target: Actor) { + if (damage == 0) return + val label = (-damage).toString().toLabel(Color.RED, damageLabelFontSize, Align.topLeft, true) + label.touchable = Touchable.disabled + val shadow = (-damage).toString().toLabel(Color.BLACK, damageLabelFontSize, Align.bottomRight, true) + shadow.touchable = Touchable.disabled + + val container = Stack(shadow, label) + container.touchable = Touchable.disabled + + container.pack() + // The +1f is what displaces the shadow under the red label + container.setSize(container.width + 1f, container.height + 1f) + val targetRight = target.run { localToStageCoordinates(Vector2(width, height * 0.5f)) } + container.setPosition(targetRight.x, targetRight.y, Align.center) + target.stage.addActor(container) + + container.addAction(DamageLabelAnimation(container)) } fun getHealthBar(maxHealth: Int, currentHealth: Int, maxRemainingHealth: Int, minRemainingHealth: Int): Table {