Compare commits

..

1 Commits

Author SHA1 Message Date
bryanthaboi aad86fb351 pokecenter fix 2026-07-23 15:15:35 -04:00
86 changed files with 651 additions and 5433 deletions
+15 -16
View File
@@ -1,19 +1,18 @@
# Pokemon Gen 1 Recompilation Project
A native LÖVE2D recreation of Pokemon Red and Blue. The engine and map
behavior are hand-written Lua; game data and graphics are decoded from a ROM
supplied by the player.
A native LÖVE2D recreation of Pokemon Red. The engine and map behavior are
hand-written Lua; game data and graphics are decoded from a ROM supplied by
the player.
SUPPORT AND ANNOUNCEMENTS: [Discord](https://bois.icu)
This project does not include a ROM, emulate the Game Boy, transpile assembly,
or download a disassembly. A canonical US Pokemon Red or Blue ROM is the only
game content input.
or download a disassembly. A canonical US Pokemon Red ROM is the only game
content input.
The ROM is verified, used during import, and then released from memory. It is
not copied into the cache. Later launches load the private generated cache and
do not ask for the ROM again. Red and Blue can both be imported and played
side by side.
do not ask for the ROM again.
## Quick Start
@@ -21,12 +20,8 @@ Open the desktop app. On first boot, choose your legally obtained `.gb` file
or drop it onto the window. Import takes a few seconds and the game starts
automatically.
Only the canonical 1 MiB US Red and Blue ROMs are accepted. The importer
verifies SHA-1 before creating any game data:
- Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a`
- Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2`
Only the canonical 1 MiB US Red ROM is accepted. The importer verifies SHA-1
`ea9bcae617fdf159b045185467ae58b2e4a48b9a` before creating any game data.
The packaged app contains neither a ROM nor pre-extracted game data. Music,
sound effects, and cries are synthesized while the game runs from compact
audio channel programs copied out of the verified ROM.
@@ -38,11 +33,11 @@ B; Escape opens START. F1 saves and F2 loads. Controllers are supported.
## Running From Source
Requires LÖVE 11.x. Place a Red or Blue ROM in the project folder and
double-click `Play-Mac.command` or `Play-Windows.bat`, or run:
Requires LÖVE 11.x. Place the ROM in the project folder and double-click
`Play-Mac.command` or `Play-Windows.bat`, or run:
```sh
scripts/setup.sh --rom "/path/to/Pokemon Red.gb" # or Pokemon Blue.gb
scripts/setup.sh --rom "/path/to/Pokemon Red.gb"
scripts/run.sh
```
@@ -112,3 +107,7 @@ request with real detail is one that can actually get built.
This project would not be possible without [pret](https://github.com/pret) >
the pret band of decompiling maniacs > and their
[pokered](https://github.com/pret/pokered) disassembly.
## Wanna Support My Work?
[![Buy Me a Coffee](https://img.shields.io/badge/Buy_Me_a_Coffee-FFDD00?style=for-the-badge&logo=buy-me-a-coffee&logoColor=black)](https://buymeacoffee.com/bryanthaboi)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 753 KiB

After

Width:  |  Height:  |  Size: 370 KiB

+18 -5
View File
@@ -1,11 +1,15 @@
-- Pewter City flavor dialogue (pokered/scripts/PewterCity.asm).
-- PewterCity_TextPointers text_asm bodies for the SUPER_NERD1 museum
-- guide and SUPER_NERD2 garden nerd.
-- guide, SUPER_NERD2 garden nerd, and the leaving-east YOUNGSTER.
--
-- The YOUNGSTER's gym escort (talk + east-exit onStep) lives in
-- story5.lua so the lockstep RLE walk is not overwritten by this
-- flavor merge. SUPER_NERD1's museum escort is not ported; only the
-- YES/NO-branched flavor text is here.
-- The escort choreography (SUPER_NERD1 walking the player to the
-- museum, YOUNGSTER walking the player to the gym) is scripted NPC
-- movement + a wPewterCityCurScript state machine that steers the
-- player off-map; the YOUNGSTER's escort to the gym is handled on this
-- map by story5.lua's onStep gate at the east exit (before
-- EVENT_BEAT_BROCK), while the SUPER_NERD1 museum escort is not ported.
-- Here we only port the real YES/NO-branched flavor text these NPCs
-- speak when talked to.
local M = {}
@@ -57,6 +61,15 @@ M.PEWTER_CITY = {
end)
end,
-- PewterCityYoungsterText (scripts/PewterCity.asm): the "follow
-- me" line the youngster says when the player is stopped from
-- leaving Pewter east before beating Brock; the actual gate/step
-- block is handled by story5.lua's onStep for this map.
TEXT_PEWTERCITY_YOUNGSTER = function(game, ow, npc, done)
local t = text(game)
push(game, t._PewterCityYoungsterYoureATrainerFollowMeText
or "You're a trainer\nright? BROCK's\nlooking for new\nchallengers!\nFollow me!", done)
end,
},
}
-22
View File
@@ -1,22 +0,0 @@
-- Red's House 2F (pokered data/events/hidden_events.asm +
-- engine/events/hidden_objects PrintRedSNESText).
--
-- The bedroom SNES is a hidden_event at (3, 5) with ANY_FACING that
-- prints _RedBedroomSNESText. field.py extracts OpenRedsPC on this map
-- but skips PrintRedSNESText, so interaction was a no-op (#135).
local TextBox = require("src.render.TextBox")
return {
REDS_HOUSE_2F = {
-- hidden_events.asm:
-- hidden_event 3, 5, PrintRedSNESText, ANY_FACING
onInteract = function(game, ow, fx, fy)
if fx ~= 3 or fy ~= 5 then return false end
local text = game.data.text._RedBedroomSNESText
or "{PLAYER} is\nplaying the SNES!"
game.stack:push(TextBox.new(game, text))
return true
end,
},
}
+12 -16
View File
@@ -5,23 +5,19 @@ return {
SILPH_CO_9F = {
talk = {
-- SilphCo9FNurseText (pokered/scripts/SilphCo9F.asm):
-- before EVENT_BEAT_SILPH_CO_GIOVANNI: heals the party, white fade
-- (Delay3 between out/in; no Music_PkmnHealed), then "Don't give
-- up!"; after the event, just says thanks. Nurse texts are not in
-- data/generated/text.lua, so the exact pokered/text/SilphCo9F.asm
-- strings are used as literals.
-- before EVENT_BEAT_SILPH_CO_GIOVANNI: heals the party and shows
-- "You look tired..." then "Don't give up!"; after the event, just
-- says thanks. Nurse texts are not in data/generated/text.lua, so
-- the exact pokered/text/SilphCo9F.asm strings are used as literals.
TEXT_SILPHCO9F_NURSE = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" }, -- 2
{ "jump_if_true", 11 }, -- 3
{ "show_text", "You look tired!\nYou should take a\nquick nap!" }, -- 4
{ "heal_party" }, -- 5
{ "fade", "out", "white" }, -- 6
{ "wait", 3 }, -- 7 Delay3
{ "fade", "in", "white" }, -- 8
{ "show_text", "Don't give up!" }, -- 9
{ "jump", "end" }, -- 10
{ "show_text", "Thank you so\nmuch!" }, -- 11
{ "face_player" },
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
{ "jump_if_true", 8 },
{ "show_text", "You look tired!\nYou should take a\nquick nap!" },
{ "heal_party" },
{ "show_text", "Don't give up!" },
{ "jump", 9 },
{ "show_text", "Thank you so\nmuch!" },
},
},
},
-1
View File
@@ -28,7 +28,6 @@ local files = {
"data.scripts.flavor.pokemon_fan_club",
"data.scripts.flavor.power_plant",
"data.scripts.flavor.reds_house_1f",
"data.scripts.flavor.reds_house_2f",
"data.scripts.flavor.route11_gate_2f",
"data.scripts.flavor.route18_gate_2f",
"data.scripts.flavor.route_12_gate_2f",
+29 -52
View File
@@ -24,23 +24,17 @@ local function starterBall(askText, species, choseFlag, ownBall,
{ "check_flag", "EVENT_FOLLOWED_OAK_INTO_LAB" }, -- 3
{ "jump_if_false", 20 }, -- 4
-- the Pokédex "new species" entry shows before the ask (predef
-- StarterDex ahead of OaksLabYouWant...Text). StarterDex temporarily
-- sets the owned bits so ShowPokedexData prints height/weight/text;
-- forceOwned is that bypass without mutating save.pokedex.owned.
{ "push_screen", "DexEntryMenu",
{ species = species, forceOwned = true } }, -- 5
-- StarterDex ahead of OaksLabYouWant...Text)
{ "push_screen", "DexEntryMenu", species }, -- 5
{ "ask", askText }, -- 6
{ "jump_if_false", 21 }, -- 7
-- OaksLab.asm prints ReceivedMon then AddPartyMon (AskName lives
-- inside give_pokemon). Show the received text first so the
-- nickname prompt follows "you got X", matching Gen1.
{ "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 8
{ "give_pokemon", species, 5 }, -- 9
{ "set_flag", "EVENT_GOT_STARTER" }, -- 10
{ "set_flag", choseFlag }, -- 11
{ "give_pokemon", species, 5 }, -- 8
{ "set_flag", "EVENT_GOT_STARTER" }, -- 9
{ "set_flag", choseFlag }, -- 10
-- POKé BALLs are not handed out here in the original -- Oak gives
-- them later, at OaksLabOak1Text's .give_poke_balls beat once the
-- player has beaten the Route 22 rival (see TEXT_OAKSLAB_OAK1 below)
{ "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 11
{ "hide_object", "OAKS_LAB", ownBall }, -- 12
-- the rival walks to the countering ball (around the furniture)
{ "move_npc_to", 1, rivalBallX, 4 }, -- 13
@@ -67,44 +61,40 @@ return {
TEXT_OAKSLAB_OAK1 = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_GOT_OAKS_PARCEL" }, -- 2
{ "jump_if_false", 16 }, -- 3
{ "jump_if_false", 14 }, -- 3
{ "check_flag", "EVENT_OAK_GOT_PARCEL" }, -- 4
{ "jump_if_true", 16 }, -- 5
{ "jump_if_true", 14 }, -- 5
{ "show_text", "_OaksLabOak1DeliverParcelText" }, -- 6
{ "take_item", "OAKS_PARCEL", 1 }, -- 7
{ "set_flag", "EVENT_OAK_GOT_PARCEL" }, -- 8
{ "show_text", "_OaksLabOak1PokemonAroundTheWorldText" }, -- 9
{ "set_flag", "EVENT_GOT_POKEDEX" }, -- 10
-- OaksLab.asm OakGivesPokedex: HideObject TOGGLE_POKEDEX_1/2
-- so the table sprites leave with the gift (#106).
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX1" }, -- 11
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX2" }, -- 12
-- the Pokédex swaps Viridian's two old men (OaksLab.asm:602-606:
-- HideObject TOGGLE_LYING_OLD_MAN / ShowObject TOGGLE_OLD_MAN).
-- Until this ran, the walking man at (17,5) -- who owns the coffee
-- ask and the catch tutorial -- stayed OFF for the whole game
-- (toggleable_objects.asm seeds him OFF, the sleeper ON).
{ "hide_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY" }, -- 13
{ "show_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN" }, -- 14
{ "jump", "end" }, -- 15
{ "check_flag", "EVENT_GOT_STARTER" }, -- 16
{ "jump_if_false", 31 }, -- 17
{ "check_item", "POKE_BALL" }, -- 18
{ "jump_if_true", 29 }, -- 19
{ "check_flag", "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE" }, -- 20
{ "jump_if_false", 33 }, -- 21
{ "check_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 22
{ "jump_if_true", 29 }, -- 23
{ "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 24
{ "give_item", "POKE_BALL", 5, false }, -- 25
{ "show_text", "_OaksLabOak1ReceivedPokeballsText" }, -- 26
{ "show_text", "_OaksLabGivePokeballsExplanationText" }, -- 27
{ "jump", "end" }, -- 28
{ "show_text", "_OaksLabOak1ComeSeeMeSometimesText" }, -- 29
{ "jump", "end" }, -- 30
{ "show_text", "_OaksLabOak1WhichPokemonDoYouWantText" }, -- 31
{ "jump", "end" }, -- 32
{ "show_text", "_OaksLabOak1RaiseYourYoungPokemonText" }, -- 33
{ "hide_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY" }, -- 11
{ "show_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN" }, -- 12
{ "jump", 32 }, -- 13
{ "check_flag", "EVENT_GOT_STARTER" }, -- 14
{ "jump_if_false", 29 }, -- 15
{ "check_item", "POKE_BALL" }, -- 16
{ "jump_if_true", 27 }, -- 17
{ "check_flag", "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE" }, -- 18
{ "jump_if_false", 31 }, -- 19
{ "check_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 20
{ "jump_if_true", 27 }, -- 21
{ "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 22
{ "give_item", "POKE_BALL", 5, false }, -- 23
{ "show_text", "_OaksLabOak1ReceivedPokeballsText" }, -- 24
{ "show_text", "_OaksLabGivePokeballsExplanationText" }, -- 25
{ "jump", 32 }, -- 26
{ "show_text", "_OaksLabOak1ComeSeeMeSometimesText" }, -- 27
{ "jump", 32 }, -- 28
{ "show_text", "_OaksLabOak1WhichPokemonDoYouWantText" }, -- 29
{ "jump", 32 }, -- 30
{ "show_text", "_OaksLabOak1RaiseYourYoungPokemonText" }, -- 31 (32 = end)
},
TEXT_OAKSLAB_CHARMANDER_POKE_BALL =
@@ -150,19 +140,6 @@ return {
},
},
-- Saves that got the Pokédex before #106 never wrote objectToggles for
-- the table sprites; re-entering the lab applies the same HideObject
-- the gift script now does (OaksLab.asm OakGivesPokedex).
onEnter = function(game, ow)
if not (game.save.flags and game.save.flags.EVENT_GOT_POKEDEX) then
return
end
local Commands = require("src.script.Commands")
local ctx = { save = game.save, game = game, overworld = ow }
Commands.hide_object(ctx, "OAKS_LAB", "OAKSLAB_POKEDEX1")
Commands.hide_object(ctx, "OAKS_LAB", "OAKSLAB_POKEDEX2")
end,
-- Oak stops you leaving without a starter; the rival stops you on
-- the way out for the first battle (scripts/OaksLab.asm
-- OaksLabScript8 / OaksLabRivalChallenge)
+7 -15
View File
@@ -1,23 +1,15 @@
-- Hand-ported from pret/pokered scripts/RedsHouse1F.asm.
-- Mom (RedsHouse1FMomText): pre-starter shows the wake-up / Oak tip;
-- after EVENT_GOT_STARTER, RedsHouse1FMomHealScript fades to white,
-- heals, plays MUSIC_PKMN_HEALED, fades back, then "looking great".
-- Mom (RedsHouse1FMomText, text_asm) heals the party and shows the
-- "you should rest" / "looking great" dialogue. The intro "wake up"
-- branch is tied to the unported intro cutscene, so the heal path is used.
return {
talk = {
TEXT_REDSHOUSE1F_MOM = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_GOT_STARTER" }, -- 2
{ "jump_if_true", 6 }, -- 3
{ "show_text", "_RedsHouse1FMomWakeUpText" }, -- 4
{ "jump", "end" }, -- 5
-- RedsHouse1FMomHealScript
{ "show_text", "_RedsHouse1FMomYouShouldRestText" }, -- 6
{ "fade", "out", "white" }, -- 7 GBFadeOutToWhite
{ "heal_party" }, -- 8
{ "play_once", "Music_PkmnHealed" }, -- 9 wait + restore map
{ "fade", "in", "white" }, -- 10 GBFadeInFromWhite
{ "show_text", "_RedsHouse1FMomLookingGreatText" }, -- 11
{ "face_player" },
{ "show_text", "_RedsHouse1FMomYouShouldRestText" },
{ "heal_party" },
{ "show_text", "_RedsHouse1FMomLookingGreatText" },
},
},
}
+73 -211
View File
@@ -222,55 +222,17 @@ M.BILLS_HOUSE = {
-- repair saves that already got the ticket under the old collapsed
-- script (the monster never hidden, human Bill never shown)
onEnter = function(game, ow)
local flags = game.save.flags
if flags.EVENT_GOT_SS_TICKET
and not flags.EVENT_USED_CELL_SEPARATOR_ON_BILL then
if game.save.flags.EVENT_GOT_SS_TICKET
and not game.save.flags.EVENT_USED_CELL_SEPARATOR_ON_BILL then
local Commands = require("src.script.Commands")
local ctx = { game = game, save = game.save, overworld = ow }
Commands.hide_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL_POKEMON")
Commands.show_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL1")
flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = true
flags.EVENT_USED_CELL_SEPARATOR_ON_BILL = true
flags.EVENT_MET_BILL = true
flags.EVENT_MET_BILL_2 = true
game.save.flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = true
game.save.flags.EVENT_USED_CELL_SEPARATOR_ON_BILL = true
game.save.flags.EVENT_MET_BILL = true
game.save.flags.EVENT_MET_BILL_2 = true
end
-- After Route25ToggleBillsScript, BILL2 is the visible human Bill
-- ("check out my rare POKéMON"). Re-apply on enter so a mid-house
-- load still matches the toggles if the pool was rebuilt.
if flags.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING then
local Commands = require("src.script.Commands")
local ctx = { game = game, save = game.save, overworld = ow }
Commands.hide_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL_POKEMON")
Commands.hide_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL1")
Commands.show_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL2")
end
end,
}
-- -------------------------------------------------------------------
-- Route 25 outside Bill's house (scripts/Route25.asm
-- Route25ToggleBillsScript): leaving after the SS Ticket arms the
-- Eevee PC list and swaps human Bill to his post-help dialogue NPC.
-- Leaving mid-quest (Bill still in the machine) puts the monster back.
-- -------------------------------------------------------------------
M.ROUTE_25 = {
onEnter = function(game, ow)
local flags = game.save.flags
if flags.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING then return end
local Commands = require("src.script.Commands")
local ctx = { game = game, save = game.save, overworld = ow }
if not flags.EVENT_MET_BILL_2 then
flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = nil
Commands.show_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL_POKEMON")
return
end
if not flags.EVENT_GOT_SS_TICKET then return end
flags.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING = true
-- TOGGLE_NUGGET_BRIDGE_GUY (ROUTE24_COOLTRAINER_M1)
Commands.hide_object(ctx, "ROUTE_24", "ROUTE24_COOLTRAINER_M1")
Commands.hide_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL1")
Commands.show_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL2")
end,
}
@@ -294,40 +256,25 @@ M.VERMILION_CITY = {
end,
-- VermilionCityDefaultScript's per-frame SSAnneTicketCheckCoords check:
-- the unguarded cell (18,30) just west of the sailor leads straight
-- onto the dock warp. Stepping onto it facing down always runs the
-- sailor dialog (DisplayTextID TEXT_VERMILIONCITY_SAILOR1); only a
-- ticket while the ship is still docked lets the player continue --
-- otherwise they are walked back up. The sailor himself never hides.
-- onto the dock warp, so stepping onto it heading for the dock gets
-- ticket-checked (and turned back once the ship has sailed) without
-- the player ever pressing A. The sailor himself never disappears.
onStep = function(game, ow, x, y)
if x ~= 18 or y ~= 30 then return false end
if ow.player.facing ~= "down" then return false end
local Flags = require("src.script.Flags")
local f = game.save.flags
local t = game.data.text
local TextBox = require("src.render.TextBox")
local shipLeft = Flags.get(game.save, "EVENT_SS_ANNE_LEFT")
local hasTicket = (game.save.inventory.S_S_TICKET or 0) > 0
if shipLeft then
if f.EVENT_SS_ANNE_LEFT then
game.stack:push(TextBox.new(game,
t._VermilionCitySailor1ShipSetSailText or "The ship set sail.",
function() ow:scriptMove(ow.player, "up", 1) end))
return true
end
-- Walk-past is never facing-right / inFrontOfOrBehindGuardCoords, so
-- VermilionCitySailor1Text always takes .greet_player_and_check_ticket:
-- DoYouHaveATicket, then FlashedTicket (allow through) or YouNeedATicket
-- (walk back). Returning true while the box is up also blocks the
-- dock warp on this step.
local ask = t._VermilionCitySailor1DoYouHaveATicketText
or "Welcome to S.S.\nANNE!\fExcuse me, do you\nhave a ticket?"
if hasTicket then
game.stack:push(TextBox.new(game,
ask .. "\f"
.. (t._VermilionCitySailor1FlashedTicketText
or "{PLAYER} flashed\nthe S.S.TICKET!")))
return true
end
if (game.save.inventory.S_S_TICKET or 0) > 0 then return false end
game.stack:push(TextBox.new(game,
ask .. "\f"
(t._VermilionCitySailor1WelcomeToSSAnneText or "Welcome to S.S.\nANNE!")
.. "\f"
.. (t._VermilionCitySailor1YouNeedATicketText
or "You need a ticket\nto get aboard."),
function() ow:scriptMove(ow.player, "up", 1) end))
@@ -341,7 +288,7 @@ M.VERMILION_CITY = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_SS_ANNE_LEFT" }, -- 2
{ "jump_if_true", 11 }, -- 3
{ "show_text", "_VermilionCitySailor1DoYouHaveATicketText" }, -- 4
{ "show_text", "_VermilionCitySailor1WelcomeToSSAnneText" }, -- 4
{ "check_item", "S_S_TICKET" }, -- 5
{ "jump_if_false", 9 }, -- 6
{ "show_text", "_VermilionCitySailor1FlashedTicketText" }, -- 7
@@ -371,21 +318,18 @@ M.SS_ANNE_2F = {
M.SS_ANNE_CAPTAINS_ROOM = {
talk = {
-- SSAnneCaptainsRoomCaptainText: after the rub line's text_asm tail,
-- pokered plays MUSIC_PKMN_HEALED (scripts/SSAnneCaptainsRoom.asm).
TEXT_SSANNECAPTAINSROOM_CAPTAIN = {
{ "check_flag", "EVENT_GOT_HM01" }, -- 1
{ "jump_if_true", 10 }, -- 2
{ "jump_if_true", 9 }, -- 2
{ "show_text", "_SSAnneCaptainsRoomRubCaptainsBackText" }, -- 3
{ "play_once", "Music_PkmnHealed" }, -- 4
{ "show_text", "_SSAnneCaptainsRoomCaptainIFeelMuchBetterText" }, -- 5
{ "show_text", "_SSAnneCaptainsRoomCaptainIFeelMuchBetterText" }, -- 4
-- give-then-print like scripts/SSAnneCaptainsRoom.asm (GiveItem
-- fills wStringBuffer; the received text reads it)
{ "give_item", "HM_CUT", 1, false }, -- 6
{ "show_text", "_SSAnneCaptainsRoomCaptainReceivedHM01Text" }, -- 7
{ "set_flag", "EVENT_GOT_HM01" }, -- 8
{ "jump", 11 }, -- 9
{ "show_text", "_SSAnneCaptainsRoomCaptainNotSickAnymoreText" }, -- 10
{ "give_item", "HM_CUT", 1, false }, -- 5
{ "show_text", "_SSAnneCaptainsRoomCaptainReceivedHM01Text" }, -- 6
{ "set_flag", "EVENT_GOT_HM01" }, -- 7
{ "jump", 10 }, -- 8
{ "show_text", "_SSAnneCaptainsRoomCaptainNotSickAnymoreText" }, -- 9
},
},
}
@@ -616,8 +560,7 @@ M.SILPH_CO_11F = {
-- -------------------------------------------------------------------
-- Victory Road boulder switches (scripts/VictoryRoad1F/2F/3F.asm):
-- a boulder resting on a switch removes a barrier block; the 3F hole
-- drops a boulder down to the 2F switch, and also dungeon-warps the
-- player (IsPlayerOnDungeonWarp + DungeonWarpList/Data -> 2F at 22,16).
-- drops a boulder down to the 2F switch.
-- -------------------------------------------------------------------
local function boulderAt(ow, x, y)
@@ -683,18 +626,6 @@ M.VICTORY_ROAD_3F = {
Commands.show_object(ctx, "VICTORY_ROAD_2F", "VICTORYROAD2F_BOULDER")
end
end,
-- scripts/VictoryRoad3F.asm VictoryRoad3FDefaultScript: the same hole
-- is a dungeon warp for the player (wDungeonWarpDestinationMap =
-- VICTORY_ROAD_2F, wWhichDungeonWarp = 2 -> DungeonWarpData 22,16).
-- Mirrors POKEMON_MANSION_3F.onStep; CAVERN $22 is walkable so the
-- fall is onStep, not a collision block.
onStep = function(game, ow, x, y)
if x == 23 and y == 15 then
ow:startWarpTo("VICTORY_ROAD_2F", 22, 16, ow.player.facing)
return true
end
return false
end,
}
-- -------------------------------------------------------------------
@@ -707,16 +638,6 @@ M.VICTORY_ROAD_3F = {
-- IndigoPlateauLobby.asm) so the champion is re-fightable on rematches,
-- like pokered's re-entry cutscene. EVENT_BEAT_CHAMPION_RIVAL stays set
-- forever (postgame gates like the Cerulean cave guard read it).
--
-- pokered forces the fight on map entry: Agatha's victory arms
-- SCRIPT_CHAMPIONSROOM_PLAYER_ENTERS (scripts/AgathasRoom.asm), and
-- ChampionsRoomPlayerEntersScript then runs RivalEntrance_RLEMovement
-- (up 1, right 1, up 3) before ChampionsRoomRivalReadyToBattleScript.
-- The rival object has no trainer header / sight range, so without that
-- entrance script the player can walk past (issue #99). We arm on the
-- run flag instead of Agatha's victory bit: same observable effect for
-- first clear and Indigo rematches, without a cross-map script pointer.
--
-- scripts/ChampionsRoom.asm ChampionsRoomRivalDefeatedScript ->
-- OakArrivesScript -> OakCongratulatesPlayerScript ->
-- OakDisappointedWithRivalScript -> OakComeWithMeScript -> OakExitsScript,
@@ -725,69 +646,43 @@ M.VICTORY_ROAD_3F = {
-- itself is NOT run here: it belongs to the HALL_OF_FAME room script
-- (scripts/HallOfFame.asm), so we set a one-shot marker and warp; the room
-- onEnter (M.HALL_OF_FAME below) drives the HoF Oak speech + record.
local championsRoomRivalScript = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 2
{ "jump_if_true", 25 }, -- 3 past end
{ "show_text", "_ChampionsRoomRivalIntroText" }, -- 4
{ "rival_battle", "OPP_RIVAL3", 1 }, -- 5
{ "jump_if_false", 25 }, -- 6 past end
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 7
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL" }, -- 8
-- ChampionsRoomRivalDefeatedScript re-displays TEXT_CHAMPIONSROOM_RIVAL,
-- whose text_asm takes the EVENT_BEAT_CHAMPION_RIVAL branch =
-- _ChampionsRoomRivalAfterBattleText (the in-battle _RivalDefeatedText
-- is the port's generic "<PLAYER> defeated BLUE!" engine line instead).
{ "show_text", "_ChampionsRoomRivalAfterBattleText" }, -- 9
-- ChampionsRoomOakArrivesScript: Music_Cities1AlternateTempo
-- (Cities1, kept into HALL_OF_FAME like BIT_NO_MAP_MUSIC after
-- defeating RIVAL3), then Oak's "{PLAYER}!" + reveal + walk in
{ "play_music", "Music_Cities1", { keep = true } }, -- 10
{ "show_text", "_ChampionsRoomOakText" }, -- 11
{ "show_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 12
{ "move_npc", 2, "up", 5 }, -- 13 OakEntranceAfterVictoryMovement
-- OakCongratulatesPlayerScript: rival faces left, Oak faces down
{ "face_object", 1, "left" }, -- 14
{ "face_object", 2, "down" }, -- 15
{ "show_text", "_ChampionsRoomOakCongratulatesPlayerText" }, -- 16
-- OakDisappointedWithRivalScript: Oak turns to the rival (right)
{ "face_object", 2, "right" }, -- 17
{ "show_text", "_ChampionsRoomOakDisappointedWithRivalText" }, -- 18
-- OakComeWithMeScript: Oak faces down again, then exits up
{ "face_object", 2, "down" }, -- 19
{ "show_text", "_ChampionsRoomOakComeWithMeText" }, -- 20
{ "move_npc", 2, "up", 2 }, -- 21 OakExitChampionsRoomMovement
{ "hide_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 22
-- hand the induction off to the HALL_OF_FAME room (consumed by its
-- onEnter), then warp up into it (destWarp 1 lands at (4,7) facing up)
{ "set_field", "pendingHallOfFame", true }, -- 23
{ "warp", "HALL_OF_FAME", 4, 7, "up" }, -- 24
}
M.CHAMPIONS_ROOM = {
onEnter = function(game, ow)
if game.save.flags.EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN then return end
-- Lance entrance warps land at y=7; HoF return warps land at y=0.
-- Only the south entry should run ChampionsRoomPlayerEntersScript.
if ow.player.cellY < 7 then return end
local rival
for _, npc in ipairs(ow.npcs) do
if npc.def and npc.def.name == "CHAMPIONSROOM_RIVAL" then
rival = npc
break
end
end
-- RivalEntrance_RLEMovement, then the battle/Oak script (queued
-- separately so talk-script jump indices stay 1-based as written).
ow:queueScript({
{ "move_player", "up", 1 },
{ "move_player", "right", 1 },
{ "move_player", "up", 3 },
})
ow:queueScript(championsRoomRivalScript, { npc = rival })
end,
talk = {
TEXT_CHAMPIONSROOM_RIVAL = championsRoomRivalScript,
TEXT_CHAMPIONSROOM_RIVAL = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 2
{ "jump_if_true", 24 }, -- 3
{ "show_text", "_ChampionsRoomRivalIntroText" }, -- 4
{ "rival_battle", "OPP_RIVAL3", 1 }, -- 5
{ "jump_if_false", 24 }, -- 6
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 7
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL" }, -- 8
-- ChampionsRoomRivalDefeatedScript re-displays TEXT_CHAMPIONSROOM_RIVAL,
-- whose text_asm takes the EVENT_BEAT_CHAMPION_RIVAL branch =
-- _ChampionsRoomRivalAfterBattleText (the in-battle _RivalDefeatedText
-- is the port's generic "<PLAYER> defeated BLUE!" engine line instead).
{ "show_text", "_ChampionsRoomRivalAfterBattleText" }, -- 9
-- ChampionsRoomOakArrivesScript: Oak's "{PLAYER}!" then reveal + walk in
{ "show_text", "_ChampionsRoomOakText" }, -- 10
{ "show_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 11
{ "move_npc", 2, "up", 5 }, -- 12 OakEntranceAfterVictoryMovement (3,7)->(3,2)
-- OakCongratulatesPlayerScript: rival faces left, Oak faces down
{ "face_object", 1, "left" }, -- 13
{ "face_object", 2, "down" }, -- 14
{ "show_text", "_ChampionsRoomOakCongratulatesPlayerText" }, -- 15
-- OakDisappointedWithRivalScript: Oak turns to the rival (right)
{ "face_object", 2, "right" }, -- 16
{ "show_text", "_ChampionsRoomOakDisappointedWithRivalText" }, -- 17
-- OakComeWithMeScript: Oak faces down again, then exits up
{ "face_object", 2, "down" }, -- 18
{ "show_text", "_ChampionsRoomOakComeWithMeText" }, -- 19
{ "move_npc", 2, "up", 2 }, -- 20 OakExitChampionsRoomMovement (3,2)->(3,0)
{ "hide_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 21
-- hand the induction off to the HALL_OF_FAME room (consumed by its
-- onEnter), then warp up into it (destWarp 1 lands at (4,7) facing up)
{ "set_field", "pendingHallOfFame", true }, -- 22
{ "warp", "HALL_OF_FAME", 4, 7, "up" }, -- 23
},
},
}
@@ -858,14 +753,10 @@ M.HALL_OF_FAME = {
-- that teleports back to the new-game bedroom spawn (special_warps.asm
-- NewGameWarp: REDS_HOUSE_2F, 3, 6, facing down). Fabricated
-- convenience -- there is no such prompt in the original ROM.
-- Heal + remember_outdoor so house LAST_MAP mats land in Pallet, not
-- Indigo Plateau (issue #103 escape path for already-stuck saves).
TEXT_HALLOFFAME_PC = {
{ "ask", "Return to\nPALLET TOWN?" }, -- 1 YES/NO -> lastCheck
{ "jump_if_false", "end" }, -- 2 NO: back away
{ "heal_party" }, -- 3
{ "remember_outdoor", "PALLET_TOWN", 5, 6 }, -- 4 LAST_MAP -> Pallet door
{ "warp", "REDS_HOUSE_2F", 3, 6, "down" }, -- 5 YES: home to your room
{ "warp", "REDS_HOUSE_2F", 3, 6, "down" }, -- 3 YES: home to your room
},
},
}
@@ -892,51 +783,21 @@ M.CERULEAN_CITY = {
},
}
-- PokemonTower2F.asm: after the battle, PokemonTower2FDefeatedRivalScript
-- walks him out (RightThenDown vs DownThenRight from EVENT_POKEMON_TOWER_
-- RIVAL_ON_LEFT) then HideObject TOGGLE_POKEMON_TOWER_2F_RIVAL. Player
-- at (15,5) is the ON_LEFT case.
local TOWER_RIVAL_EXIT_RIGHT_THEN_DOWN =
{ "right", "down", "down", "right", "down", "down", "right", "right" }
local TOWER_RIVAL_EXIT_DOWN_THEN_RIGHT =
{ "down", "down", "right", "right", "right", "right", "down", "down" }
local function pokemonTower2FRivalScript(playerX)
local exitDirs = (playerX == 15)
and TOWER_RIVAL_EXIT_DOWN_THEN_RIGHT
or TOWER_RIVAL_EXIT_RIGHT_THEN_DOWN
return {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 2
{ "jump_if_true", 12 }, -- 3
{ "show_text", "_PokemonTower2FRivalWhatBringsYouHereText" }, -- 4
{ "rival_battle", "OPP_RIVAL2", 4 }, -- 5
{ "jump_if_false", "end" }, -- 6 loss: stay
{ "set_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 7
{ "show_text", "_PokemonTower2FRivalDefeatedText" }, -- 8
{ "walk_npc", 1, exitDirs }, -- 9
{ "hide_object", "POKEMON_TOWER_2F", "POKEMONTOWER2F_RIVAL" }, -- 10
{ "jump", "end" }, -- 11
{ "show_text", "_PokemonTower2FRivalHowsYourDexText" }, -- 12
}
end
M.POKEMON_TOWER_2F = {
rivalScript = pokemonTower2FRivalScript,
talk = {
TEXT_POKEMONTOWER2F_RIVAL = function(game, ow, npc, done)
ow.runner:run(pokemonTower2FRivalScript(ow.player.cellX),
{ npc = npc, onDone = done })
end,
TEXT_POKEMONTOWER2F_RIVAL = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 2
{ "jump_if_true", 10 }, -- 3
{ "show_text", "_PokemonTower2FRivalWhatBringsYouHereText" }, -- 4
{ "rival_battle", "OPP_RIVAL2", 4 }, -- 5
{ "jump_if_false", 11 }, -- 6
{ "set_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 7
{ "show_text", "_PokemonTower2FRivalDefeatedText" }, -- 8
{ "jump", 11 }, -- 9
{ "show_text", "_PokemonTower2FRivalHowsYourDexText" }, -- 10
},
},
-- Saves that beat him before the exit walk was ported still have the
-- flag but a visible rival; hide on enter like BillsHouse repairs.
onEnter = function(game, ow)
if not game.save.flags.EVENT_BEAT_POKEMON_TOWER_RIVAL then return end
local Commands = require("src.script.Commands")
Commands.hide_object({ game = game, save = game.save, overworld = ow },
"POKEMON_TOWER_2F", "POKEMONTOWER2F_RIVAL")
end,
-- PokemonTower2FDefaultScript: walking past the rival's tile forces
-- the encounter (ArePlayerCoordsInArray on (15,5)/(14,6)) -- he never
-- waits to be talked to
@@ -948,7 +809,8 @@ M.POKEMON_TOWER_2F = {
if not rival then return false end
ow.player.facing = (x == 15) and "left" or "up"
require("src.core.Music").play(game.data, "Music_MeetRival")
ow.runner:run(pokemonTower2FRivalScript(x), { npc = rival })
ow.runner:run(M.POKEMON_TOWER_2F.talk.TEXT_POKEMONTOWER2F_RIVAL,
{ npc = rival })
return true
end,
}
+44 -141
View File
@@ -719,170 +719,73 @@ M.CINNABAR_LAB_FOSSIL_ROOM = {
-- -------------------------------------------------------------------
-- Day-care (scripts/Daycare.asm): the boarded Pokémon earns 1 exp per
-- step; the fee is ¥100 plus ¥100 per level gained.
--
-- #118: do not raise mon.level until a paid retrieve (pokered reverts
-- wDayCareMonBoxLevel on .leaveMonInDayCare). Fold pending steps into
-- mon.exp once and clear them so a second talk cannot re-apply the same
-- walk. Fill {RAM:wNameBuffer}/{RAM:wDayCareMonName}/{NUM:...} here —
-- TextBox.TOKENS.RAM only knows wStringBuffer.
-- -------------------------------------------------------------------
local function fillDaycareText(s, subs)
s = s:gsub("{PLAYER}", subs.player or "")
s = s:gsub("{RAM:([^}]*)}", function(name) return subs[name] or "" end)
-- extractor NUM spans may include flags after a comma; keep the name
s = s:gsub("{NUM:([%w_]+)[^}]*}", function(name)
return tostring(subs[name] or "0")
end)
return s
end
M.DAYCARE = {
talk = {
TEXT_DAYCARE_GENTLEMAN = function(game, ow, npc, done)
local TextBox = require("src.render.TextBox")
local ChoiceBox = require("src.ui.ChoiceBox")
local t = game.data.text
local dc = game.save.daycare
local playerName = game.save.player and game.save.player.name or "RED"
local function monName(mon)
local def = game.data.pokemon[mon.species]
return mon.nickname or (def and def.name) or mon.species
end
if dc and dc.mon then
local Growth = require("src.pokemon.Growth")
local Stats = require("src.pokemon.Stats")
local Party = require("src.pokemon.Party")
local mon = dc.mon
local def = game.data.pokemon[mon.species]
-- Apply deferred step-exp once (OverworldController only bumps
-- daycare.steps). Clearing prevents double-count on re-talk.
mon.exp = (mon.exp or 0) + (dc.steps or 0)
dc.steps = 0
-- depositLevel mirrors wDayCareMonBoxLevel: fee baseline that
-- must survive a declined retrieve. Fall back to mon.level for
-- older saves that predate the field.
if dc.depositLevel == nil then dc.depositLevel = mon.level end
local startLevel = dc.depositLevel
local newLevel = Growth.levelForExp(def and def.growthRate, mon.exp)
if newLevel >= 100 then
newLevel = 100
if def then
mon.exp = Growth.expForLevel(def.growthRate, 100)
end
end
local levelsGrown = math.max(0, newLevel - startLevel)
local fee = 100 + levelsGrown * 100
local name = monName(mon)
local subs = {
player = playerName,
wNameBuffer = name,
wDayCareMonName = name,
wDayCareNumLevelsGrown = levelsGrown,
wDayCareTotalCost = fee,
}
local statusText = levelsGrown > 0
and (t._DaycareGentlemanMonHasGrownText
or "Your {RAM:wNameBuffer}\nhas grown a lot!\fBy level, it's\ngrown by {NUM:wDayCareNumLevelsGrown, 1, 3}!\fAren't I great?")
or (t._DaycareGentlemanMonNeedsMoreTimeText
or "Back already?\nYour {RAM:wNameBuffer}\nneeds some more\ntime with me.")
game.stack:push(TextBox.new(game, fillDaycareText(statusText, subs), function()
if #game.save.party >= Party.MAX then
game.stack:push(TextBox.new(game,
t._DaycareGentlemanNoRoomForMonText
or "You have no room\nfor this POKéMON!", done))
return
end
game.stack:push(TextBox.new(game,
fillDaycareText(
t._DaycareGentlemanOweMoneyText
or "You owe me ¥{NUM:wDayCareTotalCost, 2 | LEADING_ZEROES | LEFT_ALIGN}\nfor the return\nof this POKéMON.",
subs),
nil, { choice = function(yes)
if not yes then
-- .leaveMonInDayCare: revert any transient level bump
mon.level = startLevel
game.stack:push(TextBox.new(game,
(t._DaycareGentlemanAllRightThenText or "All right then,\n")
.. (t._DaycareGentlemanComeAgainText or "come again."),
done))
return
end
if (game.save.money or 0) < fee then
mon.level = startLevel
game.stack:push(TextBox.new(game,
t._DaycareGentlemanNotEnoughMoneyText
or "Hey, you don't\nhave enough ¥!", done))
return
end
mon.exp = mon.exp + (dc.steps or 0)
local newLevel = math.min(100, Growth.levelForExp(def.growthRate, mon.exp))
local fee = 100 + (newLevel - mon.level) * 100
local grew = newLevel > mon.level
mon.level = newLevel
mon.stats = Stats.calc(def, mon.level, mon.dvs, mon.statExp)
mon.hp = mon.stats.hp
local msg = grew and (t._DaycareGentlemanMonHasGrownText or "It's grown a lot!")
or "Back already?"
game.stack:push(TextBox.new(game,
msg .. ("\fThe fee is ¥%d.\nGet it back?"):format(fee), function()
game.stack:push(ChoiceBox.new(game, function(yes)
if yes and game.save.money >= fee then
game.save.money = game.save.money - fee
mon.level = newLevel
if def then
mon.stats = Stats.calc(def, mon.level, mon.dvs, mon.statExp)
mon.hp = mon.stats.hp
end
table.insert(game.save.party, mon)
game.save.daycare = nil
game.stack:push(TextBox.new(game,
t._DaycareGentlemanHeresYourMonText
or "Thank you! Here's\nyour POKéMON!", function()
game.stack:push(TextBox.new(game,
fillDaycareText(
t._DaycareGentlemanGotMonBackText
or "{PLAYER} got\n{RAM:wDayCareMonName} back!",
subs), done))
end))
end }))
t._DaycareGentlemanGotMonBackText or "Here you go!", done))
else
game.stack:push(TextBox.new(game,
yes and (t._DaycareGentlemanOweMoneyText or "You owe me money!")
or "Come again!", done))
end
end))
end))
return
end
if #game.save.party < 2 then
game.stack:push(TextBox.new(game,
"You only have one\nPOKéMON with you!", done))
return
end
game.stack:push(TextBox.new(game,
t._DaycareGentlemanIntroText
or "I run a DAYCARE.\nWould you like me\nto raise one of\nyour POKéMON?",
nil, { choice = function(yes)
if not yes then
game.stack:push(TextBox.new(game,
t._DaycareGentlemanComeAgainText or "come again.", done))
return
end
if #game.save.party < 2 then
game.stack:push(TextBox.new(game,
t._DaycareGentlemanOnlyHaveOneMonText
or "You only have one\nPOKéMON with you.", done))
return
end
game.stack:push(TextBox.new(game,
t._DaycareGentlemanWhichMonText or "Which POKéMON\nshould I raise?",
function()
local PartyMenu = require("src.ui.PartyMenu")
game.stack:push(PartyMenu.new(game, {
pickOnly = true,
onSwitch = function(mon)
for i, m in ipairs(game.save.party) do
if m == mon then table.remove(game.save.party, i) break end
end
local name = monName(mon)
-- depositLevel = wDayCareMonBoxLevel at deposit time
game.save.daycare = {
mon = mon, steps = 0, depositLevel = mon.level,
}
game.stack:push(TextBox.new(game,
fillDaycareText(
t._DaycareGentlemanWillLookAfterMonText
or "Fine, I'll look\nafter {RAM:wNameBuffer}\nfor a while.",
{ player = playerName, wNameBuffer = name }),
function()
game.stack:push(TextBox.new(game,
t._DaycareGentlemanComeSeeMeInAWhileText
or "Come see me in\na while.", done))
end))
end,
}))
end))
end }))
t._DaycareGentlemanIntroText or "I can raise a\nPOKéMON for you.", function()
game.stack:push(ChoiceBox.new(game, function(yes)
if not yes then done() return end
local PartyMenu = require("src.ui.PartyMenu")
game.stack:push(PartyMenu.new(game, {
pickOnly = true,
onSwitch = function(mon)
for i, m in ipairs(game.save.party) do
if m == mon then table.remove(game.save.party, i) break end
end
game.save.daycare = { mon = mon, steps = 0 }
game.stack:push(TextBox.new(game,
t._DaycareGentlemanWillLookAfterMonText or
"Fine, I'll look\nafter it a while!", done))
end,
}))
end))
end))
end,
},
}
+48 -196
View File
@@ -150,20 +150,18 @@ local WALK_DIRVEC = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right =
local WALK_OPP = { up = "down", down = "up", left = "right", right = "left" }
local WALK_ORDER = { "up", "down", "left", "right" }
-- .UpdateWarp: point EVERY car exit warp at the same floor's elevator
-- door (warp id, map id). Shared generated map data, but the car's
-- warps are only read from inside the car; rides rewrite them again.
local function elevatorSetExit(ow, floor)
if not floor then return end
for _, w in ipairs(ow.map.def.warps) do
local function elevatorWalkOut(ow, floor)
local m, p = ow.map, ow.player
-- .UpdateWarp is run twice, so BOTH car warp entries get the same
-- (warp id, map id): point every exit warp at the picked floor's
-- elevator-door warp (the reciprocal warp found while building the
-- menu). The car map's def is shared generated data, but its own
-- warps are only ever read from inside the car, and this rewrite runs
-- on every ride before the walk-out fires, so it is self-correcting.
for _, w in ipairs(m.def.warps) do
w.destMap = floor.map
w.destWarp = floor.warpIdx
end
end
local function elevatorWalkOut(ow, floor)
local m, p = ow.map, ow.player
elevatorSetExit(ow, floor)
-- leave by the exit tile under the player (they warped in onto one),
-- else the nearest
local door
@@ -201,68 +199,37 @@ local function elevatorWalkOut(ow, floor)
end)
end
local function elevatorFloors(elevatorMapId, game)
local floors = {}
for mapId, def in pairs(game.data.maps) do
for i, w in ipairs(def.warps) do
if w.destMap == elevatorMapId then
-- short floor token pokered actually prints, e.g.
-- SILPH_CO_10F -> "10F", ROCKET_HIDEOUT_B2F -> "B2F"
local token = mapId:match("_([^_]+)$") or mapId
-- warpIdx: this floor's warp back into the elevator IS the
-- warp the car's rewritten exit lands on (the reciprocal
-- pair), matching wElevatorWarpMaps' (warp id, map id)
table.insert(floors,
{ map = mapId, x = w.x, y = w.y, token = token, warpIdx = i })
break
end
end
end
-- numeric floor order (SilphCoElevatorFloors' FLOOR_1F..FLOOR_11F),
-- not lexicographic -- otherwise 10F/11F sort before 2F..9F
table.sort(floors, function(a, b)
return (tonumber(a.token:match("%d+")) or 0) <
(tonumber(b.token:match("%d+")) or 0)
end)
return floors
end
local function elevatorSeedExit(ow, floors, fromMapId)
-- Seed a walk-out destination before the menu (or key-gate text):
-- entry floor when known, else the first listed floor (1F). Choosing
-- a floor still rewrites via elevatorWalkOut; B-cancel / no-key leave
-- keeps this seed so walking out of the car cannot hit a missing ROM
-- placeholder (#123) or the car's static default floor (#90: Rocket
-- Hideout defaults to B1F even when entered from B2F/B4F).
local exitFloor = floors[1]
if fromMapId then
for _, f in ipairs(floors) do
if f.map == fromMapId then exitFloor = f break end
end
end
elevatorSetExit(ow, exitFloor)
return exitFloor
end
local function elevator(elevatorMapId, keyGate, preFrames)
return {
-- fromMapId: the floor the player just left (setMap passes it), so a
-- B-cancel can still walk out onto a real map. Silph's ROM car warps
-- default to UNUSED_MAP_ED, which is not in Data.maps -- Warp.resolve
-- asserted and hard-crashed (#123).
onEnter = function(game, ow, fromMapId)
local floors = elevatorFloors(elevatorMapId, game)
elevatorSeedExit(ow, floors, fromMapId)
-- Rocket Hideout: without LIFT_KEY the panel only prints the need-
-- a-key line (scripts/RocketHideoutElevator.asm). Exit warps are
-- still seeded above so walking out returns to the entry floor
-- instead of the car's ROM default (B1F) — #90 / #105.
onEnter = function(game, ow)
if keyGate and not game.save.inventory[keyGate.item] then
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game,
game.data.text[keyGate.text] or "It appears to\nneed a key."))
return
end
local floors = {}
for mapId, def in pairs(game.data.maps) do
for i, w in ipairs(def.warps) do
if w.destMap == elevatorMapId then
-- short floor token pokered actually prints, e.g.
-- SILPH_CO_10F -> "10F", ROCKET_HIDEOUT_B2F -> "B2F"
local token = mapId:match("_([^_]+)$") or mapId
-- warpIdx: this floor's warp back into the elevator IS the
-- warp the car's rewritten exit lands on (the reciprocal
-- pair), matching wElevatorWarpMaps' (warp id, map id)
table.insert(floors,
{ map = mapId, x = w.x, y = w.y, token = token, warpIdx = i })
break
end
end
end
-- numeric floor order (SilphCoElevatorFloors' FLOOR_1F..FLOOR_11F),
-- not lexicographic -- otherwise 10F/11F sort before 2F..9F
table.sort(floors, function(a, b)
return (tonumber(a.token:match("%d+")) or 0) <
(tonumber(b.token:match("%d+")) or 0)
end)
local items = {}
for _, f in ipairs(floors) do
table.insert(items, { label = f.token, value = f })
@@ -295,8 +262,7 @@ local function elevator(elevatorMapId, keyGate, preFrames)
end,
onCancel = function()
-- DisplayElevatorFloorMenu: `ret c` on B -- no warp, nothing
-- happens, the player just stays in the car (exit warps were
-- already seeded to the entry floor above)
-- happens, the player just stays in the car
end,
}))
end,
@@ -308,98 +274,6 @@ M.CELADON_MART_ELEVATOR = elevator("CELADON_MART_ELEVATOR", nil, 9)
M.ROCKET_HIDEOUT_ELEVATOR = elevator("ROCKET_HIDEOUT_ELEVATOR",
{ item = "LIFT_KEY", text = "_RocketHideoutElevatorAppearsToNeedKeyText" })
-- -------------------------------------------------------------------
-- Rocket Hideout B4F (scripts/RocketHideoutB4F.asm):
-- Rocket3's after-battle text_asm drops the LIFT KEY item ball
-- (CheckAndSetEvent EVENT_ROCKET_DROPPED_LIFT_KEY / ShowObject
-- TOGGLE_ROCKET_HIDEOUT_B4F_ITEM_5). Both start hidden in the map
-- objects; without this talk side-effect the key never appears (#90,
-- #105).
-- Giovanni's post-battle script likewise ShowObject's the Silph Scope
-- after the hope-we-meet-again line (TOGGLE_ROCKET_HIDEOUT_B4F_ITEM_4).
-- -------------------------------------------------------------------
M.ROCKET_HIDEOUT_B4F = {
talk = {
TEXT_ROCKETHIDEOUTB4F_ROCKET3 = function(game, ow, npc, done)
if not ow:trainerDefeated(npc) then
ow:engageTrainer(npc, done)
return
end
local TextBox = require("src.render.TextBox")
local t = game.data.text
game.stack:push(TextBox.new(game,
t._RocketHideoutB4FRocket3AfterBattleText
or "Oh no! I dropped\nthe LIFT KEY!",
function()
-- CheckAndSetEvent EVENT_ROCKET_DROPPED_LIFT_KEY: first talk
-- after the win reveals the ball; later talks only reprint.
if not game.save.flags.EVENT_ROCKET_DROPPED_LIFT_KEY then
game.save.flags.EVENT_ROCKET_DROPPED_LIFT_KEY = true
local Commands = require("src.script.Commands")
Commands.show_object(
{ game = game, save = game.save, overworld = ow },
"ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_LIFT_KEY")
end
done()
end))
end,
TEXT_ROCKETHIDEOUTB4F_GIOVANNI = function(game, ow, npc, done)
-- Giovanni has no trainer-header row (def_trainers 2); his text_asm
-- owns both the engage and the BeatGiovanniScript aftermath.
if ow:trainerDefeated(npc)
or game.save.flags.EVENT_BEAT_ROCKET_HIDEOUT_GIOVANNI then
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game,
game.data.text._RocketHideoutB4FGiovanniHopeWeMeetAgainText
or "I hope we meet\nagain...", done))
return
end
local TextBox = require("src.render.TextBox")
local BattleState = require("src.battle.BattleState")
local t = game.data.text
local impressed = t._RocketHideoutB4FGiovanniImpressedYouGotHereText
or "So! I must say, I\nam impressed you\ngot here!"
local cannotBe = t._RocketHideoutB4FGiovanniWhatCannotBeText
or "WHAT!\nThis cannot be!"
local hope = t._RocketHideoutB4FGiovanniHopeWeMeetAgainText
or "I hope we meet\nagain..."
game.stack:push(TextBox.new(game, impressed, function()
local battle = BattleState.newTrainer(game, "OPP_GIOVANNI", 1)
battle.onFinish = function(result)
if result ~= "win" then
ow:afterBattle(result, battle)
done()
return
end
game.save.defeatedTrainers[npc.id] = true
game.save.flags.EVENT_BEAT_ROCKET_HIDEOUT_GIOVANNI = true
-- End-battle "WHAT!" then BeatGiovanniScript's hope text,
-- fade, HideObject Giovanni, ShowObject Silph Scope.
game.stack:push(TextBox.new(game, cannotBe, function()
game.stack:push(TextBox.new(game, hope, function()
local Transition = require("src.render.Transition")
game.stack:push(Transition.new(game, function()
local Commands = require("src.script.Commands")
local ctx = { game = game, save = game.save, overworld = ow }
Commands.hide_object(ctx, "ROCKET_HIDEOUT_B4F",
"ROCKETHIDEOUTB4F_GIOVANNI")
Commands.show_object(ctx, "ROCKET_HIDEOUT_B4F",
"ROCKETHIDEOUTB4F_SILPH_SCOPE")
end, function()
ow:afterBattle(result, battle)
done()
end))
end))
end))
end
ow:pushBattle(battle)
end))
end,
},
}
-- -------------------------------------------------------------------
-- Game Corner coins, prizes, and the rocket-poster switch that reveals
-- the hideout stairs (scripts/GameCorner.asm, data/events/prizes.asm +
@@ -412,23 +286,14 @@ M.GAME_CORNER = {
-- EVENT_FOUND_ROCKET_HIDEOUT is unset, $43 after)
onEnter = function(game, ow)
local poster = game.data.field.gameCornerPoster
if poster then
local block = game.save.flags[poster.event] and poster.openBlock
or poster.closedBlock
ow:replaceBlock(poster.x, poster.y, block)
end
if not poster then return end
local block = game.save.flags[poster.event] and poster.openBlock
or poster.closedBlock
ow:replaceBlock(poster.x, poster.y, block)
-- pick this visit's lucky slot machine
-- (wLuckySlotHiddenEventIndex, engine/slots/game_corner_slots2.asm)
local seats = game.data.field.slotMachines.GAME_CORNER
ow.luckySlot = love.math.random(1, #seats)
-- #131: pre-#50 saves beat the poster grunt (defeatedTrainers) but
-- never hid him; clear the tile if he is already marked defeated
local rocketId = "GAME_CORNER_obj_11"
if game.save.defeatedTrainers and game.save.defeatedTrainers[rocketId] then
local Commands = require("src.script.Commands")
Commands.hide_object({ game = game, save = game.save, overworld = ow },
"GAME_CORNER", "GAMECORNER_ROCKET")
end
end,
talk = {
-- the poster bg event: pressing A reveals the hidden switch
@@ -457,16 +322,7 @@ M.GAME_CORNER = {
-- losing he warns the BOSS and leaves the floor for good, freeing
-- the tile in front of the hideout switch
TEXT_GAMECORNER_ROCKET = function(game, ow, npc, done)
local Commands = require("src.script.Commands")
local function hideRocket()
Commands.hide_object({ game = game, save = game.save,
overworld = ow },
"GAME_CORNER", "GAMECORNER_ROCKET")
end
-- already beaten: hide anyway so pre-#50 saves that only have
-- defeatedTrainers (no objectToggles hide) clear the poster tile
if ow:trainerDefeated(npc) then
hideRocket()
done()
return
end
@@ -480,7 +336,10 @@ M.GAME_CORNER = {
game.data.text._GameCornerRocketAfterBattleText
or "Our hideout might\nbe discovered! I\nbetter tell BOSS!",
function()
hideRocket()
local Commands = require("src.script.Commands")
Commands.hide_object({ game = game, save = game.save,
overworld = ow },
"GAME_CORNER", "GAMECORNER_ROCKET")
done()
end))
end)
@@ -622,9 +481,8 @@ local DOCK_SHIP_BLOCKS = {
M.VERMILION_DOCK = {
onEnter = function(game, ow)
local Flags = require("src.script.Flags")
local f = game.save.flags
if Flags.get(game.save, "EVENT_SS_ANNE_LEFT") then
if f.EVENT_SS_ANNE_LEFT then
-- the ship is long gone: erase her right away, and anyone who
-- still lands here is sent back out past the guard
for _, b in ipairs(DOCK_SHIP_BLOCKS) do
@@ -640,15 +498,13 @@ M.VERMILION_DOCK = {
elseif f.EVENT_GOT_HM01 and ow.player.cellY == 2 then
-- VermilionDockSSAnneLeavesScript: only stepping OFF the ship
-- triggers the departure (wDestinationWarpID == 1 in pokered) --
-- Music_Surfing plays for the sail-away cutscene, smoke puffs
-- drift off the funnel, the horn blows, the ship is erased to
-- open water, and the player is walked off the dock into the
-- city past the guard (VermilionCity's
-- the horn blows, smoke puffs drift off the funnel, the ship is
-- erased to open water, and the player is walked off the dock
-- into the city past the guard (VermilionCity's
-- SCRIPT_VERMILIONCITY_PLAYER_EXIT_SHIP walk)
Flags.set(game.save, "EVENT_SS_ANNE_LEFT")
local Music = require("src.core.Music")
Music.stop()
Music.play(game.data, "Music_Surfing")
f.EVENT_SS_ANNE_LEFT = true
require("src.core.Music").stop()
require("src.core.Sound").play(game.data, "SS_Anne_Horn")
local function puff(n, cx)
if n <= 0 then return end
ow:startDustAnim(cx, 1, function() puff(n - 1, cx + 2) end)
@@ -656,15 +512,11 @@ M.VERMILION_DOCK = {
puff(3, 15)
local rows = {}
rows[#rows + 1] = { "wait", 100 }
rows[#rows + 1] = { "play_sound", "SS_Anne_Horn" }
for _, b in ipairs(DOCK_SHIP_BLOCKS) do
rows[#rows + 1] = { "replace_block", b.bx, b.by, b.water }
end
rows[#rows + 1] = { "wait", 30 }
rows[#rows + 1] = { "move_player", "up", 2 }
-- keep Music_Surfing across the city warp (pokered stays on it
-- through the exit-ship walk)
rows[#rows + 1] = { "play_music", "Music_Surfing", { keep = true } }
rows[#rows + 1] = { "warp", "VERMILION_CITY", 18, 31, "up" }
rows[#rows + 1] = { "move_player", "up", 2 }
ow:queueScript(rows)
+18 -68
View File
@@ -645,44 +645,12 @@ local function e4ExitSeal(flag, closedBlock, openBlock, dontRunText, autoFlag)
}
end
-- Lorelei/Bruno/Agatha EndBattleScript (pokered): EndTrainerBattle then
-- DisplayTextID -> TalkToTrainer, which prints the AfterBattle text on a
-- win. engageTrainer only shows the won line, so wrap talk like Lance /
-- Game Corner Rocket and push header.after immediately after a win.
local function e4LeaderTalk(afterLabel)
return function(game, ow, npc, done)
done = done or function() end
if ow:trainerDefeated(npc) then
local after = text(game)[afterLabel]
if after then push(game, after, done) else done() end
return
end
ow:engageTrainer(npc, function()
if not ow:trainerDefeated(npc) then
done()
return
end
local after = text(game)[afterLabel]
if after then push(game, after, done) else done() end
end)
end
end
M.LORELEIS_ROOM = e4ExitSeal("EVENT_BEAT_LORELEIS_ROOM_TRAINER_0", 0x24, 0x05,
"_LoreleisRoomLoreleiDontRunAwayText", "EVENT_AUTOWALKED_INTO_LORELEIS_ROOM")
M.LORELEIS_ROOM.talk = {
TEXT_LORELEISROOM_LORELEI = e4LeaderTalk("_LoreleisRoomLoreleiAfterBattleText"),
}
M.BRUNOS_ROOM = e4ExitSeal("EVENT_BEAT_BRUNOS_ROOM_TRAINER_0", 0x24, 0x05,
"_BrunosRoomBrunoDontRunAwayText", "EVENT_AUTOWALKED_INTO_BRUNOS_ROOM")
M.BRUNOS_ROOM.talk = {
TEXT_BRUNOSROOM_BRUNO = e4LeaderTalk("_BrunoAfterBattleText"),
}
M.AGATHAS_ROOM = e4ExitSeal("EVENT_BEAT_AGATHAS_ROOM_TRAINER_0", 0x3b, 0x0e,
"_AgathasRoomAgathaDontRunAwayText", "EVENT_AUTOWALKED_INTO_AGATHAS_ROOM")
M.AGATHAS_ROOM.talk = {
TEXT_AGATHASROOM_AGATHA = e4LeaderTalk("_AgathaAfterBattleText"),
}
-- -------------------------------------------------------------------
-- Lance's room (scripts/LancesRoom.asm). Unlike the other three E4
@@ -704,21 +672,13 @@ M.AGATHAS_ROOM.talk = {
-- EVENT_LANCES_ROOM_LOCK_DOOR: first crossing seals
-- the door behind the player with SFX_GO_INSIDE
-- (24,16) the entrance staircase -> WalkToLance: an auto-walk
-- landing on (6,11). Vanilla WalkToLance_RLEList is
-- up 12 / left 12 / down 7 / left 6 and skips collision
-- through void tiles; our camera follows that literally
-- and briefly shows the empty upper chamber (reads as
-- "Gary's room"). We keep the same landing cell but
-- route along the open-door floor corridor instead.
-- (up 12, left 12, down 7, left 6) landing on (6,11).
-- It marches straight across the room's water decor:
-- pokered's CollisionCheckOnLand skips collision
-- entirely while simulated joypad states run, and our
-- scriptMove is collision-free the same way.
-- -------------------------------------------------------------------
-- Floor corridor (24,16) -> (6,11) with EVENT_LANCES_ROOM_LOCK_DOOR
-- unset (entrance blocks $31/$32). Exposed for parity tests.
local LANCE_WALK_IN = {
{ "down", 2 }, { "left", 6 }, { "down", 5 }, { "left", 10 },
{ "up", 9 }, { "left", 2 }, { "up", 3 },
}
local function lanceEntranceBlocks(game, ow)
local locked = game.save.flags.EVENT_LANCES_ROOM_LOCK_DOOR
ow:replaceBlock(2, 6, locked and 0x72 or 0x31)
@@ -733,24 +693,21 @@ local function lanceLockDoor(game, ow)
end
local function lanceWalkIn(game, ow)
local i = 0
local function step()
i = i + 1
local seg = LANCE_WALK_IN[i]
if not seg then
-- lands on (6,11); vanilla's per-frame coord poll then locks the
-- door at once. scriptMove landings do not fire onStep, so lock
-- here.
lanceLockDoor(game, ow)
return
end
ow:scriptMove(ow.player, seg[1], seg[2], step)
end
step()
ow:scriptMove(ow.player, "up", 12, function()
ow:scriptMove(ow.player, "left", 12, function()
ow:scriptMove(ow.player, "down", 7, function()
ow:scriptMove(ow.player, "left", 6, function()
-- the walk lands on (6,11); vanilla's per-frame coord poll
-- then locks the door at once. scriptMove landings do not
-- fire onStep, so lock here.
lanceLockDoor(game, ow)
end)
end)
end)
end)
end
M.LANCES_ROOM = {
walkInRoute = LANCE_WALK_IN,
onEnter = function(game, ow)
lanceEntranceBlocks(game, ow)
-- the warp arrival lands ON the staircase trigger, and onStep only
@@ -770,14 +727,7 @@ M.LANCES_ROOM = {
end
if not lance or ow:trainerDefeated(lance) then return false end
lance:facePlayer(ow.player)
-- LancesRoomLanceEndBattleScript DisplayTextID -> TalkToTrainer
-- after-battle text (rival became champion first). engageTrainer
-- only shows the won line, so push the after text on a win.
ow:engageTrainer(lance, function()
if not ow:trainerDefeated(lance) then return end
local after = text(game)._LancesRoomLanceAfterBattleText
if after then push(game, after) end
end)
ow:engageTrainer(lance, function() end)
return true
end
if (x == 5 or x == 6) and y == 11 then
+25 -167
View File
@@ -262,180 +262,38 @@ M.CINNABAR_ISLAND = {
-- Pewter's youngster stops you leaving east before Brock is beaten and
-- escorts you to the gym (scripts/PewterCity.asm
-- PewterCityCheckPlayerLeavingEastScript /
-- PewterCityYoungsterShowsPlayerGymScript, engine/overworld/auto_movement.asm
-- PewterGymGuyMovementScriptPointerTable, engine/events/pewter_guys.asm
-- PewterGymGuyCoords). Same lockstep style as Oak's lab escort: the
-- youngster walks RLEList_PewterGymGuy while the player plays the
-- reverse of RLEList_PewterGymPlayer with a PewterGuys positioning
-- preamble. Ends at (11,18) / (12,18) by the gym, not phasing through
-- the building.
local pewterEscort = {}
-- RLEList_PewterGymGuy (NPC directions play forward)
pewterEscort.guySteps = {
"down", "down",
"left", "left", "left", "left", "left", "left", "left", "left",
"left", "left", "left", "left", "left", "left", "left",
"up", "up", "up", "up", "up",
"left", "left", "left", "left", "left", "left", "left", "left",
"left", "left", "left",
"down", "down", "down", "down", "down",
"right", "right", "right",
}
-- Walk home: reverse of guySteps with opposite facings (gym → spawn).
do
local opp = { up = "down", down = "up", left = "right", right = "left" }
local ret = {}
for i = #pewterEscort.guySteps, 1, -1 do
ret[#ret + 1] = opp[pewterEscort.guySteps[i]]
end
pewterEscort.guyReturnSteps = ret
end
-- RLEList_PewterGymPlayer before reverse / PewterGuys (NO_INPUT, RIGHT×2,
-- DOWN×5, LEFT×11, UP×5, LEFT×15)
pewterEscort.playerRle = {
"NO",
"right", "right",
"down", "down", "down", "down", "down",
"left", "left", "left", "left", "left", "left", "left", "left",
"left", "left", "left",
"up", "up", "up", "up", "up",
"left", "left", "left", "left", "left", "left", "left", "left",
"left", "left", "left", "left", "left", "left", "left",
}
-- PewterGymGuyCoords: (x, y) -> positioning moves written after the RLE
-- (played in reverse; $00 pauses are one overworld frame ≈ 1/8 tile for
-- the NPC, so eight of them ≈ one guy head-start step)
pewterEscort.preambles = {
["34,16"] = { "left", "down", "down", "right" },
["35,17"] = { "left", "down", "right", "left" },
["37,18"] = { "left", "left", "left",
"NO", "NO", "NO", "NO", "NO", "NO", "NO", "NO" },
["37,19"] = { "left", "left", "up", "left" },
["36,17"] = { "left", "down", "left",
"NO", "NO", "NO", "NO", "NO", "NO", "NO", "NO" },
}
-- Realized player path for a trigger tile: PewterGuys overwrites the
-- last RLE byte and appends the preamble, then simulated joypad plays
-- high→low (reverse). Leading NO×8 collapses to guyHeadStart=1; the
-- trailing end-of-list NO is dropped (one-frame pause).
function pewterEscort.playerPlan(x, y)
local pre = pewterEscort.preambles[x .. "," .. y]
if not pre then return nil end
local buf = {}
for i, d in ipairs(pewterEscort.playerRle) do buf[i] = d end
buf[#buf] = pre[1]
for i = 2, #pre do buf[#buf + 1] = pre[i] end
local path = {}
for i = #buf, 1, -1 do path[#path + 1] = buf[i] end
local head = 0
while path[head + 1] == "NO" do head = head + 1 end
local tail = #path
while tail > head and path[tail] == "NO" do tail = tail - 1 end
local steps = {}
for i = head + 1, tail do steps[#steps + 1] = path[i] end
return { steps = steps, guyHeadStart = math.floor(head / 8) }
end
local function pewterGymEscort(game, ow)
if ow.runner:isRunning() or #ow.scriptMoves > 0 then return end
local x, y = ow.player.cellX, ow.player.cellY
local plan = pewterEscort.playerPlan(x, y)
local Music = require("src.core.Music")
local t = text(game)
local follow = t._PewterCityYoungsterYoureATrainerFollowMeText
or "You're a trainer\nright? BROCK's\nlooking for new\nchallengers!\nFollow me!"
-- PewterGuys only has entries for five tiles; an unmatched talk tile
-- (e.g. (36,16) east of him) just gets the follow-me line, same as a
-- failed coords lookup would refuse to arm the walk.
if not plan then
push(game, follow)
return
end
local guy = ow:npcByIndex(5) -- PEWTERCITY_YOUNGSTER
local guySteps = pewterEscort.guySteps
local head = plan.guyHeadStart
-- After the walk: face the player, restore map music, "Go take on
-- BROCK", then retrace RLEList_PewterGymGuy back to his spawn (35,16).
-- (pokered teleports him via MovementData_PewterGymGuyExit; we walk
-- the same route home instead. Brock victory still HideObject's him.)
local function walkHome()
if not guy then return end
local ret = pewterEscort.guyReturnSteps
local i = 0
local function tick()
i = i + 1
if not ret[i] then
guy.facing = "down"
return
end
ow:scriptMove(guy, ret[i], 1, tick)
end
tick()
end
local function afterWalk()
if guy then guy.facing = "left" end
Music.playMap(game.data, "PEWTER_CITY")
push(game, t._PewterCityYoungsterGoTakeOnBrockText
or "Go take on BROCK\nat the GYM first!", walkHome)
end
local function lockstep()
local i = 0
local function tick()
i = i + 1
local ps = plan.steps[i]
if not ps then
afterWalk()
return
end
local gs = guySteps[head + i]
if guy and gs then ow:scriptMove(guy, gs, 1) end
ow:scriptMove(ow.player, ps, 1, tick)
end
tick()
end
local function beginWalk()
Music.play(game.data, "Music_MuseumGuy")
if guy and head > 0 then
local h = 0
local function headTick()
h = h + 1
if h > head then lockstep(); return end
ow:scriptMove(guy, guySteps[h], 1, headTick)
end
headTick()
else
lockstep()
end
end
push(game, follow, beginWalk)
end
-- PewterCityYoungsterShowsPlayerGymScript, engine/events/pewter_guys.asm
-- PewterGymGuyCoords): the "follow me" lines, then the player is walked
-- west along the road to the front of the PEWTER_GYM door at (16,17).
M.PEWTER_CITY = {
escort = pewterEscort,
-- PewterCityYoungsterText: talking also arms the gym escort script
talk = {
TEXT_PEWTERCITY_YOUNGSTER = function(game, ow, npc, done)
pewterGymEscort(game, ow)
if done then done() end
end,
},
onStep = function(game, ow, x, y)
if game.save.flags.EVENT_BEAT_BROCK then return false end
if ow.runner:isRunning() or #ow.scriptMoves > 0 then return false end
if not inCoords({ { 35, 17 }, { 36, 17 }, { 37, 18 }, { 37, 19 } }, x, y) then
return false
end
pewterGymEscort(game, ow)
local t = text(game)
-- walk up onto the road row (y=17), west to one tile east of the gym
-- door, then drop below the door and turn to face it
local steps = {}
for _ = 1, y - 17 do steps[#steps + 1] = "up" end
for _ = 1, x - 17 do steps[#steps + 1] = "left" end
steps[#steps + 1] = "down"
steps[#steps + 1] = "left"
local function walk(i)
if not steps[i] then
ow.player.facing = "up"
return
end
ow:scriptMove(ow.player, steps[i], 1, function() walk(i + 1) end)
end
push(game, t._PewterCityYoungsterYoureATrainerFollowMeText
or "Hey! You're a\ntrainer, right?", function()
push(game, t._PewterCityYoungsterGoTakeOnBrockText
or "Go take on BROCK\nat the GYM first!", function()
walk(1)
end)
end)
return true
end,
}
+3 -11
View File
@@ -2,9 +2,8 @@
-- "OPP_CLASS#partyIndex" (the object_event trainer args). Hand-ported
-- from the leaders'/bosses' text_asm victory scripts:
-- gym badges: scripts/PewterGym.asm ... ViridianGym.asm
-- Rocket Hideout Giovanni: Silph Scope is a hidden item ball revealed by
-- ShowObject in RocketHideoutB4FBeatGiovanniScript (ported as the
-- TEXT_ROCKETHIDEOUTB4F_GIOVANNI talk handler in story3.lua).
-- Rocket Hideout Giovanni: his Silph Scope is an item ball next to him
-- (data/maps/objects/RocketHideoutB4F.asm), so no reward entry needed.
-- The TM each gym leader hands out afterwards is also ported.
--
-- `deactivate` lists the EVENT_BEAT_* flags each gym's victory script
@@ -22,16 +21,9 @@ local function range(prefix, first, last)
end
return {
-- PewterGym.asm .gymVictory also HideObject TOGGLE_GYM_GUY
-- (PEWTERCITY_YOUNGSTER) and TOGGLE_ROUTE_22_RIVAL_1 so the east-exit
-- escort NPC and the first Route 22 rival stay gone after the badge.
["OPP_BROCK#1"] = { badge = "BOULDERBADGE", flag = "EVENT_BEAT_BROCK",
item = "TM_BIDE",
deactivate = { "EVENT_BEAT_PEWTER_GYM_TRAINER_0" },
hide = {
{ "PEWTER_CITY", "PEWTERCITY_YOUNGSTER" },
{ "ROUTE_22", "ROUTE22_RIVAL1" },
} },
deactivate = { "EVENT_BEAT_PEWTER_GYM_TRAINER_0" } },
["OPP_MISTY#1"] = { badge = "CASCADEBADGE", flag = "EVENT_BEAT_MISTY",
item = "TM_BUBBLEBEAM",
deactivate = range("EVENT_BEAT_CERULEAN_GYM_TRAINER_", 0, 1) },
+49 -215
View File
@@ -52,10 +52,6 @@ local imageCache = {}
-- fully transparent rows below a pic's content (the extracted 32x32 back
-- pics carry baked-in padding); used to sit the pic flush on the text box
local imagePadBottom = {}
-- fully transparent columns left of a pic's content; at 2x (back pics)
-- this is subtracted from hlcoord 1,5 so opaque pixels match hardware,
-- where those columns were white-on-white rather than shifted content
local imagePadLeft = {}
-- image -> { path, pal } so palette-fade variants (see fadeImage) can be
-- rebuilt for any battle pic, whatever code loaded it
local imageMeta = {}
@@ -67,7 +63,7 @@ local function getImage(path, pal, trueColor)
if trueColor then pal = nil end
local key = pal and (path .. "#" .. pal.name) or path
if not imageCache[key] then
local img, pad, padL = nil, 0, 0
local img, pad = nil, 0
if love.image and love.image.newImageData then
local id = Assets.imageData(path)
if pal then
@@ -90,25 +86,13 @@ local function getImage(path, pal, trueColor)
if opaque then break end
bottom = bottom - 1
end
local left = 0
while left < w do
local opaque = false
for y = 0, h - 1 do
local _, _, _, a = id:getPixel(left, y)
if a > 0 then opaque = true break end
end
if opaque then break end
left = left + 1
end
img = love.graphics.newImage(id)
pad = h - 1 - bottom
padL = left
else
img = Assets.image(path) -- headless stub: no pixel access
end
imageCache[key] = img
imagePadBottom[img] = pad
imagePadLeft[img] = padL
imageMeta[img] = { path = path, pal = pal, trueColor = trueColor or nil }
end
return imageCache[key]
@@ -117,7 +101,7 @@ end
-- hot reload: the next getImage re-resolves every pic through the asset
-- search path and re-measures its ground padding
function BattleState.invalidate()
imageCache, imagePadBottom, imagePadLeft, imageMeta = {}, {}, {}, {}
imageCache, imagePadBottom, imageMeta = {}, {}, {}
end
Assets.register(BattleState.invalidate)
@@ -274,10 +258,6 @@ local function makeBattler(data, mon, isPlayer, save)
}
end
-- LinkBattle builds clamped copies with save=nil (no badge boosts); wild
-- and trainer constructors pass the live save for the player side.
BattleState.makeBattler = makeBattler
-- The battle pic for `species` on the given side (back pic for the
-- player side, front pic for the enemy side), tinted PAL_GRAYMON --
-- the same path makeBattler uses, but forced gray -- since this is only
@@ -852,7 +832,7 @@ function BattleState:computeMusicKind()
return "final"
elseif isBoss or (self.trainer and self.trainer.id == "OPP_LANCE") then
return "gym"
elseif self.kind == "trainer" or self.kind == "link" then
elseif self.kind == "trainer" then
return "trainer"
end
return "wild"
@@ -914,17 +894,6 @@ function BattleState:enter()
self.showEnemyTrainer = false
self:startGrowIn(self.enemy)
end)
elseif self.kind == "link" then
-- Colosseum has no foe trainer pic, but the enemy mon still grows
-- out of the ball after "X sent out Y!" (not the wild "already there"
-- intro that LinkBattle previously inherited from newWild).
self.enemySendingOut = true
self:say(("%s sent\nout %s!"):format(self.opponentName or "FOE",
self.enemy.name))
self:act(function()
self.enemySendingOut = false
self:startGrowIn(self.enemy)
end)
end
if not self.ghost then
-- the enemy's cry plays as it appears (data/pokemon/cries.asm)
@@ -981,37 +950,20 @@ function BattleState:exit()
end
end
-- End a trapping sequence (USING_TRAPPING_MOVE). SendOutMon clears the
-- foe's bit (core.asm:1761-1762); EnemySendOutFirstMon clears the
-- player's (core.asm:1314-1315). Any switch frees the other side.
local function clearTrapping(battler)
if not battler then return end
battler.trappingTurns = nil
battler.trapMove = nil
battler.trapDamage = nil
end
-- Actions that skip DisplayBattleMenu entirely (core.asm:300-310):
-- recharge, Rage, thrash, charge. Bide / trapping / being held do NOT
-- skip the menu -- the player can still item/switch (and must press
-- FIGHT to continue a trapping sequence).
function BattleState:menuLockedAction(battler)
-- An action the battler is locked into (bypasses the menu), or nil.
function BattleState:lockedAction(battler)
if battler.mustRecharge then return { special = "recharge" } end
if battler.charging then return battler.charging end
if battler.thrashTurns and battler.thrashTurns > 0 then return battler.thrashMove end
if battler.rageMove then return battler.rageMove end
return nil
end
-- After FIGHT: skip MoveSelectionMenu (core.asm:320-329). Own
-- trapping/Bide continues; foe trapping forces CANNOT_MOVE ($ff).
function BattleState:fightLockedAction(battler)
if battler.trappingTurns and battler.trappingTurns > 0 then
return { special = "trapping" }
end
if battler.bideTurns then return { special = "bide" } end
-- held while the OPPONENT's trapping bit is set (live mirror so a
-- trap ended early by paralysis/faint frees the victim immediately)
if battler.rageMove then return battler.rageMove end
-- held in place while the OPPONENT's trapping move is running
-- (core.asm:316-322 reads the live USING_TRAPPING_MOVE bit, so a
-- trap ended early by paralysis/faint frees the victim immediately);
-- boundTurns is a mirror kept for Status.beforeMove's held check
local opp = battler.isPlayer and self.enemy or self.player
battler.boundTurns = opp and opp.trappingTurns
and math.max(1, opp.trappingTurns) or nil
@@ -1021,11 +973,6 @@ function BattleState:fightLockedAction(battler)
return nil
end
-- Full lock for AI / callers that need any forced action.
function BattleState:lockedAction(battler)
return self:menuLockedAction(battler) or self:fightLockedAction(battler)
end
function BattleState:playerHasPP()
for i, mv in ipairs(self.player.curMoves) do
if mv.pp > 0 and self.player.disabledSlot ~= i then return true end
@@ -1132,9 +1079,8 @@ function BattleState:update(dt)
if not (self.player.mustRecharge or self.player.rageMove) then
self.player.flinched, self.enemy.flinched = false, false
end
-- only recharge/Rage/thrash/charge skip DisplayBattleMenu; trapping
-- victims (and wrappers) still get FIGHT/PKMN/ITEM/RUN (core.asm:312)
local locked = self:menuLockedAction(self.player)
-- locked multi-turn actions skip the menu entirely
local locked = self:lockedAction(self.player)
if locked then
self:resolveTurn(locked)
return
@@ -1158,13 +1104,6 @@ function BattleState:update(dt)
end)
self:act(function() self:endOfTurn() end)
elseif choice == "fight" then
-- After the menu: own trapping/Bide or foe Wrap skips the move
-- list and forces the locked action (core.asm:320-329)
local fightLock = self:fightLockedAction(self.player)
if fightLock then
self:resolveTurn(fightLock)
return
end
if not self:playerHasPP() then
-- _NoMovesLeftText, then Struggle engages
self:say(("%s has no\nmoves left!"):format(self.player.name))
@@ -1575,9 +1514,6 @@ function BattleState:resolveSwitch(newMon)
self:restoreMimicked(self.player) -- the battle copy leaves with it
local previous = self.player
self.player = makeBattler(self.data, newMon, true, self.game.save)
-- SendOutMon (core.asm:1761-1762): player's send-out clears the
-- foe's USING_TRAPPING_MOVE -- Wrap/Bind/etc. ends on any switch
clearTrapping(self.enemy)
self:syncSides()
Runtime.emit("battle.battler_switched", {
battle = self, side = self.sides[1], battler = self.player,
@@ -1683,19 +1619,10 @@ end
-- only after MoveHitTest / the effect lands (HandleIfPlayerMoveMissed skips
-- it on a miss unless EXPLODE_EFFECT); we insert early for blink attachment
-- and peel it back on miss/fail paths.
-- Dig/Fly charge leaves the user pic hidden (SLIDE_DOWN / TELEPORT); the
-- second-turn DIG/FLY anim restores it via SE_SLIDE_MON_UP / SE_SHOW_MON_PIC.
-- Cancelling that row on miss/immune would otherwise leave the digger
-- invisible until another anim's resetPicFx (#100).
function BattleState:cancelMoveAnim()
local row = self.moveAnimRow
if not row then return end
self.moveAnimRow = nil
if row.anim == "DIG" or row.anim == "FLY" then
local user = row.attackerIsPlayer and self.player or self.enemy
local pf = user and self.picFx and self.picFx[user]
if pf then pf.hidden = nil end
end
for i, item in ipairs(self.queue) do
if item == row then
table.remove(self.queue, i)
@@ -1759,23 +1686,14 @@ end
-- transient pic effects reset when a new animation row starts (each
-- PlayAnimation redraws from a clean slate); `minimized` survives --
-- the minimize sprite replaces the pic DATA until reload. Dig/Fly's
-- charge hide is a cleared tilemap that must survive until SE_SHOW_* /
-- SE_SLIDE_MON_UP (or cancelMoveAnim on a missed Dig/Fly release):
-- clearing it here made Dig pop in before emerge and wrap/bounce (#100).
-- Other hides (Acid Armor, etc.) still clear so the next anim restores.
-- the minimize sprite replaces the pic DATA, so redraws keep it until
-- the pic is reloaded (switch/Transform/ChangeMonPic)
function BattleState:resetPicFx()
if not self.picFx then return end
local digFly = self.animName == "DIG" or self.animName == "FLY"
local digFlyUser = digFly and (self.animAttackerIsPlayer
and self.player or self.enemy) or nil
for battler, pf in pairs(self.picFx) do
for _, pf in pairs(self.picFx) do
pf.kind, pf.t = nil, nil
pf.ox, pf.oy = 0, 0
local keepHide = battler.invulnerable or battler == digFlyUser
if not keepHide then
pf.hidden = nil
end
pf.hidden = nil
end
end
@@ -2176,8 +2094,6 @@ function BattleState:executeAction(user, target, action)
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,
@@ -2228,43 +2144,6 @@ function BattleState:executeAction(user, target, action)
self:performMove(user, target, action, false)
end
-- Sleep / confusion onomatopoeia from Check*StatusConditions
-- (core.asm): side-specific SLP_*/CONF_* anims, not the Rest/Amnesia
-- move rows. Player sleep plays the anim before FastAsleepText;
-- enemy sleep and both confusion sides print the text first.
function BattleState:statusOnomatopoeia(user, kind)
local isPlayer = user.isPlayer
local anim
if kind == "sleep" then
anim = isPlayer and "SLP_PLAYER_ANIM" or "SLP_ANIM"
else
anim = isPlayer and "CONF_PLAYER_ANIM" or "CONF_ANIM"
end
local text = kind == "sleep"
and (displayName(user) .. "\nis fast asleep!")
or (displayName(user) .. "\nis confused!")
if kind == "sleep" and isPlayer then
self:animNext(anim, isPlayer)
self:sayNext(text)
else
self:sayNext(text)
self:animNext(anim, isPlayer)
end
end
-- Queue status text (+ sleep/confusion FX when the line matches).
-- Wake / snap-out / flinch / etc. stay text-only.
function BattleState:sayStatusMsg(user, msg)
local text = prefixEnemy(msg, user)
if msg:find("is fast asleep!", 1, true) then
self:statusOnomatopoeia(user, "sleep")
elseif msg:find("is confused!", 1, true) then
self:statusOnomatopoeia(user, "confused")
else
self:sayNext(text)
end
end
-- The pre-recharge slice of CheckPlayerStatusConditions (core.asm:
-- 3328-3382): sleep -> freeze -> held-in-place -> flinch, each losing
-- the turn WITHOUT consuming the recharge flag. The disable/confusion/
@@ -2283,7 +2162,7 @@ function BattleState:preRechargeChecks(user, target)
mon.status = nil
self:sayNext(displayName(user) .. "\nwoke up!")
else
self:statusOnomatopoeia(user, "sleep")
self:sayNext(displayName(user) .. "\nis fast asleep!")
end
return true
end
@@ -2310,7 +2189,7 @@ end
-- returns true when the user's action is interrupted.
function BattleState:statusInterrupt(user, target)
local canMove, msgs, selfHit = Status.beforeMove(user, self.rng, self)
for _, m in ipairs(msgs) do self:sayStatusMsg(user, m) end
for _, m in ipairs(msgs) do self:sayNext(prefixEnemy(m, user)) end
if selfHit then
-- confusion self-hit (core.asm:3428-3434): clears everything in
-- status1 except CONFUSED, then HandleSelfConfusionDamage deals a
@@ -2387,16 +2266,11 @@ function BattleState:performMove(user, target, moveInst, isCalled)
user.charging, user.chargeReady, user.invulnerable = nil, nil, nil
end
-- PP: not for continuations, struggle, called moves, or (under
-- gen1_faithful) wild/trainer enemies — pokered DecrementPP only ever
-- mutates wBattleMonPP / party PP (engine/battle/decrement_pp.asm).
-- PP: not for continuations, struggle, or called moves
local isContinuation = releasing
or (user.thrashTurns and user.thrashTurns > 0 and moveInst == user.thrashMove)
or moveInst == user.rageMove
local enemyUnlimited = not user.isPlayer
and self.ruleset and self.ruleset.enemyUnlimitedPP
if not isContinuation and not moveInst.struggle and not isCalled
and not enemyUnlimited then
if not isContinuation and not moveInst.struggle and not isCalled then
moveInst.pp = math.max(0, moveInst.pp - 1)
end
@@ -2700,20 +2574,8 @@ function BattleState:enemyMonFainted()
self.participants = {}
if self.kind == "trainer" then
-- EnemySendOutFirstMon / AnyEnemyPokemonAliveCheck (core.asm): scan
-- the whole enemy party for the first mon with HP left. Blindly
-- doing enemyIndex+1 softlocks after an AI switch (Agatha): a later
-- slot can already be fainted, so the empty-HP mon comes out, the
-- FIGHT menu returns, and executeAction no-ops on target.hp <= 0.
local nextIndex
for i, mon in ipairs(self.enemyParty) do
if mon.hp > 0 then
nextIndex = i
break
end
end
if nextIndex then
self.enemyIndex = nextIndex
if self.enemyIndex < #self.enemyParty then
self.enemyIndex = self.enemyIndex + 1
-- SHIFT battle style (the default): announce the next mon and
-- offer a free switch (SET skips the prompt)
local nextMon = self.enemyParty[self.enemyIndex]
@@ -2737,7 +2599,6 @@ function BattleState:enemyMonFainted()
if mon ~= self.player.mon and mon.hp > 0 then
local previous = self.player
self.player = makeBattler(self.data, mon, true, game.save)
clearTrapping(self.enemy) -- SendOutMon clears foe trap
self:syncSides()
Runtime.emit("battle.battler_switched", {
battle = self, side = self.sides[1],
@@ -2763,8 +2624,6 @@ function BattleState:enemyMonFainted()
self:act(function()
local previous = self.enemy
self.enemy = makeBattler(self.data, self.enemyParty[self.enemyIndex], 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,
@@ -2918,7 +2777,6 @@ function BattleState:openReplacementMenu()
self:restoreMimicked(self.player)
local previous = self.player
self.player = makeBattler(self.data, mon, true, game.save)
clearTrapping(self.enemy) -- SendOutMon clears foe trap
self:syncSides()
Runtime.emit("battle.battler_switched", {
battle = self, side = self.sides[1], battler = self.player,
@@ -3616,16 +3474,17 @@ function BattleState:drawBattlerPic(battler, x, y, scale)
love.graphics.draw(img, quad, x + ox, y + oy, 0, scale, scale)
end
elseif k == "slideUp" then
-- AnimationSlideMonUp (animations.asm): 7 row steps x 2f. After Dig's
-- SLIDE_DOWN the tilemap is blank; each step fills the next bottom
-- row so the mon emerges from underground. A cyclic wrap of a full
-- pic looked like a bounce at Dig's end (#100).
local step = math.min(7, math.floor((t - 1) / 2) + 1)
local visible = math.floor(h * step / 7)
if visible > 0 then
local quad = love.graphics.newQuad(0, h - visible, w, visible, w, h)
love.graphics.draw(img, quad, x + ox,
y + (h - visible) * scale, 0, scale, scale)
-- AnimationSlideMonUp: cyclic upward wrap, one row per 2 frames
local scroll = 8 * math.min(7, math.floor(t / 2) + 1)
local src = math.floor(scroll / scale) % h
if src == 0 then
love.graphics.draw(img, x + ox, y, 0, scale, scale)
else
local top = love.graphics.newQuad(0, src, w, h - src, w, h)
love.graphics.draw(img, top, x + ox, y, 0, scale, scale)
local bottom = love.graphics.newQuad(0, 0, w, src, w, h)
love.graphics.draw(img, bottom, x + ox, y + (h - src) * scale,
0, scale, scale)
end
elseif xscale < 1 then
-- AnimationSquishMonPic: columns collapse toward the middle
@@ -3837,20 +3696,6 @@ function BattleState:drawAnimLayer(colorized)
end
end
-- Front/trainer pics: LoadUncompressedSpriteData centers the sprite in
-- a 7x7 tile buffer, then CopyUncompressedPicToTilemap places that
-- buffer at hlcoord 12,0. Horizontal pad is floor((8-w)/2) tiles;
-- vertical pad is (7-h) -- bottom-aligned inside the 7x7.
local function enemyPicXY(img, slide, sx, sy)
local tw = math.floor(img:getWidth() / 8)
local th = math.floor(img:getHeight() / 8)
if tw < 1 then tw = 1 elseif tw > 7 then tw = 7 end
if th < 1 then th = 1 elseif th > 7 then th = 7 end
local hPad = math.floor((8 - tw) / 2)
local vPad = 7 - th
return 96 + 8 * hPad - slide + sx, 8 * vPad + sy
end
-- the two mon pics (or the trainer/back pics), offset by the window
-- shake -- on the GB the pics are BG tiles, so they move with it
function BattleState:drawPicsLayer(slide, sx, sy)
@@ -3868,18 +3713,19 @@ function BattleState:drawPicsLayer(slide, sx, sy)
g.intersectScissor(0, 0, 160, clipY)
clipped = true
end
-- Enemy: front sprite in the 7x7 slot at hlcoord 12,0.
-- Enemy: front sprite top-right (GB: pic at hlcoord 12,0).
if self.showEnemyTrainer and self.trainerPic then
-- the enemy trainer pic holds the mon slot until the send-out
local img = self:picImage(self.trainerPic)
love.graphics.setColor(1, 1, 1, 1)
local ex, ey = enemyPicXY(img, slide, sx, sy)
love.graphics.draw(img, ex, ey)
love.graphics.draw(img, 160 - 8 - img:getWidth() - slide + sx,
math.max(0, 48 - img:getHeight()) + sy)
elseif self.enemy and self.enemy.sprite and not self.enemyHidden
and not self.enemySendingOut and not self:fxHidden(self.enemy) then
local img = self:picImage(self.enemy.sprite)
love.graphics.setColor(1, 1, 1, 1)
local ex, ey = enemyPicXY(img, slide, sx, sy)
local ex = 160 - 8 - img:getWidth() - slide + sx
local ey = math.max(0, 48 - img:getHeight()) + sy
local gs = self:growInScale(self.enemy)
if gs then
-- AnimateSendingOutMon: the downscaled pic keeps its bottom edge
@@ -3893,18 +3739,15 @@ function BattleState:drawPicsLayer(slide, sx, sy)
end
end
-- Player: back sprite at hlcoord 1,5 (x=8), 2x like the GB, feet at y=96.
-- Left transparent columns (matted white) are pulled back so opaque
-- pixels land where hardware's white-on-white columns left them.
-- Player: back sprite bottom-left (2x like the GB, feet near y=100).
local hidePlayer = self.safari or self.demo
if self.showPlayerBack and self.playerBackPic then
-- Red's (or the old man's) back pic until "Go!"; it stays up for
-- the whole safari / catch-demo battle like the original
local img = self:picImage(self.playerBackPic)
local pad = imagePadBottom[self.playerBackPic] or 0
local padL = imagePadLeft[self.playerBackPic] or 0
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(img, 8 - padL * 2 + slide + sx,
love.graphics.draw(img, 16 + slide + sx,
96 - (img:getHeight() - pad) * 2 + sy, 0, 2, 2)
elseif self.player and self.player.sprite and not hidePlayer
and not self.sendingOut and not self:fxHidden(self.player) then
@@ -3912,19 +3755,17 @@ function BattleState:drawPicsLayer(slide, sx, sy)
love.graphics.setColor(1, 1, 1, 1)
-- feet flush on the text box top (y=96), ignoring baked-in padding
local pad = imagePadBottom[self.player.sprite] or 0
local padL = imagePadLeft[self.player.sprite] or 0
local px = 8 - padL * 2 + sx
local gs = self:growInScale(self.player)
if gs then
-- the player-side AnimateSendingOutMon grow (after the poof,
-- core.asm:1757-1762): feet pinned at y=96, center at x=8+w
-- core.asm:1757-1762): feet pinned at y=96, center at x=16+w
if gs > 0 then
love.graphics.draw(img, px + img:getWidth() * (1 - gs),
love.graphics.draw(img, 16 + img:getWidth() * (1 - gs) + sx,
96 - (img:getHeight() - pad) * 2 * gs + sy,
0, 2 * gs, 2 * gs)
end
else
self:drawBattlerPic(self.player, px,
self:drawBattlerPic(self.player, 16 + sx,
96 - (img:getHeight() - pad) * 2 + sy, 2)
end
end
@@ -3979,22 +3820,16 @@ function BattleState:drawHUDs(slide)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(("BALLx%2d"):format(self.safari.balls), 88, 72)
end
-- trainer/link party pokeball rows during the intro
-- trainer-battle party pokeball rows during the intro
-- (SetupPlayerAndEnemyPokeballs, draw_hud_pokeball_gfx.asm)
local showIntroBalls = slide == 0 and (
(self.kind == "trainer" and (self.showEnemyTrainer or self.showPlayerBack))
or (self.kind == "link" and (self.showPlayerBack or self.enemySendingOut))
)
if showIntroBalls then
if self.kind == "trainer" and (self.showEnemyTrainer or self.showPlayerBack)
and slide == 0 then
love.graphics.setColor(1, 1, 1, 1)
if self.enemyParty and (
(self.kind == "trainer" and self.showEnemyTrainer)
or (self.kind == "link" and self.enemySendingOut)
) then
if self.showEnemyTrainer and self.enemyParty then
self:drawBallRow(self.enemyParty, 64, 16, -8)
end
if self.showPlayerBack then
self:drawBallRow(self.playerParty or self.game.save.party, 88, 80, 8)
self:drawBallRow(self.game.save.party, 88, 80, 8)
end
end
local hidePlayer = self.safari or self.demo
@@ -4028,8 +3863,7 @@ function BattleState:drawTextArea()
if self.phase == "messages" and self.current then
local shown = 0
for li, codes in ipairs(self.lines) do
-- battle text uses every other tile row (hlcoord *,14 / *,16)
local y = 112 + (li - 1) * 16
local y = 104 + li * 8
for i = 1, #codes do
if shown >= self.charIndex then break end
Font.drawCode(codes[i], 8 + (i - 1) * 8, y)
+2 -7
View File
@@ -15,8 +15,7 @@
-- (super-effective) or adds 1 (not-effective when a better move exists);
-- the MINIMUM-scored move is chosen, ties broken uniformly among the
-- tied minima (core.asm:2971-3002). A non-minimal move is never
-- selectable. Respects Disable (and PP only when the ruleset depletes
-- enemy PP — Gen 1 AI never reads wEnemyMonPP).
-- selectable. Respects PP, Disable and Transform/Mimic move overrides.
local TypeChart = require("src.battle.TypeChart")
@@ -213,13 +212,9 @@ end
function TrainerAI.chooseMove(battler, rng, battle)
rng = rng or love.math.random
-- Gen 1: SelectEnemyMove never consults wEnemyMonPP; Struggle only when
-- every move slot is missing/disabled (core.asm:2957-2999). modern_clean
-- depletes enemy PP and falls back to Struggle when none remain.
local unlimited = battle and battle.ruleset and battle.ruleset.enemyUnlimitedPP
local usable = {}
for i, mv in ipairs(battler.curMoves) do
if battler.disabledSlot ~= i and (unlimited or mv.pp > 0) then
if mv.pp > 0 and battler.disabledSlot ~= i then
table.insert(usable, mv)
end
end
-3
View File
@@ -13,7 +13,4 @@ return {
randMax = 255,
-- Focus Energy famously QUARTERS the crit rate instead of x4
focusEnergyBug = true,
-- Wild/trainer enemies never spend PP (DecrementPP only touches the
-- player side in pokered). They therefore never Struggle from empty PP.
enemyUnlimitedPP = true,
}
-2
View File
@@ -9,6 +9,4 @@ return {
randMin = 217,
randMax = 255,
focusEnergyBug = false,
-- Gen 2+ style: AI opponents deplete PP and can Struggle when empty.
enemyUnlimitedPP = false,
}
-8
View File
@@ -27,12 +27,4 @@ function FixedStep:update(dt)
end
end
-- Drop any pending catch-up steps. A hitch inside one logic step (map
-- seam setMap / song start) makes the next real-time dt huge; without this
-- the while-loop above would advance many walk frames before the next
-- draw, which looks like a slide with no leg animation (issue #93).
function FixedStep:discardCatchup()
self.accum = 0
end
return FixedStep
-7
View File
@@ -460,13 +460,6 @@ function Game:restoreSave(loaded, recovered)
-- SaveData.load and skip on the format guard
local activeMods = self.modStatus and self.modStatus.loaded
SaveData.runMigrations(loaded, self.mods and self.mods.migrations, activeMods)
-- Issue #103: 0.1.11 softlocks left CONTINUE in HALL_OF_FAME with
-- lastOutdoor on Indigo. One-shot relocate + heal before validate.
if SaveData.needsPostGameRescue(loaded) then
SaveData.applyPostGameHome(loaded, self:bootConfig())
local Pokemon = require("src.pokemon.Pokemon")
for _, mon in ipairs(loaded.party or {}) do Pokemon.heal(mon) end
end
local modsDiff = SaveData.modsDiff(loaded, activeMods)
local report = SaveData.validate(loaded, self.data)
report.recovered = recovered
+9 -6
View File
@@ -360,13 +360,16 @@ local function chipAwaitingFirstBuffer()
and require("src.core.ChipAudio").awaitingFirstBuffer()
end
-- is a playOnce jingle still in flight? (AnimateHealingMachine's
-- .waitLoop2 / Mom heal / captain rub hold until MUSIC_PKMN_HEALED ends.)
-- pendingRestore stays set from playOnce until Music.update restores the
-- map theme, covering the threaded chip "empty QueueableSource" window
-- where Source:isPlaying is briefly false before the first buffer lands.
-- is a playOnce jingle still sounding? (AnimateHealingMachine's
-- .waitLoop2 holds the healing machine until MUSIC_PKMN_HEALED ends)
function Music.oneShotPlaying()
return state.pendingRestore == true
if not state.pendingRestore then return false end
-- threaded chip songs start silent for ~1 frame; that gap is not "over"
if chipAwaitingFirstBuffer() then return true end
local src = state.source
if not src then return false end
local ok, playing = pcall(src.isPlaying, src)
return ok and playing or false
end
function Music.restoreMap(data)
-53
View File
@@ -478,25 +478,6 @@ SaveData.addCoreMigration(2, function(save)
end
end)
-- #131 / follow-up to #50: Game Corner poster grunt used to stay on the
-- floor after defeat (defeatedTrainers only). The #50 script now hides
-- him via objectToggles, but saves that already beat him never got the
-- toggle -- he still blocks the hideout switch. from=3 so every pre-4
-- save reconciles once, then is skipped after re-stamp.
SaveData.addCoreMigration(3, function(save)
local defeated = save.defeatedTrainers
if not defeated or not defeated["GAME_CORNER_obj_11"] then return end
save.objectToggles = save.objectToggles or {}
local mapToggles = save.objectToggles.GAME_CORNER
if not mapToggles then
mapToggles = {}
save.objectToggles.GAME_CORNER = mapToggles
end
if mapToggles.GAMECORNER_ROCKET ~= false then
mapToggles.GAMECORNER_ROCKET = false
end
end)
-- ------- write
-- Game progress only; options are written separately via saveOptions.
@@ -832,36 +813,6 @@ function SaveData.defaultHeal(boot)
return { map = map, x = boot.startX or 3, y = boot.startY or 6 }
end
-- Post-credits home (issue #103). pokered left the player in HALL_OF_FAME
-- after jp Init; this port places CONTINUE at the NewGameWarp bedroom and
-- retargets LAST_MAP exits (Red's house mats) at the heal-point town so
-- leaving the house does not dump the player back at Indigo Plateau.
-- Marks postGameHomeOk so a later intentional HoF save is not relocated.
function SaveData.applyPostGameHome(save, boot)
boot = type(boot) == "table" and boot or {}
local heal = SaveData.defaultHeal(boot)
save.lastHeal = { map = heal.map, x = heal.x, y = heal.y }
save.lastOutdoor = { id = heal.map, x = heal.x, y = heal.y }
save.player = save.player or {}
save.player.map = boot.startMap or "REDS_HOUSE_2F"
save.player.x = boot.startX or 3
save.player.y = boot.startY or 6
save.player.facing = boot.startFacing or "down"
save.postGameHomeOk = true
return heal
end
-- Softlocked 0.1.11 saves: still standing in HALL_OF_FAME after credits,
-- with lastOutdoor on Indigo. One-shot rescue on CONTINUE.
function SaveData.needsPostGameRescue(save)
if not (save and save.player and save.player.map == "HALL_OF_FAME") then
return false
end
if save.postGameHomeOk then return false end
local hof = save.hallOfFame
return type(hof) == "table" and #hof > 0
end
function SaveData.newGame(boot)
boot = type(boot) == "table" and boot or {}
local map = boot.startMap or "REDS_HOUSE_2F"
@@ -885,10 +836,6 @@ function SaveData.newGame(boot)
},
flags = {},
inventory = {},
-- Vanilla Gen1 seeds one Potion in the player's item PC
-- (wBoxItems / players_pc.asm); existing saves keep whatever they
-- already have — this only applies to New Game.
pcItems = { POTION = 1 },
party = {},
box = {},
money = boot.startMoney or 3000,
+1 -1
View File
@@ -7,7 +7,7 @@ local Version = {
engine = "1.0.0", -- game/engine release (semver triple)
modApi = 2, -- mod API major (manifest `api`)
linkProtocol = 2, -- link handshake wire version (Handshake.PROTOCOL)
saveFormat = 4, -- save.meta.format
saveFormat = 3, -- save.meta.format
cache = "rom-cache-v5", -- ROM import cache generation (RomImporter marker)
}
+2 -15
View File
@@ -351,18 +351,6 @@ function RomImporter:setError(message, version)
self.romData = nil
end
-- draw() may leave the system hand cursor set while hovering a Play /
-- Choose control. Once the importer is torn down that draw path stops
-- running, so restore the arrow before handing off to boot (issue #114).
local function resetPointerCursor(self)
if self.android then return end
if not (love.mouse.isCursorSupported and love.mouse.isCursorSupported()) then
return
end
self.arrowCursor = self.arrowCursor or love.mouse.getSystemCursor("arrow")
love.mouse.setCursor(self.arrowCursor)
end
-- Verify + extract a ROM. The version is decided by the ROM's own SHA-1, so
-- dropping a Red or Blue cart into either column always lands in the right one.
function RomImporter:startData(data, displayName)
@@ -440,7 +428,6 @@ function RomImporter:startData(data, displayName)
-- Stay on the launcher; the player presses Play to boot the new game.
return
end
resetPointerCursor(self)
if self.onComplete then self.onComplete(version) end
end)
end
@@ -520,7 +507,6 @@ end
function RomImporter:play(version)
if self.workState == "working" then return end
if not self.ready[version] then return end
resetPointerCursor(self)
if self.onComplete then self.onComplete(version) end
end
@@ -1059,7 +1045,8 @@ function RomImporter:draw()
self.handCursor = self.handCursor or love.mouse.getSystemCursor("hand")
love.mouse.setCursor(self.handCursor)
else
resetPointerCursor(self)
self.arrowCursor = self.arrowCursor or love.mouse.getSystemCursor("arrow")
love.mouse.setCursor(self.arrowCursor)
end
end
end
+27 -49
View File
@@ -36,13 +36,18 @@ local function makeRng(seed)
end
end
-- Battlers go through BattleState.makeBattler so pics get the same
-- Assets.resolve + SGB/GBC palette + padBottom path as wild/trainer
-- battles. save=nil skips badge boosts so both machines keep identical
-- stats (Gen 1 cable battles famously kept badges; we still diverge).
-- battler builder shared by both sides -- NO badge boosts, so both
-- machines compute identical stats
local function mkBattler(data, mon, isPlayer)
local BattleState = require("src.battle.BattleState")
return BattleState.makeBattler(data, mon, isPlayer, nil)
local def = data.pokemon[mon.species]
local ok, img = pcall(love.graphics.newImage,
isPlayer and def.spriteBack or def.spriteFront)
return {
mon = mon, def = def, isPlayer = isPlayer, stages = {},
name = mon.nickname or def.name,
curStats = mon.stats, curTypes = def.types, curMoves = mon.moves,
sprite = ok and img or nil,
}
end
-- canonical (host-side-first) state hash, unchanged since v1: it stays on
@@ -208,8 +213,6 @@ function LinkBattle.new(game, net, opts)
self.player = mkBattler(game.data, myParty[1], true)
self.enemy = mkBattler(game.data, theirParty[1], false)
self.enemyParty = theirParty
self.playerParty = myParty -- intro ball row uses the clamped copies
self.opponentName = theirName
self.introText = ("%s wants\nto battle!"):format(theirName)
self.remoteHashes = {}
self.localHashes = {}
@@ -232,43 +235,6 @@ function LinkBattle.new(game, net, opts)
return nil
end
-- player-side SendOutMon (poof + grow-in + cry); mirrors BattleState
local function sendOutPlayer(s, mon)
local previous = s.player
s.player = mkBattler(game.data, mon, true)
s:syncSides()
Runtime.emit("battle.battler_switched", {
battle = s, side = s.sides[1], battler = s.player, previous = previous,
})
s.sendingOut = true
s:sayNext(s:sendOutText(s.player.name))
s:animNext("POOF_ANIM", false)
s:actNext(function()
s.sendingOut = false
s:startGrowIn(s.player)
require("src.core.Sound").playCry(s.data, s.player.mon.species)
end)
end
-- enemy-side EnemySendOut (grow-in + cry; no poof)
local function sendOutEnemy(s, mon)
local previous = s.enemy
s.enemy = mkBattler(game.data, mon, false)
s:syncSides()
Runtime.emit("battle.battler_switched", {
battle = s, side = s.sides[2], battler = s.enemy, previous = previous,
})
s.enemySendingOut = true
s:sayNext(("%s sent\nout %s!"):format(theirName, s.enemy.name))
s:actNext(function()
s.enemySendingOut = false
s:startGrowIn(s.enemy)
s:actNext(function()
require("src.core.Sound").playCry(s.data, s.enemy.mon.species)
end)
end)
end
-- decode a remote action message against the enemy battler
local function decodeTheirAction(s, msg)
if msg.kind == "move" then
@@ -340,11 +306,17 @@ function LinkBattle.new(game, net, opts)
-- switches happen before attacks (both may switch)
if myMsg.kind == "switch" then
local idx = myMsg.index
s:act(function() sendOutPlayer(s, myParty[idx]) end)
s:act(function()
s.player = mkBattler(game.data, myParty[idx], true)
s:sayNext(("Go! %s!"):format(s.player.name))
end)
myAction = nil
end
if theirSwitch then
s:act(function() sendOutEnemy(s, theirParty[theirSwitch]) end)
s:act(function()
s.enemy = mkBattler(game.data, theirParty[theirSwitch], false)
s:sayNext(("%s sent\nout %s!"):format(theirName, s.enemy.name))
end)
end
s:act(function()
@@ -480,7 +452,10 @@ function LinkBattle.new(game, net, opts)
self.playerMonFainted = function(s)
for _, mon in ipairs(myParty) do
if mon.hp > 0 then
s:act(function() sendOutPlayer(s, mon) end)
s:act(function()
s.player = mkBattler(game.data, mon, true)
s:sayNext(("Go! %s!"):format(s.player.name))
end)
return
end
end
@@ -493,7 +468,10 @@ function LinkBattle.new(game, net, opts)
self.enemyMonFainted = function(s)
for _, mon in ipairs(theirParty) do
if mon.hp > 0 then
s:act(function() sendOutEnemy(s, mon) end)
s:act(function()
s.enemy = mkBattler(game.data, mon, false)
s:sayNext(("%s sent\nout %s!"):format(theirName, s.enemy.name))
end)
return
end
end
-6
View File
@@ -18,12 +18,6 @@ ManagerState.isOpaque = true
-- Game:keypressed recognizes a directly-pushed instance
ManagerState.screenId = "ManagerState"
-- Same as OptionsMenu: without this, title LOGO zones leak through when
-- MODS is opened from the title-screen options path.
function ManagerState:sgbPalettes(game)
return require("src.render.PaletteFX").wholeNamed(game.data, "MEWMON")
end
-- the charmap has no * ~ + < > glyphs, so the status gutter uses what it
-- does have: staged-awaiting-restart, disabled, errored, dep-unhealthy
local GLYPH = { staged = ".", disabled = "-", errored = "!", blocked = "?" }
+2 -6
View File
@@ -872,15 +872,11 @@ R.palettes = {
}
-- keyed by species id, unlike the vanilla byDex array: a species past the
-- end of the dex gets an icon without punching a hole in the list. The party
-- menu (src/ui/PartyMenu.lua) reads this per-species entry before the vanilla
-- dex-indexed default. The value is a built-in icon NAME -- one of BALL, BIRD,
-- BUG, FAIRY, GRASS, HELIX, MON, QUADRUPED, SNAKE, WATER (uppercase) -- or a
-- { image = <bundled file path>, frames? } table of your own art.
-- end of the dex gets an icon without punching a hole in the list
R.icons = {
semantics = "record", target = "icons.bySpecies",
value = f.union{ f.str, f.rec{ image = f.path, frames = f.opt(f.int(1)) } },
example = 'mod.content.icons:register("MODMON", "QUADRUPED") -- a built-in name, or { image = mod.assets:path("icon.png"), frames = 2 }',
example = 'mod.content.icons:register("MISSINGNO", { image = "glitch.png" })',
}
-- glyph codes are not bytes: the vanilla pages sit at $60/$80 but a
-11
View File
@@ -148,17 +148,6 @@ function Font.advanceOf(code)
return page and page.advance or GLYPH
end
-- Pixel width of a string (glyph advances, not UTF-8 byte length).
-- Multi-byte charmap entries like "¥" are one glyph; callers that
-- right-align with `#text * 8` mis-place them.
function Font.width(text)
local w = 0
for _, code in ipairs(Font.encode(text)) do
w = w + Font.advanceOf(code)
end
return w
end
-- Draw a plain single-line string at pixel (x, y). Returns the width
-- drawn, which is #codes * 8 for every fixed-width page.
function Font.draw(text, x, y)
+2 -20
View File
@@ -242,20 +242,6 @@ function PaletteFX.pack(data)
return data and data.palettes or nil
end
-- SuperPalettes that differ between Red and Blue (pokered data/sgb/
-- sgb_palettes.asm IF DEF(_RED)/_BLUE). data/palettes_gbc.lua is the
-- Red-derived pokered-gbc pack, so under RED++ a Blue playthrough must
-- read these from the ROM-imported table or the title ribbon stays red
-- and the Game Corner reels keep Red's pink (issue #128).
local BLUE_VERSIONED = {
LOGO1 = true, SLOTS2 = true, SLOTS3 = true, SLOTS4 = true,
}
local function romNamedPal(data, name)
local p = data and data.palettes
return p and p.palettes and p.palettes[name]
end
-- named palette from the active pack (nil on stale builds / missing name).
-- RED++ falls back to the ROM pack for names the gbc table omits (rare).
-- OG RED short-circuits EVERY name to the one global GBC boot-ROM BG palette
@@ -265,15 +251,11 @@ end
-- GBC_OBJ green), so this stays a BG-only hook.
function PaletteFX.pal(data, name)
if PaletteFX.mode == "ogred" then return PaletteFX.ogBg() end
if GameVersion.isBlue() and BLUE_VERSIONED[name] then
local fromRom = romNamedPal(data, name)
if fromRom then return fromRom end
end
local p = PaletteFX.pack(data)
local c = p and p.palettes[name]
if c then return c end
if PaletteFX.usesGbcPack() then
return romNamedPal(data, name)
if PaletteFX.usesGbcPack() and data and data.palettes then
return data.palettes.palettes[name]
end
return nil
end
+15 -62
View File
@@ -23,29 +23,6 @@ Renderer.HEIGHT = 144
-- endFrame composites the padded canvas back with a matching offset.
Renderer.UPRIGHT_MARGIN = 160
-- LOVE units + framebuffer pixels + the live unit→pixel ratio.
-- Android's DisplayMetrics.density (love.graphics.getDPIScale) is often
-- non-integer (1.5, 2.75, …). Integer scaling in units then maps each GB
-- pixel to a fractional number of framebuffer pixels → shimmer, uneven /
-- non-square "pixels", and movement judder (issue #87). Always derive the
-- crisp integer scale from the drawable pixel size; draw with (pixels/dpi)
-- so the GPU lands on whole framebuffer pixels. Desktop dpi=1 is unchanged.
local function displayMetrics()
local ww, wh = love.graphics.getDimensions()
local pw, ph = ww, wh
if love.graphics.getPixelDimensions then
pw, ph = love.graphics.getPixelDimensions()
end
local dpi = 1
if ww > 0 and pw > 0 then
dpi = pw / ww
elseif love.graphics.getDPIScale then
dpi = love.graphics.getDPIScale()
end
if not dpi or dpi < 1e-6 then dpi = 1 end
return ww, wh, pw, ph, dpi
end
function Renderer:init()
self.canvas = love.graphics.newCanvas(self.WIDTH, self.HEIGHT)
self.canvas:setFilter("nearest", "nearest")
@@ -59,12 +36,10 @@ function Renderer:init()
self.uprightActive = false
end
-- Integer framebuffer pixels per GB pixel that fit the window. Zoom /
-- GBCFX / callers treat this as the crisp scale; endFrame converts to LOVE
-- units via / dpi when drawing.
-- integer scale that fits the GB UI viewport in the window
function Renderer:fitScale()
local _, _, pw, ph = displayMetrics()
return math.max(1, math.floor(math.min(pw / self.WIDTH, ph / self.HEIGHT)))
local ww, wh = love.graphics.getDimensions()
return math.max(1, math.floor(math.min(ww / self.WIDTH, wh / self.HEIGHT)))
end
-- world-pass canvas size in world pixels: enough to fill the window at s'.
@@ -73,13 +48,9 @@ end
-- background peeking at the receded top/bottom corners; flat mode returns
-- exactly today's size (growth factor is 1 when tilt is inactive).
function Renderer:worldViewSize()
local ww, wh, _, _, dpi = displayMetrics()
local s = Zoom.scale(self:fitScale()) / dpi
local ww, wh = love.graphics.getDimensions()
local s = Zoom.scale(self:fitScale())
local vw, vh = Zoom.fillViewSize(s, ww, wh)
-- Even sizes keep Camera:follow on integer pixels (viewW/2 is integral),
-- so unfloored FX/sprite math cannot phase-shimmer against the tile layer.
if vw % 2 ~= 0 then vw = vw + 1 end
if vh % 2 ~= 0 then vh = vh + 1 end
if Tilt.active() then
local g = Tilt.viewGrowth()
vw, vh = math.ceil(vw * g), math.ceil(vh * g)
@@ -91,9 +62,6 @@ end
function Renderer:beginFrame(transparent)
self.worldActive = false
self.uprightActive = false
-- warp-fade overlay from Transition (issue #121); cleared each frame so
-- a popped transition cannot leave a sticky black veil
self.worldFadeAlpha = nil
-- last frame's trueColor rects and sprite redraws go before anything
-- draws this one
PaletteFX.clearTrueColor()
@@ -318,14 +286,11 @@ end
-- presented through the GBC FX shader as a final pass.
function Renderer:endFrame(zones, worldZones)
love.graphics.setCanvas()
local ww, wh, pw, ph, dpi = displayMetrics()
-- Sp = integer framebuffer pixels per GB pixel; S = LOVE-unit draw scale.
local Sp = self:fitScale()
local S = Sp / dpi
local ww, wh = love.graphics.getDimensions()
local S = self:fitScale()
local vpw, vph = self.WIDTH * S, self.HEIGHT * S
-- Snap the letterbox origin to a framebuffer pixel, then convert to units.
local ox = math.floor((pw - self.WIDTH * Sp) / 2) / dpi
local oy = math.floor((ph - self.HEIGHT * Sp) / 2) / dpi
local ox = math.floor((ww - vpw) / 2)
local oy = math.floor((wh - vph) / 2)
local GBCFX = require("src.render.GBCFX")
-- Forced mono/Classic modes still need a whole-screen zone when a state
-- exposes no SGB packets (raw DMG canvas), so sendColors can remap.
@@ -353,9 +318,9 @@ function Renderer:endFrame(zones, worldZones)
love.graphics.rectangle("fill", 0, 0, ww, wh)
love.graphics.setColor(1, 1, 1, 1)
-- blit `canvas` at `scale` (LOVE units) into origin (bx, by), scissored to
-- blit `canvas` at integer `scale` into origin (bx, by), scissored to
-- the (boxX, boxY, boxW, boxH) screen rect. zoneScale converts zone
-- coords (canvas-space) into screen units.
-- coords (canvas-space) into screen pixels.
local function blit(canvas, scale, zoneList, zoneScale, bx, by, boxX, boxY, boxW, boxH)
local shader = zoneList and zoneList[1] and PaletteFX.shader() or nil
if not shader then
@@ -387,12 +352,11 @@ function Renderer:endFrame(zones, worldZones)
end
if self.worldActive then
local sp = Zoom.scale(Sp)
local s = sp / dpi
local s = Zoom.scale(S)
local wvw = self.worldCanvas:getWidth()
local wvh = self.worldCanvas:getHeight()
local wox = math.floor((pw - wvw * sp) / 2) / dpi
local woy = math.floor((ph - wvh * sp) / 2) / dpi
local wox = math.floor((ww - wvw * s) / 2)
local woy = math.floor((wh - wvh * s) / 2)
-- Tilt mode projects the ground world pass through the perspective mesh
-- (SGB zones baked in beforehand -- see drawTiltedWorld -- so no zone
-- scissoring here). drawTiltedWorld returns false when tilt is off or
@@ -450,24 +414,13 @@ function Renderer:endFrame(zones, worldZones)
love.graphics.draw(self.uprightCanvas, wox - M * s, woy - M * s, 0, s, s)
love.graphics.setScissor()
end
-- Screen-space warp fade (Transition) over the full world composite so
-- survey zoom / tilt edges darken with the center, not only the 160x144
-- UI letterbox. Drawn before the UI blit so menus above a fade still
-- composite normally if one is ever stacked that way.
local fade = self.worldFadeAlpha
if fade and fade > 0 then
love.graphics.setColor(0, 0, 0, fade)
love.graphics.rectangle("fill", 0, 0, ww, wh)
love.graphics.setColor(1, 1, 1, 1)
end
end
-- UI stays in the classic centered GB letterbox
blit(self.canvas, S, zones, S, ox, oy, ox, oy, vpw, vph)
if present then
love.graphics.setCanvas()
-- shader grid/shadow math is in framebuffer pixels
GBCFX.present(present, Sp)
GBCFX.present(present, S)
end
self.worldActive = false
self.uprightActive = false
-10
View File
@@ -57,16 +57,6 @@ end
function Transition:draw()
local alpha = self.t / self.frames
if self.phase == "in" then alpha = 1 - alpha end
-- Survey zoom draws the overworld into a window-filling world canvas
-- while the UI pass stays the classic 160x144 letterbox. A rect on the
-- UI canvas only darkens that center box (issue #121); when the world
-- pass ran this frame, hand the alpha to Renderer:endFrame so it paints
-- a screen-space overlay over the full composite instead.
local r = self.game and self.game.renderer
if r and r.worldActive then
r.worldFadeAlpha = alpha
return
end
love.graphics.setColor(0, 0, 0, alpha)
love.graphics.rectangle("fill", 0, 0, 160, 144)
love.graphics.setColor(1, 1, 1, 1)
+22 -137
View File
@@ -438,20 +438,6 @@ function Commands.play_sound(ctx, soundId)
require("src.core.Sound").play(ctx.game.data, soundId)
end
-- play_once <songId>: one-shot jingle (Music_PkmnHealed, etc.); blocks
-- until it finishes so heal-rest scripts (Mom, captain text_asm) match
-- Gen1's wait-on-channel loop. The map theme resumes when it ends
-- (Music.playOnce / pendingRestore).
function Commands.play_once(ctx, songId)
local Music = require("src.core.Music")
if not Music.playOnce(ctx.game.data, songId) then return end
local runner = ctx.runner
runner.waitingCheck = function()
return not Music.oneShotPlaying()
end
runner:yield()
end
-- play_cry <species>: PlayCry (home/audio.asm). The text_asm bodies that
-- use it run text_far (a no-button-wait "...@" string) -> PlayCry ->
-- WaitForSoundToFinish, all within the same text ID -- the cry only starts
@@ -484,51 +470,11 @@ function Commands.heal_party(ctx)
end
end
-- AskName (engine/menus/naming_screen.asm): yes/no then NamingScreen.
-- AddPartyMon only offers this for party mons (box deposits skip it).
-- Preserves ctx.lastCheck so GivePokemon's carry is not clobbered by the
-- yes/no result (scripts jump_if_false on give failure afterwards).
local function askNickname(ctx, mon)
local runner = ctx.runner
if not runner then return end
local success = ctx.lastCheck
local name = ctx.game.stringBuffer
or (ctx.game.data.pokemon[mon.species]
and ctx.game.data.pokemon[mon.species].name)
or mon.species
-- Prefer the real text label; fall back to the BattleState wording.
if ctx.game.data.text and ctx.game.data.text._DoYouWantToNicknameText then
Commands.show_text(ctx, "_DoYouWantToNicknameText", { RAM = name })
else
Commands.show_text(ctx, ("Do you want to\ngive a nickname\nto %s?"):format(name))
end
local ChoiceBox = require("src.ui.ChoiceBox")
ctx.game.stack:push(ChoiceBox.new(ctx.game, function(yes)
if not yes then
ctx.lastCheck = success
runner:resume()
return
end
Screens.push(ctx.game, "NamingScreen", {
title = "NICKNAME?", maxLen = 10,
onDone = function(nick)
if nick and #nick > 0 then mon.nickname = nick end
ctx.lastCheck = success
runner:resume()
end,
})
end))
runner:yield()
ctx.lastCheck = success
end
-- give_pokemon <species> <level>: _GivePokemon (engine/events/
-- give_pokemon.asm) -- party first, then the box. ctx.lastCheck gets
-- the asm's carry: true when the mon was given, false when both the
-- party and every box are full (that .boxFull path leaves the giver's
-- script able to offer again later, e.g. the Celadon Eevee ball).
-- Party adds run AskName (AddPartyMon) when a script runner is present;
-- mods that pre-set gift.nickname skip the prompt.
function Commands.give_pokemon(ctx, species, level)
-- Native mods can transform a gift before the Pokémon object is created.
-- This is intentionally an event rather than a special-case starter hook:
@@ -545,8 +491,7 @@ function Commands.give_pokemon(ctx, species, level)
ctx.game.stringBuffer = ctx.game.data.pokemon[species].name or species
ctx.pendingPokemonName = species
require("src.battle.BattleState").stampOT(ctx.save, mon)
local addedToParty = Party.add(ctx.save.party, mon)
if not addedToParty then
if not Party.add(ctx.save.party, mon) then
if not require("src.pokemon.Boxes").deposit(ctx.save, mon) then
ctx.lastCheck = false
return
@@ -558,40 +503,18 @@ function Commands.give_pokemon(ctx, species, level)
dex.owned[species] = true
end
ctx.lastCheck = true
-- AddPartyMon AskName: party only; skip box deposits, mod-set nicknames,
-- and callback-style callers that have no script runner to yield on.
if addedToParty and not gift.nickname and ctx.runner then
askNickname(ctx, mon)
end
end
function Commands.give_money(ctx, amount)
ctx.save.money = math.max(0, ctx.save.money + amount)
end
-- Point LAST_MAP exits at an outdoor door (pokered wLastMap). Keeps the
-- live overworld memory in sync so a scripted home warp from the HoF PC
-- does not leave Red's house mats aimed at Indigo Plateau (#103).
function Commands.remember_outdoor(ctx, mapId, x, y)
local outdoor = { id = mapId, x = x, y = y }
ctx.save.lastOutdoor = outdoor
if ctx.overworld and ctx.overworld.rememberOutdoor then
ctx.overworld:rememberOutdoor(mapId, x, y)
elseif ctx.overworld then
ctx.overworld.lastOutdoor = outdoor
end
end
-- Hall of Fame: snapshot the winning party (SaveHallOfFameTeams inside
-- AnimateHallOfFame), run the induction showcase and the end credits,
-- autosave while THE END is up, then soft-reset to the title -- the whole
-- predef HallOfFamePC + tail of HallOfFameResetEventsAndSaveScript
-- (engine/movie/hall_of_fame.asm, engine/movie/credits.asm,
-- scripts/HallOfFame.asm).
--
-- Departure from pokered: CONTINUE lands in the NewGameWarp bedroom with
-- a healed party and lastOutdoor on Pallet Town, instead of remaining
-- softlocked in HALL_OF_FAME with LAST_MAP still aimed at Indigo (#103).
function Commands.record_hall_of_fame(ctx)
ctx.save.hallOfFame = ctx.save.hallOfFame or {}
local entry = {}
@@ -607,25 +530,17 @@ function Commands.record_hall_of_fame(ctx)
Screens.push(game, "Credits", function()
runner:resume()
end, function()
-- THE END is on screen: heal, place the player at post-game home,
-- retarget LAST_MAP, then SaveGameData. (E4 room-script/event
-- resets that precede the save in pokered are the Indigo lobby's
-- re-entry reset here, data/scripts/story6.lua.)
local SaveData = require("src.core.SaveData")
local Pokemon = require("src.pokemon.Pokemon")
-- THE END is on screen: HallOfFameResetEventsAndSaveScript sets
-- wLastBlackoutMap := PALLET_TOWN and runs SaveGameData, so the
-- save keeps the player standing in the HALL_OF_FAME room. (The
-- E4 room-script/event resets that precede the save in pokered are
-- the Indigo lobby's re-entry reset here, data/scripts/story6.lua.)
-- pokered writes PALLET_TOWN here as a literal, not as "the spawn" --
-- the vanilla spawn is REDS_HOUSE_2F. SaveData.defaultHeal carries
-- that split (and lets a total conversion redirect it).
local boot = game.data.field and game.data.field.boot or {}
for _, mon in ipairs(ctx.save.party or {}) do
Pokemon.heal(mon)
end
SaveData.applyPostGameHome(ctx.save, boot)
if game.overworld then
game.overworld.lastOutdoor = ctx.save.lastOutdoor
end
ctx.save.lastHeal = require("src.core.SaveData").defaultHeal(boot)
if game.writeSave then game:writeSave() end
-- writeSave's captureSave re-stamps the live HALL_OF_FAME coords;
-- re-apply home and persist so CONTINUE resumes in the bedroom.
SaveData.applyPostGameHome(ctx.save, boot)
SaveData.save(ctx.save)
end)
end)
runner:yield()
@@ -940,21 +855,15 @@ function Commands.push_screen(ctx, screenId, args)
runner:yield()
end
-- fade "out"|"in" [frames|"white"|"black"] [frames|"white"|"black"]:
-- screen fade without warping (the Transition ramp startWarpTo uses,
-- split in two). "out" pushes an overlay that stays up; the held state
-- keeps ticking the runner's frame-waits so a script can wait /
-- heal_party / play_once under it. "in" ramps it away.
-- Color defaults to black (warp-style); "white" matches GBFadeOutToWhite
-- / GBFadeInFromWhite (Mom heal, Silph Co. nurse). White defaults to
-- 24 frames (3 palettes x 8); black defaults to 12 (Transition).
-- fade "out"|"in" [frames]: screen fade without warping (the Transition
-- ramp startWarpTo uses, split in two). "out" pushes a black overlay
-- that stays up; the held state keeps ticking the runner's frame-waits
-- so a script can wait/replace_block under it. "in" ramps it away.
local FadeOverlay = {}
FadeOverlay.__index = FadeOverlay
function FadeOverlay.new(game, ow, color)
return setmetatable({
game = game, ow = ow, alpha = 0, color = color or "black",
}, FadeOverlay)
function FadeOverlay.new(game, ow)
return setmetatable({ game = game, ow = ow, alpha = 0 }, FadeOverlay)
end
function FadeOverlay:update()
@@ -980,51 +889,28 @@ function FadeOverlay:update()
end
function FadeOverlay:draw()
if self.color == "white" then
love.graphics.setColor(1, 1, 1, self.alpha)
else
love.graphics.setColor(0, 0, 0, self.alpha)
end
love.graphics.setColor(0, 0, 0, self.alpha)
love.graphics.rectangle("fill", 0, 0, 160, 144)
love.graphics.setColor(1, 1, 1, 1)
end
local function parseFadeArgs(a, b)
local frames, color
if type(a) == "string" then
color = a
if type(b) == "number" then frames = b end
elseif type(a) == "number" then
frames = a
if type(b) == "string" then color = b end
end
color = color or "black"
if not frames then
frames = (color == "white") and 24 or 12
end
return frames, color
end
function Commands.fade(ctx, dir, framesOrColor, colorOrFrames)
function Commands.fade(ctx, dir, frames)
local ow = ctx.overworld
if not ow then return end
local runner = ctx.runner
local frames, color = parseFadeArgs(framesOrColor, colorOrFrames)
frames = frames or 12 -- Transition's ramp length
local overlay = ow.fadeOverlay
if dir == "out" then
if not overlay then
overlay = FadeOverlay.new(ctx.game, ow, color)
overlay = FadeOverlay.new(ctx.game, ow)
ow.fadeOverlay = overlay
ctx.game.stack:push(overlay)
else
overlay.color = color
end
overlay.ramp = { from = overlay.alpha, to = 1, frames = frames, t = 0,
onDone = function() runner:resume() end }
runner:yield()
elseif dir == "in" then
if not overlay then return end
overlay.color = color or overlay.color
overlay.ramp = { from = overlay.alpha, to = 0, frames = frames, t = 0,
onDone = function() runner:resume() end }
runner:yield()
@@ -1112,10 +998,9 @@ for _, verb in ipairs({ "show_text", "ask", "choice", "start_battle", "warp",
end
for _, verb in ipairs({ "show_text", "ask", "choice", "start_battle", "warp",
"open_mart", "trade", "push_screen", "record_hall_of_fame",
"old_man_demo", "static_battle", "rival_battle", "give_item",
"give_pokemon", "wait",
"old_man_demo", "static_battle", "rival_battle", "give_item", "wait",
"wait_flag", "move_player", "move_npc", "move_npc_to", "walk_npc",
"emote", "fade", "pan_camera", "play_once" }) do
"emote", "fade", "pan_camera" }) do
local meta = Commands.meta[verb] or {}
Commands.meta[verb] = meta
meta.blocking = true
+1 -3
View File
@@ -216,9 +216,7 @@ local function useOn(game, battle, id, target, list, moveIndex)
consume(game, id)
require("src.core.Sound").play(game.data, "Teleport_Exit1")
ow.player.surfing = false
-- EnterMapAnim on arrival (BIT_ESCAPE_WARP / special warp path);
-- blackouts omit arrive="teleport" (HandleBlackOut has no LeaveMapAnim)
ow:warpToHealPoint(nil, { arrive = "teleport" })
ow:warpToHealPoint()
else
showMessages(game, { "OAK: " .. game.save.player.name
.. "!\nThis isn't the\ntime to use that!" })
+1 -5
View File
@@ -145,10 +145,6 @@ end
function BoxMenu.new(game)
Boxes.ensure(game.save)
-- bills_pc.asm BillsPCMenu: TextBoxBorder at (0,0) with interior
-- 12x10 → total 14x12. "CHANGE BOX" is 10 tiles and needs the
-- full interior (cursor col + label); the old tw=12 right-side box
-- drew the final glyph on the border.
return Menu.new(game, {
{ label = "WITHDRAW", onSelect = function() withdraw(game) end },
{ label = "DEPOSIT", onSelect = function() deposit(game) end },
@@ -157,7 +153,7 @@ function BoxMenu.new(game)
{ label = "SEE YA!" },
-- Bill's PC runs silent end to end (BIT_NO_MENU_BUTTON_SOUND,
-- engine/menus/pokemon_pc.asm)
}, { tx = 0, ty = 0, tw = 14, th = 12, noSound = true })
}, { tx = 8, ty = 0, tw = 12, th = 12, noSound = true })
end
return BoxMenu
+1 -2
View File
@@ -217,8 +217,7 @@ function Credits:update(dt)
self.timer = FADE_FRAMES
elseif self.phase == "end_fade" then
-- Credits returns to HallOfFameResetEventsAndSaveScript here: the
-- save (plus post-game home relocate, #103) happens now, then
-- 5 x 120 DelayFrames before the button wait
-- save happens now, then 5 x 120 DelayFrames before the button wait
if self.onTheEnd then self.onTheEnd() end
self.phase = "end_hold"
self.timer = 600
+4 -23
View File
@@ -1,11 +1,5 @@
-- Pokédex entry page: front sprite, kind, height/weight and the real
-- dex description (data/pokemon/dex_entries.asm + dex_text.asm).
--
-- `species` may be a species id string, or a table
-- `{ species = id, forceOwned = true }`. forceOwned mirrors pret's
-- StarterDex (engine/events/starter_dex.asm), which temporarily sets the
-- owned bit so Oak's lab ball previews show height/weight/description
-- without permanently marking the mon owned.
local Font = require("src.render.Font")
@@ -22,17 +16,8 @@ function DexEntryMenu:sgbPalettes(game)
P.zone(P.monPal(game.data, self.def and self.def.id), 1, 1, 8, 8) }
end
local function resolveArgs(speciesOrOpts)
if type(speciesOrOpts) == "table" then
return speciesOrOpts.species or speciesOrOpts[1],
speciesOrOpts.forceOwned and true or false
end
return speciesOrOpts, false
end
function DexEntryMenu.new(game, speciesOrOpts)
local species, forceOwned = resolveArgs(speciesOrOpts)
local self = setmetatable({ game = game, forceOwned = forceOwned }, DexEntryMenu)
function DexEntryMenu.new(game, species)
local self = setmetatable({ game = game }, DexEntryMenu)
self.def = game.data.pokemon[species]
local ok, img = pcall(love.graphics.newImage, self.def.spriteFront)
self.sprite = ok and img or nil
@@ -57,16 +42,12 @@ function DexEntryMenu:draw()
love.graphics.setColor(0, 0, 0, 1)
Font.draw(def.name, 72, 8)
local e = def.dexEntry or {}
-- English R/B prints only the kind string (hlcoord 9,4 PlaceString).
-- PokeText ("#"/POKéMON) is an unreferenced JPN leftover in pokedex.asm;
-- appending " POKéMON" here clipped longer kinds ("LIZARD POKé").
Font.draw(e.kind or "?", 72, 20)
Font.draw((e.kind or "?") .. " POKéMON", 72, 20)
-- same number width as the list (constants.dexDigits), so a dex past 999
-- prints the extra digit everywhere at once
local digits = (self.game.data.constants or {}).dexDigits or 3
Font.draw(("No.%0" .. digits .. "d"):format(def.dex or 0), 72, 32)
local owned = self.forceOwned
or (self.game.save.pokedex and self.game.save.pokedex.owned[def.id])
local owned = self.game.save.pokedex and self.game.save.pokedex.owned[def.id]
-- height/weight print only once owned, like the description
-- (pokedex.asm: "if the pokemon has not been owned, don't print the
-- height, weight, or description")
+76 -201
View File
@@ -1,16 +1,14 @@
-- Hall of Fame induction (engine/movie/hall_of_fame.asm): each party
-- member's front sprite scrolls onto the right side of the screen
-- (HoFShowMonOrPlayer's .ScrollPic), then HoFDisplayMonInfo draws the
-- left-side LEVEL/TYPE box, plays the cry, holds, and pops the bottom
-- "HALL OF FAME" text box before fading to the next mon. After the
-- party, the player pic scrolls in and HoFDisplayPlayerStats shows the
-- name/time/money boxes plus the dex rating. Plays Music_HallOfFame
-- when the audio data has it. Calls onDone() after popping itself.
-- member's front sprite scrolls onto the screen (HoFShowMonOrPlayer's
-- .ScrollPic), then its name/level shows and its cry plays
-- (HoFDisplayAndRecordMonInfo). After the last mon, HoFDisplayPlayerStats
-- shows the trainer name, play time, money and Prof. Oak's dex rating.
-- Plays Music_HallOfFame when the audio data has it. Calls onDone() after
-- popping itself.
local Font = require("src.render.Font")
local Music = require("src.core.Music")
local Sound = require("src.core.Sound")
local TypeChart = require("src.battle.TypeChart")
local HallOfFame = {}
HallOfFame.__index = HallOfFame
@@ -19,10 +17,6 @@ HallOfFame.isOpaque = true
-- SGB: SetPal_PokemonWholeScreen for the mon on display
function HallOfFame:sgbPalettes(game)
local P = require("src.render.PaletteFX")
if self.phase == "player" or self.phase == "player_stats"
or self.phase == "player_dex" or self.phase == "player_rating" then
return P.wholeNamed(game.data, "MEWMON")
end
local mon = game.save.party[self.index or 0]
if mon then
local c = P.monPal(game.data, mon.species)
@@ -32,20 +26,17 @@ function HallOfFame:sgbPalettes(game)
return P.wholeNamed(game.data, "MEWMON")
end
local MON_FRAMES = 150 -- ~2.5s per inductee (A advances early)
-- HoFShowMonOrPlayer's .ScrollPic: hSCX is nudged by e = 4px per
-- DelayFrame (doubled on SGB) until it settles. The front pic rests at
-- hlcoord 12,5 (engine/movie/hall_of_fame.asm HoFLoadMonPlayerPicTileIDs).
-- DelayFrame (doubled on SGB) until it settles. The back pic (an
-- enlarged, blurred 2x scale of the back sprite) sweeps right-to-left
-- and off the left edge first; tracing the actual hSCX/hSCY math shows
-- the real front pic that follows enters from the *left* edge and
-- slides *right* into its resting tile, at that same 4px/frame rate --
-- that's the half we port here (the back-pic wipe is a VRAM/scroll-
-- register trick with no equivalent in this sprite-based renderer).
local SCROLL_SPEED = 4 -- px/frame @ 60fps
local PIC_X, PIC_Y = 12 * 8, 5 * 8
-- After HoFDisplayAndRecordMonInfo: 80 DelayFrames, then the bottom
-- HALL OF FAME box for 180 DelayFrames, then GBFadeOutToWhite.
local INFO_HOLD = 80
local HOF_HOLD = 180
local FADE_FRAMES = 20
-- HoFPrintTextAndDelay after each dex line
local DEX_HOLD = 120
local function tryImage(path)
if not path then return nil end
@@ -66,7 +57,7 @@ local function drawTextBlock(text, x, y, maxY)
for line in (text:gsub("\v", "\n"):gsub("\f", "\n") .. "\n"):gmatch("(.-)\n") do
if maxY and y > maxY then break end
Font.draw(line, x, y)
y = y + 8
y = y + 10
end
return y
end
@@ -79,10 +70,6 @@ function HallOfFame.new(game, onDone)
self.timer = 0
self.phase = "mons"
self.sprites = {} -- species -> image or false
self.playerPic = tryImage("assets/generated/trainer_card/red.png")
self.scrollX = PIC_X
self.showHofBanner = false
self.fade = 0
return self
end
@@ -97,21 +84,16 @@ end
function HallOfFame:nextMon()
self.index = self.index + 1
local mon = self.game.save.party[self.index]
self.showHofBanner = false
self.fade = 0
if mon then
self.phase = "mons"
self.timer = INFO_HOLD
self.timer = MON_FRAMES
Sound.playCry(self.game.data, mon.species)
-- scroll the new inductee's pic in from the left (see SCROLL_SPEED)
local sprite = self:spriteFor(mon.species)
local w = sprite and sprite:getWidth() or 56
local w = sprite and sprite:getWidth() or 0
self.scrollRestX = math.floor((160 - w) / 2)
self.scrollX = -w
else
-- HoFShowMonOrPlayer with wHoFMonOrPlayer = player
self.phase = "player"
self.timer = 0
local w = self.playerPic and self.playerPic:getWidth() or 56
self.scrollX = -w
self.phase = "congrats"
end
end
@@ -135,184 +117,77 @@ function HallOfFame:dexSeenOwned()
return seen, owned
end
function HallOfFame:advanceMonPhase()
if self.phase == "mons" and not self.showHofBanner then
-- 80-frame info hold done: TextBoxBorder at (2,13) + "HALL OF FAME"
self.showHofBanner = true
self.timer = HOF_HOLD
elseif self.phase == "mons" then
self.phase = "fade"
self.timer = FADE_FRAMES
self.fade = 0
elseif self.phase == "fade" then
self:nextMon()
end
end
function HallOfFame:update(dt)
local input = self.game.input
local skip = input:wasPressed("a")
if self.phase == "mons" or self.phase == "fade" then
if self.phase == "mons" and self.scrollX < PIC_X then
self.scrollX = math.min(PIC_X, self.scrollX + SCROLL_SPEED)
return
end
if self.phase == "fade" then
self.timer = self.timer - 1
self.fade = 1 - math.max(0, self.timer) / FADE_FRAMES
if self.timer <= 0 or skip then self:advanceMonPhase() end
return
if self.phase == "mons" then
if self.scrollX and self.scrollX < self.scrollRestX then
self.scrollX = math.min(self.scrollRestX, self.scrollX + SCROLL_SPEED)
end
self.timer = self.timer - 1
if skip or self.timer <= 0 then
self:advanceMonPhase()
if input:wasPressed("a") or self.timer <= 0 then
self:nextMon()
end
elseif self.phase == "player" then
if self.scrollX < PIC_X then
self.scrollX = math.min(PIC_X, self.scrollX + SCROLL_SPEED)
return
end
self.phase = "player_stats"
self.timer = DEX_HOLD
elseif self.phase == "player_stats" then
-- name / play time / money boxes are up; then DexSeenOwnedText
self.timer = self.timer - 1
if skip or self.timer <= 0 then
self.phase = "player_dex"
self.timer = DEX_HOLD
end
elseif self.phase == "player_dex" then
self.timer = self.timer - 1
if skip or self.timer <= 0 then
self.phase = "player_rating"
self.timer = DEX_HOLD
end
elseif self.phase == "player_rating" then
self.timer = self.timer - 1
if skip or self.timer <= 0 then
-- HoFFadeOutScreenAndMusic -> Credits lead-in (no A wait here)
self.game.stack:pop()
if self.onDone then self.onDone() end
end
end
end
-- HoFDisplayMonInfo: TextBoxBorder (0,2) b=9,c=10 + LEVEL/TYPE labels
function HallOfFame:drawMonInfo(mon)
local def = self.game.data.pokemon[mon.species]
Font.drawBox(0, 2, 12, 11)
love.graphics.setColor(0, 0, 0, 1)
local name = mon.nickname or (def and def.name) or mon.species
Font.draw(name, 1 * 8, 4 * 8)
Font.draw("LEVEL/", 2 * 8, 6 * 8)
Font.draw("TYPE1/", 2 * 8, 7 * 8)
local t1 = def and def.types and def.types[1]
local t2 = def and def.types and def.types[2]
local dual = t2 and t2 ~= t1
if dual then
Font.draw("TYPE2/", 2 * 8, 8 * 8)
end
-- PrintLevelCommon at (8,7): bare level digits (no <LV> tile here)
Font.draw(tostring(mon.level), 8 * 8, 7 * 8)
-- PrintMonType at (3,9) / +2 rows for type 2
if t1 then
Font.draw(TypeChart.displayName(t1), 3 * 8, 9 * 8)
end
if dual then
Font.draw(TypeChart.displayName(t2), 3 * 8, 11 * 8)
end
end
-- Bottom HALL OF FAME banner: TextBoxBorder (2,13) b=3,c=14
function HallOfFame:drawHofBanner()
Font.drawBox(2, 13, 16, 5)
love.graphics.setColor(0, 0, 0, 1)
Font.draw("HALL OF FAME", 4 * 8, 15 * 8)
end
function HallOfFame:drawPic(img)
if not img then return end
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(img, self.scrollX or PIC_X, PIC_Y)
end
-- HoFDisplayPlayerStats boxes + labels (player pic already on the right)
function HallOfFame:drawPlayerStats()
local save = self.game.save
-- name box: TextBoxBorder (5,0) b=2,c=9 → drawBox(5,0,11,4)
Font.drawBox(5, 0, 11, 4)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(save.player.name or "RED", 7 * 8, 2 * 8)
-- play time / money box: TextBoxBorder (0,4) b=6,c=10 → drawBox(0,4,12,8)
Font.drawBox(0, 4, 12, 8)
love.graphics.setColor(0, 0, 0, 1)
Font.draw("PLAY TIME", 1 * 8, 6 * 8)
local t = math.floor(save.playTime or 0)
Font.draw(("%3d:%02d"):format(math.floor(t / 3600), math.floor(t / 60) % 60),
5 * 8, 7 * 8)
Font.draw("MONEY", 1 * 8, 9 * 8)
-- PrintBCDNumber with MONEY_SIGN; port uses ¥ like TrainerCard
Font.draw(("¥%d"):format(save.money or 0), 4 * 8, 10 * 8)
end
function HallOfFame:drawDexBox(kind)
local save = self.game.save
local text = self.game.data.text or {}
Font.drawBox(0, 12, 20, 6)
love.graphics.setColor(0, 0, 0, 1)
if kind == "seen" then
local seen, owned = self:dexSeenOwned()
local seenOwned = text._DexSeenOwnedText
or "POKéDEX Seen:{NUM:wDexRatingNumMonsSeen, 1, 3}\n Owned:{NUM:wDexRatingNumMonsOwned, 1, 3}"
seenOwned = seenOwned
:gsub("{NUM:wDexRatingNumMonsSeen[^}]*}", tostring(seen))
:gsub("{NUM:wDexRatingNumMonsOwned[^}]*}", tostring(owned))
drawTextBlock(seenOwned, 1 * 8, 14 * 8, 17 * 8)
else
local _, owned = self:dexSeenOwned()
local ratingHeader = (text._DexRatingText or "POKéDEX Rating{COLON}"):gsub("{COLON}", ":")
Font.draw(ratingHeader, 1 * 8, 14 * 8)
local rating = text[dexRatingKey(owned)] or "Keep it up!"
drawTextBlock(rating, 1 * 8, 15 * 8, 17 * 8)
elseif input:wasPressed("a") then
Sound.play(self.game.data, "Press_AB")
self.game.stack:pop()
if self.onDone then self.onDone() end
end
end
function HallOfFame:draw()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
if self.phase == "mons" or self.phase == "fade" then
love.graphics.setColor(0, 0, 0, 1)
if self.phase == "mons" then
Font.draw("HALL OF FAME", (160 - 12 * 8) / 2, 8)
local mon = self.game.save.party[self.index]
if mon then
self:drawPic(self:spriteFor(mon.species))
if self.scrollX >= PIC_X then
self:drawMonInfo(mon)
if self.showHofBanner then
self:drawHofBanner()
end
local def = self.game.data.pokemon[mon.species]
love.graphics.setColor(1, 1, 1, 1)
local sprite = self:spriteFor(mon.species)
if sprite then
local w, h = sprite:getDimensions()
love.graphics.draw(sprite, self.scrollX or math.floor((160 - w) / 2), 96 - h)
end
love.graphics.setColor(0, 0, 0, 1)
local name = mon.nickname or (def and def.name) or mon.species
Font.draw(name, 32, 108)
Font.draw((":L%d"):format(mon.level), 112, 108)
end
if self.phase == "fade" and self.fade > 0 then
love.graphics.setColor(1, 1, 1, self.fade)
love.graphics.rectangle("fill", 0, 0, 160, 144)
end
elseif self.phase == "player" then
self:drawPic(self.playerPic)
elseif self.phase == "player_stats" then
self:drawPic(self.playerPic)
self:drawPlayerStats()
elseif self.phase == "player_dex" then
self:drawPic(self.playerPic)
self:drawPlayerStats()
self:drawDexBox("seen")
elseif self.phase == "player_rating" then
self:drawPic(self.playerPic)
self:drawPlayerStats()
self:drawDexBox("rating")
end
else
-- HoFDisplayPlayerStats (no "HALL OF FAME" banner here -- the real
-- screen is a fresh ClearScreen): trainer name, play time, money,
-- then the POKéDEX seen/owned tally and Prof. Oak's rating text,
-- using the same real save-data fields as TrainerCard.lua
-- (save.player.name/playTime/money) and PokedexMenu.lua/
-- OverworldController:dexRating (save.pokedex.seen/owned).
local save = self.game.save
local text = self.game.data.text or {}
local y = 8
Font.draw(save.player.name or "RED", 8, y)
y = y + 16
local t = math.floor(save.playTime or 0)
Font.draw(("PLAY TIME %3d:%02d"):format(math.floor(t / 3600),
math.floor(t / 60) % 60), 8, y)
y = y + 12
Font.draw(("MONEY ¥%d"):format(save.money or 0), 8, y)
y = y + 16
local seen, owned = self:dexSeenOwned()
local seenOwned = text._DexSeenOwnedText
or "POKéDEX Seen:{NUM:wDexRatingNumMonsSeen, 1, 3}\n Owned:{NUM:wDexRatingNumMonsOwned, 1, 3}"
seenOwned = seenOwned
:gsub("{NUM:wDexRatingNumMonsSeen[^}]*}", tostring(seen))
:gsub("{NUM:wDexRatingNumMonsOwned[^}]*}", tostring(owned))
y = drawTextBlock(seenOwned, 8, y) + 6
local ratingHeader = (text._DexRatingText or "POKéDEX Rating{COLON}"):gsub("{COLON}", ":")
Font.draw(ratingHeader, 8, y)
y = y + 12
local rating = text[dexRatingKey(owned)] or "Keep it up!"
drawTextBlock(rating, 8, y, 136)
end
love.graphics.setColor(1, 1, 1, 1)
end
+3 -13
View File
@@ -117,7 +117,7 @@ function ListMenu:draw()
love.graphics.setColor(0, 0, 0, 1)
end
if item.right then
Font.draw(item.right, 160 - 8 - Font.width(item.right), y)
Font.draw(item.right, 160 - 8 - #item.right * 8, y)
end
if i == self.index then
-- hollowIndex: a chosen row keeps the hollow '▷' left behind by
@@ -136,7 +136,7 @@ function ListMenu:draw()
Font.drawBox(11, 0, 9, 3)
love.graphics.setColor(0, 0, 0, 1)
local money = ("¥%d"):format(self.money and self.money() or 0)
Font.draw(money, 152 - Font.width(money), 8)
Font.draw(money, 152 - #money * 8, 8)
-- the clerk's line in the standard bottom text box; long prompts
-- wrap and keep their last two lines, like the GB's scrolled box
Font.drawBox(0, 12, 20, 6)
@@ -153,17 +153,7 @@ function ListMenu:draw()
end
end
elseif self.footer then
-- PC deposit/withdraw footers use "\n"; draw the last two lines so
-- long item names are not clipped at the screen edge (#115).
local flat = {}
for _, page in ipairs(require("src.render.TextBox").paginate(self.footer)) do
for _, line in ipairs(page) do flat[#flat + 1] = line end
end
local y = (#flat >= 2) and 120 or 136
for i = math.max(1, #flat - 1), #flat do
Font.draw(flat[i], 8, y)
y = y + 16
end
Font.draw(self.footer, 8, 136)
end
love.graphics.setColor(1, 1, 1, 1)
end
+4 -43
View File
@@ -19,16 +19,7 @@ function Menu.new(game, items, opts)
self.tx = opts.tx or 10
self.ty = opts.ty or 0
self.tw = opts.tw or 10
self.rowStep = opts.rowStep or 2
-- maxVisible: cap the box to this many rows and scroll the rest instead
-- of growing past it (e.g. the start menu, whose row count varies with
-- save state and mod hooks); nil/unset keeps every caller's old
-- behavior of sizing the box to fit all items.
self.maxVisible = opts.maxVisible
self.scroll = 0
local visible = (self.maxVisible and math.min(self.maxVisible, #items))
or #items
self.th = opts.th or (visible * self.rowStep + 2)
self.th = opts.th or (#items * 2 + 2)
self.cancelable = opts.cancelable ~= false
-- Whether START closes the menu. In pokered a menu responds only to the
-- keys in its wMenuWatchedKeys mask; the common PAD_A | PAD_B (and the
@@ -40,25 +31,9 @@ function Menu.new(game, items, opts)
-- BIT_NO_MENU_BUTTON_SOUND (wMiscFlags): the PC session runs its
-- menus silent (home/window.asm HandleMenuInput_)
self.noSound = opts.noSound or false
self:clampScroll()
return self
end
-- keeps self.index inside the visible [scroll+1, scroll+maxVisible] window;
-- callers that move self.index directly (e.g. restoring a saved cursor
-- position) should call this afterwards to scroll it into view
function Menu:clampScroll()
if not (self.maxVisible and #self.items > self.maxVisible) then
self.scroll = 0
return
end
if self.index - self.scroll > self.maxVisible then
self.scroll = self.index - self.maxVisible
elseif self.index - self.scroll < 1 then
self.scroll = self.index - 1
end
end
function Menu:update(dt)
local input = self.game.input
if input:wasPressed("up") then
@@ -86,29 +61,15 @@ function Menu:update(dt)
self.game.stack:pop()
if self.onCancel then self.onCancel() end
end
self:clampScroll()
end
function Menu:draw()
Font.drawBox(self.tx, self.ty, self.tw, self.th)
love.graphics.setColor(0, 0, 0, 1)
local visible = (self.maxVisible and math.min(self.maxVisible, #self.items))
or #self.items
for row = 1, visible do
local item = self.items[self.scroll + row]
if not item then break end
Font.draw(item.label, (self.tx + 2) * 8,
(self.ty + row * self.rowStep - (self.rowStep - 1)) * 8)
end
local cursorRow = self.index - self.scroll
Font.drawCode(Theme.cursor, (self.tx + 1) * 8,
(self.ty + cursorRow * self.rowStep - (self.rowStep - 1)) * 8)
-- moreArrow ($EE): the same "more below" glyph OptionRows/ManagerState
-- use, sat on the bottom border like TextBox's page-advance cursor
if self.maxVisible and self.scroll + self.maxVisible < #self.items then
Font.drawCode(Theme.moreArrow, (self.tx + self.tw - 2) * 8,
(self.ty + self.th - 2) * 8)
for i, item in ipairs(self.items) do
Font.draw(item.label, (self.tx + 2) * 8, (self.ty + i * 2 - 1) * 8)
end
Font.drawCode(Theme.cursor, (self.tx + 1) * 8, (self.ty + self.index * 2 - 1) * 8)
love.graphics.setColor(1, 1, 1, 1)
end
-8
View File
@@ -22,14 +22,6 @@ local OptionsMenu = {}
OptionsMenu.__index = OptionsMenu
OptionsMenu.isOpaque = true
-- Opaque full-screen menu: own MEWMON so opening OPTION from the title
-- (or over the overworld) does not inherit TitleState's LOGO1 band -- that
-- zone covers UI rows 8-9, which is the third options box label line
-- (pink "MODS" strip when Blue's ROM LOGO1 white is {255,239,255}).
function OptionsMenu:sgbPalettes(game)
return PaletteFX.wholeNamed(game.data, "MEWMON")
end
-- TextSpeedOptionData frame delays with the original labels
local SPEEDS = { { 1, "FAST" }, { 3, "MEDIUM" }, { 5, "SLOW" } }
-- no-loader fallback for the ruleset row, same pair BattleState keeps
+8 -31
View File
@@ -69,26 +69,10 @@ local function drawIcon(game, mon, x, y, selected, counter)
local icons = game.data.icons
if not icons then return end
local def = game.data.pokemon[mon.species]
-- Per-species override first: the icons registry folds into
-- icons.bySpecies, and a pokemon record may carry its own `icon` field.
-- Either is a built-in icon name (resolved through icons.icons) or a
-- { image = <path>, frames? } table pointing at bundled art. Falling
-- through to icons.byDex[def.dex] keeps the vanilla dex-indexed default;
-- without the override a modded or dex-renumbered species could never
-- change its menu icon.
local entry = (icons.bySpecies and icons.bySpecies[mon.species])
or (def and def.icon)
local name, path
if type(entry) == "string" then
name = entry
path = icons.icons and icons.icons[entry]
elseif type(entry) == "table" then
path = entry.image
end
if not path then
name = def and def.dex and icons.byDex and icons.byDex[def.dex]
path = name and icons.icons and icons.icons[name]
end
-- byDex is the vanilla lookup, but the icons registry can bring the table
-- into existence on its own, so it may be the only key present
local name = def and def.dex and icons.byDex and icons.byDex[def.dex]
local path = name and icons.icons[name]
if not path then return end
if iconImages[path] == nil then
local ok, img = pcall(love.graphics.newImage, path)
@@ -198,13 +182,12 @@ function PartyMenu:update(dt)
-- and music return first (PlayDefaultMusic +
-- LoadWalkingPlayerSpriteGraphics), the menu closes with the
-- GBPalWhiteOutWithDelay3 blink, and the simulated pad press
-- steps the player forward onto land (or across a connection
-- strip when the shore is the next map's edge)
-- steps the player forward onto land
self.game.stack:pop()
ow.player.surfing = false
require("src.core.Music").setSurfing(self.game.data, false)
self.game.stack:push(Transition.whiteFlash(self.game, nil, function()
ow:stepForwardOrCrossEdge(ow.player.facing)
ow:scriptMove(ow.player, ow.player.facing, 1)
end))
return
end
@@ -294,9 +277,7 @@ function PartyMenu:update(dt)
if ow and heal then
self.game.stack:push(Transition.whiteFlash(self.game, nil, function()
require("src.core.Sound").play(self.game.data, "Teleport_Exit1")
-- EnterMapAnim on arrival (HandleFlyWarpOrDungeonWarp sets
-- BIT_FLY_WARP); blackouts must not pass arrive="teleport"
ow:warpToHealPoint(nil, { arrive = "teleport" })
ow:warpToHealPoint()
end))
end
return
@@ -350,11 +331,7 @@ function PartyMenu:update(dt)
local items = { { label = "STATS", action = "stats" },
{ label = "SWITCH", action = "switch" } }
local ow = self.game.overworld
-- Field moves (HMs/TMs) are usable out of battle even when the mon
-- is fainted -- Gen 1 does not require HP for Cut/Fly/Surf/etc.
-- Battle still excludes this list via `not self.battle`. Softboiled
-- can appear for a fainted user; its heal transfer then no-ops.
if not self.battle and ow then
if not self.battle and ow and mon.hp > 0 then
for _, mv in ipairs(mon.moves) do
if mv.id == "FLY" and ow.map.def.tileset == "OVERWORLD"
and self.game.save.inventory.THUNDERBADGE then
+1 -2
View File
@@ -1,7 +1,6 @@
-- The player's item-storage PC (engine/menus/players_pc.asm):
-- WITHDRAW ITEM / DEPOSIT ITEM / TOSS ITEM / LOG OFF over
-- game.save.pcItems ({ ITEM_ID = count }; New Game seeds { POTION = 1 },
-- older saves may still be empty until first deposit). Withdraw and
-- game.save.pcItems ({ ITEM_ID = count }, created lazily). Withdraw and
-- deposit ask "How many?" via the quantity selector (key items always
-- move one); toss discards after a YES/NO confirm. Follows the
-- BoxMenu/BagMenu list idioms.
+3 -7
View File
@@ -84,17 +84,13 @@ local function buy(game, stock)
end
local function sell(game)
-- Sell list is ITEMLISTMENU with wPrintItemPrices cleared
-- (pokemart.asm .sellMenuLoop): name + quantity only. Price shows
-- in the quantity chooser. Stuffing "xN" into the label next to a
-- right-aligned ¥ price made long names overlap (issue #116).
local items = {}
for _, id in ipairs(Bag.order(game.save)) do
local def = game.data.items[id]
table.insert(items, {
value = id,
label = def and def.name or id,
right = "x" .. game.save.inventory[id],
label = (def and def.name or id) .. " x" .. game.save.inventory[id],
right = ("¥%d"):format(def and math.floor(def.price / 2) or 0),
})
end
local greet = txt(game, "_PokemartBuyingGreetingText", "Take your time.")
@@ -132,7 +128,7 @@ local function sell(game)
Bag.remove(game.save, item.value, qty)
local left = game.save.inventory[item.value]
if left then
item.right = "x" .. left
item.label = def.name .. " x" .. left
else
list:removeCurrent()
end
+1 -11
View File
@@ -7,7 +7,6 @@
local Font = require("src.render.Font")
local Logger = require("src.core.Logger")
local Menu = require("src.ui.Menu")
local Renderer = require("src.render.Renderer")
local Runtime = require("src.mods.Runtime")
local Screens = require("src.ui.Screens")
@@ -118,20 +117,11 @@ function StartMenu.new(game)
-- the start menu's mask is PAD_DOWN | PAD_UP | PAD_START | PAD_B | PAD_A
-- (engine/menus/draw_start_menu.asm), so START closes it back to the
-- overworld -- unlike most menus, whose masks omit PAD_START.
--
-- item count isn't fixed: POKéDEX/LINK/MODS come and go with save state,
-- and mods can append their own rows through the hook above, so the
-- double-spaced box (the original's style) can grow past the 18-tile
-- canvas. Cap it at however many rows actually fit and scroll the rest,
-- with Menu's moreArrow showing while there's more below.
local rowStep = 2
local maxVisible = math.floor((Renderer.HEIGHT / 8 - 2) / rowStep)
local menu = Menu.new(game, items,
{ tx = 9, ty = 0, tw = 11, maxVisible = maxVisible, startCloses = true })
{ tx = 9, ty = 0, tw = 11, th = #items * 2 + 2, startCloses = true })
-- the cursor position survives closing the menu
-- (wBattleAndStartSavedMenuItem, home/start_menu.asm)
menu.index = math.min(game.save.startMenuIndex or 1, #items)
menu:clampScroll()
local baseUpdate = menu.update
menu.update = function(self, dt)
baseUpdate(self, dt)
+3 -7
View File
@@ -115,16 +115,12 @@ function SummaryMenu:draw()
drawLineBox(19, 1, 6, 10)
Font.draw("EXP POINTS", 72, 24)
Font.draw(("%d"):format(mon.exp), 96, 32)
-- StatusScreen2: "LEVEL UP" at (9,5); next-exp PrintNumber 7 cols
-- at (7,6); space at (14,6); PrintLevel at (16,6). The old
-- "%d to L%d" string at x=88 overflowed the DrawLineBox edge.
Font.draw("LEVEL UP", 72, 40)
Font.draw("LEVEL UP", 72, 44)
local Growth = require("src.pokemon.Growth")
local nextExp = mon.level < 100
and (Growth.expForLevel(def.growthRate, mon.level + 1) - mon.exp) or 0
Font.draw(("%7d"):format(math.max(0, nextExp)), 56, 48)
HudTiles.tile(0x6E, 128, 48) -- <LV>
Font.draw(tostring(math.min(100, mon.level + 1)), 136, 48)
Font.draw(("%d to L%d"):format(math.max(0, nextExp),
math.min(100, mon.level + 1)), 88, 52)
Font.drawBox(0, 8, 20, 10)
for i = 1, 4 do
local mv = mon.moves[i]
+5 -33
View File
@@ -13,35 +13,13 @@ TitleState.isOpaque = true
-- SGB title zones (PalPacket_Titlescreen): the logo rows get LOGO2,
-- the version-ribbon band LOGO1, the rest MEWMON.
--
-- The CONTINUE / NEW GAME menu and the continue-info box sit inside those
-- LOGO bands. pokered's MainMenu clears the title and runs
-- RunDefaultPaletteCommand so black UI ink stays black; this port keeps the
-- title art visible underneath, so without an overlay those boxes inherit
-- LOGO2 blue / LOGO1 red (issue #133). A trailing trueColor zone leaves the
-- overlay's DMG black unshaded while the logo and title mon keep title pals.
--
-- ROM SuperPal whites are often {255,239,255}. Under RED++, LOGO2/MEWMON
-- come from the GBC pack (pure white) while Blue's LOGO1 stays on the ROM
-- pack (#128), so the version-ribbon row reads as a pink band. Force that
-- slot to pure white; ink colors (Blue/Red "Version" text) stay intact.
local function withPureWhite(pal)
if not pal then return nil end
return { { 255, 255, 255 }, pal[2], pal[3], pal[4] }
end
function TitleState:sgbPalettes(game)
local P = require("src.render.PaletteFX")
local z = {
P.zone(P.pal(game.data, "LOGO2"), 0, 0, 19, 7),
P.zone(withPureWhite(P.pal(game.data, "LOGO1")), 0, 8, 19, 9),
P.zone(P.pal(game.data, "LOGO1"), 0, 8, 19, 9),
P.zone(P.pal(game.data, "MEWMON"), 0, 10, 19, 17),
}
local top = game.stack and game.stack:top()
local box = top and top.titleUiBox
if box then
z[#z + 1] = P.trueColorZone(box[1], box[2], box[3], box[4])
end
return z[3] and z or nil
end
@@ -141,11 +119,8 @@ local ContinueInfo = {}
ContinueInfo.__index = ContinueInfo
function ContinueInfo.new(title, save)
-- box at (4,7), 16x10 tiles -- see ContinueInfo:draw / DisplayContinueGameInfo
return setmetatable({
title = title, game = title.game, save = save,
titleUiBox = { 4, 7, 19, 16 },
}, ContinueInfo)
return setmetatable({ title = title, game = title.game, save = save },
ContinueInfo)
end
function ContinueInfo:update(dt)
@@ -209,11 +184,8 @@ function TitleState:openMenu()
love.event.quit()
end
end })
local th = #items * 2 + 2
local menu = Menu.new(game, items, { tx = 0, ty = 0, tw = 13, th = th })
-- full-width title LOGO zones would recolor this box; see sgbPalettes
menu.titleUiBox = { 0, 0, 12, th - 1 }
game.stack:push(menu)
game.stack:push(Menu.new(game, items,
{ tx = 0, ty = 0, tw = 13, th = #items * 2 + 2 }))
end
function TitleState:update(dt)
+1 -4
View File
@@ -120,11 +120,8 @@ FieldDefaults.FIELD = {
doorBlock = { bx = 2, by = 2, block = 5 } },
},
-- IsSurfingAllowed refuses SURF on the B4F stairs square until both
-- plug boulders are down (engine/overworld/field_move_messages.asm).
-- B3F currents set BIT_FORCED_WARP so the south-edge water warps fire
-- without a held d-pad (scripts/SeafoamIslandsB3F.asm).
-- plug boulders are down (engine/overworld/field_move_messages.asm)
seafoam = {
SEAFOAM_ISLANDS_B3F = { setsForcedWarp = true },
SEAFOAM_ISLANDS_B4F = {
surfBlocked = { { x = 7, y = 11, untilEvents = {
"EVENT_SEAFOAM4_BOULDER1_DOWN_HOLE",
+26 -47
View File
@@ -38,51 +38,6 @@ local function hashSet(list, into)
return into
end
-- Collision tile (bottom-left 8x8) of a cell on an UNLOADED map def --
-- the connected neighbor during an edge crossing. pokered's
-- GetTileAndCoordsInFrontOfPlayer / collision checks read the neighbor
-- strip's tile bytes the same way.
function Map.defCellTile(def, tilesetDef, cx, cy)
if not (def and tilesetDef and tilesetDef.blocks) then return nil end
local tx, ty = cx * 2, cy * 2 + 1
local bx, by = math.floor(tx / 4), math.floor(ty / 4)
local id
if bx < 0 or by < 0 or bx >= def.width or by >= def.height then
id = def.borderBlock
else
id = def.blocks[by * def.width + bx + 1]
end
local block = tilesetDef.blocks[(id or 0) + 1]
if not block then return nil end
return block[(ty % 4) * 4 + (tx % 4) + 1]
end
local function defWaterTileSet(def, tilesetDef)
local water = {}
hashSet(tilesetDef.waterTiles or WATER_TILES, water)
local shore = tilesetDef.shoreTiles
if shore == nil and not NO_SHORE_TILESETS[def.tileset] then shore = SHORE_TILES end
hashSet(shore or {}, water)
return water
end
-- Water/shore on an unloaded map def (same tile ids as Map:isWaterCell).
function Map.defIsWaterCell(def, tilesetDef, cx, cy)
local tile = Map.defCellTile(def, tilesetDef, cx, cy)
if tile == nil then return false end
return defWaterTileSet(def, tilesetDef)[tile] or false
end
function Map.defIsWalkableCell(def, tilesetDef, cx, cy)
if not (tilesetDef and tilesetDef.walkable) then return false end
local tile = Map.defCellTile(def, tilesetDef, cx, cy)
if tile == nil then return false end
for _, t in ipairs(tilesetDef.walkable) do
if t == tile then return true end
end
return false
end
-- Passability of a cell of an UNLOADED map def -- the connected neighbor
-- during an edge crossing. pokered's collision check reads the neighbor
-- strip's tile bytes, so a step off the map edge onto a solid tile of
@@ -97,8 +52,32 @@ function Map.defPassable(def, tilesetDef, cx, cy, surfing)
if not (def and tilesetDef and tilesetDef.blocks and tilesetDef.walkable) then
return false
end
if Map.defIsWalkableCell(def, tilesetDef, cx, cy) then return true end
if surfing and Map.defIsWaterCell(def, tilesetDef, cx, cy) then return true end
local tx, ty = cx * 2, cy * 2 + 1
local bx, by = math.floor(tx / 4), math.floor(ty / 4)
local id
if bx < 0 or by < 0 or bx >= def.width or by >= def.height then
id = def.borderBlock
else
id = def.blocks[by * def.width + bx + 1]
end
local block = tilesetDef.blocks[(id or 0) + 1]
if not block then return false end
local tile = block[(ty % 4) * 4 + (tx % 4) + 1]
for _, t in ipairs(tilesetDef.walkable) do
if t == tile then return true end
end
if surfing then
for _, t in ipairs(tilesetDef.waterTiles or WATER_TILES) do
if t == tile then return true end
end
local shore = tilesetDef.shoreTiles
if shore == nil and not NO_SHORE_TILESETS[def.tileset] then
shore = SHORE_TILES
end
for _, t in ipairs(shore or {}) do
if t == tile then return true end
end
end
return false
end
+52 -209
View File
@@ -303,9 +303,6 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
else
self.player = Player.new(Game.data, x, y, facing)
end
-- crossConnection re-arms this after setMap; clear so a warp/reload
-- cannot leave a stale deferred PlayMapMusic pending
self.pendingSeamMusic = nil
self.entities = { self.player }
for _, n in ipairs(self.npcs) do table.insert(self.entities, n) end
@@ -325,12 +322,6 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
-- 16/18 gate exits), and the scripted door-mat walkout that follows
-- suppresses onStepComplete, so waiting for a plain step never mounts
self:checkForcedMovement()
-- Seafoam B4F's map script pushes off the B3F stair warps every frame
-- while the upper plugs are out (SeafoamIslandsB4FDefaultScript); the
-- B3F/B4F force-surf mouths also arm their MOVE_OBJECT current scripts
-- from CheckForceBikeOrSurf. Re-check here so a warp-in does not sit
-- idle on those cells waiting for a player step.
self:checkSeafoamCurrent()
-- snap the camera immediately: the overworld doesn't update while a
-- Transition is on top, so a stale camera would show the new map at
@@ -347,13 +338,10 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
or (fromMapId and "warp" or "boot"),
})
-- map-enter hooks (hand-ported map scripts, e.g. Victory Road barriers).
-- fromMapId lets elevators seed a valid walk-out floor when the ROM
-- car warps still point at a missing map (Silph's UNUSED_MAP_ED) and
-- the player B-cancels the floor menu without .UpdateWarp.
-- map-enter hooks (hand-ported map scripts, e.g. Victory Road barriers)
local hooks = mapScripts.get(mapId)
if hooks and hooks.onEnter then
hooks.onEnter(Game, self, fromMapId)
hooks.onEnter(Game, self)
end
self:rebuildNeighbors()
@@ -811,15 +799,6 @@ function OverworldState:update(dt)
if entry and (self.player.cellX ~= entry.x or self.player.cellY ~= entry.y) then
self.warpEntryCell = nil
end
-- deferred PlayMapMusic from crossConnection (issue #93)
if stepped and self.pendingSeamMusic then
local mapId = self.pendingSeamMusic
self.pendingSeamMusic = nil
if mapId == self.map.id then
require("src.core.Music").playMap(Game.data, mapId, Game.save.onBike,
self.player.surfing)
end
end
if stepped and not scripted then
self:onStepComplete()
end
@@ -1021,19 +1000,20 @@ function OverworldState:checkEdgeExit(dir)
return false
end
-- Landing cell on the connected map for a step off this map's edge in
-- `dir` (same math as crossConnection). Returns destDef, tilesetDef, x, y
-- or nil when there is no usable connection.
function OverworldState:connectionLanding(dir)
local conn = self.map:connection(COMPASS[dir])
if not conn then return nil end
-- Map connections: the connected map's strip offset is in blocks; arriving
-- coordinates follow destX = curX - offset*2 (see docs/extraction-notes.md).
-- The crossing scrolls continuously: the map data swaps while the player
-- is placed one cell before the entry point (their old world position,
-- which the neighbor strips render identically) and walks the seam step.
function OverworldState:crossConnection(dir, conn)
local dest = Game.data.maps[conn.map]
if not dest then return nil end
local ts = Game.data.tilesets[dest.tileset]
if not ts then return nil end
if not dest then
Logger.warn("connection to unknown map %s", tostring(conn.map))
return false
end
local p = self.player
local x, y = p.cellX, p.cellY
local destW, destH = dest.width * 2, dest.height * 2
local x, y
if dir == "up" then
x, y = p.cellX - conn.offset * 2, destH - 1
elseif dir == "down" then
@@ -1045,38 +1025,16 @@ function OverworldState:connectionLanding(dir)
end
x = math.max(0, math.min(destW - 1, x))
y = math.max(0, math.min(destH - 1, y))
return dest, ts, x, y, conn
end
-- Map connections: the connected map's strip offset is in blocks; arriving
-- coordinates follow destX = curX - offset*2 (see docs/extraction-notes.md).
-- The crossing scrolls continuously: the map data swaps while the player
-- is placed one cell before the entry point (their old world position,
-- which the neighbor strips render identically) and walks the seam step.
function OverworldState:crossConnection(dir, conn)
local dest, ts, x, y = self:connectionLanding(dir)
if not dest then
Logger.warn("connection to unknown map %s", tostring(conn and conn.map))
return false
end
local p = self.player
-- pokered's collision check reads the NEIGHBOR strip's tile bytes, so
-- stepping off the edge onto a solid tile of the connected map bumps
-- exactly like an in-map wall. Without this read, Pallet's south
-- shore (land at x2-3) walked straight onto ROUTE_21 (3,0) -- a
-- collision tile -- stranding the player on a cell no walk can leave.
if not Map.defPassable(dest, ts, x, y, p.surfing) then
if not Map.defPassable(dest, Game.data.tilesets[dest.tileset], x, y,
p.surfing) then
return false
end
-- keepMusic: defer PlayMapMusic until the seam step lands. Starting a
-- new chip song inside setMap used to hitch the render thread (~200ms)
-- so FixedStep catch-up ate the walk frames (issue #93). Threaded synth
-- removed most of that hitch; discarding catch-up + deferring the song
-- still protects the visible step when neighbor rebuild or the sync
-- fallback stalls, and avoids the rare one-frame volume spike from a
-- song swap mid-step.
self:setMap(conn.map, x, y, p.facing, { seamless = true, keepMusic = true })
self.pendingSeamMusic = conn.map
self:setMap(conn.map, x, y, p.facing, { seamless = true })
-- place the player one cell before the seam (their old world spot,
-- which the neighbor strip renders identically) and start the step
-- into the new map RIGHT NOW so there is no one-frame stall at the
@@ -1090,71 +1048,18 @@ function OverworldState:crossConnection(dir, conn)
p.targetX, p.targetY = x, y
p.moving = true
p.progress = 0
-- fresh walk-cycle clock so the seam step always shows leg frames
-- (mid-cycle stand phase would otherwise look like a slide)
p.animClock = 0
p.stepFramesCur = Game.save.onBike
and (FieldDefaults.world(Game.data, "bikeStepFrames") or 8)
or (FieldDefaults.world(Game.data, "stepFrames") or 16)
require("src.core.FixedStep"):discardCatchup()
return true
end
-- ItemUseSurfboard's simulated pad press: step onto the facing cell, or
-- cross a map connection when that cell is off this map's edge (Cinnabar
-- east coast -> Route 20 water, and the reverse dismount ashore).
function OverworldState:stepForwardOrCrossEdge(dir)
dir = dir or self.player.facing
local fx, fy = Collision.target(self.player.cellX, self.player.cellY, dir)
if not self.map:inBounds(fx, fy) then
return self:checkEdgeExit(dir)
end
self:scriptMove(self.player, dir, 1)
return true
end
-- IsNextTileShoreOrWater across a connection strip: pokered loads the
-- neighbor's tiles into the border, so wTileInFrontOfPlayer is the
-- connected map's tile even when the facing cell is off this map.
-- Shore/water classification still uses THIS map's tileset rules
-- (SHIP_PORT's $32 dock exception), matching the asm.
function OverworldState:facingIsShoreOrWater()
if not self:tilesetHasWater() then return false end
local fx, fy = self.player:facingCell()
if self.map:inBounds(fx, fy) then
return self.map:isWaterCell(fx, fy)
end
local dest, ts, x, y = self:connectionLanding(self.player.facing)
if not dest then return false end
local tile = Map.defCellTile(dest, ts, x, y)
if tile == nil then return false end
return self.map.waterTiles[tile] or false
end
-- tryToStopSurfing land check, including a land landing across a map
-- connection (surf off Cinnabar's east coast water back onto the coast).
function OverworldState:facingIsLandDismount()
local p = self.player
local fx, fy = p:facingCell()
if self.map:inBounds(fx, fy) then
return self.map:isWalkableCell(fx, fy)
and Collision.canMove(self.map, self.entities, p, p.facing)
end
local dest, ts, x, y = self:connectionLanding(p.facing)
if not dest then return false end
if not Map.defIsWalkableCell(dest, ts, x, y) then return false end
-- IsSpriteInFrontOfPlayer2: no current-map sprite can sit past the edge
return not Collision.occupied(self.entities, fx, fy, p)
end
-- -------------------------------------------------------------------------
-- interactions
-- -------------------------------------------------------------------------
-- HM field moves are gated by badges like the original
-- (constants.hmBadges; distinct from constants.hmMoves, the forget gate).
-- Gen 1 allows field use from fainted party members (party menu + name
-- lookup for Cut/Surf messages); do not require mon.hp > 0 here.
-- (constants.hmBadges; distinct from constants.hmMoves, the forget gate)
function OverworldState:partyKnows(moveId)
local gate = (FieldDefaults.constant(Game.data, "hmBadges") or {})[moveId]
local badge = gate and gate.badge
@@ -1162,8 +1067,10 @@ function OverworldState:partyKnows(moveId)
return nil
end
for _, mon in ipairs(Game.save.party) do
for _, mv in ipairs(mon.moves) do
if mv.id == moveId then return mon end
if mon.hp > 0 then
for _, mv in ipairs(mon.moves) do
if mv.id == moveId then return mon end
end
end
end
return nil
@@ -1664,21 +1571,16 @@ function OverworldState:trashCanSwitch(canIndex)
end
-- Bill's House PC (engine/events/hidden_events/bills_house_pc.asm
-- BillsHousePC). Check order matches pokered:
-- 1) EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING -> Eevee collection list
-- 2) EVENT_USED_CELL_SEPARATOR_ON_BILL -> teleporter monitor text
-- 3) EVENT_BILL_SAID_USE_CELL_SEPARATOR -> cell-separator cutscene
-- 4) else -> teleporter monitor text
-- Leaving after the SS Ticket (Route25ToggleBillsScript) arms (1).
-- BillsHousePC): once Bill-the-Pokémon has climbed into the machine
-- (EVENT_BILL_SAID_USE_CELL_SEPARATOR), running the PC plays the cell
-- separator's SFX sequence, sets EVENT_USED_CELL_SEPARATOR_ON_BILL and
-- Bill steps back out of the machine human again
-- (BillsHouseBillExitsMachineScript / CleanupScript set EVENT_MET_BILL).
function OverworldState:billsHousePC()
local t = Game.data.text
local flags = Game.save.flags
if flags.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING then
self:billsHousePokemonList()
return
end
if flags.EVENT_USED_CELL_SEPARATOR_ON_BILL
or not flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR then
if not (flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR
and not flags.EVENT_USED_CELL_SEPARATOR_ON_BILL) then
Game.stack:push(TextBox.new(Game, t._BillsHouseMonitorText
or "TELEPORTER is\ndisplayed on the\nPC monitor."))
return
@@ -1702,40 +1604,9 @@ function OverworldState:billsHousePC()
end))
end
-- BillsHousePokemonList: EEVEE / FLAREON / JOLTEON / VAPOREON + CANCEL;
-- picking one runs DisplayPokedex (DexEntryMenu) and returns to the list.
function OverworldState:billsHousePokemonList()
local t = Game.data.text
local Menu = require("src.ui.Menu")
local function openList()
local species = { "EEVEE", "FLAREON", "JOLTEON", "VAPOREON" }
local items = {}
for _, id in ipairs(species) do
local def = Game.data.pokemon[id]
table.insert(items, {
label = (def and def.name) or id,
keepOpen = true,
onSelect = function()
local dex = Game.save.pokedex
if dex then dex.seen[id] = true end
Screens.push(Game, "DexEntryMenu", id)
end,
})
end
table.insert(items, { label = "CANCEL" })
-- TextBoxBorder b=10,c=9 at (0,0) -> total tw=11, th=12
Game.stack:push(Menu.new(Game, items,
{ tx = 0, ty = 0, tw = 11, th = 12 }))
end
Game.stack:push(TextBox.new(Game, t._BillsHousePokemonListText1
or "BILL's favorite\nPOKéMON list!", openList))
end
-- BillsHouseBillExitsMachineScript: human Bill appears inside the machine
-- at (1,2) and walks out to his spot at (4,4); the map music resumes and
-- EVENT_MET_BILL / EVENT_MET_BILL_2 arm the SS-Ticket dialogue. The Eevee
-- PC list arms later, on the first Route 25 load after the ticket
-- (EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING).
-- EVENT_MET_BILL / EVENT_MET_BILL_2 arm the SS-Ticket dialogue.
function OverworldState:billsHouseBillExits()
local Commands = require("src.script.Commands")
local ctx = { game = Game, save = Game.save, overworld = self }
@@ -1827,15 +1698,14 @@ function OverworldState:trySurf(fx, fy)
Game.stack:push(TextBox.new(Game, text, function()
-- start_sub_menus.asm .surf: UseItem returns (mount + text done),
-- then GBPalWhiteOutWithDelay3 blinks before the simulated forward
-- press steps onto the water (or across a connection strip, like
-- Cinnabar's east coast onto Route 20)
-- press steps onto the water
local Transition = require("src.render.Transition")
if Transition.whiteFlash then
Game.stack:push(Transition.whiteFlash(Game, nil, function()
self:stepForwardOrCrossEdge(p.facing)
self:scriptMove(p, p.facing, 1)
end))
else
self:stepForwardOrCrossEdge(p.facing)
self:scriptMove(p, p.facing, 1)
end
end))
end
@@ -1917,7 +1787,7 @@ end
-- to SurfingAttemptFailed (_NoSurfingHereText) if it isn't water. This is
-- a side-effect-free check that reports which text/flow the caller should
-- use; the actual mount happens in trySurf on "ok". Returns:
-- "no_badge" -> SOULBADGE missing / no SURF mon (_NewBadgeRequiredText)
-- "no_badge" -> SOULBADGE missing / no live SURF mon (_NewBadgeRequiredText)
-- "forced_bike" -> on the Cycling Road (_CyclingIsFunText)
-- "current" -> Seafoam B4F stairs before the boulders (_CurrentTooFastText)
-- "dismount" -> already surfing, facing dry land; caller steps forward
@@ -1935,20 +1805,20 @@ function OverworldState:useSurfFieldMove()
-- until both EVENT_SEAFOAM4_BOULDER*_DOWN_HOLE events are set.
if Game.save.forcedBike then return "forced_bike" end
if self:surfBlockedHere() then return "current" end
local fx, fy = p:facingCell()
if p.surfing then
-- ItemUseSurfboard .tryToStopSurfing: blocked by a sprite in front
-- (IsSpriteInFrontOfPlayer2), a water tile-pair collision, or a
-- facing tile that isn't in the tileset's land-passable list;
-- otherwise the player walks forward off the water. Facing a land
-- cell across a map connection (Cinnabar east coast) counts too --
-- pokered reads that landing from the connection strip.
if self:facingIsLandDismount() then
-- otherwise the player walks forward off the water.
if self.map:inBounds(fx, fy) and self.map:isWalkableCell(fx, fy)
and Collision.canMove(self.map, self.entities, p, p.facing) then
return "dismount"
end
return "no_place"
end
-- IsNextTileShoreOrWater, including connection-strip water (issue #125)
if not self:facingIsShoreOrWater() then
if not self.map:inBounds(fx, fy)
or not self.map:isWaterCell(fx, fy) or not self:tilesetHasWater() then
return "no_water"
end
return "ok"
@@ -1960,7 +1830,7 @@ end
-- (and .loop back to the submenu) if it isn't cuttable. Side-effect-free
-- check mirroring useSurfFieldMove; tryCut does the actual cut on "ok".
-- Returns:
-- "no_badge" -> CASCADEBADGE missing / no CUT mon (_NewBadgeRequiredText)
-- "no_badge" -> CASCADEBADGE missing / no live CUT mon (_NewBadgeRequiredText)
-- "nothing" -> not facing a cuttable tree (_NothingToCutText)
-- "ok" -> facing a cuttable tree; caller may call tryCut(fx, fy)
function OverworldState:useCutFieldMove()
@@ -2381,8 +2251,6 @@ end
-- Badges/items awarded after specific battles (data/scripts/victories.lua).
-- `deactivate` retires unfought gym/dojo trainers the way the originals'
-- SetEvent / SetEventRange do after the leader victory.
-- `hide` is { { mapId, objName }, ... } — HideObject on those toggles
-- (e.g. Brock victory clears PEWTERCITY_YOUNGSTER / ROUTE22_RIVAL1).
function OverworldState:checkVictoryRewards(trainerClass, partyIndex)
local victories = require("data.scripts.victories")
local reward = victories[trainerClass .. "#" .. tostring(partyIndex or 1)]
@@ -2396,13 +2264,6 @@ function OverworldState:checkVictoryRewards(trainerClass, partyIndex)
Game.save.flags[flag] = true
end
end
if reward.hide then
local Commands = require("src.script.Commands")
local ctx = { game = Game, save = Game.save, overworld = self }
for _, entry in ipairs(reward.hide) do
Commands.hide_object(ctx, entry[1], entry[2])
end
end
local lines = {}
if reward.badge then
Game.save.inventory[reward.badge] = 1
@@ -2699,11 +2560,8 @@ function OverworldState:onStepComplete()
elseif entry then
-- still standing on the warp we arrived through; do not re-trigger it
else
-- CheckWarpsNoCollision: door/warp tiles fire immediately; otherwise
-- ExtraWarpCheck must pass AND either a d-pad is held or BIT_FORCED_WARP
-- is set (Seafoam B3F currents — home/overworld.asm).
local w = Warp.onArrive(self.map, p.cellX, p.cellY)
if not w and (self:dirHeld() or self.forcedWarp) then
if not w and self:dirHeld() then
w = Warp.onCollision(self.map, Game.data.field.warpCarpets,
p.cellX, p.cellY, p.facing)
end
@@ -2784,11 +2642,9 @@ function OverworldState:runSpinnerMoves(moves, i)
local mv = moves[i]
if not mv then
self.player.spinning = false
-- Scripted steps skip onStepComplete while they run; once the RLE
-- finishes, re-enter the normal landing pipeline so chained spinners,
-- Seafoam currents, and CheckWarpsNoCollision (incl. BIT_FORCED_WARP)
-- see the tile we stopped on — same as pokered after simulated joypad.
self:onStepComplete()
if not self:checkSpinner() and self.player.surfing then
self:checkSeafoamCurrent()
end
return
end
self.player.spinning = true -- spin the sprite while sliding
@@ -2961,9 +2817,6 @@ function OverworldState:checkSeafoamCurrent()
if sf.forcedExit and p.surfing and not allSet(sf.forcedExit.activeUntilEvents) then
for _, c in ipairs(sf.forcedExit.coords) do
if p.cellX == c.x and p.cellY == c.y then
-- SeafoamIslandsB4FDefaultScript: res BIT_FORCED_WARP before the
-- push so the B3F stair warps underfoot cannot bounce you back.
self.forcedWarp = false
require("src.core.Sound").play(Game.data, "Collision")
self:scriptMove(p, "up", c.y == 17 and 2 or 1)
return true
@@ -2985,12 +2838,6 @@ function OverworldState:checkSeafoamCurrent()
end
for _, c in ipairs(active) do
if p.cellX == c.x and p.cellY == c.y then
-- SeafoamIslandsB3F.asm sets BIT_FORCED_WARP before DecodeRLEList so
-- the south-edge water stairs auto-warp when the current ends.
if FieldDefaults.fieldValue(Game.data, "seafoam", self.map.id,
"setsForcedWarp") then
self.forcedWarp = true
end
self:runSpinnerMoves(c.moves, 1)
return true
end
@@ -3199,20 +3046,16 @@ end
-- Warp to the last heal point (blackout, ESCAPE ROPE, DIG/TELEPORT).
-- The heal point is usually an interior, so LAST_MAP exits are re-pointed
-- at its remembered town door rather than wherever the player left from.
--
-- opts.arrive = "teleport" for Dig/Teleport/Escape Rope (LeaveMapAnim /
-- EnterMapAnim). Blackouts omit it: pret HandleBlackOut only
-- GBFadeOutToBlack + PrepareForSpecialWarp + SpecialEnterMap, and never
-- sets BIT_FLY_WARP / BIT_DUNGEON_WARP, so EnterMap never runs EnterMapAnim.
function OverworldState:warpToHealPoint(onDone, opts)
function OverworldState:warpToHealPoint(onDone)
local heal = self:healPoint()
self.player.surfing = false
-- HandleFlyWarpOrDungeonWarp + DisplayPlayerBlackedOutText both clear
-- BIT_ALWAYS_ON_BIKE (home/overworld.asm / home/text_script.asm)
Game.save.forcedBike = nil
if opts and opts.arrive == "teleport" then
self.arriveWarp = "teleport"
end
-- rematerializing plays the teleport-in poof (EnterMapAnim in
-- engine/overworld/player_animations.asm: SFX_TELEPORT_ENTER_1, then
-- ENTER_2 after the spin-down); blackouts take this path too
self.arriveWarp = "teleport"
self:startWarpTo(heal.map, heal.x, heal.y, "down", onDone)
if heal.outdoor then
self:rememberOutdoor(heal.outdoor.id, heal.outdoor.x, heal.outdoor.y)
@@ -3250,9 +3093,9 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts)
-- one-step justWarped guard, which only skipped the very next frame's
-- check and so let a mon walked back onto the pad re-trigger it.
self.warpEntryCell = { x = x, y = y }
-- Fly/Teleport/Dig/Escape-Rope landings poof the player back in
-- (player_animations.asm EnterMapAnim). Blackouts and ordinary
-- door warps never take this branch.
-- Fly/Teleport/Dig/Escape-Rope/blackout landings poof the player
-- back in (player_animations.asm EnterMapAnim); ordinary door
-- warps never take this branch
if arriveWarp == "fly" then
require("src.core.Sound").play(Game.data, "Fly")
elseif arriveWarp == "teleport" then
-7
View File
@@ -64,13 +64,6 @@ return function(game)
check(warped, "YES warps to REDS_HOUSE_2F")
check(ow.player.cellX == 3 and ow.player.cellY == 6,
"landed on the bedroom spawn (3,6)")
check(ow.lastOutdoor and ow.lastOutdoor.id == "PALLET_TOWN",
"PC home warp retargets lastOutdoor to Pallet (#103)")
local lead = game.save.party and game.save.party[1]
if lead and lead.stats then
check(lead.hp == lead.stats.hp and lead.status == nil,
"PC home warp heals the party")
end
U.log(pass and "RESULT: ALL PASS" or "RESULT: SEE FAILURES ABOVE")
end
+15 -2
View File
@@ -4165,8 +4165,21 @@ function ops.fieldMove(s, where)
say(("fieldMove %s: nobody in the party knows it"):format(move))
return false
end
-- Field moves work from fainted users in Gen 1 (PartyMenu + partyKnows
-- no longer gate on hp > 0). No Poké Center detour needed here.
-- The knower must also be CONSCIOUS: the party submenu hides field
-- moves on a fainted mon (vanilla -- partyKnows requires hp > 0), so
-- the menu walk below finds only STATS/SWITCH and fails. Route 23's
-- mandatory surf strip is where this bit: WARTORTLE arrived dead from
-- the Viridian Gym attrition, "no SURF entry" x3, the water was never
-- crossed, and the whole league skip-cascaded with all eight badges
-- in the bag. Heal, fail this attempt loudly, and let the caller's
-- retry run the step again with a live user.
local knower = (G.save.party or {})[slot]
if knower and (knower.hp or 0) <= 0 then
say(("fieldMove %s: its only user has fainted -- visiting a centre "
.. "before retrying"):format(move))
visitPokeCenter(where)
return false
end
-- CUT acts on the tile the player is FACING (OverworldState:tryCut), and
-- a `goto` leaves us facing whatever direction we last stepped. Without
-- turning to the tree first the move fires into empty ground, the game
+1 -9
View File
@@ -52,9 +52,6 @@ stub.graphics = {
push = noop, pop = noop, translate = noop, scale = noop,
rotate = noop, origin = noop, setShader = noop, setScissor = noop,
getDimensions = function() return 640, 576 end,
-- dpi=1 desktop default; issue #87 tests override these for Android density
getPixelDimensions = function() return 640, 576 end,
getDPIScale = function() return 1 end,
}
stub.math = {
@@ -130,12 +127,7 @@ stub.sound = {
stub.keyboard = { isDown = function() return false end }
stub.mouse = {
getPosition = function() return 0, 0 end,
isCursorSupported = function() return false end,
getSystemCursor = function(name) return name end,
setCursor = function() end,
}
stub.mouse = { getPosition = function() return 0, 0 end }
stub.timer = { getTime = function() return 0 end }
-5
View File
@@ -320,9 +320,6 @@ check(vanillaSave.player.map == "REDS_HOUSE_2F" and vanillaSave.player.x == 3
"the seeded boot config reproduces the NewGameWarp bedroom spawn")
check(vanillaSave.player.name == "RED" and vanillaSave.player.rival == "BLUE"
and vanillaSave.money == 3000, "the seeded boot config reproduces the Red start")
-- issue #109: vanilla New Game puts 1 Potion in the player's item PC
check(vanillaSave.pcItems and vanillaSave.pcItems.POTION == 1,
"new game seeds 1 Potion in the player's PC")
-- the heal point is deliberately NOT the spawn: wLastBlackoutMap is
-- zero-filled at new game and PALLET_TOWN is map 0
check(vanillaSave.lastHeal.map == "PALLET_TOWN" and vanillaSave.lastHeal.x == 5
@@ -332,8 +329,6 @@ local bareSave = SaveData.newGame()
check(bareSave.player.map == "REDS_HOUSE_2F" and bareSave.money == 3000
and bareSave.lastHeal.map == "PALLET_TOWN",
"newGame without a boot config is unchanged")
check(bareSave.pcItems and bareSave.pcItems.POTION == 1,
"newGame without a boot config still seeds the PC Potion")
local bootData, bootLoader = withMod("total_conversion", [[
return function(mod)
-91
View File
@@ -128,8 +128,6 @@ love.graphics = {
translate = noop, scale = noop, rotate = noop, origin = noop,
setScissor = noop, getColor = function() return 1, 1, 1, 1 end,
getDimensions = function() return 640, 576 end,
getPixelDimensions = function() return 640, 576 end,
getDPIScale = function() return 1 end,
}
-- Fresh copies of the modules that cache a compiled shader or a page set
@@ -620,33 +618,6 @@ check(PaletteFX.pal({ palettes = nil }, "ROUTE") == gbc.palettes.ROUTE,
"RED++ still has ROUTE (aliased from VIRIDIAN)")
check(PaletteFX.effectiveColors(gbc.palettes.MEWMON) == gbc.palettes.MEWMON,
"RED++ passes zone colors through like GBC")
-- issue #128: RED++'s gbc pack is Red-derived; Blue must keep ROM LOGO1
-- (and the Blue-only SLOTS* rows) so the title ribbon is blue, not red
do
local GameVersion = require("src.core.GameVersion")
local prevVer = GameVersion.get()
local blueLogo1 = {
{ 255, 239, 255 }, { 247, 247, 140 }, { 173, 0, 33 }, { 115, 156, 239 },
}
local blueSlots2 = {
{ 255, 239, 255 }, { 255, 255, 140 }, { 132, 156, 239 }, { 25, 16, 16 },
}
local rom = { palettes = { palettes = {
LOGO1 = blueLogo1, SLOTS2 = blueSlots2, ROUTE = { { 1, 2, 3 }, { 0, 0, 0 },
{ 0, 0, 0 }, { 0, 0, 0 } },
} } }
GameVersion.set("blue")
check(PaletteFX.pal(rom, "LOGO1") == blueLogo1,
"Blue + RED++ prefers ROM LOGO1 over the Red gbc pack")
check(PaletteFX.pal(rom, "SLOTS2") == blueSlots2,
"Blue + RED++ prefers ROM SLOTS2 over the Red gbc pack")
check(PaletteFX.pal(rom, "ROUTE") == gbc.palettes.ROUTE,
"Blue + RED++ still uses the gbc pack for shared names")
GameVersion.set("red")
check(PaletteFX.pal(rom, "LOGO1") == gbc.palettes.LOGO1,
"Red + RED++ keeps the gbc-pack LOGO1 (title stays red)")
GameVersion.set(prevVer)
end
PaletteFX.setMode(prevMode)
-- ------- the transitions registry
@@ -676,68 +647,6 @@ check(Transition.new({ data = retimed }).frames == 30,
check(Transition.new({ data = { transitions = {} } }).frames == 12,
"an unregistered id falls back to the built-in 12 frames")
-- issue #121: with the survey-zoom world pass active, the warp fade must
-- darken the full window composite (via Renderer.worldFadeAlpha), not just
-- paint a 160x144 rect on the UI letterbox.
do
local rects = {}
local color = { 1, 1, 1, 1 }
local savedRect, savedColor = love.graphics.rectangle, love.graphics.setColor
love.graphics.rectangle = function(mode, x, y, w, h)
rects[#rects + 1] = {
mode = mode, x = x, y = y, w = w, h = h,
a = color[4], r = color[1],
}
end
love.graphics.setColor = function(r, g, b, a)
color[1], color[2], color[3], color[4] = r, g, b, a or 1
end
Renderer:init()
local fade = Transition.new({ renderer = Renderer, stack = { pop = noop } })
fade.t = 6 -- mid fade-out (12 frames)
fade.phase = "out"
Renderer:beginFrame(true)
Renderer:beginWorldPass()
Renderer:endWorldPass()
check(Renderer.worldActive == true, "world pass stays marked active until endFrame")
fade:draw()
check(Renderer.worldFadeAlpha == 0.5,
"warp fade hands mid-out alpha to the world composite overlay")
check(#rects == 0,
"warp fade does not paint the 160x144 UI letterbox while the world pass is up")
resetLog()
rects = {}
Renderer:endFrame(nil, fullWorldZones())
local fadeRect
for _, r in ipairs(rects) do
-- endFrame's letterbox clear is also a full-window black fill (a == 1);
-- the warp overlay is the half-alpha one Transition requested
if r.mode == "fill" and r.x == 0 and r.y == 0
and r.w == 640 and r.h == 576 and r.r == 0 and r.a == 0.5 then
fadeRect = r
end
end
check(fadeRect ~= nil,
"endFrame paints the warp fade over the full window at the fade alpha")
check(Renderer.worldActive == false, "endFrame clears worldActive")
-- without a world pass (opaque UI states), keep the classic UI rect
Renderer:beginFrame(false)
fade.t = 6
fade.phase = "out"
rects = {}
fade:draw()
check(Renderer.worldFadeAlpha == nil,
"no world pass: warp fade does not set a world overlay")
check(#rects == 1 and rects[1].w == 160 and rects[1].h == 144,
"no world pass: warp fade still fills the 160x144 UI canvas")
love.graphics.rectangle, love.graphics.setColor = savedRect, savedColor
end
-- ------- the transition.style hook
local stack = { pop = function() end }
-61
View File
@@ -215,41 +215,6 @@ do
SaveData.runMigrations(tagged)
check(tagged.version == "blue", "an existing version tag is left untouched")
-- #131: format-3 save that beat the Game Corner poster grunt before
-- hide_object was wired -- defeatedTrainers only, no objectToggles hide
local stuckGrunt = {
meta = { format = 3, mods = {} },
defeatedTrainers = { GAME_CORNER_obj_11 = true },
objectToggles = {},
flags = {},
player = {},
}
SaveData.runMigrations(stuckGrunt)
check(stuckGrunt.objectToggles.GAME_CORNER
and stuckGrunt.objectToggles.GAME_CORNER.GAMECORNER_ROCKET == false,
"Game Corner rocket hide backfilled from defeatedTrainers")
check(stuckGrunt.meta.format == Version.saveFormat,
"format-3 hideout migration stamps to current")
-- already-hidden stays hidden; undefeated grunt is left alone
local alreadyHidden = {
meta = { format = 3, mods = {} },
defeatedTrainers = { GAME_CORNER_obj_11 = true },
objectToggles = { GAME_CORNER = { GAMECORNER_ROCKET = false } },
player = {},
}
SaveData.runMigrations(alreadyHidden)
check(alreadyHidden.objectToggles.GAME_CORNER.GAMECORNER_ROCKET == false,
"already-hidden rocket toggle is left false")
local neverFought = {
meta = { format = 3, mods = {} },
defeatedTrainers = {},
objectToggles = {},
player = {},
}
SaveData.runMigrations(neverFought)
check(not neverFought.objectToggles.GAME_CORNER,
"undefeated Game Corner rocket is not auto-hidden")
love.filesystem = realFS
end
@@ -571,32 +536,6 @@ do
"newGame starts an empty modData")
check(plain.meta.format == Version.saveFormat and #plain.meta.mods == 0,
"newGame stamps a vanilla meta")
check(plain.pcItems and plain.pcItems.POTION == 1,
"unhooked newGame seeds 1 Potion in pcItems (issue #109)")
end
-- issue #109: loading a pre-fix save that never had pcItems must not
-- invent a free Potion (player may already have withdrawn/tossed it, or
-- the empty PC is intentional). Seeding is New Game only.
do
local files = {}
love.filesystem = memfs(files)
local legacy = {
meta = { format = Version.saveFormat, mods = {} },
player = { map = "PALLET_TOWN", x = 5, y = 6, facing = "down",
name = "RED", rival = "BLUE", id = 1 },
flags = {}, inventory = {}, party = {}, box = {}, money = 3000,
defeatedTrainers = {}, pokedex = { seen = {}, owned = {} },
lastHeal = { map = "PALLET_TOWN", x = 5, y = 6 },
options = SaveData.defaultOptions(),
}
check(legacy.pcItems == nil, "fixture omits pcItems on purpose")
check(SaveData.save(legacy), "legacy save without pcItems writes")
local loaded = SaveData.load()
check(loaded ~= nil, "legacy save loads")
check(loaded.pcItems == nil or loaded.pcItems.POTION == nil,
"load does not invent a PC Potion for existing saves")
love.filesystem = realFS
end
-- a throwing mod migration is skipped, never fatal: an uncaught error here
-126
View File
@@ -475,132 +475,6 @@ check(title.version and title.version.path
== "assets/generated/title/red_version.png",
"the shipped version ribbon loads")
-- issue #128: title SGB zones must resolve Blue's LOGO1 (blue ribbon),
-- not Red's, when the ROM pack carries Blue SuperPalettes. Color 0 is
-- forced to pure white so ROM's {255,239,255} does not paint a pink band
-- against GBC-pack LOGO2/MEWMON whites.
do
local GameVersion = require("src.core.GameVersion")
local PaletteFX = require("src.render.PaletteFX")
local prevVer, prevMode = GameVersion.get(), PaletteFX.mode
local blueLogo1 = {
{ 255, 239, 255 }, { 247, 247, 140 }, { 173, 0, 33 }, { 115, 156, 239 },
}
local mewmon = {
{ 255, 239, 255 }, { 247, 181, 140 }, { 132, 115, 156 }, { 25, 16, 16 },
}
local logo2 = {
{ 255, 239, 255 }, { 247, 247, 140 }, { 148, 148, 197 }, { 58, 58, 132 },
}
local game = { data = { palettes = { palettes = {
LOGO1 = blueLogo1, LOGO2 = logo2, MEWMON = mewmon,
} } } }
PaletteFX.setMode("redpp")
GameVersion.set("blue")
local zones = TitleState.sgbPalettes(title, game)
local ribbon = zones and zones[2] and zones[2].colors
check(ribbon and ribbon[4][1] == 115 and ribbon[4][3] == 239,
"Blue title ribbon zone keeps ROM LOGO1 blue ink under RED++")
check(ribbon and ribbon[1][1] == 255 and ribbon[1][2] == 255
and ribbon[1][3] == 255,
"Blue title ribbon white is pure (no pink SGB band)")
GameVersion.set("red")
zones = TitleState.sgbPalettes(title, game)
local gbcLogo1 = PaletteFX.gbcPack().palettes.LOGO1
ribbon = zones and zones[2] and zones[2].colors
check(ribbon and ribbon[4][1] == gbcLogo1[4][1]
and ribbon[4][2] == gbcLogo1[4][2]
and ribbon[4][3] == gbcLogo1[4][3],
"Red title under RED++ keeps gbc-pack LOGO1 ink even if ROM has Blue's")
GameVersion.set(prevVer)
PaletteFX.setMode(prevMode)
end
-- issue #133: title menu / continue overlays must not inherit LOGO2/LOGO1
-- (blue/red UI ink). A trailing trueColor zone covers the overlay box.
do
local logo2 = {
{ 255, 255, 255 }, { 230, 197, 0 }, { 148, 156, 148 }, { 41, 99, 181 },
}
local logo1 = {
{ 255, 255, 255 }, { 247, 247, 140 }, { 140, 189, 82 }, { 173, 0, 33 },
}
local mewmon = {
{ 255, 239, 255 }, { 247, 181, 140 }, { 132, 115, 156 }, { 25, 16, 16 },
}
local game = {
data = { palettes = { palettes = {
LOGO1 = logo1, LOGO2 = logo2, MEWMON = mewmon,
} } },
stack = newStack(),
}
local bare = TitleState.sgbPalettes(title, game)
check(bare and #bare == 3 and bare[1].colors == logo2,
"bare title keeps three LOGO/MEWMON zones")
check(not bare[4], "bare title has no overlay trueColor zone")
game.stack:push(title)
-- openMenu needs SaveData/hasSave; stub a no-save menu via the same stamp
local Menu = require("src.ui.Menu")
local menu = Menu.new(game, { { label = "NEW GAME" } },
{ tx = 0, ty = 0, tw = 13, th = 4 })
menu.titleUiBox = { 0, 0, 12, 3 }
game.stack:push(menu)
local withMenu = TitleState.sgbPalettes(title, game)
check(withMenu and #withMenu == 4 and withMenu[4].colors == false,
"title menu adds a trueColor overlay zone")
check(withMenu[4].x == 0 and withMenu[4].y == 0
and withMenu[4].w == 13 * 8 and withMenu[4].h == 4 * 8,
"menu overlay covers the CONTINUE/NEW GAME box")
game.stack:pop()
game.stack:push({ titleUiBox = { 4, 7, 19, 16 } })
local withCont = TitleState.sgbPalettes(title, game)
check(withCont and #withCont == 4 and withCont[4].colors == false,
"continue-info overlay adds a trueColor zone")
check(withCont[4].x == 4 * 8 and withCont[4].y == 7 * 8
and withCont[4].w == 16 * 8 and withCont[4].h == 10 * 8,
"continue overlay matches DisplayContinueGameInfo's box")
-- openMenu itself must stamp titleUiBox on the real Menu it pushes
while game.stack:top() do game.stack:pop() end
title.game = game
title:openMenu()
local opened = game.stack:top()
check(opened and opened.titleUiBox
and opened.titleUiBox[1] == 0 and opened.titleUiBox[3] == 12,
"openMenu stamps titleUiBox on the pushed Menu")
end
-- Options / mod manager opened from the title must not inherit LOGO1
-- (third options box = rows 8-9 would otherwise tint pink).
do
local OptionsMenu = require("src.ui.OptionsMenu")
local ManagerState = require("src.mods.ManagerState")
local PaletteFX = require("src.render.PaletteFX")
local mewmon = {
{ 255, 255, 255 }, { 239, 156, 107 }, { 115, 33, 165 }, { 0, 0, 0 },
}
local game = { data = { palettes = { palettes = { MEWMON = mewmon } } },
save = { options = {} } }
local optZones = OptionsMenu.sgbPalettes(OptionsMenu, game)
check(optZones and #optZones == 1 and optZones[1].colors == mewmon,
"OptionsMenu owns a whole-screen MEWMON zone")
local modZones = ManagerState.sgbPalettes(ManagerState, game)
check(modZones and #modZones == 1 and modZones[1].colors == mewmon,
"ManagerState owns a whole-screen MEWMON zone")
-- stack walk: with Options on top of Title, Game would pick Options
local titleZones = TitleState.sgbPalettes(title, {
data = { palettes = { palettes = {
LOGO1 = { { 255, 239, 255 }, { 1, 2, 3 }, { 4, 5, 6 }, { 115, 156, 239 } },
LOGO2 = mewmon, MEWMON = mewmon,
} } },
})
check(titleZones and titleZones[2].colors[1][2] == 255,
"title LOGO1 sanitize still pure-white with pink ROM input")
check(PaletteFX.wholeNamed, "PaletteFX.wholeNamed still available for menus")
end
local OakSpeech = require("src.ui.OakSpeech")
local ogame = { data = {
field = { oakSpeech = { music = "X_Song", demoSpecies = "PIKACHU" } },
-9
View File
@@ -338,15 +338,6 @@ do
end
check(pewterNpc and ow:trainerDefeated(pewterNpc),
"unfought Pewter gym trainer is defeated after badge")
-- PewterGym.asm .gymVictory HideObject TOGGLE_GYM_GUY /
-- TOGGLE_ROUTE_22_RIVAL_1 (persists via objectToggles)
check(Game.save.objectToggles
and Game.save.objectToggles.PEWTER_CITY
and Game.save.objectToggles.PEWTER_CITY.PEWTERCITY_YOUNGSTER == false,
"Brock victory hides PEWTERCITY_YOUNGSTER")
check(Game.save.objectToggles.ROUTE_22
and Game.save.objectToggles.ROUTE_22.ROUTE22_RIVAL1 == false,
"Brock victory hides ROUTE22_RIVAL1")
while Game.stack:top() do Game.stack:pop() end
Game.save = SaveData.newGame()
+1 -49
View File
@@ -21,17 +21,12 @@ check(hof and type(hof.onEnter) == "function", "HALL_OF_FAME.onEnter is a functi
local champ = init.get("CHAMPIONS_ROOM")
check(champ ~= nil, "CHAMPIONS_ROOM map script registered")
check(champ and type(champ.onEnter) == "function",
"CHAMPIONS_ROOM.onEnter is a function (forced rival entrance)")
local rows = champ and champ.talk and champ.talk.TEXT_CHAMPIONSROOM_RIVAL
check(type(rows) == "table", "CHAMPIONS_ROOM.talk.TEXT_CHAMPIONSROOM_RIVAL exists")
-- (2) the rival cutscene rows contain the pokered beats in order
rows = rows or {}
local preds = {
{ "play_music Music_Cities1 (OakArrives / Music_Cities1AlternateTempo)",
function(r) return r[1] == "play_music" and r[2] == "Music_Cities1"
and type(r[3]) == "table" and r[3].keep == true end },
{ "show_object CHAMPIONSROOM_OAK",
function(r) return r[1] == "show_object" and r[3] == "CHAMPIONSROOM_OAK" end },
{ "move_npc(2,'up',5) OakEntranceAfterVictoryMovement",
@@ -87,13 +82,7 @@ check(champToHof, "CHAMPIONS_ROOM has a warp up into HALL_OF_FAME")
-- (5) functional: HALL_OF_FAME.onEnter consumes the one-shot marker and
-- queues (does not directly run) the room cutscene.
local queued
local fakeOw = {
map = { def = { signs = {} }, widthCells = 10, signAt = {} },
queueScript = function(self, script, extra)
queued = script
self.pendingScript = { script = script }
end,
}
local fakeOw = { queueScript = function(self, script, extra) queued = script; self.pendingScript = { script = script } end }
local fakeGame = { save = { pendingHallOfFame = true } }
hof.onEnter(fakeGame, fakeOw)
check(queued ~= nil, "HALL_OF_FAME.onEnter queues a cutscene script when marker set")
@@ -113,41 +102,4 @@ fakeGame.save.pendingHallOfFame = false
hof.onEnter(fakeGame, fakeOw)
check(queued == nil, "HALL_OF_FAME.onEnter does not replay once the marker is consumed")
-- (6) Champions Room forced entrance (ChampionsRoomPlayerEntersScript):
-- from Lance (y=7), queue RivalEntrance walk then the rival battle script.
local champQueued = {}
local champOw = {
player = { cellX = 3, cellY = 7 },
npcs = { { def = { name = "CHAMPIONSROOM_RIVAL" } } },
queueScript = function(_, script, extra)
champQueued[#champQueued + 1] = { script = script, extra = extra }
end,
}
local champGame = { save = { flags = {} } }
champ.onEnter(champGame, champOw)
eq(#champQueued, 2, "CHAMPIONS_ROOM.onEnter queues entrance walk + rival script")
local walk = champQueued[1] and champQueued[1].script
check(walk and walk[1][1] == "move_player" and walk[1][2] == "up" and walk[1][3] == 1
and walk[2][1] == "move_player" and walk[2][2] == "right" and walk[2][3] == 1
and walk[3][1] == "move_player" and walk[3][2] == "up" and walk[3][3] == 3,
"entrance walk is RivalEntrance_RLEMovement (up 1, right 1, up 3)")
check(champQueued[2] and champQueued[2].script == rows,
"second queue is TEXT_CHAMPIONSROOM_RIVAL script rows")
check(champQueued[2] and champQueued[2].extra
and champQueued[2].extra.npc == champOw.npcs[1],
"rival script receives the CHAMPIONSROOM_RIVAL npc")
-- already beaten this run: no forced entrance
champQueued = {}
champGame.save.flags.EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN = true
champ.onEnter(champGame, champOw)
eq(#champQueued, 0, "CHAMPIONS_ROOM.onEnter idle after champion beaten this run")
-- Hall of Fame return (y=0) must not re-trigger even if the run flag is clear
champQueued = {}
champGame.save.flags.EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN = nil
champOw.player.cellY = 0
champ.onEnter(champGame, champOw)
eq(#champQueued, 0, "CHAMPIONS_ROOM.onEnter ignores Hall of Fame return landing")
S.finish()
+6 -64
View File
@@ -55,27 +55,9 @@ local function newStack()
return stack, items
end
-- snapshot / restore car warps so tests that seed or ride do not leak
-- across cases (Silph's ROM default is UNUSED_MAP_ED)
local function snapshotWarps(mapId)
local snap = {}
for i, w in ipairs(Data.maps[mapId].warps) do
snap[i] = { destMap = w.destMap, destWarp = w.destWarp }
end
return snap
end
local function restoreWarps(mapId, snap)
for i, w in ipairs(Data.maps[mapId].warps) do
w.destMap, w.destWarp = snap[i].destMap, snap[i].destWarp
end
end
-- drives one elevator map's onEnter and returns the pushed state (either a
-- ListMenu or, for the keyGated Rocket Hideout without the key, a TextBox).
-- fromMapId is the floor the player entered from (OverworldState:setMap
-- passes it), used to seed a cancel-safe walk-out destination.
local function openElevator(mapId, inventory, fromMapId)
-- ListMenu or, for the keyGated Rocket Hideout without the key, a TextBox)
local function openElevator(mapId, inventory)
local script = mapScripts.get(mapId)
check(script ~= nil, mapId .. " script registered")
check(script and script.onEnter ~= nil, mapId .. " has onEnter")
@@ -116,7 +98,7 @@ local function openElevator(mapId, inventory, fromMapId)
stack = stack,
}
sfxCalls = {}
script.onEnter(game, ow, fromMapId)
script.onEnter(game, ow)
return items[#items], warpCalls, stack, ow
end
@@ -148,14 +130,7 @@ end
-- Silph Co elevator: 11 floors, the double-digit sort/label regression
-- ===================================================================
do
local silphSnap = snapshotWarps("SILPH_CO_ELEVATOR")
-- ROM default (UNUSED_MAP_ED) must still be what we start from so the
-- cancel-then-exit regression below is real
eq(silphSnap[1].destMap, "UNUSED_MAP_ED",
"Silph Co ROM car warps still default to UNUSED_MAP_ED")
local menu, warpCalls, stack, ow =
openElevator("SILPH_CO_ELEVATOR", nil, "SILPH_CO_5F")
local menu, warpCalls, stack, ow = openElevator("SILPH_CO_ELEVATOR")
check(menu ~= nil and getmetatable(menu) == ListMenu, "SILPH_CO_ELEVATOR opens a ListMenu")
if menu then
eq(#menu.items, 11, "Silph Co elevator lists all 11 floors")
@@ -168,32 +143,12 @@ do
check(menu.items[1].label ~= "SILPH CO 1F" and not menu.items[1].label:find("SILPH"),
"Silph Co floor label is the short token, not the full map id")
-- onEnter seeds the car's exit to the floor we came from so a B-cancel
-- cannot leave UNUSED_MAP_ED in place (#123 hard crash on walk-out)
eq(ow.map.def.warps[1].destMap, "SILPH_CO_5F",
"Silph onEnter seeds exit warps to the entry floor")
check(Data.maps[ow.map.def.warps[1].destMap] ~= nil,
"seeded Silph exit map exists in Data.maps")
-- Cancel: pokered's DisplayElevatorFloorMenu does `ret c` on B --
-- no warp at all.
clear(warpCalls)
menu.onCancel()
eq(#warpCalls, 0, "Cancel does not warp (bare ret c, no floors[1] fallback)")
-- #123: after B-cancel, walking out of the car must resolve without
-- asserting on the ROM placeholder UNUSED_MAP_ED
clear(warpCalls)
local okExit, errExit = pcall(function()
ow:takeWarp(ow.map.def.warps[1])
end)
check(okExit, "cancel then exit does not crash: " .. tostring(errExit))
eq(#warpCalls, 1, "cancel then exit takes the seeded entry-floor warp")
if warpCalls[1] then
eq(warpCalls[1].map, "SILPH_CO_5F",
"cancel then exit returns to the floor the player entered from")
end
-- Choose a mid-list floor (5F): pokered never warps on the spot --
-- DisplayElevatorFloorMenu sets BIT_CUR_MAP_USED_ELEVATOR and the
-- map script runs ShakeElevator: a 12-frame lead-in (the script's
@@ -202,7 +157,6 @@ do
-- cycle, then SFX_SAFARI_ZONE_PA, and only then the floor warp.
clear(warpCalls)
sfxCalls = {}
ow.walkSteps = {}
local chosen = menu.items[5] -- "5F"
menu.onChoose(chosen, menu)
eq(#warpCalls, 0, "choosing a floor does not warp on the spot (the shake runs first)")
@@ -230,7 +184,6 @@ do
eq(warpCalls[1].y, chosen.value.y, "walk-out lands on the chosen floor's y")
end
end
restoreWarps("SILPH_CO_ELEVATOR", silphSnap)
end
-- ===================================================================
@@ -281,21 +234,11 @@ end
-- the digit only), plus the LIFT_KEY gate.
-- ===================================================================
do
local hideoutSnap = snapshotWarps("ROCKET_HIDEOUT_ELEVATOR")
-- ROM car defaults to B1F; entering from another floor without the key
-- must still seed a walk-out back to that floor (#90 / #105).
eq(hideoutSnap[1].destMap, "ROCKET_HIDEOUT_B1F",
"Rocket Hideout ROM car warps still default to B1F")
-- without the key: text-only, no floor menu, but exit is seeded
local gated, _, _, gatedOw =
openElevator("ROCKET_HIDEOUT_ELEVATOR", {}, "ROCKET_HIDEOUT_B2F")
-- without the key: text-only, no floor menu
local gated = openElevator("ROCKET_HIDEOUT_ELEVATOR", {})
check(gated ~= nil, "Rocket Hideout without LIFT_KEY still pushes something")
check(gated ~= nil and getmetatable(gated) ~= ListMenu,
"Rocket Hideout without LIFT_KEY does not open the floor menu")
eq(gatedOw.map.def.warps[1].destMap, "ROCKET_HIDEOUT_B2F",
"Rocket Hideout without LIFT_KEY seeds exit warps to the entry floor")
restoreWarps("ROCKET_HIDEOUT_ELEVATOR", hideoutSnap)
-- with the key: full B1F/B2F/B4F menu, same as the other elevators
local menu, warpCalls, stack, ow = openElevator("ROCKET_HIDEOUT_ELEVATOR", { LIFT_KEY = 1 })
@@ -334,7 +277,6 @@ do
eq(warpCalls[1].y, chosen.value.y, "Rocket Hideout walk-out lands on the chosen floor y")
end
end
restoreWarps("ROCKET_HIDEOUT_ELEVATOR", hideoutSnap)
end
Sound.play = origSoundPlay
+6 -62
View File
@@ -7,7 +7,6 @@
-- over later, at TEXT_OAKSLAB_OAK1's .give_poke_balls beat, gated on
-- EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE and the one-shot
-- EVENT_GOT_POKEBALLS_FROM_OAK flag (data/scripts/oaks_lab.lua).
-- Also #137: starter give_pokemon runs AskName (nickname yes/no).
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")
@@ -22,8 +21,6 @@ local StateStack = require("src.core.StateStack")
local SaveData = require("src.core.SaveData")
local ScriptRunner = require("src.script.ScriptRunner")
local Flags = require("src.script.Flags")
local ChoiceBox = require("src.ui.ChoiceBox")
local NamingScreen = require("src.ui.NamingScreen")
local mapScripts = require("data.scripts.init")
Game.data = Data
@@ -32,15 +29,16 @@ Game.stack = StateStack; StateStack:init()
Game.save = SaveData.newGame()
require("src.render.Font").load(Data)
-- pumps a script coroutine to completion; pressFn returns the Input.pressed
-- table for this frame (default: mash A through text/ask/naming)
local function runScript(script, pressFn)
-- pumps a script coroutine to completion, mashing A through any
-- show_text/ask boxes along the way (mirrors tests/run_tests.lua's
-- runScript helper for the parcel/pokedex chain)
local function runScript(script)
local r = ScriptRunner.new(Game, nil)
r:run(script, {})
local guard = 0
while r:isRunning() and guard < 4000 do
while r:isRunning() and guard < 2000 do
guard = guard + 1
Input.pressed = pressFn and pressFn() or { a = true }
Input.pressed = { a = true }
StateStack:update(1 / 60)
r:update()
end
@@ -60,11 +58,6 @@ check(runScript(mapScripts.talkScript("OAKS_LAB", "TEXT_OAKSLAB_BULBASAUR_POKE_B
"starter pick script completes")
check(Flags.get(Game.save, "EVENT_GOT_STARTER"), "starter flag set")
eq(Game.save.inventory.POKE_BALL, nil, "no POKe BALLs yet right after picking a starter")
check(Game.save.party[1] and Game.save.party[1].species == "BULBASAUR",
"starter joined the party")
-- A-mash accepts the nickname prompt and fills NamingScreen with A's
check(Game.save.party[1].nickname == "AAAAAAAAAA",
"starter nickname prompt accepted (AskName / #137)")
-- === 2) the parcel/pokedex beat still doesn't grant POKé BALLs ===
check(runScript(mapScripts.talkScript("VIRIDIAN_MART", "TEXT_VIRIDIANMART_CLERK")),
@@ -76,12 +69,6 @@ check(runScript(mapScripts.talkScript("OAKS_LAB", "TEXT_OAKSLAB_OAK1")),
eq(Game.save.inventory.OAKS_PARCEL, nil, "parcel delivered")
check(Flags.get(Game.save, "EVENT_OAK_GOT_PARCEL"), "delivery flag set")
check(Flags.get(Game.save, "EVENT_GOT_POKEDEX"), "Pokedex flag set")
-- OaksLab.asm OakGivesPokedex: HideObject TOGGLE_POKEDEX_1/2 (#106)
local labToggles = Game.save.objectToggles and Game.save.objectToggles.OAKS_LAB
check(labToggles and labToggles.OAKSLAB_POKEDEX1 == false,
"POKEDEX1 hidden after receiving Pokédex")
check(labToggles and labToggles.OAKSLAB_POKEDEX2 == false,
"POKEDEX2 hidden after receiving Pokédex")
eq(Game.save.inventory.POKE_BALL, nil, "still no POKe BALLs at the pokedex beat")
-- talking to Oak again before beating the Route 22 rival should fall
@@ -102,47 +89,4 @@ check(runScript(mapScripts.talkScript("OAKS_LAB", "TEXT_OAKSLAB_OAK1")),
"Oak talk (post-grant) script completes")
eq(Game.save.inventory.POKE_BALL, 5, "POKe Ball count unchanged on a second talk")
-- === 5) onEnter re-hides table Pokédex for pre-#106 saves ===
Game.save.objectToggles = { OAKS_LAB = {
OAKSLAB_POKEDEX1 = true, OAKSLAB_POKEDEX2 = true,
} }
local oaksLab = mapScripts.get("OAKS_LAB")
check(oaksLab and type(oaksLab.onEnter) == "function", "OAKS_LAB onEnter present")
oaksLab.onEnter(Game, nil)
local repaired = Game.save.objectToggles.OAKS_LAB
check(repaired.OAKSLAB_POKEDEX1 == false
and repaired.OAKSLAB_POKEDEX2 == false,
"onEnter hides both Pokédex table sprites when EVENT_GOT_POKEDEX is set")
-- === 6) #137: declining the starter nickname leaves no nickname ===
-- Choice boxes in order: (1) "you want X?" YES, (2) nickname YES/NO -> NO
Game.save = SaveData.newGame()
Flags.set(Game.save, "EVENT_FOLLOWED_OAK_INTO_LAB")
local choicesSeen, lastChoice = 0, nil
local function declineNickname()
local top = StateStack:top()
local mt = getmetatable(top)
if mt == ChoiceBox and top ~= lastChoice then
choicesSeen = choicesSeen + 1
lastChoice = top
elseif mt ~= ChoiceBox then
lastChoice = nil
end
if mt == ChoiceBox and choicesSeen >= 2 then
return { b = true }
end
if mt == NamingScreen then
return { start = true }
end
return { a = true }
end
check(runScript(mapScripts.talkScript("OAKS_LAB", "TEXT_OAKSLAB_CHARMANDER_POKE_BALL"),
declineNickname),
"starter pick with declined nickname completes")
check(Flags.get(Game.save, "EVENT_GOT_STARTER"), "declined-nickname path still sets starter flag")
eq(Game.save.party[1] and Game.save.party[1].species, "CHARMANDER",
"declined-nickname path still gives Charmander")
eq(Game.save.party[1] and Game.save.party[1].nickname, nil,
"declining nickname leaves the species name")
S.finish()
-51
View File
@@ -187,55 +187,4 @@ check(OW.objectVisible(Game.save, "SEAFOAM_ISLANDS_B4F",
objOf("SEAFOAM_ISLANDS_B4F", "SEAFOAMISLANDSB4F_BOULDER2")),
"SEAFOAMISLANDSB4F_BOULDER2 is visible at the end state")
-- === issue #129: B3F current auto-warps via BIT_FORCED_WARP ===
-- South-edge stairs at (20,17) are water warps (not door/warp tiles), so
-- CheckWarpsNoCollision needs BIT_FORCED_WARP (or a held d-pad). B3F
-- currents set that bit; after the RLE lands, the warp must fire without
-- further player input.
do
local FieldDefaults = require("src.world.FieldDefaults")
check(FieldDefaults.fieldValue(Data, "seafoam", "SEAFOAM_ISLANDS_B3F",
"setsForcedWarp") == true,
"B3F seafoam setsForcedWarp (BIT_FORCED_WARP) is configured")
while Game.stack:top() do Game.stack:pop() end
Game.save = SaveData.newGame()
Game.save.flags = {}
-- Landing on the B3F force-surf mouth mounts SURF and arms the current
-- (EnterMap CheckForceBikeOrSurf + Seafoam MOVE_OBJECT), including
-- BIT_FORCED_WARP for the south-edge stairs.
Game.stack:push(OW, "SEAFOAM_ISLANDS_B3F", 18, 7, "down")
local cur = Game.stack:top()
check(cur.player.surfing == true,
"B3F force-surf mouth mounts SURF on map entry")
check(cur.forcedWarp == true,
"B3F current arms forcedWarp (BIT_FORCED_WARP)")
check(#cur.scriptMoves > 0 or cur.player.moving,
"unplugged B3F current at (18,7) starts forced surfing RLE")
-- Fast-forward the scripted current onto the south-edge warp.
local guard = 0
while guard < 2000 do
guard = guard + 1
StateStack:update(1 / 60)
if cur.map and cur.map.id ~= "SEAFOAM_ISLANDS_B3F" then break end
if cur.transitioning then break end
end
-- Drain the warp Transition onto B4F (and any immediate forcedExit shove).
guard = 0
while guard < 400 do
guard = guard + 1
StateStack:update(1 / 60)
if Game.stack:top() == cur and cur.map and cur.map.id == "SEAFOAM_ISLANDS_B4F"
and not cur.transitioning and #cur.scriptMoves == 0
and not cur.player.moving then
break
end
end
check(cur.map and cur.map.id == "SEAFOAM_ISLANDS_B4F",
"B3F current auto-warps to B4F without a held d-pad (#129)")
check(cur.player.cellX == 20 or cur.player.cellX == 21,
"forced current lands on the B4F stair-warp column")
end
S.finish()
-49
View File
@@ -408,55 +408,6 @@ check(strFlash ~= page2 and strFlash ~= ow and strFlash.pages == nil,
for _ = 1, 10 do frame({}) end
eq(Game.stack:top(), ow, "back on the map after the blink")
-- ===========================================================================
-- #107: fainted Pokémon can still use field moves (party submenu + partyKnows)
-- ===========================================================================
local fainted = mkMon("PIDGEOTTO", "FLY", "CUT", "STRENGTH", "SURF")
fainted.hp = 0
Game.save.party = { fainted }
Game.save.inventory = {
THUNDERBADGE = true, CASCADEBADGE = true,
RAINBOWBADGE = true, SOULBADGE = true,
}
ow = pushOW("PALLET_TOWN", 4, 13, "down")
check(ow:partyKnows("SURF") == fainted,
"partyKnows finds SURF on a fainted mon")
check(ow:partyKnows("CUT") == fainted,
"partyKnows finds CUT on a fainted mon")
local pmFaint = PartyMenu.new(Game)
Game.stack:push(pmFaint)
frame({ "a" })
local actsFaint = submenuActions(pmFaint)
-- FLY needs OVERWORLD tileset (Pallet); CUT/SURF/STRENGTH need badges only
check(actsFaint.fly and actsFaint.cut and actsFaint.surf and actsFaint.strength,
"fainted mon still lists FLY/CUT/SURF/STRENGTH in the party submenu")
popToOW()
-- STRENGTH activation from a fainted user (name text + strengthActive)
ow = pushOW("SEAFOAM_ISLANDS_1F", 17, 10, "right")
clearCaptured()
local pmFaintStr = PartyMenu.new(Game)
-- Seafoam is not OVERWORLD, so FLY is omitted: STATS, SWITCH, CUT, STRENGTH, SURF
selectSubItem(pmFaintStr, 4)
eq(Game.overworld.strengthActive, true,
"fainted mon can activate STRENGTH from the party menu")
check(sawText("used") and sawText("STRENGTH"),
"fainted STRENGTH still prints _UsedStrengthText")
drainText()
-- SURF mount from a fainted user when facing water
Game.save.party = { fainted }
Game.save.inventory.SOULBADGE = true
ow = pushOW("PALLET_TOWN", 4, 13, "down")
ow.player.surfing = false
eq(ow:useSurfFieldMove(), "ok", "useSurfFieldMove ok with only a fainted SURF mon")
clearCaptured()
-- submenu order: STATS, SWITCH, FLY, CUT, STRENGTH, SURF (move order on mon)
local pmFaintSurf = PartyMenu.new(Game)
selectSubItem(pmFaintSurf, 6)
eq(ow.player.surfing, true, "fainted mon can SURF from the party menu")
check(not onStack(pmFaintSurf), "party menu closes after fainted SURF")
-- restore the spied constructor so later dofile'd suites are unaffected
TextBox.new = realTextBoxNew
popAll()
+1 -78
View File
@@ -79,58 +79,6 @@ do
"the queued trap anim row is attributed to the attacker")
end
-- (2b) Issue #140: being held by Wrap must NOT skip DisplayBattleMenu
-- (core.asm:312 then 323-329). FIGHT forces CANNOT_MOVE; AI still
-- auto-selects bound. A player switch clears the foe's trap bit
-- (SendOutMon core.asm:1761-1762).
do
local tb = freshBattle()
tb.enemy.trappingTurns = 2
tb.enemy.trapMove = "WRAP"
tb.enemy.trapDamage = 5
check(tb:menuLockedAction(tb.player) == nil,
"a Wrap victim still gets the battle menu")
local fight = tb:fightLockedAction(tb.player)
check(fight and fight.special == "bound",
"FIGHT while wrapped selects CANNOT_MOVE (bound)")
-- wrapper continues only after FIGHT, not by skipping the menu
check(tb:menuLockedAction(tb.enemy) == nil,
"a Wrap user still gets the battle menu")
check(tb:fightLockedAction(tb.enemy) and
tb:fightLockedAction(tb.enemy).special == "trapping",
"FIGHT while wrapping continues the trap")
check(tb:lockedAction(tb.player) and tb:lockedAction(tb.player).special == "bound",
"lockedAction still reports bound for AI / callers")
end
do
Game.save.party = {
Pokemon.new(Data, "BULBASAUR", 20),
Pokemon.new(Data, "SQUIRTLE", 20),
}
local tb = BattleState.newWild(Game, "EKANS", 10)
tb.enemy.trappingTurns = 3
tb.enemy.trapMove = "WRAP"
tb.enemy.trapDamage = 7
local acts = {}
function tb:act(fn) acts[#acts + 1] = fn end
function tb:actNext(fn) acts[#acts + 1] = fn end
function tb:sayNext() end
function tb:animNext() end
function tb:startGrowIn() end
function tb:syncSides() end
function tb:markParticipant() end
function tb:restoreMimicked() end
function tb:executeAction() end
function tb:endOfTurn() end
function tb:enemyAction() return { special = "bound" } end
tb:resolveSwitch(Game.save.party[2])
acts[1]() -- send-out clears foe trap
eq(tb.enemy.trappingTurns, nil, "player switch clears foe Wrap/Bind/etc.")
eq(tb.enemy.trapMove, nil, "player switch clears trapMove")
check(tb:fightLockedAction(tb.player) == nil,
"switch-in is free to choose a move")
end
-- (3) MIMIC runs MID-move (MimicEffect, effects.asm:1203-1273): the
-- move executes, MoveHitTest runs, and only on a hit does the player's
-- copy menu open (.letPlayerChooseMove) -- the enemy's Mimic and link
@@ -221,35 +169,10 @@ do
check(fe.chooser == nil, "enemy Mimic never opens a chooser")
eq(tbe.enemy.curMoves[1].id, "SAND_ATTACK",
"enemy Mimic copies a random player move immediately")
-- Gen 1 never decrements enemy PP, so the copied move inherits Mimic's
-- full remaining PP (still 10). Player Mimic would leave 9.
eq(tbe.enemy.curMoves[1].pp, 10, "enemy Mimic keeps full slot PP (no enemy drain)")
eq(tbe.enemy.curMoves[1].pp, 9, "enemy Mimic also keeps the slot's PP")
check(fe.anim and hasText(fe, "learned"),
"enemy Mimic still plays the animation and learned text")
-- === #94: gen1_faithful enemies never deplete PP; player still does ===
do
local tb = freshBattle()
check(tb.ruleset.enemyUnlimitedPP,
"default ruleset grants enemy unlimited PP")
local enemyMove = { id = "TACKLE", pp = 5 }
local playerMove = { id = "TACKLE", pp = 5 }
tb.queue, tb.nextInsert = {}, 0
tb:performMove(tb.enemy, tb.player, enemyMove)
eq(enemyMove.pp, 5, "gen1_faithful: enemy move PP is not decremented")
tb.queue, tb.nextInsert = {}, 0
tb:performMove(tb.player, tb.enemy, playerMove)
eq(playerMove.pp, 4, "gen1_faithful: player move PP still decrements")
-- modern_clean tracks enemy PP (Gen 2+ style)
local modern = require("src.battle.rulesets.modern_clean")
tb.ruleset = modern
local enemyModern = { id = "TACKLE", pp = 5 }
tb.queue, tb.nextInsert = {}, 0
tb:performMove(tb.enemy, tb.player, enemyModern)
eq(enemyModern.pp, 4, "modern_clean: enemy move PP decrements")
end
-- link battle: the player's Mimic rolls random too (no chooser)
local tbl = freshBattle()
tbl.kind = "link"
+3 -31
View File
@@ -115,39 +115,11 @@ do
end
-- === STRUGGLE fallback when nothing is usable ===
-- modern_clean / no unlimited flag: empty PP forces Struggle.
do
local aiMon = { curMoves = { { id = "TACKLE", pp = 0 } } }
local pick = TrainerAI.chooseMove(aiMon, rngLo, {
enemyAIMods = { 1 }, data = Data,
player = { mon = {}, curTypes = {} },
ruleset = { enemyUnlimitedPP = false },
})
check(pick and pick.struggle and pick.id == "STRUGGLE",
"STRUGGLE fallback when no PP (enemy PP tracked)")
end
-- gen1_faithful: AI ignores PP; a 0-PP move is still selectable.
do
local aiMon = { curMoves = { { id = "TACKLE", pp = 0 } } }
local pick = TrainerAI.chooseMove(aiMon, rngLo, {
enemyAIMods = { 1 }, data = Data,
player = { mon = {}, curTypes = {} },
ruleset = { enemyUnlimitedPP = true },
})
eq(pick and pick.id, "TACKLE",
"gen1_faithful: enemy still picks 0-PP moves (no Struggle)")
end
-- Disable with unlimited PP: sole disabled move still yields Struggle.
do
local aiMon = { curMoves = { { id = "TACKLE", pp = 10 } }, disabledSlot = 1 }
local pick = TrainerAI.chooseMove(aiMon, rngLo, {
enemyAIMods = {}, data = Data,
ruleset = { enemyUnlimitedPP = true },
})
check(pick and pick.struggle and pick.id == "STRUGGLE",
"gen1_faithful: Struggle only when every move is disabled")
local pick = TrainerAI.chooseMove(aiMon, rngLo, { enemyAIMods = { 1 }, data = Data,
player = { mon = {}, curTypes = {} } })
check(pick and pick.struggle and pick.id == "STRUGGLE", "STRUGGLE fallback when no PP")
end
-- === switchAction off-by-one fix (matches AISwitchIfEnoughMons cp 2) ===
-114
View File
@@ -1,114 +0,0 @@
-- Parity test: trainer next-mon after an AI switch must skip fainted slots.
--
-- Agatha (and any switchChance / switch AI) can withdraw mon 1, send mon 2,
-- then later return to mon 1. If mon 2 was KO'd in between, enemyMonFainted
-- used to do enemyIndex+1 and send the already-fainted Golbat back out.
-- The FIGHT menu reappears with an empty enemy HP bar; executeAction then
-- returns immediately on target.mon.hp <= 0 -- a softlock.
--
-- pokered's EnemySendOutFirstMon / AnyEnemyPokemonAliveCheck scan the party
-- for the first mon with HP remaining (core.asm), not "current index + 1".
--
-- Self-contained; run via `luajit tests/parity_ai_switch_faint.lua`.
-- Also picked up by tests/run_tests.lua's parity_* glob.
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.pokemon and Data.pokemon.RATTATA) then Data:load() end
local TypeChart = require("src.battle.TypeChart")
TypeChart.load(Data)
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
local S = require("tests.harness").suite("parity ai switch faint")
local check, eq = S.check, S.eq
-- Minimal game stub matching what BattleState.newTrainer / enemyMonFainted
-- touch. SET style skips the SHIFT "change POKéMON?" ChoiceBox.
local function freshGame()
return {
data = Data,
save = {
party = { Pokemon.new(Data, "BULBASAUR", 50) },
player = { name = "RED" },
inventory = {},
options = { battleStyle = "set" },
pokedex = { seen = {}, owned = {} },
flags = {},
money = 0,
},
stack = { push = function() end, pop = function() end, top = function() end },
}
end
-- Drain act/say rows until the enemy swap act runs (or the queue empties).
local function pumpUntilSwap(b, limit)
limit = limit or 200
local n = 0
while #b.queue > 0 and n < limit do
n = n + 1
local item = table.remove(b.queue, 1)
if item.fn then
b.nextInsert = 0
item.fn()
-- the send-out act replaces self.enemy; stop once a living mon is in
if b.enemy and b.enemy.mon and b.enemy.mon.hp > 0 then
return true
end
end
end
return b.enemy and b.enemy.mon and b.enemy.mon.hp > 0
end
-- #143: after AI-switch reorder, do not send a fainted slot.
do
local Game = freshGame()
local b = BattleState.newTrainer(Game, "OPP_AGATHA", 1)
check(#b.enemyParty >= 3, "Agatha has enough party slots for the scenario")
-- Simulate: switched to slot 2, KO'd it, switched back to slot 1, KO slot 1.
b.enemyParty[1].hp = 0
b.enemyParty[2].hp = 0
-- slots 3+ stay at full HP from construction
b.enemyIndex = 1
b.enemy.mon = b.enemyParty[1]
b.participants = { [Game.save.party[1]] = true }
b:enemyMonFainted()
check(pumpUntilSwap(b), "a living reserve is sent out after the KO")
eq(b.enemyIndex, 3, "next index is the first living mon, not fainted slot 2")
check(b.enemy.mon.hp > 0, "sent-out mon has HP (no empty-bar softlock)")
check(b.enemy.mon == b.enemyParty[3], "sent-out battler is party slot 3")
check(b.result ~= "win", "battle continues while reserves remain")
end
-- Sequential KOs (no AI switch) still advance one slot at a time.
do
local Game = freshGame()
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()
check(pumpUntilSwap(b), "youngster still sends the second mon")
eq(b.enemyIndex, 2, "without AI switch, first living mon is still index 2")
check(b.enemy.mon.hp > 0, "second mon is healthy")
end
-- No living reserves: victory even if enemyIndex is mid-party (AI left
-- earlier slots dead and the active one was not the last index).
do
local Game = freshGame()
local b = BattleState.newTrainer(Game, "OPP_AGATHA", 1)
for _, mon in ipairs(b.enemyParty) do mon.hp = 0 end
b.enemyIndex = 2
b.enemy.mon = b.enemyParty[2]
b.participants = { [Game.save.party[1]] = true }
b:enemyMonFainted()
eq(b.result, "win", "all-fainted party ends the trainer battle")
eq(b.afterQueue, "finish", "victory finishes after the queue")
end
S.finish()
-228
View File
@@ -1,228 +0,0 @@
-- Parity test, Bill's House PC + Route25ToggleBillsScript (#120).
--
-- asm sources:
-- engine/events/hidden_events/bills_house_pc.asm (BillsHousePC /
-- BillsHousePokemonList: after EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING
-- the PC opens EEVEE/FLAREON/JOLTEON/VAPOREON; before that the
-- teleporter monitor text or cell-separator cutscene)
-- scripts/Route25.asm (Route25ToggleBillsScript: first Route 25 load
-- after EVENT_MET_BILL_2 + EVENT_GOT_SS_TICKET sets
-- EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING, hides BILL1 / nugget-bridge
-- guy, shows BILL2; mid-quest leave resets the separator flag)
--
-- Self-contained: run via `luajit tests/parity_bills_pc.lua`; also
-- dofile'd by tests/run_tests.lua's aggregator.
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("parity bills pc")
local check, eq = S.check, S.eq
local OW = require("src.world.OverworldController")
local SaveData = require("src.core.SaveData")
local story = require("data.scripts.story")
local function getUpvalue(fn, name)
local i = 1
while true do
local n, v = debug.getupvalue(fn, i)
if not n then return nil end
if n == name then return v end
i = i + 1
end
end
local function setUpvalue(fn, name, val)
local i = 1
while true do
local n = debug.getupvalue(fn, i)
if not n then return false end
if n == name then debug.setupvalue(fn, i, val); return true end
i = i + 1
end
end
local pushed = {}
local stackStub = {
push = function(_, item)
pushed[#pushed + 1] = item
end,
}
local textBoxStub = {
new = function(_, text, onDone)
local box = { kind = "text", text = text, onDone = onDone }
if onDone then onDone() end
return box
end,
}
local menuStub = {
new = function(_, items, opts)
return { kind = "menu", items = items, opts = opts or {} }
end,
}
local screensStub = {
push = function(_, id, species)
pushed[#pushed + 1] = { kind = "screen", id = id, species = species }
end,
}
local fakeGame = {
data = Data,
save = SaveData.newGame(),
stack = stackStub,
}
check(setUpvalue(OW.billsHousePC, "TextBox", textBoxStub), "TextBox upvalue")
check(setUpvalue(OW.billsHousePC, "Game", fakeGame), "Game upvalue for PC")
-- Screens is only referenced from billsHousePokemonList
check(setUpvalue(OW.billsHousePokemonList, "Screens", screensStub),
"Screens upvalue on pokemon list")
check(setUpvalue(OW.billsHousePokemonList, "Game", fakeGame),
"Game upvalue on pokemon list")
check(setUpvalue(OW.billsHousePokemonList, "TextBox", textBoxStub),
"TextBox upvalue on pokemon list")
-- Menu is required inside billsHousePokemonList; stub via package.loaded
local realMenu = package.loaded["src.ui.Menu"]
package.loaded["src.ui.Menu"] = menuStub
local fakeSelf = setmetatable({
queueScript = function() end,
billsHouseBillExits = function() end,
}, { __index = OW })
local function resetFlags()
fakeGame.save = SaveData.newGame()
setUpvalue(OW.billsHousePC, "Game", fakeGame)
pushed = {}
end
local function lastPush()
return pushed[#pushed]
end
local function runPC()
pushed = {}
fakeSelf:billsHousePC()
end
-- === 1) default: teleporter monitor text ===
resetFlags()
runPC()
eq(lastPush() and lastPush().kind, "text", "default PC is a text box")
check(tostring(lastPush().text):find("TELEPORTER", 1, true)
or tostring(lastPush().text):find("displayed on the", 1, true),
"default PC shows monitor text")
-- === 2) Bill in machine, separator not used yet: initiated text ===
resetFlags()
fakeGame.save.flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = true
local musicStopped = false
local realMusic = package.loaded["src.core.Music"]
package.loaded["src.core.Music"] = {
stop = function() musicStopped = true end,
playMap = function() end,
}
local realSound = package.loaded["src.core.Sound"]
package.loaded["src.core.Sound"] = {
play = function() end,
playCry = function() end,
}
runPC()
check(musicStopped, "cell separator stops map music")
check(tostring(lastPush().text):find("Cell", 1, true)
or tostring(lastPush().text):find("TELEPORTER", 1, true),
"separator path prints initiated text")
check(fakeGame.save.flags.EVENT_USED_CELL_SEPARATOR_ON_BILL,
"separator path sets EVENT_USED_CELL_SEPARATOR_ON_BILL")
-- === 3) after separator, before leaving: monitor text again ===
resetFlags()
fakeGame.save.flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = true
fakeGame.save.flags.EVENT_USED_CELL_SEPARATOR_ON_BILL = true
fakeGame.save.flags.EVENT_MET_BILL = true
runPC()
check(tostring(lastPush().text):find("TELEPORTER", 1, true)
or tostring(lastPush().text):find("displayed on the", 1, true),
"post-separator pre-leave PC shows monitor text")
-- === 4) after leaving: Eevee collection menu ===
resetFlags()
fakeGame.save.flags.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING = true
runPC()
local menu
for _, p in ipairs(pushed) do
if p.kind == "menu" then menu = p break end
end
check(menu ~= nil, "post-leave PC opens a menu")
local labels = {}
for _, item in ipairs(menu.items) do labels[#labels + 1] = item.label end
eq(table.concat(labels, ","), "EEVEE,FLAREON,JOLTEON,VAPOREON,CANCEL",
"Eevee collection lists the four mons + CANCEL")
-- selecting EEVEE marks seen and opens DexEntryMenu without closing list
fakeGame.save.pokedex = { seen = {}, owned = {} }
menu.items[1].onSelect()
check(fakeGame.save.pokedex.seen.EEVEE, "viewing marks EEVEE seen")
local dexPush
for i = #pushed, 1, -1 do
if pushed[i].kind == "screen" then dexPush = pushed[i] break end
end
eq(dexPush and dexPush.id, "DexEntryMenu", "selection opens DexEntryMenu")
eq(dexPush and dexPush.species, "EEVEE", "DexEntryMenu gets EEVEE")
check(menu.items[1].keepOpen, "dex pick keeps the list open")
-- === 5) Route25ToggleBillsScript ===
local toggles = {}
local Commands = require("src.script.Commands")
local realHide, realShow = Commands.hide_object, Commands.show_object
Commands.hide_object = function(_, mapId, name)
toggles[mapId .. ":" .. name] = false
end
Commands.show_object = function(_, mapId, name)
toggles[mapId .. ":" .. name] = true
end
local function runRoute25(flags)
toggles = {}
local save = SaveData.newGame()
for k, v in pairs(flags) do save.flags[k] = v end
story.ROUTE_25.onEnter({ save = save }, {})
return save.flags, toggles
end
local f, t = runRoute25({})
check(not f.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING,
"no leave flag before meeting Bill")
check(t["BILLS_HOUSE:BILLSHOUSE_BILL_POKEMON"] == true,
"mid-quest leave restores monster Bill")
check(f.EVENT_BILL_SAID_USE_CELL_SEPARATOR == nil,
"mid-quest leave clears separator arming flag")
f, t = runRoute25({ EVENT_MET_BILL_2 = true })
check(not f.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING,
"MET_BILL_2 without ticket does not arm leave flag")
f, t = runRoute25({
EVENT_MET_BILL_2 = true,
EVENT_GOT_SS_TICKET = true,
})
check(f.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING,
"ticket + MET_BILL_2 arms EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING")
eq(t["BILLS_HOUSE:BILLSHOUSE_BILL1"], false, "hides SS-Ticket Bill")
eq(t["BILLS_HOUSE:BILLSHOUSE_BILL2"], true, "shows rare-POKéMON Bill")
eq(t["ROUTE_24:ROUTE24_COOLTRAINER_M1"], false, "hides nugget-bridge guy")
f, t = runRoute25({
EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING = true,
EVENT_MET_BILL_2 = true,
EVENT_GOT_SS_TICKET = true,
})
eq(next(t), nil, "already-left Route 25 enter is a no-op")
Commands.hide_object = realHide
Commands.show_object = realShow
package.loaded["src.ui.Menu"] = realMenu
if realMusic ~= nil then package.loaded["src.core.Music"] = realMusic end
if realSound ~= nil then package.loaded["src.core.Sound"] = realSound end
S.finish()
-68
View File
@@ -1,68 +0,0 @@
-- Parity test (#96): blackouts must not play the Dig/Teleport EnterMapAnim.
--
-- pret HandleBlackOut fades to black and SpecialEnterMap's without setting
-- BIT_FLY_WARP / BIT_DUNGEON_WARP, so EnterMap skips EnterMapAnim. Dig,
-- Teleport and Escape Rope go through HandleFlyWarpOrDungeonWarp and do
-- spin. warpToHealPoint used to always set arriveWarp="teleport", so a
-- loss (notably Elite Four -> Indigo lobby) rematerialized with the
-- teleport spin instead of a plain whiteout warp.
-- Self-contained; run via `luajit tests/parity_blackout_warp.lua`.
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("parity blackout warp")
local check, eq = S.check, S.eq
require("src.render.Font").load(Data)
local Game = require("src.core.Game")
local Input = require("src.core.Input")
local StateStack = require("src.core.StateStack")
local Renderer = require("src.render.Renderer")
local SaveData = require("src.core.SaveData")
local Pokemon = require("src.pokemon.Pokemon")
local OW = require("src.world.OverworldController")
Game.data = Data
Game.input = Input; Input:init()
Game.renderer = Renderer; Renderer:init()
Game.stack = StateStack; StateStack:init()
Game.save = SaveData.newGame()
Game.save.party = { Pokemon.new(Data, "SQUIRTLE", 5) }
Game.save.party[1].hp = 0
Game.save.lastHeal = { map = "INDIGO_PLATEAU_LOBBY", x = 7, y = 6,
outdoor = { id = "INDIGO_PLATEAU", x = 9, y = 5 } }
Game.stack:push(OW, "LORELEIS_ROOM", 4, 5, "up")
local ow = Game.stack:top()
Game.overworld = ow
local captured
local realStart = ow.startWarpTo
ow.startWarpTo = function(self, mapId, x, y, facing, onDone, opts)
captured = self.arriveWarp
-- skip Transition; only the arrive flag matters here
self.arriveWarp = nil
self.transitioning = false
end
-- Battle blackout (afterBattle -> warpToHealPoint): no EnterMapAnim.
captured = "sentinel"
ow:afterBattle("lose", { oppClass = "OPP_LORELEI" })
eq(captured, nil,
"Elite Four blackout does not set arriveWarp=teleport (#96)")
-- Poison / field blackout path uses the same helper with no opts.
captured = "sentinel"
ow.arriveWarp = nil
ow:warpToHealPoint()
eq(captured, nil, "plain warpToHealPoint has no teleport arrive FX")
-- Dig / Teleport / Escape Rope keep EnterMapAnim.
captured = "sentinel"
ow:warpToHealPoint(nil, { arrive = "teleport" })
eq(captured, "teleport",
"escape warps still request EnterMapAnim on arrival")
ow.startWarpTo = realStart
S.finish()
-116
View File
@@ -1,116 +0,0 @@
-- Regression (#125): SURF from Cinnabar's east coast into Route 20 water.
--
-- Cinnabar's easternmost cells are walkable land (tile $39); the water is
-- on ROUTE_20 across the east connection. pokered loads that strip into
-- the border, so IsNextTileShoreOrWater sees shore tile $32. The port
-- must read the connection landing the same way -- an inBounds-only
-- water check returns "no_water" and blocks the mount (and the reverse
-- party-menu dismount back onto the coast).
--
-- Self-contained; run via `luajit tests/parity_cinnabar_east_surf.lua`.
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.CINNABAR_ISLAND) then Data:load() end
local Game = require("src.core.Game")
local Input = require("src.core.Input")
local Map = require("src.world.Map")
local MapLoader = require("src.world.MapLoader")
local Renderer = require("src.render.Renderer")
local SaveData = require("src.core.SaveData")
local StateStack = require("src.core.StateStack")
local OW = require("src.world.OverworldController")
local S = require("tests.harness").suite("parity cinnabar east surf")
local check, eq = S.check, S.eq
local function mkMon(species, ...)
local moves = {}
for _, id in ipairs({ ... }) do
table.insert(moves, { id = id, pp = 10, ppUp = 0 })
end
return {
species = species, level = 30, hp = 50, maxHp = 50,
status = 0, moves = moves, nickname = nil,
}
end
local cin = MapLoader.load(Data, "CINNABAR_ISLAND")
local r20 = MapLoader.load(Data, "ROUTE_20")
local r20ts = Data.tilesets[r20.def.tileset]
-- ground truth: east edge is land, Route 20 west landing is shore/water
check(cin:isWalkableCell(19, 8), "Cinnabar (19,8) is walkable coast")
check(not cin:isWaterCell(19, 8), "Cinnabar (19,8) is not water")
check(not cin:inBounds(20, 8), "facing east from (19,8) is off-map")
check(r20:isWaterCell(0, 8), "ROUTE_20 (0,8) is water/shore")
check(Map.defIsWaterCell(r20.def, r20ts, 0, 8),
"defIsWaterCell agrees on ROUTE_20 (0,8)")
check(Map.defIsWalkableCell(cin.def, Data.tilesets[cin.def.tileset], 19, 8),
"defIsWalkableCell agrees on Cinnabar coast")
Game.data = Data
Game.input = Input; Input:init()
Game.renderer = Renderer; Renderer:init()
Game.stack = StateStack
StateStack:init()
Game.save = SaveData.newGame()
Game.save.party = { mkMon("SQUIRTLE", "SURF") }
Game.save.inventory = { SOULBADGE = true }
Game.overworld = OW
while Game.stack:top() do Game.stack:pop() end
Game.stack:push(OW, "CINNABAR_ISLAND", 19, 8, "right")
local ow = Game.stack:top()
eq(ow.map.id, "CINNABAR_ISLAND", "start on Cinnabar east coast")
eq(ow.player.cellX, 19, "coast x")
eq(ow.player.cellY, 8, "coast y")
eq(ow.player.facing, "right", "facing east toward Route 20")
check(ow:facingIsShoreOrWater(),
"facingIsShoreOrWater reads Route 20 shore across the seam")
eq(ow:useSurfFieldMove(), "ok",
"useSurfFieldMove ok facing connection water (#125)")
-- reverse: surfing on Route 20 west edge, facing Cinnabar land
while Game.stack:top() do Game.stack:pop() end
Game.stack:push(OW, "ROUTE_20", 0, 8, "left")
ow = Game.stack:top()
ow.player.surfing = true
eq(ow.map.id, "ROUTE_20", "on Route 20 west water")
check(ow.map:isWaterCell(0, 8), "standing cell is water")
check(ow:facingIsLandDismount(),
"facingIsLandDismount sees Cinnabar coast across the seam")
eq(ow:useSurfFieldMove(), "dismount",
"party-menu SURF dismounts onto Cinnabar east coast")
-- facing open water while surfing still refuses
ow.player.facing = "right"
eq(ow:useSurfFieldMove(), "no_place",
"surfing + facing open water: no place (unchanged)")
-- mount step crosses the seam onto Route 20 (surfing already armed)
while Game.stack:top() do Game.stack:pop() end
Game.stack:push(OW, "CINNABAR_ISLAND", 19, 8, "right")
ow = Game.stack:top()
ow.player.surfing = true
check(ow:stepForwardOrCrossEdge("right"),
"stepForwardOrCrossEdge crosses onto Route 20 while surfing")
eq(ow.map.id, "ROUTE_20", "map swapped to Route 20 after edge surf step")
-- crossConnection parks one cell before the seam and walks in (same as
-- a live edge press); the landing target is Route 20 (0,8)
eq(ow.player.targetX, 0, "step target is Route 20 west column")
eq(ow.player.targetY, 8, "same Y across offset-0 east connection")
check(ow.player.moving, "seam step is in progress")
-- in-map Pallet south shore still works (no false connection match)
while Game.stack:top() do Game.stack:pop() end
Game.stack:push(OW, "PALLET_TOWN", 4, 13, "down")
ow = Game.stack:top()
ow.player.surfing = false
eq(ow:useSurfFieldMove(), "ok", "in-map water mount still ok")
ow.player.facing = "up"
eq(ow:useSurfFieldMove(), "no_water", "in-map land still no_water")
S.finish()
-184
View File
@@ -1,184 +0,0 @@
-- Parity test for Route 5 Day Care (issue #118).
-- Self-contained: `luajit tests/parity_daycare.lua`; also dofile'd by
-- tests/run_tests.lua's parity_* aggregator.
--
-- Covers scripts/Daycare.asm retrieve flow ported in
-- data/scripts/story2.lua M.DAYCARE:
-- * name substitution in grown / got-back text
-- * fee = ¥100 + ¥100 per level gained
-- * declining retrieve must not collapse the fee to ¥100 on re-talk
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("parity daycare")
local check, eq = S.check, S.eq
local realTextBox = package.loaded["src.render.TextBox"]
local shownTexts = {}
local choiceAnswer = true
package.loaded["src.render.TextBox"] = {
new = function(game, text, onDone, opts)
table.insert(shownTexts, text)
if opts and opts.choice then
opts.choice(choiceAnswer)
elseif onDone then
onDone()
end
return { text = text }
end,
}
local SaveData = require("src.core.SaveData")
local Pokemon = require("src.pokemon.Pokemon")
local Growth = require("src.pokemon.Growth")
local story2 = require("data.scripts.story2")
check(story2.DAYCARE ~= nil, "DAYCARE registered")
check(story2.DAYCARE.talk.TEXT_DAYCARE_GENTLEMAN ~= nil,
"DAYCARE gentleman talk handler registered")
local talkGentleman = story2.DAYCARE.talk.TEXT_DAYCARE_GENTLEMAN
local function newGame()
local save = SaveData.newGame()
save.money = 10000
local game = { data = Data, save = save, stack = { push = function() end } }
return game
end
local function talk(game)
shownTexts = {}
local doneCalled = false
talkGentleman(game, {}, nil, function() doneCalled = true end)
return doneCalled
end
local function boardAtLevel(game, species, level, nickname)
local mon = Pokemon.new(Data, species, level)
if nickname then mon.nickname = nickname end
game.save.daycare = { mon = mon, steps = 0 }
return mon
end
-- Exp needed to gain `gained` levels from `level` (exclusive of current).
local function stepsForLevels(species, level, gained)
local def = Data.pokemon[species]
local target = Growth.expForLevel(def.growthRate, level + gained)
local current = Growth.expForLevel(def.growthRate, level)
return target - current
end
-- === 1) grown text names the mon; fee matches levels grown ===
do
local game = newGame()
local mon = boardAtLevel(game, "RATTATA", 5, "SCRAPPY")
local gained = 3
game.save.daycare.steps = stepsForLevels("RATTATA", 5, gained)
choiceAnswer = false -- decline retrieve
check(talk(game), "grown+decline talk completes")
local grown
for _, s in ipairs(shownTexts) do
if s:find("grown a lot", 1, true) then grown = s break end
end
check(grown ~= nil, "shows MonHasGrownText")
check(grown:find("SCRAPPY", 1, true), "grown text substitutes wNameBuffer")
check(grown:find(tostring(gained), 1, true), "grown text shows levels grown")
check(not grown:find("{RAM:", 1, true), "grown text has no leftover RAM tokens")
local owe
for _, s in ipairs(shownTexts) do
if s:find("owe me", 1, true) then owe = s break end
end
check(owe ~= nil, "shows OweMoneyText")
eq(owe:match("¥(%d+)"), tostring(100 + gained * 100),
"fee is ¥100 + ¥100 per level gained")
eq(game.save.daycare.mon.level, 5,
"decline leaves deposit level untouched (BoxLevel baseline)")
eq(game.save.daycare.steps, 0, "pending steps folded into exp on talk")
end
-- === 2) re-talk after decline keeps the correct fee (issue #118) ===
do
local game = newGame()
boardAtLevel(game, "RATTATA", 5, "SCRAPPY")
local gained = 3
game.save.daycare.steps = stepsForLevels("RATTATA", 5, gained)
choiceAnswer = false
talk(game)
choiceAnswer = false
check(talk(game), "second decline talk completes")
local owe
for _, s in ipairs(shownTexts) do
if s:find("owe me", 1, true) then owe = s break end
end
check(owe ~= nil, "re-talk still shows OweMoneyText")
eq(owe:match("¥(%d+)"), tostring(100 + gained * 100),
"re-talk after decline keeps fee (not ¥100)")
eq(game.save.daycare.mon.level, 5, "re-talk still leaves deposit level")
end
-- === 3) paid retrieve raises level, names the mon, clears daycare ===
do
local game = newGame()
boardAtLevel(game, "RATTATA", 5, "SCRAPPY")
local gained = 2
game.save.daycare.steps = stepsForLevels("RATTATA", 5, gained)
local fee = 100 + gained * 100
local moneyBefore = game.save.money
choiceAnswer = true
check(talk(game), "retrieve talk completes")
eq(game.save.daycare, nil, "daycare cleared after paid retrieve")
eq(#game.save.party, 1, "mon returned to party")
eq(game.save.party[1].nickname, "SCRAPPY", "retrieved nickname preserved")
eq(game.save.party[1].level, 5 + gained, "level applied only on retrieve")
eq(game.save.money, moneyBefore - fee, "money deducted by correct fee")
local got
for _, s in ipairs(shownTexts) do
if s:find("got", 1, true) and s:find("back", 1, true) then got = s break end
end
check(got ~= nil, "shows GotMonBackText")
check(got:find("SCRAPPY", 1, true), "got-back text substitutes wDayCareMonName")
check(not got:find("{RAM:", 1, true), "got-back text has no leftover RAM tokens")
end
-- === 4) no levels gained: NeedsMoreTime still names the mon; fee ¥100 ===
do
local game = newGame()
boardAtLevel(game, "RATTATA", 10, "PIP")
game.save.daycare.steps = 0
choiceAnswer = false
check(talk(game), "no-growth talk completes")
local needs
for _, s in ipairs(shownTexts) do
if s:find("Back already", 1, true) then needs = s break end
end
check(needs ~= nil, "shows MonNeedsMoreTimeText")
check(needs and needs:find("PIP", 1, true), "needs-more-time names the mon")
local owe
for _, s in ipairs(shownTexts) do
if s:find("owe me", 1, true) then owe = s break end
end
eq(owe and owe:match("¥(%d+)"), "100", "no growth still costs base ¥100")
end
-- === 5) depositLevel survives a corrupted mon.level from older buggy talks ===
do
local game = newGame()
local mon = boardAtLevel(game, "RATTATA", 5, "SCRAPPY")
local gained = 4
game.save.daycare.steps = stepsForLevels("RATTATA", 5, gained)
game.save.daycare.depositLevel = 5
mon.level = 5 + gained -- simulate pre-#118 raise-on-talk corruption
choiceAnswer = false
check(talk(game), "depositLevel baseline talk completes")
local owe
for _, s in ipairs(shownTexts) do
if s:find("owe me", 1, true) then owe = s break end
end
eq(owe and owe:match("¥(%d+)"), tostring(100 + gained * 100),
"depositLevel keeps fee correct even if mon.level was raised early")
eq(game.save.daycare.mon.level, 5, "decline restores mon.level to depositLevel")
end
package.loaded["src.render.TextBox"] = realTextBox
S.finish()
-157
View File
@@ -1,157 +0,0 @@
-- Parity: Dig / Fly semi-invulnerable pic hide (#100).
-- Dig charge SLIDE_DOWN hides the user; a cancelled Dig release (miss /
-- type immunity) must restore the pic; a successful Dig release keeps the
-- user hidden through the dirt subanim then emerges via SE_SLIDE_MON_UP
-- (not a cyclic bounce). Self-contained: `luajit tests/parity_dig_pic.lua`.
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 Font = require("src.render.Font")
Font.load(Data)
local S = require("tests.harness").suite("parity dig pic")
local check, eq = S.check, S.eq
local BattleState = require("src.battle.BattleState")
local Pokemon = require("src.pokemon.Pokemon")
local SaveData = require("src.core.SaveData")
local TypeChart = require("src.battle.TypeChart")
TypeChart.load(Data)
local function makeGame(species, level, moves)
local save = SaveData.newGame()
local mon = Pokemon.new(Data, species, level)
mon.moves = moves
save.party = { mon }
local stack = { states = {} }
function stack:push(state) self.states[#self.states + 1] = state end
function stack:pop() return table.remove(self.states) end
function stack:top() return self.states[#self.states] end
return { data = Data, save = save, stack = stack,
input = { wasPressed = function() return true end } }
end
local function pumpToMenu(battle)
local kinds, anims = {}, {}
local guard = 0
while guard < 20000 do
guard = guard + 1
battle.frame = (battle.frame or 0) + 1
battle:updateFx()
local pf = battle.picFx and battle.picFx[battle.player]
if pf and pf.kind and not kinds[pf.kind] then
kinds[pf.kind] = true
end
if battle.animPlaying and battle.animName
and anims[#anims] ~= battle.animName then
anims[#anims + 1] = battle.animName
end
if not battle:updateQueue() then
if battle.phase == "messages" and battle.afterQueue == "menu"
and not battle.animPlaying and not battle.current
and #battle.queue == 0 then
battle.phase = "menu"
break
end
if battle.phase == "menu" then break end
if not battle.animPlaying and not battle.current
and #battle.queue == 0 then
if battle.afterQueue == "menu" then battle.phase = "menu" end
break
end
end
end
for _ = 1, 200 do
battle.frame = battle.frame + 1
battle:updateFx()
end
return kinds, anims
end
local function picHidden(battle)
local pf = battle.picFx and battle.picFx[battle.player]
return pf and pf.hidden or false
end
-- Dig charge hides; Dig miss on release restores the pic (#100 vanish).
do
local game = makeGame("SANDSHREW", 40, { { id = "DIG", pp = 10 } })
local battle = BattleState.newWild(game, "RATTATA", 5)
battle.player.curMoves = game.save.party[1].moves
local dig = battle.player.curMoves[1]
battle.enemyAction = function() return { id = "TACKLE", pp = 35 } end
battle.rng = function(a) return a or 0 end
battle:resolveTurn(dig)
pumpToMenu(battle)
check(battle.player.invulnerable == true, "Dig charge sets invulnerable")
check(picHidden(battle), "Dig charge leaves the user pic hidden")
battle.rng = function(a, b)
if a == 0 and b == 255 then return 255 end -- force Dig accuracy miss
return a or 0
end
battle:resolveTurn(dig)
local kinds, anims = pumpToMenu(battle)
check(not picHidden(battle),
"Dig miss on release restores the user pic (#100)")
check(not kinds.bounce, "Dig release miss never starts a bounce pic fx")
local sawDig = false
for _, name in ipairs(anims) do
if name == "DIG" then sawDig = true end
end
check(not sawDig, "Dig miss cancels the DIG release anim")
end
-- Dig hit: stay hidden through DIG start, emerge via slideUp (not bounce).
do
local game = makeGame("SANDSHREW", 40, { { id = "DIG", pp = 10 } })
local battle = BattleState.newWild(game, "SNORLAX", 40)
battle.player.curMoves = game.save.party[1].moves
local dig = battle.player.curMoves[1]
battle.enemyAction = function() return { id = "TACKLE", pp = 35 } end
battle.rng = function(a) return a or 0 end
battle:resolveTurn(dig)
pumpToMenu(battle)
battle:resolveTurn(dig)
local hiddenAtDigStart = nil
local kinds = {}
local guard = 0
while guard < 20000 do
guard = guard + 1
battle.frame = (battle.frame or 0) + 1
battle:updateFx()
if battle.animPlaying and battle.animName == "DIG"
and hiddenAtDigStart == nil then
-- right after DIG row starts (resetPicFx already ran)
hiddenAtDigStart = picHidden(battle)
end
local pf = battle.picFx and battle.picFx[battle.player]
if pf and pf.kind then kinds[pf.kind] = true end
if not battle:updateQueue() then
if battle.phase == "messages" and battle.afterQueue == "menu"
and not battle.animPlaying and not battle.current
and #battle.queue == 0 then
battle.phase = "menu"
break
end
if battle.phase == "menu" then break end
if not battle.animPlaying and not battle.current
and #battle.queue == 0 then
if battle.afterQueue == "menu" then battle.phase = "menu" end
break
end
end
end
for _ = 1, 200 do
battle.frame = battle.frame + 1
battle:updateFx()
end
check(hiddenAtDigStart == true,
"DIG release keeps the digger hidden until SE_SLIDE_MON_UP")
check(kinds.slideUp == true, "Dig release uses slideUp emerge")
check(not kinds.bounce, "Dig release must not bounce (#100)")
check(not picHidden(battle), "Dig hit leaves the user pic shown")
end
S.finish()
-109
View File
@@ -1,109 +0,0 @@
-- Parity: Lorelei / Bruno / Agatha push AfterBattle text immediately on
-- win (pokered *EndBattleScript -> DisplayTextID -> TalkToTrainer), not
-- only on a later re-talk. Lance is covered by parity_lance.lua.
--
-- Sources: scripts/LoreleisRoom.asm, BrunosRoom.asm, AgathasRoom.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.LORELEIS_ROOM) then Data:load() end
local Font = require("src.render.Font")
if not pcall(Font.encode, "A") then Font.load(Data) end
local S = require("tests.harness").suite("parity e4 after")
local check, eq = S.check, S.eq
local mapScripts = require("data.scripts.init")
local CASES = {
{
map = "LORELEIS_ROOM", text = "TEXT_LORELEISROOM_LORELEI",
after = "_LoreleisRoomLoreleiAfterBattleText",
headerMap = "LoreleisRoom", event = "EVENT_BEAT_LORELEIS_ROOM_TRAINER_0",
npcName = "LORELEISROOM_LORELEI", class = "OPP_LORELEI",
},
{
map = "BRUNOS_ROOM", text = "TEXT_BRUNOSROOM_BRUNO",
after = "_BrunoAfterBattleText",
headerMap = "BrunosRoom", event = "EVENT_BEAT_BRUNOS_ROOM_TRAINER_0",
npcName = "BRUNOSROOM_BRUNO", class = "OPP_BRUNO",
},
{
map = "AGATHAS_ROOM", text = "TEXT_AGATHASROOM_AGATHA",
after = "_AgathaAfterBattleText",
headerMap = "AgathasRoom", event = "EVENT_BEAT_AGATHAS_ROOM_TRAINER_0",
npcName = "AGATHASROOM_AGATHA", class = "OPP_AGATHA",
},
}
for _, c in ipairs(CASES) do
local after = Data.text[c.after]
check(after ~= nil, c.after .. " extracted")
local header = Data:trainerHeader(c.headerMap, 1)
check(header and header.after == c.after,
c.headerMap .. " header wires after-battle label")
local talk = mapScripts.talkScript(c.map, c.text)
check(type(talk) == "function", c.map .. " talk wraps engageTrainer")
local pushed = {}
local game = {
data = Data,
save = {
flags = {},
defeatedTrainers = {},
player = { name = "RED", rival = "BLUE" },
},
stack = {
push = function(_, state) pushed[#pushed + 1] = state end,
},
}
local npc = {
def = { name = c.npcName, index = 1, trainerClass = c.class,
trainerParty = 1, text = c.text },
id = c.map .. ":1",
facePlayer = function() end,
}
local engaged = false
local ow = {
trainerDefeated = function(_, n)
return game.save.defeatedTrainers[n.id] == true
end,
engageTrainer = function(_, n, onDone)
engaged = n == npc
game.save.defeatedTrainers[n.id] = true
game.save.flags[c.event] = true
if onDone then onDone() end
end,
}
talk(game, ow, npc, function() end)
check(engaged, c.map .. " engages on first talk")
check(#pushed == 1 and pushed[1].pages ~= nil,
c.map .. " win pushes after-battle TextBox")
check(#pushed[1].pages > 0, c.map .. " after-battle TextBox has pages")
-- loss: no after text
pushed, engaged = {}, false
game.save.defeatedTrainers = {}
game.save.flags = {}
ow.engageTrainer = function(_, n, onDone)
engaged = true
if onDone then onDone() end
end
talk(game, ow, npc, function() end)
check(engaged, c.map .. " still engages on loss path")
eq(#pushed, 0, c.map .. " loss does not push after-battle text")
-- re-talk after win still shows after text (TalkToTrainer after branch)
pushed = {}
game.save.defeatedTrainers[npc.id] = true
game.save.flags[c.event] = true
ow.engageTrainer = function()
error(c.map .. " re-talk must not re-engage")
end
talk(game, ow, npc, function() end)
check(#pushed == 1 and pushed[1].pages ~= nil,
c.map .. " defeated re-talk shows after text")
end
S.finish()
+3 -53
View File
@@ -88,12 +88,8 @@ local SaveData = require("src.core.SaveData")
local stack2 = newStack()
local game2 = { data = Data, input = fakeInput, stack = stack2,
save = SaveData.newGame() }
-- battered party: record_hall_of_fame must heal before the autosave (#103)
game2.save.party = { { species = "PIKACHU", level = 81, hp = 1,
stats = { hp = 100 }, status = "PAR",
moves = { { id = "THUNDERBOLT", pp = 0 } } } }
game2.save.party = { { species = "PIKACHU", level = 81 } }
game2.save.player.map = "HALL_OF_FAME"
game2.save.lastOutdoor = { id = "INDIGO_PLATEAU", x = 9, y = 5 }
local wrote = false
function game2:writeSave() wrote = true; SaveData.save(self.save) end
@@ -113,22 +109,6 @@ eq(#game2.save.hallOfFame, 1, "winning team recorded (SaveHallOfFameTeams)")
local HallOfFame = require("src.ui.HallOfFame")
check(getmetatable(stack2:top()) == HallOfFame, "induction showcase pushed")
-- Gen1 layout (issue #102): pic rests at hlcoord (12,5); mon phase starts
-- with the LEVEL/TYPE info box (not a top "HALL OF FAME" banner alone)
local hofUi = stack2:top()
eq(hofUi.phase, "mons", "induction opens on the mon showcase phase")
eq(hofUi.scrollX < 12 * 8, true, "front pic starts off-screen left of (12,5)")
-- drive past the scroll so the info box is armed
local scrollGuard = 0
while hofUi.scrollX < 12 * 8 and scrollGuard < 200 do
scrollGuard = scrollGuard + 1
hofUi:update(1 / 60)
end
eq(hofUi.scrollX, 12 * 8, "front pic settles at hlcoord (12,5)")
eq(hofUi.showHofBanner, false, "bottom HALL OF FAME banner waits for the 80-frame hold")
check(hofUi.timer == 80 or hofUi.timer < 80,
"info hold uses the pokered 80 DelayFrames window")
-- drive induction + full credits with A held (pages are unskippable; A
-- only advances the induction and the final THE END wait)
pressed.a = true
@@ -149,39 +129,9 @@ local saved = savedRaw and SaveData.decode(savedRaw) or nil
check(saved ~= nil, "save.lua written and decodable")
eq(saved and saved.lastHeal and saved.lastHeal.map, "PALLET_TOWN",
"wLastBlackoutMap := PALLET_TOWN before the save")
eq(saved and saved.player and saved.player.map, "REDS_HOUSE_2F",
"CONTINUE lands in the bedroom (post-game home, #103)")
eq(saved and saved.player and saved.player.x, 3, "bedroom spawn x")
eq(saved and saved.player and saved.player.y, 6, "bedroom spawn y")
eq(saved and saved.lastOutdoor and saved.lastOutdoor.id, "PALLET_TOWN",
"LAST_MAP exits aim at Pallet Town, not Indigo")
check(saved and saved.postGameHomeOk, "postGameHomeOk marks the relocate done")
eq(saved and saved.player and saved.player.map, "HALL_OF_FAME",
"save keeps the player in the HALL_OF_FAME room")
eq(saved and #(saved.hallOfFame or {}), 1, "hall of fame team persisted")
local healed = saved and saved.party and saved.party[1]
local thunderPp = Data.moves and Data.moves.THUNDERBOLT and Data.moves.THUNDERBOLT.pp
check(healed and healed.hp == healed.stats.hp and healed.status == nil
and healed.moves[1].pp == thunderPp,
"party is fully healed in the post-credits save")
-- house exit mats resolve to Pallet's door, not Indigo
local Warp = require("src.world.Warp")
local houseExit = { destMap = "LAST_MAP", destWarp = 1, x = 2, y = 7 }
local destMap, dx, dy = Warp.destination(Data, houseExit, saved.lastOutdoor)
eq(destMap, "PALLET_TOWN", "Red's house LAST_MAP -> PALLET_TOWN")
eq(dx, 5, "Pallet door x")
eq(dy, 5, "Pallet door y")
-- stuck-save rescue: HoF + hallOfFame + no postGameHomeOk -> bedroom
local stuck = SaveData.newGame()
stuck.player.map = "HALL_OF_FAME"
stuck.player.x, stuck.player.y = 4, 2
stuck.lastOutdoor = { id = "INDIGO_PLATEAU", x = 9, y = 5 }
stuck.hallOfFame = { { { species = "PIKACHU", level = 81 } } }
check(SaveData.needsPostGameRescue(stuck), "pre-fix softlock save needs rescue")
SaveData.applyPostGameHome(stuck, Data.field.boot)
check(not SaveData.needsPostGameRescue(stuck), "rescue is one-shot")
eq(stuck.player.map, "REDS_HOUSE_2F", "rescue warps home")
eq(stuck.lastOutdoor.id, "PALLET_TOWN", "rescue retargets LAST_MAP")
-- `jp Init`: everything popped, boot sequence pushed (intro -> title)
eq(#stack2.states, 1, "soft reset leaves exactly the boot state")
-141
View File
@@ -1,141 +0,0 @@
-- Parity test: Lance room walk-in stays on the floor, and defeat dialogue
-- includes the rival-became-champion after-battle text.
--
-- Sources: scripts/LancesRoom.asm (WalkToLance / LancesRoomLanceEndBattleScript),
-- text/LancesRoom.asm (_LancesRoomLanceAfterBattleText).
-- Self-contained: run via `luajit tests/parity_lance.lua`; also dofile'd
-- by tests/run_tests.lua's aggregator.
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.LANCES_ROOM) then Data:load() end
local Font = require("src.render.Font")
if not pcall(Font.encode, "A") then Font.load(Data) end
local S = require("tests.harness").suite("parity lance")
local check, eq = S.check, S.eq
local mapScripts = require("data.scripts.init")
local hooks = mapScripts.get("LANCES_ROOM")
check(hooks and hooks.walkInRoute, "LANCES_ROOM exposes walkInRoute")
local D = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } }
-- === (1) walk-in RLE lands on the door-lock trigger (6,11) ===
do
local x, y = 24, 16
local steps = 0
for _, seg in ipairs(hooks.walkInRoute) do
local d = D[seg[1]]
check(d ~= nil, "walk segment direction " .. tostring(seg[1]))
for _ = 1, seg[2] do
x, y = x + d[1], y + d[2]
steps = steps + 1
end
end
eq(x, 6, "walk-in ends at x=6")
eq(y, 11, "walk-in ends at y=11 (door-lock / arena entry)")
check(steps > 0, "walk-in has steps")
end
-- === (2) every cell of the route is walkable with the entrance open ===
do
local Map = require("src.world.Map")
local def = Data.maps.LANCES_ROOM
local tileset = Data.tilesets[def.tileset]
-- mutate a copy of the block list so we don't poison the registry
local blocks = {}
for i, b in ipairs(def.blocks) do blocks[i] = b end
local copy = {}
for k, v in pairs(def) do copy[k] = v end
copy.blocks = blocks
-- LanceShowOrHideEntranceBlocks with the door unlocked
blocks[6 * def.width + 2 + 1] = 0x31
blocks[6 * def.width + 3 + 1] = 0x32
local map = Map.new(copy, tileset)
local x, y = 24, 16
check(map:isWalkableCell(x, y), "start (24,16) walkable")
for _, seg in ipairs(hooks.walkInRoute) do
local d = D[seg[1]]
for _ = 1, seg[2] do
x, y = x + d[1], y + d[2]
check(map:isWalkableCell(x, y),
("walk-in cell (%d,%d) is floor, not void/wall"):format(x, y))
end
end
end
-- === (3) after-battle text names the rival as the real champion ===
do
local after = Data.text._LancesRoomLanceAfterBattleText
check(after ~= nil, "_LancesRoomLanceAfterBattleText extracted")
check(after:find("ELITE", 1, true) or after:find("{RIVAL}", 1, true),
"after text mentions rival / Elite Four")
check(after:find("champion", 1, true) or after:find("CHAMPION", 1, true),
"after text has the champion reveal")
check(after:find("before you", 1, true) or after:find("before you!", 1, true)
or after:find("FOUR before", 1, true),
"after text says rival beat the Elite Four first")
local header = Data:trainerHeader("LancesRoom", 1)
check(header and header.after == "_LancesRoomLanceAfterBattleText",
"trainer header wires the after-battle label")
check(header and header.won == "_LancesRoomLanceEndBattleText",
"trainer header wires the won label")
end
-- === (4) onStep win callback pushes the after text (not only won text) ===
do
local pushed = {}
local game = {
data = Data,
save = {
flags = {},
defeatedTrainers = {},
player = { name = "RED", rival = "BLUE" },
},
stack = {
push = function(_, state)
pushed[#pushed + 1] = state
end,
},
}
local lance = {
def = { name = "LANCESROOM_LANCE", index = 1,
trainerClass = "OPP_LANCE", trainerParty = 1, text = 1 },
id = "LANCES_ROOM:1",
facePlayer = function() end,
}
local engaged = false
local ow = {
npcs = { lance },
player = { cellX = 6, cellY = 2 },
trainerDefeated = function(_, npc)
return game.save.defeatedTrainers[npc.id] == true
end,
engageTrainer = function(_, npc, onDone)
engaged = npc == lance
-- simulate engageTrainer's win path: flag the trainer, then onDone
game.save.defeatedTrainers[npc.id] = true
game.save.flags.EVENT_BEAT_LANCES_ROOM_TRAINER_0 = true
if onDone then onDone() end
end,
}
local handled = hooks.onStep(game, ow, 6, 2)
check(handled == true, "Lance coord trigger engages")
check(engaged, "engageTrainer called for Lance")
check(#pushed == 1 and pushed[1].pages ~= nil, "after-battle TextBox pushed")
-- TextBox.substitute already expanded {RIVAL}/{PLAYER}; page glyphs
-- are opaque, so re-check the source label via the header + that a
-- box was queued on win only.
check(#pushed[1].pages > 0, "after-battle TextBox has pages")
-- loss path must not show after text
pushed = {}
game.save.defeatedTrainers = {}
ow.engageTrainer = function(_, npc, onDone)
-- lose: do not mark defeated
if onDone then onDone() end
end
hooks.onStep(game, ow, 6, 2)
eq(#pushed, 0, "loss does not push after-battle text")
end
print("parity_lance: ok")
-129
View File
@@ -1,129 +0,0 @@
-- Parity: Rocket Hideout B4F Lift Key drop (#90 / #105).
--
-- Oracle: scripts/RocketHideoutB4F.asm RocketHideoutB4FRocket3AfterBattleText
-- (PrintText + CheckAndSetEvent EVENT_ROCKET_DROPPED_LIFT_KEY / ShowObject
-- TOGGLE_ROCKET_HIDEOUT_B4F_ITEM_5). The LIFT_KEY object_event starts
-- hidden; talking to Rocket3 after his defeat reveals it.
--
-- Self-contained: `luajit tests/parity_lift_key.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 Data = require("src.core.Data")
if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end
local S = require("tests.harness").suite("parity lift key")
local check, eq = S.check, S.eq
local mapScripts = require("data.scripts.init")
local OverworldState = require("src.world.OverworldController")
local liftKeyObj
for _, o in ipairs(Data.maps.ROCKET_HIDEOUT_B4F.objects) do
if o.name == "ROCKETHIDEOUTB4F_LIFT_KEY" then liftKeyObj = o break end
end
check(liftKeyObj and liftKeyObj.hidden == true,
"LIFT_KEY object_event is extracted as hidden")
local script = mapScripts.talkScript("ROCKET_HIDEOUT_B4F",
"TEXT_ROCKETHIDEOUTB4F_ROCKET3")
check(type(script) == "function",
"Rocket3 after-battle drop is a hand-ported talk handler")
-- Capture TextBox payloads without driving the typewriter / font path.
local realTB = package.loaded["src.render.TextBox"]
package.loaded["src.render.TextBox"] = {
new = function(game, text, done) return { text = text, onDone = done } end,
}
-- Drive the defeated-talk path headless: show after text, then ShowObject.
do
local pushed, engaged
local save = {
flags = {},
inventory = {},
defeatedTrainers = { ROCKET_HIDEOUT_B4F_obj_4 = true },
objectToggles = {},
}
local game = {
data = Data,
save = save,
stack = {
push = function(_, box) pushed = box end,
},
}
local ow = {
map = { id = "ROCKET_HIDEOUT_B4F", def = Data.maps.ROCKET_HIDEOUT_B4F },
npcs = {},
entities = {},
trainerDefeated = function(_, npc)
return save.defeatedTrainers[npc.id] == true
end,
engageTrainer = function() engaged = true end,
}
local npc = {
id = "ROCKET_HIDEOUT_B4F_obj_4",
def = { name = "ROCKETHIDEOUTB4F_ROCKET3", index = 4,
text = "TEXT_ROCKETHIDEOUTB4F_ROCKET3",
trainerClass = "OPP_ROCKET", trainerParty = 18 },
}
local doneCalled = false
script(game, ow, npc, function() doneCalled = true end)
check(not engaged, "defeated Rocket3 talk does not re-engage battle")
check(pushed ~= nil, "defeated Rocket3 talk pushes the after-battle text")
eq(pushed and pushed.text, Data.text._RocketHideoutB4FRocket3AfterBattleText,
"after-battle text is the dropped-key line")
check(not save.flags.EVENT_ROCKET_DROPPED_LIFT_KEY,
"drop flag unset until the text box closes")
check(not OverworldState.objectVisible(save, "ROCKET_HIDEOUT_B4F", liftKeyObj),
"LIFT_KEY still hidden before the after-battle text finishes")
-- dismiss the box: CheckAndSetEvent + ShowObject
pushed.onDone()
check(doneCalled, "talk done() runs after ShowObject")
check(save.flags.EVENT_ROCKET_DROPPED_LIFT_KEY == true,
"EVENT_ROCKET_DROPPED_LIFT_KEY set on first after-battle talk")
check(save.objectToggles.ROCKET_HIDEOUT_B4F
and save.objectToggles.ROCKET_HIDEOUT_B4F.ROCKETHIDEOUTB4F_LIFT_KEY == true,
"ShowObject toggles ROCKETHIDEOUTB4F_LIFT_KEY visible")
check(OverworldState.objectVisible(save, "ROCKET_HIDEOUT_B4F", liftKeyObj),
"LIFT_KEY objectVisible after ShowObject")
-- second talk: reprint only, no double-toggle churn
pushed, doneCalled = nil, false
local togglesBefore = save.objectToggles.ROCKET_HIDEOUT_B4F.ROCKETHIDEOUTB4F_LIFT_KEY
script(game, ow, npc, function() doneCalled = true end)
check(pushed ~= nil, "second after-battle talk still shows text")
pushed.onDone()
check(doneCalled, "second talk done() runs")
eq(save.objectToggles.ROCKET_HIDEOUT_B4F.ROCKETHIDEOUTB4F_LIFT_KEY, togglesBefore,
"second talk does not re-ShowObject")
end
-- Undefeated path still engages the trainer (TalkToTrainer).
do
local engaged = false
local save = { flags = {}, inventory = {}, defeatedTrainers = {} }
local game = {
data = Data, save = save,
stack = { push = function() end },
}
local ow = {
map = { id = "ROCKET_HIDEOUT_B4F" },
trainerDefeated = function() return false end,
engageTrainer = function(_, npc, done)
engaged = true
if done then done() end
end,
}
local npc = {
id = "ROCKET_HIDEOUT_B4F_obj_4",
def = { name = "ROCKETHIDEOUTB4F_ROCKET3", index = 4 },
}
script(game, ow, npc, function() end)
check(engaged, "undefeated Rocket3 talk engages the trainer battle")
end
package.loaded["src.render.TextBox"] = realTB
S.finish()
-100
View File
@@ -1,100 +0,0 @@
-- Parity test: Mom heal rest (#113) and Silph Co. 9F nurse fade.
--
-- pokered RedsHouse1FMomText / RedsHouse1FMomHealScript:
-- pre-starter -> WakeUpText; else YouShouldRest -> GBFadeOutToWhite ->
-- HealParty -> MUSIC_PKMN_HEALED (wait) -> GBFadeInFromWhite ->
-- LookingGreatText.
-- SilphCo9FNurseText: heal then white fade / Delay3 / fade in (no jingle).
--
-- Self-contained; run via `luajit tests/parity_mom_heal.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 mom heal")
local check, eq = S.check, S.eq
local reds = dofile("data/scripts/reds_house.lua")
local silph = dofile("data/scripts/flavor/silph_co_9f.lua")
local function cmds(rows)
local out = {}
for _, row in ipairs(rows) do out[#out + 1] = row[1] end
return out
end
local function find(rows, 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
-- --- Mom: pre-starter branch + heal rest sequence
do
local rows = reds.talk.TEXT_REDSHOUSE1F_MOM
check(rows ~= nil, "Mom talk script exists")
local iFlag = find(rows, "check_flag", "EVENT_GOT_STARTER")
check(iFlag, "Mom checks EVENT_GOT_STARTER")
local iWake = find(rows, "show_text", "_RedsHouse1FMomWakeUpText")
check(iWake and iWake > iFlag, "WakeUpText follows the starter check")
local iRest = find(rows, "show_text", "_RedsHouse1FMomYouShouldRestText")
check(iRest and iRest > iWake, "heal path is after the wake-up branch")
local iFadeOut = find(rows, "fade", "out")
local iHeal = find(rows, "heal_party")
local iJingle = find(rows, "play_once", "Music_PkmnHealed")
local iFadeIn = find(rows, "fade", "in")
local iGreat = find(rows, "show_text", "_RedsHouse1FMomLookingGreatText")
check(iFadeOut and rows[iFadeOut][3] == "white",
"Mom fades out to white after rest text")
check(iHeal and iHeal > iFadeOut, "heal_party runs under the white fade")
check(iJingle and iJingle > iHeal,
"Music_PkmnHealed plays after heal_party")
check(iFadeIn and rows[iFadeIn][3] == "white" and iFadeIn > iJingle,
"Mom fades in from white after the jingle")
check(iGreat and iGreat > iFadeIn,
"LookingGreatText follows the fade-in")
-- jump_if_true must land on the heal path (rest text), not wake-up
local jumpRow
for _, row in ipairs(rows) do
if row[1] == "jump_if_true" then jumpRow = row break end
end
check(jumpRow, "Mom has jump_if_true after starter check")
eq(rows[jumpRow[2]][2], "_RedsHouse1FMomYouShouldRestText",
"EVENT_GOT_STARTER jumps to the heal rest text")
end
-- --- Silph Co. 9F nurse: white fade, no heal jingle
do
local rows = silph.SILPH_CO_9F.talk.TEXT_SILPHCO9F_NURSE
check(rows ~= nil, "Silph nurse talk script exists")
local iHeal = find(rows, "heal_party")
local iFadeOut = find(rows, "fade", "out")
local iWait = find(rows, "wait")
local iFadeIn = find(rows, "fade", "in")
check(iHeal, "Silph nurse heals the party")
check(iFadeOut and rows[iFadeOut][3] == "white" and iFadeOut > iHeal,
"Silph nurse fades out to white after heal (pokered order)")
check(iWait and rows[iWait][2] == 3 and iWait > iFadeOut,
"Silph nurse Delay3 between fades")
check(iFadeIn and rows[iFadeIn][3] == "white" and iFadeIn > iWait,
"Silph nurse fades in from white")
check(not find(rows, "play_once", "Music_PkmnHealed"),
"Silph nurse does not play Music_PkmnHealed")
local sequence = table.concat(cmds(rows), ",")
check(sequence:find("heal_party,fade,wait,fade,show_text", 1, true),
"Silph heal rest sequence is heal → fade out → wait → fade in → text")
end
-- --- play_once is a blocking command (Mom / captain wait loops)
do
local Commands = require("src.script.Commands")
check(Commands.meta.play_once and Commands.meta.play_once.blocking,
"play_once is marked blocking so heal jingles wait to finish")
end
S.finish()
-117
View File
@@ -1,117 +0,0 @@
-- Parity test: Pewter City youngster gym escort paths.
-- Self-contained: run via `luajit tests/parity_pewter_escort.lua`; also
-- dofile'd by tests/run_tests.lua's aggregator.
--
-- Sources: scripts/PewterCity.asm, engine/overworld/auto_movement.asm
-- (RLEList_PewterGymPlayer / RLEList_PewterGymGuy),
-- engine/events/pewter_guys.asm (PewterGymGuyCoords).
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.PEWTER_CITY) then Data:load() end
local S = require("tests.harness").suite("parity pewter escort")
local check, eq = S.check, S.eq
local mapScripts = require("data.scripts.init")
local pewter = mapScripts.get("PEWTER_CITY")
check(pewter and pewter.escort, "PEWTER_CITY exposes the escort tables")
local escort = pewter.escort
local function joined(t) return table.concat(t, ",") end
local D = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } }
eq(#escort.guySteps, 41, "youngster takes 41 steps (RLEList_PewterGymGuy)")
eq(joined({ escort.guySteps[1], escort.guySteps[2], escort.guySteps[#escort.guySteps] }),
"down,down,right", "guy path starts with DOWN×2 and ends RIGHT")
eq(#escort.guyReturnSteps, 41, "return path mirrors the escort")
do
local opp = { up = "down", down = "up", left = "right", right = "left" }
eq(escort.guyReturnSteps[1], opp[escort.guySteps[#escort.guySteps]],
"return starts with opposite of escort's last step")
local gx, gy = 12, 18
for _, d in ipairs(escort.guyReturnSteps) do
gx, gy = gx + D[d][1], gy + D[d][2]
end
check(gx == 35 and gy == 16,
"return path from the gym lands on his spawn (35,16)")
end
-- every east-exit / talk tile lands both sprites by the gym road
local triggers = { { 35, 17 }, { 36, 17 }, { 37, 18 }, { 37, 19 }, { 34, 16 } }
for _, pos in ipairs(triggers) do
local px, py = pos[1], pos[2]
local plan = escort.playerPlan(px, py)
check(plan, ("playerPlan for (%d,%d)"):format(px, py))
local gx, gy = 35, 16
local gi = 0
for _ = 1, plan.guyHeadStart do
gi = gi + 1
local d = escort.guySteps[gi]
gx, gy = gx + D[d][1], gy + D[d][2]
end
for i, ps in ipairs(plan.steps) do
px, py = px + D[ps][1], py + D[ps][2]
local gs = escort.guySteps[plan.guyHeadStart + i]
if gs then
gx, gy = gx + D[gs][1], gy + D[gs][2]
end
end
eq(px, 11, ("player from (%d,%d) ends at x=11"):format(pos[1], pos[2]))
eq(py, 18, ("player from (%d,%d) ends at y=18"):format(pos[1], pos[2]))
eq(gx, 12, ("guy from (%d,%d) ends at x=12"):format(pos[1], pos[2]))
eq(gy, 18, ("guy from (%d,%d) ends at y=18"):format(pos[1], pos[2]))
end
-- the short east-road trigger uses no head-start; the (36,17)/(37,18)
-- tiles pause for one guy step (eight NO_INPUT frames)
eq(escort.playerPlan(35, 17).guyHeadStart, 0, "(35,17) no head-start")
eq(escort.playerPlan(36, 17).guyHeadStart, 1, "(36,17) one-step head-start")
eq(escort.playerPlan(37, 18).guyHeadStart, 1, "(37,18) one-step head-start")
-- path stays off the PEWTER_GYM door warp and the blocked gym building
-- cells: sample the (35,17) walk and confirm no step lands on (16,17)
do
local plan = escort.playerPlan(35, 17)
local px, py = 35, 17
local gx, gy = 35, 16
for i, ps in ipairs(plan.steps) do
px, py = px + D[ps][1], py + D[ps][2]
local gs = escort.guySteps[i]
if gs then gx, gy = gx + D[gs][1], gy + D[gs][2] end
check(not (px == 16 and py == 17),
("player beat %d does not phase onto gym door"):format(i))
check(not (gx == 16 and gy == 17),
("guy beat %d does not phase onto gym door"):format(i))
end
end
-- gym door warp is still where the player is meant to walk afterward
local MapLoader = require("src.world.MapLoader")
local city = MapLoader.load(Data, "PEWTER_CITY")
local w = city:warpAtCell(16, 17)
check(w and w.def.destMap == "PEWTER_GYM", "(16,17) is the Pewter Gym door")
-- object spawn matches pokered object_event 35, 16
local young
for _, o in ipairs(Data.maps.PEWTER_CITY.objects) do
if o.name == "PEWTERCITY_YOUNGSTER" then young = o; break end
end
check(young and young.x == 35 and young.y == 16,
"PEWTERCITY_YOUNGSTER spawns at (35,16)")
-- Brock victory permanently HideObject's him (PewterGym.asm .gymVictory)
do
local victories = require("data.scripts.victories")
local hide = victories["OPP_BROCK#1"] and victories["OPP_BROCK#1"].hide
check(hide, "OPP_BROCK#1 lists objects to hide")
local found
for _, entry in ipairs(hide or {}) do
if entry[1] == "PEWTER_CITY" and entry[2] == "PEWTERCITY_YOUNGSTER" then
found = true
end
end
check(found, "Brock victory hides PEWTERCITY_YOUNGSTER")
end
S.finish()
-57
View File
@@ -1,57 +0,0 @@
-- Parity test: Red's bedroom SNES (#135).
--
-- pokered hidden_events.asm REDS_HOUSE_2F:
-- hidden_event 3, 5, PrintRedSNESText, ANY_FACING
-- PrintRedSNESText shows _RedBedroomSNESText ("{PLAYER} is playing the SNES!").
--
-- Self-contained; run via `luajit tests/parity_red_snes.lua`.
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.REDS_HOUSE_2F) then Data:load() end
local Font = require("src.render.Font")
if not pcall(Font.encode, "A") then Font.load(Data) end
require("data.scripts.init")
local MapScripts = require("src.script.MapScripts")
local S = require("tests.harness").suite("parity red snes")
local check, eq = S.check, S.eq
check(Data.text._RedBedroomSNESText ~= nil, "_RedBedroomSNESText is extracted")
check(Data.text._RedBedroomSNESText:find("SNES", 1, true),
"_RedBedroomSNESText mentions the SNES")
local hooks = MapScripts.get("REDS_HOUSE_2F")
check(hooks and type(hooks.onInteract) == "function",
"REDS_HOUSE_2F registers onInteract for the SNES")
-- stub just enough of the overworld/game stack to exercise the hook
local pushed
local game = {
data = Data,
save = { player = { name = "RED", rival = "BLUE" } },
stack = {
push = function(_, state) pushed = state end,
},
}
local ow = { player = { facing = "up" } }
eq(hooks.onInteract(game, ow, 0, 1), false,
"bedroom PC tile is not claimed by the SNES hook")
eq(hooks.onInteract(game, ow, 3, 6), false,
"spawn tile is not claimed by the SNES hook")
pushed = nil
eq(hooks.onInteract(game, ow, 3, 5), true,
"SNES at (3,5) consumes the interact")
check(pushed ~= nil and pushed.pages ~= nil, "SNES interact pushes a TextBox")
local flat = table.concat(pushed.pages[1] or {}, "\n")
check(flat:find("SNES", 1, true) and flat:find("RED", 1, true),
"SNES TextBox shows the bedroom SNES line with the player name")
-- ANY_FACING: facing is irrelevant once the faced cell is (3,5)
ow.player.facing = "left"
pushed = nil
eq(hooks.onInteract(game, ow, 3, 5), true,
"SNES still fires when the player faces it from the side")
S.finish()
-77
View File
@@ -1,77 +0,0 @@
-- Regression: map-connection seam steps must show walk frames (issue #93).
--
-- A hitch inside setMap (neighbor rebuild / map-song start) made the next
-- real-time dt huge; FixedStep catch-up then advanced many walk frames
-- before the next draw, which looked like a slide with no leg animation.
-- crossConnection now discards that catch-up, starts a fresh animClock,
-- and defers PlayMapMusic until the seam step lands.
--
-- Self-contained; run via `luajit tests/parity_seam_walk_anim.lua`.
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 FixedStep = require("src.core.FixedStep")
local Game = require("src.core.Game")
local Input = require("src.core.Input")
local Music = require("src.core.Music")
local Renderer = require("src.render.Renderer")
local SaveData = require("src.core.SaveData")
local StateStack = require("src.core.StateStack")
local OW = require("src.world.OverworldController")
local S = require("tests.harness").suite("parity seam walk anim")
local check, eq = S.check, S.eq
Game.data = Data
Game.input = Input; Input:init()
Game.renderer = Renderer; Renderer:init()
Game.stack = StateStack
StateStack:init()
Game.save = SaveData.newGame()
Game.overworld = OW
while Game.stack:top() do Game.stack:pop() end
Game.stack:push(OW, "PALLET_TOWN", 10, 0, "up")
local ow = Game.stack:top()
local north = ow.map:connection("north")
check(north and north.map == "ROUTE_1", "Pallet north connects to ROUTE_1")
-- simulate a post-hitch catch-up budget waiting in the accumulator
FixedStep.accum = 0.24
local played = {}
local realPlayMap = Music.playMap
Music.playMap = function(data, mapId, onBike, surfing)
played[#played + 1] = mapId
return realPlayMap(data, mapId, onBike, surfing)
end
check(ow:crossConnection("up", north) == true, "Pallet -> Route 1 crosses")
eq(ow.map.id, "ROUTE_1", "landed on ROUTE_1")
eq(FixedStep.accum, 0, "seam cross discards FixedStep catch-up")
eq(ow.pendingSeamMusic, "ROUTE_1", "map music deferred across the seam step")
eq(#played, 0, "PlayMapMusic not called inside setMap for the seam")
eq(ow.player.animClock, 0, "seam step starts a fresh walk-cycle clock")
check(ow.player.moving, "seam step is in progress")
local sawWalk = false
local phases = {}
for _ = 1, 20 do
ow:update(1 / 60)
phases[#phases + 1] = ow.player:walkPhase()
if ow.player:walkPhase() == 1 then sawWalk = true end
if not ow.player.moving and not ow.pendingSeamMusic then break end
end
check(sawWalk, "seam step shows at least one walk frame")
-- mid-cycle: frames 4..11 of a fresh animClock are walk
local midWalk = false
for i = 4, 11 do
if phases[i] == 1 then midWalk = true break end
end
check(midWalk, "fresh animClock puts walk frames in the middle of the seam step")
eq(ow.pendingSeamMusic, nil, "deferred music flushed after the seam step")
eq(played[1], "ROUTE_1", "PlayMapMusic runs once the seam step lands")
Music.playMap = realPlayMap
S.finish()
-199
View File
@@ -1,199 +0,0 @@
-- Parity test: Vermilion City S.S. Anne sailor (#122).
--
-- pokered VermilionCityDefaultScript (scripts/VermilionCity.asm):
-- stepping onto SSAnneTicketCheckCoords (18,30) facing down always
-- DisplayTextID's the sailor. With a ticket and the ship still docked,
-- FlashedTicket plays and the player may continue; without a ticket, or
-- after EVENT_SS_ANNE_LEFT, they are walked back up.
--
-- The port used to early-return when the ticket was in the bag, skipping
-- the flash dialog entirely.
--
-- Self-contained; run via `luajit tests/parity_ss_anne_guard.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 ss anne guard")
local check, eq = S.check, S.eq
package.loaded["src.render.TextBox"] = {
new = function(_, text, done) return { text = text, done = done } end,
}
local musicCalls = {}
package.loaded["src.core.Music"] = {
stop = function() musicCalls[#musicCalls + 1] = { "stop" } end,
play = function(_, song)
musicCalls[#musicCalls + 1] = { "play", song }
end,
playOnce = function(_, song)
musicCalls[#musicCalls + 1] = { "playOnce", song }
return true
end,
}
local soundCalls = {}
package.loaded["src.core.Sound"] = {
play = function(_, id) soundCalls[#soundCalls + 1] = id end,
}
local story = dofile("data/scripts/story.lua")
local story3 = dofile("data/scripts/story3.lua")
local Flags = require("src.script.Flags")
local function gameWith(opts)
local pushed, moved = {}, {}
local game = {
save = {
inventory = opts.inventory or {},
flags = opts.flags or {},
},
data = {
text = {
_VermilionCitySailor1DoYouHaveATicketText =
"Welcome to S.S.\nANNE!\fExcuse me, do you\nhave a ticket?",
_VermilionCitySailor1FlashedTicketText =
"PLAYER flashed\nthe S.S.TICKET!",
_VermilionCitySailor1YouNeedATicketText =
"You need a ticket\nto get aboard.",
_VermilionCitySailor1ShipSetSailText = "The ship set sail.",
_SSAnneCaptainsRoomRubCaptainsBackText = "Rub-rub...",
_SSAnneCaptainsRoomCaptainIFeelMuchBetterText = "I feel better!",
_SSAnneCaptainsRoomCaptainReceivedHM01Text = "Got HM01!",
_SSAnneCaptainsRoomCaptainNotSickAnymoreText = "Not sick.",
},
},
stack = { push = function(_, box) pushed[#pushed + 1] = box end },
_pushed = pushed,
_moved = moved,
}
return game, pushed, moved
end
local function owWith(moved)
return {
player = { facing = "down", cellY = 2 },
scriptMove = function(_, _, dir, n) moved[#moved + 1] = { dir, n } end,
queueScript = function(self, rows) self._queued = rows end,
startDustAnim = function() end,
map = {
setBlock = function() end,
renderer = { rebuild = function() end },
},
}
end
local city = story.VERMILION_CITY
check(city and city.onStep, "VERMILION_CITY has the sailor coord trigger")
-- With ticket, ship still docked: flash dialog, do NOT walk back.
do
local game, pushed, moved = gameWith({ inventory = { S_S_TICKET = 1 } })
local ow = owWith(moved)
check(city.onStep(game, ow, 18, 30), "ticket: walk-past trigger consumes the step")
check(#pushed == 1, "ticket: auto dialog is shown")
check(pushed[1].text:find("flashed", 1, true)
or pushed[1].text:find("ticket", 1, true),
"ticket: flash / ticket-check dialog text")
check(#moved == 0, "ticket: player is not walked back")
end
-- Without ticket: dialog + walk back.
do
local game, pushed, moved = gameWith({})
local ow = owWith(moved)
check(city.onStep(game, ow, 18, 30), "no ticket: trigger fires")
check(#pushed == 1, "no ticket: dialog shown")
if pushed[1] and pushed[1].done then pushed[1].done() end
check(#moved == 1 and moved[1][1] == "up",
"no ticket: walked back up after dialog")
end
-- After the ship leaves: ShipSetSail + walk back, even with a ticket.
do
local game, pushed, moved = gameWith({
inventory = { S_S_TICKET = 1 },
flags = { EVENT_SS_ANNE_LEFT = true },
})
local ow = owWith(moved)
check(city.onStep(game, ow, 18, 30), "ship left: trigger still fires")
eq(pushed[1] and pushed[1].text, "The ship set sail.",
"ship left: dialog updates to ShipSetSail")
if pushed[1] and pushed[1].done then pushed[1].done() end
check(#moved == 1 and moved[1][1] == "up",
"ship left: walked back up")
end
-- Off-tile / wrong facing: no trigger.
do
local game, pushed = gameWith({ inventory = { S_S_TICKET = 1 } })
local ow = owWith({})
eq(city.onStep(game, ow, 18, 29), false, "wrong Y: no trigger")
ow.player.facing = "up"
eq(city.onStep(game, ow, 18, 30), false, "facing up: no trigger")
check(#pushed == 0, "no spurious dialog off the check")
end
-- Sailor talk after ship left: ShipSetSail branch (row script).
do
local ScriptRunner = require("src.script.ScriptRunner")
local game = gameWith({ flags = { EVENT_SS_ANNE_LEFT = true } })
local rows = city.talk.TEXT_VERMILIONCITY_SAILOR1
local runner = ScriptRunner.new(game, nil)
runner:run(rows, {})
local guard = 0
while runner:isRunning() and guard < 200 do
guard = guard + 1
if game.stack and game._pushed then
local box = game._pushed[#game._pushed]
if box and box.done then box.done() end
end
runner:update()
end
local last = game._pushed[#game._pushed]
eq(last and last.text, "The ship set sail.",
"talk after ship left shows ShipSetSail")
end
-- Departure cutscene: Music_Surfing + EVENT_SS_ANNE_LEFT via Flags.set.
do
for i = #musicCalls, 1, -1 do musicCalls[i] = nil end
local game, _, moved = gameWith({ flags = { EVENT_GOT_HM01 = true } })
local ow = owWith(moved)
story3.VERMILION_DOCK.onEnter(game, ow)
check(Flags.get(game.save, "EVENT_SS_ANNE_LEFT"),
"departure sets EVENT_SS_ANNE_LEFT")
local sawSurf = false
for _, c in ipairs(musicCalls) do
if c[1] == "play" and c[2] == "Music_Surfing" then sawSurf = true end
end
check(sawSurf, "departure plays Music_Surfing")
check(ow._queued ~= nil, "departure queues the sail-away script")
local kept, horn = false, false
for _, row in ipairs(ow._queued or {}) do
if row[1] == "play_music" and row[2] == "Music_Surfing"
and row[3] and row[3].keep then
kept = true
end
if row[1] == "play_sound" and row[2] == "SS_Anne_Horn" then
horn = true
end
end
check(kept, "departure keeps Music_Surfing across the city warp")
check(horn, "departure queues SS_Anne_Horn")
end
-- Captain rub jingle: play_once Music_PkmnHealed sits after the rub text.
do
local rows = story.SS_ANNE_CAPTAINS_ROOM.talk.TEXT_SSANNECAPTAINSROOM_CAPTAIN
local found = false
for i, row in ipairs(rows) do
if row[1] == "play_once" and row[2] == "Music_PkmnHealed" then
found = true
check(rows[i - 1] and rows[i - 1][2] == "_SSAnneCaptainsRoomRubCaptainsBackText",
"play_once follows the rub-back text")
end
end
check(found, "captain script plays Music_PkmnHealed after the rub")
end
S.finish()
-117
View File
@@ -1,117 +0,0 @@
-- Parity: Oak's lab starter-ball Pokédex preview (#110).
-- pret StarterDex (engine/events/starter_dex.asm) temporarily sets the
-- owned bits so ShowPokedexData prints the full entry before the player
-- has caught anything. Also: English R/B prints only the kind string
-- (no " POKéMON" suffix — that clipped "LIZARD" to "LIZARD POKé").
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("parity starter dex")
local check, eq = S.check, S.eq
local Font = require("src.render.Font")
Font.load(Data)
local DexEntryMenu = require("src.ui.DexEntryMenu")
local SaveData = require("src.core.SaveData")
local mapScripts = require("data.scripts.init")
local function fakeGame()
return {
data = Data,
save = SaveData.newGame(),
input = { wasPressed = function() return false end },
stack = { pop = function() end },
}
end
local function drawCapture(menu)
local drawn = {}
local saved = Font.draw
Font.draw = function(text, x, y)
drawn[#drawn + 1] = { text = tostring(text), x = x, y = y }
return Font.width(text)
end
menu:draw()
Font.draw = saved
return drawn
end
local function findText(drawn, needle)
for _, d in ipairs(drawn) do
if d.text == needle or d.text:find(needle, 1, true) then return d end
end
return nil
end
-- === 1) unowned entry without forceOwned stays "Data unknown." ===
do
local game = fakeGame()
game.save.pokedex = { seen = {}, owned = {} }
local menu = DexEntryMenu.new(game, "CHARMANDER")
local drawn = drawCapture(menu)
check(findText(drawn, "Data unknown."),
"unowned Charmander shows Data unknown without forceOwned")
check(not findText(drawn, "Obviously prefers"),
"unowned Charmander hides description without forceOwned")
check(not findText(drawn, "HT "),
"unowned Charmander hides height without forceOwned")
end
-- === 2) forceOwned shows full entry without mutating save ===
do
local game = fakeGame()
game.save.pokedex = { seen = {}, owned = {} }
local menu = DexEntryMenu.new(game, { species = "CHARMANDER", forceOwned = true })
check(menu.forceOwned, "forceOwned flag sticks on the menu")
local drawn = drawCapture(menu)
check(findText(drawn, "Obviously prefers"),
"forceOwned Charmander shows dex description")
check(findText(drawn, "HT "),
"forceOwned Charmander shows height")
check(not findText(drawn, "Data unknown."),
"forceOwned Charmander does not show Data unknown")
check(not game.save.pokedex.owned.CHARMANDER,
"forceOwned preview does not mark Charmander owned")
end
-- === 3) kind is the bare English string (no POKéMON suffix) ===
do
local game = fakeGame()
game.save.pokedex = { seen = {}, owned = { CHARMANDER = true } }
local menu = DexEntryMenu.new(game, "CHARMANDER")
local drawn = drawCapture(menu)
local kind = findText(drawn, "LIZARD")
check(kind and kind.text == "LIZARD",
"kind draws as LIZARD only (English R/B PlaceString)")
check(not findText(drawn, "POKéMON"),
"kind line does not append POKéMON")
check(kind.x + Font.width(kind.text) <= 160,
"LIZARD kind fits on-screen (no clip)")
end
-- === 4) Oak's lab starter scripts request forceOwned ===
do
local balls = {
"TEXT_OAKSLAB_CHARMANDER_POKE_BALL",
"TEXT_OAKSLAB_SQUIRTLE_POKE_BALL",
"TEXT_OAKSLAB_BULBASAUR_POKE_BALL",
}
for _, textId in ipairs(balls) do
local script = mapScripts.talkScript("OAKS_LAB", textId)
local found
for _, row in ipairs(script) do
if row[1] == "push_screen" and row[2] == "DexEntryMenu" then
found = row[3]
break
end
end
check(type(found) == "table" and found.forceOwned == true
and type(found.species) == "string",
textId .. " pushes DexEntryMenu with forceOwned")
end
end
S.finish()
-125
View File
@@ -1,125 +0,0 @@
-- Parity: sleep/confusion onomatopoeia on status-check text
-- (core.asm CheckPlayerStatusConditions / CheckEnemyStatusConditions).
-- Self-contained: `luajit tests/parity_status_onomatopoeia.lua`; also
-- dofile'd by tests/run_tests.lua's parity_* aggregator.
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("parity status onomatopoeia")
local check, eq = S.check, S.eq
local Game = require("src.core.Game")
Game.data = Data
Game.save = require("src.core.SaveData").newGame()
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
local function freshBattle()
Game.save.party = { Pokemon.new(Data, "NIDOKING", 40) }
return BattleState.newWild(Game, "DEWGONG", 30)
end
-- Capture queue order of anim/text rows inserted via *Next helpers.
local function capture(battle)
local seq = {}
battle.nextInsert = 0
battle.queue = {}
battle.animNext = function(_, name, isPlayer)
seq[#seq + 1] = { kind = "anim", name = name, isPlayer = isPlayer }
end
battle.sayNext = function(_, text)
seq[#seq + 1] = { kind = "text", text = text }
end
return seq
end
-- --- sleep: player anim-before-text, enemy text-before-anim -------------
do
local b = freshBattle()
b.rng = function() return 255 end
local seq = capture(b)
b.player.mon.status = "SLP"
b.player.sleepTurns = 3
check(b:statusInterrupt(b.player, b.enemy) == true, "sleep interrupts the turn")
eq(#seq, 2, "sleep queues anim + text")
eq(seq[1].kind, "anim", "player sleep: SLP_PLAYER_ANIM before text")
eq(seq[1].name, "SLP_PLAYER_ANIM", "player sleep uses SLP_PLAYER_ANIM")
eq(seq[1].isPlayer, true, "player sleep anim faces the player")
check(seq[2].text:find("is fast asleep!", 1, true),
"player sleep text follows the anim")
end
do
local b = freshBattle()
b.rng = function() return 255 end
local seq = capture(b)
b.enemy.mon.status = "SLP"
b.enemy.sleepTurns = 3
check(b:statusInterrupt(b.enemy, b.player) == true, "enemy sleep interrupts")
eq(seq[1].kind, "text", "enemy sleep: FastAsleepText before anim")
check(seq[1].text:find("Enemy ", 1, true),
"enemy sleep text carries the Enemy prefix")
eq(seq[2].name, "SLP_ANIM", "enemy sleep uses SLP_ANIM (enemy Z coords)")
eq(seq[2].isPlayer, false, "enemy sleep anim faces the enemy")
end
do
local b = freshBattle()
local seq = capture(b)
b.player.mon.status = "SLP"
b.player.sleepTurns = 3
check(b:preRechargeChecks(b.player, b.enemy) == true,
"pre-recharge sleep still loses the turn")
eq(seq[1].name, "SLP_PLAYER_ANIM",
"pre-recharge sleep plays the same onomatopoeia")
end
-- --- confusion: text then CONF_*_ANIM (both sides) ---------------------
-- Status.beforeMove: rng(0,255) < 128 -> hurt itself; else can still move.
do
local b = freshBattle()
b.rng = function() return 200 end -- no self-hit
local seq = capture(b)
b.player.confusedTurns = 3
b.player.mon.status = nil
local stopped = b:statusInterrupt(b.player, b.enemy)
check(stopped == false, "confusion can still allow a move")
eq(seq[1].kind, "text", "player confusion: IsConfusedText before anim")
check(seq[1].text:find("is confused!", 1, true), "player confusion text")
eq(seq[2].name, "CONF_PLAYER_ANIM", "player confusion uses CONF_PLAYER_ANIM")
eq(seq[2].isPlayer, true, "player confusion anim faces the player")
end
do
local b = freshBattle()
b.rng = function() return 0 end -- self-hit
local seq = capture(b)
b.computeDamage = function() return 1 end
b.applyDamage = function() end
b.onFaint = function() end
b.enemy.confusedTurns = 3
b.enemy.mon.status = nil
local stopped = b:statusInterrupt(b.enemy, b.player)
check(stopped == true, "confusion self-hit interrupts")
eq(seq[1].kind, "text", "enemy confusion text first")
check(seq[1].text:find("is confused!", 1, true), "enemy confusion text")
eq(seq[2].name, "CONF_ANIM", "enemy confusion uses CONF_ANIM")
eq(seq[2].isPlayer, false, "enemy confusion anim faces the enemy")
check(seq[3] and seq[3].text:find("hurt itself", 1, true),
"hurt-itself text follows the confusion anim")
end
-- wake stays text-only (no onomatopoeia)
do
local b = freshBattle()
b.rng = function() return 0 end
local seq = capture(b)
b.player.mon.status = "SLP"
b.player.sleepTurns = 1
b:statusInterrupt(b.player, b.enemy)
eq(#seq, 1, "waking up is text-only")
check(seq[1].text:find("woke up!", 1, true), "wake text")
end
S.finish()
-138
View File
@@ -1,138 +0,0 @@
-- Parity test: Pokémon Tower 2F rival leaves after defeat (#111).
--
-- pokered PokemonTower2FDefeatedRivalScript (scripts/PokemonTower2F.asm):
-- after EVENT_BEAT_POKEMON_TOWER_RIVAL, walk RightThenDown or
-- DownThenRight (EVENT_POKEMON_TOWER_RIVAL_ON_LEFT when the player is on
-- (15,5)), then HideObject TOGGLE_POKEMON_TOWER_2F_RIVAL. The port used
-- to end after DefeatedText, so he stayed and HowsYourDex could re-fire.
--
-- Self-contained; run via `luajit tests/parity_tower_rival.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 tower rival")
local check, eq = S.check, S.eq
package.loaded["src.render.TextBox"] = {
new = function(_, text, done) return { text = text, done = done } end,
}
package.loaded["src.core.Music"] = {
play = function() end,
playOnce = function() return true end,
stop = function() end,
}
local story = dofile("data/scripts/story.lua")
local tower = story.POKEMON_TOWER_2F
check(tower ~= nil, "POKEMON_TOWER_2F map script exists")
check(type(tower.rivalScript) == "function", "rivalScript helper exported")
check(type(tower.onStep) == "function", "onStep ambush exists")
check(type(tower.onEnter) == "function", "onEnter hide repair exists")
local RIGHT_THEN_DOWN =
{ "right", "down", "down", "right", "down", "down", "right", "right" }
local DOWN_THEN_RIGHT =
{ "down", "down", "right", "right", "right", "right", "down", "down" }
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
-- (1) both approach tiles pick the pokered exit movement
do
local below = tower.rivalScript(14)
local left = tower.rivalScript(15)
local wBelow, wLeft = findWalk(below), findWalk(left)
check(wBelow ~= nil, "player-below script has walk_npc")
check(wLeft ~= nil, "rival-on-left script has walk_npc")
eq(wBelow[2], 1, "walk targets object index 1 (rival)")
check(dirsEqual(wBelow[3], RIGHT_THEN_DOWN),
"player at x=14 uses RightThenDownMovement")
check(dirsEqual(wLeft[3], DOWN_THEN_RIGHT),
"player at x=15 uses DownThenRightMovement")
end
-- (2) win path: flag, defeat text, walk, hide -- in order
do
local rows = tower.rivalScript(14)
local preds = {
{ "set_flag EVENT_BEAT_POKEMON_TOWER_RIVAL",
function(r) return r[1] == "set_flag"
and r[2] == "EVENT_BEAT_POKEMON_TOWER_RIVAL" end },
{ "show_text DefeatedText",
function(r) return r[1] == "show_text"
and r[2] == "_PokemonTower2FRivalDefeatedText" end },
{ "walk_npc exit",
function(r) return r[1] == "walk_npc" end },
{ "hide_object POKEMONTOWER2F_RIVAL",
function(r) return r[1] == "hide_object"
and r[2] == "POKEMON_TOWER_2F"
and r[3] == "POKEMONTOWER2F_RIVAL" end },
}
local pi = 1
for _, r in ipairs(rows) do
if pi <= #preds and preds[pi][2](r) then pi = pi + 1 end
end
for i = 1, #preds do
check(i < pi, "rival script has, in order: " .. preds[i][1])
end
end
-- (3) onStep: only the encounter coords, and not after the beat flag
do
local ran
local game = { save = { flags = {} }, data = {} }
local ow = {
runner = {
isRunning = function() return false end,
run = function(_, rows) ran = rows end,
},
player = { facing = "down" },
npcByIndex = function() return { def = { name = "POKEMONTOWER2F_RIVAL" } } end,
}
check(tower.onStep(game, ow, 15, 5), "onStep fires on (15,5)")
check(ran ~= nil and findWalk(ran) ~= nil, "onStep runs exit-walk script")
check(dirsEqual(findWalk(ran)[3], DOWN_THEN_RIGHT),
"onStep (15,5) picks DownThenRight")
eq(ow.player.facing, "left", "onStep (15,5) faces the rival")
ran = nil
check(tower.onStep(game, ow, 14, 6), "onStep fires on (14,6)")
check(dirsEqual(findWalk(ran)[3], RIGHT_THEN_DOWN),
"onStep (14,6) picks RightThenDown")
check(not tower.onStep(game, ow, 14, 5), "onStep ignores rival's own tile")
game.save.flags.EVENT_BEAT_POKEMON_TOWER_RIVAL = true
check(not tower.onStep(game, ow, 15, 5), "beaten: onStep is inert")
end
-- (4) onEnter hides a stuck rival once the beat flag is set
do
local Commands = require("src.script.Commands")
local hidden = {}
local realHide = Commands.hide_object
Commands.hide_object = function(_, mapId, name)
hidden[#hidden + 1] = { mapId, name }
end
tower.onEnter({ save = { flags = {} } }, {})
eq(#hidden, 0, "onEnter: no hide while unbeaten")
tower.onEnter({
save = { flags = { EVENT_BEAT_POKEMON_TOWER_RIVAL = true } },
}, {})
eq(#hidden, 1, "onEnter: hide once beaten")
eq(hidden[1][1], "POKEMON_TOWER_2F", "onEnter hide map")
eq(hidden[1][2], "POKEMONTOWER2F_RIVAL", "onEnter hide object")
Commands.hide_object = realHide
end
S.finish()
+2 -7
View File
@@ -222,21 +222,16 @@ check(runScript("VERMILION_TRADE_HOUSE", "TEXT_VERMILIONTRADEHOUSE_LITTLE_GIRL")
"DUX post-trade script completes")
shownIs({ "_AfterTrade3Text" }, "DUX uses the happy dialogset")
-- === 9) Celadon Eevee: no confirm prompt, AskName then GotMonText,
-- ball hidden (GivePokemon -> AddPartyMon AskName; script still
-- prints GotMonText after the silent give_pokemon row) ===
-- === 9) Celadon Eevee: no confirm prompt, GotMonText, ball hidden ===
local EEVEE_MAP, EEVEE_BALL =
"CELADON_MANSION_ROOF_HOUSE", "CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL"
local EEVEE_TEXT = "TEXT_CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL"
Game.save = SaveData.newGame()
check(runScript(EEVEE_MAP, EEVEE_TEXT), "Eevee ball script completes")
shownIs({ "_DoYouWantToNicknameText", "_GotMonText" },
"Eevee gives immediately (nickname ask, then GotMonText)")
shownIs({ "_GotMonText" }, "Eevee gives immediately (no ask prompt)")
eq(#Game.save.party, 1, "Eevee joins the party")
eq(Game.save.party[1].species, "EEVEE", "gift species is EEVEE")
eq(Game.save.party[1].level, 25, "Eevee is level 25")
check(Game.save.party[1].nickname == "AAAAAAAAAA",
"Eevee nickname prompt accepted (A-mash NamingScreen)")
check(Flags.get(Game.save, "EVENT_GOT_EEVEE"), "EVENT_GOT_EEVEE bookkeeping set")
eq(toggleOf(EEVEE_MAP, EEVEE_BALL), false, "the poke ball object is hidden")
check(Game.save.pokedex.owned.EEVEE, "Eevee registered owned")
-63
View File
@@ -1,63 +0,0 @@
-- Parity test: Victory Road 3F hole at (23,15) dungeon-warps the player
-- to VICTORY_ROAD_2F (22,16).
--
-- scripts/VictoryRoad3F.asm VictoryRoad3FDefaultScript feeds
-- .SwitchOrHoleCoords into IsPlayerOnDungeonWarp with destination
-- VICTORY_ROAD_2F; data/maps/special_warps.asm DungeonWarpList entry
-- (VICTORY_ROAD_2F, 2) lands at DungeonWarpData (22, 16). The same cell
-- also drops a boulder (onBoulderMoved), but the player fall was missing
-- -- CAVERN $22 is walkable, so without onStep Red stood on the hole
-- (GitHub #86).
--
-- Self-contained; run via `luajit tests/parity_victory_road_hole.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 victory road hole")
local check, eq = S.check, S.eq
local M = dofile("data/scripts/story.lua")
local vr3 = M.VICTORY_ROAD_3F
check(vr3 ~= nil and vr3.onStep ~= nil, "VICTORY_ROAD_3F has an onStep hole trigger")
local function owRecording()
local warps = {}
return {
player = { facing = "right" },
startWarpTo = function(_, mapId, x, y, facing)
warps[#warps + 1] = { mapId = mapId, x = x, y = y, facing = facing }
end,
_warps = warps,
}, warps
end
-- Stepping onto the hole falls through to 2F at the dungeon-warp landing.
do
local ow, warps = owRecording()
local handled = vr3.onStep({}, ow, 23, 15)
check(handled, "stepping on (23,15) is consumed")
eq(#warps, 1, "exactly one dungeon warp fires")
eq(warps[1].mapId, "VICTORY_ROAD_2F", "destination is VICTORY_ROAD_2F")
eq(warps[1].x, 22, "lands at x=22")
eq(warps[1].y, 16, "lands at y=16")
eq(warps[1].facing, "right", "facing is preserved across the fall")
end
-- Any other cell is ignored (switch at 3,5 is boulder-only).
do
local ow, warps = owRecording()
eq(vr3.onStep({}, ow, 3, 5), false, "the switch cell does not dungeon-warp")
eq(vr3.onStep({}, ow, 22, 15), false, "a neighboring floor cell is ignored")
eq(#warps, 0, "no warp fires off the hole")
end
-- The hole collision tile stays walkable (fall, do not block).
local Data = require("src.core.Data")
if not (Data.maps and Data.maps.VICTORY_ROAD_3F) then Data:load() end
local MapLoader = require("src.world.MapLoader")
local map = MapLoader.load(Data, "VICTORY_ROAD_3F")
check(map:isWalkableCell(23, 15), "CAVERN hole tile at (23,15) is walkable")
eq(map:warpPadOrHoleAt(23, 15), "hole", "collision tile is the CAVERN hole ($22)")
check(map:warpAtCell(23, 15) == nil,
"the hole is a dungeon warp, not a map warp event")
S.finish()
-39
View File
@@ -1,39 +0,0 @@
-- #114: launcher must restore the arrow cursor when leaving for boot.
-- Self-contained: `luajit tests/rom_importer_cursor_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 cursor")
local eq = S.eq
local currentCursor = "arrow"
love.mouse.isCursorSupported = function() return true end
love.mouse.getSystemCursor = function(name) return name end
love.mouse.setCursor = function(c) currentCursor = c or "arrow" end
local RomImporter = require("src.import.RomImporter")
local booted = nil
local ri = setmetatable({
android = false,
workState = nil,
ready = { red = true, blue = false },
onComplete = function(version) booted = version end,
}, RomImporter)
-- Simulate leaving Play while the hand cursor is still active (hover).
currentCursor = "hand"
ri:play("red")
eq(booted, "red", "play boots the chosen version")
eq(currentCursor, "arrow", "play restores the arrow cursor before boot")
-- Android / unsupported cursors must not error.
booted = nil
currentCursor = "hand"
ri.android = true
ri:play("red")
eq(booted, "red", "android play still boots")
eq(currentCursor, "hand", "android play leaves the cursor alone")
S.finish()
-213
View File
@@ -1659,47 +1659,6 @@ do
eq(cam.y, 160 - (288 / 2 - 8), "wide view keeps player centered y")
end
-- ---------------------------------------------------------------- dpi fit scale (#87)
-- Android density is often non-integer; fitScale must use framebuffer
-- pixels so each GB pixel maps to a whole number of physical pixels.
do
local Renderer = require("src.render.Renderer")
local Zoom = require("src.render.Zoom")
local g = love.graphics
local oldDim, oldPix, oldDpi = g.getDimensions, g.getPixelDimensions, g.getDPIScale
-- desktop / dpi=1: identical to the pre-fix unit-based floor scale
g.getDimensions = function() return 1920, 1080 end
g.getPixelDimensions = function() return 1920, 1080 end
g.getDPIScale = function() return 1 end
eq(Renderer:fitScale(), 7, "dpi=1 1080p fitScale is floor(1080/144)=7")
-- density 1.5 on a 1920x1080 panel → LOVE units 1280x720
g.getDimensions = function() return 1280, 720 end
g.getPixelDimensions = function() return 1920, 1080 end
g.getDPIScale = function() return 1.5 end
eq(Renderer:fitScale(), 7,
"non-integer density still picks integer framebuffer pixels (7, not 5)")
-- old unit-only math would have returned floor(min(1280/160,720/144))=5
-- and 5*1.5=7.5 physical px/GB px (shimmer). 7 physical is crisp.
Zoom.reset()
local vw, vh = Renderer:worldViewSize()
check(vw % 2 == 0 and vh % 2 == 0, "world view sizes are even (integer camera)")
-- ceil(pw/Sp)=ceil(1920/7)=275 → even 276; ceil(1080/7)=155 → even 156
eq(vw, 276, "world fill width covers the unit window at pixel scale 7")
eq(vh, 156, "world fill height covers the unit window at pixel scale 7")
-- missing pixel API falls back to getDimensions (headless / old stub)
g.getPixelDimensions = nil
g.getDPIScale = nil
g.getDimensions = function() return 640, 576 end
eq(Renderer:fitScale(), 4, "no pixel API: fitScale uses unit dimensions")
g.getDimensions, g.getPixelDimensions, g.getDPIScale = oldDim, oldPix, oldDpi
Zoom.reset()
end
-- ---------------------------------------------------------------- spawn filter
do
local OW = require("src.world.OverworldController")
@@ -1936,87 +1895,6 @@ do
eq(#StateStack.states, depth0, "mart menu unwound cleanly")
end
-- ---------------------------------------------------------------- UI text layout (#115/#116/#119)
-- Nested function so LuaJIT's 200-local main-chunk limit is not hit.
;(function()
local BoxMenu = require("src.ui.BoxMenu")
local box = BoxMenu.new(Game)
check(box.tw >= 14, "Bill's PC menu is wide enough for CHANGE BOX")
local labelTiles = 2 + #"CHANGE BOX" -- cursor gap + label
check(box.tx + labelTiles <= box.tx + box.tw - 1,
"CHANGE BOX fits inside the Bill's PC border")
-- ¥ is one glyph (charmap 0xF0) but two UTF-8 bytes; right-align must
-- use Font.width, not #string.
eq(Font.width("¥300"), 4 * 8, "Font.width counts yen as one glyph")
check(Font.width("¥300") < #"¥300" * 8,
"byte-length right-align would shift yen prices left")
-- Sell list: name + quantity column only (no ¥ price on the row).
Game.save.inventory = { GREAT_BALL = 5, HYPER_POTION = 99 }
local sellShop = require("src.ui.ShopMenu").new(Game, { "POTION" }, function() end)
StateStack:push(sellShop)
Input.pressed = { down = true }; StateStack:update(1 / 60); Input.pressed = {}
Input.pressed = { a = true }; StateStack:update(1 / 60); Input.pressed = {}
local sellList = StateStack:top()
local foundHyper
for _, it in ipairs(sellList.items or {}) do
if it.value == "HYPER_POTION" then
foundHyper = it
local nameEnd = 16 + Font.width(it.label)
local rightX = 160 - 8 - Font.width(it.right)
check(nameEnd <= rightX,
"sell HYPER POTION name does not overlap quantity")
check(not tostring(it.right):find("¥", 1, true),
"sell list keeps prices out of the right column")
check(tostring(it.label):find("x", 1, true) == nil,
"sell list does not glue quantity into the name")
end
end
check(foundHyper, "sell list includes HYPER POTION")
StateStack:pop() -- sell list
StateStack:pop() -- shop
Game.save.inventory = {}
-- Status screen page 2: next-level line stays left of the line-box edge.
local Pokemon = require("src.pokemon.Pokemon")
local mon = Pokemon.new(Data, "RAICHU", 27)
local SummaryMenu = require("src.ui.SummaryMenu")
local HudTiles = require("src.render.HudTiles")
local drawn = {}
local savedDraw, savedTile = Font.draw, HudTiles.tile
Font.draw = function(text, x, y)
drawn[#drawn + 1] = { text = tostring(text), x = x, y = y }
return Font.width(text)
end
HudTiles.tile = function(code, x, y)
drawn[#drawn + 1] = { tile = code, x = x, y = y }
end
local summary = SummaryMenu.new(Game, mon)
summary.page = 2
summary:draw()
Font.draw = savedDraw
HudTiles.tile = savedTile
local nextExp, lvTile, lvNum
for _, d in ipairs(drawn) do
if d.text == "LEVEL UP" then
eq(d.y, 40, "LEVEL UP sits on tile row 5")
elseif type(d.text) == "string" and d.text:match("^%s*%d+$") and d.y == 48 and d.x == 56 then
nextExp = d
elseif d.tile == 0x6E and d.y == 48 then
lvTile = d
elseif d.text == "28" and d.y == 48 then
lvNum = d
end
end
check(nextExp, "next-exp prints at (7,6)")
check(lvTile and lvTile.x == 128, "next level uses the <LV> tile at col 16")
check(lvNum and lvNum.x == 136, "next level digits follow <LV>")
local edge = 19 * 8 -- DrawLineBox vertical at col 19
check(lvNum.x + Font.width(lvNum.text) <= edge,
"next level digits stay left of the status line-box")
end)()
-- ================= BUGS.md batch: battle-victory-music =================
@@ -2486,94 +2364,6 @@ do
end
end
-- ================= issue #142: player backsprite X =================
do
-- pret/pokered places the player mon pic at hlcoord 1,5 (screen x=8).
-- Drawing at x=16 put every backsprite one tile too far right.
do
local img = {
getWidth = function() return 28 end,
getHeight = function() return 28 end,
}
local battle = setmetatable({
showPlayerBack = false,
safari = false,
demo = false,
sendingOut = false,
phase = "command",
player = { sprite = img, isPlayer = true },
}, BattleState)
function battle:picImage(i) return i end
function battle:growInScale() return nil end
function battle:fxHidden() return false end
local xs = {}
local origDraw = love.graphics.draw
love.graphics.draw = function(drawn, x, y, r, sx, sy)
if drawn == img then xs[#xs + 1] = x end
end
battle:drawPicsLayer(0, 0, 0)
love.graphics.draw = origDraw
eq(xs[1], 8, "player backsprite rests at hlcoord 1,5 (x=8)")
-- trainer/old-man back pic uses the same slot
battle.showPlayerBack = true
battle.playerBackPic = img
xs = {}
love.graphics.draw = function(drawn, x, y, r, sx, sy)
if drawn == img then xs[#xs + 1] = x end
end
battle:drawPicsLayer(0, 0, 0)
love.graphics.draw = origDraw
eq(xs[1], 8, "player/old-man back pic also rests at x=8")
end
-- Front pics: LoadUncompressedSpriteData centers in a 7x7 buffer at
-- hlcoord 12,0. A 5x5 (40x40) Squirtle rests at (104,16), not
-- right/bottom-aligned to (112,8).
do
local front = {
getWidth = function() return 40 end,
getHeight = function() return 40 end,
}
local battle = setmetatable({
showEnemyTrainer = false,
enemyHidden = false,
enemySendingOut = false,
phase = "command",
enemy = { sprite = front, isPlayer = false },
}, BattleState)
function battle:picImage(i) return i end
function battle:growInScale() return nil end
function battle:fxHidden() return false end
function battle:drawBattlerPic(b, x, y, scale)
battle._ex, battle._ey, battle._scale = x, y, scale
end
battle:drawPicsLayer(0, 0, 0)
eq(battle._ex, 104, "enemy 5x5 front rests at hlcoord 12,0 + hPad 1 (x=104)")
eq(battle._ey, 16, "enemy 5x5 front bottom-aligned in 7x7 (y=16)")
eq(battle._scale, 1, "enemy front draws at 1x")
end
-- Battle message lines skip a tile row (14 then 16), matching the menu.
do
local Font = require("src.render.Font")
local ys, origCode, origBox = {}, Font.drawCode, Font.drawBox
Font.drawBox = function() end
Font.drawCode = function(_, _, y) ys[#ys + 1] = y end
local battle = setmetatable({
phase = "messages",
current = true,
charIndex = 999,
lines = { { 0x80 }, { 0x81 } },
}, BattleState)
battle:drawTextArea()
Font.drawCode, Font.drawBox = origCode, origBox
eq(ys[1], 112, "battle text line 1 at row 14 (y=112)")
eq(ys[2], 128, "battle text line 2 at row 16 (y=128)")
end
end
-- ================= BUGS.md batch: ledge-shadow =================
do
-- == Task 12: ledge-hop shadow is the 2x2 mirrored OAM block ==
@@ -2801,9 +2591,6 @@ end
-- ---------------------------------------------- input hold regressions
runSuites({ "tests/input_hold_test.lua" })
-- ---------------------------------------------- launcher cursor (#114)
runSuites({ "tests/rom_importer_cursor_test.lua" })
-- ---------------------------------------------- parity workstream tests
-- Each tests/parity_*.lua is a self-contained file (own bootstrap + check,
-- error()s if any assertion fails). Globbed, so dropping a new parity