From 0461d9d7fd6f0e639e4382b4cc4e946e55bbf822 Mon Sep 17 00:00:00 2001 From: SomeTroglodyte <63000004+SomeTroglodyte@users.noreply.github.com> Date: Wed, 25 May 2022 18:34:41 +0200 Subject: [PATCH] Fix Right-Click attacks made no sound (#6906) * Fix Right-Click attacks made no sound * Fix Right-Click attacks made no sound - no UI in logic * Fix Right-Click attacks made no sound - comments * Fix Right-Click attacks made no sound - comments --- .../unciv/logic/automation/BattleHelper.kt | 8 +- core/src/com/unciv/logic/battle/Battle.kt | 95 +++++++++++-------- core/src/com/unciv/ui/audio/Sounds.kt | 22 ++--- .../unciv/ui/worldscreen/WorldMapHolder.kt | 54 ++++++----- .../com/unciv/ui/worldscreen/WorldScreen.kt | 2 +- .../ui/worldscreen/bottombar/BattleTable.kt | 72 ++++++++------ 6 files changed, 148 insertions(+), 105 deletions(-) diff --git a/core/src/com/unciv/logic/automation/BattleHelper.kt b/core/src/com/unciv/logic/automation/BattleHelper.kt index b36fd83bed..a79f4d3cb6 100644 --- a/core/src/com/unciv/logic/automation/BattleHelper.kt +++ b/core/src/com/unciv/logic/automation/BattleHelper.kt @@ -94,7 +94,7 @@ object BattleHelper { fun containsAttackableEnemy(tile: TileInfo, combatant: ICombatant): Boolean { if (combatant is MapUnitCombatant && combatant.unit.isEmbarked() && !combatant.hasUnique(UniqueType.AttackOnSea)) { // Can't attack water units while embarked, only land - if (tile.isWater || combatant.isRanged()) + if (tile.isWater || combatant.isRanged()) return false } @@ -102,7 +102,7 @@ object BattleHelper { if (tileCombatant.getCivInfo() == combatant.getCivInfo()) return false if (!combatant.getCivInfo().isAtWarWith(tileCombatant.getCivInfo())) return false - if (combatant is MapUnitCombatant && + if (combatant is MapUnitCombatant && combatant.unit.hasUnique(UniqueType.CanOnlyAttackUnits) && combatant.unit.getMatchingUniques(UniqueType.CanOnlyAttackUnits).none { tileCombatant.matchesCategory(it.params[0]) } ) @@ -117,7 +117,7 @@ object BattleHelper { // Only units with the right unique can view submarines (or other invisible units) from more then one tile away. // Garrisoned invisible units can be attacked by anyone, as else the city will be in invincible. if (tileCombatant.isInvisible(combatant.getCivInfo()) && !tile.isCityCenter()) { - return combatant is MapUnitCombatant + return combatant is MapUnitCombatant && combatant.getCivInfo().viewableInvisibleUnitsTiles.map { it.position }.contains(tile.position) } return true @@ -169,4 +169,4 @@ object BattleHelper { return enemyTileToAttack } -} \ No newline at end of file +} diff --git a/core/src/com/unciv/logic/battle/Battle.kt b/core/src/com/unciv/logic/battle/Battle.kt index 3020fbf863..125cb5047b 100644 --- a/core/src/com/unciv/logic/battle/Battle.kt +++ b/core/src/com/unciv/logic/battle/Battle.kt @@ -27,30 +27,51 @@ import kotlin.math.min */ 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! + */ fun moveAndAttack(attacker: ICombatant, attackableTile: AttackableTile) { - if (attacker is MapUnitCombatant) { - attacker.unit.movement.moveToTile(attackableTile.tileToAttackFrom) - /** - * When calculating movement distance, we assume that a hidden tile is 1 movement point, - * which can lead to EXCEEDINGLY RARE edge cases where you think - * that you can attack a tile by passing through a HIDDEN TILE, - * but the hidden tile is actually IMPASSIBLE so you stop halfway! - */ - if (attacker.getTile() != attackableTile.tileToAttackFrom) return - /** Alternatively, maybe we DID reach that tile, but it turned out to be a hill or something, - * so we expended all of our movement points! - */ - if (attacker.unit.currentMovement == 0f) - return - if (attacker.hasUnique(UniqueType.MustSetUp) && !attacker.unit.isSetUpForSiege()) { - attacker.unit.action = UnitActionType.SetUp.value - attacker.unit.useMovementPoints(1f) - } - } + if (!movePreparingAttack(attacker, attackableTile)) return + attackOrNuke(attacker, attackableTile) + } + /** + * Moves [attacker] to [attackableTile], handles siege setup and returns `true` if an attack is still possible. + * + * This is a logic function, not UI, so e.g. sound needs to be handled after calling this. + */ + fun movePreparingAttack(attacker: ICombatant, attackableTile: AttackableTile): Boolean { + if (attacker !is MapUnitCombatant) return true + attacker.unit.movement.moveToTile(attackableTile.tileToAttackFrom) + /** + * When calculating movement distance, we assume that a hidden tile is 1 movement point, + * which can lead to EXCEEDINGLY RARE edge cases where you think + * that you can attack a tile by passing through a HIDDEN TILE, + * but the hidden tile is actually IMPASSIBLE so you stop halfway! + */ + if (attacker.getTile() != attackableTile.tileToAttackFrom) return false + /** Alternatively, maybe we DID reach that tile, but it turned out to be a hill or something, + * so we expended all of our movement points! + */ + if (attacker.hasUnique(UniqueType.MustSetUp) + && !attacker.unit.isSetUpForSiege() + && attacker.unit.currentMovement > 0f + ) { + attacker.unit.action = UnitActionType.SetUp.value + attacker.unit.useMovementPoints(1f) + } + return (attacker.unit.currentMovement > 0f) + } + + /** + * 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()) - return NUKE(attacker, attackableTile.tileToAttack) - attack(attacker, getMapCombatantOfTile(attackableTile.tileToAttack)!!) + NUKE(attacker, attackableTile.tileToAttack) + else + attack(attacker, getMapCombatantOfTile(attackableTile.tileToAttack)!!) } fun attack(attacker: ICombatant, defender: ICombatant) { @@ -98,9 +119,9 @@ object Battle { if (defender.getCivInfo().isBarbarian() && attackedTile.improvement == Constants.barbarianEncampment) defender.getCivInfo().gameInfo.barbarians.campAttacked(attackedTile.position) - + postBattleNationUniques(defender, attackedTile, attacker) - + // This needs to come BEFORE the move-to-tile, because if we haven't conquered it we can't move there =) if (defender.isDefeated() && defender is CityCombatant && attacker is MapUnitCombatant && attacker.isMelee() && !attacker.unit.hasUnique(UniqueType.CannotCaptureCities)) { @@ -316,7 +337,7 @@ object Battle { attacker.unit.healBy(amountToHeal) } } - + /** Places a [unitName] unit near [tile] after being attacked by [attacker]. * Adds a notification to [attacker]'s civInfo and returns whether the captured unit could be placed */ private fun spawnCapturedUnit(unitName: String, attacker: ICombatant, tile: TileInfo, notification: String): Boolean { @@ -332,19 +353,19 @@ object Battle { private fun postBattleNationUniques(defender: ICombatant, attackedTile: TileInfo, attacker: ICombatant) { if (!defender.isDefeated()) return - + // Barbarians reduce spawn countdown after their camp was attacked "kicking the hornet's nest" if (defender.getCivInfo().isBarbarian() && attackedTile.improvement == Constants.barbarianEncampment) { var unitPlaced = false // German unique - needs to be checked before we try to move to the enemy tile, since the encampment disappears after we move in // Deprecated as of 4.0.3 - if (attacker.getCivInfo().hasUnique(UniqueType.ChanceToRecruitBarbarianFromEncampment) + if (attacker.getCivInfo().hasUnique(UniqueType.ChanceToRecruitBarbarianFromEncampment) && Random().nextDouble() < 0.67 ) { attacker.getCivInfo().addGold(25) unitPlaced = spawnCapturedUnit(defender.getName(), attacker, attackedTile,"A barbarian [${defender.getName()}] has joined us!") } - + // New version of unique // for (unique in attacker.getCivInfo().getMatchingUniques(UniqueType.GainFromEncampment)) { @@ -353,7 +374,7 @@ object Battle { unitPlaced = spawnCapturedUnit(defender.getName(), attacker, attackedTile,"A barbarian [${defender.getName()}] has joined us!") } } - + // Similarly, Ottoman unique // Deprecated as of 4.0.3 if (attacker.getCivInfo().hasUnique(UniqueType.ChanceToRecruitNavalBarbarian) @@ -449,7 +470,7 @@ object Battle { ) { return } - + val stateForConditionals = StateForConditionals(civInfo = thisCombatant.getCivInfo(), ourCombatant = thisCombatant, theirCombatant = otherCombatant) for (unique in thisCombatant.getMatchingUniques(UniqueType.FlatXPGain, stateForConditionals, true)) @@ -459,7 +480,7 @@ object Battle { for (unique in thisCombatant.getMatchingUniques(UniqueType.PercentageXPGain, stateForConditionals, true)) xpModifier += unique.params[0].toFloat() / 100 - + val xpGained = (baseXP * xpModifier).toInt() thisCombatant.unit.promotions.XP += xpGained @@ -481,8 +502,8 @@ object Battle { private fun conquerCity(city: CityInfo, attacker: MapUnitCombatant) { val attackerCiv = attacker.getCivInfo() - - + + attackerCiv.addNotification("We have conquered the city of [${city.name}]!", city.location, NotificationIcon.War) city.hasJustBeenConquered = true @@ -610,13 +631,13 @@ object Battle { attacker.popupAlerts.add(PopupAlert(AlertType.Defeated, attackedCiv.civName)) } } - + fun mayUseNuke(nuke: MapUnitCombatant, targetTile: TileInfo): Boolean { val blastRadius = if (!nuke.hasUnique(UniqueType.BlastRadius)) 2 // Don't check conditionals as these are not supported else nuke.unit.getMatchingUniques(UniqueType.BlastRadius).first().params[0].toInt() - + var canNuke = true val attackerCiv = nuke.getCivInfo() for (tile in targetTile.getTilesInDistance(blastRadius)) { @@ -761,7 +782,7 @@ object Battle { tile.addTerrainFeature("Fallout") } if (!tile.terrainHasUnique(UniqueType.DestroyableByNukes)) return - + // Deprecated as of 3.19.19 -- If removed, the two successive `if`s above should be merged val destructionChance = if (tile.terrainHasUnique(UniqueType.ResistsNukes)) 0.25f else 0.5f @@ -797,13 +818,13 @@ object Battle { targetedCity.population.addPopulation(-populationLoss.toInt()) if (targetedCity.population.population < 1) targetedCity.population.setPopulation(1) } - + private fun tryInterceptAirAttack(attacker: MapUnitCombatant, attackedTile: TileInfo, interceptingCiv: CivilizationInfo, defender: ICombatant?) { if (attacker.unit.hasUnique(UniqueType.CannotBeIntercepted)) return // Pick highest chance interceptor for (interceptor in interceptingCiv.getCivUnits() .filter { it.canIntercept(attackedTile) } - .sortedByDescending { it.interceptChance() }) { + .sortedByDescending { it.interceptChance() }) { // defender can't also intercept if (defender != null && defender is MapUnitCombatant && interceptor == defender.unit) continue // Does Intercept happen? If not, exit diff --git a/core/src/com/unciv/ui/audio/Sounds.kt b/core/src/com/unciv/ui/audio/Sounds.kt index 8598bc0fba..ace3cd76b7 100644 --- a/core/src/com/unciv/ui/audio/Sounds.kt +++ b/core/src/com/unciv/ui/audio/Sounds.kt @@ -12,7 +12,7 @@ import java.io.File /* * Problems on Android - * + * * Essentially the freshly created Gdx Sound object from newSound() is not immediately usable, it * needs some preparation time - buffering, decoding, whatever. Calling play() immediately will result * in no sound, a logcat warning (not ready), and nothing else - specifically no exceptions. Also, @@ -20,14 +20,14 @@ import java.io.File * (resource failed to clean up). Also, Gdx will attempt fast track, which will cause logcat entries, * and these will be warnings if the sound file's sample rate (not bitrate) does not match the device's * hardware preferred bitrate. On a Xiaomi Mi8 that is 48kHz, not 44.1kHz. Channel count also must match. - * + * * @see "https://github.com/libgdx/libgdx/issues/1775" * logcat entry "W/System: A resource failed to call end.": * unavoidable as long as we cache Gdx Sound objects loaded from assets * logcat entry "W/SoundPool: sample X not READY": * could be avoided by preloading the 'cache' or otherwise ensuring a minimum delay between * newSound() and play() - there's no test function that does not trigger logcat warnings. - * + * * Current approach: Cache on demand as before, catch stream not ready and retry. This maximizes * logcat messages but user experience is acceptable. Empiric delay needed was measured a 40ms * so that is the minimum wait before attempting play when we know the sound is freshly cached @@ -47,11 +47,11 @@ object Sounds { @Suppress("EnumEntryName") private enum class SupportedExtensions { mp3, ogg, wav } // Per Gdx docs, no aac/m4a - + private val soundMap = HashMap() - + private val separator = File.separator // just a shorthand for readability - + private var modListHash = Int.MIN_VALUE /** Ensure cache is not outdated */ @@ -101,7 +101,7 @@ object Sounds { return modList.asSequence() .map { "mods$separator$it$separator" } + sequenceOf("") - } + } /** Holds a Gdx Sound and a flag indicating the sound is freshly loaded and not from cache */ private data class GetSoundResult(val resource: Sound, val isFresh: Boolean) @@ -113,7 +113,7 @@ object Sounds { private fun get(sound: UncivSound): GetSoundResult? { checkCache() // Look for cached sound - if (sound in soundMap) + if (sound in soundMap) return if(soundMap[sound] == null) null else GetSoundResult(soundMap[sound]!!, false) @@ -124,7 +124,7 @@ object Sounds { // This is essentially a cross join. To operate on all combinations, we pack both lambda // parameters into a Pair (using `to`) and unwrap that in the loop using automatic data // class deconstruction `(,)`. All this avoids a double break when a match is found. - folder -> SupportedExtensions.values().asSequence().map { folder to it } + folder -> SupportedExtensions.values().asSequence().map { folder to it } } ) { val path = "${modFolder}sounds$separator$fileName.${extension.name}" file = Gdx.files.local(path) @@ -152,10 +152,10 @@ object Sounds { * * Sources are mods from a loaded game, then mods marked as permanent audiovisual, * and lastly Unciv's own assets/sounds. Will fail silently if the sound file cannot be found. - * + * * This will wait for the Stream to become ready (Android issue) if necessary, and do so on a * separate thread. No new thread is created if the sound can be played immediately. - * + * * @param sound The sound to play */ fun play(sound: UncivSound) { diff --git a/core/src/com/unciv/ui/worldscreen/WorldMapHolder.kt b/core/src/com/unciv/ui/worldscreen/WorldMapHolder.kt index 2c6f054e84..b00a31ac54 100644 --- a/core/src/com/unciv/ui/worldscreen/WorldMapHolder.kt +++ b/core/src/com/unciv/ui/worldscreen/WorldMapHolder.kt @@ -116,29 +116,7 @@ class WorldMapHolder(internal val worldScreen: WorldScreen, internal val tileMap val unit = worldScreen.bottomUnitTable.selectedUnit ?: return launchCrashHandling("WorldScreenClick") { - val tile = tileGroup.tileInfo - - if (worldScreen.bottomUnitTable.selectedUnitIsSwapping) { - if (unit.movement.canUnitSwapTo(tile)) { - swapMoveUnitToTargetTile(unit, tile) - } - // If we are in unit-swapping mode, we don't want to move or attack - return@launchCrashHandling - } - - val attackableTile = BattleHelper.getAttackableEnemies(unit, unit.movement.getDistanceToTiles()) - .firstOrNull { it.tileToAttack == tileGroup.tileInfo } - if (unit.canAttack() && attackableTile != null) { - Battle.moveAndAttack(MapUnitCombatant(unit), attackableTile) - worldScreen.shouldUpdate = true - return@launchCrashHandling - } - - val canUnitReachTile = unit.movement.canReach(tile) - if (canUnitReachTile) { - moveUnitToTargetTile(listOf(unit), tile) - return@launchCrashHandling - } + onTileRightClicked(unit, tileGroup.tileInfo) } } }) @@ -202,6 +180,32 @@ class WorldMapHolder(internal val worldScreen: WorldScreen, internal val tileMap worldScreen.shouldUpdate = true } + private fun onTileRightClicked(unit: MapUnit, tile: TileInfo) { + if (worldScreen.bottomUnitTable.selectedUnitIsSwapping) { + if (unit.movement.canUnitSwapTo(tile)) { + swapMoveUnitToTargetTile(unit, tile) + } + // If we are in unit-swapping mode, we don't want to move or attack + return + } + + val attackableTile = BattleHelper.getAttackableEnemies(unit, unit.movement.getDistanceToTiles()) + .firstOrNull { it.tileToAttack == tile } + if (unit.canAttack() && attackableTile != null) { + worldScreen.shouldUpdate = true + val attacker = MapUnitCombatant(unit) + if (!Battle.movePreparingAttack(attacker, attackableTile)) return + Sounds.play(attacker.getAttackSound()) + Battle.attackOrNuke(attacker, attackableTile) + return + } + + val canUnitReachTile = unit.movement.canReach(tile) + if (canUnitReachTile) { + moveUnitToTargetTile(listOf(unit), tile) + return + } + } private fun moveUnitToTargetTile(selectedUnits: List, targetTile: TileInfo) { // this can take a long time, because of the unit-to-tile calculation needed, so we put it in a different thread @@ -550,7 +554,7 @@ class WorldMapHolder(internal val worldScreen: WorldScreen, internal val tileMap } } - // Same as below - randomly, tileGroups doesn't seem to contain the selected tile, and this doesn't seem duplicatable + // Same as below - randomly, tileGroups doesn't seem to contain the selected tile, and this doesn't seem reproducible val worldTileGroupsForSelectedTile = tileGroups[selectedTile] if (worldTileGroupsForSelectedTile != null) for (group in worldTileGroupsForSelectedTile) @@ -681,7 +685,7 @@ class WorldMapHolder(internal val worldScreen: WorldScreen, internal val tileMap val originalScrollX = scrollX val originalScrollY = scrollY - // We want to center on the middle of the tilegroup (TG.getX()+TG.getWidth()/2) + // We want to center on the middle of the TileGroup (TG.getX()+TG.getWidth()/2) // and so the scroll position (== filter the screen starts) needs to be half the ScrollMap away val finalScrollX = tileGroup.x + tileGroup.width / 2 - width / 2 diff --git a/core/src/com/unciv/ui/worldscreen/WorldScreen.kt b/core/src/com/unciv/ui/worldscreen/WorldScreen.kt index 584086203f..84c72adac1 100644 --- a/core/src/com/unciv/ui/worldscreen/WorldScreen.kt +++ b/core/src/com/unciv/ui/worldscreen/WorldScreen.kt @@ -407,7 +407,7 @@ class WorldScreen(val gameInfo: GameInfo, val viewingCiv:CivilizationInfo) : Bas // This is private so that we will set the shouldUpdate to true instead. // That way, not only do we save a lot of unnecessary updates, we also ensure that all updates are called from the main GL thread // and we don't get any silly concurrency problems! - internal fun update() { + private fun update() { displayTutorialsOnUpdate() diff --git a/core/src/com/unciv/ui/worldscreen/bottombar/BattleTable.kt b/core/src/com/unciv/ui/worldscreen/bottombar/BattleTable.kt index 2664f21569..16a9036af3 100644 --- a/core/src/com/unciv/ui/worldscreen/bottombar/BattleTable.kt +++ b/core/src/com/unciv/ui/worldscreen/bottombar/BattleTable.kt @@ -16,8 +16,10 @@ import com.unciv.logic.automation.UnitAutomation import com.unciv.logic.battle.* import com.unciv.logic.map.TileInfo import com.unciv.models.AttackableTile +import com.unciv.models.UncivSound import com.unciv.models.ruleset.unique.UniqueType import com.unciv.models.translations.tr +import com.unciv.ui.audio.Sounds import com.unciv.ui.images.ImageGetter import com.unciv.ui.utils.* import com.unciv.ui.worldscreen.WorldScreen @@ -84,7 +86,7 @@ class BattleTable(val worldScreen: WorldScreen): Table() { if (defender == null || (!includeFriendly && defender.getCivInfo() == attackerCiv)) return null // no enemy combatant in tile - val canSeeDefender = + val canSeeDefender = if (UncivGame.Current.viewEntireMapForDebug) true else { when { @@ -251,29 +253,8 @@ class BattleTable(val worldScreen: WorldScreen): Table() { } else { - attackButton.onClick(attacker.getAttackSound()) { - Battle.moveAndAttack(attacker, attackableTile) - worldScreen.mapHolder.removeUnitActionOverlay() // the overlay was one of attacking - worldScreen.update() - - val actorsToFlashRed = arrayListOf() - - if (damageToDefender != 0) - actorsToFlashRed.addAll(getMapActorsForCombatant(defender)) - if (damageToAttacker != 0) - actorsToFlashRed.addAll(getMapActorsForCombatant(attacker)) - fun updateRedPercent(percent: Float) { - for (actor in actorsToFlashRed) - actor.color = Color.WHITE.cpy().lerp(Color.RED, percent) - } - worldScreen.stage.addAction(Actions.sequence( - object : FloatAction(0f, 1f, 0.3f, Interpolation.sine) { - override fun update(percent: Float) = updateRedPercent(percent) - }, - object : FloatAction(0f, 1f, 0.3f, Interpolation.sine) { - override fun update(percent: Float) = updateRedPercent(1 - percent) - } - )) + attackButton.onClick(UncivSound.Silent) { // onAttackButtonClicked will do the sound + onAttackButtonClicked(attacker, defender, attackableTile, damageToAttacker, damageToDefender) } } @@ -284,6 +265,43 @@ class BattleTable(val worldScreen: WorldScreen): Table() { setPosition(worldScreen.stage.width/2-width/2, 5f) } + private fun onAttackButtonClicked( + attacker: ICombatant, + defender: ICombatant, + attackableTile: AttackableTile, + damageToAttacker: Int, + damageToDefender: Int + ) { + val canStillAttack = Battle.movePreparingAttack(attacker, attackableTile) + worldScreen.mapHolder.removeUnitActionOverlay() // the overlay was one of attacking + // There was a direct worldScreen.update() call here, removing its 'private' but not the comment justifying the modifier. + // My tests (desktop only) show the red-flash animations look just fine without. + worldScreen.shouldUpdate = true + //Gdx.graphics.requestRendering() // Use this if immediate rendering is required + + if (!canStillAttack) return + Sounds.play(attacker.getAttackSound()) + Battle.attackOrNuke(attacker, attackableTile) + + val actorsToFlashRed = arrayListOf() + + if (damageToDefender != 0) + actorsToFlashRed.addAll(getMapActorsForCombatant(defender)) + if (damageToAttacker != 0) + actorsToFlashRed.addAll(getMapActorsForCombatant(attacker)) + fun updateRedPercent(percent: Float) { + for (actor in actorsToFlashRed) + actor.color = Color.WHITE.cpy().lerp(Color.RED, percent) + } + worldScreen.stage.addAction(Actions.sequence( + object : FloatAction(0f, 1f, 0.3f, Interpolation.sine) { + override fun update(percent: Float) = updateRedPercent(percent) + }, + object : FloatAction(0f, 1f, 0.3f, Interpolation.sine) { + override fun update(percent: Float) = updateRedPercent(1 - percent) + } + )) + } fun getMapActorsForCombatant(combatant: ICombatant):Sequence = sequence { @@ -314,17 +332,17 @@ class BattleTable(val worldScreen: WorldScreen): Table() { attackerNameWrapper.add(getIcon(attacker)).padRight(5f) attackerNameWrapper.add(attackerLabel) add(attackerNameWrapper) - + val canNuke = Battle.mayUseNuke(attacker, targetTile) val blastRadius = if (!attacker.unit.hasUnique(UniqueType.BlastRadius)) 2 else attacker.unit.getMatchingUniques(UniqueType.BlastRadius).first().params[0].toInt() - + val defenderNameWrapper = Table() for (tile in targetTile.getTilesInDistance(blastRadius)) { val defender = tryGetDefenderAtTile(tile, true) ?: continue - + val defenderLabel = Label(defender.getName().tr(), skin) defenderNameWrapper.add(getIcon(defender)).padRight(5f) defenderNameWrapper.add(defenderLabel).row()