-- 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 -- ?", "Teach which ?", ...). -- -- 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 ?", "Teach which -- ?", 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. / 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 ?" inside its 18-column text box. PartyMenu.PROMPTS = { choose = Strings.source("Choose a POKéMON."), useItem = Strings.source("Use on which ?"), which = Strings.source("Which ?"), teach = Strings.source("Teach which ?"), moveTo = Strings.source("Move to where?"), toWhich = Strings.source("To which ?"), none = Strings.source("You have no !"), } 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), -- is one font glyph ($6e), not the two characters ":L". level = "" .. 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) 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: ($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