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.
This commit is contained in:
spiritsnails
2026-08-01 22:53:04 -06:00
parent e6c1ed8753
commit 46bd0f6709
6 changed files with 297 additions and 12 deletions
+10
View File
@@ -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
+65 -2
View File
@@ -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)
+33 -7
View File
@@ -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
+3 -1
View File
@@ -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
+8 -2
View File
@@ -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)
+178
View File
@@ -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")