Files
gen1recomp/src/ui/gen2/ScriptMenu.lua
T
bryanthaboi ae6cac89e1 G2 support
2026-08-11 11:53:30 -04:00

304 lines
12 KiB
Lua

-- The static menu a script puts up: `loadmenu` then `verticalmenu` or
-- `_2dmenu` (home/menu.asm VerticalMenu / _2DMenu, engine/menus/menu.asm
-- _2DMenu_). Nine sites in the game use one -- the vending machines, the Game
-- Corner prize counters, the coin vendor, Earl's blackboard -- and until the
-- extractor followed the MenuHeader pointer every one of them took the cancel
-- arm, because the VM had a raw address and nothing to draw.
--
-- The whole layout comes out of two routines, so nothing here is laid out by
-- eye:
--
-- MenuBox draws the border from (left, top) to (right, bottom)
-- inclusive, which is the four bytes `menu_coords` lays down
-- Y FIRST (macros/coords.asm is `db \2, \1` twice).
-- GetMenuTextStartCoord decides where the labels and the cursor go:
-- box + 1 for the border, + 1 more row unless
-- STATICMENU_NO_TOP_SPACING, + 1 more column if
-- STATICMENU_CURSOR. The cursor sits in the column that last
-- + 1 skipped over.
--
-- A vertical menu's items are TWO rows apart (`ld bc, 2 * SCREEN_WIDTH` in
-- PlaceMenuStrings) and its answer is wMenuCursorY. A 2D menu lays its items
-- out row by row, `spacing` tiles apart across and the same two rows down, and
-- its answer is `(cursorY - 1) * cols + cursorX` (_2DMenu_'s SimpleMultiply).
--
-- Both are ONE-BASED and both answer 0 for B, which is why the `ifequal`
-- ladder after the command starts at 1 and the fall-through is the cancel arm.
--
-- These two routines are Gold's generic menu, so this is where the generic
-- ui.list_menu hook lands: it takes the same opts (wrap, pageJump, keyRepeat,
-- repeatDelay, repeatRate) and the same ctx the Gen 1 list takes
-- (src/ui/ListMenu.lua), and a mod that asks for wrapping once has it on every
-- script menu in the game.
local Chrome = require("src.ui.gen2.Chrome")
local CoinCase = require("src.core.gen2.CoinCase")
local Runtime = require("src.mods.Runtime")
local Sound = require("src.core.Sound")
local ScriptMenu = {}
ScriptMenu.__index = ScriptMenu
ScriptMenu.isOpaque = false
-- constants/menu_constants.asm, the wMenuDataFlags bits this screen reads.
local STATICMENU_DISABLE_B = 0x01
-- STATICMENU_WRAP (bit 2): the cursor runs off one end onto the other. No
-- header a script loads sets it, so vanilla Gold never wraps -- it is read here
-- because it is the flag a ui.list_menu hook is turning on when it asks for
-- `wrap`, and a header that ever did set it must not need a second switch.
local STATICMENU_WRAP = 0x04
local STATICMENU_NO_TOP_SPACING = 0x40
local STATICMENU_CURSOR = 0x80
-- Key-repeat cadence, the same pair the Gen 1 generic list uses
-- (src/ui/ListMenu.lua): frames to wait before repeats start, then between
-- them. Both are inert until a ui.list_menu hook turns keyRepeat on.
local REPEAT_DELAY = 16
local REPEAT_RATE = 4
-- ui.list_menu identity: unhooked opts pass through unchanged.
local function sameOpts(opts) return opts end
local function hasFlag(flags, bit)
return math.floor((tonumber(flags) or 0) / bit) % 2 == 1
end
-- GetMenuTextStartCoord, exactly. Answers the label origin in tile coords;
-- the cursor column is one to the left of it when there is a cursor at all.
function ScriptMenu.startCoord(header)
local flags = (header and header.dataFlags) or 0
local y = ((header and header.top) or 0) + 1
local x = ((header and header.left) or 0) + 1
if not hasFlag(flags, STATICMENU_NO_TOP_SPACING) then y = y + 1 end
if hasFlag(flags, STATICMENU_CURSOR) then x = x + 1 end
return x, y
end
-- The item list and the grid shape, whichever of the two the header carries.
-- A vertical menu is n rows by one column; a 2D menu names its own.
function ScriptMenu.layout(header, style)
local grid = header and header.grid
if style == "2d" and grid then
return header.gridItems or {}, grid.rows, grid.cols, grid.spacing or 0
end
local items = (header and header.items) or {}
return items, #items, 1, 0
end
-- The 1-based answer _2DMenu_ computes. A vertical menu is one column, so
-- this collapses to the row index -- which is what wMenuCursorY holds.
function ScriptMenu.choiceIndex(row, col, cols)
return (row - 1) * (cols or 1) + col
end
-- opts: header (the extracted MenuHeader), style ("vertical" | "2d"),
-- balance ("coins" | "money" | "moneycoins"), save,
-- onChoose(index) -- 0 for a cancel,
-- title / kind -- context for the ui.list_menu hook, nothing else reads
-- them (a script menu has neither on the cart)
function ScriptMenu.new(game, opts)
opts = opts or {}
local self = setmetatable({}, ScriptMenu)
self.game = game
self.data = (game and game.data) or {}
-- The balance box the `special` before the `loadmenu` put up: every
-- DisplayCoinCaseBalance / DisplayMoneyAndCoinBalance / PlaceMoneyTopRight in
-- the game is followed straight away by loadmenu, and the box stays on
-- screen under the menu until CloseWindow. See Chrome's balance boxes.
self.balance = opts.balance
self.save = opts.save or (game and game.save)
self.header = opts.header or {}
self.style = opts.style or "vertical"
self.onChoose = opts.onChoose
self.items, self.rows, self.cols, self.spacing =
ScriptMenu.layout(self.header, self.style)
self.textX, self.textY = ScriptMenu.startCoord(self.header)
self.showCursor = hasFlag(self.header.dataFlags, STATICMENU_CURSOR)
-- MenuHeader's last byte is wMenuCursorPosition, the option the cursor opens
-- on. It is 1-based and every real header sets it to 1.
local start = math.max(1, math.min(#self.items,
tonumber(self.header.cursor) or 1))
self.row = math.floor((start - 1) / self.cols) + 1
self.col = (start - 1) % self.cols + 1
-- ui.list_menu: the same hook name and the same (opts, ctx) payload the Gen 1
-- generic list uses (src/ui/ListMenu.lua), wrapped around Gold's own generic
-- menu -- VerticalMenu / _2DMenu, the routine every static script menu in the
-- game is drawn and driven by -- so a mod that asks for wrapping or
-- hold-to-scroll once gets it on both generations' list-shaped menus.
-- Guarded with wantsHook: a mod-free boot builds no ctx table and keeps the
-- flags the header itself carries.
self.wrap = hasFlag(self.header.dataFlags, STATICMENU_WRAP)
self.pageJump = false
self.keyRepeat = false
self.repeatDelay = REPEAT_DELAY
self.repeatRate = REPEAT_RATE
self.holdDir, self.holdFrames = nil, 0
if Runtime.wantsHook("ui.list_menu") then
local hooked = Runtime.call("ui.list_menu", sameOpts, {
wrap = self.wrap,
pageJump = self.pageJump,
keyRepeat = self.keyRepeat,
repeatDelay = self.repeatDelay,
repeatRate = self.repeatRate,
}, {
game = game,
title = opts.title,
-- `kind` is what a Gen 1 hook switches on ("bag", "shop", ...); a script
-- menu has no title to fall back to, so it names the shape it is.
kind = opts.kind or ("script_" .. self.style),
itemCount = #self.items,
})
if type(hooked) == "table" then
if hooked.wrap ~= nil then self.wrap = hooked.wrap and true or false end
if hooked.pageJump ~= nil then
self.pageJump = hooked.pageJump and true or false
end
if hooked.keyRepeat ~= nil then
self.keyRepeat = hooked.keyRepeat and true or false
end
self.repeatDelay = tonumber(hooked.repeatDelay) or self.repeatDelay
self.repeatRate = math.max(1, tonumber(hooked.repeatRate)
or self.repeatRate)
end
end
return self
end
function ScriptMenu:wantsFillScale() return true end
function ScriptMenu:finish(index)
if self.done then return end
self.done = true
if self.onChoose then self.onChoose(index) end
end
function ScriptMenu:playSfx(name)
local sfx = self.data.audio and self.data.audio.sfx
if sfx and sfx[Sound.resolve(self.data, name)] then
Sound.play(self.data, name)
end
end
-- One cursor step along an axis. Without `wrap` the cursor stops at the ends
-- the way _2DMenu_'s exit flags leave it, which is every header the game ships.
local function step(current, total, delta, wrap)
if total <= 0 then return current end
local next_ = current + delta
if wrap then return ((next_ - 1) % total) + 1 end
return math.max(1, math.min(total, next_))
end
-- An edge press or a key-repeat tick for a held direction. Left and right are
-- the 2D grid's own axis wherever there is more than one column; on a vertical
-- menu they are free, so `pageJump` gives them the ends of the list -- the page
-- the Gen 1 list moves by is its whole visible window, and this menu draws
-- every row it has (src/ui/ListMenu.lua navPressed).
function ScriptMenu:nav(dir)
if dir == "up" then
self.row = step(self.row, self.rows, -1, self.wrap)
elseif dir == "down" then
self.row = step(self.row, self.rows, 1, self.wrap)
elseif self.cols > 1 and dir == "left" then
self.col = step(self.col, self.cols, -1, self.wrap)
elseif self.cols > 1 and dir == "right" then
self.col = step(self.col, self.cols, 1, self.wrap)
elseif self.pageJump and dir == "left" then
self.row = step(self.row, self.rows, -self.rows, self.wrap)
elseif self.pageJump and dir == "right" then
self.row = step(self.row, self.rows, self.rows, self.wrap)
end
end
-- StaticMenuJoypad, and then MenuClickSound on the way out.
function ScriptMenu:update(_dt)
if self.done then return end
local input = self.game and self.game.input
if not input then return end
local pressed = nil
if input:wasPressed("up") then
pressed = "up"
elseif input:wasPressed("down") then
pressed = "down"
elseif input:wasPressed("left") then
pressed = "left"
elseif input:wasPressed("right") then
pressed = "right"
end
if pressed then
self:nav(pressed)
self.holdDir, self.holdFrames = pressed, 0
elseif input:wasPressed("a") then
self:playSfx("Sfx_ReadText2")
self:finish(ScriptMenu.choiceIndex(self.row, self.col, self.cols))
elseif input:wasPressed("b")
and not hasFlag(self.header.dataFlags, STATICMENU_DISABLE_B) then
self:playSfx("Sfx_ReadText2")
self:finish(0)
end
if self.done then return end
-- Hold-to-scroll, opt-in through ui.list_menu's keyRepeat and driven exactly
-- as the Gen 1 list drives it (src/ui/ListMenu.lua): the held direction
-- repeats once repeatDelay frames have passed, then every repeatRate frames.
if not self.keyRepeat then return end
local dir = self.holdDir
if dir and input.isDown and input:isDown(dir) then
self.holdFrames = self.holdFrames + 1
local afterDelay = self.holdFrames - self.repeatDelay
if afterDelay >= 0 and afterDelay % self.repeatRate == 0 then
self:nav(dir)
end
else
self.holdDir, self.holdFrames = nil, 0
end
end
function ScriptMenu:itemPosition(index)
local row = math.floor((index - 1) / self.cols)
local col = (index - 1) % self.cols
return self.textX + col * self.spacing, self.textY + row * 2
end
-- Drawn BEFORE the menu box, which is the order the cart draws them in: the
-- Celadon TM counter's menu runs to column 15 on row 2 and the coin box's
-- bottom border is on that row, so the menu's own border is what survives the
-- overlap.
function ScriptMenu:drawBalance()
local kind = self.balance
if not kind then return end
local player = self.save and self.save.player
local money = (player and player.money) or 0
if kind == "coins" then
Chrome.coinBalanceBox(CoinCase.coins(self.save))
elseif kind == "moneycoins" then
Chrome.moneyAndCoinBalanceBox(money, CoinCase.coins(self.save))
else
Chrome.moneyBalanceBox(money)
end
end
function ScriptMenu:drawPanel()
self:drawBalance()
local h = self.header
local left, top = h.left or 0, h.top or 0
Chrome.box(left, top,
(h.right or left) - left + 1, (h.bottom or top) - top + 1)
for index, label in ipairs(self.items) do
local x, y = self:itemPosition(index)
Chrome.print(label, x, y)
end
if self.showCursor then
local x, y = self:itemPosition(
ScriptMenu.choiceIndex(self.row, self.col, self.cols))
Chrome.cursor(x - 1, y)
end
love.graphics.setColor(1, 1, 1, 1)
end
function ScriptMenu:draw()
self:drawPanel()
end
return ScriptMenu