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