diff --git a/data/scripts/celadon_eevee.lua b/data/scripts/celadon_eevee.lua index 55d0627d..4b6a78de 100644 --- a/data/scripts/celadon_eevee.lua +++ b/data/scripts/celadon_eevee.lua @@ -92,7 +92,7 @@ return { { "set_flag", "EVENT_GOT_EEVEE" }, -- 7 { "hide_object", "CELADON_MANSION_ROOF_HOUSE", "CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL" }, -- 8 - { "play_sound", "Get_Item1" }, -- 9 (GotMonText jingle) + { "text_sound", "Get_Item1" }, -- 9 (GotMonText jingle) { "show_text", "_GotMonText", { RAM = "EEVEE" } }, -- 10 { "jump", 13 }, -- 11 { "show_text", "_BoxIsFullText" }, -- 12 diff --git a/data/scripts/flavor/game_corner.lua b/data/scripts/flavor/game_corner.lua index 59738245..82ee7002 100644 --- a/data/scripts/flavor/game_corner.lua +++ b/data/scripts/flavor/game_corner.lua @@ -10,10 +10,10 @@ local function coinGiver(opts) return function(game, ow, npc, done) local TextBox = require("src.render.TextBox") - local Sound = require("src.core.Sound") local t = game.data.text - local function push(label, fallback, onDone) - game.stack:push(TextBox.new(game, t[label] or fallback, onDone or done)) + local function push(label, fallback, onDone, popts) + game.stack:push(TextBox.new(game, t[label] or fallback, onDone or done, + popts)) end if game.save.flags[opts.event] then push(opts.alreadyGotLabel, opts.alreadyGotFallback) @@ -30,9 +30,10 @@ local function coinGiver(opts) end game.save.coins = math.min(9999, (game.save.coins or 0) + opts.amount) game.save.flags[opts.event] = true - Sound.play(game.data, "Get_Item1") + -- the ReceivedNCoinsText strings carry sound_get_item_1 push(opts.receivedLabel, - ("{PLAYER} received\n%d coins!"):format(opts.amount)) + ("{PLAYER} received\n%d coins!"):format(opts.amount), nil, + TextBox.soundOpts(game, "Get_Item1")) end) end end diff --git a/data/scripts/flavor/museum_1f.lua b/data/scripts/flavor/museum_1f.lua index cd706e1a..21bbb2a2 100644 --- a/data/scripts/flavor/museum_1f.lua +++ b/data/scripts/flavor/museum_1f.lua @@ -22,8 +22,8 @@ return { local TextBox = require("src.render.TextBox") local Commands = require("src.script.Commands") local t = game.data.text - local function say(label, cb) - game.stack:push(TextBox.new(game, t[label] or label, cb)) + local function say(label, cb, sopts) + game.stack:push(TextBox.new(game, t[label] or label, cb, sopts)) end if game.save.flags.EVENT_GOT_OLD_AMBER then @@ -39,8 +39,9 @@ return { game.save.flags.EVENT_GOT_OLD_AMBER = true Commands.hide_object({ save = game.save, overworld = ow, game = game }, "MUSEUM_1F", "MUSEUM1F_OLD_AMBER") - require("src.core.Sound").play(game.data, "Get_Item1") - say("_Museum1FScientist2ReceivedOldAmberText", done) + -- .ReceivedOldAmberText carries sound_get_item_1 + say("_Museum1FScientist2ReceivedOldAmberText", done, + TextBox.soundOpts(game, "Get_Item1")) end) end, diff --git a/data/scripts/oaks_lab.lua b/data/scripts/oaks_lab.lua index cfce77cb..55cb3316 100644 --- a/data/scripts/oaks_lab.lua +++ b/data/scripts/oaks_lab.lua @@ -38,9 +38,9 @@ local function starterBall(askText, species, choseFlag, ownBall, -- inside give_pokemon). Show the received text first so the -- nickname prompt follows "you got X", matching Gen1. -- The received text carries sound_get_key_item (OaksLab.asm - -- OaksLabReceivedMonText); the jingle plays as the box opens - -- (same beat as the Yellow port's starter, #668). - { "play_sound", "Get_Key_Item" }, -- 8 + -- OaksLabReceivedMonText), so the jingle fires once the box has + -- typed out and holds it (same beat as the Yellow port's starter, #668). + { "text_sound", "Get_Key_Item" }, -- 8 { "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 9 { "give_pokemon", species, 5 }, -- 10 { "set_flag", "EVENT_GOT_STARTER" }, -- 11 @@ -54,7 +54,7 @@ local function starterBall(askText, species, choseFlag, ownBall, { "face_object", 1, "up" }, -- 15 { "show_text", "_OaksLabRivalIllTakeThisOneText" }, -- 16 { "hide_object", "OAKS_LAB", rivalBall }, -- 17 - { "play_sound", "Get_Key_Item" }, -- 18 (sound_get_key_item) + { "text_sound", "Get_Key_Item" }, -- 18 (sound_get_key_item) { "show_text", "_OaksLabRivalReceivedMonText", { RAM = rivalBall == "OAKSLAB_CHARMANDER_POKE_BALL" and "CHARMANDER" or rivalBall == "OAKSLAB_SQUIRTLE_POKE_BALL" and "SQUIRTLE" @@ -106,8 +106,8 @@ return { { "check_item", "OAKS_PARCEL" }, { "jump_if_false", "raise_young" }, -- OaksLabOak1Text.got_parcel → RivalArrives + OakGivesPokedex + { "text_sound", "Get_Key_Item" }, { "show_text", "_OaksLabOak1DeliverParcelText" }, - { "play_sound", "Get_Key_Item" }, { "show_text", "_OaksLabOak1ParcelThanksText" }, { "take_item", "OAKS_PARCEL", 1 }, { "stop_music" }, @@ -128,8 +128,8 @@ return { { "face_object", 1, "up" }, { "face_object", 5, "down" }, { "show_text", "_OaksLabOakMyInventionPokedexText" }, + { "text_sound", "Get_Key_Item" }, { "show_text", "_OaksLabOakGotPokedexText" }, - { "play_sound", "Get_Key_Item" }, { "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX1" }, { "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX2" }, { "face_object", 1, "up" }, diff --git a/data/scripts/oaks_lab_yellow.lua b/data/scripts/oaks_lab_yellow.lua index e5cacf8a..6c67a80f 100644 --- a/data/scripts/oaks_lab_yellow.lua +++ b/data/scripts/oaks_lab_yellow.lua @@ -38,8 +38,8 @@ return { { "jump_if_false", "raise_young" }, -- .DeliverParcelText: parcel handover, then the Pokédex scene -- (OaksLabRivalArrivesAtOaksRequestScript -> OakGivesPokedexScript) + { "text_sound", "Get_Key_Item" }, { "show_text", "_OaksLabOak1DeliverParcelText" }, - { "play_sound", "Get_Key_Item" }, { "show_text", "_OaksLabOak1ParcelThanksText" }, { "take_item", "OAKS_PARCEL", 1 }, { "stop_music" }, @@ -62,8 +62,8 @@ return { { "face_object", RIVAL, "up" }, { "face_object", OAK1, "down" }, { "show_text", "_OaksLabOakMyInventionPokedexText" }, + { "text_sound", "Get_Key_Item" }, { "show_text", "_OaksLabOakGotPokedexText" }, - { "play_sound", "Get_Key_Item" }, { "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX1" }, { "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX2" }, { "face_object", RIVAL, "up" }, @@ -113,8 +113,8 @@ return { { "jump_if_true", "come_see" }, { "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, { "give_item", "POKE_BALL", 5, false }, + { "text_sound", "Get_Key_Item" }, { "show_text", "_OaksLabOak1ReceivedPokeballsText" }, - { "play_sound", "Get_Key_Item" }, { "show_text", "_OaksLabGivePokeballsExplanationText" }, { "jump", "end" }, @@ -176,7 +176,7 @@ return { -- rival starter baseline (RIVAL_STARTER_JOLTEON) at snatch time rows[#rows + 1] = { "set_field", "rivalStarter", 1 } rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText1" } - rows[#rows + 1] = { "play_sound", "Get_Key_Item" } + rows[#rows + 1] = { "text_sound", "Get_Key_Item" } rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText2" } rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText3" } rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText4" } @@ -196,7 +196,7 @@ return { rows[#rows + 1] = { "face_object", OAK1, "down" } -- OaksLabPlayerReceivedMonText clears wMonDataLocation, so AskName runs (#1013) rows[#rows + 1] = { "show_text", "_OaksLabOakGivesText" } - rows[#rows + 1] = { "play_sound", "Get_Key_Item" } + rows[#rows + 1] = { "text_sound", "Get_Key_Item" } rows[#rows + 1] = { "show_text", "_OaksLabReceivedText", { RAM = "PIKACHU" } } rows[#rows + 1] = { "give_pokemon", "PIKACHU", 5 } -- DisablePikachuOverworldSpriteDrawing keeps it in the ball (#1009) diff --git a/data/scripts/story.lua b/data/scripts/story.lua index adbf2e6d..2941ac58 100644 --- a/data/scripts/story.lua +++ b/data/scripts/story.lua @@ -297,6 +297,7 @@ M.BILLS_HOUSE = { M.ROUTE_25 = { onEnter = function(game, ow) + game.save.pikachuMapScriptActive = nil local flags = game.save.flags if flags.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING then return end local Commands = require("src.script.Commands") @@ -329,6 +330,7 @@ M.VERMILION_CITY = { -- only read while EVENT_1ST_LOCK_OPENED is unset (the gym is only -- reachable through this map, so a fresh visit always re-rolls). onEnter = function(game, ow) + game.save.pikachuMapScriptActive = nil local puz = game.save.trashPuzzle or {} game.save.trashPuzzle = puz puz.first = love.math.random(0, 7) * 2 diff --git a/data/scripts/story2.lua b/data/scripts/story2.lua index 887a683e..aa8fb23d 100644 --- a/data/scripts/story2.lua +++ b/data/scripts/story2.lua @@ -648,8 +648,10 @@ local function mtMoonFossil(itemId, otherName, gotFlag) end local idef = game.data.items[itemId] game.stringBuffer = idef and idef.name or itemId - require("src.core.Sound").play(game.data, "Get_Key_Item") local dirs = mtMoonNerdWalk(ow.player.cellX, ow.player.cellY, itemId) + -- MtMoonB2FReceivedFossilText: text_far, sound_get_key_item, + -- text_waitbutton -- the jingle plays after the box has typed and + -- the button wait comes after it game.stack:push(TextBox.new(game, t._MtMoonB2FReceivedFossilText or ("{PLAYER} got the\n" .. game.stringBuffer .. "!"), @@ -662,11 +664,11 @@ local function mtMoonFossil(itemId, otherName, gotFlag) ow.runner:run({ { "walk_npc", 1, dirs }, { "text_opts", { auto = true } }, + { "text_sound", "Get_Key_Item" }, { "show_text", "_MtMoonB2FSuperNerdThenThisIsMineText" }, - { "play_sound", "Get_Key_Item" }, { "hide_object", "MT_MOON_B2F", otherName }, }, { onDone = done }) - end)) + end, TextBox.soundOpts(game, "Get_Key_Item"))) end })) end end diff --git a/data/scripts/story4.lua b/data/scripts/story4.lua index b8c55163..769309e6 100644 --- a/data/scripts/story4.lua +++ b/data/scripts/story4.lua @@ -8,9 +8,9 @@ local M = {} local function text(game) return game.data.text end -local function push(game, s, done) +local function push(game, s, done, opts) local TextBox = require("src.render.TextBox") - game.stack:push(TextBox.new(game, s, done)) + game.stack:push(TextBox.new(game, s, done, opts)) end -- The question stays on screen under the YES/NO menu. The dojo prize @@ -263,7 +263,7 @@ M.SILPH_CO_7F = { { "jump_if_false", "box_full" }, -- flag ahead of the jingle, like the Celadon EEVEE (#426) { "set_flag", "EVENT_GOT_LAPRAS" }, - { "play_sound", "Get_Item1" }, + { "text_sound", "Get_Item1" }, { "show_text", "_GotMonText", { RAM = "LAPRAS" } }, { "show_text", "_SilphCo7FSilphWorkerM1LaprasDescriptionText" }, { "jump", "end" }, @@ -307,12 +307,11 @@ M.COPYCATS_HOUSE_2F = { return end game.stringBuffer = game.data.items.TM_MIMIC.name - require("src.core.Sound").play(game.data, "Get_Item1") Bag.remove(game.save, "POKE_DOLL", 1) game.save.flags.EVENT_GOT_TM31 = true push(game, t._CopycatsHouse2FCopycatReceivedTM31Text, function() push(game, t._CopycatsHouse2FCopycatTM31Explanation1Text, done) - end) + end, require("src.render.TextBox").soundOpts(game, "Get_Item1")) end) end) end, @@ -478,7 +477,6 @@ M.CELADON_MART_ROOF = { return end game.save.flags[g.flag] = true - require("src.core.Sound").play(game.data, "Get_Item1") local subs = { player = game.save.player.name, ram = game.data.items[g.tm].name } local explain = fill(t[g.explain] or "", subs) @@ -490,7 +488,7 @@ M.CELADON_MART_ROOF = { else done() end - end) + end, require("src.render.TextBox").soundOpts(game, "Get_Item1")) end) end, onCancel = done, @@ -522,7 +520,6 @@ M.ROUTE_24 = { local t = text(game) push(game, t._Route24CooltrainerM1YouBeatOurContestText .. "\f" .. t._Route24CooltrainerM1YouJustEarnedAPrizeText, function() - require("src.core.Sound").play(game.data, "Get_Item1") if not require("src.inventory.Bag").add(game.save, "NUGGET", 1, game.data) then push(game, t._Route24CooltrainerM1NoRoomText, done) @@ -531,11 +528,10 @@ M.ROUTE_24 = { flags.EVENT_GOT_NUGGET = true game.stringBuffer = game.data.items.NUGGET.name push(game, t._Route24CooltrainerM1ReceivedNuggetText, function() - require("src.core.Sound").play(game.data, "Get_Item1") push(game, t._Route24CooltrainerM1JoinTeamRocketText, battleOrDone) - end) - end) + end, require("src.render.TextBox").soundOpts(game, "Get_Item1")) + end, require("src.render.TextBox").soundOpts(game, "Get_Item1")) return end battleOrDone() diff --git a/data/scripts/story5.lua b/data/scripts/story5.lua index 8aefafe3..aaba5178 100644 --- a/data/scripts/story5.lua +++ b/data/scripts/story5.lua @@ -4,9 +4,9 @@ local M = {} local function text(game) return game.data.text end -local function push(game, s, done) +local function push(game, s, done, opts) local TextBox = require("src.render.TextBox") - game.stack:push(TextBox.new(game, s, done)) + game.stack:push(TextBox.new(game, s, done, opts)) end -- fill the extracted text placeholders ({RAM:...}, {PLAYER}) @@ -25,8 +25,8 @@ local function gift(opts) local t = text(game) local itemName = game.data.items[opts.item].name local subs = { ram = itemName, player = game.save.player.name } - local function say(label, fallback, cb) - push(game, fill(t[label] or fallback, subs), cb) + local function say(label, fallback, cb, sopts) + push(game, fill(t[label] or fallback, subs), cb, sopts) end if game.save.flags[opts.flag] then say(opts.already or opts.explain, "It's a useful\nitem, isn't it?", done) @@ -39,15 +39,16 @@ local function gift(opts) end game.save.flags[opts.flag] = true local idef = game.data.items[opts.item] - require("src.core.Sound").play(game.data, - (idef and idef.keyItem) and "Get_Key_Item" or "Get_Item1") + -- the received texts carry sound_get_item_1 / sound_get_key_item, so + -- the jingle only fires once that box has typed out say(opts.received, "{PLAYER} received\n{RAM:}!", function() if opts.explain then say(opts.explain, "", done) else done() end - end) + end, require("src.render.TextBox").soundOpts(game, + (idef and idef.keyItem) and "Get_Key_Item" or "Get_Item1")) end if opts.pre then say(opts.pre, opts.preFallback or "", give) else give() end end diff --git a/data/scripts/victories.lua b/data/scripts/victories.lua index dc71e796..32e10c24 100644 --- a/data/scripts/victories.lua +++ b/data/scripts/victories.lua @@ -28,6 +28,14 @@ -- is full; `gotFlag` (pokered's EVENT_GOT_TM*) is set only on a -- successful give, which is what makes the leader's talk script retry -- later (gyms.lua). +-- +-- `badgeSound` / `tmSound` are the text sound command each gym's reward +-- text carries right after its FIRST label -- home/text.asm TextCommand_SOUND +-- plays it once that page has typed out and then blocks on +-- WaitForSoundToFinish, so the jingle sits between the pages rather than +-- under them. macros/scripts/text.asm defines sound_level_up as +-- sound_get_item_1, so Pewter's and Viridian's badge lines are Get_Item1 +-- too. Vermilion, Celadon and Fuchsia carry no sound on the badge text. local function range(prefix, first, last) local t = {} @@ -50,6 +58,8 @@ return { { "PEWTER_CITY", "PEWTERCITY_YOUNGSTER" }, { "ROUTE_22", "ROUTE22_RIVAL1" }, }, + badgeSound = "Get_Item1", -- sound_level_up + tmSound = "Get_Item1", dialogue = { "_PewterGymBrockReceivedBoulderBadgeText", "_PewterGymBrockBoulderBadgeInfoText", @@ -64,6 +74,8 @@ return { gotFlag = "EVENT_GOT_TM11", noRoom = "_CeruleanGymMistyTM11NoRoomText", deactivate = range("EVENT_BEAT_CERULEAN_GYM_TRAINER_", 0, 1), + badgeSound = "Get_Key_Item", + tmSound = "Get_Item1", dialogue = { "_CeruleanGymMistyReceivedCascadeBadgeText", }, @@ -76,6 +88,7 @@ return { gotFlag = "EVENT_GOT_TM24", noRoom = "_VermilionGymLTSurgeTM24NoRoomText", deactivate = range("EVENT_BEAT_VERMILION_GYM_TRAINER_", 0, 2), + tmSound = "Get_Key_Item", dialogue = { "_VermilionGymLTSurgeReceivedThunderBadgeText", }, @@ -89,6 +102,7 @@ return { gotFlag = "EVENT_GOT_TM21", noRoom = "_CeladonGymTM21NoRoomText", deactivate = range("EVENT_BEAT_CELADON_GYM_TRAINER_", 0, 6), + tmSound = "Get_Item1", dialogue = { "_CeladonGymErikaReceivedRainbowBadgeText", }, @@ -102,6 +116,7 @@ return { gotFlag = "EVENT_GOT_TM06", noRoom = "_FuchsiaGymKogaTM06NoRoomText", deactivate = range("EVENT_BEAT_FUCHSIA_GYM_TRAINER_", 0, 5), + tmSound = "Get_Key_Item", dialogue = { "_FuchsiaGymKogaReceivedSoulBadgeText", }, @@ -115,6 +130,8 @@ return { gotFlag = "EVENT_GOT_TM46", noRoom = "_SaffronGymSabrinaTM46NoRoomText", deactivate = range("EVENT_BEAT_SAFFRON_GYM_TRAINER_", 0, 6), + badgeSound = "Get_Key_Item", + tmSound = "Get_Item1", dialogue = { "_SaffronGymSabrinaReceivedMarshBadgeText", }, @@ -128,6 +145,8 @@ return { gotFlag = "EVENT_GOT_TM38", noRoom = "_CinnabarGymBlaineTM38NoRoomText", deactivate = range("EVENT_BEAT_CINNABAR_GYM_TRAINER_", 0, 6), + badgeSound = "Get_Key_Item", + tmSound = "Get_Item1", dialogue = { "_CinnabarGymBlaineReceivedVolcanoBadgeText", }, @@ -141,6 +160,8 @@ return { gotFlag = "EVENT_GOT_TM27", noRoom = "_ViridianGymGiovanniTM27NoRoomText", deactivate = range("EVENT_BEAT_VIRIDIAN_GYM_TRAINER_", 0, 7), + badgeSound = "Get_Item1", -- sound_level_up + tmSound = "Get_Item1", dialogue = { "_ViridianGymGiovanniReceivedEarthBadgeText", }, diff --git a/docs/new-features.md b/docs/new-features.md index 54d7fda8..80c47172 100644 --- a/docs/new-features.md +++ b/docs/new-features.md @@ -26,7 +26,7 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow ## Pokémon Gold (Gen 2) -A fourth game the launcher can import and play, built from pret/pokegold the same way Red/Blue/Yellow are built from pokered. Port extras beyond the cartridge: +A fourth game the launcher can import and play, built from pret/pokegold the same way Red/Blue/Yellow are built from pokered (and pokeyellow). Port extras beyond the cartridge: * **COLOR, zoom, tilt, GBC FX, and quick save/load** * **UI that stays fixed while the overworld zooms** diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 679e2518..2a23d96c 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -501,6 +501,9 @@ local function makeBattler(data, mon, isPlayer, save) badgeBoosts = badgeBoosts, statuses = data.statuses, shownHP = mon.hp, -- the HP the bar displays (UpdateHPBar drain) + -- the bar's own length in GetHPBarLength pixels; it trails shownHP + -- because UpdateHPBar_AnimateHPBar slides it one pixel at a time + shownPx = Timing.hpBarPixels(mon.hp, math.max(1, mon.stats.hp)), -- HUD status label (DrawHUDsAndHPBars); mon.status can land mid-move -- while the tilemap still shows the prior condition until the next -- post-action HUD refresh (core.asm after Execute*Move) @@ -563,10 +566,6 @@ local function markOwned(game, species) local dex = game.save.pokedex if dex then dex.seen[species] = true - if not dex.owned[species] then - -- new dex page registered (SFX_DEX_PAGE_ADDED) - require("src.core.Sound").play(game.data, "Dex_Page_Added") - end dex.owned[species] = true end end @@ -935,6 +934,17 @@ function BattleState:sayNextWaitSfx(text, sfx) table.insert(self.queue, self.nextInsert, { text = text, waitForLearningSfx = sfx }) end +-- WaitForSoundToFinish for a sound an act() has already started: PlayCry +-- ends in `jp WaitForSoundToFinish` (home/pokemon.asm), so every cry in +-- Gen 1 holds whatever the ROM does next. `src` is the audio source, or a +-- getter the row calls at execution time when the source is only known then. +function BattleState:waitSfxNext(src) + self.nextInsert = (self.nextInsert or 0) + 1 + table.insert(self.queue, self.nextInsert, + { waitSound = type(src) == "function" and src + or function() return src end }) +end + -- sayNext for a page that ends in `text_end` (see sayAuto) (#765) function BattleState:sayNextAuto(text, delay) self.nextInsert = (self.nextInsert or 0) + 1 @@ -1022,22 +1032,39 @@ function BattleState:stepHPDrain() and b.shownHP >= b.drainFloor then goal = b.drainFloor end + local maxHP = math.max(1, b.mon.stats.hp) + local playerSide = (b == self.player) + local targetPx = Timing.hpBarPixels(b.shownHP, maxHP) + if not b.shownPx then b.shownPx = targetPx end if (b.drainHold or 0) > 0 then b.drainHold = b.drainHold - 1 busy = true + elseif b.shownPx ~= targetPx then + -- .barAnimationLoop redraws the bar one pixel at a time, `ld c, 2 / + -- call DelayFrames` apiece (:141-148), so a single HP point that + -- spans several pixels still slides instead of jumping + b.shownPx = b.shownPx + ((b.shownPx > targetPx) and -1 or 1) + b.drainHold = Timing.HP_BAR_PIXEL_STEP - 1 + busy = true elseif b.shownHP ~= goal then - local maxHP = math.max(1, b.mon.stats.hp) - local playerSide = (b == self.player) - local cost = 0 + local spent = 0 -- consume whole HP steps until this frame's budget is spent; on the -- enemy HUD several free steps can land in the same frame - while b.shownHP ~= goal and cost < 1 do - local nextHP = b.shownHP + ((b.shownHP > goal) and -1 or 1) - cost = cost + Timing.hpDrainStepFrames(b.shownHP, nextHP, - maxHP, playerSide) - b.shownHP = nextHP + repeat + b.shownHP = b.shownHP + ((b.shownHP > goal) and -1 or 1) + spent = spent + (playerSide and Timing.HP_BAR_HP_STEP or 0) + targetPx = Timing.hpBarPixels(b.shownHP, maxHP) + until b.shownHP == goal or targetPx ~= b.shownPx or spent >= 1 + if spent > 0 then + b.drainHold = spent - 1 + elseif targetPx ~= b.shownPx then + -- the enemy HUD printed no number, so this frame is already the + -- first of the pixel step the crossing just asked for + b.shownPx = b.shownPx + ((b.shownPx > targetPx) and -1 or 1) + b.drainHold = Timing.HP_BAR_PIXEL_STEP - 1 + else + b.drainHold = 0 end - b.drainHold = math.max(0, cost - 1) b.draining = true busy = true elseif b.draining then @@ -1431,7 +1458,7 @@ end function BattleState:playEntranceCry(battler) local mon = battler and battler.mon if not mon then return end - require("src.core.Sound").playCry(self.data, mon.species, + return require("src.core.Sound").playCry(self.data, mon.species, mon.status == "SLP" and 37 or 11) end @@ -1555,7 +1582,23 @@ function BattleState:enter() -- default stays opaque for every other battle and for older saves. self.isOpaque = self:bgMode() ~= "world" self.introSlide = Timing.BATTLE_SLIDE_IN_FRAMES - self.showEnemyTrainer = self.kind == "trainer" and self.trainerPic ~= nil + -- GetTrainerInformation .linkBattle (home/trainers2.asm:26-31): the link + -- foe's pic is RedPicFront whatever either save looks like, and + -- InitBattleCommon loads and tilemaps it at hlcoord 12,0 for every + -- wIsInBattle == 2 battle (core.asm:6681-6688), the link one included. + -- wEnemyMonSpecies2 is zeroed under it, so the pic palette is PAL_MEWMON + -- exactly as it is for a trainer. + if self.kind == "link" and not self.trainerPic then + local frontPath, frontTrueColor = + require("src.pokemon.Sprites").playerPath(self.data, "front", + { kind = "battle", battle = self }) + if frontPath and require("src.render.Assets").exists(frontPath) then + self.trainerPic = getImage(frontPath, namedPalette(self.data, "MEWMON"), + frontTrueColor) + end + end + self.showEnemyTrainer = (self.kind == "trainer" or self.kind == "link") + and self.trainerPic ~= nil -- DrawAllPokeballs (common_text.asm:27) puts the party ball rows AND the -- HUD corner/underline tiles under them (PlacePlayerHUDTiles / -- PlaceEnemyHUDTiles, draw_hud_pokeball_gfx.asm:119-165) on screen with @@ -1587,13 +1630,13 @@ function BattleState:enter() -- a different point in each battle kind, so queue it per branch local function queueEnemyCry() self:act(function() - self:playEntranceCry(self.enemy) + self:waitSfxNext(self:playEntranceCry(self.enemy)) end) end -- PrintBeginningBattleText (engine/battle/common_text.asm:10-19): a wild - -- battle calls PlayCry BEFORE PrintText WildMonAppearedText, so the cry - -- sounds with the "Wild X appeared!" box instead of waiting on the A - -- press that clears its `prompt` (#303). The Silph-Scope-less tower + -- battle calls PlayCry BEFORE PrintText WildMonAppearedText, and PlayCry + -- ends in WaitForSoundToFinish, so the cry runs to its end and only then + -- does the "Wild X appeared!" box open (#303). The Silph-Scope-less tower -- ghost gets no cry at all (common_text.asm:43-48), and neither does the -- unveiled MAROWAK: .isMarowak never reaches PlayCry (#492). if self.kind ~= "trainer" and self.kind ~= "link" @@ -1611,7 +1654,10 @@ function BattleState:enter() -- The sfx is extracted as "Trainer_Appeared" (tools/rom_manifest.json -- sfxHeaders, bank 8 / $42bb -- the same header pokered names -- SFX_Silph_Scope); nothing had ever played it. - if self.kind == "trainer" then + -- + -- A link battle is wIsInBattle == 2, so PrintBeginningBattleText takes the + -- same .trainerBattle arm and owes the sfx too (common_text.asm:20-23). + if self.kind == "trainer" or self.kind == "link" then self:act(function() self.introSfx = require("src.core.Sound").play(self.data, "Trainer_Appeared") @@ -1629,13 +1675,17 @@ function BattleState:enter() -- battle -- not on a switch, and not when the beaten trainer's pic -- scrolls back in (#317, #282) self:act(function() self.introBalls = nil end) - if self.kind == "trainer" then - -- EnemySendOutFirstMon (core.asm:1308-1310): SlideTrainerPicOffScreen - -- walks the foe's pic off the RIGHT edge (hlcoord 18,0, a = 8 tiles, - -- one tile every 2 frames) BEFORE TrainerSentOutText -- the pic does - -- not blink out under the text (#317) - self:act(function() self:slidePic("foe", 0, 64, 4) end) - table.insert(self.queue, { wait = 16 }) + if self.kind == "trainer" or self.kind == "link" then + local foeName = self.trainer and self.trainer.name + or self.opponentName or Strings("FOE") + if self.showEnemyTrainer then + -- EnemySendOutFirstMon (core.asm:1308-1310): SlideTrainerPicOffScreen + -- walks the foe's pic off the RIGHT edge (hlcoord 18,0, a = 8 tiles, + -- one tile every 2 frames) BEFORE TrainerSentOutText -- the pic does + -- not blink out under the text (#317) + self:act(function() self:slidePic("foe", 0, 64, 4) end) + table.insert(self.queue, { wait = 16 }) + end self:act(function() self.showEnemyTrainer = false -- the slot is EMPTY from here until AnimateSendingOutMon runs below: @@ -1648,7 +1698,7 @@ function BattleState:enter() self.enemySendingOut = true self:slidePic("foe") end) - self:say(Strings("%s sent\nout %s!", self.trainer.name, self.enemy.name)) + self:say(Strings("%s sent\nout %s!", foeName, self.enemy.name)) self:act(function() -- EnemySendOutFirstMon (core.asm:1421-1434): after the text the -- pic grows out of the ball (AnimateSendingOutMon), then the cry @@ -1656,18 +1706,6 @@ function BattleState:enter() self:startGrowIn(self.enemy) end) queueEnemyCry() - elseif self.kind == "link" then - -- Colosseum has no foe trainer pic, but the enemy mon still grows - -- out of the ball after "X sent out Y!" (not the wild "already there" - -- intro that LinkBattle previously inherited from newWild). - self.enemySendingOut = true - self:say(Strings("%s sent\nout %s!", self.opponentName or Strings("FOE"), - self.enemy.name)) - self:act(function() - self.enemySendingOut = false - self:startGrowIn(self.enemy) - end) - queueEnemyCry() end -- StartBattle .foundFirstAliveEnemyMon (core.asm:152-156): the `call nz` -- gates only EnemySendOutFirstMon -- the `ld c, 40 / call DelayFrames` @@ -1698,7 +1736,7 @@ function BattleState:enter() -- SendOutMon (core.asm:1757-1762): after the poof the mon grows -- out of the ball (AnimateSendingOutMon at hlcoord 4,11) self:startGrowIn(self.player) - self:playEntranceCry(self.player) + self:waitSfxNext(self:playEntranceCry(self.player)) end) self:markParticipant() end @@ -1876,7 +1914,10 @@ function BattleState:update(dt) if self.phase == "menu" then for _, b in ipairs({ self.player, self.enemy }) do if b then - if b.shownHP then b.shownHP = b.mon.hp end + if b.shownHP then + b.shownHP = b.mon.hp + b.shownPx = Timing.hpBarPixels(b.mon.hp, math.max(1, b.mon.stats.hp)) + end b.drainFloor = nil b.shownStatus = b.mon.status end @@ -2294,10 +2335,10 @@ function BattleState:oldManThrow() return end self:ballChain("TOSS_ANIM", true, 3, "POKE_BALL") - self:actNext(function() - require("src.core.Sound").play(self.data, "Caught_Mon") - end) - self:sayNext(Strings("All right!\n%s was\ncaught!", self.enemy.name)) + -- ItemUseBallText05: text_far, sound_caught_mon, text_promptbutton -- + -- the fanfare follows the caught text and holds the prompt + self:sayNextWaitSfx(Strings("All right!\n%s was\ncaught!", self.enemy.name), + function() return require("src.core.Sound").play(self.data, "Caught_Mon") end) end) end @@ -2481,7 +2522,7 @@ function BattleState:resolveSwitch(newMon) self.sendingOut = false -- SendOutMon (core.asm:1757-1762): poof, then the grow-in self:startGrowIn(self.player) - self:playEntranceCry(self.player) + self:waitSfxNext(self:playEntranceCry(self.player)) end) end) self:act(function() @@ -3793,7 +3834,7 @@ function BattleState:onFaint(battler) if battler.isPlayer then -- RemoveFaintedPlayerMon (core.asm:1040-1042): the player mon's -- faint plays its ordinary species cry -- no Faint_Fall - Sound.playCry(self.data, battler.mon.species) + self.faintCry = Sound.playCry(self.data, battler.mon.species) elseif self.kind ~= "wild" then -- FaintEnemyPokemon (core.asm:732-771): the enemy faint plays no -- species cry; trainer battles get SFX_FAINT_FALL, then SFX_FAINT_THUD @@ -3808,6 +3849,11 @@ function BattleState:onFaint(battler) end) self.nextInsert = (self.nextInsert or 0) + 1 table.insert(self.queue, self.nextInsert, { wait = Timing.FAINT_SLIDE }) + if battler.isPlayer then + -- RemoveFaintedPlayerMon ends `call PlayCry / jp PrintText`, and PlayCry + -- is `jp WaitForSoundToFinish`, so "X fainted!" waits out the cry + self:waitSfxNext(function() return self.faintCry end) + end if not battler.isPlayer and self.kind ~= "wild" then -- FaintEnemyPokemon's SFX_FAINT_THUD lands as the slide does (after -- Faint_Fall, before EnemyMonFaintedText) @@ -3895,9 +3941,11 @@ function BattleState:awardExp() -- experience.asm:248 fires per grew-level text require("src.world.PikachuFollower") .modifyHappiness(game.save, "LEVELUP", mon) - self:sayNext(Strings("%s grew\nto level %d!", name, lv)) + -- GrewLevelText: text_far, sound_level_up, text_end (experience.asm: + -- 369-372); PrintStatsBox only runs once PrintText has returned + self:sayNextWaitSfx(Strings("%s grew\nto level %d!", name, lv), + function() return require("src.core.Sound").play(game.data, "Level_Up") end) self:uiNext(function() - require("src.core.Sound").play(game.data, "Level_Up") return StatBox.new(game, mon) end) -- After PrintStatsBox, experience.asm reloads the active battler's @@ -4042,7 +4090,7 @@ function BattleState:enemyMonFainted() self.enemySendingOut = false self:startGrowIn(self.enemy) self:actNext(function() - self:playEntranceCry(self.enemy) + self:waitSfxNext(self:playEntranceCry(self.enemy)) end) end) end) @@ -4081,7 +4129,7 @@ function BattleState:enemyMonFainted() self:actNext(function() self.sendingOut = false self:startGrowIn(self.player) - self:playEntranceCry(self.player) + self:waitSfxNext(self:playEntranceCry(self.player)) end) end) return @@ -4277,7 +4325,7 @@ function BattleState:openReplacementMenu() self.sendingOut = false -- SendOutMon (core.asm:1757-1762): poof, then the grow-in self:startGrowIn(self.player) - self:playEntranceCry(self.player) + self:waitSfxNext(self:playEntranceCry(self.player)) end) end, }) @@ -4321,11 +4369,10 @@ function BattleState:safariAction(choice) -- above DoBallTossSpecialEffects's <= ULTRA_BALL check) self:ballChain(self:tossAnimFor("SAFARI_BALL"), caught, shakes, "SAFARI_BALL") if caught then - -- ItemUseBallText05's sound_caught_mon: fanfare with the text - self:actNext(function() - require("src.core.Sound").play(self.data, "Caught_Mon") - end) - self:sayNext(Strings("All right!\n%s was\ncaught!", self.enemy.name)) + -- ItemUseBallText05: text_far, sound_caught_mon, text_promptbutton -- + -- the fanfare follows the caught text and holds the prompt + self:sayNextWaitSfx(Strings("All right!\n%s was\ncaught!", self.enemy.name), + function() return require("src.core.Sound").play(self.data, "Caught_Mon") end) -- same ItemUseBall .captured flow as a regular ball self:act(function() self:storeCaughtMon() end) else @@ -4553,8 +4600,12 @@ function BattleState:storeCaughtMon() markOwned(game, species) stampOT(game.save, self.enemy.mon) if isNew then - -- _ItemUseBallText06 + ShowPokedexData - self:sayNext(Strings("New POKéDEX data\nwill be added for\n%s!", self.enemy.name)) + -- _ItemUseBallText06 + ShowPokedexData: text_far, sound_dex_page_added, + -- text_promptbutton (item_effects.asm:624-629), so the fanfare follows + -- the box rather than firing when the dex bit is set + self:sayNextWaitSfx( + Strings("New POKéDEX data\nwill be added for\n%s!", self.enemy.name), + function() return require("src.core.Sound").play(self.data, "Dex_Page_Added") end) self:uiNext(function() return self:buildScreen("DexEntryMenu", species) end) @@ -4704,12 +4755,10 @@ function BattleState:throwBall(ball) self:ballChain(self:tossAnimFor(ball), caught, shakes, ball) if caught then -- ItemUseBallText05 carries sound_caught_mon (item_effects.asm: - -- 608-614): the fanfare sounds with the caught message, before - -- the prompt, not after the text is dismissed - self:actNext(function() - require("src.core.Sound").play(self.data, "Caught_Mon") - end) - self:sayNext(Strings("All right!\n%s was\ncaught!", self.enemy.name)) + -- 608-614): text_far, sound_caught_mon, text_promptbutton -- the + -- fanfare follows the caught message and holds the prompt + self:sayNextWaitSfx(Strings("All right!\n%s was\ncaught!", self.enemy.name), + function() return require("src.core.Sound").play(self.data, "Caught_Mon") end) self:act(function() self:storeCaughtMon() end) else self:sayNext(self:ballMissMessage(shakes)) @@ -5196,7 +5245,8 @@ function BattleState:sgbBattlePals() local function bar(b) if not b then return pals.GREENBAR end local hp = b.shownHP or b.mon.hp - return pals[PaletteFX.barPalName(hp, b.mon.stats.hp)] or pals.GREENBAR + return pals[PaletteFX.barPalName(hp, b.mon.stats.hp, b.shownPx)] + or pals.GREENBAR end local function mon(b, placeholder) if placeholder or not b then return pals.MEWMON or pals.GREENBAR end @@ -5594,7 +5644,7 @@ function BattleState:drawHUDs(slide) hudTile(0x73, 8, 16) drawHPBar(barData, 2, 2, { hp = shownHP(self.enemy), stats = self.enemy.mon.stats }, - nil, grayFill) + nil, grayFill, nil, self.enemy.shownPx) hudTile(0x74, 8, 24) for i = 2, 9 do hudTile(0x76, i * 8, 24) end hudTile(0x78, 80, 24) @@ -5675,7 +5725,7 @@ function BattleState:drawHUDs(slide) end drawHPBar(barData, 10, 9, { hp = shownHP(self.player), stats = self.player.mon.stats }, - 1, grayFill) -- wHPBarType 1: the $6D cap + 1, grayFill, nil, self.player.shownPx) -- wHPBarType 1: the $6D cap Font.draw(("%3d/%3d"):format(shownHP(self.player), self.player.mon.stats.hp), 88, 80) hudTile(0x73, 144, 80) hudTile(0x77, 144, 88) diff --git a/src/battle/WideBattle.lua b/src/battle/WideBattle.lua index 87e43bd2..abe3ef7a 100644 --- a/src/battle/WideBattle.lua +++ b/src/battle/WideBattle.lua @@ -108,7 +108,7 @@ local function drawStatusPanel(battle, battler, x, y, player) HudTiles.drawHPBar(battle.data, tx + 1, ty + 2, { hp = shownHP(battler), stats = battler.mon.stats, - }, nil, monoMode(), tw - 5) + }, nil, monoMode(), tw - 5, battler.shownPx) if player then Font.draw(("%3d/%3d"):format(shownHP(battler), battler.mon.stats.hp), diff --git a/src/core/BattleCheckpoint.lua b/src/core/BattleCheckpoint.lua index 67698662..c3319ab2 100644 --- a/src/core/BattleCheckpoint.lua +++ b/src/core/BattleCheckpoint.lua @@ -334,6 +334,12 @@ function BattleCheckpoint.restore(game, checkpoint, copy) battle.player.mon.hp, battle.player.mon.status battle.enemy.shownHP, battle.enemy.shownStatus = battle.enemy.mon.hp, battle.enemy.mon.status + -- the bar's own pixel length is derived HUD state, not captured: it + -- settles with shownHP above (UpdateHPBar_AnimateHPBar's end position) + local Timing = require("src.core.Timing") + for _, b in ipairs({ battle.player, battle.enemy }) do + b.shownPx = Timing.hpBarPixels(b.mon.hp, math.max(1, b.mon.stats.hp)) + end local ow = game.overworld if not ow or type(ow.restoreBattleContinuation) ~= "function" diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index 957305ff..725c013c 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -289,38 +289,6 @@ local function textField(imp, x, y, w, h, key, rawText, placeholder, focused, ac end end --- A square icon control: the header's gear and quit, and the game panel's --- manage button. Inverts to a solid white fill when hot, the same signal --- every other control here uses, and rounds to the shared control radius. --- `image` draws a texture; `drawFn(x, y, size, hot)` draws a hand-rolled --- glyph (the quit X, which ships no asset). -local function iconButton(imp, key, x, y, size, image, action, drawFn) - Kit._audit("control", x, y, size, size, key) - local focused = Kit.focusable(key, x, y, size, size) - local hot = focused or Kit.hover(x, y, size, size) - Theme.fillRounded(x, y, size, size, hot and PAL.ink or PAL.surface, 1) - Theme.strokeRounded(x, y, size, size, PAL.line, - hot and Theme.A.focus or Theme.A.hairline, 1) - if image then - local iw, ih = image:getDimensions() - local pad = math.floor(size * 0.24) - local s = math.min((size - 2 * pad) / iw, (size - 2 * pad) / ih) - if hot then love.graphics.setColor(0, 0, 0, 1) - else love.graphics.setColor(1, 1, 1, 0.85) end - love.graphics.draw(image, Theme.snap(x + (size - iw * s) / 2), - Theme.snap(y + (size - ih * s) / 2), 0, s, s) - love.graphics.setColor(1, 1, 1, 1) - elseif drawFn then - drawFn(x, y, size, hot) - end - if action and (Kit.press(x, y, size, size) or Kit._activateId == key) then - queueAction(imp, key, action) - end -end - --- The cartridge colour for a game, matching its tab in the header. Play --- wears it, so "which game is this button going to boot" is answered before --- the label is read. Unknown versions fall back to the commit green. local CART_COLOR = { red = PAL.railRed, blue = PAL.railBlue, yellow = PAL.railGold, gold = PAL.railAmber, @@ -687,7 +655,7 @@ local function buildModScopeRow(imp, x, y, w, m) end -- Dedicated Profile control section (cycle button + gear icon button) on right side of Scope Bar - local profiles, activeProf = LauncherMods.getProfiles() + local _, activeProf = LauncherMods.getProfiles() local isCompact = (w < math.floor(500 * m.s)) local nameText = tostring(activeProf or "Default") local profLabel = isCompact and nameText or Strings("Profile: %s", nameText) @@ -696,38 +664,30 @@ local function buildModScopeRow(imp, x, y, w, m) local gearX = x + w - gearW local profX = gearX - profW - math.floor(4 * m.s) - -- Tapping main profile button cycles to the next profile (styles match iconButton) - Kit._audit("control", profX, y, profW, h, "mod-scope-profile") - local focused = Kit.focusable("mod-scope-profile", profX, y, profW, h) - local hot = focused or Kit.hover(profX, y, profW, h) - Theme.fillRounded(profX, y, profW, h, hot and PAL.ink or PAL.surface, 1) - Theme.strokeRounded(profX, y, profW, h, PAL.line, - hot and Theme.A.focus or Theme.A.hairline, 1) - Kit.textCenterBold("micro", profLabel, profX, - y + (h - Kit.textHeight("micro")) / 2, profW, - hot and PAL.inverse or PAL.heading) - if Kit.press(profX, y, profW, h) or Kit._activateId == "mod-scope-profile" then - local nextIdx = 1 - for i, p in ipairs(profiles) do - if p.name == activeProf then - nextIdx = (i % #profiles) + 1 - break + btn(imp, profX, y, profW, h, "mod-scope-profile", profLabel, { + face = "invert", font = "micro", + action = function() + local list, cur = LauncherMods.getProfiles() + local nextIdx = 1 + for i, p in ipairs(list) do + if p.name == cur then + nextIdx = (i % #list) + 1 + break + end end - end - local nextProf = profiles[nextIdx] and profiles[nextIdx].name - if nextProf then - queueAction(imp, "mod-scope-profile", function() + local nextProf = list[nextIdx] and list[nextIdx].name + if nextProf then LauncherMods.applyProfile(nextProf) if imp._refreshMods then imp:_refreshMods() end - end) - end - end + end + end, + }) - -- Tapping gear button opens the Profile Manager modal imp._gearIcon = imp._gearIcon or (love and love.graphics and love.graphics.newImage and love.graphics.newImage("assets/launcher/gear.png")) - iconButton(imp, "mod-profile-gear", gearX, y, gearW, imp._gearIcon, function() - imp._profilesPopup = true - end) + btn(imp, gearX, y, gearW, gearW, "mod-profile-gear", "", { + face = "invert", image = imp._gearIcon, + action = function() imp._profilesPopup = true end, + }) if #options >= 2 then for _, opt in ipairs(options) do @@ -836,8 +796,7 @@ local function buildHeader(imp, m) local padX = math.floor(12 * m.s) local chipW = math.max(tw + 2 * padX, gear) local lx = m.x + m.pad - Theme.fill(lx, by, chipW, gear, PAL.bg, 1) - Theme.stroke(lx, by, chipW, gear, PAL.yellow, Theme.A.hover, 1) + Kit.card(lx, by, chipW, gear, "badge") local th = Kit.textHeight("small") Kit.text("small", label, lx + math.floor((chipW - tw) / 2), by + math.floor((gear - th) / 2), PAL.yellow) @@ -856,17 +815,20 @@ local function buildHeader(imp, m) imp._gearIcon = imp._gearIcon or love.graphics.newImage("assets/launcher/gear.png") rx = rx - gear - iconButton(imp, "gear", rx, by, gear, imp._gearIcon, - function() imp:_openSettings() end) + btn(imp, rx, by, gear, gear, "gear", "", { + face = "invert", image = imp._gearIcon, + action = function() imp:_openSettings() end, + }) - -- Quit, top-right corner. - iconButton(imp, "quit", quitX, by, gear, nil, - function() imp:_quitApp() end, - function(x, y, size, hot) - local pad = math.floor(size * 0.32) - drawCross(x + pad, y + pad, size - 2 * pad, + btn(imp, quitX, by, gear, gear, "quit", "", { + face = "invert", + action = function() imp:_quitApp() end, + drawFn = function(x, y, w, h, hot) + local pad = math.floor(w * 0.32) + drawCross(x + pad, y + pad, w - 2 * pad, hot and { 0, 0, 0, 1 } or { 1, 1, 1, 0.85 }) - end) + end, + }) -- The self-update control lives in the FOOTER next to the BCG mark (small, -- out of the wordmark's way -- it used to overlap the logo on a phone). It @@ -885,22 +847,16 @@ local function buildHeader(imp, m) -- bright cart gold; Gold (Gen 2) uses the deeper amber so the two do not -- collide. local tabs = { - { id = "red", letter = "R", label = Strings("RED"), color = PAL.railRed }, - { id = "blue", letter = "B", label = Strings("BLUE"), color = PAL.railBlue }, - { id = "yellow", letter = "Y", label = Strings("YELLOW"), color = PAL.railGold }, - { id = "gold", letter = "G", label = Strings("GOLD"), color = PAL.railAmber }, - { id = "mods", icon = imp._modsIcon, label = Strings("MODS") }, - { id = "find", icon = imp._findIcon, label = Strings("FIND MODS") }, + { id = "red", letter = "R", color = PAL.railRed }, + { id = "blue", letter = "B", color = PAL.railBlue }, + { id = "yellow", letter = "Y", color = PAL.railGold }, + { id = "gold", letter = "G", color = PAL.railAmber }, + { id = "mods", icon = imp._modsIcon }, + { id = "find", icon = imp._findIcon }, } local tabH = m.chip local tx = m.x + m.pad local ty = y + math.floor(6 * m.s) - -- Wrap the strip instead of running off the edge. - -- - -- Six tabs used to escape a phone width when an active icon tab spelled its - -- name out (FIND MODS at 412x915). Game tabs (R/B/Y/G) stay glyph-only even - -- when active; only MODS / FIND MODS expand. Still wrap when the next tab - -- would not fit so the divider below moves with the row count. local tabLeft = tx local tabRight = m.x + m.w - m.pad local tabGap = math.floor(6 * m.s) @@ -908,51 +864,16 @@ local function buildHeader(imp, m) for _, t in ipairs(tabs) do local active = imp.tab == t.id local key = "tab-" .. t.id - -- Cartridge tabs stay square (letter only). Icon tabs still expand to - -- show MODS / FIND MODS when selected. - local expand = active and t.icon ~= nil - local labelW = expand and Kit.textWidth("tab", t.label) or 0 - local w = expand and (tabH + math.floor(8 * m.s) + labelW + math.floor(12 * m.s)) - or tabH - -- Never wrap the first tab of a row: if one tab alone is wider than the - -- panel there is nowhere better to put it, and wrapping would loop. + local w = tabH if tx > tabLeft and tx + w > tabRight then tx = tabLeft ty = ty + tabH + tabRowGap end - Kit._audit("control", tx, ty, w, tabH, key) - local focused = Kit.focusable(key, tx, ty, w, tabH) - local hot = focused or Kit.hover(tx, ty, w, tabH) - local invert = active or hot - local tint = t.color or PAL.ink - Theme.fillRounded(tx, ty, w, tabH, invert and tint or PAL.surface, 1) - if not invert then - Theme.strokeRounded(tx, ty, w, tabH, tint, - t.color and Theme.A.hover or Theme.A.hairline, 1) - end - -- Ink on a filled tab must contrast with THAT fill: black on the light - -- red/blue/gold cartridge colours, which are all high-luminance. - local ink = invert and PAL.inverse or (t.color or PAL.text) - if t.icon then - local iw, ih = t.icon:getDimensions() - local pad = math.floor(tabH * 0.24) - local s = math.min((tabH - 2 * pad) / iw, (tabH - 2 * pad) / ih) - if invert then love.graphics.setColor(0, 0, 0, 1) - else love.graphics.setColor(1, 1, 1, 0.9) end - love.graphics.draw(t.icon, Theme.snap(tx + (tabH - iw * s) / 2), - Theme.snap(ty + (tabH - ih * s) / 2), 0, s, s) - love.graphics.setColor(1, 1, 1, 1) - else - Kit.textCenter("tab", t.letter, tx, - ty + (tabH - Kit.textHeight("tab")) / 2, tabH, ink) - end - if expand then - Kit.text("tab", t.label, tx + tabH + math.floor(4 * m.s), - ty + (tabH - Kit.textHeight("tab")) / 2, ink) - end - if Kit.press(tx, ty, w, tabH) or Kit._activateId == key then - queueAction(imp, key, function() imp:_switchTab(t.id) end) - end + btn(imp, tx, ty, w, tabH, key, "", { + face = "tab", font = "tab", color = t.color, active = active, + image = t.icon, letter = t.letter, + action = function() imp:_switchTab(t.id) end, + }) tx = tx + w + tabGap end @@ -1454,8 +1375,10 @@ local function buildGamePanel(imp, x, y, w, availH, m, version) version, gameName, function() imp:play(version, true) end) imp._gearIcon = imp._gearIcon or love.graphics.newImage("assets/launcher/gear.png") - iconButton(imp, "manage-" .. version, lx + lw - mgW, ly, mgW, - imp._gearIcon, function() imp._gameManage = version end) + btn(imp, lx + lw - mgW, ly, mgW, mgW, "manage-" .. version, "", { + face = "invert", image = imp._gearIcon, + action = function() imp._gameManage = version end, + }) ly = ly + playH + gap end @@ -1743,8 +1666,7 @@ local function buildModsPanel(imp, x, y, w, availH, m) local focused = Kit.focusable(rowKey, x, ry, w, rowH) local hot = focused or Kit.hover(x, ry, w, rowH) if isFullyDisabled then - Theme.fillRounded(x, ry, w, rowH, PAL.bg, 0.8, Theme.cardRadius()) - Theme.strokeRounded(x, ry, w, rowH, PAL.muted, hot and Theme.A.hover or 0.25, 1, Theme.cardRadius()) + Kit.card(x, ry, w, rowH, hot and "mutedHot" or "muted") else Kit.card(x, ry, w, rowH, hot) end @@ -3020,8 +2942,7 @@ local function buildSettingsModal(imp, m) else local row = item.row local key = "set-" .. i - Theme.strokeRounded(px + pad, ry, pw - 2 * pad, rowH, PAL.line, - Theme.A.hairline, 1) + Kit.card(px + pad, ry, pw - 2 * pad, rowH, "hairline") local ix = px + pad + math.floor(12 * m.s) -- Where the label prints, and where the control band starts. Stacked: -- label on its own full-width line, controls on the line below it. @@ -3136,8 +3057,7 @@ local function buildDepResolverModal(imp, m) cy = cy + Kit.textHeight("small") + math.floor(10 * m.s) -- Security Disclaimer Banner Callout Card - Theme.fillRounded(px + pad, cy, pw - 2 * pad, warnH, PAL.rowBg, 1, Theme.radius()) - Theme.strokeRounded(px + pad, cy, pw - 2 * pad, warnH, PAL.yellow, Theme.A.hover, 1, Theme.radius()) + Kit.card(px + pad, cy, pw - 2 * pad, warnH, "warn") local warnMsg = Strings("Caution: Only pull dependencies from sources you trust.\nVerify source repositories before fetching.") Kit.text("micro", warnMsg, px + pad + math.floor(12 * m.s), cy + math.floor(5 * m.s), PAL.yellow) cy = cy + warnH + math.floor(12 * m.s) @@ -3173,7 +3093,7 @@ local function buildDepResolverModal(imp, m) if ry + rowH >= cy and ry <= cy + listH then -- Item Card Fill & Stroke (matching launcher card interiors & radius) local hot = Kit.hover(px + pad, ry, pw - 2 * pad, rowH) - Theme.row(px + pad, ry, pw - 2 * pad, rowH, hot and "hover" or "normal") + Kit.card(px + pad, ry, pw - 2 * pad, rowH, hot and "rowHover" or "row") local ix = px + pad + math.floor(12 * m.s) local innerW = pw - 2 * pad - math.floor(24 * m.s) diff --git a/src/link/LinkBattle.lua b/src/link/LinkBattle.lua index 99e36aef..40c094d7 100644 --- a/src/link/LinkBattle.lua +++ b/src/link/LinkBattle.lua @@ -9,15 +9,16 @@ -- cable pull. -- -- Cable rules: no experience, no money, no items; either side may RUN --- (a draw); a fainted mon is auto-replaced by the next healthy party --- member (the original prompts; documented divergence). Badge stat --- boosts don't apply on either side (divergence: Gen 1 famously kept --- them in link battles). +-- (a draw); a fainted mon is replaced from the party menu and the chosen +-- slot rides the wire, the way ChooseNextMon hands it to +-- LinkBattleExchangeData. Badge stat boosts don't apply on either side +-- (divergence: Gen 1 famously kept them in link battles). local Fingerprint = require("src.link.Fingerprint") local Font = require("src.render.Font") local Handshake = require("src.link.Handshake") local Logger = require("src.core.Logger") +local Party = require("src.pokemon.Party") local Protocol = require("src.link.Protocol") local Runtime = require("src.mods.Runtime") local TurnOrder = require("src.battle.TurnOrder") @@ -257,7 +258,9 @@ function LinkBattle.new(game, net, opts) self.enemyParty = theirParty self.playerParty = myParty -- intro ball row uses the clamped copies self.opponentName = theirName - self.introText = Strings("%s wants\nto battle!", theirName) + -- _TrainerWantsToFightText (data/text/text_2.asm:1257): wIsInBattle == 2 + -- takes PrintBeginningBattleText's .trainerBattle arm, link included + self.introText = Strings("%s wants\nto fight!", theirName) self.remoteHashes = {} self.localHashes = {} self.remoteParts = {} @@ -327,6 +330,68 @@ function LinkBattle.new(game, net, opts) end) end + -- ChooseNextMon (engine/battle/core.asm:1086-1103): the replacement after + -- a faint is a free party-menu pick in a link battle too -- DisplayPartyMenu + -- runs first, a fainted pick or a cancel goes back to it + -- (.goBackToPartyMenu), and only the chosen slot rides + -- LinkBattleExchangeData. + local function chooseReplacement(s) + s.linkReplacement = nil + s:uiNext(function() + return s:buildScreen("PartyMenu", { + battle = s, + party = myParty, + forceSwitch = true, + onSwitch = function(mon) + if mon.hp > 0 then s.linkReplacement = mon end + end, + }) + end) + s:actNext(function() + local mon = s.linkReplacement + s.linkReplacement = nil + if s.result then return end + if not mon then + chooseReplacement(s) + return + end + for i, m in ipairs(myParty) do + if m == mon then send({ type = "replace", index = i }) break end + end + sendOutPlayer(s, mon) + end) + end + + -- ReplaceFaintedEnemyMon (core.asm:892-905) reads the peer's slot back out + -- of the exchange, and EnemySendOutFirstMon (core.asm:1315-1320) decodes it + -- as wSerialExchangeNybbleReceiveData - 4. HandlePlayerMonFainted + -- (core.asm:989-996) always runs ChooseNextMon BEFORE that read, so on a + -- double faint both machines commit their own slot before they block on the + -- peer's; our queue can reach the enemy's handler first, so the wait + -- pre-empts itself with the local pick rather than deadlocking on a message + -- neither side is going to send. + local function awaitReplacement(s) + s:actNext(function() + if s.result then return end + if s.player.mon.hp <= 0 and not s.linkChoiceQueued + and Party.firstHealthy(myParty) then + s.linkChoiceQueued = true + chooseReplacement(s) + awaitReplacement(s) + return + end + local idx = s.remoteReplace + if not idx then + awaitReplacement(s) + return + end + s.remoteReplace = nil + local mon = theirParty[idx] + if not mon or mon.hp <= 0 then mon = Party.firstHealthy(theirParty) end + sendOutEnemy(s, mon) + end) + end + -- decode a remote action message against the enemy battler local function decodeTheirAction(s, msg) return decodeWireAction(s, msg, s.enemy) @@ -540,12 +605,17 @@ function LinkBattle.new(game, net, opts) submit(s, { type = "action", kind = "run" }, nil) end - -- fainted mons auto-replace with the next healthy teammate, in party - -- order, identically on both machines + -- linkChoiceQueued: awaitReplacement already pre-empted itself with this + -- side's ChooseNextMon and put the slot on the wire, so the handler the + -- faint queued has nothing left to do but drop the flag self.playerMonFainted = function(s) + if s.linkChoiceQueued then + s.linkChoiceQueued = nil + return + end for _, mon in ipairs(myParty) do if mon.hp > 0 then - s:act(function() sendOutPlayer(s, mon) end) + if not s.result then chooseReplacement(s) end return end end @@ -558,7 +628,7 @@ function LinkBattle.new(game, net, opts) self.enemyMonFainted = function(s) for _, mon in ipairs(theirParty) do if mon.hp > 0 then - s:act(function() sendOutEnemy(s, mon) end) + if not s.result then awaitReplacement(s) end return end end @@ -579,6 +649,9 @@ function LinkBattle.new(game, net, opts) s.remoteHashes[msg.turn or 0] = msg.value s.remoteParts[msg.turn or 0] = msg.parts checkHashes(s) + elseif msg.type == "replace" then + local idx = math.floor(tonumber(msg.index) or 1) + s.remoteReplace = math.max(1, math.min(#theirParty, idx)) elseif msg.type == "bye" then -- only a draw if our own simulation hasn't already decided -- (the winner's bye can arrive while we're still animating) @@ -770,6 +843,34 @@ function LinkBattle.newSpectator(game, net, opts) end) end + local function awaitHostReplacement(s) + s:actNext(function() + if s.result then return end + local idx = table.remove(s.hostReplace, 1) + if not idx then + awaitHostReplacement(s) + return + end + local mon = hostParty[idx] + if not mon or mon.hp <= 0 then mon = Party.firstHealthy(hostParty) end + sendOutHost(s, mon) + end) + end + + local function awaitGuestReplacement(s) + s:actNext(function() + if s.result then return end + local idx = table.remove(s.guestReplace, 1) + if not idx then + awaitGuestReplacement(s) + return + end + local mon = guestParty[idx] + if not mon or mon.hp <= 0 then mon = Party.firstHealthy(guestParty) end + sendOutGuest(s, mon) + end) + end + local function resolveSpecTurn(s, hostMsg, guestMsg) if hostMsg.kind == "run" or guestMsg.kind == "run" then endSpectate(s, "The match ended.") @@ -840,7 +941,7 @@ function LinkBattle.newSpectator(game, net, opts) self.playerMonFainted = function(s) for _, mon in ipairs(hostParty) do if mon.hp > 0 then - s:act(function() sendOutHost(s, mon) end) + if not s.result then awaitHostReplacement(s) end return end end @@ -852,7 +953,7 @@ function LinkBattle.newSpectator(game, net, opts) self.enemyMonFainted = function(s) for _, mon in ipairs(guestParty) do if mon.hp > 0 then - s:act(function() sendOutGuest(s, mon) end) + if not s.result then awaitGuestReplacement(s) end return end end @@ -862,6 +963,7 @@ function LinkBattle.newSpectator(game, net, opts) end self.hostMsg, self.guestMsg = nil, nil + self.hostReplace, self.guestReplace = {}, {} local baseUpdate = self.update self.update = function(s, dt) net:update() @@ -875,6 +977,13 @@ function LinkBattle.newSpectator(game, net, opts) s.hostMsg, s.guestMsg = nil, nil resolveSpecTurn(s, h, g) end + elseif inner.type == "replace" then + local idx = math.floor(tonumber(inner.index) or 1) + if msg.side == "host" then + table.insert(s.hostReplace, math.max(1, math.min(#hostParty, idx))) + else + table.insert(s.guestReplace, math.max(1, math.min(#guestParty, idx))) + end elseif inner.type == "bye" or inner.type == "forfeit" then if not s.result then endSpectate(s, "The match ended.") end end diff --git a/src/link/LinkState.lua b/src/link/LinkState.lua index 9f3da68d..c2464beb 100644 --- a/src/link/LinkState.lua +++ b/src/link/LinkState.lua @@ -19,6 +19,7 @@ LinkState.__index = LinkState LinkState.isOpaque = true local CURSOR = 0xED +local CURSOR_HOLLOW = 0xEC local ANY = "ANY" -- sentinel: a leading nil array entry breaks ipairs under -- LuaJIT even though # still reports the full size, so -- the level picker cycles this string instead of nil, @@ -114,6 +115,7 @@ end function LinkState:exitWith(message, reason) DiscordPresence.setJoinCode(nil) self.game.linkSession = nil -- back to the player's own GAME SPEED + if self.game.linkNet == self.net then self.game.linkNet = nil end Runtime.emit("link.ended", { reason = reason or (message and "error" or "bye") }) if self.net then self.net:close() end self.game.stack:pop() @@ -519,6 +521,9 @@ function LinkState:startMode(mode, isHost) }) self.net:send(self.trade:opening()) self.index = 1 + self.theirIndex = 1 + self.side = "mine" + self.pickChoice = nil else self.stage = "battleWait" -- the host deals the shared RNG seed for the lockstep simulation @@ -536,7 +541,14 @@ end -- trade flow -- ------------------------------------------------------------------- +function LinkState:openStats(mon) + if not mon then return end + self.game.linkNet = self.net + Screens.push(self.game, "SummaryMenu", mon) +end + function LinkState:updateTrade(input) + if self.game.linkNet == self.net then self.game.linkNet = nil end for _, msg in ipairs(self.net:poll()) do local reply = self.trade:handle(msg) if reply then self.net:send(reply) end @@ -591,10 +603,51 @@ function LinkState:updateTrade(input) return end - if t.stage == "picking" and input:wasPressed("up") then - self.index = math.max(1, self.index - 1) + -- pokered engine/link/cable_club.asm TradeCenter_SelectMon: A on one of + -- your own mons opens the "STATS TRADE" row (.displayStatsTradeMenu) + -- and only TRADE commits the pick, while the enemy list carries its own + -- cursor whose A shows that mon's status pages (.displayEnemyMonStats). + -- The cart's enemy path sets hl but never wMonDataLocation, the way the + -- battle menu's STATS (engine/battle/core.asm) does, so LoadMonData_ reads + -- the player's party and it draws YOUR mon at that slot -- an omission, + -- not behaviour, so we show the peer's mon. + if t.stage == "picking" and self.pickChoice then + if input:wasPressed("left") then + self.pickChoice = 1 + elseif input:wasPressed("right") then + self.pickChoice = 2 + elseif input:wasPressed("b") then + self.pickChoice = nil -- .cancelPlayerMonChoice: back to the list, not + -- out of the trade + elseif input:wasPressed("a") then + if self.pickChoice == 1 then + self.pickChoice = nil + self:openStats(self.game.save.party[self.index]) + elseif t:canPick(self.index) then + self.pickChoice = nil + self.side = "mine" + self.net:send(t:pick(self.index)) + end + end + elseif t.stage == "picking" and input:wasPressed("up") then + if self.side == "theirs" then + self.theirIndex = math.max(1, self.theirIndex - 1) + else + self.index = math.max(1, self.index - 1) + end elseif t.stage == "picking" and input:wasPressed("down") then - self.index = math.min(#self.game.save.party, self.index + 1) + if self.side == "theirs" then + self.theirIndex = math.min(#(t.theirParty or {}), self.theirIndex + 1) + else + self.index = math.min(#self.game.save.party, self.index + 1) + end + elseif t.stage == "picking" and input:wasPressed("right") then + if t.theirParty and #t.theirParty > 0 then + self.side = "theirs" + self.theirIndex = math.min(self.theirIndex, #t.theirParty) + end + elseif t.stage == "picking" and input:wasPressed("left") then + self.side = "mine" elseif self.confirmed == nil and input:wasPressed("b") then -- once confirm=true has been sent to the peer, backing out here -- would desync the two sides (the peer may already be committing @@ -603,8 +656,10 @@ function LinkState:updateTrade(input) self.net:send({ type = "bye" }) self:exitWith(Strings("The trade was\ncancelled.")) elseif t.stage == "picking" and input:wasPressed("a") then - if t:canPick(self.index) then - self.net:send(t:pick(self.index)) + if self.side == "theirs" then + self:openStats((t.theirParty or {})[self.theirIndex]) + else + self.pickChoice = 1 end elseif t.stage == "confirming" and self.confirmed == nil then if input:wasPressed("a") then @@ -738,25 +793,40 @@ function LinkState:draw() local label = (mon.nickname or def.name):sub(1, 8) if not t:canPick(i) then label = label .. "X" end Font.draw(label, 16, 20 + i * 12) - if i == self.index then Font.drawCode(CURSOR, 8, 20 + i * 12) end + if i == self.index and self.side ~= "theirs" then + Font.drawCode(CURSOR, 8, 20 + i * 12) + end end Font.draw(Strings("THEIRS"), 84, 20) for i, mon in ipairs(t.theirParty or {}) do local def = self.game.data.pokemon[mon.species] Font.draw((mon.nickname or def.name):sub(1, 8), 92, 20 + i * 12) - if t.theirPick == i then Font.drawCode(CURSOR, 84, 20 + i * 12) end + if self.side == "theirs" and i == self.theirIndex then + Font.drawCode(CURSOR, 84, 20 + i * 12) + elseif t.theirPick == i then + Font.drawCode(CURSOR_HOLLOW, 84, 20 + i * 12) + end end - local hint - if t.stage == "waitRecords" then hint = "Comparing games..." - elseif t.stage == "waitParty" then hint = "Exchanging data..." - elseif t.stage == "picking" then - hint = t:canPick(self.index) and "Pick one to trade" - or Strings("X: not on theirs") - elseif t.stage == "waitPick" then hint = "Waiting for them..." - elseif t.stage == "confirming" then - hint = self.confirmed and "Waiting..." or Strings("A: trade B: cancel") + if self.pickChoice then + Font.draw(Strings("STATS"), 16, 128) + Font.draw(Strings("TRADE"), 96, 128) + Font.drawCode(CURSOR, self.pickChoice == 1 and 8 or 88, 128) + else + local hint + if t.stage == "waitRecords" then hint = "Comparing games..." + elseif t.stage == "waitParty" then hint = "Exchanging data..." + elseif t.stage == "picking" then + if self.side == "theirs" then hint = Strings("A: stats") + else + hint = t:canPick(self.index) and "Pick one to trade" + or Strings("X: not on theirs") + end + elseif t.stage == "waitPick" then hint = "Waiting for them..." + elseif t.stage == "confirming" then + hint = self.confirmed and "Waiting..." or Strings("A: trade B: cancel") + end + Font.draw(hint or "", 8, 132) end - Font.draw(hint or "", 8, 132) elseif self.stage == "battleWait" or self.stage == "battleRunning" then drawTitle("LINK BATTLE") diff --git a/src/pokemon/Evolution.lua b/src/pokemon/Evolution.lua index 83b6a1d6..279daa50 100644 --- a/src/pokemon/Evolution.lua +++ b/src/pokemon/Evolution.lua @@ -140,10 +140,12 @@ function Evolution.learnEvolutionMoves(game, mon, onDone) if #mon.moves < 4 then table.insert(mon.moves, { id = moveId, pp = mdef.pp }) Runtime.emit("pokemon.move_learned", { mon = mon, moveId = moveId }) - require("src.core.Sound").play(game.data, "Get_Item1") + -- LearnedMove1Text: text_far, sound_get_item_1, text_promptbutton + -- (learn_move.asm), so the jingle rides the box game.stack:push(TextBox.new(game, romText(game.data, "_LearnedMove1Text", - "%s learned\n%s!", name, mdef.name), nextStep)) + "%s learned\n%s!", name, mdef.name), nextStep, + TextBox.soundOpts(game, "Get_Item1"))) else -- LearnMoveFromLevelUp with a full moveset: the forget UI Screens.push(game, "MoveLearnMenu", mon, moveId, nextStep) diff --git a/src/render/HudTiles.lua b/src/render/HudTiles.lua index 8f148039..c9712a43 100644 --- a/src/render/HudTiles.lua +++ b/src/render/HudTiles.lua @@ -141,13 +141,20 @@ end -- Tinting first would double-apply the color: GREENBAR's fill {0,189,0} has -- red channel 0, so the tint zeroes the whole bar's red and the zone's -- red-channel-keyed shade shader then maps every pixel to color 3 = black. -function HudTiles.drawHPBar(data, tx, ty, mon, barType, grayFill, segments) +-- +-- pixels: an explicit 0..48 bar length on GetHPBarLength's scale, for a +-- caller that is animating the bar between two HP values +-- (UpdateHPBar_AnimateHPBar); it scales with `segments` like the color +-- thresholds do. Without it the length comes from mon.hp as before. +function HudTiles.drawHPBar(data, tx, ty, mon, barType, grayFill, segments, pixels) local x, y = tx * 8, ty * 8 segments = math.max(1, math.floor(segments or 6)) HudTiles.tile(0x71, x, y) HudTiles.tile(0x62, x + 8, y) local px = 0 - if mon.stats.hp > 0 and mon.hp > 0 then + if pixels then + px = math.max(0, math.floor(pixels * segments / 6)) + elseif mon.stats.hp > 0 and mon.hp > 0 then px = math.max(1, math.floor(mon.hp * segments * 8 / mon.stats.hp)) end local tint diff --git a/src/render/PaletteFX.lua b/src/render/PaletteFX.lua index ea654d9d..90fb6580 100644 --- a/src/render/PaletteFX.lua +++ b/src/render/PaletteFX.lua @@ -691,10 +691,12 @@ function PaletteFX.spriteObp(spriteDef, seed) return PaletteFX.darkObp(w.spritePalettes[group], group) end --- GetHealthBarColor (home/palettes.asm) on the standard 48px bar -function PaletteFX.barPalName(hp, maxHp) - local px = maxHp > 0 and math.floor(hp * 48 / maxHp) or 0 - if hp > 0 and px < 1 then px = 1 end +-- GetHealthBarColor (home/palettes.asm) on the standard 48px bar. It reads +-- the bar's own length, so a caller mid-drain passes the animated `pixels` +-- rather than let it be re-derived from hp. +function PaletteFX.barPalName(hp, maxHp, pixels) + local px = pixels or (maxHp > 0 and math.floor(hp * 48 / maxHp) or 0) + if not pixels and hp > 0 and px < 1 then px = 1 end return px >= 27 and "GREENBAR" or px >= 10 and "YELLOWBAR" or "REDBAR" end diff --git a/src/render/TextBox.lua b/src/render/TextBox.lua index 67ef8910..3843cbc1 100644 --- a/src/render/TextBox.lua +++ b/src/render/TextBox.lua @@ -100,6 +100,26 @@ function TextBox.new(game, text, onDone, opts) return self end +-- soundOpts: a jingle carried at the end of the string as a trailing text +-- command (sound_get_item_1 and friends -> home/text.asm TextCommand_SOUND), +-- which runs PlaySound then WaitForSoundToFinish once the last page has +-- typed, so the box holds until the fanfare is over. Merges into a caller's +-- opts table; auto.wait keeps the trailing button press the plain A/B path +-- gives every other box. +function TextBox.soundOpts(game, sound, opts) + opts = opts or {} + local auto = opts.auto + -- auto = true is the no-button-wait arming (text_opts); keep that choice + if auto == true then auto = { wait = false } end + auto = auto or {} + if auto.wait == nil then auto.wait = true end + auto.delay = auto.delay or 0 + auto.sound = type(sound) == "function" and sound + or function() return require("src.core.Sound").play(game.data, sound) end + opts.auto = auto + return opts +end + -- The runtime tokens substitute() knows, as handlers the tokens registry -- serves. Each is fn(game, arg) -> replacement, or nil to drop the token. -- RAM keeps pokered's stale-buffer semantics: give_item copies the item diff --git a/src/script/Commands.lua b/src/script/Commands.lua index 9390be2a..104b2c50 100644 --- a/src/script/Commands.lua +++ b/src/script/Commands.lua @@ -592,6 +592,17 @@ function Commands.play_sound(ctx, soundId) require("src.core.Sound").play(ctx.game.data, soundId) end +-- text_sound : the jingle the ROM parks at the END of a string as +-- a trailing text command (sound_get_item_1, sound_get_key_item -> +-- home/text.asm TextCommand_SOUND). It arms the NEXT show_text the same +-- way play_cry arms ctx.pendingCry, so the fanfare fires once the last page +-- has typed and the box holds on WaitForSoundToFinish before the button +-- wait. play_sound stays the bare PlaySound used for the non-blocking +-- beats (Bill's teleporter, the S.S. Anne horn). +function Commands.text_sound(ctx, soundId) + ctx.textOpts = TextBox.soundOpts(ctx.game, soundId, ctx.textOpts) +end + -- play_once : one-shot jingle (Music_PkmnHealed, etc.); blocks -- until it finishes so heal-rest scripts (Mom, captain text_asm) match -- Gen1's wait-on-channel loop. The map theme resumes when it ends @@ -1050,8 +1061,10 @@ function Commands.trade(ctx, tradeIndex, doneFlag) onDone = function() runner:resume() end, }) runner:yield() - -- TradedForText (sound_get_key_item) then the dialogset's thanks - require("src.core.Sound").play(data, "Get_Key_Item") + -- TradedForText carries sound_get_key_item after the text, so the jingle + -- rides the box and blocks it (home/text.asm TextCommand_SOUND), then the + -- dialogset's thanks + Commands.text_sound(ctx, "Get_Key_Item") say(texts.tradedFor or "_TradedForText") say(texts.thanks or "_Thanks" .. dialogset .. "Text") end diff --git a/src/ui/BagMenu.lua b/src/ui/BagMenu.lua index 11226454..25440f22 100644 --- a/src/ui/BagMenu.lua +++ b/src/ui/BagMenu.lua @@ -33,12 +33,12 @@ local function save_name(game) return game.save.player.name end -local function showMessages(game, msgs, onDone) +local function showMessages(game, msgs, onDone, opts) if not msgs or #msgs == 0 then if onDone then onDone() end return end - game.stack:push(TextBox.new(game, table.concat(msgs, "\f"), onDone)) + game.stack:push(TextBox.new(game, table.concat(msgs, "\f"), onDone, opts)) end -- run the use-flow for an item on a chosen target. `picker` is the party @@ -181,9 +181,11 @@ local function useOn(game, battle, id, target, list, moveIndex, picker) end if #target.moves < 4 then table.insert(target.moves, { id = moveId, pp = mdef.pp }) - require("src.core.Sound").play(game.data, "Get_Item1") + -- LearnedMove1Text: text_far, sound_get_item_1, text_promptbutton + -- (learn_move.asm), so the jingle rides the box showMessages(game, { Strings("%s learned\n%s!", target.nickname or - game.data.pokemon[target.species].name, mdef.name) }) + game.data.pokemon[target.species].name, mdef.name) }, nil, + TextBox.soundOpts(game, "Get_Item1")) if result == "learn" then consume(game, id) end list.items = buildItems(game) list.index = math.min(list.index, math.max(1, #list.items)) @@ -333,10 +335,9 @@ local function useOn(game, battle, id, target, list, moveIndex, picker) local mdef = game.data.moves[moveId] if #target.moves < 4 then table.insert(target.moves, { id = moveId, pp = mdef.pp }) - require("src.core.Sound").play(game.data, "Get_Item1") local name = target.nickname or def.name showMessages(game, { Strings("%s learned\n%s!", name, mdef.name) }, - nextStep) + nextStep, TextBox.soundOpts(game, "Get_Item1")) else require("src.ui.Screens").push(game, "MoveLearnMenu", target, moveId, nextStep) diff --git a/src/ui/EvolutionState.lua b/src/ui/EvolutionState.lua index d819b03e..d704a3e7 100644 --- a/src/ui/EvolutionState.lua +++ b/src/ui/EvolutionState.lua @@ -46,9 +46,27 @@ function EvolutionState:sgbPalettes(game) return P.wholeNamed(game.data, "MEWMON") end -local FLASH_FRAMES = 220 -- evolution.asm EvolveMon delays 80 frames before .animLoop, polling nothing (#968, #1031) local CANCEL_GRACE_FRAMES = 80 +-- .animLoop starts at `lb bc, $1, $10` and runs 8 iterations: iteration k holds +-- the old pic for c = 18-2k frames inside Evolution_CheckForCancel (72 in all), +-- then Evolution_BackAndForthAnim swaps b = k times, each swap a pair of +-- Evolution_ChangeMonPic that end in Delay3 (6 frames a swap, 216 in all). +local ANIM_LOOP_FRAMES = 288 +local FLASH_FRAMES = CANCEL_GRACE_FRAMES + ANIM_LOOP_FRAMES + +-- which pic is on screen t frames into .animLoop +local function evoShowsNew(t) + for b = 1, 8 do + local hold = 18 - 2 * b + if t < hold then return false end + t = t - hold + local swap = b * 6 + if t < swap then return t % 6 < 3 end + t = t - swap + end + return true +end local function frontSprite(game, species, mon) local path, trueColor = require("src.pokemon.Sprites").path( @@ -140,14 +158,10 @@ function EvolutionState:draw() else sprite, spriteTrueColor = self.newSprite, self.newSpriteTrueColor end + elseif evoShowsNew(self.t - CANCEL_GRACE_FRAMES) then + sprite, spriteTrueColor = self.newSprite, self.newSpriteTrueColor else - local period = math.max(4, 28 - math.floor(self.t / 40) * 6) - local showNew = math.floor(self.t / period) % 2 == 1 - if showNew then - sprite, spriteTrueColor = self.newSprite, self.newSpriteTrueColor - else - sprite, spriteTrueColor = self.oldSprite, self.oldSpriteTrueColor - end + sprite, spriteTrueColor = self.oldSprite, self.oldSpriteTrueColor end if sprite then local x = math.floor((160 - sprite:getWidth()) / 2) diff --git a/src/ui/IntroMovie.lua b/src/ui/IntroMovie.lua index ed89cd5e..b35dcef5 100644 --- a/src/ui/IntroMovie.lua +++ b/src/ui/IntroMovie.lua @@ -6,6 +6,7 @@ local GameVersion = require("src.core.GameVersion") local Music = require("src.core.Music") local Sound = require("src.core.Sound") local Strings = require("src.core.Strings") +local Timing = require("src.core.Timing") local IntroMovie = {} IntroMovie.__index = IntroMovie @@ -232,6 +233,10 @@ function IntroMovie:fightStep() self:finish() return end + -- an op that consumed frames ends ON its last frame; only the instant + -- ops (sfx / pose / frame, which are plain writes between DelayFrames + -- calls in PlayIntroScene) chain into the next op the same frame + local timed = false if op.sfx then Sound.play(self.game.data, op.sfx) elseif op.pose then @@ -250,6 +255,7 @@ function IntroMovie:fightStep() end self.opTimer = self.opTimer + 1 if self.opTimer < (op.px or math.abs(op.dx)) then return end + timed = true elseif op.anim then -- one {dy,dx} delta per 5 frames (AnimateIntroNidorino: DelayFrames 5) if self.opTimer % 5 == 0 then @@ -259,9 +265,11 @@ function IntroMovie:fightStep() end self.opTimer = self.opTimer + 1 if self.opTimer < #ANIM[op.anim] * 5 then return end + timed = true elseif op.wait then self.opTimer = self.opTimer + 1 if self.opTimer < op.wait then return end + timed = true elseif op.fade then self.opTimer = self.opTimer + 1 self.fade = self.opTimer / op.fade @@ -270,6 +278,7 @@ function IntroMovie:fightStep() end self.opIndex = self.opIndex + 1 self.opTimer = 0 + if timed then return end end end @@ -284,8 +293,9 @@ function IntroMovie:update(dt) return end local input = self.game.input - if input:wasPressed("a") or input:wasPressed("b") - or input:wasPressed("start") then + if input:wasPressed("a") or input:wasPressed("start") then + -- CheckForUserInterruption (home/overworld.asm:2395) returns carry only + -- on a fresh START or A -- B alone never skips the intro. -- PlayIntro still GBFadeOutToWhite's after an interrupted scene; the -- white hold stands in for that beat before the title is built. self:exitToTitle() @@ -300,7 +310,10 @@ function IntroMovie:update(dt) end if self.timer >= SPLASH_FRAMES then self:startPhase(3) end else - self:fightStep() + -- PlayShootingStar ends `jp Delay3` once Music_IntroBattle is playing + -- (intro.asm:337), so PlayIntroScene's first op is not on the music's + -- own frame + if self.timer > Timing.DELAY3 then self:fightStep() end end end diff --git a/src/ui/PartyMenu.lua b/src/ui/PartyMenu.lua index 013b87fd..ee471590 100644 --- a/src/ui/PartyMenu.lua +++ b/src/ui/PartyMenu.lua @@ -87,6 +87,15 @@ local function refuseUnavailable(self) t._SleepingPikachuText1 or Strings("There isn't any\nresponse..."))) end +-- .newBadgeRequired (start_sub_menus.asm): every badge-gated arm of +-- .outOfBattleMovePointers prints this and jumps back to the open submenu +local function refuseBadge(self) + local TextBox = require("src.render.TextBox") + local t = self.game.data and self.game.data.text or {} + self.game.stack:push(TextBox.new(self.game, + t._NewBadgeRequiredText or Strings("No! A new BADGE\nis required."))) +end + -- where DIG escapes work: escape_rope_tilesets.asm (Agatha's room is -- excluded by map id in ItemUseEscapeRope) local DIG_TILESETS = { FOREST = true, CEMETERY = true, CAVERN = true, @@ -411,6 +420,23 @@ function PartyMenu:update(dt) -- flyTo (OverworldController) validates the fly-warp + runs the -- departure/warp, so we just hand it the chosen mapId (#195). local ow = self.game.overworld + -- .fly checks THUNDERBADGE first, then CheckIfInOutsideMap + -- (OVERWORLD + PLATEAU -- Route 23 / Indigo Plateau outdoor -- not + -- OVERWORLD alone, #83); both refusals loop back to the submenu + if ow and not ow:partyKnows("FLY") then + refuseBadge(self) + return + end + if ow and not Map.isOutside(ow.map.def, + FieldDefaults.field(self.game.data, "outsideTilesets")) then + local TextBox = require("src.render.TextBox") + local def = self.game.data.pokemon[mon.species] + local txt = (self.game.data.text._CannotFlyHereText + or Strings("{RAM:wNameBuffer} can't\nFLY here.")) + :gsub("{RAM:wNameBuffer}", mon.nickname or def.name) + self.game.stack:push(TextBox.new(self.game, txt)) + return -- .loop: submenu stays open behind the message + end self.game.stack:pop() -- close the party menu Screens.push(self.game, "TownMap", { fly = true, onFly = function(mapId) if ow then ow:flyTo(mapId) end @@ -423,9 +449,17 @@ function PartyMenu:update(dt) -- over the menu, and the cave is lit when the blink hands the -- screen back, never under the text (#385). local ow = self.game.overworld + if ow and not ow:partyKnows("FLASH") then + refuseBadge(self) + return + end local TextBox = require("src.render.TextBox") local Transition = require("src.render.Transition") - self.game.save.flashLit = true + -- .flash prints on any map, but the light it records is this map's: + -- home/overworld.asm re-arms wMapPalOffset on the next dark map, so + -- a FLASH used in daylight must not carry into Rock Tunnel + local wasDark = ow and ow.dark + if wasDark then self.game.save.flashLit = true end self.game.stack:push(TextBox.new(self.game, self.game.data.text._FlashLightsAreaText or Strings("A blinding FLASH\nlights the area!"), function() @@ -440,13 +474,13 @@ function PartyMenu:update(dt) -- seconds of per-pixel atlas baking on a phone -- on screen as a -- solid white frame with nothing under it, which reads as a -- lockup (#610). - ow:setDark(false) + if wasDark then ow:setDark(false) end self.game.stack:push(Transition.whiteFlash(self.game)) end)) return elseif action == "surf" then - -- start_sub_menus.asm .surf: SOULBADGE-gated (checked at list time - -- above), then IsSurfingAllowed (the Cycling Road / Seafoam B4F + -- start_sub_menus.asm .surf: SOULBADGE-gated (useSurfFieldMove), + -- then IsSurfingAllowed (the Cycling Road / Seafoam B4F -- current refusals, both of which loop back to the submenu), then -- ItemUseSurfboard: while surfing it tries to dismount instead; -- otherwise it mounts only if the FACING tile is water, else @@ -503,8 +537,8 @@ function PartyMenu:update(dt) return -- .loop: submenu stays open behind the message elseif action == "cut" then -- start_sub_menus.asm .cut -> predef UsedCut (engine/overworld/cut.asm): - -- CASCADEBADGE-gated (list time); _NothingToCutText loops back to the - -- submenu when the FACING tile isn't a cuttable tree. + -- CASCADEBADGE-gated (useCutFieldMove); _NothingToCutText loops back + -- to the submenu when the FACING tile isn't a cuttable tree. local ow = self.game.overworld local reason = ow:useCutFieldMove() if reason == "ok" then @@ -522,7 +556,7 @@ function PartyMenu:update(dt) self.game.stack:push(TextBox.new(self.game, txt)) return -- .loop: submenu stays open behind the message elseif action == "strength" then - -- start_sub_menus.asm .strength: RAINBOWBADGE-gated (list time); + -- start_sub_menus.asm .strength: RAINBOWBADGE-gated; -- predef PrintStrengthText (field_move_messages.asm) sets -- BIT_STRENGTH_ACTIVE of wStatusFlags1 -- the sole gate -- push_boulder.asm reads -- then prints _UsedStrengthText (no @@ -532,6 +566,10 @@ function PartyMenu:update(dt) -- .strength, GBPalWhiteOutWithDelay3 blinks the screen white -- before CloseTextDisplay returns to the map. local ow = self.game.overworld + if ow and not ow:partyKnows("STRENGTH") then + refuseBadge(self) + return + end local TextBox = require("src.render.TextBox") local Transition = require("src.render.Transition") local def = self.game.data.pokemon[mon.species] @@ -565,6 +603,33 @@ function PartyMenu:update(dt) -- centralizes the spin -> fade -> warp so BagMenu's ESCAPE ROPE shares -- the exact departure; the fade + warp fire when the spin ends. local ow = self.game.overworld + if entry.move == "TELEPORT" then + -- .teleport: TELEPORT works only OUTDOORS (CheckIfInOutsideMap -- + -- OVERWORLD + PLATEAU, #83); dark maps don't block it + if ow and not Map.isOutside(ow.map.def, + FieldDefaults.field(self.game.data, "outsideTilesets")) then + local TextBox = require("src.render.TextBox") + local def = self.game.data.pokemon[mon.species] + local txt = (self.game.data.text._CannotUseTeleportNowText + or Strings("{RAM:wNameBuffer} can't\nuse TELEPORT now.")) + :gsub("{RAM:wNameBuffer}", mon.nickname or def.name) + self.game.stack:push(TextBox.new(self.game, txt)) + return -- .loop: submenu stays open behind the message + end + elseif ow and not (DIG_TILESETS[ow.map.def.tileset] + and ow.map.id ~= "AGATHAS_ROOM") then + -- .dig runs ItemUseEscapeRope (it sets wCurItem = ESCAPE_ROPE): + -- usable in the dungeon tilesets of escape_rope_tilesets.asm minus + -- Agatha's room, even in the dark (Rock Tunnel); anywhere else + -- .notUsable -> ItemUseNotTime, the same line BagMenu prints for a + -- bagged ESCAPE ROPE + local TextBox = require("src.render.TextBox") + self.game.stack:push(TextBox.new(self.game, + self.game.data.text._ItemUseNotTimeText + or Strings("OAK: %s!\nThis isn't the\ntime to use that!", + self.game.save.player.name))) + return -- .loop: submenu stays open behind the message + end self.game.stack:pop() if ow then ow:beginTeleportOut() end return @@ -658,42 +723,30 @@ function PartyMenu:update(dt) -- Battle still excludes this list via `not self.battle`. Softboiled -- can appear for a fainted user; its heal transfer then no-ops. if not self.battle and ow then - -- FLY/TELEPORT: CheckIfInOutsideMap (OVERWORLD + PLATEAU -- - -- Route 23 / Indigo Plateau outdoor), not OVERWORLD alone (#83) - local outside = Map.isOutside(ow.map.def, - FieldDefaults.field(self.game.data, "outsideTilesets")) + -- GetMonFieldMoves (engine/menus/text_box.asm) matches the mon's + -- four moves against FieldMoveDisplayData and nothing else -- no + -- badge, no map, no tileset test. Every one of those lives in + -- .outOfBattleMovePointers, i.e. on selection, where the refusal + -- prints and .loop returns to this still-open submenu (#1022). for _, mv in ipairs(mon.moves) do - if mv.id == "FLY" and outside - and self.game.save.inventory.THUNDERBADGE then + if mv.id == "FLY" then table.insert(items, { label = Strings("FLY"), action = "fly" }) - elseif mv.id == "FLASH" and ow.dark - and self.game.save.inventory.BOULDERBADGE then + elseif mv.id == "FLASH" then table.insert(items, { label = Strings("FLASH"), action = "flash" }) - elseif mv.id == "CUT" and self.game.save.inventory.CASCADEBADGE then - -- CUT/SURF/STRENGTH are party-menu field moves too - -- (start_sub_menus.asm .outOfBattleMovePointers); listed here - -- with the same list-time badge filter this file already uses - -- for FLY/FLASH. The facing-tile/activation check happens on - -- selection (useCutFieldMove/useSurfFieldMove). + elseif mv.id == "CUT" then table.insert(items, { label = Strings("CUT"), action = "cut" }) - elseif mv.id == "SURF" and self.game.save.inventory.SOULBADGE then + elseif mv.id == "SURF" then table.insert(items, { label = Strings("SURF"), action = "surf" }) - elseif mv.id == "STRENGTH" and self.game.save.inventory.RAINBOWBADGE then + elseif mv.id == "STRENGTH" then table.insert(items, { label = Strings("STRENGTH"), action = "strength" }) elseif mv.id == "SOFTBOILED" then table.insert(items, { label = Strings("SOFTBOILED"), action = "softboiled" }) - elseif mv.id == "TELEPORT" and outside then - -- TELEPORT works only OUTDOORS (start_sub_menus.asm - -- .teleport -> CheckIfInOutsideMap); dark maps don't - -- block it - table.insert(items, { label = Strings("TELEPORT"), action = "escape" }) - elseif mv.id == "DIG" and DIG_TILESETS[ow.map.def.tileset] - and ow.map.id ~= "AGATHAS_ROOM" then - -- DIG runs ItemUseEscapeRope (.dig sets wCurItem = - -- ESCAPE_ROPE): usable in the dungeon tilesets of - -- escape_rope_tilesets.asm minus Agatha's room, even in - -- the dark (Rock Tunnel) - table.insert(items, { label = Strings("DIG"), action = "escape" }) + elseif mv.id == "TELEPORT" then + table.insert(items, { label = Strings("TELEPORT"), + action = "escape", move = "TELEPORT" }) + elseif mv.id == "DIG" then + table.insert(items, { label = Strings("DIG"), + action = "escape", move = "DIG" }) end end end diff --git a/src/ui/kit/Kit.lua b/src/ui/kit/Kit.lua index 8c423e2e..0a8162b0 100644 --- a/src/ui/kit/Kit.lua +++ b/src/ui/kit/Kit.lua @@ -368,6 +368,7 @@ Kit._navPrevN = 0 Kit._navSeen = {} Kit._navQueue = nil Kit._activateId = nil +Kit._ringShown = false -- Register a focusable. Returns true when it currently holds the ring. -- Shielded widgets do not register: while a modal owns the frame the ring @@ -383,11 +384,12 @@ function Kit.focusable(id, x, y, w, h) -- First focusable ever drawn adopts the ring, so keyboard users start -- somewhere rather than nowhere. if Kit.focusId == nil then Kit.focusId = id end - return Kit.focusId == id + return Kit._ringShown and Kit.focusId == id end function Kit.navigate(dir) Kit._navQueue = dir + Kit._ringShown = true end function Kit.activateFocused() @@ -396,6 +398,7 @@ end function Kit.setFocus(id) Kit.focusId = id + Kit._ringShown = id ~= nil end -- Pick the nearest focusable in `dir` from the current one. Candidates must @@ -530,13 +533,10 @@ Kit._audit = audit function Kit.tapMin() return math.floor(30 * Kit.scale) end -- ---------------------------------------------------------------- surfaces -function Kit.card(x, y, w, h, emphasis) - Theme.card(x, y, w, h, emphasis) +function Kit.card(x, y, w, h, variant) + Theme.card(x, y, w, h, variant) end --- A list row. `id` opts it into the focus ring; pass nil for decorative --- rows. Returns (clicked, inkColor) -- a selected row fills white, so the --- caller must print with the returned ink or it will draw white on white. function Kit.row(x, y, w, h, selected, id) audit("row", x, y, w, h, id or "row") local focused = id and Kit.focusable(id, x, y, w, h) or false @@ -560,7 +560,7 @@ end -- hairline says the same thing for one rect.) function Kit.emptyBox(x, y, w, h, message) if not G then return end - Theme.strokeRounded(x, y, w, h, PAL.line, 0.22, 1, Theme.radius()) + Theme.card(x, y, w, h, "empty") Kit.textCenter("button", Kit.ellipsize("button", message, w - 24 * Kit.scale), x, y + (h - Kit.textHeight("button")) / 2, w, PAL.muted) end @@ -598,61 +598,130 @@ local KINDS = { disabled = { fill = PAL.steel, ink = PAL.inverse, flat = true }, } Kit.KINDS = KINDS +local NO_OPTS = {} --- opts: { kind, font, enabled, align, id, glow, fill, ink } --- id -- opts into the focus ring (give every real control one) --- glow -- a pulsing outline for "something is waiting for you" (the --- update button). No blend-mode change: the alpha of the --- existing outline is animated instead. --- fill/ink -- override the kind's colours. The ONE caller is the --- launcher's Play button, which wears its cartridge colour --- (red/blue/gold) rather than a semantic one: on that screen --- "which game am I launching" outranks "what kind of verb is --- this", and the colour is already the tab's identity. --- Returns true when activated, by click OR by the focus ring's Enter/A. function Kit.button(x, y, w, h, label, opts) - opts = opts or {} + opts = opts or NO_OPTS local enabled = opts.enabled ~= false - -- Disabled buttons audit too: they stay visible, so they still must not - -- paint over a neighbour. audit("control", x, y, w, h, label) local focused = enabled and opts.id and Kit.focusable(opts.id, x, y, w, h) or false - local kind = KINDS[enabled and (opts.kind or "ghost") or "disabled"] - if enabled and opts.fill then - kind = { fill = opts.fill, ink = opts.ink or PAL.inverse } - end local hot = enabled and Kit.hover(x, y, w, h) - - if G then - -- The fill IS the control: a rounded, embossed, colour-coded key. A - -- disabled button keeps its shape in a dead grey rather than - -- disappearing, so a layout never reflows on state. - Theme.fillRounded(x, y, w, h, kind.fill, enabled and 1 or 0.45) - Theme.emboss(x, y, w, h, enabled and (hot and 1.3 or 1) or 0.4) - if hot or focused then - -- White ring outside the fill: legible on green, blue, yellow, red and - -- white alike, which one darker/lighter shade per colour would not be. - Theme.strokeRounded(x - 2, y - 2, w + 4, h + 4, PAL.lineStrong, - Theme.A.focus, 2, Theme.radius() + 2) - elseif opts.glow and enabled then - -- "Something is waiting for you" (the update button): a pulsing ring. - -- Pure alpha on one existing stroke -- no extra draw calls, no blend - -- mode change. - local a = 0.25 + 0.75 * (0.5 + 0.5 * math.sin(Kit.time * 3)) - Theme.strokeRounded(x - 2, y - 2, w + 4, h + 4, PAL.lineStrong, a, 2, - Theme.radius() + 2) + local face = opts.face or "fill" + local B = Theme.BUTTON + local radius = opts.radius or B.radius + local active = opts.active or opts.on + local invert = false + local fill, ink, stroke, strokeA, doEmboss, doRing, glowA + if face == "invert" then + invert = hot + fill = invert and (opts.hotFill or PAL.ink) or (opts.fill or PAL.surface) + ink = invert and (opts.hotInk or PAL.inverse) or (opts.ink or PAL.heading) + stroke = opts.stroke or PAL.line + strokeA = invert and Theme.A.focus or Theme.A.hairline + doRing = focused and not hot + elseif face == "tab" then + invert = active or focused or hot + local tint = opts.color or opts.fill or PAL.ink + fill = invert and tint or PAL.surface + ink = invert and PAL.inverse or (opts.color or PAL.text) + if not invert then + stroke = tint + strokeA = opts.color and Theme.A.hover or Theme.A.hairline end - local fname = opts.font or "button" - local ink = enabled and kind.ink or PAL.inverse - local ty = y + (h - Kit.textHeight(fname)) / 2 - local shown = Kit.ellipsize(fname, label, w - 16 * Kit.scale) - -- Button labels are bold: they are the shortest, most-scanned text on - -- screen and sit on a saturated fill. - if opts.align == "left" then - Kit.textBold(fname, shown, x + 10 * Kit.scale, ty, ink) + elseif face == "chip" then + local c = opts.color or PAL.line + invert = active and true or false + if active then + fill = c + ink = PAL.inverse + doEmboss = true else - Kit.textCenterBold(fname, shown, x, ty, w, ink) + fill = PAL.bg + ink = c + stroke = c + strokeA = (focused or hot) and Theme.A.focus or Theme.A.hover + end + doRing = focused or hot + else + local kind = KINDS[enabled and (opts.kind or "ghost") or "disabled"] + fill = (enabled and opts.fill) or kind.fill + ink = (enabled and opts.ink) or kind.ink + doEmboss = true + doRing = hot or focused + if opts.glow and enabled and not doRing then + glowA = B.glowBase + B.glowAmp * (0.5 + 0.5 * math.sin(Kit.time * B.glowHz)) + end + end + if opts.emboss ~= nil then doEmboss = opts.emboss end + if opts.ring ~= nil then doRing = opts.ring end + if G then + Theme.fillRounded(x, y, w, h, fill, enabled and 1 or B.disabledA, radius) + if doEmboss then + local es = enabled and ((hot or focused) and B.embossHot or B.embossRest) + or B.embossDisabled + Theme.emboss(x, y, w, h, es) + end + if strokeA then + Theme.strokeRounded(x, y, w, h, stroke, strokeA, 1, radius) + end + if doRing then + Theme.strokeRounded(x - B.ringPad, y - B.ringPad, + w + 2 * B.ringPad, h + 2 * B.ringPad, PAL.lineStrong, + Theme.A.focus, B.ringWidth, radius + B.ringPad) + elseif glowA then + Theme.strokeRounded(x - B.ringPad, y - B.ringPad, + w + 2 * B.ringPad, h + 2 * B.ringPad, PAL.lineStrong, + glowA, B.ringWidth, radius + B.ringPad) + end + local fname = opts.font or ((face == "chip") and "micro" or "button") + local ty = y + (h - Kit.textHeight(fname)) / 2 + local image = opts.image + local drawFn = opts.drawFn + local letter = opts.letter + local hasLabel = label and label ~= "" + local bold = opts.bold + if bold == nil then bold = face ~= "tab" end + if image then + local box = h + local boxX, boxY = x, y + if not hasLabel then + box = math.min(w, h) + boxX = x + (w - box) / 2 + boxY = y + (h - box) / 2 + end + local iw, ih = image:getDimensions() + local pad = math.floor(box * (opts.iconPad or B.iconPad)) + local s = math.min((box - 2 * pad) / iw, (box - 2 * pad) / ih) + if invert then Theme.col(PAL.inverse, 1) + else Theme.col(PAL.ink, B.iconRestA) end + G.draw(image, Theme.snap(boxX + (box - iw * s) / 2), + Theme.snap(boxY + (box - ih * s) / 2), 0, s, s) + if hasLabel then + local lx = x + h + B.letterGap * Kit.scale + if bold then Kit.textBold(fname, label, lx, ty, ink) + else Kit.text(fname, label, lx, ty, ink) end + end + elseif drawFn then + drawFn(x, y, w, h, invert or hot or focused) + elseif letter then + Kit.textCenter(fname, letter, x, ty, h, ink) + if hasLabel then + local lx = x + h + B.letterGap * Kit.scale + if bold then Kit.textBold(fname, label, lx, ty, ink) + else Kit.text(fname, label, lx, ty, ink) end + end + elseif hasLabel then + local shown = Kit.ellipsize(fname, label, w - B.labelInset * Kit.scale) + if opts.align == "left" then + local lx = x + B.labelPad * Kit.scale + if bold then Kit.textBold(fname, shown, lx, ty, ink) + else Kit.text(fname, shown, lx, ty, ink) end + elseif bold then + Kit.textCenterBold(fname, shown, x, ty, w, ink) + else + Kit.textCenter(fname, shown, x, ty, w, ink) + end end end if not enabled then return false end @@ -661,7 +730,6 @@ function Kit.button(x, y, w, h, label, opts) and Kit._activateId == opts.id) end --- A small square control: +/- steppers, arrow cyclers, the row X. function Kit.stepper(x, y, w, h, glyph, opts) opts = opts or {} opts.kind = opts.kind or "ghost" @@ -669,31 +737,13 @@ function Kit.stepper(x, y, w, h, glyph, opts) return Kit.button(x, y, w, h, glyph, opts) end --- A pill toggle (badges, dex SEEN/OWN, sub-tabs). `on` inverts it. +local CHIP_OPTS = { face = "chip", font = "micro" } + function Kit.chip(x, y, w, h, label, on, color, id) - audit("control", x, y, w, h, label) - local focused = id and Kit.focusable(id, x, y, w, h) or false - local c = color or PAL.line - if G then - local hot = focused or Kit.hover(x, y, w, h) - if on then - Theme.fillRounded(x, y, w, h, c, 1) - Theme.emboss(x, y, w, h, 1) - Kit.textCenterBold("micro", label, x, - y + (h - Kit.textHeight("micro")) / 2, w, PAL.inverse) - else - Theme.fillRounded(x, y, w, h, PAL.bg, 1) - Theme.strokeRounded(x, y, w, h, c, - hot and Theme.A.focus or Theme.A.hover, 1) - Kit.textCenterBold("micro", label, x, - y + (h - Kit.textHeight("micro")) / 2, w, c) - end - if hot then - Theme.strokeRounded(x - 2, y - 2, w + 4, h + 4, PAL.lineStrong, - Theme.A.focus, 2, Theme.radius() + 2) - end - end - return Kit.press(x, y, w, h) or (id ~= nil and Kit._activateId == id) + CHIP_OPTS.active = on + CHIP_OPTS.color = color + CHIP_OPTS.id = id + return Kit.button(x, y, w, h, label, CHIP_OPTS) end -- A status label with no interaction: outlined text, the "INSTALLED"/"UPDATE" diff --git a/src/ui/kit/Theme.lua b/src/ui/kit/Theme.lua index 185c6aef..6773e3e0 100644 --- a/src/ui/kit/Theme.lua +++ b/src/ui/kit/Theme.lua @@ -35,8 +35,8 @@ local PAL = { -- it in the same hue, so a card reads as a raised object rather than as an -- outline drawn on the page. These are still flat fills -- the depth comes -- from the value step plus Theme.shadow, not from a gradient. - field = { 16, 8, 10 }, -- the page BEHIND the cards - bg = { 0, 0, 0 }, -- true black: button rests, field interiors + field = { 20, 20, 20 }, -- the page BEHIND the cards + bg = { 20, 20, 20 }, -- light black surface = { 28, 21, 24 }, -- card interiors rowBg = { 20, 14, 17 }, -- rows inside a card, one step below it raised = { 44, 34, 38 }, -- hover feedback @@ -82,6 +82,67 @@ Theme.A = { disabled = 0.30, } +Theme.BUTTON = { + radius = 8, + ringPad = 2, + ringWidth = 2, + labelInset = 16, + labelPad = 10, + iconPad = 0.24, + letterGap = 4, + disabledA = 0.45, + embossHot = 1.3, + embossRest = 1, + embossDisabled = 0.4, + glowHz = 3, + glowBase = 0.25, + glowAmp = 0.75, + iconRestA = 0.85, +} + +Theme.CARD = { + radius = 14, + shadow = true, + fill = PAL.surface, + fillA = 1, + stroke = PAL.line, + strokeA = Theme.A.hairline, +} + +Theme.CARD_VARIANT = { + emphasis = { strokeA = Theme.A.focus }, + muted = { + fill = PAL.bg, fillA = 0.8, stroke = PAL.muted, strokeA = 0.25, shadow = false, + }, + mutedHot = { + fill = PAL.bg, fillA = 0.8, stroke = PAL.muted, strokeA = Theme.A.hover, shadow = false, + }, + empty = { fillA = 0, strokeA = 0.22, shadow = false, radius = "ctl" }, + warn = { + fill = PAL.rowBg, stroke = PAL.yellow, strokeA = Theme.A.hover, + shadow = false, radius = "ctl", + }, + row = { + fill = PAL.rowBg, stroke = PAL.line, strokeA = Theme.A.hairline, + shadow = false, radius = "ctl", + }, + rowHover = { + fill = PAL.raised, stroke = PAL.line, strokeA = Theme.A.hover, + shadow = false, radius = "ctl", + }, + rowSelected = { + fill = PAL.ink, strokeA = 0, shadow = false, radius = "ctl", + }, + hairline = { + fillA = 0, stroke = PAL.line, strokeA = Theme.A.hairline, + shadow = false, radius = "ctl", + }, + badge = { + fill = PAL.bg, stroke = PAL.yellow, strokeA = Theme.A.hover, + shadow = false, radius = 0, + }, +} + local G = love and love.graphics or nil local has = {} @@ -123,11 +184,11 @@ end -- Controls get the smaller one, containers the larger, so a button never -- looks like a card and a card never looks like a button. function Theme.radius() - return 8 + return Theme.BUTTON.radius end function Theme.cardRadius() - return 14 + return Theme.CARD.radius end -- DROP SHADOW. Three stacked rounded rects at low alpha, each one step wider @@ -206,28 +267,42 @@ end -- The design's only container: a rounded surface a few values above the -- field, its own drop shadow, and a white hairline. `emphasis` raises the -- outline to full white (used for the focused/active card). -function Theme.card(x, y, w, h, emphasis) - local r = Theme.cardRadius() - Theme.shadow(x, y, w, h, r) - Theme.fillRounded(x, y, w, h, PAL.surface, 1, r) - Theme.strokeRounded(x, y, w, h, PAL.line, - emphasis and Theme.A.focus or Theme.A.hairline, 1, r) +function Theme.card(x, y, w, h, variant) + local spec = Theme.CARD + local v + if variant == true then + v = Theme.CARD_VARIANT.emphasis + elseif type(variant) == "string" then + v = Theme.CARD_VARIANT[variant] + elseif type(variant) == "table" then + v = variant + end + local radius = (v and v.radius) or spec.radius + if radius == "ctl" then radius = Theme.BUTTON.radius end + local fill = (v and v.fill) or spec.fill + local fillA = spec.fillA + if v and v.fillA ~= nil then fillA = v.fillA end + local stroke = (v and v.stroke) or spec.stroke + local strokeA = spec.strokeA + if v and v.strokeA ~= nil then strokeA = v.strokeA end + local shadow = spec.shadow + if v and v.shadow ~= nil then shadow = v.shadow end + local strokeW = (v and v.strokeW) or 1 + if fillA > 0 and fill then + if shadow then Theme.shadow(x, y, w, h, radius) end + Theme.fillRounded(x, y, w, h, fill, fillA, radius) + end + if strokeA > 0 and stroke then + Theme.strokeRounded(x, y, w, h, stroke, strokeA, strokeW, radius) + end end --- A list row. Three states, each one rect plus one outline: --- normal one value below the card it sits in, hairline --- hover lifted fill, brighter hairline --- selected WHITE fill (callers print ink = PAL.inverse over it) function Theme.row(x, y, w, h, state) - local r = Theme.radius() if state == "selected" then - Theme.fillRounded(x, y, w, h, PAL.ink, 1, r) + Theme.card(x, y, w, h, "rowSelected") return PAL.inverse end - Theme.fillRounded(x, y, w, h, - state == "hover" and PAL.raised or PAL.rowBg, 1, r) - Theme.strokeRounded(x, y, w, h, PAL.line, - state == "hover" and Theme.A.hover or Theme.A.hairline, 1, r) + Theme.card(x, y, w, h, state == "hover" and "rowHover" or "row") return PAL.text end diff --git a/src/world/NPC.lua b/src/world/NPC.lua index 06d96da6..14b48378 100644 --- a/src/world/NPC.lua +++ b/src/world/NPC.lua @@ -8,7 +8,7 @@ local SpriteRenderer = require("src.render.SpriteRenderer") local NPC = {} NPC.__index = NPC -local STEP_FRAMES = 16 +local STEP_FRAMES = 32 local FACING_FROM_RANGE = { DOWN = "down", UP = "up", LEFT = "left", RIGHT = "right", @@ -52,7 +52,16 @@ function NPC:facePlayer(player) end function NPC:update(map, entities) - -- self.stepFrames overrides the shared 16-frame walk for an object whose + -- An NPC tile is 32 frames, half the player's rate: TryWalking loads + -- WALKANIMATIONCOUNTER with $10 and UpdateSpriteInWalkingAnimation adds + -- the 1px step vector once per call (engine/overworld/movement.asm), but + -- UpdateSprites runs once per OverworldLoop pass and every pass opens + -- with two DelayFrame calls (home/overworld.asm) -- so those 16 ticks + -- cost 32 frames for one 16px cell, against AdvancePlayerSprite's 8 + -- ticks of 2px. That halving is why pokeyellow's NormalPikachuFollow + -- needs TryDoubleAddPikachuStepVectorToScreenPixelCoords to keep up. + -- + -- self.stepFrames overrides the shared walk for an object whose -- step has to stay in phase with something else: Yellow's follower -- Pikachu takes the player's own step length, halved while it is more -- than a cell behind (FastPikachuFollow, engine/pikachu/ @@ -78,7 +87,7 @@ function NPC:update(map, entities) return end local d = Collision.DELTA[self.facing] - -- 1px per frame at the default length; a shortened step scales instead, + -- 1px per 2 frames at the default length; a shortened step scales instead, -- so the cell still lands on a 16px boundary (Player:update does the -- same for the bicycle) local moved = math.floor(self.progress * 16 * span / stepLen) @@ -113,8 +122,9 @@ end function NPC:walkPhase() if not self.moving then return 0 end - local p = self.progress % 16 - return (p >= 4 and p < 12) and 1 or 0 + local stepLen = self.stepFrames or STEP_FRAMES + local p = self.progress % stepLen + return (p >= stepLen / 4 and p < stepLen * 3 / 4) and 1 or 0 end -- Same contract as Player:pose -- the sheet, position, facing and step diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index bfd61826..325991d0 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -384,12 +384,21 @@ function OverworldState:setMap(mapId, x, y, facing, opts) -- NPC instances persist across connection crossings in self.npcPool -- (keyed by NPC.id): a neighbor map's wandering ghosts ARE the -- objects that become the real NPCs when the player crosses the - -- seam, so nothing snaps back to its spawn point in view of the - -- survey zoom. Warps rebuild from scratch, like the original's - -- per-entry sprite init (home/overworld.asm LoadMapHeader - -- .loadSpriteData). + -- seam, so a map the player is not entering keeps its ghosts alive + -- in view of the survey zoom. The map being entered does not -- + -- crossing a seam runs .loadNewMap -> LoadMapHeader, whose + -- .loadSpriteData zeroes the sprite state data and re-seeds every + -- SPRITESTATEDATA2_MAPY/MAPX from the map header's object data + -- (home/overworld.asm), so an NPC who walked up to the player stands + -- on her spawn cell again the next time that map loads (#1028). Only + -- the save-side spawn flags survive, and those live in Game.save, not + -- here. Warps rebuild the whole pool from scratch. if not (opts and opts.seamless and self.npcPool) then self.npcPool = {} + elseif fromMapId ~= mapId then + for _, obj in ipairs(self.map.def.objects or {}) do + self.npcPool[mapId .. "_obj_" .. obj.index] = nil + end end self.npcs = {} for _, obj in ipairs(self.map.def.objects or {}) do @@ -1986,10 +1995,12 @@ function OverworldState:tryHiddenObject(fx, fy) end save.hiddenTaken[key] = true local name = Game.data.items[h.item] and Game.data.items[h.item].name or h.item - -- hidden items always play SFX_GET_ITEM_2 (hidden_items.asm) - require("src.core.Sound").play(Game.data, "Get_Item2") + -- hidden items always play SFX_GET_ITEM_2, and FoundHiddenItemText's + -- text_asm tail runs it as PlaySoundWaitForCurrent + + -- WaitForSoundToFinish once the box has printed (hidden_items.asm) Game.stack:push(TextBox.new(Game, - Strings("%s found\n%s!", save.player.name, name))) + Strings("%s found\n%s!", save.player.name, name), + nil, TextBox.soundOpts(Game, "Get_Item2"))) return true end end @@ -2001,9 +2012,9 @@ function OverworldState:tryHiddenObject(fx, fy) if not save.inventory.COIN_CASE then return false end save.hiddenTaken[key] = true save.coins = math.min(9999, (save.coins or 0) + h.coins) - require("src.core.Sound").play(Game.data, "Get_Item2") Game.stack:push(TextBox.new(Game, - Strings("%s found\n%d coins!", save.player.name, h.coins))) + Strings("%s found\n%d coins!", save.player.name, h.coins), + nil, TextBox.soundOpts(Game, "Get_Item2"))) return true end end @@ -2650,10 +2661,11 @@ function OverworldState:talkTo(npc) end local name = Game.data.items[d.item] and Game.data.items[d.item].name or d.item local ddef = Game.data.items[d.item] - require("src.core.Sound").play(Game.data, - (ddef and ddef.keyItem) and "Get_Key_Item" or "Get_Item1") + -- FoundItemText: text_far, sound_get_item_1, text_end (pick_up_item.asm) Game.stack:push(TextBox.new(Game, - Strings("%s found\n%s!", Game.save.player.name, name))) + Strings("%s found\n%s!", Game.save.player.name, name), nil, + TextBox.soundOpts(Game, + (ddef and ddef.keyItem) and "Get_Key_Item" or "Get_Item1"))) return end @@ -3182,6 +3194,58 @@ local function giveVictoryItem(reward) return true end +-- A gym's reward text is not one box. Every gym script carries a sound +-- command right after the FIRST label of each reward group +-- (scripts/PewterGym.asm PewterGymBrockReceivedBoulderBadgeText's +-- sound_level_up, PewterGymReceivedTM34Text's sound_get_item_1, and the +-- equivalents in the other seven), and home/text.asm TextCommand_SOUND +-- plays it only once that page has typed out, then blocks on +-- WaitForSoundToFinish before the next page prints. So the pages +-- accumulate and split into separate boxes at the sound points, each box +-- chained off the previous one's button press. +local function rewardChain() + local chain = { boxes = {}, pending = {} } + function chain.flush(sound) + if #chain.pending > 0 then + table.insert(chain.boxes, + { text = table.concat(chain.pending, "\f"), sound = sound }) + chain.pending = {} + end + end + -- one reward group; `sound` rides its first page, where the scripts put it + function chain.add(labels, sound) + local text = Game.data.text or {} + local n = 0 + for _, label in ipairs(labels or {}) do + if text[label] and text[label] ~= "" then + table.insert(chain.pending, text[label]) + n = n + 1 + if n == 1 and sound then chain.flush(sound) end + end + end + end + -- the synthetic stand-in line a reward with no `dialogue` shows + function chain.line(str, sound) + table.insert(chain.pending, str) + if sound then chain.flush(sound) end + end + function chain.push(done) + chain.flush() + local function step(i) + local box = chain.boxes[i] + if not box then + if done then done() end + return + end + local opts = box.sound and TextBox.soundOpts(Game, box.sound) or nil + Game.stack:push(TextBox.new(Game, box.text, + function() step(i + 1) end, opts)) + end + step(1) + end + return chain +end + -- Badges/items awarded after specific battles (data/scripts/victories.lua). -- `deactivate` retires unfought gym/dojo trainers the way the originals' -- SetEvent / SetEventRange do after the leader victory. @@ -3218,44 +3282,31 @@ function OverworldState:checkVictoryRewards(trainerClass, partyIndex) -- retries the hand-over later (offerGymTm via gyms.lua) tmGiven = giveVictoryItem(reward) end - local lines = {} + local chain = rewardChain() if reward.dialogue then - local text = Game.data.text or {} - for _, label in ipairs(reward.dialogue) do - if text[label] and text[label] ~= "" then - table.insert(lines, text[label]) - end - end + chain.add(reward.dialogue, reward.badgeSound) if reward.item then - for _, label in ipairs(reward.tmPre or {}) do - if text[label] and text[label] ~= "" then - table.insert(lines, text[label]) - end - end + chain.add(reward.tmPre) if tmGiven then - for _, label in ipairs(reward.tmDialogue or {}) do - if text[label] and text[label] ~= "" then - table.insert(lines, text[label]) - end - end - elseif reward.noRoom and text[reward.noRoom] and text[reward.noRoom] ~= "" then - table.insert(lines, text[reward.noRoom]) + chain.add(reward.tmDialogue, reward.tmSound) + else + chain.add({ reward.noRoom }) end end elseif reward.badge or reward.item then if reward.badge then local name = Game.data.items[reward.badge] and Game.data.items[reward.badge].name or reward.badge - table.insert(lines, Strings("%s received\nthe %s!", Game.save.player.name, name)) + chain.line(Strings("%s received\nthe %s!", Game.save.player.name, name), + reward.badgeSound) end if tmGiven then local name = Game.stringBuffer or reward.item - table.insert(lines, Strings("%s received\n%s!", Game.save.player.name, name)) + chain.line(Strings("%s received\n%s!", Game.save.player.name, name), + reward.tmSound) end end - if #lines > 0 then - Game.stack:push(TextBox.new(Game, table.concat(lines, "\f"))) - end + chain.push() self:runVictoryHook() end @@ -3266,24 +3317,14 @@ end -- show again, then the same GiveItem check decides between the received -- lines and the "make room" text. function OverworldState:offerGymTm(reward, done) - local text = Game.data.text or {} - local lines = {} - local function addLine(label) - if label and text[label] and text[label] ~= "" then - table.insert(lines, text[label]) - end - end - for _, label in ipairs(reward.tmPre or {}) do addLine(label) end + local chain = rewardChain() + chain.add(reward.tmPre) if giveVictoryItem(reward) then - for _, label in ipairs(reward.tmDialogue or {}) do addLine(label) end + chain.add(reward.tmDialogue, reward.tmSound) else - addLine(reward.noRoom) - end - if #lines > 0 then - Game.stack:push(TextBox.new(Game, table.concat(lines, "\f"), done)) - elseif done then - done() + chain.add({ reward.noRoom }) end + chain.push(done) end -- pokered reloads the map after every battle, re-running the map @@ -3661,8 +3702,9 @@ function OverworldState:onStepComplete() end end - -- wild encounters in grass, on water while surfing, or -- on indoor - -- maps whose tileset is not FOREST -- on EVERY tile + -- wild encounters in grass, on water while surfing (at the map's water + -- rate, which is 0 on every indoor map), or -- on indoor maps whose + -- tileset is not FOREST -- on every other tile -- (wild_encounters.asm: caves, towers, the Mansion, Power Plant) -- The cooldown is checked after all other step processing so repel and -- movement systems continue to advance during the protected steps. @@ -3670,10 +3712,10 @@ function OverworldState:onStepComplete() local encDef = Game.data.encounters[self.map.id] local enc local indoor = Game.data.field.indoorEncounters - if p.surfing and encDef and encDef.water and self.map:isWaterCell(p.cellX, p.cellY) then - enc = self:rollEncounter({ grass = encDef.water }, "water") - elseif self.map:isGrassCell(p.cellX, p.cellY) then + if self.map:isGrassCell(p.cellX, p.cellY) then enc = self:rollEncounter(encDef, "grass") + elseif p.surfing and self.map:isWaterCell(p.cellX, p.cellY) then + enc = self:rollEncounter({ grass = encDef and encDef.water }, "water") elseif indoor and self.map.def.index >= indoor.firstIndoorMap and self.map.def.tileset ~= indoor.excludedTileset then enc = self:rollEncounter(encDef, "indoor") @@ -3794,10 +3836,10 @@ function OverworldState:checkBadgeGate() if Game.save.inventory[g.badge] then if not Game.save.flags[passedFlag] then Game.save.flags[passedFlag] = true - -- Route22GateGuardGoRightAheadText plays sound_get_item_1 - require("src.core.Sound").play(Game.data, "Get_Item1") + -- Route22GateGuardGoRightAheadText carries sound_get_item_1 Game.stack:push(TextBox.new(Game, - t["_" .. g.passText] or Strings("Go right ahead!"))) + t["_" .. g.passText] or Strings("Go right ahead!"), + nil, TextBox.soundOpts(Game, "Get_Item1"))) end return false end @@ -3822,11 +3864,11 @@ function OverworldState:checkBadgeGate() and Game.data.items[guard.badge].name or guard.badge if Game.save.inventory[guard.badge] then Game.save.flags[guard.event] = true - -- Route23OhThatIsTheBadgeText plays sound_get_item_1 - require("src.core.Sound").play(Game.data, "Get_Item1") + -- Route23OhThatIsTheBadgeText carries sound_get_item_1 local text = (t["_" .. g.passText] or Strings("Oh! That is the\n{RAM}!")):gsub("{RAM:wNameBuffer}", badgeName) - Game.stack:push(TextBox.new(Game, text)) + Game.stack:push(TextBox.new(Game, text, + nil, TextBox.soundOpts(Game, "Get_Item1"))) return false end -- Route23YouDontHaveTheBadgeYetText plays SFX_DENIED @@ -4470,7 +4512,7 @@ function OverworldState:scriptMove(entity, dir, tiles, onDone) }) end --- A step-in-place beat: the entity plays one walk-cycle animation (16 +-- A step-in-place beat: the entity plays one walk-cycle animation (32 -- frames) without translating, keeping its current facing. Ports the -- NPC_CHANGE_FACING movement byte (engine/overworld/movement.asm -- ChangeFacingDirection -> zero-delta TryWalking), used for Oak marching @@ -4483,7 +4525,7 @@ end -- Advance scripted moves in two phases so a chained step (a new move -- queued by a completing move's onDone) begins the SAME frame the --- previous one ends -- back-to-back 16-frame tiles like the GB's +-- previous one ends -- back-to-back 32-frame tiles like the GB's -- simulated-joypad / NPC scripted movement, with no idle frame between -- tiles. Phase 1 retires finished moves (which may chain new ones); -- phase 2 then starts every not-yet-moving move. diff --git a/src/world/PikachuFollower.lua b/src/world/PikachuFollower.lua index 688c62f6..cd05496b 100644 --- a/src/world/PikachuFollower.lua +++ b/src/world/PikachuFollower.lua @@ -821,11 +821,13 @@ end function PikachuFollower.onFanClubEntered(game, ow) if not (GameVersion.isYellow() and ow.map and ow.map.id == "POKEMON_FAN_CLUB") then return end + local active = game.save.pikachuMapScriptActive + game.save.pikachuMapScriptActive = true + if active then return end local starter = PikachuFollower.starterInParty(game.save) local npc = findFollower(ow) if not npc or (starter and starter.status) then return end ow.pikachuFanClubScene = true - ow.pikachuMapScriptActive = true ow.player.facing = "down" for _, other in ipairs(ow.npcs or {}) do if other.def and other.def.name == "POKEMONFANCLUB_SEEL" then @@ -844,6 +846,13 @@ function PikachuFollower.onBillsHouseEnter(game, ow) if not (GameVersion.isYellow() and ow.map and ow.map.id == "BILLS_HOUSE") then return end + -- BillsHouse_CheckMetBill (scripts/BillsHouse.asm:22-40) sets + -- BIT_PIKACHU_MAP_SCRIPT_ACTIVE and rets nz before it looks at + -- EVENT_MET_BILL_2; the bit rides sMainData, so a reload inside the house + -- resumes at BillsHouseScript1's bare ret (#919). + local active = game.save.pikachuMapScriptActive + game.save.pikachuMapScriptActive = true + if active then return end if game.save.flags.EVENT_MET_BILL_2 then return end -- BillsHouseScript0 (scripts/BillsHouse.asm:41-47) only runs the confused -- walk while CheckPikachuStatusCondition comes back clear diff --git a/tests/drivers/evolution_black_bug279_test.lua b/tests/drivers/evolution_black_bug279_test.lua index 8d9ca4e5..ffb7eb9c 100644 --- a/tests/drivers/evolution_black_bug279_test.lua +++ b/tests/drivers/evolution_black_bug279_test.lua @@ -191,12 +191,12 @@ return function(game) return cond() end - -- Jump the flash clock to just under FLASH_FRAMES (220) once the shot is + -- Jump the flash clock to just under FLASH_FRAMES (368) once the shot is -- taken. EvolutionState:update only compares self.t against that constant, -- so this ends the animation early without touching the palette logic. local function skipToEnd() local st = evoTop() - if st then st.t = 214 end + if st then st.t = 362 end end local function startEvo(species, into) @@ -212,7 +212,7 @@ return function(game) -- ---- case 1: the reported case, SGB, mid-flash -------------------------- local weedle = startEvo("WEEDLE", "KAKUNA") - U.wait(24) -- into the flash, far short of FLASH_FRAMES = 220 + U.wait(24) -- into the flash, far short of FLASH_FRAMES = 368 U.shot(game, DIR .. "/evo279_1_flash_sgb.png") local c1 = probe("flash/SGB", { monYellow1 = YELLOW[2], monYellow2 = YELLOW[3], diff --git a/tests/drivers/evolution_cancel_bug213_test.lua b/tests/drivers/evolution_cancel_bug213_test.lua index 46dbc006..db71c43a 100644 --- a/tests/drivers/evolution_cancel_bug213_test.lua +++ b/tests/drivers/evolution_cancel_bug213_test.lua @@ -6,7 +6,7 @@ -- LINK_STATE_TRADING) skip that poll and cannot be cancelled. -- -- Case 1 (level path, cancelable): open EvolutionState directly, wait past --- the 80-frame pre-animLoop delay (still well under FLASH_FRAMES=220), +-- the 80-frame pre-animLoop delay (still well under FLASH_FRAMES=368), -- press B, and assert the mon stays CATERPIE with the "stopped evolving" -- text on screen. -- Case 1b: after cancel, checkParty with no level-ups must not re-offer; @@ -81,7 +81,7 @@ return function(game) Evolution.evolve(game, mon, "METAPOD", function() done1 = true end) if not waitFor(evoTop, 300) then error("EvolutionState never opened (case1)") end - U.wait(100) -- past the 80-frame pre-animLoop delay, still under 220 + U.wait(100) -- past the 80-frame pre-animLoop delay, still under 368 U.log("case1 flash", "t=", top().t, "species=", mon.species) U.shot(game, DIR .. "/evo213_1_evolving.png") @@ -133,8 +133,8 @@ return function(game) local done2 = false Evolution.evolve(game, mon2, "METAPOD", function() done2 = true end) if not waitFor(evoTop, 300) then error("EvolutionState never opened (case2)") end - -- let the full flash run (FLASH_FRAMES=220) without pressing B - waitFor(function() return not evoTop() end, 400) + -- let the full flash run (FLASH_FRAMES=368) without pressing B + waitFor(function() return not evoTop() end, 500) if not waitFor(function() return findText("evolved into") ~= nil end, 120) then error("Congratulations text not shown (case2)") end diff --git a/tests/drivers/evolution_move_bug12_test.lua b/tests/drivers/evolution_move_bug12_test.lua index d390389c..731690a1 100644 --- a/tests/drivers/evolution_move_bug12_test.lua +++ b/tests/drivers/evolution_move_bug12_test.lua @@ -89,8 +89,8 @@ return function(game) Evolution.evolve(game, mon, "GYARADOS", function() done1 = true end) if not waitFor(evoTop, 300) then error("EvolutionState never opened (case1)") end - -- let the full flash run (FLASH_FRAMES=220) with no input, then apply - waitFor(function() return not evoTop() end, 400) + -- let the full flash run (FLASH_FRAMES=368) with no input, then apply + waitFor(function() return not evoTop() end, 500) if not waitFor(function() return findText("evolved into") ~= nil end, 120) then error("Congratulations text not shown (case1)") end @@ -124,7 +124,7 @@ return function(game) local done2 = false Evolution.evolve(game, mon2, "GYARADOS", function() done2 = true end) if not waitFor(evoTop, 300) then error("EvolutionState never opened (case2)") end - waitFor(function() return not evoTop() end, 400) + waitFor(function() return not evoTop() end, 500) if not waitFor(function() return findText("evolved into") ~= nil end, 120) then error("Congratulations text not shown (case2)") end diff --git a/tests/drivers/trainer_reset_bug1028_test.lua b/tests/drivers/trainer_reset_bug1028_test.lua new file mode 100644 index 00000000..f1608f22 --- /dev/null +++ b/tests/drivers/trainer_reset_bug1028_test.lua @@ -0,0 +1,188 @@ +-- Driver: a trainer who walked up to the player must be back on her spawn +-- cell the next time her map loads, connection crossings included (#1028). +-- .loadNewMap calls LoadMapHeader for a seam crossing exactly as a warp does, +-- and .loadSpriteData zeroes the sprite state data and re-seeds every +-- SPRITESTATEDATA2_MAPY/MAPX from the map header's object data +-- (pokered home/overworld.asm), so only the save-side defeat flag survives. +-- +-- Route 3's Lass (data/maps/objects/Route3.asm object_event 23, 4, +-- SPRITE_COOLTRAINER_F, STAY, LEFT, ..., OPP_LASS) has sight range 4, so +-- standing above the third Bug Catcher at (19,4) is inside her line and she +-- walks dist-1 = 3 cells west to (20,4) -- the reporter's exact setup. +-- +-- POKEPORT_DRIVER=tests/drivers/trainer_reset_bug1028_test.lua \ +-- POKEPORT_IDENTITY=bug1028 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pokemon = require("src.pokemon.Pokemon") + local Zoom = require("src.render.Zoom") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + local MAP = "ROUTE_3" + local TARGET = 6 + local SPAWN = { x = 23, y = 4 } + local STAND = { x = 19, y = 4 } + + local pass = true + local function check(label, ok) + if not ok then pass = false end + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + local function lass() + local ow = game.overworld + for _, n in ipairs(ow and ow.npcs or {}) do + if n.def and n.def.index == TARGET then return n end + end + return nil + end + + local function at(n) return n and (n.cellX .. "," .. n.cellY) or "absent" end + + -- hold a direction until cond() or the budget runs out, then let the + -- half-finished step land + local function holdUntil(btn, cond, budget) + local first = true + for _ = 1, budget or 900 do + if cond() then break end + if first then table.insert(game.input.pressQueue, btn); first = false end + game.input.state[btn] = true + coroutine.yield() + end + game.input.state[btn] = false + for _ = 1, 40 do + local ow = game.overworld + if ow and not ow.player.moving then break end + coroutine.yield() + end + U.wait(4) + return cond() + end + + local function walkTo(btn, axis, want, budget) + return holdUntil(btn, function() + local p = game.overworld and game.overworld.player + return p and p[axis] == want and not p.moving + end, budget) + end + + local function onMap(id) + return game.overworld and game.overworld.map and game.overworld.map.id == id + end + + -- mash A until the battle stack unwinds back to the overworld + local function mashToOverworld(budget) + for _ = 1, budget or 4000 do + if game.stack:top() == game.overworld and not game.overworld.engaging then + return true + end + U.tap(game, "a") + U.wait(3) + end + return false + end + + -- absolute zoom: reset first so every shot frames the same amount of world + local function survey(n) + Zoom.reset() + U.wait(2) + for _ = 1, n do game:zoomStep(-1) end + U.wait(30) + end + + -- back up to the sighting cell so the before/after shots share a camera + local function returnToStand() + walkTo("right", "cellX", 11) + walkTo("up", "cellY", 4) + walkTo("right", "cellX", STAND.x) + end + + game.save.party = { + Pokemon.new(game.data, "CHARIZARD", 80), + Pokemon.new(game.data, "SNORLAX", 80), + } + game.save.player.name = "MATT" + + U.teleport(game, MAP, STAND.x, STAND.y, "right") + U.wait(20) + + -- only the target Lass may engage: the reporter has already cleared the + -- Bug Catchers and Youngsters ahead of her + local ow = game.overworld + for _, n in ipairs(ow.npcs) do + if n.def.trainerClass and n.def.index ~= TARGET then + game.save.defeatedTrainers[n.id] = true + end + end + + local l = lass() + check("Lass starts on her spawn cell " .. SPAWN.x .. "," .. SPAWN.y, + l ~= nil and l.cellX == SPAWN.x and l.cellY == SPAWN.y) + U.log("spawn:", at(l)) + + -- she sights the player and walks up, then the battle runs + for _ = 1, 600 do + if ow.engaging then break end + coroutine.yield() + end + check("the Lass sighted the player at " .. STAND.x .. "," .. STAND.y, ow.engaging == true) + check("the battle ran to completion", mashToOverworld()) + U.wait(30) + + l = lass() + local movedX, movedY = l and l.cellX, l and l.cellY + U.log("after the walk-up she stands at", at(l)) + check("she is off her spawn cell after walking up", + l ~= nil and not (movedX == SPAWN.x and movedY == SPAWN.y)) + check("she is recorded as defeated", game.save.defeatedTrainers[l.id] == true) + + survey(4) + U.shot(game, DIR .. "/bug1028_1_walked_up.png") + Zoom.reset() + U.wait(5) + + -- west along row 4, down the single gap at column 11, then west on row 9 + -- to the Pewter City seam + walkTo("left", "cellX", 11) + walkTo("down", "cellY", 9) + check("reached the descent column", game.overworld.player.cellX == 11 + and game.overworld.player.cellY == 9) + + -- crossing x=0 westward is the seam: crossConnection, not a warp + holdUntil("left", function() return onMap("PEWTER_CITY") end, 1200) + check("crossed the seam into Pewter City", onMap("PEWTER_CITY")) + + survey(12) + U.shot(game, DIR .. "/bug1028_2_pewter_survey.png") + Zoom.reset() + U.wait(5) + + -- and back east into Route 3, the map load that must re-seed her + holdUntil("right", function() return onMap(MAP) end, 1200) + check("crossed back into Route 3", onMap(MAP)) + U.wait(30) + + l = lass() + U.log("after the return crossing she stands at", at(l)) + check("she is back on her spawn cell " .. SPAWN.x .. "," .. SPAWN.y, + l ~= nil and l.cellX == SPAWN.x and l.cellY == SPAWN.y) + check("she is still recorded as defeated", + l ~= nil and game.save.defeatedTrainers[l.id] == true) + + -- same cell, same zoom as shot 1: she should now be three cells further + -- east, and being defeated she must not re-engage on the way back in + returnToStand() + check("walked back to the sighting cell", + game.overworld.player.cellX == STAND.x + and game.overworld.player.cellY == STAND.y) + check("a defeated trainer does not re-engage", game.overworld.engaging ~= true) + + survey(4) + U.shot(game, DIR .. "/bug1028_3_reset.png") + + Zoom.reset() + U.log(pass and "RESULT PASS" or "RESULT FAIL") + love.event.quit(pass and 0 or 1) + while true do coroutine.yield() end +end diff --git a/tests/engine/party_fieldmove_order_bug792.lua b/tests/engine/party_fieldmove_order_bug792.lua index 6e93110f..a134df38 100644 --- a/tests/engine/party_fieldmove_order_bug792.lua +++ b/tests/engine/party_fieldmove_order_bug792.lua @@ -20,7 +20,7 @@ local PartyMenu = require("src.ui.PartyMenu") -- minimal stack/input doubles matching the StateStack and Input surfaces, -- plus an overworld stub: PALLET_TOWN's OVERWORLD tileset passes --- CheckIfInOutsideMap, and the badges cover the list-time HM gates +-- CheckIfInOutsideMap local function newGame(moves, inventory) local game = { data = { pokemon = { LAPRAS = { name = "LAPRAS" }, @@ -87,14 +87,15 @@ openSubmenu(pm2) same(actions(pm2.subItems), { "stats", "switch" }, "no field moves: the submenu is just STATS/SWITCH") --- the badge gates still filter the list: the same Lapras without the --- badges keeps its moves but shows none of them +-- GetMonFieldMoves is badge-blind: the same Lapras without the badges +-- still lists both HMs, and .outOfBattleMovePointers refuses on +-- selection instead (#1022) local noBadges = newGame( { { id = "STRENGTH", pp = 15 }, { id = "SURF", pp = 15 } }) local pm3 = PartyMenu.new(noBadges, {}) noBadges.stack:push(pm3) openSubmenu(pm3) -same(actions(pm3.subItems), { "stats", "switch" }, - "ungated badges keep the HM moves out of the submenu") +same(actions(pm3.subItems), { "strength", "surf", "stats", "switch" }, + "the HM moves are listed without the badges (#1022)") T.finish("party_fieldmove_order_bug792") diff --git a/tests/engine/viridian_fisher_pre_bug775.lua b/tests/engine/viridian_fisher_pre_bug775.lua index 134fc120..f40af157 100644 --- a/tests/engine/viridian_fisher_pre_bug775.lua +++ b/tests/engine/viridian_fisher_pre_bug775.lua @@ -16,6 +16,7 @@ local T = require("tests.modkit") local boxes = {} package.loaded["src.render.TextBox"] = { new = function(_, s, done) return { text = s, onDone = done } end, + soundOpts = function() return {} end, } package.loaded["src.core.Sound"] = { play = function() end } package.loaded["src.inventory.Bag"] = { diff --git a/tests/link_desync_fuzz.lua b/tests/link_desync_fuzz.lua index a1bcd603..c819a24f 100644 --- a/tests/link_desync_fuzz.lua +++ b/tests/link_desync_fuzz.lua @@ -187,6 +187,13 @@ local function runOne(seed) local function drive(side) local bt = side.bt if bt.result then return end + local top = side.game.stack:top() + if top and top.forceSwitch and top.party then + for i, mon in ipairs(top.party) do + if mon.hp > 0 then top.index = i break end + end + return + end if bt.phase ~= "menu" then side.menuFrames = 0 end if bt.phase == "moveSelect" then local usable = {} diff --git a/tests/link_tournament16.lua b/tests/link_tournament16.lua index 7a893cb4..c1985434 100644 --- a/tests/link_tournament16.lua +++ b/tests/link_tournament16.lua @@ -181,6 +181,13 @@ local function playMatch(hostEntry, guestEntry, rnd, label) local function drive(side) local bt = side.bt if bt.result then return end + local top = side.game.stack:top() + if top and top.forceSwitch and top.party then + for i, mon in ipairs(top.party) do + if mon.hp > 0 then top.index = i break end + end + return + end if bt.phase ~= "menu" then side.menuFrames = 0 end if bt.phase == "moveSelect" then local usable = {} diff --git a/tests/mod_battle_tests.lua b/tests/mod_battle_tests.lua index e42df2e4..e0c74e04 100644 --- a/tests/mod_battle_tests.lua +++ b/tests/mod_battle_tests.lua @@ -604,7 +604,7 @@ do local game = uiGame({ mon }) Bag.add(game.save, "RARE_CANDY", 1) game.stack:push(BagMenu.new(game)) - for _ = 1, 600 do + for _ = 1, 800 do local top = game.stack:top() if not top then break end pressed = { a = true } diff --git a/tests/mod_ui_tests.lua b/tests/mod_ui_tests.lua index 4d9ecaf3..6ac0b329 100644 --- a/tests/mod_ui_tests.lua +++ b/tests/mod_ui_tests.lua @@ -584,7 +584,9 @@ check(not fpm.submenu and forced == fgame.save.party[1], -- ------- issues #320/#385: the STRENGTH texts print over the party menu do local owStub = { strengthActive = false, - map = { def = { tileset = "OVERWORLD" } }, dark = false } + map = { def = { tileset = "OVERWORLD" } }, dark = false, + partyKnows = function(self, id) return self.knows == id end, + knows = "STRENGTH" } local sgame = partyGame() sgame.overworld = owStub sgame.data.text = {} -- the strength texts fall back to Strings sources diff --git a/tests/modkit/link.lua b/tests/modkit/link.lua index 01c2ffc3..d11f596e 100644 --- a/tests/modkit/link.lua +++ b/tests/modkit/link.lua @@ -49,6 +49,14 @@ function Link.prepare(data) return Input end +local function steerReplacement(game) + local top = game.stack:top() + if not (top and top.forceSwitch and top.party) then return end + for i, mon in ipairs(top.party) do + if mon.hp > 0 then top.index = i return end + end +end + -- run a full lockstep battle over a loopback pair, mashing A on both -- sides, and report whether any turn's hashes disagreed function Link.lockstep(gameA, gameB, opts) @@ -80,7 +88,9 @@ function Link.lockstep(gameA, gameB, opts) while (resA == nil or resB == nil) and guard < limit do guard = guard + 1 Input.pressed = { a = true } + steerReplacement(gameA) gameA.stack:update(1 / 60) + steerReplacement(gameB) gameB.stack:update(1 / 60) end diff --git a/tests/parity_A.lua b/tests/parity_A.lua index 7c15caa0..7084a76f 100644 --- a/tests/parity_A.lua +++ b/tests/parity_A.lua @@ -315,12 +315,19 @@ do local Renderer = require("src.render.Renderer") local SaveData = require("src.core.SaveData") local OW = require("src.world.OverworldController") + -- the reward text is a CHAIN of boxes split at each gym script's sound + -- command, so walk it: read a box's pages, close it, let its onDone push + -- the next one local function stackedDialogue() - local top = Game.stack:top() - if not (top and top.pages) then return "" end local parts = {} - for _, page in ipairs(top.pages) do - parts[#parts + 1] = table.concat(page, "\n") + local top = Game.stack:top() + while top and top.pages do + for _, page in ipairs(top.pages) do + parts[#parts + 1] = table.concat(page, "\n") + end + Game.stack:pop() + if top.onDone then top.onDone() end + top = Game.stack:top() end return table.concat(parts, "\n") end diff --git a/tests/parity_I_M.lua b/tests/parity_I_M.lua index 6213e265..3d7df56d 100644 --- a/tests/parity_I_M.lua +++ b/tests/parity_I_M.lua @@ -176,7 +176,8 @@ check(not onStack(pmSurf), "party menu closes after a successful SURF") check(sawText("got on"), "_SurfingGotOnText shown on a successful SURF") -- =========================================================================== --- I: list-time badge filter, CUT/SURF/STRENGTH absent without the badge +-- I: GetMonFieldMoves is badge-blind -- CUT/SURF/STRENGTH are listed with or +-- without the badge, and .outOfBattleMovePointers refuses on selection (#1022) -- =========================================================================== Game.save.party = { mkMon("SQUIRTLE", "CUT", "SURF", "STRENGTH") } Game.save.inventory = {} @@ -185,8 +186,8 @@ local pmNoBadge = PartyMenu.new(Game) Game.stack:push(pmNoBadge) frame({ "a" }) local actsOff = submenuActions(pmNoBadge) -check(not actsOff.cut and not actsOff.surf and not actsOff.strength, - "no CUT/SURF/STRENGTH submenu entries without the required badges") +check(actsOff.cut and actsOff.surf and actsOff.strength, + "CUT/SURF/STRENGTH submenu entries listed without the badges (#1022)") popToOW() Game.save.inventory = { CASCADEBADGE = true, SOULBADGE = true, RAINBOWBADGE = true } local pmBadge = PartyMenu.new(Game) @@ -194,7 +195,7 @@ Game.stack:push(pmBadge) frame({ "a" }) local actsOn = submenuActions(pmBadge) check(actsOn.cut and actsOn.surf and actsOn.strength, - "CUT/SURF/STRENGTH submenu entries appear once the badges are held") + "CUT/SURF/STRENGTH submenu entries still there once the badges are held") -- =========================================================================== -- I: CUT from the party menu. The Cerulean tree BLOCK (50, at block 9,14) @@ -471,8 +472,9 @@ local pmLobby = PartyMenu.new(Game) Game.stack:push(pmLobby) frame({ "a" }) local actsLobby = submenuActions(pmLobby) -check(not actsLobby.fly, "FLY omitted inside Indigo Plateau lobby") -check(not actsLobby.escape, "TELEPORT omitted inside Indigo Plateau lobby") +check(actsLobby.fly, "FLY still listed inside Indigo Plateau lobby (#1022)") +check(actsLobby.escape, + "TELEPORT still listed inside Indigo Plateau lobby (#1022)") popToOW() -- restore fainted field-move mon for the STRENGTH/SURF cases below @@ -486,8 +488,8 @@ Game.save.inventory = { ow = pushOW("SEAFOAM_ISLANDS_1F", 17, 10, "right") clearCaptured() local pmFaintStr = PartyMenu.new(Game) --- Seafoam is not OVERWORLD, so FLY is omitted: CUT, STRENGTH, SURF, STATS, SWITCH -selectSubItem(pmFaintStr, 2) +-- move order on the mon: FLY, CUT, STRENGTH, SURF, then STATS, SWITCH +selectSubItem(pmFaintStr, 3) eq(Game.overworld.strengthActive, true, "fainted mon can activate STRENGTH from the party menu") check(sawText("used") and sawText("STRENGTH"), diff --git a/tests/parity_gym_tm_bag_full_bug797.lua b/tests/parity_gym_tm_bag_full_bug797.lua index 2117fe87..84906c55 100644 --- a/tests/parity_gym_tm_bag_full_bug797.lua +++ b/tests/parity_gym_tm_bag_full_bug797.lua @@ -72,13 +72,19 @@ Game.input = Input; Input:init() Game.renderer = Renderer; Renderer:init() Game.stack = StateStack; StateStack:init() --- concatenates the pages of the TextBox checkVictoryRewards pushed +-- concatenates the pages of the box CHAIN checkVictoryRewards pushed: the +-- reward text splits into a box per gym-script sound command, so close each +-- one and let its onDone push the next local function stackedDialogue() - local top = Game.stack:top() - if not (top and top.pages) then return "" end local parts = {} - for _, page in ipairs(top.pages) do - parts[#parts + 1] = table.concat(page, "\n") + local top = Game.stack:top() + while top and top.pages do + for _, page in ipairs(top.pages) do + parts[#parts + 1] = table.concat(page, "\n") + end + Game.stack:pop() + if top.onDone then top.onDone() end + top = Game.stack:top() end return table.concat(parts, "\n") end diff --git a/tests/parity_yellow_bills_pikachu.lua b/tests/parity_yellow_bills_pikachu.lua index c0437c36..20e9420d 100644 --- a/tests/parity_yellow_bills_pikachu.lua +++ b/tests/parity_yellow_bills_pikachu.lua @@ -178,6 +178,7 @@ check(#moves == 0, "the parked Pikachu of the confused beat stays put") -- BillsHouseScript0 skips the whole entry beat for a statused starter -- (CheckPikachuStatusCondition, scripts/BillsHouse.asm:45-46) ow.pikachuBillsScene = nil +yellowGame.save.pikachuMapScriptActive = nil moves = {} yellowGame.save.party = { { species = "PIKACHU", hp = 12, status = "PAR" } } PikachuFollower.onBillsHouseEnter(yellowGame, ow) diff --git a/tests/parity_yellow_disabled_pikachu.lua b/tests/parity_yellow_disabled_pikachu.lua index 86625442..ef83038e 100644 --- a/tests/parity_yellow_disabled_pikachu.lua +++ b/tests/parity_yellow_disabled_pikachu.lua @@ -42,7 +42,7 @@ local ow = { Follower.onFanClubEntered({ save = save, data = Data }, ow) check(ow.pikachuFanClubScene, "Fan Club disables normal Pikachu following") -check(ow.pikachuMapScriptActive, "Fan Club sets the map-script flag") +check(save.pikachuMapScriptActive, "Fan Club sets the map-script flag") eq(ow.player.facing, "down", "Fan Club resets the player direction") eq(moves[1] and moves[1][1], "up", "Fan Club starts with slide-up displacement") eq(moves[1] and moves[1][2], 1, "Fan Club slide-up spans one tile") diff --git a/tests/run_tests.lua b/tests/run_tests.lua index d7122562..149483aa 100644 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -2562,8 +2562,8 @@ do end eq(fanfares, 1, "one caught fanfare per capture") eq(tinks, 3, "three wobble tinks on a $43 capture") - check(fanfareAt and caughtAt and fanfareAt < caughtAt, - "Caught_Mon sounds with the caught text, not after its dismissal") + check(fanfareAt and caughtAt and caughtAt < fanfareAt, + "Caught_Mon sounds once the caught text is out, before its prompt") eq(cb4.result, "caught", "the capture resolved the battle") -- the nickname AskName that follows clears it (ClearSprites), so the -- assertion is sampled while the caught text is up