diff --git a/data/scripts/flavor/copycats_house_2f.lua b/data/scripts/flavor/copycats_house_2f.lua index ac167980..d40976e3 100644 --- a/data/scripts/flavor/copycats_house_2f.lua +++ b/data/scripts/flavor/copycats_house_2f.lua @@ -1,9 +1,8 @@ -- Flavor talk scripts for CopycatsHouse2F (registry id COPYCATS_HOUSE_2F). -- Source: pokered/scripts/CopycatsHouse2F.asm, pokered/text/CopycatsHouse2F.asm -- --- TEXT_COPYCATSHOUSE2F_COPYCAT is already ported (with the POKE DOLL / --- TM31 MIMIC trade) in data/scripts/story4.lua, so it is intentionally --- omitted here. +-- TEXT_COPYCATSHOUSE2F_COPYCAT (POKE DOLL -> TM31 MIMIC) lives in +-- data/scripts/story4.lua. return { COPYCATS_HOUSE_2F = { diff --git a/data/scripts/oaks_lab.lua b/data/scripts/oaks_lab.lua index c092467f..dbb01673 100644 --- a/data/scripts/oaks_lab.lua +++ b/data/scripts/oaks_lab.lua @@ -59,52 +59,97 @@ end return { talk = { - -- Oak: accepts the parcel and hands over the Pokédex, then (once the - -- player has beaten the Route 22 rival) hands over the real POKé - -- BALLs (scripts/OaksLab.asm OaksLabOak1Text, simplified: no rival - -- recall / dex-rating / "pokemon can fight" / "around the world" - -- branches -- see docs/known-differences.md) + -- Oak: OaksLabOak1Text. Parcel delivery kicks SCRIPT_OAKSLAB_RIVAL_ + -- ARRIVES_AT_OAKS_REQUEST + OaksLabOakGivesPokedexScript (rival walk- + -- in, full Pokédex speech, rival exit, Route 22 arm). Dex-rating + -- (DisplayDexRating) is still skipped. TEXT_OAKSLAB_OAK1 = { - { "face_player" }, -- 1 - { "check_flag", "EVENT_GOT_OAKS_PARCEL" }, -- 2 - { "jump_if_false", 16 }, -- 3 - { "check_flag", "EVENT_OAK_GOT_PARCEL" }, -- 4 - { "jump_if_true", 16 }, -- 5 - { "show_text", "_OaksLabOak1DeliverParcelText" }, -- 6 - { "take_item", "OAKS_PARCEL", 1 }, -- 7 - { "set_flag", "EVENT_OAK_GOT_PARCEL" }, -- 8 - { "show_text", "_OaksLabOak1PokemonAroundTheWorldText" }, -- 9 - { "set_flag", "EVENT_GOT_POKEDEX" }, -- 10 - -- OaksLab.asm OakGivesPokedex: HideObject TOGGLE_POKEDEX_1/2 - -- so the table sprites leave with the gift (#106). - { "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX1" }, -- 11 - { "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX2" }, -- 12 - -- the Pokédex swaps Viridian's two old men (OaksLab.asm:602-606: - -- HideObject TOGGLE_LYING_OLD_MAN / ShowObject TOGGLE_OLD_MAN). - -- Until this ran, the walking man at (17,5) -- who owns the coffee - -- ask and the catch tutorial -- stayed OFF for the whole game - -- (toggleable_objects.asm seeds him OFF, the sleeper ON). - { "hide_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY" }, -- 13 - { "show_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN" }, -- 14 - { "jump", "end" }, -- 15 - { "check_flag", "EVENT_GOT_STARTER" }, -- 16 - { "jump_if_false", 31 }, -- 17 - { "check_item", "POKE_BALL" }, -- 18 - { "jump_if_true", 29 }, -- 19 - { "check_flag", "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE" }, -- 20 - { "jump_if_false", 33 }, -- 21 - { "check_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 22 - { "jump_if_true", 29 }, -- 23 - { "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 24 - { "give_item", "POKE_BALL", 5, false }, -- 25 - { "show_text", "_OaksLabOak1ReceivedPokeballsText" }, -- 26 - { "show_text", "_OaksLabGivePokeballsExplanationText" }, -- 27 - { "jump", "end" }, -- 28 - { "show_text", "_OaksLabOak1ComeSeeMeSometimesText" }, -- 29 - { "jump", "end" }, -- 30 - { "show_text", "_OaksLabOak1WhichPokemonDoYouWantText" }, -- 31 - { "jump", "end" }, -- 32 - { "show_text", "_OaksLabOak1RaiseYourYoungPokemonText" }, -- 33 + { "face_player" }, + { "check_item", "POKE_BALL" }, + { "jump_if_true", "come_see" }, + { "check_flag", "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE" }, + { "jump_if_true", "give_balls" }, + { "check_flag", "EVENT_GOT_POKEDEX" }, + { "jump_if_true", "around_world" }, + { "check_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" }, + { "jump_if_false", "pre_lab_battle" }, + { "check_item", "OAKS_PARCEL" }, + { "jump_if_false", "raise_young" }, + -- OaksLabOak1Text.got_parcel → RivalArrives + OakGivesPokedex + { "show_text", "_OaksLabOak1DeliverParcelText" }, + { "play_sound", "Get_Key_Item" }, + { "show_text", "_OaksLabOak1ParcelThanksText" }, + { "take_item", "OAKS_PARCEL", 1 }, + { "stop_music" }, + { "play_music", "Music_MeetRival" }, + { "show_text", "_OaksLabRivalGrampsText" }, + { "show_object", "OAKS_LAB", "OAKSLAB_RIVAL" }, + -- OaksLabCalcRivalMovementScript: sprite map (8,11) → cell (4,7) + -- when the player stands below Oak (the usual desk talk). + { "place_npc", 1, 4, 7, "up" }, + { "move_npc_to", 1, 4, 3 }, + { "play_music", "Music_OaksLab" }, + { "face_object", 1, "up" }, + { "face_object", 5, "down" }, + { "show_text", "_OaksLabRivalWhatDidYouCallMeForText" }, + { "face_object", 1, "up" }, + { "face_object", 5, "down" }, + { "show_text", "_OaksLabOakIHaveARequestText" }, + { "face_object", 1, "up" }, + { "face_object", 5, "down" }, + { "show_text", "_OaksLabOakMyInventionPokedexText" }, + { "show_text", "_OaksLabOakGotPokedexText" }, + { "play_sound", "Get_Key_Item" }, + { "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX1" }, + { "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX2" }, + { "face_object", 1, "up" }, + { "face_object", 5, "down" }, + { "show_text", "_OaksLabOakThatWasMyDreamText" }, + { "face_object", 1, "right" }, + { "show_text", "_OaksLabRivalLeaveItAllToMeText" }, + { "set_flag", "EVENT_GOT_POKEDEX" }, + { "set_flag", "EVENT_OAK_GOT_PARCEL" }, + { "hide_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY" }, + { "show_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN" }, + { "stop_music" }, + { "play_music", "Music_MeetRival" }, + { "move_npc_to", 1, 4, 7 }, + { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" }, + { "play_music", "Music_OaksLab" }, + { "set_flag", "EVENT_1ST_ROUTE22_RIVAL_BATTLE" }, + { "clear_flag", "EVENT_2ND_ROUTE22_RIVAL_BATTLE" }, + { "set_flag", "EVENT_ROUTE22_RIVAL_WANTS_BATTLE" }, + { "show_object", "ROUTE_22", "ROUTE22_RIVAL1" }, + { "jump", "end" }, + + { "label", "raise_young" }, + { "show_text", "_OaksLabOak1RaiseYourYoungPokemonText" }, + { "jump", "end" }, + + { "label", "pre_lab_battle" }, + { "check_flag", "EVENT_GOT_STARTER" }, + { "jump_if_true", "can_fight" }, + { "show_text", "_OaksLabOak1WhichPokemonDoYouWantText" }, + { "jump", "end" }, + { "label", "can_fight" }, + { "show_text", "_OaksLabOak1YourPokemonCanFightText" }, + { "jump", "end" }, + + { "label", "around_world" }, + { "show_text", "_OaksLabOak1PokemonAroundTheWorldText" }, + { "jump", "end" }, + + { "label", "give_balls" }, + { "check_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, + { "jump_if_true", "come_see" }, + { "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, + { "give_item", "POKE_BALL", 5, false }, + { "show_text", "_OaksLabOak1ReceivedPokeballsText" }, + { "show_text", "_OaksLabGivePokeballsExplanationText" }, + { "jump", "end" }, + + { "label", "come_see" }, + { "show_text", "_OaksLabOak1ComeSeeMeSometimesText" }, }, TEXT_OAKSLAB_CHARMANDER_POKE_BALL = diff --git a/data/scripts/story.lua b/data/scripts/story.lua index 1920f0ee..aa161946 100644 --- a/data/scripts/story.lua +++ b/data/scripts/story.lua @@ -878,16 +878,23 @@ M.HALL_OF_FAME = { M.CERULEAN_CITY = { talk = { + -- CeruleanCityRivalText: Bill line once beaten; pre-battle otherwise. + -- Post-fight walk matches CeruleanCityMovement4 (right then into town). TEXT_CERULEANCITY_RIVAL = { - { "face_player" }, -- 1 - { "check_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 2 - { "jump_if_true", 9 }, -- 3 - { "show_text", "_CeruleanCityRivalPreBattleText" }, -- 4 - { "rival_battle", "OPP_RIVAL1", 7 }, -- 5 - { "jump_if_false", 10 }, -- 6 - { "set_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 7 - { "show_text", "_CeruleanCityRivalDefeatedText" }, -- 8 - { "jump", 10 }, -- 9 + { "face_player" }, -- 1 + { "check_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 2 + { "jump_if_true", 13 }, -- 3 + { "show_text", "_CeruleanCityRivalPreBattleText" }, -- 4 + { "rival_battle", "OPP_RIVAL1", 7 }, -- 5 + { "jump_if_false", "end" }, -- 6 + { "set_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 7 + { "show_text", "_CeruleanCityRivalDefeatedText" }, -- 8 + { "show_text", "_CeruleanCityRivalIWentToBillsText" }, -- 9 + { "walk_npc", 1, { "right", "down", "down", "down", + "down", "down", "down" } }, -- 10 + { "hide_object", "CERULEAN_CITY", "CERULEANCITY_RIVAL" }, -- 11 + { "jump", "end" }, -- 12 + { "show_text", "_CeruleanCityRivalIWentToBillsText" }, -- 13 }, }, } diff --git a/data/scripts/story2.lua b/data/scripts/story2.lua index 9156d1f5..43a98f34 100644 --- a/data/scripts/story2.lua +++ b/data/scripts/story2.lua @@ -445,33 +445,67 @@ local function engageSuperNerd(game, ow, onDone) ow:engageTrainer(nerd, onDone) end -local function mtMoonFossil(itemId, otherName) +-- MtMoonB2FMoveSuperNerdScript: player-near-dome coords walk RIGHT then +-- UP (MoveRight falls through into MoveUp); near-helix walks UP only. +local function mtMoonNerdWalk(px, py, itemId) + if (px == 12 and py == 7) or (px == 11 and py == 6) or (px == 12 and py == 5) then + return { "right", "up" } + end + if (px == 13 and py == 7) or (px == 14 and py == 6) or (px == 14 and py == 5) then + return { "up" } + end + return (itemId == "DOME_FOSSIL") and { "right", "up" } or { "up" } +end + +-- scripts/MtMoonB2F.asm Dome/Helix fossil text_asm + MoveSuperNerd + +-- SuperNerdTakesOtherFossil: pick one, hide it, walk the nerd to the +-- other, "All right. Then this is mine!", hide the other. +local function mtMoonFossil(itemId, otherName, gotFlag) return function(game, ow, npc, done) local TextBox = require("src.render.TextBox") - local ChoiceBox = require("src.ui.ChoiceBox") - if game.save.flags.EVENT_GOT_A_FOSSIL then - game.stack:push(TextBox.new(game, "You already took\na fossil.", done)) + local t = game.data.text + local flags = game.save.flags + if flags.EVENT_GOT_DOME_FOSSIL or flags.EVENT_GOT_HELIX_FOSSIL then + done() return end - -- can't grab a fossil until the Super Nerd is beaten -- he intercepts if not superNerdBeaten(ow) then engageSuperNerd(game, ow, done) return end - local name = game.data.items[itemId].name - game.stack:push(TextBox.new(game, ("You want the\n%s?"):format(name), function() - game.stack:push(ChoiceBox.new(game, function(yes) - if not yes then done() return end - game.save.inventory[itemId] = 1 - game.save.flags.EVENT_GOT_A_FOSSIL = true - local Commands = require("src.script.Commands") - local ctx = { save = game.save, overworld = ow, game = game } - Commands.hide_object(ctx, "MT_MOON_B2F", npc.def.name) - Commands.hide_object(ctx, "MT_MOON_B2F", otherName) + local want = (itemId == "DOME_FOSSIL") + and (t._MtMoonB2FDomeFossilYouWantText or "You want the\nDOME FOSSIL?") + or (t._MtMoonB2FHelixFossilYouWantText or "You want the\nHELIX FOSSIL?") + game.stack:push(TextBox.new(game, want, nil, { choice = function(yes) + if not yes then done() return end + if not require("src.inventory.Bag").add(game.save, itemId, 1) then game.stack:push(TextBox.new(game, - ("%s got the\n%s!"):format(game.save.player.name, name), done)) - end)) - end)) + t._MtMoonB2FYouHaveNoRoomText or "Look, you've got\nno room for this.", + done)) + return + 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) + game.stack:push(TextBox.new(game, + t._MtMoonB2FReceivedFossilText + or ("{PLAYER} got the\n" .. game.stringBuffer .. "!"), + function() + local Commands = require("src.script.Commands") + Commands.hide_object( + { save = game.save, overworld = ow, game = game }, + "MT_MOON_B2F", npc.def.name) + flags[gotFlag] = true + ow.runner:run({ + { "walk_npc", 1, dirs }, + { "text_opts", { auto = true } }, + { "show_text", "_MtMoonB2FSuperNerdThenThisIsMineText" }, + { "play_sound", "Get_Key_Item" }, + { "hide_object", "MT_MOON_B2F", otherName }, + }, { onDone = done }) + end)) + end })) end end @@ -486,8 +520,10 @@ M.MT_MOON_B2F = { return false end, talk = { - TEXT_MTMOONB2F_DOME_FOSSIL = mtMoonFossil("DOME_FOSSIL", "MTMOONB2F_HELIX_FOSSIL"), - TEXT_MTMOONB2F_HELIX_FOSSIL = mtMoonFossil("HELIX_FOSSIL", "MTMOONB2F_DOME_FOSSIL"), + TEXT_MTMOONB2F_DOME_FOSSIL = mtMoonFossil( + "DOME_FOSSIL", "MTMOONB2F_HELIX_FOSSIL", "EVENT_GOT_DOME_FOSSIL"), + TEXT_MTMOONB2F_HELIX_FOSSIL = mtMoonFossil( + "HELIX_FOSSIL", "MTMOONB2F_DOME_FOSSIL", "EVENT_GOT_HELIX_FOSSIL"), }, } @@ -822,6 +858,10 @@ M.DAYCARE = { if def then mon.stats = Stats.calc(def, mon.level, mon.dvs, mon.statExp) mon.hp = mon.stats.hp + -- Daycare.asm: WriteMonMoves with wLearningMovesFromDayCare + local Pokemon = require("src.pokemon.Pokemon") + Pokemon.learnMovesFromDayCare( + game.data, mon, def, startLevel, newLevel) end table.insert(game.save.party, mon) game.save.daycare = nil diff --git a/data/scripts/story4.lua b/data/scripts/story4.lua index 30b2827e..9ee5856f 100644 --- a/data/scripts/story4.lua +++ b/data/scripts/story4.lua @@ -212,30 +212,31 @@ M.COPYCATS_HOUSE_2F = { talk = { TEXT_COPYCATSHOUSE2F_COPYCAT = function(game, ow, npc, done) local t = text(game) + local Bag = require("src.inventory.Bag") if game.save.flags.EVENT_GOT_TM31 then - push(game, t._CopycatsHouse2FCopycatWhirlingText - or "Huh? Huh? Huh?", done) + push(game, t._CopycatsHouse2FCopycatTM31Explanation2Text, done) return end - if (game.save.inventory.POKE_DOLL or 0) > 0 then - ask(game, t._CopycatsHouse2FCopycatPokeDollText - or "Oh wow!\nA POKé DOLL!\fFor me?\nCan I have it?", function(yes) - if not yes then done() return end - game.save.inventory.POKE_DOLL = game.save.inventory.POKE_DOLL - 1 - if game.save.inventory.POKE_DOLL == 0 then - game.save.inventory.POKE_DOLL = nil - end - if not require("src.inventory.Bag").add(game.save, "TM_MIMIC", 1) then - push(game, "You don't have\nroom for TM31!", done) + -- Mimic line first; auto-trade POKE DOLL for TM31 (no YES/NO). + push(game, t._CopycatsHouse2FCopycatDoYouLikePokemonText, function() + if (game.save.inventory.POKE_DOLL or 0) <= 0 then + done() + return + end + push(game, t._CopycatsHouse2FCopycatTM31PreReceiveText, function() + if not Bag.add(game.save, "TM_MIMIC", 1) then + push(game, t._CopycatsHouse2FCopycatTM31NoRoomText, done) 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, ("%s got\nTM31!"):format(game.save.player.name), done) + push(game, t._CopycatsHouse2FCopycatReceivedTM31Text, function() + push(game, t._CopycatsHouse2FCopycatTM31Explanation1Text, done) + end) end) - return - end - push(game, t._CopycatsHouse2FCopycatText - or "I like to mimic\npeople!", done) + end) end, }, } diff --git a/data/scripts/story5.lua b/data/scripts/story5.lua index e460a953..5027640e 100644 --- a/data/scripts/story5.lua +++ b/data/scripts/story5.lua @@ -457,6 +457,19 @@ end -- EVENT_ROUTE22_RIVAL_WANTS_BATTLE in Oak's lab (expired by Pewter -- Gym) and again in Viridian Gym; we derive the same windows from the -- surrounding story flags so old saves work too. +-- Rival1Exit → Viridian (right/down); Rival2Exit → League (left). +local function route22ExitDirs(n, py) + if n == 2 then + if py == 5 then return { "left", "left", "left", "left" } end + return { "left", "left", "left" } + end + if py == 5 then + return { "right", "right", "down", "down", "down", "down", "down" } + end + return { "up", "right", "right", "right", + "down", "down", "down", "down", "down", "down" } +end + local function route22Scene(n, objIndex, objName, oppClass, baseParty, beatFlag, py) return { { "show_object", "ROUTE_22", objName }, -- 1 @@ -468,7 +481,7 @@ local function route22Scene(n, objIndex, objName, oppClass, baseParty, beatFlag, { "set_flag", beatFlag }, -- 7 { "show_text", "_Route22Rival" .. n .. "DefeatedText" }, -- 8 { "show_text", "_Route22RivalAfterBattleText" .. n }, -- 9 - { "move_npc_to", objIndex, 25, 5 }, -- 10 + { "walk_npc", objIndex, route22ExitDirs(n, py) }, -- 10 { "hide_object", "ROUTE_22", objName }, -- 11 } end @@ -495,6 +508,14 @@ M.ROUTE_22 = { -- Cerulean City: the Nugget Bridge rival ambush (CeruleanCityCoords2), -- the TM28 rocket thief with his forced-fight cells (Coords1), and the -- cave guard who steps aside once you are Champion. +-- Post-battle: Bill text then CeruleanCityMovement3/4 into town. +local function ceruleanRivalExitDirs(px) + if px == 20 then + return { "right", "down", "down", "down", "down", "down", "down" } + end + return { "left", "down", "down", "down", "down", "down", "down" } +end + local function ceruleanRivalScene(px, py) return { { "show_object", "CERULEAN_CITY", "CERULEANCITY_RIVAL" }, -- 1 @@ -502,41 +523,43 @@ local function ceruleanRivalScene(px, py) { "face_object", 1, "down" }, -- 3 { "show_text", "_CeruleanCityRivalPreBattleText" }, -- 4 { "rival_battle", "OPP_RIVAL1", 7 }, -- 5 - { "jump_if_false", 10 }, -- 6 + { "jump_if_false", 11 }, -- 6 { "set_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 7 { "show_text", "_CeruleanCityRivalDefeatedText" }, -- 8 - { "move_npc_to", 1, 20, 2 }, -- 9 - { "hide_object", "CERULEAN_CITY", "CERULEANCITY_RIVAL" }, -- 10 + { "show_text", "_CeruleanCityRivalIWentToBillsText" }, -- 9 + { "walk_npc", 1, ceruleanRivalExitDirs(px) }, -- 10 + { "hide_object", "CERULEAN_CITY", "CERULEANCITY_RIVAL" }, -- 11 } end -- scripts/CeruleanCity.asm CeruleanCityRocketText: fight the thief, --- then he returns TM28 (DIG) and hurries off +-- then he returns TM28 (DIG) and hurries off. CeruleanHideRocket +-- (CeruleanCity_2.asm) is GBFadeOutToBlack → Show GUARD1 / Hide GUARD2 / +-- Hide ROCKET → GBFadeInFromBlack — not a bare hide_object. local rocketRows = { { "face_player" }, -- 1 { "check_flag", "EVENT_GOT_TM28" }, -- 2 - { "jump_if_true", 15 }, -- 3 + { "jump_if_true", 15 }, -- 3 → CeruleanHideRocket { "check_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 4 { "jump_if_true", 9 }, -- 5 { "show_text", "_CeruleanCityRocketText" }, -- 6 { "start_battle", "trainer", "OPP_ROCKET", 5 }, -- 7 - { "jump_if_false", 18 }, -- 8 + { "jump_if_false", "end" }, -- 8 { "show_text", "_CeruleanCityRocketIllReturnTheTMText" }, -- 9 { "set_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 10 { "give_item", "TM_DIG", 1, false }, -- 11 (row 13 prints) { "set_flag", "EVENT_GOT_TM28" }, -- 12 { "show_text", "_CeruleanCityRocketReceivedTM28Text" }, -- 13 { "show_text", "_CeruleanCityRocketIBetterGetMovingText" }, -- 14 - { "hide_object", "CERULEAN_CITY", "CERULEANCITY_ROCKET" }, -- 15 - -- CeruleanHideRocket (scripts/CeruleanCity_2.asm) does all three behind - -- one fade: the ROCKET goes, GUARD1 (28,12) appears and GUARD2 (27,12) - -- disappears. GUARD2 stands on the only walkable neighbour of the - -- trashed house's south door, which is one of the two ways through the - -- fence dividing Cerulean, so this swap is what reconnects the city. - -- Bill's ticket performs the same swap (data/scripts/story.lua), and - -- both are idempotent, so whichever the player reaches first opens it. + { "fade", "out" }, -- 15 GBFadeOutToBlack + -- CeruleanHideRocket while black: GUARD1 (28,12) appears, GUARD2 + -- (27,12) and the ROCKET go. GUARD2 blocks the trashed-house south + -- door neighbour — the swap reconnects the city (Bill's ticket does + -- the same in story.lua; either route is enough). { "show_object", "CERULEAN_CITY", "CERULEANCITY_GUARD1" }, -- 16 { "hide_object", "CERULEAN_CITY", "CERULEANCITY_GUARD2" }, -- 17 + { "hide_object", "CERULEAN_CITY", "CERULEANCITY_ROCKET" }, -- 18 + { "fade", "in" }, -- 19 GBFadeInFromBlack } M.CERULEAN_CITY = { diff --git a/data/scripts/victories.lua b/data/scripts/victories.lua index 45c620c5..a31bbbb0 100644 --- a/data/scripts/victories.lua +++ b/data/scripts/victories.lua @@ -12,6 +12,12 @@ -- "; deactivate gym trainers" / SetEventRange in the other gyms, and -- FightingDojo.asm SetEventRange EVENT_BEAT_KARATE_MASTER .. -- EVENT_BEAT_FIGHTING_DOJO_TRAINER_3). +-- +-- `dialogue` is the end-battle + post-battle text chain each gym leader +-- runs (SaveEndBattleTextPointers then the map's *PostBattle / ReceiveTM +-- script). Leaders are not def_trainers entries, so engageTrainer has +-- no header.won — checkVictoryRewards shows this chain instead of a +-- synthetic "received badge/TM" stub. local function range(prefix, first, last) local t = {} @@ -31,28 +37,76 @@ return { hide = { { "PEWTER_CITY", "PEWTERCITY_YOUNGSTER" }, { "ROUTE_22", "ROUTE22_RIVAL1" }, + }, + dialogue = { + "_PewterGymBrockReceivedBoulderBadgeText", + "_PewterGymBrockBoulderBadgeInfoText", + "_PewterGymBrockWaitTakeThisText", + "_PewterGymReceivedTM34Text", + "_TM34ExplanationText", } }, ["OPP_MISTY#1"] = { badge = "CASCADEBADGE", flag = "EVENT_BEAT_MISTY", item = "TM_BUBBLEBEAM", - deactivate = range("EVENT_BEAT_CERULEAN_GYM_TRAINER_", 0, 1) }, + deactivate = range("EVENT_BEAT_CERULEAN_GYM_TRAINER_", 0, 1), + dialogue = { + "_CeruleanGymMistyReceivedCascadeBadgeText", + "_CeruleanGymMistyCascadeBadgeInfoText", + "_CeruleanGymMistyReceivedTM11Text", + } }, ["OPP_LT_SURGE#1"] = { badge = "THUNDERBADGE", flag = "EVENT_BEAT_LT_SURGE", item = "TM_THUNDERBOLT", - deactivate = range("EVENT_BEAT_VERMILION_GYM_TRAINER_", 0, 2) }, + deactivate = range("EVENT_BEAT_VERMILION_GYM_TRAINER_", 0, 2), + dialogue = { + "_VermilionGymLTSurgeReceivedThunderBadgeText", + "_VermilionGymLTSurgeThunderBadgeInfoText", + "_VermilionGymLTSurgeReceivedTM24Text", + "_TM24ExplanationText", + } }, ["OPP_ERIKA#1"] = { badge = "RAINBOWBADGE", flag = "EVENT_BEAT_ERIKA", item = "TM_MEGA_DRAIN", - deactivate = range("EVENT_BEAT_CELADON_GYM_TRAINER_", 0, 6) }, + deactivate = range("EVENT_BEAT_CELADON_GYM_TRAINER_", 0, 6), + dialogue = { + "_CeladonGymErikaReceivedRainbowBadgeText", + "_CeladonGymRainbowBadgeInfoText", + "_CeladonGymReceivedTM21Text", + "_TM21ExplanationText", + } }, ["OPP_KOGA#1"] = { badge = "SOULBADGE", flag = "EVENT_BEAT_KOGA", item = "TM_TOXIC", - deactivate = range("EVENT_BEAT_FUCHSIA_GYM_TRAINER_", 0, 5) }, + deactivate = range("EVENT_BEAT_FUCHSIA_GYM_TRAINER_", 0, 5), + dialogue = { + "_FuchsiaGymKogaReceivedSoulBadgeText", + "_FuchsiaGymKogaSoulBadgeInfoText", + "_FuchsiaGymKogaReceivedTM06Text", + "_FuchsiaGymKogaTM06ExplanationText", + } }, ["OPP_SABRINA#1"] = { badge = "MARSHBADGE", flag = "EVENT_BEAT_SABRINA", item = "TM_PSYWAVE", - deactivate = range("EVENT_BEAT_SAFFRON_GYM_TRAINER_", 0, 6) }, + deactivate = range("EVENT_BEAT_SAFFRON_GYM_TRAINER_", 0, 6), + dialogue = { + "_SaffronGymSabrinaReceivedMarshBadgeText", + "_SaffronGymSabrinaMarshBadgeInfoText", + "_SaffronGymSabrinaReceivedTM46Text", + "_TM46ExplanationText", + } }, ["OPP_BLAINE#1"] = { badge = "VOLCANOBADGE", flag = "EVENT_BEAT_BLAINE", item = "TM_FIRE_BLAST", - deactivate = range("EVENT_BEAT_CINNABAR_GYM_TRAINER_", 0, 6) }, + deactivate = range("EVENT_BEAT_CINNABAR_GYM_TRAINER_", 0, 6), + dialogue = { + "_CinnabarGymBlaineReceivedVolcanoBadgeText", + "_CinnabarGymBlaineVolcanoBadgeInfoText", + "_CinnabarGymBlaineReceivedTM38Text", + "_CinnabarGymBlaineTM38ExplanationText", + } }, ["OPP_GIOVANNI#3"] = { badge = "EARTHBADGE", flag = "EVENT_BEAT_GIOVANNI", item = "TM_FISSURE", - deactivate = range("EVENT_BEAT_VIRIDIAN_GYM_TRAINER_", 0, 7) }, + deactivate = range("EVENT_BEAT_VIRIDIAN_GYM_TRAINER_", 0, 7), + dialogue = { + "_ViridianGymGiovanniReceivedEarthBadgeText", + "_ViridianGymGiovanniEarthBadgeInfoText", + "_ViridianGymGiovanniReceivedTM27Text", + "_ViridianGymGiovanniTM27ExplanationText", + } }, -- Silph Co. Giovanni: unlocks the president's Master Ball gift ["OPP_GIOVANNI#2"] = { flag = "EVENT_BEAT_SILPH_CO_GIOVANNI" }, diff --git a/docs/launcher.md b/docs/launcher.md index 0d2e7b7c..57b14d76 100644 --- a/docs/launcher.md +++ b/docs/launcher.md @@ -5,6 +5,20 @@ that runs before `Game:load`. Besides ROM import (see the file's own header) it hosts a tabbed shell covering per-game save slots and a mod manager. This file documents the runtime model; the visual spec lives separately. +## Android multi-ROM import + +On Android, `love.system.pickFile()` opens the Storage Access Framework +picker (`GameActivity.showRomFilePicker`); the chosen file is copied to +`picked_rom.gb` in the app save directory. `RomImporter` then imports on +refocus / Choose via `findPendingRom`: only a 1 MiB `.gb` whose SHA-1 maps +to a version that is **not** yet ready counts as pending. A leftover +`picked_rom.gb` from Red therefore cannot block Blue's Choose (issue #167). +After a successful import the consumed save-dir `.gb` is removed. + +**Manual check (device/emulator):** import Red → switch to Blue → Choose → +system file picker must appear (not a silent Red re-extract) → pick Blue → +Blue becomes ready beside Red. + ## Tab structure `self.tab` is one of `"red"`, `"blue"`, `"yellow"`, `"mods"`. The tab bar diff --git a/mobile/ANDROID.md b/mobile/ANDROID.md index 559c1ecd..6d3251b6 100644 --- a/mobile/ANDROID.md +++ b/mobile/ANDROID.md @@ -43,9 +43,12 @@ The embedded `game.love` deliberately excludes `data/generated/`, `assets/generated/`, and any ROM. It contains the first-boot Lua importer and `tools/rom_manifest.json`. -The current importer has desktop file pickers only. A production Android -release still needs a Storage Access Framework handoff that passes the chosen -ROM to LÖVE; the APK packaging itself is data-free. +ROM import on Android uses `love.system.pickFile()` → +`GameActivity.showRomFilePicker` (Storage Access Framework), which copies the +chosen file to `picked_rom.gb` under the app save directory. `RomImporter` +imports pending (not-yet-ready) `.gb` files from that folder on Choose / +refocus; see `docs/launcher.md` (Android multi-ROM import). The APK payload +itself remains data-free (no embedded ROM or generated cache). ### SDK / NDK diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 5e08c29a..cc4b7133 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -275,6 +275,10 @@ local function makeBattler(data, mon, isPlayer, save) badgeBoosts = badgeBoosts, statuses = data.statuses, shownHP = mon.hp, -- the HP the bar displays (UpdateHPBar drain) + -- 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) + shownStatus = mon.status, stages = {}, -- volatile state; Transform/Conversion/Mimic override the cur* fields curStats = mon.stats, @@ -556,6 +560,12 @@ function BattleState:say(text) table.insert(self.queue, { text = text }) end +-- Message that opens YES/NO once typed out, keeping the text visible +-- underneath (pokered `done` + TWO_OPTION_MENU / TextBox opts.choice). +function BattleState:sayChoice(text, onChoose) + table.insert(self.queue, { text = text, choice = onChoose }) +end + function BattleState:act(fn) table.insert(self.queue, { fn = fn }) end @@ -816,8 +826,24 @@ function BattleState:updateQueue() if self.charIndex < self.total then self.charIndex = math.min(self.total, self.charIndex + 2) else + local item = self.current + -- TrainerAboutToUseText ends in `done` then DisplayTextBoxID: YES/NO + -- overlays the still-visible "Will … change POKéMON?" page. + if item and item.choice and not item.choiceOpen then + item.choiceOpen = true + self.waitingUI = true + local ChoiceBox = require("src.ui.ChoiceBox") + local battle = self + self.game.stack:push(ChoiceBox.new(self.game, function(yes) + local fn = item.choice + battle.current = nil + fn(yes) + end)) + return true + end local input = self.game.input - if input:wasPressed("a") or input:wasPressed("b") then + if not (item and item.choice) + and (input:wasPressed("a") or input:wasPressed("b")) then self.current = nil end end @@ -1068,11 +1094,14 @@ function BattleState:update(dt) self:updateFx() local input = self.game.input - -- safety net: HP changed outside a queued drain (level-up heals, - -- field effects) snaps once the queue is idle + -- safety net: HP/status changed outside a queued drain (level-up heals, + -- field effects, bag cures) snaps once the queue is idle if self.phase == "menu" then for _, b in ipairs({ self.player, self.enemy }) do - if b and b.shownHP then b.shownHP = b.mon.hp end + if b then + if b.shownHP then b.shownHP = b.mon.hp end + b.shownStatus = b.mon.status + end end end @@ -2153,90 +2182,105 @@ end -- move execution pipeline -- --------------------------------------------------------------------- +-- Mirror DrawHUDsAndHPBars: reveal mon.status on the HUD only after the +-- current action's queued anim/text have played (PoisonEffect sets the +-- bit before PlayCurrentMoveAnimation2 + PrintText, but the HUD redraw +-- waits until after Execute*Move returns). +function BattleState:syncShownStatus() + for _, b in ipairs({ self.player, self.enemy }) do + if b then b.shownStatus = b.mon.status end + end +end + function BattleState:executeAction(user, target, action) if user.mon.hp <= 0 or target.mon.hp <= 0 then return end if not action then return end - -- ghost battles: the ghost never attacks; its whole turn is the - -- GetOutText (ExecuteEnemyMove -> PrintGhostText, core.asm:5462-5463) - if self.ghost and not user.isPlayer then - self:sayNext(self.data.text._GetOutText or "GHOST: Get out...\nGet out...") - return - end - - -- refresh the held-in-place mirror before the status checks (see - -- lockedAction): the victim is held exactly while the opponent's - -- trapping bit is set -- including a counter sitting at 0 until the - -- end-of-turn CheckNumAttacksLeft clear - user.boundTurns = target.trappingTurns - and math.max(1, target.trappingTurns) or nil - - -- trainer class AI actions (engine/battle/trainer_ai.asm) - if action.special == "aiItem" then - self.aiUses = (self.aiUses or 1) - 1 - for _, m in ipairs(TrainerAI.useItem(self, action.item)) do - self:sayNext(prefixEnemy(m, self.enemy)) - end - self:drainNext() - require("src.core.Sound").play(self.data, "Heal_Ailment") - return - end - if action.special == "aiSwitch" then - self.aiUses = (self.aiUses or 1) - 1 - local previous = self.enemy - local oldName = self.enemy.name - self.enemyIndex = action.index - self.enemy = makeBattler(self.data, self.enemyParty[action.index], false) - -- EnemySendOutFirstMon (core.asm:1314-1315): clears player's trap - clearTrapping(self.player) - self:syncSides() - Runtime.emit("battle.battler_switched", { - battle = self, side = self.sides[2], battler = self.enemy, - previous = previous, - }) - self.aiUses = self:aiUsesFor() - markSeen(self.game, self.enemy.mon.species) - -- _AIBattleWithdrawText: "X with-/drew Y!" - self:sayNext(("%s with-\ndrew %s!"):format(self.trainer.name, oldName)) - self:sayNext(("%s sent\nout %s!"):format(self.trainer.name, self.enemy.name)) - return - end - - -- special locked actions. All of them still run the status gauntlet: - -- CheckPlayerStatusConditions (core.asm:3328-3583) evaluates sleep -> - -- freeze -> held-in-place -> flinch -> recharge -> disable tick -> - -- confusion -> paralysis BEFORE the bide/thrash/trapping handling. - if action.special == "recharge" then - -- only reaching .HyperBeamCheck consumes the flag (core.asm:3384- - -- 3392): sleep/freeze/held/flinch keep the mon recharging next turn - if self:preRechargeChecks(user, target) then return end - user.mustRecharge = nil - self:sayNext(("%s\nmust recharge!"):format(displayName(user))) - return - end - if action.special == "bound" then - if not target.trappingTurns then - -- the trap ended earlier this turn: the CANNOT_MOVE selection is - -- simply lost (ExecutePlayerMove returns immediately on $ff) + local function run() + -- ghost battles: the ghost never attacks; its whole turn is the + -- GetOutText (ExecuteEnemyMove -> PrintGhostText, core.asm:5462-5463) + if self.ghost and not user.isPlayer then + self:sayNext(self.data.text._GetOutText or "GHOST: Get out...\nGet out...") return end - -- sleep/freeze take precedence over the held-in-place message - if self:statusInterrupt(user, target) then return end - return - end - if action.special == "trapping" then - if self:statusInterrupt(user, target) then return end - self:continueTrapping(user, target) - return - end - if action.special == "bide" then - if self:statusInterrupt(user, target) then return end - self:continueBide(user, target) - return - end - if self:statusInterrupt(user, target) then return end - self:performMove(user, target, action, false) + -- refresh the held-in-place mirror before the status checks (see + -- lockedAction): the victim is held exactly while the opponent's + -- trapping bit is set -- including a counter sitting at 0 until the + -- end-of-turn CheckNumAttacksLeft clear + user.boundTurns = target.trappingTurns + and math.max(1, target.trappingTurns) or nil + + -- trainer class AI actions (engine/battle/trainer_ai.asm) + if action.special == "aiItem" then + self.aiUses = (self.aiUses or 1) - 1 + for _, m in ipairs(TrainerAI.useItem(self, action.item)) do + self:sayNext(prefixEnemy(m, self.enemy)) + end + self:drainNext() + require("src.core.Sound").play(self.data, "Heal_Ailment") + return + end + if action.special == "aiSwitch" then + self.aiUses = (self.aiUses or 1) - 1 + local previous = self.enemy + local oldName = self.enemy.name + self.enemyIndex = action.index + self.enemy = makeBattler(self.data, self.enemyParty[action.index], false) + -- EnemySendOutFirstMon (core.asm:1314-1315): clears player's trap + clearTrapping(self.player) + self:syncSides() + Runtime.emit("battle.battler_switched", { + battle = self, side = self.sides[2], battler = self.enemy, + previous = previous, + }) + self.aiUses = self:aiUsesFor() + markSeen(self.game, self.enemy.mon.species) + -- _AIBattleWithdrawText: "X with-/drew Y!" + self:sayNext(("%s with-\ndrew %s!"):format(self.trainer.name, oldName)) + self:sayNext(("%s sent\nout %s!"):format(self.trainer.name, self.enemy.name)) + return + end + + -- special locked actions. All of them still run the status gauntlet: + -- CheckPlayerStatusConditions (core.asm:3328-3583) evaluates sleep -> + -- freeze -> held-in-place -> flinch -> recharge -> disable tick -> + -- confusion -> paralysis BEFORE the bide/thrash/trapping handling. + if action.special == "recharge" then + -- only reaching .HyperBeamCheck consumes the flag (core.asm:3384- + -- 3392): sleep/freeze/held/flinch keep the mon recharging next turn + if self:preRechargeChecks(user, target) then return end + user.mustRecharge = nil + self:sayNext(("%s\nmust recharge!"):format(displayName(user))) + return + end + if action.special == "bound" then + if not target.trappingTurns then + -- the trap ended earlier this turn: the CANNOT_MOVE selection is + -- simply lost (ExecutePlayerMove returns immediately on $ff) + return + end + -- sleep/freeze take precedence over the held-in-place message + if self:statusInterrupt(user, target) then return end + return + end + if action.special == "trapping" then + if self:statusInterrupt(user, target) then return end + self:continueTrapping(user, target) + return + end + if action.special == "bide" then + if self:statusInterrupt(user, target) then return end + self:continueBide(user, target) + return + end + + if self:statusInterrupt(user, target) then return end + self:performMove(user, target, action, false) + end + run() + -- after announce/anim/effect text (pokered DrawHUDsAndHPBars) + self:actNext(function() self:syncShownStatus() end) end -- Sleep / confusion onomatopoeia from Check*StatusConditions @@ -2732,51 +2776,37 @@ function BattleState:enemyMonFainted() end if nextIndex then self.enemyIndex = nextIndex - -- SHIFT battle style (the default): announce the next mon and - -- offer a free switch (SET skips the prompt) + -- EnemySendOutFirstMon (core.asm:1366-1443): SHIFT offers a free + -- switch when party count > 1, the active mon is alive, and the + -- battle-style bit is clear. pokered counts party slots (not + -- remaining HP); SET / single-mon / fainted active skip the prompt. local nextMon = self.enemyParty[self.enemyIndex] local nextName = nextMon.nickname or self.data.pokemon[nextMon.species].name - local style = (self.game.save.options or {}).battleStyle or "shift" - local healthy = 0 - for _, mon in ipairs(self.game.save.party) do - if mon.hp > 0 then healthy = healthy + 1 end - end - if style ~= "set" and healthy > 1 and self.player.mon.hp > 0 then - self:say(("%s is\nabout to use\n%s!"):format(self.trainer.name, nextName)) - self:say(("Will %s\nchange POKéMON?"):format(self.game.save.player.name)) - local game = self.game - self:ui(function() - local ChoiceBox = require("src.ui.ChoiceBox") - return ChoiceBox.new(game, function(yes) + local style = tostring((self.game.save.options or {}).battleStyle or "shift") + :lower() + local partyCount = #self.game.save.party + -- SwitchPlayerMon runs AFTER TrainerSentOutText (core.asm:1436-1443) + local shiftSwitchMon = nil + if style ~= "set" and partyCount > 1 and self.player.mon.hp > 0 then + -- _TrainerAboutToUseText: "X is" / "about to use" then cont nick, + -- then para "Will PLAYER" / "change POKéMON?" with YES/NO. + self:say(("%s is\nabout to use"):format(self.trainer.name)) + self:say(("%s!"):format(nextName)) + self:sayChoice( + ("Will %s\nchange POKéMON?"):format(self.game.save.player.name), + function(yes) if not yes then return end + local game = self.game Screens.push(game, "PartyMenu", { battle = self, + forceSwitch = true, onSwitch = function(mon) if mon ~= self.player.mon and mon.hp > 0 then - local previous = self.player - self.player = makeBattler(self.data, mon, true, game.save) - clearTrapping(self.enemy) -- SendOutMon clears foe trap - self:syncSides() - Runtime.emit("battle.battler_switched", { - battle = self, side = self.sides[1], - battler = self.player, previous = previous, - }) - self:markParticipant() - self.nextInsert = 0 - self.sendingOut = true - self:sayNext(self:sendOutText(self.player.name)) - self:animNext("POOF_ANIM", false) - self:actNext(function() - self.sendingOut = false - -- SendOutMon (core.asm:1757-1762): poof, then the grow-in - self:startGrowIn(self.player) - require("src.core.Sound").playCry(self.data, self.player.mon.species) - end) + shiftSwitchMon = mon end end, }) end) - end) end self:act(function() local previous = self.enemy @@ -2805,6 +2835,29 @@ function BattleState:enemyMonFainted() end) end) end) + -- SwitchPlayerMon after the enemy is out (core.asm:1436-1443) + self:act(function() + local mon = shiftSwitchMon + if not mon then return end + local previous = self.player + self.player = makeBattler(self.data, mon, true, self.game.save) + clearTrapping(self.enemy) + self:syncSides() + Runtime.emit("battle.battler_switched", { + battle = self, side = self.sides[1], + battler = self.player, previous = previous, + }) + self:markParticipant() + self.nextInsert = 0 + self.sendingOut = true + self:sayNext(self:sendOutText(self.player.name)) + self:animNext("POOF_ANIM", false) + self:actNext(function() + self.sendingOut = false + self:startGrowIn(self.player) + require("src.core.Sound").playCry(self.data, self.player.mon.species) + end) + end) return end local prize = (self.trainer.baseMoney or 0) * self.enemy.mon.level @@ -2928,6 +2981,8 @@ function BattleState:openReplacementMenu() self:ui(function() return self:buildScreen("PartyMenu", { battle = self, + -- ChooseNextMon: pick immediately (no SWITCH/STATS/CANCEL) + forceSwitch = true, onSwitch = function(mon) if mon.hp <= 0 then self:say("There's no will\nto fight!") @@ -3138,6 +3193,9 @@ end -- called by BagMenu after an item is used in battle (consumes the turn) function BattleState:itemUsed(messages) + -- bag cures clear mon.status before the message UI; refresh the HUD + -- once control returns (pokered DrawHUDsAndHPBars after item use) + self:syncShownStatus() for _, m in ipairs(messages or {}) do self:say(m) end table.insert(self.queue, { drain = true }) -- potions animate the bar self:act(function() @@ -3159,10 +3217,39 @@ function BattleState:ballMissMessage(shakes) return t._ItemUseBallText04 or "Shoot! It was so\nclose too!" end +-- AskName (engine/menus/naming_screen.asm): ClearSprites, wild field blank, +-- PrintText, YES/NO while text stays (TextBox opts.choice). Shared by party +-- AddPartyMon and SendNewMonToBox (#172). +function BattleState:askNicknameUI(mon, displayName) + local game = self.game + self.lockedBall = nil + self.blankForAskName = true + local TextBox = require("src.render.TextBox") + local text = ("Do you want to\ngive a nickname\nto %s?"):format(displayName) + local label = game.data.text and game.data.text._DoYouWantToNicknameText + if label then + -- extractor CONT is \t; TextBox scrolls on \n/\v + text = label:gsub("\t", "\n"):gsub("{RAM:?[%w_]*}", displayName) + end + return TextBox.new(game, text, nil, { + choice = function(yes) + self.blankForAskName = false + if not yes then return end + pcall(Screens.push, game, "NamingScreen", { + title = "NICKNAME?", maxLen = 10, + onDone = function(name) + if name and #name > 0 then mon.nickname = name end + end, + }) + end, + }) +end + -- The caught mon joins the party or a PC box (ItemUseBall .captured, -- item_effects.asm:518-566): the caught text, then for a NEW species --- "New POKéDEX data will be added" + the dex entry page, then the --- party add (with the nickname ask) or the PC transfer text. +-- "New POKéDEX data will be added" + the dex entry page, then +-- AddPartyMon or SendNewMonToBox (both call AskName), then the PC +-- transfer text when the party was full. function BattleState:storeCaughtMon() -- ItemUseBall reloads the caught mon via LoadEnemyMonData -- (item_effects.asm:472-501), regenerating its move list from the @@ -3182,31 +3269,20 @@ function BattleState:storeCaughtMon() return self:buildScreen("DexEntryMenu", species) end) end - if Party.add(game.save.party, self.enemy.mon) then - -- nickname prompt (AskName runs inside AddPartyMon; box mons are - -- never offered a nickname) + local function askCaughtNickname() local caught = self.enemy.mon local enemyName = self.enemy.name self:uiNext(function() - local ChoiceBox = require("src.ui.ChoiceBox") - local TextBox = require("src.render.TextBox") - return TextBox.new(game, ("Do you want to\ngive a nickname\nto %s?") - :format(enemyName), function() - game.stack:push(ChoiceBox.new(game, function(yes) - if not yes then return end - pcall(Screens.push, game, "NamingScreen", { - title = "NICKNAME?", maxLen = 10, - onDone = function(name) - if name and #name > 0 then caught.nickname = name end - end, - }) - end)) - end) + return self:askNicknameUI(caught, enemyName) end) + end + if Party.add(game.save.party, self.enemy.mon) then + askCaughtNickname() else destination = "box" local boxNum = require("src.pokemon.Boxes").deposit(game.save, self.enemy.mon) if boxNum then + askCaughtNickname() -- _ItemUseBallText07/08 keyed on EVENT_MET_BILL local pc = (game.save.flags and game.save.flags.EVENT_MET_BILL) and "BILL's PC" or "someone's PC" @@ -4041,8 +4117,11 @@ function BattleState:drawHUDs(slide) local barData = self:colorMode() and {} or self.data -- gray fill when zoned local fx = self.fx local hudShake = (fx and fx.hudShakeX) or 0 + -- FaintEnemyPokemon clears the enemy HUD area; it stays blank through + -- TrainerAboutToUseText until DrawEnemyHUDAndHPBar after the next send-out if self.enemy and not self.showEnemyTrainer and not self.enemySendingOut - and not self:growInScale(self.enemy) and slide == 0 then + and not self:growInScale(self.enemy) and slide == 0 + and not self.enemy.fainted then -- enemy HUD (DrawEnemyHUDAndHPBar): name row 0, +level (4,1), -- HP bar (2,2) with the vertical tick at (1,2), underline row 3; -- AnimationShakeEnemyHUD nudges just this block via SCX @@ -4052,8 +4131,8 @@ function BattleState:drawHUDs(slide) end love.graphics.setColor(0, 0, 0, 1) Font.draw(self.enemy.name, nameX(1, self.enemy.name), 0) - if self.enemy.mon.status then - Font.draw(self:statusLabel(self.enemy.mon), 40, 8) + if self.enemy.shownStatus then + Font.draw(self:statusLabel({ status = self.enemy.shownStatus }), 40, 8) else hudTile(0x6E, 32, 8) -- Font.draw(tostring(self.enemy.mon.level), 40, 8) @@ -4100,8 +4179,8 @@ function BattleState:drawHUDs(slide) -- the tick at (18,10) and the triangle at (9,11) love.graphics.setColor(0, 0, 0, 1) Font.draw(self.player.name, nameX(10, self.player.name), 56) - if self.player.mon.status then - Font.draw(self:statusLabel(self.player.mon), 120, 64) + if self.player.shownStatus then + Font.draw(self:statusLabel({ status = self.player.shownStatus }), 120, 64) else hudTile(0x6E, 112, 64) -- Font.draw(tostring(self.player.mon.level), 120, 64) @@ -4207,6 +4286,13 @@ function BattleState:drawTextArea() end function BattleState:draw() + -- AskName: ClearSprites + wild ClearScreenArea — white field under the + -- nickname TextBox / YES/NO (naming_screen.asm); overlays draw on top. + if self.blankForAskName then + love.graphics.setColor(1, 1, 1, 1) + love.graphics.rectangle("fill", 0, 0, 160, 144) + return + end local fx = self.fx -- window shakes (SE_SHAKE_SCREEN / the enemy-hit vertical shake); -- the animations-off fallback keeps the old +-2 alternation diff --git a/src/core/Data.lua b/src/core/Data.lua index 47cf2429..9b62fe8f 100644 --- a/src/core/Data.lua +++ b/src/core/Data.lua @@ -102,6 +102,8 @@ function Data:seedDefaults() -- after-battle rows so Blaine's SetEventRange deactivation and talk -- after-text work like the other gyms (scripts/CinnabarGym.asm). self:seedCinnabarGymTrainerHeaders() + -- #189: 1F cabin door order vs rooms map (survey zoom) + require("src.world.SsAnneLayout").apply(self.maps) end function Data:seedCinnabarGymTrainerHeaders() diff --git a/src/import/RomExtractor.lua b/src/import/RomExtractor.lua index 7982241f..6b3c3a21 100644 --- a/src/import/RomExtractor.lua +++ b/src/import/RomExtractor.lua @@ -375,6 +375,8 @@ function RomExtractor:extractMaps() } self:tick("Maps", mapIndex, #keys) end + -- keep the extract ROM-faithful; Data:seedDefaults applies the #189 + -- S.S. Anne 1F cabin reorder at load time self:write("maps", out) return out end diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index b4dbfa05..57546109 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -270,10 +270,21 @@ end -- via USB or a file manager, no runtime permission needed), and this scans -- it directly through love.filesystem -- already mounted at the physfs -- root, so no io.* absolute-path handling is needed. -local function scanForRom() +-- +-- Only a .gb whose SHA maps to a version that is not yet ready counts as +-- pending. GameActivity always writes the SAF pick to picked_rom.gb, so a +-- naive "first .gb wins" scan would re-import Red when the player tries to +-- add Blue (issue #167). +local function findPendingRom(ready) for _, name in ipairs(love.filesystem.getDirectoryItems("")) do if name:lower():match("%.gb$") and love.filesystem.getInfo(name, "file") then - return name + local data = love.filesystem.read(name) + if type(data) == "string" and #data == 1024 * 1024 then + local version = GameVersion.forSha1(sha1(data)) + if version and not ready[version] then + return name, data + end + end end end return nil @@ -439,12 +450,12 @@ function RomImporter.new(onComplete, opts) self.romName[version] = "pokemon_" .. info.id .. ".gb" end - -- Android has no native picker until the player copies a ROM into the - -- external folder; import any .gb already there (routed by SHA-1) unless - -- both games are already imported. + -- Android: import a save-dir .gb that is not yet ready (USB drop or a + -- leftover SAF pick), routed by SHA-1. Already-imported carts are skipped + -- so a stale picked_rom.gb cannot block the opposite version. if android and not (self.ready.red and self.ready.blue) then - local name = scanForRom() - if name then self:startData(love.filesystem.read(name), name) end + local name, data = findPendingRom(self.ready) + if name then self:startData(data, name) end end -- Mouse-wheel scroll for the save-slot / mods lists. main.lua (off limits) @@ -480,13 +491,13 @@ end -- The system picker runs as a separate top activity, so LOVE's own -- love.focus/love.visible pause while it's up (see main.lua) -- once the -- player returns here with a file picked, GameActivity has already copied --- it into the folder scanForRom checks, so a rescan on refocus picks it up --- without the player needing to tap the button again. +-- it into the save directory, so a pending-ROM rescan on refocus picks it +-- up without the player needing to tap the button again. function RomImporter:focus(f) if not (f and self.android and self.workState ~= "working") then return end if self.ready.red and self.ready.blue then return end - local name = scanForRom() - if name then self:startData(love.filesystem.read(name), name) end + local name, data = findPendingRom(self.ready) + if name then self:startData(data, name) end end function RomImporter:setError(message, version) @@ -586,6 +597,12 @@ function RomImporter:startData(data, displayName) self.returning[version] = false self.romName[version] = (displayName and (displayName:match("[^/\\]+$") or displayName)) or self.romName[version] + -- Android: drop the consumed save-dir .gb (picked_rom.gb or a USB copy) + -- so the next Choose / focus cannot treat it as a fresh pending ROM. + if self.android and type(displayName) == "string" + and not displayName:find("[/\\]") then + love.filesystem.remove(displayName) + end self.importing = nil self.workState = "complete" self.completeVersion = version @@ -740,9 +757,12 @@ function RomImporter:choose(version) if self.workState == "working" then return end self.chooseVersion = version or "red" if self.android then - local name = scanForRom() + -- Prefer a not-yet-imported .gb already in the save dir (USB copy, or a + -- fresh SAF pick). Never reuse an already-imported cart's file -- that + -- was the #167 failure mode (second Choose just re-extracted Red). + local name, data = findPendingRom(self.ready) if name then - self:startData(love.filesystem.read(name), name) + self:startData(data, name) elseif not love.system.pickFile() then -- Picker unavailable (API < 19, or no document-picker app installed): -- fall back to the USB folder-drop path as a friendly notice, not an diff --git a/src/inventory/ItemEffects.lua b/src/inventory/ItemEffects.lua index 0e00be94..7cbed126 100644 --- a/src/inventory/ItemEffects.lua +++ b/src/inventory/ItemEffects.lua @@ -114,10 +114,11 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) local itemDef = data.items[itemId] local name = itemDef and itemDef.name or itemId - -- ItemUseVitamin / ItemUsePPUp / ItemUseEvoStone / ItemUseCoinCase - -- all refuse mid-battle (jp nz, ItemUseNotTime) + -- ItemUseVitamin / ItemUsePPUp / ItemUseEvoStone / ItemUseCoinCase / + -- ItemUseTMHM all refuse mid-battle (jp nz, ItemUseNotTime) if battle and (VITAMINS[itemId] or STONES[itemId] or itemId == "PP_UP" - or itemId == "RARE_CANDY" or itemId == "COIN_CASE") then + or itemId == "RARE_CANDY" or itemId == "COIN_CASE" + or (itemDef and itemDef.machine)) then return "failed", { "OAK: " .. save.player.name .. "!\nThis isn't the\ntime to use that!" } end diff --git a/src/link/LinkState.lua b/src/link/LinkState.lua index 28f98f7a..2023fa48 100644 --- a/src/link/LinkState.lua +++ b/src/link/LinkState.lua @@ -501,6 +501,10 @@ function LinkState:updateTrade(input) require("src.core.Sound").play(game.data, "Trade_Machine") Screens.push(game, "TradeAnim", { sent = sent, received = received, + enemyName = (self.peerName or "TRAINER"), + playerOt = game.save.player.name, + playerOtId = sent.otId or game.save.player.id, + enemyOtId = received.otId, onDone = function() game.stack:push(TextBox.new(game, ("Trade completed!\f%s received\n%s!"):format(game.save.player.name, name), diff --git a/src/pokemon/Pokemon.lua b/src/pokemon/Pokemon.lua index 23017407..8330ed8b 100644 --- a/src/pokemon/Pokemon.lua +++ b/src/pokemon/Pokemon.lua @@ -29,6 +29,34 @@ function Pokemon.movesAtLevel(speciesDef, level) return moves end +-- Day Care retrieve (pokered WriteMonMoves + wLearningMovesFromDayCare): +-- grant learnset moves with startLevel < moveLevel <= newLevel, shifting +-- the oldest slot out when full. Silent — no LearnMove prompts. +function Pokemon.learnMovesFromDayCare(data, mon, speciesDef, startLevel, newLevel) + if not (speciesDef and speciesDef.learnset and mon) then return end + mon.moves = mon.moves or {} + for _, entry in ipairs(speciesDef.learnset) do + local moveLevel = entry.level + if moveLevel > newLevel then break end + if moveLevel > startLevel then + local known = false + for _, mv in ipairs(mon.moves) do + if mv.id == entry.move then known = true break end + end + if not known then + local mdef = data.moves[entry.move] + local slot = { id = entry.move, pp = mdef and mdef.pp or 0 } + if #mon.moves < 4 then + mon.moves[#mon.moves + 1] = slot + else + table.remove(mon.moves, 1) + mon.moves[#mon.moves + 1] = slot + end + end + end + end +end + function Pokemon.new(data, species, level, rng) local def = data.pokemon[species] assert(def, "unknown species " .. tostring(species)) diff --git a/src/render/TextBox.lua b/src/render/TextBox.lua index eeabd45b..08f80f84 100644 --- a/src/render/TextBox.lua +++ b/src/render/TextBox.lua @@ -50,6 +50,7 @@ function TextBox.new(game, text, onDone, opts) self.charIndex = 0 self.shown = {} -- visible lines (max 2), each a list of glyph codes self.waiting = false + self.contAdvance = false self.done = false self.blink = 0 self:beginLine() @@ -65,7 +66,11 @@ TextBox.TOKENS = { PLAYER = function(game) return game.save.player.name or "RED" end, RIVAL = function(game) return game.save.player.rival or "BLUE" end, RAM = function(game, arg) - return arg == "wStringBuffer" and game.stringBuffer or nil + if arg == "wStringBuffer" then return game.stringBuffer end + if arg == "wBoxNumString" then return game.boxNumString end + -- SendNewMonToBox / _SentToBoxText reads the deposited nick here + if arg == "wBoxMonNicks" then return game.boxMonNicks end + return nil end, } @@ -83,31 +88,55 @@ end -- Split marked-up text into pages of lines. \v-scrolled lines become -- additional lines on the same page (the box scrolls them). +-- pages.contBefore[p][i] is true when line i was preceded by \v (cont): +-- pokered ContText waits for A/B + ▼ before scrolling that line in. function TextBox.paginate(text, maxCols) maxCols = maxCols or (Theme.textBox and Theme.textBox.maxCols) or MAX_COLS local pages = {} + local contBefore = {} + local function pushLine(lines, conts, line, wait) + while #line > maxCols do + local cut = maxCols + for i = maxCols, 1, -1 do + if line:sub(i, i) == " " then cut = i break end + end + table.insert(lines, line:sub(1, cut)) + table.insert(conts, wait) + wait = false + line = line:sub(cut + 1) + end + table.insert(lines, line) + table.insert(conts, wait) + end for pageText in (text .. "\f"):gmatch("(.-)\f") do if pageText ~= "" then - local lines = {} - for chunk in (pageText .. "\n"):gmatch("(.-)[\n\v]") do - local line = chunk - -- wrap long lines defensively (the source rarely needs it) - while #line > maxCols do - local cut = maxCols - for i = maxCols, 1, -1 do - if line:sub(i, i) == " " then cut = i break end - end - table.insert(lines, line:sub(1, cut)) - line = line:sub(cut + 1) + local lines, conts = {}, {} + local pos, waitNext = 1, false + while true do + local npos = pageText:find("[\n\v]", pos) + if not npos then + pushLine(lines, conts, pageText:sub(pos), waitNext) + break end - table.insert(lines, line) + pushLine(lines, conts, pageText:sub(pos, npos - 1), waitNext) + waitNext = pageText:sub(npos, npos) == "\v" + pos = npos + 1 + end + if lines[#lines] == "" then + table.remove(lines) + table.remove(conts) + end + if #lines > 0 then + table.insert(pages, lines) + table.insert(contBefore, conts) end - -- drop trailing empty line from the final gmatch round - if lines[#lines] == "" then table.remove(lines) end - if #lines > 0 then table.insert(pages, lines) end end end - if #pages == 0 then pages = { { "" } } end + if #pages == 0 then + pages = { { "" } } + contBefore = { { false } } + end + pages.contBefore = contBefore return pages end @@ -179,10 +208,17 @@ function TextBox:update(dt) if input:wasPressed("a") or input:wasPressed("b") then require("src.core.Sound").play(self.game.data, "Press_AB") self.waiting = false - self.shown = {} - self.pageIndex = self.pageIndex + 1 - self.lineIndex = 1 - self:beginLine() + if self.contAdvance then + -- ContText / ManualTextScroll: keep the box, scroll one line + self.contAdvance = false + self.lineIndex = self.lineIndex + 1 + self:beginLine() + else + self.shown = {} + self.pageIndex = self.pageIndex + 1 + self.lineIndex = 1 + self:beginLine() + end end return end @@ -203,10 +239,19 @@ function TextBox:update(dt) -- line finished local page = self.pages[self.pageIndex] if self.lineIndex < #page then - self.lineIndex = self.lineIndex + 1 - self:beginLine() + local nextIdx = self.lineIndex + 1 + local conts = self.pages.contBefore and self.pages.contBefore[self.pageIndex] + if conts and conts[nextIdx] then + -- pokered : ▼ + WaitForTextScrollButtonPress before scroll + self.waiting = true + self.contAdvance = true + else + self.lineIndex = nextIdx + self:beginLine() + end elseif self.pageIndex < #self.pages then self.waiting = true + self.contAdvance = false else self.done = true end diff --git a/src/script/Commands.lua b/src/script/Commands.lua index 9b23ef81..caf9469e 100644 --- a/src/script/Commands.lua +++ b/src/script/Commands.lua @@ -261,7 +261,9 @@ function Commands.move_player(ctx, dir, tiles) end function Commands.move_npc(ctx, objIndex, dir, tiles) - local npc = ctx.overworld:npcByIndex(objIndex) + local ow = ctx.overworld + if not ow then return end + local npc = ow:npcByIndex(objIndex) if npc then walkEntity(ctx, npc, dir, tiles) end end @@ -308,6 +310,7 @@ end function Commands.move_npc_to(ctx, objIndex, tx, ty) local ow = ctx.overworld + if not ow then return end local npc = ow:npcByIndex(objIndex) if not npc then return end local path = bfsPath(ow.map, npc.cellX, npc.cellY, tx, ty, ow.entities, npc) @@ -339,6 +342,20 @@ function Commands.face_object(ctx, objIndex, dir) if npc then npc.facing = dir end end +-- Instantly relocate an NPC (OaksLabCalcRivalMovementScript / SetSpritePosition1). +-- facing is optional. Headless-safe no-op without an overworld. +function Commands.place_npc(ctx, objIndex, x, y, facing) + local ow = ctx.overworld + if not ow then return end + local npc = ow:npcByIndex(objIndex) + if not npc then return end + npc.cellX, npc.cellY = x, y + npc.px, npc.py = x * 16, y * 16 + npc.moving = false + npc.targetX, npc.targetY = nil, nil + if facing then npc.facing = facing end +end + function Commands.face_npc(ctx) -- make the player face the talking NPC if ctx.npc and ctx.overworld then @@ -485,7 +502,7 @@ function Commands.heal_party(ctx) end -- AskName (engine/menus/naming_screen.asm): yes/no then NamingScreen. --- AddPartyMon only offers this for party mons (box deposits skip it). +-- AddPartyMon and SendNewMonToBox both offer this. -- Preserves ctx.lastCheck so GivePokemon's carry is not clobbered by the -- yes/no result (scripts jump_if_false on give failure afterwards). local function askNickname(ctx, mon) @@ -527,8 +544,9 @@ end -- the asm's carry: true when the mon was given, false when both the -- party and every box are full (that .boxFull path leaves the giver's -- script able to offer again later, e.g. the Celadon Eevee ball). --- Party adds run AskName (AddPartyMon) when a script runner is present; --- mods that pre-set gift.nickname skip the prompt. +-- AskName runs for party (AddPartyMon) and box (SendNewMonToBox) when a +-- script runner is present; mods that pre-set gift.nickname skip it. +-- Box deposits also print SentToBoxText (give_pokemon.asm:36-37). function Commands.give_pokemon(ctx, species, level) -- Native mods can transform a gift before the Pokémon object is created. -- This is intentionally an event rather than a special-case starter hook: @@ -540,14 +558,17 @@ function Commands.give_pokemon(ctx, species, level) end local Pokemon = require("src.pokemon.Pokemon") local Party = require("src.pokemon.Party") + local Boxes = require("src.pokemon.Boxes") local mon = Pokemon.new(ctx.game.data, species, level) if gift.nickname then mon.nickname = gift.nickname end ctx.game.stringBuffer = ctx.game.data.pokemon[species].name or species ctx.pendingPokemonName = species require("src.battle.BattleState").stampOT(ctx.save, mon) local addedToParty = Party.add(ctx.save.party, mon) + local boxNum = nil if not addedToParty then - if not require("src.pokemon.Boxes").deposit(ctx.save, mon) then + boxNum = Boxes.deposit(ctx.save, mon) + if not boxNum then ctx.lastCheck = false return end @@ -558,11 +579,30 @@ function Commands.give_pokemon(ctx, species, level) dex.owned[species] = true end ctx.lastCheck = true - -- AddPartyMon AskName: party only; skip box deposits, mod-set nicknames, - -- and callback-style callers that have no script runner to yield on. - if addedToParty and not gift.nickname and ctx.runner then + ctx.addedToParty = addedToParty + ctx.boxNum = boxNum + -- AskName: both AddPartyMon and SendNewMonToBox; skip mod-set nicks + -- and callback-style callers with no script runner to yield on. + if not gift.nickname and ctx.runner then askNickname(ctx, mon) end + if boxNum then + local name = mon.nickname + or (ctx.game.data.pokemon[species] and ctx.game.data.pokemon[species].name) + or species + ctx.game.boxMonNicks = name + ctx.game.stringBuffer = tostring(boxNum) + if ctx.runner then + Commands.show_text(ctx, "_SentToBoxText") + else + local TextBox = require("src.render.TextBox") + local raw = ctx.game.data.text and ctx.game.data.text._SentToBoxText + ctx.game.stack:push(TextBox.new(ctx.game, raw or ( + "There's no more\nroom for POKéMON!\v" .. name + .. " was\vsent to POKéMON\vBOX " .. boxNum .. " on PC!"), + function() end)) + end + end end function Commands.give_money(ctx, amount) @@ -783,6 +823,8 @@ function Commands.trade(ctx, tradeIndex, doneFlag) local newMon = Pokemon.new(data, trade.get, sent.level) newMon.nickname = trade.nickname newMon.traded = true -- boosted exp + Name Rater refusal (different OT) + newMon.ot = "TRAINER" -- InGameTrade_TrainerString + newMon.otId = (love.math and love.math.random or math.random)(0, 65535) table.remove(party, slot) table.insert(party, newMon) local dex = ctx.save.pokedex @@ -790,9 +832,13 @@ function Commands.trade(ctx, tradeIndex, doneFlag) dex.seen[trade.get] = true dex.owned[trade.get] = true end - -- the trade machine animation (engine/movie/trade.asm) + -- InternalClockTradeAnim (engine/movie/trade.asm) Screens.push(ctx.game, "TradeAnim", { sent = sent, received = newMon, + enemyName = "TRAINER", + playerOt = ctx.save.player.name, + playerOtId = sent.otId or ctx.save.player.id, + enemyOtId = newMon.otId, onDone = function() runner:resume() end, }) runner:yield() diff --git a/src/ui/BagMenu.lua b/src/ui/BagMenu.lua index 193170fb..c125669e 100644 --- a/src/ui/BagMenu.lua +++ b/src/ui/BagMenu.lua @@ -331,6 +331,12 @@ end local function useItem(game, battle, id, list) local def = game.data.items[id] + -- ItemUseTMHM checks wIsInBattle before BootedUpTMText + if battle and def and def.machine then + local _, payload = ItemEffects.use(game.data, game.save, id, nil, battle) + showMessages(game, payload) + return + end if ItemEffects.needsTarget(id, def) and not ItemEffects.isBall(id) then -- TMs/HMs boot up and announce their move before the target picker -- (ItemUseTMHM: BootedUpTMText / BootedUpHMText + TeachMachineMoveText) diff --git a/src/ui/BoxMenu.lua b/src/ui/BoxMenu.lua index ba205a51..adb6e62c 100644 --- a/src/ui/BoxMenu.lua +++ b/src/ui/BoxMenu.lua @@ -3,9 +3,11 @@ -- plus CHANGE BOX. local Boxes = require("src.pokemon.Boxes") +local Font = require("src.render.Font") local ListMenu = require("src.ui.ListMenu") local Menu = require("src.ui.Menu") local Party = require("src.pokemon.Party") +local TextBox = require("src.render.TextBox") local BoxMenu = {} @@ -14,6 +16,11 @@ local function monLabel(game, mon) return ("%s :L%d"):format(mon.nickname or def.name, mon.level) end +local function monName(game, mon) + local def = game.data.pokemon[mon.species] + return mon.nickname or def.name +end + -- Per-mon submenu (bills_pc.asm DisplayDepositWithdrawMenu): the chosen -- action + STATS + CANCEL. STATS shows the status screen and returns -- here; CANCEL/B goes back to the list. @@ -31,8 +38,26 @@ local function monSubmenu(game, action, mon, onAction) }, { tx = 9, ty = 10, tw = 11, th = 8, noSound = true })) end +-- After a successful transfer: close the mon list (BoxMenu stays beneath +-- via keepOpen) and show the taken-out / stored text (jp BillsPCMenu). +local function afterTransfer(game, list, text) + list:close() + game.stack:push(TextBox.new(game, text)) +end + local function withdraw(game) local box = Boxes.active(game.save) + local t = game.data.text + if #box == 0 then + game.stack:push(TextBox.new(game, t._NoMonText + or "What? There are\nno POKéMON here!")) + return + end + if #game.save.party >= Party.MAX then + game.stack:push(TextBox.new(game, t._CantTakeMonText + or "You can't take\nany more POKéMON.\fDeposit POKéMON\nfirst.")) + return + end local items = {} for i, mon in ipairs(box) do table.insert(items, { label = monLabel(game, mon), value = i }) @@ -49,13 +74,29 @@ local function withdraw(game) end table.remove(box, item.value) table.insert(game.save.party, mon) - list:close() + local name = monName(game, mon) + game.stringBuffer = name + require("src.core.Sound").playCry(game.data, mon.species) + afterTransfer(game, list, t._MonIsTakenOutText + or (name .. " is\ntaken out.\vGot " .. name .. ".")) end) end, })) end local function deposit(game) + local t = game.data.text + if #game.save.party <= 1 then + game.stack:push(TextBox.new(game, t._CantDepositLastMonText + or "You can't deposit\nthe last POKéMON!")) + return + end + local box = Boxes.active(game.save) + if #box >= Boxes.CAPACITY then + game.stack:push(TextBox.new(game, t._BoxFullText + or "Oops! This Box is\nfull of POKéMON.")) + return + end local items = {} for i, mon in ipairs(game.save.party) do table.insert(items, { label = monLabel(game, mon), value = i }) @@ -69,40 +110,50 @@ local function deposit(game) list.footer = "You need at least\none POKéMON!" return end - local box = Boxes.active(game.save) - if #box >= Boxes.CAPACITY then + local active = Boxes.active(game.save) + if #active >= Boxes.CAPACITY then list.footer = ("BOX %d is full!"):format(game.save.currentBox) return end table.remove(game.save.party, item.value) - table.insert(box, mon) - list:close() + table.insert(active, mon) + local name = monName(game, mon) + game.stringBuffer = name + game.boxNumString = tostring(game.save.currentBox) + require("src.core.Sound").playCry(game.data, mon.species) + afterTransfer(game, list, t._MonWasStoredText + or (name .. " was\nstored in Box " .. game.boxNumString .. ".")) end) end, })) end --- RELEASE POKéMON (bills_pc.asm .release): confirm, then "Bye [MON]!" +-- RELEASE POKéMON (bills_pc.asm BillsPCRelease): confirm, then "Bye [MON]!". +-- Index by list.index (not stale item.value) after removeCurrent (#171). local function release(game) local box = Boxes.active(game.save) + local t = game.data.text + if #box == 0 then + game.stack:push(TextBox.new(game, t._NoMonText + or "What? There are\nno POKéMON here!")) + return + end local items = {} for i, mon in ipairs(box) do table.insert(items, { label = monLabel(game, mon), value = i }) end game.stack:push(ListMenu.new(game, ("BOX %d (RELEASE)"):format(game.save.currentBox), items, { - onChoose = function(item, list) - local mon = box[item.value] + onChoose = function(_, list) + local mon = box[list.index] if not mon then return end - local def = game.data.pokemon[mon.species] - local name = mon.nickname or def.name + local name = monName(game, mon) local ChoiceBox = require("src.ui.ChoiceBox") - local TextBox = require("src.render.TextBox") game.stack:push(TextBox.new(game, "Once released,\n" .. name .. " is\ngone forever. OK?", function() game.stack:push(ChoiceBox.new(game, function(yes) if not yes then return end - table.remove(box, item.value) + table.remove(box, list.index) require("src.core.Sound").playCry(game.data, mon.species) game.stack:push(TextBox.new(game, ("%s was\nreleased outside.\fBye %s!"):format(name, name))) @@ -129,7 +180,6 @@ local function changeBox(game) -- the original asks BEFORE switching ("When you change a #MON -- BOX, data will be saved. OK?"); declining aborts the change local ChoiceBox = require("src.ui.ChoiceBox") - local TextBox = require("src.render.TextBox") game.stack:push(TextBox.new(game, "When you change a\nPOKéMON BOX, data\nwill be saved. OK?", function() game.stack:push(ChoiceBox.new(game, function(yes) @@ -143,21 +193,48 @@ local function changeBox(game) })) end +-- bills_pc.asm BillsPCMenu chrome: What? text box + BOX No. overlay +local function drawChrome(game) + Font.drawBox(0, 12, 20, 6) + love.graphics.setColor(0, 0, 0, 1) + Font.draw("What?", 8, 112) + Font.drawBox(9, 14, 11, 4) + love.graphics.setColor(0, 0, 0, 1) + Font.draw("BOX No.", 80, 128) + local n = game.save.currentBox or 1 + if n >= 10 then + Font.draw(tostring(n), 136, 128) + else + Font.draw(tostring(n), 144, 128) + end + love.graphics.setColor(1, 1, 1, 1) +end + function BoxMenu.new(game) Boxes.ensure(game.save) -- bills_pc.asm BillsPCMenu: TextBoxBorder at (0,0) with interior - -- 12x10 → total 14x12. "CHANGE BOX" is 10 tiles and needs the - -- full interior (cursor col + label); the old tw=12 right-side box - -- drew the final glyph on the border. - return Menu.new(game, { - { label = "WITHDRAW", onSelect = function() withdraw(game) end }, - { label = "DEPOSIT", onSelect = function() deposit(game) end }, - { label = "RELEASE", onSelect = function() release(game) end }, - { label = "CHANGE BOX", onSelect = function() changeBox(game) end }, + -- 12x10 → total 14x12. "CHANGE BOX" / "WITHDRAW " need the + -- full interior (cursor col + label). keepOpen so WITHDRAW/DEPOSIT/ + -- RELEASE/CHANGE BOX leave this menu underneath (jp BillsPCMenu). + local menu = Menu.new(game, { + { label = "WITHDRAW ", keepOpen = true, + onSelect = function() withdraw(game) end }, + { label = "DEPOSIT ", keepOpen = true, + onSelect = function() deposit(game) end }, + { label = "RELEASE ", keepOpen = true, + onSelect = function() release(game) end }, + { label = "CHANGE BOX", keepOpen = true, + onSelect = function() changeBox(game) end }, { label = "SEE YA!" }, -- Bill's PC runs silent end to end (BIT_NO_MENU_BUTTON_SOUND, -- engine/menus/pokemon_pc.asm) }, { tx = 0, ty = 0, tw = 14, th = 12, noSound = true }) + local baseDraw = menu.draw + function menu:draw() + baseDraw(self) + drawChrome(game) + end + return menu end return BoxMenu diff --git a/src/ui/ChoiceBox.lua b/src/ui/ChoiceBox.lua index 60299fde..b30fda61 100644 --- a/src/ui/ChoiceBox.lua +++ b/src/ui/ChoiceBox.lua @@ -1,4 +1,4 @@ --- YES/NO choice box (top-left of the text box area, like the original). +-- YES/NO choice box (InitYesNoTextBoxParameters: above the text box, right). local Font = require("src.render.Font") local Theme = require("src.ui.Theme") @@ -14,6 +14,11 @@ function ChoiceBox.new(game, onChoose, opts) self.index = (opts and opts.defaultNo) and 2 or 1 -- BIT_NO_MENU_BUTTON_SOUND: PC-session prompts stay silent self.noSound = (opts and opts.noSound) or false + local box = Theme.choiceBox + self.tx = (opts and opts.tx) or box.tx + self.ty = (opts and opts.ty) or box.ty + self.tw = (opts and opts.tw) or box.tw + self.th = (opts and opts.th) or box.th return self end @@ -38,13 +43,13 @@ function ChoiceBox:update(dt) end function ChoiceBox:draw() - local box = Theme.choiceBox - Font.drawBox(box.tx, box.ty, box.tw, box.th) + local tx, ty, tw, th = self.tx, self.ty, self.tw, self.th + Font.drawBox(tx, ty, tw, th) love.graphics.setColor(0, 0, 0, 1) - Font.draw("YES", (box.tx + 2) * 8, (box.ty + 1) * 8) - Font.draw("NO", (box.tx + 2) * 8, (box.ty + 3) * 8) - Font.drawCode(Theme.cursor, (box.tx + 1) * 8, - (box.ty + (self.index == 1 and 1 or 3)) * 8) + Font.draw("YES", (tx + 2) * 8, (ty + 1) * 8) + Font.draw("NO", (tx + 2) * 8, (ty + 3) * 8) + Font.drawCode(Theme.cursor, (tx + 1) * 8, + (ty + (self.index == 1 and 1 or 3)) * 8) love.graphics.setColor(1, 1, 1, 1) end diff --git a/src/ui/ListMenu.lua b/src/ui/ListMenu.lua index bc0ee654..516fa83b 100644 --- a/src/ui/ListMenu.lua +++ b/src/ui/ListMenu.lua @@ -37,8 +37,12 @@ function ListMenu.new(game, title, items, opts) -- text box, a money box sits top-right, and the list shortens to -- clear them (DisplayPokemartDialogue_'s screen) self.dialogue = opts.dialogue + -- PC item lists (players_pc.asm): PrintListMenuEntries shows 4 names + -- and PrintText footers ("How many?", stored/withdrew) use the standard + -- bottom text box — same row budget as the mart, without the money box. + self.messageBox = opts.messageBox self.money = opts.money -- () -> current money for the box - self.rows = opts.dialogue and 4 or ROWS + self.rows = opts.rows or ((opts.dialogue or opts.messageBox) and 4 or ROWS) return self end @@ -137,8 +141,10 @@ function ListMenu:draw() love.graphics.setColor(0, 0, 0, 1) local money = ("¥%d"):format(self.money and self.money() or 0) Font.draw(money, 152 - Font.width(money), 8) - -- the clerk's line in the standard bottom text box; long prompts - -- wrap and keep their last two lines, like the GB's scrolled box + end + if self.dialogue or (self.messageBox and self.footer) then + -- standard bottom text box (PrintText); long prompts wrap and keep + -- their last two lines, like the GB's scrolled box (#115/#174) Font.drawBox(0, 12, 20, 6) love.graphics.setColor(0, 0, 0, 1) if self.footer then @@ -153,8 +159,7 @@ function ListMenu:draw() end end elseif self.footer then - -- PC deposit/withdraw footers use "\n"; draw the last two lines so - -- long item names are not clipped at the screen edge (#115). + -- bare footer (bag money line, etc.) local flat = {} for _, page in ipairs(require("src.render.TextBox").paginate(self.footer)) do for _, line in ipairs(page) do flat[#flat + 1] = line end diff --git a/src/ui/MoveLearnMenu.lua b/src/ui/MoveLearnMenu.lua index 3a16d5de..f107b454 100644 --- a/src/ui/MoveLearnMenu.lua +++ b/src/ui/MoveLearnMenu.lua @@ -22,6 +22,8 @@ function MoveLearnMenu.new(game, mon, newMoveId, onDone) self.newMoveId = newMoveId self.onDone = onDone self.index = 1 + -- forget-list UI only after TryingToLearn YES (learn_move.asm .loop) + self.selecting = false return self end @@ -31,24 +33,32 @@ end -- TryingToLearnText + yes/no (learn_move.asm TryingToLearn): NO offers -- AbandonLearning, whose own NO loops back here (DontAbandonLearning). +-- Use TextBox opts.choice so YES/NO overlays the still-visible prompt; +-- pushing ChoiceBox from onDone pops the text first and leaves YES/NO +-- on "Which move should be forgotten?" (#173). function MoveLearnMenu:enter() local TextBox = require("src.render.TextBox") - local ChoiceBox = require("src.ui.ChoiceBox") local game = self.game local mdef = game.data.moves[self.newMoveId] local name = self:monName() + self.selecting = false game.stack:push(TextBox.new(game, ("%s is\ntrying to learn\v%s!\fBut, %s\ncan't learn more\vthan 4 moves!\f") :format(name, mdef.name, name) .. ("Delete an older\nmove to make room\vfor %s?"):format(mdef.name), - function() - game.stack:push(ChoiceBox.new(game, function(yes) - if not yes then self:confirmAbandon() end - end)) - end)) + nil, { + choice = function(yes) + if yes then + self.selecting = true + else + self:confirmAbandon() + end + end, + })) end function MoveLearnMenu:update(dt) + if not self.selecting then return end local input = self.game.input local n = #self.mon.moves + 1 -- moves + CANCEL if input:wasPressed("up") then @@ -82,15 +92,15 @@ end -- (DontAbandonLearning) function MoveLearnMenu:confirmAbandon() local TextBox = require("src.render.TextBox") - local ChoiceBox = require("src.ui.ChoiceBox") local game = self.game local mdef = game.data.moves[self.newMoveId] + self.selecting = false game.stack:push(TextBox.new(game, - ("Abandon learning\n%s?"):format(mdef.name), function() - game.stack:push(ChoiceBox.new(game, function(yes) - if yes then self:finish(false) else self:enter() end - end)) - end)) + ("Abandon learning\n%s?"):format(mdef.name), nil, { + choice = function(yes) + if yes then self:finish(false) else self:enter() end + end, + })) end function MoveLearnMenu:finish(learned) @@ -98,6 +108,7 @@ function MoveLearnMenu:finish(learned) local game = self.game local name = self:monName() local mdef = game.data.moves[self.newMoveId] + self.selecting = false game.stack:pop() local msg if learned then @@ -114,6 +125,7 @@ function MoveLearnMenu:finish(learned) end function MoveLearnMenu:draw() + if not self.selecting then return end -- single-spaced move list box (TryingToLearn: TextBoxBorder at 4,7) -- plus the port's extra CANCEL row Font.drawBox(4, 5, 16, 7) diff --git a/src/ui/PartyMenu.lua b/src/ui/PartyMenu.lua index 8c455d3e..5de185ca 100644 --- a/src/ui/PartyMenu.lua +++ b/src/ui/PartyMenu.lua @@ -1,8 +1,11 @@ -- Party menu: list the party, choose a member. -- Modes: --- default: A -> submenu (STATS / SWITCH order / CANCEL) --- opts.onSwitch: A -> hand the chosen mon to the callback (battle --- switch, item targeting via opts.pickOnly) +-- default: A -> submenu (STATS / SWITCH / field moves) +-- opts.onSwitch + opts.battle (voluntary PKMN): A -> SWITCH / STATS / +-- CANCEL (core.asm PartyMenuOrRockOrRun), then onSwitch on SWITCH +-- opts.onSwitch + opts.forceSwitch: A -> onSwitch immediately +-- (ChooseNextMon / SHIFT free-switch) +-- opts.pickOnly + opts.onSwitch: A -> onSwitch (item / script target) -- opts.onCancel: fired when the menu closes without a pick (B) -- Pops itself on B. @@ -125,6 +128,7 @@ function PartyMenu.new(game, opts) self.onSwitch = opts.onSwitch self.onCancel = opts.onCancel self.pickOnly = opts.pickOnly + self.forceSwitch = opts.forceSwitch self.battle = opts.battle self.party = opts.party -- link battles pass their clamped copies self.swapFrom = nil @@ -156,7 +160,17 @@ function PartyMenu:update(dt) -- hook-injected entries carry a callback instead of an action id entry.onSelect(mon, self.game) elseif action == "stats" then + -- battle and field alike return to the party list afterwards + -- (core.asm .partyMenuWasSelected) Screens.push(self.game, "SummaryMenu", mon) + elseif action == "battle_switch" then + self.game.stack:pop() + self.onSwitch(mon) + return + elseif action == "cancel" then + self.game.stack:pop() + if self.onCancel then self.onCancel() end + return elseif action == "switch" then self.swapFrom = self.index elseif action == "fly" then @@ -341,58 +355,66 @@ function PartyMenu:update(dt) require("src.core.Sound").play(self.game.data, "Swap") end self.swapFrom = nil - elseif self.onSwitch then + elseif self.onSwitch and (self.forceSwitch or self.pickOnly or not self.battle) then self.game.stack:pop() self.onSwitch(mon) else self.submenu = true self.subIndex = 1 - -- STATS/SWITCH plus this mon's field moves (start_sub_menus.asm - -- builds the same dynamic list) - local items = { { label = "STATS", action = "stats" }, - { label = "SWITCH", action = "switch" } } + local items local ow = self.game.overworld - -- Field moves (HMs/TMs) are usable out of battle even when the mon - -- is fainted -- Gen 1 does not require HP for Cut/Fly/Surf/etc. - -- 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")) - for _, mv in ipairs(mon.moves) do - if mv.id == "FLY" and outside - and self.game.save.inventory.THUNDERBADGE then - table.insert(items, { label = "FLY", action = "fly" }) - elseif mv.id == "FLASH" and ow.dark - and self.game.save.inventory.BOULDERBADGE then - table.insert(items, { label = "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). - table.insert(items, { label = "CUT", action = "cut" }) - elseif mv.id == "SURF" and self.game.save.inventory.SOULBADGE then - table.insert(items, { label = "SURF", action = "surf" }) - elseif mv.id == "STRENGTH" and self.game.save.inventory.RAINBOWBADGE then - table.insert(items, { label = "STRENGTH", action = "strength" }) - elseif mv.id == "SOFTBOILED" then - table.insert(items, { label = "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 = "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 = "DIG", action = "escape" }) + if self.battle and self.onSwitch then + -- SwitchStatsCancelText (core.asm PartyMenuOrRockOrRun) + items = { { label = "SWITCH", action = "battle_switch" }, + { label = "STATS", action = "stats" }, + { label = "CANCEL", action = "cancel" } } + else + -- STATS/SWITCH plus this mon's field moves (start_sub_menus.asm + -- builds the same dynamic list) + items = { { label = "STATS", action = "stats" }, + { label = "SWITCH", action = "switch" } } + -- Field moves (HMs/TMs) are usable out of battle even when the mon + -- is fainted -- Gen 1 does not require HP for Cut/Fly/Surf/etc. + -- 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")) + for _, mv in ipairs(mon.moves) do + if mv.id == "FLY" and outside + and self.game.save.inventory.THUNDERBADGE then + table.insert(items, { label = "FLY", action = "fly" }) + elseif mv.id == "FLASH" and ow.dark + and self.game.save.inventory.BOULDERBADGE then + table.insert(items, { label = "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). + table.insert(items, { label = "CUT", action = "cut" }) + elseif mv.id == "SURF" and self.game.save.inventory.SOULBADGE then + table.insert(items, { label = "SURF", action = "surf" }) + elseif mv.id == "STRENGTH" and self.game.save.inventory.RAINBOWBADGE then + table.insert(items, { label = "STRENGTH", action = "strength" }) + elseif mv.id == "SOFTBOILED" then + table.insert(items, { label = "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 = "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 = "DIG", action = "escape" }) + end end end end diff --git a/src/ui/PlayerPC.lua b/src/ui/PlayerPC.lua index 6a9f9379..1f11aebe 100644 --- a/src/ui/PlayerPC.lua +++ b/src/ui/PlayerPC.lua @@ -71,6 +71,7 @@ end local function withdraw(game) local pc = game.save.pcItems game.stack:push(ListMenu.new(game, "WITHDRAW ITEM", buildItems(game, pc), { + messageBox = true, onChoose = function(item, list) askQuantity(game, list, pc[item.value] or 1, item.value, function(qty) local Bag = require("src.inventory.Bag") @@ -107,6 +108,7 @@ local function deposit(game) if not Bag.isBadge(id) then depositable[id] = count end end game.stack:push(ListMenu.new(game, "DEPOSIT ITEM", buildItems(game, depositable), { + messageBox = true, onChoose = function(item, list) askQuantity(game, list, inv[item.value] or 1, item.value, function(qty) if pcFull(game, pc, item.value) then @@ -126,6 +128,7 @@ end local function toss(game) local pc = game.save.pcItems game.stack:push(ListMenu.new(game, "TOSS ITEM", buildItems(game, pc), { + messageBox = true, onChoose = function(item, list) local def = game.data.items[item.value] if (def and def.keyItem) or item.value:find("^HM_") then @@ -161,9 +164,9 @@ function PlayerPC.new(game) { label = "DEPOSIT ITEM", onSelect = function() deposit(game) end }, { label = "TOSS ITEM", onSelect = function() toss(game) end }, { label = "LOG OFF" }, - -- the whole PC session runs silent (BIT_NO_MENU_BUTTON_SOUND, - -- engine/menus/players_pc.asm PlayersPCMenu) - }, { tx = 3, ty = 0, tw = 17, th = 10, noSound = true }) + -- silent PC session (BIT_NO_MENU_BUTTON_SOUND); players_pc.asm + -- PlayersPCMenu TextBoxBorder (0,0) b=8 c=14 → 16x10 + }, { tx = 0, ty = 0, tw = 16, th = 10, noSound = true }) end return PlayerPC diff --git a/src/ui/QuantityBox.lua b/src/ui/QuantityBox.lua index e7a73705..b763d4f1 100644 --- a/src/ui/QuantityBox.lua +++ b/src/ui/QuantityBox.lua @@ -40,15 +40,19 @@ function QuantityBox:update(dt) end function QuantityBox:draw() - local w = self.unitPrice and 11 or 7 - local tx = 20 - w - 1 - Font.drawBox(tx, 13, w, 3) + -- DisplayChooseQuantityMenu (home/list_menu.asm): non-priced box at + -- hlcoord 15,9 (interior 3x1); priced at hlcoord 7,9 (interior 11x1). + -- TextBoxBorder adds the frame, so outer size is +2 on each axis. + local tw = self.unitPrice and 13 or 5 + local tx = self.unitPrice and 7 or 15 + local ty = 9 + Font.drawBox(tx, ty, tw, 3) love.graphics.setColor(0, 0, 0, 1) local s = ("×%02d"):format(self.qty) -- the multiply glyph tile if self.unitPrice then s = s .. (" ¥%d"):format(self.qty * self.unitPrice) end - Font.draw(s, (tx + 1) * 8, 14 * 8) + Font.draw(s, (tx + 1) * 8, (ty + 1) * 8) love.graphics.setColor(1, 1, 1, 1) end diff --git a/src/ui/Theme.lua b/src/ui/Theme.lua index c38181f2..c72992d1 100644 --- a/src/ui/Theme.lua +++ b/src/ui/Theme.lua @@ -15,7 +15,8 @@ local Theme = { cols = Renderer.WIDTH / 8, rows = Renderer.HEIGHT / 8, textBox = { tx = 0, ty = 12, tw = 20, th = 6, maxCols = 18 }, - choiceBox = { tx = 0, ty = 7, tw = 6, th = 5 }, + -- InitYesNoTextBoxParameters / AskName: hlcoord 14, 7 (YES_NO_MENU 4x3) + choiceBox = { tx = 14, ty = 7, tw = 6, th = 5 }, } function Theme.load(data) diff --git a/src/ui/TradeAnim.lua b/src/ui/TradeAnim.lua index 137854ee..7ead3d55 100644 --- a/src/ui/TradeAnim.lua +++ b/src/ui/TradeAnim.lua @@ -1,8 +1,7 @@ --- Link/in-game trade cinematic (engine/movie/trade.asm, trade2.asm): --- the traded POKéMON rises away with its cry and a goodbye, then the --- received one descends with its cry and "take good care" text. --- A skips the slide animations ahead. Calls onDone() after popping. +-- InternalClockTradeAnim (engine/movie/trade.asm): cable-trade cinematic +-- used by in-game NPC trades and internally-clocked link trades. +local Font = require("src.render.Font") local Sound = require("src.core.Sound") local TextBox = require("src.render.TextBox") @@ -10,13 +9,22 @@ local TradeAnim = {} TradeAnim.__index = TradeAnim TradeAnim.isOpaque = true --- SGB: generic whole-screen palette (SET_PAL_GENERIC) function TradeAnim:sgbPalettes(game) return require("src.render.PaletteFX").wholeNamed(game.data, "MEWMON") end -local SLIDE_FRAMES = 90 -local REST_Y = 44 -- resting top of the sprite, roughly screen centre +local DEFAULT_ART = { + gameBoy = "assets/generated/trade/game_boy.png", + openCable = "assets/generated/trade/open_cable.png", + cableHoriz = "assets/generated/trade/cable_horiz.png", + cableConn = "assets/generated/trade/cable_conn.png", + cableVert = "assets/generated/trade/cable_vert.png", + cableCorner = "assets/generated/trade/cable_corner.png", + cableEnd = "assets/generated/trade/cable_end.png", + cableBall = "assets/generated/trade/cable_ball.png", + cableBallAlt = "assets/generated/trade/cable_ball_alt.png", + bubble = "assets/generated/trade/bubble.png", +} local function tryImage(path) if not path then return nil end @@ -29,11 +37,46 @@ local function nameOf(game, mon) return mon.nickname or (def and def.name) or mon.species end +local function speciesName(game, mon) + local def = game.data.pokemon[mon.species] + return (def and def.name) or mon.species +end + +local function dexOf(game, mon) + local def = game.data.pokemon[mon.species] + return def and def.dex or 0 +end + local function spriteOf(game, mon) local def = game.data.pokemon[mon.species] return tryImage(def and def.spriteFront) end +local function expand(game, key, subs) + local raw = game.data.text and game.data.text[key] + if not raw then return key end + for token, value in pairs(subs or {}) do + raw = raw:gsub("{" .. token .. "}", value) + end + return TextBox.substitute(game, raw) +end + +-- InternalClockTradeFuncSequence +local SEQ = { + "show_player", + "open_cable", + "ball_enter", + "transfer_lr", + "delay", + "went_to", + "for_sends", + "farewell", + "transfer_rl", + "open_cable2", + "show_enemy", + "done", +} + function TradeAnim.new(game, opts) opts = opts or {} local self = setmetatable({}, TradeAnim) @@ -41,62 +84,401 @@ function TradeAnim.new(game, opts) self.sent = opts.sent self.received = opts.received self.onDone = opts.onDone + self.enemyName = opts.enemyName or (self.received and self.received.ot) or "TRAINER" + self.playerName = (game.save.player and game.save.player.name) or "RED" + self.playerOt = opts.playerOt or self.playerName + self.playerOtId = opts.playerOtId + or (self.sent and self.sent.otId) + or (game.save.player and game.save.player.id) + or 0 + self.enemyOtId = opts.enemyOtId + or (self.received and self.received.otId) + or love.math.random(0, 65535) + + local art = (game.data.field and game.data.field.tradeArt) or DEFAULT_ART + self.img = { + gameBoy = tryImage(art.gameBoy or DEFAULT_ART.gameBoy), + openCable = tryImage(art.openCable or DEFAULT_ART.openCable), + cableHoriz = tryImage(art.cableHoriz or DEFAULT_ART.cableHoriz), + cableConn = tryImage(art.cableConn or DEFAULT_ART.cableConn), + cableVert = tryImage(art.cableVert or DEFAULT_ART.cableVert), + cableCorner = tryImage(art.cableCorner or DEFAULT_ART.cableCorner), + cableEnd = tryImage(art.cableEnd or DEFAULT_ART.cableEnd), + cableBall = tryImage(art.cableBall or DEFAULT_ART.cableBall), + cableBallAlt = tryImage(art.cableBallAlt or DEFAULT_ART.cableBallAlt), + bubble = tryImage(art.bubble or DEFAULT_ART.bubble), + } self.sentSprite = spriteOf(game, self.sent) - self.receivedSprite = spriteOf(game, self.received) - self.phase = "out" + self.recvSprite = spriteOf(game, self.received) + + self.seq = 1 + self.phase = SEQ[1] self.t = 0 + self.scx = 0 + self.ballX = 0 + self.ballY = 0 + self.monX = 0 + self.monY = 0 + self.flash = false + self.monVisible = true + self.waitingText = false + self.cableFlash = false return self end function TradeAnim:enter() - Sound.playCry(self.game.data, self.sent.species) + Sound.play(self.game.data, "Trade_Machine") +end + +function TradeAnim:advance() + self.seq = self.seq + 1 + self.phase = SEQ[self.seq] or "done" + self.t = 0 + self.scx = 0 + self.sub = nil + self.flash = false + self.cableFlash = false + self.poof = nil + if self.phase == "done" then + self.game.stack:pop() + if self.onDone then self.onDone() end + elseif self.phase == "show_enemy" then + self.monVisible = false + elseif self.phase == "transfer_lr" then + -- wBaseCoord $54, $1c OAM -> screen (76, 12) + self.monX, self.monY = 76, 12 + elseif self.phase == "transfer_rl" then + -- wBaseCoord $64, $44 OAM -> screen (92, 52), right GB on screen + self.monX, self.monY = 92, 52 + self.scx = 160 + elseif self.phase == "ball_enter" then + -- lb bc, $20, $60: b = Y, c = X (Trade_AnimateBallEnteringLinkCable) + self.ballX, self.ballY = 0x60, 0x20 + elseif self.phase == "open_cable" or self.phase == "open_cable2" then + -- SCX $a0 -> $f0: cable slides in from the right, open end rests at x64 + self.scx = 0x50 + Sound.play(self.game.data, "Heal_HP") + end +end + +function TradeAnim:say(text, delay, thenFn) + self.waitingText = true + self.game.stack:push(TextBox.new(self.game, text, function() + self.waitingText = false + if thenFn then thenFn() else self:advance() end + end, { auto = { delay = delay or 80 } })) +end + +function TradeAnim:skipHeld() + return self.game.input:wasPressed("a") or self.game.input:isDown("a") end function TradeAnim:update(dt) - local input = self.game.input - if self.phase == "out" or self.phase == "in" then - self.t = self.t + 1 - if input:wasPressed("a") then self.t = SLIDE_FRAMES end - if self.t < SLIDE_FRAMES then return end - if self.phase == "out" then - self.phase = "goodbye" - self.game.stack:push(TextBox.new(self.game, - ("Goodbye %s!"):format(nameOf(self.game, self.sent)), - function() - self.phase = "in" - self.t = 0 - Sound.playCry(self.game.data, self.received.species) - end)) + if self.waitingText or self.phase == "done" then return end + local skip = self.game.input:wasPressed("a") + self.t = self.t + 1 + local p = self.phase + + if p == "show_player" then + -- slide from SCX $86 -> 0, hold 80, poof, cry (Trade_ShowPlayerMon) + if not self.sub then self.sub = "slide" end + if self.sub == "slide" then + self.scx = math.max(0, 0x86 - self.t * 2) + if skip or self.scx <= 0 then + self.scx = 0 + self.sub = "hold" + self.t = 0 + end + elseif self.sub == "hold" then + if skip or self.t >= 80 then + self.sub = "poof" + self.t = 0 + self.monVisible = false + Sound.play(self.game.data, "Ball_Poof") + end + elseif self.sub == "poof" then + self.poof = math.max(0, 16 - self.t) + if skip or self.t >= 16 then + Sound.playCry(self.game.data, self.sent.species) + self.sub = nil + self:advance() + end + end + + elseif p == "open_cable" or p == "open_cable2" then + -- 20 steps of 4px, matching the SCX loop + self.scx = math.max(0, 0x50 - self.t * 4) + if skip or self.scx <= 0 then + self.scx = 0 + self:advance() + end + + elseif p == "ball_enter" then + -- TRADE_BALL_SHAKE then ball rides the cable; X from $60 toward $a0 + if self.t < 20 then + if skip then self.t = 20 end + return + end + local step = self.t - 20 + if step % 3 == 0 then + self.ballX = 0x60 + math.floor(step / 3) * 4 + self.flash = not self.flash + if self.ballX < 0xA0 then Sound.play(self.game.data, "Tink") end + end + if skip then self.ballX = 0xA0 end + if self.ballX >= 0xA0 then self:advance() end + + elseif p == "transfer_lr" then + -- scroll left GB off while the mon rides the cable, then the sprite + -- itself moves right and down into the right GB (Trade_AnimMonMoveVertical) + if self.t <= 80 then + self.scx = math.min(160, self.t * 2) + self.monX, self.monY = 76, 12 + elseif self.t <= 80 + 32 then + self.monX = 76 + math.floor((self.t - 80) / 8) * 4 + elseif self.t <= 80 + 64 then + self.monX = 92 + self.monY = 12 + math.floor((self.t - 112) / 8) * 10 else - self.phase = "takecare" - self.game.stack:push(TextBox.new(self.game, - ("Take good care\nof %s!"):format(nameOf(self.game, self.received)), - function() - self.game.stack:pop() - if self.onDone then self.onDone() end - end)) + self:advance() + return + end + if self.t % 8 == 0 then self.cableFlash = not self.cableFlash end + if skip then self:advance() end + + elseif p == "transfer_rl" then + -- mirror: sprite climbs out of the right GB, then the screen scrolls + -- back to the left GB + if self.t <= 32 then + self.scx = 160 + self.monX, self.monY = 92, 52 - math.floor(self.t / 8) * 10 + elseif self.t <= 64 then + self.monX = 92 - math.floor((self.t - 32) / 8) * 4 + self.monY = 12 + elseif self.t <= 64 + 80 then + self.scx = math.max(0, 160 - (self.t - 64) * 2) + self.monX, self.monY = 76, 12 + else + self:advance() + return + end + if self.t % 8 == 0 then self.cableFlash = not self.cableFlash end + if skip then self:advance() end + + elseif p == "delay" then + if skip or self.t >= 100 then self:advance() end + + elseif p == "went_to" then + local text = expand(self.game, "_TradeWentToText", { + ["RAM:wStringBuffer"] = speciesName(self.game, self.sent), + ["RAM:wLinkEnemyTrainerName"] = self.enemyName, + }) + self:say(text, 200) + + elseif p == "for_sends" then + local a = expand(self.game, "_TradeForText", { + ["RAM:wStringBuffer"] = speciesName(self.game, self.sent), + }) + local b = expand(self.game, "_TradeSendsText", { + ["RAM:wLinkEnemyTrainerName"] = self.enemyName, + ["RAM:wNameBuffer"] = nameOf(self.game, self.received), + }) + self.waitingText = true + self.game.stack:push(TextBox.new(self.game, a, function() + self.game.stack:push(TextBox.new(self.game, b, function() + self.waitingText = false + self:advance() + end, { auto = { delay = 80 } })) + end, { auto = { delay = 80 } })) + + elseif p == "farewell" then + local a = expand(self.game, "_TradeWavesFarewellText", { + ["RAM:wLinkEnemyTrainerName"] = self.enemyName, + }) + local b = expand(self.game, "_TradeTransferredText", { + ["RAM:wNameBuffer"] = nameOf(self.game, self.received), + }) + self.waitingText = true + self.game.stack:push(TextBox.new(self.game, a, function() + self.game.stack:push(TextBox.new(self.game, b, function() + self.waitingText = false + self:advance() + end, { auto = { delay = 80 } })) + end, { auto = { delay = 80 } })) + + elseif p == "show_enemy" then + if self.t == 1 then + Sound.play(self.game.data, "Ball_Poof") + self.monVisible = true + elseif self.t == 20 then + Sound.playCry(self.game.data, self.received.species) + elseif self.t >= 120 or skip then + local text = expand(self.game, "_TradeTakeCareText", { + ["RAM:wNameBuffer"] = nameOf(self.game, self.received), + }) + self:say(text, 80) end end end +local function drawCableHoriz(self, y, x0, x1) + if self.img.cableHoriz then + love.graphics.draw(self.img.cableHoriz, x0 - (self.scx % 8), y) + else + love.graphics.setColor(0.2, 0.2, 0.2, 1) + love.graphics.rectangle("fill", x0, y + 1, math.max(0, x1 - x0), 6) + love.graphics.setColor(1, 1, 1, 1) + end +end + +function TradeAnim:drawMonInfo(mon, ot, otId, boxTy) + -- Trade_PrintPlayerMonInfoText: rows +0/+2/+4/+6 from the box top; the + -- No. line replaces part of the top border (hlcoord 5, 0 in trade2.asm) + Font.drawBox(4, boxTy, 12, 8) + local y0 = boxTy * 8 + local no = ("No.%03d"):format(dexOf(self.game, mon)) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.rectangle("fill", 56, y0, Font.width(no), 8) + love.graphics.setColor(0, 0, 0, 1) + Font.draw(no, 56, y0) + Font.draw(speciesName(self.game, mon), 40, y0 + 16) + Font.draw("OT/" .. (ot or "????"), 40, y0 + 32) + Font.draw(("IDNo.%05d"):format(otId or 0), 40, y0 + 48) + love.graphics.setColor(1, 1, 1, 1) +end + +function TradeAnim:drawIconInBubble(mon, x, y) + local spr = (mon.species == self.received.species) and self.recvSprite or self.sentSprite + if spr then + local sw, sh = spr:getDimensions() + local s = 16 / math.max(sw, sh) + love.graphics.draw(spr, x, y, 0, s, s) + else + love.graphics.setColor(0, 0, 0, 1) + love.graphics.rectangle("fill", x + 4, y + 4, 8, 8) + love.graphics.setColor(1, 1, 1, 1) + end + if self.img.bubble then + if not self.bubbleQuad then + local iw, ih = self.img.bubble:getDimensions() + self.bubbleQuad = love.graphics.newQuad(0, 0, 16, 16, iw, ih) + self.bubbleQuadAlt = ih >= 32 + and love.graphics.newQuad(0, 16, 16, 16, iw, ih) + or self.bubbleQuad + end + local q = self.cableFlash and self.bubbleQuadAlt or self.bubbleQuad + love.graphics.draw(self.img.bubble, q, x - 8, y - 8) + end +end + +function TradeAnim:drawGameBoy(x, y) + if self.img.gameBoy then + love.graphics.draw(self.img.gameBoy, x, y) + else + love.graphics.setColor(0, 0, 0, 1) + love.graphics.rectangle("line", x, y, 48, 64) + love.graphics.setColor(1, 1, 1, 1) + end +end + +function TradeAnim:drawLeftGB() + -- cable from GB to the right edge + if self.img.cableConn then + love.graphics.draw(self.img.cableConn, 88, 32) + end + drawCableHoriz(self, 32, 96, 160) + self:drawGameBoy(40, 24) + Font.drawBox(4, 12, 9, 4) + love.graphics.setColor(0, 0, 0, 1) + Font.draw(self.playerName, 40, 112) + love.graphics.setColor(1, 1, 1, 1) +end + +function TradeAnim:drawRightGB() + drawCableHoriz(self, 32, 0, 112) + if self.img.cableCorner then love.graphics.draw(self.img.cableCorner, 112, 32) end + if self.img.cableVert then + for i = 1, 4 do + love.graphics.draw(self.img.cableVert, 120, 40 + (i - 1) * 8) + end + end + if self.img.cableEnd then love.graphics.draw(self.img.cableEnd, 112, 72) end + if self.img.cableConn then love.graphics.draw(self.img.cableConn, 104, 72) end + self:drawGameBoy(56, 64) + Font.drawBox(6, 0, 9, 4) + love.graphics.setColor(0, 0, 0, 1) + Font.draw(self.enemyName, 56, 16) + love.graphics.setColor(1, 1, 1, 1) +end + function TradeAnim:draw() love.graphics.setColor(1, 1, 1, 1) love.graphics.rectangle("fill", 0, 0, 160, 144) - local sprite, y - if self.phase == "out" then - -- the sent mon rises up and off the screen - sprite = self.sentSprite - y = REST_Y - math.floor((self.t / SLIDE_FRAMES) * (REST_Y + 60)) - elseif self.phase == "in" or self.phase == "takecare" then - -- the received mon descends into place - sprite = self.receivedSprite - local t = self.phase == "in" and self.t or SLIDE_FRAMES - y = -60 + math.floor((t / SLIDE_FRAMES) * (REST_Y + 60)) - end - if sprite and y then - local w = sprite:getWidth() - love.graphics.draw(sprite, math.floor((160 - w) / 2), y) + local p = self.phase + + if p == "show_player" then + love.graphics.push() + love.graphics.translate(-self.scx, 0) + -- mon pic on the BG at hlcoord 7, 2; info box on the window at + -- hWY $50, so it sits in the bottom half of the screen + if self.monVisible and self.sentSprite then + love.graphics.draw(self.sentSprite, 56, 16) + end + self:drawMonInfo(self.sent, self.playerOt, self.playerOtId, 10) + love.graphics.pop() + if self.poof and self.poof > 0 then + love.graphics.setColor(0, 0, 0, self.poof / 16) + love.graphics.circle("fill", 80, 40, 20 - (self.poof or 0)) + love.graphics.setColor(1, 1, 1, 1) + end + + elseif p == "open_cable" or p == "open_cable2" then + love.graphics.push() + love.graphics.translate(self.scx, 0) + if self.img.openCable then + love.graphics.draw(self.img.openCable, 64, 16) -- open end at SCX $f0 + end + love.graphics.pop() + + elseif p == "ball_enter" then + if self.img.openCable then + love.graphics.draw(self.img.openCable, 64, 16) + end + -- OAM coords carry a (+8, +16) hardware offset + local ball = self.flash and (self.img.cableBallAlt or self.img.cableBall) + or self.img.cableBall + if ball then + love.graphics.draw(ball, self.ballX - 8, self.ballY - 16) + else + love.graphics.setColor(0, 0, 0, 1) + love.graphics.circle("fill", self.ballX, self.ballY - 8, 6) + love.graphics.setColor(1, 1, 1, 1) + end + + elseif p == "transfer_lr" or p == "transfer_rl" then + -- two-screen world: left GB scene at x0, right GB scene at x160; + -- the mon icon stays in screen space like the OAM sprite it ports + love.graphics.push() + love.graphics.translate(-self.scx, 0) + self:drawLeftGB() + love.graphics.translate(160, 0) + self:drawRightGB() + love.graphics.pop() + local mon = (p == "transfer_lr") and self.sent or self.received + self:drawIconInBubble(mon, self.monX, self.monY) + if self.cableFlash then + love.graphics.setColor(1, 1, 1, 0.15) + love.graphics.rectangle("fill", 0, 32, 160, 8) + love.graphics.setColor(1, 1, 1, 1) + end + + elseif p == "show_enemy" then + if self.monVisible and self.recvSprite then + love.graphics.draw(self.recvSprite, 56, 16) + end + self:drawMonInfo(self.received, self.enemyName, self.enemyOtId, 10) end + -- went_to / for_sends / farewell / delay: cleared window; TextBox draws end return TradeAnim diff --git a/src/ui/TrainerCard.lua b/src/ui/TrainerCard.lua index 7f7f78a8..7493d2c5 100644 --- a/src/ui/TrainerCard.lua +++ b/src/ui/TrainerCard.lua @@ -34,7 +34,8 @@ function TrainerCard.new(game) local self = setmetatable({ game = game }, TrainerCard) local img = tryImage("assets/generated/trainer_card/badges.png") if img then - -- 8 pairs of [gym leader face, badge] + -- badges.2bpp is 8 stacked [face, badge] pairs (DrawBadges FaceBadgeTiles) + self.faces = { img = img, quads = quads16(img, 8, 32, 0, 0) } self.badges = { img = img, quads = quads16(img, 8, 32, 0, 16) } end local nums = tryImage("assets/generated/trainer_card/badge_numbers.png") @@ -124,7 +125,7 @@ function TrainerCard:draw() love.graphics.setColor(0, 0, 0, 1) end - -- numbered badge grid (rows 11-17): earned solid, unearned dimmed + -- numbered badge grid (rows 11-17): face by default, badge when owned self:frameBox(0, 11, 20, 7) local badges = Badges.list(self.game.data) for i = 1, #badges do @@ -136,12 +137,11 @@ function TrainerCard:draw() love.graphics.setColor(1, 1, 1, 1) love.graphics.draw(self.nums.img, self.nums.quads[i - 1], tx, ty) end - if self.badges and self.badges.quads[i - 1] - and save.inventory[Badges.itemFor(badges[i])] then - -- unearned badge slots stay blank (DrawBadges) + if self.faces and self.faces.quads[i - 1] then love.graphics.setColor(1, 1, 1, 1) - love.graphics.draw(self.badges.img, self.badges.quads[i - 1], - tx + 4, ty + 6) + local owned = save.inventory[Badges.itemFor(badges[i])] + local sheet = owned and self.badges or self.faces + love.graphics.draw(sheet.img, sheet.quads[i - 1], tx + 4, ty + 6) end end love.graphics.setColor(1, 1, 1, 1) diff --git a/src/world/FieldDefaults.lua b/src/world/FieldDefaults.lua index 4b1027b6..f26dc38b 100644 --- a/src/world/FieldDefaults.lua +++ b/src/world/FieldDefaults.lua @@ -116,6 +116,16 @@ FieldDefaults.FIELD = { badgeGates = { ROUTE_22_GATE = { passedFlag = "PASSED_ROUTE22_GATE" } }, -- VermilionGymSetDoorTile opens the motorized door once both locks are hit hiddenExtras = { + -- PrintTrashText bins (#188); seeds stale caches missing the key + printTrash = { + SS_ANNE_KITCHEN = { + { x = 13, y = 5, facing = "down" }, + { x = 13, y = 7, facing = "down" }, + }, + VERMILION_GYM = { + { x = 6, y = 1, facing = "down" }, + }, + }, trashCans = { map = "VERMILION_GYM", doorBlock = { bx = 2, by = 2, block = 5 } }, }, diff --git a/src/world/Map.lua b/src/world/Map.lua index 7c261732..2b966af4 100644 --- a/src/world/Map.lua +++ b/src/world/Map.lua @@ -223,6 +223,12 @@ function Map:setBlock(bx, by, block) self.def.blocks[by * self.def.width + bx + 1] = block end +-- true if the cell's collision tile is a door tile +-- (pokered IsPlayerStandingOnDoorTile) +function Map:isDoorTileCell(cx, cy) + return self.doorTiles[self:cellTile(cx, cy)] or false +end + -- true if the cell's collision tile is a door or warp-activating tile function Map:isWarpTileCell(cx, cy) local t = self:cellTile(cx, cy) diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index d4c99d85..0772056f 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -718,16 +718,13 @@ function OverworldState:update(dt) end if self.healAnim then local ha = self.healAnim - local Music = require("src.core.Music") - if ha.jinglePlaying and not ha.jingleDone then - ha.jingleDone = not Music.oneShotPlaying() - end local ev = OverworldState.stepHealAnim(ha) if ev == "ball" then require("src.core.Sound").play(Game.data, "Healing_Machine") elseif ev == "jingle" then - ha.jinglePlaying = Music.playOnce(Game.data, "Music_PkmnHealed") - ha.jingleDone = not ha.jinglePlaying + -- playOnce restores the map theme when the jingle ends; we no longer + -- block the fighting-fit text on that (#157) + require("src.core.Music").playOnce(Game.data, "Music_PkmnHealed") elseif ev == "done" then local done = ha.onDone self.healAnim = nil @@ -1511,6 +1508,15 @@ function OverworldState:tryHiddenObject(fx, fy) end end + -- PrintTrashText: SS Anne kitchen + Vermilion Gym non-puzzle can + for _, h in ipairs(extras.printTrash and extras.printTrash[self.map.id] or {}) do + if h.x == fx and h.y == fy then + Game.stack:push(TextBox.new(Game, txt._VermilionGymTrashText + or "Nope, there's\nonly trash here.")) + return true + end + end + -- the Vermilion Gym trash can lock puzzle if self.map.id == "VERMILION_GYM" then for _, h in ipairs(extras.trashCans.cans or {}) do @@ -2190,13 +2196,10 @@ function OverworldState:dexRating() Game.stack:push(TextBox.new(Game, completion .. "\f" .. rating)) end --- AnimateHealingMachine (engine/overworld/healing_machine.asm): the --- monitor lights, then one ball per party mon appears every 30 frames --- (SFX_HEALING_MACHINE each); the healed jingle plays while the machine --- sprites flash 8 times (an OBP1 xor every 10 frames), then a 32-frame --- beat once the jingle ends. Pure per-frame step over the ha table --- ({ balls, lit, timer, visible, jingleDone }); returns "ball"/"jingle"/ --- "done" when the caller must fire the matching side effect. +-- AnimateHealingMachine (engine/overworld/healing_machine.asm): balls +-- every 30 frames, then jingle + FlashSprite8Times (8 x 10). #157: skip +-- pokered's post-flash .waitLoop2 / DelayFrames 32 so fighting-fit is +-- immediate; jingle still plays and restoreMap runs when it ends. function OverworldState.stepHealAnim(ha) ha.timer = ha.timer + 1 ha.phase = ha.phase or "balls" @@ -2219,17 +2222,11 @@ function OverworldState.stepHealAnim(ha) ha.visible = not ha.visible ha.flashes = ha.flashes + 1 if ha.flashes >= 8 then - ha.phase = "wait" ha.visible = true + ha.phase = "done" + return "done" end end - elseif ha.phase == "wait" then - -- .waitLoop2: hold until the jingle ends, then 32 more frames - if not ha.jingleDone then - ha.timer = 0 - elseif ha.timer >= 32 then - return "done" - end end end @@ -2405,19 +2402,34 @@ function OverworldState:checkVictoryRewards(trainerClass, partyIndex) Commands.hide_object(ctx, entry[1], entry[2]) end end - local lines = {} if reward.badge then Game.save.inventory[reward.badge] = 1 - local name = Game.data.items[reward.badge] and Game.data.items[reward.badge].name - or reward.badge - table.insert(lines, ("%s received\nthe %s!"):format(Game.save.player.name, name)) end if reward.item then local inv = Game.save.inventory inv[reward.item] = (inv[reward.item] or 0) + 1 - local name = Game.data.items[reward.item] and Game.data.items[reward.item].name - or reward.item - table.insert(lines, ("%s received\n%s!"):format(Game.save.player.name, name)) + local idef = Game.data.items[reward.item] + -- GiveItem -> CopyToStringBuffer for "{RAM:wStringBuffer}" received texts + Game.stringBuffer = idef and idef.name or reward.item + end + local lines = {} + 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 + 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, ("%s received\nthe %s!"):format(Game.save.player.name, name)) + end + if reward.item then + local name = Game.stringBuffer or reward.item + table.insert(lines, ("%s received\n%s!"):format(Game.save.player.name, name)) + end end if #lines > 0 then Game.stack:push(TextBox.new(Game, table.concat(lines, "\f"))) @@ -2439,6 +2451,8 @@ end -- interposed NPCs / walls -- but unsigned 8-bit Y makes a sprite exactly 4 -- tiles north of the player sit at Y=$fc, so |$3c-$fc|=$c0 and a range-4 -- DOWN trainer does not engage that tile (Route 9 Bug Catcher / issue #76). +-- Off-screen sprites (IMAGEINDEX=$ff) never engage: without that gate, the +-- same 8-bit wrap makes far same-row trainers look in-range (#153/#183). local PLAYER_SCREEN_X, PLAYER_SCREEN_Y = 0x40, 0x3c local function u8(n) return n % 256 end local function calcDiff(a, b) @@ -2454,6 +2468,14 @@ local function trainerSightPixelDist(npc, player, horizontal) u8(PLAYER_SCREEN_Y + (npc.cellY - player.cellY) * 16)) end +-- CheckSpriteAvailability (movement.asm): wXCoord/wYCoord = player - 4; +-- visible when sprite is in [wCoord, wCoord + SCREEN_*/2 - 1] (GB 10x9). +local function trainerSpriteOnScreen(npc, player) + local dx = npc.cellX - player.cellX + local dy = npc.cellY - player.cellY + return dx >= -4 and dx <= 5 and dy >= -4 and dy <= 4 +end + -- STAY trainers with a facing spot the player crossing their line of -- sight (range from the extracted trainer headers), walk up and battle. function OverworldState:checkTrainerSight() @@ -2466,7 +2488,8 @@ function OverworldState:checkTrainerSight() -- walkers included (they sight between steps) if d.trainerClass and not npc.moving and not self:trainerDefeated(npc) - and not mapScripts.talkScript(self.map.id, d.text) then + and not mapScripts.talkScript(self.map.id, d.text) + and trainerSpriteOnScreen(npc, p) then local header = Game.data:trainerHeader(self.map.def.label, d.index) local range = header and header.range or 0 local vec = DIRVEC[npc.facing] @@ -3210,7 +3233,7 @@ function OverworldState:takeWarp(warpDef) self:startWarpTo(destMap, x, y, facing) return end - self.doorWarp = true -- door SFX + outdoor walk-out step + self.doorWarp = true -- door SFX + PlayerStepOutFromDoor walk-out self:startWarpTo(destMap, x, y, facing) end @@ -3295,12 +3318,12 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts) local outdoor = Map.isOutdoor(self.map.def) require("src.core.Sound").play(Game.data, outdoor and "Go_Outside" or "Go_Inside") - -- stepping out of an outdoor door/cave entrance (the original's - -- walk-out). Auto-walk leaves the mat, so the arrival disable + -- PlayerStepOutFromDoor (engine/overworld/auto_movement.asm): any + -- warp that lands on a door tile auto-steps south once, indoor or + -- outdoor. Auto-walk leaves the mat, so the arrival disable -- (warpEntryCell / justWarped) is unnecessary -- and would let you -- stand on the door without re-entering if you hold back into it. - if outdoor and self.player.facing == "down" - and self.map:isWarpTileCell(self.player.cellX, self.player.cellY) then + if self.map:isDoorTileCell(self.player.cellX, self.player.cellY) then self.warpEntryCell = nil self.justWarped = false self:scriptMove(self.player, "down", 1) diff --git a/src/world/SsAnneLayout.lua b/src/world/SsAnneLayout.lua new file mode 100644 index 00000000..527cb806 --- /dev/null +++ b/src/world/SsAnneLayout.lua @@ -0,0 +1,62 @@ +-- S.S. Anne 1F cabins: ROM packs rooms so hallway doors map right→left +-- onto the 3×2 rooms map (warp 1 = top-left = rightmost door). Survey +-- zoom makes that look left/right flipped vs 2F/B1F. Reorder door→cell +-- and slide objects so each door keeps its OG occupants while L→R doors +-- land in reading order on the rooms map. + +local SsAnneLayout = {} + +local function leftmostRoomDoor(hall) + local best + for _, w in ipairs(hall.warps or {}) do + if w.destMap == "SS_ANNE_1F_ROOMS" then + if not best or w.x < best.x then best = w end + end + end + return best +end + +function SsAnneLayout.apply(maps) + if not maps then return false end + local hall = maps.SS_ANNE_1F + local rooms = maps.SS_ANNE_1F_ROOMS + if not hall or not rooms then return false end + local left = leftmostRoomDoor(hall) + -- already hallway-ordered (leftmost door → rooms warp 1) + if not left or left.destWarp == 1 then return false end + + local doors = {} + for _, w in ipairs(hall.warps) do + if w.destMap == "SS_ANNE_1F_ROOMS" then doors[#doors + 1] = w end + end + table.sort(doors, function(a, b) return a.x < b.x end) + for i, w in ipairs(doors) do w.destWarp = i end + + -- rooms warps 1..6 return through hall warps 8..3 (L→R cabin doors) + for i, w in ipairs(rooms.warps or {}) do + if w.destMap == "SS_ANNE_1F" then w.destWarp = 9 - i end + end + + local warpPos = {} + for i, w in ipairs(rooms.warps or {}) do + warpPos[i] = { x = w.x, y = w.y } + end + for _, o in ipairs(rooms.objects or {}) do + local col, row = math.floor(o.x / 10), math.floor(o.y / 10) + local oldK + for k, p in ipairs(warpPos) do + if math.floor(p.x / 10) == col and math.floor(p.y / 10) == row then + oldK = k + break + end + end + if oldK then + local newK = 7 - oldK + o.x = o.x + (warpPos[newK].x - warpPos[oldK].x) + o.y = o.y + (warpPos[newK].y - warpPos[oldK].y) + end + end + return true +end + +return SsAnneLayout diff --git a/tests/drivers/anne_door_test.lua b/tests/drivers/anne_door_test.lua new file mode 100644 index 00000000..e8bea3a0 --- /dev/null +++ b/tests/drivers/anne_door_test.lua @@ -0,0 +1,28 @@ +-- Driver: #187 S.S. Anne cabin door entry. Entering a room from the +-- corridor should land one tile south of the door (pokered +-- PlayerStepOutFromDoor), not leave the player on the door tile. +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + -- SS_ANNE_1F warp at (31,8) -> SS_ANNE_1F_ROOMS (rightmost cabin) + U.teleport(game, "SS_ANNE_1F", 31, 9, "up") + U.shot(game, DIR .. "/anne_0_before_enter.png") + local ow = game.overworld + U.log("before map:", ow.map.id, "pos:", ow.player.cellX, ow.player.cellY, + "facing:", ow.player.facing) + + U.hold(game, "up", 20) + for _ = 1, 90 do + U.wait(1) + if ow.map.id == "SS_ANNE_1F_ROOMS" and not ow.transitioning + and #ow.scriptMoves == 0 and not ow.player.moving then + break + end + end + U.wait(8) + U.shot(game, DIR .. "/anne_1_after_enter.png") + U.log("after map:", ow.map.id, "pos:", ow.player.cellX, ow.player.cellY, + "facing:", ow.player.facing, + "onDoor:", tostring(ow.map:isWarpTileCell(ow.player.cellX, ow.player.cellY))) +end diff --git a/tests/drivers/battle_party_submenu_test.lua b/tests/drivers/battle_party_submenu_test.lua new file mode 100644 index 00000000..7a4f38f4 --- /dev/null +++ b/tests/drivers/battle_party_submenu_test.lua @@ -0,0 +1,58 @@ +-- Driver: mid-battle PKMN opens SWITCH / STATS / CANCEL (#180). +-- SHOT_DIR=/tmp/shots POKEPORT_DRIVER=tests/drivers/battle_party_submenu_test.lua love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local Pokemon = require("src.pokemon.Pokemon") + local BattleState = require("src.battle.BattleState") + + game.save.party = { + Pokemon.new(game.data, "CHARMANDER", 12), + Pokemon.new(game.data, "SQUIRTLE", 10), + } + U.teleport(game, "ROUTE_1", 5, 5, "down") + local ow = game.overworld + + local battle = BattleState.newWild(game, "PIDGEY", 8) + battle.onFinish = function() end + ow:pushBattle(battle) + + local function mashUntil(cond, max) + for _ = 1, max or 80 do + if cond() then return true end + U.tap(game, "a") + U.wait(4) + end + return false + end + + mashUntil(function() return battle.phase == "menu" end) + U.shot(game, DIR .. "/battle_party_0_menu.png") + + -- FIGHT/PKMN/ITEM/RUN: right to PKMN, then A + U.tap(game, "right"); U.wait(4) + U.tap(game, "a"); U.wait(12) + U.shot(game, DIR .. "/battle_party_1_list.png") + + local pm = game.stack:top() + U.log("party open:", pm and pm.onSwitch ~= nil) + U.tap(game, "a"); U.wait(8) + pm = game.stack:top() + U.shot(game, DIR .. "/battle_party_2_switch_stats.png") + + local labels = {} + if pm and pm.submenu and pm.subItems then + for _, e in ipairs(pm.subItems) do + table.insert(labels, e.label) + end + end + U.log("submenu:", table.concat(labels, "/")) + U.log("order ok:", labels[1] == "SWITCH" and labels[2] == "STATS" + and labels[3] == "CANCEL") + + -- STATS + U.tap(game, "down"); U.wait(4) + U.tap(game, "a"); U.wait(14) + U.shot(game, DIR .. "/battle_party_3_stats.png") + U.log("stats open:", game.stack:top() ~= pm) +end diff --git a/tests/drivers/battleflow_test.lua b/tests/drivers/battleflow_test.lua index da8135e3..6984ed9a 100644 --- a/tests/drivers/battleflow_test.lua +++ b/tests/drivers/battleflow_test.lua @@ -1,10 +1,12 @@ --- Driver: the two reworked battle flows. +-- Driver: reworked battle flows. -- A) Old man catch tutorial (DisplayBattleMenu's old-man script): -- scripted cursor FIGHT(80f) -> ITEM(50f), forced item menu with -- one POKé BALL x50 -- itself scripted (list_menu.asm:65-80): -- '▶' hover 80f, auto-A leaves the hollow '▷', always-caught throw. -- B) Mimic's MID-move copy menu (MimicEffect): the chooser opens only -- after the hit test, at (0,7) like MoveSelectionMenu .mimicmenu. +-- C) #162 trainer SHIFT offer: after KO'ing the first enemy mon, +-- "about to use" + "Will … change POKéMON?" with YES/NO. return function(game) local U = dofile("tests/drivers/util.lua") local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" @@ -83,5 +85,55 @@ return function(game) U.shot(game, DIR .. "/mimic_5_moves_after.png") -- slot now holds the copy while game.stack:top() ~= ow do game.stack:pop() end U.wait(5) + + -- ------------------------------------------------ C) SHIFT about-to-use + -- Inject the foe-faint path at the menu so screenshots don't depend on + -- accuracy rolls; party has 2 slots so TrainerAboutToUseText fires. + local ChoiceBox = require("src.ui.ChoiceBox") + local PartyMenu = require("src.ui.PartyMenu") + game.save.options = game.save.options or {} + game.save.options.battleStyle = "shift" + game.save.options.animations = false + game.save.party = { + Pokemon.new(game.data, "CHARIZARD", 50), + Pokemon.new(game.data, "BLASTOISE", 50), + } + local trainer = BattleState.newTrainer(game, "OPP_YOUNGSTER", 1) + trainer.onFinish = function() end + ow:pushBattle(trainer) + mashUntil(function() return trainer.phase == "menu" end) + U.shot(game, DIR .. "/shift_0_menu.png") + trainer:markParticipant() + trainer.enemy.mon.hp = 0 + trainer.enemyParty[1].hp = 0 + trainer.phase = "messages" + trainer.afterQueue = "menu" + trainer:onFaint(trainer.enemy) + local function topIs(cls) + return getmetatable(game.stack:top()) == cls + end + local function textDone(needle) + local t = trainer.current and trainer.current.text + return t and t:find(needle, 1, true) + and (trainer.charIndex or 0) >= (trainer.total or 0) + end + mashUntil(function() return textDone("about to use") end, 200) + U.shot(game, DIR .. "/shift_1_about_to_use.png") + mashUntil(function() return textDone("change POKéMON") end, 80) + U.wait(20) -- ChoiceBox auto-opens once the page has typed out + mashUntil(function() return topIs(ChoiceBox) end, 40) + U.shot(game, DIR .. "/shift_2_will_change.png") + U.shot(game, DIR .. "/shift_3_yes_no.png") + U.tap(game, "a"); U.wait(8) -- YES + mashUntil(function() return topIs(PartyMenu) end, 40) + U.shot(game, DIR .. "/shift_4_party.png") + U.tap(game, "down"); U.wait(4) + U.tap(game, "a"); U.wait(8) -- BLASTOISE + mashUntil(function() return textDone("sent") end, 120) + U.shot(game, DIR .. "/shift_5_enemy_sent.png") + mashUntil(function() return textDone("BLASTOISE") end, 120) + U.shot(game, DIR .. "/shift_6_player_sent.png") + while game.stack:top() ~= ow do game.stack:pop() end + U.wait(5) love.event.quit() end diff --git a/tests/drivers/bills_pc_test.lua b/tests/drivers/bills_pc_test.lua new file mode 100644 index 00000000..8b4e57aa --- /dev/null +++ b/tests/drivers/bills_pc_test.lua @@ -0,0 +1,120 @@ +-- Driver: Bill's PC (#177) — chrome (What? / BOX No. / ) and +-- withdraw/deposit returning to BillsPCMenu instead of closing the PC. + +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local Boxes = require("src.pokemon.Boxes") + local Menu = require("src.ui.Menu") + local ListMenu = require("src.ui.ListMenu") + local TextBox = require("src.render.TextBox") + local Pokemon = require("src.pokemon.Pokemon") + + local pass = true + local function check(cond, msg) + if cond then U.log("PASS: " .. msg) else pass = false; U.log("FAIL: " .. msg) end + end + local function topIs(mt) return getmetatable(game.stack:top()) == mt end + local function topMenu() + local top = game.stack:top() + return getmetatable(top) == Menu and top or nil + end + local function mash(btn, cond, frames) + for _ = 1, frames or 120 do + if cond() then return true end + U.tap(game, btn) + U.wait(3) + end + return false + end + + -- party + box stocked so withdraw and deposit both work + game.save.party = { + Pokemon.new(game.data, "PIKACHU", 12), + Pokemon.new(game.data, "EEVEE", 10), + } + Boxes.ensure(game.save) + game.save.currentBox = 1 + game.save.boxes[1] = { + Pokemon.new(game.data, "SPEAROW", 8), + Pokemon.new(game.data, "RATTATA", 6), + } + game.save.flags = game.save.flags or {} + game.save.flags.EVENT_MET_BILL = true + + -- PC tile at (13,3); stand on (13,4) facing up + U.teleport(game, "VIRIDIAN_POKECENTER", 13, 4, "up") + local ow = game.overworld + + U.tap(game, "a") -- open PC + U.wait(6) + check(topIs(Menu), "PC main menu opened") + U.tap(game, "a") -- BILL'S PC + U.wait(8) + local bills = topMenu() + check(bills ~= nil, "Bill's PC menu opened") + check(bills and bills.items[1] and bills.items[1].label:find("", 1, true), + "WITHDRAW label includes icon tokens") + U.shot(game, DIR .. "/bills_pc_1_menu.png") + + -- WITHDRAW first box mon + U.tap(game, "a") -- WITHDRAW + U.wait(8) + check(topIs(ListMenu), "withdraw list opened") + U.tap(game, "a") -- pick SPEAROW + U.wait(6) + check(topIs(Menu), "withdraw mon submenu opened") + U.tap(game, "a") -- WITHDRAW action + U.wait(6) + check(mash("a", function() return topIs(TextBox) end, 60), + "withdraw shows taken-out text") + -- let the typewriter finish so the shot shows the taken-out line + for _ = 1, 90 do + local tb = game.stack:top() + if getmetatable(tb) == TextBox and (tb.waiting or tb.done) then break end + U.wait(2) + end + U.shot(game, DIR .. "/bills_pc_2_withdraw_text.png") + check(mash("a", function() + local m = topMenu() + return m and m.items and m.items[1] + and tostring(m.items[1].label):find("WITHDRAW", 1, true) + end, 120), "after withdraw, back on Bill's PC menu") + check(#game.save.party == 3, "party gained the withdrawn mon") + check(#game.save.boxes[1] == 1, "box lost the withdrawn mon") + U.shot(game, DIR .. "/bills_pc_3_menu_after_withdraw.png") + + -- DEPOSIT party lead (PIKACHU) + U.tap(game, "down") -- DEPOSIT + U.wait(2) + U.tap(game, "a") + U.wait(8) + check(topIs(ListMenu), "deposit list opened") + U.tap(game, "a") -- first party mon + U.wait(6) + check(topIs(Menu), "deposit mon submenu opened") + U.tap(game, "a") -- DEPOSIT action + U.wait(6) + check(mash("a", function() return topIs(TextBox) end, 60), + "deposit shows stored text") + for _ = 1, 90 do + local tb = game.stack:top() + if getmetatable(tb) == TextBox and (tb.waiting or tb.done) then break end + U.wait(2) + end + U.shot(game, DIR .. "/bills_pc_4_deposit_text.png") + check(mash("a", function() + local m = topMenu() + return m and m.items and m.items[1] + and tostring(m.items[1].label):find("WITHDRAW", 1, true) + end, 120), "after deposit, back on Bill's PC menu") + check(#game.save.party == 2, "party lost the deposited mon") + check(#game.save.boxes[1] == 2, "box gained the deposited mon") + U.shot(game, DIR .. "/bills_pc_5_menu_after_deposit.png") + + -- still inside the PC (not back on the overworld) + check(game.stack:top() ~= ow, "PC still open after transfers") + check(not topIs(ListMenu), "not stuck in a transfer list") + + U.log(pass and "RESULT: ALL PASS" or "RESULT: SEE FAILURES ABOVE") +end diff --git a/tests/drivers/catch_test.lua b/tests/drivers/catch_test.lua index ca0bc30c..73821f31 100644 --- a/tests/drivers/catch_test.lua +++ b/tests/drivers/catch_test.lua @@ -1,15 +1,35 @@ -- Driver: throw Poké Balls and capture the catch suspense sequence --- (toss -> poof -> mon hides -> ball shakes -> breakout or capture). +-- (toss -> poof -> mon hides -> ball shakes -> breakout or capture), +-- #159 nickname ask (text + white field, YES then NO), and #172 +-- full-party catch nickname prompt before box transfer. return function(game) local U = dofile("tests/drivers/util.lua") local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" local Pokemon = require("src.pokemon.Pokemon") + local ChoiceBox = require("src.ui.ChoiceBox") table.insert(game.save.party, Pokemon.new(game.data, "CHARMANDER", 12)) U.teleport(game, "ROUTE_1", 5, 5, "down") local ow = game.overworld local BattleState = require("src.battle.BattleState") + local function topIs(cls) + return getmetatable(game.stack:top()) == cls + end + + local function mashUntil(cond, n) + for _ = 1, (n or 400) do + if cond() then return true end + U.tap(game, "a") + U.wait(3) + end + return false + end + + local function onNicknameChoice() + return topIs(ChoiceBox) + end + -- one throw, screenshotting every 20 frames through the chain local function throwAndShoot(tag, rng) local battle = BattleState.newWild(game, "PIDGEY", 8) @@ -36,4 +56,78 @@ return function(game) throwAndShoot("break", function(a, b) return b end) -- rng low: clean capture, ball stays shut throwAndShoot("caught", function(a, b) return a end) + + -- #159: party catch -> nickname ask on white (YES cursor, then NO) + game.save.party = { Pokemon.new(game.data, "PIKACHU", 5) } + game.save.pokedex = game.save.pokedex or { owned = {}, seen = {} } + game.save.pokedex.owned.RATTATA = true + game.save.pokedex.seen.RATTATA = true + + local battle = BattleState.newWild(game, "RATTATA", 3) + battle.onFinish = function() end + battle.rng = function(a, b) return a end + ow:pushBattle(battle) + for _ = 1, 14 do U.tap(game, "a"); U.wait(6) end + battle.phase = "messages" + battle.afterQueue = "menu" + battle:throwBall("POKE_BALL") + + U.log("nick YES:", mashUntil(onNicknameChoice, 600)) + U.shot(game, DIR .. "/catch_nickname_yes.png") + U.tap(game, "down") + U.wait(4) + U.shot(game, DIR .. "/catch_nickname_no.png") + U.tap(game, "a") -- confirm NO + U.wait(8) + for _ = 1, 20 do U.tap(game, "a"); U.wait(4) end + while game.stack:top() ~= ow do game.stack:pop() end + U.wait(5) + + -- #172: full party of 6 -> nickname ask, then "sent to BOX" / PC text + game.save.party = {} + for _ = 1, 6 do + table.insert(game.save.party, Pokemon.new(game.data, "RATTATA", 5)) + end + game.save.pokedex.owned.PIDGEY = true + game.save.pokedex.seen.PIDGEY = true + game.save.flags = game.save.flags or {} + game.save.flags.EVENT_MET_BILL = true + + battle = BattleState.newWild(game, "PIDGEY", 8) + battle.onFinish = function() end + battle.rng = function(a, b) return a end + ow:pushBattle(battle) + for _ = 1, 14 do U.tap(game, "a"); U.wait(6) end + battle.phase = "messages" + battle.afterQueue = "menu" + battle:throwBall("POKE_BALL") + + U.log("full-party nick ask:", mashUntil(onNicknameChoice, 600)) + U.wait(4) + U.shot(game, DIR .. "/catch_fullparty_nickname.png") + + -- decline nickname (NO), then wait (no A/B) for ItemUseBallText07 + if topIs(ChoiceBox) then + U.tap(game, "b") -- NO + end + -- drain the B edge before the transfer text can see it + for _ = 1, 8 do U.wait(1) end + local sawBox = false + for _ = 1, 400 do + if game.stack:top() == battle and battle.current and battle.current.text + and battle.current.text:find("transferred", 1, true) then + sawBox = true + -- wait until the typewriter has drawn most of the line + if (battle.charIndex or 0) >= math.floor((battle.total or 0) * 0.7) then + break + end + end + U.wait(1) + end + U.log("full-party box text:", sawBox, + battle.current and battle.current.text or "nil") + U.shot(game, DIR .. "/catch_fullparty_box.png") + + for _ = 1, 30 do U.tap(game, "a"); U.wait(4) end + while game.stack:top() ~= ow do game.stack:pop() end end diff --git a/tests/drivers/cerulean_rocket_test.lua b/tests/drivers/cerulean_rocket_test.lua new file mode 100644 index 00000000..c64f61ac --- /dev/null +++ b/tests/drivers/cerulean_rocket_test.lua @@ -0,0 +1,70 @@ +-- Driver: Cerulean TM28 Rocket despawn fade (#170). +-- Skips the fight (EVENT_BEAT set), talks through the TM return, and +-- captures a mid-fade frame while CeruleanHideRocket runs. +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local Pokemon = require("src.pokemon.Pokemon") + table.insert(game.save.party, Pokemon.new(game.data, "BLASTOISE", 50)) + game.save.flags.EVENT_BEAT_CERULEAN_ROCKET_THIEF = true + -- Rocket at (30,8); stand south and face him + U.teleport(game, "CERULEAN_CITY", 30, 9, "up") + local ow = game.overworld + + local function mashUntil(cond, label, cap) + for _ = 1, cap or 600 do + if cond() then return true end + if game.stack:top() ~= ow then U.tap(game, "a") end + U.wait(3) + end + U.log("TIMEOUT waiting for " .. label) + return false + end + + U.shot(game, DIR .. "/cerulean_rocket_00_before.png") + U.tap(game, "a") + U.wait(20) + U.shot(game, DIR .. "/cerulean_rocket_01_return_tm.png") + + local function rocketAlive() + for _, n in ipairs(ow.npcs or {}) do + if n.def and n.def.name == "CERULEANCITY_ROCKET" then return true end + end + return false + end + + -- Prefer mid fade-in (rocket already toggled off, screen still dark), + -- matching pokered's post-HideObject GBFadeInFromBlack frames. + local midShot = false + for _ = 1, 900 do + local overlay = ow.fadeOverlay + local alpha = overlay and overlay.alpha or 0 + if not midShot and alpha > 0.35 and alpha < 0.95 and not rocketAlive() then + U.shot(game, DIR .. "/cerulean_rocket_02_mid_fade.png") + midShot = true + end + if midShot and not ow.fadeOverlay and game.stack:top() == ow + and not ow.runner:isRunning() then + break + end + if game.stack:top() ~= ow then U.tap(game, "a") end + U.wait(2) + end + U.log("mid-fade shot:", tostring(midShot)) + mashUntil(function() + return game.stack:top() == ow and not ow.runner:isRunning() + and not ow.fadeOverlay + end, "idle after hide", 200) + U.wait(10) + U.shot(game, DIR .. "/cerulean_rocket_03_after.png") + + local names = {} + for _, n in ipairs(ow.npcs or {}) do + names[#names + 1] = n.def and n.def.name or "?" + end + local toggles = game.save.objectToggles and game.save.objectToggles.CERULEAN_CITY + U.log("rocket alive:", tostring(rocketAlive())) + U.log("rocket toggle:", tostring(toggles and toggles.CERULEANCITY_ROCKET)) + U.log("got TM28:", tostring(game.save.flags.EVENT_GOT_TM28)) + U.log("npcs:", table.concat(names, ",")) +end diff --git a/tests/drivers/copycat_tm31_test.lua b/tests/drivers/copycat_tm31_test.lua new file mode 100644 index 00000000..cbb98b17 --- /dev/null +++ b/tests/drivers/copycat_tm31_test.lua @@ -0,0 +1,104 @@ +-- Driver: Copycat TM31 MIMIC trade (scripts/CopycatsHouse2F.asm). +-- Teleport to 2F with a POKE DOLL, talk through the mimic dialogue + gift, +-- screenshot the TM receive box, and assert flag/inventory. +-- +-- SHOT_DIR=/tmp/copycat_tm31 POKEPORT_DRIVER=tests/drivers/copycat_tm31_test.lua love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local Bag = require("src.inventory.Bag") + local TextBox = require("src.render.TextBox") + + Bag.add(game.save, "POKE_DOLL", 1) + U.teleport(game, "COPYCATS_HOUSE_2F", 4, 4, "up") + local ow = game.overworld + + for _, npc in ipairs(ow.npcs) do + if npc.def and npc.def.text == "TEXT_COPYCATSHOUSE2F_COPYCAT" then + npc.wanders, npc.moving = false, false + npc.cellX, npc.cellY = 4, 3 + npc.px, npc.py = npc.cellX * 16, npc.cellY * 16 + npc.facing = "down" + end + end + U.wait(5) + U.shot(game, DIR .. "/copycat_0_room.png") + + local function topBox() + local top = game.stack:top() + return getmetatable(top) == TextBox and top or nil + end + + local function pageText(box) + if not box then return "" end + return table.concat(box.pages[box.pageIndex] or {}, "\n") + end + + -- Advance until a fully-typed page matches pred(pageText), then stop + -- without consuming that page (so the shot catches it). Last pages + -- set done (not waiting); mid-text page breaks set waiting. + local function mashToPage(pred) + for _ = 1, 500 do + local box = topBox() + if box and (box.waiting or box.done) and pred(pageText(box)) then + return true + end + U.tap(game, "a") + U.wait(3) + end + return false + end + + U.tap(game, "a") + U.wait(20) + U.log("mimic page:", mashToPage(function(s) + return s:find("like POKéMON", 1, true) ~= nil + or s:find("like POK", 1, true) ~= nil + end)) + U.shot(game, DIR .. "/copycat_1_mimic.png") + + U.log("pre-receive page:", mashToPage(function(s) + return s:find("DOLL", 1, true) ~= nil + end)) + U.shot(game, DIR .. "/copycat_2_prereceive.png") + + U.log("TM receive page:", mashToPage(function(s) + return s:find("received", 1, true) ~= nil + and s:find("TM31", 1, true) ~= nil + end)) + U.shot(game, DIR .. "/copycat_3_received_tm31.png") + + local function mash(btn, cond) + for _ = 1, 400 do + if cond() then return true end + U.tap(game, btn) + U.wait(3) + end + return false + end + + U.log("dialogue done:", mash("a", function() + return game.stack:top() == ow + end)) + U.shot(game, DIR .. "/copycat_4_done.png") + + U.log("EVENT_GOT_TM31:", tostring(game.save.flags.EVENT_GOT_TM31)) + U.log("bag TM_MIMIC:", tostring(game.save.inventory.TM_MIMIC), + "POKE_DOLL:", tostring(game.save.inventory.POKE_DOLL)) + + assert(game.save.flags.EVENT_GOT_TM31, "EVENT_GOT_TM31 not set") + assert((game.save.inventory.TM_MIMIC or 0) >= 1, "TM_MIMIC missing from bag") + assert((game.save.inventory.POKE_DOLL or 0) == 0, "POKE_DOLL not taken") + + U.tap(game, "a") + U.wait(20) + U.log("thanks page:", mashToPage(function(s) + return s:find("Thanks for TM31", 1, true) ~= nil + end)) + U.wait(2) + U.shot(game, DIR .. "/copycat_5_thanks.png") + mash("a", function() return game.stack:top() == ow end) + + U.log("DONE") + love.event.quit() +end diff --git a/tests/drivers/daycare_moves_test.lua b/tests/drivers/daycare_moves_test.lua new file mode 100644 index 00000000..e4492236 --- /dev/null +++ b/tests/drivers/daycare_moves_test.lua @@ -0,0 +1,75 @@ +-- Driver (#184): Magikarp Day Care 13→19 should learn Tackle at 15. +-- Runs the DAYCARE retrieve script (same path as the gentleman NPC), +-- logs the moveset, then screenshots status page 2 (moves + PP). +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local Pokemon = require("src.pokemon.Pokemon") + local Growth = require("src.pokemon.Growth") + local SummaryMenu = require("src.ui.SummaryMenu") + local story2 = require("data.scripts.story2") + + local def = game.data.pokemon.MAGIKARP + local karp = Pokemon.new(game.data, "MAGIKARP", 13) + karp.nickname = "FISHY" + game.save.party = { Pokemon.new(game.data, "RATTATA", 5) } + game.save.money = 10000 + game.save.player.name = "RED" + + local steps = Growth.expForLevel(def.growthRate, 19) + - Growth.expForLevel(def.growthRate, 13) + game.save.daycare = { + mon = karp, steps = steps, depositLevel = 13, + } + + local before = {} + for _, mv in ipairs(karp.moves) do before[#before + 1] = mv.id end + U.log("before: Lv", karp.level, "moves", table.concat(before, ",")) + + U.teleport(game, "DAYCARE", 2, 2, "up") + + -- Resolve dialogue synchronously (parity_daycare style) without stacking + -- fake TextBoxes over the overworld. + local realTextBox = package.loaded["src.render.TextBox"] + package.loaded["src.render.TextBox"] = { + new = function(_, text, onDone, opts) + if opts and opts.choice then + opts.choice(true) + elseif onDone then + onDone() + end + return { text = text } + end, + } + local realPush = game.stack.push + game.stack.push = function() end + local done = false + story2.DAYCARE.talk.TEXT_DAYCARE_GENTLEMAN( + game, game.overworld, nil, function() done = true end) + game.stack.push = realPush + package.loaded["src.render.TextBox"] = realTextBox + + local mon = game.save.party[#game.save.party] + local ids = {} + for _, mv in ipairs(mon.moves or {}) do ids[#ids + 1] = mv.id end + U.log("after: Lv", mon.level, "moves", table.concat(ids, ","), + "done=", done, "daycare_cleared=", game.save.daycare == nil) + + local hasTackle = false + for _, id in ipairs(ids) do if id == "TACKLE" then hasTackle = true end end + U.log("Magikarp 13→19 learned Tackle:", hasTackle) + + local summary = SummaryMenu.new(game, mon) + game.stack:push(summary) + U.wait(4) + U.shot(game, DIR .. "/daycare_00_summary_p1.png") + summary.page = 2 + U.wait(2) + U.shot(game, DIR .. "/daycare_01_moves.png") + U.log("shots under", DIR) + + if not (mon.species == "MAGIKARP" and mon.level == 19 and hasTackle) then + error("daycare #184 failed: Lv" .. tostring(mon.level) + .. " [" .. table.concat(ids, ",") .. "]") + end +end diff --git a/tests/drivers/dialogue_cont_wait_test.lua b/tests/drivers/dialogue_cont_wait_test.lua new file mode 100644 index 00000000..306d2ddf --- /dev/null +++ b/tests/drivers/dialogue_cont_wait_test.lua @@ -0,0 +1,97 @@ +-- Driver: #163 cont (\\v) must show ▼ and wait for A before scrolling. +-- Talks to VIRIDIANFOREST_YOUNGSTER5 (cont mid-page) and the nearby +-- TRAINER TIPS1 sign (para then conts). Screenshots the ▼ wait frames. +-- +-- SHOT_DIR=/tmp/dialogue_cont POKEPORT_DRIVER=tests/drivers/dialogue_cont_wait_test.lua love . + +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/dialogue_cont" + local TextBox = require("src.render.TextBox") + + local pass = true + local function check(cond, msg) + if cond then U.log("PASS: " .. msg) else pass = false; U.log("FAIL: " .. msg) end + end + local function topIs(mt) return getmetatable(game.stack:top()) == mt end + + local function waitForTextBox(frames) + for _ = 1, frames or 60 do + if topIs(TextBox) then return game.stack:top() end + U.wait(1) + end + return nil + end + + -- type out until waiting/done without mashing A (held A would only + -- speed the typewriter; wasPressed edges are what clear waits) + local function waitUntilPrompt(box, frames) + for _ = 1, frames or 240 do + if not topIs(TextBox) then return false end + if box.waiting or box.done then return true end + U.wait(1) + end + return box.waiting or box.done + end + + -- ▼ blinks half the time (blink < 30); settle so the shot shows it. + -- Keep POKEPORT_SPEED=1: scriptedIterations>1 can advance past the + -- wait in the same rendered frame before love.draw captures. + local function shotPrompt(box, path) + for _ = 1, 60 do + if not (box.waiting or box.done) then break end + if box.blink < 20 then break end + U.wait(1) + end + U.shot(game, path) + U.wait(2) + end + + local function dismissText() + for _ = 1, 80 do + if not topIs(TextBox) then return end + U.tap(game, "a") + U.wait(2) + end + end + + -- Youngster5 @ (27,40): "I ran out of POKé / BALLs to catch" then + -- \v "POKéMON with!" — the bug auto-scrolled past this with no ▼. + U.teleport(game, "VIRIDIAN_FOREST", 27, 41, "up") + U.tap(game, "a") + local box = waitForTextBox(90) + check(box ~= nil, "youngster5 opened a TextBox") + if box then + check(waitUntilPrompt(box, 300), "youngster5 reached a prompt") + check(box.waiting and box.contAdvance, + "youngster5 first prompt is a cont wait (▼)") + shotPrompt(box, DIR .. "/dialogue_cont_00_youngster5_arrow.png") + U.tap(game, "a") + U.wait(3) + check(waitUntilPrompt(box, 300), "youngster5 reached para/done after cont") + shotPrompt(box, DIR .. "/dialogue_cont_01_youngster5_after.png") + dismissText() + end + + -- Trainer Tips1 sign @ (24,40): para after title, then two conts. + U.teleport(game, "VIRIDIAN_FOREST", 24, 41, "up") + U.tap(game, "a") + box = waitForTextBox(90) + check(box ~= nil, "tips1 sign opened a TextBox") + if box then + check(waitUntilPrompt(box, 300), "tips1 reached first prompt") + -- first prompt is the para after "TRAINER TIPS" + check(box.waiting and not box.contAdvance, + "tips1 first prompt is a para wait") + shotPrompt(box, DIR .. "/dialogue_cont_02_tips1_para.png") + U.tap(game, "a") + U.wait(3) + check(waitUntilPrompt(box, 300), "tips1 typed into page 2") + check(box.waiting and box.contAdvance, + "tips1 page2 pauses on cont with ▼") + shotPrompt(box, DIR .. "/dialogue_cont_03_tips1_cont_arrow.png") + dismissText() + end + + U.log(pass and "RESULT: ALL PASS" or "RESULT: SEE FAILURES ABOVE") +end diff --git a/tests/drivers/door_test.lua b/tests/drivers/door_test.lua index d434c868..0ff70bae 100644 --- a/tests/drivers/door_test.lua +++ b/tests/drivers/door_test.lua @@ -1,5 +1,5 @@ --- Driver: reproduce the door-warp flow. Teleports to Pallet Town in --- front of Red's house, walks in, tries to move inside, walks back out. +-- Driver: door-warp flow (Pallet house) plus #187 S.S. Anne enter-from-above. +-- Anne-only shots: tests/drivers/anne_door_test.lua return function(game) local U = dofile("tests/drivers/util.lua") @@ -32,4 +32,20 @@ return function(game) U.shot(game, DIR .. "/door_9_back_outside.png") U.log("final map:", ow.map.id, "pos:", ow.player.cellX, ow.player.cellY, "transitioning:", tostring(ow.transitioning)) + + -- #187: enter SS Anne cabin from above; expect one tile south of door + U.teleport(game, "SS_ANNE_1F", 31, 9, "up") + U.shot(game, DIR .. "/door_10_anne_before.png") + U.hold(game, "up", 20) + for _ = 1, 90 do + U.wait(1) + if ow.map.id == "SS_ANNE_1F_ROOMS" and not ow.transitioning + and #ow.scriptMoves == 0 and not ow.player.moving then + break + end + end + U.wait(8) + U.shot(game, DIR .. "/door_11_anne_after.png") + U.log("anne map:", ow.map.id, "pos:", ow.player.cellX, ow.player.cellY, + "facing:", ow.player.facing) end diff --git a/tests/drivers/gym_leader_victory_test.lua b/tests/drivers/gym_leader_victory_test.lua new file mode 100644 index 00000000..0f74f596 --- /dev/null +++ b/tests/drivers/gym_leader_victory_test.lua @@ -0,0 +1,93 @@ +-- Driver: gym-leader post-battle dialogue chain (#164). +-- Invokes checkVictoryRewards for Brock then Misty (same path as a win) +-- and screenshots pages that must include badge-effect + TM explanation +-- text — not just a synthetic "received badge/TM" stub. +-- +-- SHOT_DIR=/tmp/gym164 POKEPORT_DRIVER=tests/drivers/gym_leader_victory_test.lua love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local TextBox = require("src.render.TextBox") + local OW = require("src.world.OverworldController") + + local function currentPageText() + local top = game.stack:top() + if getmetatable(top) ~= TextBox then return "" end + local page = top.pages and top.pages[top.pageIndex] + if not page then return "" end + return table.concat(page, "\n") + end + + local function mashUntil(cond) + for _ = 1, 1200 do + if cond() then return true end + U.tap(game, "a") + U.wait(2) + end + return false + end + + local function advancePages(want, shotName) + U.log(shotName, "waiting for:", want) + local ok = mashUntil(function() + local top = game.stack:top() + if getmetatable(top) ~= TextBox then return false end + -- only match once the current page has finished typing + if not (top.waiting or top.done) then return false end + return currentPageText():find(want, 1, true) ~= nil + end) + U.log(shotName, "found:", ok, "page:", currentPageText():gsub("\n", "\\n")) + U.shot(game, DIR .. "/" .. shotName .. ".png") + assert(ok, shotName .. " missing dialogue containing: " .. want) + end + + local function runLeader(mapId, x, y, class, party, shots) + while game.stack:top() do game.stack:pop() end + game.save.flags = game.save.flags or {} + game.save.inventory = game.save.inventory or {} + game.save.defeatedTrainers = game.save.defeatedTrainers or {} + game.stack:push(OW, mapId, x, y, "up") + U.wait(5) + local ow = game.stack:top() + U.shot(game, DIR .. "/" .. shots.prefix .. "_0_gym.png") + ow:checkVictoryRewards(class, party) + U.wait(10) + for _, s in ipairs(shots.pages) do + advancePages(s.want, shots.prefix .. "_" .. s.name) + end + U.log(shots.prefix, "closing box:", mashUntil(function() + return game.stack:top() == ow + end)) + U.shot(game, DIR .. "/" .. shots.prefix .. "_done.png") + end + + game.save.player.name = game.save.player.name or "RED" + + runLeader("PEWTER_GYM", 4, 3, "OPP_BROCK", 1, { + prefix = "brock", + pages = { + { want = "BOULDERBADGE", name = "1_badge" }, + { want = "FLASH", name = "2_flash" }, + { want = "Wait!", name = "3_wait" }, + { want = "TM34", name = "4_tm34" }, + { want = "BIDE", name = "5_bide" }, + }, + }) + assert(game.save.flags.EVENT_BEAT_BROCK, "EVENT_BEAT_BROCK") + assert(game.save.inventory.BOULDERBADGE, "BOULDERBADGE") + assert((game.save.inventory.TM_BIDE or 0) >= 1, "TM_BIDE") + + runLeader("CERULEAN_GYM", 5, 5, "OPP_MISTY", 1, { + prefix = "misty", + pages = { + { want = "CASCADEBADGE", name = "1_badge" }, + { want = "CUT", name = "2_cut" }, + { want = "TM11", name = "3_tm11" }, + }, + }) + assert(game.save.flags.EVENT_BEAT_MISTY, "EVENT_BEAT_MISTY") + assert(game.save.inventory.CASCADEBADGE, "CASCADEBADGE") + assert((game.save.inventory.TM_BUBBLEBEAM or 0) >= 1, "TM_BUBBLEBEAM") + + U.log("gym_leader_victory_test: ok") +end diff --git a/tests/drivers/heal_test.lua b/tests/drivers/heal_test.lua index b339f2a0..19158252 100644 --- a/tests/drivers/heal_test.lua +++ b/tests/drivers/heal_test.lua @@ -1,10 +1,11 @@ --- Driver: Pokémon Center nurse heal, welcome/choice dialogue, the --- machine monitor + per-mon balls, the jingle flash, and the farewell. +-- Driver: Pokémon Center nurse heal + Mom home heal. +-- Logs frames between healAnim end and the fighting-fit / looking-great text. return function(game) local U = dofile("tests/drivers/util.lua") local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" local Pokemon = require("src.pokemon.Pokemon") local ChoiceBox = require("src.ui.ChoiceBox") + local TextBox = require("src.render.TextBox") local mon = Pokemon.new(game.data, "CHARMANDER", 12) mon.hp = 3 table.insert(game.save.party, mon) @@ -14,7 +15,6 @@ return function(game) U.teleport(game, "VIRIDIAN_POKECENTER", 3, 3, "up") local ow = game.overworld - -- mash A until a condition holds local function mashUntil(cond) for _ = 1, 400 do if cond() then return true end @@ -24,6 +24,11 @@ return function(game) return false end + local function topIsText() + return getmetatable(game.stack:top()) == TextBox + end + + -- -------- Poké Center -------- U.tap(game, "a") -- talk to the nurse U.wait(30) U.shot(game, DIR .. "/heal_00_welcome.png") @@ -38,7 +43,6 @@ return function(game) U.shot(game, DIR .. "/heal_02_ball1.png") U.wait(28) U.shot(game, DIR .. "/heal_03_ball2.png") - -- overlay must stay glued to the machine under survey zoom game:zoomStep(-1); game:zoomStep(-1) U.wait(3) U.shot(game, DIR .. "/heal_03z_zoomed.png") @@ -51,14 +55,73 @@ return function(game) U.shot(game, DIR .. "/heal_05_flash_b.png") U.wait(10) U.shot(game, DIR .. "/heal_06_flash_c.png") - -- wait out the jingle + 32-frame beat - for _ = 1, 40 do - if not ow.healAnim then break end - U.wait(10) + + local animEndFrame, fitFrame + for _ = 1, 400 do + if ow.healAnim == nil and not animEndFrame then + animEndFrame = U.frame() + if topIsText() then fitFrame = animEndFrame end + U.shot(game, DIR .. "/heal_06b_anim_end.png") + if fitFrame then break end + end + if animEndFrame and topIsText() and not fitFrame then + fitFrame = U.frame() + break + end + U.wait(1) end U.shot(game, DIR .. "/heal_07_fit.png") + U.log("pc anim_end_frame:", animEndFrame, "fit_frame:", fitFrame, + "gap_anim_to_text:", + (fitFrame and animEndFrame) and (fitFrame - animEndFrame) or "n/a") U.log("farewell reached:", mashUntil(function() return game.stack:top() == ow end)) U.shot(game, DIR .. "/heal_08_end.png") + + -- -------- Mom (Reds House) -------- + require("src.script.Flags").set(game.save, "EVENT_GOT_STARTER") + for _, m in ipairs(game.save.party) do + m.hp = 1 + end + -- stand below Mom (5,4) facing up + U.teleport(game, "REDS_HOUSE_1F", 5, 5, "up") + ow = game.overworld + U.tap(game, "a") + U.wait(20) + U.shot(game, DIR .. "/heal_mom_00_rest.png") + U.log("mom fade started:", mashUntil(function() + return ow.fadeOverlay ~= nil + end)) + local fadeStart = U.frame() + U.shot(game, DIR .. "/heal_mom_01_fade.png") + local Music = require("src.core.Music") + local sawJingle, jingleDoneFrame, fadeGone, greatFrame = false + for _ = 1, 600 do + if Music.oneShotPlaying() then sawJingle = true end + if sawJingle and not Music.oneShotPlaying() and not jingleDoneFrame then + jingleDoneFrame = U.frame() + end + if ow.fadeOverlay == nil and fadeStart and not fadeGone + and U.frame() > fadeStart + 5 then + fadeGone = U.frame() + end + if fadeGone and topIsText() then + greatFrame = U.frame() + break + end + U.wait(1) + end + U.shot(game, DIR .. "/heal_mom_02_great.png") + U.log("mom saw_jingle:", sawJingle, + "jingle_done_frame:", jingleDoneFrame, + "fade_gone_frame:", fadeGone, + "great_frame:", greatFrame, + "gap_fade_to_text:", + (greatFrame and fadeGone) and (greatFrame - fadeGone) or "n/a") + mashUntil(function() + return game.stack:top() == ow + end) + U.shot(game, DIR .. "/heal_mom_03_end.png") end + diff --git a/tests/drivers/learn_move_full_test.lua b/tests/drivers/learn_move_full_test.lua new file mode 100644 index 00000000..e89d6c71 --- /dev/null +++ b/tests/drivers/learn_move_full_test.lua @@ -0,0 +1,162 @@ +-- Driver: level-up learn with a full moveset (#173). +-- Injects the post-KO level-up queue (grew + StatBox + learnMove) inside +-- a live wild battle so MoveLearnMenu runs the same path as a real win. +-- +-- SHOT_DIR=/tmp/learn173 POKEPORT_DRIVER=tests/drivers/learn_move_full_test.lua love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/learn173" + os.execute("mkdir -p " .. DIR) + + local Pokemon = require("src.pokemon.Pokemon") + local mon = Pokemon.new(game.data, "CHARMANDER", 14) + mon.moves = { + { id = "SCRATCH", pp = 35 }, + { id = "GROWL", pp = 40 }, + { id = "EMBER", pp = 25 }, + { id = "SMOKESCREEN", pp = 20 }, + } + table.insert(game.save.party, 1, mon) + game.save.options = game.save.options or {} + game.save.options.textSpeed = 1 + + U.teleport(game, "ROUTE_1", 5, 5, "down") + local ow = game.overworld + local BattleState = require("src.battle.BattleState") + local battle = BattleState.newWild(game, "RATTATA", 2) + battle.onFinish = function() end + ow:pushBattle(battle) + + local function top() return game.stack:top() end + local function stackIds() + local parts = {} + for _, st in ipairs(game.stack.states or {}) do + local id = st.screenId or (st.pages and "TextBox") + or (st.onChoose and "ChoiceBox") + or (st.mon and st.stats and "StatBox") + or (st.newMoveId and "MoveLearnMenu") + or (st.phase and ("Battle:" .. tostring(st.phase))) + or "?" + if st.selecting ~= nil then + id = id .. (st.selecting and "[sel]" or "[ask]") + end + if st.pages then + id = id .. string.format("[p%d/%d%s%s]", st.pageIndex or 0, #st.pages, + st.waiting and "W" or "", st.done and "D" or "") + end + parts[#parts + 1] = id + end + return table.concat(parts, ">") + end + local function isText() + local t = top() + return t and t.pages ~= nil + end + local function isChoice() + local t = top() + return t and t.onChoose ~= nil and t.index ~= nil and not t.mon + end + local function learnMenu() + for _, st in ipairs(game.stack.states or {}) do + if st.newMoveId and st.mon and st.index then return st end + end + end + local function waitFor(cond, max) + for i = 1, max or 900 do + if cond() then return true end + U.wait(1) + end + return false + end + local function atPrompt() + local t = top() + return t and t.pages and (t.waiting or t.done) + end + -- #163: \v conts pause with ▼ mid-page; tap through them so the shot + -- lands on the full page (or the done+choice tail) + local function advanceTextPage() + waitFor(atPrompt, 300) + for _ = 1, 4 do + local t = top() + if not (t and t.pages and t.waiting and t.contAdvance) then break end + U.tap(game, "a") + U.wait(2) + waitFor(atPrompt, 300) + end + U.wait(2) + end + + waitFor(function() return battle.phase == "menu" end, 400) + local StatBox = BattleState.StatBox + battle.phase = "messages" + battle.afterQueue = "menu" + battle.queue = {} + battle.current = nil + battle.nextInsert = 0 + battle:say("CHARMANDER gained\n16 EXP. Points!") + battle:say("CHARMANDER grew\nto level 15!") + battle:ui(function() return StatBox.new(game, mon) end) + mon.level = 15 + -- append (not uiNext): learnMove uses uiNext for mid-fn ordering + battle:ui(function() + return battle:buildScreen("MoveLearnMenu", mon, "LEER") + end) + + for _ = 1, 400 do + if learnMenu() then break end + U.tap(game, "a") + U.wait(2) + end + if not learnMenu() then error("MoveLearnMenu never opened: " .. stackIds()) end + U.log("opened", stackIds()) + + -- page 1 + advanceTextPage() + U.log("shot0", stackIds()) + U.shot(game, DIR .. "/learn_0_trying.png") + U.tap(game, "a"); U.wait(2) + + -- page 2 + advanceTextPage() + U.log("shot1", stackIds()) + U.shot(game, DIR .. "/learn_1_cant_more.png") + U.tap(game, "a"); U.wait(2) + + -- page 3 types out (through its cont), then ChoiceBox overlays + advanceTextPage() + if not waitFor(isChoice, 120) then + error("ChoiceBox never appeared: " .. stackIds()) + end + U.wait(4) -- let choice settle over Delete prompt + U.log("shot2", stackIds()) + U.shot(game, DIR .. "/learn_2_delete_yesno.png") + U.tap(game, "a"); U.wait(4) -- YES + + if not waitFor(function() + local m = learnMenu() + return m and m.selecting and top() == m + end, 120) then + error("forget list never active: " .. stackIds()) + end + U.log("shot3", stackIds()) + U.shot(game, DIR .. "/learn_3_which_move.png") + U.tap(game, "a"); U.wait(4) -- forget SCRATCH + + local n = 4 + for _ = 1, 60 do + if not isText() then break end + local t = top() + if t.done or t.waiting then + U.log("shot" .. n, stackIds()) + U.shot(game, ("%s/learn_%d_after.png"):format(DIR, n)) + n = n + 1 + U.tap(game, "a") + U.wait(4) + else + U.wait(1) + end + end + + U.log("done", DIR, "moves[1]=", mon.moves[1] and mon.moves[1].id, stackIds()) + love.event.quit() +end diff --git a/tests/drivers/mt_moon_fossil_test.lua b/tests/drivers/mt_moon_fossil_test.lua new file mode 100644 index 00000000..19613a98 --- /dev/null +++ b/tests/drivers/mt_moon_fossil_test.lua @@ -0,0 +1,112 @@ +-- Driver: Mt Moon B2F fossil pick (scripts/MtMoonB2F.asm). After the +-- Super Nerd is beaten, take one fossil and confirm he walks to the other +-- and says "All right. Then this is mine!" before it vanishes. +-- Runs DOME then HELIX paths (map is reset between). +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local Pokemon = require("src.pokemon.Pokemon") + + if #game.save.party == 0 then + table.insert(game.save.party, Pokemon.new(game.data, "PIKACHU", 20)) + end + + local function runPick(tag, x, y, takeName, leaveName, itemId, expectNerdX, expectNerdY, gotFlag) + game.save.flags = game.save.flags or {} + game.save.flags.EVENT_BEAT_MT_MOON_3_SUPER_NERD = true + game.save.flags.EVENT_GOT_DOME_FOSSIL = nil + game.save.flags.EVENT_GOT_HELIX_FOSSIL = nil + game.save.inventory.DOME_FOSSIL = nil + game.save.inventory.HELIX_FOSSIL = nil + game.save.objectToggles = game.save.objectToggles or {} + game.save.objectToggles.MT_MOON_B2F = nil + + U.teleport(game, "MT_MOON_B2F", x, y, "up") + local ow = game.overworld + U.wait(5) + + local function idle() + return game.stack:top() == ow and not ow.runner:isRunning() + and #ow.scriptMoves == 0 + end + local function mash(cond, label, cap) + for _ = 1, cap or 400 do + if cond() then return true end + U.tap(game, "a") + U.wait(3) + end + U.log("TIMEOUT:", tag, label) + return false + end + local function fossilVisible(name) + for _, n in ipairs(ow.npcs) do + if n.def and n.def.name == name then return true end + end + return false + end + local function nerd() + return ow:npcByIndex(1) + end + + U.shot(game, DIR .. ("/mtmoon_%s_0_before.png"):format(tag)) + U.log(tag, "before", takeName .. ":", tostring(fossilVisible(takeName)), + leaveName .. ":", tostring(fossilVisible(leaveName)), + "nerd:", nerd() and (nerd().cellX .. "," .. nerd().cellY) or "nil") + + U.tap(game, "a") + U.wait(24) + U.shot(game, DIR .. ("/mtmoon_%s_1_ask.png"):format(tag)) + U.tap(game, "a") -- YES + mash(function() + return game.stack:top() == ow or ow.runner:isRunning() + end, "received / walk start", 200) + + local sawMineText, midwalk = false, false + for _ = 1, 500 do + local n = nerd() + if not midwalk and n and (n.cellX ~= 12 or n.cellY ~= 8 + or n.moving or #ow.scriptMoves > 0) then + if n.cellX ~= 12 or n.cellY ~= 8 or n.moving then + midwalk = true + U.shot(game, DIR .. ("/mtmoon_%s_2_walk.png"):format(tag)) + end + end + local top = game.stack:top() + if top and top ~= ow and top.pages and top.done then + local flat = "" + for _, page in ipairs(top.pages) do + for _, line in ipairs(page) do flat = flat .. line .. " " end + end + if flat:find("this is mine", 1, true) then + sawMineText = true + U.shot(game, DIR .. ("/mtmoon_%s_3_mine.png"):format(tag)) + break + end + end + U.wait(1) + end + + mash(idle, "cutscene idle", 400) + U.shot(game, DIR .. ("/mtmoon_%s_4_done.png"):format(tag)) + + local n = nerd() + U.log(tag, "sawMineText:", tostring(sawMineText), + "midwalk:", tostring(midwalk)) + U.log(tag, "after", takeName .. ":", tostring(fossilVisible(takeName)), + leaveName .. ":", tostring(fossilVisible(leaveName))) + U.log(tag, "nerd at:", n and (n.cellX .. "," .. n.cellY) or "nil", + "expect:", expectNerdX .. "," .. expectNerdY) + U.log(tag, "flag:", gotFlag, tostring(game.save.flags[gotFlag]), + "bag:", tostring(game.save.inventory[itemId])) + end + + runPick("dome", 12, 7, + "MTMOONB2F_DOME_FOSSIL", "MTMOONB2F_HELIX_FOSSIL", "DOME_FOSSIL", + 13, 7, "EVENT_GOT_DOME_FOSSIL") + runPick("helix", 13, 7, + "MTMOONB2F_HELIX_FOSSIL", "MTMOONB2F_DOME_FOSSIL", "HELIX_FOSSIL", + 12, 7, "EVENT_GOT_HELIX_FOSSIL") + + U.log("DONE") + love.event.quit() +end diff --git a/tests/drivers/oak_test.lua b/tests/drivers/oak_test.lua index 92a8e626..d3683ea9 100644 --- a/tests/drivers/oak_test.lua +++ b/tests/drivers/oak_test.lua @@ -1,6 +1,5 @@ --- Driver: the full Pallet intro chain. Steps north to trigger Oak, --- follows him to the lab, takes a starter, watches the rival --- counter-pick, tries to leave (door gate + ambush battle). +-- Driver: Pallet intro (Oak escort → starter → rival ambush) plus the +-- #158 parcel-return / rival-join / Pokédex-gift cutscene. return function(game) local U = dofile("tests/drivers/util.lua") @@ -96,13 +95,68 @@ return function(game) U.log("final:", ow.map.id, ow.player.cellX, ow.player.cellY, "battled:", tostring(game.save.flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB), "battleSeen:", tostring(BattleSeen)) - U.log("stuck-state: runner=", tostring(ow.runner:isRunning()), - "moves=", #ow.scriptMoves, "top=", tostring(game.stack:top() == ow), - "transitioning=", tostring(ow.transitioning), - "emote=", tostring(ow.emote ~= nil)) - for i, mv in ipairs(ow.scriptMoves) do - U.log((" move %d: entity=%s dir=%s remaining=%d moving=%s"):format( - i, tostring(mv.entity.def and mv.entity.def.sprite or "PLAYER"), - tostring(mv.dir), mv.remaining, tostring(mv.entity.moving))) + + -- ---- #158: return Oak's Parcel → rival joins → Pokédex gift ---- + local Flags = require("src.script.Flags") + Flags.set(game.save, "EVENT_GOT_OAKS_PARCEL") + game.save.inventory.OAKS_PARCEL = 1 + -- desk Oak + both dexes visible; rival hidden until the script shows + -- him. Keep the starter-ball hides from the pick (Squirtle + Bulbasaur). + game.save.objectToggles = game.save.objectToggles or {} + game.save.objectToggles.OAKS_LAB = { + OAKSLAB_OAK1 = true, + OAKSLAB_OAK2 = false, + OAKSLAB_RIVAL = false, + OAKSLAB_POKEDEX1 = true, + OAKSLAB_POKEDEX2 = true, + OAKSLAB_SQUIRTLE_POKE_BALL = false, + OAKSLAB_BULBASAUR_POKE_BALL = false, + } + U.teleport(game, "OAKS_LAB", 5, 3, "up") + ow = game.overworld + U.wait(20) + U.shot(game, DIR .. "/oak_12_parcel_ready.png") + U.tap(game, "a") + U.wait(40) + U.shot(game, DIR .. "/oak_13_deliver.png") + local rivalSeen, atDeskShot, leaveShot = false, false, false + for _ = 1, 1200 do + if idle() and Flags.get(game.save, "EVENT_GOT_POKEDEX") + and not ow:npcByIndex(1) then + break + end + local rival = ow:npcByIndex(1) + local top = game.stack:top() + if rival and not rivalSeen then + rivalSeen = true + U.wait(24) + U.shot(game, DIR .. "/oak_14_rival_arrive.png") + end + if rival and rival.cellY <= 3 and not atDeskShot then + atDeskShot = true + U.shot(game, DIR .. "/oak_14b_rival_at_desk.png") + end + if rival and rival.cellY >= 6 and Flags.get(game.save, "EVENT_GOT_POKEDEX") + and not leaveShot then + leaveShot = true + U.shot(game, DIR .. "/oak_14c_rival_leave.png") + end + if top ~= ow then + U.tap(game, "a") + end + U.wait(4) end + mashUntil(idle, "pokedex cutscene done", 600) + U.shot(game, DIR .. "/oak_15_after_pokedex.png") + local toggles = game.save.objectToggles and game.save.objectToggles.OAKS_LAB + local rivalAfter = ow:npcByIndex(1) + U.log("parcel:", + "gotDex=", tostring(Flags.get(game.save, "EVENT_GOT_POKEDEX")), + "oakGot=", tostring(Flags.get(game.save, "EVENT_OAK_GOT_PARCEL")), + "rivalSeen=", tostring(rivalSeen), + "rivalGone=", tostring(rivalAfter == nil), + "atDesk=", tostring(atDeskShot), + "dex1=", tostring(toggles and toggles.OAKSLAB_POKEDEX1), + "dex2=", tostring(toggles and toggles.OAKSLAB_POKEDEX2), + "route22=", tostring(Flags.get(game.save, "EVENT_ROUTE22_RIVAL_WANTS_BATTLE"))) end diff --git a/tests/drivers/pc_deposit_test.lua b/tests/drivers/pc_deposit_test.lua new file mode 100644 index 00000000..0de8e0c1 --- /dev/null +++ b/tests/drivers/pc_deposit_test.lua @@ -0,0 +1,87 @@ +-- Driver: Player's PC deposit "How many?" with >7 bag stacks (#174). +-- pokered PrintListMenuEntries shows 4 names; PrintText + quantity box +-- must not overlap the list. +-- SHOT_DIR=/tmp/pc_deposit POKEPORT_DRIVER=tests/drivers/pc_deposit_test.lua love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local PlayerPC = require("src.ui.PlayerPC") + local ListMenu = require("src.ui.ListMenu") + local QuantityBox = require("src.ui.QuantityBox") + local Bag = require("src.inventory.Bag") + + local pass = true + local function check(cond, msg) + if cond then U.log("PASS: " .. msg) else pass = false; U.log("FAIL: " .. msg) end + end + local function topIs(mt) return getmetatable(game.stack:top()) == mt end + + U.teleport(game, "VIRIDIAN_POKECENTER", 13, 4, "up") + game.save.pcItems = {} + -- >7 distinct depositable stacks (bug reproduces only then) + local seed = { + "POTION", "SUPER_POTION", "ANTIDOTE", "AWAKENING", "BURN_HEAL", + "ICE_HEAL", "PARLYZ_HEAL", "FULL_HEAL", "REPEL", "POKE_BALL", + } + for _, id in ipairs(seed) do Bag.add(game.save, id, 5) end + check(#seed > 7, "seeded more than 7 bag stacks") + + game.stack:push(PlayerPC.new(game)) + U.wait(4) + U.tap(game, "down"); U.wait(1) -- DEPOSIT ITEM + U.tap(game, "a"); U.wait(4) + check(topIs(ListMenu), "DEPOSIT ITEM list opened") + local list = game.stack:top() + check(list.rows == 4, "deposit list uses 4 rows like PrintListMenuEntries") + check(list.messageBox == true, "deposit list uses messageBox footer") + check(#list.items > 7, "list has more than 7 depositable items") + U.shot(game, DIR .. "/pc_deposit_0_list.png") + + -- pick first non-key stack (seed items are all quantity-prompted) + U.tap(game, "a"); U.wait(4) + check(topIs(QuantityBox), "quantity box on stack") + check(list.footer == "How many?", "How many? footer set") + U.shot(game, DIR .. "/pc_deposit_1_how_many.png") + + -- layout probe: list rows stay above the text box (ty=12 → y=96) + local drawn = {} + local Font = require("src.render.Font") + local savedDraw, savedBox = Font.draw, Font.drawBox + Font.draw = function(text, x, y) + drawn[#drawn + 1] = { kind = "text", text = tostring(text), x = x, y = y } + return savedDraw(text, x, y) + end + Font.drawBox = function(tx, ty, tw, th) + drawn[#drawn + 1] = { kind = "box", tx = tx, ty = ty, tw = tw, th = th } + return savedBox(tx, ty, tw, th) + end + game.stack:draw() + Font.draw, Font.drawBox = savedDraw, savedBox + + local howY, qtyBox, textBox, maxItemY + for _, d in ipairs(drawn) do + if d.kind == "text" and d.text == "How many?" then howY = d.y end + if d.kind == "box" and d.ty == 9 and d.tw == 5 then qtyBox = d end + if d.kind == "box" and d.tx == 0 and d.ty == 12 and d.tw == 20 then + textBox = d + end + if d.kind == "text" and tostring(d.text):match("^x%d") and d.y then + if not maxItemY or d.y > maxItemY then maxItemY = d.y end + end + end + check(howY == 112, "How many? sits on text-box first line (y=112)") + check(textBox ~= nil, "bottom PrintText box drawn") + check(qtyBox ~= nil, "quantity box at pokered row 9") + check(maxItemY and maxItemY <= 72, "last visible item row above qty/text (y<=72)") + if maxItemY and howY then + check(maxItemY + 8 <= howY, "item glyphs do not overlap How many?") + end + + U.tap(game, "a"); U.wait(6) -- confirm x01 + U.shot(game, DIR .. "/pc_deposit_2_stored.png") + check(type(list.footer) == "string" and list.footer:find("stored", 1, true), + "stored-via-PC footer after deposit") + + U.log(pass and "RESULT: ALL PASS" or "RESULT: SEE FAILURES ABOVE") + love.event.quit(pass and 0 or 1) +end diff --git a/tests/drivers/pc_menu_sides_test.lua b/tests/drivers/pc_menu_sides_test.lua new file mode 100644 index 00000000..2e2264d9 --- /dev/null +++ b/tests/drivers/pc_menu_sides_test.lua @@ -0,0 +1,37 @@ +-- Driver: Bill's PC vs player's PC top menus (#176). Both should open +-- on the left (pokered BillsPCMenu / PlayersPCMenu TextBoxBorder at 0,0). +-- SHOT_DIR=/tmp/pc_sides POKEPORT_DRIVER=tests/drivers/pc_menu_sides_test.lua love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local BoxMenu = require("src.ui.BoxMenu") + local PlayerPC = require("src.ui.PlayerPC") + + U.teleport(game, "VIRIDIAN_POKECENTER", 13, 4, "up") + game.save.flags = game.save.flags or {} + game.save.flags.EVENT_MET_BILL = true + + game.stack:push(BoxMenu.new(game)) + U.wait(5) + local bills = game.stack:top() + U.log(("bills tx=%d ty=%d tw=%d th=%d"):format( + bills.tx, bills.ty, bills.tw, bills.th)) + U.shot(game, DIR .. "/pc_0_bills.png") + U.wait(8) -- let async captureScreenshot flush before pop + game.stack:pop() + U.wait(2) + + game.stack:push(PlayerPC.new(game)) + U.wait(5) + local player = game.stack:top() + U.log(("player tx=%d ty=%d tw=%d th=%d"):format( + player.tx, player.ty, player.tw, player.th)) + U.shot(game, DIR .. "/pc_1_player.png") + U.wait(8) + + local sameSide = player.tx == bills.tx and player.ty == bills.ty + U.log(sameSide and "PASS: both menus share top-left origin" + or "FAIL: menus not on the same side") + U.log("DONE") + love.event.quit() +end diff --git a/tests/drivers/pc_release_two_test.lua b/tests/drivers/pc_release_two_test.lua new file mode 100644 index 00000000..f134120d --- /dev/null +++ b/tests/drivers/pc_release_two_test.lua @@ -0,0 +1,74 @@ +-- Driver: Bill's PC RELEASE two mons in one list session (#171). +-- SHOT_DIR=/tmp/pc_release POKEPORT_DRIVER=tests/drivers/pc_release_two_test.lua love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local Pokemon = require("src.pokemon.Pokemon") + local Boxes = require("src.pokemon.Boxes") + local BoxMenu = require("src.ui.BoxMenu") + local ListMenu = require("src.ui.ListMenu") + local ChoiceBox = require("src.ui.ChoiceBox") + local TextBox = require("src.render.TextBox") + + local pass = true + local function check(cond, msg) + if cond then U.log("PASS: " .. msg) else pass = false; U.log("FAIL: " .. msg) end + end + local function topIs(mt) return getmetatable(game.stack:top()) == mt end + local function mash(btn, cond, n) + for _ = 1, (n or 240) do + if cond() then return true end + U.tap(game, btn) + U.wait(2) + end + return false + end + + U.teleport(game, "VIRIDIAN_POKECENTER", 13, 4, "up") + game.save.flags = game.save.flags or {} + game.save.flags.EVENT_MET_BILL = true + game.save.options = game.save.options or {} + game.save.options.textSpeed = 1 + + local box = Boxes.active(game.save) + for i = #box, 1, -1 do box[i] = nil end + box[1] = Pokemon.new(game.data, "RATTATA", 5) + box[2] = Pokemon.new(game.data, "PIDGEY", 6) + box[3] = Pokemon.new(game.data, "CATERPIE", 4) + check(#box == 3, "seeded 3 boxed mons") + + game.stack:push(BoxMenu.new(game)) + U.wait(4) + U.tap(game, "down"); U.wait(1) + U.tap(game, "down"); U.wait(1) + U.tap(game, "a") -- RELEASE + check(mash("a", function() return topIs(ListMenu) end, 20), "RELEASE list opened") + U.shot(game, DIR .. "/pc_release_0_list.png") + + local function releaseOne(label) + check(topIs(ListMenu), label .. ": still on release list") + local before = #Boxes.active(game.save) + U.tap(game, "a") -- pick current row + check(mash("a", function() return topIs(ChoiceBox) end), label .. ": confirm choice") + U.shot(game, DIR .. "/pc_release_" .. label .. "_confirm.png") + U.tap(game, "up"); U.wait(1) -- defaultNo -> YES + U.tap(game, "a") + check(mash("a", function() + return topIs(ListMenu) or (#game.stack.states > 0 and topIs(TextBox) == false) + end), label .. ": bye text advancing") + check(mash("a", function() return topIs(ListMenu) end), label .. ": back on list") + local after = #Boxes.active(game.save) + check(after == before - 1, label .. ": box count " .. before .. " -> " .. after) + U.shot(game, DIR .. "/pc_release_" .. label .. "_list.png") + end + + releaseOne("1st") + releaseOne("2nd") + + check(#Boxes.active(game.save) == 1, "one mon remains after two releases") + check(topIs(ListMenu), "still in RELEASE list without re-entering") + U.shot(game, DIR .. "/pc_release_done.png") + + U.log(pass and "RESULT: ALL PASS" or "RESULT: SEE FAILURES ABOVE") + love.event.quit(pass and 0 or 1) +end diff --git a/tests/drivers/rival_walkoff_test.lua b/tests/drivers/rival_walkoff_test.lua new file mode 100644 index 00000000..58467f40 --- /dev/null +++ b/tests/drivers/rival_walkoff_test.lua @@ -0,0 +1,203 @@ +-- Driver: #154 Route 22 rival exit direction + #168 Cerulean Bill text +-- and southbound walk-off. +-- +-- SHOT_DIR=/tmp/rival_walkoff POKEPORT_SPEED=8 \ +-- POKEPORT_DRIVER=tests/drivers/rival_walkoff_test.lua love . + +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/rival_walkoff" + os.execute("mkdir -p " .. DIR) + + local Pokemon = require("src.pokemon.Pokemon") + local Flags = require("src.script.Flags") + local Zoom = require("src.render.Zoom") + if game.save.options then game.save.options.zoom = 0 end + Zoom.reset() + + local function tank() + local mon = Pokemon.new(game.data, "MEWTWO", 100) + mon.moves = { + { id = "PSYCHIC_M", pp = 99 }, + { id = "THUNDERBOLT", pp = 99 }, + { id = "ICE_BEAM", pp = 99 }, + { id = "RECOVER", pp = 99 }, + } + return mon + end + + game.save.party = { tank() } + game.save.player = game.save.player or {} + game.save.player.name = "RED" + game.save.player.rival = "BLUE" + Flags.set(game.save, "EVENT_CHOSE_SQUIRTLE") + + local function idle(ow) + return game.stack:top() == ow and not ow.runner:isRunning() + and #ow.scriptMoves == 0 and not ow.transitioning + end + + local function mashUntil(cond, label, cap) + for _ = 1, cap or 800 do + if cond() then return true end + if game.stack:top() ~= game.overworld then U.tap(game, "a") end + U.wait(2) + end + U.log("TIMEOUT waiting for " .. label) + return false + end + + local function rivalNpc() + local ow = game.overworld + for _, npc in ipairs(ow.npcs or {}) do + if npc.def and npc.def.name and npc.def.name:find("RIVAL") then + return npc + end + end + end + + -- Fight until the battle state leaves the stack. Do NOT mash the + -- post-battle script text -- the caller screenshots that. + local function winBattle(label) + local ow = game.overworld + local sawBattle = false + for frames = 1, 5000 do + local top = game.stack:top() + if top and top.phase then + sawBattle = true + if top.phase == "menu" then + top.menuIndex = 1 + elseif top.phase == "moveSelect" then + top.moveIndex = 1 + end + U.tap(game, "a") + if frames > 2400 and top.onFinish then + U.log("force-finishing stalled", label) + top.onFinish("win") + if game.stack:top() == top then game.stack:pop() end + end + elseif sawBattle then + -- battle popped: either a TextBox or the overworld runner + return true + elseif top ~= ow then + U.tap(game, "a") -- pre-battle dialogue + end + U.wait(2) + end + U.log("TIMEOUT winBattle", label) + return false + end + + -- ---------------------------------------------------------- #154 + -- Route 22 optional post-lab rival: after the fight he must walk + -- toward Viridian (right / down), not back toward the League. + Flags.set(game.save, "EVENT_GOT_POKEDEX") + Flags.clear(game.save, "EVENT_BEAT_BROCK") + Flags.clear(game.save, "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE") + Flags.clear(game.save, "EVENT_BEAT_GIOVANNI") + Flags.clear(game.save, "EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE") + + U.teleport(game, "ROUTE_22", 30, 5, "left") + local ow = game.overworld + U.shot(game, DIR .. "/r22_0_before.png") + U.hold(game, "left", 20) + + mashUntil(function() + return ow.runner:isRunning() or game.stack:top() ~= ow + end, "route22 ambush", 200) + U.shot(game, DIR .. "/r22_1_ambush.png") + mashUntil(function() return game.stack:top() ~= ow end, "route22 battle", 800) + winBattle("route22") + + local sawExit, exitFacing, exitX, exitY = false, nil, nil, nil + for _ = 1, 600 do + if game.stack:top() ~= ow then U.tap(game, "a") end + local r = rivalNpc() + if r and game.save.flags.EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE then + if (r.facing == "right" or r.facing == "down" or r.cellX > 28) + and not sawExit then + sawExit = true + exitFacing, exitX, exitY = r.facing, r.cellX, r.cellY + U.shot(game, DIR .. "/r22_2_exit.png") + U.log("r22 exit:", exitFacing, exitX, exitY) + elseif sawExit and (r.facing == "down" or r.cellY > 5) then + U.shot(game, DIR .. "/r22_3_exit_mid.png") + U.log("r22 exit mid:", r.facing, r.cellX, r.cellY) + break + end + end + if idle(ow) and game.save.flags.EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE then + break + end + U.wait(2) + end + mashUntil(function() return idle(ow) end, "route22 done", 800) + U.shot(game, DIR .. "/r22_4_done.png") + U.log("r22 beat:", tostring(game.save.flags.EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE), + "exitFacing:", tostring(exitFacing)) + + -- ---------------------------------------------------------- #168 + -- Nugget Bridge: Bill dialogue, then walk into Cerulean (south). + Flags.clear(game.save, "EVENT_BEAT_CERULEAN_RIVAL") + game.save.party = { tank() } + + U.teleport(game, "CERULEAN_CITY", 20, 7, "up") + ow = game.overworld + U.shot(game, DIR .. "/cer_0_before.png") + U.hold(game, "up", 20) + + mashUntil(function() + return ow.runner:isRunning() or game.stack:top() ~= ow + end, "cerulean ambush", 200) + U.shot(game, DIR .. "/cer_1_ambush.png") + mashUntil(function() return game.stack:top() ~= ow end, "cerulean battle", 800) + winBattle("cerulean") + + local function boxText(top) + if not top or type(top.pages) ~= "table" then return "" end + local parts = {} + for _, page in ipairs(top.pages) do + if type(page) == "table" then + for _, line in ipairs(page) do parts[#parts + 1] = tostring(line) end + end + end + return table.concat(parts, " ") + end + + local billShot, exitShot, lastBox, postBoxes = false, false, nil, 0 + for _ = 1, 2000 do + local top = game.stack:top() + if top ~= ow then + local t = boxText(top) + if game.save.flags.EVENT_BEAT_CERULEAN_RIVAL and t ~= "" and top ~= lastBox then + lastBox = top + postBoxes = postBoxes + 1 + U.log("post-battle box", postBoxes, t:sub(1, 80)) + -- Bill line is the second post-flag text (after DefeatedText) + if not billShot and (t:find("BILL") or t:find("Storage") + or t:find("invented") or t:find("MANIAC") or t:find("pages") + or t:find("guess what") or postBoxes >= 2) then + billShot = true + for _ = 1, 30 do U.wait(2); U.tap(game, "a") end -- advance into Bill body + U.wait(20) + U.shot(game, DIR .. "/cer_2_bill.png") + U.log("bill text:", boxText(game.stack:top()):sub(1, 160)) + end + end + U.tap(game, "a") + elseif game.save.flags.EVENT_BEAT_CERULEAN_RIVAL then + local r = rivalNpc() + if r and r.cellY and r.cellY >= 6 and r.facing == "down" and not exitShot then + exitShot = true + U.shot(game, DIR .. "/cer_3_exit.png") + U.log("cer exit:", r.facing, r.cellX, r.cellY) + end + if idle(ow) then break end + end + U.wait(2) + end + mashUntil(function() return idle(ow) end, "cerulean done", 800) + U.shot(game, DIR .. "/cer_4_done.png") + U.log("cer beat:", tostring(game.save.flags.EVENT_BEAT_CERULEAN_RIVAL), + "billShot:", tostring(billShot), "exitShot:", tostring(exitShot)) +end diff --git a/tests/drivers/route.lua b/tests/drivers/route.lua index 69dd7c67..40aadd12 100644 --- a/tests/drivers/route.lua +++ b/tests/drivers/route.lua @@ -731,6 +731,11 @@ local function switchTo(battle, slot) end local t = replacementMenu() if not t or t.index ~= slot then break end + press("a") -- select mon -> SWITCH / STATS / CANCEL + U.wait(8) + t = replacementMenu() + if not (t and t.submenu) then break end + -- SWITCH is first (SwitchStatsCancelText) press("a") U.wait(10) ok = true diff --git a/tests/drivers/ss_anne_kitchen_trash_test.lua b/tests/drivers/ss_anne_kitchen_trash_test.lua new file mode 100644 index 00000000..c9605d2c --- /dev/null +++ b/tests/drivers/ss_anne_kitchen_trash_test.lua @@ -0,0 +1,60 @@ +-- Driver: S.S. Anne kitchen PrintTrashText bins (#188). +-- Teleports beside each bin, interacts, screenshots the dialogue. + +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local TextBox = require("src.render.TextBox") + + local pass = true + local function check(cond, msg) + if cond then U.log("PASS: " .. msg) else pass = false; U.log("FAIL: " .. msg) end + end + local function topIs(mt) return getmetatable(game.stack:top()) == mt end + + local bins = game.data.field.hiddenExtras + and game.data.field.hiddenExtras.printTrash + and game.data.field.hiddenExtras.printTrash.SS_ANNE_KITCHEN + check(bins and #bins == 2, "SS_ANNE_KITCHEN has 2 PrintTrashText bins") + + for i, bin in ipairs(bins or {}) do + -- stand west of the bin facing right (cooks occupy the column) + U.teleport(game, "SS_ANNE_KITCHEN", bin.x - 1, bin.y, "right") + local ow = game.overworld + local fx, fy = ow.player:facingCell() + U.log(("bin %d: at (%d,%d) facing %s -> (%d,%d)"):format( + i, ow.player.cellX, ow.player.cellY, ow.player.facing, fx, fy)) + check(fx == bin.x and fy == bin.y, ("facing kitchen trash at (%d,%d)"):format(bin.x, bin.y)) + + U.tap(game, "a") + local opened = false + for _ = 1, 60 do + if topIs(TextBox) then opened = true; break end + U.wait(2) + end + check(opened, ("bin %d opened a TextBox"):format(i)) + if opened then + local box = game.stack:top() + for _ = 1, 180 do + if not topIs(TextBox) then break end + if box.waiting or box.done then break end + U.wait(1) + end + U.wait(4) + local page = box.pages and box.pages[1] + local body = type(page) == "table" and table.concat(page, "\n") + or (type(page) == "string" and page or "") + U.log("dialogue:", body:gsub("\n", "\\n"):sub(1, 80)) + check(body:find("only trash here", 1, true) ~= nil, + ("bin %d shows trash dialogue"):format(i)) + U.shot(game, DIR .. ("/ss_anne_kitchen_trash_%d.png"):format(i)) + for _ = 1, 40 do + if not topIs(TextBox) then break end + U.tap(game, "a") + U.wait(2) + end + end + end + + U.log(pass and "RESULT: ALL PASS" or "RESULT: SEE FAILURES ABOVE") +end diff --git a/tests/drivers/ss_anne_rooms_order_test.lua b/tests/drivers/ss_anne_rooms_order_test.lua new file mode 100644 index 00000000..a85690c0 --- /dev/null +++ b/tests/drivers/ss_anne_rooms_order_test.lua @@ -0,0 +1,49 @@ +-- Driver: #189 S.S. Anne 1F door→room order. Enter each hallway cabin +-- door left→right; screenshot room contents (SHOT_DIR). +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots-189" + + local doors = {} + for i, w in ipairs(game.data.maps.SS_ANNE_1F.warps) do + if w.destMap == "SS_ANNE_1F_ROOMS" then + doors[#doors + 1] = { index = i, x = w.x, y = w.y, destWarp = w.destWarp } + end + end + table.sort(doors, function(a, b) return a.x < b.x end) + + U.teleport(game, "SS_ANNE_1F_ROOMS", 12, 8, "down") + U.shot(game, DIR .. "/anne189_rooms_overview.png") + + U.teleport(game, "SS_ANNE_1F", 20, 10, "up") + U.shot(game, DIR .. "/anne189_hall_overview.png") + + for n, d in ipairs(doors) do + U.teleport(game, "SS_ANNE_1F", d.x, d.y + 1, "up") + U.shot(game, string.format("%s/anne189_door%d_hall_x%d.png", DIR, n, d.x)) + U.hold(game, "up", 24) + local ow = game.overworld + for _ = 1, 120 do + U.wait(1) + if ow.map.id == "SS_ANNE_1F_ROOMS" and not ow.transitioning + and #(ow.scriptMoves or {}) == 0 and not ow.player.moving then + break + end + end + U.wait(10) + local names = {} + local col = math.floor(ow.player.cellX / 10) + local row = math.floor(ow.player.cellY / 10) + for _, npc in ipairs(ow.npcs or {}) do + if math.floor(npc.cellX / 10) == col and math.floor(npc.cellY / 10) == row then + names[#names + 1] = npc.def.name or "?" + end + end + table.sort(names) + U.log(string.format( + "door%d hall(%d,%d)#%d destWarp=%d -> %s (%d,%d) cell=%d,%d npcs: %s", + n, d.x, d.y, d.index, d.destWarp, ow.map.id, + ow.player.cellX, ow.player.cellY, col, row, table.concat(names, ","))) + U.shot(game, string.format("%s/anne189_door%d_room.png", DIR, n)) + end +end diff --git a/tests/drivers/status_poison_test.lua b/tests/drivers/status_poison_test.lua new file mode 100644 index 00000000..f2a57680 --- /dev/null +++ b/tests/drivers/status_poison_test.lua @@ -0,0 +1,84 @@ +-- Driver for #169: poison status icon must appear AFTER move anim + +-- "was poisoned!" text, not during the "used POISONPOWDER!" announce. +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local Pokemon = require("src.pokemon.Pokemon") + local BattleState = require("src.battle.BattleState") + + local mon = Pokemon.new(game.data, "BULBASAUR", 12) + mon.moves[1] = { id = "POISONPOWDER", pp = 20 } + mon.moves[2] = { id = "TACKLE", pp = 20 } + table.insert(game.save.party, 1, mon) + U.teleport(game, "ROUTE_1", 5, 5, "down") + local ow = game.overworld + + local battle = BattleState.newWild(game, "PIDGEY", 8) + battle.onFinish = function() end + -- force hit + make the foe never act (skip second half of the turn) + battle.rng = function(a, b) return a or 0 end + battle.enemyAction = function() return nil end + ow:pushBattle(battle) + + local function waitFor(cond, max) + for _ = 1, max or 600 do + if cond() then return true end + U.wait(1) + end + return false + end + local function mashUntil(cond, max) + for _ = 1, max or 80 do + if cond() then return true end + U.tap(game, "a") + U.wait(4) + end + return false + end + + mashUntil(function() return battle.phase == "menu" end) + U.shot(game, DIR .. "/poison_0_menu.png") + + U.tap(game, "a"); U.wait(8) -- FIGHT + U.tap(game, "a"); U.wait(2) -- POISONPOWDER + + -- announce: "BULBASAUR used POISONPOWDER!" — no PSN on HUD yet + waitFor(function() + return battle.current and battle.current.text + and battle.current.text:find("POISONPOWDER", 1, true) + end, 120) + U.shot(game, DIR .. "/poison_1_announce.png") + U.log(("announce shownStatus=%s mon.status=%s"):format( + tostring(battle.enemy.shownStatus), tostring(battle.enemy.mon.status))) + + -- mash through announce into the move anim / poisoned text + local sawAnim, sawText = false, false + for _ = 1, 200 do + if battle.animPlaying or battle.animName == "POISONPOWDER" then + if not sawAnim then + sawAnim = true + U.shot(game, DIR .. "/poison_2_anim.png") + U.log(("anim shownStatus=%s"):format(tostring(battle.enemy.shownStatus))) + end + end + if battle.current and battle.current.text + and battle.current.text:find("poisoned", 1, true) then + if not sawText then + sawText = true + U.shot(game, DIR .. "/poison_3_text.png") + U.log(("text shownStatus=%s"):format(tostring(battle.enemy.shownStatus))) + end + end + if battle.enemy.shownStatus == "PSN" and sawText then + U.shot(game, DIR .. "/poison_4_icon.png") + U.log("HUD PSN revealed after poisoned text") + break + end + U.tap(game, "a") + U.wait(2) + end + + waitFor(function() return battle.phase == "menu" end, 200) + U.shot(game, DIR .. "/poison_5_menu.png") + U.log(("final shownStatus=%s"):format(tostring(battle.enemy.shownStatus))) +end diff --git a/tests/drivers/tm_battle_test.lua b/tests/drivers/tm_battle_test.lua new file mode 100644 index 00000000..33b29c66 --- /dev/null +++ b/tests/drivers/tm_battle_test.lua @@ -0,0 +1,60 @@ +-- Driver: try a TM from the battle bag; Gen 1 refuses with Oak's +-- "This isn't the time to use that!" (ItemUseTMHM -> ItemUseNotTime). +-- +-- SHOT_DIR=/tmp/tm_battle_shots POKEPORT_DRIVER=tests/drivers/tm_battle_test.lua love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local Pokemon = require("src.pokemon.Pokemon") + local Bag = require("src.inventory.Bag") + local TextBox = require("src.render.TextBox") + local ListMenu = require("src.ui.ListMenu") + + table.insert(game.save.party, Pokemon.new(game.data, "CHARMANDER", 12)) + Bag.add(game.save, "TM_TOXIC", 1) + U.teleport(game, "ROUTE_1", 5, 5, "down") + local ow = game.overworld + + local BattleState = require("src.battle.BattleState") + local battle = BattleState.newWild(game, "PIDGEY", 8) + battle.onFinish = function() end + ow:pushBattle(battle) + + local function mashUntil(cond, max) + for _ = 1, max or 80 do + if cond() then return true end + U.tap(game, "a") + U.wait(4) + end + return false + end + + local function pageText(box) + if not box or getmetatable(box) ~= TextBox then return "" end + return table.concat(box.pages[box.pageIndex] or {}, "\n") + end + + U.log("to menu:", mashUntil(function() return battle.phase == "menu" end)) + U.shot(game, DIR .. "/tm_battle_0_menu.png") + + -- FIGHT/PKMN / ITEM/RUN: down to ITEM, then A + U.tap(game, "down"); U.wait(4) + U.tap(game, "a"); U.wait(12) + U.shot(game, DIR .. "/tm_battle_1_bag.png") + U.log("bag open:", getmetatable(game.stack:top()) == ListMenu) + + U.tap(game, "a") + mashUntil(function() + local top = game.stack:top() + return getmetatable(top) == TextBox and top.waiting + and pageText(top):find("isn't the", 1, true) + end, 40) + U.shot(game, DIR .. "/tm_battle_2_reject.png") + + local top = game.stack:top() + local text = pageText(top) + U.log("reject TextBox:", getmetatable(top) == TextBox) + U.log("oak text:", text:find("isn't the", 1, true) and "ok" or text) + U.log("tm still in bag:", (game.save.inventory.TM_TOXIC or 0) >= 1) + U.log("not booted:", not text:find("Booted", 1, true)) +end diff --git a/tests/drivers/trade_anim_test.lua b/tests/drivers/trade_anim_test.lua new file mode 100644 index 00000000..3363a9f1 --- /dev/null +++ b/tests/drivers/trade_anim_test.lua @@ -0,0 +1,155 @@ +-- Driver: NPC in-game trade cable animation (InternalClockTradeAnim). +-- Vermilion Trade House: SPEAROW -> FARFETCH'D (DUX). +-- +-- SHOT_DIR=/tmp/trade_shots POKEPORT_DRIVER=tests/drivers/trade_anim_test.lua love . + +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/trade_shots" + local Pokemon = require("src.pokemon.Pokemon") + local TradeAnim = require("src.ui.TradeAnim") + local TextBox = require("src.render.TextBox") + local PartyMenu = require("src.ui.PartyMenu") + local ChoiceBox = require("src.ui.ChoiceBox") + + local function topIs(cls) + return getmetatable(game.stack:top()) == cls + end + + game.save.party = { Pokemon.new(game.data, "SPEAROW", 10) } + U.teleport(game, "VERMILION_TRADE_HOUSE", 3, 6, "up") + U.wait(5) + U.shot(game, DIR .. "/trade_00_house.png") + + U.tap(game, "a") + U.wait(10) + for _ = 1, 200 do + if topIs(ChoiceBox) then break end + U.tap(game, "a") + U.wait(2) + end + U.shot(game, DIR .. "/trade_01_offer.png") + U.tap(game, "a") -- YES + U.wait(6) + + for _ = 1, 60 do + if topIs(PartyMenu) then break end + U.wait(1) + end + U.log("party menu:", topIs(PartyMenu)) + U.shot(game, DIR .. "/trade_02_party.png") + U.tap(game, "a") + U.wait(6) + + for _ = 1, 200 do + if topIs(TradeAnim) then break end + U.tap(game, "a") + U.wait(2) + end + local anim = game.stack:top() + if getmetatable(anim) ~= TradeAnim then + U.log("FAIL: TradeAnim never appeared") + return + end + U.log("TradeAnim phase:", anim.phase) + + local function ffUntil(phase, cap) + for _ = 1, cap or 3000 do + if anim.phase == phase or anim.phase == "done" then break end + if anim.waitingText or topIs(TextBox) then + U.wait(1) + else + anim:update(1 / 60) + end + end + U.wait(1) + end + + -- hold mid show_player (scx settled, mon visible) + ffUntil("show_player", 1) + while anim.phase == "show_player" and (anim.sub ~= "hold" or anim.scx > 0) do + anim:update(1 / 60) + end + for _ = 1, 10 do anim:update(1 / 60) end + U.wait(1) + U.shot(game, DIR .. "/trade_03_show_player.png") + U.log("show_player sub=", anim.sub, "scx=", anim.scx) + + ffUntil("open_cable", 500) + -- mid scroll-in + while anim.phase == "open_cable" and anim.scx > 40 do + anim:update(1 / 60) + end + U.wait(1) + U.shot(game, DIR .. "/trade_04_open_cable.png") + U.log("open_cable scx=", anim.scx) + + ffUntil("ball_enter", 200) + while anim.phase == "ball_enter" and anim.ballX < 0x80 do + anim:update(1 / 60) + end + U.wait(1) + U.shot(game, DIR .. "/trade_05_ball_enter.png") + U.log("ball at", anim.ballX, anim.ballY) + + ffUntil("transfer_lr", 400) + for _ = 1, 24 do anim:update(1 / 60) end + U.wait(1) + U.shot(game, DIR .. "/trade_06_transfer_lr.png") + + ffUntil("went_to", 800) + for _ = 1, 600 do + if topIs(TextBox) then + local tb = game.stack:top() + if tb.done then break end + -- finish typewriter so both lines are visible + if tb.codes then tb.charIndex = #tb.codes end + if tb.pages and tb.pageIndex and tb.lineIndex then + local page = tb.pages[tb.pageIndex] + if page and tb.lineIndex < #page then + tb.lineIndex = #page + tb.shown = {} + for _, line in ipairs(page) do + local codes = require("src.render.Font").encode(line) + tb.shown[#tb.shown + 1] = codes + end + tb.charIndex = #(tb.shown[#tb.shown] or {}) + tb.done = true + end + end + break + end + U.wait(1) + end + U.wait(2) + U.shot(game, DIR .. "/trade_07_went_to.png") + U.log("went-to:", topIs(TextBox)) + + ffUntil("transfer_rl", 2500) + for _ = 1, 16 do anim:update(1 / 60) end + U.wait(1) + U.shot(game, DIR .. "/trade_08_transfer_rl.png") + + ffUntil("show_enemy", 800) + while anim.phase == "show_enemy" and not anim.monVisible do + anim:update(1 / 60) + end + for _ = 1, 30 do anim:update(1 / 60) end + U.wait(1) + U.shot(game, DIR .. "/trade_09_show_enemy.png") + + for _ = 1, 5000 do + if game.stack:top() == game.overworld then break end + local top = game.stack:top() + if getmetatable(top) == TradeAnim and not top.waitingText then + top:update(1 / 60) + end + U.tap(game, "a") + U.wait(1) + end + U.shot(game, DIR .. "/trade_10_done.png") + local mon = game.save.party[1] + U.log("party:", mon and mon.species, "nick:", mon and mon.nickname, + "ot:", mon and mon.ot) + U.log("done") +end diff --git a/tests/drivers/trainer_card_test.lua b/tests/drivers/trainer_card_test.lua new file mode 100644 index 00000000..7c3dcc62 --- /dev/null +++ b/tests/drivers/trainer_card_test.lua @@ -0,0 +1,34 @@ +-- Driver: trainer card with empty badge slots (gym leader faces) and a +-- partial set (face/badge mix), matching DrawBadges in pokered. +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + U.teleport(game, "PALLET_TOWN", 10, 8, "down") + game.save.player.name = "RED" + game.save.money = 3000 + game.save.playTime = 3661 + game.save.inventory = game.save.inventory or {} + + local TrainerCard = require("src.ui.TrainerCard") + + -- no badges: every slot should show the gym leader face + for _, id in ipairs({ + "BOULDERBADGE", "CASCADEBADGE", "THUNDERBADGE", "RAINBOWBADGE", + "SOULBADGE", "MARSHBADGE", "VOLCANOBADGE", "EARTHBADGE", + }) do + game.save.inventory[id] = nil + end + game.stack:push(TrainerCard.new(game)) + U.wait(5) + U.shot(game, DIR .. "/trainer_card_0_faces.png") + U.tap(game, "b"); U.wait(3) + + -- boulder + cascade owned: first two slots swap to badges + game.save.inventory.BOULDERBADGE = true + game.save.inventory.CASCADEBADGE = true + game.stack:push(TrainerCard.new(game)) + U.wait(5) + U.shot(game, DIR .. "/trainer_card_1_partial.png") + U.tap(game, "b"); U.wait(3) + U.log("TRAINER_CARD_DRIVER: done") +end diff --git a/tests/drivers/trainer_sight_walls_test.lua b/tests/drivers/trainer_sight_walls_test.lua new file mode 100644 index 00000000..3b27fedb --- /dev/null +++ b/tests/drivers/trainer_sight_walls_test.lua @@ -0,0 +1,70 @@ +-- Driver: trainer sight must not engage off the GB screen (#153/#183). +-- Oracle: engine/overworld/trainer_sight.asm TrainerEngage (IMAGEINDEX=$ff) +-- + movement.asm CheckSpriteAvailability (10x9 window). 8-bit pixel +-- distance wraps at ~12/28 tiles; without the on-screen gate, Viridian +-- Forest / Victory Road trainers aggro through trees and walls. +-- +-- Scene A: VIRIDIAN_FOREST (18,33), west of tree wall vs Bug Catcher +-- (30,33) LEFT range 4 — dx=12 pixel-wrap must NOT engage. +-- Scene B: same trainer at (26,33) on-screen — MUST engage + walk-up. +-- Scene C: VICTORY_ROAD_2F (0,9) vs Hiker (12,9) — dx=12 must NOT engage. + +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local function trainerByIndex(ow, index) + for _, npc in ipairs(ow.npcs) do + if npc.def.index == index then return npc end + end + end + + -- --- A: Viridian Forest wrap through trees (dx=12, walkable west of wall) --- + U.teleport(game, "VIRIDIAN_FOREST", 18, 33, "right") + local ow = game.overworld + local t = trainerByIndex(ow, 2) + U.wait(10) + U.log("VF far: player", ow.player.cellX, ow.player.cellY, + "trainer", t and t.cellX, t and t.cellY, + "engaging", tostring(ow.engaging)) + assert(t and t.cellX == 30 and t.cellY == 33, "VF Bug Catcher at (30,33)") + assert(not ow.engaging, "VF: off-screen trainer must not engage through trees") + assert(t.cellX == 30 and t.cellY == 33, "VF: trainer must not walk through trees") + U.shot(game, DIR .. "/153_vf_far_no_aggro.png") + + -- --- B: real on-screen sight line (same corridor, no tree wall) --- + U.teleport(game, "VIRIDIAN_FOREST", 26, 33, "right") + ow = game.overworld + t = trainerByIndex(ow, 2) + local guard = 0 + while not ow.engaging and guard < 30 do + guard = guard + 1 + U.wait(1) + end + U.log("VF near: engaging", tostring(ow.engaging), + "emote", tostring(ow.emote and ow.emote.frames), + "trainer", t and t.cellX, t and t.cellY) + assert(ow.engaging, "VF: distance-4 on-screen must engage") + U.shot(game, DIR .. "/153_vf_near_engage.png") + -- EmotionBubble 60f + (dist-1)*16 walk frames + U.wait(60 + 3 * 16 + 10) + U.log("VF walkup: trainer", t.cellX, t.cellY, "player", ow.player.cellX, ow.player.cellY) + assert(t.cellY == 33 and t.cellX >= 26 and t.cellX <= 30, + "VF: walk-up stays on the open corridor") + assert(t.cellX == 27, "VF: trainer stops adjacent after distance-1 walk-up") + U.shot(game, DIR .. "/153_vf_near_walkup.png") + + -- --- C: Victory Road 2F wrap (dx=12) --- + U.teleport(game, "VICTORY_ROAD_2F", 0, 9, "right") + ow = game.overworld + t = trainerByIndex(ow, 1) + U.wait(10) + U.log("VR2 far: player", ow.player.cellX, ow.player.cellY, + "trainer", t and t.cellX, t and t.cellY, + "engaging", tostring(ow.engaging)) + assert(t and t.cellX == 12 and t.cellY == 9, "VR2 Hiker at (12,9)") + assert(not ow.engaging, "VR2: off-screen trainer must not engage") + assert(t.cellX == 12 and t.cellY == 9, "VR2: trainer must not walk through walls") + U.shot(game, DIR .. "/183_vr2_far_no_aggro.png") + + U.log("trainer_sight_walls_test OK") +end diff --git a/tests/drivers/util.lua b/tests/drivers/util.lua index 4f843460..9ab46208 100644 --- a/tests/drivers/util.lua +++ b/tests/drivers/util.lua @@ -33,7 +33,15 @@ end function U.shot(game, path) game.capturePath = path - U.wait(2) -- let the capture flush + -- love.draw consumes capturePath once per rendered frame, but fast runs + -- (POKEPORT_SPEED) step the driver many times per render; spin until the + -- capture lands so later actions can't outrun it + for _ = 1, 120 do + if not game.capturePath then break end + frame = frame + 1 + coroutine.yield() + end + U.wait(1) end -- skip the intro movie + title into a fresh overworld game diff --git a/tests/engine/pc_menu_sides.lua b/tests/engine/pc_menu_sides.lua new file mode 100644 index 00000000..93b8bc3d --- /dev/null +++ b/tests/engine/pc_menu_sides.lua @@ -0,0 +1,33 @@ +-- Bill's PC vs player's PC top-menu origin/size (#176). +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local T = require("tests.harness") +local Data = require("src.core.Data") +if not (Data.pokemon and next(Data.pokemon)) then Data:load() end + +local SaveData = require("src.core.SaveData") +local BoxMenu = require("src.ui.BoxMenu") +local PlayerPC = require("src.ui.PlayerPC") + +local game = { + data = Data, + save = SaveData.newGame(), + stack = { push = function() end }, +} + +local bills = BoxMenu.new(game) +local player = PlayerPC.new(game) + +T.eq(bills.tx, 0, "Bill's PC TextBoxBorder at x=0") +T.eq(player.tx, 0, "Player's PC TextBoxBorder at x=0") +T.eq(player.ty, 0, "Player's PC at y=0") +T.eq(player.tw, 16, "Player's PC width (players_pc.asm c=$e +2)") +T.eq(player.th, 10, "Player's PC height (players_pc.asm b=$8 +2)") +T.check(player.tx == bills.tx and player.ty == bills.ty, + "both PC menus share the same top-left origin") +local labelTiles = 2 + #"WITHDRAW ITEM" +T.check(player.tx + labelTiles <= player.tx + player.tw - 1, + "WITHDRAW ITEM fits inside the Player's PC border") + +T.finish("pc_menu_sides") diff --git a/tests/engine/pc_release.lua b/tests/engine/pc_release.lua new file mode 100644 index 00000000..bfa9d39b --- /dev/null +++ b/tests/engine/pc_release.lua @@ -0,0 +1,92 @@ +-- Headless regression: two Bill's PC releases in one list session (#171). +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local T = require("tests.harness") +local Data = require("src.core.Data") +if not (Data.pokemon and Data.pokemon.RATTATA) then Data:load() end +require("src.render.Font").load(Data) + +local Pokemon = require("src.pokemon.Pokemon") +local Boxes = require("src.pokemon.Boxes") +local BoxMenu = require("src.ui.BoxMenu") +local ListMenu = require("src.ui.ListMenu") +local ChoiceBox = require("src.ui.ChoiceBox") +local SaveData = require("src.core.SaveData") +local Sound = require("src.core.Sound") + +local realCry, realPlay = Sound.playCry, Sound.play +Sound.playCry = function() end +Sound.play = function() end + +local stack = { states = {} } +function stack:push(s) self.states[#self.states + 1] = s end +function stack:pop() + local t = self.states[#self.states] + self.states[#self.states] = nil + return t +end +function stack:top() return self.states[#self.states] end +function stack:update(dt) + local t = self:top() + if t and t.update then t:update(dt) end +end + +local pressed = {} +local game = { + data = Data, + save = SaveData.newGame(), + stack = stack, + input = { + wasPressed = function(_, key) return pressed[key] or false end, + isDown = function() return false end, + }, +} +game.save.options = game.save.options or {} +game.save.options.textSpeed = 1 + +local box = Boxes.active(game.save) +box[1] = Pokemon.new(Data, "RATTATA", 5) +box[2] = Pokemon.new(Data, "PIDGEY", 6) +box[3] = Pokemon.new(Data, "CATERPIE", 4) + +local function press(btn) + pressed = { [btn] = true } + stack:update(1 / 60) + pressed = {} +end + +local function topMt() return getmetatable(stack:top()) end +local function mash(btn, cond, n) + for _ = 1, (n or 400) do + if cond() then return true end + press(btn) + end + return false +end + +stack:push(BoxMenu.new(game)) +press("down"); press("down"); press("a") +T.check(topMt() == ListMenu, "RELEASE opens the box list") +T.eq(#box, 3, "box still has 3 before releases") + +local function releaseCurrent() + local before = #box + press("a") + T.check(mash("a", function() return topMt() == ChoiceBox end), + "confirm choice opens") + press("up") -- defaultNo -> YES + press("a") + T.check(mash("a", function() return topMt() == ListMenu end), + "returns to RELEASE list") + T.eq(#box, before - 1, "one mon removed from the box") +end + +releaseCurrent() +releaseCurrent() +T.eq(#box, 1, "two releases leave one mon") +T.check(topMt() == ListMenu, "still on RELEASE list after the second") +T.eq(box[1].species, "CATERPIE", "remaining mon is the third seeded one") + +Sound.playCry, Sound.play = realCry, realPlay +T.finish("pc_release") diff --git a/tests/engine/trade_anim_tests.lua b/tests/engine/trade_anim_tests.lua new file mode 100644 index 00000000..373d867f --- /dev/null +++ b/tests/engine/trade_anim_tests.lua @@ -0,0 +1,58 @@ +-- TradeAnim InternalClockTradeFuncSequence completes under A-skip and +-- exposes the cable-trade phases (engine/movie/trade.asm). +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local Data = require("src.core.Data") +if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end + +local S = require("tests.harness").suite("trade anim") +local check, eq = S.check, S.eq + +local Game = require("src.core.Game") +local Input = require("src.core.Input") +local StateStack = require("src.core.StateStack") +local SaveData = require("src.core.SaveData") +local Pokemon = require("src.pokemon.Pokemon") +local TradeAnim = require("src.ui.TradeAnim") + +Game.data = Data +Game.input = Input; Input:init() +Game.stack = StateStack; StateStack:init() +Game.save = SaveData.newGame() +require("src.render.Font").load(Data) + +local sent = Pokemon.new(Data, "SPEAROW", 10) +local recv = Pokemon.new(Data, "FARFETCHD", 10) +recv.nickname = "DUX" +recv.ot = "TRAINER" +recv.otId = 8193 + +local done = false +local anim = TradeAnim.new(Game, { + sent = sent, received = recv, enemyName = "TRAINER", + onDone = function() done = true end, +}) +Game.stack:push(anim) +if anim.enter then anim:enter() end + +local seen = {} +local guard = 0 +while not done and guard < 20000 do + guard = guard + 1 + seen[anim.phase] = true + Input.pressed = { a = true } + StateStack:update(1 / 60) + Input.pressed = {} +end + +check(done, "TradeAnim reaches onDone") +for _, phase in ipairs({ + "show_player", "open_cable", "ball_enter", "transfer_lr", + "went_to", "transfer_rl", "show_enemy", +}) do + check(seen[phase], "saw phase " .. phase) +end +eq(Game.stack:top(), nil, "TradeAnim pops itself") + +S.finish() diff --git a/tests/love_stub.lua b/tests/love_stub.lua index aed833ec..77dc8b12 100644 --- a/tests/love_stub.lua +++ b/tests/love_stub.lua @@ -103,6 +103,7 @@ stub.math = { stub.filesystem = { write = function(name, content) files[name] = content return true end, read = function(name) return files[name] end, + remove = function(name) files[name] = nil return true end, -- directories are implied by key prefixes ("mods/x/manifest.json") getInfo = function(name) if files[name] then return { type = "file" } end @@ -118,15 +119,27 @@ stub.filesystem = { end, getDirectoryItems = function(name) local seen, items = {}, {} - local prefix = name .. "/" - for key in pairs(files) do - if key:sub(1, #prefix) == prefix then - local child = key:sub(#prefix + 1):match("^[^/]+") + name = name or "" + -- "" / "/" = save-dir root (RomImporter Android ROM scan) + if name == "" or name == "/" then + for key in pairs(files) do + local child = key:match("^[^/]+") if child and not seen[child] then seen[child] = true items[#items + 1] = child end end + else + local prefix = name .. "/" + for key in pairs(files) do + if key:sub(1, #prefix) == prefix then + local child = key:sub(#prefix + 1):match("^[^/]+") + if child and not seen[child] then + seen[child] = true + items[#items + 1] = child + end + end + end end table.sort(items) return items diff --git a/tests/mod_ui_tests.lua b/tests/mod_ui_tests.lua index 0b40ac6b..1f92c6c3 100644 --- a/tests/mod_ui_tests.lua +++ b/tests/mod_ui_tests.lua @@ -423,6 +423,38 @@ check(#pm.subItems == 2, "a non-table submenu result keeps the vanilla list") hooks:removeOwner("bad") pm.submenu = nil +-- ------- battle PKMN: SWITCH / STATS / CANCEL (#180) +local switched +local bgame = partyGame() +local bpm = PartyMenu.new(bgame, { + battle = {}, + onSwitch = function(mon) switched = mon end, +}) +bpm.game = bgame +bgame.stack:push(bpm) +press(bpm, "a") +check(bpm.submenu and #bpm.subItems == 3 + and bpm.subItems[1].label == "SWITCH" + and bpm.subItems[2].label == "STATS" + and bpm.subItems[3].label == "CANCEL", + "voluntary battle party opens SWITCH/STATS/CANCEL") +press(bpm, "a") -- SWITCH +check(switched == bgame.save.party[1], "battle SWITCH hands the mon to onSwitch") +check(bgame.stack:top() ~= bpm, "battle SWITCH closes the party menu") + +local forced +local fgame = partyGame() +local fpm = PartyMenu.new(fgame, { + battle = {}, + forceSwitch = true, + onSwitch = function(mon) forced = mon end, +}) +fpm.game = fgame +fgame.stack:push(fpm) +press(fpm, "a") +check(not fpm.submenu and forced == fgame.save.party[1], + "forceSwitch still picks immediately (ChooseNextMon / SHIFT)") + -- ------- mod.ui helpers and theme defaults local items = { { label = "A" }, { label = "B" } } ModUI.insertAfter(items, "A", { label = "X" }) @@ -440,9 +472,9 @@ check(type(ModUI.push) == "function", "mod.ui.push opens screens") check(Theme.cursor == 0xED and Theme.cursorHollow == 0xEC and Theme.moreArrow == 0xEE, "theme defaults are the old literals") -check(Theme.choiceBox.tx == 0 and Theme.choiceBox.ty == 7 +check(Theme.choiceBox.tx == 14 and Theme.choiceBox.ty == 7 and Theme.choiceBox.tw == 6 and Theme.choiceBox.th == 5, - "choice box geometry keeps its vanilla tiles") + "choice box geometry keeps its vanilla tiles (InitYesNoTextBoxParameters hlcoord 14,7)") Theme.load({ field = { theme = { cursor = 0xAA } } }) check(Theme.cursor == 0xAA, "field.theme restyles the cursor glyph") Theme.cursor = 0xED diff --git a/tests/parity_A.lua b/tests/parity_A.lua index 09c6786d..7c8bd473 100644 --- a/tests/parity_A.lua +++ b/tests/parity_A.lua @@ -312,10 +312,15 @@ do local Renderer = require("src.render.Renderer") local SaveData = require("src.core.SaveData") local OW = require("src.world.OverworldController") - local realTB = package.loaded["src.render.TextBox"] - package.loaded["src.render.TextBox"] = { - new = function(game, text, done) return { text = text, onDone = done } end, - } + 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") + end + return table.concat(parts, "\n") + end Game.data = Data Game.input = Input; Input:init() Game.renderer = Renderer; Renderer:init() @@ -328,10 +333,17 @@ do Game.stack:push(OW, "PEWTER_GYM", 4, 13, "up") local ow = Game.stack:top() ow:checkVictoryRewards("OPP_BROCK", 1) + local brockText = stackedDialogue() check(Game.save.flags.EVENT_BEAT_BROCK, "Brock victory sets EVENT_BEAT_BROCK") check(Game.save.inventory.BOULDERBADGE == 1, "Brock victory awards BOULDERBADGE") check(Game.save.flags.EVENT_BEAT_PEWTER_GYM_TRAINER_0, "Brock victory deactivates the Pewter Gym Cooltrainer") + check(brockText:find("I took", 1, true), + "Brock victory dialogue starts with ReceivedBoulderBadge speech") + check(brockText:find("FLASH", 1, true), + "Brock victory dialogue includes BoulderBadge FLASH explanation") + check(brockText:find("BIDE", 1, true), + "Brock victory dialogue includes TM34 BIDE explanation") local pewterNpc for _, npc in ipairs(ow.npcs) do if npc.def.index == 2 then pewterNpc = npc break end @@ -348,6 +360,19 @@ do and Game.save.objectToggles.ROUTE_22.ROUTE22_RIVAL1 == false, "Brock victory hides ROUTE22_RIVAL1") + while Game.stack:top() do Game.stack:pop() end + Game.save = SaveData.newGame() + Game.save.flags = {} + Game.save.inventory = {} + Game.save.defeatedTrainers = {} + Game.stack:push(OW, "CERULEAN_GYM", 4, 10, "up") + ow = Game.stack:top() + ow:checkVictoryRewards("OPP_MISTY", 1) + local mistyText = stackedDialogue() + check(mistyText:find("CUT", 1, true), + "Misty victory dialogue includes CascadeBadge CUT explanation") + check(Game.save.inventory.CASCADEBADGE == 1, "Misty victory awards CASCADEBADGE") + while Game.stack:top() do Game.stack:pop() end Game.save = SaveData.newGame() Game.save.flags = {} @@ -368,7 +393,6 @@ do "unfought Cinnabar trainer is defeated via seeded header event") while Game.stack:top() do Game.stack:pop() end - package.loaded["src.render.TextBox"] = realTB end S.finish() diff --git a/tests/parity_F.lua b/tests/parity_F.lua index 691375e5..18f440fb 100644 --- a/tests/parity_F.lua +++ b/tests/parity_F.lua @@ -67,6 +67,8 @@ check(Game.save.party[1].nickname == "AAAAAAAAAA", "starter nickname prompt accepted (AskName / #137)") -- === 2) the parcel/pokedex beat still doesn't grant POKé BALLs === +-- OaksLabOak1Text.got_parcel requires EVENT_BATTLED_RIVAL_IN_OAKS_LAB +Flags.set(Game.save, "EVENT_BATTLED_RIVAL_IN_OAKS_LAB") check(runScript(mapScripts.talkScript("VIRIDIAN_MART", "TEXT_VIRIDIANMART_CLERK")), "mart clerk script completes") eq(Game.save.inventory.OAKS_PARCEL, 1, "clerk hands over Oak's Parcel") @@ -84,8 +86,8 @@ check(labToggles and labToggles.OAKSLAB_POKEDEX2 == false, "POKEDEX2 hidden after receiving Pokédex") eq(Game.save.inventory.POKE_BALL, nil, "still no POKe BALLs at the pokedex beat") --- talking to Oak again before beating the Route 22 rival should fall --- into the RaiseYourYoungPokemon branch, not give balls +-- talking to Oak again before beating the Route 22 rival is the +-- .mon_around_the_world branch (not the poke-ball grant) check(runScript(mapScripts.talkScript("OAKS_LAB", "TEXT_OAKSLAB_OAK1")), "Oak talk (pre-Route22-win) script completes") eq(Game.save.inventory.POKE_BALL, nil, "still no POKe BALLs before the Route 22 rival is beaten") diff --git a/tests/parity_ai_switch_faint.lua b/tests/parity_ai_switch_faint.lua index db128f57..da66bdbb 100644 --- a/tests/parity_ai_switch_faint.lua +++ b/tests/parity_ai_switch_faint.lua @@ -111,4 +111,62 @@ do eq(b.afterQueue, "finish", "victory finishes after the queue") end +-- #162: SHIFT style with 2+ party slots announces the next mon and +-- offers a free switch (TrainerAboutToUseText + YES/NO). Party count +-- gates the offer (pokered wPartyCount), not living-HP count — a fainted +-- reserve still unlocks the prompt. +do + local Game = freshGame() + Game.save.options.battleStyle = "shift" + local reserve = Pokemon.new(Data, "SQUIRTLE", 40) + reserve.hp = 0 + table.insert(Game.save.party, reserve) + local b = BattleState.newTrainer(Game, "OPP_YOUNGSTER", 1) + check(#b.enemyParty >= 2, "youngster has a second mon") + b.enemyParty[1].hp = 0 + b.enemyIndex = 1 + b.enemy.mon = b.enemyParty[1] + b.participants = { [Game.save.party[1]] = true } + b:enemyMonFainted() + local about, willChoice, sent = false, false, false + local n = 0 + while #b.queue > 0 and n < 200 do + n = n + 1 + local item = table.remove(b.queue, 1) + if item.fn then + b.nextInsert = 0 + item.fn() + elseif item.text then + if item.text:find("about to use", 1, true) then about = true end + if item.text:find("change POKéMON", 1, true) and item.choice then + willChoice = true + item.choice(false) -- decline; enemy still sends out + end + if item.text:find("sent", 1, true) then sent = true end + end + end + check(about, "SHIFT announces trainer about-to-use") + check(willChoice, "SHIFT queues Will-change with YES/NO choice") + check(sent, "enemy send-out still follows after declining") + check(b.enemy.mon.hp > 0, "second enemy mon is out after the offer") +end + +-- SET style (and single-mon parties) skip the offer entirely. +do + local Game = freshGame() + Game.save.options.battleStyle = "set" + table.insert(Game.save.party, Pokemon.new(Data, "SQUIRTLE", 40)) + local b = BattleState.newTrainer(Game, "OPP_YOUNGSTER", 1) + b.enemyParty[1].hp = 0 + b.enemyIndex = 1 + b.enemy.mon = b.enemyParty[1] + b.participants = { [Game.save.party[1]] = true } + b:enemyMonFainted() + local about = false + for _, item in ipairs(b.queue) do + if item.text and item.text:find("about to use", 1, true) then about = true end + end + check(not about, "SET style skips about-to-use") +end + S.finish() diff --git a/tests/parity_cerulean_rocket.lua b/tests/parity_cerulean_rocket.lua new file mode 100644 index 00000000..9aaa076a --- /dev/null +++ b/tests/parity_cerulean_rocket.lua @@ -0,0 +1,74 @@ +-- Parity: Cerulean TM28 Rocket HideObject fade (#170). +-- +-- pokered CeruleanCityRocketText .Success → PrintText (ReceivedTM28 + +-- IBetterGetMoving) → farcall CeruleanHideRocket: +-- GBFadeOutToBlack → Show GUARD1 / Hide GUARD2 / Hide ROCKET → +-- GBFadeInFromBlack. +-- +-- Self-contained; run via `luajit tests/parity_cerulean_rocket.lua`. +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end +local S = require("tests.harness").suite("parity cerulean rocket") +local check, eq = S.check, S.eq + +local story5 = dofile("data/scripts/story5.lua") +local rows = story5.CERULEAN_CITY.talk.TEXT_CERULEANCITY_ROCKET +check(rows ~= nil, "Cerulean Rocket talk script exists") + +local function find(verb, arg) + for i, row in ipairs(rows) do + if row[1] == verb and (arg == nil or row[2] == arg) then + return i, row + end + end +end + +local iMoving = find("show_text", "_CeruleanCityRocketIBetterGetMovingText") +local iFadeOut = find("fade", "out") +local iGuard1 = find("show_object", "CERULEAN_CITY") +local iGuard2 = find("hide_object", "CERULEAN_CITY") +local iRocket, rocketRow +for i, row in ipairs(rows) do + if row[1] == "hide_object" and row[3] == "CERULEANCITY_ROCKET" then + iRocket, rocketRow = i, row + break + end +end +local iFadeIn = find("fade", "in") + +check(iMoving, "prints IBetterGetMovingText before hide") +check(iFadeOut and iFadeOut == iMoving + 1, + "GBFadeOutToBlack follows IBetterGetMovingText") +check(iGuard1 and rows[iGuard1][3] == "CERULEANCITY_GUARD1" + and iGuard1 > iFadeOut, + "ShowObject GUARD1 runs under the black fade") +check(iGuard2 and rows[iGuard2][3] == "CERULEANCITY_GUARD2" + and iGuard2 > iGuard1, + "HideObject GUARD2 follows GUARD1 (pokered CeruleanHideRocket order)") +check(iRocket and iRocket > iGuard2 and iRocket < iFadeIn, + "HideObject ROCKET runs while screen is black") +eq(rocketRow[2], "CERULEAN_CITY", "Rocket hide targets CERULEAN_CITY") +check(iFadeIn and iFadeIn == iRocket + 1, + "GBFadeInFromBlack follows the three object toggles") + +-- loss must not land on a hide/show row +local iBattleJump +for _, row in ipairs(rows) do + if row[1] == "jump_if_false" then iBattleJump = row break end +end +check(iBattleJump and iBattleJump[2] == "end", + "battle loss jumps to end (not into CeruleanHideRocket)") + +-- GOT_TM28 re-entry jumps at the fade (idempotent HideRocket) +local iGotJump +for _, row in ipairs(rows) do + if row[1] == "jump_if_true" then iGotJump = row break end +end +check(iGotJump and iGotJump[2] == iFadeOut, + "EVENT_GOT_TM28 jumps to CeruleanHideRocket fade-out") + +local ScriptRunner = require("src.script.ScriptRunner") +local problems = ScriptRunner.validate(rows) +eq(#problems, 0, "Rocket script validates: " .. table.concat(problems, "; ")) + +S.finish() diff --git a/tests/parity_daycare.lua b/tests/parity_daycare.lua index eb4fa328..c11d70fb 100644 --- a/tests/parity_daycare.lua +++ b/tests/parity_daycare.lua @@ -180,5 +180,43 @@ do eq(game.save.daycare.mon.level, 5, "decline restores mon.level to depositLevel") end +-- === 6) skipped level-up moves granted on retrieve (#184 / WriteMonMoves) === +do + local game = newGame() + local mon = boardAtLevel(game, "MAGIKARP", 13, "FISHY") + game.save.daycare.depositLevel = 13 + local hasTackle = false + for _, mv in ipairs(mon.moves) do + if mv.id == "TACKLE" then hasTackle = true break end + end + check(not hasTackle, "Magikarp Lv13 does not already know Tackle") + game.save.daycare.steps = stepsForLevels("MAGIKARP", 13, 6) -- → 19 + choiceAnswer = true + check(talk(game), "Magikarp retrieve talk completes") + local back = game.save.party[1] + eq(back.level, 19, "Magikarp raised 13→19 on retrieve") + local moveIds = {} + for _, mv in ipairs(back.moves) do moveIds[#moveIds + 1] = mv.id end + local joined = table.concat(moveIds, ",") + check(joined:find("TACKLE", 1, true), + "Magikarp learns Tackle at 15 via daycare skip (#184): " .. joined) + check(joined:find("SPLASH", 1, true), "Splash retained alongside Tackle") +end + +-- === 7) decline does not teach skipped moves === +do + local game = newGame() + local mon = boardAtLevel(game, "MAGIKARP", 13) + game.save.daycare.depositLevel = 13 + game.save.daycare.steps = stepsForLevels("MAGIKARP", 13, 6) + choiceAnswer = false + talk(game) + local hasTackle = false + for _, mv in ipairs(game.save.daycare.mon.moves) do + if mv.id == "TACKLE" then hasTackle = true break end + end + check(not hasTackle, "decline leaves moves unchanged (no Tackle yet)") +end + package.loaded["src.render.TextBox"] = realTextBox S.finish() diff --git a/tests/parity_marowak.lua b/tests/parity_marowak.lua index d3d957ec..bcfa9c2b 100644 --- a/tests/parity_marowak.lua +++ b/tests/parity_marowak.lua @@ -24,7 +24,10 @@ local S = require("tests.harness").suite("parity ghost marowak") local check, eq = S.check, S.eq -- Real TextBoxes want a Font atlas; the decision under test is which --- branch runs, not how the text renders. +-- branch runs, not how the text renders. Both stubs are restored at the +-- bottom for the suites run_tests.lua chains after this file. +local realTextBox = package.loaded["src.render.TextBox"] +local realBattleState = package.loaded["src.battle.BattleState"] package.loaded["src.render.TextBox"] = { new = function(_, text, done) return { text = text, done = done } end, } @@ -158,4 +161,7 @@ do eq(0, #moved, "no scripted step on a loss") end +package.loaded["src.render.TextBox"] = realTextBox +package.loaded["src.battle.BattleState"] = realBattleState + S.finish() diff --git a/tests/parity_rival_walkoff.lua b/tests/parity_rival_walkoff.lua new file mode 100644 index 00000000..2c78a864 --- /dev/null +++ b/tests/parity_rival_walkoff.lua @@ -0,0 +1,148 @@ +-- Parity: Route 22 + Cerulean rival post-battle exits (#154 / #168). +-- +-- pokered Route22Rival{1,2}ExitMovementData* and CeruleanCityMovement3/4 +-- plus CeruleanCityRivalText's Bill line after EVENT_BEAT_CERULEAN_RIVAL. +-- +-- luajit tests/parity_rival_walkoff.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end +local S = require("tests.harness").suite("parity rival walkoff") +local check, eq = S.check, S.eq + +-- restored at the bottom for the suites run after this file +local realMusic = package.loaded["src.core.Music"] +local realCommands = package.loaded["src.script.Commands"] +local realPicBox = package.loaded["src.ui.PicBox"] +package.loaded["src.core.Music"] = { + play = function() end, playOnce = function() return true end, stop = function() end, +} +package.loaded["src.script.Commands"] = { + hide_object = function() end, +} +package.loaded["src.ui.PicBox"] = { new = function() return {} end } + +local story5 = dofile("data/scripts/story5.lua") +local story = dofile("data/scripts/story.lua") + +local function dirsEqual(a, b) + if type(a) ~= "table" or #a ~= #b then return false end + for i = 1, #b do if a[i] ~= b[i] then return false end end + return true +end + +local function findWalk(rows) + for _, r in ipairs(rows) do + if r[1] == "walk_npc" then return r end + end +end + +local function texts(rows) + local out = {} + for _, r in ipairs(rows) do + if r[1] == "show_text" then out[#out + 1] = r[2] end + end + return out +end + +local function capture(mapMod, game, x, y) + local rows + local ow = { + runner = { + isRunning = function() return false end, + run = function(_, r) rows = r end, + }, + player = { facing = "down" }, + npcByIndex = function() return { def = { name = "X" } } end, + } + check(mapMod.onStep(game, ow, x, y), + ("onStep fires at (%d,%d)"):format(x, y)) + check(rows ~= nil, "onStep queued rows") + return rows +end + +-- Route22Rival1ExitMovementData1 / Data2 +local R1_Y5 = { "right", "right", "down", "down", "down", "down", "down" } +local R1_Y4 = { "up", "right", "right", "right", + "down", "down", "down", "down", "down", "down" } +-- Route22Rival2ExitMovementData1 falls through Data2 on y=5 +local R2_Y5 = { "left", "left", "left", "left" } +local R2_Y4 = { "left", "left", "left" } +local CER_X20 = { "right", "down", "down", "down", "down", "down", "down" } +local CER_X21 = { "left", "down", "down", "down", "down", "down", "down" } + +do + local game = { save = { flags = { EVENT_GOT_POKEDEX = true } }, data = {} } + local w5 = findWalk(capture(story5.ROUTE_22, game, 29, 5)) + local w4 = findWalk(capture(story5.ROUTE_22, game, 29, 4)) + check(dirsEqual(w5[3], R1_Y5), "Rival1 y=5 exits toward Viridian (R,R,D×5)") + check(dirsEqual(w4[3], R1_Y4), "Rival1 y=4 exits U,R×3,D×6") + local rows = capture(story5.ROUTE_22, + { save = { flags = { EVENT_GOT_POKEDEX = true } }, data = {} }, 29, 5) + for _, r in ipairs(rows) do + check(not (r[1] == "move_npc_to" and r[3] == 25 and r[4] == 5), + "Rival1 must not retreat to spawn (25,5)") + end +end + +do + local flags = { + EVENT_BEAT_BROCK = true, + EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE = true, + EVENT_BEAT_GIOVANNI = true, + } + local w5 = findWalk(capture(story5.ROUTE_22, { save = { flags = flags }, data = {} }, 29, 5)) + local w4 = findWalk(capture(story5.ROUTE_22, { save = { flags = flags }, data = {} }, 29, 4)) + check(dirsEqual(w5[3], R2_Y5), "Rival2 y=5 exits left×4 toward League") + check(dirsEqual(w4[3], R2_Y4), "Rival2 y=4 exits left×3 toward League") +end + +do + local rows = capture(story5.CERULEAN_CITY, { save = { flags = {} }, data = {} }, 20, 6) + local t = texts(rows) + local hasBill = false + for _, id in ipairs(t) do + if id == "_CeruleanCityRivalIWentToBillsText" then hasBill = true end + end + check(hasBill, "Cerulean post-battle includes Bill dialogue") + local order = { + "_CeruleanCityRivalPreBattleText", + "_CeruleanCityRivalDefeatedText", + "_CeruleanCityRivalIWentToBillsText", + } + local pi = 1 + for _, id in ipairs(t) do + if pi <= #order and id == order[pi] then pi = pi + 1 end + end + eq(pi, #order + 1, "Cerulean text order: pre, defeated, Bill") + check(dirsEqual(findWalk(rows)[3], CER_X20), + "Cerulean x=20 exit: right then into town") + for _, r in ipairs(rows) do + check(not (r[1] == "move_npc_to" and r[4] == 2), + "Cerulean must not walk back north to (20,2)") + end +end + +do + local rows = capture(story5.CERULEAN_CITY, { save = { flags = {} }, data = {} }, 21, 6) + check(dirsEqual(findWalk(rows)[3], CER_X21), + "Cerulean x=21 exit: left then into town") +end + +do + local talk = story.CERULEAN_CITY.talk.TEXT_CERULEANCITY_RIVAL + check(talk ~= nil, "talk script for CERULEANCITY_RIVAL exists") + local hasBill = false + for _, r in ipairs(talk) do + if r[1] == "show_text" and r[2] == "_CeruleanCityRivalIWentToBillsText" then + hasBill = true + end + end + check(hasBill, "talk path also exposes Bill dialogue") +end + +package.loaded["src.core.Music"] = realMusic +package.loaded["src.script.Commands"] = realCommands +package.loaded["src.ui.PicBox"] = realPicBox + +S.finish() diff --git a/tests/parity_ss_anne_guard.lua b/tests/parity_ss_anne_guard.lua index 5b115b80..23b36524 100644 --- a/tests/parity_ss_anne_guard.lua +++ b/tests/parity_ss_anne_guard.lua @@ -15,6 +15,16 @@ if not _G.love then _G.love = require("tests.love_stub") end local S = require("tests.harness").suite("parity ss anne guard") local check, eq = S.check, S.eq +-- restored at the bottom for the suites run after this file +local realTextBox = package.loaded["src.render.TextBox"] +local realMusic = package.loaded["src.core.Music"] +local realSound = package.loaded["src.core.Sound"] +-- Commands captures TextBox at module load; drop any already-loaded copy +-- so the script runner below binds the stub, not the real renderer +local realCommands = package.loaded["src.script.Commands"] +local realScriptRunner = package.loaded["src.script.ScriptRunner"] +package.loaded["src.script.Commands"] = nil +package.loaded["src.script.ScriptRunner"] = nil package.loaded["src.render.TextBox"] = { new = function(_, text, done) return { text = text, done = done } end, } @@ -196,4 +206,10 @@ do check(found, "captain script plays Music_PkmnHealed after the rub") end +package.loaded["src.render.TextBox"] = realTextBox +package.loaded["src.core.Music"] = realMusic +package.loaded["src.core.Sound"] = realSound +package.loaded["src.script.Commands"] = realCommands +package.loaded["src.script.ScriptRunner"] = realScriptRunner + S.finish() diff --git a/tests/parity_ss_anne_rooms.lua b/tests/parity_ss_anne_rooms.lua new file mode 100644 index 00000000..29095e65 --- /dev/null +++ b/tests/parity_ss_anne_rooms.lua @@ -0,0 +1,110 @@ +-- Parity: S.S. Anne 1F door→room contents match pokered; after layout +-- patch, L→R hallway doors also land in rooms-map reading order (#189). +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end +local S = require("tests.harness").suite("parity ss anne rooms") +local check, eq = S.check, S.eq + +local maps = dofile("data/generated/maps.lua") +local SsAnneLayout = require("src.world.SsAnneLayout") +local Warp = require("src.world.Warp") + +-- OG occupants behind each hallway door, left → right (pokered + guides) +local OG_BY_DOOR = { + { "SSANNE1FROOMS_GENTLEMAN3" }, + { "SSANNE1FROOMS_YOUNGSTER", "SSANNE1FROOMS_COOLTRAINER_F", + "SSANNE1FROOMS_GIRL2", "SSANNE1FROOMS_TM_BODY_SLAM" }, + { "SSANNE1FROOMS_MIDDLE_AGED_MAN", "SSANNE1FROOMS_LITTLE_GIRL", + "SSANNE1FROOMS_WIGGLYTUFF" }, + { "SSANNE1FROOMS_GIRL1" }, + { "SSANNE1FROOMS_GENTLEMAN2" }, + { "SSANNE1FROOMS_GENTLEMAN1" }, +} + +local function copyMaps() + local ok, ser = pcall(require, "serpent") + if ok and ser and ser.dump and ser.load then + return ser.load(ser.dump(maps)) + end + -- deep-ish copy of the Anne maps only + local function deep(t) + if type(t) ~= "table" then return t end + local o = {} + for k, v in pairs(t) do o[k] = deep(v) end + return o + end + return { + SS_ANNE_1F = deep(maps.SS_ANNE_1F), + SS_ANNE_1F_ROOMS = deep(maps.SS_ANNE_1F_ROOMS), + } +end + +local function roomDoors(hall) + local doors = {} + for i, w in ipairs(hall.warps) do + if w.destMap == "SS_ANNE_1F_ROOMS" then + doors[#doors + 1] = { index = i, w = w } + end + end + table.sort(doors, function(a, b) return a.w.x < b.w.x end) + return doors +end + +local function objsAt(rooms, x, y) + local names = {} + local col, row = math.floor(x / 10), math.floor(y / 10) + for _, o in ipairs(rooms.objects) do + if math.floor(o.x / 10) == col and math.floor(o.y / 10) == row then + names[#names + 1] = o.name + end + end + table.sort(names) + return names +end + +local function hasAll(got, want) + local set = {} + for _, n in ipairs(got) do set[n] = true end + for _, n in ipairs(want) do + if not set[n] then return false, n end + end + return true +end + +-- raw ROM extract order: leftmost door → rooms warp 6 +do + local doors = roomDoors(maps.SS_ANNE_1F) + eq(doors[1].w.destWarp, 6, "raw extract: leftmost door → rooms #6") + eq(doors[#doors].w.destWarp, 1, "raw extract: rightmost door → rooms #1") +end + +local m = copyMaps() +check(SsAnneLayout.apply(m) == true, "layout patch applies once") +check(SsAnneLayout.apply(m) == false, "layout patch is idempotent") + +local data = { maps = m } +local doors = roomDoors(m.SS_ANNE_1F) +eq(#doors, 6, "six cabin doors") + +local prevCell +for i, d in ipairs(doors) do + eq(d.w.destWarp, i, ("L→R door %d → rooms warp %d"):format(i, i)) + local mid, x, y = Warp.destination(data, d.w, nil) + eq(mid, "SS_ANNE_1F_ROOMS", "door lands in 1F rooms") + local cell = math.floor(y / 10) * 3 + math.floor(x / 10) + if prevCell then + check(cell > prevCell, + ("door %d cell %d is after door %d cell %d"):format(i, cell, i - 1, prevCell)) + end + prevCell = cell + local got = objsAt(m.SS_ANNE_1F_ROOMS, x, y) + local ok, missing = hasAll(got, OG_BY_DOOR[i]) + check(ok, ("door %d keeps OG occupant %s (got %s)") + :format(i, tostring(missing), table.concat(got, ","))) +end + +-- return warps: rooms #1 exits to leftmost hall cabin door (warp 8) +eq(m.SS_ANNE_1F_ROOMS.warps[1].destWarp, 8, "rooms #1 returns to hall #8") +eq(m.SS_ANNE_1F_ROOMS.warps[6].destWarp, 3, "rooms #6 returns to hall #3") + +S.finish() diff --git a/tests/parity_trade_gift.lua b/tests/parity_trade_gift.lua index 340c4fb7..ab0383c4 100644 --- a/tests/parity_trade_gift.lua +++ b/tests/parity_trade_gift.lua @@ -254,11 +254,29 @@ shownIs({}, "old save: silent") eq(#Game.save.party, 0, "old save: no Eevee re-gift") eq(toggleOf(EEVEE_MAP, EEVEE_BALL), false, "old save: leftover ball hidden") --- === 11) GivePokemon failure (party + every box full): BoxIsFullText, +-- === 11) GivePokemon party full, box has room: AskName + SentToBoxText +-- (SendNewMonToBox), then script GotMonText; ball hidden === +Game.save = SaveData.newGame() +for i = 1, 6 do Game.save.party[i] = Pokemon.new(Data, "PIDGEY", 5) end +check(runScript(EEVEE_MAP, EEVEE_TEXT), "full-party Eevee script completes") +shownIs({ "_DoYouWantToNicknameText", "_SentToBoxText", "_GotMonText" }, + "full-party gift: nickname, sent-to-box, then GotMonText") +eq(#Game.save.party, 6, "full-party gift leaves party size at 6") +local Boxes = require("src.pokemon.Boxes") +local boxed = false +for _, box in ipairs(Boxes.ensure(Game.save)) do + for _, m in ipairs(box) do + if m.species == "EEVEE" then boxed = true end + end +end +check(boxed, "full-party Eevee lands in a box") +check(Flags.get(Game.save, "EVENT_GOT_EEVEE"), "full-party gift still sets flag") +eq(toggleOf(EEVEE_MAP, EEVEE_BALL), false, "full-party gift hides the ball") + +-- === 12) GivePokemon failure (party + every box full): BoxIsFullText, -- ball stays, flag unset -- the gift stays claimable === Game.save = SaveData.newGame() for i = 1, 6 do Game.save.party[i] = Pokemon.new(Data, "PIDGEY", 5) end -local Boxes = require("src.pokemon.Boxes") Boxes.ensure(Game.save) for b = 1, Boxes.COUNT do for s = 1, Boxes.CAPACITY do Game.save.boxes[b][s] = { species = "PIDGEY" } end @@ -268,7 +286,7 @@ shownIs({ "_BoxIsFullText" }, "full party+box shows BoxIsFullText") check(not Flags.get(Game.save, "EVENT_GOT_EEVEE"), "full party+box leaves flag unset") eq(toggleOf(EEVEE_MAP, EEVEE_BALL), nil, "full party+box leaves the ball visible") --- === 12) SS Anne rival ambush guard (scripts/SSAnne2F.asm: the +-- === 13) SS Anne rival ambush guard (scripts/SSAnne2F.asm: the -- wSSAnne2FCurScript NOOP progression <-> the port's -- EVENT_BEAT_SS_ANNE_RIVAL flag) === local ss = require("data.scripts.story5").SS_ANNE_2F diff --git a/tests/parity_trainer_sight.lua b/tests/parity_trainer_sight.lua index 9dbb9ee7..99128a52 100644 --- a/tests/parity_trainer_sight.lua +++ b/tests/parity_trainer_sight.lua @@ -218,4 +218,78 @@ do check(ow3.engaging, "distance 3 on the upper plateau still engages") end +-- === (5) off-screen sprites never engage (CheckSpriteAvailability) === +-- TrainerEngage bails when IMAGEINDEX=$ff. The 8-bit screen-pixel distance +-- wraps so a trainer ~12/28 tiles away can look in-range; the GB 10x9 window +-- (player±4/±5) is what stops that (GitHub #153 / #183). +local VF_BUG_CATCHER = 2 -- ViridianForest Youngster2 at (30,33) LEFT range 4 + +local function freshForest(px, py, facing) + while Game.stack:top() do Game.stack:pop() end + Game.save = SaveData.newGame() + Input:init() + OW.engaging = false + OW.emote = nil + Game.stack:push(OW, "VIRIDIAN_FOREST", px, py, facing) + local ow = Game.stack:top() + local trainer + for _, npc in ipairs(ow.npcs) do + if npc.def.index == VF_BUG_CATCHER then trainer = npc end + end + return ow, trainer +end + +do + -- (18,33) is walkable west of the tree wall; dx=12 to (30,33) wraps + -- the 8-bit pixel distance into range-4, but the sprite is off the GB screen. + local ow, trainer = freshForest(18, 33, "right") + check(trainer ~= nil and trainer.cellX == 30 and trainer.cellY == 33, + "Viridian Forest Bug Catcher stands at (30,33)") + eq(trainer.facing, "left", "Bug Catcher faces left") + local header = Data:trainerHeader("ViridianForest", VF_BUG_CATCHER) + eq(header and header.range, 4, "Bug Catcher sight range is 4") + + for _ = 1, 10 do frame(ow) end + check(not ow.engaging, + "through-trees tile (dx=12 wrap) must not engage: sprite off GB screen") + check(trainer.cellX == 30 and trainer.cellY == 33, + "off-screen trainer must not walk up through trees") + + -- same corridor as the trainer, distance 4, on the GB screen: must engage + local ow2, t2 = freshForest(26, 33, "right") + local guard = 0 + while not ow2.engaging and guard < 10 do guard = guard + 1; frame(ow2) end + check(ow2.engaging and t2.cellX == 30, + "distance 4 on-screen still engages (real sight line)") +end + +-- Victory Road 2F Hiker at (12,9) LEFT range 4: far west same row wraps +local VR_HIKER = 1 + +local function freshVR2(px, py, facing) + while Game.stack:top() do Game.stack:pop() end + Game.save = SaveData.newGame() + Input:init() + OW.engaging = false + OW.emote = nil + Game.stack:push(OW, "VICTORY_ROAD_2F", px, py, facing) + local ow = Game.stack:top() + local trainer + for _, npc in ipairs(ow.npcs) do + if npc.def.index == VR_HIKER then trainer = npc end + end + return ow, trainer +end + +do + local ow, trainer = freshVR2(0, 9, "right") + check(trainer ~= nil and trainer.cellX == 12 and trainer.cellY == 9, + "Victory Road 2F Hiker stands at (12,9)") + for _ = 1, 10 do frame(ow) end + check(not ow.engaging, + "Victory Road far same-row (dx=12 wrap) must not engage through walls") + check(trainer.cellX == 12 and trainer.cellY == 9, + "off-screen Victory Road trainer must stay put") +end + S.finish() diff --git a/tests/rom_importer_android_pick_test.lua b/tests/rom_importer_android_pick_test.lua new file mode 100644 index 00000000..fc0a77f5 --- /dev/null +++ b/tests/rom_importer_android_pick_test.lua @@ -0,0 +1,110 @@ +-- #167: Android second-ROM Choose must open the picker (or import a pending +-- opposite cart), not re-import a leftover picked_rom.gb from the first game. +-- Self-contained: `luajit tests/rom_importer_android_pick_test.lua`; also +-- dofile'd by tests/run_tests.lua. +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local S = require("tests.harness").suite("rom importer android pick") +local eq = S.eq +local check = S.check + +local GameVersion = require("src.core.GameVersion") +local RomImporter = require("src.import.RomImporter") + +local MiB = 1024 * 1024 +local redData = string.rep("R", MiB) +local blueData = string.rep("B", MiB) + +-- Map our fake 1 MiB blobs onto the real Red/Blue SHA-1 ids without needing +-- a crypto library in the headless stub. +love.data = love.data or {} +love.system = love.system or {} +-- the love stub is shared with every later suite in run_tests.lua; +-- restore whatever this file overrides +local saved = { + hash = love.data.hash, + encode = love.data.encode, + getSaveDirectory = love.filesystem.getSaveDirectory, + getOS = love.system.getOS, + pickFile = love.system.pickFile, +} +love.data.hash = function(_, data) + return { tag = data:sub(1, 1) } +end +love.data.encode = function(_, _, digest) + if type(digest) == "table" and digest.tag == "R" then + return GameVersion.info("red").sha1 + end + if type(digest) == "table" and digest.tag == "B" then + return GameVersion.info("blue").sha1 + end + return "0000000000000000000000000000000000000000" +end + +love.filesystem.getSaveDirectory = function() return "/tmp/pokemon-love2d" end + +local pickCalls = 0 +love.system.getOS = function() return "Android" end +love.system.pickFile = function() + pickCalls = pickCalls + 1 + return true +end + +local function freshImporter(ready) + return setmetatable({ + android = true, + workState = nil, + ready = { + red = ready.red and true or false, + blue = ready.blue and true or false, + }, + notice = nil, + chooseVersion = nil, + startData = function(self, data, displayName) + self._started = { data = data, name = displayName } + end, + }, RomImporter) +end + +-- Stale Red pick still on disk, Red already ready → Choose Blue opens picker. +love.filesystem.write("picked_rom.gb", redData) +pickCalls = 0 +local ri = freshImporter({ red = true, blue = false }) +ri:choose("blue") +eq(pickCalls, 1, "choose opens picker when only an already-imported ROM remains") +eq(ri._started, nil, "choose does not re-import the stale Red pick") + +-- Same leftover must not re-import on focus either (picker cancelled). +ri = freshImporter({ red = true, blue = false }) +ri:focus(true) +eq(ri._started, nil, "focus ignores already-imported leftover picked_rom.gb") + +-- A pending Blue cart in the save dir imports without needing the picker. +love.filesystem.write("pokemon_blue.gb", blueData) +pickCalls = 0 +ri = freshImporter({ red = true, blue = false }) +ri:choose("blue") +eq(pickCalls, 0, "choose imports a pending opposite ROM from the save dir") +check(ri._started ~= nil, "choose started import for pending Blue") +eq(ri._started.name, "pokemon_blue.gb", "choose picked the pending Blue file") +eq(ri._started.data, blueData, "choose passed Blue ROM bytes") + +-- focus after SAF overwrite: pending Blue becomes importable. +love.filesystem.write("picked_rom.gb", blueData) +ri = freshImporter({ red = true, blue = false }) +ri:focus(true) +check(ri._started ~= nil, "focus imports pending Blue after picker returns") +eq(ri._started.name, "picked_rom.gb", "focus reads the SAF drop filename") + +-- Cleanup so other suites sharing the stub do not see leftover .gb files +-- or this file's function stubs. +love.filesystem.remove("picked_rom.gb") +love.filesystem.remove("pokemon_blue.gb") +love.data.hash = saved.hash +love.data.encode = saved.encode +love.filesystem.getSaveDirectory = saved.getSaveDirectory +love.system.getOS = saved.getOS +love.system.pickFile = saved.pickFile + +S.finish() diff --git a/tests/run_tests.lua b/tests/run_tests.lua index 7ab0758d..cf641279 100644 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -89,6 +89,36 @@ local pages = TextBox.paginate("I'm raising\nPOKéMON too!\fWhen they get\nstron eq(#pages, 2, "two pages") eq(#pages[1], 2, "page 1 has two lines") eq(#pages[2], 3, "page 2 keeps scrolled line") +eq(pages.contBefore[1][1], false, "page1 line1 is not cont") +eq(pages.contBefore[1][2], false, "page1 line2 via \\n has no wait") +eq(pages.contBefore[2][3], true, "page2 line3 via \\v waits (cont)") +-- #163: ContText must set waiting + ▼ before scrolling the next line +do + local pressed = { a = true } + local box = TextBox.new({ + input = { + wasPressed = function(_, k) return pressed[k] end, + isDown = function() return false end, + }, + save = { options = { textSpeed = 1 }, player = { name = "RED" } }, + data = {}, + stringBuffer = "", + stack = { pop = function() end, push = function() end }, + }, "AAA\nBBB\vCCC") + pressed.a = false + for _ = 1, 80 do + if box.waiting or box.done then break end + box:update(0) + end + check(box.waiting and box.contAdvance, + "cont boundary waits for A with contAdvance") + check(not box.done, "cont wait is not the final done prompt") + eq(box.lineIndex, 2, "cont wait stays on the finished line until A") + pressed.a = true + box:update(0) + check(not box.waiting and not box.contAdvance, "A clears cont wait") + eq(box.lineIndex, 3, "A advances into the cont line") +end -- ---------------------------------------------------------------- stats & growth local Stats = require("src.pokemon.Stats") @@ -239,6 +269,14 @@ local r3 = ItemEffects.use(Data, save, "TM_TOXIC", pikachu) check(r3 == "learn", "Pikachu can learn Toxic (in tmhm list)") local r4 = ItemEffects.use(Data, save, "HM_SURF", pikachu) eq(r4, "failed", "Pikachu can't learn Surf") +do + local rBattle, oakMsg = ItemEffects.use(Data, save, "TM_TOXIC", hurt, {}) + eq(rBattle, "failed", "TM refuses mid-battle (ItemUseNotTime)") + check(oakMsg and oakMsg[1] and oakMsg[1]:find("isn't the", 1, true), + "TM mid-battle Oak text") + eq(ItemEffects.use(Data, save, "HM_SURF", pikachu, {}), "failed", + "HM refuses mid-battle") +end local r5, _, extra = ItemEffects.use(Data, save, "THUNDER_STONE", pikachu) eq(r5, "consumed", "Thunder Stone works on Pikachu") eq(extra.evolveTo, "RAICHU", "Thunder Stone evolves Pikachu to Raichu") @@ -494,7 +532,7 @@ local function runScript(script) local r = ScriptRunner.new(Game, nil) r:run(script, {}) local guard = 0 - while r:isRunning() and guard < 2000 do + while r:isRunning() and guard < 4000 do guard = guard + 1 Input.pressed = { a = true } StateStack:update(1 / 60) @@ -505,6 +543,7 @@ local function runScript(script) end Flags.set(Game.save, "EVENT_GOT_STARTER") +Flags.set(Game.save, "EVENT_BATTLED_RIVAL_IN_OAKS_LAB") check(runScript(mapScripts.talkScript("VIRIDIAN_MART", "TEXT_VIRIDIANMART_CLERK")), "mart clerk script completes") eq(Game.save.inventory.OAKS_PARCEL, 1, "clerk hands over Oak's Parcel") @@ -768,6 +807,10 @@ eq(Data.field.darkMaps.maps[1], "ROCK_TUNNEL_1F", "Rock Tunnel is dark") check(Data.field.hiddenExtras.trashCans.adjacent[0][1] == 1, "trash can adjacency table extracted") check(#Data.field.hiddenExtras.trashCans.cans == 15, "15 Vermilion trash cans") +check(#(Data.field.hiddenExtras.printTrash.SS_ANNE_KITCHEN or {}) == 2, + "SS Anne kitchen PrintTrashText bins") +check(#(Data.field.hiddenExtras.printTrash.VERMILION_GYM or {}) == 1, + "Vermilion Gym PrintTrashText bin") local tf = io.open(Data.field.title.logo.path, "rb") check(tf ~= nil, "title logo asset exists") if tf then tf:close() end @@ -882,6 +925,35 @@ do eq(sbst.player.stages.attack, 1, "SHARPEN skips the accuracy roll") end + -- #169: HUD status (shownStatus) stays hidden through announce/anim/ + -- inflict text, then syncs after Execute*Move (DrawHUDsAndHPBars) + do + Game.save.party = { Pokemon.new(Data, "BULBASAUR", 10) } + local pb = BattleState.newWild(Game, "RATTATA", 5) + pb.rng = function(a, b) return a or 0 end -- always hit + pb.phase = "messages" + pb:executeAction(pb.player, pb.enemy, { id = "POISONPOWDER", pp = 10 }) + eq(pb.enemy.mon.status, "PSN", "POISONPOWDER sets mon.status immediately") + eq(pb.enemy.shownStatus, nil, "HUD status deferred until after move queue") + local sawPoisonText, synced = false, false + local steps = 0 + while steps < 4000 and not synced do + steps = steps + 1 + if pb.enemy.shownStatus == "PSN" then synced = true; break end + if not sawPoisonText and pb.current and pb.current.text + and pb.current.text:find("poisoned", 1, true) then + sawPoisonText = true + eq(pb.enemy.shownStatus, nil, + "PSN icon still deferred while poisoned text shows") + end + Input.pressed = { a = true } + if not pb:updateQueue() then break end + Input.pressed = {} + end + check(sawPoisonText, "poisoned text appeared before HUD sync") + eq(pb.enemy.shownStatus, "PSN", "HUD status syncs after move completes") + end + -- HandleIfPlayerMoveMissed: skip PlayMoveAnimation on a miss -- (unless EXPLODE_EFFECT) do @@ -1256,6 +1328,23 @@ do "_ItemUseBallText08 before meeting Bill") check(not hasText(cb2, "New POKéDEX data"), "no dex page for an already-owned species") + -- #172: SendNewMonToBox still runs AskName before the PC text + local hasNickUi = false + for _, it in ipairs(cb2.queue) do + if it.ui then + local ui = it.ui() + if ui and ui.pages then + for _, page in ipairs(ui.pages) do + for _, line in ipairs(page) do + if type(line) == "string" and line:lower():find("nickname", 1, true) then + hasNickUi = true + end + end + end + end + end + end + check(hasNickUi, "full-party catch still asks for a nickname (#172)") Game.save.flags.EVENT_MET_BILL = true local cb3 = BattleState.newWild(Game, "EKANS", 5) cb3:storeCaughtMon() @@ -1269,7 +1358,9 @@ do tb.enemyIndex = #tb.enemyParty tb.enemy = { mon = tb.enemyParty[#tb.enemyParty], def = tb.enemy.def, name = tb.enemy.name, isPlayer = false } - tb.enemy.mon.hp = 0 + -- the whole team must be down: enemyMonFainted scans every slot for a + -- replacement (EnemySendOutFirstMon / AnyEnemyPokemonAliveCheck) + for _, m in ipairs(tb.enemyParty) do m.hp = 0 end tb:enemyMonFainted() check(hasText(tb, ("%s defeated\n%s!"):format(Game.save.player.name, tb.trainer.name)), @@ -1946,8 +2037,9 @@ end -- ---------------------------------------------------------------- heal machine cadence -- AnimateHealingMachine (engine/overworld/healing_machine.asm): one ball -- per party mon every 30 frames (SFX_HEALING_MACHINE each), then the --- healed jingle while the machine flashes 8 times (10 frames a toggle), --- then a 32-frame beat after the jingle ends. +-- healed jingle while the machine flashes 8 times (10 frames a toggle). +-- #157: fighting-fit text follows the flash immediately (no .waitLoop2 / +-- DelayFrames 32 hold after the machine). do local OW = require("src.world.OverworldController") local ha = { balls = 3, lit = 0, timer = 0, visible = true } @@ -1958,32 +2050,26 @@ do local ev = OW.stepHealAnim(ha) if ha.visible ~= wasVisible then toggles = toggles + 1 end if ev then events[#events + 1] = frame .. ev end - if ev == "jingle" then ha.jingleDone = true end -- headless: no audio if ev == "done" then break end end end) check(ok, "stepHealAnim runs") eq(table.concat(events, ","), - "1ball,31ball,61ball,91jingle,203done", + "1ball,31ball,61ball,91jingle,171done", "heal machine: a ball per mon every 30 frames, jingle, flash, done") eq(toggles, 8, "machine sprites flash 8 times") check(ha.visible, "machine sprites end visible") - -- the wait phase holds until the jingle actually finishes + -- unfinished jingle must not delay "done" past the flash (#157) local ha2 = { balls = 1, lit = 0, timer = 0, visible = true } - local doneEarly = false + local doneAt pcall(function() - for _ = 1, 300 do - if OW.stepHealAnim(ha2) == "done" then doneEarly = true end + for frame = 1, 300 do + if OW.stepHealAnim(ha2) == "done" then doneAt = frame break end end end) - check(not doneEarly, "heal machine waits for the jingle to end") - ha2.jingleDone = true - local extra = 0 - pcall(function() - repeat extra = extra + 1 until OW.stepHealAnim(ha2) == "done" or extra > 100 - end) - eq(extra, 32, "32-frame beat after the jingle") + -- 1 ball @1, jingle @31, 8x10 flash -> done @111 + eq(doneAt, 111, "fighting fit is not gated on the jingle ending") end -- ---------------------------------------------------------------- HP bar right cap @@ -2213,8 +2299,12 @@ do cb4.onFinish = function() end cb4.rng = function(a, b) return a end -- rng low: guaranteed capture local origStart = cb4.startMessage + local ballAtCaughtText cb4.startMessage = function(s, item) log[#log + 1] = "text:" .. item.text:gsub("\n.*", "") + if item.text:find("All right!", 1, true) then + ballAtCaughtText = cb4.lockedBall and #cb4.lockedBall > 0 + end return origStart(s, item) end cb4.queue = {} @@ -2223,8 +2313,11 @@ do while steps < 4000 do steps = steps + 1 Input.pressed = { a = true } - if not cb4:updateQueue() then break end + -- the #172 nickname ui row parks the queue on waitingUI (the battle + -- is not on the stack here); everything under test has run by then + if cb4.waitingUI or not cb4:updateQueue() then break end end + if cb4.waitingUI then StateStack:pop() end -- the pushed nickname TextBox Input.pressed = {} Sound.play = origPlay local caughtAt, fanfareAt, fanfares, tinks = nil, nil, 0, 0 @@ -2244,7 +2337,9 @@ do check(fanfareAt and caughtAt and fanfareAt < caughtAt, "Caught_Mon sounds with the caught text, not after its dismissal") eq(cb4.result, "caught", "the capture resolved the battle") - check(cb4.lockedBall and #cb4.lockedBall > 0, + -- the nickname AskName that follows clears it (ClearSprites), so the + -- assertion is sampled while the caught text is up + check(ballAtCaughtText, "the resting closed ball stays compiled for the caught text") Game.save.party = savedParty end @@ -2964,6 +3059,9 @@ runSuites({ "tests/input_hold_test.lua" }) -- ---------------------------------------------- launcher cursor (#114) runSuites({ "tests/rom_importer_cursor_test.lua" }) +-- ---------------------------------------------- Android second ROM pick (#167) +runSuites({ "tests/rom_importer_android_pick_test.lua" }) + -- ---------------------------------------------- parity workstream tests -- Each tests/parity_*.lua is a self-contained file (own bootstrap + check, -- error()s if any assertion fails). Globbed, so dropping a new parity diff --git a/tools/extract/field.py b/tools/extract/field.py index c3281a51..081372a3 100644 --- a/tools/extract/field.py +++ b/tools/extract/field.py @@ -6,7 +6,8 @@ Sources: data/tilesets/water_tilesets.asm -> tilesets where Surf works data/events/hidden_events.asm -> hidden items / coins / slot machines, PC tiles, bench guys, gym statues, - Vermilion Gym trash cans + PrintTrashText bins, Vermilion Gym + trash cans data/events/bench_guys.asm -> bench guy text per map data/events/slot_machine_wheels.asm -> the three slot wheel symbol lists data/events/card_key_{coords,maps}.asm + engine/events/card_key.asm @@ -117,15 +118,20 @@ def parse_hidden_events(pokered): StartSlotMachine (slot machine seats; arg SLOTS_* marks broken ones). Also collects the engine text hooks that the port implements natively: OpenPokemonCenterPC / OpenRedsPC (the player's storage PC in the - bedroom), PrintBenchGuyText, GymStatues and the Vermilion - Gym GymTrashScript cans (arg = [wGymTrashCanIndex]). For those the - fourth macro argument is the facing direction required to trigger the - event, except GymTrashScript where it is the can index. + bedroom), PrintBenchGuyText, GymStatues, PrintTrashText (plain + "only trash here" bins) and the Vermilion Gym GymTrashScript cans + (arg = [wGymTrashCanIndex]). For those the fourth macro argument is + the facing direction required to trigger the event, except + GymTrashScript where it is the can index and PrintTrashText which + stores a facing but ignores it. """ items = {} coins = {} slots = {} - extras = {"pcTiles": {}, "benchGuys": {}, "gymStatues": {}, "trashCans": []} + extras = { + "pcTiles": {}, "benchGuys": {}, "gymStatues": {}, + "printTrash": {}, "trashCans": [], + } current = None path = os.path.join(pokered, "data/events/hidden_events.asm") for lineno, line in read_asm(path): @@ -163,6 +169,9 @@ def parse_hidden_events(pokered): elif func == "GymStatues": extras["gymStatues"].setdefault(current, []).append( {"x": x, "y": y, "facing": DIRS.get(arg, arg)}) + elif func == "PrintTrashText": + extras["printTrash"].setdefault(current, []).append( + {"x": x, "y": y, "facing": DIRS.get(arg, arg)}) elif func == "GymTrashScript": if current != "VERMILION_GYM": util.die(f"hidden_events.asm:{lineno}: GymTrashScript outside VERMILION_GYM") @@ -1339,6 +1348,7 @@ def extract(pokered, out_dir): "pcTiles": extras["pcTiles"], "benchGuys": extras["benchGuys"], "gymStatues": extras["gymStatues"], + "printTrash": extras["printTrash"], "trashCans": parse_trash_can_puzzle(pokered, extras["trashCans"]), } @@ -1373,6 +1383,7 @@ def extract(pokered, out_dir): credits["theEnd"] = gfx.extract_the_end(pokered, assets_dir) battle_hud = gfx.extract_battle_hud(pokered, assets_dir) town_map["background"] = gfx.extract_town_map_bg(pokered, assets_dir) + trade_art = gfx.extract_trade(pokered, assets_dir) if not ledges or not cut_trees or "OVERWORLD" not in water or len(trades) < 8 \ or "PALLET_TOWN" not in fly_warps: @@ -1387,7 +1398,9 @@ def extract(pokered, out_dir): or len(hidden_extras["pcTiles"]) < 10 \ or "VIRIDIAN_GYM" not in hidden_extras["gymStatues"] \ or hidden_extras["benchGuys"].get("VIRIDIAN_POKECENTER", - [{}])[0].get("text") is None: + [{}])[0].get("text") is None \ + or len(hidden_extras["printTrash"].get("SS_ANNE_KITCHEN", [])) != 2 \ + or len(hidden_extras["printTrash"].get("VERMILION_GYM", [])) != 1: util.die("hidden extras extraction sanity check failed") if sorted(forced_movement["tiles"]) != ["ROUTE_16", "ROUTE_18", "SEAFOAM_ISLANDS_B3F", @@ -1499,6 +1512,7 @@ def extract(pokered, out_dir): "townMap": town_map, "credits": credits, "slotSymbols": slot_symbols, + "tradeArt": trade_art, "emotionBubbles": emotion_bubbles, "oakSpeech": oak_speech, "overworldFx": overworld_fx, @@ -1525,6 +1539,7 @@ def extract(pokered, out_dir): "+ gfx/credits/the_end.png,\n" "constants/script_constants.asm (SLOTS*, *_BUBBLE)\n" "+ gfx/slots/red_slots_{1,2}.png + gfx/emotes/*.png\n" + "+ gfx/trade/* (InternalClockTradeAnim),\n" "+ engine/{slots/slot_machine,overworld/emotion_bubbles}.asm,\n" "scripts/ViridianCity.asm + engine/battle/core.asm,\n" "scripts/GameCorner.asm + text/GameCorner.asm,\n" diff --git a/tools/extract/gfx.py b/tools/extract/gfx.py index e36a9f9a..6a75483b 100644 --- a/tools/extract/gfx.py +++ b/tools/extract/gfx.py @@ -809,3 +809,110 @@ def extract_town_map_bg(pokered, assets_dir): "source": "gfx/town_map/town_map.rle + town_map.png " "(engine/items/town_map.asm LoadTownMap)", } + + +# --------------------------------------------------------------------------- +# Trade animation graphics (engine/movie/trade.asm) +# --------------------------------------------------------------------------- + +def _unique_tiles(im, cols): + """Row-major 8x8 tiles kept in first-seen order (rgbgfx --remove-duplicates).""" + kept, index_of = [], {} + for r in range(im.size[1] // 8): + for c in range(cols): + tile = im.crop((c * 8, r * 8, c * 8 + 8, r * 8 + 8)) + key = tile.tobytes() + if key not in index_of: + index_of[key] = len(kept) + kept.append(tile) + return kept + + +def _all_tiles(im, cols): + return [im.crop((c * 8, r * 8, c * 8 + 8, r * 8 + 8)) + for r in range(im.size[1] // 8) for c in range(cols)] + + +def extract_trade(pokered, assets_dir): + """TradingAnimationGraphics (+ cable ball / bubble) for InternalClockTradeAnim. + + game_boy.2bpp is built with --remove-duplicates; link_cable.2bpp is not. + Tilemaps reference absolute vChars2 ids starting at $31. We rebuild the + open-cable plate and cable segment tiles from that atlas, and convert the + Game Boy / ball / bubble sheets for direct blitting. + """ + trade_dir = os.path.join(pokered, "gfx/trade") + out_dir = os.path.join(assets_dir, "trade") + gb_src = Image.open(os.path.join(trade_dir, "game_boy.png")).convert("L") + lc_src = Image.open(os.path.join(trade_dir, "link_cable.png")).convert("L") + if gb_src.size != (48, 64) or lc_src.size != (24, 40): + util.die(f"trade sheets: unexpected sizes {gb_src.size} / {lc_src.size}") + atlas = _unique_tiles(gb_src, 6) + _all_tiles(lc_src, 3) + if len(atlas) != 49: + util.die(f"trade atlas: expected 49 tiles after dedupe, got {len(atlas)}") + + convert_png(os.path.join(trade_dir, "game_boy.png"), + os.path.join(out_dir, "game_boy.png"), transparent_matte=True) + convert_png(os.path.join(trade_dir, "bubble.png"), + os.path.join(out_dir, "bubble.png"), transparent_color0=True) + + # cable_ball.2bpp is four 8x8 tiles at vSprites $7c; the ball OAM uses + # tile $7e four times with X/Y flips (and $7f for the bulge frame). + ball_src = _convert_image( + Image.open(os.path.join(trade_dir, "cable_ball.png")), + transparent_color0=True) + if ball_src.size != (16, 16): + util.die(f"cable_ball.png: expected 16x16, got {ball_src.size}") + + def compose_ball(tile): + out = Image.new("RGBA", (16, 16), (0, 0, 0, 0)) + out.paste(tile, (0, 0)) + out.paste(tile.transpose(Image.FLIP_LEFT_RIGHT), (8, 0)) + out.paste(tile.transpose(Image.FLIP_TOP_BOTTOM), (0, 8)) + out.paste(tile.transpose(Image.FLIP_LEFT_RIGHT).transpose( + Image.FLIP_TOP_BOTTOM), (8, 8)) + return out + + _save_png(compose_ball(ball_src.crop((0, 8, 8, 16))), + os.path.join(out_dir, "cable_ball.png")) + _save_png(compose_ball(ball_src.crop((8, 8, 16, 16))), + os.path.join(out_dir, "cable_ball_alt.png")) + + with open(os.path.join(trade_dir, "link_cable.tilemap"), "rb") as fh: + lc_map = fh.read() + if len(lc_map) != 36: + util.die(f"link_cable.tilemap: expected 36 bytes, got {len(lc_map)}") + open_cable = Image.new("L", (96, 24), 255) + for i, tid in enumerate(lc_map): + idx = tid - 0x31 + if idx < 0 or idx >= len(atlas): + util.die(f"link_cable.tilemap: tile ${tid:02x} outside atlas") + open_cable.paste(atlas[idx], ((i % 12) * 8, (i // 12) * 8)) + _save_png(_convert_image(open_cable), os.path.join(out_dir, "open_cable.png")) + + for name, vid in (("cable_seg", 0x5E), ("cable_conn", 0x5D), + ("cable_vert", 0x61), ("cable_corner", 0x5F), + ("cable_end", 0x60)): + _save_png(_convert_image(atlas[vid - 0x31]), + os.path.join(out_dir, f"{name}.png")) + + horiz = Image.new("L", (160, 8), 255) + seg = atlas[0x5E - 0x31] + for x in range(20): + horiz.paste(seg, (x * 8, 0)) + _save_png(_convert_image(horiz), os.path.join(out_dir, "cable_horiz.png")) + + return { + "gameBoy": "assets/generated/trade/game_boy.png", + "openCable": "assets/generated/trade/open_cable.png", + "cableHoriz": "assets/generated/trade/cable_horiz.png", + "cableConn": "assets/generated/trade/cable_conn.png", + "cableVert": "assets/generated/trade/cable_vert.png", + "cableCorner": "assets/generated/trade/cable_corner.png", + "cableEnd": "assets/generated/trade/cable_end.png", + "cableBall": "assets/generated/trade/cable_ball.png", + "cableBallAlt": "assets/generated/trade/cable_ball_alt.png", + "bubble": "assets/generated/trade/bubble.png", + "source": "gfx/trade/* (TradingAnimationGraphics, " + "engine/movie/trade.asm InternalClockTradeAnim)", + } diff --git a/tools/rom_manifest.json b/tools/rom_manifest.json index e82c41a6..69e17b00 100644 --- a/tools/rom_manifest.json +++ b/tools/rom_manifest.json @@ -1629,7 +1629,7 @@ }, "charmap": { "0": "", - "10": "ザ", + "10": "\u30b6", "100": "", "101": "", "102": "", @@ -1640,29 +1640,29 @@ "107": "", "108": "", "109": "", - "11": "ジ", - "110": "ぃ", - "111": "ぅ", - "112": "‘", - "113": "’", - "114": "“", - "115": "”", - "116": "·", - "117": "…", - "118": "ぁ", - "119": "ぇ", - "12": "ズ", - "120": "ぉ", - "121": "┌", - "122": "─", - "123": "┐", - "124": "│", - "125": "└", - "126": "┘", + "11": "\u30b8", + "110": "\u3043", + "111": "\u3045", + "112": "\u2018", + "113": "\u2019", + "114": "\u201c", + "115": "\u201d", + "116": "\u00b7", + "117": "\u2026", + "118": "\u3041", + "119": "\u3047", + "12": "\u30ba", + "120": "\u3049", + "121": "\u250c", + "122": "\u2500", + "123": "\u2510", + "124": "\u2502", + "125": "\u2514", + "126": "\u2518", "127": " ", "128": "A", "129": "B", - "13": "ゼ", + "13": "\u30bc", "130": "C", "131": "D", "132": "E", @@ -1673,7 +1673,7 @@ "137": "J", "138": "K", "139": "L", - "14": "ゾ", + "14": "\u30be", "140": "M", "141": "N", "142": "O", @@ -1684,7 +1684,7 @@ "147": "T", "148": "U", "149": "V", - "15": "ダ", + "15": "\u30c0", "150": "W", "151": "X", "152": "Y", @@ -1695,7 +1695,7 @@ "157": ";", "158": "[", "159": "]", - "16": "ヂ", + "16": "\u30c2", "160": "a", "161": "b", "162": "c", @@ -1706,7 +1706,7 @@ "167": "h", "168": "i", "169": "j", - "17": "ヅ", + "17": "\u30c5", "170": "k", "171": "l", "172": "m", @@ -1717,52 +1717,52 @@ "177": "r", "178": "s", "179": "t", - "18": "デ", + "18": "\u30c7", "180": "u", "181": "v", "182": "w", "183": "x", "184": "y", "185": "z", - "186": "é", + "186": "\u00e9", "187": "'d", "188": "'l", "189": "'s", - "19": "ド", + "19": "\u30c9", "190": "'t", "191": "'v", - "192": "た", - "193": "ち", - "194": "つ", - "195": "て", - "196": "と", - "197": "な", - "198": "に", - "199": "ぬ", - "200": "ね", - "201": "の", - "202": "は", - "203": "ひ", - "204": "ふ", - "205": "へ", - "206": "ほ", - "207": "ま", - "208": "み", - "209": "む", - "210": "め", - "211": "も", - "212": "や", - "213": "ゆ", - "214": "よ", - "215": "ら", - "216": "り", - "217": "る", - "218": "れ", - "219": "ろ", - "220": "わ", - "221": "を", - "222": "ん", - "223": "っ", + "192": "\u305f", + "193": "\u3061", + "194": "\u3064", + "195": "\u3066", + "196": "\u3068", + "197": "\u306a", + "198": "\u306b", + "199": "\u306c", + "200": "\u306d", + "201": "\u306e", + "202": "\u306f", + "203": "\u3072", + "204": "\u3075", + "205": "\u3078", + "206": "\u307b", + "207": "\u307e", + "208": "\u307f", + "209": "\u3080", + "210": "\u3081", + "211": "\u3082", + "212": "\u3084", + "213": "\u3086", + "214": "\u3088", + "215": "\u3089", + "216": "\u308a", + "217": "\u308b", + "218": "\u308c", + "219": "\u308d", + "220": "\u308f", + "221": "\u3092", + "222": "\u3093", + "223": "\u3063", "224": "'", "225": "", "226": "", @@ -1772,83 +1772,83 @@ "230": "?", "231": "!", "232": ".", - "233": "ァ", - "234": "ゥ", - "235": "ェ", - "236": "▷", - "237": "▲", - "238": "▼", - "239": "♂", + "233": "\u30a1", + "234": "\u30a5", + "235": "\u30a7", + "236": "\u25b7", + "237": "\u25b2", + "238": "\u25bc", + "239": "\u2642", "240": "", - "241": "×", + "241": "\u00d7", "242": ".", "243": "/", "244": ",", - "245": "♀", + "245": "\u2640", "246": "0", "247": "1", "248": "2", "249": "3", - "25": "バ", + "25": "\u30d0", "250": "4", "251": "5", "252": "6", "253": "7", "254": "8", "255": "9", - "26": "ビ", - "27": "ブ", - "28": "ボ", - "38": "が", - "39": "ぎ", - "40": "ぐ", - "41": "げ", - "42": "ご", - "43": "ざ", - "44": "じ", - "45": "ず", - "46": "ぜ", - "47": "ぞ", - "48": "だ", - "49": "ぢ", - "5": "ガ", - "50": "づ", - "51": "で", - "52": "ど", - "58": "ば", - "59": "び", - "6": "ギ", - "60": "ぶ", - "61": "べ", - "62": "ぼ", - "64": "パ", - "65": "ピ", - "66": "プ", - "67": "ポ", - "68": "ぱ", - "69": "ぴ", - "7": "グ", - "70": "ぷ", - "71": "ぺ", - "72": "ぽ", + "26": "\u30d3", + "27": "\u30d6", + "28": "\u30dc", + "38": "\u304c", + "39": "\u304e", + "40": "\u3050", + "41": "\u3052", + "42": "\u3054", + "43": "\u3056", + "44": "\u3058", + "45": "\u305a", + "46": "\u305c", + "47": "\u305e", + "48": "\u3060", + "49": "\u3062", + "5": "\u30ac", + "50": "\u3065", + "51": "\u3067", + "52": "\u3069", + "58": "\u3070", + "59": "\u3073", + "6": "\u30ae", + "60": "\u3076", + "61": "\u3079", + "62": "\u307c", + "64": "\u30d1", + "65": "\u30d4", + "66": "\u30d7", + "67": "\u30dd", + "68": "\u3071", + "69": "\u3074", + "7": "\u30b0", + "70": "\u3077", + "71": "\u307a", + "72": "\u307d", "73": "\f", - "74": "POKéMON", + "74": "POK\u00e9MON", "75": "<_CONT>", "76": "", "78": "\n", "79": "\n", - "8": "ゲ", + "8": "\u30b2", "80": "", "81": "\f", "82": "{PLAYER}", "83": "{RIVAL}", - "84": "POKé", + "84": "POK\u00e9", "85": "\u000b", - "86": "……", + "86": "\u2026\u2026", "87": "", "88": "", "89": "{TARGET}", - "9": "ゴ", + "9": "\u30b4", "90": "{USER}", "91": "PC", "92": "TM", @@ -4255,44 +4255,164 @@ "cardKeyDoors": { "closedDoors": { "SILPH_CO_2F": [ - { "block": 84, "bx": 2, "by": 2, "event": "EVENT_SILPH_CO_2_UNLOCKED_DOOR1", "open": 14 }, - { "block": 84, "bx": 2, "by": 5, "event": "EVENT_SILPH_CO_2_UNLOCKED_DOOR2", "open": 14 } + { + "block": 84, + "bx": 2, + "by": 2, + "event": "EVENT_SILPH_CO_2_UNLOCKED_DOOR1", + "open": 14 + }, + { + "block": 84, + "bx": 2, + "by": 5, + "event": "EVENT_SILPH_CO_2_UNLOCKED_DOOR2", + "open": 14 + } ], "SILPH_CO_3F": [ - { "block": 95, "bx": 4, "by": 4, "event": "EVENT_SILPH_CO_3_UNLOCKED_DOOR1", "open": 14 }, - { "block": 95, "bx": 8, "by": 4, "event": "EVENT_SILPH_CO_3_UNLOCKED_DOOR2", "open": 14 } + { + "block": 95, + "bx": 4, + "by": 4, + "event": "EVENT_SILPH_CO_3_UNLOCKED_DOOR1", + "open": 14 + }, + { + "block": 95, + "bx": 8, + "by": 4, + "event": "EVENT_SILPH_CO_3_UNLOCKED_DOOR2", + "open": 14 + } ], "SILPH_CO_4F": [ - { "block": 84, "bx": 2, "by": 6, "event": "EVENT_SILPH_CO_4_UNLOCKED_DOOR1", "open": 14 }, - { "block": 84, "bx": 6, "by": 4, "event": "EVENT_SILPH_CO_4_UNLOCKED_DOOR2", "open": 14 } + { + "block": 84, + "bx": 2, + "by": 6, + "event": "EVENT_SILPH_CO_4_UNLOCKED_DOOR1", + "open": 14 + }, + { + "block": 84, + "bx": 6, + "by": 4, + "event": "EVENT_SILPH_CO_4_UNLOCKED_DOOR2", + "open": 14 + } ], "SILPH_CO_5F": [ - { "block": 95, "bx": 3, "by": 2, "event": "EVENT_SILPH_CO_5_UNLOCKED_DOOR1", "open": 14 }, - { "block": 95, "bx": 3, "by": 6, "event": "EVENT_SILPH_CO_5_UNLOCKED_DOOR2", "open": 14 }, - { "block": 95, "bx": 7, "by": 5, "event": "EVENT_SILPH_CO_5_UNLOCKED_DOOR3", "open": 14 } + { + "block": 95, + "bx": 3, + "by": 2, + "event": "EVENT_SILPH_CO_5_UNLOCKED_DOOR1", + "open": 14 + }, + { + "block": 95, + "bx": 3, + "by": 6, + "event": "EVENT_SILPH_CO_5_UNLOCKED_DOOR2", + "open": 14 + }, + { + "block": 95, + "bx": 7, + "by": 5, + "event": "EVENT_SILPH_CO_5_UNLOCKED_DOOR3", + "open": 14 + } ], "SILPH_CO_6F": [ - { "block": 95, "bx": 2, "by": 6, "event": "EVENT_SILPH_CO_6_UNLOCKED_DOOR", "open": 14 } + { + "block": 95, + "bx": 2, + "by": 6, + "event": "EVENT_SILPH_CO_6_UNLOCKED_DOOR", + "open": 14 + } ], "SILPH_CO_7F": [ - { "block": 84, "bx": 5, "by": 3, "event": "EVENT_SILPH_CO_7_UNLOCKED_DOOR1", "open": 14 }, - { "block": 84, "bx": 10, "by": 2, "event": "EVENT_SILPH_CO_7_UNLOCKED_DOOR2", "open": 14 }, - { "block": 84, "bx": 10, "by": 6, "event": "EVENT_SILPH_CO_7_UNLOCKED_DOOR3", "open": 14 } + { + "block": 84, + "bx": 5, + "by": 3, + "event": "EVENT_SILPH_CO_7_UNLOCKED_DOOR1", + "open": 14 + }, + { + "block": 84, + "bx": 10, + "by": 2, + "event": "EVENT_SILPH_CO_7_UNLOCKED_DOOR2", + "open": 14 + }, + { + "block": 84, + "bx": 10, + "by": 6, + "event": "EVENT_SILPH_CO_7_UNLOCKED_DOOR3", + "open": 14 + } ], "SILPH_CO_8F": [ - { "block": 95, "bx": 3, "by": 4, "event": "EVENT_SILPH_CO_8_UNLOCKED_DOOR", "open": 14 } + { + "block": 95, + "bx": 3, + "by": 4, + "event": "EVENT_SILPH_CO_8_UNLOCKED_DOOR", + "open": 14 + } ], "SILPH_CO_9F": [ - { "block": 95, "bx": 1, "by": 4, "event": "EVENT_SILPH_CO_9_UNLOCKED_DOOR1", "open": 14 }, - { "block": 84, "bx": 9, "by": 2, "event": "EVENT_SILPH_CO_9_UNLOCKED_DOOR2", "open": 14 }, - { "block": 84, "bx": 9, "by": 5, "event": "EVENT_SILPH_CO_9_UNLOCKED_DOOR3", "open": 14 }, - { "block": 95, "bx": 5, "by": 6, "event": "EVENT_SILPH_CO_9_UNLOCKED_DOOR4", "open": 14 } + { + "block": 95, + "bx": 1, + "by": 4, + "event": "EVENT_SILPH_CO_9_UNLOCKED_DOOR1", + "open": 14 + }, + { + "block": 84, + "bx": 9, + "by": 2, + "event": "EVENT_SILPH_CO_9_UNLOCKED_DOOR2", + "open": 14 + }, + { + "block": 84, + "bx": 9, + "by": 5, + "event": "EVENT_SILPH_CO_9_UNLOCKED_DOOR3", + "open": 14 + }, + { + "block": 95, + "bx": 5, + "by": 6, + "event": "EVENT_SILPH_CO_9_UNLOCKED_DOOR4", + "open": 14 + } ], "SILPH_CO_10F": [ - { "block": 84, "bx": 5, "by": 4, "event": "EVENT_SILPH_CO_10_UNLOCKED_DOOR", "open": 14 } + { + "block": 84, + "bx": 5, + "by": 4, + "event": "EVENT_SILPH_CO_10_UNLOCKED_DOOR", + "open": 14 + } ], "SILPH_CO_11F": [ - { "block": 32, "bx": 3, "by": 6, "event": "EVENT_SILPH_CO_11_UNLOCKED_DOOR", "open": 3 } + { + "block": 32, + "bx": 3, + "by": 6, + "event": "EVENT_SILPH_CO_11_UNLOCKED_DOOR", + "open": 3 + } ] }, "doorTiles": [ @@ -4479,7 +4599,7 @@ "lines": [ { "column": 6, - "text": "POKéMON" + "text": "POK\u00e9MON" }, { "column": 1, @@ -5762,6 +5882,27 @@ } ] }, + "printTrash": { + "SS_ANNE_KITCHEN": [ + { + "facing": "down", + "x": 13, + "y": 5 + }, + { + "facing": "down", + "x": 13, + "y": 7 + } + ], + "VERMILION_GYM": [ + { + "facing": "down", + "x": 6, + "y": 1 + } + ] + }, "trashCans": { "adjacent": { "0": [ @@ -9229,7 +9370,7 @@ "gridPixelSize": 8, "locations": { "AGATHAS_ROOM": { - "name": "POKéMON LEAGUE", + "name": "POK\u00e9MON LEAGUE", "x": 0, "y": 2 }, @@ -9249,7 +9390,7 @@ "y": 11 }, "BRUNOS_ROOM": { - "name": "POKéMON LEAGUE", + "name": "POK\u00e9MON LEAGUE", "x": 0, "y": 2 }, @@ -9399,7 +9540,7 @@ "y": 2 }, "CHAMPIONS_ROOM": { - "name": "POKéMON LEAGUE", + "name": "POK\u00e9MON LEAGUE", "x": 0, "y": 2 }, @@ -9449,7 +9590,7 @@ "y": 15 }, "COLOSSEUM": { - "name": "POKéMON LEAGUE", + "name": "POK\u00e9MON LEAGUE", "x": 0, "y": 2 }, @@ -9534,7 +9675,7 @@ "y": 5 }, "HALL_OF_FAME": { - "name": "POKéMON LEAGUE", + "name": "POK\u00e9MON LEAGUE", "x": 0, "y": 2 }, @@ -9549,7 +9690,7 @@ "y": 2 }, "LANCES_ROOM": { - "name": "POKéMON LEAGUE", + "name": "POK\u00e9MON LEAGUE", "x": 0, "y": 2 }, @@ -9574,7 +9715,7 @@ "y": 5 }, "LORELEIS_ROOM": { - "name": "POKéMON LEAGUE", + "name": "POK\u00e9MON LEAGUE", "x": 0, "y": 2 }, @@ -9669,57 +9810,57 @@ "y": 9 }, "POKEMON_MANSION_1F": { - "name": "POKéMON MANSION", + "name": "POK\u00e9MON MANSION", "x": 2, "y": 15 }, "POKEMON_MANSION_2F": { - "name": "POKéMON MANSION", + "name": "POK\u00e9MON MANSION", "x": 2, "y": 15 }, "POKEMON_MANSION_3F": { - "name": "POKéMON MANSION", + "name": "POK\u00e9MON MANSION", "x": 2, "y": 15 }, "POKEMON_MANSION_B1F": { - "name": "POKéMON MANSION", + "name": "POK\u00e9MON MANSION", "x": 2, "y": 15 }, "POKEMON_TOWER_1F": { - "name": "POKéMON TOWER", + "name": "POK\u00e9MON TOWER", "x": 15, "y": 5 }, "POKEMON_TOWER_2F": { - "name": "POKéMON TOWER", + "name": "POK\u00e9MON TOWER", "x": 15, "y": 5 }, "POKEMON_TOWER_3F": { - "name": "POKéMON TOWER", + "name": "POK\u00e9MON TOWER", "x": 15, "y": 5 }, "POKEMON_TOWER_4F": { - "name": "POKéMON TOWER", + "name": "POK\u00e9MON TOWER", "x": 15, "y": 5 }, "POKEMON_TOWER_5F": { - "name": "POKéMON TOWER", + "name": "POK\u00e9MON TOWER", "x": 15, "y": 5 }, "POKEMON_TOWER_6F": { - "name": "POKéMON TOWER", + "name": "POK\u00e9MON TOWER", "x": 15, "y": 5 }, "POKEMON_TOWER_7F": { - "name": "POKéMON TOWER", + "name": "POK\u00e9MON TOWER", "x": 15, "y": 5 }, @@ -10209,7 +10350,7 @@ "y": 10 }, "TRADE_CENTER": { - "name": "POKéMON LEAGUE", + "name": "POK\u00e9MON LEAGUE", "x": 0, "y": 2 }, @@ -10905,631 +11046,631 @@ }, { "code": 240, - "seq": "¥" + "seq": "\u00a5" }, { "code": 116, - "seq": "·" + "seq": "\u00b7" }, { "code": 241, - "seq": "×" + "seq": "\u00d7" }, { "code": 186, - "seq": "é" + "seq": "\u00e9" }, { "code": 112, - "seq": "‘" + "seq": "\u2018" }, { "code": 113, - "seq": "’" + "seq": "\u2019" }, { "code": 114, - "seq": "“" + "seq": "\u201c" }, { "code": 115, - "seq": "”" + "seq": "\u201d" }, { "code": 117, - "seq": "…" + "seq": "\u2026" }, { "code": 96, - "seq": "′" + "seq": "\u2032" }, { "code": 97, - "seq": "″" + "seq": "\u2033" }, { "code": 116, - "seq": "№" + "seq": "\u2116" }, { "code": 117, - "seq": "⋯" + "seq": "\u22ef" }, { "code": 122, - "seq": "─" + "seq": "\u2500" }, { "code": 124, - "seq": "│" + "seq": "\u2502" }, { "code": 121, - "seq": "┌" + "seq": "\u250c" }, { "code": 123, - "seq": "┐" + "seq": "\u2510" }, { "code": 125, - "seq": "└" + "seq": "\u2514" }, { "code": 126, - "seq": "┘" + "seq": "\u2518" }, { "code": 237, - "seq": "▲" + "seq": "\u25b2" }, { "code": 237, - "seq": "▶" + "seq": "\u25b6" }, { "code": 236, - "seq": "▷" + "seq": "\u25b7" }, { "code": 238, - "seq": "▼" + "seq": "\u25bc" }, { "code": 245, - "seq": "♀" + "seq": "\u2640" }, { "code": 239, - "seq": "♂" + "seq": "\u2642" }, { "code": 127, - "seq": " " + "seq": "\u3000" }, { "code": 232, - "seq": "。" + "seq": "\u3002" }, { "code": 112, - "seq": "「" + "seq": "\u300c" }, { "code": 113, - "seq": "」" + "seq": "\u300d" }, { "code": 114, - "seq": "『" + "seq": "\u300e" }, { "code": 115, - "seq": "』" + "seq": "\u300f" }, { "code": 118, - "seq": "ぁ" + "seq": "\u3041" }, { "code": 177, - "seq": "あ" + "seq": "\u3042" }, { "code": 110, - "seq": "ぃ" + "seq": "\u3043" }, { "code": 178, - "seq": "い" + "seq": "\u3044" }, { "code": 111, - "seq": "ぅ" + "seq": "\u3045" }, { "code": 179, - "seq": "う" + "seq": "\u3046" }, { "code": 119, - "seq": "ぇ" + "seq": "\u3047" }, { "code": 180, - "seq": "え" + "seq": "\u3048" }, { "code": 120, - "seq": "ぉ" + "seq": "\u3049" }, { "code": 181, - "seq": "お" + "seq": "\u304a" }, { "code": 182, - "seq": "か" + "seq": "\u304b" }, { "code": 183, - "seq": "き" + "seq": "\u304d" }, { "code": 184, - "seq": "く" + "seq": "\u304f" }, { "code": 185, - "seq": "け" + "seq": "\u3051" }, { "code": 186, - "seq": "こ" + "seq": "\u3053" }, { "code": 187, - "seq": "さ" + "seq": "\u3055" }, { "code": 188, - "seq": "し" + "seq": "\u3057" }, { "code": 189, - "seq": "す" + "seq": "\u3059" }, { "code": 190, - "seq": "せ" + "seq": "\u305b" }, { "code": 191, - "seq": "そ" + "seq": "\u305d" }, { "code": 192, - "seq": "た" + "seq": "\u305f" }, { "code": 193, - "seq": "ち" + "seq": "\u3061" }, { "code": 223, - "seq": "っ" + "seq": "\u3063" }, { "code": 194, - "seq": "つ" + "seq": "\u3064" }, { "code": 195, - "seq": "て" + "seq": "\u3066" }, { "code": 196, - "seq": "と" + "seq": "\u3068" }, { "code": 197, - "seq": "な" + "seq": "\u306a" }, { "code": 198, - "seq": "に" + "seq": "\u306b" }, { "code": 199, - "seq": "ぬ" + "seq": "\u306c" }, { "code": 200, - "seq": "ね" + "seq": "\u306d" }, { "code": 201, - "seq": "の" + "seq": "\u306e" }, { "code": 202, - "seq": "は" + "seq": "\u306f" }, { "code": 203, - "seq": "ひ" + "seq": "\u3072" }, { "code": 204, - "seq": "ふ" + "seq": "\u3075" }, { "code": 205, - "seq": "へ" + "seq": "\u3078" }, { "code": 206, - "seq": "ほ" + "seq": "\u307b" }, { "code": 207, - "seq": "ま" + "seq": "\u307e" }, { "code": 208, - "seq": "み" + "seq": "\u307f" }, { "code": 209, - "seq": "む" + "seq": "\u3080" }, { "code": 210, - "seq": "め" + "seq": "\u3081" }, { "code": 211, - "seq": "も" + "seq": "\u3082" }, { "code": 224, - "seq": "ゃ" + "seq": "\u3083" }, { "code": 212, - "seq": "や" + "seq": "\u3084" }, { "code": 225, - "seq": "ゅ" + "seq": "\u3085" }, { "code": 213, - "seq": "ゆ" + "seq": "\u3086" }, { "code": 226, - "seq": "ょ" + "seq": "\u3087" }, { "code": 214, - "seq": "よ" + "seq": "\u3088" }, { "code": 215, - "seq": "ら" + "seq": "\u3089" }, { "code": 216, - "seq": "り" + "seq": "\u308a" }, { "code": 217, - "seq": "る" + "seq": "\u308b" }, { "code": 218, - "seq": "れ" + "seq": "\u308c" }, { "code": 219, - "seq": "ろ" + "seq": "\u308d" }, { "code": 220, - "seq": "わ" + "seq": "\u308f" }, { "code": 221, - "seq": "を" + "seq": "\u3092" }, { "code": 222, - "seq": "ん" + "seq": "\u3093" }, { "code": 233, - "seq": "ァ" + "seq": "\u30a1" }, { "code": 128, - "seq": "ア" + "seq": "\u30a2" }, { "code": 176, - "seq": "ィ" + "seq": "\u30a3" }, { "code": 129, - "seq": "イ" + "seq": "\u30a4" }, { "code": 234, - "seq": "ゥ" + "seq": "\u30a5" }, { "code": 130, - "seq": "ウ" + "seq": "\u30a6" }, { "code": 235, - "seq": "ェ" + "seq": "\u30a7" }, { "code": 131, - "seq": "エ" + "seq": "\u30a8" }, { "code": 244, - "seq": "ォ" + "seq": "\u30a9" }, { "code": 132, - "seq": "オ" + "seq": "\u30aa" }, { "code": 133, - "seq": "カ" + "seq": "\u30ab" }, { "code": 134, - "seq": "キ" + "seq": "\u30ad" }, { "code": 135, - "seq": "ク" + "seq": "\u30af" }, { "code": 136, - "seq": "ケ" + "seq": "\u30b1" }, { "code": 137, - "seq": "コ" + "seq": "\u30b3" }, { "code": 138, - "seq": "サ" + "seq": "\u30b5" }, { "code": 139, - "seq": "シ" + "seq": "\u30b7" }, { "code": 140, - "seq": "ス" + "seq": "\u30b9" }, { "code": 141, - "seq": "セ" + "seq": "\u30bb" }, { "code": 142, - "seq": "ソ" + "seq": "\u30bd" }, { "code": 143, - "seq": "タ" + "seq": "\u30bf" }, { "code": 144, - "seq": "チ" + "seq": "\u30c1" }, { "code": 172, - "seq": "ッ" + "seq": "\u30c3" }, { "code": 145, - "seq": "ツ" + "seq": "\u30c4" }, { "code": 146, - "seq": "テ" + "seq": "\u30c6" }, { "code": 147, - "seq": "ト" + "seq": "\u30c8" }, { "code": 148, - "seq": "ナ" + "seq": "\u30ca" }, { "code": 149, - "seq": "ニ" + "seq": "\u30cb" }, { "code": 150, - "seq": "ヌ" + "seq": "\u30cc" }, { "code": 151, - "seq": "ネ" + "seq": "\u30cd" }, { "code": 152, - "seq": "ノ" + "seq": "\u30ce" }, { "code": 153, - "seq": "ハ" + "seq": "\u30cf" }, { "code": 154, - "seq": "ヒ" + "seq": "\u30d2" }, { "code": 155, - "seq": "フ" + "seq": "\u30d5" }, { "code": 156, - "seq": "ホ" + "seq": "\u30db" }, { "code": 157, - "seq": "マ" + "seq": "\u30de" }, { "code": 158, - "seq": "ミ" + "seq": "\u30df" }, { "code": 159, - "seq": "ム" + "seq": "\u30e0" }, { "code": 160, - "seq": "メ" + "seq": "\u30e1" }, { "code": 161, - "seq": "モ" + "seq": "\u30e2" }, { "code": 173, - "seq": "ャ" + "seq": "\u30e3" }, { "code": 162, - "seq": "ヤ" + "seq": "\u30e4" }, { "code": 174, - "seq": "ュ" + "seq": "\u30e5" }, { "code": 163, - "seq": "ユ" + "seq": "\u30e6" }, { "code": 175, - "seq": "ョ" + "seq": "\u30e7" }, { "code": 164, - "seq": "ヨ" + "seq": "\u30e8" }, { "code": 165, - "seq": "ラ" + "seq": "\u30e9" }, { "code": 166, - "seq": "ル" + "seq": "\u30eb" }, { "code": 167, - "seq": "レ" + "seq": "\u30ec" }, { "code": 168, - "seq": "ロ" + "seq": "\u30ed" }, { "code": 169, - "seq": "ワ" + "seq": "\u30ef" }, { "code": 170, - "seq": "ヲ" + "seq": "\u30f2" }, { "code": 171, - "seq": "ン" + "seq": "\u30f3" }, { "code": 227, - "seq": "ー" + "seq": "\u30fc" }, { "code": 240, - "seq": "円" + "seq": "\u5186" }, { "code": 231, - "seq": "!" + "seq": "\uff01" }, { "code": 242, - "seq": "." + "seq": "\uff0e" }, { "code": 243, - "seq": "/" + "seq": "\uff0f" }, { "code": 246, - "seq": "0" + "seq": "\uff10" }, { "code": 247, - "seq": "1" + "seq": "\uff11" }, { "code": 248, - "seq": "2" + "seq": "\uff12" }, { "code": 249, - "seq": "3" + "seq": "\uff13" }, { "code": 250, - "seq": "4" + "seq": "\uff14" }, { "code": 251, - "seq": "5" + "seq": "\uff15" }, { "code": 252, - "seq": "6" + "seq": "\uff16" }, { "code": 253, - "seq": "7" + "seq": "\uff17" }, { "code": 254, - "seq": "8" + "seq": "\uff18" }, { "code": 255, - "seq": "9" + "seq": "\uff19" }, { "code": 230, - "seq": "?" + "seq": "\uff1f" }, { "code": 229, - "seq": "゙" + "seq": "\uff9e" }, { "code": 228, - "seq": "゚" + "seq": "\uff9f" } ], "format": 2, @@ -44745,11 +44886,26 @@ ], "trainerPartyOverrides": { "OPP_CHIEF": [ - { "level": 41, "species": "MACHOKE" }, - { "level": 41, "species": "GOLBAT" }, - { "level": 43, "species": "MAROWAK" }, - { "level": 43, "species": "WEEZING" }, - { "level": 46, "species": "PERSIAN" } + { + "level": 41, + "species": "MACHOKE" + }, + { + "level": 41, + "species": "GOLBAT" + }, + { + "level": 43, + "species": "MAROWAK" + }, + { + "level": 43, + "species": "WEEZING" + }, + { + "level": 46, + "species": "PERSIAN" + } ] }, "trainerPics": [ diff --git a/tools/rom_manifest_blue.json b/tools/rom_manifest_blue.json index fc9f5e3f..1453551c 100644 --- a/tools/rom_manifest_blue.json +++ b/tools/rom_manifest_blue.json @@ -1629,7 +1629,7 @@ }, "charmap": { "0": "", - "10": "ザ", + "10": "\u30b6", "100": "", "101": "", "102": "", @@ -1640,29 +1640,29 @@ "107": "", "108": "", "109": "", - "11": "ジ", - "110": "ぃ", - "111": "ぅ", - "112": "‘", - "113": "’", - "114": "“", - "115": "”", - "116": "·", - "117": "…", - "118": "ぁ", - "119": "ぇ", - "12": "ズ", - "120": "ぉ", - "121": "┌", - "122": "─", - "123": "┐", - "124": "│", - "125": "└", - "126": "┘", + "11": "\u30b8", + "110": "\u3043", + "111": "\u3045", + "112": "\u2018", + "113": "\u2019", + "114": "\u201c", + "115": "\u201d", + "116": "\u00b7", + "117": "\u2026", + "118": "\u3041", + "119": "\u3047", + "12": "\u30ba", + "120": "\u3049", + "121": "\u250c", + "122": "\u2500", + "123": "\u2510", + "124": "\u2502", + "125": "\u2514", + "126": "\u2518", "127": " ", "128": "A", "129": "B", - "13": "ゼ", + "13": "\u30bc", "130": "C", "131": "D", "132": "E", @@ -1673,7 +1673,7 @@ "137": "J", "138": "K", "139": "L", - "14": "ゾ", + "14": "\u30be", "140": "M", "141": "N", "142": "O", @@ -1684,7 +1684,7 @@ "147": "T", "148": "U", "149": "V", - "15": "ダ", + "15": "\u30c0", "150": "W", "151": "X", "152": "Y", @@ -1695,7 +1695,7 @@ "157": ";", "158": "[", "159": "]", - "16": "ヂ", + "16": "\u30c2", "160": "a", "161": "b", "162": "c", @@ -1706,7 +1706,7 @@ "167": "h", "168": "i", "169": "j", - "17": "ヅ", + "17": "\u30c5", "170": "k", "171": "l", "172": "m", @@ -1717,52 +1717,52 @@ "177": "r", "178": "s", "179": "t", - "18": "デ", + "18": "\u30c7", "180": "u", "181": "v", "182": "w", "183": "x", "184": "y", "185": "z", - "186": "é", + "186": "\u00e9", "187": "'d", "188": "'l", "189": "'s", - "19": "ド", + "19": "\u30c9", "190": "'t", "191": "'v", - "192": "た", - "193": "ち", - "194": "つ", - "195": "て", - "196": "と", - "197": "な", - "198": "に", - "199": "ぬ", - "200": "ね", - "201": "の", - "202": "は", - "203": "ひ", - "204": "ふ", - "205": "へ", - "206": "ほ", - "207": "ま", - "208": "み", - "209": "む", - "210": "め", - "211": "も", - "212": "や", - "213": "ゆ", - "214": "よ", - "215": "ら", - "216": "り", - "217": "る", - "218": "れ", - "219": "ろ", - "220": "わ", - "221": "を", - "222": "ん", - "223": "っ", + "192": "\u305f", + "193": "\u3061", + "194": "\u3064", + "195": "\u3066", + "196": "\u3068", + "197": "\u306a", + "198": "\u306b", + "199": "\u306c", + "200": "\u306d", + "201": "\u306e", + "202": "\u306f", + "203": "\u3072", + "204": "\u3075", + "205": "\u3078", + "206": "\u307b", + "207": "\u307e", + "208": "\u307f", + "209": "\u3080", + "210": "\u3081", + "211": "\u3082", + "212": "\u3084", + "213": "\u3086", + "214": "\u3088", + "215": "\u3089", + "216": "\u308a", + "217": "\u308b", + "218": "\u308c", + "219": "\u308d", + "220": "\u308f", + "221": "\u3092", + "222": "\u3093", + "223": "\u3063", "224": "'", "225": "", "226": "", @@ -1772,83 +1772,83 @@ "230": "?", "231": "!", "232": ".", - "233": "ァ", - "234": "ゥ", - "235": "ェ", - "236": "▷", - "237": "▲", - "238": "▼", - "239": "♂", + "233": "\u30a1", + "234": "\u30a5", + "235": "\u30a7", + "236": "\u25b7", + "237": "\u25b2", + "238": "\u25bc", + "239": "\u2642", "240": "", - "241": "×", + "241": "\u00d7", "242": ".", "243": "/", "244": ",", - "245": "♀", + "245": "\u2640", "246": "0", "247": "1", "248": "2", "249": "3", - "25": "バ", + "25": "\u30d0", "250": "4", "251": "5", "252": "6", "253": "7", "254": "8", "255": "9", - "26": "ビ", - "27": "ブ", - "28": "ボ", - "38": "が", - "39": "ぎ", - "40": "ぐ", - "41": "げ", - "42": "ご", - "43": "ざ", - "44": "じ", - "45": "ず", - "46": "ぜ", - "47": "ぞ", - "48": "だ", - "49": "ぢ", - "5": "ガ", - "50": "づ", - "51": "で", - "52": "ど", - "58": "ば", - "59": "び", - "6": "ギ", - "60": "ぶ", - "61": "べ", - "62": "ぼ", - "64": "パ", - "65": "ピ", - "66": "プ", - "67": "ポ", - "68": "ぱ", - "69": "ぴ", - "7": "グ", - "70": "ぷ", - "71": "ぺ", - "72": "ぽ", + "26": "\u30d3", + "27": "\u30d6", + "28": "\u30dc", + "38": "\u304c", + "39": "\u304e", + "40": "\u3050", + "41": "\u3052", + "42": "\u3054", + "43": "\u3056", + "44": "\u3058", + "45": "\u305a", + "46": "\u305c", + "47": "\u305e", + "48": "\u3060", + "49": "\u3062", + "5": "\u30ac", + "50": "\u3065", + "51": "\u3067", + "52": "\u3069", + "58": "\u3070", + "59": "\u3073", + "6": "\u30ae", + "60": "\u3076", + "61": "\u3079", + "62": "\u307c", + "64": "\u30d1", + "65": "\u30d4", + "66": "\u30d7", + "67": "\u30dd", + "68": "\u3071", + "69": "\u3074", + "7": "\u30b0", + "70": "\u3077", + "71": "\u307a", + "72": "\u307d", "73": "\f", - "74": "POKéMON", + "74": "POK\u00e9MON", "75": "<_CONT>", "76": "", "78": "\n", "79": "\n", - "8": "ゲ", + "8": "\u30b2", "80": "", "81": "\f", "82": "{PLAYER}", "83": "{RIVAL}", - "84": "POKé", + "84": "POK\u00e9", "85": "\u000b", - "86": "……", + "86": "\u2026\u2026", "87": "", "88": "", "89": "{TARGET}", - "9": "ゴ", + "9": "\u30b4", "90": "{USER}", "91": "PC", "92": "TM", @@ -4599,7 +4599,7 @@ "lines": [ { "column": 6, - "text": "POKéMON" + "text": "POK\u00e9MON" }, { "column": 1, @@ -5859,6 +5859,27 @@ } ] }, + "printTrash": { + "SS_ANNE_KITCHEN": [ + { + "facing": "down", + "x": 13, + "y": 5 + }, + { + "facing": "down", + "x": 13, + "y": 7 + } + ], + "VERMILION_GYM": [ + { + "facing": "down", + "x": 6, + "y": 1 + } + ] + }, "trashCans": { "adjacent": { "0": [ @@ -9326,7 +9347,7 @@ "gridPixelSize": 8, "locations": { "AGATHAS_ROOM": { - "name": "POKéMON LEAGUE", + "name": "POK\u00e9MON LEAGUE", "x": 0, "y": 2 }, @@ -9346,7 +9367,7 @@ "y": 11 }, "BRUNOS_ROOM": { - "name": "POKéMON LEAGUE", + "name": "POK\u00e9MON LEAGUE", "x": 0, "y": 2 }, @@ -9496,7 +9517,7 @@ "y": 2 }, "CHAMPIONS_ROOM": { - "name": "POKéMON LEAGUE", + "name": "POK\u00e9MON LEAGUE", "x": 0, "y": 2 }, @@ -9546,7 +9567,7 @@ "y": 15 }, "COLOSSEUM": { - "name": "POKéMON LEAGUE", + "name": "POK\u00e9MON LEAGUE", "x": 0, "y": 2 }, @@ -9631,7 +9652,7 @@ "y": 5 }, "HALL_OF_FAME": { - "name": "POKéMON LEAGUE", + "name": "POK\u00e9MON LEAGUE", "x": 0, "y": 2 }, @@ -9646,7 +9667,7 @@ "y": 2 }, "LANCES_ROOM": { - "name": "POKéMON LEAGUE", + "name": "POK\u00e9MON LEAGUE", "x": 0, "y": 2 }, @@ -9671,7 +9692,7 @@ "y": 5 }, "LORELEIS_ROOM": { - "name": "POKéMON LEAGUE", + "name": "POK\u00e9MON LEAGUE", "x": 0, "y": 2 }, @@ -9766,57 +9787,57 @@ "y": 9 }, "POKEMON_MANSION_1F": { - "name": "POKéMON MANSION", + "name": "POK\u00e9MON MANSION", "x": 2, "y": 15 }, "POKEMON_MANSION_2F": { - "name": "POKéMON MANSION", + "name": "POK\u00e9MON MANSION", "x": 2, "y": 15 }, "POKEMON_MANSION_3F": { - "name": "POKéMON MANSION", + "name": "POK\u00e9MON MANSION", "x": 2, "y": 15 }, "POKEMON_MANSION_B1F": { - "name": "POKéMON MANSION", + "name": "POK\u00e9MON MANSION", "x": 2, "y": 15 }, "POKEMON_TOWER_1F": { - "name": "POKéMON TOWER", + "name": "POK\u00e9MON TOWER", "x": 15, "y": 5 }, "POKEMON_TOWER_2F": { - "name": "POKéMON TOWER", + "name": "POK\u00e9MON TOWER", "x": 15, "y": 5 }, "POKEMON_TOWER_3F": { - "name": "POKéMON TOWER", + "name": "POK\u00e9MON TOWER", "x": 15, "y": 5 }, "POKEMON_TOWER_4F": { - "name": "POKéMON TOWER", + "name": "POK\u00e9MON TOWER", "x": 15, "y": 5 }, "POKEMON_TOWER_5F": { - "name": "POKéMON TOWER", + "name": "POK\u00e9MON TOWER", "x": 15, "y": 5 }, "POKEMON_TOWER_6F": { - "name": "POKéMON TOWER", + "name": "POK\u00e9MON TOWER", "x": 15, "y": 5 }, "POKEMON_TOWER_7F": { - "name": "POKéMON TOWER", + "name": "POK\u00e9MON TOWER", "x": 15, "y": 5 }, @@ -10306,7 +10327,7 @@ "y": 10 }, "TRADE_CENTER": { - "name": "POKéMON LEAGUE", + "name": "POK\u00e9MON LEAGUE", "x": 0, "y": 2 }, @@ -11002,631 +11023,631 @@ }, { "code": 240, - "seq": "¥" + "seq": "\u00a5" }, { "code": 116, - "seq": "·" + "seq": "\u00b7" }, { "code": 241, - "seq": "×" + "seq": "\u00d7" }, { "code": 186, - "seq": "é" + "seq": "\u00e9" }, { "code": 112, - "seq": "‘" + "seq": "\u2018" }, { "code": 113, - "seq": "’" + "seq": "\u2019" }, { "code": 114, - "seq": "“" + "seq": "\u201c" }, { "code": 115, - "seq": "”" + "seq": "\u201d" }, { "code": 117, - "seq": "…" + "seq": "\u2026" }, { "code": 96, - "seq": "′" + "seq": "\u2032" }, { "code": 97, - "seq": "″" + "seq": "\u2033" }, { "code": 116, - "seq": "№" + "seq": "\u2116" }, { "code": 117, - "seq": "⋯" + "seq": "\u22ef" }, { "code": 122, - "seq": "─" + "seq": "\u2500" }, { "code": 124, - "seq": "│" + "seq": "\u2502" }, { "code": 121, - "seq": "┌" + "seq": "\u250c" }, { "code": 123, - "seq": "┐" + "seq": "\u2510" }, { "code": 125, - "seq": "└" + "seq": "\u2514" }, { "code": 126, - "seq": "┘" + "seq": "\u2518" }, { "code": 237, - "seq": "▲" + "seq": "\u25b2" }, { "code": 237, - "seq": "▶" + "seq": "\u25b6" }, { "code": 236, - "seq": "▷" + "seq": "\u25b7" }, { "code": 238, - "seq": "▼" + "seq": "\u25bc" }, { "code": 245, - "seq": "♀" + "seq": "\u2640" }, { "code": 239, - "seq": "♂" + "seq": "\u2642" }, { "code": 127, - "seq": " " + "seq": "\u3000" }, { "code": 232, - "seq": "。" + "seq": "\u3002" }, { "code": 112, - "seq": "「" + "seq": "\u300c" }, { "code": 113, - "seq": "」" + "seq": "\u300d" }, { "code": 114, - "seq": "『" + "seq": "\u300e" }, { "code": 115, - "seq": "』" + "seq": "\u300f" }, { "code": 118, - "seq": "ぁ" + "seq": "\u3041" }, { "code": 177, - "seq": "あ" + "seq": "\u3042" }, { "code": 110, - "seq": "ぃ" + "seq": "\u3043" }, { "code": 178, - "seq": "い" + "seq": "\u3044" }, { "code": 111, - "seq": "ぅ" + "seq": "\u3045" }, { "code": 179, - "seq": "う" + "seq": "\u3046" }, { "code": 119, - "seq": "ぇ" + "seq": "\u3047" }, { "code": 180, - "seq": "え" + "seq": "\u3048" }, { "code": 120, - "seq": "ぉ" + "seq": "\u3049" }, { "code": 181, - "seq": "お" + "seq": "\u304a" }, { "code": 182, - "seq": "か" + "seq": "\u304b" }, { "code": 183, - "seq": "き" + "seq": "\u304d" }, { "code": 184, - "seq": "く" + "seq": "\u304f" }, { "code": 185, - "seq": "け" + "seq": "\u3051" }, { "code": 186, - "seq": "こ" + "seq": "\u3053" }, { "code": 187, - "seq": "さ" + "seq": "\u3055" }, { "code": 188, - "seq": "し" + "seq": "\u3057" }, { "code": 189, - "seq": "す" + "seq": "\u3059" }, { "code": 190, - "seq": "せ" + "seq": "\u305b" }, { "code": 191, - "seq": "そ" + "seq": "\u305d" }, { "code": 192, - "seq": "た" + "seq": "\u305f" }, { "code": 193, - "seq": "ち" + "seq": "\u3061" }, { "code": 223, - "seq": "っ" + "seq": "\u3063" }, { "code": 194, - "seq": "つ" + "seq": "\u3064" }, { "code": 195, - "seq": "て" + "seq": "\u3066" }, { "code": 196, - "seq": "と" + "seq": "\u3068" }, { "code": 197, - "seq": "な" + "seq": "\u306a" }, { "code": 198, - "seq": "に" + "seq": "\u306b" }, { "code": 199, - "seq": "ぬ" + "seq": "\u306c" }, { "code": 200, - "seq": "ね" + "seq": "\u306d" }, { "code": 201, - "seq": "の" + "seq": "\u306e" }, { "code": 202, - "seq": "は" + "seq": "\u306f" }, { "code": 203, - "seq": "ひ" + "seq": "\u3072" }, { "code": 204, - "seq": "ふ" + "seq": "\u3075" }, { "code": 205, - "seq": "へ" + "seq": "\u3078" }, { "code": 206, - "seq": "ほ" + "seq": "\u307b" }, { "code": 207, - "seq": "ま" + "seq": "\u307e" }, { "code": 208, - "seq": "み" + "seq": "\u307f" }, { "code": 209, - "seq": "む" + "seq": "\u3080" }, { "code": 210, - "seq": "め" + "seq": "\u3081" }, { "code": 211, - "seq": "も" + "seq": "\u3082" }, { "code": 224, - "seq": "ゃ" + "seq": "\u3083" }, { "code": 212, - "seq": "や" + "seq": "\u3084" }, { "code": 225, - "seq": "ゅ" + "seq": "\u3085" }, { "code": 213, - "seq": "ゆ" + "seq": "\u3086" }, { "code": 226, - "seq": "ょ" + "seq": "\u3087" }, { "code": 214, - "seq": "よ" + "seq": "\u3088" }, { "code": 215, - "seq": "ら" + "seq": "\u3089" }, { "code": 216, - "seq": "り" + "seq": "\u308a" }, { "code": 217, - "seq": "る" + "seq": "\u308b" }, { "code": 218, - "seq": "れ" + "seq": "\u308c" }, { "code": 219, - "seq": "ろ" + "seq": "\u308d" }, { "code": 220, - "seq": "わ" + "seq": "\u308f" }, { "code": 221, - "seq": "を" + "seq": "\u3092" }, { "code": 222, - "seq": "ん" + "seq": "\u3093" }, { "code": 233, - "seq": "ァ" + "seq": "\u30a1" }, { "code": 128, - "seq": "ア" + "seq": "\u30a2" }, { "code": 176, - "seq": "ィ" + "seq": "\u30a3" }, { "code": 129, - "seq": "イ" + "seq": "\u30a4" }, { "code": 234, - "seq": "ゥ" + "seq": "\u30a5" }, { "code": 130, - "seq": "ウ" + "seq": "\u30a6" }, { "code": 235, - "seq": "ェ" + "seq": "\u30a7" }, { "code": 131, - "seq": "エ" + "seq": "\u30a8" }, { "code": 244, - "seq": "ォ" + "seq": "\u30a9" }, { "code": 132, - "seq": "オ" + "seq": "\u30aa" }, { "code": 133, - "seq": "カ" + "seq": "\u30ab" }, { "code": 134, - "seq": "キ" + "seq": "\u30ad" }, { "code": 135, - "seq": "ク" + "seq": "\u30af" }, { "code": 136, - "seq": "ケ" + "seq": "\u30b1" }, { "code": 137, - "seq": "コ" + "seq": "\u30b3" }, { "code": 138, - "seq": "サ" + "seq": "\u30b5" }, { "code": 139, - "seq": "シ" + "seq": "\u30b7" }, { "code": 140, - "seq": "ス" + "seq": "\u30b9" }, { "code": 141, - "seq": "セ" + "seq": "\u30bb" }, { "code": 142, - "seq": "ソ" + "seq": "\u30bd" }, { "code": 143, - "seq": "タ" + "seq": "\u30bf" }, { "code": 144, - "seq": "チ" + "seq": "\u30c1" }, { "code": 172, - "seq": "ッ" + "seq": "\u30c3" }, { "code": 145, - "seq": "ツ" + "seq": "\u30c4" }, { "code": 146, - "seq": "テ" + "seq": "\u30c6" }, { "code": 147, - "seq": "ト" + "seq": "\u30c8" }, { "code": 148, - "seq": "ナ" + "seq": "\u30ca" }, { "code": 149, - "seq": "ニ" + "seq": "\u30cb" }, { "code": 150, - "seq": "ヌ" + "seq": "\u30cc" }, { "code": 151, - "seq": "ネ" + "seq": "\u30cd" }, { "code": 152, - "seq": "ノ" + "seq": "\u30ce" }, { "code": 153, - "seq": "ハ" + "seq": "\u30cf" }, { "code": 154, - "seq": "ヒ" + "seq": "\u30d2" }, { "code": 155, - "seq": "フ" + "seq": "\u30d5" }, { "code": 156, - "seq": "ホ" + "seq": "\u30db" }, { "code": 157, - "seq": "マ" + "seq": "\u30de" }, { "code": 158, - "seq": "ミ" + "seq": "\u30df" }, { "code": 159, - "seq": "ム" + "seq": "\u30e0" }, { "code": 160, - "seq": "メ" + "seq": "\u30e1" }, { "code": 161, - "seq": "モ" + "seq": "\u30e2" }, { "code": 173, - "seq": "ャ" + "seq": "\u30e3" }, { "code": 162, - "seq": "ヤ" + "seq": "\u30e4" }, { "code": 174, - "seq": "ュ" + "seq": "\u30e5" }, { "code": 163, - "seq": "ユ" + "seq": "\u30e6" }, { "code": 175, - "seq": "ョ" + "seq": "\u30e7" }, { "code": 164, - "seq": "ヨ" + "seq": "\u30e8" }, { "code": 165, - "seq": "ラ" + "seq": "\u30e9" }, { "code": 166, - "seq": "ル" + "seq": "\u30eb" }, { "code": 167, - "seq": "レ" + "seq": "\u30ec" }, { "code": 168, - "seq": "ロ" + "seq": "\u30ed" }, { "code": 169, - "seq": "ワ" + "seq": "\u30ef" }, { "code": 170, - "seq": "ヲ" + "seq": "\u30f2" }, { "code": 171, - "seq": "ン" + "seq": "\u30f3" }, { "code": 227, - "seq": "ー" + "seq": "\u30fc" }, { "code": 240, - "seq": "円" + "seq": "\u5186" }, { "code": 231, - "seq": "!" + "seq": "\uff01" }, { "code": 242, - "seq": "." + "seq": "\uff0e" }, { "code": 243, - "seq": "/" + "seq": "\uff0f" }, { "code": 246, - "seq": "0" + "seq": "\uff10" }, { "code": 247, - "seq": "1" + "seq": "\uff11" }, { "code": 248, - "seq": "2" + "seq": "\uff12" }, { "code": 249, - "seq": "3" + "seq": "\uff13" }, { "code": 250, - "seq": "4" + "seq": "\uff14" }, { "code": 251, - "seq": "5" + "seq": "\uff15" }, { "code": 252, - "seq": "6" + "seq": "\uff16" }, { "code": 253, - "seq": "7" + "seq": "\uff17" }, { "code": 254, - "seq": "8" + "seq": "\uff18" }, { "code": 255, - "seq": "9" + "seq": "\uff19" }, { "code": 230, - "seq": "?" + "seq": "\uff1f" }, { "code": 229, - "seq": "゙" + "seq": "\uff9e" }, { "code": 228, - "seq": "゚" + "seq": "\uff9f" } ], "format": 2,