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