mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-18 19:54:21 +02:00
466 lines
17 KiB
Lua
466 lines
17 KiB
Lua
-- 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 <PLAYER>'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 Strings = require("src.core.Strings")
|
|
|
|
-- _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."),
|
|
}
|
|
|
|
-- _ChangeBoxSaveText (data/text/common_2.asm:1306) is three lines whose `cont`
|
|
-- has already scrolled by the time YesNoBox goes up over its last two.
|
|
local CHANGE_BOX_SAVE = { "#MON BOX, data", "will 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. <PK> and <MN> 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 <PK><MN>
|
|
-- W/O MAIL" fits inside a 20-tile screen.
|
|
local ENTRIES = {
|
|
{ id = "withdraw", label = "WITHDRAW <PK><MN>" },
|
|
{ id = "deposit", label = "DEPOSIT <PK><MN>" },
|
|
{ id = "changebox", label = "CHANGE BOX" },
|
|
{ id = "move", label = "MOVE <PK><MN> W/O MAIL" },
|
|
-- 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 = "MAIL BOX" },
|
|
{ id = "seeya", label = "SEE YA!" },
|
|
}
|
|
|
|
-- 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 = "DECORATION" }
|
|
|
|
-- 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 <PLAYER>'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.
|
|
local ok, reason = Boxes.canUsePc(self.save)
|
|
if not ok then self.message = reason 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
|
|
|
|
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.savePhase = "confirm"
|
|
self.saveChoice = 1
|
|
self.saveTimer = 0
|
|
self.saved = nil
|
|
local existed = self.saveExists
|
|
if existed == nil then existed = Save.exists("gold") 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
|
|
end
|
|
|
|
function PcMenu:acceptChangeBox()
|
|
if self.saveChoice == 2 then return self:refuseChangeBox() end
|
|
if self.savePhase == "confirm" and self.existed then
|
|
self.savePhase = "overwrite"
|
|
self.saveChoice = 1
|
|
return
|
|
end
|
|
self.savePhase = "saving"
|
|
self.saveTimer = 0
|
|
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
|
|
if ok then SaveMenu.playSaveSfx(self.game, SaveMenu.SFX_SAVE) end
|
|
end
|
|
|
|
function PcMenu:savePrompt()
|
|
if self.savePhase == "overwrite" then return SaveMenu.OVERWRITE_PROMPT end
|
|
if self.savePhase == "saving" then return SaveMenu.SAVING_PROMPT end
|
|
if self.savePhase == "done" then
|
|
if self.saved then
|
|
local name = (self.save.player and self.save.player.name) or "GOLD"
|
|
return { name .. " saved", "the game." }
|
|
end
|
|
return { "Could not save.", "" }
|
|
end
|
|
return CHANGE_BOX_SAVE
|
|
end
|
|
|
|
function PcMenu:updateChangeBox()
|
|
-- 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
|
|
self.saveTimer = self.saveTimer + 1
|
|
if self.saveTimer >= SaveMenu.SAVING_FRAMES then
|
|
self:writeChangeBox()
|
|
self.savePhase = "done"
|
|
self.saveTimer = 0
|
|
end
|
|
return
|
|
end
|
|
if self.savePhase == "done" then
|
|
self.saveTimer = self.saveTimer + 1
|
|
if self.saveTimer >= SaveMenu.SAVED_FRAMES then
|
|
self.savePhase, self.changeBox = nil, nil
|
|
self.picking = false
|
|
end
|
|
return
|
|
end
|
|
|
|
local input = self.game and self.game.input
|
|
if not input then 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
|
|
self:acceptChangeBox()
|
|
elseif input:wasPressed("b") then
|
|
-- B out of a yes/no is NO (InterpretTwoOptionMenu returns carry).
|
|
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
|
|
if self.pickIndex == (self.save.currentBox or 1) then
|
|
self.picking = false
|
|
else
|
|
self:beginChangeBox(self.pickIndex)
|
|
end
|
|
elseif input:wasPressed("b") then
|
|
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
|
|
self:choose()
|
|
elseif input:wasPressed("b") then
|
|
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 lines = self:savePrompt()
|
|
Chrome.print(lines[1] or "", 1, 14)
|
|
Chrome.print(lines[2] or "", 1, 16)
|
|
if self.savePhase == "confirm" or self.savePhase == "overwrite" then
|
|
Chrome.box(YESNO_X, YESNO_Y, YESNO_W, YESNO_H)
|
|
Chrome.print("YES", YESNO_X + 2, YESNO_Y + 1)
|
|
Chrome.print("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("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("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.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
|
|
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
|
|
|
|
PcMenu.ENTRIES = ENTRIES
|
|
|
|
return PcMenu
|