Files
gen1recomp/src/ui/Menu.lua
T
Shane McGovern 795276935c Performance pass: cut per-frame allocations and draw calls, fix frame-rate and batch bugs
- Shared render: cache Font encode/width, skip unchanged palette/ShaderFX
  uniform sends, reuse quads and scratch tables, cheaper sprite palettes
- Mods: fast path for loaded requires, memoized sandbox verdicts, fewer
  Hooks:call allocations, drop emptied hook chains
- Audio: bulk PCM writes, cry/SFX prewarm on the worker, sliced sync
  fallback, cached Gen 3 SE sources
- Gen 1: per-frame SGB battle palettes, hoisted overworld FX, cached menus
- Gen 2: numeric attr grid, indexed anim cells, entity culling, memoized
  cache keys, palette shader set once per tile run
- Gen 3: reuse native tile batches, per-cell metatile rebuilds, partial
  FRLG tile anim uploads, near-view ghost ticking, packed FRLG font atlas
- Fixes: emergency quit timer, Gen 3 batch regression and stale sprite
  index, trainer card texture leak, missing pic re-reads, anim tint cache,
  Crystal ghost grass overdraw, refresh-rate dependent counters, pad swap
2026-09-30 14:53:18 +00:00

198 lines
8.1 KiB
Lua

-- Generic bordered list menu with the blinking ▶ cursor.
-- items: { { label=..., onSelect=function }, ... }
-- Pops itself on B (unless cancelable=false or opts.keepOnCancel); also on
-- START only when opts.startCloses is set -- pokered's wMenuWatchedKeys mask
-- varies per menu and only the start menu's adds PAD_START.
local Font = require("src.render.Font")
local Theme = require("src.ui.Theme")
local Menu = {}
Menu.__index = Menu
function Menu.new(game, items, opts)
local self = setmetatable({}, Menu)
opts = opts or {}
self.game = game
self.items = items
self.index = 1
self.tx = opts.tx or 10
self.ty = opts.ty or 0
self.tw = opts.tw or 10
-- grow the box to the widest label so longer (e.g. localized) labels don't
-- overflow the frame; nudge tx left to keep the box on-screen (20 tiles).
do
local widest = 0
for _, it in ipairs(items) do
if it.label then
local n = #Font.split(it.label)
if n > widest then widest = n end
end
end
local needed = widest + 3
if needed > self.tw then self.tw = needed end
if self.tx + self.tw > 20 then self.tx = math.max(0, 20 - self.tw) end
end
self.rowStep = opts.rowStep or 2
-- home/window.asm:56-83
self.noWrap = opts.noWrap or false
-- engine/movie/oak_speech/oak_speech2.asm:162 (DisplayIntroNameTextBox)
self.title = opts.title
self.itemY = opts.itemY
-- maxVisible: cap the box to this many rows and scroll the rest instead
-- of growing past it (e.g. the start menu, whose row count varies with
-- save state and mod hooks); nil/unset keeps every caller's old
-- behavior of sizing the box to fit all items.
self.maxVisible = opts.maxVisible
self.scroll = 0
local visible = (self.maxVisible and math.min(self.maxVisible, #items))
or #items
self.th = opts.th or (visible * self.rowStep + 2)
self.cancelable = opts.cancelable ~= false
-- Whether START closes the menu. In pokered a menu responds only to the
-- keys in its wMenuWatchedKeys mask; the common PAD_A | PAD_B (and the
-- list menu's PAD_A | PAD_B | PAD_SELECT) masks leave START unwatched, so
-- only menus whose real mask includes PAD_START -- the start menu
-- (engine/menus/draw_start_menu.asm) -- opt in here.
self.startCloses = opts.startCloses or false
-- screen-edge anchor for this menu (see Menu:draw); nil keeps it in the
-- classic centred letterbox
self.anchor = opts.anchor
self.onCancel = opts.onCancel
-- engine/events/cinnabar_lab.asm:70-73
self.keepOnCancel = opts.keepOnCancel or false
-- BIT_NO_MENU_BUTTON_SOUND (wMiscFlags): the PC session runs its
-- menus silent (home/window.asm HandleMenuInput_)
self.noSound = opts.noSound or false
self:clampScroll()
return self
end
-- keeps self.index inside the visible [scroll+1, scroll+maxVisible] window;
-- callers that move self.index directly (e.g. restoring a saved cursor
-- position) should call this afterwards to scroll it into view
function Menu:clampScroll()
if not (self.maxVisible and #self.items > self.maxVisible) then
self.scroll = 0
return
end
if self.index - self.scroll > self.maxVisible then
self.scroll = self.index - self.maxVisible
elseif self.index - self.scroll < 1 then
self.scroll = self.index - 1
end
end
function Menu:update(dt)
local input = self.game.input
-- engine/events/cinnabar_lab.asm:29
if self.frozen then return end
if input:wasPressed("up") then
self.index = self.index > 1 and self.index - 1
or (self.noWrap and 1 or #self.items)
elseif input:wasPressed("down") then
self.index = self.index < #self.items and self.index + 1
or (self.noWrap and #self.items or 1)
elseif input:wasPressed("a") then
-- HandleMenuInput_ (home/window.asm): SFX_PRESS_AB on every A press
if not self.noSound then
require("src.core.Sound").play(self.game.data, "Press_AB")
end
local item = self.items[self.index]
-- keepOpen entries run without closing the menu (e.g. the
-- Pokédex CRY option keeps the side menu up)
if not item.keepOpen then self.game.stack:pop() end
if item.onSelect then item.onSelect() end
elseif self.cancelable and (input:wasPressed("b")
or (self.startCloses and input:wasPressed("start"))) then
-- HandleMenuInput_ returns for any watched key, but only replays
-- SFX_PRESS_AB for the PAD_A | PAD_B branch -- so B beeps and START
-- (when watched, e.g. the start menu) closes silently.
if input:wasPressed("b") and not self.noSound then
require("src.core.Sound").play(self.game.data, "Press_AB")
end
if not self.keepOnCancel then self.game.stack:pop() end
if self.onCancel then self.onCancel() end
end
self:clampScroll()
end
function Menu:draw()
-- opts.anchor opts a menu out of the centred letterbox and onto a screen
-- edge (the START menu asks for "topright"). Only menus that ask for it
-- move; every other menu is placed exactly as before.
local r = self.anchor and self.game and self.game.renderer
if r and r.setUIAnchor then
r:setUIAnchor(self.tx * 8, self.ty * 8,
self.tw * 8, self.th * 8, self.anchor)
end
Font.drawBox(self.tx, self.ty, self.tw, self.th)
-- PlaceString at hlcoord 3,0 writes over the border row it was just
-- drawn on (oak_speech2.asm:162-170)
if self.title then
-- the title's lines and glyph counts, rebuilt only when it changes
-- (this draws every frame the menu is up)
local lines, glyphs = self.titleLines, self.titleGlyphs
if self.titleLinesFor ~= self.title or not lines then
local title = self.title:gsub("{DONE}%s*$", "")
:gsub("{PROMPT}%s*$", "")
lines, glyphs = {}, {}
for line in (title .. "\n"):gmatch("(.-)\n") do
lines[#lines + 1] = line
glyphs[#lines] = #Font.split(line)
end
self.titleLinesFor = self.title
self.titleLines, self.titleGlyphs = lines, glyphs
end
love.graphics.setColor(1, 1, 1, 1)
for i = 1, #lines do
love.graphics.rectangle("fill", (self.tx + 3) * 8,
(self.ty + i - 1) * 8, glyphs[i] * 8, 8)
end
love.graphics.setColor(0, 0, 0, 1)
for i, line in ipairs(lines) do
Font.draw(line, (self.tx + 3) * 8, (self.ty + i - 1) * 8)
end
end
local visible = (self.maxVisible and math.min(self.maxVisible, #self.items))
or #self.items
-- Row Y: pokered's boxed menus anchor the choices to the BOTTOM interior
-- row and let any slack fall as a blank row under the top edge --
-- draw_start_menu.asm (TextBoxBorder 10,0, then hlcoord 12,2 /
-- wTopMenuItemY 2), players_pc.asm and bills_pc.asm all do the same. The
-- bottom border is ty + th - 1, so the last choice sits on ty + th - 2 and
-- row r counts back up from there. Anchoring from the top instead only
-- agrees when th is exactly visible * rowStep + 2, which is Menu.new's
-- default but NOT what a caller sizing its own box passes: BagMenu's
-- USE/TOSS is th = 5 for two choices (#284, matching text_boxes.asm's
-- USE_TOSS_MENU_TEMPLATE rows 10..14), and a top anchor pushed TOSS onto
-- the bottom border (#564, #572).
local function rowY(row)
if self.itemY then
return (self.ty + self.itemY + (row - 1) * self.rowStep) * 8
end
return (self.ty + self.th - 2 - (visible - row) * self.rowStep) * 8
end
for row = 1, visible do
local item = self.items[self.scroll + row]
if not item then break end
Font.draw(item.label, (self.tx + 2) * 8, rowY(row))
end
-- hollowIndex: home/window.asm:198
Font.drawCode(self.hollowIndex == self.index
and Theme.cursorHollow or Theme.cursor,
(self.tx + 1) * 8, rowY(self.index - self.scroll))
-- moreArrow ($EE): the same "more below" glyph OptionRows/ManagerState
-- use, sat on the bottom border like TextBox's page-advance cursor. It
-- has to be the border row, not ty + th - 2: that is the last interior
-- row, which the last choice now occupies, and Menu.new widens the box to
-- widest + 3 so tx + tw - 2 is exactly that label's final glyph (#564).
if self.maxVisible and self.scroll + self.maxVisible < #self.items then
Font.drawCode(Theme.moreArrow, (self.tx + self.tw - 2) * 8,
(self.ty + self.th - 1) * 8)
end
love.graphics.setColor(1, 1, 1, 1)
end
return Menu