From f00e867be819cd24f5ffd047090fc0c771cddd3e Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Thu, 3 Sep 2026 01:36:18 -0400 Subject: [PATCH] CLOSES #2130, CLOSES #2131, CLOSES #2132, CLOSES #2134, CLOSES #2135, CLOSES #2136, CLOSES #2137, CLOSES #2138 --- src/battle/gen2/Battle.lua | 15 +- src/render/TextBox.lua | 16 ++- src/script/gen2/Movement.lua | 16 +++ src/script/gen2/Vm.lua | 4 + src/ui/gen2/BattleState.lua | 61 +++++++- src/ui/gen2/Chrome.lua | 6 +- src/ui/gen2/GenderSelect.lua | 3 +- src/ui/gen2/InitClock.lua | 39 ++++-- src/ui/gen2/PcMenu.lua | 69 +++++++-- src/ui/gen2/SaveMenu.lua | 109 +++++++++++---- src/world/gen2/Npc.lua | 31 ++++- src/world/gen2/Player.lua | 38 ++--- src/world/gen2/World.lua | 23 ++- tests/drivers/crystal_day_of_week_bug2138.lua | 75 ++++++++++ tests/drivers/crystal_lance_hof_bug2135.lua | 93 +++++++++++++ tests/drivers/crystal_reload_text_bug2130.lua | 97 +++++++++++++ tests/drivers/crystal_rival_shove_bug2136.lua | 83 +++++++++++ .../drivers/crystal_sendout_name_bug2134.lua | 116 ++++++++++++++++ tests/drivers/crystal_text_arrow_bug2132.lua | 128 +++++++++++++++++ tests/drivers/gen2_save_overwrite_bug2131.lua | 120 ++++++++++++++++ ...pcmenu_changebox_save_translation_test.lua | 56 +++++++- .../gen2_save_menu_translation_test.lua | 131 +++++++++++++----- .../gen2_textbox_arrow_paper_bug2132.lua | 95 +++++++++++++ tests/engine/gen2_yesno_typing_bug2085.lua | 18 +++ tests/gen2_battle_test.lua | 3 +- tests/gen2_battle_ui_test.lua | 68 ++++++++- tests/gen2_catch_tutorial_test.lua | 9 +- tests/gen2_clock_test.lua | 45 ++++++ tests/gen2_crystal_gender_test.lua | 15 +- tests/gen2_field_anim_test.lua | 59 ++++++++ tests/gen2_npc_interact_test.lua | 7 +- tests/gen2_trainerhouse_test.lua | 7 +- tests/gen2_trainers_test.lua | 37 +++++ tests/gen2_world_test.lua | 41 ++++++ 34 files changed, 1588 insertions(+), 145 deletions(-) create mode 100644 tests/drivers/crystal_day_of_week_bug2138.lua create mode 100644 tests/drivers/crystal_lance_hof_bug2135.lua create mode 100644 tests/drivers/crystal_reload_text_bug2130.lua create mode 100644 tests/drivers/crystal_rival_shove_bug2136.lua create mode 100644 tests/drivers/crystal_sendout_name_bug2134.lua create mode 100644 tests/drivers/crystal_text_arrow_bug2132.lua create mode 100644 tests/drivers/gen2_save_overwrite_bug2131.lua create mode 100644 tests/engine/gen2_textbox_arrow_paper_bug2132.lua diff --git a/src/battle/gen2/Battle.lua b/src/battle/gen2/Battle.lua index 6dbe0120..5ae211c5 100644 --- a/src/battle/gen2/Battle.lua +++ b/src/battle/gen2/Battle.lua @@ -501,6 +501,11 @@ function Battle:monName(mon) return mon.nickname or mon.name or mon.species or "?" end +-- ../pokecrystal/data/text/battle.asm:240-246 +function Battle.sentOutText(trainerName, monName) + return Strings("%s\nsent out\v%s!", trainerName, monName) +end + function Battle:moveDef(moveId) return self.data.moves and self.data.moves[moveId] or nil end @@ -3554,8 +3559,7 @@ function Battle:resolveFaints() replacement = true, hp = self.enemy.hp or 0, status = self.enemy.status or false, level = self.enemy.level, experience = self.enemy.experience, - text = Strings("%s sent out %s!", - self.trainer and self.trainer.name or "Foe", + text = Battle.sentOutText(self.trainer and self.trainer.name or "Foe", self:monName(self.enemy)) }) Runtime.emit("battle.battler_switched", { battle = self, side = self:sideRecord(self.enemy), battler = self.enemy, @@ -4546,8 +4550,7 @@ function Battle:switchEnemy(index) self:emit({ kind = "send", side = "enemy", mon = self.enemy, hp = self.enemy.hp or 0, status = self.enemy.status or false, level = self.enemy.level, experience = self.enemy.experience, - text = Strings("%s sent out %s!", trainerName, - self:monName(self.enemy)) }) + text = Battle.sentOutText(trainerName, self:monName(self.enemy)) }) Runtime.emit("battle.battler_switched", { battle = self, side = self:sideRecord(self.enemy), battler = self.enemy, previous = outgoing, @@ -5313,8 +5316,8 @@ function Battle:forcedReplacement(side, index) self:emit({ kind = "send", side = "enemy", mon = mon, replacement = true, hp = mon.hp or 0, status = mon.status or false, level = mon.level, experience = mon.experience, - text = Strings("%s sent out %s!", - (self.trainer and self.trainer.name) or "Foe", self:monName(mon)) }) + text = Battle.sentOutText((self.trainer and self.trainer.name) or "Foe", + self:monName(mon)) }) Runtime.emit("battle.battler_switched", { battle = self, side = self:sideRecord(mon), battler = mon, previous = previous, diff --git a/src/render/TextBox.lua b/src/render/TextBox.lua index 74cdaa42..412a8a1a 100644 --- a/src/render/TextBox.lua +++ b/src/render/TextBox.lua @@ -640,10 +640,19 @@ function TextBox:draw() and (self.game.save.generation == 2 or self.game.save.version == "gold") local Chrome = gold and Chrome2 or nil local drawGlyph, finishGlyph = Font.drawCode, nil + -- pokegold home/joypad.asm:430 + local arrowOn = self.blink % 60 < 30 + if gold then arrowOn = self.blink % 32 < 16 end + local arrowX = (self.boxTx + self.boxTw - 2) * 8 + local arrowY = (self.boxTy + self.boxTh - 1) * 8 - (gold and 0 or 4) if Chrome then local base = paper and { paper, paper, paper, { 0, 0, 0 } } or Chrome.DEFAULT_BOX_PALETTE Chrome.paletteBox(self.boxTx, self.boxTy, self.boxTw, self.boxTh, base) + if arrowOn and self:arrowVisible() then + -- ../pokecrystal/home/text.asm:630 + Chrome.paletteFill(arrowX, arrowY, 8, 8, base) + end local _, dg, fg = Chrome.paletteGlyphs(base) drawGlyph, finishGlyph = dg, fg else @@ -697,16 +706,11 @@ function TextBox:draw() pen = pen + Font.advanceOf(code) end end - -- pokegold home/joypad.asm:430 - local arrowOn = self.blink % 60 < 30 - if gold then arrowOn = self.blink % 32 < 16 end if self:arrowVisible() and arrowOn then -- page-advance cursor: glyph $EE by default, the blinking down arrow -- the original prints via `ld a, "▼"` (home/text.asm) -- pokegold home/text.asm:549 - drawGlyph(Theme.moreArrow or 0xEE, - (self.boxTx + self.boxTw - 2) * 8, - (self.boxTy + self.boxTh - 1) * 8 - (gold and 0 or 4)) + drawGlyph(Theme.moreArrow or 0xEE, arrowX, arrowY) end if finishGlyph then finishGlyph() end love.graphics.setColor(1, 1, 1, 1) diff --git a/src/script/gen2/Movement.lua b/src/script/gen2/Movement.lua index 413d5390..13658773 100644 --- a/src/script/gen2/Movement.lua +++ b/src/script/gen2/Movement.lua @@ -179,6 +179,22 @@ end Movement.TELEPORT_RISE_HEIGHT = 0x10 Movement.TELEPORT_FALL_HEIGHT = 0 +-- engine/overworld/map_objects.asm:1796-1817 +Movement.JUMP_Y = { + -4, -6, -8, -10, -11, -12, -12, -12, + -11, -10, -9, -8, -6, -4, 0, 0, +} + +function Movement.jumpYOffset(progress, frames) + local curve = Movement.JUMP_Y + local n = #curve + local t = ((progress or 0) - 1) * (n - 1) / math.max((frames or n) - 1, 1) + 1 + local idx = math.floor(t) + if idx < 1 then idx = 1 end + if idx >= n then return curve[n] end + return math.floor(curve[idx] + (curve[idx + 1] - curve[idx]) * (t - idx) + 0.5) +end + local DIR_BYTE = { down = 0, up = 1, left = 2, right = 3 } -- The `step ` family ($0c-$0f), which is what InitMovementBuffer fills diff --git a/src/script/gen2/Vm.lua b/src/script/gen2/Vm.lua index f585e981..375d5749 100644 --- a/src/script/gen2/Vm.lua +++ b/src/script/gen2/Vm.lua @@ -1151,6 +1151,8 @@ local function runCmd(self, cmd, op) return "end" end if self.reloadMapFn then self.reloadMapFn(op ~= "refreshmap") end + -- engine/overworld/scripting.asm:1209 + if op ~= "refreshmap" then self:waitFrames(1) end elseif op == "catchtutorial" then -- `catchtutorial battle_type` runs the DUDE's catch demo -- (engine/events/catch_tutorial.asm): the player's name is parked in @@ -1182,6 +1184,8 @@ local function runCmd(self, cmd, op) -- `jp Script_reloadmap`, so the setup script (and its ForceMapMusic row) -- really does run here. if self.reloadMapFn then self.reloadMapFn(true) end + -- engine/overworld/scripting.asm:1209 + self:waitFrames(1) elseif op == "winlosstext" then -- Overrides the struct's win/loss text for this battle only; a 0 -- argument zeroes that pointer (engine/overworld/scripting.asm:651) diff --git a/src/ui/gen2/BattleState.lua b/src/ui/gen2/BattleState.lua index c07b087c..16ecfaea 100644 --- a/src/ui/gen2/BattleState.lua +++ b/src/ui/gen2/BattleState.lua @@ -228,10 +228,11 @@ local PAGE, SCROLL, LINE = "\f", "\v", "\n" local SEPARATORS = "([^" .. LINE .. PAGE .. SCROLL .. "]*)([" .. LINE .. PAGE .. SCROLL .. "])" local function paginate(text) - local pages, rows = {}, {} + local pages, rows, carried = {}, {}, {} local function flush(scroll) if #rows > 0 then pages[#pages + 1] = table.concat(rows, LINE) end rows = scroll and { rows[#rows] or "" } or {} + if scroll then carried[#pages + 1] = true end end for chunk, sep in (tostring(text or "") .. PAGE):gmatch(SEPARATORS) do rows[#rows + 1] = chunk @@ -239,7 +240,23 @@ local function paginate(text) elseif sep == SCROLL then flush(true) end end if #pages == 0 then pages[1] = tostring(text or "") end - return pages + return pages, carried +end + +-- ../pokecrystal/home/text.asm:517-526 +local function foldPages(pages, carried) + local out, outCarried = {}, {} + for i, page in ipairs(pages) do + local lines = Chrome.wrap(page, TEXT_WIDTH) + out[#out + 1] = #lines > TEXT_ROWS + and table.concat(lines, LINE, 1, TEXT_ROWS) or page + outCarried[#out] = carried[i] + for j = TEXT_ROWS + 1, #lines do + out[#out + 1] = table.concat(lines, LINE, j - TEXT_ROWS + 1, j) + outCarried[#out] = true + end + end + return out, outCarried end function BattleState.fillScale(winW, winH) @@ -496,7 +513,7 @@ function BattleState.new(game, opts) -- (core.asm:2978-2980, 3354): this is where the mon's frontpic first -- appears, where ANIM_SEND_OUT_MON plays and where the HUD comes up. self:push({ kind = "send", side = "enemy", mon = enemy, - text = Strings("%s sent out %s!", trainerName, self:name(enemy)) }) + text = Battle.sentOutText(trainerName, self:name(enemy)) }) end end local player = self.battle and self.battle.player @@ -1671,6 +1688,12 @@ end function BattleState:advanceQueue() local event = table.remove(self.queue, 1) + self.messagePages, self.messageCarry = nil, nil + local pendingSend = self.pendingSendOut + if pendingSend then + self.pendingSendOut = nil + self:startSendOut(pendingSend.side, pendingSend.mon) + end -- StartBattle runs `call z, UpdateEnemyHUD` AFTER BattleStartMessage returns, -- and only for a wild battle (engine/battle/core.asm:7808-7817): the appeared -- line is read against an empty HUD area and the bar comes up on the step @@ -1995,7 +2018,7 @@ function BattleState:advanceQueue() end end if event.text and not event.lineShown then - self.message = event.text + self:showPages(event.text) self.typedText = nil -- engine/battle/core.asm:8733 if self.startHuds then @@ -2111,7 +2134,12 @@ function BattleState:advanceQueue() -- player's voluntary switch through SendOutPlayerMon (:3796). Without it -- the replacement simply appeared, which with two of a species back to back -- reads as one mon growing a second health bar. - self:startSendOut(event.side, event.mon) + -- ../pokecrystal/engine/battle/core.asm:3146-3147 + if self.messagePages then + self.pendingSendOut = { side = event.side, mon = event.mon } + else + self:startSendOut(event.side, event.mon) + end end end @@ -3470,10 +3498,12 @@ end -- One prompt page in the box, with the rest held for the presses `para` and -- `cont` wait on (home/text.asm:403-448). function BattleState:showPages(text) - local pages = paginate(text) + local pages, carried = foldPages(paginate(text)) self.messagePages = #pages > 1 and pages or nil + self.messageCarried = carried self.messagePage = 1 self.message = pages[1] + self.messageCarry = nil self.messageTimer = MESSAGE_FRAMES end @@ -3483,8 +3513,18 @@ function BattleState:nextPage() local i = self.messagePage + 1 self.messagePage = i self.message = pages[i] + self.messageCarry = self.messageCarried and self.messageCarried[i] + and pages[i] or nil self.messageTimer = MESSAGE_FRAMES - if i >= #pages then self.messagePages = nil end + if i >= #pages then + self.messagePages = nil + -- ../pokecrystal/engine/battle/core.asm:3146-3147 + local pending = self.pendingSendOut + if pending then + self.pendingSendOut = nil + self:startSendOut(pending.side, pending.mon) + end + end return true end @@ -4209,7 +4249,14 @@ function BattleState:syncTyper() for i = #lines, TEXT_ROWS + 1, -1 do lines[i] = nil end self.typer = Typer.new(self.game) self.typer:start(lines) + -- ../pokecrystal/home/text.asm:521-523 + if self.messageCarry == text and lines[1] then + local carried = Typer.new(self.game) + carried:start({ lines[1] }) + self.typer.shown = math.max(self.typer.shown, carried.total) + end end + self.messageCarry = nil end return self.typer ~= nil and not self.typer:done() end diff --git a/src/ui/gen2/Chrome.lua b/src/ui/gen2/Chrome.lua index 03c987c2..b2139c60 100644 --- a/src/ui/gen2/Chrome.lua +++ b/src/ui/gen2/Chrome.lua @@ -341,10 +341,12 @@ function Chrome.wrap(text, width) end -- Print wrapped text from (tx, ty) downward, at most `rows` lines. -function Chrome.printWrapped(text, tx, ty, width, rows) +-- ../pokecrystal/home/text.asm:473 +function Chrome.printWrapped(text, tx, ty, width, rows, step) + step = step or 1 local lines = Chrome.wrap(text, width) for i = 1, math.min(#lines, rows or #lines) do - Chrome.print(lines[i], tx, ty + i - 1) + Chrome.print(lines[i], tx, ty + (i - 1) * step) end return #lines end diff --git a/src/ui/gen2/GenderSelect.lua b/src/ui/gen2/GenderSelect.lua index 2881c651..e8e03d1b 100644 --- a/src/ui/gen2/GenderSelect.lua +++ b/src/ui/gen2/GenderSelect.lua @@ -111,7 +111,8 @@ function GenderSelect:drawPanel() G.rectangle("fill", 0, 0, Chrome.SCREEN_W * 8, Chrome.SCREEN_H * 8) G.setColor(1, 1, 1, 1) Chrome.textbox(SAY_X, SAY_Y, SAY_W, SAY_H) - Chrome.printWrapped(self.text, SAY_TEXT_X, SAY_TEXT_Y, SAY_W, SAY_H - 1) + -- ../pokecrystal/home/text.asm:473 + Chrome.printWrapped(self.text, SAY_TEXT_X, SAY_TEXT_Y, SAY_W, 2, 2) Chrome.box(BOX_X, BOX_Y, BOX_W, BOX_H) for i, option in ipairs(GenderSelect.OPTIONS) do local row = TEXT_Y + (i - 1) * ROW_STEP diff --git a/src/ui/gen2/InitClock.lua b/src/ui/gen2/InitClock.lua index 15b3b242..f233444c 100644 --- a/src/ui/gen2/InitClock.lua +++ b/src/ui/gen2/InitClock.lua @@ -67,8 +67,9 @@ local MORN_HOUR, DAY_HOUR, NITE_HOUR = 4, 10, 18 local DAYS = Clock.DAY_NAMES InitClock.DAYS = DAYS -function InitClock:wantsFillScale() return true end -function InitClock:drawsWidescreen() return true end +-- ../pokecrystal/engine/rtc/timeset.asm:385 +function InitClock:wantsFillScale() return self.mode ~= "day" end +function InitClock:drawsWidescreen() return self.mode ~= "day" end -- PrintHour (engine/rtc/timeset.asm:672) is GetTimeOfDayString + PlaceString, -- then AdjustHourForAMorPM as a left-aligned two-digit number. So the cart @@ -123,6 +124,8 @@ function InitClock.new(game, opts) local self = setmetatable({}, InitClock) self.game = game self.mode = opts.mode == "day" and "day" or "clock" + -- ../pokecrystal/engine/rtc/timeset.asm:385 + self.isOpaque = self.mode ~= "day" self.save = opts.save or (game and game.save) self.onDone = opts.onDone self.autoConfirm = opts.autoConfirm or false @@ -144,11 +147,24 @@ end -- The current question, split into its pages. function InitClock:pages() + local question = self:question() + if self.pagesText == question and self.pagesCache then + return self.pagesCache + end local out = {} - for page in (self:question() .. "\f"):gmatch("(.-)\f") do - if page ~= "" then out[#out + 1] = page end + for page in (question .. "\f"):gmatch("(.-)\f") do + if page ~= "" then + local lines = {} + for line in (page .. "\n"):gmatch("(.-)\n") do lines[#lines + 1] = line end + -- ../pokecrystal/home/text.asm:502 + out[#out + 1] = table.concat({ lines[1], lines[2] }, "\n") + for i = 3, #lines do + out[#out + 1] = table.concat({ lines[i - 1], lines[i] }, "\n") + end + end end if #out == 0 then out[1] = "" end + self.pagesText, self.pagesCache = question, out return out end @@ -329,7 +345,8 @@ local function arrow(tx, ty, up) end function InitClock:drawPanel() - Chrome.clear() + -- ../pokecrystal/engine/rtc/timeset.asm:22 + if self.mode ~= "day" then Chrome.clear() end local value = self:display() if value then local bx, by, bw, bh, arrowX, tx, ty = self:pickerBox() @@ -343,12 +360,14 @@ function InitClock:drawPanel() -- The question (and the confirmations) share the bottom textbox every other -- Gold prompt uses. Chrome.textbox(0, 12, 18, 4) - Chrome.printWrapped(self:pageText(), 1, 14, 18, 3) + -- ../pokecrystal/home/text.asm:473 + Chrome.printWrapped(self:pageText(), 1, 14, 18, 2, 2) if self:confirming() then - Chrome.box(14, 6, 6, 5) - Chrome.print(Strings("YES"), 16, 7) - Chrome.print(Strings("NO"), 16, 9) - Chrome.cursor(15, self.yesNo == 1 and 7 or 9) + -- ../pokecrystal/home/menu.asm:418 + Chrome.box(14, 7, 6, 5) + Chrome.print(Strings("YES"), 16, 8) + Chrome.print(Strings("NO"), 16, 10) + Chrome.cursor(15, self.yesNo == 1 and 8 or 10) end end diff --git a/src/ui/gen2/PcMenu.lua b/src/ui/gen2/PcMenu.lua index d9ae72cb..10e88d8f 100644 --- a/src/ui/gen2/PcMenu.lua +++ b/src/ui/gen2/PcMenu.lua @@ -29,6 +29,7 @@ local SaveMenu = require("src.ui.gen2.SaveMenu") local Screens = require("src.ui.Screens") local Sound = require("src.core.Sound") local Strings = require("src.core.Strings") +local Typer = require("src.ui.gen2.Typer") -- _PCMonHoldingMailText (data/text/common_2.asm), the refusal -- BillsPC_MovePKMNMenu prints instead of opening the list. Two pages, because @@ -202,9 +203,8 @@ end -- engine/menus/save.asm:40 ChangeBoxSaveGame function PcMenu:beginChangeBox(index) self.changeBox = index - self.savePhase = "confirm" + self:setSavePhase("confirm") self.saveChoice = 1 - self.saveTimer = 0 self.saved = nil local existed = self.saveExists if existed == nil then existed = Save.exists() end @@ -217,15 +217,20 @@ function PcMenu:refuseChangeBox() self.saveTimer = 0 end +function PcMenu:setSavePhase(phase) + self.savePhase = phase + self.savePage = 1 + self.saveTimer = 0 +end + function PcMenu:acceptChangeBox() if self.saveChoice == 2 then return self:refuseChangeBox() end if self.savePhase == "confirm" and self.existed then - self.savePhase = "overwrite" + self:setSavePhase("overwrite") self.saveChoice = 1 return end - self.savePhase = "saving" - self.saveTimer = 0 + self:setSavePhase("saving") end -- `pop de / ld a, e / ld [wCurBox], a` sits between SaveBox and @@ -241,32 +246,54 @@ function PcMenu:writeChangeBox() end end -function PcMenu:savePrompt() +function PcMenu:savePromptText() if self.savePhase == "overwrite" then - return SaveMenu.twoLines(Strings(SaveMenu.OVERWRITE_PROMPT_SOURCE)) + return Strings(SaveMenu.OVERWRITE_PROMPT_SOURCE) end if self.savePhase == "saving" then - return SaveMenu.twoLines(Strings(SaveMenu.SAVING_PROMPT_SOURCE)) + return Strings(SaveMenu.SAVING_PROMPT_SOURCE) end if self.savePhase == "done" then if self.saved then local name = (self.save.player and self.save.player.name) or "GOLD" - return SaveMenu.twoLines(Strings("%s saved\nthe game.", name)) + return Strings("%s saved\nthe game.", name) end - return SaveMenu.twoLines(Strings("Could not save.")) + return Strings("Could not save.") end - return SaveMenu.twoLines(Strings(CHANGE_BOX_SAVE_SOURCE)) + return Strings(CHANGE_BOX_SAVE_SOURCE) +end + +function PcMenu:savePages() + local text = self:savePromptText() + if self.savePagesText ~= text or not self.savePagesCache then + self.savePagesText = text + self.savePagesCache = SaveMenu.pagesOf(text) + end + return self.savePagesCache +end + +function PcMenu:savePrompt() + local pages = self:savePages() + return pages[self.savePage or 1] or pages[1] +end + +-- ../pokecrystal/engine/menus/save.asm:209 SaveTheGame_yesorno +function PcMenu:saveYesNoVisible(pages) + if self.savePhase ~= "confirm" and self.savePhase ~= "overwrite" then + return false + end + return (self.savePage or 1) >= #(pages or self:savePages()) end function PcMenu:updateChangeBox() + Typer.step(self) -- SavingDontTurnOffThePower is DelayFrames, not a prompt: no button does -- anything until the sequence runs out (engine/menus/save.asm:55). if self.savePhase == "saving" then self.saveTimer = self.saveTimer + 1 if self.saveTimer >= SaveMenu.SAVING_FRAMES then self:writeChangeBox() - self.savePhase = "done" - self.saveTimer = 0 + self:setSavePhase("done") end return end @@ -281,6 +308,14 @@ function PcMenu:updateChangeBox() local input = self.game and self.game.input if not input then return end + -- ../pokecrystal/home/text.asm:502 _ContText + if not self:saveYesNoVisible() then + if input:wasPressed("a") or input:wasPressed("b") then + self:playSfx("Sfx_ReadText2") + self.savePage = (self.savePage or 1) + 1 + end + return + end if input:wasPressed("up") or input:wasPressed("down") then self.saveChoice = self.saveChoice == 1 and 2 or 1 elseif input:wasPressed("a") then @@ -442,10 +477,14 @@ function PcMenu:drawPanel() if self.savePhase then -- ChangeBoxSaveGame's MenuTextbox, then YesNoBox over the box list. Chrome.box(0, 12, 20, 6) - local lines = self:savePrompt() + local pages = self:savePages() + local lines = pages[self.savePage or 1] or pages[1] Chrome.print(lines[1] or "", 1, 14) Chrome.print(lines[2] or "", 1, 16) - if self.savePhase == "confirm" or self.savePhase == "overwrite" then + if (self.savePage or 1) < #pages and Typer.arrowOn(self) then + Chrome.print(SaveMenu.DOWN_ARROW, SaveMenu.ARROW_X, SaveMenu.ARROW_Y) + end + if self:saveYesNoVisible(pages) then Chrome.box(YESNO_X, YESNO_Y, YESNO_W, YESNO_H) Chrome.print(Strings("YES"), YESNO_X + 2, YESNO_Y + 1) Chrome.print(Strings("NO"), YESNO_X + 2, YESNO_Y + 3) diff --git a/src/ui/gen2/SaveMenu.lua b/src/ui/gen2/SaveMenu.lua index 76214b08..b5781823 100644 --- a/src/ui/gen2/SaveMenu.lua +++ b/src/ui/gen2/SaveMenu.lua @@ -24,7 +24,6 @@ -- one-line call. local Chrome = require("src.ui.gen2.Chrome") -local Logger = require("src.core.Logger") local Save = require("src.core.gen2.Save") local Sound = require("src.core.Sound") local Strings = require("src.core.Strings") @@ -64,7 +63,7 @@ local YESNO_X, YESNO_Y, YESNO_W, YESNO_H = 0, 7, 6, 5 -- AlreadyASaveFileText (AskOverwriteSaveFile, engine/menus/save.asm:47) and -- SavingDontTurnOffThePower's own line -- one \n-joined translatable key -- each, used both by this screen's own prompt() below and, through the --- SOURCE/twoLines() exports at the bottom of this file, by the PC's CHANGE +-- SOURCE/pagesOf() exports at the bottom of this file, by the PC's CHANGE -- BOX save (src/ui/gen2/PcMenu.lua:savePrompt()), which shares these exact -- same two cart messages. One key per prompt lets a translation write one -- whole, freely reordered sentence instead of two fragments translated in @@ -77,27 +76,46 @@ local YESNO_X, YESNO_Y, YESNO_W, YESNO_H = 0, 7, 6, 5 -- string harvester only recognizes a literal inside Strings.source(...), -- not a computed expression, so a concat call here would quietly never -- reach a translator. -local OVERWRITE_PROMPT_SOURCE = Strings.source("There is already a\nsave file. Is it") +-- ../pokecrystal/data/text/common_3.asm:202 _AlreadyASaveFileText +local OVERWRITE_PROMPT_SOURCE = Strings.source("There is already a\nsave file. Is it\vOK to overwrite?") local SAVING_PROMPT_SOURCE = Strings.source("SAVING… DON'T TURN\nOFF THE POWER.") --- Splits a translated "line one\nline two" string back into the two-slot --- table drawPanel's fixed Chrome.print calls expect. No "\n" at all (a --- single-line message, or German's one-line SAVING prompt) lands whole on --- the first slot, matching the untranslated code's own { text, "" } shape. --- --- Only the first "\n" splits, since this box has room for exactly two --- lines. A third line would otherwise draw as a raw newline byte -- garbage --- glyph data -- with no other sign anything went wrong, so this warns once --- per string instead. -local warnedTooManyLines = {} -local function twoLines(text) - local first, second = text:match("^(.-)\n(.*)$") - if second and second:find("\n", 1, true) and not warnedTooManyLines[text] then - warnedTooManyLines[text] = true - Logger.warn("SaveMenu: translation of %q has more than two lines; " .. - "only the first two fit this box", text) +-- ../pokecrystal/home/text.asm:630 LoadBlinkingCursor +local DOWN_ARROW = "\xe2\x96\xbc" +local ARROW_X, ARROW_Y = 18, 17 + +-- ../pokecrystal/home/text.asm:479 Paragraph +-- ../pokecrystal/home/text.asm:520 _ContTextNoPause +local function pagesOf(body) + local pages = {} + for chunk in (tostring(body) .. "\f"):gmatch("(.-)\f") do + local flat, pos, scrolled = {}, 1, false + while true do + local brk = chunk:find("[\n\v]", pos) + local line = brk and chunk:sub(pos, brk - 1) or chunk:sub(pos) + if line ~= "" then flat[#flat + 1] = { line, scrolled } end + if not brk then break end + scrolled = chunk:sub(brk, brk) == "\v" + pos = brk + 1 + end + local page + for _, entry in ipairs(flat) do + if not page then + page = { entry[1] } + pages[#pages + 1] = page + elseif entry[2] then + page = { page[#page], entry[1] } + pages[#pages + 1] = page + elseif #page >= 2 then + page = { entry[1] } + pages[#pages + 1] = page + else + page[#page + 1] = entry[1] + end + end end - return { first or text, second or "" } + if #pages == 0 then pages[1] = { "" } end + return pages end function SaveMenu:wantsFillScale() return true end @@ -132,6 +150,13 @@ function SaveMenu:playSfx(id) SaveMenu.playSaveSfx(self.game, id) end +-- ../pokecrystal/home/joypad.asm:392 +function SaveMenu:playSfxNamed(name) + local data = self.game and self.game.data + local sfx = data and data.audio and data.audio.sfx + if sfx and sfx[Sound.resolve(data, name)] then Sound.play(data, name) end +end + function SaveMenu:finish(saved) if self.onDone then self.onDone(saved) end end @@ -152,9 +177,11 @@ function SaveMenu:enterPhase(phase) self.phase = phase self.timer = 0 self.typedPhase = phase + self.pages = pagesOf(self:promptText()) + self.page = 1 local pinned = (phase == "saving" or phase == "done") and "MID" or nil self.typer = Typer.new(self.game, { speed = pinned }) - self.typer:start(self:prompt()) + self.typer:start(self.pages[1]) end function SaveMenu:accept() @@ -215,6 +242,15 @@ function SaveMenu:update(_dt) local input = self.game and self.game.input if not input then return end if Typer.typing(self) then return end + -- ../pokecrystal/home/text.asm:502 _ContText + if self.page < #self.pages then + if input:wasPressed("a") or input:wasPressed("b") then + self.page = self.page + 1 + self.typer:start(self.pages[self.page]) + self:playSfxNamed("Sfx_ReadText2") + end + return + end if input:wasPressed("up") or input:wasPressed("down") then self.choice = self.choice == 1 and 2 or 1 return @@ -230,28 +266,36 @@ end -- The two lines the speech box holds, in the cart's own wording. `line` puts -- the second one on the box's lower line; `cont` scrolls, which the overwrite -- prompt uses for its third line and which this shows as a second page. -function SaveMenu:prompt() +function SaveMenu:promptText() if self.phase == "overwrite" then -- AlreadyASaveFileText when the file is this player's; AnotherSaveFileText -- when the ID differs. Only the first can happen here. - return twoLines(Strings(OVERWRITE_PROMPT_SOURCE)) + return Strings(OVERWRITE_PROMPT_SOURCE) end if self.phase == "saving" then - return twoLines(Strings(SAVING_PROMPT_SOURCE)) + return Strings(SAVING_PROMPT_SOURCE) end if self.phase == "done" then if self.saved then - return twoLines(Strings("%s saved\nthe game.", self:playerName())) + return Strings("%s saved\nthe game.", self:playerName()) end - return twoLines(Strings("Could not save.")) + return Strings("Could not save.") end - return twoLines(Strings("Would you like to\nsave the game?")) + return Strings("Would you like to\nsave the game?") +end + +function SaveMenu:prompt() + if self.typedPhase == self.phase and self.pages then + return self.pages[self.page] or self.pages[1] + end + return pagesOf(self:promptText())[1] end -- ../pokecrystal/engine/menus/save.asm:209 SaveTheGame_yesorno function SaveMenu:yesNoVisible() if self.phase ~= "confirm" and self.phase ~= "overwrite" then return false end - return self.typedPhase == self.phase and not Typer.typing(self) + if self.typedPhase ~= self.phase or Typer.typing(self) then return false end + return self.page >= #self.pages end function SaveMenu:drawPanel() @@ -278,6 +322,11 @@ function SaveMenu:drawPanel() Chrome.print(lines[1] or "", 1, 14) Chrome.print(lines[2] or "", 1, 16) + if self.typedPhase == self.phase and not Typer.typing(self) + and self.page < #self.pages and Typer.arrowOn(self) then + Chrome.print(DOWN_ARROW, ARROW_X, ARROW_Y) + end + if self:yesNoVisible() then Chrome.box(YESNO_X, YESNO_Y, YESNO_W, YESNO_H) Chrome.print(Strings("YES"), YESNO_X + 2, YESNO_Y + 1) @@ -307,6 +356,8 @@ SaveMenu.SAVED_TAIL_FRAMES = SAVED_TAIL_FRAMES SaveMenu.SAVED_FRAMES = SAVED_FRAMES SaveMenu.OVERWRITE_PROMPT_SOURCE = OVERWRITE_PROMPT_SOURCE SaveMenu.SAVING_PROMPT_SOURCE = SAVING_PROMPT_SOURCE -SaveMenu.twoLines = twoLines +SaveMenu.pagesOf = pagesOf +SaveMenu.DOWN_ARROW = DOWN_ARROW +SaveMenu.ARROW_X, SaveMenu.ARROW_Y = ARROW_X, ARROW_Y return SaveMenu diff --git a/src/world/gen2/Npc.lua b/src/world/gen2/Npc.lua index 4c33c4e2..8869b635 100644 --- a/src/world/gen2/Npc.lua +++ b/src/world/gen2/Npc.lua @@ -359,6 +359,26 @@ function NPC:scriptStep(dir) return true end +-- engine/overworld/movement.asm:741 +function NPC:scriptJump(dir) + if self.moving then return false end + self.stepDir = dir or self.facing + if not self.fixedFacing and not self.sliding then + self.facing = self.stepDir + end + local d = Map.DELTA[self.stepDir] + if not d then + self.stepDir = nil + return false + end + self.targetX, self.targetY = self.cellX + d[1] * 2, self.cellY + d[2] * 2 + self.moving, self.jumping = true, true + self.inGrass, self.grassShake = false, nil + self.progress = 0 + self.frozen = true + return true +end + -- StepFunction_TeleportFrom / _TeleportTo (engine/overworld/map_objects.asm). -- `from` is sixteen frames of OBJECT_ACTION_SPIN on the spot and then sixteen -- more spinning while OBJECT_JUMP_HEIGHT walks OBJECT_SPRITE_Y_OFFSET up a @@ -575,7 +595,7 @@ function NPC:update(map, entities) if self.moving then -- NormalStep's begin-of-step grass work (engine/overworld/movement.asm:657-674); -- UpdateTallGrassFlags only RE-tests while IN_GRASS is set (map_objects.asm:226). - if self.progress == 0 and map then + if self.progress == 0 and map and not self.jumping then local grass = NPC.grassAt(map, self.targetX, self.targetY) if self.inGrass then self.inGrass = grass end self.grassShake = grass or nil @@ -585,17 +605,26 @@ function NPC:update(map, entities) -- a two-cell move over one step (src/world/gen2/Player.lua:150 does the -- same), and `stepFrames` is what lets it keep pace with a bike. local frames = self.stepFrames or STEP_FRAMES + -- engine/overworld/map_objects.asm:1129 + if self.jumping then frames = STEP_FRAMES * 2 end local moved = math.floor(self.progress * 16 / frames) local dx = (self.targetX or self.cellX) - self.cellX local dy = (self.targetY or self.cellY) - self.cellY self.px = self.cellX * 16 + dx * moved self.py = self.cellY * 16 + dy * moved + if self.jumping then + self.spriteYOffset = Movement.jumpYOffset(self.progress, frames) + end if self.progress >= frames then self.cellX, self.cellY = self.targetX, self.targetY self.targetX, self.targetY = nil, nil self.px, self.py = self.cellX * 16, self.cellY * 16 self.moving = false self.stepDir = nil + if self.jumping then + self.jumping = nil + self.spriteYOffset = 0 + end self.stepFlip = not self.stepFlip -- CopyCoordsTileToLastCoordsTile -> SetTallGrassFlags at the step's end -- (map_objects.asm:196-208, :247). diff --git a/src/world/gen2/Player.lua b/src/world/gen2/Player.lua index 3a068961..e967b628 100644 --- a/src/world/gen2/Player.lua +++ b/src/world/gen2/Player.lua @@ -2,6 +2,7 @@ -- Draws via shared SpriteRenderer (same 16x96 facing layout as Gen 1). local Map = require("src.world.gen2.Map") +local Movement = require("src.script.gen2.Movement") local Runtime = require("src.mods.Runtime") local SpriteRenderer = require("src.render.SpriteRenderer") @@ -17,12 +18,6 @@ local TURN_FRAMES = 4 -- at the walking rate, which is what stops a bike step flickering the legs. Player.STEP_FRAMES = STEP_FRAMES --- engine/overworld/map_objects.asm:1815 -local JUMP_Y = { - -4, -6, -8, -10, -11, -12, -12, -12, - -11, -10, -9, -8, -6, -4, 0, 0, -} - -- FacingFish*'s loose rod OAM, offset from the sprite's top-left, and which -- 8x8 of the sheet's rod row it draws (data/sprites/facings.asm:122-152). local ROD_OAM = { @@ -149,8 +144,9 @@ function Player:scriptStep(dir) -- A scripted step names its own STEP_* on the cart (SurfStartStep is a slow -- step), so it never inherits the bike's shorter one. self.stepFrames = STEP_FRAMES - self.facing = dir or self.facing - local d = Map.DELTA[self.facing] + -- engine/overworld/map_objects.asm:284-294 + if not self.fixedFacing then self.facing = dir or self.facing end + local d = Map.DELTA[dir or self.facing] if not d then return false end self.targetX, self.targetY = self.cellX + d[1], self.cellY + d[2] self.moving = true @@ -159,6 +155,21 @@ function Player:scriptStep(dir) return true end +-- engine/overworld/movement.asm:741 +function Player:scriptJump(dir) + if self.moving then return false end + if not self.fixedFacing then self.facing = dir or self.facing end + local d = Map.DELTA[dir or self.facing] + if not d then return false end + self.targetX, self.targetY = self.cellX + d[1] * 2, self.cellY + d[2] * 2 + self.moving, self.jumping = true, true + self.bumpFrames = nil + self.inGrass, self.grassShake = false, nil + self.progress = 0 + self.stepFrames = STEP_FRAMES * 2 + return true +end + -- CounterclockwiseSpinAction's .facings, seeded from the current direction by -- Movement_step_dig -- map_object_action.asm:96-152, movement.asm:113-116 local SPIN_FACINGS = { "down", "right", "up", "left" } @@ -235,16 +246,7 @@ function Player:update() if self.jumping then -- engine/overworld/map_objects.asm:1796 -- one table entry per cart frame, -- tweened across our doubled step (#1713) - local t = (self.progress - 1) * (#JUMP_Y - 1) - / math.max(frames - 1, 1) + 1 - local idx = math.floor(t) - if idx < 1 then idx = 1 end - if idx >= #JUMP_Y then - self.spriteYOffset = JUMP_Y[#JUMP_Y] - else - self.spriteYOffset = math.floor( - JUMP_Y[idx] + (JUMP_Y[idx + 1] - JUMP_Y[idx]) * (t - idx) + 0.5) - end + self.spriteYOffset = Movement.jumpYOffset(self.progress, frames) end if self.progress >= frames then self.cellX, self.cellY = self.targetX, self.targetY diff --git a/src/world/gen2/World.lua b/src/world/gen2/World.lua index 64226dc6..4da6f5d8 100644 --- a/src/world/gen2/World.lua +++ b/src/world/gen2/World.lua @@ -1941,6 +1941,11 @@ end -- command -- unlike a plain `setevent`, which the cart only reads back on the -- next map load. function World:appearObject(objectId) + -- home/map_objects.asm:309 + if objectId == 0 then + self.playerMasked = nil + return + end local index = (objectId or 0) - 1 local def = self.map and self.map.def local obj = def and def.objects and def.objects[index] @@ -3522,6 +3527,8 @@ function World:runMapSetup(method, load, fly) self:roamMonsBeforeLoad(method) local wrapped = function() local ok = load() + -- engine/overworld/map_objects_2.asm:1 + self.playerMasked = nil self:roamMonsAfterLoad(method) return ok end @@ -3893,6 +3900,11 @@ function World:turnObject(objectId, facing) end function World:disappearObject(objectId) + -- home/map_objects.asm:317 + if objectId == 0 then + self.playerMasked = true + return + end local index = (objectId or 0) - 1 local def = self.map and self.map.def local obj = def and def.objects and def.objects[index] @@ -4021,9 +4033,7 @@ function World:updateMovement() return end if ent.moving then return end - -- StepFunction_NPCJump's `.Land` beat (engine/overworld/map_objects.asm:1150): - -- `.Jump` already walked one cell and ran GetNextTile again, so the second - -- cell belongs to the jump and not to the next movement byte. + -- engine/overworld/map_objects.asm:1150 if st.pendingStep then local dir = st.pendingStep st.pendingStep = nil @@ -4083,7 +4093,9 @@ function World:updateMovement() -- GetNextTile only record the tile for the grass flag and never block -- scripted movement. local fromX, fromY = ent.cellX, ent.cellY - if ent:scriptStep(act.dir) then + if ent.scriptJump and ent:scriptJump(act.dir) then + self:followStep(ent, fromX, fromY) + elseif not ent.scriptJump and ent:scriptStep(act.dir) then st.pendingStep = act.dir self:followStep(ent, fromX, fromY) end @@ -9721,6 +9733,7 @@ function World:setMap(mapId, cx, cy, facing, opts) -- A follow pairing points at two live objects, and a map load rebuilds them -- (RefreshMapSprites); nothing on the cart survives that either. self.followState = nil + self.playerMasked = nil -- EnterMap's SetUpFiveStepWildEncounterCooldown (engine/overworld/events.asm: -- 110, :367-370): four encounter-free steps after every map entry. self.wildCooldown = 5 @@ -11102,7 +11115,7 @@ function World:drawPeople(s, billboard) local filter = self.spriteFilter local drawList = {} if not hideAll then - if not hidePlayer then + if not hidePlayer and not self.playerMasked then drawList[1] = { kind = "player", py = p.py, ox = 0, oy = 0 } end for _, npc in ipairs(self.npcs) do diff --git a/tests/drivers/crystal_day_of_week_bug2138.lua b/tests/drivers/crystal_day_of_week_bug2138.lua new file mode 100644 index 00000000..fb931bc5 --- /dev/null +++ b/tests/drivers/crystal_day_of_week_bug2138.lua @@ -0,0 +1,75 @@ +local U = require("tests.drivers.util") + +-- ../pokecrystal/engine/rtc/timeset.asm:385 +local SHOT_DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/pokeport-shots" + +return function(game) + local fails = 0 + local function say(line) print("[2138] " .. line) end + local function ok(cond, line) + if not cond then fails = fails + 1 end + say((cond and "PASS " or "FAIL ") .. line) + end + + local function tap(button, frames) + game.input.pressQueue[#game.input.pressQueue + 1] = button + game.input.state[button] = true + U.wait(2) + game.input.state[button] = false + U.wait(frames or 4) + end + + local function dayWheel() + local top = game.stack and game.stack:top() + if top and top.mode == "day" and top.pickerBox then return top end + return nil + end + + U.wait(60) + local world = game.world + if not (world and world.map) then + say("FAIL the gen 2 world did not boot") + love.event.quit(1) + return + end + + -- ../pokecrystal/maps/PlayersHouse1F.asm:395 + world:warpToMapId("PLAYERS_HOUSE_1F", 9, 1, "down") + U.wait(30) + ok(world.map and world.map.id == "PLAYERS_HOUSE_1F", "standing at the top of the stairs") + for _ = 1, 3 do tap("down", 10) end + U.wait(30) + U.shot(game, SHOT_DIR .. "/2138_scene_start.png") + + local wheel + for _ = 1, 120 do + wheel = dayWheel() + if wheel then break end + tap("a", 6) + end + if not wheel then + say("the Mom scene did not reach SetDayOfWeek; pushing the wheel directly") + world:setDayOfWeek(function() end) + U.wait(10) + wheel = dayWheel() + end + ok(wheel ~= nil, "the day-of-week wheel is up") + if wheel then + ok(wheel.isOpaque == false, "and it is an overlay, not an opaque page") + ok(not wheel:drawsWidescreen(), "with no widescreen surround of its own") + end + U.wait(10) + U.shot(game, SHOT_DIR .. "/2138_day_wheel.png") + say("eyeball 2138_day_wheel.png: the living room, Mom and the player stay " + .. "visible around the SUNDAY picker and the question box") + + tap("a", 20) + U.shot(game, SHOT_DIR .. "/2138_confirm.png") + say("eyeball 2138_confirm.png: YES/NO box at (14,7), YES on row 8, NO on row 10") + tap("a", 30) + ok(dayWheel() == nil, "YES pops the wheel") + U.shot(game, SHOT_DIR .. "/2138_after.png") + + say(fails == 0 and "all claims passed" or (fails .. " claims failed")) + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/crystal_lance_hof_bug2135.lua b/tests/drivers/crystal_lance_hof_bug2135.lua new file mode 100644 index 00000000..06bcf2e7 --- /dev/null +++ b/tests/drivers/crystal_lance_hof_bug2135.lua @@ -0,0 +1,93 @@ +local U = require("tests.drivers.util") +local Mon = require("src.battle.gen2.Mon") + +local SHOT_DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/pokeport-shots" + +local EVENT_LANCES_ROOM_ENTRANCE_CLOSED = 785 +local EVENT_LANCES_ROOM_OAK_AND_MARY = 1887 + +return function(game) + local fails = 0 + local function say(line) print("[2135] " .. line) end + local function ok(cond, line) + if not cond then fails = fails + 1 end + say((cond and "PASS " or "FAIL ") .. line) + end + + U.wait(60) + local world = game.world + if not (world and world.map and world.vm and game.save) then + say("FAIL the gen 2 world did not boot") + love.event.quit(1) + return + end + + game.save.party = { Mon.new(game.data, "TYPHLOSION", 100) } + world.events:set(EVENT_LANCES_ROOM_ENTRANCE_CLOSED, true) + world.events:set(EVENT_LANCES_ROOM_OAK_AND_MARY, true) + world.mapScenes = world.mapScenes or {} + world.mapScenes.LANCES_ROOM = 1 + world:warpToMapId("LANCES_ROOM", 4, 6, "up") + for _ = 1, 120 do if not world:busy() then break end U.wait(1) end + U.wait(10) + ok(world.map and world.map.id == "LANCES_ROOM", "standing in Lance's room") + + U.hold(game, "up", 8) + for _ = 1, 120 do + if world.vm:running() then break end + U.wait(1) + end + ok(world.vm:running(), "the (4,5) coord event started the approach script") + + local screen + for _ = 1, 1200 do + local top = game.stack:top() + if top and top.battle then screen = top break end + U.tap(game, "a") + U.wait(2) + end + ok(screen ~= nil, "the Lance battle opened") + if not screen then + love.event.quit(1) + return + end + for _ = 1, 6000 do + if game.stack:top() ~= screen then break end + for _, m in ipairs(screen.battle.enemyParty or {}) do m.hp = 0 end + if screen.battle.enemy then screen.battle.enemy.hp = 0 end + U.tap(game, "a"); U.wait(2) + end + ok(game.stack:top() ~= screen, "Lance went down") + + local masked, maskedShot, unmaskedInHof, shots = false, false, false, 0 + local frames = 0 + for _ = 1, 6000 do + frames = frames + 1 + if world.map and world.map.id == "LANCES_ROOM" and world.playerMasked then + masked = true + if not maskedShot and not world.mapSetup then + maskedShot = true + U.shot(game, SHOT_DIR .. "/crystal_bug2135_01_player_gone.png") + say("the shot must show an EMPTY doorway while Mary runs up") + end + end + if world.map and world.map.id == "HALL_OF_FAME" then + if not world.mapSetup then + unmaskedInHof = not world.playerMasked + U.wait(30) + U.shot(game, SHOT_DIR .. "/crystal_bug2135_02_hof.png") + break + end + elseif masked and shots < 3 and frames % 30 == 0 then + shots = shots + 1 + U.shot(game, string.format("%s/crystal_bug2135_mary_%d.png", SHOT_DIR, shots)) + end + if frames % 6 == 0 then U.tap(game, "a") else U.wait(1) end + end + ok(masked, "disappear PLAYER masked the player before the warp") + ok(world.map and world.map.id == "HALL_OF_FAME", "warpfacing reached HALL_OF_FAME") + ok(unmaskedInHof, "and the load respawned the player there") + + say(fails == 0 and "all claims passed" or (fails .. " claims failed")) + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/crystal_reload_text_bug2130.lua b/tests/drivers/crystal_reload_text_bug2130.lua new file mode 100644 index 00000000..df4e605c --- /dev/null +++ b/tests/drivers/crystal_reload_text_bug2130.lua @@ -0,0 +1,97 @@ +local U = require("tests.drivers.util") +local Mon = require("src.battle.gen2.Mon") + +local SHOT_DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/pokeport-shots" + +return function(game) + local fails = 0 + local function say(line) print("[2130] " .. line) end + local function ok(cond, line) + if not cond then fails = fails + 1 end + say((cond and "PASS " or "FAIL ") .. line) + end + + U.wait(60) + local world = game.world + if not (world and world.map and world.vm and game.save) then + say("FAIL the crystal world did not boot") + love.event.quit(1) + return + end + + game.save.party = { Mon.new(game.data, "TYPHLOSION", 90) } + world.mapScenes = world.mapScenes or {} + world.mapScenes.WILLS_ROOM = 1 + world:warpToMapId("WILLS_ROOM", 5, 8, "up") + for _ = 1, 120 do if not world:busy() then break end U.wait(1) end + U.wait(10) + ok(world.map and world.map.id == "WILLS_ROOM", "standing in Will's room") + U.shot(game, SHOT_DIR .. "/crystal_bug2130_00_room.png") + + local started = world.vm:start({ + { op = "loadtrainer", class = 11, member = 1 }, + { op = "startbattle" }, + { op = "reloadmapafterbattle" }, + { op = "opentext" }, + { op = "writetext", text = "60:4644" }, + { op = "waitbutton" }, + { op = "closetext" }, + { op = "end" }, + }) + ok(started, "the battle-then-text script started") + + local screen + for _ = 1, 900 do + local top = game.stack:top() + if top and top.battle then screen = top break end + U.wait(1) + end + ok(screen ~= nil, "the trainer battle opened") + if not screen then + love.event.quit(1) + return + end + for _ = 1, 3000 do + if game.stack:top() ~= screen then break end + for _, m in ipairs(screen.battle.enemyParty or {}) do m.hp = 0 end + if screen.battle.enemy then screen.battle.enemy.hp = 0 end + U.tap(game, "a"); U.wait(2) + end + ok(game.stack:top() ~= screen, "the battle screen popped") + say("battle popped at frame " .. U.frame() .. " fade=" .. tostring(world.fade) + .. " mapSetup=" .. tostring(world.mapSetup and world.mapSetup.phase)) + + local sawFade, boxFrame, boxFade, boxSetup = false, nil, nil, nil + for _ = 1, 240 do + if world.mapSetup then sawFade = true end + local top = game.stack:top() + if world.textbox or (top and top.text and not top.battle) then + boxFrame = U.frame() + boxFade, boxSetup = world.fade, world.mapSetup and world.mapSetup.phase + break + end + U.wait(1) + end + ok(sawFade, "FadeInFromWhite ran after the battle") + ok(boxFrame ~= nil, "the after-battle text box opened") + ok(boxFade == nil, "no white sheet under the text box (fade=" + .. tostring(boxFade) .. ")") + ok(boxSetup == nil, "the map setup chain finished before the box (phase=" + .. tostring(boxSetup) .. ")") + U.wait(4) + U.shot(game, SHOT_DIR .. "/crystal_bug2130_01_text.png") + say("the shot must show Will's room and both sprites behind the text box") + U.wait(20) + U.shot(game, SHOT_DIR .. "/crystal_bug2130_02_text.png") + + for _ = 1, 300 do + if not world.vm:running() then break end + U.tap(game, "a") + U.wait(4) + end + ok(not world.vm:running(), "the script ran to completion") + U.shot(game, SHOT_DIR .. "/crystal_bug2130_03_end.png") + + say(fails == 0 and "all claims passed" or (fails .. " claims failed")) + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/crystal_rival_shove_bug2136.lua b/tests/drivers/crystal_rival_shove_bug2136.lua new file mode 100644 index 00000000..855c5dab --- /dev/null +++ b/tests/drivers/crystal_rival_shove_bug2136.lua @@ -0,0 +1,83 @@ +local U = require("tests.drivers.util") + +local SHOT_DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/pokeport-shots" + +local EVENT_RIVAL_NEW_BARK_TOWN = 1725 + +return function(game) + local fails = 0 + local function say(line) print("[2136] " .. line) end + local function ok(cond, line) + if not cond then fails = fails + 1 end + say((cond and "PASS " or "FAIL ") .. line) + end + + U.wait(60) + local world = game.world + if not (world and world.map and world.vm and game.save) then + say("FAIL the gen 2 world did not boot") + love.event.quit(1) + return + end + + world.events:set(EVENT_RIVAL_NEW_BARK_TOWN, false) + world:warpToMapId("NEW_BARK_TOWN", 3, 3, "up") + for _ = 1, 120 do if not world:busy() then break end U.wait(1) end + U.wait(10) + ok(world.map and world.map.id == "NEW_BARK_TOWN", "standing under the rival") + local rival + for _, npc in ipairs(world.npcs or {}) do + if npc.cellX == 3 and npc.cellY == 2 then rival = npc end + end + ok(rival ~= nil, "the rival is at (3,2)") + U.shot(game, SHOT_DIR .. "/crystal_bug2136_00_before.png") + + U.tap(game, "a") + for _ = 1, 60 do + if world.vm:running() then break end + U.wait(1) + end + ok(world.vm:running(), "talking to the rival started NewBarkTownRivalScript") + + local p = world.player + local lowest, jumpFrames, shot, pushedTo = 0, 0, false, nil + local facingDuringJump + for _ = 1, 1800 do + if not world.vm:running() then break end + if p.jumping then + jumpFrames = jumpFrames + 1 + lowest = math.min(lowest, p.spriteYOffset or 0) + facingDuringJump = facingDuringJump or p.facing + if not shot and (p.spriteYOffset or 0) <= -11 then + shot = true + U.shot(game, SHOT_DIR .. "/crystal_bug2136_01_airborne.png") + say("the shot must show the player lifted off the ground with a shadow under them") + end + elseif p.moving and not pushedTo then + pushedTo = p.targetY + end + local top = game.stack:top() + if (world.textbox or (top and top.text)) and jumpFrames == 0 then + U.tap(game, "a") + U.wait(3) + else + U.wait(1) + end + end + ok(not world.vm:running(), "the rival script ran to completion") + ok(pushedTo == 4, "the push walks the player one cell down (target=" + .. tostring(pushedTo) .. ")") + ok(jumpFrames > 0, "jump_step DOWN set the player jumping") + ok(jumpFrames >= 28 and jumpFrames <= 34, + "for one doubled step (" .. jumpFrames .. " frames)") + ok(lowest == -12, "the arc peaks twelve pixels up (lowest=" .. lowest .. ")") + ok(facingDuringJump == "up", "under fix_facing the player keeps facing up (facing=" + .. tostring(facingDuringJump) .. ")") + ok(p.cellX == 3 and p.cellY == 6, "and lands at (3,6) (at " + .. p.cellX .. "," .. p.cellY .. ")") + ok(p.spriteYOffset == 0 and not p.jumping, "on the ground again") + U.shot(game, SHOT_DIR .. "/crystal_bug2136_02_landed.png") + + say(fails == 0 and "all claims passed" or (fails .. " claims failed")) + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/crystal_sendout_name_bug2134.lua b/tests/drivers/crystal_sendout_name_bug2134.lua new file mode 100644 index 00000000..11cd2c0d --- /dev/null +++ b/tests/drivers/crystal_sendout_name_bug2134.lua @@ -0,0 +1,116 @@ +-- POKEPORT_IDENTITY=crystal-2134 POKEPORT_VERSION=crystal \ +-- POKEPORT_DRIVER=tests/drivers/crystal_sendout_name_bug2134.lua \ +-- POKEPORT_SHOT_DIR=/tmp/c2134 love . +-- ../pokecrystal/data/text/battle.asm:240-246 +-- ../pokecrystal/engine/battle/core.asm:3146-3147 +-- ../pokecrystal/home/text.asm:502-526 +local U = require("tests.drivers.util") + +local Mon = require("src.battle.gen2.Mon") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/c2134" + local fails = 0 + local function ok(cond, line) + if not cond then fails = fails + 1 end + print("[sendout-2134] " .. (cond and "PASS " or "FAIL ") .. line) + end + local function lines(screen) + return table.concat(screen:messageLines(), "|") + end + local function sendStarted(screen) + return screen.afterSendOut ~= nil or screen.showEnemyHud == true + end + + U.wait(45) + local world = game.world + assert(world and world.map, "the crystal world did not boot") + + local lead = Mon.new(game.data, "DRAGONITE", 47) + assert(lead and #lead.moves > 0, "could not build a DRAGONITE") + for i, move in ipairs(lead.moves) do + local def = game.data.moves[move.id] + if def and (def.power or 0) > 0 then + table.remove(lead.moves, i) + table.insert(lead.moves, 1, move) + break + end + end + game.save.party = { lead } + game.save.inventory = { POKE_BALL = 5 } + local trainer = { + class = 16, classId = "CHAMPION", memberId = "LANCE", + name = "CHAMPION LANCE", trainerName = "LANCE", className = "CHAMPION", + party = { Mon.new(game.data, "GYARADOS", 44), + Mon.new(game.data, "DRAGONITE", 61) }, + baseMoney = 25, + } + assert(world:startBattle({ trainer = trainer }), "startBattle failed") + local screen + for _ = 1, 600 do + local top = game.stack:top() + if top and top.battle then screen = top break end + U.wait(1) + end + assert(screen and screen.battle, "battle screen never came up") + + local function settle() + for _ = 1, 600 do + if screen.typer and screen.typer:done() then return true end + U.wait(1) + end + return false + end + + local function awaitSendOut(name, tag) + for _ = 1, 2400 do + local m = screen.message + if m and m:find("sent out", 1, true) and screen.messagePages then + settle() + local first = lines(screen) + ok(first == "CHAMPION LANCE|sent out", + tag .. " page 1 reads CHAMPION LANCE / sent out: " .. first) + ok(not sendStarted(screen) and screen.pendingSendOut ~= nil, + tag .. " the ball is still shut on page 1") + U.shot(game, out .. "/" .. tag .. "-page1.png") + U.tap(game, "a") + U.wait(2) + local early = screen:messageLines() + ok(early[1] == "sent out", + tag .. " 'sent out' is already on row 1 of page 2") + settle() + local second = lines(screen) + ok(second == "sent out|" .. name .. "!", + tag .. " page 2 reads sent out / " .. name .. "!: " .. second) + ok(sendStarted(screen), tag .. " ANIM_SEND_OUT_MON runs with page 2") + U.shot(game, out .. "/" .. tag .. "-page2.png") + return true + end + if screen.phase == "menu" then + if screen.battle.enemyIndex == 1 + and (screen.battle.enemy.hp or 0) > 1 then + screen.battle.enemy.hp = 1 + screen.battle.enemy.moves = {} + screen.battle.enemy.status = false + end + U.tap(game, "a") + U.wait(2) + U.tap(game, "a") + U.wait(2) + elseif (screen.messageTimer or 0) > 0 and screen.typer + and screen.typer:done() then + U.tap(game, "a") + end + U.wait(1) + end + ok(false, tag .. " send-out line never came up") + return false + end + + awaitSendOut("GYARADOS", "intro") + awaitSendOut("DRAGONITE", "replacement") + + print("[sendout-2134] " .. (fails == 0 and "PASS all claims" or + (fails .. " claims failed")) .. " -- shots in " .. out) + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/crystal_text_arrow_bug2132.lua b/tests/drivers/crystal_text_arrow_bug2132.lua new file mode 100644 index 00000000..ad84df2c --- /dev/null +++ b/tests/drivers/crystal_text_arrow_bug2132.lua @@ -0,0 +1,128 @@ +local U = require("tests.drivers.util") + +-- ../pokecrystal/home/text.asm:630 +local SHOT_DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/pokeport-shots" + +return function(game) + local fails = 0 + local function say(line) print("[2132] " .. line) end + local function ok(cond, line) + if not cond then fails = fails + 1 end + say((cond and "PASS " or "FAIL ") .. line) + end + + U.wait(60) + local world = game.world + if not (world and world.map) then + say("FAIL the gen 2 world did not boot") + love.event.quit(1) + return + end + + -- ../pokecrystal/maps/NewBarkTown.asm:296 + world:warpToMapId("NEW_BARK_TOWN", 8, 9, "up") + U.wait(30) + ok(world.map and world.map.id == "NEW_BARK_TOWN", "standing under the town sign") + + U.tap(game, "a") + local box + for _ = 1, 600 do + local top = game.stack and game.stack:top() + if top and top.isTextBox and top:arrowVisible() then box = top; break end + U.wait(1) + end + ok(box ~= nil, "the sign's first page is up and waiting on the arrow") + if not box then + love.event.quit(1) + return + end + + box.blink = 0 + U.shot(game, SHOT_DIR .. "/2132_arrow.png") + + local canvas = love.graphics.newCanvas(160, 144) + love.graphics.push("all") + love.graphics.setCanvas(canvas) + love.graphics.clear(1, 0, 1, 1) + box.blink = 0 + box:draw() + love.graphics.setCanvas() + love.graphics.pop() + local id = canvas:newImageData() + + local function px(x, y) + local r, g, b = id:getPixel(x, y) + return r, g, b + end + local function near(x, y, r, g, b) + local pr, pg, pb = px(x, y) + return math.abs(pr - r) < 0.05 and math.abs(pg - g) < 0.05 + and math.abs(pb - b) < 0.05 + end + local pr, pg, pb = px(12, 108) + say(("paper sampled at (12,108): %.2f %.2f %.2f"):format(pr, pg, pb)) + ok(not near(12, 108, 1, 0, 1), "the box interior was painted") + + local function paperInRow(y) + local n = 0 + for x = 144, 151 do + if near(x, y, pr, pg, pb) then n = n + 1 end + end + return n + end + local function inkInCell() + local n = 0 + for y = 136, 143 do + for x = 144, 151 do + local r, g, b = px(x, y) + if r < 0.3 and g < 0.3 and b < 0.3 then n = n + 1 end + end + end + return n + end + local ink = inkInCell() + say(("ink pixels in tile (18,17): %d"):format(ink)) + ok(ink > 0, "the arrow is drawn in the corner cell") + local function inkInRow(cx, y) + local n = 0 + for x = cx * 8, cx * 8 + 7 do + local r, g, b = px(x, y) + if r < 0.3 and g < 0.3 and b < 0.3 then n = n + 1 end + end + return n + end + local lineRows = {} + for y = 136, 143 do + if inkInRow(17, y) >= 6 then lineRows[#lineRows + 1] = y end + end + ok(#lineRows > 0, "tile (17,17) carries the frame line rows") + for _, y in ipairs(lineRows) do + ok(paperInRow(y) > 0, ("frame row %d of tile (18,17) shows paper under the arrow"):format(y - 136)) + end + + box.blink = 16 + love.graphics.push("all") + love.graphics.setCanvas(canvas) + love.graphics.clear(1, 0, 1, 1) + box:draw() + love.graphics.setCanvas() + love.graphics.pop() + id = canvas:newImageData() + local same = true + for y = 136, 143 do + for x = 0, 7 do + local r1, g1, b1 = px(136 + x, y) + local r2, g2, b2 = px(144 + x, y) + if math.abs(r1 - r2) > 0.05 or math.abs(g1 - g2) > 0.05 or math.abs(b1 - b2) > 0.05 then + same = false + end + end + end + ok(same, "on the off phase tile (18,17) matches the plain frame tile (17,17)") + + say("eyeball 2132_arrow.png: the down arrow in the bottom-right corner sits " + .. "in a paper cell with no frame lines through it (compare shots/2132_2.png)") + + say(fails == 0 and "all claims passed" or (fails .. " claims failed")) + love.event.quit(fails == 0 and 0 or 1) +end diff --git a/tests/drivers/gen2_save_overwrite_bug2131.lua b/tests/drivers/gen2_save_overwrite_bug2131.lua new file mode 100644 index 00000000..65dd3e55 --- /dev/null +++ b/tests/drivers/gen2_save_overwrite_bug2131.lua @@ -0,0 +1,120 @@ +-- ../pokecrystal/engine/menus/save.asm:209 SaveTheGame_yesorno +-- ../pokecrystal/data/text/common_3.asm:202 _AlreadyASaveFileText +local U = require("tests.drivers.util") + +local SaveMenu = require("src.ui.gen2.SaveMenu") +local PcMenu = require("src.ui.gen2.PcMenu") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gen2-save-overwrite-2131" + local failed = 0 + + local function pass(ok, line) + if not ok then failed = failed + 1 end + U.log((ok and "PASS " or "FAIL ") .. line) + end + + local function settle(menu) + for _ = 1, 600 do + if menu.typer and menu.typedPhase == menu.phase and menu.typer:done() then break end + U.wait(1) + end + U.wait(2) + end + + U.wait(45) + assert(game.world and game.world.map, "gen2 world did not boot") + + local closed = false + local menu = SaveMenu.new(game, { + existed = true, + writer = function() return true end, + onDone = function() + closed = true + game.stack:pop() + end, + }) + game.stack:push(menu) + settle(menu) + pass(menu:yesNoVisible(), "confirm YES/NO is up after the question") + U.shot(game, out .. "/01-confirm.png") + + U.tap(game, "a") + U.wait(2) + pass(menu.phase == "overwrite", "YES on an existing file opens the overwrite prompt") + settle(menu) + pass(menu.page == 1 and #menu.pages == 2, + ("overwrite prompt is on page 1 of 2 (page %s of %s)"):format( + tostring(menu.page), tostring(menu.pages and #menu.pages))) + pass(not menu:yesNoVisible(), "no YES/NO while the cont page waits") + U.log("02-overwrite-page1: 'There is already a / save file. Is it' with the") + U.log("blinking arrow at the bottom right and NO yes/no box yet.") + U.shot(game, out .. "/02-overwrite-page1.png") + + U.tap(game, "a") + U.wait(2) + pass(menu.page == 2, "A turns to the cont page") + pass(menu.phase == "overwrite", "without answering the question") + settle(menu) + pass(menu:yesNoVisible(), "YES/NO goes up once 'OK to overwrite?' has printed") + U.log("03-overwrite-page2: 'save file. Is it / OK to overwrite?' with the") + U.log("YES/NO box at (0,7) over the summary panel, as on the cart.") + U.shot(game, out .. "/03-overwrite-page2.png") + + U.tap(game, "b") + U.wait(5) + pass(closed, "B on the overwrite YES/NO closes without saving") + + local Mon = require("src.battle.gen2.Mon") + if not (game.save.party and #game.save.party > 0) then + game.save.party = { Mon.new(game.data, "CYNDAQUIL", 10) } + end + local pc = PcMenu.new(game, { + save = game.save, + saveExists = true, + bills = true, + writer = function() return true end, + onClose = function() game.stack:pop() end, + }) + game.stack:push(pc) + U.wait(3) + pc.picking = true + pc.pickIndex = ((game.save.currentBox or 1) % 14) + 1 + pc:beginChangeBox(pc.pickIndex) + U.wait(3) + pass(pc.savePhase == "confirm" and pc:saveYesNoVisible(), + "CHANGE BOX asks '#MON BOX, data will be saved. OK?' with YES/NO up") + U.shot(game, out .. "/04-changebox-confirm.png") + + U.tap(game, "a") + U.wait(3) + pass(pc.savePhase == "overwrite" and pc.savePage == 1, + "YES on an existing file opens the overwrite prompt on page 1") + pass(not pc:saveYesNoVisible(), "no YES/NO while the cont page waits") + U.log("05-changebox-page1: 'There is already a / save file. Is it', arrow,") + U.log("no yes/no box.") + U.shot(game, out .. "/05-changebox-page1.png") + + U.tap(game, "a") + U.wait(3) + pass(pc.savePhase == "overwrite" and pc.savePage == 2, + "A turns to the cont page without answering") + pass(pc:saveYesNoVisible(), "YES/NO is up on 'OK to overwrite?'") + U.log("06-changebox-page2: 'save file. Is it / OK to overwrite?' with the") + U.log("YES/NO box at (14,7).") + U.shot(game, out .. "/06-changebox-page2.png") + + U.tap(game, "b") + U.wait(3) + pass(pc.savePhase == nil and pc.picking, + "B on the overwrite YES/NO drops the change and keeps the box picker") + U.tap(game, "b") + U.wait(3) + U.tap(game, "b") + U.wait(5) + + U.log(("%d check(s) failed"):format(failed)) + U.log((failed == 0 and "PASS" or "FAIL") .. " gen2 save overwrite cont page (#2131) shots in " .. out) + + while true do coroutine.yield() end +end diff --git a/tests/engine/gen2_pcmenu_changebox_save_translation_test.lua b/tests/engine/gen2_pcmenu_changebox_save_translation_test.lua index ef58dba7..1d3d5d13 100644 --- a/tests/engine/gen2_pcmenu_changebox_save_translation_test.lua +++ b/tests/engine/gen2_pcmenu_changebox_save_translation_test.lua @@ -92,6 +92,60 @@ do T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "Could not save.", "the failed-save message") end +-- ../pokecrystal/engine/menus/save.asm:39 ChangeBoxSaveGame +local ARROW_X, ARROW_Y = 18 * 8, 17 * 8 +do + local input = { pressed = {} } + function input:press(button) self.pressed[button] = true end + function input:wasPressed(button) + if self.pressed[button] then + self.pressed[button] = nil + return true + end + return false + end + local menu = PcMenu.new({ input = input }, { + save = SAVE, + saveExists = true, + writer = function() return true end, + }) + menu.picking = true + menu.pickIndex = 2 + menu:beginChangeBox(2) + T.eq(menu.savePhase, "confirm", "CHANGE BOX asks to save first") + drawn = {} + menu:drawPanel() + T.eq(drawnAt(YES_X, YES_Y), "YES", "with its YES/NO up at once") + + input:press("a") + menu:update(0) + T.eq(menu.savePhase, "overwrite", "YES on an existing file asks to overwrite") + T.eq(menu.savePage, 1, "on the prompt's first page") + menu.arrowBlink = 0 + drawn = {} + menu:drawPanel() + T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "There is already a", "page one, first line") + T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "save file. Is it", "page one, second line") + T.eq(drawnAt(YES_X, YES_Y), nil, "no YES on page one") + T.eq(drawnAt(ARROW_X, ARROW_Y), "\xe2\x96\xbc", "the cont arrow blinks at (18,17)") + + input:press("a") + menu:update(0) + T.eq(menu.savePhase, "overwrite", "A turns the page rather than answering") + T.eq(menu.savePage, 2, "to the cont page") + drawn = {} + menu:drawPanel() + T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "save file. Is it", "page two scrolls the second line up") + T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "OK to overwrite?", "and prints the cont line") + T.eq(drawnAt(YES_X, YES_Y), "YES", "YES is up on the last page") + T.eq(drawnAt(ARROW_X, ARROW_Y), nil, "and the arrow is gone") + + input:press("b") + menu:update(0) + T.eq(menu.savePhase, nil, "B on the YES/NO is NO and drops the change") + T.eq(menu.picking, true, "with the box picker still up") +end + -- ------------------------------------------------- a translation mod's turn -- -- Same catalog values as gen2_save_menu_translation_test.lua's own @@ -105,7 +159,7 @@ do ["YES"] = "OUI", ["NO"] = "NON", ["#MON BOX, data\nwill be saved. OK?"] = "Les donnees de la\nBOITE seront sauv.", - ["There is already a\nsave file. Is it"] = "Un fichier existe\ndeja. Est-ce", + ["There is already a\nsave file. Is it\vOK to overwrite?"] = "Un fichier existe\ndeja. Est-ce\vOK pour ecraser?", ["SAVING… DON'T TURN\nOFF THE POWER."] = "SAUVEGARDE...\nN'ETEIGNEZ PAS.", ["%s saved\nthe game."] = "%s a sauvegarde\nla partie.", ["Could not save."] = "Echec de sauvegarde.", diff --git a/tests/engine/gen2_save_menu_translation_test.lua b/tests/engine/gen2_save_menu_translation_test.lua index 6f6f3fc0..d26ec631 100644 --- a/tests/engine/gen2_save_menu_translation_test.lua +++ b/tests/engine/gen2_save_menu_translation_test.lua @@ -103,7 +103,7 @@ do ["YES"] = "OUI", ["NO"] = "NON", ["Would you like to\nsave the game?"] = "Voulez-vous\nsauvegarder ?", - ["There is already a\nsave file. Is it"] = "Un fichier existe\ndeja. Est-ce", + ["There is already a\nsave file. Is it\vOK to overwrite?"] = "Un fichier existe\ndeja. Est-ce\vOK pour ecraser?", ["SAVING… DON'T TURN\nOFF THE POWER."] = "SAUVEGARDE...\nN'ETEIGNEZ PAS.", ["%s saved\nthe game."] = "%s a sauvegarde\nla partie.", ["Could not save."] = "Echec de sauvegarde.", @@ -156,32 +156,101 @@ do T.check(not Strings.active(), "the catalog is unloaded for the checks after this one") end --- src/ui/gen2/PcMenu.lua:savePrompt() shares SaveMenu's overwrite/saving +-- src/ui/gen2/PcMenu.lua:savePages() shares SaveMenu's overwrite/saving -- prompts through SaveMenu.OVERWRITE_PROMPT_SOURCE/SAVING_PROMPT_SOURCE and --- SaveMenu.twoLines(), rather than duplicating them -- both exported below, +-- SaveMenu.pagesOf(), rather than duplicating them -- both exported below, -- both used by PcMenu's own translation test -- (tests/engine/gen2_pcmenu_changebox_save_translation_test.lua). Checked --- here that they stay callable the shape twoLines() expects: a table in, --- one \n-joined string out with the split back on load. +-- here that they stay callable the shape pagesOf() expects: one +-- \n/\v-joined string in, a list of two-line pages out. +-- ../pokecrystal/data/text/common_3.asm:202 _AlreadyASaveFileText do - T.eq(SaveMenu.OVERWRITE_PROMPT_SOURCE, "There is already a\nsave file. Is it", - "OVERWRITE_PROMPT_SOURCE stays the cart's own \\n-joined text") - T.eq(SaveMenu.twoLines(Strings(SaveMenu.OVERWRITE_PROMPT_SOURCE))[1], "There is already a", - "and twoLines() splits its untranslated fallback back to the first line") - T.eq(SaveMenu.twoLines(Strings(SaveMenu.OVERWRITE_PROMPT_SOURCE))[2], "save file. Is it", - "and its second line") + T.eq(SaveMenu.OVERWRITE_PROMPT_SOURCE, + "There is already a\nsave file. Is it\vOK to overwrite?", + "OVERWRITE_PROMPT_SOURCE is the cart's whole text, cont line included") + local pages = SaveMenu.pagesOf(Strings(SaveMenu.OVERWRITE_PROMPT_SOURCE)) + T.eq(#pages, 2, "and pagesOf() turns the cont into a second page") + T.eq(pages[1][1], "There is already a", "page one, first line") + T.eq(pages[1][2], "save file. Is it", "page one, second line") + T.eq(pages[2][1], "save file. Is it", "page two scrolls the old second line up") + T.eq(pages[2][2], "OK to overwrite?", "and prints the cont line under it") T.eq(SaveMenu.SAVING_PROMPT_SOURCE, "SAVING… DON'T TURN\nOFF THE POWER.", "SAVING_PROMPT_SOURCE stays the cart's own \\n-joined text") - T.eq(SaveMenu.twoLines(Strings(SaveMenu.SAVING_PROMPT_SOURCE))[1], "SAVING… DON'T TURN", - "and twoLines() splits its untranslated fallback back to the first line") - T.eq(SaveMenu.twoLines(Strings(SaveMenu.SAVING_PROMPT_SOURCE))[2], "OFF THE POWER.", - "and its second line") + pages = SaveMenu.pagesOf(Strings(SaveMenu.SAVING_PROMPT_SOURCE)) + T.eq(#pages, 1, "a \\n-only message is a single page") + T.eq(pages[1][1], "SAVING… DON'T TURN", "with its first line") + T.eq(pages[1][2], "OFF THE POWER.", "and its second line") + T.eq(SaveMenu.pagesOf("Could not save.")[1][2], nil, + "a one-line message leaves the second slot empty") +end + +-- ../pokecrystal/engine/menus/save.asm:209 SaveTheGame_yesorno +local ARROW_X, ARROW_Y = 18 * 8, 17 * 8 +local function newInput() + local input = { pressed = {} } + function input:press(button) self.pressed[button] = true end + function input:wasPressed(button) + if self.pressed[button] then + self.pressed[button] = nil + return true + end + return false + end + function input:isDown() return false end + return input +end + +do + local input = newInput() + local menu = SaveMenu.new({ input = input }, { save = SAVE, existed = true }) + typeOut(menu) + input:press("a") + menu:update(0) + T.eq(menu.phase, "overwrite", "YES on an existing file asks to overwrite") + typeOut(menu) + menu.arrowBlink = 0 + drawn = {} + menu:drawPanel() + T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "There is already a", "page one, first line") + T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "save file. Is it", "page one, second line") + T.eq(drawnAt(YES_X, YES_Y), nil, "no YES on page one") + T.eq(drawnAt(ARROW_X, ARROW_Y), "\xe2\x96\xbc", "the cont arrow blinks at (18,17)") + + input:press("a") + menu:update(0) + T.eq(menu.page, 2, "A turns to the cont page") + typeOut(menu) + drawn = {} + menu:drawPanel() + T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "save file. Is it", "page two scrolls the second line up") + T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "OK to overwrite?", "and prints the cont line") + T.eq(drawnAt(YES_X, YES_Y), "YES", "YES is up on the last page") + T.eq(drawnAt(NO_X, NO_Y), "NO", "and NO") + T.eq(drawnAt(ARROW_X, ARROW_Y), nil, "and the arrow is gone") +end + +do + Strings.load({ + strings = { + ["There is already a\nsave file. Is it\vOK to overwrite?"] = "Ecraser la\nsauvegarde?", + }, + }) + local input = newInput() + local menu = SaveMenu.new({ input = input }, { save = SAVE, existed = true }) + typeOut(menu) + input:press("a") + menu:update(0) + typeOut(menu) + T.eq(#menu.pages, 1, "a translation without \\v is one page") + T.check(menu:yesNoVisible(), "and its YES/NO goes straight up") + drawn = {} + menu:drawPanel() + T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "Ecraser la", "the translated first line") + T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "sauvegarde?", "and second") + T.eq(drawnAt(YES_X, YES_Y), "YES", "with YES drawn") + Strings.load({}) end --- A translation with a THIRD line (a second embedded "\n") has nowhere on --- screen to go -- drawPanel's box has room for exactly two Chrome.print --- calls -- so it must not silently draw the literal newline byte as glyph --- garbage on the second line, and should warn so a translator notices. do Strings.load({ strings = { @@ -189,24 +258,24 @@ do }, }) - local warned = {} - require("src.core.Logger").warn = function(fmt, ...) - warned[#warned + 1] = select("#", ...) > 0 and fmt:format(...) or fmt - end - - local menu = SaveMenu.new({}, { save = SAVE, existed = false }) + local input = newInput() + local menu = SaveMenu.new({ input = input }, { save = SAVE, existed = false }) + typeOut(menu) drawn = {} menu:drawPanel() - T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "Ligne un", "only the first line reaches the box") - T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "Ligne deux\nLigne trois", - "the rest lands in the second slot rather than vanishing") - T.check(#warned == 1, "and a single warning is logged") + T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "Ligne un", "the first line reaches the box") + T.eq(drawnAt(PROMPT2_X, PROMPT2_Y), "Ligne deux", "and the second, without the third") + T.eq(drawnAt(YES_X, YES_Y), nil, "no YES while a page is still to come") + input:press("a") + menu:update(0) + typeOut(menu) drawn = {} menu:drawPanel() - T.check(#warned == 1, "the warning does not repeat for the same text") + T.eq(drawnAt(PROMPT1_X, PROMPT1_Y), "Ligne trois", "the third line gets its own page") + T.eq(drawnAt(PROMPT2_X, PROMPT2_Y) or "", "", "with an empty second slot") + T.eq(drawnAt(YES_X, YES_Y), "YES", "and YES on the last page") - require("src.core.Logger").warn = function() end Strings.load({}) end diff --git a/tests/engine/gen2_textbox_arrow_paper_bug2132.lua b/tests/engine/gen2_textbox_arrow_paper_bug2132.lua new file mode 100644 index 00000000..fde9cb13 --- /dev/null +++ b/tests/engine/gen2_textbox_arrow_paper_bug2132.lua @@ -0,0 +1,95 @@ +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") + +require("src.core.Logger").warn = function() end + +local Chrome = require("src.ui.gen2.Chrome") +local Font = require("src.render.Font") +local Theme = require("src.ui.Theme") +local TextBox = require("src.render.TextBox") + +local ARROW = Theme.moreArrow or 0xEE +local CREAM = { 231, 227, 231 } + +local function newGame(gen, paper) + return { + save = { player = {}, options = { textSpeed = "FAST" }, generation = gen }, + data = { text = {} }, + input = { + wasPressed = function() return false end, + isDown = function() return false end, + }, + textboxPaper = paper and function() return paper end or nil, + } +end + +local events +local realDraw, realFill = Font.drawCode, Chrome.paletteFill +Font.drawCode = function(code, x, y) + events[#events + 1] = { kind = "glyph", code = code, x = x, y = y } +end +Chrome.paletteFill = function(x, y, w, h, palette) + events[#events + 1] = { kind = "fill", x = x, y = y, w = w, h = h, + palette = palette } +end + +local function frame(box, blink) + box.blink = blink + events = {} + box:draw() + local fill, arrow + for i, e in ipairs(events) do + if e.kind == "fill" and e.x == 144 and e.y == 136 then fill = i end + if e.kind == "glyph" and e.code == ARROW then arrow = i end + end + return fill, arrow +end + +local function box(gen, paper) + local b = TextBox.new(newGame(gen, paper), "A") + b.waiting = true + b.shown = {} + return b +end + +-- ../pokecrystal/home/text.asm:630 +do + local gold = box(2) + local fill, arrow = frame(gold, 0) + check(arrow, "gold arrow draws on the on phase") + check(fill, "and the (18,17) border cell is painted paper first") + check(fill and arrow and fill < arrow, "paper goes under the arrow, not over it") + local e = fill and events[fill] + check(e and e.w == 8 and e.h == 8, "one whole 8x8 tile") + check(e and e.palette == Chrome.DEFAULT_BOX_PALETTE, + "through the box's own palette") +end + +-- ../pokecrystal/home/joypad.asm:458 +do + local gold = box(2) + local fill, arrow = frame(gold, 16) + eq(arrow, nil, "gold arrow is off from frame 16") + eq(fill, nil, "and the border tile is left alone on the off phase") +end + +do + local gear = box(2, CREAM) + local fill = frame(gear, 0) + local e = fill and events[fill] + check(e and e.palette and e.palette[1] == CREAM, + "a pushed textbox on the Pokegear's cream paper fills the cell cream") +end + +do + local red = box(1) + local fill, arrow = frame(red, 0) + check(arrow, "gen1 arrow still draws") + eq(fill, nil, "and gen1 never paints the cell (its arrow sits on the interior)") +end + +Font.drawCode, Chrome.paletteFill = realDraw, realFill +T.finish() diff --git a/tests/engine/gen2_yesno_typing_bug2085.lua b/tests/engine/gen2_yesno_typing_bug2085.lua index ec7106ce..89574928 100644 --- a/tests/engine/gen2_yesno_typing_bug2085.lua +++ b/tests/engine/gen2_yesno_typing_bug2085.lua @@ -64,6 +64,24 @@ while Typer.typing(menu) and frames < 600 do frames = frames + 1 end check(frames > 0 and frames < 600, "the overwrite question finishes typing") +-- ../pokecrystal/data/text/common_3.asm:205 +eq(menu.page, 1, "the overwrite prompt's first page is up") +eq(#menu.pages, 2, "and it has a cont page after it") +check(not menu:yesNoVisible(), "no YES/NO while the cont page waits for a button") + +input:press("a") +menu:update(0) +eq(menu.page, 2, "A turns to the cont page") +eq(menu.phase, "overwrite", "without answering the question") +check(Typer.typing(menu), "the cont page types letter by letter") +check(not menu:yesNoVisible(), "and the YES/NO waits for it") + +frames = 0 +while Typer.typing(menu) and frames < 600 do + menu:update(0) + frames = frames + 1 +end +check(frames > 0 and frames < 600, "the cont page finishes typing") check(menu:yesNoVisible(), "then the overwrite YES/NO goes up") input:press("a") diff --git a/tests/gen2_battle_test.lua b/tests/gen2_battle_test.lua index ffaaa6aa..e2ffda4d 100644 --- a/tests/gen2_battle_test.lua +++ b/tests/gen2_battle_test.lua @@ -850,7 +850,8 @@ do if event.kind == "send" then sentOut = event.text end end check("EnemyWithdrewText comes first", withdrew, "JOEY withdrew GEODUDE!") - check("then the send-out line", sentOut, "JOEY sent out PIDGEY!") + -- ../pokecrystal/data/text/battle.asm:240-246 + check("then the send-out line", sentOut, "JOEY\nsent out\vPIDGEY!") -- A refused RUN costs nothing. .cant_run_from_trainer leaves -- wBattlePlayerAction at BATTLEPLAYERACTION_USEMOVE, so BattleMenu_Run's diff --git a/tests/gen2_battle_ui_test.lua b/tests/gen2_battle_ui_test.lua index 81a5b494..9e789436 100644 --- a/tests/gen2_battle_ui_test.lua +++ b/tests/gen2_battle_ui_test.lua @@ -1754,7 +1754,7 @@ end do local screen, lead = learnScreen() screen:push({ kind = "send", side = "enemy", mon = { hp = 1 }, hp = 1, - text = "JOE sent out PIDGEY!" }) + text = Battle.sentOutText("JOE", "PIDGEY") }) check(runToPhase(screen, "ask-forget"), "the pages reach the question") local tap = tapper(screen) tap("a") -- read the question @@ -1779,6 +1779,72 @@ do "and its line prints ahead of the send-out that was already queued") end +-- ../pokecrystal/data/text/battle.asm:240-246 +-- ../pokecrystal/engine/battle/core.asm:3146-3147 +-- ../pokecrystal/home/text.asm:502-526 +do + local function typed(screen) + for _ = 1, 400 do + if not screen:syncTyper() then break end + Input:step() + screen:update(1 / 60) + end + return table.concat(screen:messageLines(), "|") + end + local function sendOutScreen(trainer, monName) + local screen, battle = newScreen() + check(runToMenu(screen), "reached the menu") + battle:takeEvents() + local mon = Mon.new(DATA, "PIDGEY", 5, { dvs = perfect }) + mon.nickname = monName + screen.phase = "resolving" + screen.showEnemyHud = false + screen:push({ kind = "send", side = "enemy", mon = mon, hp = mon.hp, + text = Battle.sentOutText(trainer, monName) }) + screen:advanceQueue() + return screen + end + local function sendStarted(screen) + return screen.afterSendOut ~= nil or screen.showEnemyHud == true + end + + local screen = sendOutScreen("CHAMPION LANCE", "DRAGONITE") + eq(typed(screen), "CHAMPION LANCE|sent out", + "the trainer and 'sent out' are the first page, no name on it") + check(not sendStarted(screen) and screen.pendingSendOut ~= nil, + "the ball stays shut while the first page waits for A") + local tap = tapper(screen) + tap("a") + Input:step() + screen:update(1 / 60) + local lines = screen:messageLines() + eq(lines[1], "sent out", "cont scrolls 'sent out' up already printed") + check(lines[2] ~= "DRAGONITE!", "and only the name types") + eq(typed(screen), "sent out|DRAGONITE!", + "a nine-letter name lands whole on the scrolled row") + check(sendStarted(screen) and screen.pendingSendOut == nil, + "and ANIM_SEND_OUT_MON starts with the name page") + + local short = sendOutScreen("CHAMPION LANCE", "PIDGEY") + eq(typed(short), "CHAMPION LANCE|sent out", + "a short name still gets the cart's unconditional third row") + tapper(short)("a") + eq(typed(short), "sent out|PIDGEY!", "and lands on the scrolled page") + + local long = newScreen() + check(runToMenu(long), "reached the menu") + long.battle:takeEvents() + long.phase = "resolving" + long:push({ kind = "message", + text = "SOMELONGNAME gained 12345 EXP. Points!" }) + long:advanceQueue() + eq(typed(long), "SOMELONGNAME|gained 12345 EXP.", + "an overlong line shows its first two rows") + tapper(long)("a") + eq(typed(long), "gained 12345 EXP.|Points!", + "and the third row scrolls in instead of being cut") +end + -- ---- MoveSelectionScreen's two boxes (#1478) ------------------------------ -- engine/battle/core.asm:5074-5094, MoveInfoBox :5403-5478 do diff --git a/tests/gen2_catch_tutorial_test.lua b/tests/gen2_catch_tutorial_test.lua index c3321b1c..516e0b6e 100644 --- a/tests/gen2_catch_tutorial_test.lua +++ b/tests/gen2_catch_tutorial_test.lua @@ -138,10 +138,15 @@ do end, }) vm:start("s:t") + eq(table.concat(order, ","), + "start:CATCH_TUTORIAL,battle,stop,reload", + "the battle runs inside the lockout, and the script parks on the " + .. "map reload") + check(vm:running(), "catchtutorial is not a terminator") + vm:update() eq(table.concat(order, ","), "start:CATCH_TUTORIAL,battle,stop,reload,text", - "the battle runs inside the lockout, and the script continues after the " - .. "map reload -- catchtutorial is not a terminator") + "and the script continues once the map setup has run") eq(got and got.battleType, 3, "wBattleType is the command's own byte") eq(got and got.wild and got.wild.species, 19, "and the wild mon is the one loadwildmon left behind") diff --git a/tests/gen2_clock_test.lua b/tests/gen2_clock_test.lua index 3d82d007..313ca0d7 100644 --- a/tests/gen2_clock_test.lua +++ b/tests/gen2_clock_test.lua @@ -255,6 +255,51 @@ do end end +-- ../pokecrystal/home/text.asm:473 +do + local Chrome = require("src.ui.gen2.Chrome") + local priorPrint = Chrome.print + local rows = {} + Chrome.print = function(text, _tx, ty) rows[text] = ty end + local screen = InitClock.new({}, { save = {} }) + screen:drawPanel() + Chrome.print = priorPrint + eq(rows["Zzz... Hm? Wha...?"], 14, "Oak's first line prints on row 14") + eq(rows["You woke me up!"], 16, "and `line` puts the second on row 16") +end + +-- ../pokecrystal/engine/rtc/timeset.asm:385 +do + local Chrome = require("src.ui.gen2.Chrome") + local day = InitClock.new({}, { mode = "day", save = {} }) + eq(day.isOpaque, false, "SetDayOfWeek draws over the map") + eq(day:drawsWidescreen(), false, "and paints no surround of its own") + eq(day:wantsFillScale(), false, "nor asks for the fill scale") + local clock = InitClock.new({}, { mode = "clock", save = {} }) + eq(clock.isOpaque, true, "InitClock still runs on a cleared page") + eq(clock:drawsWidescreen(), true, "with its own widescreen surround") + local priorClear, cleared = Chrome.clear, 0 + Chrome.clear = function() cleared = cleared + 1 end + day:drawPanel() + eq(cleared, 0, "the day wheel never clears the tilemap") + clock:drawPanel() + eq(cleared, 1, "the clock does") + Chrome.clear = priorClear +end + +-- ../pokecrystal/home/text.asm:502 +do + local screen = InitClock.new({}, { save = {}, hour = 12, minute = 0 }) + screen.phase = "response" + eq(InitClock.responseKey(12), "yikes", "noon is the three-line response") + local pages = screen:pages() + eq(#pages, 2, "`cont` scrolls the third line in as a second page") + check(pages[1]:find("Yikes! I over-", 1, true) ~= nil + and not pages[1]:find("slept!", 1, true), "page one holds lines 1-2") + check(pages[2]:find("^Yikes! I over%-\nslept!$") ~= nil, + "page two keeps line 2 on top and scrolls line 3 under it") +end + -- Clock.DAY_NAMES / Clock.weekdayName / Clock.daytimeLabel: the single home -- InitClock, MainMenu and the Pokegear clock card all share, so a weekday -- cannot be named one way on one screen and another way on the next. diff --git a/tests/gen2_crystal_gender_test.lua b/tests/gen2_crystal_gender_test.lua index 187cc7ed..5001164e 100644 --- a/tests/gen2_crystal_gender_test.lua +++ b/tests/gen2_crystal_gender_test.lua @@ -193,10 +193,12 @@ end -- home/text.asm:566 do local Chrome = require("src.ui.gen2.Chrome") - local priorPrint, priorWrapped = Chrome.print, Chrome.printWrapped - local printed = {} - Chrome.print = function(text) printed[#printed + 1] = text end - Chrome.printWrapped = function(text) printed[#printed + 1] = text end + local priorPrint = Chrome.print + local printed, rows = {}, {} + Chrome.print = function(text, _tx, ty) + printed[#printed + 1] = text + rows[text] = ty + end local done = GenderSelect.new({ data = { text = { _AreYouABoyOrAreYouAGirlText = "Are you a boy?\nOr are you a girl?{DONE}", } } }) @@ -205,11 +207,14 @@ do done:drawPanel() check(not table.concat(printed, "|"):find("DONE", 1, true), "and never reaches the tile grid") + -- ../pokecrystal/home/text.asm:473 + eq(rows["Are you a boy?"], 14, "the prompt's first line sits on row 14") + eq(rows["Or are you a girl?"], 16, "and `line` drops the second to row 16") local prompt = GenderSelect.new({ data = { text = { _AreYouABoyOrAreYouAGirlText = "Are you a boy?{PROMPT}", } } }) eq(prompt.text, "Are you a boy?", "nor is {PROMPT}") - Chrome.print, Chrome.printWrapped = priorPrint, priorWrapped + Chrome.print = priorPrint end -- ------------------------------------------------- the beat in Oak's speech diff --git a/tests/gen2_field_anim_test.lua b/tests/gen2_field_anim_test.lua index 50b77d6e..7483805e 100644 --- a/tests/gen2_field_anim_test.lua +++ b/tests/gen2_field_anim_test.lua @@ -240,6 +240,65 @@ previous, alpha = BorderFill.crossfade(bare, trees, nil) check("a caller with no map key never fades", previous, nil) check("and draws opaque", alpha, 1) +-- JumpStep engine/overworld/movement.asm:747-776 +-- map_objects.asm:1828-1849 +check("the curve starts four pixels up", Movement.jumpYOffset(1, 16), -4) +check("peaks at twelve", Movement.jumpYOffset(6, 16), -12) +check("and lands on the tile", Movement.jumpYOffset(16, 16), 0) +check("a doubled step reads the same peak", Movement.jumpYOffset(12, 32), -12) + +local Player = require("src.world.gen2.Player") +local hopper = Player.new(3, 4, "down") +hopper.fixedFacing = true +hopper:scriptFace("up") +check("a scripted jump starts", hopper:scriptJump("down"), true) +check("it targets two cells", hopper.targetY, 6) +check("keeps a fixed facing", hopper.facing, "up") +check("and is a jump", hopper.jumping, true) +check("over one doubled step", hopper.stepFrames, 32) +local lowest, shadowFrames = 0, 0 +for _ = 1, 32 do + hopper:update() + lowest = math.min(lowest, hopper.spriteYOffset or 0) + if hopper.jumping then shadowFrames = shadowFrames + 1 end +end +check("the player arc peaks at -12", lowest, -12) +check("the shadow flag holds for the whole hop", shadowFrames, 31) +check("it lands two cells down", hopper.cellY, 6) +check("with the offset cleared", hopper.spriteYOffset, 0) +check("and the jump over", hopper.jumping, nil) +check("a fixed-facing scripted step also keeps its facing", + hopper:scriptStep("left") and hopper.facing, "up") +check("while still moving left", hopper.targetX, 2) +hopper.fixedFacing = nil + +local jumper = NPC.new("NEW_BARK_TOWN", { index = 2, x = 4, y = 5, + movement = 1 }, SPRITE) +jumper.fixedFacing = nil +check("an NPC jump starts", jumper:scriptJump("right"), true) +check("it targets two cells", jumper.targetX, 6) +check("faces the way it jumps", jumper.facing, "right") +check("and is a jump", jumper.jumping, true) +local npcLowest, npcFrames = 0, 0 +while jumper.moving and npcFrames < 64 do + jumper:update(nil, {}) + npcFrames = npcFrames + 1 + npcLowest = math.min(npcLowest, jumper.spriteYOffset or 0) +end +check("the NPC arc peaks at -12", npcLowest, -12) +check("over the doubled step", npcFrames, 32) +check("it lands two cells right", jumper.cellX, 6) +check("on the tile", jumper.spriteYOffset, 0) +check("with the jump over", jumper.jumping, nil) +check("a following plain step is back to one cell", + jumper:scriptStep("down") and jumper.targetY, 6) +local plainFrames = 0 +while jumper.moving and plainFrames < 64 do + jumper:update(nil, {}) + plainFrames = plainFrames + 1 +end +check("at the walking pace", plainFrames, 16) + print(("gen2 field anim: %d checks, %d failures"):format(checks, failures)) if failures > 0 then error(("%d assertion(s) failed"):format(failures), 0) diff --git a/tests/gen2_npc_interact_test.lua b/tests/gen2_npc_interact_test.lua index ccbb2216..dcf36f6a 100644 --- a/tests/gen2_npc_interact_test.lua +++ b/tests/gen2_npc_interact_test.lua @@ -172,10 +172,11 @@ do for _ = 1, 30 do world:updatePeople() end eq(npc.facing, before, "his spin cannot roll a new facing mid-script") -- Drain the engagement: the emote's 30-frame hold, the seen text, the - -- battle, the after script. updatePeople keeps running under it, which is - -- exactly when an unfrozen spinner would drift. + -- battle, the reload's FadeInFromWhite, the after script. updatePeople + -- keeps running under it, which is exactly when an unfrozen spinner would + -- drift. local drifted = false - for _ = 1, 60 do + for _ = 1, 90 do world:step() world:updatePeople() if world.vm:running() and npc.facing ~= before then drifted = true end diff --git a/tests/gen2_trainerhouse_test.lua b/tests/gen2_trainerhouse_test.lua index 11f09619..bdde134b 100644 --- a/tests/gen2_trainerhouse_test.lua +++ b/tests/gen2_trainerhouse_test.lua @@ -221,8 +221,11 @@ local function runScript(opts) reloadMap = function() log.reloaded = (log.reloaded or 0) + 1 end, }) local started = vm:start(map.coordEvents[1].scriptKey) + local parkedAfterReload = log.reloaded and vm.busy and #moves == 1 + for _ = 1, 4 do vm:update() end return { started = started, busy = vm.busy, log = log, moves = moves, + parkedAfterReload = parkedAfterReload, battles = battles, save = save, } end @@ -237,7 +240,9 @@ end do -- the first visit of the day, accepted local run = runScript({ accept = true }) check(run.started, "the script starts") - check(not run.busy, "and runs to completion without parking on a command") + check(run.parkedAfterReload, + "reloadmapafterbattle parks the script for the map setup") + check(not run.busy, "then it runs to completion") eq(run.log.turned and run.log.turned.facing, "up", "turnobject PLAYER, UP squares the player up to the desk") check(saw(run.log, "TRAINING HALL"), "the welcome") diff --git a/tests/gen2_trainers_test.lua b/tests/gen2_trainers_test.lua index f9549f33..68340d51 100644 --- a/tests/gen2_trainers_test.lua +++ b/tests/gen2_trainers_test.lua @@ -161,6 +161,9 @@ local vm = Vm.new(scripts, texts, events, { vm.trainerObject = record check(vm:start(SEEN_SCRIPT), "an inline command list runs") for _ = 1, 60 do vm:update() end +check(battled and vm:running() and #shown == 1, + "reloadmapafterbattle parks the script for the map setup") +vm:update() check(not vm:running(), "the trainer script finished") eq(emoted, -2, "the bubble goes over LAST_TALKED") check(approached, "the trainer walked up") @@ -170,6 +173,40 @@ eq(shown[1], "Wait! Let's battle!", "TRAINERTEXT_SEEN is the struct's seen text" eq(shown[2], "Train harder.", "the after-battle script ran") check(events:get(BEAT_FLAG), "SET_FLAG marked the trainer beaten") +-- engine/overworld/scripting.asm:1209 +do + local reloaded, opened = {}, {} + local rvm = Vm.new(scripts, texts, events, { + showText = function(body, onDone) opened[#opened + 1] = body onDone() end, + reloadMap = function(setup) reloaded[#reloaded + 1] = setup end, + startBattle = function(_trainer, _wild, onDone) onDone("win") end, + }) + check(rvm:start({ + { op = "startbattle" }, + { op = "reloadmapafterbattle" }, + { op = "opentext" }, + { op = "writetext", text = "t:after" }, + { op = "closetext" }, + { op = "end" }, + }), "battle-then-text script runs") + eq(#reloaded, 1, "reloadmapafterbattle ran the reload hook") + eq(reloaded[1], true, "as a map setup") + eq(#opened, 0, "and parked the script before the text") + check(rvm:running(), "the script is still live") + rvm:update() + eq(#opened, 1, "the text opens on the next VM tick") + check(not rvm:running(), "and the script runs out") + + reloaded, opened = {}, {} + rvm:start({ + { op = "refreshmap" }, + { op = "writetext", text = "t:after" }, + { op = "end" }, + }) + eq(reloaded[1], false, "refreshmap runs no setup script") + eq(#opened, 1, "and does not park") +end + -- A beaten trainer takes the CHECK_FLAG branch straight to its after script. local TALK_SCRIPT = { { op = "faceplayer" }, diff --git a/tests/gen2_world_test.lua b/tests/gen2_world_test.lua index d5d07ae3..85016ffd 100644 --- a/tests/gen2_world_test.lua +++ b/tests/gen2_world_test.lua @@ -2019,6 +2019,44 @@ objWorld:appearObject(3) check(objWorld.objectMasks["TEST_MAP:2"] == false, "and appear unmasks it, so the pair is not one-way") +-- maps/LancesRoom.asm:118 +do + local rebuildsBefore = objWorld.rebuilds or 0 + objWorld:disappearObject(0) + check(objWorld.playerMasked == true, + "disappear PLAYER masks the player sprite") + check(objWorld.objectMasks["TEST_MAP:-1"] == nil, "and touches no map object") + eq(objWorld.rebuilds or 0, rebuildsBefore, "and rebuilds nobody") + objWorld:appearObject(0) + check(not objWorld.playerMasked, "appear PLAYER unmasks it") +end + +-- NewBarkTown_RivalShovesYouOutMovement maps/NewBarkTown.asm:178-183 +do + local Player = require("src.world.gen2.Player") + local jw = hookWorld() + jw.player = Player.new(3, 4, "down") + local done = false + jw:beginMovement(0, { 0x01, 0x3b, 0x30, 0x3a, 0x47 }, function() done = true end) + jw:updateMovement() + eq(jw.player.facing, "up", "turn_head UP turns the player") + jw:updateMovement() + check(jw.player.moving and jw.player.jumping, "jump_step DOWN is a jump") + eq(jw.player.targetY, 6, "two cells long") + eq(jw.player.facing, "up", "under fix_facing the player keeps facing up") + eq(jw.moveState and jw.moveState.pendingStep, nil, "with no second walk queued") + local lowest = 0 + for _ = 1, 32 do + jw.player:update() + lowest = math.min(lowest, jw.player.spriteYOffset or 0) + jw:updateMovement() + end + eq(lowest, -12, "the sprite arcs twelve pixels up on the way") + eq(jw.player.cellY, 6, "and lands two cells down") + check(done, "then the stream ends") + check(not jw.player.fixedFacing, "remove_fixed_facing released the facing") +end + -- Three object_events SHARING one MAPOBJECT_EVENT_FLAG is ordinary: the -- animated Burned Tower beasts all carry EVENT_BURNED_TOWER_B1F_BEASTS_1 -- (maps/BurnedTowerB1F.asm:152) and ReleaseTheBeasts appears and jumps them @@ -2351,8 +2389,11 @@ setupWorld.setMap = function() setupLoads = setupLoads + 1 return true end setupWorld:runMapSetup(0xf7, function() return setupWorld:setMap() end) eq(setupLoads, 1, "MAPSETUP_CONNECTION loads on the spot") check(setupWorld.mapSetup == nil, "with no chain behind it") +setupWorld.playerMasked = true setupWorld:runMapSetup(0xf1, function() return setupWorld:setMap() end) eq(setupLoads, 2, "MAPSETUP_WARP opens on DisableLCD, so it loads at once too") +check(setupWorld.playerMasked == nil, + "and the load respawns a `disappear`ed player") eq(setupWorld.mapSetup.phase, "in", "and only fades back in") setupWorld.mapSetup = nil setupWorld:runMapSetup(0xf6, function() return setupWorld:setMap() end)