diff --git a/docs/modding.md b/docs/modding.md index b13fb048..7bd5df7a 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -374,9 +374,12 @@ identifiers: `pc_box_withdraw`, `pc_box_deposit`, `pc_box_release`, `battle.bottom_ui_visible` and `battle.status_hud_visible` independently control the battle text/menu layer and the HP/status panels. Both receive `(next, state)` and default to `true`, so vanilla rendering is unchanged. -Pushed text boxes also pass through `battle.bottom_ui_visible`; a wrapper that -only owns battle presentation should return `false` only for its active battle -or text-box state. +Text boxes and YES/NO prompts pushed above a battle inherit a `false` result +for that battle, so hiding the bottom layer cannot leave their white backing +behind under another overlay. Text boxes also pass through the hook as their +own state, preserving selective control outside a battle; a wrapper that only +owns battle presentation should return `false` only for its active battle or +text-box state. `core.logic_speed` receives `(next, game)` once per `Game:logicSpeed()` call (once per frame). Vanilla behavior resolves the per-category GAME SPEED diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 6cee785a..7f850be0 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -27,6 +27,7 @@ local Timing = require("src.core.Timing") local TrainerAI = require("src.battle.TrainerAI") local TurnOrder = require("src.battle.TurnOrder") local TypeChart = require("src.battle.TypeChart") +local UIVisibility = require("src.battle.UIVisibility") local RomText = require("src.core.RomText") local Strings = require("src.core.Strings") local WideBattle = require("src.battle.WideBattle") @@ -134,9 +135,7 @@ function BattleState:sgbPalettes() end function BattleState:bottomUIVisible() - if not Runtime.wantsHook("battle.bottom_ui_visible") then return true end - return Runtime.call("battle.bottom_ui_visible", function() return true end, - self) ~= false + return UIVisibility.bottomVisible(self, true) end function BattleState:statusHUDVisible() diff --git a/src/battle/UIVisibility.lua b/src/battle/UIVisibility.lua new file mode 100644 index 00000000..858acc07 --- /dev/null +++ b/src/battle/UIVisibility.lua @@ -0,0 +1,39 @@ +-- Shared visibility rules for battle-owned UI states. Text and choice +-- overlays live above BattleState on the state stack, but they are still part +-- of its bottom UI layer and must inherit that layer's visibility. + +local Runtime = require("src.mods.Runtime") + +local UIVisibility = {} + +local function enclosingBattle(state) + local stack = state and state.game and state.game.stack + local states = stack and stack.states + local found = false + for i = #(states or {}), 1, -1 do + local candidate = states[i] + if candidate == state then found = true end + if found and candidate and candidate.isBattle then return candidate end + end + return nil +end + +-- queryState keeps the existing TextBox contract: a mod may still decide +-- visibility for that individual box. ChoiceBox only inherits the enclosing +-- battle decision, so field YES/NO prompts never become battle-hook states. +function UIVisibility.bottomVisible(state, queryState) + if not Runtime.wantsHook("battle.bottom_ui_visible") then return true end + local battle = enclosingBattle(state) + if battle and battle ~= state + and Runtime.call("battle.bottom_ui_visible", + function() return true end, battle) == false then + return false + end + if queryState or battle == state then + return Runtime.call("battle.bottom_ui_visible", + function() return true end, state) ~= false + end + return true +end + +return UIVisibility diff --git a/src/render/TextBox.lua b/src/render/TextBox.lua index 290c4ecc..d2c64dd3 100644 --- a/src/render/TextBox.lua +++ b/src/render/TextBox.lua @@ -7,7 +7,7 @@ -- the text is exhausted and A is pressed, then calls onDone. local Font = require("src.render.Font") -local Runtime = require("src.mods.Runtime") +local UIVisibility = require("src.battle.UIVisibility") local Theme = require("src.ui.Theme") local Timing = require("src.core.Timing") @@ -346,11 +346,7 @@ function TextBox:update(dt) end function TextBox:draw() - if Runtime.wantsHook("battle.bottom_ui_visible") - and Runtime.call("battle.bottom_ui_visible", function() return true end, - self) == false then - return - end + if not UIVisibility.bottomVisible(self, true) then return end -- The dialogue box belongs against the bottom of the screen, not floating -- in the middle of a zoomed-out letterbox. Declared per frame; the -- renderer blits this region to the screen edge and the rest of the UI diff --git a/src/ui/ChoiceBox.lua b/src/ui/ChoiceBox.lua index 1f9f41f8..dae0cce6 100644 --- a/src/ui/ChoiceBox.lua +++ b/src/ui/ChoiceBox.lua @@ -1,6 +1,7 @@ -- YES/NO choice box (InitYesNoTextBoxParameters: above the text box, right). local Font = require("src.render.Font") +local UIVisibility = require("src.battle.UIVisibility") local Theme = require("src.ui.Theme") local Strings = require("src.core.Strings") local Timing = require("src.core.Timing") @@ -67,6 +68,7 @@ function ChoiceBox:update(dt) end function ChoiceBox:draw() + if not UIVisibility.bottomVisible(self, false) then return end 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) diff --git a/tests/mod_qol_hooks_tests.lua b/tests/mod_qol_hooks_tests.lua index dfa425b2..da1a2392 100644 --- a/tests/mod_qol_hooks_tests.lua +++ b/tests/mod_qol_hooks_tests.lua @@ -12,6 +12,7 @@ local Zoom = require("src.render.Zoom") local ListMenu = require("src.ui.ListMenu") local NamingScreen = require("src.ui.NamingScreen") local TextBox = require("src.render.TextBox") +local ChoiceBox = require("src.ui.ChoiceBox") local PartyMenu = require("src.ui.PartyMenu") local Player = require("src.world.Player") local Music = require("src.core.Music") @@ -180,6 +181,23 @@ do text:draw() check(seen == text, "pushed text boxes use the same visibility hook") unsub() + + local battle = setmetatable({ isBattle = true }, BattleState) + local game = { stack = { states = {} } } + text = setmetatable({ game = game }, TextBox) + local choice = setmetatable({ game = game }, ChoiceBox) + game.stack.states = { battle, text, choice } + local queried = {} + unsub = wrap("battle.bottom_ui_visible", function(_, state) + queried[#queried + 1] = state + return state ~= battle + end) + text:draw() + choice:draw() + check(queried[1] == battle and queried[2] == battle and #queried == 2, + "battle overlays inherit a hidden bottom layer without drawing backings") + unsub() + check(BattleState.bottomUIVisible({ phase = "moveSelect" }), "battle bottom UI returns when the hook is removed")