From 1256f137892084c8671f442570a34e94d0770f2f Mon Sep 17 00:00:00 2001 From: Vanja Date: Sun, 2 Aug 2026 17:31:56 +0100 Subject: [PATCH 1/3] Adventure: Make action animation timing frame-aware --- .../adventure/character/CharacterSprite.java | 28 ++++++++++++++++++- .../src/forge/adventure/stage/MapStage.java | 17 ++++++++--- .../src/forge/adventure/stage/WorldStage.java | 17 ++++++++--- 3 files changed, 53 insertions(+), 9 deletions(-) diff --git a/forge-gui-mobile/src/forge/adventure/character/CharacterSprite.java b/forge-gui-mobile/src/forge/adventure/character/CharacterSprite.java index 88ed76daff74..1416a41d9a5f 100644 --- a/forge-gui-mobile/src/forge/adventure/character/CharacterSprite.java +++ b/forge-gui-mobile/src/forge/adventure/character/CharacterSprite.java @@ -130,10 +130,36 @@ static public Animation FlipAnimation(Animation an public void setAnimation(AnimationTypes type) { if (currentAnimationType != type) { currentAnimationType = type; + if (isOneShotAnimation(type)) { + timer = 0.0f; + } updateAnimation(); } } + /** + * Returns the duration of an animation in the sprite's current direction. + * Uses the supplied fallback when the atlas does not define that animation. + */ + public float getAnimationDuration(AnimationTypes type, float fallbackDuration) { + HashMap> dirs = animations.get(type); + if (dirs == null || dirs.isEmpty()) { + return fallbackDuration; + } + + Animation animation = dirs.get(currentAnimationDir); + if (animation == null) { + animation = dirs.get(AnimationDirections.Right); + } + return animation == null ? fallbackDuration : animation.getAnimationDuration(); + } + + private boolean isOneShotAnimation(AnimationTypes type) { + return type == AnimationTypes.Attack + || type == AnimationTypes.Death + || type == AnimationTypes.Hit; + } + private void updateAnimation() { AnimationTypes aniType = currentAnimationType; AnimationDirections aniDir = currentAnimationDir; @@ -249,7 +275,7 @@ public void draw(Batch batch, float parentAlpha) { if (currentAnimationType.equals(AnimationTypes.Wake)) { currentFrame = currentAnimation.getKeyFrame(wakeTimer, false); } else { - currentFrame = currentAnimation.getKeyFrame(timer, true); + currentFrame = currentAnimation.getKeyFrame(timer, !isOneShotAnimation(currentAnimationType)); } float scale = 1f; diff --git a/forge-gui-mobile/src/forge/adventure/stage/MapStage.java b/forge-gui-mobile/src/forge/adventure/stage/MapStage.java index 276c8597f327..2b522b051d18 100644 --- a/forge-gui-mobile/src/forge/adventure/stage/MapStage.java +++ b/forge-gui-mobile/src/forge/adventure/stage/MapStage.java @@ -828,13 +828,16 @@ public void setWinner(boolean playerWins, boolean isArena) { currentMob.clearCollisionHeight(); Current.player().win(); player.setAnimation(CharacterSprite.AnimationTypes.Attack); + float attackDuration = Math.max(1f, + player.getAnimationDuration(CharacterSprite.AnimationTypes.Attack, 1f)); currentMob.playEffect(Paths.EFFECT_BLOOD, 0.5f); Timer.schedule(new Timer.Task() { @Override public void run() { currentMob.setAnimation(CharacterSprite.AnimationTypes.Death); currentMob.resetCollisionHeight(); - startPause(0.3f, () -> { + float deathDuration = currentMob.getAnimationDuration(CharacterSprite.AnimationTypes.Death, 0.3f); + startPause(deathDuration, () -> { MapStage.this.getReward(); AdventureQuestController.instance().updateQuestsWin(currentMob,enemies); AdventureQuestController.instance().showQuestDialogs(MapStage.this); @@ -842,12 +845,15 @@ public void run() { }); player.setAnimation(CharacterSprite.AnimationTypes.Idle); } - }, 1f); + }, attackDuration); } else { currentMob.clearCollisionHeight(); player.setAnimation(CharacterSprite.AnimationTypes.Hit); currentMob.setAnimation(CharacterSprite.AnimationTypes.Attack); - startPause(0.3f, () -> { + float resultAnimationDuration = Math.max( + player.getAnimationDuration(CharacterSprite.AnimationTypes.Hit, 0.3f), + currentMob.getAnimationDuration(CharacterSprite.AnimationTypes.Attack, 0.3f)); + startPause(resultAnimationDuration, () -> { player.setAnimation(CharacterSprite.AnimationTypes.Idle); currentMob.setAnimation(CharacterSprite.AnimationTypes.Idle); currentMob.resetCollisionHeight(); @@ -1145,7 +1151,10 @@ public void beginDuel(EnemySprite mob) { HapticEngine.vibrate(FPref.UI_VIBRATE_ON_ENEMY_ENCOUNTER, mob.getData().boss ? 400 : 200); Forge.advFreezePlayerControls = true; player.clearCollisionHeight(); - startPause(0.8f, () -> { + float attackDuration = Math.max( + player.getAnimationDuration(CharacterSprite.AnimationTypes.Attack, 0.8f), + mob.getAnimationDuration(CharacterSprite.AnimationTypes.Attack, 0.8f)); + startPause(attackDuration, () -> { if (started) return; started = true; diff --git a/forge-gui-mobile/src/forge/adventure/stage/WorldStage.java b/forge-gui-mobile/src/forge/adventure/stage/WorldStage.java index a843e4b434ae..be5dea209af7 100644 --- a/forge-gui-mobile/src/forge/adventure/stage/WorldStage.java +++ b/forge-gui-mobile/src/forge/adventure/stage/WorldStage.java @@ -121,7 +121,10 @@ protected void onActing(float delta) { HapticEngine.vibrate(FPref.UI_VIBRATE_ON_ENEMY_ENCOUNTER, mob.getData().boss ? 400 : 200); Forge.advFreezePlayerControls = true; player.clearCollisionHeight(); - startPause(0.8f, () -> { + float attackDuration = Math.max( + player.getAnimationDuration(CharacterSprite.AnimationTypes.Attack, 0.8f), + mob.getAnimationDuration(CharacterSprite.AnimationTypes.Attack, 0.8f)); + startPause(attackDuration, () -> { Forge.setCursor(null, Forge.magnifyToggle ? "1" : "2"); SoundSystem.instance.play(SoundEffectType.ManaBurn, false); DuelScene duelScene = DuelScene.instance(); @@ -164,13 +167,16 @@ public void setWinner(boolean playerIsWinner, boolean isArena) { currentMob.clearCollisionHeight(); Current.player().win(); player.setAnimation(CharacterSprite.AnimationTypes.Attack); + float attackDuration = Math.max(1f, + player.getAnimationDuration(CharacterSprite.AnimationTypes.Attack, 1f)); currentMob.playEffect(Paths.EFFECT_BLOOD, 0.5f); Timer.schedule(new Timer.Task() { @Override public void run() { currentMob.setAnimation(CharacterSprite.AnimationTypes.Death); currentMob.resetCollisionHeight(); - startPause(0.3f, () -> { + float deathDuration = currentMob.getAnimationDuration(CharacterSprite.AnimationTypes.Death, 0.3f); + startPause(deathDuration, () -> { RewardScene.instance().loadRewards(currentMob.getRewards(), RewardScene.Type.Loot, null); WorldStage.this.removeEnemy(currentMob); AdventureQuestController.instance().updateQuestsWin(currentMob); @@ -179,12 +185,15 @@ public void run() { currentMob = null; }); } - }, 1f); + }, attackDuration); } else { currentMob.clearCollisionHeight(); player.setAnimation(CharacterSprite.AnimationTypes.Hit); currentMob.setAnimation(CharacterSprite.AnimationTypes.Attack); - startPause(0.5f, () -> { + float resultAnimationDuration = Math.max( + player.getAnimationDuration(CharacterSprite.AnimationTypes.Hit, 0.5f), + currentMob.getAnimationDuration(CharacterSprite.AnimationTypes.Attack, 0.5f)); + startPause(resultAnimationDuration, () -> { currentMob.resetCollisionHeight(); boolean defeated = Current.player().defeated(); AdventureQuestController.instance().updateQuestsLose(currentMob); From 7d3f66a336e1cf49aecf1a56725554cb3ff7d4dd Mon Sep 17 00:00:00 2001 From: Vanja Date: Sun, 2 Aug 2026 18:03:48 +0100 Subject: [PATCH 2/3] Adventure: Harden frame-aware animation playback --- .../adventure/character/CharacterSprite.java | 43 +++++++++---------- .../src/forge/adventure/stage/GameStage.java | 16 ++++--- 2 files changed, 30 insertions(+), 29 deletions(-) diff --git a/forge-gui-mobile/src/forge/adventure/character/CharacterSprite.java b/forge-gui-mobile/src/forge/adventure/character/CharacterSprite.java index 1416a41d9a5f..5f19d6fd53d8 100644 --- a/forge-gui-mobile/src/forge/adventure/character/CharacterSprite.java +++ b/forge-gui-mobile/src/forge/adventure/character/CharacterSprite.java @@ -128,12 +128,17 @@ static public Animation FlipAnimation(Animation an } public void setAnimation(AnimationTypes type) { - if (currentAnimationType != type) { + Animation animation = getAnimation(type, currentAnimationDir); + if (animation == null) { + return; + } + + if (currentAnimationType != type || currentAnimation != animation || isOneShotAnimation(type)) { currentAnimationType = type; + currentAnimation = animation; if (isOneShotAnimation(type)) { timer = 0.0f; } - updateAnimation(); } } @@ -142,16 +147,18 @@ public void setAnimation(AnimationTypes type) { * Uses the supplied fallback when the atlas does not define that animation. */ public float getAnimationDuration(AnimationTypes type, float fallbackDuration) { + Animation animation = getAnimation(type, currentAnimationDir); + return animation == null ? fallbackDuration : animation.getAnimationDuration(); + } + + private Animation getAnimation(AnimationTypes type, AnimationDirections direction) { HashMap> dirs = animations.get(type); if (dirs == null || dirs.isEmpty()) { - return fallbackDuration; + return null; } - Animation animation = dirs.get(currentAnimationDir); - if (animation == null) { - animation = dirs.get(AnimationDirections.Right); - } - return animation == null ? fallbackDuration : animation.getAnimationDuration(); + Animation animation = dirs.get(direction); + return animation == null ? dirs.get(AnimationDirections.Right) : animation; } private boolean isOneShotAnimation(AnimationTypes type) { @@ -161,23 +168,13 @@ private boolean isOneShotAnimation(AnimationTypes type) { } private void updateAnimation() { - AnimationTypes aniType = currentAnimationType; - AnimationDirections aniDir = currentAnimationDir; - if (!animations.containsKey(aniType)) { - aniType = AnimationTypes.Idle; - } - if (!animations.containsKey(aniType)) { - return; - } - HashMap> dirs = animations.get(aniType); - - if (!dirs.containsKey(aniDir)) { - aniDir = AnimationDirections.Right; + Animation animation = getAnimation(currentAnimationType, currentAnimationDir); + if (animation == null) { + animation = getAnimation(AnimationTypes.Idle, currentAnimationDir); } - if (!dirs.containsKey(aniDir)) { - return; + if (animation != null) { + currentAnimation = animation; } - currentAnimation = dirs.get(aniDir); } public void setDirection(AnimationDirections dir) { diff --git a/forge-gui-mobile/src/forge/adventure/stage/GameStage.java b/forge-gui-mobile/src/forge/adventure/stage/GameStage.java index 4944c9822a4e..79fc378f4602 100644 --- a/forge-gui-mobile/src/forge/adventure/stage/GameStage.java +++ b/forge-gui-mobile/src/forge/adventure/stage/GameStage.java @@ -655,8 +655,10 @@ public void resetPlayerLocation() { PointOfInterest poi = Current.world().findPointsOfInterest("Spawn"); if (poi != null) { Forge.advFreezePlayerControls = true; - getPlayerSprite().setAnimation(CharacterSprite.AnimationTypes.Death); - getPlayerSprite().playEffect(Paths.EFFECT_BLOOD, 0.5f); + PlayerSprite playerSprite = getPlayerSprite(); + playerSprite.setAnimation(CharacterSprite.AnimationTypes.Death); + playerSprite.playEffect(Paths.EFFECT_BLOOD, 0.5f); + float deathDuration = playerSprite.getAnimationDuration(CharacterSprite.AnimationTypes.Death, 1f); Timer.schedule(new Timer.Task() { @Override public void run() { @@ -669,7 +671,7 @@ public void run() { Forge.clearTransitionScreen(); }, Forge.takeScreenshot())))); } - }, 1f); + }, deathDuration); }//Spawn shouldn't be null } @@ -677,14 +679,16 @@ public void defeatedFromBoss() { if (!Current.player().hasEquippedItem()) return; Forge.advFreezePlayerControls = true; - getPlayerSprite().setAnimation(CharacterSprite.AnimationTypes.Hit); - getPlayerSprite().playEffect(Paths.EFFECT_BLOOD, 0.5f); + PlayerSprite playerSprite = getPlayerSprite(); + playerSprite.setAnimation(CharacterSprite.AnimationTypes.Hit); + playerSprite.playEffect(Paths.EFFECT_BLOOD, 0.5f); + float hitDuration = playerSprite.getAnimationDuration(CharacterSprite.AnimationTypes.Hit, 1f); Timer.schedule(new Timer.Task() { @Override public void run() { showImageDialog(Current.generateDefeatMessage(false), getDefeatBadge(), () -> Forge.advFreezePlayerControls = false); } - }, 1f); + }, hitDuration); } private FBufferedImage getDefeatBadge() { From f45dd9dac3a5a4530962ea7cb9f743873211f7ad Mon Sep 17 00:00:00 2001 From: Vanja Date: Tue, 4 Aug 2026 01:10:51 +0100 Subject: [PATCH 3/3] Adventure: Cap action animation waits --- .../forge/adventure/character/CharacterSprite.java | 8 +++++--- .../src/forge/adventure/stage/GameStage.java | 4 ++-- .../src/forge/adventure/stage/MapStage.java | 12 ++++++------ .../src/forge/adventure/stage/WorldStage.java | 12 ++++++------ 4 files changed, 19 insertions(+), 17 deletions(-) diff --git a/forge-gui-mobile/src/forge/adventure/character/CharacterSprite.java b/forge-gui-mobile/src/forge/adventure/character/CharacterSprite.java index 5f19d6fd53d8..89789953a2b4 100644 --- a/forge-gui-mobile/src/forge/adventure/character/CharacterSprite.java +++ b/forge-gui-mobile/src/forge/adventure/character/CharacterSprite.java @@ -16,6 +16,7 @@ */ public class CharacterSprite extends MapActor { + private static final float MAX_ACTION_ANIMATION_DURATION = 5f; private final HashMap>> animations = new HashMap<>(); float timer; private Animation currentAnimation = null; @@ -143,12 +144,13 @@ public void setAnimation(AnimationTypes type) { } /** - * Returns the duration of an animation in the sprite's current direction. + * Returns the capped duration of an action animation in the sprite's current direction. * Uses the supplied fallback when the atlas does not define that animation. */ - public float getAnimationDuration(AnimationTypes type, float fallbackDuration) { + public float getActionAnimationDuration(AnimationTypes type, float fallbackDuration) { Animation animation = getAnimation(type, currentAnimationDir); - return animation == null ? fallbackDuration : animation.getAnimationDuration(); + float duration = animation == null ? fallbackDuration : animation.getAnimationDuration(); + return Math.min(duration, MAX_ACTION_ANIMATION_DURATION); } private Animation getAnimation(AnimationTypes type, AnimationDirections direction) { diff --git a/forge-gui-mobile/src/forge/adventure/stage/GameStage.java b/forge-gui-mobile/src/forge/adventure/stage/GameStage.java index 79fc378f4602..d384dea8b885 100644 --- a/forge-gui-mobile/src/forge/adventure/stage/GameStage.java +++ b/forge-gui-mobile/src/forge/adventure/stage/GameStage.java @@ -658,7 +658,7 @@ public void resetPlayerLocation() { PlayerSprite playerSprite = getPlayerSprite(); playerSprite.setAnimation(CharacterSprite.AnimationTypes.Death); playerSprite.playEffect(Paths.EFFECT_BLOOD, 0.5f); - float deathDuration = playerSprite.getAnimationDuration(CharacterSprite.AnimationTypes.Death, 1f); + float deathDuration = playerSprite.getActionAnimationDuration(CharacterSprite.AnimationTypes.Death, 1f); Timer.schedule(new Timer.Task() { @Override public void run() { @@ -682,7 +682,7 @@ public void defeatedFromBoss() { PlayerSprite playerSprite = getPlayerSprite(); playerSprite.setAnimation(CharacterSprite.AnimationTypes.Hit); playerSprite.playEffect(Paths.EFFECT_BLOOD, 0.5f); - float hitDuration = playerSprite.getAnimationDuration(CharacterSprite.AnimationTypes.Hit, 1f); + float hitDuration = playerSprite.getActionAnimationDuration(CharacterSprite.AnimationTypes.Hit, 1f); Timer.schedule(new Timer.Task() { @Override public void run() { diff --git a/forge-gui-mobile/src/forge/adventure/stage/MapStage.java b/forge-gui-mobile/src/forge/adventure/stage/MapStage.java index 2b522b051d18..cd2fcd7c7f37 100644 --- a/forge-gui-mobile/src/forge/adventure/stage/MapStage.java +++ b/forge-gui-mobile/src/forge/adventure/stage/MapStage.java @@ -829,14 +829,14 @@ public void setWinner(boolean playerWins, boolean isArena) { Current.player().win(); player.setAnimation(CharacterSprite.AnimationTypes.Attack); float attackDuration = Math.max(1f, - player.getAnimationDuration(CharacterSprite.AnimationTypes.Attack, 1f)); + player.getActionAnimationDuration(CharacterSprite.AnimationTypes.Attack, 1f)); currentMob.playEffect(Paths.EFFECT_BLOOD, 0.5f); Timer.schedule(new Timer.Task() { @Override public void run() { currentMob.setAnimation(CharacterSprite.AnimationTypes.Death); currentMob.resetCollisionHeight(); - float deathDuration = currentMob.getAnimationDuration(CharacterSprite.AnimationTypes.Death, 0.3f); + float deathDuration = currentMob.getActionAnimationDuration(CharacterSprite.AnimationTypes.Death, 0.3f); startPause(deathDuration, () -> { MapStage.this.getReward(); AdventureQuestController.instance().updateQuestsWin(currentMob,enemies); @@ -851,8 +851,8 @@ public void run() { player.setAnimation(CharacterSprite.AnimationTypes.Hit); currentMob.setAnimation(CharacterSprite.AnimationTypes.Attack); float resultAnimationDuration = Math.max( - player.getAnimationDuration(CharacterSprite.AnimationTypes.Hit, 0.3f), - currentMob.getAnimationDuration(CharacterSprite.AnimationTypes.Attack, 0.3f)); + player.getActionAnimationDuration(CharacterSprite.AnimationTypes.Hit, 0.3f), + currentMob.getActionAnimationDuration(CharacterSprite.AnimationTypes.Attack, 0.3f)); startPause(resultAnimationDuration, () -> { player.setAnimation(CharacterSprite.AnimationTypes.Idle); currentMob.setAnimation(CharacterSprite.AnimationTypes.Idle); @@ -1152,8 +1152,8 @@ public void beginDuel(EnemySprite mob) { Forge.advFreezePlayerControls = true; player.clearCollisionHeight(); float attackDuration = Math.max( - player.getAnimationDuration(CharacterSprite.AnimationTypes.Attack, 0.8f), - mob.getAnimationDuration(CharacterSprite.AnimationTypes.Attack, 0.8f)); + player.getActionAnimationDuration(CharacterSprite.AnimationTypes.Attack, 0.8f), + mob.getActionAnimationDuration(CharacterSprite.AnimationTypes.Attack, 0.8f)); startPause(attackDuration, () -> { if (started) return; diff --git a/forge-gui-mobile/src/forge/adventure/stage/WorldStage.java b/forge-gui-mobile/src/forge/adventure/stage/WorldStage.java index be5dea209af7..e4620a978eca 100644 --- a/forge-gui-mobile/src/forge/adventure/stage/WorldStage.java +++ b/forge-gui-mobile/src/forge/adventure/stage/WorldStage.java @@ -122,8 +122,8 @@ protected void onActing(float delta) { Forge.advFreezePlayerControls = true; player.clearCollisionHeight(); float attackDuration = Math.max( - player.getAnimationDuration(CharacterSprite.AnimationTypes.Attack, 0.8f), - mob.getAnimationDuration(CharacterSprite.AnimationTypes.Attack, 0.8f)); + player.getActionAnimationDuration(CharacterSprite.AnimationTypes.Attack, 0.8f), + mob.getActionAnimationDuration(CharacterSprite.AnimationTypes.Attack, 0.8f)); startPause(attackDuration, () -> { Forge.setCursor(null, Forge.magnifyToggle ? "1" : "2"); SoundSystem.instance.play(SoundEffectType.ManaBurn, false); @@ -168,14 +168,14 @@ public void setWinner(boolean playerIsWinner, boolean isArena) { Current.player().win(); player.setAnimation(CharacterSprite.AnimationTypes.Attack); float attackDuration = Math.max(1f, - player.getAnimationDuration(CharacterSprite.AnimationTypes.Attack, 1f)); + player.getActionAnimationDuration(CharacterSprite.AnimationTypes.Attack, 1f)); currentMob.playEffect(Paths.EFFECT_BLOOD, 0.5f); Timer.schedule(new Timer.Task() { @Override public void run() { currentMob.setAnimation(CharacterSprite.AnimationTypes.Death); currentMob.resetCollisionHeight(); - float deathDuration = currentMob.getAnimationDuration(CharacterSprite.AnimationTypes.Death, 0.3f); + float deathDuration = currentMob.getActionAnimationDuration(CharacterSprite.AnimationTypes.Death, 0.3f); startPause(deathDuration, () -> { RewardScene.instance().loadRewards(currentMob.getRewards(), RewardScene.Type.Loot, null); WorldStage.this.removeEnemy(currentMob); @@ -191,8 +191,8 @@ public void run() { player.setAnimation(CharacterSprite.AnimationTypes.Hit); currentMob.setAnimation(CharacterSprite.AnimationTypes.Attack); float resultAnimationDuration = Math.max( - player.getAnimationDuration(CharacterSprite.AnimationTypes.Hit, 0.5f), - currentMob.getAnimationDuration(CharacterSprite.AnimationTypes.Attack, 0.5f)); + player.getActionAnimationDuration(CharacterSprite.AnimationTypes.Hit, 0.5f), + currentMob.getActionAnimationDuration(CharacterSprite.AnimationTypes.Attack, 0.5f)); startPause(resultAnimationDuration, () -> { currentMob.resetCollisionHeight(); boolean defeated = Current.player().defeated();