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

828 lines
34 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 Font = require("src.render.Font")
local GbcPalette = require("src.render.GbcPalette")
local HpBar = require("src.battle.gen2.HpBar")
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 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 = "Choose a POKéMON.",
useItem = "Use on which <PK><MN>?",
which = "Which <PK><MN>?",
teach = "Teach which <PK><MN>?",
moveTo = "Move to where?",
toWhich = "To which <PK><MN>?",
none = "You have no <PK><MN>!",
}
-- The icon's two frames swap every 16 logic steps, close to the cart's
-- SPRITE_ANIM cadence.
local ICON_FRAME_STEPS = 16
-- 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
-- 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.
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
self.prompt = PartyMenu.PROMPTS[opts.prompt or "choose"] 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
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.submenu = nil
-- The held slot while SwitchPartyMons' second pick is open; nil otherwise.
self.switchFrom = 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 then return #self.party end
return #self.party + 1
end
function PartyMenu:isCancel()
return self.index > #self.party
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 = "STATS" },
{ id = "SWITCH", label = "SWITCH" },
{ id = "CANCEL", label = "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 = "STATS" }
items[#items + 1] = { id = "SWITCH", label = "SWITCH" }
items[#items + 1] = { id = "MOVE", label = "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 = "MAIL" }
or { id = "ITEM", label = "ITEM" }
if #items < NUM_MONMENU_ITEMS then
items[#items + 1] = { id = "CANCEL", label = "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 = "SWITCH" },
{ id = "STATS", label = "STATS" },
{ id = "CANCEL", label = "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:38
local data = self.game and self.game.data
local ok, Sound = pcall(require, "src.core.Sound")
if not (ok and data and Sound and Sound.play) then return end
local sfx = data.audio and data.audio.sfx
if sfx and sfx[Sound.resolve(data, "Sfx_SwitchPokemon")] then
pcall(Sound.play, data, "Sfx_SwitchPokemon")
end
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
-- 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.
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 result and result.ok then self:exitToField() end
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()
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:update(_dt)
self.clock = self.clock + 1
local input = self.game and self.game.input
if not input then return end
if self.submenu then
self:updateSubmenu(input)
return
end
if self.switchFrom then
self:updateSwitch(input)
return
end
local total = self:count()
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("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)
local maxHp = mon.maxHp or (mon.stats and mon.stats.hp)
if self.hud and self.hud:available() then
return self.hud:drawHpBar(mon.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, mon.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 STATUS_STRING = {
slp = "SLP", psn = "PSN", brn = "BRN", frz = "FRZ", par = "PAR",
poison = "PSN", burn = "BRN", freeze = "FRZ", paralysis = "PAR",
sleep = "SLP", toxic = "PSN",
}
local function statusString(mon)
if (mon.hp or 0) <= 0 then return "FNT" end
local status = mon.status
if not status then return nil end
return STATUS_STRING[tostring(status):lower()]
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)
if mon.isEgg then return { name = "EGG" } end
local maxHp = mon.maxHp or (mon.stats and mon.stats.hp) or 0
return {
name = mon.nickname or mon.name or mon.species or "?",
hp = num3(mon.hp) .. "/" .. num3(maxHp),
status = statusString(mon),
-- <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 "ABLE" end
end
return "NOT ABLE"
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 row = PartyMenu.rowFor(mon)
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) 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("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)
Chrome.box(0, 14, 20, 4)
-- SwitchPartyMons swaps the prompt for PARTYMENUACTION_MOVE's string while
-- the second pick is open, then puts the caller's own back.
local prompt = self.switchFrom and PartyMenu.PROMPTS.moveTo or self.prompt
Chrome.print(#self.party == 0 and PartyMenu.PROMPTS.none or prompt, 1, 16)
-- 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
G.setColor(1, 1, 1, 1)
G.rectangle("fill", 0, 0, winW, winH)
local scale = Chrome.fitScale(winW, winH)
G.push()
G.translate(math.floor((winW - 160 * scale) / 2),
math.floor((winH - 144 * scale) / 2))
G.scale(scale, scale)
self:drawPanel()
G.pop()
end
return PartyMenu