diff --git a/src/render/TextBox.lua b/src/render/TextBox.lua index 85e457b3..e67efc05 100644 --- a/src/render/TextBox.lua +++ b/src/render/TextBox.lua @@ -39,6 +39,7 @@ function TextBox.new(game, text, onDone, opts) self.onDone = onDone self.choice = opts and opts.choice self.defaultNo = opts and opts.defaultNo + self.choiceNoSound = opts and opts.noSound self.auto = opts and opts.auto local box = Theme.textBox or {} self.boxTx = box.tx or BOX_TX @@ -240,7 +241,7 @@ 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 })) + end, { defaultNo = self.defaultNo, noSound = self.choiceNoSound })) end return end diff --git a/src/script/Commands.lua b/src/script/Commands.lua index c8e468d7..4f9380dc 100644 --- a/src/script/Commands.lua +++ b/src/script/Commands.lua @@ -78,7 +78,7 @@ end -- play_cry's waitForButton form keeps that cry gate but hands the box back -- to the A/B path once the cry is over -- see TextBox's opts.auto.wait -- (#247, #251). -function Commands.show_text(ctx, textId, subs) +function Commands.show_text(ctx, textId, subs, extraOpts) local text = ctx.game.data.text[textId] if not text and ctx.overworld then text = ctx.game.data:resolveText(ctx.overworld.map.def.label, textId) @@ -128,6 +128,12 @@ function Commands.show_text(ctx, textId, subs) end end end + if extraOpts then + opts = opts or {} + for k, v in pairs(extraOpts) do + opts[k] = v + end + end ctx.game.stack:push(TextBox.new(ctx.game, text, function() runner:resume() end, opts)) @@ -139,16 +145,18 @@ function Commands.jump(ctx, target) end -- ask [subs]: show text, then a YES/NO box; result lands in --- ctx.lastCheck. subs are forwarded to show_text's {token} filling. +-- ctx.lastCheck. The box rides opts.choice (TextBox.lua), so the YES/NO +-- menu pops up over the still-visible text once it finishes typing, the +-- same as every hand-written prompt (OverworldController, BattleState, +-- OakSpeech) -- not a bare ChoiceBox after the text box has already been +-- cleared with A (DisplayTextID's WaitForTextScrollButtonPress never fires +-- ahead of a YES/NO box in the original; home/text.asm). function Commands.ask(ctx, textId, subs) - Commands.show_text(ctx, textId, subs) - local ChoiceBox = require("src.ui.ChoiceBox") local runner = ctx.runner - ctx.game.stack:push(ChoiceBox.new(ctx.game, function(yes) + Commands.show_text(ctx, textId, subs, { choice = function(yes) ctx.lastCheck = yes runner:resume() - end)) - runner:yield() + end }) end function Commands.face_player(ctx) @@ -596,13 +604,16 @@ local function askNickname(ctx, mon) and ctx.game.data.pokemon[mon.species].name) or mon.species -- Prefer the real text label; fall back to the BattleState wording. + local textId, subs if ctx.game.data.text and ctx.game.data.text._DoYouWantToNicknameText then - Commands.show_text(ctx, "_DoYouWantToNicknameText", { RAM = name }) + textId, subs = "_DoYouWantToNicknameText", { RAM = name } else - Commands.show_text(ctx, Strings("Do you want to\ngive a nickname\nto %s?", name)) + textId = Strings("Do you want to\ngive a nickname\nto %s?", name) end - local ChoiceBox = require("src.ui.ChoiceBox") - ctx.game.stack:push(ChoiceBox.new(ctx.game, function(yes) + -- opts.choice (TextBox.lua): the YES/NO box rides over the still-visible + -- question instead of a bare ChoiceBox popping up after an A press + -- already cleared it (same fix as Commands.ask). + Commands.show_text(ctx, textId, subs, { choice = function(yes) if not yes then ctx.lastCheck = success runner:resume() @@ -616,9 +627,7 @@ local function askNickname(ctx, mon) runner:resume() end, }) - end)) - runner:yield() - ctx.lastCheck = success + end }) end -- give_pokemon : _GivePokemon (engine/events/ diff --git a/src/ui/BoxMenu.lua b/src/ui/BoxMenu.lua index ca7462d1..332e90ef 100644 --- a/src/ui/BoxMenu.lua +++ b/src/ui/BoxMenu.lua @@ -164,18 +164,18 @@ local function release(game) local mon = box[list.index] if not mon then return end local name = monName(game, mon) - local ChoiceBox = require("src.ui.ChoiceBox") game.stack:push(TextBox.new(game, - Strings("Once released,\n%s is\ngone forever. OK?", name), function() - game.stack:push(ChoiceBox.new(game, function(yes) + Strings("Once released,\n%s is\ngone forever. OK?", name), nil, { + defaultNo = true, noSound = true, + choice = function(yes) if not yes then return end table.remove(box, list.index) require("src.core.Sound").playCry(game.data, mon.species) game.stack:push(TextBox.new(game, Strings("%s was\nreleased outside.\fBye %s!", name, name))) list:removeCurrent() - end, { defaultNo = true, noSound = true })) - end)) + end, + })) end, })) end @@ -196,16 +196,16 @@ local function changeBox(game) onChoose = function(item, list) -- the original asks BEFORE switching ("When you change a #MON -- BOX, data will be saved. OK?"); declining aborts the change - local ChoiceBox = require("src.ui.ChoiceBox") game.stack:push(TextBox.new(game, - Strings("When you change a\nPOKéMON BOX, data\nwill be saved. OK?"), function() - game.stack:push(ChoiceBox.new(game, function(yes) + Strings("When you change a\nPOKéMON BOX, data\nwill be saved. OK?"), nil, { + noSound = true, + choice = function(yes) if not yes then return end game.save.currentBox = item.value if game.writeSave then game:writeSave() end list:close() - end, { noSound = true })) - end)) + end, + })) end, })) end diff --git a/src/ui/StartMenu.lua b/src/ui/StartMenu.lua index 04a526f8..ecbfe50d 100644 --- a/src/ui/StartMenu.lua +++ b/src/ui/StartMenu.lua @@ -53,7 +53,6 @@ function StartMenu.new(game) -- (PrintSaveScreenText) table.insert(items, { label = Strings("SAVE"), onSelect = function() local TextBox = require("src.render.TextBox") - local ChoiceBox = require("src.ui.ChoiceBox") local badges = require("src.inventory.Badges").count(game.data, game.save) local owned = 0 for _ in pairs(game.save.pokedex and game.save.pokedex.owned or {}) do @@ -64,8 +63,8 @@ function StartMenu.new(game) game.save.player.name or "RED", badges, owned, math.floor(t / 3600), math.floor(t / 60) % 60) game.stack:push(TextBox.new(game, - panel .. Strings("\fWould you like to\nSAVE the game?"), function() - game.stack:push(ChoiceBox.new(game, function(yes) + panel .. Strings("\fWould you like to\nSAVE the game?"), nil, { + choice = function(yes) if not yes then return end -- "Now saving..." beat before the write (save.asm -- NowSavingString), then GameSavedText + SFX_SAVE @@ -75,8 +74,8 @@ function StartMenu.new(game) game.stack:push(TextBox.new(game, Strings("%s saved\nthe game!", game.save.player.name or "RED"))) end)) - end)) - end)) + end, + })) end }) table.insert(items, { label = Strings("OPTION"), onSelect = function() @@ -105,12 +104,12 @@ function StartMenu.new(game) -- to the title after a confirm (defaultNo guards accidental quits) table.insert(items, { label = Strings("QUIT"), onSelect = function() local TextBox = require("src.render.TextBox") - local ChoiceBox = require("src.ui.ChoiceBox") - game.stack:push(TextBox.new(game, Strings("RETURN TO MAIN\nMENU?"), function() - game.stack:push(ChoiceBox.new(game, function(yes) + game.stack:push(TextBox.new(game, Strings("RETURN TO MAIN\nMENU?"), nil, { + defaultNo = true, + choice = function(yes) if yes then game:returnToTitle() end - end, { defaultNo = true })) - end)) + end, + })) end }) local hooked = Runtime.call("ui.start_menu.items", sameItems, game, items) diff --git a/tests/engine/ask_yesno_overlap.lua b/tests/engine/ask_yesno_overlap.lua new file mode 100644 index 00000000..0b641072 --- /dev/null +++ b/tests/engine/ask_yesno_overlap.lua @@ -0,0 +1,82 @@ +-- The script DSL's `ask` command (src/script/Commands.lua) has to bring its +-- YES/NO box up WHILE the question text is still on screen, the same as +-- every hand-written prompt +-- (OverworldController's healer, BattleState's nickname ask, OakSpeech, +-- MoveLearnMenu -- all TextBox opts.choice). DisplayTextID never clears +-- the box itself before a YES/NO row; ManualTextScroll's button wait is +-- what `ask` used to run through, closing the text box first and popping a +-- bare ChoiceBox in afterwards. Commands.ask now rides opts.choice like +-- the rest of the engine instead of chaining show_text + a second push. +-- luajit tests/engine/ask_yesno_overlap.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local Commands = require("src.script.Commands") +local TextBox = require("src.render.TextBox") +local Timing = require("src.core.Timing") + +local function newGame() + 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, + } + -- data = {} downstream (Sound.play(game.data, ...)) keeps ChoiceBox's + -- un-guarded beep on the headless no-audio path, same as + -- rebind_swap_clear_bug589.lua's fixture. + game.input = { + queue = {}, + wasPressed = function(self, btn) return self.queue[btn] or false end, + isDown = function() return false end, + } + return game +end + +local game = newGame() +game.data.text.TEST_ASK = "Shall we heal\nyour POKEMON?" + +local resumeCount = 0 +local runner = { yield = function() end, + resume = function() resumeCount = resumeCount + 1 end } +local ctx = { game = game, runner = runner } + +Commands.ask(ctx, "TEST_ASK") + +eq(#game.stack.states, 1, "ask opens the question as one text box, not a text box plus a bare choice box up front") +local box = game.stack:top() +check(getmetatable(box) == TextBox, "the state on the stack is the text box") + +-- type the question out +for _ = 1, 600 do + if box.done then break end + box:update(1 / 60) +end +check(box.done, "the question finished typing") +eq(#game.stack.states, 1, "the text box does not pop itself on its own once typing is done (#589-parity)") + +-- the very next update is what used to require an A press to clear the box +-- first; now it pushes the YES/NO box straight over the still-open text +box:update(1 / 60) +eq(#game.stack.states, 2, "a YES/NO box appears while the question text is still up") +check(game.stack.states[1] == box, "the text box is still there, underneath") +local choiceBox = game.stack:top() +check(choiceBox ~= box, "the choice box is a separate state on top of it") + +-- answer YES (the default cursor position) and let the 15-frame hold run +-- out, same hold rebind_swap_clear_bug589.lua's settleChoice() drains +game.input.queue = { a = true } +choiceBox:update(1 / 60) +game.input.queue = {} +for _ = 1, Timing.YES_NO_ANSWER do choiceBox:update(1 / 60) end + +eq(#game.stack.states, 0, "answering pops both the choice box and the text box") +eq(ctx.lastCheck, true, "YES lands in ctx.lastCheck") +eq(resumeCount, 1, "the script runner resumes exactly once") + +T.finish("ask_yesno_overlap")