mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-15 15:51:17 +02:00
df0be1cba6
The save editor now routes Gold vs RBY through a generation adapter so boxes, items, events, and maps write the right fields. The launcher layout is shuffled a bit, and patch notes can come from the updater, a packed file, or the iOS sidecar.
969 lines
36 KiB
Lua
969 lines
36 KiB
Lua
-- Gen 2 PACK: four pockets instead of Gen 1's one bag.
|
||
--
|
||
-- constants/item_data_constants.asm orders them ITEM, KEY_ITEM, BALL, TM_HM,
|
||
-- and each item's own ItemAttributes row says which pocket it lives in -- so
|
||
-- the flat id->count inventory the engine already keeps is bucketed here at
|
||
-- draw time rather than stored four ways.
|
||
--
|
||
-- Left/right switch pockets, up/down scroll the list, A selects, B closes.
|
||
-- A TM/HM row shows the move it teaches (attributes carry `teaches`), which is
|
||
-- the whole reason the TM pocket is readable at all -- the item names are just
|
||
-- "TM01".."HM07".
|
||
|
||
local Bag = require("src.inventory.Bag")
|
||
local Chrome = require("src.ui.gen2.Chrome")
|
||
local PackGfx = require("src.ui.gen2.PackGfx")
|
||
local Screens = require("src.ui.Screens")
|
||
local Strings = require("src.core.Strings")
|
||
|
||
local PackMenu = {}
|
||
PackMenu.__index = PackMenu
|
||
PackMenu.isOpaque = true
|
||
|
||
-- ItemsPocketMenuHeader (engine/items/pack.asm): menu_coords 7, 1, 19, 11 --
|
||
-- so the list body starts one row and one column inside that box, five rows of
|
||
-- two, with the quantity on each entry's second line.
|
||
local LIST_X = 8
|
||
local LIST_Y = 2
|
||
local LIST_SPACING = 2
|
||
|
||
-- Display order and titles. The cart shows the pocket name in a tab strip
|
||
-- across the top; these are the strings it uses.
|
||
local POCKETS = {
|
||
{ id = "ITEM", label = "ITEMS" },
|
||
{ id = "BALL", label = "POKé BALLS" },
|
||
{ id = "KEY_ITEM", label = "KEY ITEMS" },
|
||
{ id = "TM_HM", label = "TM/HM" },
|
||
}
|
||
|
||
-- Five item rows fit under the tab strip, two lines each.
|
||
local VISIBLE_ROWS = 5
|
||
|
||
-- The item submenu (.ItemBallsKey_LoadSubmenu, engine/items/pack.asm:243).
|
||
-- A on a row does NOT use the item on the cart: it opens a menu whose rows are
|
||
-- picked from the item's own ITEMATTR_PERMISSIONS bits and its field-menu
|
||
-- nibble, and the six headers between MenuHeader_UsableKeyItem and
|
||
-- MenuHeader_HoldableItem are every combination of them:
|
||
--
|
||
-- CAN toss + CAN select + usable USE / GIVE / TOSS / SEL / QUIT
|
||
-- CAN toss + CAN select + NOUSE GIVE / TOSS / SEL / QUIT
|
||
-- CAN toss + cant select + usable USE / GIVE / TOSS / QUIT
|
||
-- CAN toss + cant select + NOUSE GIVE / TOSS / QUIT
|
||
-- cant toss + cant select USE / QUIT
|
||
-- cant toss + CAN select USE / SEL / QUIT
|
||
--
|
||
-- (the labels read backwards against the header names -- _CheckTossableItem
|
||
-- and CheckSelectableItem both answer NON-zero for the item that CANNOT, so
|
||
-- pack.asm's `.tossable` arm is the untossable one.) The TM/HM pocket has a
|
||
-- pair of its own, .MenuHeader1 / .MenuHeader2 at pack.asm:160.
|
||
--
|
||
-- Without this menu a TOSS is unreachable and the PACK is a one-verb screen,
|
||
-- which is what "the pack only offers USE" is.
|
||
local SUBMENU_LABEL = {
|
||
use = "USE", give = "GIVE", toss = "TOSS", sel = "SEL", quit = "QUIT",
|
||
}
|
||
|
||
-- _AskThrowAwayText / _AskQuantityThrowAwayText / _ThrewAwayText
|
||
-- (data/text/common_2.asm), the three lines TossMenu prints in order.
|
||
local TOSS_HOW_MANY = { "Throw away how", "many?" }
|
||
|
||
-- _YouDontHaveAMonText and .AnEggCantHoldAnItemText, GiveItem's two refusals.
|
||
local NO_POKEMON = { "You don't have a", "#MON!" }
|
||
local EGG_CANT_HOLD = { "An EGG can't hold", "an item." }
|
||
|
||
-- The PACK's cursor bytes. Every pocket menu restores its own cursor and
|
||
-- scroll before ScrollingMenu and writes them back after -- `ld a,
|
||
-- [wItemsPocketCursor] / ld [wMenuCursorPosition], a` ... `ld a, [wMenuCursorY]
|
||
-- / ld [wItemsPocketCursor], a` (engine/items/pack.asm:76), and the same pair
|
||
-- for wKeyItemsPocketCursor, wBallsPocketCursor and wTMHMPocketCursor -- while
|
||
-- InitPackBuffers opens the PACK on wLastPocket, which Pack's own exit path
|
||
-- stored. They are WRAM, not save data: they last for the session and must not
|
||
-- survive a reload, and CleanUpBattleRAM is the only thing that clears them
|
||
-- (engine/battle/core.asm:7994, which pointedly leaves the TM/HM pair in place).
|
||
local function cursorStore(game)
|
||
if not game then return nil end
|
||
local mem = game.packCursor
|
||
if not mem then
|
||
mem = { cursor = {}, scroll = {} }
|
||
game.packCursor = mem
|
||
end
|
||
return mem
|
||
end
|
||
|
||
-- OakThisIsntTheTimeText (data/text/common_2.asm), as the three rows it
|
||
-- prints: `text` / `line` / `cont`. On the cart that is a two-row text box
|
||
-- that scrolls once; the PACK's description box here is four rows tall, so all
|
||
-- three fit at once and nothing has to scroll. {PLAYER} is filled in from the
|
||
-- save, the way TextBox fills it everywhere else.
|
||
local OAK_THIS_ISNT_THE_TIME = {
|
||
"OAK: {PLAYER}!",
|
||
"This isn't the",
|
||
"time to use that!",
|
||
}
|
||
|
||
-- RepelUsedEarlierIsStillInEffectText (data/text/common_3.asm): static, and
|
||
-- names REPEL no matter which of the three repel items is the one actually
|
||
-- still ticking down -- the cart never reads the active item back out to
|
||
-- print it.
|
||
local REPEL_STILL_ACTIVE = {
|
||
"The REPEL used",
|
||
"earlier is still",
|
||
"in effect.",
|
||
}
|
||
|
||
function PackMenu:wantsFillScale() return true end
|
||
function PackMenu:drawsWidescreen() return true end
|
||
|
||
-- opts: save, items (items.lua), onChoose(itemId, count), onClose(),
|
||
-- pocket (starting pocket id), world (the overworld a field item acts on;
|
||
-- defaults to game.world, which is where Game2 keeps it),
|
||
-- give (DepositSellPack: the PACK is a CHOOSER, so selecting a row hands the
|
||
-- id back instead of running the item's field effect),
|
||
-- battle (BattlePack rather than the field Pack: a different jumptable, which
|
||
-- dispatches on the item's BATTLE menu nibble and never runs a field effect)
|
||
function PackMenu.new(game, opts)
|
||
opts = opts or {}
|
||
local self = setmetatable({}, PackMenu)
|
||
self.game = game
|
||
self.save = opts.save or (game and game.save)
|
||
self.items = opts.items or (game and game.data and game.data.items)
|
||
self.world = opts.world or (game and game.world)
|
||
self.onChoose = opts.onChoose
|
||
self.onClose = opts.onClose
|
||
-- DepositSellPack rather than the PACK's own UseItem: a chooser must not run
|
||
-- a rod or the ITEMFINDER on the way past (src/ui/gen2/HeldItemMenu.lua).
|
||
self.give = opts.give and true or false
|
||
self.battle = opts.battle and true or false
|
||
self.cursorStore = cursorStore(game)
|
||
self.pocketIndex = 1
|
||
-- wLastPocket, unless the caller names one: DepositSellInitPackBuffers writes
|
||
-- ITEM_POCKET over it, so an explicit pocket still wins.
|
||
local startPocket = opts.pocket
|
||
or (self.cursorStore and self.cursorStore.pocket)
|
||
if startPocket then
|
||
for i, p in ipairs(POCKETS) do
|
||
if p.id == startPocket then self.pocketIndex = i break end
|
||
end
|
||
end
|
||
self:restoreCursor()
|
||
-- The cart's own PACK tiles, when the cache has them.
|
||
self.gfx = PackGfx.new(game and game.data and game.data.gen2MenuGfx)
|
||
self:rebuild()
|
||
return self
|
||
end
|
||
|
||
function PackMenu:pocket()
|
||
return POCKETS[self.pocketIndex]
|
||
end
|
||
|
||
-- The pair of loads each pocket menu runs before ScrollingMenu. rebuild()
|
||
-- clamps the row afterwards, so a pocket that shrank while the PACK was closed
|
||
-- lands on its last entry rather than past it.
|
||
function PackMenu:restoreCursor()
|
||
local mem = self.cursorStore
|
||
local pocketId = self:pocket().id
|
||
self.index = (mem and mem.cursor[pocketId]) or 1
|
||
self.scroll = (mem and mem.scroll[pocketId]) or 0
|
||
end
|
||
|
||
-- And the pair of stores after it, plus Pack's `.done` writing wCurPocket into
|
||
-- wLastPocket.
|
||
function PackMenu:storeCursor()
|
||
local mem = self.cursorStore
|
||
if not mem then return end
|
||
local pocketId = self:pocket().id
|
||
mem.cursor[pocketId] = self.index
|
||
mem.scroll[pocketId] = self.scroll
|
||
mem.pocket = pocketId
|
||
end
|
||
|
||
-- Which pocket an item belongs to. Items imported before attributes existed
|
||
-- have no `pocket`; treat those as general items rather than dropping them,
|
||
-- so an older cache still shows a full bag.
|
||
function PackMenu:pocketOf(itemId)
|
||
local def = self.items and self.items[itemId]
|
||
return (def and def.pocket) or "ITEM"
|
||
end
|
||
|
||
-- The name on the row. An inventory key with no ItemAttributes row behind it
|
||
-- (an older cache, a mod's own item, a driver seeding an id that is not in
|
||
-- items.lua) still has to draw something a person can read, so the id stands
|
||
-- in for the name with its underscores opened out.
|
||
function PackMenu.label(itemId, def)
|
||
if def and def.name then return def.name end
|
||
return (tostring(itemId):gsub("_", " "))
|
||
end
|
||
|
||
-- TMHMPocket (engine/items/tmhm.asm) writes GetMoveName's string under the
|
||
-- TM's own name, so the second line of a TM row is the MOVE's name and not the
|
||
-- constant the attributes row carries.
|
||
function PackMenu:moveLabel(moveId)
|
||
if not moveId then return nil end
|
||
local moves = self.game and self.game.data and self.game.data.moves
|
||
local def = moves and moves[moveId]
|
||
return (def and def.name) or (tostring(moveId):gsub("_", " "))
|
||
end
|
||
|
||
function PackMenu:rebuild()
|
||
local pocket = self:pocket().id
|
||
local rows = {}
|
||
for itemId, raw in pairs((self.save and self.save.inventory) or {}) do
|
||
-- A count that is not a number at all (a hand-written save, a mod, an old
|
||
-- migration) counts as one rather than raising out of the draw.
|
||
local count = tonumber(raw) or (raw and 1) or 0
|
||
if count > 0 and self:pocketOf(itemId) == pocket then
|
||
local def = self.items and self.items[itemId]
|
||
rows[#rows + 1] = {
|
||
id = itemId,
|
||
count = count,
|
||
name = PackMenu.label(itemId, def),
|
||
teaches = self:moveLabel(def and def.teaches),
|
||
tmNumber = def and def.tmNumber,
|
||
-- KEY_ITEM and TM_HM rows do not show a quantity on the cart.
|
||
showCount = pocket == "ITEM" or pocket == "BALL",
|
||
index = def and def.index or math.huge,
|
||
}
|
||
end
|
||
end
|
||
-- Bag order on the cart is acquisition order; without that recorded, item id
|
||
-- order is the stable, reproducible choice.
|
||
table.sort(rows, function(a, b)
|
||
if a.index ~= b.index then return a.index < b.index end
|
||
return a.id < b.id
|
||
end)
|
||
self.rows = rows
|
||
self.index = math.min(self.index, #rows + 1)
|
||
if self.index < 1 then self.index = 1 end
|
||
self:ensureVisible()
|
||
end
|
||
|
||
function PackMenu:total()
|
||
return #self.rows + 1 -- CANCEL
|
||
end
|
||
|
||
function PackMenu:isCancel()
|
||
return self.index > #self.rows
|
||
end
|
||
|
||
function PackMenu:ensureVisible()
|
||
if self.index <= self.scroll then
|
||
self.scroll = self.index - 1
|
||
elseif self.index > self.scroll + VISIBLE_ROWS then
|
||
self.scroll = self.index - VISIBLE_ROWS
|
||
end
|
||
self.scroll = math.max(0, math.min(self.scroll,
|
||
math.max(0, self:total() - VISIBLE_ROWS)))
|
||
end
|
||
|
||
function PackMenu:switchPocket(delta)
|
||
-- The pocket being left keeps its own cursor and scroll; the one being
|
||
-- entered restores its own (pack.asm:76).
|
||
self:storeCursor()
|
||
self.pocketIndex = (self.pocketIndex - 1 + delta) % #POCKETS + 1
|
||
self:restoreCursor()
|
||
self:rebuild()
|
||
self:storeCursor()
|
||
end
|
||
|
||
-- The player name OakThisIsntTheTimeText addresses, same fallback the SAVE
|
||
-- screen uses when a driver runs without a named save.
|
||
function PackMenu:playerName()
|
||
return (self.save and self.save.player and self.save.player.name) or "GOLD"
|
||
end
|
||
|
||
-- .Field (engine/items/pack.asm UseItem): a field-usable item runs its effect,
|
||
-- and only a NON-ZERO wItemEffectSucceeded sets PACKSTATE_QUITRUNSCRIPT --
|
||
-- which quits the PACK, and with it the START menu it was opened from, so the
|
||
-- script the effect queued can run in the overworld. A zero drops into .Oak
|
||
-- instead, which prints inside the PACK and leaves it exactly where it was.
|
||
function PackMenu:exitToField()
|
||
self:storeCursor()
|
||
local stack = self.game and self.game.stack
|
||
if stack and stack.clear then
|
||
stack:clear()
|
||
elseif self.onClose then
|
||
-- No clear on this stack (a test harness, or a screen pushed on its own):
|
||
-- at least give the pack back.
|
||
self.onClose()
|
||
end
|
||
end
|
||
|
||
-- Whether this pack is BattlePack (engine/items/pack.asm:627) rather than the
|
||
-- field Pack. The two are separate jumptables: nothing opened over a battle
|
||
-- may reach a field effect, so the live overworld's own flag counts as well as
|
||
-- the caller saying so.
|
||
function PackMenu:inBattle()
|
||
if self.battle then return true end
|
||
return (self.world and self.world.battleActive) and true or false
|
||
end
|
||
|
||
-- A on a row. A field item the world claims never reaches onChoose: the world
|
||
-- has already run its effect, and all that is left is which of UseItem's two
|
||
-- endings the PACK takes.
|
||
function PackMenu:useSelected()
|
||
local row = self.rows[self.index]
|
||
if not row then return end
|
||
-- ScrollingMenu has returned by the time a row is acted on, so the pocket's
|
||
-- cursor bytes are already written back before the submenu opens.
|
||
self:storeCursor()
|
||
if self.give then
|
||
if self.onChoose then self.onChoose(row.id, row.count) end
|
||
return
|
||
end
|
||
-- BattlePack's .Use dispatches on the item's BATTLE menu nibble, and the
|
||
-- first four entries of its .ItemFunctionJumptable are all .Oak: a battle-
|
||
-- NOUSE item prints OakThisIsntTheTimeText inside the pack and goes nowhere.
|
||
-- Everything else is handed to the screen that opened this one
|
||
-- (src/ui/gen2/BattleState.lua), which owns the balls, the X items and the
|
||
-- party-target heals. The FIELD jumptable is not on this path at all.
|
||
if self:inBattle() then
|
||
local def = self.items and self.items[row.id]
|
||
if def and def.battleMenu == "ITEMMENU_NOUSE" then
|
||
self.message = OAK_THIS_ISNT_THE_TIME
|
||
return
|
||
end
|
||
if self.onChoose then
|
||
self.staleRows = true
|
||
self.onChoose(row.id, row.count)
|
||
end
|
||
return
|
||
end
|
||
local world = self.world
|
||
local result = world and world.useFieldItem and world:useFieldItem(row.id)
|
||
if result then
|
||
if result == "nowhere" then
|
||
self.message = OAK_THIS_ISNT_THE_TIME
|
||
elseif result == "repel_used" then
|
||
-- ItemUsedText (data/text/common_3.asm): "<PLAYER> used the\n<ITEM>."
|
||
-- World already wrote the counter and took the item out of the bag, so
|
||
-- the row list is rebuilt under the message the way a TOSS would.
|
||
self.message = { Strings("{PLAYER} used the"), row.name .. "." }
|
||
self:rebuild()
|
||
elseif result == "repel_active" then
|
||
self.message = REPEL_STILL_ACTIVE
|
||
elseif result == "trophy_sent" then
|
||
-- _SentTrophyHomeText (data/text/common_3.asm). Two pages on the cart
|
||
-- with sound_dex_fanfare_50_79 between them; the PACK's box here holds
|
||
-- all four rows at once, the way OAK_THIS_ISNT_THE_TIME's three fit.
|
||
-- World has already set the decoration's flag and taken the box.
|
||
self.message = { "There was a trophy", "inside!",
|
||
"{PLAYER} sent the", "trophy home." }
|
||
self:rebuild()
|
||
else
|
||
self:exitToField()
|
||
end
|
||
return
|
||
end
|
||
-- UseItem's FIELD-pack tail (engine/items/tmhm.asm:73): a TM/HM row opens
|
||
-- the party to teach, and a field-NOUSE item -- an X ATTACK or a POKé DOLL
|
||
-- used from the field PACK -- prints OakThisIsntTheTimeText and goes
|
||
-- nowhere (UseItem's jumptable's first four entries are all .Oak). Both
|
||
-- checks live behind `world.useFieldItem` on purpose: DepositSellPack (the
|
||
-- mart's SELL, the item PC's DEPOSIT) is a chooser whose jumptable is four
|
||
-- ScrollingMenus and never reaches tmhm.asm, so a TM picked there hands
|
||
-- its row to onChoose like every other item instead of opening the teach
|
||
-- party. The battle pack returned above, and the catch tutorial's DUDE
|
||
-- pack carries a stub world with no useFieldItem at all -- its POKE BALL
|
||
-- is field-NOUSE and must still reach the throw.
|
||
if world and world.useFieldItem then
|
||
local def = self.items and self.items[row.id]
|
||
if def and def.teaches then
|
||
self:openTeachParty(row)
|
||
return
|
||
end
|
||
if def and def.fieldMenu == "ITEMMENU_NOUSE" then
|
||
self.message = OAK_THIS_ISNT_THE_TIME
|
||
return
|
||
end
|
||
end
|
||
if self.onChoose then
|
||
-- The .Party flow runs OVER this pack and UseDisposableItem spends the
|
||
-- item out from under the row list; rebuild on the first frame the pack
|
||
-- owns again, which is UseItem .Party's own Pack_InitGFX redraw.
|
||
self.staleRows = true
|
||
self.onChoose(row.id, row.count)
|
||
end
|
||
end
|
||
|
||
-- ------------------------------------------------------------- the submenu
|
||
|
||
-- Whether A on a row opens the item submenu. Three packs on the cart skip it
|
||
-- and hand their row straight back, and all three are here:
|
||
--
|
||
-- DepositSellPack (pack.asm:931) -- the mart's SELL, the item PC's DEPOSIT
|
||
-- and HeldItemMenu's GIVE. Its jumptable is four ScrollingMenus and
|
||
-- nothing else, which is why `give` and the empty-world callers answer
|
||
-- their chooser directly.
|
||
-- TutorialPack (pack.asm:1068) -- the DUDE's pack, same shape.
|
||
-- BattlePack (pack.asm:627) -- this one DOES have a submenu on the cart
|
||
-- (ItemSubmenu, USE / QUIT or QUIT alone), but it can neither toss, give
|
||
-- nor register, so the row it would add over this port's direct dispatch
|
||
-- is a second A press on the way to the same item effect. The field
|
||
-- PACK is the one this bug is about; see src/ui/gen2/BattleState.lua for
|
||
-- the battle side.
|
||
--
|
||
-- The test is the world rather than a flag because that is what already tells
|
||
-- a field PACK from a chooser here: MartMenu:enterSell and
|
||
-- ItemPcMenu:enterDeposit both pass `world = {}` precisely so no field effect
|
||
-- can fire, and Game2's START-menu PACK passes the real overworld.
|
||
function PackMenu:hasSubmenu()
|
||
if self.give then return false end
|
||
if self:inBattle() then return false end
|
||
local world = self.world
|
||
return (world and world.useFieldItem) and true or false
|
||
end
|
||
|
||
-- ITEMATTR_PERMISSIONS' two bits and the field-menu nibble, as the cart reads
|
||
-- them. An id with no attributes row at all (an older cache, a mod's item)
|
||
-- counts as tossable and unusable-for-SEL, which is the same lean the sell
|
||
-- gate and ItemPcMenu:cantToss take.
|
||
function PackMenu:submenuRows(itemId)
|
||
local def = self.items and self.items[itemId]
|
||
local canToss = not (def and def.canToss == false)
|
||
local canSelect = def ~= nil and def.canSelect == true
|
||
local usable = not (def and def.fieldMenu == "ITEMMENU_NOUSE")
|
||
local rows = {}
|
||
local function add(id) rows[#rows + 1] = id end
|
||
if self:pocket().id == "TM_HM" then
|
||
-- .TMHMPocketMenu's own pair: an HM cannot be tossed and gets USE / QUIT,
|
||
-- a TM gets USE / GIVE / QUIT. Neither has a TOSS row.
|
||
add("use")
|
||
if canToss then add("give") end
|
||
add("quit")
|
||
return rows
|
||
end
|
||
if not canToss then
|
||
-- MenuHeader_UnusableItem / MenuHeader_UnusableKeyItem: the untossable arm
|
||
-- never looks at the menu nibble, so a key item always offers USE.
|
||
add("use")
|
||
if canSelect then add("sel") end
|
||
add("quit")
|
||
return rows
|
||
end
|
||
if usable then add("use") end
|
||
add("give")
|
||
add("toss")
|
||
if canSelect then add("sel") end
|
||
add("quit")
|
||
return rows
|
||
end
|
||
|
||
function PackMenu:openSubmenu()
|
||
local row = self.rows[self.index]
|
||
if not row then return end
|
||
-- ScrollingMenu has returned by the time the submenu opens, so the pocket's
|
||
-- cursor bytes are written back first (pack.asm:76).
|
||
self:storeCursor()
|
||
self.submenu = {
|
||
row = row,
|
||
rows = self:submenuRows(row.id),
|
||
index = 1, -- `db 1 ; default option`
|
||
}
|
||
end
|
||
|
||
function PackMenu:closeSubmenu()
|
||
self.submenu = nil
|
||
end
|
||
|
||
function PackMenu:chooseSubmenu()
|
||
local menu = self.submenu
|
||
if not menu then return end
|
||
local id = menu.rows[menu.index]
|
||
local row = menu.row
|
||
if id == "quit" then
|
||
-- QuitItemSubmenu: a bare `ret`, back to the pocket list.
|
||
self:closeSubmenu()
|
||
elseif id == "use" then
|
||
self:closeSubmenu()
|
||
self:useSelected()
|
||
elseif id == "sel" then
|
||
self:closeSubmenu()
|
||
self:registerSelected()
|
||
elseif id == "toss" then
|
||
self:closeSubmenu()
|
||
self:tossItem(row)
|
||
elseif id == "give" then
|
||
self:closeSubmenu()
|
||
self:giveItem(row)
|
||
end
|
||
end
|
||
|
||
-- ------------------------------------------------------------------- TOSS
|
||
|
||
-- BuySellToss_InterpretJoypad (engine/items/buy_sell_toss.asm): up and down
|
||
-- wrap through the ends, left and right step by ten and clamp. The same
|
||
-- stepper src/ui/gen2/ItemPcMenu.lua uses, because it is the same loop.
|
||
local function qtyStep(qty, max, delta)
|
||
local n = qty + delta
|
||
if delta == 1 then
|
||
if n > max then n = 1 end
|
||
elseif delta == -1 then
|
||
if n < 1 then n = max end
|
||
elseif delta > 0 then
|
||
if n > max then n = max end
|
||
else
|
||
if n <= 0 then n = 1 end
|
||
end
|
||
return n
|
||
end
|
||
|
||
-- TossMenu (engine/items/pack.asm:477): "Throw away how many?" over
|
||
-- SelectQuantityToToss, then the count in a yes/no, then TossItem and
|
||
-- "Threw away <ITEM>(S)." Backing out of either question is `jr c, .finish`
|
||
-- -- the item is untouched and the PACK is exactly where it was.
|
||
function PackMenu:tossItem(row)
|
||
if not row then return end
|
||
self.message = TOSS_HOW_MANY
|
||
self.qtyState = {
|
||
row = row,
|
||
qty = 1,
|
||
max = row.count or 1,
|
||
}
|
||
end
|
||
|
||
function PackMenu:confirmToss()
|
||
local state = self.qtyState
|
||
if not state then return end
|
||
self.qtyState = nil
|
||
local row, qty = state.row, state.qty
|
||
self.confirm = {
|
||
prompt = { ("Throw away %d"):format(qty), row.name .. "(S)?" },
|
||
-- YesNoBox opens on YES; B and NO are the same `jr c, .finish`.
|
||
choice = 1,
|
||
onYes = function()
|
||
Bag.remove(self.save, row.id, qty)
|
||
self:rebuild()
|
||
self.message = { "Threw away", row.name .. "(S)." }
|
||
end,
|
||
}
|
||
end
|
||
|
||
-- ------------------------------------------------------------------- GIVE
|
||
|
||
-- GiveItem (engine/items/pack.asm:562): the party list under
|
||
-- PARTYMENUACTION_GIVE_ITEM ("To which <PK><MN>?"), an EGG refused with
|
||
-- .AnEggCantHoldAnItemText, and everything else handed to
|
||
-- TryGiveItemToPartymon -- which is exactly what the party's own GIVE row runs
|
||
-- (src/ui/gen2/HeldItemMenu.lua), so the two doors share one routine rather
|
||
-- than each growing a copy of the swap question and the mail keyboard.
|
||
function PackMenu:giveItem(row)
|
||
local game = self.game
|
||
local party = (self.save and self.save.party) or {}
|
||
if #party == 0 then
|
||
self.message = NO_POKEMON
|
||
return
|
||
end
|
||
if not (game and game.stack) then return end
|
||
if not pcall(Screens.get, game, "Gen2PartyMenu") then return end
|
||
Screens.push(game, "Gen2PartyMenu", {
|
||
save = self.save,
|
||
prompt = "toWhich",
|
||
onChoose = function(slot) self:giveToSlot(slot, row) end,
|
||
onCancel = function()
|
||
-- `.finish` / PartyMenuSelect's carry: back to the PACK.
|
||
game.stack:pop()
|
||
self:rebuild()
|
||
end,
|
||
})
|
||
end
|
||
|
||
function PackMenu:giveToSlot(slot, row)
|
||
local game = self.game
|
||
local mon = self.save and self.save.party and self.save.party[slot]
|
||
if not (mon and game and game.stack) then return end
|
||
if not pcall(Screens.get, game, "Gen2HeldItemMenu") then return end
|
||
-- The GIVE/TAKE menu's own machinery, opened past its two rows: it already
|
||
-- owns the text box over the party list, the swap question and the mail
|
||
-- keyboard, and this is the same TryGiveItemToPartymon call its GIVE row
|
||
-- makes.
|
||
local held = Screens.build(game, "Gen2HeldItemMenu", {
|
||
save = self.save,
|
||
slot = slot,
|
||
items = self.items,
|
||
onClose = function()
|
||
game.stack:pop()
|
||
self:rebuild()
|
||
end,
|
||
})
|
||
game.stack:push(held)
|
||
if mon.isEgg then
|
||
-- `cp EGG / jr nz, .give`: the refusal prints over the party list, which
|
||
-- stays up (`jr .loop`) for another pick.
|
||
held:say({ EGG_CANT_HOLD }, function() game.stack:pop() end)
|
||
return
|
||
end
|
||
held:giveItem(row.id)
|
||
end
|
||
|
||
-- engine/items/tmhm.asm:73
|
||
function PackMenu:openTeachParty(row)
|
||
local game = self.game
|
||
local party = (self.save and self.save.party) or {}
|
||
if #party == 0 then
|
||
self.message = NO_POKEMON
|
||
return
|
||
end
|
||
if not (game and game.stack) then return end
|
||
if not pcall(Screens.get, game, "Gen2PartyMenu") then return end
|
||
local def = self.items and self.items[row.id]
|
||
local moveId = def and def.teaches
|
||
local moves = game.data and game.data.moves
|
||
local moveDef = moves and moves[moveId]
|
||
local moveName = (moveDef and moveDef.name) or moveId
|
||
self.staleRows = true
|
||
Screens.push(game, "Gen2PartyMenu", {
|
||
save = self.save,
|
||
prompt = "teach",
|
||
tmhm = { move = moveId },
|
||
onCancel = function()
|
||
game.stack:pop()
|
||
self:rebuild()
|
||
end,
|
||
onChoose = function(_slot, mon)
|
||
game.stack:pop()
|
||
local species = game.data and game.data.pokemon
|
||
and game.data.pokemon[mon.species]
|
||
local allowed = false
|
||
for _, id in ipairs((species and species.tmhm) or {}) do
|
||
if id == moveId then allowed = true end
|
||
end
|
||
if not allowed then
|
||
if game.say then
|
||
game:say(("%s can't learn %s!"):format(
|
||
require("src.battle.gen2.Mon").displayName(mon), moveName))
|
||
end
|
||
return
|
||
end
|
||
for _, move in ipairs(mon.moves or {}) do
|
||
if move.id == moveId then
|
||
if game.say then
|
||
game:say(("%s already knows %s!"):format(
|
||
require("src.battle.gen2.Mon").displayName(mon), moveName))
|
||
end
|
||
return
|
||
end
|
||
end
|
||
if not game.learnMoveOn then return end
|
||
game:learnMoveOn(mon, moveId, function(learned)
|
||
if not learned then return end
|
||
if tostring(row.id):sub(1, 3) == "HM_" then return end
|
||
require("src.core.gen2.Happiness").change(mon, "LEARNMOVE")
|
||
if game.consumeItem then game:consumeItem(row.id) end
|
||
self:rebuild()
|
||
end)
|
||
end,
|
||
})
|
||
end
|
||
|
||
function PackMenu:update(_dt)
|
||
local input = self.game and self.game.input
|
||
if not input then return end
|
||
if self.staleRows then
|
||
self.staleRows = nil
|
||
self:rebuild()
|
||
end
|
||
-- Pack_PrintTextNoScroll ends on a `prompt`, so the message holds the PACK
|
||
-- until a button clears it and the list is untouchable underneath. The
|
||
-- quantity selector is the one thing drawn OVER a message rather than under
|
||
-- it: "Throw away how many?" is printed and SelectQuantityToToss runs on top
|
||
-- of it, so that pair is stepped before the message is cleared.
|
||
if self.qtyState then
|
||
self:updateQuantity(input)
|
||
return
|
||
end
|
||
if self.message then
|
||
if input:wasPressed("a") or input:wasPressed("b") then
|
||
self.message = nil
|
||
end
|
||
return
|
||
end
|
||
if self.confirm then
|
||
self:updateConfirm(input)
|
||
return
|
||
end
|
||
if self.submenu then
|
||
self:updateSubmenu(input)
|
||
return
|
||
end
|
||
if input:wasPressed("left") then
|
||
self:switchPocket(-1)
|
||
return
|
||
elseif input:wasPressed("right") then
|
||
self:switchPocket(1)
|
||
return
|
||
elseif input:wasPressed("up") then
|
||
self.index = self.index > 1 and self.index - 1 or self:total()
|
||
self:ensureVisible()
|
||
return
|
||
elseif input:wasPressed("down") then
|
||
self.index = self.index < self:total() and self.index + 1 or 1
|
||
self:ensureVisible()
|
||
return
|
||
elseif input:wasPressed("b") then
|
||
self:storeCursor()
|
||
if self.onClose then self.onClose() end
|
||
return
|
||
elseif input:wasPressed("a") then
|
||
if self:isCancel() then
|
||
self:storeCursor()
|
||
if self.onClose then self.onClose() end
|
||
elseif self:hasSubmenu() then
|
||
-- Pack_InterpretJoypad's A falls through to .ItemBallsKey_LoadSubmenu:
|
||
-- the row is chosen, not used.
|
||
self:openSubmenu()
|
||
else
|
||
self:useSelected()
|
||
end
|
||
return
|
||
elseif input:wasPressed("select") then
|
||
self:registerSelected()
|
||
return
|
||
end
|
||
end
|
||
|
||
-- VerticalMenu over the submenu rows: up/down wrap, A picks, B is the carry
|
||
-- that ExitMenu answers with (`ret c`), which is QUIT by another name.
|
||
function PackMenu:updateSubmenu(input)
|
||
local menu = self.submenu
|
||
local total = #menu.rows
|
||
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("a") then
|
||
self:chooseSubmenu()
|
||
elseif input:wasPressed("b") then
|
||
self:closeSubmenu()
|
||
end
|
||
end
|
||
|
||
-- Toss_Sell_Loop: the count is stepped until A takes it or B backs out, and
|
||
-- backing out is the whole toss cancelled.
|
||
function PackMenu:updateQuantity(input)
|
||
local state = self.qtyState
|
||
if input:wasPressed("up") then
|
||
state.qty = qtyStep(state.qty, state.max, 1)
|
||
elseif input:wasPressed("down") then
|
||
state.qty = qtyStep(state.qty, state.max, -1)
|
||
elseif input:wasPressed("right") then
|
||
state.qty = qtyStep(state.qty, state.max, 10)
|
||
elseif input:wasPressed("left") then
|
||
state.qty = qtyStep(state.qty, state.max, -10)
|
||
elseif input:wasPressed("a") then
|
||
self.message = nil
|
||
self:confirmToss()
|
||
elseif input:wasPressed("b") then
|
||
self.qtyState = nil
|
||
self.message = nil
|
||
end
|
||
end
|
||
|
||
-- YesNoBox: up/down flip, A takes the highlighted row, B is NO.
|
||
function PackMenu:updateConfirm(input)
|
||
local confirm = self.confirm
|
||
if input:wasPressed("up") or input:wasPressed("down") then
|
||
confirm.choice = confirm.choice == 1 and 2 or 1
|
||
elseif input:wasPressed("b") then
|
||
self.confirm = nil
|
||
if confirm.onNo then confirm.onNo() end
|
||
elseif input:wasPressed("a") then
|
||
local yes = confirm.choice == 1
|
||
self.confirm = nil
|
||
if yes then
|
||
if confirm.onYes then confirm.onYes() end
|
||
elseif confirm.onNo then
|
||
confirm.onNo()
|
||
end
|
||
end
|
||
end
|
||
|
||
-- RegisterItem (engine/items/pack.asm), the submenu's SEL row. SELECT on the
|
||
-- highlighted row reaches the same routine: the cart's SELECT is the bag's own
|
||
-- item shuffle, which this port does not have, so the button is free and a
|
||
-- player who knows Gen 1's registration shortcut gets it. World:registerItem
|
||
-- re-runs CheckSelectableItem's gate (TM/HM and anything CANT_SELECT_F
|
||
-- refuses), so neither door can register what the cart would not.
|
||
function PackMenu:registerSelected()
|
||
if self:isCancel() then return end
|
||
local row = self.rows[self.index]
|
||
if not row then return end
|
||
-- BattlePack shares Pack_InterpretJoypad, whose SELECT arm is the bag's own
|
||
-- item shuffle rather than the field pack's item submenu, so nothing over a
|
||
-- battle registers anything.
|
||
local world = not self:inBattle() and self.world or nil
|
||
local ok = world and world.registerItem and world:registerItem(row.id)
|
||
if ok then
|
||
-- RegisteredItemText: "Registered the\n<item>."
|
||
self.message = { Strings("Registered the"), row.name .. "." }
|
||
else
|
||
-- CantRegisterText: "You can't register\nthat item."
|
||
self.message = { Strings("You can't register"), Strings("that item.") }
|
||
end
|
||
end
|
||
|
||
-- A TM or HM's `move` is what it teaches; the extractor carries it on the item
|
||
-- record, and moves.lua carries that move's own description.
|
||
function PackMenu:moveOf(itemId)
|
||
local def = itemId and self.items and self.items[itemId]
|
||
-- The extractor calls it `teaches`.
|
||
return def and def.teaches or nil
|
||
end
|
||
|
||
-- The description under the list. A TM shows the MOVE's description rather
|
||
-- than the item's -- which is what the cart's TM pocket does, and the whole
|
||
-- reason move descriptions are worth extracting.
|
||
function PackMenu:description()
|
||
if self:isCancel() then return nil end
|
||
local row = self.rows[self.index]
|
||
if not row then return nil end
|
||
local moveId = self:moveOf(row.id)
|
||
if moveId then
|
||
local moves = self.game and self.game.data and self.game.data.moves
|
||
local moveDef = moves and moves[moveId]
|
||
if moveDef and moveDef.description then return moveDef.description end
|
||
end
|
||
local def = self.items and self.items[row.id]
|
||
return def and def.description or nil
|
||
end
|
||
|
||
-- The list, description and cursor, on top of whatever chrome was drawn.
|
||
--
|
||
-- PlaceMenuItemQuantity (engine/menus/menu_2.asm) writes the ×N one row DOWN
|
||
-- and one column RIGHT of the name -- the quantity is the entry's second line,
|
||
-- not a right-aligned column, which is why every PACK row is two tiles tall.
|
||
function PackMenu:drawList(listX, listY)
|
||
for row = 1, VISIBLE_ROWS do
|
||
local i = row + self.scroll
|
||
local ty = listY + (row - 1) * LIST_SPACING
|
||
if i <= #self.rows then
|
||
local entry = self.rows[i]
|
||
if i == self.index then Chrome.cursor(listX - 1, ty) end
|
||
Chrome.print(entry.name, listX, ty)
|
||
if entry.teaches then
|
||
-- The TM pocket puts the move the TM teaches on that second line.
|
||
Chrome.print(entry.teaches, listX + 1, ty + 1)
|
||
elseif entry.showCount then
|
||
Chrome.print("\xc3\x97" .. tostring(entry.count), listX + 1, ty + 1)
|
||
end
|
||
elseif i == self:total() then
|
||
if i == self.index then Chrome.cursor(listX - 1, ty) end
|
||
Chrome.print("CANCEL", listX, ty)
|
||
end
|
||
end
|
||
end
|
||
|
||
function PackMenu:drawDescription(ty)
|
||
-- Pack_PrintTextNoScroll writes over the same box the description lives in,
|
||
-- so while a message is up it IS the box's contents. TossMenu's yes/no
|
||
-- (AskQuantityThrowAwayText through MenuTextbox) is the same box: the
|
||
-- question is printed there and the YES/NO window opens over the list.
|
||
local lines = self.message or (self.confirm and self.confirm.prompt)
|
||
if lines then
|
||
local name = self:playerName()
|
||
for i, line in ipairs(lines) do
|
||
Chrome.print((line:gsub("{PLAYER}", name)), 1, ty + i - 2)
|
||
end
|
||
return
|
||
end
|
||
local description = self:description()
|
||
if not description then return end
|
||
-- Item descriptions join their two lines with the '<NEXT>' the extractor
|
||
-- leaves in place, and $4e steps SCREEN_WIDTH * 2 from the line's own
|
||
-- start (home/text.asm NextLineChar) -- TWO rows, the same metric every
|
||
-- text box uses. PrintItemDescription writes them from decoord 1, 14, so
|
||
-- the second line is row 16. '\n' covers hand-written data.
|
||
local first, second = description:match("^(.-)<NEXT>(.*)$")
|
||
if not first then first, second = description:match("^(.-)\n(.*)$") end
|
||
Chrome.print(first or description, 1, ty)
|
||
if second then Chrome.print(second, 1, ty + 2) end
|
||
end
|
||
|
||
-- The submenu box. Every one of the seven headers is `menu_coords 0, top,
|
||
-- SCREEN_WIDTH - 14, TEXTBOX_Y - 1` -- the left six columns, growing UPWARD
|
||
-- from the description box so its bottom edge never moves. The five-row
|
||
-- header is the one exception, reaching one row further down (TEXTBOX_Y), so
|
||
-- the bottom is 12 there and 11 otherwise; either way the first label sits one
|
||
-- row inside (STATICMENU_NO_TOP_SPACING) with the cursor a column left of it.
|
||
function PackMenu:drawSubmenu()
|
||
local menu = self.submenu
|
||
local count = #menu.rows
|
||
local bottom = count >= 5 and 12 or 11
|
||
local top = bottom - count * 2
|
||
Chrome.box(0, top, 7, bottom - top + 1)
|
||
for i, id in ipairs(menu.rows) do
|
||
local ty = top + 1 + (i - 1) * 2
|
||
if i == menu.index then Chrome.cursor(1, ty) end
|
||
Chrome.print(SUBMENU_LABEL[id] or id, 2, ty)
|
||
end
|
||
end
|
||
|
||
-- TossItem_MenuHeader is `menu_coords 15, 9, SCREEN_WIDTH - 1, TEXTBOX_Y - 1`
|
||
-- with NoPriceToDisplay behind it: a small box in the bottom right holding
|
||
-- nothing but the count.
|
||
function PackMenu:drawQuantity()
|
||
Chrome.box(15, 9, 5, 3)
|
||
Chrome.print("\xc3\x97" .. tostring(self.qtyState.qty), 16, 10)
|
||
end
|
||
|
||
-- YesNoBox's own coords, the same box every other Gen 2 screen here draws.
|
||
function PackMenu:drawYesNo()
|
||
Chrome.box(14, 7, 6, 5)
|
||
Chrome.print("YES", 16, 8)
|
||
Chrome.print("NO", 16, 10)
|
||
Chrome.cursor(15, self.confirm.choice == 1 and 8 or 10)
|
||
end
|
||
|
||
function PackMenu:drawOverlays()
|
||
if self.submenu then self:drawSubmenu() end
|
||
if self.qtyState then self:drawQuantity() end
|
||
if self.confirm then self:drawYesNo() end
|
||
end
|
||
|
||
function PackMenu:drawPanel()
|
||
if self.gfx:available() then
|
||
-- Pack_InitGFX's screen: the header strip, the patterned left column, the
|
||
-- bag picture for this pocket and the pocket plaque, then Textbox at
|
||
-- (0,12) for the item description.
|
||
self.gfx:draw(self:pocket().id)
|
||
Chrome.box(0, PackGfx.DESCRIPTION_Y, 20, 6)
|
||
self:drawList(LIST_X, LIST_Y)
|
||
self:drawDescription(PackGfx.DESCRIPTION_Y + 2)
|
||
self:drawOverlays()
|
||
love.graphics.setColor(1, 1, 1, 1)
|
||
return
|
||
end
|
||
|
||
-- No pack tiles in the cache (an import from before the pack stage): plain
|
||
-- boxes, the layout this screen shipped with.
|
||
Chrome.clear()
|
||
Chrome.box(0, 0, 20, 3)
|
||
Chrome.print(self:pocket().label, 2, 1)
|
||
Chrome.box(0, 3, 20, 12)
|
||
self:drawList(2, 4)
|
||
Chrome.box(0, 12, 20, 6)
|
||
self:drawDescription(14)
|
||
self:drawOverlays()
|
||
love.graphics.setColor(1, 1, 1, 1)
|
||
end
|
||
|
||
function PackMenu:draw()
|
||
self:drawPanel()
|
||
end
|
||
|
||
function PackMenu:drawWidescreen(winW, winH)
|
||
local G = love.graphics
|
||
G.setColor(1, 1, 1, 1)
|
||
G.rectangle("fill", 0, 0, winW, winH)
|
||
local scale = Chrome.fitScale(winW, winH)
|
||
G.push()
|
||
G.translate(math.floor((winW - 160 * scale) / 2),
|
||
math.floor((winH - 144 * scale) / 2))
|
||
G.scale(scale, scale)
|
||
self:drawPanel()
|
||
G.pop()
|
||
end
|
||
|
||
PackMenu.POCKETS = POCKETS
|
||
|
||
return PackMenu
|