Files
gen1recomp/src/ui/gen2/BattleState.lua
T
2026-08-14 17:07:06 -04:00

3434 lines
147 KiB
Lua
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- The Gen 2 battle screen.
--
-- All logic lives in src/battle/gen2/Battle.lua; this only draws it and feeds
-- it actions. That split is deliberate: the engine emits an event queue, so a
-- test can assert a whole battle without a window and this file stays about
-- layout and pacing.
--
-- Layout follows the cart (engine/battle/core.asm's HUD placement): the enemy's
-- name/level and HP bar top-left with its pic top-right, the player's pic
-- bottom-left with its HUD bottom-right, and the message box across the bottom
-- two rows. FIGHT/PACK/POKéMON/RUN sit in that box when it is the player's
-- turn.
local AnimRunner = require("src.battle.gen2.AnimRunner")
local Assets = require("src.render.Assets")
local Battle = require("src.battle.gen2.Battle")
local BattleAnimView = require("src.ui.gen2.BattleAnimView")
local BattleHud = require("src.ui.gen2.BattleHud")
local BattleMusic = require("src.battle.gen2.BattleMusic")
local BerryJuice = require("src.battle.gen2.BerryJuice")
local Boxes = require("src.core.gen2.Boxes")
local BugContest = require("src.core.gen2.BugContest")
local CatchTutorial = require("src.core.gen2.CatchTutorial")
local Catching = require("src.battle.gen2.Catching")
local Chrome = require("src.ui.gen2.Chrome")
local Evolution = require("src.core.gen2.Evolution")
local GbcPalette = require("src.render.GbcPalette")
local Font = require("src.render.Font")
local HpBar = require("src.battle.gen2.HpBar")
local ItemEffects = require("src.core.gen2.ItemEffects")
local Mon = require("src.battle.gen2.Mon")
local Palettes = require("src.world.gen2.Palettes")
local Pokerus = require("src.core.gen2.Pokerus")
local Runtime = require("src.mods.Runtime")
local Screens = require("src.ui.Screens")
local Sound = require("src.core.Sound")
-- Only for playerPic: the player.sprite raiser both generations share.
local Sprites = require("src.pokemon.Sprites")
local Strings = require("src.core.Strings")
local Unown = require("src.core.gen2.Unown")
local BattleState = {}
BattleState.__index = BattleState
BattleState.isOpaque = true
-- Armed while a battle line waits for PromptButton (home/text.asm). Any
-- positive value means "hold until A/B"; the cart never times these out, so
-- the victory jingle can keep looping through the post-win prompts.
local MESSAGE_FRAMES = 48
-- home/hm_moves.asm:17-25 IsHMMove's .HMMoves.
local HM_MOVES = {
CUT = true, FLY = true, SURF = true, STRENGTH = true, FLASH = true,
WATERFALL = true, WHIRLPOOL = true,
}
-- CheckReceivedDex's ENGINE_POKEDEX (home/flag.asm:97-102), read the way
-- StartMenu:availability reads it out of save.engineFlags.
local ENGINE_POKEDEX = 11
-- The message box's own two rows. PrintTextboxText plants the cursor at
-- (TEXTBOX_INNERX, TEXTBOX_INNERY) = tile (1,14) (home/text.asm:143), and
-- LineChar does NOT step one row: it reloads the cursor at TEXTBOX_INNERY + 2
-- (home/text.asm:397), so a two-line battle string sits on rows 14 and 16 with
-- row 15 left blank. Paragraph's ClearBox wipes exactly rows 14-16
-- (home/text.asm:411), which is why there is no third row to spill onto.
local TEXT_INNER_X = 1
local TEXT_INNER_Y = 14
local TEXT_WIDTH = 18
local TEXT_ROWS = 2
local TEXT_ROW_STEP = 2
-- SlideBattlePicOut (engine/battle/core.asm:2882) is called with a = 8: eight
-- one-tile steps with `ld c, 2 / call DelayFrames` between them, so the enemy
-- trainer's pic clears the box in 16 frames.
local TRAINER_SLIDE_STEPS = 8
local TRAINER_SLIDE_FRAMES_PER_STEP = 2
local TRAINER_SLIDE_FRAMES = TRAINER_SLIDE_STEPS * TRAINER_SLIDE_FRAMES_PER_STEP
-- MonFaintedAnimation (engine/battle/core.asm), which PlayerMonFaintedAnimation
-- and EnemyMonFaintedAnimation both fall into with the fainted side's pic
-- corner: the pic's tilemap rows are copied DOWN one row per step and the row
-- it vacates is blanked, so what is left standing shrinks from the top while
-- the feet stay on the ground line -- the mon sinks out of the field. The step
-- is the same `ld c, 2 / call DelayFrames` SlideBattlePicOut uses, and the loop
-- runs the pic box's own height (7 rows for the enemy's 7x7 box, 6 for the
-- player's 6x6), so the pic is gone when it ends.
local FAINT_SLIDE_FRAMES_PER_ROW = 2
-- BattleText_TheresNoWillToBattle / BattleText_AnEGGCantBattle, the two lines
-- CheckIfCurPartyMonIsFitToFight prints before it returns zero
-- (engine/battle/core.asm:3439-3466, data/text/battle.asm:241-249).
local TEXT_NO_WILL_TO_FIGHT = "There's no will to battle!"
local TEXT_EGG_CANT_BATTLE = "An EGG can't battle!"
-- data/text/battle.asm:207
local TEXT_USE_NEXT_MON = "Use next POKéMON?"
-- BattleText_TheMoveIsDisabled / BattleText_TheresNoPPLeftForThisMove
-- (data/text/battle.asm:315-322).
local TEXT_NO_PP_LEFT = "There's no PP left for this move!"
local TEXT_MOVE_DISABLED = "The move is DISABLED!"
-- _MoveAskForgetText, _MoveCantForgetHMText and _StopLearningMoveText
-- (data/text/common_3.asm:124-134).
local TEXT_ASK_FORGET_SLOT = Strings.source("Which move should\nbe forgotten?")
local TEXT_CANT_FORGET_HM = Strings.source("HM moves can't be\nforgotten now.")
local TEXT_STOP_LEARNING = Strings.source("Stop learning\n%s?")
-- BattleText_EnemyIsAboutToUseWillPlayerChangeMon (data/text/battle.asm:222-231).
local TEXT_ENEMY_ABOUT_TO_USE = Strings.source(
"%s\nis about to use\v%s.\fWill %s\nchange POKéMON?")
-- _AskForgetMoveText, all three paragraphs (data/text/common_3.asm:141-165).
local TEXT_ASK_FORGET_MOVE = Strings.source(
"%s is\ntrying to learn\v%s.\fBut %s\ncan't learn more\vthan four moves."
.. "\fDelete an older\nmove to make room\vfor %s?")
-- engine/battle/menu.asm BattleMenuHeader: a 2x2 grid at menu_coords 8, 12,
-- 19, 17 with 6 tiles of column spacing, filled row-major, so the order on
-- screen is FIGHT / PkMn on top and PACK / RUN below -- not the four-in-a-row
-- Gen 1 uses. The second label is the two-glyph <PK><MN> ligature (charmap
-- $e1/$e2), which is what makes it fit a six-tile column.
local MENU = { "FIGHT", "<PK><MN>", "PACK", "RUN" }
local MENU_BOX_X = 8
local MENU_COL_SPACING = 6
-- ContestBattleMenuHeader is the same 2x2 grid moved out to menu_coords 2, 12
-- with 12 tiles of column spacing, because its third label is "PARKBALL×" and
-- the count PrintNum writes after it (two digits, leading zeros) at (13,16).
local CONTEST_MENU_BOX_X = 2
local CONTEST_MENU_COL_SPACING = 12
-- charmap.asm's quantity glyph, spelled the way MartMenu spells it.
local CONTEST_BALL_LABEL = "PARKBALL\xc3\x97"
-- data/items/heal_status.asm StatusHealingActions: the four rows whose status
-- mask is %11111111. HealStatus's `.not_full_heal` arm is what makes exactly
-- these also clear SUBSTATUS_CONFUSED, and IsItemUsedOnConfusedMon what lets
-- them be spent on a mon whose only complaint IS the confusion.
local FULL_MASK_HEALERS = {
FULL_HEAL = true, FULL_RESTORE = true, HEAL_POWDER = true,
MIRACLEBERRY = true,
}
-- Collapses runs of spaces and tabs so a text assembled out of several pieces
-- prints as one flowing line. A "\n" is deliberately NOT touched: it is the
-- cart's own `line` control byte and Chrome.wrap honours it as a hard break, so
-- flattening it here would throw away a break the cart authored (the used-move
-- line, data/text/common_2.asm:339).
local function oneLine(text)
return (tostring(text or ""):gsub("[ \t]+", " "))
end
-- `para` and `cont` both PromptButton before they redraw, and `cont` scrolls
-- twice so the new page opens on the old page's last line (home/text.asm:403).
local PAGE, SCROLL, LINE = "\f", "\v", "\n"
local SEPARATORS = "([^" .. LINE .. PAGE .. SCROLL .. "]*)([" ..
LINE .. PAGE .. SCROLL .. "])"
local function paginate(text)
local pages, rows = {}, {}
local function flush(scroll)
if #rows > 0 then pages[#pages + 1] = table.concat(rows, LINE) end
rows = scroll and { rows[#rows] or "" } or {}
end
for chunk, sep in (tostring(text or "") .. PAGE):gmatch(SEPARATORS) do
rows[#rows + 1] = chunk
if sep == PAGE then flush(false)
elseif sep == SCROLL then flush(true) end
end
if #pages == 0 then pages[1] = tostring(text or "") end
return pages
end
function BattleState:wantsFillScale() return true end
function BattleState:drawsWidescreen() return true end
function BattleState:bottomUIVisible()
if not Runtime.wantsHook("battle.bottom_ui_visible") then return true end
return Runtime.call("battle.bottom_ui_visible", function() return true end,
self) ~= false
end
function BattleState:statusHUDVisible()
if not Runtime.wantsHook("battle.status_hud_visible") then return true end
return Runtime.call("battle.status_hud_visible", function() return true end,
self) ~= false
end
-- Class frontpic for the battle intro. A trainers-registry `pic` wins over
-- the extracted menu_gfx sheet; `trueColor` skips the GBC 4-shade remap.
-- Returns path, trueColor.
function BattleState.trainerArt(data, classId)
if not classId then return nil, false end
local classes = data and data.gen2Trainers and data.gen2Trainers.classes
local classDef = classes and classes[classId]
local hud = data and data.gen2MenuGfx and data.gen2MenuGfx.battleHud
local path = (classDef and classDef.pic)
or (hud and hud.trainerPics and hud.trainerPics[classId])
return path, (classDef and classDef.trueColor) and true or false
end
-- opts: battle (a Battle), onDone(outcome), save
function BattleState.new(game, opts)
opts = opts or {}
local self = setmetatable({}, BattleState)
self.game = game
self.save = opts.save or (game and game.save)
local data = (game and game.data) or {}
self.palettes = data.gen2Palettes
self.pokemon = data.pokemon
self.battle = opts.battle
self.onDone = opts.onDone
-- What PlayVictoryMusic needs to know about the opponent (the class the
-- trainer belongs to); nil for a wild battle.
self.music = opts.music
-- BATTLETYPE_CONTEST (constants/battle_constants.asm): the park ball menu,
-- the caught mon being HELD in wContestMon rather than added to the party,
-- and CheckContestBattleOver's draw on the last ball. Set only by
-- World:tryContestEncounter.
self.contest = opts.contest and true or nil
-- BATTLETYPE_TUTORIAL: the DUDE's demonstration. No mon is sent out
-- (engine/battle/core.asm jumps straight to BattleMenu), the pack is his,
-- the ball cannot fail and nothing it catches is kept. Set only by
-- World:startCatchTutorial; src/core/gen2/CatchTutorial.lua has the rest.
self.tutorial = opts.tutorial and true or nil
self.queue = {}
self.message = nil
self.messageTimer = 0
self.phase = "intro" -- intro | menu | moves | resolving | evolving | done
-- wEvolvableFlags (ram/wram.asm, one bit per party slot).
-- engine/battle/core.asm sets a mon's bit the moment it levels up, right
-- after its LearnLevelMoves run, and ExitBattle's EvolveAfterBattle sweep
-- only looks at flagged slots. start_battle.asm clears the array at the
-- start of every battle, which is why this lives on the screen and not on
-- the save.
self.evolvable = {}
self.menuIndex = 1
self.moveIndex = 1
self.picCache = {}
-- Which side's pic box the tilemap has been left EMPTY in. BattleBGEffect_
-- ReturnMon's last row (what swallows a mon into a thrown ball) and
-- MonFaintedAnimation both clear the box and neither puts anything back: it
-- stays blank until something DRAWS a pic into it, which on the cart is only
-- ever a send-out (ShowSetEnemyMonAndSendOutAnimation / SendOutPlayerMon).
-- Without this latch the pic came back the instant the animation let go of
-- the screen, so a caught mon stood there through "Gotcha!" and a fainted one
-- popped back up for its own faint line. See stepAnim for why it is latched
-- at those two moments rather than off the runner's own last frame.
self.picHidden = { player = false, enemy = false }
-- engine/battle/sliding_intro.asm: 72 frames of the two halves sliding in
-- from opposite sides before the first message.
self.slideFrame = 0
-- The HUD draws from the cart's own tiles when the cache has them; without
-- them (an older import) drawHpBar falls back to the plain rectangles.
self.hud = BattleHud.new(data.gen2MenuGfx, self.palettes)
-- The battle-animation runtime. Both halves are optional: a cache built
-- before the scripts were extracted simply has no `anims`, and every call
-- site below already guards on that.
self.anims = data.gen2BattleAnims
self.animConstants = data.gen2Constants
if self.anims and self.anims.scripts then
self.animView = BattleAnimView.new(self.anims, self.palettes)
end
self.anim = nil
-- The intro sequence is the cart's, in the cart's order:
-- BattleIntroSlidingPics both halves slide in, and the player's box
-- holds the TRAINER's back-pic, not a mon
-- BattleStartMessage "Wild X appeared!"
-- SendOutPlayerMon swap in the mon's backpic, play
-- ANIM_SEND_OUT_MON, then "Go! X!"
-- so `showPlayerTrainer` is true for everything up to the send-out.
--
-- In the tutorial the send-out never comes, so the back-pic stands for the
-- whole battle -- and GetTrainerBackpic's "Special exception for Dude" swaps
-- ChrisBackpic for DudeBackpic to draw it. A cache built before DudeBackpic
-- was extracted has no `dudeBack`, and falls back to the player's own.
self.showPlayerTrainer = true
self.playerBackImage = nil
self.playerBackTrueColor = false
local hudGfx = data.gen2MenuGfx and data.gen2MenuGfx.battleHud
local backPath = hudGfx and hudGfx.playerBack
if self.tutorial and hudGfx and hudGfx.dudeBack then
backPath = hudGfx.dudeBack
end
-- player.sprite, the same hook and payload Gen 1 raises for its own back pic
-- (src/pokemon/Sprites.lua): the Dude's stand-in is the `demo` flag there.
-- Both return values matter here -- a mod's trueColor answer has to survive
-- to drawPic, or GbcPalette treats the replacement art as a grayscale 2bpp
-- sheet and remaps it through a palette instead of leaving it alone.
local backTrueColor
backPath, backTrueColor = Sprites.playerPic(backPath, {
side = "back", kind = "battle", demo = self.tutorial and true or false,
battle = self.battle, data = data,
})
if backPath then
local ok, image = pcall(Assets.image, backPath)
if ok then
self.playerBackImage = image
-- Kept so battle_sprite_scales can be looked up for this pic too: it is
-- not a species' pic, so its asset path is the only key it has.
self.playerBackPath = backPath
self.playerBackTrueColor = backTrueColor and true or false
end
end
-- Neither HUD exists while the bands slide. InitBattleDisplay blanks the
-- WHOLE tilemap (.BlankBGMap) and draws only the textbox and the two pics
-- before BattleIntroSlidingPics (engine/battle/core.asm:8554/8564), so the
-- names, levels, bars and borders are not on screen to ride in with them.
-- UpdateEnemyHUD runs only after BattleStartMessage returns, and then only
-- for a WILD battle (core.asm:7815-7817); a trainer's comes up at the tail of
-- ShowSetEnemyMonAndSendOutAnimation (core.asm:3384). UpdatePlayerHUD runs
-- at the tail of SendOutPlayerMon, after the send-out anim and the cry
-- (core.asm:3838).
self.showEnemyHud = false
self.showPlayerHud = false
-- InitEnemyTrainer (engine/battle/core.asm:7848) puts the CLASS's 7x7
-- frontpic in the enemy pic box BEFORE the intro slide, and it stays there
-- until ResetEnemyBattleVars slides it off; only then is the mon drawn. The
-- pic is a cache asset, so an import made before the extractor grew that
-- stage has none and the mon stands in for the whole intro.
self.showEnemyTrainer = false
self.enemyTrainerTrueColor = false
-- The CLASS CONSTANT (BUG_CATCHER), which is what both tables this looks the
-- pic up in are keyed by: menu_gfx's trainerPics is written out of
-- constants.trainerClassOrder, and palettes.trainers out of the same names.
-- Trainers.lookup's `class` field is whatever the CALLER asked with, and the
-- overworld asks with the numeric constant an object_event's trainer struct
-- carries (36, not "BUG_CATCHER") -- so reading `class` here found no pic and
-- no palette for every trainer the world starts, which is all of them.
-- `classId` is the trainers.lua key, i.e. the constant; `className` is the
-- DISPLAY name ("BUG CATCHER", with the space) and is not a key at all.
-- A class record's own `pic` / `trueColor` (the trainers registry) wins
-- over the extracted sheet, so a mod can drop in full-color art.
local enemyTrainer = self.battle and self.battle.trainer
self.enemyTrainerClass = enemyTrainer
and (enemyTrainer.classId or enemyTrainer.class)
local trainerPath, trainerTrueColor =
BattleState.trainerArt(data, self.enemyTrainerClass)
if trainerPath then
local ok, image = pcall(Assets.image, trainerPath)
if ok and image then
self.enemyTrainerImage = image
self.enemyTrainerPath = trainerPath
self.enemyTrainerTrueColor = trainerTrueColor and true or false
self.showEnemyTrainer = true
end
end
local enemy = self.battle and self.battle.enemy
self:noteFirstUnown(enemy)
if enemy then
if self.battle.wild then
-- BattleCheckEnemyShininess: a shiny wild mon gets ANIM_SEND_OUT_MON's
-- `.Shiny` arm before its cry and its line (core.asm:8705-8715).
if enemy.shiny then self:push({ kind = "shiny-flash" }) end
-- BattleStartMessage's `.wild` arm ends on WildPokemonAppearedText
-- (core.asm:8730); `intro` is what defers the enemy HUD to the step after
-- it, which is where StartBattle's `call z, UpdateEnemyHUD` sits.
self:push({ kind = "message", intro = true, cry = enemy,
text = "Wild " .. self:name(enemy) .. " appeared!" })
else
local trainerName = (self.battle.trainer and self.battle.trainer.name)
or "Foe"
-- WantsToBattleText (core.asm:8701), read against the trainer's own pic.
self:push({ kind = "message",
text = trainerName .. " wants to battle!" })
-- ResetEnemyBattleVars' SlideBattlePicOut at the head of EnemySwitch
-- (core.asm:3027) pushes that pic off the right edge before the mon is
-- announced. Nothing to slide when the cache has no trainer pic.
if self.showEnemyTrainer then
self:push({ kind = "trainer-slide" })
end
-- ShowBattleTextEnemySentOut, then ShowSetEnemyMonAndSendOutAnimation
-- (core.asm:2978-2980, 3354): this is where the mon's frontpic first
-- appears, where ANIM_SEND_OUT_MON plays and where the HUD comes up.
self:push({ kind = "send", side = "enemy", mon = enemy,
text = trainerName .. " sent out " .. self:name(enemy) .. "!" })
end
end
local player = self.battle and self.battle.player
if player then
self:push({ kind = "sendout",
text = "Go! " .. self:name(player) .. "!" })
end
-- What the HUD shows chases the real HP one tick at a time
-- (engine/battle/anim_hp_bar.asm), re-armed by each damage/heal event as
-- the queue consumes it. The engine has already finished the whole turn's
-- math by the time the first message shows, so drawing mon.hp directly
-- would spoil every hit before its own line ran -- and leave the bars
-- looking frozen while the messages replay.
self.shownHp = {
player = (player and player.hp) or 0,
enemy = (enemy and enemy.hp) or 0,
}
-- Which mon each side's HUD and pic actually draw, for the same reason: the
-- engine has already rebound battle.enemy by the time the faint line runs, so
-- reading it straight would swap the sprite and the name a beat before
-- "X fainted!" is even displayed. The replacement arrives with its own
-- `send` event, which is where the cart's send-out animation sits.
self.shownMon = { player = player, enemy = enemy }
-- And the same for the two numbers AnimateExpBar walks: wBattleMonLevel is
-- only advanced inside its level loop, right after that level's bar has
-- crawled full (engine/battle/core.asm:7267-7274), so neither the level nor
-- the exp fill may be read live off the mon.
self.shownLevel = (player and player.level) or 1
self.shownExp = player
and self:expPixels(player, player.level, player.experience) or 0
return self
end
-- CalcExpBar (engine/battle/core.asm:7555): the bar is 64 pixels of the span
-- between THIS level's exp and the next level's, not a share of the mon's
-- total exp.
function BattleState:expPixels(mon, level, exp)
local growth = self:growthOf(mon)
if not growth then return 0 end
level = math.max(1, math.min(Mon.MAX_LEVEL, level or 1))
local base = Mon.experienceForLevel(growth, level)
local next_ = Mon.experienceForLevel(growth, level + 1)
if not base or not next_ or next_ <= base then return 0 end
local into = math.max(0, math.min(next_ - base, (exp or base) - base))
return math.floor(into * BattleHud.EXP_LENGTH_PX / (next_ - base))
end
function BattleState:name(mon)
if not mon then return "?" end
return mon.nickname or mon.name or mon.species or "?"
end
-- wFirstUnownSeen: the letter of the first Unown the player ever MET, written
-- by both enemy send-out paths (`cp UNOWN / ld a, [wFirstUnownSeen] / and a /
-- jr nz / predef GetUnownLetter / ld [wFirstUnownSeen], a`,
-- engine/battle/core.asm:7894-7902 and :3251-3259) and only while it is still
-- zero. Pokedex_LoadSelectedMonTiles copies it into wUnownLetter before
-- GetMonFrontpic (engine/pokedex/pokedex.asm:2364), so the #DEX entry shows the
-- form the player first met -- seeing order, not catching order, which is why
-- an Unown that was fled from still sets it.
function BattleState:noteFirstUnown(mon)
local save = self.save
if not (save and mon and mon.species == Unown.SPECIES) then return end
if (save.firstUnownSeen or 0) ~= 0 then return end
save.firstUnownSeen = Unown.monLetter(mon)
end
-- The DUDE answering a prompt. Every re-arm in the ASM sits at the moment the
-- cart starts WAITING for a button (`.wait_input` in home/joypad.asm, BattleMenu
-- before LoadBattleMenu, TutorialPack before its own loop), so each one goes
-- here right where this screen starts waiting for the same button.
--
-- `key` makes the arm idempotent for a wait that spans many steps: the prompt
-- stream must be armed ONCE per message, not re-armed every step, or its 0x51
-- blank frames restart forever and the A never lands. A nil key arms every
-- time it is called, which is what the one-shot menu and pack arms want.
--
-- `skipIdle` is the pacing correction, and it is a port decision rather than
-- the cart's: the menu and pack streams are consumed by loops that call
-- GetJoypad with NO frame delay (engine/menus/menu.asm `.loopRTC`, and the
-- pack's own), so their long NO_INPUT runs are loop iterations and are gone in
-- a frame or two. This port polls once per fixed step, so replaying those runs
-- step by step would park the DUDE on the battle menu for seventeen seconds.
-- The presses and their ORDER are what the stream is for, and both survive.
function BattleState:dudeInput(stream, key, skipIdle)
if not self.tutorial then return false end
if key ~= nil and self.dudeArmed == key then return false end
self.dudeArmed = key
local game = self.game
return CatchTutorial.rearm(game and game.autoInput, stream,
game and game.input, skipIdle)
end
function BattleState:push(event)
self.queue[#self.queue + 1] = event
end
function BattleState:pushAll(events)
for _, event in ipairs(events or {}) do self:push(event) end
end
function BattleState:pic(mon, back)
local def = self.pokemon and mon and self.pokemon[mon.species]
local path = def and (back and def.spriteBack or def.spriteFront)
-- `ld hl, wEnemyMonDVs / predef GetUnownLetter / predef GetMonFrontpic`:
-- Unown's pic is picked by FORM, out of UnownPicPointers rather than out of
-- its own PokemonPicPointers row. Everything else reads one row.
local letter
local trueColor = (def and def.trueColor) and true or false
if mon and mon.species == Unown.SPECIES then
letter = Unown.monLetter(mon)
path = Unown.formSprite(self.pokemon, letter, back) or path
end
-- pokemon.sprite, the same name and the same ctx keys Gen 1 resolves its
-- battle pics through (src/pokemon/Sprites.lua:path), so one subscription
-- reskins both games: `side` is "front"/"back", `kind` says which screen is
-- asking, `mon` is the live battler for a per-instance skin and `trueColor`
-- is the mod's way of saying "this art is already coloured, leave the GBC
-- palette off it". The seam sits HERE rather than on Sprites.path because
-- the vanilla answer it has to be given is the one the Unown row above
-- picked -- resolving the species row again would throw the form away. The
-- two extra keys are what Gen 2 genuinely carries more of: the Unown letter
-- and the shiny flag that decides the palette.
if path and Runtime.wantsHook("pokemon.sprite") then
local ctx = {
species = mon.species,
side = back and "back" or "front",
kind = "battle",
mon = mon,
trueColor = (def and def.trueColor) and true or false,
data = (self.game and self.game.data) or nil,
letter = letter,
shiny = mon.shiny and true or false,
}
local hooked = Runtime.call("pokemon.sprite",
function(value) return value end, path, ctx)
if type(hooked) == "string" and hooked ~= "" then path = hooked end
trueColor = ctx.trueColor and true or false
end
if not path then return nil, false end
local cached = self.picCache[path]
if cached == nil then
local ok, image = pcall(Assets.image, path)
cached = ok and image or false
self.picCache[path] = cached
end
return cached or nil, trueColor, path
end
-- The battle_sprite_scales registry: record id -> { path, scale }, keyed by the
-- ASSET PATH the pic is drawn from rather than by species, which is the only
-- handle there is on the pics that are not a species' own (the player's
-- trainer back, the DUDE's, an opponent's frontpic). Same table, same record
-- shape and same resolution order as Gen 1's BattleState.imageBattleScale /
-- resolveBattleScale: image-level first, then the species record's own
-- battleScaleFront / battleScaleBack, then the default. The DEFAULT is where
-- the two generations genuinely differ and why this is not a call into that
-- module: Gen 1's back pics are 32x32 drawn at 2x, Gen 2's are 48x48 and fill
-- their 6x6 box at 1x, so both sides default to 1 here.
function BattleState:imageScale(path)
local data = self.game and self.game.data
local scales = data and data.battle_sprite_scales
if not (scales and path) then return nil end
for id, record in pairs(scales) do
-- `_owners` is the registry's own bookkeeping row, not a record.
if id ~= "_owners" and type(record) == "table" and record.path == path then
return tonumber(record.scale)
end
end
return nil
end
function BattleState:picScale(path, mon, back)
local scale = self:imageScale(path)
if scale then return scale end
local def = self.pokemon and mon and self.pokemon[mon.species]
local override = def and (back and def.battleScaleBack or def.battleScaleFront)
return tonumber(override) or 1
end
-- Where the two pics go, straight out of engine/battle/core.asm:
--
-- enemy front: hlcoord 12, 0 with lb bc, 7, 7 -- a 7x7 tile box at (96, 0)
-- player back: hlcoord 2, 6 with lb bc, 6, 6 -- a 6x6 tile box at (16, 48)
--
-- Back pics are always 48x48 so they fill their box exactly. Front pics vary
-- (Cyndaquil is 40x40, Onix 56x56) and are padded into the 7x7 box bottom-first,
-- which is what keeps a small mon standing on the same ground line as a big one
-- instead of floating at the top of the box.
BattleState.ENEMY_PIC_TILE_X = 12
BattleState.ENEMY_PIC_TILE_Y = 0
BattleState.ENEMY_PIC_TILES = 7
BattleState.PLAYER_PIC_TILE_X = 2
BattleState.PLAYER_PIC_TILE_Y = 6
BattleState.PLAYER_PIC_TILES = 6
-- BattleBGEffect_RunPicResizeScript draws the mon at one of six BG squares:
-- 6x6 / 4x4 / 2x2 tiles for the player and 7x7 / 5x5 / 3x3 for the enemy, in
-- that order. Only the SIZE matters here -- the cart's tile tables are the
-- same pic sampled coarsely -- so a size index becomes a scale about the
-- box's own bottom centre.
local PIC_RESIZE_TILES = { [0] = 6, [1] = 4, [2] = 2, [3] = 7, [4] = 5, [5] = 3 }
-- SUBSTATUS_UNDERGROUND / SUBSTATUS_FLYING, which BattleCommand_Charge sets on
-- FLY and DIG only (engine/battle/effect_commands.asm:5478-5485); the port
-- carries both as the volatile `vanished` flag.
function BattleState.isVanished(mon)
local volatiles = mon and mon.volatile
return (volatiles and volatiles.vanished) and true or false
end
function BattleState:drawPic(mon, back)
-- During the intro slide the player-side pic belongs to presentSlide's
-- backpic overlay, not to the baked bands (see BattleAnimView).
if back and self.slidingBackpic then return end
local image, trueColor, path = self:pic(mon, back)
-- Before SendOutPlayerMon the player's box holds ChrisBackpic instead, in
-- the same 6x6 box at hlcoord 2, 6 that the mon's backpic uses.
local trainerBack = back and self.showPlayerTrainer and self.playerBackImage
if trainerBack then
image, path = trainerBack, self.playerBackPath
trueColor = self.playerBackTrueColor
end
-- And the enemy's box holds the trainer's own frontpic until EnemySwitch
-- slides it out (InitEnemyTrainer, engine/battle/core.asm:7848).
local enemyTrainer = (not back) and self.showEnemyTrainer
and self.enemyTrainerImage
if enemyTrainer then
image, path = enemyTrainer, self.enemyTrainerPath
trueColor = self.enemyTrainerTrueColor
end
if not image then return end
local side = back and "player" or "enemy"
local anim = self:animPicState(side)
-- The box is empty either because the animation running right now has
-- cleared it, or because the last one ENDED with it cleared (picHidden).
if (anim and anim.hidden) or self.picHidden[side] then return end
-- Mid FLY / DIG the box is empty: DisappearUser ClearBoxes it
-- (engine/battle/misc.asm:1-13), AppearUserRaiseSub puts it back on the
-- stored attack (engine/battle/effect_commands.asm:2113-2117).
if not (trainerBack or enemyTrainer) and BattleState.isVanished(mon)
and not (self.vanishAnim and self.vanishAnim == self.anim) then
return
end
local G = love.graphics
local w, h = image:getDimensions()
local px, py
local boxTiles
if back then
-- pokegold engine/battle/core.asm:8569: 6x6 box, bottom-aligned/centred
local box = BattleState.PLAYER_PIC_TILES * 8
px = BattleState.PLAYER_PIC_TILE_X * 8 + math.floor((box - w) / 2)
py = BattleState.PLAYER_PIC_TILE_Y * 8 + (box - h)
boxTiles = BattleState.PLAYER_PIC_TILES
else
-- PadFrontpic pads a short pic and never a long one
-- (engine/gfx/load_pics.asm:342-386), so an oversized mod pic pins to the
-- box's own corner at hlcoord 12, 0 rather than to a negative offset.
local box = BattleState.ENEMY_PIC_TILES * 8
px = BattleState.ENEMY_PIC_TILE_X * 8 + math.max(0, math.floor((box - w) / 2))
py = BattleState.ENEMY_PIC_TILE_Y * 8 + math.max(0, box - h)
boxTiles = BattleState.ENEMY_PIC_TILES
end
-- One tile per two frames to the right, SlideBattlePicOut's own step.
if enemyTrainer and self.trainerSlide then
px = px + math.floor(self.trainerSlide / TRAINER_SLIDE_FRAMES_PER_STEP) * 8
end
-- The pic's own scale (battle_sprite_scales, then the species record, then
-- 1x) composed with whatever square BattleBGEffect_RunPicResizeScript has
-- the mon drawn at this frame.
local scale = self:picScale(path, mon, back)
if anim then
px = px + (anim.slide or 0)
local resized = anim.size and PIC_RESIZE_TILES[anim.size]
if resized then scale = scale * (resized / boxTiles) end
end
if scale ~= 1 then
-- Centred in the same box and standing on the same ground line at every
-- scale: the smaller resize squares, and a mod scale, both compensate the
-- same way.
px = px + math.floor(w * (1 - scale) / 2)
py = py + math.floor(h * (1 - scale))
end
G.setColor(1, 1, 1, 1)
-- No mon on this side at all in the catching tutorial, where the box holds
-- the DUDE's back-pic and nothing else for the whole battle.
local colors = self.palettes and mon
and Palettes.monColors(self.palettes, mon.species, mon.shiny)
if trainerBack then
-- PAL_BATTLE_OB_PLAYER: the player's own colours, which are row 0 of
-- TrainerPalettes (Chris shares Cal's).
colors = Palettes.trainerColors(self.palettes, "PLAYER") or colors
elseif enemyTrainer then
-- The opponent's class row out of the same TrainerPalettes table.
colors = Palettes.trainerColors(self.palettes, self.enemyTrainerClass)
or colors
end
if anim and anim.shade then
colors = BattleAnimView.shadeColors(colors, anim.shade)
end
-- MonFaintedAnimation, mid-slide: the rows that have walked past the bottom
-- of the pic box are not on the tilemap any more, so the pic is CROPPED to
-- what is still inside the box rather than drawn over the HUD below it.
local sunk = self:faintSink(side)
local function body()
if sunk > 0 then
local visible = h - math.floor(sunk / scale)
if visible <= 0 then return end
G.draw(image, self:cropQuad(image, visible), px, py + sunk, 0,
scale, scale)
return
end
G.draw(image, px, py, 0, scale, scale)
end
-- A mod-supplied pic that says it is already coloured is drawn as it is:
-- pokemon.sprite's ctx.trueColor, the same flag Gen 1's Sprites.path hands
-- back to its own draw site.
if colors and not trueColor and GbcPalette.available() then
GbcPalette.with(colors, body)
else
body()
end
end
-- The top `visible` rows of a pic, for the faint slide. One quad, re-aimed,
-- the way BattleAnimView keeps one blit quad rather than a new one per frame.
function BattleState:cropQuad(image, visible)
local w, h = image:getDimensions()
if not self.picQuad then
self.picQuad = love.graphics.newQuad(0, 0, w, visible, w, h)
else
self.picQuad:setViewport(0, 0, w, visible, w, h)
end
return self.picQuad
end
-- How far this side's pic has sunk, in pixels. MonFaintedAnimation moves one
-- 8px row per FAINT_SLIDE_FRAMES_PER_ROW frames.
function BattleState:faintSink(side)
local slide = self.faintSlide
if not (slide and slide.side == side) then return 0 end
return math.floor(slide.frames / FAINT_SLIDE_FRAMES_PER_ROW) * 8
end
-- The whole slide, in frames: the pic box's own height in rows.
function BattleState:faintSlideFrames(side)
local tiles = side == "player" and BattleState.PLAYER_PIC_TILES
or BattleState.ENEMY_PIC_TILES
return tiles * FAINT_SLIDE_FRAMES_PER_ROW
end
-- One tick of the HP bar chase (_AnimateHPBar): under 48 max HP the bar steps
-- one hit point a frame (ShortAnim_UpdateVariables); from 48 up it moves one
-- PIXEL a frame, which is maxHp/48 hit points at a time
-- (LongAnim_UpdateVariables). Returns true while a step was taken, so the
-- caller holds the queue the way the cart's loop holds the game.
function BattleState:stepHpAnim()
local anim = self.hpAnim
if not anim or not self.shownHp then return false end
-- wCurHPAnimMaxHP is loaded from the battle struct of the mon whose bar is
-- ON SCREEN (wEnemyMonMaxHP -> wHPBuffer1 before `predef AnimateHPBar`,
-- engine/battle/effect_commands.asm:3399-3414), and _AnimateHPBar picks its
-- short/long loop and its pixels off that alone (anim_hp_bar.asm:42-50,
-- :56-82). After a faint that mon is still the OUTGOING one: the engine has
-- already rebound battle.enemy, but its `send` event has not been dequeued
-- yet, so sizing the tick off battle[side] drains the dead mon's bar at the
-- replacement's rate.
local mon = self:activeMon(anim.side)
local maxHp = (mon and (mon.maxHp or (mon.stats and mon.stats.hp))) or 0
local step = 1
if maxHp >= HpBar.LENGTH_PX then
step = math.max(1, math.ceil(maxHp / HpBar.LENGTH_PX))
end
local shown = self.shownHp[anim.side] or 0
local target = anim.to or 0
if shown < target then
shown = math.min(target, shown + step)
else
shown = math.max(target, shown - step)
end
self.shownHp[anim.side] = shown
if shown == target then self.hpAnim = nil end
return true
end
-- .PlayExpBarSound's own two halves (engine/battle/core.asm:7311-7318): the
-- looping SFX_EXP_BAR, then `ld c, 10 / call DelayFrames` before the first
-- pixel moves. TerminateExpBarSound (home/audio.asm:497) cuts it dead at the
-- end of the segment rather than letting it ring on, and the end-of-bar hit
-- only plays where a level was actually crossed.
local SFX_EXP_BAR = "Sfx_ExpBar"
local SFX_END_OF_EXP_BAR = "Sfx_HitEndOfExpBar"
local EXP_SOUND_FRAMES = 10
-- One tick of the exp bar crawl. AnimateExpBar walks the bar one PIXEL at a
-- time out of 64 (.LoopBarAnimation, engine/battle/core.asm:7325-7362): the
-- gap starts at three frames a pixel and drops by one after every SECOND
-- pixel, floored at one, so the bar starts slow and finishes fast. A level
-- crossing fills the segment to 64, plays SFX_HIT_END_OF_EXP_BAR, advances the
-- level the HUD prints and restarts the bar at 0 (:7259-7285), which is why
-- the number changes as the bar tops out and not a message earlier.
--
-- Returns true while the crawl is running so the caller holds the queue the
-- way the cart's loop holds the game.
function BattleState:stepExpAnim()
local anim = self.expAnim
if not anim then return false end
local mon = anim.mon
local toLevel = (mon and mon.level) or self.shownLevel or 1
local target = BattleHud.EXP_LENGTH_PX
if (self.shownLevel or 1) >= toLevel then
target = self:expPixels(mon, self.shownLevel, mon and mon.experience)
end
if not anim.started then
anim.started = true
anim.frames = 3
anim.wait = 0
anim.pixels = 0
anim.delay = EXP_SOUND_FRAMES
self:playSfx(SFX_EXP_BAR)
end
if anim.delay > 0 then
anim.delay = anim.delay - 1
return true
end
local shown = self.shownExp or 0
if shown < target then
anim.wait = anim.wait + 1
if anim.wait < anim.frames then return true end
anim.wait = 0
shown = shown + 1
self.shownExp = shown
anim.pixels = anim.pixels + 1
if anim.pixels % 2 == 0 then anim.frames = math.max(1, anim.frames - 1) end
if shown < target then return true end
end
-- TerminateExpBarSound at the tail of every segment (:7279 and :7301).
Sound.stop(SFX_EXP_BAR)
if (self.shownLevel or 1) < toLevel then
self.shownLevel = (self.shownLevel or 1) + 1
self.shownExp = 0
self:playSfx(SFX_END_OF_EXP_BAR)
anim.started = false
return true
end
self.expAnim = nil
return true
end
-- The HP a side's HUD prints and fills its bar from: the chased value, not
-- the engine's, which ran a whole turn ahead.
function BattleState:hudHp(mon, side)
local shown = self.shownHp and self.shownHp[side]
if shown == nil then return (mon and mon.hp) or 0 end
return shown
end
-- The bar is src/battle/gen2/HpBar.lua's: the pixel count comes from
-- ComputeHPBarPixels and the colour from GetHPPal, so this screen and the party
-- list can never disagree about when a mon is in the red.
function BattleState:drawHpBar(mon, side, tx, ty)
local maxHp = mon.maxHp or (mon.stats and mon.stats.hp)
local hp = self:hudHp(mon, side)
if self.hud:available() then
return self.hud:drawHpBar(hp, maxHp, tx, ty)
end
return HpBar.drawWithLabel(self.palettes, hp, maxHp, tx, ty, Font)
end
-- CheckCaughtMon against wPokedexCaught (home/pokedex_flags.asm:48-51).
function BattleState:dexCaught(mon)
local caught = self.save and self.save.pokedex and self.save.pokedex.caught
return (mon and caught and caught[mon.species]) and true or false
end
-- The low-HP alarm is not an SFX id at all. PlayDanger (audio/engine.asm:531)
-- runs every frame while DANGER_ON_F is set in wLowHealthAlarm and writes a
-- two-tone square straight to channel 1 -- DangerSoundHigh ($750) at counter 0,
-- DangerSoundLow ($6ee) at counter 16 -- while audio/engine.asm:244 keeps music
-- channel 1 quiet for as long as the flag is up. CheckDanger
-- (engine/battle/core.asm:4393) sets and clears the flag off wPlayerHPPal ==
-- HP_RED, and StopDangerSound (core.asm:2189) zeroes it on a faint and at the
-- end of the battle. src/core/ChipAudio.lua synthesizes that exact pair, which
-- is what Sound.startLoop("Low_Health_Alarm") reaches.
--
-- Keyed to the DISPLAYED bar, because wPlayerHPPal is what the bar animation
-- updates: the siren starts when the bar drains into the red, not a turn early.
function BattleState:lowHealthAlarmActive()
-- wBattleLowHealthAlarm is the per-battle DISABLE latch, and CheckDanger
-- reads it before anything else (`ld a, [wBattleLowHealthAlarm] / and a /
-- jr nz, .done`, engine/battle/core.asm:4396-4399): once it is set the
-- DANGER_ON_F bit StopDangerSound just cleared is left alone, so the siren
-- cannot come back for the rest of the battle however red the bar stays.
if self.lowHealthAlarmDisabled then return false end
-- The healing item's exception, set by applyPartyItem: wLowHealthAlarm is
-- zeroed before the HP moves, and CheckDanger is not asked again until
-- UpdatePlayerHUD runs at the end of the bar climb, so nothing re-arms the
-- siren while the bar is walking back out of the red.
if self.healSilence then
if self.hpAnim and self.hpAnim.side == "player" then return false end
self.healSilence = nil
end
local player = self.battle and self.battle.player
return (player and (player.hp or 0) > 0
and HpBar.paletteFor(self:hudHp(player, "player"),
player.maxHp or (player.stats and player.stats.hp)) == "red") and true
or false
end
function BattleState:updateAlarm()
local data = self.game and self.game.data
-- Mirrors wLowHealthAlarm's DANGER_ON_F bit, under the same field name Gen 1
-- keeps it in (src/battle/BattleState.lua).
self.lowHealthAlarmOn = self:lowHealthAlarmActive() and data ~= nil
-- battle.low_health_alarm: on/off toggle for the siren loop, ctx.on mirrors
-- self.lowHealthAlarmOn -- the same name and the same ctx keys as the Gen 1
-- site, so one subscription covers both games and a mod can reshape the
-- toggle (mute it after a budget, swap the loop) before vanilla acts on it.
-- `data` is added because this screen's cache lives on the game rather than
-- on the battle the way Gen 1's does; `battle` is still the battle screen.
if Runtime.wantsHook("battle.low_health_alarm") then
return Runtime.call("battle.low_health_alarm", function(ctx)
if ctx.on and ctx.data then
Sound.startLoop(ctx.data, "Low_Health_Alarm")
else
Sound.stopLoop("Low_Health_Alarm")
end
end, { on = self.lowHealthAlarmOn, battle = self, data = data })
end
if self.lowHealthAlarmOn then
-- Sound.startLoop returns early when the loop is already sounding, so this
-- can run every step the way PlayDanger runs every frame.
Sound.startLoop(data, "Low_Health_Alarm")
else
self:stopAlarm()
end
end
-- StopDangerSound (engine/battle/core.asm:2189): the siren cannot outlive the
-- mon that raised it, nor the battle screen.
function BattleState:stopAlarm()
Sound.stopLoop("Low_Health_Alarm")
end
--------------------------------------------------------------------------
-- Battle animations
--------------------------------------------------------------------------
-- hBattleTurn: 0 while the player is attacking. Every object function and
-- every BG effect keys the side it acts on off this.
function BattleState:turnFor(side)
return side == "enemy" and 1 or 0
end
-- Starts an animation script and returns true when there is one to play.
-- `key` is a pool key from battle_anims.lua's `moves` or `ids` map.
function BattleState:startAnim(key, opts)
if not (self.anims and self.anims.scripts and key) then return false end
if not self.anims.scripts[key] then return false end
-- BattleAnimRunScript's own gate: `bit BATTLE_SCENE, [wOptions]` skips the
-- move animation entirely, which is the OPTION screen's BATTLE SCENE row.
-- The check only applies to a real move id (wFXAnimID+1 == 0); non-move
-- ids (isMove unset here) branch straight to .not_move and always run.
local options = self.game and self.game.options
if options and options.battleScene == false and opts and opts.isMove then
return false
end
opts = opts or {}
local data = (self.game and self.game.data) or {}
local audio = data.audio or {}
self.anim = AnimRunner.new({
data = self.anims,
constants = self.animConstants,
battleTurn = opts.turn or 0,
animId = opts.animId,
param = opts.param or 0,
sfxOrder = audio.sfxOrder,
ballPalette = opts.ballPalette,
-- BGEffect_CheckFlyDigStatus reads wPlayerSubStatus3 / wEnemySubStatus3
-- (engine/battle_anims/bg_effects.asm:2838-2851); the port keeps that bit
-- on the mon's volatile table, not on the mon itself.
flying = {
player = BattleState.isVanished(self.battle and self.battle.player),
enemy = BattleState.isVanished(self.battle and self.battle.enemy),
},
hooks = {
-- anim_sound (engine/battle_anims/anim_commands.asm:1105) calls
-- PlayStereoSFX (audio/engine.asm:2571), the ONE sfx path with no
-- CheckSFX/wCurSFX comparison: an animation's second sound is never
-- dropped for being outranked by its first. BattleAnim_ThrowPokeBall's
-- SFX_THROW_BALL then SFX_BALL_POOF is the case that goes silent if this
-- goes through the gated Sound.play.
sound = function(name)
if name and audio.sfx and audio.sfx[name] then
Sound.playStereo(data, name)
end
end,
-- The cry is the battler's own, at the pitch/length the command adds;
-- the port's Sound layer has no pitch shift, so the plain cry is what
-- plays. audio.cries is keyed by SPECIES (the same table every other
-- Gen 2 screen plays through Sound.playCry), which is what makes
-- anim_cry moves like GROWL audible at all.
cry = function(side)
local mon = side == "enemy" and self.battle.enemy or self.battle.player
local species = mon and mon.species
if species and audio.cries and audio.cries[species] then
Sound.playCry(data, species)
end
end,
-- GetPokeBallWobble, which BattleAnim_ThrowPokeBall's .Loop calls through
-- anim_checkpokeball once per wobble.
pokeballWobble = function() return self:pokeballWobble() end,
},
})
self.anim:start(key)
-- BattleAnimRunScript calls BattleAnimClearHud before a MOVE's script and
-- BattleAnimRestoreHuds after; the `.not_move` path (the shared ANIM_* ids)
-- skips both. ClearActorHud blanks the ATTACKER's own HUD, which is what
-- keeps a Tackle from dragging the name and HP bar along with the pic.
self.anim.clearsHud = opts.isMove and true or false
self.anim.hudSide = (opts.turn or 0) == 0 and "player" or "enemy"
return true
end
-- wBattleAfterAnim target for this attacker's turn
-- (effect_commands.asm:1963-1972): player swing -> enemy shake, and reverse.
function BattleState:afterAnimFor(side)
if side == "player" then return "ANIM_ENEMY_DAMAGE" end
return "ANIM_PLAYER_DAMAGE"
end
function BattleState:animForMove(moveId, side)
local key = self.anims and self.anims.moves and self.anims.moves[moveId]
local started = self:startAnim(key, {
turn = self:turnFor(side), animId = moveId, isMove = true,
})
if started then
-- BattleAnimRunScript (anim_commands.asm:55-72): after the move script
-- restores HUDs it immediately runs wBattleAfterAnim (the hit shake).
-- Queue it so stepAnim chains without waiting on the next event.
self.pendingAfterAnim = { name = self:afterAnimFor(side), side = side }
end
return started
end
-- Kick off a queued after-anim; returns true when one is now running.
function BattleState:startPendingAfterAnim()
local pending = self.pendingAfterAnim
if not pending then return false end
self.pendingAfterAnim = nil
if self:animForId(pending.name, pending.side) then
-- dealDamage's default ANIM_x_DAMAGE is this same shake; skip it there.
self.afterAnimPlayed = true
return true
end
return false
end
-- True while BattleAnimClearHud has that side's HUD blanked.
function BattleState:hudCleared(side)
return self.anim ~= nil and self.anim.clearsHud and self.anim.hudSide == side
end
-- `param` is wBattleAnimParam, which BattleAnim_SendOutMon branches on
-- (data/moves/animations.asm:414-417).
function BattleState:animForId(idName, side, param)
local key = self.anims and self.anims.ids and self.anims.ids[idName]
return self:startAnim(key, {
turn = self:turnFor(side), animId = idName, param = param,
})
end
-- One logic frame of a running animation. B cuts it short, the way holding B
-- pages a text box.
function BattleState:stepAnim(input)
if not self.anim then return end
if input and (input:wasPressed("b") or input:wasPressed("start")) then
-- Cut short: the BG effects never reached their own last step, so the
-- tilemap is whatever they had got to and nothing is latched -- the
-- explicit latches (a catch) are the only ones that survive a skip.
self.anim = nil
-- Cart still reaches the after-anim arm after a move script ends; a skip
-- of the move should not drop the hit shake that follows it.
if self:startPendingAfterAnim() then return end
return self:endSendOutAnim()
end
if not self.anim:step() then
-- pokegold data/moves/animations.asm .Click: anim_keepsprites means
-- the OAM outlives the script, so keep the runner for drawing too.
if not self.anim.keepSprites then self.anim = nil end
if self:startPendingAfterAnim() then return end
return self:endSendOutAnim()
end
end
-- NOTE on picHidden and the animation runtime. An animation that ENDS with a
-- pic box cleared could latch it here, and the tilemap argument says it should:
-- BattleAnimRestoreHuds redraws the two HUDs and nothing else. It deliberately
-- does not, because BATTLE_BG_EFFECT_REMOVE_MON and _RETURN_MON are also used
-- by moves whose user is still standing there afterwards -- SUBSTITUTE (the
-- doll takes the box over, and nothing in this port draws one yet), SKY_ATTACK,
-- BEAT_UP, BATON_PASS -- and a blanket latch would make those mons invisible
-- for the rest of the fight. The two moments the cart really does leave the
-- box empty for good are latched explicitly instead: a catch (pushCaught) and
-- a faint (MonFaintedAnimation, in update).
-- Whatever Call_PlayBattleAnim was standing in front of: a send-out's cry and
-- HUD update run the moment its animation is done, cut short or not.
function BattleState:endSendOutAnim()
local after = self.afterSendOut
if not after then return end
self.afterSendOut = nil
self:finishSendOut(after)
end
-- What the BG effects are doing to a battler's pic this frame.
function BattleState:animPicState(side)
if not self.anim then return nil end
local bg = self.anim.bg
return {
hidden = bg.hidden[side],
size = bg.picSize[side],
slide = bg.slide[side] or 0,
shade = bg.monShade[side],
}
end
function BattleState:advanceQueue()
local event = table.remove(self.queue, 1)
-- StartBattle runs `call z, UpdateEnemyHUD` AFTER BattleStartMessage returns,
-- and only for a wild battle (engine/battle/core.asm:7808-7817): the appeared
-- line is read against an empty HUD area and the bar comes up on the step
-- after it. A trainer's HUD is turned on by the send-out arm below instead.
if self.introTextShown then
self.showEnemyHud = true
self.introTextShown = nil
end
if not event then
-- `jp PlayerSwitch`, which follows the enemy's own send-out and spends no
-- turn (engine/battle/core.asm:2955-2963).
if self.shiftSwitchIndex then
local index = self.shiftSwitchIndex
self.shiftSwitchIndex = nil
if self.battle:shiftSwitch(index) then
self:pushAll(self.battle:takeEvents())
return self:advanceQueue()
end
end
-- Nothing left: either the battle ended or it is the player's turn.
if self.battle and self.battle.over then
-- ExitBattle runs the evolution sweep BEFORE it cleans up the battle
-- RAM, so the screens come up while the battle is still notionally on.
return self:startEvolutions()
end
self.phase = "menu"
if self.tutorial then
-- BattleMenu's tutorial arm skips UpdateBattleHuds AND EmptyBattleTextbox,
-- so the box keeps whatever it already said while the menu opens over it,
-- and there is no mon to name in a prompt anyway. The DOWN + A that
-- picks PACK is armed here, where the cart arms it: right before
-- LoadBattleMenu.
self:dudeInput(CatchTutorial.MENU_STREAM, nil, true)
return
end
-- `call CheckPlayerLockedIn / jr c, .skip_iteration` (engine/battle/core.asm
-- :162-176) jumps past `call BattleMenu` ENTIRELY, not just past the move
-- list: a mon partway through a Rollout or a Thrash is offered no menu at
-- all, and ParsePlayerAction's .locked_in arm runs the move it is stuck on.
-- The engine side already forces the move (Battle:forcedMove overrides
-- whatever is submitted, and Battle:usableMoves narrows to the one), so all
-- that is left here is not to draw a menu the cart never draws. Deferred
-- to update() rather than submitted from inside advanceQueue, so a turn
-- that somehow emitted no events cannot recurse.
if not self.tutorial and self.battle and self.battle.player
and self.battle:lockedInMove(self.battle.player) then
-- The message is deliberately NOT cleared: EmptyBattleTextbox lives
-- inside BattleMenu, which this turn never calls, so the box keeps
-- whatever the last line was.
self.phase = "locked-in"
return
end
-- BattleMenu (engine/battle/core.asm) runs EmptyBattleTextbox before
-- LoadBattleMenu: the half of the box beside the 2x2 menu is BLANK on the
-- cart. Gen 2 has no "What will X do?" line, and printing one here only
-- got it clipped mid-word by the menu box drawn over its right half.
self.message = nil
return
end
-- HandleEnemyMonFaint / HandlePlayerMonFaint run their side's
-- MonFaintedAnimation BEFORE the faint text (engine/battle/core.asm): the pic
-- sinks out of the field and only then does "X fainted!" go up. The slide
-- owns the screen the way SlideBattlePicOut does, so the event is put back at
-- the head of the queue and re-runs for its text (and for the alarm latch and
-- the victory jingle below it) once the pic is gone.
if event.kind == "faint" and event.side and not event.slid
and not self.faintSlide then
event.slid = true
table.insert(self.queue, 1, event)
self.faintSlide = { side = event.side, frames = 0 }
-- FaintEnemyPokemon opens on SFX_KINESIS, FaintYourPokemon on the fainting
-- mon's own cry (engine/battle/core.asm:2196-2201, :2210-2212).
if event.side == "enemy" then
self:playSfx("Sfx_Kinesis")
else
self:playCry(self:activeMon("player"))
end
return
end
-- Battle:awardExperience emits one `level` event per mon that grew, which is
-- exactly where the cart sets that slot's wEvolvableFlags bit.
if event.kind == "level" and event.index then
self.evolvable[event.index] = true
-- GiveExperiencePoints' `.skip_active_mon_update` guard
-- (engine/battle/core.asm:6999-7003): only the mon that is OUT copies its
-- recalculated HP, max HP and level into the battle struct, and only then
-- does `callfar UpdatePlayerHUD` (:7034) redraw the bar. That is a
-- REDRAW, not AnimateHPBar, so the shown HP snaps instead of chasing --
-- without it the bar kept the pre-level-up HP against the new maximum
-- until the next damage or heal event moved it.
local battle = self.battle
local mon = battle and battle.party and battle.party[event.index]
-- pokegold engine/battle/core.asm:7057-7069: every mon that leveled
-- gets the stats box, not just the mon currently on the field.
self.pendingStatsMon = mon
-- engine/battle/core.asm:7044
if mon and mon == battle.player then
event.text = nil
event.sfx = nil
event.waitSfx = nil
if self.shownHp then
self.shownHp.player = mon.hp or 0
if self.hpAnim and self.hpAnim.side == "player" then
self.hpAnim = nil
end
end
-- `ld [wBattleMonLevel], a` in the same guarded block (:7018-7020).
-- AnimateExpBar has already walked the number up one level at a time by
-- the time this runs, so this only catches a level gained with no exp
-- crawl behind it.
self.shownLevel = mon.level or self.shownLevel
end
end
-- EnemySwitch's shift arm asks BEFORE ClearEnemyMonBox and
-- ShowBattleTextEnemySentOut (engine/battle/core.asm:2941-2955), so the send
-- goes back at the head of the queue and re-runs once the prompt is answered.
if event.kind == "send" and event.side == "enemy" and event.replacement
and not event.offered and self:shiftOfferAllowed() then
event.offered = true
table.insert(self.queue, 1, event)
return self:offerShiftSwitch(event.mon)
end
-- A damage or heal event re-arms the HP bar chase (AnimateHPBar runs from
-- UpdateBattleHuds between one battle message and the next), and a send
-- snaps that side's bar straight to the incoming mon.
if (event.kind == "damage" or event.kind == "heal")
and event.side and event.hp and self.shownHp
and self.shownHp[event.side] ~= event.hp then
self.hpAnim = { side = event.side, to = event.hp }
elseif event.kind == "send" and event.side and event.mon and self.shownHp then
self.shownHp[event.side] = event.mon.hp or 0
if self.hpAnim and self.hpAnim.side == event.side then self.hpAnim = nil end
end
-- AnimateExpBar (engine/battle/core.asm:7191) is called from INSIDE
-- GiveExperiencePoints before the exp is committed (the call at :6888 sits
-- ahead of the commit at :6889-6901), so the bar crawls from the figures
-- the HUD is already showing up to the new ones, filling to 64 and
-- restarting at 0 for every level crossed (:7259-7285). The engine has
-- written mon.experience and mon.level a whole turn earlier here, so the
-- crawl's starting point is the chased state (shownExp / shownLevel) rather
-- than the mon: same reason the HP bar has shownHp.
if event.kind == "experience" and event.index then
local battle = self.battle
local mon = battle and battle.party and battle.party[event.index]
-- AnimateExpBar's own two guards: only the mon that is OUT animates (the
-- wCurBattleMon == wCurPartyMon test at :7194-7197), and nothing animates
-- at MAX_LEVEL (:7199-7201).
if mon and mon == battle.player
and (self.shownLevel or 1) < Mon.MAX_LEVEL then
self.expAnim = { mon = mon, frames = 3, wait = 0, pixels = 0 }
end
end
if event.kind == "send" and event.side and event.mon then
-- The pic and the HUD name follow the queue, so the mon that just fainted
-- is still on screen for its own line and the replacement arrives here.
if self.shownMon then self.shownMon[event.side] = event.mon end
-- The second of the cart's two wFirstUnownSeen writes (core.asm:3251).
if event.side == "enemy" then self:noteFirstUnown(event.mon) end
if event.side == "player" then
-- SendOutPlayerMon zeroes wBattleMenuCursorPosition and wCurMoveNum back
-- to back (engine/battle/core.asm:3809), so a switched-in mon opens on
-- FIGHT and on its first move. Player side only: the zeroing lives
-- inside SendOutPlayerMon and nothing on the enemy's path touches them.
self.menuIndex = 1
self.moveIndex = 1
-- The incoming mon's own level and exp bar: SendOutPlayerMon reloads
-- wBattleMon* from the party slot and UpdatePlayerHUD draws them at its
-- tail (:3838), so both snap here the way shownHp does above.
self.shownLevel = event.mon.level or 1
self.shownExp = self:expPixels(event.mon, event.mon.level,
event.mon.experience)
self.expAnim = nil
end
end
-- ResetEnemyBattleVars' SlideBattlePicOut (engine/battle/core.asm:3027):
-- eight one-tile steps push the trainer's pic off the right edge, and the
-- queue holds until they are done.
if event.kind == "trainer-slide" then
self.trainerSlide = 0
return
end
-- The shiny sparkle: hBattleTurn 1 and wBattleAnimParam 1 pick
-- BattleAnim_SendOutMon's `.Shiny` arm on the enemy (core.asm:8708-8715).
if event.kind == "shiny-flash" then
self:animForId("ANIM_SEND_OUT_MON", "enemy", 1)
return
end
-- SendOutPlayerMon: the trainer's back-pic gives way to the mon and
-- ANIM_SEND_OUT_MON plays over the "Go!" line.
if event.kind == "sendout" then
self.showPlayerTrainer = false
self.menuIndex = 1
self.moveIndex = 1
self.message = event.text
self.messageTimer = MESSAGE_FRAMES
self:startSendOut("player", self.battle and self.battle.player)
return
end
-- DisplayCaughtContestMonStats, which BugContest_SetCaughtContestMon opens
-- over the battle once a second mon is caught: the stock-versus-this
-- comparison and its yes/no, both of which live in the contest screen.
if event.kind == "contest-switch" then
return self:openContestSwitch(event)
end
-- The failure line is picked from wThrownBallWobbleCount, which only reaches
-- its final value inside the animation (item_effects.asm:414-428).
if event.kind == "ball-result" then
self.message = self:ballFailureText()
self.messageTimer = MESSAGE_FRAMES
return
end
-- `predef NewPokedexEntry` (item_effects.asm:542).
if event.kind == "dex-entry" then
return self:openDexEntry(event.species)
end
if event.kind == "ask-nickname" then
return self:askNickname(event.mon)
end
if event.kind == "choose-switch" then
-- engine/battle/core.asm:2590
if self.battle and self.battle.wild then
self.nextMonIndex = 1
self.phase = "ask-next-mon"
self.message = TEXT_USE_NEXT_MON
self.messageTimer = 0
return
end
-- A fainted lead: force a switch before anything else runs.
self.phase = "forced-switch"
self.message = "Choose a POKéMON."
return
end
-- LearnMove's full-moveset arm: the exp queue stops on ForgetMove's own text
-- and the player drops a move or declines (engine/pokemon/learn.asm:29-33).
if event.kind == "choose-forget" then
self.pendingLearn = { index = event.index, move = event.move,
moveName = event.moveName }
return self:askForget()
end
-- PlayVictoryMusic sits in the faint handler, not at the end of the battle:
-- the jingle is already going while "X fainted!" is on screen and it loops
-- through the exp and money lines until the overworld comes back.
if event.kind == "faint" and event.side == "enemy" then
-- UpdateBattleStateAndExperienceAfterEnemyFaint (core.asm:2044) reaches
-- `.wild2` on EVERY wild enemy faint and there calls StopDangerSound and
-- writes 1 to wBattleLowHealthAlarm (:2071-2074), before a single point of
-- experience is awarded; WinTrainerBattle does the same pair when a
-- trainer's last mon drops (:2293-2296). Until this latch existed the
-- siren kept blaring under the victory jingle, the exp bar and the
-- level-up prompts for as long as the player's own bar stayed red.
if self.battle.wild
or (self.battle.over and self.battle.outcome == "win") then
self:stopAlarm()
self.lowHealthAlarmDisabled = true
end
if self.battle.over and self.battle.outcome == "win" then
self:playVictoryMusic()
end
end
if event.text then
self.message = event.text
-- Lines that must not hold the queue for A/B:
-- move UsedMoveText -> text_end, then moveanim
-- level GrewToLevel is text_end (battle.asm:336-343), then the stats
-- box's WaitPressAorB is the real hold
-- experience keeps the wait: _ExpPointsText ends in `prompt`
-- (common_1.asm:1660-1665). update() runs stepExpAnim before that wait,
-- so the bar crawls under the line and A dismisses it before the battle
-- can end.
if event.kind == "move" or event.kind == "level" then
self.messageTimer = 0
else
self.messageTimer = MESSAGE_FRAMES
end
-- BattleStartMessage's own line: the enemy HUD comes up on the step after
-- it returns (engine/battle/core.asm:7808-7817), not with it.
if event.intro then self.introTextShown = true end
-- BattleStartMessage's `.not_shiny` cries the wild mon before its own line
-- (engine/battle/core.asm:8718-8721).
if event.cry then self:playCry(event.cry) end
-- A text_asm tail that plays its own sound, the way Text_BallCaught's
-- sound_caught_mon rides the "Gotcha!" line rather than following it.
if event.sfx then
self:playSfx(event.sfx)
-- TextCommand_SOUND is `call PlaySFX` followed by `call WaitSFX`
-- (home/text.asm:829-836, its table row at :860), so the cart stays
-- INSIDE the text command until the sound has finished: the box cannot
-- be paged away from mid-jingle, and PokeBallEffect's `.FinishTutorial`
-- tail cannot return under it.
if event.waitSfx then self.waitSfx = event.sfx end
end
end
-- The move's own animation plays over its "used X!" line, which is where
-- PlayBattleAnim sits in the effect command list. Its after-anim (the hit
-- shake) is chained by animForMove / stepAnim, matching BattleAnimRunScript.
-- BattleCommand_MoveAnimNoSub (engine/battle/effect_commands.asm:1958) opens
-- with `ld a, [wAttackMissed] / and a / jp nz, BattleCommand_MoveDelay`: a
-- move that missed burns the delay and plays nothing. Battle:markMissed sets
-- event.missed on every wAttackMissed path.
if event.kind == "move" and not event.missed then
self.afterAnimPlayed = nil
self.pendingAfterAnim = nil
if not self:animForMove(event.move, event.side) then
-- BATTLE SCENE off skips the move script but still runs wBattleAfterAnim
-- (anim_commands.asm:55-72 .disabled fallthrough).
local options = self.game and self.game.options
if options and options.battleScene == false then
if self:animForId(self:afterAnimFor(event.side), event.side) then
self.afterAnimPlayed = true
end
end
end
-- BattleCommand_Charge runs LoadMoveAnim BEFORE DisappearUser
-- (engine/battle/effect_commands.asm:5459-5470), so FLY / DIG still draw
-- the take-off or the burrow on the turn the substatus goes up; the box
-- only empties once THIS animation is done with.
if BattleState.isVanished(self:activeMon(event.side)) then
self.vanishAnim = self.anim
end
elseif event.kind == "damage" and event.side then
-- ANIM_x_DAMAGE is the MOVE's after-anim (effect_commands.asm:1963-1972),
-- so only a move hit gets it; `animMove` is HandleWrap's (core.asm:1198-1203).
local from = event.animSide
or (event.side == "enemy" and "player" or "enemy")
if event.animMove then
self:animForMove(event.animMove, from)
elseif event.anim ~= false then
local hit = event.anim
or (event.side == "enemy" and "ANIM_ENEMY_DAMAGE"
or "ANIM_PLAYER_DAMAGE")
-- Already played as the move's after-anim; do not shake twice.
if self.afterAnimPlayed
and (hit == "ANIM_ENEMY_DAMAGE" or hit == "ANIM_PLAYER_DAMAGE") then
self.afterAnimPlayed = nil
else
self:animForId(hit, from)
end
end
else
-- Status moves still chain the after-anim but emit no damage event to
-- consume the latch; drop it before the next unrelated line.
self.afterAnimPlayed = nil
end
if event.kind == "heal" and event.anim and event.side then
-- pokegold engine/battle/core.asm:4074 ItemRecoveryAnim
self:animForMove(event.anim, event.side)
elseif event.kind == "send" and event.side then
-- Every enemy send-out goes through ShowSetEnemyMonAndSendOutAnimation
-- (engine/battle/core.asm:3354) -- the faint replacement out of
-- EnemyPartyMonEntrance and the AI's mid-turn rotation alike -- and the
-- player's voluntary switch through SendOutPlayerMon (:3796). Without it
-- the replacement simply appeared, which with two of a species back to back
-- reads as one mon growing a second health bar.
self:startSendOut(event.side, event.mon)
end
end
-- SetEnemyTurn / SetPlayerTurn, then ANIM_SEND_OUT_MON. The cry and the HUD
-- come after the animation, not with it.
function BattleState:startSendOut(side, mon)
local after = { side = side, mon = mon }
-- ShowSetEnemyMonAndSendOutAnimation and SendOutPlayerMon both draw the pic
-- into the box before they play the animation, which is the one thing that
-- undoes a cleared box.
self.picHidden[side] = false
self.faintSlide = nil
if self:animForId("ANIM_SEND_OUT_MON", side) then
self.afterSendOut = after
return true
end
-- BattleAnimRunScript is skipped with BATTLE SCENE off (and there are no
-- scripts at all in a cache built before they were extracted), but the cart
-- still runs the cry and the HUD update, so they happen now.
self:finishSendOut(after)
return false
end
-- `ld a, [wTempEnemyMonSpecies] / call PlayStereoCry / call UpdateEnemyHUD`
-- (engine/battle/core.asm:3380-3384), and the same pair at the tail of
-- SendOutPlayerMon (:3836-3838).
function BattleState:finishSendOut(after)
if not after then return end
self:playCry(after.mon)
if after.side == "enemy" then
self.showEnemyHud = true
else
self.showPlayerHud = true
end
end
-- PlaySFX with one of the sfx the extractor named, or nothing at all when this
-- cache does not carry it.
function BattleState:playSfx(name)
local data = self.game and self.game.data
local audio = data and data.audio
if name and audio and audio.sfx and audio.sfx[name] then
Sound.play(data, name)
end
end
-- PlayStereoCry with the battler's own species. audio.cries is keyed by
-- SPECIES, the same table the animation runtime's `cry` callback plays through.
function BattleState:playCry(mon)
local species = mon and mon.species
if not species then return end
local data = self.game and self.game.data
local audio = data and data.audio
if audio and audio.cries and audio.cries[species] then
Sound.playCry(data, species)
end
end
--------------------------------------------------------------------------
-- EvolveAfterBattle
--------------------------------------------------------------------------
-- ExitBattle (engine/battle/core.asm): `ld a, [wBattleResult] / and $f /
-- jr nz, .CleanUpBattleRAM` -- only a WIN reaches `xor a / ld
-- [wForceEvolution], a / predef EvolveAfterBattle`. A loss goes straight to
-- the whiteout, so a mon that leveled on the way down never evolves.
--
-- wForceEvolution is cleared here, which is what makes B a working cancel and
-- what keeps the EVOLVE_ITEM rows (Eevee's stones) from firing off a battle.
function BattleState:startEvolutions()
self.phase = "evolving"
-- ExitBattle's CleanUpBattleRAM zeroes wLowHealthAlarm; nothing past here
-- runs updateAlarm, so the siren has to be cut before the sweep takes over.
self:stopAlarm()
local battle = self.battle
if not (battle and Evolution.runsAfterBattle(battle.outcome)) then
return self:finishBattle()
end
local stack = self.game and self.game.stack
local party = battle.party or (self.save and self.save.party) or {}
self.evolutions = Evolution.plan((self.game and self.game.data) or {},
party, self.evolvable, {
-- wTimeOfDay, for the TR_MORNDAY / TR_NITE happiness rows.
timeOfDay = Palettes.clockDaytime(),
})
self.evolutionIndex = 0
if #self.evolutions == 0 or not stack then return self:finishBattle() end
return self:nextEvolution()
end
-- EvolveAfterBattle_MasterLoop, one flagged slot at a time: the screen owns
-- the stack until it reports back, and the next slot only starts once it does.
function BattleState:nextEvolution()
self.evolutionIndex = self.evolutionIndex + 1
local plan = self.evolutions[self.evolutionIndex]
if not plan then return self:finishBattle() end
local stack = self.game.stack
Screens.push(self.game, "Gen2EvolutionAnim", {
mon = plan.mon,
entry = plan.entry,
index = plan.index,
party = self.battle.party or (self.save and self.save.party),
save = self.save,
onDone = function()
stack:pop()
self:nextEvolution()
end,
})
end
-- ExitBattle's `farcall GivePokerusAndConvertBerries`, which sits immediately
-- after `predef EvolveAfterBattle` inside the same WIN arm -- so it runs once
-- per won battle, after every evolution has resolved, and never after a loss.
-- Silent by design: nothing tells the player, and the Pokemon Center nurse is
-- the first thing that ever mentions it (std_scripts.asm PokeCenterNurseScript,
-- through the CheckPokerus special).
function BattleState:givePokerus()
local battle = self.battle
if not (battle and Evolution.runsAfterBattle(battle.outcome)) then return nil end
local party = battle.party or (self.save and self.save.party)
-- GivePokerusAndConvertBerries opens on `call ConvertBerriesToBerryJuice`,
-- so the Shuckle's held BERRY converts before the Pokerus roll runs.
BerryJuice.convertAfterBattle(self.save, party)
return Pokerus.giveAfterBattle(self.save, party)
end
-- .ReturnToMap, minus the RestartMapMusic the overworld's own onDone already
-- does (src/world/gen2/World.lua calls Music.restoreMap there).
function BattleState:finishBattle()
self.phase = "done"
self:stopAlarm()
self:clearMenuCursors()
self:givePokerus()
-- CleanUpBattleRAM: every substatus the battle wrote goes with the battle.
-- The party tables it wrote them on are the save's own, so this has to run
-- before the overworld (and the next save write) sees them again.
if self.battle then self.battle:clearAllVolatiles() end
if self.onDone then
self.onDone(self.battle and self.battle.outcome, self.battle)
end
end
-- CleanUpBattleRAM's cursor block (engine/battle/core.asm:7994-8004): the menu
-- bytes that live ACROSS menu openings are zeroed here and nowhere else --
-- wPartyMenuCursor, wLastPocket, and the ITEM / KEY_ITEM / BALL pocket cursors
-- with their scroll positions. The TM/HM pair is deliberately NOT in that
-- list, so it survives a battle and is left alone here.
function BattleState:clearMenuCursors()
self.shiftIndex, self.shiftSwitchIndex = nil, nil
local game = self.game
if not game then return end
game.partyMenuCursor = nil
local pack = game.packCursor
if not pack then return end
pack.pocket = nil
for _, id in ipairs({ "ITEM", "KEY_ITEM", "BALL" }) do
pack.cursor[id] = nil
pack.scroll[id] = nil
end
end
-- Whether any mon that took part in the battle is still standing. A wild win
-- with none left plays NO music at all (PlayVictoryMusic's `.lost` path), so
-- the map theme carries straight on -- which is what a mon fainting to its own
-- recoil on the winning blow sounds like.
function BattleState:participantsFainted()
local battle = self.battle
if not battle then return true end
for index in pairs(battle.participants or {}) do
local mon = battle.party and battle.party[index]
if mon and (mon.hp or 0) > 0 then return false end
end
return true
end
function BattleState:playVictoryMusic()
local data = self.game and self.game.data
local audio = data and data.audio
if not (audio and audio.songs) then return nil end
local song = BattleMusic.victorySong({
class = self.music and self.music.class,
participantsFainted = self:participantsFainted(),
})
if not (song and audio.songs[song]) then return nil end
require("src.core.Music").play(data, song, true, { reason = "victory" })
return song
end
function BattleState:submit(action)
self.phase = "resolving"
self:pushAll(self.battle:takeTurn(action))
self.message = nil
self.messageTimer = 0
self:advanceQueue()
end
function BattleState:playerMoves()
return (self.battle and self.battle.player and self.battle.player.moves) or {}
end
-- MoveSelectionScreen's `.pressed_select` (engine/battle/core.asm:5320-5374).
-- SELECT marks a slot, SELECT again swaps the marked slot with the one under
-- the cursor, and A or B clears the mark without swapping (the A arm opens
-- `xor a / ld [wSwappingMove], a`).
--
-- The cart swaps wBattleMonMoves and wBattleMonPP as two separate byte pairs,
-- then repeats both in the party struct. Here a move IS one record carrying
-- its own pp and maxPp, and Battle:switchIn takes the party table by reference
-- (Battle.player is the party entry), so exchanging the two records does all
-- four of those swaps at once and the party keeps the new order after the
-- battle, which is the point of reordering mid-fight.
--
-- Two things the cart does that this does NOT need:
-- * `.not_swapping_disabled_move` rewrites wPlayerDisableCount's slot nibble
-- so DISABLE keeps pointing at the same MOVE after the swap. Gold's
-- disable here stores the move id (Battle:volatile(mon).disabled == moveId,
-- src/battle/gen2/Battle.lua:3489), so it follows the move already.
-- * `.swap_moves_in_party_struct` is skipped when SUBSTATUS_TRANSFORMED is
-- set -- the COOLTRAINER glitch fix, which stops a Transformed mon writing
-- borrowed moves into its own party slot. One table per mon means the
-- battle copy and the party slot cannot be written separately, so the swap
-- is refused outright while transformed. A transformed mon's moves are
-- borrowed and vanish on switch-out, so there is nothing to reorder.
function BattleState:swapAllowed()
local player = self.battle and self.battle.player
if not player then return false end
return not (self.battle:volatile(player) or {}).transformed
end
function BattleState:swapMoves(i, j)
if i == j then return end
local moves = self:playerMoves()
local a, b = moves[i], moves[j]
if not (a and b) then return end
moves[i], moves[j] = b, a
end
function BattleState:update(_dt)
-- The evolution sweep owns the stack (and the low-HP alarm is long over);
-- this state is only still here because ExitBattle has not cleaned up yet.
if self.phase == "evolving" or self.phase == "done" then return end
self:updateAlarm()
local input = self.game and self.game.input
if not input then return end
-- The intro slide blocks everything: BattleIntroSlidingPics is a plain
-- 72-frame loop with no input read inside it.
if self.slideFrame < BattleAnimView.SLIDE_FRAMES then
self.slideFrame = self.slideFrame + 1
return
end
-- SlideBattlePicOut is a plain loop with DelayFrames in it, so it owns the
-- screen the same way (engine/battle/core.asm:2882).
if self.trainerSlide then
self.trainerSlide = self.trainerSlide + 1
if self.trainerSlide >= TRAINER_SLIDE_FRAMES then
self.trainerSlide = nil
self.showEnemyTrainer = false
self:advanceQueue()
end
return
end
-- MonFaintedAnimation is a plain loop with DelayFrames in it, like
-- SlideBattlePicOut above: it owns the screen until the pic is off the
-- field, and the box it emptied stays empty (only a send-out refills it).
if self.faintSlide then
local slide = self.faintSlide
slide.frames = slide.frames + 1
if slide.frames >= self:faintSlideFrames(slide.side) then
self.picHidden[slide.side] = true
-- SFX_FAINT follows EnemyMonFaintedAnimation, and ClearBox blanks the
-- fainted side's HUD before its text (core.asm:2213-2218, :2202-2205).
if slide.side == "enemy" then
self:playSfx("Sfx_Faint")
self.showEnemyHud = false
else
self.showPlayerHud = false
end
self.faintSlide = nil
self:advanceQueue()
end
return
end
-- An animation owns the screen for as long as it runs, exactly the way
-- RunBattleAnimScript owns the main loop.
-- pokegold data/moves/animations.asm .Click: a finished keepsprites run
-- no longer owns the loop, just the OAM the draw path still reads.
if self.anim and not (self.anim:done() and self.anim.keepSprites) then
self:stepAnim(input)
return
end
-- The bar drain holds the queue the way AnimateHPBar's loop holds the
-- cart: the next event runs once the shown HP has caught the real one.
if self:stepHpAnim() then return end
if self.phase == "resolving" or self.phase == "intro" then
-- The `call WaitSFX` half of TextCommand_SOUND (home/text.asm:834-835):
-- the line holds, unskippable, for as long as its own sound is sounding.
-- Without it the DUDE's auto-input tapped straight through the "Gotcha!"
-- line and finishBattle closed the tutorial over the jingle.
if self.waitSfx then
if Sound.isPlaying(self.waitSfx) then return end
self.waitSfx = nil
end
-- AnimateExpBar sits right after PrintText Text_MonGainedExpPoint
-- (core.asm:6881-6888), with that line still on screen. Run the crawl
-- before any PromptButton wait so the bar does not sit frozen until A.
if self:stepExpAnim() then return end
if self.messageTimer > 0 then
if self.tutorial then
-- PromptButton waits for the button; the tutorial cannot press it, so
-- DudeAutoInput_A (frame 0x51) answers. Never auto-timeout here: a
-- 48-frame skip would hand his press to the next screen.
self:dudeInput(CatchTutorial.PROMPT_STREAM,
"prompt:" .. tostring(self.message))
end
-- PromptButton (home/text.asm): A/B pages; no frame countdown.
if input:wasPressed("a") or input:wasPressed("b") then
self.messageTimer = 0
end
return
end
-- pokegold engine/battle/core.asm:7057-7069: the stats box shows once
-- the "grew to level" line has finished, held for A/B.
if self.pendingStatsMon then
self.statsBoxMon = self.pendingStatsMon
self.pendingStatsMon = nil
self.phase = "stats-box"
return
end
self:advanceQueue()
return
end
-- pokegold engine/battle/core.asm:7069 (WaitPressAorB_BlinkCursor).
if self.phase == "stats-box" then
if input:wasPressed("a") or input:wasPressed("b") then
self.statsBoxMon = nil
self.phase = "resolving"
end
return
end
-- The turn CheckPlayerLockedIn skipped the menu for. No input is read: the
-- cart falls straight into ParsePlayerAction, whose .locked_in arm has no
-- MoveSelectionScreen in front of it.
if self.phase == "locked-in" then
local locked = self.battle and self.battle.player
and self.battle:lockedInMove(self.battle.player)
if locked then
self:submit({ kind = "move", move = locked })
else
self.phase = "menu"
end
return
end
if self.phase == "menu" then
-- 2x2 grid: left/right swap the column, up/down the row.
-- MenuClickSound / PlayClickSFX (home/menu.asm:746-762): SFX_READ_TEXT_2
-- on A/B only, never on D-pad.
if input:wasPressed("left") or input:wasPressed("right") then
self.menuIndex = self.menuIndex % 2 == 1 and self.menuIndex + 1
or self.menuIndex - 1
elseif input:wasPressed("up") or input:wasPressed("down") then
self.menuIndex = self.menuIndex <= 2 and self.menuIndex + 2
or self.menuIndex - 2
elseif input:wasPressed("a") then
self:playSfx("Sfx_ReadText2")
local choice = MENU[self.menuIndex]
if choice == "FIGHT" then
-- `call .CheckPlayerHasUsableMoves / ret z` (engine/battle/core.asm
-- :5058-5059): a mon with nothing to spend never sees the list.
local fighter = self.battle and self.battle.player
if fighter and #self:playerMoves() > 0
and not self.battle:hasUsableMoves(fighter) then
return self:submit({ kind = "move", move = Battle.STRUGGLE })
end
self.phase = "moves"
-- MoveSelectionScreen seeds wMenuCursorY from wCurMoveNum + 1
-- (engine/battle/core.asm:5111) and the A-press writes the picked row
-- back, so the list reopens on the move used last turn; only
-- SendOutPlayerMon and CleanUpBattleRAM zero it. Clamp rather than
-- reset, for a moveset that shrank (Mimic, a forgotten slot).
local moves = self:playerMoves()
self.moveIndex = math.max(1,
math.min(self.moveIndex or 1, math.max(1, #moves)))
elseif choice == "RUN" then
self:submit({ kind = "run" })
elseif choice == "PACK" then
if self.tutorial then
self:openTutorialPack()
elseif self.contest then
self:throwParkBall()
else
self:openPack()
end
else
self:openParty()
end
end
return
end
if self.phase == "moves" then
local moves = self:playerMoves()
if input:wasPressed("up") then
self.moveIndex = self.moveIndex > 1 and self.moveIndex - 1 or #moves
elseif input:wasPressed("down") then
self.moveIndex = self.moveIndex < #moves and self.moveIndex + 1 or 1
elseif input:wasPressed("select") then
if self.moveSwapIndex then
self:swapMoves(self.moveSwapIndex, self.moveIndex)
self.moveSwapIndex = nil
elseif self:swapAllowed() then
self.moveSwapIndex = self.moveIndex
end
elseif input:wasPressed("b") then
-- B leaves the list, and a mark never survives it
self:playSfx("Sfx_ReadText2")
self.moveSwapIndex = nil
self.phase = "menu"
elseif input:wasPressed("a") then
-- `xor a / ld [wSwappingMove], a` opens the A arm: choosing a move
-- cancels a pending swap rather than performing it
self:playSfx("Sfx_ReadText2")
self.moveSwapIndex = nil
local move = moves[self.moveIndex]
if not move then return end
-- `.no_pp_left` and `.move_disabled` both end on `jp MoveSelectionScreen`
-- (engine/battle/core.asm:5213-5246): neither spends the turn.
if (move.pp or 0) <= 0 then return self:refuseMove(TEXT_NO_PP_LEFT) end
if self.battle:moveDisabled(self.battle.player, move.id) then
return self:refuseMove(TEXT_MOVE_DISABLED)
end
self:submit({ kind = "move", move = move.id })
end
return
end
if self.phase == "ask-nickname" then
-- AskGiveNicknameText ends on `done`, so the line stands while the box is
-- up rather than paging away from under it.
if self.messageTimer > 0 then
if input:wasPressed("a") or input:wasPressed("b") then
self.messageTimer = 0
end
return
end
if input:wasPressed("up") or input:wasPressed("down") then
self.nicknameIndex = self.nicknameIndex == 1 and 2 or 1
elseif input:wasPressed("b") then
-- YesNoMenuHeader carries no STATICMENU_DISABLE_B: B is NO.
return self:answerNickname(false)
elseif input:wasPressed("a") then
return self:answerNickname(self.nicknameIndex == 1)
end
return
end
-- The prompt's earlier pages: PlaceYesNoBox only follows the LAST one
-- (engine/battle/core.asm:3302-3305), so the mon is named and read first.
if self.phase == "shift-intro" then
if self.messageTimer > 0 then
if input:wasPressed("a") or input:wasPressed("b") then
self.messageTimer = 0
end
return
end
self:nextPage()
if not self.messagePages then self.phase = "ask-shift" end
return
end
-- OfferSwitch's YesNoBox: YES opens PickSwitchMonInBattle, NO (and B) falls
-- straight through to the enemy's send-out (engine/battle/core.asm:3305-3310).
if self.phase == "ask-shift" then
if self.messageTimer > 0 then
if input:wasPressed("a") or input:wasPressed("b") then
self.messageTimer = 0
end
return
end
if input:wasPressed("up") or input:wasPressed("down") then
self.shiftIndex = self.shiftIndex == 1 and 2 or 1
elseif input:wasPressed("b") then
self.phase = "resolving"
return self:advanceQueue()
elseif input:wasPressed("a") then
if self.shiftIndex == 1 then return self:openShiftParty() end
self.phase = "resolving"
return self:advanceQueue()
end
return
end
-- engine/battle/core.asm:2590
if self.phase == "ask-next-mon" then
if self.messageTimer > 0 then
if input:wasPressed("a") or input:wasPressed("b") then
self.messageTimer = 0
end
return
end
if input:wasPressed("up") or input:wasPressed("down") then
self.nextMonIndex = self.nextMonIndex == 1 and 2 or 1
elseif input:wasPressed("b") then
return self:answerUseNextMon(false)
elseif input:wasPressed("a") then
return self:answerUseNextMon(self.nextMonIndex == 1)
end
return
end
if self.phase == "cant-escape-then-switch" then
if self.messageTimer > 0 then
if input:wasPressed("a") or input:wasPressed("b") then
self.messageTimer = 0
end
return
end
self.message = "Choose a POKéMON."
self.phase = "forced-switch"
return
end
if self.phase == "refuse-shift" then
if self.messageTimer > 0 then
if input:wasPressed("a") or input:wasPressed("b") then
self.messageTimer = 0
end
return
end
self.message = nil
return self:openShiftParty()
end
if self.phase == "forced-switch" then
-- Reuse the party list so the layout and controls match the start menu's.
self:openParty(true)
return
end
-- CheckIfCurPartyMonIsFitToFight said no. Its text is read with the list
-- CLOSED here rather than over it (the party menu is its own screen in this
-- port), and the list comes back the moment the line is done -- which is
-- what ForcePickPartyMonInBattle's `jr c, .loop` does with the carry.
if self.phase == "refuse-switch" then
if self.messageTimer > 0 then
if input:wasPressed("a") or input:wasPressed("b") then
self.messageTimer = 0
end
return
end
local forced = self.refuseForced
self.refuseForced = nil
self.message = nil
if not self:openParty(forced) then
-- No stack to open a list on (headless): the forced arm falls back to
-- the phase that keeps asking, and a voluntary one to the menu.
self.phase = forced and "forced-switch" or "menu"
end
return
end
if self.phase == "refuse-move" then
if self.messageTimer > 0 then
if input:wasPressed("a") or input:wasPressed("b") then
self.messageTimer = 0
end
return
end
self.message = nil
self.phase = "moves"
return
end
if self.phase == "learn-intro" then
if self.messageTimer > 0 then
if input:wasPressed("a") or input:wasPressed("b") then
self.messageTimer = 0
end
return
end
-- YesNoBox follows the last page of the text (engine/pokemon/learn.asm:125).
self:nextPage()
if not self.messagePages then self.phase = "ask-forget" end
return
end
if self.phase == "ask-forget" or self.phase == "stop-learning" then
if self.messageTimer > 0 then
if input:wasPressed("a") or input:wasPressed("b") then
self.messageTimer = 0
end
return
end
if input:wasPressed("up") or input:wasPressed("down") then
self.forgetChoice = self.forgetChoice == 1 and 2 or 1
elseif input:wasPressed("b") then
-- YesNoMenuHeader carries no STATICMENU_DISABLE_B: B is NO.
return self:answerForgetPrompt(false)
elseif input:wasPressed("a") then
return self:answerForgetPrompt(self.forgetChoice == 1)
end
return
end
if self.phase == "choose-forget" then
-- MoveCantForgetHMText holds like any prompt, then `jr .loop` reprints
-- MoveAskForgetText over the list (engine/pokemon/learn.asm:193-197).
if self.messageTimer > 0 then
if input:wasPressed("a") or input:wasPressed("b") then
self.messageTimer = 0
end
return
end
self.message = Strings(TEXT_ASK_FORGET_SLOT)
local learn = self.pendingLearn
local mon = learn and self.battle.party[learn.index]
local moves = (mon and mon.moves) or {}
if input:wasPressed("up") then
self.forgetIndex = self.forgetIndex > 1 and self.forgetIndex - 1 or #moves
elseif input:wasPressed("down") then
self.forgetIndex = self.forgetIndex < #moves and self.forgetIndex + 1 or 1
elseif input:wasPressed("b") then
-- ForgetMove's .cancel sets carry, which is LearnMove's .cancel
-- (engine/pokemon/learn.asm:187-201).
return self:askStopLearning()
elseif input:wasPressed("a") then
local slot = moves[self.forgetIndex]
if slot and HM_MOVES[slot.id] then
-- MoveCantForgetHMText, then `jr .loop`, which re-seeds wMenuCursorY
-- (engine/pokemon/learn.asm:155-157, 193-197).
self.message = Strings(TEXT_CANT_FORGET_HM)
self.messageTimer = MESSAGE_FRAMES
self.forgetIndex = 1
return
end
self.battle:resolveForget(learn.index, self.forgetIndex,
learn.move, learn.moveName)
self.pendingLearn = nil
self.phase = "resolving"
self:pushAll(self.battle:takeEvents())
self:advanceQueue()
end
return
end
end
-- Returns whether the list actually opened, so a caller that has to do
-- something else when it cannot (no stack at all) can tell.
function BattleState:openParty(forced)
local stack = self.game and self.game.stack
if not stack then return false end
self.phase = "submenu"
Screens.push(self.game, "Gen2PartyMenu", {
-- PARTYMENUACTION_CHOOSE_POKEMON for the voluntary list and
-- PARTYMENUACTION_SWITCH for the forced one (engine/battle/core.asm:4795,
-- :2702; engine/pokemon/party_menu.asm:660-679). Only the voluntary list
-- carries BattleMonMenu; PickPartyMonInBattle has no submenu.
prompt = forced and "which" or "choose",
battleSubmenu = not forced,
onCancel = function()
stack:pop()
-- A forced switch cannot be cancelled.
self.phase = forced and "forced-switch" or "menu"
end,
onChoose = function(index, mon)
stack:pop()
-- TryPlayerSwitch's own order, every arm ending on
-- `jp BattleMenuPKMN_Loop` (engine/battle/core.asm:4863-4888).
if not forced and mon == self.battle.player then
return self:refuseSwitch(false,
self:name(mon) .. " is already out.")
end
if not forced and self.battle:switchLocked() then
return self:refuseSwitch(false,
self:name(self.battle.player) .. " can't be recalled!")
end
-- CheckIfCurPartyMonIsFitToFight's `cp EGG` arm (core.asm:3450-3456).
if mon.isEgg then
return self:refuseSwitch(forced, TEXT_EGG_CANT_BATTLE)
end
if (mon.hp or 0) <= 0 then
-- CheckIfCurPartyMonIsFitToFight (engine/pokemon/party_menu.asm):
-- a fainted pick prints Text_TheresNoWillToFight and returns carry, so
-- the caller re-opens the list -- BattleMenu_PKMN's own loop for a
-- voluntary switch, ForcePickPartyMonInBattle's `jr c` after a faint.
-- Silently dropping the player back on the list (or, worse, back on
-- the battle menu) is what made the forced switch read as taking two
-- or three presses: the cursor opens on the mon that just fainted, and
-- pressing A on it did nothing a player could see.
return self:refuseSwitch(forced)
end
if forced then
-- ForcePickPartyMonInBattle loops on carry: a pick the engine will not
-- take has to come back as the list again, never as the battle menu
-- with a fainted mon standing on the field.
if not self.battle:switch(index) then
return self:refuseSwitch(true)
end
self:pushAll(self.battle:takeEvents())
self.phase = "resolving"
self:advanceQueue()
else
self:submit({ kind = "switch", index = index })
end
end,
})
return true
end
-- The refusal itself: the line, then the same list again. `forced` is carried
-- so a faint's list comes back with no CANCEL of its own and a voluntary one
-- keeps its own.
function BattleState:refuseSwitch(forced, text)
self.refuseForced = forced and true or false
self.phase = "refuse-switch"
self.message = text or TEXT_NO_WILL_TO_FIGHT
self.messageTimer = MESSAGE_FRAMES
end
function BattleState:refuseMove(text)
self.phase = "refuse-move"
self.message = text
self.messageTimer = MESSAGE_FRAMES
end
-- BattleMenu_Pack: `farcall BattlePack`, which is a different jumptable from
-- the field PACK's -- it dispatches on the item's BATTLE menu nibble and never
-- reaches a field effect. The empty world is the same guard MartMenu:enterSell
-- and ItemPcMenu:enterDeposit carry: PackMenu falls back to game.world when it
-- is nil, and that world is the overworld this battle is suspended over.
function BattleState:openPack()
local stack = self.game and self.game.stack
if not stack then return end
self.phase = "submenu"
Screens.push(self.game, "Gen2PackMenu", {
battle = true,
world = {},
onClose = function()
stack:pop()
self.phase = "menu"
end,
onChoose = function(itemId)
stack:pop()
self:useItem(itemId)
end,
})
end
-- BattleMenu_Pack's `.tutorial` arm: `farcall TutorialPack`, and then POKE_BALL
-- goes into wCurItem and DoItemEffect runs WHATEVER the pack came back with --
-- TutorialPack's own tail writes FALSE to wPackUsedItem, so its answer is
-- discarded. The pack is real all the same: it is drawn from the DUDE's own
-- buffers (wDudeNumItems / wDudeNumBalls, one POTION and one POKE BALL), and
-- the DUDE_RIGHT_A stream armed with it is what crosses from the ITEM pocket to
-- the BALL pocket and picks the ball, which is the whole point of the demo.
function BattleState:openTutorialPack()
local stack = self.game and self.game.stack
if not stack then return self:useItem(CatchTutorial.BALL) end
self.phase = "submenu"
local function throw()
stack:pop()
self:useItem(CatchTutorial.BALL)
end
Screens.push(self.game, "Gen2PackMenu", {
battle = true,
save = CatchTutorial.dudeSave(),
-- An empty world rather than the real one: a DUDE pocket must not reach
-- World:useFieldItem, because these buffers are not the player's bag and
-- nothing in them may be spent. PackMenu falls back to game.world when
-- this is nil, so it has to be a table.
world = {},
onClose = throw,
onChoose = throw,
})
self:dudeInput(CatchTutorial.PACK_STREAM, nil, true)
end
-- BattleMenu_Pack's `.contest` arm: it does NOT open the pack. PARK_BALL goes
-- straight into wCurItem and DoItemEffect runs, so the third menu slot IS the
-- throw and nothing else can be used inside the park.
function BattleState:throwParkBall()
self:useItem(BugContest.BALL)
end
--------------------------------------------------------------------------
-- PokeBallEffect (engine/items/item_effects.asm:213)
--------------------------------------------------------------------------
-- data/battle_anims/ball_colors.asm BallColors, in its own order. Anything
-- not listed falls to the terminator row's PAL_BATTLE_OB_GRAY.
local BALL_COLORS = {
MASTER_BALL = "PAL_BATTLE_OB_GREEN",
ULTRA_BALL = "PAL_BATTLE_OB_YELLOW",
GREAT_BALL = "PAL_BATTLE_OB_BLUE",
POKE_BALL = "PAL_BATTLE_OB_RED",
HEAVY_BALL = "PAL_BATTLE_OB_GRAY",
LEVEL_BALL = "PAL_BATTLE_OB_BROWN",
LURE_BALL = "PAL_BATTLE_OB_BLUE",
FAST_BALL = "PAL_BATTLE_OB_BLUE",
FRIEND_BALL = "PAL_BATTLE_OB_YELLOW",
MOON_BALL = "PAL_BATTLE_OB_GRAY",
LOVE_BALL = "PAL_BATTLE_OB_RED",
}
local BALL_COLOR_DEFAULT = "PAL_BATTLE_OB_GRAY"
-- POKE_BALL's own item id (constants/item_constants.asm:13), for a cache whose
-- items table has no index on the row.
local POKE_BALL_ID = 5
-- data/battle/wobble_probabilities.asm WobbleProbabilities: catch rate, then
-- the chance out of 255 of wobbling again rather than breaking free.
local WOBBLE_PROBABILITIES = {
{ 1, 63 }, { 2, 75 }, { 3, 84 }, { 4, 90 }, { 5, 95 }, { 7, 103 },
{ 10, 113 }, { 15, 126 }, { 20, 134 }, { 30, 149 }, { 40, 160 },
{ 50, 169 }, { 60, 177 }, { 80, 191 }, { 100, 201 }, { 120, 211 },
{ 140, 220 }, { 160, 227 }, { 180, 234 }, { 200, 240 }, { 220, 246 },
{ 240, 251 }, { 254, 253 }, { 255, 255 },
}
-- GetPokeBallWobble's `cp 3 + 1`: the ball wobbles up to three times and the
-- fourth call is the verdict.
local WOBBLE_LIMIT = 3
-- The four failure lines, indexed by wThrownBallWobbleCount exactly the way
-- item_effects.asm:414-428 indexes them (data/text/common_3.asm:239-258).
local BALL_FAILURE_TEXT = {
"Oh no! The POKéMON broke free!",
"Aww! It appeared to be caught!",
"Aargh! Almost had it!",
"Shoot! It was so close too!",
}
-- Text_BallCaught's own sound_caught_mon (data/text/common_3.asm:265).
local SFX_CAUGHT_MON = "Sfx_CaughtMon"
-- wBattleAnimParam for the throw: the item's own id, except that everything
-- past POKE_BALL (the Kurt balls) is thrown with POKE_BALL's -- `cp POKE_BALL
-- + 1 / jr c, .not_kurt_ball / ld a, POKE_BALL` (item_effects.asm:396). It is
-- what BattleAnim_ThrowPokeBall's anim_if_param_equal rows branch on
-- (data/moves/animations.asm:305-308).
function BattleState:ballAnimParam(itemId)
local items = ((self.game and self.game.data) or {}).items or {}
local pokeBall = (items.POKE_BALL and items.POKE_BALL.index) or POKE_BALL_ID
local id = (items[itemId] and items[itemId].index) or pokeBall
if id > pokeBall then return pokeBall end
return id
end
-- GetBallAnimPal (engine/battle_anims/functions.asm:292), which the thrown
-- ball's object function reads out of env.ballPalette.
function BattleState:ballPalette(itemId)
return BALL_COLORS[itemId] or BALL_COLOR_DEFAULT
end
-- `ld de, ANIM_THROW_POKE_BALL ... xor a / ldh [hBattleTurn], a`: the ball is
-- thrown from the player's side whatever the turn order was.
function BattleState:startBallAnim(param, itemId)
local key = self.anims and self.anims.ids
and self.anims.ids.ANIM_THROW_POKE_BALL
return self:startAnim(key, {
turn = 0, animId = "ANIM_THROW_POKE_BALL", param = param,
ballPalette = self:ballPalette(itemId),
})
end
-- GetPokeBallWobble (engine/battle_anims/pokeball_wobble.asm), which
-- anim_checkpokeball loops on: 0 wobble again, 1 click, 2 break free. The
-- counter goes up FIRST, and the fourth call ends the loop -- a caught mon
-- clicks, anything else breaks free. Before that a caught mon always wobbles
-- again and a doomed one re-rolls: the first WobbleProbabilities row whose
-- catch rate is at least the final rate is the one whose byte the roll has to
-- come in under.
function BattleState:pokeballWobble()
local throw = self.ballThrow
if not throw then return 0 end
throw.wobble = (throw.wobble or 0) + 1
if throw.wobble == WOBBLE_LIMIT + 1 then
return throw.caught and 1 or 2
end
if throw.caught then return 0 end
local chance = 0
for _, row in ipairs(WOBBLE_PROBABILITIES) do
if row[1] >= (throw.rate or 0) then chance = row[2] break end
end
local random = self.battle and self.battle.random
local roll = random and random(256) or 0
return roll < chance and 0 or 2
end
-- Which of the four lines the throw earned. With no animation to run (an
-- older cache, or BATTLE SCENE off) nothing ever wobbled, so it is the first.
function BattleState:ballFailureText()
local wobble = (self.ballThrow and self.ballThrow.wobble) or 1
return BALL_FAILURE_TEXT[math.max(1, math.min(#BALL_FAILURE_TEXT, wobble))]
end
-- UseBallInTrainerBattle (item_effects.asm:2579). Not a bare refusal: the ball
-- is thrown with wBattleAnimParam = 0, which BattleAnim_ThrowPokeBall's
-- `anim_if_param_equal NO_ITEM` sends to .TheTrainerBlockedTheBall, then BOTH
-- lines print and it falls into UseDisposableItem -- so the ball is spent and
-- the turn goes with it.
function BattleState:throwBallAtTrainer(itemId)
self.queue = {}
self:push({ kind = "message", text = "The trainer blocked the BALL!" })
self:push({ kind = "message", text = "Don't be a thief!" })
self:consumeItem(itemId)
self:pushAll(self.battle:takeTurn({ kind = "item", item = itemId }))
-- NO_ITEM is 0, the id BattleAnim_ThrowPokeBall's first row tests.
self:startBallAnim(0, itemId)
self.message = nil
self.messageTimer = 0
self.phase = "resolving"
if not self.anim then self:advanceQueue() end
end
-- Which box `.SendToPC` writes into. wCurBox is a BYTE the cart masks before
-- it ever indexes with it (`ld a, [wCurBox] / and $f`, and GetBoxCount's own
-- bounds), so no value of it can address a box that is not there; this save
-- field holds the same number 1-based. The mask is worth keeping because
-- Boxes.box answers an index outside 1..NUM_BOXES with a THROWAWAY table
-- rather than an error (src/core/gen2/Boxes.lua), so an out-of-range wCurBox
-- would insert the catch into a table nothing owns while "was sent to BILL's
-- PC" printed -- a silently lost mon.
--
-- No path writes one today, and the comment that used to stand here was wrong
-- to say a converted cartridge save does: GenSave decodes the cart's 0-based
-- byte as `curBoxNum + 1` clamped to 1..12 before it ever reaches
-- save.currentBox (src/save_convert/GenSave.lua), and SaveConvert refuses a
-- Gen 2 cart save outright anyway; Save.lua defaults the field to 1 and
-- Boxes.setCurrent validates every write. So this is the cart's own mask,
-- not a fix for a reachable state. What it DOES buy every day is that the
-- storage gate (Ball_BoxIsFullMessage), the box-just-filled test and the
-- insert all ask about the SAME box, so those three can never disagree.
function BattleState:currentBox()
local save = self.save
local index = (save and tonumber(save.currentBox)) or 1
return math.max(1, math.min(Boxes.NUM_BOXES, math.floor(index)))
end
function BattleState:hasPokedex()
local save = self.save or {}
return (save.engineFlags or {})[ENGINE_POKEDEX] == true
or save.pokedexReceived == true
end
-- PokeBallEffect's caught tail, in the cart's order (item_effects.asm:514-676):
-- Text_GotchaMonWasCaught, CheckCaughtMon / SetSeenAndCaughtMon, the new-entry
-- line and NewPokedexEntry, the party add or .SendToPC, then
-- AskGiveNicknameText.
function BattleState:pushCaught(enemy, itemId)
local save = self.save
self.battle.over = true
self.battle.outcome = "caught"
-- The mon is INSIDE the ball from here on. BattleAnim_ThrowPokeBall's caught
-- arm ends on the return-mon BG effect, which leaves the enemy pic box
-- cleared, and PokeBallEffect never draws a frontpic again -- there is no
-- send-out left in a battle that is already over. Latched here as well as
-- from the animation's own last step so that a throw the player skipped with
-- B (BattleAnimRunScript has no such skip; this port does) cannot put the
-- caught mon back on the field for the "Gotcha!" line.
self.picHidden.enemy = true
-- PokeBallEffect's FRIEND_BALL arm: the caught mon's happiness is set to
-- FRIEND_BALL_HAPPINESS (200) instead of the base 70. That is the ball's
-- whole effect; its catch rate is a plain ball's. It applies on the box
-- path too (item_effects.asm:620-625).
if itemId == "FRIEND_BALL" then
enemy.happiness = Catching.FRIEND_BALL_HAPPINESS
end
-- Text_BallCaught ends in `sound_caught_mon` (data/text/common_3.asm:260-266),
-- and TX_SOUND holds the text engine until the jingle is done
-- (home/text.asm:834-835), so the line is not dismissable under it.
self:push({ kind = "message", sfx = SFX_CAUGHT_MON, waitSfx = true,
text = "Gotcha! " .. self:name(enemy) .. " was caught!" })
-- BATTLETYPE_TUTORIAL returns before every one of the steps below
-- (`.FinishTutorial`, and `.return_from_capture: ret z`).
if self.tutorial or not save then return end
-- TryAddMonToParty and BugContest_SetCaughtContestMon both end in the same
-- wPlayerID write (item_effects.asm:548-556, :680; move_mon.asm:143-149).
Mon.stampOT(save, enemy)
save.pokedex = save.pokedex or { seen = {}, caught = {} }
-- CheckCaughtMon answers whether this row was ALREADY owned, and it is asked
-- before SetSeenAndCaughtMon stamps it (item_effects.asm:519-527). Both run
-- BEFORE the `.catch_bug_contest_mon` branch, so the dex is marked even
-- though a contest mon is only being HELD.
local knew = save.pokedex.caught[enemy.species] and true or false
save.pokedex.caught[enemy.species] = true
save.pokedex.seen[enemy.species] = true
-- NewDexDataText and `predef NewPokedexEntry` both run above `.skip_pokedex`,
-- so the contest branch is BELOW them (item_effects.asm:528-546), and
-- CheckReceivedDex gates the pair (:532-533).
if not knew and self:hasPokedex() then
self:push({ kind = "message",
text = self:name(enemy) .. "'s data was newly added to the #DEX." })
self:push({ kind = "dex-entry", species = enemy.species })
end
if self.contest then
return self:contestCatch(enemy)
end
save.party = save.party or {}
local toPc = #save.party >= Boxes.PARTY_SIZE
if toPc then
-- `.SendToPC` / `predef SendMonIntoBox` (item_effects.asm:548-550, 604):
-- a full party sends the catch to the current box. Not Boxes.deposit,
-- which is the PC's own party-to-box move and carries the last-healthy-mon
-- and mail refusals that have nothing to do with a capture.
local box = Boxes.box(save, self:currentBox())
-- SendMonIntoBox inserts at the HEAD: its species loop cascades every
-- entry one slot further down (move_mon.asm:954-965) and ShiftBoxMon does
-- the same for the OT names, nicknames and mon structs (:968, :1074-1085),
-- so the catch lands in slot 1 -- which is what lets the FRIEND_BALL arm
-- write sBoxMon1Happiness unconditionally ("The captured mon is now first
-- in the box", item_effects.asm:624). Boxes.deposit stays an append: the
-- PC's own move is InsertPokemonIntoBox, which inserts at the cursor.
table.insert(box, 1, enemy)
-- `.SendToPC` re-reads sBoxCount AFTER the insert and sets
-- BATTLERESULT_BOX_FULL when the box has just filled
-- (item_effects.asm:612-619); Script_reloadmapafterbattle tests that bit
-- on the wild arm and rings the player as PHONE_BILL on the first step
-- back in the overworld (engine/overworld/scripting.asm:1097-1104).
if Boxes.isFull(save, self:currentBox()) then
self.battle.boxFilled = true
end
else
save.party[#save.party + 1] = enemy
end
-- AddPartyMon's `.registerunowndex` and SendMonIntoBox's `.not_unown` are the
-- two places the cart appends to wUnownDex, and both are on this path: the
-- form list is what UNOWN MODE and VAR_UNOWNCOUNT read
-- (src/core/gen2/Unown.lua). A contest catch is only HELD, so it returned
-- above without registering.
Unown.registerCatch(save, enemy)
-- Same name and same payload keys as the Gen 1 site
-- (src/battle/BattleState.lua's pokemon.caught), so one subscription covers
-- both games: `isNew` is CheckCaughtMon's answer read BEFORE
-- SetSeenAndCaughtMon stamped it, and `destination` is which of the two
-- homes PokeBallEffect actually used. Emitted after the mon is in that
-- home and after Unown.registerCatch, so a listener reading save.party,
-- the box or the Unown dex sees the settled state. A tutorial catch and a
-- contest catch both returned above without ever owning the mon, which is
-- why neither reaches this line.
Runtime.emit("pokemon.caught", {
battle = self.battle, mon = enemy, species = enemy.species,
isNew = not knew, ball = itemId,
destination = toPc and "box" or "party", game = self.game,
})
self:push({ kind = "ask-nickname", mon = enemy })
if toPc then
-- BallSentToPCText, which .SendToPC prints AFTER the nickname prompt
-- (item_effects.asm:672).
self:push({ kind = "message",
text = self:name(enemy) .. " was sent to BILL's PC." })
end
end
-- CheckWhetherToAskSwitch: a started battle, more than one mon, no link, the
-- BATTLE_SHIFT bit CLEAR (which is SHIFT), and the active mon not fainted
-- (engine/battle/core.asm:3269-3295, engine/menus/options_menu.asm:249-256).
function BattleState:shiftOfferAllowed()
local battle = self.battle
if not (battle and battle.player and battle.trainer) then return false end
if #(battle.party or {}) < 2 then return false end
if (battle.player.hp or 0) <= 0 then return false end
local options = self.game and self.game.options
return (options and options.battleStyle or "SHIFT") == "SHIFT"
end
-- OfferSwitch: Battle_GetTrainerName, the prompt, then PlaceYesNoBox
-- (engine/battle/core.asm:3298-3304, data/text/battle.asm:222-231).
function BattleState:offerShiftSwitch(mon)
self.shiftIndex = 1
local trainer = (self.battle.trainer and self.battle.trainer.name) or "Foe"
local player = (self.save and self.save.player and self.save.player.name)
or "GOLD"
-- The `para` splits this in two (data/text/battle.asm:222-231): the incoming
-- mon is NAMED on its own page, and only the second carries the yes/no box.
self:showPages(Strings(TEXT_ENEMY_ABOUT_TO_USE, trainer, self:name(mon), player))
self.phase = self.messagePages and "shift-intro" or "ask-shift"
end
function BattleState:answerUseNextMon(yes)
if yes then
self.phase = "forced-switch"
self.message = "Choose a POKéMON."
return
end
local battle = self.battle
if not battle then
self.phase = "forced-switch"
return
end
local lead = battle.party and battle.party[1]
local pSpd = (lead and lead.stats and lead.stats.speed) or 0
-- engine/battle/core.asm:2614
if battle:tryRun(pSpd) then
self:pushAll(battle:takeEvents())
self.phase = "resolving"
return self:advanceQueue()
end
battle:takeEvents()
self.message = "Can't escape!"
self.messageTimer = MESSAGE_FRAMES
self.phase = "cant-escape-then-switch"
end
-- SetUpBattlePartyMenu + PickSwitchMonInBattle (core.asm:3307-3308), which is
-- PARTYMENUACTION_SWITCH and carries no submenu; a cancel is `.canceled_switch`
-- and answers exactly like NO (:3327).
function BattleState:openShiftParty()
local stack = self.game and self.game.stack
if not stack then
self.phase = "resolving"
return self:advanceQueue()
end
self.phase = "submenu"
Screens.push(self.game, "Gen2PartyMenu", {
prompt = "which",
onCancel = function()
stack:pop()
self.phase = "resolving"
self:advanceQueue()
end,
onChoose = function(index, mon)
stack:pop()
if mon == self.battle.player then
return self:refuseShift(self:name(mon) .. " is already out.")
end
if mon.isEgg then return self:refuseShift(TEXT_EGG_CANT_BATTLE) end
if (mon.hp or 0) <= 0 then return self:refuseShift(nil) end
self.shiftSwitchIndex = index
self.phase = "resolving"
self:advanceQueue()
end,
})
end
-- PickSwitchMonInBattle loops on carry the way BattleMenuPKMN_Loop does
-- (engine/battle/core.asm:2716-2728).
function BattleState:refuseShift(text)
self.phase = "refuse-shift"
self.message = text or TEXT_NO_WILL_TO_FIGHT
self.messageTimer = MESSAGE_FRAMES
end
-- AskGiveNicknameText + YesNoBox (item_effects.asm:566-578). B is the NO arm
-- (`jp c, .return_from_capture`), which leaves the species name standing.
function BattleState:askNickname(mon)
self.nicknameMon = mon
-- YesNoBox opens on YES; YesNoMenuHeader sets no STATICMENU_DISABLE_B.
self.nicknameIndex = 1
self.phase = "ask-nickname"
self.message = "Give a nickname to " .. self:name(mon) .. "?"
self.messageTimer = MESSAGE_FRAMES
end
-- One prompt page in the box, with the rest held for the presses `para` and
-- `cont` wait on (home/text.asm:403-448).
function BattleState:showPages(text)
local pages = paginate(text)
self.messagePages = #pages > 1 and pages or nil
self.messagePage = 1
self.message = pages[1]
self.messageTimer = MESSAGE_FRAMES
end
function BattleState:nextPage()
local pages = self.messagePages
if not pages then return false end
local i = self.messagePage + 1
self.messagePage = i
self.message = pages[i]
self.messageTimer = MESSAGE_FRAMES
if i >= #pages then self.messagePages = nil end
return true
end
-- ForgetMove's AskForgetMoveText + YesNoBox (engine/pokemon/learn.asm:123-127);
-- LearnMove's `jp c, .loop` reprints the whole text, so this is the loop head.
function BattleState:askForget()
if not self.pendingLearn then return self:advanceQueue() end
self.forgetIndex = 1
self.forgetChoice = 1
local party = (self.battle and self.battle.party) or {}
local name = self:name(party[self.pendingLearn.index])
local moveName = self.pendingLearn.moveName or "?"
self:showPages(Strings(TEXT_ASK_FORGET_MOVE, name, moveName, name, moveName))
self.phase = self.messagePages and "learn-intro" or "ask-forget"
end
-- LearnMove's .cancel: StopLearningMoveText, and a NO is `jp c, .loop`
-- (engine/pokemon/learn.asm:104-108).
function BattleState:askStopLearning()
if not self.pendingLearn then return self:advanceQueue() end
self.forgetChoice = 1
self.phase = "stop-learning"
self:showPages(Strings(TEXT_STOP_LEARNING, self.pendingLearn.moveName or "?"))
end
-- DidNotLearnMoveText, then `ld b, 0` and back to the queue (learn.asm:110-113).
function BattleState:finishDecline()
local learn = self.pendingLearn
self.pendingLearn = nil
self.phase = "resolving"
if learn then self.battle:declineForget(learn.index, learn.moveName) end
self:pushAll(self.battle:takeEvents())
self:advanceQueue()
end
function BattleState:answerForgetPrompt(yes)
if self.phase == "ask-forget" then
if not yes then return self:askStopLearning() end
-- MoveAskForgetText over the four-slot list (learn.asm:135-146).
self.forgetIndex = 1
self.phase = "choose-forget"
self.message = Strings(TEXT_ASK_FORGET_SLOT)
self.messageTimer = 0
return
end
if yes then return self:finishDecline() end
return self:askForget()
end
function BattleState:answerNickname(yes)
local mon = self.nicknameMon
self.nicknameMon = nil
self.phase = "resolving"
local stack = self.game and self.game.stack
if not (yes and mon and stack) then return self:advanceQueue() end
self.phase = "submenu"
local data = (self.game and self.game.data) or {}
local icons = data.gen2Icons
local iconId = icons and icons.species and icons.species[mon.species]
local entry = iconId and icons.icons and icons.icons[iconId]
local done = function(name)
stack:pop()
-- InitName: an empty entry keeps whatever was already in the buffer, which
-- for a fresh capture is the species name.
if name and #name > 0 then mon.nickname = name end
self.phase = "resolving"
self:advanceQueue()
end
Screens.push(self.game, "Gen2NamingScreen", {
type = "nickname",
monName = mon.name or mon.species,
iconPath = entry and entry.image or nil,
menuGfx = data.gen2MenuGfx,
onDone = done,
onCancel = function() done(nil) end,
})
end
-- BugContest_SetCaughtContestMon (engine/events/bug_contest/caught_mon.asm).
-- With nothing in stock the catch is kept outright (`.firstcatch`); with a mon
-- already in stock the player is shown the comparison and asked, and the NO arm
-- -- which is also what B does -- keeps the mon they already had.
function BattleState:contestCatch(mon)
local kind, stock, fresh = BugContest.catch(self.save, mon)
if kind ~= BugContest.ASK_SWITCH then
self:push({ kind = "message", text = "Caught " .. self:name(mon) .. "!" })
return
end
self:push({ kind = "message",
text = "You already caught a " .. self:name(stock) .. "." })
self:push({ kind = "contest-switch", stock = stock, caught = fresh })
end
function BattleState:openContestSwitch(event)
local stack = self.game and self.game.stack
if not stack then return self:advanceQueue() end
self.phase = "submenu"
Screens.push(self.game, "Gen2ContestMenu", {
save = self.save,
stock = event.stock,
caught = event.caught,
onClose = function()
stack:pop()
self.phase = "resolving"
self:advanceQueue()
end,
})
end
-- NewPokedexEntry: the dex opens straight on the new species' entry and pages
-- twice (engine/pokedex/new_pokedex_entry.asm:19-23).
function BattleState:openDexEntry(species)
local stack = self.game and self.game.stack
local dex = ((self.game and self.game.data) or {}).gen2Pokedex
local entry = dex and dex.entries and dex.entries[species]
if not (stack and entry) then return self:advanceQueue() end
self.phase = "submenu"
Screens.push(self.game, "Gen2PokedexMenu", {
entrySpecies = species,
newEntry = true,
onClose = function()
stack:pop()
self.phase = "resolving"
self:advanceQueue()
end,
})
end
-- Items in battle: balls try a catch, the stat items apply their stage, and
-- everything with a ported party effect runs the same item_effects.asm routine
-- the field pack runs. Anything else reports that it cannot be used, which is
-- what the cart does for a key item.
function BattleState:useItem(itemId)
local data = self.game and self.game.data or {}
local def = data.items and data.items[itemId]
local pocket = def and def.pocket
local save = self.save
if pocket == "BALL" then
-- `ld a, [wBattleMode] / dec a / jp nz, UseBallInTrainerBattle`, the very
-- first thing PokeBallEffect does.
if not self.battle.wild then
return self:throwBallAtTrainer(itemId)
end
-- The storage gate, before the ball is spent and before the rate is
-- computed (item_effects.asm:217-226): a full party AND a full current box
-- takes Ball_BoxIsFullMessage, which writes wItemEffectSucceeded = 2 --
-- "item wasn't used" -- so neither the ball nor the turn goes.
if #((save and save.party) or {}) >= Boxes.PARTY_SIZE
and Boxes.isFull(save, self:currentBox()) then
-- BallBoxFullText (data/text/common_3.asm:427).
self.message = "The POKéMON BOX is full. That can't be used now."
self.messageTimer = MESSAGE_FRAMES
self.phase = "resolving"
return
end
local enemy = self.battle.enemy
local enemyDef = data.pokemon and data.pokemon[enemy.species]
local caught, rate
if self.tutorial then
-- `ld a, [wBattleType] / cp BATTLETYPE_TUTORIAL /
-- jp z, .catch_without_fail`, checked BEFORE the Master Ball and before
-- the rate is ever computed. The tail then returns early for a tutorial
-- battle (`.return_from_capture: ret z`), which is why the DUDE's
-- RATTATA is not added to a party, not written to the Pokedex and not
-- registered in wUnownDex, and why the ball is not tossed out of the
-- bag: the bag it came from was never the player's. The THROW still
-- happens: `.catch_without_fail` falls into the shared animation.
caught, rate = true, 255
else
-- The specialty-ball conditions (BallMultiplierFunctionTable): each one
-- is something this screen already knows. Heavy Ball reads the dex
-- weight, Moon Ball the species' stone row, Love Ball both genders,
-- Level Ball the two levels, Lure Ball wBattleType.
local dexEntry = data.gen2Pokedex and data.gen2Pokedex[enemy.species]
local evolveItem
for _, entry in ipairs((enemyDef and enemyDef.evolutions) or {}) do
if entry.method == "EVOLVE_ITEM" then evolveItem = entry.item end
end
local player = self.battle.player
caught, rate = Catching.attempt({
maxHp = enemy.maxHp or (enemy.stats and enemy.stats.hp),
hp = enemy.hp,
catchRate = enemyDef and enemyDef.catchRate or 45,
ball = itemId,
status = enemy.status,
random = self.battle.random,
weight = dexEntry and dexEntry.weight,
level = enemy.level,
playerLevel = player and player.level,
fishing = self.battle.battleType == "fish",
species = enemy.species,
gender = enemy.gender,
playerSpecies = player and player.species,
playerGender = player and player.gender,
evolveItem = evolveItem,
})
end
-- wWildMon carries the answer through the animation, and
-- wThrownBallWobbleCount is the counter GetPokeBallWobble bumps once per
-- wobble -- which is what the failure line is picked from afterwards.
self.ballThrow = { caught = caught, rate = rate or 0, wobble = 0 }
-- A PARK BALL is never in the bag: PokeBallEffect's `.used_park_ball` does
-- `dec [hl]` on wParkBallsRemaining instead of tossing an item, so the
-- contest takes its ball off the counter and leaves the pack alone. The
-- tutorial spends nothing at all (`.return_from_capture: ret z`).
if not self.tutorial then
if self.contest then
if not caught then BugContest.useBall(save) end
else
self:consumeItem(itemId)
end
end
self.queue = {}
if caught then
self:pushCaught(enemy, itemId)
else
-- Resolved at drain time, because which of the four lines it is depends
-- on how far the wobble counter got inside the animation.
self:push({ kind = "ball-result" })
-- A failed ball still costs the turn.
self:pushAll(self.battle:takeTurn({ kind = "item", item = itemId }))
-- CheckContestBattleOver: the throw that empties the counter turns the
-- battle into a DRAW there and then, which is what sends the player back
-- to the gate instead of into the next patch of grass.
if self.contest and BugContest.isOver(save) then
self.battle.over = true
self.battle.outcome = "draw"
end
end
-- item_effects.asm:405-412: wBattleAnimParam from wCurItem, hBattleTurn 0,
-- wThrownBallWobbleCount 0, then `predef PlayBattleAnim`. Everything
-- pushed above is drained only once the ball has finished wobbling.
self:startBallAnim(self:ballAnimParam(itemId), itemId)
self.message = nil
self.messageTimer = 0
self.phase = "resolving"
if not self.anim then self:advanceQueue() end
return
end
-- The battle stat items (XItemEffect, XAccuracyEffect, DireHitEffect,
-- GuardSpecEffect): the engine applies the stage or the substatus bit and
-- this side spends the item and the turn. A refused re-use
-- (WontHaveAnyEffect_NotUsedMessage) costs neither.
if Battle.X_ITEM_STATS[itemId] or Battle.SUBSTATUS_ITEMS[itemId] then
local ok = self.battle:useBattleItem(itemId)
if not ok then
-- _ItemWontHaveEffectText's own `line` break, the same one
-- ItemEffects.TEXT_NO_EFFECT carries (data/text/common_3.asm).
self.message = ItemEffects.TEXT_NO_EFFECT
self.messageTimer = MESSAGE_FRAMES
self.phase = "resolving"
return
end
if save and save.inventory then
save.inventory[itemId] = math.max(0, (save.inventory[itemId] or 1) - 1)
if save.inventory[itemId] == 0 then save.inventory[itemId] = nil end
end
self.queue = {}
self:pushAll(self.battle:takeEvents())
self:pushAll(self.battle:takeTurn({ kind = "item", item = itemId }))
self.phase = "resolving"
self:advanceQueue()
return
end
-- BattlePack's .ItemFunctionJumptable (engine/items/pack.asm): its first
-- four entries are all .Oak, so an item that is ITEMMENU_NOUSE in a battle
-- does nothing there at all. The gate has to sit here rather than in the
-- pack, because the battle pack has no field-menu filter of its own and the
-- two nibbles disagree: a RARE CANDY is ITEMMENU_PARTY in the FIELD and
-- would otherwise level a mon mid-fight, and a BITTER BERRY is the reverse.
if not (def and def.battleMenu == "ITEMMENU_NOUSE") then
-- BitterBerryEffect: its whole effect is a battle substatus, so it has no
-- field row for src/core/gen2/ItemEffects.lua to carry and it never opens
-- the party list.
if itemId == "BITTER_BERRY" then
return self:cureBattleConfusion(itemId)
end
-- Everything else the pack can spend on a party mon runs the same
-- item_effects.asm routine the field pack runs: the potion line and the
-- drinks, the status cures and their berries, REVIVE / MAX REVIVE, and
-- the ETHER / ELIXER family.
local action = ItemEffects.partyAction(itemId)
if action then
return self:useOnPartyMon(itemId, action)
end
end
self.message = "That isn't going to help here."
self.messageTimer = MESSAGE_FRAMES
self.phase = "resolving"
end
-- UseItem_SelectMon (engine/items/item_effects.asm): every party-target item
-- picks its mon FIRST, so a benched mon can be healed, cured or stood back up
-- mid-battle -- ItemRestoreHP, StatusHealingEffect, ReviveEffect and
-- RestorePPEffect all open the list before they do anything. Backing out is
-- the .SelectMon carry path: back to the pack with nothing spent.
function BattleState:useOnPartyMon(itemId, action)
local stack = self.game and self.game.stack
if not stack then
return self:applyPartyItem(itemId, action, self.battle.player)
end
self.phase = "submenu"
Screens.push(self.game, "Gen2PartyMenu", {
prompt = "useItem",
party = self.battle.party or (self.save and self.save.party),
onCancel = function()
stack:pop()
self:openPack()
end,
onChoose = function(_, mon)
-- RestorePPEffect: the ETHER pair needs the move pick first, the ELIXER
-- pair walks every slot without one, and an EGG refuses before the move
-- list ever opens (UseItem_SelectMon's `cp EGG`).
local row = (action == "pp") and ItemEffects.RESTORE_PP[itemId] or nil
if row and not row.each and mon and not mon.isEgg then
return self:pickMoveForItem(itemId, mon)
end
stack:pop()
self:applyPartyItem(itemId, action, mon)
end,
})
end
-- RestorePPEffect's "Restore the PP of which move?" pick. MoveSelectionScreen
-- and ChooseMoveToDelete are the same SetUpMoveList box on the cart, so the
-- port serves both with src/ui/gen2/MoveDeleter.lua. Backing out drops only
-- the move list and leaves the party list standing, which is the routine's own
-- `jr nz, .loop`.
function BattleState:pickMoveForItem(itemId, mon)
local stack = self.game.stack
Screens.push(self.game, "Gen2MoveDeleter", {
mon = mon,
moves = self.game.data and self.game.data.moves,
onCancel = function() stack:pop() end,
onChoose = function(slot)
stack:pop() -- the move list
stack:pop() -- the party list
self:applyPartyItem(itemId, "pp", mon, slot)
end,
})
end
-- BitterBerryEffect: it reads wPlayerSubStatus3 straight off, so it acts on
-- whoever is out and a mon that is not confused refuses without spending
-- anything. UseItemText falls through into UseDisposableItem, so a cure does
-- cost the berry.
function BattleState:cureBattleConfusion(itemId)
local mon = self.battle.player
local state = mon and self.battle:volatile(mon)
if not (state and state.confuseCount) then
self.message = oneLine(ItemEffects.TEXT_NO_EFFECT)
self.messageTimer = MESSAGE_FRAMES
self.phase = "resolving"
return
end
state.confuseCount = nil
self:consumeItem(itemId)
self.queue = {}
-- ConfusedNoMoreText (data/text/battle.asm).
self:push({ kind = "message",
text = self:name(mon) .. "'s confused no more!" })
self:pushAll(self.battle:takeTurn({ kind = "item", item = itemId }))
self.phase = "resolving"
self:advanceQueue()
end
-- UseDisposableItem: one copy leaves the pack, and only on a success -- every
-- refusal above returns before this.
function BattleState:consumeItem(itemId)
local save = self.save
if not (save and save.inventory) then return end
save.inventory[itemId] = math.max(0, (save.inventory[itemId] or 1) - 1)
if save.inventory[itemId] == 0 then save.inventory[itemId] = nil end
end
-- The effect itself. ItemEffects owns the item_effects.asm arithmetic and
-- every refusal it prints (an EGG, a fainted or full-HP heal target, a healthy
-- revive target, a PP slot already full); this side adds the arm that only
-- exists with a battle up, spends the item where UseDisposableItem sits, and
-- pays the turn the pack costs.
function BattleState:applyPartyItem(itemId, action, mon, slot)
local data = (self.game and self.game.data) or {}
local before = (mon and mon.hp) or 0
local result
if action == "pp" then
result = ItemEffects.usePpItem(itemId, mon, slot)
else
result = ItemEffects.useOnMon(itemId, mon, data)
end
-- HealStatus's `.not_full_heal` and IsItemUsedOnConfusedMon: a $ff-mask item
-- used on whoever is OUT also clears SUBSTATUS_CONFUSED, and clears it even
-- when the status byte was already empty -- which is the one case where a
-- FULL HEAL that the field routine refuses is still spent in battle.
if mon and FULL_MASK_HEALERS[itemId] and mon == self.battle.player
and self.battle:volatile(mon).confuseCount then
self.battle:volatile(mon).confuseCount = nil
if not result.used then
-- PARTYMENUTEXT_HEAL_CONFUSION (_CameToItsSensesText).
result = { used = true,
text = self:name(mon) .. " came to its senses." }
end
end
if not result.used then
self.message = oneLine(result.text)
self.messageTimer = MESSAGE_FRAMES
self.phase = "resolving"
return
end
self:consumeItem(itemId)
self.queue = {}
if mon == self.battle.player and (mon.hp or 0) ~= before then
-- Every HP-restoring effect zeroes wLowHealthAlarm BEFORE it touches the
-- HP or runs HealHP_SFX_GFX (RestoreHPEffect, engine/items/item_effects.asm:
-- 1657-1658; .FullRestore :1580-1581; .skip_to_revive :1542-1543), so the
-- siren dies with the item rather than with the bar animation -- the bar
-- is still in the red for the whole climb. Not a latch: CheckDanger runs
-- again from UpdatePlayerHUD once AnimateHPBar has finished, so a heal
-- that leaves the mon in the red correctly starts the siren back up.
self:stopAlarm()
self.healSilence = true
-- The active mon's HP change carries its new value so the HUD bar refills
-- on screen (HealHP_SFX_GFX runs AnimateHPBar for exactly this case).
self:push({ kind = "heal", side = "player", hp = mon.hp,
text = oneLine(result.text) })
else
self:push({ kind = "message", text = oneLine(result.text) })
end
self:pushAll(self.battle:takeTurn({ kind = "item", item = itemId }))
self.phase = "resolving"
self:advanceQueue()
end
-- The HUD is not a box: engine/battle/core.asm draws an L-shaped frame out of
-- four tiles (DrawEnemyHUDBorder / DrawPlayerHUDBorder) -- a horizontal rule
-- under the whole thing with a short vertical stub at one end, opening left for
-- the enemy and right for the player. Name and level sit on plain background
-- above it, not inside a border.
function BattleState:drawFrame(tx, ty, width, stubRight)
local G = love.graphics
G.setColor(0, 0, 0, 1)
-- The bottom rule ($76 repeated, capped by $74/$78 or $6f/$77) sits at the
-- top of its own tile row, immediately under the bar above it.
G.rectangle("fill", tx * 8, ty * 8, width * 8, 2)
-- The vertical stub ($6d on the enemy's left, $73 on the player's right)
-- climbs from the rule past the bar row.
local stubX = stubRight and ((tx + width - 2) * 8 + 6) or (tx * 8)
G.rectangle("fill", stubX, ty * 8 - 8, 2, 10)
end
-- LoadBattleFontsHPBar puts FontBattleExtra in the $60 slot for the whole
-- battle, which is why the HUD's level reads as the bold ":L" glyph ($6e) and
-- not the two characters ':' and 'L'. The message box below is ordinary text,
-- so the swap is scoped to the HUD.
-- Which mon a side DRAWS. Battle finishes the whole turn before the first
-- message is displayed, so battle.enemy is already the replacement while the
-- outgoing mon's "fainted!" line is still on screen; the queue's own copy is
-- what keeps the pic and the name where the cart has them.
function BattleState:activeMon(side)
local shown = self.shownMon and self.shownMon[side]
if shown ~= nil then return shown end
return self.battle and self.battle[side] or nil
end
function BattleState:drawHud()
local wasBattle = Font.useBattleExtra(true)
local enemy, player = self:activeMon("enemy"), self:activeMon("player")
local showStatus = self:statusHUDVisible()
-- Enemy HUD (DrawEnemyHUD clears (1,0) 4 rows x 11 cols):
-- name at (1,0); PrintLevel at (6,1) with the gender symbol at (9,1);
-- the HP bar's "HP:" at (2,2); the border from (1,2).
-- ClearActorHud blanks this whole block while that side's move animation
-- runs, so a shake or a slide does not drag the HP bar with it.
-- And nothing at all before UpdateEnemyHUD has ever run: the intro bands
-- slide in over a blanked tilemap (core.asm:8554/8564).
if showStatus and self.showEnemyHud and not self:hudCleared("enemy") then
Chrome.print(self:name(enemy), 1, 0)
-- PrintLevel writes <LV> at the coordinate it is given and then LEFT-aligns
-- the digits after it, so the glyph is pinned to column 6 whether the level
-- is 5 or 100; only a three-digit level moves, and it does so by eating the
-- <LV> tile. The gender symbol sits past the two digit columns, at (9,1).
-- PlaceNonFaintStatus (engine/pokemon/mon_stats.asm): a statused mon's tag
-- prints where the level goes, and DrawEnemyHUD's `.skip_level` arm drops
-- the level entirely while one is up.
Chrome.print(self:statusTag(enemy) or ("<LV>" .. tostring(enemy.level or 1)),
6, 1)
local enemyGender = self:genderSymbol(enemy)
if enemyGender then Chrome.print(enemyGender, 9, 1) end
-- `ld a, [wBattleMode] / dec a / ret nz`, then CheckCaughtMon puts $5d at
-- (1,1) (engine/battle/trainer_huds.asm:140-152).
if self.battle and self.battle.wild and self:dexCaught(enemy) then
self.hud:drawCaughtIcon(1, 1, self:hudHp(enemy, "enemy"),
enemy.maxHp or (enemy.stats and enemy.stats.hp))
end
self:drawHpBar(enemy, "enemy", 2, 2)
-- Stub on the LEFT (tile $6d), rule on the row under the bar.
if self.hud:available() then
self.hud:drawEnemyFrame()
else
self:drawFrame(1, 3, 10, false)
end
end
self:drawPic(enemy, false)
-- Player HUD (DrawPlayerHUD clears (9,7) 5 rows x 11 cols):
-- name at (10,7); PrintLevel at (14,8) with the gender at (17,8);
-- the HP bar at (10,9), its numbers below; the vertical bar at (18,9), the
-- border from (18,10) going left, and the exp bar at (10,11).
self:drawPic(player, true)
-- No player HUD in the catching tutorial: DrawPlayerHUD lives in
-- SendOutPlayerMon, which BATTLETYPE_TUTORIAL jumps straight over, and
-- BattleMenu's own tutorial arm skips UpdateBattleHuds as well. The DUDE's
-- half of the screen is his back-pic and nothing more.
-- Nor before SendOutPlayerMon's own UpdatePlayerHUD (core.asm:3838).
if not showStatus or not player or not self.showPlayerHud
or self:hudCleared("player") then
Font.useBattleExtra(wasBattle)
return
end
Chrome.print(self:name(player), 10, 7)
-- PrintPlayerHUD places the same status tag at (14,8) and skips the level
-- while it is up.
Chrome.print(self:statusTag(player)
or ("<LV>" .. tostring(self.shownLevel or player.level or 1)), 14, 8)
local playerGender = self:genderSymbol(player)
if playerGender then Chrome.print(playerGender, 17, 8) end
self:drawHpBar(player, "player", 10, 9)
local maxHp = player.maxHp or (player.stats and player.stats.hp) or 0
Chrome.printRight(("%d/%d"):format(self:hudHp(player, "player"), maxHp),
18, 10)
-- Stub on the RIGHT (tile $73), border from (18,10) laid leftward, exp bar
-- at (10,11).
-- The chased fill, not the mon's: AnimateExpBar crawls it, and reading the
-- mon straight put the bar at its post-kill value before the "gained N EXP.
-- Points!" line was even on screen.
local expFraction = (self.shownExp or 0) / BattleHud.EXP_LENGTH_PX
if self.hud:available() then
self.hud:drawPlayerFrame()
-- Eight tiles at (10,11); FillInExpBar's own span.
self.hud:drawExpBar(expFraction, 10, 11)
else
self:drawFrame(9, 11, 10, true)
HpBar.drawExp(self.palettes, expFraction, 10 * 8, 11 * 8 + 4)
end
Font.useBattleExtra(wasBattle)
end
-- PlaceNonFaintStatus's five strings, checked in its own priority order
-- (PSN, BRN, FRZ, PAR, SLP). Toxic is the PSN bit worn harder, so it shares
-- the tag; confusion is a substatus on the cart and never reaches the HUD.
local STATUS_TAGS = {
poison = "PSN", toxic = "PSN", burn = "BRN", freeze = "FRZ",
paralyze = "PAR", sleep = "SLP",
}
function BattleState:statusTag(mon)
return mon and STATUS_TAGS[mon.status] or nil
end
-- ♂ / ♀ after the level, or nil for a genderless species (PrintPlayerHUD
-- writes a plain space in that case).
function BattleState:genderSymbol(mon)
local gender = mon and mon.gender
if gender == "male" then return "\xe2\x99\x82" end
if gender == "female" then return "\xe2\x99\x80" end
return nil
end
-- The growth record for a mon's species, for the exp bar's "how far to the next
-- level" fraction.
function BattleState:growthOf(mon)
local def = self.pokemon and mon and self.pokemon[mon.species]
if not def then return nil end
-- Mon.growthFor off the LIVE game.data, so the exp bar's fraction is drawn
-- against the very curve Mon.gainExperience just used; a mod-registered
-- curve must not leave the bar disagreeing with the level it reports. The
-- {pokemon=} fallback is for a screen built with no game (drivers, tests),
-- where there is no merged registry to find anyway.
local data = (self.game and self.game.data) or { pokemon = self.pokemon }
return Mon.growthFor(data, def.growthRate)
end
-- The four labels the battle menu draws. Inside the contest the third one
-- carries the park ball count, which .PrintParkBallsRemaining writes with
-- PRINTNUM_LEADINGZEROS over two digits.
function BattleState:menuLabels()
if not self.contest then return MENU end
return { MENU[1], MENU[2],
CONTEST_BALL_LABEL .. ("%02d"):format(BugContest.ballsLeft(self.save)),
MENU[4] }
end
-- The message on the cart's own two rows: 14 and 16, with 15 blank between
-- them (home/text.asm:143 and :397). A string that will not fit two 18-tile
-- lines is cut rather than spilling onto the rows Paragraph clears.
function BattleState:printMessage()
local lines = Chrome.wrap(self.message or "", TEXT_WIDTH)
for i = 1, math.min(#lines, TEXT_ROWS) do
Chrome.print(lines[i], TEXT_INNER_X,
TEXT_INNER_Y + (i - 1) * TEXT_ROW_STEP)
end
end
function BattleState:drawPanel()
Chrome.clear()
-- A tutorial battle legitimately has no player mon, so only the enemy is
-- required there; everywhere else a missing side is a caller bug.
local hasPlayer = self.battle and (self.battle.player or self.tutorial)
if not (self.battle and hasPlayer and self.battle.enemy) then
Chrome.print("NO BATTLE", 1, 1)
return
end
self:drawHud()
if not self:bottomUIVisible() then
love.graphics.setColor(1, 1, 1, 1)
return
end
-- Message box across the bottom, with the menu window over its right half --
-- the cart draws the prompt into the full-width box and then opens the menu
-- on top, so the tail of a long name is simply covered.
Chrome.box(0, 12, 20, 6)
if self.phase == "menu" then
self:printMessage()
local boxX = self.contest and CONTEST_MENU_BOX_X or MENU_BOX_X
local spacing = self.contest and CONTEST_MENU_COL_SPACING
or MENU_COL_SPACING
Chrome.box(boxX, 12, 20 - boxX, 6)
for i, label in ipairs(self:menuLabels()) do
local col = ((i - 1) % 2) * spacing
local row = math.floor((i - 1) / 2) * 2
local tx, ty = boxX + 2 + col, 14 + row
if i == self.menuIndex then Chrome.cursor(tx - 1, ty) end
Chrome.print(label, tx, ty)
end
elseif self.phase == "moves"
or (self.phase == "choose-forget" and (self.messageTimer or 0) <= 0) then
-- The forget picker shows the SAME four moves, cursored by forgetIndex --
-- pick one to drop for the pending move.
local forgetting = self.phase == "choose-forget"
local moves = self:playerMoves()
if forgetting then
local learn = self.pendingLearn
local mon = learn and self.battle.party[learn.index]
moves = (mon and mon.moves) or moves
end
local cursorRow = forgetting and self.forgetIndex or self.moveIndex
for i, move in ipairs(moves) do
local ty = 13 + (i - 1)
-- Cursor in the box's own gutter, not clipped against the border.
if i == cursorRow then Chrome.cursor(1, ty) end
-- The held slot's marker. `.battle_player_moves` writes '▷' into the
-- row wSwappingMove names (engine/battle/core.asm:5157-5165) so a move
-- picked up for a swap is visible while the cursor moves off it. It
-- sits a column right of the cursor gutter, where the cart puts it
-- (hlcoord 5, 13 against the cursor's own column), and only while the
-- move list itself is up -- the forget picker has no swapping.
if not forgetting and self.moveSwapIndex == i then
Chrome.print("\u{25B7}", 0, ty)
end
local def = self.game and self.game.data and self.game.data.moves
and self.game.data.moves[move.id]
Chrome.print((def and def.name) or move.id, 2, ty)
Chrome.printRight(("%d/%d"):format(move.pp or 0, move.maxPp or 0), 19, ty)
end
else
-- Battle messages wrap inside the box rather than running off the frame.
self:printMessage()
-- YesNoBox: `lb bc, SCREEN_WIDTH - 6, 7`, a 6x5 box at (14,7) with YES at
-- (16,8) and NO at (16,10), drawn over the battle while the question
-- stands.
local asking = self.phase == "ask-nickname" or self.phase == "ask-forget"
or self.phase == "stop-learning" or self.phase == "ask-shift"
or self.phase == "ask-next-mon"
if asking and (self.messageTimer or 0) <= 0 then
-- OfferSwitch calls PlaceYesNoBox with `lb bc, 1, 7`, so its box is at
-- (1,7) instead (engine/battle/core.asm:3303, home/menu.asm:392-410).
local left = (self.phase == "ask-shift" or self.phase == "ask-next-mon")
and 1 or 14
Chrome.box(left, 7, 6, 5)
Chrome.print("YES", left + 2, 8)
Chrome.print("NO", left + 2, 10)
local index = self.phase == "ask-nickname" and self.nicknameIndex
or self.phase == "ask-shift" and self.shiftIndex
or self.phase == "ask-next-mon" and self.nextMonIndex
or self.forgetChoice
Chrome.cursor(left + 1, index == 1 and 8 or 10)
end
end
if self.phase == "stats-box" and self.statsBoxMon then
self:drawStatsBox(self.statsBoxMon)
end
love.graphics.setColor(1, 1, 1, 1)
end
-- pokegold engine/pokemon/mon_stats.asm:118-124 (PrintTempMonStats.StatNames).
local STATS_BOX_ROWS = {
{ "ATTACK", "attack" }, { "DEFENSE", "defense" },
{ "SPCL.ATK", "specialAttack" }, { "SPCL.DEF", "specialDefense" },
{ "SPEED", "speed" },
}
-- pokegold engine/battle/core.asm:7060-7066 (box at hlcoord 9,0, stats at 11,y).
function BattleState:drawStatsBox(mon)
if not mon then return end
local stats = mon.stats
local data = self.game and self.game.data
local def = data and data.pokemon and data.pokemon[mon.species]
if def and def.baseStats then
stats = Mon.stats(def.baseStats, mon.dvs, mon.level, mon.statExp)
end
if not stats then return end
Chrome.textbox(9, 0, 9, 10)
for i, row in ipairs(STATS_BOX_ROWS) do
local ty = 1 + (i - 1) * 2
Chrome.print(Strings(row[1]), 11, ty)
Chrome.printRight(("%d"):format(stats[row[2]] or 0), 19, ty + 1)
end
end
-- The BG layer, plus whatever the animation is doing to it, plus the OBJ
-- layer on top. OBJs are not affected by SCX/SCY, which is why they are drawn
-- after the scanline blit rather than into the canvas with everything else.
function BattleState:drawScene()
self:drawSceneBody()
-- battle.overlay: shiny sparkles, custom HUD chrome, and so on. Draw-only,
-- and the same name, the same payload (the battle screen) and the same place
-- in the frame as the Gen 1 site (src/battle/BattleState.lua's draw tail):
-- after everything the scene composites, in the 160x144 space, so a mod
-- draws in screen coordinates whichever game it is under. The vanilla link
-- is a no-op, so an empty chain costs one wantsHook.
if Runtime.wantsHook("battle.overlay") then
Runtime.call("battle.overlay", function() end, self)
end
end
function BattleState:drawSceneBody()
local panel = function() self:drawPanel() end
if self.animView and self.slideFrame < BattleAnimView.SLIDE_FRAMES then
-- The back pic is lifted out of the sliding bands and drawn the way the
-- cart's OAM copy is: one intact piece riding in from the right, so it
-- cannot tear at the $40 scanline where the bands part ways.
self.slidingBackpic = true
self.animView:presentSlide(self.slideFrame, panel, function(offset)
self.slidingBackpic = nil
local G = love.graphics
G.push()
G.translate(offset, 0)
self:drawPic(self.battle and self.battle.player, true)
G.pop()
self.slidingBackpic = true
end)
self.slidingBackpic = nil
return
end
if self.anim and self.animView then
self.animView:present(self.anim, panel)
self.animView:drawObjects(self.anim, self.battle)
return
end
panel()
end
function BattleState:draw()
self:drawScene()
end
function BattleState:drawWidescreen(winW, winH)
local G = love.graphics
G.setColor(1, 1, 1, 1)
G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH)
local ox, oy = Chrome.fitOrigin(winW, winH, scale)
G.push()
G.translate(ox, oy)
G.scale(scale, scale)
self:drawScene()
G.pop()
end
BattleState.MENU = MENU
BattleState.STATUS_TAGS = STATUS_TAGS
BattleState.Battle = Battle
return BattleState