Compare commits

..

10 Commits

Author SHA1 Message Date
bryanthaboi 6625391f76 online play updates and fixes and tweaks and cheeks 2026-07-24 14:36:10 -04:00
bryanthaboi c40dcd6159 Merge branch 'online-play' 2026-07-24 13:31:07 -04:00
bryanthaboi 35f5c9513c Update BattleState.lua 2026-07-24 13:30:54 -04:00
bryanthaboi 5a0f9ab06d Online play (#156)
* new options, and hot keys and readme clean up

* oooooooooooh bazinga
2026-07-24 13:11:40 -04:00
bryanthaboi 13b58dc51e oooooooooooh bazinga 2026-07-24 13:09:00 -04:00
bryanthaboi d872010209 new options, and hot keys and readme clean up (#148) 2026-07-24 10:12:27 -04:00
bryanthaboi 6306975352 new options, and hot keys and readme clean up 2026-07-24 10:10:51 -04:00
bryanthaboi 819150f50f Bugs and stuff (#146)
* main menu scrollable when over 8 items

* buggies

* more buggies

* Lorelei, Bruno, and Agatha now push their AfterBattle text right after a win

* more and more bugs
2026-07-24 09:26:43 -04:00
bryanthaboi bfba1f7bb7 Bugs and stuff (#144)
* main menu scrollable when over 8 items

* buggies

* more buggies

* Lorelei, Bruno, and Agatha now push their AfterBattle text right after a win
2026-07-24 09:06:29 -04:00
bryanthaboi 8278b209b4 Squadhing som bug (#130)
* squashy squash

* squassshheee

CLOSES #128
CLOSES #114
CLOSES #113
CLOSES #111
CLOSES #110
CLOSES #109
CLOSES #107
CLOSES #106
CLOSES #105
CLOSES #90
CLOSES #103

* brock guy fix

CLOSES #39
2026-07-23 16:55:03 -04:00
103 changed files with 7583 additions and 688 deletions
+42 -17
View File
@@ -1,18 +1,19 @@
# Pokemon Gen 1 Recompilation Project # Pokemon Gen 1 Recompilation Project
A native LÖVE2D recreation of Pokemon Red. The engine and map behavior are A native LÖVE2D recreation of Pokemon Red and Blue. The engine and map
hand-written Lua; game data and graphics are decoded from a ROM supplied by behavior are hand-written Lua; game data and graphics are decoded from a ROM
the player. supplied by the player.
SUPPORT AND ANNOUNCEMENTS: [Discord](https://bois.icu) SUPPORT AND ANNOUNCEMENTS: [Discord](https://bois.icu)
This project does not include a ROM, emulate the Game Boy, transpile assembly, This project does not include a ROM, emulate the Game Boy, transpile assembly,
or download a disassembly. A canonical US Pokemon Red ROM is the only game or download a disassembly. A canonical US Pokemon Red or Blue ROM is the only
content input. game content input.
The ROM is verified, used during import, and then released from memory. It is 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 not copied into the cache. Later launches load the private generated cache and
do not ask for the ROM again. do not ask for the ROM again. Red and Blue can both be imported and played
side by side.
## Quick Start ## Quick Start
@@ -20,24 +21,52 @@ 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 or drop it onto the window. Import takes a few seconds and the game starts
automatically. automatically.
Only the canonical 1 MiB US Red ROM is accepted. The importer verifies SHA-1 Only the canonical 1 MiB US Red and Blue ROMs are accepted. The importer
`ea9bcae617fdf159b045185467ae58b2e4a48b9a` before creating any game data. verifies SHA-1 before creating any game data:
- Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a`
- Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2`
The packaged app contains neither a ROM nor pre-extracted game data. Music, 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 sound effects, and cries are synthesized while the game runs from compact
audio channel programs copied out of the verified ROM. audio channel programs copied out of the verified ROM.
## Controls ## Controls
arrow keys or WASD move; Z, Enter, or Space is A; X or Backspace is | Action | Keyboard | Controller |
B; Escape opens START. F1 saves and F2 loads. Controllers are supported. |--------|----------|------------|
| Move | Arrow keys / WASD | D-pad / left stick |
| A | Z / Enter / Space | A |
| B | X / Backspace | B |
| Start | Escape | Start |
| Select | Tab / Shift | Back / Select |
Rebind any of these in-game under **OPTIONS → CONTROLS**. Controllers are
supported out of the box.
### Hotkeys
| Key | What it does |
|-----|----------------|
| `-` / `=` | Zoom out / in (overworld; also mouse wheel) |
| `2` | Cycle COLORS |
| `3` | Cycle TILT (free-roam overworld) |
| `4` | Cycle ZOOM through every level (free-roam overworld) |
| `5` | Cycle GBC FX |
| `F1` | Save |
| `F2` | Load |
| `F10` | Open / close the mod manager |
COLORS, TILT, ZOOM, GBC FX, and VOID FILL are also in the Options menu
and persist in `options.lua`.
## Running From Source ## Running From Source
Requires LÖVE 11.x. Place the ROM in the project folder and double-click Requires LÖVE 11.x. Place a Red or Blue ROM in the project folder and
`Play-Mac.command` or `Play-Windows.bat`, or run: double-click `Play-Mac.command` or `Play-Windows.bat`, or run:
```sh ```sh
scripts/setup.sh --rom "/path/to/Pokemon Red.gb" scripts/setup.sh --rom "/path/to/Pokemon Red.gb" # or Pokemon Blue.gb
scripts/run.sh scripts/run.sh
``` ```
@@ -107,7 +136,3 @@ request with real detail is one that can actually get built.
This project would not be possible without [pret](https://github.com/pret) > This project would not be possible without [pret](https://github.com/pret) >
the pret band of decompiling maniacs > and their the pret band of decompiling maniacs > and their
[pokered](https://github.com/pret/pokered) disassembly. [pokered](https://github.com/pret/pokered) disassembly.
## Wanna Support My Work?
[![Buy Me a Coffee](https://img.shields.io/badge/Buy_Me_a_Coffee-FFDD00?style=for-the-badge&logo=buy-me-a-coffee&logoColor=black)](https://buymeacoffee.com/bryanthaboi)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 370 KiB

After

Width:  |  Height:  |  Size: 753 KiB

+5 -18
View File
@@ -1,15 +1,11 @@
-- Pewter City flavor dialogue (pokered/scripts/PewterCity.asm). -- Pewter City flavor dialogue (pokered/scripts/PewterCity.asm).
-- PewterCity_TextPointers text_asm bodies for the SUPER_NERD1 museum -- PewterCity_TextPointers text_asm bodies for the SUPER_NERD1 museum
-- guide, SUPER_NERD2 garden nerd, and the leaving-east YOUNGSTER. -- guide and SUPER_NERD2 garden nerd.
-- --
-- The escort choreography (SUPER_NERD1 walking the player to the -- The YOUNGSTER's gym escort (talk + east-exit onStep) lives in
-- museum, YOUNGSTER walking the player to the gym) is scripted NPC -- story5.lua so the lockstep RLE walk is not overwritten by this
-- movement + a wPewterCityCurScript state machine that steers the -- flavor merge. SUPER_NERD1's museum escort is not ported; only the
-- player off-map; the YOUNGSTER's escort to the gym is handled on this -- YES/NO-branched flavor text is here.
-- 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 = {} local M = {}
@@ -61,15 +57,6 @@ M.PEWTER_CITY = {
end) end)
end, end,
-- PewterCityYoungsterText (scripts/PewterCity.asm): the "follow
-- me" line the youngster says when the player is stopped from
-- leaving Pewter east before beating Brock; the actual gate/step
-- block is handled by story5.lua's onStep for this map.
TEXT_PEWTERCITY_YOUNGSTER = function(game, ow, npc, done)
local t = text(game)
push(game, t._PewterCityYoungsterYoureATrainerFollowMeText
or "You're a trainer\nright? BROCK's\nlooking for new\nchallengers!\nFollow me!", done)
end,
}, },
} }
+22
View File
@@ -0,0 +1,22 @@
-- 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,
},
}
+16 -12
View File
@@ -5,19 +5,23 @@ return {
SILPH_CO_9F = { SILPH_CO_9F = {
talk = { talk = {
-- SilphCo9FNurseText (pokered/scripts/SilphCo9F.asm): -- SilphCo9FNurseText (pokered/scripts/SilphCo9F.asm):
-- before EVENT_BEAT_SILPH_CO_GIOVANNI: heals the party and shows -- before EVENT_BEAT_SILPH_CO_GIOVANNI: heals the party, white fade
-- "You look tired..." then "Don't give up!"; after the event, just -- (Delay3 between out/in; no Music_PkmnHealed), then "Don't give
-- says thanks. Nurse texts are not in data/generated/text.lua, so -- up!"; after the event, just says thanks. Nurse texts are not in
-- the exact pokered/text/SilphCo9F.asm strings are used as literals. -- data/generated/text.lua, so the exact pokered/text/SilphCo9F.asm
-- strings are used as literals.
TEXT_SILPHCO9F_NURSE = { TEXT_SILPHCO9F_NURSE = {
{ "face_player" }, { "face_player" }, -- 1
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" }, { "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" }, -- 2
{ "jump_if_true", 8 }, { "jump_if_true", 11 }, -- 3
{ "show_text", "You look tired!\nYou should take a\nquick nap!" }, { "show_text", "You look tired!\nYou should take a\nquick nap!" }, -- 4
{ "heal_party" }, { "heal_party" }, -- 5
{ "show_text", "Don't give up!" }, { "fade", "out", "white" }, -- 6
{ "jump", 9 }, { "wait", 3 }, -- 7 Delay3
{ "show_text", "Thank you so\nmuch!" }, { "fade", "in", "white" }, -- 8
{ "show_text", "Don't give up!" }, -- 9
{ "jump", "end" }, -- 10
{ "show_text", "Thank you so\nmuch!" }, -- 11
}, },
}, },
}, },
+1
View File
@@ -28,6 +28,7 @@ local files = {
"data.scripts.flavor.pokemon_fan_club", "data.scripts.flavor.pokemon_fan_club",
"data.scripts.flavor.power_plant", "data.scripts.flavor.power_plant",
"data.scripts.flavor.reds_house_1f", "data.scripts.flavor.reds_house_1f",
"data.scripts.flavor.reds_house_2f",
"data.scripts.flavor.route11_gate_2f", "data.scripts.flavor.route11_gate_2f",
"data.scripts.flavor.route18_gate_2f", "data.scripts.flavor.route18_gate_2f",
"data.scripts.flavor.route_12_gate_2f", "data.scripts.flavor.route_12_gate_2f",
+52 -29
View File
@@ -24,17 +24,23 @@ local function starterBall(askText, species, choseFlag, ownBall,
{ "check_flag", "EVENT_FOLLOWED_OAK_INTO_LAB" }, -- 3 { "check_flag", "EVENT_FOLLOWED_OAK_INTO_LAB" }, -- 3
{ "jump_if_false", 20 }, -- 4 { "jump_if_false", 20 }, -- 4
-- the Pokédex "new species" entry shows before the ask (predef -- the Pokédex "new species" entry shows before the ask (predef
-- StarterDex ahead of OaksLabYouWant...Text) -- StarterDex ahead of OaksLabYouWant...Text). StarterDex temporarily
{ "push_screen", "DexEntryMenu", species }, -- 5 -- 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
{ "ask", askText }, -- 6 { "ask", askText }, -- 6
{ "jump_if_false", 21 }, -- 7 { "jump_if_false", 21 }, -- 7
{ "give_pokemon", species, 5 }, -- 8 -- OaksLab.asm prints ReceivedMon then AddPartyMon (AskName lives
{ "set_flag", "EVENT_GOT_STARTER" }, -- 9 -- inside give_pokemon). Show the received text first so the
{ "set_flag", choseFlag }, -- 10 -- 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
-- POKé BALLs are not handed out here in the original -- Oak gives -- POKé BALLs are not handed out here in the original -- Oak gives
-- them later, at OaksLabOak1Text's .give_poke_balls beat once the -- them later, at OaksLabOak1Text's .give_poke_balls beat once the
-- player has beaten the Route 22 rival (see TEXT_OAKSLAB_OAK1 below) -- player has beaten the Route 22 rival (see TEXT_OAKSLAB_OAK1 below)
{ "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 11
{ "hide_object", "OAKS_LAB", ownBall }, -- 12 { "hide_object", "OAKS_LAB", ownBall }, -- 12
-- the rival walks to the countering ball (around the furniture) -- the rival walks to the countering ball (around the furniture)
{ "move_npc_to", 1, rivalBallX, 4 }, -- 13 { "move_npc_to", 1, rivalBallX, 4 }, -- 13
@@ -61,40 +67,44 @@ return {
TEXT_OAKSLAB_OAK1 = { TEXT_OAKSLAB_OAK1 = {
{ "face_player" }, -- 1 { "face_player" }, -- 1
{ "check_flag", "EVENT_GOT_OAKS_PARCEL" }, -- 2 { "check_flag", "EVENT_GOT_OAKS_PARCEL" }, -- 2
{ "jump_if_false", 14 }, -- 3 { "jump_if_false", 16 }, -- 3
{ "check_flag", "EVENT_OAK_GOT_PARCEL" }, -- 4 { "check_flag", "EVENT_OAK_GOT_PARCEL" }, -- 4
{ "jump_if_true", 14 }, -- 5 { "jump_if_true", 16 }, -- 5
{ "show_text", "_OaksLabOak1DeliverParcelText" }, -- 6 { "show_text", "_OaksLabOak1DeliverParcelText" }, -- 6
{ "take_item", "OAKS_PARCEL", 1 }, -- 7 { "take_item", "OAKS_PARCEL", 1 }, -- 7
{ "set_flag", "EVENT_OAK_GOT_PARCEL" }, -- 8 { "set_flag", "EVENT_OAK_GOT_PARCEL" }, -- 8
{ "show_text", "_OaksLabOak1PokemonAroundTheWorldText" }, -- 9 { "show_text", "_OaksLabOak1PokemonAroundTheWorldText" }, -- 9
{ "set_flag", "EVENT_GOT_POKEDEX" }, -- 10 { "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: -- the Pokédex swaps Viridian's two old men (OaksLab.asm:602-606:
-- HideObject TOGGLE_LYING_OLD_MAN / ShowObject TOGGLE_OLD_MAN). -- HideObject TOGGLE_LYING_OLD_MAN / ShowObject TOGGLE_OLD_MAN).
-- Until this ran, the walking man at (17,5) -- who owns the coffee -- Until this ran, the walking man at (17,5) -- who owns the coffee
-- ask and the catch tutorial -- stayed OFF for the whole game -- ask and the catch tutorial -- stayed OFF for the whole game
-- (toggleable_objects.asm seeds him OFF, the sleeper ON). -- (toggleable_objects.asm seeds him OFF, the sleeper ON).
{ "hide_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY" }, -- 11 { "hide_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY" }, -- 13
{ "show_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN" }, -- 12 { "show_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN" }, -- 14
{ "jump", 32 }, -- 13 { "jump", "end" }, -- 15
{ "check_flag", "EVENT_GOT_STARTER" }, -- 14 { "check_flag", "EVENT_GOT_STARTER" }, -- 16
{ "jump_if_false", 29 }, -- 15 { "jump_if_false", 31 }, -- 17
{ "check_item", "POKE_BALL" }, -- 16 { "check_item", "POKE_BALL" }, -- 18
{ "jump_if_true", 27 }, -- 17 { "jump_if_true", 29 }, -- 19
{ "check_flag", "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE" }, -- 18 { "check_flag", "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE" }, -- 20
{ "jump_if_false", 31 }, -- 19 { "jump_if_false", 33 }, -- 21
{ "check_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 20 { "check_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 22
{ "jump_if_true", 27 }, -- 21 { "jump_if_true", 29 }, -- 23
{ "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 22 { "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 24
{ "give_item", "POKE_BALL", 5, false }, -- 23 { "give_item", "POKE_BALL", 5, false }, -- 25
{ "show_text", "_OaksLabOak1ReceivedPokeballsText" }, -- 24 { "show_text", "_OaksLabOak1ReceivedPokeballsText" }, -- 26
{ "show_text", "_OaksLabGivePokeballsExplanationText" }, -- 25 { "show_text", "_OaksLabGivePokeballsExplanationText" }, -- 27
{ "jump", 32 }, -- 26 { "jump", "end" }, -- 28
{ "show_text", "_OaksLabOak1ComeSeeMeSometimesText" }, -- 27 { "show_text", "_OaksLabOak1ComeSeeMeSometimesText" }, -- 29
{ "jump", 32 }, -- 28 { "jump", "end" }, -- 30
{ "show_text", "_OaksLabOak1WhichPokemonDoYouWantText" }, -- 29 { "show_text", "_OaksLabOak1WhichPokemonDoYouWantText" }, -- 31
{ "jump", 32 }, -- 30 { "jump", "end" }, -- 32
{ "show_text", "_OaksLabOak1RaiseYourYoungPokemonText" }, -- 31 (32 = end) { "show_text", "_OaksLabOak1RaiseYourYoungPokemonText" }, -- 33
}, },
TEXT_OAKSLAB_CHARMANDER_POKE_BALL = TEXT_OAKSLAB_CHARMANDER_POKE_BALL =
@@ -140,6 +150,19 @@ 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 -- Oak stops you leaving without a starter; the rival stops you on
-- the way out for the first battle (scripts/OaksLab.asm -- the way out for the first battle (scripts/OaksLab.asm
-- OaksLabScript8 / OaksLabRivalChallenge) -- OaksLabScript8 / OaksLabRivalChallenge)
+15 -7
View File
@@ -1,15 +1,23 @@
-- Hand-ported from pret/pokered scripts/RedsHouse1F.asm. -- Hand-ported from pret/pokered scripts/RedsHouse1F.asm.
-- Mom (RedsHouse1FMomText, text_asm) heals the party and shows the -- Mom (RedsHouse1FMomText): pre-starter shows the wake-up / Oak tip;
-- "you should rest" / "looking great" dialogue. The intro "wake up" -- after EVENT_GOT_STARTER, RedsHouse1FMomHealScript fades to white,
-- branch is tied to the unported intro cutscene, so the heal path is used. -- heals, plays MUSIC_PKMN_HEALED, fades back, then "looking great".
return { return {
talk = { talk = {
TEXT_REDSHOUSE1F_MOM = { TEXT_REDSHOUSE1F_MOM = {
{ "face_player" }, { "face_player" }, -- 1
{ "show_text", "_RedsHouse1FMomYouShouldRestText" }, { "check_flag", "EVENT_GOT_STARTER" }, -- 2
{ "heal_party" }, { "jump_if_true", 6 }, -- 3
{ "show_text", "_RedsHouse1FMomLookingGreatText" }, { "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
}, },
}, },
} }
+133 -51
View File
@@ -616,7 +616,8 @@ M.SILPH_CO_11F = {
-- ------------------------------------------------------------------- -- -------------------------------------------------------------------
-- Victory Road boulder switches (scripts/VictoryRoad1F/2F/3F.asm): -- Victory Road boulder switches (scripts/VictoryRoad1F/2F/3F.asm):
-- a boulder resting on a switch removes a barrier block; the 3F hole -- a boulder resting on a switch removes a barrier block; the 3F hole
-- drops a boulder down to the 2F switch. -- drops a boulder down to the 2F switch, and also dungeon-warps the
-- player (IsPlayerOnDungeonWarp + DungeonWarpList/Data -> 2F at 22,16).
-- ------------------------------------------------------------------- -- -------------------------------------------------------------------
local function boulderAt(ow, x, y) local function boulderAt(ow, x, y)
@@ -682,6 +683,18 @@ M.VICTORY_ROAD_3F = {
Commands.show_object(ctx, "VICTORY_ROAD_2F", "VICTORYROAD2F_BOULDER") Commands.show_object(ctx, "VICTORY_ROAD_2F", "VICTORYROAD2F_BOULDER")
end end
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,
} }
-- ------------------------------------------------------------------- -- -------------------------------------------------------------------
@@ -694,6 +707,16 @@ M.VICTORY_ROAD_3F = {
-- IndigoPlateauLobby.asm) so the champion is re-fightable on rematches, -- IndigoPlateauLobby.asm) so the champion is re-fightable on rematches,
-- like pokered's re-entry cutscene. EVENT_BEAT_CHAMPION_RIVAL stays set -- like pokered's re-entry cutscene. EVENT_BEAT_CHAMPION_RIVAL stays set
-- forever (postgame gates like the Cerulean cave guard read it). -- 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 -> -- scripts/ChampionsRoom.asm ChampionsRoomRivalDefeatedScript ->
-- OakArrivesScript -> OakCongratulatesPlayerScript -> -- OakArrivesScript -> OakCongratulatesPlayerScript ->
-- OakDisappointedWithRivalScript -> OakComeWithMeScript -> OakExitsScript, -- OakDisappointedWithRivalScript -> OakComeWithMeScript -> OakExitsScript,
@@ -702,43 +725,69 @@ M.VICTORY_ROAD_3F = {
-- itself is NOT run here: it belongs to the HALL_OF_FAME room script -- 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 -- (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. -- 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 = { 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 = { talk = {
TEXT_CHAMPIONSROOM_RIVAL = { TEXT_CHAMPIONSROOM_RIVAL = championsRoomRivalScript,
{ "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
},
}, },
} }
@@ -809,10 +858,14 @@ M.HALL_OF_FAME = {
-- that teleports back to the new-game bedroom spawn (special_warps.asm -- that teleports back to the new-game bedroom spawn (special_warps.asm
-- NewGameWarp: REDS_HOUSE_2F, 3, 6, facing down). Fabricated -- NewGameWarp: REDS_HOUSE_2F, 3, 6, facing down). Fabricated
-- convenience -- there is no such prompt in the original ROM. -- 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 = { TEXT_HALLOFFAME_PC = {
{ "ask", "Return to\nPALLET TOWN?" }, -- 1 YES/NO -> lastCheck { "ask", "Return to\nPALLET TOWN?" }, -- 1 YES/NO -> lastCheck
{ "jump_if_false", "end" }, -- 2 NO: back away { "jump_if_false", "end" }, -- 2 NO: back away
{ "warp", "REDS_HOUSE_2F", 3, 6, "down" }, -- 3 YES: home to your room { "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
}, },
}, },
} }
@@ -839,21 +892,51 @@ 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 = { M.POKEMON_TOWER_2F = {
rivalScript = pokemonTower2FRivalScript,
talk = { talk = {
TEXT_POKEMONTOWER2F_RIVAL = { TEXT_POKEMONTOWER2F_RIVAL = function(game, ow, npc, done)
{ "face_player" }, -- 1 ow.runner:run(pokemonTower2FRivalScript(ow.player.cellX),
{ "check_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 2 { npc = npc, onDone = done })
{ "jump_if_true", 10 }, -- 3 end,
{ "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 -- PokemonTower2FDefaultScript: walking past the rival's tile forces
-- the encounter (ArePlayerCoordsInArray on (15,5)/(14,6)) -- he never -- the encounter (ArePlayerCoordsInArray on (15,5)/(14,6)) -- he never
-- waits to be talked to -- waits to be talked to
@@ -865,8 +948,7 @@ M.POKEMON_TOWER_2F = {
if not rival then return false end if not rival then return false end
ow.player.facing = (x == 15) and "left" or "up" ow.player.facing = (x == 15) and "left" or "up"
require("src.core.Music").play(game.data, "Music_MeetRival") require("src.core.Music").play(game.data, "Music_MeetRival")
ow.runner:run(M.POKEMON_TOWER_2F.talk.TEXT_POKEMONTOWER2F_RIVAL, ow.runner:run(pokemonTower2FRivalScript(x), { npc = rival })
{ npc = rival })
return true return true
end, end,
} }
+208 -41
View File
@@ -61,6 +61,50 @@ M.ROUTE_12_SUPER_ROD_HOUSE = {
M.ROUTE_12_SUPER_ROD_HOUSE.talk.TEXT_ROUTE12SUPERRODHOUSE_FISHING_GURU[9] = M.ROUTE_12_SUPER_ROD_HOUSE.talk.TEXT_ROUTE12SUPERRODHOUSE_FISHING_GURU[9] =
{ "show_text", "_Route12SuperRodHouseFishingGuruTryFishingText" } { "show_text", "_Route12SuperRodHouseFishingGuruTryFishingText" }
-- -------------------------------------------------------------------
-- Pokemon Tower 5F purified zone (scripts/PokemonTower5F.asm
-- PokemonTower5FDefaultScript): the 2x2 center pad heals the party once
-- per visit. EVENT_IN_PURIFIED_ZONE latches until the player steps off;
-- while on the pad the map script also sets BIT_NO_BATTLES (we return
-- true from onStep so wild encounters are skipped the same way).
-- -------------------------------------------------------------------
local TOWER_5F_PURIFIED = {
[10 * 256 + 8] = true, [11 * 256 + 8] = true,
[10 * 256 + 9] = true, [11 * 256 + 9] = true,
}
-- HealParty -> GBFadeOutToWhite -> Delay3 -> Delay3 -> GBFadeInFromWhite
-- -> TEXT_POKEMONTOWER5F_PURIFIEDZONE (no Music_PkmnHealed).
local TOWER_5F_HEAL = {
{ "heal_party" },
{ "fade", "out", "white" },
{ "wait", 3 },
{ "wait", 3 },
{ "fade", "in", "white" },
{ "show_text", "_PokemonTower5FPurifiedZoneText" },
}
M.POKEMON_TOWER_5F = {
onStep = function(game, ow, x, y)
if not TOWER_5F_PURIFIED[x * 256 + y] then
game.save.flags.EVENT_IN_PURIFIED_ZONE = nil
return false
end
if game.save.flags.EVENT_IN_PURIFIED_ZONE then
return true
end
if ow.runner and ow.runner:isRunning() then return false end
game.save.flags.EVENT_IN_PURIFIED_ZONE = true
if ow.runner then
ow.runner:run(TOWER_5F_HEAL)
elseif ow.queueScript then
ow:queueScript(TOWER_5F_HEAL)
end
return true
end,
}
-- ------------------------------------------------------------------- -- -------------------------------------------------------------------
-- The ghost Marowak (scripts/PokemonTower6F.asm): blocks the stairs at -- The ghost Marowak (scripts/PokemonTower6F.asm): blocks the stairs at
-- (10,16) until defeated. -- (10,16) until defeated.
@@ -201,6 +245,49 @@ local function elevatorWalkOut(ow, floor)
end) end)
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) local function elevator(elevatorMapId, keyGate, preFrames)
return { return {
-- fromMapId: the floor the player just left (setMap passes it), so a -- fromMapId: the floor the player just left (setMap passes it), so a
@@ -208,45 +295,18 @@ local function elevator(elevatorMapId, keyGate, preFrames)
-- default to UNUSED_MAP_ED, which is not in Data.maps -- Warp.resolve -- default to UNUSED_MAP_ED, which is not in Data.maps -- Warp.resolve
-- asserted and hard-crashed (#123). -- asserted and hard-crashed (#123).
onEnter = function(game, ow, fromMapId) 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.
if keyGate and not game.save.inventory[keyGate.item] then if keyGate and not game.save.inventory[keyGate.item] then
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
game.data.text[keyGate.text] or "It appears to\nneed a key.")) game.data.text[keyGate.text] or "It appears to\nneed a key."))
return return
end 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)
-- Seed a walk-out destination before the menu: entry floor when
-- known, else the first listed floor (1F). Choosing a floor still
-- rewrites via elevatorWalkOut; B-cancel keeps this seed so leaving
-- the car cannot hit a missing ROM placeholder map.
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)
local items = {} local items = {}
for _, f in ipairs(floors) do for _, f in ipairs(floors) do
table.insert(items, { label = f.token, value = f }) table.insert(items, { label = f.token, value = f })
@@ -292,6 +352,98 @@ M.CELADON_MART_ELEVATOR = elevator("CELADON_MART_ELEVATOR", nil, 9)
M.ROCKET_HIDEOUT_ELEVATOR = elevator("ROCKET_HIDEOUT_ELEVATOR", M.ROCKET_HIDEOUT_ELEVATOR = elevator("ROCKET_HIDEOUT_ELEVATOR",
{ item = "LIFT_KEY", text = "_RocketHideoutElevatorAppearsToNeedKeyText" }) { 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 -- Game Corner coins, prizes, and the rocket-poster switch that reveals
-- the hideout stairs (scripts/GameCorner.asm, data/events/prizes.asm + -- the hideout stairs (scripts/GameCorner.asm, data/events/prizes.asm +
@@ -304,14 +456,23 @@ M.GAME_CORNER = {
-- EVENT_FOUND_ROCKET_HIDEOUT is unset, $43 after) -- EVENT_FOUND_ROCKET_HIDEOUT is unset, $43 after)
onEnter = function(game, ow) onEnter = function(game, ow)
local poster = game.data.field.gameCornerPoster local poster = game.data.field.gameCornerPoster
if not poster then return end if poster then
local block = game.save.flags[poster.event] and poster.openBlock local block = game.save.flags[poster.event] and poster.openBlock
or poster.closedBlock or poster.closedBlock
ow:replaceBlock(poster.x, poster.y, block) ow:replaceBlock(poster.x, poster.y, block)
end
-- pick this visit's lucky slot machine -- pick this visit's lucky slot machine
-- (wLuckySlotHiddenEventIndex, engine/slots/game_corner_slots2.asm) -- (wLuckySlotHiddenEventIndex, engine/slots/game_corner_slots2.asm)
local seats = game.data.field.slotMachines.GAME_CORNER local seats = game.data.field.slotMachines.GAME_CORNER
ow.luckySlot = love.math.random(1, #seats) 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, end,
talk = { talk = {
-- the poster bg event: pressing A reveals the hidden switch -- the poster bg event: pressing A reveals the hidden switch
@@ -340,7 +501,16 @@ M.GAME_CORNER = {
-- losing he warns the BOSS and leaves the floor for good, freeing -- losing he warns the BOSS and leaves the floor for good, freeing
-- the tile in front of the hideout switch -- the tile in front of the hideout switch
TEXT_GAMECORNER_ROCKET = function(game, ow, npc, done) 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 if ow:trainerDefeated(npc) then
hideRocket()
done() done()
return return
end end
@@ -354,10 +524,7 @@ M.GAME_CORNER = {
game.data.text._GameCornerRocketAfterBattleText game.data.text._GameCornerRocketAfterBattleText
or "Our hideout might\nbe discovered! I\nbetter tell BOSS!", or "Our hideout might\nbe discovered! I\nbetter tell BOSS!",
function() function()
local Commands = require("src.script.Commands") hideRocket()
Commands.hide_object({ game = game, save = game.save,
overworld = ow },
"GAME_CORNER", "GAMECORNER_ROCKET")
done() done()
end)) end))
end) end)
+68 -18
View File
@@ -645,12 +645,44 @@ local function e4ExitSeal(flag, closedBlock, openBlock, dontRunText, autoFlag)
} }
end 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, M.LORELEIS_ROOM = e4ExitSeal("EVENT_BEAT_LORELEIS_ROOM_TRAINER_0", 0x24, 0x05,
"_LoreleisRoomLoreleiDontRunAwayText", "EVENT_AUTOWALKED_INTO_LORELEIS_ROOM") "_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, M.BRUNOS_ROOM = e4ExitSeal("EVENT_BEAT_BRUNOS_ROOM_TRAINER_0", 0x24, 0x05,
"_BrunosRoomBrunoDontRunAwayText", "EVENT_AUTOWALKED_INTO_BRUNOS_ROOM") "_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, M.AGATHAS_ROOM = e4ExitSeal("EVENT_BEAT_AGATHAS_ROOM_TRAINER_0", 0x3b, 0x0e,
"_AgathasRoomAgathaDontRunAwayText", "EVENT_AUTOWALKED_INTO_AGATHAS_ROOM") "_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 -- Lance's room (scripts/LancesRoom.asm). Unlike the other three E4
@@ -672,13 +704,21 @@ M.AGATHAS_ROOM = e4ExitSeal("EVENT_BEAT_AGATHAS_ROOM_TRAINER_0", 0x3b, 0x0e,
-- EVENT_LANCES_ROOM_LOCK_DOOR: first crossing seals -- EVENT_LANCES_ROOM_LOCK_DOOR: first crossing seals
-- the door behind the player with SFX_GO_INSIDE -- the door behind the player with SFX_GO_INSIDE
-- (24,16) the entrance staircase -> WalkToLance: an auto-walk -- (24,16) the entrance staircase -> WalkToLance: an auto-walk
-- (up 12, left 12, down 7, left 6) landing on (6,11). -- landing on (6,11). Vanilla WalkToLance_RLEList is
-- It marches straight across the room's water decor: -- up 12 / left 12 / down 7 / left 6 and skips collision
-- pokered's CollisionCheckOnLand skips collision -- through void tiles; our camera follows that literally
-- entirely while simulated joypad states run, and our -- and briefly shows the empty upper chamber (reads as
-- scriptMove is collision-free the same way. -- "Gary's room"). We keep the same landing cell but
-- route along the open-door floor corridor instead.
-- ------------------------------------------------------------------- -- -------------------------------------------------------------------
-- 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 function lanceEntranceBlocks(game, ow)
local locked = game.save.flags.EVENT_LANCES_ROOM_LOCK_DOOR local locked = game.save.flags.EVENT_LANCES_ROOM_LOCK_DOOR
ow:replaceBlock(2, 6, locked and 0x72 or 0x31) ow:replaceBlock(2, 6, locked and 0x72 or 0x31)
@@ -693,21 +733,24 @@ local function lanceLockDoor(game, ow)
end end
local function lanceWalkIn(game, ow) local function lanceWalkIn(game, ow)
ow:scriptMove(ow.player, "up", 12, function() local i = 0
ow:scriptMove(ow.player, "left", 12, function() local function step()
ow:scriptMove(ow.player, "down", 7, function() i = i + 1
ow:scriptMove(ow.player, "left", 6, function() local seg = LANCE_WALK_IN[i]
-- the walk lands on (6,11); vanilla's per-frame coord poll if not seg then
-- then locks the door at once. scriptMove landings do not -- lands on (6,11); vanilla's per-frame coord poll then locks the
-- fire onStep, so lock here. -- door at once. scriptMove landings do not fire onStep, so lock
lanceLockDoor(game, ow) -- here.
end) lanceLockDoor(game, ow)
end) return
end) end
end) ow:scriptMove(ow.player, seg[1], seg[2], step)
end
step()
end end
M.LANCES_ROOM = { M.LANCES_ROOM = {
walkInRoute = LANCE_WALK_IN,
onEnter = function(game, ow) onEnter = function(game, ow)
lanceEntranceBlocks(game, ow) lanceEntranceBlocks(game, ow)
-- the warp arrival lands ON the staircase trigger, and onStep only -- the warp arrival lands ON the staircase trigger, and onStep only
@@ -727,7 +770,14 @@ M.LANCES_ROOM = {
end end
if not lance or ow:trainerDefeated(lance) then return false end if not lance or ow:trainerDefeated(lance) then return false end
lance:facePlayer(ow.player) lance:facePlayer(ow.player)
ow:engageTrainer(lance, function() end) -- 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)
return true return true
end end
if (x == 5 or x == 6) and y == 11 then if (x == 5 or x == 6) and y == 11 then
+167 -25
View File
@@ -262,38 +262,180 @@ M.CINNABAR_ISLAND = {
-- Pewter's youngster stops you leaving east before Brock is beaten and -- Pewter's youngster stops you leaving east before Brock is beaten and
-- escorts you to the gym (scripts/PewterCity.asm -- escorts you to the gym (scripts/PewterCity.asm
-- PewterCityCheckPlayerLeavingEastScript / -- PewterCityCheckPlayerLeavingEastScript /
-- PewterCityYoungsterShowsPlayerGymScript, engine/events/pewter_guys.asm -- PewterCityYoungsterShowsPlayerGymScript, engine/overworld/auto_movement.asm
-- PewterGymGuyCoords): the "follow me" lines, then the player is walked -- PewterGymGuyMovementScriptPointerTable, engine/events/pewter_guys.asm
-- west along the road to the front of the PEWTER_GYM door at (16,17). -- 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
M.PEWTER_CITY = { 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) onStep = function(game, ow, x, y)
if game.save.flags.EVENT_BEAT_BROCK then return false end if game.save.flags.EVENT_BEAT_BROCK then return false end
if ow.runner:isRunning() or #ow.scriptMoves > 0 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 if not inCoords({ { 35, 17 }, { 36, 17 }, { 37, 18 }, { 37, 19 } }, x, y) then
return false return false
end end
local t = text(game) pewterGymEscort(game, ow)
-- 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 return true
end, end,
} }
+11 -3
View File
@@ -2,8 +2,9 @@
-- "OPP_CLASS#partyIndex" (the object_event trainer args). Hand-ported -- "OPP_CLASS#partyIndex" (the object_event trainer args). Hand-ported
-- from the leaders'/bosses' text_asm victory scripts: -- from the leaders'/bosses' text_asm victory scripts:
-- gym badges: scripts/PewterGym.asm ... ViridianGym.asm -- gym badges: scripts/PewterGym.asm ... ViridianGym.asm
-- Rocket Hideout Giovanni: his Silph Scope is an item ball next to him -- Rocket Hideout Giovanni: Silph Scope is a hidden item ball revealed by
-- (data/maps/objects/RocketHideoutB4F.asm), so no reward entry needed. -- ShowObject in RocketHideoutB4FBeatGiovanniScript (ported as the
-- TEXT_ROCKETHIDEOUTB4F_GIOVANNI talk handler in story3.lua).
-- The TM each gym leader hands out afterwards is also ported. -- The TM each gym leader hands out afterwards is also ported.
-- --
-- `deactivate` lists the EVENT_BEAT_* flags each gym's victory script -- `deactivate` lists the EVENT_BEAT_* flags each gym's victory script
@@ -21,9 +22,16 @@ local function range(prefix, first, last)
end end
return { 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", ["OPP_BROCK#1"] = { badge = "BOULDERBADGE", flag = "EVENT_BEAT_BROCK",
item = "TM_BIDE", item = "TM_BIDE",
deactivate = { "EVENT_BEAT_PEWTER_GYM_TRAINER_0" } }, deactivate = { "EVENT_BEAT_PEWTER_GYM_TRAINER_0" },
hide = {
{ "PEWTER_CITY", "PEWTERCITY_YOUNGSTER" },
{ "ROUTE_22", "ROUTE22_RIVAL1" },
} },
["OPP_MISTY#1"] = { badge = "CASCADEBADGE", flag = "EVENT_BEAT_MISTY", ["OPP_MISTY#1"] = { badge = "CASCADEBADGE", flag = "EVENT_BEAT_MISTY",
item = "TM_BUBBLEBEAM", item = "TM_BUBBLEBEAM",
deactivate = range("EVENT_BEAT_CERULEAN_GYM_TRAINER_", 0, 1) }, deactivate = range("EVENT_BEAT_CERULEAN_GYM_TRAINER_", 0, 1) },
+38 -11
View File
@@ -8,27 +8,42 @@ behavior is in docs/behavior-porting-notes.md.
## Survey zoom ## Survey zoom
The mouse wheel (or `-`/`=`) zooms the overworld between 1 pixel per world The mouse wheel (or `-`/`=`), the Options **ZOOM** row, or hotkey `4`
pixel (full survey) and 2× the window fit scale (close-up), in crisp zooms the overworld between 1 pixel per world pixel (full survey) and 2×
integer steps. This has no Game Boy equivalent: the window fit scale (close-up), in crisp integer steps. This has no Game
Boy equivalent:
- Connected maps render their full bodies, and their NPCs appear as - Connected maps render their full bodies, and their NPCs appear as
visual-only "ghosts", they wander but have no sight lines, triggers, visual-only "ghosts", they wander but have no sight lines, triggers,
dialogue, or collision until the map is actually entered. dialogue, or collision until the map is actually entered.
- Menus, text boxes, and battles draw at normal scale on top of the - Menus, text boxes, and battles draw at normal scale on top of the
zoomed world. Zoom input is ignored while a script, menu, or battle is zoomed world. Zoom input is ignored while a script, menu, or battle is
active; the zoom level persists across warps and is never saved. active; the zoom offset is persisted as `save.options.zoom` (default
- Beyond the border ring the border block repeats indefinitely (interiors `0` = FIT) and survives New Game via `options.lua`.
stay black, seaside towns stay water, except OVERWORLD-tileset maps, - Hotkey `4` ticks through every integer zoom level (survey → FIT →
whose beyond-edge space fills with the solid tree wall instead of the close-up → wrap). The Options row shows `FIT` / `OUTn` / `INn`.
per-map border block), and each visible map area is colorized with its - Beyond the border ring the void fill repeats indefinitely (see VOID
own SGB palette (the original recolored the whole screen per map). FILL below); interiors keep their own border block. Each visible map
area is colorized with its own SGB palette (the original recolored the
whole screen per map).
- Neighbor maps load two connection hops out so corner-adjacent maps - Neighbor maps load two connection hops out so corner-adjacent maps
don't pop in and out, and ghost NPCs share instances with the real ones don't pop in and out, and ghost NPCs share instances with the real ones
so their wander positions persist across seamless connection crossings so their wander positions persist across seamless connection crossings
(a warp or fresh map entry still respawns everything at its script (a warp or fresh map entry still respawns everything at its script
position, like the original's per-entry sprite init). position, like the original's per-entry sprite init).
## VOID FILL
The Options **VOID FILL** row picks what paints the infinite beyond-edge
space on OVERWORLD-tileset maps during survey zoom:
- **TREES** (default): solid tree wall block `$0F`.
- **WATER**: animated water tile `$14` (same hshift cycle as on-map water).
- **BLACK**: solid black.
Other tilesets are unchanged (house/cave borders stay as authored).
Persisted as `save.options.voidFill`.
## Tilt mode ## Tilt mode
The `3` key (and the Options menu TILT row) cycles a visual-only perspective The `3` key (and the Options menu TILT row) cycles a visual-only perspective
@@ -139,7 +154,7 @@ Nidorino-vs-Gengar attract scene) mirror the original.
Options persist in a standalone `options.lua` (separate from the game Options persist in a standalone `options.lua` (separate from the game
progress `save.lua`), so audio/display/battle preferences survive New Game progress `save.lua`), so audio/display/battle preferences survive New Game
and aren't wiped when a save slot is cleared. Changing a row in the Options and aren't wiped when a save slot is cleared. Changing a row in the Options
menu or cycling hotkeys `2`/`3`/`5` writes immediately; an in-game save also menu or cycling hotkeys `2`/`3`/`4`/`5` writes immediately; an in-game save also
flushes the live options. Old saves that still embed an `options` table are flushes the live options. Old saves that still embed an `options` table are
migrated once into `options.lua` on load. migrated once into `options.lua` on load.
@@ -150,6 +165,18 @@ migrated once into `options.lua` on load.
hotkey `2` (OG RED = GBC boot-ROM look; RED++ uses pokered-gbc hotkey `2` (OG RED = GBC boot-ROM look; RED++ uses pokered-gbc
SuperPalettes + per-species mon colors) SuperPalettes + per-species mon colors)
- TILT (OFF / 15 / 35 / 50), also hotkey `3` while free-roaming - TILT (OFF / 15 / 35 / 50), also hotkey `3` while free-roaming
- ZOOM (FIT / OUTn / INn), also hotkey `4` while free-roaming; wheel and
`-`/`=` step one level and save
- VOID FILL (TREES / WATER / BLACK) for OVERWORLD beyond-edge space
- GBC FX (OFF / 1 / 2 / 3 / 4), also hotkey `5` - GBC FX (OFF / 1 / 2 / 3 / 4), also hotkey `5`
- MAX FPS (30 / 40 / 50 / 60 / 75 / 90 / 100 / 120 / 144 / 160, default 60), - MAX FPS (30 / 40 / 50 / 60 / 75 / 90 / 100 / 120 / 144 / 160, default 60),
a hard render frame-rate cap (`save.options.fpsCap`). a hard render frame-rate cap (`save.options.fpsCap`).
## Battle transition cascade + white battle letterbox
Into-battle wipes still run the original eight styles inside the classic
160×144 letterbox. On wide/tall windows (survey zoom), matching black 8×8
blocks cascade outward from that square into the surrounding world so the
void outside the OG wipe fills in lockstep. Once the battle state is up,
letterbox voids around the battle canvas fill **white** instead of black
so the whole window reads as one continuous battle screen.
+225 -49
View File
@@ -30,6 +30,9 @@ local TypeChart = require("src.battle.TypeChart")
local BattleState = {} local BattleState = {}
BattleState.__index = BattleState BattleState.__index = BattleState
BattleState.isOpaque = true BattleState.isOpaque = true
-- Letterbox voids around the 160x144 battle canvas fill white so the
-- window reads as one continuous battle screen (no black bars).
BattleState.letterboxWhite = true
-- Battle colors itself per-pixel (species pics + HP bar tints), so the -- Battle colors itself per-pixel (species pics + HP bar tints), so the
-- SGB whole-screen remap must not run over it. -- SGB whole-screen remap must not run over it.
@@ -52,6 +55,10 @@ local imageCache = {}
-- fully transparent rows below a pic's content (the extracted 32x32 back -- 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 -- pics carry baked-in padding); used to sit the pic flush on the text box
local imagePadBottom = {} 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 -- image -> { path, pal } so palette-fade variants (see fadeImage) can be
-- rebuilt for any battle pic, whatever code loaded it -- rebuilt for any battle pic, whatever code loaded it
local imageMeta = {} local imageMeta = {}
@@ -63,7 +70,7 @@ local function getImage(path, pal, trueColor)
if trueColor then pal = nil end if trueColor then pal = nil end
local key = pal and (path .. "#" .. pal.name) or path local key = pal and (path .. "#" .. pal.name) or path
if not imageCache[key] then if not imageCache[key] then
local img, pad = nil, 0 local img, pad, padL = nil, 0, 0
if love.image and love.image.newImageData then if love.image and love.image.newImageData then
local id = Assets.imageData(path) local id = Assets.imageData(path)
if pal then if pal then
@@ -86,13 +93,25 @@ local function getImage(path, pal, trueColor)
if opaque then break end if opaque then break end
bottom = bottom - 1 bottom = bottom - 1
end 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) img = love.graphics.newImage(id)
pad = h - 1 - bottom pad = h - 1 - bottom
padL = left
else else
img = Assets.image(path) -- headless stub: no pixel access img = Assets.image(path) -- headless stub: no pixel access
end end
imageCache[key] = img imageCache[key] = img
imagePadBottom[img] = pad imagePadBottom[img] = pad
imagePadLeft[img] = padL
imageMeta[img] = { path = path, pal = pal, trueColor = trueColor or nil } imageMeta[img] = { path = path, pal = pal, trueColor = trueColor or nil }
end end
return imageCache[key] return imageCache[key]
@@ -101,7 +120,7 @@ end
-- hot reload: the next getImage re-resolves every pic through the asset -- hot reload: the next getImage re-resolves every pic through the asset
-- search path and re-measures its ground padding -- search path and re-measures its ground padding
function BattleState.invalidate() function BattleState.invalidate()
imageCache, imagePadBottom, imageMeta = {}, {}, {} imageCache, imagePadBottom, imagePadLeft, imageMeta = {}, {}, {}, {}
end end
Assets.register(BattleState.invalidate) Assets.register(BattleState.invalidate)
@@ -258,6 +277,10 @@ local function makeBattler(data, mon, isPlayer, save)
} }
end 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 -- The battle pic for `species` on the given side (back pic for the
-- player side, front pic for the enemy side), tinted PAL_GRAYMON -- -- player side, front pic for the enemy side), tinted PAL_GRAYMON --
-- the same path makeBattler uses, but forced gray -- since this is only -- the same path makeBattler uses, but forced gray -- since this is only
@@ -832,7 +855,7 @@ function BattleState:computeMusicKind()
return "final" return "final"
elseif isBoss or (self.trainer and self.trainer.id == "OPP_LANCE") then elseif isBoss or (self.trainer and self.trainer.id == "OPP_LANCE") then
return "gym" return "gym"
elseif self.kind == "trainer" then elseif self.kind == "trainer" or self.kind == "link" then
return "trainer" return "trainer"
end end
return "wild" return "wild"
@@ -894,6 +917,17 @@ function BattleState:enter()
self.showEnemyTrainer = false self.showEnemyTrainer = false
self:startGrowIn(self.enemy) self:startGrowIn(self.enemy)
end) 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 end
if not self.ghost then if not self.ghost then
-- the enemy's cry plays as it appears (data/pokemon/cries.asm) -- the enemy's cry plays as it appears (data/pokemon/cries.asm)
@@ -950,20 +984,37 @@ function BattleState:exit()
end end
end end
-- An action the battler is locked into (bypasses the menu), or nil. -- End a trapping sequence (USING_TRAPPING_MOVE). SendOutMon clears the
function BattleState:lockedAction(battler) -- 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)
if battler.mustRecharge then return { special = "recharge" } end if battler.mustRecharge then return { special = "recharge" } end
if battler.charging then return battler.charging end if battler.charging then return battler.charging end
if battler.thrashTurns and battler.thrashTurns > 0 then return battler.thrashMove 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 if battler.trappingTurns and battler.trappingTurns > 0 then
return { special = "trapping" } return { special = "trapping" }
end end
if battler.bideTurns then return { special = "bide" } end if battler.bideTurns then return { special = "bide" } end
if battler.rageMove then return battler.rageMove end -- held while the OPPONENT's trapping bit is set (live mirror so a
-- held in place while the OPPONENT's trapping move is running -- trap ended early by paralysis/faint frees the victim immediately)
-- (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 local opp = battler.isPlayer and self.enemy or self.player
battler.boundTurns = opp and opp.trappingTurns battler.boundTurns = opp and opp.trappingTurns
and math.max(1, opp.trappingTurns) or nil and math.max(1, opp.trappingTurns) or nil
@@ -973,6 +1024,11 @@ function BattleState:lockedAction(battler)
return nil return nil
end 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() function BattleState:playerHasPP()
for i, mv in ipairs(self.player.curMoves) do for i, mv in ipairs(self.player.curMoves) do
if mv.pp > 0 and self.player.disabledSlot ~= i then return true end if mv.pp > 0 and self.player.disabledSlot ~= i then return true end
@@ -1079,8 +1135,9 @@ function BattleState:update(dt)
if not (self.player.mustRecharge or self.player.rageMove) then if not (self.player.mustRecharge or self.player.rageMove) then
self.player.flinched, self.enemy.flinched = false, false self.player.flinched, self.enemy.flinched = false, false
end end
-- locked multi-turn actions skip the menu entirely -- only recharge/Rage/thrash/charge skip DisplayBattleMenu; trapping
local locked = self:lockedAction(self.player) -- victims (and wrappers) still get FIGHT/PKMN/ITEM/RUN (core.asm:312)
local locked = self:menuLockedAction(self.player)
if locked then if locked then
self:resolveTurn(locked) self:resolveTurn(locked)
return return
@@ -1104,6 +1161,13 @@ function BattleState:update(dt)
end) end)
self:act(function() self:endOfTurn() end) self:act(function() self:endOfTurn() end)
elseif choice == "fight" then 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 if not self:playerHasPP() then
-- _NoMovesLeftText, then Struggle engages -- _NoMovesLeftText, then Struggle engages
self:say(("%s has no\nmoves left!"):format(self.player.name)) self:say(("%s has no\nmoves left!"):format(self.player.name))
@@ -1514,6 +1578,9 @@ function BattleState:resolveSwitch(newMon)
self:restoreMimicked(self.player) -- the battle copy leaves with it self:restoreMimicked(self.player) -- the battle copy leaves with it
local previous = self.player local previous = self.player
self.player = makeBattler(self.data, newMon, true, self.game.save) 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() self:syncSides()
Runtime.emit("battle.battler_switched", { Runtime.emit("battle.battler_switched", {
battle = self, side = self.sides[1], battler = self.player, battle = self, side = self.sides[1], battler = self.player,
@@ -1619,10 +1686,19 @@ end
-- only after MoveHitTest / the effect lands (HandleIfPlayerMoveMissed skips -- only after MoveHitTest / the effect lands (HandleIfPlayerMoveMissed skips
-- it on a miss unless EXPLODE_EFFECT); we insert early for blink attachment -- it on a miss unless EXPLODE_EFFECT); we insert early for blink attachment
-- and peel it back on miss/fail paths. -- 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() function BattleState:cancelMoveAnim()
local row = self.moveAnimRow local row = self.moveAnimRow
if not row then return end if not row then return end
self.moveAnimRow = nil 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 for i, item in ipairs(self.queue) do
if item == row then if item == row then
table.remove(self.queue, i) table.remove(self.queue, i)
@@ -1686,14 +1762,23 @@ end
-- transient pic effects reset when a new animation row starts (each -- transient pic effects reset when a new animation row starts (each
-- PlayAnimation redraws from a clean slate); `minimized` survives -- -- PlayAnimation redraws from a clean slate); `minimized` survives --
-- the minimize sprite replaces the pic DATA, so redraws keep it until -- the minimize sprite replaces the pic DATA until reload. Dig/Fly's
-- the pic is reloaded (switch/Transform/ChangeMonPic) -- 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.
function BattleState:resetPicFx() function BattleState:resetPicFx()
if not self.picFx then return end if not self.picFx then return end
for _, pf in pairs(self.picFx) do 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
pf.kind, pf.t = nil, nil pf.kind, pf.t = nil, nil
pf.ox, pf.oy = 0, 0 pf.ox, pf.oy = 0, 0
pf.hidden = nil local keepHide = battler.invulnerable or battler == digFlyUser
if not keepHide then
pf.hidden = nil
end
end end
end end
@@ -2094,6 +2179,8 @@ function BattleState:executeAction(user, target, action)
local oldName = self.enemy.name local oldName = self.enemy.name
self.enemyIndex = action.index self.enemyIndex = action.index
self.enemy = makeBattler(self.data, self.enemyParty[action.index], false) self.enemy = makeBattler(self.data, self.enemyParty[action.index], false)
-- EnemySendOutFirstMon (core.asm:1314-1315): clears player's trap
clearTrapping(self.player)
self:syncSides() self:syncSides()
Runtime.emit("battle.battler_switched", { Runtime.emit("battle.battler_switched", {
battle = self, side = self.sides[2], battler = self.enemy, battle = self, side = self.sides[2], battler = self.enemy,
@@ -2144,6 +2231,43 @@ function BattleState:executeAction(user, target, action)
self:performMove(user, target, action, false) self:performMove(user, target, action, false)
end 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: -- The pre-recharge slice of CheckPlayerStatusConditions (core.asm:
-- 3328-3382): sleep -> freeze -> held-in-place -> flinch, each losing -- 3328-3382): sleep -> freeze -> held-in-place -> flinch, each losing
-- the turn WITHOUT consuming the recharge flag. The disable/confusion/ -- the turn WITHOUT consuming the recharge flag. The disable/confusion/
@@ -2162,7 +2286,7 @@ function BattleState:preRechargeChecks(user, target)
mon.status = nil mon.status = nil
self:sayNext(displayName(user) .. "\nwoke up!") self:sayNext(displayName(user) .. "\nwoke up!")
else else
self:sayNext(displayName(user) .. "\nis fast asleep!") self:statusOnomatopoeia(user, "sleep")
end end
return true return true
end end
@@ -2189,7 +2313,7 @@ end
-- returns true when the user's action is interrupted. -- returns true when the user's action is interrupted.
function BattleState:statusInterrupt(user, target) function BattleState:statusInterrupt(user, target)
local canMove, msgs, selfHit = Status.beforeMove(user, self.rng, self) local canMove, msgs, selfHit = Status.beforeMove(user, self.rng, self)
for _, m in ipairs(msgs) do self:sayNext(prefixEnemy(m, user)) end for _, m in ipairs(msgs) do self:sayStatusMsg(user, m) end
if selfHit then if selfHit then
-- confusion self-hit (core.asm:3428-3434): clears everything in -- confusion self-hit (core.asm:3428-3434): clears everything in
-- status1 except CONFUSED, then HandleSelfConfusionDamage deals a -- status1 except CONFUSED, then HandleSelfConfusionDamage deals a
@@ -2266,11 +2390,23 @@ function BattleState:performMove(user, target, moveInst, isCalled)
user.charging, user.chargeReady, user.invulnerable = nil, nil, nil user.charging, user.chargeReady, user.invulnerable = nil, nil, nil
end end
-- PP: not for continuations, struggle, or called moves -- 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).
-- That rule doesn't apply in a link battle: "the enemy" there is a real
-- human peer independently tracking their own PP the normal way, not an
-- AI Gen 1 never bothered to decrement -- applying it made each side
-- silently skip decrementing the OTHER side's PP for the move it just
-- used, so both simulations' party state diverged by exactly 1 PP on
-- the very first move either side made, failing the lockstep hash
-- check turn 1 of literally every link battle.
local isContinuation = releasing local isContinuation = releasing
or (user.thrashTurns and user.thrashTurns > 0 and moveInst == user.thrashMove) or (user.thrashTurns and user.thrashTurns > 0 and moveInst == user.thrashMove)
or moveInst == user.rageMove or moveInst == user.rageMove
if not isContinuation and not moveInst.struggle and not isCalled then local enemyUnlimited = not user.isPlayer and self.kind ~= "link"
and self.ruleset and self.ruleset.enemyUnlimitedPP
if not isContinuation and not moveInst.struggle and not isCalled
and not enemyUnlimited then
moveInst.pp = math.max(0, moveInst.pp - 1) moveInst.pp = math.max(0, moveInst.pp - 1)
end end
@@ -2574,8 +2710,20 @@ function BattleState:enemyMonFainted()
self.participants = {} self.participants = {}
if self.kind == "trainer" then if self.kind == "trainer" then
if self.enemyIndex < #self.enemyParty then -- EnemySendOutFirstMon / AnyEnemyPokemonAliveCheck (core.asm): scan
self.enemyIndex = self.enemyIndex + 1 -- 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
-- SHIFT battle style (the default): announce the next mon and -- SHIFT battle style (the default): announce the next mon and
-- offer a free switch (SET skips the prompt) -- offer a free switch (SET skips the prompt)
local nextMon = self.enemyParty[self.enemyIndex] local nextMon = self.enemyParty[self.enemyIndex]
@@ -2599,6 +2747,7 @@ function BattleState:enemyMonFainted()
if mon ~= self.player.mon and mon.hp > 0 then if mon ~= self.player.mon and mon.hp > 0 then
local previous = self.player local previous = self.player
self.player = makeBattler(self.data, mon, true, game.save) self.player = makeBattler(self.data, mon, true, game.save)
clearTrapping(self.enemy) -- SendOutMon clears foe trap
self:syncSides() self:syncSides()
Runtime.emit("battle.battler_switched", { Runtime.emit("battle.battler_switched", {
battle = self, side = self.sides[1], battle = self, side = self.sides[1],
@@ -2624,6 +2773,8 @@ function BattleState:enemyMonFainted()
self:act(function() self:act(function()
local previous = self.enemy local previous = self.enemy
self.enemy = makeBattler(self.data, self.enemyParty[self.enemyIndex], false) self.enemy = makeBattler(self.data, self.enemyParty[self.enemyIndex], false)
-- EnemySendOutFirstMon (core.asm:1314-1315): clears player's trap
clearTrapping(self.player)
self:syncSides() self:syncSides()
Runtime.emit("battle.battler_switched", { Runtime.emit("battle.battler_switched", {
battle = self, side = self.sides[2], battler = self.enemy, battle = self, side = self.sides[2], battler = self.enemy,
@@ -2777,6 +2928,7 @@ function BattleState:openReplacementMenu()
self:restoreMimicked(self.player) self:restoreMimicked(self.player)
local previous = self.player local previous = self.player
self.player = makeBattler(self.data, mon, true, game.save) self.player = makeBattler(self.data, mon, true, game.save)
clearTrapping(self.enemy) -- SendOutMon clears foe trap
self:syncSides() self:syncSides()
Runtime.emit("battle.battler_switched", { Runtime.emit("battle.battler_switched", {
battle = self, side = self.sides[1], battler = self.player, battle = self, side = self.sides[1], battler = self.player,
@@ -3474,17 +3626,16 @@ function BattleState:drawBattlerPic(battler, x, y, scale)
love.graphics.draw(img, quad, x + ox, y + oy, 0, scale, scale) love.graphics.draw(img, quad, x + ox, y + oy, 0, scale, scale)
end end
elseif k == "slideUp" then elseif k == "slideUp" then
-- AnimationSlideMonUp: cyclic upward wrap, one row per 2 frames -- AnimationSlideMonUp (animations.asm): 7 row steps x 2f. After Dig's
local scroll = 8 * math.min(7, math.floor(t / 2) + 1) -- SLIDE_DOWN the tilemap is blank; each step fills the next bottom
local src = math.floor(scroll / scale) % h -- row so the mon emerges from underground. A cyclic wrap of a full
if src == 0 then -- pic looked like a bounce at Dig's end (#100).
love.graphics.draw(img, x + ox, y, 0, scale, scale) local step = math.min(7, math.floor((t - 1) / 2) + 1)
else local visible = math.floor(h * step / 7)
local top = love.graphics.newQuad(0, src, w, h - src, w, h) if visible > 0 then
love.graphics.draw(img, top, x + ox, y, 0, scale, scale) local quad = love.graphics.newQuad(0, h - visible, w, visible, w, h)
local bottom = love.graphics.newQuad(0, 0, w, src, w, h) love.graphics.draw(img, quad, x + ox,
love.graphics.draw(img, bottom, x + ox, y + (h - src) * scale, y + (h - visible) * scale, 0, scale, scale)
0, scale, scale)
end end
elseif xscale < 1 then elseif xscale < 1 then
-- AnimationSquishMonPic: columns collapse toward the middle -- AnimationSquishMonPic: columns collapse toward the middle
@@ -3696,6 +3847,20 @@ function BattleState:drawAnimLayer(colorized)
end end
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 -- 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 -- shake -- on the GB the pics are BG tiles, so they move with it
function BattleState:drawPicsLayer(slide, sx, sy) function BattleState:drawPicsLayer(slide, sx, sy)
@@ -3713,19 +3878,18 @@ function BattleState:drawPicsLayer(slide, sx, sy)
g.intersectScissor(0, 0, 160, clipY) g.intersectScissor(0, 0, 160, clipY)
clipped = true clipped = true
end end
-- Enemy: front sprite top-right (GB: pic at hlcoord 12,0). -- Enemy: front sprite in the 7x7 slot at hlcoord 12,0.
if self.showEnemyTrainer and self.trainerPic then if self.showEnemyTrainer and self.trainerPic then
-- the enemy trainer pic holds the mon slot until the send-out -- the enemy trainer pic holds the mon slot until the send-out
local img = self:picImage(self.trainerPic) local img = self:picImage(self.trainerPic)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(img, 160 - 8 - img:getWidth() - slide + sx, local ex, ey = enemyPicXY(img, slide, sx, sy)
math.max(0, 48 - img:getHeight()) + sy) love.graphics.draw(img, ex, ey)
elseif self.enemy and self.enemy.sprite and not self.enemyHidden elseif self.enemy and self.enemy.sprite and not self.enemyHidden
and not self.enemySendingOut and not self:fxHidden(self.enemy) then and not self.enemySendingOut and not self:fxHidden(self.enemy) then
local img = self:picImage(self.enemy.sprite) local img = self:picImage(self.enemy.sprite)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
local ex = 160 - 8 - img:getWidth() - slide + sx local ex, ey = enemyPicXY(img, slide, sx, sy)
local ey = math.max(0, 48 - img:getHeight()) + sy
local gs = self:growInScale(self.enemy) local gs = self:growInScale(self.enemy)
if gs then if gs then
-- AnimateSendingOutMon: the downscaled pic keeps its bottom edge -- AnimateSendingOutMon: the downscaled pic keeps its bottom edge
@@ -3739,15 +3903,18 @@ function BattleState:drawPicsLayer(slide, sx, sy)
end end
end end
-- Player: back sprite bottom-left (2x like the GB, feet near y=100). -- 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.
local hidePlayer = self.safari or self.demo local hidePlayer = self.safari or self.demo
if self.showPlayerBack and self.playerBackPic then if self.showPlayerBack and self.playerBackPic then
-- Red's (or the old man's) back pic until "Go!"; it stays up for -- Red's (or the old man's) back pic until "Go!"; it stays up for
-- the whole safari / catch-demo battle like the original -- the whole safari / catch-demo battle like the original
local img = self:picImage(self.playerBackPic) local img = self:picImage(self.playerBackPic)
local pad = imagePadBottom[self.playerBackPic] or 0 local pad = imagePadBottom[self.playerBackPic] or 0
local padL = imagePadLeft[self.playerBackPic] or 0
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(img, 16 + slide + sx, love.graphics.draw(img, 8 - padL * 2 + slide + sx,
96 - (img:getHeight() - pad) * 2 + sy, 0, 2, 2) 96 - (img:getHeight() - pad) * 2 + sy, 0, 2, 2)
elseif self.player and self.player.sprite and not hidePlayer elseif self.player and self.player.sprite and not hidePlayer
and not self.sendingOut and not self:fxHidden(self.player) then and not self.sendingOut and not self:fxHidden(self.player) then
@@ -3755,17 +3922,19 @@ function BattleState:drawPicsLayer(slide, sx, sy)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
-- feet flush on the text box top (y=96), ignoring baked-in padding -- feet flush on the text box top (y=96), ignoring baked-in padding
local pad = imagePadBottom[self.player.sprite] or 0 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) local gs = self:growInScale(self.player)
if gs then if gs then
-- the player-side AnimateSendingOutMon grow (after the poof, -- the player-side AnimateSendingOutMon grow (after the poof,
-- core.asm:1757-1762): feet pinned at y=96, center at x=16+w -- core.asm:1757-1762): feet pinned at y=96, center at x=8+w
if gs > 0 then if gs > 0 then
love.graphics.draw(img, 16 + img:getWidth() * (1 - gs) + sx, love.graphics.draw(img, px + img:getWidth() * (1 - gs),
96 - (img:getHeight() - pad) * 2 * gs + sy, 96 - (img:getHeight() - pad) * 2 * gs + sy,
0, 2 * gs, 2 * gs) 0, 2 * gs, 2 * gs)
end end
else else
self:drawBattlerPic(self.player, 16 + sx, self:drawBattlerPic(self.player, px,
96 - (img:getHeight() - pad) * 2 + sy, 2) 96 - (img:getHeight() - pad) * 2 + sy, 2)
end end
end end
@@ -3820,16 +3989,22 @@ function BattleState:drawHUDs(slide)
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
Font.draw(("BALLx%2d"):format(self.safari.balls), 88, 72) Font.draw(("BALLx%2d"):format(self.safari.balls), 88, 72)
end end
-- trainer-battle party pokeball rows during the intro -- trainer/link party pokeball rows during the intro
-- (SetupPlayerAndEnemyPokeballs, draw_hud_pokeball_gfx.asm) -- (SetupPlayerAndEnemyPokeballs, draw_hud_pokeball_gfx.asm)
if self.kind == "trainer" and (self.showEnemyTrainer or self.showPlayerBack) local showIntroBalls = slide == 0 and (
and slide == 0 then (self.kind == "trainer" and (self.showEnemyTrainer or self.showPlayerBack))
or (self.kind == "link" and (self.showPlayerBack or self.enemySendingOut))
)
if showIntroBalls then
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
if self.showEnemyTrainer and self.enemyParty then if self.enemyParty and (
(self.kind == "trainer" and self.showEnemyTrainer)
or (self.kind == "link" and self.enemySendingOut)
) then
self:drawBallRow(self.enemyParty, 64, 16, -8) self:drawBallRow(self.enemyParty, 64, 16, -8)
end end
if self.showPlayerBack then if self.showPlayerBack then
self:drawBallRow(self.game.save.party, 88, 80, 8) self:drawBallRow(self.playerParty or self.game.save.party, 88, 80, 8)
end end
end end
local hidePlayer = self.safari or self.demo local hidePlayer = self.safari or self.demo
@@ -3863,7 +4038,8 @@ function BattleState:drawTextArea()
if self.phase == "messages" and self.current then if self.phase == "messages" and self.current then
local shown = 0 local shown = 0
for li, codes in ipairs(self.lines) do for li, codes in ipairs(self.lines) do
local y = 104 + li * 8 -- battle text uses every other tile row (hlcoord *,14 / *,16)
local y = 112 + (li - 1) * 16
for i = 1, #codes do for i = 1, #codes do
if shown >= self.charIndex then break end if shown >= self.charIndex then break end
Font.drawCode(codes[i], 8 + (i - 1) * 8, y) Font.drawCode(codes[i], 8 + (i - 1) * 8, y)
+36 -27
View File
@@ -176,25 +176,51 @@ function EffectRegistry.runDamaging(battle, ctx, record)
end end
battle.lastDamage = dmg -- wDamage (shared by both sides, read by Counter) battle.lastDamage = dmg -- wDamage (shared by both sides, read by Counter)
-- the hit blink + damage sound ride the queue behind the animation: -- the hit blink + damage sound ride each animation row, placed BEFORE
-- on the move's anim row when one was announced, else on a bare hit -- that hit's drain so the blink precedes the bar. Multi-hit moves
-- row (thrash/rage continuations), placed BEFORE the drain rows the -- replay PlayMoveAnimation per strike (pokered: GetPlayerAnimationType
-- hits loop inserts so the blink precedes the bar drain -- / GetEnemyAnimationType loop on wNumAttacksLeft); hit 1 reuses the
local hitRow = battle.moveAnimRow -- announcement-time moveAnimRow, later hits queue fresh anim rows.
if not hitRow then -- Thrash/rage continuations have no announcement anim -- a bare
battle.nextInsert = (battle.nextInsert or 0) + 1 -- hitRow carries the blink instead.
hitRow = { hitRow = true } local hitSfx = info.typeMult > 10 and "Super_Effective"
table.insert(battle.queue, battle.nextInsert, hitRow) or info.typeMult < 10 and "Not_Very_Effective" or "Damage"
end local hitFx = { sfx = hitSfx,
blink = battle:animationsOn() and target or nil }
local totalDealt = 0 local totalDealt = 0
local landed, brokeSub = 0, false local landed, brokeSub = 0, false
for h = 1, hits do for h = 1, hits do
if target.mon.hp <= 0 then break end if target.mon.hp <= 0 then break end
local hitRow
if h == 1 then
hitRow = battle.moveAnimRow
if not hitRow then
battle.nextInsert = (battle.nextInsert or 0) + 1
hitRow = { hitRow = true }
table.insert(battle.queue, battle.nextInsert, hitRow)
end
else
battle.nextInsert = (battle.nextInsert or 0) + 1
hitRow = { anim = move.id, attackerIsPlayer = user.isPlayer }
table.insert(battle.queue, battle.nextInsert, hitRow)
end
local hadSub = target.substituteHP ~= nil local hadSub = target.substituteHP ~= nil
local dealt = battle:applyDamage(target, dmg) local dealt = battle:applyDamage(target, dmg)
totalDealt = totalDealt + dealt totalDealt = totalDealt + dealt
landed = h landed = h
if dealt > 0 then hitRow.hit = hitFx end
-- PrintCriticalOHKOText + DisplayEffectiveness run inside the
-- multi-hit loop (core.asm .moveDidNotMiss before the jump back
-- to GetPlayerAnimationType), so crit/effectiveness reprint on
-- every strike -- damage was only rolled once
if info.crit then battle:sayNext("Critical hit!") end
if info.ohko then battle:sayNext("One-hit KO!") end
if info.typeMult > 10 then
battle:sayNext("It's super\neffective!")
elseif info.typeMult < 10 then
battle:sayNext("It's not very\neffective...")
end
if Runtime.wants("battle.damage_dealt") then if Runtime.wants("battle.damage_dealt") then
Runtime.emit("battle.damage_dealt", { Runtime.emit("battle.damage_dealt", {
battle = battle, user = user, target = target, move = move, battle = battle, user = user, target = target, move = move,
@@ -208,23 +234,6 @@ function EffectRegistry.runDamaging(battle, ctx, record)
end end
end end
hits = landed > 0 and landed or hits hits = landed > 0 and landed or hits
if totalDealt > 0 then
-- the original's per-hit sound: normal / super / not-very-effective
local hitSfx = info.typeMult > 10 and "Super_Effective"
or info.typeMult < 10 and "Not_Very_Effective" or "Damage"
hitRow.hit = { sfx = hitSfx,
blink = battle:animationsOn() and target or nil }
end
-- PrintCriticalOHKOText prints "Critical hit!"/"One-hit KO!" right
-- after the damage lands, BEFORE DisplayEffectiveness (core.asm
-- .moveDidNotMiss); the multi-hit count follows the last hit
if info.crit then battle:sayNext("Critical hit!") end
if info.ohko then battle:sayNext("One-hit KO!") end
if info.typeMult > 10 then
battle:sayNext("It's super\neffective!")
elseif info.typeMult < 10 then
battle:sayNext("It's not very\neffective...")
end
if hits > 1 then if hits > 1 then
-- player: _MultiHitText; enemy: _HitXTimesText (always plural) -- player: _MultiHitText; enemy: _HitXTimesText (always plural)
if user.isPlayer then if user.isPlayer then
+7 -2
View File
@@ -15,7 +15,8 @@
-- (super-effective) or adds 1 (not-effective when a better move exists); -- (super-effective) or adds 1 (not-effective when a better move exists);
-- the MINIMUM-scored move is chosen, ties broken uniformly among the -- the MINIMUM-scored move is chosen, ties broken uniformly among the
-- tied minima (core.asm:2971-3002). A non-minimal move is never -- tied minima (core.asm:2971-3002). A non-minimal move is never
-- selectable. Respects PP, Disable and Transform/Mimic move overrides. -- selectable. Respects Disable (and PP only when the ruleset depletes
-- enemy PP — Gen 1 AI never reads wEnemyMonPP).
local TypeChart = require("src.battle.TypeChart") local TypeChart = require("src.battle.TypeChart")
@@ -212,9 +213,13 @@ end
function TrainerAI.chooseMove(battler, rng, battle) function TrainerAI.chooseMove(battler, rng, battle)
rng = rng or love.math.random 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 = {} local usable = {}
for i, mv in ipairs(battler.curMoves) do for i, mv in ipairs(battler.curMoves) do
if mv.pp > 0 and battler.disabledSlot ~= i then if battler.disabledSlot ~= i and (unlimited or mv.pp > 0) then
table.insert(usable, mv) table.insert(usable, mv)
end end
end end
+3
View File
@@ -13,4 +13,7 @@ return {
randMax = 255, randMax = 255,
-- Focus Energy famously QUARTERS the crit rate instead of x4 -- Focus Energy famously QUARTERS the crit rate instead of x4
focusEnergyBug = true, focusEnergyBug = true,
-- Wild/trainer enemies never spend PP (DecrementPP only touches the
-- player side in pokered). They therefore never Struggle from empty PP.
enemyUnlimitedPP = true,
} }
+2
View File
@@ -9,4 +9,6 @@ return {
randMin = 217, randMin = 217,
randMax = 255, randMax = 255,
focusEnergyBug = false, focusEnergyBug = false,
-- Gen 2+ style: AI opponents deplete PP and can Struggle when empty.
enemyUnlimitedPP = false,
} }
+131 -2
View File
@@ -38,8 +38,18 @@ local state = {
unsubs = {}, unsubs = {},
pid = nil, pid = nil,
loggedAbsent = false, -- one quiet note when Discord isn't running loggedAbsent = false, -- one quiet note when Discord isn't running
game = nil, -- kept for the "friend clicked join" -> push a screen path
joinCode = nil, -- the online-match/tournament code to advertise as a join secret, if any
joinKind = "match", -- "match" (LinkState online) or "tournament"
partySize = 1, -- current headcount for the party.size Discord shows
partyMax = 2,
subscribedJoin = false, -- SUBSCRIBE ACTIVITY_JOIN sent on this connection?
} }
-- MSG_DONTWAIT differs by OS (Darwin 0x80, Linux 0x40); only used for the
-- ongoing per-frame inbound poll, never for the one-time connect/handshake
local MSG_DONTWAIT_BY_OS = { ["OS X"] = 0x80, Linux = 0x40 }
local function disabledByEnv() local function disabledByEnv()
return os.getenv("POKEPORT_NO_DISCORD") == "1" return os.getenv("POKEPORT_NO_DISCORD") == "1"
or os.getenv("POKEPORT_AUTOPILOT") ~= nil or os.getenv("POKEPORT_AUTOPILOT") ~= nil
@@ -378,6 +388,15 @@ local function connect()
state.dirty = true state.dirty = true
state.lastSentKey = nil state.lastSentKey = nil
state.loggedAbsent = false state.loggedAbsent = false
state.subscribedJoin = false
-- ask for "friend clicked join" dispatches; a plain outbound write, so
-- it's harmless to send even on Windows, where the inbound poll below
-- isn't implemented yet (see pollJoinRequest)
if sendFrame(OP_FRAME, Json.encode({
cmd = "SUBSCRIBE", evt = "ACTIVITY_JOIN", nonce = nonce(),
})) then
state.subscribedJoin = true
end
Logger.info("discord: rich presence connected") Logger.info("discord: rich presence connected")
return true return true
end) end)
@@ -427,7 +446,7 @@ local function buildActivity()
activityState = "At the title screen" activityState = "At the title screen"
end end
return { local activity = {
details = details, details = details,
state = activityState, state = activityState,
timestamps = { start = state.startedAt }, timestamps = { start = state.startedAt },
@@ -436,10 +455,25 @@ local function buildActivity()
large_text = "Pokemon Gen1Recomp", large_text = "Pokemon Gen1Recomp",
}, },
} }
-- a party/join-secret pair is what makes Discord show "Ask to Join" on
-- the profile card; the secret carries our own room/tournament code plus
-- a one-letter kind tag ("m"/"t") so the click handler on the other end
-- knows which screen to jump into -- Discord only echoes the string
-- back, so the kind has to ride inside it
if state.joinCode then
local kindTag = state.joinKind == "tournament" and "t" or "m"
activity.party = { id = "pokeport-" .. state.joinCode,
size = { state.partySize, state.partyMax } }
activity.secrets = { join = "join:" .. kindTag .. ":" .. state.joinCode }
end
return activity
end end
local function activityKey(activity) local function activityKey(activity)
return (activity.details or "") .. "|" .. (activity.state or "") local partyKey = activity.party
and (activity.party.size[1] .. "/" .. activity.party.size[2]) or ""
return (activity.details or "") .. "|" .. (activity.state or "") .. "|"
.. (activity.secrets and activity.secrets.join or "") .. "|" .. partyKey
end end
local function flush(force) local function flush(force)
@@ -567,6 +601,96 @@ local function subscribe(game)
end) end)
end end
-- Advertise (or clear, with code=nil) an online-match/tournament code as a
-- Discord join secret. LinkState/Tournament call this once they have a
-- real code from the relay, and clear it again once paired/started or the
-- hosting screen exits (an invite that's already full or gone is worse
-- than no invite). kind is "match" (default) or "tournament"; size/max are
-- the party.size Discord shows (default 1/2, a plain 1v1 room) -- a
-- tournament passes its live roster count and a generous cap instead, and
-- should call this again whenever the roster changes, not just once.
function DiscordPresence.setJoinCode(code, kind, size, max)
pcall(function()
state.joinCode = code
state.joinKind = kind or "match"
state.partySize = size or 1
state.partyMax = max or 2
state.dirty = true
flush(false)
end)
end
-- A friend clicked "Ask to Join" on our profile and Discord delivered the
-- secret back over this same connection. Only act from a safe spot (never
-- yank the player out of a battle or an already-running link session) --
-- "exploring"/"menu" are the coarse buckets subscribe() above already
-- tracks; anything more specific (a shop, a dialogue box) is a rarer miss
-- worth accepting rather than adding a second, finer-grained state tracker
-- just for this.
local function handleJoinRequest(secret)
if not secret or secret == "" then return end
if state.activity == "battle" then return end
local game = state.game
if not game or not game.stack then return end
local top = game.stack:top()
if top and top.stage and top.net then return end -- already in a link session
local kindTag, code = secret:match("^(%a):(.+)$")
if not kindTag then kindTag, code = "m", secret end -- older/plain secret: assume match
Runtime.emit("discord.join_requested", { code = code, kind = kindTag })
if kindTag == "t" then
local ok, Tournament = pcall(require, "src.link.Tournament")
if ok and Tournament.newJoinOnline then
game.stack:push(Tournament.newJoinOnline(game, code))
end
else
local ok, LinkState = pcall(require, "src.link.LinkState")
if ok and LinkState.newJoinOnline then
game.stack:push(LinkState.newJoinOnline(game, code))
end
end
end
-- non-blocking peek for an incoming ACTIVITY_JOIN dispatch. Unix (FFI)
-- only for now: reading a Windows named pipe without risking a stall needs
-- a HANDLE-level PeekNamedPipe that the plain io.open() connection below
-- doesn't give us, so a Windows player's presence/party/join-secret still
-- all work (they can still be seen and clicked), the click just isn't
-- delivered back to their own game session yet.
local function pollJoinRequest()
if state.isWindows or not state.ffi or not state.socket or not state.subscribedJoin then
return
end
pcall(function()
local ffi = state.ffi
local dontwait = MSG_DONTWAIT_BY_OS[love.system.getOS()] or 0
local hbuf = ffi.new("char[8]")
local n = ffi.C.recv(state.socket, hbuf, 8, dontwait)
if not n or tonumber(n) <= 0 then return end -- nothing waiting right now
local header = ffi.string(hbuf, 8)
local opcode = unpackU32(header:sub(1, 4))
local length = unpackU32(header:sub(5, 8))
if not opcode or not length or length < 0 or length > 65536 then return end
-- the body may lag the header by a packet; the connection's normal
-- (short-timeout) blocking read is fine here since it has already
-- announced its exact length
local body = length > 0 and readExact(length) or ""
if body == nil then return end
if opcode == 3 then -- PING -> PONG, keeps Discord from closing on us
sendFrame(4, body)
return
end
if opcode ~= 1 then return end
local decoded = Json.decode(body)
if decoded and decoded.evt == "ACTIVITY_JOIN" then
local secret = decoded.data and decoded.data.secret
if type(secret) == "string" and secret ~= "" then
if secret:sub(1, 5) == "join:" then secret = secret:sub(6) end
handleJoinRequest(secret)
end
end
end)
end
function DiscordPresence.init(game) function DiscordPresence.init(game)
local ok, err = pcall(function() local ok, err = pcall(function()
DiscordPresence.shutdown() DiscordPresence.shutdown()
@@ -579,6 +703,8 @@ function DiscordPresence.init(game)
state.location = "Title screen" state.location = "Title screen"
state.mapId = nil state.mapId = nil
state.battleLabel = nil state.battleLabel = nil
state.joinCode = nil
state.game = game
state.dirty = true state.dirty = true
state.nextReconnectAt = 0 state.nextReconnectAt = 0
state.loggedAbsent = false state.loggedAbsent = false
@@ -621,6 +747,7 @@ function DiscordPresence.update(_dt)
end end
return return
end end
pollJoinRequest()
if state.dirty then flush(false) end if state.dirty then flush(false) end
end) end)
end end
@@ -649,6 +776,8 @@ function DiscordPresence.shutdown()
state.lastSentKey = nil state.lastSentKey = nil
state.connected = false state.connected = false
state.socket = nil state.socket = nil
state.subscribedJoin = false
state.joinCode = nil
end end
-- test / debug helpers -- test / debug helpers
+8
View File
@@ -27,4 +27,12 @@ function FixedStep:update(dt)
end end
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 return FixedStep
+28 -2
View File
@@ -246,7 +246,11 @@ end
function Game:zoomStep(delta) function Game:zoomStep(delta)
local Zoom = require("src.render.Zoom") local Zoom = require("src.render.Zoom")
if not Zoom.gateOK(self.stack:top(), self.overworld) then return end if not Zoom.gateOK(self.stack:top(), self.overworld) then return end
Zoom.step(delta, Renderer:fitScale()) local offset = Zoom.step(delta, Renderer:fitScale())
if self.save and self.save.options then
self.save.options.zoom = offset
self:writeOptions()
end
end end
function Game:wheelmoved(_, dy) function Game:wheelmoved(_, dy)
@@ -320,9 +324,19 @@ function Game:keypressed(key)
self:writeOptions() self:writeOptions()
end end
return return
elseif key == "4" then
-- cycle ZOOM through every integer level (survey → FIT → close-up → wrap)
local Zoom = require("src.render.Zoom")
if Zoom.gateOK(self.stack:top(), self.overworld) then
self.save.options.zoom = Zoom.cycle(Renderer:fitScale())
self:writeOptions()
end
return
elseif key == "5" then elseif key == "5" then
-- cycle GBC FX OFF → 1 → 2 → 3 → 4 (unlit-GBC ladder); always on -- cycle GBC FX OFF → 1 → 2 → 3 → 4 (unlit-GBC ladder); always on
-- desktop. Mobile refuses the present shader (issue #136).
local GBCFX = require("src.render.GBCFX") local GBCFX = require("src.render.GBCFX")
if not GBCFX.isSupported() then return end
self.save.options.gbcfx = GBCFX.cycle() self.save.options.gbcfx = GBCFX.cycle()
self:writeOptions() self:writeOptions()
return return
@@ -443,12 +457,17 @@ function Game:applyOptions(opts)
if Sound.applyOptions then Sound.applyOptions(opts) end if Sound.applyOptions then Sound.applyOptions(opts) end
require("src.render.PaletteFX").applyOptions(opts) require("src.render.PaletteFX").applyOptions(opts)
require("src.render.Tilt").applyOptions(opts) require("src.render.Tilt").applyOptions(opts)
require("src.render.GBCFX").applyOptions(opts) require("src.render.Zoom").applyOptions(opts)
require("src.render.TileRenderer").applyOptions(opts)
-- returns true when a persisted GBC FX level was cleared on mobile
local gbcCleared = require("src.render.GBCFX").applyOptions(opts)
require("src.core.VideoMode").applyOptions(opts) require("src.core.VideoMode").applyOptions(opts)
-- normalizes a nil/garbage cap to the 60 default, so old saves with no -- normalizes a nil/garbage cap to the 60 default, so old saves with no
-- fpsCap key pace at the standard rate (issue #88) -- fpsCap key pace at the standard rate (issue #88)
require("src.core.FrameCap").applyOptions(opts) require("src.core.FrameCap").applyOptions(opts)
Input:applyBindings(opts.bindings) Input:applyBindings(opts.bindings)
-- heal soft-bricked APK installs that already saved gbcfx > 0 (#136)
if gbcCleared then self:writeOptions() end
end end
function Game:restoreSave(loaded, recovered) function Game:restoreSave(loaded, recovered)
@@ -460,6 +479,13 @@ function Game:restoreSave(loaded, recovered)
-- SaveData.load and skip on the format guard -- SaveData.load and skip on the format guard
local activeMods = self.modStatus and self.modStatus.loaded local activeMods = self.modStatus and self.modStatus.loaded
SaveData.runMigrations(loaded, self.mods and self.mods.migrations, activeMods) 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 modsDiff = SaveData.modsDiff(loaded, activeMods)
local report = SaveData.validate(loaded, self.data) local report = SaveData.validate(loaded, self.data)
report.recovered = recovered report.recovered = recovered
+5
View File
@@ -11,7 +11,12 @@ local DEFAULT_BINDINGS = {
z = "a", ["return"] = "a", space = "a", z = "a", ["return"] = "a", space = "a",
x = "b", backspace = "b", x = "b", backspace = "b",
["kpenter"] = "start", escape = "start", ["kpenter"] = "start", escape = "start",
-- Select: fight-menu move reorder + bag item reorder. Tab is the
-- discoverable default (shown in CONTROLS); both shifts stay as
-- aliases so Right-Shift muscle memory from older builds still works.
tab = "select",
rshift = "select", rshift = "select",
lshift = "select",
} }
-- keys that map to "start" but also to "a" would conflict; keep Enter = a, -- keys that map to "start" but also to "a" would conflict; keep Enter = a,
+6 -9
View File
@@ -360,16 +360,13 @@ local function chipAwaitingFirstBuffer()
and require("src.core.ChipAudio").awaitingFirstBuffer() and require("src.core.ChipAudio").awaitingFirstBuffer()
end end
-- is a playOnce jingle still sounding? (AnimateHealingMachine's -- is a playOnce jingle still in flight? (AnimateHealingMachine's
-- .waitLoop2 holds the healing machine until MUSIC_PKMN_HEALED ends) -- .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.
function Music.oneShotPlaying() function Music.oneShotPlaying()
if not state.pendingRestore then return false end return state.pendingRestore == true
-- 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 end
function Music.restoreMap(data) function Music.restoreMap(data)
+58 -1
View File
@@ -186,10 +186,14 @@ function SaveData.defaultOptions()
musicFilter = 0, musicFilter = 0,
-- logic fast-forward multiplier; audio is unaffected (GameSpeed.lua) -- logic fast-forward multiplier; audio is unaffected (GameSpeed.lua)
speed = 1, speed = 1,
-- port display options (OptionsMenu / hotkeys 2/3/5) -- port display options (OptionsMenu / hotkeys 2/3/4/5)
colors = "gbc", colors = "gbc",
tilt = 0, tilt = 0,
gbcfx = 0, gbcfx = 0,
-- survey zoom offset from window fit scale (0 = FIT); see Zoom.lua
zoom = 0,
-- OVERWORLD beyond-edge fill: trees | water | black
voidFill = "trees",
-- windowed | borderless (desktop fullscreen); ignored on mobile -- windowed | borderless (desktop fullscreen); ignored on mobile
videoMode = "windowed", videoMode = "windowed",
-- hard render frame-rate cap; render-only pacing (issue #88, FrameCap.lua) -- hard render frame-rate cap; render-only pacing (issue #88, FrameCap.lua)
@@ -478,6 +482,25 @@ SaveData.addCoreMigration(2, function(save)
end end
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 -- ------- write
-- Game progress only; options are written separately via saveOptions. -- Game progress only; options are written separately via saveOptions.
@@ -813,6 +836,36 @@ function SaveData.defaultHeal(boot)
return { map = map, x = boot.startX or 3, y = boot.startY or 6 } return { map = map, x = boot.startX or 3, y = boot.startY or 6 }
end 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) function SaveData.newGame(boot)
boot = type(boot) == "table" and boot or {} boot = type(boot) == "table" and boot or {}
local map = boot.startMap or "REDS_HOUSE_2F" local map = boot.startMap or "REDS_HOUSE_2F"
@@ -836,6 +889,10 @@ function SaveData.newGame(boot)
}, },
flags = {}, flags = {},
inventory = {}, 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 = {}, party = {},
box = {}, box = {},
money = boot.startMoney or 3000, money = boot.startMoney or 3000,
+1 -1
View File
@@ -48,7 +48,7 @@ local KEY = {
a = "z", a = "z",
b = "x", b = "x",
start = "escape", start = "escape",
select = "rshift", select = "tab",
} }
local function nowMs() local function nowMs()
+1 -1
View File
@@ -7,7 +7,7 @@ local Version = {
engine = "1.0.0", -- game/engine release (semver triple) engine = "1.0.0", -- game/engine release (semver triple)
modApi = 2, -- mod API major (manifest `api`) modApi = 2, -- mod API major (manifest `api`)
linkProtocol = 2, -- link handshake wire version (Handshake.PROTOCOL) linkProtocol = 2, -- link handshake wire version (Handshake.PROTOCOL)
saveFormat = 3, -- save.meta.format saveFormat = 4, -- save.meta.format
cache = "rom-cache-v5", -- ROM import cache generation (RomImporter marker) cache = "rom-cache-v5", -- ROM import cache generation (RomImporter marker)
} }
+15 -2
View File
@@ -351,6 +351,18 @@ function RomImporter:setError(message, version)
self.romData = nil self.romData = nil
end 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 -- 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. -- dropping a Red or Blue cart into either column always lands in the right one.
function RomImporter:startData(data, displayName) function RomImporter:startData(data, displayName)
@@ -428,6 +440,7 @@ function RomImporter:startData(data, displayName)
-- Stay on the launcher; the player presses Play to boot the new game. -- Stay on the launcher; the player presses Play to boot the new game.
return return
end end
resetPointerCursor(self)
if self.onComplete then self.onComplete(version) end if self.onComplete then self.onComplete(version) end
end) end)
end end
@@ -507,6 +520,7 @@ end
function RomImporter:play(version) function RomImporter:play(version)
if self.workState == "working" then return end if self.workState == "working" then return end
if not self.ready[version] then return end if not self.ready[version] then return end
resetPointerCursor(self)
if self.onComplete then self.onComplete(version) end if self.onComplete then self.onComplete(version) end
end end
@@ -1045,8 +1059,7 @@ function RomImporter:draw()
self.handCursor = self.handCursor or love.mouse.getSystemCursor("hand") self.handCursor = self.handCursor or love.mouse.getSystemCursor("hand")
love.mouse.setCursor(self.handCursor) love.mouse.setCursor(self.handCursor)
else else
self.arrowCursor = self.arrowCursor or love.mouse.getSystemCursor("arrow") resetPointerCursor(self)
love.mouse.setCursor(self.arrowCursor)
end end
end end
end end
+44
View File
@@ -0,0 +1,44 @@
-- Shared 6-slot room-code entry widget: the digit-scrub interaction
-- LinkState's own `ipDigits`/`addrPos` already uses for IP entry, over the
-- Crockford-32-style alphabet pokeserver room/tournament codes are drawn
-- from (23456789ABCDEFGHJKMNPQRSTUVWXYZ -- no 0/O/1/I/L, so a code read
-- aloud or handwritten never has to be checked twice).
local CodeEntry = {}
CodeEntry.CHARSET = "23456789ABCDEFGHJKMNPQRSTUVWXYZ"
CodeEntry.LENGTH = 6
function CodeEntry.new()
local chars = {}
for i = 1, CodeEntry.LENGTH do chars[i] = 1 end -- index into CHARSET, 1-based
return { chars = chars, pos = 1 }
end
local N = #CodeEntry.CHARSET
function CodeEntry.up(state)
state.chars[state.pos] = state.chars[state.pos] % N + 1
end
function CodeEntry.down(state)
state.chars[state.pos] = (state.chars[state.pos] - 2) % N + 1
end
function CodeEntry.left(state)
state.pos = math.max(1, state.pos - 1)
end
function CodeEntry.right(state)
state.pos = math.min(CodeEntry.LENGTH, state.pos + 1)
end
function CodeEntry.text(state)
local out = {}
for i = 1, CodeEntry.LENGTH do
out[i] = CodeEntry.CHARSET:sub(state.chars[i], state.chars[i])
end
return table.concat(out)
end
return CodeEntry
+11
View File
@@ -67,6 +67,17 @@ function Handshake.linkModified(game)
return false return false
end end
-- online play (the relay-based online match / tournament flows in
-- LinkState/Tournament) meets strangers, not a coordinating friend, so it
-- skips the LAN path's per-peer compatibility negotiation entirely and
-- just requires vanilla on both ends: no mod-added Pokemon, no surprises.
-- Mods only ever get baked in at boot (Loader:load), so this is a gate on
-- attempting to go online, not a live mod toggle -- the player disables
-- mods via the mod manager and relaunches.
function Handshake.onlineAllowed(game)
return #Handshake.mods(game) == 0
end
-- mode is nil on the guest: it pairs and announces itself before the host -- mode is nil on the guest: it pairs and announces itself before the host
-- has picked, and compatibility is decided from the two hellos, not the mode -- has picked, and compatibility is decided from the two hellos, not the mode
function Handshake.hello(game, mode) function Handshake.hello(game, mode)
+387 -57
View File
@@ -15,6 +15,7 @@
-- them in link battles). -- them in link battles).
local Fingerprint = require("src.link.Fingerprint") local Fingerprint = require("src.link.Fingerprint")
local Font = require("src.render.Font")
local Handshake = require("src.link.Handshake") local Handshake = require("src.link.Handshake")
local Logger = require("src.core.Logger") local Logger = require("src.core.Logger")
local Protocol = require("src.link.Protocol") local Protocol = require("src.link.Protocol")
@@ -36,18 +37,45 @@ local function makeRng(seed)
end end
end end
-- battler builder shared by both sides -- NO badge boosts, so both -- Battlers go through BattleState.makeBattler so pics get the same
-- machines compute identical stats -- 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).
local function mkBattler(data, mon, isPlayer) local function mkBattler(data, mon, isPlayer)
local def = data.pokemon[mon.species] local BattleState = require("src.battle.BattleState")
local ok, img = pcall(love.graphics.newImage, return BattleState.makeBattler(data, mon, isPlayer, nil)
isPlayer and def.spriteBack or def.spriteFront) end
return {
mon = mon, def = def, isPlayer = isPlayer, stages = {}, -- shared by LinkBattle.new (myParty/theirParty) and LinkBattle.newSpectator
name = mon.nickname or def.name, -- (hostParty/guestParty): pack->unpack clamp so every perspective watching
curStats = mon.stats, curTypes = def.types, curMoves = mon.moves, -- a given match holds identical mon copies. errFmt(packedMon, why) builds
sprite = ok and img or nil, -- the message shown when strict mode refuses an unrebuildable mon.
} local function unpackParty(game, packed, unpackOpts, errFmt)
local out = {}
for _, p in ipairs(packed or {}) do
local mon, why = Protocol.unpackMon(game.data, p, unpackOpts)
if mon then
table.insert(out, mon)
elseif unpackOpts.strict then
return nil, errFmt(p, why)
end
end
return out
end
-- decode a wire action message against whichever battler it belongs to
-- (the opponent's, from a real participant's perspective; either side's,
-- from a spectator's)
local function decodeWireAction(s, msg, battler)
if msg.kind == "move" then
local slot = math.max(1, math.min(#battler.curMoves, math.floor(msg.slot or 1)))
return battler.curMoves[slot]
elseif msg.kind == "struggle" then
return { id = "STRUGGLE", pp = 1, struggle = true }
elseif msg.kind == "locked" then
return s:lockedAction(battler)
end
return nil
end end
-- canonical (host-side-first) state hash, unchanged since v1: it stays on -- canonical (host-side-first) state hash, unchanged since v1: it stays on
@@ -154,26 +182,15 @@ function LinkBattle.new(game, net, opts)
-- both parties pass through the same pack->unpack clamp on both -- both parties pass through the same pack->unpack clamp on both
-- machines, so the copies are identical everywhere -- machines, so the copies are identical everywhere
local unpackOpts = { strict = opts.strict or false } local unpackOpts = { strict = opts.strict or false, forceLevel = opts.forceLevel }
local myParty, theirParty = {}, {} local myParty, myErr = unpackParty(game, opts.myParty, unpackOpts, function(p)
for _, p in ipairs(opts.myParty or {}) do return ("Your %s can't\nbattle on the\nother game."):format(tostring(p.species))
local mon = Protocol.unpackMon(game.data, p, unpackOpts) end)
if mon then if not myParty then return nil, myErr end
table.insert(myParty, mon) local theirParty, theirErr = unpackParty(game, opts.theirParty, unpackOpts, function(p, why)
elseif unpackOpts.strict then return ("Their %s isn't\nin this game.\n(%s)"):format(tostring(p.species), tostring(why))
return nil, ("Your %s can't\nbattle on the\nother game."):format( end)
tostring(p.species)) if not theirParty then return nil, theirErr end
end
end
for _, p in ipairs(opts.theirParty or {}) do
local mon, why = Protocol.unpackMon(game.data, p, unpackOpts)
if mon then
table.insert(theirParty, mon)
elseif unpackOpts.strict then
return nil, ("Their %s isn't\nin this game.\n(%s)"):format(
tostring(p.species), tostring(why))
end
end
if #myParty == 0 or #theirParty == 0 then if #myParty == 0 or #theirParty == 0 then
Logger.warn("link: empty party on one side") Logger.warn("link: empty party on one side")
end end
@@ -213,6 +230,8 @@ function LinkBattle.new(game, net, opts)
self.player = mkBattler(game.data, myParty[1], true) self.player = mkBattler(game.data, myParty[1], true)
self.enemy = mkBattler(game.data, theirParty[1], false) self.enemy = mkBattler(game.data, theirParty[1], false)
self.enemyParty = theirParty 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.introText = ("%s wants\nto battle!"):format(theirName)
self.remoteHashes = {} self.remoteHashes = {}
self.localHashes = {} self.localHashes = {}
@@ -230,22 +249,62 @@ function LinkBattle.new(game, net, opts)
if text then s:say(text) end if text then s:say(text) end
end end
-- a tournament shot clock (opts.turnLimit) costs the slow player
-- specifically, unlike RUN or a desync -- both of which stay a draw --
-- so it needs its own result rather than reusing endAsDraw
local function endWithResult(s, result, text)
if s.linkEnded then return end
s.result = result
s.afterQueue = "finish"
s.phase = "messages"
if text then s:say(text) end
end
local function orderMove(action) local function orderMove(action)
if action and action.id then return game.data.moves[action.id] end if action and action.id then return game.data.moves[action.id] end
return nil return nil
end 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 -- decode a remote action message against the enemy battler
local function decodeTheirAction(s, msg) local function decodeTheirAction(s, msg)
if msg.kind == "move" then return decodeWireAction(s, msg, s.enemy)
local slot = math.max(1, math.min(#s.enemy.curMoves, math.floor(msg.slot or 1)))
return s.enemy.curMoves[slot]
elseif msg.kind == "struggle" then
return { id = "STRUGGLE", pp = 1, struggle = true }
elseif msg.kind == "locked" then
return s:lockedAction(s.enemy)
end
return nil
end end
-- with the handshake guaranteeing both games share a link surface, a -- with the handshake guaranteeing both games share a link surface, a
@@ -306,17 +365,11 @@ function LinkBattle.new(game, net, opts)
-- switches happen before attacks (both may switch) -- switches happen before attacks (both may switch)
if myMsg.kind == "switch" then if myMsg.kind == "switch" then
local idx = myMsg.index local idx = myMsg.index
s:act(function() s:act(function() sendOutPlayer(s, myParty[idx]) end)
s.player = mkBattler(game.data, myParty[idx], true)
s:sayNext(("Go! %s!"):format(s.player.name))
end)
myAction = nil myAction = nil
end end
if theirSwitch then if theirSwitch then
s:act(function() s:act(function() sendOutEnemy(s, theirParty[theirSwitch]) end)
s.enemy = mkBattler(game.data, theirParty[theirSwitch], false)
s:sayNext(("%s sent\nout %s!"):format(theirName, s.enemy.name))
end)
end end
s:act(function() s:act(function()
@@ -452,10 +505,7 @@ function LinkBattle.new(game, net, opts)
self.playerMonFainted = function(s) self.playerMonFainted = function(s)
for _, mon in ipairs(myParty) do for _, mon in ipairs(myParty) do
if mon.hp > 0 then if mon.hp > 0 then
s:act(function() s:act(function() sendOutPlayer(s, mon) end)
s.player = mkBattler(game.data, mon, true)
s:sayNext(("Go! %s!"):format(s.player.name))
end)
return return
end end
end end
@@ -468,10 +518,7 @@ function LinkBattle.new(game, net, opts)
self.enemyMonFainted = function(s) self.enemyMonFainted = function(s)
for _, mon in ipairs(theirParty) do for _, mon in ipairs(theirParty) do
if mon.hp > 0 then if mon.hp > 0 then
s:act(function() s:act(function() sendOutEnemy(s, mon) end)
s.enemy = mkBattler(game.data, mon, false)
s:sayNext(("%s sent\nout %s!"):format(theirName, s.enemy.name))
end)
return return
end end
end end
@@ -498,6 +545,19 @@ function LinkBattle.new(game, net, opts)
if not s.result then if not s.result then
endAsDraw(s, ("%s left the\nbattle."):format(theirName)) endAsDraw(s, ("%s left the\nbattle."):format(theirName))
end end
elseif msg.type == "forfeit" then
-- the peer's own shot clock ran out; unlike a mutual RUN/desync
-- draw, this has a definite winner (us)
if not s.result then
endWithResult(s, "win", ("%s ran out of\ntime!"):format(theirName))
end
else
-- a tournament control message (bracket_update, the next
-- match_start, ...) can arrive while this match is still
-- finishing up; Tournament.lua drains this once it regains the
-- stack top rather than losing it to this poll loop
s.pendingTournamentMessages = s.pendingTournamentMessages or {}
table.insert(s.pendingTournamentMessages, msg)
end end
end end
if net.closed and not s.linkEnded and not s.result then if net.closed and not s.linkEnded and not s.result then
@@ -513,16 +573,48 @@ function LinkBattle.new(game, net, opts)
end end
return return
end end
if opts.turnLimit and s.phase == "menu" then
if not s.turnClockActive then
s.turnClockActive = true
s.turnClock = opts.turnLimit
end
s.turnClock = s.turnClock - dt
if s.turnClock <= 0 then
s.turnClockActive = false
send({ type = "forfeit" })
endWithResult(s, "lose", "Time's up! You\nforfeit the match.")
end
elseif opts.turnLimit then
s.turnClockActive = false
end
baseUpdate(s, dt) baseUpdate(s, dt)
end end
if opts.turnLimit then
local baseDraw = self.draw
self.draw = function(s, ...)
baseDraw(s, ...)
if s.phase == "menu" and s.turnClockActive then
love.graphics.setColor(1, 1, 1, 1)
Font.draw(tostring(math.max(0, math.ceil(s.turnClock))), 144, 4)
end
end
end
local baseFinish = self.finish local baseFinish = self.finish
self.finish = function(s) self.finish = function(s)
if not s.linkEnded then if not s.linkEnded then
s.linkEnded = true s.linkEnded = true
send({ type = "bye" }) send({ type = "bye" })
end end
net:close() -- opts.keepNetOpen: a tournament match's `net` is the caller's
-- long-lived tournament connection (still needed for the next round,
-- spectating, bracket updates, ...), not a dedicated match socket --
-- closing it here the way a plain 1v1 link battle does would sever
-- the whole tournament, not just this match.
if not opts.keepNetOpen then
net:close()
end
if game.linkNet == net then game.linkNet = nil end if game.linkNet == net then game.linkNet = nil end
baseFinish(s) baseFinish(s)
end end
@@ -530,6 +622,244 @@ function LinkBattle.new(game, net, opts)
return self return self
end end
-- A tournament spectator: reconstructs the exact same lockstep battle a
-- live match's two real participants are playing, from a copy of the
-- traffic the relay fans out to onlookers (see pokeserver's `spectate`
-- envelope). No local input drives anything here -- both sides' actions
-- arrive over the wire, tagged by which real player sent them -- so it's
-- a read-only replay, not a third participant: no hash/desync checking
-- (a spectator has nothing to verify against), no shot clock (nothing to
-- act on), and `finish` must NOT close `net`, since that's the caller's
-- long-lived tournament connection, not a dedicated match socket.
--
-- opts: { hostParty = packed, guestParty = packed, hostName, guestName,
-- seed, verdict, strict }. `self.player` is always the host's battler and
-- `self.enemy` the guest's, which is what makes TurnOrder.firstMover's
-- tie-break (below) land on the same result the host's own instance
-- already computed with invertTie=false.
function LinkBattle.newSpectator(game, net, opts)
local BattleState = require("src.battle.BattleState")
local hostName = opts.hostName or "HOST"
local guestName = opts.guestName or "GUEST"
if not Handshake.battleAllowed(opts.verdict) then
return nil, "Link battle needs\nthe same mods on\nboth games."
end
local unpackOpts = { strict = opts.strict or false, forceLevel = opts.forceLevel }
local hostParty, hostErr = unpackParty(game, opts.hostParty, unpackOpts, function(p)
return ("%s's %s can't\nbattle on this\ngame."):format(hostName, tostring(p.species))
end)
if not hostParty then return nil, hostErr end
local guestParty, guestErr = unpackParty(game, opts.guestParty, unpackOpts, function(p, why)
return ("%s's %s can't\nbattle on this\ngame.\n(%s)"):format(
guestName, tostring(p.species), tostring(why))
end)
if not guestParty then return nil, guestErr end
if #hostParty == 0 or #guestParty == 0 then
Logger.warn("link: empty party on one side (spectator)")
end
local self = BattleState.newWild(game, guestParty[1] and guestParty[1].species
or "RATTATA", 5)
self.kind = "link" -- exact same visual treatment as a real link battle
self.spectating = true -- Tournament.lua's marker: don't report a result for this one
self.net = net
game.linkNet = net
self.rng = makeRng(opts.seed or 1)
self.player = mkBattler(game.data, hostParty[1], true)
self.enemy = mkBattler(game.data, guestParty[1], false)
self.enemyParty = guestParty
self.playerParty = hostParty
self.opponentName = guestName
self.introText = ("%s vs %s!"):format(hostName, guestName)
local function orderMove(action)
if action and action.id then return game.data.moves[action.id] end
return nil
end
local function endSpectate(s, text)
if s.linkEnded then return end
s.result = s.result or "ended"
s.afterQueue = "finish"
s.phase = "messages"
if text then s:say(text) end
end
local function sendOutHost(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
local function sendOutGuest(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(guestName, 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
local function resolveSpecTurn(s, hostMsg, guestMsg)
if hostMsg.kind == "run" or guestMsg.kind == "run" then
endSpectate(s, "The match ended.")
return
end
s.phase = "messages"
s.afterQueue = "linkNext"
s.turnCount = (s.turnCount or 0) + 1
if hostMsg.kind == "switch" then
local idx = hostMsg.index
s:act(function() sendOutHost(s, hostParty[idx]) end)
end
if guestMsg.kind == "switch" then
local idx = guestMsg.index
s:act(function() sendOutGuest(s, guestParty[idx]) end)
end
s:act(function()
local hostAction = hostMsg.kind ~= "switch" and hostMsg.kind ~= "run"
and decodeWireAction(s, hostMsg, s.player) or nil
local guestAction = guestMsg.kind ~= "switch" and guestMsg.kind ~= "run"
and decodeWireAction(s, guestMsg, s.enemy) or nil
Runtime.emit("battle.turn_started", {
battle = s, turn = s.turnCount,
playerAction = hostAction, enemyAction = guestAction,
})
if hostAction and guestAction then
local hostMove, guestMove = orderMove(hostAction), orderMove(guestAction)
local first
if Runtime.wantsHook("battle.turn_order") then
first = Runtime.call("battle.turn_order", function(a, aMove, b, bMove, c)
return TurnOrder.firstMover(a, aMove, b, bMove, c.rng, c.invertTie)
end, s.player, hostMove, s.enemy, guestMove, { rng = s.rng, invertTie = false })
else
first = TurnOrder.firstMover(s.player, hostMove, s.enemy, guestMove, s.rng, false)
end
local order
if first then
order = { { s.player, s.enemy, hostAction }, { s.enemy, s.player, guestAction } }
else
order = { { s.enemy, s.player, guestAction }, { s.player, s.enemy, hostAction } }
end
for _, entry in ipairs(order) do
s:act(function() s:executeAction(entry[1], entry[2], entry[3]) end)
end
elseif hostAction then
s:act(function() s:executeAction(s.player, s.enemy, hostAction) end)
elseif guestAction then
s:act(function() s:executeAction(s.enemy, s.player, guestAction) end)
end
s:act(function() s:endOfTurn() end)
end)
end
self.resolveTurn = function() end -- a spectator's own input never drives anything
self.resolveSwitch = function() end
self.tryRun = function() end
self.openParty = function(s) s.phase = "waitBoth" end
self.playerMonFainted = function(s)
for _, mon in ipairs(hostParty) do
if mon.hp > 0 then
s:act(function() sendOutHost(s, mon) end)
return
end
end
s:sayNext(("%s is out of\nPOKéMON!\f%s wins!"):format(hostName, guestName))
s.result = "guestWin"
s.afterQueue = "finish"
end
self.enemyMonFainted = function(s)
for _, mon in ipairs(guestParty) do
if mon.hp > 0 then
s:act(function() sendOutGuest(s, mon) end)
return
end
end
s:sayNext(("%s is out of\nPOKéMON!\f%s wins!"):format(guestName, hostName))
s.result = "hostWin"
s.afterQueue = "finish"
end
self.hostMsg, self.guestMsg = nil, nil
local baseUpdate = self.update
self.update = function(s, dt)
net:update()
for _, msg in ipairs(net:poll()) do
if msg.type == "spectate" then
local inner = msg.msg
if inner.type == "action" then
if msg.side == "host" then s.hostMsg = inner else s.guestMsg = inner end
if s.hostMsg and s.guestMsg then
local h, g = s.hostMsg, s.guestMsg
s.hostMsg, s.guestMsg = nil, nil
resolveSpecTurn(s, h, g)
end
elseif inner.type == "bye" or inner.type == "forfeit" then
if not s.result then endSpectate(s, "The match ended.") end
end
-- "hello"/"party"/"hash" ride along too (Tournament.lua already
-- consumed hello/party before building this battle); none of them
-- need any action here
else
-- same reasoning as the real-participant loop above: don't lose a
-- bracket_update/match_start_spectate that arrives mid-match
s.pendingTournamentMessages = s.pendingTournamentMessages or {}
table.insert(s.pendingTournamentMessages, msg)
end
end
if net.closed and not s.linkEnded and not s.result then
endSpectate(s)
end
if s.phase == "messages" and s.afterQueue == "linkNext" then
if not s:updateQueue() then
s.afterQueue = "waitBoth"
s.phase = "waitBoth"
end
return
end
if s.phase == "menu" or s.phase == "waitBoth" then
return -- frozen between resolved turns; never a real decision here
end
baseUpdate(s, dt)
end
local baseFinish = self.finish
self.finish = function(s)
s.linkEnded = true
if game.linkNet == net then game.linkNet = nil end
baseFinish(s) -- deliberately doesn't touch net: it's the caller's
-- tournament connection, still needed after this match
end
return self
end
-- backwards-compatible entry points (LinkState passes role explicitly) -- backwards-compatible entry points (LinkState passes role explicitly)
function LinkBattle.newHost(game, net, opts) function LinkBattle.newHost(game, net, opts)
opts.role = "host" opts.role = "host"
+211 -7
View File
@@ -2,6 +2,8 @@
-- the other joins by typing that address in. Direct peer-to-peer over -- the other joins by typing that address in. Direct peer-to-peer over
-- lua-enet (bundled with LÖVE), no relay server. -- lua-enet (bundled with LÖVE), no relay server.
local CodeEntry = require("src.link.CodeEntry")
local DiscordPresence = require("src.core.DiscordPresence")
local Font = require("src.render.Font") local Font = require("src.render.Font")
local Handshake = require("src.link.Handshake") local Handshake = require("src.link.Handshake")
local Net = require("src.link.Net") local Net = require("src.link.Net")
@@ -15,6 +17,32 @@ LinkState.__index = LinkState
LinkState.isOpaque = true LinkState.isOpaque = true
local CURSOR = 0xED local CURSOR = 0xED
local ANY = "ANY" -- sentinel: a leading nil array entry breaks ipairs under
-- LuaJIT even though # still reports the full size, so
-- the level picker cycles this string instead of nil,
-- converted to nil only on the wire (see levelForWire)
local FORCE_LEVEL_STEPS = { ANY, 50, 100 }
local function indexOf(list, value)
for i, v in ipairs(list) do
if v == value then return i end
end
return 1
end
local function levelForWire(v)
return v == ANY and nil or v
end
local function forceLevelLabel(v)
return (v == ANY or v == nil) and "ANY" or ("AUTO " .. tostring(v))
end
-- stages before any Net object is meaningfully "this session's link" --
-- .net can still be a leftover failed attempt sitting on self, so error/
-- closed checks below skip these rather than keying off self.net's
-- presence alone
local PRE_CONNECT_STAGES = { menu = true, lanMenu = true, onlineMenu = true }
-- how long the host waits for a v2 hello before deciding the peer predates -- how long the host waits for a v2 hello before deciding the peer predates
-- the handshake (a pre-mod guest sends nothing until it hears the mode) -- the handshake (a pre-mod guest sends nothing until it hears the mode)
@@ -47,7 +75,23 @@ function LinkState.new(game)
return self return self
end end
-- entry point for a Discord "Ask to Join" click (see DiscordPresence.lua):
-- skips the whole LAN/ONLINE/TOURNAMENT menu and jumps straight to
-- "connecting with this code", same as if the player had typed it in
function LinkState.newJoinOnline(game, code)
local self = LinkState.new(game)
self.net = Net.new()
if self.net:joinOnline(nil, code) then
self.stage = "onlineJoining"
else
self.stage = "menu" -- exitWith below needs a real stage to unwind from
self:exitWith("Link error:\n" .. (self.net.error or "?"))
end
return self
end
function LinkState:exitWith(message, reason) function LinkState:exitWith(message, reason)
DiscordPresence.setJoinCode(nil)
Runtime.emit("link.ended", { reason = reason or (message and "error" or "bye") }) Runtime.emit("link.ended", { reason = reason or (message and "error" or "bye") })
if self.net then self.net:close() end if self.net then self.net:close() end
self.game.stack:pop() self.game.stack:pop()
@@ -101,7 +145,12 @@ function LinkState:decideCompat(mode, isHost)
mods = peer and peer.mods, fingerprint = peer and peer.fingerprint }, mods = peer and peer.mods, fingerprint = peer and peer.fingerprint },
}) })
if self.verdict == "full" or self.verdict == "vanilla_peer" then if self.verdict == "full" or self.verdict == "vanilla_peer" then
self:startMode(mode, isHost) if isHost and mode == "battle" then
-- host picks the level rule now, before the parties are exchanged
self.stage = "battleOptions"
else
self:startMode(mode, isHost)
end
return return
end end
-- naming the difference up front is the whole point: the old behaviour -- naming the difference up front is the whole point: the old behaviour
@@ -119,25 +168,54 @@ function LinkState:update(dt)
local input = self.game.input local input = self.game.input
if self.net then if self.net then
self.net:update() self.net:update()
if self.net.error and self.stage ~= "menu" then if self.net.error and not PRE_CONNECT_STAGES[self.stage] then
self:exitWith("Link error:\n" .. self.net.error:sub(1, 60)) self:exitWith("Link error:\n" .. self.net.error:sub(1, 60))
return return
end end
-- the peer vanished without a bye (only once the inbox is drained, -- the peer vanished without a bye (only once the inbox is drained,
-- so a final message travelling with the disconnect still counts) -- so a final message travelling with the disconnect still counts)
if self.net.closed and #self.net.inbox == 0 if self.net.closed and #self.net.inbox == 0
and self.stage ~= "menu" and self.stage ~= "addrEntry" and not PRE_CONNECT_STAGES[self.stage] and self.stage ~= "addrEntry"
and self.stage ~= "notice" and self.stage ~= "battleRunning" then and self.stage ~= "codeEntry" and self.stage ~= "notice"
and self.stage ~= "battleRunning" then
self:exitWith("The link was\nbroken.") self:exitWith("The link was\nbroken.")
return return
end end
end end
if self.stage == "menu" then if self.stage == "menu" then
if input:wasPressed("down") then
self.index = self.index % 3 + 1
elseif input:wasPressed("up") then
self.index = (self.index - 2) % 3 + 1
elseif input:wasPressed("b") then
self:exitWith(nil)
elseif input:wasPressed("a") then
if self.index == 1 then
self.stage = "lanMenu"
self.index = 1
elseif self.index == 2 or self.index == 3 then
if not Handshake.onlineAllowed(self.game) then
self:exitWith("Online play needs\nno mods enabled.\fDisable them in\nSTART > MODS.")
return
end
if self.index == 2 then
self.stage = "onlineMenu"
else
local Tournament = require("src.link.Tournament")
self.game.stack:pop()
self.game.stack:push(Tournament.new(self.game))
end
self.index = 1
end
end
elseif self.stage == "lanMenu" then
if input:wasPressed("up") or input:wasPressed("down") then if input:wasPressed("up") or input:wasPressed("down") then
self.index = self.index == 1 and 2 or 1 self.index = self.index == 1 and 2 or 1
elseif input:wasPressed("b") then elseif input:wasPressed("b") then
self:exitWith(nil) self.stage = "menu"
self.index = 1
elseif input:wasPressed("a") then elseif input:wasPressed("a") then
self.net = Net.new() self.net = Net.new()
if self.index == 1 then if self.index == 1 then
@@ -151,6 +229,67 @@ function LinkState:update(dt)
end end
end end
elseif self.stage == "onlineMenu" then
if input:wasPressed("up") or input:wasPressed("down") then
self.index = self.index == 1 and 2 or 1
elseif input:wasPressed("b") then
self.stage = "menu"
self.index = 2
elseif input:wasPressed("a") then
if self.index == 1 then
self.net = Net.new()
if self.net:hostOnline() then
self.stage = "onlineHosting"
else
self:exitWith("Link error:\n" .. (self.net.error or "?"))
end
else
self.stage = "codeEntry"
self.codeEntry = CodeEntry.new()
end
end
elseif self.stage == "onlineHosting" then
if not self.discordCodeSet and self.net.code then
DiscordPresence.setJoinCode(self.net.code)
self.discordCodeSet = true
end
if input:wasPressed("b") then self:exitWith(nil) return end
if self.net.paired then
DiscordPresence.setJoinCode(nil) -- someone's here now; stop advertising
self.stage = "modeSelect"
self.index = 1
end
elseif self.stage == "codeEntry" then
if input:wasPressed("b") then
self.stage = "onlineMenu"
self.index = 2
elseif input:wasPressed("up") then
CodeEntry.up(self.codeEntry)
elseif input:wasPressed("down") then
CodeEntry.down(self.codeEntry)
elseif input:wasPressed("left") then
CodeEntry.left(self.codeEntry)
elseif input:wasPressed("right") then
CodeEntry.right(self.codeEntry)
elseif input:wasPressed("a") then
local code = CodeEntry.text(self.codeEntry)
self.net = Net.new()
if self.net:joinOnline(nil, code) then
self.stage = "onlineJoining"
else
self:exitWith("Link error:\n" .. (self.net.error or "?"))
end
end
elseif self.stage == "onlineJoining" then
if input:wasPressed("b") then self:exitWith(nil) return end
if self.net.paired then
self.stage = "waitMode"
self:sendHello(nil) -- the host owns the mode; this is just who we are
end
elseif self.stage == "hosting" then elseif self.stage == "hosting" then
if input:wasPressed("b") then self:exitWith(nil) return end if input:wasPressed("b") then self:exitWith(nil) return end
if self.net.paired then if self.net.paired then
@@ -208,6 +347,23 @@ function LinkState:update(dt)
self:exitWith(nil) self:exitWith(nil)
end end
elseif self.stage == "battleOptions" then -- host picks the level rule,
-- once compat is confirmed and
-- before parties are exchanged
self.levelChoice = self.levelChoice or ANY
if input:wasPressed("b") then
self:exitWith(nil)
elseif input:wasPressed("up") or input:wasPressed("down")
or input:wasPressed("left") or input:wasPressed("right") then
local delta = (input:wasPressed("down") or input:wasPressed("left")) and -1 or 1
local i = indexOf(FORCE_LEVEL_STEPS, self.levelChoice)
i = ((i - 1 + delta) % #FORCE_LEVEL_STEPS) + 1
self.levelChoice = FORCE_LEVEL_STEPS[i]
elseif input:wasPressed("a") then
self.forceLevel = levelForWire(self.levelChoice)
self:startMode(self.pendingMode, true)
end
elseif self.stage == "waitHello" then -- host waits for the peer's hello elseif self.stage == "waitHello" then -- host waits for the peer's hello
if input:wasPressed("b") then self:exitWith(nil) return end if input:wasPressed("b") then self:exitWith(nil) return end
local got, other = self:pollHello() local got, other = self:pollHello()
@@ -251,6 +407,9 @@ function LinkState:update(dt)
local msgs = self.net:poll() local msgs = self.net:poll()
for i, msg in ipairs(msgs) do for i, msg in ipairs(msgs) do
if msg.type == "party" then if msg.type == "party" then
-- the host owns this rule (same as mode); the guest only learns
-- it here, off the host's own party message
if not self.isHost then self.forceLevel = msg.forceLevel end
local LinkBattle = require("src.link.LinkBattle") local LinkBattle = require("src.link.LinkBattle")
local opts = { local opts = {
myParty = Protocol.packParty(self.game.save.party), myParty = Protocol.packParty(self.game.save.party),
@@ -259,6 +418,7 @@ function LinkState:update(dt)
seed = self.isHost and self.linkSeed or msg.seed, seed = self.isHost and self.linkSeed or msg.seed,
verdict = self.verdict, verdict = self.verdict,
strict = Handshake.strict(self.verdict), strict = Handshake.strict(self.verdict),
forceLevel = self.forceLevel,
} }
local battle, why local battle, why
if self.isHost then if self.isHost then
@@ -309,7 +469,8 @@ function LinkState:startMode(mode, isHost)
end end
self.net:send({ type = "party", self.net:send({ type = "party",
mons = Protocol.packParty(self.game.save.party), mons = Protocol.packParty(self.game.save.party),
seed = self.linkSeed }) seed = self.linkSeed,
forceLevel = isHost and self.forceLevel or nil })
end end
end end
@@ -389,12 +550,49 @@ end
function LinkState:draw() function LinkState:draw()
if self.stage == "menu" then if self.stage == "menu" then
drawTitle("LINK CABLE CLUB") drawTitle("BOIS CLUB LIVE")
Font.draw("LINK CABLE (LAN)", 32, 44)
Font.draw("ONLINE MATCH", 32, 60)
Font.draw("TOURNAMENT", 32, 76)
Font.drawCode(CURSOR, 24, 44 + (self.index - 1) * 16)
elseif self.stage == "lanMenu" then
drawTitle("LINK CABLE (LAN)")
Font.draw("HOST A GAME", 32, 48) Font.draw("HOST A GAME", 32, 48)
Font.draw("JOIN A GAME", 32, 68) Font.draw("JOIN A GAME", 32, 68)
Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68) Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68)
Font.draw("UDP port " .. Net.defaultPort(), 8, 128) Font.draw("UDP port " .. Net.defaultPort(), 8, 128)
elseif self.stage == "onlineMenu" then
drawTitle("ONLINE MATCH")
Font.draw("HOST ONLINE", 32, 48)
Font.draw("JOIN ONLINE", 32, 68)
Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68)
elseif self.stage == "onlineHosting" then
drawTitle("HOSTING ONLINE")
Font.draw("Tell your friend", 16, 40)
Font.draw("the code:", 16, 52)
Font.draw(self.net.code or "??????", 32, 68)
Font.draw("Waiting for join...", 8, 96)
elseif self.stage == "codeEntry" then
drawTitle("ENTER CODE")
for i = 1, CodeEntry.LENGTH do
local x = 16 + (i - 1) * 16
local ch = CodeEntry.CHARSET:sub(self.codeEntry.chars[i], self.codeEntry.chars[i])
Font.draw(ch, x, 64)
if i == self.codeEntry.pos then
Font.drawCode(0xEE, x, 76) -- ▼ under the active slot
end
end
Font.draw("A: connect B: back", 8, 128)
elseif self.stage == "onlineJoining" then
drawTitle("CONNECTING...")
Font.draw("Calling...", 8, 56)
Font.draw(self.net.target or "", 8, 72)
elseif self.stage == "hosting" then elseif self.stage == "hosting" then
drawTitle("HOSTING") drawTitle("HOSTING")
Font.draw("Friend joins at:", 16, 48) Font.draw("Friend joins at:", 16, 48)
@@ -428,6 +626,12 @@ function LinkState:draw()
Font.draw("BATTLE", 32, 68) Font.draw("BATTLE", 32, 68)
Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68) Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68)
elseif self.stage == "battleOptions" then
drawTitle("BATTLE OPTIONS")
Font.draw("LEVELS:", 16, 56)
Font.draw(forceLevelLabel(self.levelChoice or ANY), 88, 56)
Font.draw("A: continue B: back", 8, 128)
elseif self.stage == "waitMode" or self.stage == "waitHello" then elseif self.stage == "waitMode" or self.stage == "waitHello" then
drawTitle("CONNECTED!") drawTitle("CONNECTED!")
if self.stage == "waitHello" then if self.stage == "waitHello" then
+170 -1
View File
@@ -14,6 +14,15 @@
-- Plain luajit (headless tests) has no enet; Net.available() reports -- Plain luajit (headless tests) has no enet; Net.available() reports
-- that, and Net.loopbackPair() returns two in-memory ends with the -- that, and Net.loopbackPair() returns two in-memory ends with the
-- same API so the protocol/battle logic stays testable offline. -- same API so the protocol/battle logic stays testable offline.
--
-- A second backend, alongside the ENet one above, talks plain TCP to a
-- pokeserver relay instead of direct peer-to-peer: both sides dial out
-- to a public server (works through NAT with no hole-punching), the host
-- gets a 6-character room code instead of an IP, and the server forwards
-- messages between them. Same newline-delimited JSON on the wire, same
-- Net API (host/join/send/update/poll/.paired/.closed/.error) -- see
-- Net:hostOnline/Net:joinOnline below. LinkState/LinkBattle/Protocol don't
-- know or care which backend is in play.
local Json = require("src.link.Json") local Json = require("src.link.Json")
local Logger = require("src.core.Logger") local Logger = require("src.core.Logger")
@@ -21,10 +30,14 @@ local Logger = require("src.core.Logger")
local hasEnet, enet = pcall(require, "enet") local hasEnet, enet = pcall(require, "enet")
if not hasEnet then enet = nil end if not hasEnet then enet = nil end
local hasSocket, socket = pcall(require, "socket")
if not hasSocket then socket = nil end
local Net = {} local Net = {}
Net.__index = Net Net.__index = Net
Net.DEFAULT_PORT = 7777 Net.DEFAULT_PORT = 7777
Net.DEFAULT_RELAY_ADDRESS = "147.182.215.255:7778"
function Net.available() function Net.available()
return enet ~= nil return enet ~= nil
@@ -34,6 +47,10 @@ function Net.defaultPort()
return tonumber(os.getenv("POKEPORT_LINK_PORT") or "") or Net.DEFAULT_PORT return tonumber(os.getenv("POKEPORT_LINK_PORT") or "") or Net.DEFAULT_PORT
end end
function Net.defaultRelayAddress()
return os.getenv("POKEPORT_RELAY_ADDR") or Net.DEFAULT_RELAY_ADDRESS
end
-- monotonic-ish clock for the join timeout -- monotonic-ish clock for the join timeout
local function now() local function now()
if love and love.timer and love.timer.getTime then if love and love.timer and love.timer.getTime then
@@ -64,13 +81,17 @@ function Net.new()
enetHost = nil, -- our enet host object (both ends have one) enetHost = nil, -- our enet host object (both ends have one)
peer = nil, -- the connected remote peer peer = nil, -- the connected remote peer
inbox = {}, inbox = {},
outbox = {}, -- messages queued before pairing completes outbox = {}, -- messages queued before pairing completes (enet only)
paired = false, paired = false,
address = nil, -- host: "ip:port" the other player types in address = nil, -- host: "ip:port" the other player types in
error = nil, error = nil,
closed = false, closed = false,
mode = nil, mode = nil,
joinTimeout = 10, joinTimeout = 10,
tcpSocket = nil, -- relay backend: the luasocket TCP connection
rxBuf = "", -- relay backend: bytes read but not yet a full line
txBuf = "", -- relay backend: bytes queued but not yet written
code = nil, -- relay backend, hosting: the room/tournament code
}, Net) }, Net)
end end
@@ -133,8 +154,56 @@ function Net:join(address)
return true return true
end end
-- opens a TCP connection to a pokeserver relay (blocking connect with a
-- short timeout -- this runs once, from a single explicit user action, not
-- from a per-frame poll, so blocking briefly is fine). Callers then send
-- whatever control message starts their session ({type="host"},
-- {type="join",...}, {type="host_tournament",...}, ...); every reply that
-- isn't one of the four generic ones below lands in the normal inbox for
-- the caller (LinkState, or Tournament.lua) to interpret.
function Net:connectTCP(addr)
if not socket then
self.error = "online play needs luasocket (bundled with LOVE)"
return false
end
local host, port = addr:match("^(.-):(%d+)$")
host = host or addr
port = tonumber(port) or 7778
local tcp = socket.tcp()
tcp:settimeout(5)
local ok, err = tcp:connect(host, port)
if not ok then
self.error = ("can't reach relay %s:%d\n(%s)"):format(host, port, tostring(err))
return false
end
tcp:settimeout(0)
self.tcpSocket = tcp
self.rxBuf = ""
self.txBuf = ""
return true
end
function Net:hostOnline(addr)
if not self:connectTCP(addr or Net.defaultRelayAddress()) then return false end
self.mode = "onlineHosting"
self:send({ type = "host" })
return true
end
function Net:joinOnline(addr, code)
if not self:connectTCP(addr or Net.defaultRelayAddress()) then return false end
self.mode = "onlineJoining"
self.target = code
self:send({ type = "join", code = code })
return true
end
function Net:send(msg) function Net:send(msg)
if self.closed then return end if self.closed then return end
if self.tcpSocket then
self.txBuf = self.txBuf .. Json.encode(msg) .. "\n"
return
end
if self.peerEnd then -- loopback: re-encode through json like the wire if self.peerEnd then -- loopback: re-encode through json like the wire
local decoded = Json.decode(Json.encode(msg)) local decoded = Json.decode(Json.encode(msg))
if decoded and not self.peerEnd.closed then if decoded and not self.peerEnd.closed then
@@ -155,8 +224,102 @@ function Net:send(msg)
end end
end end
-- one control message recognized on every relay connection, regardless of
-- what it's being used for (a 1v1 room or a tournament): "peer_gone" only
-- ever fires for a paired 1v1 room (tournaments signal disconnects through
-- bracket_update/tournament_over instead), so it's unambiguous here.
local function handleGenericRelayControl(self, msg)
if msg.type == "hosted" then
self.code = msg.code
return true
elseif msg.type == "paired" then
self.paired = true
return true
elseif msg.type == "join_error" then
self.error = ({
not_found = "That code wasn't\nfound.",
full = "That game already\nhas two players.",
expired = "That code has\nexpired.",
})[msg.reason] or ("Couldn't join:\n%s"):format(tostring(msg.reason))
self.closed = true
return true
elseif msg.type == "peer_gone" then
self.closed = true
return true
end
return false
end
function Net:handleTCPLine(line)
local msg = Json.decode(line)
if not msg then
Logger.warn("link: bad relay message %q", line:sub(1, 60))
return
end
if not handleGenericRelayControl(self, msg) then
table.insert(self.inbox, msg)
end
end
-- pulls every complete "\n"-terminated line out of rxBuf (leaving a
-- trailing partial line, if any, for the next call to complete) and hands
-- each to handleTCPLine. Pure buffer manipulation, no socket -- factored
-- out of updateTCP so the framing logic is testable without a real
-- connection (see the relay-path tests in tests/run_link_tests.lua).
function Net:drainLines()
while true do
local nl = self.rxBuf:find("\n", 1, true)
if not nl then break end
local line = self.rxBuf:sub(1, nl - 1)
self.rxBuf = self.rxBuf:sub(nl + 1)
if #line > 0 then self:handleTCPLine(line) end
end
end
-- non-blocking pump for the relay TCP backend: flush queued writes, drain
-- whatever's arrived into complete lines. Uses a byte-count receive
-- (rather than the "*l" pattern) because luasocket's "*l" doesn't let a
-- non-blocking caller recover the partial line across calls -- a
-- byte-count read hands back whatever's available via the third return
-- value on timeout, which we can buffer ourselves.
function Net:updateTCP()
if self.closed then return end
local sock = self.tcpSocket
if #self.txBuf > 0 then
local sent, err, lastByte = sock:send(self.txBuf)
if sent then
self.txBuf = ""
elseif err == "timeout" then
self.txBuf = self.txBuf:sub((lastByte or 0) + 1)
else
self.error = "send failed: " .. tostring(err)
self.closed = true
return
end
end
while true do
local data, err, partial = sock:receive(8192)
local chunk = data or partial or ""
if #chunk > 0 then self.rxBuf = self.rxBuf .. chunk end
if err == "closed" then
self.closed = true
break
elseif err and err ~= "timeout" then
self.error = tostring(err)
self.closed = true
break
end
if not data then break end -- nothing more buffered this frame
end
self:drainLines()
end
-- pump enet events; decoded JSON messages are queued for poll() -- pump enet events; decoded JSON messages are queued for poll()
function Net:update() function Net:update()
if self.tcpSocket then
self:updateTCP()
return
end
if self.peerEnd then return end -- loopback needs no pumping if self.peerEnd then return end -- loopback needs no pumping
if not self.enetHost or self.closed then return end if not self.enetHost or self.closed then return end
while true do while true do
@@ -219,6 +382,12 @@ function Net:close()
self.closed = true self.closed = true
return return
end end
if self.tcpSocket then
pcall(function() self.tcpSocket:close() end)
self.tcpSocket = nil
self.closed = true
return
end
if self.enetHost then if self.enetHost then
if self.peer and self.paired and not self.closed then if self.peer and self.paired and not self.closed then
-- graceful goodbye: disconnect_later delivers the queued -- graceful goodbye: disconnect_later delivers the queued
+17 -2
View File
@@ -77,6 +77,13 @@ function Protocol.unpackMon(data, packed, opts)
return nil return nil
end end
local level = math.max(2, math.min(100, math.floor(packed.level or 5))) local level = math.max(2, math.min(100, math.floor(packed.level or 5)))
-- "auto-level" tournaments/matches: every participant's real level is
-- ignored and everyone rebuilds at the same fixed level instead, so a
-- Lv12 and a Lv100 party can battle on equal footing. Both sides pass
-- the identical forceLevel for a given match, so this stays symmetric.
if opts and opts.forceLevel then
level = math.max(2, math.min(100, math.floor(opts.forceLevel)))
end
local dvs = {} local dvs = {}
for _, k in ipairs({ "hp", "attack", "defense", "speed", "special" }) do for _, k in ipairs({ "hp", "attack", "defense", "speed", "special" }) do
dvs[k] = math.max(0, math.min(15, math.floor((packed.dvs or {})[k] or 0))) dvs[k] = math.max(0, math.min(15, math.floor((packed.dvs or {})[k] or 0)))
@@ -104,6 +111,14 @@ function Protocol.unpackMon(data, packed, opts)
if strict then return nil, "no shared moves" end if strict then return nil, "no shared moves" end
moves = { { id = "TACKLE", pp = 35 } } moves = { { id = "TACKLE", pp = 35 } }
end end
-- a forced level scales every stat including max HP, so the real party's
-- current HP/status (a different level's numbers, possibly mid-fight)
-- isn't meaningful anymore -- auto-level starts everyone full and fresh,
-- same as a standardized tournament format would
local forced = opts and opts.forceLevel
local hp = forced and stats.hp
or math.max(0, math.min(stats.hp, math.floor(packed.hp or stats.hp)))
local status = forced and nil or packed.status
return { return {
species = packed.species, species = packed.species,
level = level, level = level,
@@ -111,8 +126,8 @@ function Protocol.unpackMon(data, packed, opts)
dvs = dvs, dvs = dvs,
statExp = statExp, statExp = statExp,
stats = stats, stats = stats,
hp = math.max(0, math.min(stats.hp, math.floor(packed.hp or stats.hp))), hp = hp,
status = packed.status, status = status,
nickname = packed.nickname, nickname = packed.nickname,
moves = moves, moves = moves,
-- a namespace whose mod this install lacks survives untouched, so the -- a namespace whose mod this install lacks survives untouched, so the
+634
View File
@@ -0,0 +1,634 @@
-- Tournament play: host or join a bracket over the pokeserver relay.
-- Single-elimination, server-managed (pokeserver's `tournaments` map).
-- Matches run one at a time in bracket order; everyone not currently
-- playing -- still waiting their turn, or already eliminated -- watches
-- the live match play out via LinkBattle.newSpectator, reconstructed from
-- a copy of the real traffic the server fans out. Battle mode only (no
-- trade); vanilla only (Handshake.onlineAllowed already gated entry here
-- from LinkState). Elite Four music (Music_IndigoPlateau) loops the whole
-- time, uninterrupted by individual matches.
local CodeEntry = require("src.link.CodeEntry")
local DiscordPresence = require("src.core.DiscordPresence")
local Font = require("src.render.Font")
local Handshake = require("src.link.Handshake")
local LinkBattle = require("src.link.LinkBattle")
local Net = require("src.link.Net")
local Protocol = require("src.link.Protocol")
local Runtime = require("src.mods.Runtime")
local Sound = require("src.core.Sound")
local TextBox = require("src.render.TextBox")
local Tournament = {}
Tournament.__index = Tournament
Tournament.isOpaque = true
local CURSOR = 0xED
local MUSIC = "Music_IndigoPlateau"
local TURN_LIMITS = { 3, 6, 9 }
local PARTY_SIZES = { 1, 2, 3, 4, 5, 6 }
local ANY = "ANY" -- sentinel: a leading *nil* array entry breaks ipairs
-- (LuaJIT's # still reports the literal's full size, but
-- ipairs stops dead at the hole), so level bounds use
-- this string in self.settings instead of nil, converted
-- to nil only on the wire
local LEVEL_STEPS = { ANY, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65,
70, 75, 80, 85, 90, 95, 100 }
local SETTINGS_ROWS = 6 -- POKEMON, MIN LV, MAX LV, TIMER, LEVELS, PLAYING
-- LEVELS cycles through this: ANY (real levels) or a fixed level every
-- match normalizes to, regardless of each side's real party
local FORCE_LEVEL_STEPS = { ANY, 50, 100 }
-- tournaments have no real participant cap (any number can join before
-- start); this is just generous headroom so Discord's party.size never
-- reads as "full" and blocks a real invite click
local TOURNAMENT_PARTY_MAX = 16
local function indexOf(list, value)
for i, v in ipairs(list) do
if v == value then return i end
end
return 1
end
-- accepts either the internal ANY sentinel or a raw wire value (nil means
-- "any" there too, since the server never round-trips the sentinel string)
local function levelLabel(v)
return (v == ANY or v == nil) and "ANY" or tostring(v)
end
local function levelForWire(v)
return v == ANY and nil or v
end
local function forceLevelLabel(v)
return (v == ANY or v == nil) and "ANY" or ("AUTO " .. tostring(v))
end
local function partyStats(party)
local size, minLevel, maxLevel = 0, nil, nil
for _, mon in ipairs(party or {}) do
size = size + 1
local lvl = mon.level or 1
minLevel = minLevel and math.min(minLevel, lvl) or lvl
maxLevel = maxLevel and math.max(maxLevel, lvl) or lvl
end
return size, minLevel or 0, maxLevel or 0
end
function Tournament.new(game)
local self = setmetatable({}, Tournament)
self.game = game
self.stage = "menu"
self.index = 1
self.settings = { turnLimit = 6, requiredPartySize = 3, minLevel = ANY, maxLevel = ANY,
forceLevel = ANY, participating = true }
self.settingsIndex = 1
self.roster = {}
self.spectatorRoster = {}
Sound.startLoop(game.data, MUSIC)
return self
end
-- entry point for a Discord "Ask to Join" click on a tournament invite
-- (see DiscordPresence.lua): skips the HOST/JOIN menu and code entry,
-- straight to "connecting with this code"
function Tournament.newJoinOnline(game, code)
local self = Tournament.new(game)
self:startJoining(code)
return self
end
-- headcount for Discord's party.size: the roster only ever lists
-- competing players, so a non-participating (organizer-only) host isn't
-- in it even though they're right here running the thing
function Tournament:discordPartySize()
return #self.roster + (self.participating == false and 1 or 0)
end
function Tournament:exitWith(message)
DiscordPresence.setJoinCode(nil)
Sound.stopLoop(MUSIC)
Runtime.emit("link.ended", { reason = message and "error" or "bye" })
if self.net then self.net:close() end
self.game.stack:pop()
if message then
self.game.stack:push(TextBox.new(self.game, message))
end
end
-- -------------------------------------------------------------------
-- host / join
-- -------------------------------------------------------------------
function Tournament:startHosting()
self.net = Net.new()
if not self.net:connectTCP(Net.defaultRelayAddress()) then
self:exitWith("Link error:\n" .. (self.net.error or "?"))
return
end
local size, minL, maxL = partyStats(self.game.save.party)
self.isCreator = true
self.participating = self.settings.participating
self.net:send({
type = "host_tournament",
turnLimit = self.settings.turnLimit,
requiredPartySize = self.settings.requiredPartySize,
minLevel = levelForWire(self.settings.minLevel),
maxLevel = levelForWire(self.settings.maxLevel),
forceLevel = levelForWire(self.settings.forceLevel),
participating = self.settings.participating,
name = self.game.save.player.name,
partySize = size, partyMinLevel = minL, partyMaxLevel = maxL,
})
self.stage = "registering"
end
function Tournament:startJoining(code)
self.net = Net.new()
if not self.net:connectTCP(Net.defaultRelayAddress()) then
self:exitWith("Link error:\n" .. (self.net.error or "?"))
return
end
local size, minL, maxL = partyStats(self.game.save.party)
self.isCreator = false
self.participating = true -- joining is always to compete; only hosting can opt out
self.code = code
self.net:send({
type = "join_tournament", code = code, name = self.game.save.player.name,
partySize = size, partyMinLevel = minL, partyMaxLevel = maxL,
})
self.stage = "registering"
end
-- -------------------------------------------------------------------
-- message handling (shared between "registering"/"bracket" and drained
-- again from a just-finished match's pendingTournamentMessages)
-- -------------------------------------------------------------------
local JOIN_ERROR_TEXT = {
not_found = "That code wasn't\nfound.",
already_started = "That tournament\nhas already begun.",
expired = "That code has\nexpired.",
}
function Tournament:handleMessage(msg)
if msg.type == "tournament_hosted" then
self.code = msg.code
self.settings.turnLimit = msg.turnLimit
self.settings.forceLevel = msg.forceLevel == nil and ANY or msg.forceLevel
self.participating = msg.participating
self.stage = "bracket"
-- the server already seeded t.players/spectators with the creator at
-- creation time; mirror that here so the Discord party size (and the
-- "waiting for players" list) show the host from the very first frame,
-- not just once someone else joins and a real roster broadcast arrives
if self.participating then
self.roster = { self.game.save.player.name }
else
self.spectatorRoster = { self.game.save.player.name }
end
if self.isCreator then
DiscordPresence.setJoinCode(self.code, "tournament",
self:discordPartySize(), TOURNAMENT_PARTY_MAX)
end
elseif msg.type == "tournament_host_error" then
if msg.reason == "party_ineligible" then
self:exitWith(("Can't host:\nneed %d Pokemon\nLv %s-%s."):format(
msg.requiredPartySize, levelLabel(msg.minLevel), levelLabel(msg.maxLevel)))
else
self:exitWith("Couldn't host\nthat tournament.")
end
elseif msg.type == "tournament_join_error" then
if msg.reason == "party_ineligible" then
self:exitWith(("Your party needs\n%d Pokemon, Lv\n%s-%s."):format(
msg.requiredPartySize, levelLabel(msg.minLevel), levelLabel(msg.maxLevel)))
else
self:exitWith(JOIN_ERROR_TEXT[msg.reason] or "Couldn't join\nthat tournament.")
end
elseif msg.type == "tournament_roster" then
self.roster = msg.players
self.spectatorRoster = msg.spectators or {}
self.settings.turnLimit = msg.turnLimit
self.settings.requiredPartySize = msg.requiredPartySize
self.settings.minLevel = msg.minLevel == nil and ANY or msg.minLevel
self.settings.maxLevel = msg.maxLevel == nil and ANY or msg.maxLevel
self.settings.forceLevel = msg.forceLevel == nil and ANY or msg.forceLevel
self.stage = "bracket"
if self.isCreator and self.code then
DiscordPresence.setJoinCode(self.code, "tournament",
self:discordPartySize(), TOURNAMENT_PARTY_MAX)
end
elseif msg.type == "bracket_update" then
self.bracket = msg.tournament
self.code = self.bracket.code
if self.stage == "registering" then self.stage = "bracket" end
elseif msg.type == "match_start" then
self:enterMatch(msg)
elseif msg.type == "match_start_spectate" then
self:enterSpectate(msg)
elseif msg.type == "tournament_bye" then
self.byeRound = msg.round
elseif msg.type == "tournament_over" then
self.champion = msg.champion
self.stage = "done"
end
end
-- -------------------------------------------------------------------
-- entering a match: real participant
-- -------------------------------------------------------------------
function Tournament:sendHello(mode)
self.myHello = Handshake.hello(self.game, mode)
self.net:send(self.myHello)
end
function Tournament:pollHello()
local msgs = self.net:poll()
local keep, got = {}, false
for _, msg in ipairs(msgs) do
if msg.type == "hello" and not self.peerHello then
self.peerHello = msg
got = true
else
keep[#keep + 1] = msg
end
end
for i = #keep, 1, -1 do
table.insert(self.net.inbox, 1, keep[i])
end
return got
end
function Tournament:enterMatch(msg)
self.isHost = (msg.role == "host")
self.opponentName = msg.opponent
self.matchRound = msg.round
self.matchTurnLimit = msg.turnLimit
self.peerHello = nil
self:sendHello(self.isHost and "battle" or nil)
self.stage = "matchHello"
end
function Tournament:beginMatchBattle()
local verdict = Handshake.checkCompat(self.myHello, self.peerHello)
if not (verdict == "full" or verdict == "vanilla_peer") then
-- shouldn't happen (both sides already passed the online-play mods
-- gate), but a mismatched engine/build is still possible -- bail out
-- of just this match rather than crash the tournament
self:exitWith("Link error:\nversion mismatch\nwith opponent.")
return
end
self.linkSeed = self.isHost and love.math.random(1, 2 ^ 30) or nil
local opts = {
myParty = Protocol.packParty(self.game.save.party),
theirName = self.opponentName or "FOE",
seed = self.isHost and self.linkSeed or nil,
verdict = verdict,
strict = Handshake.strict(verdict),
turnLimit = self.matchTurnLimit,
forceLevel = levelForWire(self.settings.forceLevel),
keepNetOpen = true, -- this is the tournament's own connection, not a
-- dedicated match socket -- don't let finish() close it
}
self.stage = "matchWaitParty"
self.pendingBattleOpts = opts
self.net:send({ type = "party",
mons = Protocol.packParty(self.game.save.party),
seed = self.linkSeed })
end
-- -------------------------------------------------------------------
-- entering a match: spectator
-- -------------------------------------------------------------------
function Tournament:enterSpectate(msg)
self.matchRound = msg.round
self.spectate = {
hostName = msg.playerHost, guestName = msg.playerGuest,
hostParty = nil, guestParty = nil, seed = nil,
}
self.stage = "spectateWait"
end
-- -------------------------------------------------------------------
-- update
-- -------------------------------------------------------------------
function Tournament:update(dt)
local input = self.game.input
if self.stage == "matchRunning" or self.stage == "spectateRunning" then
if self.game.stack:top() == self then
-- the battle popped; drain anything the network handed to it but it
-- didn't itself understand (a bracket_update, the next match...)
local battle = self.activeBattle
self.activeBattle = nil
if self.stage == "matchRunning" and battle and battle.result and self.net
and not self.net.closed then
-- a real participant reports its own outcome; the server resolves
-- the match once both sides have (or one disconnects)
self.net:send({ type = "tournament_result", result = battle.result })
end
if battle and battle.pendingTournamentMessages then
for _, msg in ipairs(battle.pendingTournamentMessages) do
self:handleMessage(msg)
end
end
if self.stage == "matchRunning" or self.stage == "spectateRunning" then
self.stage = "bracket"
end
end
return
end
if self.net then
self.net:update()
if self.net.error and self.stage ~= "menu" and self.stage ~= "hostSettings"
and self.stage ~= "codeEntry" then
self:exitWith("Link error:\n" .. self.net.error:sub(1, 60))
return
end
if self.net.closed and self.stage ~= "done" then
self:exitWith("The tournament\nconnection was\nlost.")
return
end
end
if self.stage == "matchHello" then
if input:wasPressed("b") then self:exitWith(nil) return end
self:pollHello()
if self.peerHello then self:beginMatchBattle() end
for _, msg in ipairs(self.net:poll()) do self:handleMessage(msg) end
return
elseif self.stage == "matchWaitParty" then
if input:wasPressed("b") then self:exitWith(nil) return end
local msgs = self.net:poll()
for i, msg in ipairs(msgs) do
if msg.type == "party" then
self.pendingBattleOpts.theirParty = msg.mons
if self.isHost then
self.pendingBattleOpts.seed = self.pendingBattleOpts.seed or self.linkSeed
else
self.pendingBattleOpts.seed = msg.seed
end
local battle, why = self.isHost
and LinkBattle.newHost(self.game, self.net, self.pendingBattleOpts)
or LinkBattle.newGuest(self.game, self.net, self.pendingBattleOpts)
if not battle then
self:exitWith(why or "Link battle\ncan't start.")
return
end
-- anything after `party` in this same batch belongs to the
-- battle now, not to Tournament -- put it back for its own poll()
for j = #msgs, i + 1, -1 do
table.insert(self.net.inbox, 1, msgs[j])
end
self.activeBattle = battle
self.game.stack:push(battle)
self.stage = "matchRunning"
return
else
self:handleMessage(msg)
end
end
return
elseif self.stage == "spectateWait" then
if input:wasPressed("b") then self:exitWith(nil) return end
local msgs = self.net:poll()
for i, msg in ipairs(msgs) do
if msg.type == "spectate" and msg.msg.type == "party" then
local inner = msg.msg
if msg.side == "host" then
self.spectate.hostParty = inner.mons
self.spectate.seed = inner.seed
else
self.spectate.guestParty = inner.mons
end
if self.spectate.hostParty and self.spectate.guestParty then
local battle, why = LinkBattle.newSpectator(self.game, self.net, {
hostParty = self.spectate.hostParty, guestParty = self.spectate.guestParty,
hostName = self.spectate.hostName, guestName = self.spectate.guestName,
seed = self.spectate.seed,
forceLevel = levelForWire(self.settings.forceLevel),
})
if not battle then
self:exitWith(why or "Can't watch this\nmatch.")
return
end
for j = #msgs, i + 1, -1 do
table.insert(self.net.inbox, 1, msgs[j])
end
self.activeBattle = battle
self.game.stack:push(battle)
self.stage = "spectateRunning"
return
end
else
self:handleMessage(msg)
end
end
return
end
if self.net then
for _, msg in ipairs(self.net:poll()) do self:handleMessage(msg) end
end
if self.stage == "menu" then
if input:wasPressed("up") or input:wasPressed("down") then
self.index = self.index == 1 and 2 or 1
elseif input:wasPressed("b") then
self:exitWith(nil)
elseif input:wasPressed("a") then
if self.index == 1 then
self.stage = "hostSettings"
self.settingsIndex = 1
else
self.stage = "codeEntry"
self.codeEntry = CodeEntry.new()
end
end
elseif self.stage == "hostSettings" then
if input:wasPressed("b") then
self.stage = "menu"
self.index = 1
elseif input:wasPressed("up") then
self.settingsIndex = self.settingsIndex == 1 and SETTINGS_ROWS or self.settingsIndex - 1
elseif input:wasPressed("down") then
self.settingsIndex = self.settingsIndex % SETTINGS_ROWS + 1
elseif input:wasPressed("left") or input:wasPressed("right") then
local delta = input:wasPressed("right") and 1 or -1
if self.settingsIndex == 1 then
local i = indexOf(PARTY_SIZES, self.settings.requiredPartySize)
i = ((i - 1 + delta) % #PARTY_SIZES) + 1
self.settings.requiredPartySize = PARTY_SIZES[i]
elseif self.settingsIndex == 2 then
local i = indexOf(LEVEL_STEPS, self.settings.minLevel)
i = ((i - 1 + delta) % #LEVEL_STEPS) + 1
self.settings.minLevel = LEVEL_STEPS[i]
elseif self.settingsIndex == 3 then
local i = indexOf(LEVEL_STEPS, self.settings.maxLevel)
i = ((i - 1 + delta) % #LEVEL_STEPS) + 1
self.settings.maxLevel = LEVEL_STEPS[i]
elseif self.settingsIndex == 4 then
local i = indexOf(TURN_LIMITS, self.settings.turnLimit)
i = ((i - 1 + delta) % #TURN_LIMITS) + 1
self.settings.turnLimit = TURN_LIMITS[i]
elseif self.settingsIndex == 5 then
local i = indexOf(FORCE_LEVEL_STEPS, self.settings.forceLevel)
i = ((i - 1 + delta) % #FORCE_LEVEL_STEPS) + 1
self.settings.forceLevel = FORCE_LEVEL_STEPS[i]
elseif self.settingsIndex == 6 then
self.settings.participating = not self.settings.participating
end
elseif input:wasPressed("a") or input:wasPressed("start") then
self:startHosting()
end
elseif self.stage == "codeEntry" then
if input:wasPressed("b") then
self.stage = "menu"
self.index = 2
elseif input:wasPressed("up") then
CodeEntry.up(self.codeEntry)
elseif input:wasPressed("down") then
CodeEntry.down(self.codeEntry)
elseif input:wasPressed("left") then
CodeEntry.left(self.codeEntry)
elseif input:wasPressed("right") then
CodeEntry.right(self.codeEntry)
elseif input:wasPressed("a") then
self:startJoining(CodeEntry.text(self.codeEntry))
end
elseif self.stage == "registering" then
if input:wasPressed("b") then self:exitWith(nil) end
elseif self.stage == "bracket" then
if input:wasPressed("b") then self:exitWith(nil) return end
if input:wasPressed("a") and self.isCreator and #self.roster >= 2 then
DiscordPresence.setJoinCode(nil) -- roster's locking in; stop advertising
self.net:send({ type = "start_tournament" })
end
elseif self.stage == "done" then
if input:wasPressed("a") or input:wasPressed("b") then
self:exitWith(nil)
end
end
end
-- -------------------------------------------------------------------
-- draw
-- -------------------------------------------------------------------
local function drawTitle(text)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(text, 8, 6)
end
local SETTINGS_LABELS = { "POKEMON", "MIN LV", "MAX LV", "TIMER", "LEVELS", "PLAYING" }
function Tournament:draw()
if self.stage == "menu" then
drawTitle("TOURNAMENT")
Font.draw("HOST", 32, 48)
Font.draw("JOIN", 32, 68)
Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68)
elseif self.stage == "hostSettings" then
drawTitle("TOURNAMENT RULES")
local values = {
tostring(self.settings.requiredPartySize),
levelLabel(self.settings.minLevel),
levelLabel(self.settings.maxLevel),
self.settings.turnLimit .. "s",
forceLevelLabel(self.settings.forceLevel),
self.settings.participating and "YES" or "NO",
}
for i, label in ipairs(SETTINGS_LABELS) do
local y = 32 + (i - 1) * 16
Font.draw(label, 16, y)
Font.draw(values[i], 96, y)
if i == self.settingsIndex then Font.drawCode(CURSOR, 8, y) end
end
Font.draw("START: create", 8, 128)
elseif self.stage == "codeEntry" then
drawTitle("ENTER CODE")
for i = 1, CodeEntry.LENGTH do
local x = 16 + (i - 1) * 16
local ch = CodeEntry.CHARSET:sub(self.codeEntry.chars[i], self.codeEntry.chars[i])
Font.draw(ch, x, 64)
if i == self.codeEntry.pos then
Font.drawCode(0xEE, x, 76)
end
end
Font.draw("A: join B: back", 8, 128)
elseif self.stage == "registering" then
drawTitle("CONNECTING...")
Font.draw("B: cancel", 8, 128)
elseif self.stage == "bracket" or self.stage == "matchHello"
or self.stage == "matchWaitParty" or self.stage == "spectateWait" then
drawTitle(("TOURNAMENT %s"):format(self.code or "??????"))
if self.bracket then
local y = 20
for _, round in ipairs(self.bracket.rounds) do
Font.draw(("ROUND %d"):format(round.round), 8, y)
y = y + 10
for _, m in ipairs(round.matches) do
local line
if m.bye then
line = ("%s (bye)"):format(m.a or m.b or "?")
else
local mark = m.state == "live" and "*" or (m.winner and "" or "")
line = ("%s%s vs %s%s"):format(
m.winner == m.a and ">" or " ", m.a or "?",
m.b or "?", m.winner == m.b and "<" or (mark == "*" and " *" or ""))
end
Font.draw(line, 12, y)
y = y + 10
if y > 120 then break end
end
end
else
if self.participating == false then
Font.draw("(organizing --", 16, 32)
Font.draw("not playing)", 16, 42)
end
Font.draw("Waiting for", 16, 48)
Font.draw("players to join:", 16, 60)
local y = 60
for i, name in ipairs(self.roster) do
y = 60 + i * 10
Font.draw(name, 24, y)
end
for _, name in ipairs(self.spectatorRoster) do
y = y + 10
Font.draw(name .. " (watch)", 24, y)
end
end
if self.isCreator and #self.roster >= 2 and not self.bracket then
Font.draw("A: START B: cancel", 8, 132)
else
Font.draw("B: cancel", 8, 132)
end
elseif self.stage == "done" then
drawTitle("TOURNAMENT OVER")
if self.champion then
Font.draw(("%s is the"):format(self.champion), 16, 56)
Font.draw("champion!", 16, 68)
end
Font.draw("A: continue", 8, 128)
end
love.graphics.setColor(1, 1, 1, 1)
end
return Tournament
+6
View File
@@ -18,6 +18,12 @@ ManagerState.isOpaque = true
-- Game:keypressed recognizes a directly-pushed instance -- Game:keypressed recognizes a directly-pushed instance
ManagerState.screenId = "ManagerState" 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 -- the charmap has no * ~ + < > glyphs, so the status gutter uses what it
-- does have: staged-awaiting-restart, disabled, errored, dep-unhealthy -- does have: staged-awaiting-restart, disabled, errored, dep-unhealthy
local GLYPH = { staged = ".", disabled = "-", errored = "!", blocked = "?" } local GLYPH = { staged = ".", disabled = "-", errored = "!", blocked = "?" }
+6 -2
View File
@@ -872,11 +872,15 @@ R.palettes = {
} }
-- keyed by species id, unlike the vanilla byDex array: a species past the -- 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 -- 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.
R.icons = { R.icons = {
semantics = "record", target = "icons.bySpecies", semantics = "record", target = "icons.bySpecies",
value = f.union{ f.str, f.rec{ image = f.path, frames = f.opt(f.int(1)) } }, value = f.union{ f.str, f.rec{ image = f.path, frames = f.opt(f.int(1)) } },
example = 'mod.content.icons:register("MISSINGNO", { image = "glitch.png" })', example = 'mod.content.icons:register("MODMON", "QUADRUPED") -- a built-in name, or { image = mod.assets:path("icon.png"), frames = 2 }',
} }
-- glyph codes are not bytes: the vanilla pages sit at $60/$80 but a -- glyph codes are not bytes: the vanilla pages sit at $60/$80 but a
+5
View File
@@ -230,6 +230,11 @@ function BattleTransition:draw()
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
local prog = math.min(1, self.t / self.wipeLen) local prog = math.min(1, self.t / self.wipeLen)
-- Cascade black 8x8 blocks across the window area *outside* the classic
-- 160x144 wipe square, in lockstep with the OG wipe progress. Renderer
-- paints them in screen space after the world blit (see endFrame).
local renderer = self.game and self.game.renderer
if renderer then renderer.battleCascadeProg = prog end
local style = self.style local style = self.style
-- a registered style may draw itself; the eight built-ins do not -- a registered style may draw itself; the eight built-ins do not
+29 -1
View File
@@ -22,6 +22,15 @@ GBCFX.level = 0
local shader -- false = unavailable (headless / no shader support) local shader -- false = unavailable (headless / no shader support)
-- Mobile GPUs often compile this pass but present a black frame, and the
-- level persists in options.lua -- soft-bricking the APK until a manual
-- edit (issue #136). Desktop is unchanged; Android/iOS refuse the effect.
function GBCFX.isSupported()
if not love or not love.system or not love.system.getOS then return true end
local osName = love.system.getOS()
return osName ~= "Android" and osName ~= "iOS"
end
-- GLSL 1.20-compatible (no array initializers; wavelength terms and the -- GLSL 1.20-compatible (no array initializers; wavelength terms and the
-- shadow blur are unrolled by hand). -- shadow blur are unrolled by hand).
local SHADER_SRC = [[ local SHADER_SRC = [[
@@ -214,6 +223,7 @@ vec4 effect(vec4 color, Image tex, vec2 tc, vec2 pc)
GBCFX.SHADER_SRC = SHADER_SRC -- exposed for the standalone compile check GBCFX.SHADER_SRC = SHADER_SRC -- exposed for the standalone compile check
function GBCFX.shader() function GBCFX.shader()
if not GBCFX.isSupported() then return nil end
if shader == nil then if shader == nil then
local ok, sh = pcall(love.graphics.newShader, SHADER_SRC) local ok, sh = pcall(love.graphics.newShader, SHADER_SRC)
shader = ok and sh or false shader = ok and sh or false
@@ -222,6 +232,10 @@ function GBCFX.shader()
end end
function GBCFX.setLevel(level) function GBCFX.setLevel(level)
if not GBCFX.isSupported() then
GBCFX.level = 0
return
end
level = math.floor(tonumber(level) or 0) level = math.floor(tonumber(level) or 0)
if level < 0 then level = 0 end if level < 0 then level = 0 end
if level > 4 then level = 4 end if level > 4 then level = 4 end
@@ -230,12 +244,26 @@ end
-- Advance OFF → 1 → 2 → 3 → 4 → OFF. Returns the new level. -- Advance OFF → 1 → 2 → 3 → 4 → OFF. Returns the new level.
function GBCFX.cycle() function GBCFX.cycle()
if not GBCFX.isSupported() then
GBCFX.level = 0
return 0
end
GBCFX.setLevel((GBCFX.level + 1) % 5) GBCFX.setLevel((GBCFX.level + 1) % 5)
return GBCFX.level return GBCFX.level
end end
-- Apply opts.gbcfx. On unsupported platforms force OFF and clear a
-- persisted non-zero value so boot recovers from a soft-brick. Returns
-- true when opts was sanitized (caller should persist options.lua).
function GBCFX.applyOptions(opts) function GBCFX.applyOptions(opts)
if not GBCFX.isSupported() then
local had = opts and (tonumber(opts.gbcfx) or 0) ~= 0
if opts then opts.gbcfx = 0 end
GBCFX.level = 0
return had and true or false
end
GBCFX.setLevel(opts and opts.gbcfx or 0) GBCFX.setLevel(opts and opts.gbcfx or 0)
return false
end end
function GBCFX.levelLabel(level) function GBCFX.levelLabel(level)
@@ -243,7 +271,7 @@ function GBCFX.levelLabel(level)
end end
function GBCFX.active() function GBCFX.active()
return GBCFX.level > 0 and GBCFX.shader() ~= nil return GBCFX.isSupported() and GBCFX.level > 0 and GBCFX.shader() ~= nil
end end
-- Draw `canvas` fullscreen through the GBC FX shader into the current -- Draw `canvas` fullscreen through the GBC FX shader into the current
+44 -7
View File
@@ -242,6 +242,20 @@ function PaletteFX.pack(data)
return data and data.palettes or nil return data and data.palettes or nil
end 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). -- 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). -- 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 -- OG RED short-circuits EVERY name to the one global GBC boot-ROM BG palette
@@ -251,11 +265,15 @@ end
-- GBC_OBJ green), so this stays a BG-only hook. -- GBC_OBJ green), so this stays a BG-only hook.
function PaletteFX.pal(data, name) function PaletteFX.pal(data, name)
if PaletteFX.mode == "ogred" then return PaletteFX.ogBg() end 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 p = PaletteFX.pack(data)
local c = p and p.palettes[name] local c = p and p.palettes[name]
if c then return c end if c then return c end
if PaletteFX.usesGbcPack() and data and data.palettes then if PaletteFX.usesGbcPack() then
return data.palettes.palettes[name] return romNamedPal(data, name)
end end
return nil return nil
end end
@@ -277,6 +295,16 @@ function PaletteFX.monPal(data, species, transformed)
return p.palettes.GRAYMON return p.palettes.GRAYMON
or (data and data.palettes and data.palettes.palettes.GRAYMON) or (data and data.palettes and data.palettes.palettes.GRAYMON)
end end
-- a species' own `palette` field (per-record override) wins over the
-- vanilla species->name map. Mod-registered palettes live in
-- data.palettes.palettes even when the active pack is the RED++ gbc pack,
-- so fall back to it when the pack itself doesn't carry the name.
local def = data and data.pokemon and data.pokemon[species]
if def and def.palette then
local pal = p.palettes[def.palette]
or (data and data.palettes and data.palettes.palettes[def.palette])
if pal then return pal end
end
local name = p.pokemon[species] or "MEWMON" local name = p.pokemon[species] or "MEWMON"
local c = p.palettes[name] local c = p.palettes[name]
if c then return c end if c then return c end
@@ -290,6 +318,12 @@ end
-- palette name a species currently resolves to (for image-cache keys) -- palette name a species currently resolves to (for image-cache keys)
function PaletteFX.monPalName(data, species, transformed) function PaletteFX.monPalName(data, species, transformed)
if transformed then return "GRAYMON" end if transformed then return "GRAYMON" end
-- honor the per-record palette override, matching monPal
local def = data and data.pokemon and data.pokemon[species]
if def and def.palette and data.palettes
and data.palettes.palettes[def.palette] then
return def.palette
end
local p = PaletteFX.pack(data) local p = PaletteFX.pack(data)
if p and p.pokemon[species] then return p.pokemon[species] end if p and p.pokemon[species] then return p.pokemon[species] end
if data and data.palettes and data.palettes.pokemon[species] then if data and data.palettes and data.palettes.pokemon[species] then
@@ -335,18 +369,21 @@ local TILESET_GROUP_EXCEPTIONS = {
CEMETERY = { tiles = { [0x22] = true }, group = 0 }, CEMETERY = { tiles = { [0x22] = true }, group = 0 },
} }
-- pokered-gbc's lobby.bst repoints the Celadon roof table's flat top -- pokered-gbc's lobby.bst repoints the Celadon LOBBY table's flat top
-- (block 29, cells 5/6/9/10) at a duplicate tile ($5a, BROWN) so the -- (block 29, cells 5/6/9/10) at a duplicate tile ($5a, BROWN) so the
-- tabletop and the checkerboard floor -- both raw tile $37 -- can take -- tabletop and the checkerboard floor -- both raw tile $37 -- can take
-- different palettes; the vanilla-derived blockset shares the one tile -- different palettes; the vanilla-derived blockset shares the one tile
-- id, so the RED++ atlas path re-creates the duplicate: the alias slot -- id, so the RED++ atlas path re-creates the duplicate: the alias slot
-- is baked as a copy of `tile` in `group`'s colors, and the listed -- is baked as a copy of `tile` in `group`'s colors, and the listed
-- 0-based block cells draw the alias instead of the shared tile. -- 0-based block cells draw the alias instead of the shared tile.
-- Same block appears on CELADON_MART_ROOF (#52) and CELADON_DINER (#84).
local LOBBY_TABLE_TOP_ALIAS = {
{ block = 29, cells = { [5] = true, [6] = true, [9] = true, [10] = true },
tile = 0x37, alias = 0x5a, group = 5 },
}
PaletteFX.TILE_ALIASES = { PaletteFX.TILE_ALIASES = {
CELADON_MART_ROOF = { CELADON_MART_ROOF = LOBBY_TABLE_TOP_ALIAS,
{ block = 29, cells = { [5] = true, [6] = true, [9] = true, [10] = true }, CELADON_DINER = LOBBY_TABLE_TOP_ALIAS,
tile = 0x37, alias = 0x5a, group = 5 },
},
} }
local ROOF_GROUP = 6 local ROOF_GROUP = 6
local ROUTE_6_SAFFRON = { mapId = "ROUTE_6", useMapId = "SAFFRON_CITY", cellYBelow = 2 } local ROUTE_6_SAFFRON = { mapId = "ROUTE_6", useMapId = "SAFFRON_CITY", cellYBelow = 2 }
+110 -16
View File
@@ -23,6 +23,29 @@ Renderer.HEIGHT = 144
-- endFrame composites the padded canvas back with a matching offset. -- endFrame composites the padded canvas back with a matching offset.
Renderer.UPRIGHT_MARGIN = 160 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() function Renderer:init()
self.canvas = love.graphics.newCanvas(self.WIDTH, self.HEIGHT) self.canvas = love.graphics.newCanvas(self.WIDTH, self.HEIGHT)
self.canvas:setFilter("nearest", "nearest") self.canvas:setFilter("nearest", "nearest")
@@ -36,10 +59,12 @@ function Renderer:init()
self.uprightActive = false self.uprightActive = false
end end
-- integer scale that fits the GB UI viewport in the window -- 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.
function Renderer:fitScale() function Renderer:fitScale()
local ww, wh = love.graphics.getDimensions() local _, _, pw, ph = displayMetrics()
return math.max(1, math.floor(math.min(ww / self.WIDTH, wh / self.HEIGHT))) return math.max(1, math.floor(math.min(pw / self.WIDTH, ph / self.HEIGHT)))
end end
-- world-pass canvas size in world pixels: enough to fill the window at s'. -- world-pass canvas size in world pixels: enough to fill the window at s'.
@@ -48,9 +73,13 @@ end
-- background peeking at the receded top/bottom corners; flat mode returns -- background peeking at the receded top/bottom corners; flat mode returns
-- exactly today's size (growth factor is 1 when tilt is inactive). -- exactly today's size (growth factor is 1 when tilt is inactive).
function Renderer:worldViewSize() function Renderer:worldViewSize()
local ww, wh = love.graphics.getDimensions() local ww, wh, _, _, dpi = displayMetrics()
local s = Zoom.scale(self:fitScale()) local s = Zoom.scale(self:fitScale()) / dpi
local vw, vh = Zoom.fillViewSize(s, ww, wh) 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 if Tilt.active() then
local g = Tilt.viewGrowth() local g = Tilt.viewGrowth()
vw, vh = math.ceil(vw * g), math.ceil(vh * g) vw, vh = math.ceil(vw * g), math.ceil(vh * g)
@@ -65,6 +94,8 @@ function Renderer:beginFrame(transparent)
-- warp-fade overlay from Transition (issue #121); cleared each frame so -- warp-fade overlay from Transition (issue #121); cleared each frame so
-- a popped transition cannot leave a sticky black veil -- a popped transition cannot leave a sticky black veil
self.worldFadeAlpha = nil self.worldFadeAlpha = nil
-- battle-transition cascade outside the 160x144 wipe (BattleTransition)
self.battleCascadeProg = nil
-- last frame's trueColor rects and sprite redraws go before anything -- last frame's trueColor rects and sprite redraws go before anything
-- draws this one -- draws this one
PaletteFX.clearTrueColor() PaletteFX.clearTrueColor()
@@ -78,6 +109,46 @@ function Renderer:beginFrame(transparent)
end end
end end
-- Black 8x8 (scaled) blocks cascading outward from the classic GB letterbox
-- into the surrounding window, matching BattleTransition wipe progress.
-- Tiles that sit entirely inside the 160x144 square are left to the OG wipe.
function Renderer:drawBattleCascade(prog, ww, wh, ox, oy, vpw, vph, S)
if not prog or prog <= 0 then return end
local TILE = 8 * S
if TILE < 1 then TILE = 1 end
local cols = math.ceil(ww / TILE)
local rows = math.ceil(wh / TILE)
local cx, cy = ox + vpw / 2, oy + vph / 2
local order = {}
for row = 0, rows - 1 do
for col = 0, cols - 1 do
local x, y = col * TILE, row * TILE
-- any tile with area outside the letterbox participates
if x < ox or y < oy or x + TILE > ox + vpw or y + TILE > oy + vph then
local dist = math.max(math.abs(x + TILE / 2 - cx),
math.abs(y + TILE / 2 - cy))
order[#order + 1] = { x, y, dist }
end
end
end
if #order == 0 then return end
table.sort(order, function(a, b)
if a[3] ~= b[3] then return a[3] < b[3] end
if a[2] ~= b[2] then return a[2] < b[2] end
return a[1] < b[1]
end)
local n = math.floor(#order * math.min(1, prog) + 1e-6)
if prog >= 1 then n = #order end
love.graphics.setColor(0, 0, 0, 1)
love.graphics.setScissor(0, 0, ww, wh)
for i = 1, n do
local t = order[i]
love.graphics.rectangle("fill", t[1], t[2], TILE, TILE)
end
love.graphics.setScissor()
love.graphics.setColor(1, 1, 1, 1)
end
function Renderer:beginWorldPass() function Renderer:beginWorldPass()
local vw, vh = self:worldViewSize() local vw, vh = self:worldViewSize()
if not self.worldCanvas or self.worldCanvas:getWidth() ~= vw if not self.worldCanvas or self.worldCanvas:getWidth() ~= vw
@@ -289,11 +360,14 @@ end
-- presented through the GBC FX shader as a final pass. -- presented through the GBC FX shader as a final pass.
function Renderer:endFrame(zones, worldZones) function Renderer:endFrame(zones, worldZones)
love.graphics.setCanvas() love.graphics.setCanvas()
local ww, wh = love.graphics.getDimensions() local ww, wh, pw, ph, dpi = displayMetrics()
local S = self:fitScale() -- Sp = integer framebuffer pixels per GB pixel; S = LOVE-unit draw scale.
local Sp = self:fitScale()
local S = Sp / dpi
local vpw, vph = self.WIDTH * S, self.HEIGHT * S local vpw, vph = self.WIDTH * S, self.HEIGHT * S
local ox = math.floor((ww - vpw) / 2) -- Snap the letterbox origin to a framebuffer pixel, then convert to units.
local oy = math.floor((wh - vph) / 2) local ox = math.floor((pw - self.WIDTH * Sp) / 2) / dpi
local oy = math.floor((ph - self.HEIGHT * Sp) / 2) / dpi
local GBCFX = require("src.render.GBCFX") local GBCFX = require("src.render.GBCFX")
-- Forced mono/Classic modes still need a whole-screen zone when a state -- Forced mono/Classic modes still need a whole-screen zone when a state
-- exposes no SGB packets (raw DMG canvas), so sendColors can remap. -- exposes no SGB packets (raw DMG canvas), so sendColors can remap.
@@ -317,13 +391,26 @@ function Renderer:endFrame(zones, worldZones)
present = self.presentCanvas present = self.presentCanvas
love.graphics.setCanvas(present) love.graphics.setCanvas(present)
end end
love.graphics.setColor(0, 0, 0, 1) -- Default letterbox is black. Battle (and any state that opts in via
-- letterboxWhite) fills the voids white so the window matches the
-- white battle canvas instead of showing black bars.
local clearR, clearG, clearB = 0, 0, 0
if not self.worldActive then
local ok, Game = pcall(require, "src.core.Game")
local stack = ok and Game and Game.stack
local base = stack and stack.visibleBase and stack:visibleBase()
local state = base and stack.states and stack.states[base]
if state and state.letterboxWhite then
clearR, clearG, clearB = 1, 1, 1
end
end
love.graphics.setColor(clearR, clearG, clearB, 1)
love.graphics.rectangle("fill", 0, 0, ww, wh) love.graphics.rectangle("fill", 0, 0, ww, wh)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
-- blit `canvas` at integer `scale` into origin (bx, by), scissored to -- blit `canvas` at `scale` (LOVE units) into origin (bx, by), scissored to
-- the (boxX, boxY, boxW, boxH) screen rect. zoneScale converts zone -- the (boxX, boxY, boxW, boxH) screen rect. zoneScale converts zone
-- coords (canvas-space) into screen pixels. -- coords (canvas-space) into screen units.
local function blit(canvas, scale, zoneList, zoneScale, bx, by, boxX, boxY, boxW, boxH) local function blit(canvas, scale, zoneList, zoneScale, bx, by, boxX, boxY, boxW, boxH)
local shader = zoneList and zoneList[1] and PaletteFX.shader() or nil local shader = zoneList and zoneList[1] and PaletteFX.shader() or nil
if not shader then if not shader then
@@ -355,11 +442,12 @@ function Renderer:endFrame(zones, worldZones)
end end
if self.worldActive then if self.worldActive then
local s = Zoom.scale(S) local sp = Zoom.scale(Sp)
local s = sp / dpi
local wvw = self.worldCanvas:getWidth() local wvw = self.worldCanvas:getWidth()
local wvh = self.worldCanvas:getHeight() local wvh = self.worldCanvas:getHeight()
local wox = math.floor((ww - wvw * s) / 2) local wox = math.floor((pw - wvw * sp) / 2) / dpi
local woy = math.floor((wh - wvh * s) / 2) local woy = math.floor((ph - wvh * sp) / 2) / dpi
-- Tilt mode projects the ground world pass through the perspective mesh -- Tilt mode projects the ground world pass through the perspective mesh
-- (SGB zones baked in beforehand -- see drawTiltedWorld -- so no zone -- (SGB zones baked in beforehand -- see drawTiltedWorld -- so no zone
-- scissoring here). drawTiltedWorld returns false when tilt is off or -- scissoring here). drawTiltedWorld returns false when tilt is off or
@@ -427,13 +515,19 @@ function Renderer:endFrame(zones, worldZones)
love.graphics.rectangle("fill", 0, 0, ww, wh) love.graphics.rectangle("fill", 0, 0, ww, wh)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
end end
-- Battle transition: cascade black blocks into the area outside the
-- classic 160x144 wipe square (world still shows through until filled).
if self.battleCascadeProg then
self:drawBattleCascade(self.battleCascadeProg, ww, wh, ox, oy, vpw, vph, S)
end
end end
-- UI stays in the classic centered GB letterbox -- UI stays in the classic centered GB letterbox
blit(self.canvas, S, zones, S, ox, oy, ox, oy, vpw, vph) blit(self.canvas, S, zones, S, ox, oy, ox, oy, vpw, vph)
if present then if present then
love.graphics.setCanvas() love.graphics.setCanvas()
GBCFX.present(present, S) -- shader grid/shadow math is in framebuffer pixels
GBCFX.present(present, Sp)
end end
self.worldActive = false self.worldActive = false
self.uprightActive = false self.uprightActive = false
+171 -33
View File
@@ -10,19 +10,57 @@ TileRenderer.__index = TileRenderer
local BORDER_BLOCKS = 3 -- ring width; > half a screen (2.5 blocks) local BORDER_BLOCKS = 3 -- ring width; > half a screen (2.5 blocks)
-- OVERWORLD maps fill beyond-edge space with the solid tree wall -- OVERWORLD maps fill beyond-edge space from save.options.voidFill:
-- (blockset $0F: four regular-tree metatiles, tiles $40/$41/$50/$51, -- trees (default) — solid tree wall $0F (Viridian/Cerulean/Celadon)
-- the border block of ViridianCity/CeruleanCity/CeladonCity et al.), -- water — solid water $43 (Cinnabar/Route 19 border block)
-- not each map's own border_block, which can be grass ($0B, the -- black — solid black (no tiled metatile)
-- CutTreeBlockSwaps $0B->$0A cut-grass block) or water; other -- Other tilesets keep their designated border (interiors stay black/void).
-- tilesets keep their designated border (interiors stay black/void)
local TREE_WALL_BLOCK = 0x0F local TREE_WALL_BLOCK = 0x0F
local WATER_BORDER_BLOCK = 0x43
TileRenderer.VOID_FILLS = { "trees", "water", "black" }
TileRenderer.voidFill = "trees"
local function borderBlockFor(map) local function borderBlockFor(map)
if map.def.tileset == "OVERWORLD" then return TREE_WALL_BLOCK end if map.def.tileset == "OVERWORLD" then
local mode = TileRenderer.voidFill or "trees"
if mode == "water" then return WATER_BORDER_BLOCK end
if mode == "black" then return false end
return TREE_WALL_BLOCK
end
return map.def.borderBlock return map.def.borderBlock
end end
TileRenderer.borderBlockFor = borderBlockFor TileRenderer.borderBlockFor = borderBlockFor
function TileRenderer.setVoidFill(mode)
local ok = false
for _, m in ipairs(TileRenderer.VOID_FILLS) do
if m == mode then ok = true; break end
end
TileRenderer.voidFill = ok and mode or "trees"
end
function TileRenderer.cycleVoidFill()
local cur = TileRenderer.voidFill or "trees"
local idx = 1
for i, m in ipairs(TileRenderer.VOID_FILLS) do
if m == cur then idx = i; break end
end
TileRenderer.setVoidFill(
TileRenderer.VOID_FILLS[idx % #TileRenderer.VOID_FILLS + 1])
return TileRenderer.voidFill
end
function TileRenderer.applyOptions(opts)
TileRenderer.setVoidFill(opts and opts.voidFill or "trees")
end
function TileRenderer.voidFillLabel(mode)
mode = mode or TileRenderer.voidFill or "trees"
if mode == "water" then return "WATER" end
if mode == "black" then return "BLACK" end
return "TREES"
end
local imageCache = {} local imageCache = {}
local function getImage(path) local function getImage(path)
@@ -55,8 +93,29 @@ local FLOWER_IMAGES = {
local SPINNER_STRIP = "assets/generated/tilesets/spinners.png" local SPINNER_STRIP = "assets/generated/tilesets/spinners.png"
local animFrame = 0 local animFrame = 0
function TileRenderer.tick() local animAccum = 0
animFrame = animFrame + 1 local ANIM_STEP = 1 / 60 -- Game Boy logic rate (matches FixedStep.STEP)
-- Advance water/flower/spinner tile animation. Called from the overworld
-- draw path so it keeps running under dialogs (overworld update does not),
-- but consumes wall-clock dt into 60Hz steps so high/low display refresh
-- no longer speeds up or slows the cycle (issue #4).
-- tick() / tick(nil) with no love.timer.getDelta (headless tests) still
-- advances exactly one frame per call.
function TileRenderer.tick(dt)
if dt == nil and love and love.timer and love.timer.getDelta then
dt = love.timer.getDelta()
end
if dt == nil then
animFrame = animFrame + 1
return
end
-- Cap catch-up so a long stall cannot jump many water/flower periods
animAccum = math.min(animAccum + dt, 0.25)
while animAccum >= ANIM_STEP do
animAccum = animAccum - ANIM_STEP
animFrame = animFrame + 1
end
end end
-- ------------------------------------------------------------------ -- ------------------------------------------------------------------
@@ -466,42 +525,118 @@ function TileRenderer.new(map, data)
self.anims = anims self.anims = anims
self.claimedBy = claimedBy self.claimedBy = claimedBy
-- a repeating 32x32 image of the border block, tiled behind -- border-fill image is built lazily in :ensureBorderFill so a VOID FILL
-- everything the 3-block ring doesn't cover (the survey zoom sees -- option change can swap trees/water/black without reloading the map
-- far past the ring; interiors keep their black border this way) self.borderFillMode = nil
pcall(function() self.borderFill = nil
local border = map.tileset.blocks[borderBlockFor(map) + 1]
if not border then return end
local canvas = love.graphics.newCanvas(32, 32)
love.graphics.push("all")
love.graphics.setCanvas(canvas)
love.graphics.clear(1, 1, 1, 1)
for ty = 0, 3 do
for tx = 0, 3 do
local quad = self.quads[border[ty * 4 + tx + 1]]
if quad then love.graphics.draw(self.image, quad, tx * 8, ty * 8) end
end
end
love.graphics.setCanvas()
love.graphics.pop()
local img = love.graphics.newImage(canvas:newImageData())
img:setWrap("repeat", "repeat")
img:setFilter("nearest", "nearest")
self.borderFill = img
end)
return self return self
end end
-- Bake a static repeating 32x32 of `block` into self.borderFill.
local function bakeBorderFill(self, block)
local border = self.map.tileset.blocks[block + 1]
if not border then return end
local canvas = love.graphics.newCanvas(32, 32)
love.graphics.push("all")
love.graphics.setCanvas(canvas)
love.graphics.clear(1, 1, 1, 1)
for ty = 0, 3 do
for tx = 0, 3 do
local quad = self.quads[border[ty * 4 + tx + 1]]
if quad then love.graphics.draw(self.image, quad, tx * 8, ty * 8) end
end
end
love.graphics.setCanvas()
love.graphics.pop()
local img = love.graphics.newImage(canvas:newImageData())
img:setWrap("repeat", "repeat")
img:setFilter("nearest", "nearest")
self.borderFill = img
end
-- WATER void fill: the eight hshift frames of tile $14 (same cycle as map
-- water), wrap-tiled so the void scrolls in lockstep with on-map water.
local function ensureWaterBorderFill(self)
if self.borderWaterTextures then return true end
local map = self.map
local perRow = map.tileset.tilesPerRow
local colors, gbcKey
if self.gbcAtlas and self.data then
local group = PaletteFX.worldGroupAt(map.tileset.id, map.id, WATER_TILE)
local groupColors = PaletteFX.worldGroupColors(
self.data, map.tileset.id, map.id, nil)
colors = group and groupColors and groupColors[group + 1] or nil
gbcKey = "#gbc:" .. map.id
end
local textures = getShiftVariants(map.tileset.image, perRow, WATER_TILE,
colors, gbcKey)
if not textures then return false end
for _, img in ipairs(textures) do
img:setWrap("repeat", "repeat")
img:setFilter("nearest", "nearest")
end
self.borderWaterTextures = textures
return true
end
-- (re)build the repeating border image when the VOID FILL mode or tileset
-- choice changes. OVERWORLD "black" leaves borderFill nil and draws a
-- solid clear; "water" keeps the live hshift textures instead of a bake.
function TileRenderer:ensureBorderFill()
local block = borderBlockFor(self.map)
local mode = block == false and "black"
or ((self.map.def.tileset == "OVERWORLD")
and (TileRenderer.voidFill or "trees")
or "map")
local ready = (mode == "black")
or (mode == "water" and self.borderWaterTextures)
or (mode ~= "water" and mode ~= "black" and self.borderFill)
if self.borderFillMode == mode and ready then return end
if self.borderFill and self.borderFill.release then
pcall(self.borderFill.release, self.borderFill)
end
self.borderFill = nil
-- shared shift-variant cache: drop the reference only, never release
self.borderWaterTextures = nil
self.borderFillMode = mode
if mode == "black" or block == false or block == nil then return end
if mode == "water" then
if ensureWaterBorderFill(self) then return end
-- headless / missing pixels: fall back to the static water block bake
end
pcall(bakeBorderFill, self, block)
end
-- tile the border block across the whole view (world-aligned so it -- tile the border block across the whole view (world-aligned so it
-- meshes seamlessly with the ring batch) -- meshes seamlessly with the ring batch)
function TileRenderer:drawBorderFill(camX, camY, vw, vh) function TileRenderer:drawBorderFill(camX, camY, vw, vh)
if not self.borderFill then return end self:ensureBorderFill()
if self.borderFillMode == "black" then
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("fill", 0, 0, vw, vh)
love.graphics.setColor(1, 1, 1, 1)
return
end
if self.trueColor then PaletteFX.markTrueColor(0, 0, vw, vh) end if self.trueColor then PaletteFX.markTrueColor(0, 0, vw, vh) end
local x, y = math.floor(camX), math.floor(camY) local x, y = math.floor(camX), math.floor(camY)
-- one reused Quad per renderer, mutated in place: this runs every -- one reused Quad per renderer, mutated in place: this runs every
-- overworld frame, so allocating a fresh Quad here churned the GC -- overworld frame, so allocating a fresh Quad here churned the GC
local q = self.borderQuad local q = self.borderQuad
if self.borderFillMode == "water" and self.borderWaterTextures then
local step = math.floor(animFrame / ANIM_PERIOD) % #WATER_OFFSETS + 1
local tex = self.borderWaterTextures[WATER_OFFSETS[step] + 1]
if not tex then return end
if q then
q:setViewport(x, y, vw, vh, 8, 8)
else
q = love.graphics.newQuad(x, y, vw, vh, 8, 8)
self.borderQuad = q
end
love.graphics.draw(tex, q, 0, 0)
return
end
if not self.borderFill then return end
if q then if q then
q:setViewport(x, y, vw, vh, 32, 32) q:setViewport(x, y, vw, vh, 32, 32)
else else
@@ -709,6 +844,9 @@ function TileRenderer:releaseBatches()
safeRelease(self.winBatch); self.winBatch = nil safeRelease(self.winBatch); self.winBatch = nil
safeRelease(self.borderFill); self.borderFill = nil safeRelease(self.borderFill); self.borderFill = nil
safeRelease(self.borderQuad); self.borderQuad = nil safeRelease(self.borderQuad); self.borderQuad = nil
-- shared shift-variant cache; only drop the reference
self.borderWaterTextures = nil
self.borderFillMode = nil
self.win = nil self.win = nil
if self.quads then if self.quads then
for _, q in pairs(self.quads) do safeRelease(q) end for _, q in pairs(self.quads) do safeRelease(q) end
+41 -5
View File
@@ -1,6 +1,7 @@
-- Overworld survey zoom: integer pixels-per-world-pixel scales stepped -- Overworld survey zoom: integer pixels-per-world-pixel scales stepped
-- by the mouse wheel. Stored as an offset from the window fit scale S -- by the mouse wheel, Options ZOOM row, or hotkey `4`. Stored as an
-- so a resize keeps the relative zoom. Session-only; never saved. -- offset from the window fit scale S so a resize keeps the relative
-- zoom. Persisted as save.options.zoom (default 0 = FIT).
-- Spec: docs/new-features.md (survey zoom) -- Spec: docs/new-features.md (survey zoom)
local Zoom = {} local Zoom = {}
@@ -12,16 +13,51 @@ function Zoom.scale(S)
return math.max(1, math.min(2 * S, S + Zoom.offset)) return math.max(1, math.min(2 * S, S + Zoom.offset))
end end
-- legal offset range for a given fit scale
function Zoom.offsetRange(S)
S = math.max(1, math.floor(tonumber(S) or 1))
return 1 - S, S
end
function Zoom.clampOffset(offset, S)
local lo, hi = Zoom.offsetRange(S)
offset = math.floor(tonumber(offset) or 0)
if offset < lo then return lo end
if offset > hi then return hi end
return offset
end
function Zoom.step(delta, S) function Zoom.step(delta, S)
Zoom.offset = Zoom.offset + delta Zoom.offset = Zoom.clampOffset(Zoom.offset + delta, S)
if S + Zoom.offset < 1 then Zoom.offset = 1 - S end return Zoom.offset
if S + Zoom.offset > 2 * S then Zoom.offset = S end end
-- Advance one zoom level toward max close-up, then wrap to full survey.
-- Returns the new offset.
function Zoom.cycle(S)
local lo, hi = Zoom.offsetRange(S)
local next = Zoom.offset + 1
if next > hi then next = lo end
Zoom.offset = next
return Zoom.offset
end end
function Zoom.reset() function Zoom.reset()
Zoom.offset = 0 Zoom.offset = 0
end end
function Zoom.applyOptions(opts)
Zoom.offset = math.floor(tonumber(opts and opts.zoom) or 0)
end
-- FIT / OUT1 / OUT2 / … / IN1 / IN2 / …
function Zoom.offsetLabel(offset)
offset = math.floor(tonumber(offset) or 0)
if offset == 0 then return "FIT" end
if offset < 0 then return "OUT" .. tostring(-offset) end
return "IN" .. tostring(offset)
end
-- world pixels covered by a w x h letterbox viewport at fit scale S -- world pixels covered by a w x h letterbox viewport at fit scale S
-- (legacy GB-framed size; prefer fillViewSize for the live world pass) -- (legacy GB-framed size; prefer fillViewSize for the live world pass)
function Zoom.viewSize(S, w, h) function Zoom.viewSize(S, w, h)
+134 -25
View File
@@ -438,10 +438,18 @@ function Commands.play_sound(ctx, soundId)
require("src.core.Sound").play(ctx.game.data, soundId) require("src.core.Sound").play(ctx.game.data, soundId)
end end
-- play_once <songId>: one-shot jingle (Music_PkmnHealed, etc.); the map -- play_once <songId>: one-shot jingle (Music_PkmnHealed, etc.); blocks
-- theme resumes when it ends (Music.playOnce / pendingRestore) -- 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) function Commands.play_once(ctx, songId)
require("src.core.Music").playOnce(ctx.game.data, 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 end
-- play_cry <species>: PlayCry (home/audio.asm). The text_asm bodies that -- play_cry <species>: PlayCry (home/audio.asm). The text_asm bodies that
@@ -476,11 +484,51 @@ function Commands.heal_party(ctx)
end end
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 <species> <level>: _GivePokemon (engine/events/
-- give_pokemon.asm) -- party first, then the box. ctx.lastCheck gets -- 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 -- the asm's carry: true when the mon was given, false when both the
-- party and every box are full (that .boxFull path leaves the giver's -- party and every box are full (that .boxFull path leaves the giver's
-- script able to offer again later, e.g. the Celadon Eevee ball). -- script able to offer again later, e.g. the Celadon Eevee ball).
-- Party adds run AskName (AddPartyMon) when a script runner is present;
-- mods that pre-set gift.nickname skip the prompt.
function Commands.give_pokemon(ctx, species, level) function Commands.give_pokemon(ctx, species, level)
-- Native mods can transform a gift before the Pokémon object is created. -- Native mods can transform a gift before the Pokémon object is created.
-- This is intentionally an event rather than a special-case starter hook: -- This is intentionally an event rather than a special-case starter hook:
@@ -497,7 +545,8 @@ function Commands.give_pokemon(ctx, species, level)
ctx.game.stringBuffer = ctx.game.data.pokemon[species].name or species ctx.game.stringBuffer = ctx.game.data.pokemon[species].name or species
ctx.pendingPokemonName = species ctx.pendingPokemonName = species
require("src.battle.BattleState").stampOT(ctx.save, mon) require("src.battle.BattleState").stampOT(ctx.save, mon)
if not Party.add(ctx.save.party, mon) then local addedToParty = Party.add(ctx.save.party, mon)
if not addedToParty then
if not require("src.pokemon.Boxes").deposit(ctx.save, mon) then if not require("src.pokemon.Boxes").deposit(ctx.save, mon) then
ctx.lastCheck = false ctx.lastCheck = false
return return
@@ -509,18 +558,40 @@ function Commands.give_pokemon(ctx, species, level)
dex.owned[species] = true dex.owned[species] = true
end end
ctx.lastCheck = true ctx.lastCheck = true
-- AddPartyMon AskName: party only; skip box deposits, mod-set nicknames,
-- 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 end
function Commands.give_money(ctx, amount) function Commands.give_money(ctx, amount)
ctx.save.money = math.max(0, ctx.save.money + amount) ctx.save.money = math.max(0, ctx.save.money + amount)
end 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 -- Hall of Fame: snapshot the winning party (SaveHallOfFameTeams inside
-- AnimateHallOfFame), run the induction showcase and the end credits, -- AnimateHallOfFame), run the induction showcase and the end credits,
-- autosave while THE END is up, then soft-reset to the title -- the whole -- autosave while THE END is up, then soft-reset to the title -- the whole
-- predef HallOfFamePC + tail of HallOfFameResetEventsAndSaveScript -- predef HallOfFamePC + tail of HallOfFameResetEventsAndSaveScript
-- (engine/movie/hall_of_fame.asm, engine/movie/credits.asm, -- (engine/movie/hall_of_fame.asm, engine/movie/credits.asm,
-- scripts/HallOfFame.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) function Commands.record_hall_of_fame(ctx)
ctx.save.hallOfFame = ctx.save.hallOfFame or {} ctx.save.hallOfFame = ctx.save.hallOfFame or {}
local entry = {} local entry = {}
@@ -536,17 +607,25 @@ function Commands.record_hall_of_fame(ctx)
Screens.push(game, "Credits", function() Screens.push(game, "Credits", function()
runner:resume() runner:resume()
end, function() end, function()
-- THE END is on screen: HallOfFameResetEventsAndSaveScript sets -- THE END is on screen: heal, place the player at post-game home,
-- wLastBlackoutMap := PALLET_TOWN and runs SaveGameData, so the -- retarget LAST_MAP, then SaveGameData. (E4 room-script/event
-- save keeps the player standing in the HALL_OF_FAME room. (The -- resets that precede the save in pokered are the Indigo lobby's
-- E4 room-script/event resets that precede the save in pokered are -- re-entry reset here, data/scripts/story6.lua.)
-- the Indigo lobby's re-entry reset here, data/scripts/story6.lua.) local SaveData = require("src.core.SaveData")
-- pokered writes PALLET_TOWN here as a literal, not as "the spawn" -- local Pokemon = require("src.pokemon.Pokemon")
-- 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 {} local boot = game.data.field and game.data.field.boot or {}
ctx.save.lastHeal = require("src.core.SaveData").defaultHeal(boot) 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
if game.writeSave then game:writeSave() end 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)
end) end)
runner:yield() runner:yield()
@@ -861,15 +940,21 @@ function Commands.push_screen(ctx, screenId, args)
runner:yield() runner:yield()
end end
-- fade "out"|"in" [frames]: screen fade without warping (the Transition -- fade "out"|"in" [frames|"white"|"black"] [frames|"white"|"black"]:
-- ramp startWarpTo uses, split in two). "out" pushes a black overlay -- screen fade without warping (the Transition ramp startWarpTo uses,
-- that stays up; the held state keeps ticking the runner's frame-waits -- split in two). "out" pushes an overlay that stays up; the held state
-- so a script can wait/replace_block under it. "in" ramps it away. -- 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).
local FadeOverlay = {} local FadeOverlay = {}
FadeOverlay.__index = FadeOverlay FadeOverlay.__index = FadeOverlay
function FadeOverlay.new(game, ow) function FadeOverlay.new(game, ow, color)
return setmetatable({ game = game, ow = ow, alpha = 0 }, FadeOverlay) return setmetatable({
game = game, ow = ow, alpha = 0, color = color or "black",
}, FadeOverlay)
end end
function FadeOverlay:update() function FadeOverlay:update()
@@ -895,28 +980,51 @@ function FadeOverlay:update()
end end
function FadeOverlay:draw() function FadeOverlay:draw()
love.graphics.setColor(0, 0, 0, self.alpha) 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.rectangle("fill", 0, 0, 160, 144) love.graphics.rectangle("fill", 0, 0, 160, 144)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
end end
function Commands.fade(ctx, dir, frames) 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)
local ow = ctx.overworld local ow = ctx.overworld
if not ow then return end if not ow then return end
local runner = ctx.runner local runner = ctx.runner
frames = frames or 12 -- Transition's ramp length local frames, color = parseFadeArgs(framesOrColor, colorOrFrames)
local overlay = ow.fadeOverlay local overlay = ow.fadeOverlay
if dir == "out" then if dir == "out" then
if not overlay then if not overlay then
overlay = FadeOverlay.new(ctx.game, ow) overlay = FadeOverlay.new(ctx.game, ow, color)
ow.fadeOverlay = overlay ow.fadeOverlay = overlay
ctx.game.stack:push(overlay) ctx.game.stack:push(overlay)
else
overlay.color = color
end end
overlay.ramp = { from = overlay.alpha, to = 1, frames = frames, t = 0, overlay.ramp = { from = overlay.alpha, to = 1, frames = frames, t = 0,
onDone = function() runner:resume() end } onDone = function() runner:resume() end }
runner:yield() runner:yield()
elseif dir == "in" then elseif dir == "in" then
if not overlay then return end if not overlay then return end
overlay.color = color or overlay.color
overlay.ramp = { from = overlay.alpha, to = 0, frames = frames, t = 0, overlay.ramp = { from = overlay.alpha, to = 0, frames = frames, t = 0,
onDone = function() runner:resume() end } onDone = function() runner:resume() end }
runner:yield() runner:yield()
@@ -1004,9 +1112,10 @@ for _, verb in ipairs({ "show_text", "ask", "choice", "start_battle", "warp",
end end
for _, verb in ipairs({ "show_text", "ask", "choice", "start_battle", "warp", for _, verb in ipairs({ "show_text", "ask", "choice", "start_battle", "warp",
"open_mart", "trade", "push_screen", "record_hall_of_fame", "open_mart", "trade", "push_screen", "record_hall_of_fame",
"old_man_demo", "static_battle", "rival_battle", "give_item", "wait", "old_man_demo", "static_battle", "rival_battle", "give_item",
"give_pokemon", "wait",
"wait_flag", "move_player", "move_npc", "move_npc_to", "walk_npc", "wait_flag", "move_player", "move_npc", "move_npc_to", "walk_npc",
"emote", "fade", "pan_camera" }) do "emote", "fade", "pan_camera", "play_once" }) do
local meta = Commands.meta[verb] or {} local meta = Commands.meta[verb] or {}
Commands.meta[verb] = meta Commands.meta[verb] = meta
meta.blocking = true meta.blocking = true
+3 -1
View File
@@ -216,7 +216,9 @@ local function useOn(game, battle, id, target, list, moveIndex)
consume(game, id) consume(game, id)
require("src.core.Sound").play(game.data, "Teleport_Exit1") require("src.core.Sound").play(game.data, "Teleport_Exit1")
ow.player.surfing = false ow.player.surfing = false
ow:warpToHealPoint() -- EnterMapAnim on arrival (BIT_ESCAPE_WARP / special warp path);
-- blackouts omit arrive="teleport" (HandleBlackOut has no LeaveMapAnim)
ow:warpToHealPoint(nil, { arrive = "teleport" })
else else
showMessages(game, { "OAK: " .. game.save.player.name showMessages(game, { "OAK: " .. game.save.player.name
.. "!\nThis isn't the\ntime to use that!" }) .. "!\nThis isn't the\ntime to use that!" })
+22 -4
View File
@@ -11,7 +11,9 @@ local Input = require("src.core.Input")
local BindingsMenu = setmetatable({}, { __index = ListMenu }) local BindingsMenu = setmetatable({}, { __index = ListMenu })
BindingsMenu.__index = BindingsMenu BindingsMenu.__index = BindingsMenu
-- Input.lua's map, primary key first where several keys share a button -- Input.lua's map, primary key first where several keys share a button.
-- `pad` is the default SDL gamecontroller button (see Input.lua); shown
-- on the SELECT row so controller Back/View is discoverable (#73).
local BUTTONS = { local BUTTONS = {
{ id = "up", label = "UP", key = "up" }, { id = "up", label = "UP", key = "up" },
{ id = "down", label = "DOWN", key = "down" }, { id = "down", label = "DOWN", key = "down" },
@@ -20,7 +22,7 @@ local BUTTONS = {
{ id = "a", label = "A", key = "z" }, { id = "a", label = "A", key = "z" },
{ id = "b", label = "B", key = "x" }, { id = "b", label = "B", key = "x" },
{ id = "start", label = "START", key = "escape" }, { id = "start", label = "START", key = "escape" },
{ id = "select", label = "SELECT", key = "rshift" }, { id = "select", label = "SELECT", key = "tab", pad = "back" },
} }
-- a binding is a plain key string or { key, pad }; absent = the fixed -- a binding is a plain key string or { key, pad }; absent = the fixed
@@ -32,13 +34,29 @@ local function boundKey(overlay, def)
return def.key return def.key
end end
local function boundPad(overlay, def)
local b = overlay and overlay[def.id]
if type(b) == "table" and b.pad then return b.pad end
return def.pad
end
-- Key column for every row. SELECT also appends "/PAD" (default BACK)
-- so controller Select/View is visible without opening a second legend.
local function boundRight(overlay, def)
local key = boundKey(overlay, def)
if def.id ~= "select" then return key:upper() end
local pad = boundPad(overlay, def)
if pad then return (key .. "/" .. pad):upper() end
return key:upper()
end
function BindingsMenu.new(game) function BindingsMenu.new(game)
local overlay = game.save and game.save.options local overlay = game.save and game.save.options
and game.save.options.bindings and game.save.options.bindings
local items = {} local items = {}
for i, def in ipairs(BUTTONS) do for i, def in ipairs(BUTTONS) do
items[i] = { label = def.label, items[i] = { label = def.label,
right = boundKey(overlay, def):upper(), button = def } right = boundRight(overlay, def), button = def }
end end
local self = setmetatable(ListMenu.new(game, "CONTROLS", items, {}), local self = setmetatable(ListMenu.new(game, "CONTROLS", items, {}),
BindingsMenu) BindingsMenu)
@@ -78,7 +96,7 @@ function BindingsMenu:storeBinding(slot, value)
end end
b[slot] = value b[slot] = value
opts.bindings[item.button.id] = b opts.bindings[item.button.id] = b
item.right = boundKey(opts.bindings, item.button):upper() item.right = boundRight(opts.bindings, item.button)
Input:applyBindings(opts.bindings) Input:applyBindings(opts.bindings)
if game.writeOptions then game:writeOptions() end if game.writeOptions then game:writeOptions() end
end end
+2 -1
View File
@@ -217,7 +217,8 @@ function Credits:update(dt)
self.timer = FADE_FRAMES self.timer = FADE_FRAMES
elseif self.phase == "end_fade" then elseif self.phase == "end_fade" then
-- Credits returns to HallOfFameResetEventsAndSaveScript here: the -- Credits returns to HallOfFameResetEventsAndSaveScript here: the
-- save happens now, then 5 x 120 DelayFrames before the button wait -- save (plus post-game home relocate, #103) happens now, then
-- 5 x 120 DelayFrames before the button wait
if self.onTheEnd then self.onTheEnd() end if self.onTheEnd then self.onTheEnd() end
self.phase = "end_hold" self.phase = "end_hold"
self.timer = 600 self.timer = 600
+23 -4
View File
@@ -1,5 +1,11 @@
-- Pokédex entry page: front sprite, kind, height/weight and the real -- Pokédex entry page: front sprite, kind, height/weight and the real
-- dex description (data/pokemon/dex_entries.asm + dex_text.asm). -- 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") local Font = require("src.render.Font")
@@ -16,8 +22,17 @@ function DexEntryMenu:sgbPalettes(game)
P.zone(P.monPal(game.data, self.def and self.def.id), 1, 1, 8, 8) } P.zone(P.monPal(game.data, self.def and self.def.id), 1, 1, 8, 8) }
end end
function DexEntryMenu.new(game, species) local function resolveArgs(speciesOrOpts)
local self = setmetatable({ game = game }, DexEntryMenu) 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)
self.def = game.data.pokemon[species] self.def = game.data.pokemon[species]
local ok, img = pcall(love.graphics.newImage, self.def.spriteFront) local ok, img = pcall(love.graphics.newImage, self.def.spriteFront)
self.sprite = ok and img or nil self.sprite = ok and img or nil
@@ -42,12 +57,16 @@ function DexEntryMenu:draw()
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
Font.draw(def.name, 72, 8) Font.draw(def.name, 72, 8)
local e = def.dexEntry or {} local e = def.dexEntry or {}
Font.draw((e.kind or "?") .. " POKéMON", 72, 20) -- 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)
-- same number width as the list (constants.dexDigits), so a dex past 999 -- same number width as the list (constants.dexDigits), so a dex past 999
-- prints the extra digit everywhere at once -- prints the extra digit everywhere at once
local digits = (self.game.data.constants or {}).dexDigits or 3 local digits = (self.game.data.constants or {}).dexDigits or 3
Font.draw(("No.%0" .. digits .. "d"):format(def.dex or 0), 72, 32) Font.draw(("No.%0" .. digits .. "d"):format(def.dex or 0), 72, 32)
local owned = self.game.save.pokedex and self.game.save.pokedex.owned[def.id] local owned = self.forceOwned
or (self.game.save.pokedex and self.game.save.pokedex.owned[def.id])
-- height/weight print only once owned, like the description -- height/weight print only once owned, like the description
-- (pokedex.asm: "if the pokemon has not been owned, don't print the -- (pokedex.asm: "if the pokemon has not been owned, don't print the
-- height, weight, or description") -- height, weight, or description")
+204 -79
View File
@@ -1,14 +1,16 @@
-- Hall of Fame induction (engine/movie/hall_of_fame.asm): each party -- Hall of Fame induction (engine/movie/hall_of_fame.asm): each party
-- member's front sprite scrolls onto the screen (HoFShowMonOrPlayer's -- member's front sprite scrolls onto the right side of the screen
-- .ScrollPic), then its name/level shows and its cry plays -- (HoFShowMonOrPlayer's .ScrollPic), then HoFDisplayMonInfo draws the
-- (HoFDisplayAndRecordMonInfo). After the last mon, HoFDisplayPlayerStats -- left-side LEVEL/TYPE box, plays the cry, holds, and pops the bottom
-- shows the trainer name, play time, money and Prof. Oak's dex rating. -- "HALL OF FAME" text box before fading to the next mon. After the
-- Plays Music_HallOfFame when the audio data has it. Calls onDone() after -- party, the player pic scrolls in and HoFDisplayPlayerStats shows the
-- popping itself. -- name/time/money boxes plus the dex rating. Plays Music_HallOfFame
-- when the audio data has it. Calls onDone() after popping itself.
local Font = require("src.render.Font") local Font = require("src.render.Font")
local Music = require("src.core.Music") local Music = require("src.core.Music")
local Sound = require("src.core.Sound") local Sound = require("src.core.Sound")
local TypeChart = require("src.battle.TypeChart")
local HallOfFame = {} local HallOfFame = {}
HallOfFame.__index = HallOfFame HallOfFame.__index = HallOfFame
@@ -17,6 +19,10 @@ HallOfFame.isOpaque = true
-- SGB: SetPal_PokemonWholeScreen for the mon on display -- SGB: SetPal_PokemonWholeScreen for the mon on display
function HallOfFame:sgbPalettes(game) function HallOfFame:sgbPalettes(game)
local P = require("src.render.PaletteFX") 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] local mon = game.save.party[self.index or 0]
if mon then if mon then
local c = P.monPal(game.data, mon.species) local c = P.monPal(game.data, mon.species)
@@ -26,17 +32,20 @@ function HallOfFame:sgbPalettes(game)
return P.wholeNamed(game.data, "MEWMON") return P.wholeNamed(game.data, "MEWMON")
end end
local MON_FRAMES = 150 -- ~2.5s per inductee (A advances early)
-- HoFShowMonOrPlayer's .ScrollPic: hSCX is nudged by e = 4px per -- HoFShowMonOrPlayer's .ScrollPic: hSCX is nudged by e = 4px per
-- DelayFrame (doubled on SGB) until it settles. The back pic (an -- DelayFrame (doubled on SGB) until it settles. The front pic rests at
-- enlarged, blurred 2x scale of the back sprite) sweeps right-to-left -- hlcoord 12,5 (engine/movie/hall_of_fame.asm HoFLoadMonPlayerPicTileIDs).
-- 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 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) local function tryImage(path)
if not path then return nil end if not path then return nil end
@@ -57,7 +66,7 @@ local function drawTextBlock(text, x, y, maxY)
for line in (text:gsub("\v", "\n"):gsub("\f", "\n") .. "\n"):gmatch("(.-)\n") do for line in (text:gsub("\v", "\n"):gsub("\f", "\n") .. "\n"):gmatch("(.-)\n") do
if maxY and y > maxY then break end if maxY and y > maxY then break end
Font.draw(line, x, y) Font.draw(line, x, y)
y = y + 10 y = y + 8
end end
return y return y
end end
@@ -70,6 +79,10 @@ function HallOfFame.new(game, onDone)
self.timer = 0 self.timer = 0
self.phase = "mons" self.phase = "mons"
self.sprites = {} -- species -> image or false 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 return self
end end
@@ -84,16 +97,21 @@ end
function HallOfFame:nextMon() function HallOfFame:nextMon()
self.index = self.index + 1 self.index = self.index + 1
local mon = self.game.save.party[self.index] local mon = self.game.save.party[self.index]
self.showHofBanner = false
self.fade = 0
if mon then if mon then
self.timer = MON_FRAMES self.phase = "mons"
self.timer = INFO_HOLD
Sound.playCry(self.game.data, mon.species) 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 sprite = self:spriteFor(mon.species)
local w = sprite and sprite:getWidth() or 0 local w = sprite and sprite:getWidth() or 56
self.scrollRestX = math.floor((160 - w) / 2)
self.scrollX = -w self.scrollX = -w
else else
self.phase = "congrats" -- HoFShowMonOrPlayer with wHoFMonOrPlayer = player
self.phase = "player"
self.timer = 0
local w = self.playerPic and self.playerPic:getWidth() or 56
self.scrollX = -w
end end
end end
@@ -117,77 +135,184 @@ function HallOfFame:dexSeenOwned()
return seen, owned return seen, owned
end end
function HallOfFame:update(dt) function HallOfFame:advanceMonPhase()
local input = self.game.input if self.phase == "mons" and not self.showHofBanner then
if self.phase == "mons" then -- 80-frame info hold done: TextBoxBorder at (2,13) + "HALL OF FAME"
if self.scrollX and self.scrollX < self.scrollRestX then self.showHofBanner = true
self.scrollX = math.min(self.scrollRestX, self.scrollX + SCROLL_SPEED) self.timer = HOF_HOLD
end elseif self.phase == "mons" then
self.timer = self.timer - 1 self.phase = "fade"
if input:wasPressed("a") or self.timer <= 0 then self.timer = FADE_FRAMES
self:nextMon() self.fade = 0
end elseif self.phase == "fade" then
elseif input:wasPressed("a") then self:nextMon()
Sound.play(self.game.data, "Press_AB")
self.game.stack:pop()
if self.onDone then self.onDone() end
end end
end end
function HallOfFame:draw() function HallOfFame:update(dt)
love.graphics.setColor(1, 1, 1, 1) local input = self.game.input
love.graphics.rectangle("fill", 0, 0, 160, 144) local skip = input:wasPressed("a")
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
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
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
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
end
self.timer = self.timer - 1
if skip or self.timer <= 0 then
self:advanceMonPhase()
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 seen, owned = self:dexSeenOwned()
local seenOwned = text._DexSeenOwnedText local seenOwned = text._DexSeenOwnedText
or "POKéDEX Seen:{NUM:wDexRatingNumMonsSeen, 1, 3}\n Owned:{NUM:wDexRatingNumMonsOwned, 1, 3}" or "POKéDEX Seen:{NUM:wDexRatingNumMonsSeen, 1, 3}\n Owned:{NUM:wDexRatingNumMonsOwned, 1, 3}"
seenOwned = seenOwned seenOwned = seenOwned
:gsub("{NUM:wDexRatingNumMonsSeen[^}]*}", tostring(seen)) :gsub("{NUM:wDexRatingNumMonsSeen[^}]*}", tostring(seen))
:gsub("{NUM:wDexRatingNumMonsOwned[^}]*}", tostring(owned)) :gsub("{NUM:wDexRatingNumMonsOwned[^}]*}", tostring(owned))
y = drawTextBlock(seenOwned, 8, y) + 6 drawTextBlock(seenOwned, 1 * 8, 14 * 8, 17 * 8)
else
local _, owned = self:dexSeenOwned()
local ratingHeader = (text._DexRatingText or "POKéDEX Rating{COLON}"):gsub("{COLON}", ":") local ratingHeader = (text._DexRatingText or "POKéDEX Rating{COLON}"):gsub("{COLON}", ":")
Font.draw(ratingHeader, 8, y) Font.draw(ratingHeader, 1 * 8, 14 * 8)
y = y + 12
local rating = text[dexRatingKey(owned)] or "Keep it up!" local rating = text[dexRatingKey(owned)] or "Keep it up!"
drawTextBlock(rating, 8, y, 136) drawTextBlock(rating, 1 * 8, 15 * 8, 17 * 8)
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
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
end
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
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
end end
+43 -4
View File
@@ -19,7 +19,16 @@ function Menu.new(game, items, opts)
self.tx = opts.tx or 10 self.tx = opts.tx or 10
self.ty = opts.ty or 0 self.ty = opts.ty or 0
self.tw = opts.tw or 10 self.tw = opts.tw or 10
self.th = opts.th or (#items * 2 + 2) 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.cancelable = opts.cancelable ~= false self.cancelable = opts.cancelable ~= false
-- Whether START closes the menu. In pokered a menu responds only to the -- 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 -- keys in its wMenuWatchedKeys mask; the common PAD_A | PAD_B (and the
@@ -31,9 +40,25 @@ function Menu.new(game, items, opts)
-- BIT_NO_MENU_BUTTON_SOUND (wMiscFlags): the PC session runs its -- BIT_NO_MENU_BUTTON_SOUND (wMiscFlags): the PC session runs its
-- menus silent (home/window.asm HandleMenuInput_) -- menus silent (home/window.asm HandleMenuInput_)
self.noSound = opts.noSound or false self.noSound = opts.noSound or false
self:clampScroll()
return self return self
end 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) function Menu:update(dt)
local input = self.game.input local input = self.game.input
if input:wasPressed("up") then if input:wasPressed("up") then
@@ -61,15 +86,29 @@ function Menu:update(dt)
self.game.stack:pop() self.game.stack:pop()
if self.onCancel then self.onCancel() end if self.onCancel then self.onCancel() end
end end
self:clampScroll()
end end
function Menu:draw() function Menu:draw()
Font.drawBox(self.tx, self.ty, self.tw, self.th) Font.drawBox(self.tx, self.ty, self.tw, self.th)
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
for i, item in ipairs(self.items) do local visible = (self.maxVisible and math.min(self.maxVisible, #self.items))
Font.draw(item.label, (self.tx + 2) * 8, (self.ty + i * 2 - 1) * 8) 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)
end end
Font.drawCode(Theme.cursor, (self.tx + 1) * 8, (self.ty + self.index * 2 - 1) * 8)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
end end
+54 -2
View File
@@ -3,7 +3,8 @@
-- (cycles the merged rulesets registry; gen1_faithful keeps the original -- (cycles the merged rulesets registry; gen1_faithful keeps the original
-- quirks), plus the port's audio rows and display rows: music/SFX -- quirks), plus the port's audio rows and display rows: music/SFX
-- volume (0-7), music low-pass filter (OFF/1X/2X/3X), COLORS / TILT / -- volume (0-7), music low-pass filter (OFF/1X/2X/3X), COLORS / TILT /
-- GBC FX / VIDEO MODE, and the MODS row that opens the mod manager. -- GBC FX / ZOOM / VOID FILL / VIDEO MODE, and the MODS row that opens
-- the mod manager.
-- Rows are descriptors fed through the ui.options.rows hook, so mods can -- Rows are descriptors fed through the ui.options.rows hook, so mods can
-- add their own; CANCEL is appended after the hook and stays fixed on the -- add their own; CANCEL is appended after the hook and stays fixed on the
-- bottom line like pokered's. -- bottom line like pokered's.
@@ -11,17 +12,28 @@
local PaletteFX = require("src.render.PaletteFX") local PaletteFX = require("src.render.PaletteFX")
local Tilt = require("src.render.Tilt") local Tilt = require("src.render.Tilt")
local GBCFX = require("src.render.GBCFX") local GBCFX = require("src.render.GBCFX")
local Zoom = require("src.render.Zoom")
local TileRenderer = require("src.render.TileRenderer")
local GameSpeed = require("src.core.GameSpeed") local GameSpeed = require("src.core.GameSpeed")
local VideoMode = require("src.core.VideoMode") local VideoMode = require("src.core.VideoMode")
local FrameCap = require("src.core.FrameCap") local FrameCap = require("src.core.FrameCap")
local Logger = require("src.core.Logger") local Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime") local Runtime = require("src.mods.Runtime")
local OptionRows = require("src.ui.OptionRows") local OptionRows = require("src.ui.OptionRows")
local Renderer = require("src.render.Renderer")
local OptionsMenu = {} local OptionsMenu = {}
OptionsMenu.__index = OptionsMenu OptionsMenu.__index = OptionsMenu
OptionsMenu.isOpaque = true 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 -- TextSpeedOptionData frame delays with the original labels
local SPEEDS = { { 1, "FAST" }, { 3, "MEDIUM" }, { 5, "SLOW" } } local SPEEDS = { { 1, "FAST" }, { 3, "MEDIUM" }, { 5, "SLOW" } }
-- no-loader fallback for the ruleset row, same pair BattleState keeps -- no-loader fallback for the ruleset row, same pair BattleState keeps
@@ -102,7 +114,7 @@ local function sameRows(_, rows) return rows end
-- the vanilla rows as descriptors; each step body is the old per-index -- the vanilla rows as descriptors; each step body is the old per-index
-- ladder's, so the save.options mutations are unchanged -- ladder's, so the save.options mutations are unchanged
local function buildRows(game) local function buildRows(game)
return { local rows = {
{ id = "textSpeed", label = "TEXT SPEED", { id = "textSpeed", label = "TEXT SPEED",
value = function(g) return SPEEDS[speedIndex(g)][2] end, value = function(g) return SPEEDS[speedIndex(g)][2] end,
step = function(g) step = function(g)
@@ -193,6 +205,37 @@ local function buildRows(game)
GBCFX.setLevel(o.gbcfx) GBCFX.setLevel(o.gbcfx)
return true return true
end }, end },
{ id = "zoom", label = "ZOOM",
value = function(g)
return Zoom.offsetLabel(g.save.options.zoom or 0)
end,
step = function(g, dir)
local o = g.save.options
local S = Renderer:fitScale()
local lo, hi = Zoom.offsetRange(S)
local off = (o.zoom or 0) + dir
if off > hi then off = lo
elseif off < lo then off = hi end
o.zoom = off
Zoom.offset = off
return true
end },
{ id = "voidFill", label = "VOID FILL",
value = function(g)
return TileRenderer.voidFillLabel(g.save.options.voidFill)
end,
step = function(g, dir)
local o = g.save.options
local modes = TileRenderer.VOID_FILLS
local cur = o.voidFill or "trees"
local i = 1
for idx, m in ipairs(modes) do
if m == cur then i = idx; break end
end
o.voidFill = modes[wrapIndex(i - 1 + dir, #modes) + 1]
TileRenderer.setVoidFill(o.voidFill)
return true
end },
{ id = "videoMode", label = "VIDEO MODE", { id = "videoMode", label = "VIDEO MODE",
value = function(g) value = function(g)
return VideoMode.modeLabel(g.save.options.videoMode) return VideoMode.modeLabel(g.save.options.videoMode)
@@ -244,6 +287,15 @@ local function buildRows(game)
require("src.ui.Screens").push(g, "BindingsMenu") require("src.ui.Screens").push(g, "BindingsMenu")
end }, end },
} }
-- issue #136: hide GBC FX on Android/iOS -- the present shader soft-bricks
if not GBCFX.isSupported() then
local filtered = {}
for _, row in ipairs(rows) do
if row.id ~= "gbcfx" then filtered[#filtered + 1] = row end
end
rows = filtered
end
return rows
end end
function OptionsMenu.new(game) function OptionsMenu.new(game)
+36 -8
View File
@@ -11,6 +11,8 @@ local Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime") local Runtime = require("src.mods.Runtime")
local Screens = require("src.ui.Screens") local Screens = require("src.ui.Screens")
local Theme = require("src.ui.Theme") local Theme = require("src.ui.Theme")
local FieldDefaults = require("src.world.FieldDefaults")
local Map = require("src.world.Map")
local PartyMenu = {} local PartyMenu = {}
PartyMenu.__index = PartyMenu PartyMenu.__index = PartyMenu
@@ -69,10 +71,26 @@ local function drawIcon(game, mon, x, y, selected, counter)
local icons = game.data.icons local icons = game.data.icons
if not icons then return end if not icons then return end
local def = game.data.pokemon[mon.species] local def = game.data.pokemon[mon.species]
-- byDex is the vanilla lookup, but the icons registry can bring the table -- Per-species override first: the icons registry folds into
-- into existence on its own, so it may be the only key present -- icons.bySpecies, and a pokemon record may carry its own `icon` field.
local name = def and def.dex and icons.byDex and icons.byDex[def.dex] -- Either is a built-in icon name (resolved through icons.icons) or a
local path = name and icons.icons[name] -- { 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
if not path then return end if not path then return end
if iconImages[path] == nil then if iconImages[path] == nil then
local ok, img = pcall(love.graphics.newImage, path) local ok, img = pcall(love.graphics.newImage, path)
@@ -278,7 +296,9 @@ function PartyMenu:update(dt)
if ow and heal then if ow and heal then
self.game.stack:push(Transition.whiteFlash(self.game, nil, function() self.game.stack:push(Transition.whiteFlash(self.game, nil, function()
require("src.core.Sound").play(self.game.data, "Teleport_Exit1") require("src.core.Sound").play(self.game.data, "Teleport_Exit1")
ow:warpToHealPoint() -- EnterMapAnim on arrival (HandleFlyWarpOrDungeonWarp sets
-- BIT_FLY_WARP); blackouts must not pass arrive="teleport"
ow:warpToHealPoint(nil, { arrive = "teleport" })
end)) end))
end end
return return
@@ -332,9 +352,17 @@ function PartyMenu:update(dt)
local items = { { label = "STATS", action = "stats" }, local items = { { label = "STATS", action = "stats" },
{ label = "SWITCH", action = "switch" } } { label = "SWITCH", action = "switch" } }
local ow = self.game.overworld local ow = self.game.overworld
if not self.battle and ow and mon.hp > 0 then -- 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
-- FLY/TELEPORT: CheckIfInOutsideMap (OVERWORLD + PLATEAU —
-- Route 23 / Indigo Plateau outdoor), not OVERWORLD alone (#83)
local outside = Map.isOutside(ow.map.def,
FieldDefaults.field(self.game.data, "outsideTilesets"))
for _, mv in ipairs(mon.moves) do for _, mv in ipairs(mon.moves) do
if mv.id == "FLY" and ow.map.def.tileset == "OVERWORLD" if mv.id == "FLY" and outside
and self.game.save.inventory.THUNDERBADGE then and self.game.save.inventory.THUNDERBADGE then
table.insert(items, { label = "FLY", action = "fly" }) table.insert(items, { label = "FLY", action = "fly" })
elseif mv.id == "FLASH" and ow.dark elseif mv.id == "FLASH" and ow.dark
@@ -353,7 +381,7 @@ function PartyMenu:update(dt)
table.insert(items, { label = "STRENGTH", action = "strength" }) table.insert(items, { label = "STRENGTH", action = "strength" })
elseif mv.id == "SOFTBOILED" then elseif mv.id == "SOFTBOILED" then
table.insert(items, { label = "SOFTBOILED", action = "softboiled" }) table.insert(items, { label = "SOFTBOILED", action = "softboiled" })
elseif mv.id == "TELEPORT" and ow.map.def.tileset == "OVERWORLD" then elseif mv.id == "TELEPORT" and outside then
-- TELEPORT works only OUTDOORS (start_sub_menus.asm -- TELEPORT works only OUTDOORS (start_sub_menus.asm
-- .teleport -> CheckIfInOutsideMap); dark maps don't -- .teleport -> CheckIfInOutsideMap); dark maps don't
-- block it -- block it
+2 -1
View File
@@ -1,6 +1,7 @@
-- The player's item-storage PC (engine/menus/players_pc.asm): -- The player's item-storage PC (engine/menus/players_pc.asm):
-- WITHDRAW ITEM / DEPOSIT ITEM / TOSS ITEM / LOG OFF over -- WITHDRAW ITEM / DEPOSIT ITEM / TOSS ITEM / LOG OFF over
-- game.save.pcItems ({ ITEM_ID = count }, created lazily). Withdraw and -- game.save.pcItems ({ ITEM_ID = count }; New Game seeds { POTION = 1 },
-- older saves may still be empty until first deposit). Withdraw and
-- deposit ask "How many?" via the quantity selector (key items always -- deposit ask "How many?" via the quantity selector (key items always
-- move one); toss discards after a YES/NO confirm. Follows the -- move one); toss discards after a YES/NO confirm. Follows the
-- BoxMenu/BagMenu list idioms. -- BoxMenu/BagMenu list idioms.
+11 -1
View File
@@ -7,6 +7,7 @@
local Font = require("src.render.Font") local Font = require("src.render.Font")
local Logger = require("src.core.Logger") local Logger = require("src.core.Logger")
local Menu = require("src.ui.Menu") local Menu = require("src.ui.Menu")
local Renderer = require("src.render.Renderer")
local Runtime = require("src.mods.Runtime") local Runtime = require("src.mods.Runtime")
local Screens = require("src.ui.Screens") local Screens = require("src.ui.Screens")
@@ -117,11 +118,20 @@ function StartMenu.new(game)
-- the start menu's mask is PAD_DOWN | PAD_UP | PAD_START | PAD_B | PAD_A -- 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 -- (engine/menus/draw_start_menu.asm), so START closes it back to the
-- overworld -- unlike most menus, whose masks omit PAD_START. -- 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, local menu = Menu.new(game, items,
{ tx = 9, ty = 0, tw = 11, th = #items * 2 + 2, startCloses = true }) { tx = 9, ty = 0, tw = 11, maxVisible = maxVisible, startCloses = true })
-- the cursor position survives closing the menu -- the cursor position survives closing the menu
-- (wBattleAndStartSavedMenuItem, home/start_menu.asm) -- (wBattleAndStartSavedMenuItem, home/start_menu.asm)
menu.index = math.min(game.save.startMenuIndex or 1, #items) menu.index = math.min(game.save.startMenuIndex or 1, #items)
menu:clampScroll()
local baseUpdate = menu.update local baseUpdate = menu.update
menu.update = function(self, dt) menu.update = function(self, dt)
baseUpdate(self, dt) baseUpdate(self, dt)
+33 -5
View File
@@ -13,13 +13,35 @@ TitleState.isOpaque = true
-- SGB title zones (PalPacket_Titlescreen): the logo rows get LOGO2, -- SGB title zones (PalPacket_Titlescreen): the logo rows get LOGO2,
-- the version-ribbon band LOGO1, the rest MEWMON. -- 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) function TitleState:sgbPalettes(game)
local P = require("src.render.PaletteFX") local P = require("src.render.PaletteFX")
local z = { local z = {
P.zone(P.pal(game.data, "LOGO2"), 0, 0, 19, 7), P.zone(P.pal(game.data, "LOGO2"), 0, 0, 19, 7),
P.zone(P.pal(game.data, "LOGO1"), 0, 8, 19, 9), P.zone(withPureWhite(P.pal(game.data, "LOGO1")), 0, 8, 19, 9),
P.zone(P.pal(game.data, "MEWMON"), 0, 10, 19, 17), 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 return z[3] and z or nil
end end
@@ -119,8 +141,11 @@ local ContinueInfo = {}
ContinueInfo.__index = ContinueInfo ContinueInfo.__index = ContinueInfo
function ContinueInfo.new(title, save) function ContinueInfo.new(title, save)
return setmetatable({ title = title, game = title.game, save = save }, -- box at (4,7), 16x10 tiles -- see ContinueInfo:draw / DisplayContinueGameInfo
ContinueInfo) return setmetatable({
title = title, game = title.game, save = save,
titleUiBox = { 4, 7, 19, 16 },
}, ContinueInfo)
end end
function ContinueInfo:update(dt) function ContinueInfo:update(dt)
@@ -184,8 +209,11 @@ function TitleState:openMenu()
love.event.quit() love.event.quit()
end end
end }) end })
game.stack:push(Menu.new(game, items, local th = #items * 2 + 2
{ tx = 0, ty = 0, tw = 13, 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)
end end
function TitleState:update(dt) function TitleState:update(dt)
+4 -1
View File
@@ -120,8 +120,11 @@ FieldDefaults.FIELD = {
doorBlock = { bx = 2, by = 2, block = 5 } }, doorBlock = { bx = 2, by = 2, block = 5 } },
}, },
-- IsSurfingAllowed refuses SURF on the B4F stairs square until both -- IsSurfingAllowed refuses SURF on the B4F stairs square until both
-- plug boulders are down (engine/overworld/field_move_messages.asm) -- 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).
seafoam = { seafoam = {
SEAFOAM_ISLANDS_B3F = { setsForcedWarp = true },
SEAFOAM_ISLANDS_B4F = { SEAFOAM_ISLANDS_B4F = {
surfBlocked = { { x = 7, y = 11, untilEvents = { surfBlocked = { { x = 7, y = 11, untilEvents = {
"EVENT_SEAFOAM4_BOULDER1_DOWN_HOLE", "EVENT_SEAFOAM4_BOULDER1_DOWN_HOLE",
+113 -32
View File
@@ -303,6 +303,9 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
else else
self.player = Player.new(Game.data, x, y, facing) self.player = Player.new(Game.data, x, y, facing)
end 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 } self.entities = { self.player }
for _, n in ipairs(self.npcs) do table.insert(self.entities, n) end for _, n in ipairs(self.npcs) do table.insert(self.entities, n) end
@@ -322,6 +325,12 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
-- 16/18 gate exits), and the scripted door-mat walkout that follows -- 16/18 gate exits), and the scripted door-mat walkout that follows
-- suppresses onStepComplete, so waiting for a plain step never mounts -- suppresses onStepComplete, so waiting for a plain step never mounts
self:checkForcedMovement() 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 -- 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 -- Transition is on top, so a stale camera would show the new map at
@@ -802,6 +811,15 @@ function OverworldState:update(dt)
if entry and (self.player.cellX ~= entry.x or self.player.cellY ~= entry.y) then if entry and (self.player.cellX ~= entry.x or self.player.cellY ~= entry.y) then
self.warpEntryCell = nil self.warpEntryCell = nil
end 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 if stepped and not scripted then
self:onStepComplete() self:onStepComplete()
end end
@@ -1050,7 +1068,15 @@ function OverworldState:crossConnection(dir, conn)
if not Map.defPassable(dest, ts, x, y, p.surfing) then if not Map.defPassable(dest, ts, x, y, p.surfing) then
return false return false
end end
self:setMap(conn.map, x, y, p.facing, { seamless = true }) -- 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
-- place the player one cell before the seam (their old world spot, -- place the player one cell before the seam (their old world spot,
-- which the neighbor strip renders identically) and start the step -- 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 -- into the new map RIGHT NOW so there is no one-frame stall at the
@@ -1064,9 +1090,13 @@ function OverworldState:crossConnection(dir, conn)
p.targetX, p.targetY = x, y p.targetX, p.targetY = x, y
p.moving = true p.moving = true
p.progress = 0 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 p.stepFramesCur = Game.save.onBike
and (FieldDefaults.world(Game.data, "bikeStepFrames") or 8) and (FieldDefaults.world(Game.data, "bikeStepFrames") or 8)
or (FieldDefaults.world(Game.data, "stepFrames") or 16) or (FieldDefaults.world(Game.data, "stepFrames") or 16)
require("src.core.FixedStep"):discardCatchup()
return true return true
end end
@@ -1122,7 +1152,9 @@ end
-- ------------------------------------------------------------------------- -- -------------------------------------------------------------------------
-- HM field moves are gated by badges like the original -- HM field moves are gated by badges like the original
-- (constants.hmBadges; distinct from constants.hmMoves, the forget gate) -- (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.
function OverworldState:partyKnows(moveId) function OverworldState:partyKnows(moveId)
local gate = (FieldDefaults.constant(Game.data, "hmBadges") or {})[moveId] local gate = (FieldDefaults.constant(Game.data, "hmBadges") or {})[moveId]
local badge = gate and gate.badge local badge = gate and gate.badge
@@ -1130,10 +1162,8 @@ function OverworldState:partyKnows(moveId)
return nil return nil
end end
for _, mon in ipairs(Game.save.party) do for _, mon in ipairs(Game.save.party) do
if mon.hp > 0 then for _, mv in ipairs(mon.moves) do
for _, mv in ipairs(mon.moves) do if mv.id == moveId then return mon end
if mv.id == moveId then return mon end
end
end end
end end
return nil return nil
@@ -1887,7 +1917,7 @@ end
-- to SurfingAttemptFailed (_NoSurfingHereText) if it isn't water. This is -- to SurfingAttemptFailed (_NoSurfingHereText) if it isn't water. This is
-- a side-effect-free check that reports which text/flow the caller should -- a side-effect-free check that reports which text/flow the caller should
-- use; the actual mount happens in trySurf on "ok". Returns: -- use; the actual mount happens in trySurf on "ok". Returns:
-- "no_badge" -> SOULBADGE missing / no live SURF mon (_NewBadgeRequiredText) -- "no_badge" -> SOULBADGE missing / no SURF mon (_NewBadgeRequiredText)
-- "forced_bike" -> on the Cycling Road (_CyclingIsFunText) -- "forced_bike" -> on the Cycling Road (_CyclingIsFunText)
-- "current" -> Seafoam B4F stairs before the boulders (_CurrentTooFastText) -- "current" -> Seafoam B4F stairs before the boulders (_CurrentTooFastText)
-- "dismount" -> already surfing, facing dry land; caller steps forward -- "dismount" -> already surfing, facing dry land; caller steps forward
@@ -1930,7 +1960,7 @@ end
-- (and .loop back to the submenu) if it isn't cuttable. Side-effect-free -- (and .loop back to the submenu) if it isn't cuttable. Side-effect-free
-- check mirroring useSurfFieldMove; tryCut does the actual cut on "ok". -- check mirroring useSurfFieldMove; tryCut does the actual cut on "ok".
-- Returns: -- Returns:
-- "no_badge" -> CASCADEBADGE missing / no live CUT mon (_NewBadgeRequiredText) -- "no_badge" -> CASCADEBADGE missing / no CUT mon (_NewBadgeRequiredText)
-- "nothing" -> not facing a cuttable tree (_NothingToCutText) -- "nothing" -> not facing a cuttable tree (_NothingToCutText)
-- "ok" -> facing a cuttable tree; caller may call tryCut(fx, fy) -- "ok" -> facing a cuttable tree; caller may call tryCut(fx, fy)
function OverworldState:useCutFieldMove() function OverworldState:useCutFieldMove()
@@ -2351,6 +2381,8 @@ end
-- Badges/items awarded after specific battles (data/scripts/victories.lua). -- Badges/items awarded after specific battles (data/scripts/victories.lua).
-- `deactivate` retires unfought gym/dojo trainers the way the originals' -- `deactivate` retires unfought gym/dojo trainers the way the originals'
-- SetEvent / SetEventRange do after the leader victory. -- 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) function OverworldState:checkVictoryRewards(trainerClass, partyIndex)
local victories = require("data.scripts.victories") local victories = require("data.scripts.victories")
local reward = victories[trainerClass .. "#" .. tostring(partyIndex or 1)] local reward = victories[trainerClass .. "#" .. tostring(partyIndex or 1)]
@@ -2364,6 +2396,13 @@ function OverworldState:checkVictoryRewards(trainerClass, partyIndex)
Game.save.flags[flag] = true Game.save.flags[flag] = true
end end
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 = {} local lines = {}
if reward.badge then if reward.badge then
Game.save.inventory[reward.badge] = 1 Game.save.inventory[reward.badge] = 1
@@ -2392,6 +2431,27 @@ function OverworldState:runVictoryHook()
if hooks and hooks.onVictory then hooks.onVictory(Game, self) end if hooks and hooks.onVictory then hooks.onVictory(Game, self) end
end end
-- pokered player sprite is fixed at screen ($40, $3c). TrainerEngage reads
-- the NPC's 8-bit SPRITESTATEDATA1 X/Y pixels and CalcDifference; engage
-- distance is stored as range<<4 (pixels). There is no tile LOS check for
-- interposed NPCs / walls -- but unsigned 8-bit Y makes a sprite exactly 4
-- tiles north of the player sit at Y=$fc, so |$3c-$fc|=$c0 and a range-4
-- DOWN trainer does not engage that tile (Route 9 Bug Catcher / issue #76).
local PLAYER_SCREEN_X, PLAYER_SCREEN_Y = 0x40, 0x3c
local function u8(n) return n % 256 end
local function calcDiff(a, b)
a, b = u8(a), u8(b)
return a >= b and a - b or b - a
end
local function trainerSightPixelDist(npc, player, horizontal)
if horizontal then
return calcDiff(PLAYER_SCREEN_X,
u8(PLAYER_SCREEN_X + (npc.cellX - player.cellX) * 16))
end
return calcDiff(PLAYER_SCREEN_Y,
u8(PLAYER_SCREEN_Y + (npc.cellY - player.cellY) * 16))
end
-- STAY trainers with a facing spot the player crossing their line of -- STAY trainers with a facing spot the player crossing their line of
-- sight (range from the extracted trainer headers), walk up and battle. -- sight (range from the extracted trainer headers), walk up and battle.
function OverworldState:checkTrainerSight() function OverworldState:checkTrainerSight()
@@ -2409,22 +2469,23 @@ function OverworldState:checkTrainerSight()
local range = header and header.range or 0 local range = header and header.range or 0
local vec = DIRVEC[npc.facing] local vec = DIRVEC[npc.facing]
if range > 0 and vec then if range > 0 and vec then
local dist local dist, horizontal
if vec[1] ~= 0 and npc.cellY == p.cellY then if vec[1] ~= 0 and npc.cellY == p.cellY then
dist = (p.cellX - npc.cellX) * vec[1] dist = (p.cellX - npc.cellX) * vec[1]
horizontal = true
elseif vec[2] ~= 0 and npc.cellX == p.cellX then elseif vec[2] ~= 0 and npc.cellX == p.cellX then
dist = (p.cellY - npc.cellY) * vec[2] dist = (p.cellY - npc.cellY) * vec[2]
horizontal = false
end end
-- pokered's TrainerEngage / CheckSpriteCanSeePlayer compares screen -- Screen-pixel range (CheckSpriteCanSeePlayer), not cell count:
-- coordinates only (home/trainers.asm, engine/overworld/ -- same facing-line rule as before, but the $fc Y quirk excludes the
-- trainer_sight.asm) -- there is no line-of-sight obstruction check. -- 4-tiles-north tile that cell math would still count as in range.
-- An aligned trainer within range engages through interposed NPCs and if dist and dist >= 1 then
-- unwalkable tiles, and the scripted walk-up below (scriptMove) also local pixelDist = trainerSightPixelDist(npc, p, horizontal)
-- ignores collision, so the trainer simply walks/overlaps through if pixelDist > 0 and pixelDist <= range * 16 then
-- anything on the line -- exactly as OAM sprites overlap on hardware. self:startTrainerApproach(npc, dist)
if dist and dist >= 1 and dist <= range then return
self:startTrainerApproach(npc, dist) end
return
end end
end end
end end
@@ -2660,8 +2721,11 @@ function OverworldState:onStepComplete()
elseif entry then elseif entry then
-- still standing on the warp we arrived through; do not re-trigger it -- still standing on the warp we arrived through; do not re-trigger it
else 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) local w = Warp.onArrive(self.map, p.cellX, p.cellY)
if not w and self:dirHeld() then if not w and (self:dirHeld() or self.forcedWarp) then
w = Warp.onCollision(self.map, Game.data.field.warpCarpets, w = Warp.onCollision(self.map, Game.data.field.warpCarpets,
p.cellX, p.cellY, p.facing) p.cellX, p.cellY, p.facing)
end end
@@ -2742,9 +2806,11 @@ function OverworldState:runSpinnerMoves(moves, i)
local mv = moves[i] local mv = moves[i]
if not mv then if not mv then
self.player.spinning = false self.player.spinning = false
if not self:checkSpinner() and self.player.surfing then -- Scripted steps skip onStepComplete while they run; once the RLE
self:checkSeafoamCurrent() -- finishes, re-enter the normal landing pipeline so chained spinners,
end -- Seafoam currents, and CheckWarpsNoCollision (incl. BIT_FORCED_WARP)
-- see the tile we stopped on — same as pokered after simulated joypad.
self:onStepComplete()
return return
end end
self.player.spinning = true -- spin the sprite while sliding self.player.spinning = true -- spin the sprite while sliding
@@ -2917,6 +2983,9 @@ function OverworldState:checkSeafoamCurrent()
if sf.forcedExit and p.surfing and not allSet(sf.forcedExit.activeUntilEvents) then if sf.forcedExit and p.surfing and not allSet(sf.forcedExit.activeUntilEvents) then
for _, c in ipairs(sf.forcedExit.coords) do for _, c in ipairs(sf.forcedExit.coords) do
if p.cellX == c.x and p.cellY == c.y then 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") require("src.core.Sound").play(Game.data, "Collision")
self:scriptMove(p, "up", c.y == 17 and 2 or 1) self:scriptMove(p, "up", c.y == 17 and 2 or 1)
return true return true
@@ -2938,6 +3007,12 @@ function OverworldState:checkSeafoamCurrent()
end end
for _, c in ipairs(active) do for _, c in ipairs(active) do
if p.cellX == c.x and p.cellY == c.y then 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) self:runSpinnerMoves(c.moves, 1)
return true return true
end end
@@ -3146,16 +3221,20 @@ end
-- Warp to the last heal point (blackout, ESCAPE ROPE, DIG/TELEPORT). -- 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 -- 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. -- at its remembered town door rather than wherever the player left from.
function OverworldState:warpToHealPoint(onDone) --
-- 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)
local heal = self:healPoint() local heal = self:healPoint()
self.player.surfing = false self.player.surfing = false
-- HandleFlyWarpOrDungeonWarp + DisplayPlayerBlackedOutText both clear -- HandleFlyWarpOrDungeonWarp + DisplayPlayerBlackedOutText both clear
-- BIT_ALWAYS_ON_BIKE (home/overworld.asm / home/text_script.asm) -- BIT_ALWAYS_ON_BIKE (home/overworld.asm / home/text_script.asm)
Game.save.forcedBike = nil Game.save.forcedBike = nil
-- rematerializing plays the teleport-in poof (EnterMapAnim in if opts and opts.arrive == "teleport" then
-- engine/overworld/player_animations.asm: SFX_TELEPORT_ENTER_1, then self.arriveWarp = "teleport"
-- ENTER_2 after the spin-down); blackouts take this path too end
self.arriveWarp = "teleport"
self:startWarpTo(heal.map, heal.x, heal.y, "down", onDone) self:startWarpTo(heal.map, heal.x, heal.y, "down", onDone)
if heal.outdoor then if heal.outdoor then
self:rememberOutdoor(heal.outdoor.id, heal.outdoor.x, heal.outdoor.y) self:rememberOutdoor(heal.outdoor.id, heal.outdoor.x, heal.outdoor.y)
@@ -3193,9 +3272,9 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts)
-- one-step justWarped guard, which only skipped the very next frame's -- 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. -- check and so let a mon walked back onto the pad re-trigger it.
self.warpEntryCell = { x = x, y = y } self.warpEntryCell = { x = x, y = y }
-- Fly/Teleport/Dig/Escape-Rope/blackout landings poof the player -- Fly/Teleport/Dig/Escape-Rope landings poof the player back in
-- back in (player_animations.asm EnterMapAnim); ordinary door -- (player_animations.asm EnterMapAnim). Blackouts and ordinary
-- warps never take this branch -- door warps never take this branch.
if arriveWarp == "fly" then if arriveWarp == "fly" then
require("src.core.Sound").play(Game.data, "Fly") require("src.core.Sound").play(Game.data, "Fly")
elseif arriveWarp == "teleport" then elseif arriveWarp == "teleport" then
@@ -3435,7 +3514,9 @@ function OverworldState:billboard(fx, fy, vw, vh, colors, keyed, drawFn)
end end
function OverworldState:drawWorld() function OverworldState:drawWorld()
-- advance the water/flower tile animation (runs under dialogs too) -- advance the water/flower tile animation (runs under dialogs too).
-- TileRenderer.tick uses wall-clock 60Hz steps so display refresh rate
-- does not speed or slow the cycle (issue #4).
require("src.render.TileRenderer").tick() require("src.render.TileRenderer").tick()
-- let the renderer know whether a spinner puzzle is currently sliding -- let the renderer know whether a spinner puzzle is currently sliding
-- the player, so it can flicker the arrow tiles between the blur and -- the player, so it can flicker the arrow tiles between the blur and
+20 -4
View File
@@ -81,6 +81,14 @@ end
-- Advance one fixed step; returns true when a step just completed. -- Advance one fixed step; returns true when a step just completed.
function Player:update() function Player:update()
-- land-frame walk pose lasts only through the draw after completion;
-- the next update (idle or a chained step) clears it
self.stepLanded = false
-- Ledge-hop arc is cosmetic but must track the fixed 60Hz logic step,
-- not love.draw's display refresh (issue #4: >59fps ended early).
if self.hopFrames and self.hopFrames > 0 then
self.hopFrames = self.hopFrames - 1
end
if self.turnTimer > 0 then if self.turnTimer > 0 then
self.turnTimer = self.turnTimer - 1 self.turnTimer = self.turnTimer - 1
end end
@@ -109,6 +117,11 @@ function Player:update()
self.px, self.py = self.cellX * 16, self.cellY * 16 self.px, self.py = self.cellX * 16, self.cellY * 16
self.moving = false self.moving = false
self.stepFlip = not self.stepFlip self.stepFlip = not self.stepFlip
-- keep animClock's pose on this frame (issue #82): bike steps land
-- mid-cycle (animClock % 16 == 8), and walkPhase used to snap to
-- stand whenever moving cleared — a stand flash every tile on the
-- bike, and sometimes after dismount when the clock is desynced
self.stepLanded = true
return true return true
end end
return false return false
@@ -119,7 +132,7 @@ function Player:facingCell()
end end
function Player:walkPhase() function Player:walkPhase()
if not self.moving then return 0 end if not self.moving and not self.stepLanded then return 0 end
-- walk frame during the middle of each 16-frame animation cycle -- walk frame during the middle of each 16-frame animation cycle
local p = (self.animClock or self.progress) % 16 local p = (self.animClock or self.progress) % 16
return (p >= 4 and p < 12) and 1 or 0 return (p >= 4 and p < 12) and 1 or 0
@@ -129,10 +142,13 @@ local SPIN_ORDER = { "down", "left", "up", "right" }
function Player:draw(camX, camY) function Player:draw(camX, camY)
local py = self.py local py = self.py
-- ledge hops arc (set for 2 cells by the ledge handler); surfing bobs -- ledge hops arc (set for 2 cells by the ledge handler); surfing bobs.
-- hopFrames counts down in Player:update (fixed step), never here.
if self.hopFrames and self.hopFrames > 0 then if self.hopFrames and self.hopFrames > 0 then
self.hopFrames = self.hopFrames - 1 local total = self.hopTotal or 32
local t = 1 - self.hopFrames / (self.hopTotal or 32) -- update runs before draw, so remaining N means N steps already
-- consumed this hop → t matches the old draw-side post-decrement phase
local t = 1 - self.hopFrames / total
py = py - math.floor(10 * math.sin(t * math.pi) + 0.5) py = py - math.floor(10 * math.sin(t * math.pi) + 0.5)
-- the shadow stays on the ground under the jumper: one 8x8 tile -- the shadow stays on the ground under the jumper: one 8x8 tile
-- mirrored into a 2x2 block (normal/XFLIP/YFLIP/both) whose top-left -- mirrored into a 2x2 block (normal/XFLIP/YFLIP/both) whose top-left
+49
View File
@@ -0,0 +1,49 @@
-- Driver: hosts a real online match (against the deployed relay) and
-- reports Discord IPC connection/subscribe/join-code state, so the whole
-- "Ask to Join" wiring can be checked against the actual local Discord
-- client instead of guessing from code review alone.
return function(game)
local U = dofile("tests/drivers/util.lua")
local DiscordPresence = require("src.core.DiscordPresence")
local Pokemon = require("src.pokemon.Pokemon")
local function report(line)
U.log(line)
local f = io.open("/tmp/discord_test_status.txt", "a")
if f then f:write(line .. "\n"); f:close() end
end
game.save.party = { Pokemon.new(game.data, "PIKACHU", 25) }
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
U.wait(30) -- give the Discord IPC connection a moment to establish
local s = DiscordPresence._state
report(("discord: enabled=%s connected=%s subscribedJoin=%s"):format(
tostring(s.enabled), tostring(s.connected), tostring(s.subscribedJoin)))
local LinkState = require("src.link.LinkState")
local link = LinkState.new(game)
game.stack:push(link)
U.wait(3)
U.tap(game, "down"); U.wait(2) -- ONLINE MATCH row
U.tap(game, "a"); U.wait(3) -- into onlineMenu
U.tap(game, "a"); U.wait(30) -- HOST ONLINE -> connect to the real relay
local waited = 0
while not link.net.code and waited < 300 do
U.wait(1)
waited = waited + 1
end
report(("host code: %s net error: %s"):format(
tostring(link.net.code), tostring(link.net.error)))
U.wait(60) -- let DiscordPresence pick up the code and push an activity
report(("discord joinCode now: %s connected: %s"):format(
tostring(s.joinCode), tostring(s.connected)))
report("DISCORD_JOIN_TEST: check your Discord profile/status now -- it")
report("should show an 'Ask to Join' button, code " .. tostring(link.net.code))
while true do
coroutine.yield()
end
end
+7
View File
@@ -64,6 +64,13 @@ return function(game)
check(warped, "YES warps to REDS_HOUSE_2F") check(warped, "YES warps to REDS_HOUSE_2F")
check(ow.player.cellX == 3 and ow.player.cellY == 6, check(ow.player.cellX == 3 and ow.player.cellY == 6,
"landed on the bedroom spawn (3,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") U.log(pass and "RESULT: ALL PASS" or "RESULT: SEE FAILURES ABOVE")
end end
+63
View File
@@ -0,0 +1,63 @@
-- Driver: exercises src/link/Net.lua's TCP relay backend for real, inside
-- LOVE (real lua-enet/luasocket), against a pokeserver already running at
-- 127.0.0.1:7778 (POKEPORT_RELAY_ADDR overrides). Doesn't touch the game
-- UI at all -- just proves host/join/relay/close over a real socket.
return function(game)
local U = dofile("tests/drivers/util.lua")
local Net = require("src.link.Net")
local addr = os.getenv("POKEPORT_RELAY_ADDR") or "127.0.0.1:7778"
local host = Net.new()
local ok = host:hostOnline(addr)
U.log("hostOnline ok:", ok, "error:", host.error)
local frames = 0
while not host.code and not host.error and frames < 180 do
host:update()
frames = frames + 1
coroutine.yield()
end
U.log("host code:", host.code, "error:", host.error)
local guest = Net.new()
local gok = guest:joinOnline(addr, host.code)
U.log("joinOnline ok:", gok, "error:", guest.error)
frames = 0
while (not host.paired or not guest.paired) and frames < 180 do
host:update()
guest:update()
frames = frames + 1
coroutine.yield()
end
U.log("host.paired:", host.paired, "guest.paired:", guest.paired)
host:send({ type = "hello", name = "RED" })
local relayed = nil
frames = 0
while not relayed and frames < 180 do
host:update() -- flushes host's queued send
guest:update()
for _, msg in ipairs(guest:poll()) do
if msg.type == "hello" then relayed = msg end
end
frames = frames + 1
coroutine.yield()
end
U.log("relayed hello name:", relayed and relayed.name)
guest:close()
frames = 0
while not host.closed and frames < 180 do
host:update()
frames = frames + 1
coroutine.yield()
end
U.log("host saw peer_gone / closed:", host.closed)
host:close()
local pass = host.code ~= nil and host.paired and guest.paired
and relayed ~= nil and relayed.name == "RED" and host.closed
U.log("NET_RELAY_SMOKE:", pass and "PASS" or "FAIL")
end
+53
View File
@@ -0,0 +1,53 @@
-- Driver: screenshots the new online-play menu surface (LinkState's
-- restructured top menu, the online host/join flow). Pushed directly
-- (bypassing Start-menu navigation, matching options_test.lua's
-- convention) so it doesn't depend on party/save state.
--
-- Run with no mods discoverable (mod enable/disable only takes effect at
-- the next boot -- see Handshake.onlineAllowed's comment -- so a live
-- toggle can't simulate "vanilla" mid-session; the mod directory has to
-- actually be absent for this run).
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
local LinkState = require("src.link.LinkState")
local function closeAnyOpenScreens()
while game.stack:top() and game.stack:top().exitWith do
game.stack:top():exitWith(nil)
U.wait(2)
end
end
-- top menu (3 rows: LAN / ONLINE MATCH / TOURNAMENT)
game.stack:push(LinkState.new(game))
U.wait(5)
U.shot(game, DIR .. "/link_0_top_menu.png")
U.tap(game, "down"); U.wait(2)
U.shot(game, DIR .. "/link_1_top_menu_online.png")
-- ONLINE MATCH -> HOST ONLINE (needs pokeserver reachable at the
-- default relay address; POKEPORT_RELAY_ADDR overrides)
U.tap(game, "a"); U.wait(3) -- into onlineMenu
U.shot(game, DIR .. "/link_2_online_menu.png")
U.tap(game, "a"); U.wait(20) -- HOST ONLINE -> connect
U.shot(game, DIR .. "/link_3_online_hosting.png")
-- back out, try JOIN ONLINE's code-entry screen
closeAnyOpenScreens()
game.stack:push(LinkState.new(game))
U.wait(5)
U.tap(game, "down"); U.wait(2) -- ONLINE MATCH row
U.tap(game, "a"); U.wait(3)
U.tap(game, "down"); U.wait(2) -- JOIN ONLINE row
U.tap(game, "a"); U.wait(3)
U.shot(game, DIR .. "/link_4_code_entry.png")
U.tap(game, "up"); U.wait(1)
U.tap(game, "right"); U.wait(1)
U.shot(game, DIR .. "/link_5_code_entry_scrubbed.png")
closeAnyOpenScreens()
U.log("ONLINE_PLAY_DRIVER: done")
end
+2 -15
View File
@@ -4165,21 +4165,8 @@ function ops.fieldMove(s, where)
say(("fieldMove %s: nobody in the party knows it"):format(move)) say(("fieldMove %s: nobody in the party knows it"):format(move))
return false return false
end end
-- The knower must also be CONSCIOUS: the party submenu hides field -- Field moves work from fainted users in Gen 1 (PartyMenu + partyKnows
-- moves on a fainted mon (vanilla -- partyKnows requires hp > 0), so -- no longer gate on hp > 0). No Poké Center detour needed here.
-- 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 -- CUT acts on the tile the player is FACING (OverworldState:tryCut), and
-- a `goto` leaves us facing whatever direction we last stepped. Without -- a `goto` leaves us facing whatever direction we last stepped. Without
-- turning to the tree first the move fires into empty ground, the game -- turning to the tree first the move fires into empty ground, the game
+19
View File
@@ -0,0 +1,19 @@
-- One-shot: injects a fake 3-mon party and writes it to disk for the
-- current identity, then exits. Meant to be followed by a normal
-- (driver-free) `love .` launch, since POKEPORT_DRIVER disables Discord
-- presence for the run -- this is how to get a ready-made party AND a
-- fully interactive session with real Discord presence.
return function(game)
local Pokemon = require("src.pokemon.Pokemon")
local SaveData = require("src.core.SaveData")
game.save.party = {
Pokemon.new(game.data, "CHARIZARD", 50),
Pokemon.new(game.data, "PIKACHU", 30),
Pokemon.new(game.data, "SNORLAX", 40),
}
game.save.player.name = "RED"
local ok = SaveData.save(game.save)
print("save_fake_party: saved =", ok)
end
+72
View File
@@ -0,0 +1,72 @@
-- Driver: joins the tournament tournament_host_test.lua creates, reading
-- the code from a shared file since the two LOVE processes otherwise
-- can't coordinate. Meant for a fresh throwaway POKEPORT_IDENTITY, so it
-- injects a test party directly (mirroring run_link_tests.lua's
-- makeFakeGame) rather than playing through a whole new-game intro.
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots_tourney"
local CODE_FILE = os.getenv("TOURNEY_CODE_FILE") or "/tmp/tourney_code.txt"
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
-- the default tournament rule is exactly 3 Pokemon; overwrite whatever
-- this identity's party actually is (safe: a match uses clamped copies)
local Pokemon = require("src.pokemon.Pokemon")
game.save.party = {
Pokemon.new(game.data, "BLASTOISE", 50),
Pokemon.new(game.data, "GENGAR", 35),
Pokemon.new(game.data, "ALAKAZAM", 45),
}
game.save.player.name = "BLUE"
local CodeEntry = require("src.link.CodeEntry")
local Tournament = require("src.link.Tournament")
local t = Tournament.new(game)
game.stack:push(t)
U.wait(3)
local code = nil
local waited = 0
while not code and waited < 1800 do
local f = io.open(CODE_FILE, "r")
if f then
local c = f:read("*l")
f:close()
if c and #c == 6 then code = c end
end
U.wait(1)
waited = waited + 1
end
U.log("guest read code:", code)
if not code then
U.log("TOURNAMENT_GUEST_DRIVER: never saw a code, aborting")
return
end
U.tap(game, "down"); U.wait(2) -- JOIN row
U.tap(game, "a"); U.wait(3) -- into codeEntry
U.shot(game, DIR .. "/guest_0_code_entry.png")
for i = 1, CodeEntry.LENGTH do
local idx = CodeEntry.CHARSET:find(code:sub(i, i), 1, true)
if idx then t.codeEntry.chars[i] = idx end
end
U.tap(game, "a"); U.wait(3) -- confirm -> startJoining
U.shot(game, DIR .. "/guest_1_joining.png")
waited = 0
while #t.roster == 0 and t.stage ~= "done" and waited < 1800 do
U.wait(1)
waited = waited + 1
end
U.log("guest roster:", table.concat(t.roster or {}, ","))
U.shot(game, DIR .. "/guest_2_roster.png")
local doneWait = 0
while t.stage ~= "done" and doneWait < 5400 do
U.tap(game, "a")
U.wait(2)
doneWait = doneWait + 1
end
U.shot(game, DIR .. "/guest_3_done.png")
U.log("TOURNAMENT_GUEST_DRIVER: champion=", t.champion, "stage=", t.stage)
end
+57
View File
@@ -0,0 +1,57 @@
-- Driver: hosts a real 2-player tournament against a real running
-- pokeserver, paired with tournament_guest_test.lua running in a second
-- LOVE process. Coordinates over a shared code file (TOURNEY_CODE_FILE)
-- since the two processes have no other way to talk before the code
-- exists. The default tournament rule is exactly 3 Pokemon, so this
-- overwrites whatever party the identity actually has with 3 fixed mons
-- -- a tournament match never touches the real save (clamped copies, same
-- as any other link battle), so clobbering it here for the test is safe.
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots_tourney"
local CODE_FILE = os.getenv("TOURNEY_CODE_FILE") or "/tmp/tourney_code.txt"
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
local Pokemon = require("src.pokemon.Pokemon")
game.save.party = {
Pokemon.new(game.data, "CHARIZARD", 50),
Pokemon.new(game.data, "PIKACHU", 30),
Pokemon.new(game.data, "SNORLAX", 40),
}
game.save.player.name = "RED"
local Tournament = require("src.link.Tournament")
local t = Tournament.new(game)
game.stack:push(t)
U.wait(3)
U.shot(game, DIR .. "/host_0_menu.png")
U.tap(game, "a"); U.wait(3) -- HOST -> hostSettings
U.shot(game, DIR .. "/host_1_settings.png")
U.tap(game, "start"); U.wait(30) -- create with defaults (3 mons, any/any, 6s)
U.shot(game, DIR .. "/host_2_hosting.png")
U.log("host code:", t.code, "error:", t.net and t.net.error)
local f = io.open(CODE_FILE, "w")
if f then f:write(t.code or ""); f:close() end
local waited = 0
while #t.roster < 2 and waited < 1800 do
U.wait(1)
waited = waited + 1
end
U.log("host roster:", table.concat(t.roster, ","))
U.shot(game, DIR .. "/host_3_roster.png")
U.tap(game, "a"); U.wait(1) -- start_tournament
U.shot(game, DIR .. "/host_4_bracket.png")
local doneWait = 0
while t.stage ~= "done" and doneWait < 5400 do
U.tap(game, "a")
U.wait(2)
doneWait = doneWait + 1
end
U.shot(game, DIR .. "/host_5_done.png")
U.log("TOURNAMENT_HOST_DRIVER: champion=", t.champion, "stage=", t.stage)
end
+9 -1
View File
@@ -52,6 +52,9 @@ stub.graphics = {
push = noop, pop = noop, translate = noop, scale = noop, push = noop, pop = noop, translate = noop, scale = noop,
rotate = noop, origin = noop, setShader = noop, setScissor = noop, rotate = noop, origin = noop, setShader = noop, setScissor = noop,
getDimensions = function() return 640, 576 end, 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 = { stub.math = {
@@ -127,7 +130,12 @@ stub.sound = {
stub.keyboard = { isDown = function() return false end } stub.keyboard = { isDown = function() return false end }
stub.mouse = { getPosition = function() return 0, 0 end } stub.mouse = {
getPosition = function() return 0, 0 end,
isCursorSupported = function() return false end,
getSystemCursor = function(name) return name end,
setCursor = function() end,
}
stub.timer = { getTime = function() return 0 end } stub.timer = { getTime = function() return 0 end }
+5
View File
@@ -320,6 +320,9 @@ check(vanillaSave.player.map == "REDS_HOUSE_2F" and vanillaSave.player.x == 3
"the seeded boot config reproduces the NewGameWarp bedroom spawn") "the seeded boot config reproduces the NewGameWarp bedroom spawn")
check(vanillaSave.player.name == "RED" and vanillaSave.player.rival == "BLUE" check(vanillaSave.player.name == "RED" and vanillaSave.player.rival == "BLUE"
and vanillaSave.money == 3000, "the seeded boot config reproduces the Red start") 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 -- the heal point is deliberately NOT the spawn: wLastBlackoutMap is
-- zero-filled at new game and PALLET_TOWN is map 0 -- zero-filled at new game and PALLET_TOWN is map 0
check(vanillaSave.lastHeal.map == "PALLET_TOWN" and vanillaSave.lastHeal.x == 5 check(vanillaSave.lastHeal.map == "PALLET_TOWN" and vanillaSave.lastHeal.x == 5
@@ -329,6 +332,8 @@ local bareSave = SaveData.newGame()
check(bareSave.player.map == "REDS_HOUSE_2F" and bareSave.money == 3000 check(bareSave.player.map == "REDS_HOUSE_2F" and bareSave.money == 3000
and bareSave.lastHeal.map == "PALLET_TOWN", and bareSave.lastHeal.map == "PALLET_TOWN",
"newGame without a boot config is unchanged") "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", [[ local bootData, bootLoader = withMod("total_conversion", [[
return function(mod) return function(mod)
+136
View File
@@ -128,6 +128,8 @@ love.graphics = {
translate = noop, scale = noop, rotate = noop, origin = noop, translate = noop, scale = noop, rotate = noop, origin = noop,
setScissor = noop, getColor = function() return 1, 1, 1, 1 end, setScissor = noop, getColor = function() return 1, 1, 1, 1 end,
getDimensions = function() return 640, 576 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 -- Fresh copies of the modules that cache a compiled shader or a page set
@@ -618,6 +620,49 @@ check(PaletteFX.pal({ palettes = nil }, "ROUTE") == gbc.palettes.ROUTE,
"RED++ still has ROUTE (aliased from VIRIDIAN)") "RED++ still has ROUTE (aliased from VIRIDIAN)")
check(PaletteFX.effectiveColors(gbc.palettes.MEWMON) == gbc.palettes.MEWMON, check(PaletteFX.effectiveColors(gbc.palettes.MEWMON) == gbc.palettes.MEWMON,
"RED++ passes zone colors through like GBC") "RED++ passes zone colors through like GBC")
-- issue #84: CELADON_DINER shares LOBBY block 29 (table top) with
-- CELADON_MART_ROOF (#52); both need the $37->$5a BROWN alias
do
local aliases = PaletteFX.TILE_ALIASES
local roof = aliases and aliases.CELADON_MART_ROOF
local diner = aliases and aliases.CELADON_DINER
check(roof ~= nil and diner ~= nil,
"CELADON_MART_ROOF and CELADON_DINER both have TILE_ALIASES")
check(diner == roof,
"diner reuses the same lobby table-top alias as the mart roof")
local al = diner and diner[1]
check(al and al.block == 29 and al.tile == 0x37 and al.alias == 0x5a
and al.group == 5 and al.cells[5] and al.cells[6]
and al.cells[9] and al.cells[10],
"lobby table-top alias remaps block 29 cells 5/6/9/10")
end
-- 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) PaletteFX.setMode(prevMode)
-- ------- the transitions registry -- ------- the transitions registry
@@ -709,6 +754,97 @@ do
love.graphics.rectangle, love.graphics.setColor = savedRect, savedColor love.graphics.rectangle, love.graphics.setColor = savedRect, savedColor
end end
-- ------- battle transition cascade outside the OG square + white letterbox
do
local rects = {}
local color = { 1, 1, 1, 1 }
local savedRect, savedColor = love.graphics.rectangle, love.graphics.setColor
local savedGetDims = love.graphics.getDimensions
local savedGetPix = love.graphics.getPixelDimensions
-- taller-than-fit window so the 160x144 letterbox leaves real voids
-- (default 640x576 is an exact 4x fit with nothing outside the square)
love.graphics.getDimensions = function() return 800, 600 end
love.graphics.getPixelDimensions = function() return 800, 600 end
love.graphics.rectangle = function(mode, x, y, w, h)
rects[#rects + 1] = {
mode = mode, x = x, y = y, w = w, h = h,
r = color[1], g = color[2], b = color[3], a = color[4],
}
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 btGame = { renderer = Renderer, stack = { pop = noop } }
local wipe = BattleTransition.new(btGame, nil, {})
wipe.phase = "wipe"
wipe.t = math.floor(wipe.wipeLen / 2)
Renderer:beginFrame(true)
Renderer:beginWorldPass()
Renderer:endWorldPass()
wipe:draw()
check(Renderer.battleCascadeProg ~= nil
and Renderer.battleCascadeProg > 0
and Renderer.battleCascadeProg < 1,
"battle wipe publishes mid-progress cascade to the renderer")
rects = {}
Renderer:endFrame(nil, fullWorldZones())
local cascadeTile
for _, r in ipairs(rects) do
-- fitScale at 800x600 is min(5,4)=4 → 32px tiles outside the letterbox
if r.mode == "fill" and r.r == 0 and r.a == 1 and r.w == 32 and r.h == 32 then
cascadeTile = r
break
end
end
check(cascadeTile ~= nil,
"endFrame paints cascading black tiles outside the OG wipe square")
love.graphics.rectangle, love.graphics.setColor = savedRect, savedColor
love.graphics.getDimensions = savedGetDims
love.graphics.getPixelDimensions = savedGetPix
end
do
-- BattleState asks for white letterbox voids instead of black bars
local BattleState = require("src.battle.BattleState")
check(BattleState.letterboxWhite == true,
"BattleState opts into white letterbox fill")
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,
r = color[1], g = color[2], b = color[3], a = color[4],
}
end
love.graphics.setColor = function(r, g, b, a)
color[1], color[2], color[3], color[4] = r, g, b, a or 1
end
local Game = require("src.core.Game")
local savedStack = Game.stack
Game.stack = {
states = { { letterboxWhite = true, isOpaque = true } },
visibleBase = function() return 1 end,
}
Renderer:init()
Renderer:beginFrame(false)
rects = {}
Renderer:endFrame(nil, nil)
local clear
for _, r in ipairs(rects) do
if r.mode == "fill" and r.x == 0 and r.y == 0 and r.w == 640 and r.h == 576 then
clear = r
break
end
end
check(clear and clear.r == 1 and clear.g == 1 and clear.b == 1,
"endFrame fills the window white when the visible base wants letterboxWhite")
Game.stack = savedStack
love.graphics.rectangle, love.graphics.setColor = savedRect, savedColor
end
-- ------- the transition.style hook -- ------- the transition.style hook
local stack = { pop = function() end } local stack = { pop = function() end }
+61
View File
@@ -215,6 +215,41 @@ do
SaveData.runMigrations(tagged) SaveData.runMigrations(tagged)
check(tagged.version == "blue", "an existing version tag is left untouched") 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 love.filesystem = realFS
end end
@@ -536,6 +571,32 @@ do
"newGame starts an empty modData") "newGame starts an empty modData")
check(plain.meta.format == Version.saveFormat and #plain.meta.mods == 0, check(plain.meta.format == Version.saveFormat and #plain.meta.mods == 0,
"newGame stamps a vanilla meta") "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 end
-- a throwing mod migration is skipped, never fatal: an uncaught error here -- a throwing mod migration is skipped, never fatal: an uncaught error here
+155 -9
View File
@@ -206,7 +206,8 @@ end
local om = OptionsMenu.new(optGame()) local om = OptionsMenu.new(optGame())
local WANT_IDS = { "textSpeed", "animations", "battleStyle", "ruleset", local WANT_IDS = { "textSpeed", "animations", "battleStyle", "ruleset",
"musicVol", "sfxVol", "musicFilter", "colors", "tilt", "musicVol", "sfxVol", "musicFilter", "colors", "tilt",
"gbcfx", "videoMode", "fpsCap", "speed", "mods", "controls" } "gbcfx", "zoom", "voidFill", "videoMode", "fpsCap",
"speed", "mods", "controls" }
check(#om.rows == #WANT_IDS, "vanilla options row count (plus MODS/CONTROLS)") check(#om.rows == #WANT_IDS, "vanilla options row count (plus MODS/CONTROLS)")
for i, id in ipairs(WANT_IDS) do for i, id in ipairs(WANT_IDS) do
check(om.rows[i].id == id, "options row order: " .. id) check(om.rows[i].id == id, "options row order: " .. id)
@@ -236,17 +237,35 @@ check(om.game.save.options.musicVol == 6, "music volume steps down")
for _ = 1, 10 do om.rows[5].step(om.game, -1) end for _ = 1, 10 do om.rows[5].step(om.game, -1) end
check(om.game.save.options.musicVol == 0, "music volume clamps at 0") check(om.game.save.options.musicVol == 0, "music volume clamps at 0")
-- ZOOM / VOID FILL rows
local Zoom = require("src.render.Zoom")
local TileRenderer = require("src.render.TileRenderer")
om.game.save.options.zoom = 0
Zoom.offset = 0
check(om.rows[11].value(om.game) == "FIT", "ZOOM row shows FIT at offset 0")
om.rows[11].step(om.game, 1)
check(om.game.save.options.zoom == 1 and Zoom.offset == 1,
"ZOOM row steps to IN1")
om.rows[12].step(om.game, 1)
check(om.game.save.options.voidFill == "water"
and TileRenderer.voidFill == "water",
"VOID FILL row cycles TREES → WATER")
om.rows[12].step(om.game, 1)
check(om.game.save.options.voidFill == "black", "VOID FILL steps to BLACK")
om.rows[12].step(om.game, 1)
check(om.game.save.options.voidFill == "trees", "VOID FILL wraps to TREES")
-- the MAX FPS row cycles the render-cap steps and shows the value plain -- the MAX FPS row cycles the render-cap steps and shows the value plain
om.game.save.options.fpsCap = nil om.game.save.options.fpsCap = nil
check(om.rows[12].value(om.game) == "60", check(om.rows[14].value(om.game) == "60",
"MAX FPS row defaults to 60 with no saved cap") "MAX FPS row defaults to 60 with no saved cap")
om.rows[12].step(om.game, 1) om.rows[14].step(om.game, 1)
check(om.game.save.options.fpsCap == 75, "MAX FPS steps up from 60 to 75") check(om.game.save.options.fpsCap == 75, "MAX FPS steps up from 60 to 75")
check(om.rows[12].value(om.game) == "75", "the MAX FPS row renders the cap") check(om.rows[14].value(om.game) == "75", "the MAX FPS row renders the cap")
om.game.save.options.fpsCap = 160 om.game.save.options.fpsCap = 160
om.rows[12].step(om.game, 1) om.rows[14].step(om.game, 1)
check(om.game.save.options.fpsCap == 30, "MAX FPS wraps past the ceiling to 30") check(om.game.save.options.fpsCap == 30, "MAX FPS wraps past the ceiling to 30")
om.rows[12].step(om.game, -1) om.rows[14].step(om.game, -1)
check(om.game.save.options.fpsCap == 160, "MAX FPS wraps back down to the ceiling") check(om.game.save.options.fpsCap == 160, "MAX FPS wraps back down to the ceiling")
-- ------- FrameCap normalize / cycle (issue #88) -- ------- FrameCap normalize / cycle (issue #88)
@@ -276,7 +295,7 @@ check(FrameCap.current == 60, "FrameCap.applyOptions defaults a missing key to 6
-- the MODS row is the manager's discoverable home -- the MODS row is the manager's discoverable home
local mgGame = optGame() local mgGame = optGame()
om = OptionsMenu.new(mgGame) om = OptionsMenu.new(mgGame)
om.rows[14].activate(mgGame) om.rows[16].activate(mgGame)
check(getmetatable(mgGame.stack:top()) == ManagerState, check(getmetatable(mgGame.stack:top()) == ManagerState,
"the MODS row opens the manager") "the MODS row opens the manager")
check(mgGame.stack:top().screenId == "ManagerState", check(mgGame.stack:top().screenId == "ManagerState",
@@ -286,7 +305,7 @@ check(mgGame.stack:top().screenId == "ManagerState",
local BindingsMenu = require("src.ui.BindingsMenu") local BindingsMenu = require("src.ui.BindingsMenu")
local cbGame = optGame() local cbGame = optGame()
om = OptionsMenu.new(cbGame) om = OptionsMenu.new(cbGame)
om.rows[15].activate(cbGame) om.rows[17].activate(cbGame)
local bm = cbGame.stack:top() local bm = cbGame.stack:top()
check(getmetatable(bm) == BindingsMenu, check(getmetatable(bm) == BindingsMenu,
"the CONTROLS row opens the rebind list") "the CONTROLS row opens the rebind list")
@@ -295,7 +314,8 @@ check(bm.screenId == "BindingsMenu",
check(#bm.items == 8, "one row per logical button") check(#bm.items == 8, "one row per logical button")
check(bm.items[1].label == "UP" and bm.items[1].right == "UP" check(bm.items[1].label == "UP" and bm.items[1].right == "UP"
and bm.items[5].label == "A" and bm.items[5].right == "Z" and bm.items[5].label == "A" and bm.items[5].right == "Z"
and bm.items[7].label == "START" and bm.items[7].right == "ESCAPE", and bm.items[7].label == "START" and bm.items[7].right == "ESCAPE"
and bm.items[8].label == "SELECT" and bm.items[8].right == "TAB/BACK",
"with no rebind the rows mirror the fixed map") "with no rebind the rows mirror the fixed map")
check(cbGame.save.options.bindings == nil, check(cbGame.save.options.bindings == nil,
"opening the screen alone writes nothing") "opening the screen alone writes nothing")
@@ -475,6 +495,132 @@ check(title.version and title.version.path
== "assets/generated/title/red_version.png", == "assets/generated/title/red_version.png",
"the shipped version ribbon loads") "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 OakSpeech = require("src.ui.OakSpeech")
local ogame = { data = { local ogame = { data = {
field = { oakSpeech = { music = "X_Song", demoSpecies = "PIKACHU" } }, field = { oakSpeech = { music = "X_Song", demoSpecies = "PIKACHU" } },
+9
View File
@@ -338,6 +338,15 @@ do
end end
check(pewterNpc and ow:trainerDefeated(pewterNpc), check(pewterNpc and ow:trainerDefeated(pewterNpc),
"unfought Pewter gym trainer is defeated after badge") "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 while Game.stack:top() do Game.stack:pop() end
Game.save = SaveData.newGame() Game.save = SaveData.newGame()
+49 -1
View File
@@ -21,12 +21,17 @@ check(hof and type(hof.onEnter) == "function", "HALL_OF_FAME.onEnter is a functi
local champ = init.get("CHAMPIONS_ROOM") local champ = init.get("CHAMPIONS_ROOM")
check(champ ~= nil, "CHAMPIONS_ROOM map script registered") 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 local rows = champ and champ.talk and champ.talk.TEXT_CHAMPIONSROOM_RIVAL
check(type(rows) == "table", "CHAMPIONS_ROOM.talk.TEXT_CHAMPIONSROOM_RIVAL exists") check(type(rows) == "table", "CHAMPIONS_ROOM.talk.TEXT_CHAMPIONSROOM_RIVAL exists")
-- (2) the rival cutscene rows contain the pokered beats in order -- (2) the rival cutscene rows contain the pokered beats in order
rows = rows or {} rows = rows or {}
local preds = { 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", { "show_object CHAMPIONSROOM_OAK",
function(r) return r[1] == "show_object" and r[3] == "CHAMPIONSROOM_OAK" end }, function(r) return r[1] == "show_object" and r[3] == "CHAMPIONSROOM_OAK" end },
{ "move_npc(2,'up',5) OakEntranceAfterVictoryMovement", { "move_npc(2,'up',5) OakEntranceAfterVictoryMovement",
@@ -82,7 +87,13 @@ check(champToHof, "CHAMPIONS_ROOM has a warp up into HALL_OF_FAME")
-- (5) functional: HALL_OF_FAME.onEnter consumes the one-shot marker and -- (5) functional: HALL_OF_FAME.onEnter consumes the one-shot marker and
-- queues (does not directly run) the room cutscene. -- queues (does not directly run) the room cutscene.
local queued local queued
local fakeOw = { queueScript = function(self, script, extra) queued = script; self.pendingScript = { script = script } end } local fakeOw = {
map = { def = { signs = {} }, widthCells = 10, signAt = {} },
queueScript = function(self, script, extra)
queued = script
self.pendingScript = { script = script }
end,
}
local fakeGame = { save = { pendingHallOfFame = true } } local fakeGame = { save = { pendingHallOfFame = true } }
hof.onEnter(fakeGame, fakeOw) hof.onEnter(fakeGame, fakeOw)
check(queued ~= nil, "HALL_OF_FAME.onEnter queues a cutscene script when marker set") check(queued ~= nil, "HALL_OF_FAME.onEnter queues a cutscene script when marker set")
@@ -102,4 +113,41 @@ fakeGame.save.pendingHallOfFame = false
hof.onEnter(fakeGame, fakeOw) hof.onEnter(fakeGame, fakeOw)
check(queued == nil, "HALL_OF_FAME.onEnter does not replay once the marker is consumed") 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() S.finish()
+13 -2
View File
@@ -281,11 +281,21 @@ end
-- the digit only), plus the LIFT_KEY gate. -- the digit only), plus the LIFT_KEY gate.
-- =================================================================== -- ===================================================================
do do
-- without the key: text-only, no floor menu local hideoutSnap = snapshotWarps("ROCKET_HIDEOUT_ELEVATOR")
local gated = openElevator("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")
check(gated ~= nil, "Rocket Hideout without LIFT_KEY still pushes something") check(gated ~= nil, "Rocket Hideout without LIFT_KEY still pushes something")
check(gated ~= nil and getmetatable(gated) ~= ListMenu, check(gated ~= nil and getmetatable(gated) ~= ListMenu,
"Rocket Hideout without LIFT_KEY does not open the floor menu") "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 -- 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 }) local menu, warpCalls, stack, ow = openElevator("ROCKET_HIDEOUT_ELEVATOR", { LIFT_KEY = 1 })
@@ -324,6 +334,7 @@ do
eq(warpCalls[1].y, chosen.value.y, "Rocket Hideout walk-out lands on the chosen floor y") eq(warpCalls[1].y, chosen.value.y, "Rocket Hideout walk-out lands on the chosen floor y")
end end
end end
restoreWarps("ROCKET_HIDEOUT_ELEVATOR", hideoutSnap)
end end
Sound.play = origSoundPlay Sound.play = origSoundPlay
+62 -6
View File
@@ -7,6 +7,7 @@
-- over later, at TEXT_OAKSLAB_OAK1's .give_poke_balls beat, gated on -- over later, at TEXT_OAKSLAB_OAK1's .give_poke_balls beat, gated on
-- EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE and the one-shot -- EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE and the one-shot
-- EVENT_GOT_POKEBALLS_FROM_OAK flag (data/scripts/oaks_lab.lua). -- 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 package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data") local Data = require("src.core.Data")
@@ -21,6 +22,8 @@ local StateStack = require("src.core.StateStack")
local SaveData = require("src.core.SaveData") local SaveData = require("src.core.SaveData")
local ScriptRunner = require("src.script.ScriptRunner") local ScriptRunner = require("src.script.ScriptRunner")
local Flags = require("src.script.Flags") local Flags = require("src.script.Flags")
local ChoiceBox = require("src.ui.ChoiceBox")
local NamingScreen = require("src.ui.NamingScreen")
local mapScripts = require("data.scripts.init") local mapScripts = require("data.scripts.init")
Game.data = Data Game.data = Data
@@ -29,16 +32,15 @@ Game.stack = StateStack; StateStack:init()
Game.save = SaveData.newGame() Game.save = SaveData.newGame()
require("src.render.Font").load(Data) require("src.render.Font").load(Data)
-- pumps a script coroutine to completion, mashing A through any -- pumps a script coroutine to completion; pressFn returns the Input.pressed
-- show_text/ask boxes along the way (mirrors tests/run_tests.lua's -- table for this frame (default: mash A through text/ask/naming)
-- runScript helper for the parcel/pokedex chain) local function runScript(script, pressFn)
local function runScript(script)
local r = ScriptRunner.new(Game, nil) local r = ScriptRunner.new(Game, nil)
r:run(script, {}) r:run(script, {})
local guard = 0 local guard = 0
while r:isRunning() and guard < 2000 do while r:isRunning() and guard < 4000 do
guard = guard + 1 guard = guard + 1
Input.pressed = { a = true } Input.pressed = pressFn and pressFn() or { a = true }
StateStack:update(1 / 60) StateStack:update(1 / 60)
r:update() r:update()
end end
@@ -58,6 +60,11 @@ check(runScript(mapScripts.talkScript("OAKS_LAB", "TEXT_OAKSLAB_BULBASAUR_POKE_B
"starter pick script completes") "starter pick script completes")
check(Flags.get(Game.save, "EVENT_GOT_STARTER"), "starter flag set") 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") 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 === -- === 2) the parcel/pokedex beat still doesn't grant POKé BALLs ===
check(runScript(mapScripts.talkScript("VIRIDIAN_MART", "TEXT_VIRIDIANMART_CLERK")), check(runScript(mapScripts.talkScript("VIRIDIAN_MART", "TEXT_VIRIDIANMART_CLERK")),
@@ -69,6 +76,12 @@ check(runScript(mapScripts.talkScript("OAKS_LAB", "TEXT_OAKSLAB_OAK1")),
eq(Game.save.inventory.OAKS_PARCEL, nil, "parcel delivered") 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_OAK_GOT_PARCEL"), "delivery flag set")
check(Flags.get(Game.save, "EVENT_GOT_POKEDEX"), "Pokedex 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") eq(Game.save.inventory.POKE_BALL, nil, "still no POKe BALLs at the pokedex beat")
-- talking to Oak again before beating the Route 22 rival should fall -- talking to Oak again before beating the Route 22 rival should fall
@@ -89,4 +102,47 @@ check(runScript(mapScripts.talkScript("OAKS_LAB", "TEXT_OAKSLAB_OAK1")),
"Oak talk (post-grant) script completes") "Oak talk (post-grant) script completes")
eq(Game.save.inventory.POKE_BALL, 5, "POKe Ball count unchanged on a second talk") 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() S.finish()
+51
View File
@@ -187,4 +187,55 @@ check(OW.objectVisible(Game.save, "SEAFOAM_ISLANDS_B4F",
objOf("SEAFOAM_ISLANDS_B4F", "SEAFOAMISLANDSB4F_BOULDER2")), objOf("SEAFOAM_ISLANDS_B4F", "SEAFOAMISLANDSB4F_BOULDER2")),
"SEAFOAMISLANDSB4F_BOULDER2 is visible at the end state") "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() S.finish()
+93
View File
@@ -408,6 +408,99 @@ check(strFlash ~= page2 and strFlash ~= ow and strFlash.pages == nil,
for _ = 1, 10 do frame({}) end for _ = 1, 10 do frame({}) end
eq(Game.stack:top(), ow, "back on the map after the blink") 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()
-- ===========================================================================
-- #83: FLY/TELEPORT use CheckIfInOutsideMap (OVERWORLD + PLATEAU), so the
-- outdoor strip between Victory Road and the Elite Four / Indigo Plateau
-- building allows Fly like any other outdoor overworld map.
-- ===========================================================================
Game.save.party = { mkMon("AERODACTYL", "FLY", "TELEPORT") }
Game.save.inventory = { THUNDERBADGE = true }
ow = pushOW("INDIGO_PLATEAU", 10, 5, "down")
eq(ow.map.def.tileset, "PLATEAU", "Indigo Plateau outdoor uses PLATEAU tileset")
local pmIndigo = PartyMenu.new(Game)
Game.stack:push(pmIndigo)
frame({ "a" })
local actsIndigo = submenuActions(pmIndigo)
check(actsIndigo.fly, "FLY listed on Indigo Plateau outdoor (PLATEAU)")
check(actsIndigo.escape, "TELEPORT listed on Indigo Plateau outdoor (PLATEAU)")
popToOW()
ow = pushOW("ROUTE_23", 10, 6, "down")
eq(ow.map.def.tileset, "PLATEAU", "Route 23 uses PLATEAU tileset")
local pmR23 = PartyMenu.new(Game)
Game.stack:push(pmR23)
frame({ "a" })
check(submenuActions(pmR23).fly, "FLY listed on Route 23 (PLATEAU)")
popToOW()
-- Indoors at the lobby still blocks FLY/TELEPORT (MART tileset)
ow = pushOW("INDIGO_PLATEAU_LOBBY", 7, 8, "down")
check(ow.map.def.tileset ~= "OVERWORLD" and ow.map.def.tileset ~= "PLATEAU",
"Indigo lobby is not an outside tileset")
local pmLobby = PartyMenu.new(Game)
Game.stack:push(pmLobby)
frame({ "a" })
local actsLobby = submenuActions(pmLobby)
check(not actsLobby.fly, "FLY omitted inside Indigo Plateau lobby")
check(not actsLobby.escape, "TELEPORT omitted inside Indigo Plateau lobby")
popToOW()
-- restore fainted field-move mon for the STRENGTH/SURF cases below
Game.save.party = { fainted }
Game.save.inventory = {
THUNDERBADGE = true, CASCADEBADGE = true,
RAINBOWBADGE = true, SOULBADGE = true,
}
-- 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 -- restore the spied constructor so later dofile'd suites are unaffected
TextBox.new = realTextBoxNew TextBox.new = realTextBoxNew
popAll() popAll()
+78 -1
View File
@@ -79,6 +79,58 @@ do
"the queued trap anim row is attributed to the attacker") "the queued trap anim row is attributed to the attacker")
end 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 -- (3) MIMIC runs MID-move (MimicEffect, effects.asm:1203-1273): the
-- move executes, MoveHitTest runs, and only on a hit does the player's -- move executes, MoveHitTest runs, and only on a hit does the player's
-- copy menu open (.letPlayerChooseMove) -- the enemy's Mimic and link -- copy menu open (.letPlayerChooseMove) -- the enemy's Mimic and link
@@ -169,10 +221,35 @@ do
check(fe.chooser == nil, "enemy Mimic never opens a chooser") check(fe.chooser == nil, "enemy Mimic never opens a chooser")
eq(tbe.enemy.curMoves[1].id, "SAND_ATTACK", eq(tbe.enemy.curMoves[1].id, "SAND_ATTACK",
"enemy Mimic copies a random player move immediately") "enemy Mimic copies a random player move immediately")
eq(tbe.enemy.curMoves[1].pp, 9, "enemy Mimic also keeps the slot's PP") -- 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)")
check(fe.anim and hasText(fe, "learned"), check(fe.anim and hasText(fe, "learned"),
"enemy Mimic still plays the animation and learned text") "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) -- link battle: the player's Mimic rolls random too (no chooser)
local tbl = freshBattle() local tbl = freshBattle()
tbl.kind = "link" tbl.kind = "link"
+31 -3
View File
@@ -115,11 +115,39 @@ do
end end
-- === STRUGGLE fallback when nothing is usable === -- === STRUGGLE fallback when nothing is usable ===
-- modern_clean / no unlimited flag: empty PP forces Struggle.
do do
local aiMon = { curMoves = { { id = "TACKLE", pp = 0 } } } local aiMon = { curMoves = { { id = "TACKLE", pp = 0 } } }
local pick = TrainerAI.chooseMove(aiMon, rngLo, { enemyAIMods = { 1 }, data = Data, local pick = TrainerAI.chooseMove(aiMon, rngLo, {
player = { mon = {}, curTypes = {} } }) enemyAIMods = { 1 }, data = Data,
check(pick and pick.struggle and pick.id == "STRUGGLE", "STRUGGLE fallback when no PP") 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")
end end
-- === switchAction off-by-one fix (matches AISwitchIfEnoughMons cp 2) === -- === switchAction off-by-one fix (matches AISwitchIfEnoughMons cp 2) ===
+114
View File
@@ -0,0 +1,114 @@
-- 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()
+97
View File
@@ -0,0 +1,97 @@
-- Regression: bike / desynced walk steps must not flash stand on land (issue #82).
--
-- walkPhase used to return 0 whenever moving was false. A step clears
-- moving on its final FixedStep tick, so the draw after landing snapped
-- to stand even when animClock was mid walk-cycle. Bike steps are 8
-- frames, so they land at animClock % 16 == 8 (walk) every tile — always
-- stuttery. Walking after a bike ride inherits a desynced animClock and
-- hit the same stand flash "sometimes."
--
-- Self-contained; run via `luajit tests/parity_bike_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 Player = require("src.world.Player")
local S = require("tests.harness").suite("parity bike walk anim")
local check, eq = S.check, S.eq
local function makePlayer(onBike)
local p = Player.new(Data, 5, 5, "down")
p.onBike = onBike
if onBike then
p.stepFramesCur = p.bikeStepFrames or 8
else
p.stepFramesCur = p.stepFrames or 16
end
p.animClock = 0
return p
end
local function startStep(p)
p.moving = true
p.progress = 0
p.targetX, p.targetY = p.cellX, p.cellY + 1
end
-- --- bike: every land frame mid-cycle must stay walk ---
local bike = makePlayer(true)
local landPhases = {}
for tile = 1, 4 do
startStep(bike)
local stepLen = bike.stepFramesCur
for _ = 1, stepLen do
local done = bike:update()
if done then
landPhases[#landPhases + 1] = bike:walkPhase()
eq(bike.moving, false, "bike step clears moving on land")
check(bike.stepLanded, "bike land latches stepLanded for draw")
end
end
end
-- lands at animClock 8, 16, 24, 32 → %16 = 8, 0, 8, 0
-- walk band is 4..11, so lands at 8 must be phase 1; at 0 must be phase 0
eq(landPhases[1], 1, "bike land at animClock%16==8 keeps walk pose")
eq(landPhases[2], 0, "bike land at animClock%16==0 is stand (in-band)")
eq(landPhases[3], 1, "bike land at animClock%16==8 keeps walk pose again")
eq(landPhases[4], 0, "bike land at animClock%16==0 is stand again")
-- continuous bike phases across two tiles: no forced stand in the walk band
bike = makePlayer(true)
local phases = {}
for _ = 1, 2 do
startStep(bike)
for _ = 1, bike.stepFramesCur do
bike:update()
phases[#phases + 1] = bike:walkPhase()
end
end
-- frames 4..11 of animClock are walk; across 16 ticks that is indices 4..11
for i = 4, 11 do
eq(phases[i], 1, "bike continuous walk band has no stand flash at " .. i)
end
-- idle after land: next update drops the latch → stand
bike = makePlayer(true)
startStep(bike)
for _ = 1, bike.stepFramesCur do bike:update() end
eq(bike:walkPhase(), 1, "latched land frame still walk")
bike:update()
eq(bike.stepLanded, false, "idle update clears stepLanded")
eq(bike:walkPhase(), 0, "truly idle is stand")
-- --- walk after desynced animClock (post-bike): land must not force stand ---
local walk = makePlayer(false)
walk.animClock = 8 -- leftover from a bike half-cycle
startStep(walk)
local lastPhase
for _ = 1, walk.stepFramesCur do
walk:update()
lastPhase = walk:walkPhase()
end
eq(walk.animClock % 16, 8, "desynced walk lands mid walk-cycle")
eq(lastPhase, 1, "desynced walk land keeps walk pose (no post-bike stutter)")
S.finish()
+68
View File
@@ -0,0 +1,68 @@
-- 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()
+157
View File
@@ -0,0 +1,157 @@
-- Parity: Dig / Fly semi-invulnerable pic hide (#100).
-- Dig charge SLIDE_DOWN hides the user; a cancelled Dig release (miss /
-- type immunity) must restore the pic; a successful Dig release keeps the
-- user hidden through the dirt subanim then emerges via SE_SLIDE_MON_UP
-- (not a cyclic bounce). Self-contained: `luajit tests/parity_dig_pic.lua`.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data")
if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end
local Font = require("src.render.Font")
Font.load(Data)
local S = require("tests.harness").suite("parity dig pic")
local check, eq = S.check, S.eq
local BattleState = require("src.battle.BattleState")
local Pokemon = require("src.pokemon.Pokemon")
local SaveData = require("src.core.SaveData")
local TypeChart = require("src.battle.TypeChart")
TypeChart.load(Data)
local function makeGame(species, level, moves)
local save = SaveData.newGame()
local mon = Pokemon.new(Data, species, level)
mon.moves = moves
save.party = { mon }
local stack = { states = {} }
function stack:push(state) self.states[#self.states + 1] = state end
function stack:pop() return table.remove(self.states) end
function stack:top() return self.states[#self.states] end
return { data = Data, save = save, stack = stack,
input = { wasPressed = function() return true end } }
end
local function pumpToMenu(battle)
local kinds, anims = {}, {}
local guard = 0
while guard < 20000 do
guard = guard + 1
battle.frame = (battle.frame or 0) + 1
battle:updateFx()
local pf = battle.picFx and battle.picFx[battle.player]
if pf and pf.kind and not kinds[pf.kind] then
kinds[pf.kind] = true
end
if battle.animPlaying and battle.animName
and anims[#anims] ~= battle.animName then
anims[#anims + 1] = battle.animName
end
if not battle:updateQueue() then
if battle.phase == "messages" and battle.afterQueue == "menu"
and not battle.animPlaying and not battle.current
and #battle.queue == 0 then
battle.phase = "menu"
break
end
if battle.phase == "menu" then break end
if not battle.animPlaying and not battle.current
and #battle.queue == 0 then
if battle.afterQueue == "menu" then battle.phase = "menu" end
break
end
end
end
for _ = 1, 200 do
battle.frame = battle.frame + 1
battle:updateFx()
end
return kinds, anims
end
local function picHidden(battle)
local pf = battle.picFx and battle.picFx[battle.player]
return pf and pf.hidden or false
end
-- Dig charge hides; Dig miss on release restores the pic (#100 vanish).
do
local game = makeGame("SANDSHREW", 40, { { id = "DIG", pp = 10 } })
local battle = BattleState.newWild(game, "RATTATA", 5)
battle.player.curMoves = game.save.party[1].moves
local dig = battle.player.curMoves[1]
battle.enemyAction = function() return { id = "TACKLE", pp = 35 } end
battle.rng = function(a) return a or 0 end
battle:resolveTurn(dig)
pumpToMenu(battle)
check(battle.player.invulnerable == true, "Dig charge sets invulnerable")
check(picHidden(battle), "Dig charge leaves the user pic hidden")
battle.rng = function(a, b)
if a == 0 and b == 255 then return 255 end -- force Dig accuracy miss
return a or 0
end
battle:resolveTurn(dig)
local kinds, anims = pumpToMenu(battle)
check(not picHidden(battle),
"Dig miss on release restores the user pic (#100)")
check(not kinds.bounce, "Dig release miss never starts a bounce pic fx")
local sawDig = false
for _, name in ipairs(anims) do
if name == "DIG" then sawDig = true end
end
check(not sawDig, "Dig miss cancels the DIG release anim")
end
-- Dig hit: stay hidden through DIG start, emerge via slideUp (not bounce).
do
local game = makeGame("SANDSHREW", 40, { { id = "DIG", pp = 10 } })
local battle = BattleState.newWild(game, "SNORLAX", 40)
battle.player.curMoves = game.save.party[1].moves
local dig = battle.player.curMoves[1]
battle.enemyAction = function() return { id = "TACKLE", pp = 35 } end
battle.rng = function(a) return a or 0 end
battle:resolveTurn(dig)
pumpToMenu(battle)
battle:resolveTurn(dig)
local hiddenAtDigStart = nil
local kinds = {}
local guard = 0
while guard < 20000 do
guard = guard + 1
battle.frame = (battle.frame or 0) + 1
battle:updateFx()
if battle.animPlaying and battle.animName == "DIG"
and hiddenAtDigStart == nil then
-- right after DIG row starts (resetPicFx already ran)
hiddenAtDigStart = picHidden(battle)
end
local pf = battle.picFx and battle.picFx[battle.player]
if pf and pf.kind then kinds[pf.kind] = true end
if not battle:updateQueue() then
if battle.phase == "messages" and battle.afterQueue == "menu"
and not battle.animPlaying and not battle.current
and #battle.queue == 0 then
battle.phase = "menu"
break
end
if battle.phase == "menu" then break end
if not battle.animPlaying and not battle.current
and #battle.queue == 0 then
if battle.afterQueue == "menu" then battle.phase = "menu" end
break
end
end
end
for _ = 1, 200 do
battle.frame = battle.frame + 1
battle:updateFx()
end
check(hiddenAtDigStart == true,
"DIG release keeps the digger hidden until SE_SLIDE_MON_UP")
check(kinds.slideUp == true, "Dig release uses slideUp emerge")
check(not kinds.bounce, "Dig release must not bounce (#100)")
check(not picHidden(battle), "Dig hit leaves the user pic shown")
end
S.finish()
+109
View File
@@ -0,0 +1,109 @@
-- 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()
+41
View File
@@ -80,5 +80,46 @@ check(drawn and drawn[1] == canvas and drawn[2] == 0 and drawn[3] == 0,
GBCFX.setLevel(0) GBCFX.setLevel(0)
-- issue #136: Android/iOS refuse GBC FX (shader soft-bricks the APK)
check(GBCFX.isSupported(), "desktop / headless stub supports GBC FX")
local prevSystem = love.system
love.system = { getOS = function() return "Android" end }
check(not GBCFX.isSupported(), "Android reports GBC FX unsupported")
GBCFX.setLevel(3)
eq(GBCFX.level, 0, "setLevel forces OFF on Android")
eq(GBCFX.cycle(), 0, "cycle stays OFF on Android")
local opts = { gbcfx = 4 }
check(GBCFX.applyOptions(opts) == true,
"applyOptions reports a cleared persisted level on Android")
eq(opts.gbcfx, 0, "applyOptions clears opts.gbcfx on Android")
eq(GBCFX.level, 0, "applyOptions leaves level OFF on Android")
check(not GBCFX.active(), "active() is false on Android")
eq(GBCFX.shader(), nil, "shader() is nil on Android")
love.system = { getOS = function() return "iOS" end }
check(not GBCFX.isSupported(), "iOS reports GBC FX unsupported")
love.system = prevSystem
check(GBCFX.isSupported(), "support restores when OS stub is removed")
-- desktop path still applies a level after leaving the mobile gate
GBCFX.applyOptions({ gbcfx = 2 })
eq(GBCFX.level, 2, "applyOptions still sets levels on desktop")
GBCFX.setLevel(0)
-- Options menu hides the GBC FX row on Android
local OptionsMenu = require("src.ui.OptionsMenu")
love.system = { getOS = function() return "Android" end }
local om = OptionsMenu.new({
data = { rulesets = {}, constants = {} },
save = { options = {} },
stack = { pop = function() end },
input = { wasPressed = function() return false end },
modStatus = { available = {} },
})
local hasGbc = false
for _, row in ipairs(om.rows) do
if row.id == "gbcfx" then hasGbc = true end
end
check(not hasGbc, "Options menu omits GBC FX on Android")
love.system = prevSystem
-- === summary === -- === summary ===
S.finish() S.finish()
+53 -3
View File
@@ -88,8 +88,12 @@ local SaveData = require("src.core.SaveData")
local stack2 = newStack() local stack2 = newStack()
local game2 = { data = Data, input = fakeInput, stack = stack2, local game2 = { data = Data, input = fakeInput, stack = stack2,
save = SaveData.newGame() } save = SaveData.newGame() }
game2.save.party = { { species = "PIKACHU", level = 81 } } -- 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.player.map = "HALL_OF_FAME" game2.save.player.map = "HALL_OF_FAME"
game2.save.lastOutdoor = { id = "INDIGO_PLATEAU", x = 9, y = 5 }
local wrote = false local wrote = false
function game2:writeSave() wrote = true; SaveData.save(self.save) end function game2:writeSave() wrote = true; SaveData.save(self.save) end
@@ -109,6 +113,22 @@ eq(#game2.save.hallOfFame, 1, "winning team recorded (SaveHallOfFameTeams)")
local HallOfFame = require("src.ui.HallOfFame") local HallOfFame = require("src.ui.HallOfFame")
check(getmetatable(stack2:top()) == HallOfFame, "induction showcase pushed") 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 -- drive induction + full credits with A held (pages are unskippable; A
-- only advances the induction and the final THE END wait) -- only advances the induction and the final THE END wait)
pressed.a = true pressed.a = true
@@ -129,9 +149,39 @@ local saved = savedRaw and SaveData.decode(savedRaw) or nil
check(saved ~= nil, "save.lua written and decodable") check(saved ~= nil, "save.lua written and decodable")
eq(saved and saved.lastHeal and saved.lastHeal.map, "PALLET_TOWN", eq(saved and saved.lastHeal and saved.lastHeal.map, "PALLET_TOWN",
"wLastBlackoutMap := PALLET_TOWN before the save") "wLastBlackoutMap := PALLET_TOWN before the save")
eq(saved and saved.player and saved.player.map, "HALL_OF_FAME", eq(saved and saved.player and saved.player.map, "REDS_HOUSE_2F",
"save keeps the player in the HALL_OF_FAME room") "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.hallOfFame or {}), 1, "hall of fame team persisted") 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) -- `jp Init`: everything popped, boot sequence pushed (intro -> title)
eq(#stack2.states, 1, "soft reset leaves exactly the boot state") eq(#stack2.states, 1, "soft reset leaves exactly the boot state")
+141
View File
@@ -0,0 +1,141 @@
-- Parity test: Lance room walk-in stays on the floor, and defeat dialogue
-- includes the rival-became-champion after-battle text.
--
-- Sources: scripts/LancesRoom.asm (WalkToLance / LancesRoomLanceEndBattleScript),
-- text/LancesRoom.asm (_LancesRoomLanceAfterBattleText).
-- Self-contained: run via `luajit tests/parity_lance.lua`; also dofile'd
-- by tests/run_tests.lua's aggregator.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data")
if not (Data.maps and Data.maps.LANCES_ROOM) then Data:load() end
local Font = require("src.render.Font")
if not pcall(Font.encode, "A") then Font.load(Data) end
local S = require("tests.harness").suite("parity lance")
local check, eq = S.check, S.eq
local mapScripts = require("data.scripts.init")
local hooks = mapScripts.get("LANCES_ROOM")
check(hooks and hooks.walkInRoute, "LANCES_ROOM exposes walkInRoute")
local D = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } }
-- === (1) walk-in RLE lands on the door-lock trigger (6,11) ===
do
local x, y = 24, 16
local steps = 0
for _, seg in ipairs(hooks.walkInRoute) do
local d = D[seg[1]]
check(d ~= nil, "walk segment direction " .. tostring(seg[1]))
for _ = 1, seg[2] do
x, y = x + d[1], y + d[2]
steps = steps + 1
end
end
eq(x, 6, "walk-in ends at x=6")
eq(y, 11, "walk-in ends at y=11 (door-lock / arena entry)")
check(steps > 0, "walk-in has steps")
end
-- === (2) every cell of the route is walkable with the entrance open ===
do
local Map = require("src.world.Map")
local def = Data.maps.LANCES_ROOM
local tileset = Data.tilesets[def.tileset]
-- mutate a copy of the block list so we don't poison the registry
local blocks = {}
for i, b in ipairs(def.blocks) do blocks[i] = b end
local copy = {}
for k, v in pairs(def) do copy[k] = v end
copy.blocks = blocks
-- LanceShowOrHideEntranceBlocks with the door unlocked
blocks[6 * def.width + 2 + 1] = 0x31
blocks[6 * def.width + 3 + 1] = 0x32
local map = Map.new(copy, tileset)
local x, y = 24, 16
check(map:isWalkableCell(x, y), "start (24,16) walkable")
for _, seg in ipairs(hooks.walkInRoute) do
local d = D[seg[1]]
for _ = 1, seg[2] do
x, y = x + d[1], y + d[2]
check(map:isWalkableCell(x, y),
("walk-in cell (%d,%d) is floor, not void/wall"):format(x, y))
end
end
end
-- === (3) after-battle text names the rival as the real champion ===
do
local after = Data.text._LancesRoomLanceAfterBattleText
check(after ~= nil, "_LancesRoomLanceAfterBattleText extracted")
check(after:find("ELITE", 1, true) or after:find("{RIVAL}", 1, true),
"after text mentions rival / Elite Four")
check(after:find("champion", 1, true) or after:find("CHAMPION", 1, true),
"after text has the champion reveal")
check(after:find("before you", 1, true) or after:find("before you!", 1, true)
or after:find("FOUR before", 1, true),
"after text says rival beat the Elite Four first")
local header = Data:trainerHeader("LancesRoom", 1)
check(header and header.after == "_LancesRoomLanceAfterBattleText",
"trainer header wires the after-battle label")
check(header and header.won == "_LancesRoomLanceEndBattleText",
"trainer header wires the won label")
end
-- === (4) onStep win callback pushes the after text (not only won text) ===
do
local pushed = {}
local game = {
data = Data,
save = {
flags = {},
defeatedTrainers = {},
player = { name = "RED", rival = "BLUE" },
},
stack = {
push = function(_, state)
pushed[#pushed + 1] = state
end,
},
}
local lance = {
def = { name = "LANCESROOM_LANCE", index = 1,
trainerClass = "OPP_LANCE", trainerParty = 1, text = 1 },
id = "LANCES_ROOM:1",
facePlayer = function() end,
}
local engaged = false
local ow = {
npcs = { lance },
player = { cellX = 6, cellY = 2 },
trainerDefeated = function(_, npc)
return game.save.defeatedTrainers[npc.id] == true
end,
engageTrainer = function(_, npc, onDone)
engaged = npc == lance
-- simulate engageTrainer's win path: flag the trainer, then onDone
game.save.defeatedTrainers[npc.id] = true
game.save.flags.EVENT_BEAT_LANCES_ROOM_TRAINER_0 = true
if onDone then onDone() end
end,
}
local handled = hooks.onStep(game, ow, 6, 2)
check(handled == true, "Lance coord trigger engages")
check(engaged, "engageTrainer called for Lance")
check(#pushed == 1 and pushed[1].pages ~= nil, "after-battle TextBox pushed")
-- TextBox.substitute already expanded {RIVAL}/{PLAYER}; page glyphs
-- are opaque, so re-check the source label via the header + that a
-- box was queued on win only.
check(#pushed[1].pages > 0, "after-battle TextBox has pages")
-- loss path must not show after text
pushed = {}
game.save.defeatedTrainers = {}
ow.engageTrainer = function(_, npc, onDone)
-- lose: do not mark defeated
if onDone then onDone() end
end
hooks.onStep(game, ow, 6, 2)
eq(#pushed, 0, "loss does not push after-battle text")
end
print("parity_lance: ok")
+129
View File
@@ -0,0 +1,129 @@
-- Parity: Rocket Hideout B4F Lift Key drop (#90 / #105).
--
-- Oracle: scripts/RocketHideoutB4F.asm RocketHideoutB4FRocket3AfterBattleText
-- (PrintText + CheckAndSetEvent EVENT_ROCKET_DROPPED_LIFT_KEY / ShowObject
-- TOGGLE_ROCKET_HIDEOUT_B4F_ITEM_5). The LIFT_KEY object_event starts
-- hidden; talking to Rocket3 after his defeat reveals it.
--
-- Self-contained: `luajit tests/parity_lift_key.lua`; also dofile'd by
-- tests/run_tests.lua.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data")
if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end
local S = require("tests.harness").suite("parity lift key")
local check, eq = S.check, S.eq
local mapScripts = require("data.scripts.init")
local OverworldState = require("src.world.OverworldController")
local liftKeyObj
for _, o in ipairs(Data.maps.ROCKET_HIDEOUT_B4F.objects) do
if o.name == "ROCKETHIDEOUTB4F_LIFT_KEY" then liftKeyObj = o break end
end
check(liftKeyObj and liftKeyObj.hidden == true,
"LIFT_KEY object_event is extracted as hidden")
local script = mapScripts.talkScript("ROCKET_HIDEOUT_B4F",
"TEXT_ROCKETHIDEOUTB4F_ROCKET3")
check(type(script) == "function",
"Rocket3 after-battle drop is a hand-ported talk handler")
-- Capture TextBox payloads without driving the typewriter / font path.
local realTB = package.loaded["src.render.TextBox"]
package.loaded["src.render.TextBox"] = {
new = function(game, text, done) return { text = text, onDone = done } end,
}
-- Drive the defeated-talk path headless: show after text, then ShowObject.
do
local pushed, engaged
local save = {
flags = {},
inventory = {},
defeatedTrainers = { ROCKET_HIDEOUT_B4F_obj_4 = true },
objectToggles = {},
}
local game = {
data = Data,
save = save,
stack = {
push = function(_, box) pushed = box end,
},
}
local ow = {
map = { id = "ROCKET_HIDEOUT_B4F", def = Data.maps.ROCKET_HIDEOUT_B4F },
npcs = {},
entities = {},
trainerDefeated = function(_, npc)
return save.defeatedTrainers[npc.id] == true
end,
engageTrainer = function() engaged = true end,
}
local npc = {
id = "ROCKET_HIDEOUT_B4F_obj_4",
def = { name = "ROCKETHIDEOUTB4F_ROCKET3", index = 4,
text = "TEXT_ROCKETHIDEOUTB4F_ROCKET3",
trainerClass = "OPP_ROCKET", trainerParty = 18 },
}
local doneCalled = false
script(game, ow, npc, function() doneCalled = true end)
check(not engaged, "defeated Rocket3 talk does not re-engage battle")
check(pushed ~= nil, "defeated Rocket3 talk pushes the after-battle text")
eq(pushed and pushed.text, Data.text._RocketHideoutB4FRocket3AfterBattleText,
"after-battle text is the dropped-key line")
check(not save.flags.EVENT_ROCKET_DROPPED_LIFT_KEY,
"drop flag unset until the text box closes")
check(not OverworldState.objectVisible(save, "ROCKET_HIDEOUT_B4F", liftKeyObj),
"LIFT_KEY still hidden before the after-battle text finishes")
-- dismiss the box: CheckAndSetEvent + ShowObject
pushed.onDone()
check(doneCalled, "talk done() runs after ShowObject")
check(save.flags.EVENT_ROCKET_DROPPED_LIFT_KEY == true,
"EVENT_ROCKET_DROPPED_LIFT_KEY set on first after-battle talk")
check(save.objectToggles.ROCKET_HIDEOUT_B4F
and save.objectToggles.ROCKET_HIDEOUT_B4F.ROCKETHIDEOUTB4F_LIFT_KEY == true,
"ShowObject toggles ROCKETHIDEOUTB4F_LIFT_KEY visible")
check(OverworldState.objectVisible(save, "ROCKET_HIDEOUT_B4F", liftKeyObj),
"LIFT_KEY objectVisible after ShowObject")
-- second talk: reprint only, no double-toggle churn
pushed, doneCalled = nil, false
local togglesBefore = save.objectToggles.ROCKET_HIDEOUT_B4F.ROCKETHIDEOUTB4F_LIFT_KEY
script(game, ow, npc, function() doneCalled = true end)
check(pushed ~= nil, "second after-battle talk still shows text")
pushed.onDone()
check(doneCalled, "second talk done() runs")
eq(save.objectToggles.ROCKET_HIDEOUT_B4F.ROCKETHIDEOUTB4F_LIFT_KEY, togglesBefore,
"second talk does not re-ShowObject")
end
-- Undefeated path still engages the trainer (TalkToTrainer).
do
local engaged = false
local save = { flags = {}, inventory = {}, defeatedTrainers = {} }
local game = {
data = Data, save = save,
stack = { push = function() end },
}
local ow = {
map = { id = "ROCKET_HIDEOUT_B4F" },
trainerDefeated = function() return false end,
engageTrainer = function(_, npc, done)
engaged = true
if done then done() end
end,
}
local npc = {
id = "ROCKET_HIDEOUT_B4F_obj_4",
def = { name = "ROCKETHIDEOUTB4F_ROCKET3", index = 4 },
}
script(game, ow, npc, function() end)
check(engaged, "undefeated Rocket3 talk engages the trainer battle")
end
package.loaded["src.render.TextBox"] = realTB
S.finish()
+100
View File
@@ -0,0 +1,100 @@
-- 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()
+129
View File
@@ -0,0 +1,129 @@
-- Parity / regression for #73: Gen 1 fight-menu SELECT reorders moves.
--
-- Select marks a slot, move the cursor, Select (or A) swaps. Defaults:
-- Tab / either Shift / gamepad Back. Self-contained; 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 Input = require("src.core.Input")
local S = require("tests.harness").suite("parity move swap")
local check, eq = S.check, S.eq
local function freshGame()
local mon = Pokemon.new(Data, "NIDORAN_M", 8)
mon.moves = {
{ id = "TACKLE", pp = 35 },
{ id = "LEER", pp = 30 },
{ id = "HORN_ATTACK", pp = 25 },
{ id = "POISON_STING", pp = 35 },
}
return {
data = Data,
input = Input,
save = {
party = { mon },
player = { name = "RED" },
inventory = {},
options = {},
pokedex = { seen = {}, owned = {} },
flags = {},
money = 0,
},
stack = { push = function() end, pop = function() end, top = function() end },
}
end
local function tapKey(battle, key)
Input:keypressed(key)
Input:step()
battle:update(0)
Input:keyreleased(key)
end
local function tapPad(battle, button)
Input:gamepadpressed(nil, button)
Input:step()
battle:update(0)
Input:gamepadreleased(nil, button)
end
-- Default Select sources all edge the logical select button.
do
Input:init()
for _, key in ipairs({ "tab", "rshift", "lshift" }) do
Input:reset()
Input:keypressed(key)
Input:step()
check(Input:wasPressed("select"), key .. " maps to select")
end
Input:reset()
Input:gamepadpressed(nil, "back")
Input:step()
check(Input:wasPressed("select"), "gamepad back maps to select")
end
-- Fight menu: Select, move, Select swaps slots 1 and 2.
do
Input:init()
local game = freshGame()
local battle = BattleState.newWild(game, "PIDGEY", 5)
battle.phase = "moveSelect"
battle.moveIndex = 1
battle.moveSwapIndex = nil
local a = battle.player.curMoves[1].id
local b = battle.player.curMoves[2].id
tapKey(battle, "tab")
eq(battle.moveSwapIndex, 1, "first Select marks the current slot")
tapKey(battle, "down")
eq(battle.moveIndex, 2, "cursor moved to slot 2")
tapKey(battle, "tab")
check(battle.moveSwapIndex == nil, "second Select clears the mark")
eq(battle.player.curMoves[1].id, b, "slot 1 holds the former slot 2 move")
eq(battle.player.curMoves[2].id, a, "slot 2 holds the former slot 1 move")
eq(battle.player.mon.moves[1].id, b, "party moves table stays in sync")
end
-- Same reorder via gamepad Back (SDL "back" = controller Select/View).
do
Input:init()
local game = freshGame()
local battle = BattleState.newWild(game, "PIDGEY", 5)
battle.phase = "moveSelect"
battle.moveIndex = 1
battle.moveSwapIndex = nil
local a = battle.player.curMoves[1].id
local b = battle.player.curMoves[2].id
tapPad(battle, "back")
tapPad(battle, "dpdown")
tapPad(battle, "back")
eq(battle.player.curMoves[1].id, b, "pad Select swaps slot 1")
eq(battle.player.curMoves[2].id, a, "pad Select swaps slot 2")
end
-- A confirms a pending swap (bag-style), without starting the turn.
do
Input:init()
local game = freshGame()
local battle = BattleState.newWild(game, "PIDGEY", 5)
battle.phase = "moveSelect"
battle.moveIndex = 1
battle.moveSwapIndex = nil
local a = battle.player.curMoves[1].id
local b = battle.player.curMoves[2].id
tapKey(battle, "tab")
tapKey(battle, "down")
tapKey(battle, "z") -- A
eq(battle.phase, "moveSelect", "A completes a pending swap without attacking")
eq(battle.player.curMoves[1].id, b, "A-confirm swapped slot 1")
eq(battle.player.curMoves[2].id, a, "A-confirm swapped slot 2")
end
S.finish()
+117
View File
@@ -0,0 +1,117 @@
-- Parity test: Pewter City youngster gym escort paths.
-- Self-contained: run via `luajit tests/parity_pewter_escort.lua`; also
-- dofile'd by tests/run_tests.lua's aggregator.
--
-- Sources: scripts/PewterCity.asm, engine/overworld/auto_movement.asm
-- (RLEList_PewterGymPlayer / RLEList_PewterGymGuy),
-- engine/events/pewter_guys.asm (PewterGymGuyCoords).
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data")
if not (Data.maps and Data.maps.PEWTER_CITY) then Data:load() end
local S = require("tests.harness").suite("parity pewter escort")
local check, eq = S.check, S.eq
local mapScripts = require("data.scripts.init")
local pewter = mapScripts.get("PEWTER_CITY")
check(pewter and pewter.escort, "PEWTER_CITY exposes the escort tables")
local escort = pewter.escort
local function joined(t) return table.concat(t, ",") end
local D = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } }
eq(#escort.guySteps, 41, "youngster takes 41 steps (RLEList_PewterGymGuy)")
eq(joined({ escort.guySteps[1], escort.guySteps[2], escort.guySteps[#escort.guySteps] }),
"down,down,right", "guy path starts with DOWN×2 and ends RIGHT")
eq(#escort.guyReturnSteps, 41, "return path mirrors the escort")
do
local opp = { up = "down", down = "up", left = "right", right = "left" }
eq(escort.guyReturnSteps[1], opp[escort.guySteps[#escort.guySteps]],
"return starts with opposite of escort's last step")
local gx, gy = 12, 18
for _, d in ipairs(escort.guyReturnSteps) do
gx, gy = gx + D[d][1], gy + D[d][2]
end
check(gx == 35 and gy == 16,
"return path from the gym lands on his spawn (35,16)")
end
-- every east-exit / talk tile lands both sprites by the gym road
local triggers = { { 35, 17 }, { 36, 17 }, { 37, 18 }, { 37, 19 }, { 34, 16 } }
for _, pos in ipairs(triggers) do
local px, py = pos[1], pos[2]
local plan = escort.playerPlan(px, py)
check(plan, ("playerPlan for (%d,%d)"):format(px, py))
local gx, gy = 35, 16
local gi = 0
for _ = 1, plan.guyHeadStart do
gi = gi + 1
local d = escort.guySteps[gi]
gx, gy = gx + D[d][1], gy + D[d][2]
end
for i, ps in ipairs(plan.steps) do
px, py = px + D[ps][1], py + D[ps][2]
local gs = escort.guySteps[plan.guyHeadStart + i]
if gs then
gx, gy = gx + D[gs][1], gy + D[gs][2]
end
end
eq(px, 11, ("player from (%d,%d) ends at x=11"):format(pos[1], pos[2]))
eq(py, 18, ("player from (%d,%d) ends at y=18"):format(pos[1], pos[2]))
eq(gx, 12, ("guy from (%d,%d) ends at x=12"):format(pos[1], pos[2]))
eq(gy, 18, ("guy from (%d,%d) ends at y=18"):format(pos[1], pos[2]))
end
-- the short east-road trigger uses no head-start; the (36,17)/(37,18)
-- tiles pause for one guy step (eight NO_INPUT frames)
eq(escort.playerPlan(35, 17).guyHeadStart, 0, "(35,17) no head-start")
eq(escort.playerPlan(36, 17).guyHeadStart, 1, "(36,17) one-step head-start")
eq(escort.playerPlan(37, 18).guyHeadStart, 1, "(37,18) one-step head-start")
-- path stays off the PEWTER_GYM door warp and the blocked gym building
-- cells: sample the (35,17) walk and confirm no step lands on (16,17)
do
local plan = escort.playerPlan(35, 17)
local px, py = 35, 17
local gx, gy = 35, 16
for i, ps in ipairs(plan.steps) do
px, py = px + D[ps][1], py + D[ps][2]
local gs = escort.guySteps[i]
if gs then gx, gy = gx + D[gs][1], gy + D[gs][2] end
check(not (px == 16 and py == 17),
("player beat %d does not phase onto gym door"):format(i))
check(not (gx == 16 and gy == 17),
("guy beat %d does not phase onto gym door"):format(i))
end
end
-- gym door warp is still where the player is meant to walk afterward
local MapLoader = require("src.world.MapLoader")
local city = MapLoader.load(Data, "PEWTER_CITY")
local w = city:warpAtCell(16, 17)
check(w and w.def.destMap == "PEWTER_GYM", "(16,17) is the Pewter Gym door")
-- object spawn matches pokered object_event 35, 16
local young
for _, o in ipairs(Data.maps.PEWTER_CITY.objects) do
if o.name == "PEWTERCITY_YOUNGSTER" then young = o; break end
end
check(young and young.x == 35 and young.y == 16,
"PEWTERCITY_YOUNGSTER spawns at (35,16)")
-- Brock victory permanently HideObject's him (PewterGym.asm .gymVictory)
do
local victories = require("data.scripts.victories")
local hide = victories["OPP_BROCK#1"] and victories["OPP_BROCK#1"].hide
check(hide, "OPP_BROCK#1 lists objects to hide")
local found
for _, entry in ipairs(hide or {}) do
if entry[1] == "PEWTER_CITY" and entry[2] == "PEWTERCITY_YOUNGSTER" then
found = true
end
end
check(found, "Brock victory hides PEWTERCITY_YOUNGSTER")
end
S.finish()
+57
View File
@@ -0,0 +1,57 @@
-- Parity test: Red's bedroom SNES (#135).
--
-- pokered hidden_events.asm REDS_HOUSE_2F:
-- hidden_event 3, 5, PrintRedSNESText, ANY_FACING
-- PrintRedSNESText shows _RedBedroomSNESText ("{PLAYER} is playing the SNES!").
--
-- Self-contained; run via `luajit tests/parity_red_snes.lua`.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data")
if not (Data.maps and Data.maps.REDS_HOUSE_2F) then Data:load() end
local Font = require("src.render.Font")
if not pcall(Font.encode, "A") then Font.load(Data) end
require("data.scripts.init")
local MapScripts = require("src.script.MapScripts")
local S = require("tests.harness").suite("parity red snes")
local check, eq = S.check, S.eq
check(Data.text._RedBedroomSNESText ~= nil, "_RedBedroomSNESText is extracted")
check(Data.text._RedBedroomSNESText:find("SNES", 1, true),
"_RedBedroomSNESText mentions the SNES")
local hooks = MapScripts.get("REDS_HOUSE_2F")
check(hooks and type(hooks.onInteract) == "function",
"REDS_HOUSE_2F registers onInteract for the SNES")
-- stub just enough of the overworld/game stack to exercise the hook
local pushed
local game = {
data = Data,
save = { player = { name = "RED", rival = "BLUE" } },
stack = {
push = function(_, state) pushed = state end,
},
}
local ow = { player = { facing = "up" } }
eq(hooks.onInteract(game, ow, 0, 1), false,
"bedroom PC tile is not claimed by the SNES hook")
eq(hooks.onInteract(game, ow, 3, 6), false,
"spawn tile is not claimed by the SNES hook")
pushed = nil
eq(hooks.onInteract(game, ow, 3, 5), true,
"SNES at (3,5) consumes the interact")
check(pushed ~= nil and pushed.pages ~= nil, "SNES interact pushes a TextBox")
local flat = table.concat(pushed.pages[1] or {}, "\n")
check(flat:find("SNES", 1, true) and flat:find("RED", 1, true),
"SNES TextBox shows the bedroom SNES line with the player name")
-- ANY_FACING: facing is irrelevant once the faced cell is (3,5)
ow.player.facing = "left"
pushed = nil
eq(hooks.onInteract(game, ow, 3, 5), true,
"SNES still fires when the player faces it from the side")
S.finish()
+77
View File
@@ -0,0 +1,77 @@
-- 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()
+117
View File
@@ -0,0 +1,117 @@
-- Parity: Oak's lab starter-ball Pokédex preview (#110).
-- pret StarterDex (engine/events/starter_dex.asm) temporarily sets the
-- owned bits so ShowPokedexData prints the full entry before the player
-- has caught anything. Also: English R/B prints only the kind string
-- (no " POKéMON" suffix — that clipped "LIZARD" to "LIZARD POKé").
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data")
if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end
local S = require("tests.harness").suite("parity starter dex")
local check, eq = S.check, S.eq
local Font = require("src.render.Font")
Font.load(Data)
local DexEntryMenu = require("src.ui.DexEntryMenu")
local SaveData = require("src.core.SaveData")
local mapScripts = require("data.scripts.init")
local function fakeGame()
return {
data = Data,
save = SaveData.newGame(),
input = { wasPressed = function() return false end },
stack = { pop = function() end },
}
end
local function drawCapture(menu)
local drawn = {}
local saved = Font.draw
Font.draw = function(text, x, y)
drawn[#drawn + 1] = { text = tostring(text), x = x, y = y }
return Font.width(text)
end
menu:draw()
Font.draw = saved
return drawn
end
local function findText(drawn, needle)
for _, d in ipairs(drawn) do
if d.text == needle or d.text:find(needle, 1, true) then return d end
end
return nil
end
-- === 1) unowned entry without forceOwned stays "Data unknown." ===
do
local game = fakeGame()
game.save.pokedex = { seen = {}, owned = {} }
local menu = DexEntryMenu.new(game, "CHARMANDER")
local drawn = drawCapture(menu)
check(findText(drawn, "Data unknown."),
"unowned Charmander shows Data unknown without forceOwned")
check(not findText(drawn, "Obviously prefers"),
"unowned Charmander hides description without forceOwned")
check(not findText(drawn, "HT "),
"unowned Charmander hides height without forceOwned")
end
-- === 2) forceOwned shows full entry without mutating save ===
do
local game = fakeGame()
game.save.pokedex = { seen = {}, owned = {} }
local menu = DexEntryMenu.new(game, { species = "CHARMANDER", forceOwned = true })
check(menu.forceOwned, "forceOwned flag sticks on the menu")
local drawn = drawCapture(menu)
check(findText(drawn, "Obviously prefers"),
"forceOwned Charmander shows dex description")
check(findText(drawn, "HT "),
"forceOwned Charmander shows height")
check(not findText(drawn, "Data unknown."),
"forceOwned Charmander does not show Data unknown")
check(not game.save.pokedex.owned.CHARMANDER,
"forceOwned preview does not mark Charmander owned")
end
-- === 3) kind is the bare English string (no POKéMON suffix) ===
do
local game = fakeGame()
game.save.pokedex = { seen = {}, owned = { CHARMANDER = true } }
local menu = DexEntryMenu.new(game, "CHARMANDER")
local drawn = drawCapture(menu)
local kind = findText(drawn, "LIZARD")
check(kind and kind.text == "LIZARD",
"kind draws as LIZARD only (English R/B PlaceString)")
check(not findText(drawn, "POKéMON"),
"kind line does not append POKéMON")
check(kind.x + Font.width(kind.text) <= 160,
"LIZARD kind fits on-screen (no clip)")
end
-- === 4) Oak's lab starter scripts request forceOwned ===
do
local balls = {
"TEXT_OAKSLAB_CHARMANDER_POKE_BALL",
"TEXT_OAKSLAB_SQUIRTLE_POKE_BALL",
"TEXT_OAKSLAB_BULBASAUR_POKE_BALL",
}
for _, textId in ipairs(balls) do
local script = mapScripts.talkScript("OAKS_LAB", textId)
local found
for _, row in ipairs(script) do
if row[1] == "push_screen" and row[2] == "DexEntryMenu" then
found = row[3]
break
end
end
check(type(found) == "table" and found.forceOwned == true
and type(found.species) == "string",
textId .. " pushes DexEntryMenu with forceOwned")
end
end
S.finish()
+125
View File
@@ -0,0 +1,125 @@
-- 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()
+98
View File
@@ -0,0 +1,98 @@
-- Parity test: Pokemon Tower 5F purified-zone heal pad (#81).
--
-- pokered scripts/PokemonTower5F.asm PokemonTower5FDefaultScript:
-- coords (10,8)/(11,8)/(10,9)/(11,9); CheckAndSetEvent
-- EVENT_IN_PURIFIED_ZONE so the heal fires once until the player
-- leaves; HealParty -> GBFadeOutToWhite -> Delay3 x2 ->
-- GBFadeInFromWhite -> _PokemonTower5FPurifiedZoneText (no heal jingle).
--
-- Self-contained; run via `luajit tests/parity_tower_heal_pad.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 heal pad")
local check, eq = S.check, S.eq
local M = dofile("data/scripts/story3.lua")
local tower = M.POKEMON_TOWER_5F
check(tower ~= nil and type(tower.onStep) == "function",
"POKEMON_TOWER_5F has an onStep heal-pad trigger")
local function cmds(rows)
local out = {}
for _, row in ipairs(rows) do out[#out + 1] = row[1] end
return out
end
local function gameWith(flags)
return { save = { flags = flags or {} } }
end
local function owWith()
local ran = {}
return {
runner = {
isRunning = function() return false end,
run = function(_, rows) ran[#ran + 1] = rows end,
},
}, ran
end
-- ---- 1. pad coords + leave clears latch --------------------------------
do
local game = gameWith()
local ow, ran = owWith()
check(not tower.onStep(game, ow, 9, 8), "off-pad X does not heal")
check(not tower.onStep(game, ow, 10, 7), "off-pad Y does not heal")
eq(#ran, 0, "no script off the pad")
end
do
local game = gameWith()
local ow, ran = owWith()
check(tower.onStep(game, ow, 10, 8), "first step on (10,8) heals")
check(game.save.flags.EVENT_IN_PURIFIED_ZONE, "latches EVENT_IN_PURIFIED_ZONE")
eq(#ran, 1, "heal script runs once")
check(tower.onStep(game, ow, 11, 9),
"staying on the pad still consumes the step (BIT_NO_BATTLES)")
eq(#ran, 1, "heal does not re-fire while still on the pad")
check(not tower.onStep(game, ow, 12, 9), "stepping off clears and returns false")
check(not game.save.flags.EVENT_IN_PURIFIED_ZONE,
"EVENT_IN_PURIFIED_ZONE resets off the pad")
check(tower.onStep(game, ow, 11, 8), "re-entering the pad heals again")
eq(#ran, 2, "a fresh visit runs the heal script again")
end
-- ---- 2. all four pad tiles trigger -------------------------------------
for _, cell in ipairs({ { 10, 8 }, { 11, 8 }, { 10, 9 }, { 11, 9 } }) do
local game = gameWith()
local ow, ran = owWith()
check(tower.onStep(game, ow, cell[1], cell[2]),
("pad tile (%d,%d) heals"):format(cell[1], cell[2]))
eq(#ran, 1, ("pad tile (%d,%d) queued a script"):format(cell[1], cell[2]))
end
-- ---- 3. heal sequence matches pokered order ----------------------------
do
local game = gameWith()
local ow, ran = owWith()
tower.onStep(game, ow, 10, 9)
local rows = ran[1]
check(rows ~= nil, "heal rows captured")
local sequence = table.concat(cmds(rows), ",")
eq(sequence, "heal_party,fade,wait,wait,fade,show_text",
"Tower pad sequence is heal → fade out → Delay3×2 → fade in → text")
eq(rows[2][3], "white", "fades out to white")
eq(rows[3][2], 3, "first Delay3 is 3 frames")
eq(rows[4][2], 3, "second Delay3 is 3 frames")
eq(rows[5][3], "white", "fades in from white")
eq(rows[6][2], "_PokemonTower5FPurifiedZoneText",
"shows the purified-zone text")
for _, row in ipairs(rows) do
check(row[1] ~= "play_once", "no Music_PkmnHealed on the Tower pad")
end
end
S.finish()
+138
View File
@@ -0,0 +1,138 @@
-- 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()
+7 -2
View File
@@ -222,16 +222,21 @@ check(runScript("VERMILION_TRADE_HOUSE", "TEXT_VERMILIONTRADEHOUSE_LITTLE_GIRL")
"DUX post-trade script completes") "DUX post-trade script completes")
shownIs({ "_AfterTrade3Text" }, "DUX uses the happy dialogset") shownIs({ "_AfterTrade3Text" }, "DUX uses the happy dialogset")
-- === 9) Celadon Eevee: no confirm prompt, GotMonText, ball hidden === -- === 9) Celadon Eevee: no confirm prompt, AskName then GotMonText,
-- ball hidden (GivePokemon -> AddPartyMon AskName; script still
-- prints GotMonText after the silent give_pokemon row) ===
local EEVEE_MAP, EEVEE_BALL = local EEVEE_MAP, EEVEE_BALL =
"CELADON_MANSION_ROOF_HOUSE", "CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL" "CELADON_MANSION_ROOF_HOUSE", "CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL"
local EEVEE_TEXT = "TEXT_CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL" local EEVEE_TEXT = "TEXT_CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL"
Game.save = SaveData.newGame() Game.save = SaveData.newGame()
check(runScript(EEVEE_MAP, EEVEE_TEXT), "Eevee ball script completes") check(runScript(EEVEE_MAP, EEVEE_TEXT), "Eevee ball script completes")
shownIs({ "_GotMonText" }, "Eevee gives immediately (no ask prompt)") shownIs({ "_DoYouWantToNicknameText", "_GotMonText" },
"Eevee gives immediately (nickname ask, then GotMonText)")
eq(#Game.save.party, 1, "Eevee joins the party") 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].species, "EEVEE", "gift species is EEVEE")
eq(Game.save.party[1].level, 25, "Eevee is level 25") 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") 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") eq(toggleOf(EEVEE_MAP, EEVEE_BALL), false, "the poke ball object is hidden")
check(Game.save.pokedex.owned.EEVEE, "Eevee registered owned") check(Game.save.pokedex.owned.EEVEE, "Eevee registered owned")
+49
View File
@@ -169,4 +169,53 @@ do
"tile behind the trainer never engages (CheckPlayerIsInFrontOfSprite)") "tile behind the trainer never engages (CheckPlayerIsInFrontOfSprite)")
end end
-- === (4) Route 9 Bug Catcher: 4-tiles-north screen Y $fc quirk ===
-- Object 7 = SPRITE_YOUNGSTER at (22,2), STAY DOWN, OPP_BUG_CATCHER
-- (data/maps/objects/Route9.asm); header range 4 (Route9TrainerHeader6).
-- A ledge sits on (22,5); the walkable tile below it is (22,6) at cell
-- distance 4. Cell math would engage, but pokered's unsigned screen-Y
-- distance with SPRITESTATEDATA1_YPIXELS=$fc is $c0 > range<<4 ($40), so
-- the trainer must not challenge through that ledge (GitHub #76).
local ROUTE9_BUG_CATCHER = 7
local function freshRoute9(px, py, facing)
while Game.stack:top() do Game.stack:pop() end
Game.save = SaveData.newGame()
Input:init()
OW.engaging = false
OW.emote = nil
Game.stack:push(OW, "ROUTE_9", px, py, facing)
local ow = Game.stack:top()
local trainer
for _, npc in ipairs(ow.npcs) do
if npc.def.index == ROUTE9_BUG_CATCHER then trainer = npc end
end
return ow, trainer
end
do
local ow, trainer = freshRoute9(22, 6, "up")
check(trainer ~= nil and trainer.cellX == 22 and trainer.cellY == 2,
"Route 9 Bug Catcher stands at (22,2)")
eq(trainer.facing, "down", "Bug Catcher faces down (STAY DOWN)")
local header = Data:trainerHeader("Route9", ROUTE9_BUG_CATCHER)
eq(header and header.range, 4, "Bug Catcher sight range is 4")
for _ = 1, 10 do frame(ow) end
check(not ow.engaging,
"distance 4 south of DOWN trainer: no engage (screen Y $fc quirk)")
-- same plateau, still in pixel range: distance 2 and 3 must engage
local ow2, t2 = freshRoute9(22, 4, "up")
local guard = 0
while not ow2.engaging and guard < 10 do guard = guard + 1; frame(ow2) end
check(ow2.engaging and t2.cellY == 2,
"distance 2 on the upper plateau still engages")
local ow3 = freshRoute9(22, 3, "up")
guard = 0
while not ow3.engaging and guard < 10 do guard = guard + 1; frame(ow3) end
check(ow3.engaging, "distance 3 on the upper plateau still engages")
end
S.finish() S.finish()
+63
View File
@@ -0,0 +1,63 @@
-- 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()

Some files were not shown because too many files have changed in this diff Show More