-- 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