diff --git a/data/scripts/safari.lua b/data/scripts/safari.lua index b920e883..ae8c64ab 100644 --- a/data/scripts/safari.lua +++ b/data/scripts/safari.lua @@ -50,19 +50,30 @@ end local function startGame(game, ow, t, done, balls, introText) game.save.safari = { balls = balls or BALLS, steps = STEPS } game.save.safariNags = nil + -- ResetEventReuseHL EVENT_SAFARI_GAME_OVER -- scripts/SafariZoneGate.asm:196 + game.save.safariGameOver = nil local TextBox = require("src.render.TextBox") local paid = introText or (t._SafariZoneGateSafariZoneWorker1ThatllBe500PleaseText or "That'll be ¥500\nplease!\f{PLAYER} received\n30 SAFARI BALLs!") :gsub("{NUM:[^}]*}", "500") paid = paid:gsub("{PLAYER}", game.save.player.name) - local pa = t._SafariZoneGateSafariZoneWorker1CallYouOnThePAText - or "\fWe'll call you on\nthe PA when you\nrun out of time\nor SAFARI BALLs!" - local luck = t._SafariZoneGateSafariZoneWorker1GoodLuckText or "Good Luck!" - game.stack:push(TextBox.new(game, paid .. pa .. "\f" .. luck, function() - if done then done() end - walkIntoZone(game, ow) - end)) + local pa = (t._SafariZoneGateSafariZoneWorker1CallYouOnThePAText + or "\fWe'll call you on\nthe PA when you\nrun out of time\nor SAFARI BALLs!") + :gsub("^\f", "") + local money = function() return game.save.money end + -- scripts/SafariZoneGate.asm:181 + local opts = { money = money } + -- scripts/SafariZoneGate.asm:215, pokeyellow scripts/SafariZoneGate_2.asm:57 + if not introText then + opts = TextBox.soundOpts(game, "Get_Item1", opts) + end + game.stack:push(TextBox.new(game, paid, function() + game.stack:push(TextBox.new(game, pa, function() + if done then done() end + walkIntoZone(game, ow) + end, { money = money })) + end, opts)) end -- Yellow's soft-lock fix (scripts/SafariZoneGate_2.asm): a player short of @@ -107,18 +118,20 @@ end local function joinPrompt(game, ow, done) done = done or function() end local TextBox = require("src.render.TextBox") - local ChoiceBox = require("src.ui.ChoiceBox") local t = game.data.text local back = function(text) game.stack:push(TextBox.new(game, text, function() ow:scriptMove(ow.player, "down", 1, done, { collide = true }) end)) end + -- scripts/SafariZoneGate.asm:150-154 game.stack:push(TextBox.new(game, t._SafariZoneGateSafariZoneWorker1WouldYouLikeToJoinText or "For just ¥500 you\ncan join the hunt!\fWould you like to\njoin the hunt?", - function() - game.stack:push(ChoiceBox.new(game, function(yes) + nil, + { money = function() return game.save.money end, + moneyWithChoice = true, + choice = function(yes) if not yes then back(t._SafariZoneGateSafariZoneWorker1PleaseComeAgainText or "OK! Please come\nagain!") @@ -133,8 +146,7 @@ local function joinPrompt(game, ow, done) game.save.money = game.save.money - FEE startGame(game, ow, t, done) end - end)) - end)) + end })) end M.SAFARI_ZONE_GATE = { @@ -154,10 +166,26 @@ M.SAFARI_ZONE_GATE = { }, -- the join trigger cells in front of the worker + -- (SafariZoneGateDefaultScript, scripts/SafariZoneGate.asm:18-50) onStep = function(game, ow, x, y) if y ~= 2 or (x ~= 3 and x ~= 4) then return false end if game.save.safari then return false end -- paid, walking in - joinPrompt(game, ow, nil) + local TextBox = require("src.render.TextBox") + game.stack:push(TextBox.new(game, + game.data.text._SafariZoneGateSafariZoneWorker1Text + or "Welcome to the\nSAFARI ZONE!", + function() + -- scripts/SafariZoneGate.asm:29 + ow.player.facing = "right" + -- scripts/SafariZoneGate.asm:31-40, home/map_objects.asm:107-121 + if x == 3 then + ow:scriptMove(ow.player, "right", 1, function() + joinPrompt(game, ow, nil) + end) + else + joinPrompt(game, ow, nil) + end + end)) return true end, @@ -165,6 +193,15 @@ M.SAFARI_ZONE_GATE = { -- "Leaving early?" -- yes ends the game and takes the leftover balls, -- no walks you back into the zone onEnter = function(game, ow) + -- scripts/SafariZoneGate.asm:79-94 + if game.save.safariGameOver then + game.save.safariGameOver = nil + ow:queueScript({ + { "show_text", "_SafariZoneGateSafariZoneWorker1GoodHaulComeAgainText" }, + { "move_player", "down", 3 }, + }) + return + end if not game.save.safari or ow.player.cellY > 1 then return end -- QUEUED, never pushed: onEnter runs inside the arriving warp's -- Transition midpoint, and Transition:finish pops whatever is on top @@ -183,11 +220,7 @@ M.SAFARI_ZONE_GATE = { ow:queueScript({ { "ask", "_SafariZoneGateSafariZoneWorker1LeavingEarlyText" }, { "jump_if_false", "stay" }, - -- the port never reaches SafariZoneGateLeavingSafariScript's own - -- GOOD_HAUL_COME_AGAIN branch (safariGameOver warps straight to the - -- counter), so the sign-off rides on this path { "show_text", "_SafariZoneGateSafariZoneWorker1ReturnSafariBallsText" }, - { "show_text", "_SafariZoneGateSafariZoneWorker1GoodHaulComeAgainText" }, -- no value: set_field assigns nil, which is how save.safari is cleared { "set_field", "safari" }, -- move_player runs through scriptMove, which skips onStepComplete, so diff --git a/data/scripts/story.lua b/data/scripts/story.lua index 518124c8..a92d4665 100644 --- a/data/scripts/story.lua +++ b/data/scripts/story.lua @@ -751,35 +751,39 @@ M.WARDENS_HOUSE = { { "jump_if_true", "got_hm04" }, -- 3 { "check_item", "GOLD_TEETH" }, -- 4 { "jump_if_false", "no_teeth" }, -- 5 - { "show_text", "_WardensHouseWardenGaveTheGoldTeethText" }, -- 6 - { "take_item", "GOLD_TEETH", 1 }, -- 7 - { "set_flag", "EVENT_GAVE_GOLD_TEETH" }, -- 8 - { "show_text", "_WardensHouseWardenThanksText" }, -- 9 + -- .GaveTheGoldTeethText has no text_end and falls through into + -- .PoppedInHisTeethText -- scripts/WardensHouse.asm:71 + { "text_sound", "Get_Item1" }, -- 6 + { "show_text", "_WardensHouseWardenGaveTheGoldTeethText" }, -- 7 + { "show_text", "_WardensHouseWardenTeethPoppedInHisTeethText" }, -- 8 + { "take_item", "GOLD_TEETH", 1 }, -- 9 + { "set_flag", "EVENT_GAVE_GOLD_TEETH" }, -- 10 + { "show_text", "_WardensHouseWardenThanksText" }, -- 11 -- give-then-print like scripts/WardensHouse.asm - { "give_item", "HM_STRENGTH", 1, false }, -- 10 - { "show_text", "_WardensHouseWardenReceivedHM04Text" }, -- 11 - { "set_flag", "EVENT_GOT_HM04" }, -- 12 - { "jump", "end" }, -- 13 (jp .done) + { "give_item", "HM_STRENGTH", 1, false }, -- 12 + { "show_text", "_WardensHouseWardenReceivedHM04Text" }, -- 13 + { "set_flag", "EVENT_GOT_HM04" }, -- 14 + { "jump", "end" }, -- 15 (jp .done) -- #645: WardensHouseWardenText prints Gibberish1, then YesNoChoice, -- and the warden answers the same gibberish either way -- Gibberish2 -- on yes, Gibberish3 on no (scripts/WardensHouse.asm). The port -- printed the question and walked off before the answer. - { "label", "no_teeth" }, -- 14 - { "ask", "_WardensHouseWardenGibberish1Text" }, -- 15 - { "jump_if_true", "gibberish_yes" }, -- 16 - { "show_text", "_WardensHouseWardenGibberish3Text" }, -- 17 - { "jump", "end" }, -- 18 - { "label", "gibberish_yes" }, -- 19 - { "show_text", "_WardensHouseWardenGibberish2Text" }, -- 20 - { "jump", "end" }, -- 21 + { "label", "no_teeth" }, -- 16 + { "ask", "_WardensHouseWardenGibberish1Text" }, -- 17 + { "jump_if_true", "gibberish_yes" }, -- 18 + { "show_text", "_WardensHouseWardenGibberish3Text" }, -- 19 + { "jump", "end" }, -- 20 + { "label", "gibberish_yes" }, -- 21 + { "show_text", "_WardensHouseWardenGibberish2Text" }, -- 22 + { "jump", "end" }, -- 23 -- #535: pokered .got_item branch (scripts/WardensHouse.asm) -- -- printed on every subsequent talk once EVENT_GOT_HM04 is set. -- Text is _WardensHouseWardenHM04ExplanationText (text/WardensHouse.asm): -- HM04 teaches Strength, and hints at the Safari Zone secret house. - { "label", "got_hm04" }, -- 22 - { "show_text", "_WardensHouseWardenHM04ExplanationText" }, -- 23 + { "label", "got_hm04" }, -- 24 + { "show_text", "_WardensHouseWardenHM04ExplanationText" }, -- 25 }, }, } diff --git a/data/scripts/story2.lua b/data/scripts/story2.lua index 56bc86a1..2105162b 100644 --- a/data/scripts/story2.lua +++ b/data/scripts/story2.lua @@ -357,13 +357,15 @@ local function saffronGate(guardText, triggers, horizontal) return end if takeGuardDrink(game) then + -- SaffronGateGuardGiveDrinkText: sound_get_key_item sits between the + -- two text_far halves (scripts/Route5Gate.asm:102-106) game.stack:push(TextBox.new(game, t._SaffronGateGuardImParchedText or "Whoa, boy!\nI'm parched!", function() game.stack:push(TextBox.new(game, (t._SaffronGateGuardYouCanGoOnThroughText or "You can go on\nthrough!"), done)) - end)) + end, TextBox.soundOpts(game, "Get_Key_Item"))) return end game.stack:push(TextBox.new(game, @@ -403,7 +405,7 @@ local function saffronGate(guardText, triggers, horizontal) game.stack:push(TextBox.new(game, (t._SaffronGateGuardYouCanGoOnThroughText or "You can go on\nthrough!"))) - end)) + end, TextBox.soundOpts(game, "Get_Key_Item"))) return true end local back diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index a3cb3f86..9166891c 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -3970,7 +3970,11 @@ function BattleState:executeAction(user, target, action) -- nickname on purpose (no "Enemy " in AIPrintItemUseText), so the -- prefix splice must not touch them. for _, m in ipairs(TrainerAI.useItem(self, action.item)) do - self:sayNext(m) + if type(m) == "table" then + self:animNext(m.anim, false) + else + self:sayNext(m) + end end self:drainNext() require("src.core.Sound").play(self.data, "Heal_Ailment") @@ -5764,6 +5768,18 @@ local function balls() return ballQuads or nil end +-- engine/battle/draw_hud_pokeball_gfx.asm:97-108 +local function ballObpSheet() + local PaletteFX = require("src.render.PaletteFX") + if not PaletteFX.usesSpriteObp() then return nil end + local colors, group = PaletteFX.ogObj() + if not colors then return nil end + local SpriteRenderer = require("src.render.SpriteRenderer") + local ok, img = pcall(SpriteRenderer.obpImage, + "assets/generated/battle/balls.png", colors, group) + return ok and img or nil +end + function BattleState:drawCaughtBall(x, y) local quads = balls() if not quads then return end @@ -5774,10 +5790,16 @@ end function BattleState:drawBallRow(party, x, y, dx) local quads = balls() if not quads then return end + local obp = ballObpSheet() + local img = obp or quads.img for i = 1, 6 do local mon = party[i] local tile = not mon and 3 or mon.hp <= 0 and 2 or mon.status and 1 or 0 - love.graphics.draw(quads.img, quads[tile], x + (i - 1) * dx, y) + love.graphics.draw(img, quads[tile], x + (i - 1) * dx, y) + if obp then + require("src.render.PaletteFX") + .markUiSpriteRedraw(img, quads[tile], x + (i - 1) * dx, y) + end end end diff --git a/src/battle/TrainerAI.lua b/src/battle/TrainerAI.lua index 74cc7d58..2e602b5a 100644 --- a/src/battle/TrainerAI.lua +++ b/src/battle/TrainerAI.lua @@ -135,6 +135,8 @@ function TrainerAI.useItem(battle, item) -- trainer_ai.asm:719 -> effects.asm:414-415 Status.afterStatChange(battle, enemy, stat, battle.player) enemy.hazeStatReset = nil + -- trainer_ai.asm:716 -> effects.asm:484 (PlayCurrentMoveAnimation) + table.insert(msgs, { anim = "XSTATITEM_DUPLICATE_ANIM" }) table.insert(msgs, Strings("%s's\n%s rose!", displayName(enemy), Strings(STAT_LABEL[stat]))) elseif item == "GUARD_SPEC" then enemy.mist = true diff --git a/src/inventory/ItemEffects.lua b/src/inventory/ItemEffects.lua index b69dc899..6ed6073e 100644 --- a/src/inventory/ItemEffects.lua +++ b/src/inventory/ItemEffects.lua @@ -627,6 +627,10 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) end if itemId == "ESCAPE_ROPE" then + -- ItemUseEscapeRope: engine/items/item_effects.asm:1492 + if battle then + return "failed", { notTime(data, save) } + end return "escape_rope" end if itemId == "TOWN_MAP" then diff --git a/src/render/GbcPalette.lua b/src/render/GbcPalette.lua index 97c496ff..207f362f 100644 --- a/src/render/GbcPalette.lua +++ b/src/render/GbcPalette.lua @@ -146,10 +146,23 @@ local keyedShader = nil local keyedFailed = false local remapShader = nil local remapFailed = false +local shaderCtor = nil + +local function compiler() + local ctor = love and love.graphics and love.graphics.newShader or nil + if ctor ~= shaderCtor then + shaderCtor = ctor + shader, failed = nil, false + keyedShader, keyedFailed = nil, false + remapShader, remapFailed = nil, false + end + return ctor +end -- nil (and a one-shot warning) if shaders are unavailable, so callers can fall -- back to the plain grayscale draw instead of crashing a whole boot. function GbcPalette.shader() + compiler() if shader or failed then return shader end if not (love and love.graphics and love.graphics.newShader) then failed = true @@ -165,6 +178,7 @@ function GbcPalette.shader() end function GbcPalette.keyedShader() + compiler() if keyedShader or keyedFailed then return keyedShader end if not (love and love.graphics and love.graphics.newShader) then keyedFailed = true @@ -182,6 +196,7 @@ end -- The same contract as GbcPalette.shader for the backwards pass: nil rather -- than an error, so a caller can fall back to its own approximation. function GbcPalette.remapShader() + compiler() if remapShader or remapFailed then return remapShader end if not (love and love.graphics and love.graphics.newShader) then remapFailed = true diff --git a/src/render/PaletteFX.lua b/src/render/PaletteFX.lua index 86fab381..6a4d3149 100644 --- a/src/render/PaletteFX.lua +++ b/src/render/PaletteFX.lua @@ -82,6 +82,25 @@ local function darkGroups(groups) return out end +-- home/fade.asm:52-58 +local fadeObpMap = nil +local fadeObpGroups = setmetatable({}, { __mode = "k" }) + +function PaletteFX.setFadeObp(map) + fadeObpMap = map +end + +function PaletteFX.fadeObp(colors, group) + if not (colors and fadeObpMap) then return colors, group end + local suffix = fadeObpGroups[fadeObpMap] + if not suffix then + suffix = "#fade" .. fadeObpMap[0] .. fadeObpMap[1] + .. fadeObpMap[2] .. fadeObpMap[3] + fadeObpGroups[fadeObpMap] = suffix + end + return PaletteFX.permute(colors, fadeObpMap), tostring(group) .. suffix +end + -- Classic DMG pea-soup greens (#9BBC0F / #8BAC0F / #306230 / #0F380F) PaletteFX.CLASSIC = { { 155, 188, 15 }, { 139, 172, 15 }, { 48, 98, 48 }, { 15, 56, 15 }, @@ -143,9 +162,9 @@ end -- is unused there and kept as Red green only as a safe leftover. function PaletteFX.ogObj() if GameVersion.isBlue() then - return PaletteFX.darkObp(PaletteFX.GBC_OBJ_BLUE, "gbcobj_blue") + return PaletteFX.fadeObp(PaletteFX.darkObp(PaletteFX.GBC_OBJ_BLUE, "gbcobj_blue")) end - return PaletteFX.darkObp(PaletteFX.GBC_OBJ, "gbcobj") + return PaletteFX.fadeObp(PaletteFX.darkObp(PaletteFX.GBC_OBJ, "gbcobj")) end -- The DMG object ramp every mode except OG RED bakes onto overworld sprites, diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index 34568626..4c51cc66 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -358,6 +358,7 @@ function Renderer:beginFrame(transparent) -- rBGP is a per-frame register here: the state that draws a dark map -- re-arms it while it draws (#322), so nothing inherits last frame's PaletteFX.setShadeMap(nil) + PaletteFX.setFadeObp(nil) PaletteFX.setPass("ui") love.graphics.setCanvas(self.canvas) if transparent then diff --git a/src/render/TextBox.lua b/src/render/TextBox.lua index ea385c44..0246dd3c 100644 --- a/src/render/TextBox.lua +++ b/src/render/TextBox.lua @@ -173,6 +173,9 @@ function TextBox.new(game, text, onDone, opts) -- opts.pauseSounds[i] is the sfx the i-th marker fires once its wait is -- over (text_asm SFX_SWAP, engine/pokemon/learn_move.asm:210-213) self.pauseSounds = opts and opts.pauseSounds + -- opts.pauseSoundWait: the mark is TextCommand_SOUND, not TextCommand_PAUSE + -- (home/text.asm:506-534) + self.pauseSoundWait = opts and opts.pauseSoundWait self.pauseAt = marks and mapPauses(self.pages, marks) or nil self.pageIndex = 1 self.lineIndex = 1 @@ -445,11 +448,24 @@ function TextBox:update(dt) end self.pauseFrames = nil local snd = self.pauseSounds and self.pauseSounds[self.pauseMark] + local src if type(snd) == "function" then - snd() + src = snd() elseif snd then - require("src.core.Sound").play(self.game.data, snd) + src = require("src.core.Sound").play(self.game.data, snd) end + -- home/text.asm:530 + if src and self.pauseSoundWait then + self.pauseSrc = src + self.pauseSrcLeft = sfxWaitFrames(src) + end + end + if self.pauseSrc then + self.pauseSrcLeft = (self.pauseSrcLeft or 0) - sfxWaitStep(self.game) + local playing = self.pauseSrc.isPlaying and self.pauseSrc:isPlaying() + if playing and self.pauseSrcLeft > 0 then return end + if playing then pcall(self.pauseSrc.stop, self.pauseSrc) end + self.pauseSrc, self.pauseSrcLeft = nil, nil end if self.done then -- opts.stay: the box is finished but stays up under whatever the caller @@ -607,7 +623,8 @@ function TextBox:update(dt) if marks and marks[self.charIndex] then self.pauseMark = marks[self.charIndex] -- TextCommand_PAUSE reads hJoyHeld, so a held A/B skips the wait - self.pauseFrames = (input:isDown("a") or input:isDown("b")) + self.pauseFrames = (self.pauseSoundWait + or input:isDown("a") or input:isDown("b")) and 0 or PAUSE_FRAMES break end diff --git a/src/render/Transition.lua b/src/render/Transition.lua index 6646387c..421059c3 100644 --- a/src/render/Transition.lua +++ b/src/render/Transition.lua @@ -30,6 +30,13 @@ local FADE_BGP = { white = { out = { 0x90, 0x40, 0x00 }, ["in"] = { 0x40, 0x90, 0xE4 } }, } +-- FadePal1..FadePal8 rOBP0 column, pokered home/fade.asm:65-73 +local FADE_OBP0 = { + black = { out = { 0xD0, 0xE4, 0xFE, 0xFF }, + ["in"] = { 0xFF, 0xFE, 0xE4, 0xD0 } }, + white = { out = { 0x80, 0x40, 0x00 }, + ["in"] = { 0x40, 0x80, 0xD0 } }, +} local BGP_IDENTITY = 0xE4 -- Veil alpha `t` frames into a `len`-frame fade out. GBFadeOutToBlack @@ -155,20 +162,28 @@ function Transition.shadeMapFor(byte) return map end -function Transition:fadeTable() +function Transition:fadeTable(column) local c = self.color or { 0, 0, 0 } local white = (c[1] or 0) > 0.5 and (c[2] or 0) > 0.5 and (c[3] or 0) > 0.5 - local set = FADE_BGP[white and "white" or "black"] + local set = (column or FADE_BGP)[white and "white" or "black"] return set[self.phase == "in" and "in" or "out"] end +local function fadeByte(self, tab) + local len = (self.phase == "out") and self.frames or self.framesIn + local step = math.min(#tab, + math.floor(fadeAlpha(self.t, len) * (#tab - 1) + 0.5) + 1) + return tab[step] or 0xFF +end + -- pokered home/fade.asm:58 function Transition:bgp() - local len = (self.phase == "out") and self.frames or self.framesIn - local tab = self:fadeTable() - local step = math.min(#tab, - math.floor(fadeAlpha(self.t, len) * (#tab - 1) + 0.5) + 1) - return tab[step] or 0xFF + return fadeByte(self, self:fadeTable()) +end + +-- pokered home/fade.asm:55 +function Transition:obp0() + return fadeByte(self, self:fadeTable(FADE_OBP0)) end function Transition:draw() diff --git a/src/ui/DexEntryMenu.lua b/src/ui/DexEntryMenu.lua index 5c934770..5671366e 100644 --- a/src/ui/DexEntryMenu.lua +++ b/src/ui/DexEntryMenu.lua @@ -124,6 +124,9 @@ local DIVIDER = { 0x6B, 0x6B, 0x69, 0x6B, 0x69, 0x6B, 0x69, 0x6B, 0x69, 0x6A, } +-- engine/menus/pokedex.asm:500-504 +local PIC_DELAY = 36 + function DexEntryMenu.new(game, speciesOrOpts, onDone) local species, forceOwned = resolveArgs(speciesOrOpts) local self = setmetatable({ game = game, forceOwned = forceOwned, @@ -140,8 +143,8 @@ function DexEntryMenu.new(game, speciesOrOpts, onDone) self.blink = 0 local pages = descPages(game, self.def, forceOwned) self.pageCount = pages and #pages or 1 - -- engine/menus/pokedex.asm:500-506, home/pokemon.asm:145-148 - self.crySrc = require("src.core.Sound").playCry(game.data, species) + self.species = species + self.picDelay = PIC_DELAY return self end @@ -158,6 +161,15 @@ end function DexEntryMenu:update(dt) local input = self.game.input self.blink = ((self.blink or 0) + 1) % 60 + if (self.picDelay or 0) > 0 then + self.picDelay = self.picDelay - 1 + if self.picDelay == 0 then + -- engine/menus/pokedex.asm:504-506, home/pokemon.asm:145-148 + self.crySrc = require("src.core.Sound").playCry(self.game.data, + self.species) + end + return + end if self:crying() then return end if input:wasPressed("a") or input:wasPressed("b") then -- home/text.asm:245 @@ -171,9 +183,10 @@ function DexEntryMenu:update(dt) end function DexEntryMenu:draw() - DexEntryMenu.render(self.game, self.def, self.sprite, self.forceOwned, - self.spriteTrueColor, self.page, - { crying = self:crying(), + local waiting = (self.picDelay or 0) > 0 + DexEntryMenu.render(self.game, self.def, not waiting and self.sprite or nil, + self.forceOwned, self.spriteTrueColor, self.page, + { crying = self:crying(), waiting = waiting, arrow = (self.blink or 0) < 30 }) end @@ -242,7 +255,8 @@ function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor, page, sta Font.draw("″", 136, 48) Font.draw(Strings("WT"), 72, 64) Font.draw(Strings("lb"), 136, 64) - if not numbers then + -- engine/menus/pokedex.asm:449-450, overwritten at :518-520 + if not numbers or state.crying or state.waiting then Font.draw("?", 104, 48) Font.draw("??", 120, 48) Font.draw("???", 112, 64) @@ -250,7 +264,7 @@ function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor, page, sta end -- engine/menus/pokedex.asm:516: everything below the divider waits on the -- cry the line above it started - if state.crying then + if state.crying or state.waiting then love.graphics.setColor(1, 1, 1, 1) return end diff --git a/src/ui/StartMenu.lua b/src/ui/StartMenu.lua index 75885878..27ca32ae 100644 --- a/src/ui/StartMenu.lua +++ b/src/ui/StartMenu.lua @@ -185,10 +185,11 @@ function StartMenu.new(game) local maxVisible = math.floor((Renderer.HEIGHT / 8 - 2) / rowStep) menu = Menu.new(game, items, -- the START menu hugs the top-right corner of the SCREEN, not of a - -- centred letterbox: at 9,0 x 11 it is already flush with the top and - -- right of the 20x18 grid, so the anchor keeps it flush when the view - -- is zoomed out and the letterbox no longer fills the window - { tx = 9, ty = 0, tw = 11, maxVisible = maxVisible, startCloses = true, + -- centred letterbox: at 10,0 x 10 (engine/menus/draw_start_menu.asm:5-12) + -- it is already flush with the top and right of the 20x18 grid, so the + -- anchor keeps it flush when the view is zoomed out and the letterbox no + -- longer fills the window + { tx = 10, ty = 0, tw = 10, maxVisible = maxVisible, startCloses = true, anchor = "topright" }) -- the cursor position survives closing the menu -- (wBattleAndStartSavedMenuItem, home/start_menu.asm) @@ -202,7 +203,7 @@ function StartMenu.new(game) -- inside the Safari Zone the start menu also shows remaining steps and -- SAFARI BALLs (PrintSafariZoneSteps, engine/overworld/player_state.asm: - -- 219-224): a 9x5 border at the top-left with "steps/500" and "BALL xx". + -- 219-224): a 9x5 border at the top-left with "steps/500" and "BALL×xx". -- It opens with `cp SAFARI_ZONE_EAST / ret c`, so only the nine interior -- maps ($D9..$E1) get it -- SAFARI_ZONE_GATE is $9C and falls under that -- early out, and used to show "502/500" while the player was still @@ -220,7 +221,11 @@ function StartMenu.new(game) Font.draw(("%3d"):format(math.floor(safari.steps or 0)), 8, 8) Font.draw("/500", 32, 8) Font.draw(Strings("BALL"), 8, 24) - Font.draw(("%2d"):format(math.floor(safari.balls or 0)), 48, 24) + -- engine/overworld/player_state.asm:236-249, home/print_num.asm:205-217 + local digits = tostring(math.max(0, math.floor(safari.balls or 0))) + local bx = (7 - #digits) * 8 + Font.draw("\xc3\x97", bx, 24) + Font.draw(digits, bx + 8, 24) love.graphics.setColor(1, 1, 1, 1) end end diff --git a/src/ui/TrainerCard.lua b/src/ui/TrainerCard.lua index e1e0bf7d..26534a20 100644 --- a/src/ui/TrainerCard.lua +++ b/src/ui/TrainerCard.lua @@ -75,6 +75,13 @@ function TrainerCard.new(game, opts) game.data, "front", { kind = "trainer_card" }) self.pic = tryImage(picPath) self.picTrueColor = self.pic and picTrueColor or false + -- engine/menus/start_sub_menus.asm:480-487 + if self.pic then + local pw, ph = self.pic:getDimensions() + self.picW = math.max(0, math.min(40, pw)) + self.picH = math.min(56, ph) + self.picQuad = love.graphics.newQuad(0, 0, self.picW, self.picH, pw, ph) + end return self end @@ -123,9 +130,9 @@ function TrainerCard:draw() -- top card (rows 0-7): NAME / MONEY / TIME, pic upper-right self:frameBox(0, 0, 20, 8) - if self.pic then + if self.pic and self.picQuad then love.graphics.setColor(1, 1, 1, 1) - love.graphics.draw(self.pic, 104, 4) + love.graphics.draw(self.pic, self.picQuad, 120, 8) -- True-colour portraits (e.g. mod-injected custom characters) carry their -- own colours and must not be re-mapped by the MEWMON zone shader. -- markTrueColor appends a colors=false zone that the Renderer splices at @@ -133,19 +140,19 @@ function TrainerCard:draw() -- the palette shader on top of the already-colourised frame. -- This matches the pattern used by OakSpeech, HallOfFame and SummaryMenu. if self.picTrueColor then - local w, h = self.pic:getDimensions() - require("src.render.PaletteFX").markTrueColor(104, 4, w, h) + require("src.render.PaletteFX").markTrueColor(120, 8, self.picW, self.picH) end end love.graphics.setColor(0, 0, 0, 1) Font.draw(Strings("NAME/%s", save.player.name or "RED"), 16, 16) Font.draw(Strings("MONEY/¥%d", save.money or 0), 16, 32) local t = math.floor(save.playTime or 0) - Font.draw(Strings("TIME/%3d:%02d", math.floor(t / 3600), + -- home/print_num.asm:217 + Font.draw(Strings("TIME/ %d:%02d", math.floor(t / 3600), math.floor(t / 60) % 60), 16, 48) -- the circle-dotted BADGES banner (TrainerInfo_BadgesText) - self:frameBox(0, 8, 20, 3) + -- engine/menus/start_sub_menus.asm:544 love.graphics.setColor(0, 0, 0, 1) Font.draw(Strings("BADGES"), 56, 72) if self.circle then @@ -155,12 +162,21 @@ function TrainerCard:draw() love.graphics.setColor(0, 0, 0, 1) end - -- numbered badge grid (rows 11-17): face by default, badge when owned - self:frameBox(0, 11, 20, 7) + -- numbered badge grid (rows 10-17): face by default, badge when owned + -- engine/menus/start_sub_menus.asm:537 + self:frameBox(1, 10, 18, 8) + if self.frame then + love.graphics.setColor(1, 1, 1, 1) + for j = 0, 7 do + love.graphics.draw(self.frame.img, self.frame.quads[8], 0, (10 + j) * 8) + love.graphics.draw(self.frame.img, self.frame.quads[8], 152, (10 + j) * 8) + end + end local badges = Badges.list(self.game.data) for i = 1, #badges do local col, row = (i - 1) % 4, math.floor((i - 1) / 4) - local tx, ty = 16 + col * 32, 94 + row * 24 + -- engine/menus/draw_badges.asm:46 + local tx, ty = 16 + col * 32, 88 + row * 24 -- the extracted sheets cover the eight Kanto slots; a longer badge -- list draws its extra entries unnumbered rather than crashing if self.nums and self.nums.quads[i - 1] then @@ -171,7 +187,7 @@ function TrainerCard:draw() love.graphics.setColor(1, 1, 1, 1) local owned = save.inventory[Badges.itemFor(badges[i])] local sheet = owned and self.badges or self.faces - love.graphics.draw(sheet.img, sheet.quads[i - 1], tx + 4, ty + 6) + love.graphics.draw(sheet.img, sheet.quads[i - 1], tx + 8, ty + 8) end end love.graphics.setColor(1, 1, 1, 1) diff --git a/src/ui/gen2/BoxMenu.lua b/src/ui/gen2/BoxMenu.lua index b1d94225..8abef309 100644 --- a/src/ui/gen2/BoxMenu.lua +++ b/src/ui/gen2/BoxMenu.lua @@ -136,6 +136,9 @@ local RELEASED = Strings.source("Released .\fBye,\n%s!") local GOT_MON = Strings.source("Got %s!") local STORED_MON = Strings.source("Stored %s!") +-- engine/pokemon/bills_pc.asm:1804 `ld c, 50 / call DelayFrames` +local STORE_MESSAGE_FRAMES = 50 + -- Boxes.lua owns the storage mutation, so its finite refusals arrive here as -- return values. Mark their complete text for the catalog and look them up -- when they cross this UI boundary. @@ -184,6 +187,7 @@ function BoxMenu.new(game, opts) self.scroll = 0 self.picCache = {} self.message = nil + self.messageFrames = nil -- nil while the list is being browsed; "submenu" while MOVE/STATS/CANCEL is -- up, "insert" while the insert cursor is picking a destination. Only the -- move screen has phases -- the other two lists act on A. @@ -364,6 +368,15 @@ function BoxMenu:openStats() }) end +-- engine/pokemon/bills_pc.asm:1785-1787 ClearBox over the left panel, then the +-- timed hold at :1804; .Init (:161) only runs after it. +function BoxMenu:holdMessage(text) + self.message = text + self.messagePage = 1 + self.messageFrames = STORE_MESSAGE_FRAMES + self.panelCleared = true +end + -- engine/pokemon/bills_pc.asm:397-411: failed withdraw stays on the submenu. function BoxMenu:doWithdraw() local ok, result = Boxes.withdraw(self.save, self.boxIndex, self.index) @@ -376,9 +389,7 @@ function BoxMenu:doWithdraw() -- engine/pokemon/bills_pc.asm:1817 self:playMonCry(result) local name = result.nickname or result.name or result.species or "?" - self.message = Strings(GOT_MON, name) - self.phase = nil - self:clampIndex() + self:holdMessage(Strings(GOT_MON, name)) end -- engine/pokemon/bills_pc.asm:155 BillsPCDepositFuncDeposit @@ -394,10 +405,7 @@ function BoxMenu:doDeposit() end -- engine/pokemon/bills_pc.asm:1762 self:playMonCry(result) - self.message = Strings(STORED_MON, name) - self.phase = nil - self.index, self.scroll = 1, 0 - self:clampIndex() + self:holdMessage(Strings(STORED_MON, name)) end function BoxMenu:chooseSubmenu() @@ -522,6 +530,20 @@ function BoxMenu:update(_dt) local input = self.game and self.game.input if not input then return end + -- engine/pokemon/bills_pc.asm:1804 DelayFrames reads no joypad; :161 zeroes + -- wJumptableIndex / cursor / scroll only once it returns. + if self.messageFrames then + self.messageFrames = self.messageFrames - 1 + if self.messageFrames > 0 then return end + self.messageFrames = nil + self.message, self.messagePage = nil, nil + self.panelCleared = nil + self.phase = nil + self.index, self.scroll = 1, 0 + self:clampIndex() + return + end + if self.message then if input:wasPressed("a") or input:wasPressed("b") then local page = (self.messagePage or 1) + 1 @@ -954,7 +976,7 @@ function BoxMenu:drawPanel() -- The left panel: pic, level, gender, species -- blank on CANCEL, the way -- PCMonInfo clears it when the selection is not a mon. - local mon = self:panelMon() + local mon = not self.panelCleared and self:panelMon() or nil if mon then -- `cp EGG / ret z` right after the frontpic: no name, no level, no gender -- (engine/pokemon/bills_pc.asm:1057-1058). diff --git a/src/world/FieldDefaults.lua b/src/world/FieldDefaults.lua index c174e8f1..1bd193d4 100644 --- a/src/world/FieldDefaults.lua +++ b/src/world/FieldDefaults.lua @@ -69,7 +69,8 @@ local SAFARI = { "SAFARI_ZONE_NORTH_REST_HOUSE", "SAFARI_ZONE_WEST_REST_HOUSE", "SAFARI_ZONE_SECRET_HOUSE", }, - exitWarp = { map = "SAFARI_ZONE_GATE", x = 4, y = 3, facing = "down" }, + -- wDestinationWarpID $3 = warp_event 4,0 (data/maps/objects/SafariZoneGate.asm:12) + exitWarp = { map = "SAFARI_ZONE_GATE", x = 4, y = 0, facing = "down" }, } -- home/overworld.asm LoadPlayerSpriteGraphics / LoadSurfingPlayerSprite- diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index f12030cc..8db7cfe3 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -84,9 +84,15 @@ local TELEPORT_OUT_SFX = { [4] = "Teleport_Exit2", [8] = "Teleport_Exit2", [12] = "Teleport_Exit2", [TELEPORT_OUT_INPLACE] = "Teleport_Exit1", } +-- engine/overworld/player_animations.asm:104-118 +local WARP_PAD_OUT_HOLDS = { 3, 3, 3, 3, 3, 0 } +local WARP_PAD_OUT_FRAMES = 15 -- counting 0 up to 8 -- engine/overworld/player_animations.asm:20 local TELEPORT_IN_HOLDS = { 3, 3, 3, 3, 3, 0, 1, 2, 3, 4, 5, 6, 7, 0 } local TELEPORT_IN_FRAMES = 43 +-- engine/overworld/player_animations.asm:22-25 +local TELEPORT_IN_PAD_HOLDS = { 3, 3, 3, 3, 3, 3, 0 } +local TELEPORT_IN_PAD_FRAMES = 18 local SPIN_DOWN_STEPS = 6 -- engine/overworld/player_animations.asm:41-45 local HOLE_IN_HOLDS = { 3, 3, 3, 3, 3, 0 } @@ -108,20 +114,31 @@ local HEAL_FLASH_MAP = { [0] = 0, [1] = 2, [2] = 1, [3] = 3 } -- engine/overworld/healing_machine.asm:54 local HEAL_FLASH_MAP_GBC = { [0] = 0, [1] = 0, [2] = 1, [3] = 2 } +-- engine/overworld/healing_machine.asm:13-14 +local HEAL_OBP1_MAP = { [0] = 0, [1] = 0, [2] = 2, [3] = 3 } +-- engine/overworld/healing_machine.asm:50 +local HEAL_OBP1_FLASH_MAP = { [0] = 0, [1] = 2, [2] = 0, [3] = 3 } + -- engine/overworld/healing_machine.asm:74 local function healMachineShader(visible) local base = PaletteFX.usesGbcPack() and PaletteFX.healMachineObp() or nil + local ogColors = nil + if not base and PaletteFX.usesSpriteObp() then + ogColors = PaletteFX.ogObj() + base = ogColors and PaletteFX.permute(ogColors, + visible and HEAL_OBP1_MAP or HEAL_OBP1_FLASH_MAP) + end if not base and visible then return nil end local shader = PaletteFX.shader() if not shader then return nil end local colors = base or PaletteFX.GRAYS - if not visible then + if not visible and not ogColors then colors = PaletteFX.permute(colors, base and HEAL_FLASH_MAP_GBC or HEAL_FLASH_MAP) end PaletteFX.sendColors(shader, colors) love.graphics.setShader(shader) - return shader + return shader, ogColors and colors or nil end -- scripts/VermilionDock.asm:39 VermilionDockSSAnneLeavesScript: her hull is @@ -388,6 +405,7 @@ function OverworldState:setMap(mapId, x, y, facing, opts) end self.marchers = {} self.shipAnim = nil + self.spinnerSliding = nil local queue = self.pendingScripts if queue then for i = #queue, 1, -1 do @@ -1338,12 +1356,13 @@ function OverworldState:update(dt) local step = self.player.spinStep or 0 if step ~= self.teleportOut.step then self.teleportOut.step = step - local key = TELEPORT_OUT_SFX[step] + local key = (self.teleportOut.sfx or TELEPORT_OUT_SFX)[step] if key then require("src.core.Sound").play(Game.data, key) end end self.teleportOut.frames = self.teleportOut.frames - 1 if self.teleportOut.frames <= 0 then local onDone = self.teleportOut.onDone + local go = self.teleportOut.go self.teleportOut = nil self.player.spinning = false self.player.spinFrames = nil @@ -1356,7 +1375,7 @@ function OverworldState:update(dt) -- over but the arrival spin-drop is not armed until startWarpTo's -- midpoint, so without this the standing trainer shows under the veil self.playerHidden = true - self:warpToHealPoint(onDone, { arrive = "teleport" }) + if go then go() else self:warpToHealPoint(onDone, { arrive = "teleport" }) end return end end @@ -2777,9 +2796,10 @@ function OverworldState:tryCardKeyDoor(fx, fy) end -- ../pokered/engine/events/card_key.asm:63-67 Game.stack:push(TextBox.new(Game, - (t._CardKeySuccessText1 or Strings("Bingo!")) + TextBox.strip(t._CardKeySuccessText1 or Strings("Bingo!")) + .. TextBox.PAUSE .. (t._CardKeySuccessText2 or romText(Game.data, "_CardKeySuccessText2", "\nThe CARD KEY\nopened the door!")), - openDoor, TextBox.soundOpts(Game, "Get_Item1"))) + openDoor, { pauseSounds = { "Get_Item1" }, pauseSoundWait = true })) return true end @@ -4620,6 +4640,7 @@ function OverworldState:runSpinnerMoves(moves, i) local mv = moves[i] if not mv then self.player.spinning = false + self.spinnerSliding = nil -- Scripted steps skip onStepComplete while they run; once the RLE -- finishes, re-enter the normal landing pipeline so chained spinners, -- Seafoam currents, and CheckWarpsNoCollision (incl. BIT_FORCED_WARP) @@ -4628,6 +4649,8 @@ function OverworldState:runSpinnerMoves(moves, i) return end self.player.spinning = true -- spin the sprite while sliding + -- home/overworld.asm:268-273 + self.spinnerSliding = true self:scriptMove(self.player, mv.dir, mv.count, function() self:runSpinnerMoves(moves, i + 1) end) @@ -4932,6 +4955,8 @@ end function OverworldState:safariGameOver(text) require("src.core.Sound").play(Game.data, "Safari_Zone_PA") Game.save.safari = nil + -- SetEvent EVENT_SAFARI_GAME_OVER -- engine/events/hidden_events/safari_game.asm:54 + Game.save.safariGameOver = true local t = Game.data.text Game.stack:push(TextBox.new(Game, (text or "") .. "\f" .. (t._GameOverText or romText(Game.data, "_GameOverText", "PA: Your SAFARI\nGAME is over!")), @@ -5085,11 +5110,24 @@ function OverworldState:takeWarp(warpDef) if pad == "pad" then -- teleporter: spin out with the exit SFX, spin back in on arrival -- (player_animations.asm _LeaveMapAnim / EnterMapAnim) + -- engine/overworld/player_animations.asm:104-118 require("src.core.Sound").play(Game.data, "Teleport_Exit1") + self:hidePikachuForWarp() + self.arriveWarp = "teleport" + self.player.inputLocked = true self.player.spinning = true self.player.spinTimer = 0 - self.arriveWarp = "teleport" - self:startWarpTo(destMap, x, y, facing, nil, { pikachuSpawn = spawn }) + self.player.spinFrames = WARP_PAD_OUT_FRAMES + self.player.spinTotal = WARP_PAD_OUT_FRAMES + self.player.spinRise = true + self.player.spinHolds = WARP_PAD_OUT_HOLDS + self.player.spinStep = 0 + self.player.spinHold = WARP_PAD_OUT_HOLDS[1] + self.player.spinRiseFrom = -1 + self.teleportOut = { frames = WARP_PAD_OUT_FRAMES, step = 0, sfx = {}, + go = function() + self:startWarpTo(destMap, x, y, facing, nil, { pikachuSpawn = spawn }) + end } return elseif pad == "hole" then self:fallThroughHole(destMap, x, y, facing) @@ -5253,14 +5291,19 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts) self.delaySfx = { frames = HOLE_IN_FRAMES, key = "Teleport_Enter2" } -- the sprite spins down into place (EnterMapAnim -- PlayerSpinWhileMovingDown), not just the SFX + -- engine/overworld/player_animations.asm:22-25 + local onPad = self.map.warpPadOrHoleAt + and self.map:warpPadOrHoleAt(x, y) == "pad" + local holds = onPad and TELEPORT_IN_PAD_HOLDS or TELEPORT_IN_HOLDS + local frames = onPad and TELEPORT_IN_PAD_FRAMES or TELEPORT_IN_FRAMES self.player.spinning = true self.player.spinTimer = 0 - self.player.spinFrames = TELEPORT_IN_FRAMES - self.player.spinTotal = TELEPORT_IN_FRAMES + self.player.spinFrames = frames + self.player.spinTotal = frames self.player.spinDrop = true - self.player.spinHolds = TELEPORT_IN_HOLDS + self.player.spinHolds = holds self.player.spinStep = 0 - self.player.spinHold = TELEPORT_IN_HOLDS[1] + self.player.spinHold = holds[1] self.player.spinDropSteps = SPIN_DOWN_STEPS -- engine/overworld/player_animations.asm:19 self.spinArrive = true @@ -5678,6 +5721,10 @@ function OverworldState:drawWorld() local fadeMap = fadeBgp and Transition.shadeMapFor(fadeBgp) if fadeBgp and not self:bakedWorldColors() then PaletteFX.setShadeMap(fadeMap) + -- home/fade.asm:52-58 + -- home/fade.asm:65-73 + local fadeObp = fade.obp0 and fade:obp0() or fadeBgp + PaletteFX.setFadeObp(Transition.shadeMapFor(fadeObp)) fade.paletteStepped = true else PaletteFX.setShadeMap((self.dark and not battleOverWorld) @@ -5690,7 +5737,7 @@ function OverworldState:drawWorld() -- let the renderer know whether a spinner puzzle is currently sliding -- the player, so it can flicker the arrow tiles between the blur and -- static graphic (engine/overworld/spinners.asm LoadSpinnerArrowTiles) - require("src.render.TileRenderer").setSpinning(self.player.spinning) + require("src.render.TileRenderer").setSpinning(self.spinnerSliding or false) local cam = self.camera -- ShakeElevator's oscillation (engine/overworld/elevator.asm) writes -- hSCY, which scrolls the BG layer only -- tiles bounce while OAM @@ -5774,7 +5821,8 @@ function OverworldState:drawWorld() love.graphics.newQuad(0, 8, 8, 8, w, h), -- ball ($7d) } end - local shader = healMachineShader(ha.visible) + local shader, redrawColors = healMachineShader(ha.visible) + local mark = redrawColors and PaletteFX.spriteRedrawPassActive() -- TileRenderer windows with -floor(cam), so the overlay must use the -- same snap or a fractional camera (odd fill/tilt view sizes) parks -- the balls a pixel off the machine tiles @@ -5782,14 +5830,26 @@ function OverworldState:drawWorld() local oy = ha.py - 64 - math.floor(cam.y) love.graphics.setColor(1, 1, 1, 1) love.graphics.draw(img, self.healMachineQuads[1], ox + 44, oy + 20) + if mark then + PaletteFX.markSpriteRedraw(img, self.healMachineQuads[1], + ox + 44, oy + 20, 1, redrawColors) + end for i = 1, math.min(ha.lit, #HEAL_BALL_XY) do local b = HEAL_BALL_XY[i] if b[3] then -- right column: OAM_XFLIP love.graphics.draw(img, self.healMachineQuads[2], ox + b[1] + 8, oy + b[2], 0, -1, 1) + if mark then + PaletteFX.markSpriteRedraw(img, self.healMachineQuads[2], + ox + b[1] + 8, oy + b[2], -1, redrawColors) + end else love.graphics.draw(img, self.healMachineQuads[2], ox + b[1], oy + b[2]) + if mark then + PaletteFX.markSpriteRedraw(img, self.healMachineQuads[2], + ox + b[1], oy + b[2], 1, redrawColors) + end end end if shader then love.graphics.setShader() end diff --git a/tests/crystal_world_test.lua b/tests/crystal_world_test.lua index ede2212e..0af6fbda 100644 --- a/tests/crystal_world_test.lua +++ b/tests/crystal_world_test.lua @@ -65,6 +65,16 @@ if not mapsFile then end mapsFile:close() +local CacheContract = require("src.import.CacheContract") +local markerFile = io.open(cache .. "/rom-cache.complete", "rb") +local marker = markerFile and markerFile:read("*a") +if markerFile then markerFile:close() end +if not CacheContract.markerMatches("crystal", marker) then + check(true, "crystal cache predates the current format : SKIP") + S.finish() + return +end + local function loadLua(rel) local chunk = loadfile(cache .. "/" .. rel) if not chunk then return nil end diff --git a/tests/drivers/dex_pic_bug307_test.lua b/tests/drivers/dex_pic_bug307_test.lua index 493c9767..24170be7 100644 --- a/tests/drivers/dex_pic_bug307_test.lua +++ b/tests/drivers/dex_pic_bug307_test.lua @@ -50,7 +50,7 @@ return function(game) for _, species in ipairs(CASES) do while game.stack:top() do game.stack:pop() end Screens.push(game, "DexEntryMenu", { species = species, forceOwned = true }) - U.wait(30) + U.wait(60) U.shot(game, SHOT_DIR .. "/dex_" .. species:lower() .. ".png") U.log("captured", SHOT_DIR .. "/dex_" .. species:lower() .. ".png") end diff --git a/tests/engine/pokedex_entry_layout_bug1341.lua b/tests/engine/pokedex_entry_layout_bug1341.lua index 213b6809..f45df507 100644 --- a/tests/engine/pokedex_entry_layout_bug1341.lua +++ b/tests/engine/pokedex_entry_layout_bug1341.lua @@ -62,6 +62,9 @@ local ns = DexEntryMenu.new(game, "BULBASAUR") eq(ns.pageCount, 2, "the entry has two -separated pages") eq(ns.page, 1, "starts on page 1") +-- engine/menus/pokedex.asm:500-506 +ns.picDelay = 0 + ns:draw() check(hasText("BULBASAUR", 72, 16), "name at (72,16), not the port's old (72,8)") check(hasText("SEED POKEMON", 72, 32), "kind at (72,32)") diff --git a/tests/engine/rby_translation_runtime_test.lua b/tests/engine/rby_translation_runtime_test.lua index 478a06e1..100c20d8 100644 --- a/tests/engine/rby_translation_runtime_test.lua +++ b/tests/engine/rby_translation_runtime_test.lua @@ -282,7 +282,7 @@ local harvestCalls = { 'Strings("No.%03d",', 'Strings("IDNo.%05d",', }, ["src/ui/TrainerCard.lua"] = { - 'Strings("MONEY/¥%d",', 'Strings("TIME/%3d:%02d",', + 'Strings("MONEY/¥%d",', 'Strings("TIME/ %d:%02d",', }, ["src/world/OverworldController.lua"] = { 'Strings("BILL\'S PC")', 'Strings("%s\'s PC",', 'Strings("HEAL")', diff --git a/tests/engine/safari_gate_bug540.lua b/tests/engine/safari_gate_bug540.lua index 409032af..b7b617ce 100644 --- a/tests/engine/safari_gate_bug540.lua +++ b/tests/engine/safari_gate_bug540.lua @@ -30,7 +30,22 @@ local ScriptRunner = require("src.script.ScriptRunner") local answer = true package.loaded["src.render.TextBox"] = { - new = function(_, text, done) return { text = text, done = done } end, + new = function(_, text, done, opts) + local box = { text = text, opts = opts } + box.done = function() + if opts and opts.choice then + opts.choice(answer) + elseif done then + done() + end + end + return box + end, + soundOpts = function(_, sound, opts) + opts = opts or {} + opts.auto = { sound = sound, wait = true } + return opts + end, } package.loaded["src.ui.ChoiceBox"] = { new = function(_, cb) return { done = function() cb(answer) end } end, @@ -96,11 +111,42 @@ answer = true local ow = newWorld(4, 2) local game = newGame(ow) T.eq(gate.onStep(game, ow, 4, 2), true, "the trigger cell claims the step") +-- SafariZoneGateDefaultScript displays TEXT_..._WORKER1_1 before dispatching +-- to the join prompt (scripts/SafariZoneGate.asm:22-24) (#2205) +T.check(ow.pushed[1] and ow.pushed[1].text:find("SAFARI ZONE"), + "the worker welcomes you before the join prompt") +T.eq(ow.player.facing, "right", "SPRITE_FACING_RIGHT is written on both branches") +local joinBox = ow.pushed[2] +T.check(joinBox ~= nil and joinBox.opts ~= nil and joinBox.opts.choice ~= nil, + "the YES/NO rides over the still-visible join text") +T.check(joinBox and joinBox.opts and joinBox.opts.money ~= nil + and joinBox.opts.moneyWithChoice == true, + "MONEY_BOX goes up with the YES/NO") T.check(game.save.safari ~= nil, "paying starts the game") T.eq(game.save.safari.steps, 502, "wSafariSteps is written as 502") T.eq(game.save.safari.balls, 30, "SAFARI_BALLS_RECEIVED is 30") T.eq(game.save.money, 2500, "the ¥500 fee is taken") +-- .MakePaymentText carries sound_get_item_1 between the "received 30 SAFARI +-- BALLs!" page and the PA page (scripts/SafariZoneGate.asm:213-217), and the +-- MONEY box is redrawn with the reduced total (:181-183) (#2205) +local paidBox = ow.pushed[3] +T.check(paidBox ~= nil and paidBox.text:find("SAFARI BALLs"), + "the payment text is its own box") +T.check(paidBox and paidBox.opts and paidBox.opts.auto + and paidBox.opts.auto.sound == "Get_Item1", + "the Get_Item1 fanfare holds the box the balls were handed over in") +T.check(paidBox and paidBox.opts and paidBox.opts.money ~= nil, + "the MONEY box stays up through the payment text") +local paBox = ow.pushed[4] +T.check(paBox ~= nil and paBox.text:find("PA"), "the PA line follows it") +T.check(paBox and paBox.opts and paBox.opts.money ~= nil, + "and the MONEY box is still up for it") +for _, box in ipairs(ow.pushed) do + T.check(not box.text:find("Good Luck"), + "\"Good Luck!\" belongs to the leaving script, never the entry path") +end + T.eq(#ow.moves, 1, "the payment text is followed by the entrance auto-walk") T.eq(ow.moves[1].dir, "up", "SafariZoneEntranceAutoWalk walks PAD_UP") T.eq(ow.moves[1].tiles, 2, "two cells reach the north warp row") @@ -111,6 +157,20 @@ T.eq(game.save.safari.steps, 500, T.eq(ow.warps[1], NORTH_WARP, "a scripted step skips CheckWarpsNoCollision, so the script takes the warp") +-- the LEFT trigger cell auto-walks one step right to the counter first: +-- wCoordIndex is 1-based (home/map_objects.asm:107-121), so `cp 1` is (3,2) +-- (scripts/SafariZoneGate.asm:31-40) (#2205) +answer = true +local leftCell = newWorld(3, 2) +local leftCellGame = newGame(leftCell) +T.eq(gate.onStep(leftCellGame, leftCell, 3, 2), true, "the left cell triggers too") +T.eq(leftCellGame.save.safari, nil, "nothing is paid until the walk finishes") +T.eq(#leftCell.moves, 1, "the welcome text is followed by the walk right") +T.eq(leftCell.moves[1].dir, "right", "PAD_RIGHT, c = 1") +T.eq(leftCell.moves[1].tiles, 1, "one cell reaches (4,2)") +leftCell.moves[1].onDone() +T.check(leftCellGame.save.safari ~= nil, "the join prompt runs from (4,2)") + -- talking to the worker from anywhere else has no warp above the player, so -- the auto-walk stays out of the way and he walks in himself answer = true @@ -188,8 +248,9 @@ T.eq(yes.texts[1], "_SafariZoneGateSafariZoneWorker1LeavingEarlyText", "the worker asks first") T.eq(yes.texts[2], "_SafariZoneGateSafariZoneWorker1ReturnSafariBallsText", "YES takes the leftover balls back") -T.eq(yes.texts[3], "_SafariZoneGateSafariZoneWorker1GoodHaulComeAgainText", - "the good-haul sign-off stays reachable on this branch") +-- .leaving_early prints the return-balls text and nothing else; the good-haul +-- sign-off belongs to the game-over branch (scripts/SafariZoneGate.asm:85-99) +T.eq(yes.texts[3], nil, "no good-haul sign-off on the leaving-early branch") T.eq(#yes.fields, 1, "the game state is cleared once") T.eq(yes.fields[1].key, "safari", "save.safari is the field cleared") T.eq(yes.fields[1].value, nil, "set_field with no value assigns nil") @@ -215,6 +276,36 @@ gate.onEnter(leftGame, left) local leftStay = runRows(left.queued[1], false) T.eq(leftStay.warps[1].x, 14, "the left-hand warp column comes back on 14") +-- ---- the game-over return: EVENT_SAFARI_GAME_OVER takes the other branch +-- (scripts/SafariZoneGate.asm:79-94) (#2206) + +local over = newWorld(4, 0) +local overGame = newGame(over) +overGame.save.safariGameOver = true +gate.onEnter(overGame, over) +T.eq(#over.pushed, 0, "the game-over branch queues rather than pushes too") +T.eq(#over.queued, 1, "the sign-off script is queued") +T.eq(overGame.save.safariGameOver, nil, + "CheckAndResetEvent clears the flag, so the next arrival asks normally") +local overRows = over.queued[1] +T.eq(#ScriptRunner.validate(overRows), 0, "the queued rows validate") +local overOut = runRows(overRows, true) +T.eq(overOut.texts[1], "_SafariZoneGateSafariZoneWorker1GoodHaulComeAgainText", + "the worker signs off with the good-haul text") +T.eq(#overOut.moves, 1, "then the exit auto-walk runs") +T.eq(overOut.moves[1].dir, "down", "PAD_DOWN") +T.eq(overOut.moves[1].tiles, 3, "c = 3, from the warp row down to the counter") +T.eq(#overOut.warps, 0, "the game-over branch never warps back into the zone") + +-- wDestinationWarpID $3 is warp_event 4,0 +-- (data/maps/objects/SafariZoneGate.asm:12), the walk's START, not its end +local FieldDefaults = require("src.world.FieldDefaults") +local exitWarp = FieldDefaults.fieldValue(nil, "safari", "exitWarp") +T.check(exitWarp ~= nil, "the safari exit warp is defined") +T.eq(exitWarp and exitWarp.map, "SAFARI_ZONE_GATE", "it lands in the gate") +T.eq(exitWarp and exitWarp.x, 4, "on the right-hand north warp column") +T.eq(exitWarp and exitWarp.y, 0, "on the warp row, not the counter row") + -- no game running, or arriving from the town side, asks nothing local idle = newWorld(4, 0) local idleGame = newGame(idle) diff --git a/tests/engine/stat_rise_message_translation_test.lua b/tests/engine/stat_rise_message_translation_test.lua index 943d785e..f71d894c 100644 --- a/tests/engine/stat_rise_message_translation_test.lua +++ b/tests/engine/stat_rise_message_translation_test.lua @@ -70,7 +70,9 @@ local aiBattle = { enemy = enemy, trainer = { name = "TRAINER" }, data = Data } withCatalog({ SPEED = "VITESSE" }, function() local msgs = TrainerAI.useItem(aiBattle, "X_SPEED") - T.check(msgs[2]:find("VITESSE", 1, true) ~= nil, + T.check(type(msgs[2]) == "table" and msgs[2].anim == "XSTATITEM_DUPLICATE_ANIM", + "the AI trainer's X item plays the stat-up animation before the rose! page") + T.check(msgs[3]:find("VITESSE", 1, true) ~= nil, "a catalog translating SPEED reaches the AI trainer's X SPEED rose! message") end) diff --git a/tests/gen2_arrow_blink_test.lua b/tests/gen2_arrow_blink_test.lua index 62cdb6d5..076da45d 100644 --- a/tests/gen2_arrow_blink_test.lua +++ b/tests/gen2_arrow_blink_test.lua @@ -1,7 +1,7 @@ package.path = "./?.lua;./?/init.lua;" .. package.path -local T = require("tests.harness") -local eq = T.eq +local S = require("tests.harness").suite("gen2 arrow blink") +local eq = S.eq love = love or require("tests.love_stub") local Typer = require("src.ui.gen2.Typer") @@ -23,4 +23,4 @@ Typer.say(screen, { "HELLO", "WORLD" }) Typer.step(screen) eq(screen.arrowBlink, 1, "step keeps counting while a typer runs") -T.finish() +S.finish() diff --git a/tests/gen2_unown_words_test.lua b/tests/gen2_unown_words_test.lua index 1d425e15..b9511899 100644 --- a/tests/gen2_unown_words_test.lua +++ b/tests/gen2_unown_words_test.lua @@ -178,8 +178,12 @@ if not cache then .. "/Library/Application Support/LOVE/crystal-dev/crystal" end -local events = loadTable(cache .. "/data/generated/events.lua") -local tilesets = loadTable(cache .. "/data/generated/tilesets.lua") +local CacheContract = require("src.import.CacheContract") +local stale = not CacheContract.markerMatches( + "crystal", readFile(cache .. "/rom-cache.complete")) + +local events = not stale and loadTable(cache .. "/data/generated/events.lua") or nil +local tilesets = not stale and loadTable(cache .. "/data/generated/tilesets.lua") or nil -- data/events/unown_walls.asm:2-5 local EXPECTED = { diff --git a/tests/goldens/vanilla_fingerprint.txt b/tests/goldens/vanilla_fingerprint.txt index c1e3a511..168a73cf 100644 --- a/tests/goldens/vanilla_fingerprint.txt +++ b/tests/goldens/vanilla_fingerprint.txt @@ -1 +1 @@ -789ccca105bbebed +0d60c593c618791f diff --git a/tests/mod_ui_tests.lua b/tests/mod_ui_tests.lua index 890f5c7b..04c250e0 100644 --- a/tests/mod_ui_tests.lua +++ b/tests/mod_ui_tests.lua @@ -11,6 +11,7 @@ local check = S.check local love = _G.love or require("tests.love_stub") _G.love = love +_G.POKEPORT_LOOP_PANEL_SYNC = true local Events = require("src.mods.Events") local Hooks = require("src.mods.Hooks") diff --git a/tests/parity_fighting_dojo_dex.lua b/tests/parity_fighting_dojo_dex.lua index 448ac2db..52b45522 100644 --- a/tests/parity_fighting_dojo_dex.lua +++ b/tests/parity_fighting_dojo_dex.lua @@ -65,6 +65,8 @@ for _, c in ipairs({ "the preview marks " .. c.species .. " seen") check(not game.save.pokedex.owned[c.species], "the preview does not mark " .. c.species .. " owned") + -- engine/menus/pokedex.asm:500-506 + top.picDelay = 0 game.pressed = true top:update(0) local ask = game.stack:top() diff --git a/tests/parity_saffron_gate.lua b/tests/parity_saffron_gate.lua index fd8ce5d0..b98b26c1 100644 --- a/tests/parity_saffron_gate.lua +++ b/tests/parity_saffron_gate.lua @@ -34,7 +34,14 @@ local check, eq = S.check, S.eq -- suite that sorts after this one. local realTextBox = package.loaded["src.render.TextBox"] package.loaded["src.render.TextBox"] = { - new = function(_, text, done) return { text = text, done = done } end, + new = function(_, text, done, opts) + return { text = text, done = done, opts = opts } + end, + soundOpts = function(_, sound, opts) + opts = opts or {} + opts.sound = sound + return opts + end, } local M = dofile("data/scripts/story2.lua") @@ -73,6 +80,9 @@ for _, drink in ipairs({ "FRESH_WATER", "SODA_POP", "LEMONADE" }) do drink .. ": the guards are marked as having been given a drink") eq(game.save.inventory[drink], nil, drink .. ": exactly one was removed") check(#pushed == 1, drink .. ": the thanks text is shown") + -- scripts/Route5Gate.asm:104 + check(pushed[1] and pushed[1].opts and pushed[1].opts.sound == "Get_Key_Item", + drink .. ": the thanks box carries the key-item jingle") check(#moved == 0, drink .. ": we are NOT walked back") end diff --git a/tests/parity_true_color_ui.lua b/tests/parity_true_color_ui.lua index d65f6feb..ca2ef25b 100644 --- a/tests/parity_true_color_ui.lua +++ b/tests/parity_true_color_ui.lua @@ -48,6 +48,8 @@ local game = { local dex = DexEntryMenu.new(game, "PIKACHU") check(dex.spriteTrueColor == true, "Pokedex keeps a Pokemon sprite's trueColor flag") +-- engine/menus/pokedex.asm:500-506 +dex.picDelay = 0 local dexRects = uiRects(function() dex:draw() end) -- engine/menus/pokedex.asm:503: the pic sits in the 7x7 window at (8,8) local dw, dh = dex.sprite:getDimensions() diff --git a/tests/rby_translation_harvest_test.py b/tests/rby_translation_harvest_test.py index b8ac354e..d9f42e11 100644 --- a/tests/rby_translation_harvest_test.py +++ b/tests/rby_translation_harvest_test.py @@ -23,7 +23,7 @@ class RbyTranslationHarvestTest(TestCase): '"DEPOSIT"', '"The party is full!"', '"MONEY/¥%d"', - '"TIME/%3d:%02d"', + '"TIME/ %d:%02d"', '"No.%03d"', '"IDNo.%05d"', '"BILL\'S PC"', diff --git a/tests/run_tests.lua b/tests/run_tests.lua index ee4531e1..e6544267 100644 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -15,6 +15,7 @@ local T = require("tests.harness") -- every suite below is dofile'd in this process, so a suite that reaches for -- T.finish must raise into runSuites' pcall instead of os.exit(0)-ing the tier _G.POKEPORT_TEST_CHILD = true +_G.POKEPORT_LOOP_PANEL_SYNC = true -- this suite has always streamed a line per check, and it is the one a -- developer watches for progress through ~1600 assertions T.verbose = true