-- 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. "\nused !"), 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