Compare commits

...

13 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
bryanthaboi 1c4515eaaa squashy squash (#127) 2026-07-23 15:42:54 -04:00
bryanthaboi c04b93ce1c pokecenter fix (#126) 2026-07-23 15:17:20 -04:00
bryanthaboi 2c9970a643 Frame Cap (#124) 2026-07-23 14:35:26 -04:00
119 changed files with 9298 additions and 843 deletions
+42 -17
View File
@@ -1,18 +1,19 @@
# Pokemon Gen 1 Recompilation Project
A native LÖVE2D recreation of Pokemon Red. The engine and map behavior are
hand-written Lua; game data and graphics are decoded from a ROM supplied by
the player.
A native LÖVE2D recreation of Pokemon Red and Blue. The engine and map
behavior are hand-written Lua; game data and graphics are decoded from a ROM
supplied by the player.
SUPPORT AND ANNOUNCEMENTS: [Discord](https://bois.icu)
This project does not include a ROM, emulate the Game Boy, transpile assembly,
or download a disassembly. A canonical US Pokemon Red ROM is the only game
content input.
or download a disassembly. A canonical US Pokemon Red or Blue ROM is the only
game content input.
The ROM is verified, used during import, and then released from memory. It is
not copied into the cache. Later launches load the private generated cache and
do not ask for the ROM again.
do not ask for the ROM again. Red and Blue can both be imported and played
side by side.
## 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
automatically.
Only the canonical 1 MiB US Red ROM is accepted. The importer verifies SHA-1
`ea9bcae617fdf159b045185467ae58b2e4a48b9a` before creating any game data.
Only the canonical 1 MiB US Red and Blue ROMs are accepted. The importer
verifies SHA-1 before creating any game data:
- Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a`
- Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2`
The packaged app contains neither a ROM nor pre-extracted game data. Music,
sound effects, and cries are synthesized while the game runs from compact
audio channel programs copied out of the verified ROM.
## Controls
arrow keys or WASD move; Z, Enter, or Space is A; X or Backspace is
B; Escape opens START. F1 saves and F2 loads. Controllers are supported.
| Action | Keyboard | Controller |
|--------|----------|------------|
| 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
Requires LÖVE 11.x. Place the ROM in the project folder and double-click
`Play-Mac.command` or `Play-Windows.bat`, or run:
Requires LÖVE 11.x. Place a Red or Blue ROM in the project folder and
double-click `Play-Mac.command` or `Play-Windows.bat`, or run:
```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
```
@@ -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) >
the pret band of decompiling maniacs > and their
[pokered](https://github.com/pret/pokered) disassembly.
## Wanna Support My Work?
[![Buy Me a Coffee](https://img.shields.io/badge/Buy_Me_a_Coffee-FFDD00?style=for-the-badge&logo=buy-me-a-coffee&logoColor=black)](https://buymeacoffee.com/bryanthaboi)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 370 KiB

After

Width:  |  Height:  |  Size: 753 KiB

+5 -18
View File
@@ -1,15 +1,11 @@
-- Pewter City flavor dialogue (pokered/scripts/PewterCity.asm).
-- 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
-- museum, YOUNGSTER walking the player to the gym) is scripted NPC
-- movement + a wPewterCityCurScript state machine that steers the
-- player off-map; the YOUNGSTER's escort to the gym is handled on this
-- map by story5.lua's onStep gate at the east exit (before
-- EVENT_BEAT_BROCK), while the SUPER_NERD1 museum escort is not ported.
-- Here we only port the real YES/NO-branched flavor text these NPCs
-- speak when talked to.
-- The YOUNGSTER's gym escort (talk + east-exit onStep) lives in
-- story5.lua so the lockstep RLE walk is not overwritten by this
-- flavor merge. SUPER_NERD1's museum escort is not ported; only the
-- YES/NO-branched flavor text is here.
local M = {}
@@ -61,15 +57,6 @@ M.PEWTER_CITY = {
end)
end,
-- PewterCityYoungsterText (scripts/PewterCity.asm): the "follow
-- me" line the youngster says when the player is stopped from
-- leaving Pewter east before beating Brock; the actual gate/step
-- block is handled by story5.lua's onStep for this map.
TEXT_PEWTERCITY_YOUNGSTER = function(game, ow, npc, done)
local t = text(game)
push(game, t._PewterCityYoungsterYoureATrainerFollowMeText
or "You're a trainer\nright? BROCK's\nlooking for new\nchallengers!\nFollow me!", done)
end,
},
}
+22
View File
@@ -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 = {
talk = {
-- SilphCo9FNurseText (pokered/scripts/SilphCo9F.asm):
-- before EVENT_BEAT_SILPH_CO_GIOVANNI: heals the party and shows
-- "You look tired..." then "Don't give up!"; after the event, just
-- says thanks. Nurse texts are not in data/generated/text.lua, so
-- the exact pokered/text/SilphCo9F.asm strings are used as literals.
-- before EVENT_BEAT_SILPH_CO_GIOVANNI: heals the party, white fade
-- (Delay3 between out/in; no Music_PkmnHealed), then "Don't give
-- up!"; after the event, just says thanks. Nurse texts are not in
-- data/generated/text.lua, so the exact pokered/text/SilphCo9F.asm
-- strings are used as literals.
TEXT_SILPHCO9F_NURSE = {
{ "face_player" },
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
{ "jump_if_true", 8 },
{ "show_text", "You look tired!\nYou should take a\nquick nap!" },
{ "heal_party" },
{ "show_text", "Don't give up!" },
{ "jump", 9 },
{ "show_text", "Thank you so\nmuch!" },
{ "face_player" }, -- 1
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" }, -- 2
{ "jump_if_true", 11 }, -- 3
{ "show_text", "You look tired!\nYou should take a\nquick nap!" }, -- 4
{ "heal_party" }, -- 5
{ "fade", "out", "white" }, -- 6
{ "wait", 3 }, -- 7 Delay3
{ "fade", "in", "white" }, -- 8
{ "show_text", "Don't give up!" }, -- 9
{ "jump", "end" }, -- 10
{ "show_text", "Thank you so\nmuch!" }, -- 11
},
},
},
+1
View File
@@ -28,6 +28,7 @@ local files = {
"data.scripts.flavor.pokemon_fan_club",
"data.scripts.flavor.power_plant",
"data.scripts.flavor.reds_house_1f",
"data.scripts.flavor.reds_house_2f",
"data.scripts.flavor.route11_gate_2f",
"data.scripts.flavor.route18_gate_2f",
"data.scripts.flavor.route_12_gate_2f",
+52 -29
View File
@@ -24,17 +24,23 @@ local function starterBall(askText, species, choseFlag, ownBall,
{ "check_flag", "EVENT_FOLLOWED_OAK_INTO_LAB" }, -- 3
{ "jump_if_false", 20 }, -- 4
-- the Pokédex "new species" entry shows before the ask (predef
-- StarterDex ahead of OaksLabYouWant...Text)
{ "push_screen", "DexEntryMenu", species }, -- 5
-- StarterDex ahead of OaksLabYouWant...Text). StarterDex temporarily
-- sets the owned bits so ShowPokedexData prints height/weight/text;
-- forceOwned is that bypass without mutating save.pokedex.owned.
{ "push_screen", "DexEntryMenu",
{ species = species, forceOwned = true } }, -- 5
{ "ask", askText }, -- 6
{ "jump_if_false", 21 }, -- 7
{ "give_pokemon", species, 5 }, -- 8
{ "set_flag", "EVENT_GOT_STARTER" }, -- 9
{ "set_flag", choseFlag }, -- 10
-- OaksLab.asm prints ReceivedMon then AddPartyMon (AskName lives
-- inside give_pokemon). Show the received text first so the
-- nickname prompt follows "you got X", matching Gen1.
{ "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 8
{ "give_pokemon", species, 5 }, -- 9
{ "set_flag", "EVENT_GOT_STARTER" }, -- 10
{ "set_flag", choseFlag }, -- 11
-- POKé BALLs are not handed out here in the original -- Oak gives
-- them later, at OaksLabOak1Text's .give_poke_balls beat once the
-- player has beaten the Route 22 rival (see TEXT_OAKSLAB_OAK1 below)
{ "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 11
{ "hide_object", "OAKS_LAB", ownBall }, -- 12
-- the rival walks to the countering ball (around the furniture)
{ "move_npc_to", 1, rivalBallX, 4 }, -- 13
@@ -61,40 +67,44 @@ return {
TEXT_OAKSLAB_OAK1 = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_GOT_OAKS_PARCEL" }, -- 2
{ "jump_if_false", 14 }, -- 3
{ "jump_if_false", 16 }, -- 3
{ "check_flag", "EVENT_OAK_GOT_PARCEL" }, -- 4
{ "jump_if_true", 14 }, -- 5
{ "jump_if_true", 16 }, -- 5
{ "show_text", "_OaksLabOak1DeliverParcelText" }, -- 6
{ "take_item", "OAKS_PARCEL", 1 }, -- 7
{ "set_flag", "EVENT_OAK_GOT_PARCEL" }, -- 8
{ "show_text", "_OaksLabOak1PokemonAroundTheWorldText" }, -- 9
{ "set_flag", "EVENT_GOT_POKEDEX" }, -- 10
-- OaksLab.asm OakGivesPokedex: HideObject TOGGLE_POKEDEX_1/2
-- so the table sprites leave with the gift (#106).
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX1" }, -- 11
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX2" }, -- 12
-- the Pokédex swaps Viridian's two old men (OaksLab.asm:602-606:
-- HideObject TOGGLE_LYING_OLD_MAN / ShowObject TOGGLE_OLD_MAN).
-- Until this ran, the walking man at (17,5) -- who owns the coffee
-- ask and the catch tutorial -- stayed OFF for the whole game
-- (toggleable_objects.asm seeds him OFF, the sleeper ON).
{ "hide_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY" }, -- 11
{ "show_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN" }, -- 12
{ "jump", 32 }, -- 13
{ "check_flag", "EVENT_GOT_STARTER" }, -- 14
{ "jump_if_false", 29 }, -- 15
{ "check_item", "POKE_BALL" }, -- 16
{ "jump_if_true", 27 }, -- 17
{ "check_flag", "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE" }, -- 18
{ "jump_if_false", 31 }, -- 19
{ "check_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 20
{ "jump_if_true", 27 }, -- 21
{ "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 22
{ "give_item", "POKE_BALL", 5, false }, -- 23
{ "show_text", "_OaksLabOak1ReceivedPokeballsText" }, -- 24
{ "show_text", "_OaksLabGivePokeballsExplanationText" }, -- 25
{ "jump", 32 }, -- 26
{ "show_text", "_OaksLabOak1ComeSeeMeSometimesText" }, -- 27
{ "jump", 32 }, -- 28
{ "show_text", "_OaksLabOak1WhichPokemonDoYouWantText" }, -- 29
{ "jump", 32 }, -- 30
{ "show_text", "_OaksLabOak1RaiseYourYoungPokemonText" }, -- 31 (32 = end)
{ "hide_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY" }, -- 13
{ "show_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN" }, -- 14
{ "jump", "end" }, -- 15
{ "check_flag", "EVENT_GOT_STARTER" }, -- 16
{ "jump_if_false", 31 }, -- 17
{ "check_item", "POKE_BALL" }, -- 18
{ "jump_if_true", 29 }, -- 19
{ "check_flag", "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE" }, -- 20
{ "jump_if_false", 33 }, -- 21
{ "check_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 22
{ "jump_if_true", 29 }, -- 23
{ "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 24
{ "give_item", "POKE_BALL", 5, false }, -- 25
{ "show_text", "_OaksLabOak1ReceivedPokeballsText" }, -- 26
{ "show_text", "_OaksLabGivePokeballsExplanationText" }, -- 27
{ "jump", "end" }, -- 28
{ "show_text", "_OaksLabOak1ComeSeeMeSometimesText" }, -- 29
{ "jump", "end" }, -- 30
{ "show_text", "_OaksLabOak1WhichPokemonDoYouWantText" }, -- 31
{ "jump", "end" }, -- 32
{ "show_text", "_OaksLabOak1RaiseYourYoungPokemonText" }, -- 33
},
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
-- the way out for the first battle (scripts/OaksLab.asm
-- OaksLabScript8 / OaksLabRivalChallenge)
+15 -7
View File
@@ -1,15 +1,23 @@
-- Hand-ported from pret/pokered scripts/RedsHouse1F.asm.
-- Mom (RedsHouse1FMomText, text_asm) heals the party and shows the
-- "you should rest" / "looking great" dialogue. The intro "wake up"
-- branch is tied to the unported intro cutscene, so the heal path is used.
-- Mom (RedsHouse1FMomText): pre-starter shows the wake-up / Oak tip;
-- after EVENT_GOT_STARTER, RedsHouse1FMomHealScript fades to white,
-- heals, plays MUSIC_PKMN_HEALED, fades back, then "looking great".
return {
talk = {
TEXT_REDSHOUSE1F_MOM = {
{ "face_player" },
{ "show_text", "_RedsHouse1FMomYouShouldRestText" },
{ "heal_party" },
{ "show_text", "_RedsHouse1FMomLookingGreatText" },
{ "face_player" }, -- 1
{ "check_flag", "EVENT_GOT_STARTER" }, -- 2
{ "jump_if_true", 6 }, -- 3
{ "show_text", "_RedsHouse1FMomWakeUpText" }, -- 4
{ "jump", "end" }, -- 5
-- RedsHouse1FMomHealScript
{ "show_text", "_RedsHouse1FMomYouShouldRestText" }, -- 6
{ "fade", "out", "white" }, -- 7 GBFadeOutToWhite
{ "heal_party" }, -- 8
{ "play_once", "Music_PkmnHealed" }, -- 9 wait + restore map
{ "fade", "in", "white" }, -- 10 GBFadeInFromWhite
{ "show_text", "_RedsHouse1FMomLookingGreatText" }, -- 11
},
},
}
+211 -73
View File
@@ -222,17 +222,55 @@ M.BILLS_HOUSE = {
-- repair saves that already got the ticket under the old collapsed
-- script (the monster never hidden, human Bill never shown)
onEnter = function(game, ow)
if game.save.flags.EVENT_GOT_SS_TICKET
and not game.save.flags.EVENT_USED_CELL_SEPARATOR_ON_BILL then
local flags = game.save.flags
if flags.EVENT_GOT_SS_TICKET
and not flags.EVENT_USED_CELL_SEPARATOR_ON_BILL then
local Commands = require("src.script.Commands")
local ctx = { game = game, save = game.save, overworld = ow }
Commands.hide_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL_POKEMON")
Commands.show_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL1")
game.save.flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = true
game.save.flags.EVENT_USED_CELL_SEPARATOR_ON_BILL = true
game.save.flags.EVENT_MET_BILL = true
game.save.flags.EVENT_MET_BILL_2 = true
flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = true
flags.EVENT_USED_CELL_SEPARATOR_ON_BILL = true
flags.EVENT_MET_BILL = true
flags.EVENT_MET_BILL_2 = true
end
-- After Route25ToggleBillsScript, BILL2 is the visible human Bill
-- ("check out my rare POKéMON"). Re-apply on enter so a mid-house
-- load still matches the toggles if the pool was rebuilt.
if flags.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING then
local Commands = require("src.script.Commands")
local ctx = { game = game, save = game.save, overworld = ow }
Commands.hide_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL_POKEMON")
Commands.hide_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL1")
Commands.show_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL2")
end
end,
}
-- -------------------------------------------------------------------
-- Route 25 outside Bill's house (scripts/Route25.asm
-- Route25ToggleBillsScript): leaving after the SS Ticket arms the
-- Eevee PC list and swaps human Bill to his post-help dialogue NPC.
-- Leaving mid-quest (Bill still in the machine) puts the monster back.
-- -------------------------------------------------------------------
M.ROUTE_25 = {
onEnter = function(game, ow)
local flags = game.save.flags
if flags.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING then return end
local Commands = require("src.script.Commands")
local ctx = { game = game, save = game.save, overworld = ow }
if not flags.EVENT_MET_BILL_2 then
flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = nil
Commands.show_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL_POKEMON")
return
end
if not flags.EVENT_GOT_SS_TICKET then return end
flags.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING = true
-- TOGGLE_NUGGET_BRIDGE_GUY (ROUTE24_COOLTRAINER_M1)
Commands.hide_object(ctx, "ROUTE_24", "ROUTE24_COOLTRAINER_M1")
Commands.hide_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL1")
Commands.show_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL2")
end,
}
@@ -256,25 +294,40 @@ M.VERMILION_CITY = {
end,
-- VermilionCityDefaultScript's per-frame SSAnneTicketCheckCoords check:
-- the unguarded cell (18,30) just west of the sailor leads straight
-- onto the dock warp, so stepping onto it heading for the dock gets
-- ticket-checked (and turned back once the ship has sailed) without
-- the player ever pressing A. The sailor himself never disappears.
-- onto the dock warp. Stepping onto it facing down always runs the
-- sailor dialog (DisplayTextID TEXT_VERMILIONCITY_SAILOR1); only a
-- ticket while the ship is still docked lets the player continue --
-- otherwise they are walked back up. The sailor himself never hides.
onStep = function(game, ow, x, y)
if x ~= 18 or y ~= 30 then return false end
if ow.player.facing ~= "down" then return false end
local f = game.save.flags
local Flags = require("src.script.Flags")
local t = game.data.text
local TextBox = require("src.render.TextBox")
if f.EVENT_SS_ANNE_LEFT then
local shipLeft = Flags.get(game.save, "EVENT_SS_ANNE_LEFT")
local hasTicket = (game.save.inventory.S_S_TICKET or 0) > 0
if shipLeft then
game.stack:push(TextBox.new(game,
t._VermilionCitySailor1ShipSetSailText or "The ship set sail.",
function() ow:scriptMove(ow.player, "up", 1) end))
return true
end
if (game.save.inventory.S_S_TICKET or 0) > 0 then return false end
-- Walk-past is never facing-right / inFrontOfOrBehindGuardCoords, so
-- VermilionCitySailor1Text always takes .greet_player_and_check_ticket:
-- DoYouHaveATicket, then FlashedTicket (allow through) or YouNeedATicket
-- (walk back). Returning true while the box is up also blocks the
-- dock warp on this step.
local ask = t._VermilionCitySailor1DoYouHaveATicketText
or "Welcome to S.S.\nANNE!\fExcuse me, do you\nhave a ticket?"
if hasTicket then
game.stack:push(TextBox.new(game,
ask .. "\f"
.. (t._VermilionCitySailor1FlashedTicketText
or "{PLAYER} flashed\nthe S.S.TICKET!")))
return true
end
game.stack:push(TextBox.new(game,
(t._VermilionCitySailor1WelcomeToSSAnneText or "Welcome to S.S.\nANNE!")
.. "\f"
ask .. "\f"
.. (t._VermilionCitySailor1YouNeedATicketText
or "You need a ticket\nto get aboard."),
function() ow:scriptMove(ow.player, "up", 1) end))
@@ -288,7 +341,7 @@ M.VERMILION_CITY = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_SS_ANNE_LEFT" }, -- 2
{ "jump_if_true", 11 }, -- 3
{ "show_text", "_VermilionCitySailor1WelcomeToSSAnneText" }, -- 4
{ "show_text", "_VermilionCitySailor1DoYouHaveATicketText" }, -- 4
{ "check_item", "S_S_TICKET" }, -- 5
{ "jump_if_false", 9 }, -- 6
{ "show_text", "_VermilionCitySailor1FlashedTicketText" }, -- 7
@@ -318,18 +371,21 @@ M.SS_ANNE_2F = {
M.SS_ANNE_CAPTAINS_ROOM = {
talk = {
-- SSAnneCaptainsRoomCaptainText: after the rub line's text_asm tail,
-- pokered plays MUSIC_PKMN_HEALED (scripts/SSAnneCaptainsRoom.asm).
TEXT_SSANNECAPTAINSROOM_CAPTAIN = {
{ "check_flag", "EVENT_GOT_HM01" }, -- 1
{ "jump_if_true", 9 }, -- 2
{ "jump_if_true", 10 }, -- 2
{ "show_text", "_SSAnneCaptainsRoomRubCaptainsBackText" }, -- 3
{ "show_text", "_SSAnneCaptainsRoomCaptainIFeelMuchBetterText" }, -- 4
{ "play_once", "Music_PkmnHealed" }, -- 4
{ "show_text", "_SSAnneCaptainsRoomCaptainIFeelMuchBetterText" }, -- 5
-- give-then-print like scripts/SSAnneCaptainsRoom.asm (GiveItem
-- fills wStringBuffer; the received text reads it)
{ "give_item", "HM_CUT", 1, false }, -- 5
{ "show_text", "_SSAnneCaptainsRoomCaptainReceivedHM01Text" }, -- 6
{ "set_flag", "EVENT_GOT_HM01" }, -- 7
{ "jump", 10 }, -- 8
{ "show_text", "_SSAnneCaptainsRoomCaptainNotSickAnymoreText" }, -- 9
{ "give_item", "HM_CUT", 1, false }, -- 6
{ "show_text", "_SSAnneCaptainsRoomCaptainReceivedHM01Text" }, -- 7
{ "set_flag", "EVENT_GOT_HM01" }, -- 8
{ "jump", 11 }, -- 9
{ "show_text", "_SSAnneCaptainsRoomCaptainNotSickAnymoreText" }, -- 10
},
},
}
@@ -560,7 +616,8 @@ M.SILPH_CO_11F = {
-- -------------------------------------------------------------------
-- Victory Road boulder switches (scripts/VictoryRoad1F/2F/3F.asm):
-- a boulder resting on a switch removes a barrier block; the 3F hole
-- drops a boulder down to the 2F switch.
-- 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)
@@ -626,6 +683,18 @@ M.VICTORY_ROAD_3F = {
Commands.show_object(ctx, "VICTORY_ROAD_2F", "VICTORYROAD2F_BOULDER")
end
end,
-- scripts/VictoryRoad3F.asm VictoryRoad3FDefaultScript: the same hole
-- is a dungeon warp for the player (wDungeonWarpDestinationMap =
-- VICTORY_ROAD_2F, wWhichDungeonWarp = 2 -> DungeonWarpData 22,16).
-- Mirrors POKEMON_MANSION_3F.onStep; CAVERN $22 is walkable so the
-- fall is onStep, not a collision block.
onStep = function(game, ow, x, y)
if x == 23 and y == 15 then
ow:startWarpTo("VICTORY_ROAD_2F", 22, 16, ow.player.facing)
return true
end
return false
end,
}
-- -------------------------------------------------------------------
@@ -638,6 +707,16 @@ M.VICTORY_ROAD_3F = {
-- IndigoPlateauLobby.asm) so the champion is re-fightable on rematches,
-- like pokered's re-entry cutscene. EVENT_BEAT_CHAMPION_RIVAL stays set
-- forever (postgame gates like the Cerulean cave guard read it).
--
-- pokered forces the fight on map entry: Agatha's victory arms
-- SCRIPT_CHAMPIONSROOM_PLAYER_ENTERS (scripts/AgathasRoom.asm), and
-- ChampionsRoomPlayerEntersScript then runs RivalEntrance_RLEMovement
-- (up 1, right 1, up 3) before ChampionsRoomRivalReadyToBattleScript.
-- The rival object has no trainer header / sight range, so without that
-- entrance script the player can walk past (issue #99). We arm on the
-- run flag instead of Agatha's victory bit: same observable effect for
-- first clear and Indigo rematches, without a cross-map script pointer.
--
-- scripts/ChampionsRoom.asm ChampionsRoomRivalDefeatedScript ->
-- OakArrivesScript -> OakCongratulatesPlayerScript ->
-- OakDisappointedWithRivalScript -> OakComeWithMeScript -> OakExitsScript,
@@ -646,43 +725,69 @@ M.VICTORY_ROAD_3F = {
-- itself is NOT run here: it belongs to the HALL_OF_FAME room script
-- (scripts/HallOfFame.asm), so we set a one-shot marker and warp; the room
-- onEnter (M.HALL_OF_FAME below) drives the HoF Oak speech + record.
local championsRoomRivalScript = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 2
{ "jump_if_true", 25 }, -- 3 past end
{ "show_text", "_ChampionsRoomRivalIntroText" }, -- 4
{ "rival_battle", "OPP_RIVAL3", 1 }, -- 5
{ "jump_if_false", 25 }, -- 6 past end
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 7
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL" }, -- 8
-- ChampionsRoomRivalDefeatedScript re-displays TEXT_CHAMPIONSROOM_RIVAL,
-- whose text_asm takes the EVENT_BEAT_CHAMPION_RIVAL branch =
-- _ChampionsRoomRivalAfterBattleText (the in-battle _RivalDefeatedText
-- is the port's generic "<PLAYER> defeated BLUE!" engine line instead).
{ "show_text", "_ChampionsRoomRivalAfterBattleText" }, -- 9
-- ChampionsRoomOakArrivesScript: Music_Cities1AlternateTempo
-- (Cities1, kept into HALL_OF_FAME like BIT_NO_MAP_MUSIC after
-- defeating RIVAL3), then Oak's "{PLAYER}!" + reveal + walk in
{ "play_music", "Music_Cities1", { keep = true } }, -- 10
{ "show_text", "_ChampionsRoomOakText" }, -- 11
{ "show_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 12
{ "move_npc", 2, "up", 5 }, -- 13 OakEntranceAfterVictoryMovement
-- OakCongratulatesPlayerScript: rival faces left, Oak faces down
{ "face_object", 1, "left" }, -- 14
{ "face_object", 2, "down" }, -- 15
{ "show_text", "_ChampionsRoomOakCongratulatesPlayerText" }, -- 16
-- OakDisappointedWithRivalScript: Oak turns to the rival (right)
{ "face_object", 2, "right" }, -- 17
{ "show_text", "_ChampionsRoomOakDisappointedWithRivalText" }, -- 18
-- OakComeWithMeScript: Oak faces down again, then exits up
{ "face_object", 2, "down" }, -- 19
{ "show_text", "_ChampionsRoomOakComeWithMeText" }, -- 20
{ "move_npc", 2, "up", 2 }, -- 21 OakExitChampionsRoomMovement
{ "hide_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 22
-- hand the induction off to the HALL_OF_FAME room (consumed by its
-- onEnter), then warp up into it (destWarp 1 lands at (4,7) facing up)
{ "set_field", "pendingHallOfFame", true }, -- 23
{ "warp", "HALL_OF_FAME", 4, 7, "up" }, -- 24
}
M.CHAMPIONS_ROOM = {
onEnter = function(game, ow)
if game.save.flags.EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN then return end
-- Lance entrance warps land at y=7; HoF return warps land at y=0.
-- Only the south entry should run ChampionsRoomPlayerEntersScript.
if ow.player.cellY < 7 then return end
local rival
for _, npc in ipairs(ow.npcs) do
if npc.def and npc.def.name == "CHAMPIONSROOM_RIVAL" then
rival = npc
break
end
end
-- RivalEntrance_RLEMovement, then the battle/Oak script (queued
-- separately so talk-script jump indices stay 1-based as written).
ow:queueScript({
{ "move_player", "up", 1 },
{ "move_player", "right", 1 },
{ "move_player", "up", 3 },
})
ow:queueScript(championsRoomRivalScript, { npc = rival })
end,
talk = {
TEXT_CHAMPIONSROOM_RIVAL = {
{ "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
},
TEXT_CHAMPIONSROOM_RIVAL = championsRoomRivalScript,
},
}
@@ -753,10 +858,14 @@ M.HALL_OF_FAME = {
-- that teleports back to the new-game bedroom spawn (special_warps.asm
-- NewGameWarp: REDS_HOUSE_2F, 3, 6, facing down). Fabricated
-- convenience -- there is no such prompt in the original ROM.
-- Heal + remember_outdoor so house LAST_MAP mats land in Pallet, not
-- Indigo Plateau (issue #103 escape path for already-stuck saves).
TEXT_HALLOFFAME_PC = {
{ "ask", "Return to\nPALLET TOWN?" }, -- 1 YES/NO -> lastCheck
{ "jump_if_false", "end" }, -- 2 NO: back away
{ "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
},
},
}
@@ -783,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 = {
rivalScript = pokemonTower2FRivalScript,
talk = {
TEXT_POKEMONTOWER2F_RIVAL = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 2
{ "jump_if_true", 10 }, -- 3
{ "show_text", "_PokemonTower2FRivalWhatBringsYouHereText" }, -- 4
{ "rival_battle", "OPP_RIVAL2", 4 }, -- 5
{ "jump_if_false", 11 }, -- 6
{ "set_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 7
{ "show_text", "_PokemonTower2FRivalDefeatedText" }, -- 8
{ "jump", 11 }, -- 9
{ "show_text", "_PokemonTower2FRivalHowsYourDexText" }, -- 10
},
TEXT_POKEMONTOWER2F_RIVAL = function(game, ow, npc, done)
ow.runner:run(pokemonTower2FRivalScript(ow.player.cellX),
{ npc = npc, onDone = done })
end,
},
-- Saves that beat him before the exit walk was ported still have the
-- flag but a visible rival; hide on enter like BillsHouse repairs.
onEnter = function(game, ow)
if not game.save.flags.EVENT_BEAT_POKEMON_TOWER_RIVAL then return end
local Commands = require("src.script.Commands")
Commands.hide_object({ game = game, save = game.save, overworld = ow },
"POKEMON_TOWER_2F", "POKEMONTOWER2F_RIVAL")
end,
-- PokemonTower2FDefaultScript: walking past the rival's tile forces
-- the encounter (ArePlayerCoordsInArray on (15,5)/(14,6)) -- he never
-- waits to be talked to
@@ -809,8 +948,7 @@ M.POKEMON_TOWER_2F = {
if not rival then return false end
ow.player.facing = (x == 15) and "left" or "up"
require("src.core.Music").play(game.data, "Music_MeetRival")
ow.runner:run(M.POKEMON_TOWER_2F.talk.TEXT_POKEMONTOWER2F_RIVAL,
{ npc = rival })
ow.runner:run(pokemonTower2FRivalScript(x), { npc = rival })
return true
end,
}
+141 -44
View File
@@ -719,73 +719,170 @@ M.CINNABAR_LAB_FOSSIL_ROOM = {
-- -------------------------------------------------------------------
-- Day-care (scripts/Daycare.asm): the boarded Pokémon earns 1 exp per
-- step; the fee is ¥100 plus ¥100 per level gained.
--
-- #118: do not raise mon.level until a paid retrieve (pokered reverts
-- wDayCareMonBoxLevel on .leaveMonInDayCare). Fold pending steps into
-- mon.exp once and clear them so a second talk cannot re-apply the same
-- walk. Fill {RAM:wNameBuffer}/{RAM:wDayCareMonName}/{NUM:...} here —
-- TextBox.TOKENS.RAM only knows wStringBuffer.
-- -------------------------------------------------------------------
local function fillDaycareText(s, subs)
s = s:gsub("{PLAYER}", subs.player or "")
s = s:gsub("{RAM:([^}]*)}", function(name) return subs[name] or "" end)
-- extractor NUM spans may include flags after a comma; keep the name
s = s:gsub("{NUM:([%w_]+)[^}]*}", function(name)
return tostring(subs[name] or "0")
end)
return s
end
M.DAYCARE = {
talk = {
TEXT_DAYCARE_GENTLEMAN = function(game, ow, npc, done)
local TextBox = require("src.render.TextBox")
local ChoiceBox = require("src.ui.ChoiceBox")
local t = game.data.text
local dc = game.save.daycare
local playerName = game.save.player and game.save.player.name or "RED"
local function monName(mon)
local def = game.data.pokemon[mon.species]
return mon.nickname or (def and def.name) or mon.species
end
if dc and dc.mon then
local Growth = require("src.pokemon.Growth")
local Stats = require("src.pokemon.Stats")
local Party = require("src.pokemon.Party")
local mon = dc.mon
local def = game.data.pokemon[mon.species]
mon.exp = mon.exp + (dc.steps or 0)
local newLevel = math.min(100, Growth.levelForExp(def.growthRate, mon.exp))
local fee = 100 + (newLevel - mon.level) * 100
local grew = newLevel > mon.level
mon.level = newLevel
mon.stats = Stats.calc(def, mon.level, mon.dvs, mon.statExp)
mon.hp = mon.stats.hp
local msg = grew and (t._DaycareGentlemanMonHasGrownText or "It's grown a lot!")
or "Back already?"
game.stack:push(TextBox.new(game,
msg .. ("\fThe fee is ¥%d.\nGet it back?"):format(fee), function()
game.stack:push(ChoiceBox.new(game, function(yes)
if yes and game.save.money >= fee then
-- Apply deferred step-exp once (OverworldController only bumps
-- daycare.steps). Clearing prevents double-count on re-talk.
mon.exp = (mon.exp or 0) + (dc.steps or 0)
dc.steps = 0
-- depositLevel mirrors wDayCareMonBoxLevel: fee baseline that
-- must survive a declined retrieve. Fall back to mon.level for
-- older saves that predate the field.
if dc.depositLevel == nil then dc.depositLevel = mon.level end
local startLevel = dc.depositLevel
local newLevel = Growth.levelForExp(def and def.growthRate, mon.exp)
if newLevel >= 100 then
newLevel = 100
if def then
mon.exp = Growth.expForLevel(def.growthRate, 100)
end
end
local levelsGrown = math.max(0, newLevel - startLevel)
local fee = 100 + levelsGrown * 100
local name = monName(mon)
local subs = {
player = playerName,
wNameBuffer = name,
wDayCareMonName = name,
wDayCareNumLevelsGrown = levelsGrown,
wDayCareTotalCost = fee,
}
local statusText = levelsGrown > 0
and (t._DaycareGentlemanMonHasGrownText
or "Your {RAM:wNameBuffer}\nhas grown a lot!\fBy level, it's\ngrown by {NUM:wDayCareNumLevelsGrown, 1, 3}!\fAren't I great?")
or (t._DaycareGentlemanMonNeedsMoreTimeText
or "Back already?\nYour {RAM:wNameBuffer}\nneeds some more\ntime with me.")
game.stack:push(TextBox.new(game, fillDaycareText(statusText, subs), function()
if #game.save.party >= Party.MAX then
game.stack:push(TextBox.new(game,
t._DaycareGentlemanNoRoomForMonText
or "You have no room\nfor this POKéMON!", done))
return
end
game.stack:push(TextBox.new(game,
fillDaycareText(
t._DaycareGentlemanOweMoneyText
or "You owe me ¥{NUM:wDayCareTotalCost, 2 | LEADING_ZEROES | LEFT_ALIGN}\nfor the return\nof this POKéMON.",
subs),
nil, { choice = function(yes)
if not yes then
-- .leaveMonInDayCare: revert any transient level bump
mon.level = startLevel
game.stack:push(TextBox.new(game,
(t._DaycareGentlemanAllRightThenText or "All right then,\n")
.. (t._DaycareGentlemanComeAgainText or "come again."),
done))
return
end
if (game.save.money or 0) < fee then
mon.level = startLevel
game.stack:push(TextBox.new(game,
t._DaycareGentlemanNotEnoughMoneyText
or "Hey, you don't\nhave enough ¥!", done))
return
end
game.save.money = game.save.money - fee
mon.level = newLevel
if def then
mon.stats = Stats.calc(def, mon.level, mon.dvs, mon.statExp)
mon.hp = mon.stats.hp
end
table.insert(game.save.party, mon)
game.save.daycare = nil
game.stack:push(TextBox.new(game,
t._DaycareGentlemanGotMonBackText or "Here you go!", done))
else
game.stack:push(TextBox.new(game,
yes and (t._DaycareGentlemanOweMoneyText or "You owe me money!")
or "Come again!", done))
end
end))
t._DaycareGentlemanHeresYourMonText
or "Thank you! Here's\nyour POKéMON!", function()
game.stack:push(TextBox.new(game,
fillDaycareText(
t._DaycareGentlemanGotMonBackText
or "{PLAYER} got\n{RAM:wDayCareMonName} back!",
subs), done))
end))
end }))
end))
return
end
if #game.save.party < 2 then
game.stack:push(TextBox.new(game,
"You only have one\nPOKéMON with you!", done))
return
end
game.stack:push(TextBox.new(game,
t._DaycareGentlemanIntroText or "I can raise a\nPOKéMON for you.", function()
game.stack:push(ChoiceBox.new(game, function(yes)
if not yes then done() return end
local PartyMenu = require("src.ui.PartyMenu")
game.stack:push(PartyMenu.new(game, {
pickOnly = true,
onSwitch = function(mon)
for i, m in ipairs(game.save.party) do
if m == mon then table.remove(game.save.party, i) break end
end
game.save.daycare = { mon = mon, steps = 0 }
game.stack:push(TextBox.new(game,
t._DaycareGentlemanWillLookAfterMonText or
"Fine, I'll look\nafter it a while!", done))
end,
}))
end))
end))
t._DaycareGentlemanIntroText
or "I run a DAYCARE.\nWould you like me\nto raise one of\nyour POKéMON?",
nil, { choice = function(yes)
if not yes then
game.stack:push(TextBox.new(game,
t._DaycareGentlemanComeAgainText or "come again.", done))
return
end
if #game.save.party < 2 then
game.stack:push(TextBox.new(game,
t._DaycareGentlemanOnlyHaveOneMonText
or "You only have one\nPOKéMON with you.", done))
return
end
game.stack:push(TextBox.new(game,
t._DaycareGentlemanWhichMonText or "Which POKéMON\nshould I raise?",
function()
local PartyMenu = require("src.ui.PartyMenu")
game.stack:push(PartyMenu.new(game, {
pickOnly = true,
onSwitch = function(mon)
for i, m in ipairs(game.save.party) do
if m == mon then table.remove(game.save.party, i) break end
end
local name = monName(mon)
-- depositLevel = wDayCareMonBoxLevel at deposit time
game.save.daycare = {
mon = mon, steps = 0, depositLevel = mon.level,
}
game.stack:push(TextBox.new(game,
fillDaycareText(
t._DaycareGentlemanWillLookAfterMonText
or "Fine, I'll look\nafter {RAM:wNameBuffer}\nfor a while.",
{ player = playerName, wNameBuffer = name }),
function()
game.stack:push(TextBox.new(game,
t._DaycareGentlemanComeSeeMeInAWhileText
or "Come see me in\na while.", done))
end))
end,
}))
end))
end }))
end,
},
}
+240 -48
View File
@@ -61,6 +61,50 @@ M.ROUTE_12_SUPER_ROD_HOUSE = {
M.ROUTE_12_SUPER_ROD_HOUSE.talk.TEXT_ROUTE12SUPERRODHOUSE_FISHING_GURU[9] =
{ "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
-- (10,16) until defeated.
@@ -150,18 +194,20 @@ local WALK_DIRVEC = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right =
local WALK_OPP = { up = "down", down = "up", left = "right", right = "left" }
local WALK_ORDER = { "up", "down", "left", "right" }
local function elevatorWalkOut(ow, floor)
local m, p = ow.map, ow.player
-- .UpdateWarp is run twice, so BOTH car warp entries get the same
-- (warp id, map id): point every exit warp at the picked floor's
-- elevator-door warp (the reciprocal warp found while building the
-- menu). The car map's def is shared generated data, but its own
-- warps are only ever read from inside the car, and this rewrite runs
-- on every ride before the walk-out fires, so it is self-correcting.
for _, w in ipairs(m.def.warps) do
-- .UpdateWarp: point EVERY car exit warp at the same floor's elevator
-- door (warp id, map id). Shared generated map data, but the car's
-- warps are only read from inside the car; rides rewrite them again.
local function elevatorSetExit(ow, floor)
if not floor then return end
for _, w in ipairs(ow.map.def.warps) do
w.destMap = floor.map
w.destWarp = floor.warpIdx
end
end
local function elevatorWalkOut(ow, floor)
local m, p = ow.map, ow.player
elevatorSetExit(ow, floor)
-- leave by the exit tile under the player (they warped in onto one),
-- else the nearest
local door
@@ -199,37 +245,68 @@ local function elevatorWalkOut(ow, floor)
end)
end
local function elevatorFloors(elevatorMapId, game)
local floors = {}
for mapId, def in pairs(game.data.maps) do
for i, w in ipairs(def.warps) do
if w.destMap == elevatorMapId then
-- short floor token pokered actually prints, e.g.
-- SILPH_CO_10F -> "10F", ROCKET_HIDEOUT_B2F -> "B2F"
local token = mapId:match("_([^_]+)$") or mapId
-- warpIdx: this floor's warp back into the elevator IS the
-- warp the car's rewritten exit lands on (the reciprocal
-- pair), matching wElevatorWarpMaps' (warp id, map id)
table.insert(floors,
{ map = mapId, x = w.x, y = w.y, token = token, warpIdx = i })
break
end
end
end
-- numeric floor order (SilphCoElevatorFloors' FLOOR_1F..FLOOR_11F),
-- not lexicographic -- otherwise 10F/11F sort before 2F..9F
table.sort(floors, function(a, b)
return (tonumber(a.token:match("%d+")) or 0) <
(tonumber(b.token:match("%d+")) or 0)
end)
return floors
end
local function elevatorSeedExit(ow, floors, fromMapId)
-- Seed a walk-out destination before the menu (or key-gate text):
-- entry floor when known, else the first listed floor (1F). Choosing
-- a floor still rewrites via elevatorWalkOut; B-cancel / no-key leave
-- keeps this seed so walking out of the car cannot hit a missing ROM
-- placeholder (#123) or the car's static default floor (#90: Rocket
-- Hideout defaults to B1F even when entered from B2F/B4F).
local exitFloor = floors[1]
if fromMapId then
for _, f in ipairs(floors) do
if f.map == fromMapId then exitFloor = f break end
end
end
elevatorSetExit(ow, exitFloor)
return exitFloor
end
local function elevator(elevatorMapId, keyGate, preFrames)
return {
onEnter = function(game, ow)
-- fromMapId: the floor the player just left (setMap passes it), so a
-- B-cancel can still walk out onto a real map. Silph's ROM car warps
-- default to UNUSED_MAP_ED, which is not in Data.maps -- Warp.resolve
-- asserted and hard-crashed (#123).
onEnter = function(game, ow, fromMapId)
local floors = elevatorFloors(elevatorMapId, game)
elevatorSeedExit(ow, floors, fromMapId)
-- Rocket Hideout: without LIFT_KEY the panel only prints the need-
-- a-key line (scripts/RocketHideoutElevator.asm). Exit warps are
-- still seeded above so walking out returns to the entry floor
-- instead of the car's ROM default (B1F) — #90 / #105.
if keyGate and not game.save.inventory[keyGate.item] then
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game,
game.data.text[keyGate.text] or "It appears to\nneed a key."))
return
end
local floors = {}
for mapId, def in pairs(game.data.maps) do
for i, w in ipairs(def.warps) do
if w.destMap == elevatorMapId then
-- short floor token pokered actually prints, e.g.
-- SILPH_CO_10F -> "10F", ROCKET_HIDEOUT_B2F -> "B2F"
local token = mapId:match("_([^_]+)$") or mapId
-- warpIdx: this floor's warp back into the elevator IS the
-- warp the car's rewritten exit lands on (the reciprocal
-- pair), matching wElevatorWarpMaps' (warp id, map id)
table.insert(floors,
{ map = mapId, x = w.x, y = w.y, token = token, warpIdx = i })
break
end
end
end
-- numeric floor order (SilphCoElevatorFloors' FLOOR_1F..FLOOR_11F),
-- not lexicographic -- otherwise 10F/11F sort before 2F..9F
table.sort(floors, function(a, b)
return (tonumber(a.token:match("%d+")) or 0) <
(tonumber(b.token:match("%d+")) or 0)
end)
local items = {}
for _, f in ipairs(floors) do
table.insert(items, { label = f.token, value = f })
@@ -262,7 +339,8 @@ local function elevator(elevatorMapId, keyGate, preFrames)
end,
onCancel = function()
-- DisplayElevatorFloorMenu: `ret c` on B -- no warp, nothing
-- happens, the player just stays in the car
-- happens, the player just stays in the car (exit warps were
-- already seeded to the entry floor above)
end,
}))
end,
@@ -274,6 +352,98 @@ M.CELADON_MART_ELEVATOR = elevator("CELADON_MART_ELEVATOR", nil, 9)
M.ROCKET_HIDEOUT_ELEVATOR = elevator("ROCKET_HIDEOUT_ELEVATOR",
{ item = "LIFT_KEY", text = "_RocketHideoutElevatorAppearsToNeedKeyText" })
-- -------------------------------------------------------------------
-- Rocket Hideout B4F (scripts/RocketHideoutB4F.asm):
-- Rocket3's after-battle text_asm drops the LIFT KEY item ball
-- (CheckAndSetEvent EVENT_ROCKET_DROPPED_LIFT_KEY / ShowObject
-- TOGGLE_ROCKET_HIDEOUT_B4F_ITEM_5). Both start hidden in the map
-- objects; without this talk side-effect the key never appears (#90,
-- #105).
-- Giovanni's post-battle script likewise ShowObject's the Silph Scope
-- after the hope-we-meet-again line (TOGGLE_ROCKET_HIDEOUT_B4F_ITEM_4).
-- -------------------------------------------------------------------
M.ROCKET_HIDEOUT_B4F = {
talk = {
TEXT_ROCKETHIDEOUTB4F_ROCKET3 = function(game, ow, npc, done)
if not ow:trainerDefeated(npc) then
ow:engageTrainer(npc, done)
return
end
local TextBox = require("src.render.TextBox")
local t = game.data.text
game.stack:push(TextBox.new(game,
t._RocketHideoutB4FRocket3AfterBattleText
or "Oh no! I dropped\nthe LIFT KEY!",
function()
-- CheckAndSetEvent EVENT_ROCKET_DROPPED_LIFT_KEY: first talk
-- after the win reveals the ball; later talks only reprint.
if not game.save.flags.EVENT_ROCKET_DROPPED_LIFT_KEY then
game.save.flags.EVENT_ROCKET_DROPPED_LIFT_KEY = true
local Commands = require("src.script.Commands")
Commands.show_object(
{ game = game, save = game.save, overworld = ow },
"ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_LIFT_KEY")
end
done()
end))
end,
TEXT_ROCKETHIDEOUTB4F_GIOVANNI = function(game, ow, npc, done)
-- Giovanni has no trainer-header row (def_trainers 2); his text_asm
-- owns both the engage and the BeatGiovanniScript aftermath.
if ow:trainerDefeated(npc)
or game.save.flags.EVENT_BEAT_ROCKET_HIDEOUT_GIOVANNI then
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game,
game.data.text._RocketHideoutB4FGiovanniHopeWeMeetAgainText
or "I hope we meet\nagain...", done))
return
end
local TextBox = require("src.render.TextBox")
local BattleState = require("src.battle.BattleState")
local t = game.data.text
local impressed = t._RocketHideoutB4FGiovanniImpressedYouGotHereText
or "So! I must say, I\nam impressed you\ngot here!"
local cannotBe = t._RocketHideoutB4FGiovanniWhatCannotBeText
or "WHAT!\nThis cannot be!"
local hope = t._RocketHideoutB4FGiovanniHopeWeMeetAgainText
or "I hope we meet\nagain..."
game.stack:push(TextBox.new(game, impressed, function()
local battle = BattleState.newTrainer(game, "OPP_GIOVANNI", 1)
battle.onFinish = function(result)
if result ~= "win" then
ow:afterBattle(result, battle)
done()
return
end
game.save.defeatedTrainers[npc.id] = true
game.save.flags.EVENT_BEAT_ROCKET_HIDEOUT_GIOVANNI = true
-- End-battle "WHAT!" then BeatGiovanniScript's hope text,
-- fade, HideObject Giovanni, ShowObject Silph Scope.
game.stack:push(TextBox.new(game, cannotBe, function()
game.stack:push(TextBox.new(game, hope, function()
local Transition = require("src.render.Transition")
game.stack:push(Transition.new(game, function()
local Commands = require("src.script.Commands")
local ctx = { game = game, save = game.save, overworld = ow }
Commands.hide_object(ctx, "ROCKET_HIDEOUT_B4F",
"ROCKETHIDEOUTB4F_GIOVANNI")
Commands.show_object(ctx, "ROCKET_HIDEOUT_B4F",
"ROCKETHIDEOUTB4F_SILPH_SCOPE")
end, function()
ow:afterBattle(result, battle)
done()
end))
end))
end))
end
ow:pushBattle(battle)
end))
end,
},
}
-- -------------------------------------------------------------------
-- Game Corner coins, prizes, and the rocket-poster switch that reveals
-- the hideout stairs (scripts/GameCorner.asm, data/events/prizes.asm +
@@ -286,14 +456,23 @@ M.GAME_CORNER = {
-- EVENT_FOUND_ROCKET_HIDEOUT is unset, $43 after)
onEnter = function(game, ow)
local poster = game.data.field.gameCornerPoster
if not poster then return end
local block = game.save.flags[poster.event] and poster.openBlock
or poster.closedBlock
ow:replaceBlock(poster.x, poster.y, block)
if poster then
local block = game.save.flags[poster.event] and poster.openBlock
or poster.closedBlock
ow:replaceBlock(poster.x, poster.y, block)
end
-- pick this visit's lucky slot machine
-- (wLuckySlotHiddenEventIndex, engine/slots/game_corner_slots2.asm)
local seats = game.data.field.slotMachines.GAME_CORNER
ow.luckySlot = love.math.random(1, #seats)
-- #131: pre-#50 saves beat the poster grunt (defeatedTrainers) but
-- never hid him; clear the tile if he is already marked defeated
local rocketId = "GAME_CORNER_obj_11"
if game.save.defeatedTrainers and game.save.defeatedTrainers[rocketId] then
local Commands = require("src.script.Commands")
Commands.hide_object({ game = game, save = game.save, overworld = ow },
"GAME_CORNER", "GAMECORNER_ROCKET")
end
end,
talk = {
-- the poster bg event: pressing A reveals the hidden switch
@@ -322,7 +501,16 @@ M.GAME_CORNER = {
-- losing he warns the BOSS and leaves the floor for good, freeing
-- the tile in front of the hideout switch
TEXT_GAMECORNER_ROCKET = function(game, ow, npc, done)
local Commands = require("src.script.Commands")
local function hideRocket()
Commands.hide_object({ game = game, save = game.save,
overworld = ow },
"GAME_CORNER", "GAMECORNER_ROCKET")
end
-- already beaten: hide anyway so pre-#50 saves that only have
-- defeatedTrainers (no objectToggles hide) clear the poster tile
if ow:trainerDefeated(npc) then
hideRocket()
done()
return
end
@@ -336,10 +524,7 @@ M.GAME_CORNER = {
game.data.text._GameCornerRocketAfterBattleText
or "Our hideout might\nbe discovered! I\nbetter tell BOSS!",
function()
local Commands = require("src.script.Commands")
Commands.hide_object({ game = game, save = game.save,
overworld = ow },
"GAME_CORNER", "GAMECORNER_ROCKET")
hideRocket()
done()
end))
end)
@@ -481,8 +666,9 @@ local DOCK_SHIP_BLOCKS = {
M.VERMILION_DOCK = {
onEnter = function(game, ow)
local Flags = require("src.script.Flags")
local f = game.save.flags
if f.EVENT_SS_ANNE_LEFT then
if Flags.get(game.save, "EVENT_SS_ANNE_LEFT") then
-- the ship is long gone: erase her right away, and anyone who
-- still lands here is sent back out past the guard
for _, b in ipairs(DOCK_SHIP_BLOCKS) do
@@ -498,13 +684,15 @@ M.VERMILION_DOCK = {
elseif f.EVENT_GOT_HM01 and ow.player.cellY == 2 then
-- VermilionDockSSAnneLeavesScript: only stepping OFF the ship
-- triggers the departure (wDestinationWarpID == 1 in pokered) --
-- the horn blows, smoke puffs drift off the funnel, the ship is
-- erased to open water, and the player is walked off the dock
-- into the city past the guard (VermilionCity's
-- Music_Surfing plays for the sail-away cutscene, smoke puffs
-- drift off the funnel, the horn blows, the ship is erased to
-- open water, and the player is walked off the dock into the
-- city past the guard (VermilionCity's
-- SCRIPT_VERMILIONCITY_PLAYER_EXIT_SHIP walk)
f.EVENT_SS_ANNE_LEFT = true
require("src.core.Music").stop()
require("src.core.Sound").play(game.data, "SS_Anne_Horn")
Flags.set(game.save, "EVENT_SS_ANNE_LEFT")
local Music = require("src.core.Music")
Music.stop()
Music.play(game.data, "Music_Surfing")
local function puff(n, cx)
if n <= 0 then return end
ow:startDustAnim(cx, 1, function() puff(n - 1, cx + 2) end)
@@ -512,11 +700,15 @@ M.VERMILION_DOCK = {
puff(3, 15)
local rows = {}
rows[#rows + 1] = { "wait", 100 }
rows[#rows + 1] = { "play_sound", "SS_Anne_Horn" }
for _, b in ipairs(DOCK_SHIP_BLOCKS) do
rows[#rows + 1] = { "replace_block", b.bx, b.by, b.water }
end
rows[#rows + 1] = { "wait", 30 }
rows[#rows + 1] = { "move_player", "up", 2 }
-- keep Music_Surfing across the city warp (pokered stays on it
-- through the exit-ship walk)
rows[#rows + 1] = { "play_music", "Music_Surfing", { keep = true } }
rows[#rows + 1] = { "warp", "VERMILION_CITY", 18, 31, "up" }
rows[#rows + 1] = { "move_player", "up", 2 }
ow:queueScript(rows)
+68 -18
View File
@@ -645,12 +645,44 @@ local function e4ExitSeal(flag, closedBlock, openBlock, dontRunText, autoFlag)
}
end
-- Lorelei/Bruno/Agatha EndBattleScript (pokered): EndTrainerBattle then
-- DisplayTextID -> TalkToTrainer, which prints the AfterBattle text on a
-- win. engageTrainer only shows the won line, so wrap talk like Lance /
-- Game Corner Rocket and push header.after immediately after a win.
local function e4LeaderTalk(afterLabel)
return function(game, ow, npc, done)
done = done or function() end
if ow:trainerDefeated(npc) then
local after = text(game)[afterLabel]
if after then push(game, after, done) else done() end
return
end
ow:engageTrainer(npc, function()
if not ow:trainerDefeated(npc) then
done()
return
end
local after = text(game)[afterLabel]
if after then push(game, after, done) else done() end
end)
end
end
M.LORELEIS_ROOM = e4ExitSeal("EVENT_BEAT_LORELEIS_ROOM_TRAINER_0", 0x24, 0x05,
"_LoreleisRoomLoreleiDontRunAwayText", "EVENT_AUTOWALKED_INTO_LORELEIS_ROOM")
M.LORELEIS_ROOM.talk = {
TEXT_LORELEISROOM_LORELEI = e4LeaderTalk("_LoreleisRoomLoreleiAfterBattleText"),
}
M.BRUNOS_ROOM = e4ExitSeal("EVENT_BEAT_BRUNOS_ROOM_TRAINER_0", 0x24, 0x05,
"_BrunosRoomBrunoDontRunAwayText", "EVENT_AUTOWALKED_INTO_BRUNOS_ROOM")
M.BRUNOS_ROOM.talk = {
TEXT_BRUNOSROOM_BRUNO = e4LeaderTalk("_BrunoAfterBattleText"),
}
M.AGATHAS_ROOM = e4ExitSeal("EVENT_BEAT_AGATHAS_ROOM_TRAINER_0", 0x3b, 0x0e,
"_AgathasRoomAgathaDontRunAwayText", "EVENT_AUTOWALKED_INTO_AGATHAS_ROOM")
M.AGATHAS_ROOM.talk = {
TEXT_AGATHASROOM_AGATHA = e4LeaderTalk("_AgathaAfterBattleText"),
}
-- -------------------------------------------------------------------
-- Lance's room (scripts/LancesRoom.asm). Unlike the other three E4
@@ -672,13 +704,21 @@ M.AGATHAS_ROOM = e4ExitSeal("EVENT_BEAT_AGATHAS_ROOM_TRAINER_0", 0x3b, 0x0e,
-- EVENT_LANCES_ROOM_LOCK_DOOR: first crossing seals
-- the door behind the player with SFX_GO_INSIDE
-- (24,16) the entrance staircase -> WalkToLance: an auto-walk
-- (up 12, left 12, down 7, left 6) landing on (6,11).
-- It marches straight across the room's water decor:
-- pokered's CollisionCheckOnLand skips collision
-- entirely while simulated joypad states run, and our
-- scriptMove is collision-free the same way.
-- landing on (6,11). Vanilla WalkToLance_RLEList is
-- up 12 / left 12 / down 7 / left 6 and skips collision
-- through void tiles; our camera follows that literally
-- and briefly shows the empty upper chamber (reads as
-- "Gary's room"). We keep the same landing cell but
-- route along the open-door floor corridor instead.
-- -------------------------------------------------------------------
-- Floor corridor (24,16) -> (6,11) with EVENT_LANCES_ROOM_LOCK_DOOR
-- unset (entrance blocks $31/$32). Exposed for parity tests.
local LANCE_WALK_IN = {
{ "down", 2 }, { "left", 6 }, { "down", 5 }, { "left", 10 },
{ "up", 9 }, { "left", 2 }, { "up", 3 },
}
local function lanceEntranceBlocks(game, ow)
local locked = game.save.flags.EVENT_LANCES_ROOM_LOCK_DOOR
ow:replaceBlock(2, 6, locked and 0x72 or 0x31)
@@ -693,21 +733,24 @@ local function lanceLockDoor(game, ow)
end
local function lanceWalkIn(game, ow)
ow:scriptMove(ow.player, "up", 12, function()
ow:scriptMove(ow.player, "left", 12, function()
ow:scriptMove(ow.player, "down", 7, function()
ow:scriptMove(ow.player, "left", 6, function()
-- the walk lands on (6,11); vanilla's per-frame coord poll
-- then locks the door at once. scriptMove landings do not
-- fire onStep, so lock here.
lanceLockDoor(game, ow)
end)
end)
end)
end)
local i = 0
local function step()
i = i + 1
local seg = LANCE_WALK_IN[i]
if not seg then
-- lands on (6,11); vanilla's per-frame coord poll then locks the
-- door at once. scriptMove landings do not fire onStep, so lock
-- here.
lanceLockDoor(game, ow)
return
end
ow:scriptMove(ow.player, seg[1], seg[2], step)
end
step()
end
M.LANCES_ROOM = {
walkInRoute = LANCE_WALK_IN,
onEnter = function(game, ow)
lanceEntranceBlocks(game, ow)
-- the warp arrival lands ON the staircase trigger, and onStep only
@@ -727,7 +770,14 @@ M.LANCES_ROOM = {
end
if not lance or ow:trainerDefeated(lance) then return false end
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
end
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
-- escorts you to the gym (scripts/PewterCity.asm
-- PewterCityCheckPlayerLeavingEastScript /
-- PewterCityYoungsterShowsPlayerGymScript, engine/events/pewter_guys.asm
-- PewterGymGuyCoords): the "follow me" lines, then the player is walked
-- west along the road to the front of the PEWTER_GYM door at (16,17).
-- PewterCityYoungsterShowsPlayerGymScript, engine/overworld/auto_movement.asm
-- PewterGymGuyMovementScriptPointerTable, engine/events/pewter_guys.asm
-- PewterGymGuyCoords). Same lockstep style as Oak's lab escort: the
-- youngster walks RLEList_PewterGymGuy while the player plays the
-- reverse of RLEList_PewterGymPlayer with a PewterGuys positioning
-- preamble. Ends at (11,18) / (12,18) by the gym, not phasing through
-- the building.
local pewterEscort = {}
-- RLEList_PewterGymGuy (NPC directions play forward)
pewterEscort.guySteps = {
"down", "down",
"left", "left", "left", "left", "left", "left", "left", "left",
"left", "left", "left", "left", "left", "left", "left",
"up", "up", "up", "up", "up",
"left", "left", "left", "left", "left", "left", "left", "left",
"left", "left", "left",
"down", "down", "down", "down", "down",
"right", "right", "right",
}
-- Walk home: reverse of guySteps with opposite facings (gym → spawn).
do
local opp = { up = "down", down = "up", left = "right", right = "left" }
local ret = {}
for i = #pewterEscort.guySteps, 1, -1 do
ret[#ret + 1] = opp[pewterEscort.guySteps[i]]
end
pewterEscort.guyReturnSteps = ret
end
-- RLEList_PewterGymPlayer before reverse / PewterGuys (NO_INPUT, RIGHT×2,
-- DOWN×5, LEFT×11, UP×5, LEFT×15)
pewterEscort.playerRle = {
"NO",
"right", "right",
"down", "down", "down", "down", "down",
"left", "left", "left", "left", "left", "left", "left", "left",
"left", "left", "left",
"up", "up", "up", "up", "up",
"left", "left", "left", "left", "left", "left", "left", "left",
"left", "left", "left", "left", "left", "left", "left",
}
-- PewterGymGuyCoords: (x, y) -> positioning moves written after the RLE
-- (played in reverse; $00 pauses are one overworld frame ≈ 1/8 tile for
-- the NPC, so eight of them ≈ one guy head-start step)
pewterEscort.preambles = {
["34,16"] = { "left", "down", "down", "right" },
["35,17"] = { "left", "down", "right", "left" },
["37,18"] = { "left", "left", "left",
"NO", "NO", "NO", "NO", "NO", "NO", "NO", "NO" },
["37,19"] = { "left", "left", "up", "left" },
["36,17"] = { "left", "down", "left",
"NO", "NO", "NO", "NO", "NO", "NO", "NO", "NO" },
}
-- Realized player path for a trigger tile: PewterGuys overwrites the
-- last RLE byte and appends the preamble, then simulated joypad plays
-- high→low (reverse). Leading NO×8 collapses to guyHeadStart=1; the
-- trailing end-of-list NO is dropped (one-frame pause).
function pewterEscort.playerPlan(x, y)
local pre = pewterEscort.preambles[x .. "," .. y]
if not pre then return nil end
local buf = {}
for i, d in ipairs(pewterEscort.playerRle) do buf[i] = d end
buf[#buf] = pre[1]
for i = 2, #pre do buf[#buf + 1] = pre[i] end
local path = {}
for i = #buf, 1, -1 do path[#path + 1] = buf[i] end
local head = 0
while path[head + 1] == "NO" do head = head + 1 end
local tail = #path
while tail > head and path[tail] == "NO" do tail = tail - 1 end
local steps = {}
for i = head + 1, tail do steps[#steps + 1] = path[i] end
return { steps = steps, guyHeadStart = math.floor(head / 8) }
end
local function pewterGymEscort(game, ow)
if ow.runner:isRunning() or #ow.scriptMoves > 0 then return end
local x, y = ow.player.cellX, ow.player.cellY
local plan = pewterEscort.playerPlan(x, y)
local Music = require("src.core.Music")
local t = text(game)
local follow = t._PewterCityYoungsterYoureATrainerFollowMeText
or "You're a trainer\nright? BROCK's\nlooking for new\nchallengers!\nFollow me!"
-- PewterGuys only has entries for five tiles; an unmatched talk tile
-- (e.g. (36,16) east of him) just gets the follow-me line, same as a
-- failed coords lookup would refuse to arm the walk.
if not plan then
push(game, follow)
return
end
local guy = ow:npcByIndex(5) -- PEWTERCITY_YOUNGSTER
local guySteps = pewterEscort.guySteps
local head = plan.guyHeadStart
-- After the walk: face the player, restore map music, "Go take on
-- BROCK", then retrace RLEList_PewterGymGuy back to his spawn (35,16).
-- (pokered teleports him via MovementData_PewterGymGuyExit; we walk
-- the same route home instead. Brock victory still HideObject's him.)
local function walkHome()
if not guy then return end
local ret = pewterEscort.guyReturnSteps
local i = 0
local function tick()
i = i + 1
if not ret[i] then
guy.facing = "down"
return
end
ow:scriptMove(guy, ret[i], 1, tick)
end
tick()
end
local function afterWalk()
if guy then guy.facing = "left" end
Music.playMap(game.data, "PEWTER_CITY")
push(game, t._PewterCityYoungsterGoTakeOnBrockText
or "Go take on BROCK\nat the GYM first!", walkHome)
end
local function lockstep()
local i = 0
local function tick()
i = i + 1
local ps = plan.steps[i]
if not ps then
afterWalk()
return
end
local gs = guySteps[head + i]
if guy and gs then ow:scriptMove(guy, gs, 1) end
ow:scriptMove(ow.player, ps, 1, tick)
end
tick()
end
local function beginWalk()
Music.play(game.data, "Music_MuseumGuy")
if guy and head > 0 then
local h = 0
local function headTick()
h = h + 1
if h > head then lockstep(); return end
ow:scriptMove(guy, guySteps[h], 1, headTick)
end
headTick()
else
lockstep()
end
end
push(game, follow, beginWalk)
end
M.PEWTER_CITY = {
escort = pewterEscort,
-- PewterCityYoungsterText: talking also arms the gym escort script
talk = {
TEXT_PEWTERCITY_YOUNGSTER = function(game, ow, npc, done)
pewterGymEscort(game, ow)
if done then done() end
end,
},
onStep = function(game, ow, x, y)
if game.save.flags.EVENT_BEAT_BROCK then return false end
if ow.runner:isRunning() or #ow.scriptMoves > 0 then return false end
if not inCoords({ { 35, 17 }, { 36, 17 }, { 37, 18 }, { 37, 19 } }, x, y) then
return false
end
local t = text(game)
-- walk up onto the road row (y=17), west to one tile east of the gym
-- door, then drop below the door and turn to face it
local steps = {}
for _ = 1, y - 17 do steps[#steps + 1] = "up" end
for _ = 1, x - 17 do steps[#steps + 1] = "left" end
steps[#steps + 1] = "down"
steps[#steps + 1] = "left"
local function walk(i)
if not steps[i] then
ow.player.facing = "up"
return
end
ow:scriptMove(ow.player, steps[i], 1, function() walk(i + 1) end)
end
push(game, t._PewterCityYoungsterYoureATrainerFollowMeText
or "Hey! You're a\ntrainer, right?", function()
push(game, t._PewterCityYoungsterGoTakeOnBrockText
or "Go take on BROCK\nat the GYM first!", function()
walk(1)
end)
end)
pewterGymEscort(game, ow)
return true
end,
}
+11 -3
View File
@@ -2,8 +2,9 @@
-- "OPP_CLASS#partyIndex" (the object_event trainer args). Hand-ported
-- from the leaders'/bosses' text_asm victory scripts:
-- gym badges: scripts/PewterGym.asm ... ViridianGym.asm
-- Rocket Hideout Giovanni: his Silph Scope is an item ball next to him
-- (data/maps/objects/RocketHideoutB4F.asm), so no reward entry needed.
-- Rocket Hideout Giovanni: Silph Scope is a hidden item ball revealed by
-- ShowObject in RocketHideoutB4FBeatGiovanniScript (ported as the
-- TEXT_ROCKETHIDEOUTB4F_GIOVANNI talk handler in story3.lua).
-- The TM each gym leader hands out afterwards is also ported.
--
-- `deactivate` lists the EVENT_BEAT_* flags each gym's victory script
@@ -21,9 +22,16 @@ local function range(prefix, first, last)
end
return {
-- PewterGym.asm .gymVictory also HideObject TOGGLE_GYM_GUY
-- (PEWTERCITY_YOUNGSTER) and TOGGLE_ROUTE_22_RIVAL_1 so the east-exit
-- escort NPC and the first Route 22 rival stay gone after the badge.
["OPP_BROCK#1"] = { badge = "BOULDERBADGE", flag = "EVENT_BEAT_BROCK",
item = "TM_BIDE",
deactivate = { "EVENT_BEAT_PEWTER_GYM_TRAINER_0" } },
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",
item = "TM_BUBBLEBEAM",
deactivate = range("EVENT_BEAT_CERULEAN_GYM_TRAINER_", 0, 1) },
+44 -13
View File
@@ -8,27 +8,42 @@ behavior is in docs/behavior-porting-notes.md.
## Survey zoom
The mouse wheel (or `-`/`=`) zooms the overworld between 1 pixel per world
pixel (full survey) and 2× the window fit scale (close-up), in crisp
integer steps. This has no Game Boy equivalent:
The mouse wheel (or `-`/`=`), the Options **ZOOM** row, or hotkey `4`
zooms the overworld between 1 pixel per world pixel (full survey) and 2×
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
visual-only "ghosts", they wander but have no sight lines, triggers,
dialogue, or collision until the map is actually entered.
- 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
active; the zoom level persists across warps and is never saved.
- Beyond the border ring the border block repeats indefinitely (interiors
stay black, seaside towns stay water, except OVERWORLD-tileset maps,
whose beyond-edge space fills with the solid tree wall instead of the
per-map border block), and each visible map area is colorized with its
own SGB palette (the original recolored the whole screen per map).
active; the zoom offset is persisted as `save.options.zoom` (default
`0` = FIT) and survives New Game via `options.lua`.
- Hotkey `4` ticks through every integer zoom level (survey → FIT →
close-up → wrap). The Options row shows `FIT` / `OUTn` / `INn`.
- Beyond the border ring the void fill repeats indefinitely (see VOID
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
don't pop in and out, and ghost NPCs share instances with the real ones
so their wander positions persist across seamless connection crossings
(a warp or fresh map entry still respawns everything at its script
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
The `3` key (and the Options menu TILT row) cycles a visual-only perspective
@@ -42,8 +57,10 @@ Game Boy equivalent:
rows above the player recede and rows below come toward the viewer. Only
things that actually *stand* on the ground draw as upright billboards,
unscaled and pixel-identical to flat mode: the player, NPCs, item balls,
and the screen-anchored FX attached to them (heal machine glow, emote
bubbles, the fishing rod, the FLY bird). An earlier revision tried
and the standing FX attached to them (emote bubbles, the fishing rod,
the FLY bird). The Poké Center heal-machine overlay stays on the ground
plane with the machine tiles (it is OAM glued to a BG graphic, not a
standing sprite). An earlier revision tried
billboarding buildings/trees/signs too (cutting them out of the ground
per hand-curated per-tileset tables); that chased an endless tail of
special cases, dense tree canopy, fences fused into grass, building
@@ -137,7 +154,7 @@ Nidorino-vs-Gengar attract scene) mirror the original.
Options persist in a standalone `options.lua` (separate from the 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
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
migrated once into `options.lua` on load.
@@ -148,4 +165,18 @@ migrated once into `options.lua` on load.
hotkey `2` (OG RED = GBC boot-ROM look; RED++ uses pokered-gbc
SuperPalettes + per-species mon colors)
- TILT (OFF / 15 / 35 / 50), also hotkey `3` while free-roaming
- GBC FX (OFF / 1 / 2 / 3 / 4), also hotkey `5`
- 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`
- MAX FPS (30 / 40 / 50 / 60 / 75 / 90 / 100 / 120 / 144 / 160, default 60),
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.
+75
View File
@@ -299,3 +299,78 @@ function love.filedropped(file)
end
if Importer then Importer:filedropped(file) end
end
local function pacingEnabled()
if os.getenv("POKEPORT_AUTOPILOT") then return false end
if os.getenv("POKEPORT_DRIVER") then return false end
if os.getenv("POKEPORT_IMPORT_ONLY") == "1" then return false end
return true
end
function love.run()
if love.load then love.load(love.arg.parseGameArguments(arg), arg) end
-- don't let love.load's cost land in the first frame's dt
if love.timer then love.timer.step() end
local FrameCap = require("src.core.FrameCap")
local paced = pacingEnabled()
-- The deadline the next present() should not beat. Carried forward one
-- budget per frame so pacing stays even instead of drifting with the
-- per-frame sleep-granularity jitter.
local nextFrame = love.timer and love.timer.getTime() or 0
local dt = 0
return function()
-- process events
if love.event then
love.event.pump()
for name, a, b, c, d, e, f in love.event.poll() do
if name == "quit" then
if not love.quit or not love.quit() then
return a or 0
end
end
love.handlers[name](a, b, c, d, e, f)
end
end
-- update dt
if love.timer then dt = love.timer.step() end
-- call update and draw
if love.update then love.update(dt) end
if love.graphics and love.graphics.isActive() then
love.graphics.origin()
love.graphics.clear(love.graphics.getBackgroundColor())
if love.draw then love.draw() end
love.graphics.present()
end
if love.timer then
if paced then
-- Sleep out the remainder of the frame budget, measured from the
-- carried deadline, in small chunks so the OS timer stays
-- responsive. vsync is untouched: when it already paces slower
-- than the cap the remainder is <= 0 and this rounds to a no-op.
local budget = 1 / FrameCap.current
nextFrame = nextFrame + budget
local now = love.timer.getTime()
-- A stall (alt-tab, a GC pause, a blocked import) can leave the
-- deadline more than a full budget in the past; re-anchor to now so
-- we pace the next frame rather than burst uncapped to catch up.
if now - nextFrame > budget then
nextFrame = now
end
while true do
local remaining = nextFrame - love.timer.getTime()
if remaining <= 0 then break end
love.timer.sleep(remaining < 0.001 and remaining or 0.001)
end
else
love.timer.sleep(0.001)
end
end
end
end
+225 -49
View File
@@ -30,6 +30,9 @@ local TypeChart = require("src.battle.TypeChart")
local BattleState = {}
BattleState.__index = BattleState
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
-- 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
-- pics carry baked-in padding); used to sit the pic flush on the text box
local imagePadBottom = {}
-- fully transparent columns left of a pic's content; at 2x (back pics)
-- this is subtracted from hlcoord 1,5 so opaque pixels match hardware,
-- where those columns were white-on-white rather than shifted content
local imagePadLeft = {}
-- image -> { path, pal } so palette-fade variants (see fadeImage) can be
-- rebuilt for any battle pic, whatever code loaded it
local imageMeta = {}
@@ -63,7 +70,7 @@ local function getImage(path, pal, trueColor)
if trueColor then pal = nil end
local key = pal and (path .. "#" .. pal.name) or path
if not imageCache[key] then
local img, pad = nil, 0
local img, pad, padL = nil, 0, 0
if love.image and love.image.newImageData then
local id = Assets.imageData(path)
if pal then
@@ -86,13 +93,25 @@ local function getImage(path, pal, trueColor)
if opaque then break end
bottom = bottom - 1
end
local left = 0
while left < w do
local opaque = false
for y = 0, h - 1 do
local _, _, _, a = id:getPixel(left, y)
if a > 0 then opaque = true break end
end
if opaque then break end
left = left + 1
end
img = love.graphics.newImage(id)
pad = h - 1 - bottom
padL = left
else
img = Assets.image(path) -- headless stub: no pixel access
end
imageCache[key] = img
imagePadBottom[img] = pad
imagePadLeft[img] = padL
imageMeta[img] = { path = path, pal = pal, trueColor = trueColor or nil }
end
return imageCache[key]
@@ -101,7 +120,7 @@ end
-- hot reload: the next getImage re-resolves every pic through the asset
-- search path and re-measures its ground padding
function BattleState.invalidate()
imageCache, imagePadBottom, imageMeta = {}, {}, {}
imageCache, imagePadBottom, imagePadLeft, imageMeta = {}, {}, {}, {}
end
Assets.register(BattleState.invalidate)
@@ -258,6 +277,10 @@ local function makeBattler(data, mon, isPlayer, save)
}
end
-- LinkBattle builds clamped copies with save=nil (no badge boosts); wild
-- and trainer constructors pass the live save for the player side.
BattleState.makeBattler = makeBattler
-- The battle pic for `species` on the given side (back pic for the
-- player side, front pic for the enemy side), tinted PAL_GRAYMON --
-- the same path makeBattler uses, but forced gray -- since this is only
@@ -832,7 +855,7 @@ function BattleState:computeMusicKind()
return "final"
elseif isBoss or (self.trainer and self.trainer.id == "OPP_LANCE") then
return "gym"
elseif self.kind == "trainer" then
elseif self.kind == "trainer" or self.kind == "link" then
return "trainer"
end
return "wild"
@@ -894,6 +917,17 @@ function BattleState:enter()
self.showEnemyTrainer = false
self:startGrowIn(self.enemy)
end)
elseif self.kind == "link" then
-- Colosseum has no foe trainer pic, but the enemy mon still grows
-- out of the ball after "X sent out Y!" (not the wild "already there"
-- intro that LinkBattle previously inherited from newWild).
self.enemySendingOut = true
self:say(("%s sent\nout %s!"):format(self.opponentName or "FOE",
self.enemy.name))
self:act(function()
self.enemySendingOut = false
self:startGrowIn(self.enemy)
end)
end
if not self.ghost then
-- the enemy's cry plays as it appears (data/pokemon/cries.asm)
@@ -950,20 +984,37 @@ function BattleState:exit()
end
end
-- An action the battler is locked into (bypasses the menu), or nil.
function BattleState:lockedAction(battler)
-- End a trapping sequence (USING_TRAPPING_MOVE). SendOutMon clears the
-- foe's bit (core.asm:1761-1762); EnemySendOutFirstMon clears the
-- player's (core.asm:1314-1315). Any switch frees the other side.
local function clearTrapping(battler)
if not battler then return end
battler.trappingTurns = nil
battler.trapMove = nil
battler.trapDamage = nil
end
-- Actions that skip DisplayBattleMenu entirely (core.asm:300-310):
-- recharge, Rage, thrash, charge. Bide / trapping / being held do NOT
-- skip the menu -- the player can still item/switch (and must press
-- FIGHT to continue a trapping sequence).
function BattleState:menuLockedAction(battler)
if battler.mustRecharge then return { special = "recharge" } end
if battler.charging then return battler.charging end
if battler.thrashTurns and battler.thrashTurns > 0 then return battler.thrashMove end
if battler.rageMove then return battler.rageMove end
return nil
end
-- After FIGHT: skip MoveSelectionMenu (core.asm:320-329). Own
-- trapping/Bide continues; foe trapping forces CANNOT_MOVE ($ff).
function BattleState:fightLockedAction(battler)
if battler.trappingTurns and battler.trappingTurns > 0 then
return { special = "trapping" }
end
if battler.bideTurns then return { special = "bide" } end
if battler.rageMove then return battler.rageMove end
-- held in place while the OPPONENT's trapping move is running
-- (core.asm:316-322 reads the live USING_TRAPPING_MOVE bit, so a
-- trap ended early by paralysis/faint frees the victim immediately);
-- boundTurns is a mirror kept for Status.beforeMove's held check
-- held while the OPPONENT's trapping bit is set (live mirror so a
-- trap ended early by paralysis/faint frees the victim immediately)
local opp = battler.isPlayer and self.enemy or self.player
battler.boundTurns = opp and opp.trappingTurns
and math.max(1, opp.trappingTurns) or nil
@@ -973,6 +1024,11 @@ function BattleState:lockedAction(battler)
return nil
end
-- Full lock for AI / callers that need any forced action.
function BattleState:lockedAction(battler)
return self:menuLockedAction(battler) or self:fightLockedAction(battler)
end
function BattleState:playerHasPP()
for i, mv in ipairs(self.player.curMoves) do
if mv.pp > 0 and self.player.disabledSlot ~= i then return true end
@@ -1079,8 +1135,9 @@ function BattleState:update(dt)
if not (self.player.mustRecharge or self.player.rageMove) then
self.player.flinched, self.enemy.flinched = false, false
end
-- locked multi-turn actions skip the menu entirely
local locked = self:lockedAction(self.player)
-- only recharge/Rage/thrash/charge skip DisplayBattleMenu; trapping
-- victims (and wrappers) still get FIGHT/PKMN/ITEM/RUN (core.asm:312)
local locked = self:menuLockedAction(self.player)
if locked then
self:resolveTurn(locked)
return
@@ -1104,6 +1161,13 @@ function BattleState:update(dt)
end)
self:act(function() self:endOfTurn() end)
elseif choice == "fight" then
-- After the menu: own trapping/Bide or foe Wrap skips the move
-- list and forces the locked action (core.asm:320-329)
local fightLock = self:fightLockedAction(self.player)
if fightLock then
self:resolveTurn(fightLock)
return
end
if not self:playerHasPP() then
-- _NoMovesLeftText, then Struggle engages
self:say(("%s has no\nmoves left!"):format(self.player.name))
@@ -1514,6 +1578,9 @@ function BattleState:resolveSwitch(newMon)
self:restoreMimicked(self.player) -- the battle copy leaves with it
local previous = self.player
self.player = makeBattler(self.data, newMon, true, self.game.save)
-- SendOutMon (core.asm:1761-1762): player's send-out clears the
-- foe's USING_TRAPPING_MOVE -- Wrap/Bind/etc. ends on any switch
clearTrapping(self.enemy)
self:syncSides()
Runtime.emit("battle.battler_switched", {
battle = self, side = self.sides[1], battler = self.player,
@@ -1619,10 +1686,19 @@ end
-- only after MoveHitTest / the effect lands (HandleIfPlayerMoveMissed skips
-- it on a miss unless EXPLODE_EFFECT); we insert early for blink attachment
-- and peel it back on miss/fail paths.
-- Dig/Fly charge leaves the user pic hidden (SLIDE_DOWN / TELEPORT); the
-- second-turn DIG/FLY anim restores it via SE_SLIDE_MON_UP / SE_SHOW_MON_PIC.
-- Cancelling that row on miss/immune would otherwise leave the digger
-- invisible until another anim's resetPicFx (#100).
function BattleState:cancelMoveAnim()
local row = self.moveAnimRow
if not row then return end
self.moveAnimRow = nil
if row.anim == "DIG" or row.anim == "FLY" then
local user = row.attackerIsPlayer and self.player or self.enemy
local pf = user and self.picFx and self.picFx[user]
if pf then pf.hidden = nil end
end
for i, item in ipairs(self.queue) do
if item == row then
table.remove(self.queue, i)
@@ -1686,14 +1762,23 @@ end
-- transient pic effects reset when a new animation row starts (each
-- PlayAnimation redraws from a clean slate); `minimized` survives --
-- the minimize sprite replaces the pic DATA, so redraws keep it until
-- the pic is reloaded (switch/Transform/ChangeMonPic)
-- the minimize sprite replaces the pic DATA until reload. Dig/Fly's
-- charge hide is a cleared tilemap that must survive until SE_SHOW_* /
-- SE_SLIDE_MON_UP (or cancelMoveAnim on a missed Dig/Fly release):
-- clearing it here made Dig pop in before emerge and wrap/bounce (#100).
-- Other hides (Acid Armor, etc.) still clear so the next anim restores.
function BattleState:resetPicFx()
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.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
@@ -2094,6 +2179,8 @@ function BattleState:executeAction(user, target, action)
local oldName = self.enemy.name
self.enemyIndex = action.index
self.enemy = makeBattler(self.data, self.enemyParty[action.index], false)
-- EnemySendOutFirstMon (core.asm:1314-1315): clears player's trap
clearTrapping(self.player)
self:syncSides()
Runtime.emit("battle.battler_switched", {
battle = self, side = self.sides[2], battler = self.enemy,
@@ -2144,6 +2231,43 @@ function BattleState:executeAction(user, target, action)
self:performMove(user, target, action, false)
end
-- Sleep / confusion onomatopoeia from Check*StatusConditions
-- (core.asm): side-specific SLP_*/CONF_* anims, not the Rest/Amnesia
-- move rows. Player sleep plays the anim before FastAsleepText;
-- enemy sleep and both confusion sides print the text first.
function BattleState:statusOnomatopoeia(user, kind)
local isPlayer = user.isPlayer
local anim
if kind == "sleep" then
anim = isPlayer and "SLP_PLAYER_ANIM" or "SLP_ANIM"
else
anim = isPlayer and "CONF_PLAYER_ANIM" or "CONF_ANIM"
end
local text = kind == "sleep"
and (displayName(user) .. "\nis fast asleep!")
or (displayName(user) .. "\nis confused!")
if kind == "sleep" and isPlayer then
self:animNext(anim, isPlayer)
self:sayNext(text)
else
self:sayNext(text)
self:animNext(anim, isPlayer)
end
end
-- Queue status text (+ sleep/confusion FX when the line matches).
-- Wake / snap-out / flinch / etc. stay text-only.
function BattleState:sayStatusMsg(user, msg)
local text = prefixEnemy(msg, user)
if msg:find("is fast asleep!", 1, true) then
self:statusOnomatopoeia(user, "sleep")
elseif msg:find("is confused!", 1, true) then
self:statusOnomatopoeia(user, "confused")
else
self:sayNext(text)
end
end
-- The pre-recharge slice of CheckPlayerStatusConditions (core.asm:
-- 3328-3382): sleep -> freeze -> held-in-place -> flinch, each losing
-- the turn WITHOUT consuming the recharge flag. The disable/confusion/
@@ -2162,7 +2286,7 @@ function BattleState:preRechargeChecks(user, target)
mon.status = nil
self:sayNext(displayName(user) .. "\nwoke up!")
else
self:sayNext(displayName(user) .. "\nis fast asleep!")
self:statusOnomatopoeia(user, "sleep")
end
return true
end
@@ -2189,7 +2313,7 @@ end
-- returns true when the user's action is interrupted.
function BattleState:statusInterrupt(user, target)
local canMove, msgs, selfHit = Status.beforeMove(user, self.rng, self)
for _, m in ipairs(msgs) do self:sayNext(prefixEnemy(m, user)) end
for _, m in ipairs(msgs) do self:sayStatusMsg(user, m) end
if selfHit then
-- confusion self-hit (core.asm:3428-3434): clears everything in
-- 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
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
or (user.thrashTurns and user.thrashTurns > 0 and moveInst == user.thrashMove)
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)
end
@@ -2574,8 +2710,20 @@ function BattleState:enemyMonFainted()
self.participants = {}
if self.kind == "trainer" then
if self.enemyIndex < #self.enemyParty then
self.enemyIndex = self.enemyIndex + 1
-- EnemySendOutFirstMon / AnyEnemyPokemonAliveCheck (core.asm): scan
-- the whole enemy party for the first mon with HP left. Blindly
-- doing enemyIndex+1 softlocks after an AI switch (Agatha): a later
-- slot can already be fainted, so the empty-HP mon comes out, the
-- FIGHT menu returns, and executeAction no-ops on target.hp <= 0.
local nextIndex
for i, mon in ipairs(self.enemyParty) do
if mon.hp > 0 then
nextIndex = i
break
end
end
if nextIndex then
self.enemyIndex = nextIndex
-- SHIFT battle style (the default): announce the next mon and
-- offer a free switch (SET skips the prompt)
local nextMon = self.enemyParty[self.enemyIndex]
@@ -2599,6 +2747,7 @@ function BattleState:enemyMonFainted()
if mon ~= self.player.mon and mon.hp > 0 then
local previous = self.player
self.player = makeBattler(self.data, mon, true, game.save)
clearTrapping(self.enemy) -- SendOutMon clears foe trap
self:syncSides()
Runtime.emit("battle.battler_switched", {
battle = self, side = self.sides[1],
@@ -2624,6 +2773,8 @@ function BattleState:enemyMonFainted()
self:act(function()
local previous = self.enemy
self.enemy = makeBattler(self.data, self.enemyParty[self.enemyIndex], false)
-- EnemySendOutFirstMon (core.asm:1314-1315): clears player's trap
clearTrapping(self.player)
self:syncSides()
Runtime.emit("battle.battler_switched", {
battle = self, side = self.sides[2], battler = self.enemy,
@@ -2777,6 +2928,7 @@ function BattleState:openReplacementMenu()
self:restoreMimicked(self.player)
local previous = self.player
self.player = makeBattler(self.data, mon, true, game.save)
clearTrapping(self.enemy) -- SendOutMon clears foe trap
self:syncSides()
Runtime.emit("battle.battler_switched", {
battle = self, side = self.sides[1], battler = self.player,
@@ -3474,17 +3626,16 @@ function BattleState:drawBattlerPic(battler, x, y, scale)
love.graphics.draw(img, quad, x + ox, y + oy, 0, scale, scale)
end
elseif k == "slideUp" then
-- AnimationSlideMonUp: cyclic upward wrap, one row per 2 frames
local scroll = 8 * math.min(7, math.floor(t / 2) + 1)
local src = math.floor(scroll / scale) % h
if src == 0 then
love.graphics.draw(img, x + ox, y, 0, scale, scale)
else
local top = love.graphics.newQuad(0, src, w, h - src, w, h)
love.graphics.draw(img, top, x + ox, y, 0, scale, scale)
local bottom = love.graphics.newQuad(0, 0, w, src, w, h)
love.graphics.draw(img, bottom, x + ox, y + (h - src) * scale,
0, scale, scale)
-- AnimationSlideMonUp (animations.asm): 7 row steps x 2f. After Dig's
-- SLIDE_DOWN the tilemap is blank; each step fills the next bottom
-- row so the mon emerges from underground. A cyclic wrap of a full
-- pic looked like a bounce at Dig's end (#100).
local step = math.min(7, math.floor((t - 1) / 2) + 1)
local visible = math.floor(h * step / 7)
if visible > 0 then
local quad = love.graphics.newQuad(0, h - visible, w, visible, w, h)
love.graphics.draw(img, quad, x + ox,
y + (h - visible) * scale, 0, scale, scale)
end
elseif xscale < 1 then
-- AnimationSquishMonPic: columns collapse toward the middle
@@ -3696,6 +3847,20 @@ function BattleState:drawAnimLayer(colorized)
end
end
-- Front/trainer pics: LoadUncompressedSpriteData centers the sprite in
-- a 7x7 tile buffer, then CopyUncompressedPicToTilemap places that
-- buffer at hlcoord 12,0. Horizontal pad is floor((8-w)/2) tiles;
-- vertical pad is (7-h) -- bottom-aligned inside the 7x7.
local function enemyPicXY(img, slide, sx, sy)
local tw = math.floor(img:getWidth() / 8)
local th = math.floor(img:getHeight() / 8)
if tw < 1 then tw = 1 elseif tw > 7 then tw = 7 end
if th < 1 then th = 1 elseif th > 7 then th = 7 end
local hPad = math.floor((8 - tw) / 2)
local vPad = 7 - th
return 96 + 8 * hPad - slide + sx, 8 * vPad + sy
end
-- the two mon pics (or the trainer/back pics), offset by the window
-- shake -- on the GB the pics are BG tiles, so they move with it
function BattleState:drawPicsLayer(slide, sx, sy)
@@ -3713,19 +3878,18 @@ function BattleState:drawPicsLayer(slide, sx, sy)
g.intersectScissor(0, 0, 160, clipY)
clipped = true
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
-- the enemy trainer pic holds the mon slot until the send-out
local img = self:picImage(self.trainerPic)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(img, 160 - 8 - img:getWidth() - slide + sx,
math.max(0, 48 - img:getHeight()) + sy)
local ex, ey = enemyPicXY(img, slide, sx, sy)
love.graphics.draw(img, ex, ey)
elseif self.enemy and self.enemy.sprite and not self.enemyHidden
and not self.enemySendingOut and not self:fxHidden(self.enemy) then
local img = self:picImage(self.enemy.sprite)
love.graphics.setColor(1, 1, 1, 1)
local ex = 160 - 8 - img:getWidth() - slide + sx
local ey = math.max(0, 48 - img:getHeight()) + sy
local ex, ey = enemyPicXY(img, slide, sx, sy)
local gs = self:growInScale(self.enemy)
if gs then
-- AnimateSendingOutMon: the downscaled pic keeps its bottom edge
@@ -3739,15 +3903,18 @@ function BattleState:drawPicsLayer(slide, sx, sy)
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
if self.showPlayerBack and self.playerBackPic then
-- Red's (or the old man's) back pic until "Go!"; it stays up for
-- the whole safari / catch-demo battle like the original
local img = self:picImage(self.playerBackPic)
local pad = imagePadBottom[self.playerBackPic] or 0
local padL = imagePadLeft[self.playerBackPic] or 0
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(img, 16 + slide + sx,
love.graphics.draw(img, 8 - padL * 2 + slide + sx,
96 - (img:getHeight() - pad) * 2 + sy, 0, 2, 2)
elseif self.player and self.player.sprite and not hidePlayer
and not self.sendingOut and not self:fxHidden(self.player) then
@@ -3755,17 +3922,19 @@ function BattleState:drawPicsLayer(slide, sx, sy)
love.graphics.setColor(1, 1, 1, 1)
-- feet flush on the text box top (y=96), ignoring baked-in padding
local pad = imagePadBottom[self.player.sprite] or 0
local padL = imagePadLeft[self.player.sprite] or 0
local px = 8 - padL * 2 + sx
local gs = self:growInScale(self.player)
if gs then
-- the player-side AnimateSendingOutMon grow (after the poof,
-- core.asm:1757-1762): feet pinned at y=96, center at x=16+w
-- core.asm:1757-1762): feet pinned at y=96, center at x=8+w
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,
0, 2 * gs, 2 * gs)
end
else
self:drawBattlerPic(self.player, 16 + sx,
self:drawBattlerPic(self.player, px,
96 - (img:getHeight() - pad) * 2 + sy, 2)
end
end
@@ -3820,16 +3989,22 @@ function BattleState:drawHUDs(slide)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(("BALLx%2d"):format(self.safari.balls), 88, 72)
end
-- trainer-battle party pokeball rows during the intro
-- trainer/link party pokeball rows during the intro
-- (SetupPlayerAndEnemyPokeballs, draw_hud_pokeball_gfx.asm)
if self.kind == "trainer" and (self.showEnemyTrainer or self.showPlayerBack)
and slide == 0 then
local showIntroBalls = slide == 0 and (
(self.kind == "trainer" and (self.showEnemyTrainer or self.showPlayerBack))
or (self.kind == "link" and (self.showPlayerBack or self.enemySendingOut))
)
if showIntroBalls then
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)
end
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
local hidePlayer = self.safari or self.demo
@@ -3863,7 +4038,8 @@ function BattleState:drawTextArea()
if self.phase == "messages" and self.current then
local shown = 0
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
if shown >= self.charIndex then break end
Font.drawCode(codes[i], 8 + (i - 1) * 8, y)
+36 -27
View File
@@ -176,25 +176,51 @@ function EffectRegistry.runDamaging(battle, ctx, record)
end
battle.lastDamage = dmg -- wDamage (shared by both sides, read by Counter)
-- the hit blink + damage sound ride the queue behind the animation:
-- on the move's anim row when one was announced, else on a bare hit
-- row (thrash/rage continuations), placed BEFORE the drain rows the
-- hits loop inserts so the blink precedes the bar drain
local 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
-- the hit blink + damage sound ride each animation row, placed BEFORE
-- that hit's drain so the blink precedes the bar. Multi-hit moves
-- replay PlayMoveAnimation per strike (pokered: GetPlayerAnimationType
-- / GetEnemyAnimationType loop on wNumAttacksLeft); hit 1 reuses the
-- announcement-time moveAnimRow, later hits queue fresh anim rows.
-- Thrash/rage continuations have no announcement anim -- a bare
-- hitRow carries the blink instead.
local hitSfx = info.typeMult > 10 and "Super_Effective"
or info.typeMult < 10 and "Not_Very_Effective" or "Damage"
local hitFx = { sfx = hitSfx,
blink = battle:animationsOn() and target or nil }
local totalDealt = 0
local landed, brokeSub = 0, false
for h = 1, hits do
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 dealt = battle:applyDamage(target, dmg)
totalDealt = totalDealt + dealt
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
Runtime.emit("battle.damage_dealt", {
battle = battle, user = user, target = target, move = move,
@@ -208,23 +234,6 @@ function EffectRegistry.runDamaging(battle, ctx, record)
end
end
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
-- player: _MultiHitText; enemy: _HitXTimesText (always plural)
if user.isPlayer then
+7 -2
View File
@@ -15,7 +15,8 @@
-- (super-effective) or adds 1 (not-effective when a better move exists);
-- the MINIMUM-scored move is chosen, ties broken uniformly among the
-- tied minima (core.asm:2971-3002). A non-minimal move is never
-- selectable. Respects 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")
@@ -212,9 +213,13 @@ end
function TrainerAI.chooseMove(battler, rng, battle)
rng = rng or love.math.random
-- Gen 1: SelectEnemyMove never consults wEnemyMonPP; Struggle only when
-- every move slot is missing/disabled (core.asm:2957-2999). modern_clean
-- depletes enemy PP and falls back to Struggle when none remain.
local unlimited = battle and battle.ruleset and battle.ruleset.enemyUnlimitedPP
local usable = {}
for i, mv in ipairs(battler.curMoves) do
if mv.pp > 0 and battler.disabledSlot ~= i then
if battler.disabledSlot ~= i and (unlimited or mv.pp > 0) then
table.insert(usable, mv)
end
end
+3
View File
@@ -13,4 +13,7 @@ return {
randMax = 255,
-- Focus Energy famously QUARTERS the crit rate instead of x4
focusEnergyBug = true,
-- Wild/trainer enemies never spend PP (DecrementPP only touches the
-- player side in pokered). They therefore never Struggle from empty PP.
enemyUnlimitedPP = true,
}
+2
View File
@@ -9,4 +9,6 @@ return {
randMin = 217,
randMax = 255,
focusEnergyBug = false,
-- Gen 2+ style: AI opponents deplete PP and can Struggle when empty.
enemyUnlimitedPP = false,
}
+34
View File
@@ -230,6 +230,25 @@ function ChipAudio.ensureMusicPlaying()
end
end
-- Threaded playMusic returns an empty QueueableSource and only calls
-- Source:play once the first worker buffer lands (~1 frame later). Until
-- then Source:isPlaying is false -- callers that treat that as "song over"
-- (Music.oneShotPlaying / pendingRestore) must wait here instead, or a
-- playOnce jingle like Music_PkmnHealed is cut off before it starts.
local forceAwaitingFirstBuffer -- test-only override (see _simulate*)
function ChipAudio.awaitingFirstBuffer()
if forceAwaitingFirstBuffer then return true end
local m = currentMusic
if not (m and m.threaded and not m.started and not m.finished) then
return false
end
-- a dead worker will never deliver the first buffer
if workerReady == false then return false end
if worker and worker.getError and worker:getError() then return false end
return true
end
function ChipAudio.stopMusic()
if currentMusic and currentMusic.source then
pcall(currentMusic.source.stop, currentMusic.source)
@@ -240,6 +259,7 @@ function ChipAudio.stopMusic()
end
pendingBuf = nil
currentMusic = nil
forceAwaitingFirstBuffer = nil
end
-- hot reload: the next play re-reads programs.bin (a mod may have swapped the
@@ -302,6 +322,20 @@ end
-- test hooks (headless): synchronous synthesis straight through ChipSynth
-- ---------------------------------------------------------------------------
-- Force the "threaded, first buffer not yet queued" window so Music's
-- playOnce / pendingRestore race can be asserted without love.thread.
-- Returns a clear() that drops the override (call after the assertion).
function ChipAudio._simulateAwaitingFirstBufferForTest()
local m = currentMusic
if not m or not m.source then return nil end
m.threaded = true
m.started = false
m.finished = false
pcall(function() m.source.playing = false end)
forceAwaitingFirstBuffer = true
return function() forceAwaitingFirstBuffer = nil end
end
function ChipAudio._renderMusicForTest(data, header, seconds)
local engine = ChipSynth.newEngine(data, header, { allowLoops = true })
return ChipSynth.soundData(engine, math.floor(seconds * SAMPLE_RATE), 2)
+131 -2
View File
@@ -38,8 +38,18 @@ local state = {
unsubs = {},
pid = nil,
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()
return os.getenv("POKEPORT_NO_DISCORD") == "1"
or os.getenv("POKEPORT_AUTOPILOT") ~= nil
@@ -378,6 +388,15 @@ local function connect()
state.dirty = true
state.lastSentKey = nil
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")
return true
end)
@@ -427,7 +446,7 @@ local function buildActivity()
activityState = "At the title screen"
end
return {
local activity = {
details = details,
state = activityState,
timestamps = { start = state.startedAt },
@@ -436,10 +455,25 @@ local function buildActivity()
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
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
local function flush(force)
@@ -567,6 +601,96 @@ local function subscribe(game)
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)
local ok, err = pcall(function()
DiscordPresence.shutdown()
@@ -579,6 +703,8 @@ function DiscordPresence.init(game)
state.location = "Title screen"
state.mapId = nil
state.battleLabel = nil
state.joinCode = nil
state.game = game
state.dirty = true
state.nextReconnectAt = 0
state.loggedAbsent = false
@@ -621,6 +747,7 @@ function DiscordPresence.update(_dt)
end
return
end
pollJoinRequest()
if state.dirty then flush(false) end
end)
end
@@ -649,6 +776,8 @@ function DiscordPresence.shutdown()
state.lastSentKey = nil
state.connected = false
state.socket = nil
state.subscribedJoin = false
state.joinCode = nil
end
-- test / debug helpers
+8
View File
@@ -27,4 +27,12 @@ function FixedStep:update(dt)
end
end
-- Drop any pending catch-up steps. A hitch inside one logic step (map
-- seam setMap / song start) makes the next real-time dt huge; without this
-- the while-loop above would advance many walk frames before the next
-- draw, which looks like a slide with no leg animation (issue #93).
function FixedStep:discardCatchup()
self.accum = 0
end
return FixedStep
+72
View File
@@ -0,0 +1,72 @@
-- Render frame-rate cap. With a driver control panel forcing
-- vsync off, the 160x144 game is trivially cheap and love.run will present
-- thousands of frames a second; over hours that cooks the graphics driver
-- until a restart, and it wastes power whenever the window is left open in
-- the background. A hard cap bounds the present rate. Render-only: game
-- logic is fixed-step off dt (src/core/FixedStep.lua), so pacing present()
-- changes nothing about timing, audio, or determinism.
--
-- Persisted as save.options.fpsCap; applied from OptionsMenu and on boot
-- via Game:applyOptions. main.lua's love.run reads FrameCap.current each
-- frame for its sleep budget. The module never touches love.timer itself,
-- so it stays safe under the headless test stub.
local FrameCap = {}
-- Selectable steps: the normal framerate stops between the floor and the
-- ceiling. STEPS[1] == MIN and STEPS[#STEPS] == MAX, so the nearest-step
-- snap in normalize doubles as the clamp. Cycling past the last wraps.
FrameCap.STEPS = { 30, 40, 50, 60, 75, 90, 100, 120, 144, 160 }
FrameCap.MIN = 30
FrameCap.MAX = 160
FrameCap.DEFAULT = 60
-- The live cap the run loop paces to. Defaults so the launcher and the
-- save editor are paced before any save applies its stored option.
FrameCap.current = FrameCap.DEFAULT
-- Nearest valid step for an arbitrary value (a hand-edited options.lua or
-- an old save with no fpsCap key), so a bad number degrades to something
-- sane; nil / non-numbers fall back to the default. A value below MIN or
-- above MAX snaps to that end, since MIN/MAX are the first/last steps.
function FrameCap.normalize(value)
value = tonumber(value)
if not value then return FrameCap.DEFAULT end
local best, bestDiff = FrameCap.DEFAULT, math.huge
for _, step in ipairs(FrameCap.STEPS) do
local diff = math.abs(step - value)
if diff < bestDiff then best, bestDiff = step, diff end
end
return best
end
-- plain numeric text for the options row (e.g. "60")
function FrameCap.label(value)
return tostring(FrameCap.normalize(value))
end
-- cycle to the next/previous step, wrapping (the options row idiom)
function FrameCap.cycle(value, dir)
local steps = FrameCap.STEPS
local snapped = FrameCap.normalize(value)
local cur = 1
for i, step in ipairs(steps) do
if step == snapped then cur = i break end
end
local nextIdx = (cur - 1 + (dir or 1)) % #steps + 1
return steps[nextIdx]
end
-- Store the chosen cap as the live value the run loop paces to. Never
-- touches love.timer, so it is safe headless -- the loop just reads the
-- number back. Returns the normalized value it stored.
function FrameCap.apply(value)
FrameCap.current = FrameCap.normalize(value)
return FrameCap.current
end
function FrameCap.applyOptions(opts)
FrameCap.apply(opts and opts.fpsCap)
end
return FrameCap
+31 -2
View File
@@ -246,7 +246,11 @@ end
function Game:zoomStep(delta)
local Zoom = require("src.render.Zoom")
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
function Game:wheelmoved(_, dy)
@@ -320,9 +324,19 @@ function Game:keypressed(key)
self:writeOptions()
end
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
-- 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")
if not GBCFX.isSupported() then return end
self.save.options.gbcfx = GBCFX.cycle()
self:writeOptions()
return
@@ -443,9 +457,17 @@ function Game:applyOptions(opts)
if Sound.applyOptions then Sound.applyOptions(opts) end
require("src.render.PaletteFX").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)
-- normalizes a nil/garbage cap to the 60 default, so old saves with no
-- fpsCap key pace at the standard rate (issue #88)
require("src.core.FrameCap").applyOptions(opts)
Input:applyBindings(opts.bindings)
-- heal soft-bricked APK installs that already saved gbcfx > 0 (#136)
if gbcCleared then self:writeOptions() end
end
function Game:restoreSave(loaded, recovered)
@@ -457,6 +479,13 @@ function Game:restoreSave(loaded, recovered)
-- SaveData.load and skip on the format guard
local activeMods = self.modStatus and self.modStatus.loaded
SaveData.runMigrations(loaded, self.mods and self.mods.migrations, activeMods)
-- Issue #103: 0.1.11 softlocks left CONTINUE in HALL_OF_FAME with
-- lastOutdoor on Indigo. One-shot relocate + heal before validate.
if SaveData.needsPostGameRescue(loaded) then
SaveData.applyPostGameHome(loaded, self:bootConfig())
local Pokemon = require("src.pokemon.Pokemon")
for _, mon in ipairs(loaded.party or {}) do Pokemon.heal(mon) end
end
local modsDiff = SaveData.modsDiff(loaded, activeMods)
local report = SaveData.validate(loaded, self.data)
report.recovered = recovered
+5
View File
@@ -11,7 +11,12 @@ local DEFAULT_BINDINGS = {
z = "a", ["return"] = "a", space = "a",
x = "b", backspace = "b",
["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",
lshift = "select",
}
-- keys that map to "start" but also to "a" would conflict; keep Enter = a,
+18 -8
View File
@@ -254,6 +254,7 @@ function Music.stop()
require("src.core.ChipAudio").stopMusic()
state.current, state.source, state.loopSource, state.fade = nil, nil, nil, nil
state.chip = false
state.pendingRestore = nil
if previous and Runtime.wants("music.stopped") then
Runtime.emit("music.stopped", { song = previous })
end
@@ -347,18 +348,25 @@ end
function Music.playOnce(data, song)
if not songDef(data, song) then return false end
Music.play(data, song, false, { reason = "once" })
-- play() can no-op (hook silence, failed def); only arm restore when
-- the jingle actually became current
if state.current ~= song then return false end
state.pendingRestore = true
return true
end
-- is a playOnce jingle still sounding? (AnimateHealingMachine's
-- .waitLoop2 holds the healing machine until MUSIC_PKMN_HEALED ends)
local function chipAwaitingFirstBuffer()
return state.chip
and require("src.core.ChipAudio").awaitingFirstBuffer()
end
-- is a playOnce jingle still in flight? (AnimateHealingMachine's
-- .waitLoop2 / Mom heal / captain rub hold until MUSIC_PKMN_HEALED ends.)
-- pendingRestore stays set from playOnce until Music.update restores the
-- map theme, covering the threaded chip "empty QueueableSource" window
-- where Source:isPlaying is briefly false before the first buffer lands.
function Music.oneShotPlaying()
if not state.pendingRestore then return false 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
return state.pendingRestore == true
end
function Music.restoreMap(data)
@@ -441,8 +449,10 @@ function Music.update(data)
state.source = loopSrc
pcall(loopSrc.play, loopSrc)
end
-- do not treat "threaded source still waiting on its first buffer" as
-- ended, or playOnce jingles get restored over before they can sound
if state.pendingRestore and sourceStopped(state.source)
and not state.loopSource then
and not state.loopSource and not chipAwaitingFirstBuffer() then
Music.restoreMap(data)
end
end
+60 -1
View File
@@ -186,12 +186,18 @@ function SaveData.defaultOptions()
musicFilter = 0,
-- logic fast-forward multiplier; audio is unaffected (GameSpeed.lua)
speed = 1,
-- port display options (OptionsMenu / hotkeys 2/3/5)
-- port display options (OptionsMenu / hotkeys 2/3/4/5)
colors = "gbc",
tilt = 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
videoMode = "windowed",
-- hard render frame-rate cap; render-only pacing (issue #88, FrameCap.lua)
fpsCap = 60,
-- Native mod enablement is an installation option, not save-slot data.
-- Missing entries mean enabled so newly installed mods work by default.
mods = {},
@@ -476,6 +482,25 @@ SaveData.addCoreMigration(2, function(save)
end
end)
-- #131 / follow-up to #50: Game Corner poster grunt used to stay on the
-- floor after defeat (defeatedTrainers only). The #50 script now hides
-- him via objectToggles, but saves that already beat him never got the
-- toggle -- he still blocks the hideout switch. from=3 so every pre-4
-- save reconciles once, then is skipped after re-stamp.
SaveData.addCoreMigration(3, function(save)
local defeated = save.defeatedTrainers
if not defeated or not defeated["GAME_CORNER_obj_11"] then return end
save.objectToggles = save.objectToggles or {}
local mapToggles = save.objectToggles.GAME_CORNER
if not mapToggles then
mapToggles = {}
save.objectToggles.GAME_CORNER = mapToggles
end
if mapToggles.GAMECORNER_ROCKET ~= false then
mapToggles.GAMECORNER_ROCKET = false
end
end)
-- ------- write
-- Game progress only; options are written separately via saveOptions.
@@ -811,6 +836,36 @@ function SaveData.defaultHeal(boot)
return { map = map, x = boot.startX or 3, y = boot.startY or 6 }
end
-- Post-credits home (issue #103). pokered left the player in HALL_OF_FAME
-- after jp Init; this port places CONTINUE at the NewGameWarp bedroom and
-- retargets LAST_MAP exits (Red's house mats) at the heal-point town so
-- leaving the house does not dump the player back at Indigo Plateau.
-- Marks postGameHomeOk so a later intentional HoF save is not relocated.
function SaveData.applyPostGameHome(save, boot)
boot = type(boot) == "table" and boot or {}
local heal = SaveData.defaultHeal(boot)
save.lastHeal = { map = heal.map, x = heal.x, y = heal.y }
save.lastOutdoor = { id = heal.map, x = heal.x, y = heal.y }
save.player = save.player or {}
save.player.map = boot.startMap or "REDS_HOUSE_2F"
save.player.x = boot.startX or 3
save.player.y = boot.startY or 6
save.player.facing = boot.startFacing or "down"
save.postGameHomeOk = true
return heal
end
-- Softlocked 0.1.11 saves: still standing in HALL_OF_FAME after credits,
-- with lastOutdoor on Indigo. One-shot rescue on CONTINUE.
function SaveData.needsPostGameRescue(save)
if not (save and save.player and save.player.map == "HALL_OF_FAME") then
return false
end
if save.postGameHomeOk then return false end
local hof = save.hallOfFame
return type(hof) == "table" and #hof > 0
end
function SaveData.newGame(boot)
boot = type(boot) == "table" and boot or {}
local map = boot.startMap or "REDS_HOUSE_2F"
@@ -834,6 +889,10 @@ function SaveData.newGame(boot)
},
flags = {},
inventory = {},
-- Vanilla Gen1 seeds one Potion in the player's item PC
-- (wBoxItems / players_pc.asm); existing saves keep whatever they
-- already have — this only applies to New Game.
pcItems = { POTION = 1 },
party = {},
box = {},
money = boot.startMoney or 3000,
+1 -1
View File
@@ -48,7 +48,7 @@ local KEY = {
a = "z",
b = "x",
start = "escape",
select = "rshift",
select = "tab",
}
local function nowMs()
+1 -1
View File
@@ -7,7 +7,7 @@ local Version = {
engine = "1.0.0", -- game/engine release (semver triple)
modApi = 2, -- mod API major (manifest `api`)
linkProtocol = 2, -- link handshake wire version (Handshake.PROTOCOL)
saveFormat = 3, -- save.meta.format
saveFormat = 4, -- save.meta.format
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
end
-- draw() may leave the system hand cursor set while hovering a Play /
-- Choose control. Once the importer is torn down that draw path stops
-- running, so restore the arrow before handing off to boot (issue #114).
local function resetPointerCursor(self)
if self.android then return end
if not (love.mouse.isCursorSupported and love.mouse.isCursorSupported()) then
return
end
self.arrowCursor = self.arrowCursor or love.mouse.getSystemCursor("arrow")
love.mouse.setCursor(self.arrowCursor)
end
-- Verify + extract a ROM. The version is decided by the ROM's own SHA-1, so
-- dropping a Red or Blue cart into either column always lands in the right one.
function RomImporter:startData(data, displayName)
@@ -428,6 +440,7 @@ function RomImporter:startData(data, displayName)
-- Stay on the launcher; the player presses Play to boot the new game.
return
end
resetPointerCursor(self)
if self.onComplete then self.onComplete(version) end
end)
end
@@ -507,6 +520,7 @@ end
function RomImporter:play(version)
if self.workState == "working" then return end
if not self.ready[version] then return end
resetPointerCursor(self)
if self.onComplete then self.onComplete(version) end
end
@@ -1045,8 +1059,7 @@ function RomImporter:draw()
self.handCursor = self.handCursor or love.mouse.getSystemCursor("hand")
love.mouse.setCursor(self.handCursor)
else
self.arrowCursor = self.arrowCursor or love.mouse.getSystemCursor("arrow")
love.mouse.setCursor(self.arrowCursor)
resetPointerCursor(self)
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
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
-- has picked, and compatibility is decided from the two hellos, not the mode
function Handshake.hello(game, mode)
+387 -57
View File
@@ -15,6 +15,7 @@
-- them in link battles).
local Fingerprint = require("src.link.Fingerprint")
local Font = require("src.render.Font")
local Handshake = require("src.link.Handshake")
local Logger = require("src.core.Logger")
local Protocol = require("src.link.Protocol")
@@ -36,18 +37,45 @@ local function makeRng(seed)
end
end
-- battler builder shared by both sides -- NO badge boosts, so both
-- machines compute identical stats
-- Battlers go through BattleState.makeBattler so pics get the same
-- Assets.resolve + SGB/GBC palette + padBottom path as wild/trainer
-- battles. save=nil skips badge boosts so both machines keep identical
-- stats (Gen 1 cable battles famously kept badges; we still diverge).
local function mkBattler(data, mon, isPlayer)
local def = data.pokemon[mon.species]
local ok, img = pcall(love.graphics.newImage,
isPlayer and def.spriteBack or def.spriteFront)
return {
mon = mon, def = def, isPlayer = isPlayer, stages = {},
name = mon.nickname or def.name,
curStats = mon.stats, curTypes = def.types, curMoves = mon.moves,
sprite = ok and img or nil,
}
local BattleState = require("src.battle.BattleState")
return BattleState.makeBattler(data, mon, isPlayer, nil)
end
-- shared by LinkBattle.new (myParty/theirParty) and LinkBattle.newSpectator
-- (hostParty/guestParty): pack->unpack clamp so every perspective watching
-- a given match holds identical mon copies. errFmt(packedMon, why) builds
-- 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
-- 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
-- machines, so the copies are identical everywhere
local unpackOpts = { strict = opts.strict or false }
local myParty, theirParty = {}, {}
for _, p in ipairs(opts.myParty or {}) do
local mon = Protocol.unpackMon(game.data, p, unpackOpts)
if mon then
table.insert(myParty, mon)
elseif unpackOpts.strict then
return nil, ("Your %s can't\nbattle on the\nother game."):format(
tostring(p.species))
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
local unpackOpts = { strict = opts.strict or false, forceLevel = opts.forceLevel }
local myParty, myErr = unpackParty(game, opts.myParty, unpackOpts, function(p)
return ("Your %s can't\nbattle on the\nother game."):format(tostring(p.species))
end)
if not myParty then return nil, myErr end
local theirParty, theirErr = unpackParty(game, opts.theirParty, unpackOpts, function(p, why)
return ("Their %s isn't\nin this game.\n(%s)"):format(tostring(p.species), tostring(why))
end)
if not theirParty then return nil, theirErr end
if #myParty == 0 or #theirParty == 0 then
Logger.warn("link: empty party on one side")
end
@@ -213,6 +230,8 @@ function LinkBattle.new(game, net, opts)
self.player = mkBattler(game.data, myParty[1], true)
self.enemy = mkBattler(game.data, theirParty[1], false)
self.enemyParty = theirParty
self.playerParty = myParty -- intro ball row uses the clamped copies
self.opponentName = theirName
self.introText = ("%s wants\nto battle!"):format(theirName)
self.remoteHashes = {}
self.localHashes = {}
@@ -230,22 +249,62 @@ function LinkBattle.new(game, net, opts)
if text then s:say(text) 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)
if action and action.id then return game.data.moves[action.id] end
return nil
end
-- player-side SendOutMon (poof + grow-in + cry); mirrors BattleState
local function sendOutPlayer(s, mon)
local previous = s.player
s.player = mkBattler(game.data, mon, true)
s:syncSides()
Runtime.emit("battle.battler_switched", {
battle = s, side = s.sides[1], battler = s.player, previous = previous,
})
s.sendingOut = true
s:sayNext(s:sendOutText(s.player.name))
s:animNext("POOF_ANIM", false)
s:actNext(function()
s.sendingOut = false
s:startGrowIn(s.player)
require("src.core.Sound").playCry(s.data, s.player.mon.species)
end)
end
-- enemy-side EnemySendOut (grow-in + cry; no poof)
local function sendOutEnemy(s, mon)
local previous = s.enemy
s.enemy = mkBattler(game.data, mon, false)
s:syncSides()
Runtime.emit("battle.battler_switched", {
battle = s, side = s.sides[2], battler = s.enemy, previous = previous,
})
s.enemySendingOut = true
s:sayNext(("%s sent\nout %s!"):format(theirName, s.enemy.name))
s:actNext(function()
s.enemySendingOut = false
s:startGrowIn(s.enemy)
s:actNext(function()
require("src.core.Sound").playCry(s.data, s.enemy.mon.species)
end)
end)
end
-- decode a remote action message against the enemy battler
local function decodeTheirAction(s, msg)
if msg.kind == "move" then
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
return decodeWireAction(s, msg, s.enemy)
end
-- 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)
if myMsg.kind == "switch" then
local idx = myMsg.index
s:act(function()
s.player = mkBattler(game.data, myParty[idx], true)
s:sayNext(("Go! %s!"):format(s.player.name))
end)
s:act(function() sendOutPlayer(s, myParty[idx]) end)
myAction = nil
end
if theirSwitch then
s:act(function()
s.enemy = mkBattler(game.data, theirParty[theirSwitch], false)
s:sayNext(("%s sent\nout %s!"):format(theirName, s.enemy.name))
end)
s:act(function() sendOutEnemy(s, theirParty[theirSwitch]) end)
end
s:act(function()
@@ -452,10 +505,7 @@ function LinkBattle.new(game, net, opts)
self.playerMonFainted = function(s)
for _, mon in ipairs(myParty) do
if mon.hp > 0 then
s:act(function()
s.player = mkBattler(game.data, mon, true)
s:sayNext(("Go! %s!"):format(s.player.name))
end)
s:act(function() sendOutPlayer(s, mon) end)
return
end
end
@@ -468,10 +518,7 @@ function LinkBattle.new(game, net, opts)
self.enemyMonFainted = function(s)
for _, mon in ipairs(theirParty) do
if mon.hp > 0 then
s:act(function()
s.enemy = mkBattler(game.data, mon, false)
s:sayNext(("%s sent\nout %s!"):format(theirName, s.enemy.name))
end)
s:act(function() sendOutEnemy(s, mon) end)
return
end
end
@@ -498,6 +545,19 @@ function LinkBattle.new(game, net, opts)
if not s.result then
endAsDraw(s, ("%s left the\nbattle."):format(theirName))
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
if net.closed and not s.linkEnded and not s.result then
@@ -513,16 +573,48 @@ function LinkBattle.new(game, net, opts)
end
return
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)
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
self.finish = function(s)
if not s.linkEnded then
s.linkEnded = true
send({ type = "bye" })
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
baseFinish(s)
end
@@ -530,6 +622,244 @@ function LinkBattle.new(game, net, opts)
return self
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)
function LinkBattle.newHost(game, net, opts)
opts.role = "host"
+211 -7
View File
@@ -2,6 +2,8 @@
-- the other joins by typing that address in. Direct peer-to-peer over
-- 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 Handshake = require("src.link.Handshake")
local Net = require("src.link.Net")
@@ -15,6 +17,32 @@ LinkState.__index = LinkState
LinkState.isOpaque = true
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
-- the handshake (a pre-mod guest sends nothing until it hears the mode)
@@ -47,7 +75,23 @@ function LinkState.new(game)
return self
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)
DiscordPresence.setJoinCode(nil)
Runtime.emit("link.ended", { reason = reason or (message and "error" or "bye") })
if self.net then self.net:close() end
self.game.stack:pop()
@@ -101,7 +145,12 @@ function LinkState:decideCompat(mode, isHost)
mods = peer and peer.mods, fingerprint = peer and peer.fingerprint },
})
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
end
-- 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
if self.net then
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))
return
end
-- the peer vanished without a bye (only once the inbox is drained,
-- so a final message travelling with the disconnect still counts)
if self.net.closed and #self.net.inbox == 0
and self.stage ~= "menu" and self.stage ~= "addrEntry"
and self.stage ~= "notice" and self.stage ~= "battleRunning" then
and not PRE_CONNECT_STAGES[self.stage] and self.stage ~= "addrEntry"
and self.stage ~= "codeEntry" and self.stage ~= "notice"
and self.stage ~= "battleRunning" then
self:exitWith("The link was\nbroken.")
return
end
end
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
self.index = self.index == 1 and 2 or 1
elseif input:wasPressed("b") then
self:exitWith(nil)
self.stage = "menu"
self.index = 1
elseif input:wasPressed("a") then
self.net = Net.new()
if self.index == 1 then
@@ -151,6 +229,67 @@ function LinkState:update(dt)
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
if input:wasPressed("b") then self:exitWith(nil) return end
if self.net.paired then
@@ -208,6 +347,23 @@ function LinkState:update(dt)
self:exitWith(nil)
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
if input:wasPressed("b") then self:exitWith(nil) return end
local got, other = self:pollHello()
@@ -251,6 +407,9 @@ function LinkState:update(dt)
local msgs = self.net:poll()
for i, msg in ipairs(msgs) do
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 opts = {
myParty = Protocol.packParty(self.game.save.party),
@@ -259,6 +418,7 @@ function LinkState:update(dt)
seed = self.isHost and self.linkSeed or msg.seed,
verdict = self.verdict,
strict = Handshake.strict(self.verdict),
forceLevel = self.forceLevel,
}
local battle, why
if self.isHost then
@@ -309,7 +469,8 @@ function LinkState:startMode(mode, isHost)
end
self.net:send({ type = "party",
mons = Protocol.packParty(self.game.save.party),
seed = self.linkSeed })
seed = self.linkSeed,
forceLevel = isHost and self.forceLevel or nil })
end
end
@@ -389,12 +550,49 @@ end
function LinkState:draw()
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("JOIN A GAME", 32, 68)
Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68)
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
drawTitle("HOSTING")
Font.draw("Friend joins at:", 16, 48)
@@ -428,6 +626,12 @@ function LinkState:draw()
Font.draw("BATTLE", 32, 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
drawTitle("CONNECTED!")
if self.stage == "waitHello" then
+170 -1
View File
@@ -14,6 +14,15 @@
-- Plain luajit (headless tests) has no enet; Net.available() reports
-- that, and Net.loopbackPair() returns two in-memory ends with the
-- 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 Logger = require("src.core.Logger")
@@ -21,10 +30,14 @@ local Logger = require("src.core.Logger")
local hasEnet, enet = pcall(require, "enet")
if not hasEnet then enet = nil end
local hasSocket, socket = pcall(require, "socket")
if not hasSocket then socket = nil end
local Net = {}
Net.__index = Net
Net.DEFAULT_PORT = 7777
Net.DEFAULT_RELAY_ADDRESS = "147.182.215.255:7778"
function Net.available()
return enet ~= nil
@@ -34,6 +47,10 @@ function Net.defaultPort()
return tonumber(os.getenv("POKEPORT_LINK_PORT") or "") or Net.DEFAULT_PORT
end
function Net.defaultRelayAddress()
return os.getenv("POKEPORT_RELAY_ADDR") or Net.DEFAULT_RELAY_ADDRESS
end
-- monotonic-ish clock for the join timeout
local function now()
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)
peer = nil, -- the connected remote peer
inbox = {},
outbox = {}, -- messages queued before pairing completes
outbox = {}, -- messages queued before pairing completes (enet only)
paired = false,
address = nil, -- host: "ip:port" the other player types in
error = nil,
closed = false,
mode = nil,
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)
end
@@ -133,8 +154,56 @@ function Net:join(address)
return true
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)
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
local decoded = Json.decode(Json.encode(msg))
if decoded and not self.peerEnd.closed then
@@ -155,8 +224,102 @@ function Net:send(msg)
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()
function Net:update()
if self.tcpSocket then
self:updateTCP()
return
end
if self.peerEnd then return end -- loopback needs no pumping
if not self.enetHost or self.closed then return end
while true do
@@ -219,6 +382,12 @@ function Net:close()
self.closed = true
return
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.peer and self.paired and not self.closed then
-- graceful goodbye: disconnect_later delivers the queued
+17 -2
View File
@@ -77,6 +77,13 @@ function Protocol.unpackMon(data, packed, opts)
return nil
end
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 = {}
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)))
@@ -104,6 +111,14 @@ function Protocol.unpackMon(data, packed, opts)
if strict then return nil, "no shared moves" end
moves = { { id = "TACKLE", pp = 35 } }
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 {
species = packed.species,
level = level,
@@ -111,8 +126,8 @@ function Protocol.unpackMon(data, packed, opts)
dvs = dvs,
statExp = statExp,
stats = stats,
hp = math.max(0, math.min(stats.hp, math.floor(packed.hp or stats.hp))),
status = packed.status,
hp = hp,
status = status,
nickname = packed.nickname,
moves = moves,
-- 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
ManagerState.screenId = "ManagerState"
-- Same as OptionsMenu: without this, title LOGO zones leak through when
-- MODS is opened from the title-screen options path.
function ManagerState:sgbPalettes(game)
return require("src.render.PaletteFX").wholeNamed(game.data, "MEWMON")
end
-- the charmap has no * ~ + < > glyphs, so the status gutter uses what it
-- does have: staged-awaiting-restart, disabled, errored, dep-unhealthy
local GLYPH = { staged = ".", disabled = "-", errored = "!", blocked = "?" }
+6 -2
View File
@@ -872,11 +872,15 @@ R.palettes = {
}
-- keyed by species id, unlike the vanilla byDex array: a species past the
-- end of the dex gets an icon without punching a hole in the list
-- 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 = {
semantics = "record", target = "icons.bySpecies",
value = f.union{ f.str, f.rec{ image = f.path, frames = f.opt(f.int(1)) } },
example = 'mod.content.icons:register("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
+5
View File
@@ -230,6 +230,11 @@ function BattleTransition:draw()
love.graphics.setColor(0, 0, 0, 1)
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
-- a registered style may draw itself; the eight built-ins do not
+11
View File
@@ -148,6 +148,17 @@ function Font.advanceOf(code)
return page and page.advance or GLYPH
end
-- Pixel width of a string (glyph advances, not UTF-8 byte length).
-- Multi-byte charmap entries like "¥" are one glyph; callers that
-- right-align with `#text * 8` mis-place them.
function Font.width(text)
local w = 0
for _, code in ipairs(Font.encode(text)) do
w = w + Font.advanceOf(code)
end
return w
end
-- Draw a plain single-line string at pixel (x, y). Returns the width
-- drawn, which is #codes * 8 for every fixed-width page.
function Font.draw(text, x, y)
+29 -1
View File
@@ -22,6 +22,15 @@ GBCFX.level = 0
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
-- shadow blur are unrolled by hand).
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
function GBCFX.shader()
if not GBCFX.isSupported() then return nil end
if shader == nil then
local ok, sh = pcall(love.graphics.newShader, SHADER_SRC)
shader = ok and sh or false
@@ -222,6 +232,10 @@ function GBCFX.shader()
end
function GBCFX.setLevel(level)
if not GBCFX.isSupported() then
GBCFX.level = 0
return
end
level = math.floor(tonumber(level) or 0)
if level < 0 then level = 0 end
if level > 4 then level = 4 end
@@ -230,12 +244,26 @@ end
-- Advance OFF → 1 → 2 → 3 → 4 → OFF. Returns the new level.
function GBCFX.cycle()
if not GBCFX.isSupported() then
GBCFX.level = 0
return 0
end
GBCFX.setLevel((GBCFX.level + 1) % 5)
return GBCFX.level
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)
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)
return false
end
function GBCFX.levelLabel(level)
@@ -243,7 +271,7 @@ function GBCFX.levelLabel(level)
end
function GBCFX.active()
return GBCFX.level > 0 and GBCFX.shader() ~= nil
return GBCFX.isSupported() and GBCFX.level > 0 and GBCFX.shader() ~= nil
end
-- 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
end
-- SuperPalettes that differ between Red and Blue (pokered data/sgb/
-- sgb_palettes.asm IF DEF(_RED)/_BLUE). data/palettes_gbc.lua is the
-- Red-derived pokered-gbc pack, so under RED++ a Blue playthrough must
-- read these from the ROM-imported table or the title ribbon stays red
-- and the Game Corner reels keep Red's pink (issue #128).
local BLUE_VERSIONED = {
LOGO1 = true, SLOTS2 = true, SLOTS3 = true, SLOTS4 = true,
}
local function romNamedPal(data, name)
local p = data and data.palettes
return p and p.palettes and p.palettes[name]
end
-- named palette from the active pack (nil on stale builds / missing name).
-- RED++ falls back to the ROM pack for names the gbc table omits (rare).
-- OG RED short-circuits EVERY name to the one global GBC boot-ROM BG palette
@@ -251,11 +265,15 @@ end
-- GBC_OBJ green), so this stays a BG-only hook.
function PaletteFX.pal(data, name)
if PaletteFX.mode == "ogred" then return PaletteFX.ogBg() end
if GameVersion.isBlue() and BLUE_VERSIONED[name] then
local fromRom = romNamedPal(data, name)
if fromRom then return fromRom end
end
local p = PaletteFX.pack(data)
local c = p and p.palettes[name]
if c then return c end
if PaletteFX.usesGbcPack() and data and data.palettes then
return data.palettes.palettes[name]
if PaletteFX.usesGbcPack() then
return romNamedPal(data, name)
end
return nil
end
@@ -277,6 +295,16 @@ function PaletteFX.monPal(data, species, transformed)
return p.palettes.GRAYMON
or (data and data.palettes and data.palettes.palettes.GRAYMON)
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 c = p.palettes[name]
if c then return c end
@@ -290,6 +318,12 @@ end
-- palette name a species currently resolves to (for image-cache keys)
function PaletteFX.monPalName(data, species, transformed)
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)
if p and p.pokemon[species] then return p.pokemon[species] end
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 },
}
-- 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
-- tabletop and the checkerboard floor -- both raw tile $37 -- can take
-- different palettes; the vanilla-derived blockset shares the one tile
-- 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
-- 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 = {
CELADON_MART_ROOF = {
{ block = 29, cells = { [5] = true, [6] = true, [9] = true, [10] = true },
tile = 0x37, alias = 0x5a, group = 5 },
},
CELADON_MART_ROOF = LOBBY_TABLE_TOP_ALIAS,
CELADON_DINER = LOBBY_TABLE_TOP_ALIAS,
}
local ROOF_GROUP = 6
local ROUTE_6_SAFFRON = { mapId = "ROUTE_6", useMapId = "SAFFRON_CITY", cellYBelow = 2 }
+123 -16
View File
@@ -23,6 +23,29 @@ Renderer.HEIGHT = 144
-- endFrame composites the padded canvas back with a matching offset.
Renderer.UPRIGHT_MARGIN = 160
-- LOVE units + framebuffer pixels + the live unit→pixel ratio.
-- Android's DisplayMetrics.density (love.graphics.getDPIScale) is often
-- non-integer (1.5, 2.75, …). Integer scaling in units then maps each GB
-- pixel to a fractional number of framebuffer pixels → shimmer, uneven /
-- non-square "pixels", and movement judder (issue #87). Always derive the
-- crisp integer scale from the drawable pixel size; draw with (pixels/dpi)
-- so the GPU lands on whole framebuffer pixels. Desktop dpi=1 is unchanged.
local function displayMetrics()
local ww, wh = love.graphics.getDimensions()
local pw, ph = ww, wh
if love.graphics.getPixelDimensions then
pw, ph = love.graphics.getPixelDimensions()
end
local dpi = 1
if ww > 0 and pw > 0 then
dpi = pw / ww
elseif love.graphics.getDPIScale then
dpi = love.graphics.getDPIScale()
end
if not dpi or dpi < 1e-6 then dpi = 1 end
return ww, wh, pw, ph, dpi
end
function Renderer:init()
self.canvas = love.graphics.newCanvas(self.WIDTH, self.HEIGHT)
self.canvas:setFilter("nearest", "nearest")
@@ -36,10 +59,12 @@ function Renderer:init()
self.uprightActive = false
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()
local ww, wh = love.graphics.getDimensions()
return math.max(1, math.floor(math.min(ww / self.WIDTH, wh / self.HEIGHT)))
local _, _, pw, ph = displayMetrics()
return math.max(1, math.floor(math.min(pw / self.WIDTH, ph / self.HEIGHT)))
end
-- 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
-- exactly today's size (growth factor is 1 when tilt is inactive).
function Renderer:worldViewSize()
local ww, wh = love.graphics.getDimensions()
local s = Zoom.scale(self:fitScale())
local ww, wh, _, _, dpi = displayMetrics()
local s = Zoom.scale(self:fitScale()) / dpi
local vw, vh = Zoom.fillViewSize(s, ww, wh)
-- Even sizes keep Camera:follow on integer pixels (viewW/2 is integral),
-- so unfloored FX/sprite math cannot phase-shimmer against the tile layer.
if vw % 2 ~= 0 then vw = vw + 1 end
if vh % 2 ~= 0 then vh = vh + 1 end
if Tilt.active() then
local g = Tilt.viewGrowth()
vw, vh = math.ceil(vw * g), math.ceil(vh * g)
@@ -62,6 +91,11 @@ end
function Renderer:beginFrame(transparent)
self.worldActive = false
self.uprightActive = false
-- warp-fade overlay from Transition (issue #121); cleared each frame so
-- a popped transition cannot leave a sticky black veil
self.worldFadeAlpha = nil
-- battle-transition cascade outside the 160x144 wipe (BattleTransition)
self.battleCascadeProg = nil
-- last frame's trueColor rects and sprite redraws go before anything
-- draws this one
PaletteFX.clearTrueColor()
@@ -75,6 +109,46 @@ function Renderer:beginFrame(transparent)
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()
local vw, vh = self:worldViewSize()
if not self.worldCanvas or self.worldCanvas:getWidth() ~= vw
@@ -286,11 +360,14 @@ end
-- presented through the GBC FX shader as a final pass.
function Renderer:endFrame(zones, worldZones)
love.graphics.setCanvas()
local ww, wh = love.graphics.getDimensions()
local S = self:fitScale()
local ww, wh, pw, ph, dpi = displayMetrics()
-- Sp = integer framebuffer pixels per GB pixel; S = LOVE-unit draw scale.
local Sp = self:fitScale()
local S = Sp / dpi
local vpw, vph = self.WIDTH * S, self.HEIGHT * S
local ox = math.floor((ww - vpw) / 2)
local oy = math.floor((wh - vph) / 2)
-- Snap the letterbox origin to a framebuffer pixel, then convert to units.
local ox = math.floor((pw - self.WIDTH * Sp) / 2) / dpi
local oy = math.floor((ph - self.HEIGHT * Sp) / 2) / dpi
local GBCFX = require("src.render.GBCFX")
-- Forced mono/Classic modes still need a whole-screen zone when a state
-- exposes no SGB packets (raw DMG canvas), so sendColors can remap.
@@ -314,13 +391,26 @@ function Renderer:endFrame(zones, worldZones)
present = self.presentCanvas
love.graphics.setCanvas(present)
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.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
-- 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 shader = zoneList and zoneList[1] and PaletteFX.shader() or nil
if not shader then
@@ -352,11 +442,12 @@ function Renderer:endFrame(zones, worldZones)
end
if self.worldActive then
local s = Zoom.scale(S)
local sp = Zoom.scale(Sp)
local s = sp / dpi
local wvw = self.worldCanvas:getWidth()
local wvh = self.worldCanvas:getHeight()
local wox = math.floor((ww - wvw * s) / 2)
local woy = math.floor((wh - wvh * s) / 2)
local wox = math.floor((pw - wvw * sp) / 2) / dpi
local woy = math.floor((ph - wvh * sp) / 2) / dpi
-- Tilt mode projects the ground world pass through the perspective mesh
-- (SGB zones baked in beforehand -- see drawTiltedWorld -- so no zone
-- scissoring here). drawTiltedWorld returns false when tilt is off or
@@ -414,13 +505,29 @@ function Renderer:endFrame(zones, worldZones)
love.graphics.draw(self.uprightCanvas, wox - M * s, woy - M * s, 0, s, s)
love.graphics.setScissor()
end
-- Screen-space warp fade (Transition) over the full world composite so
-- survey zoom / tilt edges darken with the center, not only the 160x144
-- UI letterbox. Drawn before the UI blit so menus above a fade still
-- composite normally if one is ever stacked that way.
local fade = self.worldFadeAlpha
if fade and fade > 0 then
love.graphics.setColor(0, 0, 0, fade)
love.graphics.rectangle("fill", 0, 0, ww, wh)
love.graphics.setColor(1, 1, 1, 1)
end
-- 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
-- UI stays in the classic centered GB letterbox
blit(self.canvas, S, zones, S, ox, oy, ox, oy, vpw, vph)
if present then
love.graphics.setCanvas()
GBCFX.present(present, S)
-- shader grid/shadow math is in framebuffer pixels
GBCFX.present(present, Sp)
end
self.worldActive = 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)
-- OVERWORLD maps fill beyond-edge space with the solid tree wall
-- (blockset $0F: four regular-tree metatiles, tiles $40/$41/$50/$51,
-- the border block of ViridianCity/CeruleanCity/CeladonCity et al.),
-- not each map's own border_block, which can be grass ($0B, the
-- CutTreeBlockSwaps $0B->$0A cut-grass block) or water; other
-- tilesets keep their designated border (interiors stay black/void)
-- OVERWORLD maps fill beyond-edge space from save.options.voidFill:
-- trees (default) — solid tree wall $0F (Viridian/Cerulean/Celadon)
-- water — solid water $43 (Cinnabar/Route 19 border block)
-- black — solid black (no tiled metatile)
-- Other tilesets keep their designated border (interiors stay black/void).
local TREE_WALL_BLOCK = 0x0F
local WATER_BORDER_BLOCK = 0x43
TileRenderer.VOID_FILLS = { "trees", "water", "black" }
TileRenderer.voidFill = "trees"
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
end
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 function getImage(path)
@@ -55,8 +93,29 @@ local FLOWER_IMAGES = {
local SPINNER_STRIP = "assets/generated/tilesets/spinners.png"
local animFrame = 0
function TileRenderer.tick()
animFrame = animFrame + 1
local animAccum = 0
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
-- ------------------------------------------------------------------
@@ -466,42 +525,118 @@ function TileRenderer.new(map, data)
self.anims = anims
self.claimedBy = claimedBy
-- a repeating 32x32 image of the border block, tiled behind
-- everything the 3-block ring doesn't cover (the survey zoom sees
-- far past the ring; interiors keep their black border this way)
pcall(function()
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)
-- border-fill image is built lazily in :ensureBorderFill so a VOID FILL
-- option change can swap trees/water/black without reloading the map
self.borderFillMode = nil
self.borderFill = nil
return self
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
-- meshes seamlessly with the ring batch)
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
local x, y = math.floor(camX), math.floor(camY)
-- one reused Quad per renderer, mutated in place: this runs every
-- overworld frame, so allocating a fresh Quad here churned the GC
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
q:setViewport(x, y, vw, vh, 32, 32)
else
@@ -709,6 +844,9 @@ function TileRenderer:releaseBatches()
safeRelease(self.winBatch); self.winBatch = nil
safeRelease(self.borderFill); self.borderFill = nil
safeRelease(self.borderQuad); self.borderQuad = nil
-- shared shift-variant cache; only drop the reference
self.borderWaterTextures = nil
self.borderFillMode = nil
self.win = nil
if self.quads then
for _, q in pairs(self.quads) do safeRelease(q) end
+10
View File
@@ -57,6 +57,16 @@ end
function Transition:draw()
local alpha = self.t / self.frames
if self.phase == "in" then alpha = 1 - alpha end
-- Survey zoom draws the overworld into a window-filling world canvas
-- while the UI pass stays the classic 160x144 letterbox. A rect on the
-- UI canvas only darkens that center box (issue #121); when the world
-- pass ran this frame, hand the alpha to Renderer:endFrame so it paints
-- a screen-space overlay over the full composite instead.
local r = self.game and self.game.renderer
if r and r.worldActive then
r.worldFadeAlpha = alpha
return
end
love.graphics.setColor(0, 0, 0, alpha)
love.graphics.rectangle("fill", 0, 0, 160, 144)
love.graphics.setColor(1, 1, 1, 1)
+41 -5
View File
@@ -1,6 +1,7 @@
-- Overworld survey zoom: integer pixels-per-world-pixel scales stepped
-- by the mouse wheel. Stored as an offset from the window fit scale S
-- so a resize keeps the relative zoom. Session-only; never saved.
-- by the mouse wheel, Options ZOOM row, or hotkey `4`. Stored as an
-- 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)
local Zoom = {}
@@ -12,16 +13,51 @@ function Zoom.scale(S)
return math.max(1, math.min(2 * S, S + Zoom.offset))
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)
Zoom.offset = Zoom.offset + delta
if S + Zoom.offset < 1 then Zoom.offset = 1 - S end
if S + Zoom.offset > 2 * S then Zoom.offset = S end
Zoom.offset = Zoom.clampOffset(Zoom.offset + delta, S)
return Zoom.offset
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
function Zoom.reset()
Zoom.offset = 0
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
-- (legacy GB-framed size; prefer fillViewSize for the live world pass)
function Zoom.viewSize(S, w, h)
+137 -22
View File
@@ -438,6 +438,20 @@ function Commands.play_sound(ctx, soundId)
require("src.core.Sound").play(ctx.game.data, soundId)
end
-- play_once <songId>: one-shot jingle (Music_PkmnHealed, etc.); blocks
-- until it finishes so heal-rest scripts (Mom, captain text_asm) match
-- Gen1's wait-on-channel loop. The map theme resumes when it ends
-- (Music.playOnce / pendingRestore).
function Commands.play_once(ctx, songId)
local Music = require("src.core.Music")
if not Music.playOnce(ctx.game.data, songId) then return end
local runner = ctx.runner
runner.waitingCheck = function()
return not Music.oneShotPlaying()
end
runner:yield()
end
-- play_cry <species>: PlayCry (home/audio.asm). The text_asm bodies that
-- use it run text_far (a no-button-wait "...@" string) -> PlayCry ->
-- WaitForSoundToFinish, all within the same text ID -- the cry only starts
@@ -470,11 +484,51 @@ function Commands.heal_party(ctx)
end
end
-- AskName (engine/menus/naming_screen.asm): yes/no then NamingScreen.
-- AddPartyMon only offers this for party mons (box deposits skip it).
-- Preserves ctx.lastCheck so GivePokemon's carry is not clobbered by the
-- yes/no result (scripts jump_if_false on give failure afterwards).
local function askNickname(ctx, mon)
local runner = ctx.runner
if not runner then return end
local success = ctx.lastCheck
local name = ctx.game.stringBuffer
or (ctx.game.data.pokemon[mon.species]
and ctx.game.data.pokemon[mon.species].name)
or mon.species
-- Prefer the real text label; fall back to the BattleState wording.
if ctx.game.data.text and ctx.game.data.text._DoYouWantToNicknameText then
Commands.show_text(ctx, "_DoYouWantToNicknameText", { RAM = name })
else
Commands.show_text(ctx, ("Do you want to\ngive a nickname\nto %s?"):format(name))
end
local ChoiceBox = require("src.ui.ChoiceBox")
ctx.game.stack:push(ChoiceBox.new(ctx.game, function(yes)
if not yes then
ctx.lastCheck = success
runner:resume()
return
end
Screens.push(ctx.game, "NamingScreen", {
title = "NICKNAME?", maxLen = 10,
onDone = function(nick)
if nick and #nick > 0 then mon.nickname = nick end
ctx.lastCheck = success
runner:resume()
end,
})
end))
runner:yield()
ctx.lastCheck = success
end
-- give_pokemon <species> <level>: _GivePokemon (engine/events/
-- give_pokemon.asm) -- party first, then the box. ctx.lastCheck gets
-- the asm's carry: true when the mon was given, false when both the
-- party and every box are full (that .boxFull path leaves the giver's
-- script able to offer again later, e.g. the Celadon Eevee ball).
-- Party adds run AskName (AddPartyMon) when a script runner is present;
-- mods that pre-set gift.nickname skip the prompt.
function Commands.give_pokemon(ctx, species, level)
-- Native mods can transform a gift before the Pokémon object is created.
-- This is intentionally an event rather than a special-case starter hook:
@@ -491,7 +545,8 @@ function Commands.give_pokemon(ctx, species, level)
ctx.game.stringBuffer = ctx.game.data.pokemon[species].name or species
ctx.pendingPokemonName = species
require("src.battle.BattleState").stampOT(ctx.save, mon)
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
ctx.lastCheck = false
return
@@ -503,18 +558,40 @@ function Commands.give_pokemon(ctx, species, level)
dex.owned[species] = true
end
ctx.lastCheck = true
-- AddPartyMon AskName: party only; skip box deposits, mod-set nicknames,
-- and callback-style callers that have no script runner to yield on.
if addedToParty and not gift.nickname and ctx.runner then
askNickname(ctx, mon)
end
end
function Commands.give_money(ctx, amount)
ctx.save.money = math.max(0, ctx.save.money + amount)
end
-- Point LAST_MAP exits at an outdoor door (pokered wLastMap). Keeps the
-- live overworld memory in sync so a scripted home warp from the HoF PC
-- does not leave Red's house mats aimed at Indigo Plateau (#103).
function Commands.remember_outdoor(ctx, mapId, x, y)
local outdoor = { id = mapId, x = x, y = y }
ctx.save.lastOutdoor = outdoor
if ctx.overworld and ctx.overworld.rememberOutdoor then
ctx.overworld:rememberOutdoor(mapId, x, y)
elseif ctx.overworld then
ctx.overworld.lastOutdoor = outdoor
end
end
-- Hall of Fame: snapshot the winning party (SaveHallOfFameTeams inside
-- AnimateHallOfFame), run the induction showcase and the end credits,
-- autosave while THE END is up, then soft-reset to the title -- the whole
-- predef HallOfFamePC + tail of HallOfFameResetEventsAndSaveScript
-- (engine/movie/hall_of_fame.asm, engine/movie/credits.asm,
-- scripts/HallOfFame.asm).
--
-- Departure from pokered: CONTINUE lands in the NewGameWarp bedroom with
-- a healed party and lastOutdoor on Pallet Town, instead of remaining
-- softlocked in HALL_OF_FAME with LAST_MAP still aimed at Indigo (#103).
function Commands.record_hall_of_fame(ctx)
ctx.save.hallOfFame = ctx.save.hallOfFame or {}
local entry = {}
@@ -530,17 +607,25 @@ function Commands.record_hall_of_fame(ctx)
Screens.push(game, "Credits", function()
runner:resume()
end, function()
-- THE END is on screen: HallOfFameResetEventsAndSaveScript sets
-- wLastBlackoutMap := PALLET_TOWN and runs SaveGameData, so the
-- save keeps the player standing in the HALL_OF_FAME room. (The
-- E4 room-script/event resets that precede the save in pokered are
-- the Indigo lobby's re-entry reset here, data/scripts/story6.lua.)
-- pokered writes PALLET_TOWN here as a literal, not as "the spawn" --
-- the vanilla spawn is REDS_HOUSE_2F. SaveData.defaultHeal carries
-- that split (and lets a total conversion redirect it).
-- THE END is on screen: heal, place the player at post-game home,
-- retarget LAST_MAP, then SaveGameData. (E4 room-script/event
-- resets that precede the save in pokered are the Indigo lobby's
-- re-entry reset here, data/scripts/story6.lua.)
local SaveData = require("src.core.SaveData")
local Pokemon = require("src.pokemon.Pokemon")
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
-- writeSave's captureSave re-stamps the live HALL_OF_FAME coords;
-- re-apply home and persist so CONTINUE resumes in the bedroom.
SaveData.applyPostGameHome(ctx.save, boot)
SaveData.save(ctx.save)
end)
end)
runner:yield()
@@ -855,15 +940,21 @@ function Commands.push_screen(ctx, screenId, args)
runner:yield()
end
-- fade "out"|"in" [frames]: screen fade without warping (the Transition
-- ramp startWarpTo uses, split in two). "out" pushes a black overlay
-- that stays up; the held state keeps ticking the runner's frame-waits
-- so a script can wait/replace_block under it. "in" ramps it away.
-- fade "out"|"in" [frames|"white"|"black"] [frames|"white"|"black"]:
-- screen fade without warping (the Transition ramp startWarpTo uses,
-- split in two). "out" pushes an overlay that stays up; the held state
-- keeps ticking the runner's frame-waits so a script can wait /
-- heal_party / play_once under it. "in" ramps it away.
-- Color defaults to black (warp-style); "white" matches GBFadeOutToWhite
-- / GBFadeInFromWhite (Mom heal, Silph Co. nurse). White defaults to
-- 24 frames (3 palettes x 8); black defaults to 12 (Transition).
local FadeOverlay = {}
FadeOverlay.__index = FadeOverlay
function FadeOverlay.new(game, ow)
return setmetatable({ game = game, ow = ow, alpha = 0 }, FadeOverlay)
function FadeOverlay.new(game, ow, color)
return setmetatable({
game = game, ow = ow, alpha = 0, color = color or "black",
}, FadeOverlay)
end
function FadeOverlay:update()
@@ -889,28 +980,51 @@ function FadeOverlay:update()
end
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.setColor(1, 1, 1, 1)
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
if not ow then return end
local runner = ctx.runner
frames = frames or 12 -- Transition's ramp length
local frames, color = parseFadeArgs(framesOrColor, colorOrFrames)
local overlay = ow.fadeOverlay
if dir == "out" then
if not overlay then
overlay = FadeOverlay.new(ctx.game, ow)
overlay = FadeOverlay.new(ctx.game, ow, color)
ow.fadeOverlay = overlay
ctx.game.stack:push(overlay)
else
overlay.color = color
end
overlay.ramp = { from = overlay.alpha, to = 1, frames = frames, t = 0,
onDone = function() runner:resume() end }
runner:yield()
elseif dir == "in" then
if not overlay then return end
overlay.color = color or overlay.color
overlay.ramp = { from = overlay.alpha, to = 0, frames = frames, t = 0,
onDone = function() runner:resume() end }
runner:yield()
@@ -998,9 +1112,10 @@ for _, verb in ipairs({ "show_text", "ask", "choice", "start_battle", "warp",
end
for _, verb in ipairs({ "show_text", "ask", "choice", "start_battle", "warp",
"open_mart", "trade", "push_screen", "record_hall_of_fame",
"old_man_demo", "static_battle", "rival_battle", "give_item", "wait",
"old_man_demo", "static_battle", "rival_battle", "give_item",
"give_pokemon", "wait",
"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 {}
Commands.meta[verb] = meta
meta.blocking = true
+3 -1
View File
@@ -216,7 +216,9 @@ local function useOn(game, battle, id, target, list, moveIndex)
consume(game, id)
require("src.core.Sound").play(game.data, "Teleport_Exit1")
ow.player.surfing = false
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
showMessages(game, { "OAK: " .. game.save.player.name
.. "!\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 })
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 = {
{ id = "up", label = "UP", key = "up" },
{ id = "down", label = "DOWN", key = "down" },
@@ -20,7 +22,7 @@ local BUTTONS = {
{ id = "a", label = "A", key = "z" },
{ id = "b", label = "B", key = "x" },
{ 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
@@ -32,13 +34,29 @@ local function boundKey(overlay, def)
return def.key
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)
local overlay = game.save and game.save.options
and game.save.options.bindings
local items = {}
for i, def in ipairs(BUTTONS) do
items[i] = { label = def.label,
right = boundKey(overlay, def):upper(), button = def }
right = boundRight(overlay, def), button = def }
end
local self = setmetatable(ListMenu.new(game, "CONTROLS", items, {}),
BindingsMenu)
@@ -78,7 +96,7 @@ function BindingsMenu:storeBinding(slot, value)
end
b[slot] = value
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)
if game.writeOptions then game:writeOptions() end
end
+5 -1
View File
@@ -145,6 +145,10 @@ end
function BoxMenu.new(game)
Boxes.ensure(game.save)
-- bills_pc.asm BillsPCMenu: TextBoxBorder at (0,0) with interior
-- 12x10 → total 14x12. "CHANGE BOX" is 10 tiles and needs the
-- full interior (cursor col + label); the old tw=12 right-side box
-- drew the final glyph on the border.
return Menu.new(game, {
{ label = "WITHDRAW", onSelect = function() withdraw(game) end },
{ label = "DEPOSIT", onSelect = function() deposit(game) end },
@@ -153,7 +157,7 @@ function BoxMenu.new(game)
{ label = "SEE YA!" },
-- Bill's PC runs silent end to end (BIT_NO_MENU_BUTTON_SOUND,
-- engine/menus/pokemon_pc.asm)
}, { tx = 8, ty = 0, tw = 12, th = 12, noSound = true })
}, { tx = 0, ty = 0, tw = 14, th = 12, noSound = true })
end
return BoxMenu
+2 -1
View File
@@ -217,7 +217,8 @@ function Credits:update(dt)
self.timer = FADE_FRAMES
elseif self.phase == "end_fade" then
-- 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
self.phase = "end_hold"
self.timer = 600
+23 -4
View File
@@ -1,5 +1,11 @@
-- Pokédex entry page: front sprite, kind, height/weight and the real
-- dex description (data/pokemon/dex_entries.asm + dex_text.asm).
--
-- `species` may be a species id string, or a table
-- `{ species = id, forceOwned = true }`. forceOwned mirrors pret's
-- StarterDex (engine/events/starter_dex.asm), which temporarily sets the
-- owned bit so Oak's lab ball previews show height/weight/description
-- without permanently marking the mon owned.
local Font = require("src.render.Font")
@@ -16,8 +22,17 @@ function DexEntryMenu:sgbPalettes(game)
P.zone(P.monPal(game.data, self.def and self.def.id), 1, 1, 8, 8) }
end
function DexEntryMenu.new(game, species)
local self = setmetatable({ game = game }, DexEntryMenu)
local function resolveArgs(speciesOrOpts)
if type(speciesOrOpts) == "table" then
return speciesOrOpts.species or speciesOrOpts[1],
speciesOrOpts.forceOwned and true or false
end
return speciesOrOpts, false
end
function DexEntryMenu.new(game, speciesOrOpts)
local species, forceOwned = resolveArgs(speciesOrOpts)
local self = setmetatable({ game = game, forceOwned = forceOwned }, DexEntryMenu)
self.def = game.data.pokemon[species]
local ok, img = pcall(love.graphics.newImage, self.def.spriteFront)
self.sprite = ok and img or nil
@@ -42,12 +57,16 @@ function DexEntryMenu:draw()
love.graphics.setColor(0, 0, 0, 1)
Font.draw(def.name, 72, 8)
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
-- prints the extra digit everywhere at once
local digits = (self.game.data.constants or {}).dexDigits or 3
Font.draw(("No.%0" .. digits .. "d"):format(def.dex or 0), 72, 32)
local owned = self.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
-- (pokedex.asm: "if the pokemon has not been owned, don't print the
-- height, weight, or description")
+204 -79
View File
@@ -1,14 +1,16 @@
-- Hall of Fame induction (engine/movie/hall_of_fame.asm): each party
-- member's front sprite scrolls onto the screen (HoFShowMonOrPlayer's
-- .ScrollPic), then its name/level shows and its cry plays
-- (HoFDisplayAndRecordMonInfo). After the last mon, HoFDisplayPlayerStats
-- shows the trainer name, play time, money and Prof. Oak's dex rating.
-- Plays Music_HallOfFame when the audio data has it. Calls onDone() after
-- popping itself.
-- member's front sprite scrolls onto the right side of the screen
-- (HoFShowMonOrPlayer's .ScrollPic), then HoFDisplayMonInfo draws the
-- left-side LEVEL/TYPE box, plays the cry, holds, and pops the bottom
-- "HALL OF FAME" text box before fading to the next mon. After the
-- party, the player pic scrolls in and HoFDisplayPlayerStats shows the
-- name/time/money boxes plus the dex rating. Plays Music_HallOfFame
-- when the audio data has it. Calls onDone() after popping itself.
local Font = require("src.render.Font")
local Music = require("src.core.Music")
local Sound = require("src.core.Sound")
local TypeChart = require("src.battle.TypeChart")
local HallOfFame = {}
HallOfFame.__index = HallOfFame
@@ -17,6 +19,10 @@ HallOfFame.isOpaque = true
-- SGB: SetPal_PokemonWholeScreen for the mon on display
function HallOfFame:sgbPalettes(game)
local P = require("src.render.PaletteFX")
if self.phase == "player" or self.phase == "player_stats"
or self.phase == "player_dex" or self.phase == "player_rating" then
return P.wholeNamed(game.data, "MEWMON")
end
local mon = game.save.party[self.index or 0]
if mon then
local c = P.monPal(game.data, mon.species)
@@ -26,17 +32,20 @@ function HallOfFame:sgbPalettes(game)
return P.wholeNamed(game.data, "MEWMON")
end
local MON_FRAMES = 150 -- ~2.5s per inductee (A advances early)
-- HoFShowMonOrPlayer's .ScrollPic: hSCX is nudged by e = 4px per
-- DelayFrame (doubled on SGB) until it settles. The back pic (an
-- enlarged, blurred 2x scale of the back sprite) sweeps right-to-left
-- and off the left edge first; tracing the actual hSCX/hSCY math shows
-- the real front pic that follows enters from the *left* edge and
-- slides *right* into its resting tile, at that same 4px/frame rate --
-- that's the half we port here (the back-pic wipe is a VRAM/scroll-
-- register trick with no equivalent in this sprite-based renderer).
-- DelayFrame (doubled on SGB) until it settles. The front pic rests at
-- hlcoord 12,5 (engine/movie/hall_of_fame.asm HoFLoadMonPlayerPicTileIDs).
local SCROLL_SPEED = 4 -- px/frame @ 60fps
local PIC_X, PIC_Y = 12 * 8, 5 * 8
-- After HoFDisplayAndRecordMonInfo: 80 DelayFrames, then the bottom
-- HALL OF FAME box for 180 DelayFrames, then GBFadeOutToWhite.
local INFO_HOLD = 80
local HOF_HOLD = 180
local FADE_FRAMES = 20
-- HoFPrintTextAndDelay after each dex line
local DEX_HOLD = 120
local function tryImage(path)
if not path then return nil end
@@ -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
if maxY and y > maxY then break end
Font.draw(line, x, y)
y = y + 10
y = y + 8
end
return y
end
@@ -70,6 +79,10 @@ function HallOfFame.new(game, onDone)
self.timer = 0
self.phase = "mons"
self.sprites = {} -- species -> image or false
self.playerPic = tryImage("assets/generated/trainer_card/red.png")
self.scrollX = PIC_X
self.showHofBanner = false
self.fade = 0
return self
end
@@ -84,16 +97,21 @@ end
function HallOfFame:nextMon()
self.index = self.index + 1
local mon = self.game.save.party[self.index]
self.showHofBanner = false
self.fade = 0
if mon then
self.timer = MON_FRAMES
self.phase = "mons"
self.timer = INFO_HOLD
Sound.playCry(self.game.data, mon.species)
-- scroll the new inductee's pic in from the left (see SCROLL_SPEED)
local sprite = self:spriteFor(mon.species)
local w = sprite and sprite:getWidth() or 0
self.scrollRestX = math.floor((160 - w) / 2)
local w = sprite and sprite:getWidth() or 56
self.scrollX = -w
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
@@ -117,77 +135,184 @@ function HallOfFame:dexSeenOwned()
return seen, owned
end
function HallOfFame:update(dt)
local input = self.game.input
if self.phase == "mons" then
if self.scrollX and self.scrollX < self.scrollRestX then
self.scrollX = math.min(self.scrollRestX, self.scrollX + SCROLL_SPEED)
end
self.timer = self.timer - 1
if input:wasPressed("a") or self.timer <= 0 then
self:nextMon()
end
elseif input:wasPressed("a") then
Sound.play(self.game.data, "Press_AB")
self.game.stack:pop()
if self.onDone then self.onDone() end
function HallOfFame:advanceMonPhase()
if self.phase == "mons" and not self.showHofBanner then
-- 80-frame info hold done: TextBoxBorder at (2,13) + "HALL OF FAME"
self.showHofBanner = true
self.timer = HOF_HOLD
elseif self.phase == "mons" then
self.phase = "fade"
self.timer = FADE_FRAMES
self.fade = 0
elseif self.phase == "fade" then
self:nextMon()
end
end
function HallOfFame:draw()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
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
function HallOfFame:update(dt)
local input = self.game.input
local skip = input:wasPressed("a")
if self.phase == "mons" or self.phase == "fade" then
if self.phase == "mons" and self.scrollX < PIC_X then
self.scrollX = math.min(PIC_X, self.scrollX + SCROLL_SPEED)
return
end
if self.phase == "fade" then
self.timer = self.timer - 1
self.fade = 1 - math.max(0, self.timer) / FADE_FRAMES
if self.timer <= 0 or skip then self:advanceMonPhase() end
return
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 seenOwned = text._DexSeenOwnedText
or "POKéDEX Seen:{NUM:wDexRatingNumMonsSeen, 1, 3}\n Owned:{NUM:wDexRatingNumMonsOwned, 1, 3}"
seenOwned = seenOwned
:gsub("{NUM:wDexRatingNumMonsSeen[^}]*}", tostring(seen))
:gsub("{NUM:wDexRatingNumMonsOwned[^}]*}", tostring(owned))
y = drawTextBlock(seenOwned, 8, y) + 6
drawTextBlock(seenOwned, 1 * 8, 14 * 8, 17 * 8)
else
local _, owned = self:dexSeenOwned()
local ratingHeader = (text._DexRatingText or "POKéDEX Rating{COLON}"):gsub("{COLON}", ":")
Font.draw(ratingHeader, 8, y)
y = y + 12
Font.draw(ratingHeader, 1 * 8, 14 * 8)
local rating = text[dexRatingKey(owned)] or "Keep it up!"
drawTextBlock(rating, 8, y, 136)
drawTextBlock(rating, 1 * 8, 15 * 8, 17 * 8)
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)
end
+13 -3
View File
@@ -117,7 +117,7 @@ function ListMenu:draw()
love.graphics.setColor(0, 0, 0, 1)
end
if item.right then
Font.draw(item.right, 160 - 8 - #item.right * 8, y)
Font.draw(item.right, 160 - 8 - Font.width(item.right), y)
end
if i == self.index then
-- hollowIndex: a chosen row keeps the hollow '▷' left behind by
@@ -136,7 +136,7 @@ function ListMenu:draw()
Font.drawBox(11, 0, 9, 3)
love.graphics.setColor(0, 0, 0, 1)
local money = ("¥%d"):format(self.money and self.money() or 0)
Font.draw(money, 152 - #money * 8, 8)
Font.draw(money, 152 - Font.width(money), 8)
-- the clerk's line in the standard bottom text box; long prompts
-- wrap and keep their last two lines, like the GB's scrolled box
Font.drawBox(0, 12, 20, 6)
@@ -153,7 +153,17 @@ function ListMenu:draw()
end
end
elseif self.footer then
Font.draw(self.footer, 8, 136)
-- PC deposit/withdraw footers use "\n"; draw the last two lines so
-- long item names are not clipped at the screen edge (#115).
local flat = {}
for _, page in ipairs(require("src.render.TextBox").paginate(self.footer)) do
for _, line in ipairs(page) do flat[#flat + 1] = line end
end
local y = (#flat >= 2) and 120 or 136
for i = math.max(1, #flat - 1), #flat do
Font.draw(flat[i], 8, y)
y = y + 16
end
end
love.graphics.setColor(1, 1, 1, 1)
end
+43 -4
View File
@@ -19,7 +19,16 @@ function Menu.new(game, items, opts)
self.tx = opts.tx or 10
self.ty = opts.ty or 0
self.tw = opts.tw or 10
self.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
-- 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
@@ -31,9 +40,25 @@ function Menu.new(game, items, opts)
-- BIT_NO_MENU_BUTTON_SOUND (wMiscFlags): the PC session runs its
-- menus silent (home/window.asm HandleMenuInput_)
self.noSound = opts.noSound or false
self:clampScroll()
return self
end
-- keeps self.index inside the visible [scroll+1, scroll+maxVisible] window;
-- callers that move self.index directly (e.g. restoring a saved cursor
-- position) should call this afterwards to scroll it into view
function Menu:clampScroll()
if not (self.maxVisible and #self.items > self.maxVisible) then
self.scroll = 0
return
end
if self.index - self.scroll > self.maxVisible then
self.scroll = self.index - self.maxVisible
elseif self.index - self.scroll < 1 then
self.scroll = self.index - 1
end
end
function Menu:update(dt)
local input = self.game.input
if input:wasPressed("up") then
@@ -61,15 +86,29 @@ function Menu:update(dt)
self.game.stack:pop()
if self.onCancel then self.onCancel() end
end
self:clampScroll()
end
function Menu:draw()
Font.drawBox(self.tx, self.ty, self.tw, self.th)
love.graphics.setColor(0, 0, 0, 1)
for i, item in ipairs(self.items) do
Font.draw(item.label, (self.tx + 2) * 8, (self.ty + i * 2 - 1) * 8)
local visible = (self.maxVisible and math.min(self.maxVisible, #self.items))
or #self.items
for row = 1, visible do
local item = self.items[self.scroll + row]
if not item then break end
Font.draw(item.label, (self.tx + 2) * 8,
(self.ty + row * self.rowStep - (self.rowStep - 1)) * 8)
end
local cursorRow = self.index - self.scroll
Font.drawCode(Theme.cursor, (self.tx + 1) * 8,
(self.ty + cursorRow * self.rowStep - (self.rowStep - 1)) * 8)
-- moreArrow ($EE): the same "more below" glyph OptionRows/ManagerState
-- use, sat on the bottom border like TextBox's page-advance cursor
if self.maxVisible and self.scroll + self.maxVisible < #self.items then
Font.drawCode(Theme.moreArrow, (self.tx + self.tw - 2) * 8,
(self.ty + self.th - 2) * 8)
end
Font.drawCode(Theme.cursor, (self.tx + 1) * 8, (self.ty + self.index * 2 - 1) * 8)
love.graphics.setColor(1, 1, 1, 1)
end
+68 -2
View File
@@ -3,7 +3,8 @@
-- (cycles the merged rulesets registry; gen1_faithful keeps the original
-- 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 /
-- 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
-- add their own; CANCEL is appended after the hook and stays fixed on the
-- bottom line like pokered's.
@@ -11,16 +12,28 @@
local PaletteFX = require("src.render.PaletteFX")
local Tilt = require("src.render.Tilt")
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 VideoMode = require("src.core.VideoMode")
local FrameCap = require("src.core.FrameCap")
local Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime")
local OptionRows = require("src.ui.OptionRows")
local Renderer = require("src.render.Renderer")
local OptionsMenu = {}
OptionsMenu.__index = OptionsMenu
OptionsMenu.isOpaque = true
-- Opaque full-screen menu: own MEWMON so opening OPTION from the title
-- (or over the overworld) does not inherit TitleState's LOGO1 band -- that
-- zone covers UI rows 8-9, which is the third options box label line
-- (pink "MODS" strip when Blue's ROM LOGO1 white is {255,239,255}).
function OptionsMenu:sgbPalettes(game)
return PaletteFX.wholeNamed(game.data, "MEWMON")
end
-- TextSpeedOptionData frame delays with the original labels
local SPEEDS = { { 1, "FAST" }, { 3, "MEDIUM" }, { 5, "SLOW" } }
-- no-loader fallback for the ruleset row, same pair BattleState keeps
@@ -101,7 +114,7 @@ local function sameRows(_, rows) return rows end
-- the vanilla rows as descriptors; each step body is the old per-index
-- ladder's, so the save.options mutations are unchanged
local function buildRows(game)
return {
local rows = {
{ id = "textSpeed", label = "TEXT SPEED",
value = function(g) return SPEEDS[speedIndex(g)][2] end,
step = function(g)
@@ -192,6 +205,37 @@ local function buildRows(game)
GBCFX.setLevel(o.gbcfx)
return true
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",
value = function(g)
return VideoMode.modeLabel(g.save.options.videoMode)
@@ -202,6 +246,19 @@ local function buildRows(game)
VideoMode.apply(o.videoMode)
return true
end },
-- hard render cap (issue #88): bounds the present rate so a
-- driver-forced vsync-off run cannot spin at thousands of FPS. Logic
-- is fixed-step off dt, so this touches presentation only.
{ id = "fpsCap", label = "MAX FPS",
value = function(g)
return FrameCap.label(g.save.options.fpsCap)
end,
step = function(g, dir)
local o = g.save.options
o.fpsCap = FrameCap.cycle(o.fpsCap, dir)
FrameCap.apply(o.fpsCap)
return true
end },
-- fast-forward the logic clock only; music and sfx keep their tempo
-- (src/core/GameSpeed.lua), so this is safe to leave on
{ id = "speed", label = "GAME SPEED",
@@ -230,6 +287,15 @@ local function buildRows(game)
require("src.ui.Screens").push(g, "BindingsMenu")
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
function OptionsMenu.new(game)
+39 -10
View File
@@ -11,6 +11,8 @@ local Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime")
local Screens = require("src.ui.Screens")
local Theme = require("src.ui.Theme")
local FieldDefaults = require("src.world.FieldDefaults")
local Map = require("src.world.Map")
local PartyMenu = {}
PartyMenu.__index = PartyMenu
@@ -69,10 +71,26 @@ local function drawIcon(game, mon, x, y, selected, counter)
local icons = game.data.icons
if not icons then return end
local def = game.data.pokemon[mon.species]
-- byDex is the vanilla lookup, but the icons registry can bring the table
-- into existence on its own, so it may be the only key present
local name = def and def.dex and icons.byDex and icons.byDex[def.dex]
local path = name and icons.icons[name]
-- Per-species override first: the icons registry folds into
-- icons.bySpecies, and a pokemon record may carry its own `icon` field.
-- Either is a built-in icon name (resolved through icons.icons) or a
-- { image = <path>, frames? } table pointing at bundled art. Falling
-- through to icons.byDex[def.dex] keeps the vanilla dex-indexed default;
-- without the override a modded or dex-renumbered species could never
-- change its menu icon.
local entry = (icons.bySpecies and icons.bySpecies[mon.species])
or (def and def.icon)
local name, path
if type(entry) == "string" then
name = entry
path = icons.icons and icons.icons[entry]
elseif type(entry) == "table" then
path = entry.image
end
if not path then
name = def and def.dex and icons.byDex and icons.byDex[def.dex]
path = name and icons.icons and icons.icons[name]
end
if not path then return end
if iconImages[path] == nil then
local ok, img = pcall(love.graphics.newImage, path)
@@ -182,12 +200,13 @@ function PartyMenu:update(dt)
-- and music return first (PlayDefaultMusic +
-- LoadWalkingPlayerSpriteGraphics), the menu closes with the
-- GBPalWhiteOutWithDelay3 blink, and the simulated pad press
-- steps the player forward onto land
-- steps the player forward onto land (or across a connection
-- strip when the shore is the next map's edge)
self.game.stack:pop()
ow.player.surfing = false
require("src.core.Music").setSurfing(self.game.data, false)
self.game.stack:push(Transition.whiteFlash(self.game, nil, function()
ow:scriptMove(ow.player, ow.player.facing, 1)
ow:stepForwardOrCrossEdge(ow.player.facing)
end))
return
end
@@ -277,7 +296,9 @@ function PartyMenu:update(dt)
if ow and heal then
self.game.stack:push(Transition.whiteFlash(self.game, nil, function()
require("src.core.Sound").play(self.game.data, "Teleport_Exit1")
ow:warpToHealPoint()
-- EnterMapAnim on arrival (HandleFlyWarpOrDungeonWarp sets
-- BIT_FLY_WARP); blackouts must not pass arrive="teleport"
ow:warpToHealPoint(nil, { arrive = "teleport" })
end))
end
return
@@ -331,9 +352,17 @@ function PartyMenu:update(dt)
local items = { { label = "STATS", action = "stats" },
{ label = "SWITCH", action = "switch" } }
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
if mv.id == "FLY" and ow.map.def.tileset == "OVERWORLD"
if mv.id == "FLY" and outside
and self.game.save.inventory.THUNDERBADGE then
table.insert(items, { label = "FLY", action = "fly" })
elseif mv.id == "FLASH" and ow.dark
@@ -352,7 +381,7 @@ function PartyMenu:update(dt)
table.insert(items, { label = "STRENGTH", action = "strength" })
elseif mv.id == "SOFTBOILED" then
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 -> CheckIfInOutsideMap); dark maps don't
-- block it
+2 -1
View File
@@ -1,6 +1,7 @@
-- The player's item-storage PC (engine/menus/players_pc.asm):
-- WITHDRAW ITEM / DEPOSIT ITEM / TOSS ITEM / LOG OFF over
-- game.save.pcItems ({ ITEM_ID = count }, 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
-- move one); toss discards after a YES/NO confirm. Follows the
-- BoxMenu/BagMenu list idioms.
+7 -3
View File
@@ -84,13 +84,17 @@ local function buy(game, stock)
end
local function sell(game)
-- Sell list is ITEMLISTMENU with wPrintItemPrices cleared
-- (pokemart.asm .sellMenuLoop): name + quantity only. Price shows
-- in the quantity chooser. Stuffing "xN" into the label next to a
-- right-aligned ¥ price made long names overlap (issue #116).
local items = {}
for _, id in ipairs(Bag.order(game.save)) do
local def = game.data.items[id]
table.insert(items, {
value = id,
label = (def and def.name or id) .. " x" .. game.save.inventory[id],
right = ("¥%d"):format(def and math.floor(def.price / 2) or 0),
label = def and def.name or id,
right = "x" .. game.save.inventory[id],
})
end
local greet = txt(game, "_PokemartBuyingGreetingText", "Take your time.")
@@ -128,7 +132,7 @@ local function sell(game)
Bag.remove(game.save, item.value, qty)
local left = game.save.inventory[item.value]
if left then
item.label = def.name .. " x" .. left
item.right = "x" .. left
else
list:removeCurrent()
end
+11 -1
View File
@@ -7,6 +7,7 @@
local Font = require("src.render.Font")
local Logger = require("src.core.Logger")
local Menu = require("src.ui.Menu")
local Renderer = require("src.render.Renderer")
local Runtime = require("src.mods.Runtime")
local Screens = require("src.ui.Screens")
@@ -117,11 +118,20 @@ function StartMenu.new(game)
-- the start menu's mask is PAD_DOWN | PAD_UP | PAD_START | PAD_B | PAD_A
-- (engine/menus/draw_start_menu.asm), so START closes it back to the
-- overworld -- unlike most menus, whose masks omit PAD_START.
--
-- item count isn't fixed: POKéDEX/LINK/MODS come and go with save state,
-- and mods can append their own rows through the hook above, so the
-- double-spaced box (the original's style) can grow past the 18-tile
-- canvas. Cap it at however many rows actually fit and scroll the rest,
-- with Menu's moreArrow showing while there's more below.
local rowStep = 2
local maxVisible = math.floor((Renderer.HEIGHT / 8 - 2) / rowStep)
local menu = Menu.new(game, items,
{ tx = 9, ty = 0, tw = 11, th = #items * 2 + 2, startCloses = true })
{ tx = 9, ty = 0, tw = 11, maxVisible = maxVisible, startCloses = true })
-- the cursor position survives closing the menu
-- (wBattleAndStartSavedMenuItem, home/start_menu.asm)
menu.index = math.min(game.save.startMenuIndex or 1, #items)
menu:clampScroll()
local baseUpdate = menu.update
menu.update = function(self, dt)
baseUpdate(self, dt)
+7 -3
View File
@@ -115,12 +115,16 @@ function SummaryMenu:draw()
drawLineBox(19, 1, 6, 10)
Font.draw("EXP POINTS", 72, 24)
Font.draw(("%d"):format(mon.exp), 96, 32)
Font.draw("LEVEL UP", 72, 44)
-- StatusScreen2: "LEVEL UP" at (9,5); next-exp PrintNumber 7 cols
-- at (7,6); space at (14,6); PrintLevel at (16,6). The old
-- "%d to L%d" string at x=88 overflowed the DrawLineBox edge.
Font.draw("LEVEL UP", 72, 40)
local Growth = require("src.pokemon.Growth")
local nextExp = mon.level < 100
and (Growth.expForLevel(def.growthRate, mon.level + 1) - mon.exp) or 0
Font.draw(("%d to L%d"):format(math.max(0, nextExp),
math.min(100, mon.level + 1)), 88, 52)
Font.draw(("%7d"):format(math.max(0, nextExp)), 56, 48)
HudTiles.tile(0x6E, 128, 48) -- <LV>
Font.draw(tostring(math.min(100, mon.level + 1)), 136, 48)
Font.drawBox(0, 8, 20, 10)
for i = 1, 4 do
local mv = mon.moves[i]
+33 -5
View File
@@ -13,13 +13,35 @@ TitleState.isOpaque = true
-- SGB title zones (PalPacket_Titlescreen): the logo rows get LOGO2,
-- the version-ribbon band LOGO1, the rest MEWMON.
--
-- The CONTINUE / NEW GAME menu and the continue-info box sit inside those
-- LOGO bands. pokered's MainMenu clears the title and runs
-- RunDefaultPaletteCommand so black UI ink stays black; this port keeps the
-- title art visible underneath, so without an overlay those boxes inherit
-- LOGO2 blue / LOGO1 red (issue #133). A trailing trueColor zone leaves the
-- overlay's DMG black unshaded while the logo and title mon keep title pals.
--
-- ROM SuperPal whites are often {255,239,255}. Under RED++, LOGO2/MEWMON
-- come from the GBC pack (pure white) while Blue's LOGO1 stays on the ROM
-- pack (#128), so the version-ribbon row reads as a pink band. Force that
-- slot to pure white; ink colors (Blue/Red "Version" text) stay intact.
local function withPureWhite(pal)
if not pal then return nil end
return { { 255, 255, 255 }, pal[2], pal[3], pal[4] }
end
function TitleState:sgbPalettes(game)
local P = require("src.render.PaletteFX")
local z = {
P.zone(P.pal(game.data, "LOGO2"), 0, 0, 19, 7),
P.zone(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),
}
local top = game.stack and game.stack:top()
local box = top and top.titleUiBox
if box then
z[#z + 1] = P.trueColorZone(box[1], box[2], box[3], box[4])
end
return z[3] and z or nil
end
@@ -119,8 +141,11 @@ local ContinueInfo = {}
ContinueInfo.__index = ContinueInfo
function ContinueInfo.new(title, save)
return setmetatable({ title = title, game = title.game, save = save },
ContinueInfo)
-- box at (4,7), 16x10 tiles -- see ContinueInfo:draw / DisplayContinueGameInfo
return setmetatable({
title = title, game = title.game, save = save,
titleUiBox = { 4, 7, 19, 16 },
}, ContinueInfo)
end
function ContinueInfo:update(dt)
@@ -184,8 +209,11 @@ function TitleState:openMenu()
love.event.quit()
end
end })
game.stack:push(Menu.new(game, items,
{ tx = 0, ty = 0, tw = 13, th = #items * 2 + 2 }))
local th = #items * 2 + 2
local menu = Menu.new(game, items, { tx = 0, ty = 0, tw = 13, th = th })
-- full-width title LOGO zones would recolor this box; see sgbPalettes
menu.titleUiBox = { 0, 0, 12, th - 1 }
game.stack:push(menu)
end
function TitleState:update(dt)
+4 -1
View File
@@ -120,8 +120,11 @@ FieldDefaults.FIELD = {
doorBlock = { bx = 2, by = 2, block = 5 } },
},
-- IsSurfingAllowed refuses SURF on the B4F stairs square until both
-- plug boulders are down (engine/overworld/field_move_messages.asm)
-- 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_ISLANDS_B3F = { setsForcedWarp = true },
SEAFOAM_ISLANDS_B4F = {
surfBlocked = { { x = 7, y = 11, untilEvents = {
"EVENT_SEAFOAM4_BOULDER1_DOWN_HOLE",
+47 -26
View File
@@ -38,6 +38,51 @@ local function hashSet(list, into)
return into
end
-- Collision tile (bottom-left 8x8) of a cell on an UNLOADED map def --
-- the connected neighbor during an edge crossing. pokered's
-- GetTileAndCoordsInFrontOfPlayer / collision checks read the neighbor
-- strip's tile bytes the same way.
function Map.defCellTile(def, tilesetDef, cx, cy)
if not (def and tilesetDef and tilesetDef.blocks) then return nil end
local tx, ty = cx * 2, cy * 2 + 1
local bx, by = math.floor(tx / 4), math.floor(ty / 4)
local id
if bx < 0 or by < 0 or bx >= def.width or by >= def.height then
id = def.borderBlock
else
id = def.blocks[by * def.width + bx + 1]
end
local block = tilesetDef.blocks[(id or 0) + 1]
if not block then return nil end
return block[(ty % 4) * 4 + (tx % 4) + 1]
end
local function defWaterTileSet(def, tilesetDef)
local water = {}
hashSet(tilesetDef.waterTiles or WATER_TILES, water)
local shore = tilesetDef.shoreTiles
if shore == nil and not NO_SHORE_TILESETS[def.tileset] then shore = SHORE_TILES end
hashSet(shore or {}, water)
return water
end
-- Water/shore on an unloaded map def (same tile ids as Map:isWaterCell).
function Map.defIsWaterCell(def, tilesetDef, cx, cy)
local tile = Map.defCellTile(def, tilesetDef, cx, cy)
if tile == nil then return false end
return defWaterTileSet(def, tilesetDef)[tile] or false
end
function Map.defIsWalkableCell(def, tilesetDef, cx, cy)
if not (tilesetDef and tilesetDef.walkable) then return false end
local tile = Map.defCellTile(def, tilesetDef, cx, cy)
if tile == nil then return false end
for _, t in ipairs(tilesetDef.walkable) do
if t == tile then return true end
end
return false
end
-- Passability of a cell of an UNLOADED map def -- the connected neighbor
-- during an edge crossing. pokered's collision check reads the neighbor
-- strip's tile bytes, so a step off the map edge onto a solid tile of
@@ -52,32 +97,8 @@ function Map.defPassable(def, tilesetDef, cx, cy, surfing)
if not (def and tilesetDef and tilesetDef.blocks and tilesetDef.walkable) then
return false
end
local tx, ty = cx * 2, cy * 2 + 1
local bx, by = math.floor(tx / 4), math.floor(ty / 4)
local id
if bx < 0 or by < 0 or bx >= def.width or by >= def.height then
id = def.borderBlock
else
id = def.blocks[by * def.width + bx + 1]
end
local block = tilesetDef.blocks[(id or 0) + 1]
if not block then return false end
local tile = block[(ty % 4) * 4 + (tx % 4) + 1]
for _, t in ipairs(tilesetDef.walkable) do
if t == tile then return true end
end
if surfing then
for _, t in ipairs(tilesetDef.waterTiles or WATER_TILES) do
if t == tile then return true end
end
local shore = tilesetDef.shoreTiles
if shore == nil and not NO_SHORE_TILESETS[def.tileset] then
shore = SHORE_TILES
end
for _, t in ipairs(shore or {}) do
if t == tile then return true end
end
end
if Map.defIsWalkableCell(def, tilesetDef, cx, cy) then return true end
if surfing and Map.defIsWaterCell(def, tilesetDef, cx, cy) then return true end
return false
end
+259 -80
View File
@@ -303,6 +303,9 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
else
self.player = Player.new(Game.data, x, y, facing)
end
-- crossConnection re-arms this after setMap; clear so a warp/reload
-- cannot leave a stale deferred PlayMapMusic pending
self.pendingSeamMusic = nil
self.entities = { self.player }
for _, n in ipairs(self.npcs) do table.insert(self.entities, n) end
@@ -322,6 +325,12 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
-- 16/18 gate exits), and the scripted door-mat walkout that follows
-- suppresses onStepComplete, so waiting for a plain step never mounts
self:checkForcedMovement()
-- Seafoam B4F's map script pushes off the B3F stair warps every frame
-- while the upper plugs are out (SeafoamIslandsB4FDefaultScript); the
-- B3F/B4F force-surf mouths also arm their MOVE_OBJECT current scripts
-- from CheckForceBikeOrSurf. Re-check here so a warp-in does not sit
-- idle on those cells waiting for a player step.
self:checkSeafoamCurrent()
-- snap the camera immediately: the overworld doesn't update while a
-- Transition is on top, so a stale camera would show the new map at
@@ -338,10 +347,13 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
or (fromMapId and "warp" or "boot"),
})
-- map-enter hooks (hand-ported map scripts, e.g. Victory Road barriers)
-- map-enter hooks (hand-ported map scripts, e.g. Victory Road barriers).
-- fromMapId lets elevators seed a valid walk-out floor when the ROM
-- car warps still point at a missing map (Silph's UNUSED_MAP_ED) and
-- the player B-cancels the floor menu without .UpdateWarp.
local hooks = mapScripts.get(mapId)
if hooks and hooks.onEnter then
hooks.onEnter(Game, self)
hooks.onEnter(Game, self, fromMapId)
end
self:rebuildNeighbors()
@@ -799,6 +811,15 @@ function OverworldState:update(dt)
if entry and (self.player.cellX ~= entry.x or self.player.cellY ~= entry.y) then
self.warpEntryCell = nil
end
-- deferred PlayMapMusic from crossConnection (issue #93)
if stepped and self.pendingSeamMusic then
local mapId = self.pendingSeamMusic
self.pendingSeamMusic = nil
if mapId == self.map.id then
require("src.core.Music").playMap(Game.data, mapId, Game.save.onBike,
self.player.surfing)
end
end
if stepped and not scripted then
self:onStepComplete()
end
@@ -1000,20 +1021,19 @@ function OverworldState:checkEdgeExit(dir)
return false
end
-- Map connections: the connected map's strip offset is in blocks; arriving
-- coordinates follow destX = curX - offset*2 (see docs/extraction-notes.md).
-- The crossing scrolls continuously: the map data swaps while the player
-- is placed one cell before the entry point (their old world position,
-- which the neighbor strips render identically) and walks the seam step.
function OverworldState:crossConnection(dir, conn)
-- Landing cell on the connected map for a step off this map's edge in
-- `dir` (same math as crossConnection). Returns destDef, tilesetDef, x, y
-- or nil when there is no usable connection.
function OverworldState:connectionLanding(dir)
local conn = self.map:connection(COMPASS[dir])
if not conn then return nil end
local dest = Game.data.maps[conn.map]
if not dest then
Logger.warn("connection to unknown map %s", tostring(conn.map))
return false
end
if not dest then return nil end
local ts = Game.data.tilesets[dest.tileset]
if not ts then return nil end
local p = self.player
local x, y = p.cellX, p.cellY
local destW, destH = dest.width * 2, dest.height * 2
local x, y
if dir == "up" then
x, y = p.cellX - conn.offset * 2, destH - 1
elseif dir == "down" then
@@ -1025,16 +1045,38 @@ function OverworldState:crossConnection(dir, conn)
end
x = math.max(0, math.min(destW - 1, x))
y = math.max(0, math.min(destH - 1, y))
return dest, ts, x, y, conn
end
-- Map connections: the connected map's strip offset is in blocks; arriving
-- coordinates follow destX = curX - offset*2 (see docs/extraction-notes.md).
-- The crossing scrolls continuously: the map data swaps while the player
-- is placed one cell before the entry point (their old world position,
-- which the neighbor strips render identically) and walks the seam step.
function OverworldState:crossConnection(dir, conn)
local dest, ts, x, y = self:connectionLanding(dir)
if not dest then
Logger.warn("connection to unknown map %s", tostring(conn and conn.map))
return false
end
local p = self.player
-- pokered's collision check reads the NEIGHBOR strip's tile bytes, so
-- stepping off the edge onto a solid tile of the connected map bumps
-- exactly like an in-map wall. Without this read, Pallet's south
-- shore (land at x2-3) walked straight onto ROUTE_21 (3,0) -- a
-- collision tile -- stranding the player on a cell no walk can leave.
if not Map.defPassable(dest, Game.data.tilesets[dest.tileset], x, y,
p.surfing) then
if not Map.defPassable(dest, ts, x, y, p.surfing) then
return false
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,
-- 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
@@ -1048,18 +1090,71 @@ function OverworldState:crossConnection(dir, conn)
p.targetX, p.targetY = x, y
p.moving = true
p.progress = 0
-- fresh walk-cycle clock so the seam step always shows leg frames
-- (mid-cycle stand phase would otherwise look like a slide)
p.animClock = 0
p.stepFramesCur = Game.save.onBike
and (FieldDefaults.world(Game.data, "bikeStepFrames") or 8)
or (FieldDefaults.world(Game.data, "stepFrames") or 16)
require("src.core.FixedStep"):discardCatchup()
return true
end
-- ItemUseSurfboard's simulated pad press: step onto the facing cell, or
-- cross a map connection when that cell is off this map's edge (Cinnabar
-- east coast -> Route 20 water, and the reverse dismount ashore).
function OverworldState:stepForwardOrCrossEdge(dir)
dir = dir or self.player.facing
local fx, fy = Collision.target(self.player.cellX, self.player.cellY, dir)
if not self.map:inBounds(fx, fy) then
return self:checkEdgeExit(dir)
end
self:scriptMove(self.player, dir, 1)
return true
end
-- IsNextTileShoreOrWater across a connection strip: pokered loads the
-- neighbor's tiles into the border, so wTileInFrontOfPlayer is the
-- connected map's tile even when the facing cell is off this map.
-- Shore/water classification still uses THIS map's tileset rules
-- (SHIP_PORT's $32 dock exception), matching the asm.
function OverworldState:facingIsShoreOrWater()
if not self:tilesetHasWater() then return false end
local fx, fy = self.player:facingCell()
if self.map:inBounds(fx, fy) then
return self.map:isWaterCell(fx, fy)
end
local dest, ts, x, y = self:connectionLanding(self.player.facing)
if not dest then return false end
local tile = Map.defCellTile(dest, ts, x, y)
if tile == nil then return false end
return self.map.waterTiles[tile] or false
end
-- tryToStopSurfing land check, including a land landing across a map
-- connection (surf off Cinnabar's east coast water back onto the coast).
function OverworldState:facingIsLandDismount()
local p = self.player
local fx, fy = p:facingCell()
if self.map:inBounds(fx, fy) then
return self.map:isWalkableCell(fx, fy)
and Collision.canMove(self.map, self.entities, p, p.facing)
end
local dest, ts, x, y = self:connectionLanding(p.facing)
if not dest then return false end
if not Map.defIsWalkableCell(dest, ts, x, y) then return false end
-- IsSpriteInFrontOfPlayer2: no current-map sprite can sit past the edge
return not Collision.occupied(self.entities, fx, fy, p)
end
-- -------------------------------------------------------------------------
-- interactions
-- -------------------------------------------------------------------------
-- HM field moves are gated by badges like the original
-- (constants.hmBadges; distinct from constants.hmMoves, the forget gate)
-- (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)
local gate = (FieldDefaults.constant(Game.data, "hmBadges") or {})[moveId]
local badge = gate and gate.badge
@@ -1067,10 +1162,8 @@ function OverworldState:partyKnows(moveId)
return nil
end
for _, mon in ipairs(Game.save.party) do
if mon.hp > 0 then
for _, mv in ipairs(mon.moves) do
if mv.id == moveId then return mon end
end
for _, mv in ipairs(mon.moves) do
if mv.id == moveId then return mon end
end
end
return nil
@@ -1571,16 +1664,21 @@ function OverworldState:trashCanSwitch(canIndex)
end
-- Bill's House PC (engine/events/hidden_events/bills_house_pc.asm
-- BillsHousePC): once Bill-the-Pokémon has climbed into the machine
-- (EVENT_BILL_SAID_USE_CELL_SEPARATOR), running the PC plays the cell
-- separator's SFX sequence, sets EVENT_USED_CELL_SEPARATOR_ON_BILL and
-- Bill steps back out of the machine human again
-- (BillsHouseBillExitsMachineScript / CleanupScript set EVENT_MET_BILL).
-- BillsHousePC). Check order matches pokered:
-- 1) EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING -> Eevee collection list
-- 2) EVENT_USED_CELL_SEPARATOR_ON_BILL -> teleporter monitor text
-- 3) EVENT_BILL_SAID_USE_CELL_SEPARATOR -> cell-separator cutscene
-- 4) else -> teleporter monitor text
-- Leaving after the SS Ticket (Route25ToggleBillsScript) arms (1).
function OverworldState:billsHousePC()
local t = Game.data.text
local flags = Game.save.flags
if not (flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR
and not flags.EVENT_USED_CELL_SEPARATOR_ON_BILL) then
if flags.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING then
self:billsHousePokemonList()
return
end
if flags.EVENT_USED_CELL_SEPARATOR_ON_BILL
or not flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR then
Game.stack:push(TextBox.new(Game, t._BillsHouseMonitorText
or "TELEPORTER is\ndisplayed on the\nPC monitor."))
return
@@ -1604,9 +1702,40 @@ function OverworldState:billsHousePC()
end))
end
-- BillsHousePokemonList: EEVEE / FLAREON / JOLTEON / VAPOREON + CANCEL;
-- picking one runs DisplayPokedex (DexEntryMenu) and returns to the list.
function OverworldState:billsHousePokemonList()
local t = Game.data.text
local Menu = require("src.ui.Menu")
local function openList()
local species = { "EEVEE", "FLAREON", "JOLTEON", "VAPOREON" }
local items = {}
for _, id in ipairs(species) do
local def = Game.data.pokemon[id]
table.insert(items, {
label = (def and def.name) or id,
keepOpen = true,
onSelect = function()
local dex = Game.save.pokedex
if dex then dex.seen[id] = true end
Screens.push(Game, "DexEntryMenu", id)
end,
})
end
table.insert(items, { label = "CANCEL" })
-- TextBoxBorder b=10,c=9 at (0,0) -> total tw=11, th=12
Game.stack:push(Menu.new(Game, items,
{ tx = 0, ty = 0, tw = 11, th = 12 }))
end
Game.stack:push(TextBox.new(Game, t._BillsHousePokemonListText1
or "BILL's favorite\nPOKéMON list!", openList))
end
-- BillsHouseBillExitsMachineScript: human Bill appears inside the machine
-- at (1,2) and walks out to his spot at (4,4); the map music resumes and
-- EVENT_MET_BILL / EVENT_MET_BILL_2 arm the SS-Ticket dialogue.
-- EVENT_MET_BILL / EVENT_MET_BILL_2 arm the SS-Ticket dialogue. The Eevee
-- PC list arms later, on the first Route 25 load after the ticket
-- (EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING).
function OverworldState:billsHouseBillExits()
local Commands = require("src.script.Commands")
local ctx = { game = Game, save = Game.save, overworld = self }
@@ -1698,14 +1827,15 @@ function OverworldState:trySurf(fx, fy)
Game.stack:push(TextBox.new(Game, text, function()
-- start_sub_menus.asm .surf: UseItem returns (mount + text done),
-- then GBPalWhiteOutWithDelay3 blinks before the simulated forward
-- press steps onto the water
-- press steps onto the water (or across a connection strip, like
-- Cinnabar's east coast onto Route 20)
local Transition = require("src.render.Transition")
if Transition.whiteFlash then
Game.stack:push(Transition.whiteFlash(Game, nil, function()
self:scriptMove(p, p.facing, 1)
self:stepForwardOrCrossEdge(p.facing)
end))
else
self:scriptMove(p, p.facing, 1)
self:stepForwardOrCrossEdge(p.facing)
end
end))
end
@@ -1787,7 +1917,7 @@ end
-- to SurfingAttemptFailed (_NoSurfingHereText) if it isn't water. This is
-- a side-effect-free check that reports which text/flow the caller should
-- use; the actual mount happens in trySurf on "ok". Returns:
-- "no_badge" -> SOULBADGE missing / no live SURF mon (_NewBadgeRequiredText)
-- "no_badge" -> SOULBADGE missing / no SURF mon (_NewBadgeRequiredText)
-- "forced_bike" -> on the Cycling Road (_CyclingIsFunText)
-- "current" -> Seafoam B4F stairs before the boulders (_CurrentTooFastText)
-- "dismount" -> already surfing, facing dry land; caller steps forward
@@ -1805,20 +1935,20 @@ function OverworldState:useSurfFieldMove()
-- until both EVENT_SEAFOAM4_BOULDER*_DOWN_HOLE events are set.
if Game.save.forcedBike then return "forced_bike" end
if self:surfBlockedHere() then return "current" end
local fx, fy = p:facingCell()
if p.surfing then
-- ItemUseSurfboard .tryToStopSurfing: blocked by a sprite in front
-- (IsSpriteInFrontOfPlayer2), a water tile-pair collision, or a
-- facing tile that isn't in the tileset's land-passable list;
-- otherwise the player walks forward off the water.
if self.map:inBounds(fx, fy) and self.map:isWalkableCell(fx, fy)
and Collision.canMove(self.map, self.entities, p, p.facing) then
-- otherwise the player walks forward off the water. Facing a land
-- cell across a map connection (Cinnabar east coast) counts too --
-- pokered reads that landing from the connection strip.
if self:facingIsLandDismount() then
return "dismount"
end
return "no_place"
end
if not self.map:inBounds(fx, fy)
or not self.map:isWaterCell(fx, fy) or not self:tilesetHasWater() then
-- IsNextTileShoreOrWater, including connection-strip water (issue #125)
if not self:facingIsShoreOrWater() then
return "no_water"
end
return "ok"
@@ -1830,7 +1960,7 @@ end
-- (and .loop back to the submenu) if it isn't cuttable. Side-effect-free
-- check mirroring useSurfFieldMove; tryCut does the actual cut on "ok".
-- Returns:
-- "no_badge" -> CASCADEBADGE missing / no live CUT mon (_NewBadgeRequiredText)
-- "no_badge" -> CASCADEBADGE missing / no CUT mon (_NewBadgeRequiredText)
-- "nothing" -> not facing a cuttable tree (_NothingToCutText)
-- "ok" -> facing a cuttable tree; caller may call tryCut(fx, fy)
function OverworldState:useCutFieldMove()
@@ -2251,6 +2381,8 @@ end
-- Badges/items awarded after specific battles (data/scripts/victories.lua).
-- `deactivate` retires unfought gym/dojo trainers the way the originals'
-- SetEvent / SetEventRange do after the leader victory.
-- `hide` is { { mapId, objName }, ... } — HideObject on those toggles
-- (e.g. Brock victory clears PEWTERCITY_YOUNGSTER / ROUTE22_RIVAL1).
function OverworldState:checkVictoryRewards(trainerClass, partyIndex)
local victories = require("data.scripts.victories")
local reward = victories[trainerClass .. "#" .. tostring(partyIndex or 1)]
@@ -2264,6 +2396,13 @@ function OverworldState:checkVictoryRewards(trainerClass, partyIndex)
Game.save.flags[flag] = true
end
end
if reward.hide then
local Commands = require("src.script.Commands")
local ctx = { game = Game, save = Game.save, overworld = self }
for _, entry in ipairs(reward.hide) do
Commands.hide_object(ctx, entry[1], entry[2])
end
end
local lines = {}
if reward.badge then
Game.save.inventory[reward.badge] = 1
@@ -2292,6 +2431,27 @@ function OverworldState:runVictoryHook()
if hooks and hooks.onVictory then hooks.onVictory(Game, self) 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
-- sight (range from the extracted trainer headers), walk up and battle.
function OverworldState:checkTrainerSight()
@@ -2309,22 +2469,23 @@ function OverworldState:checkTrainerSight()
local range = header and header.range or 0
local vec = DIRVEC[npc.facing]
if range > 0 and vec then
local dist
local dist, horizontal
if vec[1] ~= 0 and npc.cellY == p.cellY then
dist = (p.cellX - npc.cellX) * vec[1]
horizontal = true
elseif vec[2] ~= 0 and npc.cellX == p.cellX then
dist = (p.cellY - npc.cellY) * vec[2]
horizontal = false
end
-- pokered's TrainerEngage / CheckSpriteCanSeePlayer compares screen
-- coordinates only (home/trainers.asm, engine/overworld/
-- trainer_sight.asm) -- there is no line-of-sight obstruction check.
-- An aligned trainer within range engages through interposed NPCs and
-- unwalkable tiles, and the scripted walk-up below (scriptMove) also
-- ignores collision, so the trainer simply walks/overlaps through
-- anything on the line -- exactly as OAM sprites overlap on hardware.
if dist and dist >= 1 and dist <= range then
self:startTrainerApproach(npc, dist)
return
-- Screen-pixel range (CheckSpriteCanSeePlayer), not cell count:
-- same facing-line rule as before, but the $fc Y quirk excludes the
-- 4-tiles-north tile that cell math would still count as in range.
if dist and dist >= 1 then
local pixelDist = trainerSightPixelDist(npc, p, horizontal)
if pixelDist > 0 and pixelDist <= range * 16 then
self:startTrainerApproach(npc, dist)
return
end
end
end
end
@@ -2560,8 +2721,11 @@ function OverworldState:onStepComplete()
elseif entry then
-- still standing on the warp we arrived through; do not re-trigger it
else
-- CheckWarpsNoCollision: door/warp tiles fire immediately; otherwise
-- ExtraWarpCheck must pass AND either a d-pad is held or BIT_FORCED_WARP
-- is set (Seafoam B3F currents — home/overworld.asm).
local w = Warp.onArrive(self.map, p.cellX, p.cellY)
if not w and self:dirHeld() then
if not w and (self:dirHeld() or self.forcedWarp) then
w = Warp.onCollision(self.map, Game.data.field.warpCarpets,
p.cellX, p.cellY, p.facing)
end
@@ -2642,9 +2806,11 @@ function OverworldState:runSpinnerMoves(moves, i)
local mv = moves[i]
if not mv then
self.player.spinning = false
if not self:checkSpinner() and self.player.surfing then
self:checkSeafoamCurrent()
end
-- Scripted steps skip onStepComplete while they run; once the RLE
-- finishes, re-enter the normal landing pipeline so chained spinners,
-- Seafoam currents, and CheckWarpsNoCollision (incl. BIT_FORCED_WARP)
-- see the tile we stopped on — same as pokered after simulated joypad.
self:onStepComplete()
return
end
self.player.spinning = true -- spin the sprite while sliding
@@ -2817,6 +2983,9 @@ function OverworldState:checkSeafoamCurrent()
if sf.forcedExit and p.surfing and not allSet(sf.forcedExit.activeUntilEvents) then
for _, c in ipairs(sf.forcedExit.coords) do
if p.cellX == c.x and p.cellY == c.y then
-- SeafoamIslandsB4FDefaultScript: res BIT_FORCED_WARP before the
-- push so the B3F stair warps underfoot cannot bounce you back.
self.forcedWarp = false
require("src.core.Sound").play(Game.data, "Collision")
self:scriptMove(p, "up", c.y == 17 and 2 or 1)
return true
@@ -2838,6 +3007,12 @@ function OverworldState:checkSeafoamCurrent()
end
for _, c in ipairs(active) do
if p.cellX == c.x and p.cellY == c.y then
-- SeafoamIslandsB3F.asm sets BIT_FORCED_WARP before DecodeRLEList so
-- the south-edge water stairs auto-warp when the current ends.
if FieldDefaults.fieldValue(Game.data, "seafoam", self.map.id,
"setsForcedWarp") then
self.forcedWarp = true
end
self:runSpinnerMoves(c.moves, 1)
return true
end
@@ -3046,16 +3221,20 @@ end
-- Warp to the last heal point (blackout, ESCAPE ROPE, DIG/TELEPORT).
-- The heal point is usually an interior, so LAST_MAP exits are re-pointed
-- at its remembered town door rather than wherever the player left from.
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()
self.player.surfing = false
-- HandleFlyWarpOrDungeonWarp + DisplayPlayerBlackedOutText both clear
-- BIT_ALWAYS_ON_BIKE (home/overworld.asm / home/text_script.asm)
Game.save.forcedBike = nil
-- rematerializing plays the teleport-in poof (EnterMapAnim in
-- engine/overworld/player_animations.asm: SFX_TELEPORT_ENTER_1, then
-- ENTER_2 after the spin-down); blackouts take this path too
self.arriveWarp = "teleport"
if opts and opts.arrive == "teleport" then
self.arriveWarp = "teleport"
end
self:startWarpTo(heal.map, heal.x, heal.y, "down", onDone)
if heal.outdoor then
self:rememberOutdoor(heal.outdoor.id, heal.outdoor.x, heal.outdoor.y)
@@ -3093,9 +3272,9 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts)
-- one-step justWarped guard, which only skipped the very next frame's
-- check and so let a mon walked back onto the pad re-trigger it.
self.warpEntryCell = { x = x, y = y }
-- Fly/Teleport/Dig/Escape-Rope/blackout landings poof the player
-- back in (player_animations.asm EnterMapAnim); ordinary door
-- warps never take this branch
-- Fly/Teleport/Dig/Escape-Rope landings poof the player back in
-- (player_animations.asm EnterMapAnim). Blackouts and ordinary
-- door warps never take this branch.
if arriveWarp == "fly" then
require("src.core.Sound").play(Game.data, "Fly")
elseif arriveWarp == "teleport" then
@@ -3335,7 +3514,9 @@ function OverworldState:billboard(fx, fy, vw, vh, colors, keyed, drawFn)
end
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()
-- let the renderer know whether a spinner puzzle is currently sliding
-- the player, so it can flicker the arrow tiles between the blur and
@@ -3415,8 +3596,11 @@ function OverworldState:drawWorld()
love.graphics.setShader(shader)
end
end
local ox = ha.px - 64 - cam.x
local oy = ha.py - 64 - cam.y
-- TileRenderer windows with -floor(cam), so the overlay must use the
-- same snap or a fractional camera (odd fill/tilt view sizes) parks
-- the balls a pixel off the machine tiles
local ox = ha.px - 64 - math.floor(cam.x)
local oy = ha.py - 64 - math.floor(cam.y)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(img, self.healMachineQuads[1], ox + 44, oy + 20)
for i = 1, math.min(ha.lit, #HEAL_BALL_XY) do
@@ -3632,11 +3816,13 @@ function OverworldState:drawWorld()
else
-- === TILT PATH: ground-hugging FX stay on the projected ground, all
-- standing things billboard upright over it in a separate pass. ======
-- Dust is ground-hugging smoke -> ground canvas (puts it
-- with the flat layer, so it projects with the ground). Flat mode
-- draws it last, over the sprites, in the same canvas; here the two
-- Dust / cut / the Poké Center heal overlay hug the BG (the heal
-- machine is a tileset graphic; its OAM balls must ride that plane or
-- they float off the machine once the ground foreshortens). Flat mode
-- draws them last, over the sprites, in the same canvas; here the two
-- layers are separate and composited ground-under-upright, so drawing
-- it now into the still-active ground canvas is order-equivalent.
-- them now into the still-active ground canvas is order-equivalent.
fxHeal()
fxDust()
fxCutTree()
@@ -3692,18 +3878,11 @@ function OverworldState:drawWorld()
end
end
-- Screen-anchored world FX : each billboards at the
-- ground foot of the character it belongs to, so it stands upright and
-- scales with that character's depth.
-- heal machine -> the healed player's foot (the machine stands on
-- the ground in front of where the player was)
-- Standing world FX: each billboards at the ground foot of the
-- character it belongs to, so it stays upright over the tilted ground.
-- emote bubble -> the spotting NPC's foot (rides above its head)
-- fly bird, rod -> the player's foot
if self.healAnim then
local fx = self.healAnim.px - cam.x + 8
local fy = self.healAnim.py - cam.y + 16
self:billboard(fx, fy, vw, vh, zoneColorsAt(zones, fx, fy), false, fxHeal)
end
-- (heal machine is ground-hugging -- drawn above with dust/cut)
if self.emote and self.emote.npc then
local fx = self.emote.npc.px - cam.x + 8
local fy = self.emote.npc.py - cam.y + 16
+20 -4
View File
@@ -81,6 +81,14 @@ end
-- Advance one fixed step; returns true when a step just completed.
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
self.turnTimer = self.turnTimer - 1
end
@@ -109,6 +117,11 @@ function Player:update()
self.px, self.py = self.cellX * 16, self.cellY * 16
self.moving = false
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
end
return false
@@ -119,7 +132,7 @@ function Player:facingCell()
end
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
local p = (self.animClock or self.progress) % 16
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)
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
self.hopFrames = self.hopFrames - 1
local t = 1 - self.hopFrames / (self.hopTotal or 32)
local total = 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)
-- the shadow stays on the ground under the jumper: one 8x8 tile
-- 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(ow.player.cellX == 3 and ow.player.cellY == 6,
"landed on the bedroom spawn (3,6)")
check(ow.lastOutdoor and ow.lastOutdoor.id == "PALLET_TOWN",
"PC home warp retargets lastOutdoor to Pallet (#103)")
local lead = game.save.party and game.save.party[1]
if lead and lead.stats then
check(lead.hp == lead.stats.hp and lead.status == nil,
"PC home warp heals the party")
end
U.log(pass and "RESULT: ALL PASS" or "RESULT: SEE FAILURES ABOVE")
end
+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))
return false
end
-- The knower must also be CONSCIOUS: the party submenu hides field
-- moves on a fainted mon (vanilla -- partyKnows requires hp > 0), so
-- the menu walk below finds only STATS/SWITCH and fails. Route 23's
-- mandatory surf strip is where this bit: WARTORTLE arrived dead from
-- the Viridian Gym attrition, "no SURF entry" x3, the water was never
-- crossed, and the whole league skip-cascaded with all eight badges
-- in the bag. Heal, fail this attempt loudly, and let the caller's
-- retry run the step again with a live user.
local knower = (G.save.party or {})[slot]
if knower and (knower.hp or 0) <= 0 then
say(("fieldMove %s: its only user has fainted -- visiting a centre "
.. "before retrying"):format(move))
visitPokeCenter(where)
return false
end
-- Field moves work from fainted users in Gen 1 (PartyMenu + partyKnows
-- no longer gate on hp > 0). No Poké Center detour needed here.
-- CUT acts on the tile the player is FACING (OverworldState:tryCut), and
-- a `goto` leaves us facing whatever direction we last stepped. Without
-- turning to the tree first the move fires into empty ground, the game
+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,
rotate = noop, origin = noop, setShader = noop, setScissor = noop,
getDimensions = function() return 640, 576 end,
-- dpi=1 desktop default; issue #87 tests override these for Android density
getPixelDimensions = function() return 640, 576 end,
getDPIScale = function() return 1 end,
}
stub.math = {
@@ -127,7 +130,12 @@ stub.sound = {
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 }
+18
View File
@@ -344,6 +344,24 @@ check(lastSource().queueable and lastSource().playing,
"a chip song still plays after a file song")
check(not body.playing, "the outgoing file song was stopped")
-- playOnce must survive the threaded "empty QueueableSource" window:
-- Source:isPlaying is false until the first worker buffer lands, and that
-- gap must not look like the jingle already ended (Poké Center heal).
data = reset(fixtureData())
Music.playMap(data, "PALLET_TOWN", false, false)
check(Music.playOnce(data, "Music_Chip"), "playOnce starts a chip jingle")
local jingle = lastSource()
local clearAwait = ChipAudio._simulateAwaitingFirstBufferForTest()
check(clearAwait ~= nil, "test can force the awaiting-first-buffer window")
check(Music.oneShotPlaying(),
"oneShotPlaying stays true while the first buffer is still in flight")
Music.update(data)
check(jingle == lastSource() and jingle.queueable,
"pendingRestore does not swap the map theme over a pending chip jingle")
check(ChipAudio.awaitingFirstBuffer(),
"awaitingFirstBuffer reports the forced window")
clearAwait()
-- sfx shape dispatch
check(Sound.play(data, "Beep") == nil, "Sound.play returns nothing")
check(lastSource().file == "assets/beep.wav", "a bare string sfx is a static source")
+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")
check(vanillaSave.player.name == "RED" and vanillaSave.player.rival == "BLUE"
and vanillaSave.money == 3000, "the seeded boot config reproduces the Red start")
-- issue #109: vanilla New Game puts 1 Potion in the player's item PC
check(vanillaSave.pcItems and vanillaSave.pcItems.POTION == 1,
"new game seeds 1 Potion in the player's PC")
-- the heal point is deliberately NOT the spawn: wLastBlackoutMap is
-- zero-filled at new game and PALLET_TOWN is map 0
check(vanillaSave.lastHeal.map == "PALLET_TOWN" and vanillaSave.lastHeal.x == 5
@@ -329,6 +332,8 @@ local bareSave = SaveData.newGame()
check(bareSave.player.map == "REDS_HOUSE_2F" and bareSave.money == 3000
and bareSave.lastHeal.map == "PALLET_TOWN",
"newGame without a boot config is unchanged")
check(bareSave.pcItems and bareSave.pcItems.POTION == 1,
"newGame without a boot config still seeds the PC Potion")
local bootData, bootLoader = withMod("total_conversion", [[
return function(mod)
+198
View File
@@ -128,6 +128,8 @@ love.graphics = {
translate = noop, scale = noop, rotate = noop, origin = noop,
setScissor = noop, getColor = function() return 1, 1, 1, 1 end,
getDimensions = function() return 640, 576 end,
getPixelDimensions = function() return 640, 576 end,
getDPIScale = function() return 1 end,
}
-- Fresh copies of the modules that cache a compiled shader or a page set
@@ -618,6 +620,49 @@ check(PaletteFX.pal({ palettes = nil }, "ROUTE") == gbc.palettes.ROUTE,
"RED++ still has ROUTE (aliased from VIRIDIAN)")
check(PaletteFX.effectiveColors(gbc.palettes.MEWMON) == gbc.palettes.MEWMON,
"RED++ passes zone colors through like GBC")
-- issue #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)
-- ------- the transitions registry
@@ -647,6 +692,159 @@ check(Transition.new({ data = retimed }).frames == 30,
check(Transition.new({ data = { transitions = {} } }).frames == 12,
"an unregistered id falls back to the built-in 12 frames")
-- issue #121: with the survey-zoom world pass active, the warp fade must
-- darken the full window composite (via Renderer.worldFadeAlpha), not just
-- paint a 160x144 rect on the UI letterbox.
do
local rects = {}
local color = { 1, 1, 1, 1 }
local savedRect, savedColor = love.graphics.rectangle, love.graphics.setColor
love.graphics.rectangle = function(mode, x, y, w, h)
rects[#rects + 1] = {
mode = mode, x = x, y = y, w = w, h = h,
a = color[4], r = color[1],
}
end
love.graphics.setColor = function(r, g, b, a)
color[1], color[2], color[3], color[4] = r, g, b, a or 1
end
Renderer:init()
local fade = Transition.new({ renderer = Renderer, stack = { pop = noop } })
fade.t = 6 -- mid fade-out (12 frames)
fade.phase = "out"
Renderer:beginFrame(true)
Renderer:beginWorldPass()
Renderer:endWorldPass()
check(Renderer.worldActive == true, "world pass stays marked active until endFrame")
fade:draw()
check(Renderer.worldFadeAlpha == 0.5,
"warp fade hands mid-out alpha to the world composite overlay")
check(#rects == 0,
"warp fade does not paint the 160x144 UI letterbox while the world pass is up")
resetLog()
rects = {}
Renderer:endFrame(nil, fullWorldZones())
local fadeRect
for _, r in ipairs(rects) do
-- endFrame's letterbox clear is also a full-window black fill (a == 1);
-- the warp overlay is the half-alpha one Transition requested
if r.mode == "fill" and r.x == 0 and r.y == 0
and r.w == 640 and r.h == 576 and r.r == 0 and r.a == 0.5 then
fadeRect = r
end
end
check(fadeRect ~= nil,
"endFrame paints the warp fade over the full window at the fade alpha")
check(Renderer.worldActive == false, "endFrame clears worldActive")
-- without a world pass (opaque UI states), keep the classic UI rect
Renderer:beginFrame(false)
fade.t = 6
fade.phase = "out"
rects = {}
fade:draw()
check(Renderer.worldFadeAlpha == nil,
"no world pass: warp fade does not set a world overlay")
check(#rects == 1 and rects[1].w == 160 and rects[1].h == 144,
"no world pass: warp fade still fills the 160x144 UI canvas")
love.graphics.rectangle, love.graphics.setColor = savedRect, savedColor
end
-- ------- 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
local stack = { pop = function() end }
+61
View File
@@ -215,6 +215,41 @@ do
SaveData.runMigrations(tagged)
check(tagged.version == "blue", "an existing version tag is left untouched")
-- #131: format-3 save that beat the Game Corner poster grunt before
-- hide_object was wired -- defeatedTrainers only, no objectToggles hide
local stuckGrunt = {
meta = { format = 3, mods = {} },
defeatedTrainers = { GAME_CORNER_obj_11 = true },
objectToggles = {},
flags = {},
player = {},
}
SaveData.runMigrations(stuckGrunt)
check(stuckGrunt.objectToggles.GAME_CORNER
and stuckGrunt.objectToggles.GAME_CORNER.GAMECORNER_ROCKET == false,
"Game Corner rocket hide backfilled from defeatedTrainers")
check(stuckGrunt.meta.format == Version.saveFormat,
"format-3 hideout migration stamps to current")
-- already-hidden stays hidden; undefeated grunt is left alone
local alreadyHidden = {
meta = { format = 3, mods = {} },
defeatedTrainers = { GAME_CORNER_obj_11 = true },
objectToggles = { GAME_CORNER = { GAMECORNER_ROCKET = false } },
player = {},
}
SaveData.runMigrations(alreadyHidden)
check(alreadyHidden.objectToggles.GAME_CORNER.GAMECORNER_ROCKET == false,
"already-hidden rocket toggle is left false")
local neverFought = {
meta = { format = 3, mods = {} },
defeatedTrainers = {},
objectToggles = {},
player = {},
}
SaveData.runMigrations(neverFought)
check(not neverFought.objectToggles.GAME_CORNER,
"undefeated Game Corner rocket is not auto-hidden")
love.filesystem = realFS
end
@@ -536,6 +571,32 @@ do
"newGame starts an empty modData")
check(plain.meta.format == Version.saveFormat and #plain.meta.mods == 0,
"newGame stamps a vanilla meta")
check(plain.pcItems and plain.pcItems.POTION == 1,
"unhooked newGame seeds 1 Potion in pcItems (issue #109)")
end
-- issue #109: loading a pre-fix save that never had pcItems must not
-- invent a free Potion (player may already have withdrawn/tossed it, or
-- the empty PC is intentional). Seeding is New Game only.
do
local files = {}
love.filesystem = memfs(files)
local legacy = {
meta = { format = Version.saveFormat, mods = {} },
player = { map = "PALLET_TOWN", x = 5, y = 6, facing = "down",
name = "RED", rival = "BLUE", id = 1 },
flags = {}, inventory = {}, party = {}, box = {}, money = 3000,
defeatedTrainers = {}, pokedex = { seen = {}, owned = {} },
lastHeal = { map = "PALLET_TOWN", x = 5, y = 6 },
options = SaveData.defaultOptions(),
}
check(legacy.pcItems == nil, "fixture omits pcItems on purpose")
check(SaveData.save(legacy), "legacy save without pcItems writes")
local loaded = SaveData.load()
check(loaded ~= nil, "legacy save loads")
check(loaded.pcItems == nil or loaded.pcItems.POTION == nil,
"load does not invent a PC Potion for existing saves")
love.filesystem = realFS
end
-- a throwing mod migration is skipped, never fatal: an uncaught error here
+188 -4
View File
@@ -22,6 +22,7 @@ local StateStack = require("src.core.StateStack")
local ManagerState = require("src.mods.ManagerState")
local ModUI = require("src.ui.ModUI")
local Theme = require("src.ui.Theme")
local FrameCap = require("src.core.FrameCap")
local savedEvents, savedHooks, savedErrors =
Runtime.events, Runtime.hooks, Runtime.errors
@@ -205,7 +206,8 @@ end
local om = OptionsMenu.new(optGame())
local WANT_IDS = { "textSpeed", "animations", "battleStyle", "ruleset",
"musicVol", "sfxVol", "musicFilter", "colors", "tilt",
"gbcfx", "videoMode", "speed", "mods", "controls" }
"gbcfx", "zoom", "voidFill", "videoMode", "fpsCap",
"speed", "mods", "controls" }
check(#om.rows == #WANT_IDS, "vanilla options row count (plus MODS/CONTROLS)")
for i, id in ipairs(WANT_IDS) do
check(om.rows[i].id == id, "options row order: " .. id)
@@ -235,10 +237,65 @@ check(om.game.save.options.musicVol == 6, "music volume steps down")
for _ = 1, 10 do om.rows[5].step(om.game, -1) end
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
om.game.save.options.fpsCap = nil
check(om.rows[14].value(om.game) == "60",
"MAX FPS row defaults to 60 with no saved cap")
om.rows[14].step(om.game, 1)
check(om.game.save.options.fpsCap == 75, "MAX FPS steps up from 60 to 75")
check(om.rows[14].value(om.game) == "75", "the MAX FPS row renders the cap")
om.game.save.options.fpsCap = 160
om.rows[14].step(om.game, 1)
check(om.game.save.options.fpsCap == 30, "MAX FPS wraps past the ceiling to 30")
om.rows[14].step(om.game, -1)
check(om.game.save.options.fpsCap == 160, "MAX FPS wraps back down to the ceiling")
-- ------- FrameCap normalize / cycle (issue #88)
check(FrameCap.normalize(nil) == 60, "FrameCap defaults nil to 60")
check(FrameCap.normalize("junk") == 60, "FrameCap defaults garbage to 60")
check(FrameCap.normalize(60) == 60, "FrameCap keeps an exact step")
check(FrameCap.normalize(58) == 60, "FrameCap snaps 58 to the nearest step 60")
check(FrameCap.normalize(72) == 75, "FrameCap snaps 72 to the nearest step 75")
check(FrameCap.normalize(0) == 30, "FrameCap clamps below the floor to 30")
check(FrameCap.normalize(9999) == 160, "FrameCap clamps above the ceiling to 160")
check(FrameCap.normalize(30) == 30 and FrameCap.normalize(160) == 160,
"FrameCap keeps the exact floor and ceiling")
check(FrameCap.label(nil) == "60" and FrameCap.label(144) == "144",
"FrameCap.label renders the normalized cap as plain text")
check(FrameCap.cycle(60, 1) == 75, "FrameCap cycles 60 up to 75")
check(FrameCap.cycle(60, -1) == 50, "FrameCap cycles 60 down to 50")
check(FrameCap.cycle(160, 1) == 30, "FrameCap cycle wraps the ceiling to the floor")
check(FrameCap.cycle(30, -1) == 160, "FrameCap cycle wraps the floor to the ceiling")
check(FrameCap.cycle(nil, 1) == 75,
"FrameCap cycle normalizes a nil cap (60) before stepping")
-- apply drives the live value the run loop paces to; never touches love.timer
FrameCap.apply(144)
check(FrameCap.current == 144, "FrameCap.apply stores the live cap")
FrameCap.applyOptions({})
check(FrameCap.current == 60, "FrameCap.applyOptions defaults a missing key to 60")
-- the MODS row is the manager's discoverable home
local mgGame = optGame()
om = OptionsMenu.new(mgGame)
om.rows[13].activate(mgGame)
om.rows[16].activate(mgGame)
check(getmetatable(mgGame.stack:top()) == ManagerState,
"the MODS row opens the manager")
check(mgGame.stack:top().screenId == "ManagerState",
@@ -248,7 +305,7 @@ check(mgGame.stack:top().screenId == "ManagerState",
local BindingsMenu = require("src.ui.BindingsMenu")
local cbGame = optGame()
om = OptionsMenu.new(cbGame)
om.rows[14].activate(cbGame)
om.rows[17].activate(cbGame)
local bm = cbGame.stack:top()
check(getmetatable(bm) == BindingsMenu,
"the CONTROLS row opens the rebind list")
@@ -257,7 +314,8 @@ check(bm.screenId == "BindingsMenu",
check(#bm.items == 8, "one row per logical button")
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[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")
check(cbGame.save.options.bindings == nil,
"opening the screen alone writes nothing")
@@ -437,6 +495,132 @@ check(title.version and title.version.path
== "assets/generated/title/red_version.png",
"the shipped version ribbon loads")
-- issue #128: title SGB zones must resolve Blue's LOGO1 (blue ribbon),
-- not Red's, when the ROM pack carries Blue SuperPalettes. Color 0 is
-- forced to pure white so ROM's {255,239,255} does not paint a pink band
-- against GBC-pack LOGO2/MEWMON whites.
do
local GameVersion = require("src.core.GameVersion")
local PaletteFX = require("src.render.PaletteFX")
local prevVer, prevMode = GameVersion.get(), PaletteFX.mode
local blueLogo1 = {
{ 255, 239, 255 }, { 247, 247, 140 }, { 173, 0, 33 }, { 115, 156, 239 },
}
local mewmon = {
{ 255, 239, 255 }, { 247, 181, 140 }, { 132, 115, 156 }, { 25, 16, 16 },
}
local logo2 = {
{ 255, 239, 255 }, { 247, 247, 140 }, { 148, 148, 197 }, { 58, 58, 132 },
}
local game = { data = { palettes = { palettes = {
LOGO1 = blueLogo1, LOGO2 = logo2, MEWMON = mewmon,
} } } }
PaletteFX.setMode("redpp")
GameVersion.set("blue")
local zones = TitleState.sgbPalettes(title, game)
local ribbon = zones and zones[2] and zones[2].colors
check(ribbon and ribbon[4][1] == 115 and ribbon[4][3] == 239,
"Blue title ribbon zone keeps ROM LOGO1 blue ink under RED++")
check(ribbon and ribbon[1][1] == 255 and ribbon[1][2] == 255
and ribbon[1][3] == 255,
"Blue title ribbon white is pure (no pink SGB band)")
GameVersion.set("red")
zones = TitleState.sgbPalettes(title, game)
local gbcLogo1 = PaletteFX.gbcPack().palettes.LOGO1
ribbon = zones and zones[2] and zones[2].colors
check(ribbon and ribbon[4][1] == gbcLogo1[4][1]
and ribbon[4][2] == gbcLogo1[4][2]
and ribbon[4][3] == gbcLogo1[4][3],
"Red title under RED++ keeps gbc-pack LOGO1 ink even if ROM has Blue's")
GameVersion.set(prevVer)
PaletteFX.setMode(prevMode)
end
-- issue #133: title menu / continue overlays must not inherit LOGO2/LOGO1
-- (blue/red UI ink). A trailing trueColor zone covers the overlay box.
do
local logo2 = {
{ 255, 255, 255 }, { 230, 197, 0 }, { 148, 156, 148 }, { 41, 99, 181 },
}
local logo1 = {
{ 255, 255, 255 }, { 247, 247, 140 }, { 140, 189, 82 }, { 173, 0, 33 },
}
local mewmon = {
{ 255, 239, 255 }, { 247, 181, 140 }, { 132, 115, 156 }, { 25, 16, 16 },
}
local game = {
data = { palettes = { palettes = {
LOGO1 = logo1, LOGO2 = logo2, MEWMON = mewmon,
} } },
stack = newStack(),
}
local bare = TitleState.sgbPalettes(title, game)
check(bare and #bare == 3 and bare[1].colors == logo2,
"bare title keeps three LOGO/MEWMON zones")
check(not bare[4], "bare title has no overlay trueColor zone")
game.stack:push(title)
-- openMenu needs SaveData/hasSave; stub a no-save menu via the same stamp
local Menu = require("src.ui.Menu")
local menu = Menu.new(game, { { label = "NEW GAME" } },
{ tx = 0, ty = 0, tw = 13, th = 4 })
menu.titleUiBox = { 0, 0, 12, 3 }
game.stack:push(menu)
local withMenu = TitleState.sgbPalettes(title, game)
check(withMenu and #withMenu == 4 and withMenu[4].colors == false,
"title menu adds a trueColor overlay zone")
check(withMenu[4].x == 0 and withMenu[4].y == 0
and withMenu[4].w == 13 * 8 and withMenu[4].h == 4 * 8,
"menu overlay covers the CONTINUE/NEW GAME box")
game.stack:pop()
game.stack:push({ titleUiBox = { 4, 7, 19, 16 } })
local withCont = TitleState.sgbPalettes(title, game)
check(withCont and #withCont == 4 and withCont[4].colors == false,
"continue-info overlay adds a trueColor zone")
check(withCont[4].x == 4 * 8 and withCont[4].y == 7 * 8
and withCont[4].w == 16 * 8 and withCont[4].h == 10 * 8,
"continue overlay matches DisplayContinueGameInfo's box")
-- openMenu itself must stamp titleUiBox on the real Menu it pushes
while game.stack:top() do game.stack:pop() end
title.game = game
title:openMenu()
local opened = game.stack:top()
check(opened and opened.titleUiBox
and opened.titleUiBox[1] == 0 and opened.titleUiBox[3] == 12,
"openMenu stamps titleUiBox on the pushed Menu")
end
-- Options / mod manager opened from the title must not inherit LOGO1
-- (third options box = rows 8-9 would otherwise tint pink).
do
local OptionsMenu = require("src.ui.OptionsMenu")
local ManagerState = require("src.mods.ManagerState")
local PaletteFX = require("src.render.PaletteFX")
local mewmon = {
{ 255, 255, 255 }, { 239, 156, 107 }, { 115, 33, 165 }, { 0, 0, 0 },
}
local game = { data = { palettes = { palettes = { MEWMON = mewmon } } },
save = { options = {} } }
local optZones = OptionsMenu.sgbPalettes(OptionsMenu, game)
check(optZones and #optZones == 1 and optZones[1].colors == mewmon,
"OptionsMenu owns a whole-screen MEWMON zone")
local modZones = ManagerState.sgbPalettes(ManagerState, game)
check(modZones and #modZones == 1 and modZones[1].colors == mewmon,
"ManagerState owns a whole-screen MEWMON zone")
-- stack walk: with Options on top of Title, Game would pick Options
local titleZones = TitleState.sgbPalettes(title, {
data = { palettes = { palettes = {
LOGO1 = { { 255, 239, 255 }, { 1, 2, 3 }, { 4, 5, 6 }, { 115, 156, 239 } },
LOGO2 = mewmon, MEWMON = mewmon,
} } },
})
check(titleZones and titleZones[2].colors[1][2] == 255,
"title LOGO1 sanitize still pure-white with pink ROM input")
check(PaletteFX.wholeNamed, "PaletteFX.wholeNamed still available for menus")
end
local OakSpeech = require("src.ui.OakSpeech")
local ogame = { data = {
field = { oakSpeech = { music = "X_Song", demoSpecies = "PIKACHU" } },
+9
View File
@@ -338,6 +338,15 @@ do
end
check(pewterNpc and ow:trainerDefeated(pewterNpc),
"unfought Pewter gym trainer is defeated after badge")
-- PewterGym.asm .gymVictory HideObject TOGGLE_GYM_GUY /
-- TOGGLE_ROUTE_22_RIVAL_1 (persists via objectToggles)
check(Game.save.objectToggles
and Game.save.objectToggles.PEWTER_CITY
and Game.save.objectToggles.PEWTER_CITY.PEWTERCITY_YOUNGSTER == false,
"Brock victory hides PEWTERCITY_YOUNGSTER")
check(Game.save.objectToggles.ROUTE_22
and Game.save.objectToggles.ROUTE_22.ROUTE22_RIVAL1 == false,
"Brock victory hides ROUTE22_RIVAL1")
while Game.stack:top() do Game.stack:pop() end
Game.save = SaveData.newGame()
+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")
check(champ ~= nil, "CHAMPIONS_ROOM map script registered")
check(champ and type(champ.onEnter) == "function",
"CHAMPIONS_ROOM.onEnter is a function (forced rival entrance)")
local rows = champ and champ.talk and champ.talk.TEXT_CHAMPIONSROOM_RIVAL
check(type(rows) == "table", "CHAMPIONS_ROOM.talk.TEXT_CHAMPIONSROOM_RIVAL exists")
-- (2) the rival cutscene rows contain the pokered beats in order
rows = rows or {}
local preds = {
{ "play_music Music_Cities1 (OakArrives / Music_Cities1AlternateTempo)",
function(r) return r[1] == "play_music" and r[2] == "Music_Cities1"
and type(r[3]) == "table" and r[3].keep == true end },
{ "show_object CHAMPIONSROOM_OAK",
function(r) return r[1] == "show_object" and r[3] == "CHAMPIONSROOM_OAK" end },
{ "move_npc(2,'up',5) OakEntranceAfterVictoryMovement",
@@ -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
-- queues (does not directly run) the room cutscene.
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 } }
hof.onEnter(fakeGame, fakeOw)
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)
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()
+64 -6
View File
@@ -55,9 +55,27 @@ local function newStack()
return stack, items
end
-- snapshot / restore car warps so tests that seed or ride do not leak
-- across cases (Silph's ROM default is UNUSED_MAP_ED)
local function snapshotWarps(mapId)
local snap = {}
for i, w in ipairs(Data.maps[mapId].warps) do
snap[i] = { destMap = w.destMap, destWarp = w.destWarp }
end
return snap
end
local function restoreWarps(mapId, snap)
for i, w in ipairs(Data.maps[mapId].warps) do
w.destMap, w.destWarp = snap[i].destMap, snap[i].destWarp
end
end
-- drives one elevator map's onEnter and returns the pushed state (either a
-- ListMenu or, for the keyGated Rocket Hideout without the key, a TextBox)
local function openElevator(mapId, inventory)
-- ListMenu or, for the keyGated Rocket Hideout without the key, a TextBox).
-- fromMapId is the floor the player entered from (OverworldState:setMap
-- passes it), used to seed a cancel-safe walk-out destination.
local function openElevator(mapId, inventory, fromMapId)
local script = mapScripts.get(mapId)
check(script ~= nil, mapId .. " script registered")
check(script and script.onEnter ~= nil, mapId .. " has onEnter")
@@ -98,7 +116,7 @@ local function openElevator(mapId, inventory)
stack = stack,
}
sfxCalls = {}
script.onEnter(game, ow)
script.onEnter(game, ow, fromMapId)
return items[#items], warpCalls, stack, ow
end
@@ -130,7 +148,14 @@ end
-- Silph Co elevator: 11 floors, the double-digit sort/label regression
-- ===================================================================
do
local menu, warpCalls, stack, ow = openElevator("SILPH_CO_ELEVATOR")
local silphSnap = snapshotWarps("SILPH_CO_ELEVATOR")
-- ROM default (UNUSED_MAP_ED) must still be what we start from so the
-- cancel-then-exit regression below is real
eq(silphSnap[1].destMap, "UNUSED_MAP_ED",
"Silph Co ROM car warps still default to UNUSED_MAP_ED")
local menu, warpCalls, stack, ow =
openElevator("SILPH_CO_ELEVATOR", nil, "SILPH_CO_5F")
check(menu ~= nil and getmetatable(menu) == ListMenu, "SILPH_CO_ELEVATOR opens a ListMenu")
if menu then
eq(#menu.items, 11, "Silph Co elevator lists all 11 floors")
@@ -143,12 +168,32 @@ do
check(menu.items[1].label ~= "SILPH CO 1F" and not menu.items[1].label:find("SILPH"),
"Silph Co floor label is the short token, not the full map id")
-- onEnter seeds the car's exit to the floor we came from so a B-cancel
-- cannot leave UNUSED_MAP_ED in place (#123 hard crash on walk-out)
eq(ow.map.def.warps[1].destMap, "SILPH_CO_5F",
"Silph onEnter seeds exit warps to the entry floor")
check(Data.maps[ow.map.def.warps[1].destMap] ~= nil,
"seeded Silph exit map exists in Data.maps")
-- Cancel: pokered's DisplayElevatorFloorMenu does `ret c` on B --
-- no warp at all.
clear(warpCalls)
menu.onCancel()
eq(#warpCalls, 0, "Cancel does not warp (bare ret c, no floors[1] fallback)")
-- #123: after B-cancel, walking out of the car must resolve without
-- asserting on the ROM placeholder UNUSED_MAP_ED
clear(warpCalls)
local okExit, errExit = pcall(function()
ow:takeWarp(ow.map.def.warps[1])
end)
check(okExit, "cancel then exit does not crash: " .. tostring(errExit))
eq(#warpCalls, 1, "cancel then exit takes the seeded entry-floor warp")
if warpCalls[1] then
eq(warpCalls[1].map, "SILPH_CO_5F",
"cancel then exit returns to the floor the player entered from")
end
-- Choose a mid-list floor (5F): pokered never warps on the spot --
-- DisplayElevatorFloorMenu sets BIT_CUR_MAP_USED_ELEVATOR and the
-- map script runs ShakeElevator: a 12-frame lead-in (the script's
@@ -157,6 +202,7 @@ do
-- cycle, then SFX_SAFARI_ZONE_PA, and only then the floor warp.
clear(warpCalls)
sfxCalls = {}
ow.walkSteps = {}
local chosen = menu.items[5] -- "5F"
menu.onChoose(chosen, menu)
eq(#warpCalls, 0, "choosing a floor does not warp on the spot (the shake runs first)")
@@ -184,6 +230,7 @@ do
eq(warpCalls[1].y, chosen.value.y, "walk-out lands on the chosen floor's y")
end
end
restoreWarps("SILPH_CO_ELEVATOR", silphSnap)
end
-- ===================================================================
@@ -234,11 +281,21 @@ end
-- the digit only), plus the LIFT_KEY gate.
-- ===================================================================
do
-- without the key: text-only, no floor menu
local gated = openElevator("ROCKET_HIDEOUT_ELEVATOR", {})
local hideoutSnap = snapshotWarps("ROCKET_HIDEOUT_ELEVATOR")
-- ROM car defaults to B1F; entering from another floor without the key
-- must still seed a walk-out back to that floor (#90 / #105).
eq(hideoutSnap[1].destMap, "ROCKET_HIDEOUT_B1F",
"Rocket Hideout ROM car warps still default to B1F")
-- without the key: text-only, no floor menu, but exit is seeded
local gated, _, _, gatedOw =
openElevator("ROCKET_HIDEOUT_ELEVATOR", {}, "ROCKET_HIDEOUT_B2F")
check(gated ~= nil, "Rocket Hideout without LIFT_KEY still pushes something")
check(gated ~= nil and getmetatable(gated) ~= ListMenu,
"Rocket Hideout without LIFT_KEY does not open the floor menu")
eq(gatedOw.map.def.warps[1].destMap, "ROCKET_HIDEOUT_B2F",
"Rocket Hideout without LIFT_KEY seeds exit warps to the entry floor")
restoreWarps("ROCKET_HIDEOUT_ELEVATOR", hideoutSnap)
-- with the key: full B1F/B2F/B4F menu, same as the other elevators
local menu, warpCalls, stack, ow = openElevator("ROCKET_HIDEOUT_ELEVATOR", { LIFT_KEY = 1 })
@@ -277,6 +334,7 @@ do
eq(warpCalls[1].y, chosen.value.y, "Rocket Hideout walk-out lands on the chosen floor y")
end
end
restoreWarps("ROCKET_HIDEOUT_ELEVATOR", hideoutSnap)
end
Sound.play = origSoundPlay
+62 -6
View File
@@ -7,6 +7,7 @@
-- over later, at TEXT_OAKSLAB_OAK1's .give_poke_balls beat, gated on
-- EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE and the one-shot
-- EVENT_GOT_POKEBALLS_FROM_OAK flag (data/scripts/oaks_lab.lua).
-- Also #137: starter give_pokemon runs AskName (nickname yes/no).
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data")
@@ -21,6 +22,8 @@ local StateStack = require("src.core.StateStack")
local SaveData = require("src.core.SaveData")
local ScriptRunner = require("src.script.ScriptRunner")
local Flags = require("src.script.Flags")
local ChoiceBox = require("src.ui.ChoiceBox")
local NamingScreen = require("src.ui.NamingScreen")
local mapScripts = require("data.scripts.init")
Game.data = Data
@@ -29,16 +32,15 @@ Game.stack = StateStack; StateStack:init()
Game.save = SaveData.newGame()
require("src.render.Font").load(Data)
-- pumps a script coroutine to completion, mashing A through any
-- show_text/ask boxes along the way (mirrors tests/run_tests.lua's
-- runScript helper for the parcel/pokedex chain)
local function runScript(script)
-- pumps a script coroutine to completion; pressFn returns the Input.pressed
-- table for this frame (default: mash A through text/ask/naming)
local function runScript(script, pressFn)
local r = ScriptRunner.new(Game, nil)
r:run(script, {})
local guard = 0
while r:isRunning() and guard < 2000 do
while r:isRunning() and guard < 4000 do
guard = guard + 1
Input.pressed = { a = true }
Input.pressed = pressFn and pressFn() or { a = true }
StateStack:update(1 / 60)
r:update()
end
@@ -58,6 +60,11 @@ check(runScript(mapScripts.talkScript("OAKS_LAB", "TEXT_OAKSLAB_BULBASAUR_POKE_B
"starter pick script completes")
check(Flags.get(Game.save, "EVENT_GOT_STARTER"), "starter flag set")
eq(Game.save.inventory.POKE_BALL, nil, "no POKe BALLs yet right after picking a starter")
check(Game.save.party[1] and Game.save.party[1].species == "BULBASAUR",
"starter joined the party")
-- A-mash accepts the nickname prompt and fills NamingScreen with A's
check(Game.save.party[1].nickname == "AAAAAAAAAA",
"starter nickname prompt accepted (AskName / #137)")
-- === 2) the parcel/pokedex beat still doesn't grant POKé BALLs ===
check(runScript(mapScripts.talkScript("VIRIDIAN_MART", "TEXT_VIRIDIANMART_CLERK")),
@@ -69,6 +76,12 @@ check(runScript(mapScripts.talkScript("OAKS_LAB", "TEXT_OAKSLAB_OAK1")),
eq(Game.save.inventory.OAKS_PARCEL, nil, "parcel delivered")
check(Flags.get(Game.save, "EVENT_OAK_GOT_PARCEL"), "delivery flag set")
check(Flags.get(Game.save, "EVENT_GOT_POKEDEX"), "Pokedex flag set")
-- OaksLab.asm OakGivesPokedex: HideObject TOGGLE_POKEDEX_1/2 (#106)
local labToggles = Game.save.objectToggles and Game.save.objectToggles.OAKS_LAB
check(labToggles and labToggles.OAKSLAB_POKEDEX1 == false,
"POKEDEX1 hidden after receiving Pokédex")
check(labToggles and labToggles.OAKSLAB_POKEDEX2 == false,
"POKEDEX2 hidden after receiving Pokédex")
eq(Game.save.inventory.POKE_BALL, nil, "still no POKe BALLs at the pokedex beat")
-- talking to Oak again before beating the Route 22 rival should fall
@@ -89,4 +102,47 @@ check(runScript(mapScripts.talkScript("OAKS_LAB", "TEXT_OAKSLAB_OAK1")),
"Oak talk (post-grant) script completes")
eq(Game.save.inventory.POKE_BALL, 5, "POKe Ball count unchanged on a second talk")
-- === 5) onEnter re-hides table Pokédex for pre-#106 saves ===
Game.save.objectToggles = { OAKS_LAB = {
OAKSLAB_POKEDEX1 = true, OAKSLAB_POKEDEX2 = true,
} }
local oaksLab = mapScripts.get("OAKS_LAB")
check(oaksLab and type(oaksLab.onEnter) == "function", "OAKS_LAB onEnter present")
oaksLab.onEnter(Game, nil)
local repaired = Game.save.objectToggles.OAKS_LAB
check(repaired.OAKSLAB_POKEDEX1 == false
and repaired.OAKSLAB_POKEDEX2 == false,
"onEnter hides both Pokédex table sprites when EVENT_GOT_POKEDEX is set")
-- === 6) #137: declining the starter nickname leaves no nickname ===
-- Choice boxes in order: (1) "you want X?" YES, (2) nickname YES/NO -> NO
Game.save = SaveData.newGame()
Flags.set(Game.save, "EVENT_FOLLOWED_OAK_INTO_LAB")
local choicesSeen, lastChoice = 0, nil
local function declineNickname()
local top = StateStack:top()
local mt = getmetatable(top)
if mt == ChoiceBox and top ~= lastChoice then
choicesSeen = choicesSeen + 1
lastChoice = top
elseif mt ~= ChoiceBox then
lastChoice = nil
end
if mt == ChoiceBox and choicesSeen >= 2 then
return { b = true }
end
if mt == NamingScreen then
return { start = true }
end
return { a = true }
end
check(runScript(mapScripts.talkScript("OAKS_LAB", "TEXT_OAKSLAB_CHARMANDER_POKE_BALL"),
declineNickname),
"starter pick with declined nickname completes")
check(Flags.get(Game.save, "EVENT_GOT_STARTER"), "declined-nickname path still sets starter flag")
eq(Game.save.party[1] and Game.save.party[1].species, "CHARMANDER",
"declined-nickname path still gives Charmander")
eq(Game.save.party[1] and Game.save.party[1].nickname, nil,
"declining nickname leaves the species name")
S.finish()
+51
View File
@@ -187,4 +187,55 @@ check(OW.objectVisible(Game.save, "SEAFOAM_ISLANDS_B4F",
objOf("SEAFOAM_ISLANDS_B4F", "SEAFOAMISLANDSB4F_BOULDER2")),
"SEAFOAMISLANDSB4F_BOULDER2 is visible at the end state")
-- === issue #129: B3F current auto-warps via BIT_FORCED_WARP ===
-- South-edge stairs at (20,17) are water warps (not door/warp tiles), so
-- CheckWarpsNoCollision needs BIT_FORCED_WARP (or a held d-pad). B3F
-- currents set that bit; after the RLE lands, the warp must fire without
-- further player input.
do
local FieldDefaults = require("src.world.FieldDefaults")
check(FieldDefaults.fieldValue(Data, "seafoam", "SEAFOAM_ISLANDS_B3F",
"setsForcedWarp") == true,
"B3F seafoam setsForcedWarp (BIT_FORCED_WARP) is configured")
while Game.stack:top() do Game.stack:pop() end
Game.save = SaveData.newGame()
Game.save.flags = {}
-- Landing on the B3F force-surf mouth mounts SURF and arms the current
-- (EnterMap CheckForceBikeOrSurf + Seafoam MOVE_OBJECT), including
-- BIT_FORCED_WARP for the south-edge stairs.
Game.stack:push(OW, "SEAFOAM_ISLANDS_B3F", 18, 7, "down")
local cur = Game.stack:top()
check(cur.player.surfing == true,
"B3F force-surf mouth mounts SURF on map entry")
check(cur.forcedWarp == true,
"B3F current arms forcedWarp (BIT_FORCED_WARP)")
check(#cur.scriptMoves > 0 or cur.player.moving,
"unplugged B3F current at (18,7) starts forced surfing RLE")
-- Fast-forward the scripted current onto the south-edge warp.
local guard = 0
while guard < 2000 do
guard = guard + 1
StateStack:update(1 / 60)
if cur.map and cur.map.id ~= "SEAFOAM_ISLANDS_B3F" then break end
if cur.transitioning then break end
end
-- Drain the warp Transition onto B4F (and any immediate forcedExit shove).
guard = 0
while guard < 400 do
guard = guard + 1
StateStack:update(1 / 60)
if Game.stack:top() == cur and cur.map and cur.map.id == "SEAFOAM_ISLANDS_B4F"
and not cur.transitioning and #cur.scriptMoves == 0
and not cur.player.moving then
break
end
end
check(cur.map and cur.map.id == "SEAFOAM_ISLANDS_B4F",
"B3F current auto-warps to B4F without a held d-pad (#129)")
check(cur.player.cellX == 20 or cur.player.cellX == 21,
"forced current lands on the B4F stair-warp column")
end
S.finish()
+93
View File
@@ -408,6 +408,99 @@ check(strFlash ~= page2 and strFlash ~= ow and strFlash.pages == nil,
for _ = 1, 10 do frame({}) end
eq(Game.stack:top(), ow, "back on the map after the blink")
-- ===========================================================================
-- #107: fainted Pokémon can still use field moves (party submenu + partyKnows)
-- ===========================================================================
local fainted = mkMon("PIDGEOTTO", "FLY", "CUT", "STRENGTH", "SURF")
fainted.hp = 0
Game.save.party = { fainted }
Game.save.inventory = {
THUNDERBADGE = true, CASCADEBADGE = true,
RAINBOWBADGE = true, SOULBADGE = true,
}
ow = pushOW("PALLET_TOWN", 4, 13, "down")
check(ow:partyKnows("SURF") == fainted,
"partyKnows finds SURF on a fainted mon")
check(ow:partyKnows("CUT") == fainted,
"partyKnows finds CUT on a fainted mon")
local pmFaint = PartyMenu.new(Game)
Game.stack:push(pmFaint)
frame({ "a" })
local actsFaint = submenuActions(pmFaint)
-- FLY needs OVERWORLD tileset (Pallet); CUT/SURF/STRENGTH need badges only
check(actsFaint.fly and actsFaint.cut and actsFaint.surf and actsFaint.strength,
"fainted mon still lists FLY/CUT/SURF/STRENGTH in the party submenu")
popToOW()
-- ===========================================================================
-- #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
TextBox.new = realTextBoxNew
popAll()
+78 -1
View File
@@ -79,6 +79,58 @@ do
"the queued trap anim row is attributed to the attacker")
end
-- (2b) Issue #140: being held by Wrap must NOT skip DisplayBattleMenu
-- (core.asm:312 then 323-329). FIGHT forces CANNOT_MOVE; AI still
-- auto-selects bound. A player switch clears the foe's trap bit
-- (SendOutMon core.asm:1761-1762).
do
local tb = freshBattle()
tb.enemy.trappingTurns = 2
tb.enemy.trapMove = "WRAP"
tb.enemy.trapDamage = 5
check(tb:menuLockedAction(tb.player) == nil,
"a Wrap victim still gets the battle menu")
local fight = tb:fightLockedAction(tb.player)
check(fight and fight.special == "bound",
"FIGHT while wrapped selects CANNOT_MOVE (bound)")
-- wrapper continues only after FIGHT, not by skipping the menu
check(tb:menuLockedAction(tb.enemy) == nil,
"a Wrap user still gets the battle menu")
check(tb:fightLockedAction(tb.enemy) and
tb:fightLockedAction(tb.enemy).special == "trapping",
"FIGHT while wrapping continues the trap")
check(tb:lockedAction(tb.player) and tb:lockedAction(tb.player).special == "bound",
"lockedAction still reports bound for AI / callers")
end
do
Game.save.party = {
Pokemon.new(Data, "BULBASAUR", 20),
Pokemon.new(Data, "SQUIRTLE", 20),
}
local tb = BattleState.newWild(Game, "EKANS", 10)
tb.enemy.trappingTurns = 3
tb.enemy.trapMove = "WRAP"
tb.enemy.trapDamage = 7
local acts = {}
function tb:act(fn) acts[#acts + 1] = fn end
function tb:actNext(fn) acts[#acts + 1] = fn end
function tb:sayNext() end
function tb:animNext() end
function tb:startGrowIn() end
function tb:syncSides() end
function tb:markParticipant() end
function tb:restoreMimicked() end
function tb:executeAction() end
function tb:endOfTurn() end
function tb:enemyAction() return { special = "bound" } end
tb:resolveSwitch(Game.save.party[2])
acts[1]() -- send-out clears foe trap
eq(tb.enemy.trappingTurns, nil, "player switch clears foe Wrap/Bind/etc.")
eq(tb.enemy.trapMove, nil, "player switch clears trapMove")
check(tb:fightLockedAction(tb.player) == nil,
"switch-in is free to choose a move")
end
-- (3) MIMIC runs MID-move (MimicEffect, effects.asm:1203-1273): the
-- move executes, MoveHitTest runs, and only on a hit does the player's
-- copy menu open (.letPlayerChooseMove) -- the enemy's Mimic and link
@@ -169,10 +221,35 @@ do
check(fe.chooser == nil, "enemy Mimic never opens a chooser")
eq(tbe.enemy.curMoves[1].id, "SAND_ATTACK",
"enemy Mimic copies a random player move immediately")
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"),
"enemy Mimic still plays the animation and learned text")
-- === #94: gen1_faithful enemies never deplete PP; player still does ===
do
local tb = freshBattle()
check(tb.ruleset.enemyUnlimitedPP,
"default ruleset grants enemy unlimited PP")
local enemyMove = { id = "TACKLE", pp = 5 }
local playerMove = { id = "TACKLE", pp = 5 }
tb.queue, tb.nextInsert = {}, 0
tb:performMove(tb.enemy, tb.player, enemyMove)
eq(enemyMove.pp, 5, "gen1_faithful: enemy move PP is not decremented")
tb.queue, tb.nextInsert = {}, 0
tb:performMove(tb.player, tb.enemy, playerMove)
eq(playerMove.pp, 4, "gen1_faithful: player move PP still decrements")
-- modern_clean tracks enemy PP (Gen 2+ style)
local modern = require("src.battle.rulesets.modern_clean")
tb.ruleset = modern
local enemyModern = { id = "TACKLE", pp = 5 }
tb.queue, tb.nextInsert = {}, 0
tb:performMove(tb.enemy, tb.player, enemyModern)
eq(enemyModern.pp, 4, "modern_clean: enemy move PP decrements")
end
-- link battle: the player's Mimic rolls random too (no chooser)
local tbl = freshBattle()
tbl.kind = "link"
+31 -3
View File
@@ -115,11 +115,39 @@ do
end
-- === STRUGGLE fallback when nothing is usable ===
-- modern_clean / no unlimited flag: empty PP forces Struggle.
do
local aiMon = { curMoves = { { id = "TACKLE", pp = 0 } } }
local pick = TrainerAI.chooseMove(aiMon, rngLo, { enemyAIMods = { 1 }, data = Data,
player = { mon = {}, curTypes = {} } })
check(pick and pick.struggle and pick.id == "STRUGGLE", "STRUGGLE fallback when no PP")
local pick = TrainerAI.chooseMove(aiMon, rngLo, {
enemyAIMods = { 1 }, data = Data,
player = { mon = {}, curTypes = {} },
ruleset = { enemyUnlimitedPP = false },
})
check(pick and pick.struggle and pick.id == "STRUGGLE",
"STRUGGLE fallback when no PP (enemy PP tracked)")
end
-- gen1_faithful: AI ignores PP; a 0-PP move is still selectable.
do
local aiMon = { curMoves = { { id = "TACKLE", pp = 0 } } }
local pick = TrainerAI.chooseMove(aiMon, rngLo, {
enemyAIMods = { 1 }, data = Data,
player = { mon = {}, curTypes = {} },
ruleset = { enemyUnlimitedPP = true },
})
eq(pick and pick.id, "TACKLE",
"gen1_faithful: enemy still picks 0-PP moves (no Struggle)")
end
-- Disable with unlimited PP: sole disabled move still yields Struggle.
do
local aiMon = { curMoves = { { id = "TACKLE", pp = 10 } }, disabledSlot = 1 }
local pick = TrainerAI.chooseMove(aiMon, rngLo, {
enemyAIMods = {}, data = Data,
ruleset = { enemyUnlimitedPP = true },
})
check(pick and pick.struggle and pick.id == "STRUGGLE",
"gen1_faithful: Struggle only when every move is disabled")
end
-- === 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()
+228
View File
@@ -0,0 +1,228 @@
-- Parity test, Bill's House PC + Route25ToggleBillsScript (#120).
--
-- asm sources:
-- engine/events/hidden_events/bills_house_pc.asm (BillsHousePC /
-- BillsHousePokemonList: after EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING
-- the PC opens EEVEE/FLAREON/JOLTEON/VAPOREON; before that the
-- teleporter monitor text or cell-separator cutscene)
-- scripts/Route25.asm (Route25ToggleBillsScript: first Route 25 load
-- after EVENT_MET_BILL_2 + EVENT_GOT_SS_TICKET sets
-- EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING, hides BILL1 / nugget-bridge
-- guy, shows BILL2; mid-quest leave resets the separator flag)
--
-- Self-contained: run via `luajit tests/parity_bills_pc.lua`; also
-- dofile'd by tests/run_tests.lua's aggregator.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data")
if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end
local S = require("tests.harness").suite("parity bills pc")
local check, eq = S.check, S.eq
local OW = require("src.world.OverworldController")
local SaveData = require("src.core.SaveData")
local story = require("data.scripts.story")
local function getUpvalue(fn, name)
local i = 1
while true do
local n, v = debug.getupvalue(fn, i)
if not n then return nil end
if n == name then return v end
i = i + 1
end
end
local function setUpvalue(fn, name, val)
local i = 1
while true do
local n = debug.getupvalue(fn, i)
if not n then return false end
if n == name then debug.setupvalue(fn, i, val); return true end
i = i + 1
end
end
local pushed = {}
local stackStub = {
push = function(_, item)
pushed[#pushed + 1] = item
end,
}
local textBoxStub = {
new = function(_, text, onDone)
local box = { kind = "text", text = text, onDone = onDone }
if onDone then onDone() end
return box
end,
}
local menuStub = {
new = function(_, items, opts)
return { kind = "menu", items = items, opts = opts or {} }
end,
}
local screensStub = {
push = function(_, id, species)
pushed[#pushed + 1] = { kind = "screen", id = id, species = species }
end,
}
local fakeGame = {
data = Data,
save = SaveData.newGame(),
stack = stackStub,
}
check(setUpvalue(OW.billsHousePC, "TextBox", textBoxStub), "TextBox upvalue")
check(setUpvalue(OW.billsHousePC, "Game", fakeGame), "Game upvalue for PC")
-- Screens is only referenced from billsHousePokemonList
check(setUpvalue(OW.billsHousePokemonList, "Screens", screensStub),
"Screens upvalue on pokemon list")
check(setUpvalue(OW.billsHousePokemonList, "Game", fakeGame),
"Game upvalue on pokemon list")
check(setUpvalue(OW.billsHousePokemonList, "TextBox", textBoxStub),
"TextBox upvalue on pokemon list")
-- Menu is required inside billsHousePokemonList; stub via package.loaded
local realMenu = package.loaded["src.ui.Menu"]
package.loaded["src.ui.Menu"] = menuStub
local fakeSelf = setmetatable({
queueScript = function() end,
billsHouseBillExits = function() end,
}, { __index = OW })
local function resetFlags()
fakeGame.save = SaveData.newGame()
setUpvalue(OW.billsHousePC, "Game", fakeGame)
pushed = {}
end
local function lastPush()
return pushed[#pushed]
end
local function runPC()
pushed = {}
fakeSelf:billsHousePC()
end
-- === 1) default: teleporter monitor text ===
resetFlags()
runPC()
eq(lastPush() and lastPush().kind, "text", "default PC is a text box")
check(tostring(lastPush().text):find("TELEPORTER", 1, true)
or tostring(lastPush().text):find("displayed on the", 1, true),
"default PC shows monitor text")
-- === 2) Bill in machine, separator not used yet: initiated text ===
resetFlags()
fakeGame.save.flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = true
local musicStopped = false
local realMusic = package.loaded["src.core.Music"]
package.loaded["src.core.Music"] = {
stop = function() musicStopped = true end,
playMap = function() end,
}
local realSound = package.loaded["src.core.Sound"]
package.loaded["src.core.Sound"] = {
play = function() end,
playCry = function() end,
}
runPC()
check(musicStopped, "cell separator stops map music")
check(tostring(lastPush().text):find("Cell", 1, true)
or tostring(lastPush().text):find("TELEPORTER", 1, true),
"separator path prints initiated text")
check(fakeGame.save.flags.EVENT_USED_CELL_SEPARATOR_ON_BILL,
"separator path sets EVENT_USED_CELL_SEPARATOR_ON_BILL")
-- === 3) after separator, before leaving: monitor text again ===
resetFlags()
fakeGame.save.flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = true
fakeGame.save.flags.EVENT_USED_CELL_SEPARATOR_ON_BILL = true
fakeGame.save.flags.EVENT_MET_BILL = true
runPC()
check(tostring(lastPush().text):find("TELEPORTER", 1, true)
or tostring(lastPush().text):find("displayed on the", 1, true),
"post-separator pre-leave PC shows monitor text")
-- === 4) after leaving: Eevee collection menu ===
resetFlags()
fakeGame.save.flags.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING = true
runPC()
local menu
for _, p in ipairs(pushed) do
if p.kind == "menu" then menu = p break end
end
check(menu ~= nil, "post-leave PC opens a menu")
local labels = {}
for _, item in ipairs(menu.items) do labels[#labels + 1] = item.label end
eq(table.concat(labels, ","), "EEVEE,FLAREON,JOLTEON,VAPOREON,CANCEL",
"Eevee collection lists the four mons + CANCEL")
-- selecting EEVEE marks seen and opens DexEntryMenu without closing list
fakeGame.save.pokedex = { seen = {}, owned = {} }
menu.items[1].onSelect()
check(fakeGame.save.pokedex.seen.EEVEE, "viewing marks EEVEE seen")
local dexPush
for i = #pushed, 1, -1 do
if pushed[i].kind == "screen" then dexPush = pushed[i] break end
end
eq(dexPush and dexPush.id, "DexEntryMenu", "selection opens DexEntryMenu")
eq(dexPush and dexPush.species, "EEVEE", "DexEntryMenu gets EEVEE")
check(menu.items[1].keepOpen, "dex pick keeps the list open")
-- === 5) Route25ToggleBillsScript ===
local toggles = {}
local Commands = require("src.script.Commands")
local realHide, realShow = Commands.hide_object, Commands.show_object
Commands.hide_object = function(_, mapId, name)
toggles[mapId .. ":" .. name] = false
end
Commands.show_object = function(_, mapId, name)
toggles[mapId .. ":" .. name] = true
end
local function runRoute25(flags)
toggles = {}
local save = SaveData.newGame()
for k, v in pairs(flags) do save.flags[k] = v end
story.ROUTE_25.onEnter({ save = save }, {})
return save.flags, toggles
end
local f, t = runRoute25({})
check(not f.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING,
"no leave flag before meeting Bill")
check(t["BILLS_HOUSE:BILLSHOUSE_BILL_POKEMON"] == true,
"mid-quest leave restores monster Bill")
check(f.EVENT_BILL_SAID_USE_CELL_SEPARATOR == nil,
"mid-quest leave clears separator arming flag")
f, t = runRoute25({ EVENT_MET_BILL_2 = true })
check(not f.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING,
"MET_BILL_2 without ticket does not arm leave flag")
f, t = runRoute25({
EVENT_MET_BILL_2 = true,
EVENT_GOT_SS_TICKET = true,
})
check(f.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING,
"ticket + MET_BILL_2 arms EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING")
eq(t["BILLS_HOUSE:BILLSHOUSE_BILL1"], false, "hides SS-Ticket Bill")
eq(t["BILLS_HOUSE:BILLSHOUSE_BILL2"], true, "shows rare-POKéMON Bill")
eq(t["ROUTE_24:ROUTE24_COOLTRAINER_M1"], false, "hides nugget-bridge guy")
f, t = runRoute25({
EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING = true,
EVENT_MET_BILL_2 = true,
EVENT_GOT_SS_TICKET = true,
})
eq(next(t), nil, "already-left Route 25 enter is a no-op")
Commands.hide_object = realHide
Commands.show_object = realShow
package.loaded["src.ui.Menu"] = realMenu
if realMusic ~= nil then package.loaded["src.core.Music"] = realMusic end
if realSound ~= nil then package.loaded["src.core.Sound"] = realSound end
S.finish()
+68
View File
@@ -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()
+116
View File
@@ -0,0 +1,116 @@
-- Regression (#125): SURF from Cinnabar's east coast into Route 20 water.
--
-- Cinnabar's easternmost cells are walkable land (tile $39); the water is
-- on ROUTE_20 across the east connection. pokered loads that strip into
-- the border, so IsNextTileShoreOrWater sees shore tile $32. The port
-- must read the connection landing the same way -- an inBounds-only
-- water check returns "no_water" and blocks the mount (and the reverse
-- party-menu dismount back onto the coast).
--
-- Self-contained; run via `luajit tests/parity_cinnabar_east_surf.lua`.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data")
if not (Data.maps and Data.maps.CINNABAR_ISLAND) then Data:load() end
local Game = require("src.core.Game")
local Input = require("src.core.Input")
local Map = require("src.world.Map")
local MapLoader = require("src.world.MapLoader")
local Renderer = require("src.render.Renderer")
local SaveData = require("src.core.SaveData")
local StateStack = require("src.core.StateStack")
local OW = require("src.world.OverworldController")
local S = require("tests.harness").suite("parity cinnabar east surf")
local check, eq = S.check, S.eq
local function mkMon(species, ...)
local moves = {}
for _, id in ipairs({ ... }) do
table.insert(moves, { id = id, pp = 10, ppUp = 0 })
end
return {
species = species, level = 30, hp = 50, maxHp = 50,
status = 0, moves = moves, nickname = nil,
}
end
local cin = MapLoader.load(Data, "CINNABAR_ISLAND")
local r20 = MapLoader.load(Data, "ROUTE_20")
local r20ts = Data.tilesets[r20.def.tileset]
-- ground truth: east edge is land, Route 20 west landing is shore/water
check(cin:isWalkableCell(19, 8), "Cinnabar (19,8) is walkable coast")
check(not cin:isWaterCell(19, 8), "Cinnabar (19,8) is not water")
check(not cin:inBounds(20, 8), "facing east from (19,8) is off-map")
check(r20:isWaterCell(0, 8), "ROUTE_20 (0,8) is water/shore")
check(Map.defIsWaterCell(r20.def, r20ts, 0, 8),
"defIsWaterCell agrees on ROUTE_20 (0,8)")
check(Map.defIsWalkableCell(cin.def, Data.tilesets[cin.def.tileset], 19, 8),
"defIsWalkableCell agrees on Cinnabar coast")
Game.data = Data
Game.input = Input; Input:init()
Game.renderer = Renderer; Renderer:init()
Game.stack = StateStack
StateStack:init()
Game.save = SaveData.newGame()
Game.save.party = { mkMon("SQUIRTLE", "SURF") }
Game.save.inventory = { SOULBADGE = true }
Game.overworld = OW
while Game.stack:top() do Game.stack:pop() end
Game.stack:push(OW, "CINNABAR_ISLAND", 19, 8, "right")
local ow = Game.stack:top()
eq(ow.map.id, "CINNABAR_ISLAND", "start on Cinnabar east coast")
eq(ow.player.cellX, 19, "coast x")
eq(ow.player.cellY, 8, "coast y")
eq(ow.player.facing, "right", "facing east toward Route 20")
check(ow:facingIsShoreOrWater(),
"facingIsShoreOrWater reads Route 20 shore across the seam")
eq(ow:useSurfFieldMove(), "ok",
"useSurfFieldMove ok facing connection water (#125)")
-- reverse: surfing on Route 20 west edge, facing Cinnabar land
while Game.stack:top() do Game.stack:pop() end
Game.stack:push(OW, "ROUTE_20", 0, 8, "left")
ow = Game.stack:top()
ow.player.surfing = true
eq(ow.map.id, "ROUTE_20", "on Route 20 west water")
check(ow.map:isWaterCell(0, 8), "standing cell is water")
check(ow:facingIsLandDismount(),
"facingIsLandDismount sees Cinnabar coast across the seam")
eq(ow:useSurfFieldMove(), "dismount",
"party-menu SURF dismounts onto Cinnabar east coast")
-- facing open water while surfing still refuses
ow.player.facing = "right"
eq(ow:useSurfFieldMove(), "no_place",
"surfing + facing open water: no place (unchanged)")
-- mount step crosses the seam onto Route 20 (surfing already armed)
while Game.stack:top() do Game.stack:pop() end
Game.stack:push(OW, "CINNABAR_ISLAND", 19, 8, "right")
ow = Game.stack:top()
ow.player.surfing = true
check(ow:stepForwardOrCrossEdge("right"),
"stepForwardOrCrossEdge crosses onto Route 20 while surfing")
eq(ow.map.id, "ROUTE_20", "map swapped to Route 20 after edge surf step")
-- crossConnection parks one cell before the seam and walks in (same as
-- a live edge press); the landing target is Route 20 (0,8)
eq(ow.player.targetX, 0, "step target is Route 20 west column")
eq(ow.player.targetY, 8, "same Y across offset-0 east connection")
check(ow.player.moving, "seam step is in progress")
-- in-map Pallet south shore still works (no false connection match)
while Game.stack:top() do Game.stack:pop() end
Game.stack:push(OW, "PALLET_TOWN", 4, 13, "down")
ow = Game.stack:top()
ow.player.surfing = false
eq(ow:useSurfFieldMove(), "ok", "in-map water mount still ok")
ow.player.facing = "up"
eq(ow:useSurfFieldMove(), "no_water", "in-map land still no_water")
S.finish()
+184
View File
@@ -0,0 +1,184 @@
-- Parity test for Route 5 Day Care (issue #118).
-- Self-contained: `luajit tests/parity_daycare.lua`; also dofile'd by
-- tests/run_tests.lua's parity_* aggregator.
--
-- Covers scripts/Daycare.asm retrieve flow ported in
-- data/scripts/story2.lua M.DAYCARE:
-- * name substitution in grown / got-back text
-- * fee = ¥100 + ¥100 per level gained
-- * declining retrieve must not collapse the fee to ¥100 on re-talk
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data")
if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end
local S = require("tests.harness").suite("parity daycare")
local check, eq = S.check, S.eq
local realTextBox = package.loaded["src.render.TextBox"]
local shownTexts = {}
local choiceAnswer = true
package.loaded["src.render.TextBox"] = {
new = function(game, text, onDone, opts)
table.insert(shownTexts, text)
if opts and opts.choice then
opts.choice(choiceAnswer)
elseif onDone then
onDone()
end
return { text = text }
end,
}
local SaveData = require("src.core.SaveData")
local Pokemon = require("src.pokemon.Pokemon")
local Growth = require("src.pokemon.Growth")
local story2 = require("data.scripts.story2")
check(story2.DAYCARE ~= nil, "DAYCARE registered")
check(story2.DAYCARE.talk.TEXT_DAYCARE_GENTLEMAN ~= nil,
"DAYCARE gentleman talk handler registered")
local talkGentleman = story2.DAYCARE.talk.TEXT_DAYCARE_GENTLEMAN
local function newGame()
local save = SaveData.newGame()
save.money = 10000
local game = { data = Data, save = save, stack = { push = function() end } }
return game
end
local function talk(game)
shownTexts = {}
local doneCalled = false
talkGentleman(game, {}, nil, function() doneCalled = true end)
return doneCalled
end
local function boardAtLevel(game, species, level, nickname)
local mon = Pokemon.new(Data, species, level)
if nickname then mon.nickname = nickname end
game.save.daycare = { mon = mon, steps = 0 }
return mon
end
-- Exp needed to gain `gained` levels from `level` (exclusive of current).
local function stepsForLevels(species, level, gained)
local def = Data.pokemon[species]
local target = Growth.expForLevel(def.growthRate, level + gained)
local current = Growth.expForLevel(def.growthRate, level)
return target - current
end
-- === 1) grown text names the mon; fee matches levels grown ===
do
local game = newGame()
local mon = boardAtLevel(game, "RATTATA", 5, "SCRAPPY")
local gained = 3
game.save.daycare.steps = stepsForLevels("RATTATA", 5, gained)
choiceAnswer = false -- decline retrieve
check(talk(game), "grown+decline talk completes")
local grown
for _, s in ipairs(shownTexts) do
if s:find("grown a lot", 1, true) then grown = s break end
end
check(grown ~= nil, "shows MonHasGrownText")
check(grown:find("SCRAPPY", 1, true), "grown text substitutes wNameBuffer")
check(grown:find(tostring(gained), 1, true), "grown text shows levels grown")
check(not grown:find("{RAM:", 1, true), "grown text has no leftover RAM tokens")
local owe
for _, s in ipairs(shownTexts) do
if s:find("owe me", 1, true) then owe = s break end
end
check(owe ~= nil, "shows OweMoneyText")
eq(owe:match("¥(%d+)"), tostring(100 + gained * 100),
"fee is ¥100 + ¥100 per level gained")
eq(game.save.daycare.mon.level, 5,
"decline leaves deposit level untouched (BoxLevel baseline)")
eq(game.save.daycare.steps, 0, "pending steps folded into exp on talk")
end
-- === 2) re-talk after decline keeps the correct fee (issue #118) ===
do
local game = newGame()
boardAtLevel(game, "RATTATA", 5, "SCRAPPY")
local gained = 3
game.save.daycare.steps = stepsForLevels("RATTATA", 5, gained)
choiceAnswer = false
talk(game)
choiceAnswer = false
check(talk(game), "second decline talk completes")
local owe
for _, s in ipairs(shownTexts) do
if s:find("owe me", 1, true) then owe = s break end
end
check(owe ~= nil, "re-talk still shows OweMoneyText")
eq(owe:match("¥(%d+)"), tostring(100 + gained * 100),
"re-talk after decline keeps fee (not ¥100)")
eq(game.save.daycare.mon.level, 5, "re-talk still leaves deposit level")
end
-- === 3) paid retrieve raises level, names the mon, clears daycare ===
do
local game = newGame()
boardAtLevel(game, "RATTATA", 5, "SCRAPPY")
local gained = 2
game.save.daycare.steps = stepsForLevels("RATTATA", 5, gained)
local fee = 100 + gained * 100
local moneyBefore = game.save.money
choiceAnswer = true
check(talk(game), "retrieve talk completes")
eq(game.save.daycare, nil, "daycare cleared after paid retrieve")
eq(#game.save.party, 1, "mon returned to party")
eq(game.save.party[1].nickname, "SCRAPPY", "retrieved nickname preserved")
eq(game.save.party[1].level, 5 + gained, "level applied only on retrieve")
eq(game.save.money, moneyBefore - fee, "money deducted by correct fee")
local got
for _, s in ipairs(shownTexts) do
if s:find("got", 1, true) and s:find("back", 1, true) then got = s break end
end
check(got ~= nil, "shows GotMonBackText")
check(got:find("SCRAPPY", 1, true), "got-back text substitutes wDayCareMonName")
check(not got:find("{RAM:", 1, true), "got-back text has no leftover RAM tokens")
end
-- === 4) no levels gained: NeedsMoreTime still names the mon; fee ¥100 ===
do
local game = newGame()
boardAtLevel(game, "RATTATA", 10, "PIP")
game.save.daycare.steps = 0
choiceAnswer = false
check(talk(game), "no-growth talk completes")
local needs
for _, s in ipairs(shownTexts) do
if s:find("Back already", 1, true) then needs = s break end
end
check(needs ~= nil, "shows MonNeedsMoreTimeText")
check(needs and needs:find("PIP", 1, true), "needs-more-time names the mon")
local owe
for _, s in ipairs(shownTexts) do
if s:find("owe me", 1, true) then owe = s break end
end
eq(owe and owe:match("¥(%d+)"), "100", "no growth still costs base ¥100")
end
-- === 5) depositLevel survives a corrupted mon.level from older buggy talks ===
do
local game = newGame()
local mon = boardAtLevel(game, "RATTATA", 5, "SCRAPPY")
local gained = 4
game.save.daycare.steps = stepsForLevels("RATTATA", 5, gained)
game.save.daycare.depositLevel = 5
mon.level = 5 + gained -- simulate pre-#118 raise-on-talk corruption
choiceAnswer = false
check(talk(game), "depositLevel baseline talk completes")
local owe
for _, s in ipairs(shownTexts) do
if s:find("owe me", 1, true) then owe = s break end
end
eq(owe and owe:match("¥(%d+)"), tostring(100 + gained * 100),
"depositLevel keeps fee correct even if mon.level was raised early")
eq(game.save.daycare.mon.level, 5, "decline restores mon.level to depositLevel")
end
package.loaded["src.render.TextBox"] = realTextBox
S.finish()
+157
View File
@@ -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)
-- 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 ===
S.finish()

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