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
+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")