diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 34658437..b25167e6 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -1556,6 +1556,25 @@ function BattleState:animationsOn() return not o or o.animations ~= false end +-- Drop the announcement-time move-anim row. Gen 1 queues PlayMoveAnimation +-- only after MoveHitTest / the effect lands (HandleIfPlayerMoveMissed skips +-- it on a miss unless EXPLODE_EFFECT); we insert early for blink attachment +-- and peel it back on miss/fail paths. +function BattleState:cancelMoveAnim() + local row = self.moveAnimRow + if not row then return end + self.moveAnimRow = nil + for i, item in ipairs(self.queue) do + if item == row then + table.remove(self.queue, i) + if self.nextInsert and i <= self.nextInsert then + self.nextInsert = self.nextInsert - 1 + end + return + end + end +end + -- ------------------------------------------------------------------ -- special-effect (SE_*) implementations. Palette effects are BGP -- shade maps ({[i] = shade color index i displays as}); on the SGB the @@ -2160,6 +2179,20 @@ end -- Decomposed into a staged pipeline over the merged move_effects record: -- announcement -> callsMove -> charge -> perform -> primary run -> the -- damaging pipeline (EffectRegistry.runDamaging). + +-- Gen 1 status/stat primary effects call PlayCurrentMoveAnimation only +-- after they land; these failure texts print with no animation. +local function primaryEffectFailed(msgs) + if not msgs or #msgs == 0 then return true end + local m = msgs[1] + if m == "But, it failed!" or m == "Nothing happened!" then return true end + if m:find("didn't affect", 1, true) then return true end + if m:find("is unaffected", 1, true) then return true end + if m:find("protected by MIST", 1, true) then return true end + if m:find("Already", 1, true) then return true end + return false +end + function BattleState:performMove(user, target, moveInst, isCalled) local move = self:moveDef(moveInst) if not move then @@ -2207,6 +2240,11 @@ function BattleState:performMove(user, target, moveInst, isCalled) -- already said its failure text if record and record.callsMove then local pick = record.callsMove(ctx) + -- Mirror Move never plays its own anim (MetronomePickMove does; + -- MirrorMoveCopyMove only reloads the copied move or prints fail) + if move.id == "MIRROR_MOVE" or not pick then + self:cancelMoveAnim() + end if pick then self:performMove(user, target, { id = pick, pp = 1 }, true) end @@ -2249,10 +2287,19 @@ function BattleState:performMove(user, target, moveInst, isCalled) if record.accuracyChecked and (target.invulnerable or not self:accuracyRoll(move, user, target)) then + -- SleepEffect/PoisonEffect/... call PlayCurrentMoveAnimation only + -- after the effect lands; a miss skips it + self:cancelMoveAnim() self:sayNext(("%s's\nattack missed!"):format(displayName(user))) return end - for _, m in ipairs(record.run(ctx)) do + local msgs = record.run(ctx) + -- Gen 1 status/stat effects animate only when they take effect + -- (AlreadyAsleep / NothingHappened / ButItFailed print with no anim) + if primaryEffectFailed(msgs) then + self:cancelMoveAnim() + end + for _, m in ipairs(msgs) do self:sayNext(m) end self:drainNext() -- REST/RECOVER/SOFTBOILED move the user's bar @@ -2260,6 +2307,7 @@ function BattleState:performMove(user, target, moveInst, isCalled) end if move.power == 0 and not (record and record.kind == "full") then MoveEffects.warnUnknown(move.effect) + self:cancelMoveAnim() self:sayNext("But, it failed!") return end @@ -2299,6 +2347,7 @@ function BattleState:continueBide(user, target) local dmg = (user.bideDamage or 0) * 2 user.bideTurns, user.bideDamage = nil, nil if dmg <= 0 then + self:cancelMoveAnim() self:sayNext("But, it failed!") return end diff --git a/src/battle/EffectRegistry.lua b/src/battle/EffectRegistry.lua index 2bf023d2..78d55837 100644 --- a/src/battle/EffectRegistry.lua +++ b/src/battle/EffectRegistry.lua @@ -89,6 +89,8 @@ function EffectRegistry.runDamaging(battle, ctx, record) -- Swift ignores semi-invulnerability (MoveHitTest returns hit for -- SWIFT_EFFECT before the INVULNERABLE check) if target.invulnerable and not neverMiss then + -- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed) + if not (record and record.explode) then battle:cancelMoveAnim() end battle:sayNext(("%s's\nattack missed!"):format(displayName(user))) return end @@ -97,6 +99,7 @@ function EffectRegistry.runDamaging(battle, ctx, record) if record and record.gate then local ok, failMsg = record.gate(ctx) if not ok then + battle:cancelMoveAnim() if failMsg then battle:sayNext(failMsg) end return end @@ -108,6 +111,8 @@ function EffectRegistry.runDamaging(battle, ctx, record) if not neverMiss then if not battle:accuracyRoll(move, user, target) then + -- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed) + if not (record and record.explode) then battle:cancelMoveAnim() end battle:sayNext(("%s's\nattack missed!"):format(displayName(user))) -- Jump Kick crash, Explode self-destruct if record and record.onMiss then record.onMiss(ctx, "accuracy") end @@ -134,6 +139,7 @@ function EffectRegistry.runDamaging(battle, ctx, record) end end if not counterable or (battle.lastDamage or 0) == 0 then + battle:cancelMoveAnim() battle:sayNext(("%s's\nattack missed!"):format(displayName(user))) return end @@ -144,6 +150,7 @@ function EffectRegistry.runDamaging(battle, ctx, record) -- failed with that text already chosen local chosen, extra = record.chooseDamage(ctx) if not chosen then + battle:cancelMoveAnim() if extra then battle:sayNext(extra) end return end @@ -154,12 +161,15 @@ function EffectRegistry.runDamaging(battle, ctx, record) end if info.typeMult == 0 then + -- type immunity zeros damage and sets wMoveMissed in Gen 1, so no anim + if not (record and record.explode) then battle:cancelMoveAnim() end battle:sayNext(("It doesn't affect\n%s!"):format(displayName(target))) if record and record.onMiss then record.onMiss(ctx, "immune") end return end if info.missed then -- 0.25x floored the damage to zero: the original registers a miss + if not (record and record.explode) then battle:cancelMoveAnim() end battle:sayNext(("%s's\nattack missed!"):format(displayName(user))) if record and record.onMiss then record.onMiss(ctx, "floored") end return diff --git a/src/battle/MoveEffects.lua b/src/battle/MoveEffects.lua index 686729ad..183966f0 100644 --- a/src/battle/MoveEffects.lua +++ b/src/battle/MoveEffects.lua @@ -616,6 +616,7 @@ MoveEffects.full = { -- roll is below opponentLevel/4. Teleport's failure text is "But -- it failed!", Roar/Whirlwind's is DidntAffectText; in trainer -- battles Teleport fails and Roar/Whirlwind are "unaffected". + -- Fail paths DelayFrames then print -- no PlayCurrentMoveAnimation. perform = function(ctx) local battle, user, target, move = ctx.battle, ctx.user, ctx.target, ctx.move if battle.kind == "wild" then @@ -635,13 +636,17 @@ MoveEffects.full = { battle.result = "run" battle.afterQueue = "finish" elseif move.id == "TELEPORT" then + battle:cancelMoveAnim() ctx.say("But, it failed!") else + battle:cancelMoveAnim() ctx.say(("It didn't affect\n%s!"):format(displayName(target))) end elseif move.id == "TELEPORT" then + battle:cancelMoveAnim() ctx.say("But, it failed!") else + battle:cancelMoveAnim() ctx.say(("%s\nis unaffected!"):format(displayName(target))) end end, diff --git a/tests/run_tests.lua b/tests/run_tests.lua index 247cee26..ccccc589 100644 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -854,6 +854,11 @@ do ab.rng = mkseq({ 255 }) -- the 1/256 miss ab:performMove(ab.player, ab.enemy, { id = "THUNDER_WAVE", pp = 10 }) eq(ab.enemy.mon.status, nil, "THUNDER WAVE misses on the 255 roll") + local function hasAnim(b) + for _, r in ipairs(b.queue) do if r.anim then return true end end + return false + end + check(not hasAnim(ab), "a missed THUNDER WAVE plays no move animation") ab.rng = mkseq({ 254 }) ab:performMove(ab.player, ab.enemy, { id = "THUNDER_WAVE", pp = 10 }) eq(ab.enemy.mon.status, "PAR", "THUNDER WAVE lands on the 254 roll") @@ -864,6 +869,33 @@ do eq(sbst.player.stages.attack, 1, "SHARPEN skips the accuracy roll") end + -- HandleIfPlayerMoveMissed: skip PlayMoveAnimation on a miss + -- (unless EXPLODE_EFFECT) + do + Game.save.party = { Pokemon.new(Data, "BULBASAUR", 20) } + local function hasAnim(b) + for _, r in ipairs(b.queue) do if r.anim then return true end end + return false + end + local function sawMiss(b) + for _, r in ipairs(b.queue) do + if r.text and r.text:find("attack missed!", 1, true) then return true end + end + return false + end + local mb = BattleState.newWild(Game, "RATTATA", 5) + mb.rng = function(a, b) return b end -- accuracy 255: miss + mb:performMove(mb.player, mb.enemy, { id = "TACKLE", pp = 10 }) + check(sawMiss(mb), "TACKLE miss prints AttackMissedText") + check(not hasAnim(mb), "a missed TACKLE plays no move animation") + eq(mb.enemy.mon.hp, mb.enemy.mon.stats.hp, "a missed TACKLE deals no damage") + + local hb = BattleState.newWild(Game, "RATTATA", 5) + hb.rng = function(a, b) return a end -- hit + hb:performMove(hb.player, hb.enemy, { id = "TACKLE", pp = 10 }) + check(hasAnim(hb), "a landing TACKLE still queues its move animation") + end + -- #14: EXP.ALL second pass inherits the participant divisor and skips -- fainted mons do