-- Party menu: list the party, choose a member. -- Modes: -- default: A -> submenu (STATS / SWITCH / field moves) -- opts.onSwitch + opts.battle (voluntary PKMN): A -> SWITCH / STATS / -- CANCEL (core.asm PartyMenuOrRockOrRun), then onSwitch on SWITCH -- opts.onSwitch + opts.forceSwitch: A -> onSwitch immediately -- (ChooseNextMon / SHIFT free-switch) -- opts.pickOnly + opts.onSwitch: A -> onSwitch (item / script target) -- opts.onCancel: fired when the menu closes without a pick (B) -- Pops itself on B. local Assets = require("src.render.Assets") 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 FieldDefaults = require("src.world.FieldDefaults") local Map = require("src.world.Map") local Strings = require("src.core.Strings") local PartyMenu = {} PartyMenu.__index = PartyMenu PartyMenu.isOpaque = true -- SGB (SetPal_PartyMenu, engine/gfx/palettes.asm:90): the party screen is -- NOT a one-palette screen. data/sgb/sgb_packets.asm BlkPacket_PartyMenu -- splits it into MEWMON over the mon-icon column with GREENBAR everywhere -- else, plus one block per HP bar row whose palette -- UpdatePartyMenuBlkPacket (engine/gfx/palettes.asm:299-325) sets from that -- mon's GetHealthBarColor -- pal 1 GREENBAR / 2 YELLOWBAR / 3 REDBAR -- (PalPacket_PartyMenu, sgb_packets.asm:219). Handing the whole screen -- MEWMON instead painted every bar with MEWMON's shades, which is why a -- full bar came out black and a low one purple (#274, absorbing #272). -- -- Two rects differ from the packet's, both because this port draws pixels -- where the hardware drew OAM over BG: -- * the icon block is rows 0-11, not the packet's 0-12 -- row 12 is the -- message box's top edge, which on hardware was BG under an OBJ-free -- part of the block; here it would take MEWMON instead of the base. -- * the bar blocks sit one tile right of the packet's 05-11 because this -- port's bar starts at tile 5 where party_menu.asm:71-76 starts it at -- 4; the span is the same "left cap + six fill tiles". function PartyMenu:sgbPalettes(game) local P = require("src.render.PaletteFX") local base = P.pal(game.data, "GREENBAR") if not base then return nil end local zones = { P.whole(base) } local mew = P.pal(game.data, "MEWMON") if mew then zones[#zones + 1] = P.zone(mew, 1, 0, 2, 11) end -- the TM/HM list prints ABLE / NOT ABLE where the bar would be, so those -- rows have no bar to color (party_menu.asm .teachMoveMenu; #210) if not self.tmhm then local party = self.party or (game.save and game.save.party) or {} for i, mon in ipairs(party) do -- While a medicine's bar fill runs the block palette is STALE, not -- recomputed: SetPartyMenuHPBarColor (party_menu.asm:80/295) is only -- reached from the party-menu redraw loop, never from hp_bar.asm, so -- UpdateHPBar2 lengthens the bar under the PRE-heal color and -- RedrawPartyMenu snaps it green when the message prints. Hold the -- starting HP here for exactly that window (#252). local hp = mon.hp if self.heal and self.heal.mon == mon then hp = self.heal.from end local bar = P.pal(game.data, P.barPalName(hp, mon.stats.hp)) if bar then zones[#zones + 1] = P.zone(bar, 6, i * 2 - 1, 12, i * 2 - 1) end end end return zones 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. Only the frame's LEFT half -- ever reaches the screen -- see PartyMenu.mirrorsIcon (#276) -- which -- is why a walk frame does not look like a walk frame here. 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 PIKACHU = { rest = 0, alt = 3 }, -- Yellow: PikachuSprite tile 0 <-> 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 -- HELIX is the one icon WriteMonPartySpriteOAM sends down the asymmetric -- path (engine/gfx/mon_icons.asm:246 `cp ICON_HELIX << 2 / jr z, .helix`); -- every other built-in icon is drawn as a mirrored left half (see -- drawIcon). A mod that supplies its own image instead of a built-in icon -- name has no vanilla counterpart, so its art draws whole. #276 function PartyMenu.mirrorsIcon(name) return name ~= nil and name ~= "HELIX" end local iconImages = {} -- Party icons are OBJs (engine/gfx/mon_icons.asm WriteMonPartySpriteOAM -- writes OAM blocks), so they render through OBP0, and GBPalNormal -- (home/palettes.asm:20-26 `ld a, %11010000 ; 3100 / ldh [rOBP0], a`) -- holds OBP0 at "3100": OBJ color 1 shows as shade 0, color 2 as shade 1, -- color 3 as shade 3. An object never displays shade 2. This canvas has -- no OBJ layer, so bake that map into the icon art once per path (the same -- CPU-remap trick as SpriteRenderer.getObpImage, and the same "#obp" cache -- key convention) and let the screen's SGB zone color the result. Without -- it every color-2 pixel took the zone palette's shade-2 color -- the -- ADVANCED pack's MEWMON purple {115,33,165}, i.e. the "weirdly colored" -- party sprites of #274. local function obpIcon(path) if not (love.image and love.image.newImageData) then return love.graphics.newImage(Assets.resolve(path)) -- headless stub end local id = Assets.imageData(path) id:mapPixel(function(_, _, r, _, _, a) -- the extracted art is the four DMG grays, keyed off the red channel -- exactly the way PaletteFX's shade-remap shader keys them local v = 0 if r > 0.5 then v = 1 -- OBJ colors 0 and 1 -> shade 0 elseif r > 0.17 then v = 170 / 255 -- OBJ color 2 -> shade 1 end -- OBJ color 3 -> shade 3 return v, v, v, a end) return love.graphics.newImage(id) end 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 path = require("src.pokemon.Sprites").iconPath(game.data, mon, path, { name = name }) if not path then return end -- Built-in icon classes are DMG 2bpp OBJ art and get the OBP0 bake; a -- mod's own image (an entry table rather than an icon name) is authored -- art with no hardware counterpart, so it loads untouched -- the same -- split PartyMenu.mirrorsIcon makes for the OAM mirror. Both live in one -- cache under different keys, so a mod pointing a table entry at a -- built-in path still gets its unbaked copy. #274 local key = name and (path .. "#obp") or path if iconImages[key] == nil then -- resolve through Assets so an overrides/ or transform-derived icon -- (e.g. a per-species image at assets/generated/icons/.png) is -- picked up the same way battle sprites are local ok, img if name then ok, img = pcall(obpIcon, path) else ok, img = pcall(love.graphics.newImage, Assets.resolve(path)) end iconImages[key] = ok and img or false end local img = iconImages[key] 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() -- a 16x16 sheet (BALL, HELIX) is its own only frame local frame = ih > 16 and PartyMenu.frameFor(name, alt, ih) or 0 if PartyMenu.mirrorsIcon(name) then -- WriteSymmetricMonPartySpriteOAM (engine/items/town_map.asm:494-534) -- lays each icon out as 2x2 OAM blocks that use only the frame's LEFT -- column of tiles (base+0, base+2): the inner loop writes the same -- wOAMBaseTile twice with the attributes alternating 0 / OAM_XFLIP and -- only then bumps the tile by 2, because "all the sprites other than -- the helix one have a vertical line of symmetry". MON / FAIRY / BIRD -- reuse overworld sheets whose walk-down frame is NOT symmetric, so -- drawing the raw 16x16 showed a tucked-back foot the hardware never -- displays (#276, absorbing #238). local half = love.graphics.newQuad(0, frame * 16, 8, 16, iw, ih) love.graphics.draw(img, half, x, y) -- sx = -1 about the block's right edge, so the flipped copy lands on -- x+8..x+16: the OAM_XFLIP half love.graphics.draw(img, half, x + 16, y, 0, -1, 1) elseif ih > 16 then love.graphics.draw(img, love.graphics.newQuad(0, frame * 16, 16, 16, iw, ih), x, y) else -- HELIX and any mod art that is a single frame: drawn whole, at -- whatever size the file is (unchanged path) 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 -- Medicine keeps the picker on screen: item_effects.asm .doneHealing -- animates the party HP bar and then prints the message through -- RedrawPartyMenu with the menu STILL up, so BagMenu asks for keepOpen and -- calls :close() itself once the message is done (#252). self.keepOpen = opts.keepOpen -- TM/HM teaching: opts.tmhm = { move, kind } switches the list to Gen 1's -- TM/HM display (ABLE / NOT ABLE per mon instead of the HP bar, and the -- "Use TM on which POKeMON?" prompt). Set by BagMenu.pickTargetAndUse. #210 self.tmhm = opts.tmhm self.forceSwitch = opts.forceSwitch 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 -- UpdateHPBar2 (engine/gfx/hp_bar.asm, predef'd from item_effects.asm's -- .doneHealing): UpdateHPBar_AnimateHPBar is documented "for (a) ticks (two -- waiting frames each)" over a 48-pixel bar, so the shown HP walks -- maxHP/96 per frame -- the same rate the battle HUD drains at -- (BattleState:stepHPDrain). onDone fires on the frame it lands, which is -- when the caller prints its message. #252 function PartyMenu:animateTo(mon, fromHP, onDone) if not (mon and mon.stats) then if onDone then onDone() end return end local from = math.max(0, fromHP or mon.hp) -- `from` outlives `shown`: sgbPalettes above needs the pre-heal HP for the -- whole fill, because the SGB bar color does not move until the redraw. self.heal = { mon = mon, from = from, shown = from, onDone = onDone } end -- Close a picker the caller kept open (see self.keepOpen). A TextBox pops -- itself BEFORE it fires onDone (src/render/TextBox.lua), so this menu is -- the top state by then; the identity check makes it a no-op for the pickers -- that already popped themselves, and stops a double close eating the bag -- underneath. #252 function PartyMenu:close() if self.game.stack:top() == self then self.game.stack:pop() end end function PartyMenu:update(dt) -- icon animation counter; 320 = a whole cycle at every HP speed self.blink = ((self.blink or 0) + 1) % 320 -- The bar fill owns the menu while it runs: UpdateHPBar2 is a blocking -- predef in item_effects.asm, so no button is read until it lands (#252). local heal = self.heal if heal then heal.shown = math.min(heal.mon.hp, heal.shown + math.max(1, heal.mon.stats.hp) / 96) if heal.shown >= heal.mon.hp then self.heal = nil if heal.onDone then heal.onDone() end end return end local input = self.game.input local party = self.party or self.game.save.party -- HandlePartyMenuInput (home/pokemon.asm) and the field-move submenu -- (engine/menus/start_sub_menus.asm .chosePokemon) both run through -- HandleMenuInput_, which beeps SFX_PRESS_AB on any A or B press (#570). -- game.data is nil under the stub games the UI harnesses drive. if self.game.data and (input:wasPressed("a") or input:wasPressed("b")) then require("src.core.Sound").play(self.game.data, "Press_AB") end 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 -- battle and field alike return to the party list afterwards -- (core.asm .partyMenuWasSelected) Screens.push(self.game, "SummaryMenu", mon) elseif action == "battle_switch" then self.game.stack:pop() self.onSwitch(mon) return elseif action == "cancel" then self.game.stack:pop() if self.onCancel then self.onCancel() end return elseif action == "switch" then self.swapFrom = self.index elseif action == "fly" then -- FLY opens the TOWN MAP with a cursor over the visited fly towns, -- not a plain text list (engine/menus/town_map.asm LoadTownMap_Fly). -- flyTo (OverworldController) validates the fly-warp + runs the -- departure/warp, so we just hand it the chosen mapId (#195). local ow = self.game.overworld self.game.stack:pop() -- close the party menu Screens.push(self.game, "TownMap", { fly = true, onFly = function(mapId) if ow then ow:flyTo(mapId) end end }) return elseif action == "flash" then -- FLASH lights dark tunnels -- start_sub_menus.asm .flash: PrintText _FlashLightsAreaText runs -- with the party menu still on screen, and only then -- GBPalWhiteOutWithDelay3 + jp .goBackToMap. So the message reads -- over the menu, and the cave is lit when the blink hands the -- screen back, never under the text (#385). local ow = self.game.overworld local TextBox = require("src.render.TextBox") local Transition = require("src.render.Transition") self.game.save.flashLit = true self.game.stack:push(TextBox.new(self.game, self.game.data.text._FlashLightsAreaText or Strings("A blinding FLASH\nlights the area!"), function() self:close() -- setDark, not a bare field write: ADVANCED carries the darkness -- in a baked atlas, so lighting the cave drops every resident map -- and rebakes this one (#383). It runs HERE, before the blink, -- because start_sub_menus.asm .flash clears wMapPalOffset before -- PrintText and blinks last of all: the cave is already lit by the -- time GBPalWhiteOutWithDelay3 runs. Hanging the rebuild off the -- blink's completion instead left that rebuild's whole cost -- -- seconds of per-pixel atlas baking on a phone -- on screen as a -- solid white frame with nothing under it, which reads as a -- lockup (#610). ow:setDark(false) 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 -- UseItem prints _SurfingGotOnText with the party menu still up; -- GBPalWhiteOutWithDelay3 + jp .goBackToMap only follow it, so -- trySurf closes this menu when its text does (#385) local fx, fy = ow.player:facingCell() ow:trySurf(fx, fy, function() self:close() end) 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 Strings("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), and the text prints -- over the still-open menu like every other .loop refusal (#385) self.game.stack:push(TextBox.new(self.game, txt, function() self:close() 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 Strings("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 ow.strengthActive = true local t1 = (self.game.data.text._UsedStrengthText or Strings("{RAM:wNameBuffer} used\nSTRENGTH.")):gsub("{RAM:wNameBuffer}", name) local t2 = (self.game.data.text._CanMoveBouldersText or Strings("{RAM:wNameBuffer} can\nmove boulders.")):gsub("{RAM:wNameBuffer}", name) -- like surf (#320, #385): both texts print with the party menu -- still on screen, and the blink IS the menu closing afterwards, -- not a flashbang on the empty map self.game.stack:push(TextBox.new(self.game, t1, function() self.game.stack:push(TextBox.new(self.game, t2, function() self:close() 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 warp to the last Pokémon Center TOWN (wLastBlackoutMap, -- special_warps.asm escape warp). pokered's .dig/.teleport spin the -- player up (LeaveMapAnim), white/fade out, then land it; this port -- lands OUTSIDE the town PC door like Fly (#196). beginTeleportOut -- centralizes the spin -> fade -> warp so BagMenu's ESCAPE ROPE shares -- the exact departure; the fade + warp fire when the spin ends. local ow = self.game.overworld self.game.stack:pop() if ow then ow:beginTeleportOut() 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, Strings("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, Strings("%s's HP\nwas restored!", 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 and (self.forceSwitch or self.pickOnly or not self.battle) then -- keepOpen callers (HP medicine) need the menu still drawn while the -- bar fills and the message prints, and close it themselves; everyone -- else keeps the old pop-then-call order. Popping first is what made -- a POTION snap the picker shut before the item had even run (#252). if not self.keepOpen then self.game.stack:pop() end self.onSwitch(mon, self) else self.submenu = true self.subIndex = 1 local items local ow = self.game.overworld if self.battle and self.onSwitch then -- SwitchStatsCancelText (core.asm PartyMenuOrRockOrRun) items = { { label = Strings("SWITCH"), action = "battle_switch" }, { label = Strings("STATS"), action = "stats" }, { label = Strings("CANCEL"), action = "cancel" } } else -- STATS/SWITCH plus this mon's field moves (start_sub_menus.asm -- builds the same dynamic list) items = { { label = Strings("STATS"), action = "stats" }, { label = Strings("SWITCH"), action = "switch" } } -- 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 -- FLY/TELEPORT: CheckIfInOutsideMap (OVERWORLD + PLATEAU -- -- Route 23 / Indigo Plateau outdoor), not OVERWORLD alone (#83) local outside = Map.isOutside(ow.map.def, FieldDefaults.field(self.game.data, "outsideTilesets")) for _, mv in ipairs(mon.moves) do if mv.id == "FLY" and outside and self.game.save.inventory.THUNDERBADGE then table.insert(items, { label = Strings("FLY"), action = "fly" }) elseif mv.id == "FLASH" and ow.dark and self.game.save.inventory.BOULDERBADGE then table.insert(items, { label = Strings("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 = Strings("CUT"), action = "cut" }) elseif mv.id == "SURF" and self.game.save.inventory.SOULBADGE then table.insert(items, { label = Strings("SURF"), action = "surf" }) elseif mv.id == "STRENGTH" and self.game.save.inventory.RAINBOWBADGE then table.insert(items, { label = Strings("STRENGTH"), action = "strength" }) elseif mv.id == "SOFTBOILED" then table.insert(items, { label = Strings("SOFTBOILED"), action = "softboiled" }) elseif mv.id == "TELEPORT" and outside then -- TELEPORT works only OUTDOORS (start_sub_menus.asm -- .teleport -> CheckIfInOutsideMap); dark maps don't -- block it table.insert(items, { label = Strings("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 = Strings("DIG"), action = "escape" }) end 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 -- The bottom-of-screen context message for the current menu state -- (pokered engine/menus/party_menu.asm PartyMenuMessage / RedrawPartyMenu_): -- the party menu always prints a message in the bottom text box. With the -- normal message id that is PartyMenuBattleText ("Bring out which POKéMON?") -- when IsInBattle else PartyMenuNormalText ("Choose a POKéMON."); the swap / -- item / TM-HM ids print their own strings, which draw() handles inline. -- Pure (no side effects) so drivers can assert it. #147 function PartyMenu:bottomMessage() if self.swapFrom then return "Move to where?" elseif self.softboiledFrom or self.pickOnly then return "Use on which one?" elseif self.tmhm then return self.game.data.text._PartyMenuUseTMText or Strings("Use TM on which\nPOKéMON?") elseif self.battle then return self.game.data.text._PartyMenuBattleText or Strings("Bring out which\nPOKéMON?") else return self.game.data.text._PartyMenuNormalText or Strings("Choose a POKéMON.") end end -- Name-row pixel Y for party slot i (1-based). -- pokered party_menu.asm RedrawPartyMenu_: hlcoord 3, 0, then each entry -- advances 2*SCREEN_WIDTH (16 px). The bottom message box sits at tile -- row 12 (y=96); slot 6's HP row is therefore at y=88. #262 function PartyMenu.entryY(i) return (i - 1) * 16 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(Strings("No POKéMON!"), 16, 64) end local HudTiles = require("src.render.HudTiles") local PaletteFX = require("src.render.PaletteFX") -- Each bar row carries its own GREENBAR / YELLOWBAR / REDBAR zone (see -- sgbPalettes), so the fill must stay the raw DMG shade-2 gray and let -- the zone color it -- but only when a zone pass will actually run. -- Renderer's blit takes the shader path exactly when the zone list is -- non-empty AND PaletteFX.shader() resolves, which is the same pair of -- conditions tested here; with no shader the canvas blits unshaded and -- drawHPBar's per-pixel tint is the only color the bar can get. #274 local barZoned = PaletteFX.shader() ~= nil and PaletteFX.pal(self.game.data, "GREENBAR") ~= nil for i, mon in ipairs(party) do local def = self.game.data.pokemon[mon.species] local y = PartyMenu.entryY(i) love.graphics.setColor(1, 1, 1, 1) drawIcon(self.game, mon, 8, y, 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 self.tmhm then -- TM/HM teaching menu (engine/menus/party_menu.asm PrintPartyMenu): -- the second row shows the inline "ABLE" / "NOT ABLE" learnability -- strings in place of the HP bar and status, decided by CanLearnTM. -- The learnset scan mirrors ItemEffects.use so the display can never -- disagree with the actual teach. #210 local can = false for _, m in ipairs(def.tmhm or {}) do if m == self.tmhm.move then can = true break end end -- right-aligned so the shorter "ABLE" shares "NOT ABLE"'s right edge if can then Font.draw(Strings("ABLE"), 120, y + 8) else Font.draw(Strings("NOT ABLE"), 88, y + 8) end else if mon.hp <= 0 then Font.draw(Strings("FNT"), 136, y) elseif mon.status then Font.draw(mon.status, 136, y) end -- the tile HP bar (DrawHP2 + SetPartyMenuHPBarColor). grayFill: -- tinting the fill AND running it through the row's zone -- double-applies -- a green fill has red channel 0, so the tint -- zeroes the bar's red and the zone's red-keyed shade shader then -- maps every pixel to color 3, i.e. black. That is the #229 hazard -- HudTiles documents; #274 (with #272) is this screen's instance. -- -- While a medicine's UpdateHPBar2 fill runs, this row draws the HP the -- animation has reached rather than the final value; drawHPBar reads -- only .hp and .stats, so a shim table is enough and the real mon is -- never mutated for display (#252). local shown = mon if self.heal and self.heal.mon == mon then shown = { hp = math.floor(self.heal.shown), stats = mon.stats } end love.graphics.setColor(1, 1, 1, 1) HudTiles.drawHPBar(self.game.data, 5, (y + 8) / 8, shown, nil, barZoned) love.graphics.setColor(0, 0, 0, 1) Font.draw(("%3d/%3d"):format(shown.hp, mon.stats.hp), 104, y + 8) end -- home/pokemon.asm PartyMenuInit seeds wTopMenuItemY/X with 1/0, so the -- cursor sits on the entry's *second* tile row (the level/HP line), -- level with the middle of the two-row icon -- not on the name row that -- entryY returns. Drawing it at y put it a tile too high (#278). local cursorY = y + 8 if i == self.index then Font.drawCode(Theme.cursor, 0, cursorY) end if i == self.swapFrom or i == self.softboiledFrom then Font.drawCode(Theme.cursorHollow, 0, cursorY) -- the unfilled swap arrow end end if self.swapFrom then Font.draw(Strings("Move to where?"), 8, 136) elseif self.softboiledFrom then Font.draw(Strings("Use on which one?"), 8, 136) elseif self.tmhm then -- "Use TM on which\nPOKeMON?" in the standard bottom text box -- (party_menu.asm keeps the message box for the TM/HM menu); box + line -- geometry match TextBox's default (rows 12-17, text on rows 14/16). #210 Font.drawBox(0, 12, 20, 6) love.graphics.setColor(0, 0, 0, 1) local prompt = self.game.data.text._PartyMenuUseTMText or Strings("Use TM on which\nPOKéMON?") local ly = 112 for line in (prompt .. "\n"):gmatch("([^\n]*)\n") do Font.draw(line, 8, ly) ly = ly + 16 end elseif self.pickOnly then Font.draw(Strings("Use on which one?"), 8, 136) else -- default field party menu (StartMenu) and the battle voluntary-switch -- (BattleState:openParty): Gen1 prints PartyMenuNormalText / PartyMenuBattleText -- in the standard bottom text box (party_menu.asm PartyMenuMessage), not -- plain bottom-row text. Box + line geometry match the #210 TM/HM case and -- TextBox's default (rows 12-17, text on rows 14/16). #147 Font.drawBox(0, 12, 20, 6) love.graphics.setColor(0, 0, 0, 1) local ly = 112 for line in (self:bottomMessage() .. "\n"):gmatch("([^\n]*)\n") do Font.draw(line, 8, ly) ly = ly + 16 end 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