Merge branch 'bryanthaboi:dev' into experiment/fixed-extended-world-alignment

This commit is contained in:
James Hall
2026-08-16 19:30:19 -05:00
committed by GitHub
233 changed files with 8377 additions and 45512 deletions
+194 -120
View File
@@ -38,6 +38,7 @@ local romText = RomText
local BattleState = {}
BattleState.__index = BattleState
BattleState.isOpaque = true
BattleState.isBattleState = true
-- Category identity for per-category GAME SPEED (RFC 0007), the same
-- style OverworldController.isOverworld already uses. Every battle --
@@ -183,6 +184,13 @@ function BattleState:caughtMarkerVisible()
function() return false end, self) == true
end
function BattleState:catchChance(ball, rateOverride)
if Runtime.wantsHook("catch.rate") then return nil end
return Catching.chance(ball, self.enemy.mon, self.enemy.def, rateOverride,
{ ballDef = self:ballDef(ball), statuses = self.data.statuses,
battle = self })
end
function BattleState:moveGridNavigation()
if self:wideLayout() then return true end
if not Runtime.wantsHook("battle.move_grid_navigation") then return false end
@@ -391,21 +399,17 @@ local function grayImage(img)
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.
-- SET_PAL_BATTLE_BLACK re-bake of a pic (engine/battle/core.asm:1151,
-- engine/gfx/palettes.asm:22-25); resolved like sgbBattlePals' blackout (#292).
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 pals = pack and pack.palettes
if not (pals and pals.BLACK) then return img end
local colors = PaletteFX.usesYellowCgb() and pals.BLACK
or PaletteFX.pal(data, "BLACK") or pals.BLACK
local name = PaletteFX.usesGbcPack() and "redpp:BLACK" or "BLACK"
return getImage(meta.path, { name = name, colors = colors }) or img
end
@@ -606,7 +610,8 @@ end
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
-- engine/battle/experience.asm:69
if not mon.traded then mon.otId = mon.otId or save.player.id end
end
BattleState.stampOT = stampOT
@@ -1193,7 +1198,7 @@ function BattleState:startMessage(item)
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.lines[#self.lines + 1] = { codes = codes, cont = cont, text = chunk }
self.total = self.total + #codes
if not npos then break end
cont = text:sub(npos, npos) == "\v"
@@ -1226,6 +1231,18 @@ function BattleState:beginMsgLine()
self.shown[#self.shown + 1] = {}
end
function BattleState:visibleText()
if self.phase ~= "messages" or not (self.current or self.animPlaying) then
return nil
end
local out, count = {}, #(self.shown or {})
for i = math.max(1, self.lineIndex - count + 1), self.lineIndex do
local line = self.lines and self.lines[i]
if line then out[#out + 1] = line.text or "" end
end
return #out > 0 and out or nil
end
function BattleState:updateQueue()
if self.waitingUI then
if self.game.stack:top() ~= self then return true end
@@ -1958,6 +1975,109 @@ function BattleState:playerHasPP()
return false
end
-- One semantic path for the native command menu and mod.battle intents.
function BattleState:chooseMenu(choice)
if self.phase ~= "menu" then return nil, "battle menu is not active" end
if not self.player or not self.player.mon or self.player.mon.hp <= 0
or self:menuLockedAction(self.player) then
return nil, "battle menu is not ready"
end
self:clearTurnFlinches()
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)
-- A scared turn still ticks the player's residual effects.
self:queueResidual(self.player, self.enemy)
self:act(function() self:endOfTurn() end)
elseif choice == "fight" then
-- Trapping, Bide, and similar locks skip the move list.
local fightLock = self:fightLockedAction(self.player)
if fightLock then
self:resolveTurn(fightLock)
elseif not self:playerHasPP() then
-- No usable PP goes straight to Struggle.
self:say(Strings("%s has no\nmoves left!", self.player.name))
self:resolveTurn({ id = "STRUGGLE", pp = 1, struggle = true })
else
self.phase = "moveSelect"
self.moveIndex = math.min(self.moveIndex, #self.player.curMoves)
self.moveSwapIndex = nil
end
elseif choice == "run" then
self:tryRun()
elseif choice == "item" then
self:openItems()
elseif choice == "party" then
self:openParty()
else
return nil, "unknown battle menu choice"
end
return true
end
function BattleState:chooseMove(index)
if self.phase ~= "moveSelect" then return nil, "move menu is not active" end
local move = self.player.curMoves[index]
if not move then return nil, "invalid move slot" end
self.moveIndex = index
if self.player.disabledSlot == index then
self:say(self:romText("_MoveDisabledText", "The move is\ndisabled!"))
self.phase = "messages"
self.afterQueue = "menu"
elseif move.pp <= 0 then
self:say(self:romText("_MoveNoPPText", "No PP left for\nthis move!"))
self.phase = "messages"
self.afterQueue = "menu"
else
self.playerMoveListIndex = index
self:resolveTurn(move)
end
return true
end
function BattleState:cancelMove()
if self.phase ~= "moveSelect" then return nil, "move menu is not active" end
self.moveSwapIndex = nil
self.phase = "menu"
return true
end
local SAFARI_ACTION_INDEX = { ball = 1, bait = 2, rock = 3, run = 4 }
function BattleState:chooseSafari(action)
if self.phase ~= "menu" or not self.safari then
return nil, "safari menu is not active"
end
if self.safari.balls <= 0 then return nil, "no safari balls remain" end
local index = SAFARI_ACTION_INDEX[action]
if not index then return nil, "invalid safari action" end
self.menuIndex = index
self:safariAction(action)
return true
end
function BattleState:chooseMimic(index)
if self.phase ~= "mimicSelect" then
return nil, "mimic menu is not active"
end
if type(index) ~= "number" or index % 1 ~= 0 then
return nil, "invalid mimic slot"
end
local pick = self.mimicMoves and self.mimicMoves[index]
local ctx = self.mimicCtx
if not pick or not ctx then return nil, "invalid mimic slot" end
self.mimicIndex = index
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)
return true
end
function BattleState:swapMoves(i, j)
if i == j then return end
local moves = self.player.curMoves
@@ -2079,7 +2199,7 @@ function BattleState:update(dt)
self.menuIndex = row * 2 + col + 1
if input:wasPressed("a") then
require("src.core.Sound").play(self.data, "Press_AB")
self:safariAction(({ "ball", "bait", "rock", "run" })[self.menuIndex])
self:chooseSafari(({ "ball", "bait", "rock", "run" })[self.menuIndex])
end
return
end
@@ -2127,42 +2247,7 @@ function BattleState:update(dt)
self.menuIndex = row * 2 + col + 1
if input:wasPressed("a") then
require("src.core.Sound").play(self.data, "Press_AB")
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)
-- the scared turn still ticks the player's residual (PrintGhostText
-- -> ExecutePlayerMoveDone, core.asm:3056, 3275-3279)
self:queueResidual(self.player, self.enemy)
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
self:chooseMenu(({ "fight", "party", "item", "run" })[self.menuIndex])
end
return
end
@@ -2191,8 +2276,7 @@ function BattleState:update(dt)
end
elseif input:wasPressed("b") then
require("src.core.Sound").play(self.data, "Press_AB")
self.moveSwapIndex = nil
self.phase = "menu"
self:cancelMove()
elseif input:wasPressed("a") then
require("src.core.Sound").play(self.data, "Press_AB")
if self.moveSwapIndex then
@@ -2200,19 +2284,7 @@ function BattleState:update(dt)
self.moveSwapIndex = nil
return
end
local mv = moves[self.moveIndex]
if self.player.disabledSlot == self.moveIndex then
self:say(self:romText("_MoveDisabledText", "The move is\ndisabled!"))
self.phase = "messages"
self.afterQueue = "menu"
elseif mv.pp <= 0 then
self:say(self:romText("_MoveNoPPText", "No PP left for\nthis move!"))
self.phase = "messages"
self.afterQueue = "menu"
else
self.playerMoveListIndex = self.moveIndex
self:resolveTurn(mv)
end
self:chooseMove(self.moveIndex)
end
return
end
@@ -2235,12 +2307,7 @@ function BattleState:update(dt)
self.mimicIndex = self.mimicIndex < #moves and self.mimicIndex + 1 or 1
elseif input:wasPressed("a") then
require("src.core.Sound").play(self.data, "Press_AB")
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)
self:chooseMimic(self.mimicIndex)
end
return
end
@@ -2635,6 +2702,9 @@ function BattleState:residualFor(b, opp)
if self.result then return end
if self.player ~= b and self.enemy ~= b then return end
if b.mon.hp <= 0 or opp.mon.hp <= 0 then return end
-- engine/battle/core.asm:435-473
if b.residualDone then return end
b.residualDone = true
local msgs = Status.residual(b, opp, self)
for _, m in ipairs(msgs) do self:sayNext(prefixEnemy(m, b)) end
if b.leechSeeded and b.mon.hp > 0 then
@@ -2691,7 +2761,8 @@ function BattleState:endOfTurn()
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 sweep and b.mon.hp > 0 and oppAlive then
if sweep and not b.residualDone and b.mon.hp > 0 and oppAlive then
b.residualDone = true
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
@@ -2705,6 +2776,7 @@ function BattleState:endOfTurn()
-- the Haze move-forfeit only covers the turn Haze was used; if the
-- cured mon had already moved, drop the flag before next turn
b.skipMove = nil
b.residualDone = nil
-- 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
@@ -3919,9 +3991,9 @@ function BattleState:onFaint(battler)
battler.fainted = true
local Sound = require("src.core.Sound")
if battler.isPlayer then
-- RemoveFaintedPlayerMon (core.asm:1040-1042): the player mon's
-- faint plays its ordinary species cry -- no Faint_Fall
self.faintCry = Sound.playCry(self.data, battler.mon.species)
-- RemoveFaintedPlayerMon: the species cry (core.asm:1040-1042),
-- PikachuCry4 on Yellow (engine/battle/core.asm:1058)
self.faintCry = Sound.playCry(self.data, battler.mon.species, 4)
elseif self.kind ~= "wild" then
-- FaintEnemyPokemon (core.asm:732-771): the enemy faint plays no
-- species cry; trainer battles get SFX_FAINT_FALL, then SFX_FAINT_THUD
@@ -5323,24 +5395,28 @@ function BattleState:sgbBattlePals()
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).
-- HandlePlayerBlackOut (core.asm:1151) runs SET_PAL_BATTLE_BLACK
-- (engine/gfx/palettes.asm:22-25); picImage re-bakes through it (#292).
if self.blackedOut and pals.BLACK then
local b = pals.BLACK
local b = PaletteFX.usesYellowCgb() and pals.BLACK
or PaletteFX.pal(self.data, "BLACK") or pals.BLACK
return { [0] = b, [1] = b, [2] = b, [3] = b }
end
-- home/palettes.asm:38
local function bar(b)
if not b then return pals.GREENBAR end
if not b then
return PaletteFX.pal(self.data, "GREENBAR") or pals.GREENBAR
end
local hp = b.shownHP or b.mon.hp
return pals[PaletteFX.barPalName(hp, b.mon.stats.hp, b.shownPx)]
return PaletteFX.pal(self.data,
PaletteFX.barPalName(hp, b.mon.stats.hp, b.shownPx))
or pals.GREENBAR
end
local function mon(b, placeholder)
if placeholder or not b then return pals.MEWMON or pals.GREENBAR end
if placeholder or not b then
if PaletteFX.usesYellowCgb() then return pals.MEWMON or pals.GREENBAR end
return PaletteFX.pal(self.data, "MEWMON") or pals.MEWMON or pals.GREENBAR
end
return PaletteFX.monPal(self.data, b.mon.species) or pals.MEWMON
end
local out = {
@@ -5349,23 +5425,6 @@ function BattleState:sgbBattlePals()
[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
@@ -5459,23 +5518,29 @@ function BattleState:drawZonePass(src, sx, sy)
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)
-- SetAnimationPalette (engine/battle/animations.asm:551): wAnimPalette = $f0
-- on SGB, $e4 otherwise; rOBP1 = $6c either way
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)
f0x = { 3, 0, 3 }, -- $f0 xor %00111100 = $cc (DoBallTossSpecialEffects,
-- engine/battle/animations.asm:685)
e4 = { 1, 2, 3 }, -- identity
e4x = { 2, 1, 3 }, -- $e4 xor %00111100 = $d8
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))
local PaletteFX = require("src.render.PaletteFX")
local key = s.obp or "f0"
local P
-- engine/battle/animations.asm:551 (.notSGB)
if PaletteFX.usesSpriteObp() then
P = PaletteFX.ogObj()
if key == "f0" then key = "e4" elseif key == "f0x" then key = "e4x" end
else
P = self:zoneColorsAt(px or (s.x - 8 + 4), py or (s.y - 16 + 4))
end
if not P then return nil end
local m = OBJ_SHADES[s.obp or "f0"] or OBJ_SHADES.f0
local m = OBJ_SHADES[key] or OBJ_SHADES.f0
local function c(shade)
local col = P[shade + 1]
return { col[1] / 255, col[2] / 255, col[3] / 255 }
@@ -5913,10 +5978,16 @@ function BattleState:drawTextArea()
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)
-- engine/battle/misc.asm:37
for i = 1, 4 do
local mv = self.player.curMoves[i]
if mv then
-- 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)
else
Font.draw("-", 48, 96 + i * 8)
end
end
-- Swap cursor: SelectMenuItem parks the hollow arrow on the marked row
-- (core.asm:2600-2607), then HandleMenuInput's PlaceMenuCursor writes the
@@ -5943,13 +6014,16 @@ function BattleState:drawTextArea()
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
-- Mimic's copy menu (MoveSelectionMenu .mimicmenu, core.asm:2506-2517):
-- 16x6 box at (0,7), names 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)
-- engine/battle/misc.asm:37
for i = 1, 4 do
local m = self.mimicMoves[i]
local def = m and self.data.moves[m.id]
Font.draw(m and (def and def.name or tostring(m.id)) or "-",
16, (7 + i) * 8)
end
Font.drawCode(0xED, 8, (7 + self.mimicIndex) * 8)
Font.draw(Strings("WHICH TECHNIQUE?"), 8, 112)