mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 16:31:05 +02:00
46bd0f6709
BATTLE SIZE "fixed" draws the battle as a discrete letterbox rather than
filling the window, and BATTLE BG "world" composes it over the live map.
Everything the battle then opens broke out of that composition, because
each piece of the frame's geometry was read off a fact about THIS FRAME
instead of about the battle:
* Renderer:uiScale follows the survey zoom only while a world is behind
the UI, gated on worldActive -- this frame's world pass. PartyMenu and
ListMenu are opaque, so pushing one makes StateStack:visibleBase skip
the map, no world pass runs, and the menu loses the step-down and blits
a whole integer scale larger than the battle it just covered. Held
with uiWorldHold, the same whole-stack rule uiFill and the battle dim
already use. ("fill" hid this: it overrides the scale outright.)
* Game:draw started the frame at visibleBase, so that same opaque menu
cut the overworld -- and the world pass with it -- out of the frame
entirely, collapsing a "world" backdrop to endFrame's flat black clear.
A world-bg battle now keeps the frame starting from underneath itself
(drawBaseInStack). Only the START of the draw moves; the clear stays
keyed to the real visibleBase, so the menu still gets its opaque canvas
and draws exactly as before.
* worldZones was keyed to that same clear base, so it came out nil for a
frame whose world pass HAD run -- dropping endFrame's world blit onto
the UI zone list instead, smearing the party menu's own HP-bar palettes
across a world-canvas-sized image. Keyed to whether the map drew.
* endFrame's letterbox clear read letterboxWhite off visibleBase alone,
so an opaque menu over a BG "white" battle flipped its surround to
black the same way. Same whole-stack hold.
* ChoiceBox bottom-anchored unconditionally, docking it to the WINDOW
edge. That is only right when it rides the dialogue box beneath it,
which is anchored there too; TextBox now passes the anchor and nothing
else does, so the battle's switch offer and the shop/PC confirms stay
over the screen that pushed them.
* TextBox anchors likewise: a battle is a self-contained SCREEN, not the
window, and pokered prints its text box in the same 160x144 tilemap as
the HUD. The caught-mon nickname prompt was landing a whole letterbox
below the blanked battle field it is printed on. BattleState.holdsUI-
Anchors holds setUIAnchor off while a battle is in the stack; the
overworld's own dialogue box still docks to the screen edge.
5492 lines
231 KiB
Lua
5492 lines
231 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").
|
|
--
|
|
-- 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 Timing = require("src.core.Timing")
|
|
local TrainerAI = require("src.battle.TrainerAI")
|
|
local TurnOrder = require("src.battle.TurnOrder")
|
|
local TypeChart = require("src.battle.TypeChart")
|
|
local Strings = require("src.core.Strings")
|
|
local WideBattle = require("src.battle.WideBattle")
|
|
|
|
local BattleState = {}
|
|
BattleState.__index = BattleState
|
|
BattleState.isOpaque = true
|
|
-- Letterbox voids around the 160x144 battle canvas fill white so the
|
|
-- window reads as one continuous battle screen (no black bars).
|
|
BattleState.letterboxWhite = true
|
|
|
|
-- A battle is a self-contained SCREEN, not the window. The overworld's
|
|
-- dialogue box docks to the window edge on purpose (Renderer:setUIAnchor) --
|
|
-- a box floating in the middle of a zoomed-out map reads as detached. A
|
|
-- battle is the opposite: pokered draws its text box and YES/NO in the same
|
|
-- 160x144 tilemap as the HUD, and pulling them out to the window edge splits
|
|
-- the composition in two -- the caught-mon nickname prompt lands a whole
|
|
-- letterbox below the white field it is supposed to be printed on. Anchors
|
|
-- are held off for as long as a battle is in the stack.
|
|
BattleState.holdsUIAnchors = true
|
|
|
|
-- BATTLE LAYOUT: the classic 160x144 arrangement, or the widescreen one on
|
|
-- a 304x144 surface (src/battle/WideBattle.lua). Only the composition
|
|
-- differs; every battler, queue and animation below is shared. Menus and
|
|
-- prompts pushed during a wide battle keep its wide canvas, while drawing
|
|
-- their classic 160px UI centred within it (Game:draw).
|
|
function BattleState:isWideBattleLayout()
|
|
local options = self.game and self.game.save and self.game.save.options
|
|
return options and options.battleLayout == "wide" or false
|
|
end
|
|
|
|
function BattleState:wideLayout()
|
|
return self:isWideBattleLayout()
|
|
end
|
|
|
|
-- BATTLE SIZE: "fixed" keeps the classic integer-scaled letterbox (a GB pixel
|
|
-- is a whole number of screen pixels, and the battle is the same size at any
|
|
-- zoom); "fill" scales the battle surface to the window instead, so it fills
|
|
-- vertically. Filling means a fractional scale, so pixels stop being evenly
|
|
-- sized -- that is the trade, which is why it is a setting rather than a
|
|
-- change. Only the battle surface is affected; the overworld is unchanged.
|
|
function BattleState:wantsFillScale()
|
|
local options = self.game and self.game.save and self.game.save.options
|
|
return options and options.battleFit == "fill" or false
|
|
end
|
|
|
|
-- BATTLE BG: what fills the screen AROUND the battle -- the letterbox voids
|
|
-- that grow as the window gets bigger or the view is zoomed out. The battle
|
|
-- screen itself is untouched: it keeps its white paper field in every mode.
|
|
--
|
|
-- "white" the display mode's paper shade (the classic look)
|
|
-- "black" plain black bars
|
|
-- "world" the frozen overworld, dimmed
|
|
--
|
|
-- "world" works by making the battle NON-opaque: StateStack:visibleBase then
|
|
-- finds the overworld below it and Game:draw keeps drawing the map, so the
|
|
-- voids show it instead of a flat clear. The battle still paints its own
|
|
-- opaque 160x144 field over the top, so only the surround changes.
|
|
function BattleState:bgMode()
|
|
local options = self.game and self.game.save and self.game.save.options
|
|
local mode = options and options.battleBg
|
|
if mode == "black" or mode == "world" then return mode end
|
|
return "white"
|
|
end
|
|
|
|
-- How far to dim the overworld behind a "world" background, 0..1. Enough
|
|
-- that the battle reads as the foreground rather than competing with a fully
|
|
-- lit map behind it.
|
|
BattleState.BG_WORLD_DIM = 0.55
|
|
|
|
-- Renderer:setUISize asks the top state for its surface before anything draws
|
|
function BattleState:uiSize()
|
|
if self:wideLayout() then return WideBattle.WIDTH, WideBattle.HEIGHT end
|
|
return 160, 144
|
|
end
|
|
|
|
-- Battle colors itself per-pixel (species pics + HP bar tints), so the
|
|
-- SGB whole-screen remap must not run over it. The wide layout still
|
|
-- needs a zone list of its own: the invented 160x144 one would leave its
|
|
-- extra columns unremapped in the forced-mono modes (WideBattle.zones).
|
|
function BattleState:sgbPalettes()
|
|
if self:wideLayout() then return WideBattle.zones() end
|
|
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 = {}
|
|
-- The three tables below are keyed by the Image OBJECT, not by a path, and a
|
|
-- running battle holds the pics it built at enter() (battler.sprite,
|
|
-- playerBackPic, trainerPic). Weak keys let a dropped pic's row go with it,
|
|
-- so invalidate() can drop the path cache without orphaning what is on
|
|
-- screen right now (#316).
|
|
local WEAK_KEYS = { __mode = "k" }
|
|
-- 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 = setmetatable({}, WEAK_KEYS)
|
|
-- 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 = setmetatable({}, WEAK_KEYS)
|
|
-- image -> { path, pal } so palette-fade variants (see fadeImage) can be
|
|
-- rebuilt for any battle pic, whatever code loaded it
|
|
local imageMeta = setmetatable({}, WEAK_KEYS)
|
|
-- 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 / COLORS change (PaletteFX.setMode calls this): the next
|
|
-- getImage re-resolves every pic through the asset search path and
|
|
-- re-measures its ground padding. ONLY the path->image cache is dropped.
|
|
-- Wiping the three per-image tables as well orphaned the pics a running
|
|
-- battle already holds: imagePadBottom went nil, so backPlacement lost the
|
|
-- four transparent rows it grounds the back pic on and the pic jumped
|
|
-- pad * 2x = 8px UP the frame COLORS changed (#316), and imageMeta went nil,
|
|
-- so picImage's forced-mono grayImage (#207), fadeImage's BGP variants and
|
|
-- imagePathOf's battle_sprite_scales lookup all silently stopped resolving.
|
|
-- Those rows are weak-keyed, so entries for pics nothing references any more
|
|
-- are collected on their own rather than leaking.
|
|
function BattleState.invalidate()
|
|
imageCache = {}
|
|
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
|
|
|
|
-- Custom trainer portraits can opt into the same Advanced OBJ palette source
|
|
-- as their overworld walker. Vanilla trainers preserve the hardware-faithful
|
|
-- MEWMON fallback used during the battle introduction.
|
|
function BattleState.trainerPalette(data, trainer)
|
|
local source = trainer and trainer.paletteSource
|
|
if source then
|
|
local PaletteFX = require("src.render.PaletteFX")
|
|
local colors, group = PaletteFX.spriteObp({ paletteSource = source }, trainer.id)
|
|
if colors then
|
|
return { name = "trainer:" .. source .. ":" .. tostring(group), colors = colors }
|
|
end
|
|
end
|
|
return namedPalette(data, "MEWMON")
|
|
end
|
|
|
|
-- Yellow only: ROCKET with wTrainerNo >= $2a is Jessie & James, who share
|
|
-- the class and the name "ROCKET" but battle behind their own pic
|
|
-- (home/trainers2.asm IsFightingJessieJames). picJessieJames exists only
|
|
-- in a Yellow cache extracted after #439, so an older cache keeps the
|
|
-- grunt pic until it is re-imported.
|
|
function BattleState.trainerPicPath(data, trainer, oppClass, partyIndex)
|
|
if oppClass == "OPP_ROCKET" and (partyIndex or 1) >= 42
|
|
and trainer and trainer.picJessieJames then
|
|
return trainer.picJessieJames
|
|
end
|
|
if trainer and trainer.pic then return trainer.pic end
|
|
local base = trainer and trainer.basePic and data.trainers[trainer.basePic]
|
|
return base and base.pic or nil
|
|
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 blacked-out battle screen. HandlePlayerBlackOut (core.asm:1151) runs
|
|
-- SET_PAL_BATTLE_BLACK, i.e. SetPal_BattleBlack sends PalPacket_Black --
|
|
-- PAL_BLACK in all four slots of BlkPacket_Battle (engine/gfx/palettes.asm:
|
|
-- 22-25). The mon pics are drawn OVER the zone pass with their palette
|
|
-- already baked in, so darkening them means re-baking through PAL_BLACK the
|
|
-- way fadeImage re-bakes through a BGP permutation (#292). Reads the palette
|
|
-- out of the active pack, exactly like sgbBattlePals, so the zone pass and
|
|
-- the pics can never disagree. trueColor art has no DMG shades to remap.
|
|
local function blackImage(data, img)
|
|
local meta = imageMeta[img]
|
|
if not meta or meta.trueColor then return img end
|
|
local PaletteFX = require("src.render.PaletteFX")
|
|
local pack = PaletteFX.pack(data)
|
|
local colors = pack and pack.palettes and pack.palettes.BLACK
|
|
if not colors then return img end
|
|
local name = PaletteFX.usesGbcPack() and "redpp:BLACK" or "BLACK"
|
|
return getImage(meta.path, { name = name, colors = colors }) or img
|
|
end
|
|
|
|
-- the asset path a loaded battle image came from (nil for the headless
|
|
-- stub images), so the battle_sprite_scales registry can be looked up by
|
|
-- the same path data references
|
|
local function imagePathOf(img)
|
|
local m = imageMeta[img]
|
|
return m and m.path
|
|
end
|
|
|
|
-- the image a battler pic actually draws with this frame
|
|
function BattleState:picImage(img)
|
|
local PaletteFX = require("src.render.PaletteFX")
|
|
-- #207: OG / OG INV / CLASSIC are forced-mono display modes. A battle that
|
|
-- exposes no SGB zones (sgbPalettes() == nil) has a whole-screen GRAYS zone
|
|
-- invented by PaletteFX.ensureZones, so Renderer:endFrame re-thresholds the
|
|
-- WHOLE finished frame through the shade shader a second time (keyed on the
|
|
-- red channel). A pic baked with the species' SGB color is then remapped
|
|
-- again and loses its warm mid shades -- REDMON's reds 1.0/0.839 both land in
|
|
-- the c0 bucket, collapsing CHARMANDER's body into the white paper and
|
|
-- leaving only the outline. Emit the raw DMG-gray build instead (exactly
|
|
-- what the SE_WAVY_SCREEN grayPics path already does) so the downstream remap
|
|
-- recolors 255->c0/170->c1/85->c2/0->c3 and all four shades survive; cool
|
|
-- palettes (CYANMON reds 0.678/0.451) already rendered correctly. This mode
|
|
-- set mirrors PaletteFX.ensureZones / effectiveColors -- keep them in sync.
|
|
local mono = PaletteFX.mode == "og" or PaletteFX.mode == "og_inv"
|
|
or PaletteFX.mode == "classic"
|
|
if self.grayPics or mono then return grayImage(img) end
|
|
-- SET_PAL_BATTLE_BLACK covers every battle palette slot, so the pics go
|
|
-- dark with the HP bars while the blackout text is up (#292). The intro
|
|
-- silhouette slide (SlidePlayerAndEnemySilhouettesOnScreen) darkens the
|
|
-- same way: the original slides both pics in under the %11100100
|
|
-- silhouette palette and only runs SET_PAL_BATTLE once they have landed,
|
|
-- so a still-sliding pic reads as a black silhouette, exactly like the
|
|
-- evolution movie's PAL_BLACK (#577). Below the mono check on purpose:
|
|
-- the forced-mono modes re-threshold the whole frame downstream, and the
|
|
-- DMG had no SGB darkening to begin with.
|
|
if self.blackedOut or (self.introSlide or 0) > 0 then
|
|
return blackImage(self.data, 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)
|
|
-- Strings.source, not Strings: this table is built at require time, before
|
|
-- Strings.load has a catalog, so translating here would freeze the English.
|
|
-- The marker is a no-op that puts these lines in the catalog anyway; the
|
|
-- lookup happens where chargeText is formatted below.
|
|
local CHARGE_TEXT = {
|
|
FLY = Strings.source("%s\nflew up high!"),
|
|
DIG = Strings.source("%s\ndug a hole!"),
|
|
RAZOR_WIND = Strings.source("%s\nmade a whirlwind!"),
|
|
SOLARBEAM = Strings.source("%s\ntook in sunlight!"),
|
|
SKULL_BASH = Strings.source("%s\nlowered its head!"),
|
|
SKY_ATTACK = Strings.source("%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)
|
|
-- HUD status label (DrawHUDsAndHPBars); mon.status can land mid-move
|
|
-- while the tilemap still shows the prior condition until the next
|
|
-- post-action HUD refresh (core.asm after Execute*Move)
|
|
shownStatus = mon.status,
|
|
stages = {},
|
|
-- volatile state; Transform/Conversion/Mimic override the cur* fields
|
|
curStats = mon.stats,
|
|
curTypes = def.types,
|
|
curMoves = mon.moves,
|
|
sprite = (function()
|
|
local Sprites = require("src.pokemon.Sprites")
|
|
local path, tc = Sprites.path(data, mon.species,
|
|
isPlayer and "back" or "front",
|
|
{ mon = mon, kind = "battle" })
|
|
return getImage(path, monPalette(data, mon.species), tc)
|
|
end)(),
|
|
}
|
|
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 Sprites = require("src.pokemon.Sprites")
|
|
local path, tc = Sprites.path(self.data, species,
|
|
isPlayerSide and "back" or "front", { kind = "battle" })
|
|
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(path,
|
|
colors and { name = name, colors = colors } or nil,
|
|
tc)
|
|
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 = Strings("The hooked\n%s\nattacked!", self.enemy.name)
|
|
else
|
|
self.introText = Strings("Wild %s\nappeared!", 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(
|
|
BattleState.trainerPicPath(game.data, self.trainer, oppClass, partyIndex),
|
|
BattleState.trainerPalette(game.data, self.trainer))
|
|
self.introText = Strings("%s wants\nto fight!", self.trainer.name)
|
|
return self
|
|
end
|
|
|
|
-- The disguise itself. InitWildBattle .isGhost (engine/battle/core.asm)
|
|
-- swaps only the pic and the nick -- wEnemyMonSpecies2 still holds the real
|
|
-- species, so the SGB palette stays the disguised mon's. ghostReal keeps
|
|
-- what LoadEnemyMonData puts back when the scope unveils.
|
|
local function disguiseAsGhost(self)
|
|
self.ghostReal = { name = self.enemy.name, sprite = self.enemy.sprite }
|
|
self.enemy.name = "GHOST"
|
|
self.enemy.sprite = getImage("assets/generated/battle/front/ghost.png",
|
|
monPalette(self.data, self.enemy.mon.species))
|
|
self.introText = Strings("The GHOST\nappeared!")
|
|
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
|
|
disguiseAsGhost(self)
|
|
end
|
|
|
|
-- The RESTLESS SOUL with the SILPH SCOPE in the bag. Mechanically this is
|
|
-- not a ghost battle at all -- IsGhostBattle (core.asm) returns false the
|
|
-- moment the scope is in the bag, so the mon attacks, is attacked and rolls
|
|
-- for flight normally -- but InitWildBattle still enters disguised: its
|
|
-- .isGhost branch fires on wCurOpponent == RESTLESS_SOUL as well. Taking
|
|
-- the disguise off is PrintBeginningBattleText .isMarowak's job
|
|
-- (engine/battle/common_text.asm), which enter() queues through
|
|
-- queueScopeReveal (#492).
|
|
function BattleState:makeUnveiledGhost()
|
|
self.scopeReveal = true
|
|
disguiseAsGhost(self)
|
|
end
|
|
|
|
-- MarowakAnim (engine/battle/ghost_marowak_anim.asm) counted in frames: the
|
|
-- ghost pic is copied into OAM and flashed 8 times by xor-ing rOBP1 with
|
|
-- $80 (10 frames a side), the BG pic underneath is swapped to the RESTLESS
|
|
-- SOUL, rOBP1 is shifted left two bits every 10 frames until the ghost has
|
|
-- faded into the paper (3 shifts), and the Marowak copy fades back in the
|
|
-- same way (4 shifts). Alpha over the paper is what those OBP shifts look
|
|
-- like on screen.
|
|
BattleState.GHOST_FLASH_FRAMES = 80
|
|
BattleState.GHOST_FADE_OUT_FRAMES = 30
|
|
BattleState.GHOST_FADE_IN_FRAMES = 40
|
|
BattleState.GHOST_REVEAL_FRAMES = 150
|
|
|
|
-- The rows .isMarowak prints over the disguise, after the "GHOST appeared!"
|
|
-- box enter() has already queued: the unveil line, MarowakAnim, then "Wild
|
|
-- MAROWAK appeared!" under the restored name. The fall-through into
|
|
-- .playSFX at the end is the same SFX_TRAINER_APPEARED the port does not
|
|
-- play for trainer intros either, so it is left out here too.
|
|
function BattleState:queueScopeReveal()
|
|
local unveiled = self.data.text and self.data.text._UnveiledGhostText
|
|
self:say(unveiled
|
|
or Strings("SILPH SCOPE\nunveiled the\vGHOST's identity!"))
|
|
self:act(function() self.ghostReveal = { t = 0 } end)
|
|
table.insert(self.queue, { wait = BattleState.GHOST_REVEAL_FRAMES })
|
|
self:say(Strings("Wild %s\nappeared!",
|
|
self.ghostReal and self.ghostReal.name or self.enemy.name))
|
|
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.
|
|
-- Yellow's Pallet intro (BATTLE_TYPE_PIKACHU) is the same simulated
|
|
-- script under "PROF.OAK" (pokeyellow core.asm .profOakName), so the
|
|
-- displayed thrower name is a parameter.
|
|
function BattleState:makeOldManDemo(name)
|
|
self.demo = true
|
|
self.demoName = name or "OLD MAN"
|
|
-- LoadPlayerBackPic and DisplayBattleMenu split on the same wBattleType:
|
|
-- BATTLE_TYPE_OLD_MAN gets .oldManName + OldManPicBack, BATTLE_TYPE_PIKACHU
|
|
-- gets .profOakName + ProfOakPicBack (pokeyellow core.asm). The thrower
|
|
-- name the caller passes IS that distinction here, so it picks the pic too
|
|
-- -- data/scripts/story2.lua is the only site that names PROF.OAK (#557).
|
|
self.oakDemo = name == "PROF.OAK"
|
|
-- Yellow's Pallet intro runs this before the player owns any mon
|
|
-- (BATTLE_TYPE_PIKACHU precedes the lab gift), so newWild flagged the
|
|
-- battle dead for lack of a party. The demo never sends out, draws, or
|
|
-- acts with the player side; a hidden placeholder battler keeps the
|
|
-- shared battle phases nil-safe.
|
|
if not self.player then
|
|
self.dead = false
|
|
self.player = makeBattler(self.game.data,
|
|
Pokemon.new(self.game.data, self.enemy.mon.species, 5), true)
|
|
end
|
|
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
|
|
|
|
-- Message that opens YES/NO once typed out, keeping the text visible
|
|
-- underneath (pokered `done` + TWO_OPTION_MENU / TextBox opts.choice).
|
|
function BattleState:sayChoice(text, onChoose)
|
|
table.insert(self.queue, { text = text, choice = onChoose })
|
|
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.
|
|
-- `stopAt` pins how far that battler's bar may drain on this row. A
|
|
-- multi-hit move takes every strike off the model while the turn is still
|
|
-- being queued, so an unpinned row would drain straight to the
|
|
-- post-last-hit HP and the later strikes would animate nothing (#394);
|
|
-- ApplyDamageToEnemyPokemon runs UpdateHPBar2 once per strike inside the
|
|
-- wNumAttacksLeft loop (engine/battle/core.asm:4727).
|
|
function BattleState:drainNext(battler, stopAt)
|
|
self.nextInsert = (self.nextInsert or 0) + 1
|
|
table.insert(self.queue, self.nextInsert,
|
|
{ drain = true, battler = battler, stopAt = stopAt })
|
|
end
|
|
|
|
-- Queue a pure frame hold at the current insert point, the way the original
|
|
-- spends DelayFrames between the beats of a turn. Mirrors sayNext/drainNext
|
|
-- so a caller can interleave holds with messages in source order.
|
|
function BattleState:waitNext(frames)
|
|
if not frames or frames <= 0 then return end
|
|
self.nextInsert = (self.nextInsert or 0) + 1
|
|
table.insert(self.queue, self.nextInsert, { wait = frames })
|
|
end
|
|
|
|
-- One frame of the HP-bar drain (engine/gfx/hp_bar.asm UpdateHPBar).
|
|
--
|
|
-- The original walks the bar ONE HP POINT per loop iteration (:81-120), and
|
|
-- what each iteration costs depends on the side:
|
|
-- * UpdateHPBar_PrintHPNumber spends a DelayFrame (:234) reprinting the
|
|
-- number, but only when wHPBarType is nonzero (:207-209) -- the player's
|
|
-- own HUD and the party menu, never the enemy's;
|
|
-- * UpdateHPBar_AnimateHPBar spends 2 frames for each pixel the bar
|
|
-- actually moved (:147-148), and most single-HP steps move none.
|
|
-- So the player's bar drains at 1 HP per frame plus 2 frames per pixel,
|
|
-- while the enemy's costs nothing until it crosses a pixel boundary. The
|
|
-- old flat maxHP/96 rate was the enemy-side formula applied to both, which
|
|
-- ran a 150 HP mon's full drain in 96 frames against hardware's 249.
|
|
--
|
|
-- Returns true while animating.
|
|
function BattleState:stepHPDrain()
|
|
local busy = false
|
|
for _, b in ipairs({ self.player, self.enemy }) do
|
|
if b and b.shownHP then
|
|
-- drainFloor is the stop the running row carries (see drainNext)
|
|
local goal = b.mon.hp
|
|
if b.drainFloor and b.drainFloor > goal
|
|
and b.shownHP >= b.drainFloor then
|
|
goal = b.drainFloor
|
|
end
|
|
if (b.drainHold or 0) > 0 then
|
|
b.drainHold = b.drainHold - 1
|
|
busy = true
|
|
elseif b.shownHP ~= goal then
|
|
local maxHP = math.max(1, b.mon.stats.hp)
|
|
local playerSide = (b == self.player)
|
|
local cost = 0
|
|
-- consume whole HP steps until this frame's budget is spent; on the
|
|
-- enemy HUD several free steps can land in the same frame
|
|
while b.shownHP ~= goal and cost < 1 do
|
|
local nextHP = b.shownHP + ((b.shownHP > goal) and -1 or 1)
|
|
cost = cost + Timing.hpDrainStepFrames(b.shownHP, nextHP,
|
|
maxHP, playerSide)
|
|
b.shownHP = nextHP
|
|
end
|
|
b.drainHold = math.max(0, cost - 1)
|
|
b.draining = true
|
|
busy = true
|
|
elseif b.draining then
|
|
-- .animateHPBarDone's final number print, one more pixel step and
|
|
-- Delay3 (hp_bar.asm:132-135); this frame is the first of them
|
|
b.draining = nil
|
|
b.drainHold = Timing.hpDrainClosingFrames(b == self.player) - 1
|
|
busy = true
|
|
end
|
|
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
|
|
|
|
-- Parse a battle message into its rendered lines. The extractor marks
|
|
-- \n = next line and \v = CONT (home/text.asm ContText: draw the blinking
|
|
-- ▼, WaitForTextScrollButtonPress, then ScrollTextUpOneLine); the boosted /
|
|
-- EXP.ALL exp lines end in the CONT code in the ROM (data/generated/text.lua
|
|
-- _BoostedText/_WithExpAllText = "...\011"). Each entry is { codes, cont },
|
|
-- cont true when the line was preceded by \v. Splitting before Font.encode
|
|
-- keeps the control chars out of the glyph stream. The box then types into
|
|
-- a rolling 2-line window (self.shown) that scrolls when a 3rd line arrives
|
|
-- instead of drawing it off-screen at y=144 (#216).
|
|
function BattleState:startMessage(item)
|
|
self.current = item
|
|
self.lines = {}
|
|
self.total = 0
|
|
local text = item.text or ""
|
|
local pos, cont = 1, false
|
|
while true do
|
|
local npos = text:find("[\n\v]", pos)
|
|
local chunk = npos and text:sub(pos, npos - 1) or text:sub(pos)
|
|
local codes = Font.encode(chunk)
|
|
self.lines[#self.lines + 1] = { codes = codes, cont = cont }
|
|
self.total = self.total + #codes
|
|
if not npos then break end
|
|
cont = text:sub(npos, npos) == "\v"
|
|
pos = npos + 1
|
|
end
|
|
self.shown = {} -- up to two visible lines of revealed glyph codes
|
|
self.lineIndex = 0
|
|
-- self.charIndex counts glyphs typed across the WHOLE message (drivers read
|
|
-- it against self.total); the current line's revealed count is #shown[last]
|
|
self.charIndex = 0
|
|
self.msgWaiting = nil
|
|
self.msgPrompt = nil
|
|
self.scrollPx = nil
|
|
self:beginMsgLine()
|
|
end
|
|
|
|
-- Start typing the next line into the rolling window. When the box already
|
|
-- shows two lines, drop the top one and set the pixel scroll-up
|
|
-- (ScrollTextUpOneLine), mirroring TextBox:beginLine.
|
|
function BattleState:beginMsgLine()
|
|
self.lineIndex = self.lineIndex + 1
|
|
local ln = self.lines[self.lineIndex]
|
|
self.codes = ln and ln.codes or {}
|
|
if #self.shown >= 2 then
|
|
table.remove(self.shown, 1)
|
|
self.scrollPx = 8
|
|
end
|
|
self.shown[#self.shown + 1] = {}
|
|
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
|
|
-- WaitForSoundToFinish (home/delay.asm:15-20) blocks until the sfx has
|
|
-- actually stopped sounding, which is how the original gives a sound its
|
|
-- own clear window instead of letting the next beat play over it
|
|
if self.waitingSound then
|
|
local src = self.waitingSound
|
|
if src and src.isPlaying and src:isPlaying() then return true end
|
|
self.waitingSound = nil
|
|
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
|
|
if self.player then self.player.drainFloor = nil end
|
|
if self.enemy then self.enemy.drainFloor = nil end
|
|
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
|
|
if item.battler then item.battler.drainFloor = item.stopAt end
|
|
return true
|
|
end
|
|
if item.wait then
|
|
self.waitFrames = item.wait
|
|
return true
|
|
end
|
|
if item.waitSound then
|
|
-- the source is fetched now, not when the row was queued, so the
|
|
-- act() that started the sound has already run
|
|
self.waitingSound = item.waitSound()
|
|
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
|
|
-- PlayMoveAnimation writes wAnimationID, calls Delay3, and only then
|
|
-- jumps to MoveAnimation (core.asm:6635-6640), so three frames pass
|
|
-- between the move's announcement and the first frame of its
|
|
-- animation. Put the row back and pay that first.
|
|
if item.anim and not item.animDelayed then
|
|
item.animDelayed = true
|
|
table.insert(self.queue, 1, item)
|
|
self.waitFrames = Timing.MOVE_ANIM_PRE
|
|
return true
|
|
end
|
|
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
|
|
local input = self.game.input
|
|
-- a \v CONT wait holds the box until A/B, then scrolls the next line in
|
|
-- (home/text.asm ContText); this keeps a 3rd line on-screen (#216)
|
|
if self.msgWaiting then
|
|
-- _ContText prints the â–¼ and runs ProtectedDelay3 BEFORE ManualTextScroll
|
|
-- starts watching the joypad (home/text.asm:263-267), so three frames
|
|
-- pass with the arrow up and the button ignored
|
|
if (self.msgPreWait or 0) > 0 then
|
|
self.msgPreWait = self.msgPreWait - 1
|
|
return true
|
|
end
|
|
if input:wasPressed("a") or input:wasPressed("b") then
|
|
self.msgWaiting = nil
|
|
self:beginMsgLine()
|
|
-- then the two ScrollTextUpOneLine calls block for 5 frames each
|
|
-- (home/text.asm:280-305) before the next line starts typing
|
|
self.waitFrames = Timing.TEXT_SCROLL_PAIR
|
|
end
|
|
return true
|
|
end
|
|
local cur = self.shown[#self.shown]
|
|
if #cur < #self.codes then
|
|
-- Battle text prints through the same PrintText path as everything
|
|
-- else, so it pays PrintLetterDelay per character (home/print_text.asm:
|
|
-- 4-45): hFrameCounter is loaded from wOptions & $f -- the OPTION text
|
|
-- speed, 1/3/5, default 3 -- and the loop spins until it drains, unless
|
|
-- A or B is held, which collapses the wait to a single DelayFrame.
|
|
-- This used to run two glyphs per frame flat, six times hardware speed
|
|
-- at the default setting, and ignored the text-speed option entirely.
|
|
local delay = (self.game.save.options and self.game.save.options.textSpeed)
|
|
or 3
|
|
if delay ~= 1 and delay ~= 3 and delay ~= 5 then delay = 3 end
|
|
if input:isDown("a") or input:isDown("b") then delay = 1 end
|
|
self.charTimer = (self.charTimer or 0) + 1
|
|
while self.charTimer >= delay and #cur < #self.codes do
|
|
self.charTimer = self.charTimer - delay
|
|
cur[#cur + 1] = self.codes[#cur + 1]
|
|
self.charIndex = self.charIndex + 1
|
|
end
|
|
elseif self.lineIndex < #self.lines then
|
|
-- current line finished, more lines remain: \v waits for A/B + ▼ before
|
|
-- scrolling, \n advances now (beginMsgLine scrolls if the box is full)
|
|
if self.lines[self.lineIndex + 1].cont then
|
|
self.msgWaiting = true
|
|
self.msgPreWait = Timing.TEXT_PRE_ADVANCE
|
|
else
|
|
self:beginMsgLine()
|
|
end
|
|
else
|
|
local item = self.current
|
|
-- TrainerAboutToUseText ends in `done` then DisplayTextBoxID: YES/NO
|
|
-- overlays the still-visible "Will … change POKéMON?" page.
|
|
if item and item.choice and not item.choiceOpen then
|
|
item.choiceOpen = true
|
|
self.waitingUI = true
|
|
local ChoiceBox = require("src.ui.ChoiceBox")
|
|
local battle = self
|
|
self.game.stack:push(ChoiceBox.new(self.game, function(yes)
|
|
local fn = item.choice
|
|
battle.current = nil
|
|
fn(yes)
|
|
end))
|
|
return true
|
|
end
|
|
if not (item and item.choice) then
|
|
-- The page is typed out and waiting on the player: PromptText
|
|
-- (home/text.asm:209-217) writes '▼' at (18,16) and ManualTextScroll
|
|
-- blinks it until A/B, so the arrow belongs on a finished page and not
|
|
-- only on a \v CONT hold (#317). A flag of its own, not msgWaiting:
|
|
-- that branch above scrolls the NEXT line in, which this page has not
|
|
-- got, so reusing it would call beginMsgLine on a drained message.
|
|
if not self.msgPrompt then
|
|
self.msgPrompt = true
|
|
-- PromptText runs ProtectedDelay3 between writing the arrow and
|
|
-- ManualTextScroll (home/text.asm:213-217), so the page holds for
|
|
-- three frames with the button ignored before it can be dismissed.
|
|
-- Without it a queued A press could clear a page the same frame its
|
|
-- last glyph landed, which is most of "it doesn't hold sometimes".
|
|
self.msgPromptWait = Timing.TEXT_PRE_ADVANCE
|
|
end
|
|
if (self.msgPromptWait or 0) > 0 then
|
|
self.msgPromptWait = self.msgPromptWait - 1
|
|
elseif input:wasPressed("a") or input:wasPressed("b") then
|
|
self.msgPrompt = nil
|
|
self.current = nil
|
|
end
|
|
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 Strings("Go! %s!", name) end
|
|
if pct >= 40 then return Strings("Do it! %s!", name) end
|
|
if pct >= 10 then return Strings("Get'm! %s!", name) end
|
|
return Strings("The enemy's weak!\nGet'm! %s!", 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
|
|
-- init_battle.asm: challenging a gym leader (wGymLeaderNo, the badge
|
|
-- fights only -- not Lance or the Champion) bumps the companion's
|
|
-- happiness the moment the battle starts
|
|
self.isGymLeader = isBoss
|
|
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()
|
|
-- Out of useable POKéMON before the battle even starts. pokered does not
|
|
-- skip the battle: .checkAnyPartyAlive (engine/battle/core.asm:158-162)
|
|
-- runs right after the intro and jumps to HandlePlayerBlackOut, so the
|
|
-- player blacks out and afterBattle's "lose" path revives the party at the
|
|
-- last heal point. Handing the map back with a 0 HP party instead bricked
|
|
-- the save: every later encounter aborted here too and sighted trainers
|
|
-- re-engaged forever (#425). self.dead alone is not the test --
|
|
-- makeOldManDemo and LinkBattle install a player battler after the
|
|
-- constructor flagged the battle dead.
|
|
if self.dead and not self.player then
|
|
local name = self.game.save.player.name
|
|
self.result = "lose"
|
|
self.game.stack:pop()
|
|
require("src.core.Music").restoreMap(self.data)
|
|
Runtime.emit("battle.ended", { battle = self, result = "lose", skipped = true })
|
|
local onFinish = self.onFinish
|
|
local function blackedOut()
|
|
if onFinish then onFinish("lose") end
|
|
end
|
|
-- the Oak's Lab starter rival returns above PlayerBlackedOutText2 and
|
|
-- afterBattle keeps the player in the lab, so print nothing there
|
|
if BattleState.isOaksLabStarterRival(self) then return blackedOut() end
|
|
-- _PlayerBlackedOutText2 (data/text/text_2.asm:896): the two paragraphs
|
|
-- playerMonFainted queues on the battle screen; there is no battle
|
|
-- screen to queue them on here, so they print over the map.
|
|
self.game.stack:push(require("src.render.TextBox").new(self.game,
|
|
Strings("%s is out of\nuseable POKéMON!", name) .. "\f"
|
|
.. Strings("%s blacked\nout!", name), blackedOut))
|
|
return
|
|
end
|
|
local Music = require("src.core.Music")
|
|
self.musicKind = self:computeMusicKind()
|
|
if self.isGymLeader then
|
|
require("src.world.PikachuFollower")
|
|
.modifyHappiness(self.game.save, "GYMLEADER")
|
|
end
|
|
-- 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
|
|
-- BATTLE BG "world" drops this battle's opacity so StateStack keeps drawing
|
|
-- the overworld underneath it (see bgMode). Per instance, so the class
|
|
-- default stays opaque for every other battle and for older saves.
|
|
self.isOpaque = self:bgMode() ~= "world"
|
|
self.introSlide = Timing.BATTLE_SLIDE_IN_FRAMES
|
|
self.showEnemyTrainer = self.kind == "trainer" and self.trainerPic ~= nil
|
|
-- DrawAllPokeballs (common_text.asm:27) puts the party ball rows AND the
|
|
-- HUD corner/underline tiles under them (PlacePlayerHUDTiles /
|
|
-- PlaceEnemyHUDTiles, draw_hud_pokeball_gfx.asm:119-165) on screen with
|
|
-- the intro text; _InitBattleCommon (core.asm:6755-6762) ClearScreenArea's
|
|
-- both HUD blocks and ClearSprites's the balls the moment that text is
|
|
-- dismissed. This flag is exactly that window: drawHUDs draws the intro
|
|
-- chrome while it is up and holds the real enemy HUD back, since a wild
|
|
-- battle's DrawEnemyHUDAndHPBar only runs after the text (#317). The
|
|
-- draw is still gated on the slide having landed, so nothing shows while
|
|
-- the silhouettes are still coming in.
|
|
self.introBalls = true
|
|
-- 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)
|
|
-- field.playerPics picks the pic (the catch tutorial's old man fights in
|
|
-- the player's place), then the player.sprite hook gets the last word so
|
|
-- a mod can vary it per save. It loads through getImage like every other
|
|
-- battle pic, so a replacement keeps the SGB recolor, the transition
|
|
-- fade, the ground-padding measurement and battle_sprite_scales.
|
|
local backPath, backTrueColor =
|
|
require("src.pokemon.Sprites").playerPath(self.data, "back",
|
|
{ kind = "battle", demo = self.demo, oakDemo = self.oakDemo,
|
|
battle = self })
|
|
self.playerBackPic = getImage(backPath,
|
|
namedPalette(self.data, "MEWMON"), backTrueColor)
|
|
self.showPlayerBack = self.playerBackPic ~= nil
|
|
-- the enemy's cry as it appears (data/pokemon/cries.asm); PlayCry sits at
|
|
-- a different point in each battle kind, so queue it per branch
|
|
local function queueEnemyCry()
|
|
self:act(function()
|
|
require("src.core.Sound").playCry(self.data, self.enemy.mon.species)
|
|
end)
|
|
end
|
|
-- PrintBeginningBattleText (engine/battle/common_text.asm:10-19): a wild
|
|
-- battle calls PlayCry BEFORE PrintText WildMonAppearedText, so the cry
|
|
-- sounds with the "Wild X appeared!" box instead of waiting on the A
|
|
-- press that clears its `prompt` (#303). The Silph-Scope-less tower
|
|
-- ghost gets no cry at all (common_text.asm:43-48), and neither does the
|
|
-- unveiled MAROWAK: .isMarowak never reaches PlayCry (#492).
|
|
if self.kind ~= "trainer" and self.kind ~= "link"
|
|
and not self.ghost and not self.scopeReveal then
|
|
queueEnemyCry()
|
|
end
|
|
-- PrintBeginningBattleText .trainerBattle (common_text.asm): a trainer
|
|
-- battle gives SFX_SILPH_SCOPE a clear window -- PlaySound, then
|
|
-- WaitForSoundToFinish, which blocks -- and only after `ld c, 20 /
|
|
-- DelayFrames` do DrawAllPokeballs and the "wants to fight!" text run.
|
|
-- The balls and the text used to appear on the same frame the silhouettes
|
|
-- landed, so the sound had to share its whole duration with the ball draw
|
|
-- and the text scroll instead of landing on its own.
|
|
--
|
|
-- The sfx is extracted as "Trainer_Appeared" (tools/rom_manifest.json
|
|
-- sfxHeaders, bank 8 / $42bb -- the same header pokered names
|
|
-- SFX_Silph_Scope); nothing had ever played it.
|
|
if self.kind == "trainer" then
|
|
self:act(function()
|
|
self.introSfx = require("src.core.Sound").play(self.data,
|
|
"Trainer_Appeared")
|
|
end)
|
|
table.insert(self.queue, { waitSound = function() return self.introSfx end })
|
|
table.insert(self.queue, { wait = Timing.TRAINER_INTRO_SFX_GAP })
|
|
end
|
|
self:say(self.introText)
|
|
-- the unveil rides on that same box, before _InitBattleCommon clears the
|
|
-- intro chrome below (#492)
|
|
if self.scopeReveal then self:queueScopeReveal() end
|
|
-- _InitBattleCommon (core.asm:6755-6762): the instant the intro text is
|
|
-- dismissed both HUD blocks are cleared and ClearSprites drops the
|
|
-- pokeball OAM, so the intro chrome never returns for the rest of the
|
|
-- battle -- not on a switch, and not when the beaten trainer's pic
|
|
-- scrolls back in (#317, #282)
|
|
self:act(function() self.introBalls = nil end)
|
|
if self.kind == "trainer" then
|
|
-- EnemySendOutFirstMon (core.asm:1308-1310): SlideTrainerPicOffScreen
|
|
-- walks the foe's pic off the RIGHT edge (hlcoord 18,0, a = 8 tiles,
|
|
-- one tile every 2 frames) BEFORE TrainerSentOutText -- the pic does
|
|
-- not blink out under the text (#317)
|
|
self:act(function() self:slidePic("foe", 0, 64, 4) end)
|
|
table.insert(self.queue, { wait = 16 })
|
|
self:act(function()
|
|
self.showEnemyTrainer = false
|
|
self:slidePic("foe")
|
|
end)
|
|
self:say(Strings("%s sent\nout %s!", 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:startGrowIn(self.enemy)
|
|
end)
|
|
queueEnemyCry()
|
|
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(Strings("%s sent\nout %s!", self.opponentName or "FOE",
|
|
self.enemy.name))
|
|
self:act(function()
|
|
self.enemySendingOut = false
|
|
self:startGrowIn(self.enemy)
|
|
end)
|
|
queueEnemyCry()
|
|
end
|
|
-- StartBattle .foundFirstAliveEnemyMon (core.asm:152-156): the `call nz`
|
|
-- gates only EnemySendOutFirstMon -- the `ld c, 40 / call DelayFrames`
|
|
-- after it is unconditional, so a wild battle pays it too, between
|
|
-- "Wild X appeared!" and "Go! Y!". It lands before .playerSendOutFirstMon
|
|
-- (:166), not at the end of the intro. Appended, not waitNext'd: the
|
|
-- intro is built linearly, and waitNext's insert point is for rows added
|
|
-- while the queue is already running.
|
|
table.insert(self.queue, { wait = Timing.BATTLE_START_SENDOUT })
|
|
if not self.safari and not self.demo then
|
|
-- StartBattle .playerSendOutFirstMon (core.asm:236-240): the back pic
|
|
-- walks off the LEFT edge (SlideTrainerPicOffScreen, hlcoord 1,5,
|
|
-- a = 9 tiles, one tile every 2 frames) BEFORE SendOutMon prints
|
|
-- "Go! X!" -- Red does not simply vanish under the message (#317)
|
|
self:act(function() self:slidePic("back", 0, -72, 4) end)
|
|
table.insert(self.queue, { wait = 18 })
|
|
self:act(function()
|
|
self.showPlayerBack = false
|
|
self.sendingOut = true
|
|
self:slidePic("back")
|
|
end)
|
|
self:say(self:sendOutText(self.player.name))
|
|
-- then the POOF plays and the mon appears with its cry
|
|
-- (SendOutMon: message -> AnimateSendingOutMon -> PlayCry)
|
|
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
|
|
|
|
-- core.asm:297-300: both sides' FLINCHED bits are cleared as a turn's move
|
|
-- selection opens, but the clear is skipped for a mon that must recharge or
|
|
-- is locked into Rage (core.asm:293-295 -- the Hyper Beam flinch-recharge
|
|
-- glitch).
|
|
--
|
|
-- A method rather than three lines inside the menu branch, because two
|
|
-- other places need the identical rule at the identical point in the turn
|
|
-- and both got it wrong by not having it:
|
|
--
|
|
-- * guarded PER BATTLER, not off self.player alone. This runs on
|
|
-- whichever machine is looking at its own menu, and in a lockstep link
|
|
-- battle "self.player" is the host's mon on one peer and the guest's on
|
|
-- the other, so one shared guard let one peer clear both flags while
|
|
-- the other cleared neither -- a bogus desync draw in a winnable match.
|
|
-- * a tournament spectator never enters the menu phase at all (it has no
|
|
-- decision to make), so nothing cleared a flinch in its replay: the
|
|
-- flag survived into the next turn, ate a move the real players saw
|
|
-- land, and from there the replay was watching a different battle.
|
|
-- LinkBattle.newSpectator calls this at the head of every turn.
|
|
function BattleState:clearTurnFlinches()
|
|
for _, b in ipairs({ self.player, self.enemy }) do
|
|
if b and not (b.mustRecharge or b.rageMove) then b.flinched = false end
|
|
end
|
|
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).
|
|
-- Read, not written: executeAction refreshes battler.boundTurns from the
|
|
-- same expression when the action actually runs, and that site runs on
|
|
-- both peers of a link battle. Storing it here instead wrote a hashed
|
|
-- field on whichever machine happened to open its own FIGHT menu, which
|
|
-- left the two peers holding boundTurns=0 against nil for the same
|
|
-- battler and ended the match as a desync over a mirror of a mirror.
|
|
local opp = battler.isPlayer and self.enemy or self.player
|
|
local bound = opp and opp.trappingTurns
|
|
and math.max(1, opp.trappingTurns) or nil
|
|
if bound 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
|
|
|
|
-- One frame of the presentational clock: the BGP flash sequences, the
|
|
-- per-battler pic slide/hide programs, the send-out grow-in, the intro
|
|
-- slide and the screen-shake programs all advance in updateFx and nowhere
|
|
-- else. It lives behind its own entry point because a caller that has to
|
|
-- skip the rest of update() for a frame must still tick this, and a link
|
|
-- battle does exactly that on two hot paths -- waiting on the peer's action,
|
|
-- and draining a resolved lockstep turn. Skipping it there froze whatever
|
|
-- was mid-flight: a flash stuck on its inverted BGP step repainted the whole
|
|
-- UI in inverted shades, and a pic part-way through a slide-off or a grow-in
|
|
-- simply stayed gone -- for as long as the opponent took to choose.
|
|
function BattleState:tickFx()
|
|
self.frame = self.frame + 1
|
|
self:updateFx()
|
|
end
|
|
|
|
function BattleState:update(dt)
|
|
self:tickFx()
|
|
local input = self.game.input
|
|
|
|
-- safety net: HP/status changed outside a queued drain (level-up heals,
|
|
-- field effects, bag cures) snaps once the queue is idle
|
|
if self.phase == "menu" then
|
|
for _, b in ipairs({ self.player, self.enemy }) do
|
|
if b then
|
|
if b.shownHP then b.shownHP = b.mon.hp end
|
|
b.drainFloor = nil
|
|
b.shownStatus = b.mon.status
|
|
end
|
|
end
|
|
end
|
|
|
|
if self.phase == "messages" then
|
|
-- Nothing queued starts until the silhouettes have finished sliding in:
|
|
-- SlidePlayerAndEnemySilhouettesOnScreen ends with `jpfar
|
|
-- PrintBeginningBattleText` (engine/battle/core.asm:100), so the enemy
|
|
-- cry and the "Wild X appeared!" box belong at the end of the slide, not
|
|
-- on its first frame (#303). The hold sits here rather than in
|
|
-- updateQueue because only this loop has a frame clock: updateFx above
|
|
-- counts introSlide down, and a headless caller driving updateQueue on
|
|
-- its own has no slide to wait for.
|
|
if (self.introSlide or 0) > 0 then return end
|
|
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(Strings("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") then
|
|
col = math.max(0, col - 1)
|
|
elseif input:wasPressed("right") then
|
|
col = math.min(1, col + 1)
|
|
elseif input:wasPressed("up") then
|
|
row = math.max(0, row - 1)
|
|
elseif input:wasPressed("down") then
|
|
row = math.min(1, row + 1)
|
|
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
|
|
self:clearTurnFlinches()
|
|
-- 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") then
|
|
col = math.max(0, col - 1)
|
|
elseif input:wasPressed("right") then
|
|
col = math.min(1, col + 1)
|
|
elseif input:wasPressed("up") then
|
|
row = math.max(0, row - 1)
|
|
elseif input:wasPressed("down") then
|
|
row = math.min(1, row + 1)
|
|
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(Strings("%s is too\nscared to move!", 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(Strings("%s has no\nmoves left!", 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
|
|
-- The widescreen layout lays the four slots out as a 2x2 grid, so all
|
|
-- four directions navigate it; nil means no direction was pressed and
|
|
-- A / B / SELECT below behave the same in either layout.
|
|
local grid = self:wideLayout()
|
|
and WideBattle.navigate(self.moveIndex, #moves, input)
|
|
if grid then
|
|
self.moveIndex = grid
|
|
elseif 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(Strings("The move is\ndisabled!"))
|
|
self.phase = "messages"
|
|
self.afterQueue = "menu"
|
|
elseif mv.pp <= 0 then
|
|
self:say(Strings("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
|
|
-- the copy menu shares the widescreen move grid, so it navigates the
|
|
-- same way there (the classic layout keeps the vertical list)
|
|
local grid = self:wideLayout()
|
|
and WideBattle.navigate(self.mimicIndex, #moves, input)
|
|
if grid then
|
|
self.mimicIndex = grid
|
|
elseif 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(Strings("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(Strings("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(Strings("%s\nlearned\n%s!", 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 = Strings("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(Strings("%s used\nPOKé BALL!", self.demoName or "OLD MAN"))
|
|
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(Strings("All right!\n%s was\ncaught!", 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()
|
|
-- the same ret: a decided battle never reaches HandlePoisonBurnLeechSeed
|
|
-- or CheckNumAttacksLeft (core.asm:417-421, 456-460), so the residual
|
|
-- sweep and the trapping-counter release are skipped on the turn a
|
|
-- Teleport escape (or a win/loss/capture) settles it (#441). The
|
|
-- turn_ended hook still fires: mods count turns, not residuals.
|
|
if self.result then
|
|
Runtime.emit("battle.turn_ended", { battle = self, turn = self.turnCount or 0 })
|
|
return
|
|
end
|
|
-- 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
|
|
|
|
-- PredefShakeScreenHorizontally (engine/gfx/screen_effects.asm): the window
|
|
-- jumps right by b for 5 frames then home for 4, b counting down to 1.
|
|
-- b = 8 for SE_SHAKE_SCREEN and the heavy applying-attack shake, b = 2 for
|
|
-- the light one.
|
|
local function fastShakeProg(b)
|
|
local prog = {}
|
|
for i = b, 1, -1 do
|
|
prog[#prog + 1] = { dx = i, frames = 5 }
|
|
prog[#prog + 1] = { dx = 0, frames = 4 }
|
|
end
|
|
return prog
|
|
end
|
|
|
|
-- AnimationShakeScreenHorizontallySlow (engine/battle/animations.asm:526):
|
|
-- rWX creeps 1px right every 2 frames b times, then back down to 0, c times
|
|
-- over. Silent -- this is the non-damaging move's feedback.
|
|
local function slowShakeProg(b, c)
|
|
local prog = {}
|
|
for _ = 1, c do
|
|
for i = 1, b do prog[#prog + 1] = { dx = i, frames = 2 } end
|
|
for i = b - 1, 0, -1 do prog[#prog + 1] = { dx = i, frames = 2 } end
|
|
end
|
|
return prog
|
|
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
|
|
fx.shakeProg = fastShakeProg(8)
|
|
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 post-animation applying-attack feedback (PlayApplyingAttackAnimation
|
|
-- -> AnimationTypePointerTable, engine/battle/animations.asm:475-524).
|
|
-- hit.animType is wAnimationType, 1..6:
|
|
-- 1 enemy damaging, no added effect ShakeScreenVertically (b=8)
|
|
-- 2 enemy damaging, added effect fast horizontal shake, b=8
|
|
-- 3 enemy non-damaging slow horizontal shake, b=6, c=2
|
|
-- 4 player damaging, no added effect BlinkEnemyMonSprite
|
|
-- 5 player damaging, added effect fast horizontal shake, b=2
|
|
-- 6 player non-damaging slow horizontal shake, b=3, c=2
|
|
-- Types 3 and 6 are silent; the rest open with PlayApplyingAttackSound,
|
|
-- which is the damage sound hit.sfx already carries. Only 1 and 4 were
|
|
-- implemented, so every move with an added effect blinked (or shook
|
|
-- vertically) instead of shaking sideways and every status move showed
|
|
-- nothing at all -- Bubblebeam, Confusion, Hypnosis (#354). A hold keeps
|
|
-- the queue still until the effect finishes.
|
|
function BattleState:applyHitFx(hit)
|
|
self.fx = self.fx or {}
|
|
-- rows queued before animType existed carry only the blink target
|
|
local t = hit.animType
|
|
if not t and hit.blink then t = hit.blink.isPlayer and 1 or 4 end
|
|
if hit.sfx then
|
|
require("src.core.Sound").play(self.data, hit.sfx)
|
|
end
|
|
if not t or not self:animationsOn() then return end
|
|
if t == 1 then
|
|
-- PredefShakeScreenVertically b=8: the window drops by b for 3 frames
|
|
-- then home for 3, b counting down
|
|
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 = Timing.SHAKE_VERTICAL -- the predef blocks until it settles
|
|
elseif t == 2 then
|
|
self.fx.shakeProg = fastShakeProg(8)
|
|
self.waitFrames = Timing.SHAKE_HORIZ_HEAVY
|
|
elseif t == 3 then
|
|
self.fx.shakeProg = slowShakeProg(6, 2)
|
|
self.waitFrames = Timing.SHAKE_HORIZ_SLOW
|
|
elseif t == 4 then
|
|
if hit.blink then
|
|
-- AnimationBlinkMon: 6 iterations of hide/5 frames/show/5 frames.
|
|
-- This is the animation for every plain damaging move the player
|
|
-- uses, and it ran at a third of its length.
|
|
self.fx.blink = { target = hit.blink, frames = Timing.BLINK_MON }
|
|
self.waitFrames = Timing.BLINK_MON
|
|
end
|
|
elseif t == 5 then
|
|
self.fx.shakeProg = fastShakeProg(2)
|
|
self.waitFrames = Timing.SHAKE_HORIZ_LIGHT
|
|
elseif t == 6 then
|
|
self.fx.shakeProg = slowShakeProg(3, 2)
|
|
self.waitFrames = Timing.SHAKE_HORIZ_SLOW2
|
|
end
|
|
end
|
|
|
|
-- Primary status effects whose pokered handler ends in
|
|
-- PlayCurrentMoveAnimation2 (engine/battle/effects.asm:1448), which sets
|
|
-- wAnimationType 6 on the player's turn and 3 on the enemy's: sleep,
|
|
-- poison, confuse, disable and the primary stat-down effects. Every other
|
|
-- primary effect goes through PlayCurrentMoveAnimation and leaves the type
|
|
-- at 0 (no applying animation): paralysis (FreezeBurnParalyzeEffect),
|
|
-- leech seed, the stat-UP effects, Splash. Side-effect stat drops are
|
|
-- skipped too -- UpdateLoweredStatDone bails out for them because the
|
|
-- damaging move's own type 2/5 shake already played.
|
|
local SLOW_SHAKE_EFFECTS = {
|
|
SLEEP_EFFECT = true, POISON_EFFECT = true, CONFUSION_EFFECT = true,
|
|
DISABLE_EFFECT = true,
|
|
ATTACK_DOWN1_EFFECT = true, DEFENSE_DOWN1_EFFECT = true,
|
|
DEFENSE_DOWN2_EFFECT = true, SPEED_DOWN1_EFFECT = true,
|
|
ACCURACY_DOWN1_EFFECT = true,
|
|
}
|
|
|
|
-- 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
|
|
-- never the stop, and healing out of the red silences it at once
|
|
-- (item_effects.asm:991-994 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
|
|
if p.fainted then return false end
|
|
-- A siren that is ALREADY sounding follows the drawn bar, not the
|
|
-- model: wLowHealthAlarm is a latch DrawPlayerHUDAndHPBar only revisits
|
|
-- once UpdateHPBar2 has finished animating (core.asm:4727-4729 /
|
|
-- core.asm:4845-4847 both drain first, then jp DrawHUDsAndHPBars), and
|
|
-- a KO clears it in RemoveFaintedPlayerMon (core.asm:1011-1016), i.e.
|
|
-- after the bar has drained empty. applyDamage takes the HP off the
|
|
-- model while the turn is still being queued, so keying a running alarm
|
|
-- off mon.hp cut it dead for the whole "used X!" line + move animation
|
|
-- + drain window (#293). max() keeps a heal out of the red silencing
|
|
-- it on the spot, the way item_effects.asm does.
|
|
local hp = p.mon.hp
|
|
if self.lowHealthAlarmOn then
|
|
hp = math.max(hp, shownHP(p))
|
|
elseif p.shownHP and p.shownHP > hp then
|
|
return false -- drain running: the HUD redraw has not happened yet
|
|
end
|
|
if hp <= 0 then return false end
|
|
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
|
|
|
|
-- Trainer-pic slides. SlideTrainerPicOffScreen (core.asm:1235) walks a
|
|
-- trainer pic off its own screen edge one tile every 2 frames (9 tiles left
|
|
-- for the player back pic, 8 tiles right for the foe), and
|
|
-- _ScrollTrainerPicAfterBattle (engine/battle/scroll_draw_trainer_pic.asm)
|
|
-- brings the beaten foe back in from the right one column every 4 frames.
|
|
-- picOff holds the live programs by slot -- "foe" = the enemy trainer pic,
|
|
-- "back" = the player's back pic -- as a screen-pixel x offset stepped
|
|
-- toward `to`; updateFx advances them, drawPicsLayer adds them, and the
|
|
-- queue rows that start them park a { wait } of the matching length. Call
|
|
-- with no target to clear a slot (#317, #282).
|
|
function BattleState:slidePic(slot, from, to, step)
|
|
self.picOff = self.picOff or {}
|
|
if to == nil then
|
|
self.picOff[slot] = nil
|
|
return
|
|
end
|
|
self.picOff[slot] = { x = from or 0, to = to, step = step or 4 }
|
|
end
|
|
|
|
-- the live x offset for a pic slot, 0 when nothing is sliding
|
|
function BattleState:picOffset(slot)
|
|
local p = self.picOff and self.picOff[slot]
|
|
return p and p.x or 0
|
|
end
|
|
|
|
function BattleState:updateFx()
|
|
if self.introSlide and self.introSlide > 0 then
|
|
self.introSlide = self.introSlide - 1
|
|
end
|
|
-- step each live trainer-pic slide toward its target; a landed program
|
|
-- holds its offset (the after-battle scroll-in rests two tiles right of
|
|
-- the battle slot) until its owner clears the slot
|
|
if self.picOff then
|
|
for _, p in pairs(self.picOff) do
|
|
if p.x < p.to then
|
|
p.x = math.min(p.to, p.x + p.step)
|
|
elseif p.x > p.to then
|
|
p.x = math.max(p.to, p.x - p.step)
|
|
end
|
|
end
|
|
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 Silph Scope unveil (MarowakAnim; queueScopeReveal parks a wait row
|
|
-- of GHOST_REVEAL_FRAMES over it): the ghost flashes, fades out, the pic
|
|
-- and the nick swap back the way LoadEnemyMonData restores them, and the
|
|
-- real mon fades in (#492).
|
|
local gr = self.ghostReveal
|
|
if gr and self.enemy then
|
|
gr.t = gr.t + 1
|
|
local pf = self:picFxFor(self.enemy)
|
|
local flashEnd = BattleState.GHOST_FLASH_FRAMES
|
|
local outEnd = flashEnd + BattleState.GHOST_FADE_OUT_FRAMES
|
|
if gr.t <= flashEnd then
|
|
pf.fade = (math.floor((gr.t - 1) / 10) % 2 == 1) and 0.5 or 1
|
|
elseif gr.t <= outEnd then
|
|
pf.fade = 1 - math.ceil((gr.t - flashEnd) / 10) / 3
|
|
else
|
|
if not gr.swapped then
|
|
gr.swapped = true
|
|
local real = self.ghostReal
|
|
if real then
|
|
self.enemy.name = real.name or self.enemy.name
|
|
self.enemy.sprite = real.sprite or self.enemy.sprite
|
|
end
|
|
end
|
|
pf.fade = math.min(1, math.ceil((gr.t - outEnd) / 10) / 4)
|
|
end
|
|
if gr.t >= BattleState.GHOST_REVEAL_FRAMES then
|
|
self.ghostReveal, self.scopeReveal, pf.fade = nil, nil, nil
|
|
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")
|
|
-- self.lowHealthAlarmOn mirrors wLowHealthAlarm's bit 7: a latch read
|
|
-- back inside lowHealthAlarmActive (the RHS sees last frame's value)
|
|
-- so a sounding siren rides out the next hit's HP drain instead of
|
|
-- dropping out mid-announcement (#293)
|
|
self.lowHealthAlarmOn = self:lowHealthAlarmActive()
|
|
-- battle.low_health_alarm: on/off toggle for the siren loop, ctx.on
|
|
-- mirrors self.lowHealthAlarmOn. Vanilla just starts/stops the loop
|
|
-- each frame; a mod can wrap this to reshape the toggle (e.g. force
|
|
-- ctx.on false after some budget) before letting vanilla act on it.
|
|
if Runtime.wantsHook("battle.low_health_alarm") then
|
|
Runtime.call("battle.low_health_alarm", function(ctx)
|
|
if ctx.on then
|
|
Sound.startLoop(ctx.battle.data, "Low_Health_Alarm")
|
|
else
|
|
Sound.stopLoop("Low_Health_Alarm")
|
|
end
|
|
end, { on = self.lowHealthAlarmOn, battle = self })
|
|
else
|
|
if self.lowHealthAlarmOn then
|
|
Sound.startLoop(self.data, "Low_Health_Alarm")
|
|
else
|
|
Sound.stopLoop("Low_Health_Alarm")
|
|
end
|
|
end
|
|
end
|
|
|
|
-- ---------------------------------------------------------------------
|
|
-- move execution pipeline
|
|
-- ---------------------------------------------------------------------
|
|
|
|
-- Mirror DrawHUDsAndHPBars: reveal mon.status on the HUD only after the
|
|
-- current action's queued anim/text have played (PoisonEffect sets the
|
|
-- bit before PlayCurrentMoveAnimation2 + PrintText, but the HUD redraw
|
|
-- waits until after Execute*Move returns).
|
|
function BattleState:syncShownStatus()
|
|
for _, b in ipairs({ self.player, self.enemy }) do
|
|
if b then b.shownStatus = b.mon.status end
|
|
end
|
|
end
|
|
|
|
function BattleState:executeAction(user, target, action)
|
|
-- MainInBattleLoop reads wEscapedFromBattle right after Execute*Move and
|
|
-- rets (core.asm:417-421, 456-460): a Teleport/Roar/Whirlwind escape ends
|
|
-- the turn where it lands and the second mover never moves. self.result
|
|
-- is only ever set once the battle is over (run/win/lose/caught), and the
|
|
-- faint cases are already covered by the HP guard below (#441)
|
|
if self.result then return end
|
|
if user.mon.hp <= 0 or target.mon.hp <= 0 then return end
|
|
if not action then return end
|
|
|
|
local function run()
|
|
-- 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 Strings("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(Strings("%s with-\ndrew %s!", self.trainer.name, oldName))
|
|
self:sayNext(Strings("%s sent\nout %s!", 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(Strings("%s\nmust recharge!", 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
|
|
run()
|
|
-- after announce/anim/effect text (pokered DrawHUDsAndHPBars)
|
|
self:actNext(function() self:syncShownStatus() end)
|
|
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 Strings("%s\nis fast asleep!", displayName(user))
|
|
or Strings("%s\nis confused!", displayName(user))
|
|
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(Strings("%s\nwoke up!", displayName(user)))
|
|
else
|
|
self:statusOnomatopoeia(user, "sleep")
|
|
end
|
|
return true
|
|
end
|
|
if mon.status == "FRZ" then
|
|
self:sayNext(Strings("%s\nis frozen solid!", displayName(user)))
|
|
return true
|
|
end
|
|
if target.trappingTurns then
|
|
self:sayNext(Strings("%s\ncan't move!", displayName(user)))
|
|
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(Strings("%s\nflinched!", displayName(user)))
|
|
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(Strings("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).
|
|
-- That rule doesn't apply in a link battle: "the enemy" there is a real
|
|
-- human peer independently tracking their own PP the normal way, not an
|
|
-- AI Gen 1 never bothered to decrement -- applying it made each side
|
|
-- silently skip decrementing the OTHER side's PP for the move it just
|
|
-- used, so both simulations' party state diverged by exactly 1 PP on
|
|
-- the very first move either side made, failing the lockstep hash
|
|
-- check turn 1 of literally every link battle.
|
|
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.kind ~= "link"
|
|
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(Strings("%s\nused %s!", 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 Strings.source("%s\nis charging up!")
|
|
-- the template is a source string (a move record may supply its own),
|
|
-- so translate it here rather than where it was declared
|
|
self:sayNext(Strings(chargeText, 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(Strings("%s's\nattack missed!", 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()
|
|
elseif SLOW_SHAKE_EFFECTS[move.effect] and self.moveAnimRow then
|
|
self.moveAnimRow.hit = { animType = user.isPlayer and 6 or 3 }
|
|
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(Strings("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(Strings("%s's\nattack continues!", 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(Strings("%s\nis storing energy!", displayName(user)))
|
|
return
|
|
end
|
|
self:sayNext(Strings("%s\nunleashed energy!", displayName(user)))
|
|
local dmg = (user.bideDamage or 0) * 2
|
|
user.bideTurns, user.bideDamage = nil, nil
|
|
if dmg <= 0 then
|
|
self:cancelMoveAnim()
|
|
self:sayNext(Strings("But, it failed!"))
|
|
return
|
|
end
|
|
-- .UnleashEnergy (core.asm:3501-3529) re-points wPlayerMoveNum at BIDE
|
|
-- and rejoins HandleIfPlayerMoveMissed, so BIDE's own animation plays
|
|
-- here, after UnleashedEnergyText and before the damage (#375)
|
|
self:animNext("BIDE", user.isPlayer)
|
|
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(Strings("%s's\nSUBSTITUTE broke!", displayName(target)))
|
|
else
|
|
self:sayNext(Strings("The SUBSTITUTE\ntook damage for\n%s!", 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(target, target.mon.hp) 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(Strings("%s's\nRAGE is building!", 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 })
|
|
if battler.isPlayer then
|
|
-- HandlePlayerMonFainted (core.asm:1070-1085): the companion loses
|
|
-- happiness on its own faint; an enemy 30+ levels above it makes
|
|
-- that the CARELESSTRAINER hit instead
|
|
local enemyLevel = self.enemy and self.enemy.mon
|
|
and self.enemy.mon.level or 0
|
|
local reason = (enemyLevel - (battler.mon.level or 0)) >= 30
|
|
and "CARELESSTRAINER" or "FAINTED"
|
|
require("src.world.PikachuFollower")
|
|
.modifyHappiness(self.game.save, reason, battler.mon)
|
|
end
|
|
-- 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 {}
|
|
-- SlideDownFaintedMonPic: PIC_HEIGHT (7) slide steps, each closing with
|
|
-- DelayFrames 2 (core.asm:1186-1222). The port held this one twice as
|
|
-- long as hardware.
|
|
self.fx.faint = { battler = battler, frames = Timing.FAINT_SLIDE }
|
|
end)
|
|
self.nextInsert = (self.nextInsert or 0) + 1
|
|
table.insert(self.queue, self.nextInsert, { wait = Timing.FAINT_SLIDE })
|
|
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(Strings("%s\nfainted!", displayName(battler)))
|
|
if battler.isPlayer then
|
|
self:act(function() self:playerMonFainted() end)
|
|
else
|
|
self:act(function() self:enemyMonFainted() end)
|
|
end
|
|
end
|
|
|
|
-- Exp for the defeated enemy, shared by the faint path (enemyMonFainted)
|
|
-- and, when a mod's battle.catch_exp hook says so, the catch path
|
|
-- (storeCaughtMon).
|
|
function BattleState:awardExp()
|
|
-- 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)
|
|
-- Track level-ups for EvolveAfterBattle (OverworldState:afterBattle ->
|
|
-- Evolution.checkParty). B-cancel leaves the mon at/above threshold;
|
|
-- without this gate it re-triggers after every later fight (#213).
|
|
if #levels > 0 then
|
|
self.leveledUp = self.leveledUp or {}
|
|
self.leveledUp[mon] = true
|
|
end
|
|
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).
|
|
-- _BoostedText / _WithExpAllText end in the CONT code (\v, "...\011"
|
|
-- in data/generated/text.lua): the box waits for A/B + ▼ then scrolls
|
|
-- the amount line in, so it stays on-screen instead of at y=144 (#216).
|
|
local text = Strings.source("%s gained\n%d EXP. Points!")
|
|
if announce == "expAll" then
|
|
text = Strings.source("%s gained\nwith EXP.ALL,\v%d EXP. Points!")
|
|
elseif mon.traded then
|
|
text = Strings.source("%s gained\na boosted\v%d EXP. Points!")
|
|
end
|
|
self:sayNext(Strings(text, 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
|
|
-- experience.asm:248 fires per grew-level text
|
|
require("src.world.PikachuFollower")
|
|
.modifyHappiness(game.save, "LEVELUP", mon)
|
|
self:sayNext(Strings("%s grew\nto level %d!", name, lv))
|
|
self:uiNext(function()
|
|
require("src.core.Sound").play(game.data, "Level_Up")
|
|
return StatBox.new(game, mon)
|
|
end)
|
|
-- After PrintStatsBox, experience.asm reloads the active battler's
|
|
-- wBattleMon and runs DrawHUDsAndHPBars, so its HP bar reflects the
|
|
-- higher current HP. Experience.lua:84 already raised mon.hp by
|
|
-- (newMaxHP - oldMaxHP); the party mon and the battler share one table
|
|
-- (makeBattler), so mon.stats.hp (the bar's denominator) jumps to the
|
|
-- new max instantly while the battler's shownHP numerator lags at the
|
|
-- old current HP -- the bar SHRINKS (#224). Animate shownHP up to the
|
|
-- new current HP (house convention: potions drain the bar too, see
|
|
-- itemUsed) so the bar grows instead. Only the active player battler
|
|
-- shares its table with the HUD; other party mons (EXP.ALL) have no bar.
|
|
if mon == self.player.mon then self:drainNext() end
|
|
for _, moveId in ipairs(Experience.movesLearnedAt(
|
|
self.data.pokemon[mon.species], lv)) do
|
|
self:learnMove(mon, moveId)
|
|
end
|
|
end
|
|
end
|
|
-- battle.exp_award: the participant/EXP.ALL split, factored out so a
|
|
-- mod can replace it wholesale (e.g. a flat undivided share to every
|
|
-- non-fainted party mon) without re-deriving participants/alive.
|
|
-- ctx.applyShare(mon, split, announce) is the same helper vanilla uses.
|
|
local function vanillaExpAward(ctx)
|
|
-- 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(ctx.alive) do
|
|
ctx.applyShare(mon, ctx.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
|
|
ctx.applyShare(mon, math.max(1, ctx.participants) * #self.game.save.party * 2, "expAll")
|
|
end
|
|
end
|
|
end
|
|
end
|
|
local awardCtx = { battle = self, participants = participants, alive = alive,
|
|
applyShare = applyShare }
|
|
if Runtime.wantsHook("battle.exp_award") then
|
|
Runtime.call("battle.exp_award", vanillaExpAward, awardCtx)
|
|
else
|
|
vanillaExpAward(awardCtx)
|
|
end
|
|
self.participants = {}
|
|
end
|
|
|
|
function BattleState:enemyMonFainted()
|
|
self:awardExp()
|
|
|
|
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
|
|
-- EnemySendOutFirstMon (core.asm:1366-1443): SHIFT offers a free
|
|
-- switch when party count > 1, the active mon is alive, and the
|
|
-- battle-style bit is clear. pokered counts party slots (not
|
|
-- remaining HP); SET / single-mon / fainted active skip the prompt.
|
|
local nextMon = self.enemyParty[self.enemyIndex]
|
|
local nextName = nextMon.nickname or self.data.pokemon[nextMon.species].name
|
|
local style = tostring((self.game.save.options or {}).battleStyle or "shift")
|
|
:lower()
|
|
local partyCount = #self.game.save.party
|
|
-- ReplaceFaintedEnemyMon (core.asm:892-896): DrawEnemyPokeballs puts the
|
|
-- foe's party ball row -- and the HUD chrome PlaceEnemyHUDTiles lays
|
|
-- down under it (draw_hud_pokeball_gfx.asm:9-11, 33-45, 134-141) -- into
|
|
-- the block FaintEnemyPokemon just cleared, after the exp text and
|
|
-- BEFORE the next send-out. It survives EnemySendOutFirstMon's
|
|
-- SlideTrainerPicOffScreen (core.asm:1308-1310, 8 steps x DelayFrames 2)
|
|
-- so SET style gets the brief flash, and stays up through the whole
|
|
-- SHIFT prompt below (#283).
|
|
self:act(function() self.showEnemyBalls = true end)
|
|
table.insert(self.queue, { wait = 16 })
|
|
-- SwitchPlayerMon runs AFTER TrainerSentOutText (core.asm:1436-1443)
|
|
local shiftSwitchMon = nil
|
|
if style ~= "set" and partyCount > 1 and self.player.mon.hp > 0 then
|
|
-- _TrainerAboutToUseText (data/text/text_2.asm): "X is" / "about to
|
|
-- use" then a CONT to the nick, not a fresh box -- the box scrolls
|
|
-- "X is" off so "about to use" stays above the name, instead of the
|
|
-- page ending on a bare nick (#565). Then para "Will PLAYER" /
|
|
-- "change POKéMON?" with YES/NO.
|
|
self:say(Strings("%s is\nabout to use\v%s!", self.trainer.name, nextName))
|
|
self:sayChoice(
|
|
Strings("Will %s\nchange POKéMON?", self.game.save.player.name),
|
|
function(yes)
|
|
if not yes then return end
|
|
local game = self.game
|
|
Screens.push(game, "PartyMenu", {
|
|
battle = self,
|
|
forceSwitch = true,
|
|
onSwitch = function(mon)
|
|
if mon ~= self.player.mon and mon.hp > 0 then
|
|
shiftSwitchMon = mon
|
|
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)
|
|
-- EnemySendOutFirstMon .next4 (core.asm:1413-1417): ClearSprites and
|
|
-- the 4x11 ClearScreenArea take the ball row away with the rest of
|
|
-- the enemy HUD block, right before TrainerSentOutText (#283)
|
|
self.showEnemyBalls = nil
|
|
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(Strings("%s sent\nout %s!", 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)
|
|
-- SwitchPlayerMon after the enemy is out (core.asm:1436-1443)
|
|
self:act(function()
|
|
local mon = shiftSwitchMon
|
|
if not mon then return end
|
|
local previous = self.player
|
|
self.player = makeBattler(self.data, mon, true, self.game.save)
|
|
clearTrapping(self.enemy)
|
|
self:syncSides()
|
|
Runtime.emit("battle.battler_switched", {
|
|
battle = self, side = self.sides[1],
|
|
battler = self.player, previous = previous,
|
|
})
|
|
-- Taking the SHIFT offer ZEROES wPartyGainExpFlags and
|
|
-- wPartyFoughtCurrentEnemyFlags before jumping to SwitchPlayerMon
|
|
-- (EnemySendOutFirstMon tail, core.asm:1436-1443), and SwitchPlayerMon
|
|
-- then FLAG_SETs only the mon coming in (core.asm:2424-2433). Without
|
|
-- the reset the mon that was out when the enemy fainted -- marked by
|
|
-- the send-out act above, which mirrors EnemySendOut's own re-flag
|
|
-- (core.asm:1276-1289) -- stayed a participant, so the exp divisor in
|
|
-- enemyMonFainted counted two mons and the switch-in earned half the
|
|
-- next KO (#275). Voluntary switches (resolveSwitch) and post-faint
|
|
-- replacements (openReplacementMenu) must NOT do this: pokered's
|
|
-- party-menu SwitchPlayerMon keeps the outgoing mon flagged, which is
|
|
-- the deliberate exp-share, and a fainted mon is already dropped by
|
|
-- onFaint mirroring RemoveFaintedPlayerMon (core.asm:1002-1007).
|
|
self.participants = {}
|
|
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
|
|
self:startGrowIn(self.player)
|
|
require("src.core.Sound").playCry(self.data, self.player.mon.species)
|
|
end)
|
|
end)
|
|
return
|
|
end
|
|
local prize = (self.trainer.baseMoney or 0) * self.enemy.mon.level
|
|
self.game.save.money = self.game.save.money + prize
|
|
-- TrainerBattleVictory (core.asm:915-949) in order: EndLowHealthAlarm
|
|
-- and the victory theme, TrainerDefeatedText, ScrollTrainerPicAfterBattle
|
|
-- (the beaten trainer scrolls back in from the right, one column every 4
|
|
-- frames, resting two tiles right of the battle slot), DelayFrames 40,
|
|
-- PrintEndBattleText -- the trainer's OWN loss line, on the battle
|
|
-- screen -- and only then MoneyForWinningText. Every row rides the
|
|
-- *Next inserters so it keeps that order behind the running queue item;
|
|
-- the plain act() the pic used to ride appended to the END of the queue,
|
|
-- which is why the trainer only flashed up for a frame or two as the
|
|
-- battle popped and the loss line had to be printed by the overworld
|
|
-- afterwards, stranding any evolution between two cuts (#282).
|
|
-- endBattleText is filled in by whoever started the battle
|
|
-- (OverworldState:engageTrainer in src/world/OverworldController.lua);
|
|
-- scripted battles that print their own follow-up leave it nil.
|
|
self:actNext(function() self:playVictoryMusic() end)
|
|
-- _TrainerDefeatedText: "<PLAYER> defeated\nTRAINER!"
|
|
self:sayNext(Strings("%s defeated\n%s!", self.game.save.player.name,
|
|
self.trainer.name))
|
|
self:actNext(function()
|
|
self.showEnemyTrainer = self.trainerPic ~= nil
|
|
if self.showEnemyTrainer then self:slidePic("foe", 64, 16, 2) end
|
|
end)
|
|
-- the 24-frame scroll-in plus the DelayFrames 40 that follows it
|
|
self.nextInsert = (self.nextInsert or 0) + 1
|
|
table.insert(self.queue, self.nextInsert, { wait = 64 })
|
|
if self.endBattleText then
|
|
-- PrintEndBattleText prints one text box; a `para` (\f) inside it
|
|
-- starts a fresh page, which is a message row of its own here (five
|
|
-- EndBattleTexts carry one, e.g. _Route9Youngster1EndBattleText)
|
|
-- TrainerEndBattleText (home/trainers.asm) prints _TrainerNameText
|
|
-- (wNameBuffer then ": ", data/text/text_1.asm) and only then the
|
|
-- saved pointer, so the loss line opens with the trainer class tag on
|
|
-- its first line (RIVAL1/2/3 tag with the rival's name:
|
|
-- TrainerNamePointers aims those entries at wTrainerName). The tag
|
|
-- prints once, so a `para` page carries no second copy (#566).
|
|
local tag = self.trainer and self.trainer.name
|
|
for page in (self.endBattleText .. "\f"):gmatch("(.-)\f") do
|
|
if page ~= "" then
|
|
self:sayNext(tag and (tag .. ": " .. page) or page)
|
|
tag = nil
|
|
end
|
|
end
|
|
end
|
|
self:sayNext(Strings("%s got ¥%d\nfor winning!", 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(Strings("%s learned\n%s!", 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 Strings("{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
|
|
-- HandlePlayerBlackOut (core.asm:1150-1159): SET_PAL_BATTLE_BLACK runs
|
|
-- BEFORE PlayerBlackedOutText2, so the enemy pic and both HP bars are
|
|
-- already dark under the blackout lines (#292). The Oak's Lab starter
|
|
-- rival returns one line above that call and never darkens. Set here
|
|
-- rather than queued: this whole function already runs from a queued
|
|
-- act after "<mon> fainted!" was dismissed, which is where the palette
|
|
-- command sits. (The Route 22 RIVAL1 wipe darkens one box early, over
|
|
-- Rival1WinText, which pokered prints just before the same command.)
|
|
self.blackedOut = true
|
|
self:sayNext(Strings("%s is out of\nuseable POKéMON!", self.game.save.player.name))
|
|
self:sayNext(Strings("%s blacked\nout!", 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 Strings("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(Strings("Got away safely!"))
|
|
self.result = "run"
|
|
self.afterQueue = "finish"
|
|
else
|
|
self:say(Strings("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,
|
|
-- ChooseNextMon: pick immediately (no SWITCH/STATS/CANCEL)
|
|
forceSwitch = true,
|
|
onSwitch = function(mon)
|
|
if mon.hp <= 0 then
|
|
self:say(Strings("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(Strings("Got away safely!"))
|
|
self.result = "run"
|
|
self.afterQueue = "finish"
|
|
return
|
|
end
|
|
|
|
if choice == "ball" then
|
|
st.balls = st.balls - 1
|
|
self:say(Strings("%s used\nSAFARI BALL!", 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(Strings("All right!\n%s was\ncaught!", 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(Strings("%s threw some\nBAIT.", 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(Strings("%s threw a\nROCK.", 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(Strings("Wild %s\nis eating!", 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(Strings("Wild %s\nis angry!", 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(Strings("Wild %s\nran!", 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
|
|
-- _NoRunningText is three lines in a two-line box, so the third arrives
|
|
-- on a \v scroll (ContText: ▼ then a button press) rather than a \n.
|
|
-- Spelling it with three \n dropped "trainer battle!" and handed the
|
|
-- menu straight back (#239).
|
|
self:say(self.data.text._NoRunningText
|
|
or Strings("No! There's no\nrunning from a\vtrainer 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(Strings("Got away safely!"))
|
|
self.result = "run"
|
|
self.afterQueue = "finish"
|
|
else
|
|
self:say(Strings("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)
|
|
-- bag cures clear mon.status before the message UI; refresh the HUD
|
|
-- once control returns (pokered DrawHUDsAndHPBars after item use)
|
|
self:syncShownStatus()
|
|
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 Strings("You missed the\nPOKéMON!")
|
|
elseif shakes == 1 then
|
|
return t._ItemUseBallText02 or Strings("Darn! The POKéMON\nbroke free!")
|
|
elseif shakes == 2 then
|
|
return (t._ItemUseBallText03 or Strings("Aww! It appeared\nto be caught!")):gsub("%s+$", "")
|
|
end
|
|
return t._ItemUseBallText04 or Strings("Shoot! It was so\nclose too!")
|
|
end
|
|
|
|
-- AskName (engine/menus/naming_screen.asm): ClearSprites, wild field blank,
|
|
-- PrintText, YES/NO while text stays (TextBox opts.choice). Shared by party
|
|
-- AddPartyMon and SendNewMonToBox (#172).
|
|
function BattleState:askNicknameUI(mon, displayName)
|
|
local game = self.game
|
|
self.lockedBall = nil
|
|
self.blankForAskName = true
|
|
local TextBox = require("src.render.TextBox")
|
|
local text = Strings("Do you want to\ngive a nickname\nto %s?", displayName)
|
|
local label = game.data.text and game.data.text._DoYouWantToNicknameText
|
|
if label then
|
|
-- extractor CONT is \t; TextBox scrolls on \n/\v
|
|
text = label:gsub("\t", "\n"):gsub("{RAM:?[%w_]*}", displayName)
|
|
end
|
|
return TextBox.new(game, text, nil, {
|
|
choice = function(yes)
|
|
self.blankForAskName = false
|
|
if not yes then return end
|
|
pcall(Screens.push, game, "NamingScreen", {
|
|
title = Strings("NICKNAME?"), maxLen = 10,
|
|
onDone = function(name)
|
|
if name and #name > 0 then mon.nickname = name end
|
|
end,
|
|
})
|
|
end,
|
|
})
|
|
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
|
|
-- AddPartyMon or SendNewMonToBox (both call AskName), then the PC
|
|
-- transfer text when the party was full.
|
|
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)
|
|
-- battle.catch_exp: vanilla catches never grant exp; a mod can flip
|
|
-- this to true to pay out the same award a faint would have.
|
|
if Runtime.wantsHook("battle.catch_exp")
|
|
and Runtime.call("battle.catch_exp", function() return false end, { battle = self }) then
|
|
self:awardExp()
|
|
end
|
|
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(Strings("New POKéDEX data\nwill be added for\n%s!", self.enemy.name))
|
|
self:uiNext(function()
|
|
return self:buildScreen("DexEntryMenu", species)
|
|
end)
|
|
end
|
|
local function askCaughtNickname()
|
|
local caught = self.enemy.mon
|
|
local enemyName = self.enemy.name
|
|
self:uiNext(function()
|
|
return self:askNicknameUI(caught, enemyName)
|
|
end)
|
|
end
|
|
if Party.add(game.save.party, self.enemy.mon) then
|
|
askCaughtNickname()
|
|
else
|
|
destination = "box"
|
|
local boxNum = require("src.pokemon.Boxes").deposit(game.save, self.enemy.mon)
|
|
if boxNum then
|
|
askCaughtNickname()
|
|
-- _ItemUseBallText07/08 keyed on EVENT_MET_BILL
|
|
local pc = (game.save.flags and game.save.flags.EVENT_MET_BILL)
|
|
and "BILL's PC" or Strings("someone's PC")
|
|
self:sayNext(Strings("%s was\ntransferred to\n%s!", self.enemy.name, pc))
|
|
else
|
|
self:sayNext(Strings("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)
|
|
-- ItemUseBall branches to ThrowBallAtTrainerMon on wIsInBattle != 1
|
|
-- (item_effects.asm:109-113) BEFORE it reaches `ld hl, ItemUseText00 /
|
|
-- call PrintText` (:146-147), so a trainer battle never shows the
|
|
-- "<PLAYER> used <ITEM>!" line (#291). Safari and the old man demo are
|
|
-- still wIsInBattle == 1, and this port models both as kind == "wild".
|
|
if self.kind == "wild" then
|
|
self:say(Strings("%s used\n%s!", self.game.save.player.name,
|
|
self.data.items[ball].name))
|
|
end
|
|
self:act(function()
|
|
require("src.core.Sound").play(self.data, "Ball_Toss")
|
|
if self.kind ~= "wild" then
|
|
-- ThrowBallAtTrainerMon (item_effects.asm:2292-2303) still animates the
|
|
-- toss: MoveAnimation routes TOSS_ANIM to TossBallAnimation, which takes
|
|
-- its .BlockBall branch in a trainer battle (animations.asm:2582-2585,
|
|
-- 2629-2637) -- the plain TOSS arc whatever the ball tier, then
|
|
-- SFX_FAINT_THUD and BLOCKBALL_ANIM, and only then the two texts. The
|
|
-- ball still counts as used: UseItem_ sets
|
|
-- wActionResultOrTookBattleTurn = 1 (item_effects.asm:1-3) and this path
|
|
-- never clears it, so UseBagItem does not fall back to the bag
|
|
-- (core.asm:2257-2259) and the turn is spent -- the foe moves (#291).
|
|
local t = self.data.text
|
|
self:animNext("TOSS_ANIM", true, nil, ball)
|
|
self:actNext(function()
|
|
require("src.core.Sound").play(self.data, "Faint_Thud")
|
|
end)
|
|
self:animNext("BLOCKBALL_ANIM", true)
|
|
self:sayNext(t._ThrowBallAtTrainerMonText1
|
|
or Strings("The trainer\nblocked the BALL!"))
|
|
self:sayNext(t._ThrowBallAtTrainerMonText2
|
|
or Strings("Don't be a thief!"))
|
|
self:act(function()
|
|
self:executeAction(self.enemy, self.player, self:enemyAction())
|
|
end)
|
|
self:act(function() self:endOfTurn() end)
|
|
return
|
|
end
|
|
if self.ghost or self.noCatch 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. battle.noCatch is the .notOldManBattle half of the
|
|
-- same check (item_effects.asm:166-175): the POKEMON_TOWER_6F
|
|
-- RESTLESS SOUL dodges balls even once the scope has revealed it,
|
|
-- so it is not a ghost battle any more (#444)
|
|
self:animNext(self:tossAnimFor(ball), true, nil, ball)
|
|
self:sayNext(Strings("It dodged the\nthrown BALL!"))
|
|
self:sayNext(Strings("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(Strings("All right!\n%s was\ncaught!", 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(Strings("%s is\nalready out!", self.player.name))
|
|
elseif mon.hp <= 0 then
|
|
self:say(Strings("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(Strings("%s picked up\n¥%d!", 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.
|
|
-- The old-man / PROF.OAK demo also skips it: the party never fought
|
|
-- (Yellow's Pallet intro runs before the player owns a mon at all).
|
|
if self.result ~= "lose" and not self.demo
|
|
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" })
|
|
-- Coming back from the battle screen is a fade, not a cut: EnterMap sees
|
|
-- BIT_BATTLE_OVER_OR_BLACKOUT set and runs MapEntryAfterBattle
|
|
-- (home/overworld.asm:22, :749-753) = GBFadeInFromWhite. This is the one
|
|
-- choke point every battle -- wild, trainer, walk-up, scripted, link --
|
|
-- passes through on its way out, so the fade is guaranteed here rather
|
|
-- than depending on each caller having wrapped onFinish correctly.
|
|
local result = self.result or "run"
|
|
local onFinish = self.onFinish
|
|
if result == "lose" then
|
|
-- the blackout path warps to the heal point with its own transition
|
|
if onFinish then onFinish(result) end
|
|
return
|
|
end
|
|
self.game.stack:push(require("src.render.Transition").battleReturn(self.game,
|
|
function() if onFinish then onFinish(result) end 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.
|
|
-- Counted in glyphs, not bytes: a nickname carrying "é" or "♂" is one
|
|
-- charmap sequence per glyph, and byte length would push it a tile left.
|
|
local function nameX(tx, name)
|
|
local n = #Font.split(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)
|
|
--
|
|
-- AnimationBlinkMon hides the pic, waits DelayFrames 5, shows it, waits
|
|
-- DelayFrames 5, six times over (animations.asm:1360-1376) -- a 10-frame
|
|
-- period, not 8. With Timing.BLINK_MON that is exactly six blinks.
|
|
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 % 10 < 5
|
|
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 offset is in screen
|
|
-- pixels, so it scales with the pic's draw scale (the player's default 2x
|
|
-- sinks 2x as fast to sink at the same visual rate); a mod scale composes
|
|
-- the same way. scale defaults to the vanilla side scale when unknown.
|
|
function BattleState:fxFaintOffset(battler, scale)
|
|
local fx = self.fx
|
|
if self:fxFaintActive(battler) then
|
|
scale = scale or (battler.isPlayer and 2 or 1)
|
|
return (30 - fx.faint.frames) * 2 * scale
|
|
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, scale)
|
|
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
|
|
|
|
-- MarowakAnim's rOBP1 fades (updateFx): alpha over the paper is what those
|
|
-- palette shifts look like, and the test has to sit ahead of the plain
|
|
-- fast path below (#492)
|
|
local fadePf = self.picFx and self.picFx[battler]
|
|
if fadePf and fadePf.fade then
|
|
local cr, cg, cb, ca = love.graphics.getColor()
|
|
love.graphics.setColor(cr, cg, cb, ca * fadePf.fade)
|
|
love.graphics.draw(img, x, y, 0, scale, scale)
|
|
love.graphics.setColor(cr, cg, cb, ca)
|
|
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)
|
|
-- ...except under the widescreen layout, where the side's own region
|
|
-- scissor is already that window on a battlefield the classic tile
|
|
-- columns do not describe (an 88..160 clip would fall entirely outside
|
|
-- the enemy's region and erase the pic).
|
|
local clip = love.graphics.setScissor and love.graphics.intersectScissor
|
|
and not self.wideRegion
|
|
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
|
|
-- 160x144 real pixels, not DPI units, or the colored battle background
|
|
-- resamples against the UI canvas on mobile (#208; PixelCanvas.lua)
|
|
local PixelCanvas = require("src.render.PixelCanvas")
|
|
local ok1, bg = pcall(PixelCanvas.new, 160, 144)
|
|
local ok2, wv = pcall(PixelCanvas.new, 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
|
|
-- HandlePlayerBlackOut (core.asm:1151) runs SET_PAL_BATTLE_BLACK:
|
|
-- SetPal_BattleBlack sends PalPacket_Black, PAL_BLACK in all four slots of
|
|
-- BlkPacket_Battle (engine/gfx/palettes.asm:22-25), so every zone of the
|
|
-- battle screen -- both HP bars and both mon regions -- goes dark behind
|
|
-- the blackout text. picImage re-bakes the pics through the same palette,
|
|
-- since those draw over the zone pass rather than through it (#292).
|
|
if self.blackedOut and pals.BLACK then
|
|
local b = pals.BLACK
|
|
return { [0] = b, [1] = b, [2] = b, [3] = b }
|
|
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.
|
|
-- Boot-ROM OG only (Red/Blue): snap zone paper to the global GBC white/black.
|
|
-- OG YELLOW keeps each CGBBasePalettes endpoint (already near-white / near-black).
|
|
if PaletteFX.mode == "ogred" and not require("src.core.GameVersion").isYellow() 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 only the offset copy: the baked canvas holds the HUDs and
|
|
-- text box (window-layer content), so compositing an unshifted copy
|
|
-- underneath ghosted every name in the vacated strip (#295). The strip
|
|
-- shows blank color 0 instead, like the hardware revealing empty BG.
|
|
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)
|
|
local shaking = sx ~= 0 or sy ~= 0
|
|
for _, z in ipairs(BATTLE_ZONES) do
|
|
PaletteFX.sendColors(shader, PaletteFX.permute(pals[z.pal], bgp))
|
|
local zx, zy = z[1] * 8, z[2] * 8
|
|
local zw, zh = (z[3] - z[1] + 1) * 8, (z[4] - z[2] + 1) * 8
|
|
love.graphics.setScissor(zx, zy, zw, zh)
|
|
if shaking then
|
|
love.graphics.rectangle("fill", zx, zy, zw, zh)
|
|
love.graphics.draw(src, sx, sy)
|
|
else
|
|
love.graphics.draw(src, 0, 0)
|
|
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
|
|
|
|
-- ------------------------------------------------------------------
|
|
-- Mod-facing battle sprite scaling. The enemy front pic draws at 1x and
|
|
-- the player back pic at 2x on the GB; a mod can override either per
|
|
-- species (pokemon.battleScaleFront / battleScaleBack) or per image path
|
|
-- (the battle_sprite_scales registry, which is the only handle on the
|
|
-- non-species pics like the trainer back). These resolvers and the
|
|
-- placement math are pure (no love.*) so the grounding contract -- feet
|
|
-- pinned at any scale -- is unit-tested directly.
|
|
-- ------------------------------------------------------------------
|
|
|
|
-- the vanilla scale for a side: enemy front 1x, player back 2x
|
|
BattleState.BATTLE_SCALE_DEFAULT = { front = 1, back = 2 }
|
|
|
|
-- image-level override for an asset path, or nil. scales is the merged
|
|
-- data.battle_sprite_scales table (record id -> { path, scale }).
|
|
function BattleState.imageBattleScale(scales, path)
|
|
if not scales or not path then return nil end
|
|
for id, rec in pairs(scales) do
|
|
if id ~= "_owners" and type(rec) == "table" and rec.path == path then
|
|
return rec.scale
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
-- effective battle scale for a pic: image-level override, else the
|
|
-- species-level override for the side, else the side default. side is
|
|
-- "front" (enemy) or "back" (player); species may be nil (a non-species
|
|
-- pic like the trainer back, which only image-level scaling reaches).
|
|
function BattleState.resolveBattleScale(data, side, path, species)
|
|
local img = data and BattleState.imageBattleScale(data.battle_sprite_scales, path)
|
|
if img then return img end
|
|
local def = species and data and data.pokemon and data.pokemon[species]
|
|
local field = side == "back" and "battleScaleBack" or "battleScaleFront"
|
|
local override = def and def[field]
|
|
if override then return override end
|
|
return BattleState.BATTLE_SCALE_DEFAULT[side] or 1
|
|
end
|
|
|
|
-- Player (back) placement: feet flush on the text-box top (y=96) at any
|
|
-- scale, with the left transparent columns pulled back so opaque pixels
|
|
-- land where hardware's white-on-white columns left them. Returns the
|
|
-- top-left x, y and the scale (slide/shake offsets are added by the
|
|
-- caller). Feet stay at 96 for every scale: y + (h - pad) * scale == 96.
|
|
function BattleState.backPlacement(w, h, pad, padL, scale)
|
|
return 8 - padL * scale, 96 - (h - pad) * scale, scale
|
|
end
|
|
|
|
-- Enemy (front) placement: given the s=1 slot origin (ex, ey) from the
|
|
-- 7x7 tile layout, keep the bottom edge and horizontal centre pinned as
|
|
-- the pic scales -- the same compensation AnimateSendingOutMon's grow
|
|
-- uses. Returns top-left x, y and the scale. The bottom edge stays put
|
|
-- for every scale: y + h * scale == ey + h.
|
|
function BattleState.frontPlacement(ex, ey, w, h, scale)
|
|
return ex + w * (1 - scale) / 2, ey + h * (1 - scale), scale
|
|
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
|
|
-- onlySide ("player" / "enemy") draws one side's pic alone, and
|
|
-- skipMenuClip drops the move-menu row clip below: the widescreen layout
|
|
-- composites each side into its own region of a taller battlefield, where
|
|
-- neither the other side's pixels nor the classic menu rows apply.
|
|
function BattleState:drawPicsLayer(slide, sx, sy, onlySide, skipMenuClip)
|
|
-- 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 = not skipMenuClip
|
|
and (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 onlySide ~= "player" and 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)
|
|
-- SlideTrainerPicOffScreen / _ScrollTrainerPicAfterBattle offset (#317)
|
|
love.graphics.draw(img, ex + self:picOffset("foe"), ey)
|
|
elseif onlySide ~= "player"
|
|
and 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 s = BattleState.resolveBattleScale(self.data, "front",
|
|
imagePathOf(img), self.enemy.mon and self.enemy.mon.species)
|
|
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 --
|
|
-- the mod scale composes multiplicatively with the grow stage
|
|
local eff = s * gs
|
|
if eff > 0 then
|
|
local dx, dy = BattleState.frontPlacement(ex, ey,
|
|
img:getWidth(), img:getHeight(), eff)
|
|
love.graphics.draw(img, dx, dy, 0, eff, eff)
|
|
end
|
|
else
|
|
local dx, dy = BattleState.frontPlacement(ex, ey,
|
|
img:getWidth(), img:getHeight(), s)
|
|
self:drawBattlerPic(self.enemy, dx, dy, s)
|
|
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 onlySide ~= "enemy" and 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
|
|
-- the trainer back is a bare pic, not species-keyed, so only an
|
|
-- image-level battle_sprite_scales entry can rescale it
|
|
local s = BattleState.resolveBattleScale(self.data, "back",
|
|
imagePathOf(self.playerBackPic), nil)
|
|
love.graphics.setColor(1, 1, 1, 1)
|
|
local dx, dy = BattleState.backPlacement(img:getWidth(), img:getHeight(),
|
|
pad, padL, s)
|
|
-- picOffset: SlideTrainerPicOffScreen walking the back pic off the left
|
|
love.graphics.draw(img, dx + slide + sx + self:picOffset("back"),
|
|
dy + sy, 0, s, s)
|
|
elseif onlySide ~= "enemy"
|
|
and 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 s = BattleState.resolveBattleScale(self.data, "back",
|
|
imagePathOf(self.player.sprite),
|
|
self.player.mon and self.player.mon.species)
|
|
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, horizontal centre
|
|
-- pinned, mod scale composed with the grow stage
|
|
local eff = s * gs
|
|
if eff > 0 then
|
|
love.graphics.draw(img,
|
|
8 - padL * s + img:getWidth() * s * (1 - gs) / 2 + sx,
|
|
96 - (img:getHeight() - pad) * eff + sy, 0, eff, eff)
|
|
end
|
|
else
|
|
local dx, dy = BattleState.backPlacement(img:getWidth(),
|
|
img:getHeight(), pad, padL, s)
|
|
self:drawBattlerPic(self.player, dx + sx, dy + sy, s)
|
|
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
|
|
-- In colorized modes the zone pass (drawZonePass over BATTLE_ZONES pal 0/1)
|
|
-- recolors the bar's DMG gray fill by region, so drawHPBar must skip its
|
|
-- per-pixel tint (grayFill) -- otherwise GREENBAR's red-channel-0 fill
|
|
-- double-applies and the zone shade shader maps the whole bar to black (#229).
|
|
local grayFill = self:colorMode()
|
|
local barData = self.data
|
|
local fx = self.fx
|
|
local hudShake = (fx and fx.hudShakeX) or 0
|
|
-- FaintEnemyPokemon clears the enemy HUD area; it stays blank through
|
|
-- TrainerAboutToUseText until DrawEnemyHUDAndHPBar after the next send-out
|
|
-- ...and it is not up yet during the intro text either: a wild battle's
|
|
-- DrawEnemyHUDAndHPBar is called from _InitBattleCommon (core.asm:6763)
|
|
-- AFTER PrintBeginningBattleText returns, so "Wild X appeared!" shows the
|
|
-- player's ball row with no enemy HUD beside it (#317)
|
|
if self.enemy and not self.showEnemyTrainer and not self.enemySendingOut
|
|
and not self:growInScale(self.enemy) and slide == 0
|
|
and not self.introBalls and not self.enemy.fainted 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.shownStatus then
|
|
Font.draw(self:statusLabel({ status = self.enemy.shownStatus }), 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 },
|
|
nil, grayFill)
|
|
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
|
|
|
|
-- ReplaceFaintedEnemyMon -> DrawEnemyPokeballs (core.asm:896,
|
|
-- draw_hud_pokeball_gfx.asm:9-11 -> SetupEnemyPartyPokeballs :33-45):
|
|
-- between a KO and the next send-out the foe's ball row sits in the enemy
|
|
-- HUD block FaintEnemyPokemon cleared, over the chrome PlaceEnemyHUDTiles
|
|
-- writes with it -- the same $73 (1,2) / $74 (1,3) / $76 run / $78 tiles
|
|
-- the live HUD draws, minus the HP bar (#283). Its own block rather than
|
|
-- a third arm of showIntroBalls below: that window is DrawAllPokeballs's
|
|
-- (#317) and clears for the rest of the battle, this one reopens on every
|
|
-- enemy faint. wBaseCoordX $48 / wBaseCoordY $20 stepping -8 is screen
|
|
-- (64,16) leftward, the same row the intro draws.
|
|
if self.showEnemyBalls and self.enemyParty and slide == 0 then
|
|
hudTile(0x73, 8, 16)
|
|
hudTile(0x74, 8, 24)
|
|
for i = 2, 9 do hudTile(0x76, i * 8, 24) end
|
|
hudTile(0x78, 80, 24)
|
|
love.graphics.setColor(1, 1, 1, 1)
|
|
self:drawBallRow(self.enemyParty, 64, 16, -8)
|
|
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
|
|
-- Party pokeball rows and the HUD chrome under them, for exactly the
|
|
-- window DrawAllPokeballs owns (common_text.asm:27, with the intro text).
|
|
-- SetupOwnPartyPokeballs runs in EVERY battle, so the player's row belongs
|
|
-- on the wild intro too -- keying it off the enemy trainer pic meant a
|
|
-- wild battle never drew one (#317) -- and SetupEnemyPartyPokeballs is
|
|
-- skipped when wIsInBattle == 1, so only a trainer/link battle gets the
|
|
-- foe's row. Gating on introBalls rather than on the pics also stops the
|
|
-- rows coming back when the beaten trainer scrolls in (#282):
|
|
-- _ScrollTrainerPicAfterBattle redraws tilemap columns and never touches
|
|
-- OAM, which ClearSprites emptied when the intro text was dismissed.
|
|
local showIntroBalls = self.introBalls and slide == 0
|
|
if showIntroBalls then
|
|
if self.enemyParty and (self.kind == "trainer" or self.kind == "link") then
|
|
-- PlaceEnemyHUDTiles (hlcoord 1,2): $73, then $74 + 8x $76 + $78
|
|
-- rightward along row 3 (draw_hud_pokeball_gfx.asm:133-165)
|
|
hudTile(0x73, 8, 16)
|
|
hudTile(0x74, 8, 24)
|
|
for i = 2, 9 do hudTile(0x76, i * 8, 24) end
|
|
hudTile(0x78, 80, 24)
|
|
love.graphics.setColor(1, 1, 1, 1)
|
|
self:drawBallRow(self.enemyParty, 64, 16, -8)
|
|
end
|
|
-- PlacePlayerHUDTiles (hlcoord 18,10): $73, then $77 + 8x $76 + $6F
|
|
-- LEFTWARD along row 11 (draw_hud_pokeball_gfx.asm:119-131)
|
|
hudTile(0x73, 144, 80)
|
|
hudTile(0x77, 144, 88)
|
|
for i = 10, 17 do hudTile(0x76, i * 8, 88) end
|
|
hudTile(0x6F, 72, 88)
|
|
love.graphics.setColor(1, 1, 1, 1)
|
|
self:drawBallRow(self.playerParty or self.game.save.party, 88, 80, 8)
|
|
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.shownStatus then
|
|
Font.draw(self:statusLabel({ status = self.player.shownStatus }), 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, grayFill) -- 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 or self.animPlaying) then
|
|
-- during the move animation self.current is nil but shown still holds
|
|
-- the "used X!" lines; keep drawing them like pokered, whose move
|
|
-- animations only touch sprites and never the textbox tilemap (#296)
|
|
-- rolling 2-line window: shown[1] at row y=112, shown[2] at y=128 (battle
|
|
-- text uses every other tile row, hlcoord *,14 / *,16). scrollPx animates
|
|
-- the lines up one row (ScrollTextUpOneLine) so a 3rd line scrolls into
|
|
-- view instead of drawing off-screen at y=144 (#216).
|
|
if self.scrollPx and self.scrollPx > 0 then
|
|
self.scrollPx = self.scrollPx - 2
|
|
if self.scrollPx <= 0 then self.scrollPx = nil end
|
|
end
|
|
local off = self.scrollPx or 0
|
|
local ys = { 112, 128 }
|
|
for li, line in ipairs(self.shown or {}) do
|
|
local y = (ys[li] or 128) + off
|
|
for i = 1, #line do
|
|
Font.drawCode(line[i], 8 + (i - 1) * 8, y)
|
|
end
|
|
end
|
|
-- the blinking down arrow ('▼', glyph $EE) while a \v CONT wait
|
|
-- (_ContText) or a typed-out page (PromptText) holds the box; both write
|
|
-- it at (18,16), bottom-right, like TextBox / home/text.asm (#317)
|
|
if (self.msgWaiting or self.msgPrompt) and self.frame % 60 < 30 then
|
|
Font.drawCode(0xEE, (0 + 20 - 2) * 8, (12 + 6 - 1) * 8 - 4)
|
|
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(Strings("FIGHT"), 80, 112)
|
|
Font.drawCode(0xE1, 128, 112); Font.drawCode(0xE2, 136, 112)
|
|
Font.draw(Strings("ITEM"), 80, 128); Font.draw(Strings("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(Strings("BALLx"), 16, 112); Font.draw(Strings("BAIT"), 112, 112)
|
|
Font.draw(Strings("THROW ROCK"), 16, 128); Font.draw(Strings("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(Strings("FIGHT"), 80, 112)
|
|
Font.drawCode(0xE1, 128, 112); Font.drawCode(0xE2, 136, 112)
|
|
Font.draw(Strings("ITEM"), 80, 128); Font.draw(Strings("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)
|
|
-- Those two cells are REPLACED on hardware: MoveSelectionMenu writes them
|
|
-- straight into the tilemap over the border it just laid down
|
|
-- (core.asm:2492-2501), and PrintMenuItem's own TextBoxBorder then redraws
|
|
-- the whole row on top (core.asm:2838-2844). Font.drawCode blits a
|
|
-- black-on-transparent glyph instead, so the tile underneath survives: the
|
|
-- move box's '┌' keeps its Poké Ball corner showing through the '─', and
|
|
-- the '─' the move box drew at (10,12) pokes two dots out from under the
|
|
-- '┘' (#240). Wipe each cell back to box white first, the way a tilemap
|
|
-- write does.
|
|
love.graphics.setColor(1, 1, 1, 1)
|
|
love.graphics.rectangle("fill", 32, 96, 8, 8)
|
|
love.graphics.rectangle("fill", 80, 96, 8, 8)
|
|
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
|
|
-- unknown ids (mod-injected moves) print raw instead of crashing
|
|
local def = self.data.moves[mv.id]
|
|
Font.draw(def and def.name or tostring(mv.id), 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
|
|
local def = self.data.moves[sel.id]
|
|
if self.player.disabledSlot == self.moveIndex then
|
|
Font.draw(Strings("disabled!"), 8, 80)
|
|
elseif def then
|
|
Font.draw(Strings("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()
|
|
if self:wideLayout() then return WideBattle.draw(self) end
|
|
return self:drawClassic()
|
|
end
|
|
|
|
function BattleState:drawClassic()
|
|
-- AskName: ClearSprites + wild ClearScreenArea -- white field under the
|
|
-- nickname TextBox / YES/NO (naming_screen.asm); overlays draw on top.
|
|
if self.blankForAskName then
|
|
love.graphics.setColor(1, 1, 1, 1)
|
|
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
|
return
|
|
end
|
|
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
|
|
-- intro slide-in offset: 2 px per frame, so 144 px over 72 frames
|
|
local slide = (self.introSlide or 0) * Timing.BATTLE_SLIDE_PX_PER_FRAME
|
|
|
|
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)
|
|
-- battle.overlay: shiny sparkles, custom HUD chrome, etc. Draw-only;
|
|
-- the vanilla link is a no-op so an empty chain costs nothing.
|
|
if Runtime.wantsHook("battle.overlay") then
|
|
Runtime.call("battle.overlay", function() end, self)
|
|
end
|
|
end
|
|
|
|
return BattleState
|