From 46bd0f6709bb0aa32090b0bf5fa3de9832f02b4d Mon Sep 17 00:00:00 2001 From: spiritsnails <307422241+spiritsnails@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:53:04 -0600 Subject: [PATCH] fix: battle screens keep their composition when they open a menu or prompt BATTLE SIZE "fixed" draws the battle as a discrete letterbox rather than filling the window, and BATTLE BG "world" composes it over the live map. Everything the battle then opens broke out of that composition, because each piece of the frame's geometry was read off a fact about THIS FRAME instead of about the battle: * Renderer:uiScale follows the survey zoom only while a world is behind the UI, gated on worldActive -- this frame's world pass. PartyMenu and ListMenu are opaque, so pushing one makes StateStack:visibleBase skip the map, no world pass runs, and the menu loses the step-down and blits a whole integer scale larger than the battle it just covered. Held with uiWorldHold, the same whole-stack rule uiFill and the battle dim already use. ("fill" hid this: it overrides the scale outright.) * Game:draw started the frame at visibleBase, so that same opaque menu cut the overworld -- and the world pass with it -- out of the frame entirely, collapsing a "world" backdrop to endFrame's flat black clear. A world-bg battle now keeps the frame starting from underneath itself (drawBaseInStack). Only the START of the draw moves; the clear stays keyed to the real visibleBase, so the menu still gets its opaque canvas and draws exactly as before. * worldZones was keyed to that same clear base, so it came out nil for a frame whose world pass HAD run -- dropping endFrame's world blit onto the UI zone list instead, smearing the party menu's own HP-bar palettes across a world-canvas-sized image. Keyed to whether the map drew. * endFrame's letterbox clear read letterboxWhite off visibleBase alone, so an opaque menu over a BG "white" battle flipped its surround to black the same way. Same whole-stack hold. * ChoiceBox bottom-anchored unconditionally, docking it to the WINDOW edge. That is only right when it rides the dialogue box beneath it, which is anchored there too; TextBox now passes the anchor and nothing else does, so the battle's switch offer and the shop/PC confirms stay over the screen that pushed them. * TextBox anchors likewise: a battle is a self-contained SCREEN, not the window, and pokered prints its text box in the same 160x144 tilemap as the HUD. The caught-mon nickname prompt was landing a whole letterbox below the blanked battle field it is printed on. BattleState.holdsUI- Anchors holds setUIAnchor off while a battle is in the stack; the overworld's own dialogue box still docks to the screen edge. --- src/battle/BattleState.lua | 10 ++ src/core/Game.lua | 67 ++++++++- src/render/Renderer.lua | 40 ++++- src/render/TextBox.lua | 4 +- src/ui/ChoiceBox.lua | 10 +- tests/engine/battle_fixed_menu_scale.lua | 178 +++++++++++++++++++++++ 6 files changed, 297 insertions(+), 12 deletions(-) create mode 100644 tests/engine/battle_fixed_menu_scale.lua diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 075572a9..a7f55aab 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -37,6 +37,16 @@ BattleState.isOpaque = true -- window reads as one continuous battle screen (no black bars). BattleState.letterboxWhite = true +-- A battle is a self-contained SCREEN, not the window. The overworld's +-- dialogue box docks to the window edge on purpose (Renderer:setUIAnchor) -- +-- a box floating in the middle of a zoomed-out map reads as detached. A +-- battle is the opposite: pokered draws its text box and YES/NO in the same +-- 160x144 tilemap as the HUD, and pulling them out to the window edge splits +-- the composition in two -- the caught-mon nickname prompt lands a whole +-- letterbox below the white field it is supposed to be printed on. Anchors +-- are held off for as long as a battle is in the stack. +BattleState.holdsUIAnchors = true + -- BATTLE LAYOUT: the classic 160x144 arrangement, or the widescreen one on -- a 304x144 surface (src/battle/WideBattle.lua). Only the composition -- differs; every battler, queue and animation below is shared. Menus and diff --git a/src/core/Game.lua b/src/core/Game.lua index 064974ed..b7f77b99 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -310,6 +310,49 @@ function Game.wideBattleInStack(stack) return nil end +-- Whether a state on the stack composes its own screen and so wants the +-- edge anchors held off (BattleState.holdsUIAnchors). Whole-stack, like +-- 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. +function Game.uiAnchorsHeldInStack(stack) + for i = #(stack and stack.states or {}), 1, -1 do + local state = stack.states[i] + if state and state.holdsUIAnchors then return true end + end + return false +end + +-- Where Game:draw starts drawing this frame. Normally the topmost opaque +-- state (StateStack:visibleBase) -- but BATTLE BG "world" composes the battle +-- over the LIVE map, and an opaque state pushed on top of it (the party menu, +-- the bag) becomes that base, cutting the overworld -- and with it the world +-- pass -- out of the frame entirely. The backdrop the battle established +-- then collapses to endFrame's flat black clear for as long as the menu is +-- up. So a world-bg battle keeps the frame starting from underneath itself +-- until it leaves the stack, the same hold uiFill and the dim already use. +-- +-- Only the START of the draw moves. The clear stays keyed to the real +-- visibleBase, so the menu still gets its opaque canvas and draws exactly as +-- before; what changes is the window AROUND its letterbox, which keeps +-- showing the map instead of going black. Both menus fill their own +-- 160x144 field first, so nothing beneath them shows through it. +function Game.drawBaseInStack(stack, visibleBase) + local states = stack and stack.states or {} + for i = visibleBase - 1, 1, -1 do + local state = states[i] + if state and state.bgMode and state:bgMode() == "world" then + -- restart the search from under the battle: the highest opaque state at + -- or below it (the overworld), not the menu sitting over it + for j = i, 1, -1 do + if states[j].isOpaque then return j end + end + return 1 + end + end + return visibleBase +end + -- Shift classic SGB zones to the centred UI. A full-width base zone extends -- into both margins, keeping the canvas' paper color continuous; narrower -- sprite and status zones move with the classic UI content. @@ -335,6 +378,10 @@ function Game:draw() -- white clear local base = self.stack:visibleBase() local worldBelow = self.stack.states[base] == self.overworld + -- a world-bg battle keeps the map drawing under whatever it opened, so the + -- world pass can run for a frame whose CLEAR is still an opaque menu's + local drawFrom = Game.drawBaseInStack(self.stack, base) + local worldDrawn = self.stack.states[drawFrom] == self.overworld -- A wide battle holds its 304px surface through every menu or prompt it -- opens. States that do not draw the wide battle composition are centred -- in that surface below, so their classic coordinates and hit testing stay @@ -359,8 +406,18 @@ function Game:draw() -- the stack for the same reason as uiFill above -- a prompt opened during -- the battle must not drop the dim for a frame. Renderer.battleDim = Game.worldBgBattleDim(self.stack) + -- ...and for the same reason the UI's own scale has to know the world is + -- still the backdrop while an opaque menu covers it. Renderer:uiScale + -- steps the UI down with the survey zoom only while a world is behind it, + -- gated on this frame's world pass -- which the party menu and the bag end + -- by being opaque. Without this hold they lose the step-down and blit at + -- full fit scale over a battle drawn at the zoomed-out one. + 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. + Renderer.uiAnchorHold = Game.uiAnchorsHeldInStack(self.stack) Renderer:beginFrame(worldBelow) - for i = self.stack:visibleBase(), #self.stack.states do + for i = drawFrom, #self.stack.states do local state = self.stack.states[i] local wideState = state and state.isWideBattleLayout and state:isWideBattleLayout() @@ -397,7 +454,13 @@ function Game:draw() if ModRuntime.wantsHook("render.zones") then zones = ModRuntime.call("render.zones", sameZones, self, zones) end - if worldBelow and self.overworld.sgbWorldZones then + -- Keyed to whether the map actually DREW, not to whether it is the clear's + -- base: an opaque menu over a world-bg battle still renders the world pass + -- (drawBaseInStack), and leaving worldZones nil there drops endFrame's + -- world blit onto the UI zone list instead -- the party menu's own HP-bar + -- palettes, in 160x144 space, smeared across a world-canvas-sized image. + -- That is the offset, red-for-green map behind the menu. + if worldDrawn and self.overworld.sgbWorldZones then worldZones = self.overworld:sgbWorldZones() end local viewport = Renderer:endFrame(zones, worldZones) diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index 0a47fc86..c6098be2 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -142,13 +142,22 @@ end function Renderer:uiScale() local S = self:fitScale() local off = Zoom.offset or 0 - -- Only follow the zoom when a world is actually on screen. 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 for - -- the map is meaningless. worldActive is this frame's answer -- beginFrame - -- clears it and beginWorldPass sets it -- so a state that draws no world - -- keeps the full fit scale. - if not self.worldActive 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 + -- for the map is meaningless. worldActive is this frame's answer -- + -- beginFrame clears it and beginWorldPass sets it -- so a state that draws + -- no world keeps the full fit scale. + -- + -- uiWorldHold (Game:draw) is the case worldActive cannot answer: a BATTLE + -- BG "world" battle stays the scene's backdrop while an OPAQUE state -- the + -- party menu, the bag -- covers it, whether or not a world pass ran under + -- that state. Reading worldActive alone drops the step-down there and + -- blits those menus a whole scale larger than the battle they cover. + -- Game.drawBaseInStack keeps the map drawing in the common case, so the two + -- normally agree; this holds the scale even when it cannot (a battle with + -- something other than the overworld beneath it). + if not (self.worldActive or self.uiWorldHold) then return S end if off >= 0 then return S end local floorS = math.ceil(S / 2) -- at most a 50% reduction local s = S + off -- one integer step per zoom-out step @@ -621,6 +630,10 @@ end -- pixels, and consumed by endFrame this frame only. -- anchor: "bottom" | "topright" | "topleft" | "bottomright" function Renderer:setUIAnchor(x, y, w, h, anchor) + -- 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. + if self.uiAnchorHold then return end self.uiAnchors = self.uiAnchors or {} self.uiAnchors[#self.uiAnchors + 1] = { x = x, y = y, w = w, h = h, anchor = anchor } @@ -740,6 +753,19 @@ function Renderer:endFrame(zones, worldZones) local stack = ok and Game and Game.stack local base = stack and stack.visibleBase and stack:visibleBase() local state = base and stack.states and stack.states[base] + -- A battle owns the surround it established until it leaves the stack. + -- Reading it off visibleBase alone loses that the moment the battle opens + -- an OPAQUE state -- the party menu, the bag -- because that state becomes + -- the base and answers no to letterboxWhite, flipping a white battle + -- surround to flat black for as long as the menu is up. Same whole-stack + -- hold as uiFill and the dim. + for i = #(stack and stack.states or {}), 1, -1 do + local s = stack.states[i] + if s and s.letterboxWhite then + state = s + break + end + end -- BATTLE BG "black" keeps the default black clear; "white" (and any -- non-battle state that opts in) uses the paper shade. "world" never -- reaches here -- it makes the battle non-opaque, so the world pass is diff --git a/src/render/TextBox.lua b/src/render/TextBox.lua index e67efc05..515c690a 100644 --- a/src/render/TextBox.lua +++ b/src/render/TextBox.lua @@ -241,7 +241,9 @@ function TextBox:update(dt) self.game.stack:push(ChoiceBox.new(self.game, function(yes) self.game.stack:pop() -- this text box, under the choice self.choice(yes) - end, { defaultNo = self.defaultNo, noSound = self.choiceNoSound })) + end, { defaultNo = self.defaultNo, noSound = self.choiceNoSound, + -- this box is anchored below it; the pair moves together + anchor = "bottom" })) end return end diff --git a/src/ui/ChoiceBox.lua b/src/ui/ChoiceBox.lua index abe0e1f7..1f9f41f8 100644 --- a/src/ui/ChoiceBox.lua +++ b/src/ui/ChoiceBox.lua @@ -16,6 +16,12 @@ function ChoiceBox.new(game, onChoose, opts) self.index = (opts and opts.defaultNo) and 2 or 1 -- BIT_NO_MENU_BUTTON_SOUND: PC-session prompts stay silent self.noSound = (opts and opts.noSound) or false + -- Only a choice box sitting on top of an ANCHORED dialogue box rides the + -- anchor with it (TextBox passes it). A bare one -- the battle's switch + -- offer, a shop or PC confirm -- belongs over the screen that pushed it, + -- and docking it to the window edge instead tears it off that screen by + -- however far the letterbox sits from the edge. + self.anchor = opts and opts.anchor or nil local box = Theme.choiceBox self.tx = (opts and opts.tx) or box.tx self.ty = (opts and opts.ty) or box.ty @@ -64,9 +70,9 @@ function ChoiceBox:draw() local tx, ty, tw, th = self.tx, self.ty, self.tw, self.th -- rides the same bottom anchor as the dialogue box it sits above, so the -- pair travels together (the anchor keeps each element's gap from the edge) - local r = self.game and self.game.renderer + local r = self.anchor and self.game and self.game.renderer if r and r.setUIAnchor then - r:setUIAnchor(tx * 8, ty * 8, tw * 8, th * 8, "bottom") + r:setUIAnchor(tx * 8, ty * 8, tw * 8, th * 8, self.anchor) end Font.drawBox(tx, ty, tw, th) love.graphics.setColor(0, 0, 0, 1) diff --git a/tests/engine/battle_fixed_menu_scale.lua b/tests/engine/battle_fixed_menu_scale.lua new file mode 100644 index 00000000..fd5e27d2 --- /dev/null +++ b/tests/engine/battle_fixed_menu_scale.lua @@ -0,0 +1,178 @@ +-- BATTLE SIZE "fixed" + BATTLE BG "world": the battle draws as a fixed +-- letterbox over the map, stepped down with the survey zoom. The PKMN and +-- ITEM menus it opens have to stay that size, and its YES/NO prompt has to +-- stay inside it. +-- +-- Both broke the same way -- by reading a fact about THIS FRAME instead of +-- about the battle: +-- +-- * Renderer:uiScale follows the zoom only while a world is behind the UI, +-- gated on worldActive (beginWorldPass set it this frame). A "world"-bg +-- battle is non-opaque so the map keeps drawing under it -- but PartyMenu +-- and ListMenu ARE opaque, so pushing one makes StateStack:visibleBase +-- skip the map, the world pass never runs, and the menu loses the +-- step-down and blits a whole integer scale larger than the battle it +-- just covered. ("fill" hid this: it overrides the scale outright.) +-- +-- * ChoiceBox bottom-anchored unconditionally, which docks it to the +-- WINDOW's bottom edge. That is right only when it is riding the +-- dialogue box below it, which is anchored there too. The battle draws +-- its own text inside the battle canvas, so the switch offer's YES/NO was +-- the only piece of that prompt flung to the window edge -- further off +-- the smaller the fixed battle is drawn. +-- luajit tests/engine/battle_fixed_menu_scale.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Renderer = require("src.render.Renderer") +local Zoom = require("src.render.Zoom") +local TextBox = require("src.render.TextBox") +local ChoiceBox = require("src.ui.ChoiceBox") + +-- ------------------------------------------------- the UI scale holds its size + +-- pin the window so the scales below are exact numbers rather than whatever +-- the runner happens to be sized at (this suite also runs under real LOVE) +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 + +-- 640x576 fits the 160x144 classic surface at exactly 4x +T.eq(Renderer:fitScale(), 4, "the fixture window fits the classic surface at 4x") + +local function uiScaleWith(offset, worldActive, hold) + local oldOffset, oldActive, oldHold = + Zoom.offset, Renderer.worldActive, Renderer.uiWorldHold + Zoom.offset, Renderer.worldActive, Renderer.uiWorldHold = + offset, worldActive, hold + local s = Renderer:uiScale() + Zoom.offset, Renderer.worldActive, Renderer.uiWorldHold = + oldOffset, oldActive, oldHold + return s +end + +T.eq(uiScaleWith(0, true, false), 4, "unzoomed, the UI is the fit scale") +T.eq(uiScaleWith(-2, true, false), 2, + "zoomed out over a live world pass, the UI steps down with it") +T.eq(uiScaleWith(-2, false, false), 4, + "with no world behind it at all (title screen), the zoom is ignored") + +-- the fix: the party menu / bag ended the world pass, but the battle under +-- them is still drawn over the map, so the surface must not grow +T.eq(uiScaleWith(-2, false, true), 2, + "an opaque menu over a world-bg battle keeps the battle's stepped-down scale") +T.eq(uiScaleWith(0, false, true), 4, + "and the hold changes nothing when the player never zoomed out") + +g.getDimensions, g.getPixelDimensions = realDims, realPixelDims + +-- the hold is the same whole-stack answer the dim already uses, so a menu +-- opened over the battle cannot drop it for a frame (Game:draw wires +-- uiWorldHold to worldBgBattleDim ~= nil; battle_fit_option covers the scan) +local Game = require("src.core.Game") +local BattleState = require("src.battle.BattleState") +-- isOpaque false is what "world" actually does to a live battle (BattleState +-- drops it per-instance at start, so the class default stays opaque for every +-- other battle) -- and it is the whole reason the map draws underneath +local worldBattle = setmetatable( + { game = { save = { options = { battleBg = "world" } } }, isOpaque = false }, + { __index = BattleState }) +T.check(Game.worldBgBattleDim({ states = { {}, worldBattle, {} } }) ~= nil, + "the stack scan the hold reads still finds the battle under an opaque menu") + +-- --------------------------------------------- the backdrop holds under menus + +-- The battle establishes the surround too, and the same opaque menu used to +-- take that over: it becomes visibleBase, the overworld stops drawing, and +-- the world the battle was composed over collapses to a flat black clear. +local overworld = { isOpaque = true } +local menu = { isOpaque = true } -- PartyMenu / ListMenu +local whiteBattle = setmetatable( + { game = { save = { options = { battleBg = "white" } } } }, + { __index = BattleState }) +local function stack(...) return { states = { ... }, + visibleBase = function(self) + for i = #self.states, 1, -1 do + if self.states[i].isOpaque then return i end + end + return 1 + end } end + +local s = stack(overworld, worldBattle, menu) +T.eq(s:visibleBase(), 3, "the menu is the topmost opaque state, as before") +T.eq(Game.drawBaseInStack(s, s:visibleBase()), 1, + "but the frame still starts at the overworld, so the map keeps drawing") + +-- unchanged everywhere else +local s2 = stack(overworld, worldBattle) +T.eq(s2:visibleBase(), 1, "the battle alone already drew from the overworld") +T.eq(Game.drawBaseInStack(s2, s2:visibleBase()), 1, "and still does") +local s3 = stack(overworld, whiteBattle, menu) +T.eq(Game.drawBaseInStack(s3, s3:visibleBase()), 3, + "a white-bg battle has no map to hold, so nothing moves") +local s4 = stack(overworld, menu) +T.eq(Game.drawBaseInStack(s4, s4:visibleBase()), 2, + "and a menu outside a battle is untouched") +T.eq(Game.drawBaseInStack(stack(overworld), 1), 1, "a lone base is safe") +T.eq(Game.drawBaseInStack(nil, 1), 1, "and so is no stack at all") + +-- ------------------------------------------- the YES/NO stays with its screen + +-- a bare choice box -- the battle's switch offer, a shop or PC confirm -- has +-- no anchored dialogue box under it to ride +T.eq(ChoiceBox.new({}, function() end).anchor, nil, + "a bare choice box does not anchor itself to the window edge") +T.eq(ChoiceBox.new({}, function() end, { defaultNo = true }).anchor, nil, + "and neither does one that only asked to start on NO") + +-- ...but the one a dialogue box opens does, because that box is anchored too +local game = { save = { player = {} }, data = { text = {} } } +game.stack = { + states = {}, + push = function(self, s) table.insert(self.states, s) end, + pop = function(self) return table.remove(self.states) end, + top = function(self) return self.states[#self.states] end, +} +game.input = { + wasPressed = function() return false end, + isDown = function() return false end, +} + +local box = TextBox.new(game, "Shall we heal\nyour POKEMON?", nil, + { choice = function() end }) +game.stack:push(box) +for _ = 1, 600 do + if box.done then break end + box:update(1 / 60) +end +T.check(box.done, "the question finished typing") +box:update(1 / 60) -- the update after done is the one that pushes the choice +T.eq(#game.stack.states, 2, "the dialogue box opened its YES/NO") +T.eq(game.stack:top().anchor, "bottom", + "a choice box riding a dialogue box shares its bottom anchor") + +-- ...and inside a battle even THAT one stays put, because the battle is the +-- screen: the caught-mon nickname prompt prints on the blanked battle field +-- (BattleState.blankForAskName), and docking it to the window edge drops it a +-- whole letterbox below what it is printed on. +T.eq(BattleState.holdsUIAnchors, true, "a battle composes its own screen") +T.eq(Game.uiAnchorsHeldInStack(stack(overworld, worldBattle)), true, + "so the anchors are held while it is up") +T.eq(Game.uiAnchorsHeldInStack(stack(overworld, worldBattle, {})), true, + "including for the text box and YES/NO it opens above itself") +T.eq(Game.uiAnchorsHeldInStack(stack(overworld)), false, + "the overworld's own dialogue box still docks to the screen edge") +T.eq(Game.uiAnchorsHeldInStack(nil), false, "and no stack is safe") + +Renderer.uiAnchors = nil +Renderer.uiAnchorHold = true +Renderer:setUIAnchor(0, 96, 160, 48, "bottom") +T.eq(Renderer.uiAnchors, nil, "a held anchor never reaches the frame") +Renderer.uiAnchorHold = false +Renderer:setUIAnchor(0, 96, 160, 48, "bottom") +T.eq(#(Renderer.uiAnchors or {}), 1, "and an unheld one still does") +Renderer.uiAnchors = nil + +T.finish("battle fixed menu scale")