mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-15 15:51:17 +02:00
3434 lines
147 KiB
Lua
3434 lines
147 KiB
Lua
-- 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
|