mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 16:31:05 +02:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aad86fb351 |
@@ -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?
|
||||
[](https://buymeacoffee.com/bryanthaboi)
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 753 KiB After Width: | Height: | Size: 370 KiB |
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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!" },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 = "?" }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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 }
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" } },
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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) ===
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user