-- The Pokemon PC's top menu (engine/pokemon/bills_pc_top.asm _BillsPC). -- -- WITHDRAW POKéMON / DEPOSIT POKéMON / CHANGE BOX / -- MOVE POKéMON W/O MAIL / MAIL BOX / SEE YA! -- -- MAIL BOX is the item PC's PLAYERSPCITEM_MAIL_BOX row rather than one of -- _BillsPC's five; it is here because this port used to fold both PCs into -- one menu, and a directly-constructed PcMenu still folds them. The -- Pokecenter's whose-PC menu (src/ui/gen2/CenterPcMenu.lua) opens this as -- BILL's PC with `bills = true`, which shows the cart's own five rows and -- leaves the MAIL BOX to 's PC (src/ui/gen2/ItemPcMenu.lua). -- -- .MenuHeader is menu_coords 0, 0, 19, 17 -- the menu owns the whole screen, -- with "What do you want to do?" in a text box along the bottom. Choosing an -- entry pushes BoxMenu (the withdraw/deposit list) or the box picker. -- -- The assembled rows run through the ui.pc.items hook, the same name and the -- same (game, items) payload the Gen 1 PC uses -- (src/world/OverworldController.lua openPC), with SEE YA! appended after it -- the way that site appends LOG OFF. local Boxes = require("src.core.gen2.Boxes") local Chrome = require("src.ui.gen2.Chrome") local Logger = require("src.core.Logger") local Mail = require("src.core.gen2.Mail") local Runtime = require("src.mods.Runtime") local Save = require("src.core.gen2.Save") local SaveMenu = require("src.ui.gen2.SaveMenu") local Screens = require("src.ui.Screens") local Sound = require("src.core.Sound") local Strings = require("src.core.Strings") local Typer = require("src.ui.gen2.Typer") -- _PCMonHoldingMailText (data/text/common_2.asm), the refusal -- BillsPC_MovePKMNMenu prints instead of opening the list. Two pages, because -- the ASM has a `para` in the middle of it. Declared up here and looked up at -- the call site, so Strings.source is what puts both in the catalog. local MON_HOLDING_MAIL = { Strings.source("There is a POKéMON\nholding MAIL."), Strings.source("Please remove the\nMAIL."), } -- ../pokecrystal/data/text/common_3.asm:219 _ChangeBoxSaveText -- ../pokegold/data/text/common_2.asm:1306 _ChangeBoxSaveText local CHANGE_BOX_SAVE_SOURCE = Strings.source("When you change a\n#MON BOX, data\vwill be saved. OK?") -- YesNoBox's own `lb bc, SCREEN_WIDTH - 6, 7` (home/menu.asm:382-383). local YESNO_X, YESNO_Y, YESNO_W, YESNO_H = 14, 7, 6, 5 local PcMenu = {} PcMenu.__index = PcMenu PcMenu.isOpaque = true -- .strings, verbatim. and are real font glyphs (codes $e1/$e2) and -- Font.split matches charmap sequences, so writing them the way the ASM does -- draws two tiles rather than seven -- which is the only reason "MOVE -- W/O MAIL" fits inside a 20-tile screen. local ENTRIES = { { id = "withdraw", label = Strings.source("WITHDRAW "), builtin = true }, { id = "deposit", label = Strings.source("DEPOSIT "), builtin = true }, { id = "changebox", label = Strings.source("CHANGE BOX"), builtin = true }, { id = "move", label = Strings.source("MOVE W/O MAIL"), builtin = true }, -- PLAYERSPCITEM_MAIL_BOX (engine/events/pokecenter_pc.asm), which BOTH -- .WhichPC lists carry: the MAILBOX is on the item PC in a Pokecenter and in -- the bedroom alike, unlike DECORATION below. It sits here because this -- port folds the item PC's menu into the storage one. { id = "mailbox", label = Strings.source("MAIL BOX"), builtin = true }, { id = "seeya", label = Strings.source("SEE YA!"), builtin = true }, } -- PLAYERSPCITEM_DECORATION, the one row the bedroom's PC has that a -- Pokecenter's does not (engine/events/pokecenter_pc.asm: PLAYERSPC_HOUSE -- carries it, PLAYERSPC_NORMAL does not). It belongs to the item PC's menu on -- the cart, which this port folds into the storage menu the same way both PCs -- are folded -- so it hangs off the same list, gated on `house`. local DECORATION = { id = "decoration", label = Strings.source("DECORATION"), builtin = true, } -- The exit row. It is a member of ENTRIES (it is one of _BillsPC's five), but -- the list is assembled without it and it is put back on the end AFTER the -- ui.pc.items hook has run, exactly as the Gen 1 site appends LOG OFF -- (src/world/OverworldController.lua openPC): a mod may add, drop or reorder -- anything it likes and still cannot orphan the way out. local EXIT_ID = "seeya" -- ui.pc.items identity: an unhooked build hands its own list back. local function sameItems(_, items) return items end function PcMenu:wantsFillScale() return true end function PcMenu:drawsWidescreen() return true end -- opts: save, house (the bedroom's PC, which also does decorations), events -- (the wEventFlags bitfield the decoration menu reads ownership from), -- bills (_BillsPC's own five rows, no MAIL BOX: what the whose-PC -- menu's BILL's PC entry opens, src/ui/gen2/CenterPcMenu.lua), -- onClose(changedDecorations) function PcMenu.new(game, opts) opts = opts or {} local self = setmetatable({}, PcMenu) self.game = game self.save = opts.save or (game and game.save) self.onClose = opts.onClose self.house = opts.house and true or false self.events = opts.events -- The same route the start menu's SAVE row takes (src/core/Game2.lua:435), -- so the save.write veto and the save.writing event fire here too. self.writer = opts.writer or (game and type(game.writeSave) == "function" and function() return game:writeSave() end) or Save.save self.saveExists = opts.saveExists -- The folded MAIL BOX row belongs to the item PC, and the whose-PC menu -- reaches that through 's PC (src/ui/gen2/ItemPcMenu.lua), so -- BILL's PC shows the cart's own five rows. The bedroom's PC keeps it: the -- MAILBOX is on both .WhichPC lists. local dropMailbox = opts.bills and not self.house local entries = {} for _, entry in ipairs(ENTRIES) do local drop = entry.id == EXIT_ID or (dropMailbox and entry.id == "mailbox") if not drop then entries[#entries + 1] = entry end end if self.house then entries[#entries + 1] = DECORATION end -- Same hook name and same (game, items) payload as the Gen 1 PC menu -- (src/world/OverworldController.lua openPC), so one mod source can add a -- row to both generations' PCs. Unguarded, like that site: the list is -- built once per session, not per frame. A hook that answers with anything -- but a table is degraded to the vanilla list. local hooked = Runtime.call("ui.pc.items", sameItems, game, entries) if type(hooked) == "table" then entries = hooked else Logger.error("ui.pc.items returned %s; keeping the vanilla items", type(hooked)) end -- SEE YA! goes back on last: it is the row B lands on, and TURN OFF sits at -- the bottom of the cart's house list too. for _, entry in ipairs(ENTRIES) do if entry.id == EXIT_ID then entries[#entries + 1] = entry end end self.entries = entries -- wChangedDecorations, carried out to `special PlayersHousePC` so its -- script's `iftrue` can reload the map. self.changedDecorations = false self.index = 1 self.message = nil -- Whether clearing the message also logs off. .CheckCanUsePC's does (the PC -- never opened at all); BillsPC_MovePKMNMenu's mail refusal does not -- its -- `.quit` returns with the carry clear, which drops back into the _BillsPC -- loop and redraws this menu. self.messageCloses = true -- .CheckCanUsePC: an empty party gets the "You'll need a POKéMON" line and -- the PC never opens. Kept here rather than at the call site so every route -- into the PC (the overworld script, a driver, a mod) gets the same gate. -- Boxes.lua's own \f is the same catalog key BoxMenu.lua's -- BOX_FAILURE_SOURCES already declares for this string; split the -- translated result on it into notice()'s page-per-string shape rather -- than calling notice() itself, which would also reset messageCloses to -- false -- this refusal closes the PC, the mail-holding one does not. local ok, reason = Boxes.canUsePc(self.save) if not ok then local pages = {} for page in (Strings(reason) .. "\f"):gmatch("(.-)\f") do pages[#pages + 1] = page end self.message = pages[1] self.messagePages = pages self.messagePage = 1 end return self end -- A refusal that leaves the PC open: the message replaces the menu until a -- button clears it, and then the menu is back exactly where it was. `pages` -- is a list because a `para` in the ASM is a screenful of its own -- the text -- box holds two rows and the fourth line of _PCMonHoldingMailText would -- otherwise be drawn off the bottom of the screen. function PcMenu:notice(pages) self.message = pages[1] self.messagePages = pages self.messagePage = 1 self.messageCloses = false end -- home/menu.asm:746 function PcMenu: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 function PcMenu:close() if self.onClose then self.onClose(self.changedDecorations) end end -- engine/pokemon/bills_pc.asm:2403 BillsPC_ChangeBoxSubmenu .Switch -- engine/menus/save.asm:40 ChangeBoxSaveGame function PcMenu:beginChangeBox(index) self.changeBox = index self:setSavePhase("confirm") self.saveChoice = 1 self.saved = nil local existed = self.saveExists if existed == nil then existed = Save.exists() end self.existed = existed end -- .refused: `pop de / ret`, with wCurBox untouched and the picker still up. function PcMenu:refuseChangeBox() self.savePhase, self.changeBox = nil, nil self.saveTimer = 0 self.typer = nil end -- ../pokecrystal/engine/menus/save.asm:336 SavingDontTurnOffThePower -- ../pokecrystal/engine/menus/save.asm:241 SavedTheGame function PcMenu:setSavePhase(phase) self.savePhase = phase self.savePage = 1 self.saveTimer = 0 local pinned = (phase == "saving" or phase == "done") and "MID" or nil self.typer = Typer.new(self.game, { speed = pinned }) self.typer:start(self:savePages()[1]) end function PcMenu:acceptChangeBox() if self.saveChoice == 2 then return self:refuseChangeBox() end if self.savePhase == "confirm" and self.existed then self:setSavePhase("overwrite") self.saveChoice = 1 return end self:setSavePhase("saving") end -- `pop de / ld a, e / ld [wCurBox], a` sits between SaveBox and -- SavingDontTurnOffThePower, so the new index rides the file that is written. function PcMenu:writeChangeBox() Boxes.setCurrent(self.save, self.changeBox) local ok = self.writer(self.save) self.saved = ok and true or false -- engine/menus/save.asm:266 if ok then Sound.waitSfxDone() SaveMenu.playSaveSfx(self.game, SaveMenu.SFX_SAVE) end end function PcMenu:savePromptText() if self.savePhase == "overwrite" then return Strings(SaveMenu.OVERWRITE_PROMPT_SOURCE) end if self.savePhase == "saving" then return Strings(SaveMenu.SAVING_PROMPT_SOURCE) end if self.savePhase == "done" then if self.saved then local name = (self.save.player and self.save.player.name) or "GOLD" return Strings("%s saved\nthe game.", name) end return Strings("Could not save.") end return Strings(CHANGE_BOX_SAVE_SOURCE) end function PcMenu:savePages() local text = self:savePromptText() if self.savePagesText ~= text or not self.savePagesCache then self.savePagesText = text self.savePagesCache = SaveMenu.pagesOf(text) end return self.savePagesCache end function PcMenu:savePrompt() local pages = self:savePages() return pages[self.savePage or 1] or pages[1] end -- ../pokecrystal/engine/menus/save.asm:209 SaveTheGame_yesorno function PcMenu:saveYesNoVisible(pages) if self.savePhase ~= "confirm" and self.savePhase ~= "overwrite" then return false end if Typer.typing(self) then return false end return (self.savePage or 1) >= #(pages or self:savePages()) end function PcMenu:updateChangeBox() local typed = Typer.step(self) -- SavingDontTurnOffThePower is DelayFrames, not a prompt: no button does -- anything until the sequence runs out (engine/menus/save.asm:55). if self.savePhase == "saving" then if not typed then return end self.saveTimer = self.saveTimer + 1 if self.saveTimer >= SaveMenu.SAVING_FRAMES then self:writeChangeBox() self:setSavePhase("done") end return end if self.savePhase == "done" then if not typed then return end self.saveTimer = self.saveTimer + 1 if self.saveTimer >= SaveMenu.SAVED_FRAMES then self.savePhase, self.changeBox = nil, nil self.picking = false self.typer = nil end return end local input = self.game and self.game.input if not input then return end if Typer.typing(self) then return end -- ../pokecrystal/home/text.asm:502 _ContText if not self:saveYesNoVisible() then if input:wasPressed("a") or input:wasPressed("b") then self:playSfx("Sfx_ReadText2") self.savePage = (self.savePage or 1) + 1 if self.typer then self.typer:start(self:savePages()[self.savePage]) end end return end if input:wasPressed("up") or input:wasPressed("down") then self.saveChoice = self.saveChoice == 1 and 2 or 1 elseif input:wasPressed("a") then -- home/menu.asm:345 self:playSfx("Sfx_ReadText2") self:acceptChangeBox() elseif input:wasPressed("b") then -- B out of a yes/no is NO (InterpretTwoOptionMenu returns carry). self:playSfx("Sfx_ReadText2") self:refuseChangeBox() end end function PcMenu:choose() local entry = self.entries[self.index] if not entry then return end local game = self.game -- A hook- or monkey-patch-injected row carries label + onSelect and no id -- the ladder below knows, so without this arm it draws and does nothing. -- Same shape and same argument order as src/ui/gen2/PartyMenu.lua:433. if type(entry.onSelect) == "function" then entry.onSelect(self, game) return end if entry.id == "seeya" then self:close() return end if entry.id == "decoration" then if not (game and game.stack) then return end Screens.push(game, "Gen2DecorationMenu", { save = self.save, events = self.events, onDone = function(changed) self.changedDecorations = self.changedDecorations or changed or false game.stack:pop() end, }) return end if entry.id == "mailbox" then if not (game and game.stack) then return end Screens.push(game, "Gen2MailboxMenu", { save = self.save, onClose = function() game.stack:pop() end, }) return end if entry.id == "changebox" then self.picking = true self.pickIndex = self.save.currentBox or 1 return end -- BillsPC_MovePKMNMenu asks IsAnyMonHoldingMail BEFORE it opens the list and -- refuses outright: MOVE POKéMON W/O MAIL is a whole-party operation, so one -- letter anywhere in the party stops it. if entry.id == "move" and Mail.anyMonHoldingMail(self.save) then self:notice({ Strings(MON_HOLDING_MAIL[1]), Strings(MON_HOLDING_MAIL[2]) }) return end if not (game and game.stack) then return end Screens.push(game, "Gen2BoxMenu", { save = self.save, mode = entry.id, -- "withdraw" | "deposit" | "move" onClose = function() game.stack:pop() end, }) end function PcMenu:update(_dt) local input = self.game and self.game.input if not input then return end if self.message then if input:wasPressed("a") or input:wasPressed("b") then local pages = self.messagePages if pages and self.messagePage < #pages then self.messagePage = self.messagePage + 1 self.message = pages[self.messagePage] return end local closes = self.messageCloses self.message, self.messagePages, self.messagePage = nil, nil, nil self.messageCloses = true if closes then self:close() end end return end if self.savePhase then self:updateChangeBox() return end if self.picking then local total = Boxes.NUM_BOXES if input:wasPressed("up") then self.pickIndex = self.pickIndex > 1 and self.pickIndex - 1 or total elseif input:wasPressed("down") then self.pickIndex = self.pickIndex < total and self.pickIndex + 1 or 1 elseif input:wasPressed("a") then -- engine/menus/scrolling_menu.asm:24 self:playSfx("Sfx_ReadText2") if self.pickIndex == (self.save.currentBox or 1) then self.picking = false else self:beginChangeBox(self.pickIndex) end elseif input:wasPressed("b") then self:playSfx("Sfx_ReadText2") self.picking = false end return end if input:wasPressed("up") then self.index = self.index > 1 and self.index - 1 or #self.entries elseif input:wasPressed("down") then self.index = self.index < #self.entries and self.index + 1 or 1 elseif input:wasPressed("a") then -- home/menu.asm:476 self:playSfx("Sfx_ReadText2") self:choose() elseif input:wasPressed("b") then self:playSfx("Sfx_ReadText2") self:close() end end function PcMenu:drawPanel() Chrome.clear() if self.message then Chrome.box(0, 12, 20, 6) -- A text box's two lines sit two tile rows apart, not one -- the same -- spacing src/render/TextBox.lua uses (line1 = ty+2, line2 = ty+4). local line = 14 for part in (self.message .. "\n"):gmatch("(.-)\n") do Chrome.print(part, 1, line) line = line + 2 end love.graphics.setColor(1, 1, 1, 1) return end if self.picking then -- CHANGE BOX: the 14 box names with how full each one is, six at a time. Chrome.box(0, 0, 20, 14) local rows = 6 local scroll = math.max(0, math.min(self.pickIndex - rows, Boxes.NUM_BOXES - rows)) for row = 1, rows do local i = row + scroll local ty = row * 2 - 1 if i == self.pickIndex then Chrome.cursor(1, ty) end Chrome.print(Boxes.name(self.save, i), 2, ty) Chrome.printRight( ("%d/%d"):format(Boxes.count(self.save, i), Boxes.MONS_PER_BOX), 18, ty) end if self.savePhase then -- ChangeBoxSaveGame's MenuTextbox, then YesNoBox over the box list. Chrome.box(0, 12, 20, 6) local pages = self:savePages() local lines = Typer.text(self, pages[self.savePage or 1] or pages[1]) Chrome.print(lines[1] or "", 1, 14) Chrome.print(lines[2] or "", 1, 16) if not Typer.typing(self) and (self.savePage or 1) < #pages and Typer.arrowOn(self) then Chrome.print(SaveMenu.DOWN_ARROW, SaveMenu.ARROW_X, SaveMenu.ARROW_Y) end if self:saveYesNoVisible(pages) then Chrome.box(YESNO_X, YESNO_Y, YESNO_W, YESNO_H) Chrome.print(Strings("YES"), YESNO_X + 2, YESNO_Y + 1) Chrome.print(Strings("NO"), YESNO_X + 2, YESNO_Y + 3) Chrome.cursor(YESNO_X + 1, YESNO_Y + (self.saveChoice == 1 and 1 or 3)) end love.graphics.setColor(1, 1, 1, 1) return end Chrome.box(0, 14, 20, 4) Chrome.print(Strings("Which BOX?"), 1, 16) love.graphics.setColor(1, 1, 1, 1) return end -- .LogIn prints _PCWhatText into the (0,12) box, and that text is one word: -- "What?". PrintText starts at (1,14). The box goes down first because -- the menu window is drawn over it wherever a folded list runs past row 12, -- the way the cart's windows stack (src/ui/gen2/ItemPcMenu.lua does the -- same with the house's six-row item list). Chrome.box(0, 12, 20, 6) Chrome.print(Strings("What?"), 1, 14) -- ClearPCItemScreen: Textbox at (0,0) with a 10x18 interior, and a second -- at (0,12) with a 4x18 one. GetMenuTextStartCoord then puts the first -- label at (left+1+1, top+1+1) = (2,2) because STATICMENU_CURSOR is set and -- the menu does not ask for NO_TOP_SPACING; rows are two apart and the -- cursor sits one column left of the label. _BillsPC's own five rows end -- at row 10 and fit that box exactly; the folded MAIL BOX (and the -- bedroom's DECORATION) put rows below it, so the window is sized to the -- list rather than to the five the cart ships. Chrome.box(0, 0, 20, math.max(12, #self.entries * 2 + 2)) for i, entry in ipairs(self.entries) do local ty = i * 2 if i == self.index then Chrome.cursor(1, ty) end Chrome.print(entry.builtin and Strings(entry.label) or entry.label, 2, ty) end love.graphics.setColor(1, 1, 1, 1) end function PcMenu:draw() self:drawPanel() end function PcMenu: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 PcMenu.ENTRIES = ENTRIES return PcMenu