From 6165969e0d5f4d9a072f5388e4aaa49f42bebc9b Mon Sep 17 00:00:00 2001 From: Maarten Paus Date: Sun, 25 Oct 2026 07:23:59 +0100 Subject: [PATCH] Add native Gen2 shared menu chrome with native palette/font ownership --- src/core/ModCompat.lua | 2 +- src/render/PaletteFX.lua | 6 +- src/ui/Gen2Chrome.lua | 664 ++++++++++++++++++++++++++++++ tests/engine/gen2_menu_chrome.lua | 79 ++++ 4 files changed, 749 insertions(+), 2 deletions(-) create mode 100644 src/ui/Gen2Chrome.lua create mode 100644 tests/engine/gen2_menu_chrome.lua diff --git a/src/core/ModCompat.lua b/src/core/ModCompat.lua index ee5e198fd..5b234b263 100644 --- a/src/core/ModCompat.lua +++ b/src/core/ModCompat.lua @@ -32,7 +32,6 @@ -- hook, not one module -- src.world.gen2.Follower PikachuFollower is Yellow's, not a general -- party follower --- src.ui.gen2.Chrome no shared window-chrome module -- src.ui.gen2.SaveMenu the save flow is part of StartMenu here -- src.ui.gen2.BattleAnimView Gen2AnimPlayer is a player, not a view -- src.battle.UIVisibility no equivalent @@ -80,6 +79,7 @@ ModCompat.ALIASES = { ["src.battle.gen2.Effects"] = "src.battle.MoveEffects", ["src.battle.gen2.Mon"] = "src.pokemon.Pokemon", ["src.ui.gen2.BattleState"] = "src.battle.BattleState", + ["src.ui.gen2.Chrome"] = "src.ui.Gen2Chrome", -- menus. The Gold port names two of these differently rather than -- namespacing them, so both spellings land on the one implementation. diff --git a/src/render/PaletteFX.lua b/src/render/PaletteFX.lua index 88984d94b..4bdfe5f67 100644 --- a/src/render/PaletteFX.lua +++ b/src/render/PaletteFX.lua @@ -1155,7 +1155,7 @@ function PaletteFX.paperShade(data) return c[1] / 255, c[2] / 255, c[3] / 255 end -function PaletteFX.effectiveColors(c) +function PaletteFX.effectiveColors(c, invert) if not c then return nil end local mode = PaletteFX.mode or "gbc" local out = c @@ -1176,6 +1176,10 @@ function PaletteFX.effectiveColors(c) -- cave has to read dark in CLASSIC's pea greens and in plain DMG grays too -- (#322). permute() is the identity (and returns `out` itself, which the -- mod-graphics parity check leans on) while nothing has armed one. + -- An inverted font page reverses its settled display ramp before the + -- hardware shade register permutes it. Keep this on the native path so + -- Gen2 menu chrome cannot carry a second palette mode/register state. + if invert then out = { out[4], out[3], out[2], out[1] } end return PaletteFX.permute(out, shadeMap) end diff --git a/src/ui/Gen2Chrome.lua b/src/ui/Gen2Chrome.lua new file mode 100644 index 000000000..df557ba62 --- /dev/null +++ b/src/ui/Gen2Chrome.lua @@ -0,0 +1,664 @@ +-- Ported from Gen1Recomp 535a5ec0; palette, font and DPI use this engine. +-- 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 PaletteFX = require("src.render.PaletteFX") +-- A view of the native palette renderer, with no extra mode or BGP state. +local Colors = {} +function Colors.available() return PaletteFX.shader() ~= nil end +function Colors.resolve(colors) return PaletteFX.effectiveColors(colors) end +function Colors.useRaw(colors) + local shader = PaletteFX.shader() + if not shader then return end + for i = 1, 4 do + local c = colors[i] + shader:send("c" .. (i - 1), { c[1] / 255, c[2] / 255, c[3] / 255 }) + end + love.graphics.setShader(shader) +end +function Colors.use(colors) Colors.useRaw(PaletteFX.effectiveColors(colors)) end + +local function drawGlyphCode(code, x, y, ink) + if code >= 0 then return Font.drawCode(code, x, y) end + -- This engine's UnicodeFont encodes rasterized glyphs as negative codes. + ink = ink or { 0, 0, 0 } + Font.pushStyle({ text = { ink[1] / 255, ink[2] / 255, ink[3] / 255, 1 } }) + local ok, err = pcall(Font.drawCode, code, x, y) + Font.popStyle() + if not ok then error(err, 0) end +end + +local WHITE = { 255, 255, 255 } +local BLACK = { 0, 0, 0 } + +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.paletteFill(px, py, pw, ph, palette) + palette = palette or Chrome.DEFAULT_BOX_PALETTE + local G = love.graphics + G.setColor(1, 1, 1, 1) + if Colors.available() then + -- Colors.with without the closure: set, draw, restore. + local previous = G.getShader and G.getShader() or nil + Colors.use(palette) + G.rectangle("fill", px, py, pw, ph) + G.setShader(previous) + else + G.rectangle("fill", px, py, pw, ph) + end + G.setColor(0, 0, 0, 1) +end + +-- Every drawWidescreen paints the window around its 160x144 panel through +-- here, so UI LETTERBOX has exactly one place to override. r/g/b is what the +-- screen was authored with and is what AUTO keeps. +Chrome.worldSurround = false + +function Chrome.letterbox(winW, winH, r, g, b) + if Chrome.worldSurround then return end + love.graphics.setColor(r or 1, g or 1, b or 1, 1) + love.graphics.rectangle("fill", 0, 0, winW, winH) +end + +function Chrome.clear() + Chrome.paletteFill(0, 0, Chrome.SCREEN_W * 8, Chrome.SCREEN_H * 8) +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. +local function playfieldRect(winW, winH) + local ok, Playfield = pcall(require, "src.render.Playfield") + if ok and Playfield.rect then + local okv, x, y, w, h = pcall(Playfield.rect, winW, winH) + if okv and w and w >= 1 and h and h >= 1 then + return x, y, w, h + end + end + return 0, 0, winW or 0, winH or 0 +end + +Chrome.playfieldRect = playfieldRect + +local function pixelRatio() + local get = love.graphics.getDPIScale or (love.window and love.window.getDPIScale) + local d = get and get() or 1 + return type(d) == "number" and d > 0 and d or 1 +end + +Chrome.pixelRatio = pixelRatio + +function Chrome.fitScaleFor(winW, winH, tilesW, tilesH) + local _, _, w, h = playfieldRect(winW, winH) + local d = pixelRatio() + return math.max(1, math.floor(math.min(w * d / (tilesW * 8), + h * d / (tilesH * 8)) + 1e-6)) / d +end + +function Chrome.fitOriginFor(winW, winH, scale, tilesW, tilesH) + scale = scale or Chrome.fitScaleFor(winW, winH, tilesW, tilesH) + local x, y, w, h = playfieldRect(winW, winH) + local d = pixelRatio() + return x + math.floor((w - tilesW * 8 * scale) * d / 2 + 1e-6) / d, + y + math.floor((h - tilesH * 8 * scale) * d / 2 + 1e-6) / d + - Chrome.positionLift(winW, winH, scale) +end + +function Chrome.fitScale(winW, winH) + return Chrome.fitScaleFor(winW, winH, Chrome.SCREEN_W, Chrome.SCREEN_H) +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 Chrome.fitOriginFor(winW, winH, scale, Chrome.SCREEN_W, + Chrome.SCREEN_H) +end + +function Chrome.positionLift(winW, winH, scale) + local ok, ScreenPosition = pcall(require, "src.core.ScreenPosition") + if not ok or ScreenPosition.skinActive(winW, winH) then return 0 end + local _, _, _, h = playfieldRect(winW, winH) + local d = pixelRatio() + return ScreenPosition.lift(h * d, Chrome.SCREEN_H * 8 * (scale + or Chrome.fitScale(winW, winH)) * d, ScreenPosition.safeTop() * d) / d +end + +-- pokegold engine/battle/core.asm:8646, engine/events/halloffame.asm:270 +local function clipTo(x, y, w, h) + local G = love.graphics + if G.transformPoint then + local x1, y1 = G.transformPoint(x, y) + local x2, y2 = G.transformPoint(x + w, y + h) + x, y = math.floor(math.min(x1, x2)), math.floor(math.min(y1, y2)) + w, h = math.ceil(math.abs(x2 - x1)), math.ceil(math.abs(y2 - y1)) + end + if G.intersectScissor then G.intersectScissor(x, y, w, h) + else G.setScissor(x, y, w, h) end +end + +Chrome.clipTo = clipTo + +function Chrome.withPanel(winW, winH, r, g, b, drawFn, scale) + local G = love.graphics + Chrome.letterbox(winW, winH, r, g, b) + scale = scale or Chrome.fitScale(winW, winH) + local ox, oy = Chrome.fitOrigin(winW, winH, scale) + G.push("all") + clipTo(ox, oy, Chrome.SCREEN_W * 8 * scale, Chrome.SCREEN_H * 8 * scale) + G.translate(ox, oy) + G.scale(scale, scale) + local ok, err = pcall(drawFn) + G.pop() + if not ok then error(err, 0) end +end + +function Chrome.withClip(drawFn) + local G = love.graphics + G.push("all") + clipTo(0, 0, Chrome.SCREEN_W * 8, Chrome.SCREEN_H * 8) + local ok, err = pcall(drawFn) + G.pop() + if not ok then error(err, 0) end +end + +Chrome.DEFAULT_BOX_PALETTE = { + { 255, 255, 255 }, { 255, 255, 255 }, { 255, 255, 255 }, { 0, 0, 0 }, +} + +function Chrome.paletteBox(tx, ty, tw, th, palette) + palette = palette or Chrome.DEFAULT_BOX_PALETTE + if Colors.available() then + local G = love.graphics + G.setColor(1, 1, 1, 1) + -- Colors.with, restoring the caller's shader even if the box throws. + local previous = G.getShader and G.getShader() or nil + Colors.use(palette) + local ok, err = pcall(Font.drawBox, tx, ty, tw, th) + G.setShader(previous) + if not ok then error(err, 0) end + else + Font.drawBox(tx, ty, tw, th) + end + love.graphics.setColor(0, 0, 0, 1) +end + +-- A bordered box, tile coords. Leaves the draw color black for text. +function Chrome.box(tx, ty, tw, th) + Chrome.paletteBox(tx, ty, tw, th) +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 + +local function flatPrint(text, tx, ty) + love.graphics.setColor(0, 0, 0, 1) + return Font.draw(text, tx * 8, ty * 8) +end + +function Chrome.print(text, tx, ty) + if not Colors.available() then return flatPrint(text, tx, ty) end + return Chrome.printThrough(text, tx, ty, Chrome.DEFAULT_BOX_PALETTE) +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 Colors.use follows. Split out so the fold can be checked +-- without a real shader (Colors.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) + return PaletteFX.effectiveColors(palette, invert) +end + +function Chrome.rawPalette(palette, invert) + if invert then return { palette[4], palette[3], palette[2], palette[1] } end + return palette +end + +-- The per-glyph shader/tint switch printThrough needs, factored out so a +-- caller that positions its own glyphs (TextBox's typewriter/scroll +-- animation) gets the same shaded ink without re-deriving the TTF-vs-tile +-- split. +-- +-- Returns (pal, drawGlyph, finish): +-- pal the resolved ramp, or nil if there's no shader to draw +-- through; a caller that also draws a paper rect should skip +-- it and take the plain Chrome.print degrade instead. +-- drawGlyph (code, x, y): draws one glyph, shaded through `pal` for a +-- tile glyph or tinted with pal[4] for a TTF one (see the +-- inline comment below, gen1recomp#1642). +-- finish call once after the last glyph to restore the previous +-- shader and draw colour. +function Chrome.paletteGlyphs(palette, invert, raw) + if not (palette and Colors.available()) then + return nil, Font.drawCode, function() end + end + -- `pal` is used below and never touches the caller's palette again -- + -- including useRaw for the draw, since useRaw skips the fold Colors.use + -- would otherwise apply and folding it a second time would undo this. + local pal = raw and Chrome.rawPalette(palette, invert) + or Chrome.throughPalette(palette, invert) + local ink = pal[4] or { 0, 0, 0 } + local previous = love.graphics.getShader() + local shaded = false + local function drawGlyph(code, x, y) + if code < 0 then + -- flat-shaded 2bpp tile sheet -- exactly what a TTF glyph is not. + -- LÖVE's font rasterizer stores glyph coverage as alpha over a plain + -- white texture, which this shader reads back as shade 0 no matter how + -- solid the glyph looks, painting the character the SAME colour as the + -- paper rect just drawn above it: invisible (reported against a real + -- Gold build with a TTF translation mod active, gen1recomp#1642). A TTF + -- glyph has no discrete shade to recover in the first place, so skip + -- the shader for it and tint it with the palette's own ink colour + -- (shade 3, the same entry `rgb = pal3` would have mapped a black tile + -- pixel to) directly. + if shaded then love.graphics.setShader(previous); shaded = false end + love.graphics.setColor(ink[1] / 255, ink[2] / 255, ink[3] / 255, 1) + elseif not shaded then + love.graphics.setColor(1, 1, 1, 1) + Colors.useRaw(pal) + shaded = true + end + drawGlyphCode(code, x, y, ink) + end + local function finish() + if shaded then love.graphics.setShader(previous) end + love.graphics.setColor(0, 0, 0, 1) + end + return pal, drawGlyph, finish +end + +-- printThrough's resolved palette, or nil when it has to take the plain +-- degrade: paletteGlyphs' own test and fold, without its two closures. +local function glyphPalette(palette, invert, raw) + if not (palette and Colors.available()) then return nil end + return raw and Chrome.rawPalette(palette, invert) + or Chrome.throughPalette(palette, invert) +end + +-- The string's glyph codes and its width, off one encode: the width is the +-- sum of the same advances the pen walks, which is all Font.width is. +local function measure(text) + local codes = Font.encode(text) + local width = 0 + for i = 1, #codes do width = width + Font.advanceOf(codes[i]) end + return codes, width +end + +-- paper rect, then every glyph through `pal` -- paletteGlyphs' drawGlyph and +-- finish inlined (see there for the TTF split, gen1recomp#1642). +local function drawThrough(codes, width, x, y, pal) + local G = love.graphics + local paper = pal[1] or WHITE + local ink = pal[4] or BLACK + local previous = G.getShader() + G.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1) + G.rectangle("fill", x, y, width, 8) + local shaded = false + local pen = x + for i = 1, #codes do + local code = codes[i] + if code < 0 then + if shaded then G.setShader(previous); shaded = false end + G.setColor(ink[1] / 255, ink[2] / 255, ink[3] / 255, 1) + elseif not shaded then + G.setColor(1, 1, 1, 1) + Colors.useRaw(pal) + shaded = true + end + drawGlyphCode(code, pen, y, ink) + pen = pen + Font.advanceOf(code) + end + if shaded then G.setShader(previous) end + G.setColor(0, 0, 0, 1) +end + +function Chrome.printThrough(text, tx, ty, palette, invert, raw) + local pal = glyphPalette(palette, invert, raw) + if not pal then return Chrome.print(text, tx, ty) end + local codes, width = measure(text) + drawThrough(codes, width, tx * 8, ty * 8, pal) + 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). +local function flatPrintRight(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 + +function Chrome.printRight(text, txEnd, ty) + if not Colors.available() then return flatPrintRight(text, txEnd, ty) end + return Chrome.printRightThrough(text, txEnd, ty, Chrome.DEFAULT_BOX_PALETTE) +end + +function Chrome.printRightThrough(text, txEnd, ty, palette, invert, raw) + local pal = glyphPalette(palette, invert, raw) + if not pal then return Chrome.printRight(text, txEnd, ty) end + local codes, width = measure(text) + drawThrough(codes, width, txEnd * 8 - width, ty * 8, pal) + return width +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. +-- ../pokecrystal/home/text.asm:473 +function Chrome.printWrapped(text, tx, ty, width, rows, step, palette) + step = step or 1 + local lines = Chrome.wrap(text, width) + for i = 1, math.min(#lines, rows or #lines) do + if palette then + Chrome.printThrough(lines[i], tx, ty + (i - 1) * step, palette) + else + Chrome.print(lines[i], tx, ty + (i - 1) * step) + end + end + return #lines +end + +local function flatCursor(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 + +function Chrome.cursor(tx, ty, hollow) + if not Colors.available() then return flatCursor(tx, ty, hollow) end + return Chrome.cursorThrough(tx, ty, Chrome.DEFAULT_BOX_PALETTE, nil, hollow) +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, raw) + if not (palette and Colors.available()) then + return Chrome.cursor(tx, ty, hollow) + end + local pal = raw and Chrome.rawPalette(palette, invert) + or 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() + Colors.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.printThrough("COIN", 12, 0, Chrome.DEFAULT_BOX_PALETTE) + Chrome.printThrough(Chrome.number(coins, 4, true), 13, 1, Chrome.DEFAULT_BOX_PALETTE) +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.printThrough("MONEY", 6, 1, Chrome.DEFAULT_BOX_PALETTE) + Chrome.printThrough(Chrome.money(money), 12, 1, Chrome.DEFAULT_BOX_PALETTE) + Chrome.printThrough("COIN", 6, 3, Chrome.DEFAULT_BOX_PALETTE) + Chrome.printThrough(Chrome.number(coins, 4, true), 15, 3, Chrome.DEFAULT_BOX_PALETTE) +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.printThrough(Chrome.money(money), 12, 1, Chrome.DEFAULT_BOX_PALETTE) +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.palette = opts.palette == nil and Chrome.DEFAULT_BOX_PALETTE or opts.palette + 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 + if self.palette then + Chrome.cursorThrough(self.x - 1, ty, self.palette) + else + flatCursor(self.x - 1, ty) + end + end + if self.palette then + Chrome.printThrough(item.label, self.x, ty, self.palette) + else + flatPrint(item.label, self.x, ty) + end + 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 + if self.palette then + local _, drawGlyph, finish = Chrome.paletteGlyphs(self.palette) + drawGlyph(Chrome.DOWN_ARROW, (self.x - 1) * 8, + (self.y + self.rows * self.spacing - 1) * 8) + finish() + else + 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 +end + +return Chrome diff --git a/tests/engine/gen2_menu_chrome.lua b/tests/engine/gen2_menu_chrome.lua new file mode 100644 index 000000000..f1fd4be61 --- /dev/null +++ b/tests/engine/gen2_menu_chrome.lua @@ -0,0 +1,79 @@ +package.path = './?.lua;./?/init.lua;' .. package.path +local T = require('tests.modkit') +local P = require('src.render.PaletteFX') +local Font = require('src.render.Font') +local V = require('src.core.GameVersion') +local C = require('src.ui.Gen2Chrome') +local before = {mode=P.mode, shade=P.shadeMap(), version=V.get()} +require('src.core.ModCompat').install() +T.eq(require('src.ui.gen2.Chrome'),C,'public Gen2 name resolves actual native menu renderer') +Font.load(T.fixtures.fresh()) +local ramp={{250,240,230},{180,170,160},{100,90,80},{20,10,0}} +local map={[0]=1,[1]=3,[2]=0,[3]=2} +P.setShadeMap(map) +for _,version in ipairs({'gold','silver','crystal','red','firered','emerald'})do + V.set(version) + for _,mode in ipairs({'gbc','gbc_inv','og','og_inv','classic'})do + P.setMode(mode) + T.same(C.throughPalette(ramp),P.effectiveColors(ramp),'menu uses actual native mode/register '..version..'/'..mode) + -- Inversion belongs BEFORE the noncommuting shade map. Compare against + -- the native resolved display ramp with the shade register disarmed. + P.setShadeMap(nil);local settled=P.effectiveColors(ramp);P.setShadeMap(map) + local reversed={settled[4],settled[3],settled[2],settled[1]} + T.same(C.throughPalette(ramp,true),P.permute(reversed,map),'inverted text then hardware shade register '..version..'/'..mode) + T.same(C.rawPalette(ramp,true),{ramp[4],ramp[3],ramp[2],ramp[1]},'raw palette skips native display substitutions') + end +end +P.setShadeMap(nil);P.setMode('gbc');V.set('crystal') +local dpi=love.graphics.getDPIScale +for _,d in ipairs({1,1.5,2})do + love.graphics.getDPIScale=function()return d end -- output metrics only + local scale=C.fitScale(801,601) + T.eq(scale*d,math.floor(scale*d),'native panel pixels land on framebuffer grid') + local x,y=C.fitOrigin(801,601,scale) + T.check(x>=0 and y>=0 and x+160*scale<=801 and y+144*scale<=601,'native panel fits its actual viewport') +end +love.graphics.getDPIScale=dpi +T.eq(C.number(23,4,false),' 23','native padded number') +T.eq(C.number(23,4,true),'0023','native zero-filled number') +T.eq(C.money(23),' ¥23','native floating yen field') +T.same(C.wrap('ONE TWO\nTHREE',7),{'ONE TWO','THREE'},'cart hard line break retained') +local chosen,cancel,moved +local list=C.List.new({items={{label='ONE',value=1},{label='TWO',value=2,disabled=true},{label='THREE',value=3}},rows=2,onChoose=function(v)chosen=v end,onCancel=function()cancel=true end,onMove=function(v)moved=v end}) +local input=require('src.core.Input');input:init() +local function tap(key)input:keypressed(key);input:step();local out=list:update(input);input:keyreleased(key);input:step();return out end +T.check(tap('down'),'real native down edge consumed') +T.eq(list.index,2,'list moves once per native edge');T.eq(moved,2,'movement callback follows actual item') +tap('z');T.eq(chosen,nil,'disabled entry cannot be selected') +tap('down');T.eq(list.scroll,1,'native list scrolls into actual item') +tap('z');T.eq(chosen,3,'real native A selects current item') +tap('down');T.eq(list.index,1,'native list wraps at end') +tap('x');T.eq(cancel,true,'real native B cancels list') + +-- Real native box and font calls, with only the output APIs recorded. +local draw,rect=love.graphics.draw,love.graphics.rectangle +local drawn,filled=0,0 +love.graphics.draw=function(...)drawn=drawn+1;return draw(...)end +love.graphics.rectangle=function(...)filled=filled+1;return rect(...)end +C.clear();C.textbox(0,12,18,4);C.print('ONE',1,13);C.cursor(1,1);list:draw() +T.check(drawn>0 and filled>0,'native chrome actually renders font, box and paper') +local shader={tag='caller'};love.graphics.setShader(shader) +T.raises(function()C.withPanel(800,600,1,1,1,function()love.graphics.setShader({});error('panel body probe')end)end,'panel body probe','panel propagates actual draw failure') +T.eq(love.graphics.getShader(),shader,'panel restores native graphics state on failure') +love.graphics.setShader();love.graphics.draw,love.graphics.rectangle=draw,rect +-- Exercise the shaded path too; only the GPU uniform sink is replaced. +local nativeShader=P.shader +local uniforms={} +local output={send=function(_,key,value)uniforms[key]=value end} +P.shader=function()return output end +P.setShadeMap(map) +local caller={tag='native caller'};love.graphics.setShader(caller) +local expected=P.effectiveColors(ramp,true) +C.printThrough('ONE',1,1,ramp,true) +T.same(uniforms.c0,{expected[1][1]/255,expected[1][2]/255,expected[1][3]/255},'glyph shader receives once-resolved native paper') +T.same(uniforms.c3,{expected[4][1]/255,expected[4][2]/255,expected[4][3]/255},'glyph shader receives once-resolved native ink') +T.eq(love.graphics.getShader(),caller,'palette text restores actual caller shader') +T.eq(P.shadeMap(),map,'chrome does not replace native shade register') +P.shader=nativeShader;love.graphics.setShader() +P.setMode(before.mode);P.setShadeMap(before.shade);V.set(before.version) +T.finish('native Gen2 shared menu chrome')