Files
gen1recomp/src/ui/gen2/PartyMenu.lua
T

1109 lines
44 KiB
Lua

-- Gen 2 party list (engine/pokemon/party_menu.asm).
--
-- Six rows plus CANCEL. Each row is two lines: an animated 16x16 menu icon
-- with the nickname and gender on the first, level and HP bar on the second --
-- the layout Gold uses everywhere it asks "which #MON?", which is why the
-- prompt text is a parameter (.Strings: "Choose a #MON.", "Use on which
-- <PK><MN>?", "Teach which <PK><MN>?", ...).
--
-- Icons come from icons.lua: one 16x32 sheet per ICON_*, two 16x16 frames that
-- alternate roughly twice a second, and MonMenuIcons maps species -> icon.
--
-- Choosing a mon from the FIELD list opens the action submenu (MonSubmenu,
-- engine/pokemon/mon_submenu.asm) rather than answering straight away; every
-- other flavour of the list -- "Use on which <PK><MN>?", "Teach which
-- <PK><MN>?", a battle switch -- goes directly to its caller, which is why
-- the submenu is opt-in through `opts.submenu` and not the default.
local Assets = require("src.render.Assets")
local Chrome = require("src.ui.gen2.Chrome")
local FieldMoves = require("src.world.gen2.FieldMoves")
local Font = require("src.render.Font")
local GbcPalette = require("src.render.GbcPalette")
local HpBar = require("src.battle.gen2.HpBar")
local ItemEffects = require("src.core.gen2.ItemEffects")
local Logger = require("src.core.Logger")
local Mail = require("src.core.gen2.Mail")
local Mon = require("src.battle.gen2.Mon")
local Runtime = require("src.mods.Runtime")
local Screens = require("src.ui.Screens")
local Sound = require("src.core.Sound")
local Status = require("src.battle.Status")
local Strings = require("src.core.Strings")
local WaitPlaySFX = require("src.ui.gen2.WaitPlaySFX")
local PartyMenu = {}
PartyMenu.__index = PartyMenu
PartyMenu.isOpaque = true
-- PartyMenuStrings, verbatim. <PK>/<MN> are single font glyphs ($e1/$e2) and
-- Font.split matches charmap sequences, so writing them the way the ASM does
-- is both faithful and two tiles narrower than spelling POKéMON out -- which
-- is what keeps "Use on which <PK><MN>?" inside its 18-column text box.
PartyMenu.PROMPTS = {
choose = Strings.source("Choose a POKéMON."),
useItem = Strings.source("Use on which <PK><MN>?"),
which = Strings.source("Which <PK><MN>?"),
teach = Strings.source("Teach which <PK><MN>?"),
moveTo = Strings.source("Move to where?"),
toWhich = Strings.source("To which <PK><MN>?"),
none = Strings.source("You have no <PK><MN>!"),
}
local FAINTED_LABEL = Strings.source("FNT")
local EGG_LABEL = Strings.source("EGG")
local ABLE_LABEL = Strings.source("ABLE")
local NOT_ABLE_LABEL = Strings.source("NOT ABLE")
-- The icon's two frames swap every 16 logic steps, close to the cart's
-- SPRITE_ANIM cadence.
local ICON_FRAME_STEPS = 16
-- engine/items/item_effects.asm:1748
PartyMenu.ACTION_TEXT_DELAY = 50
-- data/mon_menu.asm MonMenuOptions' MONMENU_FIELD_MOVE rows, in table order:
-- a move the mon knows that appears here gets a row of its own above the
-- fixed options, named with GetMoveName rather than a menu string.
PartyMenu.FIELD_MOVES = {
"CUT", "FLY", "SURF", "STRENGTH", "FLASH", "WATERFALL", "WHIRLPOOL", "DIG",
"TELEPORT", "SOFTBOILED", "HEADBUTT", "ROCK_SMASH", "MILK_DRINK",
"SWEET_SCENT",
}
-- MonMenuOptionStrings, in MONMENUVALUE_* order. GetMonSubmenuItems adds
-- STATS, SWITCH, MOVE and then ITEM (or MAIL, when the held item is mail),
-- and only appends CANCEL while the list is still under NUM_MONMENU_ITEMS.
local NUM_MONMENU_ITEMS = 8
-- MonMenuOptionStrings are engine-owned labels. Translate them while the
-- built-in rows are assembled so hook-injected rows keep their authored text;
-- the stable id remains the value every action branch consumes.
local ACTION_LABELS = {
STATS = Strings.source("STATS"),
SWITCH = Strings.source("SWITCH"),
MOVE = Strings.source("MOVE"),
ITEM = Strings.source("ITEM"),
MAIL = Strings.source("MAIL"),
CANCEL = Strings.source("CANCEL"),
}
local function actionLabel(id)
return Strings(ACTION_LABELS[id] or id)
end
-- MonSubmenu's .MenuHeader is `menu_coords 6, 0, SCREEN_WIDTH - 1,
-- SCREEN_HEIGHT - 1`, and .GetTopCoord then pulls the top edge up to
-- 1 + bottom - 2 * (count + 1) so the box grows downward from a fixed bottom.
local SUBMENU_LEFT = 6
local SUBMENU_RIGHT = 19
local SUBMENU_BOTTOM = 17
-- BattleMonMenu's .MenuHeader is `menu_coords 11, 11, SCREEN_WIDTH - 1,
-- SCREEN_HEIGHT - 1` (engine/pokemon/mon_submenu.asm:277-292).
local BATTLE_SUBMENU_LEFT, BATTLE_SUBMENU_TOP = 11, 11
-- HP bar is 6 tiles wide (48px) in the party list.
local function gridIndex(index, count, direction)
if count < 1 then return nil end
local row, col = math.floor((index - 1) / 2), (index - 1) % 2
if direction == "left" or direction == "right" then
local other = row * 2 + (1 - col) + 1
return other <= count and other or index
end
local step = direction == "up" and -1 or direction == "down" and 1
if not step then return nil end
local rows = math.ceil(count / 2)
for offset = 1, rows do
local other = ((row + step * offset) % rows) * 2 + col + 1
if other <= count then return other end
end
return index
end
function PartyMenu:wantsFillScale() return true end
function PartyMenu:drawsWidescreen() return true end
-- opts: party, prompt (key or literal), onChoose(index), onCancel(),
-- icons (icons.lua), palettes (palettes.lua), pokemon (pokemon.lua),
-- submenu (the field MonSubmenu), battleSubmenu (BattleMonMenu)
function PartyMenu.new(game, opts)
opts = opts or {}
local self = setmetatable({}, PartyMenu)
self.game = game
local save = opts.save or (game and game.save)
-- The MAIL row writes to sPartyMail, which lives on the save rather than on
-- the mon, so the list keeps the save it was opened over.
self.save = save
self.party = opts.party or (save and save.party) or {}
local data = game and game.data or {}
-- engine/pokemon/move_mon.asm:1402
for i = 1, #self.party do
Mon.refreshStats(self.party[i], data)
end
self.icons = opts.icons or data.gen2Icons
self.palettes = opts.palettes or data.gen2Palettes
self.pokemon = opts.pokemon or data.pokemon
local promptKey = opts.prompt or "choose"
self.promptIsBuiltin = PartyMenu.PROMPTS[promptKey] ~= nil
self.prompt = PartyMenu.PROMPTS[promptKey] or opts.prompt
or PartyMenu.PROMPTS.choose
-- engine/pokemon/party_menu.asm:297
self.tmhm = opts.tmhm
if self.tmhm and (opts.prompt == nil or opts.prompt == "teach") then
self.prompt = PartyMenu.PROMPTS.teach
self.promptIsBuiltin = true
end
self.onChoose = opts.onChoose
self.onCancel = opts.onCancel
self.moves = opts.moves or data.moves
self.items = opts.items or data.items
-- PokemonActionSubmenu is only reached from the field list; see the header.
self.wantsSubmenu = opts.submenu == true
-- BattleMenu_PKMN's `callfar BattleMonMenu` (engine/battle/core.asm:4810).
self.wantsBattleSubmenu = opts.battleSubmenu == true
self.battle = opts.battle == true
self.submenu = nil
-- The held slot while SwitchPartyMons' second pick is open; nil otherwise.
self.switchFrom = nil
self.repeatSfx = nil
-- ../pokecrystal/engine/items/item_effects.asm:2016
self.softboiledFrom = nil
self.softboiledCost = nil
-- wPartyMenuCursor lives ACROSS openings: InitPartyMenuWithCancel /
-- InitPartyMenuNoCancel seed wMenuCursorY from it and fall back to row 1 only
-- when it is zero or no longer inside the party (`and a / jr z, .skip / inc b
-- / cp b / jr c, .done`, engine/pokemon/party_menu.asm:546), so the list -- in
-- battle and in the field alike -- reopens on the mon last picked. It is a
-- WRAM byte, not save data, so it hangs off the game and CleanUpBattleRAM is
-- what clears it (src/ui/gen2/BattleState.lua's clearMenuCursors).
local stored = game and game.partyMenuCursor or 0
self.index = (stored >= 1 and stored <= #self.party) and stored or 1
self.clock = 0
self.iconCache = {}
-- PlacePartyHPBar draws through DrawBattleHPBar, so the party list uses the
-- battle HUD's tile sheet rather than a bar of its own.
local BattleHud = require("src.ui.gen2.BattleHud")
self.hud = BattleHud.new(data.gen2MenuGfx, self.palettes)
return self
end
function PartyMenu:count()
-- CANCEL is one past the last mon. SwitchPartyMons reopens the list
-- through InitPartyMenuNoCancel, which caps the cursor at the last mon.
if self.switchFrom or self.softboiledFrom then return #self.party end
return #self.party + 1
end
function PartyMenu:isCancel()
return self.index > #self.party
end
function PartyMenu:gridNavigation()
if not self.battle
or not Runtime.wantsHook("ui.party.grid_navigation") then return false end
return Runtime.call("ui.party.grid_navigation", function() return false end,
self) == true
end
-- ------------------------------------------------------------- mon submenu
-- GetMonSubmenuItems, in its own order: every field move the mon knows first,
-- then STATS, SWITCH, MOVE, and ITEM (MAIL when the held item is mail).
-- CANCEL is appended only while the list is still short of NUM_MONMENU_ITEMS,
-- which is why a mon with five field moves has no CANCEL row and has to be
-- backed out of with B.
--
-- Every PokemonActionSubmenu branch is wired up here: STATS, SWITCH
-- (SwitchPartyMons), the field moves, ITEM (GiveTakePartyMonItem,
-- src/ui/gen2/HeldItemMenu.lua), MAIL (MonMailAction,
-- src/ui/gen2/MailMenu.lua) and MOVE (ManagePokemonMoves, which opens
-- MoveScreenLoop's screen -- src/ui/gen2/SummaryMenu.lua's `moveScreen`).
-- ui.party.submenu identity: an unhooked build hands its own list back.
local function sameItems(_, items) return items end
local function buildSubmenuItems(self, mon)
-- GetMonSubmenuItems' .egg arm: an EGG offers STATS, SWITCH and CANCEL --
-- no field moves, no MOVE row, and no ITEM (GiveTakePartyMonItem's first
-- test is `cp EGG`).
if mon and mon.isEgg then
return {
{ id = "STATS", label = actionLabel("STATS") },
{ id = "SWITCH", label = actionLabel("SWITCH") },
{ id = "CANCEL", label = actionLabel("CANCEL") },
}
end
local items = {}
local known = {}
for _, entry in ipairs((mon and mon.moves) or {}) do
if entry.id then known[entry.id] = entry end
end
for _, id in ipairs(PartyMenu.FIELD_MOVES) do
if known[id] then
local def = self.moves and self.moves[id]
items[#items + 1] = {
id = id, label = (def and def.name) or id, fieldMove = true,
}
end
end
items[#items + 1] = { id = "STATS", label = actionLabel("STATS") }
items[#items + 1] = { id = "SWITCH", label = actionLabel("SWITCH") }
items[#items + 1] = { id = "MOVE", label = actionLabel("MOVE") }
-- ItemIsMail, not a pocket test: mail lives in the ordinary ITEM pocket
-- (ItemAttributes gives FLOWER_MAIL pocketId 1), so the only thing that
-- says "this is mail" is data/items/mail_items.asm's own list.
local isMail = Mail.monHoldsMail(mon)
items[#items + 1] = isMail
and { id = "MAIL", label = actionLabel("MAIL") }
or { id = "ITEM", label = actionLabel("ITEM") }
if #items < NUM_MONMENU_ITEMS then
items[#items + 1] = { id = "CANCEL", label = actionLabel("CANCEL") }
end
return items
end
-- BattleMonMenu's .MenuData: three rows, SWITCH first
-- (engine/pokemon/mon_submenu.asm:286-292).
local function buildBattleSubmenuItems()
return {
{ id = "SWITCH", label = actionLabel("SWITCH") },
{ id = "STATS", label = actionLabel("STATS") },
{ id = "CANCEL", label = actionLabel("CANCEL") },
}
end
-- The assembled list runs through ui.party.submenu -- the same hook name and
-- the same (game, items, mon, ctx) payload the Gen 1 site uses
-- (src/ui/PartyMenu.lua) -- so one mod source can add, drop or reorder rows on
-- both generations. Gold's MAIL row and its field moves are simply entries in
-- the list the hook receives; they need no name of their own.
--
-- ctx.battle marks the BattleMonMenu list (engine/pokemon/mon_submenu.asm:247),
-- the SWITCH/STATS/CANCEL box BattleMenu_PKMN opens over the battle party list;
-- it is false for PokemonActionSubmenu, the FIELD list (see the header).
-- ctx.overworld is Gold's World, the Gen 1 key's counterpart.
function PartyMenu:submenuItems(mon)
local battle = self.wantsBattleSubmenu == true
local items = battle and buildBattleSubmenuItems()
or buildSubmenuItems(self, mon)
local ctx = { battle = battle, overworld = self.game and self.game.world }
local hooked = Runtime.call("ui.party.submenu", sameItems,
self.game, items, mon, ctx)
if type(hooked) == "table" then return hooked end
Logger.error("ui.party.submenu returned %s; keeping the vanilla list",
type(hooked))
return items
end
-- .GetTopCoord: top = 1 + bottom - 2 * (count + 1). PopulateMonMenu then
-- starts writing at MenuBoxCoord2Tile + 2 * SCREEN_WIDTH + 2, so the labels
-- are two rows and two columns inside the box's top-left corner and step two
-- rows each -- the cursor sits one column left of them, at left + 1.
function PartyMenu.submenuTop(count)
return 1 + SUBMENU_BOTTOM - 2 * (count + 1)
end
function PartyMenu.submenuLabelCoord(count, row)
return SUBMENU_LEFT + 2, PartyMenu.submenuTop(count) + 2 + (row - 1) * 2
end
function PartyMenu:openSubmenu()
local mon = self.party[self.index]
if not mon then return end
self.submenu = { items = self:submenuItems(mon), index = 1, mon = mon,
slot = self.index, battle = self.wantsBattleSubmenu or nil }
end
function PartyMenu:closeSubmenu()
self.submenu = nil
end
-- SwitchPartyMons (engine/pokemon/mon_menu.asm). `cp 2 / jr c, .DontSwitch`:
-- one mon is nothing to switch with, and the row backs out the way
-- CancelPokemonAction does. Otherwise the list reopens through
-- InitPartyMenuNoCancel in PARTYMENUACTION_MOVE dress -- "Move to where?",
-- a '▷' parked on the held row -- and PartyMenuSelect picks the other end.
function PartyMenu:beginSwitch(slot)
if #self.party < 2 then return end
self.switchFrom = slot
end
-- _SwitchPartyMons (engine/pokemon/switchpartymons.asm): the two party
-- structs swap whole (nickname and OT ride inside the port's mon record), and
-- the sPartyMail structs are copied across with them -- Mail.swapSlots is
-- that CopyBytes pair. Picking the held slot again is the `.skip` arm:
-- nothing moves.
function PartyMenu:finishSwitch()
local from, to = self.switchFrom, self.index
self.switchFrom = nil
if not (from and to) or from == to then return end
local party = self.party
party[from], party[to] = party[to], party[from]
-- sPartyMail is keyed by party slot on the save; a list opened over some
-- other table (a battle copy, a day-care pick) has no mail to carry.
if self.save and self.save.party == party then
Mail.swapSlots(self.save, from, to)
end
-- engine/pokemon/switchpartymons.asm:13
self:playSfxTwice("Sfx_SwitchPokemon")
end
-- The reopened list: InitPartyMenuNoCancel caps the cursor at the last mon,
-- and PartyMenuSelect reads only A (hand both slots to _SwitchPartyMons) and
-- B (the .DontSwitch arm -- back to the ordinary list, nothing moved).
function PartyMenu:updateSwitch(input)
local total = #self.party
if input:wasPressed("up") then
self.index = self.index > 1 and self.index - 1 or total
elseif input:wasPressed("down") then
self.index = self.index < total and self.index + 1 or 1
elseif input:wasPressed("b") then
self.switchFrom = nil
elseif input:wasPressed("a") then
self:finishSwitch()
end
end
-- ../pokecrystal/engine/items/item_effects.asm:2016 .SelectMilkDrinkRecipient
function PartyMenu:beginSoftboiled(slot, cost)
self.softboiledFrom = slot
self.softboiledCost = cost
end
-- ../pokecrystal/engine/items/item_effects.asm:2020 .SelectMilkDrinkRecipient
function PartyMenu:updateSoftboiled(input)
local total = #self.party
if input:wasPressed("up") then
self.index = self.index > 1 and self.index - 1 or total
elseif input:wasPressed("down") then
self.index = self.index < total and self.index + 1 or 1
elseif input:wasPressed("b") then
self.softboiledFrom, self.softboiledCost = nil, nil
elseif input:wasPressed("a") then
self:finishSoftboiled()
end
end
-- ../pokecrystal/engine/items/item_effects.asm:1986 Softboiled_MilkDrinkFunction
function PartyMenu:finishSoftboiled()
local slot = self.index
local userSlot = self.softboiledFrom
local user = self.party[userSlot]
local target = self.party[slot]
local userBefore = user and user.hp or 0
local before, after =
FieldMoves.softboiledTransfer(user, target, self.softboiledCost or 0)
if not before then
self:showItemResult(slot, { text = Strings(ItemEffects.TEXT_CANT_USE_ON_MON) })
return
end
self.softboiledFrom, self.softboiledCost = nil, nil
-- data/text/common_1.asm:40 _RecoveredSomeHPText
local climb = {
fromHp = before, toHp = after, sfx = "Sfx_Potion",
text = Strings("%s\nrecovered %dHP!",
target.nickname or target.name or target.species or "?",
after - before),
}
-- ../pokecrystal/engine/items/item_effects.asm:1999 HealHP_SFX_GFX
self:showItemResult(userSlot, {
fromHp = userBefore, toHp = user.hp, sfx = "Sfx_Potion", auto = true,
holdSlot = slot, holdHp = before,
onDone = function() self:showItemResult(slot, climb) end,
})
end
-- OpenPartyStats (engine/pokemon/mon_menu.asm): wMonType is cleared to
-- PARTYMON, the volume is dropped for the cry, and StatsScreenInit runs over
-- the party list -- so the summary is pushed on top rather than replacing it,
-- and closing it lands back on the same row.
function PartyMenu:openStats()
local stack = self.game and self.game.stack
if not stack then return end
Screens.push(self.game, "Gen2SummaryMenu", {
party = self.party,
index = self.index,
onClose = function() stack:pop() end,
})
end
-- MonMenu_Cut and its thirteen siblings (engine/pokemon/mon_menu.asm) are one
-- routine each and all the same shape: farcall the move's *Function, then read
-- wFieldMoveSucceeded. $1 returns $2 from PokemonActionSubmenu, which closes
-- the party list AND the START menu behind it so the script the function
-- queued can run in the overworld; anything else returns $3 and redraws the
-- list where it was, with the refusal already printed over it by
-- MenuTextboxBackup.
--
-- The refusal text and the queueing both live in the world
-- (src/world/gen2/FieldMoves.lua and World:useFieldMove), because a field move
-- is a question about the map, not about the menu. All that is left here is
-- the $2 / $3 branch.
-- once a spawn is picked -- engine/pokemon/mon_menu.asm:609-626 over
-- engine/events/overworld.asm:556-568. B answers -1 and takes .Error's $0,
-- which is StartMenu_Pokemon's .choosemenu (engine/menus/start_menu.asm:503).
function PartyMenu:useFieldMove(moveId, mon)
local world = self.game and self.game.world
if not (world and world.useFieldMove) then return end
local result = world:useFieldMove(moveId, mon)
if not (result and result.ok) then return end
if result.inMenu then
self:beginSoftboiled(self.index, result.cost)
return
end
-- ../pokecrystal/engine/events/overworld.asm:1357 RockSmashFromMenuScript
if result.action == "rocksmash" then
world.queuedFieldMove = nil
local script = FieldMoves.rockSmashFromMenuScript(
world.stdScripts, world.vm and world.vm.scripts,
function(name) return world:specialIdNamed(name) end)
if not script then return end
-- ../pokecrystal/engine/events/overworld.asm:1341 GetFacingObject
if world.vm then world.vm.lastTalked = result.lastTalked end
world.queuedScript = script
self:exitToField()
return
end
if result.action == "fly" and world.openFlyMap then
world.queuedFieldMove = nil
local opened = world:openFlyMap(mon, {
onChosen = function(spawnId)
result.flySpawn = spawnId
world.queuedFieldMove = result
-- engine/pokegear/pokegear.asm:2078, home/map.asm:1927
if world.exitMenusFadeForFly then
world:exitMenusFadeForFly()
elseif world.exitMenusFade then
world:exitMenusFade()
end
self:exitToField()
end,
onCancel = function() end,
})
if opened then return end
world.queuedFieldMove = result
end
self:exitToField()
end
-- ManagePokemonMoves (engine/pokemon/mon_menu.asm:858-873), the MOVE row: an
-- EGG returns at once (`cp EGG / jr z, .egg`), and every other mon gets
-- MoveScreenLoop -- the move list with its descriptions, and A to lift a move
-- and drop it on another row. The screen clears the tilemap for itself
-- (SetUpMoveScreenBG), so it is pushed on top rather than replacing this list.
--
-- MoveScreenLoop opens with `ld a, [wCurPartyMon] / inc a / ld
-- [wPartyMenuCursor], a` and does it again on every left/right cycle, so the
-- slot it was last showing is the row this list comes back on.
function PartyMenu:openMoveManager(slot, mon)
local game = self.game
if not (game and game.stack) then return end
if mon and mon.isEgg then return end
local screen
screen = Screens.push(game, "Gen2SummaryMenu", {
party = self.party,
index = slot,
moveScreen = true,
onClose = function()
game.stack:pop()
local landed = screen and screen.index or slot
self.index = math.max(1, math.min(landed, #self.party))
self:storeCursor()
end,
})
end
-- GiveTakePartyMonItem, the ITEM row. Its own menu is drawn over this list
-- (menu_coords 12, 12, 19, 17), so it is a non-opaque state too. An EGG never
-- reaches it: `cp EGG / jr z, .cancel` is the routine's first test.
function PartyMenu:openHeldItemMenu(slot, mon)
local game = self.game
if not (game and game.stack and self.save) then return end
if mon and mon.isEgg then return end
Screens.push(game, "Gen2HeldItemMenu", {
save = self.save,
slot = slot,
items = self.items,
onClose = function() game.stack:pop() end,
})
end
-- MonMailAction (engine/pokemon/mon_menu.asm), the MAIL row. The READ / TAKE
-- / QUIT menu is drawn OVER this list (MENU_BACKUP_TILES at menu_coords 9, 10,
-- 19, 17), so it is a non-opaque state pushed on top rather than a submenu of
-- this screen's own.
function PartyMenu:openMailMenu(slot)
local game = self.game
if not (game and game.stack and self.save) then return end
Screens.push(game, "Gen2MailMenu", {
save = self.save,
slot = slot,
onClose = function() game.stack:pop() end,
})
end
-- The $2 return. Same exit PackMenu's PACKSTATE_QUITRUNSCRIPT takes, for the
-- same reason: only an empty stack lets the overworld run the queued script.
function PartyMenu:exitToField()
local stack = self.game and self.game.stack
if stack and stack.clear then
stack:clear()
-- ../pokecrystal/home/map.asm:1927-1940
local world = self.game.world
if world and world.exitMenusFade and not world.mapSetup then
world:exitMenusFade()
end
elseif self.onCancel then
self.onCancel()
end
end
-- The ids updateSubmenu dispatches itself; anything else is a mod's row.
local VANILLA_SUBMENU_IDS = {
STATS = true, SWITCH = true, MAIL = true, ITEM = true, MOVE = true,
CANCEL = true,
}
-- MonMenuLoop: A selects, B is MONMENUITEM_CANCEL, and nothing else is read.
function PartyMenu:updateSubmenu(input)
local menu = self.submenu
local total = #menu.items
if input:wasPressed("up") then
menu.index = menu.index > 1 and menu.index - 1 or total
elseif input:wasPressed("down") then
menu.index = menu.index < total and menu.index + 1 or 1
elseif input:wasPressed("b") then
self:closeSubmenu()
elseif input:wasPressed("a") then
local item = menu.items[menu.index]
local mon = menu.mon
local slot = menu.slot or self.index
local battle = menu.battle
self:closeSubmenu()
if not item then return end
-- `cp $1 ; SWITCH / jp z, TryPlayerSwitch` and `.Cancel: jp BattleMenu`
-- (engine/battle/core.asm:4811-4816).
if battle and item.id == "SWITCH" then
if self.onChoose then self.onChoose(slot, mon) end
return
elseif battle and item.id == "CANCEL" then
if self.onCancel then self.onCancel() end
return
end
-- A hook-injected entry carries a callback instead of one of the ids this
-- chain knows, and it is answered first for the reason the Gen 1 site
-- answers it first (src/ui/PartyMenu.lua:346): a mod cannot mint new ids
-- into the chain, so without the arm its row draws and does nothing.
if item.onSelect and not VANILLA_SUBMENU_IDS[item.id] and not item.fieldMove then
item.onSelect(mon, self.game)
elseif item.id == "STATS" then
self:openStats()
elseif item.id == "SWITCH" then
self:beginSwitch(slot)
elseif item.id == "MAIL" then
self:openMailMenu(slot)
elseif item.id == "ITEM" then
self:openHeldItemMenu(slot, mon)
elseif item.id == "MOVE" then
self:openMoveManager(slot, mon)
elseif item.fieldMove then
self:useFieldMove(item.id, mon)
end
end
end
function PartyMenu:playSfx(name)
local data = self.game and self.game.data
local sfx = data and data.audio and data.audio.sfx
if sfx and sfx[Sound.resolve(data, name)] then Sound.play(data, name) end
end
-- engine/pokemon/switchpartymons.asm:13
function PartyMenu:playSfxTwice(name)
local data = self.game and self.game.data
local sfx = data and data.audio and data.audio.sfx
if not (sfx and Sound.resolve and Sound.play) then return end
if not sfx[Sound.resolve(data, name)] then return end
self:playSfx(name)
self.repeatSfx = WaitPlaySFX.arm(name)
end
-- home/audio.asm:225
function PartyMenu:tickRepeatSfx()
local pending = self.repeatSfx
if not pending then return false end
if WaitPlaySFX.waiting(pending) then return true end
self.repeatSfx = nil
self:playSfx(pending.name)
return false
end
-- engine/items/item_effects.asm:1671
function PartyMenu:showItemResult(slot, opts)
opts = opts or {}
self.itemResult = {
slot = slot,
shown = opts.fromHp,
target = opts.toHp,
text = opts.text,
delay = opts.delay or PartyMenu.ACTION_TEXT_DELAY,
onDone = opts.onDone,
auto = opts.auto,
holdSlot = opts.holdSlot,
holdHp = opts.holdHp,
}
if opts.sfx then self:playSfx(opts.sfx) end
end
-- ../pokecrystal/engine/battle/core.asm:5156
-- ../pokecrystal/home/text.asm:124
function PartyMenu:refuse(text)
self:closeSubmenu()
local lines = Chrome.wrap(text, 18)
for i = #lines, 3, -1 do lines[i] = nil end
self:showItemResult(nil, { text = table.concat(lines, "\n"), delay = 0 })
end
function PartyMenu:itemResultClimbing()
local r = self.itemResult
return r ~= nil and r.shown ~= nil and r.target ~= nil and r.shown ~= r.target
end
-- engine/battle/anim_hp_bar.asm:246
function PartyMenu:shownHpFor(slot, mon)
local r = self.itemResult
if r then
if r.slot == slot and r.shown then return r.shown end
if r.holdSlot == slot and r.holdHp then return r.holdHp end
end
return mon and mon.hp
end
function PartyMenu:updateItemResult(input)
local r = self.itemResult
if self:itemResultClimbing() then
local mon = self.party[r.slot]
local maxHp = mon and (mon.maxHp or (mon.stats and mon.stats.hp)) or 0
r.shown = HpBar.stepToward(r.shown, r.target, maxHp)
return
end
if r.auto then
self.itemResult = nil
if r.onDone then r.onDone() end
return
end
if r.delay > 0 then
r.delay = r.delay - 1
return
end
if input:wasPressed("a") or input:wasPressed("b") then
self.itemResult = nil
if r.onDone then r.onDone() end
end
end
function PartyMenu:update(_dt)
self.clock = self.clock + 1
local input = self.game and self.game.input
if not input then return end
-- home/audio.asm:225
if self:tickRepeatSfx() then return end
if self.itemResult then
self:updateItemResult(input)
return
end
if self.submenu then
self:updateSubmenu(input)
return
end
if self.switchFrom then
self:updateSwitch(input)
return
end
if self.softboiledFrom then
self:updateSoftboiled(input)
return
end
local total = self:count()
local grid
if self:gridNavigation() then
local direction = input:wasPressed("left") and "left"
or input:wasPressed("right") and "right"
or input:wasPressed("up") and "up"
or input:wasPressed("down") and "down"
grid = gridIndex(self.index, #self.party, direction)
end
if grid then
self.index = grid
self:storeCursor()
elseif input:wasPressed("up") then
self.index = self.index > 1 and self.index - 1 or total
elseif input:wasPressed("down") then
self.index = self.index < total and self.index + 1 or 1
elseif input:wasPressed("a") then
self:storeCursor()
if self:isCancel() then
if self.onCancel then self.onCancel() end
elseif self.wantsSubmenu or self.wantsBattleSubmenu then
self:openSubmenu()
elseif self.onChoose then
local mon = self.party[self.index]
if self.tmhm and mon and mon.isEgg then
-- engine/items/tmhm.asm:104
local world = self.game and self.game.world
if world and world.playSfxNamed then world:playSfxNamed("Sfx_Wrong") end
return
end
self.onChoose(self.index, mon)
end
elseif input:wasPressed("b") then
self:storeCursor()
if self.onCancel then self.onCancel() end
end
end
-- PartyMenuSelect's `ld [wPartyMenuCursor], a` sits after the CANCEL test but
-- BEFORE the B-button test (engine/pokemon/party_menu.asm:600), so backing out
-- of a mon's row still records it and only the CANCEL row leaves the byte
-- alone. 1-based over the party, the same numbering PartyMenu:count() uses.
function PartyMenu:storeCursor()
local game = self.game
if not game or self:isCancel() then return end
game.partyMenuCursor = self.index
end
-- ReadMonMenuIcon (engine/gfx/mon_icons.asm): an EGG slot draws ICON_EGG --
-- the `cp EGG / jr z, .egg` arm -- and any other species reads MonMenuIcons.
function PartyMenu:iconIdFor(mon)
if not mon then return nil end
if mon.isEgg then return "ICON_EGG" end
return self.icons and self.icons.species and mon.species
and self.icons.species[mon.species] or nil
end
-- The icon image for a mon, plus which 16x16 frame to show.
--
-- The path goes out through pokemon.icon before it is loaded -- the SAME hook
-- name, the same (data, mon, vanillaPath, { name = iconId }) call and the same
-- ctx the Gen 1 party list makes (src/ui/PartyMenu.lua:186 through
-- src/pokemon/Sprites.lua iconPath), so one skin mod repaints the party icons
-- in both games. Sprites.iconPath does its own Runtime.wantsHook check and
-- hands the vanilla path straight back on an unhooked boot, so this costs a
-- table lookup per row; the cache is keyed on the RESOLVED path so a mod's
-- image does not ride the built-in's entry.
function PartyMenu:iconFor(mon)
local iconId = self:iconIdFor(mon)
local entry = iconId and self.icons and self.icons.icons
and self.icons.icons[iconId]
local path = entry and entry.image
path = require("src.pokemon.Sprites").iconPath(
self.game and self.game.data, mon, path, { name = iconId })
if not path then return nil end
local cached = self.iconCache[path]
if cached == nil then
local ok, img = pcall(Assets.image, path)
cached = ok and img or false
self.iconCache[path] = cached
end
if not cached then return nil end
local frame = math.floor(self.clock / ICON_FRAME_STEPS) % 2
return cached, frame
end
-- .SpawnItemIcon (engine/gfx/mon_icons.asm): a mon carrying something does not
-- get a word of text, it gets its ICON's bottom-left tile swapped. A zero
-- MON_ITEM returns early; otherwise ItemIsMail picks
-- SPRITE_ANIM_FRAMESET_PARTY_MON_WITH_MAIL over ..._WITH_ITEM. Returns the row
-- of the HeldItemIcons sheet to draw -- 0 is mail.2bpp, 1 is item.2bpp, the
-- order the two tiles are INCBIN'd in -- or nil for an empty hand. There is
-- deliberately no EGG arm: the cart has none, because an egg's item byte is
-- always zero (Breeding.hatchEgg/DayCare_GiveEgg never write one).
function PartyMenu.heldMarkerRow(mon)
if type(mon) ~= "table" then return nil end
local item = mon.item
if item == nil or item == 0 or item == "" then return nil end
return Mail.monHoldsMail(mon) and 0 or 1
end
-- The HeldItemIcons sheet, which GetIconGFX uploads as the two tiles straight
-- after an icon's eight (`ld de, 8 tiles / add hl, de`), so it rides the same
-- cache entry as the icons themselves. A cache built before the extractor read
-- the symbol simply has no `heldItem` row: the marker goes undrawn and the list
-- looks exactly as it did, rather than erroring on a missing image.
function PartyMenu:heldMarkerImage()
local entry = self.icons and self.icons.heldItem
if not (entry and entry.image) then return nil end
local cached = self.iconCache[entry.image]
if cached == nil then
local ok, img = pcall(Assets.image, entry.image)
cached = ok and img or false
self.iconCache[entry.image] = cached
end
return cached or nil
end
-- AnimSeq_PartyMon rewrites the icon's x every frame: an unselected row sits at
-- 8 * 2 and the highlighted one at 8 * 3, so the icons rest against the left
-- wall and the one under the cursor slides a tile right to make room for it.
-- Screen x is the struct's x minus 16 (the OAM template's -1 tile, then the
-- hardware's -8), which is 0 and 8.
function PartyMenu:iconX(index)
return index == self.index and 8 or 0
end
-- ...and AnimSeq_PartyMonSwitch bobs that one: VAR1 counts frames and, every
-- sixteenth, bit 4 decides whether YOFFSET is 0 or negative. So the selected
-- icon rides two pixels high for half of each 32-frame cycle.
function PartyMenu:iconBob(index)
if index ~= self.index then return 0 end
return (math.floor(self.clock / 16) % 2 == 1) and -2 or 0
end
function PartyMenu:drawIcon(mon, px, py)
local image, frame = self:iconFor(mon)
if not image then return end
local G = love.graphics
local iw, ih = image:getDimensions()
local markerRow = PartyMenu.heldMarkerRow(mon)
local marker = markerRow and self:heldMarkerImage() or nil
local paint
if marker then
-- The _WITH_ITEM / _WITH_MAIL OAM sets (data/sprite_anims/oam.asm) are the
-- ordinary four quadrants with the `dbsprite -1, 0` entry -- the bottom
-- left -- reading tile $08/$09 instead of the icon's own. So three of the
-- icon's 8x8 tiles are drawn and the fourth is REPLACED, not covered: the
-- marker tile is transparent in places and the icon would show through it.
-- Both of the frameset's oamframes name the same marker tile, so it must
-- not be indexed by `frame`: the icon bobs, the marker does not.
local mw, mh = marker:getDimensions()
local topLeft = G.newQuad(0, frame * 16, 8, 8, iw, ih)
local topRight = G.newQuad(8, frame * 16, 8, 8, iw, ih)
local bottomRight = G.newQuad(8, frame * 16 + 8, 8, 8, iw, ih)
local held = G.newQuad(0, markerRow * 8, 8, 8, mw, mh)
paint = function()
G.draw(image, topLeft, px, py)
G.draw(image, topRight, px + 8, py)
G.draw(image, bottomRight, px + 8, py + 8)
G.draw(marker, held, px, py + 8)
end
else
local quad = G.newQuad(0, frame * 16, 16, 16, iw, ih)
paint = function() G.draw(image, quad, px, py) end
end
G.setColor(1, 1, 1, 1)
-- Every party icon OAM entry is PAL_OW_RED (data/sprite_anims/oam.asm:315-355)
-- and InitPartyMenuOBPals loads PartyMenuOBPals into OBJ 0 for the whole list,
-- species and EGG alike (engine/gfx/color.asm:593-598, :1228-1229).
local pals = self.palettes and self.palettes.partyMenu
local colors = pals and pals[1] or nil
if colors and GbcPalette.available() then
GbcPalette.with(colors, paint)
else
paint()
end
end
-- PlacePartyHPBar calls DrawBattleHPBar (home/pokemon.asm) with `ld d, $6`, and
-- that routine lays the WHOLE assembly, not just the bar: "HP:" is $60/$61 at
-- the coordinate it is given, then six $62 bar cells, then the $6b end cap. So
-- a party row's bar is the battle HUD's bar, tile for tile -- which is why this
-- goes through BattleHud instead of drawing a rectangle.
function PartyMenu:drawHpBar(mon, tx, ty, hp)
local maxHp = mon.maxHp or (mon.stats and mon.stats.hp)
if hp == nil then hp = mon.hp end
if self.hud and self.hud:available() then
return self.hud:drawHpBar(hp, maxHp, tx, ty)
end
-- No battle-HUD sheet in the cache: the plain bar, two tiles in, so the
-- fallback still lands where the cells would.
HpBar.draw(self.palettes, hp, maxHp, (tx + 2) * 8, ty * 8 + 2)
return tx + 2 + HpBar.LENGTH_TILES
end
-- PrintNum with `lb bc, 2, 3`: three columns, space-padded from the right.
local function num3(value)
local text = tostring(math.max(0, math.floor(value or 0)))
if #text > 3 then text = text:sub(-3) end
return (" "):rep(3 - #text) .. text
end
-- PlaceStatusString (engine/pokemon/mon_stats.asm): three letters, and a mon
-- with no HP reads FNT whatever its status byte says.
local function statusString(mon, hp, statuses)
if hp == nil then hp = mon.hp end
if (hp or 0) <= 0 then return Strings(FAINTED_LABEL) end
local status = mon.status
if not status then return nil end
local key = tostring(status):lower()
if statuses and statuses[key] then
return Strings(Status.hudLabelFor(statuses, key))
end
local class = ItemEffects.STATUS_CLASS[key]
if not class then return nil end
if not statuses then return class:upper() end
-- Gen 2's registry is keyed by effect ids (poison/burn/freeze/...), while
-- save and field code may still carry their three-letter spellings.
local id = Status.GEN2_ID_ALIASES[key] or key
return Strings(Status.hudLabelFor(statuses, id))
end
-- One list row's strings, exactly what WritePartyMenuTilemap's quality
-- routines write. Every routine but PlacePartyNicknames begins with
-- PartyMenuCheckEgg (`cp EGG`) and skips its row for an egg, so an egg is a
-- name and an icon alone: no HP digits, no bar, no level, no FNT. The name
-- itself is String_Egg -- GiveEgg writes "EGG" over the nickname slot -- so
-- it never reads as the species hiding inside.
function PartyMenu.rowFor(mon, hp, statuses)
if mon.isEgg then return { name = Strings(EGG_LABEL) } end
local maxHp = mon.maxHp or (mon.stats and mon.stats.hp) or 0
if hp == nil then hp = mon.hp end
return {
name = mon.nickname or mon.name or mon.species or "?",
hp = num3(hp) .. "/" .. num3(maxHp),
status = statusString(mon, hp, statuses),
-- <LV> is one font glyph ($6e), not the two characters ":L".
level = "<LV>" .. tostring(mon.level or 1),
}
end
-- engine/pokemon/party_menu.asm:331
function PartyMenu:tmhmAble(mon)
if not mon or mon.isEgg then return nil end
local move = self.tmhm and self.tmhm.move
if not move then return nil end
local species = self.pokemon and self.pokemon[mon.species]
for _, id in ipairs((species and species.tmhm) or {}) do
if id == move then return Strings(ABLE_LABEL) end
end
return Strings(NOT_ABLE_LABEL)
end
-- WritePartyMenuTilemap, jumptable entry by jumptable entry. Every coordinate
-- below is the hlcoord the matching PARTYMENUQUALITY_* routine uses, and each
-- steps 2 * SCREEN_WIDTH per mon:
--
-- PlacePartyNicknames (3, 1) CANCEL two columns left of the row
-- after the last mon
-- PlacePartyMenuHPDigits (13,1) "%3d" "/" "%3d"
-- PlacePartyMonStatus (5, 2)
-- PlacePartyMonLevel (8, 2) <LV> then a left-aligned number
-- PlacePartyHPBar (11,2) DrawBattleHPBar: "HP:" + 6 cells + cap
-- PartyMenu_InitAnimatedMonIcon sprite anim at y $1c + $10 * i;
-- .OAMData_RedWalk's first entry is
-- dbsprite -1,-1, so the icon's top-left
-- OAM is (x - 8, y - 8) and, minus the
-- (8,16) hardware offset, pixel
-- (x - 16, 4 + 16 * i)
-- PartyMenu2DMenuData cursor at column 0, rows 1, 3, 5 ...
-- PlacePartyMenuText Textbox at (0,14) 18x2 interior, string at (1,16)
function PartyMenu:drawPanel()
-- LoadPartyMenuGFX starts with LoadFontsBattleExtra, so tiles $60-$6f on this
-- screen are FontBattleExtra's: <LV> ($6e) is the bold ":L" here, not
-- FontExtra's "Lv", and the HP bar's cells come from the same sheet.
local wasBattle = Font.useBattleExtra(true)
Chrome.clear()
for i, mon in ipairs(self.party) do
local nameY = 1 + (i - 1) * 2
local dataY = nameY + 1
if i == self.index then
Chrome.cursor(0, nameY)
elseif self.switchFrom == i then
-- SwitchPartyMons parks '▷' on the held row (`ld [hl], '▷'`); the live
-- cursor overwrites it whenever it sits there.
Chrome.cursor(0, nameY, true)
end
self:drawIcon(mon, self:iconX(i), 4 + (i - 1) * 16 + self:iconBob(i))
local hp = self:shownHpFor(i, mon)
local row = PartyMenu.rowFor(mon, hp,
self.game and self.game.data and self.game.data.gen2Statuses)
Chrome.print(row.name, 3, nameY)
if self.tmhm then
local able = self:tmhmAble(mon)
if able then Chrome.print(able, 12, dataY) end
else
if row.hp then Chrome.print(row.hp, 13, nameY) end
if row.hp then self:drawHpBar(mon, 11, dataY, hp) end
end
if row.status then Chrome.print(row.status, 5, dataY) end
if row.level then Chrome.print(row.level, 8, dataY) end
end
-- .end does `dec hl` twice from the row past the last nickname, so CANCEL
-- starts two columns left of where the nicknames do.
local cancelY = 1 + #self.party * 2
if self:isCancel() then Chrome.cursor(0, cancelY) end
Chrome.print(actionLabel("CANCEL"), 1, cancelY)
-- PlacePartyMenuText: Textbox at (0,14) with a 2x18 interior, string at (1,16).
-- ReturnToMapWithSpeechTextbox restores the normal font afterwards, and so
-- does this: the prompt is ordinary text.
Font.useBattleExtra(wasBattle)
-- engine/pokemon/party_menu.asm:698
if self.itemResult and self.itemResult.text and not self:itemResultClimbing() then
Chrome.textbox(0, 12, 18, 4)
local line = 14
for part in tostring(self.itemResult.text):gmatch("[^\n]+") do
if line <= 16 then Chrome.print(part, 1, line) end
line = line + 2
end
else
Chrome.box(0, 14, 20, 4)
-- ../pokecrystal/engine/items/item_effects.asm:2016
local prompt = self.switchFrom and Strings(PartyMenu.PROMPTS.moveTo)
or (self.softboiledFrom and Strings(PartyMenu.PROMPTS.useItem))
or (self.promptIsBuiltin and Strings(self.prompt) or self.prompt)
Chrome.print(#self.party == 0 and Strings(PartyMenu.PROMPTS.none) or prompt,
1, 16)
end
-- PokemonActionSubmenu clears (1,15) 2x18 before MonSubmenu draws, so the
-- prompt is gone behind the box rather than showing through it.
if self.submenu then self:drawSubmenu() end
love.graphics.setColor(1, 1, 1, 1)
end
-- MenuBox over the bottom right of the list, with the labels two rows and two
-- columns inside it and the cursor one column left of them.
function PartyMenu:drawSubmenu()
local menu = self.submenu
-- GetMenuTextStartCoord puts the labels at (13, 12) two rows apart, cursor
-- one column left (home/menu.asm:199-226).
if menu.battle then
Chrome.box(BATTLE_SUBMENU_LEFT, BATTLE_SUBMENU_TOP, 9, 7)
for row, item in ipairs(menu.items) do
local ty = BATTLE_SUBMENU_TOP + 1 + (row - 1) * 2
if row == menu.index then Chrome.cursor(BATTLE_SUBMENU_LEFT + 1, ty) end
Chrome.print(item.label, BATTLE_SUBMENU_LEFT + 2, ty)
end
return
end
local count = #menu.items
local top = PartyMenu.submenuTop(count)
Chrome.box(SUBMENU_LEFT, top, SUBMENU_RIGHT - SUBMENU_LEFT + 1,
SUBMENU_BOTTOM - top + 1)
for row, item in ipairs(menu.items) do
local tx, ty = PartyMenu.submenuLabelCoord(count, row)
if row == menu.index then Chrome.cursor(tx - 1, ty) end
Chrome.print(item.label, tx, ty)
end
end
function PartyMenu:draw()
self:drawPanel()
end
function PartyMenu:drawWidescreen(winW, winH)
local G = love.graphics
Chrome.letterbox(winW, winH, 1, 1, 1)
local scale = Chrome.fitScale(winW, winH)
G.push()
G.translate(Chrome.fitOrigin(winW, winH, scale))
G.scale(scale, scale)
self:drawPanel()
G.pop()
end
return PartyMenu