-- Party menu: list the party, choose a member. -- Modes: -- default: A -> submenu (STATS / SWITCH order / CANCEL) -- opts.onSwitch: A -> hand the chosen mon to the callback (battle -- switch, item targeting via opts.pickOnly) -- opts.onCancel: fired when the menu closes without a pick (B) -- Pops itself on B. local Font = require("src.render.Font") local Logger = require("src.core.Logger") local Runtime = require("src.mods.Runtime") local Screens = require("src.ui.Screens") local Theme = require("src.ui.Theme") local PartyMenu = {} PartyMenu.__index = PartyMenu PartyMenu.isOpaque = true -- SGB: generic whole-screen palette (SET_PAL_GENERIC) function PartyMenu:sgbPalettes(game) return require("src.render.PaletteFX").wholeNamed(game.data, "MEWMON") end local function sameItems(_, items) return items end -- where DIG escapes work: escape_rope_tilesets.asm (Agatha's room is -- excluded by map id in ItemUseEscapeRope) local DIG_TILESETS = { FOREST = true, CEMETERY = true, CAVERN = true, FACILITY = true, INTERIOR = true } -- Party mon icons (engine/gfx/mon_icons.asm AnimatePartyMon): only the -- SELECTED mon's icon animates, at a speed set by its HP bar color -- -- 5 / 16 / 32 frames per phase for green / yellow / red (the famous -- health-speed detail). BALL and HELIX icons nudge one pixel down -- instead of switching frames; every other icon swaps to a real second -- frame (+ICONOFFSET). -- Rest/alt frame per icon (data/icon_pointers.asm -- MonPartySpritePointers): the base entries are the RESTING frame, -- the +ICONOFFSET entries the animated alternate. The 16x32 icon -- sheets stack Frame1 (index 0) over Frame2 (index 1, INC_FRAME_2): -- BUG/GRASS rest on BugIconFrame2/PlantIconFrame2 and animate to -- Frame1; SNAKE/QUADRUPED are the reverse. Sprite-reused icons draw -- from 16x16x6 overworld sheets where index 3 is walk-down (tile 12): -- MON/FAIRY/BIRD rest on the walk frame and animate to standing -- (tile 0); WATER (Seel) is the reverse. PartyMenu.iconFrames = { BUG = { rest = 1, alt = 0 }, -- BugIconFrame2 <-> BugIconFrame1 GRASS = { rest = 1, alt = 0 }, -- PlantIconFrame2 <-> PlantIconFrame1 SNAKE = { rest = 0, alt = 1 }, -- SnakeIconFrame1 <-> SnakeIconFrame2 QUADRUPED = { rest = 0, alt = 1 }, -- QuadrupedIconFrame1 <-> Frame2 MON = { rest = 3, alt = 0 }, -- MonsterSprite tile 12 <-> tile 0 FAIRY = { rest = 3, alt = 0 }, -- FairySprite tile 12 <-> tile 0 BIRD = { rest = 3, alt = 0 }, -- BirdSprite tile 12 <-> tile 0 WATER = { rest = 0, alt = 3 }, -- SeelSprite tile 0 <-> tile 12 } -- Which 16x16 frame of `name`'s sheet to draw; `ih` (sheet pixel -- height) only matters for the fallback, which keeps the old uniform -- behavior for icons outside the table (BALL/HELIX y-bob instead). function PartyMenu.frameFor(name, alt, ih) local m = PartyMenu.iconFrames[name] if m then return alt and m.alt or m.rest end return alt and ((ih or 0) >= 64 and 3 or 1) or 0 end local iconImages = {} local function drawIcon(game, mon, x, y, selected, counter) local icons = game.data.icons if not icons then return end local def = game.data.pokemon[mon.species] -- Per-species override first: the icons registry folds into -- icons.bySpecies, and a pokemon record may carry its own `icon` field. -- Either is a built-in icon name (resolved through icons.icons) or a -- { image = , frames? } table pointing at bundled art. Falling -- through to icons.byDex[def.dex] keeps the vanilla dex-indexed default; -- without the override a modded or dex-renumbered species could never -- change its menu icon. local entry = (icons.bySpecies and icons.bySpecies[mon.species]) or (def and def.icon) local name, path if type(entry) == "string" then name = entry path = icons.icons and icons.icons[entry] elseif type(entry) == "table" then path = entry.image end if not path then name = def and def.dex and icons.byDex and icons.byDex[def.dex] path = name and icons.icons and icons.icons[name] end if not path then return end if iconImages[path] == nil then local ok, img = pcall(love.graphics.newImage, path) iconImages[path] = ok and img or false end local img = iconImages[path] if not img then return end local alt = false if selected then local px = math.floor(mon.hp * 48 / math.max(1, mon.stats.hp)) local speed = px >= 27 and 5 or px >= 10 and 16 or 32 alt = math.floor(counter / speed) % 2 == 1 end if alt and (name == "BALL" or name == "HELIX") then y = y + 1 alt = false end local iw, ih = img:getDimensions() if ih > 16 then local frame = PartyMenu.frameFor(name, alt, ih) love.graphics.draw(img, love.graphics.newQuad(0, frame * 16, 16, 16, iw, ih), x, y) else love.graphics.draw(img, x, y) end end function PartyMenu.new(game, opts) opts = opts or {} local self = setmetatable({}, PartyMenu) self.game = game self.index = 1 self.onSwitch = opts.onSwitch self.onCancel = opts.onCancel self.pickOnly = opts.pickOnly self.battle = opts.battle self.party = opts.party -- link battles pass their clamped copies self.swapFrom = nil self.submenu = nil self.subIndex = 1 self.blink = 0 return self end function PartyMenu:update(dt) -- icon animation counter; 320 = a whole cycle at every HP speed self.blink = ((self.blink or 0) + 1) % 320 local input = self.game.input local party = self.party or self.game.save.party if self.submenu then local n = #self.subItems if input:wasPressed("up") then self.subIndex = self.subIndex > 1 and self.subIndex - 1 or n elseif input:wasPressed("down") then self.subIndex = self.subIndex < n and self.subIndex + 1 or 1 elseif input:wasPressed("b") then self.submenu = nil elseif input:wasPressed("a") then local mon = party[self.index] local entry = self.subItems[self.subIndex] local action = entry.action if not action and entry.onSelect then -- hook-injected entries carry a callback instead of an action id entry.onSelect(mon, self.game) elseif action == "stats" then Screens.push(self.game, "SummaryMenu", mon) elseif action == "switch" then self.swapFrom = self.index elseif action == "fly" then self.game.stack:pop() -- close the party menu Screens.push(self.game, "FlyMenu") return elseif action == "flash" then -- FLASH lights dark tunnels -- start_sub_menus.asm .flash: PrintText _FlashLightsAreaText, then -- GBPalWhiteOutWithDelay3 + jp .goBackToMap local ow = self.game.overworld local TextBox = require("src.render.TextBox") local Transition = require("src.render.Transition") self.game.stack:pop() ow.dark = false self.game.save.flashLit = true self.game.stack:push(TextBox.new(self.game, self.game.data.text._FlashLightsAreaText or "A blinding FLASH\nlights the area!", function() self.game.stack:push(Transition.whiteFlash(self.game)) end)) return elseif action == "surf" then -- start_sub_menus.asm .surf: SOULBADGE-gated (checked at list time -- above), then IsSurfingAllowed (the Cycling Road / Seafoam B4F -- current refusals, both of which loop back to the submenu), then -- ItemUseSurfboard: while surfing it tries to dismount instead; -- otherwise it mounts only if the FACING tile is water, else -- SurfingAttemptFailed (_NoSurfingHereText) loops back to the -- submenu. useSurfFieldMove reports which; trySurf does the mount. local ow = self.game.overworld local reason = ow:useSurfFieldMove() local Transition = require("src.render.Transition") if reason == "ok" then self.game.stack:pop() -- close the party menu (jp .goBackToMap) local fx, fy = ow.player:facingCell() ow:trySurf(fx, fy) return end if reason == "dismount" then -- ItemUseSurfboard .stopSurfing: no text -- the walking state -- and music return first (PlayDefaultMusic + -- LoadWalkingPlayerSpriteGraphics), the menu closes with the -- GBPalWhiteOutWithDelay3 blink, and the simulated pad press -- steps the player forward onto land (or across a connection -- strip when the shore is the next map's edge) self.game.stack:pop() ow.player.surfing = false require("src.core.Music").setSurfing(self.game.data, false) self.game.stack:push(Transition.whiteFlash(self.game, nil, function() ow:stepForwardOrCrossEdge(ow.player.facing) end)) return end local TextBox = require("src.render.TextBox") local def = self.game.data.pokemon[mon.species] local key = ({ no_badge = "_NewBadgeRequiredText", forced_bike = "_CyclingIsFunText", current = "_CurrentTooFastText", no_place = "_SurfingNoPlaceToGetOffText" })[reason] or "_NoSurfingHereText" local txt = (self.game.data.text[key] or "No SURFing here!") :gsub("{RAM:wNameBuffer}", mon.nickname or def.name) if reason == "no_place" then -- .cannotStopSurfing prints _SurfingNoPlaceToGetOffText but -- never zeroes wActionResultOrTookBattleTurn, so unlike the -- other refusals the menu still closes afterwards -- (GBPalWhiteOutWithDelay3 + .goBackToMap) self.game.stack:pop() self.game.stack:push(TextBox.new(self.game, txt, function() self.game.stack:push(Transition.whiteFlash(self.game)) end)) return end self.game.stack:push(TextBox.new(self.game, txt)) return -- .loop: submenu stays open behind the message elseif action == "cut" then -- start_sub_menus.asm .cut -> predef UsedCut (engine/overworld/cut.asm): -- CASCADEBADGE-gated (list time); _NothingToCutText loops back to the -- submenu when the FACING tile isn't a cuttable tree. local ow = self.game.overworld local reason = ow:useCutFieldMove() if reason == "ok" then self.game.stack:pop() -- close the party menu (CloseTextDisplay) local fx, fy = ow.player:facingCell() ow:tryCut(fx, fy) return end local TextBox = require("src.render.TextBox") local def = self.game.data.pokemon[mon.species] local key = (reason == "no_badge") and "_NewBadgeRequiredText" or "_NothingToCutText" local txt = (self.game.data.text[key] or "Nothing to CUT!") :gsub("{RAM:wNameBuffer}", mon.nickname or def.name) self.game.stack:push(TextBox.new(self.game, txt)) return -- .loop: submenu stays open behind the message elseif action == "strength" then -- start_sub_menus.asm .strength: RAINBOWBADGE-gated (list time); -- predef PrintStrengthText (field_move_messages.asm) sets -- BIT_STRENGTH_ACTIVE of wStatusFlags1 -- the sole gate -- push_boulder.asm reads -- then prints _UsedStrengthText (no -- prompt: after the text, the text_asm tail plays the chosen -- mon's cry, Delay3, and it auto-advances) and -- _CanMoveBouldersText (`prompt`: waits for A/B). Back in -- .strength, GBPalWhiteOutWithDelay3 blinks the screen white -- before CloseTextDisplay returns to the map. local ow = self.game.overworld local TextBox = require("src.render.TextBox") local Transition = require("src.render.Transition") local def = self.game.data.pokemon[mon.species] local name = mon.nickname or def.name self.game.stack:pop() -- close the party menu (jp .goBackToMap) ow.strengthActive = true local t1 = (self.game.data.text._UsedStrengthText or "{RAM:wNameBuffer} used\nSTRENGTH."):gsub("{RAM:wNameBuffer}", name) local t2 = (self.game.data.text._CanMoveBouldersText or "{RAM:wNameBuffer} can\nmove boulders."):gsub("{RAM:wNameBuffer}", name) self.game.stack:push(TextBox.new(self.game, t1, function() self.game.stack:push(TextBox.new(self.game, t2, function() self.game.stack:push(Transition.whiteFlash(self.game)) end)) end, { auto = { sound = function() return require("src.core.Sound").playCry(self.game.data, mon.species) end } })) return elseif action == "softboiled" then -- field SOFTBOILED (StartMenu_Pokemon .softboiled): transfer -- 1/5 of the user's max HP to a chosen teammate self.softboiledFrom = self.index elseif action == "escape" then -- DIG / TELEPORT both warp to the last Pokémon Center town -- (wLastBlackoutMap, special_warps.asm escape warp); .dig/.teleport -- end with GBPalWhiteOutWithDelay3 + jp .goBackToMap local ow = self.game.overworld local heal = self.game.save.lastHeal local Transition = require("src.render.Transition") self.game.stack:pop() if ow and heal then self.game.stack:push(Transition.whiteFlash(self.game, nil, function() require("src.core.Sound").play(self.game.data, "Teleport_Exit1") ow:warpToHealPoint() end)) end return end self.submenu = nil end return end if input:wasPressed("up") then self.index = self.index > 1 and self.index - 1 or math.max(1, #party) elseif input:wasPressed("down") then self.index = self.index < #party and self.index + 1 or 1 elseif input:wasPressed("b") then self.game.stack:pop() if self.onCancel then self.onCancel() end elseif input:wasPressed("a") and #party > 0 then local mon = party[self.index] if self.softboiledFrom then local user = party[self.softboiledFrom] local heal = math.floor(user.stats.hp / 5) if mon == user or mon.hp <= 0 or mon.hp >= mon.stats.hp or user.hp <= heal then self.softboiledFrom = nil local TextBox = require("src.render.TextBox") self.game.stack:push(TextBox.new(self.game, "It won't have\nany effect.")) else user.hp = user.hp - heal mon.hp = math.min(mon.stats.hp, mon.hp + heal) self.softboiledFrom = nil require("src.core.Sound").play(self.game.data, "Heal_HP") local def = self.game.data.pokemon[mon.species] local TextBox = require("src.render.TextBox") self.game.stack:push(TextBox.new(self.game, ("%s's HP\nwas restored!"):format(mon.nickname or def.name))) end elseif self.swapFrom then if self.swapFrom ~= self.index then party[self.swapFrom], party[self.index] = party[self.index], party[self.swapFrom] require("src.core.Sound").play(self.game.data, "Swap") end self.swapFrom = nil elseif self.onSwitch then self.game.stack:pop() self.onSwitch(mon) else self.submenu = true self.subIndex = 1 -- STATS/SWITCH plus this mon's field moves (start_sub_menus.asm -- builds the same dynamic list) local items = { { label = "STATS", action = "stats" }, { label = "SWITCH", action = "switch" } } local ow = self.game.overworld -- Field moves (HMs/TMs) are usable out of battle even when the mon -- is fainted -- Gen 1 does not require HP for Cut/Fly/Surf/etc. -- Battle still excludes this list via `not self.battle`. Softboiled -- can appear for a fainted user; its heal transfer then no-ops. if not self.battle and ow then for _, mv in ipairs(mon.moves) do if mv.id == "FLY" and ow.map.def.tileset == "OVERWORLD" and self.game.save.inventory.THUNDERBADGE then table.insert(items, { label = "FLY", action = "fly" }) elseif mv.id == "FLASH" and ow.dark and self.game.save.inventory.BOULDERBADGE then table.insert(items, { label = "FLASH", action = "flash" }) elseif mv.id == "CUT" and self.game.save.inventory.CASCADEBADGE then -- CUT/SURF/STRENGTH are party-menu field moves too -- (start_sub_menus.asm .outOfBattleMovePointers); listed here -- with the same list-time badge filter this file already uses -- for FLY/FLASH. The facing-tile/activation check happens on -- selection (useCutFieldMove/useSurfFieldMove). table.insert(items, { label = "CUT", action = "cut" }) elseif mv.id == "SURF" and self.game.save.inventory.SOULBADGE then table.insert(items, { label = "SURF", action = "surf" }) elseif mv.id == "STRENGTH" and self.game.save.inventory.RAINBOWBADGE then table.insert(items, { label = "STRENGTH", action = "strength" }) elseif mv.id == "SOFTBOILED" then table.insert(items, { label = "SOFTBOILED", action = "softboiled" }) elseif mv.id == "TELEPORT" and ow.map.def.tileset == "OVERWORLD" then -- TELEPORT works only OUTDOORS (start_sub_menus.asm -- .teleport -> CheckIfInOutsideMap); dark maps don't -- block it table.insert(items, { label = "TELEPORT", action = "escape" }) elseif mv.id == "DIG" and DIG_TILESETS[ow.map.def.tileset] and ow.map.id ~= "AGATHAS_ROOM" then -- DIG runs ItemUseEscapeRope (.dig sets wCurItem = -- ESCAPE_ROPE): usable in the dungeon tilesets of -- escape_rope_tilesets.asm minus Agatha's room, even in -- the dark (Rock Tunnel) table.insert(items, { label = "DIG", action = "escape" }) end end end local ctx = { battle = self.battle, overworld = ow } local hooked = Runtime.call("ui.party.submenu", sameItems, self.game, items, mon, ctx) if type(hooked) == "table" then items = hooked else Logger.error("ui.party.submenu returned %s; keeping the vanilla list", type(hooked)) end self.subItems = items end end end function PartyMenu:draw() love.graphics.setColor(1, 1, 1, 1) love.graphics.rectangle("fill", 0, 0, 160, 144) love.graphics.setColor(0, 0, 0, 1) local party = self.party or self.game.save.party if #party == 0 then Font.draw("No POKéMON!", 16, 64) end local HudTiles = require("src.render.HudTiles") for i, mon in ipairs(party) do local def = self.game.data.pokemon[mon.species] local y = (i - 1) * 16 + 12 love.graphics.setColor(1, 1, 1, 1) drawIcon(self.game, mon, 8, y - 2, i == self.index, self.blink or 0) love.graphics.setColor(0, 0, 0, 1) Font.draw(mon.nickname or def.name, 24, y) -- level at column 13 ( tile + digits, PrintLevel) AND the -- status/FNT text at column 17 (PrintStatusCondition), like the -- original rows -- statused mons keep their level display if mon.level < 100 then HudTiles.tile(0x6E, 104, y) -- Font.draw(tostring(mon.level), 112, y) else -- PrintLevel overwrites the tile with the third digit Font.draw(tostring(mon.level), 104, y) end if mon.hp <= 0 then Font.draw("FNT", 136, y) elseif mon.status then Font.draw(mon.status, 136, y) end -- the colored tile HP bar (DrawHP2 + SetPartyMenuHPBarColor) love.graphics.setColor(1, 1, 1, 1) HudTiles.drawHPBar(self.game.data, 5, (y + 8) / 8, mon) love.graphics.setColor(0, 0, 0, 1) Font.draw(("%3d/%3d"):format(mon.hp, mon.stats.hp), 104, y + 8) if i == self.index then Font.drawCode(Theme.cursor, 0, y) end if i == self.swapFrom or i == self.softboiledFrom then Font.drawCode(Theme.cursorHollow, 0, y) -- the unfilled swap arrow end end if self.swapFrom then Font.draw("Move to where?", 8, 136) elseif self.softboiledFrom then Font.draw("Use on which one?", 8, 136) elseif self.pickOnly then Font.draw("Use on which one?", 8, 136) end if self.submenu then local n = #self.subItems Font.drawBox(9, 17 - n * 2 - 1, 11, n * 2 + 1) local y0 = (17 - n * 2) * 8 for si, entry in ipairs(self.subItems) do Font.draw(entry.label, 88, y0 + (si - 1) * 16) end Font.drawCode(Theme.cursor, 80, y0 + (self.subIndex - 1) * 16) end love.graphics.setColor(1, 1, 1, 1) end return PartyMenu