fixing bugs

This commit is contained in:
bryanthaboi
2026-07-25 12:35:00 -04:00
parent 9307488dc1
commit 347838619b
83 changed files with 5625 additions and 1160 deletions
+2 -3
View File
@@ -1,9 +1,8 @@
-- Flavor talk scripts for CopycatsHouse2F (registry id COPYCATS_HOUSE_2F). -- Flavor talk scripts for CopycatsHouse2F (registry id COPYCATS_HOUSE_2F).
-- Source: pokered/scripts/CopycatsHouse2F.asm, pokered/text/CopycatsHouse2F.asm -- Source: pokered/scripts/CopycatsHouse2F.asm, pokered/text/CopycatsHouse2F.asm
-- --
-- TEXT_COPYCATSHOUSE2F_COPYCAT is already ported (with the POKE DOLL / -- TEXT_COPYCATSHOUSE2F_COPYCAT (POKE DOLL -> TM31 MIMIC) lives in
-- TM31 MIMIC trade) in data/scripts/story4.lua, so it is intentionally -- data/scripts/story4.lua.
-- omitted here.
return { return {
COPYCATS_HOUSE_2F = { COPYCATS_HOUSE_2F = {
+90 -45
View File
@@ -59,52 +59,97 @@ end
return { return {
talk = { talk = {
-- Oak: accepts the parcel and hands over the Pokédex, then (once the -- Oak: OaksLabOak1Text. Parcel delivery kicks SCRIPT_OAKSLAB_RIVAL_
-- player has beaten the Route 22 rival) hands over the real POKé -- ARRIVES_AT_OAKS_REQUEST + OaksLabOakGivesPokedexScript (rival walk-
-- BALLs (scripts/OaksLab.asm OaksLabOak1Text, simplified: no rival -- in, full Pokédex speech, rival exit, Route 22 arm). Dex-rating
-- recall / dex-rating / "pokemon can fight" / "around the world" -- (DisplayDexRating) is still skipped.
-- branches -- see docs/known-differences.md)
TEXT_OAKSLAB_OAK1 = { TEXT_OAKSLAB_OAK1 = {
{ "face_player" }, -- 1 { "face_player" },
{ "check_flag", "EVENT_GOT_OAKS_PARCEL" }, -- 2 { "check_item", "POKE_BALL" },
{ "jump_if_false", 16 }, -- 3 { "jump_if_true", "come_see" },
{ "check_flag", "EVENT_OAK_GOT_PARCEL" }, -- 4 { "check_flag", "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE" },
{ "jump_if_true", 16 }, -- 5 { "jump_if_true", "give_balls" },
{ "show_text", "_OaksLabOak1DeliverParcelText" }, -- 6 { "check_flag", "EVENT_GOT_POKEDEX" },
{ "take_item", "OAKS_PARCEL", 1 }, -- 7 { "jump_if_true", "around_world" },
{ "set_flag", "EVENT_OAK_GOT_PARCEL" }, -- 8 { "check_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" },
{ "show_text", "_OaksLabOak1PokemonAroundTheWorldText" }, -- 9 { "jump_if_false", "pre_lab_battle" },
{ "set_flag", "EVENT_GOT_POKEDEX" }, -- 10 { "check_item", "OAKS_PARCEL" },
-- OaksLab.asm OakGivesPokedex: HideObject TOGGLE_POKEDEX_1/2 { "jump_if_false", "raise_young" },
-- so the table sprites leave with the gift (#106). -- OaksLabOak1Text.got_parcel → RivalArrives + OakGivesPokedex
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX1" }, -- 11 { "show_text", "_OaksLabOak1DeliverParcelText" },
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX2" }, -- 12 { "play_sound", "Get_Key_Item" },
-- the Pokédex swaps Viridian's two old men (OaksLab.asm:602-606: { "show_text", "_OaksLabOak1ParcelThanksText" },
-- HideObject TOGGLE_LYING_OLD_MAN / ShowObject TOGGLE_OLD_MAN). { "take_item", "OAKS_PARCEL", 1 },
-- Until this ran, the walking man at (17,5) -- who owns the coffee { "stop_music" },
-- ask and the catch tutorial -- stayed OFF for the whole game { "play_music", "Music_MeetRival" },
-- (toggleable_objects.asm seeds him OFF, the sleeper ON). { "show_text", "_OaksLabRivalGrampsText" },
{ "hide_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY" }, -- 13 { "show_object", "OAKS_LAB", "OAKSLAB_RIVAL" },
{ "show_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN" }, -- 14 -- OaksLabCalcRivalMovementScript: sprite map (8,11) → cell (4,7)
{ "jump", "end" }, -- 15 -- when the player stands below Oak (the usual desk talk).
{ "check_flag", "EVENT_GOT_STARTER" }, -- 16 { "place_npc", 1, 4, 7, "up" },
{ "jump_if_false", 31 }, -- 17 { "move_npc_to", 1, 4, 3 },
{ "check_item", "POKE_BALL" }, -- 18 { "play_music", "Music_OaksLab" },
{ "jump_if_true", 29 }, -- 19 { "face_object", 1, "up" },
{ "check_flag", "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE" }, -- 20 { "face_object", 5, "down" },
{ "jump_if_false", 33 }, -- 21 { "show_text", "_OaksLabRivalWhatDidYouCallMeForText" },
{ "check_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 22 { "face_object", 1, "up" },
{ "jump_if_true", 29 }, -- 23 { "face_object", 5, "down" },
{ "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 24 { "show_text", "_OaksLabOakIHaveARequestText" },
{ "give_item", "POKE_BALL", 5, false }, -- 25 { "face_object", 1, "up" },
{ "show_text", "_OaksLabOak1ReceivedPokeballsText" }, -- 26 { "face_object", 5, "down" },
{ "show_text", "_OaksLabGivePokeballsExplanationText" }, -- 27 { "show_text", "_OaksLabOakMyInventionPokedexText" },
{ "jump", "end" }, -- 28 { "show_text", "_OaksLabOakGotPokedexText" },
{ "show_text", "_OaksLabOak1ComeSeeMeSometimesText" }, -- 29 { "play_sound", "Get_Key_Item" },
{ "jump", "end" }, -- 30 { "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX1" },
{ "show_text", "_OaksLabOak1WhichPokemonDoYouWantText" }, -- 31 { "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX2" },
{ "jump", "end" }, -- 32 { "face_object", 1, "up" },
{ "show_text", "_OaksLabOak1RaiseYourYoungPokemonText" }, -- 33 { "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 = TEXT_OAKSLAB_CHARMANDER_POKE_BALL =
+16 -9
View File
@@ -878,16 +878,23 @@ M.HALL_OF_FAME = {
M.CERULEAN_CITY = { M.CERULEAN_CITY = {
talk = { talk = {
-- CeruleanCityRivalText: Bill line once beaten; pre-battle otherwise.
-- Post-fight walk matches CeruleanCityMovement4 (right then into town).
TEXT_CERULEANCITY_RIVAL = { TEXT_CERULEANCITY_RIVAL = {
{ "face_player" }, -- 1 { "face_player" }, -- 1
{ "check_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 2 { "check_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 2
{ "jump_if_true", 9 }, -- 3 { "jump_if_true", 13 }, -- 3
{ "show_text", "_CeruleanCityRivalPreBattleText" }, -- 4 { "show_text", "_CeruleanCityRivalPreBattleText" }, -- 4
{ "rival_battle", "OPP_RIVAL1", 7 }, -- 5 { "rival_battle", "OPP_RIVAL1", 7 }, -- 5
{ "jump_if_false", 10 }, -- 6 { "jump_if_false", "end" }, -- 6
{ "set_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 7 { "set_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 7
{ "show_text", "_CeruleanCityRivalDefeatedText" }, -- 8 { "show_text", "_CeruleanCityRivalDefeatedText" }, -- 8
{ "jump", 10 }, -- 9 { "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
}, },
}, },
} }
+60 -20
View File
@@ -445,33 +445,67 @@ local function engageSuperNerd(game, ow, onDone)
ow:engageTrainer(nerd, onDone) ow:engageTrainer(nerd, onDone)
end 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) return function(game, ow, npc, done)
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
local ChoiceBox = require("src.ui.ChoiceBox") local t = game.data.text
if game.save.flags.EVENT_GOT_A_FOSSIL then local flags = game.save.flags
game.stack:push(TextBox.new(game, "You already took\na fossil.", done)) if flags.EVENT_GOT_DOME_FOSSIL or flags.EVENT_GOT_HELIX_FOSSIL then
done()
return return
end end
-- can't grab a fossil until the Super Nerd is beaten -- he intercepts
if not superNerdBeaten(ow) then if not superNerdBeaten(ow) then
engageSuperNerd(game, ow, done) engageSuperNerd(game, ow, done)
return return
end end
local name = game.data.items[itemId].name local want = (itemId == "DOME_FOSSIL")
game.stack:push(TextBox.new(game, ("You want the\n%s?"):format(name), function() and (t._MtMoonB2FDomeFossilYouWantText or "You want the\nDOME FOSSIL?")
game.stack:push(ChoiceBox.new(game, function(yes) or (t._MtMoonB2FHelixFossilYouWantText or "You want the\nHELIX FOSSIL?")
if not yes then done() return end game.stack:push(TextBox.new(game, want, nil, { choice = function(yes)
game.save.inventory[itemId] = 1 if not yes then done() return end
game.save.flags.EVENT_GOT_A_FOSSIL = true if not require("src.inventory.Bag").add(game.save, itemId, 1) then
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)
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
("%s got the\n%s!"):format(game.save.player.name, name), done)) t._MtMoonB2FYouHaveNoRoomText or "Look, you've got\nno room for this.",
end)) done))
end)) 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
end end
@@ -486,8 +520,10 @@ M.MT_MOON_B2F = {
return false return false
end, end,
talk = { talk = {
TEXT_MTMOONB2F_DOME_FOSSIL = mtMoonFossil("DOME_FOSSIL", "MTMOONB2F_HELIX_FOSSIL"), TEXT_MTMOONB2F_DOME_FOSSIL = mtMoonFossil(
TEXT_MTMOONB2F_HELIX_FOSSIL = mtMoonFossil("HELIX_FOSSIL", "MTMOONB2F_DOME_FOSSIL"), "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 if def then
mon.stats = Stats.calc(def, mon.level, mon.dvs, mon.statExp) mon.stats = Stats.calc(def, mon.level, mon.dvs, mon.statExp)
mon.hp = mon.stats.hp 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 end
table.insert(game.save.party, mon) table.insert(game.save.party, mon)
game.save.daycare = nil game.save.daycare = nil
+18 -17
View File
@@ -212,30 +212,31 @@ M.COPYCATS_HOUSE_2F = {
talk = { talk = {
TEXT_COPYCATSHOUSE2F_COPYCAT = function(game, ow, npc, done) TEXT_COPYCATSHOUSE2F_COPYCAT = function(game, ow, npc, done)
local t = text(game) local t = text(game)
local Bag = require("src.inventory.Bag")
if game.save.flags.EVENT_GOT_TM31 then if game.save.flags.EVENT_GOT_TM31 then
push(game, t._CopycatsHouse2FCopycatWhirlingText push(game, t._CopycatsHouse2FCopycatTM31Explanation2Text, done)
or "Huh? Huh? Huh?", done)
return return
end end
if (game.save.inventory.POKE_DOLL or 0) > 0 then -- Mimic line first; auto-trade POKE DOLL for TM31 (no YES/NO).
ask(game, t._CopycatsHouse2FCopycatPokeDollText push(game, t._CopycatsHouse2FCopycatDoYouLikePokemonText, function()
or "Oh wow!\nA POKé DOLL!\fFor me?\nCan I have it?", function(yes) if (game.save.inventory.POKE_DOLL or 0) <= 0 then
if not yes then done() return end done()
game.save.inventory.POKE_DOLL = game.save.inventory.POKE_DOLL - 1 return
if game.save.inventory.POKE_DOLL == 0 then end
game.save.inventory.POKE_DOLL = nil push(game, t._CopycatsHouse2FCopycatTM31PreReceiveText, function()
end if not Bag.add(game.save, "TM_MIMIC", 1) then
if not require("src.inventory.Bag").add(game.save, "TM_MIMIC", 1) then push(game, t._CopycatsHouse2FCopycatTM31NoRoomText, done)
push(game, "You don't have\nroom for TM31!", done)
return return
end 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 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) end)
return end)
end
push(game, t._CopycatsHouse2FCopycatText
or "I like to mimic\npeople!", done)
end, end,
}, },
} }
+38 -15
View File
@@ -457,6 +457,19 @@ end
-- EVENT_ROUTE22_RIVAL_WANTS_BATTLE in Oak's lab (expired by Pewter -- 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 -- Gym) and again in Viridian Gym; we derive the same windows from the
-- surrounding story flags so old saves work too. -- 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) local function route22Scene(n, objIndex, objName, oppClass, baseParty, beatFlag, py)
return { return {
{ "show_object", "ROUTE_22", objName }, -- 1 { "show_object", "ROUTE_22", objName }, -- 1
@@ -468,7 +481,7 @@ local function route22Scene(n, objIndex, objName, oppClass, baseParty, beatFlag,
{ "set_flag", beatFlag }, -- 7 { "set_flag", beatFlag }, -- 7
{ "show_text", "_Route22Rival" .. n .. "DefeatedText" }, -- 8 { "show_text", "_Route22Rival" .. n .. "DefeatedText" }, -- 8
{ "show_text", "_Route22RivalAfterBattleText" .. n }, -- 9 { "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 { "hide_object", "ROUTE_22", objName }, -- 11
} }
end end
@@ -495,6 +508,14 @@ M.ROUTE_22 = {
-- Cerulean City: the Nugget Bridge rival ambush (CeruleanCityCoords2), -- Cerulean City: the Nugget Bridge rival ambush (CeruleanCityCoords2),
-- the TM28 rocket thief with his forced-fight cells (Coords1), and the -- the TM28 rocket thief with his forced-fight cells (Coords1), and the
-- cave guard who steps aside once you are Champion. -- 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) local function ceruleanRivalScene(px, py)
return { return {
{ "show_object", "CERULEAN_CITY", "CERULEANCITY_RIVAL" }, -- 1 { "show_object", "CERULEAN_CITY", "CERULEANCITY_RIVAL" }, -- 1
@@ -502,41 +523,43 @@ local function ceruleanRivalScene(px, py)
{ "face_object", 1, "down" }, -- 3 { "face_object", 1, "down" }, -- 3
{ "show_text", "_CeruleanCityRivalPreBattleText" }, -- 4 { "show_text", "_CeruleanCityRivalPreBattleText" }, -- 4
{ "rival_battle", "OPP_RIVAL1", 7 }, -- 5 { "rival_battle", "OPP_RIVAL1", 7 }, -- 5
{ "jump_if_false", 10 }, -- 6 { "jump_if_false", 11 }, -- 6
{ "set_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 7 { "set_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 7
{ "show_text", "_CeruleanCityRivalDefeatedText" }, -- 8 { "show_text", "_CeruleanCityRivalDefeatedText" }, -- 8
{ "move_npc_to", 1, 20, 2 }, -- 9 { "show_text", "_CeruleanCityRivalIWentToBillsText" }, -- 9
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_RIVAL" }, -- 10 { "walk_npc", 1, ceruleanRivalExitDirs(px) }, -- 10
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_RIVAL" }, -- 11
} }
end end
-- scripts/CeruleanCity.asm CeruleanCityRocketText: fight the thief, -- 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 = { local rocketRows = {
{ "face_player" }, -- 1 { "face_player" }, -- 1
{ "check_flag", "EVENT_GOT_TM28" }, -- 2 { "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 { "check_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 4
{ "jump_if_true", 9 }, -- 5 { "jump_if_true", 9 }, -- 5
{ "show_text", "_CeruleanCityRocketText" }, -- 6 { "show_text", "_CeruleanCityRocketText" }, -- 6
{ "start_battle", "trainer", "OPP_ROCKET", 5 }, -- 7 { "start_battle", "trainer", "OPP_ROCKET", 5 }, -- 7
{ "jump_if_false", 18 }, -- 8 { "jump_if_false", "end" }, -- 8
{ "show_text", "_CeruleanCityRocketIllReturnTheTMText" }, -- 9 { "show_text", "_CeruleanCityRocketIllReturnTheTMText" }, -- 9
{ "set_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 10 { "set_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 10
{ "give_item", "TM_DIG", 1, false }, -- 11 (row 13 prints) { "give_item", "TM_DIG", 1, false }, -- 11 (row 13 prints)
{ "set_flag", "EVENT_GOT_TM28" }, -- 12 { "set_flag", "EVENT_GOT_TM28" }, -- 12
{ "show_text", "_CeruleanCityRocketReceivedTM28Text" }, -- 13 { "show_text", "_CeruleanCityRocketReceivedTM28Text" }, -- 13
{ "show_text", "_CeruleanCityRocketIBetterGetMovingText" }, -- 14 { "show_text", "_CeruleanCityRocketIBetterGetMovingText" }, -- 14
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_ROCKET" }, -- 15 { "fade", "out" }, -- 15 GBFadeOutToBlack
-- CeruleanHideRocket (scripts/CeruleanCity_2.asm) does all three behind -- CeruleanHideRocket while black: GUARD1 (28,12) appears, GUARD2
-- one fade: the ROCKET goes, GUARD1 (28,12) appears and GUARD2 (27,12) -- (27,12) and the ROCKET go. GUARD2 blocks the trashed-house south
-- disappears. GUARD2 stands on the only walkable neighbour of the -- door neighbour — the swap reconnects the city (Bill's ticket does
-- trashed house's south door, which is one of the two ways through the -- the same in story.lua; either route is enough).
-- 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.
{ "show_object", "CERULEAN_CITY", "CERULEANCITY_GUARD1" }, -- 16 { "show_object", "CERULEAN_CITY", "CERULEANCITY_GUARD1" }, -- 16
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_GUARD2" }, -- 17 { "hide_object", "CERULEAN_CITY", "CERULEANCITY_GUARD2" }, -- 17
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_ROCKET" }, -- 18
{ "fade", "in" }, -- 19 GBFadeInFromBlack
} }
M.CERULEAN_CITY = { M.CERULEAN_CITY = {
+61 -7
View File
@@ -12,6 +12,12 @@
-- "; deactivate gym trainers" / SetEventRange in the other gyms, and -- "; deactivate gym trainers" / SetEventRange in the other gyms, and
-- FightingDojo.asm SetEventRange EVENT_BEAT_KARATE_MASTER .. -- FightingDojo.asm SetEventRange EVENT_BEAT_KARATE_MASTER ..
-- EVENT_BEAT_FIGHTING_DOJO_TRAINER_3). -- 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 function range(prefix, first, last)
local t = {} local t = {}
@@ -31,28 +37,76 @@ return {
hide = { hide = {
{ "PEWTER_CITY", "PEWTERCITY_YOUNGSTER" }, { "PEWTER_CITY", "PEWTERCITY_YOUNGSTER" },
{ "ROUTE_22", "ROUTE22_RIVAL1" }, { "ROUTE_22", "ROUTE22_RIVAL1" },
},
dialogue = {
"_PewterGymBrockReceivedBoulderBadgeText",
"_PewterGymBrockBoulderBadgeInfoText",
"_PewterGymBrockWaitTakeThisText",
"_PewterGymReceivedTM34Text",
"_TM34ExplanationText",
} }, } },
["OPP_MISTY#1"] = { badge = "CASCADEBADGE", flag = "EVENT_BEAT_MISTY", ["OPP_MISTY#1"] = { badge = "CASCADEBADGE", flag = "EVENT_BEAT_MISTY",
item = "TM_BUBBLEBEAM", 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", ["OPP_LT_SURGE#1"] = { badge = "THUNDERBADGE", flag = "EVENT_BEAT_LT_SURGE",
item = "TM_THUNDERBOLT", 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", ["OPP_ERIKA#1"] = { badge = "RAINBOWBADGE", flag = "EVENT_BEAT_ERIKA",
item = "TM_MEGA_DRAIN", 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", ["OPP_KOGA#1"] = { badge = "SOULBADGE", flag = "EVENT_BEAT_KOGA",
item = "TM_TOXIC", 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", ["OPP_SABRINA#1"] = { badge = "MARSHBADGE", flag = "EVENT_BEAT_SABRINA",
item = "TM_PSYWAVE", 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", ["OPP_BLAINE#1"] = { badge = "VOLCANOBADGE", flag = "EVENT_BEAT_BLAINE",
item = "TM_FIRE_BLAST", 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", ["OPP_GIOVANNI#3"] = { badge = "EARTHBADGE", flag = "EVENT_BEAT_GIOVANNI",
item = "TM_FISSURE", 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 -- Silph Co. Giovanni: unlocks the president's Master Ball gift
["OPP_GIOVANNI#2"] = { flag = "EVENT_BEAT_SILPH_CO_GIOVANNI" }, ["OPP_GIOVANNI#2"] = { flag = "EVENT_BEAT_SILPH_CO_GIOVANNI" },
+14
View File
@@ -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 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. 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 ## Tab structure
`self.tab` is one of `"red"`, `"blue"`, `"yellow"`, `"mods"`. The tab bar `self.tab` is one of `"red"`, `"blue"`, `"yellow"`, `"mods"`. The tab bar
+6 -3
View File
@@ -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 `assets/generated/`, and any ROM. It contains the first-boot Lua importer and
`tools/rom_manifest.json`. `tools/rom_manifest.json`.
The current importer has desktop file pickers only. A production Android ROM import on Android uses `love.system.pickFile()`
release still needs a Storage Access Framework handoff that passes the chosen `GameActivity.showRomFilePicker` (Storage Access Framework), which copies the
ROM to LÖVE; the APK packaging itself is data-free. 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 ### SDK / NDK
+225 -139
View File
@@ -275,6 +275,10 @@ local function makeBattler(data, mon, isPlayer, save)
badgeBoosts = badgeBoosts, badgeBoosts = badgeBoosts,
statuses = data.statuses, statuses = data.statuses,
shownHP = mon.hp, -- the HP the bar displays (UpdateHPBar drain) 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 = {}, stages = {},
-- volatile state; Transform/Conversion/Mimic override the cur* fields -- volatile state; Transform/Conversion/Mimic override the cur* fields
curStats = mon.stats, curStats = mon.stats,
@@ -556,6 +560,12 @@ function BattleState:say(text)
table.insert(self.queue, { text = text }) table.insert(self.queue, { text = text })
end 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) function BattleState:act(fn)
table.insert(self.queue, { fn = fn }) table.insert(self.queue, { fn = fn })
end end
@@ -816,8 +826,24 @@ function BattleState:updateQueue()
if self.charIndex < self.total then if self.charIndex < self.total then
self.charIndex = math.min(self.total, self.charIndex + 2) self.charIndex = math.min(self.total, self.charIndex + 2)
else 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 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 self.current = nil
end end
end end
@@ -1068,11 +1094,14 @@ function BattleState:update(dt)
self:updateFx() self:updateFx()
local input = self.game.input local input = self.game.input
-- safety net: HP changed outside a queued drain (level-up heals, -- safety net: HP/status changed outside a queued drain (level-up heals,
-- field effects) snaps once the queue is idle -- field effects, bag cures) snaps once the queue is idle
if self.phase == "menu" then if self.phase == "menu" then
for _, b in ipairs({ self.player, self.enemy }) do 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
end end
@@ -2153,90 +2182,105 @@ end
-- move execution pipeline -- 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) function BattleState:executeAction(user, target, action)
if user.mon.hp <= 0 or target.mon.hp <= 0 then return end if user.mon.hp <= 0 or target.mon.hp <= 0 then return end
if not action then return end if not action then return end
-- ghost battles: the ghost never attacks; its whole turn is the local function run()
-- GetOutText (ExecuteEnemyMove -> PrintGhostText, core.asm:5462-5463) -- ghost battles: the ghost never attacks; its whole turn is the
if self.ghost and not user.isPlayer then -- GetOutText (ExecuteEnemyMove -> PrintGhostText, core.asm:5462-5463)
self:sayNext(self.data.text._GetOutText or "GHOST: Get out...\nGet out...") if self.ghost and not user.isPlayer then
return self:sayNext(self.data.text._GetOutText or "GHOST: Get out...\nGet out...")
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)
return return
end 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 -- refresh the held-in-place mirror before the status checks (see
self:performMove(user, target, action, false) -- 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 end
-- Sleep / confusion onomatopoeia from Check*StatusConditions -- Sleep / confusion onomatopoeia from Check*StatusConditions
@@ -2732,51 +2776,37 @@ function BattleState:enemyMonFainted()
end end
if nextIndex then if nextIndex then
self.enemyIndex = nextIndex self.enemyIndex = nextIndex
-- SHIFT battle style (the default): announce the next mon and -- EnemySendOutFirstMon (core.asm:1366-1443): SHIFT offers a free
-- offer a free switch (SET skips the prompt) -- 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 nextMon = self.enemyParty[self.enemyIndex]
local nextName = nextMon.nickname or self.data.pokemon[nextMon.species].name local nextName = nextMon.nickname or self.data.pokemon[nextMon.species].name
local style = (self.game.save.options or {}).battleStyle or "shift" local style = tostring((self.game.save.options or {}).battleStyle or "shift")
local healthy = 0 :lower()
for _, mon in ipairs(self.game.save.party) do local partyCount = #self.game.save.party
if mon.hp > 0 then healthy = healthy + 1 end -- SwitchPlayerMon runs AFTER TrainerSentOutText (core.asm:1436-1443)
end local shiftSwitchMon = nil
if style ~= "set" and healthy > 1 and self.player.mon.hp > 0 then if style ~= "set" and partyCount > 1 and self.player.mon.hp > 0 then
self:say(("%s is\nabout to use\n%s!"):format(self.trainer.name, nextName)) -- _TrainerAboutToUseText: "X is" / "about to use" then cont nick,
self:say(("Will %s\nchange POKéMON?"):format(self.game.save.player.name)) -- then para "Will PLAYER" / "change POKéMON?" with YES/NO.
local game = self.game self:say(("%s is\nabout to use"):format(self.trainer.name))
self:ui(function() self:say(("%s!"):format(nextName))
local ChoiceBox = require("src.ui.ChoiceBox") self:sayChoice(
return ChoiceBox.new(game, function(yes) ("Will %s\nchange POKéMON?"):format(self.game.save.player.name),
function(yes)
if not yes then return end if not yes then return end
local game = self.game
Screens.push(game, "PartyMenu", { Screens.push(game, "PartyMenu", {
battle = self, battle = self,
forceSwitch = true,
onSwitch = function(mon) onSwitch = function(mon)
if mon ~= self.player.mon and mon.hp > 0 then if mon ~= self.player.mon and mon.hp > 0 then
local previous = self.player shiftSwitchMon = mon
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)
end end
end, end,
}) })
end) end)
end)
end end
self:act(function() self:act(function()
local previous = self.enemy local previous = self.enemy
@@ -2805,6 +2835,29 @@ function BattleState:enemyMonFainted()
end) end)
end) 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 return
end end
local prize = (self.trainer.baseMoney or 0) * self.enemy.mon.level local prize = (self.trainer.baseMoney or 0) * self.enemy.mon.level
@@ -2928,6 +2981,8 @@ function BattleState:openReplacementMenu()
self:ui(function() self:ui(function()
return self:buildScreen("PartyMenu", { return self:buildScreen("PartyMenu", {
battle = self, battle = self,
-- ChooseNextMon: pick immediately (no SWITCH/STATS/CANCEL)
forceSwitch = true,
onSwitch = function(mon) onSwitch = function(mon)
if mon.hp <= 0 then if mon.hp <= 0 then
self:say("There's no will\nto fight!") 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) -- called by BagMenu after an item is used in battle (consumes the turn)
function BattleState:itemUsed(messages) 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 for _, m in ipairs(messages or {}) do self:say(m) end
table.insert(self.queue, { drain = true }) -- potions animate the bar table.insert(self.queue, { drain = true }) -- potions animate the bar
self:act(function() self:act(function()
@@ -3159,10 +3217,39 @@ function BattleState:ballMissMessage(shakes)
return t._ItemUseBallText04 or "Shoot! It was so\nclose too!" return t._ItemUseBallText04 or "Shoot! It was so\nclose too!"
end 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, -- 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 -- 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 -- "New POKéDEX data will be added" + the dex entry page, then
-- party add (with the nickname ask) or the PC transfer text. -- AddPartyMon or SendNewMonToBox (both call AskName), then the PC
-- transfer text when the party was full.
function BattleState:storeCaughtMon() function BattleState:storeCaughtMon()
-- ItemUseBall reloads the caught mon via LoadEnemyMonData -- ItemUseBall reloads the caught mon via LoadEnemyMonData
-- (item_effects.asm:472-501), regenerating its move list from the -- (item_effects.asm:472-501), regenerating its move list from the
@@ -3182,31 +3269,20 @@ function BattleState:storeCaughtMon()
return self:buildScreen("DexEntryMenu", species) return self:buildScreen("DexEntryMenu", species)
end) end)
end end
if Party.add(game.save.party, self.enemy.mon) then local function askCaughtNickname()
-- nickname prompt (AskName runs inside AddPartyMon; box mons are
-- never offered a nickname)
local caught = self.enemy.mon local caught = self.enemy.mon
local enemyName = self.enemy.name local enemyName = self.enemy.name
self:uiNext(function() self:uiNext(function()
local ChoiceBox = require("src.ui.ChoiceBox") return self:askNicknameUI(caught, enemyName)
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)
end) end)
end
if Party.add(game.save.party, self.enemy.mon) then
askCaughtNickname()
else else
destination = "box" destination = "box"
local boxNum = require("src.pokemon.Boxes").deposit(game.save, self.enemy.mon) local boxNum = require("src.pokemon.Boxes").deposit(game.save, self.enemy.mon)
if boxNum then if boxNum then
askCaughtNickname()
-- _ItemUseBallText07/08 keyed on EVENT_MET_BILL -- _ItemUseBallText07/08 keyed on EVENT_MET_BILL
local pc = (game.save.flags and game.save.flags.EVENT_MET_BILL) local pc = (game.save.flags and game.save.flags.EVENT_MET_BILL)
and "BILL's PC" or "someone's PC" 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 barData = self:colorMode() and {} or self.data -- gray fill when zoned
local fx = self.fx local fx = self.fx
local hudShake = (fx and fx.hudShakeX) or 0 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 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, <LV>+level (4,1), -- enemy HUD (DrawEnemyHUDAndHPBar): name row 0, <LV>+level (4,1),
-- HP bar (2,2) with the vertical tick at (1,2), underline row 3; -- HP bar (2,2) with the vertical tick at (1,2), underline row 3;
-- AnimationShakeEnemyHUD nudges just this block via SCX -- AnimationShakeEnemyHUD nudges just this block via SCX
@@ -4052,8 +4131,8 @@ function BattleState:drawHUDs(slide)
end end
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
Font.draw(self.enemy.name, nameX(1, self.enemy.name), 0) Font.draw(self.enemy.name, nameX(1, self.enemy.name), 0)
if self.enemy.mon.status then if self.enemy.shownStatus then
Font.draw(self:statusLabel(self.enemy.mon), 40, 8) Font.draw(self:statusLabel({ status = self.enemy.shownStatus }), 40, 8)
else else
hudTile(0x6E, 32, 8) -- <LV> hudTile(0x6E, 32, 8) -- <LV>
Font.draw(tostring(self.enemy.mon.level), 40, 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) -- the tick at (18,10) and the triangle at (9,11)
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
Font.draw(self.player.name, nameX(10, self.player.name), 56) Font.draw(self.player.name, nameX(10, self.player.name), 56)
if self.player.mon.status then if self.player.shownStatus then
Font.draw(self:statusLabel(self.player.mon), 120, 64) Font.draw(self:statusLabel({ status = self.player.shownStatus }), 120, 64)
else else
hudTile(0x6E, 112, 64) -- <LV> hudTile(0x6E, 112, 64) -- <LV>
Font.draw(tostring(self.player.mon.level), 120, 64) Font.draw(tostring(self.player.mon.level), 120, 64)
@@ -4207,6 +4286,13 @@ function BattleState:drawTextArea()
end end
function BattleState:draw() 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 local fx = self.fx
-- window shakes (SE_SHAKE_SCREEN / the enemy-hit vertical shake); -- window shakes (SE_SHAKE_SCREEN / the enemy-hit vertical shake);
-- the animations-off fallback keeps the old +-2 alternation -- the animations-off fallback keeps the old +-2 alternation
+2
View File
@@ -102,6 +102,8 @@ function Data:seedDefaults()
-- after-battle rows so Blaine's SetEventRange deactivation and talk -- after-battle rows so Blaine's SetEventRange deactivation and talk
-- after-text work like the other gyms (scripts/CinnabarGym.asm). -- after-text work like the other gyms (scripts/CinnabarGym.asm).
self:seedCinnabarGymTrainerHeaders() self:seedCinnabarGymTrainerHeaders()
-- #189: 1F cabin door order vs rooms map (survey zoom)
require("src.world.SsAnneLayout").apply(self.maps)
end end
function Data:seedCinnabarGymTrainerHeaders() function Data:seedCinnabarGymTrainerHeaders()
+2
View File
@@ -375,6 +375,8 @@ function RomExtractor:extractMaps()
} }
self:tick("Maps", mapIndex, #keys) self:tick("Maps", mapIndex, #keys)
end end
-- keep the extract ROM-faithful; Data:seedDefaults applies the #189
-- S.S. Anne 1F cabin reorder at load time
self:write("maps", out) self:write("maps", out)
return out return out
end end
+33 -13
View File
@@ -270,10 +270,21 @@ end
-- via USB or a file manager, no runtime permission needed), and this scans -- via USB or a file manager, no runtime permission needed), and this scans
-- it directly through love.filesystem -- already mounted at the physfs -- it directly through love.filesystem -- already mounted at the physfs
-- root, so no io.* absolute-path handling is needed. -- 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 for _, name in ipairs(love.filesystem.getDirectoryItems("")) do
if name:lower():match("%.gb$") and love.filesystem.getInfo(name, "file") then 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
end end
return nil return nil
@@ -439,12 +450,12 @@ function RomImporter.new(onComplete, opts)
self.romName[version] = "pokemon_" .. info.id .. ".gb" self.romName[version] = "pokemon_" .. info.id .. ".gb"
end end
-- Android has no native picker until the player copies a ROM into the -- Android: import a save-dir .gb that is not yet ready (USB drop or a
-- external folder; import any .gb already there (routed by SHA-1) unless -- leftover SAF pick), routed by SHA-1. Already-imported carts are skipped
-- both games are already imported. -- so a stale picked_rom.gb cannot block the opposite version.
if android and not (self.ready.red and self.ready.blue) then if android and not (self.ready.red and self.ready.blue) then
local name = scanForRom() local name, data = findPendingRom(self.ready)
if name then self:startData(love.filesystem.read(name), name) end if name then self:startData(data, name) end
end end
-- Mouse-wheel scroll for the save-slot / mods lists. main.lua (off limits) -- 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 -- 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 -- 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 -- 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 -- it into the save directory, so a pending-ROM rescan on refocus picks it
-- without the player needing to tap the button again. -- up without the player needing to tap the button again.
function RomImporter:focus(f) function RomImporter:focus(f)
if not (f and self.android and self.workState ~= "working") then return end if not (f and self.android and self.workState ~= "working") then return end
if self.ready.red and self.ready.blue then return end if self.ready.red and self.ready.blue then return end
local name = scanForRom() local name, data = findPendingRom(self.ready)
if name then self:startData(love.filesystem.read(name), name) end if name then self:startData(data, name) end
end end
function RomImporter:setError(message, version) function RomImporter:setError(message, version)
@@ -586,6 +597,12 @@ function RomImporter:startData(data, displayName)
self.returning[version] = false self.returning[version] = false
self.romName[version] = (displayName self.romName[version] = (displayName
and (displayName:match("[^/\\]+$") or displayName)) or self.romName[version] 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.importing = nil
self.workState = "complete" self.workState = "complete"
self.completeVersion = version self.completeVersion = version
@@ -740,9 +757,12 @@ function RomImporter:choose(version)
if self.workState == "working" then return end if self.workState == "working" then return end
self.chooseVersion = version or "red" self.chooseVersion = version or "red"
if self.android then 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 if name then
self:startData(love.filesystem.read(name), name) self:startData(data, name)
elseif not love.system.pickFile() then elseif not love.system.pickFile() then
-- Picker unavailable (API < 19, or no document-picker app installed): -- Picker unavailable (API < 19, or no document-picker app installed):
-- fall back to the USB folder-drop path as a friendly notice, not an -- fall back to the USB folder-drop path as a friendly notice, not an
+4 -3
View File
@@ -114,10 +114,11 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
local itemDef = data.items[itemId] local itemDef = data.items[itemId]
local name = itemDef and itemDef.name or itemId local name = itemDef and itemDef.name or itemId
-- ItemUseVitamin / ItemUsePPUp / ItemUseEvoStone / ItemUseCoinCase -- ItemUseVitamin / ItemUsePPUp / ItemUseEvoStone / ItemUseCoinCase /
-- all refuse mid-battle (jp nz, ItemUseNotTime) -- ItemUseTMHM all refuse mid-battle (jp nz, ItemUseNotTime)
if battle and (VITAMINS[itemId] or STONES[itemId] or itemId == "PP_UP" 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 return "failed", { "OAK: " .. save.player.name
.. "!\nThis isn't the\ntime to use that!" } .. "!\nThis isn't the\ntime to use that!" }
end end
+4
View File
@@ -501,6 +501,10 @@ function LinkState:updateTrade(input)
require("src.core.Sound").play(game.data, "Trade_Machine") require("src.core.Sound").play(game.data, "Trade_Machine")
Screens.push(game, "TradeAnim", { Screens.push(game, "TradeAnim", {
sent = sent, received = received, 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() onDone = function()
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
("Trade completed!\f%s received\n%s!"):format(game.save.player.name, name), ("Trade completed!\f%s received\n%s!"):format(game.save.player.name, name),
+28
View File
@@ -29,6 +29,34 @@ function Pokemon.movesAtLevel(speciesDef, level)
return moves return moves
end 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) function Pokemon.new(data, species, level, rng)
local def = data.pokemon[species] local def = data.pokemon[species]
assert(def, "unknown species " .. tostring(species)) assert(def, "unknown species " .. tostring(species))
+68 -23
View File
@@ -50,6 +50,7 @@ function TextBox.new(game, text, onDone, opts)
self.charIndex = 0 self.charIndex = 0
self.shown = {} -- visible lines (max 2), each a list of glyph codes self.shown = {} -- visible lines (max 2), each a list of glyph codes
self.waiting = false self.waiting = false
self.contAdvance = false
self.done = false self.done = false
self.blink = 0 self.blink = 0
self:beginLine() self:beginLine()
@@ -65,7 +66,11 @@ TextBox.TOKENS = {
PLAYER = function(game) return game.save.player.name or "RED" end, PLAYER = function(game) return game.save.player.name or "RED" end,
RIVAL = function(game) return game.save.player.rival or "BLUE" end, RIVAL = function(game) return game.save.player.rival or "BLUE" end,
RAM = function(game, arg) 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, end,
} }
@@ -83,31 +88,55 @@ end
-- Split marked-up text into pages of lines. \v-scrolled lines become -- Split marked-up text into pages of lines. \v-scrolled lines become
-- additional lines on the same page (the box scrolls them). -- 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) function TextBox.paginate(text, maxCols)
maxCols = maxCols or (Theme.textBox and Theme.textBox.maxCols) or MAX_COLS maxCols = maxCols or (Theme.textBox and Theme.textBox.maxCols) or MAX_COLS
local pages = {} 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 for pageText in (text .. "\f"):gmatch("(.-)\f") do
if pageText ~= "" then if pageText ~= "" then
local lines = {} local lines, conts = {}, {}
for chunk in (pageText .. "\n"):gmatch("(.-)[\n\v]") do local pos, waitNext = 1, false
local line = chunk while true do
-- wrap long lines defensively (the source rarely needs it) local npos = pageText:find("[\n\v]", pos)
while #line > maxCols do if not npos then
local cut = maxCols pushLine(lines, conts, pageText:sub(pos), waitNext)
for i = maxCols, 1, -1 do break
if line:sub(i, i) == " " then cut = i break end
end
table.insert(lines, line:sub(1, cut))
line = line:sub(cut + 1)
end 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 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
end end
if #pages == 0 then pages = { { "" } } end if #pages == 0 then
pages = { { "" } }
contBefore = { { false } }
end
pages.contBefore = contBefore
return pages return pages
end end
@@ -179,10 +208,17 @@ function TextBox:update(dt)
if input:wasPressed("a") or input:wasPressed("b") then if input:wasPressed("a") or input:wasPressed("b") then
require("src.core.Sound").play(self.game.data, "Press_AB") require("src.core.Sound").play(self.game.data, "Press_AB")
self.waiting = false self.waiting = false
self.shown = {} if self.contAdvance then
self.pageIndex = self.pageIndex + 1 -- ContText / ManualTextScroll: keep the box, scroll one line
self.lineIndex = 1 self.contAdvance = false
self:beginLine() self.lineIndex = self.lineIndex + 1
self:beginLine()
else
self.shown = {}
self.pageIndex = self.pageIndex + 1
self.lineIndex = 1
self:beginLine()
end
end end
return return
end end
@@ -203,10 +239,19 @@ function TextBox:update(dt)
-- line finished -- line finished
local page = self.pages[self.pageIndex] local page = self.pages[self.pageIndex]
if self.lineIndex < #page then if self.lineIndex < #page then
self.lineIndex = self.lineIndex + 1 local nextIdx = self.lineIndex + 1
self:beginLine() local conts = self.pages.contBefore and self.pages.contBefore[self.pageIndex]
if conts and conts[nextIdx] then
-- pokered <CONT>: ▼ + WaitForTextScrollButtonPress before scroll
self.waiting = true
self.contAdvance = true
else
self.lineIndex = nextIdx
self:beginLine()
end
elseif self.pageIndex < #self.pages then elseif self.pageIndex < #self.pages then
self.waiting = true self.waiting = true
self.contAdvance = false
else else
self.done = true self.done = true
end end
+55 -9
View File
@@ -261,7 +261,9 @@ function Commands.move_player(ctx, dir, tiles)
end end
function Commands.move_npc(ctx, objIndex, dir, tiles) 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 if npc then walkEntity(ctx, npc, dir, tiles) end
end end
@@ -308,6 +310,7 @@ end
function Commands.move_npc_to(ctx, objIndex, tx, ty) function Commands.move_npc_to(ctx, objIndex, tx, ty)
local ow = ctx.overworld local ow = ctx.overworld
if not ow then return end
local npc = ow:npcByIndex(objIndex) local npc = ow:npcByIndex(objIndex)
if not npc then return end if not npc then return end
local path = bfsPath(ow.map, npc.cellX, npc.cellY, tx, ty, ow.entities, npc) 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 if npc then npc.facing = dir end
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) function Commands.face_npc(ctx)
-- make the player face the talking NPC -- make the player face the talking NPC
if ctx.npc and ctx.overworld then if ctx.npc and ctx.overworld then
@@ -485,7 +502,7 @@ function Commands.heal_party(ctx)
end end
-- AskName (engine/menus/naming_screen.asm): yes/no then NamingScreen. -- 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 -- Preserves ctx.lastCheck so GivePokemon's carry is not clobbered by the
-- yes/no result (scripts jump_if_false on give failure afterwards). -- yes/no result (scripts jump_if_false on give failure afterwards).
local function askNickname(ctx, mon) local function askNickname(ctx, mon)
@@ -527,8 +544,9 @@ end
-- the asm's carry: true when the mon was given, false when both the -- 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 -- 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). -- script able to offer again later, e.g. the Celadon Eevee ball).
-- Party adds run AskName (AddPartyMon) when a script runner is present; -- AskName runs for party (AddPartyMon) and box (SendNewMonToBox) when a
-- mods that pre-set gift.nickname skip the prompt. -- 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) function Commands.give_pokemon(ctx, species, level)
-- Native mods can transform a gift before the Pokémon object is created. -- 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: -- This is intentionally an event rather than a special-case starter hook:
@@ -540,14 +558,17 @@ function Commands.give_pokemon(ctx, species, level)
end end
local Pokemon = require("src.pokemon.Pokemon") local Pokemon = require("src.pokemon.Pokemon")
local Party = require("src.pokemon.Party") local Party = require("src.pokemon.Party")
local Boxes = require("src.pokemon.Boxes")
local mon = Pokemon.new(ctx.game.data, species, level) local mon = Pokemon.new(ctx.game.data, species, level)
if gift.nickname then mon.nickname = gift.nickname end if gift.nickname then mon.nickname = gift.nickname end
ctx.game.stringBuffer = ctx.game.data.pokemon[species].name or species ctx.game.stringBuffer = ctx.game.data.pokemon[species].name or species
ctx.pendingPokemonName = species ctx.pendingPokemonName = species
require("src.battle.BattleState").stampOT(ctx.save, mon) require("src.battle.BattleState").stampOT(ctx.save, mon)
local addedToParty = Party.add(ctx.save.party, mon) local addedToParty = Party.add(ctx.save.party, mon)
local boxNum = nil
if not addedToParty then 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 ctx.lastCheck = false
return return
end end
@@ -558,11 +579,30 @@ function Commands.give_pokemon(ctx, species, level)
dex.owned[species] = true dex.owned[species] = true
end end
ctx.lastCheck = true ctx.lastCheck = true
-- AddPartyMon AskName: party only; skip box deposits, mod-set nicknames, ctx.addedToParty = addedToParty
-- and callback-style callers that have no script runner to yield on. ctx.boxNum = boxNum
if addedToParty and not gift.nickname and ctx.runner then -- 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) askNickname(ctx, mon)
end 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 end
function Commands.give_money(ctx, amount) 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) local newMon = Pokemon.new(data, trade.get, sent.level)
newMon.nickname = trade.nickname newMon.nickname = trade.nickname
newMon.traded = true -- boosted exp + Name Rater refusal (different OT) 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.remove(party, slot)
table.insert(party, newMon) table.insert(party, newMon)
local dex = ctx.save.pokedex local dex = ctx.save.pokedex
@@ -790,9 +832,13 @@ function Commands.trade(ctx, tradeIndex, doneFlag)
dex.seen[trade.get] = true dex.seen[trade.get] = true
dex.owned[trade.get] = true dex.owned[trade.get] = true
end end
-- the trade machine animation (engine/movie/trade.asm) -- InternalClockTradeAnim (engine/movie/trade.asm)
Screens.push(ctx.game, "TradeAnim", { Screens.push(ctx.game, "TradeAnim", {
sent = sent, received = newMon, 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, onDone = function() runner:resume() end,
}) })
runner:yield() runner:yield()
+6
View File
@@ -331,6 +331,12 @@ end
local function useItem(game, battle, id, list) local function useItem(game, battle, id, list)
local def = game.data.items[id] 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 if ItemEffects.needsTarget(id, def) and not ItemEffects.isBall(id) then
-- TMs/HMs boot up and announce their move before the target picker -- TMs/HMs boot up and announce their move before the target picker
-- (ItemUseTMHM: BootedUpTMText / BootedUpHMText + TeachMachineMoveText) -- (ItemUseTMHM: BootedUpTMText / BootedUpHMText + TeachMachineMoveText)
+98 -21
View File
@@ -3,9 +3,11 @@
-- plus CHANGE BOX. -- plus CHANGE BOX.
local Boxes = require("src.pokemon.Boxes") local Boxes = require("src.pokemon.Boxes")
local Font = require("src.render.Font")
local ListMenu = require("src.ui.ListMenu") local ListMenu = require("src.ui.ListMenu")
local Menu = require("src.ui.Menu") local Menu = require("src.ui.Menu")
local Party = require("src.pokemon.Party") local Party = require("src.pokemon.Party")
local TextBox = require("src.render.TextBox")
local BoxMenu = {} local BoxMenu = {}
@@ -14,6 +16,11 @@ local function monLabel(game, mon)
return ("%s :L%d"):format(mon.nickname or def.name, mon.level) return ("%s :L%d"):format(mon.nickname or def.name, mon.level)
end 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 -- Per-mon submenu (bills_pc.asm DisplayDepositWithdrawMenu): the chosen
-- action + STATS + CANCEL. STATS shows the status screen and returns -- action + STATS + CANCEL. STATS shows the status screen and returns
-- here; CANCEL/B goes back to the list. -- 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 })) }, { tx = 9, ty = 10, tw = 11, th = 8, noSound = true }))
end 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 function withdraw(game)
local box = Boxes.active(game.save) 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 = {} local items = {}
for i, mon in ipairs(box) do for i, mon in ipairs(box) do
table.insert(items, { label = monLabel(game, mon), value = i }) table.insert(items, { label = monLabel(game, mon), value = i })
@@ -49,13 +74,29 @@ local function withdraw(game)
end end
table.remove(box, item.value) table.remove(box, item.value)
table.insert(game.save.party, mon) 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, end,
})) }))
end end
local function deposit(game) 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 = {} local items = {}
for i, mon in ipairs(game.save.party) do for i, mon in ipairs(game.save.party) do
table.insert(items, { label = monLabel(game, mon), value = i }) 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!" list.footer = "You need at least\none POKéMON!"
return return
end end
local box = Boxes.active(game.save) local active = Boxes.active(game.save)
if #box >= Boxes.CAPACITY then if #active >= Boxes.CAPACITY then
list.footer = ("BOX %d is full!"):format(game.save.currentBox) list.footer = ("BOX %d is full!"):format(game.save.currentBox)
return return
end end
table.remove(game.save.party, item.value) table.remove(game.save.party, item.value)
table.insert(box, mon) table.insert(active, mon)
list:close() 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, 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 function release(game)
local box = Boxes.active(game.save) 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 = {} local items = {}
for i, mon in ipairs(box) do for i, mon in ipairs(box) do
table.insert(items, { label = monLabel(game, mon), value = i }) table.insert(items, { label = monLabel(game, mon), value = i })
end end
game.stack:push(ListMenu.new(game, game.stack:push(ListMenu.new(game,
("BOX %d (RELEASE)"):format(game.save.currentBox), items, { ("BOX %d (RELEASE)"):format(game.save.currentBox), items, {
onChoose = function(item, list) onChoose = function(_, list)
local mon = box[item.value] local mon = box[list.index]
if not mon then return end if not mon then return end
local def = game.data.pokemon[mon.species] local name = monName(game, mon)
local name = mon.nickname or def.name
local ChoiceBox = require("src.ui.ChoiceBox") local ChoiceBox = require("src.ui.ChoiceBox")
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
"Once released,\n" .. name .. " is\ngone forever. OK?", function() "Once released,\n" .. name .. " is\ngone forever. OK?", function()
game.stack:push(ChoiceBox.new(game, function(yes) game.stack:push(ChoiceBox.new(game, function(yes)
if not yes then return end 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) require("src.core.Sound").playCry(game.data, mon.species)
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
("%s was\nreleased outside.\fBye %s!"):format(name, name))) ("%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 -- the original asks BEFORE switching ("When you change a #MON
-- BOX, data will be saved. OK?"); declining aborts the change -- BOX, data will be saved. OK?"); declining aborts the change
local ChoiceBox = require("src.ui.ChoiceBox") local ChoiceBox = require("src.ui.ChoiceBox")
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
"When you change a\nPOKéMON BOX, data\nwill be saved. OK?", function() "When you change a\nPOKéMON BOX, data\nwill be saved. OK?", function()
game.stack:push(ChoiceBox.new(game, function(yes) game.stack:push(ChoiceBox.new(game, function(yes)
@@ -143,21 +193,48 @@ local function changeBox(game)
})) }))
end 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) function BoxMenu.new(game)
Boxes.ensure(game.save) Boxes.ensure(game.save)
-- bills_pc.asm BillsPCMenu: TextBoxBorder at (0,0) with interior -- bills_pc.asm BillsPCMenu: TextBoxBorder at (0,0) with interior
-- 12x10 → total 14x12. "CHANGE BOX" is 10 tiles and needs the -- 12x10 → total 14x12. "CHANGE BOX" / "WITHDRAW <PK><MN>" need the
-- full interior (cursor col + label); the old tw=12 right-side box -- full interior (cursor col + label). keepOpen so WITHDRAW/DEPOSIT/
-- drew the final glyph on the border. -- RELEASE/CHANGE BOX leave this menu underneath (jp BillsPCMenu).
return Menu.new(game, { local menu = Menu.new(game, {
{ label = "WITHDRAW", onSelect = function() withdraw(game) end }, { label = "WITHDRAW <PK><MN>", keepOpen = true,
{ label = "DEPOSIT", onSelect = function() deposit(game) end }, onSelect = function() withdraw(game) end },
{ label = "RELEASE", onSelect = function() release(game) end }, { label = "DEPOSIT <PK><MN>", keepOpen = true,
{ label = "CHANGE BOX", onSelect = function() changeBox(game) end }, onSelect = function() deposit(game) end },
{ label = "RELEASE <PK><MN>", keepOpen = true,
onSelect = function() release(game) end },
{ label = "CHANGE BOX", keepOpen = true,
onSelect = function() changeBox(game) end },
{ label = "SEE YA!" }, { label = "SEE YA!" },
-- Bill's PC runs silent end to end (BIT_NO_MENU_BUTTON_SOUND, -- Bill's PC runs silent end to end (BIT_NO_MENU_BUTTON_SOUND,
-- engine/menus/pokemon_pc.asm) -- engine/menus/pokemon_pc.asm)
}, { tx = 0, ty = 0, tw = 14, th = 12, noSound = true }) }, { 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 end
return BoxMenu return BoxMenu
+12 -7
View File
@@ -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 Font = require("src.render.Font")
local Theme = require("src.ui.Theme") 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 self.index = (opts and opts.defaultNo) and 2 or 1
-- BIT_NO_MENU_BUTTON_SOUND: PC-session prompts stay silent -- BIT_NO_MENU_BUTTON_SOUND: PC-session prompts stay silent
self.noSound = (opts and opts.noSound) or false 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 return self
end end
@@ -38,13 +43,13 @@ function ChoiceBox:update(dt)
end end
function ChoiceBox:draw() function ChoiceBox:draw()
local box = Theme.choiceBox local tx, ty, tw, th = self.tx, self.ty, self.tw, self.th
Font.drawBox(box.tx, box.ty, box.tw, box.th) Font.drawBox(tx, ty, tw, th)
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
Font.draw("YES", (box.tx + 2) * 8, (box.ty + 1) * 8) Font.draw("YES", (tx + 2) * 8, (ty + 1) * 8)
Font.draw("NO", (box.tx + 2) * 8, (box.ty + 3) * 8) Font.draw("NO", (tx + 2) * 8, (ty + 3) * 8)
Font.drawCode(Theme.cursor, (box.tx + 1) * 8, Font.drawCode(Theme.cursor, (tx + 1) * 8,
(box.ty + (self.index == 1 and 1 or 3)) * 8) (ty + (self.index == 1 and 1 or 3)) * 8)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
end end
+10 -5
View File
@@ -37,8 +37,12 @@ function ListMenu.new(game, title, items, opts)
-- text box, a money box sits top-right, and the list shortens to -- text box, a money box sits top-right, and the list shortens to
-- clear them (DisplayPokemartDialogue_'s screen) -- clear them (DisplayPokemartDialogue_'s screen)
self.dialogue = opts.dialogue 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.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 return self
end end
@@ -137,8 +141,10 @@ function ListMenu:draw()
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
local money = ("¥%d"):format(self.money and self.money() or 0) local money = ("¥%d"):format(self.money and self.money() or 0)
Font.draw(money, 152 - Font.width(money), 8) Font.draw(money, 152 - Font.width(money), 8)
-- the clerk's line in the standard bottom text box; long prompts end
-- wrap and keep their last two lines, like the GB's scrolled box 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) Font.drawBox(0, 12, 20, 6)
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
if self.footer then if self.footer then
@@ -153,8 +159,7 @@ function ListMenu:draw()
end end
end end
elseif self.footer then elseif self.footer then
-- PC deposit/withdraw footers use "\n"; draw the last two lines so -- bare footer (bag money line, etc.)
-- long item names are not clipped at the screen edge (#115).
local flat = {} local flat = {}
for _, page in ipairs(require("src.render.TextBox").paginate(self.footer)) do for _, page in ipairs(require("src.render.TextBox").paginate(self.footer)) do
for _, line in ipairs(page) do flat[#flat + 1] = line end for _, line in ipairs(page) do flat[#flat + 1] = line end
+24 -12
View File
@@ -22,6 +22,8 @@ function MoveLearnMenu.new(game, mon, newMoveId, onDone)
self.newMoveId = newMoveId self.newMoveId = newMoveId
self.onDone = onDone self.onDone = onDone
self.index = 1 self.index = 1
-- forget-list UI only after TryingToLearn YES (learn_move.asm .loop)
self.selecting = false
return self return self
end end
@@ -31,24 +33,32 @@ end
-- TryingToLearnText + yes/no (learn_move.asm TryingToLearn): NO offers -- TryingToLearnText + yes/no (learn_move.asm TryingToLearn): NO offers
-- AbandonLearning, whose own NO loops back here (DontAbandonLearning). -- 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() function MoveLearnMenu:enter()
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
local ChoiceBox = require("src.ui.ChoiceBox")
local game = self.game local game = self.game
local mdef = game.data.moves[self.newMoveId] local mdef = game.data.moves[self.newMoveId]
local name = self:monName() local name = self:monName()
self.selecting = false
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
("%s is\ntrying to learn\v%s!\fBut, %s\ncan't learn more\vthan 4 moves!\f") ("%s is\ntrying to learn\v%s!\fBut, %s\ncan't learn more\vthan 4 moves!\f")
:format(name, mdef.name, name) .. :format(name, mdef.name, name) ..
("Delete an older\nmove to make room\vfor %s?"):format(mdef.name), ("Delete an older\nmove to make room\vfor %s?"):format(mdef.name),
function() nil, {
game.stack:push(ChoiceBox.new(game, function(yes) choice = function(yes)
if not yes then self:confirmAbandon() end if yes then
end)) self.selecting = true
end)) else
self:confirmAbandon()
end
end,
}))
end end
function MoveLearnMenu:update(dt) function MoveLearnMenu:update(dt)
if not self.selecting then return end
local input = self.game.input local input = self.game.input
local n = #self.mon.moves + 1 -- moves + CANCEL local n = #self.mon.moves + 1 -- moves + CANCEL
if input:wasPressed("up") then if input:wasPressed("up") then
@@ -82,15 +92,15 @@ end
-- (DontAbandonLearning) -- (DontAbandonLearning)
function MoveLearnMenu:confirmAbandon() function MoveLearnMenu:confirmAbandon()
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
local ChoiceBox = require("src.ui.ChoiceBox")
local game = self.game local game = self.game
local mdef = game.data.moves[self.newMoveId] local mdef = game.data.moves[self.newMoveId]
self.selecting = false
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
("Abandon learning\n%s?"):format(mdef.name), function() ("Abandon learning\n%s?"):format(mdef.name), nil, {
game.stack:push(ChoiceBox.new(game, function(yes) choice = function(yes)
if yes then self:finish(false) else self:enter() end if yes then self:finish(false) else self:enter() end
end)) end,
end)) }))
end end
function MoveLearnMenu:finish(learned) function MoveLearnMenu:finish(learned)
@@ -98,6 +108,7 @@ function MoveLearnMenu:finish(learned)
local game = self.game local game = self.game
local name = self:monName() local name = self:monName()
local mdef = game.data.moves[self.newMoveId] local mdef = game.data.moves[self.newMoveId]
self.selecting = false
game.stack:pop() game.stack:pop()
local msg local msg
if learned then if learned then
@@ -114,6 +125,7 @@ function MoveLearnMenu:finish(learned)
end end
function MoveLearnMenu:draw() function MoveLearnMenu:draw()
if not self.selecting then return end
-- single-spaced move list box (TryingToLearn: TextBoxBorder at 4,7) -- single-spaced move list box (TryingToLearn: TextBoxBorder at 4,7)
-- plus the port's extra CANCEL row -- plus the port's extra CANCEL row
Font.drawBox(4, 5, 16, 7) Font.drawBox(4, 5, 16, 7)
+71 -49
View File
@@ -1,8 +1,11 @@
-- Party menu: list the party, choose a member. -- Party menu: list the party, choose a member.
-- Modes: -- Modes:
-- default: A -> submenu (STATS / SWITCH order / CANCEL) -- default: A -> submenu (STATS / SWITCH / field moves)
-- opts.onSwitch: A -> hand the chosen mon to the callback (battle -- opts.onSwitch + opts.battle (voluntary PKMN): A -> SWITCH / STATS /
-- switch, item targeting via opts.pickOnly) -- 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) -- opts.onCancel: fired when the menu closes without a pick (B)
-- Pops itself on B. -- Pops itself on B.
@@ -125,6 +128,7 @@ function PartyMenu.new(game, opts)
self.onSwitch = opts.onSwitch self.onSwitch = opts.onSwitch
self.onCancel = opts.onCancel self.onCancel = opts.onCancel
self.pickOnly = opts.pickOnly self.pickOnly = opts.pickOnly
self.forceSwitch = opts.forceSwitch
self.battle = opts.battle self.battle = opts.battle
self.party = opts.party -- link battles pass their clamped copies self.party = opts.party -- link battles pass their clamped copies
self.swapFrom = nil self.swapFrom = nil
@@ -156,7 +160,17 @@ function PartyMenu:update(dt)
-- hook-injected entries carry a callback instead of an action id -- hook-injected entries carry a callback instead of an action id
entry.onSelect(mon, self.game) entry.onSelect(mon, self.game)
elseif action == "stats" then elseif action == "stats" then
-- battle and field alike return to the party list afterwards
-- (core.asm .partyMenuWasSelected)
Screens.push(self.game, "SummaryMenu", mon) 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 elseif action == "switch" then
self.swapFrom = self.index self.swapFrom = self.index
elseif action == "fly" then elseif action == "fly" then
@@ -341,58 +355,66 @@ function PartyMenu:update(dt)
require("src.core.Sound").play(self.game.data, "Swap") require("src.core.Sound").play(self.game.data, "Swap")
end end
self.swapFrom = nil 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.game.stack:pop()
self.onSwitch(mon) self.onSwitch(mon)
else else
self.submenu = true self.submenu = true
self.subIndex = 1 self.subIndex = 1
-- STATS/SWITCH plus this mon's field moves (start_sub_menus.asm local items
-- builds the same dynamic list)
local items = { { label = "STATS", action = "stats" },
{ label = "SWITCH", action = "switch" } }
local ow = self.game.overworld local ow = self.game.overworld
-- Field moves (HMs/TMs) are usable out of battle even when the mon if self.battle and self.onSwitch then
-- is fainted -- Gen 1 does not require HP for Cut/Fly/Surf/etc. -- SwitchStatsCancelText (core.asm PartyMenuOrRockOrRun)
-- Battle still excludes this list via `not self.battle`. Softboiled items = { { label = "SWITCH", action = "battle_switch" },
-- can appear for a fainted user; its heal transfer then no-ops. { label = "STATS", action = "stats" },
if not self.battle and ow then { label = "CANCEL", action = "cancel" } }
-- FLY/TELEPORT: CheckIfInOutsideMap (OVERWORLD + PLATEAU — else
-- Route 23 / Indigo Plateau outdoor), not OVERWORLD alone (#83) -- STATS/SWITCH plus this mon's field moves (start_sub_menus.asm
local outside = Map.isOutside(ow.map.def, -- builds the same dynamic list)
FieldDefaults.field(self.game.data, "outsideTilesets")) items = { { label = "STATS", action = "stats" },
for _, mv in ipairs(mon.moves) do { label = "SWITCH", action = "switch" } }
if mv.id == "FLY" and outside -- Field moves (HMs/TMs) are usable out of battle even when the mon
and self.game.save.inventory.THUNDERBADGE then -- is fainted -- Gen 1 does not require HP for Cut/Fly/Surf/etc.
table.insert(items, { label = "FLY", action = "fly" }) -- Battle still excludes this list via `not self.battle`. Softboiled
elseif mv.id == "FLASH" and ow.dark -- can appear for a fainted user; its heal transfer then no-ops.
and self.game.save.inventory.BOULDERBADGE then if not self.battle and ow then
table.insert(items, { label = "FLASH", action = "flash" }) -- FLY/TELEPORT: CheckIfInOutsideMap (OVERWORLD + PLATEAU —
elseif mv.id == "CUT" and self.game.save.inventory.CASCADEBADGE then -- Route 23 / Indigo Plateau outdoor), not OVERWORLD alone (#83)
-- CUT/SURF/STRENGTH are party-menu field moves too local outside = Map.isOutside(ow.map.def,
-- (start_sub_menus.asm .outOfBattleMovePointers); listed here FieldDefaults.field(self.game.data, "outsideTilesets"))
-- with the same list-time badge filter this file already uses for _, mv in ipairs(mon.moves) do
-- for FLY/FLASH. The facing-tile/activation check happens on if mv.id == "FLY" and outside
-- selection (useCutFieldMove/useSurfFieldMove). and self.game.save.inventory.THUNDERBADGE then
table.insert(items, { label = "CUT", action = "cut" }) table.insert(items, { label = "FLY", action = "fly" })
elseif mv.id == "SURF" and self.game.save.inventory.SOULBADGE then elseif mv.id == "FLASH" and ow.dark
table.insert(items, { label = "SURF", action = "surf" }) and self.game.save.inventory.BOULDERBADGE then
elseif mv.id == "STRENGTH" and self.game.save.inventory.RAINBOWBADGE then table.insert(items, { label = "FLASH", action = "flash" })
table.insert(items, { label = "STRENGTH", action = "strength" }) elseif mv.id == "CUT" and self.game.save.inventory.CASCADEBADGE then
elseif mv.id == "SOFTBOILED" then -- CUT/SURF/STRENGTH are party-menu field moves too
table.insert(items, { label = "SOFTBOILED", action = "softboiled" }) -- (start_sub_menus.asm .outOfBattleMovePointers); listed here
elseif mv.id == "TELEPORT" and outside then -- with the same list-time badge filter this file already uses
-- TELEPORT works only OUTDOORS (start_sub_menus.asm -- for FLY/FLASH. The facing-tile/activation check happens on
-- .teleport -> CheckIfInOutsideMap); dark maps don't -- selection (useCutFieldMove/useSurfFieldMove).
-- block it table.insert(items, { label = "CUT", action = "cut" })
table.insert(items, { label = "TELEPORT", action = "escape" }) elseif mv.id == "SURF" and self.game.save.inventory.SOULBADGE then
elseif mv.id == "DIG" and DIG_TILESETS[ow.map.def.tileset] table.insert(items, { label = "SURF", action = "surf" })
and ow.map.id ~= "AGATHAS_ROOM" then elseif mv.id == "STRENGTH" and self.game.save.inventory.RAINBOWBADGE then
-- DIG runs ItemUseEscapeRope (.dig sets wCurItem = table.insert(items, { label = "STRENGTH", action = "strength" })
-- ESCAPE_ROPE): usable in the dungeon tilesets of elseif mv.id == "SOFTBOILED" then
-- escape_rope_tilesets.asm minus Agatha's room, even in table.insert(items, { label = "SOFTBOILED", action = "softboiled" })
-- the dark (Rock Tunnel) elseif mv.id == "TELEPORT" and outside then
table.insert(items, { label = "DIG", action = "escape" }) -- 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 end
end end
+6 -3
View File
@@ -71,6 +71,7 @@ end
local function withdraw(game) local function withdraw(game)
local pc = game.save.pcItems local pc = game.save.pcItems
game.stack:push(ListMenu.new(game, "WITHDRAW ITEM", buildItems(game, pc), { game.stack:push(ListMenu.new(game, "WITHDRAW ITEM", buildItems(game, pc), {
messageBox = true,
onChoose = function(item, list) onChoose = function(item, list)
askQuantity(game, list, pc[item.value] or 1, item.value, function(qty) askQuantity(game, list, pc[item.value] or 1, item.value, function(qty)
local Bag = require("src.inventory.Bag") local Bag = require("src.inventory.Bag")
@@ -107,6 +108,7 @@ local function deposit(game)
if not Bag.isBadge(id) then depositable[id] = count end if not Bag.isBadge(id) then depositable[id] = count end
end end
game.stack:push(ListMenu.new(game, "DEPOSIT ITEM", buildItems(game, depositable), { game.stack:push(ListMenu.new(game, "DEPOSIT ITEM", buildItems(game, depositable), {
messageBox = true,
onChoose = function(item, list) onChoose = function(item, list)
askQuantity(game, list, inv[item.value] or 1, item.value, function(qty) askQuantity(game, list, inv[item.value] or 1, item.value, function(qty)
if pcFull(game, pc, item.value) then if pcFull(game, pc, item.value) then
@@ -126,6 +128,7 @@ end
local function toss(game) local function toss(game)
local pc = game.save.pcItems local pc = game.save.pcItems
game.stack:push(ListMenu.new(game, "TOSS ITEM", buildItems(game, pc), { game.stack:push(ListMenu.new(game, "TOSS ITEM", buildItems(game, pc), {
messageBox = true,
onChoose = function(item, list) onChoose = function(item, list)
local def = game.data.items[item.value] local def = game.data.items[item.value]
if (def and def.keyItem) or item.value:find("^HM_") then 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 = "DEPOSIT ITEM", onSelect = function() deposit(game) end },
{ label = "TOSS ITEM", onSelect = function() toss(game) end }, { label = "TOSS ITEM", onSelect = function() toss(game) end },
{ label = "LOG OFF" }, { label = "LOG OFF" },
-- the whole PC session runs silent (BIT_NO_MENU_BUTTON_SOUND, -- silent PC session (BIT_NO_MENU_BUTTON_SOUND); players_pc.asm
-- engine/menus/players_pc.asm PlayersPCMenu) -- PlayersPCMenu TextBoxBorder (0,0) b=8 c=14 → 16x10
}, { tx = 3, ty = 0, tw = 17, th = 10, noSound = true }) }, { tx = 0, ty = 0, tw = 16, th = 10, noSound = true })
end end
return PlayerPC return PlayerPC
+8 -4
View File
@@ -40,15 +40,19 @@ function QuantityBox:update(dt)
end end
function QuantityBox:draw() function QuantityBox:draw()
local w = self.unitPrice and 11 or 7 -- DisplayChooseQuantityMenu (home/list_menu.asm): non-priced box at
local tx = 20 - w - 1 -- hlcoord 15,9 (interior 3x1); priced at hlcoord 7,9 (interior 11x1).
Font.drawBox(tx, 13, w, 3) -- 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) love.graphics.setColor(0, 0, 0, 1)
local s = ("×%02d"):format(self.qty) -- the multiply glyph tile local s = ("×%02d"):format(self.qty) -- the multiply glyph tile
if self.unitPrice then if self.unitPrice then
s = s .. (" ¥%d"):format(self.qty * self.unitPrice) s = s .. (" ¥%d"):format(self.qty * self.unitPrice)
end 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) love.graphics.setColor(1, 1, 1, 1)
end end
+2 -1
View File
@@ -15,7 +15,8 @@ local Theme = {
cols = Renderer.WIDTH / 8, cols = Renderer.WIDTH / 8,
rows = Renderer.HEIGHT / 8, rows = Renderer.HEIGHT / 8,
textBox = { tx = 0, ty = 12, tw = 20, th = 6, maxCols = 18 }, 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) function Theme.load(data)
+427 -45
View File
@@ -1,8 +1,7 @@
-- Link/in-game trade cinematic (engine/movie/trade.asm, trade2.asm): -- InternalClockTradeAnim (engine/movie/trade.asm): cable-trade cinematic
-- the traded POKéMON rises away with its cry and a goodbye, then the -- used by in-game NPC trades and internally-clocked link trades.
-- received one descends with its cry and "take good care" text.
-- A skips the slide animations ahead. Calls onDone() after popping.
local Font = require("src.render.Font")
local Sound = require("src.core.Sound") local Sound = require("src.core.Sound")
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
@@ -10,13 +9,22 @@ local TradeAnim = {}
TradeAnim.__index = TradeAnim TradeAnim.__index = TradeAnim
TradeAnim.isOpaque = true TradeAnim.isOpaque = true
-- SGB: generic whole-screen palette (SET_PAL_GENERIC)
function TradeAnim:sgbPalettes(game) function TradeAnim:sgbPalettes(game)
return require("src.render.PaletteFX").wholeNamed(game.data, "MEWMON") return require("src.render.PaletteFX").wholeNamed(game.data, "MEWMON")
end end
local SLIDE_FRAMES = 90 local DEFAULT_ART = {
local REST_Y = 44 -- resting top of the sprite, roughly screen centre 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) local function tryImage(path)
if not path then return nil end 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 return mon.nickname or (def and def.name) or mon.species
end 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 function spriteOf(game, mon)
local def = game.data.pokemon[mon.species] local def = game.data.pokemon[mon.species]
return tryImage(def and def.spriteFront) return tryImage(def and def.spriteFront)
end 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) function TradeAnim.new(game, opts)
opts = opts or {} opts = opts or {}
local self = setmetatable({}, TradeAnim) local self = setmetatable({}, TradeAnim)
@@ -41,62 +84,401 @@ function TradeAnim.new(game, opts)
self.sent = opts.sent self.sent = opts.sent
self.received = opts.received self.received = opts.received
self.onDone = opts.onDone 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.sentSprite = spriteOf(game, self.sent)
self.receivedSprite = spriteOf(game, self.received) self.recvSprite = spriteOf(game, self.received)
self.phase = "out"
self.seq = 1
self.phase = SEQ[1]
self.t = 0 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 return self
end end
function TradeAnim:enter() 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 end
function TradeAnim:update(dt) function TradeAnim:update(dt)
local input = self.game.input if self.waitingText or self.phase == "done" then return end
if self.phase == "out" or self.phase == "in" then local skip = self.game.input:wasPressed("a")
self.t = self.t + 1 self.t = self.t + 1
if input:wasPressed("a") then self.t = SLIDE_FRAMES end local p = self.phase
if self.t < SLIDE_FRAMES then return end
if self.phase == "out" then if p == "show_player" then
self.phase = "goodbye" -- slide from SCX $86 -> 0, hold 80, poof, cry (Trade_ShowPlayerMon)
self.game.stack:push(TextBox.new(self.game, if not self.sub then self.sub = "slide" end
("Goodbye %s!"):format(nameOf(self.game, self.sent)), if self.sub == "slide" then
function() self.scx = math.max(0, 0x86 - self.t * 2)
self.phase = "in" if skip or self.scx <= 0 then
self.t = 0 self.scx = 0
Sound.playCry(self.game.data, self.received.species) self.sub = "hold"
end)) 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 else
self.phase = "takecare" self:advance()
self.game.stack:push(TextBox.new(self.game, return
("Take good care\nof %s!"):format(nameOf(self.game, self.received)), end
function() if self.t % 8 == 0 then self.cableFlash = not self.cableFlash end
self.game.stack:pop() if skip then self:advance() end
if self.onDone then self.onDone() end
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 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() function TradeAnim:draw()
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144) love.graphics.rectangle("fill", 0, 0, 160, 144)
local sprite, y local p = self.phase
if self.phase == "out" then
-- the sent mon rises up and off the screen if p == "show_player" then
sprite = self.sentSprite love.graphics.push()
y = REST_Y - math.floor((self.t / SLIDE_FRAMES) * (REST_Y + 60)) love.graphics.translate(-self.scx, 0)
elseif self.phase == "in" or self.phase == "takecare" then -- mon pic on the BG at hlcoord 7, 2; info box on the window at
-- the received mon descends into place -- hWY $50, so it sits in the bottom half of the screen
sprite = self.receivedSprite if self.monVisible and self.sentSprite then
local t = self.phase == "in" and self.t or SLIDE_FRAMES love.graphics.draw(self.sentSprite, 56, 16)
y = -60 + math.floor((t / SLIDE_FRAMES) * (REST_Y + 60)) end
end self:drawMonInfo(self.sent, self.playerOt, self.playerOtId, 10)
if sprite and y then love.graphics.pop()
local w = sprite:getWidth() if self.poof and self.poof > 0 then
love.graphics.draw(sprite, math.floor((160 - w) / 2), y) 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 end
-- went_to / for_sends / farewell / delay: cleared window; TextBox draws
end end
return TradeAnim return TradeAnim
+7 -7
View File
@@ -34,7 +34,8 @@ function TrainerCard.new(game)
local self = setmetatable({ game = game }, TrainerCard) local self = setmetatable({ game = game }, TrainerCard)
local img = tryImage("assets/generated/trainer_card/badges.png") local img = tryImage("assets/generated/trainer_card/badges.png")
if img then 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) } self.badges = { img = img, quads = quads16(img, 8, 32, 0, 16) }
end end
local nums = tryImage("assets/generated/trainer_card/badge_numbers.png") local nums = tryImage("assets/generated/trainer_card/badge_numbers.png")
@@ -124,7 +125,7 @@ function TrainerCard:draw()
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
end 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) self:frameBox(0, 11, 20, 7)
local badges = Badges.list(self.game.data) local badges = Badges.list(self.game.data)
for i = 1, #badges do for i = 1, #badges do
@@ -136,12 +137,11 @@ function TrainerCard:draw()
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(self.nums.img, self.nums.quads[i - 1], tx, ty) love.graphics.draw(self.nums.img, self.nums.quads[i - 1], tx, ty)
end end
if self.badges and self.badges.quads[i - 1] if self.faces and self.faces.quads[i - 1] then
and save.inventory[Badges.itemFor(badges[i])] then
-- unearned badge slots stay blank (DrawBadges)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(self.badges.img, self.badges.quads[i - 1], local owned = save.inventory[Badges.itemFor(badges[i])]
tx + 4, ty + 6) local sheet = owned and self.badges or self.faces
love.graphics.draw(sheet.img, sheet.quads[i - 1], tx + 4, ty + 6)
end end
end end
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
+10
View File
@@ -116,6 +116,16 @@ FieldDefaults.FIELD = {
badgeGates = { ROUTE_22_GATE = { passedFlag = "PASSED_ROUTE22_GATE" } }, badgeGates = { ROUTE_22_GATE = { passedFlag = "PASSED_ROUTE22_GATE" } },
-- VermilionGymSetDoorTile opens the motorized door once both locks are hit -- VermilionGymSetDoorTile opens the motorized door once both locks are hit
hiddenExtras = { 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", trashCans = { map = "VERMILION_GYM",
doorBlock = { bx = 2, by = 2, block = 5 } }, doorBlock = { bx = 2, by = 2, block = 5 } },
}, },
+6
View File
@@ -223,6 +223,12 @@ function Map:setBlock(bx, by, block)
self.def.blocks[by * self.def.width + bx + 1] = block self.def.blocks[by * self.def.width + bx + 1] = block
end 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 -- true if the cell's collision tile is a door or warp-activating tile
function Map:isWarpTileCell(cx, cy) function Map:isWarpTileCell(cx, cy)
local t = self:cellTile(cx, cy) local t = self:cellTile(cx, cy)
+57 -34
View File
@@ -718,16 +718,13 @@ function OverworldState:update(dt)
end end
if self.healAnim then if self.healAnim then
local ha = self.healAnim 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) local ev = OverworldState.stepHealAnim(ha)
if ev == "ball" then if ev == "ball" then
require("src.core.Sound").play(Game.data, "Healing_Machine") require("src.core.Sound").play(Game.data, "Healing_Machine")
elseif ev == "jingle" then elseif ev == "jingle" then
ha.jinglePlaying = Music.playOnce(Game.data, "Music_PkmnHealed") -- playOnce restores the map theme when the jingle ends; we no longer
ha.jingleDone = not ha.jinglePlaying -- block the fighting-fit text on that (#157)
require("src.core.Music").playOnce(Game.data, "Music_PkmnHealed")
elseif ev == "done" then elseif ev == "done" then
local done = ha.onDone local done = ha.onDone
self.healAnim = nil self.healAnim = nil
@@ -1511,6 +1508,15 @@ function OverworldState:tryHiddenObject(fx, fy)
end end
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 -- the Vermilion Gym trash can lock puzzle
if self.map.id == "VERMILION_GYM" then if self.map.id == "VERMILION_GYM" then
for _, h in ipairs(extras.trashCans.cans or {}) do 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)) Game.stack:push(TextBox.new(Game, completion .. "\f" .. rating))
end end
-- AnimateHealingMachine (engine/overworld/healing_machine.asm): the -- AnimateHealingMachine (engine/overworld/healing_machine.asm): balls
-- monitor lights, then one ball per party mon appears every 30 frames -- every 30 frames, then jingle + FlashSprite8Times (8 x 10). #157: skip
-- (SFX_HEALING_MACHINE each); the healed jingle plays while the machine -- pokered's post-flash .waitLoop2 / DelayFrames 32 so fighting-fit is
-- sprites flash 8 times (an OBP1 xor every 10 frames), then a 32-frame -- immediate; jingle still plays and restoreMap runs when it ends.
-- 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.
function OverworldState.stepHealAnim(ha) function OverworldState.stepHealAnim(ha)
ha.timer = ha.timer + 1 ha.timer = ha.timer + 1
ha.phase = ha.phase or "balls" ha.phase = ha.phase or "balls"
@@ -2219,17 +2222,11 @@ function OverworldState.stepHealAnim(ha)
ha.visible = not ha.visible ha.visible = not ha.visible
ha.flashes = ha.flashes + 1 ha.flashes = ha.flashes + 1
if ha.flashes >= 8 then if ha.flashes >= 8 then
ha.phase = "wait"
ha.visible = true ha.visible = true
ha.phase = "done"
return "done"
end end
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
end end
@@ -2405,19 +2402,34 @@ function OverworldState:checkVictoryRewards(trainerClass, partyIndex)
Commands.hide_object(ctx, entry[1], entry[2]) Commands.hide_object(ctx, entry[1], entry[2])
end end
end end
local lines = {}
if reward.badge then if reward.badge then
Game.save.inventory[reward.badge] = 1 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 end
if reward.item then if reward.item then
local inv = Game.save.inventory local inv = Game.save.inventory
inv[reward.item] = (inv[reward.item] or 0) + 1 inv[reward.item] = (inv[reward.item] or 0) + 1
local name = Game.data.items[reward.item] and Game.data.items[reward.item].name local idef = Game.data.items[reward.item]
or reward.item -- GiveItem -> CopyToStringBuffer for "{RAM:wStringBuffer}" received texts
table.insert(lines, ("%s received\n%s!"):format(Game.save.player.name, name)) 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 end
if #lines > 0 then if #lines > 0 then
Game.stack:push(TextBox.new(Game, table.concat(lines, "\f"))) 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 -- 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 -- 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). -- 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 PLAYER_SCREEN_X, PLAYER_SCREEN_Y = 0x40, 0x3c
local function u8(n) return n % 256 end local function u8(n) return n % 256 end
local function calcDiff(a, b) local function calcDiff(a, b)
@@ -2454,6 +2468,14 @@ local function trainerSightPixelDist(npc, player, horizontal)
u8(PLAYER_SCREEN_Y + (npc.cellY - player.cellY) * 16)) u8(PLAYER_SCREEN_Y + (npc.cellY - player.cellY) * 16))
end 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 -- STAY trainers with a facing spot the player crossing their line of
-- sight (range from the extracted trainer headers), walk up and battle. -- sight (range from the extracted trainer headers), walk up and battle.
function OverworldState:checkTrainerSight() function OverworldState:checkTrainerSight()
@@ -2466,7 +2488,8 @@ function OverworldState:checkTrainerSight()
-- walkers included (they sight between steps) -- walkers included (they sight between steps)
if d.trainerClass and not npc.moving if d.trainerClass and not npc.moving
and not self:trainerDefeated(npc) 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 header = Game.data:trainerHeader(self.map.def.label, d.index)
local range = header and header.range or 0 local range = header and header.range or 0
local vec = DIRVEC[npc.facing] local vec = DIRVEC[npc.facing]
@@ -3210,7 +3233,7 @@ function OverworldState:takeWarp(warpDef)
self:startWarpTo(destMap, x, y, facing) self:startWarpTo(destMap, x, y, facing)
return return
end end
self.doorWarp = true -- door SFX + outdoor walk-out step self.doorWarp = true -- door SFX + PlayerStepOutFromDoor walk-out
self:startWarpTo(destMap, x, y, facing) self:startWarpTo(destMap, x, y, facing)
end end
@@ -3295,12 +3318,12 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts)
local outdoor = Map.isOutdoor(self.map.def) local outdoor = Map.isOutdoor(self.map.def)
require("src.core.Sound").play(Game.data, require("src.core.Sound").play(Game.data,
outdoor and "Go_Outside" or "Go_Inside") outdoor and "Go_Outside" or "Go_Inside")
-- stepping out of an outdoor door/cave entrance (the original's -- PlayerStepOutFromDoor (engine/overworld/auto_movement.asm): any
-- walk-out). Auto-walk leaves the mat, so the arrival disable -- 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 -- (warpEntryCell / justWarped) is unnecessary -- and would let you
-- stand on the door without re-entering if you hold back into it. -- stand on the door without re-entering if you hold back into it.
if outdoor and self.player.facing == "down" if self.map:isDoorTileCell(self.player.cellX, self.player.cellY) then
and self.map:isWarpTileCell(self.player.cellX, self.player.cellY) then
self.warpEntryCell = nil self.warpEntryCell = nil
self.justWarped = false self.justWarped = false
self:scriptMove(self.player, "down", 1) self:scriptMove(self.player, "down", 1)
+62
View File
@@ -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
+28
View File
@@ -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
@@ -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
+53 -1
View File
@@ -1,10 +1,12 @@
-- Driver: the two reworked battle flows. -- Driver: reworked battle flows.
-- A) Old man catch tutorial (DisplayBattleMenu's old-man script): -- A) Old man catch tutorial (DisplayBattleMenu's old-man script):
-- scripted cursor FIGHT(80f) -> ITEM(50f), forced item menu with -- scripted cursor FIGHT(80f) -> ITEM(50f), forced item menu with
-- one POKé BALL x50 -- itself scripted (list_menu.asm:65-80): -- one POKé BALL x50 -- itself scripted (list_menu.asm:65-80):
-- '▶' hover 80f, auto-A leaves the hollow '▷', always-caught throw. -- '▶' hover 80f, auto-A leaves the hollow '▷', always-caught throw.
-- B) Mimic's MID-move copy menu (MimicEffect): the chooser opens only -- B) Mimic's MID-move copy menu (MimicEffect): the chooser opens only
-- after the hit test, at (0,7) like MoveSelectionMenu .mimicmenu. -- 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) return function(game)
local U = dofile("tests/drivers/util.lua") local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" 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 U.shot(game, DIR .. "/mimic_5_moves_after.png") -- slot now holds the copy
while game.stack:top() ~= ow do game.stack:pop() end while game.stack:top() ~= ow do game.stack:pop() end
U.wait(5) 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() love.event.quit()
end end
+120
View File
@@ -0,0 +1,120 @@
-- Driver: Bill's PC (#177) — chrome (What? / BOX No. / <PK><MN>) 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("<PK>", 1, true),
"WITHDRAW label includes <PK><MN> 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
+95 -1
View File
@@ -1,15 +1,35 @@
-- Driver: throw Poké Balls and capture the catch suspense sequence -- 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) return function(game)
local U = dofile("tests/drivers/util.lua") local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local Pokemon = require("src.pokemon.Pokemon") local Pokemon = require("src.pokemon.Pokemon")
local ChoiceBox = require("src.ui.ChoiceBox")
table.insert(game.save.party, Pokemon.new(game.data, "CHARMANDER", 12)) table.insert(game.save.party, Pokemon.new(game.data, "CHARMANDER", 12))
U.teleport(game, "ROUTE_1", 5, 5, "down") U.teleport(game, "ROUTE_1", 5, 5, "down")
local ow = game.overworld local ow = game.overworld
local BattleState = require("src.battle.BattleState") 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 -- one throw, screenshotting every 20 frames through the chain
local function throwAndShoot(tag, rng) local function throwAndShoot(tag, rng)
local battle = BattleState.newWild(game, "PIDGEY", 8) local battle = BattleState.newWild(game, "PIDGEY", 8)
@@ -36,4 +56,78 @@ return function(game)
throwAndShoot("break", function(a, b) return b end) throwAndShoot("break", function(a, b) return b end)
-- rng low: clean capture, ball stays shut -- rng low: clean capture, ball stays shut
throwAndShoot("caught", function(a, b) return a end) 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 end
+70
View File
@@ -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
+104
View File
@@ -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
+75
View File
@@ -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
+97
View File
@@ -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
+18 -2
View File
@@ -1,5 +1,5 @@
-- Driver: reproduce the door-warp flow. Teleports to Pallet Town in -- Driver: door-warp flow (Pallet house) plus #187 S.S. Anne enter-from-above.
-- front of Red's house, walks in, tries to move inside, walks back out. -- Anne-only shots: tests/drivers/anne_door_test.lua
return function(game) return function(game)
local U = dofile("tests/drivers/util.lua") local U = dofile("tests/drivers/util.lua")
@@ -32,4 +32,20 @@ return function(game)
U.shot(game, DIR .. "/door_9_back_outside.png") U.shot(game, DIR .. "/door_9_back_outside.png")
U.log("final map:", ow.map.id, "pos:", ow.player.cellX, ow.player.cellY, U.log("final map:", ow.map.id, "pos:", ow.player.cellX, ow.player.cellY,
"transitioning:", tostring(ow.transitioning)) "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 end
+93
View File
@@ -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
+71 -8
View File
@@ -1,10 +1,11 @@
-- Driver: Pokémon Center nurse heal, welcome/choice dialogue, the -- Driver: Pokémon Center nurse heal + Mom home heal.
-- machine monitor + per-mon balls, the jingle flash, and the farewell. -- Logs frames between healAnim end and the fighting-fit / looking-great text.
return function(game) return function(game)
local U = dofile("tests/drivers/util.lua") local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local Pokemon = require("src.pokemon.Pokemon") local Pokemon = require("src.pokemon.Pokemon")
local ChoiceBox = require("src.ui.ChoiceBox") local ChoiceBox = require("src.ui.ChoiceBox")
local TextBox = require("src.render.TextBox")
local mon = Pokemon.new(game.data, "CHARMANDER", 12) local mon = Pokemon.new(game.data, "CHARMANDER", 12)
mon.hp = 3 mon.hp = 3
table.insert(game.save.party, mon) table.insert(game.save.party, mon)
@@ -14,7 +15,6 @@ return function(game)
U.teleport(game, "VIRIDIAN_POKECENTER", 3, 3, "up") U.teleport(game, "VIRIDIAN_POKECENTER", 3, 3, "up")
local ow = game.overworld local ow = game.overworld
-- mash A until a condition holds
local function mashUntil(cond) local function mashUntil(cond)
for _ = 1, 400 do for _ = 1, 400 do
if cond() then return true end if cond() then return true end
@@ -24,6 +24,11 @@ return function(game)
return false return false
end end
local function topIsText()
return getmetatable(game.stack:top()) == TextBox
end
-- -------- Poké Center --------
U.tap(game, "a") -- talk to the nurse U.tap(game, "a") -- talk to the nurse
U.wait(30) U.wait(30)
U.shot(game, DIR .. "/heal_00_welcome.png") U.shot(game, DIR .. "/heal_00_welcome.png")
@@ -38,7 +43,6 @@ return function(game)
U.shot(game, DIR .. "/heal_02_ball1.png") U.shot(game, DIR .. "/heal_02_ball1.png")
U.wait(28) U.wait(28)
U.shot(game, DIR .. "/heal_03_ball2.png") U.shot(game, DIR .. "/heal_03_ball2.png")
-- overlay must stay glued to the machine under survey zoom
game:zoomStep(-1); game:zoomStep(-1) game:zoomStep(-1); game:zoomStep(-1)
U.wait(3) U.wait(3)
U.shot(game, DIR .. "/heal_03z_zoomed.png") 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.shot(game, DIR .. "/heal_05_flash_b.png")
U.wait(10) U.wait(10)
U.shot(game, DIR .. "/heal_06_flash_c.png") U.shot(game, DIR .. "/heal_06_flash_c.png")
-- wait out the jingle + 32-frame beat
for _ = 1, 40 do local animEndFrame, fitFrame
if not ow.healAnim then break end for _ = 1, 400 do
U.wait(10) 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 end
U.shot(game, DIR .. "/heal_07_fit.png") 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() U.log("farewell reached:", mashUntil(function()
return game.stack:top() == ow return game.stack:top() == ow
end)) end))
U.shot(game, DIR .. "/heal_08_end.png") 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 end
+162
View File
@@ -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
+112
View File
@@ -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
+65 -11
View File
@@ -1,6 +1,5 @@
-- Driver: the full Pallet intro chain. Steps north to trigger Oak, -- Driver: Pallet intro (Oak escort → starter → rival ambush) plus the
-- follows him to the lab, takes a starter, watches the rival -- #158 parcel-return / rival-join / Pokédex-gift cutscene.
-- counter-pick, tries to leave (door gate + ambush battle).
return function(game) return function(game)
local U = dofile("tests/drivers/util.lua") 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, U.log("final:", ow.map.id, ow.player.cellX, ow.player.cellY,
"battled:", tostring(game.save.flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB), "battled:", tostring(game.save.flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB),
"battleSeen:", tostring(BattleSeen)) "battleSeen:", tostring(BattleSeen))
U.log("stuck-state: runner=", tostring(ow.runner:isRunning()),
"moves=", #ow.scriptMoves, "top=", tostring(game.stack:top() == ow), -- ---- #158: return Oak's Parcel → rival joins → Pokédex gift ----
"transitioning=", tostring(ow.transitioning), local Flags = require("src.script.Flags")
"emote=", tostring(ow.emote ~= nil)) Flags.set(game.save, "EVENT_GOT_OAKS_PARCEL")
for i, mv in ipairs(ow.scriptMoves) do game.save.inventory.OAKS_PARCEL = 1
U.log((" move %d: entity=%s dir=%s remaining=%d moving=%s"):format( -- desk Oak + both dexes visible; rival hidden until the script shows
i, tostring(mv.entity.def and mv.entity.def.sprite or "PLAYER"), -- him. Keep the starter-ball hides from the pick (Squirtle + Bulbasaur).
tostring(mv.dir), mv.remaining, tostring(mv.entity.moving))) 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 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 end
+87
View File
@@ -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
+37
View File
@@ -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
+74
View File
@@ -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
+203
View File
@@ -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
+5
View File
@@ -731,6 +731,11 @@ local function switchTo(battle, slot)
end end
local t = replacementMenu() local t = replacementMenu()
if not t or t.index ~= slot then break end 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") press("a")
U.wait(10) U.wait(10)
ok = true ok = true
@@ -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
@@ -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
+84
View File
@@ -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
+60
View File
@@ -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
+155
View File
@@ -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
+34
View File
@@ -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
@@ -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
+9 -1
View File
@@ -33,7 +33,15 @@ end
function U.shot(game, path) function U.shot(game, path)
game.capturePath = 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 end
-- skip the intro movie + title into a fresh overworld game -- skip the intro movie + title into a fresh overworld game
+33
View File
@@ -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")
+92
View File
@@ -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")
+58
View File
@@ -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()
+17 -4
View File
@@ -103,6 +103,7 @@ stub.math = {
stub.filesystem = { stub.filesystem = {
write = function(name, content) files[name] = content return true end, write = function(name, content) files[name] = content return true end,
read = function(name) return files[name] 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") -- directories are implied by key prefixes ("mods/x/manifest.json")
getInfo = function(name) getInfo = function(name)
if files[name] then return { type = "file" } end if files[name] then return { type = "file" } end
@@ -118,15 +119,27 @@ stub.filesystem = {
end, end,
getDirectoryItems = function(name) getDirectoryItems = function(name)
local seen, items = {}, {} local seen, items = {}, {}
local prefix = name .. "/" name = name or ""
for key in pairs(files) do -- "" / "/" = save-dir root (RomImporter Android ROM scan)
if key:sub(1, #prefix) == prefix then if name == "" or name == "/" then
local child = key:sub(#prefix + 1):match("^[^/]+") for key in pairs(files) do
local child = key:match("^[^/]+")
if child and not seen[child] then if child and not seen[child] then
seen[child] = true seen[child] = true
items[#items + 1] = child items[#items + 1] = child
end end
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 end
table.sort(items) table.sort(items)
return items return items
+34 -2
View File
@@ -423,6 +423,38 @@ check(#pm.subItems == 2, "a non-table submenu result keeps the vanilla list")
hooks:removeOwner("bad") hooks:removeOwner("bad")
pm.submenu = nil 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 -- ------- mod.ui helpers and theme defaults
local items = { { label = "A" }, { label = "B" } } local items = { { label = "A" }, { label = "B" } }
ModUI.insertAfter(items, "A", { label = "X" }) 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 check(Theme.cursor == 0xED and Theme.cursorHollow == 0xEC
and Theme.moreArrow == 0xEE, "theme defaults are the old literals") 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, 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 } } }) Theme.load({ field = { theme = { cursor = 0xAA } } })
check(Theme.cursor == 0xAA, "field.theme restyles the cursor glyph") check(Theme.cursor == 0xAA, "field.theme restyles the cursor glyph")
Theme.cursor = 0xED Theme.cursor = 0xED
+29 -5
View File
@@ -312,10 +312,15 @@ do
local Renderer = require("src.render.Renderer") local Renderer = require("src.render.Renderer")
local SaveData = require("src.core.SaveData") local SaveData = require("src.core.SaveData")
local OW = require("src.world.OverworldController") local OW = require("src.world.OverworldController")
local realTB = package.loaded["src.render.TextBox"] local function stackedDialogue()
package.loaded["src.render.TextBox"] = { local top = Game.stack:top()
new = function(game, text, done) return { text = text, onDone = done } end, 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.data = Data
Game.input = Input; Input:init() Game.input = Input; Input:init()
Game.renderer = Renderer; Renderer:init() Game.renderer = Renderer; Renderer:init()
@@ -328,10 +333,17 @@ do
Game.stack:push(OW, "PEWTER_GYM", 4, 13, "up") Game.stack:push(OW, "PEWTER_GYM", 4, 13, "up")
local ow = Game.stack:top() local ow = Game.stack:top()
ow:checkVictoryRewards("OPP_BROCK", 1) ow:checkVictoryRewards("OPP_BROCK", 1)
local brockText = stackedDialogue()
check(Game.save.flags.EVENT_BEAT_BROCK, "Brock victory sets EVENT_BEAT_BROCK") 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.inventory.BOULDERBADGE == 1, "Brock victory awards BOULDERBADGE")
check(Game.save.flags.EVENT_BEAT_PEWTER_GYM_TRAINER_0, check(Game.save.flags.EVENT_BEAT_PEWTER_GYM_TRAINER_0,
"Brock victory deactivates the Pewter Gym Cooltrainer") "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 local pewterNpc
for _, npc in ipairs(ow.npcs) do for _, npc in ipairs(ow.npcs) do
if npc.def.index == 2 then pewterNpc = npc break end if npc.def.index == 2 then pewterNpc = npc break end
@@ -348,6 +360,19 @@ do
and Game.save.objectToggles.ROUTE_22.ROUTE22_RIVAL1 == false, and Game.save.objectToggles.ROUTE_22.ROUTE22_RIVAL1 == false,
"Brock victory hides ROUTE22_RIVAL1") "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 while Game.stack:top() do Game.stack:pop() end
Game.save = SaveData.newGame() Game.save = SaveData.newGame()
Game.save.flags = {} Game.save.flags = {}
@@ -368,7 +393,6 @@ do
"unfought Cinnabar trainer is defeated via seeded header event") "unfought Cinnabar trainer is defeated via seeded header event")
while Game.stack:top() do Game.stack:pop() end while Game.stack:top() do Game.stack:pop() end
package.loaded["src.render.TextBox"] = realTB
end end
S.finish() S.finish()
+4 -2
View File
@@ -67,6 +67,8 @@ check(Game.save.party[1].nickname == "AAAAAAAAAA",
"starter nickname prompt accepted (AskName / #137)") "starter nickname prompt accepted (AskName / #137)")
-- === 2) the parcel/pokedex beat still doesn't grant POKé BALLs === -- === 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")), check(runScript(mapScripts.talkScript("VIRIDIAN_MART", "TEXT_VIRIDIANMART_CLERK")),
"mart clerk script completes") "mart clerk script completes")
eq(Game.save.inventory.OAKS_PARCEL, 1, "clerk hands over Oak's Parcel") 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") "POKEDEX2 hidden after receiving Pokédex")
eq(Game.save.inventory.POKE_BALL, nil, "still no POKe BALLs at the pokedex beat") 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 -- talking to Oak again before beating the Route 22 rival is the
-- into the RaiseYourYoungPokemon branch, not give balls -- .mon_around_the_world branch (not the poke-ball grant)
check(runScript(mapScripts.talkScript("OAKS_LAB", "TEXT_OAKSLAB_OAK1")), check(runScript(mapScripts.talkScript("OAKS_LAB", "TEXT_OAKSLAB_OAK1")),
"Oak talk (pre-Route22-win) script completes") "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") eq(Game.save.inventory.POKE_BALL, nil, "still no POKe BALLs before the Route 22 rival is beaten")
+58
View File
@@ -111,4 +111,62 @@ do
eq(b.afterQueue, "finish", "victory finishes after the queue") eq(b.afterQueue, "finish", "victory finishes after the queue")
end 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() S.finish()
+74
View File
@@ -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()
+38
View File
@@ -180,5 +180,43 @@ do
eq(game.save.daycare.mon.level, 5, "decline restores mon.level to depositLevel") eq(game.save.daycare.mon.level, 5, "decline restores mon.level to depositLevel")
end 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 package.loaded["src.render.TextBox"] = realTextBox
S.finish() S.finish()
+7 -1
View File
@@ -24,7 +24,10 @@ local S = require("tests.harness").suite("parity ghost marowak")
local check, eq = S.check, S.eq local check, eq = S.check, S.eq
-- Real TextBoxes want a Font atlas; the decision under test is which -- 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"] = { package.loaded["src.render.TextBox"] = {
new = function(_, text, done) return { text = text, done = done } end, new = function(_, text, done) return { text = text, done = done } end,
} }
@@ -158,4 +161,7 @@ do
eq(0, #moved, "no scripted step on a loss") eq(0, #moved, "no scripted step on a loss")
end end
package.loaded["src.render.TextBox"] = realTextBox
package.loaded["src.battle.BattleState"] = realBattleState
S.finish() S.finish()
+148
View File
@@ -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()
+16
View File
@@ -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 S = require("tests.harness").suite("parity ss anne guard")
local check, eq = S.check, S.eq 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"] = { package.loaded["src.render.TextBox"] = {
new = function(_, text, done) return { text = text, done = done } end, 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") check(found, "captain script plays Music_PkmnHealed after the rub")
end 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() S.finish()
+110
View File
@@ -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()
+21 -3
View File
@@ -254,11 +254,29 @@ shownIs({}, "old save: silent")
eq(#Game.save.party, 0, "old save: no Eevee re-gift") eq(#Game.save.party, 0, "old save: no Eevee re-gift")
eq(toggleOf(EEVEE_MAP, EEVEE_BALL), false, "old save: leftover ball hidden") 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 === -- ball stays, flag unset -- the gift stays claimable ===
Game.save = SaveData.newGame() Game.save = SaveData.newGame()
for i = 1, 6 do Game.save.party[i] = Pokemon.new(Data, "PIDGEY", 5) end 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) Boxes.ensure(Game.save)
for b = 1, Boxes.COUNT do for b = 1, Boxes.COUNT do
for s = 1, Boxes.CAPACITY do Game.save.boxes[b][s] = { species = "PIDGEY" } end 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") 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") 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 -- wSSAnne2FCurScript NOOP progression <-> the port's
-- EVENT_BEAT_SS_ANNE_RIVAL flag) === -- EVENT_BEAT_SS_ANNE_RIVAL flag) ===
local ss = require("data.scripts.story5").SS_ANNE_2F local ss = require("data.scripts.story5").SS_ANNE_2F
+74
View File
@@ -218,4 +218,78 @@ do
check(ow3.engaging, "distance 3 on the upper plateau still engages") check(ow3.engaging, "distance 3 on the upper plateau still engages")
end 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() S.finish()
+110
View File
@@ -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()
+117 -19
View File
@@ -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, 2, "two pages")
eq(#pages[1], 2, "page 1 has two lines") eq(#pages[1], 2, "page 1 has two lines")
eq(#pages[2], 3, "page 2 keeps scrolled line") 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 -- ---------------------------------------------------------------- stats & growth
local Stats = require("src.pokemon.Stats") 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)") check(r3 == "learn", "Pikachu can learn Toxic (in tmhm list)")
local r4 = ItemEffects.use(Data, save, "HM_SURF", pikachu) local r4 = ItemEffects.use(Data, save, "HM_SURF", pikachu)
eq(r4, "failed", "Pikachu can't learn Surf") 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) local r5, _, extra = ItemEffects.use(Data, save, "THUNDER_STONE", pikachu)
eq(r5, "consumed", "Thunder Stone works on Pikachu") eq(r5, "consumed", "Thunder Stone works on Pikachu")
eq(extra.evolveTo, "RAICHU", "Thunder Stone evolves Pikachu to Raichu") eq(extra.evolveTo, "RAICHU", "Thunder Stone evolves Pikachu to Raichu")
@@ -494,7 +532,7 @@ local function runScript(script)
local r = ScriptRunner.new(Game, nil) local r = ScriptRunner.new(Game, nil)
r:run(script, {}) r:run(script, {})
local guard = 0 local guard = 0
while r:isRunning() and guard < 2000 do while r:isRunning() and guard < 4000 do
guard = guard + 1 guard = guard + 1
Input.pressed = { a = true } Input.pressed = { a = true }
StateStack:update(1 / 60) StateStack:update(1 / 60)
@@ -505,6 +543,7 @@ local function runScript(script)
end end
Flags.set(Game.save, "EVENT_GOT_STARTER") 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")), check(runScript(mapScripts.talkScript("VIRIDIAN_MART", "TEXT_VIRIDIANMART_CLERK")),
"mart clerk script completes") "mart clerk script completes")
eq(Game.save.inventory.OAKS_PARCEL, 1, "clerk hands over Oak's Parcel") 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, check(Data.field.hiddenExtras.trashCans.adjacent[0][1] == 1,
"trash can adjacency table extracted") "trash can adjacency table extracted")
check(#Data.field.hiddenExtras.trashCans.cans == 15, "15 Vermilion trash cans") 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") local tf = io.open(Data.field.title.logo.path, "rb")
check(tf ~= nil, "title logo asset exists") check(tf ~= nil, "title logo asset exists")
if tf then tf:close() end if tf then tf:close() end
@@ -882,6 +925,35 @@ do
eq(sbst.player.stages.attack, 1, "SHARPEN skips the accuracy roll") eq(sbst.player.stages.attack, 1, "SHARPEN skips the accuracy roll")
end 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 -- HandleIfPlayerMoveMissed: skip PlayMoveAnimation on a miss
-- (unless EXPLODE_EFFECT) -- (unless EXPLODE_EFFECT)
do do
@@ -1256,6 +1328,23 @@ do
"_ItemUseBallText08 before meeting Bill") "_ItemUseBallText08 before meeting Bill")
check(not hasText(cb2, "New POKéDEX data"), check(not hasText(cb2, "New POKéDEX data"),
"no dex page for an already-owned species") "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 Game.save.flags.EVENT_MET_BILL = true
local cb3 = BattleState.newWild(Game, "EKANS", 5) local cb3 = BattleState.newWild(Game, "EKANS", 5)
cb3:storeCaughtMon() cb3:storeCaughtMon()
@@ -1269,7 +1358,9 @@ do
tb.enemyIndex = #tb.enemyParty tb.enemyIndex = #tb.enemyParty
tb.enemy = { mon = tb.enemyParty[#tb.enemyParty], def = tb.enemy.def, tb.enemy = { mon = tb.enemyParty[#tb.enemyParty], def = tb.enemy.def,
name = tb.enemy.name, isPlayer = false } 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() tb:enemyMonFainted()
check(hasText(tb, ("%s defeated\n%s!"):format(Game.save.player.name, check(hasText(tb, ("%s defeated\n%s!"):format(Game.save.player.name,
tb.trainer.name)), tb.trainer.name)),
@@ -1946,8 +2037,9 @@ end
-- ---------------------------------------------------------------- heal machine cadence -- ---------------------------------------------------------------- heal machine cadence
-- AnimateHealingMachine (engine/overworld/healing_machine.asm): one ball -- AnimateHealingMachine (engine/overworld/healing_machine.asm): one ball
-- per party mon every 30 frames (SFX_HEALING_MACHINE each), then the -- per party mon every 30 frames (SFX_HEALING_MACHINE each), then the
-- healed jingle while the machine flashes 8 times (10 frames a toggle), -- healed jingle while the machine flashes 8 times (10 frames a toggle).
-- then a 32-frame beat after the jingle ends. -- #157: fighting-fit text follows the flash immediately (no .waitLoop2 /
-- DelayFrames 32 hold after the machine).
do do
local OW = require("src.world.OverworldController") local OW = require("src.world.OverworldController")
local ha = { balls = 3, lit = 0, timer = 0, visible = true } local ha = { balls = 3, lit = 0, timer = 0, visible = true }
@@ -1958,32 +2050,26 @@ do
local ev = OW.stepHealAnim(ha) local ev = OW.stepHealAnim(ha)
if ha.visible ~= wasVisible then toggles = toggles + 1 end if ha.visible ~= wasVisible then toggles = toggles + 1 end
if ev then events[#events + 1] = frame .. ev 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 if ev == "done" then break end
end end
end) end)
check(ok, "stepHealAnim runs") check(ok, "stepHealAnim runs")
eq(table.concat(events, ","), 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") "heal machine: a ball per mon every 30 frames, jingle, flash, done")
eq(toggles, 8, "machine sprites flash 8 times") eq(toggles, 8, "machine sprites flash 8 times")
check(ha.visible, "machine sprites end visible") 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 ha2 = { balls = 1, lit = 0, timer = 0, visible = true }
local doneEarly = false local doneAt
pcall(function() pcall(function()
for _ = 1, 300 do for frame = 1, 300 do
if OW.stepHealAnim(ha2) == "done" then doneEarly = true end if OW.stepHealAnim(ha2) == "done" then doneAt = frame break end
end end
end) end)
check(not doneEarly, "heal machine waits for the jingle to end") -- 1 ball @1, jingle @31, 8x10 flash -> done @111
ha2.jingleDone = true eq(doneAt, 111, "fighting fit is not gated on the jingle ending")
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")
end end
-- ---------------------------------------------------------------- HP bar right cap -- ---------------------------------------------------------------- HP bar right cap
@@ -2213,8 +2299,12 @@ do
cb4.onFinish = function() end cb4.onFinish = function() end
cb4.rng = function(a, b) return a end -- rng low: guaranteed capture cb4.rng = function(a, b) return a end -- rng low: guaranteed capture
local origStart = cb4.startMessage local origStart = cb4.startMessage
local ballAtCaughtText
cb4.startMessage = function(s, item) cb4.startMessage = function(s, item)
log[#log + 1] = "text:" .. item.text:gsub("\n.*", "") 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) return origStart(s, item)
end end
cb4.queue = {} cb4.queue = {}
@@ -2223,8 +2313,11 @@ do
while steps < 4000 do while steps < 4000 do
steps = steps + 1 steps = steps + 1
Input.pressed = { a = true } 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 end
if cb4.waitingUI then StateStack:pop() end -- the pushed nickname TextBox
Input.pressed = {} Input.pressed = {}
Sound.play = origPlay Sound.play = origPlay
local caughtAt, fanfareAt, fanfares, tinks = nil, nil, 0, 0 local caughtAt, fanfareAt, fanfares, tinks = nil, nil, 0, 0
@@ -2244,7 +2337,9 @@ do
check(fanfareAt and caughtAt and fanfareAt < caughtAt, check(fanfareAt and caughtAt and fanfareAt < caughtAt,
"Caught_Mon sounds with the caught text, not after its dismissal") "Caught_Mon sounds with the caught text, not after its dismissal")
eq(cb4.result, "caught", "the capture resolved the battle") 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") "the resting closed ball stays compiled for the caught text")
Game.save.party = savedParty Game.save.party = savedParty
end end
@@ -2964,6 +3059,9 @@ runSuites({ "tests/input_hold_test.lua" })
-- ---------------------------------------------- launcher cursor (#114) -- ---------------------------------------------- launcher cursor (#114)
runSuites({ "tests/rom_importer_cursor_test.lua" }) runSuites({ "tests/rom_importer_cursor_test.lua" })
-- ---------------------------------------------- Android second ROM pick (#167)
runSuites({ "tests/rom_importer_android_pick_test.lua" })
-- ---------------------------------------------- parity workstream tests -- ---------------------------------------------- parity workstream tests
-- Each tests/parity_*.lua is a self-contained file (own bootstrap + check, -- Each tests/parity_*.lua is a self-contained file (own bootstrap + check,
-- error()s if any assertion fails). Globbed, so dropping a new parity -- error()s if any assertion fails). Globbed, so dropping a new parity
+22 -7
View File
@@ -6,7 +6,8 @@ Sources:
data/tilesets/water_tilesets.asm -> tilesets where Surf works data/tilesets/water_tilesets.asm -> tilesets where Surf works
data/events/hidden_events.asm -> hidden items / coins / slot machines, data/events/hidden_events.asm -> hidden items / coins / slot machines,
PC tiles, bench guys, gym statues, 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/bench_guys.asm -> bench guy text per map
data/events/slot_machine_wheels.asm -> the three slot wheel symbol lists data/events/slot_machine_wheels.asm -> the three slot wheel symbol lists
data/events/card_key_{coords,maps}.asm + engine/events/card_key.asm 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). StartSlotMachine (slot machine seats; arg SLOTS_* marks broken ones).
Also collects the engine text hooks that the port implements natively: Also collects the engine text hooks that the port implements natively:
OpenPokemonCenterPC / OpenRedsPC (the player's storage PC in the OpenPokemonCenterPC / OpenRedsPC (the player's storage PC in the
bedroom), PrintBenchGuyText, GymStatues and the Vermilion bedroom), PrintBenchGuyText, GymStatues, PrintTrashText (plain
Gym GymTrashScript cans (arg = [wGymTrashCanIndex]). For those the "only trash here" bins) and the Vermilion Gym GymTrashScript cans
fourth macro argument is the facing direction required to trigger the (arg = [wGymTrashCanIndex]). For those the fourth macro argument is
event, except GymTrashScript where it is the can index. 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 = {} items = {}
coins = {} coins = {}
slots = {} slots = {}
extras = {"pcTiles": {}, "benchGuys": {}, "gymStatues": {}, "trashCans": []} extras = {
"pcTiles": {}, "benchGuys": {}, "gymStatues": {},
"printTrash": {}, "trashCans": [],
}
current = None current = None
path = os.path.join(pokered, "data/events/hidden_events.asm") path = os.path.join(pokered, "data/events/hidden_events.asm")
for lineno, line in read_asm(path): for lineno, line in read_asm(path):
@@ -163,6 +169,9 @@ def parse_hidden_events(pokered):
elif func == "GymStatues": elif func == "GymStatues":
extras["gymStatues"].setdefault(current, []).append( extras["gymStatues"].setdefault(current, []).append(
{"x": x, "y": y, "facing": DIRS.get(arg, arg)}) {"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": elif func == "GymTrashScript":
if current != "VERMILION_GYM": if current != "VERMILION_GYM":
util.die(f"hidden_events.asm:{lineno}: GymTrashScript outside 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"], "pcTiles": extras["pcTiles"],
"benchGuys": extras["benchGuys"], "benchGuys": extras["benchGuys"],
"gymStatues": extras["gymStatues"], "gymStatues": extras["gymStatues"],
"printTrash": extras["printTrash"],
"trashCans": parse_trash_can_puzzle(pokered, extras["trashCans"]), "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) credits["theEnd"] = gfx.extract_the_end(pokered, assets_dir)
battle_hud = gfx.extract_battle_hud(pokered, assets_dir) battle_hud = gfx.extract_battle_hud(pokered, assets_dir)
town_map["background"] = gfx.extract_town_map_bg(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 \ if not ledges or not cut_trees or "OVERWORLD" not in water or len(trades) < 8 \
or "PALLET_TOWN" not in fly_warps: or "PALLET_TOWN" not in fly_warps:
@@ -1387,7 +1398,9 @@ def extract(pokered, out_dir):
or len(hidden_extras["pcTiles"]) < 10 \ or len(hidden_extras["pcTiles"]) < 10 \
or "VIRIDIAN_GYM" not in hidden_extras["gymStatues"] \ or "VIRIDIAN_GYM" not in hidden_extras["gymStatues"] \
or hidden_extras["benchGuys"].get("VIRIDIAN_POKECENTER", 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") util.die("hidden extras extraction sanity check failed")
if sorted(forced_movement["tiles"]) != ["ROUTE_16", "ROUTE_18", if sorted(forced_movement["tiles"]) != ["ROUTE_16", "ROUTE_18",
"SEAFOAM_ISLANDS_B3F", "SEAFOAM_ISLANDS_B3F",
@@ -1499,6 +1512,7 @@ def extract(pokered, out_dir):
"townMap": town_map, "townMap": town_map,
"credits": credits, "credits": credits,
"slotSymbols": slot_symbols, "slotSymbols": slot_symbols,
"tradeArt": trade_art,
"emotionBubbles": emotion_bubbles, "emotionBubbles": emotion_bubbles,
"oakSpeech": oak_speech, "oakSpeech": oak_speech,
"overworldFx": overworld_fx, "overworldFx": overworld_fx,
@@ -1525,6 +1539,7 @@ def extract(pokered, out_dir):
"+ gfx/credits/the_end.png,\n" "+ gfx/credits/the_end.png,\n"
"constants/script_constants.asm (SLOTS*, *_BUBBLE)\n" "constants/script_constants.asm (SLOTS*, *_BUBBLE)\n"
"+ gfx/slots/red_slots_{1,2}.png + gfx/emotes/*.png\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" "+ engine/{slots/slot_machine,overworld/emotion_bubbles}.asm,\n"
"scripts/ViridianCity.asm + engine/battle/core.asm,\n" "scripts/ViridianCity.asm + engine/battle/core.asm,\n"
"scripts/GameCorner.asm + text/GameCorner.asm,\n" "scripts/GameCorner.asm + text/GameCorner.asm,\n"
+107
View File
@@ -809,3 +809,110 @@ def extract_town_map_bg(pokered, assets_dir):
"source": "gfx/town_map/town_map.rle + town_map.png " "source": "gfx/town_map/town_map.rle + town_map.png "
"(engine/items/town_map.asm LoadTownMap)", "(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)",
}
+468 -312
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff