mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-19 12:15:31 +02:00
304 lines
12 KiB
Lua
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
|