Files
gen1recomp/src/battle/BattleState.lua
T
bryanthaboi bfba1f7bb7 Bugs and stuff (#144)
* main menu scrollable when over 8 items

* buggies

* more buggies

* Lorelei, Bruno, and Agatha now push their AfterBattle text right after a win
2026-07-24 09:06:29 -04:00

4185 lines
163 KiB
Lua

-- The battle state: wild and trainer battles driven entirely by generated
-- data (species, moves, type chart, trainer parties, encounter tables).
--
-- Flow: intro -> menu (FIGHT/PKMN/ITEM/RUN) -> move select -> turn
-- resolution (a queue of messages/actions/UI pushes) -> back to menu,
-- until one side is out, then finish. Pops itself and calls
-- onFinish("win"|"lose"|"run"|"caught"|"skipped").
--
-- The Gen 1 move-effect pipeline (multi-hit, charge, trapping, thrash,
-- bide, recharge, confusion, screens, substitute, transform, ...) is
-- ported from engine/battle/core.asm; see docs/behavior-porting-notes.md.
local Assets = require("src.render.Assets")
local Catching = require("src.battle.Catching")
local Damage = require("src.battle.Damage")
local EffectRegistry = require("src.battle.EffectRegistry")
local Experience = require("src.battle.Experience")
local Font = require("src.render.Font")
local Logger = require("src.core.Logger")
local MoveEffects = require("src.battle.MoveEffects")
local Party = require("src.pokemon.Party")
local Pokemon = require("src.pokemon.Pokemon")
local Runtime = require("src.mods.Runtime")
local Screens = require("src.ui.Screens")
local Status = require("src.battle.Status")
local TrainerAI = require("src.battle.TrainerAI")
local TurnOrder = require("src.battle.TurnOrder")
local TypeChart = require("src.battle.TypeChart")
local BattleState = {}
BattleState.__index = BattleState
BattleState.isOpaque = true
-- Battle colors itself per-pixel (species pics + HP bar tints), so the
-- SGB whole-screen remap must not run over it.
function BattleState.sgbPalettes() return nil end
local Rulesets = {
gen1_faithful = require("src.battle.rulesets.gen1_faithful"),
modern_clean = require("src.battle.rulesets.modern_clean"),
}
-- the Poké Ball toss chain (TossBallAnimation) plays even with battle
-- animations off: PlayMoveAnimation jumps to it before checking wOptions
local BALL_ANIMS = {
TOSS_ANIM = true, GREATTOSS_ANIM = true, ULTRATOSS_ANIM = true,
BLOCKBALL_ANIM = true, POOF_ANIM = true, HIDEPIC_ANIM = true,
SHAKE_ANIM = true, SHOWPIC_ANIM = true,
}
local imageCache = {}
-- fully transparent rows below a pic's content (the extracted 32x32 back
-- pics carry baked-in padding); used to sit the pic flush on the text box
local imagePadBottom = {}
-- fully transparent columns left of a pic's content; at 2x (back pics)
-- this is subtracted from hlcoord 1,5 so opaque pixels match hardware,
-- where those columns were white-on-white rather than shifted content
local imagePadLeft = {}
-- image -> { path, pal } so palette-fade variants (see fadeImage) can be
-- rebuilt for any battle pic, whatever code loaded it
local imageMeta = {}
-- pal = { name, colors } recolors the 4 GB shades like the Super Game Boy.
-- trueColor art (14 §the 4-shade contract) opts out of the quantize
-- entirely, so its palette variant collapses back onto the plain path.
local function getImage(path, pal, trueColor)
if not path then return nil end
if trueColor then pal = nil end
local key = pal and (path .. "#" .. pal.name) or path
if not imageCache[key] then
local img, pad, padL = nil, 0, 0
if love.image and love.image.newImageData then
local id = Assets.imageData(path)
if pal then
local c = pal.colors
id:mapPixel(function(_, _, r, g, b, a)
if a == 0 then return r, g, b, a end
local col = r > 0.83 and c[1] or r > 0.5 and c[2]
or r > 0.17 and c[3] or c[4]
return col[1] / 255, col[2] / 255, col[3] / 255, a
end)
end
local w, h = id:getDimensions()
local bottom = h - 1
while bottom >= 0 do
local opaque = false
for x = 0, w - 1 do
local _, _, _, a = id:getPixel(x, bottom)
if a > 0 then opaque = true break end
end
if opaque then break end
bottom = bottom - 1
end
local left = 0
while left < w do
local opaque = false
for y = 0, h - 1 do
local _, _, _, a = id:getPixel(left, y)
if a > 0 then opaque = true break end
end
if opaque then break end
left = left + 1
end
img = love.graphics.newImage(id)
pad = h - 1 - bottom
padL = left
else
img = Assets.image(path) -- headless stub: no pixel access
end
imageCache[key] = img
imagePadBottom[img] = pad
imagePadLeft[img] = padL
imageMeta[img] = { path = path, pal = pal, trueColor = trueColor or nil }
end
return imageCache[key]
end
-- hot reload: the next getImage re-resolves every pic through the asset
-- search path and re-measures its ground padding
function BattleState.invalidate()
imageCache, imagePadBottom, imagePadLeft, imageMeta = {}, {}, {}, {}
end
Assets.register(BattleState.invalidate)
-- the species' SGB palette (active COLORS pack), or nil
local function monPalette(data, species)
local PaletteFX = require("src.render.PaletteFX")
local colors = PaletteFX.monPal(data, species)
if not colors then return nil end
local name = PaletteFX.monPalName(data, species)
-- prefix so GBC vs RED++ cache keys don't collide on shared names
if PaletteFX.usesGbcPack() then name = "redpp:" .. name end
return { name = name, colors = colors }
end
-- a named palette from the active COLORS pack as a getImage pal
local function namedPalette(data, name)
local PaletteFX = require("src.render.PaletteFX")
local colors = PaletteFX.pal(data, name)
if not colors then return nil end
local key = name
if PaletteFX.usesGbcPack() then key = "redpp:" .. name end
return { name = key, colors = colors }
end
-- The battle-BGP fade variant of a pic (AnimationFlashScreen and the
-- SetAnimationBGPalette effects remap the four BG shades; on the SGB
-- the colorizer then colors the REMAPPED shade, so a faded pic shows
-- palette[bgp[shade]]). bgp = shade map {[0..3] -> 0..3} or nil.
local function fadeImage(img, bgp)
if not bgp or not img then return img end
local meta = imageMeta[img]
if not meta then return img end
-- a full-color pic has no DMG shades to remap
if meta.trueColor then return img end
local PaletteFX = require("src.render.PaletteFX")
local base = meta.pal and meta.pal.colors or PaletteFX.GRAYS
local name = (meta.pal and meta.pal.name or "GB")
.. "&" .. bgp[0] .. bgp[1] .. bgp[2] .. bgp[3]
return getImage(meta.path,
{ name = name, colors = PaletteFX.permute(base, bgp) })
end
-- the raw DMG-gray build of a colored pic (SE_WAVY_SCREEN bakes the
-- pics into the BG canvas so they wave with it; the zone pass then
-- colors them by region like the real SGB)
local function grayImage(img)
local meta = imageMeta[img]
if not meta or not meta.pal then return img end
return getImage(meta.path) or img
end
-- the image a battler pic actually draws with this frame
function BattleState:picImage(img)
if self.grayPics then return grayImage(img) end
return fadeImage(img, self:activeBgp())
end
-- Gen 1 trainer Pokémon have fixed DVs (engine/battle/core.asm);
-- constants.trainerDvs overrides, this is the imported-cache fallback
local TRAINER_DVS = { attack = 9, defense = 8, speed = 8, special = 8, hp = 8 }
-- charge-turn texts by move id; the move record's chargeText field wins
-- (ChargeEffect's per-move text pointers)
local CHARGE_TEXT = {
FLY = "%s\nflew up high!",
DIG = "%s\ndug a hole!",
RAZOR_WIND = "%s\nmade a whirlwind!",
SOLARBEAM = "%s\ntook in sunlight!",
SKULL_BASH = "%s\nlowered its head!",
SKY_ATTACK = "%s\nis glowing!",
}
-- pokered's <USER>/<TARGET> text macros (home/text.asm
-- PlaceMoveUsersName): battle texts naming the enemy mon print
-- "Enemy " before the nickname; player-side mons never get it.
local function displayName(b)
return b.isPlayer and b.name or ("Enemy " .. b.name)
end
-- Apply the "Enemy " prefix to a pre-built message from a module that
-- only knows the raw nickname (Status.beforeMove/residual,
-- TrainerAI.useItem): splice it in before the first name occurrence.
local function prefixEnemy(msg, battler)
if battler.isPlayer then return msg end
local s = msg:find(battler.name, 1, true)
if not s then return msg end
return msg:sub(1, s - 1) .. "Enemy " .. msg:sub(s)
end
-- Level-up stats window (PrintStatsBox .LevelUpStatsBox: box (9,2)
-- 11x10 over the battle, dismissed with A/B)
local StatBox = {}
StatBox.__index = StatBox
function StatBox.new(game, mon, onDone)
return setmetatable({ game = game, mon = mon, onDone = onDone }, StatBox)
end
function StatBox:update()
local input = self.game.input
if input:wasPressed("a") or input:wasPressed("b") then
self.game.stack:pop()
if self.onDone then self.onDone() end
end
end
function StatBox:draw()
Font.drawBox(9, 2, 11, 10)
love.graphics.setColor(0, 0, 0, 1)
local s = self.mon.stats
local rows = { { "ATTACK", s.attack }, { "DEFENSE", s.defense },
{ "SPEED", s.speed }, { "SPECIAL", s.special } }
for i, r in ipairs(rows) do
Font.draw(r[1], 88, 24 + (i - 1) * 16)
Font.draw(("%3d"):format(r[2]), 128, 32 + (i - 1) * 16)
end
love.graphics.setColor(1, 1, 1, 1)
end
-- ---------------------------------------------------------------------
-- construction
-- ---------------------------------------------------------------------
local function makeBattler(data, mon, isPlayer, save)
local def = data.pokemon[mon.species]
local badgeBoosts = data.constants and data.constants.badgeBoosts
local badges = nil
if isPlayer and save then
-- Gen 1 badge stat boosts (x9/8); the badge set follows the merged
-- badgeBoosts rows so a retuned list changes what gets baked in
badges = {}
for _, row in ipairs(badgeBoosts or Damage.BADGE_BOOSTS) do
if save.inventory[row.badge] then badges[row.badge] = true end
end
end
return {
mon = mon,
def = def,
name = mon.nickname or def.name,
isPlayer = isPlayer,
badges = badges,
-- merged registry views consumed by the pure battle modules
badgeBoosts = badgeBoosts,
statuses = data.statuses,
shownHP = mon.hp, -- the HP the bar displays (UpdateHPBar drain)
stages = {},
-- volatile state; Transform/Conversion/Mimic override the cur* fields
curStats = mon.stats,
curTypes = def.types,
curMoves = mon.moves,
sprite = getImage(isPlayer and def.spriteBack or def.spriteFront,
monPalette(data, mon.species), def.trueColor),
}
end
-- LinkBattle builds clamped copies with save=nil (no badge boosts); wild
-- and trainer constructors pass the live save for the player side.
BattleState.makeBattler = makeBattler
-- The battle pic for `species` on the given side (back pic for the
-- player side, front pic for the enemy side), tinted PAL_GRAYMON --
-- the same path makeBattler uses, but forced gray -- since this is only
-- ever used for a Transformed mon's swapped-in pic (transform.asm:31-53
-- AnimationTransformMon; the SGB color comes from DeterminePaletteID,
-- which forces PAL_GRAYMON for a Transformed mon rather than the copied
-- species' own palette).
function BattleState:speciesSprite(species, isPlayerSide)
local def = self.data.pokemon[species]
if not def then return nil end
local PaletteFX = require("src.render.PaletteFX")
local colors = PaletteFX.monPal(self.data, species, true)
local name = "GRAYMON"
if PaletteFX.usesGbcPack() then name = "redpp:GRAYMON" end
return getImage(isPlayerSide and def.spriteBack or def.spriteFront,
colors and { name = name, colors = colors } or nil,
def.trueColor)
end
local function markSeen(game, species)
local dex = game.save.pokedex
if dex then dex.seen[species] = true end
end
-- newly obtained mons carry the player's OT name/ID (status screen)
local function stampOT(save, mon)
save.player.id = save.player.id or math.random(0, 65535)
mon.ot = mon.ot or save.player.name
mon.otId = mon.otId or save.player.id
end
BattleState.stampOT = stampOT
local function markOwned(game, species)
local dex = game.save.pokedex
if dex then
dex.seen[species] = true
if not dex.owned[species] then
-- new dex page registered (SFX_DEX_PAGE_ADDED)
require("src.core.Sound").play(game.data, "Dex_Page_Added")
end
dex.owned[species] = true
end
end
BattleState.markOwned = markOwned
BattleState.StatBox = StatBox -- the level-up stat window (PrintStatsBox)
local function newBattle(game)
local self = setmetatable({}, BattleState)
self.game = game
self.data = game.data
-- ruleset from the merged registry (the requires above are the same
-- records on a mod-free boot); an unknown save value falls back to the
-- default with a notice instead of silently switching behavior
local rulesets = game.data.rulesets or Rulesets
local selected = game.save.options and game.save.options.ruleset
local fallback = (game.data.constants and game.data.constants.defaultRuleset)
or "gen1_faithful"
local ruleset = selected and rulesets[selected]
if selected and not ruleset then
Logger.warn("unknown ruleset %s; using %s", tostring(selected), fallback)
end
self.ruleset = ruleset or rulesets[fallback] or Rulesets.gen1_faithful
self.rng = function(a, b) return love.math.random(a, b) end
-- side/field substrate: vanilla writes nothing here, but every battle
-- carries the stable shape mods hang screens/hazards/tokens on
self.sides = {
{ index = 1, battlers = {}, screens = {}, hazards = {}, tokens = {} },
{ index = 2, battlers = {}, screens = {}, hazards = {}, tokens = {} },
}
self.field = { weather = nil, tokens = {}, sides = self.sides }
TypeChart.load(game.data)
-- the subanimation player (data/battle_anims via battle_anims.lua)
if game.data.battle_anims then
local ok, AnimPlayer = pcall(require, "src.battle.AnimPlayer")
if ok then
self.animPlayer = AnimPlayer.new(game.data.battle_anims)
end
end
self.queue = {}
self.phase = "intro"
self.menuIndex = 1
self.moveIndex = 1
self.frame = 0
return self
end
-- opts.hooked: rod encounter, announced with _HookedMonAttackedText
function BattleState.newWild(game, species, level, opts)
local self = newBattle(game)
self.kind = "wild"
local playerMon = Party.firstHealthy(game.save.party)
if not playerMon then
Logger.warn("wild battle with no healthy party; skipping")
self.dead = true
else
self.player = makeBattler(game.data, playerMon, true, game.save)
end
self.enemy = makeBattler(game.data, Pokemon.new(game.data, species, level), false)
markSeen(game, species)
if opts and opts.hooked then
self.introText = ("The hooked\n%s\nattacked!"):format(self.enemy.name)
else
self.introText = ("Wild %s\nappeared!"):format(self.enemy.name)
end
return self
end
-- data/trainers/special_moves.asm + read_trainer_party.asm: boss move
-- overrides, always written into the mon's THIRD move slot.
-- LoneMoves: the gym scripts write the gym number to wGymLeaderNo, so
-- these fire only for the leaders' gym battles (Giovanni: party 3);
-- the table's "index n" lands on the (n+1)-th party mon via AddNTimes.
-- TeamMoves: despite the "whole team" comment, the code writes only
-- wEnemyMon5Moves+2, the FIFTH mon of each Elite Four member.
-- RIVAL3 (Champion): Pidgeot gets SKY ATTACK, the starter's final
-- form gets MEGA DRAIN / FIRE BLAST / BLIZZARD.
local LONE_MOVES = {
OPP_BROCK = { 2, "BIDE" },
OPP_MISTY = { 2, "BUBBLEBEAM" },
OPP_LT_SURGE = { 3, "THUNDERBOLT" },
OPP_ERIKA = { 3, "MEGA_DRAIN" },
OPP_KOGA = { 4, "TOXIC" },
OPP_SABRINA = { 4, "PSYWAVE" },
OPP_BLAINE = { 4, "FIRE_BLAST" },
OPP_GIOVANNI = { 5, "FISSURE", onlyParty = 3 },
}
local TEAM_MOVES = {
OPP_LORELEI = "BLIZZARD", OPP_BRUNO = "FISSURE",
OPP_AGATHA = "TOXIC", OPP_LANCE = "BARRIER",
}
local RIVAL_STARTER_MOVES = {
VENUSAUR = "MEGA_DRAIN", CHARIZARD = "FIRE_BLAST", BLASTOISE = "BLIZZARD",
}
local function setThirdMove(data, mon, moveId)
if not mon then return end
local mdef = data.moves[moveId]
local entry = { id = moveId, pp = mdef and mdef.pp or 0 }
mon.moves[math.min(3, #mon.moves + 1)] = entry
end
local function applySpecialMoves(data, oppClass, partyIndex, party)
local lone = LONE_MOVES[oppClass]
if lone and (not lone.onlyParty or lone.onlyParty == partyIndex) then
setThirdMove(data, party[lone[1]], lone[2])
return
end
local team = TEAM_MOVES[oppClass]
if team then
setThirdMove(data, party[5], team)
return
end
if oppClass == "OPP_RIVAL3" then
setThirdMove(data, party[1], "SKY_ATTACK")
local starter = party[6]
if starter and RIVAL_STARTER_MOVES[starter.species] then
setThirdMove(data, starter, RIVAL_STARTER_MOVES[starter.species])
end
end
end
function BattleState.newTrainer(game, oppClass, partyIndex)
local self = newBattle(game)
self.kind = "trainer"
self.oppClass = oppClass
self.trainer = game.data.trainers[oppClass]
assert(self.trainer, "unknown trainer class " .. tostring(oppClass))
-- pret GetTrainerName_: RIVAL1/2/3 copy wRivalName into wTrainerName
-- instead of TrainerNames ("RIVAL1" etc.). Overlay so we don't mutate
-- the shared data table.
if oppClass == "OPP_RIVAL1" or oppClass == "OPP_RIVAL2"
or oppClass == "OPP_RIVAL3" then
local rivalName = (game.save.player and game.save.player.rival) or "BLUE"
self.trainer = setmetatable({ name = rivalName }, { __index = self.trainer })
end
self.enemyAIMods = self.trainer.aiMods
local partyDef = self.trainer.parties[partyIndex or 1]
assert(partyDef, ("trainer %s has no party %s"):format(oppClass, tostring(partyIndex)))
if Runtime.wantsHook("trainer.party") then
partyDef = Runtime.call("trainer.party", function(_, _, party)
return party
end, oppClass, partyIndex or 1, partyDef) or partyDef
end
local trainerDvs = (game.data.constants and game.data.constants.trainerDvs)
or TRAINER_DVS
self.enemyParty = {}
for _, slot in ipairs(partyDef) do
local mon = Pokemon.new(game.data, slot.species, slot.level)
-- fixed trainer DVs, recomputed stats
mon.dvs = trainerDvs
mon.stats = require("src.pokemon.Stats").calc(game.data.pokemon[slot.species],
slot.level, trainerDvs)
mon.hp = mon.stats.hp
table.insert(self.enemyParty, mon)
end
applySpecialMoves(game.data, oppClass, partyIndex or 1, self.enemyParty)
-- a party slot's own moves list wins over the legacy boss-move tables
for i, slot in ipairs(partyDef) do
local mon = self.enemyParty[i]
if mon and slot.moves then
mon.moves = {}
for _, moveId in ipairs(slot.moves) do
local mdef = game.data.moves[moveId]
table.insert(mon.moves, { id = moveId, pp = mdef and mdef.pp or 0 })
end
end
end
self.enemyIndex = 1
local playerMon = Party.firstHealthy(game.save.party)
if not playerMon then
Logger.warn("trainer battle with no healthy party; skipping")
self.dead = true
else
self.player = makeBattler(game.data, playerMon, true, game.save)
end
self.enemy = makeBattler(game.data, self.enemyParty[1], false)
self.aiUses = self:aiUsesFor() -- wAICount, reset per enemy mon
markSeen(game, self.enemyParty[1].species)
-- SGB: the enemy-side battle palette while the trainer pic is up is
-- MonsterPalettes[0] = PAL_MEWMON -- InitBattleCommon zeroes
-- wEnemyMonSpecies2 before the intro's SET_PAL_BATTLE
-- (engine/battle/core.asm:6682, engine/gfx/palettes.asm SetPal_Battle)
self.trainerPic = getImage(self.trainer.pic, namedPalette(game.data, "MEWMON"))
self.introText = ("%s wants\nto fight!"):format(self.trainer.name)
return self
end
-- Pokémon Tower ghosts (engine/battle/core.asm): without the Silph Scope
-- the enemy is "GHOST", you're too scared to attack, and balls fail.
function BattleState:makeGhost()
self.ghost = true
self.enemy.name = "GHOST"
-- the ghost keeps the disguised mon's SGB palette: InitWildBattle
-- swaps only the pic, wEnemyMonSpecies2 still holds the real species
-- (engine/battle/core.asm InitWildBattle .isGhost)
self.enemy.sprite = getImage("assets/generated/battle/front/ghost.png",
monPalette(self.data, self.enemy.mon.species))
self.introText = "The GHOST\nappeared!"
end
-- The old man's catch tutorial (BATTLE_TYPE_OLD_MAN,
-- engine/battle/core.asm DisplayBattleMenu .oldManName branch): no
-- player mon; the battle menu appears under the OLD MAN's name and a
-- scripted cursor hovers FIGHT, hops to ITEM and forces the item menu
-- (one POKé BALL x50). The throw always catches; nothing is kept.
function BattleState:makeOldManDemo()
self.demo = true
end
-- Safari Zone battles (engine/battle/core.asm safari sections +
-- engine/battle/safari_zone.asm): no player mon acts; the menu is
-- BALL / BAIT / ROCK / RUN. state is save.safari ({balls, steps}).
function BattleState:makeSafari(state)
self.safari = state
self.safariCatchRate = self.enemy.def.catchRate
self.baitFactor = 0
self.escapeFactor = 0
end
-- ---------------------------------------------------------------------
-- message/action queue
-- ---------------------------------------------------------------------
function BattleState:say(text)
table.insert(self.queue, { text = text })
end
function BattleState:act(fn)
table.insert(self.queue, { fn = fn })
end
-- push a UI state above the battle; the queue pauses until it pops
function BattleState:ui(factory)
table.insert(self.queue, { ui = factory })
end
-- insert an animation row right after the current queue item (the
-- POOF/ball-toss animations past the move table); `shakes` marks the
-- ball-shake row with its wNumShakes repeat count, `ball` marks a toss
-- row with the thrown ball item (wCurItem -- a Master/Ultra toss
-- flickers the OBJ palette, DoBallTossSpecialEffects)
function BattleState:animNext(name, isPlayer, shakes, ball)
self.nextInsert = (self.nextInsert or 0) + 1
table.insert(self.queue, self.nextInsert,
{ anim = name, attackerIsPlayer = isPlayer, shakes = shakes,
ball = ball })
end
-- insert an act right after the current queue item
function BattleState:actNext(fn)
self.nextInsert = (self.nextInsert or 0) + 1
table.insert(self.queue, self.nextInsert, { fn = fn })
end
-- insert message right after the currently-executing queue item (the
-- counter is reset by updateQueue before each fn item runs)
function BattleState:sayNext(text)
self.nextInsert = (self.nextInsert or 0) + 1
table.insert(self.queue, self.nextInsert, { text = text })
end
-- insert a UI push right after the current queue item (dex page, the
-- level-up stat box -- anything that must keep queue order)
function BattleState:uiNext(factory)
self.nextInsert = (self.nextInsert or 0) + 1
table.insert(self.queue, self.nextInsert, { ui = factory })
end
-- ui rows compose screens unpushed (updateQueue pushes them), so
-- Screens.push's mod-screen degrade can't cover them; mirror it here,
-- stamping the id the same way
function BattleState:buildScreen(id, ...)
local game = self.game
local factory = Screens.get(game, id)
local inst
if factory.__modOwned then
-- a broken mod screen degrades to the builtin, never a dead end
local ok, result = pcall(factory.new, game, ...)
if ok and result then
inst = result
else
Logger.error("mod screen '%s' failed: %s -- using builtin",
id, tostring(result))
Screens.invalidate()
inst = require("src.ui." .. id).new(game, ...)
end
else
inst = factory.new(game, ...)
end
inst.screenId = inst.screenId or id
return inst
end
-- insert a wait for the HP bars to finish draining (UpdateHPBar):
-- the queue holds until every battler's displayed HP catches up
function BattleState:drainNext()
self.nextInsert = (self.nextInsert or 0) + 1
table.insert(self.queue, self.nextInsert, { drain = true })
end
-- One frame of the HP-bar drain (engine/gfx/hp_bar.asm UpdateHPBar):
-- the bar animates a pixel per two frames, so displayed HP moves at
-- maxHP/96 per frame (48-pixel bar). Returns true while animating.
function BattleState:stepHPDrain()
local busy = false
for _, b in ipairs({ self.player, self.enemy }) do
if b and b.shownHP and b.shownHP ~= b.mon.hp then
local step = math.max(1, b.mon.stats.hp) / 96
if b.shownHP > b.mon.hp then
b.shownHP = math.max(b.mon.hp, b.shownHP - step)
else
b.shownHP = math.min(b.mon.hp, b.shownHP + step)
end
busy = busy or b.shownHP ~= b.mon.hp
end
end
return busy
end
-- the integer HP the HUD shows for a battler (whole HP ticks, like
-- UpdateHPBar's 1-HP steps)
local function shownHP(b)
local shown = b.shownHP or b.mon.hp
if shown > b.mon.hp then return math.ceil(shown) end
return math.floor(shown)
end
function BattleState:startMessage(item)
self.current = item
self.lines = {}
self.total = 0
for chunk in (item.text .. "\n"):gmatch("(.-)\n") do
local codes = Font.encode(chunk)
table.insert(self.lines, codes)
self.total = self.total + #codes
end
self.charIndex = 0
end
function BattleState:updateQueue()
if self.waitingUI then
if self.game.stack:top() ~= self then return true end
self.waitingUI = nil
end
-- a queued hold (faint slide, hit blink) counts down before the next row
if self.waitFrames and self.waitFrames > 0 then
self.waitFrames = self.waitFrames - 1
return true
end
-- an HP-bar drain holds the queue until the bar catches up
if self.draining then
if self:stepHPDrain() then return true end
self.draining = nil
end
-- a move animation holds the queue until it finishes; its screen
-- effects (SE_*) and per-row sounds route into the fx layer as they
-- fire (applyAnimEffect implements each AnimationXXX routine)
if self.animPlaying then
self.animPlayer:update()
if self.animPlayer.pollEffects and self.applyAnimEffect then
for _, ev in ipairs(self.animPlayer:pollEffects()) do
self:applyAnimEffect(ev)
end
end
if self.animPlayer:isDone() then
self.animPlaying = false
-- the target's hit blink + damage sound follow the animation
-- (pokered plays them after PlayMoveAnimation returns)
if self.pendingHit then
self:applyHitFx(self.pendingHit)
self.pendingHit = nil
end
end
return true
end
if not self.current then
local item = table.remove(self.queue, 1)
if not item then return false end
if item.fn then
self.nextInsert = 0 -- sayNext inserts right after this item
item.fn()
self.current = nil
return true
end
if item.ui then
self.waitingUI = true
self.game.stack:push(item.ui())
return true
end
if item.drain then
self.draining = true
return true
end
if item.wait then
self.waitFrames = item.wait
return true
end
if item.mimicSelect then
-- pause the queue on Mimic's copy menu (MoveSelectionMenu with
-- wMoveMenuType = 1 lists the enemy's moves; cursor starts on 1)
local ctx = item.mimicSelect
local rows = {}
for i, m in ipairs(ctx.target.curMoves) do
if m.id and m.pp ~= nil then rows[#rows + 1] = { slot = i, id = m.id } end
end
self.mimicMoves = rows
self.mimicIndex = 1
self.mimicCtx = ctx
self.phase = "mimicSelect"
return true
end
-- animation queue rows: play the move sound and start the
-- subanimation (or just the coarse fx when animations are off).
-- item.hit carries the target's blink + damage sound, applied when
-- the animation ends (hitRow rows carry a hit with no animation --
-- thrash/rage continuation turns that skip the announcement).
if item.anim or item.hitRow then
local mdef = item.anim and self.data.moves[item.anim]
local anim = mdef and mdef.anim
if item.anim == "POOF_ANIM" then
-- the send-out poof plays SFX_BALL_POOF
require("src.core.Sound").play(self.data, "Ball_Poof")
elseif item.anim == "HIDEPIC_ANIM" then
self.enemyHidden = true -- SE_HIDE_ENEMY_MON_PIC
elseif item.anim == "SHOWPIC_ANIM" then
self.enemyHidden = false -- SE_SHOW_ENEMY_MON_PIC
end
-- ball/send-out anims ignore the OPTIONS toggle: PlayMoveAnimation
-- short-circuits to TossBallAnimation before its wOptions check
-- (engine/battle/animations.asm:415)
if item.anim and (self:animationsOn() or BALL_ANIMS[item.anim]) then
if self.animPlayer then
local ok = pcall(self.animPlayer.start, self.animPlayer,
item.anim, item.attackerIsPlayer,
(item.shakes or item.ball)
and { shakes = item.shakes, ball = item.ball,
ballFlicker = item.ball
and self:ballFlicker(item.ball) or nil }
or nil)
self.animPlaying = ok
end
self.fx = self.fx or {}
if anim and anim.shake and not self.animPlaying then self.fx.shake = 24 end
if anim and anim.flash and not self.animPlaying then self.fx.flash = 16 end
end
if self.animPlaying then
-- the animation rows carry their own sounds (PlayAnimation
-- plays each row's MoveSoundTable entry with its pitch/tempo
-- modifiers); which side the pic effects target follows the
-- attacker (hWhoseTurn)
self.animName = item.anim
self.animAttackerIsPlayer = item.attackerIsPlayer
self:resetPicFx()
for _, ev in ipairs(self.animPlayer:pollEffects()) do
self:applyAnimEffect(ev) -- frame-0 rows (first sound/effect)
end
self.pendingHit = item.hit
else
-- no subanimation player: keep the single-sound fallback (with
-- the move's pitch/tempo modifiers; GROWL/ROAR play the
-- attacker's cry -- GetMoveSound/IsCryMove)
if item.anim == "GROWL" or item.anim == "ROAR" then
local attacker = item.attackerIsPlayer and self.player or self.enemy
if attacker then
require("src.core.Sound").playMoveCry(self.data, attacker.mon.species,
anim and anim.tempo)
end
elseif anim and anim.sound then
local Sound = require("src.core.Sound")
if Sound.playMove then
Sound.playMove(self.data, anim)
else
Sound.play(self.data, anim.sound)
end
end
if item.hit then
self:applyHitFx(item.hit)
end
end
self.current = nil
return true
end
self:startMessage(item)
end
if self.charIndex < self.total then
self.charIndex = math.min(self.total, self.charIndex + 2)
else
local input = self.game.input
if input:wasPressed("a") or input:wasPressed("b") then
self.current = nil
end
end
return true
end
-- ---------------------------------------------------------------------
-- update / menus
-- ---------------------------------------------------------------------
-- PrintSendOutMonMessage (engine/battle/common_text.asm): the shout
-- scales with the enemy's remaining HP percentage, approximated as
-- curHP * 25 / (maxHP / 4): >=70 "Go!", 40-69 "Do it!", 10-39
-- "Get'm!", below 10 "The enemy's weak! Get'm!".
function BattleState:sendOutText(name)
local e = self.enemy and self.enemy.mon
local pct = 100
if e and e.hp > 0 and math.floor(e.stats.hp / 4) > 0 then
pct = math.floor(e.hp * 25 / math.floor(e.stats.hp / 4))
end
if pct >= 70 then return ("Go! %s!"):format(name) end
if pct >= 40 then return ("Do it! %s!"):format(name) end
if pct >= 10 then return ("Get'm! %s!"):format(name) end
return ("The enemy's weak!\nGet'm! %s!"):format(name)
end
-- audio/play_battle_music.asm: gym leaders (wGymLeaderNo) get the
-- gym-leader theme, Lance does too, and the Champion (OPP_RIVAL3)
-- gets the final-battle theme
function BattleState:computeMusicKind()
local isBoss = false
if self.kind == "trainer" and self.trainer then
local victories = require("data.scripts.victories")
for key, reward in pairs(victories) do
if reward.badge and key:find(self.trainer.id .. "#", 1, true) == 1 then
isBoss = true
break
end
end
end
if self.kind == "trainer" and self.trainer
and self.trainer.id == "OPP_RIVAL3" then
return "final"
elseif isBoss or (self.trainer and self.trainer.id == "OPP_LANCE") then
return "gym"
elseif self.kind == "trainer" or self.kind == "link" then
return "trainer"
end
return "wild"
end
-- side tables mirror the singles battlers; called before every
-- battler-switch notification so sides[i].battlers[1] stays honest
function BattleState:syncSides()
self.sides[1].battlers[1] = self.player
self.sides[2].battlers[1] = self.enemy
end
function BattleState:sideOf(battler)
return (battler and battler.isPlayer) and self.sides[1] or self.sides[2]
end
-- battle.started's kind verb: the mutated ghost/safari/oldman variants
-- override the constructor's wild/trainer/link
function BattleState:battleKind()
if self.ghost then return "ghost" end
if self.safari then return "safari" end
if self.demo then return "oldman" end
return self.kind
end
function BattleState:enter()
if self.dead then
self.game.stack:pop()
Runtime.emit("battle.ended", { battle = self, result = "skipped" })
if self.onFinish then self.onFinish("skipped") end
return
end
local Music = require("src.core.Music")
self.musicKind = self:computeMusicKind()
-- normally already playing: the transition wipe starts the theme
-- (audio/play_battle_music.asm runs before the transition, and
-- Music.play no-ops on the same song); this covers battles pushed
-- without a transition (link battles, scripted pushes)
Music.playBattle(self.data, self.musicKind)
-- intro presentation (SlidePlayerAndEnemySilhouettesOnScreen): both
-- sides slide in; the trainer pics stay up until the send-outs
self.introSlide = 40
self.showEnemyTrainer = self.kind == "trainer" and self.trainerPic ~= nil
-- SGB: the player-side battle palette while the back pic is up is
-- MonsterPalettes[0] = PAL_MEWMON (wBattleMonSpecies is still 0 when
-- the intro's SET_PAL_BATTLE runs -- SetPal_Battle,
-- engine/gfx/palettes.asm:28)
self.playerBackPic = getImage(self.demo
and "assets/generated/battle/oldmanb.png"
or "assets/generated/battle/redb.png",
namedPalette(self.data, "MEWMON"))
self.showPlayerBack = self.playerBackPic ~= nil
self:say(self.introText)
if self.kind == "trainer" then
self:say(("%s sent\nout %s!"):format(self.trainer.name, self.enemy.name))
self:act(function()
-- EnemySendOutFirstMon (core.asm:1421-1434): after the text the
-- pic grows out of the ball (AnimateSendingOutMon), then the cry
self.showEnemyTrainer = false
self:startGrowIn(self.enemy)
end)
elseif self.kind == "link" then
-- Colosseum has no foe trainer pic, but the enemy mon still grows
-- out of the ball after "X sent out Y!" (not the wild "already there"
-- intro that LinkBattle previously inherited from newWild).
self.enemySendingOut = true
self:say(("%s sent\nout %s!"):format(self.opponentName or "FOE",
self.enemy.name))
self:act(function()
self.enemySendingOut = false
self:startGrowIn(self.enemy)
end)
end
if not self.ghost then
-- the enemy's cry plays as it appears (data/pokemon/cries.asm)
self:act(function()
require("src.core.Sound").playCry(self.data, self.enemy.mon.species)
end)
end
if not self.safari and not self.demo then
self:say(self:sendOutText(self.player.name))
-- Red's pic clears, the POOF plays, then the mon appears with its
-- cry (SendOutMon: message -> AnimateSendingOutMon -> PlayCry)
self:act(function()
self.showPlayerBack = false
self.sendingOut = true
end)
table.insert(self.queue, { anim = "POOF_ANIM", attackerIsPlayer = false })
self:act(function()
self.sendingOut = false
-- SendOutMon (core.asm:1757-1762): after the poof the mon grows
-- out of the ball (AnimateSendingOutMon at hlcoord 4,11)
self:startGrowIn(self.player)
require("src.core.Sound").playCry(self.data, self.player.mon.species)
end)
self:markParticipant()
end
self.phase = "messages"
self.afterQueue = "menu"
self:syncSides()
Runtime.emit("battle.started", {
battle = self, kind = self:battleKind(),
trainerId = self.trainer and self.trainer.id,
species = self.enemy and self.enemy.mon.species,
level = self.enemy and self.enemy.mon.level,
})
end
-- any pop (finish, script teardown) must silence the alarm loop
-- (end_of_battle.asm clears wLowHealthAlarm when a battle ends)
function BattleState:exit()
require("src.core.Sound").stopLoop("Low_Health_Alarm")
-- Free this battle's own GPU objects now rather than waiting on a GC
-- finalizer: the two full-screen wavy-effect canvases (colorMode) and
-- the AnimPlayer's per-instance tilesheet images/quads. The shared
-- module caches (imageCache/imagePadBottom, keyed by path+palette) are
-- reused by the next battle, so they are deliberately left alone -- only
-- the per-instance objects, which are dead once this battle is popped,
-- are released here.
local function rel(o) if o and o.release then pcall(o.release, o) end end
rel(self.bgCanvas); self.bgCanvas = nil
rel(self.waveCanvas); self.waveCanvas = nil
self.colorFxReady = nil
if self.animPlayer and self.animPlayer.release then
self.animPlayer:release()
end
end
-- End a trapping sequence (USING_TRAPPING_MOVE). SendOutMon clears the
-- foe's bit (core.asm:1761-1762); EnemySendOutFirstMon clears the
-- player's (core.asm:1314-1315). Any switch frees the other side.
local function clearTrapping(battler)
if not battler then return end
battler.trappingTurns = nil
battler.trapMove = nil
battler.trapDamage = nil
end
-- Actions that skip DisplayBattleMenu entirely (core.asm:300-310):
-- recharge, Rage, thrash, charge. Bide / trapping / being held do NOT
-- skip the menu -- the player can still item/switch (and must press
-- FIGHT to continue a trapping sequence).
function BattleState:menuLockedAction(battler)
if battler.mustRecharge then return { special = "recharge" } end
if battler.charging then return battler.charging end
if battler.thrashTurns and battler.thrashTurns > 0 then return battler.thrashMove end
if battler.rageMove then return battler.rageMove end
return nil
end
-- After FIGHT: skip MoveSelectionMenu (core.asm:320-329). Own
-- trapping/Bide continues; foe trapping forces CANNOT_MOVE ($ff).
function BattleState:fightLockedAction(battler)
if battler.trappingTurns and battler.trappingTurns > 0 then
return { special = "trapping" }
end
if battler.bideTurns then return { special = "bide" } end
-- held while the OPPONENT's trapping bit is set (live mirror so a
-- trap ended early by paralysis/faint frees the victim immediately)
local opp = battler.isPlayer and self.enemy or self.player
battler.boundTurns = opp and opp.trappingTurns
and math.max(1, opp.trappingTurns) or nil
if battler.boundTurns then
return { special = "bound" }
end
return nil
end
-- Full lock for AI / callers that need any forced action.
function BattleState:lockedAction(battler)
return self:menuLockedAction(battler) or self:fightLockedAction(battler)
end
function BattleState:playerHasPP()
for i, mv in ipairs(self.player.curMoves) do
if mv.pp > 0 and self.player.disabledSlot ~= i then return true end
end
return false
end
function BattleState:swapMoves(i, j)
if i == j then return end
local moves = self.player.curMoves
local a, b = moves[i], moves[j]
if not (a and b) then return end
moves[i], moves[j] = b, a
local stored = self.player.mon and self.player.mon.moves
if stored and stored ~= moves and stored[i] and stored[j] then
stored[i], stored[j] = stored[j], stored[i]
end
local disabled = self.player.disabledSlot
if disabled == i then
self.player.disabledSlot = j
elseif disabled == j then
self.player.disabledSlot = i
end
require("src.core.Sound").play(self.data, "Swap")
end
function BattleState:update(dt)
self.frame = self.frame + 1
self:updateFx()
local input = self.game.input
-- safety net: HP changed outside a queued drain (level-up heals,
-- field effects) snaps once the queue is idle
if self.phase == "menu" then
for _, b in ipairs({ self.player, self.enemy }) do
if b and b.shownHP then b.shownHP = b.mon.hp end
end
end
if self.phase == "messages" then
if not self:updateQueue() then
if self.afterQueue == "menu" then
self.phase = "menu"
elseif self.afterQueue == "finish" then
self:finish()
end
end
return
end
if self.phase == "menu" and self.demo then
-- DisplayBattleMenu's old-man branch (core.asm:2018-2050): input is
-- never read. The player name is swapped to OLD MAN, then the
-- keystrokes are simulated on screen -- the '▶' cursor sits next to
-- FIGHT (9,14) for 80 frames, hops down to ITEM (9,16) for 50, goes
-- hollow ('▷') and the ITEM menu is forced (a = $2 ->
-- .upperLeftMenuItemWasNotSelected). The old man never attacks;
-- backing out of the ball menu re-enters DisplayBattleMenu, which
-- replays the whole script.
self.demoTimer = (self.demoTimer or 0) + 1
if self.demoTimer > 130 then
self.demoTimer = nil
self:openOldManBag()
end
return
end
if self.phase == "menu" and self.safari then
if self.safari.balls <= 0 then
self:say("PA: You're out of\nSAFARI BALLs!\nGame over!")
self.phase = "messages"
self.result = "run"
self.afterQueue = "finish"
return
end
local col = (self.menuIndex - 1) % 2
local row = math.floor((self.menuIndex - 1) / 2)
if input:wasPressed("left") or input:wasPressed("right") then
col = 1 - col
elseif input:wasPressed("up") or input:wasPressed("down") then
row = 1 - row
end
self.menuIndex = row * 2 + col + 1
if input:wasPressed("a") then
self:safariAction(({ "ball", "bait", "rock", "run" })[self.menuIndex])
end
return
end
if self.phase == "menu" then
-- forced replacement after a faint: ChooseNextMon (core.asm:1086)
-- loops the party menu until a healthy mon is picked, so B and
-- fainted picks land back here and reopen it
if self.player.mon.hp <= 0 then
if Party.firstHealthy(self.game.save.party) then
self:openReplacementMenu()
end
return
end
-- core.asm:297-300: both sides' FLINCHED bits are cleared during
-- move selection, but the clear is skipped while the player must
-- recharge or is locked into Rage (core.asm:293-295 -- the Hyper
-- Beam flinch-recharge glitch)
if not (self.player.mustRecharge or self.player.rageMove) then
self.player.flinched, self.enemy.flinched = false, false
end
-- only recharge/Rage/thrash/charge skip DisplayBattleMenu; trapping
-- victims (and wrappers) still get FIGHT/PKMN/ITEM/RUN (core.asm:312)
local locked = self:menuLockedAction(self.player)
if locked then
self:resolveTurn(locked)
return
end
local col = (self.menuIndex - 1) % 2
local row = math.floor((self.menuIndex - 1) / 2)
if input:wasPressed("left") or input:wasPressed("right") then
col = 1 - col
elseif input:wasPressed("up") or input:wasPressed("down") then
row = 1 - row
end
self.menuIndex = row * 2 + col + 1
if input:wasPressed("a") then
local choice = ({ "fight", "pkmn", "item", "run" })[self.menuIndex]
if choice == "fight" and self.ghost then
self:say(("%s is too\nscared to move!"):format(self.player.name))
self.phase = "messages"
self.afterQueue = "menu"
self:act(function()
self:executeAction(self.enemy, self.player, self:enemyAction())
end)
self:act(function() self:endOfTurn() end)
elseif choice == "fight" then
-- After the menu: own trapping/Bide or foe Wrap skips the move
-- list and forces the locked action (core.asm:320-329)
local fightLock = self:fightLockedAction(self.player)
if fightLock then
self:resolveTurn(fightLock)
return
end
if not self:playerHasPP() then
-- _NoMovesLeftText, then Struggle engages
self:say(("%s has no\nmoves left!"):format(self.player.name))
self:resolveTurn({ id = "STRUGGLE", pp = 1, struggle = true })
return
end
self.phase = "moveSelect"
self.moveIndex = math.min(self.moveIndex, #self.player.curMoves)
self.moveSwapIndex = nil
elseif choice == "run" then
self:tryRun()
elseif choice == "item" then
self:openItems()
else
self:openParty()
end
end
return
end
if self.phase == "moveSelect" then
local moves = self.player.curMoves
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
else
self.moveSwapIndex = self.moveIndex
end
elseif input:wasPressed("b") then
self.moveSwapIndex = nil
self.phase = "menu"
elseif input:wasPressed("a") then
if self.moveSwapIndex then
self:swapMoves(self.moveSwapIndex, self.moveIndex)
self.moveSwapIndex = nil
return
end
local mv = moves[self.moveIndex]
if self.player.disabledSlot == self.moveIndex then
self:say("The move is\ndisabled!")
self.phase = "messages"
self.afterQueue = "menu"
elseif mv.pp <= 0 then
self:say("No PP left for\nthis move!")
self.phase = "messages"
self.afterQueue = "menu"
else
self:resolveTurn(mv)
end
end
return
end
-- Mimic's mid-move copy menu (MimicEffect .letPlayerChooseMove,
-- effects.asm:1243-1260): opened by the queue AFTER the hit test
-- passes. MoveSelectionMenu's mimic type watches only UP/DOWN/A
-- (core.asm:2553-2557), so there is no backing out with B.
if self.phase == "mimicSelect" then
local moves = self.mimicMoves
if input:wasPressed("up") then
self.mimicIndex = self.mimicIndex > 1 and self.mimicIndex - 1 or #moves
elseif input:wasPressed("down") then
self.mimicIndex = self.mimicIndex < #moves and self.mimicIndex + 1 or 1
elseif input:wasPressed("a") then
local pick = moves[self.mimicIndex]
local ctx = self.mimicCtx
self.mimicMoves, self.mimicCtx = nil, nil
self.phase = "messages"
self.nextInsert = 0 -- the copy's anim + text go to the queue head
self:applyMimic(ctx.user, ctx.target, ctx.moveInst, pick.slot)
end
return
end
end
-- MimicEffect (engine/battle/effects.asm:1203-1273) runs MID-move: a
-- 50-frame beat, MoveHitTest, and only on a hit does the player's copy
-- menu open (.letPlayerChooseMove). The enemy's Mimic -- and either
-- side of a link battle -- copies a RANDOM non-empty slot instead
-- (.getRandomMove). Both failure paths (accuracy roll, mid-Fly/Dig
-- target) print PrintButItFailedText_ and skip the move animation.
function BattleState:resolveMimic(user, target, move, moveInst)
-- ld c, 50 / call DelayFrames before anything happens
self.nextInsert = (self.nextInsert or 0) + 1
table.insert(self.queue, self.nextInsert, { wait = 50 })
if target.invulnerable
or not self:accuracyRoll(move, user, target) then
self:sayNext("But, it failed!")
return
end
local slots = {}
for i, m in ipairs(target.curMoves) do
if m.id and m.pp ~= nil then slots[#slots + 1] = i end
end
if #slots == 0 then
-- .getRandomMove rerolls empty slots forever; a moveless target
-- can't happen in practice, so just fail instead of hanging
self:sayNext("But, it failed!")
return
end
if user.isPlayer and self.kind ~= "link" then
if self.mimicChoice then -- test-injection hook for the player pick
local slot = self:mimicChoice(target)
if slot and target.curMoves[slot] and target.curMoves[slot].pp ~= nil then
self:applyMimic(user, target, moveInst, slot)
return
end
end
-- pause the queue on a chooser row; the mimicSelect phase applies
-- the pick and resumes
self.nextInsert = self.nextInsert + 1
table.insert(self.queue, self.nextInsert, {
mimicSelect = { user = user, target = target, moveInst = moveInst },
})
return
end
self:applyMimic(user, target, moveInst, slots[self.rng(1, #slots)])
end
-- The copied move OVERWRITES the used slot's move id in place; the PP
-- byte is untouched (only wBattleMonMoves is written, effects.asm:
-- 1261-1266), so the copy inherits Mimic's remaining PP and keeps
-- draining that same slot (DecrementPP hits both the battle copy and
-- the party struct). curMoves aliases mon.moves, so the original id is
-- remembered and restored when the battler leaves play -- pokered never
-- writes the party copy, and the battle copy is rebuilt from it on a
-- switch or at battle end. Then PlayCurrentMoveAnimation and
-- _MimicLearnedMoveText.
function BattleState:applyMimic(user, target, moveInst, slot)
local src = target.curMoves[slot]
if not (src and src.id) then return end
local mySlot
for i, m in ipairs(user.curMoves) do
if m == moveInst then mySlot = i break end
end
if not mySlot then
-- a called Mimic (Metronome) isn't in the list. For the player,
-- non-link case, MimicEffect snapshots wCurrentMenuItem BEFORE the
-- copy-picker menu opens and restores it afterward as the write
-- index (effects.asm:1247, 1256/1261) -- it reuses whatever slot
-- was left highlighted by the FIGHT menu. That's provably always
-- the calling move's own slot (e.g. METRONOME's): selecting a move
-- syncs wCurrentMenuItem and wPlayerMoveListIndex together
-- (core.asm's SelectMenuItem), and nothing touches either before
-- the effect runs. self.moveIndex mirrors this exactly -- it's
-- frozen at the FIGHT-menu confirm and untouched through mid-move
-- resolution -- so it already equals the calling move's slot here;
-- there's no separate "reused index" to chase. (Enemy/link Mimic
-- instead reads w*MoveListIndex directly, effects.asm:1235-1241.)
mySlot = user.isPlayer and math.min(self.moveIndex or 1, #user.curMoves) or 1
end
local entry = user.curMoves[mySlot]
self.mimicRestores = self.mimicRestores or {}
table.insert(self.mimicRestores, { battler = user, entry = entry, id = entry.id })
entry.id = src.id
entry.mimic = true
self:animNext("MIMIC", user.isPlayer)
-- _MimicLearnedMoveText: "<USER> / learned / MOVE!"
self:sayNext(("%s\nlearned\n%s!"):format(displayName(user),
self.data.moves[src.id].name))
end
-- Undo Mimic's in-place id overwrite for a battler leaving play (the GB
-- battle copy is discarded; the party struct never changed).
function BattleState:restoreMimicked(battler)
if not self.mimicRestores then return end
local keep = {}
-- iterate newest-first so the oldest snapshot (the true pre-Mimic
-- move id, from before any repeated Mimic-on-Mimic via Metronome)
-- is applied last and wins, instead of a stale intermediate id
for i = #self.mimicRestores, 1, -1 do
local r = self.mimicRestores[i]
if r.battler == battler then
r.entry.id, r.entry.mimic = r.id, nil
else
keep[#keep + 1] = r
end
end
self.mimicRestores = #keep > 0 and keep or nil
end
-- BagWasSelected's old-man fork (core.asm:2193-2210): the list menu is
-- fed OldManItemList -- one POKé BALL x50 -- instead of the player's
-- bag. The list is as scripted as the battle menu (DisplayListMenuID's
-- old-man branch, home/list_menu.asm:65-80): no input is ever read --
-- backing out is impossible -- the '▶' sits in front of POKé BALL for
-- 80 frames, then A is auto-pressed and .buttonAPressed's
-- PlaceUnfilledArrowMenuCursor leaves the hollow '▷' on the row for the
-- handful of frames UseBagItem takes to reach ItemUseBall's screen
-- restore (item_effects.asm:145) and the throw text.
function BattleState:openOldManBag()
local ListMenu = require("src.ui.ListMenu")
local game = self.game
self.phase = "messages"
self.afterQueue = "menu"
self:ui(function()
local list
list = ListMenu.new(game, "ITEMS", {
{ value = "POKE_BALL", label = "POKé BALL", right = "x50" },
}, {
script = function(l)
l.scriptTimer = (l.scriptTimer or 0) + 1
if l.scriptTimer == 81 then
-- the auto A-press: the cursor goes hollow on the chosen row
l.hollowIndex = l.index
elseif l.scriptTimer > 88 then
-- ItemUseBall takes over: list down, OLD MAN throws
l:close()
self:oldManThrow()
end
end,
})
return list
end)
end
-- ItemUseBall for BATTLE_TYPE_OLD_MAN: the party/box-full checks are
-- skipped (item_effects.asm:114-118), every capture calculation is
-- skipped -- the old man branch jumps straight to .captured, $43 anim
-- data = 3 shakes and caught (:155-164 + :193-200) -- and
-- .oldManCaughtMon prints the caught text WITHOUT adding the mon to
-- the party or the dex (:568-570). The "used" line reads OLD MAN
-- because DisplayBattleMenu swapped wPlayerName (core.asm:2024-2037);
-- no ball is consumed (.done returns early, :576-578).
function BattleState:oldManThrow()
self.phase = "messages"
self.afterQueue = "finish"
self.result = "run" -- nothing is kept; wBattleResult only ends the demo
self:say("OLD MAN used\nPOKé BALL!")
self:act(function()
require("src.core.Sound").play(self.data, "Ball_Toss")
-- ItemUseBall's beat before the toss chain (like throwBall)
self.nextInsert = (self.nextInsert or 0) + 1
table.insert(self.queue, self.nextInsert, { wait = 20 })
self:ballChain("TOSS_ANIM", true, 3, "POKE_BALL")
self:actNext(function()
require("src.core.Sound").play(self.data, "Caught_Mon")
end)
self:sayNext(("All right!\n%s was\ncaught!"):format(self.enemy.name))
end)
end
-- ---------------------------------------------------------------------
-- turn resolution
-- ---------------------------------------------------------------------
function BattleState:moveDef(moveInst)
return self.data.moves[moveInst.id]
end
-- the merged move_effects record for an effect id; the module records
-- cover battles built without a loader
function BattleState:effectRecord(effect)
local effects = self.data.move_effects
if effects then return effects[effect] end
return MoveEffects.RECORDS[effect]
end
-- the merged ball record (Catching.attempt handles the unknown-id default)
function BattleState:ballDef(ball)
local balls = self.data.balls
return balls and balls[ball] or Catching.BALLS[ball]
end
-- the HUD label drawn in place of the level for a statused mon
function BattleState:statusLabel(mon)
local record = Status.recordFor(self.data.statuses, mon.status)
if record then
return record.hudLabel or record.label or mon.status
end
return mon.status
end
-- the one accuracy roll (MoveHitTest), hooked as battle.accuracy
function BattleState:accuracyRoll(move, user, target)
if Runtime.wantsHook("battle.accuracy") then
return Runtime.call("battle.accuracy", function(c)
return Damage.accuracyRoll(c.ruleset, c.move, c.user, c.target, c.rng)
end, { battle = self, ruleset = self.ruleset, move = move,
user = user, target = target, rng = self.rng })
end
return Damage.accuracyRoll(self.ruleset, move, user, target, self.rng)
end
-- Damage.compute, hooked as battle.damage; the ctx table is only built
-- when a chain is installed, so the no-mod path allocates nothing
function BattleState:computeDamage(user, target, move, opts)
if Runtime.wantsHook("battle.damage") then
return Runtime.call("battle.damage", function(c)
return Damage.compute(c.ruleset, c.user, c.target, c.move, c.opts)
end, { battle = self, ruleset = self.ruleset, user = user,
target = target, move = move, opts = opts, rng = self.rng })
end
return Damage.compute(self.ruleset, user, target, move, opts)
end
-- Catching.attempt against the merged registry, hooked as catch.rate
function BattleState:catchAttempt(ball, rateOverride)
if Runtime.wantsHook("catch.rate") then
local battle = self
return Runtime.call("catch.rate", function(b, mon, def, o)
return Catching.attempt(b, mon, def, o.rng, o.rateOverride,
{ ballDef = battle:ballDef(b), statuses = battle.data.statuses,
battle = battle })
end, ball, self.enemy.mon, self.enemy.def,
{ rng = self.rng, rateOverride = rateOverride, battle = self })
end
return Catching.attempt(ball, self.enemy.mon, self.enemy.def, self.rng,
rateOverride, { ballDef = self:ballDef(ball),
statuses = self.data.statuses, battle = self })
end
-- wAICount: item/switch uses per enemy Pokémon for this trainer class
function BattleState:aiUsesFor()
if self.kind ~= "trainer" or not self.trainer then return 0 end
local class = TrainerAI.classFor(self)
return class and class.uses or 0
end
-- Exp participants: every player mon that has been in against the
-- current enemy mon (wPartyGainExpFlags).
function BattleState:markParticipant()
self.participants = self.participants or {}
if self.player and self.player.mon then
self.participants[self.player.mon] = true
end
end
-- the whole choke point is hooked (battle.enemy_action), so a mod can
-- rewrite any trainer's choice without registering brains
function BattleState:enemyAction()
if Runtime.wantsHook("battle.enemy_action") then
return Runtime.call("battle.enemy_action", function(battle)
return battle:vanillaEnemyAction()
end, self)
end
return self:vanillaEnemyAction()
end
function BattleState:vanillaEnemyAction()
local locked = self:lockedAction(self.enemy)
if locked then return locked end
-- an ai_classes brain (or one on the trainer record) supersedes the
-- class action and move scoring entirely
if self.kind == "trainer" and self.trainer then
local class = TrainerAI.classFor(self)
local brain = self.trainer.brain or (class and class.brain)
if brain then return brain(self) end
end
-- class AI may spend the turn on an item or a switch
local classAct = TrainerAI.classAction(self)
if classAct then return classAct end
return TrainerAI.chooseMove(self.enemy, self.rng, self)
end
local function orderMove(action, data)
if action and action.id then return data.moves[action.id] end
return nil
end
function BattleState:resolveTurn(playerAction)
local enemyAction = self:enemyAction()
self.turnCount = (self.turnCount or 0) + 1
Runtime.emit("battle.turn_started", {
battle = self, turn = self.turnCount,
playerAction = playerAction, enemyAction = enemyAction,
})
local pMove = orderMove(playerAction, self.data)
local eMove = orderMove(enemyAction, self.data)
local pFirst
if Runtime.wantsHook("battle.turn_order") then
pFirst = Runtime.call("battle.turn_order", function(a, aMove, b, bMove, c)
return TurnOrder.firstMover(a, aMove, b, bMove, c.rng, c.invertTie)
end, self.player, pMove, self.enemy, eMove, { rng = self.rng })
else
pFirst = TurnOrder.firstMover(self.player, pMove, self.enemy, eMove, self.rng)
end
local order
if pFirst then
order = { { self.player, self.enemy, playerAction },
{ self.enemy, self.player, enemyAction } }
else
order = { { self.enemy, self.player, enemyAction },
{ self.player, self.enemy, playerAction } }
end
self.phase = "messages"
self.afterQueue = "menu"
for _, entry in ipairs(order) do
self:act(function()
self:executeAction(entry[1], entry[2], entry[3])
end)
end
self:act(function() self:endOfTurn() end)
end
-- A switch action: replace the player's mon, enemy gets a free move.
function BattleState:resolveSwitch(newMon)
self.phase = "messages"
self.afterQueue = "menu"
self:act(function()
self:restoreMimicked(self.player) -- the battle copy leaves with it
local previous = self.player
self.player = makeBattler(self.data, newMon, true, self.game.save)
-- SendOutMon (core.asm:1761-1762): player's send-out clears the
-- foe's USING_TRAPPING_MOVE -- Wrap/Bind/etc. ends on any switch
clearTrapping(self.enemy)
self:syncSides()
Runtime.emit("battle.battler_switched", {
battle = self, side = self.sides[1], battler = self.player,
previous = previous,
})
self:markParticipant()
self.sendingOut = true
self:sayNext(self:sendOutText(self.player.name))
self:animNext("POOF_ANIM", false)
self:actNext(function()
self.sendingOut = false
-- SendOutMon (core.asm:1757-1762): poof, then the grow-in
self:startGrowIn(self.player)
require("src.core.Sound").playCry(self.data, self.player.mon.species)
end)
end)
self:act(function()
self:executeAction(self.enemy, self.player, self:enemyAction())
end)
self:act(function() self:endOfTurn() end)
end
function BattleState:endOfTurn()
-- sideToxic mirrors w*ToxicCounter: it advances only while the
-- battler's badly-poisoned flag (toxicCounter) is set, an item/AI
-- cure clears the flag but NOT the side counter, and a fresh Toxic
-- re-seeds it (effects.asm:137-139 zeroes the counter when setting
-- BADLY_POISONED). It is never copied back onto a battler: pokered
-- reads the counter only while the flag is set, and the only code
-- that sets the flag also zeroes the counter, so a stale value is
-- unobservable (a switch or cure downgrades Toxic to plain poison).
self.sideToxic = self.sideToxic or {}
-- a battler whose opponent was already knocked out by a move this turn
-- skips its own residual (HandlePoisonBurnLeechSeed is bypassed when the
-- move faints the target); snapshot before residual so one side's
-- residual faint can't suppress the other's
local playerAlive = self.player.mon.hp > 0
local enemyAlive = self.enemy.mon.hp > 0
for _, pair in ipairs({ { self.player, self.enemy, "player", enemyAlive },
{ self.enemy, self.player, "enemy", playerAlive } }) do
local b, opp, side, oppAlive = pair[1], pair[2], pair[3], pair[4]
if b.mon.hp > 0 and oppAlive then
local msgs = Status.residual(b, opp, self)
for _, m in ipairs(msgs) do self:sayNext(prefixEnemy(m, b)) end
if #msgs > 0 then self:drainNext() end -- poison/burn/seed HP moved
if b.toxicCounter then
self.sideToxic[side] = b.toxicCounter
end
if b.mon.hp <= 0 then
self:onFaint(b)
end
end
-- CheckNumAttacksLeft (core.asm:683-697): a trapping counter that
-- hit 0 this turn releases its bit only now, at the end of the turn
if b.trappingTurns and b.trappingTurns <= 0 then
b.trappingTurns = nil
end
end
self:tickTokens()
Runtime.emit("battle.turn_ended", { battle = self, turn = self.turnCount or 0 })
end
-- side/field tokens ({ id, turns?, onResidual?, onExpire? }) tick after
-- the residual sweep; with the tables empty this is a nil check per list
local function tickTokenList(battle, tokens, holder)
if tokens[1] == nil then return end
for i = #tokens, 1, -1 do
local token = tokens[i]
if token.turns then token.turns = token.turns - 1 end
if token.onResidual then token.onResidual(battle, holder) end
if token.turns and token.turns <= 0 then
if token.onExpire then token.onExpire(battle, holder) end
table.remove(tokens, i)
end
end
end
function BattleState:tickTokens()
for _, side in ipairs(self.sides) do
tickTokenList(self, side.tokens, side)
end
tickTokenList(self, self.field.tokens, self.field)
end
-- ---------------------------------------------------------------------
-- battle animation layer
-- ---------------------------------------------------------------------
--
-- An approximation of the original's subanimation bytecode engine
-- (docs/known-differences.md): the move's real sound (data/moves/sfx.asm
-- via each move's anim table), screen shake / flash for moves whose
-- animation data uses SE_SHAKE_SCREEN / screen-flash effects, target
-- blink on damage, and a faint slide with the cry. The Poké Ball toss
-- chain (toss/poof/hide/shake/show) rides the queue as anim rows.
-- the OPTIONS animation toggle (sounds always play)
function BattleState:animationsOn()
local o = self.game.save.options
return not o or o.animations ~= false
end
-- Drop the announcement-time move-anim row. Gen 1 queues PlayMoveAnimation
-- only after MoveHitTest / the effect lands (HandleIfPlayerMoveMissed skips
-- it on a miss unless EXPLODE_EFFECT); we insert early for blink attachment
-- and peel it back on miss/fail paths.
-- Dig/Fly charge leaves the user pic hidden (SLIDE_DOWN / TELEPORT); the
-- second-turn DIG/FLY anim restores it via SE_SLIDE_MON_UP / SE_SHOW_MON_PIC.
-- Cancelling that row on miss/immune would otherwise leave the digger
-- invisible until another anim's resetPicFx (#100).
function BattleState:cancelMoveAnim()
local row = self.moveAnimRow
if not row then return end
self.moveAnimRow = nil
if row.anim == "DIG" or row.anim == "FLY" then
local user = row.attackerIsPlayer and self.player or self.enemy
local pf = user and self.picFx and self.picFx[user]
if pf then pf.hidden = nil end
end
for i, item in ipairs(self.queue) do
if item == row then
table.remove(self.queue, i)
if self.nextInsert and i <= self.nextInsert then
self.nextInsert = self.nextInsert - 1
end
return
end
end
end
-- ------------------------------------------------------------------
-- special-effect (SE_*) implementations. Palette effects are BGP
-- shade maps ({[i] = shade color index i displays as}); on the SGB the
-- colorizer colors the REMAPPED shade, so the zone palettes are
-- permuted through the active map (engine/battle/animations.asm
-- SetAnimationBGPalette / AnimationFlashScreen / ...ScreenLong).
-- ------------------------------------------------------------------
local BGP_IDENTITY = { [0] = 0, 1, 2, 3 } -- $e4
local BGP_INVERT = { [0] = 3, 2, 1, 0 } -- $1b (flash phase 1)
local BGP_WHITE = { [0] = 0, 0, 0, 0 } -- $00 (flash phase 2)
local BGP_DARK = { [0] = 3, 3, 2, 1 } -- $6f DarkScreenPalette
local BGP_LIGHT = { [0] = 0, 0, 1, 2 } -- $90 LightScreenPalette
local BGP_DARKEN = { [0] = 0, 1, 3, 3 } -- $f4 DarkenMonPalette (SGB)
-- FlashScreenLongSGB (animations.asm:1010): 12 BGP values per cycle,
-- 3 cycles; the first cycle holds each for 2 frames, the rest for 1
-- (FlashScreenLongDelay)
local FLASH_LONG_MAPS = {
{ [0] = 0, 2, 3, 3 }, { [0] = 0, 3, 3, 3 }, { [0] = 3, 3, 3, 3 },
{ [0] = 0, 3, 3, 3 }, { [0] = 0, 2, 3, 3 }, { [0] = 0, 1, 2, 3 },
{ [0] = 0, 0, 1, 2 }, { [0] = 0, 0, 0, 1 }, { [0] = 0, 0, 0, 0 },
{ [0] = 0, 0, 0, 1 }, { [0] = 0, 0, 1, 2 }, { [0] = 0, 1, 2, 3 },
}
-- the shade map in force this frame (a running flash wins over the
-- persistent palette)
function BattleState:activeBgp()
local fx = self.fx
if not fx then return nil end
local seq = fx.bgpSeq
if seq then
local st = seq.steps[seq.idx]
if st then return st.map end
end
return fx.bgp
end
-- per-battler pic effect state (offsets/hides driven by the SE rows)
function BattleState:picFxFor(battler)
if not battler then return nil end
self.picFx = self.picFx or {}
local pf = self.picFx[battler]
if not pf then
pf = { ox = 0, oy = 0 }
self.picFx[battler] = pf
end
return pf
end
-- transient pic effects reset when a new animation row starts (each
-- PlayAnimation redraws from a clean slate); `minimized` survives --
-- the minimize sprite replaces the pic DATA until reload. Dig/Fly's
-- charge hide is a cleared tilemap that must survive until SE_SHOW_* /
-- SE_SLIDE_MON_UP (or cancelMoveAnim on a missed Dig/Fly release):
-- clearing it here made Dig pop in before emerge and wrap/bounce (#100).
-- Other hides (Acid Armor, etc.) still clear so the next anim restores.
function BattleState:resetPicFx()
if not self.picFx then return end
local digFly = self.animName == "DIG" or self.animName == "FLY"
local digFlyUser = digFly and (self.animAttackerIsPlayer
and self.player or self.enemy) or nil
for battler, pf in pairs(self.picFx) do
pf.kind, pf.t = nil, nil
pf.ox, pf.oy = 0, 0
local keepHide = battler.invulnerable or battler == digFlyUser
if not keepHide then
pf.hidden = nil
end
end
end
-- the battler an SE row's routine acts on: "the mon" is the attacker's
-- side; the SE_*_ENEMY_* variants run through CallWithTurnFlipped
function BattleState:animFxBattler(flipped)
local isPlayer = self.animAttackerIsPlayer
if flipped then isPlayer = not isPlayer end
return isPlayer and self.player or self.enemy
end
-- a row's sound byte is a move id: GetMoveSound plays its
-- MoveSoundTable sfx with the pitch/tempo modifier bytes; for the
-- GROWL/ROAR animations (IsCryMove) it plays the attacker's cry, with
-- the move's own pitch/tempo bytes (from its own row, soundMove ==
-- self.animName for these) layered on as the extra shift
function BattleState:playAnimSound(soundMove)
local Sound = require("src.core.Sound")
local mdef = self.data.moves[soundMove]
if self.animName == "GROWL" or self.animName == "ROAR" then
local attacker = self:animFxBattler(false)
if attacker then
Sound.playMoveCry(self.data, attacker.mon.species,
mdef and mdef.anim and mdef.anim.tempo)
end
return
end
if mdef and mdef.anim then
if Sound.playMove then
Sound.playMove(self.data, mdef.anim)
else
Sound.play(self.data, mdef.anim.sound)
end
end
end
local function startPicKind(pf, kind)
if not pf then return end
pf.kind, pf.t = kind, 0
pf.hidden = nil
end
-- Route one AnimPlayer event into the fx layer. Frame counts and
-- amplitudes are the routines' own (engine/battle/animations.asm;
-- shakes: engine/gfx/screen_effects.asm).
function BattleState:applyAnimEffect(ev)
self.fx = self.fx or {}
local fx = self.fx
if ev.sound then
self:playAnimSound(ev.sound)
end
local e = ev.effect
if not e then return end
if e == "SFX_TINK" then
-- each ball shake opens with a tink (DoBallShakeSpecialEffects)
require("src.core.Sound").play(self.data, "Tink")
-- ---------------------------------------------- palette effects
elseif e == "SE_DARK_SCREEN_PALETTE" then
fx.bgp = BGP_DARK
elseif e == "SE_LIGHT_SCREEN_PALETTE" then
fx.bgp = BGP_LIGHT
elseif e == "SE_DARKEN_MON_PALETTE" then
fx.bgp = BGP_DARKEN
elseif e == "SE_RESET_SCREEN_PALETTE" then
fx.bgp = nil
elseif e == "SE_DARK_SCREEN_FLASH" then
-- AnimationFlashScreen: 2 frames inverted, 2 frames white, restore
fx.bgpSeq = { steps = { { map = BGP_INVERT, frames = 2 },
{ map = BGP_WHITE, frames = 2 } },
idx = 1, left = 2 }
elseif e == "SE_FLASH_SCREEN_LONG" then
local steps = {}
for cycle = 1, 3 do
for _, m in ipairs(FLASH_LONG_MAPS) do
steps[#steps + 1] = { map = m, frames = (cycle == 1) and 2 or 1 }
end
end
fx.bgpSeq = { steps = steps, idx = 1, left = steps[1].frames }
-- ---------------------------------------------- screen shakes
elseif e == "SE_SHAKE_SCREEN" then
-- PredefShakeScreenHorizontally b=8: the window jumps right by b
-- for 5 frames then home for 4, b counting down 8..1
local prog = {}
for b = 8, 1, -1 do
prog[#prog + 1] = { dx = b, frames = 5 }
prog[#prog + 1] = { dx = 0, frames = 4 }
end
fx.shakeProg = prog
elseif e == "SE_ROCK_SLIDE_SHAKE" then
-- DoRockSlideSpecialEffects: 1px horizontal then vertical rumble
fx.shakeProg = { { dx = 1, frames = 5 }, { dx = 0, frames = 4 },
{ dy = 1, frames = 3 }, { dy = 0, frames = 3 } }
elseif e == "SE_SHAKE_ENEMY_HUD" then
-- AnimationShakeEnemyHUD: SCX +-2 for 2 frames each, 8 times; the
-- window + a sprite copy of the back pic keep everything below the
-- enemy HUD still, so only the HUD area moves
local prog = {}
for _ = 1, 8 do
prog[#prog + 1] = { dx = 2, frames = 2 }
prog[#prog + 1] = { dx = -2, frames = 2 }
end
fx.hudShakeProg = prog
elseif e == "SE_WAVY_SCREEN" then
-- AnimationWavyScreen: 255 frames of per-scanline SCX offsets
-- walking WavyScreenLineOffsets
fx.wavy = { left = 255, phase = 0 }
-- ---------------------------------------------- mon pic effects
elseif e == "SE_SLIDE_MON_OFF" then
startPicKind(self:picFxFor(self:animFxBattler(false)), "slideOff")
elseif e == "SE_SLIDE_ENEMY_MON_OFF" then
startPicKind(self:picFxFor(self:animFxBattler(true)), "slideOff")
elseif e == "SE_SLIDE_MON_HALF_OFF" then
startPicKind(self:picFxFor(self:animFxBattler(false)), "slideHalf")
elseif e == "SE_SLIDE_MON_UP" then
startPicKind(self:picFxFor(self:animFxBattler(false)), "slideUp")
elseif e == "SE_SLIDE_MON_DOWN" then
startPicKind(self:picFxFor(self:animFxBattler(false)), "slideDown")
elseif e == "SE_SLIDE_MON_DOWN_AND_HIDE" then
startPicKind(self:picFxFor(self:animFxBattler(false)), "slideDownHide")
elseif e == "SE_SHAKE_BACK_AND_FORTH" then
startPicKind(self:picFxFor(self:animFxBattler(false)), "shakeBF")
elseif e == "SE_BOUNCE_UP_AND_DOWN" then
startPicKind(self:picFxFor(self:animFxBattler(false)), "bounce")
elseif e == "SE_SQUISH_MON_PIC" then
startPicKind(self:picFxFor(self:animFxBattler(false)), "squish")
elseif e == "SE_BLINK_MON" then
startPicKind(self:picFxFor(self:animFxBattler(false)), "blink")
elseif e == "SE_BLINK_ENEMY_MON" then
startPicKind(self:picFxFor(self:animFxBattler(true)), "blink")
elseif e == "SE_MOVE_MON_HORIZONTALLY" then
-- redraw one tile inward: player pic at hlcoord 2,5 (from 1,5),
-- enemy pic at 11,0 (from 12,0)
local b = self:animFxBattler(false)
local pf = self:picFxFor(b)
if pf then
pf.kind, pf.hidden = nil, nil
pf.ox = b.isPlayer and 8 or -8
pf.oy = 0
end
elseif e == "SE_RESET_MON_POSITION" then
local pf = self:picFxFor(self:animFxBattler(false))
if pf then
pf.kind, pf.hidden, pf.ox, pf.oy = nil, nil, 0, 0
end
elseif e == "SE_SHOW_MON_PIC" then
local pf = self:picFxFor(self:animFxBattler(false))
if pf then pf.kind, pf.hidden, pf.ox, pf.oy = nil, nil, 0, 0 end
elseif e == "SE_SHOW_ENEMY_MON_PIC" then
local pf = self:picFxFor(self:animFxBattler(true))
if pf then pf.kind, pf.hidden, pf.ox, pf.oy = nil, nil, 0, 0 end
elseif e == "SE_HIDE_MON_PIC" or e == "SE_HIDE_ATTACKER_PIC" then
local pf = self:picFxFor(self:animFxBattler(false))
if pf then pf.kind, pf.hidden = nil, true end
elseif e == "SE_HIDE_ENEMY_MON_PIC" then
local pf = self:picFxFor(self:animFxBattler(true))
if pf then pf.kind, pf.hidden = nil, true end
elseif e == "SE_MINIMIZE_MON" then
-- the pic data is replaced by the tiny MinimizedMonSprite blob
local pf = self:picFxFor(self:animFxBattler(false))
if pf then
pf.kind, pf.hidden = nil, nil
pf.minimized = true
end
elseif e == "SE_FLASH_MON_PIC" or e == "SE_FLASH_ENEMY_MON_PIC" then
-- ChangeMonPic reloads the mon's own pic (clears a minimize)
local pf = self:picFxFor(self:animFxBattler(e == "SE_FLASH_ENEMY_MON_PIC"))
if pf then pf.kind, pf.hidden, pf.minimized = nil, nil, nil end
elseif e == "SE_TRANSFORM_MON" then
-- AnimationTransformMon redraws the user as the opposing species
-- (MoveEffects.TRANSFORM_EFFECT swaps the rest when it applies)
local user = self:animFxBattler(false)
local target = self:animFxBattler(true)
if user and target and self.speciesSprite then
user.sprite = self:speciesSprite(target.mon.species, user.isPlayer)
or user.sprite
local pf = self:picFxFor(user)
if pf then pf.minimized = nil end
end
end
-- SE_SUBSTITUTE_MON needs no visual here: the doll is drawn while
-- battler.substituteHP is set (MoveEffects raises it with the move)
end
-- The target's post-animation hit feedback (PlayApplyingAttackAnimation,
-- engine/battle/animations.asm:475): the player's damaging moves blink
-- the ENEMY pic; the enemy's damaging moves shake the screen vertically
-- (ShakeScreenVertically -> PredefShakeScreenVertically b=8: the window
-- drops by b for 3 frames then home for 3, b counting down) -- the
-- player's pic never blinks. Damage sound with either. A hold keeps
-- the queue still until the effect finishes.
function BattleState:applyHitFx(hit)
if hit.blink then
self.fx = self.fx or {}
if hit.blink.isPlayer then
local prog = {}
for b = 8, 1, -1 do
prog[#prog + 1] = { dy = b, frames = 3 }
prog[#prog + 1] = { dy = 0, frames = 3 }
end
self.fx.shakeProg = prog
self.waitFrames = 48 -- the predef blocks until the shake settles
else
self.fx.blink = { target = hit.blink, frames = 20 }
self.waitFrames = 20
end
end
if hit.sfx then
require("src.core.Sound").play(self.data, hit.sfx)
end
end
-- AnimateSendingOutMon (core.asm:6801-6838): the mon grows out of the
-- ball -- a 3-frame ball beat, 4 frames of the pic at 3/7 scale (a 3x3
-- block of its 7x7 tiles), 5 frames at 5/7 (5x5), then full size.
-- Queues a hold so the text stays up while it grows. Runs inside a
-- queued fn (updateQueue resets nextInsert before each one).
function BattleState:startGrowIn(battler)
self.growIn = { battler = battler, frame = 0 }
self.nextInsert = (self.nextInsert or 0) + 1
table.insert(self.queue, self.nextInsert, { wait = 12 })
end
-- Should the low-health alarm sound this frame? pokered keys it off
-- the drawn bar color: DrawPlayerHUDAndHPBar (core.asm:1846-1875) sets
-- wLowHealthAlarm bit 7 when GetHealthBarColor says the player bar is
-- red (< 10 of 48 pixels -- the same threshold HudTiles.drawHPBar
-- tints with) and clears it when the bar isn't red or the mon fainted
-- (RemoveFaintedPlayerMon). Winning disables it for the rest of the
-- battle (EndLowHealthAlarm sets wLowHealthAlarmDisabled, mirrored by
-- playVictoryMusic) and every other outcome tears it down in
-- end_of_battle.asm -- self.result covers those. The damage drain
-- gates the start (the HUD redraw runs after UpdateHPBar finishes),
-- but healing out of the red stops it at once (item_effects.asm clears
-- the alarm before the bar animates). No alarm before the player HUD
-- first draws (send-out), nor in the safari/old-man battles, which
-- have no player mon HUD.
function BattleState:lowHealthAlarmActive()
local p = self.player
if not p or self.safari or self.demo or self.result
or self.lowHealthAlarmDisabled then return false end
if self.showPlayerBack or (self.introSlide or 0) > 0 then return false end
local hp = p.mon.hp
if hp <= 0 or p.fainted then return false end
if p.shownHP and p.shownHP > hp then return false end -- drain running
local px = math.max(1, math.floor(hp * 48 / math.max(1, p.mon.stats.hp)))
return px < 10
end
-- advance a {dx/dy, frames} step program; returns the current step
local function stepProgram(prog)
local head = prog[1]
while head and head.frames <= 0 do
table.remove(prog, 1)
head = prog[1]
end
if head then head.frames = head.frames - 1 end
return head
end
function BattleState:updateFx()
if self.introSlide and self.introSlide > 0 then
self.introSlide = self.introSlide - 1
end
local fx = self.fx
if fx then
if fx.shake and fx.shake > 0 then fx.shake = fx.shake - 1 end
if fx.flash and fx.flash > 0 then fx.flash = fx.flash - 1 end
if fx.blink and fx.blink.frames > 0 then
fx.blink.frames = fx.blink.frames - 1
end
if fx.faint and fx.faint.frames > 0 then
fx.faint.frames = fx.faint.frames - 1
end
-- SE-driven screen offsets (window/SCX shakes)
fx.shakeX, fx.shakeY = 0, 0
if fx.shakeProg then
local st = stepProgram(fx.shakeProg)
if st then
fx.shakeX, fx.shakeY = st.dx or 0, st.dy or 0
else
fx.shakeProg = nil
end
end
fx.hudShakeX = 0
if fx.hudShakeProg then
local st = stepProgram(fx.hudShakeProg)
if st then
fx.hudShakeX = st.dx or 0
else
fx.hudShakeProg = nil
end
end
-- BGP flash sequences
local seq = fx.bgpSeq
if seq then
seq.left = seq.left - 1
if seq.left <= 0 then
seq.idx = seq.idx + 1
local st = seq.steps[seq.idx]
if st then
seq.left = st.frames
else
fx.bgpSeq = nil -- restore: activeBgp falls back to fx.bgp
end
end
end
if fx.wavy then
fx.wavy.left = fx.wavy.left - 1
fx.wavy.phase = fx.wavy.phase + 1
if fx.wavy.left <= 0 then fx.wavy = nil end
end
end
-- SE-driven pic effects: advance the per-battler programs and apply
-- their end states (timings in the SE_* handlers' comments)
if self.picFx then
for b, pf in pairs(self.picFx) do
if pf.kind then
pf.t = (pf.t or 0) + 1
local k, t = pf.kind, pf.t
if k == "slideOff" and t >= 24 then
pf.kind, pf.hidden = nil, true
elseif k == "slideHalf" and t >= 19 then
pf.kind = nil
pf.ox = b.isPlayer and -32 or 32 -- the pic stays half off
elseif k == "slideUp" and t >= 14 then
pf.kind = nil -- a full cyclic wrap lands back on the pic
elseif k == "slideDown" and t >= 21 then
pf.kind, pf.hidden = nil, true
elseif k == "slideDownHide" and t >= 19 then
pf.kind, pf.hidden = nil, true
elseif k == "shakeBF" and t >= 96 then
pf.kind, pf.hidden = nil, true -- the loop ends on a cleared pic
elseif k == "bounce" and t >= 105 then
pf.kind = nil -- AnimationShowMonPic after the last bounce
elseif k == "squish" and t >= 24 then
pf.kind, pf.hidden = nil, true
elseif k == "blink" and t >= 60 then
pf.kind = nil -- ends shown
end
end
end
end
-- the send-out grow-in (AnimateSendingOutMon): 3+4+5 frames, then
-- the pic draws at full size again
if self.growIn then
self.growIn.frame = self.growIn.frame + 1
if self.growIn.frame >= 12 then self.growIn = nil end
end
-- low-HP alarm (audio/low_health_alarm.asm): the two-tone siren
-- loops while the player's bar is red; see lowHealthAlarmActive
local Sound = require("src.core.Sound")
if self:lowHealthAlarmActive() then
Sound.startLoop(self.data, "Low_Health_Alarm")
else
Sound.stopLoop("Low_Health_Alarm")
end
end
-- ---------------------------------------------------------------------
-- move execution pipeline
-- ---------------------------------------------------------------------
function BattleState:executeAction(user, target, action)
if user.mon.hp <= 0 or target.mon.hp <= 0 then return end
if not action then return end
-- ghost battles: the ghost never attacks; its whole turn is the
-- GetOutText (ExecuteEnemyMove -> PrintGhostText, core.asm:5462-5463)
if self.ghost and not user.isPlayer then
self:sayNext(self.data.text._GetOutText or "GHOST: Get out...\nGet out...")
return
end
-- refresh the held-in-place mirror before the status checks (see
-- lockedAction): the victim is held exactly while the opponent's
-- trapping bit is set -- including a counter sitting at 0 until the
-- end-of-turn CheckNumAttacksLeft clear
user.boundTurns = target.trappingTurns
and math.max(1, target.trappingTurns) or nil
-- trainer class AI actions (engine/battle/trainer_ai.asm)
if action.special == "aiItem" then
self.aiUses = (self.aiUses or 1) - 1
for _, m in ipairs(TrainerAI.useItem(self, action.item)) do
self:sayNext(prefixEnemy(m, self.enemy))
end
self:drainNext()
require("src.core.Sound").play(self.data, "Heal_Ailment")
return
end
if action.special == "aiSwitch" then
self.aiUses = (self.aiUses or 1) - 1
local previous = self.enemy
local oldName = self.enemy.name
self.enemyIndex = action.index
self.enemy = makeBattler(self.data, self.enemyParty[action.index], false)
-- EnemySendOutFirstMon (core.asm:1314-1315): clears player's trap
clearTrapping(self.player)
self:syncSides()
Runtime.emit("battle.battler_switched", {
battle = self, side = self.sides[2], battler = self.enemy,
previous = previous,
})
self.aiUses = self:aiUsesFor()
markSeen(self.game, self.enemy.mon.species)
-- _AIBattleWithdrawText: "X with-/drew Y!"
self:sayNext(("%s with-\ndrew %s!"):format(self.trainer.name, oldName))
self:sayNext(("%s sent\nout %s!"):format(self.trainer.name, self.enemy.name))
return
end
-- special locked actions. All of them still run the status gauntlet:
-- CheckPlayerStatusConditions (core.asm:3328-3583) evaluates sleep ->
-- freeze -> held-in-place -> flinch -> recharge -> disable tick ->
-- confusion -> paralysis BEFORE the bide/thrash/trapping handling.
if action.special == "recharge" then
-- only reaching .HyperBeamCheck consumes the flag (core.asm:3384-
-- 3392): sleep/freeze/held/flinch keep the mon recharging next turn
if self:preRechargeChecks(user, target) then return end
user.mustRecharge = nil
self:sayNext(("%s\nmust recharge!"):format(displayName(user)))
return
end
if action.special == "bound" then
if not target.trappingTurns then
-- the trap ended earlier this turn: the CANNOT_MOVE selection is
-- simply lost (ExecutePlayerMove returns immediately on $ff)
return
end
-- sleep/freeze take precedence over the held-in-place message
if self:statusInterrupt(user, target) then return end
return
end
if action.special == "trapping" then
if self:statusInterrupt(user, target) then return end
self:continueTrapping(user, target)
return
end
if action.special == "bide" then
if self:statusInterrupt(user, target) then return end
self:continueBide(user, target)
return
end
if self:statusInterrupt(user, target) then return end
self:performMove(user, target, action, false)
end
-- Sleep / confusion onomatopoeia from Check*StatusConditions
-- (core.asm): side-specific SLP_*/CONF_* anims, not the Rest/Amnesia
-- move rows. Player sleep plays the anim before FastAsleepText;
-- enemy sleep and both confusion sides print the text first.
function BattleState:statusOnomatopoeia(user, kind)
local isPlayer = user.isPlayer
local anim
if kind == "sleep" then
anim = isPlayer and "SLP_PLAYER_ANIM" or "SLP_ANIM"
else
anim = isPlayer and "CONF_PLAYER_ANIM" or "CONF_ANIM"
end
local text = kind == "sleep"
and (displayName(user) .. "\nis fast asleep!")
or (displayName(user) .. "\nis confused!")
if kind == "sleep" and isPlayer then
self:animNext(anim, isPlayer)
self:sayNext(text)
else
self:sayNext(text)
self:animNext(anim, isPlayer)
end
end
-- Queue status text (+ sleep/confusion FX when the line matches).
-- Wake / snap-out / flinch / etc. stay text-only.
function BattleState:sayStatusMsg(user, msg)
local text = prefixEnemy(msg, user)
if msg:find("is fast asleep!", 1, true) then
self:statusOnomatopoeia(user, "sleep")
elseif msg:find("is confused!", 1, true) then
self:statusOnomatopoeia(user, "confused")
else
self:sayNext(text)
end
end
-- The pre-recharge slice of CheckPlayerStatusConditions (core.asm:
-- 3328-3382): sleep -> freeze -> held-in-place -> flinch, each losing
-- the turn WITHOUT consuming the recharge flag. The disable/confusion/
-- paralysis ticks come after the recharge consume in the asm, so they
-- must not run on a recharge turn. Mirrors Status.beforeMove's early
-- checks (kept there for normal moves).
function BattleState:preRechargeChecks(user, target)
if user.skipMove then -- Haze forfeit (selected move = CANNOT_MOVE)
user.skipMove = nil
return true
end
local mon = user.mon
if mon.status == "SLP" then
user.sleepTurns = (user.sleepTurns or 1) - 1
if user.sleepTurns <= 0 then
mon.status = nil
self:sayNext(displayName(user) .. "\nwoke up!")
else
self:statusOnomatopoeia(user, "sleep")
end
return true
end
if mon.status == "FRZ" then
self:sayNext(displayName(user) .. "\nis frozen solid!")
return true
end
if target.trappingTurns then
self:sayNext(displayName(user) .. "\ncan't move!")
return true
end
if user.flinched then
-- reachable: the turn-start flinch reset is skipped while the
-- player recharges, so the flinch eats the recharge turn and the
-- flag survives (the Hyper Beam flinch glitch)
user.flinched = false
self:sayNext(displayName(user) .. "\nflinched!")
return true
end
return false
end
-- Runs Status.beforeMove plus the shared interruption bookkeeping;
-- returns true when the user's action is interrupted.
function BattleState:statusInterrupt(user, target)
local canMove, msgs, selfHit = Status.beforeMove(user, self.rng, self)
for _, m in ipairs(msgs) do self:sayStatusMsg(user, m) end
if selfHit then
-- confusion self-hit (core.asm:3428-3434): clears everything in
-- status1 except CONFUSED, then HandleSelfConfusionDamage deals a
-- 40-power typeless hit against the mon's own defense -- with the
-- OPPONENT's Reflect still applying (the screen check keeps
-- reading the opponent's battle status)
local dmg = self:computeDamage(user, user,
{ id = "CONFUSED", power = 40, type = "NORMAL", accuracy = 100 },
{ rng = self.rng, forceCrit = false, typeless = true,
screens = target })
self:sayNext("It hurt itself in\nits confusion!")
self:clearVolatiles(user, true)
self:applyDamage(user, dmg)
if user.mon.hp <= 0 then self:onFaint(user) end
return true
end
if not canMove then
-- full paralysis (core.asm:3459-3464) clears bide/thrash/charge/
-- trapping; sleep, freeze, flinch and held-in-place leave every
-- volatile in place (a sleeping wrapper keeps its victim held)
if user.mon.status == "PAR" and msgs[#msgs]
and msgs[#msgs]:find("fully paralyzed", 1, true) then
self:clearVolatiles(user, false)
end
return true
end
return false
end
-- The status1 volatile clears shared by full paralysis and the
-- confusion self-hit. selfHit additionally clears INVULNERABLE and
-- FLINCHED (status1 &= CONFUSED); full paralysis does NOT touch
-- INVULNERABLE -- the famous Fly/Dig invulnerability glitch.
function BattleState:clearVolatiles(user, selfHit)
user.bideTurns, user.bideDamage = nil, nil
user.thrashTurns, user.thrashMove, user.thrashAnnounced = nil, nil, nil
user.charging, user.chargeReady = nil, nil
user.trappingTurns = nil -- the opponent is freed via the live mirror
if selfHit then
user.invulnerable = nil
user.flinched = false
end
end
-- performMove runs a move (possibly via Metronome/Mirror Move recursion).
-- Decomposed into a staged pipeline over the merged move_effects record:
-- announcement -> callsMove -> charge -> perform -> primary run -> the
-- damaging pipeline (EffectRegistry.runDamaging).
-- Gen 1 status/stat primary effects call PlayCurrentMoveAnimation only
-- after they land; these failure texts print with no animation.
local function primaryEffectFailed(msgs)
if not msgs or #msgs == 0 then return true end
local m = msgs[1]
if m == "But, it failed!" or m == "Nothing happened!" then return true end
if m:find("didn't affect", 1, true) then return true end
if m:find("is unaffected", 1, true) then return true end
if m:find("protected by MIST", 1, true) then return true end
if m:find("Already", 1, true) then return true end
return false
end
function BattleState:performMove(user, target, moveInst, isCalled)
local move = self:moveDef(moveInst)
if not move then
Logger.warn("unknown move instance %s", tostring(moveInst.id))
return
end
local record = self:effectRecord(move.effect)
-- charge release?
local releasing = user.charging == moveInst and user.chargeReady
if releasing then
user.charging, user.chargeReady, user.invulnerable = nil, nil, nil
end
-- PP: not for continuations, struggle, called moves, or (under
-- gen1_faithful) wild/trainer enemies — pokered DecrementPP only ever
-- mutates wBattleMonPP / party PP (engine/battle/decrement_pp.asm).
local isContinuation = releasing
or (user.thrashTurns and user.thrashTurns > 0 and moveInst == user.thrashMove)
or moveInst == user.rageMove
local enemyUnlimited = not user.isPlayer
and self.ruleset and self.ruleset.enemyUnlimitedPP
if not isContinuation and not moveInst.struggle and not isCalled
and not enemyUnlimited then
moveInst.pp = math.max(0, moveInst.pp - 1)
end
self.moveAnimRow = nil
if not (user.thrashTurns and moveInst == user.thrashMove and user.thrashAnnounced) then
self:sayNext(("%s\nused %s!"):format(displayName(user), move.name))
-- the move's animation plays right after the announcement; the
-- damage path attaches the target's hit blink to this row so the
-- blink follows the animation (pokered's order). Mimic is the
-- exception (announceAnim = false): PlayCurrentMoveAnimation runs
-- only after a successful copy, never on a miss -- applyMimic queues it
if not (record and record.announceAnim == false) then
self.nextInsert = (self.nextInsert or 0) + 1
self.moveAnimRow = { anim = move.id, attackerIsPlayer = user.isPlayer }
table.insert(self.queue, self.nextInsert, self.moveAnimRow)
end
end
Runtime.emit("battle.move_used", {
battle = self, user = user, target = target, move = move,
isCalled = isCalled or false,
})
local ctx = EffectRegistry.makeCtx(self, user, target, move, moveInst, isCalled)
-- Metronome / Mirror Move re-entry; a nil pick means the record
-- already said its failure text
if record and record.callsMove then
local pick = record.callsMove(ctx)
-- Mirror Move never plays its own anim (MetronomePickMove does;
-- MirrorMoveCopyMove only reloads the copied move or prints fail)
if move.id == "MIRROR_MOVE" or not pick then
self:cancelMoveAnim()
end
if pick then
self:performMove(user, target, { id = pick, pp = 1 }, true)
end
return
end
user.lastMove = move.id
-- charge moves: first turn just charges; the text comes from the move
-- record (chargeText) and the invulnerability from semiInvulnerable,
-- falling back to the id tables (Fly AND Dig go semi-invulnerable:
-- ChargeEffect sets INVULNERABLE for both)
if record and record.charge and not releasing then
self:cancelMoveAnim()
user.charging = moveInst
user.chargeReady = true
local invulnerable = move.semiInvulnerable
if invulnerable == nil then
invulnerable = record.charge.invulnerable or move.id == "DIG"
end
if invulnerable then
user.invulnerable = true
end
local chargeAnim = record.charge.anim
if move.id == "DIG" then
chargeAnim = "SLIDE_DOWN_ANIM"
elseif record.charge.enemyAnim and not user.isPlayer then
chargeAnim = record.charge.enemyAnim
end
if chargeAnim then
self:animNext(chargeAnim, user.isPlayer)
end
local chargeText = move.chargeText or CHARGE_TEXT[move.id]
or "%s\nis charging up!"
self:sayNext(chargeText:format(displayName(user)))
return
end
-- fully custom resolution (Bide, Roar/Teleport, Mimic)
if record and record.perform then
record.perform(ctx)
return
end
-- pure status moves
if move.power == 0 and record and record.kind == "primary" and record.run then
-- accuracy-checked status effects run MoveHitTest, which has no
-- 100%-accuracy early-out (even Thunder Wave misses on the 255
-- roll) and misses outright against a mid-Fly/Dig target; the
-- never-miss paths (X ACCURACY) live inside Damage.accuracyRoll
if record.accuracyChecked
and (target.invulnerable
or not self:accuracyRoll(move, user, target)) then
-- SleepEffect/PoisonEffect/... call PlayCurrentMoveAnimation only
-- after the effect lands; a miss skips it
self:cancelMoveAnim()
self:sayNext(("%s's\nattack missed!"):format(displayName(user)))
return
end
local msgs = record.run(ctx)
-- Gen 1 status/stat effects animate only when they take effect
-- (AlreadyAsleep / NothingHappened / ButItFailed print with no anim)
if primaryEffectFailed(msgs) then
self:cancelMoveAnim()
end
for _, m in ipairs(msgs) do
self:sayNext(m)
end
self:drainNext() -- REST/RECOVER/SOFTBOILED move the user's bar
return
end
if move.power == 0 and not (record and record.kind == "full") then
MoveEffects.warnUnknown(move.effect)
self:cancelMoveAnim()
self:sayNext("But, it failed!")
return
end
-- damaging pipeline, driven by the record's stage callbacks
EffectRegistry.runDamaging(self, ctx, record)
end
function BattleState:continueTrapping(user, target)
self:sayNext(("%s's\nattack continues!"):format(displayName(user)))
-- .MultiturnMoveCheck (core.asm:3554-3566) prints AttackContinuesText
-- then jumps to GetPlayerAnimationType, so the trapping move's full
-- animation replays each locked turn (same damage, animation shown).
-- Mirror performMove's anim row (BattleState.lua ~1307), gated on the
-- OPTIONS animation toggle.
if user.trapMove and self:animationsOn() then
self.nextInsert = (self.nextInsert or 0) + 1
table.insert(self.queue, self.nextInsert,
{ anim = user.trapMove, attackerIsPlayer = user.isPlayer })
end
-- the counter can sit at 0 until the END of the turn: the trapping
-- bit is only cleared by CheckNumAttacksLeft (core.asm:439/467)
-- after BOTH battlers acted, so a slower victim is still held
-- through the attacker's final hit (endOfTurn nils it)
user.trappingTurns = user.trappingTurns - 1
self:applyDamage(target, user.trapDamage or 1)
if target.mon.hp <= 0 then self:onFaint(target) end
end
function BattleState:continueBide(user, target)
user.bideTurns = user.bideTurns - 1
if user.bideTurns > 0 then
self:sayNext(("%s\nis storing energy!"):format(displayName(user)))
return
end
self:sayNext(("%s\nunleashed energy!"):format(displayName(user)))
local dmg = (user.bideDamage or 0) * 2
user.bideTurns, user.bideDamage = nil, nil
if dmg <= 0 then
self:cancelMoveAnim()
self:sayNext("But, it failed!")
return
end
self:applyDamage(target, dmg)
if target.mon.hp <= 0 then self:onFaint(target) end
end
function BattleState:selfDestruct(user)
user.mon.hp = 0
self:onFaint(user)
end
-- Applies damage honoring Substitute, Bide storage and Rage; returns the
-- amount that counts as dealt (for recoil/drain).
function BattleState:applyDamage(target, dmg)
if target.substituteHP then
target.substituteHP = target.substituteHP - dmg
if target.substituteHP <= 0 then
target.substituteHP = nil
self:sayNext(("%s's\nSUBSTITUTE broke!"):format(displayName(target)))
else
self:sayNext(("The SUBSTITUTE\ntook damage for\n%s!"):format(displayName(target)))
end
return dmg
end
local dealt = math.min(dmg, target.mon.hp)
target.mon.hp = target.mon.hp - dealt
if dealt > 0 then self:drainNext() end -- animate the bar down
if target.bideTurns then
target.bideDamage = (target.bideDamage or 0) + dealt
end
if target.rageMove and dealt > 0 then
target.stages.attack = math.min(6, (target.stages.attack or 0) + 1)
self:sayNext(("%s's\nRAGE is building!"):format(displayName(target)))
end
return dealt
end
-- ---------------------------------------------------------------------
-- fainting / exp / party
-- ---------------------------------------------------------------------
function BattleState:onFaint(battler)
if battler.faintQueued then return end
battler.faintQueued = true
if battler.isPlayer and self.participants then
self.participants[battler.mon] = nil
end
Runtime.emit("battle.fainted", { battle = self, battler = battler })
-- the faint slide + cry ride the queue (after the move animation and
-- the HP-bar drain, pokered's order); the slide finishes before the
-- faint text via a queued hold
self:actNext(function()
battler.fainted = true
local Sound = require("src.core.Sound")
Sound.playCry(self.data, battler.mon.species)
Sound.play(self.data, "Faint_Fall")
self.fx = self.fx or {}
self.fx.faint = { battler = battler, frames = 30 }
end)
self.nextInsert = (self.nextInsert or 0) + 1
table.insert(self.queue, self.nextInsert, { wait = 30 })
if not battler.isPlayer and self.kind == "wild" then
-- FaintEnemyPokemon .wild_win (core.asm:792-795): beating a wild
-- mon calls EndLowHealthAlarm and starts MUSIC_DEFEATED_WILD_MON
-- as the slide lands, BEFORE EnemyMonFaintedText and the exp text;
-- trainer battles keep the battle theme until TrainerBattleVictory.
-- (Starting it even when the player mon dropped too matches the
-- acknowledged core.asm:797-798 bug.)
self:actNext(function() self:playVictoryMusic() end)
end
-- _EnemyMonFaintedText "Enemy X fainted!" / _PlayerMonFaintedText
self:sayNext(("%s\nfainted!"):format(displayName(battler)))
if battler.isPlayer then
self:act(function() self:playerMonFainted() end)
else
self:act(function() self:enemyMonFainted() end)
end
end
function BattleState:enemyMonFainted()
-- exp is split among the mons that fought this enemy
-- (engine/battle/experience.asm); traded mons earn x1.5; each
-- participant gets the full stat exp
-- a mon that fainted mid-fight has had its gain-exp flag cleared
-- (RemoveFaintedPlayerMon), so it drops out of the divisor and only
-- the surviving participants are counted and paid
local participants, alive = 0, {}
for _, mon in ipairs(self.game.save.party) do
if self.participants and self.participants[mon] then
participants = participants + 1
if mon.hp > 0 then table.insert(alive, mon) end
end
end
if participants == 0 and self.player.mon.hp > 0 then
participants, alive = 1, { self.player.mon }
end
local function applyShare(mon, split, announce)
local levels, gained = Experience.apply(self.data, mon, self.enemy.def,
self.enemy.mon.level, self.kind == "trainer",
split, mon.traded)
Runtime.emit("battle.exp_gained", {
battle = self, mon = mon, gained = gained, levels = levels,
})
local name = mon.nickname or self.data.pokemon[mon.species].name
if announce then
-- GainedText (experience.asm:342-354): "X gained" plus one of
-- _WithExpAllText / _BoostedText / _ExpPointsText; the EXP.ALL
-- pass beats the traded boost (wBoostExpByExpAll checks first),
-- and _ExpPointsText prints wExpAmountGained -- the raw share,
-- captured before the max-level cap (experience.asm:92-100)
local tail = "%d EXP. Points!"
if announce == "expAll" then
tail = "with EXP.ALL,\n" .. tail
elseif mon.traded then
tail = "a boosted\n" .. tail
end
self:sayNext(("%s gained\n" .. tail):format(name, gained))
end
-- per level: GrewLevelText -> the stats window (PrintStatsBox) ->
-- the move-learn checks (experience.asm:245-256)
local game = self.game
for _, lv in ipairs(levels) do
self:sayNext(("%s grew\nto level %d!"):format(name, lv))
self:uiNext(function()
require("src.core.Sound").play(game.data, "Level_Up")
return StatBox.new(game, mon)
end)
for _, moveId in ipairs(Experience.movesLearnedAt(
self.data.pokemon[mon.species], lv)) do
self:learnMove(mon, moveId)
end
end
end
-- with EXP.ALL, participants split half the exp and the other half
-- is divided among the whole party (engine/battle/experience.asm)
local expAll = (self.game.save.inventory.EXP_ALL or 0) > 0
for _, mon in ipairs(alive) do
applyShare(mon, participants * (expAll and 2 or 1), true)
end
if expAll then
-- the second GainExperience pass sets the gain flags for the WHOLE
-- party, so DivideExpDataByNumMonsGainingExp divides the already
-- halved-and-participant-divided exp again by the party count, and
-- .partyMonLoop still skips fainted mons (core.asm:818-858 +
-- experience.asm:9-13); each mon gets its own GainedText with the
-- "with EXP.ALL," tail (wBoostExpByExpAll) -- pokered prints no
-- summary line
for _, mon in ipairs(self.game.save.party) do
if mon.hp > 0 then
applyShare(mon, math.max(1, participants) * #self.game.save.party * 2, "expAll")
end
end
end
self.participants = {}
if self.kind == "trainer" then
-- EnemySendOutFirstMon / AnyEnemyPokemonAliveCheck (core.asm): scan
-- the whole enemy party for the first mon with HP left. Blindly
-- doing enemyIndex+1 softlocks after an AI switch (Agatha): a later
-- slot can already be fainted, so the empty-HP mon comes out, the
-- FIGHT menu returns, and executeAction no-ops on target.hp <= 0.
local nextIndex
for i, mon in ipairs(self.enemyParty) do
if mon.hp > 0 then
nextIndex = i
break
end
end
if nextIndex then
self.enemyIndex = nextIndex
-- SHIFT battle style (the default): announce the next mon and
-- offer a free switch (SET skips the prompt)
local nextMon = self.enemyParty[self.enemyIndex]
local nextName = nextMon.nickname or self.data.pokemon[nextMon.species].name
local style = (self.game.save.options or {}).battleStyle or "shift"
local healthy = 0
for _, mon in ipairs(self.game.save.party) do
if mon.hp > 0 then healthy = healthy + 1 end
end
if style ~= "set" and healthy > 1 and self.player.mon.hp > 0 then
self:say(("%s is\nabout to use\n%s!"):format(self.trainer.name, nextName))
self:say(("Will %s\nchange POKéMON?"):format(self.game.save.player.name))
local game = self.game
self:ui(function()
local ChoiceBox = require("src.ui.ChoiceBox")
return ChoiceBox.new(game, function(yes)
if not yes then return end
Screens.push(game, "PartyMenu", {
battle = self,
onSwitch = function(mon)
if mon ~= self.player.mon and mon.hp > 0 then
local previous = self.player
self.player = makeBattler(self.data, mon, true, game.save)
clearTrapping(self.enemy) -- SendOutMon clears foe trap
self:syncSides()
Runtime.emit("battle.battler_switched", {
battle = self, side = self.sides[1],
battler = self.player, previous = previous,
})
self:markParticipant()
self.nextInsert = 0
self.sendingOut = true
self:sayNext(self:sendOutText(self.player.name))
self:animNext("POOF_ANIM", false)
self:actNext(function()
self.sendingOut = false
-- SendOutMon (core.asm:1757-1762): poof, then the grow-in
self:startGrowIn(self.player)
require("src.core.Sound").playCry(self.data, self.player.mon.species)
end)
end
end,
})
end)
end)
end
self:act(function()
local previous = self.enemy
self.enemy = makeBattler(self.data, self.enemyParty[self.enemyIndex], false)
-- EnemySendOutFirstMon (core.asm:1314-1315): clears player's trap
clearTrapping(self.player)
self:syncSides()
Runtime.emit("battle.battler_switched", {
battle = self, side = self.sides[2], battler = self.enemy,
previous = previous,
})
self.aiUses = self:aiUsesFor()
markSeen(self.game, self.enemy.mon.species)
self:markParticipant()
-- EnemySendOutFirstMon (core.asm:1413-1435): the enemy HUD area
-- clears, TrainerSentOutText prints, THEN the pic appears
-- (AnimateSendingOutMon) with the cry; no POOF -- that animation
-- belongs to the player-side SendOutMon (core.asm:1757-1762)
self.enemySendingOut = true
self:sayNext(("%s sent\nout %s!"):format(self.trainer.name, self.enemy.name))
self:actNext(function()
self.enemySendingOut = false
self:startGrowIn(self.enemy)
self:actNext(function()
require("src.core.Sound").playCry(self.data, self.enemy.mon.species)
end)
end)
end)
return
end
local prize = (self.trainer.baseMoney or 0) * self.enemy.mon.level
self.game.save.money = self.game.save.money + prize
-- the beaten trainer's pic returns for the defeat text (pokered
-- DisplayBattleMenu's defeat flow)
self:act(function() self.showEnemyTrainer = self.trainerPic ~= nil end)
-- TrainerBattleVictory (core.asm:915-933): EndLowHealthAlarm, then
-- the victory theme starts BEFORE TrainerDefeatedText and the
-- prize money
self:actNext(function() self:playVictoryMusic() end)
-- _TrainerDefeatedText: "<PLAYER> defeated\nTRAINER!"
self:sayNext(("%s defeated\n%s!"):format(self.game.save.player.name,
self.trainer.name))
self:sayNext(("%s got ¥%d\nfor winning!"):format(self.game.save.player.name, prize))
end
self.result = "win"
self.afterQueue = "finish"
end
-- Queue the learn-a-move flow (auto if a slot is free, else the forget UI)
function BattleState:learnMove(mon, moveId)
local mdef = self.data.moves[moveId]
if not mdef then return end
for _, mv in ipairs(mon.moves) do
if mv.id == moveId then return end
end
if #mon.moves < 4 then
table.insert(mon.moves, { id = moveId, pp = mdef.pp })
Runtime.emit("pokemon.move_learned", { mon = mon, moveId = moveId })
self:sayNext(("%s learned\n%s!"):format(mon.nickname or self.data.pokemon[mon.species].name,
mdef.name))
return
end
-- the "trying to learn" preamble lives inside MoveLearnMenu:enter;
-- ordered insert so multi-level gains keep each level's checks
-- between its own stat box and the next "grew to level" text
self:uiNext(function()
return self:buildScreen("MoveLearnMenu", mon, moveId)
end)
end
-- Map the battle was fought on (overworld wins; save.player.map is fallback).
function BattleState.currentMapId(self)
local game = self.game
local ow = game and game.overworld
if ow and ow.map then return ow.map.id end
local player = game and game.save and game.save.player
return player and player.map
end
-- pret HandlePlayerBlackOut: OPP_RIVAL1 in OAKS_LAB prints Rival1WinText
-- and returns without blacking out. OaksLabRivalEndBattleScript then heals.
function BattleState.isOaksLabStarterRival(self)
return self.oppClass == "OPP_RIVAL1"
and BattleState.currentMapId(self) == "OAKS_LAB"
end
function BattleState:playerMonFainted()
local nextMon = Party.firstHealthy(self.game.save.party)
-- Being out of useable POKéMON blacks you out even when the battle was
-- already decided in our favour. A double faint -- our last mon dying
-- to residual damage on the turn it lands the KO -- used to hit the
-- "battle is decided" guard below and return with result = "win", so
-- afterBattle never took the lose branch: no revive, no warp to the
-- heal point, and the player was left standing on the map with a party
-- at 0 HP. Nothing recovers from that state (every later encounter
-- aborts with "no healthy party"), and it is not reachable in pokered:
-- HandlePlayerMonFainted runs the player-side check on its own, so
-- losing your last mon always blacks you out whatever the enemy did.
-- Exception: the Oak's Lab starter rival (HandlePlayerBlackOut).
if not nextMon and self.result ~= "lose" then
if self.oppClass == "OPP_RIVAL1" then
local TextBox = require("src.render.TextBox")
local raw = (self.data.text and self.data.text._Rival1WinText)
or "{RIVAL}: Yeah! Am\nI great or what?"
self:sayNext(TextBox.substitute(self.game, raw))
end
-- Oak's Lab starter rival: Rival1WinText only (no blackout lines).
-- Any other wipe, including Route 22 RIVAL1, still blacks out.
if not BattleState.isOaksLabStarterRival(self) then
self:sayNext(("%s is out of\nuseable POKéMON!"):format(self.game.save.player.name))
self:sayNext(("%s blacked\nout!"):format(self.game.save.player.name))
end
self.result = "lose"
self.afterQueue = "finish"
return
end
if self.result then return end -- double faint: the battle is decided
-- DoUseNextMonDialogue (core.asm:1052-1078): only WILD battles ask
-- "Use next POKéMON?"; NO goes through the run check with party slot
-- 1's speed, and a failed run still forces the party menu. Trainer
-- battles go straight to the party menu (the menu-phase guard).
if self.kind ~= "wild" then return end
local game = self.game
self:say(self.data.text._UseNextMonText or "Use next POKéMON?")
self:ui(function()
local ChoiceBox = require("src.ui.ChoiceBox")
return ChoiceBox.new(game, function(yes)
if yes then return end -- the menu-phase guard opens the party menu
local pSpd = (game.save.party[1].stats or { speed = 0 }).speed or 0
if self:runRoll(pSpd, TurnOrder.effectiveSpeed(self.enemy)) then
require("src.core.Sound").play(self.data, "Run")
self:say("Got away safely!")
self.result = "run"
self.afterQueue = "finish"
else
self:say("Can't escape!")
end
end)
end)
end
-- ChooseNextMon (core.asm:1086-1128): the battle party menu; a fainted
-- pick re-prompts (via the menu-phase guard), a healthy pick is sent
-- out with no free enemy move.
function BattleState:openReplacementMenu()
local game = self.game
self.phase = "messages"
self.afterQueue = "menu"
self:ui(function()
return self:buildScreen("PartyMenu", {
battle = self,
onSwitch = function(mon)
if mon.hp <= 0 then
self:say("There's no will\nto fight!")
return -- the menu-phase guard reopens the menu
end
self:restoreMimicked(self.player)
local previous = self.player
self.player = makeBattler(self.data, mon, true, game.save)
clearTrapping(self.enemy) -- SendOutMon clears foe trap
self:syncSides()
Runtime.emit("battle.battler_switched", {
battle = self, side = self.sides[1], battler = self.player,
previous = previous,
})
self:markParticipant()
self.nextInsert = 0
self.sendingOut = true
self:sayNext(self:sendOutText(self.player.name))
self:animNext("POOF_ANIM", false)
self:actNext(function()
self.sendingOut = false
-- SendOutMon (core.asm:1757-1762): poof, then the grow-in
self:startGrowIn(self.player)
require("src.core.Sound").playCry(self.data, self.player.mon.species)
end)
end,
})
end)
end
-- ---------------------------------------------------------------------
-- Safari game turns
-- ---------------------------------------------------------------------
-- BAIT halves the working catch rate and raises the bait factor by 1-5
-- (zeroing the escape factor); ROCK doubles the catch rate and raises
-- the escape factor by 1-5 (zeroing bait) -- ItemUseBait/ItemUseRock,
-- engine/items/item_effects.asm.
function BattleState:safariAction(choice)
self.phase = "messages"
self.afterQueue = "menu"
local st = self.safari
local playerName = self.game.save.player.name
if choice == "run" then
require("src.core.Sound").play(self.data, "Run")
self:say("Got away safely!")
self.result = "run"
self.afterQueue = "finish"
return
end
if choice == "ball" then
st.balls = st.balls - 1
self:say(("%s used\nSAFARI BALL!"):format(playerName))
self:act(function()
require("src.core.Sound").play(self.data, "Ball_Toss")
self.lastBall = "SAFARI_BALL"
local caught, shakes = self:catchAttempt("SAFARI_BALL", self.safariCatchRate)
Runtime.emit("battle.ball_thrown", {
battle = self, ball = "SAFARI_BALL", caught = caught, shakes = shakes,
})
-- SAFARI_BALL is neither POKE nor GREAT, so TossBallAnimation
-- lands on the ULTRATOSS arc (no flicker: SAFARI_BALL is $08,
-- above DoBallTossSpecialEffects's <= ULTRA_BALL check)
self:ballChain(self:tossAnimFor("SAFARI_BALL"), caught, shakes, "SAFARI_BALL")
if caught then
-- ItemUseBallText05's sound_caught_mon: fanfare with the text
self:actNext(function()
require("src.core.Sound").play(self.data, "Caught_Mon")
end)
self:sayNext(("All right!\n%s was\ncaught!"):format(self.enemy.name))
-- same ItemUseBall .captured flow as a regular ball
self:act(function() self:storeCaughtMon() end)
else
self:sayNext(self:ballMissMessage(shakes))
self:act(function() self:safariEnemyTurn() end)
end
end)
return
end
if choice == "bait" then
self:say(("%s threw some\nBAIT."):format(playerName))
self.safariCatchRate = math.floor(self.safariCatchRate / 2)
self.baitFactor = math.min(255, self.baitFactor + self.rng(1, 5))
self.escapeFactor = 0
else -- rock
self:say(("%s threw a\nROCK."):format(playerName))
self.safariCatchRate = math.min(255, self.safariCatchRate * 2)
self.escapeFactor = math.min(255, self.escapeFactor + self.rng(1, 5))
self.baitFactor = 0
end
self:act(function() self:safariEnemyTurn() end)
end
-- Per-turn factor decay (PrintSafariZoneBattleText,
-- engine/battle/safari_zone.asm: when the escape factor runs out the
-- catch rate resets) then the flee check (engine/battle/core.asm:
-- b = 2*speed, quartered while eating, doubled while angry; the mon
-- flees when speed > 127 or rand(0,255) < b).
function BattleState:safariEnemyTurn()
if self.baitFactor > 0 then
self.baitFactor = self.baitFactor - 1
self:sayNext(("Wild %s\nis eating!"):format(self.enemy.name))
elseif self.escapeFactor > 0 then
self.escapeFactor = self.escapeFactor - 1
if self.escapeFactor == 0 then
self.safariCatchRate = self.enemy.def.catchRate
end
self:sayNext(("Wild %s\nis angry!"):format(self.enemy.name))
end
self:act(function()
local speed = self.enemy.curStats.speed % 256
local fled = speed > 127
local b = (speed * 2) % 256
if not fled then
if self.baitFactor > 0 then
b = math.floor(b / 4)
end
if self.escapeFactor > 0 then
b = math.min(255, b * 2)
end
fled = self.rng(0, 255) < b
end
if fled then
self:sayNext(("Wild %s\nran!"):format(self.enemy.name))
self:actNext(function()
require("src.core.Sound").play(self.data, "Run")
startPicKind(self:picFxFor(self.enemy), "slideOff")
end)
self.nextInsert = (self.nextInsert or 0) + 1
table.insert(self.queue, self.nextInsert, { wait = 24 })
self.result = "run"
self.afterQueue = "finish"
end
end)
end
-- ---------------------------------------------------------------------
-- run / items / party
-- ---------------------------------------------------------------------
-- Gen 1 escape formula (engine/battle/core.asm TryRunningFromBattle),
-- shared by the RUN menu choice and the faint dialogue's NO branch;
-- counts a run attempt each call. Hooked as battle.run.
function BattleState:runRoll(pSpd, eSpd)
self.runAttempts = (self.runAttempts or 0) + 1
if Runtime.wantsHook("battle.run") then
local battle = self
return Runtime.call("battle.run", function(c)
return battle:runRollVanilla(c.pSpd, c.eSpd)
end, { battle = self, pSpd = pSpd, eSpd = eSpd,
attempts = self.runAttempts, rng = self.rng })
end
return self:runRollVanilla(pSpd, eSpd)
end
function BattleState:runRollVanilla(pSpd, eSpd)
if self.ghost then
return true -- IsGhostBattle -> always escapes
end
if pSpd >= eSpd then return true end
local b = math.floor(eSpd / 4) % 256
if b == 0 then
return true -- divisor of zero auto-escapes
end
local x = math.floor(pSpd * 32 / b)
-- +30 per PREVIOUS attempt, escape on 8-bit overflow or on
-- rand <= x (the original's jr nc keeps the equal case)
x = x + 30 * (self.runAttempts - 1)
return x >= 256 or self.rng(0, 255) <= x
end
-- Gen 1 escape formula (engine/battle/core.asm TryRunningFromBattle)
function BattleState:tryRun()
self.phase = "messages"
self.afterQueue = "menu"
if self.kind == "trainer" then
self:say("No! There's no\nrunning from a\ntrainer battle!")
return
end
-- modified in-battle speeds (stat stages + paralysis), like the
-- wBattleMonSpeed the original hands to TryRunningFromBattle
local escaped = self:runRoll(TurnOrder.effectiveSpeed(self.player),
TurnOrder.effectiveSpeed(self.enemy))
if escaped then
require("src.core.Sound").play(self.data, "Run")
self:say("Got away safely!")
self.result = "run"
self.afterQueue = "finish"
else
self:say("Can't escape!")
self:act(function()
self:executeAction(self.enemy, self.player, self:enemyAction())
end)
self:act(function() self:endOfTurn() end)
end
end
function BattleState:openItems()
self.phase = "messages"
self.afterQueue = "menu"
self:ui(function()
return self:buildScreen("BagMenu", { battle = self })
end)
end
-- called by BagMenu after an item is used in battle (consumes the turn)
function BattleState:itemUsed(messages)
for _, m in ipairs(messages or {}) do self:say(m) end
table.insert(self.queue, { drain = true }) -- potions animate the bar
self:act(function()
self:executeAction(self.enemy, self.player, self:enemyAction())
end)
self:act(function() self:endOfTurn() end)
end
-- Wobble messages by shake count (ItemUseBallText01..04)
function BattleState:ballMissMessage(shakes)
local t = self.data.text
if shakes == 0 then
return t._ItemUseBallText01 or "You missed the\nPOKéMON!"
elseif shakes == 1 then
return t._ItemUseBallText02 or "Darn! The POKéMON\nbroke free!"
elseif shakes == 2 then
return (t._ItemUseBallText03 or "Aww! It appeared\nto be caught!"):gsub("%s+$", "")
end
return t._ItemUseBallText04 or "Shoot! It was so\nclose too!"
end
-- The caught mon joins the party or a PC box (ItemUseBall .captured,
-- item_effects.asm:518-566): the caught text, then for a NEW species
-- "New POKéDEX data will be added" + the dex entry page, then the
-- party add (with the nickname ask) or the PC transfer text.
function BattleState:storeCaughtMon()
-- ItemUseBall reloads the caught mon via LoadEnemyMonData
-- (item_effects.asm:472-501), regenerating its move list from the
-- base data -- a Mimic'd slot never leaves the battle with it
self:restoreMimicked(self.enemy)
local game = self.game
local dex = game.save.pokedex
local species = self.enemy.mon.species
local isNew = dex ~= nil and not dex.owned[species]
local destination = "party"
markOwned(game, species)
stampOT(game.save, self.enemy.mon)
if isNew then
-- _ItemUseBallText06 + ShowPokedexData
self:sayNext(("New POKéDEX data\nwill be added for\n%s!"):format(self.enemy.name))
self:uiNext(function()
return self:buildScreen("DexEntryMenu", species)
end)
end
if Party.add(game.save.party, self.enemy.mon) then
-- nickname prompt (AskName runs inside AddPartyMon; box mons are
-- never offered a nickname)
local caught = self.enemy.mon
local enemyName = self.enemy.name
self:uiNext(function()
local ChoiceBox = require("src.ui.ChoiceBox")
local TextBox = require("src.render.TextBox")
return TextBox.new(game, ("Do you want to\ngive a nickname\nto %s?")
:format(enemyName), function()
game.stack:push(ChoiceBox.new(game, function(yes)
if not yes then return end
pcall(Screens.push, game, "NamingScreen", {
title = "NICKNAME?", maxLen = 10,
onDone = function(name)
if name and #name > 0 then caught.nickname = name end
end,
})
end))
end)
end)
else
destination = "box"
local boxNum = require("src.pokemon.Boxes").deposit(game.save, self.enemy.mon)
if boxNum then
-- _ItemUseBallText07/08 keyed on EVENT_MET_BILL
local pc = (game.save.flags and game.save.flags.EVENT_MET_BILL)
and "BILL's PC" or "someone's PC"
self:sayNext(("%s was\ntransferred to\n%s!"):format(self.enemy.name, pc))
else
self:sayNext("But every BOX\nis full!")
end
end
Runtime.emit("pokemon.caught", {
battle = self, mon = self.enemy.mon, species = species, isNew = isNew,
ball = self.lastBall, destination = destination, game = game,
})
self.result = "caught"
self.afterQueue = "finish"
end
-- TossBallAnimation (engine/battle/animations.asm:2582): the tier's toss
-- anim, then wPokeBallAnimData's upper-nybble count of .PokeBallAnimations
-- entries -- POOF+HIDEPIC+SHAKE for a capture ($43), all five (plus a
-- reappearing POOF+SHOWPIC) for a breakout ($6x); a clean miss ($20)
-- stops after the poof, so the mon never hides
function BattleState:ballChain(tossAnim, caught, shakes, ball)
self:animNext(tossAnim, true, nil, ball)
self:animNext("POOF_ANIM", true)
if not caught and shakes == 0 then return end
self:animNext("HIDEPIC_ANIM", true)
self:animNext("SHAKE_ANIM", true, shakes)
if not caught then
self:animNext("POOF_ANIM", true)
self:animNext("SHOWPIC_ANIM", true)
return
end
-- on a capture the $43 chain simply ends after SHAKE_ANIM
-- (TossBallAnimation returns): the GB leaves the resting closed ball
-- in OAM, so it stays on screen through the caught text
self:actNext(function()
self.lockedBall = self.animPlayer and self.animPlayer:finalSprites() or nil
end)
end
-- TossBallAnimation picks the toss arc from the ball record's tossAnim;
-- an unknown ball keeps the wCurItem mapping: POKE->TOSS,
-- GREAT->GREATTOSS, everything else (ULTRA/MASTER/SAFARI...)->ULTRATOSS
function BattleState:tossAnimFor(ball)
local def = self:ballDef(ball)
if def and def.tossAnim then return def.tossAnim end
return ball == "POKE_BALL" and "TOSS_ANIM"
or ball == "GREAT_BALL" and "GREATTOSS_ANIM"
or "ULTRATOSS_ANIM"
end
-- the Master/Ultra OBJ-palette flicker, from the ball record
function BattleState:ballFlicker(ball)
local def = self:ballDef(ball)
return (def and def.flicker) or false
end
-- called by BagMenu when a ball is thrown
function BattleState:throwBall(ball)
self:say(("%s used\n%s!"):format(self.game.save.player.name,
self.data.items[ball].name))
self:act(function()
require("src.core.Sound").play(self.data, "Ball_Toss")
if self.kind ~= "wild" then
self:sayNext("The TRAINER\nblocked the BALL!")
self:sayNext("Don't be a thief!")
return
end
if self.ghost then
-- ItemUseBall's can't-be-caught path (item_effects.asm:149-153):
-- the ball is thrown (TossBallAnimation still picks the arc from
-- wCurItem, so a Master/Ultra toss keeps its flicker), dodged
-- ($10 anim data, no wobbles), and the turn is spent like any
-- failed throw
self:animNext(self:tossAnimFor(ball), true, nil, ball)
self:sayNext("It dodged the\nthrown BALL!")
self:sayNext("This POKéMON\ncan't be caught!")
self:act(function()
self:executeAction(self.enemy, self.player, self:enemyAction())
end)
self:act(function() self:endOfTurn() end)
return
end
self.lastBall = ball
local caught, shakes = self:catchAttempt(ball)
Runtime.emit("battle.ball_thrown", {
battle = self, ball = ball, caught = caught, shakes = shakes,
})
-- ItemUseBall's 20-frame beat, then the toss chain for the outcome
self.nextInsert = (self.nextInsert or 0) + 1
table.insert(self.queue, self.nextInsert, { wait = 20 })
self:ballChain(self:tossAnimFor(ball), caught, shakes, ball)
if caught then
-- ItemUseBallText05 carries sound_caught_mon (item_effects.asm:
-- 608-614): the fanfare sounds with the caught message, before
-- the prompt, not after the text is dismissed
self:actNext(function()
require("src.core.Sound").play(self.data, "Caught_Mon")
end)
self:sayNext(("All right!\n%s was\ncaught!"):format(self.enemy.name))
self:act(function() self:storeCaughtMon() end)
else
self:sayNext(self:ballMissMessage(shakes))
self:act(function()
self:executeAction(self.enemy, self.player, self:enemyAction())
end)
self:act(function() self:endOfTurn() end)
end
end)
end
function BattleState:openParty()
self.phase = "messages"
self.afterQueue = "menu"
self:ui(function()
return self:buildScreen("PartyMenu", {
battle = self,
onSwitch = function(mon)
if mon == self.player.mon then
self:say(("%s is\nalready out!"):format(self.player.name))
elseif mon.hp <= 0 then
self:say("There's no will\nto fight!")
else
self:resolveSwitch(mon)
end
end,
})
end)
end
-- PlayBattleVictoryMusic (core.asm:959-967) + EndLowHealthAlarm
-- (core.asm:864-872): winning stops the low-health alarm and disables
-- it for the rest of the battle (wLowHealthAlarmDisabled), then starts
-- the victory theme once; gym leaders, Lance and the final rival share
-- MUSIC_DEFEATED_GYM_LEADER (core.asm:917-926).
function BattleState:playVictoryMusic()
require("src.core.Sound").stopLoop("Low_Health_Alarm")
self.lowHealthAlarmDisabled = true
if self.victoryMusicPlayed then return end
self.victoryMusicPlayed = true
local kind = self.musicKind == "final" and "gym" or (self.musicKind or "wild")
require("src.core.Music").playVictory(self.data, kind)
end
function BattleState:finish()
if self.payDay and self.result == "win" then
self.game.save.money = self.game.save.money + self.payDay
self:say(("%s picked up\n¥%d!"):format(self.game.save.player.name, self.payDay))
self.payDay = nil
self.afterQueue = "finish"
self.phase = "messages"
return
end
-- Invariant: a battle can never hand the overworld a party with nothing
-- healthy in it -- except the Oak's Lab starter rival, where pret skips
-- the blackout and OaksLabRivalEndBattleScript HealParty's immediately.
-- afterBattle only revives and warps to the heal point on a normal
-- "lose", so any other result here strands the player at 0 HP with no
-- way back -- an unrecoverable state, not merely a wrong one.
-- playerMonFainted is the path that should have caught this; if we land
-- here it did not, so say so rather than silently papering over it.
if self.result ~= "lose" and not Party.firstHealthy(self.game.save.party) then
Logger.warn("battle finished %s with no healthy party; forcing blackout",
tostring(self.result))
self.result = "lose"
end
self.lockedBall = nil
-- pokered never writes Mimic's copy into the party struct; leaving
-- battle discards the battle copy, so the original ids come back
self:restoreMimicked(self.player)
self:restoreMimicked(self.enemy)
-- end_of_battle.asm clears wLowHealthAlarm at battle teardown
require("src.core.Sound").stopLoop("Low_Health_Alarm")
-- the victory theme already started when the win was decided
-- (FaintEnemyPokemon .wild_win / TrainerBattleVictory) and loops until
-- the battle screen closes; leaving battle brings back the map theme,
-- like the overworld reload's PlayDefaultMusicFadeOutCurrent
-- (home/overworld.asm:2343-2348)
require("src.core.Music").restoreMap(self.data)
self.game.stack:pop()
Runtime.emit("battle.ended", { battle = self, result = self.result or "run" })
if self.onFinish then self.onFinish(self.result or "run") end
end
-- ---------------------------------------------------------------------
-- draw
-- ---------------------------------------------------------------------
-- In-battle HUD tiles + the tile HP bar live in src/render/HudTiles.lua
-- (shared with the status screen)
local HudTiles = require("src.render.HudTiles")
local hudTile = HudTiles.tile
local drawHPBar = HudTiles.drawHPBar
-- CenterMonName: 1-2 letter names print two tiles right, 3-4 one tile
local function nameX(tx, name)
local n = #name
return tx * 8 + (n <= 2 and 16 or n <= 4 and 8 or 0)
end
-- Party pokeball row (SetupPokeballs tiles: ball / status ball /
-- fainted ball / empty), 6 slots stepping dx from (x,y).
local ballQuads
function BattleState:drawBallRow(party, x, y, dx)
if ballQuads == nil then
local ok, img = pcall(love.graphics.newImage, "assets/generated/battle/balls.png")
if ok then
ballQuads = { img = img }
for i = 0, 3 do
ballQuads[i] = love.graphics.newQuad(i * 8, 0, 8, 8, img:getDimensions())
end
else
ballQuads = false
end
end
if not ballQuads then return end
for i = 1, 6 do
local mon = party[i]
local tile = not mon and 3 or mon.hp <= 0 and 2 or mon.status and 1 or 0
love.graphics.draw(ballQuads.img, ballQuads[tile], x + (i - 1) * dx, y)
end
end
-- the grow-in scale for a battler's pic this frame: nil when not
-- growing, else 0 (ball beat) / 3/7 / 5/7 -- AnimateSendingOutMon's
-- stages (core.asm:6801-6838): 3 frames of the ball tile, 4 frames of
-- a 3x3 block of the 7x7 pic tiles, 5 frames of 5x5, then full size
function BattleState:growInScale(battler)
local grow = self.growIn
if not grow or grow.battler ~= battler then return nil end
local f = grow.frame
return f < 3 and 0 or f < 7 and 3 / 7 or 5 / 7
end
-- battler hidden this frame? (damage blink)
function BattleState:fxHidden(battler)
local fx = self.fx
if fx and fx.blink and fx.blink.target == battler and fx.blink.frames > 0 then
return self.frame % 8 < 4
end
return false
end
-- is the faint slide currently playing for this battler?
function BattleState:fxFaintActive(battler)
local fx = self.fx
return fx and fx.faint and fx.faint.battler == battler
and fx.faint.frames > 0 or false
end
-- vertical slide offset for a fainting battler (the player's pic is
-- drawn 2x, so it slides 2x as fast to sink at the same visual rate)
function BattleState:fxFaintOffset(battler)
local fx = self.fx
if self:fxFaintActive(battler) then
return (30 - fx.faint.frames) * 2 * (battler.isPlayer and 2 or 1)
end
return 0
end
-- Substitute doll (AnimationSubstitute, engine/battle/animations.asm):
-- while a battler's substitute is up, its pic is replaced by the mini
-- doll from gfx/sprites/monster.png -- the facing-DOWN frame for the
-- enemy, facing-UP for the player, a 16x16 sprite at pic tiles
-- (2..3,4..5) / (3..4,4..5) of the 7x7 frame: screen (112,32) enemy,
-- (32,72) player.
local substDoll
function BattleState:drawSubstituteDoll(battler)
if substDoll == nil then
local ok, img = pcall(love.graphics.newImage,
"assets/generated/sprites/monster.png")
if ok then
local w, h = img:getDimensions()
substDoll = { img = img,
down = love.graphics.newQuad(0, 0, 16, 16, w, h),
up = love.graphics.newQuad(0, 16, 16, 16, w, h) }
else
substDoll = false
end
end
if not substDoll then return end
-- in colorized mode the doll (drawn from BG tiles on the GB) takes
-- its screen zone's SGB palette like everything else in the region
local shader
if self:colorMode() then
local PaletteFX = require("src.render.PaletteFX")
shader = PaletteFX.shader()
if shader then
local colors = self:zoneColorsAt(battler.isPlayer and 32 or 112,
battler.isPlayer and 72 or 32)
if colors then
love.graphics.setShader(shader)
PaletteFX.sendColors(shader,
require("src.render.PaletteFX").permute(colors, self:activeBgp()))
else
shader = nil
end
end
end
if battler.isPlayer then
love.graphics.draw(substDoll.img, substDoll.up, 32, 72)
else
love.graphics.draw(substDoll.img, substDoll.down, 112, 32)
end
if shader then love.graphics.setShader() end
end
-- MinimizedMonSprite (animations.asm:1745): the 8x5 blob that replaces
-- a minimized mon's pic, written at pic tile (3,4)+2px. Rows are bit
-- patterns, drawn as shade-3 pixels.
local MINIMIZED_ROWS = {
{ 3, 4 }, -- ...XX...
{ 2, 5 }, -- ..XXXX..
{ 1, 6 }, -- .XXXXXX.
{ 2, 5 }, -- ..XXXX..
{ 2, 2, 5, 5 }, -- ..X..X..
}
function BattleState:drawMinimizedBlob(battler, x, y)
local r, g, b, a = love.graphics.getColor()
local col = { 0, 0, 0, 1 }
local pals = self:colorMode() and self:sgbBattlePals()
if pals then
local P = pals[battler.isPlayer and 2 or 3]
local shade = P[4]
col = { shade[1] / 255, shade[2] / 255, shade[3] / 255, 1 }
end
love.graphics.setColor(col)
for row, runs in ipairs(MINIMIZED_ROWS) do
for i = 1, #runs, 2 do
love.graphics.rectangle("fill", x + 24 + runs[i], y + 34 + row - 1,
runs[i + 1] - runs[i] + 1, 1)
end
end
love.graphics.setColor(r, g, b, a)
end
-- Draw a battler pic, sinking it behind its own baseline while the
-- faint slide plays (pokered's AnimationSlideMonDown); a fainted
-- battler stays hidden once the slide ends. A standing substitute
-- shows the mini doll instead of the mon's own pic. The SE-driven
-- pic effects (slides/squish/blink/minimize; see applyAnimEffect)
-- offset, clip or replace the pic, and an active BGP fade swaps in a
-- shade-remapped recolor of it.
function BattleState:drawBattlerPic(battler, x, y, scale)
local img = self:picImage(battler.sprite)
if battler.substituteHP and not self:fxFaintActive(battler)
and not battler.fainted then
self:drawSubstituteDoll(battler)
return
end
if self:fxFaintActive(battler) then
local off = self:fxFaintOffset(battler)
local visible = img:getHeight() - math.floor(off / scale)
if visible > 0 then
local quad = love.graphics.newQuad(0, 0, img:getWidth(), visible,
img:getWidth(), img:getHeight())
love.graphics.draw(img, quad, x, y + off, 0, scale, scale)
end
return
end
if battler.fainted then return end
local pf = self.picFx and self.picFx[battler]
if not pf or (not pf.kind and not pf.hidden and not pf.minimized
and (pf.ox or 0) == 0 and (pf.oy or 0) == 0) then
love.graphics.draw(img, x, y, 0, scale, scale)
return
end
if pf.hidden then return end
if pf.minimized then
self:drawMinimizedBlob(battler, x, y)
return
end
local w, h = img:getWidth(), img:getHeight()
local ox, oy = pf.ox or 0, pf.oy or 0
local k, t = pf.kind, pf.t or 0
local xscale = 1
-- while an SE effect displaces the pic, confine it to its side's
-- tile window like the GB tilemap does (the pic can never overwrite
-- the HUD columns or the text box rows)
local clip = love.graphics.setScissor and love.graphics.intersectScissor
local scx, scy, scw, sch
if clip then
scx, scy, scw, sch = love.graphics.getScissor()
if battler.isPlayer then
love.graphics.intersectScissor(0, 0, 80, 96)
else
love.graphics.intersectScissor(88, 0, 72, 56)
end
end
if k == "slideOff" then
-- one tile (8px) toward the mon's own screen edge per 3 frames
local dir = battler.isPlayer and -1 or 1
ox = ox + dir * 8 * math.min(8, math.floor(t / 3) + 1)
elseif k == "slideHalf" then
local dir = battler.isPlayer and -1 or 1
ox = ox + dir * 8 * math.min(4, math.floor(t / 4) + 1)
elseif k == "slideDown" then
oy = oy + 8 * math.min(7, math.floor(t / 3) + 1)
elseif k == "slideDownHide" then
oy = oy + 16 * (math.floor(t / 8) + 1)
elseif k == "bounce" then
-- 5 back-to-back AnimationSlideMonDown passes
oy = oy + 8 * math.min(7, math.floor((t % 21) / 3) + 1)
elseif k == "shakeBF" then
ox = ox + ((math.floor(t / 3) % 2 == 0) and -8 or 8)
elseif k == "squish" then
xscale = math.max(0, 7 - 2 * (math.floor(t / 6) + 1)) / 7
elseif k == "blink" then
-- skip; falls through to the scissor-restore below instead of an
-- early return that would leave the pic-window scissor stuck
end
local skipDraw = (k == "squish" and xscale <= 0)
or (k == "blink" and math.floor(t / 5) % 2 == 0)
if skipDraw then
-- draw nothing this frame, but still restore the scissor rect
elseif oy > 0 then
-- sink below the baseline (AnimationSlideMonDown-style row clip)
local visible = h - math.floor(oy / scale)
if visible > 0 then
local quad = love.graphics.newQuad(0, 0, w, visible, w, h)
love.graphics.draw(img, quad, x + ox, y + oy, 0, scale, scale)
end
elseif k == "slideUp" then
-- AnimationSlideMonUp (animations.asm): 7 row steps x 2f. After Dig's
-- SLIDE_DOWN the tilemap is blank; each step fills the next bottom
-- row so the mon emerges from underground. A cyclic wrap of a full
-- pic looked like a bounce at Dig's end (#100).
local step = math.min(7, math.floor((t - 1) / 2) + 1)
local visible = math.floor(h * step / 7)
if visible > 0 then
local quad = love.graphics.newQuad(0, h - visible, w, visible, w, h)
love.graphics.draw(img, quad, x + ox,
y + (h - visible) * scale, 0, scale, scale)
end
elseif xscale < 1 then
-- AnimationSquishMonPic: columns collapse toward the middle
love.graphics.draw(img, x + w * scale * (1 - xscale) / 2, y,
0, scale * xscale, scale)
else
love.graphics.draw(img, x + ox, y + oy, 0, scale, scale)
end
if clip then
if scx then
love.graphics.setScissor(scx, scy, scw, sch)
else
love.graphics.setScissor()
end
end
end
-- ------------------------------------------------------------------
-- SGB battle colorization. SetPal_Battle (engine/gfx/palettes.asm:28)
-- assigns pal 0 = player HP-bar palette, pal 1 = enemy HP-bar palette,
-- pal 2 = player mon palette, pal 3 = enemy mon palette;
-- BlkPacket_Battle (data/sgb/sgb_packets.asm:65) maps them onto screen
-- regions. The BG layer is drawn in DMG grays to a canvas and each
-- region is recolored through the PaletteFX shader; the OAM anim
-- sprites are colored per sprite afterwards (BGP fades never touch
-- them, matching the hardware).
-- ------------------------------------------------------------------
-- BlkPacket_Battle ATTR_BLK data: pal slot + inclusive tile rect.
-- The first entry is the %111 outside fill; the blocks are disjoint.
local BATTLE_ZONES = {
{ pal = 0, 0, 0, 19, 17 }, -- everything else
{ pal = 1, 1, 0, 10, 3 }, -- enemy HUD
{ pal = 0, 10, 7, 19, 10 }, -- player HUD
{ pal = 2, 0, 4, 8, 11 }, -- player mon
{ pal = 3, 11, 0, 19, 6 }, -- enemy mon
{ pal = 2, 0, 12, 19, 17 }, -- message box
}
-- the colorizer needs canvases + shaders + pixel access (headless
-- stubs and stripped-down builds fall back to the flat colored path)
function BattleState:colorMode()
if self.colorFxReady == nil then
local ready = false
local g = love and love.graphics
local PaletteFX = require("src.render.PaletteFX")
if g and g.newCanvas and g.setScissor and g.setShader and g.getCanvas
and love.image and PaletteFX.pack(self.data)
and PaletteFX.shader() then
local ok1, bg = pcall(g.newCanvas, 160, 144)
local ok2, wv = pcall(g.newCanvas, 160, 144)
if ok1 and ok2 and bg and wv then
self.bgCanvas, self.waveCanvas = bg, wv
ready = true
end
end
self.colorFxReady = ready
end
return self.colorFxReady
end
-- The four SGB palettes SetPal_Battle would currently send: bar
-- palettes track the drawn HP bars (GetHealthBarColor), the mon slots
-- hold MonsterPalettes[wBattleMonSpecies]/[wEnemyMonSpecies2] --
-- PAL_MEWMON (= MonsterPalettes[0]) while a side still shows its
-- trainer/back pic (the species bytes are 0 then).
function BattleState:sgbBattlePals()
local PaletteFX = require("src.render.PaletteFX")
local pack = PaletteFX.pack(self.data)
local pals = pack and pack.palettes
if not pals then return nil end
local function bar(b)
if not b then return pals.GREENBAR end
local hp = b.shownHP or b.mon.hp
return pals[PaletteFX.barPalName(hp, b.mon.stats.hp)] or pals.GREENBAR
end
local function mon(b, placeholder)
if placeholder or not b then return pals.MEWMON or pals.GREENBAR end
return PaletteFX.monPal(self.data, b.mon.species) or pals.MEWMON
end
local out = {
[0] = bar(self.player),
[1] = bar(self.enemy),
[2] = mon(self.player, self.showPlayerBack or self.safari or self.demo),
[3] = mon(self.enemy, self.showEnemyTrainer),
}
-- OG RED: the Game Boy Color drew the whole battle from one BG palette --
-- white paper, black ink -- so every zone shares the same background and
-- outline; only the two mid shades differ per element (green HP bar, red
-- mon pic). The bar/base zones otherwise carry the SGB off-white
-- (255,239,255) as color 0 while the mon zones (monPal -> GBC_BG) carry a
-- true white, which is what drew a white box around each pic on the pink
-- field. Snap every zone's color 0/3 to the global GBC white/black; the
-- mid shades (and the green bar the user prefers) stay untouched.
if PaletteFX.mode == "ogred" then
local white, black = PaletteFX.GBC_BG[1], PaletteFX.GBC_BG[4]
for i = 0, 3 do
local c = out[i]
out[i] = { white, c[2], c[3], black }
end
end
return out
end
-- the SGB palette covering a screen pixel (BlkPacket_Battle regions)
function BattleState:zoneColorsAt(x, y)
local pals = self:sgbBattlePals()
if not pals then return nil end
local tx = math.floor(x / 8)
local ty = math.floor(y / 8)
if ty >= 12 then return pals[2] end -- message box
if tx >= 11 and ty <= 6 then return pals[3] end -- enemy mon
if tx <= 8 and ty >= 4 and ty <= 11 then return pals[2] end -- player mon
if tx >= 1 and tx <= 10 and ty <= 3 then return pals[1] end -- enemy HUD
return pals[0]
end
-- AnimationWavyScreen's per-scanline SCX offsets
-- (WavyScreenLineOffsets, animations.asm:1926)
local WAVY_OFFSETS = { 0, 0, 0, 0, 0, 1, 1, 1, 2, 2, 2, 2, 2, 1, 1, 1,
0, 0, 0, 0, 0, -1, -1, -1, -2, -2, -2, -2, -2,
-1, -1, -1 }
-- wave the BG canvas one scanline at a time; the offset table walks
-- one entry per frame like the asm's advancing pointer
function BattleState:applyWavy(src)
local wavy = self.fx and self.fx.wavy
if not wavy then return src end
local g = love.graphics
local prev = g.getCanvas()
g.setCanvas(self.waveCanvas)
g.setColor(1, 1, 1, 1)
g.rectangle("fill", 0, 0, 160, 144)
self.waveQuad = self.waveQuad or g.newQuad(0, 0, 160, 1, 160, 144)
for line = 0, 143 do
self.waveQuad:setViewport(0, line, 160, 1)
g.draw(src, self.waveQuad,
WAVY_OFFSETS[(line + wavy.phase) % 32 + 1], line)
end
g.setCanvas(prev)
return self.waveCanvas
end
-- recolor the grayscale BG canvas per zone; an active BGP fade permutes
-- the zone palette (the SGB colors the remapped DMG shade). A window
-- shake draws a second, offset copy over the base one: the color
-- regions themselves never move on the SGB, and the vacated strip
-- shows the unshifted BG map like the hardware.
function BattleState:drawZonePass(src, sx, sy)
local PaletteFX = require("src.render.PaletteFX")
local shader = PaletteFX.shader()
local pals = self:sgbBattlePals()
local bgp = self:activeBgp()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.setShader(shader)
for _, z in ipairs(BATTLE_ZONES) do
PaletteFX.sendColors(shader, PaletteFX.permute(pals[z.pal], bgp))
love.graphics.setScissor(z[1] * 8, z[2] * 8,
(z[3] - z[1] + 1) * 8, (z[4] - z[2] + 1) * 8)
love.graphics.draw(src, 0, 0)
if sx ~= 0 or sy ~= 0 then
love.graphics.draw(src, sx, sy)
end
end
love.graphics.setScissor()
love.graphics.setShader()
end
-- colors for one anim-layer OAM sprite at screen pixel (px, py): the
-- zone palette under that pixel's 8x8 attribute cell (the SGB colors
-- the composited picture per cell, so AnimPlayer samples once per cell
-- the tile overlaps), through the OBJ palette the routine ran with
-- (SetAnimationPalette: wAnimPalette = $f0 on SGB, rOBP1 = $6c,
-- ambient rOBP0 = $e4)
local OBJ_SHADES = {
f0 = { 0, 3, 3 }, -- color 1 -> shade 0, colors 2/3 -> shade 3
f0x = { 3, 0, 3 }, -- $f0 xor %00111100 = $cc: the Master/Ultra ball
-- toss flicker (DoBallTossSpecialEffects)
e4 = { 1, 2, 3 }, -- identity
obp1 = { 3, 2, 1 }, -- $6c
}
function BattleState:animSpriteColors(s, px, py)
local P = self:zoneColorsAt(px or (s.x - 8 + 4), py or (s.y - 16 + 4))
if not P then return nil end
local m = OBJ_SHADES[s.obp or "f0"] or OBJ_SHADES.f0
local function c(shade)
local col = P[shade + 1]
return { col[1] / 255, col[2] / 255, col[3] / 255 }
end
return { c(m[1]), c(m[2]), c(m[3]) }
end
-- the OAM anim layer (subanimation sprites / the resting caught ball)
function BattleState:drawAnimLayer(colorized)
local colorFn
if colorized then
colorFn = function(s, px, py) return self:animSpriteColors(s, px, py) end
end
if self.animPlaying and self.animPlayer then
love.graphics.setColor(1, 1, 1, 1)
pcall(self.animPlayer.draw, self.animPlayer, colorFn)
elseif self.lockedBall and self.animPlayer then
-- the resting closed ball stays on screen through the caught text
-- (the $43 chain ends after SHAKE_ANIM and the GB never clears the
-- ball's OAM entries until the battle screen is torn down)
love.graphics.setColor(1, 1, 1, 1)
pcall(self.animPlayer.drawSprites, self.animPlayer, self.lockedBall,
colorFn)
end
end
-- Front/trainer pics: LoadUncompressedSpriteData centers the sprite in
-- a 7x7 tile buffer, then CopyUncompressedPicToTilemap places that
-- buffer at hlcoord 12,0. Horizontal pad is floor((8-w)/2) tiles;
-- vertical pad is (7-h) -- bottom-aligned inside the 7x7.
local function enemyPicXY(img, slide, sx, sy)
local tw = math.floor(img:getWidth() / 8)
local th = math.floor(img:getHeight() / 8)
if tw < 1 then tw = 1 elseif tw > 7 then tw = 7 end
if th < 1 then th = 1 elseif th > 7 then th = 7 end
local hPad = math.floor((8 - tw) / 2)
local vPad = 7 - th
return 96 + 8 * hPad - slide + sx, 8 * vPad + sy
end
-- the two mon pics (or the trainer/back pics), offset by the window
-- shake -- on the GB the pics are BG tiles, so they move with it
function BattleState:drawPicsLayer(slide, sx, sy)
-- The move-select boxes are BG tiles on the GB, so they REPLACE the
-- player pic's rows: the TYPE/PP box at (0,8) (PrintMenuItem) wipes
-- pic rows 8+, and Mimic's copy menu at (0,7) (MoveSelectionMenu
-- .mimicmenu) wipes rows 7+. The port draws pics above the menu
-- layer in the colorized pipeline, so clip them to the visible rows.
local g = love.graphics
local clipY = self.phase == "mimicSelect" and 56
or self.phase == "moveSelect" and 64 or nil
local clipped, cs1, cs2, cs3, cs4
if clipY and g.getScissor and g.intersectScissor then
cs1, cs2, cs3, cs4 = g.getScissor()
g.intersectScissor(0, 0, 160, clipY)
clipped = true
end
-- Enemy: front sprite in the 7x7 slot at hlcoord 12,0.
if self.showEnemyTrainer and self.trainerPic then
-- the enemy trainer pic holds the mon slot until the send-out
local img = self:picImage(self.trainerPic)
love.graphics.setColor(1, 1, 1, 1)
local ex, ey = enemyPicXY(img, slide, sx, sy)
love.graphics.draw(img, ex, ey)
elseif self.enemy and self.enemy.sprite and not self.enemyHidden
and not self.enemySendingOut and not self:fxHidden(self.enemy) then
local img = self:picImage(self.enemy.sprite)
love.graphics.setColor(1, 1, 1, 1)
local ex, ey = enemyPicXY(img, slide, sx, sy)
local gs = self:growInScale(self.enemy)
if gs then
-- AnimateSendingOutMon: the downscaled pic keeps its bottom edge
-- and horizontal center pinned to the mon's slot while it grows
if gs > 0 then
love.graphics.draw(img, ex + img:getWidth() * (1 - gs) / 2,
ey + img:getHeight() * (1 - gs), 0, gs, gs)
end
else
self:drawBattlerPic(self.enemy, ex, ey, 1)
end
end
-- Player: back sprite at hlcoord 1,5 (x=8), 2x like the GB, feet at y=96.
-- Left transparent columns (matted white) are pulled back so opaque
-- pixels land where hardware's white-on-white columns left them.
local hidePlayer = self.safari or self.demo
if self.showPlayerBack and self.playerBackPic then
-- Red's (or the old man's) back pic until "Go!"; it stays up for
-- the whole safari / catch-demo battle like the original
local img = self:picImage(self.playerBackPic)
local pad = imagePadBottom[self.playerBackPic] or 0
local padL = imagePadLeft[self.playerBackPic] or 0
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(img, 8 - padL * 2 + slide + sx,
96 - (img:getHeight() - pad) * 2 + sy, 0, 2, 2)
elseif self.player and self.player.sprite and not hidePlayer
and not self.sendingOut and not self:fxHidden(self.player) then
local img = self:picImage(self.player.sprite)
love.graphics.setColor(1, 1, 1, 1)
-- feet flush on the text box top (y=96), ignoring baked-in padding
local pad = imagePadBottom[self.player.sprite] or 0
local padL = imagePadLeft[self.player.sprite] or 0
local px = 8 - padL * 2 + sx
local gs = self:growInScale(self.player)
if gs then
-- the player-side AnimateSendingOutMon grow (after the poof,
-- core.asm:1757-1762): feet pinned at y=96, center at x=8+w
if gs > 0 then
love.graphics.draw(img, px + img:getWidth() * (1 - gs),
96 - (img:getHeight() - pad) * 2 * gs + sy,
0, 2 * gs, 2 * gs)
end
else
self:drawBattlerPic(self.player, px,
96 - (img:getHeight() - pad) * 2 + sy, 2)
end
end
if clipped then
if cs1 then
g.setScissor(cs1, cs2, cs3, cs4)
else
g.setScissor()
end
end
end
-- the BG-tile UI: HUDs, pokeball rows, safari ball count. Grayscale;
-- the zone pass colors it in colorized mode.
function BattleState:drawHUDs(slide)
-- the HUD clears with the send-out text (ClearScreenArea,
-- core.asm:1414-1417) and DrawEnemyHUDAndHPBar (1435) only redraws
-- it after the grow-in + cry
local barData = self:colorMode() and {} or self.data -- gray fill when zoned
local fx = self.fx
local hudShake = (fx and fx.hudShakeX) or 0
if self.enemy and not self.showEnemyTrainer and not self.enemySendingOut
and not self:growInScale(self.enemy) and slide == 0 then
-- enemy HUD (DrawEnemyHUDAndHPBar): name row 0, <LV>+level (4,1),
-- HP bar (2,2) with the vertical tick at (1,2), underline row 3;
-- AnimationShakeEnemyHUD nudges just this block via SCX
if hudShake ~= 0 then
love.graphics.push()
love.graphics.translate(hudShake, 0)
end
love.graphics.setColor(0, 0, 0, 1)
Font.draw(self.enemy.name, nameX(1, self.enemy.name), 0)
if self.enemy.mon.status then
Font.draw(self:statusLabel(self.enemy.mon), 40, 8)
else
hudTile(0x6E, 32, 8) -- <LV>
Font.draw(tostring(self.enemy.mon.level), 40, 8)
end
hudTile(0x73, 8, 16)
drawHPBar(barData, 2, 2,
{ hp = shownHP(self.enemy), stats = self.enemy.mon.stats })
hudTile(0x74, 8, 24)
for i = 2, 9 do hudTile(0x76, i * 8, 24) end
hudTile(0x78, 80, 24)
if hudShake ~= 0 then
love.graphics.pop()
end
end
-- Safari shows only the ball count; the old man demo shows neither mon
if self.safari then
love.graphics.setColor(0, 0, 0, 1)
Font.draw(("BALLx%2d"):format(self.safari.balls), 88, 72)
end
-- trainer/link party pokeball rows during the intro
-- (SetupPlayerAndEnemyPokeballs, draw_hud_pokeball_gfx.asm)
local showIntroBalls = slide == 0 and (
(self.kind == "trainer" and (self.showEnemyTrainer or self.showPlayerBack))
or (self.kind == "link" and (self.showPlayerBack or self.enemySendingOut))
)
if showIntroBalls then
love.graphics.setColor(1, 1, 1, 1)
if self.enemyParty and (
(self.kind == "trainer" and self.showEnemyTrainer)
or (self.kind == "link" and self.enemySendingOut)
) then
self:drawBallRow(self.enemyParty, 64, 16, -8)
end
if self.showPlayerBack then
self:drawBallRow(self.playerParty or self.game.save.party, 88, 80, 8)
end
end
local hidePlayer = self.safari or self.demo
if self.player and not hidePlayer and not self.showPlayerBack
and slide == 0 then
-- player HUD (DrawPlayerHUDAndHPBar): name (10,7), <LV>+level
-- (14,8), HP bar (10,9), HP numbers row 10, underline row 11 with
-- the tick at (18,10) and the triangle at (9,11)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(self.player.name, nameX(10, self.player.name), 56)
if self.player.mon.status then
Font.draw(self:statusLabel(self.player.mon), 120, 64)
else
hudTile(0x6E, 112, 64) -- <LV>
Font.draw(tostring(self.player.mon.level), 120, 64)
end
drawHPBar(barData, 10, 9,
{ hp = shownHP(self.player), stats = self.player.mon.stats },
1) -- wHPBarType 1: the $6D cap
Font.draw(("%3d/%3d"):format(shownHP(self.player), self.player.mon.stats.hp), 88, 80)
hudTile(0x73, 144, 80)
hudTile(0x77, 144, 88)
for i = 10, 17 do hudTile(0x76, i * 8, 88) end
hudTile(0x6F, 72, 88)
end
end
function BattleState:drawTextArea()
Font.drawBox(0, 12, 20, 6)
love.graphics.setColor(0, 0, 0, 1)
if self.phase == "messages" and self.current then
local shown = 0
for li, codes in ipairs(self.lines) do
-- battle text uses every other tile row (hlcoord *,14 / *,16)
local y = 112 + (li - 1) * 16
for i = 1, #codes do
if shown >= self.charIndex then break end
Font.drawCode(codes[i], 8 + (i - 1) * 8, y)
shown = shown + 1
end
end
elseif self.phase == "menu" and self.demo then
-- the old-man script (DisplayBattleMenu, core.asm:2038-2049): the
-- standard menu, with the '▶' hand drawn by the scripted keystrokes
-- -- next to FIGHT (9,14) for the first 80 frames, then ITEM (9,16)
Font.drawBox(8, 12, 12, 6)
love.graphics.setColor(0, 0, 0, 1)
Font.draw("FIGHT", 80, 112)
Font.drawCode(0xE1, 128, 112); Font.drawCode(0xE2, 136, 112)
Font.draw("ITEM", 80, 128); Font.draw("RUN", 128, 128)
Font.drawCode(0xED, 72, (self.demoTimer or 0) <= 80 and 112 or 128)
elseif self.phase == "menu" then
local col = (self.menuIndex - 1) % 2
local row = math.floor((self.menuIndex - 1) / 2)
if self.safari then
-- SAFARI_BATTLE_MENU_TEMPLATE: full-width box, "BALLx BAIT /
-- THROW ROCK RUN" from (2,14)
Font.drawBox(0, 12, 20, 6)
Font.draw("BALLx", 16, 112); Font.draw("BAIT", 112, 112)
Font.draw("THROW ROCK", 16, 128); Font.draw("RUN", 112, 128)
Font.drawCode(0xED, (col == 0 and 8 or 104), 112 + row * 16)
else
-- BATTLE_MENU_TEMPLATE: box (8,12)-(19,17), "FIGHT <PK><MN> /
-- ITEM RUN" from (10,14); cursor columns 9 / 15
Font.drawBox(8, 12, 12, 6)
Font.draw("FIGHT", 80, 112)
Font.drawCode(0xE1, 128, 112); Font.drawCode(0xE2, 136, 112)
Font.draw("ITEM", 80, 128); Font.draw("RUN", 128, 128)
Font.drawCode(0xED, (col == 0 and 72 or 120), 112 + row * 16)
end
elseif self.phase == "moveSelect" then
-- pokered MoveSelectionMenu: move list in a box at (4,12) 16x6,
-- names at column 6 from row 13, cursor at column 5. PrintMenuItem:
-- the TYPE/PP box at (0,8) 11x5, with "TYPE/" at (1,9), the type at
-- (2,10) and "PP cur/max" at (5,11); its bottom border merges into
-- the move box's top border ('─' at (4,12), '┘' at (10,12)).
Font.drawBox(0, 8, 11, 5)
Font.drawBox(4, 12, 16, 6)
Font.drawCode(Font.BORDER.h, 32, 96)
Font.drawCode(Font.BORDER.br, 80, 96)
love.graphics.setColor(0, 0, 0, 1)
for i, mv in ipairs(self.player.curMoves) do
Font.draw(self.data.moves[mv.id].name, 48, 96 + i * 8)
end
Font.drawCode((self.moveSwapIndex == self.moveIndex) and 0xEC or 0xED,
40, 96 + self.moveIndex * 8)
if self.moveSwapIndex and self.moveSwapIndex ~= self.moveIndex then
Font.drawCode(0xEC, 40, 96 + self.moveSwapIndex * 8)
end
local sel = self.player.curMoves[self.moveIndex]
if sel then
if self.player.disabledSlot == self.moveIndex then
Font.draw("disabled!", 8, 80)
else
local def = self.data.moves[sel.id]
Font.draw("TYPE/", 8, 72)
-- the type record's display name (a mod type shows its name, and
-- PSYCHIC_TYPE prints PSYCHIC like the original)
Font.draw(def.type and TypeChart.displayName(def.type) or "", 16, 80)
local maxPP = def.pp + (sel.ppUps or 0) * math.floor(def.pp / 5)
Font.draw(("%2d/%2d"):format(sel.pp, maxPP), 40, 88)
end
end
elseif self.phase == "mimicSelect" then
-- Mimic's copy menu (MoveSelectionMenu .mimicmenu, core.asm:
-- 2506-2517): the enemy's move list in a 16x6 box at (0,7), names
-- single-spaced from (2,8), cursor at column 1
Font.drawBox(0, 7, 16, 6)
love.graphics.setColor(0, 0, 0, 1)
for i, m in ipairs(self.mimicMoves) do
Font.draw(self.data.moves[m.id].name, 16, (7 + i) * 8)
end
Font.drawCode(0xED, 8, (7 + self.mimicIndex) * 8)
end
end
function BattleState:draw()
local fx = self.fx
-- window shakes (SE_SHAKE_SCREEN / the enemy-hit vertical shake);
-- the animations-off fallback keeps the old +-2 alternation
local sx = (fx and fx.shakeX) or 0
local sy = (fx and fx.shakeY) or 0
if sx == 0 and sy == 0 and fx and fx.shake and fx.shake > 0 then
sx = self.frame % 4 < 2 and 2 or -2
end
local slide = (self.introSlide or 0) * 4 -- intro slide-in offset
if self:colorMode() then
-- SGB pipeline: gray BG canvas -> (wavy) -> zone recolor with the
-- BGP fade -> mon pics -> OAM anim sprites (never BGP-faded)
local g = love.graphics
local prev = g.getCanvas()
local wavy = fx and fx.wavy
g.setCanvas(self.bgCanvas)
g.setColor(1, 1, 1, 1)
g.rectangle("fill", 0, 0, 160, 144)
self:drawHUDs(slide)
self:drawTextArea()
if wavy then
-- the mon pics are BG tiles on the GB, so SE_WAVY_SCREEN bends
-- them too: bake them into the canvas as DMG grays and let the
-- zone pass color them by region (exactly what the SGB did)
self.grayPics = true
g.setScissor(0, 0, 160, 96) -- BG pics live above the text box
self:drawPicsLayer(slide, 0, 0)
g.setScissor()
self.grayPics = nil
end
g.setCanvas(prev)
self:drawZonePass(self:applyWavy(self.bgCanvas), sx, sy)
if not wavy then
-- the pics are BG tiles in rows 0-11 on the GB: they can never
-- cover the text box, whatever the SE offsets do (a vertical
-- window shake moves the box down with everything else)
g.setScissor(0, 0, 160, 96 + math.max(0, sy))
self:drawPicsLayer(slide, sx, sy)
g.setScissor()
end
self:drawAnimLayer(true)
else
-- flat fallback (headless / no shader support): pre-colorized pics
-- on white, no palette fades
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
local shaking = sx ~= 0 or sy ~= 0
if shaking then
love.graphics.push()
love.graphics.translate(sx, sy)
end
self:drawPicsLayer(slide, 0, 0)
self:drawHUDs(slide)
self:drawAnimLayer(false)
self:drawTextArea()
if shaking then
love.graphics.pop()
end
end
-- screen flash (flash-effect moves without the subanimation player):
-- white flicker overlay
if fx and fx.flash and fx.flash > 0 and self.frame % 4 < 2 then
love.graphics.setColor(1, 1, 1, 0.85)
love.graphics.rectangle("fill", 0, 0, 160, 144)
end
love.graphics.setColor(1, 1, 1, 1)
end
return BattleState