feat: UI LAYOUT option, centered by default

Edge docking and zoom-linked UI scaling shipped as unconditional
behaviour. Both are departures from how the port composed the screen, so
they become a setting instead: UI LAYOUT = CENTERED (the default) or
DYNAMIC.

CENTERED is a fixed letterbox. Elements stay where they were drawn in the
160x144 canvas, and the UI does not follow the survey zoom, so screen
furniture neither moves nor resizes under the player. That is what the
pre-anchoring builds did. DYNAMIC is the current behaviour, unchanged.

Both halves matter together: gating only the anchoring would stop the
dialogue box moving but leave it resizing with the zoom, which is the same
complaint in a different form.

Gated at Renderer:setUIAnchor and Renderer:uiScale rather than at each
caller, so one switch covers the dialogue box, its YES/NO, the START menu
and anything anchored later, and no caller knows the option exists.
Game.dynamicUI answers true only for an explicit "dynamic", so a save
written before this keeps the layout it already had.

Independent of it, deliberately: BATTLE SIZE still works under either mode
(uiFill overrides the scale later, in endFrame), and a battle still holds
its own prompts inside its screen under DYNAMIC.

Also includes the Oak intro fix (previously #674): the speech fills white
over the UI canvas while its dialogue box docks to the window edge, so
under DYNAMIC black showed between the two. letterboxWhite closes it, and
the shrink beat's replica box rides the same anchor as the real box it
stands in for.
This commit is contained in:
spiritsnails
2026-08-02 10:47:27 -06:00
parent 0f45bb5792
commit fe7dcf33ec
8 changed files with 319 additions and 0 deletions
+16
View File
@@ -315,6 +315,15 @@ end
-- everything else here: the text box and YES/NO a battle puts up are states
-- of their own sitting above it, and they are exactly the elements that must
-- stay inside the battle's composition rather than dock to the window.
-- UI LAYOUT: is edge docking switched on? Only the explicit "dynamic" turns
-- it on, so a save written before the option existed -- and any caller with no
-- save at all, which is most of the headless suites -- gets CENTERED, the
-- behaviour the port shipped with.
function Game.dynamicUI(save)
local options = save and save.options
return options ~= nil and options.uiLayout == "dynamic"
end
function Game.uiAnchorsHeldInStack(stack)
for i = #(stack and stack.states or {}), 1, -1 do
local state = stack.states[i]
@@ -415,6 +424,13 @@ function Game:draw()
Renderer.uiWorldHold = Renderer.battleDim ~= nil
-- ...and a battle keeps its dialogue box and YES/NO inside its own screen
-- instead of letting them dock to the window edge.
-- UI LAYOUT: CENTERED (the default) is a fixed letterbox -- every element
-- stays inside the 160x144 canvas and the UI does not follow the survey
-- zoom, so the screen furniture never moves or resizes under the player.
-- That is the composition the port shipped with. DYNAMIC opts into both
-- halves: the dialogue box docks to the window's bottom edge, the START
-- menu to its top right, and the whole UI steps down with the zoom.
Renderer.uiCentered = not Game.dynamicUI(self.save)
Renderer.uiAnchorHold = Game.uiAnchorsHeldInStack(self.stack)
Renderer:beginFrame(worldBelow)
for i = drawFrom, #self.stack.states do
+9
View File
@@ -228,6 +228,15 @@ function SaveData.defaultOptions()
-- "black" = plain black bars, "world" = the frozen overworld showing
-- through, dimmed. See BattleState:bgMode.
battleBg = "white",
-- UI LAYOUT: "centered" = a fixed letterbox. Every element sits where it
-- was drawn in the 160x144 canvas and the UI does not follow the survey
-- zoom, so nothing moves or resizes under the player. The original
-- composition. "dynamic" = the dialogue box docks to the window's bottom
-- edge, the START menu to its top right, and the UI steps down with the
-- zoom. Centered is the default: dynamic reads better zoomed out, but it
-- moves the screen furniture, so it is opt-in.
-- See Game.dynamicUI, Renderer:setUIAnchor and Renderer:uiScale.
uiLayout = "centered",
ruleset = "gen1_faithful",
-- 0-7 like the GB's NR50 master volume
musicVol = 7,
+15
View File
@@ -142,6 +142,13 @@ end
function Renderer:uiScale()
local S = self:fitScale()
local off = Zoom.offset or 0
-- UI LAYOUT = CENTERED (uiCentered, set per frame by Game:draw): the UI is
-- a fixed letterbox at the fit scale and does not follow the survey zoom at
-- all, which is the whole point of the setting -- the screen furniture
-- stays put instead of resizing under the player. DYNAMIC keeps the
-- step-down below. BATTLE SIZE is unaffected either way: uiFill overrides
-- the scale in endFrame, after this.
if self.uiCentered then return S end
-- Only follow the zoom when a world is actually behind the UI. Survey zoom
-- is an OVERWORLD control; the title screen, the intro and the credits show
-- no world at all, and shrinking them to match a zoom level the player set
@@ -630,6 +637,14 @@ end
-- pixels, and consumed by endFrame this frame only.
-- anchor: "bottom" | "topright" | "topleft" | "bottomright"
function Renderer:setUIAnchor(x, y, w, h, anchor)
-- UI LAYOUT = CENTERED (uiCentered, set per frame by Game:draw from
-- save.options.uiLayout): every element stays where it was drawn in the
-- 160x144 canvas and the letterbox centres the lot, which is how the port
-- behaved before edge docking existed. This is the DEFAULT; DYNAMIC opts
-- back into docking. Gating here rather than at each caller means one
-- switch covers the dialogue box, its YES/NO, the START menu and anything
-- added later, and none of them has to know the option exists.
if self.uiCentered then return end
-- uiAnchorHold (Game:draw): a state that composes its own screen -- a
-- battle -- keeps every element inside it, so the box blits where it was
-- drawn in the canvas instead of being pulled to the window edge.
+19
View File
@@ -20,6 +20,16 @@ local OakSpeech = {}
OakSpeech.__index = OakSpeech
OakSpeech.isOpaque = true
-- The speech is a white field with a pic on it, and its dialogue box docks to
-- the WINDOW's bottom edge (Renderer:setUIAnchor, via TextBox). The white it
-- fills below is only the 160x144 UI canvas, so once the box moved to the
-- window edge the two stopped touching: black letterbox showed between the
-- bottom of Oak's white and the top of the box he is speaking from. Filling
-- the voids with the paper shade -- the same opt-in a battle uses -- puts the
-- box back on the field. Not a literal 1,1,1: the canvas is colorized, so
-- endFrame matches it with PaletteFX.paperShade.
OakSpeech.letterboxWhite = true
-- FadeInIntroPic runs a 6-step palette fade; MovePicLeft wipes the mon
-- sprite in from the right. Both play out before the beat's text prints.
local FADE_FRAMES = 24
@@ -611,6 +621,15 @@ function OakSpeech:draw()
love.graphics.draw(self.walkSheet, self.walkQuad, 64, 60)
end
if self.shrinkText then
-- This is a REPLICA of the dialogue box that just closed, redrawn at
-- TextBox's own rect (BOX_TX..BOX_TH = 0,12,20,6) so the last page holds
-- while the pic shrinks. The real box rides the bottom anchor, so this
-- one has to as well -- otherwise the text visibly jumps up a letterbox
-- on the frame the real box is swapped for this copy.
local r = self.game and self.game.renderer
if r and r.setUIAnchor then
r:setUIAnchor(0, 12 * 8, 20 * 8, 6 * 8, "bottom")
end
Font.drawBox(0, 12, 20, 6)
love.graphics.setColor(0, 0, 0, 1)
for i, line in ipairs(self.shrinkText) do
+17
View File
@@ -191,6 +191,23 @@ local function buildRows(game)
o.battleBg = order[(cur - 1 + (dir or 1)) % #order + 1]
return true
end },
-- CENTERED is a fixed letterbox: elements stay inside the 160x144 canvas
-- and the UI does not follow the survey zoom, so nothing moves or resizes
-- under the player. The composition the port shipped with. DYNAMIC docks
-- the dialogue box to the window's bottom edge and the START menu to its
-- top right, and steps the UI down with the zoom -- easier to read zoomed
-- out, but it moves furniture the original never moved, so it is opt-in.
-- BATTLE SIZE is independent of this and works under either.
{ id = "uiLayout", label = Strings("UI LAYOUT"),
value = function(g)
return g.save.options.uiLayout == "dynamic" and Strings("DYNAMIC")
or Strings("CENTERED")
end,
step = function(g)
local o = g.save.options
o.uiLayout = o.uiLayout == "dynamic" and "centered" or "dynamic"
return true
end },
{ id = "ruleset", label = Strings("RULESET"),
value = function(g) return rulesetName(g) end,
step = function(g, dir)
@@ -0,0 +1,67 @@
-- Driver: Oak's intro white field must reach the dialogue box.
--
-- The speech fills white over the 160x144 UI canvas, but TextBox docks to the
-- WINDOW's bottom edge (Renderer:setUIAnchor). In a letterboxed window that
-- left black between the bottom of the white and the top of the box.
-- OakSpeech.letterboxWhite fills the voids with the paper shade instead.
--
-- Needs a window that actually letterboxes -- an exact multiple of 160x144
-- has no voids to get wrong -- so it resizes before shooting.
-- POKEPORT_DRIVER=tests/drivers/oak_speech_letterbox_test.lua lovec .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local OakSpeech = require("src.ui.OakSpeech")
local function speechUp()
for _, s in ipairs(game.stack.states or {}) do
if getmetatable(s) == OakSpeech then return s end
end
return nil
end
-- 1000x700 is not a multiple of 160x144, so the UI blits at 4x (640x576)
-- with real bars above/below -- exactly where the seam shows
love.window.setMode(1000, 700)
U.wait(30)
U.wait(5)
U.tap(game, "start") -- skip the intro movie
U.wait(20)
U.tap(game, "a") -- title -> menu
U.wait(20)
U.tap(game, "a") -- NEW GAME
U.wait(30)
local speech
for _ = 1, 600 do
speech = speechUp()
if speech then break end
U.wait(2)
end
if not speech then
U.log("FAIL never reached Oak's speech")
return
end
U.log("Oak speech is up; letterboxWhite =", tostring(OakSpeech.letterboxWhite))
-- page through, shooting a few beats: the pic + box together is the shot
-- that shows whether the white reaches the box
for i = 1, 4 do
for _ = 1, 200 do
local top = game.stack:top()
if top and top ~= speech and top.done then break end
U.tap(game, "a")
U.wait(2)
if not speechUp() then break end
end
if not speechUp() then break end
U.wait(20)
U.shot(game, DIR .. ("/oak_%d.png"):format(i))
U.tap(game, "a")
U.wait(20)
end
U.log("done")
U.wait(30)
end
+53
View File
@@ -0,0 +1,53 @@
-- Driver: UI LAYOUT centered vs dynamic, same moment shot twice.
--
-- Only visible when the window letterboxes, so it resizes first: at an exact
-- multiple of 160x144 there is nowhere for a docked element to dock TO.
-- Shoots the overworld dialogue box and the START menu, the two pieces the
-- option moves.
-- POKEPORT_DRIVER=tests/drivers/ui_layout_option_test.lua lovec .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
love.window.setMode(1000, 700)
U.wait(30)
-- straight into the overworld, no intro
U.teleport(game, "PALLET_TOWN", 5, 6, "down")
U.wait(40)
local function shootBoth(tag)
-- START menu (Menu anchors "topright")
U.tap(game, "start")
U.wait(45)
U.shot(game, DIR .. ("/uilayout_%s_startmenu.png"):format(tag))
U.tap(game, "b")
U.wait(30)
-- a dialogue box (TextBox anchors "bottom"): read the sign by the door
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game, "UI LAYOUT check:\nthis box.", function() end))
for _ = 1, 400 do
local top = game.stack:top()
if top and top.done then break end
U.wait(2)
end
U.wait(30)
U.shot(game, DIR .. ("/uilayout_%s_textbox.png"):format(tag))
game.stack:pop()
U.wait(20)
end
game.save.options.uiLayout = "centered"
U.log("UI LAYOUT = centered (the default)")
U.wait(20)
shootBoth("centered")
game.save.options.uiLayout = "dynamic"
U.log("UI LAYOUT = dynamic")
U.wait(20)
shootBoth("dynamic")
U.log("done")
U.wait(20)
end
+123
View File
@@ -0,0 +1,123 @@
-- UI LAYOUT (save.options.uiLayout): "centered" keeps every element where it
-- was drawn in the 160x144 canvas, so the letterbox centres the whole screen
-- the way the port composed it before edge docking existed. "dynamic" opts
-- into docking: the dialogue box to the window's bottom edge, the START menu
-- to its top right.
--
-- Centered is the DEFAULT. Docking is a real change to where screen
-- furniture sits, so it is opt-in rather than something a player has to
-- discover and turn off.
--
-- One gate, at Renderer:setUIAnchor, so the switch covers the dialogue box,
-- its YES/NO, the START menu and anything added later without any of them
-- knowing the option exists.
-- luajit tests/engine/ui_layout_option.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Game = require("src.core.Game")
local Renderer = require("src.render.Renderer")
local SaveData = require("src.core.SaveData")
-- ------------------------------------------------------------- the default
T.eq(SaveData.newGame().options.uiLayout, "centered",
"a new game starts centered, not docked")
-- ------------------------------------------------------ reading the option
-- Only the explicit "dynamic" switches docking on. Everything else means
-- centered, which is what makes this safe for a save written before the
-- option existed: the key is simply absent and the player keeps the layout
-- they already had.
T.eq(Game.dynamicUI({ options = { uiLayout = "dynamic" } }), true,
"DYNAMIC turns edge docking on")
T.eq(Game.dynamicUI({ options = { uiLayout = "centered" } }), false,
"CENTERED leaves it off")
T.eq(Game.dynamicUI({ options = {} }), false,
"a save from before the option existed is centered")
T.eq(Game.dynamicUI({}), false, "a save with no options at all is centered")
T.eq(Game.dynamicUI(nil), false, "and no save at all is centered")
-- ------------------------------------------------------------- the gate
local function anchorsAfter(opts)
Renderer.uiAnchors = nil
Renderer.uiAnchorHold = opts.hold or false
Renderer.uiCentered = opts.centered or false
-- the dialogue box's own declaration (TextBox:draw)
Renderer:setUIAnchor(0, 96, 160, 48, "bottom")
-- and the START menu's (Menu:draw, anchor "topright")
Renderer:setUIAnchor(80, 0, 80, 88, "topright")
local n = #(Renderer.uiAnchors or {})
Renderer.uiAnchors, Renderer.uiAnchorHold, Renderer.uiCentered =
nil, false, false
return n
end
T.eq(anchorsAfter({ centered = true }), 0,
"CENTERED: neither the dialogue box nor the START menu leaves the canvas")
T.eq(anchorsAfter({ centered = false }), 2,
"DYNAMIC: both dock to the window edge")
-- the battle hold is unchanged by any of this -- a battle still keeps its own
-- prompts inside its screen even with DYNAMIC on (see battle_fixed_menu_scale)
T.eq(anchorsAfter({ centered = false, hold = true }), 0,
"a battle still holds the anchors while DYNAMIC is on")
T.eq(anchorsAfter({ centered = true, hold = true }), 0, "and with it off")
-- ------------------------------------------------- the scale half of it
-- CENTERED is a FIXED letterbox, so the UI must not follow the survey zoom
-- either: the box that stopped moving must not start resizing instead.
local g = love.graphics
local realDims, realPixelDims = g.getDimensions, g.getPixelDimensions
g.getDimensions = function() return 640, 576 end
g.getPixelDimensions = function() return 640, 576 end
T.eq(Renderer:fitScale(), 4, "the fixture window fits the classic surface at 4x")
local Zoom = require("src.render.Zoom")
local function scaleAt(offset, centered)
local oldOff, oldActive, oldCentered =
Zoom.offset, Renderer.worldActive, Renderer.uiCentered
-- worldActive true: a live overworld pass, the one case DYNAMIC steps down
Zoom.offset, Renderer.worldActive, Renderer.uiCentered = offset, true, centered
local s = Renderer:uiScale()
Zoom.offset, Renderer.worldActive, Renderer.uiCentered =
oldOff, oldActive, oldCentered
return s
end
T.eq(scaleAt(0, true), 4, "CENTERED at rest is the fit scale")
T.eq(scaleAt(-2, true), 4, "CENTERED zoomed out is STILL the fit scale")
T.eq(scaleAt(0, false), 4, "DYNAMIC at rest matches it")
T.eq(scaleAt(-2, false), 2, "DYNAMIC zoomed out steps the UI down, as before")
g.getDimensions, g.getPixelDimensions = realDims, realPixelDims
-- ------------------------------------------------------------- the row
local OptionsMenu = require("src.ui.OptionsMenu")
local Font = require("src.render.Font")
local Data = require("src.core.Data")
if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end
Font.load(Data)
local game = { data = Data, save = SaveData.newGame(),
stack = { states = {}, push = function() end,
pop = function() end, top = function() end } }
local menu = OptionsMenu.new(game)
local row
for _, r in ipairs(menu.rows) do
if r.id == "uiLayout" then row = r end
end
T.check(row ~= nil, "OPTIONS carries a UI LAYOUT row")
T.eq(row.value(game), "CENTERED", "and it opens on CENTERED")
row.step(game, 1)
T.eq(game.save.options.uiLayout, "dynamic", "stepping it turns docking on")
T.eq(row.value(game), "DYNAMIC", "and the row says so")
row.step(game, 1)
T.eq(game.save.options.uiLayout, "centered", "stepping again returns to it")
T.finish("ui layout option")