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).
-- Source: pokered/scripts/CopycatsHouse2F.asm, pokered/text/CopycatsHouse2F.asm
--
-- TEXT_COPYCATSHOUSE2F_COPYCAT is already ported (with the POKE DOLL /
-- TM31 MIMIC trade) in data/scripts/story4.lua, so it is intentionally
-- omitted here.
-- TEXT_COPYCATSHOUSE2F_COPYCAT (POKE DOLL -> TM31 MIMIC) lives in
-- data/scripts/story4.lua.
return {
COPYCATS_HOUSE_2F = {
+90 -45
View File
@@ -59,52 +59,97 @@ end
return {
talk = {
-- Oak: accepts the parcel and hands over the Pokédex, then (once the
-- player has beaten the Route 22 rival) hands over the real POKé
-- BALLs (scripts/OaksLab.asm OaksLabOak1Text, simplified: no rival
-- recall / dex-rating / "pokemon can fight" / "around the world"
-- branches -- see docs/known-differences.md)
-- Oak: OaksLabOak1Text. Parcel delivery kicks SCRIPT_OAKSLAB_RIVAL_
-- ARRIVES_AT_OAKS_REQUEST + OaksLabOakGivesPokedexScript (rival walk-
-- in, full Pokédex speech, rival exit, Route 22 arm). Dex-rating
-- (DisplayDexRating) is still skipped.
TEXT_OAKSLAB_OAK1 = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_GOT_OAKS_PARCEL" }, -- 2
{ "jump_if_false", 16 }, -- 3
{ "check_flag", "EVENT_OAK_GOT_PARCEL" }, -- 4
{ "jump_if_true", 16 }, -- 5
{ "show_text", "_OaksLabOak1DeliverParcelText" }, -- 6
{ "take_item", "OAKS_PARCEL", 1 }, -- 7
{ "set_flag", "EVENT_OAK_GOT_PARCEL" }, -- 8
{ "show_text", "_OaksLabOak1PokemonAroundTheWorldText" }, -- 9
{ "set_flag", "EVENT_GOT_POKEDEX" }, -- 10
-- OaksLab.asm OakGivesPokedex: HideObject TOGGLE_POKEDEX_1/2
-- so the table sprites leave with the gift (#106).
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX1" }, -- 11
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX2" }, -- 12
-- the Pokédex swaps Viridian's two old men (OaksLab.asm:602-606:
-- HideObject TOGGLE_LYING_OLD_MAN / ShowObject TOGGLE_OLD_MAN).
-- Until this ran, the walking man at (17,5) -- who owns the coffee
-- ask and the catch tutorial -- stayed OFF for the whole game
-- (toggleable_objects.asm seeds him OFF, the sleeper ON).
{ "hide_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY" }, -- 13
{ "show_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN" }, -- 14
{ "jump", "end" }, -- 15
{ "check_flag", "EVENT_GOT_STARTER" }, -- 16
{ "jump_if_false", 31 }, -- 17
{ "check_item", "POKE_BALL" }, -- 18
{ "jump_if_true", 29 }, -- 19
{ "check_flag", "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE" }, -- 20
{ "jump_if_false", 33 }, -- 21
{ "check_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 22
{ "jump_if_true", 29 }, -- 23
{ "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 24
{ "give_item", "POKE_BALL", 5, false }, -- 25
{ "show_text", "_OaksLabOak1ReceivedPokeballsText" }, -- 26
{ "show_text", "_OaksLabGivePokeballsExplanationText" }, -- 27
{ "jump", "end" }, -- 28
{ "show_text", "_OaksLabOak1ComeSeeMeSometimesText" }, -- 29
{ "jump", "end" }, -- 30
{ "show_text", "_OaksLabOak1WhichPokemonDoYouWantText" }, -- 31
{ "jump", "end" }, -- 32
{ "show_text", "_OaksLabOak1RaiseYourYoungPokemonText" }, -- 33
{ "face_player" },
{ "check_item", "POKE_BALL" },
{ "jump_if_true", "come_see" },
{ "check_flag", "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE" },
{ "jump_if_true", "give_balls" },
{ "check_flag", "EVENT_GOT_POKEDEX" },
{ "jump_if_true", "around_world" },
{ "check_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" },
{ "jump_if_false", "pre_lab_battle" },
{ "check_item", "OAKS_PARCEL" },
{ "jump_if_false", "raise_young" },
-- OaksLabOak1Text.got_parcel → RivalArrives + OakGivesPokedex
{ "show_text", "_OaksLabOak1DeliverParcelText" },
{ "play_sound", "Get_Key_Item" },
{ "show_text", "_OaksLabOak1ParcelThanksText" },
{ "take_item", "OAKS_PARCEL", 1 },
{ "stop_music" },
{ "play_music", "Music_MeetRival" },
{ "show_text", "_OaksLabRivalGrampsText" },
{ "show_object", "OAKS_LAB", "OAKSLAB_RIVAL" },
-- OaksLabCalcRivalMovementScript: sprite map (8,11) → cell (4,7)
-- when the player stands below Oak (the usual desk talk).
{ "place_npc", 1, 4, 7, "up" },
{ "move_npc_to", 1, 4, 3 },
{ "play_music", "Music_OaksLab" },
{ "face_object", 1, "up" },
{ "face_object", 5, "down" },
{ "show_text", "_OaksLabRivalWhatDidYouCallMeForText" },
{ "face_object", 1, "up" },
{ "face_object", 5, "down" },
{ "show_text", "_OaksLabOakIHaveARequestText" },
{ "face_object", 1, "up" },
{ "face_object", 5, "down" },
{ "show_text", "_OaksLabOakMyInventionPokedexText" },
{ "show_text", "_OaksLabOakGotPokedexText" },
{ "play_sound", "Get_Key_Item" },
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX1" },
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX2" },
{ "face_object", 1, "up" },
{ "face_object", 5, "down" },
{ "show_text", "_OaksLabOakThatWasMyDreamText" },
{ "face_object", 1, "right" },
{ "show_text", "_OaksLabRivalLeaveItAllToMeText" },
{ "set_flag", "EVENT_GOT_POKEDEX" },
{ "set_flag", "EVENT_OAK_GOT_PARCEL" },
{ "hide_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY" },
{ "show_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN" },
{ "stop_music" },
{ "play_music", "Music_MeetRival" },
{ "move_npc_to", 1, 4, 7 },
{ "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" },
{ "play_music", "Music_OaksLab" },
{ "set_flag", "EVENT_1ST_ROUTE22_RIVAL_BATTLE" },
{ "clear_flag", "EVENT_2ND_ROUTE22_RIVAL_BATTLE" },
{ "set_flag", "EVENT_ROUTE22_RIVAL_WANTS_BATTLE" },
{ "show_object", "ROUTE_22", "ROUTE22_RIVAL1" },
{ "jump", "end" },
{ "label", "raise_young" },
{ "show_text", "_OaksLabOak1RaiseYourYoungPokemonText" },
{ "jump", "end" },
{ "label", "pre_lab_battle" },
{ "check_flag", "EVENT_GOT_STARTER" },
{ "jump_if_true", "can_fight" },
{ "show_text", "_OaksLabOak1WhichPokemonDoYouWantText" },
{ "jump", "end" },
{ "label", "can_fight" },
{ "show_text", "_OaksLabOak1YourPokemonCanFightText" },
{ "jump", "end" },
{ "label", "around_world" },
{ "show_text", "_OaksLabOak1PokemonAroundTheWorldText" },
{ "jump", "end" },
{ "label", "give_balls" },
{ "check_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" },
{ "jump_if_true", "come_see" },
{ "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" },
{ "give_item", "POKE_BALL", 5, false },
{ "show_text", "_OaksLabOak1ReceivedPokeballsText" },
{ "show_text", "_OaksLabGivePokeballsExplanationText" },
{ "jump", "end" },
{ "label", "come_see" },
{ "show_text", "_OaksLabOak1ComeSeeMeSometimesText" },
},
TEXT_OAKSLAB_CHARMANDER_POKE_BALL =
+10 -3
View File
@@ -878,16 +878,23 @@ M.HALL_OF_FAME = {
M.CERULEAN_CITY = {
talk = {
-- CeruleanCityRivalText: Bill line once beaten; pre-battle otherwise.
-- Post-fight walk matches CeruleanCityMovement4 (right then into town).
TEXT_CERULEANCITY_RIVAL = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 2
{ "jump_if_true", 9 }, -- 3
{ "jump_if_true", 13 }, -- 3
{ "show_text", "_CeruleanCityRivalPreBattleText" }, -- 4
{ "rival_battle", "OPP_RIVAL1", 7 }, -- 5
{ "jump_if_false", 10 }, -- 6
{ "jump_if_false", "end" }, -- 6
{ "set_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 7
{ "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
},
},
}
+58 -18
View File
@@ -445,33 +445,67 @@ local function engageSuperNerd(game, ow, onDone)
ow:engageTrainer(nerd, onDone)
end
local function mtMoonFossil(itemId, otherName)
-- MtMoonB2FMoveSuperNerdScript: player-near-dome coords walk RIGHT then
-- UP (MoveRight falls through into MoveUp); near-helix walks UP only.
local function mtMoonNerdWalk(px, py, itemId)
if (px == 12 and py == 7) or (px == 11 and py == 6) or (px == 12 and py == 5) then
return { "right", "up" }
end
if (px == 13 and py == 7) or (px == 14 and py == 6) or (px == 14 and py == 5) then
return { "up" }
end
return (itemId == "DOME_FOSSIL") and { "right", "up" } or { "up" }
end
-- scripts/MtMoonB2F.asm Dome/Helix fossil text_asm + MoveSuperNerd +
-- SuperNerdTakesOtherFossil: pick one, hide it, walk the nerd to the
-- other, "All right. Then this is mine!", hide the other.
local function mtMoonFossil(itemId, otherName, gotFlag)
return function(game, ow, npc, done)
local TextBox = require("src.render.TextBox")
local ChoiceBox = require("src.ui.ChoiceBox")
if game.save.flags.EVENT_GOT_A_FOSSIL then
game.stack:push(TextBox.new(game, "You already took\na fossil.", done))
local t = game.data.text
local flags = game.save.flags
if flags.EVENT_GOT_DOME_FOSSIL or flags.EVENT_GOT_HELIX_FOSSIL then
done()
return
end
-- can't grab a fossil until the Super Nerd is beaten -- he intercepts
if not superNerdBeaten(ow) then
engageSuperNerd(game, ow, done)
return
end
local name = game.data.items[itemId].name
game.stack:push(TextBox.new(game, ("You want the\n%s?"):format(name), function()
game.stack:push(ChoiceBox.new(game, function(yes)
local want = (itemId == "DOME_FOSSIL")
and (t._MtMoonB2FDomeFossilYouWantText or "You want the\nDOME FOSSIL?")
or (t._MtMoonB2FHelixFossilYouWantText or "You want the\nHELIX FOSSIL?")
game.stack:push(TextBox.new(game, want, nil, { choice = function(yes)
if not yes then done() return end
game.save.inventory[itemId] = 1
game.save.flags.EVENT_GOT_A_FOSSIL = true
local Commands = require("src.script.Commands")
local ctx = { save = game.save, overworld = ow, game = game }
Commands.hide_object(ctx, "MT_MOON_B2F", npc.def.name)
Commands.hide_object(ctx, "MT_MOON_B2F", otherName)
if not require("src.inventory.Bag").add(game.save, itemId, 1) then
game.stack:push(TextBox.new(game,
("%s got the\n%s!"):format(game.save.player.name, name), done))
end))
t._MtMoonB2FYouHaveNoRoomText or "Look, you've got\nno room for this.",
done))
return
end
local idef = game.data.items[itemId]
game.stringBuffer = idef and idef.name or itemId
require("src.core.Sound").play(game.data, "Get_Key_Item")
local dirs = mtMoonNerdWalk(ow.player.cellX, ow.player.cellY, itemId)
game.stack:push(TextBox.new(game,
t._MtMoonB2FReceivedFossilText
or ("{PLAYER} got the\n" .. game.stringBuffer .. "!"),
function()
local Commands = require("src.script.Commands")
Commands.hide_object(
{ save = game.save, overworld = ow, game = game },
"MT_MOON_B2F", npc.def.name)
flags[gotFlag] = true
ow.runner:run({
{ "walk_npc", 1, dirs },
{ "text_opts", { auto = true } },
{ "show_text", "_MtMoonB2FSuperNerdThenThisIsMineText" },
{ "play_sound", "Get_Key_Item" },
{ "hide_object", "MT_MOON_B2F", otherName },
}, { onDone = done })
end))
end }))
end
end
@@ -486,8 +520,10 @@ M.MT_MOON_B2F = {
return false
end,
talk = {
TEXT_MTMOONB2F_DOME_FOSSIL = mtMoonFossil("DOME_FOSSIL", "MTMOONB2F_HELIX_FOSSIL"),
TEXT_MTMOONB2F_HELIX_FOSSIL = mtMoonFossil("HELIX_FOSSIL", "MTMOONB2F_DOME_FOSSIL"),
TEXT_MTMOONB2F_DOME_FOSSIL = mtMoonFossil(
"DOME_FOSSIL", "MTMOONB2F_HELIX_FOSSIL", "EVENT_GOT_DOME_FOSSIL"),
TEXT_MTMOONB2F_HELIX_FOSSIL = mtMoonFossil(
"HELIX_FOSSIL", "MTMOONB2F_DOME_FOSSIL", "EVENT_GOT_HELIX_FOSSIL"),
},
}
@@ -822,6 +858,10 @@ M.DAYCARE = {
if def then
mon.stats = Stats.calc(def, mon.level, mon.dvs, mon.statExp)
mon.hp = mon.stats.hp
-- Daycare.asm: WriteMonMoves with wLearningMovesFromDayCare
local Pokemon = require("src.pokemon.Pokemon")
Pokemon.learnMovesFromDayCare(
game.data, mon, def, startLevel, newLevel)
end
table.insert(game.save.party, mon)
game.save.daycare = nil
+18 -17
View File
@@ -212,30 +212,31 @@ M.COPYCATS_HOUSE_2F = {
talk = {
TEXT_COPYCATSHOUSE2F_COPYCAT = function(game, ow, npc, done)
local t = text(game)
local Bag = require("src.inventory.Bag")
if game.save.flags.EVENT_GOT_TM31 then
push(game, t._CopycatsHouse2FCopycatWhirlingText
or "Huh? Huh? Huh?", done)
push(game, t._CopycatsHouse2FCopycatTM31Explanation2Text, done)
return
end
if (game.save.inventory.POKE_DOLL or 0) > 0 then
ask(game, t._CopycatsHouse2FCopycatPokeDollText
or "Oh wow!\nA POKé DOLL!\fFor me?\nCan I have it?", function(yes)
if not yes then done() return end
game.save.inventory.POKE_DOLL = game.save.inventory.POKE_DOLL - 1
if game.save.inventory.POKE_DOLL == 0 then
game.save.inventory.POKE_DOLL = nil
end
if not require("src.inventory.Bag").add(game.save, "TM_MIMIC", 1) then
push(game, "You don't have\nroom for TM31!", done)
-- Mimic line first; auto-trade POKE DOLL for TM31 (no YES/NO).
push(game, t._CopycatsHouse2FCopycatDoYouLikePokemonText, function()
if (game.save.inventory.POKE_DOLL or 0) <= 0 then
done()
return
end
push(game, t._CopycatsHouse2FCopycatTM31PreReceiveText, function()
if not Bag.add(game.save, "TM_MIMIC", 1) then
push(game, t._CopycatsHouse2FCopycatTM31NoRoomText, done)
return
end
game.stringBuffer = game.data.items.TM_MIMIC.name
require("src.core.Sound").play(game.data, "Get_Item1")
Bag.remove(game.save, "POKE_DOLL", 1)
game.save.flags.EVENT_GOT_TM31 = true
push(game, ("%s got\nTM31!"):format(game.save.player.name), done)
push(game, t._CopycatsHouse2FCopycatReceivedTM31Text, function()
push(game, t._CopycatsHouse2FCopycatTM31Explanation1Text, done)
end)
end)
end)
return
end
push(game, t._CopycatsHouse2FCopycatText
or "I like to mimic\npeople!", done)
end,
},
}
+38 -15
View File
@@ -457,6 +457,19 @@ end
-- EVENT_ROUTE22_RIVAL_WANTS_BATTLE in Oak's lab (expired by Pewter
-- Gym) and again in Viridian Gym; we derive the same windows from the
-- surrounding story flags so old saves work too.
-- Rival1Exit → Viridian (right/down); Rival2Exit → League (left).
local function route22ExitDirs(n, py)
if n == 2 then
if py == 5 then return { "left", "left", "left", "left" } end
return { "left", "left", "left" }
end
if py == 5 then
return { "right", "right", "down", "down", "down", "down", "down" }
end
return { "up", "right", "right", "right",
"down", "down", "down", "down", "down", "down" }
end
local function route22Scene(n, objIndex, objName, oppClass, baseParty, beatFlag, py)
return {
{ "show_object", "ROUTE_22", objName }, -- 1
@@ -468,7 +481,7 @@ local function route22Scene(n, objIndex, objName, oppClass, baseParty, beatFlag,
{ "set_flag", beatFlag }, -- 7
{ "show_text", "_Route22Rival" .. n .. "DefeatedText" }, -- 8
{ "show_text", "_Route22RivalAfterBattleText" .. n }, -- 9
{ "move_npc_to", objIndex, 25, 5 }, -- 10
{ "walk_npc", objIndex, route22ExitDirs(n, py) }, -- 10
{ "hide_object", "ROUTE_22", objName }, -- 11
}
end
@@ -495,6 +508,14 @@ M.ROUTE_22 = {
-- Cerulean City: the Nugget Bridge rival ambush (CeruleanCityCoords2),
-- the TM28 rocket thief with his forced-fight cells (Coords1), and the
-- cave guard who steps aside once you are Champion.
-- Post-battle: Bill text then CeruleanCityMovement3/4 into town.
local function ceruleanRivalExitDirs(px)
if px == 20 then
return { "right", "down", "down", "down", "down", "down", "down" }
end
return { "left", "down", "down", "down", "down", "down", "down" }
end
local function ceruleanRivalScene(px, py)
return {
{ "show_object", "CERULEAN_CITY", "CERULEANCITY_RIVAL" }, -- 1
@@ -502,41 +523,43 @@ local function ceruleanRivalScene(px, py)
{ "face_object", 1, "down" }, -- 3
{ "show_text", "_CeruleanCityRivalPreBattleText" }, -- 4
{ "rival_battle", "OPP_RIVAL1", 7 }, -- 5
{ "jump_if_false", 10 }, -- 6
{ "jump_if_false", 11 }, -- 6
{ "set_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 7
{ "show_text", "_CeruleanCityRivalDefeatedText" }, -- 8
{ "move_npc_to", 1, 20, 2 }, -- 9
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_RIVAL" }, -- 10
{ "show_text", "_CeruleanCityRivalIWentToBillsText" }, -- 9
{ "walk_npc", 1, ceruleanRivalExitDirs(px) }, -- 10
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_RIVAL" }, -- 11
}
end
-- scripts/CeruleanCity.asm CeruleanCityRocketText: fight the thief,
-- then he returns TM28 (DIG) and hurries off
-- then he returns TM28 (DIG) and hurries off. CeruleanHideRocket
-- (CeruleanCity_2.asm) is GBFadeOutToBlack → Show GUARD1 / Hide GUARD2 /
-- Hide ROCKET → GBFadeInFromBlack — not a bare hide_object.
local rocketRows = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_GOT_TM28" }, -- 2
{ "jump_if_true", 15 }, -- 3
{ "jump_if_true", 15 }, -- 3 → CeruleanHideRocket
{ "check_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 4
{ "jump_if_true", 9 }, -- 5
{ "show_text", "_CeruleanCityRocketText" }, -- 6
{ "start_battle", "trainer", "OPP_ROCKET", 5 }, -- 7
{ "jump_if_false", 18 }, -- 8
{ "jump_if_false", "end" }, -- 8
{ "show_text", "_CeruleanCityRocketIllReturnTheTMText" }, -- 9
{ "set_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 10
{ "give_item", "TM_DIG", 1, false }, -- 11 (row 13 prints)
{ "set_flag", "EVENT_GOT_TM28" }, -- 12
{ "show_text", "_CeruleanCityRocketReceivedTM28Text" }, -- 13
{ "show_text", "_CeruleanCityRocketIBetterGetMovingText" }, -- 14
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_ROCKET" }, -- 15
-- CeruleanHideRocket (scripts/CeruleanCity_2.asm) does all three behind
-- one fade: the ROCKET goes, GUARD1 (28,12) appears and GUARD2 (27,12)
-- disappears. GUARD2 stands on the only walkable neighbour of the
-- trashed house's south door, which is one of the two ways through the
-- fence dividing Cerulean, so this swap is what reconnects the city.
-- Bill's ticket performs the same swap (data/scripts/story.lua), and
-- both are idempotent, so whichever the player reaches first opens it.
{ "fade", "out" }, -- 15 GBFadeOutToBlack
-- CeruleanHideRocket while black: GUARD1 (28,12) appears, GUARD2
-- (27,12) and the ROCKET go. GUARD2 blocks the trashed-house south
-- door neighbour — the swap reconnects the city (Bill's ticket does
-- the same in story.lua; either route is enough).
{ "show_object", "CERULEAN_CITY", "CERULEANCITY_GUARD1" }, -- 16
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_GUARD2" }, -- 17
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_ROCKET" }, -- 18
{ "fade", "in" }, -- 19 GBFadeInFromBlack
}
M.CERULEAN_CITY = {
+61 -7
View File
@@ -12,6 +12,12 @@
-- "; deactivate gym trainers" / SetEventRange in the other gyms, and
-- FightingDojo.asm SetEventRange EVENT_BEAT_KARATE_MASTER ..
-- EVENT_BEAT_FIGHTING_DOJO_TRAINER_3).
--
-- `dialogue` is the end-battle + post-battle text chain each gym leader
-- runs (SaveEndBattleTextPointers then the map's *PostBattle / ReceiveTM
-- script). Leaders are not def_trainers entries, so engageTrainer has
-- no header.won — checkVictoryRewards shows this chain instead of a
-- synthetic "received badge/TM" stub.
local function range(prefix, first, last)
local t = {}
@@ -31,28 +37,76 @@ return {
hide = {
{ "PEWTER_CITY", "PEWTERCITY_YOUNGSTER" },
{ "ROUTE_22", "ROUTE22_RIVAL1" },
},
dialogue = {
"_PewterGymBrockReceivedBoulderBadgeText",
"_PewterGymBrockBoulderBadgeInfoText",
"_PewterGymBrockWaitTakeThisText",
"_PewterGymReceivedTM34Text",
"_TM34ExplanationText",
} },
["OPP_MISTY#1"] = { badge = "CASCADEBADGE", flag = "EVENT_BEAT_MISTY",
item = "TM_BUBBLEBEAM",
deactivate = range("EVENT_BEAT_CERULEAN_GYM_TRAINER_", 0, 1) },
deactivate = range("EVENT_BEAT_CERULEAN_GYM_TRAINER_", 0, 1),
dialogue = {
"_CeruleanGymMistyReceivedCascadeBadgeText",
"_CeruleanGymMistyCascadeBadgeInfoText",
"_CeruleanGymMistyReceivedTM11Text",
} },
["OPP_LT_SURGE#1"] = { badge = "THUNDERBADGE", flag = "EVENT_BEAT_LT_SURGE",
item = "TM_THUNDERBOLT",
deactivate = range("EVENT_BEAT_VERMILION_GYM_TRAINER_", 0, 2) },
deactivate = range("EVENT_BEAT_VERMILION_GYM_TRAINER_", 0, 2),
dialogue = {
"_VermilionGymLTSurgeReceivedThunderBadgeText",
"_VermilionGymLTSurgeThunderBadgeInfoText",
"_VermilionGymLTSurgeReceivedTM24Text",
"_TM24ExplanationText",
} },
["OPP_ERIKA#1"] = { badge = "RAINBOWBADGE", flag = "EVENT_BEAT_ERIKA",
item = "TM_MEGA_DRAIN",
deactivate = range("EVENT_BEAT_CELADON_GYM_TRAINER_", 0, 6) },
deactivate = range("EVENT_BEAT_CELADON_GYM_TRAINER_", 0, 6),
dialogue = {
"_CeladonGymErikaReceivedRainbowBadgeText",
"_CeladonGymRainbowBadgeInfoText",
"_CeladonGymReceivedTM21Text",
"_TM21ExplanationText",
} },
["OPP_KOGA#1"] = { badge = "SOULBADGE", flag = "EVENT_BEAT_KOGA",
item = "TM_TOXIC",
deactivate = range("EVENT_BEAT_FUCHSIA_GYM_TRAINER_", 0, 5) },
deactivate = range("EVENT_BEAT_FUCHSIA_GYM_TRAINER_", 0, 5),
dialogue = {
"_FuchsiaGymKogaReceivedSoulBadgeText",
"_FuchsiaGymKogaSoulBadgeInfoText",
"_FuchsiaGymKogaReceivedTM06Text",
"_FuchsiaGymKogaTM06ExplanationText",
} },
["OPP_SABRINA#1"] = { badge = "MARSHBADGE", flag = "EVENT_BEAT_SABRINA",
item = "TM_PSYWAVE",
deactivate = range("EVENT_BEAT_SAFFRON_GYM_TRAINER_", 0, 6) },
deactivate = range("EVENT_BEAT_SAFFRON_GYM_TRAINER_", 0, 6),
dialogue = {
"_SaffronGymSabrinaReceivedMarshBadgeText",
"_SaffronGymSabrinaMarshBadgeInfoText",
"_SaffronGymSabrinaReceivedTM46Text",
"_TM46ExplanationText",
} },
["OPP_BLAINE#1"] = { badge = "VOLCANOBADGE", flag = "EVENT_BEAT_BLAINE",
item = "TM_FIRE_BLAST",
deactivate = range("EVENT_BEAT_CINNABAR_GYM_TRAINER_", 0, 6) },
deactivate = range("EVENT_BEAT_CINNABAR_GYM_TRAINER_", 0, 6),
dialogue = {
"_CinnabarGymBlaineReceivedVolcanoBadgeText",
"_CinnabarGymBlaineVolcanoBadgeInfoText",
"_CinnabarGymBlaineReceivedTM38Text",
"_CinnabarGymBlaineTM38ExplanationText",
} },
["OPP_GIOVANNI#3"] = { badge = "EARTHBADGE", flag = "EVENT_BEAT_GIOVANNI",
item = "TM_FISSURE",
deactivate = range("EVENT_BEAT_VIRIDIAN_GYM_TRAINER_", 0, 7) },
deactivate = range("EVENT_BEAT_VIRIDIAN_GYM_TRAINER_", 0, 7),
dialogue = {
"_ViridianGymGiovanniReceivedEarthBadgeText",
"_ViridianGymGiovanniEarthBadgeInfoText",
"_ViridianGymGiovanniReceivedTM27Text",
"_ViridianGymGiovanniTM27ExplanationText",
} },
-- Silph Co. Giovanni: unlocks the president's Master Ball gift
["OPP_GIOVANNI#2"] = { flag = "EVENT_BEAT_SILPH_CO_GIOVANNI" },
+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
file documents the runtime model; the visual spec lives separately.
## Android multi-ROM import
On Android, `love.system.pickFile()` opens the Storage Access Framework
picker (`GameActivity.showRomFilePicker`); the chosen file is copied to
`picked_rom.gb` in the app save directory. `RomImporter` then imports on
refocus / Choose via `findPendingRom`: only a 1 MiB `.gb` whose SHA-1 maps
to a version that is **not** yet ready counts as pending. A leftover
`picked_rom.gb` from Red therefore cannot block Blue's Choose (issue #167).
After a successful import the consumed save-dir `.gb` is removed.
**Manual check (device/emulator):** import Red → switch to Blue → Choose →
system file picker must appear (not a silent Red re-extract) → pick Blue →
Blue becomes ready beside Red.
## Tab structure
`self.tab` is one of `"red"`, `"blue"`, `"yellow"`, `"mods"`. The tab bar
+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
`tools/rom_manifest.json`.
The current importer has desktop file pickers only. A production Android
release still needs a Storage Access Framework handoff that passes the chosen
ROM to LÖVE; the APK packaging itself is data-free.
ROM import on Android uses `love.system.pickFile()`
`GameActivity.showRomFilePicker` (Storage Access Framework), which copies the
chosen file to `picked_rom.gb` under the app save directory. `RomImporter`
imports pending (not-yet-ready) `.gb` files from that folder on Choose /
refocus; see `docs/launcher.md` (Android multi-ROM import). The APK payload
itself remains data-free (no embedded ROM or generated cache).
### SDK / NDK
+148 -62
View File
@@ -275,6 +275,10 @@ local function makeBattler(data, mon, isPlayer, save)
badgeBoosts = badgeBoosts,
statuses = data.statuses,
shownHP = mon.hp, -- the HP the bar displays (UpdateHPBar drain)
-- HUD status label (DrawHUDsAndHPBars); mon.status can land mid-move
-- while the tilemap still shows the prior condition until the next
-- post-action HUD refresh (core.asm after Execute*Move)
shownStatus = mon.status,
stages = {},
-- volatile state; Transform/Conversion/Mimic override the cur* fields
curStats = mon.stats,
@@ -556,6 +560,12 @@ function BattleState:say(text)
table.insert(self.queue, { text = text })
end
-- Message that opens YES/NO once typed out, keeping the text visible
-- underneath (pokered `done` + TWO_OPTION_MENU / TextBox opts.choice).
function BattleState:sayChoice(text, onChoose)
table.insert(self.queue, { text = text, choice = onChoose })
end
function BattleState:act(fn)
table.insert(self.queue, { fn = fn })
end
@@ -816,8 +826,24 @@ function BattleState:updateQueue()
if self.charIndex < self.total then
self.charIndex = math.min(self.total, self.charIndex + 2)
else
local item = self.current
-- TrainerAboutToUseText ends in `done` then DisplayTextBoxID: YES/NO
-- overlays the still-visible "Will … change POKéMON?" page.
if item and item.choice and not item.choiceOpen then
item.choiceOpen = true
self.waitingUI = true
local ChoiceBox = require("src.ui.ChoiceBox")
local battle = self
self.game.stack:push(ChoiceBox.new(self.game, function(yes)
local fn = item.choice
battle.current = nil
fn(yes)
end))
return true
end
local input = self.game.input
if input:wasPressed("a") or input:wasPressed("b") then
if not (item and item.choice)
and (input:wasPressed("a") or input:wasPressed("b")) then
self.current = nil
end
end
@@ -1068,11 +1094,14 @@ function BattleState:update(dt)
self:updateFx()
local input = self.game.input
-- safety net: HP changed outside a queued drain (level-up heals,
-- field effects) snaps once the queue is idle
-- safety net: HP/status changed outside a queued drain (level-up heals,
-- field effects, bag cures) snaps once the queue is idle
if self.phase == "menu" then
for _, b in ipairs({ self.player, self.enemy }) do
if b and b.shownHP then b.shownHP = b.mon.hp end
if b then
if b.shownHP then b.shownHP = b.mon.hp end
b.shownStatus = b.mon.status
end
end
end
@@ -2153,10 +2182,21 @@ end
-- move execution pipeline
-- ---------------------------------------------------------------------
-- Mirror DrawHUDsAndHPBars: reveal mon.status on the HUD only after the
-- current action's queued anim/text have played (PoisonEffect sets the
-- bit before PlayCurrentMoveAnimation2 + PrintText, but the HUD redraw
-- waits until after Execute*Move returns).
function BattleState:syncShownStatus()
for _, b in ipairs({ self.player, self.enemy }) do
if b then b.shownStatus = b.mon.status end
end
end
function BattleState:executeAction(user, target, action)
if user.mon.hp <= 0 or target.mon.hp <= 0 then return end
if not action then return end
local function run()
-- ghost battles: the ghost never attacks; its whole turn is the
-- GetOutText (ExecuteEnemyMove -> PrintGhostText, core.asm:5462-5463)
if self.ghost and not user.isPlayer then
@@ -2238,6 +2278,10 @@ function BattleState:executeAction(user, target, action)
if self:statusInterrupt(user, target) then return end
self:performMove(user, target, action, false)
end
run()
-- after announce/anim/effect text (pokered DrawHUDsAndHPBars)
self:actNext(function() self:syncShownStatus() end)
end
-- Sleep / confusion onomatopoeia from Check*StatusConditions
-- (core.asm): side-specific SLP_*/CONF_* anims, not the Rest/Amnesia
@@ -2732,51 +2776,37 @@ function BattleState:enemyMonFainted()
end
if nextIndex then
self.enemyIndex = nextIndex
-- SHIFT battle style (the default): announce the next mon and
-- offer a free switch (SET skips the prompt)
-- EnemySendOutFirstMon (core.asm:1366-1443): SHIFT offers a free
-- switch when party count > 1, the active mon is alive, and the
-- battle-style bit is clear. pokered counts party slots (not
-- remaining HP); SET / single-mon / fainted active skip the prompt.
local nextMon = self.enemyParty[self.enemyIndex]
local nextName = nextMon.nickname or self.data.pokemon[nextMon.species].name
local style = (self.game.save.options or {}).battleStyle or "shift"
local healthy = 0
for _, mon in ipairs(self.game.save.party) do
if mon.hp > 0 then healthy = healthy + 1 end
end
if style ~= "set" and healthy > 1 and self.player.mon.hp > 0 then
self:say(("%s is\nabout to use\n%s!"):format(self.trainer.name, nextName))
self:say(("Will %s\nchange POKéMON?"):format(self.game.save.player.name))
local game = self.game
self:ui(function()
local ChoiceBox = require("src.ui.ChoiceBox")
return ChoiceBox.new(game, function(yes)
local style = tostring((self.game.save.options or {}).battleStyle or "shift")
:lower()
local partyCount = #self.game.save.party
-- SwitchPlayerMon runs AFTER TrainerSentOutText (core.asm:1436-1443)
local shiftSwitchMon = nil
if style ~= "set" and partyCount > 1 and self.player.mon.hp > 0 then
-- _TrainerAboutToUseText: "X is" / "about to use" then cont nick,
-- then para "Will PLAYER" / "change POKéMON?" with YES/NO.
self:say(("%s is\nabout to use"):format(self.trainer.name))
self:say(("%s!"):format(nextName))
self:sayChoice(
("Will %s\nchange POKéMON?"):format(self.game.save.player.name),
function(yes)
if not yes then return end
local game = self.game
Screens.push(game, "PartyMenu", {
battle = self,
forceSwitch = true,
onSwitch = function(mon)
if mon ~= self.player.mon and mon.hp > 0 then
local previous = self.player
self.player = makeBattler(self.data, mon, true, game.save)
clearTrapping(self.enemy) -- SendOutMon clears foe trap
self:syncSides()
Runtime.emit("battle.battler_switched", {
battle = self, side = self.sides[1],
battler = self.player, previous = previous,
})
self:markParticipant()
self.nextInsert = 0
self.sendingOut = true
self:sayNext(self:sendOutText(self.player.name))
self:animNext("POOF_ANIM", false)
self:actNext(function()
self.sendingOut = false
-- SendOutMon (core.asm:1757-1762): poof, then the grow-in
self:startGrowIn(self.player)
require("src.core.Sound").playCry(self.data, self.player.mon.species)
end)
shiftSwitchMon = mon
end
end,
})
end)
end)
end
self:act(function()
local previous = self.enemy
@@ -2805,6 +2835,29 @@ function BattleState:enemyMonFainted()
end)
end)
end)
-- SwitchPlayerMon after the enemy is out (core.asm:1436-1443)
self:act(function()
local mon = shiftSwitchMon
if not mon then return end
local previous = self.player
self.player = makeBattler(self.data, mon, true, self.game.save)
clearTrapping(self.enemy)
self:syncSides()
Runtime.emit("battle.battler_switched", {
battle = self, side = self.sides[1],
battler = self.player, previous = previous,
})
self:markParticipant()
self.nextInsert = 0
self.sendingOut = true
self:sayNext(self:sendOutText(self.player.name))
self:animNext("POOF_ANIM", false)
self:actNext(function()
self.sendingOut = false
self:startGrowIn(self.player)
require("src.core.Sound").playCry(self.data, self.player.mon.species)
end)
end)
return
end
local prize = (self.trainer.baseMoney or 0) * self.enemy.mon.level
@@ -2928,6 +2981,8 @@ function BattleState:openReplacementMenu()
self:ui(function()
return self:buildScreen("PartyMenu", {
battle = self,
-- ChooseNextMon: pick immediately (no SWITCH/STATS/CANCEL)
forceSwitch = true,
onSwitch = function(mon)
if mon.hp <= 0 then
self:say("There's no will\nto fight!")
@@ -3138,6 +3193,9 @@ end
-- called by BagMenu after an item is used in battle (consumes the turn)
function BattleState:itemUsed(messages)
-- bag cures clear mon.status before the message UI; refresh the HUD
-- once control returns (pokered DrawHUDsAndHPBars after item use)
self:syncShownStatus()
for _, m in ipairs(messages or {}) do self:say(m) end
table.insert(self.queue, { drain = true }) -- potions animate the bar
self:act(function()
@@ -3159,10 +3217,39 @@ function BattleState:ballMissMessage(shakes)
return t._ItemUseBallText04 or "Shoot! It was so\nclose too!"
end
-- AskName (engine/menus/naming_screen.asm): ClearSprites, wild field blank,
-- PrintText, YES/NO while text stays (TextBox opts.choice). Shared by party
-- AddPartyMon and SendNewMonToBox (#172).
function BattleState:askNicknameUI(mon, displayName)
local game = self.game
self.lockedBall = nil
self.blankForAskName = true
local TextBox = require("src.render.TextBox")
local text = ("Do you want to\ngive a nickname\nto %s?"):format(displayName)
local label = game.data.text and game.data.text._DoYouWantToNicknameText
if label then
-- extractor CONT is \t; TextBox scrolls on \n/\v
text = label:gsub("\t", "\n"):gsub("{RAM:?[%w_]*}", displayName)
end
return TextBox.new(game, text, nil, {
choice = function(yes)
self.blankForAskName = false
if not yes then return end
pcall(Screens.push, game, "NamingScreen", {
title = "NICKNAME?", maxLen = 10,
onDone = function(name)
if name and #name > 0 then mon.nickname = name end
end,
})
end,
})
end
-- The caught mon joins the party or a PC box (ItemUseBall .captured,
-- item_effects.asm:518-566): the caught text, then for a NEW species
-- "New POKéDEX data will be added" + the dex entry page, then the
-- party add (with the nickname ask) or the PC transfer text.
-- "New POKéDEX data will be added" + the dex entry page, then
-- AddPartyMon or SendNewMonToBox (both call AskName), then the PC
-- transfer text when the party was full.
function BattleState:storeCaughtMon()
-- ItemUseBall reloads the caught mon via LoadEnemyMonData
-- (item_effects.asm:472-501), regenerating its move list from the
@@ -3182,31 +3269,20 @@ function BattleState:storeCaughtMon()
return self:buildScreen("DexEntryMenu", species)
end)
end
if Party.add(game.save.party, self.enemy.mon) then
-- nickname prompt (AskName runs inside AddPartyMon; box mons are
-- never offered a nickname)
local function askCaughtNickname()
local caught = self.enemy.mon
local enemyName = self.enemy.name
self:uiNext(function()
local ChoiceBox = require("src.ui.ChoiceBox")
local TextBox = require("src.render.TextBox")
return TextBox.new(game, ("Do you want to\ngive a nickname\nto %s?")
:format(enemyName), function()
game.stack:push(ChoiceBox.new(game, function(yes)
if not yes then return end
pcall(Screens.push, game, "NamingScreen", {
title = "NICKNAME?", maxLen = 10,
onDone = function(name)
if name and #name > 0 then caught.nickname = name end
end,
})
end))
end)
return self:askNicknameUI(caught, enemyName)
end)
end
if Party.add(game.save.party, self.enemy.mon) then
askCaughtNickname()
else
destination = "box"
local boxNum = require("src.pokemon.Boxes").deposit(game.save, self.enemy.mon)
if boxNum then
askCaughtNickname()
-- _ItemUseBallText07/08 keyed on EVENT_MET_BILL
local pc = (game.save.flags and game.save.flags.EVENT_MET_BILL)
and "BILL's PC" or "someone's PC"
@@ -4041,8 +4117,11 @@ function BattleState:drawHUDs(slide)
local barData = self:colorMode() and {} or self.data -- gray fill when zoned
local fx = self.fx
local hudShake = (fx and fx.hudShakeX) or 0
-- FaintEnemyPokemon clears the enemy HUD area; it stays blank through
-- TrainerAboutToUseText until DrawEnemyHUDAndHPBar after the next send-out
if self.enemy and not self.showEnemyTrainer and not self.enemySendingOut
and not self:growInScale(self.enemy) and slide == 0 then
and not self:growInScale(self.enemy) and slide == 0
and not self.enemy.fainted then
-- enemy HUD (DrawEnemyHUDAndHPBar): name row 0, <LV>+level (4,1),
-- HP bar (2,2) with the vertical tick at (1,2), underline row 3;
-- AnimationShakeEnemyHUD nudges just this block via SCX
@@ -4052,8 +4131,8 @@ function BattleState:drawHUDs(slide)
end
love.graphics.setColor(0, 0, 0, 1)
Font.draw(self.enemy.name, nameX(1, self.enemy.name), 0)
if self.enemy.mon.status then
Font.draw(self:statusLabel(self.enemy.mon), 40, 8)
if self.enemy.shownStatus then
Font.draw(self:statusLabel({ status = self.enemy.shownStatus }), 40, 8)
else
hudTile(0x6E, 32, 8) -- <LV>
Font.draw(tostring(self.enemy.mon.level), 40, 8)
@@ -4100,8 +4179,8 @@ function BattleState:drawHUDs(slide)
-- the tick at (18,10) and the triangle at (9,11)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(self.player.name, nameX(10, self.player.name), 56)
if self.player.mon.status then
Font.draw(self:statusLabel(self.player.mon), 120, 64)
if self.player.shownStatus then
Font.draw(self:statusLabel({ status = self.player.shownStatus }), 120, 64)
else
hudTile(0x6E, 112, 64) -- <LV>
Font.draw(tostring(self.player.mon.level), 120, 64)
@@ -4207,6 +4286,13 @@ function BattleState:drawTextArea()
end
function BattleState:draw()
-- AskName: ClearSprites + wild ClearScreenArea — white field under the
-- nickname TextBox / YES/NO (naming_screen.asm); overlays draw on top.
if self.blankForAskName then
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
return
end
local fx = self.fx
-- window shakes (SE_SHAKE_SCREEN / the enemy-hit vertical shake);
-- the animations-off fallback keeps the old +-2 alternation
+2
View File
@@ -102,6 +102,8 @@ function Data:seedDefaults()
-- after-battle rows so Blaine's SetEventRange deactivation and talk
-- after-text work like the other gyms (scripts/CinnabarGym.asm).
self:seedCinnabarGymTrainerHeaders()
-- #189: 1F cabin door order vs rooms map (survey zoom)
require("src.world.SsAnneLayout").apply(self.maps)
end
function Data:seedCinnabarGymTrainerHeaders()
+2
View File
@@ -375,6 +375,8 @@ function RomExtractor:extractMaps()
}
self:tick("Maps", mapIndex, #keys)
end
-- keep the extract ROM-faithful; Data:seedDefaults applies the #189
-- S.S. Anne 1F cabin reorder at load time
self:write("maps", out)
return out
end
+33 -13
View File
@@ -270,10 +270,21 @@ end
-- via USB or a file manager, no runtime permission needed), and this scans
-- it directly through love.filesystem -- already mounted at the physfs
-- root, so no io.* absolute-path handling is needed.
local function scanForRom()
--
-- Only a .gb whose SHA maps to a version that is not yet ready counts as
-- pending. GameActivity always writes the SAF pick to picked_rom.gb, so a
-- naive "first .gb wins" scan would re-import Red when the player tries to
-- add Blue (issue #167).
local function findPendingRom(ready)
for _, name in ipairs(love.filesystem.getDirectoryItems("")) do
if name:lower():match("%.gb$") and love.filesystem.getInfo(name, "file") then
return name
local data = love.filesystem.read(name)
if type(data) == "string" and #data == 1024 * 1024 then
local version = GameVersion.forSha1(sha1(data))
if version and not ready[version] then
return name, data
end
end
end
end
return nil
@@ -439,12 +450,12 @@ function RomImporter.new(onComplete, opts)
self.romName[version] = "pokemon_" .. info.id .. ".gb"
end
-- Android has no native picker until the player copies a ROM into the
-- external folder; import any .gb already there (routed by SHA-1) unless
-- both games are already imported.
-- Android: import a save-dir .gb that is not yet ready (USB drop or a
-- leftover SAF pick), routed by SHA-1. Already-imported carts are skipped
-- so a stale picked_rom.gb cannot block the opposite version.
if android and not (self.ready.red and self.ready.blue) then
local name = scanForRom()
if name then self:startData(love.filesystem.read(name), name) end
local name, data = findPendingRom(self.ready)
if name then self:startData(data, name) end
end
-- Mouse-wheel scroll for the save-slot / mods lists. main.lua (off limits)
@@ -480,13 +491,13 @@ end
-- The system picker runs as a separate top activity, so LOVE's own
-- love.focus/love.visible pause while it's up (see main.lua) -- once the
-- player returns here with a file picked, GameActivity has already copied
-- it into the folder scanForRom checks, so a rescan on refocus picks it up
-- without the player needing to tap the button again.
-- it into the save directory, so a pending-ROM rescan on refocus picks it
-- up without the player needing to tap the button again.
function RomImporter:focus(f)
if not (f and self.android and self.workState ~= "working") then return end
if self.ready.red and self.ready.blue then return end
local name = scanForRom()
if name then self:startData(love.filesystem.read(name), name) end
local name, data = findPendingRom(self.ready)
if name then self:startData(data, name) end
end
function RomImporter:setError(message, version)
@@ -586,6 +597,12 @@ function RomImporter:startData(data, displayName)
self.returning[version] = false
self.romName[version] = (displayName
and (displayName:match("[^/\\]+$") or displayName)) or self.romName[version]
-- Android: drop the consumed save-dir .gb (picked_rom.gb or a USB copy)
-- so the next Choose / focus cannot treat it as a fresh pending ROM.
if self.android and type(displayName) == "string"
and not displayName:find("[/\\]") then
love.filesystem.remove(displayName)
end
self.importing = nil
self.workState = "complete"
self.completeVersion = version
@@ -740,9 +757,12 @@ function RomImporter:choose(version)
if self.workState == "working" then return end
self.chooseVersion = version or "red"
if self.android then
local name = scanForRom()
-- Prefer a not-yet-imported .gb already in the save dir (USB copy, or a
-- fresh SAF pick). Never reuse an already-imported cart's file -- that
-- was the #167 failure mode (second Choose just re-extracted Red).
local name, data = findPendingRom(self.ready)
if name then
self:startData(love.filesystem.read(name), name)
self:startData(data, name)
elseif not love.system.pickFile() then
-- Picker unavailable (API < 19, or no document-picker app installed):
-- fall back to the USB folder-drop path as a friendly notice, not an
+4 -3
View File
@@ -114,10 +114,11 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
local itemDef = data.items[itemId]
local name = itemDef and itemDef.name or itemId
-- ItemUseVitamin / ItemUsePPUp / ItemUseEvoStone / ItemUseCoinCase
-- all refuse mid-battle (jp nz, ItemUseNotTime)
-- ItemUseVitamin / ItemUsePPUp / ItemUseEvoStone / ItemUseCoinCase /
-- ItemUseTMHM all refuse mid-battle (jp nz, ItemUseNotTime)
if battle and (VITAMINS[itemId] or STONES[itemId] or itemId == "PP_UP"
or itemId == "RARE_CANDY" or itemId == "COIN_CASE") then
or itemId == "RARE_CANDY" or itemId == "COIN_CASE"
or (itemDef and itemDef.machine)) then
return "failed", { "OAK: " .. save.player.name
.. "!\nThis isn't the\ntime to use that!" }
end
+4
View File
@@ -501,6 +501,10 @@ function LinkState:updateTrade(input)
require("src.core.Sound").play(game.data, "Trade_Machine")
Screens.push(game, "TradeAnim", {
sent = sent, received = received,
enemyName = (self.peerName or "TRAINER"),
playerOt = game.save.player.name,
playerOtId = sent.otId or game.save.player.id,
enemyOtId = received.otId,
onDone = function()
game.stack:push(TextBox.new(game,
("Trade completed!\f%s received\n%s!"):format(game.save.player.name, name),
+28
View File
@@ -29,6 +29,34 @@ function Pokemon.movesAtLevel(speciesDef, level)
return moves
end
-- Day Care retrieve (pokered WriteMonMoves + wLearningMovesFromDayCare):
-- grant learnset moves with startLevel < moveLevel <= newLevel, shifting
-- the oldest slot out when full. Silent — no LearnMove prompts.
function Pokemon.learnMovesFromDayCare(data, mon, speciesDef, startLevel, newLevel)
if not (speciesDef and speciesDef.learnset and mon) then return end
mon.moves = mon.moves or {}
for _, entry in ipairs(speciesDef.learnset) do
local moveLevel = entry.level
if moveLevel > newLevel then break end
if moveLevel > startLevel then
local known = false
for _, mv in ipairs(mon.moves) do
if mv.id == entry.move then known = true break end
end
if not known then
local mdef = data.moves[entry.move]
local slot = { id = entry.move, pp = mdef and mdef.pp or 0 }
if #mon.moves < 4 then
mon.moves[#mon.moves + 1] = slot
else
table.remove(mon.moves, 1)
mon.moves[#mon.moves + 1] = slot
end
end
end
end
end
function Pokemon.new(data, species, level, rng)
local def = data.pokemon[species]
assert(def, "unknown species " .. tostring(species))
+57 -12
View File
@@ -50,6 +50,7 @@ function TextBox.new(game, text, onDone, opts)
self.charIndex = 0
self.shown = {} -- visible lines (max 2), each a list of glyph codes
self.waiting = false
self.contAdvance = false
self.done = false
self.blink = 0
self:beginLine()
@@ -65,7 +66,11 @@ TextBox.TOKENS = {
PLAYER = function(game) return game.save.player.name or "RED" end,
RIVAL = function(game) return game.save.player.rival or "BLUE" end,
RAM = function(game, arg)
return arg == "wStringBuffer" and game.stringBuffer or nil
if arg == "wStringBuffer" then return game.stringBuffer end
if arg == "wBoxNumString" then return game.boxNumString end
-- SendNewMonToBox / _SentToBoxText reads the deposited nick here
if arg == "wBoxMonNicks" then return game.boxMonNicks end
return nil
end,
}
@@ -83,31 +88,55 @@ end
-- Split marked-up text into pages of lines. \v-scrolled lines become
-- additional lines on the same page (the box scrolls them).
-- pages.contBefore[p][i] is true when line i was preceded by \v (cont):
-- pokered ContText waits for A/B + ▼ before scrolling that line in.
function TextBox.paginate(text, maxCols)
maxCols = maxCols or (Theme.textBox and Theme.textBox.maxCols) or MAX_COLS
local pages = {}
for pageText in (text .. "\f"):gmatch("(.-)\f") do
if pageText ~= "" then
local lines = {}
for chunk in (pageText .. "\n"):gmatch("(.-)[\n\v]") do
local line = chunk
-- wrap long lines defensively (the source rarely needs it)
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
-- 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
for pageText in (text .. "\f"):gmatch("(.-)\f") do
if pageText ~= "" then
local lines, conts = {}, {}
local pos, waitNext = 1, false
while true do
local npos = pageText:find("[\n\v]", pos)
if not npos then
pushLine(lines, conts, pageText:sub(pos), waitNext)
break
end
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
if #pages == 0 then pages = { { "" } } end
end
if #pages == 0 then
pages = { { "" } }
contBefore = { { false } }
end
pages.contBefore = contBefore
return pages
end
@@ -179,11 +208,18 @@ function TextBox:update(dt)
if input:wasPressed("a") or input:wasPressed("b") then
require("src.core.Sound").play(self.game.data, "Press_AB")
self.waiting = false
if self.contAdvance then
-- ContText / ManualTextScroll: keep the box, scroll one line
self.contAdvance = false
self.lineIndex = self.lineIndex + 1
self:beginLine()
else
self.shown = {}
self.pageIndex = self.pageIndex + 1
self.lineIndex = 1
self:beginLine()
end
end
return
end
-- typewriter cadence: one character every N frames, N = the OPTION
@@ -203,10 +239,19 @@ function TextBox:update(dt)
-- line finished
local page = self.pages[self.pageIndex]
if self.lineIndex < #page then
self.lineIndex = self.lineIndex + 1
local nextIdx = self.lineIndex + 1
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
self.waiting = true
self.contAdvance = false
else
self.done = true
end
+55 -9
View File
@@ -261,7 +261,9 @@ function Commands.move_player(ctx, dir, tiles)
end
function Commands.move_npc(ctx, objIndex, dir, tiles)
local npc = ctx.overworld:npcByIndex(objIndex)
local ow = ctx.overworld
if not ow then return end
local npc = ow:npcByIndex(objIndex)
if npc then walkEntity(ctx, npc, dir, tiles) end
end
@@ -308,6 +310,7 @@ end
function Commands.move_npc_to(ctx, objIndex, tx, ty)
local ow = ctx.overworld
if not ow then return end
local npc = ow:npcByIndex(objIndex)
if not npc then return end
local path = bfsPath(ow.map, npc.cellX, npc.cellY, tx, ty, ow.entities, npc)
@@ -339,6 +342,20 @@ function Commands.face_object(ctx, objIndex, dir)
if npc then npc.facing = dir end
end
-- Instantly relocate an NPC (OaksLabCalcRivalMovementScript / SetSpritePosition1).
-- facing is optional. Headless-safe no-op without an overworld.
function Commands.place_npc(ctx, objIndex, x, y, facing)
local ow = ctx.overworld
if not ow then return end
local npc = ow:npcByIndex(objIndex)
if not npc then return end
npc.cellX, npc.cellY = x, y
npc.px, npc.py = x * 16, y * 16
npc.moving = false
npc.targetX, npc.targetY = nil, nil
if facing then npc.facing = facing end
end
function Commands.face_npc(ctx)
-- make the player face the talking NPC
if ctx.npc and ctx.overworld then
@@ -485,7 +502,7 @@ function Commands.heal_party(ctx)
end
-- AskName (engine/menus/naming_screen.asm): yes/no then NamingScreen.
-- AddPartyMon only offers this for party mons (box deposits skip it).
-- AddPartyMon and SendNewMonToBox both offer this.
-- Preserves ctx.lastCheck so GivePokemon's carry is not clobbered by the
-- yes/no result (scripts jump_if_false on give failure afterwards).
local function askNickname(ctx, mon)
@@ -527,8 +544,9 @@ end
-- the asm's carry: true when the mon was given, false when both the
-- party and every box are full (that .boxFull path leaves the giver's
-- script able to offer again later, e.g. the Celadon Eevee ball).
-- Party adds run AskName (AddPartyMon) when a script runner is present;
-- mods that pre-set gift.nickname skip the prompt.
-- AskName runs for party (AddPartyMon) and box (SendNewMonToBox) when a
-- script runner is present; mods that pre-set gift.nickname skip it.
-- Box deposits also print SentToBoxText (give_pokemon.asm:36-37).
function Commands.give_pokemon(ctx, species, level)
-- Native mods can transform a gift before the Pokémon object is created.
-- This is intentionally an event rather than a special-case starter hook:
@@ -540,14 +558,17 @@ function Commands.give_pokemon(ctx, species, level)
end
local Pokemon = require("src.pokemon.Pokemon")
local Party = require("src.pokemon.Party")
local Boxes = require("src.pokemon.Boxes")
local mon = Pokemon.new(ctx.game.data, species, level)
if gift.nickname then mon.nickname = gift.nickname end
ctx.game.stringBuffer = ctx.game.data.pokemon[species].name or species
ctx.pendingPokemonName = species
require("src.battle.BattleState").stampOT(ctx.save, mon)
local addedToParty = Party.add(ctx.save.party, mon)
local boxNum = nil
if not addedToParty then
if not require("src.pokemon.Boxes").deposit(ctx.save, mon) then
boxNum = Boxes.deposit(ctx.save, mon)
if not boxNum then
ctx.lastCheck = false
return
end
@@ -558,11 +579,30 @@ function Commands.give_pokemon(ctx, species, level)
dex.owned[species] = true
end
ctx.lastCheck = true
-- AddPartyMon AskName: party only; skip box deposits, mod-set nicknames,
-- and callback-style callers that have no script runner to yield on.
if addedToParty and not gift.nickname and ctx.runner then
ctx.addedToParty = addedToParty
ctx.boxNum = boxNum
-- AskName: both AddPartyMon and SendNewMonToBox; skip mod-set nicks
-- and callback-style callers with no script runner to yield on.
if not gift.nickname and ctx.runner then
askNickname(ctx, mon)
end
if boxNum then
local name = mon.nickname
or (ctx.game.data.pokemon[species] and ctx.game.data.pokemon[species].name)
or species
ctx.game.boxMonNicks = name
ctx.game.stringBuffer = tostring(boxNum)
if ctx.runner then
Commands.show_text(ctx, "_SentToBoxText")
else
local TextBox = require("src.render.TextBox")
local raw = ctx.game.data.text and ctx.game.data.text._SentToBoxText
ctx.game.stack:push(TextBox.new(ctx.game, raw or (
"There's no more\nroom for POKéMON!\v" .. name
.. " was\vsent to POKéMON\vBOX " .. boxNum .. " on PC!"),
function() end))
end
end
end
function Commands.give_money(ctx, amount)
@@ -783,6 +823,8 @@ function Commands.trade(ctx, tradeIndex, doneFlag)
local newMon = Pokemon.new(data, trade.get, sent.level)
newMon.nickname = trade.nickname
newMon.traded = true -- boosted exp + Name Rater refusal (different OT)
newMon.ot = "TRAINER" -- InGameTrade_TrainerString
newMon.otId = (love.math and love.math.random or math.random)(0, 65535)
table.remove(party, slot)
table.insert(party, newMon)
local dex = ctx.save.pokedex
@@ -790,9 +832,13 @@ function Commands.trade(ctx, tradeIndex, doneFlag)
dex.seen[trade.get] = true
dex.owned[trade.get] = true
end
-- the trade machine animation (engine/movie/trade.asm)
-- InternalClockTradeAnim (engine/movie/trade.asm)
Screens.push(ctx.game, "TradeAnim", {
sent = sent, received = newMon,
enemyName = "TRAINER",
playerOt = ctx.save.player.name,
playerOtId = sent.otId or ctx.save.player.id,
enemyOtId = newMon.otId,
onDone = function() runner:resume() end,
})
runner:yield()
+6
View File
@@ -331,6 +331,12 @@ end
local function useItem(game, battle, id, list)
local def = game.data.items[id]
-- ItemUseTMHM checks wIsInBattle before BootedUpTMText
if battle and def and def.machine then
local _, payload = ItemEffects.use(game.data, game.save, id, nil, battle)
showMessages(game, payload)
return
end
if ItemEffects.needsTarget(id, def) and not ItemEffects.isBall(id) then
-- TMs/HMs boot up and announce their move before the target picker
-- (ItemUseTMHM: BootedUpTMText / BootedUpHMText + TeachMachineMoveText)
+98 -21
View File
@@ -3,9 +3,11 @@
-- plus CHANGE BOX.
local Boxes = require("src.pokemon.Boxes")
local Font = require("src.render.Font")
local ListMenu = require("src.ui.ListMenu")
local Menu = require("src.ui.Menu")
local Party = require("src.pokemon.Party")
local TextBox = require("src.render.TextBox")
local BoxMenu = {}
@@ -14,6 +16,11 @@ local function monLabel(game, mon)
return ("%s :L%d"):format(mon.nickname or def.name, mon.level)
end
local function monName(game, mon)
local def = game.data.pokemon[mon.species]
return mon.nickname or def.name
end
-- Per-mon submenu (bills_pc.asm DisplayDepositWithdrawMenu): the chosen
-- action + STATS + CANCEL. STATS shows the status screen and returns
-- here; CANCEL/B goes back to the list.
@@ -31,8 +38,26 @@ local function monSubmenu(game, action, mon, onAction)
}, { tx = 9, ty = 10, tw = 11, th = 8, noSound = true }))
end
-- After a successful transfer: close the mon list (BoxMenu stays beneath
-- via keepOpen) and show the taken-out / stored text (jp BillsPCMenu).
local function afterTransfer(game, list, text)
list:close()
game.stack:push(TextBox.new(game, text))
end
local function withdraw(game)
local box = Boxes.active(game.save)
local t = game.data.text
if #box == 0 then
game.stack:push(TextBox.new(game, t._NoMonText
or "What? There are\nno POKéMON here!"))
return
end
if #game.save.party >= Party.MAX then
game.stack:push(TextBox.new(game, t._CantTakeMonText
or "You can't take\nany more POKéMON.\fDeposit POKéMON\nfirst."))
return
end
local items = {}
for i, mon in ipairs(box) do
table.insert(items, { label = monLabel(game, mon), value = i })
@@ -49,13 +74,29 @@ local function withdraw(game)
end
table.remove(box, item.value)
table.insert(game.save.party, mon)
list:close()
local name = monName(game, mon)
game.stringBuffer = name
require("src.core.Sound").playCry(game.data, mon.species)
afterTransfer(game, list, t._MonIsTakenOutText
or (name .. " is\ntaken out.\vGot " .. name .. "."))
end)
end,
}))
end
local function deposit(game)
local t = game.data.text
if #game.save.party <= 1 then
game.stack:push(TextBox.new(game, t._CantDepositLastMonText
or "You can't deposit\nthe last POKéMON!"))
return
end
local box = Boxes.active(game.save)
if #box >= Boxes.CAPACITY then
game.stack:push(TextBox.new(game, t._BoxFullText
or "Oops! This Box is\nfull of POKéMON."))
return
end
local items = {}
for i, mon in ipairs(game.save.party) do
table.insert(items, { label = monLabel(game, mon), value = i })
@@ -69,40 +110,50 @@ local function deposit(game)
list.footer = "You need at least\none POKéMON!"
return
end
local box = Boxes.active(game.save)
if #box >= Boxes.CAPACITY then
local active = Boxes.active(game.save)
if #active >= Boxes.CAPACITY then
list.footer = ("BOX %d is full!"):format(game.save.currentBox)
return
end
table.remove(game.save.party, item.value)
table.insert(box, mon)
list:close()
table.insert(active, mon)
local name = monName(game, mon)
game.stringBuffer = name
game.boxNumString = tostring(game.save.currentBox)
require("src.core.Sound").playCry(game.data, mon.species)
afterTransfer(game, list, t._MonWasStoredText
or (name .. " was\nstored in Box " .. game.boxNumString .. "."))
end)
end,
}))
end
-- RELEASE POKéMON (bills_pc.asm .release): confirm, then "Bye [MON]!"
-- RELEASE POKéMON (bills_pc.asm BillsPCRelease): confirm, then "Bye [MON]!".
-- Index by list.index (not stale item.value) after removeCurrent (#171).
local function release(game)
local box = Boxes.active(game.save)
local t = game.data.text
if #box == 0 then
game.stack:push(TextBox.new(game, t._NoMonText
or "What? There are\nno POKéMON here!"))
return
end
local items = {}
for i, mon in ipairs(box) do
table.insert(items, { label = monLabel(game, mon), value = i })
end
game.stack:push(ListMenu.new(game,
("BOX %d (RELEASE)"):format(game.save.currentBox), items, {
onChoose = function(item, list)
local mon = box[item.value]
onChoose = function(_, list)
local mon = box[list.index]
if not mon then return end
local def = game.data.pokemon[mon.species]
local name = mon.nickname or def.name
local name = monName(game, mon)
local ChoiceBox = require("src.ui.ChoiceBox")
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game,
"Once released,\n" .. name .. " is\ngone forever. OK?", function()
game.stack:push(ChoiceBox.new(game, function(yes)
if not yes then return end
table.remove(box, item.value)
table.remove(box, list.index)
require("src.core.Sound").playCry(game.data, mon.species)
game.stack:push(TextBox.new(game,
("%s was\nreleased outside.\fBye %s!"):format(name, name)))
@@ -129,7 +180,6 @@ local function changeBox(game)
-- the original asks BEFORE switching ("When you change a #MON
-- BOX, data will be saved. OK?"); declining aborts the change
local ChoiceBox = require("src.ui.ChoiceBox")
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game,
"When you change a\nPOKéMON BOX, data\nwill be saved. OK?", function()
game.stack:push(ChoiceBox.new(game, function(yes)
@@ -143,21 +193,48 @@ local function changeBox(game)
}))
end
-- bills_pc.asm BillsPCMenu chrome: What? text box + BOX No. overlay
local function drawChrome(game)
Font.drawBox(0, 12, 20, 6)
love.graphics.setColor(0, 0, 0, 1)
Font.draw("What?", 8, 112)
Font.drawBox(9, 14, 11, 4)
love.graphics.setColor(0, 0, 0, 1)
Font.draw("BOX No.", 80, 128)
local n = game.save.currentBox or 1
if n >= 10 then
Font.draw(tostring(n), 136, 128)
else
Font.draw(tostring(n), 144, 128)
end
love.graphics.setColor(1, 1, 1, 1)
end
function BoxMenu.new(game)
Boxes.ensure(game.save)
-- bills_pc.asm BillsPCMenu: TextBoxBorder at (0,0) with interior
-- 12x10 → total 14x12. "CHANGE BOX" is 10 tiles and needs the
-- full interior (cursor col + label); the old tw=12 right-side box
-- drew the final glyph on the border.
return Menu.new(game, {
{ label = "WITHDRAW", onSelect = function() withdraw(game) end },
{ label = "DEPOSIT", onSelect = function() deposit(game) end },
{ label = "RELEASE", onSelect = function() release(game) end },
{ label = "CHANGE BOX", onSelect = function() changeBox(game) end },
-- 12x10 → total 14x12. "CHANGE BOX" / "WITHDRAW <PK><MN>" need the
-- full interior (cursor col + label). keepOpen so WITHDRAW/DEPOSIT/
-- RELEASE/CHANGE BOX leave this menu underneath (jp BillsPCMenu).
local menu = Menu.new(game, {
{ label = "WITHDRAW <PK><MN>", keepOpen = true,
onSelect = function() withdraw(game) end },
{ label = "DEPOSIT <PK><MN>", keepOpen = true,
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!" },
-- Bill's PC runs silent end to end (BIT_NO_MENU_BUTTON_SOUND,
-- engine/menus/pokemon_pc.asm)
}, { tx = 0, ty = 0, tw = 14, th = 12, noSound = true })
local baseDraw = menu.draw
function menu:draw()
baseDraw(self)
drawChrome(game)
end
return menu
end
return BoxMenu
+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 Theme = require("src.ui.Theme")
@@ -14,6 +14,11 @@ function ChoiceBox.new(game, onChoose, opts)
self.index = (opts and opts.defaultNo) and 2 or 1
-- BIT_NO_MENU_BUTTON_SOUND: PC-session prompts stay silent
self.noSound = (opts and opts.noSound) or false
local box = Theme.choiceBox
self.tx = (opts and opts.tx) or box.tx
self.ty = (opts and opts.ty) or box.ty
self.tw = (opts and opts.tw) or box.tw
self.th = (opts and opts.th) or box.th
return self
end
@@ -38,13 +43,13 @@ function ChoiceBox:update(dt)
end
function ChoiceBox:draw()
local box = Theme.choiceBox
Font.drawBox(box.tx, box.ty, box.tw, box.th)
local tx, ty, tw, th = self.tx, self.ty, self.tw, self.th
Font.drawBox(tx, ty, tw, th)
love.graphics.setColor(0, 0, 0, 1)
Font.draw("YES", (box.tx + 2) * 8, (box.ty + 1) * 8)
Font.draw("NO", (box.tx + 2) * 8, (box.ty + 3) * 8)
Font.drawCode(Theme.cursor, (box.tx + 1) * 8,
(box.ty + (self.index == 1 and 1 or 3)) * 8)
Font.draw("YES", (tx + 2) * 8, (ty + 1) * 8)
Font.draw("NO", (tx + 2) * 8, (ty + 3) * 8)
Font.drawCode(Theme.cursor, (tx + 1) * 8,
(ty + (self.index == 1 and 1 or 3)) * 8)
love.graphics.setColor(1, 1, 1, 1)
end
+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
-- clear them (DisplayPokemartDialogue_'s screen)
self.dialogue = opts.dialogue
-- PC item lists (players_pc.asm): PrintListMenuEntries shows 4 names
-- and PrintText footers ("How many?", stored/withdrew) use the standard
-- bottom text box — same row budget as the mart, without the money box.
self.messageBox = opts.messageBox
self.money = opts.money -- () -> current money for the box
self.rows = opts.dialogue and 4 or ROWS
self.rows = opts.rows or ((opts.dialogue or opts.messageBox) and 4 or ROWS)
return self
end
@@ -137,8 +141,10 @@ function ListMenu:draw()
love.graphics.setColor(0, 0, 0, 1)
local money = ("¥%d"):format(self.money and self.money() or 0)
Font.draw(money, 152 - Font.width(money), 8)
-- the clerk's line in the standard bottom text box; long prompts
-- wrap and keep their last two lines, like the GB's scrolled box
end
if self.dialogue or (self.messageBox and self.footer) then
-- standard bottom text box (PrintText); long prompts wrap and keep
-- their last two lines, like the GB's scrolled box (#115/#174)
Font.drawBox(0, 12, 20, 6)
love.graphics.setColor(0, 0, 0, 1)
if self.footer then
@@ -153,8 +159,7 @@ function ListMenu:draw()
end
end
elseif self.footer then
-- PC deposit/withdraw footers use "\n"; draw the last two lines so
-- long item names are not clipped at the screen edge (#115).
-- bare footer (bag money line, etc.)
local flat = {}
for _, page in ipairs(require("src.render.TextBox").paginate(self.footer)) do
for _, line in ipairs(page) do flat[#flat + 1] = line end
+23 -11
View File
@@ -22,6 +22,8 @@ function MoveLearnMenu.new(game, mon, newMoveId, onDone)
self.newMoveId = newMoveId
self.onDone = onDone
self.index = 1
-- forget-list UI only after TryingToLearn YES (learn_move.asm .loop)
self.selecting = false
return self
end
@@ -31,24 +33,32 @@ end
-- TryingToLearnText + yes/no (learn_move.asm TryingToLearn): NO offers
-- AbandonLearning, whose own NO loops back here (DontAbandonLearning).
-- Use TextBox opts.choice so YES/NO overlays the still-visible prompt;
-- pushing ChoiceBox from onDone pops the text first and leaves YES/NO
-- on "Which move should be forgotten?" (#173).
function MoveLearnMenu:enter()
local TextBox = require("src.render.TextBox")
local ChoiceBox = require("src.ui.ChoiceBox")
local game = self.game
local mdef = game.data.moves[self.newMoveId]
local name = self:monName()
self.selecting = false
game.stack:push(TextBox.new(game,
("%s is\ntrying to learn\v%s!\fBut, %s\ncan't learn more\vthan 4 moves!\f")
:format(name, mdef.name, name) ..
("Delete an older\nmove to make room\vfor %s?"):format(mdef.name),
function()
game.stack:push(ChoiceBox.new(game, function(yes)
if not yes then self:confirmAbandon() end
end))
end))
nil, {
choice = function(yes)
if yes then
self.selecting = true
else
self:confirmAbandon()
end
end,
}))
end
function MoveLearnMenu:update(dt)
if not self.selecting then return end
local input = self.game.input
local n = #self.mon.moves + 1 -- moves + CANCEL
if input:wasPressed("up") then
@@ -82,15 +92,15 @@ end
-- (DontAbandonLearning)
function MoveLearnMenu:confirmAbandon()
local TextBox = require("src.render.TextBox")
local ChoiceBox = require("src.ui.ChoiceBox")
local game = self.game
local mdef = game.data.moves[self.newMoveId]
self.selecting = false
game.stack:push(TextBox.new(game,
("Abandon learning\n%s?"):format(mdef.name), function()
game.stack:push(ChoiceBox.new(game, function(yes)
("Abandon learning\n%s?"):format(mdef.name), nil, {
choice = function(yes)
if yes then self:finish(false) else self:enter() end
end))
end))
end,
}))
end
function MoveLearnMenu:finish(learned)
@@ -98,6 +108,7 @@ function MoveLearnMenu:finish(learned)
local game = self.game
local name = self:monName()
local mdef = game.data.moves[self.newMoveId]
self.selecting = false
game.stack:pop()
local msg
if learned then
@@ -114,6 +125,7 @@ function MoveLearnMenu:finish(learned)
end
function MoveLearnMenu:draw()
if not self.selecting then return end
-- single-spaced move list box (TryingToLearn: TextBoxBorder at 4,7)
-- plus the port's extra CANCEL row
Font.drawBox(4, 5, 16, 7)
+28 -6
View File
@@ -1,8 +1,11 @@
-- Party menu: list the party, choose a member.
-- Modes:
-- default: A -> submenu (STATS / SWITCH order / CANCEL)
-- opts.onSwitch: A -> hand the chosen mon to the callback (battle
-- switch, item targeting via opts.pickOnly)
-- default: A -> submenu (STATS / SWITCH / field moves)
-- opts.onSwitch + opts.battle (voluntary PKMN): A -> SWITCH / STATS /
-- CANCEL (core.asm PartyMenuOrRockOrRun), then onSwitch on SWITCH
-- opts.onSwitch + opts.forceSwitch: A -> onSwitch immediately
-- (ChooseNextMon / SHIFT free-switch)
-- opts.pickOnly + opts.onSwitch: A -> onSwitch (item / script target)
-- opts.onCancel: fired when the menu closes without a pick (B)
-- Pops itself on B.
@@ -125,6 +128,7 @@ function PartyMenu.new(game, opts)
self.onSwitch = opts.onSwitch
self.onCancel = opts.onCancel
self.pickOnly = opts.pickOnly
self.forceSwitch = opts.forceSwitch
self.battle = opts.battle
self.party = opts.party -- link battles pass their clamped copies
self.swapFrom = nil
@@ -156,7 +160,17 @@ function PartyMenu:update(dt)
-- hook-injected entries carry a callback instead of an action id
entry.onSelect(mon, self.game)
elseif action == "stats" then
-- battle and field alike return to the party list afterwards
-- (core.asm .partyMenuWasSelected)
Screens.push(self.game, "SummaryMenu", mon)
elseif action == "battle_switch" then
self.game.stack:pop()
self.onSwitch(mon)
return
elseif action == "cancel" then
self.game.stack:pop()
if self.onCancel then self.onCancel() end
return
elseif action == "switch" then
self.swapFrom = self.index
elseif action == "fly" then
@@ -341,17 +355,24 @@ function PartyMenu:update(dt)
require("src.core.Sound").play(self.game.data, "Swap")
end
self.swapFrom = nil
elseif self.onSwitch then
elseif self.onSwitch and (self.forceSwitch or self.pickOnly or not self.battle) then
self.game.stack:pop()
self.onSwitch(mon)
else
self.submenu = true
self.subIndex = 1
local items
local ow = self.game.overworld
if self.battle and self.onSwitch then
-- SwitchStatsCancelText (core.asm PartyMenuOrRockOrRun)
items = { { label = "SWITCH", action = "battle_switch" },
{ label = "STATS", action = "stats" },
{ label = "CANCEL", action = "cancel" } }
else
-- STATS/SWITCH plus this mon's field moves (start_sub_menus.asm
-- builds the same dynamic list)
local items = { { label = "STATS", action = "stats" },
items = { { label = "STATS", action = "stats" },
{ label = "SWITCH", action = "switch" } }
local ow = self.game.overworld
-- Field moves (HMs/TMs) are usable out of battle even when the mon
-- is fainted -- Gen 1 does not require HP for Cut/Fly/Surf/etc.
-- Battle still excludes this list via `not self.battle`. Softboiled
@@ -396,6 +417,7 @@ function PartyMenu:update(dt)
end
end
end
end
local ctx = { battle = self.battle, overworld = ow }
local hooked = Runtime.call("ui.party.submenu", sameItems,
self.game, items, mon, ctx)
+6 -3
View File
@@ -71,6 +71,7 @@ end
local function withdraw(game)
local pc = game.save.pcItems
game.stack:push(ListMenu.new(game, "WITHDRAW ITEM", buildItems(game, pc), {
messageBox = true,
onChoose = function(item, list)
askQuantity(game, list, pc[item.value] or 1, item.value, function(qty)
local Bag = require("src.inventory.Bag")
@@ -107,6 +108,7 @@ local function deposit(game)
if not Bag.isBadge(id) then depositable[id] = count end
end
game.stack:push(ListMenu.new(game, "DEPOSIT ITEM", buildItems(game, depositable), {
messageBox = true,
onChoose = function(item, list)
askQuantity(game, list, inv[item.value] or 1, item.value, function(qty)
if pcFull(game, pc, item.value) then
@@ -126,6 +128,7 @@ end
local function toss(game)
local pc = game.save.pcItems
game.stack:push(ListMenu.new(game, "TOSS ITEM", buildItems(game, pc), {
messageBox = true,
onChoose = function(item, list)
local def = game.data.items[item.value]
if (def and def.keyItem) or item.value:find("^HM_") then
@@ -161,9 +164,9 @@ function PlayerPC.new(game)
{ label = "DEPOSIT ITEM", onSelect = function() deposit(game) end },
{ label = "TOSS ITEM", onSelect = function() toss(game) end },
{ label = "LOG OFF" },
-- the whole PC session runs silent (BIT_NO_MENU_BUTTON_SOUND,
-- engine/menus/players_pc.asm PlayersPCMenu)
}, { tx = 3, ty = 0, tw = 17, th = 10, noSound = true })
-- silent PC session (BIT_NO_MENU_BUTTON_SOUND); players_pc.asm
-- PlayersPCMenu TextBoxBorder (0,0) b=8 c=14 → 16x10
}, { tx = 0, ty = 0, tw = 16, th = 10, noSound = true })
end
return PlayerPC
+8 -4
View File
@@ -40,15 +40,19 @@ function QuantityBox:update(dt)
end
function QuantityBox:draw()
local w = self.unitPrice and 11 or 7
local tx = 20 - w - 1
Font.drawBox(tx, 13, w, 3)
-- DisplayChooseQuantityMenu (home/list_menu.asm): non-priced box at
-- hlcoord 15,9 (interior 3x1); priced at hlcoord 7,9 (interior 11x1).
-- TextBoxBorder adds the frame, so outer size is +2 on each axis.
local tw = self.unitPrice and 13 or 5
local tx = self.unitPrice and 7 or 15
local ty = 9
Font.drawBox(tx, ty, tw, 3)
love.graphics.setColor(0, 0, 0, 1)
local s = ("×%02d"):format(self.qty) -- the multiply glyph tile
if self.unitPrice then
s = s .. (" ¥%d"):format(self.qty * self.unitPrice)
end
Font.draw(s, (tx + 1) * 8, 14 * 8)
Font.draw(s, (tx + 1) * 8, (ty + 1) * 8)
love.graphics.setColor(1, 1, 1, 1)
end
+2 -1
View File
@@ -15,7 +15,8 @@ local Theme = {
cols = Renderer.WIDTH / 8,
rows = Renderer.HEIGHT / 8,
textBox = { tx = 0, ty = 12, tw = 20, th = 6, maxCols = 18 },
choiceBox = { tx = 0, ty = 7, tw = 6, th = 5 },
-- InitYesNoTextBoxParameters / AskName: hlcoord 14, 7 (YES_NO_MENU 4x3)
choiceBox = { tx = 14, ty = 7, tw = 6, th = 5 },
}
function Theme.load(data)
+424 -42
View File
@@ -1,8 +1,7 @@
-- Link/in-game trade cinematic (engine/movie/trade.asm, trade2.asm):
-- the traded POKéMON rises away with its cry and a goodbye, then the
-- received one descends with its cry and "take good care" text.
-- A skips the slide animations ahead. Calls onDone() after popping.
-- InternalClockTradeAnim (engine/movie/trade.asm): cable-trade cinematic
-- used by in-game NPC trades and internally-clocked link trades.
local Font = require("src.render.Font")
local Sound = require("src.core.Sound")
local TextBox = require("src.render.TextBox")
@@ -10,13 +9,22 @@ local TradeAnim = {}
TradeAnim.__index = TradeAnim
TradeAnim.isOpaque = true
-- SGB: generic whole-screen palette (SET_PAL_GENERIC)
function TradeAnim:sgbPalettes(game)
return require("src.render.PaletteFX").wholeNamed(game.data, "MEWMON")
end
local SLIDE_FRAMES = 90
local REST_Y = 44 -- resting top of the sprite, roughly screen centre
local DEFAULT_ART = {
gameBoy = "assets/generated/trade/game_boy.png",
openCable = "assets/generated/trade/open_cable.png",
cableHoriz = "assets/generated/trade/cable_horiz.png",
cableConn = "assets/generated/trade/cable_conn.png",
cableVert = "assets/generated/trade/cable_vert.png",
cableCorner = "assets/generated/trade/cable_corner.png",
cableEnd = "assets/generated/trade/cable_end.png",
cableBall = "assets/generated/trade/cable_ball.png",
cableBallAlt = "assets/generated/trade/cable_ball_alt.png",
bubble = "assets/generated/trade/bubble.png",
}
local function tryImage(path)
if not path then return nil end
@@ -29,11 +37,46 @@ local function nameOf(game, mon)
return mon.nickname or (def and def.name) or mon.species
end
local function speciesName(game, mon)
local def = game.data.pokemon[mon.species]
return (def and def.name) or mon.species
end
local function dexOf(game, mon)
local def = game.data.pokemon[mon.species]
return def and def.dex or 0
end
local function spriteOf(game, mon)
local def = game.data.pokemon[mon.species]
return tryImage(def and def.spriteFront)
end
local function expand(game, key, subs)
local raw = game.data.text and game.data.text[key]
if not raw then return key end
for token, value in pairs(subs or {}) do
raw = raw:gsub("{" .. token .. "}", value)
end
return TextBox.substitute(game, raw)
end
-- InternalClockTradeFuncSequence
local SEQ = {
"show_player",
"open_cable",
"ball_enter",
"transfer_lr",
"delay",
"went_to",
"for_sends",
"farewell",
"transfer_rl",
"open_cable2",
"show_enemy",
"done",
}
function TradeAnim.new(game, opts)
opts = opts or {}
local self = setmetatable({}, TradeAnim)
@@ -41,62 +84,401 @@ function TradeAnim.new(game, opts)
self.sent = opts.sent
self.received = opts.received
self.onDone = opts.onDone
self.enemyName = opts.enemyName or (self.received and self.received.ot) or "TRAINER"
self.playerName = (game.save.player and game.save.player.name) or "RED"
self.playerOt = opts.playerOt or self.playerName
self.playerOtId = opts.playerOtId
or (self.sent and self.sent.otId)
or (game.save.player and game.save.player.id)
or 0
self.enemyOtId = opts.enemyOtId
or (self.received and self.received.otId)
or love.math.random(0, 65535)
local art = (game.data.field and game.data.field.tradeArt) or DEFAULT_ART
self.img = {
gameBoy = tryImage(art.gameBoy or DEFAULT_ART.gameBoy),
openCable = tryImage(art.openCable or DEFAULT_ART.openCable),
cableHoriz = tryImage(art.cableHoriz or DEFAULT_ART.cableHoriz),
cableConn = tryImage(art.cableConn or DEFAULT_ART.cableConn),
cableVert = tryImage(art.cableVert or DEFAULT_ART.cableVert),
cableCorner = tryImage(art.cableCorner or DEFAULT_ART.cableCorner),
cableEnd = tryImage(art.cableEnd or DEFAULT_ART.cableEnd),
cableBall = tryImage(art.cableBall or DEFAULT_ART.cableBall),
cableBallAlt = tryImage(art.cableBallAlt or DEFAULT_ART.cableBallAlt),
bubble = tryImage(art.bubble or DEFAULT_ART.bubble),
}
self.sentSprite = spriteOf(game, self.sent)
self.receivedSprite = spriteOf(game, self.received)
self.phase = "out"
self.recvSprite = spriteOf(game, self.received)
self.seq = 1
self.phase = SEQ[1]
self.t = 0
self.scx = 0
self.ballX = 0
self.ballY = 0
self.monX = 0
self.monY = 0
self.flash = false
self.monVisible = true
self.waitingText = false
self.cableFlash = false
return self
end
function TradeAnim:enter()
Sound.playCry(self.game.data, self.sent.species)
Sound.play(self.game.data, "Trade_Machine")
end
function TradeAnim:advance()
self.seq = self.seq + 1
self.phase = SEQ[self.seq] or "done"
self.t = 0
self.scx = 0
self.sub = nil
self.flash = false
self.cableFlash = false
self.poof = nil
if self.phase == "done" then
self.game.stack:pop()
if self.onDone then self.onDone() end
elseif self.phase == "show_enemy" then
self.monVisible = false
elseif self.phase == "transfer_lr" then
-- wBaseCoord $54, $1c OAM -> screen (76, 12)
self.monX, self.monY = 76, 12
elseif self.phase == "transfer_rl" then
-- wBaseCoord $64, $44 OAM -> screen (92, 52), right GB on screen
self.monX, self.monY = 92, 52
self.scx = 160
elseif self.phase == "ball_enter" then
-- lb bc, $20, $60: b = Y, c = X (Trade_AnimateBallEnteringLinkCable)
self.ballX, self.ballY = 0x60, 0x20
elseif self.phase == "open_cable" or self.phase == "open_cable2" then
-- SCX $a0 -> $f0: cable slides in from the right, open end rests at x64
self.scx = 0x50
Sound.play(self.game.data, "Heal_HP")
end
end
function TradeAnim:say(text, delay, thenFn)
self.waitingText = true
self.game.stack:push(TextBox.new(self.game, text, function()
self.waitingText = false
if thenFn then thenFn() else self:advance() end
end, { auto = { delay = delay or 80 } }))
end
function TradeAnim:skipHeld()
return self.game.input:wasPressed("a") or self.game.input:isDown("a")
end
function TradeAnim:update(dt)
local input = self.game.input
if self.phase == "out" or self.phase == "in" then
if self.waitingText or self.phase == "done" then return end
local skip = self.game.input:wasPressed("a")
self.t = self.t + 1
if input:wasPressed("a") then self.t = SLIDE_FRAMES end
if self.t < SLIDE_FRAMES then return end
if self.phase == "out" then
self.phase = "goodbye"
self.game.stack:push(TextBox.new(self.game,
("Goodbye %s!"):format(nameOf(self.game, self.sent)),
function()
self.phase = "in"
local p = self.phase
if p == "show_player" then
-- slide from SCX $86 -> 0, hold 80, poof, cry (Trade_ShowPlayerMon)
if not self.sub then self.sub = "slide" end
if self.sub == "slide" then
self.scx = math.max(0, 0x86 - self.t * 2)
if skip or self.scx <= 0 then
self.scx = 0
self.sub = "hold"
self.t = 0
Sound.playCry(self.game.data, self.received.species)
end))
end
elseif self.sub == "hold" then
if skip or self.t >= 80 then
self.sub = "poof"
self.t = 0
self.monVisible = false
Sound.play(self.game.data, "Ball_Poof")
end
elseif self.sub == "poof" then
self.poof = math.max(0, 16 - self.t)
if skip or self.t >= 16 then
Sound.playCry(self.game.data, self.sent.species)
self.sub = nil
self:advance()
end
end
elseif p == "open_cable" or p == "open_cable2" then
-- 20 steps of 4px, matching the SCX loop
self.scx = math.max(0, 0x50 - self.t * 4)
if skip or self.scx <= 0 then
self.scx = 0
self:advance()
end
elseif p == "ball_enter" then
-- TRADE_BALL_SHAKE then ball rides the cable; X from $60 toward $a0
if self.t < 20 then
if skip then self.t = 20 end
return
end
local step = self.t - 20
if step % 3 == 0 then
self.ballX = 0x60 + math.floor(step / 3) * 4
self.flash = not self.flash
if self.ballX < 0xA0 then Sound.play(self.game.data, "Tink") end
end
if skip then self.ballX = 0xA0 end
if self.ballX >= 0xA0 then self:advance() end
elseif p == "transfer_lr" then
-- scroll left GB off while the mon rides the cable, then the sprite
-- itself moves right and down into the right GB (Trade_AnimMonMoveVertical)
if self.t <= 80 then
self.scx = math.min(160, self.t * 2)
self.monX, self.monY = 76, 12
elseif self.t <= 80 + 32 then
self.monX = 76 + math.floor((self.t - 80) / 8) * 4
elseif self.t <= 80 + 64 then
self.monX = 92
self.monY = 12 + math.floor((self.t - 112) / 8) * 10
else
self.phase = "takecare"
self.game.stack:push(TextBox.new(self.game,
("Take good care\nof %s!"):format(nameOf(self.game, self.received)),
function()
self.game.stack:pop()
if self.onDone then self.onDone() end
end))
self:advance()
return
end
if self.t % 8 == 0 then self.cableFlash = not self.cableFlash end
if skip then self:advance() end
elseif p == "transfer_rl" then
-- mirror: sprite climbs out of the right GB, then the screen scrolls
-- back to the left GB
if self.t <= 32 then
self.scx = 160
self.monX, self.monY = 92, 52 - math.floor(self.t / 8) * 10
elseif self.t <= 64 then
self.monX = 92 - math.floor((self.t - 32) / 8) * 4
self.monY = 12
elseif self.t <= 64 + 80 then
self.scx = math.max(0, 160 - (self.t - 64) * 2)
self.monX, self.monY = 76, 12
else
self:advance()
return
end
if self.t % 8 == 0 then self.cableFlash = not self.cableFlash end
if skip then self:advance() end
elseif p == "delay" then
if skip or self.t >= 100 then self:advance() end
elseif p == "went_to" then
local text = expand(self.game, "_TradeWentToText", {
["RAM:wStringBuffer"] = speciesName(self.game, self.sent),
["RAM:wLinkEnemyTrainerName"] = self.enemyName,
})
self:say(text, 200)
elseif p == "for_sends" then
local a = expand(self.game, "_TradeForText", {
["RAM:wStringBuffer"] = speciesName(self.game, self.sent),
})
local b = expand(self.game, "_TradeSendsText", {
["RAM:wLinkEnemyTrainerName"] = self.enemyName,
["RAM:wNameBuffer"] = nameOf(self.game, self.received),
})
self.waitingText = true
self.game.stack:push(TextBox.new(self.game, a, function()
self.game.stack:push(TextBox.new(self.game, b, function()
self.waitingText = false
self:advance()
end, { auto = { delay = 80 } }))
end, { auto = { delay = 80 } }))
elseif p == "farewell" then
local a = expand(self.game, "_TradeWavesFarewellText", {
["RAM:wLinkEnemyTrainerName"] = self.enemyName,
})
local b = expand(self.game, "_TradeTransferredText", {
["RAM:wNameBuffer"] = nameOf(self.game, self.received),
})
self.waitingText = true
self.game.stack:push(TextBox.new(self.game, a, function()
self.game.stack:push(TextBox.new(self.game, b, function()
self.waitingText = false
self:advance()
end, { auto = { delay = 80 } }))
end, { auto = { delay = 80 } }))
elseif p == "show_enemy" then
if self.t == 1 then
Sound.play(self.game.data, "Ball_Poof")
self.monVisible = true
elseif self.t == 20 then
Sound.playCry(self.game.data, self.received.species)
elseif self.t >= 120 or skip then
local text = expand(self.game, "_TradeTakeCareText", {
["RAM:wNameBuffer"] = nameOf(self.game, self.received),
})
self:say(text, 80)
end
end
end
local function drawCableHoriz(self, y, x0, x1)
if self.img.cableHoriz then
love.graphics.draw(self.img.cableHoriz, x0 - (self.scx % 8), y)
else
love.graphics.setColor(0.2, 0.2, 0.2, 1)
love.graphics.rectangle("fill", x0, y + 1, math.max(0, x1 - x0), 6)
love.graphics.setColor(1, 1, 1, 1)
end
end
function TradeAnim:drawMonInfo(mon, ot, otId, boxTy)
-- Trade_PrintPlayerMonInfoText: rows +0/+2/+4/+6 from the box top; the
-- No. line replaces part of the top border (hlcoord 5, 0 in trade2.asm)
Font.drawBox(4, boxTy, 12, 8)
local y0 = boxTy * 8
local no = ("No.%03d"):format(dexOf(self.game, mon))
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 56, y0, Font.width(no), 8)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(no, 56, y0)
Font.draw(speciesName(self.game, mon), 40, y0 + 16)
Font.draw("OT/" .. (ot or "????"), 40, y0 + 32)
Font.draw(("IDNo.%05d"):format(otId or 0), 40, y0 + 48)
love.graphics.setColor(1, 1, 1, 1)
end
function TradeAnim:drawIconInBubble(mon, x, y)
local spr = (mon.species == self.received.species) and self.recvSprite or self.sentSprite
if spr then
local sw, sh = spr:getDimensions()
local s = 16 / math.max(sw, sh)
love.graphics.draw(spr, x, y, 0, s, s)
else
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("fill", x + 4, y + 4, 8, 8)
love.graphics.setColor(1, 1, 1, 1)
end
if self.img.bubble then
if not self.bubbleQuad then
local iw, ih = self.img.bubble:getDimensions()
self.bubbleQuad = love.graphics.newQuad(0, 0, 16, 16, iw, ih)
self.bubbleQuadAlt = ih >= 32
and love.graphics.newQuad(0, 16, 16, 16, iw, ih)
or self.bubbleQuad
end
local q = self.cableFlash and self.bubbleQuadAlt or self.bubbleQuad
love.graphics.draw(self.img.bubble, q, x - 8, y - 8)
end
end
function TradeAnim:drawGameBoy(x, y)
if self.img.gameBoy then
love.graphics.draw(self.img.gameBoy, x, y)
else
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("line", x, y, 48, 64)
love.graphics.setColor(1, 1, 1, 1)
end
end
function TradeAnim:drawLeftGB()
-- cable from GB to the right edge
if self.img.cableConn then
love.graphics.draw(self.img.cableConn, 88, 32)
end
drawCableHoriz(self, 32, 96, 160)
self:drawGameBoy(40, 24)
Font.drawBox(4, 12, 9, 4)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(self.playerName, 40, 112)
love.graphics.setColor(1, 1, 1, 1)
end
function TradeAnim:drawRightGB()
drawCableHoriz(self, 32, 0, 112)
if self.img.cableCorner then love.graphics.draw(self.img.cableCorner, 112, 32) end
if self.img.cableVert then
for i = 1, 4 do
love.graphics.draw(self.img.cableVert, 120, 40 + (i - 1) * 8)
end
end
if self.img.cableEnd then love.graphics.draw(self.img.cableEnd, 112, 72) end
if self.img.cableConn then love.graphics.draw(self.img.cableConn, 104, 72) end
self:drawGameBoy(56, 64)
Font.drawBox(6, 0, 9, 4)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(self.enemyName, 56, 16)
love.graphics.setColor(1, 1, 1, 1)
end
function TradeAnim:draw()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
local sprite, y
if self.phase == "out" then
-- the sent mon rises up and off the screen
sprite = self.sentSprite
y = REST_Y - math.floor((self.t / SLIDE_FRAMES) * (REST_Y + 60))
elseif self.phase == "in" or self.phase == "takecare" then
-- the received mon descends into place
sprite = self.receivedSprite
local t = self.phase == "in" and self.t or SLIDE_FRAMES
y = -60 + math.floor((t / SLIDE_FRAMES) * (REST_Y + 60))
local p = self.phase
if p == "show_player" then
love.graphics.push()
love.graphics.translate(-self.scx, 0)
-- mon pic on the BG at hlcoord 7, 2; info box on the window at
-- hWY $50, so it sits in the bottom half of the screen
if self.monVisible and self.sentSprite then
love.graphics.draw(self.sentSprite, 56, 16)
end
if sprite and y then
local w = sprite:getWidth()
love.graphics.draw(sprite, math.floor((160 - w) / 2), y)
self:drawMonInfo(self.sent, self.playerOt, self.playerOtId, 10)
love.graphics.pop()
if self.poof and self.poof > 0 then
love.graphics.setColor(0, 0, 0, self.poof / 16)
love.graphics.circle("fill", 80, 40, 20 - (self.poof or 0))
love.graphics.setColor(1, 1, 1, 1)
end
elseif p == "open_cable" or p == "open_cable2" then
love.graphics.push()
love.graphics.translate(self.scx, 0)
if self.img.openCable then
love.graphics.draw(self.img.openCable, 64, 16) -- open end at SCX $f0
end
love.graphics.pop()
elseif p == "ball_enter" then
if self.img.openCable then
love.graphics.draw(self.img.openCable, 64, 16)
end
-- OAM coords carry a (+8, +16) hardware offset
local ball = self.flash and (self.img.cableBallAlt or self.img.cableBall)
or self.img.cableBall
if ball then
love.graphics.draw(ball, self.ballX - 8, self.ballY - 16)
else
love.graphics.setColor(0, 0, 0, 1)
love.graphics.circle("fill", self.ballX, self.ballY - 8, 6)
love.graphics.setColor(1, 1, 1, 1)
end
elseif p == "transfer_lr" or p == "transfer_rl" then
-- two-screen world: left GB scene at x0, right GB scene at x160;
-- the mon icon stays in screen space like the OAM sprite it ports
love.graphics.push()
love.graphics.translate(-self.scx, 0)
self:drawLeftGB()
love.graphics.translate(160, 0)
self:drawRightGB()
love.graphics.pop()
local mon = (p == "transfer_lr") and self.sent or self.received
self:drawIconInBubble(mon, self.monX, self.monY)
if self.cableFlash then
love.graphics.setColor(1, 1, 1, 0.15)
love.graphics.rectangle("fill", 0, 32, 160, 8)
love.graphics.setColor(1, 1, 1, 1)
end
elseif p == "show_enemy" then
if self.monVisible and self.recvSprite then
love.graphics.draw(self.recvSprite, 56, 16)
end
self:drawMonInfo(self.received, self.enemyName, self.enemyOtId, 10)
end
-- went_to / for_sends / farewell / delay: cleared window; TextBox draws
end
return TradeAnim
+7 -7
View File
@@ -34,7 +34,8 @@ function TrainerCard.new(game)
local self = setmetatable({ game = game }, TrainerCard)
local img = tryImage("assets/generated/trainer_card/badges.png")
if img then
-- 8 pairs of [gym leader face, badge]
-- badges.2bpp is 8 stacked [face, badge] pairs (DrawBadges FaceBadgeTiles)
self.faces = { img = img, quads = quads16(img, 8, 32, 0, 0) }
self.badges = { img = img, quads = quads16(img, 8, 32, 0, 16) }
end
local nums = tryImage("assets/generated/trainer_card/badge_numbers.png")
@@ -124,7 +125,7 @@ function TrainerCard:draw()
love.graphics.setColor(0, 0, 0, 1)
end
-- numbered badge grid (rows 11-17): earned solid, unearned dimmed
-- numbered badge grid (rows 11-17): face by default, badge when owned
self:frameBox(0, 11, 20, 7)
local badges = Badges.list(self.game.data)
for i = 1, #badges do
@@ -136,12 +137,11 @@ function TrainerCard:draw()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(self.nums.img, self.nums.quads[i - 1], tx, ty)
end
if self.badges and self.badges.quads[i - 1]
and save.inventory[Badges.itemFor(badges[i])] then
-- unearned badge slots stay blank (DrawBadges)
if self.faces and self.faces.quads[i - 1] then
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(self.badges.img, self.badges.quads[i - 1],
tx + 4, ty + 6)
local owned = save.inventory[Badges.itemFor(badges[i])]
local sheet = owned and self.badges or self.faces
love.graphics.draw(sheet.img, sheet.quads[i - 1], tx + 4, ty + 6)
end
end
love.graphics.setColor(1, 1, 1, 1)
+10
View File
@@ -116,6 +116,16 @@ FieldDefaults.FIELD = {
badgeGates = { ROUTE_22_GATE = { passedFlag = "PASSED_ROUTE22_GATE" } },
-- VermilionGymSetDoorTile opens the motorized door once both locks are hit
hiddenExtras = {
-- PrintTrashText bins (#188); seeds stale caches missing the key
printTrash = {
SS_ANNE_KITCHEN = {
{ x = 13, y = 5, facing = "down" },
{ x = 13, y = 7, facing = "down" },
},
VERMILION_GYM = {
{ x = 6, y = 1, facing = "down" },
},
},
trashCans = { map = "VERMILION_GYM",
doorBlock = { bx = 2, by = 2, block = 5 } },
},
+6
View File
@@ -223,6 +223,12 @@ function Map:setBlock(bx, by, block)
self.def.blocks[by * self.def.width + bx + 1] = block
end
-- true if the cell's collision tile is a door tile
-- (pokered IsPlayerStandingOnDoorTile)
function Map:isDoorTileCell(cx, cy)
return self.doorTiles[self:cellTile(cx, cy)] or false
end
-- true if the cell's collision tile is a door or warp-activating tile
function Map:isWarpTileCell(cx, cy)
local t = self:cellTile(cx, cy)
+55 -32
View File
@@ -718,16 +718,13 @@ function OverworldState:update(dt)
end
if self.healAnim then
local ha = self.healAnim
local Music = require("src.core.Music")
if ha.jinglePlaying and not ha.jingleDone then
ha.jingleDone = not Music.oneShotPlaying()
end
local ev = OverworldState.stepHealAnim(ha)
if ev == "ball" then
require("src.core.Sound").play(Game.data, "Healing_Machine")
elseif ev == "jingle" then
ha.jinglePlaying = Music.playOnce(Game.data, "Music_PkmnHealed")
ha.jingleDone = not ha.jinglePlaying
-- playOnce restores the map theme when the jingle ends; we no longer
-- block the fighting-fit text on that (#157)
require("src.core.Music").playOnce(Game.data, "Music_PkmnHealed")
elseif ev == "done" then
local done = ha.onDone
self.healAnim = nil
@@ -1511,6 +1508,15 @@ function OverworldState:tryHiddenObject(fx, fy)
end
end
-- PrintTrashText: SS Anne kitchen + Vermilion Gym non-puzzle can
for _, h in ipairs(extras.printTrash and extras.printTrash[self.map.id] or {}) do
if h.x == fx and h.y == fy then
Game.stack:push(TextBox.new(Game, txt._VermilionGymTrashText
or "Nope, there's\nonly trash here."))
return true
end
end
-- the Vermilion Gym trash can lock puzzle
if self.map.id == "VERMILION_GYM" then
for _, h in ipairs(extras.trashCans.cans or {}) do
@@ -2190,13 +2196,10 @@ function OverworldState:dexRating()
Game.stack:push(TextBox.new(Game, completion .. "\f" .. rating))
end
-- AnimateHealingMachine (engine/overworld/healing_machine.asm): the
-- monitor lights, then one ball per party mon appears every 30 frames
-- (SFX_HEALING_MACHINE each); the healed jingle plays while the machine
-- sprites flash 8 times (an OBP1 xor every 10 frames), then a 32-frame
-- beat once the jingle ends. Pure per-frame step over the ha table
-- ({ balls, lit, timer, visible, jingleDone }); returns "ball"/"jingle"/
-- "done" when the caller must fire the matching side effect.
-- AnimateHealingMachine (engine/overworld/healing_machine.asm): balls
-- every 30 frames, then jingle + FlashSprite8Times (8 x 10). #157: skip
-- pokered's post-flash .waitLoop2 / DelayFrames 32 so fighting-fit is
-- immediate; jingle still plays and restoreMap runs when it ends.
function OverworldState.stepHealAnim(ha)
ha.timer = ha.timer + 1
ha.phase = ha.phase or "balls"
@@ -2219,19 +2222,13 @@ function OverworldState.stepHealAnim(ha)
ha.visible = not ha.visible
ha.flashes = ha.flashes + 1
if ha.flashes >= 8 then
ha.phase = "wait"
ha.visible = true
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
ha.phase = "done"
return "done"
end
end
end
end
-- Nurse dialogue uses the real engine strings (data/text/text_4.asm via
-- engine/events/pokecenter.asm): welcome (plus "Shall we heal" the first
@@ -2405,20 +2402,35 @@ function OverworldState:checkVictoryRewards(trainerClass, partyIndex)
Commands.hide_object(ctx, entry[1], entry[2])
end
end
local lines = {}
if reward.badge then
Game.save.inventory[reward.badge] = 1
end
if reward.item then
local inv = Game.save.inventory
inv[reward.item] = (inv[reward.item] or 0) + 1
local idef = Game.data.items[reward.item]
-- GiveItem -> CopyToStringBuffer for "{RAM:wStringBuffer}" received texts
Game.stringBuffer = idef and idef.name or reward.item
end
local lines = {}
if reward.dialogue then
local text = Game.data.text or {}
for _, label in ipairs(reward.dialogue) do
if text[label] and text[label] ~= "" then
table.insert(lines, text[label])
end
end
elseif reward.badge or reward.item then
if reward.badge then
local name = Game.data.items[reward.badge] and Game.data.items[reward.badge].name
or reward.badge
table.insert(lines, ("%s received\nthe %s!"):format(Game.save.player.name, name))
end
if reward.item then
local inv = Game.save.inventory
inv[reward.item] = (inv[reward.item] or 0) + 1
local name = Game.data.items[reward.item] and Game.data.items[reward.item].name
or reward.item
local name = Game.stringBuffer or reward.item
table.insert(lines, ("%s received\n%s!"):format(Game.save.player.name, name))
end
end
if #lines > 0 then
Game.stack:push(TextBox.new(Game, table.concat(lines, "\f")))
end
@@ -2439,6 +2451,8 @@ end
-- interposed NPCs / walls -- but unsigned 8-bit Y makes a sprite exactly 4
-- tiles north of the player sit at Y=$fc, so |$3c-$fc|=$c0 and a range-4
-- DOWN trainer does not engage that tile (Route 9 Bug Catcher / issue #76).
-- Off-screen sprites (IMAGEINDEX=$ff) never engage: without that gate, the
-- same 8-bit wrap makes far same-row trainers look in-range (#153/#183).
local PLAYER_SCREEN_X, PLAYER_SCREEN_Y = 0x40, 0x3c
local function u8(n) return n % 256 end
local function calcDiff(a, b)
@@ -2454,6 +2468,14 @@ local function trainerSightPixelDist(npc, player, horizontal)
u8(PLAYER_SCREEN_Y + (npc.cellY - player.cellY) * 16))
end
-- CheckSpriteAvailability (movement.asm): wXCoord/wYCoord = player - 4;
-- visible when sprite is in [wCoord, wCoord + SCREEN_*/2 - 1] (GB 10x9).
local function trainerSpriteOnScreen(npc, player)
local dx = npc.cellX - player.cellX
local dy = npc.cellY - player.cellY
return dx >= -4 and dx <= 5 and dy >= -4 and dy <= 4
end
-- STAY trainers with a facing spot the player crossing their line of
-- sight (range from the extracted trainer headers), walk up and battle.
function OverworldState:checkTrainerSight()
@@ -2466,7 +2488,8 @@ function OverworldState:checkTrainerSight()
-- walkers included (they sight between steps)
if d.trainerClass and not npc.moving
and not self:trainerDefeated(npc)
and not mapScripts.talkScript(self.map.id, d.text) then
and not mapScripts.talkScript(self.map.id, d.text)
and trainerSpriteOnScreen(npc, p) then
local header = Game.data:trainerHeader(self.map.def.label, d.index)
local range = header and header.range or 0
local vec = DIRVEC[npc.facing]
@@ -3210,7 +3233,7 @@ function OverworldState:takeWarp(warpDef)
self:startWarpTo(destMap, x, y, facing)
return
end
self.doorWarp = true -- door SFX + outdoor walk-out step
self.doorWarp = true -- door SFX + PlayerStepOutFromDoor walk-out
self:startWarpTo(destMap, x, y, facing)
end
@@ -3295,12 +3318,12 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts)
local outdoor = Map.isOutdoor(self.map.def)
require("src.core.Sound").play(Game.data,
outdoor and "Go_Outside" or "Go_Inside")
-- stepping out of an outdoor door/cave entrance (the original's
-- walk-out). Auto-walk leaves the mat, so the arrival disable
-- PlayerStepOutFromDoor (engine/overworld/auto_movement.asm): any
-- warp that lands on a door tile auto-steps south once, indoor or
-- outdoor. Auto-walk leaves the mat, so the arrival disable
-- (warpEntryCell / justWarped) is unnecessary -- and would let you
-- stand on the door without re-entering if you hold back into it.
if outdoor and self.player.facing == "down"
and self.map:isWarpTileCell(self.player.cellX, self.player.cellY) then
if self.map:isDoorTileCell(self.player.cellX, self.player.cellY) then
self.warpEntryCell = nil
self.justWarped = false
self:scriptMove(self.player, "down", 1)
+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):
-- scripted cursor FIGHT(80f) -> ITEM(50f), forced item menu with
-- one POKé BALL x50 -- itself scripted (list_menu.asm:65-80):
-- '▶' hover 80f, auto-A leaves the hollow '▷', always-caught throw.
-- B) Mimic's MID-move copy menu (MimicEffect): the chooser opens only
-- after the hit test, at (0,7) like MoveSelectionMenu .mimicmenu.
-- C) #162 trainer SHIFT offer: after KO'ing the first enemy mon,
-- "about to use" + "Will … change POKéMON?" with YES/NO.
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
@@ -83,5 +85,55 @@ return function(game)
U.shot(game, DIR .. "/mimic_5_moves_after.png") -- slot now holds the copy
while game.stack:top() ~= ow do game.stack:pop() end
U.wait(5)
-- ------------------------------------------------ C) SHIFT about-to-use
-- Inject the foe-faint path at the menu so screenshots don't depend on
-- accuracy rolls; party has 2 slots so TrainerAboutToUseText fires.
local ChoiceBox = require("src.ui.ChoiceBox")
local PartyMenu = require("src.ui.PartyMenu")
game.save.options = game.save.options or {}
game.save.options.battleStyle = "shift"
game.save.options.animations = false
game.save.party = {
Pokemon.new(game.data, "CHARIZARD", 50),
Pokemon.new(game.data, "BLASTOISE", 50),
}
local trainer = BattleState.newTrainer(game, "OPP_YOUNGSTER", 1)
trainer.onFinish = function() end
ow:pushBattle(trainer)
mashUntil(function() return trainer.phase == "menu" end)
U.shot(game, DIR .. "/shift_0_menu.png")
trainer:markParticipant()
trainer.enemy.mon.hp = 0
trainer.enemyParty[1].hp = 0
trainer.phase = "messages"
trainer.afterQueue = "menu"
trainer:onFaint(trainer.enemy)
local function topIs(cls)
return getmetatable(game.stack:top()) == cls
end
local function textDone(needle)
local t = trainer.current and trainer.current.text
return t and t:find(needle, 1, true)
and (trainer.charIndex or 0) >= (trainer.total or 0)
end
mashUntil(function() return textDone("about to use") end, 200)
U.shot(game, DIR .. "/shift_1_about_to_use.png")
mashUntil(function() return textDone("change POKéMON") end, 80)
U.wait(20) -- ChoiceBox auto-opens once the page has typed out
mashUntil(function() return topIs(ChoiceBox) end, 40)
U.shot(game, DIR .. "/shift_2_will_change.png")
U.shot(game, DIR .. "/shift_3_yes_no.png")
U.tap(game, "a"); U.wait(8) -- YES
mashUntil(function() return topIs(PartyMenu) end, 40)
U.shot(game, DIR .. "/shift_4_party.png")
U.tap(game, "down"); U.wait(4)
U.tap(game, "a"); U.wait(8) -- BLASTOISE
mashUntil(function() return textDone("sent") end, 120)
U.shot(game, DIR .. "/shift_5_enemy_sent.png")
mashUntil(function() return textDone("BLASTOISE") end, 120)
U.shot(game, DIR .. "/shift_6_player_sent.png")
while game.stack:top() ~= ow do game.stack:pop() end
U.wait(5)
love.event.quit()
end
+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
-- (toss -> poof -> mon hides -> ball shakes -> breakout or capture).
-- (toss -> poof -> mon hides -> ball shakes -> breakout or capture),
-- #159 nickname ask (text + white field, YES then NO), and #172
-- full-party catch nickname prompt before box transfer.
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local Pokemon = require("src.pokemon.Pokemon")
local ChoiceBox = require("src.ui.ChoiceBox")
table.insert(game.save.party, Pokemon.new(game.data, "CHARMANDER", 12))
U.teleport(game, "ROUTE_1", 5, 5, "down")
local ow = game.overworld
local BattleState = require("src.battle.BattleState")
local function topIs(cls)
return getmetatable(game.stack:top()) == cls
end
local function mashUntil(cond, n)
for _ = 1, (n or 400) do
if cond() then return true end
U.tap(game, "a")
U.wait(3)
end
return false
end
local function onNicknameChoice()
return topIs(ChoiceBox)
end
-- one throw, screenshotting every 20 frames through the chain
local function throwAndShoot(tag, rng)
local battle = BattleState.newWild(game, "PIDGEY", 8)
@@ -36,4 +56,78 @@ return function(game)
throwAndShoot("break", function(a, b) return b end)
-- rng low: clean capture, ball stays shut
throwAndShoot("caught", function(a, b) return a end)
-- #159: party catch -> nickname ask on white (YES cursor, then NO)
game.save.party = { Pokemon.new(game.data, "PIKACHU", 5) }
game.save.pokedex = game.save.pokedex or { owned = {}, seen = {} }
game.save.pokedex.owned.RATTATA = true
game.save.pokedex.seen.RATTATA = true
local battle = BattleState.newWild(game, "RATTATA", 3)
battle.onFinish = function() end
battle.rng = function(a, b) return a end
ow:pushBattle(battle)
for _ = 1, 14 do U.tap(game, "a"); U.wait(6) end
battle.phase = "messages"
battle.afterQueue = "menu"
battle:throwBall("POKE_BALL")
U.log("nick YES:", mashUntil(onNicknameChoice, 600))
U.shot(game, DIR .. "/catch_nickname_yes.png")
U.tap(game, "down")
U.wait(4)
U.shot(game, DIR .. "/catch_nickname_no.png")
U.tap(game, "a") -- confirm NO
U.wait(8)
for _ = 1, 20 do U.tap(game, "a"); U.wait(4) end
while game.stack:top() ~= ow do game.stack:pop() end
U.wait(5)
-- #172: full party of 6 -> nickname ask, then "sent to BOX" / PC text
game.save.party = {}
for _ = 1, 6 do
table.insert(game.save.party, Pokemon.new(game.data, "RATTATA", 5))
end
game.save.pokedex.owned.PIDGEY = true
game.save.pokedex.seen.PIDGEY = true
game.save.flags = game.save.flags or {}
game.save.flags.EVENT_MET_BILL = true
battle = BattleState.newWild(game, "PIDGEY", 8)
battle.onFinish = function() end
battle.rng = function(a, b) return a end
ow:pushBattle(battle)
for _ = 1, 14 do U.tap(game, "a"); U.wait(6) end
battle.phase = "messages"
battle.afterQueue = "menu"
battle:throwBall("POKE_BALL")
U.log("full-party nick ask:", mashUntil(onNicknameChoice, 600))
U.wait(4)
U.shot(game, DIR .. "/catch_fullparty_nickname.png")
-- decline nickname (NO), then wait (no A/B) for ItemUseBallText07
if topIs(ChoiceBox) then
U.tap(game, "b") -- NO
end
-- drain the B edge before the transfer text can see it
for _ = 1, 8 do U.wait(1) end
local sawBox = false
for _ = 1, 400 do
if game.stack:top() == battle and battle.current and battle.current.text
and battle.current.text:find("transferred", 1, true) then
sawBox = true
-- wait until the typewriter has drawn most of the line
if (battle.charIndex or 0) >= math.floor((battle.total or 0) * 0.7) then
break
end
end
U.wait(1)
end
U.log("full-party box text:", sawBox,
battle.current and battle.current.text or "nil")
U.shot(game, DIR .. "/catch_fullparty_box.png")
for _ = 1, 30 do U.tap(game, "a"); U.wait(4) end
while game.stack:top() ~= ow do game.stack:pop() end
end
+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
-- front of Red's house, walks in, tries to move inside, walks back out.
-- Driver: door-warp flow (Pallet house) plus #187 S.S. Anne enter-from-above.
-- Anne-only shots: tests/drivers/anne_door_test.lua
return function(game)
local U = dofile("tests/drivers/util.lua")
@@ -32,4 +32,20 @@ return function(game)
U.shot(game, DIR .. "/door_9_back_outside.png")
U.log("final map:", ow.map.id, "pos:", ow.player.cellX, ow.player.cellY,
"transitioning:", tostring(ow.transitioning))
-- #187: enter SS Anne cabin from above; expect one tile south of door
U.teleport(game, "SS_ANNE_1F", 31, 9, "up")
U.shot(game, DIR .. "/door_10_anne_before.png")
U.hold(game, "up", 20)
for _ = 1, 90 do
U.wait(1)
if ow.map.id == "SS_ANNE_1F_ROOMS" and not ow.transitioning
and #ow.scriptMoves == 0 and not ow.player.moving then
break
end
end
U.wait(8)
U.shot(game, DIR .. "/door_11_anne_after.png")
U.log("anne map:", ow.map.id, "pos:", ow.player.cellX, ow.player.cellY,
"facing:", ow.player.facing)
end
+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
-- machine monitor + per-mon balls, the jingle flash, and the farewell.
-- Driver: Pokémon Center nurse heal + Mom home heal.
-- Logs frames between healAnim end and the fighting-fit / looking-great text.
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local Pokemon = require("src.pokemon.Pokemon")
local ChoiceBox = require("src.ui.ChoiceBox")
local TextBox = require("src.render.TextBox")
local mon = Pokemon.new(game.data, "CHARMANDER", 12)
mon.hp = 3
table.insert(game.save.party, mon)
@@ -14,7 +15,6 @@ return function(game)
U.teleport(game, "VIRIDIAN_POKECENTER", 3, 3, "up")
local ow = game.overworld
-- mash A until a condition holds
local function mashUntil(cond)
for _ = 1, 400 do
if cond() then return true end
@@ -24,6 +24,11 @@ return function(game)
return false
end
local function topIsText()
return getmetatable(game.stack:top()) == TextBox
end
-- -------- Poké Center --------
U.tap(game, "a") -- talk to the nurse
U.wait(30)
U.shot(game, DIR .. "/heal_00_welcome.png")
@@ -38,7 +43,6 @@ return function(game)
U.shot(game, DIR .. "/heal_02_ball1.png")
U.wait(28)
U.shot(game, DIR .. "/heal_03_ball2.png")
-- overlay must stay glued to the machine under survey zoom
game:zoomStep(-1); game:zoomStep(-1)
U.wait(3)
U.shot(game, DIR .. "/heal_03z_zoomed.png")
@@ -51,14 +55,73 @@ return function(game)
U.shot(game, DIR .. "/heal_05_flash_b.png")
U.wait(10)
U.shot(game, DIR .. "/heal_06_flash_c.png")
-- wait out the jingle + 32-frame beat
for _ = 1, 40 do
if not ow.healAnim then break end
U.wait(10)
local animEndFrame, fitFrame
for _ = 1, 400 do
if ow.healAnim == nil and not animEndFrame then
animEndFrame = U.frame()
if topIsText() then fitFrame = animEndFrame end
U.shot(game, DIR .. "/heal_06b_anim_end.png")
if fitFrame then break end
end
if animEndFrame and topIsText() and not fitFrame then
fitFrame = U.frame()
break
end
U.wait(1)
end
U.shot(game, DIR .. "/heal_07_fit.png")
U.log("pc anim_end_frame:", animEndFrame, "fit_frame:", fitFrame,
"gap_anim_to_text:",
(fitFrame and animEndFrame) and (fitFrame - animEndFrame) or "n/a")
U.log("farewell reached:", mashUntil(function()
return game.stack:top() == ow
end))
U.shot(game, DIR .. "/heal_08_end.png")
-- -------- Mom (Reds House) --------
require("src.script.Flags").set(game.save, "EVENT_GOT_STARTER")
for _, m in ipairs(game.save.party) do
m.hp = 1
end
-- stand below Mom (5,4) facing up
U.teleport(game, "REDS_HOUSE_1F", 5, 5, "up")
ow = game.overworld
U.tap(game, "a")
U.wait(20)
U.shot(game, DIR .. "/heal_mom_00_rest.png")
U.log("mom fade started:", mashUntil(function()
return ow.fadeOverlay ~= nil
end))
local fadeStart = U.frame()
U.shot(game, DIR .. "/heal_mom_01_fade.png")
local Music = require("src.core.Music")
local sawJingle, jingleDoneFrame, fadeGone, greatFrame = false
for _ = 1, 600 do
if Music.oneShotPlaying() then sawJingle = true end
if sawJingle and not Music.oneShotPlaying() and not jingleDoneFrame then
jingleDoneFrame = U.frame()
end
if ow.fadeOverlay == nil and fadeStart and not fadeGone
and U.frame() > fadeStart + 5 then
fadeGone = U.frame()
end
if fadeGone and topIsText() then
greatFrame = U.frame()
break
end
U.wait(1)
end
U.shot(game, DIR .. "/heal_mom_02_great.png")
U.log("mom saw_jingle:", sawJingle,
"jingle_done_frame:", jingleDoneFrame,
"fade_gone_frame:", fadeGone,
"great_frame:", greatFrame,
"gap_fade_to_text:",
(greatFrame and fadeGone) and (greatFrame - fadeGone) or "n/a")
mashUntil(function()
return game.stack:top() == ow
end)
U.shot(game, DIR .. "/heal_mom_03_end.png")
end
+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,
-- follows him to the lab, takes a starter, watches the rival
-- counter-pick, tries to leave (door gate + ambush battle).
-- Driver: Pallet intro (Oak escort → starter → rival ambush) plus the
-- #158 parcel-return / rival-join / Pokédex-gift cutscene.
return function(game)
local U = dofile("tests/drivers/util.lua")
@@ -96,13 +95,68 @@ return function(game)
U.log("final:", ow.map.id, ow.player.cellX, ow.player.cellY,
"battled:", tostring(game.save.flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB),
"battleSeen:", tostring(BattleSeen))
U.log("stuck-state: runner=", tostring(ow.runner:isRunning()),
"moves=", #ow.scriptMoves, "top=", tostring(game.stack:top() == ow),
"transitioning=", tostring(ow.transitioning),
"emote=", tostring(ow.emote ~= nil))
for i, mv in ipairs(ow.scriptMoves) do
U.log((" move %d: entity=%s dir=%s remaining=%d moving=%s"):format(
i, tostring(mv.entity.def and mv.entity.def.sprite or "PLAYER"),
tostring(mv.dir), mv.remaining, tostring(mv.entity.moving)))
-- ---- #158: return Oak's Parcel → rival joins → Pokédex gift ----
local Flags = require("src.script.Flags")
Flags.set(game.save, "EVENT_GOT_OAKS_PARCEL")
game.save.inventory.OAKS_PARCEL = 1
-- desk Oak + both dexes visible; rival hidden until the script shows
-- him. Keep the starter-ball hides from the pick (Squirtle + Bulbasaur).
game.save.objectToggles = game.save.objectToggles or {}
game.save.objectToggles.OAKS_LAB = {
OAKSLAB_OAK1 = true,
OAKSLAB_OAK2 = false,
OAKSLAB_RIVAL = false,
OAKSLAB_POKEDEX1 = true,
OAKSLAB_POKEDEX2 = true,
OAKSLAB_SQUIRTLE_POKE_BALL = false,
OAKSLAB_BULBASAUR_POKE_BALL = false,
}
U.teleport(game, "OAKS_LAB", 5, 3, "up")
ow = game.overworld
U.wait(20)
U.shot(game, DIR .. "/oak_12_parcel_ready.png")
U.tap(game, "a")
U.wait(40)
U.shot(game, DIR .. "/oak_13_deliver.png")
local rivalSeen, atDeskShot, leaveShot = false, false, false
for _ = 1, 1200 do
if idle() and Flags.get(game.save, "EVENT_GOT_POKEDEX")
and not ow:npcByIndex(1) then
break
end
local rival = ow:npcByIndex(1)
local top = game.stack:top()
if rival and not rivalSeen then
rivalSeen = true
U.wait(24)
U.shot(game, DIR .. "/oak_14_rival_arrive.png")
end
if rival and rival.cellY <= 3 and not atDeskShot then
atDeskShot = true
U.shot(game, DIR .. "/oak_14b_rival_at_desk.png")
end
if rival and rival.cellY >= 6 and Flags.get(game.save, "EVENT_GOT_POKEDEX")
and not leaveShot then
leaveShot = true
U.shot(game, DIR .. "/oak_14c_rival_leave.png")
end
if top ~= ow then
U.tap(game, "a")
end
U.wait(4)
end
mashUntil(idle, "pokedex cutscene done", 600)
U.shot(game, DIR .. "/oak_15_after_pokedex.png")
local toggles = game.save.objectToggles and game.save.objectToggles.OAKS_LAB
local rivalAfter = ow:npcByIndex(1)
U.log("parcel:",
"gotDex=", tostring(Flags.get(game.save, "EVENT_GOT_POKEDEX")),
"oakGot=", tostring(Flags.get(game.save, "EVENT_OAK_GOT_PARCEL")),
"rivalSeen=", tostring(rivalSeen),
"rivalGone=", tostring(rivalAfter == nil),
"atDesk=", tostring(atDeskShot),
"dex1=", tostring(toggles and toggles.OAKSLAB_POKEDEX1),
"dex2=", tostring(toggles and toggles.OAKSLAB_POKEDEX2),
"route22=", tostring(Flags.get(game.save, "EVENT_ROUTE22_RIVAL_WANTS_BATTLE")))
end
+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
local t = replacementMenu()
if not t or t.index ~= slot then break end
press("a") -- select mon -> SWITCH / STATS / CANCEL
U.wait(8)
t = replacementMenu()
if not (t and t.submenu) then break end
-- SWITCH is first (SwitchStatsCancelText)
press("a")
U.wait(10)
ok = true
@@ -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)
game.capturePath = path
U.wait(2) -- let the capture flush
-- love.draw consumes capturePath once per rendered frame, but fast runs
-- (POKEPORT_SPEED) step the driver many times per render; spin until the
-- capture lands so later actions can't outrun it
for _ = 1, 120 do
if not game.capturePath then break end
frame = frame + 1
coroutine.yield()
end
U.wait(1)
end
-- skip the intro movie + title into a fresh overworld game
+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()
+13
View File
@@ -103,6 +103,7 @@ stub.math = {
stub.filesystem = {
write = function(name, content) files[name] = content return true end,
read = function(name) return files[name] end,
remove = function(name) files[name] = nil return true end,
-- directories are implied by key prefixes ("mods/x/manifest.json")
getInfo = function(name)
if files[name] then return { type = "file" } end
@@ -118,6 +119,17 @@ stub.filesystem = {
end,
getDirectoryItems = function(name)
local seen, items = {}, {}
name = name or ""
-- "" / "/" = save-dir root (RomImporter Android ROM scan)
if name == "" or name == "/" then
for key in pairs(files) do
local child = key:match("^[^/]+")
if child and not seen[child] then
seen[child] = true
items[#items + 1] = child
end
end
else
local prefix = name .. "/"
for key in pairs(files) do
if key:sub(1, #prefix) == prefix then
@@ -128,6 +140,7 @@ stub.filesystem = {
end
end
end
end
table.sort(items)
return items
end,
+34 -2
View File
@@ -423,6 +423,38 @@ check(#pm.subItems == 2, "a non-table submenu result keeps the vanilla list")
hooks:removeOwner("bad")
pm.submenu = nil
-- ------- battle PKMN: SWITCH / STATS / CANCEL (#180)
local switched
local bgame = partyGame()
local bpm = PartyMenu.new(bgame, {
battle = {},
onSwitch = function(mon) switched = mon end,
})
bpm.game = bgame
bgame.stack:push(bpm)
press(bpm, "a")
check(bpm.submenu and #bpm.subItems == 3
and bpm.subItems[1].label == "SWITCH"
and bpm.subItems[2].label == "STATS"
and bpm.subItems[3].label == "CANCEL",
"voluntary battle party opens SWITCH/STATS/CANCEL")
press(bpm, "a") -- SWITCH
check(switched == bgame.save.party[1], "battle SWITCH hands the mon to onSwitch")
check(bgame.stack:top() ~= bpm, "battle SWITCH closes the party menu")
local forced
local fgame = partyGame()
local fpm = PartyMenu.new(fgame, {
battle = {},
forceSwitch = true,
onSwitch = function(mon) forced = mon end,
})
fpm.game = fgame
fgame.stack:push(fpm)
press(fpm, "a")
check(not fpm.submenu and forced == fgame.save.party[1],
"forceSwitch still picks immediately (ChooseNextMon / SHIFT)")
-- ------- mod.ui helpers and theme defaults
local items = { { label = "A" }, { label = "B" } }
ModUI.insertAfter(items, "A", { label = "X" })
@@ -440,9 +472,9 @@ check(type(ModUI.push) == "function", "mod.ui.push opens screens")
check(Theme.cursor == 0xED and Theme.cursorHollow == 0xEC
and Theme.moreArrow == 0xEE, "theme defaults are the old literals")
check(Theme.choiceBox.tx == 0 and Theme.choiceBox.ty == 7
check(Theme.choiceBox.tx == 14 and Theme.choiceBox.ty == 7
and Theme.choiceBox.tw == 6 and Theme.choiceBox.th == 5,
"choice box geometry keeps its vanilla tiles")
"choice box geometry keeps its vanilla tiles (InitYesNoTextBoxParameters hlcoord 14,7)")
Theme.load({ field = { theme = { cursor = 0xAA } } })
check(Theme.cursor == 0xAA, "field.theme restyles the cursor glyph")
Theme.cursor = 0xED
+29 -5
View File
@@ -312,10 +312,15 @@ do
local Renderer = require("src.render.Renderer")
local SaveData = require("src.core.SaveData")
local OW = require("src.world.OverworldController")
local realTB = package.loaded["src.render.TextBox"]
package.loaded["src.render.TextBox"] = {
new = function(game, text, done) return { text = text, onDone = done } end,
}
local function stackedDialogue()
local top = Game.stack:top()
if not (top and top.pages) then return "" end
local parts = {}
for _, page in ipairs(top.pages) do
parts[#parts + 1] = table.concat(page, "\n")
end
return table.concat(parts, "\n")
end
Game.data = Data
Game.input = Input; Input:init()
Game.renderer = Renderer; Renderer:init()
@@ -328,10 +333,17 @@ do
Game.stack:push(OW, "PEWTER_GYM", 4, 13, "up")
local ow = Game.stack:top()
ow:checkVictoryRewards("OPP_BROCK", 1)
local brockText = stackedDialogue()
check(Game.save.flags.EVENT_BEAT_BROCK, "Brock victory sets EVENT_BEAT_BROCK")
check(Game.save.inventory.BOULDERBADGE == 1, "Brock victory awards BOULDERBADGE")
check(Game.save.flags.EVENT_BEAT_PEWTER_GYM_TRAINER_0,
"Brock victory deactivates the Pewter Gym Cooltrainer")
check(brockText:find("I took", 1, true),
"Brock victory dialogue starts with ReceivedBoulderBadge speech")
check(brockText:find("FLASH", 1, true),
"Brock victory dialogue includes BoulderBadge FLASH explanation")
check(brockText:find("BIDE", 1, true),
"Brock victory dialogue includes TM34 BIDE explanation")
local pewterNpc
for _, npc in ipairs(ow.npcs) do
if npc.def.index == 2 then pewterNpc = npc break end
@@ -348,6 +360,19 @@ do
and Game.save.objectToggles.ROUTE_22.ROUTE22_RIVAL1 == false,
"Brock victory hides ROUTE22_RIVAL1")
while Game.stack:top() do Game.stack:pop() end
Game.save = SaveData.newGame()
Game.save.flags = {}
Game.save.inventory = {}
Game.save.defeatedTrainers = {}
Game.stack:push(OW, "CERULEAN_GYM", 4, 10, "up")
ow = Game.stack:top()
ow:checkVictoryRewards("OPP_MISTY", 1)
local mistyText = stackedDialogue()
check(mistyText:find("CUT", 1, true),
"Misty victory dialogue includes CascadeBadge CUT explanation")
check(Game.save.inventory.CASCADEBADGE == 1, "Misty victory awards CASCADEBADGE")
while Game.stack:top() do Game.stack:pop() end
Game.save = SaveData.newGame()
Game.save.flags = {}
@@ -368,7 +393,6 @@ do
"unfought Cinnabar trainer is defeated via seeded header event")
while Game.stack:top() do Game.stack:pop() end
package.loaded["src.render.TextBox"] = realTB
end
S.finish()
+4 -2
View File
@@ -67,6 +67,8 @@ check(Game.save.party[1].nickname == "AAAAAAAAAA",
"starter nickname prompt accepted (AskName / #137)")
-- === 2) the parcel/pokedex beat still doesn't grant POKé BALLs ===
-- OaksLabOak1Text.got_parcel requires EVENT_BATTLED_RIVAL_IN_OAKS_LAB
Flags.set(Game.save, "EVENT_BATTLED_RIVAL_IN_OAKS_LAB")
check(runScript(mapScripts.talkScript("VIRIDIAN_MART", "TEXT_VIRIDIANMART_CLERK")),
"mart clerk script completes")
eq(Game.save.inventory.OAKS_PARCEL, 1, "clerk hands over Oak's Parcel")
@@ -84,8 +86,8 @@ check(labToggles and labToggles.OAKSLAB_POKEDEX2 == false,
"POKEDEX2 hidden after receiving Pokédex")
eq(Game.save.inventory.POKE_BALL, nil, "still no POKe BALLs at the pokedex beat")
-- talking to Oak again before beating the Route 22 rival should fall
-- into the RaiseYourYoungPokemon branch, not give balls
-- talking to Oak again before beating the Route 22 rival is the
-- .mon_around_the_world branch (not the poke-ball grant)
check(runScript(mapScripts.talkScript("OAKS_LAB", "TEXT_OAKSLAB_OAK1")),
"Oak talk (pre-Route22-win) script completes")
eq(Game.save.inventory.POKE_BALL, nil, "still no POKe BALLs before the Route 22 rival is beaten")
+58
View File
@@ -111,4 +111,62 @@ do
eq(b.afterQueue, "finish", "victory finishes after the queue")
end
-- #162: SHIFT style with 2+ party slots announces the next mon and
-- offers a free switch (TrainerAboutToUseText + YES/NO). Party count
-- gates the offer (pokered wPartyCount), not living-HP count — a fainted
-- reserve still unlocks the prompt.
do
local Game = freshGame()
Game.save.options.battleStyle = "shift"
local reserve = Pokemon.new(Data, "SQUIRTLE", 40)
reserve.hp = 0
table.insert(Game.save.party, reserve)
local b = BattleState.newTrainer(Game, "OPP_YOUNGSTER", 1)
check(#b.enemyParty >= 2, "youngster has a second mon")
b.enemyParty[1].hp = 0
b.enemyIndex = 1
b.enemy.mon = b.enemyParty[1]
b.participants = { [Game.save.party[1]] = true }
b:enemyMonFainted()
local about, willChoice, sent = false, false, false
local n = 0
while #b.queue > 0 and n < 200 do
n = n + 1
local item = table.remove(b.queue, 1)
if item.fn then
b.nextInsert = 0
item.fn()
elseif item.text then
if item.text:find("about to use", 1, true) then about = true end
if item.text:find("change POKéMON", 1, true) and item.choice then
willChoice = true
item.choice(false) -- decline; enemy still sends out
end
if item.text:find("sent", 1, true) then sent = true end
end
end
check(about, "SHIFT announces trainer about-to-use")
check(willChoice, "SHIFT queues Will-change with YES/NO choice")
check(sent, "enemy send-out still follows after declining")
check(b.enemy.mon.hp > 0, "second enemy mon is out after the offer")
end
-- SET style (and single-mon parties) skip the offer entirely.
do
local Game = freshGame()
Game.save.options.battleStyle = "set"
table.insert(Game.save.party, Pokemon.new(Data, "SQUIRTLE", 40))
local b = BattleState.newTrainer(Game, "OPP_YOUNGSTER", 1)
b.enemyParty[1].hp = 0
b.enemyIndex = 1
b.enemy.mon = b.enemyParty[1]
b.participants = { [Game.save.party[1]] = true }
b:enemyMonFainted()
local about = false
for _, item in ipairs(b.queue) do
if item.text and item.text:find("about to use", 1, true) then about = true end
end
check(not about, "SET style skips about-to-use")
end
S.finish()
+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")
end
-- === 6) skipped level-up moves granted on retrieve (#184 / WriteMonMoves) ===
do
local game = newGame()
local mon = boardAtLevel(game, "MAGIKARP", 13, "FISHY")
game.save.daycare.depositLevel = 13
local hasTackle = false
for _, mv in ipairs(mon.moves) do
if mv.id == "TACKLE" then hasTackle = true break end
end
check(not hasTackle, "Magikarp Lv13 does not already know Tackle")
game.save.daycare.steps = stepsForLevels("MAGIKARP", 13, 6) -- → 19
choiceAnswer = true
check(talk(game), "Magikarp retrieve talk completes")
local back = game.save.party[1]
eq(back.level, 19, "Magikarp raised 13→19 on retrieve")
local moveIds = {}
for _, mv in ipairs(back.moves) do moveIds[#moveIds + 1] = mv.id end
local joined = table.concat(moveIds, ",")
check(joined:find("TACKLE", 1, true),
"Magikarp learns Tackle at 15 via daycare skip (#184): " .. joined)
check(joined:find("SPLASH", 1, true), "Splash retained alongside Tackle")
end
-- === 7) decline does not teach skipped moves ===
do
local game = newGame()
local mon = boardAtLevel(game, "MAGIKARP", 13)
game.save.daycare.depositLevel = 13
game.save.daycare.steps = stepsForLevels("MAGIKARP", 13, 6)
choiceAnswer = false
talk(game)
local hasTackle = false
for _, mv in ipairs(game.save.daycare.mon.moves) do
if mv.id == "TACKLE" then hasTackle = true break end
end
check(not hasTackle, "decline leaves moves unchanged (no Tackle yet)")
end
package.loaded["src.render.TextBox"] = realTextBox
S.finish()
+7 -1
View File
@@ -24,7 +24,10 @@ local S = require("tests.harness").suite("parity ghost marowak")
local check, eq = S.check, S.eq
-- Real TextBoxes want a Font atlas; the decision under test is which
-- branch runs, not how the text renders.
-- branch runs, not how the text renders. Both stubs are restored at the
-- bottom for the suites run_tests.lua chains after this file.
local realTextBox = package.loaded["src.render.TextBox"]
local realBattleState = package.loaded["src.battle.BattleState"]
package.loaded["src.render.TextBox"] = {
new = function(_, text, done) return { text = text, done = done } end,
}
@@ -158,4 +161,7 @@ do
eq(0, #moved, "no scripted step on a loss")
end
package.loaded["src.render.TextBox"] = realTextBox
package.loaded["src.battle.BattleState"] = realBattleState
S.finish()
+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 check, eq = S.check, S.eq
-- restored at the bottom for the suites run after this file
local realTextBox = package.loaded["src.render.TextBox"]
local realMusic = package.loaded["src.core.Music"]
local realSound = package.loaded["src.core.Sound"]
-- Commands captures TextBox at module load; drop any already-loaded copy
-- so the script runner below binds the stub, not the real renderer
local realCommands = package.loaded["src.script.Commands"]
local realScriptRunner = package.loaded["src.script.ScriptRunner"]
package.loaded["src.script.Commands"] = nil
package.loaded["src.script.ScriptRunner"] = nil
package.loaded["src.render.TextBox"] = {
new = function(_, text, done) return { text = text, done = done } end,
}
@@ -196,4 +206,10 @@ do
check(found, "captain script plays Music_PkmnHealed after the rub")
end
package.loaded["src.render.TextBox"] = realTextBox
package.loaded["src.core.Music"] = realMusic
package.loaded["src.core.Sound"] = realSound
package.loaded["src.script.Commands"] = realCommands
package.loaded["src.script.ScriptRunner"] = realScriptRunner
S.finish()
+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(toggleOf(EEVEE_MAP, EEVEE_BALL), false, "old save: leftover ball hidden")
-- === 11) GivePokemon failure (party + every box full): BoxIsFullText,
-- === 11) GivePokemon party full, box has room: AskName + SentToBoxText
-- (SendNewMonToBox), then script GotMonText; ball hidden ===
Game.save = SaveData.newGame()
for i = 1, 6 do Game.save.party[i] = Pokemon.new(Data, "PIDGEY", 5) end
check(runScript(EEVEE_MAP, EEVEE_TEXT), "full-party Eevee script completes")
shownIs({ "_DoYouWantToNicknameText", "_SentToBoxText", "_GotMonText" },
"full-party gift: nickname, sent-to-box, then GotMonText")
eq(#Game.save.party, 6, "full-party gift leaves party size at 6")
local Boxes = require("src.pokemon.Boxes")
local boxed = false
for _, box in ipairs(Boxes.ensure(Game.save)) do
for _, m in ipairs(box) do
if m.species == "EEVEE" then boxed = true end
end
end
check(boxed, "full-party Eevee lands in a box")
check(Flags.get(Game.save, "EVENT_GOT_EEVEE"), "full-party gift still sets flag")
eq(toggleOf(EEVEE_MAP, EEVEE_BALL), false, "full-party gift hides the ball")
-- === 12) GivePokemon failure (party + every box full): BoxIsFullText,
-- ball stays, flag unset -- the gift stays claimable ===
Game.save = SaveData.newGame()
for i = 1, 6 do Game.save.party[i] = Pokemon.new(Data, "PIDGEY", 5) end
local Boxes = require("src.pokemon.Boxes")
Boxes.ensure(Game.save)
for b = 1, Boxes.COUNT do
for s = 1, Boxes.CAPACITY do Game.save.boxes[b][s] = { species = "PIDGEY" } end
@@ -268,7 +286,7 @@ shownIs({ "_BoxIsFullText" }, "full party+box shows BoxIsFullText")
check(not Flags.get(Game.save, "EVENT_GOT_EEVEE"), "full party+box leaves flag unset")
eq(toggleOf(EEVEE_MAP, EEVEE_BALL), nil, "full party+box leaves the ball visible")
-- === 12) SS Anne rival ambush guard (scripts/SSAnne2F.asm: the
-- === 13) SS Anne rival ambush guard (scripts/SSAnne2F.asm: the
-- wSSAnne2FCurScript NOOP progression <-> the port's
-- EVENT_BEAT_SS_ANNE_RIVAL flag) ===
local ss = require("data.scripts.story5").SS_ANNE_2F
+74
View File
@@ -218,4 +218,78 @@ do
check(ow3.engaging, "distance 3 on the upper plateau still engages")
end
-- === (5) off-screen sprites never engage (CheckSpriteAvailability) ===
-- TrainerEngage bails when IMAGEINDEX=$ff. The 8-bit screen-pixel distance
-- wraps so a trainer ~12/28 tiles away can look in-range; the GB 10x9 window
-- (player±4/±5) is what stops that (GitHub #153 / #183).
local VF_BUG_CATCHER = 2 -- ViridianForest Youngster2 at (30,33) LEFT range 4
local function freshForest(px, py, facing)
while Game.stack:top() do Game.stack:pop() end
Game.save = SaveData.newGame()
Input:init()
OW.engaging = false
OW.emote = nil
Game.stack:push(OW, "VIRIDIAN_FOREST", px, py, facing)
local ow = Game.stack:top()
local trainer
for _, npc in ipairs(ow.npcs) do
if npc.def.index == VF_BUG_CATCHER then trainer = npc end
end
return ow, trainer
end
do
-- (18,33) is walkable west of the tree wall; dx=12 to (30,33) wraps
-- the 8-bit pixel distance into range-4, but the sprite is off the GB screen.
local ow, trainer = freshForest(18, 33, "right")
check(trainer ~= nil and trainer.cellX == 30 and trainer.cellY == 33,
"Viridian Forest Bug Catcher stands at (30,33)")
eq(trainer.facing, "left", "Bug Catcher faces left")
local header = Data:trainerHeader("ViridianForest", VF_BUG_CATCHER)
eq(header and header.range, 4, "Bug Catcher sight range is 4")
for _ = 1, 10 do frame(ow) end
check(not ow.engaging,
"through-trees tile (dx=12 wrap) must not engage: sprite off GB screen")
check(trainer.cellX == 30 and trainer.cellY == 33,
"off-screen trainer must not walk up through trees")
-- same corridor as the trainer, distance 4, on the GB screen: must engage
local ow2, t2 = freshForest(26, 33, "right")
local guard = 0
while not ow2.engaging and guard < 10 do guard = guard + 1; frame(ow2) end
check(ow2.engaging and t2.cellX == 30,
"distance 4 on-screen still engages (real sight line)")
end
-- Victory Road 2F Hiker at (12,9) LEFT range 4: far west same row wraps
local VR_HIKER = 1
local function freshVR2(px, py, facing)
while Game.stack:top() do Game.stack:pop() end
Game.save = SaveData.newGame()
Input:init()
OW.engaging = false
OW.emote = nil
Game.stack:push(OW, "VICTORY_ROAD_2F", px, py, facing)
local ow = Game.stack:top()
local trainer
for _, npc in ipairs(ow.npcs) do
if npc.def.index == VR_HIKER then trainer = npc end
end
return ow, trainer
end
do
local ow, trainer = freshVR2(0, 9, "right")
check(trainer ~= nil and trainer.cellX == 12 and trainer.cellY == 9,
"Victory Road 2F Hiker stands at (12,9)")
for _ = 1, 10 do frame(ow) end
check(not ow.engaging,
"Victory Road far same-row (dx=12 wrap) must not engage through walls")
check(trainer.cellX == 12 and trainer.cellY == 9,
"off-screen Victory Road trainer must stay put")
end
S.finish()
+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[1], 2, "page 1 has two lines")
eq(#pages[2], 3, "page 2 keeps scrolled line")
eq(pages.contBefore[1][1], false, "page1 line1 is not cont")
eq(pages.contBefore[1][2], false, "page1 line2 via \\n has no wait")
eq(pages.contBefore[2][3], true, "page2 line3 via \\v waits (cont)")
-- #163: ContText must set waiting + ▼ before scrolling the next line
do
local pressed = { a = true }
local box = TextBox.new({
input = {
wasPressed = function(_, k) return pressed[k] end,
isDown = function() return false end,
},
save = { options = { textSpeed = 1 }, player = { name = "RED" } },
data = {},
stringBuffer = "",
stack = { pop = function() end, push = function() end },
}, "AAA\nBBB\vCCC")
pressed.a = false
for _ = 1, 80 do
if box.waiting or box.done then break end
box:update(0)
end
check(box.waiting and box.contAdvance,
"cont boundary waits for A with contAdvance")
check(not box.done, "cont wait is not the final done prompt")
eq(box.lineIndex, 2, "cont wait stays on the finished line until A")
pressed.a = true
box:update(0)
check(not box.waiting and not box.contAdvance, "A clears cont wait")
eq(box.lineIndex, 3, "A advances into the cont line")
end
-- ---------------------------------------------------------------- stats & growth
local Stats = require("src.pokemon.Stats")
@@ -239,6 +269,14 @@ local r3 = ItemEffects.use(Data, save, "TM_TOXIC", pikachu)
check(r3 == "learn", "Pikachu can learn Toxic (in tmhm list)")
local r4 = ItemEffects.use(Data, save, "HM_SURF", pikachu)
eq(r4, "failed", "Pikachu can't learn Surf")
do
local rBattle, oakMsg = ItemEffects.use(Data, save, "TM_TOXIC", hurt, {})
eq(rBattle, "failed", "TM refuses mid-battle (ItemUseNotTime)")
check(oakMsg and oakMsg[1] and oakMsg[1]:find("isn't the", 1, true),
"TM mid-battle Oak text")
eq(ItemEffects.use(Data, save, "HM_SURF", pikachu, {}), "failed",
"HM refuses mid-battle")
end
local r5, _, extra = ItemEffects.use(Data, save, "THUNDER_STONE", pikachu)
eq(r5, "consumed", "Thunder Stone works on Pikachu")
eq(extra.evolveTo, "RAICHU", "Thunder Stone evolves Pikachu to Raichu")
@@ -494,7 +532,7 @@ local function runScript(script)
local r = ScriptRunner.new(Game, nil)
r:run(script, {})
local guard = 0
while r:isRunning() and guard < 2000 do
while r:isRunning() and guard < 4000 do
guard = guard + 1
Input.pressed = { a = true }
StateStack:update(1 / 60)
@@ -505,6 +543,7 @@ local function runScript(script)
end
Flags.set(Game.save, "EVENT_GOT_STARTER")
Flags.set(Game.save, "EVENT_BATTLED_RIVAL_IN_OAKS_LAB")
check(runScript(mapScripts.talkScript("VIRIDIAN_MART", "TEXT_VIRIDIANMART_CLERK")),
"mart clerk script completes")
eq(Game.save.inventory.OAKS_PARCEL, 1, "clerk hands over Oak's Parcel")
@@ -768,6 +807,10 @@ eq(Data.field.darkMaps.maps[1], "ROCK_TUNNEL_1F", "Rock Tunnel is dark")
check(Data.field.hiddenExtras.trashCans.adjacent[0][1] == 1,
"trash can adjacency table extracted")
check(#Data.field.hiddenExtras.trashCans.cans == 15, "15 Vermilion trash cans")
check(#(Data.field.hiddenExtras.printTrash.SS_ANNE_KITCHEN or {}) == 2,
"SS Anne kitchen PrintTrashText bins")
check(#(Data.field.hiddenExtras.printTrash.VERMILION_GYM or {}) == 1,
"Vermilion Gym PrintTrashText bin")
local tf = io.open(Data.field.title.logo.path, "rb")
check(tf ~= nil, "title logo asset exists")
if tf then tf:close() end
@@ -882,6 +925,35 @@ do
eq(sbst.player.stages.attack, 1, "SHARPEN skips the accuracy roll")
end
-- #169: HUD status (shownStatus) stays hidden through announce/anim/
-- inflict text, then syncs after Execute*Move (DrawHUDsAndHPBars)
do
Game.save.party = { Pokemon.new(Data, "BULBASAUR", 10) }
local pb = BattleState.newWild(Game, "RATTATA", 5)
pb.rng = function(a, b) return a or 0 end -- always hit
pb.phase = "messages"
pb:executeAction(pb.player, pb.enemy, { id = "POISONPOWDER", pp = 10 })
eq(pb.enemy.mon.status, "PSN", "POISONPOWDER sets mon.status immediately")
eq(pb.enemy.shownStatus, nil, "HUD status deferred until after move queue")
local sawPoisonText, synced = false, false
local steps = 0
while steps < 4000 and not synced do
steps = steps + 1
if pb.enemy.shownStatus == "PSN" then synced = true; break end
if not sawPoisonText and pb.current and pb.current.text
and pb.current.text:find("poisoned", 1, true) then
sawPoisonText = true
eq(pb.enemy.shownStatus, nil,
"PSN icon still deferred while poisoned text shows")
end
Input.pressed = { a = true }
if not pb:updateQueue() then break end
Input.pressed = {}
end
check(sawPoisonText, "poisoned text appeared before HUD sync")
eq(pb.enemy.shownStatus, "PSN", "HUD status syncs after move completes")
end
-- HandleIfPlayerMoveMissed: skip PlayMoveAnimation on a miss
-- (unless EXPLODE_EFFECT)
do
@@ -1256,6 +1328,23 @@ do
"_ItemUseBallText08 before meeting Bill")
check(not hasText(cb2, "New POKéDEX data"),
"no dex page for an already-owned species")
-- #172: SendNewMonToBox still runs AskName before the PC text
local hasNickUi = false
for _, it in ipairs(cb2.queue) do
if it.ui then
local ui = it.ui()
if ui and ui.pages then
for _, page in ipairs(ui.pages) do
for _, line in ipairs(page) do
if type(line) == "string" and line:lower():find("nickname", 1, true) then
hasNickUi = true
end
end
end
end
end
end
check(hasNickUi, "full-party catch still asks for a nickname (#172)")
Game.save.flags.EVENT_MET_BILL = true
local cb3 = BattleState.newWild(Game, "EKANS", 5)
cb3:storeCaughtMon()
@@ -1269,7 +1358,9 @@ do
tb.enemyIndex = #tb.enemyParty
tb.enemy = { mon = tb.enemyParty[#tb.enemyParty], def = tb.enemy.def,
name = tb.enemy.name, isPlayer = false }
tb.enemy.mon.hp = 0
-- the whole team must be down: enemyMonFainted scans every slot for a
-- replacement (EnemySendOutFirstMon / AnyEnemyPokemonAliveCheck)
for _, m in ipairs(tb.enemyParty) do m.hp = 0 end
tb:enemyMonFainted()
check(hasText(tb, ("%s defeated\n%s!"):format(Game.save.player.name,
tb.trainer.name)),
@@ -1946,8 +2037,9 @@ end
-- ---------------------------------------------------------------- heal machine cadence
-- AnimateHealingMachine (engine/overworld/healing_machine.asm): one ball
-- per party mon every 30 frames (SFX_HEALING_MACHINE each), then the
-- healed jingle while the machine flashes 8 times (10 frames a toggle),
-- then a 32-frame beat after the jingle ends.
-- healed jingle while the machine flashes 8 times (10 frames a toggle).
-- #157: fighting-fit text follows the flash immediately (no .waitLoop2 /
-- DelayFrames 32 hold after the machine).
do
local OW = require("src.world.OverworldController")
local ha = { balls = 3, lit = 0, timer = 0, visible = true }
@@ -1958,32 +2050,26 @@ do
local ev = OW.stepHealAnim(ha)
if ha.visible ~= wasVisible then toggles = toggles + 1 end
if ev then events[#events + 1] = frame .. ev end
if ev == "jingle" then ha.jingleDone = true end -- headless: no audio
if ev == "done" then break end
end
end)
check(ok, "stepHealAnim runs")
eq(table.concat(events, ","),
"1ball,31ball,61ball,91jingle,203done",
"1ball,31ball,61ball,91jingle,171done",
"heal machine: a ball per mon every 30 frames, jingle, flash, done")
eq(toggles, 8, "machine sprites flash 8 times")
check(ha.visible, "machine sprites end visible")
-- the wait phase holds until the jingle actually finishes
-- unfinished jingle must not delay "done" past the flash (#157)
local ha2 = { balls = 1, lit = 0, timer = 0, visible = true }
local doneEarly = false
local doneAt
pcall(function()
for _ = 1, 300 do
if OW.stepHealAnim(ha2) == "done" then doneEarly = true end
for frame = 1, 300 do
if OW.stepHealAnim(ha2) == "done" then doneAt = frame break end
end
end)
check(not doneEarly, "heal machine waits for the jingle to end")
ha2.jingleDone = true
local extra = 0
pcall(function()
repeat extra = extra + 1 until OW.stepHealAnim(ha2) == "done" or extra > 100
end)
eq(extra, 32, "32-frame beat after the jingle")
-- 1 ball @1, jingle @31, 8x10 flash -> done @111
eq(doneAt, 111, "fighting fit is not gated on the jingle ending")
end
-- ---------------------------------------------------------------- HP bar right cap
@@ -2213,8 +2299,12 @@ do
cb4.onFinish = function() end
cb4.rng = function(a, b) return a end -- rng low: guaranteed capture
local origStart = cb4.startMessage
local ballAtCaughtText
cb4.startMessage = function(s, item)
log[#log + 1] = "text:" .. item.text:gsub("\n.*", "")
if item.text:find("All right!", 1, true) then
ballAtCaughtText = cb4.lockedBall and #cb4.lockedBall > 0
end
return origStart(s, item)
end
cb4.queue = {}
@@ -2223,8 +2313,11 @@ do
while steps < 4000 do
steps = steps + 1
Input.pressed = { a = true }
if not cb4:updateQueue() then break end
-- the #172 nickname ui row parks the queue on waitingUI (the battle
-- is not on the stack here); everything under test has run by then
if cb4.waitingUI or not cb4:updateQueue() then break end
end
if cb4.waitingUI then StateStack:pop() end -- the pushed nickname TextBox
Input.pressed = {}
Sound.play = origPlay
local caughtAt, fanfareAt, fanfares, tinks = nil, nil, 0, 0
@@ -2244,7 +2337,9 @@ do
check(fanfareAt and caughtAt and fanfareAt < caughtAt,
"Caught_Mon sounds with the caught text, not after its dismissal")
eq(cb4.result, "caught", "the capture resolved the battle")
check(cb4.lockedBall and #cb4.lockedBall > 0,
-- the nickname AskName that follows clears it (ClearSprites), so the
-- assertion is sampled while the caught text is up
check(ballAtCaughtText,
"the resting closed ball stays compiled for the caught text")
Game.save.party = savedParty
end
@@ -2964,6 +3059,9 @@ runSuites({ "tests/input_hold_test.lua" })
-- ---------------------------------------------- launcher cursor (#114)
runSuites({ "tests/rom_importer_cursor_test.lua" })
-- ---------------------------------------------- Android second ROM pick (#167)
runSuites({ "tests/rom_importer_android_pick_test.lua" })
-- ---------------------------------------------- parity workstream tests
-- Each tests/parity_*.lua is a self-contained file (own bootstrap + check,
-- error()s if any assertion fails). Globbed, so dropping a new parity
+22 -7
View File
@@ -6,7 +6,8 @@ Sources:
data/tilesets/water_tilesets.asm -> tilesets where Surf works
data/events/hidden_events.asm -> hidden items / coins / slot machines,
PC tiles, bench guys, gym statues,
Vermilion Gym trash cans
PrintTrashText bins, Vermilion Gym
trash cans
data/events/bench_guys.asm -> bench guy text per map
data/events/slot_machine_wheels.asm -> the three slot wheel symbol lists
data/events/card_key_{coords,maps}.asm + engine/events/card_key.asm
@@ -117,15 +118,20 @@ def parse_hidden_events(pokered):
StartSlotMachine (slot machine seats; arg SLOTS_* marks broken ones).
Also collects the engine text hooks that the port implements natively:
OpenPokemonCenterPC / OpenRedsPC (the player's storage PC in the
bedroom), PrintBenchGuyText, GymStatues and the Vermilion
Gym GymTrashScript cans (arg = [wGymTrashCanIndex]). For those the
fourth macro argument is the facing direction required to trigger the
event, except GymTrashScript where it is the can index.
bedroom), PrintBenchGuyText, GymStatues, PrintTrashText (plain
"only trash here" bins) and the Vermilion Gym GymTrashScript cans
(arg = [wGymTrashCanIndex]). For those the fourth macro argument is
the facing direction required to trigger the event, except
GymTrashScript where it is the can index and PrintTrashText which
stores a facing but ignores it.
"""
items = {}
coins = {}
slots = {}
extras = {"pcTiles": {}, "benchGuys": {}, "gymStatues": {}, "trashCans": []}
extras = {
"pcTiles": {}, "benchGuys": {}, "gymStatues": {},
"printTrash": {}, "trashCans": [],
}
current = None
path = os.path.join(pokered, "data/events/hidden_events.asm")
for lineno, line in read_asm(path):
@@ -163,6 +169,9 @@ def parse_hidden_events(pokered):
elif func == "GymStatues":
extras["gymStatues"].setdefault(current, []).append(
{"x": x, "y": y, "facing": DIRS.get(arg, arg)})
elif func == "PrintTrashText":
extras["printTrash"].setdefault(current, []).append(
{"x": x, "y": y, "facing": DIRS.get(arg, arg)})
elif func == "GymTrashScript":
if current != "VERMILION_GYM":
util.die(f"hidden_events.asm:{lineno}: GymTrashScript outside VERMILION_GYM")
@@ -1339,6 +1348,7 @@ def extract(pokered, out_dir):
"pcTiles": extras["pcTiles"],
"benchGuys": extras["benchGuys"],
"gymStatues": extras["gymStatues"],
"printTrash": extras["printTrash"],
"trashCans": parse_trash_can_puzzle(pokered, extras["trashCans"]),
}
@@ -1373,6 +1383,7 @@ def extract(pokered, out_dir):
credits["theEnd"] = gfx.extract_the_end(pokered, assets_dir)
battle_hud = gfx.extract_battle_hud(pokered, assets_dir)
town_map["background"] = gfx.extract_town_map_bg(pokered, assets_dir)
trade_art = gfx.extract_trade(pokered, assets_dir)
if not ledges or not cut_trees or "OVERWORLD" not in water or len(trades) < 8 \
or "PALLET_TOWN" not in fly_warps:
@@ -1387,7 +1398,9 @@ def extract(pokered, out_dir):
or len(hidden_extras["pcTiles"]) < 10 \
or "VIRIDIAN_GYM" not in hidden_extras["gymStatues"] \
or hidden_extras["benchGuys"].get("VIRIDIAN_POKECENTER",
[{}])[0].get("text") is None:
[{}])[0].get("text") is None \
or len(hidden_extras["printTrash"].get("SS_ANNE_KITCHEN", [])) != 2 \
or len(hidden_extras["printTrash"].get("VERMILION_GYM", [])) != 1:
util.die("hidden extras extraction sanity check failed")
if sorted(forced_movement["tiles"]) != ["ROUTE_16", "ROUTE_18",
"SEAFOAM_ISLANDS_B3F",
@@ -1499,6 +1512,7 @@ def extract(pokered, out_dir):
"townMap": town_map,
"credits": credits,
"slotSymbols": slot_symbols,
"tradeArt": trade_art,
"emotionBubbles": emotion_bubbles,
"oakSpeech": oak_speech,
"overworldFx": overworld_fx,
@@ -1525,6 +1539,7 @@ def extract(pokered, out_dir):
"+ gfx/credits/the_end.png,\n"
"constants/script_constants.asm (SLOTS*, *_BUBBLE)\n"
"+ gfx/slots/red_slots_{1,2}.png + gfx/emotes/*.png\n"
"+ gfx/trade/* (InternalClockTradeAnim),\n"
"+ engine/{slots/slot_machine,overworld/emotion_bubbles}.asm,\n"
"scripts/ViridianCity.asm + engine/battle/core.asm,\n"
"scripts/GameCorner.asm + text/GameCorner.asm,\n"
+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 "
"(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