Compare commits

..

1 Commits

Author SHA1 Message Date
bryanthaboi aad86fb351 pokecenter fix 2026-07-23 15:15:35 -04:00
227 changed files with 2581 additions and 26111 deletions
+1 -16
View File
@@ -214,21 +214,8 @@ jobs:
apk="$(find dist/android/debug -name '*.apk' | head -1)"
[ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/debug"; exit 1; }
cp "$apk" "$outdir/gen1recomp-${v}-android.apk"
# Platform-independent update payload, built alongside the desktop
# apps above (same game.love that gets fused into each of them).
love_file=".bazinga/work/game.love"
[ -f "$love_file" ] || { echo "::error::$love_file not found (expected from scripts/build.sh)"; exit 1; }
cp "$love_file" "$outdir/gen1recomp-${v}.love"
ls -lh "$outdir"
# Checksums for every staged release asset (sums file itself is
# written after this and named outside the gen1recomp-* glob, so it
# never lists itself).
(cd "$outdir" && shasum -a 256 gen1recomp-* > sha256sums.txt)
cat "$outdir/sha256sums.txt"
- name: Publish GitHub Release
env:
GH_TOKEN: ${{ github.token }}
@@ -279,9 +266,7 @@ jobs:
"dist/release/gen1recomp-${v}-macos.zip" \
"dist/release/gen1recomp-${v}-windows.zip" \
"dist/release/gen1recomp-${v}-linux.zip" \
"dist/release/gen1recomp-${v}-android.apk" \
"dist/release/gen1recomp-${v}.love" \
"dist/release/sha256sums.txt"
"dist/release/gen1recomp-${v}-android.apk"
echo "Published release $tag"
+17 -42
View File
@@ -1,19 +1,18 @@
# Pokemon Gen 1 Recompilation Project
A native LÖVE2D recreation of Pokemon Red and Blue. The engine and map
behavior are hand-written Lua; game data and graphics are decoded from a ROM
supplied by the player.
A native LÖVE2D recreation of Pokemon Red. The engine and map behavior are
hand-written Lua; game data and graphics are decoded from a ROM supplied by
the player.
SUPPORT AND ANNOUNCEMENTS: [Discord](https://bois.icu)
This project does not include a ROM, emulate the Game Boy, transpile assembly,
or download a disassembly. A canonical US Pokemon Red or Blue ROM is the only
game content input.
or download a disassembly. A canonical US Pokemon Red ROM is the only game
content input.
The ROM is verified, used during import, and then released from memory. It is
not copied into the cache. Later launches load the private generated cache and
do not ask for the ROM again. Red and Blue can both be imported and played
side by side.
do not ask for the ROM again.
## Quick Start
@@ -21,52 +20,24 @@ Open the desktop app. On first boot, choose your legally obtained `.gb` file
or drop it onto the window. Import takes a few seconds and the game starts
automatically.
Only the canonical 1 MiB US Red and Blue ROMs are accepted. The importer
verifies SHA-1 before creating any game data:
- Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a`
- Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2`
Only the canonical 1 MiB US Red ROM is accepted. The importer verifies SHA-1
`ea9bcae617fdf159b045185467ae58b2e4a48b9a` before creating any game data.
The packaged app contains neither a ROM nor pre-extracted game data. Music,
sound effects, and cries are synthesized while the game runs from compact
audio channel programs copied out of the verified ROM.
## Controls
| 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`.
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.
## Running From Source
Requires LÖVE 11.x. Place a Red or Blue ROM in the project folder and
double-click `Play-Mac.command` or `Play-Windows.bat`, or run:
Requires LÖVE 11.x. Place the ROM in the project folder and double-click
`Play-Mac.command` or `Play-Windows.bat`, or run:
```sh
scripts/setup.sh --rom "/path/to/Pokemon Red.gb" # or Pokemon Blue.gb
scripts/setup.sh --rom "/path/to/Pokemon Red.gb"
scripts/run.sh
```
@@ -136,3 +107,7 @@ request with real detail is one that can actually get built.
This project would not be possible without [pret](https://github.com/pret) >
the pret band of decompiling maniacs > and their
[pokered](https://github.com/pret/pokered) disassembly.
## Wanna Support My Work?
[![Buy Me a Coffee](https://img.shields.io/badge/Buy_Me_a_Coffee-FFDD00?style=for-the-badge&logo=buy-me-a-coffee&logoColor=black)](https://buymeacoffee.com/bryanthaboi)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 753 KiB

After

Width:  |  Height:  |  Size: 370 KiB

-9
View File
@@ -1,9 +0,0 @@
# On-screen touch control art
From Xelu's "Free Controller & Keyboard Prompts" pack (Nicolae Berbece /
Those Awesome Guys), public domain under CC0:
https://thoseawesomeguys.com/prompts/
256px Nintendo Switch set, renamed for src/core/TouchControls.lua:
d-pad (neutral + per-direction highlight), A, B, plus (START) and
minus (SELECT).
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 751 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

+2 -3
View File
@@ -47,9 +47,8 @@ function love.conf(t)
-- freely to portrait or landscape), otherwise locked to the window's w/h
-- aspect. So a non-resizable tall window forced portrait; resizable lets
-- the game follow the device. The renderer letterboxes the 160x144
-- viewport into whatever size results, and the on-screen touch controls
-- re-lay themselves out from the new window size, so both orientations
-- just work. iOS follows the Info.plist orientations
-- viewport into whatever size results, and touch input is gesture-based,
-- so both orientations just work. iOS follows the Info.plist orientations
-- (see mobile/ios/overlays/love-ios.plist, now portrait + landscape).
t.window.resizable = true
-- Starting size is a tall portrait hint; the OS resizes to the real
+3 -2
View File
@@ -1,8 +1,9 @@
-- Flavor talk scripts for CopycatsHouse2F (registry id COPYCATS_HOUSE_2F).
-- Source: pokered/scripts/CopycatsHouse2F.asm, pokered/text/CopycatsHouse2F.asm
--
-- TEXT_COPYCATSHOUSE2F_COPYCAT (POKE DOLL -> TM31 MIMIC) lives in
-- data/scripts/story4.lua.
-- TEXT_COPYCATSHOUSE2F_COPYCAT is already ported (with the POKE DOLL /
-- TM31 MIMIC trade) in data/scripts/story4.lua, so it is intentionally
-- omitted here.
return {
COPYCATS_HOUSE_2F = {
+18 -5
View File
@@ -1,11 +1,15 @@
-- Pewter City flavor dialogue (pokered/scripts/PewterCity.asm).
-- PewterCity_TextPointers text_asm bodies for the SUPER_NERD1 museum
-- guide and SUPER_NERD2 garden nerd.
-- guide, SUPER_NERD2 garden nerd, and the leaving-east YOUNGSTER.
--
-- The YOUNGSTER's gym escort (talk + east-exit onStep) lives in
-- story5.lua so the lockstep RLE walk is not overwritten by this
-- flavor merge. SUPER_NERD1's museum escort is not ported; only the
-- YES/NO-branched flavor text is here.
-- The escort choreography (SUPER_NERD1 walking the player to the
-- museum, YOUNGSTER walking the player to the gym) is scripted NPC
-- movement + a wPewterCityCurScript state machine that steers the
-- player off-map; the YOUNGSTER's escort to the gym is handled on this
-- map by story5.lua's onStep gate at the east exit (before
-- EVENT_BEAT_BROCK), while the SUPER_NERD1 museum escort is not ported.
-- Here we only port the real YES/NO-branched flavor text these NPCs
-- speak when talked to.
local M = {}
@@ -57,6 +61,15 @@ M.PEWTER_CITY = {
end)
end,
-- PewterCityYoungsterText (scripts/PewterCity.asm): the "follow
-- me" line the youngster says when the player is stopped from
-- leaving Pewter east before beating Brock; the actual gate/step
-- block is handled by story5.lua's onStep for this map.
TEXT_PEWTERCITY_YOUNGSTER = function(game, ow, npc, done)
local t = text(game)
push(game, t._PewterCityYoungsterYoureATrainerFollowMeText
or "You're a trainer\nright? BROCK's\nlooking for new\nchallengers!\nFollow me!", done)
end,
},
}
-22
View File
@@ -1,22 +0,0 @@
-- Red's House 2F (pokered data/events/hidden_events.asm +
-- engine/events/hidden_objects PrintRedSNESText).
--
-- The bedroom SNES is a hidden_event at (3, 5) with ANY_FACING that
-- prints _RedBedroomSNESText. field.py extracts OpenRedsPC on this map
-- but skips PrintRedSNESText, so interaction was a no-op (#135).
local TextBox = require("src.render.TextBox")
return {
REDS_HOUSE_2F = {
-- hidden_events.asm:
-- hidden_event 3, 5, PrintRedSNESText, ANY_FACING
onInteract = function(game, ow, fx, fy)
if fx ~= 3 or fy ~= 5 then return false end
local text = game.data.text._RedBedroomSNESText
or "{PLAYER} is\nplaying the SNES!"
game.stack:push(TextBox.new(game, text))
return true
end,
},
}
+12 -16
View File
@@ -5,23 +5,19 @@ return {
SILPH_CO_9F = {
talk = {
-- SilphCo9FNurseText (pokered/scripts/SilphCo9F.asm):
-- before EVENT_BEAT_SILPH_CO_GIOVANNI: heals the party, white fade
-- (Delay3 between out/in; no Music_PkmnHealed), then "Don't give
-- up!"; after the event, just says thanks. Nurse texts are not in
-- data/generated/text.lua, so the exact pokered/text/SilphCo9F.asm
-- strings are used as literals.
-- before EVENT_BEAT_SILPH_CO_GIOVANNI: heals the party and shows
-- "You look tired..." then "Don't give up!"; after the event, just
-- says thanks. Nurse texts are not in data/generated/text.lua, so
-- the exact pokered/text/SilphCo9F.asm strings are used as literals.
TEXT_SILPHCO9F_NURSE = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" }, -- 2
{ "jump_if_true", 11 }, -- 3
{ "show_text", "You look tired!\nYou should take a\nquick nap!" }, -- 4
{ "heal_party" }, -- 5
{ "fade", "out", "white" }, -- 6
{ "wait", 3 }, -- 7 Delay3
{ "fade", "in", "white" }, -- 8
{ "show_text", "Don't give up!" }, -- 9
{ "jump", "end" }, -- 10
{ "show_text", "Thank you so\nmuch!" }, -- 11
{ "face_player" },
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
{ "jump_if_true", 8 },
{ "show_text", "You look tired!\nYou should take a\nquick nap!" },
{ "heal_party" },
{ "show_text", "Don't give up!" },
{ "jump", 9 },
{ "show_text", "Thank you so\nmuch!" },
},
},
},
-1
View File
@@ -28,7 +28,6 @@ local files = {
"data.scripts.flavor.pokemon_fan_club",
"data.scripts.flavor.power_plant",
"data.scripts.flavor.reds_house_1f",
"data.scripts.flavor.reds_house_2f",
"data.scripts.flavor.route11_gate_2f",
"data.scripts.flavor.route18_gate_2f",
"data.scripts.flavor.route_12_gate_2f",
+47 -115
View File
@@ -24,23 +24,17 @@ local function starterBall(askText, species, choseFlag, ownBall,
{ "check_flag", "EVENT_FOLLOWED_OAK_INTO_LAB" }, -- 3
{ "jump_if_false", 20 }, -- 4
-- the Pokédex "new species" entry shows before the ask (predef
-- StarterDex ahead of OaksLabYouWant...Text). StarterDex temporarily
-- sets the owned bits so ShowPokedexData prints height/weight/text;
-- forceOwned is that bypass without mutating save.pokedex.owned.
{ "push_screen", "DexEntryMenu",
{ species = species, forceOwned = true } }, -- 5
-- StarterDex ahead of OaksLabYouWant...Text)
{ "push_screen", "DexEntryMenu", species }, -- 5
{ "ask", askText }, -- 6
{ "jump_if_false", 21 }, -- 7
-- OaksLab.asm prints ReceivedMon then AddPartyMon (AskName lives
-- inside give_pokemon). Show the received text first so the
-- nickname prompt follows "you got X", matching Gen1.
{ "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 8
{ "give_pokemon", species, 5 }, -- 9
{ "set_flag", "EVENT_GOT_STARTER" }, -- 10
{ "set_flag", choseFlag }, -- 11
{ "give_pokemon", species, 5 }, -- 8
{ "set_flag", "EVENT_GOT_STARTER" }, -- 9
{ "set_flag", choseFlag }, -- 10
-- POKé BALLs are not handed out here in the original -- Oak gives
-- them later, at OaksLabOak1Text's .give_poke_balls beat once the
-- player has beaten the Route 22 rival (see TEXT_OAKSLAB_OAK1 below)
{ "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 11
{ "hide_object", "OAKS_LAB", ownBall }, -- 12
-- the rival walks to the countering ball (around the furniture)
{ "move_npc_to", 1, rivalBallX, 4 }, -- 13
@@ -59,97 +53,48 @@ end
return {
talk = {
-- Oak: OaksLabOak1Text. Parcel delivery kicks SCRIPT_OAKSLAB_RIVAL_
-- ARRIVES_AT_OAKS_REQUEST + OaksLabOakGivesPokedexScript (rival walk-
-- in, full Pokédex speech, rival exit, Route 22 arm). Dex-rating
-- (DisplayDexRating) is still skipped.
-- Oak: accepts the parcel and hands over the Pokédex, then (once the
-- player has beaten the Route 22 rival) hands over the real POKé
-- BALLs (scripts/OaksLab.asm OaksLabOak1Text, simplified: no rival
-- recall / dex-rating / "pokemon can fight" / "around the world"
-- branches -- see docs/known-differences.md)
TEXT_OAKSLAB_OAK1 = {
{ "face_player" },
{ "check_item", "POKE_BALL" },
{ "jump_if_true", "come_see" },
{ "check_flag", "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE" },
{ "jump_if_true", "give_balls" },
{ "check_flag", "EVENT_GOT_POKEDEX" },
{ "jump_if_true", "around_world" },
{ "check_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" },
{ "jump_if_false", "pre_lab_battle" },
{ "check_item", "OAKS_PARCEL" },
{ "jump_if_false", "raise_young" },
-- OaksLabOak1Text.got_parcel → RivalArrives + OakGivesPokedex
{ "show_text", "_OaksLabOak1DeliverParcelText" },
{ "play_sound", "Get_Key_Item" },
{ "show_text", "_OaksLabOak1ParcelThanksText" },
{ "take_item", "OAKS_PARCEL", 1 },
{ "stop_music" },
{ "play_music", "Music_MeetRival" },
{ "show_text", "_OaksLabRivalGrampsText" },
{ "show_object", "OAKS_LAB", "OAKSLAB_RIVAL" },
-- OaksLabCalcRivalMovementScript: sprite map (8,11) → cell (4,7)
-- when the player stands below Oak (the usual desk talk).
{ "place_npc", 1, 4, 7, "up" },
{ "move_npc_to", 1, 4, 3 },
{ "play_music", "Music_OaksLab" },
{ "face_object", 1, "up" },
{ "face_object", 5, "down" },
{ "show_text", "_OaksLabRivalWhatDidYouCallMeForText" },
{ "face_object", 1, "up" },
{ "face_object", 5, "down" },
{ "show_text", "_OaksLabOakIHaveARequestText" },
{ "face_object", 1, "up" },
{ "face_object", 5, "down" },
{ "show_text", "_OaksLabOakMyInventionPokedexText" },
{ "show_text", "_OaksLabOakGotPokedexText" },
{ "play_sound", "Get_Key_Item" },
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX1" },
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX2" },
{ "face_object", 1, "up" },
{ "face_object", 5, "down" },
{ "show_text", "_OaksLabOakThatWasMyDreamText" },
{ "face_object", 1, "right" },
{ "show_text", "_OaksLabRivalLeaveItAllToMeText" },
{ "set_flag", "EVENT_GOT_POKEDEX" },
{ "set_flag", "EVENT_OAK_GOT_PARCEL" },
{ "hide_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY" },
{ "show_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN" },
{ "stop_music" },
{ "play_music", "Music_MeetRival" },
{ "move_npc_to", 1, 4, 7 },
{ "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" },
{ "play_music", "Music_OaksLab" },
{ "set_flag", "EVENT_1ST_ROUTE22_RIVAL_BATTLE" },
{ "clear_flag", "EVENT_2ND_ROUTE22_RIVAL_BATTLE" },
{ "set_flag", "EVENT_ROUTE22_RIVAL_WANTS_BATTLE" },
{ "show_object", "ROUTE_22", "ROUTE22_RIVAL1" },
{ "jump", "end" },
{ "label", "raise_young" },
{ "show_text", "_OaksLabOak1RaiseYourYoungPokemonText" },
{ "jump", "end" },
{ "label", "pre_lab_battle" },
{ "check_flag", "EVENT_GOT_STARTER" },
{ "jump_if_true", "can_fight" },
{ "show_text", "_OaksLabOak1WhichPokemonDoYouWantText" },
{ "jump", "end" },
{ "label", "can_fight" },
{ "show_text", "_OaksLabOak1YourPokemonCanFightText" },
{ "jump", "end" },
{ "label", "around_world" },
{ "show_text", "_OaksLabOak1PokemonAroundTheWorldText" },
{ "jump", "end" },
{ "label", "give_balls" },
{ "check_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" },
{ "jump_if_true", "come_see" },
{ "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" },
{ "give_item", "POKE_BALL", 5, false },
{ "show_text", "_OaksLabOak1ReceivedPokeballsText" },
{ "show_text", "_OaksLabGivePokeballsExplanationText" },
{ "jump", "end" },
{ "label", "come_see" },
{ "show_text", "_OaksLabOak1ComeSeeMeSometimesText" },
{ "face_player" }, -- 1
{ "check_flag", "EVENT_GOT_OAKS_PARCEL" }, -- 2
{ "jump_if_false", 14 }, -- 3
{ "check_flag", "EVENT_OAK_GOT_PARCEL" }, -- 4
{ "jump_if_true", 14 }, -- 5
{ "show_text", "_OaksLabOak1DeliverParcelText" }, -- 6
{ "take_item", "OAKS_PARCEL", 1 }, -- 7
{ "set_flag", "EVENT_OAK_GOT_PARCEL" }, -- 8
{ "show_text", "_OaksLabOak1PokemonAroundTheWorldText" }, -- 9
{ "set_flag", "EVENT_GOT_POKEDEX" }, -- 10
-- 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)
},
TEXT_OAKSLAB_CHARMANDER_POKE_BALL =
@@ -195,19 +140,6 @@ return {
},
},
-- Saves that got the Pokédex before #106 never wrote objectToggles for
-- the table sprites; re-entering the lab applies the same HideObject
-- the gift script now does (OaksLab.asm OakGivesPokedex).
onEnter = function(game, ow)
if not (game.save.flags and game.save.flags.EVENT_GOT_POKEDEX) then
return
end
local Commands = require("src.script.Commands")
local ctx = { save = game.save, game = game, overworld = ow }
Commands.hide_object(ctx, "OAKS_LAB", "OAKSLAB_POKEDEX1")
Commands.hide_object(ctx, "OAKS_LAB", "OAKSLAB_POKEDEX2")
end,
-- Oak stops you leaving without a starter; the rival stops you on
-- the way out for the first battle (scripts/OaksLab.asm
-- OaksLabScript8 / OaksLabRivalChallenge)
+7 -15
View File
@@ -1,23 +1,15 @@
-- Hand-ported from pret/pokered scripts/RedsHouse1F.asm.
-- Mom (RedsHouse1FMomText): pre-starter shows the wake-up / Oak tip;
-- after EVENT_GOT_STARTER, RedsHouse1FMomHealScript fades to white,
-- heals, plays MUSIC_PKMN_HEALED, fades back, then "looking great".
-- Mom (RedsHouse1FMomText, text_asm) heals the party and shows the
-- "you should rest" / "looking great" dialogue. The intro "wake up"
-- branch is tied to the unported intro cutscene, so the heal path is used.
return {
talk = {
TEXT_REDSHOUSE1F_MOM = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_GOT_STARTER" }, -- 2
{ "jump_if_true", 6 }, -- 3
{ "show_text", "_RedsHouse1FMomWakeUpText" }, -- 4
{ "jump", "end" }, -- 5
-- RedsHouse1FMomHealScript
{ "show_text", "_RedsHouse1FMomYouShouldRestText" }, -- 6
{ "fade", "out", "white" }, -- 7 GBFadeOutToWhite
{ "heal_party" }, -- 8
{ "play_once", "Music_PkmnHealed" }, -- 9 wait + restore map
{ "fade", "in", "white" }, -- 10 GBFadeInFromWhite
{ "show_text", "_RedsHouse1FMomLookingGreatText" }, -- 11
{ "face_player" },
{ "show_text", "_RedsHouse1FMomYouShouldRestText" },
{ "heal_party" },
{ "show_text", "_RedsHouse1FMomLookingGreatText" },
},
},
}
+82 -227
View File
@@ -222,55 +222,17 @@ M.BILLS_HOUSE = {
-- repair saves that already got the ticket under the old collapsed
-- script (the monster never hidden, human Bill never shown)
onEnter = function(game, ow)
local flags = game.save.flags
if flags.EVENT_GOT_SS_TICKET
and not flags.EVENT_USED_CELL_SEPARATOR_ON_BILL then
if game.save.flags.EVENT_GOT_SS_TICKET
and not game.save.flags.EVENT_USED_CELL_SEPARATOR_ON_BILL then
local Commands = require("src.script.Commands")
local ctx = { game = game, save = game.save, overworld = ow }
Commands.hide_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL_POKEMON")
Commands.show_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL1")
flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = true
flags.EVENT_USED_CELL_SEPARATOR_ON_BILL = true
flags.EVENT_MET_BILL = true
flags.EVENT_MET_BILL_2 = true
game.save.flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = true
game.save.flags.EVENT_USED_CELL_SEPARATOR_ON_BILL = true
game.save.flags.EVENT_MET_BILL = true
game.save.flags.EVENT_MET_BILL_2 = true
end
-- After Route25ToggleBillsScript, BILL2 is the visible human Bill
-- ("check out my rare POKéMON"). Re-apply on enter so a mid-house
-- load still matches the toggles if the pool was rebuilt.
if flags.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING then
local Commands = require("src.script.Commands")
local ctx = { game = game, save = game.save, overworld = ow }
Commands.hide_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL_POKEMON")
Commands.hide_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL1")
Commands.show_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL2")
end
end,
}
-- -------------------------------------------------------------------
-- Route 25 outside Bill's house (scripts/Route25.asm
-- Route25ToggleBillsScript): leaving after the SS Ticket arms the
-- Eevee PC list and swaps human Bill to his post-help dialogue NPC.
-- Leaving mid-quest (Bill still in the machine) puts the monster back.
-- -------------------------------------------------------------------
M.ROUTE_25 = {
onEnter = function(game, ow)
local flags = game.save.flags
if flags.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING then return end
local Commands = require("src.script.Commands")
local ctx = { game = game, save = game.save, overworld = ow }
if not flags.EVENT_MET_BILL_2 then
flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = nil
Commands.show_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL_POKEMON")
return
end
if not flags.EVENT_GOT_SS_TICKET then return end
flags.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING = true
-- TOGGLE_NUGGET_BRIDGE_GUY (ROUTE24_COOLTRAINER_M1)
Commands.hide_object(ctx, "ROUTE_24", "ROUTE24_COOLTRAINER_M1")
Commands.hide_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL1")
Commands.show_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL2")
end,
}
@@ -294,40 +256,25 @@ M.VERMILION_CITY = {
end,
-- VermilionCityDefaultScript's per-frame SSAnneTicketCheckCoords check:
-- the unguarded cell (18,30) just west of the sailor leads straight
-- onto the dock warp. Stepping onto it facing down always runs the
-- sailor dialog (DisplayTextID TEXT_VERMILIONCITY_SAILOR1); only a
-- ticket while the ship is still docked lets the player continue --
-- otherwise they are walked back up. The sailor himself never hides.
-- onto the dock warp, so stepping onto it heading for the dock gets
-- ticket-checked (and turned back once the ship has sailed) without
-- the player ever pressing A. The sailor himself never disappears.
onStep = function(game, ow, x, y)
if x ~= 18 or y ~= 30 then return false end
if ow.player.facing ~= "down" then return false end
local Flags = require("src.script.Flags")
local f = game.save.flags
local t = game.data.text
local TextBox = require("src.render.TextBox")
local shipLeft = Flags.get(game.save, "EVENT_SS_ANNE_LEFT")
local hasTicket = (game.save.inventory.S_S_TICKET or 0) > 0
if shipLeft then
if f.EVENT_SS_ANNE_LEFT then
game.stack:push(TextBox.new(game,
t._VermilionCitySailor1ShipSetSailText or "The ship set sail.",
function() ow:scriptMove(ow.player, "up", 1) end))
return true
end
-- Walk-past is never facing-right / inFrontOfOrBehindGuardCoords, so
-- VermilionCitySailor1Text always takes .greet_player_and_check_ticket:
-- DoYouHaveATicket, then FlashedTicket (allow through) or YouNeedATicket
-- (walk back). Returning true while the box is up also blocks the
-- dock warp on this step.
local ask = t._VermilionCitySailor1DoYouHaveATicketText
or "Welcome to S.S.\nANNE!\fExcuse me, do you\nhave a ticket?"
if hasTicket then
game.stack:push(TextBox.new(game,
ask .. "\f"
.. (t._VermilionCitySailor1FlashedTicketText
or "{PLAYER} flashed\nthe S.S.TICKET!")))
return true
end
if (game.save.inventory.S_S_TICKET or 0) > 0 then return false end
game.stack:push(TextBox.new(game,
ask .. "\f"
(t._VermilionCitySailor1WelcomeToSSAnneText or "Welcome to S.S.\nANNE!")
.. "\f"
.. (t._VermilionCitySailor1YouNeedATicketText
or "You need a ticket\nto get aboard."),
function() ow:scriptMove(ow.player, "up", 1) end))
@@ -341,7 +288,7 @@ M.VERMILION_CITY = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_SS_ANNE_LEFT" }, -- 2
{ "jump_if_true", 11 }, -- 3
{ "show_text", "_VermilionCitySailor1DoYouHaveATicketText" }, -- 4
{ "show_text", "_VermilionCitySailor1WelcomeToSSAnneText" }, -- 4
{ "check_item", "S_S_TICKET" }, -- 5
{ "jump_if_false", 9 }, -- 6
{ "show_text", "_VermilionCitySailor1FlashedTicketText" }, -- 7
@@ -371,21 +318,18 @@ M.SS_ANNE_2F = {
M.SS_ANNE_CAPTAINS_ROOM = {
talk = {
-- SSAnneCaptainsRoomCaptainText: after the rub line's text_asm tail,
-- pokered plays MUSIC_PKMN_HEALED (scripts/SSAnneCaptainsRoom.asm).
TEXT_SSANNECAPTAINSROOM_CAPTAIN = {
{ "check_flag", "EVENT_GOT_HM01" }, -- 1
{ "jump_if_true", 10 }, -- 2
{ "jump_if_true", 9 }, -- 2
{ "show_text", "_SSAnneCaptainsRoomRubCaptainsBackText" }, -- 3
{ "play_once", "Music_PkmnHealed" }, -- 4
{ "show_text", "_SSAnneCaptainsRoomCaptainIFeelMuchBetterText" }, -- 5
{ "show_text", "_SSAnneCaptainsRoomCaptainIFeelMuchBetterText" }, -- 4
-- give-then-print like scripts/SSAnneCaptainsRoom.asm (GiveItem
-- fills wStringBuffer; the received text reads it)
{ "give_item", "HM_CUT", 1, false }, -- 6
{ "show_text", "_SSAnneCaptainsRoomCaptainReceivedHM01Text" }, -- 7
{ "set_flag", "EVENT_GOT_HM01" }, -- 8
{ "jump", 11 }, -- 9
{ "show_text", "_SSAnneCaptainsRoomCaptainNotSickAnymoreText" }, -- 10
{ "give_item", "HM_CUT", 1, false }, -- 5
{ "show_text", "_SSAnneCaptainsRoomCaptainReceivedHM01Text" }, -- 6
{ "set_flag", "EVENT_GOT_HM01" }, -- 7
{ "jump", 10 }, -- 8
{ "show_text", "_SSAnneCaptainsRoomCaptainNotSickAnymoreText" }, -- 9
},
},
}
@@ -616,8 +560,7 @@ M.SILPH_CO_11F = {
-- -------------------------------------------------------------------
-- Victory Road boulder switches (scripts/VictoryRoad1F/2F/3F.asm):
-- a boulder resting on a switch removes a barrier block; the 3F hole
-- drops a boulder down to the 2F switch, and also dungeon-warps the
-- player (IsPlayerOnDungeonWarp + DungeonWarpList/Data -> 2F at 22,16).
-- drops a boulder down to the 2F switch.
-- -------------------------------------------------------------------
local function boulderAt(ow, x, y)
@@ -683,18 +626,6 @@ M.VICTORY_ROAD_3F = {
Commands.show_object(ctx, "VICTORY_ROAD_2F", "VICTORYROAD2F_BOULDER")
end
end,
-- scripts/VictoryRoad3F.asm VictoryRoad3FDefaultScript: the same hole
-- is a dungeon warp for the player (wDungeonWarpDestinationMap =
-- VICTORY_ROAD_2F, wWhichDungeonWarp = 2 -> DungeonWarpData 22,16).
-- Mirrors POKEMON_MANSION_3F.onStep; CAVERN $22 is walkable so the
-- fall is onStep, not a collision block.
onStep = function(game, ow, x, y)
if x == 23 and y == 15 then
ow:startWarpTo("VICTORY_ROAD_2F", 22, 16, ow.player.facing)
return true
end
return false
end,
}
-- -------------------------------------------------------------------
@@ -707,16 +638,6 @@ M.VICTORY_ROAD_3F = {
-- IndigoPlateauLobby.asm) so the champion is re-fightable on rematches,
-- like pokered's re-entry cutscene. EVENT_BEAT_CHAMPION_RIVAL stays set
-- forever (postgame gates like the Cerulean cave guard read it).
--
-- pokered forces the fight on map entry: Agatha's victory arms
-- SCRIPT_CHAMPIONSROOM_PLAYER_ENTERS (scripts/AgathasRoom.asm), and
-- ChampionsRoomPlayerEntersScript then runs RivalEntrance_RLEMovement
-- (up 1, right 1, up 3) before ChampionsRoomRivalReadyToBattleScript.
-- The rival object has no trainer header / sight range, so without that
-- entrance script the player can walk past (issue #99). We arm on the
-- run flag instead of Agatha's victory bit: same observable effect for
-- first clear and Indigo rematches, without a cross-map script pointer.
--
-- scripts/ChampionsRoom.asm ChampionsRoomRivalDefeatedScript ->
-- OakArrivesScript -> OakCongratulatesPlayerScript ->
-- OakDisappointedWithRivalScript -> OakComeWithMeScript -> OakExitsScript,
@@ -725,69 +646,43 @@ M.VICTORY_ROAD_3F = {
-- itself is NOT run here: it belongs to the HALL_OF_FAME room script
-- (scripts/HallOfFame.asm), so we set a one-shot marker and warp; the room
-- onEnter (M.HALL_OF_FAME below) drives the HoF Oak speech + record.
local championsRoomRivalScript = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 2
{ "jump_if_true", 25 }, -- 3 past end
{ "show_text", "_ChampionsRoomRivalIntroText" }, -- 4
{ "rival_battle", "OPP_RIVAL3", 1 }, -- 5
{ "jump_if_false", 25 }, -- 6 past end
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 7
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL" }, -- 8
-- ChampionsRoomRivalDefeatedScript re-displays TEXT_CHAMPIONSROOM_RIVAL,
-- whose text_asm takes the EVENT_BEAT_CHAMPION_RIVAL branch =
-- _ChampionsRoomRivalAfterBattleText (the in-battle _RivalDefeatedText
-- is the port's generic "<PLAYER> defeated BLUE!" engine line instead).
{ "show_text", "_ChampionsRoomRivalAfterBattleText" }, -- 9
-- ChampionsRoomOakArrivesScript: Music_Cities1AlternateTempo
-- (Cities1, kept into HALL_OF_FAME like BIT_NO_MAP_MUSIC after
-- defeating RIVAL3), then Oak's "{PLAYER}!" + reveal + walk in
{ "play_music", "Music_Cities1", { keep = true } }, -- 10
{ "show_text", "_ChampionsRoomOakText" }, -- 11
{ "show_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 12
{ "move_npc", 2, "up", 5 }, -- 13 OakEntranceAfterVictoryMovement
-- OakCongratulatesPlayerScript: rival faces left, Oak faces down
{ "face_object", 1, "left" }, -- 14
{ "face_object", 2, "down" }, -- 15
{ "show_text", "_ChampionsRoomOakCongratulatesPlayerText" }, -- 16
-- OakDisappointedWithRivalScript: Oak turns to the rival (right)
{ "face_object", 2, "right" }, -- 17
{ "show_text", "_ChampionsRoomOakDisappointedWithRivalText" }, -- 18
-- OakComeWithMeScript: Oak faces down again, then exits up
{ "face_object", 2, "down" }, -- 19
{ "show_text", "_ChampionsRoomOakComeWithMeText" }, -- 20
{ "move_npc", 2, "up", 2 }, -- 21 OakExitChampionsRoomMovement
{ "hide_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 22
-- hand the induction off to the HALL_OF_FAME room (consumed by its
-- onEnter), then warp up into it (destWarp 1 lands at (4,7) facing up)
{ "set_field", "pendingHallOfFame", true }, -- 23
{ "warp", "HALL_OF_FAME", 4, 7, "up" }, -- 24
}
M.CHAMPIONS_ROOM = {
onEnter = function(game, ow)
if game.save.flags.EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN then return end
-- Lance entrance warps land at y=7; HoF return warps land at y=0.
-- Only the south entry should run ChampionsRoomPlayerEntersScript.
if ow.player.cellY < 7 then return end
local rival
for _, npc in ipairs(ow.npcs) do
if npc.def and npc.def.name == "CHAMPIONSROOM_RIVAL" then
rival = npc
break
end
end
-- RivalEntrance_RLEMovement, then the battle/Oak script (queued
-- separately so talk-script jump indices stay 1-based as written).
ow:queueScript({
{ "move_player", "up", 1 },
{ "move_player", "right", 1 },
{ "move_player", "up", 3 },
})
ow:queueScript(championsRoomRivalScript, { npc = rival })
end,
talk = {
TEXT_CHAMPIONSROOM_RIVAL = championsRoomRivalScript,
TEXT_CHAMPIONSROOM_RIVAL = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 2
{ "jump_if_true", 24 }, -- 3
{ "show_text", "_ChampionsRoomRivalIntroText" }, -- 4
{ "rival_battle", "OPP_RIVAL3", 1 }, -- 5
{ "jump_if_false", 24 }, -- 6
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 7
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL" }, -- 8
-- ChampionsRoomRivalDefeatedScript re-displays TEXT_CHAMPIONSROOM_RIVAL,
-- whose text_asm takes the EVENT_BEAT_CHAMPION_RIVAL branch =
-- _ChampionsRoomRivalAfterBattleText (the in-battle _RivalDefeatedText
-- is the port's generic "<PLAYER> defeated BLUE!" engine line instead).
{ "show_text", "_ChampionsRoomRivalAfterBattleText" }, -- 9
-- ChampionsRoomOakArrivesScript: Oak's "{PLAYER}!" then reveal + walk in
{ "show_text", "_ChampionsRoomOakText" }, -- 10
{ "show_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 11
{ "move_npc", 2, "up", 5 }, -- 12 OakEntranceAfterVictoryMovement (3,7)->(3,2)
-- OakCongratulatesPlayerScript: rival faces left, Oak faces down
{ "face_object", 1, "left" }, -- 13
{ "face_object", 2, "down" }, -- 14
{ "show_text", "_ChampionsRoomOakCongratulatesPlayerText" }, -- 15
-- OakDisappointedWithRivalScript: Oak turns to the rival (right)
{ "face_object", 2, "right" }, -- 16
{ "show_text", "_ChampionsRoomOakDisappointedWithRivalText" }, -- 17
-- OakComeWithMeScript: Oak faces down again, then exits up
{ "face_object", 2, "down" }, -- 18
{ "show_text", "_ChampionsRoomOakComeWithMeText" }, -- 19
{ "move_npc", 2, "up", 2 }, -- 20 OakExitChampionsRoomMovement (3,2)->(3,0)
{ "hide_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 21
-- hand the induction off to the HALL_OF_FAME room (consumed by its
-- onEnter), then warp up into it (destWarp 1 lands at (4,7) facing up)
{ "set_field", "pendingHallOfFame", true }, -- 22
{ "warp", "HALL_OF_FAME", 4, 7, "up" }, -- 23
},
},
}
@@ -858,14 +753,10 @@ M.HALL_OF_FAME = {
-- that teleports back to the new-game bedroom spawn (special_warps.asm
-- NewGameWarp: REDS_HOUSE_2F, 3, 6, facing down). Fabricated
-- convenience -- there is no such prompt in the original ROM.
-- Heal + remember_outdoor so house LAST_MAP mats land in Pallet, not
-- Indigo Plateau (issue #103 escape path for already-stuck saves).
TEXT_HALLOFFAME_PC = {
{ "ask", "Return to\nPALLET TOWN?" }, -- 1 YES/NO -> lastCheck
{ "jump_if_false", "end" }, -- 2 NO: back away
{ "heal_party" }, -- 3
{ "remember_outdoor", "PALLET_TOWN", 5, 6 }, -- 4 LAST_MAP -> Pallet door
{ "warp", "REDS_HOUSE_2F", 3, 6, "down" }, -- 5 YES: home to your room
{ "warp", "REDS_HOUSE_2F", 3, 6, "down" }, -- 3 YES: home to your room
},
},
}
@@ -878,72 +769,35 @@ M.HALL_OF_FAME = {
M.CERULEAN_CITY = {
talk = {
-- CeruleanCityRivalText: Bill line once beaten; pre-battle otherwise.
-- Post-fight walk matches CeruleanCityMovement4 (right then into town).
TEXT_CERULEANCITY_RIVAL = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 2
{ "jump_if_true", 13 }, -- 3
{ "show_text", "_CeruleanCityRivalPreBattleText" }, -- 4
{ "rival_battle", "OPP_RIVAL1", 7 }, -- 5
{ "jump_if_false", "end" }, -- 6
{ "set_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 7
{ "show_text", "_CeruleanCityRivalDefeatedText" }, -- 8
{ "show_text", "_CeruleanCityRivalIWentToBillsText" }, -- 9
{ "walk_npc", 1, { "right", "down", "down", "down",
"down", "down", "down" } }, -- 10
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_RIVAL" }, -- 11
{ "jump", "end" }, -- 12
{ "show_text", "_CeruleanCityRivalIWentToBillsText" }, -- 13
{ "face_player" }, -- 1
{ "check_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 2
{ "jump_if_true", 9 }, -- 3
{ "show_text", "_CeruleanCityRivalPreBattleText" }, -- 4
{ "rival_battle", "OPP_RIVAL1", 7 }, -- 5
{ "jump_if_false", 10 }, -- 6
{ "set_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 7
{ "show_text", "_CeruleanCityRivalDefeatedText" }, -- 8
{ "jump", 10 }, -- 9
},
},
}
-- PokemonTower2F.asm: after the battle, PokemonTower2FDefeatedRivalScript
-- walks him out (RightThenDown vs DownThenRight from EVENT_POKEMON_TOWER_
-- RIVAL_ON_LEFT) then HideObject TOGGLE_POKEMON_TOWER_2F_RIVAL. Player
-- at (15,5) is the ON_LEFT case.
local TOWER_RIVAL_EXIT_RIGHT_THEN_DOWN =
{ "right", "down", "down", "right", "down", "down", "right", "right" }
local TOWER_RIVAL_EXIT_DOWN_THEN_RIGHT =
{ "down", "down", "right", "right", "right", "right", "down", "down" }
local function pokemonTower2FRivalScript(playerX)
local exitDirs = (playerX == 15)
and TOWER_RIVAL_EXIT_DOWN_THEN_RIGHT
or TOWER_RIVAL_EXIT_RIGHT_THEN_DOWN
return {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 2
{ "jump_if_true", 12 }, -- 3
{ "show_text", "_PokemonTower2FRivalWhatBringsYouHereText" }, -- 4
{ "rival_battle", "OPP_RIVAL2", 4 }, -- 5
{ "jump_if_false", "end" }, -- 6 loss: stay
{ "set_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 7
{ "show_text", "_PokemonTower2FRivalDefeatedText" }, -- 8
{ "walk_npc", 1, exitDirs }, -- 9
{ "hide_object", "POKEMON_TOWER_2F", "POKEMONTOWER2F_RIVAL" }, -- 10
{ "jump", "end" }, -- 11
{ "show_text", "_PokemonTower2FRivalHowsYourDexText" }, -- 12
}
end
M.POKEMON_TOWER_2F = {
rivalScript = pokemonTower2FRivalScript,
talk = {
TEXT_POKEMONTOWER2F_RIVAL = function(game, ow, npc, done)
ow.runner:run(pokemonTower2FRivalScript(ow.player.cellX),
{ npc = npc, onDone = done })
end,
TEXT_POKEMONTOWER2F_RIVAL = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 2
{ "jump_if_true", 10 }, -- 3
{ "show_text", "_PokemonTower2FRivalWhatBringsYouHereText" }, -- 4
{ "rival_battle", "OPP_RIVAL2", 4 }, -- 5
{ "jump_if_false", 11 }, -- 6
{ "set_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 7
{ "show_text", "_PokemonTower2FRivalDefeatedText" }, -- 8
{ "jump", 11 }, -- 9
{ "show_text", "_PokemonTower2FRivalHowsYourDexText" }, -- 10
},
},
-- Saves that beat him before the exit walk was ported still have the
-- flag but a visible rival; hide on enter like BillsHouse repairs.
onEnter = function(game, ow)
if not game.save.flags.EVENT_BEAT_POKEMON_TOWER_RIVAL then return end
local Commands = require("src.script.Commands")
Commands.hide_object({ game = game, save = game.save, overworld = ow },
"POKEMON_TOWER_2F", "POKEMONTOWER2F_RIVAL")
end,
-- PokemonTower2FDefaultScript: walking past the rival's tile forces
-- the encounter (ArePlayerCoordsInArray on (15,5)/(14,6)) -- he never
-- waits to be talked to
@@ -955,7 +809,8 @@ M.POKEMON_TOWER_2F = {
if not rival then return false end
ow.player.facing = (x == 15) and "left" or "up"
require("src.core.Music").play(game.data, "Music_MeetRival")
ow.runner:run(pokemonTower2FRivalScript(x), { npc = rival })
ow.runner:run(M.POKEMON_TOWER_2F.talk.TEXT_POKEMONTOWER2F_RIVAL,
{ npc = rival })
return true
end,
}
+64 -201
View File
@@ -445,67 +445,33 @@ local function engageSuperNerd(game, ow, onDone)
ow:engageTrainer(nerd, onDone)
end
-- MtMoonB2FMoveSuperNerdScript: player-near-dome coords walk RIGHT then
-- UP (MoveRight falls through into MoveUp); near-helix walks UP only.
local function mtMoonNerdWalk(px, py, itemId)
if (px == 12 and py == 7) or (px == 11 and py == 6) or (px == 12 and py == 5) then
return { "right", "up" }
end
if (px == 13 and py == 7) or (px == 14 and py == 6) or (px == 14 and py == 5) then
return { "up" }
end
return (itemId == "DOME_FOSSIL") and { "right", "up" } or { "up" }
end
-- scripts/MtMoonB2F.asm Dome/Helix fossil text_asm + MoveSuperNerd +
-- SuperNerdTakesOtherFossil: pick one, hide it, walk the nerd to the
-- other, "All right. Then this is mine!", hide the other.
local function mtMoonFossil(itemId, otherName, gotFlag)
local function mtMoonFossil(itemId, otherName)
return function(game, ow, npc, done)
local TextBox = require("src.render.TextBox")
local t = game.data.text
local flags = game.save.flags
if flags.EVENT_GOT_DOME_FOSSIL or flags.EVENT_GOT_HELIX_FOSSIL then
done()
local ChoiceBox = require("src.ui.ChoiceBox")
if game.save.flags.EVENT_GOT_A_FOSSIL then
game.stack:push(TextBox.new(game, "You already took\na fossil.", done))
return
end
-- can't grab a fossil until the Super Nerd is beaten -- he intercepts
if not superNerdBeaten(ow) then
engageSuperNerd(game, ow, done)
return
end
local want = (itemId == "DOME_FOSSIL")
and (t._MtMoonB2FDomeFossilYouWantText or "You want the\nDOME FOSSIL?")
or (t._MtMoonB2FHelixFossilYouWantText or "You want the\nHELIX FOSSIL?")
game.stack:push(TextBox.new(game, want, nil, { choice = function(yes)
if not yes then done() return end
if not require("src.inventory.Bag").add(game.save, itemId, 1) then
local name = game.data.items[itemId].name
game.stack:push(TextBox.new(game, ("You want the\n%s?"):format(name), function()
game.stack:push(ChoiceBox.new(game, function(yes)
if not yes then done() return end
game.save.inventory[itemId] = 1
game.save.flags.EVENT_GOT_A_FOSSIL = true
local Commands = require("src.script.Commands")
local ctx = { save = game.save, overworld = ow, game = game }
Commands.hide_object(ctx, "MT_MOON_B2F", npc.def.name)
Commands.hide_object(ctx, "MT_MOON_B2F", otherName)
game.stack:push(TextBox.new(game,
t._MtMoonB2FYouHaveNoRoomText or "Look, you've got\nno room for this.",
done))
return
end
local idef = game.data.items[itemId]
game.stringBuffer = idef and idef.name or itemId
require("src.core.Sound").play(game.data, "Get_Key_Item")
local dirs = mtMoonNerdWalk(ow.player.cellX, ow.player.cellY, itemId)
game.stack:push(TextBox.new(game,
t._MtMoonB2FReceivedFossilText
or ("{PLAYER} got the\n" .. game.stringBuffer .. "!"),
function()
local Commands = require("src.script.Commands")
Commands.hide_object(
{ save = game.save, overworld = ow, game = game },
"MT_MOON_B2F", npc.def.name)
flags[gotFlag] = true
ow.runner:run({
{ "walk_npc", 1, dirs },
{ "text_opts", { auto = true } },
{ "show_text", "_MtMoonB2FSuperNerdThenThisIsMineText" },
{ "play_sound", "Get_Key_Item" },
{ "hide_object", "MT_MOON_B2F", otherName },
}, { onDone = done })
end))
end }))
("%s got the\n%s!"):format(game.save.player.name, name), done))
end))
end))
end
end
@@ -520,10 +486,8 @@ M.MT_MOON_B2F = {
return false
end,
talk = {
TEXT_MTMOONB2F_DOME_FOSSIL = mtMoonFossil(
"DOME_FOSSIL", "MTMOONB2F_HELIX_FOSSIL", "EVENT_GOT_DOME_FOSSIL"),
TEXT_MTMOONB2F_HELIX_FOSSIL = mtMoonFossil(
"HELIX_FOSSIL", "MTMOONB2F_DOME_FOSSIL", "EVENT_GOT_HELIX_FOSSIL"),
TEXT_MTMOONB2F_DOME_FOSSIL = mtMoonFossil("DOME_FOSSIL", "MTMOONB2F_HELIX_FOSSIL"),
TEXT_MTMOONB2F_HELIX_FOSSIL = mtMoonFossil("HELIX_FOSSIL", "MTMOONB2F_DOME_FOSSIL"),
},
}
@@ -755,174 +719,73 @@ M.CINNABAR_LAB_FOSSIL_ROOM = {
-- -------------------------------------------------------------------
-- Day-care (scripts/Daycare.asm): the boarded Pokémon earns 1 exp per
-- step; the fee is ¥100 plus ¥100 per level gained.
--
-- #118: do not raise mon.level until a paid retrieve (pokered reverts
-- wDayCareMonBoxLevel on .leaveMonInDayCare). Fold pending steps into
-- mon.exp once and clear them so a second talk cannot re-apply the same
-- walk. Fill {RAM:wNameBuffer}/{RAM:wDayCareMonName}/{NUM:...} here —
-- TextBox.TOKENS.RAM only knows wStringBuffer.
-- -------------------------------------------------------------------
local function fillDaycareText(s, subs)
s = s:gsub("{PLAYER}", subs.player or "")
s = s:gsub("{RAM:([^}]*)}", function(name) return subs[name] or "" end)
-- extractor NUM spans may include flags after a comma; keep the name
s = s:gsub("{NUM:([%w_]+)[^}]*}", function(name)
return tostring(subs[name] or "0")
end)
return s
end
M.DAYCARE = {
talk = {
TEXT_DAYCARE_GENTLEMAN = function(game, ow, npc, done)
local TextBox = require("src.render.TextBox")
local ChoiceBox = require("src.ui.ChoiceBox")
local t = game.data.text
local dc = game.save.daycare
local playerName = game.save.player and game.save.player.name or "RED"
local function monName(mon)
local def = game.data.pokemon[mon.species]
return mon.nickname or (def and def.name) or mon.species
end
if dc and dc.mon then
local Growth = require("src.pokemon.Growth")
local Stats = require("src.pokemon.Stats")
local Party = require("src.pokemon.Party")
local mon = dc.mon
local def = game.data.pokemon[mon.species]
-- Apply deferred step-exp once (OverworldController only bumps
-- daycare.steps). Clearing prevents double-count on re-talk.
mon.exp = (mon.exp or 0) + (dc.steps or 0)
dc.steps = 0
-- depositLevel mirrors wDayCareMonBoxLevel: fee baseline that
-- must survive a declined retrieve. Fall back to mon.level for
-- older saves that predate the field.
if dc.depositLevel == nil then dc.depositLevel = mon.level end
local startLevel = dc.depositLevel
local newLevel = Growth.levelForExp(def and def.growthRate, mon.exp)
if newLevel >= 100 then
newLevel = 100
if def then
mon.exp = Growth.expForLevel(def.growthRate, 100)
end
end
local levelsGrown = math.max(0, newLevel - startLevel)
local fee = 100 + levelsGrown * 100
local name = monName(mon)
local subs = {
player = playerName,
wNameBuffer = name,
wDayCareMonName = name,
wDayCareNumLevelsGrown = levelsGrown,
wDayCareTotalCost = fee,
}
local statusText = levelsGrown > 0
and (t._DaycareGentlemanMonHasGrownText
or "Your {RAM:wNameBuffer}\nhas grown a lot!\fBy level, it's\ngrown by {NUM:wDayCareNumLevelsGrown, 1, 3}!\fAren't I great?")
or (t._DaycareGentlemanMonNeedsMoreTimeText
or "Back already?\nYour {RAM:wNameBuffer}\nneeds some more\ntime with me.")
game.stack:push(TextBox.new(game, fillDaycareText(statusText, subs), function()
if #game.save.party >= Party.MAX then
game.stack:push(TextBox.new(game,
t._DaycareGentlemanNoRoomForMonText
or "You have no room\nfor this POKéMON!", done))
return
end
game.stack:push(TextBox.new(game,
fillDaycareText(
t._DaycareGentlemanOweMoneyText
or "You owe me ¥{NUM:wDayCareTotalCost, 2 | LEADING_ZEROES | LEFT_ALIGN}\nfor the return\nof this POKéMON.",
subs),
nil, { choice = function(yes)
if not yes then
-- .leaveMonInDayCare: revert any transient level bump
mon.level = startLevel
game.stack:push(TextBox.new(game,
(t._DaycareGentlemanAllRightThenText or "All right then,\n")
.. (t._DaycareGentlemanComeAgainText or "come again."),
done))
return
end
if (game.save.money or 0) < fee then
mon.level = startLevel
game.stack:push(TextBox.new(game,
t._DaycareGentlemanNotEnoughMoneyText
or "Hey, you don't\nhave enough ¥!", done))
return
end
mon.exp = mon.exp + (dc.steps or 0)
local newLevel = math.min(100, Growth.levelForExp(def.growthRate, mon.exp))
local fee = 100 + (newLevel - mon.level) * 100
local grew = newLevel > mon.level
mon.level = newLevel
mon.stats = Stats.calc(def, mon.level, mon.dvs, mon.statExp)
mon.hp = mon.stats.hp
local msg = grew and (t._DaycareGentlemanMonHasGrownText or "It's grown a lot!")
or "Back already?"
game.stack:push(TextBox.new(game,
msg .. ("\fThe fee is ¥%d.\nGet it back?"):format(fee), function()
game.stack:push(ChoiceBox.new(game, function(yes)
if yes and game.save.money >= fee then
game.save.money = game.save.money - fee
mon.level = newLevel
if def then
mon.stats = Stats.calc(def, mon.level, mon.dvs, mon.statExp)
mon.hp = mon.stats.hp
-- Daycare.asm: WriteMonMoves with wLearningMovesFromDayCare
local Pokemon = require("src.pokemon.Pokemon")
Pokemon.learnMovesFromDayCare(
game.data, mon, def, startLevel, newLevel)
end
table.insert(game.save.party, mon)
game.save.daycare = nil
game.stack:push(TextBox.new(game,
t._DaycareGentlemanHeresYourMonText
or "Thank you! Here's\nyour POKéMON!", function()
game.stack:push(TextBox.new(game,
fillDaycareText(
t._DaycareGentlemanGotMonBackText
or "{PLAYER} got\n{RAM:wDayCareMonName} back!",
subs), done))
end))
end }))
t._DaycareGentlemanGotMonBackText or "Here you go!", done))
else
game.stack:push(TextBox.new(game,
yes and (t._DaycareGentlemanOweMoneyText or "You owe me money!")
or "Come again!", done))
end
end))
end))
return
end
if #game.save.party < 2 then
game.stack:push(TextBox.new(game,
"You only have one\nPOKéMON with you!", done))
return
end
game.stack:push(TextBox.new(game,
t._DaycareGentlemanIntroText
or "I run a DAYCARE.\nWould you like me\nto raise one of\nyour POKéMON?",
nil, { choice = function(yes)
if not yes then
game.stack:push(TextBox.new(game,
t._DaycareGentlemanComeAgainText or "come again.", done))
return
end
if #game.save.party < 2 then
game.stack:push(TextBox.new(game,
t._DaycareGentlemanOnlyHaveOneMonText
or "You only have one\nPOKéMON with you.", done))
return
end
game.stack:push(TextBox.new(game,
t._DaycareGentlemanWhichMonText or "Which POKéMON\nshould I raise?",
function()
local PartyMenu = require("src.ui.PartyMenu")
game.stack:push(PartyMenu.new(game, {
pickOnly = true,
onSwitch = function(mon)
for i, m in ipairs(game.save.party) do
if m == mon then table.remove(game.save.party, i) break end
end
local name = monName(mon)
-- depositLevel = wDayCareMonBoxLevel at deposit time
game.save.daycare = {
mon = mon, steps = 0, depositLevel = mon.level,
}
game.stack:push(TextBox.new(game,
fillDaycareText(
t._DaycareGentlemanWillLookAfterMonText
or "Fine, I'll look\nafter {RAM:wNameBuffer}\nfor a while.",
{ player = playerName, wNameBuffer = name }),
function()
game.stack:push(TextBox.new(game,
t._DaycareGentlemanComeSeeMeInAWhileText
or "Come see me in\na while.", done))
end))
end,
}))
end))
end }))
t._DaycareGentlemanIntroText or "I can raise a\nPOKéMON for you.", function()
game.stack:push(ChoiceBox.new(game, function(yes)
if not yes then done() return end
local PartyMenu = require("src.ui.PartyMenu")
game.stack:push(PartyMenu.new(game, {
pickOnly = true,
onSwitch = function(mon)
for i, m in ipairs(game.save.party) do
if m == mon then table.remove(game.save.party, i) break end
end
game.save.daycare = { mon = mon, steps = 0 }
game.stack:push(TextBox.new(game,
t._DaycareGentlemanWillLookAfterMonText or
"Fine, I'll look\nafter it a while!", done))
end,
}))
end))
end))
end,
},
}
+48 -240
View File
@@ -61,50 +61,6 @@ 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.
@@ -194,20 +150,18 @@ local WALK_DIRVEC = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right =
local WALK_OPP = { up = "down", down = "up", left = "right", right = "left" }
local WALK_ORDER = { "up", "down", "left", "right" }
-- .UpdateWarp: point EVERY car exit warp at the same floor's elevator
-- door (warp id, map id). Shared generated map data, but the car's
-- warps are only read from inside the car; rides rewrite them again.
local function elevatorSetExit(ow, floor)
if not floor then return end
for _, w in ipairs(ow.map.def.warps) do
local function elevatorWalkOut(ow, floor)
local m, p = ow.map, ow.player
-- .UpdateWarp is run twice, so BOTH car warp entries get the same
-- (warp id, map id): point every exit warp at the picked floor's
-- elevator-door warp (the reciprocal warp found while building the
-- menu). The car map's def is shared generated data, but its own
-- warps are only ever read from inside the car, and this rewrite runs
-- on every ride before the walk-out fires, so it is self-correcting.
for _, w in ipairs(m.def.warps) do
w.destMap = floor.map
w.destWarp = floor.warpIdx
end
end
local function elevatorWalkOut(ow, floor)
local m, p = ow.map, ow.player
elevatorSetExit(ow, floor)
-- leave by the exit tile under the player (they warped in onto one),
-- else the nearest
local door
@@ -245,68 +199,37 @@ local function elevatorWalkOut(ow, floor)
end)
end
local function elevatorFloors(elevatorMapId, game)
local floors = {}
for mapId, def in pairs(game.data.maps) do
for i, w in ipairs(def.warps) do
if w.destMap == elevatorMapId then
-- short floor token pokered actually prints, e.g.
-- SILPH_CO_10F -> "10F", ROCKET_HIDEOUT_B2F -> "B2F"
local token = mapId:match("_([^_]+)$") or mapId
-- warpIdx: this floor's warp back into the elevator IS the
-- warp the car's rewritten exit lands on (the reciprocal
-- pair), matching wElevatorWarpMaps' (warp id, map id)
table.insert(floors,
{ map = mapId, x = w.x, y = w.y, token = token, warpIdx = i })
break
end
end
end
-- numeric floor order (SilphCoElevatorFloors' FLOOR_1F..FLOOR_11F),
-- not lexicographic -- otherwise 10F/11F sort before 2F..9F
table.sort(floors, function(a, b)
return (tonumber(a.token:match("%d+")) or 0) <
(tonumber(b.token:match("%d+")) or 0)
end)
return floors
end
local function elevatorSeedExit(ow, floors, fromMapId)
-- Seed a walk-out destination before the menu (or key-gate text):
-- entry floor when known, else the first listed floor (1F). Choosing
-- a floor still rewrites via elevatorWalkOut; B-cancel / no-key leave
-- keeps this seed so walking out of the car cannot hit a missing ROM
-- placeholder (#123) or the car's static default floor (#90: Rocket
-- Hideout defaults to B1F even when entered from B2F/B4F).
local exitFloor = floors[1]
if fromMapId then
for _, f in ipairs(floors) do
if f.map == fromMapId then exitFloor = f break end
end
end
elevatorSetExit(ow, exitFloor)
return exitFloor
end
local function elevator(elevatorMapId, keyGate, preFrames)
return {
-- fromMapId: the floor the player just left (setMap passes it), so a
-- B-cancel can still walk out onto a real map. Silph's ROM car warps
-- default to UNUSED_MAP_ED, which is not in Data.maps -- Warp.resolve
-- asserted and hard-crashed (#123).
onEnter = function(game, ow, fromMapId)
local floors = elevatorFloors(elevatorMapId, game)
elevatorSeedExit(ow, floors, fromMapId)
-- Rocket Hideout: without LIFT_KEY the panel only prints the need-
-- a-key line (scripts/RocketHideoutElevator.asm). Exit warps are
-- still seeded above so walking out returns to the entry floor
-- instead of the car's ROM default (B1F) — #90 / #105.
onEnter = function(game, ow)
if keyGate and not game.save.inventory[keyGate.item] then
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game,
game.data.text[keyGate.text] or "It appears to\nneed a key."))
return
end
local floors = {}
for mapId, def in pairs(game.data.maps) do
for i, w in ipairs(def.warps) do
if w.destMap == elevatorMapId then
-- short floor token pokered actually prints, e.g.
-- SILPH_CO_10F -> "10F", ROCKET_HIDEOUT_B2F -> "B2F"
local token = mapId:match("_([^_]+)$") or mapId
-- warpIdx: this floor's warp back into the elevator IS the
-- warp the car's rewritten exit lands on (the reciprocal
-- pair), matching wElevatorWarpMaps' (warp id, map id)
table.insert(floors,
{ map = mapId, x = w.x, y = w.y, token = token, warpIdx = i })
break
end
end
end
-- numeric floor order (SilphCoElevatorFloors' FLOOR_1F..FLOOR_11F),
-- not lexicographic -- otherwise 10F/11F sort before 2F..9F
table.sort(floors, function(a, b)
return (tonumber(a.token:match("%d+")) or 0) <
(tonumber(b.token:match("%d+")) or 0)
end)
local items = {}
for _, f in ipairs(floors) do
table.insert(items, { label = f.token, value = f })
@@ -339,8 +262,7 @@ local function elevator(elevatorMapId, keyGate, preFrames)
end,
onCancel = function()
-- DisplayElevatorFloorMenu: `ret c` on B -- no warp, nothing
-- happens, the player just stays in the car (exit warps were
-- already seeded to the entry floor above)
-- happens, the player just stays in the car
end,
}))
end,
@@ -352,98 +274,6 @@ M.CELADON_MART_ELEVATOR = elevator("CELADON_MART_ELEVATOR", nil, 9)
M.ROCKET_HIDEOUT_ELEVATOR = elevator("ROCKET_HIDEOUT_ELEVATOR",
{ item = "LIFT_KEY", text = "_RocketHideoutElevatorAppearsToNeedKeyText" })
-- -------------------------------------------------------------------
-- Rocket Hideout B4F (scripts/RocketHideoutB4F.asm):
-- Rocket3's after-battle text_asm drops the LIFT KEY item ball
-- (CheckAndSetEvent EVENT_ROCKET_DROPPED_LIFT_KEY / ShowObject
-- TOGGLE_ROCKET_HIDEOUT_B4F_ITEM_5). Both start hidden in the map
-- objects; without this talk side-effect the key never appears (#90,
-- #105).
-- Giovanni's post-battle script likewise ShowObject's the Silph Scope
-- after the hope-we-meet-again line (TOGGLE_ROCKET_HIDEOUT_B4F_ITEM_4).
-- -------------------------------------------------------------------
M.ROCKET_HIDEOUT_B4F = {
talk = {
TEXT_ROCKETHIDEOUTB4F_ROCKET3 = function(game, ow, npc, done)
if not ow:trainerDefeated(npc) then
ow:engageTrainer(npc, done)
return
end
local TextBox = require("src.render.TextBox")
local t = game.data.text
game.stack:push(TextBox.new(game,
t._RocketHideoutB4FRocket3AfterBattleText
or "Oh no! I dropped\nthe LIFT KEY!",
function()
-- CheckAndSetEvent EVENT_ROCKET_DROPPED_LIFT_KEY: first talk
-- after the win reveals the ball; later talks only reprint.
if not game.save.flags.EVENT_ROCKET_DROPPED_LIFT_KEY then
game.save.flags.EVENT_ROCKET_DROPPED_LIFT_KEY = true
local Commands = require("src.script.Commands")
Commands.show_object(
{ game = game, save = game.save, overworld = ow },
"ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_LIFT_KEY")
end
done()
end))
end,
TEXT_ROCKETHIDEOUTB4F_GIOVANNI = function(game, ow, npc, done)
-- Giovanni has no trainer-header row (def_trainers 2); his text_asm
-- owns both the engage and the BeatGiovanniScript aftermath.
if ow:trainerDefeated(npc)
or game.save.flags.EVENT_BEAT_ROCKET_HIDEOUT_GIOVANNI then
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game,
game.data.text._RocketHideoutB4FGiovanniHopeWeMeetAgainText
or "I hope we meet\nagain...", done))
return
end
local TextBox = require("src.render.TextBox")
local BattleState = require("src.battle.BattleState")
local t = game.data.text
local impressed = t._RocketHideoutB4FGiovanniImpressedYouGotHereText
or "So! I must say, I\nam impressed you\ngot here!"
local cannotBe = t._RocketHideoutB4FGiovanniWhatCannotBeText
or "WHAT!\nThis cannot be!"
local hope = t._RocketHideoutB4FGiovanniHopeWeMeetAgainText
or "I hope we meet\nagain..."
game.stack:push(TextBox.new(game, impressed, function()
local battle = BattleState.newTrainer(game, "OPP_GIOVANNI", 1)
battle.onFinish = function(result)
if result ~= "win" then
ow:afterBattle(result, battle)
done()
return
end
game.save.defeatedTrainers[npc.id] = true
game.save.flags.EVENT_BEAT_ROCKET_HIDEOUT_GIOVANNI = true
-- End-battle "WHAT!" then BeatGiovanniScript's hope text,
-- fade, HideObject Giovanni, ShowObject Silph Scope.
game.stack:push(TextBox.new(game, cannotBe, function()
game.stack:push(TextBox.new(game, hope, function()
local Transition = require("src.render.Transition")
game.stack:push(Transition.new(game, function()
local Commands = require("src.script.Commands")
local ctx = { game = game, save = game.save, overworld = ow }
Commands.hide_object(ctx, "ROCKET_HIDEOUT_B4F",
"ROCKETHIDEOUTB4F_GIOVANNI")
Commands.show_object(ctx, "ROCKET_HIDEOUT_B4F",
"ROCKETHIDEOUTB4F_SILPH_SCOPE")
end, function()
ow:afterBattle(result, battle)
done()
end))
end))
end))
end
ow:pushBattle(battle)
end))
end,
},
}
-- -------------------------------------------------------------------
-- Game Corner coins, prizes, and the rocket-poster switch that reveals
-- the hideout stairs (scripts/GameCorner.asm, data/events/prizes.asm +
@@ -456,23 +286,14 @@ M.GAME_CORNER = {
-- EVENT_FOUND_ROCKET_HIDEOUT is unset, $43 after)
onEnter = function(game, ow)
local poster = game.data.field.gameCornerPoster
if poster then
local block = game.save.flags[poster.event] and poster.openBlock
or poster.closedBlock
ow:replaceBlock(poster.x, poster.y, block)
end
if not poster then return end
local block = game.save.flags[poster.event] and poster.openBlock
or poster.closedBlock
ow:replaceBlock(poster.x, poster.y, block)
-- pick this visit's lucky slot machine
-- (wLuckySlotHiddenEventIndex, engine/slots/game_corner_slots2.asm)
local seats = game.data.field.slotMachines.GAME_CORNER
ow.luckySlot = love.math.random(1, #seats)
-- #131: pre-#50 saves beat the poster grunt (defeatedTrainers) but
-- never hid him; clear the tile if he is already marked defeated
local rocketId = "GAME_CORNER_obj_11"
if game.save.defeatedTrainers and game.save.defeatedTrainers[rocketId] then
local Commands = require("src.script.Commands")
Commands.hide_object({ game = game, save = game.save, overworld = ow },
"GAME_CORNER", "GAMECORNER_ROCKET")
end
end,
talk = {
-- the poster bg event: pressing A reveals the hidden switch
@@ -501,16 +322,7 @@ M.GAME_CORNER = {
-- losing he warns the BOSS and leaves the floor for good, freeing
-- the tile in front of the hideout switch
TEXT_GAMECORNER_ROCKET = function(game, ow, npc, done)
local Commands = require("src.script.Commands")
local function hideRocket()
Commands.hide_object({ game = game, save = game.save,
overworld = ow },
"GAME_CORNER", "GAMECORNER_ROCKET")
end
-- already beaten: hide anyway so pre-#50 saves that only have
-- defeatedTrainers (no objectToggles hide) clear the poster tile
if ow:trainerDefeated(npc) then
hideRocket()
done()
return
end
@@ -524,7 +336,10 @@ M.GAME_CORNER = {
game.data.text._GameCornerRocketAfterBattleText
or "Our hideout might\nbe discovered! I\nbetter tell BOSS!",
function()
hideRocket()
local Commands = require("src.script.Commands")
Commands.hide_object({ game = game, save = game.save,
overworld = ow },
"GAME_CORNER", "GAMECORNER_ROCKET")
done()
end))
end)
@@ -666,9 +481,8 @@ local DOCK_SHIP_BLOCKS = {
M.VERMILION_DOCK = {
onEnter = function(game, ow)
local Flags = require("src.script.Flags")
local f = game.save.flags
if Flags.get(game.save, "EVENT_SS_ANNE_LEFT") then
if f.EVENT_SS_ANNE_LEFT then
-- the ship is long gone: erase her right away, and anyone who
-- still lands here is sent back out past the guard
for _, b in ipairs(DOCK_SHIP_BLOCKS) do
@@ -684,15 +498,13 @@ M.VERMILION_DOCK = {
elseif f.EVENT_GOT_HM01 and ow.player.cellY == 2 then
-- VermilionDockSSAnneLeavesScript: only stepping OFF the ship
-- triggers the departure (wDestinationWarpID == 1 in pokered) --
-- Music_Surfing plays for the sail-away cutscene, smoke puffs
-- drift off the funnel, the horn blows, the ship is erased to
-- open water, and the player is walked off the dock into the
-- city past the guard (VermilionCity's
-- the horn blows, smoke puffs drift off the funnel, the ship is
-- erased to open water, and the player is walked off the dock
-- into the city past the guard (VermilionCity's
-- SCRIPT_VERMILIONCITY_PLAYER_EXIT_SHIP walk)
Flags.set(game.save, "EVENT_SS_ANNE_LEFT")
local Music = require("src.core.Music")
Music.stop()
Music.play(game.data, "Music_Surfing")
f.EVENT_SS_ANNE_LEFT = true
require("src.core.Music").stop()
require("src.core.Sound").play(game.data, "SS_Anne_Horn")
local function puff(n, cx)
if n <= 0 then return end
ow:startDustAnim(cx, 1, function() puff(n - 1, cx + 2) end)
@@ -700,15 +512,11 @@ M.VERMILION_DOCK = {
puff(3, 15)
local rows = {}
rows[#rows + 1] = { "wait", 100 }
rows[#rows + 1] = { "play_sound", "SS_Anne_Horn" }
for _, b in ipairs(DOCK_SHIP_BLOCKS) do
rows[#rows + 1] = { "replace_block", b.bx, b.by, b.water }
end
rows[#rows + 1] = { "wait", 30 }
rows[#rows + 1] = { "move_player", "up", 2 }
-- keep Music_Surfing across the city warp (pokered stays on it
-- through the exit-ship walk)
rows[#rows + 1] = { "play_music", "Music_Surfing", { keep = true } }
rows[#rows + 1] = { "warp", "VERMILION_CITY", 18, 31, "up" }
rows[#rows + 1] = { "move_player", "up", 2 }
ow:queueScript(rows)
+35 -86
View File
@@ -212,31 +212,30 @@ M.COPYCATS_HOUSE_2F = {
talk = {
TEXT_COPYCATSHOUSE2F_COPYCAT = function(game, ow, npc, done)
local t = text(game)
local Bag = require("src.inventory.Bag")
if game.save.flags.EVENT_GOT_TM31 then
push(game, t._CopycatsHouse2FCopycatTM31Explanation2Text, done)
push(game, t._CopycatsHouse2FCopycatWhirlingText
or "Huh? Huh? Huh?", done)
return
end
-- Mimic line first; auto-trade POKE DOLL for TM31 (no YES/NO).
push(game, t._CopycatsHouse2FCopycatDoYouLikePokemonText, function()
if (game.save.inventory.POKE_DOLL or 0) <= 0 then
done()
return
end
push(game, t._CopycatsHouse2FCopycatTM31PreReceiveText, function()
if not Bag.add(game.save, "TM_MIMIC", 1) then
push(game, t._CopycatsHouse2FCopycatTM31NoRoomText, done)
if (game.save.inventory.POKE_DOLL or 0) > 0 then
ask(game, t._CopycatsHouse2FCopycatPokeDollText
or "Oh wow!\nA POKé DOLL!\fFor me?\nCan I have it?", function(yes)
if not yes then done() return end
game.save.inventory.POKE_DOLL = game.save.inventory.POKE_DOLL - 1
if game.save.inventory.POKE_DOLL == 0 then
game.save.inventory.POKE_DOLL = nil
end
if not require("src.inventory.Bag").add(game.save, "TM_MIMIC", 1) then
push(game, "You don't have\nroom for TM31!", done)
return
end
game.stringBuffer = game.data.items.TM_MIMIC.name
require("src.core.Sound").play(game.data, "Get_Item1")
Bag.remove(game.save, "POKE_DOLL", 1)
game.save.flags.EVENT_GOT_TM31 = true
push(game, t._CopycatsHouse2FCopycatReceivedTM31Text, function()
push(game, t._CopycatsHouse2FCopycatTM31Explanation1Text, done)
end)
push(game, ("%s got\nTM31!"):format(game.save.player.name), done)
end)
end)
return
end
push(game, t._CopycatsHouse2FCopycatText
or "I like to mimic\npeople!", done)
end,
},
}
@@ -646,44 +645,12 @@ local function e4ExitSeal(flag, closedBlock, openBlock, dontRunText, autoFlag)
}
end
-- Lorelei/Bruno/Agatha EndBattleScript (pokered): EndTrainerBattle then
-- DisplayTextID -> TalkToTrainer, which prints the AfterBattle text on a
-- win. engageTrainer only shows the won line, so wrap talk like Lance /
-- Game Corner Rocket and push header.after immediately after a win.
local function e4LeaderTalk(afterLabel)
return function(game, ow, npc, done)
done = done or function() end
if ow:trainerDefeated(npc) then
local after = text(game)[afterLabel]
if after then push(game, after, done) else done() end
return
end
ow:engageTrainer(npc, function()
if not ow:trainerDefeated(npc) then
done()
return
end
local after = text(game)[afterLabel]
if after then push(game, after, done) else done() end
end)
end
end
M.LORELEIS_ROOM = e4ExitSeal("EVENT_BEAT_LORELEIS_ROOM_TRAINER_0", 0x24, 0x05,
"_LoreleisRoomLoreleiDontRunAwayText", "EVENT_AUTOWALKED_INTO_LORELEIS_ROOM")
M.LORELEIS_ROOM.talk = {
TEXT_LORELEISROOM_LORELEI = e4LeaderTalk("_LoreleisRoomLoreleiAfterBattleText"),
}
M.BRUNOS_ROOM = e4ExitSeal("EVENT_BEAT_BRUNOS_ROOM_TRAINER_0", 0x24, 0x05,
"_BrunosRoomBrunoDontRunAwayText", "EVENT_AUTOWALKED_INTO_BRUNOS_ROOM")
M.BRUNOS_ROOM.talk = {
TEXT_BRUNOSROOM_BRUNO = e4LeaderTalk("_BrunoAfterBattleText"),
}
M.AGATHAS_ROOM = e4ExitSeal("EVENT_BEAT_AGATHAS_ROOM_TRAINER_0", 0x3b, 0x0e,
"_AgathasRoomAgathaDontRunAwayText", "EVENT_AUTOWALKED_INTO_AGATHAS_ROOM")
M.AGATHAS_ROOM.talk = {
TEXT_AGATHASROOM_AGATHA = e4LeaderTalk("_AgathaAfterBattleText"),
}
-- -------------------------------------------------------------------
-- Lance's room (scripts/LancesRoom.asm). Unlike the other three E4
@@ -705,21 +672,13 @@ M.AGATHAS_ROOM.talk = {
-- EVENT_LANCES_ROOM_LOCK_DOOR: first crossing seals
-- the door behind the player with SFX_GO_INSIDE
-- (24,16) the entrance staircase -> WalkToLance: an auto-walk
-- landing on (6,11). Vanilla WalkToLance_RLEList is
-- up 12 / left 12 / down 7 / left 6 and skips collision
-- through void tiles; our camera follows that literally
-- and briefly shows the empty upper chamber (reads as
-- "Gary's room"). We keep the same landing cell but
-- route along the open-door floor corridor instead.
-- (up 12, left 12, down 7, left 6) landing on (6,11).
-- It marches straight across the room's water decor:
-- pokered's CollisionCheckOnLand skips collision
-- entirely while simulated joypad states run, and our
-- scriptMove is collision-free the same way.
-- -------------------------------------------------------------------
-- Floor corridor (24,16) -> (6,11) with EVENT_LANCES_ROOM_LOCK_DOOR
-- unset (entrance blocks $31/$32). Exposed for parity tests.
local LANCE_WALK_IN = {
{ "down", 2 }, { "left", 6 }, { "down", 5 }, { "left", 10 },
{ "up", 9 }, { "left", 2 }, { "up", 3 },
}
local function lanceEntranceBlocks(game, ow)
local locked = game.save.flags.EVENT_LANCES_ROOM_LOCK_DOOR
ow:replaceBlock(2, 6, locked and 0x72 or 0x31)
@@ -734,24 +693,21 @@ local function lanceLockDoor(game, ow)
end
local function lanceWalkIn(game, ow)
local i = 0
local function step()
i = i + 1
local seg = LANCE_WALK_IN[i]
if not seg then
-- lands on (6,11); vanilla's per-frame coord poll then locks the
-- door at once. scriptMove landings do not fire onStep, so lock
-- here.
lanceLockDoor(game, ow)
return
end
ow:scriptMove(ow.player, seg[1], seg[2], step)
end
step()
ow:scriptMove(ow.player, "up", 12, function()
ow:scriptMove(ow.player, "left", 12, function()
ow:scriptMove(ow.player, "down", 7, function()
ow:scriptMove(ow.player, "left", 6, function()
-- the walk lands on (6,11); vanilla's per-frame coord poll
-- then locks the door at once. scriptMove landings do not
-- fire onStep, so lock here.
lanceLockDoor(game, ow)
end)
end)
end)
end)
end
M.LANCES_ROOM = {
walkInRoute = LANCE_WALK_IN,
onEnter = function(game, ow)
lanceEntranceBlocks(game, ow)
-- the warp arrival lands ON the staircase trigger, and onStep only
@@ -771,14 +727,7 @@ M.LANCES_ROOM = {
end
if not lance or ow:trainerDefeated(lance) then return false end
lance:facePlayer(ow.player)
-- LancesRoomLanceEndBattleScript DisplayTextID -> TalkToTrainer
-- after-battle text (rival became champion first). engageTrainer
-- only shows the won line, so push the after text on a win.
ow:engageTrainer(lance, function()
if not ow:trainerDefeated(lance) then return end
local after = text(game)._LancesRoomLanceAfterBattleText
if after then push(game, after) end
end)
ow:engageTrainer(lance, function() end)
return true
end
if (x == 5 or x == 6) and y == 11 then
+40 -205
View File
@@ -262,180 +262,38 @@ M.CINNABAR_ISLAND = {
-- Pewter's youngster stops you leaving east before Brock is beaten and
-- escorts you to the gym (scripts/PewterCity.asm
-- PewterCityCheckPlayerLeavingEastScript /
-- PewterCityYoungsterShowsPlayerGymScript, engine/overworld/auto_movement.asm
-- PewterGymGuyMovementScriptPointerTable, engine/events/pewter_guys.asm
-- PewterGymGuyCoords). Same lockstep style as Oak's lab escort: the
-- youngster walks RLEList_PewterGymGuy while the player plays the
-- reverse of RLEList_PewterGymPlayer with a PewterGuys positioning
-- preamble. Ends at (11,18) / (12,18) by the gym, not phasing through
-- the building.
local pewterEscort = {}
-- RLEList_PewterGymGuy (NPC directions play forward)
pewterEscort.guySteps = {
"down", "down",
"left", "left", "left", "left", "left", "left", "left", "left",
"left", "left", "left", "left", "left", "left", "left",
"up", "up", "up", "up", "up",
"left", "left", "left", "left", "left", "left", "left", "left",
"left", "left", "left",
"down", "down", "down", "down", "down",
"right", "right", "right",
}
-- Walk home: reverse of guySteps with opposite facings (gym → spawn).
do
local opp = { up = "down", down = "up", left = "right", right = "left" }
local ret = {}
for i = #pewterEscort.guySteps, 1, -1 do
ret[#ret + 1] = opp[pewterEscort.guySteps[i]]
end
pewterEscort.guyReturnSteps = ret
end
-- RLEList_PewterGymPlayer before reverse / PewterGuys (NO_INPUT, RIGHT×2,
-- DOWN×5, LEFT×11, UP×5, LEFT×15)
pewterEscort.playerRle = {
"NO",
"right", "right",
"down", "down", "down", "down", "down",
"left", "left", "left", "left", "left", "left", "left", "left",
"left", "left", "left",
"up", "up", "up", "up", "up",
"left", "left", "left", "left", "left", "left", "left", "left",
"left", "left", "left", "left", "left", "left", "left",
}
-- PewterGymGuyCoords: (x, y) -> positioning moves written after the RLE
-- (played in reverse; $00 pauses are one overworld frame ≈ 1/8 tile for
-- the NPC, so eight of them ≈ one guy head-start step)
pewterEscort.preambles = {
["34,16"] = { "left", "down", "down", "right" },
["35,17"] = { "left", "down", "right", "left" },
["37,18"] = { "left", "left", "left",
"NO", "NO", "NO", "NO", "NO", "NO", "NO", "NO" },
["37,19"] = { "left", "left", "up", "left" },
["36,17"] = { "left", "down", "left",
"NO", "NO", "NO", "NO", "NO", "NO", "NO", "NO" },
}
-- Realized player path for a trigger tile: PewterGuys overwrites the
-- last RLE byte and appends the preamble, then simulated joypad plays
-- high→low (reverse). Leading NO×8 collapses to guyHeadStart=1; the
-- trailing end-of-list NO is dropped (one-frame pause).
function pewterEscort.playerPlan(x, y)
local pre = pewterEscort.preambles[x .. "," .. y]
if not pre then return nil end
local buf = {}
for i, d in ipairs(pewterEscort.playerRle) do buf[i] = d end
buf[#buf] = pre[1]
for i = 2, #pre do buf[#buf + 1] = pre[i] end
local path = {}
for i = #buf, 1, -1 do path[#path + 1] = buf[i] end
local head = 0
while path[head + 1] == "NO" do head = head + 1 end
local tail = #path
while tail > head and path[tail] == "NO" do tail = tail - 1 end
local steps = {}
for i = head + 1, tail do steps[#steps + 1] = path[i] end
return { steps = steps, guyHeadStart = math.floor(head / 8) }
end
local function pewterGymEscort(game, ow)
if ow.runner:isRunning() or #ow.scriptMoves > 0 then return end
local x, y = ow.player.cellX, ow.player.cellY
local plan = pewterEscort.playerPlan(x, y)
local Music = require("src.core.Music")
local t = text(game)
local follow = t._PewterCityYoungsterYoureATrainerFollowMeText
or "You're a trainer\nright? BROCK's\nlooking for new\nchallengers!\nFollow me!"
-- PewterGuys only has entries for five tiles; an unmatched talk tile
-- (e.g. (36,16) east of him) just gets the follow-me line, same as a
-- failed coords lookup would refuse to arm the walk.
if not plan then
push(game, follow)
return
end
local guy = ow:npcByIndex(5) -- PEWTERCITY_YOUNGSTER
local guySteps = pewterEscort.guySteps
local head = plan.guyHeadStart
-- After the walk: face the player, restore map music, "Go take on
-- BROCK", then retrace RLEList_PewterGymGuy back to his spawn (35,16).
-- (pokered teleports him via MovementData_PewterGymGuyExit; we walk
-- the same route home instead. Brock victory still HideObject's him.)
local function walkHome()
if not guy then return end
local ret = pewterEscort.guyReturnSteps
local i = 0
local function tick()
i = i + 1
if not ret[i] then
guy.facing = "down"
return
end
ow:scriptMove(guy, ret[i], 1, tick)
end
tick()
end
local function afterWalk()
if guy then guy.facing = "left" end
Music.playMap(game.data, "PEWTER_CITY")
push(game, t._PewterCityYoungsterGoTakeOnBrockText
or "Go take on BROCK\nat the GYM first!", walkHome)
end
local function lockstep()
local i = 0
local function tick()
i = i + 1
local ps = plan.steps[i]
if not ps then
afterWalk()
return
end
local gs = guySteps[head + i]
if guy and gs then ow:scriptMove(guy, gs, 1) end
ow:scriptMove(ow.player, ps, 1, tick)
end
tick()
end
local function beginWalk()
Music.play(game.data, "Music_MuseumGuy")
if guy and head > 0 then
local h = 0
local function headTick()
h = h + 1
if h > head then lockstep(); return end
ow:scriptMove(guy, guySteps[h], 1, headTick)
end
headTick()
else
lockstep()
end
end
push(game, follow, beginWalk)
end
-- PewterCityYoungsterShowsPlayerGymScript, engine/events/pewter_guys.asm
-- PewterGymGuyCoords): the "follow me" lines, then the player is walked
-- west along the road to the front of the PEWTER_GYM door at (16,17).
M.PEWTER_CITY = {
escort = pewterEscort,
-- PewterCityYoungsterText: talking also arms the gym escort script
talk = {
TEXT_PEWTERCITY_YOUNGSTER = function(game, ow, npc, done)
pewterGymEscort(game, ow)
if done then done() end
end,
},
onStep = function(game, ow, x, y)
if game.save.flags.EVENT_BEAT_BROCK then return false end
if ow.runner:isRunning() or #ow.scriptMoves > 0 then return false end
if not inCoords({ { 35, 17 }, { 36, 17 }, { 37, 18 }, { 37, 19 } }, x, y) then
return false
end
pewterGymEscort(game, ow)
local t = text(game)
-- walk up onto the road row (y=17), west to one tile east of the gym
-- door, then drop below the door and turn to face it
local steps = {}
for _ = 1, y - 17 do steps[#steps + 1] = "up" end
for _ = 1, x - 17 do steps[#steps + 1] = "left" end
steps[#steps + 1] = "down"
steps[#steps + 1] = "left"
local function walk(i)
if not steps[i] then
ow.player.facing = "up"
return
end
ow:scriptMove(ow.player, steps[i], 1, function() walk(i + 1) end)
end
push(game, t._PewterCityYoungsterYoureATrainerFollowMeText
or "Hey! You're a\ntrainer, right?", function()
push(game, t._PewterCityYoungsterGoTakeOnBrockText
or "Go take on BROCK\nat the GYM first!", function()
walk(1)
end)
end)
return true
end,
}
@@ -457,19 +315,6 @@ end
-- EVENT_ROUTE22_RIVAL_WANTS_BATTLE in Oak's lab (expired by Pewter
-- Gym) and again in Viridian Gym; we derive the same windows from the
-- surrounding story flags so old saves work too.
-- Rival1Exit → Viridian (right/down); Rival2Exit → League (left).
local function route22ExitDirs(n, py)
if n == 2 then
if py == 5 then return { "left", "left", "left", "left" } end
return { "left", "left", "left" }
end
if py == 5 then
return { "right", "right", "down", "down", "down", "down", "down" }
end
return { "up", "right", "right", "right",
"down", "down", "down", "down", "down", "down" }
end
local function route22Scene(n, objIndex, objName, oppClass, baseParty, beatFlag, py)
return {
{ "show_object", "ROUTE_22", objName }, -- 1
@@ -481,7 +326,7 @@ local function route22Scene(n, objIndex, objName, oppClass, baseParty, beatFlag,
{ "set_flag", beatFlag }, -- 7
{ "show_text", "_Route22Rival" .. n .. "DefeatedText" }, -- 8
{ "show_text", "_Route22RivalAfterBattleText" .. n }, -- 9
{ "walk_npc", objIndex, route22ExitDirs(n, py) }, -- 10
{ "move_npc_to", objIndex, 25, 5 }, -- 10
{ "hide_object", "ROUTE_22", objName }, -- 11
}
end
@@ -508,14 +353,6 @@ M.ROUTE_22 = {
-- Cerulean City: the Nugget Bridge rival ambush (CeruleanCityCoords2),
-- the TM28 rocket thief with his forced-fight cells (Coords1), and the
-- cave guard who steps aside once you are Champion.
-- Post-battle: Bill text then CeruleanCityMovement3/4 into town.
local function ceruleanRivalExitDirs(px)
if px == 20 then
return { "right", "down", "down", "down", "down", "down", "down" }
end
return { "left", "down", "down", "down", "down", "down", "down" }
end
local function ceruleanRivalScene(px, py)
return {
{ "show_object", "CERULEAN_CITY", "CERULEANCITY_RIVAL" }, -- 1
@@ -523,43 +360,41 @@ local function ceruleanRivalScene(px, py)
{ "face_object", 1, "down" }, -- 3
{ "show_text", "_CeruleanCityRivalPreBattleText" }, -- 4
{ "rival_battle", "OPP_RIVAL1", 7 }, -- 5
{ "jump_if_false", 11 }, -- 6
{ "jump_if_false", 10 }, -- 6
{ "set_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 7
{ "show_text", "_CeruleanCityRivalDefeatedText" }, -- 8
{ "show_text", "_CeruleanCityRivalIWentToBillsText" }, -- 9
{ "walk_npc", 1, ceruleanRivalExitDirs(px) }, -- 10
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_RIVAL" }, -- 11
{ "move_npc_to", 1, 20, 2 }, -- 9
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_RIVAL" }, -- 10
}
end
-- scripts/CeruleanCity.asm CeruleanCityRocketText: fight the thief,
-- then he returns TM28 (DIG) and hurries off. CeruleanHideRocket
-- (CeruleanCity_2.asm) is GBFadeOutToBlack → Show GUARD1 / Hide GUARD2 /
-- Hide ROCKET → GBFadeInFromBlack — not a bare hide_object.
-- then he returns TM28 (DIG) and hurries off
local rocketRows = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_GOT_TM28" }, -- 2
{ "jump_if_true", 15 }, -- 3 → CeruleanHideRocket
{ "jump_if_true", 15 }, -- 3
{ "check_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 4
{ "jump_if_true", 9 }, -- 5
{ "show_text", "_CeruleanCityRocketText" }, -- 6
{ "start_battle", "trainer", "OPP_ROCKET", 5 }, -- 7
{ "jump_if_false", "end" }, -- 8
{ "jump_if_false", 18 }, -- 8
{ "show_text", "_CeruleanCityRocketIllReturnTheTMText" }, -- 9
{ "set_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 10
{ "give_item", "TM_DIG", 1, false }, -- 11 (row 13 prints)
{ "set_flag", "EVENT_GOT_TM28" }, -- 12
{ "show_text", "_CeruleanCityRocketReceivedTM28Text" }, -- 13
{ "show_text", "_CeruleanCityRocketIBetterGetMovingText" }, -- 14
{ "fade", "out" }, -- 15 GBFadeOutToBlack
-- CeruleanHideRocket while black: GUARD1 (28,12) appears, GUARD2
-- (27,12) and the ROCKET go. GUARD2 blocks the trashed-house south
-- door neighbour — the swap reconnects the city (Bill's ticket does
-- the same in story.lua; either route is enough).
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_ROCKET" }, -- 15
-- CeruleanHideRocket (scripts/CeruleanCity_2.asm) does all three behind
-- one fade: the ROCKET goes, GUARD1 (28,12) appears and GUARD2 (27,12)
-- disappears. GUARD2 stands on the only walkable neighbour of the
-- trashed house's south door, which is one of the two ways through the
-- fence dividing Cerulean, so this swap is what reconnects the city.
-- Bill's ticket performs the same swap (data/scripts/story.lua), and
-- both are idempotent, so whichever the player reaches first opens it.
{ "show_object", "CERULEAN_CITY", "CERULEANCITY_GUARD1" }, -- 16
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_GUARD2" }, -- 17
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_ROCKET" }, -- 18
{ "fade", "in" }, -- 19 GBFadeInFromBlack
}
M.CERULEAN_CITY = {
+10 -72
View File
@@ -2,9 +2,8 @@
-- "OPP_CLASS#partyIndex" (the object_event trainer args). Hand-ported
-- from the leaders'/bosses' text_asm victory scripts:
-- gym badges: scripts/PewterGym.asm ... ViridianGym.asm
-- Rocket Hideout Giovanni: Silph Scope is a hidden item ball revealed by
-- ShowObject in RocketHideoutB4FBeatGiovanniScript (ported as the
-- TEXT_ROCKETHIDEOUTB4F_GIOVANNI talk handler in story3.lua).
-- Rocket Hideout Giovanni: his Silph Scope is an item ball next to him
-- (data/maps/objects/RocketHideoutB4F.asm), so no reward entry needed.
-- The TM each gym leader hands out afterwards is also ported.
--
-- `deactivate` lists the EVENT_BEAT_* flags each gym's victory script
@@ -12,12 +11,6 @@
-- "; deactivate gym trainers" / SetEventRange in the other gyms, and
-- FightingDojo.asm SetEventRange EVENT_BEAT_KARATE_MASTER ..
-- EVENT_BEAT_FIGHTING_DOJO_TRAINER_3).
--
-- `dialogue` is the end-battle + post-battle text chain each gym leader
-- runs (SaveEndBattleTextPointers then the map's *PostBattle / ReceiveTM
-- script). Leaders are not def_trainers entries, so engageTrainer has
-- no header.won — checkVictoryRewards shows this chain instead of a
-- synthetic "received badge/TM" stub.
local function range(prefix, first, last)
local t = {}
@@ -28,85 +21,30 @@ local function range(prefix, first, last)
end
return {
-- PewterGym.asm .gymVictory also HideObject TOGGLE_GYM_GUY
-- (PEWTERCITY_YOUNGSTER) and TOGGLE_ROUTE_22_RIVAL_1 so the east-exit
-- escort NPC and the first Route 22 rival stay gone after the badge.
["OPP_BROCK#1"] = { badge = "BOULDERBADGE", flag = "EVENT_BEAT_BROCK",
item = "TM_BIDE",
deactivate = { "EVENT_BEAT_PEWTER_GYM_TRAINER_0" },
hide = {
{ "PEWTER_CITY", "PEWTERCITY_YOUNGSTER" },
{ "ROUTE_22", "ROUTE22_RIVAL1" },
},
dialogue = {
"_PewterGymBrockReceivedBoulderBadgeText",
"_PewterGymBrockBoulderBadgeInfoText",
"_PewterGymBrockWaitTakeThisText",
"_PewterGymReceivedTM34Text",
"_TM34ExplanationText",
} },
deactivate = { "EVENT_BEAT_PEWTER_GYM_TRAINER_0" } },
["OPP_MISTY#1"] = { badge = "CASCADEBADGE", flag = "EVENT_BEAT_MISTY",
item = "TM_BUBBLEBEAM",
deactivate = range("EVENT_BEAT_CERULEAN_GYM_TRAINER_", 0, 1),
dialogue = {
"_CeruleanGymMistyReceivedCascadeBadgeText",
"_CeruleanGymMistyCascadeBadgeInfoText",
"_CeruleanGymMistyReceivedTM11Text",
} },
deactivate = range("EVENT_BEAT_CERULEAN_GYM_TRAINER_", 0, 1) },
["OPP_LT_SURGE#1"] = { badge = "THUNDERBADGE", flag = "EVENT_BEAT_LT_SURGE",
item = "TM_THUNDERBOLT",
deactivate = range("EVENT_BEAT_VERMILION_GYM_TRAINER_", 0, 2),
dialogue = {
"_VermilionGymLTSurgeReceivedThunderBadgeText",
"_VermilionGymLTSurgeThunderBadgeInfoText",
"_VermilionGymLTSurgeReceivedTM24Text",
"_TM24ExplanationText",
} },
deactivate = range("EVENT_BEAT_VERMILION_GYM_TRAINER_", 0, 2) },
["OPP_ERIKA#1"] = { badge = "RAINBOWBADGE", flag = "EVENT_BEAT_ERIKA",
item = "TM_MEGA_DRAIN",
deactivate = range("EVENT_BEAT_CELADON_GYM_TRAINER_", 0, 6),
dialogue = {
"_CeladonGymErikaReceivedRainbowBadgeText",
"_CeladonGymRainbowBadgeInfoText",
"_CeladonGymReceivedTM21Text",
"_TM21ExplanationText",
} },
deactivate = range("EVENT_BEAT_CELADON_GYM_TRAINER_", 0, 6) },
["OPP_KOGA#1"] = { badge = "SOULBADGE", flag = "EVENT_BEAT_KOGA",
item = "TM_TOXIC",
deactivate = range("EVENT_BEAT_FUCHSIA_GYM_TRAINER_", 0, 5),
dialogue = {
"_FuchsiaGymKogaReceivedSoulBadgeText",
"_FuchsiaGymKogaSoulBadgeInfoText",
"_FuchsiaGymKogaReceivedTM06Text",
"_FuchsiaGymKogaTM06ExplanationText",
} },
deactivate = range("EVENT_BEAT_FUCHSIA_GYM_TRAINER_", 0, 5) },
["OPP_SABRINA#1"] = { badge = "MARSHBADGE", flag = "EVENT_BEAT_SABRINA",
item = "TM_PSYWAVE",
deactivate = range("EVENT_BEAT_SAFFRON_GYM_TRAINER_", 0, 6),
dialogue = {
"_SaffronGymSabrinaReceivedMarshBadgeText",
"_SaffronGymSabrinaMarshBadgeInfoText",
"_SaffronGymSabrinaReceivedTM46Text",
"_TM46ExplanationText",
} },
deactivate = range("EVENT_BEAT_SAFFRON_GYM_TRAINER_", 0, 6) },
["OPP_BLAINE#1"] = { badge = "VOLCANOBADGE", flag = "EVENT_BEAT_BLAINE",
item = "TM_FIRE_BLAST",
deactivate = range("EVENT_BEAT_CINNABAR_GYM_TRAINER_", 0, 6),
dialogue = {
"_CinnabarGymBlaineReceivedVolcanoBadgeText",
"_CinnabarGymBlaineVolcanoBadgeInfoText",
"_CinnabarGymBlaineReceivedTM38Text",
"_CinnabarGymBlaineTM38ExplanationText",
} },
deactivate = range("EVENT_BEAT_CINNABAR_GYM_TRAINER_", 0, 6) },
["OPP_GIOVANNI#3"] = { badge = "EARTHBADGE", flag = "EVENT_BEAT_GIOVANNI",
item = "TM_FISSURE",
deactivate = range("EVENT_BEAT_VIRIDIAN_GYM_TRAINER_", 0, 7),
dialogue = {
"_ViridianGymGiovanniReceivedEarthBadgeText",
"_ViridianGymGiovanniEarthBadgeInfoText",
"_ViridianGymGiovanniReceivedTM27Text",
"_ViridianGymGiovanniTM27ExplanationText",
} },
deactivate = range("EVENT_BEAT_VIRIDIAN_GYM_TRAINER_", 0, 7) },
-- Silph Co. Giovanni: unlocks the president's Master Ball gift
["OPP_GIOVANNI#2"] = { flag = "EVENT_BEAT_SILPH_CO_GIOVANNI" },
-163
View File
@@ -1,163 +0,0 @@
# Launcher
The launcher is `src/import/RomImporter.lua`, the first-run / title screen
that runs before `Game:load`. Besides ROM import (see the file's own header)
it hosts a tabbed shell covering per-game save slots and a mod manager. This
file documents the runtime model; the visual spec lives separately.
## Android multi-ROM import
On Android, `love.system.pickFile()` opens the Storage Access Framework
picker (`GameActivity.showRomFilePicker`); the chosen file is copied to
`picked_rom.gb` in the app save directory. `RomImporter` then imports on
refocus / Choose via `findPendingRom`: only a 1 MiB `.gb` whose SHA-1 maps
to a version that is **not** yet ready counts as pending. A leftover
`picked_rom.gb` from Red therefore cannot block Blue's Choose (issue #167).
After a successful import the consumed save-dir `.gb` is removed.
**Manual check (device/emulator):** import Red → switch to Blue → Choose →
system file picker must appear (not a silent Red re-extract) → pick Blue →
Blue becomes ready beside Red.
## Tab structure
`self.tab` is one of `"red"`, `"blue"`, `"yellow"`, `"mods"`. The tab bar
draws one chip per game plus a MODS chip and rebuilds `self.tabRects` every
frame so `mousepressed` can dispatch clicks; switching tabs mid-import is
allowed (a dropped ROM still routes by SHA-1 regardless of which tab shows).
- A game tab (`_drawGamePanel`) shows the ROM card, the SAVE FILES card, the
Play button, and the SAVE SLOT card in a responsive two-column grid (see
Responsiveness). The MODS tab (`_drawModsPanel`) shows the mod list instead.
- The self-updater banner (`self.Check`, see `docs/updater.md`) draws as a
centered pill in a reserved band just above the footer, on every tab. That
position is unchanged by this redesign, so `docs/updater.md` needed no edits.
## Save slot model
All slot I/O lives in `src/core/SaveData.lua` and goes through the same fs
abstraction (`persistFs`) every other save/options call uses, so portable
mode (an `io.*` filesystem used when `portable.txt` marks the install)
keeps working unchanged.
- **Files.** A version's playthroughs live under `saves/<version>/`, one file
per slot: `saves/<version>/slot1.lua` plus a rolling `.bak` and staged
`.tmp` witness (`slotNames`), mirroring the write/recovery discipline
`SaveData.save`/`load` already use for the flat legacy file. Slot ids match
`slot%d+`; `createSlot` allocates one past the highest existing number so a
reused id can never collide with a lingering file.
- **Registry.** The ordered slot list and which one is active persist in
`options.lua` (via the existing `SaveData.loadOptions`/`saveOptions`):
`options.saveSlots = { [version] = { list = {"slot1", ...}, active = "slot1" } }`.
- **Active slot resolution.** `saveNames(version)`, the function every
existing caller (`TitleState` hasSave/load/save, recovery order) already
goes through, now resolves the *active* slot instead of a fixed flat name.
Resolved once per version per process (`ensureVersionSlots`, cached in
`activeSlotCache`/`slotsChecked`): a registry entry wins; otherwise a lazy
legacy migration may create one; otherwise the flat legacy path is used
(`save.lua` / `save_blue.lua`), so a pre-slots install keeps working as before.
- **Legacy migration.** One-time per version, lazy on first
`listSlots`/`load`/`saveNames` call (`tryMigrateLegacy`): if a flat legacy
file exists and no `saves/<version>/` registry does, its main + `.bak` are
copied into `saves/<version>/slot1.lua(.bak)`, verified readable
(`decodeSlot`: main, then `.tmp`, then `.bak`), and only then are the
originals removed and `slot1` registered as active. A copy that fails to
verify leaves the originals in place; migration never loses data.
The launcher-facing API:
- `SaveData.listSlots(version)` -> array of `{id, exists, name, meta}` for
every registered slot. `name` is the save's player name, or `nil` for an
empty slot; `meta` is `{badges, timeText, dexCount}` (the same fields the
title screen's `ContinueInfo` shows) or `nil`. The pure part,
`SaveData.slotSummary(save)`, is unit-testable with no filesystem.
- `SaveData.setActiveSlot(version, slotId)` registers the id if new, persists
it as active, and updates the process cache so the very next save/load
lands there. The launcher calls this the moment a slot row is clicked
(`RomImporter:_selectSlot`); pressing Play needs no signature change, since
`Game.lua`/`main.lua` still just call `SaveData.load()`/`save()`.
- `SaveData.createSlot(version)` -> new slot id, registered but with **no
save file written**. An empty slot means the title screen offers NEW GAME
only, which needs no further changes.
## Launcher mod manager
`src/mods/LauncherMods.lua` is a launcher-only read of the mod set. It runs
before `Game:load`, so **it never loads a mod's entry chunk**; only
`manifest.json` is read and validated (`src/mods/Manifest.validate`), the way
`Loader:_discover` finds mods without running them. The real loader
(`src/mods/Loader.lua`) still owns the actual load at boot.
- `LauncherMods.list()` scans `mods/` one level deep (first id wins on a
duplicate) and returns one row per mod:
`{id, name, version, badge, description, enabled, status, statusDetail}`.
`badge` is the manifest's `category`, falling back to `profile`, then
`"MOD"`, uppercased. `enabled` reads `options.mods[id]` (missing means
enabled, matching the loader's own default).
- `status` is `"ok"`, `"warn"`, or `"conflict"`, computed by the pure
`LauncherMods.deriveList`/`statusFor` against `ManagerState.resolveToggle`
and the validated manifests: `conflict` when enabling this mod collides
with another enabled one; `warn` for an out-of-range `game_version` or an
absent/disabled/wrong-version hard dependency; `ok` otherwise. Having no
`love.*` calls, this half is table-driven by the test suite on its own.
- `LauncherMods.setEnabled(id, bool)` persists `options.mods[id]` as a plain
boolean, the exact shape `Loader:_saveState` writes, so the running game
and the in-game `ManagerState` see the change on next boot. The mods panel
calls this on every toggle and re-derives the list right away
(`RomImporter:_refreshMods`) so a status change (e.g. a new conflict)
shows without waiting for a reload.
- `LauncherMods.installZip(path)` mounts the archive with
`love.filesystem.mount`, locates the mod root via `locateRoot` (manifest at
the zip root, or inside one top-level folder), validates its manifest, and
copies the tree into the save-dir `mods/<id>/` before unmounting. Rejects a
duplicate of an already-installed mod id, and accepts either an external
path string or a LOVE `DroppedFile`, staging a dropped file into a save-dir
temp first (mount only reaches save-dir-relative paths), the same way
`RomImporter` handles a dropped ROM. A failed copy rolls its partial tree
back, and every path unmounts and clears the staged temp file.
## Import / Export save
The SAVE FILES card wires a raw Gen1 `.sav` battery image to the save slots
through `src/import/SaveFileIO.lua`, which sits on top of
`src/save_convert/SaveConvert.lua` and the slot API in `SaveData`.
- **Import save** is live once the game's ROM is imported (playable). It opens
a native `.sav` picker (`chooseSav`, the per-OS dialogs mirror `chooseZip`;
Android has no picker and shows a drop hint). `SaveFileIO.importToSlot`
reads the bytes (an absolute path, a dropped LOVE file, or raw bytes),
guards the 32768-byte size, runs `SaveConvert.importSav` (which also rejects
a bad main-data checksum), then registers a fresh slot (`SaveData.createSlot`),
writes it (`SaveData.writeSlot`), and makes it active (`SaveData.setActiveSlot`).
The meta stamp is re-stamped off `gen1_import` to the current numeric format
so `SaveData.load`'s migration pass accepts the slot. On success the SAVE SLOT
panel is refreshed with the new slot selected.
- **Export save** is live only when the active slot actually holds a save
(checked against `listSlots`). `SaveFileIO.exportActiveSlot` loads the active
slot, encodes it back with `SaveConvert.exportSav` (a slot never keeps
`rawImport`, so this is a zero-filled template export, which is valid), and
writes `exports/gen1recomp-<version>-<slotId>.sav` in the save directory
(`love.filesystem.createDirectory("exports")`). It returns the absolute path
(`love.filesystem.getSaveDirectory()`), which the notice line shows with a
desktop "Open folder" affordance (`love.system.openURL("file://" .. dir)`).
- **Drag-drop.** `filedropped` routes a `.sav` to the import path for the
currently active game tab; when a non-game tab (mods, or the locked yellow
placeholder) is showing it defaults to red, the always-present first game
(`_savedropTarget`). `.gb` (ROM) and `.zip` (mod) routing is unchanged.
- **Failure UX.** Every error path (wrong size, bad checksum, write failure,
nothing to export, ROM not imported yet) surfaces as a red notice line on the
card. Nothing raises and nothing silently no-ops.
`SaveFileIO` is love-free enough to unit-test through the same in-memory
filesystem stub the slot backend uses (`tests/engine/save_file_io_tests.lua`).
## Responsiveness
Every measurement derives from `love.graphics.getDimensions()` each frame
plus the existing global scale `s = clamp(height / 768, 0.7, 1.6)`; nothing
assumes a fixed window size. The game panel's two-column grid (ROM/SAVE
FILES/Play on the left, SAVE SLOT on the right) collapses to one stacked
column, slot card below Play, when the window is too narrow for both
`~300 * s`-wide columns. The save-slot list and the mod list both scroll
(wheel, or drag on touch/desktop) clamped to their own content extent,
recomputed every draw. The tab bar labels only the active chip so it stays
narrow-safe, and content caps out at `~1440 * s` wide, centered.
-105
View File
@@ -18,111 +18,6 @@ Regenerate the reference straight into a wiki checkout:
luajit tools/gen_registry_docs.lua ../pokemon-gen1-recomp-project.wiki
```
## Rendering pipelines
Most registries hand the engine *content*. `render_pipelines` hands it
*drawing*: a pipeline is a display mode a mod owns, which may replace the
overworld's world pass with geometry of its own and/or post-process the
finished image. `mods/voxel_world` is the worked example — a 3D diorama
overworld plus a tilt-shift miniature pass, in about 120 lines of glue over
its renderer.
A record declares what the mode *is*; the engine
(`src/render/Pipelines.lua`) supplies everything about *being a display
mode*: the OFF/1/2/3 ladder, an options row next to TILT, a hotkey,
persistence in `save.options.pipelines`, and the rule that a world pipeline
and the engine's own TILT are mutually exclusive.
```lua
mod.content.render_pipelines:register("diorama", {
label = "DIORAMA", -- options row label
levels = { "OFF", "15", "35", "50" }, -- ladder; defaults to OFF/ON
hotkey = "6", -- checked after the engine's keys
priority = 20, -- highest eligible wins the world
available = function() return Renderer3D.ok() end,
update = function(dt, level) Camera.ease(dt, level) end,
drawWorld = function(ctx) return renderScene(ctx) end,
})
```
Three draw stages, each optional; a record needs at least one:
| stage | signature | runs |
| --- | --- | --- |
| `drawWorld` | `(ctx) -> canvas \| nil` | instead of the flat/tilt world pass |
| `worldPresent` | `(canvas, ctx) -> canvas` | over the world, **before** the UI composites |
| `present` | `(canvas, ctx) -> canvas` | over the whole frame, world and UI alike |
`worldPresent` is the one to reach for when an effect must leave dialog
boxes and menus crisp — a depth-of-field or colour grade on the world only.
`present` is for effects that genuinely own the screen, like a CRT curve.
`ctx` carries the frame: `state`, `cam`, `vw`/`vh` (world-pixel view),
`width`/`height` (window pixels), `scale`, `level`, `paletteFor(map)` and
`spriteColors(map)`. It also carries `ctx.drawFx(project, scale)` — call it
with your own projection and the engine draws every active field effect
(the "!" bubble, the Poké Center heal machine, the Fly bird, the fishing
rod, Rock Tunnel darkness) at its correct anchor under your camera. There
is exactly one copy of each effect, so a new engine effect works in your
pipeline without you touching anything.
Three rules worth knowing:
- **`gate` governs input, never the draw.** It decides whether the player
may *change* the mode (default: free-roam overworld only). A mode that
stopped rendering during a warp would flash the flat 2D world every time
the player walked through a door.
- **`available` is re-read every frame** and is the only thing that decides
whether the mode can render at all. Answer `false` on a headless run or a
driver with no depth canvas and the engine silently keeps the vanilla 2D
path — which is why shipping a pipeline enabled is safe.
- **A callback that throws retires its pipeline**, attributed to your mod in
the manager's error feed, and the frame falls back to 2D. A broken
renderer costs the player a display mode, never the game.
Returning `nil` from `drawWorld` is a normal answer meaning "not this
frame"; the engine draws the vanilla world instead.
## Battle sprite scaling
The enemy's front pic draws at 1x and the player's back pic at 2x, the way
the Game Boy did. A mod can override either, per species or per image.
Per species, on the `pokemon` record:
```lua
-- MEW's back pic renders 1.5x; its front pic is untouched
mod.content.pokemon:patch("MEW", { battleScaleBack = 1.5 })
```
`battleScaleFront` scales the enemy pic, `battleScaleBack` the player pic;
both take a number in `0.25 .. 4.0`.
Per image, on the `battle_sprite_scales` registry, keyed by the asset path
exactly as the data references it:
```lua
mod.content.battle_sprite_scales:register("abra_back", {
path = "assets/generated/battle/back/abrab.png",
scale = 1.5,
})
```
An image-level entry beats the species scale for that one pic, and it is
the only way to scale a pic that is not species-keyed — the player's
trainer back sprite, held on screen until "Go!", is a bare image path.
The resolution order at draw time is **image-level → species-level →
default** (1x front, 2x back).
- **The pic stays grounded at every scale.** The player pic keeps its feet
flush on the text-box top (`y = 96`); the enemy pic keeps its bottom edge
and horizontal centre pinned in its 7×7 slot. A larger pic grows upward
and outward from that anchor, never off the shelf.
- **Scaling composes with the send-out grow.** The `AnimateSendingOutMon`
ball-to-pic grow multiplies your scale through each stage, so a rescaled
mon still grows into place from the ball, grounded the whole way.
## Developer console
Boot with developer mode on to unlock the in-game console and hot-reload
+11 -52
View File
@@ -8,42 +8,27 @@ behavior is in docs/behavior-porting-notes.md.
## Survey zoom
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:
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:
- 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 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).
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).
- 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
@@ -154,7 +139,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`/`4`/`5` writes immediately; an in-game save also
menu or cycling hotkeys `2`/`3`/`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.
@@ -165,32 +150,6 @@ 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
- 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.
## On-screen touch controls (mobile)
On Android/iOS the game draws a translucent d-pad (bottom-left), A/B
buttons (bottom-right, Game Boy diagonal), and +/- START/SELECT (bottom
center) over the frame, using Xelu's CC0 controller prompts
(`assets/touch/`). Real buttons, not gestures: press lands the frame the
finger does, sliding on the d-pad changes direction without lifting, and
multi-touch chords (e.g. hold a direction + tap B) work. The overlay only
appears while no controller is being used: the first gamepad button or
stick push hides it, the next screen touch brings it back, and unplugging
the last controller restores it immediately. Layout re-derives from the
window size on rotation. Desktop testing: `POKEPORT_TOUCH=1 love .` forces
the overlay on and lets the mouse act as a finger (`=0` forces it off).
a hard render frame-rate cap (`save.options.fpsCap`).
-126
View File
@@ -1,126 +0,0 @@
# Updater
A fused build (`love.filesystem.isFused()` true) ships a bundled `game.love`
baked into the executable, but that bundled copy is only ever the *fallback*.
On every launch, before anything else runs, `Boot.run` (`src/update/Boot.lua`)
looks in the save directory's `updates/` folder for a downloaded
`gen1recomp-X.Y.Z.love` payload that is both strictly newer than the bundled
engine version and runnable on this shell. If one qualifies, it is mounted
over `/` (so its files win over the fused source for every subsequent
`require`) and chainloaded in place: the payload's `main.lua` and `love.load`
run as if they had shipped in the executable. A dev/source checkout is never
fused, so `Boot.run` no-ops there and the working tree always runs itself.
The pieces are deliberately layered so the risky part is small. `Boot.select`
is a pure function (no `love.*` calls) that, given probed candidates and the
bundled `engine`/`shell`, decides what to run and what stale payloads to
delete. `Boot.probePayload` mounts one archive at an isolated mountpoint and
reads its `src/core/Version.lua` with `loadstring` (never `require`, so it is
never cached as a module) to learn its `engine` and `minShell`. `Boot.run`
orchestrates the crash guard, enumeration, selection, and the mount +
chainload, with full rollback on any failure. Checking for and fetching a
new payload is a separate, slower path: `src/update/Check.lua` is a thin
main-thread state machine the launcher screen polls, while the curl calls,
JSON parsing, and sha256 verification run on a background `love.thread`
(`src/update/check_worker.lua`) so a hung network call never blocks a frame.
## Version.lua fields
`src/core/Version.lua` carries three fields the updater reads directly (the
existing `modApi`, `linkProtocol`, `saveFormat`, and `cache` fields are
untouched):
- `engine` - the semver release, e.g. `"1.4.0"`. The repo default is the
`"0.0.0-dev"` placeholder; CI stamps the real `X.Y.Z` into the packed
`game.love` only, never the working tree. A `"0.0.0-dev"` engine always
reports itself up to date (it never chases a release, and it never counts
as a valid payload to chainload).
- `shell` - the native-shell contract this build's fused executable
implements.
- `minShell` - the lowest shell contract required to *run* this payload.
Bump `minShell` only when a payload needs something the currently-shipped
native shell cannot provide, for example a LOVE version bump, a new required
system binary, or a change to `love.run` itself (see Known limitations
below). An older shell refuses to chainload a payload whose `minShell`
exceeds the shell it provides; `Boot.select` keeps that payload in `updates/`
rather than deleting it, in case a future shell upgrade can run it, and
`Check`'s worker reports `needs_full` so the player is pointed at a full
installer instead. Do not bump `minShell` for an ordinary Lua/data release;
that is exactly the case the updater exists to avoid a reinstall for.
## Release assets
Each tagged release `vX.Y.Z` carries the existing per-platform archives
(`gen1recomp-X.Y.Z-macos.zip`, `-windows.zip`, `-linux.zip`,
`-android.apk`) plus two assets the updater itself consumes:
- `gen1recomp-X.Y.Z.love` - the payload, matched by the exact pattern
`gen1recomp-<version>.love` (see `isPayloadName` in `Boot.lua` and
`Check.parseRelease`).
- `sha256sums.txt` - `shasum -a 256` output (`<hex> <filename>`, bare
filenames) covering at least the `.love` payload. `Check.parseSums`
tolerates a leading `*` binary marker and a `./` prefix but expects the
filename otherwise to match the asset name exactly.
A release missing either asset is treated as "no in-place update available":
`Check` reports `needs_full` and sends the player to `Check.releaseUrl()`
(`https://github.com/bryanthaboi/pokemon-gen1-recomp-project/releases/latest`).
## Save-directory layout
Under the save directory (identity `pokemon-love2d`):
```
updates/gen1recomp-<X.Y.Z>.love downloaded payload(s)
updates/pending.txt crash-guard marker
```
`pending.txt` holds the filename of the payload currently being chainloaded.
`Boot.run`'s `chainload` writes it immediately before mounting, and removes it
on both a successful handoff and a clean rollback. If it is still present the
*next* time `Boot.run` starts, the previous boot died mid-handoff, so that
named payload is distrusted: it and the marker are deleted before candidates
are enumerated. Boot may still fall back to an older valid payload, or to the
bundled game, in that case.
## Update flow
1. **Boot** (every launch, fused builds only): crash-guard check, enumerate
and probe every `updates/*.love`, pick the highest engine that is
strictly newer than the bundled one and whose `minShell` this shell
satisfies, delete stale payloads, chainload the winner (or run the
bundled game if none qualifies).
2. **Check** (launcher screen): `Check.start()` kicks off an async check
against the GitHub releases API; safe to call every frame, it is a no-op
once a check is in flight or has reached a terminal state. `Check.state()`
reports `idle | checking | uptodate | available | downloading | ready |
needs_full | error` plus the latest version and download progress.
3. **Download + verify**: on `available`, `Check.download()` tells the
worker to fetch the payload, polling the growing `.part` file for
progress. On completion the worker re-fetches `sha256sums.txt`, verifies
the payload's sha256, and probes it with `Boot.probePayload` to gate its
`minShell` against this shell's `shell`. A verified, runnable payload is
renamed into place and reported as `ready`; anything else reports
`error` or `needs_full` and leaves `updates/` clean.
4. **Restart to apply**: a `ready` payload just sits in `updates/` until the
player relaunches; the next launch's Boot step (1) is what actually
mounts and runs it. There is no in-session hot-swap.
## Known limitations
- **`love.run` persists across handoff.** By the time `chainload` runs, the
bundled `love.run` has already returned its stepper to LOVE; redefining the
global `love.run` from the payload's `main.lua` does not affect the loop
already driving the frame. A payload that must change `love.run` itself
needs a `minShell` bump so an older shell refuses to chainload it rather
than running with half its intended behavior.
- **Android has no in-app download transport yet.** `check_worker.lua`
shells out to curl for both the release check and the download; curl is
absent on Android, so `Check` degrades to `status = "error"` there (the
launcher UI hides on that status) and the player is directed to the
releases page via `Check.releaseUrl()` instead.
- **Dev/source runs never self-update.** `Boot.run` returns immediately when
`love.filesystem.isFused()` is false, and a working tree's `engine` is the
`"0.0.0-dev"` placeholder that always reports up to date, so a source
checkout is always "the game" itself; updating it means pulling the repo.
-24
View File
@@ -18,11 +18,6 @@ local driverCo -- optional frame-driver (POKEPORT_DRIVER=file.lua): a
-- bot or screenshot run is not at the mercy of the player's last choice.
local speedOverride = tonumber(os.getenv("POKEPORT_SPEED"))
-- POKEPORT_TOUCH=1 forces the mobile on-screen controls on and lets the
-- mouse stand in for a finger, so the overlay can be exercised on desktop
-- (see src/core/TouchControls.lua).
local mouseTouch = os.getenv("POKEPORT_TOUCH") == "1"
-- How many times to run a scripted act+step loop per rendered frame. Only
-- scripted runs use this; interactive play fast-forwards through
-- Game.speedOverride / the GAME SPEED option instead.
@@ -61,12 +56,6 @@ local function bootGame(version)
end
function love.load(args)
-- Self-updater boot shell: a fused build may mount and chainload a newer
-- downloaded payload here. True means it took over, so we must stop. A
-- dev / source checkout no-ops (see src/update/Boot.lua).
local Boot = require("src.update.Boot")
if Boot.run(args) then return end
local savePath
for i, a in ipairs(args or {}) do
if a == "--editor" then
@@ -279,9 +268,6 @@ function love.mousepressed(x, y, button)
if editorMode and EditorApp.mousepressed then
return EditorApp.mousepressed(x, y, button)
end
if mouseTouch and Game and button == 1 then
Game:touchpressed("mouse", x, y)
end
end
function love.mousereleased(x, y, button)
@@ -289,16 +275,6 @@ function love.mousereleased(x, y, button)
if editorMode and EditorApp.mousereleased then
return EditorApp.mousereleased(x, y, button)
end
if mouseTouch and Game and button == 1 then
Game:touchreleased("mouse", x, y)
end
end
function love.mousemoved(x, y)
if editorMode or Importer then return end
if mouseTouch and Game and love.mouse.isDown(1) then
Game:touchmoved("mouse", x, y)
end
end
function love.textinput(text)
+3 -6
View File
@@ -43,12 +43,9 @@ The embedded `game.love` deliberately excludes `data/generated/`,
`assets/generated/`, and any ROM. It contains the first-boot Lua importer and
`tools/rom_manifest.json`.
ROM import on Android uses `love.system.pickFile()`
`GameActivity.showRomFilePicker` (Storage Access Framework), which copies the
chosen file to `picked_rom.gb` under the app save directory. `RomImporter`
imports pending (not-yet-ready) `.gb` files from that folder on Choose /
refocus; see `docs/launcher.md` (Android multi-ROM import). The APK payload
itself remains data-free (no embedded ROM or generated cache).
The current importer has desktop file pickers only. A production Android
release still needs a Storage Access Framework handoff that passes the chosen
ROM to LÖVE; the APK packaging itself is data-free.
### SDK / NDK
@@ -6,7 +6,7 @@
"entry": "main.lua",
"profile": "content",
"category": "BALANCE",
"game_version": ">=0.0.0-0 <2.0.0",
"game_version": ">=1.0.0 <2.0.0",
"priority": 100,
"dependencies": [],
"optional_dependencies": [],
+1 -1
View File
@@ -6,7 +6,7 @@
"entry": "main.lua",
"profile": "content",
"category": "TOOL",
"game_version": ">=0.0.0-0 <2.0.0",
"game_version": ">=1.0.0 <2.0.0",
"priority": 100,
"dependencies": [],
"optional_dependencies": [],
+1 -1
View File
@@ -6,7 +6,7 @@
"entry": "main.lua",
"profile": "content",
"category": "AUDIO",
"game_version": ">=0.0.0-0 <2.0.0",
"game_version": ">=1.0.0 <2.0.0",
"priority": 100,
"permissions": ["engine_internals"],
"dependencies": [],
@@ -6,7 +6,7 @@
"entry": "main.lua",
"profile": "content",
"category": "QUEST",
"game_version": ">=0.0.0-0 <2.0.0",
"game_version": ">=1.0.0 <2.0.0",
"priority": 100,
"permissions": ["engine_internals"],
"dependencies": [],
@@ -6,7 +6,7 @@
"entry": "main.lua",
"profile": "total_conversion",
"category": "TOTAL_CONVERSION",
"game_version": ">=0.0.0-0 <2.0.0",
"game_version": ">=1.0.0 <2.0.0",
"priority": 900,
"dependencies": [],
"optional_dependencies": [],
@@ -6,7 +6,7 @@
"entry": "main.lua",
"profile": "content",
"category": "GRAPHICS",
"game_version": ">=0.0.0-0 <2.0.0",
"game_version": ">=1.0.0 <2.0.0",
"priority": 100,
"assets_transforms": "transforms.lua",
"dependencies": [],
+1 -1
View File
@@ -6,7 +6,7 @@
"entry": "main.lua",
"profile": "overhaul",
"category": "MECHANIC",
"game_version": ">=0.0.0-0 <2.0.0",
"game_version": ">=1.0.0 <2.0.0",
"priority": 100,
"dependencies": [],
"optional_dependencies": [],
-25
View File
@@ -68,31 +68,6 @@ if unzip -Z1 "$LOVE_FILE" \
fi
say "game.love: $(du -h "$LOVE_FILE" | cut -f1)"
# ------------------------------------------------------- stamp release version
# The working tree ships Version.lua with engine "0.0.0-dev"; the real release
# number only ever lives inside the packed archive. When --version is a strict
# X.Y.Z, patch a copy of Version.lua (engine set to that number) under a staging
# dir and replace the entry inside game.love in place -- never the source tree.
# Short-hash / "dev" builds are left with the "-dev" default so they cannot be
# mistaken for a release. The stamp is then read back out of the archive and the
# build fails if it did not take.
if printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
say "stamping engine version $VERSION into game.love"
stamp_dir="$WORK/stamp"
rm -rf "$stamp_dir"
mkdir -p "$stamp_dir/src/core"
sed -E "s/(engine[[:space:]]*=[[:space:]]*\")[^\"]*(\")/\1$VERSION\2/" \
"$ROOT/src/core/Version.lua" > "$stamp_dir/src/core/Version.lua"
(cd "$stamp_dir" && zip -q "$LOVE_FILE" src/core/Version.lua)
version_re="$(printf '%s' "$VERSION" | sed 's/\./\\./g')"
unzip -p "$LOVE_FILE" src/core/Version.lua \
| grep -Eq "engine[[:space:]]*=[[:space:]]*\"$version_re\"" \
|| fail "version stamp failed: game.love does not report engine $VERSION"
say "stamped engine version: $VERSION"
else
say "version '$VERSION' is not X.Y.Z, shipping default engine (no stamp)"
fi
# --------------------------------------------------------------- macOS
build_mac() {
say "building macOS app"
-26
View File
@@ -145,32 +145,6 @@ pack_game_love() {
fail "game.love unexpectedly contains generated ROM data"
fi
say "game.love: $(du -h "$LOVE_FILE" | cut -f1) -> $LOVE_FILE"
# This script packs its own game.love (it does not reuse build.sh's), so it
# stamps the release version the same way: patch a copy of Version.lua
# (engine set to $VERSION) under a throwaway staging dir and replace the
# entry inside the archive in place -- never the source tree. VERSION is
# already validated as X.Y.Z above; when it is empty the packaged game keeps
# the "0.0.0-dev" default. The stamp is read back out and the build fails if
# it did not take.
if printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
say "stamping engine version $VERSION into game.love"
local stamp_dir
stamp_dir="$(mktemp -d)"
mkdir -p "$stamp_dir/src/core"
sed -E "s/(engine[[:space:]]*=[[:space:]]*\")[^\"]*(\")/\1$VERSION\2/" \
"$ROOT/src/core/Version.lua" > "$stamp_dir/src/core/Version.lua"
(cd "$stamp_dir" && zip -q "$LOVE_FILE" src/core/Version.lua)
local version_re
version_re="$(printf '%s' "$VERSION" | sed 's/\./\\./g')"
unzip -p "$LOVE_FILE" src/core/Version.lua \
| grep -Eq "engine[[:space:]]*=[[:space:]]*\"$version_re\"" \
|| fail "version stamp failed: game.love does not report engine $VERSION"
rm -rf "$stamp_dir"
say "stamped engine version: $VERSION"
else
say "no X.Y.Z --version, shipping default engine (no stamp)"
fi
}
# --------------------------------------------------------------- SDK check
+194 -541
View File
File diff suppressed because it is too large Load Diff
+27 -36
View File
@@ -176,51 +176,25 @@ function EffectRegistry.runDamaging(battle, ctx, record)
end
battle.lastDamage = dmg -- wDamage (shared by both sides, read by Counter)
-- 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 }
-- 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
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,
@@ -234,6 +208,23 @@ 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
+2 -7
View File
@@ -15,8 +15,7 @@
-- (super-effective) or adds 1 (not-effective when a better move exists);
-- the MINIMUM-scored move is chosen, ties broken uniformly among the
-- tied minima (core.asm:2971-3002). A non-minimal move is never
-- selectable. Respects Disable (and PP only when the ruleset depletes
-- enemy PP — Gen 1 AI never reads wEnemyMonPP).
-- selectable. Respects PP, Disable and Transform/Mimic move overrides.
local TypeChart = require("src.battle.TypeChart")
@@ -213,13 +212,9 @@ end
function TrainerAI.chooseMove(battler, rng, battle)
rng = rng or love.math.random
-- Gen 1: SelectEnemyMove never consults wEnemyMonPP; Struggle only when
-- every move slot is missing/disabled (core.asm:2957-2999). modern_clean
-- depletes enemy PP and falls back to Struggle when none remain.
local unlimited = battle and battle.ruleset and battle.ruleset.enemyUnlimitedPP
local usable = {}
for i, mv in ipairs(battler.curMoves) do
if battler.disabledSlot ~= i and (unlimited or mv.pp > 0) then
if mv.pp > 0 and battler.disabledSlot ~= i then
table.insert(usable, mv)
end
end
-3
View File
@@ -13,7 +13,4 @@ return {
randMax = 255,
-- Focus Energy famously QUARTERS the crit rate instead of x4
focusEnergyBug = true,
-- Wild/trainer enemies never spend PP (DecrementPP only touches the
-- player side in pokered). They therefore never Struggle from empty PP.
enemyUnlimitedPP = true,
}
-2
View File
@@ -9,6 +9,4 @@ return {
randMin = 217,
randMax = 255,
focusEnergyBug = false,
-- Gen 2+ style: AI opponents deplete PP and can Struggle when empty.
enemyUnlimitedPP = false,
}
-2
View File
@@ -102,8 +102,6 @@ function Data:seedDefaults()
-- after-battle rows so Blaine's SetEventRange deactivation and talk
-- after-text work like the other gyms (scripts/CinnabarGym.asm).
self:seedCinnabarGymTrainerHeaders()
-- #189: 1F cabin door order vs rooms map (survey zoom)
require("src.world.SsAnneLayout").apply(self.maps)
end
function Data:seedCinnabarGymTrainerHeaders()
+2 -132
View File
@@ -38,18 +38,8 @@ 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
@@ -388,15 +378,6 @@ 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)
@@ -446,7 +427,7 @@ local function buildActivity()
activityState = "At the title screen"
end
local activity = {
return {
details = details,
state = activityState,
timestamps = { start = state.startedAt },
@@ -455,25 +436,10 @@ 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)
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
return (activity.details or "") .. "|" .. (activity.state or "")
end
local function flush(force)
@@ -601,96 +567,6 @@ 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()
@@ -703,8 +579,6 @@ 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
@@ -747,7 +621,6 @@ function DiscordPresence.update(_dt)
end
return
end
pollJoinRequest()
if state.dirty then flush(false) end
end)
end
@@ -776,13 +649,10 @@ function DiscordPresence.shutdown()
state.lastSentKey = nil
state.connected = false
state.socket = nil
state.subscribedJoin = false
state.joinCode = nil
end
-- test / debug helpers
DiscordPresence._state = state
DiscordPresence.locationName = locationName
DiscordPresence.handleJoinRequest = handleJoinRequest
return DiscordPresence
-8
View File
@@ -27,12 +27,4 @@ function FixedStep:update(dt)
end
end
-- Drop any pending catch-up steps. A hitch inside one logic step (map
-- seam setMap / song start) makes the next real-time dt huge; without this
-- the while-loop above would advance many walk frames before the next
-- draw, which looks like a slide with no leg animation (issue #93).
function FixedStep:discardCatchup()
self.accum = 0
end
return FixedStep
+14 -64
View File
@@ -8,7 +8,7 @@ local Logger = require("src.core.Logger")
local Renderer = require("src.render.Renderer")
local SaveData = require("src.core.SaveData")
local StateStack = require("src.core.StateStack")
local TouchControls = require("src.core.TouchControls")
local TouchInput = require("src.core.TouchInput")
local ModLoader = require("src.mods.Loader")
local ModRuntime = require("src.mods.Runtime")
local Screens = require("src.ui.Screens")
@@ -37,15 +37,12 @@ function Game:load()
self.mods = ModLoader.new()
self.mods:load(Data)
self.modStatus = self.mods:status()
-- render pipelines dispatch off the merged dataset; point them at the
-- one the mods just merged into before anything can draw a frame
require("src.render.Pipelines").install(Data)
self.input = Input
Input:init()
self.touchControls = TouchControls
TouchControls:init()
self.touchInput = TouchInput
TouchInput:init()
self.renderer = Renderer
Renderer:init()
@@ -180,6 +177,9 @@ function Game:logicSpeed()
end
function Game:update(dt)
-- Touch timers / prior-frame auto-releases before the fixed step so
-- deferred A and edge pulses land in Input's press queue for this step.
TouchInput:update(dt)
-- Fast-forward scales only the logic clock (see src/core/GameSpeed.lua).
-- Give the accumulator room for one full frame at the current speed,
-- or the anti-spiral clamp quietly caps every level above ~15X.
@@ -198,9 +198,6 @@ function Game:update(dt)
-- Overworld tilt toggle tween: presentational, so it runs on the real
-- frame dt (not the fixed logic step) for a smooth ~0.25s glide.
require("src.render.Tilt").update(dt)
-- mod render pipelines tween on the same real-frame clock, for the same
-- reason: they are presentational, so fast-forward must not speed them up
require("src.render.Pipelines").update(dt)
pcall(function() require("src.core.DiscordPresence").update(dt) end)
-- Steady-state memory backstop: advance the incremental collector one
-- small step every rendered frame. The heavy GPU objects are now freed
@@ -243,19 +240,13 @@ function Game:draw()
worldZones = self.overworld:sgbWorldZones()
end
Renderer:endFrame(zones, worldZones)
-- on-screen mobile controls: pure screen-space, over the finished frame
TouchControls:draw()
end
-- overworld survey zoom: wheel up / '=' zooms in, wheel down / '-' out
function Game:zoomStep(delta)
local Zoom = require("src.render.Zoom")
if not Zoom.gateOK(self.stack:top(), self.overworld) then return end
local offset = Zoom.step(delta, Renderer:fitScale())
if self.save and self.save.options then
self.save.options.zoom = offset
self:writeOptions()
end
Zoom.step(delta, Renderer:fitScale())
end
function Game:wheelmoved(_, dy)
@@ -329,34 +320,13 @@ 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
end
-- Mod render pipelines claim their hotkeys last, so one can never shadow
-- an engine display key however a mod declares it (12 §rendering
-- pipelines). syncOptions writes the whole ladder back, including the
-- tilt exclusion a world pipeline forces.
local Pipelines = require("src.render.Pipelines")
if Pipelines.hotkey(key, self.stack:top(), self.overworld) then
Pipelines.syncOptions(self.save.options)
require("src.render.Tilt").setLevel(self.save.options.tilt or 0)
self:writeOptions()
return
end
Input:keypressed(key)
end
@@ -374,9 +344,6 @@ function Game:keyreleased(key)
end
function Game:gamepadpressed(joystick, button)
-- a controller is being used: the touch overlay steps aside until the
-- next screen touch (mobile only; a no-op elsewhere)
TouchControls:noteGamepad()
-- BindingsMenu's pad capture rides the same top-state routing as keys
local top = self.stack and self.stack:top()
if top and top.onGamepadPressed then
@@ -391,8 +358,6 @@ function Game:gamepadreleased(joystick, button)
end
function Game:gamepadaxis(joystick, axis, value)
-- past-deadzone only, so resting-stick drift can't hide the overlay
if math.abs(value) > 0.5 then TouchControls:noteGamepad() end
Input:gamepadaxis(joystick, axis, value)
end
@@ -402,12 +367,12 @@ end
-- state is worse than asking the player to re-press.
function Game:focus(f)
Input:reset()
TouchControls:reset()
TouchInput:reset()
end
function Game:visible(v)
Input:reset()
TouchControls:reset()
TouchInput:reset()
end
-- A disconnected/dropped controller can't send the button-up for whatever
@@ -415,19 +380,19 @@ end
-- flags it owned.
function Game:joystickremoved(joystick)
Input:reset()
TouchControls:joystickremoved()
TouchInput:reset()
end
function Game:touchpressed(id, x, y)
TouchControls:touchpressed(id, x, y)
TouchInput:touchpressed(id, x, y)
end
function Game:touchmoved(id, x, y)
TouchControls:touchmoved(id, x, y)
TouchInput:touchmoved(id, x, y)
end
function Game:touchreleased(id, x, y)
TouchControls:touchreleased(id, x, y)
TouchInput:touchreleased(id, x, y)
end
-- Point the loader's mod.save backing at this save's modData so per-mod
@@ -478,20 +443,12 @@ function Game:applyOptions(opts)
if Sound.applyOptions then Sound.applyOptions(opts) end
require("src.render.PaletteFX").applyOptions(opts)
require("src.render.Tilt").applyOptions(opts)
-- after Tilt, so a persisted world pipeline can switch the tilt level it
-- just restored back off (the two are mutually exclusive)
require("src.render.Pipelines").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.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)
@@ -503,13 +460,6 @@ function Game:restoreSave(loaded, recovered)
-- SaveData.load and skip on the format guard
local activeMods = self.modStatus and self.modStatus.loaded
SaveData.runMigrations(loaded, self.mods and self.mods.migrations, activeMods)
-- Issue #103: 0.1.11 softlocks left CONTINUE in HALL_OF_FAME with
-- lastOutdoor on Indigo. One-shot relocate + heal before validate.
if SaveData.needsPostGameRescue(loaded) then
SaveData.applyPostGameHome(loaded, self:bootConfig())
local Pokemon = require("src.pokemon.Pokemon")
for _, mon in ipairs(loaded.party or {}) do Pokemon.heal(mon) end
end
local modsDiff = SaveData.modsDiff(loaded, activeMods)
local report = SaveData.validate(loaded, self.data)
report.recovered = recovered
-16
View File
@@ -11,12 +11,7 @@ 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,
@@ -154,17 +149,6 @@ function Input:step()
self.pressQueue = {}
end
-- The on-screen touch overlay (src/core/TouchControls.lua) presses GB
-- buttons directly by name -- not through a keyboard alias -- so a player
-- rebind can never detach or shadow the overlay.
function Input:overlayPressed(btn)
press(self, btn, "touch:" .. btn)
end
function Input:overlayReleased(btn)
release(self, btn, "touch:" .. btn)
end
function Input:gamepadpressed(joystick, button)
local btn = self.padBindings[button]
if btn then
+9 -6
View File
@@ -360,13 +360,16 @@ local function chipAwaitingFirstBuffer()
and require("src.core.ChipAudio").awaitingFirstBuffer()
end
-- is a playOnce jingle still in flight? (AnimateHealingMachine's
-- .waitLoop2 / Mom heal / captain rub hold until MUSIC_PKMN_HEALED ends.)
-- pendingRestore stays set from playOnce until Music.update restores the
-- map theme, covering the threaded chip "empty QueueableSource" window
-- where Source:isPlaying is briefly false before the first buffer lands.
-- is a playOnce jingle still sounding? (AnimateHealingMachine's
-- .waitLoop2 holds the healing machine until MUSIC_PKMN_HEALED ends)
function Music.oneShotPlaying()
return state.pendingRestore == true
if not state.pendingRestore then return false end
-- threaded chip songs start silent for ~1 frame; that gap is not "over"
if chipAwaitingFirstBuffer() then return true end
local src = state.source
if not src then return false end
local ok, playing = pcall(src.isPlaying, src)
return ok and playing or false
end
function Music.restoreMap(data)
+5 -342
View File
@@ -17,7 +17,6 @@ local Runtime = require("src.mods.Runtime")
local Semver = require("src.mods.Semver")
local Boxes = require("src.pokemon.Boxes")
local Bag = require("src.inventory.Bag")
local Badges = require("src.inventory.Badges")
local GameVersion = require("src.core.GameVersion")
@@ -32,11 +31,10 @@ local OPTIONS_FILENAME = "options.lua"
-- Main / backup / staged-witness names for a version (defaults to the active
-- one). The backup is a rolling copy and .tmp is the staged-write witness;
-- load promotes either when the main file is missing or fails to parse.
-- Forward-declared here so saveFilename (just below) and every save/load
-- caller share the one upvalue; the body is filled in under "save slots"
-- once the options IO it depends on exists, because it now resolves the
-- ACTIVE slot for a version rather than the fixed flat name.
local saveNames
local function saveNames(version)
local main = "save" .. GameVersion.saveSuffix(version) .. ".lua"
return main, main .. ".bak", main .. ".tmp"
end
-- The main save filename for a version -- used by the title screen's
-- CONTINUE gate so it looks for the right game's save.
@@ -93,18 +91,6 @@ local function makePortableFs(dir)
os.remove(full(name))
return true
end,
createDirectory = function(name)
-- portable mode writes real files through io.*, which will not
-- create missing parent directories; mkdir the tree so a slot path
-- like "saves/red" exists before a write lands inside it
local osPath = full(name):gsub("/", SEP)
if SEP == "\\" then
os.execute('mkdir "' .. osPath .. '" 2>nul')
else
os.execute('mkdir -p "' .. osPath .. '" 2>/dev/null')
end
return true
end,
}
end
@@ -200,23 +186,14 @@ function SaveData.defaultOptions()
musicFilter = 0,
-- logic fast-forward multiplier; audio is unaffected (GameSpeed.lua)
speed = 1,
-- port display options (OptionsMenu / hotkeys 2/3/4/5)
-- port display options (OptionsMenu / hotkeys 2/3/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,
-- Per-pipeline display levels, keyed by render_pipelines id (see
-- src/render/Pipelines.lua). A level for a mod that is not installed
-- is kept rather than pruned, so re-enabling the mod restores the mode
-- the player left it in.
pipelines = {},
-- Native mod enablement is an installation option, not save-slot data.
-- Missing entries mean enabled so newly installed mods work by default.
mods = {},
@@ -302,264 +279,6 @@ function SaveData.loadOptions(fs)
return SaveData.mergeOptions(data)
end
-- ------- save slots
-- A version's playthroughs live in numbered slots under saves/<version>/;
-- the active slot is where in-game SAVE and CONTINUE land. The registry
-- (the ordered slot list plus which one is active) persists in options.lua
-- under options.saveSlots[version]; the active slot is also cached
-- process-wide (like GameVersion.current) so the hot saveNames path does
-- not re-read options every call. A false cache entry means "no slot in
-- use" and the flat legacy path (save.lua / save_blue.lua) is used, which
-- keeps a brand-new install and every pre-slots caller working unchanged.
local activeSlotCache = {} -- version -> slotId in use, or false when none
local slotsChecked = {} -- version -> true once resolved this process
local function slotDir(version) return "saves/" .. version end
local function slotNames(version, id)
local main = slotDir(version) .. "/" .. id .. ".lua"
return main, main .. ".bak", main .. ".tmp"
end
-- the pre-slots flat names a version always used (save.lua for Red,
-- save_blue.lua for Blue); still the destination before any slot exists
local function legacyNames(version)
local main = "save" .. GameVersion.saveSuffix(version) .. ".lua"
return main, main .. ".bak", main .. ".tmp"
end
-- Slot resolution is only meaningful for versions GameVersion actually knows
-- (red/blue). The launcher also renders a locked placeholder tab ("yellow")
-- that has no info entry and therefore no saveSuffix; resolving its legacy
-- names would index a nil info table and crash. Treat any unknown version as
-- having no slots so the slot APIs degrade to empty/no-op instead.
local function knownVersion(version)
return GameVersion.info(version) ~= nil
end
-- Create the parent directory of a slot path when the fs supports it.
-- love.filesystem.createDirectory makes the whole tree; the injected memfs
-- stub keys files by full path and exposes no such method, so this is a
-- no-op there.
local function ensureParentDir(fs, name)
local dir = name:match("^(.*)/[^/]+$")
if dir and fs.createDirectory then fs.createDirectory(dir) end
end
-- Decode a slot's save using the same recovery order load() uses -- main,
-- then the .tmp write-witness, then the .bak -- so a slot mid-crash still
-- summarizes. nil when nothing readable is present.
local function decodeSlot(fs, version, id)
local main, bak, tmp = slotNames(version, id)
local data = fs.getInfo(main) and SaveSerializer.decode(fs.read(main) or "")
if data then return data end
data = fs.getInfo(tmp) and SaveSerializer.decode(fs.read(tmp) or "")
if data then return data end
data = fs.getInfo(bak) and SaveSerializer.decode(fs.read(bak) or "")
return data or nil
end
-- One-time legacy consolidation: a pre-slots install has a flat save file
-- (+ .bak) and no saves/<version>/ registry. Copy both into slot1, verify
-- the copy reads back, then remove the originals and register slot1 as the
-- active slot. Returns the new slot id, or nil when there is nothing to
-- migrate or the copy could not be verified (originals left in place so no
-- data is ever lost to a failed move).
local function tryMigrateLegacy(version, fs)
local lmain, lbak, ltmp = legacyNames(version)
local mainBody = fs.getInfo(lmain) and fs.read(lmain)
local bakBody = fs.getInfo(lbak) and fs.read(lbak)
if not (mainBody or bakBody) then return nil end
local id = "slot1"
local dmain, dbak = slotNames(version, id)
ensureParentDir(fs, dmain)
if mainBody then fs.write(dmain, mainBody) end
if bakBody then fs.write(dbak, bakBody) end
-- refuse to delete the originals unless the new slot is loadable (from
-- the main copy or, failing that, the backup)
if not decodeSlot(fs, version, id) then return nil end
remove(fs, lmain)
remove(fs, lbak)
remove(fs, ltmp)
local opts = SaveData.loadOptions(fs)
opts.saveSlots = opts.saveSlots or {}
opts.saveSlots[version] = { list = { id }, active = id }
SaveData.saveOptions(opts, fs)
return id
end
-- Resolve (once per version per process) which slot in-game saves use: an
-- existing registry wins; otherwise a lazy legacy migration may create
-- slot1; otherwise false, meaning the flat legacy path.
local function ensureVersionSlots(version, fs)
if slotsChecked[version] then return end
slotsChecked[version] = true
if not knownVersion(version) then
activeSlotCache[version] = false
return
end
local opts = SaveData.loadOptions(fs)
local reg = opts.saveSlots and opts.saveSlots[version]
if reg and type(reg.list) == "table" and #reg.list > 0 then
activeSlotCache[version] = reg.active or reg.list[1]
return
end
activeSlotCache[version] = tryMigrateLegacy(version, fs) or false
end
-- (body for the forward-declared saveNames.) Resolves the ACTIVE slot for
-- the version, falling back to the flat legacy names when no slot is in use.
function saveNames(version)
version = version or GameVersion.get()
local fs = persistFs(nil)
ensureVersionSlots(version, fs)
local slot = activeSlotCache[version]
if slot then return slotNames(version, slot) end
return legacyNames(version)
end
-- Pure extraction of the launcher's per-slot summary from a decoded save,
-- factored out so it is unit-testable with no filesystem: the player name
-- (nil for an empty slot) and { badges, timeText, dexCount } -- the same
-- fields the title screen's ContinueInfo derives. Badges resolve against
-- the vanilla gym list (launcher has no loaded Data), which is what the
-- flat launcher meta line needs.
function SaveData.slotSummary(save)
if type(save) ~= "table" then return nil, nil end
local name = save.player and save.player.name or nil
local dexCount = 0
for _ in pairs((save.pokedex and save.pokedex.owned) or {}) do
dexCount = dexCount + 1
end
local t = math.floor(save.playTime or 0)
local timeText = ("%d:%02d"):format(math.floor(t / 3600),
math.floor(t / 60) % 60)
return name, {
badges = Badges.count(nil, save),
timeText = timeText,
dexCount = dexCount,
}
end
-- Slots visible to the launcher: every registered slot for a version, each
-- with whether it holds a save and the cheap summary above. A fresh
-- install with nothing registered returns an empty array; a legacy install
-- is migrated to slot1 first.
function SaveData.listSlots(version)
version = version or GameVersion.get()
if not knownVersion(version) then return {} end
local fs = persistFs(nil)
ensureVersionSlots(version, fs)
local opts = SaveData.loadOptions(fs)
local reg = opts.saveSlots and opts.saveSlots[version]
local list = (reg and reg.list) or {}
local out = {}
for _, id in ipairs(list) do
local save = decodeSlot(fs, version, id)
local name, meta = SaveData.slotSummary(save)
out[#out + 1] = { id = id, exists = save ~= nil, name = name, meta = meta }
end
return out
end
-- Point the active slot at slotId (registering it if new) and persist the
-- choice to options.lua; also update the process-global cache so the very
-- next save/load lands in the chosen slot.
function SaveData.setActiveSlot(version, slotId)
version = version or GameVersion.get()
if not knownVersion(version) then return nil end
local fs = persistFs(nil)
local opts = SaveData.loadOptions(fs)
opts.saveSlots = opts.saveSlots or {}
local reg = opts.saveSlots[version] or { list = {}, active = nil }
local found = false
for _, id in ipairs(reg.list) do
if id == slotId then found = true break end
end
if not found then reg.list[#reg.list + 1] = slotId end
reg.active = slotId
opts.saveSlots[version] = reg
SaveData.saveOptions(opts, fs)
slotsChecked[version] = true
activeSlotCache[version] = slotId
return slotId
end
-- Register a new empty slot for the version and return its id. Does NOT
-- write a save file and does NOT change the active slot: an empty slot
-- means the title screen offers NEW GAME only. Ids are "slot%d+",
-- allocated one past the highest existing number so a reused id can never
-- collide with a lingering file.
function SaveData.createSlot(version)
version = version or GameVersion.get()
if not knownVersion(version) then return nil end
local fs = persistFs(nil)
ensureVersionSlots(version, fs)
local opts = SaveData.loadOptions(fs)
opts.saveSlots = opts.saveSlots or {}
local reg = opts.saveSlots[version] or { list = {}, active = nil }
local maxN = 0
for _, id in ipairs(reg.list) do
local n = tonumber(tostring(id):match("^slot(%d+)$"))
if n and n > maxN then maxN = n end
end
local id = "slot" .. (maxN + 1)
reg.list[#reg.list + 1] = id
opts.saveSlots[version] = reg
SaveData.saveOptions(opts, fs)
return id
end
-- The active slot id in use for a version (resolved once per process like
-- saveNames does), or nil when none is registered and the flat legacy path is
-- in use. Public so the launcher's save Import/Export glue can name an export
-- after the slot it came from without reaching into the private cache.
function SaveData.activeSlot(version)
version = version or GameVersion.get()
if not knownVersion(version) then return nil end
local fs = persistFs(nil)
ensureVersionSlots(version, fs)
return activeSlotCache[version] or nil
end
-- Write saveTable into an existing slot's file (SaveSerializer.encode), through
-- the same fs seam every other save/load call uses (so portable mode keeps
-- working) and the same .tmp-witness / .bak recovery discipline SaveData.save
-- uses for the flat path. Used by the launcher's save-import glue, which has
-- already registered the slot via createSlot but written no bytes yet; unlike
-- SaveData.save this targets a specific slot and never rebuilds meta or touches
-- options. Returns true, or false + an error string on a failed write.
function SaveData.writeSlot(version, slotId, saveTable)
version = version or GameVersion.get()
if not knownVersion(version) then return false, "unknown version" end
if type(slotId) ~= "string" then return false, "missing slot id" end
if type(saveTable) ~= "table" then return false, "missing save table" end
local main, bak, tmp = slotNames(version, slotId)
local encoded = SaveSerializer.encode(saveTable)
local fs = persistFs(nil)
ensureParentDir(fs, main)
if fs.getInfo(main) then
local prev = fs.read(main)
if prev then fs.write(bak, prev) end
end
local ok, err = fs.write(tmp, encoded)
if not ok then return false, err end
remove(fs, main)
ok, err = fs.write(main, encoded)
if not ok then return false, err end
remove(fs, tmp)
return true
end
-- Test seam: drop the process-global slot cache so a suite can exercise
-- migration/resolution against a freshly injected filesystem. Unused by
-- the game, which resolves each version exactly once per boot.
function SaveData.resetSlotState()
for k in pairs(activeSlotCache) do activeSlotCache[k] = nil end
for k in pairs(slotsChecked) do slotsChecked[k] = nil end
end
-- ------- meta
-- the version/engine/mod-set stamp every v2 save carries; mods is the
@@ -759,25 +478,6 @@ SaveData.addCoreMigration(2, function(save)
end
end)
-- #131 / follow-up to #50: Game Corner poster grunt used to stay on the
-- floor after defeat (defeatedTrainers only). The #50 script now hides
-- him via objectToggles, but saves that already beat him never got the
-- toggle -- he still blocks the hideout switch. from=3 so every pre-4
-- save reconciles once, then is skipped after re-stamp.
SaveData.addCoreMigration(3, function(save)
local defeated = save.defeatedTrainers
if not defeated or not defeated["GAME_CORNER_obj_11"] then return end
save.objectToggles = save.objectToggles or {}
local mapToggles = save.objectToggles.GAME_CORNER
if not mapToggles then
mapToggles = {}
save.objectToggles.GAME_CORNER = mapToggles
end
if mapToggles.GAMECORNER_ROCKET ~= false then
mapToggles.GAMECORNER_ROCKET = false
end
end)
-- ------- write
-- Game progress only; options are written separately via saveOptions.
@@ -802,9 +502,6 @@ function SaveData.save(data, mods)
end
local encoded = SaveSerializer.encode(gameOnly)
local fs = persistFs(nil)
-- the active slot may live in saves/<version>/, which must exist before
-- the .tmp/.bak/main writes land (a no-op for the flat legacy path)
ensureParentDir(fs, FILENAME)
if fs.getInfo(FILENAME) then
local prev = fs.read(FILENAME)
if prev then fs.write(BACKUP_FILENAME, prev) end
@@ -1116,36 +813,6 @@ function SaveData.defaultHeal(boot)
return { map = map, x = boot.startX or 3, y = boot.startY or 6 }
end
-- Post-credits home (issue #103). pokered left the player in HALL_OF_FAME
-- after jp Init; this port places CONTINUE at the NewGameWarp bedroom and
-- retargets LAST_MAP exits (Red's house mats) at the heal-point town so
-- leaving the house does not dump the player back at Indigo Plateau.
-- Marks postGameHomeOk so a later intentional HoF save is not relocated.
function SaveData.applyPostGameHome(save, boot)
boot = type(boot) == "table" and boot or {}
local heal = SaveData.defaultHeal(boot)
save.lastHeal = { map = heal.map, x = heal.x, y = heal.y }
save.lastOutdoor = { id = heal.map, x = heal.x, y = heal.y }
save.player = save.player or {}
save.player.map = boot.startMap or "REDS_HOUSE_2F"
save.player.x = boot.startX or 3
save.player.y = boot.startY or 6
save.player.facing = boot.startFacing or "down"
save.postGameHomeOk = true
return heal
end
-- Softlocked 0.1.11 saves: still standing in HALL_OF_FAME after credits,
-- with lastOutdoor on Indigo. One-shot rescue on CONTINUE.
function SaveData.needsPostGameRescue(save)
if not (save and save.player and save.player.map == "HALL_OF_FAME") then
return false
end
if save.postGameHomeOk then return false end
local hof = save.hallOfFame
return type(hof) == "table" and #hof > 0
end
function SaveData.newGame(boot)
boot = type(boot) == "table" and boot or {}
local map = boot.startMap or "REDS_HOUSE_2F"
@@ -1169,10 +836,6 @@ function SaveData.newGame(boot)
},
flags = {},
inventory = {},
-- Vanilla Gen1 seeds one Potion in the player's item PC
-- (wBoxItems / players_pc.asm); existing saves keep whatever they
-- already have — this only applies to New Game.
pcItems = { POTION = 1 },
party = {},
box = {},
money = boot.startMoney or 3000,
-287
View File
@@ -1,287 +0,0 @@
-- On-screen touch controls: a visible d-pad, A, B, START and SELECT drawn
-- over the finished frame (art: Xelu's CC0 controller prompts, see
-- assets/touch/README.md). Replaces the old touch gesture recognizer:
-- every control is a real button under the thumb, so there is no
-- tap-vs-swipe classification, no deferred-A double-tap window, and no
-- added latency.
--
-- Mobile only, and only while no controller is being used: the overlay
-- shows on Android/iOS, disappears the moment a gamepad button or stick
-- is used, and comes back on the next screen touch (Game routes both
-- events here). POKEPORT_TOUCH=1 forces it on for desktop testing
-- (main.lua then drives it with the mouse); POKEPORT_TOUCH=0 forces it
-- off everywhere.
--
-- Controls press GB buttons through Input:overlayPressed/Released -- their
-- own input source, not a keyboard alias -- so a held overlay direction
-- merges cleanly with a keyboard key or stick holding the same button,
-- and a player rebind can never detach the overlay.
local Input = require("src.core.Input")
local TouchControls = {}
-- idle vs pressed overlay opacity
local ALPHA = 0.65
local ALPHA_PRESSED = 0.95
-- translucent backing disc behind each control: the prompt art is dark
-- gray, so without it the controls melt into dark map areas
local BACK = 0.24
local BACK_PRESSED = 0.38
-- neutral zone at the d-pad center, as a fraction of the d-pad width;
-- inside it no direction is held (keeps a resting thumb from jittering)
local DPAD_DEAD = 0.16
-- hit slop: how far past the visible edge a press still counts, as a
-- multiplier on the control's half-width. START/SELECT get more because
-- the glyphs are small.
local SLOP = { a = 1.3, b = 1.3, start = 1.4, select = 1.4 }
local BUTTONS = { "a", "b", "start", "select" }
local IMAGES = {
dpad = "assets/touch/dpad.png",
dpad_up = "assets/touch/dpad_up.png",
dpad_down = "assets/touch/dpad_down.png",
dpad_left = "assets/touch/dpad_left.png",
dpad_right = "assets/touch/dpad_right.png",
a = "assets/touch/a.png",
b = "assets/touch/b.png",
start = "assets/touch/start.png",
select = "assets/touch/select.png",
}
local function wantsOverlay()
local env = os.getenv("POKEPORT_TOUCH")
if env == "1" then return true end
if env == "0" then return false end
local osName = love.system and love.system.getOS and love.system.getOS()
return osName == "Android" or osName == "iOS"
end
function TouchControls:init()
self.active = wantsOverlay()
self.controllerHidden = false
self.touches = {}
-- per-GB-button owner count: two fingers on A must not double-press it,
-- and lifting one of them must not release the other's hold
self.held = {}
self.dpadTouch = nil
self.layoutW, self.layoutH = nil, nil
self.img = nil
if not self.active then return end
-- soft-fail: a missing/corrupt PNG must never block boot; the overlay
-- stays off and keyboard/controller play still works
local img = {}
for name, path in pairs(IMAGES) do
local ok, im = pcall(love.graphics.newImage, path)
if not ok then
img = nil
break
end
-- smooth UI icons; the global default filter is nearest for GB pixels
im:setFilter("linear", "linear")
img[name] = im
end
self.img = img
end
function TouchControls:visible()
return self.active and self.img ~= nil and not self.controllerHidden
end
-- Layout in LOVE units (density-independent on mobile), recomputed when
-- the window size changes (rotation, resize). D-pad bottom-left, B/A
-- bottom-right with A above B (the Game Boy diagonal), START/SELECT
-- flanking the bottom center.
function TouchControls:layout()
local ww, wh = love.graphics.getDimensions()
if self.layoutW == ww and self.layoutH == wh then return self.L end
self.layoutW, self.layoutH = ww, wh
local short = math.min(ww, wh)
-- ~a third of the short edge, capped so tablets don't get a dinner plate
local dpadW = math.min(180, short * 0.34)
local abW = dpadW * 0.46
local ssW = dpadW * 0.30
local margin = dpadW * 0.12
-- START/SELECT hug the bottom center: on a narrow portrait phone the
-- d-pad and B leave little room, so a tight pair is what keeps them off
-- the neighboring controls
self.L = {
dpad = { cx = margin + dpadW / 2, cy = wh - margin - dpadW / 2, w = dpadW },
a = { cx = ww - margin - abW * 0.55, cy = wh - margin - abW * 1.75, w = abW },
b = { cx = ww - margin - abW * 1.60, cy = wh - margin - abW * 0.55, w = abW },
start = { cx = ww / 2 + ssW * 0.60, cy = wh - margin - ssW * 0.95, w = ssW },
select = { cx = ww / 2 - ssW * 0.60, cy = wh - margin - ssW * 0.95, w = ssW },
}
local fontSize = math.max(8, math.floor(ssW * 0.26))
if not self.labelFont or self.fontSize ~= fontSize then
self.fontSize = fontSize
self.labelFont = love.graphics.newFont(fontSize)
end
return self.L
end
local function inCircle(zone, x, y, slop)
local r = zone.w * 0.5 * slop
local dx, dy = x - zone.cx, y - zone.cy
return dx * dx + dy * dy <= r * r
end
local function dpadDir(zone, x, y)
local dx, dy = x - zone.cx, y - zone.cy
local dead = zone.w * DPAD_DEAD
if math.abs(dx) < dead and math.abs(dy) < dead then return nil end
if math.abs(dx) >= math.abs(dy) then
return dx > 0 and "right" or "left"
end
return dy > 0 and "down" or "up"
end
local function pressBtn(self, btn)
local n = (self.held[btn] or 0) + 1
self.held[btn] = n
if n == 1 then Input:overlayPressed(btn) end
end
local function releaseBtn(self, btn)
local n = self.held[btn]
if not n then return end
if n > 1 then
self.held[btn] = n - 1
else
self.held[btn] = nil
Input:overlayReleased(btn)
end
end
-- the d-pad touch's held direction changed (or ended): swap the GB hold
local function setDpad(self, touch, dir)
if touch.dir == dir then return end
if touch.dir then releaseBtn(self, touch.dir) end
touch.dir = dir
if dir then pressBtn(self, dir) end
end
function TouchControls:touchpressed(id, x, y)
if not (self.active and self.img) then return end
-- a controller hid the overlay; the first touch only brings it back
if self.controllerHidden then
self.controllerHidden = false
return
end
local L = self:layout()
for _, btn in ipairs(BUTTONS) do
if inCircle(L[btn], x, y, SLOP[btn]) then
self.touches[id] = { control = btn }
pressBtn(self, btn)
return
end
end
-- square hit zone a bit past the cross art; one owning finger at a time
local dz = L.dpad
local half = dz.w * 0.65
if not self.dpadTouch
and math.abs(x - dz.cx) <= half and math.abs(y - dz.cy) <= half then
self.dpadTouch = id
local touch = { control = "dpad", dir = nil }
self.touches[id] = touch
setDpad(self, touch, dpadDir(dz, x, y))
end
end
function TouchControls:touchmoved(id, x, y)
local touch = self.touches[id]
-- only the d-pad tracks movement (slide between directions without
-- lifting); buttons hold until release wherever the finger wanders
if not touch or touch.control ~= "dpad" then return end
setDpad(self, touch, dpadDir(self:layout().dpad, x, y))
end
function TouchControls:touchreleased(id, x, y)
local touch = self.touches[id]
if not touch then return end
self.touches[id] = nil
if touch.control == "dpad" then
setDpad(self, touch, nil)
self.dpadTouch = nil
else
releaseBtn(self, touch.control)
end
end
-- LÖVE has no touchcancelled: a touch interrupted by the OS (app
-- backgrounded, a system gesture stealing the finger) never fires
-- touchreleased and would strand its button held forever. Called from
-- Game alongside Input:reset() on focus/visibility loss.
function TouchControls:reset()
for btn in pairs(self.held) do
Input:overlayReleased(btn)
end
self.held = {}
self.touches = {}
self.dpadTouch = nil
end
-- a gamepad is being used: hide the overlay (dropping anything it held)
-- until the next screen touch asks for it back
function TouchControls:noteGamepad()
if not self.active or self.controllerHidden then return end
self.controllerHidden = true
self:reset()
end
-- last controller unplugged: show the overlay again immediately instead
-- of requiring a blind first tap
function TouchControls:joystickremoved()
self:reset()
if love.joystick and love.joystick.getJoystickCount
and love.joystick.getJoystickCount() == 0 then
self.controllerHidden = false
end
end
local function drawIcon(img, zone, pressed)
love.graphics.setColor(1, 1, 1, pressed and BACK_PRESSED or BACK)
love.graphics.circle("fill", zone.cx, zone.cy, zone.w * 0.58)
local scale = zone.w / img:getWidth()
love.graphics.setColor(1, 1, 1, pressed and ALPHA_PRESSED or ALPHA)
love.graphics.draw(img, zone.cx - zone.w / 2,
zone.cy - img:getHeight() * scale / 2, 0, scale, scale)
end
-- Screen-space, called by Game:draw after Renderer:endFrame so the
-- overlay rides on top of everything (world, UI, CRT/GBC FX included).
function TouchControls:draw()
if not self:visible() then return end
local L = self:layout()
love.graphics.push("all")
love.graphics.origin()
local dpadTouch = self.dpadTouch and self.touches[self.dpadTouch]
local dir = dpadTouch and dpadTouch.dir
drawIcon(dir and self.img["dpad_" .. dir] or self.img.dpad, L.dpad,
dir ~= nil)
for _, btn in ipairs(BUTTONS) do
drawIcon(self.img[btn], L[btn], self.held[btn] ~= nil)
end
-- the +/- glyphs alone don't say which is which; shadowed so the text
-- reads on both the black letterbox and battle's white one
love.graphics.setFont(self.labelFont)
local ly = L.start.cy + L.start.w * 0.66
local function label(text, zone)
local w = self.labelFont:getWidth(text)
love.graphics.setColor(0, 0, 0, 0.6)
love.graphics.print(text, zone.cx - w / 2 + 1, ly + 1)
love.graphics.setColor(1, 1, 1, ALPHA + 0.2)
love.graphics.print(text, zone.cx - w / 2, ly)
end
label("START", L.start)
label("SELECT", L.select)
love.graphics.pop()
end
return TouchControls
+297
View File
@@ -0,0 +1,297 @@
-- Touch gesture recognizer → virtual keyboard keys for Input.lua.
--
-- Deferred-tap tradeoff: A fires only after DOUBLE_TAP_MS with no second
-- tap. That adds ~280ms latency to every A press so a double-tap can be
-- remapped to START instead of A-then-START. Gen 1 has no frame-perfect
-- input needs, so the latency is acceptable.
--
-- Select = two-finger tap (open Q1 in docs/mobile-plan.md): when a second distinct
-- touch ID lands while another short, low-movement touch is active, fire
-- SELECT (press + one-frame auto-release).
local Input = require("src.core.Input")
local TouchInput = {}
local function dpiScale()
if love and love.window then
if love.window.getDPIScale then
return love.window.getDPIScale()
end
if love.window.toPixels then
return love.window.toPixels(1)
end
end
return 1
end
-- Tunables (device-DPI-scaled where noted). Adjust after on-device testing.
local SWIPE_THRESHOLD_PX = 14
local EDGE_PX = 24
local EDGE_SWIPE_PX = 24
local DOUBLE_TAP_MS = 280
local TAP_MAX_MS = 320
local TAP_MAX_MOVE_PX = 12
local function scaled(px)
return px * dpiScale()
end
local DIRS = { up = true, down = true, left = true, right = true }
-- Virtual keys Input:keypressed looks up in KEYBOARD bindings (not button names).
local KEY = {
up = "up",
down = "down",
left = "left",
right = "right",
a = "z",
b = "x",
start = "escape",
select = "rshift",
}
local function nowMs()
return love.timer.getTime() * 1000
end
local function dominantDir(dx, dy)
if math.abs(dx) >= math.abs(dy) then
return dx > 0 and "right" or "left"
end
return dy > 0 and "down" or "up"
end
function TouchInput:init()
self.touches = {}
self.pendingA = nil -- { deadlineMs = number }
-- Edge pulses (B / START / SELECT / deferred-A): press now, release on a
-- later update so FixedStep can consume wasPressed first.
-- `armed` = pressed during events since last update; promoted to
-- `autoRelease` at the start of update (released on the *following* update).
-- Deferred-A fired inside update goes straight into `autoRelease`.
self.armed = {}
self.autoRelease = {}
self.selectFired = false -- one SELECT per two-finger gesture cluster
end
-- LÖVE has no touchcancelled event, so a touch interrupted by the OS (app
-- backgrounded mid-touch, a system gesture stealing the finger) never fires
-- touchreleased and would otherwise strand its direction held forever.
-- Called from Game alongside Input:reset() on focus/visibility loss.
function TouchInput:reset()
for _, touch in pairs(self.touches) do
releaseDir(self, touch)
end
self.touches = {}
self.pendingA = nil
self.armed = {}
self.autoRelease = {}
self.selectFired = false
end
local function pulse(self, key)
Input:keypressed(key)
self.armed[#self.armed + 1] = key
end
local function pulseInUpdate(self, key)
Input:keypressed(key)
self.autoRelease[#self.autoRelease + 1] = key
end
local function releaseDir(self, touch)
if touch.dir and DIRS[touch.dir] then
Input:keyreleased(KEY[touch.dir])
touch.dir = nil
end
end
local function pressDir(self, touch, dir)
if touch.dir == dir then return end
releaseDir(self, touch)
touch.dir = dir
Input:keypressed(KEY[dir])
end
local function totalMove(touch, x, y)
local dx = x - touch.x0
local dy = y - touch.y0
return math.abs(dx), math.abs(dy), dx, dy
end
local function isTapLike(touch, x, y, tMs)
local ax, ay = totalMove(touch, x, y)
local elapsed = tMs - touch.t0
return elapsed <= TAP_MAX_MS
and ax <= scaled(TAP_MAX_MOVE_PX)
and ay <= scaled(TAP_MAX_MOVE_PX)
and not touch.classified
end
local function countActive(self)
local n = 0
for _ in pairs(self.touches) do n = n + 1 end
return n
end
local function tryTwoFingerSelect(self, tMs)
if self.selectFired then return false end
local ids = {}
for id, touch in pairs(self.touches) do
if isTapLike(touch, touch.x, touch.y, tMs) then
ids[#ids + 1] = id
end
end
if #ids < 2 then return false end
self.selectFired = true
self.pendingA = nil
for _, id in ipairs(ids) do
local touch = self.touches[id]
touch.classified = true
touch.consumed = true
releaseDir(self, touch)
end
pulse(self, KEY.select)
return true
end
function TouchInput:update(dt)
-- Releases armed on a prior update (FixedStep already saw wasPressed).
for i = 1, #self.autoRelease do
Input:keyreleased(self.autoRelease[i])
end
-- Promote event-phase pulses from since the last update; they release next time.
self.autoRelease = self.armed
self.armed = {}
local tMs = nowMs()
-- Deferred A: fire once the double-tap window closes with no second tap.
-- Queued into autoRelease so the next update clears hold after this FixedStep.
if self.pendingA and tMs >= self.pendingA.deadlineMs then
self.pendingA = nil
pulseInUpdate(self, KEY.a)
end
-- Keep two-finger SELECT detection live while both fingers stay down.
if countActive(self) >= 2 then
tryTwoFingerSelect(self, tMs)
elseif countActive(self) == 0 then
self.selectFired = false
end
end
function TouchInput:touchpressed(id, x, y)
local tMs = nowMs()
-- Second tap inside the deferred-A window → START instead of A.
if self.pendingA and tMs < self.pendingA.deadlineMs then
self.pendingA = nil
pulse(self, KEY.start)
-- Still record this touch so a lingering finger doesn't become a stray swipe.
self.touches[id] = {
x0 = x, y0 = y, x = x, y = y, t0 = tMs,
edge = x < scaled(EDGE_PX),
classified = true,
consumed = true,
dir = nil,
}
return
end
self.touches[id] = {
x0 = x, y0 = y, x = x, y = y, t0 = tMs,
edge = x < scaled(EDGE_PX),
classified = false,
consumed = false,
dir = nil,
}
if countActive(self) >= 2 then
tryTwoFingerSelect(self, tMs)
end
end
function TouchInput:touchmoved(id, x, y)
local touch = self.touches[id]
if not touch or touch.consumed then return end
touch.x, touch.y = x, y
local ax, ay, dx, dy = totalMove(touch, x, y)
local swipeTh = scaled(SWIPE_THRESHOLD_PX)
-- Edge-origin swipes become B on release; never promote to d-pad.
if touch.edge then
if ax >= scaled(EDGE_SWIPE_PX) or ay >= scaled(EDGE_SWIPE_PX) then
touch.classified = true
end
return
end
if not touch.classified then
if ax < swipeTh and ay < swipeTh then return end
touch.classified = true
pressDir(self, touch, dominantDir(dx, dy))
return
end
-- Mid-hold direction change: release old, press new (dominant axis).
if touch.dir then
local fromLastX = x - touch.x0
local fromLastY = y - touch.y0
-- Re-evaluate from origin so small jitter doesn't flip; require threshold
-- distance from origin along the new dominant axis.
if math.abs(fromLastX) >= swipeTh or math.abs(fromLastY) >= swipeTh then
local newDir = dominantDir(fromLastX, fromLastY)
if newDir ~= touch.dir then
pressDir(self, touch, newDir)
end
end
end
end
function TouchInput:touchreleased(id, x, y)
local touch = self.touches[id]
if not touch then return end
local tMs = nowMs()
touch.x, touch.y = x, y
local ax, ay = totalMove(touch, x, y)
if touch.dir then
releaseDir(self, touch)
self.touches[id] = nil
if countActive(self) == 0 then self.selectFired = false end
return
end
if touch.consumed then
self.touches[id] = nil
if countActive(self) == 0 then self.selectFired = false end
return
end
-- Left-edge B: origin in EDGE_PX strip and movement past EDGE_SWIPE_PX
-- (or already marked classified while moving). Prefer over d-pad / tap.
if touch.edge then
local edgeTh = scaled(EDGE_SWIPE_PX)
if touch.classified or ax >= edgeTh or ay >= edgeTh then
pulse(self, KEY.b)
self.touches[id] = nil
if countActive(self) == 0 then self.selectFired = false end
return
end
end
-- Plain tap → defer A (or it was already classified as swipe without dir, ignore).
if isTapLike(touch, x, y, tMs) then
self.pendingA = { deadlineMs = tMs + DOUBLE_TAP_MS }
end
self.touches[id] = nil
if countActive(self) == 0 then self.selectFired = false end
end
return TouchInput
+3 -11
View File
@@ -4,22 +4,14 @@
-- tests.
local Version = {
engine = "0.0.0-dev", -- game/engine release (semver). Repo default is the
-- "-dev" placeholder; CI stamps the real X.Y.Z into
-- the packed game.love only, never the working tree.
shell = 1, -- native-shell contract this build implements
minShell = 1, -- lowest shell contract that can RUN this payload.
-- Bump only when a payload needs a newer native
-- binary (e.g. a LOVE version bump); an older shell
-- refuses to chainload a payload whose minShell
-- exceeds the shell it provides.
engine = "1.0.0", -- game/engine release (semver triple)
modApi = 2, -- mod API major (manifest `api`)
linkProtocol = 2, -- link handshake wire version (Handshake.PROTOCOL)
saveFormat = 4, -- save.meta.format
saveFormat = 3, -- save.meta.format
cache = "rom-cache-v5", -- ROM import cache generation (RomImporter marker)
}
-- "gen1recomp v0.0.0-dev" (or the stamped release version in shipped builds)
-- "gen1recomp v1.0.0"
function Version.title(base)
return (base or "gen1recomp")
.. " v" .. Version.engine
-2
View File
@@ -375,8 +375,6 @@ function RomExtractor:extractMaps()
}
self:tick("Maps", mapIndex, #keys)
end
-- keep the extract ROM-faithful; Data:seedDefaults applies the #189
-- S.S. Anne 1F cabin reorder at load time
self:write("maps", out)
return out
end
+292 -1531
View File
File diff suppressed because it is too large Load Diff
-112
View File
@@ -1,112 +0,0 @@
-- SaveFileIO -- the launcher's glue between a raw Gen1 .sav battery image and
-- this project's save slots. Keeps RomImporter lean: the SAVE FILES card just
-- calls importToSlot / exportActiveSlot and renders the {ok, result} outcome.
--
-- Import reads bytes (an absolute picker path, a dropped LOVE file, or raw
-- bytes), runs them through SaveConvert.importSav (32768-byte + checksum
-- validated), then registers a fresh slot, writes it, and makes it active.
-- Export loads the active slot, encodes it back to a 32768-byte SRAM image, and
-- drops it in the save directory's exports/ folder, returning the absolute path
-- so the launcher can offer an "open folder" affordance.
--
-- Every failure returns false + a friendly one-line message (never raises), so
-- the card can surface it as a red notice line rather than crashing.
local SaveConvert = require("src.save_convert.SaveConvert")
local SaveData = require("src.core.SaveData")
local GameVersion = require("src.core.GameVersion")
local SaveFileIO = {}
local SAVE_SIZE = SaveConvert.SAVE_SIZE
-- Resolve raw save bytes from whatever the launcher hands us:
-- * a LOVE DroppedFile (a table/userdata with :open/:read/:getSize), read the
-- way RomImporter reads a dropped ROM;
-- * a raw 32768-byte string (the tests and the in-memory path) used as-is;
-- * any other string treated as an absolute picker path opened with io.open.
-- A picker path is never 32768 bytes long, so the length test disambiguates it
-- from a raw image cleanly. Returns bytes, or nil + an error string.
local function readSource(source)
local t = type(source)
if t == "table" or t == "userdata" then
if type(source.read) ~= "function" then
return nil, "that file could not be read"
end
local ok, openErr = source:open("r")
if not ok then return nil, "could not open the dropped file: " .. tostring(openErr) end
local data, readErr = source:read(source:getSize())
source:close()
if not data then return nil, "could not read the dropped file: " .. tostring(readErr) end
return data
end
if t ~= "string" then
return nil, "no save file was provided"
end
if #source == SAVE_SIZE then
return source
end
local f, openErr = io.open(source, "rb")
if not f then return nil, "could not read the save file: " .. tostring(openErr) end
local data = f:read("*a")
f:close()
if type(data) ~= "string" then return nil, "the save file was empty" end
return data
end
-- importToSlot(source, version) -> ok, slotIdOrErr
-- source: an absolute path, a LOVE DroppedFile, or raw 32768 bytes. On success
-- registers a new slot for the version, writes the imported save into it, makes
-- it the active slot, and returns true + the new slot id. On any failure
-- returns false + a friendly message.
function SaveFileIO.importToSlot(source, version)
version = version or GameVersion.get()
local bytes, readErr = readSource(source)
if not bytes then return false, readErr end
if #bytes ~= SAVE_SIZE then
return false, ("A save file must be %d bytes (32 KB); this one is %d.")
:format(SAVE_SIZE, #bytes)
end
local save, convertErr = SaveConvert.importSav(bytes, version)
if not save then return false, convertErr end
-- Tag the game version and normalize the meta stamp: SaveConvert leaves
-- meta.format = "gen1_import", but SaveData.load's migration pass compares
-- the format numerically, so re-stamp it to the current format (the imported
-- table is already current-shaped, so no migration is skipped by doing so).
save.version = version
save.meta = SaveData.buildMeta(nil, save.meta)
local slotId = SaveData.createSlot(version)
if not slotId then return false, "this game has no save slots to import into" end
local ok, writeErr = SaveData.writeSlot(version, slotId, save)
if not ok then
return false, "could not write the imported save: " .. tostring(writeErr)
end
SaveData.setActiveSlot(version, slotId)
return true, slotId
end
-- exportActiveSlot(version) -> ok, pathOrErr
-- Loads the version's active slot save (SaveData.load semantics), encodes it
-- back to a 32768-byte SRAM image, and writes it to
-- exports/gen1recomp-<version>-<slotId>.sav in the save directory (created if
-- absent). Returns true + the absolute path on success, false + a friendly
-- message otherwise.
function SaveFileIO.exportActiveSlot(version)
version = version or GameVersion.get()
local save = SaveData.load(version)
if not save then return false, "this game has no save to export yet" end
local bytes, exportErr = SaveConvert.exportSav(save)
if not bytes then return false, exportErr end
local slotId = SaveData.activeSlot(version) or "save"
local fs = love and love.filesystem
if not (fs and fs.write) then return false, "no filesystem available to export to" end
if fs.createDirectory then fs.createDirectory("exports") end
local rel = ("exports/gen1recomp-%s-%s.sav"):format(version, slotId)
local ok, writeErr = fs.write(rel, bytes)
if not ok then return false, "could not write the export: " .. tostring(writeErr) end
local base = fs.getSaveDirectory and fs.getSaveDirectory() or ""
if base ~= "" then return true, base .. "/" .. rel end
return true, rel
end
return SaveFileIO
+3 -4
View File
@@ -114,11 +114,10 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
local itemDef = data.items[itemId]
local name = itemDef and itemDef.name or itemId
-- ItemUseVitamin / ItemUsePPUp / ItemUseEvoStone / ItemUseCoinCase /
-- ItemUseTMHM all refuse mid-battle (jp nz, ItemUseNotTime)
-- ItemUseVitamin / ItemUsePPUp / ItemUseEvoStone / ItemUseCoinCase
-- all refuse mid-battle (jp nz, ItemUseNotTime)
if battle and (VITAMINS[itemId] or STONES[itemId] or itemId == "PP_UP"
or itemId == "RARE_CANDY" or itemId == "COIN_CASE"
or (itemDef and itemDef.machine)) then
or itemId == "RARE_CANDY" or itemId == "COIN_CASE") then
return "failed", { "OAK: " .. save.player.name
.. "!\nThis isn't the\ntime to use that!" }
end
-44
View File
@@ -1,44 +0,0 @@
-- 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,17 +67,6 @@ 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)
+57 -387
View File
@@ -15,7 +15,6 @@
-- 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")
@@ -37,45 +36,18 @@ local function makeRng(seed)
end
end
-- Battlers go through BattleState.makeBattler so pics get the same
-- Assets.resolve + SGB/GBC palette + padBottom path as wild/trainer
-- battles. save=nil skips badge boosts so both machines keep identical
-- stats (Gen 1 cable battles famously kept badges; we still diverge).
-- battler builder shared by both sides -- NO badge boosts, so both
-- machines compute identical stats
local function mkBattler(data, mon, isPlayer)
local BattleState = require("src.battle.BattleState")
return BattleState.makeBattler(data, mon, isPlayer, nil)
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
local def = data.pokemon[mon.species]
local ok, img = pcall(love.graphics.newImage,
isPlayer and def.spriteBack or def.spriteFront)
return {
mon = mon, def = def, isPlayer = isPlayer, stages = {},
name = mon.nickname or def.name,
curStats = mon.stats, curTypes = def.types, curMoves = mon.moves,
sprite = ok and img or nil,
}
end
-- canonical (host-side-first) state hash, unchanged since v1: it stays on
@@ -182,15 +154,26 @@ 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, 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
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
if #myParty == 0 or #theirParty == 0 then
Logger.warn("link: empty party on one side")
end
@@ -230,8 +213,6 @@ function LinkBattle.new(game, net, opts)
self.player = mkBattler(game.data, myParty[1], true)
self.enemy = mkBattler(game.data, theirParty[1], false)
self.enemyParty = theirParty
self.playerParty = myParty -- intro ball row uses the clamped copies
self.opponentName = theirName
self.introText = ("%s wants\nto battle!"):format(theirName)
self.remoteHashes = {}
self.localHashes = {}
@@ -249,62 +230,22 @@ 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)
return decodeWireAction(s, msg, s.enemy)
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
end
-- with the handshake guaranteeing both games share a link surface, a
@@ -365,11 +306,17 @@ function LinkBattle.new(game, net, opts)
-- switches happen before attacks (both may switch)
if myMsg.kind == "switch" then
local idx = myMsg.index
s:act(function() sendOutPlayer(s, myParty[idx]) end)
s:act(function()
s.player = mkBattler(game.data, myParty[idx], true)
s:sayNext(("Go! %s!"):format(s.player.name))
end)
myAction = nil
end
if theirSwitch then
s:act(function() sendOutEnemy(s, theirParty[theirSwitch]) end)
s:act(function()
s.enemy = mkBattler(game.data, theirParty[theirSwitch], false)
s:sayNext(("%s sent\nout %s!"):format(theirName, s.enemy.name))
end)
end
s:act(function()
@@ -505,7 +452,10 @@ function LinkBattle.new(game, net, opts)
self.playerMonFainted = function(s)
for _, mon in ipairs(myParty) do
if mon.hp > 0 then
s:act(function() sendOutPlayer(s, mon) end)
s:act(function()
s.player = mkBattler(game.data, mon, true)
s:sayNext(("Go! %s!"):format(s.player.name))
end)
return
end
end
@@ -518,7 +468,10 @@ function LinkBattle.new(game, net, opts)
self.enemyMonFainted = function(s)
for _, mon in ipairs(theirParty) do
if mon.hp > 0 then
s:act(function() sendOutEnemy(s, mon) end)
s:act(function()
s.enemy = mkBattler(game.data, mon, false)
s:sayNext(("%s sent\nout %s!"):format(theirName, s.enemy.name))
end)
return
end
end
@@ -545,19 +498,6 @@ 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
@@ -573,48 +513,16 @@ 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
-- 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
net:close()
if game.linkNet == net then game.linkNet = nil end
baseFinish(s)
end
@@ -622,244 +530,6 @@ 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"
+7 -215
View File
@@ -2,8 +2,6 @@
-- 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")
@@ -17,32 +15,6 @@ 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)
@@ -75,23 +47,7 @@ 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()
@@ -145,12 +101,7 @@ 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
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
self:startMode(mode, isHost)
return
end
-- naming the difference up front is the whole point: the old behaviour
@@ -168,54 +119,25 @@ function LinkState:update(dt)
local input = self.game.input
if self.net then
self.net:update()
if self.net.error and not PRE_CONNECT_STAGES[self.stage] then
if self.net.error and self.stage ~= "menu" 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 not PRE_CONNECT_STAGES[self.stage] and self.stage ~= "addrEntry"
and self.stage ~= "codeEntry" and self.stage ~= "notice"
and self.stage ~= "battleRunning" then
and self.stage ~= "menu" and self.stage ~= "addrEntry"
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.stage = "menu"
self.index = 1
self:exitWith(nil)
elseif input:wasPressed("a") then
self.net = Net.new()
if self.index == 1 then
@@ -229,67 +151,6 @@ 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
@@ -347,23 +208,6 @@ 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()
@@ -407,9 +251,6 @@ 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),
@@ -418,7 +259,6 @@ 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
@@ -469,8 +309,7 @@ function LinkState:startMode(mode, isHost)
end
self.net:send({ type = "party",
mons = Protocol.packParty(self.game.save.party),
seed = self.linkSeed,
forceLevel = isHost and self.forceLevel or nil })
seed = self.linkSeed })
end
end
@@ -501,10 +340,6 @@ function LinkState:updateTrade(input)
require("src.core.Sound").play(game.data, "Trade_Machine")
Screens.push(game, "TradeAnim", {
sent = sent, received = received,
enemyName = (self.peerName or "TRAINER"),
playerOt = game.save.player.name,
playerOtId = sent.otId or game.save.player.id,
enemyOtId = received.otId,
onDone = function()
game.stack:push(TextBox.new(game,
("Trade completed!\f%s received\n%s!"):format(game.save.player.name, name),
@@ -554,49 +389,12 @@ end
function LinkState:draw()
if self.stage == "menu" then
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)")
drawTitle("LINK CABLE CLUB")
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)
@@ -630,12 +428,6 @@ 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
+1 -170
View File
@@ -14,15 +14,6 @@
-- 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")
@@ -30,14 +21,10 @@ 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
@@ -47,10 +34,6 @@ 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
@@ -81,17 +64,13 @@ 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 (enet only)
outbox = {}, -- messages queued before pairing completes
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
@@ -154,56 +133,8 @@ 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
@@ -224,102 +155,8 @@ 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
@@ -382,12 +219,6 @@ 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
+2 -17
View File
@@ -77,13 +77,6 @@ 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)))
@@ -111,14 +104,6 @@ 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,
@@ -126,8 +111,8 @@ function Protocol.unpackMon(data, packed, opts)
dvs = dvs,
statExp = statExp,
stats = stats,
hp = hp,
status = status,
hp = math.max(0, math.min(stats.hp, math.floor(packed.hp or stats.hp))),
status = packed.status,
nickname = packed.nickname,
moves = moves,
-- a namespace whose mod this install lacks survives untouched, so the
-634
View File
@@ -1,634 +0,0 @@
-- 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
-335
View File
@@ -1,335 +0,0 @@
-- Launcher-side mod surface (18/launcher redesign): the mods panel runs
-- BEFORE Game:load, so this NEVER loads a mod entry chunk -- it scans
-- manifests only. The full loader (src/mods/Loader.lua) still owns the real
-- load at boot; this reads the same options.mods enable-state the loader
-- writes, derives per-mod status with the pure ManagerState.resolveToggle,
-- and installs a dropped/chosen .zip into the save-dir "mods/<id>/" tree.
--
-- Split in two: the pure derivation (deriveList, locateRoot) has no love and
-- no filesystem, so the engine tier can table-drive it; the discovery and
-- install paths reach for love.filesystem and SaveData.
local Manifest = require("src.mods.Manifest")
local ManagerState = require("src.mods.ManagerState")
local Semver = require("src.mods.Semver")
local Version = require("src.core.Version")
local SaveData = require("src.core.SaveData")
local LauncherMods = {}
-- ------- pure status derivation
-- A hard-dependency / conflict / version verdict for one manifest. mods is
-- the id -> validated-manifest map resolveToggle reads (its dependencySpecs,
-- conflictSpecs, version and game_version are exactly the fields the loader's
-- Manifest.validate produced); enabledSet is the current desired enable-set.
local function statusFor(mods, id, enabledSet, enabled)
local m = mods[id]
-- conflict only bites an enabled mod: resolveToggle's conflict list is
-- bidirectional (this mod's conflicts spec vs an enabled other, and an
-- enabled other's spec vs this mod), which is exactly the launcher chip.
if enabled then
local r = ManagerState.resolveToggle(mods, id, true, enabledSet)
if #r.conflicts > 0 then
local otherId = r.conflicts[1]
local other = mods[otherId]
return "conflict",
"Conflicts with " .. ((other and other.name) or otherId)
end
end
-- warn: the engine is outside the mod's game_version range
if m.game_version
and not Semver.satisfies(Version.engine, m.game_version) then
return "warn", "Needs engine " .. m.game_version
.. " (have " .. Version.engine .. ")"
end
-- warn: a hard dependency is absent, switched off, or the wrong version.
-- resolveToggle would cascade-enable a merely-disabled dep rather than flag
-- it, so the disabled case is judged straight off the manifest here.
for _, spec in ipairs(m.dependencySpecs or {}) do
local dep = mods[spec.id]
if not dep then
return "warn", "Needs " .. spec.id .. " (not installed)"
elseif not enabledSet[spec.id] then
return "warn", "Needs " .. spec.id .. " (disabled)"
elseif spec.range
and not Semver.satisfies(dep.version, spec.range) then
return "warn", "Needs " .. spec.id .. " " .. spec.range
end
end
return "ok", "Ready"
end
-- deriveList(manifests, options) -> the panel row list, pure.
-- manifests is an array of validated manifests (Manifest.validate output);
-- options is the options table (only options.mods is read). Rows come back
-- sorted by id so the panel order is stable.
function LauncherMods.deriveList(manifests, options)
local mods = options and options.mods or {}
local ordered = {}
for _, m in ipairs(manifests) do ordered[#ordered + 1] = m end
table.sort(ordered, function(a, b) return a.id < b.id end)
local byId, enabledSet = {}, {}
for _, m in ipairs(ordered) do
byId[m.id] = m
-- missing entry means enabled, matching the loader's default
if mods[m.id] ~= false then enabledSet[m.id] = true end
end
local out = {}
for _, m in ipairs(ordered) do
local enabled = enabledSet[m.id] == true
local status, detail = statusFor(byId, m.id, enabledSet, enabled)
local raw = m.raw or {}
out[#out + 1] = {
id = m.id,
name = m.name or m.id,
version = m.version,
-- category, then profile, then a generic fallback -- uppercased
badge = tostring(raw.category or m.profile or "MOD"):upper(),
description = m.description or "",
enabled = enabled,
status = status,
statusDetail = detail,
}
end
return out
end
-- locateRoot(paths) -> the mod-root prefix inside a mounted archive, pure.
-- paths is a shallow listing: top-level file names as-is, and for a top-level
-- directory a "<dir>/manifest.json" entry when it holds one. Returns "" when
-- the manifest sits at the archive root, "<dir>" when a single top-level
-- folder holds it, or nil + a user-presentable reason.
function LauncherMods.locateRoot(paths)
for _, p in ipairs(paths) do
if p == "manifest.json" then return "" end
end
local topDirs, seen, hasManifest = {}, {}, {}
for _, p in ipairs(paths) do
local top, rest = p:match("^([^/]+)/(.+)$")
if top then
if not seen[top] then
seen[top] = true
topDirs[#topDirs + 1] = top
end
if rest == "manifest.json" then hasManifest[top] = true end
end
end
if #topDirs == 1 and hasManifest[topDirs[1]] then return topDirs[1] end
if #topDirs > 1 then
return nil, "the .zip must contain a single mod folder"
end
return nil, "no manifest.json found in the .zip"
end
-- ------- discovery (love.filesystem)
local function decodeManifest(raw, path)
local Json = require("src.link.Json")
local data, decodeErr = Json.decode(raw)
if not data then return nil, decodeErr end
local ok, manifest = pcall(Manifest.validate, data, path)
if not ok then return nil, manifest end
return manifest
end
-- Scan "mods/" one level deep for valid manifests (mirrors Loader:_discover,
-- but validates only -- no entry chunk is ever loaded). First id wins on a
-- duplicate. Returns an array of validated manifests.
local function discover()
local fs = love and love.filesystem
local out = {}
if not (fs and fs.getInfo and fs.getDirectoryItems) then return out end
if not fs.getInfo("mods") then return out end
local seen = {}
for _, name in ipairs(fs.getDirectoryItems("mods")) do
local path = "mods/" .. name
local info = fs.getInfo(path)
if info and info.type == "directory" then
local raw = fs.read(path .. "/manifest.json")
if raw then
local manifest = decodeManifest(raw, path)
if manifest and not seen[manifest.id] then
seen[manifest.id] = true
out[#out + 1] = manifest
end
end
end
end
return out
end
-- list() -> the mods-panel rows for the current install. Reads the same
-- options.mods enable-state the loader persists, so a toggle here is what the
-- game sees on its next boot.
function LauncherMods.list()
local options = SaveData.loadOptions()
return LauncherMods.deriveList(discover(), options)
end
-- setEnabled(id, enabled): persist options.mods[id] in the exact shape
-- Loader:_saveState writes (a plain boolean), so the running game and the
-- in-game ManagerState pick it up unchanged.
function LauncherMods.setEnabled(id, enabled)
local options = SaveData.loadOptions()
options.mods = options.mods or {}
options.mods[id] = enabled and true or false
SaveData.saveOptions(options)
return true
end
-- ------- install (love.filesystem)
-- Read a .zip source into bytes. A string is an external absolute path (like
-- a chosen ROM) read with io.*, falling back to a save-dir-relative
-- love.filesystem read; a love DroppedFile is opened the way RomImporter
-- ingests dropped ROMs.
local function readArchive(source)
local t = type(source)
if (t == "userdata" or t == "table") and type(source.open) == "function" then
local ok = source:open("r")
if not ok then return nil, "could not open the dropped file" end
local data = source:read(source:getSize())
source:close()
if not data then return nil, "the dropped file could not be read" end
return data
end
if t == "string" then
local f = io.open(source, "rb")
if f then
local data = f:read("*a")
f:close()
if not data then return nil, "could not read " .. source end
return data
end
if love and love.filesystem then
local data = love.filesystem.read(source)
if data then return data end
end
return nil, "could not open " .. source
end
return nil, "unsupported archive source"
end
-- Shallow listing of a mounted archive shaped for locateRoot: files by name,
-- and for each top-level directory a "<dir>/manifest.json" marker only when it
-- actually holds one (so a lone folder with no manifest still reads as empty).
local function topLevelPaths(mount)
local fs = love.filesystem
local paths = {}
for _, name in ipairs(fs.getDirectoryItems(mount)) do
local info = fs.getInfo(mount .. "/" .. name)
if info and info.type == "directory" then
if fs.getInfo(mount .. "/" .. name .. "/manifest.json", "file") then
paths[#paths + 1] = name .. "/manifest.json"
end
else
paths[#paths + 1] = name
end
end
return paths
end
local function copyTree(src, dst)
local fs = love.filesystem
if not fs.createDirectory(dst) then
return nil, "could not create " .. dst
end
for _, name in ipairs(fs.getDirectoryItems(src)) do
local s = src .. "/" .. name
local d = dst .. "/" .. name
local info = fs.getInfo(s)
if info and info.type == "directory" then
local ok, err = copyTree(s, d)
if not ok then return nil, err end
else
local data = fs.read(s)
if data == nil then return nil, "could not read " .. name end
local ok, err = fs.write(d, data)
if not ok then return nil, "could not write " .. name .. ": " .. tostring(err) end
end
end
return true
end
local function removeTree(path)
local fs = love.filesystem
local info = fs.getInfo(path)
if not info then return end
if info.type == "directory" then
for _, child in ipairs(fs.getDirectoryItems(path)) do
removeTree(path .. "/" .. child)
end
end
fs.remove(path)
end
-- installZip(source) -> true, id | nil, errString
-- source is an external path or a love DroppedFile. The archive is validated
-- BEFORE anything is copied; every path unmounts and clears the staged temp
-- file, and a failed copy rolls its partial tree back. A dropped file outside
-- the save dir is staged into a save-dir temp first, because
-- love.filesystem.mount only reaches a save-directory-relative path.
function LauncherMods.installZip(source)
if not (love and love.filesystem) then
return nil, "mod install needs LOVE"
end
local fs = love.filesystem
local data, readErr = readArchive(source)
if not data then return nil, readErr end
-- stage into a save-dir temp so mount can reach it
local tmp = ("mod_import_%d_%d.zip"):format(os.time(), math.random(0, 999999))
local ok, writeErr = fs.write(tmp, data)
if not ok then
return nil, "could not stage the .zip: " .. tostring(writeErr)
end
local mount = "mod_import_mount"
if not fs.mount(tmp, mount) then
fs.remove(tmp)
return nil, "that .zip could not be opened"
end
local function cleanup()
pcall(fs.unmount, tmp)
fs.remove(tmp)
end
local prefix, rootErr = LauncherMods.locateRoot(topLevelPaths(mount))
if not prefix then
cleanup()
return nil, rootErr
end
local root = prefix == "" and mount or (mount .. "/" .. prefix)
local raw = fs.read(root .. "/manifest.json")
if not raw then
cleanup()
return nil, "the .zip has no readable manifest.json"
end
local manifest, manifestErr = decodeManifest(raw, root)
if not manifest then
cleanup()
return nil, "invalid mod manifest: " .. tostring(manifestErr)
end
-- reject a duplicate before touching the mods tree
local dest = "mods/" .. manifest.id
if fs.getInfo(dest) then
cleanup()
return nil, "a mod named '" .. manifest.id .. "' is already installed"
end
fs.createDirectory("mods")
local copied, copyErr = copyTree(root, dest)
if not copied then
removeTree(dest)
cleanup()
return nil, copyErr or "could not copy the mod files"
end
cleanup()
return true, manifest.id
end
return LauncherMods
-6
View File
@@ -18,12 +18,6 @@ ManagerState.isOpaque = true
-- Game:keypressed recognizes a directly-pushed instance
ManagerState.screenId = "ManagerState"
-- Same as OptionsMenu: without this, title LOGO zones leak through when
-- MODS is opened from the title-screen options path.
function ManagerState:sgbPalettes(game)
return require("src.render.PaletteFX").wholeNamed(game.data, "MEWMON")
end
-- the charmap has no * ~ + < > glyphs, so the status gutter uses what it
-- does have: staged-awaiting-restart, disabled, errored, dep-unhealthy
local GLYPH = { staged = ".", disabled = "-", errored = "!", blocked = "?" }
+2 -137
View File
@@ -40,19 +40,6 @@ function f.int(min, max)
end }
end
-- a bounded float; like f.int but keeps the fractional part (scales,
-- gains). Rejects out-of-range with the "expected number a..b" message.
function f.numRange(min, max)
local desc = "number"
if min and max then desc = ("number %s..%s"):format(min, max)
elseif min then desc = ("number >= %s"):format(min) end
return { kind = "num", min = min, max = max, desc = desc,
check = function(v)
return type(v) == "number"
and (min == nil or v >= min) and (max == nil or v <= max)
end }
end
function f.enum(values)
local set = {}
for _, value in ipairs(values) do set[value] = true end
@@ -433,12 +420,6 @@ R.pokemon = {
frames = f.opt(f.int(1)) } }),
cry = f.opt(f.id("cries")), palette = f.opt(f.id("palettes")),
trueColor = f.opt(f.bool),
-- battle-pic scale overrides for this species' own pics: front is the
-- enemy pic (default 1x), back is the player pic (default 2x). An
-- image-level battle_sprite_scales entry for the same path beats these.
-- The pic stays grounded (feet pinned) at any scale; see docs/modding.md.
battleScaleFront = f.opt(f.numRange(0.25, 4.0)),
battleScaleBack = f.opt(f.numRange(0.25, 4.0)),
},
example = 'mod.content.pokemon:patch("MEW", { baseStats = { attack = 120 } })',
}
@@ -810,118 +791,6 @@ R.transitions = {
example = 'mod.content.transitions:register("dissolve", { frames = 30, draw = fn })',
}
-- ------- rendering pipelines
--
-- A pipeline is a display mode that owns part of the frame: it may replace
-- the overworld's world pass with geometry of its own (drawWorld) and/or
-- post-process the finished composite (present). Everything around that --
-- the OFF/1/2/3 ladder, its options row, its hotkey, persistence in
-- save.options.pipelines and the gating that keeps it out of battles and
-- menus -- is engine plumbing driven from this record, so a renderer mod
-- declares what it is and writes only the two draw functions.
--
-- Both callbacks are optional and independent: a present-only pipeline is a
-- post-process (bloom, tilt-shift, a CRT curve) that leaves whatever
-- rendered the frame alone, and a drawWorld-only pipeline is a world
-- renderer that composites straight. See src/render/Pipelines.lua for the
-- ctx each receives and docs/modding.md for the worked example.
R.render_pipelines = {
semantics = "record", target = "render_pipelines",
fields = {
-- shown in the options menu; the ladder labels default to OFF/ON
label = f.str,
levels = f.opt(f.list(f.str)),
-- keyboard key that cycles the ladder, checked after the engine's own
-- display hotkeys so a pipeline can never shadow one
hotkey = f.opt(f.str),
-- higher wins when two world pipelines are somehow active at once;
-- also the options-row order, so a mode and its post-process sort
-- together instead of by id
priority = f.opt(f.num),
-- hardware/driver gate, checked every frame: false keeps the vanilla
-- 2D path, which is what a headless run and a driver with no depth
-- canvas both get
available = f.opt(f.fn),
-- (top, overworld) -> boolean: whether the player may CHANGE the mode
-- right now. Defaults to the survey-zoom gate (free-roam overworld
-- only), which keeps a hotkey press from switching modes mid-warp or
-- mid-cutscene. It has no say over whether an already-on mode draws:
-- a mode that stopped rendering during a warp would flash the flat 2D
-- world every time the player walked through a door.
gate = f.opt(f.fn),
-- (dt, level): presentational tweens, ticked on real frame time
update = f.opt(f.fn),
-- (ctx) -> canvas | nil: render the world. nil falls back to the
-- vanilla flat/tilt draw for this frame.
drawWorld = f.opt(f.fn),
-- (canvas, ctx) -> canvas: post-process the WORLD image, before the UI
-- composites over it -- a depth-of-field or colour grade that must not
-- touch the dialog boxes and menus sitting on top. Only runs when some
-- pipeline rendered the world, since the vanilla world pass has no
-- single finished image to hand over.
worldPresent = f.opt(f.fn),
-- (canvas, ctx) -> canvas: post-process the whole finished composite,
-- world and UI alike (a CRT curve, a full-screen grade). Must return a
-- canvas; the input unchanged is the correct answer when the effect is
-- off.
present = f.opt(f.fn),
-- drop GPU objects (window resize, hot reload, mode switch)
invalidate = f.opt(f.fn),
},
-- a pipeline that does neither half is dead weight and would silently
-- occupy an options row and a hotkey
extra = function(_, value)
if value.drawWorld == nil and value.present == nil
and value.worldPresent == nil then
return "a render pipeline needs drawWorld, worldPresent or present"
end
end,
-- A pipeline callback fails at play time, long after the load phase has
-- handed its report to the mod manager, so the merge leaves behind who
-- wrote each record for Pipelines to name in the failure -- the same
-- provenance trick the audio registries use (Loader.stampAudioOwners).
-- Placement is otherwise the default record merge.
write = function(target, registry)
local owners, tombstones = {}, {}
for id in pairs(registry.ops) do
local value = registry:get(id)
if value == nil then
tombstones[#tombstones + 1] = id
else
target[id] = value
local owner = registry.owners[id]
if owner and owner ~= Schemas.ENGINE then owners[id] = owner end
end
end
for _, id in ipairs(tombstones) do target[id] = nil end
target._owners = owners
end,
example = 'mod.content.render_pipelines:register("voxel", ' ..
'{ label = "VOXEL", levels = { "OFF", "15", "35", "50" }, drawWorld = fn })',
}
-- ------- battle sprite scales
--
-- Per-image battle-pic scale overrides, keyed by record id and consulted
-- by asset path at draw time. Where a species' battleScaleFront /
-- battleScaleBack scales its own front/back pic, this scales ANY battle
-- pic by the path it is drawn from -- the only handle on the non-species
-- pics like the player's trainer back sprite. Image-level beats
-- species-level; both compose with the send-out grow and keep the sprite
-- grounded (feet pinned) at whatever scale. See docs/modding.md.
R.battle_sprite_scales = {
semantics = "record", target = "battle_sprite_scales",
fields = {
-- the asset path exactly as data references it, e.g.
-- "assets/generated/battle/back/abrab.png"
path = f.path,
-- 1 = native pixels; the drawn size relative to the pic's own pixels
scale = f.numRange(0.25, 4.0),
},
example = 'mod.content.battle_sprite_scales:register("abra_back", ' ..
'{ path = "assets/generated/battle/back/abrab.png", scale = 1.5 })',
}
-- ------- progression
R.evolution_methods = {
@@ -1003,15 +872,11 @@ R.palettes = {
}
-- keyed by species id, unlike the vanilla byDex array: a species past the
-- end of the dex gets an icon without punching a hole in the list. The party
-- menu (src/ui/PartyMenu.lua) reads this per-species entry before the vanilla
-- dex-indexed default. The value is a built-in icon NAME -- one of BALL, BIRD,
-- BUG, FAIRY, GRASS, HELIX, MON, QUADRUPED, SNAKE, WATER (uppercase) -- or a
-- { image = <bundled file path>, frames? } table of your own art.
-- end of the dex gets an icon without punching a hole in the list
R.icons = {
semantics = "record", target = "icons.bySpecies",
value = f.union{ f.str, f.rec{ image = f.path, frames = f.opt(f.int(1)) } },
example = 'mod.content.icons:register("MODMON", "QUADRUPED") -- a built-in name, or { image = mod.assets:path("icon.png"), frames = 2 }',
example = 'mod.content.icons:register("MISSINGNO", { image = "glitch.png" })',
}
-- glyph codes are not bytes: the vanilla pages sit at $60/$80 but a
-28
View File
@@ -29,34 +29,6 @@ function Pokemon.movesAtLevel(speciesDef, level)
return moves
end
-- Day Care retrieve (pokered WriteMonMoves + wLearningMovesFromDayCare):
-- grant learnset moves with startLevel < moveLevel <= newLevel, shifting
-- the oldest slot out when full. Silent — no LearnMove prompts.
function Pokemon.learnMovesFromDayCare(data, mon, speciesDef, startLevel, newLevel)
if not (speciesDef and speciesDef.learnset and mon) then return end
mon.moves = mon.moves or {}
for _, entry in ipairs(speciesDef.learnset) do
local moveLevel = entry.level
if moveLevel > newLevel then break end
if moveLevel > startLevel then
local known = false
for _, mv in ipairs(mon.moves) do
if mv.id == entry.move then known = true break end
end
if not known then
local mdef = data.moves[entry.move]
local slot = { id = entry.move, pp = mdef and mdef.pp or 0 }
if #mon.moves < 4 then
mon.moves[#mon.moves + 1] = slot
else
table.remove(mon.moves, 1)
mon.moves[#mon.moves + 1] = slot
end
end
end
end
end
function Pokemon.new(data, species, level, rng)
local def = data.pokemon[species]
assert(def, "unknown species " .. tostring(species))
-5
View File
@@ -230,11 +230,6 @@ 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,17 +148,6 @@ function Font.advanceOf(code)
return page and page.advance or GLYPH
end
-- Pixel width of a string (glyph advances, not UTF-8 byte length).
-- Multi-byte charmap entries like "¥" are one glyph; callers that
-- right-align with `#text * 8` mis-place them.
function Font.width(text)
local w = 0
for _, code in ipairs(Font.encode(text)) do
w = w + Font.advanceOf(code)
end
return w
end
-- Draw a plain single-line string at pixel (x, y). Returns the width
-- drawn, which is #codes * 8 for every fixed-width page.
function Font.draw(text, x, y)
+1 -29
View File
@@ -22,15 +22,6 @@ 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 = [[
@@ -223,7 +214,6 @@ 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
@@ -232,10 +222,6 @@ 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
@@ -244,26 +230,12 @@ 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)
@@ -271,7 +243,7 @@ function GBCFX.levelLabel(level)
end
function GBCFX.active()
return GBCFX.isSupported() and GBCFX.level > 0 and GBCFX.shader() ~= nil
return GBCFX.level > 0 and GBCFX.shader() ~= nil
end
-- Draw `canvas` fullscreen through the GBC FX shader into the current
+7 -44
View File
@@ -242,20 +242,6 @@ function PaletteFX.pack(data)
return data and data.palettes or nil
end
-- SuperPalettes that differ between Red and Blue (pokered data/sgb/
-- sgb_palettes.asm IF DEF(_RED)/_BLUE). data/palettes_gbc.lua is the
-- Red-derived pokered-gbc pack, so under RED++ a Blue playthrough must
-- read these from the ROM-imported table or the title ribbon stays red
-- and the Game Corner reels keep Red's pink (issue #128).
local BLUE_VERSIONED = {
LOGO1 = true, SLOTS2 = true, SLOTS3 = true, SLOTS4 = true,
}
local function romNamedPal(data, name)
local p = data and data.palettes
return p and p.palettes and p.palettes[name]
end
-- named palette from the active pack (nil on stale builds / missing name).
-- RED++ falls back to the ROM pack for names the gbc table omits (rare).
-- OG RED short-circuits EVERY name to the one global GBC boot-ROM BG palette
@@ -265,15 +251,11 @@ end
-- GBC_OBJ green), so this stays a BG-only hook.
function PaletteFX.pal(data, name)
if PaletteFX.mode == "ogred" then return PaletteFX.ogBg() end
if GameVersion.isBlue() and BLUE_VERSIONED[name] then
local fromRom = romNamedPal(data, name)
if fromRom then return fromRom end
end
local p = PaletteFX.pack(data)
local c = p and p.palettes[name]
if c then return c end
if PaletteFX.usesGbcPack() then
return romNamedPal(data, name)
if PaletteFX.usesGbcPack() and data and data.palettes then
return data.palettes.palettes[name]
end
return nil
end
@@ -295,16 +277,6 @@ 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
@@ -318,12 +290,6 @@ 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
@@ -369,21 +335,18 @@ local TILESET_GROUP_EXCEPTIONS = {
CEMETERY = { tiles = { [0x22] = true }, group = 0 },
}
-- pokered-gbc's lobby.bst repoints the Celadon LOBBY table's flat top
-- pokered-gbc's lobby.bst repoints the Celadon roof 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 = LOBBY_TABLE_TOP_ALIAS,
CELADON_DINER = LOBBY_TABLE_TOP_ALIAS,
CELADON_MART_ROOF = {
{ block = 29, cells = { [5] = true, [6] = true, [9] = true, [10] = true },
tile = 0x37, alias = 0x5a, group = 5 },
},
}
local ROOF_GROUP = 6
local ROUTE_6_SAFFRON = { mapId = "ROUTE_6", useMapId = "SAFFRON_CITY", cellYBelow = 2 }
-423
View File
@@ -1,423 +0,0 @@
-- Rendering pipelines: the engine side of the render_pipelines registry.
--
-- A pipeline is a display mode a mod owns. It may replace the overworld's
-- world pass with geometry of its own (drawWorld) and/or post-process the
-- finished composite (present). Everything else about being a display mode
-- -- the OFF/1/2/3 ladder, the options row, the hotkey, persistence, the
-- free-roam gate, and never letting a mod's error take the frame down -- is
-- engine plumbing and lives here, so a renderer mod writes the two draw
-- functions and declares the rest.
--
-- The two halves compose independently and in priority order: the highest
-- priority eligible drawWorld renders the world, then every eligible
-- present folds over whatever came out (the world pipeline's canvas, or the
-- vanilla flat/tilt composite when none ran). A present that is switched
-- off returns its input, so a full ladder of them costs nothing at level 0.
--
-- Nothing here reaches collision, movement, triggers or scripts: like
-- survey zoom and tilt, a pipeline is purely presentational, which is why
-- its level rides in save.options rather than the save proper.
--
-- Spec: docs/modding.md (rendering pipelines)
local Data = require("src.core.Data")
local Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime")
local Zoom = require("src.render.Zoom")
local Pipelines = {}
-- id -> level. Levels live here rather than on the records because the
-- records are merged content: frozen after load, and shared with whatever
-- else reads Data.
local levels = {}
-- ids whose callbacks have already thrown, so a pipeline that fails every
-- frame reports once instead of filling the log at 60Hz
local broken = {}
Pipelines.DEFAULT_LEVELS = { "OFF", "ON" }
-- The merged dataset the records live in. The boot singleton is the
-- default -- Game.data is that very table -- and install() lets a headless
-- caller (the SDK harness, a tool) point this at a dataset of its own.
local source = Data
function Pipelines.install(data)
source = data or Data
Pipelines.reset()
end
-- ------- catalog
-- Every registered pipeline as { id = ..., def = ... }, ordered by priority
-- (descending, ties by id) so selection, the options rows and the present
-- fold all walk the same sequence.
--
-- Memoized on the namespace table's identity. Content freezes at the merge
-- boundary, so the answer cannot change for a given table -- and this is
-- read several times per frame by update(), worldPipeline() and the
-- endFrame present check, which is no place to allocate and sort. An empty
-- list is cached too, so a mod-free boot pays one table for the process.
local listCache, listSource = nil, nil
function Pipelines.list()
local defs = source and source.render_pipelines
if defs == listSource and listCache then return listCache end
local out = {}
if type(defs) == "table" then
for id, def in pairs(defs) do
-- the merge writes provenance under _owners; skip the bookkeeping
-- keys rather than treating them as pipelines
if type(id) == "string" and id:sub(1, 1) ~= "_" and type(def) == "table" then
out[#out + 1] = { id = id, def = def }
end
end
table.sort(out, function(a, b)
local pa, pb = a.def.priority or 0, b.def.priority or 0
if pa ~= pb then return pa > pb end
return a.id < b.id
end)
end
listCache, listSource = out, defs
return out
end
function Pipelines.get(id)
local defs = source and source.render_pipelines
local def = type(defs) == "table" and defs[id] or nil
return type(def) == "table" and def or nil
end
-- the mod that registered a pipeline, so a runtime failure lands in the
-- feed the mod manager shows instead of only in the console
local function ownerOf(id)
local defs = source and source.render_pipelines
local owners = type(defs) == "table" and defs._owners or nil
return owners and owners[id] or nil
end
-- Run one of a pipeline's callbacks under pcall. A mod that throws mid-
-- frame must not take the frame with it: the pipeline is marked broken,
-- attributed once, and treated as absent from then on -- which degrades to
-- the vanilla 2D path rather than a black screen.
local function guard(id, fn, ...)
if broken[id] then return nil end
local ok, result = pcall(fn, ...)
if ok then return result end
broken[id] = true
Logger.error("render pipeline %s failed: %s -- disabled for this session",
id, tostring(result))
Runtime.reportError(ownerOf(id), "render pipeline failed: " .. tostring(result))
return nil
end
-- Whether a callback's return is a real Canvas we can composite. A mod that
-- forgets a return, or hands back a shade string / flag / number, must be
-- ignored rather than trusted -- draw() on a non-canvas takes the frame down.
-- Real LOVE canvases are userdata answering typeOf("Canvas"); the headless
-- test stub (tests/love_stub) fakes them as tables carrying the Canvas method
-- shape (love.graphics.newCanvas), so accept either and nothing else.
local function isCanvas(v)
if type(v) == "userdata" then
return type(v.typeOf) == "function" and v:typeOf("Canvas") == true
end
if type(v) == "table" then
return type(v.getWidth) == "function" and type(v.getHeight) == "function"
end
return false
end
-- Dispatch a mod render callback with its GPU state fenced off: push("all")
-- before and pop() after, so a callback that returns cleanly but leaves a
-- shader bound, the canvas redirected, or blend/colour changed cannot corrupt
-- the engine composite that follows. guard() catches a callback that throws;
-- this catches one that dirties state. A pipeline already retired skips the
-- push/pop entirely, so the stack stays balanced.
local function guardRender(id, fn, ...)
if broken[id] then return nil end
love.graphics.push("all")
local out = guard(id, fn, ...)
love.graphics.pop()
return out
end
-- ------- levels
function Pipelines.levelLabels(id)
local def = Pipelines.get(id)
local labels = def and def.levels
if type(labels) ~= "table" or labels[1] == nil then
return Pipelines.DEFAULT_LEVELS
end
return labels
end
-- highest selectable level: one less than the label count, so a two-label
-- ladder is a plain OFF/ON toggle
function Pipelines.maxLevel(id)
return #Pipelines.levelLabels(id) - 1
end
function Pipelines.level(id)
return levels[id] or 0
end
function Pipelines.levelLabel(id, level)
local labels = Pipelines.levelLabels(id)
return labels[(level or Pipelines.level(id)) + 1] or labels[1] or "OFF"
end
-- A world pipeline and the engine's own tilt mode are two answers to the
-- same question, so switching one on switches the other off -- the rule
-- tilt and survey zoom already follow between themselves. Present-only
-- pipelines (post-processes) compose with tilt and are left alone.
local function excludeTilt(id, level)
local def = Pipelines.get(id)
if not (def and def.drawWorld) or level <= 0 then return end
local Tilt = require("src.render.Tilt")
if Tilt.level > 0 then Tilt.setLevel(0) end
-- one world pipeline at a time, for the same reason
for _, entry in ipairs(Pipelines.list()) do
if entry.id ~= id and entry.def.drawWorld and Pipelines.level(entry.id) > 0 then
levels[entry.id] = 0
end
end
end
function Pipelines.setLevel(id, level)
if not Pipelines.get(id) then return 0 end
level = math.floor(tonumber(level) or 0)
if level < 0 then level = 0 end
local max = Pipelines.maxLevel(id)
if level > max then level = max end
levels[id] = level
excludeTilt(id, level)
return level
end
-- Advance the ladder and wrap to OFF, the shape every display hotkey walks.
function Pipelines.cycle(id, dir)
local max = Pipelines.maxLevel(id)
if max < 1 then return 0 end
local span = max + 1
local target = (Pipelines.level(id) + (dir or 1)) % span
if target < 0 then target = target + span end
return Pipelines.setLevel(id, target)
end
-- Turning a world pipeline on must switch tilt off in the save too, not
-- just in the live module, or the next boot restores both. Call sites hand
-- over the options table so this stays the one place that rule lives.
function Pipelines.syncOptions(opts)
if type(opts) ~= "table" then return end
local bucket = opts.pipelines
if type(bucket) ~= "table" then
bucket = {}
opts.pipelines = bucket
end
for _, entry in ipairs(Pipelines.list()) do
bucket[entry.id] = Pipelines.level(entry.id)
if entry.def.drawWorld and Pipelines.level(entry.id) > 0 then
opts.tilt = 0
end
end
end
-- Restore levels from a loaded options table. A pipeline whose mod is gone
-- keeps its stored level untouched in the bucket (so re-enabling the mod
-- restores the mode) but contributes nothing while absent.
function Pipelines.applyOptions(opts)
local bucket = type(opts) == "table" and opts.pipelines or nil
levels = {}
broken = {}
local world = nil
for _, entry in ipairs(Pipelines.list()) do
local stored = type(bucket) == "table" and bucket[entry.id] or 0
local level = math.floor(tonumber(stored) or 0)
if level < 0 then level = 0 end
local max = Pipelines.maxLevel(entry.id)
if level > max then level = max end
-- list() is priority order, so the first world pipeline with a stored
-- level is the one that wins; the rest restore to OFF rather than
-- sitting on a level that can never render
if entry.def.drawWorld and level > 0 then
if world then level = 0 else world = entry.id end
end
levels[entry.id] = level
end
-- a restored world pipeline and tilt are two answers to the same
-- question; the pipeline wins, as it does at every place that sets one
if world then require("src.render.Tilt").setLevel(0) end
end
function Pipelines.reset()
levels = {}
broken = {}
end
-- ------- per-frame
-- Presentational tweens run on real frame time, like Tilt's. Every
-- pipeline ticks, not just the active ones: a mode easing back OUT still
-- has an angle to retire.
function Pipelines.update(dt)
for _, entry in ipairs(Pipelines.list()) do
if entry.def.update then
guardRender(entry.id, entry.def.update, dt, Pipelines.level(entry.id))
end
end
end
-- A pipeline may run this frame when it is switched on, has not thrown, and
-- its hardware gate says yes. `available` is consulted every frame rather
-- than cached: a driver that loses its depth canvas on a resize has to be
-- able to change its mind.
--
-- Deliberately NOT gated on the state stack. `gate` governs whether the
-- player may CHANGE the mode, never whether it draws -- a display mode that
-- stopped rendering during a warp, a scripted cutscene or an open menu
-- would flash the flat 2D world for those frames every time the player
-- walked through a door. Once a mode is on it renders until it is off.
function Pipelines.eligible(id)
local def = Pipelines.get(id)
if not def or broken[id] then return false end
if Pipelines.level(id) <= 0 then return false end
if def.available and guard(id, def.available) ~= true then return false end
return true
end
-- Whether the player may cycle this mode right now: the free-roam gate,
-- which keeps a hotkey press from switching modes mid-warp or mid-cutscene.
-- Input only -- see eligible() for why the draw path does not consult it.
function Pipelines.canToggle(id, top, overworld)
local def = Pipelines.get(id)
if not def then return false end
local gate = def.gate or Zoom.gateOK
return guard(id, gate, top, overworld) == true
end
-- The pipeline that owns the world pass right now, or nil for the vanilla
-- flat/tilt draw. Highest priority wins; the exclusion rules above mean
-- there is normally only one candidate anyway.
function Pipelines.worldPipeline()
for _, entry in ipairs(Pipelines.list()) do
if entry.def.drawWorld and Pipelines.eligible(entry.id) then
return entry.id, entry.def
end
end
return nil
end
-- Render the world through `id`. Returns the canvas to composite, or nil
-- when the pipeline declined this frame (nothing to draw, a transient
-- failure), which the caller treats as "fall back to the 2D path".
function Pipelines.drawWorld(id, ctx)
local def = Pipelines.get(id)
if not (def and def.drawWorld) then return nil end
return guardRender(id, def.drawWorld, ctx)
end
-- Fold every eligible world post-process over a pipeline's world image,
-- before the UI composites on top. This is where a depth-of-field or a
-- colour grade belongs when it must leave the dialog boxes and menus crisp;
-- `present` below is the whole-frame counterpart. Only reachable once some
-- pipeline rendered the world, so it is gated on the overworld state the
-- same way drawWorld is.
function Pipelines.worldPresent(canvas, ctx)
if canvas == nil then return nil end
for _, entry in ipairs(Pipelines.list()) do
if entry.def.worldPresent and Pipelines.eligible(entry.id) then
local out = guardRender(entry.id, entry.def.worldPresent, canvas, ctx)
-- accept only a real Canvas: a pass that returns a non-canvas (a
-- forgotten return, a shade string) is ignored, not folded in
if isCanvas(out) then canvas = out end
end
end
return canvas
end
-- Fold every eligible post-process over the finished frame. Present
-- pipelines are not gated on the overworld state -- a CRT curve or a colour
-- grade applies to menus and battles too -- so eligibility here is just
-- "switched on and available". A pass that returns a non-canvas is
-- ignored rather than trusted, so a mod cannot blank the screen by
-- forgetting a return.
function Pipelines.present(canvas, ctx)
if canvas == nil then return nil end
for _, entry in ipairs(Pipelines.list()) do
if entry.def.present and Pipelines.eligible(entry.id) then
local out = guardRender(entry.id, entry.def.present, canvas, ctx)
-- accept only a real Canvas: a pass that returns a non-canvas is
-- ignored (docstring above), so a mod cannot blank or crash the frame
-- by forgetting a return or handing back a truthy non-canvas
if isCanvas(out) then canvas = out end
end
end
return canvas
end
-- true when any present-only pass wants to run, so the composite path can
-- skip allocating a target it would not use
function Pipelines.wantsPresent()
for _, entry in ipairs(Pipelines.list()) do
if entry.def.present and Pipelines.eligible(entry.id) then return true end
end
return false
end
-- ------- input and UI
-- Cycle whichever pipeline claims `key`. Returns the id when one did, so
-- the caller knows the key was consumed. Checked after the engine's own
-- display hotkeys, so a mod can never shadow one.
function Pipelines.hotkey(key, top, overworld)
for _, entry in ipairs(Pipelines.list()) do
if entry.def.hotkey == key then
-- the gate belongs here and nowhere else: it stops the player
-- flipping modes mid-warp or mid-cutscene, and has no say over
-- whether an already-on mode draws
if Pipelines.canToggle(entry.id, top, overworld) then
Pipelines.cycle(entry.id)
return entry.id
end
return nil
end
end
return nil
end
-- Options rows for every registered pipeline, in the same priority order,
-- in the descriptor shape src/ui/OptionRows.lua renders.
function Pipelines.rows(game)
local rows = {}
for _, entry in ipairs(Pipelines.list()) do
local id = entry.id
rows[#rows + 1] = {
id = "pipeline:" .. id,
label = entry.def.label or id:upper(),
value = function() return Pipelines.levelLabel(id) end,
step = function(g, dir)
Pipelines.cycle(id, dir)
local opts = g and g.save and g.save.options
if opts then
Pipelines.syncOptions(opts)
-- the exclusion above may have switched tilt off; keep the live
-- module in step with the option it just wrote
require("src.render.Tilt").setLevel(opts.tilt or 0)
end
return true
end,
}
end
return rows
end
-- Drop every pipeline's GPU objects (window resize, hot reload).
function Pipelines.invalidate()
for _, entry in ipairs(Pipelines.list()) do
if entry.def.invalidate then guardRender(entry.id, entry.def.invalidate) end
end
end
return Pipelines
+18 -197
View File
@@ -9,28 +9,12 @@
local Zoom = require("src.render.Zoom")
local Tilt = require("src.render.Tilt")
local PaletteFX = require("src.render.PaletteFX")
local Pipelines = require("src.render.Pipelines")
local Renderer = {}
Renderer.WIDTH = 160
Renderer.HEIGHT = 144
-- Whether a value is a real Canvas we can composite. Real LOVE canvases are
-- userdata answering typeOf("Canvas"); the headless test stub fakes them as
-- tables carrying the Canvas method shape. A mod pipeline handing back a
-- non-canvas must be rejected before it reaches love.graphics.draw, which
-- would otherwise take the frame down with it.
local function isCanvas(v)
if type(v) == "userdata" then
return type(v.typeOf) == "function" and v:typeOf("Canvas") == true
end
if type(v) == "table" then
return type(v.getWidth) == "function" and type(v.getHeight) == "function"
end
return false
end
-- Tilt mode: the upright billboard canvas is grown by this many world
-- pixels on every side beyond the ground world view, so a structure or
-- sprite standing near a view edge still draws in full instead of being
@@ -39,29 +23,6 @@ end
-- 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")
@@ -73,30 +34,12 @@ function Renderer:init()
-- the projected ground in endFrame; never touched while tilt is off.
self.uprightCanvas = nil
self.uprightActive = false
-- a render pipeline's finished world image, already at window resolution
-- (see src/render/Pipelines.lua). nil is "no pipeline rendered this
-- frame", which is every vanilla frame.
self.worldOverride = nil
end
-- Hand endFrame a pipeline's world image to composite instead of the world
-- canvas. Cleared every frame, so a pipeline that declines one frame falls
-- straight back to the 2D path rather than showing a stale image.
function Renderer:setWorldOverride(canvas)
-- Defensive: a pipeline that hands back a non-canvas (forgotten return, a
-- truthy sentinel) must not reach the worldOverride blit in endFrame, where
-- love.graphics.draw on it would crash the frame. Reject it and fall back
-- to the 2D path rather than trust it.
if canvas ~= nil and not isCanvas(canvas) then canvas = nil end
self.worldOverride = canvas
end
-- Integer framebuffer pixels per GB pixel that fit the window. Zoom /
-- GBCFX / callers treat this as the crisp scale; endFrame converts to LOVE
-- units via / dpi when drawing.
-- integer scale that fits the GB UI viewport in the window
function Renderer:fitScale()
local _, _, pw, ph = displayMetrics()
return math.max(1, math.floor(math.min(pw / self.WIDTH, ph / self.HEIGHT)))
local ww, wh = love.graphics.getDimensions()
return math.max(1, math.floor(math.min(ww / self.WIDTH, wh / self.HEIGHT)))
end
-- world-pass canvas size in world pixels: enough to fill the window at s'.
@@ -105,13 +48,9 @@ end
-- background peeking at the receded top/bottom corners; flat mode returns
-- exactly today's size (growth factor is 1 when tilt is inactive).
function Renderer:worldViewSize()
local ww, wh, _, _, dpi = displayMetrics()
local s = Zoom.scale(self:fitScale()) / dpi
local ww, wh = love.graphics.getDimensions()
local s = Zoom.scale(self:fitScale())
local vw, vh = Zoom.fillViewSize(s, ww, wh)
-- Even sizes keep Camera:follow on integer pixels (viewW/2 is integral),
-- so unfloored FX/sprite math cannot phase-shimmer against the tile layer.
if vw % 2 ~= 0 then vw = vw + 1 end
if vh % 2 ~= 0 then vh = vh + 1 end
if Tilt.active() then
local g = Tilt.viewGrowth()
vw, vh = math.ceil(vw * g), math.ceil(vh * g)
@@ -123,12 +62,6 @@ end
function Renderer:beginFrame(transparent)
self.worldActive = false
self.uprightActive = false
self.worldOverride = nil
-- 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()
@@ -142,46 +75,6 @@ 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
@@ -393,14 +286,11 @@ end
-- presented through the GBC FX shader as a final pass.
function Renderer:endFrame(zones, worldZones)
love.graphics.setCanvas()
local ww, wh, pw, ph, dpi = displayMetrics()
-- Sp = integer framebuffer pixels per GB pixel; S = LOVE-unit draw scale.
local Sp = self:fitScale()
local S = Sp / dpi
local ww, wh = love.graphics.getDimensions()
local S = self:fitScale()
local vpw, vph = self.WIDTH * S, self.HEIGHT * S
-- Snap the letterbox origin to a framebuffer pixel, then convert to units.
local ox = math.floor((pw - self.WIDTH * Sp) / 2) / dpi
local oy = math.floor((ph - self.HEIGHT * Sp) / 2) / dpi
local ox = math.floor((ww - vpw) / 2)
local oy = math.floor((wh - vph) / 2)
local GBCFX = require("src.render.GBCFX")
-- Forced mono/Classic modes still need a whole-screen zone when a state
-- exposes no SGB packets (raw DMG canvas), so sendColors can remap.
@@ -413,11 +303,7 @@ function Renderer:endFrame(zones, worldZones)
zones = withTrueColor(zones, "ui")
worldZones = withTrueColor(worldZones, "world")
-- A post-process pipeline needs the whole composite in a canvas for the
-- same reason GBC FX does, so either one alone is enough to take the
-- present path; with neither, the frame draws straight to the screen
-- exactly as it always did.
local needPresent = GBCFX.active() or Pipelines.wantsPresent()
local needPresent = GBCFX.active()
local present = nil
if needPresent then
if not self.presentCanvas or self.presentCanvas:getWidth() ~= ww
@@ -428,26 +314,13 @@ function Renderer:endFrame(zones, worldZones)
present = self.presentCanvas
love.graphics.setCanvas(present)
end
-- 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.setColor(0, 0, 0, 1)
love.graphics.rectangle("fill", 0, 0, ww, wh)
love.graphics.setColor(1, 1, 1, 1)
-- blit `canvas` at `scale` (LOVE units) into origin (bx, by), scissored to
-- blit `canvas` at integer `scale` into origin (bx, by), scissored to
-- the (boxX, boxY, boxW, boxH) screen rect. zoneScale converts zone
-- coords (canvas-space) into screen units.
-- coords (canvas-space) into screen pixels.
local function blit(canvas, scale, zoneList, zoneScale, bx, by, boxX, boxY, boxW, boxH)
local shader = zoneList and zoneList[1] and PaletteFX.shader() or nil
if not shader then
@@ -478,33 +351,12 @@ function Renderer:endFrame(zones, worldZones)
love.graphics.setShader()
end
if self.worldOverride then
-- A render pipeline already produced the whole world -- terrain,
-- characters and its own FX overlay -- as one window-resolution image,
-- so it composites with a straight 1:1 blit and the world canvas is
-- skipped entirely (nothing drew into it). The UI blit below still
-- runs, so dialogs, menus and the HUD sit on top as usual.
love.graphics.setColor(1, 1, 1, 1)
love.graphics.setScissor(0, 0, ww, wh)
love.graphics.draw(self.worldOverride, 0, 0, 0, 1 / dpi, 1 / dpi)
love.graphics.setScissor()
-- the screen-space overlays the flat path draws over its composite
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
if self.battleCascadeProg then
self:drawBattleCascade(self.battleCascadeProg, ww, wh, ox, oy, vpw, vph, S)
end
elseif self.worldActive then
local sp = Zoom.scale(Sp)
local s = sp / dpi
if self.worldActive then
local s = Zoom.scale(S)
local wvw = self.worldCanvas:getWidth()
local wvh = self.worldCanvas:getHeight()
local wox = math.floor((pw - wvw * sp) / 2) / dpi
local woy = math.floor((ph - wvh * sp) / 2) / dpi
local wox = math.floor((ww - wvw * s) / 2)
local woy = math.floor((wh - wvh * s) / 2)
-- Tilt mode projects the ground world pass through the perspective mesh
-- (SGB zones baked in beforehand -- see drawTiltedWorld -- so no zone
-- scissoring here). drawTiltedWorld returns false when tilt is off or
@@ -562,47 +414,16 @@ 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()
-- Post-process pipelines run over the finished composite -- world, UI
-- and all -- and before GBC FX, so a blur or colour grade is what the
-- LCD grid is then drawn over rather than something that smears the
-- grid itself. Each pass hands back a canvas; with none registered
-- this returns `present` unchanged and the frame is byte-identical.
local composed = Pipelines.present(present,
{ width = ww, height = wh, scale = Sp, dpi = dpi }) or present
if GBCFX.active() then
-- shader grid/shadow math is in framebuffer pixels
GBCFX.present(composed, Sp)
else
-- the present canvas only existed for the post-process, so put the
-- result on the screen at the same 1:1 unit mapping it was built at
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(composed, 0, 0)
end
GBCFX.present(present, S)
end
self.worldActive = false
self.uprightActive = false
self.worldOverride = nil
PaletteFX.setPass(nil)
end
-27
View File
@@ -65,12 +65,8 @@ end
Assets.register(SpriteRenderer.invalidate)
-- exported: a render pipeline's own sprite geometry picks frames by the
-- same tables, so a 3D pose can never drift from the 2D one
local STAND = { down = 0, up = 1, left = 2, right = 2 }
local WALK = { down = 3, up = 4, left = 5, right = 5 }
SpriteRenderer.STAND = STAND
SpriteRenderer.WALK = WALK
-- seed: any stable per-instance value (e.g. an NPC's `id`) used to resolve
-- RED++'s per-instance "random" OBP sentinel (PaletteFX.spriteObp)
@@ -87,29 +83,6 @@ function SpriteRenderer.new(spriteDef, seed)
return self
end
-- The image this sprite would draw from right now: the plain sheet, or the
-- OBP-recolored bake of it. Exposed so a render pipeline can texture its
-- own geometry from the very same image -- the geometry carries sheet pixel
-- coordinates rather than baked colors, so sharing this one resolver is
-- what makes palette modes and sprite-replacing mods apply to 2D and 3D
-- alike.
--
-- Deliberately free of draw's bookkeeping: markTrueColor and
-- markSpriteRedraw exist to patch up the screen-space zone shader, and a
-- pipeline that renders into its own canvas never runs through it. For the
-- same reason the OG-RED bake is returned unconditionally here rather than
-- only during a redraw pass -- there is no later pass to restore it.
function SpriteRenderer:resolveImage()
if self.def.trueColor then return self.image end
if PaletteFX.usesGbcPack() then
local colors, group = PaletteFX.spriteObp(self.def, self.seed)
if colors then return getObpImage(self.def.image, colors, group) end
elseif PaletteFX.usesSpriteObp() then
return getObpImage(self.def.image, PaletteFX.GBC_OBJ, "gbcobj")
end
return self.image
end
-- facing: down/up/left/right; walkPhase: 0 stand, 1 walk; flip: alternate
-- steps mirror the walk frame for up/down (GB uses OAM flip for this).
local function blitFrame(image, quad, x, y, flip, redraw)
+23 -68
View File
@@ -50,7 +50,6 @@ function TextBox.new(game, text, onDone, opts)
self.charIndex = 0
self.shown = {} -- visible lines (max 2), each a list of glyph codes
self.waiting = false
self.contAdvance = false
self.done = false
self.blink = 0
self:beginLine()
@@ -66,11 +65,7 @@ TextBox.TOKENS = {
PLAYER = function(game) return game.save.player.name or "RED" end,
RIVAL = function(game) return game.save.player.rival or "BLUE" end,
RAM = function(game, arg)
if arg == "wStringBuffer" then return game.stringBuffer end
if arg == "wBoxNumString" then return game.boxNumString end
-- SendNewMonToBox / _SentToBoxText reads the deposited nick here
if arg == "wBoxMonNicks" then return game.boxMonNicks end
return nil
return arg == "wStringBuffer" and game.stringBuffer or nil
end,
}
@@ -88,55 +83,31 @@ end
-- Split marked-up text into pages of lines. \v-scrolled lines become
-- additional lines on the same page (the box scrolls them).
-- pages.contBefore[p][i] is true when line i was preceded by \v (cont):
-- pokered ContText waits for A/B + ▼ before scrolling that line in.
function TextBox.paginate(text, maxCols)
maxCols = maxCols or (Theme.textBox and Theme.textBox.maxCols) or MAX_COLS
local pages = {}
local contBefore = {}
local function pushLine(lines, conts, line, wait)
while #line > maxCols do
local cut = maxCols
for i = maxCols, 1, -1 do
if line:sub(i, i) == " " then cut = i break end
end
table.insert(lines, line:sub(1, cut))
table.insert(conts, wait)
wait = false
line = line:sub(cut + 1)
end
table.insert(lines, line)
table.insert(conts, wait)
end
for pageText in (text .. "\f"):gmatch("(.-)\f") do
if pageText ~= "" then
local lines, conts = {}, {}
local pos, waitNext = 1, false
while true do
local npos = pageText:find("[\n\v]", pos)
if not npos then
pushLine(lines, conts, pageText:sub(pos), waitNext)
break
local lines = {}
for chunk in (pageText .. "\n"):gmatch("(.-)[\n\v]") do
local line = chunk
-- wrap long lines defensively (the source rarely needs it)
while #line > maxCols do
local cut = maxCols
for i = maxCols, 1, -1 do
if line:sub(i, i) == " " then cut = i break end
end
table.insert(lines, line:sub(1, cut))
line = line:sub(cut + 1)
end
pushLine(lines, conts, pageText:sub(pos, npos - 1), waitNext)
waitNext = pageText:sub(npos, npos) == "\v"
pos = npos + 1
end
if lines[#lines] == "" then
table.remove(lines)
table.remove(conts)
end
if #lines > 0 then
table.insert(pages, lines)
table.insert(contBefore, conts)
table.insert(lines, line)
end
-- drop trailing empty line from the final gmatch round
if lines[#lines] == "" then table.remove(lines) end
if #lines > 0 then table.insert(pages, lines) end
end
end
if #pages == 0 then
pages = { { "" } }
contBefore = { { false } }
end
pages.contBefore = contBefore
if #pages == 0 then pages = { { "" } } end
return pages
end
@@ -208,17 +179,10 @@ function TextBox:update(dt)
if input:wasPressed("a") or input:wasPressed("b") then
require("src.core.Sound").play(self.game.data, "Press_AB")
self.waiting = false
if self.contAdvance then
-- ContText / ManualTextScroll: keep the box, scroll one line
self.contAdvance = false
self.lineIndex = self.lineIndex + 1
self:beginLine()
else
self.shown = {}
self.pageIndex = self.pageIndex + 1
self.lineIndex = 1
self:beginLine()
end
self.shown = {}
self.pageIndex = self.pageIndex + 1
self.lineIndex = 1
self:beginLine()
end
return
end
@@ -239,19 +203,10 @@ function TextBox:update(dt)
-- line finished
local page = self.pages[self.pageIndex]
if self.lineIndex < #page then
local nextIdx = self.lineIndex + 1
local conts = self.pages.contBefore and self.pages.contBefore[self.pageIndex]
if conts and conts[nextIdx] then
-- pokered <CONT>: ▼ + WaitForTextScrollButtonPress before scroll
self.waiting = true
self.contAdvance = true
else
self.lineIndex = nextIdx
self:beginLine()
end
self.lineIndex = self.lineIndex + 1
self:beginLine()
elseif self.pageIndex < #self.pages then
self.waiting = true
self.contAdvance = false
else
self.done = true
end
+31 -174
View File
@@ -10,57 +10,19 @@ TileRenderer.__index = TileRenderer
local BORDER_BLOCKS = 3 -- ring width; > half a screen (2.5 blocks)
-- 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).
-- 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)
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
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
if map.def.tileset == "OVERWORLD" then 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)
@@ -93,29 +55,8 @@ local FLOWER_IMAGES = {
local SPINNER_STRIP = "assets/generated/tilesets/spinners.png"
local animFrame = 0
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
function TileRenderer.tick()
animFrame = animFrame + 1
end
-- ------------------------------------------------------------------
@@ -179,11 +120,6 @@ local function recolorSample(r, g, b, a, colors)
return col[1] / 255, col[2] / 255, col[3] / 255, a
end
-- exported: a render pipeline bakes a map's palette into its own texture
-- atlas the same way, and has to land on the identical colors as the 2D
-- tiles it is standing in for
TileRenderer.recolorSample = recolorSample
-- the 8 shifted variants of one tile (built once per sheet + tile id [+
-- gbcKey, when `colors` recolors it for RED++ -- see buildAnim])
local shiftVariants = {}
@@ -530,118 +466,42 @@ function TileRenderer.new(map, data)
self.anims = anims
self.claimedBy = claimedBy
-- 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
-- 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
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
love.graphics.setCanvas()
love.graphics.pop()
local img = love.graphics.newImage(canvas:newImageData())
img:setWrap("repeat", "repeat")
img:setFilter("nearest", "nearest")
end
self.borderWaterTextures = textures
return true
end
self.borderFill = img
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)
return self
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)
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 not self.borderFill then 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
@@ -849,9 +709,6 @@ 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,16 +57,6 @@ end
function Transition:draw()
local alpha = self.t / self.frames
if self.phase == "in" then alpha = 1 - alpha end
-- Survey zoom draws the overworld into a window-filling world canvas
-- while the UI pass stays the classic 160x144 letterbox. A rect on the
-- UI canvas only darkens that center box (issue #121); when the world
-- pass ran this frame, hand the alpha to Renderer:endFrame so it paints
-- a screen-space overlay over the full composite instead.
local r = self.game and self.game.renderer
if r and r.worldActive then
r.worldFadeAlpha = alpha
return
end
love.graphics.setColor(0, 0, 0, alpha)
love.graphics.rectangle("fill", 0, 0, 160, 144)
love.graphics.setColor(1, 1, 1, 1)
+5 -41
View File
@@ -1,7 +1,6 @@
-- Overworld survey zoom: integer pixels-per-world-pixel scales stepped
-- 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).
-- 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.
-- Spec: docs/new-features.md (survey zoom)
local Zoom = {}
@@ -13,51 +12,16 @@ 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.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
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
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)
-813
View File
@@ -1,813 +0,0 @@
-- Vanilla Gen1 (Red/Blue, international) raw SRAM save (32768 bytes) <->
-- this project's save.lua shape (src/core/SaveData.lua / SaveData.newGame).
--
-- Pure Lua, no love.* dependency -- runs under plain luajit for the CLI
-- (tools/save_convert/convert.lua) and headless tests alike.
--
-- Every offset below was derived mechanically from the authoritative
-- source (../pokered/ram/wram.asm, ram/sram.asm, macros/ram.asm) and
-- cross-checked against three independently well-known Gen1 save
-- addresses: money @ 0x25F3, badges @ 0x2602, party data @ 0x2F2C --
-- all three fall out exactly right from the single sPlayerName anchor
-- below, strong triangulated confirmation the whole chain (SRAM bank 1
-- layout, wMainData field order, party_struct/box_struct sizes) is right.
--
-- SRAM layout (32768 bytes = 4 banks x 8192): bank 0 is sprite buffers +
-- Hall of Fame (not modeled -- see "explicitly out of scope" in the
-- save-converter plan); bank 1 is "Save Data" (sPlayerName through
-- sMainDataCheckSum); banks 2/3 are the 12 PC boxes (6 each) + checksums.
--
-- Fields with no equivalent in save.lua (current sprite/animation state,
-- connection-header cache, Day Care, Safari Zone, HOF roster) are
-- intentionally not modeled: on export, encode() starts from the
-- ORIGINAL imported bytes as a template when available (GenSave.decode
-- stashes them) so that scratch state round-trips untouched instead of
-- being invented; with no template (a save that originated in this
-- project) those bytes stay zero-filled, which is safe because the real
-- game regenerates all of it from wCurMap on the next map load anyway.
local bit = require("bit")
local GenSave = {}
-- ------------------------------------------------------------------
-- Absolute byte offsets (0-based, matching a raw 32768-byte .sav file)
-- ------------------------------------------------------------------
local NAME_LENGTH = 11
local PARTY_LENGTH = 6
local MONS_PER_BOX = 20
local NUM_BADGES = 8
local BOX_STRUCT_SIZE = 33 -- Species,HP,Level,Status,Type1,Type2,CatchRate,
-- Moves x4,OTID,Exp x3,HPExp,AtkExp,DefExp,
-- SpdExp,SpcExp,DVs,PP x4 (macros/ram.asm box_struct)
local PARTY_STRUCT_SIZE = 44 -- box_struct + Level + Stats x5 (party_struct)
local BOX_REGION_SIZE = 1 + (MONS_PER_BOX + 1) + MONS_PER_BOX * BOX_STRUCT_SIZE
+ MONS_PER_BOX * NAME_LENGTH + MONS_PER_BOX * NAME_LENGTH -- 1122
local O = {}
O.playerName = 9624 -- sPlayerName (11B) = 0x2598
O.mainData = O.playerName + NAME_LENGTH -- sMainData (wMainDataStart mirror)
O.pokedexOwned = O.mainData + 0 -- 19B (flag_array 151)
O.pokedexSeen = O.mainData + 19 -- 19B
O.numBagItems = O.mainData + 38 -- 1B
O.bagItems = O.mainData + 39 -- 41B (20 x (id,qty) + $FF term)
O.money = O.mainData + 80 -- 3B BCD = 0x25F3
O.rivalName = O.mainData + 83 -- 11B
O.options = O.mainData + 94 -- 1B
O.badges = O.mainData + 95 -- 1B = 0x2602
O.playerId = O.mainData + 98 -- 2B (big-endian)
O.curMap = O.mainData + 103 -- 1B
O.yCoord = O.mainData + 106 -- 1B
O.xCoord = O.mainData + 107 -- 1B
O.lastMap = O.mainData + 110 -- 1B
O.numPcItems = O.mainData + 579 -- 1B
O.pcItems = O.mainData + 580 -- 101B (50 x (id,qty) + $FF term)
O.currentBoxNum = O.mainData + 681 -- 1B (bits 0-6: box 0-11, bit 7: unused here)
O.coins = O.mainData + 685 -- 2B BCD
O.eventFlags = O.mainData + 1104 -- 320B (flag_array NUM_EVENTS = 2560 bits)
-- Play time (wPlayTimeHours/Maxed/Minutes/Seconds/Frames) lives INSIDE the
-- sMainData window (wMainDataStart..wMainDataEnd is copied verbatim into
-- SRAM), 1866 bytes past wMainDataStart -- reached from the checksum-verified
-- wEventFlags anchor: 320 (event flag_array) + 293 (the wGrassRate/enemy-party
-- battle UNION) + 66 + 66 (wEnemyMonOT/Nicks, 6 x NAME_LENGTH each; the
-- rgbds FOR n,1,PARTY_LENGTH+1 loop is end-exclusive => 6 mons, not 7) + 2
-- (wTrainerHeaderPtr) + 6 (ds) + 1 (wOpponentAfterWrongAnswer) + 1
-- (wCurMapScript) + 7 (ds) = 762. Confirmed on the real fixture: those five
-- bytes read 201h 30m 07s, a sane completed-save clock.
O.playTimeHours = O.mainData + 1866 -- 1B
O.playTimeMaxed = O.mainData + 1867 -- 1B (set once past 255h)
O.playTimeMinutes = O.mainData + 1868 -- 1B (0-59)
O.playTimeSeconds = O.mainData + 1869 -- 1B (0-59)
O.playTimeFrames = O.mainData + 1870 -- 1B (0-59, 1/60s ticks)
O.mainDataSize = 1929 -- wMainDataEnd - wMainDataStart
O.spriteData = O.mainData + O.mainDataSize
O.spriteDataSize = 512 -- 2 x 16 sprites x 16B
O.partyData = O.spriteData + O.spriteDataSize -- = 0x2F2C
O.partyCount = O.partyData
O.partySpecies = O.partyData + 1 -- 7B (PARTY_LENGTH+1)
O.partyMons = O.partyData + 8 -- 6 x 44B
O.partyMonOT = O.partyData + 8 + PARTY_LENGTH * PARTY_STRUCT_SIZE
O.partyMonNicks = O.partyMonOT + PARTY_LENGTH * NAME_LENGTH
O.partyDataSize = 1 + (PARTY_LENGTH + 1) + PARTY_LENGTH * PARTY_STRUCT_SIZE
+ PARTY_LENGTH * NAME_LENGTH + PARTY_LENGTH * NAME_LENGTH -- 404
O.curBoxData = O.partyData + O.partyDataSize
O.boxCount = O.curBoxData
O.boxSpecies = O.curBoxData + 1 -- 21B
O.boxMons = O.curBoxData + 22 -- 20 x 33B
O.boxMonOT = O.curBoxData + 22 + MONS_PER_BOX * BOX_STRUCT_SIZE
O.boxMonNicks = O.boxMonOT + MONS_PER_BOX * NAME_LENGTH
O.checksumStart = O.playerName
O.checksumEnd = O.curBoxData + BOX_REGION_SIZE + 1 -- + sTileAnimations (1B)
O.mainChecksum = O.checksumEnd -- 1B
O.box1 = 16384 -- bank 2 start
O.boxBank2Checksum = O.box1 + 6 * BOX_REGION_SIZE
O.boxBank2IndividualChecksums = O.boxBank2Checksum + 1 -- 6B
O.box7 = 24576 -- bank 3 start
O.boxBank3Checksum = O.box7 + 6 * BOX_REGION_SIZE
O.boxBank3IndividualChecksums = O.boxBank3Checksum + 1 -- 6B
GenSave.OFFSETS = O
GenSave.BOX_REGION_SIZE = BOX_REGION_SIZE
GenSave.SAVE_SIZE = 32768
-- ------------------------------------------------------------------
-- Byte-level helpers. `bytes` is a 32768-byte Lua string (1-based
-- indexing, so byte offset N is string position N+1); `buf` for writing
-- is a 32768-entry array of 1-char strings, joined at the end.
-- ------------------------------------------------------------------
local function u8(bytes, off) return bytes:byte(off + 1) end
local function u16be(bytes, off) return u8(bytes, off) * 256 + u8(bytes, off + 1) end
local function u24be(bytes, off)
return u8(bytes, off) * 65536 + u8(bytes, off + 1) * 256 + u8(bytes, off + 2)
end
local function setByte(buf, off, v)
buf[off + 1] = string.char(bit.band(v, 0xFF))
end
local function setU16be(buf, off, v)
setByte(buf, off, bit.band(bit.rshift(v, 8), 0xFF))
setByte(buf, off + 1, bit.band(v, 0xFF))
end
local function setU24be(buf, off, v)
setByte(buf, off, bit.band(bit.rshift(v, 16), 0xFF))
setByte(buf, off + 1, bit.band(bit.rshift(v, 8), 0xFF))
setByte(buf, off + 2, bit.band(v, 0xFF))
end
local function setBcd(buf, off, nbytes, v)
for i = nbytes - 1, 0, -1 do
local d = v % 100
v = math.floor(v / 100)
setByte(buf, off + i, math.floor(d / 10) * 16 + (d % 10))
end
end
local function readBcd(bytes, off, nbytes)
local n = 0
for i = 0, nbytes - 1 do
local b = u8(bytes, off + i)
n = n * 100 + math.floor(b / 16) * 10 + (b % 16)
end
return n
end
-- CalcCheckSum (engine/menus/save.asm): complement of the additive sum
local function checksum(bytes, from, to)
local sum = 0
for i = from, to - 1 do sum = bit.band(sum + u8(bytes, i), 0xFF) end
return bit.band(bit.bnot(sum), 0xFF)
end
-- flag_array packs LSB-first within each byte (bit 0 of byte 0 = index 0).
-- This is pokered's runtime FlagAction convention (home/predef macros): it
-- takes flag number N, addresses byte N/8, and builds the mask by rotating
-- a 1 left N%8 times starting from bit 0 -- i.e. flag N%8==0 is the LSB.
-- Same convention PKHeX uses for Gen1 dex/event flags (FlagUtil.GetFlag:
-- data[ofs + bit/8] >> (bit%8) & 1). Cross-validated against the real save:
-- ZAPDOS (dex 145) is physically boxed there, so its owned/seen flag must be
-- set; only the LSB reading returns it set (MSB-first spuriously drops
-- exactly that one bit at the byte-18 boundary), yielding a complete 151/151
-- dex. The prior MSB-first code round-tripped self-consistently but decoded
-- every flag_array (pokedex AND event flags) to the wrong bit.
local function bitGet(bytes, base, index)
local byteOff = base + math.floor(index / 8)
local b = u8(bytes, byteOff)
return bit.band(bit.rshift(b, index % 8), 1) == 1
end
-- Set one bit directly in `buf` (0-based flag index into a flag_array
-- starting at `base`), preserving every other bit already in that byte --
-- template bytes (see encode()'s header note) survive for bits this pass
-- never explicitly touches, e.g. event-flag bits with no known name
-- sharing a byte with ones that do.
local function bitSet(buf, base, index, value)
local byteOff = base + math.floor(index / 8)
local bitIdx = index % 8
local cur = buf[byteOff + 1] and buf[byteOff + 1]:byte() or 0
local mask = bit.lshift(1, bitIdx)
setByte(buf, byteOff, value and bit.bor(cur, mask) or bit.band(cur, bit.bnot(mask)))
end
-- ------------------------------------------------------------------
-- Text (fixed-length name fields: charmap-encoded, "@" ($50) terminated,
-- $50-padded after the terminator). setCharmap(cm) must be called once
-- before decode/encode (src/save_convert/data/charmap.lua's shape).
-- ------------------------------------------------------------------
local charmap
function GenSave.setCharmap(cm) charmap = cm end
local function decodeName(bytes, off, len)
local out = {}
for i = 0, len - 1 do
local b = u8(bytes, off + i)
if b == 0x50 then break end
out[#out + 1] = charmap.byByte[b] or "?"
end
return table.concat(out)
end
local function encodeName(buf, off, len, text)
local i, pos = 0, 1
while i < len - 1 and pos <= #text do
-- a bracketed control token (e.g. "<DOT>", from decodeName reading a
-- byte with no plain-glyph mapping) is ONE game character despite
-- being several text bytes here; match it as a whole unit first, or
-- it would fall through to per-byte matching and turn into "?" x5
local bracket = text:match("^(<[^<>]*>)", pos)
local ch, clen
if bracket and charmap.byToken[bracket] then
ch, clen = bracket, #bracket
else
local b0 = text:byte(pos)
clen = (b0 < 0x80 and 1) or (b0 < 0xE0 and 2) or (b0 < 0xF0 and 3) or 4
ch = text:sub(pos, pos + clen - 1)
end
setByte(buf, off + i, charmap.byToken[ch] or charmap.byToken["?"] or 0x50)
i, pos = i + 1, pos + clen
end
-- Write exactly ONE $50 terminator and then STOP. The bytes after it are
-- left untouched: when encoding over a template they stay as the original
-- save's post-terminator padding (so an unchanged name round-trips
-- byte-identical), and on a templateless export they stay zero-filled. The
-- game reads a name only up to the first $50, so whatever follows is inert.
if i < len then setByte(buf, off + i, 0x50) end
end
-- ------------------------------------------------------------------
-- DV / PP packing (box_struct DVs, PP)
-- ------------------------------------------------------------------
-- DVs:: dw, packed as byte0=(Attack<<4)|Defense, byte1=(Speed<<4)|Special;
-- HP DV is derived, not stored, from each stat DV's low bit.
local function decodeDVs(bytes, off)
local b0, b1 = u8(bytes, off), u8(bytes, off + 1)
local atk, def = bit.rshift(b0, 4), bit.band(b0, 0xF)
local spe, spc = bit.rshift(b1, 4), bit.band(b1, 0xF)
local hp = bit.bor(bit.lshift(bit.band(atk, 1), 3), bit.lshift(bit.band(def, 1), 2),
bit.lshift(bit.band(spe, 1), 1), bit.band(spc, 1))
return { hp = hp, attack = atk, defense = def, speed = spe, special = spc }
end
local function encodeDVs(buf, off, dvs)
setByte(buf, off, bit.bor(bit.lshift(bit.band(dvs.attack or 0, 0xF), 4), bit.band(dvs.defense or 0, 0xF)))
setByte(buf, off + 1, bit.bor(bit.lshift(bit.band(dvs.speed or 0, 0xF), 4), bit.band(dvs.special or 0, 0xF)))
end
-- PP byte: top 2 bits = PP Up count (0-3), bottom 6 bits = current PP
local function decodePPByte(b) return bit.band(b, 0x3F), bit.rshift(b, 6) end
local function encodePPByte(pp, ppUps) return bit.bor(bit.lshift(bit.band(ppUps or 0, 3), 6), bit.band(pp or 0, 0x3F)) end
-- ------------------------------------------------------------------
-- Crosswalks (built once from `data` = {pokemon=,moves=,items=,maps=})
-- ------------------------------------------------------------------
-- pokered constants/type_constants.asm PHYSICAL/SPECIAL block; stable,
-- not worth a dedicated extractor for 15 names.
local TYPE_BY_INDEX = {
[0] = "NORMAL", [1] = "FIGHTING", [2] = "FLYING", [3] = "POISON",
[4] = "GROUND", [5] = "ROCK", [6] = "BIRD", [7] = "BUG", [8] = "GHOST",
[20] = "FIRE", [21] = "WATER", [22] = "GRASS", [23] = "ELECTRIC",
[24] = "PSYCHIC_TYPE", [25] = "ICE", [26] = "DRAGON",
}
local TYPE_INDEX = {}
for i, name in pairs(TYPE_BY_INDEX) do TYPE_INDEX[name] = i end
-- Badge bit order (constants/ram_constants.asm BIT_BOULDERBADGE=0 ..
-- BIT_EARTHBADGE=7); this project stores badges as truthy
-- save.inventory[id] entries, not a flag or a separate bitmask
-- (src/inventory/Badges.lua Badges.list's VANILLA order matches exactly).
local BADGE_BY_BIT = {
[0] = "BOULDERBADGE", [1] = "CASCADEBADGE", [2] = "THUNDERBADGE",
[3] = "RAINBOWBADGE", [4] = "SOULBADGE", [5] = "MARSHBADGE",
[6] = "VOLCANOBADGE", [7] = "EARTHBADGE",
}
local BADGE_BY_BIT_SET = {}
for _, name in pairs(BADGE_BY_BIT) do BADGE_BY_BIT_SET[name] = true end
-- STATUS_* bits (constants/battle_constants.asm): 0-2 sleep-turns-left,
-- 3 PSN, 4 BRN, 5 FRZ, 6 PAR
local STATUS_BIT = { PSN = 3, BRN = 4, FRZ = 5, PAR = 6 }
local function decodeStatus(b)
if bit.band(b, 7) > 0 then return "SLP" end
for name, bitIdx in pairs(STATUS_BIT) do
if bit.band(b, bit.lshift(1, bitIdx)) ~= 0 then return name end
end
return nil
end
local function encodeStatus(status)
if status == "SLP" then return 7 end
if status and STATUS_BIT[status] then return bit.lshift(1, STATUS_BIT[status]) end
return 0
end
local function buildIndexCrosswalk(defs)
local byIndex, byId = {}, {}
for id, def in pairs(defs or {}) do
if def.index ~= nil then
byIndex[def.index] = id
byId[id] = def.index
end
end
return byIndex, byId
end
-- Pokedex bit position: NATIONAL DEX NUMBER (1-151), NOT the internal ROM
-- species byte (`def.index`, used for party/box mon structs) -- these are
-- two completely different Gen1 numbering schemes (the whole "MissingNo"
-- phenomenon is dex-number vs internal-index mismatches). This project's
-- generated data has no dedicated dex-number field, but every pokemon.lua
-- entry's `source` documents its extraction origin as "ROM:BaseStats[N]",
-- and BaseStats is declared in dex order in the disassembly -- verified
-- directly against 5 species (BULBASAUR->[1], CHARMANDER->[4],
-- SQUIRTLE->[7], PIKACHU->[25], MEWTWO->[150], all exactly their real
-- national dex numbers) before relying on it here.
local function buildDexCrosswalk(defs)
local byDex, dexOf = {}, {}
for id, def in pairs(defs or {}) do
local n = def.source and tonumber(def.source:match("BaseStats%[(%d+)%]"))
if n then
byDex[n] = id
dexOf[id] = n
end
end
return byDex, dexOf
end
-- TM/HM item entries carry no `index` (data/generated/items.lua extracts
-- them by move/slot, not by their place in the raw item-constant table),
-- so buildIndexCrosswalk alone would silently drop every TM/HM from the
-- bag/PC on encode. Their real item ids ARE derivable: pokered's
-- constants/item_constants.asm declares "HM_\1: the item id, starting at
-- $C4" and "TM_\1: the item id, starting at $C9" for slot 1, incrementing
-- per slot -- i.e. HM01=196+.. , TM01=201+(number-1).
local function addMachineIndices(defs, byIndex, byId)
for id, def in pairs(defs or {}) do
if byId[id] == nil and def.machine and def.machine.number then
local base = def.machine.kind == "HM" and 195 or 200
local idx = base + def.machine.number
byIndex[idx] = id
byId[id] = idx
end
end
end
function GenSave.crosswalks(data)
local pokemonByIndex, pokemonIndex = buildIndexCrosswalk(data.pokemon)
local movesByIndex, movesIndex = buildIndexCrosswalk(data.moves)
local itemsByIndex, itemsIndex = buildIndexCrosswalk(data.items)
addMachineIndices(data.items, itemsByIndex, itemsIndex)
local mapsByIndex, mapsIndex = buildIndexCrosswalk(data.maps)
local pokemonByDex, pokemonDex = buildDexCrosswalk(data.pokemon)
return {
pokemonByIndex = pokemonByIndex, pokemonIndex = pokemonIndex,
pokemonByDex = pokemonByDex, pokemonDex = pokemonDex,
movesByIndex = movesByIndex, movesIndex = movesIndex,
itemsByIndex = itemsByIndex, itemsIndex = itemsIndex,
mapsByIndex = mapsByIndex, mapsIndex = mapsIndex,
speciesDefs = data.pokemon or {},
}
end
-- ------------------------------------------------------------------
-- Mon struct (box_struct is a byte-for-byte prefix of party_struct;
-- decodeMon reads the box_struct fields, then Level+Stats if isParty).
-- Type1/Type2 are read for nothing (this project derives type from
-- species) but re-derived from data.pokemon[species].types on encode.
-- ------------------------------------------------------------------
local function decodeMon(bytes, off, isParty, cw)
local speciesIdx = u8(bytes, off)
if speciesIdx == 0 then return nil end -- empty slot
local species = cw.pokemonByIndex[speciesIdx]
local hp = u16be(bytes, off + 1)
local boxLevel = u8(bytes, off + 3)
local status = decodeStatus(u8(bytes, off + 4))
local catchRate = u8(bytes, off + 7)
local moves = {}
for i = 0, 3 do
local moveIdx = u8(bytes, off + 8 + i)
if moveIdx > 0 then
local pp, ppUps = decodePPByte(u8(bytes, off + 29 + i))
moves[#moves + 1] = { id = cw.movesByIndex[moveIdx], pp = pp, ppUps = ppUps }
end
end
local otId = u16be(bytes, off + 12)
local exp = u24be(bytes, off + 14)
local statExp = {
hp = u16be(bytes, off + 17), attack = u16be(bytes, off + 19),
defense = u16be(bytes, off + 21), speed = u16be(bytes, off + 23),
special = u16be(bytes, off + 25),
}
local dvs = decodeDVs(bytes, off + 27)
local mon = {
species = species, exp = exp, dvs = dvs, statExp = statExp,
hp = hp, status = status, moves = moves, otId = otId,
catchRate = catchRate, level = boxLevel,
-- Type1/Type2 as physically stored. This project derives type from species
-- for gameplay, but the raw bytes are captured so encode() can reproduce
-- them verbatim: some real saves (traded/tampered mons) carry type values
-- that do not match the ROM base stats, and re-deriving would corrupt them.
typeBytes = { u8(bytes, off + 5), u8(bytes, off + 6) },
}
if isParty then
mon.level = u8(bytes, off + 33)
mon.stats = {
hp = u16be(bytes, off + 34), attack = u16be(bytes, off + 36),
defense = u16be(bytes, off + 38), speed = u16be(bytes, off + 40),
special = u16be(bytes, off + 42),
}
end
return mon
end
local function encodeMon(buf, off, mon, isParty, cw)
if not mon then
setByte(buf, off, 0)
return
end
setByte(buf, off, cw.pokemonIndex[mon.species] or 0)
setU16be(buf, off + 1, mon.hp or 0)
setByte(buf, off + 3, mon.level or 1)
setByte(buf, off + 4, encodeStatus(mon.status))
local def = cw.speciesDefs[mon.species]
if mon.typeBytes then
-- reproduce the exact stored type bytes captured on decode (faithful
-- byte round-trip); fresh, engine-built mons have none and derive below.
setByte(buf, off + 5, mon.typeBytes[1] or 0)
setByte(buf, off + 6, mon.typeBytes[2] or 0)
else
local t = (def and def.types) or {}
setByte(buf, off + 5, TYPE_INDEX[t[1]] or 0)
setByte(buf, off + 6, TYPE_INDEX[t[2] or t[1]] or 0)
end
setByte(buf, off + 7, mon.catchRate or (def and def.catchRate) or 0)
for i = 0, 3 do
local mv = mon.moves and mon.moves[i + 1]
setByte(buf, off + 8 + i, mv and (cw.movesIndex[mv.id] or 0) or 0)
setByte(buf, off + 29 + i, mv and encodePPByte(mv.pp, mv.ppUps) or 0)
end
setU16be(buf, off + 12, mon.otId or 0)
setU24be(buf, off + 14, mon.exp or 0)
local se = mon.statExp or {}
setU16be(buf, off + 17, se.hp or 0)
setU16be(buf, off + 19, se.attack or 0)
setU16be(buf, off + 21, se.defense or 0)
setU16be(buf, off + 23, se.speed or 0)
setU16be(buf, off + 25, se.special or 0)
encodeDVs(buf, off + 27, mon.dvs or {})
if isParty then
setByte(buf, off + 33, mon.level or 1)
local st = mon.stats or {}
setU16be(buf, off + 34, st.hp or 0)
setU16be(buf, off + 36, st.attack or 0)
setU16be(buf, off + 38, st.defense or 0)
setU16be(buf, off + 40, st.speed or 0)
setU16be(buf, off + 42, st.special or 0)
end
end
-- ------------------------------------------------------------------
-- Bag / PC items: (id, qty) byte pairs, $FF-terminated
-- ------------------------------------------------------------------
local function decodeItemList(bytes, off, capacity, cw)
local inventory, order = {}, {}
for i = 0, capacity - 1 do
local idByte = u8(bytes, off + i * 2)
if idByte == 0xFF then break end
local qty = u8(bytes, off + i * 2 + 1)
local id = cw.itemsByIndex[idByte]
if id then
inventory[id] = qty
order[#order + 1] = id
end
end
return inventory, order
end
local function encodeItemList(buf, off, capacity, inventory, order, cw)
local i = 0
local seen = {}
local function put(id, qty)
if i >= capacity or not qty or qty <= 0 then return end
local idByte = cw.itemsIndex[id]
if not idByte then return end
setByte(buf, off + i * 2, idByte)
setByte(buf, off + i * 2 + 1, math.min(qty, 99))
i = i + 1
seen[id] = true
end
for _, id in ipairs(order or {}) do
if inventory[id] and not seen[id] then put(id, inventory[id]) end
end
for id, qty in pairs(inventory or {}) do
if not seen[id] then put(id, qty) end
end
setByte(buf, off + i * 2, 0xFF)
return i -- count actually written (badges etc. in `inventory` that
-- aren't real items are silently skipped by put(), so this
-- can be less than #inventory -- see the wNumBagItems caller)
end
-- ------------------------------------------------------------------
-- decode: raw 32768-byte SRAM string -> save.lua-shaped table
-- ------------------------------------------------------------------
function GenSave.decode(bytes, data, opts)
assert(#bytes == GenSave.SAVE_SIZE, "expected a 32768-byte save")
local cw = GenSave.crosswalks(data)
local warnings = {}
local function warn(msg) warnings[#warnings + 1] = msg end
if checksum(bytes, O.checksumStart, O.checksumEnd) ~= u8(bytes, O.mainChecksum) then
warn("main data checksum mismatch (importing anyway)")
end
local save = {
meta = { format = "gen1_import" },
player = {
name = decodeName(bytes, O.playerName, NAME_LENGTH),
rival = decodeName(bytes, O.rivalName, NAME_LENGTH),
id = u16be(bytes, O.playerId),
},
money = readBcd(bytes, O.money, 3),
coins = readBcd(bytes, O.coins, 2),
inventory = {},
pcItems = {},
pokedex = { seen = {}, owned = {} },
flags = {},
party = {},
boxes = {},
currentBox = 1,
}
-- pokedex (own/seen, flag_array NUM_POKEMON: bit 0 = dex #1)
for dex, species in pairs(cw.pokemonByDex) do
local bitIdx = dex - 1
if bitGet(bytes, O.pokedexOwned, bitIdx) then save.pokedex.owned[species] = true end
if bitGet(bytes, O.pokedexSeen, bitIdx) then save.pokedex.seen[species] = true end
end
save.inventory, save.bagOrder = decodeItemList(bytes, O.bagItems, 20, cw)
save.pcItems, save.pcOrder = decodeItemList(bytes, O.pcItems, 50, cw)
-- badges: truthy save.inventory[id] entries (src/inventory/Badges.lua),
-- set AFTER decodeItemList since that call replaces save.inventory
local badgesByte = u8(bytes, O.badges)
for i = 0, NUM_BADGES - 1 do
if bit.band(badgesByte, bit.lshift(1, i)) ~= 0 then
save.inventory[BADGE_BY_BIT[i]] = 1
end
end
-- party
local partyCount = u8(bytes, O.partyCount)
for i = 0, math.min(partyCount, PARTY_LENGTH) - 1 do
local mon = decodeMon(bytes, O.partyMons + i * PARTY_STRUCT_SIZE, true, cw)
if mon then
mon.ot = decodeName(bytes, O.partyMonOT + i * NAME_LENGTH, NAME_LENGTH)
mon.nickname = decodeName(bytes, O.partyMonNicks + i * NAME_LENGTH, NAME_LENGTH)
save.party[#save.party + 1] = mon
end
end
-- current box (bank 1) + the 11 stored boxes (banks 2/3)
for i = 1, 12 do save.boxes[i] = {} end
local function decodeBoxRegion(base, boxNum)
local count = u8(bytes, base)
for i = 0, math.min(count, MONS_PER_BOX) - 1 do
local mon = decodeMon(bytes, base + 22 + i * BOX_STRUCT_SIZE, false, cw)
if mon then
mon.ot = decodeName(bytes, base + 22 + MONS_PER_BOX * BOX_STRUCT_SIZE + i * NAME_LENGTH, NAME_LENGTH)
mon.nickname = decodeName(bytes, base + 22 + MONS_PER_BOX * (BOX_STRUCT_SIZE + NAME_LENGTH) + i * NAME_LENGTH, NAME_LENGTH)
table.insert(save.boxes[boxNum], mon)
end
end
end
local curBoxNum = bit.band(u8(bytes, O.currentBoxNum), 0x7F) -- 0-based box index
curBoxNum = math.max(1, math.min(12, curBoxNum + 1))
decodeBoxRegion(O.curBoxData, curBoxNum)
for b = 1, 6 do
if b + 0 ~= curBoxNum then decodeBoxRegion(O.box1 + (b - 1) * BOX_REGION_SIZE, b) end
end
for b = 7, 12 do
if b ~= curBoxNum then decodeBoxRegion(O.box7 + (b - 7) * BOX_REGION_SIZE, b) end
end
save.currentBox = curBoxNum
-- event flags (only bits with a known name are decoded)
local events = data.eventFlags
if events then
for bitIdx, name in pairs(events.byBit) do
if bitGet(bytes, O.eventFlags, bitIdx) then save.flags[name] = true end
end
end
-- map + position
local mapIdx = u8(bytes, O.curMap)
local mapId = cw.mapsByIndex[mapIdx]
local y, x = u8(bytes, O.yCoord), u8(bytes, O.xCoord)
if mapId then
save.player.map, save.player.x, save.player.y = mapId, x, y
else
warn(("unknown map index %d, defaulting spawn"):format(mapIdx))
end
local lastMapIdx = u8(bytes, O.lastMap)
local lastMapId = cw.mapsByIndex[lastMapIdx]
if lastMapId then save.lastOutdoor = { id = lastMapId } end
-- play time: this project stores save.playTime as a single float of
-- SECONDS (src/core/Game.lua accumulates dt each frame; StartMenu /
-- TrainerCard / TitleState render it H:MM via t/3600 and (t/60)%60).
-- Fold the Gen1 H/M/S/F fields into that one number; frames are 1/60s
-- sub-second ticks, kept as a fraction so an export recovers them exactly.
save.playTime = u8(bytes, O.playTimeHours) * 3600
+ u8(bytes, O.playTimeMinutes) * 60
+ u8(bytes, O.playTimeSeconds)
+ u8(bytes, O.playTimeFrames) / 60
save.warnings = warnings
save.rawImport = bytes -- template for a later encode(); see file header
return save
end
-- ------------------------------------------------------------------
-- encode: save.lua-shaped table -> raw 32768-byte SRAM string
-- ------------------------------------------------------------------
function GenSave.encode(save, data, template)
local cw = GenSave.crosswalks(data)
local src = template or save.rawImport
local buf = {}
if src then
for i = 1, GenSave.SAVE_SIZE do buf[i] = src:sub(i, i) end
else
local zero = string.char(0)
for i = 1, GenSave.SAVE_SIZE do buf[i] = zero end
end
encodeName(buf, O.playerName, NAME_LENGTH, (save.player and save.player.name) or "RED")
encodeName(buf, O.rivalName, NAME_LENGTH, (save.player and save.player.rival) or "BLUE")
setU16be(buf, O.playerId, (save.player and save.player.id) or 0)
setBcd(buf, O.money, 3, math.min(save.money or 0, 999999))
setBcd(buf, O.coins, 2, math.min(save.coins or 0, 9999))
local badgesByte = 0
for bitIdx, name in pairs(BADGE_BY_BIT) do
if save.inventory and save.inventory[name] then
badgesByte = bit.bor(badgesByte, bit.lshift(1, bitIdx))
end
end
setByte(buf, O.badges, badgesByte)
for dex, species in pairs(cw.pokemonByDex) do
local bitIdx = dex - 1
bitSet(buf, O.pokedexOwned, bitIdx,
(save.pokedex and save.pokedex.owned and save.pokedex.owned[species]) and true or false)
bitSet(buf, O.pokedexSeen, bitIdx,
(save.pokedex and save.pokedex.seen and save.pokedex.seen[species]) and true or false)
end
-- Badges occupy real item IDs in data/generated/items.lua (Gen1's item
-- ID space includes them, $01-$08, for the "got the BOULDERBADGE!"
-- text display), but this project's save.lua stores them as truthy
-- save.inventory[id] entries alongside actual bag items (see the badge
-- block above and src/inventory/Badges.lua) -- a real save NEVER
-- writes them into wBagItems (they only ever live in wObtainedBadges,
-- already encoded above), so they must be filtered out here or they'd
-- corrupt the bag with bogus "badge items".
local bagInventory = {}
for id, qty in pairs(save.inventory or {}) do
if not BADGE_BY_BIT_SET[id] then bagInventory[id] = qty end
end
local bagN = encodeItemList(buf, O.bagItems, 20, bagInventory, save.bagOrder, cw)
setByte(buf, O.numBagItems, bagN)
local pcN = encodeItemList(buf, O.pcItems, 50, save.pcItems or {}, save.pcOrder, cw)
setByte(buf, O.numPcItems, pcN)
local events = data.eventFlags
if events and save.flags then
for name in pairs(save.flags) do
local bitIdx = events.byName[name]
if bitIdx then bitSet(buf, O.eventFlags, bitIdx, true) end
end
end
-- party
local party = save.party or {}
local partyN = math.min(#party, PARTY_LENGTH)
setByte(buf, O.partyCount, partyN)
for i = 0, partyN - 1 do
local mon = party[i + 1]
encodeMon(buf, O.partyMons + i * PARTY_STRUCT_SIZE, mon, true, cw)
setByte(buf, O.partySpecies + i, cw.pokemonIndex[mon.species] or 0)
encodeName(buf, O.partyMonOT + i * NAME_LENGTH, NAME_LENGTH,
mon.ot or (save.player and save.player.name) or "RED")
encodeName(buf, O.partyMonNicks + i * NAME_LENGTH, NAME_LENGTH,
mon.nickname or mon.species or "")
end
-- $FF-terminate the species index list right after the last real mon. The
-- struct, OT-name and nickname bytes of the empty slots past partyN are left
-- exactly as the template holds them (original stale data -> byte-identical
-- round-trip) or zero on a fresh export -- the game never reads past the
-- count, so this matches how it leaves those bytes itself.
setByte(buf, O.partySpecies + partyN, 0xFF)
-- boxes: current box mirrors save.currentBox into sCurBoxData; all 12
-- also get written into their bank-2/3 slot (sCurBoxData is a working
-- copy the real game keeps in sync on every PC visit, so keeping both
-- copies consistent here matches that invariant)
local function encodeBoxRegion(base, mons)
local n = math.min(#mons, MONS_PER_BOX)
setByte(buf, base, n)
for i = 0, n - 1 do
local mon = mons[i + 1]
encodeMon(buf, base + 22 + i * BOX_STRUCT_SIZE, mon, false, cw)
setByte(buf, base + 1 + i, cw.pokemonIndex[mon.species] or 0)
encodeName(buf, base + 22 + MONS_PER_BOX * BOX_STRUCT_SIZE + i * NAME_LENGTH, NAME_LENGTH,
mon.ot or (save.player and save.player.name) or "RED")
encodeName(buf, base + 22 + MONS_PER_BOX * (BOX_STRUCT_SIZE + NAME_LENGTH) + i * NAME_LENGTH, NAME_LENGTH,
mon.nickname or mon.species or "")
end
-- $FF-terminate the species list after the last real mon; empty slots past
-- n keep their template bytes (byte-identical round-trip) or zero (fresh
-- export), just as the game leaves stale box data untouched past the count.
setByte(buf, base + 1 + n, 0xFF)
end
local boxes = save.boxes or {}
local curBoxNum = math.max(1, math.min(12, save.currentBox or 1))
encodeBoxRegion(O.curBoxData, boxes[curBoxNum] or {})
-- bit 7 of wCurBoxNum is the "box system initialized" flag, not part of the
-- 0-11 index; preserve it from the template, or set it on a templateless
-- export (any save we emit has an initialized box system).
local prevBoxByte = buf[O.currentBoxNum + 1]
local boxHiBit = (src and prevBoxByte) and bit.band(prevBoxByte:byte(), 0x80) or 0x80
setByte(buf, O.currentBoxNum, bit.bor(bit.band(curBoxNum - 1, 0x7F), boxHiBit))
for b = 1, 6 do encodeBoxRegion(O.box1 + (b - 1) * BOX_REGION_SIZE, boxes[b] or {}) end
for b = 7, 12 do encodeBoxRegion(O.box7 + (b - 7) * BOX_REGION_SIZE, boxes[b] or {}) end
-- map + position
if save.player and save.player.map then
setByte(buf, O.curMap, cw.mapsIndex[save.player.map] or 0)
setByte(buf, O.yCoord, save.player.y or 0)
setByte(buf, O.xCoord, save.player.x or 0)
end
if save.lastOutdoor and save.lastOutdoor.id then
setByte(buf, O.lastMap, cw.mapsIndex[save.lastOutdoor.id] or 0)
end
-- play time: split save.playTime (seconds) back into H/M/S/F. The real
-- game freezes the clock at 255h and sets wPlayTimeMaxed once past it, so
-- mirror that cap rather than letting hours overflow a single byte.
local totalFrames = math.floor((save.playTime or 0) * 60 + 0.5)
local hours = math.floor(totalFrames / 216000) -- 3600s * 60 frames
if hours > 255 then
setByte(buf, O.playTimeHours, 255)
setByte(buf, O.playTimeMaxed, 1)
setByte(buf, O.playTimeMinutes, 59)
setByte(buf, O.playTimeSeconds, 59)
setByte(buf, O.playTimeFrames, 59)
else
local rem = totalFrames - hours * 216000
local mins = math.floor(rem / 3600); rem = rem - mins * 3600
local secs = math.floor(rem / 60)
setByte(buf, O.playTimeHours, hours)
setByte(buf, O.playTimeMaxed, 0)
setByte(buf, O.playTimeMinutes, mins)
setByte(buf, O.playTimeSeconds, secs)
setByte(buf, O.playTimeFrames, rem - secs * 60)
end
local out = table.concat(buf)
-- checksums, computed last over the now-final bytes
local outBuf = {}
for i = 1, #out do outBuf[i] = out:sub(i, i) end
setByte(outBuf, O.mainChecksum, checksum(out, O.checksumStart, O.checksumEnd))
-- Per pokered (engine/menus/save.asm SaveSAVtoSRAM / CalcCheckSum): each box
-- gets its own checksum, and the bank aggregate is CalcCheckSum over the
-- ENTIRE six-box region (6 x 1122 bytes), not a sum of the six box sums.
local function boxChecksum(base) return checksum(out, base, base + BOX_REGION_SIZE) end
for b = 0, 5 do
setByte(outBuf, O.boxBank2IndividualChecksums + b,
boxChecksum(O.box1 + b * BOX_REGION_SIZE))
end
setByte(outBuf, O.boxBank2Checksum,
checksum(out, O.box1, O.box1 + 6 * BOX_REGION_SIZE))
for b = 0, 5 do
setByte(outBuf, O.boxBank3IndividualChecksums + b,
boxChecksum(O.box7 + b * BOX_REGION_SIZE))
end
setByte(outBuf, O.boxBank3Checksum,
checksum(out, O.box7, O.box7 + 6 * BOX_REGION_SIZE))
return table.concat(outBuf)
end
return GenSave
-189
View File
@@ -1,189 +0,0 @@
-- SaveConvert -- the runtime-facing entry point the launcher UI calls to
-- turn a vanilla Gen1 (Red/Blue, international) battery save into this
-- project's in-memory save table, and back out to a raw .sav image.
--
-- This is the ONE place the engine, the tests and the CLI
-- (tools/save_convert/convert.lua) share: the GenSave codec, the crosswalk
-- data loading, the merge over new-game defaults, and the version tag all
-- live here so every consumer behaves identically.
--
-- Pure Lua, no love.* dependency at require time: GenSave and the crosswalk
-- tables load through `require`, exactly how src/core/Data.lua pulls the
-- generated modules -- which resolves under both plain luajit (package.path
-- "./?.lua") for the headless CLI/tests and love.filesystem for a fused
-- build, with an OS-path fallback for odd working directories. The only
-- place `love` is referenced is inside a guarded fallback, so running under
-- stock Lua never touches it.
local GenSave = require("src.save_convert.GenSave")
local SaveConvert = {}
SaveConvert.SAVE_SIZE = GenSave.SAVE_SIZE
-- ------------------------------------------------------------------
-- Crosswalk data loading (cached). Mirrors src/core/Data.lua: prefer
-- `require` (works headless via package.path and fused via love's package
-- searcher); fall back to love.filesystem.load, then a plain dofile, for
-- the rare case a host has an unusual cwd or module path.
-- ------------------------------------------------------------------
-- { require-module-path, os-relative-file-path } for each table the codec
-- needs. pokemon/moves/items/maps come from the shared generated data;
-- charmap/event_flags are the save-convert-specific crosswalks.
local DATA_MODULES = {
pokemon = { "data.generated.pokemon", "data/generated/pokemon.lua" },
moves = { "data.generated.moves", "data/generated/moves.lua" },
items = { "data.generated.items", "data/generated/items.lua" },
maps = { "data.generated.maps", "data/generated/maps.lua" },
charmap = { "src.save_convert.data.charmap", "src/save_convert/data/charmap.lua" },
eventFlags = { "src.save_convert.data.event_flags", "src/save_convert/data/event_flags.lua" },
}
local function loadTable(requirePath, filePath)
local ok, mod = pcall(require, requirePath)
if ok and type(mod) == "table" then return mod end
-- fused build with an unexpected module path: read straight off the
-- mounted filesystem (love is a global here, only ever touched when it
-- actually exists -- stock Lua never reaches this branch)
if love and love.filesystem and love.filesystem.getInfo
and love.filesystem.getInfo(filePath) then
local chunk = love.filesystem.load(filePath)
if chunk then
local m = chunk()
if type(m) == "table" then return m end
end
end
local chunk = loadfile(filePath)
if chunk then
local m = chunk()
if type(m) == "table" then return m end
end
return nil, ("cannot load save-convert data module %q (tried require %q and file %q)")
:format(requirePath, requirePath, filePath)
end
local crosswalk -- { pokemon=, moves=, items=, maps=, eventFlags= }
local charmapReady
local function ensureData()
if not crosswalk then
local data = {}
for key, spec in pairs(DATA_MODULES) do
if key ~= "charmap" then
local mod, e = loadTable(spec[1], spec[2])
if not mod then return nil, e end
data[key] = mod
end
end
crosswalk = data
end
if not charmapReady then
local cm, err = loadTable(DATA_MODULES.charmap[1], DATA_MODULES.charmap[2])
if not cm then return nil, err end
GenSave.setCharmap(cm)
charmapReady = true
end
return crosswalk
end
-- Exposed for the CLI/tests so they can share the exact data set the codec
-- uses (and so a caller can pre-warm the cache). Returns data, err.
function SaveConvert.loadData()
return ensureData()
end
-- ------------------------------------------------------------------
-- new-game default skeleton the decoded fields merge on top of. Carried
-- verbatim from tools/save_convert/convert.lua so the CLI and the runtime
-- produce a byte-identical save table for the same input.
-- ------------------------------------------------------------------
local function defaultsSave()
return {
meta = { format = "gen1_import", mods = {} },
defeatedTrainers = {},
repelSteps = 0,
modData = {},
options = {
textSpeed = 3, animations = true, battleStyle = "shift",
ruleset = "gen1_faithful", musicVol = 7, sfxVol = 7, musicFilter = 0,
speed = 1, colors = "gbc", tilt = 0, gbcfx = 0,
videoMode = "windowed", mods = {},
},
}
end
-- Merge a GenSave.decode() result over the new-game defaults, exactly the
-- way convert.lua did, then stamp the requested version. The 32768-byte
-- import template GenSave stashes as `rawImport` and the decode `warnings`
-- are dropped here: neither belongs in a serialized slot file (a fresh
-- export always starts zero-filled -- see GenSave.lua's header).
local function mergeDefaults(decoded, version)
decoded.warnings = nil
decoded.rawImport = nil
local save = defaultsSave()
for k, v in pairs(decoded) do save[k] = v end
save.lastHeal = { map = save.player.map, x = save.player.x, y = save.player.y }
save.lastOutdoor = save.lastOutdoor or { id = save.player.map }
if version ~= nil then
save.meta = save.meta or {}
save.meta.version = version
end
return save
end
SaveConvert.mergeDefaults = mergeDefaults
-- ------------------------------------------------------------------
-- Public API
-- ------------------------------------------------------------------
-- importSav(bytes, version) -> saveTable, err
-- bytes: the raw 32768-byte SRAM string. Validates size and the main-data
-- checksum, decodes through GenSave, and returns a save table fully merged
-- over the new-game defaults and tagged with `version`, ready to hand to
-- SaveSerializer.encode for a slot file. On any failure returns nil + a
-- message (never raises).
function SaveConvert.importSav(bytes, version)
if type(bytes) ~= "string" then
return nil, "expected raw save bytes as a string"
end
if #bytes ~= GenSave.SAVE_SIZE then
return nil, ("save must be %d bytes, got %d"):format(GenSave.SAVE_SIZE, #bytes)
end
local data, derr = ensureData()
if not data then return nil, derr end
local ok, decoded = pcall(GenSave.decode, bytes, data)
if not ok then return nil, "decode failed: " .. tostring(decoded) end
-- checksum validation: GenSave.decode records a warning rather than
-- throwing (so it can still read a foreign/corrupt save), but for the
-- runtime import path a bad main-data checksum means the file is not a
-- trustworthy save, so reject it.
for _, w in ipairs(decoded.warnings or {}) do
if tostring(w):find("checksum") then
return nil, "save data checksum invalid (" .. tostring(w) .. ")"
end
end
return mergeDefaults(decoded, version)
end
-- exportSav(saveTable) -> bytes, err
-- Encodes a save table back to a raw 32768-byte SRAM image. Template-aware:
-- if the table still carries the stashed import template (saveTable.rawImport)
-- GenSave reproduces every unmodeled region from it; otherwise those regions
-- are zero-filled. On failure returns nil + a message (never raises).
function SaveConvert.exportSav(saveTable)
if type(saveTable) ~= "table" then
return nil, "expected a save table"
end
local data, derr = ensureData()
if not data then return nil, derr end
local ok, bytes = pcall(GenSave.encode, saveTable, data, nil)
if not ok then return nil, "encode failed: " .. tostring(bytes) end
return bytes
end
return SaveConvert
-579
View File
@@ -1,579 +0,0 @@
-- Generated by tools/build_data.py. DO NOT EDIT.
-- Gen1 text byte <-> glyph/token charmap (fixed-length name
-- fields: player/rival/OT names, nicknames -- box/party mon
-- names are NUL-free, '@' ($50) terminated, space-padded).
-- byToken's key is the literal glyph for ordinary characters
-- ("A", "é", ...) or a bracketed control token
-- ("<PLAYER>", "@") for terminators/substitutions -- only
-- the plain single-glyph entries are meaningful inside a name.
return {
byByte = {
[0] = "<NULL>",
[5] = "",
[6] = "",
[7] = "",
[8] = "",
[9] = "",
[10] = "",
[11] = "",
[12] = "",
[13] = "",
[14] = "",
[15] = "",
[16] = "",
[17] = "",
[18] = "",
[19] = "",
[25] = "",
[26] = "",
[27] = "",
[28] = "",
[38] = "",
[39] = "",
[40] = "",
[41] = "",
[42] = "",
[43] = "",
[44] = "",
[45] = "",
[46] = "",
[47] = "",
[48] = "",
[49] = "",
[50] = "",
[51] = "",
[52] = "",
[58] = "",
[59] = "",
[60] = "",
[61] = "",
[62] = "",
[64] = "",
[65] = "",
[66] = "",
[67] = "",
[68] = "",
[69] = "",
[70] = "",
[71] = "",
[72] = "",
[73] = "<PAGE>",
[74] = "<PKMN>",
[75] = "<_CONT>",
[76] = "<SCROLL>",
[78] = "<NEXT>",
[79] = "<LINE>",
[80] = "@",
[81] = "<PARA>",
[82] = "<PLAYER>",
[83] = "<RIVAL>",
[84] = "#",
[85] = "<CONT>",
[86] = "<……>",
[87] = "<DONE>",
[88] = "<PROMPT>",
[89] = "<TARGET>",
[90] = "<USER>",
[91] = "<PC>",
[92] = "<TM>",
[93] = "<TRAINER>",
[94] = "<ROCKET>",
[95] = "<DEXEND>",
[96] = "<BOLD_A>",
[97] = "<BOLD_B>",
[98] = "<BOLD_C>",
[99] = "<BOLD_D>",
[100] = "<BOLD_E>",
[101] = "<BOLD_F>",
[102] = "<BOLD_G>",
[103] = "<BOLD_H>",
[104] = "<BOLD_I>",
[105] = "<BOLD_V>",
[106] = "<BOLD_S>",
[107] = "<BOLD_L>",
[108] = "<BOLD_M>",
[109] = "<COLON>",
[110] = "",
[111] = "",
[112] = "",
[113] = "",
[114] = "",
[115] = "",
[116] = "·",
[117] = "",
[118] = "",
[119] = "",
[120] = "",
[121] = "",
[122] = "",
[123] = "",
[124] = "",
[125] = "",
[126] = "",
[127] = " ",
[128] = "A",
[129] = "B",
[130] = "C",
[131] = "D",
[132] = "E",
[133] = "F",
[134] = "G",
[135] = "H",
[136] = "I",
[137] = "J",
[138] = "K",
[139] = "L",
[140] = "M",
[141] = "N",
[142] = "O",
[143] = "P",
[144] = "Q",
[145] = "R",
[146] = "S",
[147] = "T",
[148] = "U",
[149] = "V",
[150] = "W",
[151] = "X",
[152] = "Y",
[153] = "Z",
[154] = "(",
[155] = ")",
[156] = ":",
[157] = ";",
[158] = "[",
[159] = "]",
[160] = "a",
[161] = "b",
[162] = "c",
[163] = "d",
[164] = "e",
[165] = "f",
[166] = "g",
[167] = "h",
[168] = "i",
[169] = "j",
[170] = "k",
[171] = "l",
[172] = "m",
[173] = "n",
[174] = "o",
[175] = "p",
[176] = "q",
[177] = "r",
[178] = "s",
[179] = "t",
[180] = "u",
[181] = "v",
[182] = "w",
[183] = "x",
[184] = "y",
[185] = "z",
[186] = "é",
[187] = "'d",
[188] = "'l",
[189] = "'s",
[190] = "'t",
[191] = "'v",
[192] = "",
[193] = "",
[194] = "",
[195] = "",
[196] = "",
[197] = "",
[198] = "",
[199] = "",
[200] = "",
[201] = "",
[202] = "",
[203] = "",
[204] = "",
[205] = "",
[206] = "",
[207] = "",
[208] = "",
[209] = "",
[210] = "",
[211] = "",
[212] = "",
[213] = "",
[214] = "",
[215] = "",
[216] = "",
[217] = "",
[218] = "",
[219] = "",
[220] = "",
[221] = "",
[222] = "",
[223] = "",
[224] = "'",
[225] = "<PK>",
[226] = "<MN>",
[227] = "-",
[228] = "'r",
[229] = "'m",
[230] = "?",
[231] = "!",
[232] = ".",
[233] = "",
[234] = "",
[235] = "",
[236] = "",
[237] = "",
[238] = "",
[239] = "",
[240] = "<ED>",
[241] = "×",
[242] = "<DOT>",
[243] = "/",
[244] = ",",
[245] = "",
[246] = "0",
[247] = "1",
[248] = "2",
[249] = "3",
[250] = "4",
[251] = "5",
[252] = "6",
[253] = "7",
[254] = "8",
[255] = "9",
},
byToken = {
[" "] = 127,
["!"] = 231,
["#"] = 84,
["'"] = 224,
["'d"] = 187,
["'l"] = 188,
["'m"] = 229,
["'r"] = 228,
["'s"] = 189,
["'t"] = 190,
["'v"] = 191,
["("] = 154,
[")"] = 155,
[","] = 244,
["-"] = 227,
["."] = 232,
["/"] = 243,
["0"] = 246,
["1"] = 247,
["2"] = 248,
["3"] = 249,
["4"] = 250,
["5"] = 251,
["6"] = 252,
["7"] = 253,
["8"] = 254,
["9"] = 255,
[":"] = 156,
[";"] = 157,
["<BOLD_A>"] = 96,
["<BOLD_B>"] = 97,
["<BOLD_C>"] = 98,
["<BOLD_D>"] = 99,
["<BOLD_E>"] = 100,
["<BOLD_F>"] = 101,
["<BOLD_G>"] = 102,
["<BOLD_H>"] = 103,
["<BOLD_I>"] = 104,
["<BOLD_L>"] = 107,
["<BOLD_M>"] = 108,
["<BOLD_P>"] = 114,
["<BOLD_S>"] = 106,
["<BOLD_V>"] = 105,
["<COLON>"] = 109,
["<CONT>"] = 85,
["<DEXEND>"] = 95,
["<DONE>"] = 87,
["<DOT>"] = 242,
["<ED>"] = 240,
["<ID>"] = 115,
["<LINE>"] = 79,
["<LV>"] = 110,
["<MN>"] = 226,
["<NEXT>"] = 78,
["<NULL>"] = 0,
["<PAGE>"] = 73,
["<PARA>"] = 81,
["<PC>"] = 91,
["<PK>"] = 225,
["<PKMN>"] = 74,
["<PLAYER>"] = 82,
["<PROMPT>"] = 88,
["<RIVAL>"] = 83,
["<ROCKET>"] = 94,
["<SCROLL>"] = 76,
["<TARGET>"] = 89,
["<TM>"] = 92,
["<TRAINER>"] = 93,
["<USER>"] = 90,
["<_CONT>"] = 75,
["<to>"] = 112,
["<……>"] = 86,
["?"] = 230,
["@"] = 80,
A = 128,
B = 129,
C = 130,
D = 131,
E = 132,
F = 133,
G = 134,
H = 135,
I = 136,
J = 137,
K = 138,
L = 139,
M = 140,
N = 141,
O = 142,
P = 143,
Q = 144,
R = 145,
S = 146,
T = 147,
U = 148,
V = 149,
W = 150,
X = 151,
Y = 152,
Z = 153,
["["] = 158,
["]"] = 159,
a = 160,
b = 161,
c = 162,
d = 163,
e = 164,
f = 165,
g = 166,
h = 167,
i = 168,
j = 169,
k = 170,
l = 171,
m = 172,
n = 173,
o = 174,
p = 175,
q = 176,
r = 177,
s = 178,
t = 179,
u = 180,
v = 181,
w = 182,
x = 183,
y = 184,
z = 185,
["¥"] = 240,
["·"] = 116,
["×"] = 241,
["é"] = 186,
[""] = 112,
[""] = 113,
[""] = 114,
[""] = 115,
[""] = 117,
[""] = 96,
[""] = 97,
[""] = 116,
[""] = 117,
[""] = 122,
[""] = 124,
[""] = 121,
[""] = 123,
[""] = 125,
[""] = 126,
[""] = 237,
[""] = 237,
[""] = 236,
[""] = 238,
[""] = 245,
[""] = 239,
[" "] = 127,
[""] = 232,
[""] = 112,
[""] = 113,
[""] = 114,
[""] = 115,
[""] = 118,
[""] = 177,
[""] = 110,
[""] = 178,
[""] = 111,
[""] = 179,
[""] = 119,
[""] = 180,
[""] = 120,
[""] = 181,
[""] = 182,
[""] = 38,
[""] = 183,
[""] = 39,
[""] = 184,
[""] = 40,
[""] = 185,
[""] = 41,
[""] = 186,
[""] = 42,
[""] = 187,
[""] = 43,
[""] = 188,
[""] = 44,
[""] = 189,
[""] = 45,
[""] = 190,
[""] = 46,
[""] = 191,
[""] = 47,
[""] = 192,
[""] = 48,
[""] = 193,
[""] = 49,
[""] = 223,
[""] = 194,
[""] = 50,
[""] = 195,
[""] = 51,
[""] = 196,
[""] = 52,
[""] = 197,
[""] = 198,
[""] = 199,
[""] = 200,
[""] = 201,
[""] = 202,
[""] = 58,
[""] = 68,
[""] = 203,
[""] = 59,
[""] = 69,
[""] = 204,
[""] = 60,
[""] = 70,
[""] = 205,
[""] = 61,
[""] = 71,
[""] = 206,
[""] = 62,
[""] = 72,
[""] = 207,
[""] = 208,
[""] = 209,
[""] = 210,
[""] = 211,
[""] = 224,
[""] = 212,
[""] = 225,
[""] = 213,
[""] = 226,
[""] = 214,
[""] = 215,
[""] = 216,
[""] = 217,
[""] = 218,
[""] = 219,
[""] = 220,
[""] = 221,
[""] = 222,
[""] = 233,
[""] = 128,
[""] = 176,
[""] = 129,
[""] = 234,
[""] = 130,
[""] = 235,
[""] = 131,
[""] = 244,
[""] = 132,
[""] = 133,
[""] = 5,
[""] = 134,
[""] = 6,
[""] = 135,
[""] = 7,
[""] = 136,
[""] = 8,
[""] = 137,
[""] = 9,
[""] = 138,
[""] = 10,
[""] = 139,
[""] = 11,
[""] = 140,
[""] = 12,
[""] = 141,
[""] = 13,
[""] = 142,
[""] = 14,
[""] = 143,
[""] = 15,
[""] = 144,
[""] = 16,
[""] = 172,
[""] = 145,
[""] = 17,
[""] = 146,
[""] = 18,
[""] = 147,
[""] = 19,
[""] = 148,
[""] = 149,
[""] = 150,
[""] = 151,
[""] = 152,
[""] = 153,
[""] = 25,
[""] = 64,
[""] = 154,
[""] = 26,
[""] = 65,
[""] = 155,
[""] = 27,
[""] = 66,
[""] = 156,
[""] = 28,
[""] = 67,
[""] = 157,
[""] = 158,
[""] = 159,
[""] = 160,
[""] = 161,
[""] = 173,
[""] = 162,
[""] = 174,
[""] = 163,
[""] = 175,
[""] = 164,
[""] = 165,
[""] = 166,
[""] = 167,
[""] = 168,
[""] = 169,
[""] = 170,
[""] = 171,
[""] = 227,
[""] = 240,
[""] = 231,
[""] = 242,
[""] = 243,
[""] = 246,
[""] = 247,
[""] = 248,
[""] = 249,
[""] = 250,
[""] = 251,
[""] = 252,
[""] = 253,
[""] = 254,
[""] = 255,
[""] = 230,
[""] = 229,
[""] = 228,
},
source = "pokered constants/charmap.asm",
}
File diff suppressed because it is too large Load Diff
+25 -186
View File
@@ -261,9 +261,7 @@ function Commands.move_player(ctx, dir, tiles)
end
function Commands.move_npc(ctx, objIndex, dir, tiles)
local ow = ctx.overworld
if not ow then return end
local npc = ow:npcByIndex(objIndex)
local npc = ctx.overworld:npcByIndex(objIndex)
if npc then walkEntity(ctx, npc, dir, tiles) end
end
@@ -310,7 +308,6 @@ end
function Commands.move_npc_to(ctx, objIndex, tx, ty)
local ow = ctx.overworld
if not ow then return end
local npc = ow:npcByIndex(objIndex)
if not npc then return end
local path = bfsPath(ow.map, npc.cellX, npc.cellY, tx, ty, ow.entities, npc)
@@ -342,20 +339,6 @@ function Commands.face_object(ctx, objIndex, dir)
if npc then npc.facing = dir end
end
-- Instantly relocate an NPC (OaksLabCalcRivalMovementScript / SetSpritePosition1).
-- facing is optional. Headless-safe no-op without an overworld.
function Commands.place_npc(ctx, objIndex, x, y, facing)
local ow = ctx.overworld
if not ow then return end
local npc = ow:npcByIndex(objIndex)
if not npc then return end
npc.cellX, npc.cellY = x, y
npc.px, npc.py = x * 16, y * 16
npc.moving = false
npc.targetX, npc.targetY = nil, nil
if facing then npc.facing = facing end
end
function Commands.face_npc(ctx)
-- make the player face the talking NPC
if ctx.npc and ctx.overworld then
@@ -455,20 +438,6 @@ function Commands.play_sound(ctx, soundId)
require("src.core.Sound").play(ctx.game.data, soundId)
end
-- play_once <songId>: one-shot jingle (Music_PkmnHealed, etc.); blocks
-- until it finishes so heal-rest scripts (Mom, captain text_asm) match
-- Gen1's wait-on-channel loop. The map theme resumes when it ends
-- (Music.playOnce / pendingRestore).
function Commands.play_once(ctx, songId)
local Music = require("src.core.Music")
if not Music.playOnce(ctx.game.data, songId) then return end
local runner = ctx.runner
runner.waitingCheck = function()
return not Music.oneShotPlaying()
end
runner:yield()
end
-- play_cry <species>: PlayCry (home/audio.asm). The text_asm bodies that
-- use it run text_far (a no-button-wait "...@" string) -> PlayCry ->
-- WaitForSoundToFinish, all within the same text ID -- the cry only starts
@@ -501,52 +470,11 @@ function Commands.heal_party(ctx)
end
end
-- AskName (engine/menus/naming_screen.asm): yes/no then NamingScreen.
-- AddPartyMon and SendNewMonToBox both offer this.
-- 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).
-- AskName runs for party (AddPartyMon) and box (SendNewMonToBox) when a
-- script runner is present; mods that pre-set gift.nickname skip it.
-- Box deposits also print SentToBoxText (give_pokemon.asm:36-37).
function Commands.give_pokemon(ctx, species, level)
-- 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:
@@ -558,17 +486,13 @@ function Commands.give_pokemon(ctx, species, level)
end
local Pokemon = require("src.pokemon.Pokemon")
local Party = require("src.pokemon.Party")
local Boxes = require("src.pokemon.Boxes")
local mon = Pokemon.new(ctx.game.data, species, level)
if gift.nickname then mon.nickname = gift.nickname end
ctx.game.stringBuffer = ctx.game.data.pokemon[species].name or species
ctx.pendingPokemonName = species
require("src.battle.BattleState").stampOT(ctx.save, mon)
local addedToParty = Party.add(ctx.save.party, mon)
local boxNum = nil
if not addedToParty then
boxNum = Boxes.deposit(ctx.save, mon)
if not boxNum then
if not Party.add(ctx.save.party, mon) then
if not require("src.pokemon.Boxes").deposit(ctx.save, mon) then
ctx.lastCheck = false
return
end
@@ -579,59 +503,18 @@ function Commands.give_pokemon(ctx, species, level)
dex.owned[species] = true
end
ctx.lastCheck = true
ctx.addedToParty = addedToParty
ctx.boxNum = boxNum
-- AskName: both AddPartyMon and SendNewMonToBox; skip mod-set nicks
-- and callback-style callers with no script runner to yield on.
if not gift.nickname and ctx.runner then
askNickname(ctx, mon)
end
if boxNum then
local name = mon.nickname
or (ctx.game.data.pokemon[species] and ctx.game.data.pokemon[species].name)
or species
ctx.game.boxMonNicks = name
ctx.game.stringBuffer = tostring(boxNum)
if ctx.runner then
Commands.show_text(ctx, "_SentToBoxText")
else
local TextBox = require("src.render.TextBox")
local raw = ctx.game.data.text and ctx.game.data.text._SentToBoxText
ctx.game.stack:push(TextBox.new(ctx.game, raw or (
"There's no more\nroom for POKéMON!\v" .. name
.. " was\vsent to POKéMON\vBOX " .. boxNum .. " on PC!"),
function() end))
end
end
end
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 = {}
@@ -647,25 +530,17 @@ function Commands.record_hall_of_fame(ctx)
Screens.push(game, "Credits", function()
runner:resume()
end, function()
-- THE END is on screen: heal, place the player at post-game home,
-- retarget LAST_MAP, then SaveGameData. (E4 room-script/event
-- resets that precede the save in pokered are the Indigo lobby's
-- re-entry reset here, data/scripts/story6.lua.)
local SaveData = require("src.core.SaveData")
local Pokemon = require("src.pokemon.Pokemon")
-- THE END is on screen: HallOfFameResetEventsAndSaveScript sets
-- wLastBlackoutMap := PALLET_TOWN and runs SaveGameData, so the
-- save keeps the player standing in the HALL_OF_FAME room. (The
-- E4 room-script/event resets that precede the save in pokered are
-- the Indigo lobby's re-entry reset here, data/scripts/story6.lua.)
-- pokered writes PALLET_TOWN here as a literal, not as "the spawn" --
-- the vanilla spawn is REDS_HOUSE_2F. SaveData.defaultHeal carries
-- that split (and lets a total conversion redirect it).
local boot = game.data.field and game.data.field.boot or {}
for _, mon in ipairs(ctx.save.party or {}) do
Pokemon.heal(mon)
end
SaveData.applyPostGameHome(ctx.save, boot)
if game.overworld then
game.overworld.lastOutdoor = ctx.save.lastOutdoor
end
ctx.save.lastHeal = require("src.core.SaveData").defaultHeal(boot)
if game.writeSave then game:writeSave() end
-- writeSave's captureSave re-stamps the live HALL_OF_FAME coords;
-- re-apply home and persist so CONTINUE resumes in the bedroom.
SaveData.applyPostGameHome(ctx.save, boot)
SaveData.save(ctx.save)
end)
end)
runner:yield()
@@ -823,8 +698,6 @@ function Commands.trade(ctx, tradeIndex, doneFlag)
local newMon = Pokemon.new(data, trade.get, sent.level)
newMon.nickname = trade.nickname
newMon.traded = true -- boosted exp + Name Rater refusal (different OT)
newMon.ot = "TRAINER" -- InGameTrade_TrainerString
newMon.otId = (love.math and love.math.random or math.random)(0, 65535)
table.remove(party, slot)
table.insert(party, newMon)
local dex = ctx.save.pokedex
@@ -832,13 +705,9 @@ function Commands.trade(ctx, tradeIndex, doneFlag)
dex.seen[trade.get] = true
dex.owned[trade.get] = true
end
-- InternalClockTradeAnim (engine/movie/trade.asm)
-- the trade machine animation (engine/movie/trade.asm)
Screens.push(ctx.game, "TradeAnim", {
sent = sent, received = newMon,
enemyName = "TRAINER",
playerOt = ctx.save.player.name,
playerOtId = sent.otId or ctx.save.player.id,
enemyOtId = newMon.otId,
onDone = function() runner:resume() end,
})
runner:yield()
@@ -986,21 +855,15 @@ function Commands.push_screen(ctx, screenId, args)
runner:yield()
end
-- fade "out"|"in" [frames|"white"|"black"] [frames|"white"|"black"]:
-- screen fade without warping (the Transition ramp startWarpTo uses,
-- split in two). "out" pushes an overlay that stays up; the held state
-- keeps ticking the runner's frame-waits so a script can wait /
-- heal_party / play_once under it. "in" ramps it away.
-- Color defaults to black (warp-style); "white" matches GBFadeOutToWhite
-- / GBFadeInFromWhite (Mom heal, Silph Co. nurse). White defaults to
-- 24 frames (3 palettes x 8); black defaults to 12 (Transition).
-- fade "out"|"in" [frames]: screen fade without warping (the Transition
-- ramp startWarpTo uses, split in two). "out" pushes a black overlay
-- that stays up; the held state keeps ticking the runner's frame-waits
-- so a script can wait/replace_block under it. "in" ramps it away.
local FadeOverlay = {}
FadeOverlay.__index = FadeOverlay
function FadeOverlay.new(game, ow, color)
return setmetatable({
game = game, ow = ow, alpha = 0, color = color or "black",
}, FadeOverlay)
function FadeOverlay.new(game, ow)
return setmetatable({ game = game, ow = ow, alpha = 0 }, FadeOverlay)
end
function FadeOverlay:update()
@@ -1026,51 +889,28 @@ function FadeOverlay:update()
end
function FadeOverlay:draw()
if self.color == "white" then
love.graphics.setColor(1, 1, 1, self.alpha)
else
love.graphics.setColor(0, 0, 0, self.alpha)
end
love.graphics.setColor(0, 0, 0, self.alpha)
love.graphics.rectangle("fill", 0, 0, 160, 144)
love.graphics.setColor(1, 1, 1, 1)
end
local function parseFadeArgs(a, b)
local frames, color
if type(a) == "string" then
color = a
if type(b) == "number" then frames = b end
elseif type(a) == "number" then
frames = a
if type(b) == "string" then color = b end
end
color = color or "black"
if not frames then
frames = (color == "white") and 24 or 12
end
return frames, color
end
function Commands.fade(ctx, dir, framesOrColor, colorOrFrames)
function Commands.fade(ctx, dir, frames)
local ow = ctx.overworld
if not ow then return end
local runner = ctx.runner
local frames, color = parseFadeArgs(framesOrColor, colorOrFrames)
frames = frames or 12 -- Transition's ramp length
local overlay = ow.fadeOverlay
if dir == "out" then
if not overlay then
overlay = FadeOverlay.new(ctx.game, ow, color)
overlay = FadeOverlay.new(ctx.game, ow)
ow.fadeOverlay = overlay
ctx.game.stack:push(overlay)
else
overlay.color = color
end
overlay.ramp = { from = overlay.alpha, to = 1, frames = frames, t = 0,
onDone = function() runner:resume() end }
runner:yield()
elseif dir == "in" then
if not overlay then return end
overlay.color = color or overlay.color
overlay.ramp = { from = overlay.alpha, to = 0, frames = frames, t = 0,
onDone = function() runner:resume() end }
runner:yield()
@@ -1158,10 +998,9 @@ for _, verb in ipairs({ "show_text", "ask", "choice", "start_battle", "warp",
end
for _, verb in ipairs({ "show_text", "ask", "choice", "start_battle", "warp",
"open_mart", "trade", "push_screen", "record_hall_of_fame",
"old_man_demo", "static_battle", "rival_battle", "give_item",
"give_pokemon", "wait",
"old_man_demo", "static_battle", "rival_battle", "give_item", "wait",
"wait_flag", "move_player", "move_npc", "move_npc_to", "walk_npc",
"emote", "fade", "pan_camera", "play_once" }) do
"emote", "fade", "pan_camera" }) do
local meta = Commands.meta[verb] or {}
Commands.meta[verb] = meta
meta.blocking = true
+1 -9
View File
@@ -216,9 +216,7 @@ local function useOn(game, battle, id, target, list, moveIndex)
consume(game, id)
require("src.core.Sound").play(game.data, "Teleport_Exit1")
ow.player.surfing = false
-- EnterMapAnim on arrival (BIT_ESCAPE_WARP / special warp path);
-- blackouts omit arrive="teleport" (HandleBlackOut has no LeaveMapAnim)
ow:warpToHealPoint(nil, { arrive = "teleport" })
ow:warpToHealPoint()
else
showMessages(game, { "OAK: " .. game.save.player.name
.. "!\nThis isn't the\ntime to use that!" })
@@ -331,12 +329,6 @@ end
local function useItem(game, battle, id, list)
local def = game.data.items[id]
-- ItemUseTMHM checks wIsInBattle before BootedUpTMText
if battle and def and def.machine then
local _, payload = ItemEffects.use(game.data, game.save, id, nil, battle)
showMessages(game, payload)
return
end
if ItemEffects.needsTarget(id, def) and not ItemEffects.isBall(id) then
-- TMs/HMs boot up and announce their move before the target picker
-- (ItemUseTMHM: BootedUpTMText / BootedUpHMText + TeachMachineMoveText)
+4 -22
View File
@@ -11,9 +11,7 @@ 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.
-- `pad` is the default SDL gamecontroller button (see Input.lua); shown
-- on the SELECT row so controller Back/View is discoverable (#73).
-- Input.lua's map, primary key first where several keys share a button
local BUTTONS = {
{ id = "up", label = "UP", key = "up" },
{ id = "down", label = "DOWN", key = "down" },
@@ -22,7 +20,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 = "tab", pad = "back" },
{ id = "select", label = "SELECT", key = "rshift" },
}
-- a binding is a plain key string or { key, pad }; absent = the fixed
@@ -34,29 +32,13 @@ 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 = boundRight(overlay, def), button = def }
right = boundKey(overlay, def):upper(), button = def }
end
local self = setmetatable(ListMenu.new(game, "CONTROLS", items, {}),
BindingsMenu)
@@ -96,7 +78,7 @@ function BindingsMenu:storeBinding(slot, value)
end
b[slot] = value
opts.bindings[item.button.id] = b
item.right = boundRight(opts.bindings, item.button)
item.right = boundKey(opts.bindings, item.button):upper()
Input:applyBindings(opts.bindings)
if game.writeOptions then game:writeOptions() end
end
+19 -100
View File
@@ -3,11 +3,9 @@
-- plus CHANGE BOX.
local Boxes = require("src.pokemon.Boxes")
local Font = require("src.render.Font")
local ListMenu = require("src.ui.ListMenu")
local Menu = require("src.ui.Menu")
local Party = require("src.pokemon.Party")
local TextBox = require("src.render.TextBox")
local BoxMenu = {}
@@ -16,11 +14,6 @@ local function monLabel(game, mon)
return ("%s :L%d"):format(mon.nickname or def.name, mon.level)
end
local function monName(game, mon)
local def = game.data.pokemon[mon.species]
return mon.nickname or def.name
end
-- Per-mon submenu (bills_pc.asm DisplayDepositWithdrawMenu): the chosen
-- action + STATS + CANCEL. STATS shows the status screen and returns
-- here; CANCEL/B goes back to the list.
@@ -38,26 +31,8 @@ local function monSubmenu(game, action, mon, onAction)
}, { tx = 9, ty = 10, tw = 11, th = 8, noSound = true }))
end
-- After a successful transfer: close the mon list (BoxMenu stays beneath
-- via keepOpen) and show the taken-out / stored text (jp BillsPCMenu).
local function afterTransfer(game, list, text)
list:close()
game.stack:push(TextBox.new(game, text))
end
local function withdraw(game)
local box = Boxes.active(game.save)
local t = game.data.text
if #box == 0 then
game.stack:push(TextBox.new(game, t._NoMonText
or "What? There are\nno POKéMON here!"))
return
end
if #game.save.party >= Party.MAX then
game.stack:push(TextBox.new(game, t._CantTakeMonText
or "You can't take\nany more POKéMON.\fDeposit POKéMON\nfirst."))
return
end
local items = {}
for i, mon in ipairs(box) do
table.insert(items, { label = monLabel(game, mon), value = i })
@@ -74,29 +49,13 @@ local function withdraw(game)
end
table.remove(box, item.value)
table.insert(game.save.party, mon)
local name = monName(game, mon)
game.stringBuffer = name
require("src.core.Sound").playCry(game.data, mon.species)
afterTransfer(game, list, t._MonIsTakenOutText
or (name .. " is\ntaken out.\vGot " .. name .. "."))
list:close()
end)
end,
}))
end
local function deposit(game)
local t = game.data.text
if #game.save.party <= 1 then
game.stack:push(TextBox.new(game, t._CantDepositLastMonText
or "You can't deposit\nthe last POKéMON!"))
return
end
local box = Boxes.active(game.save)
if #box >= Boxes.CAPACITY then
game.stack:push(TextBox.new(game, t._BoxFullText
or "Oops! This Box is\nfull of POKéMON."))
return
end
local items = {}
for i, mon in ipairs(game.save.party) do
table.insert(items, { label = monLabel(game, mon), value = i })
@@ -110,50 +69,40 @@ local function deposit(game)
list.footer = "You need at least\none POKéMON!"
return
end
local active = Boxes.active(game.save)
if #active >= Boxes.CAPACITY then
local box = Boxes.active(game.save)
if #box >= Boxes.CAPACITY then
list.footer = ("BOX %d is full!"):format(game.save.currentBox)
return
end
table.remove(game.save.party, item.value)
table.insert(active, mon)
local name = monName(game, mon)
game.stringBuffer = name
game.boxNumString = tostring(game.save.currentBox)
require("src.core.Sound").playCry(game.data, mon.species)
afterTransfer(game, list, t._MonWasStoredText
or (name .. " was\nstored in Box " .. game.boxNumString .. "."))
table.insert(box, mon)
list:close()
end)
end,
}))
end
-- RELEASE POKéMON (bills_pc.asm BillsPCRelease): confirm, then "Bye [MON]!".
-- Index by list.index (not stale item.value) after removeCurrent (#171).
-- RELEASE POKéMON (bills_pc.asm .release): confirm, then "Bye [MON]!"
local function release(game)
local box = Boxes.active(game.save)
local t = game.data.text
if #box == 0 then
game.stack:push(TextBox.new(game, t._NoMonText
or "What? There are\nno POKéMON here!"))
return
end
local items = {}
for i, mon in ipairs(box) do
table.insert(items, { label = monLabel(game, mon), value = i })
end
game.stack:push(ListMenu.new(game,
("BOX %d (RELEASE)"):format(game.save.currentBox), items, {
onChoose = function(_, list)
local mon = box[list.index]
onChoose = function(item, list)
local mon = box[item.value]
if not mon then return end
local name = monName(game, mon)
local def = game.data.pokemon[mon.species]
local name = mon.nickname or def.name
local ChoiceBox = require("src.ui.ChoiceBox")
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game,
"Once released,\n" .. name .. " is\ngone forever. OK?", function()
game.stack:push(ChoiceBox.new(game, function(yes)
if not yes then return end
table.remove(box, list.index)
table.remove(box, item.value)
require("src.core.Sound").playCry(game.data, mon.species)
game.stack:push(TextBox.new(game,
("%s was\nreleased outside.\fBye %s!"):format(name, name)))
@@ -180,6 +129,7 @@ local function changeBox(game)
-- the original asks BEFORE switching ("When you change a #MON
-- BOX, data will be saved. OK?"); declining aborts the change
local ChoiceBox = require("src.ui.ChoiceBox")
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game,
"When you change a\nPOKéMON BOX, data\nwill be saved. OK?", function()
game.stack:push(ChoiceBox.new(game, function(yes)
@@ -193,48 +143,17 @@ local function changeBox(game)
}))
end
-- bills_pc.asm BillsPCMenu chrome: What? text box + BOX No. overlay
local function drawChrome(game)
Font.drawBox(0, 12, 20, 6)
love.graphics.setColor(0, 0, 0, 1)
Font.draw("What?", 8, 112)
Font.drawBox(9, 14, 11, 4)
love.graphics.setColor(0, 0, 0, 1)
Font.draw("BOX No.", 80, 128)
local n = game.save.currentBox or 1
if n >= 10 then
Font.draw(tostring(n), 136, 128)
else
Font.draw(tostring(n), 144, 128)
end
love.graphics.setColor(1, 1, 1, 1)
end
function BoxMenu.new(game)
Boxes.ensure(game.save)
-- bills_pc.asm BillsPCMenu: TextBoxBorder at (0,0) with interior
-- 12x10 → total 14x12. "CHANGE BOX" / "WITHDRAW <PK><MN>" need the
-- full interior (cursor col + label). keepOpen so WITHDRAW/DEPOSIT/
-- RELEASE/CHANGE BOX leave this menu underneath (jp BillsPCMenu).
local menu = Menu.new(game, {
{ label = "WITHDRAW <PK><MN>", keepOpen = true,
onSelect = function() withdraw(game) end },
{ label = "DEPOSIT <PK><MN>", keepOpen = true,
onSelect = function() deposit(game) end },
{ label = "RELEASE <PK><MN>", keepOpen = true,
onSelect = function() release(game) end },
{ label = "CHANGE BOX", keepOpen = true,
onSelect = function() changeBox(game) end },
return Menu.new(game, {
{ label = "WITHDRAW", onSelect = function() withdraw(game) end },
{ label = "DEPOSIT", onSelect = function() deposit(game) end },
{ label = "RELEASE", onSelect = function() release(game) end },
{ label = "CHANGE BOX", onSelect = function() changeBox(game) end },
{ label = "SEE YA!" },
-- Bill's PC runs silent end to end (BIT_NO_MENU_BUTTON_SOUND,
-- engine/menus/pokemon_pc.asm)
}, { tx = 0, ty = 0, tw = 14, th = 12, noSound = true })
local baseDraw = menu.draw
function menu:draw()
baseDraw(self)
drawChrome(game)
end
return menu
}, { tx = 8, ty = 0, tw = 12, th = 12, noSound = true })
end
return BoxMenu
+7 -12
View File
@@ -1,4 +1,4 @@
-- YES/NO choice box (InitYesNoTextBoxParameters: above the text box, right).
-- YES/NO choice box (top-left of the text box area, like the original).
local Font = require("src.render.Font")
local Theme = require("src.ui.Theme")
@@ -14,11 +14,6 @@ function ChoiceBox.new(game, onChoose, opts)
self.index = (opts and opts.defaultNo) and 2 or 1
-- BIT_NO_MENU_BUTTON_SOUND: PC-session prompts stay silent
self.noSound = (opts and opts.noSound) or false
local box = Theme.choiceBox
self.tx = (opts and opts.tx) or box.tx
self.ty = (opts and opts.ty) or box.ty
self.tw = (opts and opts.tw) or box.tw
self.th = (opts and opts.th) or box.th
return self
end
@@ -43,13 +38,13 @@ function ChoiceBox:update(dt)
end
function ChoiceBox:draw()
local tx, ty, tw, th = self.tx, self.ty, self.tw, self.th
Font.drawBox(tx, ty, tw, th)
local box = Theme.choiceBox
Font.drawBox(box.tx, box.ty, box.tw, box.th)
love.graphics.setColor(0, 0, 0, 1)
Font.draw("YES", (tx + 2) * 8, (ty + 1) * 8)
Font.draw("NO", (tx + 2) * 8, (ty + 3) * 8)
Font.drawCode(Theme.cursor, (tx + 1) * 8,
(ty + (self.index == 1 and 1 or 3)) * 8)
Font.draw("YES", (box.tx + 2) * 8, (box.ty + 1) * 8)
Font.draw("NO", (box.tx + 2) * 8, (box.ty + 3) * 8)
Font.drawCode(Theme.cursor, (box.tx + 1) * 8,
(box.ty + (self.index == 1 and 1 or 3)) * 8)
love.graphics.setColor(1, 1, 1, 1)
end
+1 -2
View File
@@ -217,8 +217,7 @@ function Credits:update(dt)
self.timer = FADE_FRAMES
elseif self.phase == "end_fade" then
-- Credits returns to HallOfFameResetEventsAndSaveScript here: the
-- save (plus post-game home relocate, #103) happens now, then
-- 5 x 120 DelayFrames before the button wait
-- save happens now, then 5 x 120 DelayFrames before the button wait
if self.onTheEnd then self.onTheEnd() end
self.phase = "end_hold"
self.timer = 600
+4 -23
View File
@@ -1,11 +1,5 @@
-- Pokédex entry page: front sprite, kind, height/weight and the real
-- dex description (data/pokemon/dex_entries.asm + dex_text.asm).
--
-- `species` may be a species id string, or a table
-- `{ species = id, forceOwned = true }`. forceOwned mirrors pret's
-- StarterDex (engine/events/starter_dex.asm), which temporarily sets the
-- owned bit so Oak's lab ball previews show height/weight/description
-- without permanently marking the mon owned.
local Font = require("src.render.Font")
@@ -22,17 +16,8 @@ function DexEntryMenu:sgbPalettes(game)
P.zone(P.monPal(game.data, self.def and self.def.id), 1, 1, 8, 8) }
end
local function resolveArgs(speciesOrOpts)
if type(speciesOrOpts) == "table" then
return speciesOrOpts.species or speciesOrOpts[1],
speciesOrOpts.forceOwned and true or false
end
return speciesOrOpts, false
end
function DexEntryMenu.new(game, speciesOrOpts)
local species, forceOwned = resolveArgs(speciesOrOpts)
local self = setmetatable({ game = game, forceOwned = forceOwned }, DexEntryMenu)
function DexEntryMenu.new(game, species)
local self = setmetatable({ game = game }, DexEntryMenu)
self.def = game.data.pokemon[species]
local ok, img = pcall(love.graphics.newImage, self.def.spriteFront)
self.sprite = ok and img or nil
@@ -57,16 +42,12 @@ function DexEntryMenu:draw()
love.graphics.setColor(0, 0, 0, 1)
Font.draw(def.name, 72, 8)
local e = def.dexEntry or {}
-- English R/B prints only the kind string (hlcoord 9,4 PlaceString).
-- PokeText ("#"/POKéMON) is an unreferenced JPN leftover in pokedex.asm;
-- appending " POKéMON" here clipped longer kinds ("LIZARD POKé").
Font.draw(e.kind or "?", 72, 20)
Font.draw((e.kind or "?") .. " POKéMON", 72, 20)
-- same number width as the list (constants.dexDigits), so a dex past 999
-- prints the extra digit everywhere at once
local digits = (self.game.data.constants or {}).dexDigits or 3
Font.draw(("No.%0" .. digits .. "d"):format(def.dex or 0), 72, 32)
local owned = self.forceOwned
or (self.game.save.pokedex and self.game.save.pokedex.owned[def.id])
local owned = self.game.save.pokedex and self.game.save.pokedex.owned[def.id]
-- height/weight print only once owned, like the description
-- (pokedex.asm: "if the pokemon has not been owned, don't print the
-- height, weight, or description")
+76 -201
View File
@@ -1,16 +1,14 @@
-- Hall of Fame induction (engine/movie/hall_of_fame.asm): each party
-- member's front sprite scrolls onto the right side of the screen
-- (HoFShowMonOrPlayer's .ScrollPic), then HoFDisplayMonInfo draws the
-- left-side LEVEL/TYPE box, plays the cry, holds, and pops the bottom
-- "HALL OF FAME" text box before fading to the next mon. After the
-- party, the player pic scrolls in and HoFDisplayPlayerStats shows the
-- name/time/money boxes plus the dex rating. Plays Music_HallOfFame
-- when the audio data has it. Calls onDone() after popping itself.
-- member's front sprite scrolls onto the screen (HoFShowMonOrPlayer's
-- .ScrollPic), then its name/level shows and its cry plays
-- (HoFDisplayAndRecordMonInfo). After the last mon, HoFDisplayPlayerStats
-- shows the trainer name, play time, money and Prof. Oak's dex rating.
-- Plays Music_HallOfFame when the audio data has it. Calls onDone() after
-- popping itself.
local Font = require("src.render.Font")
local Music = require("src.core.Music")
local Sound = require("src.core.Sound")
local TypeChart = require("src.battle.TypeChart")
local HallOfFame = {}
HallOfFame.__index = HallOfFame
@@ -19,10 +17,6 @@ HallOfFame.isOpaque = true
-- SGB: SetPal_PokemonWholeScreen for the mon on display
function HallOfFame:sgbPalettes(game)
local P = require("src.render.PaletteFX")
if self.phase == "player" or self.phase == "player_stats"
or self.phase == "player_dex" or self.phase == "player_rating" then
return P.wholeNamed(game.data, "MEWMON")
end
local mon = game.save.party[self.index or 0]
if mon then
local c = P.monPal(game.data, mon.species)
@@ -32,20 +26,17 @@ function HallOfFame:sgbPalettes(game)
return P.wholeNamed(game.data, "MEWMON")
end
local MON_FRAMES = 150 -- ~2.5s per inductee (A advances early)
-- HoFShowMonOrPlayer's .ScrollPic: hSCX is nudged by e = 4px per
-- DelayFrame (doubled on SGB) until it settles. The front pic rests at
-- hlcoord 12,5 (engine/movie/hall_of_fame.asm HoFLoadMonPlayerPicTileIDs).
-- DelayFrame (doubled on SGB) until it settles. The back pic (an
-- enlarged, blurred 2x scale of the back sprite) sweeps right-to-left
-- and off the left edge first; tracing the actual hSCX/hSCY math shows
-- the real front pic that follows enters from the *left* edge and
-- slides *right* into its resting tile, at that same 4px/frame rate --
-- that's the half we port here (the back-pic wipe is a VRAM/scroll-
-- register trick with no equivalent in this sprite-based renderer).
local SCROLL_SPEED = 4 -- px/frame @ 60fps
local PIC_X, PIC_Y = 12 * 8, 5 * 8
-- After HoFDisplayAndRecordMonInfo: 80 DelayFrames, then the bottom
-- HALL OF FAME box for 180 DelayFrames, then GBFadeOutToWhite.
local INFO_HOLD = 80
local HOF_HOLD = 180
local FADE_FRAMES = 20
-- HoFPrintTextAndDelay after each dex line
local DEX_HOLD = 120
local function tryImage(path)
if not path then return nil end
@@ -66,7 +57,7 @@ local function drawTextBlock(text, x, y, maxY)
for line in (text:gsub("\v", "\n"):gsub("\f", "\n") .. "\n"):gmatch("(.-)\n") do
if maxY and y > maxY then break end
Font.draw(line, x, y)
y = y + 8
y = y + 10
end
return y
end
@@ -79,10 +70,6 @@ function HallOfFame.new(game, onDone)
self.timer = 0
self.phase = "mons"
self.sprites = {} -- species -> image or false
self.playerPic = tryImage("assets/generated/trainer_card/red.png")
self.scrollX = PIC_X
self.showHofBanner = false
self.fade = 0
return self
end
@@ -97,21 +84,16 @@ end
function HallOfFame:nextMon()
self.index = self.index + 1
local mon = self.game.save.party[self.index]
self.showHofBanner = false
self.fade = 0
if mon then
self.phase = "mons"
self.timer = INFO_HOLD
self.timer = MON_FRAMES
Sound.playCry(self.game.data, mon.species)
-- scroll the new inductee's pic in from the left (see SCROLL_SPEED)
local sprite = self:spriteFor(mon.species)
local w = sprite and sprite:getWidth() or 56
local w = sprite and sprite:getWidth() or 0
self.scrollRestX = math.floor((160 - w) / 2)
self.scrollX = -w
else
-- HoFShowMonOrPlayer with wHoFMonOrPlayer = player
self.phase = "player"
self.timer = 0
local w = self.playerPic and self.playerPic:getWidth() or 56
self.scrollX = -w
self.phase = "congrats"
end
end
@@ -135,184 +117,77 @@ function HallOfFame:dexSeenOwned()
return seen, owned
end
function HallOfFame:advanceMonPhase()
if self.phase == "mons" and not self.showHofBanner then
-- 80-frame info hold done: TextBoxBorder at (2,13) + "HALL OF FAME"
self.showHofBanner = true
self.timer = HOF_HOLD
elseif self.phase == "mons" then
self.phase = "fade"
self.timer = FADE_FRAMES
self.fade = 0
elseif self.phase == "fade" then
self:nextMon()
end
end
function HallOfFame:update(dt)
local input = self.game.input
local skip = input:wasPressed("a")
if self.phase == "mons" or self.phase == "fade" then
if self.phase == "mons" and self.scrollX < PIC_X then
self.scrollX = math.min(PIC_X, self.scrollX + SCROLL_SPEED)
return
end
if self.phase == "fade" then
self.timer = self.timer - 1
self.fade = 1 - math.max(0, self.timer) / FADE_FRAMES
if self.timer <= 0 or skip then self:advanceMonPhase() end
return
if self.phase == "mons" then
if self.scrollX and self.scrollX < self.scrollRestX then
self.scrollX = math.min(self.scrollRestX, self.scrollX + SCROLL_SPEED)
end
self.timer = self.timer - 1
if skip or self.timer <= 0 then
self:advanceMonPhase()
if input:wasPressed("a") or self.timer <= 0 then
self:nextMon()
end
elseif self.phase == "player" then
if self.scrollX < PIC_X then
self.scrollX = math.min(PIC_X, self.scrollX + SCROLL_SPEED)
return
end
self.phase = "player_stats"
self.timer = DEX_HOLD
elseif self.phase == "player_stats" then
-- name / play time / money boxes are up; then DexSeenOwnedText
self.timer = self.timer - 1
if skip or self.timer <= 0 then
self.phase = "player_dex"
self.timer = DEX_HOLD
end
elseif self.phase == "player_dex" then
self.timer = self.timer - 1
if skip or self.timer <= 0 then
self.phase = "player_rating"
self.timer = DEX_HOLD
end
elseif self.phase == "player_rating" then
self.timer = self.timer - 1
if skip or self.timer <= 0 then
-- HoFFadeOutScreenAndMusic -> Credits lead-in (no A wait here)
self.game.stack:pop()
if self.onDone then self.onDone() end
end
end
end
-- HoFDisplayMonInfo: TextBoxBorder (0,2) b=9,c=10 + LEVEL/TYPE labels
function HallOfFame:drawMonInfo(mon)
local def = self.game.data.pokemon[mon.species]
Font.drawBox(0, 2, 12, 11)
love.graphics.setColor(0, 0, 0, 1)
local name = mon.nickname or (def and def.name) or mon.species
Font.draw(name, 1 * 8, 4 * 8)
Font.draw("LEVEL/", 2 * 8, 6 * 8)
Font.draw("TYPE1/", 2 * 8, 7 * 8)
local t1 = def and def.types and def.types[1]
local t2 = def and def.types and def.types[2]
local dual = t2 and t2 ~= t1
if dual then
Font.draw("TYPE2/", 2 * 8, 8 * 8)
end
-- PrintLevelCommon at (8,7): bare level digits (no <LV> tile here)
Font.draw(tostring(mon.level), 8 * 8, 7 * 8)
-- PrintMonType at (3,9) / +2 rows for type 2
if t1 then
Font.draw(TypeChart.displayName(t1), 3 * 8, 9 * 8)
end
if dual then
Font.draw(TypeChart.displayName(t2), 3 * 8, 11 * 8)
end
end
-- Bottom HALL OF FAME banner: TextBoxBorder (2,13) b=3,c=14
function HallOfFame:drawHofBanner()
Font.drawBox(2, 13, 16, 5)
love.graphics.setColor(0, 0, 0, 1)
Font.draw("HALL OF FAME", 4 * 8, 15 * 8)
end
function HallOfFame:drawPic(img)
if not img then return end
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(img, self.scrollX or PIC_X, PIC_Y)
end
-- HoFDisplayPlayerStats boxes + labels (player pic already on the right)
function HallOfFame:drawPlayerStats()
local save = self.game.save
-- name box: TextBoxBorder (5,0) b=2,c=9 → drawBox(5,0,11,4)
Font.drawBox(5, 0, 11, 4)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(save.player.name or "RED", 7 * 8, 2 * 8)
-- play time / money box: TextBoxBorder (0,4) b=6,c=10 → drawBox(0,4,12,8)
Font.drawBox(0, 4, 12, 8)
love.graphics.setColor(0, 0, 0, 1)
Font.draw("PLAY TIME", 1 * 8, 6 * 8)
local t = math.floor(save.playTime or 0)
Font.draw(("%3d:%02d"):format(math.floor(t / 3600), math.floor(t / 60) % 60),
5 * 8, 7 * 8)
Font.draw("MONEY", 1 * 8, 9 * 8)
-- PrintBCDNumber with MONEY_SIGN; port uses ¥ like TrainerCard
Font.draw(("¥%d"):format(save.money or 0), 4 * 8, 10 * 8)
end
function HallOfFame:drawDexBox(kind)
local save = self.game.save
local text = self.game.data.text or {}
Font.drawBox(0, 12, 20, 6)
love.graphics.setColor(0, 0, 0, 1)
if kind == "seen" then
local seen, owned = self:dexSeenOwned()
local seenOwned = text._DexSeenOwnedText
or "POKéDEX Seen:{NUM:wDexRatingNumMonsSeen, 1, 3}\n Owned:{NUM:wDexRatingNumMonsOwned, 1, 3}"
seenOwned = seenOwned
:gsub("{NUM:wDexRatingNumMonsSeen[^}]*}", tostring(seen))
:gsub("{NUM:wDexRatingNumMonsOwned[^}]*}", tostring(owned))
drawTextBlock(seenOwned, 1 * 8, 14 * 8, 17 * 8)
else
local _, owned = self:dexSeenOwned()
local ratingHeader = (text._DexRatingText or "POKéDEX Rating{COLON}"):gsub("{COLON}", ":")
Font.draw(ratingHeader, 1 * 8, 14 * 8)
local rating = text[dexRatingKey(owned)] or "Keep it up!"
drawTextBlock(rating, 1 * 8, 15 * 8, 17 * 8)
elseif input:wasPressed("a") then
Sound.play(self.game.data, "Press_AB")
self.game.stack:pop()
if self.onDone then self.onDone() end
end
end
function HallOfFame:draw()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
if self.phase == "mons" or self.phase == "fade" then
love.graphics.setColor(0, 0, 0, 1)
if self.phase == "mons" then
Font.draw("HALL OF FAME", (160 - 12 * 8) / 2, 8)
local mon = self.game.save.party[self.index]
if mon then
self:drawPic(self:spriteFor(mon.species))
if self.scrollX >= PIC_X then
self:drawMonInfo(mon)
if self.showHofBanner then
self:drawHofBanner()
end
local def = self.game.data.pokemon[mon.species]
love.graphics.setColor(1, 1, 1, 1)
local sprite = self:spriteFor(mon.species)
if sprite then
local w, h = sprite:getDimensions()
love.graphics.draw(sprite, self.scrollX or math.floor((160 - w) / 2), 96 - h)
end
love.graphics.setColor(0, 0, 0, 1)
local name = mon.nickname or (def and def.name) or mon.species
Font.draw(name, 32, 108)
Font.draw((":L%d"):format(mon.level), 112, 108)
end
if self.phase == "fade" and self.fade > 0 then
love.graphics.setColor(1, 1, 1, self.fade)
love.graphics.rectangle("fill", 0, 0, 160, 144)
end
elseif self.phase == "player" then
self:drawPic(self.playerPic)
elseif self.phase == "player_stats" then
self:drawPic(self.playerPic)
self:drawPlayerStats()
elseif self.phase == "player_dex" then
self:drawPic(self.playerPic)
self:drawPlayerStats()
self:drawDexBox("seen")
elseif self.phase == "player_rating" then
self:drawPic(self.playerPic)
self:drawPlayerStats()
self:drawDexBox("rating")
end
else
-- HoFDisplayPlayerStats (no "HALL OF FAME" banner here -- the real
-- screen is a fresh ClearScreen): trainer name, play time, money,
-- then the POKéDEX seen/owned tally and Prof. Oak's rating text,
-- using the same real save-data fields as TrainerCard.lua
-- (save.player.name/playTime/money) and PokedexMenu.lua/
-- OverworldController:dexRating (save.pokedex.seen/owned).
local save = self.game.save
local text = self.game.data.text or {}
local y = 8
Font.draw(save.player.name or "RED", 8, y)
y = y + 16
local t = math.floor(save.playTime or 0)
Font.draw(("PLAY TIME %3d:%02d"):format(math.floor(t / 3600),
math.floor(t / 60) % 60), 8, y)
y = y + 12
Font.draw(("MONEY ¥%d"):format(save.money or 0), 8, y)
y = y + 16
local seen, owned = self:dexSeenOwned()
local seenOwned = text._DexSeenOwnedText
or "POKéDEX Seen:{NUM:wDexRatingNumMonsSeen, 1, 3}\n Owned:{NUM:wDexRatingNumMonsOwned, 1, 3}"
seenOwned = seenOwned
:gsub("{NUM:wDexRatingNumMonsSeen[^}]*}", tostring(seen))
:gsub("{NUM:wDexRatingNumMonsOwned[^}]*}", tostring(owned))
y = drawTextBlock(seenOwned, 8, y) + 6
local ratingHeader = (text._DexRatingText or "POKéDEX Rating{COLON}"):gsub("{COLON}", ":")
Font.draw(ratingHeader, 8, y)
y = y + 12
local rating = text[dexRatingKey(owned)] or "Keep it up!"
drawTextBlock(rating, 8, y, 136)
end
love.graphics.setColor(1, 1, 1, 1)
end
+6 -21
View File
@@ -37,12 +37,8 @@ function ListMenu.new(game, title, items, opts)
-- text box, a money box sits top-right, and the list shortens to
-- clear them (DisplayPokemartDialogue_'s screen)
self.dialogue = opts.dialogue
-- PC item lists (players_pc.asm): PrintListMenuEntries shows 4 names
-- and PrintText footers ("How many?", stored/withdrew) use the standard
-- bottom text box — same row budget as the mart, without the money box.
self.messageBox = opts.messageBox
self.money = opts.money -- () -> current money for the box
self.rows = opts.rows or ((opts.dialogue or opts.messageBox) and 4 or ROWS)
self.rows = opts.dialogue and 4 or ROWS
return self
end
@@ -121,7 +117,7 @@ function ListMenu:draw()
love.graphics.setColor(0, 0, 0, 1)
end
if item.right then
Font.draw(item.right, 160 - 8 - Font.width(item.right), y)
Font.draw(item.right, 160 - 8 - #item.right * 8, y)
end
if i == self.index then
-- hollowIndex: a chosen row keeps the hollow '▷' left behind by
@@ -140,11 +136,9 @@ function ListMenu:draw()
Font.drawBox(11, 0, 9, 3)
love.graphics.setColor(0, 0, 0, 1)
local money = ("¥%d"):format(self.money and self.money() or 0)
Font.draw(money, 152 - Font.width(money), 8)
end
if self.dialogue or (self.messageBox and self.footer) then
-- standard bottom text box (PrintText); long prompts wrap and keep
-- their last two lines, like the GB's scrolled box (#115/#174)
Font.draw(money, 152 - #money * 8, 8)
-- the clerk's line in the standard bottom text box; long prompts
-- wrap and keep their last two lines, like the GB's scrolled box
Font.drawBox(0, 12, 20, 6)
love.graphics.setColor(0, 0, 0, 1)
if self.footer then
@@ -159,16 +153,7 @@ function ListMenu:draw()
end
end
elseif self.footer then
-- bare footer (bag money line, etc.)
local flat = {}
for _, page in ipairs(require("src.render.TextBox").paginate(self.footer)) do
for _, line in ipairs(page) do flat[#flat + 1] = line end
end
local y = (#flat >= 2) and 120 or 136
for i = math.max(1, #flat - 1), #flat do
Font.draw(flat[i], 8, y)
y = y + 16
end
Font.draw(self.footer, 8, 136)
end
love.graphics.setColor(1, 1, 1, 1)
end
+4 -43
View File
@@ -19,16 +19,7 @@ function Menu.new(game, items, opts)
self.tx = opts.tx or 10
self.ty = opts.ty or 0
self.tw = opts.tw or 10
self.rowStep = opts.rowStep or 2
-- maxVisible: cap the box to this many rows and scroll the rest instead
-- of growing past it (e.g. the start menu, whose row count varies with
-- save state and mod hooks); nil/unset keeps every caller's old
-- behavior of sizing the box to fit all items.
self.maxVisible = opts.maxVisible
self.scroll = 0
local visible = (self.maxVisible and math.min(self.maxVisible, #items))
or #items
self.th = opts.th or (visible * self.rowStep + 2)
self.th = opts.th or (#items * 2 + 2)
self.cancelable = opts.cancelable ~= false
-- Whether START closes the menu. In pokered a menu responds only to the
-- keys in its wMenuWatchedKeys mask; the common PAD_A | PAD_B (and the
@@ -40,25 +31,9 @@ function Menu.new(game, items, opts)
-- BIT_NO_MENU_BUTTON_SOUND (wMiscFlags): the PC session runs its
-- menus silent (home/window.asm HandleMenuInput_)
self.noSound = opts.noSound or false
self:clampScroll()
return self
end
-- keeps self.index inside the visible [scroll+1, scroll+maxVisible] window;
-- callers that move self.index directly (e.g. restoring a saved cursor
-- position) should call this afterwards to scroll it into view
function Menu:clampScroll()
if not (self.maxVisible and #self.items > self.maxVisible) then
self.scroll = 0
return
end
if self.index - self.scroll > self.maxVisible then
self.scroll = self.index - self.maxVisible
elseif self.index - self.scroll < 1 then
self.scroll = self.index - 1
end
end
function Menu:update(dt)
local input = self.game.input
if input:wasPressed("up") then
@@ -86,29 +61,15 @@ function Menu:update(dt)
self.game.stack:pop()
if self.onCancel then self.onCancel() end
end
self:clampScroll()
end
function Menu:draw()
Font.drawBox(self.tx, self.ty, self.tw, self.th)
love.graphics.setColor(0, 0, 0, 1)
local visible = (self.maxVisible and math.min(self.maxVisible, #self.items))
or #self.items
for row = 1, visible do
local item = self.items[self.scroll + row]
if not item then break end
Font.draw(item.label, (self.tx + 2) * 8,
(self.ty + row * self.rowStep - (self.rowStep - 1)) * 8)
end
local cursorRow = self.index - self.scroll
Font.drawCode(Theme.cursor, (self.tx + 1) * 8,
(self.ty + cursorRow * self.rowStep - (self.rowStep - 1)) * 8)
-- moreArrow ($EE): the same "more below" glyph OptionRows/ManagerState
-- use, sat on the bottom border like TextBox's page-advance cursor
if self.maxVisible and self.scroll + self.maxVisible < #self.items then
Font.drawCode(Theme.moreArrow, (self.tx + self.tw - 2) * 8,
(self.ty + self.th - 2) * 8)
for i, item in ipairs(self.items) do
Font.draw(item.label, (self.tx + 2) * 8, (self.ty + i * 2 - 1) * 8)
end
Font.drawCode(Theme.cursor, (self.tx + 1) * 8, (self.ty + self.index * 2 - 1) * 8)
love.graphics.setColor(1, 1, 1, 1)
end
+12 -24
View File
@@ -22,8 +22,6 @@ function MoveLearnMenu.new(game, mon, newMoveId, onDone)
self.newMoveId = newMoveId
self.onDone = onDone
self.index = 1
-- forget-list UI only after TryingToLearn YES (learn_move.asm .loop)
self.selecting = false
return self
end
@@ -33,32 +31,24 @@ end
-- TryingToLearnText + yes/no (learn_move.asm TryingToLearn): NO offers
-- AbandonLearning, whose own NO loops back here (DontAbandonLearning).
-- Use TextBox opts.choice so YES/NO overlays the still-visible prompt;
-- pushing ChoiceBox from onDone pops the text first and leaves YES/NO
-- on "Which move should be forgotten?" (#173).
function MoveLearnMenu:enter()
local TextBox = require("src.render.TextBox")
local ChoiceBox = require("src.ui.ChoiceBox")
local game = self.game
local mdef = game.data.moves[self.newMoveId]
local name = self:monName()
self.selecting = false
game.stack:push(TextBox.new(game,
("%s is\ntrying to learn\v%s!\fBut, %s\ncan't learn more\vthan 4 moves!\f")
:format(name, mdef.name, name) ..
("Delete an older\nmove to make room\vfor %s?"):format(mdef.name),
nil, {
choice = function(yes)
if yes then
self.selecting = true
else
self:confirmAbandon()
end
end,
}))
function()
game.stack:push(ChoiceBox.new(game, function(yes)
if not yes then self:confirmAbandon() end
end))
end))
end
function MoveLearnMenu:update(dt)
if not self.selecting then return end
local input = self.game.input
local n = #self.mon.moves + 1 -- moves + CANCEL
if input:wasPressed("up") then
@@ -92,15 +82,15 @@ end
-- (DontAbandonLearning)
function MoveLearnMenu:confirmAbandon()
local TextBox = require("src.render.TextBox")
local ChoiceBox = require("src.ui.ChoiceBox")
local game = self.game
local mdef = game.data.moves[self.newMoveId]
self.selecting = false
game.stack:push(TextBox.new(game,
("Abandon learning\n%s?"):format(mdef.name), nil, {
choice = function(yes)
if yes then self:finish(false) else self:enter() end
end,
}))
("Abandon learning\n%s?"):format(mdef.name), function()
game.stack:push(ChoiceBox.new(game, function(yes)
if yes then self:finish(false) else self:enter() end
end))
end))
end
function MoveLearnMenu:finish(learned)
@@ -108,7 +98,6 @@ function MoveLearnMenu:finish(learned)
local game = self.game
local name = self:monName()
local mdef = game.data.moves[self.newMoveId]
self.selecting = false
game.stack:pop()
local msg
if learned then
@@ -125,7 +114,6 @@ function MoveLearnMenu:finish(learned)
end
function MoveLearnMenu:draw()
if not self.selecting then return end
-- single-spaced move list box (TryingToLearn: TextBoxBorder at 4,7)
-- plus the port's extra CANCEL row
Font.drawBox(4, 5, 16, 7)
+2 -84
View File
@@ -3,38 +3,25 @@
-- (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 / ZOOM / VOID FILL / VIDEO MODE, and the MODS row that opens
-- the mod manager.
-- GBC FX / 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.
local PaletteFX = require("src.render.PaletteFX")
local Pipelines = require("src.render.Pipelines")
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
@@ -115,7 +102,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)
local rows = {
return {
{ id = "textSpeed", label = "TEXT SPEED",
value = function(g) return SPEEDS[speedIndex(g)][2] end,
step = function(g)
@@ -194,15 +181,6 @@ local function buildRows(game)
local o = g.save.options
o.tilt = wrapIndex((o.tilt or 0) + dir, 4)
Tilt.setLevel(o.tilt)
-- tilt and a mod's world pipeline are two answers to the same
-- question; turning this on switches that off (Pipelines does the
-- same in the other direction)
if o.tilt > 0 then
for _, entry in ipairs(Pipelines.list()) do
if entry.def.drawWorld then Pipelines.setLevel(entry.id, 0) end
end
Pipelines.syncOptions(o)
end
return true
end },
{ id = "gbcfx", label = "GBC FX",
@@ -215,37 +193,6 @@ 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)
@@ -297,35 +244,6 @@ 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
-- A mod's render pipelines are display modes like TILT, so their rows sit
-- with it rather than at the end of the list where a mod's own
-- ui.options.rows additions land. Nothing registered means nothing
-- spliced, so a vanilla install sees the list it always had.
local pipelineRows = Pipelines.rows(game)
if pipelineRows[1] then
local merged = {}
for _, row in ipairs(rows) do
merged[#merged + 1] = row
if row.id == "tilt" then
for _, extra in ipairs(pipelineRows) do merged[#merged + 1] = extra end
end
end
-- no TILT row to anchor to (a future build could drop it): append
-- rather than silently lose the modes
if #merged == #rows then
for _, extra in ipairs(pipelineRows) do merged[#merged + 1] = extra end
end
rows = merged
end
return rows
end
function OptionsMenu.new(game)
+49 -104
View File
@@ -1,22 +1,16 @@
-- Party menu: list the party, choose a member.
-- Modes:
-- default: A -> submenu (STATS / SWITCH / field moves)
-- opts.onSwitch + opts.battle (voluntary PKMN): A -> SWITCH / STATS /
-- CANCEL (core.asm PartyMenuOrRockOrRun), then onSwitch on SWITCH
-- opts.onSwitch + opts.forceSwitch: A -> onSwitch immediately
-- (ChooseNextMon / SHIFT free-switch)
-- opts.pickOnly + opts.onSwitch: A -> onSwitch (item / script target)
-- default: A -> submenu (STATS / SWITCH order / CANCEL)
-- opts.onSwitch: A -> hand the chosen mon to the callback (battle
-- switch, item targeting via opts.pickOnly)
-- opts.onCancel: fired when the menu closes without a pick (B)
-- Pops itself on B.
local Assets = require("src.render.Assets")
local Font = require("src.render.Font")
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
@@ -75,32 +69,13 @@ local function drawIcon(game, mon, x, y, selected, counter)
local icons = game.data.icons
if not icons then return end
local def = game.data.pokemon[mon.species]
-- Per-species override first: the icons registry folds into
-- icons.bySpecies, and a pokemon record may carry its own `icon` field.
-- Either is a built-in icon name (resolved through icons.icons) or a
-- { image = <path>, frames? } table pointing at bundled art. Falling
-- through to icons.byDex[def.dex] keeps the vanilla dex-indexed default;
-- without the override a modded or dex-renumbered species could never
-- change its menu icon.
local entry = (icons.bySpecies and icons.bySpecies[mon.species])
or (def and def.icon)
local name, path
if type(entry) == "string" then
name = entry
path = icons.icons and icons.icons[entry]
elseif type(entry) == "table" then
path = entry.image
end
if not path then
name = def and def.dex and icons.byDex and icons.byDex[def.dex]
path = name and icons.icons and icons.icons[name]
end
-- byDex is the vanilla lookup, but the icons registry can bring the table
-- into existence on its own, so it may be the only key present
local name = def and def.dex and icons.byDex and icons.byDex[def.dex]
local path = name and icons.icons[name]
if not path then return end
if iconImages[path] == nil then
-- resolve through Assets so an overrides/ or transform-derived icon
-- (e.g. a per-species image at assets/generated/icons/<name>.png) is
-- picked up the same way battle sprites are
local ok, img = pcall(love.graphics.newImage, Assets.resolve(path))
local ok, img = pcall(love.graphics.newImage, path)
iconImages[path] = ok and img or false
end
local img = iconImages[path]
@@ -132,7 +107,6 @@ function PartyMenu.new(game, opts)
self.onSwitch = opts.onSwitch
self.onCancel = opts.onCancel
self.pickOnly = opts.pickOnly
self.forceSwitch = opts.forceSwitch
self.battle = opts.battle
self.party = opts.party -- link battles pass their clamped copies
self.swapFrom = nil
@@ -164,17 +138,7 @@ function PartyMenu:update(dt)
-- hook-injected entries carry a callback instead of an action id
entry.onSelect(mon, self.game)
elseif action == "stats" then
-- battle and field alike return to the party list afterwards
-- (core.asm .partyMenuWasSelected)
Screens.push(self.game, "SummaryMenu", mon)
elseif action == "battle_switch" then
self.game.stack:pop()
self.onSwitch(mon)
return
elseif action == "cancel" then
self.game.stack:pop()
if self.onCancel then self.onCancel() end
return
elseif action == "switch" then
self.swapFrom = self.index
elseif action == "fly" then
@@ -218,13 +182,12 @@ function PartyMenu:update(dt)
-- and music return first (PlayDefaultMusic +
-- LoadWalkingPlayerSpriteGraphics), the menu closes with the
-- GBPalWhiteOutWithDelay3 blink, and the simulated pad press
-- steps the player forward onto land (or across a connection
-- strip when the shore is the next map's edge)
-- steps the player forward onto land
self.game.stack:pop()
ow.player.surfing = false
require("src.core.Music").setSurfing(self.game.data, false)
self.game.stack:push(Transition.whiteFlash(self.game, nil, function()
ow:stepForwardOrCrossEdge(ow.player.facing)
ow:scriptMove(ow.player, ow.player.facing, 1)
end))
return
end
@@ -314,9 +277,7 @@ function PartyMenu:update(dt)
if ow and heal then
self.game.stack:push(Transition.whiteFlash(self.game, nil, function()
require("src.core.Sound").play(self.game.data, "Teleport_Exit1")
-- EnterMapAnim on arrival (HandleFlyWarpOrDungeonWarp sets
-- BIT_FLY_WARP); blackouts must not pass arrive="teleport"
ow:warpToHealPoint(nil, { arrive = "teleport" })
ow:warpToHealPoint()
end))
end
return
@@ -359,66 +320,50 @@ function PartyMenu:update(dt)
require("src.core.Sound").play(self.game.data, "Swap")
end
self.swapFrom = nil
elseif self.onSwitch and (self.forceSwitch or self.pickOnly or not self.battle) then
elseif self.onSwitch then
self.game.stack:pop()
self.onSwitch(mon)
else
self.submenu = true
self.subIndex = 1
local items
-- STATS/SWITCH plus this mon's field moves (start_sub_menus.asm
-- builds the same dynamic list)
local items = { { label = "STATS", action = "stats" },
{ label = "SWITCH", action = "switch" } }
local ow = self.game.overworld
if self.battle and self.onSwitch then
-- SwitchStatsCancelText (core.asm PartyMenuOrRockOrRun)
items = { { label = "SWITCH", action = "battle_switch" },
{ label = "STATS", action = "stats" },
{ label = "CANCEL", action = "cancel" } }
else
-- STATS/SWITCH plus this mon's field moves (start_sub_menus.asm
-- builds the same dynamic list)
items = { { label = "STATS", action = "stats" },
{ label = "SWITCH", action = "switch" } }
-- 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 outside
and self.game.save.inventory.THUNDERBADGE then
table.insert(items, { label = "FLY", action = "fly" })
elseif mv.id == "FLASH" and ow.dark
and self.game.save.inventory.BOULDERBADGE then
table.insert(items, { label = "FLASH", action = "flash" })
elseif mv.id == "CUT" and self.game.save.inventory.CASCADEBADGE then
-- CUT/SURF/STRENGTH are party-menu field moves too
-- (start_sub_menus.asm .outOfBattleMovePointers); listed here
-- with the same list-time badge filter this file already uses
-- for FLY/FLASH. The facing-tile/activation check happens on
-- selection (useCutFieldMove/useSurfFieldMove).
table.insert(items, { label = "CUT", action = "cut" })
elseif mv.id == "SURF" and self.game.save.inventory.SOULBADGE then
table.insert(items, { label = "SURF", action = "surf" })
elseif mv.id == "STRENGTH" and self.game.save.inventory.RAINBOWBADGE then
table.insert(items, { label = "STRENGTH", action = "strength" })
elseif mv.id == "SOFTBOILED" then
table.insert(items, { label = "SOFTBOILED", action = "softboiled" })
elseif mv.id == "TELEPORT" and outside then
-- TELEPORT works only OUTDOORS (start_sub_menus.asm
-- .teleport -> CheckIfInOutsideMap); dark maps don't
-- block it
table.insert(items, { label = "TELEPORT", action = "escape" })
elseif mv.id == "DIG" and DIG_TILESETS[ow.map.def.tileset]
and ow.map.id ~= "AGATHAS_ROOM" then
-- DIG runs ItemUseEscapeRope (.dig sets wCurItem =
-- ESCAPE_ROPE): usable in the dungeon tilesets of
-- escape_rope_tilesets.asm minus Agatha's room, even in
-- the dark (Rock Tunnel)
table.insert(items, { label = "DIG", action = "escape" })
end
if not self.battle and ow and mon.hp > 0 then
for _, mv in ipairs(mon.moves) do
if mv.id == "FLY" and ow.map.def.tileset == "OVERWORLD"
and self.game.save.inventory.THUNDERBADGE then
table.insert(items, { label = "FLY", action = "fly" })
elseif mv.id == "FLASH" and ow.dark
and self.game.save.inventory.BOULDERBADGE then
table.insert(items, { label = "FLASH", action = "flash" })
elseif mv.id == "CUT" and self.game.save.inventory.CASCADEBADGE then
-- CUT/SURF/STRENGTH are party-menu field moves too
-- (start_sub_menus.asm .outOfBattleMovePointers); listed here
-- with the same list-time badge filter this file already uses
-- for FLY/FLASH. The facing-tile/activation check happens on
-- selection (useCutFieldMove/useSurfFieldMove).
table.insert(items, { label = "CUT", action = "cut" })
elseif mv.id == "SURF" and self.game.save.inventory.SOULBADGE then
table.insert(items, { label = "SURF", action = "surf" })
elseif mv.id == "STRENGTH" and self.game.save.inventory.RAINBOWBADGE then
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
-- TELEPORT works only OUTDOORS (start_sub_menus.asm
-- .teleport -> CheckIfInOutsideMap); dark maps don't
-- block it
table.insert(items, { label = "TELEPORT", action = "escape" })
elseif mv.id == "DIG" and DIG_TILESETS[ow.map.def.tileset]
and ow.map.id ~= "AGATHAS_ROOM" then
-- DIG runs ItemUseEscapeRope (.dig sets wCurItem =
-- ESCAPE_ROPE): usable in the dungeon tilesets of
-- escape_rope_tilesets.asm minus Agatha's room, even in
-- the dark (Rock Tunnel)
table.insert(items, { label = "DIG", action = "escape" })
end
end
end

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