diff --git a/src/core/Game.lua b/src/core/Game.lua index 832507ce..dcc87fba 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -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 diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 6f93dc93..6ce20c58 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -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, diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index c6098be2..6eb57edf 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -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. diff --git a/src/ui/OakSpeech.lua b/src/ui/OakSpeech.lua index a69b590f..4a0c1d66 100644 --- a/src/ui/OakSpeech.lua +++ b/src/ui/OakSpeech.lua @@ -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 diff --git a/src/ui/OptionsMenu.lua b/src/ui/OptionsMenu.lua index 78b8c025..5d066ce6 100644 --- a/src/ui/OptionsMenu.lua +++ b/src/ui/OptionsMenu.lua @@ -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) diff --git a/tests/drivers/oak_speech_letterbox_test.lua b/tests/drivers/oak_speech_letterbox_test.lua new file mode 100644 index 00000000..e0341e69 --- /dev/null +++ b/tests/drivers/oak_speech_letterbox_test.lua @@ -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 diff --git a/tests/drivers/ui_layout_option_test.lua b/tests/drivers/ui_layout_option_test.lua new file mode 100644 index 00000000..93124e55 --- /dev/null +++ b/tests/drivers/ui_layout_option_test.lua @@ -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 diff --git a/tests/engine/ui_layout_option.lua b/tests/engine/ui_layout_option.lua new file mode 100644 index 00000000..eb3e8b5d --- /dev/null +++ b/tests/engine/ui_layout_option.lua @@ -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")