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

375 lines
14 KiB
Lua

-- Shared Gen 2 menu chrome: boxes, tile-grid text, and the scrolling cursor
-- list that nearly every Gold screen is built out of.
--
-- Gold draws its menus through one routine (engine/menus/menu.asm SetUpMenu +
-- GetScrollingMenuJoypad), so the behaviour every screen inherits -- wrap at
-- the ends, a ▶ cursor in a one-tile gutter, B to back out, START as an
-- accept on some menus -- lives here once instead of in each screen.
--
-- Everything is in 8px tile coordinates on the 20x18 GB grid, because that is
-- how the ASM addresses the screen (hlcoord x, y) and it makes a layout
-- transcribed from pokegold land on the same pixels.
local Font = require("src.render.Font")
local GbcPalette = require("src.render.GbcPalette")
local Chrome = {}
-- charmap.asm: ▶ is the menu cursor, ▷ its hollow "held" form, ▼ the
-- text-advance arrow.
Chrome.CURSOR = 0xED
Chrome.CURSOR_HOLLOW = 0xEC
Chrome.DOWN_ARROW = 0xEE
Chrome.SCREEN_W = 20
Chrome.SCREEN_H = 18
-- charmap.asm "¥", the money field's own prefix tile.
local YEN = "\xc2\xa5"
function Chrome.clear()
local G = love.graphics
G.setColor(1, 1, 1, 1)
G.rectangle("fill", 0, 0, Chrome.SCREEN_W * 8, Chrome.SCREEN_H * 8)
G.setColor(0, 0, 0, 1)
end
-- The blit scale every `drawWidescreen` paints its 160x144 panel at.
--
-- One GB pixel has to cover a WHOLE number of window pixels or the 8x8 grid
-- everything here is laid out on stops landing on tile boundaries: at a
-- fractional scale some rows of a tile get one device pixel and their
-- neighbours get two, which is what breaks a box border into steps and eats
-- pixel rows out of glyphs. This is the same rule src/render/Renderer.lua
-- fitScale applies to the Gen 1 UI canvas; the surround a widescreen screen
-- paints still fills the window, the PANEL is what stays on the grid.
function Chrome.fitScale(winW, winH)
return math.max(1, math.floor(math.min((winW or 0) / (Chrome.SCREEN_W * 8),
(winH or 0) / (Chrome.SCREEN_H * 8))))
end
-- The centred origin that goes with it, so a caller does not re-derive it.
function Chrome.fitOrigin(winW, winH, scale)
scale = scale or Chrome.fitScale(winW, winH)
return math.floor((winW - Chrome.SCREEN_W * 8 * scale) / 2),
math.floor((winH - Chrome.SCREEN_H * 8 * scale) / 2)
end
-- A bordered box, tile coords. Leaves the draw color black for text.
function Chrome.box(tx, ty, tw, th)
Font.drawBox(tx, ty, tw, th)
love.graphics.setColor(0, 0, 0, 1)
end
-- Gold's Textbox helper takes an interior width/height and draws the border
-- around it; b/c in the ASM are interior rows/columns, so a `lb bc, 4, 13`
-- box is 6 rows by 15 columns on screen.
function Chrome.textbox(tx, ty, interiorW, interiorH)
Chrome.box(tx, ty, interiorW + 2, interiorH + 2)
end
function Chrome.print(text, tx, ty)
love.graphics.setColor(0, 0, 0, 1)
return Font.draw(text, tx * 8, ty * 8)
end
-- Prints a string the way a tilemap screen does: the glyph tiles replace the
-- cells they land on, and both their ink and their blank pixels go through
-- that screen's own BG palette.
--
-- The font sheets here are black ink (shade 3) on transparent, and setColor
-- multiplies -- black times anything is still black -- so the ink colour
-- cannot come from a tint. Drawing through GbcPalette maps shade 3 to the
-- palette's colour 3, and painting colour 0 behind the string first supplies
-- the cell background the sheet does not carry. Without a shader this
-- degrades to the ordinary black print rather than drawing nothing.
--
-- The palette printThrough actually draws with: COLOR mode resolved,
-- inverted if asked, and the active rBGP byte folded in last -- the same
-- order GbcPalette.use follows. Split out so the fold can be checked
-- without a real shader (GbcPalette.available() is false in a driverless
-- test harness, which used to hide this from every suite).
--
-- The substitution has to happen before the reversal or an inverted string
-- would come back through an un-reversed grey ramp and print black on white,
-- and the rBGP fold has to come after both: it REORDERS the four entries
-- this palette already settled on (colour 0 in the low bits, so
-- `dc 3,2,1,0` packs to $e4), it does not tint them, so folding it before
-- the invert reversal would permute the wrong four colours.
function Chrome.throughPalette(palette, invert)
local pal = GbcPalette.resolve(palette)
if invert then pal = { pal[4], pal[3], pal[2], pal[1] } end
return GbcPalette.remap(pal, GbcPalette.bgp)
end
-- `palette` is a 4-colour table of 0-255 triples. `invert` is what
-- Chrome.printInverted passes; see there.
function Chrome.printThrough(text, tx, ty, palette, invert)
if not (palette and GbcPalette.available()) then
return Chrome.print(text, tx, ty)
end
-- `pal` is used below and never touches the caller's palette again --
-- including useRaw for the draw, since useRaw skips the fold GbcPalette.use
-- would otherwise apply and folding it a second time would undo this.
local pal = Chrome.throughPalette(palette, invert)
local width = Font.width(text)
local paper = pal[1] or { 255, 255, 255 }
love.graphics.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1)
love.graphics.rectangle("fill", tx * 8, ty * 8, width, 8)
love.graphics.setColor(1, 1, 1, 1)
local previous = love.graphics.getShader()
GbcPalette.useRaw(pal)
Font.draw(text, tx * 8, ty * 8)
love.graphics.setShader(previous)
love.graphics.setColor(0, 0, 0, 1)
return width
end
-- The same, through an *inverted* font page. Pokedex_LoadInvertedFont xors
-- both bitplanes of the standard font, so a glyph pixel of shade s becomes
-- shade 3 - s before the palette sees it -- which is exactly the palette read
-- backwards, and is what makes the dex white on black.
function Chrome.printInverted(text, tx, ty, palette)
if not palette then return Chrome.print(text, tx, ty) end
return Chrome.printThrough(text, tx, ty, palette, true)
end
-- Right-aligned within a field that ends at tile `txEnd` (exclusive), which is
-- how Gold prints numbers (PrintNum fills from the right).
function Chrome.printRight(text, txEnd, ty)
local width = Font.width(text)
love.graphics.setColor(0, 0, 0, 1)
return Font.draw(text, txEnd * 8 - width, ty * 8)
end
-- Wrap text to `width` tiles, measuring with the real font so a proportional
-- page wraps where it actually overflows. Nothing on a 160px screen may print
-- past tile 20: text that does is drawn outside the GB frame entirely.
-- A "\n" in the text is the cart's OWN line break (data/text's `line` / `next`
-- control byte, e.g. "<USER>\nused <MOVE>!"), so it is a HARD break: each
-- segment wraps on its own and the break survives, rather than being eaten by
-- the %S+ tokenizer and re-wrapped by width.
function Chrome.wrap(text, width)
local budget = (width or Chrome.SCREEN_W) * 8
local lines = {}
for segment in (tostring(text or "") .. "\n"):gmatch("(.-)\n") do
local line = nil
for word in segment:gmatch("%S+") do
local candidate = line and (line .. " " .. word) or word
if line and Font.width(candidate) > budget then
lines[#lines + 1] = line
line = word
else
line = candidate
end
end
if line then lines[#lines + 1] = line end
end
return lines
end
-- Print wrapped text from (tx, ty) downward, at most `rows` lines.
function Chrome.printWrapped(text, tx, ty, width, rows)
local lines = Chrome.wrap(text, width)
for i = 1, math.min(#lines, rows or #lines) do
Chrome.print(lines[i], tx, ty + i - 1)
end
return #lines
end
function Chrome.cursor(tx, ty, hollow)
love.graphics.setColor(0, 0, 0, 1)
Font.drawCode(hollow and Chrome.CURSOR_HOLLOW or Chrome.CURSOR, tx * 8, ty * 8)
end
-- The cursor glyph through a palette, the way Chrome.printThrough draws text.
-- A screen whose font page is inverted (the #DEX: Pokedex_LoadInvertedFont
-- xors both bitplanes, so it runs white on black) needs its cursor inverted
-- with everything else -- Chrome.cursor's flat black is invisible against that
-- ground, which is what made the dex's action arrow look absent.
function Chrome.cursorThrough(tx, ty, palette, invert, hollow)
if not (palette and GbcPalette.available()) then
return Chrome.cursor(tx, ty, hollow)
end
local pal = Chrome.throughPalette(palette, invert)
local paper = pal[1] or { 255, 255, 255 }
love.graphics.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1)
love.graphics.rectangle("fill", tx * 8, ty * 8, 8, 8)
love.graphics.setColor(1, 1, 1, 1)
local previous = love.graphics.getShader()
GbcPalette.useRaw(pal)
Font.drawCode(hollow and Chrome.CURSOR_HOLLOW or Chrome.CURSOR, tx * 8, ty * 8)
love.graphics.setShader(previous)
love.graphics.setColor(0, 0, 0, 1)
end
-- Gold pads a numeric field with spaces, not zeroes, unless
-- PRINTNUM_LEADINGZEROS is set.
function Chrome.number(value, width, leadingZeros)
local text = tostring(math.floor(value or 0))
local pad = math.max(0, (width or 0) - #text)
return (leadingZeros and ("0"):rep(pad) or (" "):rep(pad)) .. text
end
-- PrintNum with PRINTNUM_MONEY and without PRINTNUM_LEADINGZEROS
-- (home/print_num.asm .PrintYen): the ¥ is emitted just before the FIRST
-- significant digit rather than at a fixed column, and the field is six digits
-- wide, so the string is always seven tiles and the yen sign floats.
function Chrome.money(amount)
local digits = ("%06d"):format(math.max(0, math.floor(amount or 0)))
local first = digits:find("[1-9]") or #digits
return (" "):rep(first - 1) .. YEN .. digits:sub(first)
end
-- ------------------------------------------------------- balance boxes
--
-- engine/menus/menu_2.asm. These three are the boxes a SCRIPT puts up with a
-- `special` and then leaves standing: every one of them is immediately
-- followed by `loadmenu`, so the box is on screen for the whole of the static
-- menu that answers. They live here rather than in one menu module because
-- three different screens print them (the Game Corner prize counters and the
-- coin vendor through src/ui/gen2/ScriptMenu.lua, the prize counter screen
-- itself) and all three are laid out by the same two routines.
--
-- DisplayCoinCaseBalance: Textbox at (11,0) with a 7x1 interior, "COIN" at
-- (12,0) -- yes, in the border row -- and the four-digit count at (13,1) with
-- PRINTNUM_LEADINGZEROS.
function Chrome.coinBalanceBox(coins)
Chrome.textbox(11, 0, 7, 1)
Chrome.print("COIN", 12, 0)
Chrome.print(Chrome.number(coins, 4, true), 13, 1)
end
-- DisplayMoneyAndCoinBalance: one Textbox at (5,0) with a 13x3 interior
-- holding both fields, "MONEY" at (6,1) with the yen field at (12,1) and
-- "COIN" at (6,3) with the four-digit count at (15,3).
function Chrome.moneyAndCoinBalanceBox(money, coins)
Chrome.textbox(5, 0, 13, 3)
Chrome.print("MONEY", 6, 1)
Chrome.print(Chrome.money(money), 12, 1)
Chrome.print("COIN", 6, 3)
Chrome.print(Chrome.number(coins, 4, true), 15, 3)
end
-- PlaceMoneyTopRight: MoneyTopRightMenuHeader is `menu_coords 11, 0,
-- SCREEN_WIDTH - 1, 2`, a MenuBox rather than a Textbox, and PlaceMoneyTextbox
-- prints the number at MenuBoxCoord2Tile + SCREEN_WIDTH + 1, i.e. (12,1).
function Chrome.moneyBalanceBox(money)
Chrome.box(11, 0, 9, 3)
Chrome.print(Chrome.money(money), 12, 1)
end
-- A vertical cursor list.
--
-- opts:
-- items array of strings, or of { label = , value = , disabled = }
-- x, y tile coords of the first row's *label* (cursor sits at x - 1)
-- spacing tile rows between entries (Gold uses 2 for most menus)
-- rows visible rows; a longer list scrolls (default: all)
-- wrap wrap past the ends (STATICMENU_WRAP)
-- startAccepts treat START like A (STATICMENU_ENABLE_START)
-- onChoose(value, index)
-- onCancel()
-- onMove(value, index) -- for the start menu's description box
local List = {}
List.__index = List
Chrome.List = List
function List.new(opts)
local self = setmetatable({}, List)
self.items = {}
for i, entry in ipairs(opts.items or {}) do
if type(entry) == "table" then
self.items[i] = entry
else
self.items[i] = { label = tostring(entry), value = entry }
end
end
self.x = opts.x or 1
self.y = opts.y or 1
self.spacing = opts.spacing or 2
self.rows = math.min(opts.rows or #self.items, #self.items)
self.wrap = opts.wrap ~= false
self.startAccepts = opts.startAccepts or false
self.onChoose = opts.onChoose
self.onCancel = opts.onCancel
self.onMove = opts.onMove
self.index = math.max(1, math.min(opts.index or 1, math.max(1, #self.items)))
self.scroll = 0
self:ensureVisible()
return self
end
function List:current()
return self.items[self.index]
end
function List:ensureVisible()
if self.rows <= 0 then return end
if self.index <= self.scroll then
self.scroll = self.index - 1
elseif self.index > self.scroll + self.rows then
self.scroll = self.index - self.rows
end
self.scroll = math.max(0, math.min(self.scroll, #self.items - self.rows))
end
function List:move(delta)
if #self.items == 0 then return end
local next_ = self.index + delta
if next_ < 1 then
if not self.wrap then return end
next_ = #self.items
elseif next_ > #self.items then
if not self.wrap then return end
next_ = 1
end
self.index = next_
self:ensureVisible()
if self.onMove then self.onMove(self:current() and self:current().value, self.index) end
end
-- Returns true when the press was consumed, so a screen can layer its own
-- handling (left/right on the options rows) behind this.
function List:update(input)
if not input then return false end
if input:wasPressed("up") then
self:move(-1)
return true
elseif input:wasPressed("down") then
self:move(1)
return true
elseif input:wasPressed("a")
or (self.startAccepts and input:wasPressed("start")) then
local item = self:current()
if item and not item.disabled and self.onChoose then
self.onChoose(item.value, self.index)
end
return true
elseif input:wasPressed("b") then
if self.onCancel then self.onCancel() end
return true
end
return false
end
function List:draw()
for row = 1, self.rows do
local i = row + self.scroll
local item = self.items[i]
if item then
local ty = self.y + (row - 1) * self.spacing
if i == self.index then Chrome.cursor(self.x - 1, ty) end
Chrome.print(item.label, self.x, ty)
end
end
-- Scrolling lists get the ▼ hint Gold shows when there is more below.
if self.rows < #self.items and self.scroll + self.rows < #self.items then
love.graphics.setColor(0, 0, 0, 1)
Font.drawCode(Chrome.DOWN_ARROW, (self.x - 1) * 8,
(self.y + self.rows * self.spacing - 1) * 8)
end
end
return Chrome