Compare commits

..

2 Commits

Author SHA1 Message Date
bryanthaboi 895e904d75 too many comments 2026-07-23 14:25:00 -04:00
bryanthaboi 1ce9e32dd1 frame cap update 2026-07-23 14:20:23 -04:00
217 changed files with 2288 additions and 25836 deletions
+1 -16
View File
@@ -214,21 +214,8 @@ jobs:
apk="$(find dist/android/debug -name '*.apk' | head -1)" apk="$(find dist/android/debug -name '*.apk' | head -1)"
[ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/debug"; exit 1; } [ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/debug"; exit 1; }
cp "$apk" "$outdir/gen1recomp-${v}-android.apk" 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" 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 - name: Publish GitHub Release
env: env:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
@@ -279,9 +266,7 @@ jobs:
"dist/release/gen1recomp-${v}-macos.zip" \ "dist/release/gen1recomp-${v}-macos.zip" \
"dist/release/gen1recomp-${v}-windows.zip" \ "dist/release/gen1recomp-${v}-windows.zip" \
"dist/release/gen1recomp-${v}-linux.zip" \ "dist/release/gen1recomp-${v}-linux.zip" \
"dist/release/gen1recomp-${v}-android.apk" \ "dist/release/gen1recomp-${v}-android.apk"
"dist/release/gen1recomp-${v}.love" \
"dist/release/sha256sums.txt"
echo "Published release $tag" echo "Published release $tag"
+17 -42
View File
@@ -1,19 +1,18 @@
# Pokemon Gen 1 Recompilation Project # Pokemon Gen 1 Recompilation Project
A native LÖVE2D recreation of Pokemon Red and Blue. The engine and map A native LÖVE2D recreation of Pokemon Red. The engine and map behavior are
behavior are hand-written Lua; game data and graphics are decoded from a ROM hand-written Lua; game data and graphics are decoded from a ROM supplied by
supplied by the player. the player.
SUPPORT AND ANNOUNCEMENTS: [Discord](https://bois.icu) SUPPORT AND ANNOUNCEMENTS: [Discord](https://bois.icu)
This project does not include a ROM, emulate the Game Boy, transpile assembly, This project does not include a ROM, emulate the Game Boy, transpile assembly,
or download a disassembly. A canonical US Pokemon Red or Blue ROM is the only or download a disassembly. A canonical US Pokemon Red ROM is the only game
game content input. content input.
The ROM is verified, used during import, and then released from memory. It is The ROM is verified, used during import, and then released from memory. It is
not copied into the cache. Later launches load the private generated cache and not copied into the cache. Later launches load the private generated cache and
do not ask for the ROM again. Red and Blue can both be imported and played do not ask for the ROM again.
side by side.
## Quick Start ## 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 or drop it onto the window. Import takes a few seconds and the game starts
automatically. automatically.
Only the canonical 1 MiB US Red and Blue ROMs are accepted. The importer Only the canonical 1 MiB US Red ROM is accepted. The importer verifies SHA-1
verifies SHA-1 before creating any game data: `ea9bcae617fdf159b045185467ae58b2e4a48b9a` before creating any game data.
- Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a`
- Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2`
The packaged app contains neither a ROM nor pre-extracted game data. Music, The packaged app contains neither a ROM nor pre-extracted game data. Music,
sound effects, and cries are synthesized while the game runs from compact sound effects, and cries are synthesized while the game runs from compact
audio channel programs copied out of the verified ROM. audio channel programs copied out of the verified ROM.
## Controls ## Controls
| Action | Keyboard | Controller | 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.
| Move | Arrow keys / WASD | D-pad / left stick |
| A | Z / Enter / Space | A |
| B | X / Backspace | B |
| Start | Escape | Start |
| Select | Tab / Shift | Back / Select |
Rebind any of these in-game under **OPTIONS → CONTROLS**. Controllers are
supported out of the box.
### Hotkeys
| Key | What it does |
|-----|----------------|
| `-` / `=` | Zoom out / in (overworld; also mouse wheel) |
| `2` | Cycle COLORS |
| `3` | Cycle TILT (free-roam overworld) |
| `4` | Cycle ZOOM through every level (free-roam overworld) |
| `5` | Cycle GBC FX |
| `F1` | Save |
| `F2` | Load |
| `F10` | Open / close the mod manager |
COLORS, TILT, ZOOM, GBC FX, and VOID FILL are also in the Options menu
and persist in `options.lua`.
## Running From Source ## Running From Source
Requires LÖVE 11.x. Place a Red or Blue ROM in the project folder and Requires LÖVE 11.x. Place the ROM in the project folder and double-click
double-click `Play-Mac.command` or `Play-Windows.bat`, or run: `Play-Mac.command` or `Play-Windows.bat`, or run:
```sh ```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 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) > This project would not be possible without [pret](https://github.com/pret) >
the pret band of decompiling maniacs > and their the pret band of decompiling maniacs > and their
[pokered](https://github.com/pret/pokered) disassembly. [pokered](https://github.com/pret/pokered) disassembly.
## Wanna Support My Work?
[![Buy Me a Coffee](https://img.shields.io/badge/Buy_Me_a_Coffee-FFDD00?style=for-the-badge&logo=buy-me-a-coffee&logoColor=black)](https://buymeacoffee.com/bryanthaboi)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 753 KiB

After

Width:  |  Height:  |  Size: 370 KiB

+3 -2
View File
@@ -1,8 +1,9 @@
-- Flavor talk scripts for CopycatsHouse2F (registry id COPYCATS_HOUSE_2F). -- Flavor talk scripts for CopycatsHouse2F (registry id COPYCATS_HOUSE_2F).
-- Source: pokered/scripts/CopycatsHouse2F.asm, pokered/text/CopycatsHouse2F.asm -- Source: pokered/scripts/CopycatsHouse2F.asm, pokered/text/CopycatsHouse2F.asm
-- --
-- TEXT_COPYCATSHOUSE2F_COPYCAT (POKE DOLL -> TM31 MIMIC) lives in -- TEXT_COPYCATSHOUSE2F_COPYCAT is already ported (with the POKE DOLL /
-- data/scripts/story4.lua. -- TM31 MIMIC trade) in data/scripts/story4.lua, so it is intentionally
-- omitted here.
return { return {
COPYCATS_HOUSE_2F = { COPYCATS_HOUSE_2F = {
+18 -5
View File
@@ -1,11 +1,15 @@
-- Pewter City flavor dialogue (pokered/scripts/PewterCity.asm). -- Pewter City flavor dialogue (pokered/scripts/PewterCity.asm).
-- PewterCity_TextPointers text_asm bodies for the SUPER_NERD1 museum -- PewterCity_TextPointers text_asm bodies for the SUPER_NERD1 museum
-- guide 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 -- The escort choreography (SUPER_NERD1 walking the player to the
-- story5.lua so the lockstep RLE walk is not overwritten by this -- museum, YOUNGSTER walking the player to the gym) is scripted NPC
-- flavor merge. SUPER_NERD1's museum escort is not ported; only the -- movement + a wPewterCityCurScript state machine that steers the
-- YES/NO-branched flavor text is here. -- 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 = {} local M = {}
@@ -57,6 +61,15 @@ M.PEWTER_CITY = {
end) end)
end, end,
-- PewterCityYoungsterText (scripts/PewterCity.asm): the "follow
-- me" line the youngster says when the player is stopped from
-- leaving Pewter east before beating Brock; the actual gate/step
-- block is handled by story5.lua's onStep for this map.
TEXT_PEWTERCITY_YOUNGSTER = function(game, ow, npc, done)
local t = text(game)
push(game, t._PewterCityYoungsterYoureATrainerFollowMeText
or "You're a trainer\nright? BROCK's\nlooking for new\nchallengers!\nFollow me!", done)
end,
}, },
} }
-22
View File
@@ -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 = { SILPH_CO_9F = {
talk = { talk = {
-- SilphCo9FNurseText (pokered/scripts/SilphCo9F.asm): -- SilphCo9FNurseText (pokered/scripts/SilphCo9F.asm):
-- before EVENT_BEAT_SILPH_CO_GIOVANNI: heals the party, white fade -- before EVENT_BEAT_SILPH_CO_GIOVANNI: heals the party and shows
-- (Delay3 between out/in; no Music_PkmnHealed), then "Don't give -- "You look tired..." then "Don't give up!"; after the event, just
-- up!"; after the event, just says thanks. Nurse texts are not in -- says thanks. Nurse texts are not in data/generated/text.lua, so
-- data/generated/text.lua, so the exact pokered/text/SilphCo9F.asm -- the exact pokered/text/SilphCo9F.asm strings are used as literals.
-- strings are used as literals.
TEXT_SILPHCO9F_NURSE = { TEXT_SILPHCO9F_NURSE = {
{ "face_player" }, -- 1 { "face_player" },
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" }, -- 2 { "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
{ "jump_if_true", 11 }, -- 3 { "jump_if_true", 8 },
{ "show_text", "You look tired!\nYou should take a\nquick nap!" }, -- 4 { "show_text", "You look tired!\nYou should take a\nquick nap!" },
{ "heal_party" }, -- 5 { "heal_party" },
{ "fade", "out", "white" }, -- 6 { "show_text", "Don't give up!" },
{ "wait", 3 }, -- 7 Delay3 { "jump", 9 },
{ "fade", "in", "white" }, -- 8 { "show_text", "Thank you so\nmuch!" },
{ "show_text", "Don't give up!" }, -- 9
{ "jump", "end" }, -- 10
{ "show_text", "Thank you so\nmuch!" }, -- 11
}, },
}, },
}, },
-1
View File
@@ -28,7 +28,6 @@ local files = {
"data.scripts.flavor.pokemon_fan_club", "data.scripts.flavor.pokemon_fan_club",
"data.scripts.flavor.power_plant", "data.scripts.flavor.power_plant",
"data.scripts.flavor.reds_house_1f", "data.scripts.flavor.reds_house_1f",
"data.scripts.flavor.reds_house_2f",
"data.scripts.flavor.route11_gate_2f", "data.scripts.flavor.route11_gate_2f",
"data.scripts.flavor.route18_gate_2f", "data.scripts.flavor.route18_gate_2f",
"data.scripts.flavor.route_12_gate_2f", "data.scripts.flavor.route_12_gate_2f",
+47 -115
View File
@@ -24,23 +24,17 @@ local function starterBall(askText, species, choseFlag, ownBall,
{ "check_flag", "EVENT_FOLLOWED_OAK_INTO_LAB" }, -- 3 { "check_flag", "EVENT_FOLLOWED_OAK_INTO_LAB" }, -- 3
{ "jump_if_false", 20 }, -- 4 { "jump_if_false", 20 }, -- 4
-- the Pokédex "new species" entry shows before the ask (predef -- the Pokédex "new species" entry shows before the ask (predef
-- StarterDex ahead of OaksLabYouWant...Text). StarterDex temporarily -- StarterDex ahead of OaksLabYouWant...Text)
-- sets the owned bits so ShowPokedexData prints height/weight/text; { "push_screen", "DexEntryMenu", species }, -- 5
-- forceOwned is that bypass without mutating save.pokedex.owned.
{ "push_screen", "DexEntryMenu",
{ species = species, forceOwned = true } }, -- 5
{ "ask", askText }, -- 6 { "ask", askText }, -- 6
{ "jump_if_false", 21 }, -- 7 { "jump_if_false", 21 }, -- 7
-- OaksLab.asm prints ReceivedMon then AddPartyMon (AskName lives { "give_pokemon", species, 5 }, -- 8
-- inside give_pokemon). Show the received text first so the { "set_flag", "EVENT_GOT_STARTER" }, -- 9
-- nickname prompt follows "you got X", matching Gen1. { "set_flag", choseFlag }, -- 10
{ "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 8
{ "give_pokemon", species, 5 }, -- 9
{ "set_flag", "EVENT_GOT_STARTER" }, -- 10
{ "set_flag", choseFlag }, -- 11
-- POKé BALLs are not handed out here in the original -- Oak gives -- POKé BALLs are not handed out here in the original -- Oak gives
-- them later, at OaksLabOak1Text's .give_poke_balls beat once the -- them later, at OaksLabOak1Text's .give_poke_balls beat once the
-- player has beaten the Route 22 rival (see TEXT_OAKSLAB_OAK1 below) -- player has beaten the Route 22 rival (see TEXT_OAKSLAB_OAK1 below)
{ "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 11
{ "hide_object", "OAKS_LAB", ownBall }, -- 12 { "hide_object", "OAKS_LAB", ownBall }, -- 12
-- the rival walks to the countering ball (around the furniture) -- the rival walks to the countering ball (around the furniture)
{ "move_npc_to", 1, rivalBallX, 4 }, -- 13 { "move_npc_to", 1, rivalBallX, 4 }, -- 13
@@ -59,97 +53,48 @@ end
return { return {
talk = { talk = {
-- Oak: OaksLabOak1Text. Parcel delivery kicks SCRIPT_OAKSLAB_RIVAL_ -- Oak: accepts the parcel and hands over the Pokédex, then (once the
-- ARRIVES_AT_OAKS_REQUEST + OaksLabOakGivesPokedexScript (rival walk- -- player has beaten the Route 22 rival) hands over the real POKé
-- in, full Pokédex speech, rival exit, Route 22 arm). Dex-rating -- BALLs (scripts/OaksLab.asm OaksLabOak1Text, simplified: no rival
-- (DisplayDexRating) is still skipped. -- recall / dex-rating / "pokemon can fight" / "around the world"
-- branches -- see docs/known-differences.md)
TEXT_OAKSLAB_OAK1 = { TEXT_OAKSLAB_OAK1 = {
{ "face_player" }, { "face_player" }, -- 1
{ "check_item", "POKE_BALL" }, { "check_flag", "EVENT_GOT_OAKS_PARCEL" }, -- 2
{ "jump_if_true", "come_see" }, { "jump_if_false", 14 }, -- 3
{ "check_flag", "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE" }, { "check_flag", "EVENT_OAK_GOT_PARCEL" }, -- 4
{ "jump_if_true", "give_balls" }, { "jump_if_true", 14 }, -- 5
{ "check_flag", "EVENT_GOT_POKEDEX" }, { "show_text", "_OaksLabOak1DeliverParcelText" }, -- 6
{ "jump_if_true", "around_world" }, { "take_item", "OAKS_PARCEL", 1 }, -- 7
{ "check_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" }, { "set_flag", "EVENT_OAK_GOT_PARCEL" }, -- 8
{ "jump_if_false", "pre_lab_battle" }, { "show_text", "_OaksLabOak1PokemonAroundTheWorldText" }, -- 9
{ "check_item", "OAKS_PARCEL" }, { "set_flag", "EVENT_GOT_POKEDEX" }, -- 10
{ "jump_if_false", "raise_young" }, -- the Pokédex swaps Viridian's two old men (OaksLab.asm:602-606:
-- OaksLabOak1Text.got_parcel → RivalArrives + OakGivesPokedex -- HideObject TOGGLE_LYING_OLD_MAN / ShowObject TOGGLE_OLD_MAN).
{ "show_text", "_OaksLabOak1DeliverParcelText" }, -- Until this ran, the walking man at (17,5) -- who owns the coffee
{ "play_sound", "Get_Key_Item" }, -- ask and the catch tutorial -- stayed OFF for the whole game
{ "show_text", "_OaksLabOak1ParcelThanksText" }, -- (toggleable_objects.asm seeds him OFF, the sleeper ON).
{ "take_item", "OAKS_PARCEL", 1 }, { "hide_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY" }, -- 11
{ "stop_music" }, { "show_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN" }, -- 12
{ "play_music", "Music_MeetRival" }, { "jump", 32 }, -- 13
{ "show_text", "_OaksLabRivalGrampsText" }, { "check_flag", "EVENT_GOT_STARTER" }, -- 14
{ "show_object", "OAKS_LAB", "OAKSLAB_RIVAL" }, { "jump_if_false", 29 }, -- 15
-- OaksLabCalcRivalMovementScript: sprite map (8,11) → cell (4,7) { "check_item", "POKE_BALL" }, -- 16
-- when the player stands below Oak (the usual desk talk). { "jump_if_true", 27 }, -- 17
{ "place_npc", 1, 4, 7, "up" }, { "check_flag", "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE" }, -- 18
{ "move_npc_to", 1, 4, 3 }, { "jump_if_false", 31 }, -- 19
{ "play_music", "Music_OaksLab" }, { "check_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 20
{ "face_object", 1, "up" }, { "jump_if_true", 27 }, -- 21
{ "face_object", 5, "down" }, { "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 22
{ "show_text", "_OaksLabRivalWhatDidYouCallMeForText" }, { "give_item", "POKE_BALL", 5, false }, -- 23
{ "face_object", 1, "up" }, { "show_text", "_OaksLabOak1ReceivedPokeballsText" }, -- 24
{ "face_object", 5, "down" }, { "show_text", "_OaksLabGivePokeballsExplanationText" }, -- 25
{ "show_text", "_OaksLabOakIHaveARequestText" }, { "jump", 32 }, -- 26
{ "face_object", 1, "up" }, { "show_text", "_OaksLabOak1ComeSeeMeSometimesText" }, -- 27
{ "face_object", 5, "down" }, { "jump", 32 }, -- 28
{ "show_text", "_OaksLabOakMyInventionPokedexText" }, { "show_text", "_OaksLabOak1WhichPokemonDoYouWantText" }, -- 29
{ "show_text", "_OaksLabOakGotPokedexText" }, { "jump", 32 }, -- 30
{ "play_sound", "Get_Key_Item" }, { "show_text", "_OaksLabOak1RaiseYourYoungPokemonText" }, -- 31 (32 = end)
{ "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" },
}, },
TEXT_OAKSLAB_CHARMANDER_POKE_BALL = 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 -- Oak stops you leaving without a starter; the rival stops you on
-- the way out for the first battle (scripts/OaksLab.asm -- the way out for the first battle (scripts/OaksLab.asm
-- OaksLabScript8 / OaksLabRivalChallenge) -- OaksLabScript8 / OaksLabRivalChallenge)
+7 -15
View File
@@ -1,23 +1,15 @@
-- Hand-ported from pret/pokered scripts/RedsHouse1F.asm. -- Hand-ported from pret/pokered scripts/RedsHouse1F.asm.
-- Mom (RedsHouse1FMomText): pre-starter shows the wake-up / Oak tip; -- Mom (RedsHouse1FMomText, text_asm) heals the party and shows the
-- after EVENT_GOT_STARTER, RedsHouse1FMomHealScript fades to white, -- "you should rest" / "looking great" dialogue. The intro "wake up"
-- heals, plays MUSIC_PKMN_HEALED, fades back, then "looking great". -- branch is tied to the unported intro cutscene, so the heal path is used.
return { return {
talk = { talk = {
TEXT_REDSHOUSE1F_MOM = { TEXT_REDSHOUSE1F_MOM = {
{ "face_player" }, -- 1 { "face_player" },
{ "check_flag", "EVENT_GOT_STARTER" }, -- 2 { "show_text", "_RedsHouse1FMomYouShouldRestText" },
{ "jump_if_true", 6 }, -- 3 { "heal_party" },
{ "show_text", "_RedsHouse1FMomWakeUpText" }, -- 4 { "show_text", "_RedsHouse1FMomLookingGreatText" },
{ "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
}, },
}, },
} }
+58 -203
View File
@@ -222,55 +222,17 @@ M.BILLS_HOUSE = {
-- repair saves that already got the ticket under the old collapsed -- repair saves that already got the ticket under the old collapsed
-- script (the monster never hidden, human Bill never shown) -- script (the monster never hidden, human Bill never shown)
onEnter = function(game, ow) onEnter = function(game, ow)
local flags = game.save.flags if game.save.flags.EVENT_GOT_SS_TICKET
if flags.EVENT_GOT_SS_TICKET and not game.save.flags.EVENT_USED_CELL_SEPARATOR_ON_BILL then
and not flags.EVENT_USED_CELL_SEPARATOR_ON_BILL then
local Commands = require("src.script.Commands") local Commands = require("src.script.Commands")
local ctx = { game = game, save = game.save, overworld = ow } 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_BILL_POKEMON")
Commands.show_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL1") Commands.show_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL1")
flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = true game.save.flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = true
flags.EVENT_USED_CELL_SEPARATOR_ON_BILL = true game.save.flags.EVENT_USED_CELL_SEPARATOR_ON_BILL = true
flags.EVENT_MET_BILL = true game.save.flags.EVENT_MET_BILL = true
flags.EVENT_MET_BILL_2 = true game.save.flags.EVENT_MET_BILL_2 = true
end 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, end,
} }
@@ -294,40 +256,25 @@ M.VERMILION_CITY = {
end, end,
-- VermilionCityDefaultScript's per-frame SSAnneTicketCheckCoords check: -- VermilionCityDefaultScript's per-frame SSAnneTicketCheckCoords check:
-- the unguarded cell (18,30) just west of the sailor leads straight -- the unguarded cell (18,30) just west of the sailor leads straight
-- onto the dock warp. Stepping onto it facing down always runs the -- onto the dock warp, so stepping onto it heading for the dock gets
-- sailor dialog (DisplayTextID TEXT_VERMILIONCITY_SAILOR1); only a -- ticket-checked (and turned back once the ship has sailed) without
-- ticket while the ship is still docked lets the player continue -- -- the player ever pressing A. The sailor himself never disappears.
-- otherwise they are walked back up. The sailor himself never hides.
onStep = function(game, ow, x, y) onStep = function(game, ow, x, y)
if x ~= 18 or y ~= 30 then return false end if x ~= 18 or y ~= 30 then return false end
if ow.player.facing ~= "down" 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 t = game.data.text
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
local shipLeft = Flags.get(game.save, "EVENT_SS_ANNE_LEFT") if f.EVENT_SS_ANNE_LEFT then
local hasTicket = (game.save.inventory.S_S_TICKET or 0) > 0
if shipLeft then
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
t._VermilionCitySailor1ShipSetSailText or "The ship set sail.", t._VermilionCitySailor1ShipSetSailText or "The ship set sail.",
function() ow:scriptMove(ow.player, "up", 1) end)) function() ow:scriptMove(ow.player, "up", 1) end))
return true return true
end end
-- Walk-past is never facing-right / inFrontOfOrBehindGuardCoords, so if (game.save.inventory.S_S_TICKET or 0) > 0 then return false end
-- 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, game.stack:push(TextBox.new(game,
ask .. "\f" (t._VermilionCitySailor1WelcomeToSSAnneText or "Welcome to S.S.\nANNE!")
.. (t._VermilionCitySailor1FlashedTicketText .. "\f"
or "{PLAYER} flashed\nthe S.S.TICKET!")))
return true
end
game.stack:push(TextBox.new(game,
ask .. "\f"
.. (t._VermilionCitySailor1YouNeedATicketText .. (t._VermilionCitySailor1YouNeedATicketText
or "You need a ticket\nto get aboard."), or "You need a ticket\nto get aboard."),
function() ow:scriptMove(ow.player, "up", 1) end)) function() ow:scriptMove(ow.player, "up", 1) end))
@@ -341,7 +288,7 @@ M.VERMILION_CITY = {
{ "face_player" }, -- 1 { "face_player" }, -- 1
{ "check_flag", "EVENT_SS_ANNE_LEFT" }, -- 2 { "check_flag", "EVENT_SS_ANNE_LEFT" }, -- 2
{ "jump_if_true", 11 }, -- 3 { "jump_if_true", 11 }, -- 3
{ "show_text", "_VermilionCitySailor1DoYouHaveATicketText" }, -- 4 { "show_text", "_VermilionCitySailor1WelcomeToSSAnneText" }, -- 4
{ "check_item", "S_S_TICKET" }, -- 5 { "check_item", "S_S_TICKET" }, -- 5
{ "jump_if_false", 9 }, -- 6 { "jump_if_false", 9 }, -- 6
{ "show_text", "_VermilionCitySailor1FlashedTicketText" }, -- 7 { "show_text", "_VermilionCitySailor1FlashedTicketText" }, -- 7
@@ -371,21 +318,18 @@ M.SS_ANNE_2F = {
M.SS_ANNE_CAPTAINS_ROOM = { M.SS_ANNE_CAPTAINS_ROOM = {
talk = { talk = {
-- SSAnneCaptainsRoomCaptainText: after the rub line's text_asm tail,
-- pokered plays MUSIC_PKMN_HEALED (scripts/SSAnneCaptainsRoom.asm).
TEXT_SSANNECAPTAINSROOM_CAPTAIN = { TEXT_SSANNECAPTAINSROOM_CAPTAIN = {
{ "check_flag", "EVENT_GOT_HM01" }, -- 1 { "check_flag", "EVENT_GOT_HM01" }, -- 1
{ "jump_if_true", 10 }, -- 2 { "jump_if_true", 9 }, -- 2
{ "show_text", "_SSAnneCaptainsRoomRubCaptainsBackText" }, -- 3 { "show_text", "_SSAnneCaptainsRoomRubCaptainsBackText" }, -- 3
{ "play_once", "Music_PkmnHealed" }, -- 4 { "show_text", "_SSAnneCaptainsRoomCaptainIFeelMuchBetterText" }, -- 4
{ "show_text", "_SSAnneCaptainsRoomCaptainIFeelMuchBetterText" }, -- 5
-- give-then-print like scripts/SSAnneCaptainsRoom.asm (GiveItem -- give-then-print like scripts/SSAnneCaptainsRoom.asm (GiveItem
-- fills wStringBuffer; the received text reads it) -- fills wStringBuffer; the received text reads it)
{ "give_item", "HM_CUT", 1, false }, -- 6 { "give_item", "HM_CUT", 1, false }, -- 5
{ "show_text", "_SSAnneCaptainsRoomCaptainReceivedHM01Text" }, -- 7 { "show_text", "_SSAnneCaptainsRoomCaptainReceivedHM01Text" }, -- 6
{ "set_flag", "EVENT_GOT_HM01" }, -- 8 { "set_flag", "EVENT_GOT_HM01" }, -- 7
{ "jump", 11 }, -- 9 { "jump", 10 }, -- 8
{ "show_text", "_SSAnneCaptainsRoomCaptainNotSickAnymoreText" }, -- 10 { "show_text", "_SSAnneCaptainsRoomCaptainNotSickAnymoreText" }, -- 9
}, },
}, },
} }
@@ -616,8 +560,7 @@ M.SILPH_CO_11F = {
-- ------------------------------------------------------------------- -- -------------------------------------------------------------------
-- Victory Road boulder switches (scripts/VictoryRoad1F/2F/3F.asm): -- Victory Road boulder switches (scripts/VictoryRoad1F/2F/3F.asm):
-- a boulder resting on a switch removes a barrier block; the 3F hole -- a boulder resting on a switch removes a barrier block; the 3F hole
-- drops a boulder down to the 2F switch, and also dungeon-warps the -- drops a boulder down to the 2F switch.
-- player (IsPlayerOnDungeonWarp + DungeonWarpList/Data -> 2F at 22,16).
-- ------------------------------------------------------------------- -- -------------------------------------------------------------------
local function boulderAt(ow, x, y) local function boulderAt(ow, x, y)
@@ -683,18 +626,6 @@ M.VICTORY_ROAD_3F = {
Commands.show_object(ctx, "VICTORY_ROAD_2F", "VICTORYROAD2F_BOULDER") Commands.show_object(ctx, "VICTORY_ROAD_2F", "VICTORYROAD2F_BOULDER")
end end
end, end,
-- scripts/VictoryRoad3F.asm VictoryRoad3FDefaultScript: the same hole
-- is a dungeon warp for the player (wDungeonWarpDestinationMap =
-- VICTORY_ROAD_2F, wWhichDungeonWarp = 2 -> DungeonWarpData 22,16).
-- Mirrors POKEMON_MANSION_3F.onStep; CAVERN $22 is walkable so the
-- fall is onStep, not a collision block.
onStep = function(game, ow, x, y)
if x == 23 and y == 15 then
ow:startWarpTo("VICTORY_ROAD_2F", 22, 16, ow.player.facing)
return true
end
return false
end,
} }
-- ------------------------------------------------------------------- -- -------------------------------------------------------------------
@@ -707,16 +638,6 @@ M.VICTORY_ROAD_3F = {
-- IndigoPlateauLobby.asm) so the champion is re-fightable on rematches, -- IndigoPlateauLobby.asm) so the champion is re-fightable on rematches,
-- like pokered's re-entry cutscene. EVENT_BEAT_CHAMPION_RIVAL stays set -- like pokered's re-entry cutscene. EVENT_BEAT_CHAMPION_RIVAL stays set
-- forever (postgame gates like the Cerulean cave guard read it). -- forever (postgame gates like the Cerulean cave guard read it).
--
-- pokered forces the fight on map entry: Agatha's victory arms
-- SCRIPT_CHAMPIONSROOM_PLAYER_ENTERS (scripts/AgathasRoom.asm), and
-- ChampionsRoomPlayerEntersScript then runs RivalEntrance_RLEMovement
-- (up 1, right 1, up 3) before ChampionsRoomRivalReadyToBattleScript.
-- The rival object has no trainer header / sight range, so without that
-- entrance script the player can walk past (issue #99). We arm on the
-- run flag instead of Agatha's victory bit: same observable effect for
-- first clear and Indigo rematches, without a cross-map script pointer.
--
-- scripts/ChampionsRoom.asm ChampionsRoomRivalDefeatedScript -> -- scripts/ChampionsRoom.asm ChampionsRoomRivalDefeatedScript ->
-- OakArrivesScript -> OakCongratulatesPlayerScript -> -- OakArrivesScript -> OakCongratulatesPlayerScript ->
-- OakDisappointedWithRivalScript -> OakComeWithMeScript -> OakExitsScript, -- OakDisappointedWithRivalScript -> OakComeWithMeScript -> OakExitsScript,
@@ -725,13 +646,15 @@ M.VICTORY_ROAD_3F = {
-- itself is NOT run here: it belongs to the HALL_OF_FAME room script -- itself is NOT run here: it belongs to the HALL_OF_FAME room script
-- (scripts/HallOfFame.asm), so we set a one-shot marker and warp; the room -- (scripts/HallOfFame.asm), so we set a one-shot marker and warp; the room
-- onEnter (M.HALL_OF_FAME below) drives the HoF Oak speech + record. -- onEnter (M.HALL_OF_FAME below) drives the HoF Oak speech + record.
local championsRoomRivalScript = { M.CHAMPIONS_ROOM = {
talk = {
TEXT_CHAMPIONSROOM_RIVAL = {
{ "face_player" }, -- 1 { "face_player" }, -- 1
{ "check_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 2 { "check_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 2
{ "jump_if_true", 25 }, -- 3 past end { "jump_if_true", 24 }, -- 3
{ "show_text", "_ChampionsRoomRivalIntroText" }, -- 4 { "show_text", "_ChampionsRoomRivalIntroText" }, -- 4
{ "rival_battle", "OPP_RIVAL3", 1 }, -- 5 { "rival_battle", "OPP_RIVAL3", 1 }, -- 5
{ "jump_if_false", 25 }, -- 6 past end { "jump_if_false", 24 }, -- 6
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 7 { "set_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 7
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL" }, -- 8 { "set_flag", "EVENT_BEAT_CHAMPION_RIVAL" }, -- 8
-- ChampionsRoomRivalDefeatedScript re-displays TEXT_CHAMPIONSROOM_RIVAL, -- ChampionsRoomRivalDefeatedScript re-displays TEXT_CHAMPIONSROOM_RIVAL,
@@ -739,55 +662,27 @@ local championsRoomRivalScript = {
-- _ChampionsRoomRivalAfterBattleText (the in-battle _RivalDefeatedText -- _ChampionsRoomRivalAfterBattleText (the in-battle _RivalDefeatedText
-- is the port's generic "<PLAYER> defeated BLUE!" engine line instead). -- is the port's generic "<PLAYER> defeated BLUE!" engine line instead).
{ "show_text", "_ChampionsRoomRivalAfterBattleText" }, -- 9 { "show_text", "_ChampionsRoomRivalAfterBattleText" }, -- 9
-- ChampionsRoomOakArrivesScript: Music_Cities1AlternateTempo -- ChampionsRoomOakArrivesScript: Oak's "{PLAYER}!" then reveal + walk in
-- (Cities1, kept into HALL_OF_FAME like BIT_NO_MAP_MUSIC after { "show_text", "_ChampionsRoomOakText" }, -- 10
-- defeating RIVAL3), then Oak's "{PLAYER}!" + reveal + walk in { "show_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 11
{ "play_music", "Music_Cities1", { keep = true } }, -- 10 { "move_npc", 2, "up", 5 }, -- 12 OakEntranceAfterVictoryMovement (3,7)->(3,2)
{ "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 -- OakCongratulatesPlayerScript: rival faces left, Oak faces down
{ "face_object", 1, "left" }, -- 14 { "face_object", 1, "left" }, -- 13
{ "face_object", 2, "down" }, -- 15 { "face_object", 2, "down" }, -- 14
{ "show_text", "_ChampionsRoomOakCongratulatesPlayerText" }, -- 16 { "show_text", "_ChampionsRoomOakCongratulatesPlayerText" }, -- 15
-- OakDisappointedWithRivalScript: Oak turns to the rival (right) -- OakDisappointedWithRivalScript: Oak turns to the rival (right)
{ "face_object", 2, "right" }, -- 17 { "face_object", 2, "right" }, -- 16
{ "show_text", "_ChampionsRoomOakDisappointedWithRivalText" }, -- 18 { "show_text", "_ChampionsRoomOakDisappointedWithRivalText" }, -- 17
-- OakComeWithMeScript: Oak faces down again, then exits up -- OakComeWithMeScript: Oak faces down again, then exits up
{ "face_object", 2, "down" }, -- 19 { "face_object", 2, "down" }, -- 18
{ "show_text", "_ChampionsRoomOakComeWithMeText" }, -- 20 { "show_text", "_ChampionsRoomOakComeWithMeText" }, -- 19
{ "move_npc", 2, "up", 2 }, -- 21 OakExitChampionsRoomMovement { "move_npc", 2, "up", 2 }, -- 20 OakExitChampionsRoomMovement (3,2)->(3,0)
{ "hide_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 22 { "hide_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 21
-- hand the induction off to the HALL_OF_FAME room (consumed by its -- 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) -- onEnter), then warp up into it (destWarp 1 lands at (4,7) facing up)
{ "set_field", "pendingHallOfFame", true }, -- 23 { "set_field", "pendingHallOfFame", true }, -- 22
{ "warp", "HALL_OF_FAME", 4, 7, "up" }, -- 24 { "warp", "HALL_OF_FAME", 4, 7, "up" }, -- 23
} },
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,
}, },
} }
@@ -858,14 +753,10 @@ M.HALL_OF_FAME = {
-- that teleports back to the new-game bedroom spawn (special_warps.asm -- that teleports back to the new-game bedroom spawn (special_warps.asm
-- NewGameWarp: REDS_HOUSE_2F, 3, 6, facing down). Fabricated -- NewGameWarp: REDS_HOUSE_2F, 3, 6, facing down). Fabricated
-- convenience -- there is no such prompt in the original ROM. -- convenience -- there is no such prompt in the original ROM.
-- Heal + remember_outdoor so house LAST_MAP mats land in Pallet, not
-- Indigo Plateau (issue #103 escape path for already-stuck saves).
TEXT_HALLOFFAME_PC = { TEXT_HALLOFFAME_PC = {
{ "ask", "Return to\nPALLET TOWN?" }, -- 1 YES/NO -> lastCheck { "ask", "Return to\nPALLET TOWN?" }, -- 1 YES/NO -> lastCheck
{ "jump_if_false", "end" }, -- 2 NO: back away { "jump_if_false", "end" }, -- 2 NO: back away
{ "heal_party" }, -- 3 { "warp", "REDS_HOUSE_2F", 3, 6, "down" }, -- 3 YES: home to your room
{ "remember_outdoor", "PALLET_TOWN", 5, 6 }, -- 4 LAST_MAP -> Pallet door
{ "warp", "REDS_HOUSE_2F", 3, 6, "down" }, -- 5 YES: home to your room
}, },
}, },
} }
@@ -878,72 +769,35 @@ M.HALL_OF_FAME = {
M.CERULEAN_CITY = { M.CERULEAN_CITY = {
talk = { talk = {
-- CeruleanCityRivalText: Bill line once beaten; pre-battle otherwise.
-- Post-fight walk matches CeruleanCityMovement4 (right then into town).
TEXT_CERULEANCITY_RIVAL = { TEXT_CERULEANCITY_RIVAL = {
{ "face_player" }, -- 1 { "face_player" }, -- 1
{ "check_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 2 { "check_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 2
{ "jump_if_true", 13 }, -- 3 { "jump_if_true", 9 }, -- 3
{ "show_text", "_CeruleanCityRivalPreBattleText" }, -- 4 { "show_text", "_CeruleanCityRivalPreBattleText" }, -- 4
{ "rival_battle", "OPP_RIVAL1", 7 }, -- 5 { "rival_battle", "OPP_RIVAL1", 7 }, -- 5
{ "jump_if_false", "end" }, -- 6 { "jump_if_false", 10 }, -- 6
{ "set_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 7 { "set_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 7
{ "show_text", "_CeruleanCityRivalDefeatedText" }, -- 8 { "show_text", "_CeruleanCityRivalDefeatedText" }, -- 8
{ "show_text", "_CeruleanCityRivalIWentToBillsText" }, -- 9 { "jump", 10 }, -- 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
}, },
}, },
} }
-- PokemonTower2F.asm: after the battle, PokemonTower2FDefeatedRivalScript M.POKEMON_TOWER_2F = {
-- walks him out (RightThenDown vs DownThenRight from EVENT_POKEMON_TOWER_ talk = {
-- RIVAL_ON_LEFT) then HideObject TOGGLE_POKEMON_TOWER_2F_RIVAL. Player TEXT_POKEMONTOWER2F_RIVAL = {
-- 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 { "face_player" }, -- 1
{ "check_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 2 { "check_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 2
{ "jump_if_true", 12 }, -- 3 { "jump_if_true", 10 }, -- 3
{ "show_text", "_PokemonTower2FRivalWhatBringsYouHereText" }, -- 4 { "show_text", "_PokemonTower2FRivalWhatBringsYouHereText" }, -- 4
{ "rival_battle", "OPP_RIVAL2", 4 }, -- 5 { "rival_battle", "OPP_RIVAL2", 4 }, -- 5
{ "jump_if_false", "end" }, -- 6 loss: stay { "jump_if_false", 11 }, -- 6
{ "set_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 7 { "set_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 7
{ "show_text", "_PokemonTower2FRivalDefeatedText" }, -- 8 { "show_text", "_PokemonTower2FRivalDefeatedText" }, -- 8
{ "walk_npc", 1, exitDirs }, -- 9 { "jump", 11 }, -- 9
{ "hide_object", "POKEMON_TOWER_2F", "POKEMONTOWER2F_RIVAL" }, -- 10 { "show_text", "_PokemonTower2FRivalHowsYourDexText" }, -- 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,
}, },
-- Saves that beat him before the exit walk was ported still have the
-- flag but a visible rival; hide on enter like BillsHouse repairs.
onEnter = function(game, ow)
if not game.save.flags.EVENT_BEAT_POKEMON_TOWER_RIVAL then return end
local Commands = require("src.script.Commands")
Commands.hide_object({ game = game, save = game.save, overworld = ow },
"POKEMON_TOWER_2F", "POKEMONTOWER2F_RIVAL")
end,
-- PokemonTower2FDefaultScript: walking past the rival's tile forces -- PokemonTower2FDefaultScript: walking past the rival's tile forces
-- the encounter (ArePlayerCoordsInArray on (15,5)/(14,6)) -- he never -- the encounter (ArePlayerCoordsInArray on (15,5)/(14,6)) -- he never
-- waits to be talked to -- waits to be talked to
@@ -955,7 +809,8 @@ M.POKEMON_TOWER_2F = {
if not rival then return false end if not rival then return false end
ow.player.facing = (x == 15) and "left" or "up" ow.player.facing = (x == 15) and "left" or "up"
require("src.core.Music").play(game.data, "Music_MeetRival") require("src.core.Music").play(game.data, "Music_MeetRival")
ow.runner:run(pokemonTower2FRivalScript(x), { npc = rival }) ow.runner:run(M.POKEMON_TOWER_2F.talk.TEXT_POKEMONTOWER2F_RIVAL,
{ npc = rival })
return true return true
end, end,
} }
+43 -180
View File
@@ -445,67 +445,33 @@ local function engageSuperNerd(game, ow, onDone)
ow:engageTrainer(nerd, onDone) ow:engageTrainer(nerd, onDone)
end end
-- MtMoonB2FMoveSuperNerdScript: player-near-dome coords walk RIGHT then local function mtMoonFossil(itemId, otherName)
-- 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)
return function(game, ow, npc, done) return function(game, ow, npc, done)
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
local t = game.data.text local ChoiceBox = require("src.ui.ChoiceBox")
local flags = game.save.flags if game.save.flags.EVENT_GOT_A_FOSSIL then
if flags.EVENT_GOT_DOME_FOSSIL or flags.EVENT_GOT_HELIX_FOSSIL then game.stack:push(TextBox.new(game, "You already took\na fossil.", done))
done()
return return
end end
-- can't grab a fossil until the Super Nerd is beaten -- he intercepts
if not superNerdBeaten(ow) then if not superNerdBeaten(ow) then
engageSuperNerd(game, ow, done) engageSuperNerd(game, ow, done)
return return
end end
local want = (itemId == "DOME_FOSSIL") local name = game.data.items[itemId].name
and (t._MtMoonB2FDomeFossilYouWantText or "You want the\nDOME FOSSIL?") game.stack:push(TextBox.new(game, ("You want the\n%s?"):format(name), function()
or (t._MtMoonB2FHelixFossilYouWantText or "You want the\nHELIX FOSSIL?") game.stack:push(ChoiceBox.new(game, function(yes)
game.stack:push(TextBox.new(game, want, nil, { choice = function(yes)
if not yes then done() return end if not yes then done() return end
if not require("src.inventory.Bag").add(game.save, itemId, 1) then game.save.inventory[itemId] = 1
game.stack:push(TextBox.new(game, game.save.flags.EVENT_GOT_A_FOSSIL = true
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") local Commands = require("src.script.Commands")
Commands.hide_object( local ctx = { save = game.save, overworld = ow, game = game }
{ save = game.save, overworld = ow, game = game }, Commands.hide_object(ctx, "MT_MOON_B2F", npc.def.name)
"MT_MOON_B2F", npc.def.name) Commands.hide_object(ctx, "MT_MOON_B2F", otherName)
flags[gotFlag] = true game.stack:push(TextBox.new(game,
ow.runner:run({ ("%s got the\n%s!"):format(game.save.player.name, name), done))
{ "walk_npc", 1, dirs }, end))
{ "text_opts", { auto = true } },
{ "show_text", "_MtMoonB2FSuperNerdThenThisIsMineText" },
{ "play_sound", "Get_Key_Item" },
{ "hide_object", "MT_MOON_B2F", otherName },
}, { onDone = done })
end)) end))
end }))
end end
end end
@@ -520,10 +486,8 @@ M.MT_MOON_B2F = {
return false return false
end, end,
talk = { talk = {
TEXT_MTMOONB2F_DOME_FOSSIL = mtMoonFossil( TEXT_MTMOONB2F_DOME_FOSSIL = mtMoonFossil("DOME_FOSSIL", "MTMOONB2F_HELIX_FOSSIL"),
"DOME_FOSSIL", "MTMOONB2F_HELIX_FOSSIL", "EVENT_GOT_DOME_FOSSIL"), TEXT_MTMOONB2F_HELIX_FOSSIL = mtMoonFossil("HELIX_FOSSIL", "MTMOONB2F_DOME_FOSSIL"),
TEXT_MTMOONB2F_HELIX_FOSSIL = mtMoonFossil(
"HELIX_FOSSIL", "MTMOONB2F_DOME_FOSSIL", "EVENT_GOT_HELIX_FOSSIL"),
}, },
} }
@@ -755,148 +719,58 @@ M.CINNABAR_LAB_FOSSIL_ROOM = {
-- ------------------------------------------------------------------- -- -------------------------------------------------------------------
-- Day-care (scripts/Daycare.asm): the boarded Pokémon earns 1 exp per -- Day-care (scripts/Daycare.asm): the boarded Pokémon earns 1 exp per
-- step; the fee is ¥100 plus ¥100 per level gained. -- 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 = { M.DAYCARE = {
talk = { talk = {
TEXT_DAYCARE_GENTLEMAN = function(game, ow, npc, done) TEXT_DAYCARE_GENTLEMAN = function(game, ow, npc, done)
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
local ChoiceBox = require("src.ui.ChoiceBox")
local t = game.data.text local t = game.data.text
local dc = game.save.daycare 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 if dc and dc.mon then
local Growth = require("src.pokemon.Growth") local Growth = require("src.pokemon.Growth")
local Stats = require("src.pokemon.Stats") local Stats = require("src.pokemon.Stats")
local Party = require("src.pokemon.Party")
local mon = dc.mon local mon = dc.mon
local def = game.data.pokemon[mon.species] local def = game.data.pokemon[mon.species]
-- Apply deferred step-exp once (OverworldController only bumps mon.exp = mon.exp + (dc.steps or 0)
-- daycare.steps). Clearing prevents double-count on re-talk. local newLevel = math.min(100, Growth.levelForExp(def.growthRate, mon.exp))
mon.exp = (mon.exp or 0) + (dc.steps or 0) local fee = 100 + (newLevel - mon.level) * 100
dc.steps = 0 local grew = newLevel > mon.level
-- depositLevel mirrors wDayCareMonBoxLevel: fee baseline that
-- must survive a declined retrieve. Fall back to mon.level for
-- older saves that predate the field.
if dc.depositLevel == nil then dc.depositLevel = mon.level end
local startLevel = dc.depositLevel
local newLevel = Growth.levelForExp(def and def.growthRate, mon.exp)
if newLevel >= 100 then
newLevel = 100
if def then
mon.exp = Growth.expForLevel(def.growthRate, 100)
end
end
local levelsGrown = math.max(0, newLevel - startLevel)
local fee = 100 + levelsGrown * 100
local name = monName(mon)
local subs = {
player = playerName,
wNameBuffer = name,
wDayCareMonName = name,
wDayCareNumLevelsGrown = levelsGrown,
wDayCareTotalCost = fee,
}
local statusText = levelsGrown > 0
and (t._DaycareGentlemanMonHasGrownText
or "Your {RAM:wNameBuffer}\nhas grown a lot!\fBy level, it's\ngrown by {NUM:wDayCareNumLevelsGrown, 1, 3}!\fAren't I great?")
or (t._DaycareGentlemanMonNeedsMoreTimeText
or "Back already?\nYour {RAM:wNameBuffer}\nneeds some more\ntime with me.")
game.stack:push(TextBox.new(game, fillDaycareText(statusText, subs), function()
if #game.save.party >= Party.MAX then
game.stack:push(TextBox.new(game,
t._DaycareGentlemanNoRoomForMonText
or "You have no room\nfor this POKéMON!", done))
return
end
game.stack:push(TextBox.new(game,
fillDaycareText(
t._DaycareGentlemanOweMoneyText
or "You owe me ¥{NUM:wDayCareTotalCost, 2 | LEADING_ZEROES | LEFT_ALIGN}\nfor the return\nof this POKéMON.",
subs),
nil, { choice = function(yes)
if not yes then
-- .leaveMonInDayCare: revert any transient level bump
mon.level = startLevel
game.stack:push(TextBox.new(game,
(t._DaycareGentlemanAllRightThenText or "All right then,\n")
.. (t._DaycareGentlemanComeAgainText or "come again."),
done))
return
end
if (game.save.money or 0) < fee then
mon.level = startLevel
game.stack:push(TextBox.new(game,
t._DaycareGentlemanNotEnoughMoneyText
or "Hey, you don't\nhave enough ¥!", done))
return
end
game.save.money = game.save.money - fee
mon.level = newLevel mon.level = newLevel
if def then
mon.stats = Stats.calc(def, mon.level, mon.dvs, mon.statExp) mon.stats = Stats.calc(def, mon.level, mon.dvs, mon.statExp)
mon.hp = mon.stats.hp mon.hp = mon.stats.hp
-- Daycare.asm: WriteMonMoves with wLearningMovesFromDayCare local msg = grew and (t._DaycareGentlemanMonHasGrownText or "It's grown a lot!")
local Pokemon = require("src.pokemon.Pokemon") or "Back already?"
Pokemon.learnMovesFromDayCare( game.stack:push(TextBox.new(game,
game.data, mon, def, startLevel, newLevel) msg .. ("\fThe fee is ¥%d.\nGet it back?"):format(fee), function()
end game.stack:push(ChoiceBox.new(game, function(yes)
if yes and game.save.money >= fee then
game.save.money = game.save.money - fee
table.insert(game.save.party, mon) table.insert(game.save.party, mon)
game.save.daycare = nil game.save.daycare = nil
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
t._DaycareGentlemanHeresYourMonText t._DaycareGentlemanGotMonBackText or "Here you go!", done))
or "Thank you! Here's\nyour POKéMON!", function() else
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
fillDaycareText( yes and (t._DaycareGentlemanOweMoneyText or "You owe me money!")
t._DaycareGentlemanGotMonBackText or "Come again!", done))
or "{PLAYER} got\n{RAM:wDayCareMonName} back!", end
subs), done))
end)) end))
end }))
end)) end))
return return
end 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 if #game.save.party < 2 then
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
t._DaycareGentlemanOnlyHaveOneMonText "You only have one\nPOKéMON with you!", done))
or "You only have one\nPOKéMON with you.", done))
return return
end end
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
t._DaycareGentlemanWhichMonText or "Which POKéMON\nshould I raise?", t._DaycareGentlemanIntroText or "I can raise a\nPOKéMON for you.", function()
function() game.stack:push(ChoiceBox.new(game, function(yes)
if not yes then done() return end
local PartyMenu = require("src.ui.PartyMenu") local PartyMenu = require("src.ui.PartyMenu")
game.stack:push(PartyMenu.new(game, { game.stack:push(PartyMenu.new(game, {
pickOnly = true, pickOnly = true,
@@ -904,25 +778,14 @@ M.DAYCARE = {
for i, m in ipairs(game.save.party) do for i, m in ipairs(game.save.party) do
if m == mon then table.remove(game.save.party, i) break end if m == mon then table.remove(game.save.party, i) break end
end end
local name = monName(mon) game.save.daycare = { mon = mon, steps = 0 }
-- depositLevel = wDayCareMonBoxLevel at deposit time
game.save.daycare = {
mon = mon, steps = 0, depositLevel = mon.level,
}
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
fillDaycareText( t._DaycareGentlemanWillLookAfterMonText or
t._DaycareGentlemanWillLookAfterMonText "Fine, I'll look\nafter it a while!", done))
or "Fine, I'll look\nafter {RAM:wNameBuffer}\nfor a while.",
{ player = playerName, wNameBuffer = name }),
function()
game.stack:push(TextBox.new(game,
t._DaycareGentlemanComeSeeMeInAWhileText
or "Come see me in\na while.", done))
end))
end, end,
})) }))
end)) end))
end })) end))
end, end,
}, },
} }
+31 -223
View File
@@ -61,50 +61,6 @@ M.ROUTE_12_SUPER_ROD_HOUSE = {
M.ROUTE_12_SUPER_ROD_HOUSE.talk.TEXT_ROUTE12SUPERRODHOUSE_FISHING_GURU[9] = M.ROUTE_12_SUPER_ROD_HOUSE.talk.TEXT_ROUTE12SUPERRODHOUSE_FISHING_GURU[9] =
{ "show_text", "_Route12SuperRodHouseFishingGuruTryFishingText" } { "show_text", "_Route12SuperRodHouseFishingGuruTryFishingText" }
-- -------------------------------------------------------------------
-- Pokemon Tower 5F purified zone (scripts/PokemonTower5F.asm
-- PokemonTower5FDefaultScript): the 2x2 center pad heals the party once
-- per visit. EVENT_IN_PURIFIED_ZONE latches until the player steps off;
-- while on the pad the map script also sets BIT_NO_BATTLES (we return
-- true from onStep so wild encounters are skipped the same way).
-- -------------------------------------------------------------------
local TOWER_5F_PURIFIED = {
[10 * 256 + 8] = true, [11 * 256 + 8] = true,
[10 * 256 + 9] = true, [11 * 256 + 9] = true,
}
-- HealParty -> GBFadeOutToWhite -> Delay3 -> Delay3 -> GBFadeInFromWhite
-- -> TEXT_POKEMONTOWER5F_PURIFIEDZONE (no Music_PkmnHealed).
local TOWER_5F_HEAL = {
{ "heal_party" },
{ "fade", "out", "white" },
{ "wait", 3 },
{ "wait", 3 },
{ "fade", "in", "white" },
{ "show_text", "_PokemonTower5FPurifiedZoneText" },
}
M.POKEMON_TOWER_5F = {
onStep = function(game, ow, x, y)
if not TOWER_5F_PURIFIED[x * 256 + y] then
game.save.flags.EVENT_IN_PURIFIED_ZONE = nil
return false
end
if game.save.flags.EVENT_IN_PURIFIED_ZONE then
return true
end
if ow.runner and ow.runner:isRunning() then return false end
game.save.flags.EVENT_IN_PURIFIED_ZONE = true
if ow.runner then
ow.runner:run(TOWER_5F_HEAL)
elseif ow.queueScript then
ow:queueScript(TOWER_5F_HEAL)
end
return true
end,
}
-- ------------------------------------------------------------------- -- -------------------------------------------------------------------
-- The ghost Marowak (scripts/PokemonTower6F.asm): blocks the stairs at -- The ghost Marowak (scripts/PokemonTower6F.asm): blocks the stairs at
-- (10,16) until defeated. -- (10,16) until defeated.
@@ -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_OPP = { up = "down", down = "up", left = "right", right = "left" }
local WALK_ORDER = { "up", "down", "left", "right" } local WALK_ORDER = { "up", "down", "left", "right" }
-- .UpdateWarp: point EVERY car exit warp at the same floor's elevator local function elevatorWalkOut(ow, floor)
-- door (warp id, map id). Shared generated map data, but the car's local m, p = ow.map, ow.player
-- warps are only read from inside the car; rides rewrite them again. -- .UpdateWarp is run twice, so BOTH car warp entries get the same
local function elevatorSetExit(ow, floor) -- (warp id, map id): point every exit warp at the picked floor's
if not floor then return end -- elevator-door warp (the reciprocal warp found while building the
for _, w in ipairs(ow.map.def.warps) do -- 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.destMap = floor.map
w.destWarp = floor.warpIdx w.destWarp = floor.warpIdx
end 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), -- leave by the exit tile under the player (they warped in onto one),
-- else the nearest -- else the nearest
local door local door
@@ -245,7 +199,15 @@ local function elevatorWalkOut(ow, floor)
end) end)
end end
local function elevatorFloors(elevatorMapId, game) local function elevator(elevatorMapId, keyGate, preFrames)
return {
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 = {} local floors = {}
for mapId, def in pairs(game.data.maps) do for mapId, def in pairs(game.data.maps) do
for i, w in ipairs(def.warps) do for i, w in ipairs(def.warps) do
@@ -268,45 +230,6 @@ local function elevatorFloors(elevatorMapId, game)
return (tonumber(a.token:match("%d+")) or 0) < return (tonumber(a.token:match("%d+")) or 0) <
(tonumber(b.token:match("%d+")) or 0) (tonumber(b.token:match("%d+")) or 0)
end) 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.
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 items = {} local items = {}
for _, f in ipairs(floors) do for _, f in ipairs(floors) do
table.insert(items, { label = f.token, value = f }) table.insert(items, { label = f.token, value = f })
@@ -339,8 +262,7 @@ local function elevator(elevatorMapId, keyGate, preFrames)
end, end,
onCancel = function() onCancel = function()
-- DisplayElevatorFloorMenu: `ret c` on B -- no warp, nothing -- DisplayElevatorFloorMenu: `ret c` on B -- no warp, nothing
-- happens, the player just stays in the car (exit warps were -- happens, the player just stays in the car
-- already seeded to the entry floor above)
end, end,
})) }))
end, end,
@@ -352,98 +274,6 @@ M.CELADON_MART_ELEVATOR = elevator("CELADON_MART_ELEVATOR", nil, 9)
M.ROCKET_HIDEOUT_ELEVATOR = elevator("ROCKET_HIDEOUT_ELEVATOR", M.ROCKET_HIDEOUT_ELEVATOR = elevator("ROCKET_HIDEOUT_ELEVATOR",
{ item = "LIFT_KEY", text = "_RocketHideoutElevatorAppearsToNeedKeyText" }) { item = "LIFT_KEY", text = "_RocketHideoutElevatorAppearsToNeedKeyText" })
-- -------------------------------------------------------------------
-- Rocket Hideout B4F (scripts/RocketHideoutB4F.asm):
-- Rocket3's after-battle text_asm drops the LIFT KEY item ball
-- (CheckAndSetEvent EVENT_ROCKET_DROPPED_LIFT_KEY / ShowObject
-- TOGGLE_ROCKET_HIDEOUT_B4F_ITEM_5). Both start hidden in the map
-- objects; without this talk side-effect the key never appears (#90,
-- #105).
-- Giovanni's post-battle script likewise ShowObject's the Silph Scope
-- after the hope-we-meet-again line (TOGGLE_ROCKET_HIDEOUT_B4F_ITEM_4).
-- -------------------------------------------------------------------
M.ROCKET_HIDEOUT_B4F = {
talk = {
TEXT_ROCKETHIDEOUTB4F_ROCKET3 = function(game, ow, npc, done)
if not ow:trainerDefeated(npc) then
ow:engageTrainer(npc, done)
return
end
local TextBox = require("src.render.TextBox")
local t = game.data.text
game.stack:push(TextBox.new(game,
t._RocketHideoutB4FRocket3AfterBattleText
or "Oh no! I dropped\nthe LIFT KEY!",
function()
-- CheckAndSetEvent EVENT_ROCKET_DROPPED_LIFT_KEY: first talk
-- after the win reveals the ball; later talks only reprint.
if not game.save.flags.EVENT_ROCKET_DROPPED_LIFT_KEY then
game.save.flags.EVENT_ROCKET_DROPPED_LIFT_KEY = true
local Commands = require("src.script.Commands")
Commands.show_object(
{ game = game, save = game.save, overworld = ow },
"ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_LIFT_KEY")
end
done()
end))
end,
TEXT_ROCKETHIDEOUTB4F_GIOVANNI = function(game, ow, npc, done)
-- Giovanni has no trainer-header row (def_trainers 2); his text_asm
-- owns both the engage and the BeatGiovanniScript aftermath.
if ow:trainerDefeated(npc)
or game.save.flags.EVENT_BEAT_ROCKET_HIDEOUT_GIOVANNI then
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game,
game.data.text._RocketHideoutB4FGiovanniHopeWeMeetAgainText
or "I hope we meet\nagain...", done))
return
end
local TextBox = require("src.render.TextBox")
local BattleState = require("src.battle.BattleState")
local t = game.data.text
local impressed = t._RocketHideoutB4FGiovanniImpressedYouGotHereText
or "So! I must say, I\nam impressed you\ngot here!"
local cannotBe = t._RocketHideoutB4FGiovanniWhatCannotBeText
or "WHAT!\nThis cannot be!"
local hope = t._RocketHideoutB4FGiovanniHopeWeMeetAgainText
or "I hope we meet\nagain..."
game.stack:push(TextBox.new(game, impressed, function()
local battle = BattleState.newTrainer(game, "OPP_GIOVANNI", 1)
battle.onFinish = function(result)
if result ~= "win" then
ow:afterBattle(result, battle)
done()
return
end
game.save.defeatedTrainers[npc.id] = true
game.save.flags.EVENT_BEAT_ROCKET_HIDEOUT_GIOVANNI = true
-- End-battle "WHAT!" then BeatGiovanniScript's hope text,
-- fade, HideObject Giovanni, ShowObject Silph Scope.
game.stack:push(TextBox.new(game, cannotBe, function()
game.stack:push(TextBox.new(game, hope, function()
local Transition = require("src.render.Transition")
game.stack:push(Transition.new(game, function()
local Commands = require("src.script.Commands")
local ctx = { game = game, save = game.save, overworld = ow }
Commands.hide_object(ctx, "ROCKET_HIDEOUT_B4F",
"ROCKETHIDEOUTB4F_GIOVANNI")
Commands.show_object(ctx, "ROCKET_HIDEOUT_B4F",
"ROCKETHIDEOUTB4F_SILPH_SCOPE")
end, function()
ow:afterBattle(result, battle)
done()
end))
end))
end))
end
ow:pushBattle(battle)
end))
end,
},
}
-- ------------------------------------------------------------------- -- -------------------------------------------------------------------
-- Game Corner coins, prizes, and the rocket-poster switch that reveals -- Game Corner coins, prizes, and the rocket-poster switch that reveals
-- the hideout stairs (scripts/GameCorner.asm, data/events/prizes.asm + -- the hideout stairs (scripts/GameCorner.asm, data/events/prizes.asm +
@@ -456,23 +286,14 @@ M.GAME_CORNER = {
-- EVENT_FOUND_ROCKET_HIDEOUT is unset, $43 after) -- EVENT_FOUND_ROCKET_HIDEOUT is unset, $43 after)
onEnter = function(game, ow) onEnter = function(game, ow)
local poster = game.data.field.gameCornerPoster local poster = game.data.field.gameCornerPoster
if poster then if not poster then return end
local block = game.save.flags[poster.event] and poster.openBlock local block = game.save.flags[poster.event] and poster.openBlock
or poster.closedBlock or poster.closedBlock
ow:replaceBlock(poster.x, poster.y, block) ow:replaceBlock(poster.x, poster.y, block)
end
-- pick this visit's lucky slot machine -- pick this visit's lucky slot machine
-- (wLuckySlotHiddenEventIndex, engine/slots/game_corner_slots2.asm) -- (wLuckySlotHiddenEventIndex, engine/slots/game_corner_slots2.asm)
local seats = game.data.field.slotMachines.GAME_CORNER local seats = game.data.field.slotMachines.GAME_CORNER
ow.luckySlot = love.math.random(1, #seats) ow.luckySlot = love.math.random(1, #seats)
-- #131: pre-#50 saves beat the poster grunt (defeatedTrainers) but
-- never hid him; clear the tile if he is already marked defeated
local rocketId = "GAME_CORNER_obj_11"
if game.save.defeatedTrainers and game.save.defeatedTrainers[rocketId] then
local Commands = require("src.script.Commands")
Commands.hide_object({ game = game, save = game.save, overworld = ow },
"GAME_CORNER", "GAMECORNER_ROCKET")
end
end, end,
talk = { talk = {
-- the poster bg event: pressing A reveals the hidden switch -- the poster bg event: pressing A reveals the hidden switch
@@ -501,16 +322,7 @@ M.GAME_CORNER = {
-- losing he warns the BOSS and leaves the floor for good, freeing -- losing he warns the BOSS and leaves the floor for good, freeing
-- the tile in front of the hideout switch -- the tile in front of the hideout switch
TEXT_GAMECORNER_ROCKET = function(game, ow, npc, done) TEXT_GAMECORNER_ROCKET = function(game, ow, npc, done)
local Commands = require("src.script.Commands")
local function hideRocket()
Commands.hide_object({ game = game, save = game.save,
overworld = ow },
"GAME_CORNER", "GAMECORNER_ROCKET")
end
-- already beaten: hide anyway so pre-#50 saves that only have
-- defeatedTrainers (no objectToggles hide) clear the poster tile
if ow:trainerDefeated(npc) then if ow:trainerDefeated(npc) then
hideRocket()
done() done()
return return
end end
@@ -524,7 +336,10 @@ M.GAME_CORNER = {
game.data.text._GameCornerRocketAfterBattleText game.data.text._GameCornerRocketAfterBattleText
or "Our hideout might\nbe discovered! I\nbetter tell BOSS!", or "Our hideout might\nbe discovered! I\nbetter tell BOSS!",
function() function()
hideRocket() local Commands = require("src.script.Commands")
Commands.hide_object({ game = game, save = game.save,
overworld = ow },
"GAME_CORNER", "GAMECORNER_ROCKET")
done() done()
end)) end))
end) end)
@@ -666,9 +481,8 @@ local DOCK_SHIP_BLOCKS = {
M.VERMILION_DOCK = { M.VERMILION_DOCK = {
onEnter = function(game, ow) onEnter = function(game, ow)
local Flags = require("src.script.Flags")
local f = game.save.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 -- the ship is long gone: erase her right away, and anyone who
-- still lands here is sent back out past the guard -- still lands here is sent back out past the guard
for _, b in ipairs(DOCK_SHIP_BLOCKS) do 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 elseif f.EVENT_GOT_HM01 and ow.player.cellY == 2 then
-- VermilionDockSSAnneLeavesScript: only stepping OFF the ship -- VermilionDockSSAnneLeavesScript: only stepping OFF the ship
-- triggers the departure (wDestinationWarpID == 1 in pokered) -- -- triggers the departure (wDestinationWarpID == 1 in pokered) --
-- Music_Surfing plays for the sail-away cutscene, smoke puffs -- the horn blows, smoke puffs drift off the funnel, the ship is
-- drift off the funnel, the horn blows, the ship is erased to -- erased to open water, and the player is walked off the dock
-- open water, and the player is walked off the dock into the -- into the city past the guard (VermilionCity's
-- city past the guard (VermilionCity's
-- SCRIPT_VERMILIONCITY_PLAYER_EXIT_SHIP walk) -- SCRIPT_VERMILIONCITY_PLAYER_EXIT_SHIP walk)
Flags.set(game.save, "EVENT_SS_ANNE_LEFT") f.EVENT_SS_ANNE_LEFT = true
local Music = require("src.core.Music") require("src.core.Music").stop()
Music.stop() require("src.core.Sound").play(game.data, "SS_Anne_Horn")
Music.play(game.data, "Music_Surfing")
local function puff(n, cx) local function puff(n, cx)
if n <= 0 then return end if n <= 0 then return end
ow:startDustAnim(cx, 1, function() puff(n - 1, cx + 2) end) ow:startDustAnim(cx, 1, function() puff(n - 1, cx + 2) end)
@@ -700,15 +512,11 @@ M.VERMILION_DOCK = {
puff(3, 15) puff(3, 15)
local rows = {} local rows = {}
rows[#rows + 1] = { "wait", 100 } rows[#rows + 1] = { "wait", 100 }
rows[#rows + 1] = { "play_sound", "SS_Anne_Horn" }
for _, b in ipairs(DOCK_SHIP_BLOCKS) do for _, b in ipairs(DOCK_SHIP_BLOCKS) do
rows[#rows + 1] = { "replace_block", b.bx, b.by, b.water } rows[#rows + 1] = { "replace_block", b.bx, b.by, b.water }
end end
rows[#rows + 1] = { "wait", 30 } rows[#rows + 1] = { "wait", 30 }
rows[#rows + 1] = { "move_player", "up", 2 } 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] = { "warp", "VERMILION_CITY", 18, 31, "up" }
rows[#rows + 1] = { "move_player", "up", 2 } rows[#rows + 1] = { "move_player", "up", 2 }
ow:queueScript(rows) ow:queueScript(rows)
+34 -85
View File
@@ -212,31 +212,30 @@ M.COPYCATS_HOUSE_2F = {
talk = { talk = {
TEXT_COPYCATSHOUSE2F_COPYCAT = function(game, ow, npc, done) TEXT_COPYCATSHOUSE2F_COPYCAT = function(game, ow, npc, done)
local t = text(game) local t = text(game)
local Bag = require("src.inventory.Bag")
if game.save.flags.EVENT_GOT_TM31 then if game.save.flags.EVENT_GOT_TM31 then
push(game, t._CopycatsHouse2FCopycatTM31Explanation2Text, done) push(game, t._CopycatsHouse2FCopycatWhirlingText
or "Huh? Huh? Huh?", done)
return return
end end
-- Mimic line first; auto-trade POKE DOLL for TM31 (no YES/NO). if (game.save.inventory.POKE_DOLL or 0) > 0 then
push(game, t._CopycatsHouse2FCopycatDoYouLikePokemonText, function() ask(game, t._CopycatsHouse2FCopycatPokeDollText
if (game.save.inventory.POKE_DOLL or 0) <= 0 then or "Oh wow!\nA POKé DOLL!\fFor me?\nCan I have it?", function(yes)
done() 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 return
end end
push(game, t._CopycatsHouse2FCopycatTM31PreReceiveText, function()
if not Bag.add(game.save, "TM_MIMIC", 1) then
push(game, t._CopycatsHouse2FCopycatTM31NoRoomText, 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 game.save.flags.EVENT_GOT_TM31 = true
push(game, t._CopycatsHouse2FCopycatReceivedTM31Text, function() push(game, ("%s got\nTM31!"):format(game.save.player.name), done)
push(game, t._CopycatsHouse2FCopycatTM31Explanation1Text, done)
end)
end)
end) end)
return
end
push(game, t._CopycatsHouse2FCopycatText
or "I like to mimic\npeople!", done)
end, end,
}, },
} }
@@ -646,44 +645,12 @@ local function e4ExitSeal(flag, closedBlock, openBlock, dontRunText, autoFlag)
} }
end end
-- Lorelei/Bruno/Agatha EndBattleScript (pokered): EndTrainerBattle then
-- DisplayTextID -> TalkToTrainer, which prints the AfterBattle text on a
-- win. engageTrainer only shows the won line, so wrap talk like Lance /
-- Game Corner Rocket and push header.after immediately after a win.
local function e4LeaderTalk(afterLabel)
return function(game, ow, npc, done)
done = done or function() end
if ow:trainerDefeated(npc) then
local after = text(game)[afterLabel]
if after then push(game, after, done) else done() end
return
end
ow:engageTrainer(npc, function()
if not ow:trainerDefeated(npc) then
done()
return
end
local after = text(game)[afterLabel]
if after then push(game, after, done) else done() end
end)
end
end
M.LORELEIS_ROOM = e4ExitSeal("EVENT_BEAT_LORELEIS_ROOM_TRAINER_0", 0x24, 0x05, M.LORELEIS_ROOM = e4ExitSeal("EVENT_BEAT_LORELEIS_ROOM_TRAINER_0", 0x24, 0x05,
"_LoreleisRoomLoreleiDontRunAwayText", "EVENT_AUTOWALKED_INTO_LORELEIS_ROOM") "_LoreleisRoomLoreleiDontRunAwayText", "EVENT_AUTOWALKED_INTO_LORELEIS_ROOM")
M.LORELEIS_ROOM.talk = {
TEXT_LORELEISROOM_LORELEI = e4LeaderTalk("_LoreleisRoomLoreleiAfterBattleText"),
}
M.BRUNOS_ROOM = e4ExitSeal("EVENT_BEAT_BRUNOS_ROOM_TRAINER_0", 0x24, 0x05, M.BRUNOS_ROOM = e4ExitSeal("EVENT_BEAT_BRUNOS_ROOM_TRAINER_0", 0x24, 0x05,
"_BrunosRoomBrunoDontRunAwayText", "EVENT_AUTOWALKED_INTO_BRUNOS_ROOM") "_BrunosRoomBrunoDontRunAwayText", "EVENT_AUTOWALKED_INTO_BRUNOS_ROOM")
M.BRUNOS_ROOM.talk = {
TEXT_BRUNOSROOM_BRUNO = e4LeaderTalk("_BrunoAfterBattleText"),
}
M.AGATHAS_ROOM = e4ExitSeal("EVENT_BEAT_AGATHAS_ROOM_TRAINER_0", 0x3b, 0x0e, M.AGATHAS_ROOM = e4ExitSeal("EVENT_BEAT_AGATHAS_ROOM_TRAINER_0", 0x3b, 0x0e,
"_AgathasRoomAgathaDontRunAwayText", "EVENT_AUTOWALKED_INTO_AGATHAS_ROOM") "_AgathasRoomAgathaDontRunAwayText", "EVENT_AUTOWALKED_INTO_AGATHAS_ROOM")
M.AGATHAS_ROOM.talk = {
TEXT_AGATHASROOM_AGATHA = e4LeaderTalk("_AgathaAfterBattleText"),
}
-- ------------------------------------------------------------------- -- -------------------------------------------------------------------
-- Lance's room (scripts/LancesRoom.asm). Unlike the other three E4 -- Lance's room (scripts/LancesRoom.asm). Unlike the other three E4
@@ -705,21 +672,13 @@ M.AGATHAS_ROOM.talk = {
-- EVENT_LANCES_ROOM_LOCK_DOOR: first crossing seals -- EVENT_LANCES_ROOM_LOCK_DOOR: first crossing seals
-- the door behind the player with SFX_GO_INSIDE -- the door behind the player with SFX_GO_INSIDE
-- (24,16) the entrance staircase -> WalkToLance: an auto-walk -- (24,16) the entrance staircase -> WalkToLance: an auto-walk
-- landing on (6,11). Vanilla WalkToLance_RLEList is -- (up 12, left 12, down 7, left 6) landing on (6,11).
-- up 12 / left 12 / down 7 / left 6 and skips collision -- It marches straight across the room's water decor:
-- through void tiles; our camera follows that literally -- pokered's CollisionCheckOnLand skips collision
-- and briefly shows the empty upper chamber (reads as -- entirely while simulated joypad states run, and our
-- "Gary's room"). We keep the same landing cell but -- scriptMove is collision-free the same way.
-- route along the open-door floor corridor instead.
-- ------------------------------------------------------------------- -- -------------------------------------------------------------------
-- Floor corridor (24,16) -> (6,11) with EVENT_LANCES_ROOM_LOCK_DOOR
-- unset (entrance blocks $31/$32). Exposed for parity tests.
local LANCE_WALK_IN = {
{ "down", 2 }, { "left", 6 }, { "down", 5 }, { "left", 10 },
{ "up", 9 }, { "left", 2 }, { "up", 3 },
}
local function lanceEntranceBlocks(game, ow) local function lanceEntranceBlocks(game, ow)
local locked = game.save.flags.EVENT_LANCES_ROOM_LOCK_DOOR local locked = game.save.flags.EVENT_LANCES_ROOM_LOCK_DOOR
ow:replaceBlock(2, 6, locked and 0x72 or 0x31) ow:replaceBlock(2, 6, locked and 0x72 or 0x31)
@@ -734,24 +693,21 @@ local function lanceLockDoor(game, ow)
end end
local function lanceWalkIn(game, ow) local function lanceWalkIn(game, ow)
local i = 0 ow:scriptMove(ow.player, "up", 12, function()
local function step() ow:scriptMove(ow.player, "left", 12, function()
i = i + 1 ow:scriptMove(ow.player, "down", 7, function()
local seg = LANCE_WALK_IN[i] ow:scriptMove(ow.player, "left", 6, function()
if not seg then -- the walk lands on (6,11); vanilla's per-frame coord poll
-- lands on (6,11); vanilla's per-frame coord poll then locks the -- then locks the door at once. scriptMove landings do not
-- door at once. scriptMove landings do not fire onStep, so lock -- fire onStep, so lock here.
-- here.
lanceLockDoor(game, ow) lanceLockDoor(game, ow)
return end)
end end)
ow:scriptMove(ow.player, seg[1], seg[2], step) end)
end end)
step()
end end
M.LANCES_ROOM = { M.LANCES_ROOM = {
walkInRoute = LANCE_WALK_IN,
onEnter = function(game, ow) onEnter = function(game, ow)
lanceEntranceBlocks(game, ow) lanceEntranceBlocks(game, ow)
-- the warp arrival lands ON the staircase trigger, and onStep only -- the warp arrival lands ON the staircase trigger, and onStep only
@@ -771,14 +727,7 @@ M.LANCES_ROOM = {
end end
if not lance or ow:trainerDefeated(lance) then return false end if not lance or ow:trainerDefeated(lance) then return false end
lance:facePlayer(ow.player) lance:facePlayer(ow.player)
-- LancesRoomLanceEndBattleScript DisplayTextID -> TalkToTrainer ow:engageTrainer(lance, function() end)
-- after-battle text (rival became champion first). engageTrainer
-- only shows the won line, so push the after text on a win.
ow:engageTrainer(lance, function()
if not ow:trainerDefeated(lance) then return end
local after = text(game)._LancesRoomLanceAfterBattleText
if after then push(game, after) end
end)
return true return true
end end
if (x == 5 or x == 6) and y == 11 then if (x == 5 or x == 6) and y == 11 then
+40 -205
View File
@@ -262,180 +262,38 @@ M.CINNABAR_ISLAND = {
-- Pewter's youngster stops you leaving east before Brock is beaten and -- Pewter's youngster stops you leaving east before Brock is beaten and
-- escorts you to the gym (scripts/PewterCity.asm -- escorts you to the gym (scripts/PewterCity.asm
-- PewterCityCheckPlayerLeavingEastScript / -- PewterCityCheckPlayerLeavingEastScript /
-- PewterCityYoungsterShowsPlayerGymScript, engine/overworld/auto_movement.asm -- PewterCityYoungsterShowsPlayerGymScript, engine/events/pewter_guys.asm
-- PewterGymGuyMovementScriptPointerTable, engine/events/pewter_guys.asm -- PewterGymGuyCoords): the "follow me" lines, then the player is walked
-- PewterGymGuyCoords). Same lockstep style as Oak's lab escort: the -- west along the road to the front of the PEWTER_GYM door at (16,17).
-- youngster walks RLEList_PewterGymGuy while the player plays the
-- reverse of RLEList_PewterGymPlayer with a PewterGuys positioning
-- preamble. Ends at (11,18) / (12,18) by the gym, not phasing through
-- the building.
local pewterEscort = {}
-- RLEList_PewterGymGuy (NPC directions play forward)
pewterEscort.guySteps = {
"down", "down",
"left", "left", "left", "left", "left", "left", "left", "left",
"left", "left", "left", "left", "left", "left", "left",
"up", "up", "up", "up", "up",
"left", "left", "left", "left", "left", "left", "left", "left",
"left", "left", "left",
"down", "down", "down", "down", "down",
"right", "right", "right",
}
-- Walk home: reverse of guySteps with opposite facings (gym → spawn).
do
local opp = { up = "down", down = "up", left = "right", right = "left" }
local ret = {}
for i = #pewterEscort.guySteps, 1, -1 do
ret[#ret + 1] = opp[pewterEscort.guySteps[i]]
end
pewterEscort.guyReturnSteps = ret
end
-- RLEList_PewterGymPlayer before reverse / PewterGuys (NO_INPUT, RIGHT×2,
-- DOWN×5, LEFT×11, UP×5, LEFT×15)
pewterEscort.playerRle = {
"NO",
"right", "right",
"down", "down", "down", "down", "down",
"left", "left", "left", "left", "left", "left", "left", "left",
"left", "left", "left",
"up", "up", "up", "up", "up",
"left", "left", "left", "left", "left", "left", "left", "left",
"left", "left", "left", "left", "left", "left", "left",
}
-- PewterGymGuyCoords: (x, y) -> positioning moves written after the RLE
-- (played in reverse; $00 pauses are one overworld frame ≈ 1/8 tile for
-- the NPC, so eight of them ≈ one guy head-start step)
pewterEscort.preambles = {
["34,16"] = { "left", "down", "down", "right" },
["35,17"] = { "left", "down", "right", "left" },
["37,18"] = { "left", "left", "left",
"NO", "NO", "NO", "NO", "NO", "NO", "NO", "NO" },
["37,19"] = { "left", "left", "up", "left" },
["36,17"] = { "left", "down", "left",
"NO", "NO", "NO", "NO", "NO", "NO", "NO", "NO" },
}
-- Realized player path for a trigger tile: PewterGuys overwrites the
-- last RLE byte and appends the preamble, then simulated joypad plays
-- high→low (reverse). Leading NO×8 collapses to guyHeadStart=1; the
-- trailing end-of-list NO is dropped (one-frame pause).
function pewterEscort.playerPlan(x, y)
local pre = pewterEscort.preambles[x .. "," .. y]
if not pre then return nil end
local buf = {}
for i, d in ipairs(pewterEscort.playerRle) do buf[i] = d end
buf[#buf] = pre[1]
for i = 2, #pre do buf[#buf + 1] = pre[i] end
local path = {}
for i = #buf, 1, -1 do path[#path + 1] = buf[i] end
local head = 0
while path[head + 1] == "NO" do head = head + 1 end
local tail = #path
while tail > head and path[tail] == "NO" do tail = tail - 1 end
local steps = {}
for i = head + 1, tail do steps[#steps + 1] = path[i] end
return { steps = steps, guyHeadStart = math.floor(head / 8) }
end
local function pewterGymEscort(game, ow)
if ow.runner:isRunning() or #ow.scriptMoves > 0 then return end
local x, y = ow.player.cellX, ow.player.cellY
local plan = pewterEscort.playerPlan(x, y)
local Music = require("src.core.Music")
local t = text(game)
local follow = t._PewterCityYoungsterYoureATrainerFollowMeText
or "You're a trainer\nright? BROCK's\nlooking for new\nchallengers!\nFollow me!"
-- PewterGuys only has entries for five tiles; an unmatched talk tile
-- (e.g. (36,16) east of him) just gets the follow-me line, same as a
-- failed coords lookup would refuse to arm the walk.
if not plan then
push(game, follow)
return
end
local guy = ow:npcByIndex(5) -- PEWTERCITY_YOUNGSTER
local guySteps = pewterEscort.guySteps
local head = plan.guyHeadStart
-- After the walk: face the player, restore map music, "Go take on
-- BROCK", then retrace RLEList_PewterGymGuy back to his spawn (35,16).
-- (pokered teleports him via MovementData_PewterGymGuyExit; we walk
-- the same route home instead. Brock victory still HideObject's him.)
local function walkHome()
if not guy then return end
local ret = pewterEscort.guyReturnSteps
local i = 0
local function tick()
i = i + 1
if not ret[i] then
guy.facing = "down"
return
end
ow:scriptMove(guy, ret[i], 1, tick)
end
tick()
end
local function afterWalk()
if guy then guy.facing = "left" end
Music.playMap(game.data, "PEWTER_CITY")
push(game, t._PewterCityYoungsterGoTakeOnBrockText
or "Go take on BROCK\nat the GYM first!", walkHome)
end
local function lockstep()
local i = 0
local function tick()
i = i + 1
local ps = plan.steps[i]
if not ps then
afterWalk()
return
end
local gs = guySteps[head + i]
if guy and gs then ow:scriptMove(guy, gs, 1) end
ow:scriptMove(ow.player, ps, 1, tick)
end
tick()
end
local function beginWalk()
Music.play(game.data, "Music_MuseumGuy")
if guy and head > 0 then
local h = 0
local function headTick()
h = h + 1
if h > head then lockstep(); return end
ow:scriptMove(guy, guySteps[h], 1, headTick)
end
headTick()
else
lockstep()
end
end
push(game, follow, beginWalk)
end
M.PEWTER_CITY = { M.PEWTER_CITY = {
escort = pewterEscort,
-- PewterCityYoungsterText: talking also arms the gym escort script
talk = {
TEXT_PEWTERCITY_YOUNGSTER = function(game, ow, npc, done)
pewterGymEscort(game, ow)
if done then done() end
end,
},
onStep = function(game, ow, x, y) onStep = function(game, ow, x, y)
if game.save.flags.EVENT_BEAT_BROCK then return false end if game.save.flags.EVENT_BEAT_BROCK then return false end
if ow.runner:isRunning() or #ow.scriptMoves > 0 then return false end if ow.runner:isRunning() or #ow.scriptMoves > 0 then return false end
if not inCoords({ { 35, 17 }, { 36, 17 }, { 37, 18 }, { 37, 19 } }, x, y) then if not inCoords({ { 35, 17 }, { 36, 17 }, { 37, 18 }, { 37, 19 } }, x, y) then
return false return false
end end
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 return true
end, end,
} }
@@ -457,19 +315,6 @@ end
-- EVENT_ROUTE22_RIVAL_WANTS_BATTLE in Oak's lab (expired by Pewter -- 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 -- Gym) and again in Viridian Gym; we derive the same windows from the
-- surrounding story flags so old saves work too. -- 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) local function route22Scene(n, objIndex, objName, oppClass, baseParty, beatFlag, py)
return { return {
{ "show_object", "ROUTE_22", objName }, -- 1 { "show_object", "ROUTE_22", objName }, -- 1
@@ -481,7 +326,7 @@ local function route22Scene(n, objIndex, objName, oppClass, baseParty, beatFlag,
{ "set_flag", beatFlag }, -- 7 { "set_flag", beatFlag }, -- 7
{ "show_text", "_Route22Rival" .. n .. "DefeatedText" }, -- 8 { "show_text", "_Route22Rival" .. n .. "DefeatedText" }, -- 8
{ "show_text", "_Route22RivalAfterBattleText" .. n }, -- 9 { "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 { "hide_object", "ROUTE_22", objName }, -- 11
} }
end end
@@ -508,14 +353,6 @@ M.ROUTE_22 = {
-- Cerulean City: the Nugget Bridge rival ambush (CeruleanCityCoords2), -- Cerulean City: the Nugget Bridge rival ambush (CeruleanCityCoords2),
-- the TM28 rocket thief with his forced-fight cells (Coords1), and the -- the TM28 rocket thief with his forced-fight cells (Coords1), and the
-- cave guard who steps aside once you are Champion. -- 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) local function ceruleanRivalScene(px, py)
return { return {
{ "show_object", "CERULEAN_CITY", "CERULEANCITY_RIVAL" }, -- 1 { "show_object", "CERULEAN_CITY", "CERULEANCITY_RIVAL" }, -- 1
@@ -523,43 +360,41 @@ local function ceruleanRivalScene(px, py)
{ "face_object", 1, "down" }, -- 3 { "face_object", 1, "down" }, -- 3
{ "show_text", "_CeruleanCityRivalPreBattleText" }, -- 4 { "show_text", "_CeruleanCityRivalPreBattleText" }, -- 4
{ "rival_battle", "OPP_RIVAL1", 7 }, -- 5 { "rival_battle", "OPP_RIVAL1", 7 }, -- 5
{ "jump_if_false", 11 }, -- 6 { "jump_if_false", 10 }, -- 6
{ "set_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 7 { "set_flag", "EVENT_BEAT_CERULEAN_RIVAL" }, -- 7
{ "show_text", "_CeruleanCityRivalDefeatedText" }, -- 8 { "show_text", "_CeruleanCityRivalDefeatedText" }, -- 8
{ "show_text", "_CeruleanCityRivalIWentToBillsText" }, -- 9 { "move_npc_to", 1, 20, 2 }, -- 9
{ "walk_npc", 1, ceruleanRivalExitDirs(px) }, -- 10 { "hide_object", "CERULEAN_CITY", "CERULEANCITY_RIVAL" }, -- 10
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_RIVAL" }, -- 11
} }
end end
-- scripts/CeruleanCity.asm CeruleanCityRocketText: fight the thief, -- scripts/CeruleanCity.asm CeruleanCityRocketText: fight the thief,
-- then he returns TM28 (DIG) and hurries off. CeruleanHideRocket -- then he returns TM28 (DIG) and hurries off
-- (CeruleanCity_2.asm) is GBFadeOutToBlack → Show GUARD1 / Hide GUARD2 /
-- Hide ROCKET → GBFadeInFromBlack — not a bare hide_object.
local rocketRows = { local rocketRows = {
{ "face_player" }, -- 1 { "face_player" }, -- 1
{ "check_flag", "EVENT_GOT_TM28" }, -- 2 { "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 { "check_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 4
{ "jump_if_true", 9 }, -- 5 { "jump_if_true", 9 }, -- 5
{ "show_text", "_CeruleanCityRocketText" }, -- 6 { "show_text", "_CeruleanCityRocketText" }, -- 6
{ "start_battle", "trainer", "OPP_ROCKET", 5 }, -- 7 { "start_battle", "trainer", "OPP_ROCKET", 5 }, -- 7
{ "jump_if_false", "end" }, -- 8 { "jump_if_false", 18 }, -- 8
{ "show_text", "_CeruleanCityRocketIllReturnTheTMText" }, -- 9 { "show_text", "_CeruleanCityRocketIllReturnTheTMText" }, -- 9
{ "set_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 10 { "set_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 10
{ "give_item", "TM_DIG", 1, false }, -- 11 (row 13 prints) { "give_item", "TM_DIG", 1, false }, -- 11 (row 13 prints)
{ "set_flag", "EVENT_GOT_TM28" }, -- 12 { "set_flag", "EVENT_GOT_TM28" }, -- 12
{ "show_text", "_CeruleanCityRocketReceivedTM28Text" }, -- 13 { "show_text", "_CeruleanCityRocketReceivedTM28Text" }, -- 13
{ "show_text", "_CeruleanCityRocketIBetterGetMovingText" }, -- 14 { "show_text", "_CeruleanCityRocketIBetterGetMovingText" }, -- 14
{ "fade", "out" }, -- 15 GBFadeOutToBlack { "hide_object", "CERULEAN_CITY", "CERULEANCITY_ROCKET" }, -- 15
-- CeruleanHideRocket while black: GUARD1 (28,12) appears, GUARD2 -- CeruleanHideRocket (scripts/CeruleanCity_2.asm) does all three behind
-- (27,12) and the ROCKET go. GUARD2 blocks the trashed-house south -- one fade: the ROCKET goes, GUARD1 (28,12) appears and GUARD2 (27,12)
-- door neighbour — the swap reconnects the city (Bill's ticket does -- disappears. GUARD2 stands on the only walkable neighbour of the
-- the same in story.lua; either route is enough). -- 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 { "show_object", "CERULEAN_CITY", "CERULEANCITY_GUARD1" }, -- 16
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_GUARD2" }, -- 17 { "hide_object", "CERULEAN_CITY", "CERULEANCITY_GUARD2" }, -- 17
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_ROCKET" }, -- 18
{ "fade", "in" }, -- 19 GBFadeInFromBlack
} }
M.CERULEAN_CITY = { M.CERULEAN_CITY = {
+10 -72
View File
@@ -2,9 +2,8 @@
-- "OPP_CLASS#partyIndex" (the object_event trainer args). Hand-ported -- "OPP_CLASS#partyIndex" (the object_event trainer args). Hand-ported
-- from the leaders'/bosses' text_asm victory scripts: -- from the leaders'/bosses' text_asm victory scripts:
-- gym badges: scripts/PewterGym.asm ... ViridianGym.asm -- gym badges: scripts/PewterGym.asm ... ViridianGym.asm
-- Rocket Hideout Giovanni: Silph Scope is a hidden item ball revealed by -- Rocket Hideout Giovanni: his Silph Scope is an item ball next to him
-- ShowObject in RocketHideoutB4FBeatGiovanniScript (ported as the -- (data/maps/objects/RocketHideoutB4F.asm), so no reward entry needed.
-- TEXT_ROCKETHIDEOUTB4F_GIOVANNI talk handler in story3.lua).
-- The TM each gym leader hands out afterwards is also ported. -- The TM each gym leader hands out afterwards is also ported.
-- --
-- `deactivate` lists the EVENT_BEAT_* flags each gym's victory script -- `deactivate` lists the EVENT_BEAT_* flags each gym's victory script
@@ -12,12 +11,6 @@
-- "; deactivate gym trainers" / SetEventRange in the other gyms, and -- "; deactivate gym trainers" / SetEventRange in the other gyms, and
-- FightingDojo.asm SetEventRange EVENT_BEAT_KARATE_MASTER .. -- FightingDojo.asm SetEventRange EVENT_BEAT_KARATE_MASTER ..
-- EVENT_BEAT_FIGHTING_DOJO_TRAINER_3). -- 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 function range(prefix, first, last)
local t = {} local t = {}
@@ -28,85 +21,30 @@ local function range(prefix, first, last)
end end
return { return {
-- PewterGym.asm .gymVictory also HideObject TOGGLE_GYM_GUY
-- (PEWTERCITY_YOUNGSTER) and TOGGLE_ROUTE_22_RIVAL_1 so the east-exit
-- escort NPC and the first Route 22 rival stay gone after the badge.
["OPP_BROCK#1"] = { badge = "BOULDERBADGE", flag = "EVENT_BEAT_BROCK", ["OPP_BROCK#1"] = { badge = "BOULDERBADGE", flag = "EVENT_BEAT_BROCK",
item = "TM_BIDE", item = "TM_BIDE",
deactivate = { "EVENT_BEAT_PEWTER_GYM_TRAINER_0" }, deactivate = { "EVENT_BEAT_PEWTER_GYM_TRAINER_0" } },
hide = {
{ "PEWTER_CITY", "PEWTERCITY_YOUNGSTER" },
{ "ROUTE_22", "ROUTE22_RIVAL1" },
},
dialogue = {
"_PewterGymBrockReceivedBoulderBadgeText",
"_PewterGymBrockBoulderBadgeInfoText",
"_PewterGymBrockWaitTakeThisText",
"_PewterGymReceivedTM34Text",
"_TM34ExplanationText",
} },
["OPP_MISTY#1"] = { badge = "CASCADEBADGE", flag = "EVENT_BEAT_MISTY", ["OPP_MISTY#1"] = { badge = "CASCADEBADGE", flag = "EVENT_BEAT_MISTY",
item = "TM_BUBBLEBEAM", item = "TM_BUBBLEBEAM",
deactivate = range("EVENT_BEAT_CERULEAN_GYM_TRAINER_", 0, 1), deactivate = range("EVENT_BEAT_CERULEAN_GYM_TRAINER_", 0, 1) },
dialogue = {
"_CeruleanGymMistyReceivedCascadeBadgeText",
"_CeruleanGymMistyCascadeBadgeInfoText",
"_CeruleanGymMistyReceivedTM11Text",
} },
["OPP_LT_SURGE#1"] = { badge = "THUNDERBADGE", flag = "EVENT_BEAT_LT_SURGE", ["OPP_LT_SURGE#1"] = { badge = "THUNDERBADGE", flag = "EVENT_BEAT_LT_SURGE",
item = "TM_THUNDERBOLT", item = "TM_THUNDERBOLT",
deactivate = range("EVENT_BEAT_VERMILION_GYM_TRAINER_", 0, 2), deactivate = range("EVENT_BEAT_VERMILION_GYM_TRAINER_", 0, 2) },
dialogue = {
"_VermilionGymLTSurgeReceivedThunderBadgeText",
"_VermilionGymLTSurgeThunderBadgeInfoText",
"_VermilionGymLTSurgeReceivedTM24Text",
"_TM24ExplanationText",
} },
["OPP_ERIKA#1"] = { badge = "RAINBOWBADGE", flag = "EVENT_BEAT_ERIKA", ["OPP_ERIKA#1"] = { badge = "RAINBOWBADGE", flag = "EVENT_BEAT_ERIKA",
item = "TM_MEGA_DRAIN", item = "TM_MEGA_DRAIN",
deactivate = range("EVENT_BEAT_CELADON_GYM_TRAINER_", 0, 6), deactivate = range("EVENT_BEAT_CELADON_GYM_TRAINER_", 0, 6) },
dialogue = {
"_CeladonGymErikaReceivedRainbowBadgeText",
"_CeladonGymRainbowBadgeInfoText",
"_CeladonGymReceivedTM21Text",
"_TM21ExplanationText",
} },
["OPP_KOGA#1"] = { badge = "SOULBADGE", flag = "EVENT_BEAT_KOGA", ["OPP_KOGA#1"] = { badge = "SOULBADGE", flag = "EVENT_BEAT_KOGA",
item = "TM_TOXIC", item = "TM_TOXIC",
deactivate = range("EVENT_BEAT_FUCHSIA_GYM_TRAINER_", 0, 5), deactivate = range("EVENT_BEAT_FUCHSIA_GYM_TRAINER_", 0, 5) },
dialogue = {
"_FuchsiaGymKogaReceivedSoulBadgeText",
"_FuchsiaGymKogaSoulBadgeInfoText",
"_FuchsiaGymKogaReceivedTM06Text",
"_FuchsiaGymKogaTM06ExplanationText",
} },
["OPP_SABRINA#1"] = { badge = "MARSHBADGE", flag = "EVENT_BEAT_SABRINA", ["OPP_SABRINA#1"] = { badge = "MARSHBADGE", flag = "EVENT_BEAT_SABRINA",
item = "TM_PSYWAVE", item = "TM_PSYWAVE",
deactivate = range("EVENT_BEAT_SAFFRON_GYM_TRAINER_", 0, 6), deactivate = range("EVENT_BEAT_SAFFRON_GYM_TRAINER_", 0, 6) },
dialogue = {
"_SaffronGymSabrinaReceivedMarshBadgeText",
"_SaffronGymSabrinaMarshBadgeInfoText",
"_SaffronGymSabrinaReceivedTM46Text",
"_TM46ExplanationText",
} },
["OPP_BLAINE#1"] = { badge = "VOLCANOBADGE", flag = "EVENT_BEAT_BLAINE", ["OPP_BLAINE#1"] = { badge = "VOLCANOBADGE", flag = "EVENT_BEAT_BLAINE",
item = "TM_FIRE_BLAST", item = "TM_FIRE_BLAST",
deactivate = range("EVENT_BEAT_CINNABAR_GYM_TRAINER_", 0, 6), deactivate = range("EVENT_BEAT_CINNABAR_GYM_TRAINER_", 0, 6) },
dialogue = {
"_CinnabarGymBlaineReceivedVolcanoBadgeText",
"_CinnabarGymBlaineVolcanoBadgeInfoText",
"_CinnabarGymBlaineReceivedTM38Text",
"_CinnabarGymBlaineTM38ExplanationText",
} },
["OPP_GIOVANNI#3"] = { badge = "EARTHBADGE", flag = "EVENT_BEAT_GIOVANNI", ["OPP_GIOVANNI#3"] = { badge = "EARTHBADGE", flag = "EVENT_BEAT_GIOVANNI",
item = "TM_FISSURE", item = "TM_FISSURE",
deactivate = range("EVENT_BEAT_VIRIDIAN_GYM_TRAINER_", 0, 7), deactivate = range("EVENT_BEAT_VIRIDIAN_GYM_TRAINER_", 0, 7) },
dialogue = {
"_ViridianGymGiovanniReceivedEarthBadgeText",
"_ViridianGymGiovanniEarthBadgeInfoText",
"_ViridianGymGiovanniReceivedTM27Text",
"_ViridianGymGiovanniTM27ExplanationText",
} },
-- Silph Co. Giovanni: unlocks the president's Master Ball gift -- Silph Co. Giovanni: unlocks the president's Master Ball gift
["OPP_GIOVANNI#2"] = { flag = "EVENT_BEAT_SILPH_CO_GIOVANNI" }, ["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 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 ## Developer console
Boot with developer mode on to unlock the in-game console and hot-reload Boot with developer mode on to unlock the in-game console and hot-reload
+12 -41
View File
@@ -8,42 +8,27 @@ behavior is in docs/behavior-porting-notes.md.
## Survey zoom ## Survey zoom
The mouse wheel (or `-`/`=`), the Options **ZOOM** row, or hotkey `4` The mouse wheel (or `-`/`=`) zooms the overworld between 1 pixel per world
zooms the overworld between 1 pixel per world pixel (full survey) and 2× pixel (full survey) and 2× the window fit scale (close-up), in crisp
the window fit scale (close-up), in crisp integer steps. This has no Game integer steps. This has no Game Boy equivalent:
Boy equivalent:
- Connected maps render their full bodies, and their NPCs appear as - Connected maps render their full bodies, and their NPCs appear as
visual-only "ghosts", they wander but have no sight lines, triggers, visual-only "ghosts", they wander but have no sight lines, triggers,
dialogue, or collision until the map is actually entered. dialogue, or collision until the map is actually entered.
- Menus, text boxes, and battles draw at normal scale on top of the - Menus, text boxes, and battles draw at normal scale on top of the
zoomed world. Zoom input is ignored while a script, menu, or battle is zoomed world. Zoom input is ignored while a script, menu, or battle is
active; the zoom offset is persisted as `save.options.zoom` (default active; the zoom level persists across warps and is never saved.
`0` = FIT) and survives New Game via `options.lua`. - Beyond the border ring the border block repeats indefinitely (interiors
- Hotkey `4` ticks through every integer zoom level (survey → FIT → stay black, seaside towns stay water, except OVERWORLD-tileset maps,
close-up → wrap). The Options row shows `FIT` / `OUTn` / `INn`. whose beyond-edge space fills with the solid tree wall instead of the
- Beyond the border ring the void fill repeats indefinitely (see VOID per-map border block), and each visible map area is colorized with its
FILL below); interiors keep their own border block. Each visible map own SGB palette (the original recolored the whole screen per map).
area is colorized with its own SGB palette (the original recolored the
whole screen per map).
- Neighbor maps load two connection hops out so corner-adjacent maps - Neighbor maps load two connection hops out so corner-adjacent maps
don't pop in and out, and ghost NPCs share instances with the real ones don't pop in and out, and ghost NPCs share instances with the real ones
so their wander positions persist across seamless connection crossings so their wander positions persist across seamless connection crossings
(a warp or fresh map entry still respawns everything at its script (a warp or fresh map entry still respawns everything at its script
position, like the original's per-entry sprite init). position, like the original's per-entry sprite init).
## VOID FILL
The Options **VOID FILL** row picks what paints the infinite beyond-edge
space on OVERWORLD-tileset maps during survey zoom:
- **TREES** (default): solid tree wall block `$0F`.
- **WATER**: animated water tile `$14` (same hshift cycle as on-map water).
- **BLACK**: solid black.
Other tilesets are unchanged (house/cave borders stay as authored).
Persisted as `save.options.voidFill`.
## Tilt mode ## Tilt mode
The `3` key (and the Options menu TILT row) cycles a visual-only perspective The `3` key (and the Options menu TILT row) cycles a visual-only perspective
@@ -57,10 +42,8 @@ Game Boy equivalent:
rows above the player recede and rows below come toward the viewer. Only rows above the player recede and rows below come toward the viewer. Only
things that actually *stand* on the ground draw as upright billboards, things that actually *stand* on the ground draw as upright billboards,
unscaled and pixel-identical to flat mode: the player, NPCs, item balls, unscaled and pixel-identical to flat mode: the player, NPCs, item balls,
and the standing FX attached to them (emote bubbles, the fishing rod, and the screen-anchored FX attached to them (heal machine glow, emote
the FLY bird). The Poké Center heal-machine overlay stays on the ground bubbles, the fishing rod, the FLY bird). An earlier revision tried
plane with the machine tiles (it is OAM glued to a BG graphic, not a
standing sprite). An earlier revision tried
billboarding buildings/trees/signs too (cutting them out of the ground billboarding buildings/trees/signs too (cutting them out of the ground
per hand-curated per-tileset tables); that chased an endless tail of per hand-curated per-tileset tables); that chased an endless tail of
special cases, dense tree canopy, fences fused into grass, building special cases, dense tree canopy, fences fused into grass, building
@@ -154,7 +137,7 @@ Nidorino-vs-Gengar attract scene) mirror the original.
Options persist in a standalone `options.lua` (separate from the game Options persist in a standalone `options.lua` (separate from the game
progress `save.lua`), so audio/display/battle preferences survive New Game progress `save.lua`), so audio/display/battle preferences survive New Game
and aren't wiped when a save slot is cleared. Changing a row in the Options and aren't wiped when a save slot is cleared. Changing a row in the Options
menu or cycling hotkeys `2`/`3`/`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 flushes the live options. Old saves that still embed an `options` table are
migrated once into `options.lua` on load. migrated once into `options.lua` on load.
@@ -165,18 +148,6 @@ migrated once into `options.lua` on load.
hotkey `2` (OG RED = GBC boot-ROM look; RED++ uses pokered-gbc hotkey `2` (OG RED = GBC boot-ROM look; RED++ uses pokered-gbc
SuperPalettes + per-species mon colors) SuperPalettes + per-species mon colors)
- TILT (OFF / 15 / 35 / 50), also hotkey `3` while free-roaming - TILT (OFF / 15 / 35 / 50), also hotkey `3` while free-roaming
- ZOOM (FIT / OUTn / INn), also hotkey `4` while free-roaming; wheel and
`-`/`=` step one level and save
- VOID FILL (TREES / WATER / BLACK) for OVERWORLD beyond-edge space
- GBC FX (OFF / 1 / 2 / 3 / 4), also hotkey `5` - GBC FX (OFF / 1 / 2 / 3 / 4), also hotkey `5`
- MAX FPS (30 / 40 / 50 / 60 / 75 / 90 / 100 / 120 / 144 / 160, default 60), - MAX FPS (30 / 40 / 50 / 60 / 75 / 90 / 100 / 120 / 144 / 160, default 60),
a hard render frame-rate cap (`save.options.fpsCap`). a hard render frame-rate cap (`save.options.fpsCap`).
## Battle transition cascade + white battle letterbox
Into-battle wipes still run the original eight styles inside the classic
160×144 letterbox. On wide/tall windows (survey zoom), matching black 8×8
blocks cascade outward from that square into the surrounding world so the
void outside the OG wipe fills in lockstep. Once the battle state is up,
letterbox voids around the battle canvas fill **white** instead of black
so the whole window reads as one continuous battle screen.
-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.
-6
View File
@@ -56,12 +56,6 @@ local function bootGame(version)
end end
function love.load(args) 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 local savePath
for i, a in ipairs(args or {}) do for i, a in ipairs(args or {}) do
if a == "--editor" then if a == "--editor" then
+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 `assets/generated/`, and any ROM. It contains the first-boot Lua importer and
`tools/rom_manifest.json`. `tools/rom_manifest.json`.
ROM import on Android uses `love.system.pickFile()` The current importer has desktop file pickers only. A production Android
`GameActivity.showRomFilePicker` (Storage Access Framework), which copies the release still needs a Storage Access Framework handoff that passes the chosen
chosen file to `picked_rom.gb` under the app save directory. `RomImporter` ROM to LÖVE; the APK packaging itself is data-free.
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).
### SDK / NDK ### SDK / NDK
@@ -6,7 +6,7 @@
"entry": "main.lua", "entry": "main.lua",
"profile": "content", "profile": "content",
"category": "BALANCE", "category": "BALANCE",
"game_version": ">=0.0.0-0 <2.0.0", "game_version": ">=1.0.0 <2.0.0",
"priority": 100, "priority": 100,
"dependencies": [], "dependencies": [],
"optional_dependencies": [], "optional_dependencies": [],
+1 -1
View File
@@ -6,7 +6,7 @@
"entry": "main.lua", "entry": "main.lua",
"profile": "content", "profile": "content",
"category": "TOOL", "category": "TOOL",
"game_version": ">=0.0.0-0 <2.0.0", "game_version": ">=1.0.0 <2.0.0",
"priority": 100, "priority": 100,
"dependencies": [], "dependencies": [],
"optional_dependencies": [], "optional_dependencies": [],
+1 -1
View File
@@ -6,7 +6,7 @@
"entry": "main.lua", "entry": "main.lua",
"profile": "content", "profile": "content",
"category": "AUDIO", "category": "AUDIO",
"game_version": ">=0.0.0-0 <2.0.0", "game_version": ">=1.0.0 <2.0.0",
"priority": 100, "priority": 100,
"permissions": ["engine_internals"], "permissions": ["engine_internals"],
"dependencies": [], "dependencies": [],
@@ -6,7 +6,7 @@
"entry": "main.lua", "entry": "main.lua",
"profile": "content", "profile": "content",
"category": "QUEST", "category": "QUEST",
"game_version": ">=0.0.0-0 <2.0.0", "game_version": ">=1.0.0 <2.0.0",
"priority": 100, "priority": 100,
"permissions": ["engine_internals"], "permissions": ["engine_internals"],
"dependencies": [], "dependencies": [],
@@ -6,7 +6,7 @@
"entry": "main.lua", "entry": "main.lua",
"profile": "total_conversion", "profile": "total_conversion",
"category": "TOTAL_CONVERSION", "category": "TOTAL_CONVERSION",
"game_version": ">=0.0.0-0 <2.0.0", "game_version": ">=1.0.0 <2.0.0",
"priority": 900, "priority": 900,
"dependencies": [], "dependencies": [],
"optional_dependencies": [], "optional_dependencies": [],
@@ -6,7 +6,7 @@
"entry": "main.lua", "entry": "main.lua",
"profile": "content", "profile": "content",
"category": "GRAPHICS", "category": "GRAPHICS",
"game_version": ">=0.0.0-0 <2.0.0", "game_version": ">=1.0.0 <2.0.0",
"priority": 100, "priority": 100,
"assets_transforms": "transforms.lua", "assets_transforms": "transforms.lua",
"dependencies": [], "dependencies": [],
+1 -1
View File
@@ -6,7 +6,7 @@
"entry": "main.lua", "entry": "main.lua",
"profile": "overhaul", "profile": "overhaul",
"category": "MECHANIC", "category": "MECHANIC",
"game_version": ">=0.0.0-0 <2.0.0", "game_version": ">=1.0.0 <2.0.0",
"priority": 100, "priority": 100,
"dependencies": [], "dependencies": [],
"optional_dependencies": [], "optional_dependencies": [],
-25
View File
@@ -68,31 +68,6 @@ if unzip -Z1 "$LOVE_FILE" \
fi fi
say "game.love: $(du -h "$LOVE_FILE" | cut -f1)" 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 # --------------------------------------------------------------- macOS
build_mac() { build_mac() {
say "building macOS app" say "building macOS app"
-26
View File
@@ -145,32 +145,6 @@ pack_game_love() {
fail "game.love unexpectedly contains generated ROM data" fail "game.love unexpectedly contains generated ROM data"
fi fi
say "game.love: $(du -h "$LOVE_FILE" | cut -f1) -> $LOVE_FILE" 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 # --------------------------------------------------------------- SDK check
+124 -471
View File
@@ -30,9 +30,6 @@ local TypeChart = require("src.battle.TypeChart")
local BattleState = {} local BattleState = {}
BattleState.__index = BattleState BattleState.__index = BattleState
BattleState.isOpaque = true BattleState.isOpaque = true
-- Letterbox voids around the 160x144 battle canvas fill white so the
-- window reads as one continuous battle screen (no black bars).
BattleState.letterboxWhite = true
-- Battle colors itself per-pixel (species pics + HP bar tints), so the -- Battle colors itself per-pixel (species pics + HP bar tints), so the
-- SGB whole-screen remap must not run over it. -- SGB whole-screen remap must not run over it.
@@ -55,10 +52,6 @@ local imageCache = {}
-- fully transparent rows below a pic's content (the extracted 32x32 back -- fully transparent rows below a pic's content (the extracted 32x32 back
-- pics carry baked-in padding); used to sit the pic flush on the text box -- pics carry baked-in padding); used to sit the pic flush on the text box
local imagePadBottom = {} local imagePadBottom = {}
-- fully transparent columns left of a pic's content; at 2x (back pics)
-- this is subtracted from hlcoord 1,5 so opaque pixels match hardware,
-- where those columns were white-on-white rather than shifted content
local imagePadLeft = {}
-- image -> { path, pal } so palette-fade variants (see fadeImage) can be -- image -> { path, pal } so palette-fade variants (see fadeImage) can be
-- rebuilt for any battle pic, whatever code loaded it -- rebuilt for any battle pic, whatever code loaded it
local imageMeta = {} local imageMeta = {}
@@ -70,7 +63,7 @@ local function getImage(path, pal, trueColor)
if trueColor then pal = nil end if trueColor then pal = nil end
local key = pal and (path .. "#" .. pal.name) or path local key = pal and (path .. "#" .. pal.name) or path
if not imageCache[key] then if not imageCache[key] then
local img, pad, padL = nil, 0, 0 local img, pad = nil, 0
if love.image and love.image.newImageData then if love.image and love.image.newImageData then
local id = Assets.imageData(path) local id = Assets.imageData(path)
if pal then if pal then
@@ -93,25 +86,13 @@ local function getImage(path, pal, trueColor)
if opaque then break end if opaque then break end
bottom = bottom - 1 bottom = bottom - 1
end end
local left = 0
while left < w do
local opaque = false
for y = 0, h - 1 do
local _, _, _, a = id:getPixel(left, y)
if a > 0 then opaque = true break end
end
if opaque then break end
left = left + 1
end
img = love.graphics.newImage(id) img = love.graphics.newImage(id)
pad = h - 1 - bottom pad = h - 1 - bottom
padL = left
else else
img = Assets.image(path) -- headless stub: no pixel access img = Assets.image(path) -- headless stub: no pixel access
end end
imageCache[key] = img imageCache[key] = img
imagePadBottom[img] = pad imagePadBottom[img] = pad
imagePadLeft[img] = padL
imageMeta[img] = { path = path, pal = pal, trueColor = trueColor or nil } imageMeta[img] = { path = path, pal = pal, trueColor = trueColor or nil }
end end
return imageCache[key] return imageCache[key]
@@ -120,7 +101,7 @@ end
-- hot reload: the next getImage re-resolves every pic through the asset -- hot reload: the next getImage re-resolves every pic through the asset
-- search path and re-measures its ground padding -- search path and re-measures its ground padding
function BattleState.invalidate() function BattleState.invalidate()
imageCache, imagePadBottom, imagePadLeft, imageMeta = {}, {}, {}, {} imageCache, imagePadBottom, imageMeta = {}, {}, {}
end end
Assets.register(BattleState.invalidate) Assets.register(BattleState.invalidate)
@@ -173,14 +154,6 @@ local function grayImage(img)
return getImage(meta.path) or img return getImage(meta.path) or img
end end
-- the asset path a loaded battle image came from (nil for the headless
-- stub images), so the battle_sprite_scales registry can be looked up by
-- the same path data references
local function imagePathOf(img)
local m = imageMeta[img]
return m and m.path
end
-- the image a battler pic actually draws with this frame -- the image a battler pic actually draws with this frame
function BattleState:picImage(img) function BattleState:picImage(img)
if self.grayPics then return grayImage(img) end if self.grayPics then return grayImage(img) end
@@ -275,10 +248,6 @@ local function makeBattler(data, mon, isPlayer, save)
badgeBoosts = badgeBoosts, badgeBoosts = badgeBoosts,
statuses = data.statuses, statuses = data.statuses,
shownHP = mon.hp, -- the HP the bar displays (UpdateHPBar drain) shownHP = mon.hp, -- the HP the bar displays (UpdateHPBar drain)
-- HUD status label (DrawHUDsAndHPBars); mon.status can land mid-move
-- while the tilemap still shows the prior condition until the next
-- post-action HUD refresh (core.asm after Execute*Move)
shownStatus = mon.status,
stages = {}, stages = {},
-- volatile state; Transform/Conversion/Mimic override the cur* fields -- volatile state; Transform/Conversion/Mimic override the cur* fields
curStats = mon.stats, curStats = mon.stats,
@@ -289,10 +258,6 @@ local function makeBattler(data, mon, isPlayer, save)
} }
end end
-- LinkBattle builds clamped copies with save=nil (no badge boosts); wild
-- and trainer constructors pass the live save for the player side.
BattleState.makeBattler = makeBattler
-- The battle pic for `species` on the given side (back pic for the -- The battle pic for `species` on the given side (back pic for the
-- player side, front pic for the enemy side), tinted PAL_GRAYMON -- -- player side, front pic for the enemy side), tinted PAL_GRAYMON --
-- the same path makeBattler uses, but forced gray -- since this is only -- the same path makeBattler uses, but forced gray -- since this is only
@@ -560,12 +525,6 @@ function BattleState:say(text)
table.insert(self.queue, { text = text }) table.insert(self.queue, { text = text })
end end
-- Message that opens YES/NO once typed out, keeping the text visible
-- underneath (pokered `done` + TWO_OPTION_MENU / TextBox opts.choice).
function BattleState:sayChoice(text, onChoose)
table.insert(self.queue, { text = text, choice = onChoose })
end
function BattleState:act(fn) function BattleState:act(fn)
table.insert(self.queue, { fn = fn }) table.insert(self.queue, { fn = fn })
end end
@@ -826,24 +785,8 @@ function BattleState:updateQueue()
if self.charIndex < self.total then if self.charIndex < self.total then
self.charIndex = math.min(self.total, self.charIndex + 2) self.charIndex = math.min(self.total, self.charIndex + 2)
else else
local item = self.current
-- TrainerAboutToUseText ends in `done` then DisplayTextBoxID: YES/NO
-- overlays the still-visible "Will … change POKéMON?" page.
if item and item.choice and not item.choiceOpen then
item.choiceOpen = true
self.waitingUI = true
local ChoiceBox = require("src.ui.ChoiceBox")
local battle = self
self.game.stack:push(ChoiceBox.new(self.game, function(yes)
local fn = item.choice
battle.current = nil
fn(yes)
end))
return true
end
local input = self.game.input local input = self.game.input
if not (item and item.choice) if input:wasPressed("a") or input:wasPressed("b") then
and (input:wasPressed("a") or input:wasPressed("b")) then
self.current = nil self.current = nil
end end
end end
@@ -889,7 +832,7 @@ function BattleState:computeMusicKind()
return "final" return "final"
elseif isBoss or (self.trainer and self.trainer.id == "OPP_LANCE") then elseif isBoss or (self.trainer and self.trainer.id == "OPP_LANCE") then
return "gym" return "gym"
elseif self.kind == "trainer" or self.kind == "link" then elseif self.kind == "trainer" then
return "trainer" return "trainer"
end end
return "wild" return "wild"
@@ -951,17 +894,6 @@ function BattleState:enter()
self.showEnemyTrainer = false self.showEnemyTrainer = false
self:startGrowIn(self.enemy) self:startGrowIn(self.enemy)
end) end)
elseif self.kind == "link" then
-- Colosseum has no foe trainer pic, but the enemy mon still grows
-- out of the ball after "X sent out Y!" (not the wild "already there"
-- intro that LinkBattle previously inherited from newWild).
self.enemySendingOut = true
self:say(("%s sent\nout %s!"):format(self.opponentName or "FOE",
self.enemy.name))
self:act(function()
self.enemySendingOut = false
self:startGrowIn(self.enemy)
end)
end end
if not self.ghost then if not self.ghost then
-- the enemy's cry plays as it appears (data/pokemon/cries.asm) -- the enemy's cry plays as it appears (data/pokemon/cries.asm)
@@ -1018,37 +950,20 @@ function BattleState:exit()
end end
end end
-- End a trapping sequence (USING_TRAPPING_MOVE). SendOutMon clears the -- An action the battler is locked into (bypasses the menu), or nil.
-- foe's bit (core.asm:1761-1762); EnemySendOutFirstMon clears the function BattleState:lockedAction(battler)
-- player's (core.asm:1314-1315). Any switch frees the other side.
local function clearTrapping(battler)
if not battler then return end
battler.trappingTurns = nil
battler.trapMove = nil
battler.trapDamage = nil
end
-- Actions that skip DisplayBattleMenu entirely (core.asm:300-310):
-- recharge, Rage, thrash, charge. Bide / trapping / being held do NOT
-- skip the menu -- the player can still item/switch (and must press
-- FIGHT to continue a trapping sequence).
function BattleState:menuLockedAction(battler)
if battler.mustRecharge then return { special = "recharge" } end if battler.mustRecharge then return { special = "recharge" } end
if battler.charging then return battler.charging end if battler.charging then return battler.charging end
if battler.thrashTurns and battler.thrashTurns > 0 then return battler.thrashMove end if battler.thrashTurns and battler.thrashTurns > 0 then return battler.thrashMove end
if battler.rageMove then return battler.rageMove end
return nil
end
-- After FIGHT: skip MoveSelectionMenu (core.asm:320-329). Own
-- trapping/Bide continues; foe trapping forces CANNOT_MOVE ($ff).
function BattleState:fightLockedAction(battler)
if battler.trappingTurns and battler.trappingTurns > 0 then if battler.trappingTurns and battler.trappingTurns > 0 then
return { special = "trapping" } return { special = "trapping" }
end end
if battler.bideTurns then return { special = "bide" } end if battler.bideTurns then return { special = "bide" } end
-- held while the OPPONENT's trapping bit is set (live mirror so a if battler.rageMove then return battler.rageMove end
-- trap ended early by paralysis/faint frees the victim immediately) -- held in place while the OPPONENT's trapping move is running
-- (core.asm:316-322 reads the live USING_TRAPPING_MOVE bit, so a
-- trap ended early by paralysis/faint frees the victim immediately);
-- boundTurns is a mirror kept for Status.beforeMove's held check
local opp = battler.isPlayer and self.enemy or self.player local opp = battler.isPlayer and self.enemy or self.player
battler.boundTurns = opp and opp.trappingTurns battler.boundTurns = opp and opp.trappingTurns
and math.max(1, opp.trappingTurns) or nil and math.max(1, opp.trappingTurns) or nil
@@ -1058,11 +973,6 @@ function BattleState:fightLockedAction(battler)
return nil return nil
end end
-- Full lock for AI / callers that need any forced action.
function BattleState:lockedAction(battler)
return self:menuLockedAction(battler) or self:fightLockedAction(battler)
end
function BattleState:playerHasPP() function BattleState:playerHasPP()
for i, mv in ipairs(self.player.curMoves) do for i, mv in ipairs(self.player.curMoves) do
if mv.pp > 0 and self.player.disabledSlot ~= i then return true end if mv.pp > 0 and self.player.disabledSlot ~= i then return true end
@@ -1094,14 +1004,11 @@ function BattleState:update(dt)
self:updateFx() self:updateFx()
local input = self.game.input local input = self.game.input
-- safety net: HP/status changed outside a queued drain (level-up heals, -- safety net: HP changed outside a queued drain (level-up heals,
-- field effects, bag cures) snaps once the queue is idle -- field effects) snaps once the queue is idle
if self.phase == "menu" then if self.phase == "menu" then
for _, b in ipairs({ self.player, self.enemy }) do for _, b in ipairs({ self.player, self.enemy }) do
if b then if b and b.shownHP then b.shownHP = b.mon.hp end
if b.shownHP then b.shownHP = b.mon.hp end
b.shownStatus = b.mon.status
end
end end
end end
@@ -1172,9 +1079,8 @@ function BattleState:update(dt)
if not (self.player.mustRecharge or self.player.rageMove) then if not (self.player.mustRecharge or self.player.rageMove) then
self.player.flinched, self.enemy.flinched = false, false self.player.flinched, self.enemy.flinched = false, false
end end
-- only recharge/Rage/thrash/charge skip DisplayBattleMenu; trapping -- locked multi-turn actions skip the menu entirely
-- victims (and wrappers) still get FIGHT/PKMN/ITEM/RUN (core.asm:312) local locked = self:lockedAction(self.player)
local locked = self:menuLockedAction(self.player)
if locked then if locked then
self:resolveTurn(locked) self:resolveTurn(locked)
return return
@@ -1198,13 +1104,6 @@ function BattleState:update(dt)
end) end)
self:act(function() self:endOfTurn() end) self:act(function() self:endOfTurn() end)
elseif choice == "fight" then elseif choice == "fight" then
-- After the menu: own trapping/Bide or foe Wrap skips the move
-- list and forces the locked action (core.asm:320-329)
local fightLock = self:fightLockedAction(self.player)
if fightLock then
self:resolveTurn(fightLock)
return
end
if not self:playerHasPP() then if not self:playerHasPP() then
-- _NoMovesLeftText, then Struggle engages -- _NoMovesLeftText, then Struggle engages
self:say(("%s has no\nmoves left!"):format(self.player.name)) self:say(("%s has no\nmoves left!"):format(self.player.name))
@@ -1615,9 +1514,6 @@ function BattleState:resolveSwitch(newMon)
self:restoreMimicked(self.player) -- the battle copy leaves with it self:restoreMimicked(self.player) -- the battle copy leaves with it
local previous = self.player local previous = self.player
self.player = makeBattler(self.data, newMon, true, self.game.save) self.player = makeBattler(self.data, newMon, true, self.game.save)
-- SendOutMon (core.asm:1761-1762): player's send-out clears the
-- foe's USING_TRAPPING_MOVE -- Wrap/Bind/etc. ends on any switch
clearTrapping(self.enemy)
self:syncSides() self:syncSides()
Runtime.emit("battle.battler_switched", { Runtime.emit("battle.battler_switched", {
battle = self, side = self.sides[1], battler = self.player, battle = self, side = self.sides[1], battler = self.player,
@@ -1723,19 +1619,10 @@ end
-- only after MoveHitTest / the effect lands (HandleIfPlayerMoveMissed skips -- only after MoveHitTest / the effect lands (HandleIfPlayerMoveMissed skips
-- it on a miss unless EXPLODE_EFFECT); we insert early for blink attachment -- it on a miss unless EXPLODE_EFFECT); we insert early for blink attachment
-- and peel it back on miss/fail paths. -- and peel it back on miss/fail paths.
-- Dig/Fly charge leaves the user pic hidden (SLIDE_DOWN / TELEPORT); the
-- second-turn DIG/FLY anim restores it via SE_SLIDE_MON_UP / SE_SHOW_MON_PIC.
-- Cancelling that row on miss/immune would otherwise leave the digger
-- invisible until another anim's resetPicFx (#100).
function BattleState:cancelMoveAnim() function BattleState:cancelMoveAnim()
local row = self.moveAnimRow local row = self.moveAnimRow
if not row then return end if not row then return end
self.moveAnimRow = nil self.moveAnimRow = nil
if row.anim == "DIG" or row.anim == "FLY" then
local user = row.attackerIsPlayer and self.player or self.enemy
local pf = user and self.picFx and self.picFx[user]
if pf then pf.hidden = nil end
end
for i, item in ipairs(self.queue) do for i, item in ipairs(self.queue) do
if item == row then if item == row then
table.remove(self.queue, i) table.remove(self.queue, i)
@@ -1799,24 +1686,15 @@ end
-- transient pic effects reset when a new animation row starts (each -- transient pic effects reset when a new animation row starts (each
-- PlayAnimation redraws from a clean slate); `minimized` survives -- -- PlayAnimation redraws from a clean slate); `minimized` survives --
-- the minimize sprite replaces the pic DATA until reload. Dig/Fly's -- the minimize sprite replaces the pic DATA, so redraws keep it until
-- charge hide is a cleared tilemap that must survive until SE_SHOW_* / -- the pic is reloaded (switch/Transform/ChangeMonPic)
-- SE_SLIDE_MON_UP (or cancelMoveAnim on a missed Dig/Fly release):
-- clearing it here made Dig pop in before emerge and wrap/bounce (#100).
-- Other hides (Acid Armor, etc.) still clear so the next anim restores.
function BattleState:resetPicFx() function BattleState:resetPicFx()
if not self.picFx then return end if not self.picFx then return end
local digFly = self.animName == "DIG" or self.animName == "FLY" for _, pf in pairs(self.picFx) do
local digFlyUser = digFly and (self.animAttackerIsPlayer
and self.player or self.enemy) or nil
for battler, pf in pairs(self.picFx) do
pf.kind, pf.t = nil, nil pf.kind, pf.t = nil, nil
pf.ox, pf.oy = 0, 0 pf.ox, pf.oy = 0, 0
local keepHide = battler.invulnerable or battler == digFlyUser
if not keepHide then
pf.hidden = nil pf.hidden = nil
end end
end
end end
-- the battler an SE row's routine acts on: "the mon" is the attacker's -- the battler an SE row's routine acts on: "the mon" is the attacker's
@@ -2182,21 +2060,10 @@ end
-- move execution pipeline -- move execution pipeline
-- --------------------------------------------------------------------- -- ---------------------------------------------------------------------
-- Mirror DrawHUDsAndHPBars: reveal mon.status on the HUD only after the
-- current action's queued anim/text have played (PoisonEffect sets the
-- bit before PlayCurrentMoveAnimation2 + PrintText, but the HUD redraw
-- waits until after Execute*Move returns).
function BattleState:syncShownStatus()
for _, b in ipairs({ self.player, self.enemy }) do
if b then b.shownStatus = b.mon.status end
end
end
function BattleState:executeAction(user, target, action) function BattleState:executeAction(user, target, action)
if user.mon.hp <= 0 or target.mon.hp <= 0 then return end if user.mon.hp <= 0 or target.mon.hp <= 0 then return end
if not action then return end if not action then return end
local function run()
-- ghost battles: the ghost never attacks; its whole turn is the -- ghost battles: the ghost never attacks; its whole turn is the
-- GetOutText (ExecuteEnemyMove -> PrintGhostText, core.asm:5462-5463) -- GetOutText (ExecuteEnemyMove -> PrintGhostText, core.asm:5462-5463)
if self.ghost and not user.isPlayer then if self.ghost and not user.isPlayer then
@@ -2227,8 +2094,6 @@ function BattleState:executeAction(user, target, action)
local oldName = self.enemy.name local oldName = self.enemy.name
self.enemyIndex = action.index self.enemyIndex = action.index
self.enemy = makeBattler(self.data, self.enemyParty[action.index], false) self.enemy = makeBattler(self.data, self.enemyParty[action.index], false)
-- EnemySendOutFirstMon (core.asm:1314-1315): clears player's trap
clearTrapping(self.player)
self:syncSides() self:syncSides()
Runtime.emit("battle.battler_switched", { Runtime.emit("battle.battler_switched", {
battle = self, side = self.sides[2], battler = self.enemy, battle = self, side = self.sides[2], battler = self.enemy,
@@ -2277,47 +2142,6 @@ function BattleState:executeAction(user, target, action)
if self:statusInterrupt(user, target) then return end if self:statusInterrupt(user, target) then return end
self:performMove(user, target, action, false) self:performMove(user, target, action, false)
end
run()
-- after announce/anim/effect text (pokered DrawHUDsAndHPBars)
self:actNext(function() self:syncShownStatus() end)
end
-- Sleep / confusion onomatopoeia from Check*StatusConditions
-- (core.asm): side-specific SLP_*/CONF_* anims, not the Rest/Amnesia
-- move rows. Player sleep plays the anim before FastAsleepText;
-- enemy sleep and both confusion sides print the text first.
function BattleState:statusOnomatopoeia(user, kind)
local isPlayer = user.isPlayer
local anim
if kind == "sleep" then
anim = isPlayer and "SLP_PLAYER_ANIM" or "SLP_ANIM"
else
anim = isPlayer and "CONF_PLAYER_ANIM" or "CONF_ANIM"
end
local text = kind == "sleep"
and (displayName(user) .. "\nis fast asleep!")
or (displayName(user) .. "\nis confused!")
if kind == "sleep" and isPlayer then
self:animNext(anim, isPlayer)
self:sayNext(text)
else
self:sayNext(text)
self:animNext(anim, isPlayer)
end
end
-- Queue status text (+ sleep/confusion FX when the line matches).
-- Wake / snap-out / flinch / etc. stay text-only.
function BattleState:sayStatusMsg(user, msg)
local text = prefixEnemy(msg, user)
if msg:find("is fast asleep!", 1, true) then
self:statusOnomatopoeia(user, "sleep")
elseif msg:find("is confused!", 1, true) then
self:statusOnomatopoeia(user, "confused")
else
self:sayNext(text)
end
end end
-- The pre-recharge slice of CheckPlayerStatusConditions (core.asm: -- The pre-recharge slice of CheckPlayerStatusConditions (core.asm:
@@ -2338,7 +2162,7 @@ function BattleState:preRechargeChecks(user, target)
mon.status = nil mon.status = nil
self:sayNext(displayName(user) .. "\nwoke up!") self:sayNext(displayName(user) .. "\nwoke up!")
else else
self:statusOnomatopoeia(user, "sleep") self:sayNext(displayName(user) .. "\nis fast asleep!")
end end
return true return true
end end
@@ -2365,7 +2189,7 @@ end
-- returns true when the user's action is interrupted. -- returns true when the user's action is interrupted.
function BattleState:statusInterrupt(user, target) function BattleState:statusInterrupt(user, target)
local canMove, msgs, selfHit = Status.beforeMove(user, self.rng, self) local canMove, msgs, selfHit = Status.beforeMove(user, self.rng, self)
for _, m in ipairs(msgs) do self:sayStatusMsg(user, m) end for _, m in ipairs(msgs) do self:sayNext(prefixEnemy(m, user)) end
if selfHit then if selfHit then
-- confusion self-hit (core.asm:3428-3434): clears everything in -- confusion self-hit (core.asm:3428-3434): clears everything in
-- status1 except CONFUSED, then HandleSelfConfusionDamage deals a -- status1 except CONFUSED, then HandleSelfConfusionDamage deals a
@@ -2442,23 +2266,11 @@ function BattleState:performMove(user, target, moveInst, isCalled)
user.charging, user.chargeReady, user.invulnerable = nil, nil, nil user.charging, user.chargeReady, user.invulnerable = nil, nil, nil
end end
-- PP: not for continuations, struggle, called moves, or (under -- PP: not for continuations, struggle, or called moves
-- gen1_faithful) wild/trainer enemies — pokered DecrementPP only ever
-- mutates wBattleMonPP / party PP (engine/battle/decrement_pp.asm).
-- That rule doesn't apply in a link battle: "the enemy" there is a real
-- human peer independently tracking their own PP the normal way, not an
-- AI Gen 1 never bothered to decrement -- applying it made each side
-- silently skip decrementing the OTHER side's PP for the move it just
-- used, so both simulations' party state diverged by exactly 1 PP on
-- the very first move either side made, failing the lockstep hash
-- check turn 1 of literally every link battle.
local isContinuation = releasing local isContinuation = releasing
or (user.thrashTurns and user.thrashTurns > 0 and moveInst == user.thrashMove) or (user.thrashTurns and user.thrashTurns > 0 and moveInst == user.thrashMove)
or moveInst == user.rageMove or moveInst == user.rageMove
local enemyUnlimited = not user.isPlayer and self.kind ~= "link" if not isContinuation and not moveInst.struggle and not isCalled then
and self.ruleset and self.ruleset.enemyUnlimitedPP
if not isContinuation and not moveInst.struggle and not isCalled
and not enemyUnlimited then
moveInst.pp = math.max(0, moveInst.pp - 1) moveInst.pp = math.max(0, moveInst.pp - 1)
end end
@@ -2762,57 +2574,56 @@ function BattleState:enemyMonFainted()
self.participants = {} self.participants = {}
if self.kind == "trainer" then if self.kind == "trainer" then
-- EnemySendOutFirstMon / AnyEnemyPokemonAliveCheck (core.asm): scan if self.enemyIndex < #self.enemyParty then
-- the whole enemy party for the first mon with HP left. Blindly self.enemyIndex = self.enemyIndex + 1
-- doing enemyIndex+1 softlocks after an AI switch (Agatha): a later -- SHIFT battle style (the default): announce the next mon and
-- slot can already be fainted, so the empty-HP mon comes out, the -- offer a free switch (SET skips the prompt)
-- FIGHT menu returns, and executeAction no-ops on target.hp <= 0.
local nextIndex
for i, mon in ipairs(self.enemyParty) do
if mon.hp > 0 then
nextIndex = i
break
end
end
if nextIndex then
self.enemyIndex = nextIndex
-- EnemySendOutFirstMon (core.asm:1366-1443): SHIFT offers a free
-- switch when party count > 1, the active mon is alive, and the
-- battle-style bit is clear. pokered counts party slots (not
-- remaining HP); SET / single-mon / fainted active skip the prompt.
local nextMon = self.enemyParty[self.enemyIndex] local nextMon = self.enemyParty[self.enemyIndex]
local nextName = nextMon.nickname or self.data.pokemon[nextMon.species].name local nextName = nextMon.nickname or self.data.pokemon[nextMon.species].name
local style = tostring((self.game.save.options or {}).battleStyle or "shift") local style = (self.game.save.options or {}).battleStyle or "shift"
:lower() local healthy = 0
local partyCount = #self.game.save.party for _, mon in ipairs(self.game.save.party) do
-- SwitchPlayerMon runs AFTER TrainerSentOutText (core.asm:1436-1443) if mon.hp > 0 then healthy = healthy + 1 end
local shiftSwitchMon = nil end
if style ~= "set" and partyCount > 1 and self.player.mon.hp > 0 then if style ~= "set" and healthy > 1 and self.player.mon.hp > 0 then
-- _TrainerAboutToUseText: "X is" / "about to use" then cont nick, self:say(("%s is\nabout to use\n%s!"):format(self.trainer.name, nextName))
-- then para "Will PLAYER" / "change POKéMON?" with YES/NO. self:say(("Will %s\nchange POKéMON?"):format(self.game.save.player.name))
self:say(("%s is\nabout to use"):format(self.trainer.name))
self:say(("%s!"):format(nextName))
self:sayChoice(
("Will %s\nchange POKéMON?"):format(self.game.save.player.name),
function(yes)
if not yes then return end
local game = self.game local game = self.game
self:ui(function()
local ChoiceBox = require("src.ui.ChoiceBox")
return ChoiceBox.new(game, function(yes)
if not yes then return end
Screens.push(game, "PartyMenu", { Screens.push(game, "PartyMenu", {
battle = self, battle = self,
forceSwitch = true,
onSwitch = function(mon) onSwitch = function(mon)
if mon ~= self.player.mon and mon.hp > 0 then if mon ~= self.player.mon and mon.hp > 0 then
shiftSwitchMon = mon local previous = self.player
self.player = makeBattler(self.data, mon, true, game.save)
self:syncSides()
Runtime.emit("battle.battler_switched", {
battle = self, side = self.sides[1],
battler = self.player, previous = previous,
})
self:markParticipant()
self.nextInsert = 0
self.sendingOut = true
self:sayNext(self:sendOutText(self.player.name))
self:animNext("POOF_ANIM", false)
self:actNext(function()
self.sendingOut = false
-- SendOutMon (core.asm:1757-1762): poof, then the grow-in
self:startGrowIn(self.player)
require("src.core.Sound").playCry(self.data, self.player.mon.species)
end)
end end
end, end,
}) })
end) end)
end)
end end
self:act(function() self:act(function()
local previous = self.enemy local previous = self.enemy
self.enemy = makeBattler(self.data, self.enemyParty[self.enemyIndex], false) self.enemy = makeBattler(self.data, self.enemyParty[self.enemyIndex], false)
-- EnemySendOutFirstMon (core.asm:1314-1315): clears player's trap
clearTrapping(self.player)
self:syncSides() self:syncSides()
Runtime.emit("battle.battler_switched", { Runtime.emit("battle.battler_switched", {
battle = self, side = self.sides[2], battler = self.enemy, battle = self, side = self.sides[2], battler = self.enemy,
@@ -2835,29 +2646,6 @@ function BattleState:enemyMonFainted()
end) end)
end) end)
end) end)
-- SwitchPlayerMon after the enemy is out (core.asm:1436-1443)
self:act(function()
local mon = shiftSwitchMon
if not mon then return end
local previous = self.player
self.player = makeBattler(self.data, mon, true, self.game.save)
clearTrapping(self.enemy)
self:syncSides()
Runtime.emit("battle.battler_switched", {
battle = self, side = self.sides[1],
battler = self.player, previous = previous,
})
self:markParticipant()
self.nextInsert = 0
self.sendingOut = true
self:sayNext(self:sendOutText(self.player.name))
self:animNext("POOF_ANIM", false)
self:actNext(function()
self.sendingOut = false
self:startGrowIn(self.player)
require("src.core.Sound").playCry(self.data, self.player.mon.species)
end)
end)
return return
end end
local prize = (self.trainer.baseMoney or 0) * self.enemy.mon.level local prize = (self.trainer.baseMoney or 0) * self.enemy.mon.level
@@ -2981,8 +2769,6 @@ function BattleState:openReplacementMenu()
self:ui(function() self:ui(function()
return self:buildScreen("PartyMenu", { return self:buildScreen("PartyMenu", {
battle = self, battle = self,
-- ChooseNextMon: pick immediately (no SWITCH/STATS/CANCEL)
forceSwitch = true,
onSwitch = function(mon) onSwitch = function(mon)
if mon.hp <= 0 then if mon.hp <= 0 then
self:say("There's no will\nto fight!") self:say("There's no will\nto fight!")
@@ -2991,7 +2777,6 @@ function BattleState:openReplacementMenu()
self:restoreMimicked(self.player) self:restoreMimicked(self.player)
local previous = self.player local previous = self.player
self.player = makeBattler(self.data, mon, true, game.save) self.player = makeBattler(self.data, mon, true, game.save)
clearTrapping(self.enemy) -- SendOutMon clears foe trap
self:syncSides() self:syncSides()
Runtime.emit("battle.battler_switched", { Runtime.emit("battle.battler_switched", {
battle = self, side = self.sides[1], battler = self.player, battle = self, side = self.sides[1], battler = self.player,
@@ -3193,9 +2978,6 @@ end
-- called by BagMenu after an item is used in battle (consumes the turn) -- called by BagMenu after an item is used in battle (consumes the turn)
function BattleState:itemUsed(messages) function BattleState:itemUsed(messages)
-- bag cures clear mon.status before the message UI; refresh the HUD
-- once control returns (pokered DrawHUDsAndHPBars after item use)
self:syncShownStatus()
for _, m in ipairs(messages or {}) do self:say(m) end for _, m in ipairs(messages or {}) do self:say(m) end
table.insert(self.queue, { drain = true }) -- potions animate the bar table.insert(self.queue, { drain = true }) -- potions animate the bar
self:act(function() self:act(function()
@@ -3217,39 +2999,10 @@ function BattleState:ballMissMessage(shakes)
return t._ItemUseBallText04 or "Shoot! It was so\nclose too!" return t._ItemUseBallText04 or "Shoot! It was so\nclose too!"
end end
-- AskName (engine/menus/naming_screen.asm): ClearSprites, wild field blank,
-- PrintText, YES/NO while text stays (TextBox opts.choice). Shared by party
-- AddPartyMon and SendNewMonToBox (#172).
function BattleState:askNicknameUI(mon, displayName)
local game = self.game
self.lockedBall = nil
self.blankForAskName = true
local TextBox = require("src.render.TextBox")
local text = ("Do you want to\ngive a nickname\nto %s?"):format(displayName)
local label = game.data.text and game.data.text._DoYouWantToNicknameText
if label then
-- extractor CONT is \t; TextBox scrolls on \n/\v
text = label:gsub("\t", "\n"):gsub("{RAM:?[%w_]*}", displayName)
end
return TextBox.new(game, text, nil, {
choice = function(yes)
self.blankForAskName = false
if not yes then return end
pcall(Screens.push, game, "NamingScreen", {
title = "NICKNAME?", maxLen = 10,
onDone = function(name)
if name and #name > 0 then mon.nickname = name end
end,
})
end,
})
end
-- The caught mon joins the party or a PC box (ItemUseBall .captured, -- The caught mon joins the party or a PC box (ItemUseBall .captured,
-- item_effects.asm:518-566): the caught text, then for a NEW species -- item_effects.asm:518-566): the caught text, then for a NEW species
-- "New POKéDEX data will be added" + the dex entry page, then -- "New POKéDEX data will be added" + the dex entry page, then the
-- AddPartyMon or SendNewMonToBox (both call AskName), then the PC -- party add (with the nickname ask) or the PC transfer text.
-- transfer text when the party was full.
function BattleState:storeCaughtMon() function BattleState:storeCaughtMon()
-- ItemUseBall reloads the caught mon via LoadEnemyMonData -- ItemUseBall reloads the caught mon via LoadEnemyMonData
-- (item_effects.asm:472-501), regenerating its move list from the -- (item_effects.asm:472-501), regenerating its move list from the
@@ -3269,20 +3022,31 @@ function BattleState:storeCaughtMon()
return self:buildScreen("DexEntryMenu", species) return self:buildScreen("DexEntryMenu", species)
end) end)
end end
local function askCaughtNickname() if Party.add(game.save.party, self.enemy.mon) then
-- nickname prompt (AskName runs inside AddPartyMon; box mons are
-- never offered a nickname)
local caught = self.enemy.mon local caught = self.enemy.mon
local enemyName = self.enemy.name local enemyName = self.enemy.name
self:uiNext(function() self:uiNext(function()
return self:askNicknameUI(caught, enemyName) local ChoiceBox = require("src.ui.ChoiceBox")
local TextBox = require("src.render.TextBox")
return TextBox.new(game, ("Do you want to\ngive a nickname\nto %s?")
:format(enemyName), function()
game.stack:push(ChoiceBox.new(game, function(yes)
if not yes then return end
pcall(Screens.push, game, "NamingScreen", {
title = "NICKNAME?", maxLen = 10,
onDone = function(name)
if name and #name > 0 then caught.nickname = name end
end,
})
end))
end)
end) end)
end
if Party.add(game.save.party, self.enemy.mon) then
askCaughtNickname()
else else
destination = "box" destination = "box"
local boxNum = require("src.pokemon.Boxes").deposit(game.save, self.enemy.mon) local boxNum = require("src.pokemon.Boxes").deposit(game.save, self.enemy.mon)
if boxNum then if boxNum then
askCaughtNickname()
-- _ItemUseBallText07/08 keyed on EVENT_MET_BILL -- _ItemUseBallText07/08 keyed on EVENT_MET_BILL
local pc = (game.save.flags and game.save.flags.EVENT_MET_BILL) local pc = (game.save.flags and game.save.flags.EVENT_MET_BILL)
and "BILL's PC" or "someone's PC" and "BILL's PC" or "someone's PC"
@@ -3533,15 +3297,12 @@ function BattleState:fxFaintActive(battler)
and fx.faint.frames > 0 or false and fx.faint.frames > 0 or false
end end
-- vertical slide offset for a fainting battler. The offset is in screen -- vertical slide offset for a fainting battler (the player's pic is
-- pixels, so it scales with the pic's draw scale (the player's default 2x -- drawn 2x, so it slides 2x as fast to sink at the same visual rate)
-- sinks 2x as fast to sink at the same visual rate); a mod scale composes function BattleState:fxFaintOffset(battler)
-- the same way. scale defaults to the vanilla side scale when unknown.
function BattleState:fxFaintOffset(battler, scale)
local fx = self.fx local fx = self.fx
if self:fxFaintActive(battler) then if self:fxFaintActive(battler) then
scale = scale or (battler.isPlayer and 2 or 1) return (30 - fx.faint.frames) * 2 * (battler.isPlayer and 2 or 1)
return (30 - fx.faint.frames) * 2 * scale
end end
return 0 return 0
end end
@@ -3637,7 +3398,7 @@ function BattleState:drawBattlerPic(battler, x, y, scale)
return return
end end
if self:fxFaintActive(battler) then if self:fxFaintActive(battler) then
local off = self:fxFaintOffset(battler, scale) local off = self:fxFaintOffset(battler)
local visible = img:getHeight() - math.floor(off / scale) local visible = img:getHeight() - math.floor(off / scale)
if visible > 0 then if visible > 0 then
local quad = love.graphics.newQuad(0, 0, img:getWidth(), visible, local quad = love.graphics.newQuad(0, 0, img:getWidth(), visible,
@@ -3713,16 +3474,17 @@ function BattleState:drawBattlerPic(battler, x, y, scale)
love.graphics.draw(img, quad, x + ox, y + oy, 0, scale, scale) love.graphics.draw(img, quad, x + ox, y + oy, 0, scale, scale)
end end
elseif k == "slideUp" then elseif k == "slideUp" then
-- AnimationSlideMonUp (animations.asm): 7 row steps x 2f. After Dig's -- AnimationSlideMonUp: cyclic upward wrap, one row per 2 frames
-- SLIDE_DOWN the tilemap is blank; each step fills the next bottom local scroll = 8 * math.min(7, math.floor(t / 2) + 1)
-- row so the mon emerges from underground. A cyclic wrap of a full local src = math.floor(scroll / scale) % h
-- pic looked like a bounce at Dig's end (#100). if src == 0 then
local step = math.min(7, math.floor((t - 1) / 2) + 1) love.graphics.draw(img, x + ox, y, 0, scale, scale)
local visible = math.floor(h * step / 7) else
if visible > 0 then local top = love.graphics.newQuad(0, src, w, h - src, w, h)
local quad = love.graphics.newQuad(0, h - visible, w, visible, w, h) love.graphics.draw(img, top, x + ox, y, 0, scale, scale)
love.graphics.draw(img, quad, x + ox, local bottom = love.graphics.newQuad(0, 0, w, src, w, h)
y + (h - visible) * scale, 0, scale, scale) love.graphics.draw(img, bottom, x + ox, y + (h - src) * scale,
0, scale, scale)
end end
elseif xscale < 1 then elseif xscale < 1 then
-- AnimationSquishMonPic: columns collapse toward the middle -- AnimationSquishMonPic: columns collapse toward the middle
@@ -3934,77 +3696,6 @@ function BattleState:drawAnimLayer(colorized)
end end
end end
-- ------------------------------------------------------------------
-- Mod-facing battle sprite scaling. The enemy front pic draws at 1x and
-- the player back pic at 2x on the GB; a mod can override either per
-- species (pokemon.battleScaleFront / battleScaleBack) or per image path
-- (the battle_sprite_scales registry, which is the only handle on the
-- non-species pics like the trainer back). These resolvers and the
-- placement math are pure (no love.*) so the grounding contract -- feet
-- pinned at any scale -- is unit-tested directly.
-- ------------------------------------------------------------------
-- the vanilla scale for a side: enemy front 1x, player back 2x
BattleState.BATTLE_SCALE_DEFAULT = { front = 1, back = 2 }
-- image-level override for an asset path, or nil. scales is the merged
-- data.battle_sprite_scales table (record id -> { path, scale }).
function BattleState.imageBattleScale(scales, path)
if not scales or not path then return nil end
for id, rec in pairs(scales) do
if id ~= "_owners" and type(rec) == "table" and rec.path == path then
return rec.scale
end
end
return nil
end
-- effective battle scale for a pic: image-level override, else the
-- species-level override for the side, else the side default. side is
-- "front" (enemy) or "back" (player); species may be nil (a non-species
-- pic like the trainer back, which only image-level scaling reaches).
function BattleState.resolveBattleScale(data, side, path, species)
local img = data and BattleState.imageBattleScale(data.battle_sprite_scales, path)
if img then return img end
local def = species and data and data.pokemon and data.pokemon[species]
local field = side == "back" and "battleScaleBack" or "battleScaleFront"
local override = def and def[field]
if override then return override end
return BattleState.BATTLE_SCALE_DEFAULT[side] or 1
end
-- Player (back) placement: feet flush on the text-box top (y=96) at any
-- scale, with the left transparent columns pulled back so opaque pixels
-- land where hardware's white-on-white columns left them. Returns the
-- top-left x, y and the scale (slide/shake offsets are added by the
-- caller). Feet stay at 96 for every scale: y + (h - pad) * scale == 96.
function BattleState.backPlacement(w, h, pad, padL, scale)
return 8 - padL * scale, 96 - (h - pad) * scale, scale
end
-- Enemy (front) placement: given the s=1 slot origin (ex, ey) from the
-- 7x7 tile layout, keep the bottom edge and horizontal centre pinned as
-- the pic scales -- the same compensation AnimateSendingOutMon's grow
-- uses. Returns top-left x, y and the scale. The bottom edge stays put
-- for every scale: y + h * scale == ey + h.
function BattleState.frontPlacement(ex, ey, w, h, scale)
return ex + w * (1 - scale) / 2, ey + h * (1 - scale), scale
end
-- Front/trainer pics: LoadUncompressedSpriteData centers the sprite in
-- a 7x7 tile buffer, then CopyUncompressedPicToTilemap places that
-- buffer at hlcoord 12,0. Horizontal pad is floor((8-w)/2) tiles;
-- vertical pad is (7-h) -- bottom-aligned inside the 7x7.
local function enemyPicXY(img, slide, sx, sy)
local tw = math.floor(img:getWidth() / 8)
local th = math.floor(img:getHeight() / 8)
if tw < 1 then tw = 1 elseif tw > 7 then tw = 7 end
if th < 1 then th = 1 elseif th > 7 then th = 7 end
local hPad = math.floor((8 - tw) / 2)
local vPad = 7 - th
return 96 + 8 * hPad - slide + sx, 8 * vPad + sy
end
-- the two mon pics (or the trainer/back pics), offset by the window -- the two mon pics (or the trainer/back pics), offset by the window
-- shake -- on the GB the pics are BG tiles, so they move with it -- shake -- on the GB the pics are BG tiles, so they move with it
function BattleState:drawPicsLayer(slide, sx, sy) function BattleState:drawPicsLayer(slide, sx, sy)
@@ -4022,81 +3713,60 @@ function BattleState:drawPicsLayer(slide, sx, sy)
g.intersectScissor(0, 0, 160, clipY) g.intersectScissor(0, 0, 160, clipY)
clipped = true clipped = true
end end
-- Enemy: front sprite in the 7x7 slot at hlcoord 12,0. -- Enemy: front sprite top-right (GB: pic at hlcoord 12,0).
if self.showEnemyTrainer and self.trainerPic then if self.showEnemyTrainer and self.trainerPic then
-- the enemy trainer pic holds the mon slot until the send-out -- the enemy trainer pic holds the mon slot until the send-out
local img = self:picImage(self.trainerPic) local img = self:picImage(self.trainerPic)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
local ex, ey = enemyPicXY(img, slide, sx, sy) love.graphics.draw(img, 160 - 8 - img:getWidth() - slide + sx,
love.graphics.draw(img, ex, ey) math.max(0, 48 - img:getHeight()) + sy)
elseif self.enemy and self.enemy.sprite and not self.enemyHidden elseif self.enemy and self.enemy.sprite and not self.enemyHidden
and not self.enemySendingOut and not self:fxHidden(self.enemy) then and not self.enemySendingOut and not self:fxHidden(self.enemy) then
local img = self:picImage(self.enemy.sprite) local img = self:picImage(self.enemy.sprite)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
local ex, ey = enemyPicXY(img, slide, sx, sy) local ex = 160 - 8 - img:getWidth() - slide + sx
local s = BattleState.resolveBattleScale(self.data, "front", local ey = math.max(0, 48 - img:getHeight()) + sy
imagePathOf(img), self.enemy.mon and self.enemy.mon.species)
local gs = self:growInScale(self.enemy) local gs = self:growInScale(self.enemy)
if gs then if gs then
-- AnimateSendingOutMon: the downscaled pic keeps its bottom edge -- AnimateSendingOutMon: the downscaled pic keeps its bottom edge
-- and horizontal center pinned to the mon's slot while it grows -- -- and horizontal center pinned to the mon's slot while it grows
-- the mod scale composes multiplicatively with the grow stage if gs > 0 then
local eff = s * gs love.graphics.draw(img, ex + img:getWidth() * (1 - gs) / 2,
if eff > 0 then ey + img:getHeight() * (1 - gs), 0, gs, gs)
local dx, dy = BattleState.frontPlacement(ex, ey,
img:getWidth(), img:getHeight(), eff)
love.graphics.draw(img, dx, dy, 0, eff, eff)
end end
else else
local dx, dy = BattleState.frontPlacement(ex, ey, self:drawBattlerPic(self.enemy, ex, ey, 1)
img:getWidth(), img:getHeight(), s)
self:drawBattlerPic(self.enemy, dx, dy, s)
end end
end end
-- Player: back sprite at hlcoord 1,5 (x=8), 2x like the GB, feet at y=96. -- Player: back sprite bottom-left (2x like the GB, feet near y=100).
-- Left transparent columns (matted white) are pulled back so opaque
-- pixels land where hardware's white-on-white columns left them.
local hidePlayer = self.safari or self.demo local hidePlayer = self.safari or self.demo
if self.showPlayerBack and self.playerBackPic then if self.showPlayerBack and self.playerBackPic then
-- Red's (or the old man's) back pic until "Go!"; it stays up for -- Red's (or the old man's) back pic until "Go!"; it stays up for
-- the whole safari / catch-demo battle like the original -- the whole safari / catch-demo battle like the original
local img = self:picImage(self.playerBackPic) local img = self:picImage(self.playerBackPic)
local pad = imagePadBottom[self.playerBackPic] or 0 local pad = imagePadBottom[self.playerBackPic] or 0
local padL = imagePadLeft[self.playerBackPic] or 0
-- the trainer back is a bare pic, not species-keyed, so only an
-- image-level battle_sprite_scales entry can rescale it
local s = BattleState.resolveBattleScale(self.data, "back",
imagePathOf(self.playerBackPic), nil)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
local dx, dy = BattleState.backPlacement(img:getWidth(), img:getHeight(), love.graphics.draw(img, 16 + slide + sx,
pad, padL, s) 96 - (img:getHeight() - pad) * 2 + sy, 0, 2, 2)
love.graphics.draw(img, dx + slide + sx, dy + sy, 0, s, s)
elseif self.player and self.player.sprite and not hidePlayer elseif self.player and self.player.sprite and not hidePlayer
and not self.sendingOut and not self:fxHidden(self.player) then and not self.sendingOut and not self:fxHidden(self.player) then
local img = self:picImage(self.player.sprite) local img = self:picImage(self.player.sprite)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
-- feet flush on the text box top (y=96), ignoring baked-in padding -- feet flush on the text box top (y=96), ignoring baked-in padding
local pad = imagePadBottom[self.player.sprite] or 0 local pad = imagePadBottom[self.player.sprite] or 0
local padL = imagePadLeft[self.player.sprite] or 0
local s = BattleState.resolveBattleScale(self.data, "back",
imagePathOf(self.player.sprite),
self.player.mon and self.player.mon.species)
local gs = self:growInScale(self.player) local gs = self:growInScale(self.player)
if gs then if gs then
-- the player-side AnimateSendingOutMon grow (after the poof, -- the player-side AnimateSendingOutMon grow (after the poof,
-- core.asm:1757-1762): feet pinned at y=96, horizontal centre -- core.asm:1757-1762): feet pinned at y=96, center at x=16+w
-- pinned, mod scale composed with the grow stage if gs > 0 then
local eff = s * gs love.graphics.draw(img, 16 + img:getWidth() * (1 - gs) + sx,
if eff > 0 then 96 - (img:getHeight() - pad) * 2 * gs + sy,
love.graphics.draw(img, 0, 2 * gs, 2 * gs)
8 - padL * s + img:getWidth() * s * (1 - gs) / 2 + sx,
96 - (img:getHeight() - pad) * eff + sy, 0, eff, eff)
end end
else else
local dx, dy = BattleState.backPlacement(img:getWidth(), self:drawBattlerPic(self.player, 16 + sx,
img:getHeight(), pad, padL, s) 96 - (img:getHeight() - pad) * 2 + sy, 2)
self:drawBattlerPic(self.player, dx + sx, dy + sy, s)
end end
end end
if clipped then if clipped then
@@ -4117,11 +3787,8 @@ function BattleState:drawHUDs(slide)
local barData = self:colorMode() and {} or self.data -- gray fill when zoned local barData = self:colorMode() and {} or self.data -- gray fill when zoned
local fx = self.fx local fx = self.fx
local hudShake = (fx and fx.hudShakeX) or 0 local hudShake = (fx and fx.hudShakeX) or 0
-- FaintEnemyPokemon clears the enemy HUD area; it stays blank through
-- TrainerAboutToUseText until DrawEnemyHUDAndHPBar after the next send-out
if self.enemy and not self.showEnemyTrainer and not self.enemySendingOut if self.enemy and not self.showEnemyTrainer and not self.enemySendingOut
and not self:growInScale(self.enemy) and slide == 0 and not self:growInScale(self.enemy) and slide == 0 then
and not self.enemy.fainted then
-- enemy HUD (DrawEnemyHUDAndHPBar): name row 0, <LV>+level (4,1), -- enemy HUD (DrawEnemyHUDAndHPBar): name row 0, <LV>+level (4,1),
-- HP bar (2,2) with the vertical tick at (1,2), underline row 3; -- HP bar (2,2) with the vertical tick at (1,2), underline row 3;
-- AnimationShakeEnemyHUD nudges just this block via SCX -- AnimationShakeEnemyHUD nudges just this block via SCX
@@ -4131,8 +3798,8 @@ function BattleState:drawHUDs(slide)
end end
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
Font.draw(self.enemy.name, nameX(1, self.enemy.name), 0) Font.draw(self.enemy.name, nameX(1, self.enemy.name), 0)
if self.enemy.shownStatus then if self.enemy.mon.status then
Font.draw(self:statusLabel({ status = self.enemy.shownStatus }), 40, 8) Font.draw(self:statusLabel(self.enemy.mon), 40, 8)
else else
hudTile(0x6E, 32, 8) -- <LV> hudTile(0x6E, 32, 8) -- <LV>
Font.draw(tostring(self.enemy.mon.level), 40, 8) Font.draw(tostring(self.enemy.mon.level), 40, 8)
@@ -4153,22 +3820,16 @@ function BattleState:drawHUDs(slide)
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
Font.draw(("BALLx%2d"):format(self.safari.balls), 88, 72) Font.draw(("BALLx%2d"):format(self.safari.balls), 88, 72)
end end
-- trainer/link party pokeball rows during the intro -- trainer-battle party pokeball rows during the intro
-- (SetupPlayerAndEnemyPokeballs, draw_hud_pokeball_gfx.asm) -- (SetupPlayerAndEnemyPokeballs, draw_hud_pokeball_gfx.asm)
local showIntroBalls = slide == 0 and ( if self.kind == "trainer" and (self.showEnemyTrainer or self.showPlayerBack)
(self.kind == "trainer" and (self.showEnemyTrainer or self.showPlayerBack)) and slide == 0 then
or (self.kind == "link" and (self.showPlayerBack or self.enemySendingOut))
)
if showIntroBalls then
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
if self.enemyParty and ( if self.showEnemyTrainer and self.enemyParty then
(self.kind == "trainer" and self.showEnemyTrainer)
or (self.kind == "link" and self.enemySendingOut)
) then
self:drawBallRow(self.enemyParty, 64, 16, -8) self:drawBallRow(self.enemyParty, 64, 16, -8)
end end
if self.showPlayerBack then if self.showPlayerBack then
self:drawBallRow(self.playerParty or self.game.save.party, 88, 80, 8) self:drawBallRow(self.game.save.party, 88, 80, 8)
end end
end end
local hidePlayer = self.safari or self.demo local hidePlayer = self.safari or self.demo
@@ -4179,8 +3840,8 @@ function BattleState:drawHUDs(slide)
-- the tick at (18,10) and the triangle at (9,11) -- the tick at (18,10) and the triangle at (9,11)
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
Font.draw(self.player.name, nameX(10, self.player.name), 56) Font.draw(self.player.name, nameX(10, self.player.name), 56)
if self.player.shownStatus then if self.player.mon.status then
Font.draw(self:statusLabel({ status = self.player.shownStatus }), 120, 64) Font.draw(self:statusLabel(self.player.mon), 120, 64)
else else
hudTile(0x6E, 112, 64) -- <LV> hudTile(0x6E, 112, 64) -- <LV>
Font.draw(tostring(self.player.mon.level), 120, 64) Font.draw(tostring(self.player.mon.level), 120, 64)
@@ -4202,8 +3863,7 @@ function BattleState:drawTextArea()
if self.phase == "messages" and self.current then if self.phase == "messages" and self.current then
local shown = 0 local shown = 0
for li, codes in ipairs(self.lines) do for li, codes in ipairs(self.lines) do
-- battle text uses every other tile row (hlcoord *,14 / *,16) local y = 104 + li * 8
local y = 112 + (li - 1) * 16
for i = 1, #codes do for i = 1, #codes do
if shown >= self.charIndex then break end if shown >= self.charIndex then break end
Font.drawCode(codes[i], 8 + (i - 1) * 8, y) Font.drawCode(codes[i], 8 + (i - 1) * 8, y)
@@ -4286,13 +3946,6 @@ function BattleState:drawTextArea()
end end
function BattleState:draw() function BattleState:draw()
-- AskName: ClearSprites + wild ClearScreenArea — white field under the
-- nickname TextBox / YES/NO (naming_screen.asm); overlays draw on top.
if self.blankForAskName then
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
return
end
local fx = self.fx local fx = self.fx
-- window shakes (SE_SHAKE_SCREEN / the enemy-hit vertical shake); -- window shakes (SE_SHAKE_SCREEN / the enemy-hit vertical shake);
-- the animations-off fallback keeps the old +-2 alternation -- the animations-off fallback keeps the old +-2 alternation
+27 -36
View File
@@ -176,51 +176,25 @@ function EffectRegistry.runDamaging(battle, ctx, record)
end end
battle.lastDamage = dmg -- wDamage (shared by both sides, read by Counter) battle.lastDamage = dmg -- wDamage (shared by both sides, read by Counter)
-- the hit blink + damage sound ride each animation row, placed BEFORE -- the hit blink + damage sound ride the queue behind the animation:
-- that hit's drain so the blink precedes the bar. Multi-hit moves -- on the move's anim row when one was announced, else on a bare hit
-- replay PlayMoveAnimation per strike (pokered: GetPlayerAnimationType -- row (thrash/rage continuations), placed BEFORE the drain rows the
-- / GetEnemyAnimationType loop on wNumAttacksLeft); hit 1 reuses the -- hits loop inserts so the blink precedes the bar drain
-- announcement-time moveAnimRow, later hits queue fresh anim rows. local hitRow = battle.moveAnimRow
-- Thrash/rage continuations have no announcement anim -- a bare
-- hitRow carries the blink instead.
local hitSfx = info.typeMult > 10 and "Super_Effective"
or info.typeMult < 10 and "Not_Very_Effective" or "Damage"
local hitFx = { sfx = hitSfx,
blink = battle:animationsOn() and target or nil }
local totalDealt = 0
local landed, brokeSub = 0, false
for h = 1, hits do
if target.mon.hp <= 0 then break end
local hitRow
if h == 1 then
hitRow = battle.moveAnimRow
if not hitRow then if not hitRow then
battle.nextInsert = (battle.nextInsert or 0) + 1 battle.nextInsert = (battle.nextInsert or 0) + 1
hitRow = { hitRow = true } hitRow = { hitRow = true }
table.insert(battle.queue, battle.nextInsert, hitRow) table.insert(battle.queue, battle.nextInsert, hitRow)
end end
else
battle.nextInsert = (battle.nextInsert or 0) + 1 local totalDealt = 0
hitRow = { anim = move.id, attackerIsPlayer = user.isPlayer } local landed, brokeSub = 0, false
table.insert(battle.queue, battle.nextInsert, hitRow) for h = 1, hits do
end if target.mon.hp <= 0 then break end
local hadSub = target.substituteHP ~= nil local hadSub = target.substituteHP ~= nil
local dealt = battle:applyDamage(target, dmg) local dealt = battle:applyDamage(target, dmg)
totalDealt = totalDealt + dealt totalDealt = totalDealt + dealt
landed = h landed = h
if dealt > 0 then hitRow.hit = hitFx end
-- PrintCriticalOHKOText + DisplayEffectiveness run inside the
-- multi-hit loop (core.asm .moveDidNotMiss before the jump back
-- to GetPlayerAnimationType), so crit/effectiveness reprint on
-- every strike -- damage was only rolled once
if info.crit then battle:sayNext("Critical hit!") end
if info.ohko then battle:sayNext("One-hit KO!") end
if info.typeMult > 10 then
battle:sayNext("It's super\neffective!")
elseif info.typeMult < 10 then
battle:sayNext("It's not very\neffective...")
end
if Runtime.wants("battle.damage_dealt") then if Runtime.wants("battle.damage_dealt") then
Runtime.emit("battle.damage_dealt", { Runtime.emit("battle.damage_dealt", {
battle = battle, user = user, target = target, move = move, battle = battle, user = user, target = target, move = move,
@@ -234,6 +208,23 @@ function EffectRegistry.runDamaging(battle, ctx, record)
end end
end end
hits = landed > 0 and landed or hits hits = landed > 0 and landed or hits
if totalDealt > 0 then
-- the original's per-hit sound: normal / super / not-very-effective
local hitSfx = info.typeMult > 10 and "Super_Effective"
or info.typeMult < 10 and "Not_Very_Effective" or "Damage"
hitRow.hit = { sfx = hitSfx,
blink = battle:animationsOn() and target or nil }
end
-- PrintCriticalOHKOText prints "Critical hit!"/"One-hit KO!" right
-- after the damage lands, BEFORE DisplayEffectiveness (core.asm
-- .moveDidNotMiss); the multi-hit count follows the last hit
if info.crit then battle:sayNext("Critical hit!") end
if info.ohko then battle:sayNext("One-hit KO!") end
if info.typeMult > 10 then
battle:sayNext("It's super\neffective!")
elseif info.typeMult < 10 then
battle:sayNext("It's not very\neffective...")
end
if hits > 1 then if hits > 1 then
-- player: _MultiHitText; enemy: _HitXTimesText (always plural) -- player: _MultiHitText; enemy: _HitXTimesText (always plural)
if user.isPlayer then if user.isPlayer then
+2 -7
View File
@@ -15,8 +15,7 @@
-- (super-effective) or adds 1 (not-effective when a better move exists); -- (super-effective) or adds 1 (not-effective when a better move exists);
-- the MINIMUM-scored move is chosen, ties broken uniformly among the -- the MINIMUM-scored move is chosen, ties broken uniformly among the
-- tied minima (core.asm:2971-3002). A non-minimal move is never -- tied minima (core.asm:2971-3002). A non-minimal move is never
-- selectable. Respects Disable (and PP only when the ruleset depletes -- selectable. Respects PP, Disable and Transform/Mimic move overrides.
-- enemy PP — Gen 1 AI never reads wEnemyMonPP).
local TypeChart = require("src.battle.TypeChart") local TypeChart = require("src.battle.TypeChart")
@@ -213,13 +212,9 @@ end
function TrainerAI.chooseMove(battler, rng, battle) function TrainerAI.chooseMove(battler, rng, battle)
rng = rng or love.math.random rng = rng or love.math.random
-- Gen 1: SelectEnemyMove never consults wEnemyMonPP; Struggle only when
-- every move slot is missing/disabled (core.asm:2957-2999). modern_clean
-- depletes enemy PP and falls back to Struggle when none remain.
local unlimited = battle and battle.ruleset and battle.ruleset.enemyUnlimitedPP
local usable = {} local usable = {}
for i, mv in ipairs(battler.curMoves) do for i, mv in ipairs(battler.curMoves) do
if battler.disabledSlot ~= i and (unlimited or mv.pp > 0) then if mv.pp > 0 and battler.disabledSlot ~= i then
table.insert(usable, mv) table.insert(usable, mv)
end end
end end
-3
View File
@@ -13,7 +13,4 @@ return {
randMax = 255, randMax = 255,
-- Focus Energy famously QUARTERS the crit rate instead of x4 -- Focus Energy famously QUARTERS the crit rate instead of x4
focusEnergyBug = true, focusEnergyBug = true,
-- Wild/trainer enemies never spend PP (DecrementPP only touches the
-- player side in pokered). They therefore never Struggle from empty PP.
enemyUnlimitedPP = true,
} }
-2
View File
@@ -9,6 +9,4 @@ return {
randMin = 217, randMin = 217,
randMax = 255, randMax = 255,
focusEnergyBug = false, focusEnergyBug = false,
-- Gen 2+ style: AI opponents deplete PP and can Struggle when empty.
enemyUnlimitedPP = false,
} }
-34
View File
@@ -230,25 +230,6 @@ function ChipAudio.ensureMusicPlaying()
end end
end end
-- Threaded playMusic returns an empty QueueableSource and only calls
-- Source:play once the first worker buffer lands (~1 frame later). Until
-- then Source:isPlaying is false -- callers that treat that as "song over"
-- (Music.oneShotPlaying / pendingRestore) must wait here instead, or a
-- playOnce jingle like Music_PkmnHealed is cut off before it starts.
local forceAwaitingFirstBuffer -- test-only override (see _simulate*)
function ChipAudio.awaitingFirstBuffer()
if forceAwaitingFirstBuffer then return true end
local m = currentMusic
if not (m and m.threaded and not m.started and not m.finished) then
return false
end
-- a dead worker will never deliver the first buffer
if workerReady == false then return false end
if worker and worker.getError and worker:getError() then return false end
return true
end
function ChipAudio.stopMusic() function ChipAudio.stopMusic()
if currentMusic and currentMusic.source then if currentMusic and currentMusic.source then
pcall(currentMusic.source.stop, currentMusic.source) pcall(currentMusic.source.stop, currentMusic.source)
@@ -259,7 +240,6 @@ function ChipAudio.stopMusic()
end end
pendingBuf = nil pendingBuf = nil
currentMusic = nil currentMusic = nil
forceAwaitingFirstBuffer = nil
end end
-- hot reload: the next play re-reads programs.bin (a mod may have swapped the -- hot reload: the next play re-reads programs.bin (a mod may have swapped the
@@ -322,20 +302,6 @@ end
-- test hooks (headless): synchronous synthesis straight through ChipSynth -- test hooks (headless): synchronous synthesis straight through ChipSynth
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- Force the "threaded, first buffer not yet queued" window so Music's
-- playOnce / pendingRestore race can be asserted without love.thread.
-- Returns a clear() that drops the override (call after the assertion).
function ChipAudio._simulateAwaitingFirstBufferForTest()
local m = currentMusic
if not m or not m.source then return nil end
m.threaded = true
m.started = false
m.finished = false
pcall(function() m.source.playing = false end)
forceAwaitingFirstBuffer = true
return function() forceAwaitingFirstBuffer = nil end
end
function ChipAudio._renderMusicForTest(data, header, seconds) function ChipAudio._renderMusicForTest(data, header, seconds)
local engine = ChipSynth.newEngine(data, header, { allowLoops = true }) local engine = ChipSynth.newEngine(data, header, { allowLoops = true })
return ChipSynth.soundData(engine, math.floor(seconds * SAMPLE_RATE), 2) return ChipSynth.soundData(engine, math.floor(seconds * SAMPLE_RATE), 2)
-2
View File
@@ -102,8 +102,6 @@ function Data:seedDefaults()
-- after-battle rows so Blaine's SetEventRange deactivation and talk -- after-battle rows so Blaine's SetEventRange deactivation and talk
-- after-text work like the other gyms (scripts/CinnabarGym.asm). -- after-text work like the other gyms (scripts/CinnabarGym.asm).
self:seedCinnabarGymTrainerHeaders() self:seedCinnabarGymTrainerHeaders()
-- #189: 1F cabin door order vs rooms map (survey zoom)
require("src.world.SsAnneLayout").apply(self.maps)
end end
function Data:seedCinnabarGymTrainerHeaders() function Data:seedCinnabarGymTrainerHeaders()
+2 -132
View File
@@ -38,18 +38,8 @@ local state = {
unsubs = {}, unsubs = {},
pid = nil, pid = nil,
loggedAbsent = false, -- one quiet note when Discord isn't running loggedAbsent = false, -- one quiet note when Discord isn't running
game = nil, -- kept for the "friend clicked join" -> push a screen path
joinCode = nil, -- the online-match/tournament code to advertise as a join secret, if any
joinKind = "match", -- "match" (LinkState online) or "tournament"
partySize = 1, -- current headcount for the party.size Discord shows
partyMax = 2,
subscribedJoin = false, -- SUBSCRIBE ACTIVITY_JOIN sent on this connection?
} }
-- MSG_DONTWAIT differs by OS (Darwin 0x80, Linux 0x40); only used for the
-- ongoing per-frame inbound poll, never for the one-time connect/handshake
local MSG_DONTWAIT_BY_OS = { ["OS X"] = 0x80, Linux = 0x40 }
local function disabledByEnv() local function disabledByEnv()
return os.getenv("POKEPORT_NO_DISCORD") == "1" return os.getenv("POKEPORT_NO_DISCORD") == "1"
or os.getenv("POKEPORT_AUTOPILOT") ~= nil or os.getenv("POKEPORT_AUTOPILOT") ~= nil
@@ -388,15 +378,6 @@ local function connect()
state.dirty = true state.dirty = true
state.lastSentKey = nil state.lastSentKey = nil
state.loggedAbsent = false state.loggedAbsent = false
state.subscribedJoin = false
-- ask for "friend clicked join" dispatches; a plain outbound write, so
-- it's harmless to send even on Windows, where the inbound poll below
-- isn't implemented yet (see pollJoinRequest)
if sendFrame(OP_FRAME, Json.encode({
cmd = "SUBSCRIBE", evt = "ACTIVITY_JOIN", nonce = nonce(),
})) then
state.subscribedJoin = true
end
Logger.info("discord: rich presence connected") Logger.info("discord: rich presence connected")
return true return true
end) end)
@@ -446,7 +427,7 @@ local function buildActivity()
activityState = "At the title screen" activityState = "At the title screen"
end end
local activity = { return {
details = details, details = details,
state = activityState, state = activityState,
timestamps = { start = state.startedAt }, timestamps = { start = state.startedAt },
@@ -455,25 +436,10 @@ local function buildActivity()
large_text = "Pokemon Gen1Recomp", large_text = "Pokemon Gen1Recomp",
}, },
} }
-- a party/join-secret pair is what makes Discord show "Ask to Join" on
-- the profile card; the secret carries our own room/tournament code plus
-- a one-letter kind tag ("m"/"t") so the click handler on the other end
-- knows which screen to jump into -- Discord only echoes the string
-- back, so the kind has to ride inside it
if state.joinCode then
local kindTag = state.joinKind == "tournament" and "t" or "m"
activity.party = { id = "pokeport-" .. state.joinCode,
size = { state.partySize, state.partyMax } }
activity.secrets = { join = "join:" .. kindTag .. ":" .. state.joinCode }
end
return activity
end end
local function activityKey(activity) local function activityKey(activity)
local partyKey = activity.party return (activity.details or "") .. "|" .. (activity.state or "")
and (activity.party.size[1] .. "/" .. activity.party.size[2]) or ""
return (activity.details or "") .. "|" .. (activity.state or "") .. "|"
.. (activity.secrets and activity.secrets.join or "") .. "|" .. partyKey
end end
local function flush(force) local function flush(force)
@@ -601,96 +567,6 @@ local function subscribe(game)
end) end)
end end
-- Advertise (or clear, with code=nil) an online-match/tournament code as a
-- Discord join secret. LinkState/Tournament call this once they have a
-- real code from the relay, and clear it again once paired/started or the
-- hosting screen exits (an invite that's already full or gone is worse
-- than no invite). kind is "match" (default) or "tournament"; size/max are
-- the party.size Discord shows (default 1/2, a plain 1v1 room) -- a
-- tournament passes its live roster count and a generous cap instead, and
-- should call this again whenever the roster changes, not just once.
function DiscordPresence.setJoinCode(code, kind, size, max)
pcall(function()
state.joinCode = code
state.joinKind = kind or "match"
state.partySize = size or 1
state.partyMax = max or 2
state.dirty = true
flush(false)
end)
end
-- A friend clicked "Ask to Join" on our profile and Discord delivered the
-- secret back over this same connection. Only act from a safe spot (never
-- yank the player out of a battle or an already-running link session) --
-- "exploring"/"menu" are the coarse buckets subscribe() above already
-- tracks; anything more specific (a shop, a dialogue box) is a rarer miss
-- worth accepting rather than adding a second, finer-grained state tracker
-- just for this.
local function handleJoinRequest(secret)
if not secret or secret == "" then return end
if state.activity == "battle" then return end
local game = state.game
if not game or not game.stack then return end
local top = game.stack:top()
if top and top.stage and top.net then return end -- already in a link session
local kindTag, code = secret:match("^(%a):(.+)$")
if not kindTag then kindTag, code = "m", secret end -- older/plain secret: assume match
Runtime.emit("discord.join_requested", { code = code, kind = kindTag })
if kindTag == "t" then
local ok, Tournament = pcall(require, "src.link.Tournament")
if ok and Tournament.newJoinOnline then
game.stack:push(Tournament.newJoinOnline(game, code))
end
else
local ok, LinkState = pcall(require, "src.link.LinkState")
if ok and LinkState.newJoinOnline then
game.stack:push(LinkState.newJoinOnline(game, code))
end
end
end
-- non-blocking peek for an incoming ACTIVITY_JOIN dispatch. Unix (FFI)
-- only for now: reading a Windows named pipe without risking a stall needs
-- a HANDLE-level PeekNamedPipe that the plain io.open() connection below
-- doesn't give us, so a Windows player's presence/party/join-secret still
-- all work (they can still be seen and clicked), the click just isn't
-- delivered back to their own game session yet.
local function pollJoinRequest()
if state.isWindows or not state.ffi or not state.socket or not state.subscribedJoin then
return
end
pcall(function()
local ffi = state.ffi
local dontwait = MSG_DONTWAIT_BY_OS[love.system.getOS()] or 0
local hbuf = ffi.new("char[8]")
local n = ffi.C.recv(state.socket, hbuf, 8, dontwait)
if not n or tonumber(n) <= 0 then return end -- nothing waiting right now
local header = ffi.string(hbuf, 8)
local opcode = unpackU32(header:sub(1, 4))
local length = unpackU32(header:sub(5, 8))
if not opcode or not length or length < 0 or length > 65536 then return end
-- the body may lag the header by a packet; the connection's normal
-- (short-timeout) blocking read is fine here since it has already
-- announced its exact length
local body = length > 0 and readExact(length) or ""
if body == nil then return end
if opcode == 3 then -- PING -> PONG, keeps Discord from closing on us
sendFrame(4, body)
return
end
if opcode ~= 1 then return end
local decoded = Json.decode(body)
if decoded and decoded.evt == "ACTIVITY_JOIN" then
local secret = decoded.data and decoded.data.secret
if type(secret) == "string" and secret ~= "" then
if secret:sub(1, 5) == "join:" then secret = secret:sub(6) end
handleJoinRequest(secret)
end
end
end)
end
function DiscordPresence.init(game) function DiscordPresence.init(game)
local ok, err = pcall(function() local ok, err = pcall(function()
DiscordPresence.shutdown() DiscordPresence.shutdown()
@@ -703,8 +579,6 @@ function DiscordPresence.init(game)
state.location = "Title screen" state.location = "Title screen"
state.mapId = nil state.mapId = nil
state.battleLabel = nil state.battleLabel = nil
state.joinCode = nil
state.game = game
state.dirty = true state.dirty = true
state.nextReconnectAt = 0 state.nextReconnectAt = 0
state.loggedAbsent = false state.loggedAbsent = false
@@ -747,7 +621,6 @@ function DiscordPresence.update(_dt)
end end
return return
end end
pollJoinRequest()
if state.dirty then flush(false) end if state.dirty then flush(false) end
end) end)
end end
@@ -776,13 +649,10 @@ function DiscordPresence.shutdown()
state.lastSentKey = nil state.lastSentKey = nil
state.connected = false state.connected = false
state.socket = nil state.socket = nil
state.subscribedJoin = false
state.joinCode = nil
end end
-- test / debug helpers -- test / debug helpers
DiscordPresence._state = state DiscordPresence._state = state
DiscordPresence.locationName = locationName DiscordPresence.locationName = locationName
DiscordPresence.handleJoinRequest = handleJoinRequest
return DiscordPresence return DiscordPresence
-8
View File
@@ -27,12 +27,4 @@ function FixedStep:update(dt)
end end
end end
-- Drop any pending catch-up steps. A hitch inside one logic step (map
-- seam setMap / song start) makes the next real-time dt huge; without this
-- the while-loop above would advance many walk frames before the next
-- draw, which looks like a slide with no leg animation (issue #93).
function FixedStep:discardCatchup()
self.accum = 0
end
return FixedStep return FixedStep
+2 -48
View File
@@ -37,9 +37,6 @@ function Game:load()
self.mods = ModLoader.new() self.mods = ModLoader.new()
self.mods:load(Data) self.mods:load(Data)
self.modStatus = self.mods:status() 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 self.input = Input
Input:init() Input:init()
@@ -201,9 +198,6 @@ function Game:update(dt)
-- Overworld tilt toggle tween: presentational, so it runs on the real -- Overworld tilt toggle tween: presentational, so it runs on the real
-- frame dt (not the fixed logic step) for a smooth ~0.25s glide. -- frame dt (not the fixed logic step) for a smooth ~0.25s glide.
require("src.render.Tilt").update(dt) 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) pcall(function() require("src.core.DiscordPresence").update(dt) end)
-- Steady-state memory backstop: advance the incremental collector one -- Steady-state memory backstop: advance the incremental collector one
-- small step every rendered frame. The heavy GPU objects are now freed -- small step every rendered frame. The heavy GPU objects are now freed
@@ -252,11 +246,7 @@ end
function Game:zoomStep(delta) function Game:zoomStep(delta)
local Zoom = require("src.render.Zoom") local Zoom = require("src.render.Zoom")
if not Zoom.gateOK(self.stack:top(), self.overworld) then return end if not Zoom.gateOK(self.stack:top(), self.overworld) then return end
local offset = Zoom.step(delta, Renderer:fitScale()) Zoom.step(delta, Renderer:fitScale())
if self.save and self.save.options then
self.save.options.zoom = offset
self:writeOptions()
end
end end
function Game:wheelmoved(_, dy) function Game:wheelmoved(_, dy)
@@ -330,34 +320,13 @@ function Game:keypressed(key)
self:writeOptions() self:writeOptions()
end end
return return
elseif key == "4" then
-- cycle ZOOM through every integer level (survey → FIT → close-up → wrap)
local Zoom = require("src.render.Zoom")
if Zoom.gateOK(self.stack:top(), self.overworld) then
self.save.options.zoom = Zoom.cycle(Renderer:fitScale())
self:writeOptions()
end
return
elseif key == "5" then elseif key == "5" then
-- cycle GBC FX OFF → 1 → 2 → 3 → 4 (unlit-GBC ladder); always on -- cycle GBC FX OFF → 1 → 2 → 3 → 4 (unlit-GBC ladder); always on
-- desktop. Mobile refuses the present shader (issue #136).
local GBCFX = require("src.render.GBCFX") local GBCFX = require("src.render.GBCFX")
if not GBCFX.isSupported() then return end
self.save.options.gbcfx = GBCFX.cycle() self.save.options.gbcfx = GBCFX.cycle()
self:writeOptions() self:writeOptions()
return return
end 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) Input:keypressed(key)
end end
@@ -474,20 +443,12 @@ function Game:applyOptions(opts)
if Sound.applyOptions then Sound.applyOptions(opts) end if Sound.applyOptions then Sound.applyOptions(opts) end
require("src.render.PaletteFX").applyOptions(opts) require("src.render.PaletteFX").applyOptions(opts)
require("src.render.Tilt").applyOptions(opts) require("src.render.Tilt").applyOptions(opts)
-- after Tilt, so a persisted world pipeline can switch the tilt level it require("src.render.GBCFX").applyOptions(opts)
-- 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.core.VideoMode").applyOptions(opts) require("src.core.VideoMode").applyOptions(opts)
-- normalizes a nil/garbage cap to the 60 default, so old saves with no -- normalizes a nil/garbage cap to the 60 default, so old saves with no
-- fpsCap key pace at the standard rate (issue #88) -- fpsCap key pace at the standard rate (issue #88)
require("src.core.FrameCap").applyOptions(opts) require("src.core.FrameCap").applyOptions(opts)
Input:applyBindings(opts.bindings) Input:applyBindings(opts.bindings)
-- heal soft-bricked APK installs that already saved gbcfx > 0 (#136)
if gbcCleared then self:writeOptions() end
end end
function Game:restoreSave(loaded, recovered) function Game:restoreSave(loaded, recovered)
@@ -499,13 +460,6 @@ function Game:restoreSave(loaded, recovered)
-- SaveData.load and skip on the format guard -- SaveData.load and skip on the format guard
local activeMods = self.modStatus and self.modStatus.loaded local activeMods = self.modStatus and self.modStatus.loaded
SaveData.runMigrations(loaded, self.mods and self.mods.migrations, activeMods) SaveData.runMigrations(loaded, self.mods and self.mods.migrations, activeMods)
-- Issue #103: 0.1.11 softlocks left CONTINUE in HALL_OF_FAME with
-- lastOutdoor on Indigo. One-shot relocate + heal before validate.
if SaveData.needsPostGameRescue(loaded) then
SaveData.applyPostGameHome(loaded, self:bootConfig())
local Pokemon = require("src.pokemon.Pokemon")
for _, mon in ipairs(loaded.party or {}) do Pokemon.heal(mon) end
end
local modsDiff = SaveData.modsDiff(loaded, activeMods) local modsDiff = SaveData.modsDiff(loaded, activeMods)
local report = SaveData.validate(loaded, self.data) local report = SaveData.validate(loaded, self.data)
report.recovered = recovered report.recovered = recovered
-5
View File
@@ -11,12 +11,7 @@ local DEFAULT_BINDINGS = {
z = "a", ["return"] = "a", space = "a", z = "a", ["return"] = "a", space = "a",
x = "b", backspace = "b", x = "b", backspace = "b",
["kpenter"] = "start", escape = "start", ["kpenter"] = "start", escape = "start",
-- Select: fight-menu move reorder + bag item reorder. Tab is the
-- discoverable default (shown in CONTROLS); both shifts stay as
-- aliases so Right-Shift muscle memory from older builds still works.
tab = "select",
rshift = "select", rshift = "select",
lshift = "select",
} }
-- keys that map to "start" but also to "a" would conflict; keep Enter = a, -- keys that map to "start" but also to "a" would conflict; keep Enter = a,
+8 -18
View File
@@ -254,7 +254,6 @@ function Music.stop()
require("src.core.ChipAudio").stopMusic() require("src.core.ChipAudio").stopMusic()
state.current, state.source, state.loopSource, state.fade = nil, nil, nil, nil state.current, state.source, state.loopSource, state.fade = nil, nil, nil, nil
state.chip = false state.chip = false
state.pendingRestore = nil
if previous and Runtime.wants("music.stopped") then if previous and Runtime.wants("music.stopped") then
Runtime.emit("music.stopped", { song = previous }) Runtime.emit("music.stopped", { song = previous })
end end
@@ -348,25 +347,18 @@ end
function Music.playOnce(data, song) function Music.playOnce(data, song)
if not songDef(data, song) then return false end if not songDef(data, song) then return false end
Music.play(data, song, false, { reason = "once" }) Music.play(data, song, false, { reason = "once" })
-- play() can no-op (hook silence, failed def); only arm restore when
-- the jingle actually became current
if state.current ~= song then return false end
state.pendingRestore = true state.pendingRestore = true
return true return true
end end
local function chipAwaitingFirstBuffer() -- is a playOnce jingle still sounding? (AnimateHealingMachine's
return state.chip -- .waitLoop2 holds the healing machine until MUSIC_PKMN_HEALED ends)
and require("src.core.ChipAudio").awaitingFirstBuffer()
end
-- is a playOnce jingle still in flight? (AnimateHealingMachine's
-- .waitLoop2 / Mom heal / captain rub hold until MUSIC_PKMN_HEALED ends.)
-- pendingRestore stays set from playOnce until Music.update restores the
-- map theme, covering the threaded chip "empty QueueableSource" window
-- where Source:isPlaying is briefly false before the first buffer lands.
function Music.oneShotPlaying() function Music.oneShotPlaying()
return state.pendingRestore == true if not state.pendingRestore then return false end
local src = state.source
if not src then return false end
local ok, playing = pcall(src.isPlaying, src)
return ok and playing or false
end end
function Music.restoreMap(data) function Music.restoreMap(data)
@@ -449,10 +441,8 @@ function Music.update(data)
state.source = loopSrc state.source = loopSrc
pcall(loopSrc.play, loopSrc) pcall(loopSrc.play, loopSrc)
end end
-- do not treat "threaded source still waiting on its first buffer" as
-- ended, or playOnce jingles get restored over before they can sound
if state.pendingRestore and sourceStopped(state.source) if state.pendingRestore and sourceStopped(state.source)
and not state.loopSource and not chipAwaitingFirstBuffer() then and not state.loopSource then
Music.restoreMap(data) Music.restoreMap(data)
end end
end end
+5 -342
View File
@@ -17,7 +17,6 @@ local Runtime = require("src.mods.Runtime")
local Semver = require("src.mods.Semver") local Semver = require("src.mods.Semver")
local Boxes = require("src.pokemon.Boxes") local Boxes = require("src.pokemon.Boxes")
local Bag = require("src.inventory.Bag") local Bag = require("src.inventory.Bag")
local Badges = require("src.inventory.Badges")
local GameVersion = require("src.core.GameVersion") 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 -- 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; -- 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. -- load promotes either when the main file is missing or fails to parse.
-- Forward-declared here so saveFilename (just below) and every save/load local function saveNames(version)
-- caller share the one upvalue; the body is filled in under "save slots" local main = "save" .. GameVersion.saveSuffix(version) .. ".lua"
-- once the options IO it depends on exists, because it now resolves the return main, main .. ".bak", main .. ".tmp"
-- ACTIVE slot for a version rather than the fixed flat name. end
local saveNames
-- The main save filename for a version -- used by the title screen's -- The main save filename for a version -- used by the title screen's
-- CONTINUE gate so it looks for the right game's save. -- CONTINUE gate so it looks for the right game's save.
@@ -93,18 +91,6 @@ local function makePortableFs(dir)
os.remove(full(name)) os.remove(full(name))
return true return true
end, 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 end
@@ -200,23 +186,14 @@ function SaveData.defaultOptions()
musicFilter = 0, musicFilter = 0,
-- logic fast-forward multiplier; audio is unaffected (GameSpeed.lua) -- logic fast-forward multiplier; audio is unaffected (GameSpeed.lua)
speed = 1, speed = 1,
-- port display options (OptionsMenu / hotkeys 2/3/4/5) -- port display options (OptionsMenu / hotkeys 2/3/5)
colors = "gbc", colors = "gbc",
tilt = 0, tilt = 0,
gbcfx = 0, gbcfx = 0,
-- survey zoom offset from window fit scale (0 = FIT); see Zoom.lua
zoom = 0,
-- OVERWORLD beyond-edge fill: trees | water | black
voidFill = "trees",
-- windowed | borderless (desktop fullscreen); ignored on mobile -- windowed | borderless (desktop fullscreen); ignored on mobile
videoMode = "windowed", videoMode = "windowed",
-- hard render frame-rate cap; render-only pacing (issue #88, FrameCap.lua) -- hard render frame-rate cap; render-only pacing (issue #88, FrameCap.lua)
fpsCap = 60, 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. -- Native mod enablement is an installation option, not save-slot data.
-- Missing entries mean enabled so newly installed mods work by default. -- Missing entries mean enabled so newly installed mods work by default.
mods = {}, mods = {},
@@ -302,264 +279,6 @@ function SaveData.loadOptions(fs)
return SaveData.mergeOptions(data) return SaveData.mergeOptions(data)
end 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 -- ------- meta
-- the version/engine/mod-set stamp every v2 save carries; mods is the -- the version/engine/mod-set stamp every v2 save carries; mods is the
@@ -759,25 +478,6 @@ SaveData.addCoreMigration(2, function(save)
end end
end) end)
-- #131 / follow-up to #50: Game Corner poster grunt used to stay on the
-- floor after defeat (defeatedTrainers only). The #50 script now hides
-- him via objectToggles, but saves that already beat him never got the
-- toggle -- he still blocks the hideout switch. from=3 so every pre-4
-- save reconciles once, then is skipped after re-stamp.
SaveData.addCoreMigration(3, function(save)
local defeated = save.defeatedTrainers
if not defeated or not defeated["GAME_CORNER_obj_11"] then return end
save.objectToggles = save.objectToggles or {}
local mapToggles = save.objectToggles.GAME_CORNER
if not mapToggles then
mapToggles = {}
save.objectToggles.GAME_CORNER = mapToggles
end
if mapToggles.GAMECORNER_ROCKET ~= false then
mapToggles.GAMECORNER_ROCKET = false
end
end)
-- ------- write -- ------- write
-- Game progress only; options are written separately via saveOptions. -- Game progress only; options are written separately via saveOptions.
@@ -802,9 +502,6 @@ function SaveData.save(data, mods)
end end
local encoded = SaveSerializer.encode(gameOnly) local encoded = SaveSerializer.encode(gameOnly)
local fs = persistFs(nil) 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 if fs.getInfo(FILENAME) then
local prev = fs.read(FILENAME) local prev = fs.read(FILENAME)
if prev then fs.write(BACKUP_FILENAME, prev) end 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 } return { map = map, x = boot.startX or 3, y = boot.startY or 6 }
end end
-- Post-credits home (issue #103). pokered left the player in HALL_OF_FAME
-- after jp Init; this port places CONTINUE at the NewGameWarp bedroom and
-- retargets LAST_MAP exits (Red's house mats) at the heal-point town so
-- leaving the house does not dump the player back at Indigo Plateau.
-- Marks postGameHomeOk so a later intentional HoF save is not relocated.
function SaveData.applyPostGameHome(save, boot)
boot = type(boot) == "table" and boot or {}
local heal = SaveData.defaultHeal(boot)
save.lastHeal = { map = heal.map, x = heal.x, y = heal.y }
save.lastOutdoor = { id = heal.map, x = heal.x, y = heal.y }
save.player = save.player or {}
save.player.map = boot.startMap or "REDS_HOUSE_2F"
save.player.x = boot.startX or 3
save.player.y = boot.startY or 6
save.player.facing = boot.startFacing or "down"
save.postGameHomeOk = true
return heal
end
-- Softlocked 0.1.11 saves: still standing in HALL_OF_FAME after credits,
-- with lastOutdoor on Indigo. One-shot rescue on CONTINUE.
function SaveData.needsPostGameRescue(save)
if not (save and save.player and save.player.map == "HALL_OF_FAME") then
return false
end
if save.postGameHomeOk then return false end
local hof = save.hallOfFame
return type(hof) == "table" and #hof > 0
end
function SaveData.newGame(boot) function SaveData.newGame(boot)
boot = type(boot) == "table" and boot or {} boot = type(boot) == "table" and boot or {}
local map = boot.startMap or "REDS_HOUSE_2F" local map = boot.startMap or "REDS_HOUSE_2F"
@@ -1169,10 +836,6 @@ function SaveData.newGame(boot)
}, },
flags = {}, flags = {},
inventory = {}, inventory = {},
-- Vanilla Gen1 seeds one Potion in the player's item PC
-- (wBoxItems / players_pc.asm); existing saves keep whatever they
-- already have — this only applies to New Game.
pcItems = { POTION = 1 },
party = {}, party = {},
box = {}, box = {},
money = boot.startMoney or 3000, money = boot.startMoney or 3000,
+1 -1
View File
@@ -48,7 +48,7 @@ local KEY = {
a = "z", a = "z",
b = "x", b = "x",
start = "escape", start = "escape",
select = "tab", select = "rshift",
} }
local function nowMs() local function nowMs()
+3 -11
View File
@@ -4,22 +4,14 @@
-- tests. -- tests.
local Version = { local Version = {
engine = "0.0.0-dev", -- game/engine release (semver). Repo default is the engine = "1.0.0", -- game/engine release (semver triple)
-- "-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.
modApi = 2, -- mod API major (manifest `api`) modApi = 2, -- mod API major (manifest `api`)
linkProtocol = 2, -- link handshake wire version (Handshake.PROTOCOL) linkProtocol = 2, -- link handshake wire version (Handshake.PROTOCOL)
saveFormat = 4, -- save.meta.format saveFormat = 3, -- save.meta.format
cache = "rom-cache-v5", -- ROM import cache generation (RomImporter marker) 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) function Version.title(base)
return (base or "gen1recomp") return (base or "gen1recomp")
.. " v" .. Version.engine .. " v" .. Version.engine
-2
View File
@@ -375,8 +375,6 @@ function RomExtractor:extractMaps()
} }
self:tick("Maps", mapIndex, #keys) self:tick("Maps", mapIndex, #keys)
end end
-- keep the extract ROM-faithful; Data:seedDefaults applies the #189
-- S.S. Anne 1F cabin reorder at load time
self:write("maps", out) self:write("maps", out)
return out return out
end end
+285 -1524
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 itemDef = data.items[itemId]
local name = itemDef and itemDef.name or itemId local name = itemDef and itemDef.name or itemId
-- ItemUseVitamin / ItemUsePPUp / ItemUseEvoStone / ItemUseCoinCase / -- ItemUseVitamin / ItemUsePPUp / ItemUseEvoStone / ItemUseCoinCase
-- ItemUseTMHM all refuse mid-battle (jp nz, ItemUseNotTime) -- all refuse mid-battle (jp nz, ItemUseNotTime)
if battle and (VITAMINS[itemId] or STONES[itemId] or itemId == "PP_UP" if battle and (VITAMINS[itemId] or STONES[itemId] or itemId == "PP_UP"
or itemId == "RARE_CANDY" or itemId == "COIN_CASE" or itemId == "RARE_CANDY" or itemId == "COIN_CASE") then
or (itemDef and itemDef.machine)) then
return "failed", { "OAK: " .. save.player.name return "failed", { "OAK: " .. save.player.name
.. "!\nThis isn't the\ntime to use that!" } .. "!\nThis isn't the\ntime to use that!" }
end 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 return false
end end
-- online play (the relay-based online match / tournament flows in
-- LinkState/Tournament) meets strangers, not a coordinating friend, so it
-- skips the LAN path's per-peer compatibility negotiation entirely and
-- just requires vanilla on both ends: no mod-added Pokemon, no surprises.
-- Mods only ever get baked in at boot (Loader:load), so this is a gate on
-- attempting to go online, not a live mod toggle -- the player disables
-- mods via the mod manager and relaunches.
function Handshake.onlineAllowed(game)
return #Handshake.mods(game) == 0
end
-- mode is nil on the guest: it pairs and announces itself before the host -- mode is nil on the guest: it pairs and announces itself before the host
-- has picked, and compatibility is decided from the two hellos, not the mode -- has picked, and compatibility is decided from the two hellos, not the mode
function Handshake.hello(game, mode) function Handshake.hello(game, mode)
+56 -386
View File
@@ -15,7 +15,6 @@
-- them in link battles). -- them in link battles).
local Fingerprint = require("src.link.Fingerprint") local Fingerprint = require("src.link.Fingerprint")
local Font = require("src.render.Font")
local Handshake = require("src.link.Handshake") local Handshake = require("src.link.Handshake")
local Logger = require("src.core.Logger") local Logger = require("src.core.Logger")
local Protocol = require("src.link.Protocol") local Protocol = require("src.link.Protocol")
@@ -37,45 +36,18 @@ local function makeRng(seed)
end end
end end
-- Battlers go through BattleState.makeBattler so pics get the same -- battler builder shared by both sides -- NO badge boosts, so both
-- Assets.resolve + SGB/GBC palette + padBottom path as wild/trainer -- machines compute identical stats
-- battles. save=nil skips badge boosts so both machines keep identical
-- stats (Gen 1 cable battles famously kept badges; we still diverge).
local function mkBattler(data, mon, isPlayer) local function mkBattler(data, mon, isPlayer)
local BattleState = require("src.battle.BattleState") local def = data.pokemon[mon.species]
return BattleState.makeBattler(data, mon, isPlayer, nil) local ok, img = pcall(love.graphics.newImage,
end isPlayer and def.spriteBack or def.spriteFront)
return {
-- shared by LinkBattle.new (myParty/theirParty) and LinkBattle.newSpectator mon = mon, def = def, isPlayer = isPlayer, stages = {},
-- (hostParty/guestParty): pack->unpack clamp so every perspective watching name = mon.nickname or def.name,
-- a given match holds identical mon copies. errFmt(packedMon, why) builds curStats = mon.stats, curTypes = def.types, curMoves = mon.moves,
-- the message shown when strict mode refuses an unrebuildable mon. sprite = ok and img or nil,
local function unpackParty(game, packed, unpackOpts, errFmt) }
local out = {}
for _, p in ipairs(packed or {}) do
local mon, why = Protocol.unpackMon(game.data, p, unpackOpts)
if mon then
table.insert(out, mon)
elseif unpackOpts.strict then
return nil, errFmt(p, why)
end
end
return out
end
-- decode a wire action message against whichever battler it belongs to
-- (the opponent's, from a real participant's perspective; either side's,
-- from a spectator's)
local function decodeWireAction(s, msg, battler)
if msg.kind == "move" then
local slot = math.max(1, math.min(#battler.curMoves, math.floor(msg.slot or 1)))
return battler.curMoves[slot]
elseif msg.kind == "struggle" then
return { id = "STRUGGLE", pp = 1, struggle = true }
elseif msg.kind == "locked" then
return s:lockedAction(battler)
end
return nil
end end
-- canonical (host-side-first) state hash, unchanged since v1: it stays on -- canonical (host-side-first) state hash, unchanged since v1: it stays on
@@ -182,15 +154,26 @@ function LinkBattle.new(game, net, opts)
-- both parties pass through the same pack->unpack clamp on both -- both parties pass through the same pack->unpack clamp on both
-- machines, so the copies are identical everywhere -- machines, so the copies are identical everywhere
local unpackOpts = { strict = opts.strict or false, forceLevel = opts.forceLevel } local unpackOpts = { strict = opts.strict or false }
local myParty, myErr = unpackParty(game, opts.myParty, unpackOpts, function(p) local myParty, theirParty = {}, {}
return ("Your %s can't\nbattle on the\nother game."):format(tostring(p.species)) for _, p in ipairs(opts.myParty or {}) do
end) local mon = Protocol.unpackMon(game.data, p, unpackOpts)
if not myParty then return nil, myErr end if mon then
local theirParty, theirErr = unpackParty(game, opts.theirParty, unpackOpts, function(p, why) table.insert(myParty, mon)
return ("Their %s isn't\nin this game.\n(%s)"):format(tostring(p.species), tostring(why)) elseif unpackOpts.strict then
end) return nil, ("Your %s can't\nbattle on the\nother game."):format(
if not theirParty then return nil, theirErr end 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 if #myParty == 0 or #theirParty == 0 then
Logger.warn("link: empty party on one side") Logger.warn("link: empty party on one side")
end end
@@ -230,8 +213,6 @@ function LinkBattle.new(game, net, opts)
self.player = mkBattler(game.data, myParty[1], true) self.player = mkBattler(game.data, myParty[1], true)
self.enemy = mkBattler(game.data, theirParty[1], false) self.enemy = mkBattler(game.data, theirParty[1], false)
self.enemyParty = theirParty self.enemyParty = theirParty
self.playerParty = myParty -- intro ball row uses the clamped copies
self.opponentName = theirName
self.introText = ("%s wants\nto battle!"):format(theirName) self.introText = ("%s wants\nto battle!"):format(theirName)
self.remoteHashes = {} self.remoteHashes = {}
self.localHashes = {} self.localHashes = {}
@@ -249,62 +230,22 @@ function LinkBattle.new(game, net, opts)
if text then s:say(text) end if text then s:say(text) end
end end
-- a tournament shot clock (opts.turnLimit) costs the slow player
-- specifically, unlike RUN or a desync -- both of which stay a draw --
-- so it needs its own result rather than reusing endAsDraw
local function endWithResult(s, result, text)
if s.linkEnded then return end
s.result = result
s.afterQueue = "finish"
s.phase = "messages"
if text then s:say(text) end
end
local function orderMove(action) local function orderMove(action)
if action and action.id then return game.data.moves[action.id] end if action and action.id then return game.data.moves[action.id] end
return nil return nil
end end
-- player-side SendOutMon (poof + grow-in + cry); mirrors BattleState
local function sendOutPlayer(s, mon)
local previous = s.player
s.player = mkBattler(game.data, mon, true)
s:syncSides()
Runtime.emit("battle.battler_switched", {
battle = s, side = s.sides[1], battler = s.player, previous = previous,
})
s.sendingOut = true
s:sayNext(s:sendOutText(s.player.name))
s:animNext("POOF_ANIM", false)
s:actNext(function()
s.sendingOut = false
s:startGrowIn(s.player)
require("src.core.Sound").playCry(s.data, s.player.mon.species)
end)
end
-- enemy-side EnemySendOut (grow-in + cry; no poof)
local function sendOutEnemy(s, mon)
local previous = s.enemy
s.enemy = mkBattler(game.data, mon, false)
s:syncSides()
Runtime.emit("battle.battler_switched", {
battle = s, side = s.sides[2], battler = s.enemy, previous = previous,
})
s.enemySendingOut = true
s:sayNext(("%s sent\nout %s!"):format(theirName, s.enemy.name))
s:actNext(function()
s.enemySendingOut = false
s:startGrowIn(s.enemy)
s:actNext(function()
require("src.core.Sound").playCry(s.data, s.enemy.mon.species)
end)
end)
end
-- decode a remote action message against the enemy battler -- decode a remote action message against the enemy battler
local function decodeTheirAction(s, msg) local function decodeTheirAction(s, msg)
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 end
-- with the handshake guaranteeing both games share a link surface, a -- 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) -- switches happen before attacks (both may switch)
if myMsg.kind == "switch" then if myMsg.kind == "switch" then
local idx = myMsg.index local idx = myMsg.index
s:act(function() 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 myAction = nil
end end
if theirSwitch then 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 end
s:act(function() s:act(function()
@@ -505,7 +452,10 @@ function LinkBattle.new(game, net, opts)
self.playerMonFainted = function(s) self.playerMonFainted = function(s)
for _, mon in ipairs(myParty) do for _, mon in ipairs(myParty) do
if mon.hp > 0 then if mon.hp > 0 then
s:act(function() sendOutPlayer(s, mon) end) s:act(function()
s.player = mkBattler(game.data, mon, true)
s:sayNext(("Go! %s!"):format(s.player.name))
end)
return return
end end
end end
@@ -518,7 +468,10 @@ function LinkBattle.new(game, net, opts)
self.enemyMonFainted = function(s) self.enemyMonFainted = function(s)
for _, mon in ipairs(theirParty) do for _, mon in ipairs(theirParty) do
if mon.hp > 0 then if mon.hp > 0 then
s:act(function() 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 return
end end
end end
@@ -545,19 +498,6 @@ function LinkBattle.new(game, net, opts)
if not s.result then if not s.result then
endAsDraw(s, ("%s left the\nbattle."):format(theirName)) endAsDraw(s, ("%s left the\nbattle."):format(theirName))
end end
elseif msg.type == "forfeit" then
-- the peer's own shot clock ran out; unlike a mutual RUN/desync
-- draw, this has a definite winner (us)
if not s.result then
endWithResult(s, "win", ("%s ran out of\ntime!"):format(theirName))
end
else
-- a tournament control message (bracket_update, the next
-- match_start, ...) can arrive while this match is still
-- finishing up; Tournament.lua drains this once it regains the
-- stack top rather than losing it to this poll loop
s.pendingTournamentMessages = s.pendingTournamentMessages or {}
table.insert(s.pendingTournamentMessages, msg)
end end
end end
if net.closed and not s.linkEnded and not s.result then if net.closed and not s.linkEnded and not s.result then
@@ -573,48 +513,16 @@ function LinkBattle.new(game, net, opts)
end end
return return
end end
if opts.turnLimit and s.phase == "menu" then
if not s.turnClockActive then
s.turnClockActive = true
s.turnClock = opts.turnLimit
end
s.turnClock = s.turnClock - dt
if s.turnClock <= 0 then
s.turnClockActive = false
send({ type = "forfeit" })
endWithResult(s, "lose", "Time's up! You\nforfeit the match.")
end
elseif opts.turnLimit then
s.turnClockActive = false
end
baseUpdate(s, dt) baseUpdate(s, dt)
end end
if opts.turnLimit then
local baseDraw = self.draw
self.draw = function(s, ...)
baseDraw(s, ...)
if s.phase == "menu" and s.turnClockActive then
love.graphics.setColor(1, 1, 1, 1)
Font.draw(tostring(math.max(0, math.ceil(s.turnClock))), 144, 4)
end
end
end
local baseFinish = self.finish local baseFinish = self.finish
self.finish = function(s) self.finish = function(s)
if not s.linkEnded then if not s.linkEnded then
s.linkEnded = true s.linkEnded = true
send({ type = "bye" }) send({ type = "bye" })
end end
-- 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() net:close()
end
if game.linkNet == net then game.linkNet = nil end if game.linkNet == net then game.linkNet = nil end
baseFinish(s) baseFinish(s)
end end
@@ -622,244 +530,6 @@ function LinkBattle.new(game, net, opts)
return self return self
end end
-- A tournament spectator: reconstructs the exact same lockstep battle a
-- live match's two real participants are playing, from a copy of the
-- traffic the relay fans out to onlookers (see pokeserver's `spectate`
-- envelope). No local input drives anything here -- both sides' actions
-- arrive over the wire, tagged by which real player sent them -- so it's
-- a read-only replay, not a third participant: no hash/desync checking
-- (a spectator has nothing to verify against), no shot clock (nothing to
-- act on), and `finish` must NOT close `net`, since that's the caller's
-- long-lived tournament connection, not a dedicated match socket.
--
-- opts: { hostParty = packed, guestParty = packed, hostName, guestName,
-- seed, verdict, strict }. `self.player` is always the host's battler and
-- `self.enemy` the guest's, which is what makes TurnOrder.firstMover's
-- tie-break (below) land on the same result the host's own instance
-- already computed with invertTie=false.
function LinkBattle.newSpectator(game, net, opts)
local BattleState = require("src.battle.BattleState")
local hostName = opts.hostName or "HOST"
local guestName = opts.guestName or "GUEST"
if not Handshake.battleAllowed(opts.verdict) then
return nil, "Link battle needs\nthe same mods on\nboth games."
end
local unpackOpts = { strict = opts.strict or false, forceLevel = opts.forceLevel }
local hostParty, hostErr = unpackParty(game, opts.hostParty, unpackOpts, function(p)
return ("%s's %s can't\nbattle on this\ngame."):format(hostName, tostring(p.species))
end)
if not hostParty then return nil, hostErr end
local guestParty, guestErr = unpackParty(game, opts.guestParty, unpackOpts, function(p, why)
return ("%s's %s can't\nbattle on this\ngame.\n(%s)"):format(
guestName, tostring(p.species), tostring(why))
end)
if not guestParty then return nil, guestErr end
if #hostParty == 0 or #guestParty == 0 then
Logger.warn("link: empty party on one side (spectator)")
end
local self = BattleState.newWild(game, guestParty[1] and guestParty[1].species
or "RATTATA", 5)
self.kind = "link" -- exact same visual treatment as a real link battle
self.spectating = true -- Tournament.lua's marker: don't report a result for this one
self.net = net
game.linkNet = net
self.rng = makeRng(opts.seed or 1)
self.player = mkBattler(game.data, hostParty[1], true)
self.enemy = mkBattler(game.data, guestParty[1], false)
self.enemyParty = guestParty
self.playerParty = hostParty
self.opponentName = guestName
self.introText = ("%s vs %s!"):format(hostName, guestName)
local function orderMove(action)
if action and action.id then return game.data.moves[action.id] end
return nil
end
local function endSpectate(s, text)
if s.linkEnded then return end
s.result = s.result or "ended"
s.afterQueue = "finish"
s.phase = "messages"
if text then s:say(text) end
end
local function sendOutHost(s, mon)
local previous = s.player
s.player = mkBattler(game.data, mon, true)
s:syncSides()
Runtime.emit("battle.battler_switched", {
battle = s, side = s.sides[1], battler = s.player, previous = previous,
})
s.sendingOut = true
s:sayNext(s:sendOutText(s.player.name))
s:animNext("POOF_ANIM", false)
s:actNext(function()
s.sendingOut = false
s:startGrowIn(s.player)
require("src.core.Sound").playCry(s.data, s.player.mon.species)
end)
end
local function sendOutGuest(s, mon)
local previous = s.enemy
s.enemy = mkBattler(game.data, mon, false)
s:syncSides()
Runtime.emit("battle.battler_switched", {
battle = s, side = s.sides[2], battler = s.enemy, previous = previous,
})
s.enemySendingOut = true
s:sayNext(("%s sent\nout %s!"):format(guestName, s.enemy.name))
s:actNext(function()
s.enemySendingOut = false
s:startGrowIn(s.enemy)
s:actNext(function()
require("src.core.Sound").playCry(s.data, s.enemy.mon.species)
end)
end)
end
local function resolveSpecTurn(s, hostMsg, guestMsg)
if hostMsg.kind == "run" or guestMsg.kind == "run" then
endSpectate(s, "The match ended.")
return
end
s.phase = "messages"
s.afterQueue = "linkNext"
s.turnCount = (s.turnCount or 0) + 1
if hostMsg.kind == "switch" then
local idx = hostMsg.index
s:act(function() sendOutHost(s, hostParty[idx]) end)
end
if guestMsg.kind == "switch" then
local idx = guestMsg.index
s:act(function() sendOutGuest(s, guestParty[idx]) end)
end
s:act(function()
local hostAction = hostMsg.kind ~= "switch" and hostMsg.kind ~= "run"
and decodeWireAction(s, hostMsg, s.player) or nil
local guestAction = guestMsg.kind ~= "switch" and guestMsg.kind ~= "run"
and decodeWireAction(s, guestMsg, s.enemy) or nil
Runtime.emit("battle.turn_started", {
battle = s, turn = s.turnCount,
playerAction = hostAction, enemyAction = guestAction,
})
if hostAction and guestAction then
local hostMove, guestMove = orderMove(hostAction), orderMove(guestAction)
local first
if Runtime.wantsHook("battle.turn_order") then
first = Runtime.call("battle.turn_order", function(a, aMove, b, bMove, c)
return TurnOrder.firstMover(a, aMove, b, bMove, c.rng, c.invertTie)
end, s.player, hostMove, s.enemy, guestMove, { rng = s.rng, invertTie = false })
else
first = TurnOrder.firstMover(s.player, hostMove, s.enemy, guestMove, s.rng, false)
end
local order
if first then
order = { { s.player, s.enemy, hostAction }, { s.enemy, s.player, guestAction } }
else
order = { { s.enemy, s.player, guestAction }, { s.player, s.enemy, hostAction } }
end
for _, entry in ipairs(order) do
s:act(function() s:executeAction(entry[1], entry[2], entry[3]) end)
end
elseif hostAction then
s:act(function() s:executeAction(s.player, s.enemy, hostAction) end)
elseif guestAction then
s:act(function() s:executeAction(s.enemy, s.player, guestAction) end)
end
s:act(function() s:endOfTurn() end)
end)
end
self.resolveTurn = function() end -- a spectator's own input never drives anything
self.resolveSwitch = function() end
self.tryRun = function() end
self.openParty = function(s) s.phase = "waitBoth" end
self.playerMonFainted = function(s)
for _, mon in ipairs(hostParty) do
if mon.hp > 0 then
s:act(function() sendOutHost(s, mon) end)
return
end
end
s:sayNext(("%s is out of\nPOKéMON!\f%s wins!"):format(hostName, guestName))
s.result = "guestWin"
s.afterQueue = "finish"
end
self.enemyMonFainted = function(s)
for _, mon in ipairs(guestParty) do
if mon.hp > 0 then
s:act(function() sendOutGuest(s, mon) end)
return
end
end
s:sayNext(("%s is out of\nPOKéMON!\f%s wins!"):format(guestName, hostName))
s.result = "hostWin"
s.afterQueue = "finish"
end
self.hostMsg, self.guestMsg = nil, nil
local baseUpdate = self.update
self.update = function(s, dt)
net:update()
for _, msg in ipairs(net:poll()) do
if msg.type == "spectate" then
local inner = msg.msg
if inner.type == "action" then
if msg.side == "host" then s.hostMsg = inner else s.guestMsg = inner end
if s.hostMsg and s.guestMsg then
local h, g = s.hostMsg, s.guestMsg
s.hostMsg, s.guestMsg = nil, nil
resolveSpecTurn(s, h, g)
end
elseif inner.type == "bye" or inner.type == "forfeit" then
if not s.result then endSpectate(s, "The match ended.") end
end
-- "hello"/"party"/"hash" ride along too (Tournament.lua already
-- consumed hello/party before building this battle); none of them
-- need any action here
else
-- same reasoning as the real-participant loop above: don't lose a
-- bracket_update/match_start_spectate that arrives mid-match
s.pendingTournamentMessages = s.pendingTournamentMessages or {}
table.insert(s.pendingTournamentMessages, msg)
end
end
if net.closed and not s.linkEnded and not s.result then
endSpectate(s)
end
if s.phase == "messages" and s.afterQueue == "linkNext" then
if not s:updateQueue() then
s.afterQueue = "waitBoth"
s.phase = "waitBoth"
end
return
end
if s.phase == "menu" or s.phase == "waitBoth" then
return -- frozen between resolved turns; never a real decision here
end
baseUpdate(s, dt)
end
local baseFinish = self.finish
self.finish = function(s)
s.linkEnded = true
if game.linkNet == net then game.linkNet = nil end
baseFinish(s) -- deliberately doesn't touch net: it's the caller's
-- tournament connection, still needed after this match
end
return self
end
-- backwards-compatible entry points (LinkState passes role explicitly) -- backwards-compatible entry points (LinkState passes role explicitly)
function LinkBattle.newHost(game, net, opts) function LinkBattle.newHost(game, net, opts)
opts.role = "host" opts.role = "host"
+6 -214
View File
@@ -2,8 +2,6 @@
-- the other joins by typing that address in. Direct peer-to-peer over -- the other joins by typing that address in. Direct peer-to-peer over
-- lua-enet (bundled with LÖVE), no relay server. -- lua-enet (bundled with LÖVE), no relay server.
local CodeEntry = require("src.link.CodeEntry")
local DiscordPresence = require("src.core.DiscordPresence")
local Font = require("src.render.Font") local Font = require("src.render.Font")
local Handshake = require("src.link.Handshake") local Handshake = require("src.link.Handshake")
local Net = require("src.link.Net") local Net = require("src.link.Net")
@@ -17,32 +15,6 @@ LinkState.__index = LinkState
LinkState.isOpaque = true LinkState.isOpaque = true
local CURSOR = 0xED local CURSOR = 0xED
local ANY = "ANY" -- sentinel: a leading nil array entry breaks ipairs under
-- LuaJIT even though # still reports the full size, so
-- the level picker cycles this string instead of nil,
-- converted to nil only on the wire (see levelForWire)
local FORCE_LEVEL_STEPS = { ANY, 50, 100 }
local function indexOf(list, value)
for i, v in ipairs(list) do
if v == value then return i end
end
return 1
end
local function levelForWire(v)
return v == ANY and nil or v
end
local function forceLevelLabel(v)
return (v == ANY or v == nil) and "ANY" or ("AUTO " .. tostring(v))
end
-- stages before any Net object is meaningfully "this session's link" --
-- .net can still be a leftover failed attempt sitting on self, so error/
-- closed checks below skip these rather than keying off self.net's
-- presence alone
local PRE_CONNECT_STAGES = { menu = true, lanMenu = true, onlineMenu = true }
-- how long the host waits for a v2 hello before deciding the peer predates -- how long the host waits for a v2 hello before deciding the peer predates
-- the handshake (a pre-mod guest sends nothing until it hears the mode) -- the handshake (a pre-mod guest sends nothing until it hears the mode)
@@ -75,23 +47,7 @@ function LinkState.new(game)
return self return self
end end
-- entry point for a Discord "Ask to Join" click (see DiscordPresence.lua):
-- skips the whole LAN/ONLINE/TOURNAMENT menu and jumps straight to
-- "connecting with this code", same as if the player had typed it in
function LinkState.newJoinOnline(game, code)
local self = LinkState.new(game)
self.net = Net.new()
if self.net:joinOnline(nil, code) then
self.stage = "onlineJoining"
else
self.stage = "menu" -- exitWith below needs a real stage to unwind from
self:exitWith("Link error:\n" .. (self.net.error or "?"))
end
return self
end
function LinkState:exitWith(message, reason) function LinkState:exitWith(message, reason)
DiscordPresence.setJoinCode(nil)
Runtime.emit("link.ended", { reason = reason or (message and "error" or "bye") }) Runtime.emit("link.ended", { reason = reason or (message and "error" or "bye") })
if self.net then self.net:close() end if self.net then self.net:close() end
self.game.stack:pop() self.game.stack:pop()
@@ -145,12 +101,7 @@ function LinkState:decideCompat(mode, isHost)
mods = peer and peer.mods, fingerprint = peer and peer.fingerprint }, mods = peer and peer.mods, fingerprint = peer and peer.fingerprint },
}) })
if self.verdict == "full" or self.verdict == "vanilla_peer" then if self.verdict == "full" or self.verdict == "vanilla_peer" then
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) self:startMode(mode, isHost)
end
return return
end end
-- naming the difference up front is the whole point: the old behaviour -- naming the difference up front is the whole point: the old behaviour
@@ -168,54 +119,25 @@ function LinkState:update(dt)
local input = self.game.input local input = self.game.input
if self.net then if self.net then
self.net:update() self.net:update()
if self.net.error and 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)) self:exitWith("Link error:\n" .. self.net.error:sub(1, 60))
return return
end end
-- the peer vanished without a bye (only once the inbox is drained, -- the peer vanished without a bye (only once the inbox is drained,
-- so a final message travelling with the disconnect still counts) -- so a final message travelling with the disconnect still counts)
if self.net.closed and #self.net.inbox == 0 if self.net.closed and #self.net.inbox == 0
and not PRE_CONNECT_STAGES[self.stage] and self.stage ~= "addrEntry" and self.stage ~= "menu" and self.stage ~= "addrEntry"
and self.stage ~= "codeEntry" and self.stage ~= "notice" and self.stage ~= "notice" and self.stage ~= "battleRunning" then
and self.stage ~= "battleRunning" then
self:exitWith("The link was\nbroken.") self:exitWith("The link was\nbroken.")
return return
end end
end end
if self.stage == "menu" then if self.stage == "menu" then
if input:wasPressed("down") then
self.index = self.index % 3 + 1
elseif input:wasPressed("up") then
self.index = (self.index - 2) % 3 + 1
elseif input:wasPressed("b") then
self:exitWith(nil)
elseif input:wasPressed("a") then
if self.index == 1 then
self.stage = "lanMenu"
self.index = 1
elseif self.index == 2 or self.index == 3 then
if not Handshake.onlineAllowed(self.game) then
self:exitWith("Online play needs\nno mods enabled.\fDisable them in\nSTART > MODS.")
return
end
if self.index == 2 then
self.stage = "onlineMenu"
else
local Tournament = require("src.link.Tournament")
self.game.stack:pop()
self.game.stack:push(Tournament.new(self.game))
end
self.index = 1
end
end
elseif self.stage == "lanMenu" then
if input:wasPressed("up") or input:wasPressed("down") then if input:wasPressed("up") or input:wasPressed("down") then
self.index = self.index == 1 and 2 or 1 self.index = self.index == 1 and 2 or 1
elseif input:wasPressed("b") then elseif input:wasPressed("b") then
self.stage = "menu" self:exitWith(nil)
self.index = 1
elseif input:wasPressed("a") then elseif input:wasPressed("a") then
self.net = Net.new() self.net = Net.new()
if self.index == 1 then if self.index == 1 then
@@ -229,67 +151,6 @@ function LinkState:update(dt)
end end
end end
elseif self.stage == "onlineMenu" then
if input:wasPressed("up") or input:wasPressed("down") then
self.index = self.index == 1 and 2 or 1
elseif input:wasPressed("b") then
self.stage = "menu"
self.index = 2
elseif input:wasPressed("a") then
if self.index == 1 then
self.net = Net.new()
if self.net:hostOnline() then
self.stage = "onlineHosting"
else
self:exitWith("Link error:\n" .. (self.net.error or "?"))
end
else
self.stage = "codeEntry"
self.codeEntry = CodeEntry.new()
end
end
elseif self.stage == "onlineHosting" then
if not self.discordCodeSet and self.net.code then
DiscordPresence.setJoinCode(self.net.code)
self.discordCodeSet = true
end
if input:wasPressed("b") then self:exitWith(nil) return end
if self.net.paired then
DiscordPresence.setJoinCode(nil) -- someone's here now; stop advertising
self.stage = "modeSelect"
self.index = 1
end
elseif self.stage == "codeEntry" then
if input:wasPressed("b") then
self.stage = "onlineMenu"
self.index = 2
elseif input:wasPressed("up") then
CodeEntry.up(self.codeEntry)
elseif input:wasPressed("down") then
CodeEntry.down(self.codeEntry)
elseif input:wasPressed("left") then
CodeEntry.left(self.codeEntry)
elseif input:wasPressed("right") then
CodeEntry.right(self.codeEntry)
elseif input:wasPressed("a") then
local code = CodeEntry.text(self.codeEntry)
self.net = Net.new()
if self.net:joinOnline(nil, code) then
self.stage = "onlineJoining"
else
self:exitWith("Link error:\n" .. (self.net.error or "?"))
end
end
elseif self.stage == "onlineJoining" then
if input:wasPressed("b") then self:exitWith(nil) return end
if self.net.paired then
self.stage = "waitMode"
self:sendHello(nil) -- the host owns the mode; this is just who we are
end
elseif self.stage == "hosting" then elseif self.stage == "hosting" then
if input:wasPressed("b") then self:exitWith(nil) return end if input:wasPressed("b") then self:exitWith(nil) return end
if self.net.paired then if self.net.paired then
@@ -347,23 +208,6 @@ function LinkState:update(dt)
self:exitWith(nil) self:exitWith(nil)
end end
elseif self.stage == "battleOptions" then -- host picks the level rule,
-- once compat is confirmed and
-- before parties are exchanged
self.levelChoice = self.levelChoice or ANY
if input:wasPressed("b") then
self:exitWith(nil)
elseif input:wasPressed("up") or input:wasPressed("down")
or input:wasPressed("left") or input:wasPressed("right") then
local delta = (input:wasPressed("down") or input:wasPressed("left")) and -1 or 1
local i = indexOf(FORCE_LEVEL_STEPS, self.levelChoice)
i = ((i - 1 + delta) % #FORCE_LEVEL_STEPS) + 1
self.levelChoice = FORCE_LEVEL_STEPS[i]
elseif input:wasPressed("a") then
self.forceLevel = levelForWire(self.levelChoice)
self:startMode(self.pendingMode, true)
end
elseif self.stage == "waitHello" then -- host waits for the peer's hello elseif self.stage == "waitHello" then -- host waits for the peer's hello
if input:wasPressed("b") then self:exitWith(nil) return end if input:wasPressed("b") then self:exitWith(nil) return end
local got, other = self:pollHello() local got, other = self:pollHello()
@@ -407,9 +251,6 @@ function LinkState:update(dt)
local msgs = self.net:poll() local msgs = self.net:poll()
for i, msg in ipairs(msgs) do for i, msg in ipairs(msgs) do
if msg.type == "party" then if msg.type == "party" then
-- the host owns this rule (same as mode); the guest only learns
-- it here, off the host's own party message
if not self.isHost then self.forceLevel = msg.forceLevel end
local LinkBattle = require("src.link.LinkBattle") local LinkBattle = require("src.link.LinkBattle")
local opts = { local opts = {
myParty = Protocol.packParty(self.game.save.party), myParty = Protocol.packParty(self.game.save.party),
@@ -418,7 +259,6 @@ function LinkState:update(dt)
seed = self.isHost and self.linkSeed or msg.seed, seed = self.isHost and self.linkSeed or msg.seed,
verdict = self.verdict, verdict = self.verdict,
strict = Handshake.strict(self.verdict), strict = Handshake.strict(self.verdict),
forceLevel = self.forceLevel,
} }
local battle, why local battle, why
if self.isHost then if self.isHost then
@@ -469,8 +309,7 @@ function LinkState:startMode(mode, isHost)
end end
self.net:send({ type = "party", self.net:send({ type = "party",
mons = Protocol.packParty(self.game.save.party), mons = Protocol.packParty(self.game.save.party),
seed = self.linkSeed, seed = self.linkSeed })
forceLevel = isHost and self.forceLevel or nil })
end end
end end
@@ -501,10 +340,6 @@ function LinkState:updateTrade(input)
require("src.core.Sound").play(game.data, "Trade_Machine") require("src.core.Sound").play(game.data, "Trade_Machine")
Screens.push(game, "TradeAnim", { Screens.push(game, "TradeAnim", {
sent = sent, received = received, 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() onDone = function()
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
("Trade completed!\f%s received\n%s!"):format(game.save.player.name, name), ("Trade completed!\f%s received\n%s!"):format(game.save.player.name, name),
@@ -554,49 +389,12 @@ end
function LinkState:draw() function LinkState:draw()
if self.stage == "menu" then if self.stage == "menu" then
drawTitle("BOIS CLUB LIVE") drawTitle("LINK CABLE CLUB")
Font.draw("LINK CABLE (LAN)", 32, 44)
Font.draw("ONLINE MATCH", 32, 60)
Font.draw("TOURNAMENT", 32, 76)
Font.drawCode(CURSOR, 24, 44 + (self.index - 1) * 16)
elseif self.stage == "lanMenu" then
drawTitle("LINK CABLE (LAN)")
Font.draw("HOST A GAME", 32, 48) Font.draw("HOST A GAME", 32, 48)
Font.draw("JOIN A GAME", 32, 68) Font.draw("JOIN A GAME", 32, 68)
Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68) Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68)
Font.draw("UDP port " .. Net.defaultPort(), 8, 128) Font.draw("UDP port " .. Net.defaultPort(), 8, 128)
elseif self.stage == "onlineMenu" then
drawTitle("ONLINE MATCH")
Font.draw("HOST ONLINE", 32, 48)
Font.draw("JOIN ONLINE", 32, 68)
Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68)
elseif self.stage == "onlineHosting" then
drawTitle("HOSTING ONLINE")
Font.draw("Tell your friend", 16, 40)
Font.draw("the code:", 16, 52)
Font.draw(self.net.code or "??????", 32, 68)
Font.draw("Waiting for join...", 8, 96)
elseif self.stage == "codeEntry" then
drawTitle("ENTER CODE")
for i = 1, CodeEntry.LENGTH do
local x = 16 + (i - 1) * 16
local ch = CodeEntry.CHARSET:sub(self.codeEntry.chars[i], self.codeEntry.chars[i])
Font.draw(ch, x, 64)
if i == self.codeEntry.pos then
Font.drawCode(0xEE, x, 76) -- ▼ under the active slot
end
end
Font.draw("A: connect B: back", 8, 128)
elseif self.stage == "onlineJoining" then
drawTitle("CONNECTING...")
Font.draw("Calling...", 8, 56)
Font.draw(self.net.target or "", 8, 72)
elseif self.stage == "hosting" then elseif self.stage == "hosting" then
drawTitle("HOSTING") drawTitle("HOSTING")
Font.draw("Friend joins at:", 16, 48) Font.draw("Friend joins at:", 16, 48)
@@ -630,12 +428,6 @@ function LinkState:draw()
Font.draw("BATTLE", 32, 68) Font.draw("BATTLE", 32, 68)
Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68) Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68)
elseif self.stage == "battleOptions" then
drawTitle("BATTLE OPTIONS")
Font.draw("LEVELS:", 16, 56)
Font.draw(forceLevelLabel(self.levelChoice or ANY), 88, 56)
Font.draw("A: continue B: back", 8, 128)
elseif self.stage == "waitMode" or self.stage == "waitHello" then elseif self.stage == "waitMode" or self.stage == "waitHello" then
drawTitle("CONNECTED!") drawTitle("CONNECTED!")
if self.stage == "waitHello" then if self.stage == "waitHello" then
+1 -170
View File
@@ -14,15 +14,6 @@
-- Plain luajit (headless tests) has no enet; Net.available() reports -- Plain luajit (headless tests) has no enet; Net.available() reports
-- that, and Net.loopbackPair() returns two in-memory ends with the -- that, and Net.loopbackPair() returns two in-memory ends with the
-- same API so the protocol/battle logic stays testable offline. -- same API so the protocol/battle logic stays testable offline.
--
-- A second backend, alongside the ENet one above, talks plain TCP to a
-- pokeserver relay instead of direct peer-to-peer: both sides dial out
-- to a public server (works through NAT with no hole-punching), the host
-- gets a 6-character room code instead of an IP, and the server forwards
-- messages between them. Same newline-delimited JSON on the wire, same
-- Net API (host/join/send/update/poll/.paired/.closed/.error) -- see
-- Net:hostOnline/Net:joinOnline below. LinkState/LinkBattle/Protocol don't
-- know or care which backend is in play.
local Json = require("src.link.Json") local Json = require("src.link.Json")
local Logger = require("src.core.Logger") local Logger = require("src.core.Logger")
@@ -30,14 +21,10 @@ local Logger = require("src.core.Logger")
local hasEnet, enet = pcall(require, "enet") local hasEnet, enet = pcall(require, "enet")
if not hasEnet then enet = nil end if not hasEnet then enet = nil end
local hasSocket, socket = pcall(require, "socket")
if not hasSocket then socket = nil end
local Net = {} local Net = {}
Net.__index = Net Net.__index = Net
Net.DEFAULT_PORT = 7777 Net.DEFAULT_PORT = 7777
Net.DEFAULT_RELAY_ADDRESS = "147.182.215.255:7778"
function Net.available() function Net.available()
return enet ~= nil return enet ~= nil
@@ -47,10 +34,6 @@ function Net.defaultPort()
return tonumber(os.getenv("POKEPORT_LINK_PORT") or "") or Net.DEFAULT_PORT return tonumber(os.getenv("POKEPORT_LINK_PORT") or "") or Net.DEFAULT_PORT
end end
function Net.defaultRelayAddress()
return os.getenv("POKEPORT_RELAY_ADDR") or Net.DEFAULT_RELAY_ADDRESS
end
-- monotonic-ish clock for the join timeout -- monotonic-ish clock for the join timeout
local function now() local function now()
if love and love.timer and love.timer.getTime then if love and love.timer and love.timer.getTime then
@@ -81,17 +64,13 @@ function Net.new()
enetHost = nil, -- our enet host object (both ends have one) enetHost = nil, -- our enet host object (both ends have one)
peer = nil, -- the connected remote peer peer = nil, -- the connected remote peer
inbox = {}, inbox = {},
outbox = {}, -- messages queued before pairing completes (enet only) outbox = {}, -- messages queued before pairing completes
paired = false, paired = false,
address = nil, -- host: "ip:port" the other player types in address = nil, -- host: "ip:port" the other player types in
error = nil, error = nil,
closed = false, closed = false,
mode = nil, mode = nil,
joinTimeout = 10, joinTimeout = 10,
tcpSocket = nil, -- relay backend: the luasocket TCP connection
rxBuf = "", -- relay backend: bytes read but not yet a full line
txBuf = "", -- relay backend: bytes queued but not yet written
code = nil, -- relay backend, hosting: the room/tournament code
}, Net) }, Net)
end end
@@ -154,56 +133,8 @@ function Net:join(address)
return true return true
end end
-- opens a TCP connection to a pokeserver relay (blocking connect with a
-- short timeout -- this runs once, from a single explicit user action, not
-- from a per-frame poll, so blocking briefly is fine). Callers then send
-- whatever control message starts their session ({type="host"},
-- {type="join",...}, {type="host_tournament",...}, ...); every reply that
-- isn't one of the four generic ones below lands in the normal inbox for
-- the caller (LinkState, or Tournament.lua) to interpret.
function Net:connectTCP(addr)
if not socket then
self.error = "online play needs luasocket (bundled with LOVE)"
return false
end
local host, port = addr:match("^(.-):(%d+)$")
host = host or addr
port = tonumber(port) or 7778
local tcp = socket.tcp()
tcp:settimeout(5)
local ok, err = tcp:connect(host, port)
if not ok then
self.error = ("can't reach relay %s:%d\n(%s)"):format(host, port, tostring(err))
return false
end
tcp:settimeout(0)
self.tcpSocket = tcp
self.rxBuf = ""
self.txBuf = ""
return true
end
function Net:hostOnline(addr)
if not self:connectTCP(addr or Net.defaultRelayAddress()) then return false end
self.mode = "onlineHosting"
self:send({ type = "host" })
return true
end
function Net:joinOnline(addr, code)
if not self:connectTCP(addr or Net.defaultRelayAddress()) then return false end
self.mode = "onlineJoining"
self.target = code
self:send({ type = "join", code = code })
return true
end
function Net:send(msg) function Net:send(msg)
if self.closed then return end if self.closed then return end
if self.tcpSocket then
self.txBuf = self.txBuf .. Json.encode(msg) .. "\n"
return
end
if self.peerEnd then -- loopback: re-encode through json like the wire if self.peerEnd then -- loopback: re-encode through json like the wire
local decoded = Json.decode(Json.encode(msg)) local decoded = Json.decode(Json.encode(msg))
if decoded and not self.peerEnd.closed then if decoded and not self.peerEnd.closed then
@@ -224,102 +155,8 @@ function Net:send(msg)
end end
end end
-- one control message recognized on every relay connection, regardless of
-- what it's being used for (a 1v1 room or a tournament): "peer_gone" only
-- ever fires for a paired 1v1 room (tournaments signal disconnects through
-- bracket_update/tournament_over instead), so it's unambiguous here.
local function handleGenericRelayControl(self, msg)
if msg.type == "hosted" then
self.code = msg.code
return true
elseif msg.type == "paired" then
self.paired = true
return true
elseif msg.type == "join_error" then
self.error = ({
not_found = "That code wasn't\nfound.",
full = "That game already\nhas two players.",
expired = "That code has\nexpired.",
})[msg.reason] or ("Couldn't join:\n%s"):format(tostring(msg.reason))
self.closed = true
return true
elseif msg.type == "peer_gone" then
self.closed = true
return true
end
return false
end
function Net:handleTCPLine(line)
local msg = Json.decode(line)
if not msg then
Logger.warn("link: bad relay message %q", line:sub(1, 60))
return
end
if not handleGenericRelayControl(self, msg) then
table.insert(self.inbox, msg)
end
end
-- pulls every complete "\n"-terminated line out of rxBuf (leaving a
-- trailing partial line, if any, for the next call to complete) and hands
-- each to handleTCPLine. Pure buffer manipulation, no socket -- factored
-- out of updateTCP so the framing logic is testable without a real
-- connection (see the relay-path tests in tests/run_link_tests.lua).
function Net:drainLines()
while true do
local nl = self.rxBuf:find("\n", 1, true)
if not nl then break end
local line = self.rxBuf:sub(1, nl - 1)
self.rxBuf = self.rxBuf:sub(nl + 1)
if #line > 0 then self:handleTCPLine(line) end
end
end
-- non-blocking pump for the relay TCP backend: flush queued writes, drain
-- whatever's arrived into complete lines. Uses a byte-count receive
-- (rather than the "*l" pattern) because luasocket's "*l" doesn't let a
-- non-blocking caller recover the partial line across calls -- a
-- byte-count read hands back whatever's available via the third return
-- value on timeout, which we can buffer ourselves.
function Net:updateTCP()
if self.closed then return end
local sock = self.tcpSocket
if #self.txBuf > 0 then
local sent, err, lastByte = sock:send(self.txBuf)
if sent then
self.txBuf = ""
elseif err == "timeout" then
self.txBuf = self.txBuf:sub((lastByte or 0) + 1)
else
self.error = "send failed: " .. tostring(err)
self.closed = true
return
end
end
while true do
local data, err, partial = sock:receive(8192)
local chunk = data or partial or ""
if #chunk > 0 then self.rxBuf = self.rxBuf .. chunk end
if err == "closed" then
self.closed = true
break
elseif err and err ~= "timeout" then
self.error = tostring(err)
self.closed = true
break
end
if not data then break end -- nothing more buffered this frame
end
self:drainLines()
end
-- pump enet events; decoded JSON messages are queued for poll() -- pump enet events; decoded JSON messages are queued for poll()
function Net:update() function Net:update()
if self.tcpSocket then
self:updateTCP()
return
end
if self.peerEnd then return end -- loopback needs no pumping if self.peerEnd then return end -- loopback needs no pumping
if not self.enetHost or self.closed then return end if not self.enetHost or self.closed then return end
while true do while true do
@@ -382,12 +219,6 @@ function Net:close()
self.closed = true self.closed = true
return return
end end
if self.tcpSocket then
pcall(function() self.tcpSocket:close() end)
self.tcpSocket = nil
self.closed = true
return
end
if self.enetHost then if self.enetHost then
if self.peer and self.paired and not self.closed then if self.peer and self.paired and not self.closed then
-- graceful goodbye: disconnect_later delivers the queued -- graceful goodbye: disconnect_later delivers the queued
+2 -17
View File
@@ -77,13 +77,6 @@ function Protocol.unpackMon(data, packed, opts)
return nil return nil
end end
local level = math.max(2, math.min(100, math.floor(packed.level or 5))) local level = math.max(2, math.min(100, math.floor(packed.level or 5)))
-- "auto-level" tournaments/matches: every participant's real level is
-- ignored and everyone rebuilds at the same fixed level instead, so a
-- Lv12 and a Lv100 party can battle on equal footing. Both sides pass
-- the identical forceLevel for a given match, so this stays symmetric.
if opts and opts.forceLevel then
level = math.max(2, math.min(100, math.floor(opts.forceLevel)))
end
local dvs = {} local dvs = {}
for _, k in ipairs({ "hp", "attack", "defense", "speed", "special" }) do for _, k in ipairs({ "hp", "attack", "defense", "speed", "special" }) do
dvs[k] = math.max(0, math.min(15, math.floor((packed.dvs or {})[k] or 0))) dvs[k] = math.max(0, math.min(15, math.floor((packed.dvs or {})[k] or 0)))
@@ -111,14 +104,6 @@ function Protocol.unpackMon(data, packed, opts)
if strict then return nil, "no shared moves" end if strict then return nil, "no shared moves" end
moves = { { id = "TACKLE", pp = 35 } } moves = { { id = "TACKLE", pp = 35 } }
end end
-- a forced level scales every stat including max HP, so the real party's
-- current HP/status (a different level's numbers, possibly mid-fight)
-- isn't meaningful anymore -- auto-level starts everyone full and fresh,
-- same as a standardized tournament format would
local forced = opts and opts.forceLevel
local hp = forced and stats.hp
or math.max(0, math.min(stats.hp, math.floor(packed.hp or stats.hp)))
local status = forced and nil or packed.status
return { return {
species = packed.species, species = packed.species,
level = level, level = level,
@@ -126,8 +111,8 @@ function Protocol.unpackMon(data, packed, opts)
dvs = dvs, dvs = dvs,
statExp = statExp, statExp = statExp,
stats = stats, stats = stats,
hp = hp, hp = math.max(0, math.min(stats.hp, math.floor(packed.hp or stats.hp))),
status = status, status = packed.status,
nickname = packed.nickname, nickname = packed.nickname,
moves = moves, moves = moves,
-- a namespace whose mod this install lacks survives untouched, so the -- a namespace whose mod this install lacks survives untouched, so the
-634
View File
@@ -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 -- Game:keypressed recognizes a directly-pushed instance
ManagerState.screenId = "ManagerState" ManagerState.screenId = "ManagerState"
-- Same as OptionsMenu: without this, title LOGO zones leak through when
-- MODS is opened from the title-screen options path.
function ManagerState:sgbPalettes(game)
return require("src.render.PaletteFX").wholeNamed(game.data, "MEWMON")
end
-- the charmap has no * ~ + < > glyphs, so the status gutter uses what it -- the charmap has no * ~ + < > glyphs, so the status gutter uses what it
-- does have: staged-awaiting-restart, disabled, errored, dep-unhealthy -- does have: staged-awaiting-restart, disabled, errored, dep-unhealthy
local GLYPH = { staged = ".", disabled = "-", errored = "!", blocked = "?" } local GLYPH = { staged = ".", disabled = "-", errored = "!", blocked = "?" }
+2 -137
View File
@@ -40,19 +40,6 @@ function f.int(min, max)
end } end }
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) function f.enum(values)
local set = {} local set = {}
for _, value in ipairs(values) do set[value] = true end for _, value in ipairs(values) do set[value] = true end
@@ -433,12 +420,6 @@ R.pokemon = {
frames = f.opt(f.int(1)) } }), frames = f.opt(f.int(1)) } }),
cry = f.opt(f.id("cries")), palette = f.opt(f.id("palettes")), cry = f.opt(f.id("cries")), palette = f.opt(f.id("palettes")),
trueColor = f.opt(f.bool), 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 } })', 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 })', 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 -- ------- progression
R.evolution_methods = { R.evolution_methods = {
@@ -1003,15 +872,11 @@ R.palettes = {
} }
-- keyed by species id, unlike the vanilla byDex array: a species past the -- keyed by species id, unlike the vanilla byDex array: a species past the
-- end of the dex gets an icon without punching a hole in the list. The party -- end of the dex gets an icon without punching a hole in the list
-- menu (src/ui/PartyMenu.lua) reads this per-species entry before the vanilla
-- dex-indexed default. The value is a built-in icon NAME -- one of BALL, BIRD,
-- BUG, FAIRY, GRASS, HELIX, MON, QUADRUPED, SNAKE, WATER (uppercase) -- or a
-- { image = <bundled file path>, frames? } table of your own art.
R.icons = { R.icons = {
semantics = "record", target = "icons.bySpecies", semantics = "record", target = "icons.bySpecies",
value = f.union{ f.str, f.rec{ image = f.path, frames = f.opt(f.int(1)) } }, value = f.union{ f.str, f.rec{ image = f.path, frames = f.opt(f.int(1)) } },
example = 'mod.content.icons:register("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 -- 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 return moves
end 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) function Pokemon.new(data, species, level, rng)
local def = data.pokemon[species] local def = data.pokemon[species]
assert(def, "unknown species " .. tostring(species)) assert(def, "unknown species " .. tostring(species))
-5
View File
@@ -230,11 +230,6 @@ function BattleTransition:draw()
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
local prog = math.min(1, self.t / self.wipeLen) local prog = math.min(1, self.t / self.wipeLen)
-- Cascade black 8x8 blocks across the window area *outside* the classic
-- 160x144 wipe square, in lockstep with the OG wipe progress. Renderer
-- paints them in screen space after the world blit (see endFrame).
local renderer = self.game and self.game.renderer
if renderer then renderer.battleCascadeProg = prog end
local style = self.style local style = self.style
-- a registered style may draw itself; the eight built-ins do not -- a registered style may draw itself; the eight built-ins do not
-11
View File
@@ -148,17 +148,6 @@ function Font.advanceOf(code)
return page and page.advance or GLYPH return page and page.advance or GLYPH
end 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 -- Draw a plain single-line string at pixel (x, y). Returns the width
-- drawn, which is #codes * 8 for every fixed-width page. -- drawn, which is #codes * 8 for every fixed-width page.
function Font.draw(text, x, y) 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) local shader -- false = unavailable (headless / no shader support)
-- Mobile GPUs often compile this pass but present a black frame, and the
-- level persists in options.lua -- soft-bricking the APK until a manual
-- edit (issue #136). Desktop is unchanged; Android/iOS refuse the effect.
function GBCFX.isSupported()
if not love or not love.system or not love.system.getOS then return true end
local osName = love.system.getOS()
return osName ~= "Android" and osName ~= "iOS"
end
-- GLSL 1.20-compatible (no array initializers; wavelength terms and the -- GLSL 1.20-compatible (no array initializers; wavelength terms and the
-- shadow blur are unrolled by hand). -- shadow blur are unrolled by hand).
local SHADER_SRC = [[ local SHADER_SRC = [[
@@ -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 GBCFX.SHADER_SRC = SHADER_SRC -- exposed for the standalone compile check
function GBCFX.shader() function GBCFX.shader()
if not GBCFX.isSupported() then return nil end
if shader == nil then if shader == nil then
local ok, sh = pcall(love.graphics.newShader, SHADER_SRC) local ok, sh = pcall(love.graphics.newShader, SHADER_SRC)
shader = ok and sh or false shader = ok and sh or false
@@ -232,10 +222,6 @@ function GBCFX.shader()
end end
function GBCFX.setLevel(level) function GBCFX.setLevel(level)
if not GBCFX.isSupported() then
GBCFX.level = 0
return
end
level = math.floor(tonumber(level) or 0) level = math.floor(tonumber(level) or 0)
if level < 0 then level = 0 end if level < 0 then level = 0 end
if level > 4 then level = 4 end if level > 4 then level = 4 end
@@ -244,26 +230,12 @@ end
-- Advance OFF → 1 → 2 → 3 → 4 → OFF. Returns the new level. -- Advance OFF → 1 → 2 → 3 → 4 → OFF. Returns the new level.
function GBCFX.cycle() function GBCFX.cycle()
if not GBCFX.isSupported() then
GBCFX.level = 0
return 0
end
GBCFX.setLevel((GBCFX.level + 1) % 5) GBCFX.setLevel((GBCFX.level + 1) % 5)
return GBCFX.level return GBCFX.level
end end
-- Apply opts.gbcfx. On unsupported platforms force OFF and clear a
-- persisted non-zero value so boot recovers from a soft-brick. Returns
-- true when opts was sanitized (caller should persist options.lua).
function GBCFX.applyOptions(opts) function GBCFX.applyOptions(opts)
if not GBCFX.isSupported() then
local had = opts and (tonumber(opts.gbcfx) or 0) ~= 0
if opts then opts.gbcfx = 0 end
GBCFX.level = 0
return had and true or false
end
GBCFX.setLevel(opts and opts.gbcfx or 0) GBCFX.setLevel(opts and opts.gbcfx or 0)
return false
end end
function GBCFX.levelLabel(level) function GBCFX.levelLabel(level)
@@ -271,7 +243,7 @@ function GBCFX.levelLabel(level)
end end
function GBCFX.active() function GBCFX.active()
return GBCFX.isSupported() and GBCFX.level > 0 and GBCFX.shader() ~= nil return GBCFX.level > 0 and GBCFX.shader() ~= nil
end end
-- Draw `canvas` fullscreen through the GBC FX shader into the current -- Draw `canvas` fullscreen through the GBC FX shader into the current
+6 -43
View File
@@ -242,20 +242,6 @@ function PaletteFX.pack(data)
return data and data.palettes or nil return data and data.palettes or nil
end end
-- SuperPalettes that differ between Red and Blue (pokered data/sgb/
-- sgb_palettes.asm IF DEF(_RED)/_BLUE). data/palettes_gbc.lua is the
-- Red-derived pokered-gbc pack, so under RED++ a Blue playthrough must
-- read these from the ROM-imported table or the title ribbon stays red
-- and the Game Corner reels keep Red's pink (issue #128).
local BLUE_VERSIONED = {
LOGO1 = true, SLOTS2 = true, SLOTS3 = true, SLOTS4 = true,
}
local function romNamedPal(data, name)
local p = data and data.palettes
return p and p.palettes and p.palettes[name]
end
-- named palette from the active pack (nil on stale builds / missing name). -- named palette from the active pack (nil on stale builds / missing name).
-- RED++ falls back to the ROM pack for names the gbc table omits (rare). -- RED++ falls back to the ROM pack for names the gbc table omits (rare).
-- OG RED short-circuits EVERY name to the one global GBC boot-ROM BG palette -- OG RED short-circuits EVERY name to the one global GBC boot-ROM BG palette
@@ -265,15 +251,11 @@ end
-- GBC_OBJ green), so this stays a BG-only hook. -- GBC_OBJ green), so this stays a BG-only hook.
function PaletteFX.pal(data, name) function PaletteFX.pal(data, name)
if PaletteFX.mode == "ogred" then return PaletteFX.ogBg() end if PaletteFX.mode == "ogred" then return PaletteFX.ogBg() end
if GameVersion.isBlue() and BLUE_VERSIONED[name] then
local fromRom = romNamedPal(data, name)
if fromRom then return fromRom end
end
local p = PaletteFX.pack(data) local p = PaletteFX.pack(data)
local c = p and p.palettes[name] local c = p and p.palettes[name]
if c then return c end if c then return c end
if PaletteFX.usesGbcPack() then if PaletteFX.usesGbcPack() and data and data.palettes then
return romNamedPal(data, name) return data.palettes.palettes[name]
end end
return nil return nil
end end
@@ -295,16 +277,6 @@ function PaletteFX.monPal(data, species, transformed)
return p.palettes.GRAYMON return p.palettes.GRAYMON
or (data and data.palettes and data.palettes.palettes.GRAYMON) or (data and data.palettes and data.palettes.palettes.GRAYMON)
end end
-- a species' own `palette` field (per-record override) wins over the
-- vanilla species->name map. Mod-registered palettes live in
-- data.palettes.palettes even when the active pack is the RED++ gbc pack,
-- so fall back to it when the pack itself doesn't carry the name.
local def = data and data.pokemon and data.pokemon[species]
if def and def.palette then
local pal = p.palettes[def.palette]
or (data and data.palettes and data.palettes.palettes[def.palette])
if pal then return pal end
end
local name = p.pokemon[species] or "MEWMON" local name = p.pokemon[species] or "MEWMON"
local c = p.palettes[name] local c = p.palettes[name]
if c then return c end if c then return c end
@@ -318,12 +290,6 @@ end
-- palette name a species currently resolves to (for image-cache keys) -- palette name a species currently resolves to (for image-cache keys)
function PaletteFX.monPalName(data, species, transformed) function PaletteFX.monPalName(data, species, transformed)
if transformed then return "GRAYMON" end if transformed then return "GRAYMON" end
-- honor the per-record palette override, matching monPal
local def = data and data.pokemon and data.pokemon[species]
if def and def.palette and data.palettes
and data.palettes.palettes[def.palette] then
return def.palette
end
local p = PaletteFX.pack(data) local p = PaletteFX.pack(data)
if p and p.pokemon[species] then return p.pokemon[species] end if p and p.pokemon[species] then return p.pokemon[species] end
if data and data.palettes and data.palettes.pokemon[species] then if data and data.palettes and data.palettes.pokemon[species] then
@@ -369,21 +335,18 @@ local TILESET_GROUP_EXCEPTIONS = {
CEMETERY = { tiles = { [0x22] = true }, group = 0 }, 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 -- (block 29, cells 5/6/9/10) at a duplicate tile ($5a, BROWN) so the
-- tabletop and the checkerboard floor -- both raw tile $37 -- can take -- tabletop and the checkerboard floor -- both raw tile $37 -- can take
-- different palettes; the vanilla-derived blockset shares the one tile -- different palettes; the vanilla-derived blockset shares the one tile
-- id, so the RED++ atlas path re-creates the duplicate: the alias slot -- id, so the RED++ atlas path re-creates the duplicate: the alias slot
-- is baked as a copy of `tile` in `group`'s colors, and the listed -- is baked as a copy of `tile` in `group`'s colors, and the listed
-- 0-based block cells draw the alias instead of the shared tile. -- 0-based block cells draw the alias instead of the shared tile.
-- Same block appears on CELADON_MART_ROOF (#52) and CELADON_DINER (#84). PaletteFX.TILE_ALIASES = {
local LOBBY_TABLE_TOP_ALIAS = { CELADON_MART_ROOF = {
{ block = 29, cells = { [5] = true, [6] = true, [9] = true, [10] = true }, { block = 29, cells = { [5] = true, [6] = true, [9] = true, [10] = true },
tile = 0x37, alias = 0x5a, group = 5 }, tile = 0x37, alias = 0x5a, group = 5 },
} },
PaletteFX.TILE_ALIASES = {
CELADON_MART_ROOF = LOBBY_TABLE_TOP_ALIAS,
CELADON_DINER = LOBBY_TABLE_TOP_ALIAS,
} }
local ROOF_GROUP = 6 local ROOF_GROUP = 6
local ROUTE_6_SAFFRON = { mapId = "ROUTE_6", useMapId = "SAFFRON_CITY", cellYBelow = 2 } local ROUTE_6_SAFFRON = { mapId = "ROUTE_6", useMapId = "SAFFRON_CITY", cellYBelow = 2 }
-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 Zoom = require("src.render.Zoom")
local Tilt = require("src.render.Tilt") local Tilt = require("src.render.Tilt")
local PaletteFX = require("src.render.PaletteFX") local PaletteFX = require("src.render.PaletteFX")
local Pipelines = require("src.render.Pipelines")
local Renderer = {} local Renderer = {}
Renderer.WIDTH = 160 Renderer.WIDTH = 160
Renderer.HEIGHT = 144 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 -- 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 -- 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 -- 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. -- endFrame composites the padded canvas back with a matching offset.
Renderer.UPRIGHT_MARGIN = 160 Renderer.UPRIGHT_MARGIN = 160
-- LOVE units + framebuffer pixels + the live unit→pixel ratio.
-- Android's DisplayMetrics.density (love.graphics.getDPIScale) is often
-- non-integer (1.5, 2.75, …). Integer scaling in units then maps each GB
-- pixel to a fractional number of framebuffer pixels → shimmer, uneven /
-- non-square "pixels", and movement judder (issue #87). Always derive the
-- crisp integer scale from the drawable pixel size; draw with (pixels/dpi)
-- so the GPU lands on whole framebuffer pixels. Desktop dpi=1 is unchanged.
local function displayMetrics()
local ww, wh = love.graphics.getDimensions()
local pw, ph = ww, wh
if love.graphics.getPixelDimensions then
pw, ph = love.graphics.getPixelDimensions()
end
local dpi = 1
if ww > 0 and pw > 0 then
dpi = pw / ww
elseif love.graphics.getDPIScale then
dpi = love.graphics.getDPIScale()
end
if not dpi or dpi < 1e-6 then dpi = 1 end
return ww, wh, pw, ph, dpi
end
function Renderer:init() function Renderer:init()
self.canvas = love.graphics.newCanvas(self.WIDTH, self.HEIGHT) self.canvas = love.graphics.newCanvas(self.WIDTH, self.HEIGHT)
self.canvas:setFilter("nearest", "nearest") self.canvas:setFilter("nearest", "nearest")
@@ -73,30 +34,12 @@ function Renderer:init()
-- the projected ground in endFrame; never touched while tilt is off. -- the projected ground in endFrame; never touched while tilt is off.
self.uprightCanvas = nil self.uprightCanvas = nil
self.uprightActive = false 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 end
-- Hand endFrame a pipeline's world image to composite instead of the world -- integer scale that fits the GB UI viewport in the window
-- 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.
function Renderer:fitScale() function Renderer:fitScale()
local _, _, pw, ph = displayMetrics() local ww, wh = love.graphics.getDimensions()
return math.max(1, math.floor(math.min(pw / self.WIDTH, ph / self.HEIGHT))) return math.max(1, math.floor(math.min(ww / self.WIDTH, wh / self.HEIGHT)))
end end
-- world-pass canvas size in world pixels: enough to fill the window at s'. -- world-pass canvas size in world pixels: enough to fill the window at s'.
@@ -105,13 +48,9 @@ end
-- background peeking at the receded top/bottom corners; flat mode returns -- background peeking at the receded top/bottom corners; flat mode returns
-- exactly today's size (growth factor is 1 when tilt is inactive). -- exactly today's size (growth factor is 1 when tilt is inactive).
function Renderer:worldViewSize() function Renderer:worldViewSize()
local ww, wh, _, _, dpi = displayMetrics() local ww, wh = love.graphics.getDimensions()
local s = Zoom.scale(self:fitScale()) / dpi local s = Zoom.scale(self:fitScale())
local vw, vh = Zoom.fillViewSize(s, ww, wh) local vw, vh = Zoom.fillViewSize(s, ww, wh)
-- Even sizes keep Camera:follow on integer pixels (viewW/2 is integral),
-- so unfloored FX/sprite math cannot phase-shimmer against the tile layer.
if vw % 2 ~= 0 then vw = vw + 1 end
if vh % 2 ~= 0 then vh = vh + 1 end
if Tilt.active() then if Tilt.active() then
local g = Tilt.viewGrowth() local g = Tilt.viewGrowth()
vw, vh = math.ceil(vw * g), math.ceil(vh * g) vw, vh = math.ceil(vw * g), math.ceil(vh * g)
@@ -123,12 +62,6 @@ end
function Renderer:beginFrame(transparent) function Renderer:beginFrame(transparent)
self.worldActive = false self.worldActive = false
self.uprightActive = 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 -- last frame's trueColor rects and sprite redraws go before anything
-- draws this one -- draws this one
PaletteFX.clearTrueColor() PaletteFX.clearTrueColor()
@@ -142,46 +75,6 @@ function Renderer:beginFrame(transparent)
end end
end end
-- Black 8x8 (scaled) blocks cascading outward from the classic GB letterbox
-- into the surrounding window, matching BattleTransition wipe progress.
-- Tiles that sit entirely inside the 160x144 square are left to the OG wipe.
function Renderer:drawBattleCascade(prog, ww, wh, ox, oy, vpw, vph, S)
if not prog or prog <= 0 then return end
local TILE = 8 * S
if TILE < 1 then TILE = 1 end
local cols = math.ceil(ww / TILE)
local rows = math.ceil(wh / TILE)
local cx, cy = ox + vpw / 2, oy + vph / 2
local order = {}
for row = 0, rows - 1 do
for col = 0, cols - 1 do
local x, y = col * TILE, row * TILE
-- any tile with area outside the letterbox participates
if x < ox or y < oy or x + TILE > ox + vpw or y + TILE > oy + vph then
local dist = math.max(math.abs(x + TILE / 2 - cx),
math.abs(y + TILE / 2 - cy))
order[#order + 1] = { x, y, dist }
end
end
end
if #order == 0 then return end
table.sort(order, function(a, b)
if a[3] ~= b[3] then return a[3] < b[3] end
if a[2] ~= b[2] then return a[2] < b[2] end
return a[1] < b[1]
end)
local n = math.floor(#order * math.min(1, prog) + 1e-6)
if prog >= 1 then n = #order end
love.graphics.setColor(0, 0, 0, 1)
love.graphics.setScissor(0, 0, ww, wh)
for i = 1, n do
local t = order[i]
love.graphics.rectangle("fill", t[1], t[2], TILE, TILE)
end
love.graphics.setScissor()
love.graphics.setColor(1, 1, 1, 1)
end
function Renderer:beginWorldPass() function Renderer:beginWorldPass()
local vw, vh = self:worldViewSize() local vw, vh = self:worldViewSize()
if not self.worldCanvas or self.worldCanvas:getWidth() ~= vw if not self.worldCanvas or self.worldCanvas:getWidth() ~= vw
@@ -393,14 +286,11 @@ end
-- presented through the GBC FX shader as a final pass. -- presented through the GBC FX shader as a final pass.
function Renderer:endFrame(zones, worldZones) function Renderer:endFrame(zones, worldZones)
love.graphics.setCanvas() love.graphics.setCanvas()
local ww, wh, pw, ph, dpi = displayMetrics() local ww, wh = love.graphics.getDimensions()
-- Sp = integer framebuffer pixels per GB pixel; S = LOVE-unit draw scale. local S = self:fitScale()
local Sp = self:fitScale()
local S = Sp / dpi
local vpw, vph = self.WIDTH * S, self.HEIGHT * S local vpw, vph = self.WIDTH * S, self.HEIGHT * S
-- Snap the letterbox origin to a framebuffer pixel, then convert to units. local ox = math.floor((ww - vpw) / 2)
local ox = math.floor((pw - self.WIDTH * Sp) / 2) / dpi local oy = math.floor((wh - vph) / 2)
local oy = math.floor((ph - self.HEIGHT * Sp) / 2) / dpi
local GBCFX = require("src.render.GBCFX") local GBCFX = require("src.render.GBCFX")
-- Forced mono/Classic modes still need a whole-screen zone when a state -- Forced mono/Classic modes still need a whole-screen zone when a state
-- exposes no SGB packets (raw DMG canvas), so sendColors can remap. -- exposes no SGB packets (raw DMG canvas), so sendColors can remap.
@@ -413,11 +303,7 @@ function Renderer:endFrame(zones, worldZones)
zones = withTrueColor(zones, "ui") zones = withTrueColor(zones, "ui")
worldZones = withTrueColor(worldZones, "world") worldZones = withTrueColor(worldZones, "world")
-- A post-process pipeline needs the whole composite in a canvas for the local needPresent = GBCFX.active()
-- 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 present = nil local present = nil
if needPresent then if needPresent then
if not self.presentCanvas or self.presentCanvas:getWidth() ~= ww if not self.presentCanvas or self.presentCanvas:getWidth() ~= ww
@@ -428,26 +314,13 @@ function Renderer:endFrame(zones, worldZones)
present = self.presentCanvas present = self.presentCanvas
love.graphics.setCanvas(present) love.graphics.setCanvas(present)
end end
-- Default letterbox is black. Battle (and any state that opts in via love.graphics.setColor(0, 0, 0, 1)
-- letterboxWhite) fills the voids white so the window matches the
-- white battle canvas instead of showing black bars.
local clearR, clearG, clearB = 0, 0, 0
if not self.worldActive then
local ok, Game = pcall(require, "src.core.Game")
local stack = ok and Game and Game.stack
local base = stack and stack.visibleBase and stack:visibleBase()
local state = base and stack.states and stack.states[base]
if state and state.letterboxWhite then
clearR, clearG, clearB = 1, 1, 1
end
end
love.graphics.setColor(clearR, clearG, clearB, 1)
love.graphics.rectangle("fill", 0, 0, ww, wh) love.graphics.rectangle("fill", 0, 0, ww, wh)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
-- blit `canvas` at `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 -- 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 function blit(canvas, scale, zoneList, zoneScale, bx, by, boxX, boxY, boxW, boxH)
local shader = zoneList and zoneList[1] and PaletteFX.shader() or nil local shader = zoneList and zoneList[1] and PaletteFX.shader() or nil
if not shader then if not shader then
@@ -478,33 +351,12 @@ function Renderer:endFrame(zones, worldZones)
love.graphics.setShader() love.graphics.setShader()
end end
if self.worldOverride then if self.worldActive then
-- A render pipeline already produced the whole world -- terrain, local s = Zoom.scale(S)
-- 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
local wvw = self.worldCanvas:getWidth() local wvw = self.worldCanvas:getWidth()
local wvh = self.worldCanvas:getHeight() local wvh = self.worldCanvas:getHeight()
local wox = math.floor((pw - wvw * sp) / 2) / dpi local wox = math.floor((ww - wvw * s) / 2)
local woy = math.floor((ph - wvh * sp) / 2) / dpi local woy = math.floor((wh - wvh * s) / 2)
-- Tilt mode projects the ground world pass through the perspective mesh -- Tilt mode projects the ground world pass through the perspective mesh
-- (SGB zones baked in beforehand -- see drawTiltedWorld -- so no zone -- (SGB zones baked in beforehand -- see drawTiltedWorld -- so no zone
-- scissoring here). drawTiltedWorld returns false when tilt is off or -- scissoring here). drawTiltedWorld returns false when tilt is off or
@@ -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.draw(self.uprightCanvas, wox - M * s, woy - M * s, 0, s, s)
love.graphics.setScissor() love.graphics.setScissor()
end 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 end
-- UI stays in the classic centered GB letterbox -- UI stays in the classic centered GB letterbox
blit(self.canvas, S, zones, S, ox, oy, ox, oy, vpw, vph) blit(self.canvas, S, zones, S, ox, oy, ox, oy, vpw, vph)
if present then if present then
love.graphics.setCanvas() love.graphics.setCanvas()
-- Post-process pipelines run over the finished composite -- world, UI GBCFX.present(present, S)
-- 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
end end
self.worldActive = false self.worldActive = false
self.uprightActive = false self.uprightActive = false
self.worldOverride = nil
PaletteFX.setPass(nil) PaletteFX.setPass(nil)
end end
-27
View File
@@ -65,12 +65,8 @@ end
Assets.register(SpriteRenderer.invalidate) 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 STAND = { down = 0, up = 1, left = 2, right = 2 }
local WALK = { down = 3, up = 4, left = 5, right = 5 } 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 -- seed: any stable per-instance value (e.g. an NPC's `id`) used to resolve
-- RED++'s per-instance "random" OBP sentinel (PaletteFX.spriteObp) -- RED++'s per-instance "random" OBP sentinel (PaletteFX.spriteObp)
@@ -87,29 +83,6 @@ function SpriteRenderer.new(spriteDef, seed)
return self return self
end 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 -- 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). -- steps mirror the walk frame for up/down (GB uses OAM flip for this).
local function blitFrame(image, quad, x, y, flip, redraw) local function blitFrame(image, quad, x, y, flip, redraw)
+12 -57
View File
@@ -50,7 +50,6 @@ function TextBox.new(game, text, onDone, opts)
self.charIndex = 0 self.charIndex = 0
self.shown = {} -- visible lines (max 2), each a list of glyph codes self.shown = {} -- visible lines (max 2), each a list of glyph codes
self.waiting = false self.waiting = false
self.contAdvance = false
self.done = false self.done = false
self.blink = 0 self.blink = 0
self:beginLine() self:beginLine()
@@ -66,11 +65,7 @@ TextBox.TOKENS = {
PLAYER = function(game) return game.save.player.name or "RED" end, PLAYER = function(game) return game.save.player.name or "RED" end,
RIVAL = function(game) return game.save.player.rival or "BLUE" end, RIVAL = function(game) return game.save.player.rival or "BLUE" end,
RAM = function(game, arg) RAM = function(game, arg)
if arg == "wStringBuffer" then return game.stringBuffer end return arg == "wStringBuffer" and game.stringBuffer or nil
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
end, end,
} }
@@ -88,55 +83,31 @@ end
-- Split marked-up text into pages of lines. \v-scrolled lines become -- Split marked-up text into pages of lines. \v-scrolled lines become
-- additional lines on the same page (the box scrolls them). -- 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) function TextBox.paginate(text, maxCols)
maxCols = maxCols or (Theme.textBox and Theme.textBox.maxCols) or MAX_COLS maxCols = maxCols or (Theme.textBox and Theme.textBox.maxCols) or MAX_COLS
local pages = {} local pages = {}
local contBefore = {} for pageText in (text .. "\f"):gmatch("(.-)\f") do
local function pushLine(lines, conts, line, wait) if pageText ~= "" then
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 while #line > maxCols do
local cut = maxCols local cut = maxCols
for i = maxCols, 1, -1 do for i = maxCols, 1, -1 do
if line:sub(i, i) == " " then cut = i break end if line:sub(i, i) == " " then cut = i break end
end end
table.insert(lines, line:sub(1, cut)) table.insert(lines, line:sub(1, cut))
table.insert(conts, wait)
wait = false
line = line:sub(cut + 1) line = line:sub(cut + 1)
end end
table.insert(lines, line) table.insert(lines, line)
table.insert(conts, wait)
end end
for pageText in (text .. "\f"):gmatch("(.-)\f") do -- drop trailing empty line from the final gmatch round
if pageText ~= "" then if lines[#lines] == "" then table.remove(lines) end
local lines, conts = {}, {} if #lines > 0 then table.insert(pages, lines) end
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
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)
end end
end end
end if #pages == 0 then pages = { { "" } } end
if #pages == 0 then
pages = { { "" } }
contBefore = { { false } }
end
pages.contBefore = contBefore
return pages return pages
end end
@@ -208,18 +179,11 @@ function TextBox:update(dt)
if input:wasPressed("a") or input:wasPressed("b") then if input:wasPressed("a") or input:wasPressed("b") then
require("src.core.Sound").play(self.game.data, "Press_AB") require("src.core.Sound").play(self.game.data, "Press_AB")
self.waiting = false 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.shown = {}
self.pageIndex = self.pageIndex + 1 self.pageIndex = self.pageIndex + 1
self.lineIndex = 1 self.lineIndex = 1
self:beginLine() self:beginLine()
end end
end
return return
end end
-- typewriter cadence: one character every N frames, N = the OPTION -- typewriter cadence: one character every N frames, N = the OPTION
@@ -239,19 +203,10 @@ function TextBox:update(dt)
-- line finished -- line finished
local page = self.pages[self.pageIndex] local page = self.pages[self.pageIndex]
if self.lineIndex < #page then if self.lineIndex < #page then
local nextIdx = self.lineIndex + 1 self.lineIndex = 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() self:beginLine()
end
elseif self.pageIndex < #self.pages then elseif self.pageIndex < #self.pages then
self.waiting = true self.waiting = true
self.contAdvance = false
else else
self.done = true self.done = true
end end
+16 -159
View File
@@ -10,57 +10,19 @@ TileRenderer.__index = TileRenderer
local BORDER_BLOCKS = 3 -- ring width; > half a screen (2.5 blocks) local BORDER_BLOCKS = 3 -- ring width; > half a screen (2.5 blocks)
-- OVERWORLD maps fill beyond-edge space from save.options.voidFill: -- OVERWORLD maps fill beyond-edge space with the solid tree wall
-- trees (default) — solid tree wall $0F (Viridian/Cerulean/Celadon) -- (blockset $0F: four regular-tree metatiles, tiles $40/$41/$50/$51,
-- water — solid water $43 (Cinnabar/Route 19 border block) -- the border block of ViridianCity/CeruleanCity/CeladonCity et al.),
-- black — solid black (no tiled metatile) -- not each map's own border_block, which can be grass ($0B, the
-- Other tilesets keep their designated border (interiors stay black/void). -- CutTreeBlockSwaps $0B->$0A cut-grass block) or water; other
-- tilesets keep their designated border (interiors stay black/void)
local TREE_WALL_BLOCK = 0x0F local TREE_WALL_BLOCK = 0x0F
local WATER_BORDER_BLOCK = 0x43
TileRenderer.VOID_FILLS = { "trees", "water", "black" }
TileRenderer.voidFill = "trees"
local function borderBlockFor(map) local function borderBlockFor(map)
if map.def.tileset == "OVERWORLD" then if map.def.tileset == "OVERWORLD" then return TREE_WALL_BLOCK end
local mode = TileRenderer.voidFill or "trees"
if mode == "water" then return WATER_BORDER_BLOCK end
if mode == "black" then return false end
return TREE_WALL_BLOCK
end
return map.def.borderBlock return map.def.borderBlock
end end
TileRenderer.borderBlockFor = borderBlockFor TileRenderer.borderBlockFor = borderBlockFor
function TileRenderer.setVoidFill(mode)
local ok = false
for _, m in ipairs(TileRenderer.VOID_FILLS) do
if m == mode then ok = true; break end
end
TileRenderer.voidFill = ok and mode or "trees"
end
function TileRenderer.cycleVoidFill()
local cur = TileRenderer.voidFill or "trees"
local idx = 1
for i, m in ipairs(TileRenderer.VOID_FILLS) do
if m == cur then idx = i; break end
end
TileRenderer.setVoidFill(
TileRenderer.VOID_FILLS[idx % #TileRenderer.VOID_FILLS + 1])
return TileRenderer.voidFill
end
function TileRenderer.applyOptions(opts)
TileRenderer.setVoidFill(opts and opts.voidFill or "trees")
end
function TileRenderer.voidFillLabel(mode)
mode = mode or TileRenderer.voidFill or "trees"
if mode == "water" then return "WATER" end
if mode == "black" then return "BLACK" end
return "TREES"
end
local imageCache = {} local imageCache = {}
local function getImage(path) local function getImage(path)
@@ -93,29 +55,8 @@ local FLOWER_IMAGES = {
local SPINNER_STRIP = "assets/generated/tilesets/spinners.png" local SPINNER_STRIP = "assets/generated/tilesets/spinners.png"
local animFrame = 0 local animFrame = 0
local animAccum = 0 function TileRenderer.tick()
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 animFrame = animFrame + 1
return
end
-- Cap catch-up so a long stall cannot jump many water/flower periods
animAccum = math.min(animAccum + dt, 0.25)
while animAccum >= ANIM_STEP do
animAccum = animAccum - ANIM_STEP
animFrame = animFrame + 1
end
end end
-- ------------------------------------------------------------------ -- ------------------------------------------------------------------
@@ -179,11 +120,6 @@ local function recolorSample(r, g, b, a, colors)
return col[1] / 255, col[2] / 255, col[3] / 255, a return col[1] / 255, col[2] / 255, col[3] / 255, a
end 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 [+ -- the 8 shifted variants of one tile (built once per sheet + tile id [+
-- gbcKey, when `colors` recolors it for RED++ -- see buildAnim]) -- gbcKey, when `colors` recolors it for RED++ -- see buildAnim])
local shiftVariants = {} local shiftVariants = {}
@@ -530,17 +466,11 @@ function TileRenderer.new(map, data)
self.anims = anims self.anims = anims
self.claimedBy = claimedBy self.claimedBy = claimedBy
-- border-fill image is built lazily in :ensureBorderFill so a VOID FILL -- a repeating 32x32 image of the border block, tiled behind
-- option change can swap trees/water/black without reloading the map -- everything the 3-block ring doesn't cover (the survey zoom sees
self.borderFillMode = nil -- far past the ring; interiors keep their black border this way)
self.borderFill = nil pcall(function()
local border = map.tileset.blocks[borderBlockFor(map) + 1]
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 if not border then return end
local canvas = love.graphics.newCanvas(32, 32) local canvas = love.graphics.newCanvas(32, 32)
love.graphics.push("all") love.graphics.push("all")
@@ -558,90 +488,20 @@ local function bakeBorderFill(self, block)
img:setWrap("repeat", "repeat") img:setWrap("repeat", "repeat")
img:setFilter("nearest", "nearest") img:setFilter("nearest", "nearest")
self.borderFill = img self.borderFill = img
end end)
-- WATER void fill: the eight hshift frames of tile $14 (same cycle as map return self
-- water), wrap-tiled so the void scrolls in lockstep with on-map water.
local function ensureWaterBorderFill(self)
if self.borderWaterTextures then return true end
local map = self.map
local perRow = map.tileset.tilesPerRow
local colors, gbcKey
if self.gbcAtlas and self.data then
local group = PaletteFX.worldGroupAt(map.tileset.id, map.id, WATER_TILE)
local groupColors = PaletteFX.worldGroupColors(
self.data, map.tileset.id, map.id, nil)
colors = group and groupColors and groupColors[group + 1] or nil
gbcKey = "#gbc:" .. map.id
end
local textures = getShiftVariants(map.tileset.image, perRow, WATER_TILE,
colors, gbcKey)
if not textures then return false end
for _, img in ipairs(textures) do
img:setWrap("repeat", "repeat")
img:setFilter("nearest", "nearest")
end
self.borderWaterTextures = textures
return true
end
-- (re)build the repeating border image when the VOID FILL mode or tileset
-- choice changes. OVERWORLD "black" leaves borderFill nil and draws a
-- solid clear; "water" keeps the live hshift textures instead of a bake.
function TileRenderer:ensureBorderFill()
local block = borderBlockFor(self.map)
local mode = block == false and "black"
or ((self.map.def.tileset == "OVERWORLD")
and (TileRenderer.voidFill or "trees")
or "map")
local ready = (mode == "black")
or (mode == "water" and self.borderWaterTextures)
or (mode ~= "water" and mode ~= "black" and self.borderFill)
if self.borderFillMode == mode and ready then return end
if self.borderFill and self.borderFill.release then
pcall(self.borderFill.release, self.borderFill)
end
self.borderFill = nil
-- shared shift-variant cache: drop the reference only, never release
self.borderWaterTextures = nil
self.borderFillMode = mode
if mode == "black" or block == false or block == nil then return end
if mode == "water" then
if ensureWaterBorderFill(self) then return end
-- headless / missing pixels: fall back to the static water block bake
end
pcall(bakeBorderFill, self, block)
end end
-- tile the border block across the whole view (world-aligned so it -- tile the border block across the whole view (world-aligned so it
-- meshes seamlessly with the ring batch) -- meshes seamlessly with the ring batch)
function TileRenderer:drawBorderFill(camX, camY, vw, vh) function TileRenderer:drawBorderFill(camX, camY, vw, vh)
self:ensureBorderFill() if not self.borderFill then return end
if self.borderFillMode == "black" then
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("fill", 0, 0, vw, vh)
love.graphics.setColor(1, 1, 1, 1)
return
end
if self.trueColor then PaletteFX.markTrueColor(0, 0, vw, vh) end if self.trueColor then PaletteFX.markTrueColor(0, 0, vw, vh) end
local x, y = math.floor(camX), math.floor(camY) local x, y = math.floor(camX), math.floor(camY)
-- one reused Quad per renderer, mutated in place: this runs every -- one reused Quad per renderer, mutated in place: this runs every
-- overworld frame, so allocating a fresh Quad here churned the GC -- overworld frame, so allocating a fresh Quad here churned the GC
local q = self.borderQuad local q = self.borderQuad
if self.borderFillMode == "water" and self.borderWaterTextures then
local step = math.floor(animFrame / ANIM_PERIOD) % #WATER_OFFSETS + 1
local tex = self.borderWaterTextures[WATER_OFFSETS[step] + 1]
if not tex then return end
if q then
q:setViewport(x, y, vw, vh, 8, 8)
else
q = love.graphics.newQuad(x, y, vw, vh, 8, 8)
self.borderQuad = q
end
love.graphics.draw(tex, q, 0, 0)
return
end
if not self.borderFill then return end
if q then if q then
q:setViewport(x, y, vw, vh, 32, 32) q:setViewport(x, y, vw, vh, 32, 32)
else else
@@ -849,9 +709,6 @@ function TileRenderer:releaseBatches()
safeRelease(self.winBatch); self.winBatch = nil safeRelease(self.winBatch); self.winBatch = nil
safeRelease(self.borderFill); self.borderFill = nil safeRelease(self.borderFill); self.borderFill = nil
safeRelease(self.borderQuad); self.borderQuad = nil safeRelease(self.borderQuad); self.borderQuad = nil
-- shared shift-variant cache; only drop the reference
self.borderWaterTextures = nil
self.borderFillMode = nil
self.win = nil self.win = nil
if self.quads then if self.quads then
for _, q in pairs(self.quads) do safeRelease(q) end for _, q in pairs(self.quads) do safeRelease(q) end
-10
View File
@@ -57,16 +57,6 @@ end
function Transition:draw() function Transition:draw()
local alpha = self.t / self.frames local alpha = self.t / self.frames
if self.phase == "in" then alpha = 1 - alpha end 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.setColor(0, 0, 0, alpha)
love.graphics.rectangle("fill", 0, 0, 160, 144) love.graphics.rectangle("fill", 0, 0, 160, 144)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
+5 -41
View File
@@ -1,7 +1,6 @@
-- Overworld survey zoom: integer pixels-per-world-pixel scales stepped -- Overworld survey zoom: integer pixels-per-world-pixel scales stepped
-- by the mouse wheel, Options ZOOM row, or hotkey `4`. Stored as an -- by the mouse wheel. Stored as an offset from the window fit scale S
-- offset from the window fit scale S so a resize keeps the relative -- so a resize keeps the relative zoom. Session-only; never saved.
-- zoom. Persisted as save.options.zoom (default 0 = FIT).
-- Spec: docs/new-features.md (survey zoom) -- Spec: docs/new-features.md (survey zoom)
local Zoom = {} local Zoom = {}
@@ -13,51 +12,16 @@ function Zoom.scale(S)
return math.max(1, math.min(2 * S, S + Zoom.offset)) return math.max(1, math.min(2 * S, S + Zoom.offset))
end end
-- legal offset range for a given fit scale
function Zoom.offsetRange(S)
S = math.max(1, math.floor(tonumber(S) or 1))
return 1 - S, S
end
function Zoom.clampOffset(offset, S)
local lo, hi = Zoom.offsetRange(S)
offset = math.floor(tonumber(offset) or 0)
if offset < lo then return lo end
if offset > hi then return hi end
return offset
end
function Zoom.step(delta, S) function Zoom.step(delta, S)
Zoom.offset = Zoom.clampOffset(Zoom.offset + delta, S) Zoom.offset = Zoom.offset + delta
return Zoom.offset if S + Zoom.offset < 1 then Zoom.offset = 1 - S end
end if S + Zoom.offset > 2 * S then Zoom.offset = S end
-- Advance one zoom level toward max close-up, then wrap to full survey.
-- Returns the new offset.
function Zoom.cycle(S)
local lo, hi = Zoom.offsetRange(S)
local next = Zoom.offset + 1
if next > hi then next = lo end
Zoom.offset = next
return Zoom.offset
end end
function Zoom.reset() function Zoom.reset()
Zoom.offset = 0 Zoom.offset = 0
end end
function Zoom.applyOptions(opts)
Zoom.offset = math.floor(tonumber(opts and opts.zoom) or 0)
end
-- FIT / OUT1 / OUT2 / … / IN1 / IN2 / …
function Zoom.offsetLabel(offset)
offset = math.floor(tonumber(offset) or 0)
if offset == 0 then return "FIT" end
if offset < 0 then return "OUT" .. tostring(-offset) end
return "IN" .. tostring(offset)
end
-- world pixels covered by a w x h letterbox viewport at fit scale S -- world pixels covered by a w x h letterbox viewport at fit scale S
-- (legacy GB-framed size; prefer fillViewSize for the live world pass) -- (legacy GB-framed size; prefer fillViewSize for the live world pass)
function Zoom.viewSize(S, w, h) function Zoom.viewSize(S, w, h)
-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
+24 -185
View File
@@ -261,9 +261,7 @@ function Commands.move_player(ctx, dir, tiles)
end end
function Commands.move_npc(ctx, objIndex, dir, tiles) function Commands.move_npc(ctx, objIndex, dir, tiles)
local ow = ctx.overworld local npc = ctx.overworld:npcByIndex(objIndex)
if not ow then return end
local npc = ow:npcByIndex(objIndex)
if npc then walkEntity(ctx, npc, dir, tiles) end if npc then walkEntity(ctx, npc, dir, tiles) end
end end
@@ -310,7 +308,6 @@ end
function Commands.move_npc_to(ctx, objIndex, tx, ty) function Commands.move_npc_to(ctx, objIndex, tx, ty)
local ow = ctx.overworld local ow = ctx.overworld
if not ow then return end
local npc = ow:npcByIndex(objIndex) local npc = ow:npcByIndex(objIndex)
if not npc then return end if not npc then return end
local path = bfsPath(ow.map, npc.cellX, npc.cellY, tx, ty, ow.entities, npc) 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 if npc then npc.facing = dir end
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) function Commands.face_npc(ctx)
-- make the player face the talking NPC -- make the player face the talking NPC
if ctx.npc and ctx.overworld then 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) require("src.core.Sound").play(ctx.game.data, soundId)
end 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 -- play_cry <species>: PlayCry (home/audio.asm). The text_asm bodies that
-- use it run text_far (a no-button-wait "...@" string) -> PlayCry -> -- use it run text_far (a no-button-wait "...@" string) -> PlayCry ->
-- WaitForSoundToFinish, all within the same text ID -- the cry only starts -- WaitForSoundToFinish, all within the same text ID -- the cry only starts
@@ -501,52 +470,11 @@ function Commands.heal_party(ctx)
end end
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 <species> <level>: _GivePokemon (engine/events/
-- give_pokemon.asm) -- party first, then the box. ctx.lastCheck gets -- give_pokemon.asm) -- party first, then the box. ctx.lastCheck gets
-- the asm's carry: true when the mon was given, false when both the -- the asm's carry: true when the mon was given, false when both the
-- party and every box are full (that .boxFull path leaves the giver's -- party and every box are full (that .boxFull path leaves the giver's
-- script able to offer again later, e.g. the Celadon Eevee ball). -- script able to offer again later, e.g. the Celadon Eevee ball).
-- 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) function Commands.give_pokemon(ctx, species, level)
-- Native mods can transform a gift before the Pokémon object is created. -- Native mods can transform a gift before the Pokémon object is created.
-- This is intentionally an event rather than a special-case starter hook: -- This is intentionally an event rather than a special-case starter hook:
@@ -558,17 +486,13 @@ function Commands.give_pokemon(ctx, species, level)
end end
local Pokemon = require("src.pokemon.Pokemon") local Pokemon = require("src.pokemon.Pokemon")
local Party = require("src.pokemon.Party") local Party = require("src.pokemon.Party")
local Boxes = require("src.pokemon.Boxes")
local mon = Pokemon.new(ctx.game.data, species, level) local mon = Pokemon.new(ctx.game.data, species, level)
if gift.nickname then mon.nickname = gift.nickname end if gift.nickname then mon.nickname = gift.nickname end
ctx.game.stringBuffer = ctx.game.data.pokemon[species].name or species ctx.game.stringBuffer = ctx.game.data.pokemon[species].name or species
ctx.pendingPokemonName = species ctx.pendingPokemonName = species
require("src.battle.BattleState").stampOT(ctx.save, mon) require("src.battle.BattleState").stampOT(ctx.save, mon)
local addedToParty = Party.add(ctx.save.party, mon) if not Party.add(ctx.save.party, mon) then
local boxNum = nil if not require("src.pokemon.Boxes").deposit(ctx.save, mon) then
if not addedToParty then
boxNum = Boxes.deposit(ctx.save, mon)
if not boxNum then
ctx.lastCheck = false ctx.lastCheck = false
return return
end end
@@ -579,59 +503,18 @@ function Commands.give_pokemon(ctx, species, level)
dex.owned[species] = true dex.owned[species] = true
end end
ctx.lastCheck = true 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 end
function Commands.give_money(ctx, amount) function Commands.give_money(ctx, amount)
ctx.save.money = math.max(0, ctx.save.money + amount) ctx.save.money = math.max(0, ctx.save.money + amount)
end end
-- Point LAST_MAP exits at an outdoor door (pokered wLastMap). Keeps the
-- live overworld memory in sync so a scripted home warp from the HoF PC
-- does not leave Red's house mats aimed at Indigo Plateau (#103).
function Commands.remember_outdoor(ctx, mapId, x, y)
local outdoor = { id = mapId, x = x, y = y }
ctx.save.lastOutdoor = outdoor
if ctx.overworld and ctx.overworld.rememberOutdoor then
ctx.overworld:rememberOutdoor(mapId, x, y)
elseif ctx.overworld then
ctx.overworld.lastOutdoor = outdoor
end
end
-- Hall of Fame: snapshot the winning party (SaveHallOfFameTeams inside -- Hall of Fame: snapshot the winning party (SaveHallOfFameTeams inside
-- AnimateHallOfFame), run the induction showcase and the end credits, -- AnimateHallOfFame), run the induction showcase and the end credits,
-- autosave while THE END is up, then soft-reset to the title -- the whole -- autosave while THE END is up, then soft-reset to the title -- the whole
-- predef HallOfFamePC + tail of HallOfFameResetEventsAndSaveScript -- predef HallOfFamePC + tail of HallOfFameResetEventsAndSaveScript
-- (engine/movie/hall_of_fame.asm, engine/movie/credits.asm, -- (engine/movie/hall_of_fame.asm, engine/movie/credits.asm,
-- scripts/HallOfFame.asm). -- scripts/HallOfFame.asm).
--
-- Departure from pokered: CONTINUE lands in the NewGameWarp bedroom with
-- a healed party and lastOutdoor on Pallet Town, instead of remaining
-- softlocked in HALL_OF_FAME with LAST_MAP still aimed at Indigo (#103).
function Commands.record_hall_of_fame(ctx) function Commands.record_hall_of_fame(ctx)
ctx.save.hallOfFame = ctx.save.hallOfFame or {} ctx.save.hallOfFame = ctx.save.hallOfFame or {}
local entry = {} local entry = {}
@@ -647,25 +530,17 @@ function Commands.record_hall_of_fame(ctx)
Screens.push(game, "Credits", function() Screens.push(game, "Credits", function()
runner:resume() runner:resume()
end, function() end, function()
-- THE END is on screen: heal, place the player at post-game home, -- THE END is on screen: HallOfFameResetEventsAndSaveScript sets
-- retarget LAST_MAP, then SaveGameData. (E4 room-script/event -- wLastBlackoutMap := PALLET_TOWN and runs SaveGameData, so the
-- resets that precede the save in pokered are the Indigo lobby's -- save keeps the player standing in the HALL_OF_FAME room. (The
-- re-entry reset here, data/scripts/story6.lua.) -- E4 room-script/event resets that precede the save in pokered are
local SaveData = require("src.core.SaveData") -- the Indigo lobby's re-entry reset here, data/scripts/story6.lua.)
local Pokemon = require("src.pokemon.Pokemon") -- 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 {} local boot = game.data.field and game.data.field.boot or {}
for _, mon in ipairs(ctx.save.party or {}) do ctx.save.lastHeal = require("src.core.SaveData").defaultHeal(boot)
Pokemon.heal(mon)
end
SaveData.applyPostGameHome(ctx.save, boot)
if game.overworld then
game.overworld.lastOutdoor = ctx.save.lastOutdoor
end
if game.writeSave then game:writeSave() end if game.writeSave then game:writeSave() end
-- writeSave's captureSave re-stamps the live HALL_OF_FAME coords;
-- re-apply home and persist so CONTINUE resumes in the bedroom.
SaveData.applyPostGameHome(ctx.save, boot)
SaveData.save(ctx.save)
end) end)
end) end)
runner:yield() runner:yield()
@@ -823,8 +698,6 @@ function Commands.trade(ctx, tradeIndex, doneFlag)
local newMon = Pokemon.new(data, trade.get, sent.level) local newMon = Pokemon.new(data, trade.get, sent.level)
newMon.nickname = trade.nickname newMon.nickname = trade.nickname
newMon.traded = true -- boosted exp + Name Rater refusal (different OT) 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.remove(party, slot)
table.insert(party, newMon) table.insert(party, newMon)
local dex = ctx.save.pokedex local dex = ctx.save.pokedex
@@ -832,13 +705,9 @@ function Commands.trade(ctx, tradeIndex, doneFlag)
dex.seen[trade.get] = true dex.seen[trade.get] = true
dex.owned[trade.get] = true dex.owned[trade.get] = true
end end
-- InternalClockTradeAnim (engine/movie/trade.asm) -- the trade machine animation (engine/movie/trade.asm)
Screens.push(ctx.game, "TradeAnim", { Screens.push(ctx.game, "TradeAnim", {
sent = sent, received = newMon, 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, onDone = function() runner:resume() end,
}) })
runner:yield() runner:yield()
@@ -986,21 +855,15 @@ function Commands.push_screen(ctx, screenId, args)
runner:yield() runner:yield()
end end
-- fade "out"|"in" [frames|"white"|"black"] [frames|"white"|"black"]: -- fade "out"|"in" [frames]: screen fade without warping (the Transition
-- screen fade without warping (the Transition ramp startWarpTo uses, -- ramp startWarpTo uses, split in two). "out" pushes a black overlay
-- split in two). "out" pushes an overlay that stays up; the held state -- that stays up; the held state keeps ticking the runner's frame-waits
-- keeps ticking the runner's frame-waits so a script can wait / -- so a script can wait/replace_block under it. "in" ramps it away.
-- heal_party / play_once under it. "in" ramps it away.
-- Color defaults to black (warp-style); "white" matches GBFadeOutToWhite
-- / GBFadeInFromWhite (Mom heal, Silph Co. nurse). White defaults to
-- 24 frames (3 palettes x 8); black defaults to 12 (Transition).
local FadeOverlay = {} local FadeOverlay = {}
FadeOverlay.__index = FadeOverlay FadeOverlay.__index = FadeOverlay
function FadeOverlay.new(game, ow, color) function FadeOverlay.new(game, ow)
return setmetatable({ return setmetatable({ game = game, ow = ow, alpha = 0 }, FadeOverlay)
game = game, ow = ow, alpha = 0, color = color or "black",
}, FadeOverlay)
end end
function FadeOverlay:update() function FadeOverlay:update()
@@ -1026,51 +889,28 @@ function FadeOverlay:update()
end end
function FadeOverlay:draw() 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) love.graphics.setColor(0, 0, 0, self.alpha)
end
love.graphics.rectangle("fill", 0, 0, 160, 144) love.graphics.rectangle("fill", 0, 0, 160, 144)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
end end
local function parseFadeArgs(a, b) function Commands.fade(ctx, dir, frames)
local frames, color
if type(a) == "string" then
color = a
if type(b) == "number" then frames = b end
elseif type(a) == "number" then
frames = a
if type(b) == "string" then color = b end
end
color = color or "black"
if not frames then
frames = (color == "white") and 24 or 12
end
return frames, color
end
function Commands.fade(ctx, dir, framesOrColor, colorOrFrames)
local ow = ctx.overworld local ow = ctx.overworld
if not ow then return end if not ow then return end
local runner = ctx.runner local runner = ctx.runner
local frames, color = parseFadeArgs(framesOrColor, colorOrFrames) frames = frames or 12 -- Transition's ramp length
local overlay = ow.fadeOverlay local overlay = ow.fadeOverlay
if dir == "out" then if dir == "out" then
if not overlay then if not overlay then
overlay = FadeOverlay.new(ctx.game, ow, color) overlay = FadeOverlay.new(ctx.game, ow)
ow.fadeOverlay = overlay ow.fadeOverlay = overlay
ctx.game.stack:push(overlay) ctx.game.stack:push(overlay)
else
overlay.color = color
end end
overlay.ramp = { from = overlay.alpha, to = 1, frames = frames, t = 0, overlay.ramp = { from = overlay.alpha, to = 1, frames = frames, t = 0,
onDone = function() runner:resume() end } onDone = function() runner:resume() end }
runner:yield() runner:yield()
elseif dir == "in" then elseif dir == "in" then
if not overlay then return end if not overlay then return end
overlay.color = color or overlay.color
overlay.ramp = { from = overlay.alpha, to = 0, frames = frames, t = 0, overlay.ramp = { from = overlay.alpha, to = 0, frames = frames, t = 0,
onDone = function() runner:resume() end } onDone = function() runner:resume() end }
runner:yield() runner:yield()
@@ -1158,10 +998,9 @@ for _, verb in ipairs({ "show_text", "ask", "choice", "start_battle", "warp",
end end
for _, verb in ipairs({ "show_text", "ask", "choice", "start_battle", "warp", for _, verb in ipairs({ "show_text", "ask", "choice", "start_battle", "warp",
"open_mart", "trade", "push_screen", "record_hall_of_fame", "open_mart", "trade", "push_screen", "record_hall_of_fame",
"old_man_demo", "static_battle", "rival_battle", "give_item", "old_man_demo", "static_battle", "rival_battle", "give_item", "wait",
"give_pokemon", "wait",
"wait_flag", "move_player", "move_npc", "move_npc_to", "walk_npc", "wait_flag", "move_player", "move_npc", "move_npc_to", "walk_npc",
"emote", "fade", "pan_camera", "play_once" }) do "emote", "fade", "pan_camera" }) do
local meta = Commands.meta[verb] or {} local meta = Commands.meta[verb] or {}
Commands.meta[verb] = meta Commands.meta[verb] = meta
meta.blocking = true meta.blocking = true
+1 -9
View File
@@ -216,9 +216,7 @@ local function useOn(game, battle, id, target, list, moveIndex)
consume(game, id) consume(game, id)
require("src.core.Sound").play(game.data, "Teleport_Exit1") require("src.core.Sound").play(game.data, "Teleport_Exit1")
ow.player.surfing = false ow.player.surfing = false
-- EnterMapAnim on arrival (BIT_ESCAPE_WARP / special warp path); ow:warpToHealPoint()
-- blackouts omit arrive="teleport" (HandleBlackOut has no LeaveMapAnim)
ow:warpToHealPoint(nil, { arrive = "teleport" })
else else
showMessages(game, { "OAK: " .. game.save.player.name showMessages(game, { "OAK: " .. game.save.player.name
.. "!\nThis isn't the\ntime to use that!" }) .. "!\nThis isn't the\ntime to use that!" })
@@ -331,12 +329,6 @@ end
local function useItem(game, battle, id, list) local function useItem(game, battle, id, list)
local def = game.data.items[id] 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 if ItemEffects.needsTarget(id, def) and not ItemEffects.isBall(id) then
-- TMs/HMs boot up and announce their move before the target picker -- TMs/HMs boot up and announce their move before the target picker
-- (ItemUseTMHM: BootedUpTMText / BootedUpHMText + TeachMachineMoveText) -- (ItemUseTMHM: BootedUpTMText / BootedUpHMText + TeachMachineMoveText)
+4 -22
View File
@@ -11,9 +11,7 @@ local Input = require("src.core.Input")
local BindingsMenu = setmetatable({}, { __index = ListMenu }) local BindingsMenu = setmetatable({}, { __index = ListMenu })
BindingsMenu.__index = BindingsMenu BindingsMenu.__index = BindingsMenu
-- Input.lua's map, primary key first where several keys share a button. -- Input.lua's map, primary key first where several keys share a button
-- `pad` is the default SDL gamecontroller button (see Input.lua); shown
-- on the SELECT row so controller Back/View is discoverable (#73).
local BUTTONS = { local BUTTONS = {
{ id = "up", label = "UP", key = "up" }, { id = "up", label = "UP", key = "up" },
{ id = "down", label = "DOWN", key = "down" }, { id = "down", label = "DOWN", key = "down" },
@@ -22,7 +20,7 @@ local BUTTONS = {
{ id = "a", label = "A", key = "z" }, { id = "a", label = "A", key = "z" },
{ id = "b", label = "B", key = "x" }, { id = "b", label = "B", key = "x" },
{ id = "start", label = "START", key = "escape" }, { id = "start", label = "START", key = "escape" },
{ id = "select", label = "SELECT", key = "tab", pad = "back" }, { id = "select", label = "SELECT", key = "rshift" },
} }
-- a binding is a plain key string or { key, pad }; absent = the fixed -- a binding is a plain key string or { key, pad }; absent = the fixed
@@ -34,29 +32,13 @@ local function boundKey(overlay, def)
return def.key return def.key
end end
local function boundPad(overlay, def)
local b = overlay and overlay[def.id]
if type(b) == "table" and b.pad then return b.pad end
return def.pad
end
-- Key column for every row. SELECT also appends "/PAD" (default BACK)
-- so controller Select/View is visible without opening a second legend.
local function boundRight(overlay, def)
local key = boundKey(overlay, def)
if def.id ~= "select" then return key:upper() end
local pad = boundPad(overlay, def)
if pad then return (key .. "/" .. pad):upper() end
return key:upper()
end
function BindingsMenu.new(game) function BindingsMenu.new(game)
local overlay = game.save and game.save.options local overlay = game.save and game.save.options
and game.save.options.bindings and game.save.options.bindings
local items = {} local items = {}
for i, def in ipairs(BUTTONS) do for i, def in ipairs(BUTTONS) do
items[i] = { label = def.label, items[i] = { label = def.label,
right = boundRight(overlay, def), button = def } right = boundKey(overlay, def):upper(), button = def }
end end
local self = setmetatable(ListMenu.new(game, "CONTROLS", items, {}), local self = setmetatable(ListMenu.new(game, "CONTROLS", items, {}),
BindingsMenu) BindingsMenu)
@@ -96,7 +78,7 @@ function BindingsMenu:storeBinding(slot, value)
end end
b[slot] = value b[slot] = value
opts.bindings[item.button.id] = b opts.bindings[item.button.id] = b
item.right = boundRight(opts.bindings, item.button) item.right = boundKey(opts.bindings, item.button):upper()
Input:applyBindings(opts.bindings) Input:applyBindings(opts.bindings)
if game.writeOptions then game:writeOptions() end if game.writeOptions then game:writeOptions() end
end end
+19 -100
View File
@@ -3,11 +3,9 @@
-- plus CHANGE BOX. -- plus CHANGE BOX.
local Boxes = require("src.pokemon.Boxes") local Boxes = require("src.pokemon.Boxes")
local Font = require("src.render.Font")
local ListMenu = require("src.ui.ListMenu") local ListMenu = require("src.ui.ListMenu")
local Menu = require("src.ui.Menu") local Menu = require("src.ui.Menu")
local Party = require("src.pokemon.Party") local Party = require("src.pokemon.Party")
local TextBox = require("src.render.TextBox")
local BoxMenu = {} local BoxMenu = {}
@@ -16,11 +14,6 @@ local function monLabel(game, mon)
return ("%s :L%d"):format(mon.nickname or def.name, mon.level) return ("%s :L%d"):format(mon.nickname or def.name, mon.level)
end 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 -- Per-mon submenu (bills_pc.asm DisplayDepositWithdrawMenu): the chosen
-- action + STATS + CANCEL. STATS shows the status screen and returns -- action + STATS + CANCEL. STATS shows the status screen and returns
-- here; CANCEL/B goes back to the list. -- 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 })) }, { tx = 9, ty = 10, tw = 11, th = 8, noSound = true }))
end 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 function withdraw(game)
local box = Boxes.active(game.save) 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 = {} local items = {}
for i, mon in ipairs(box) do for i, mon in ipairs(box) do
table.insert(items, { label = monLabel(game, mon), value = i }) table.insert(items, { label = monLabel(game, mon), value = i })
@@ -74,29 +49,13 @@ local function withdraw(game)
end end
table.remove(box, item.value) table.remove(box, item.value)
table.insert(game.save.party, mon) table.insert(game.save.party, mon)
local name = monName(game, mon) list:close()
game.stringBuffer = name
require("src.core.Sound").playCry(game.data, mon.species)
afterTransfer(game, list, t._MonIsTakenOutText
or (name .. " is\ntaken out.\vGot " .. name .. "."))
end) end)
end, end,
})) }))
end end
local function deposit(game) 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 = {} local items = {}
for i, mon in ipairs(game.save.party) do for i, mon in ipairs(game.save.party) do
table.insert(items, { label = monLabel(game, mon), value = i }) 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!" list.footer = "You need at least\none POKéMON!"
return return
end end
local active = Boxes.active(game.save) local box = Boxes.active(game.save)
if #active >= Boxes.CAPACITY then if #box >= Boxes.CAPACITY then
list.footer = ("BOX %d is full!"):format(game.save.currentBox) list.footer = ("BOX %d is full!"):format(game.save.currentBox)
return return
end end
table.remove(game.save.party, item.value) table.remove(game.save.party, item.value)
table.insert(active, mon) table.insert(box, mon)
local name = monName(game, mon) list:close()
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 .. "."))
end) end)
end, end,
})) }))
end end
-- RELEASE POKéMON (bills_pc.asm BillsPCRelease): confirm, then "Bye [MON]!". -- RELEASE POKéMON (bills_pc.asm .release): confirm, then "Bye [MON]!"
-- Index by list.index (not stale item.value) after removeCurrent (#171).
local function release(game) local function release(game)
local box = Boxes.active(game.save) 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 = {} local items = {}
for i, mon in ipairs(box) do for i, mon in ipairs(box) do
table.insert(items, { label = monLabel(game, mon), value = i }) table.insert(items, { label = monLabel(game, mon), value = i })
end end
game.stack:push(ListMenu.new(game, game.stack:push(ListMenu.new(game,
("BOX %d (RELEASE)"):format(game.save.currentBox), items, { ("BOX %d (RELEASE)"):format(game.save.currentBox), items, {
onChoose = function(_, list) onChoose = function(item, list)
local mon = box[list.index] local mon = box[item.value]
if not mon then return end 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 ChoiceBox = require("src.ui.ChoiceBox")
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
"Once released,\n" .. name .. " is\ngone forever. OK?", function() "Once released,\n" .. name .. " is\ngone forever. OK?", function()
game.stack:push(ChoiceBox.new(game, function(yes) game.stack:push(ChoiceBox.new(game, function(yes)
if not yes then return end 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) require("src.core.Sound").playCry(game.data, mon.species)
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
("%s was\nreleased outside.\fBye %s!"):format(name, name))) ("%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 -- the original asks BEFORE switching ("When you change a #MON
-- BOX, data will be saved. OK?"); declining aborts the change -- BOX, data will be saved. OK?"); declining aborts the change
local ChoiceBox = require("src.ui.ChoiceBox") local ChoiceBox = require("src.ui.ChoiceBox")
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
"When you change a\nPOKéMON BOX, data\nwill be saved. OK?", function() "When you change a\nPOKéMON BOX, data\nwill be saved. OK?", function()
game.stack:push(ChoiceBox.new(game, function(yes) game.stack:push(ChoiceBox.new(game, function(yes)
@@ -193,48 +143,17 @@ local function changeBox(game)
})) }))
end 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) function BoxMenu.new(game)
Boxes.ensure(game.save) Boxes.ensure(game.save)
-- bills_pc.asm BillsPCMenu: TextBoxBorder at (0,0) with interior return Menu.new(game, {
-- 12x10 → total 14x12. "CHANGE BOX" / "WITHDRAW <PK><MN>" need the { label = "WITHDRAW", onSelect = function() withdraw(game) end },
-- full interior (cursor col + label). keepOpen so WITHDRAW/DEPOSIT/ { label = "DEPOSIT", onSelect = function() deposit(game) end },
-- RELEASE/CHANGE BOX leave this menu underneath (jp BillsPCMenu). { label = "RELEASE", onSelect = function() release(game) end },
local menu = Menu.new(game, { { label = "CHANGE BOX", onSelect = function() changeBox(game) end },
{ 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 },
{ label = "SEE YA!" }, { label = "SEE YA!" },
-- Bill's PC runs silent end to end (BIT_NO_MENU_BUTTON_SOUND, -- Bill's PC runs silent end to end (BIT_NO_MENU_BUTTON_SOUND,
-- engine/menus/pokemon_pc.asm) -- engine/menus/pokemon_pc.asm)
}, { tx = 0, ty = 0, tw = 14, th = 12, noSound = true }) }, { tx = 8, ty = 0, tw = 12, th = 12, noSound = true })
local baseDraw = menu.draw
function menu:draw()
baseDraw(self)
drawChrome(game)
end
return menu
end end
return BoxMenu 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 Font = require("src.render.Font")
local Theme = require("src.ui.Theme") 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 self.index = (opts and opts.defaultNo) and 2 or 1
-- BIT_NO_MENU_BUTTON_SOUND: PC-session prompts stay silent -- BIT_NO_MENU_BUTTON_SOUND: PC-session prompts stay silent
self.noSound = (opts and opts.noSound) or false 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 return self
end end
@@ -43,13 +38,13 @@ function ChoiceBox:update(dt)
end end
function ChoiceBox:draw() function ChoiceBox:draw()
local tx, ty, tw, th = self.tx, self.ty, self.tw, self.th local box = Theme.choiceBox
Font.drawBox(tx, ty, tw, th) Font.drawBox(box.tx, box.ty, box.tw, box.th)
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
Font.draw("YES", (tx + 2) * 8, (ty + 1) * 8) Font.draw("YES", (box.tx + 2) * 8, (box.ty + 1) * 8)
Font.draw("NO", (tx + 2) * 8, (ty + 3) * 8) Font.draw("NO", (box.tx + 2) * 8, (box.ty + 3) * 8)
Font.drawCode(Theme.cursor, (tx + 1) * 8, Font.drawCode(Theme.cursor, (box.tx + 1) * 8,
(ty + (self.index == 1 and 1 or 3)) * 8) (box.ty + (self.index == 1 and 1 or 3)) * 8)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
end end
+1 -2
View File
@@ -217,8 +217,7 @@ function Credits:update(dt)
self.timer = FADE_FRAMES self.timer = FADE_FRAMES
elseif self.phase == "end_fade" then elseif self.phase == "end_fade" then
-- Credits returns to HallOfFameResetEventsAndSaveScript here: the -- Credits returns to HallOfFameResetEventsAndSaveScript here: the
-- save (plus post-game home relocate, #103) happens now, then -- save happens now, then 5 x 120 DelayFrames before the button wait
-- 5 x 120 DelayFrames before the button wait
if self.onTheEnd then self.onTheEnd() end if self.onTheEnd then self.onTheEnd() end
self.phase = "end_hold" self.phase = "end_hold"
self.timer = 600 self.timer = 600
+4 -23
View File
@@ -1,11 +1,5 @@
-- Pokédex entry page: front sprite, kind, height/weight and the real -- Pokédex entry page: front sprite, kind, height/weight and the real
-- dex description (data/pokemon/dex_entries.asm + dex_text.asm). -- dex description (data/pokemon/dex_entries.asm + dex_text.asm).
--
-- `species` may be a species id string, or a table
-- `{ species = id, forceOwned = true }`. forceOwned mirrors pret's
-- StarterDex (engine/events/starter_dex.asm), which temporarily sets the
-- owned bit so Oak's lab ball previews show height/weight/description
-- without permanently marking the mon owned.
local Font = require("src.render.Font") local Font = require("src.render.Font")
@@ -22,17 +16,8 @@ function DexEntryMenu:sgbPalettes(game)
P.zone(P.monPal(game.data, self.def and self.def.id), 1, 1, 8, 8) } P.zone(P.monPal(game.data, self.def and self.def.id), 1, 1, 8, 8) }
end end
local function resolveArgs(speciesOrOpts) function DexEntryMenu.new(game, species)
if type(speciesOrOpts) == "table" then local self = setmetatable({ game = game }, DexEntryMenu)
return speciesOrOpts.species or speciesOrOpts[1],
speciesOrOpts.forceOwned and true or false
end
return speciesOrOpts, false
end
function DexEntryMenu.new(game, speciesOrOpts)
local species, forceOwned = resolveArgs(speciesOrOpts)
local self = setmetatable({ game = game, forceOwned = forceOwned }, DexEntryMenu)
self.def = game.data.pokemon[species] self.def = game.data.pokemon[species]
local ok, img = pcall(love.graphics.newImage, self.def.spriteFront) local ok, img = pcall(love.graphics.newImage, self.def.spriteFront)
self.sprite = ok and img or nil self.sprite = ok and img or nil
@@ -57,16 +42,12 @@ function DexEntryMenu:draw()
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
Font.draw(def.name, 72, 8) Font.draw(def.name, 72, 8)
local e = def.dexEntry or {} local e = def.dexEntry or {}
-- English R/B prints only the kind string (hlcoord 9,4 PlaceString). Font.draw((e.kind or "?") .. " POKéMON", 72, 20)
-- PokeText ("#"/POKéMON) is an unreferenced JPN leftover in pokedex.asm;
-- appending " POKéMON" here clipped longer kinds ("LIZARD POKé").
Font.draw(e.kind or "?", 72, 20)
-- same number width as the list (constants.dexDigits), so a dex past 999 -- same number width as the list (constants.dexDigits), so a dex past 999
-- prints the extra digit everywhere at once -- prints the extra digit everywhere at once
local digits = (self.game.data.constants or {}).dexDigits or 3 local digits = (self.game.data.constants or {}).dexDigits or 3
Font.draw(("No.%0" .. digits .. "d"):format(def.dex or 0), 72, 32) Font.draw(("No.%0" .. digits .. "d"):format(def.dex or 0), 72, 32)
local owned = self.forceOwned local owned = self.game.save.pokedex and self.game.save.pokedex.owned[def.id]
or (self.game.save.pokedex and self.game.save.pokedex.owned[def.id])
-- height/weight print only once owned, like the description -- height/weight print only once owned, like the description
-- (pokedex.asm: "if the pokemon has not been owned, don't print the -- (pokedex.asm: "if the pokemon has not been owned, don't print the
-- height, weight, or description") -- height, weight, or description")
+74 -199
View File
@@ -1,16 +1,14 @@
-- Hall of Fame induction (engine/movie/hall_of_fame.asm): each party -- Hall of Fame induction (engine/movie/hall_of_fame.asm): each party
-- member's front sprite scrolls onto the right side of the screen -- member's front sprite scrolls onto the screen (HoFShowMonOrPlayer's
-- (HoFShowMonOrPlayer's .ScrollPic), then HoFDisplayMonInfo draws the -- .ScrollPic), then its name/level shows and its cry plays
-- left-side LEVEL/TYPE box, plays the cry, holds, and pops the bottom -- (HoFDisplayAndRecordMonInfo). After the last mon, HoFDisplayPlayerStats
-- "HALL OF FAME" text box before fading to the next mon. After the -- shows the trainer name, play time, money and Prof. Oak's dex rating.
-- party, the player pic scrolls in and HoFDisplayPlayerStats shows the -- Plays Music_HallOfFame when the audio data has it. Calls onDone() after
-- name/time/money boxes plus the dex rating. Plays Music_HallOfFame -- popping itself.
-- when the audio data has it. Calls onDone() after popping itself.
local Font = require("src.render.Font") local Font = require("src.render.Font")
local Music = require("src.core.Music") local Music = require("src.core.Music")
local Sound = require("src.core.Sound") local Sound = require("src.core.Sound")
local TypeChart = require("src.battle.TypeChart")
local HallOfFame = {} local HallOfFame = {}
HallOfFame.__index = HallOfFame HallOfFame.__index = HallOfFame
@@ -19,10 +17,6 @@ HallOfFame.isOpaque = true
-- SGB: SetPal_PokemonWholeScreen for the mon on display -- SGB: SetPal_PokemonWholeScreen for the mon on display
function HallOfFame:sgbPalettes(game) function HallOfFame:sgbPalettes(game)
local P = require("src.render.PaletteFX") local P = require("src.render.PaletteFX")
if self.phase == "player" or self.phase == "player_stats"
or self.phase == "player_dex" or self.phase == "player_rating" then
return P.wholeNamed(game.data, "MEWMON")
end
local mon = game.save.party[self.index or 0] local mon = game.save.party[self.index or 0]
if mon then if mon then
local c = P.monPal(game.data, mon.species) local c = P.monPal(game.data, mon.species)
@@ -32,20 +26,17 @@ function HallOfFame:sgbPalettes(game)
return P.wholeNamed(game.data, "MEWMON") return P.wholeNamed(game.data, "MEWMON")
end end
local MON_FRAMES = 150 -- ~2.5s per inductee (A advances early)
-- HoFShowMonOrPlayer's .ScrollPic: hSCX is nudged by e = 4px per -- HoFShowMonOrPlayer's .ScrollPic: hSCX is nudged by e = 4px per
-- DelayFrame (doubled on SGB) until it settles. The front pic rests at -- DelayFrame (doubled on SGB) until it settles. The back pic (an
-- hlcoord 12,5 (engine/movie/hall_of_fame.asm HoFLoadMonPlayerPicTileIDs). -- 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 SCROLL_SPEED = 4 -- px/frame @ 60fps
local PIC_X, PIC_Y = 12 * 8, 5 * 8
-- After HoFDisplayAndRecordMonInfo: 80 DelayFrames, then the bottom
-- HALL OF FAME box for 180 DelayFrames, then GBFadeOutToWhite.
local INFO_HOLD = 80
local HOF_HOLD = 180
local FADE_FRAMES = 20
-- HoFPrintTextAndDelay after each dex line
local DEX_HOLD = 120
local function tryImage(path) local function tryImage(path)
if not path then return nil end if not path then return nil end
@@ -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 for line in (text:gsub("\v", "\n"):gsub("\f", "\n") .. "\n"):gmatch("(.-)\n") do
if maxY and y > maxY then break end if maxY and y > maxY then break end
Font.draw(line, x, y) Font.draw(line, x, y)
y = y + 8 y = y + 10
end end
return y return y
end end
@@ -79,10 +70,6 @@ function HallOfFame.new(game, onDone)
self.timer = 0 self.timer = 0
self.phase = "mons" self.phase = "mons"
self.sprites = {} -- species -> image or false self.sprites = {} -- species -> image or false
self.playerPic = tryImage("assets/generated/trainer_card/red.png")
self.scrollX = PIC_X
self.showHofBanner = false
self.fade = 0
return self return self
end end
@@ -97,21 +84,16 @@ end
function HallOfFame:nextMon() function HallOfFame:nextMon()
self.index = self.index + 1 self.index = self.index + 1
local mon = self.game.save.party[self.index] local mon = self.game.save.party[self.index]
self.showHofBanner = false
self.fade = 0
if mon then if mon then
self.phase = "mons" self.timer = MON_FRAMES
self.timer = INFO_HOLD
Sound.playCry(self.game.data, mon.species) Sound.playCry(self.game.data, mon.species)
-- scroll the new inductee's pic in from the left (see SCROLL_SPEED)
local sprite = self:spriteFor(mon.species) local sprite = self:spriteFor(mon.species)
local w = sprite and sprite:getWidth() or 56 local w = sprite and sprite:getWidth() or 0
self.scrollRestX = math.floor((160 - w) / 2)
self.scrollX = -w self.scrollX = -w
else else
-- HoFShowMonOrPlayer with wHoFMonOrPlayer = player self.phase = "congrats"
self.phase = "player"
self.timer = 0
local w = self.playerPic and self.playerPic:getWidth() or 56
self.scrollX = -w
end end
end end
@@ -135,184 +117,77 @@ function HallOfFame:dexSeenOwned()
return seen, owned return seen, owned
end 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) function HallOfFame:update(dt)
local input = self.game.input local input = self.game.input
local skip = input:wasPressed("a") if self.phase == "mons" then
if self.scrollX and self.scrollX < self.scrollRestX then
if self.phase == "mons" or self.phase == "fade" then self.scrollX = math.min(self.scrollRestX, self.scrollX + SCROLL_SPEED)
if self.phase == "mons" and self.scrollX < PIC_X then
self.scrollX = math.min(PIC_X, self.scrollX + SCROLL_SPEED)
return
end
if self.phase == "fade" then
self.timer = self.timer - 1
self.fade = 1 - math.max(0, self.timer) / FADE_FRAMES
if self.timer <= 0 or skip then self:advanceMonPhase() end
return
end end
self.timer = self.timer - 1 self.timer = self.timer - 1
if skip or self.timer <= 0 then if input:wasPressed("a") or self.timer <= 0 then
self:advanceMonPhase() self:nextMon()
end end
elseif self.phase == "player" then elseif input:wasPressed("a") then
if self.scrollX < PIC_X then Sound.play(self.game.data, "Press_AB")
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() self.game.stack:pop()
if self.onDone then self.onDone() end if self.onDone then self.onDone() end
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)
end
end end
function HallOfFame:draw() function HallOfFame:draw()
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144) love.graphics.rectangle("fill", 0, 0, 160, 144)
love.graphics.setColor(0, 0, 0, 1)
if self.phase == "mons" or self.phase == "fade" then if self.phase == "mons" then
Font.draw("HALL OF FAME", (160 - 12 * 8) / 2, 8)
local mon = self.game.save.party[self.index] local mon = self.game.save.party[self.index]
if mon then if mon then
self:drawPic(self:spriteFor(mon.species)) local def = self.game.data.pokemon[mon.species]
if self.scrollX >= PIC_X then love.graphics.setColor(1, 1, 1, 1)
self:drawMonInfo(mon) local sprite = self:spriteFor(mon.species)
if self.showHofBanner then if sprite then
self:drawHofBanner() local w, h = sprite:getDimensions()
love.graphics.draw(sprite, self.scrollX or math.floor((160 - w) / 2), 96 - h)
end 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 end
end else
if self.phase == "fade" and self.fade > 0 then -- HoFDisplayPlayerStats (no "HALL OF FAME" banner here -- the real
love.graphics.setColor(1, 1, 1, self.fade) -- screen is a fresh ClearScreen): trainer name, play time, money,
love.graphics.rectangle("fill", 0, 0, 160, 144) -- then the POKéDEX seen/owned tally and Prof. Oak's rating text,
end -- using the same real save-data fields as TrainerCard.lua
elseif self.phase == "player" then -- (save.player.name/playTime/money) and PokedexMenu.lua/
self:drawPic(self.playerPic) -- OverworldController:dexRating (save.pokedex.seen/owned).
elseif self.phase == "player_stats" then local save = self.game.save
self:drawPic(self.playerPic) local text = self.game.data.text or {}
self:drawPlayerStats() local y = 8
elseif self.phase == "player_dex" then Font.draw(save.player.name or "RED", 8, y)
self:drawPic(self.playerPic) y = y + 16
self:drawPlayerStats() local t = math.floor(save.playTime or 0)
self:drawDexBox("seen") Font.draw(("PLAY TIME %3d:%02d"):format(math.floor(t / 3600),
elseif self.phase == "player_rating" then math.floor(t / 60) % 60), 8, y)
self:drawPic(self.playerPic) y = y + 12
self:drawPlayerStats() Font.draw(("MONEY ¥%d"):format(save.money or 0), 8, y)
self:drawDexBox("rating") y = y + 16
end
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) love.graphics.setColor(1, 1, 1, 1)
end 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 -- text box, a money box sits top-right, and the list shortens to
-- clear them (DisplayPokemartDialogue_'s screen) -- clear them (DisplayPokemartDialogue_'s screen)
self.dialogue = opts.dialogue 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.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 return self
end end
@@ -121,7 +117,7 @@ function ListMenu:draw()
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
end end
if item.right then 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 end
if i == self.index then if i == self.index then
-- hollowIndex: a chosen row keeps the hollow '▷' left behind by -- hollowIndex: a chosen row keeps the hollow '▷' left behind by
@@ -140,11 +136,9 @@ function ListMenu:draw()
Font.drawBox(11, 0, 9, 3) Font.drawBox(11, 0, 9, 3)
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
local money = ("¥%d"):format(self.money and self.money() or 0) local money = ("¥%d"):format(self.money and self.money() or 0)
Font.draw(money, 152 - Font.width(money), 8) Font.draw(money, 152 - #money * 8, 8)
end -- the clerk's line in the standard bottom text box; long prompts
if self.dialogue or (self.messageBox and self.footer) then -- wrap and keep their last two lines, like the GB's scrolled box
-- standard bottom text box (PrintText); long prompts wrap and keep
-- their last two lines, like the GB's scrolled box (#115/#174)
Font.drawBox(0, 12, 20, 6) Font.drawBox(0, 12, 20, 6)
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
if self.footer then if self.footer then
@@ -159,16 +153,7 @@ function ListMenu:draw()
end end
end end
elseif self.footer then elseif self.footer then
-- bare footer (bag money line, etc.) Font.draw(self.footer, 8, 136)
local flat = {}
for _, page in ipairs(require("src.render.TextBox").paginate(self.footer)) do
for _, line in ipairs(page) do flat[#flat + 1] = line end
end
local y = (#flat >= 2) and 120 or 136
for i = math.max(1, #flat - 1), #flat do
Font.draw(flat[i], 8, y)
y = y + 16
end
end end
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
end end
+4 -43
View File
@@ -19,16 +19,7 @@ function Menu.new(game, items, opts)
self.tx = opts.tx or 10 self.tx = opts.tx or 10
self.ty = opts.ty or 0 self.ty = opts.ty or 0
self.tw = opts.tw or 10 self.tw = opts.tw or 10
self.rowStep = opts.rowStep or 2 self.th = opts.th or (#items * 2 + 2)
-- maxVisible: cap the box to this many rows and scroll the rest instead
-- of growing past it (e.g. the start menu, whose row count varies with
-- save state and mod hooks); nil/unset keeps every caller's old
-- behavior of sizing the box to fit all items.
self.maxVisible = opts.maxVisible
self.scroll = 0
local visible = (self.maxVisible and math.min(self.maxVisible, #items))
or #items
self.th = opts.th or (visible * self.rowStep + 2)
self.cancelable = opts.cancelable ~= false self.cancelable = opts.cancelable ~= false
-- Whether START closes the menu. In pokered a menu responds only to the -- Whether START closes the menu. In pokered a menu responds only to the
-- keys in its wMenuWatchedKeys mask; the common PAD_A | PAD_B (and the -- keys in its wMenuWatchedKeys mask; the common PAD_A | PAD_B (and the
@@ -40,25 +31,9 @@ function Menu.new(game, items, opts)
-- BIT_NO_MENU_BUTTON_SOUND (wMiscFlags): the PC session runs its -- BIT_NO_MENU_BUTTON_SOUND (wMiscFlags): the PC session runs its
-- menus silent (home/window.asm HandleMenuInput_) -- menus silent (home/window.asm HandleMenuInput_)
self.noSound = opts.noSound or false self.noSound = opts.noSound or false
self:clampScroll()
return self return self
end end
-- keeps self.index inside the visible [scroll+1, scroll+maxVisible] window;
-- callers that move self.index directly (e.g. restoring a saved cursor
-- position) should call this afterwards to scroll it into view
function Menu:clampScroll()
if not (self.maxVisible and #self.items > self.maxVisible) then
self.scroll = 0
return
end
if self.index - self.scroll > self.maxVisible then
self.scroll = self.index - self.maxVisible
elseif self.index - self.scroll < 1 then
self.scroll = self.index - 1
end
end
function Menu:update(dt) function Menu:update(dt)
local input = self.game.input local input = self.game.input
if input:wasPressed("up") then if input:wasPressed("up") then
@@ -86,29 +61,15 @@ function Menu:update(dt)
self.game.stack:pop() self.game.stack:pop()
if self.onCancel then self.onCancel() end if self.onCancel then self.onCancel() end
end end
self:clampScroll()
end end
function Menu:draw() function Menu:draw()
Font.drawBox(self.tx, self.ty, self.tw, self.th) Font.drawBox(self.tx, self.ty, self.tw, self.th)
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
local visible = (self.maxVisible and math.min(self.maxVisible, #self.items)) for i, item in ipairs(self.items) do
or #self.items Font.draw(item.label, (self.tx + 2) * 8, (self.ty + i * 2 - 1) * 8)
for row = 1, visible do
local item = self.items[self.scroll + row]
if not item then break end
Font.draw(item.label, (self.tx + 2) * 8,
(self.ty + row * self.rowStep - (self.rowStep - 1)) * 8)
end
local cursorRow = self.index - self.scroll
Font.drawCode(Theme.cursor, (self.tx + 1) * 8,
(self.ty + cursorRow * self.rowStep - (self.rowStep - 1)) * 8)
-- moreArrow ($EE): the same "more below" glyph OptionRows/ManagerState
-- use, sat on the bottom border like TextBox's page-advance cursor
if self.maxVisible and self.scroll + self.maxVisible < #self.items then
Font.drawCode(Theme.moreArrow, (self.tx + self.tw - 2) * 8,
(self.ty + self.th - 2) * 8)
end end
Font.drawCode(Theme.cursor, (self.tx + 1) * 8, (self.ty + self.index * 2 - 1) * 8)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
end end
+11 -23
View File
@@ -22,8 +22,6 @@ function MoveLearnMenu.new(game, mon, newMoveId, onDone)
self.newMoveId = newMoveId self.newMoveId = newMoveId
self.onDone = onDone self.onDone = onDone
self.index = 1 self.index = 1
-- forget-list UI only after TryingToLearn YES (learn_move.asm .loop)
self.selecting = false
return self return self
end end
@@ -33,32 +31,24 @@ end
-- TryingToLearnText + yes/no (learn_move.asm TryingToLearn): NO offers -- TryingToLearnText + yes/no (learn_move.asm TryingToLearn): NO offers
-- AbandonLearning, whose own NO loops back here (DontAbandonLearning). -- 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() function MoveLearnMenu:enter()
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
local ChoiceBox = require("src.ui.ChoiceBox")
local game = self.game local game = self.game
local mdef = game.data.moves[self.newMoveId] local mdef = game.data.moves[self.newMoveId]
local name = self:monName() local name = self:monName()
self.selecting = false
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
("%s is\ntrying to learn\v%s!\fBut, %s\ncan't learn more\vthan 4 moves!\f") ("%s is\ntrying to learn\v%s!\fBut, %s\ncan't learn more\vthan 4 moves!\f")
:format(name, mdef.name, name) .. :format(name, mdef.name, name) ..
("Delete an older\nmove to make room\vfor %s?"):format(mdef.name), ("Delete an older\nmove to make room\vfor %s?"):format(mdef.name),
nil, { function()
choice = function(yes) game.stack:push(ChoiceBox.new(game, function(yes)
if yes then if not yes then self:confirmAbandon() end
self.selecting = true end))
else end))
self:confirmAbandon()
end
end,
}))
end end
function MoveLearnMenu:update(dt) function MoveLearnMenu:update(dt)
if not self.selecting then return end
local input = self.game.input local input = self.game.input
local n = #self.mon.moves + 1 -- moves + CANCEL local n = #self.mon.moves + 1 -- moves + CANCEL
if input:wasPressed("up") then if input:wasPressed("up") then
@@ -92,15 +82,15 @@ end
-- (DontAbandonLearning) -- (DontAbandonLearning)
function MoveLearnMenu:confirmAbandon() function MoveLearnMenu:confirmAbandon()
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
local ChoiceBox = require("src.ui.ChoiceBox")
local game = self.game local game = self.game
local mdef = game.data.moves[self.newMoveId] local mdef = game.data.moves[self.newMoveId]
self.selecting = false
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
("Abandon learning\n%s?"):format(mdef.name), nil, { ("Abandon learning\n%s?"):format(mdef.name), function()
choice = function(yes) game.stack:push(ChoiceBox.new(game, function(yes)
if yes then self:finish(false) else self:enter() end if yes then self:finish(false) else self:enter() end
end, end))
})) end))
end end
function MoveLearnMenu:finish(learned) function MoveLearnMenu:finish(learned)
@@ -108,7 +98,6 @@ function MoveLearnMenu:finish(learned)
local game = self.game local game = self.game
local name = self:monName() local name = self:monName()
local mdef = game.data.moves[self.newMoveId] local mdef = game.data.moves[self.newMoveId]
self.selecting = false
game.stack:pop() game.stack:pop()
local msg local msg
if learned then if learned then
@@ -125,7 +114,6 @@ function MoveLearnMenu:finish(learned)
end end
function MoveLearnMenu:draw() function MoveLearnMenu:draw()
if not self.selecting then return end
-- single-spaced move list box (TryingToLearn: TextBoxBorder at 4,7) -- single-spaced move list box (TryingToLearn: TextBoxBorder at 4,7)
-- plus the port's extra CANCEL row -- plus the port's extra CANCEL row
Font.drawBox(4, 5, 16, 7) Font.drawBox(4, 5, 16, 7)
+2 -84
View File
@@ -3,38 +3,25 @@
-- (cycles the merged rulesets registry; gen1_faithful keeps the original -- (cycles the merged rulesets registry; gen1_faithful keeps the original
-- quirks), plus the port's audio rows and display rows: music/SFX -- quirks), plus the port's audio rows and display rows: music/SFX
-- volume (0-7), music low-pass filter (OFF/1X/2X/3X), COLORS / TILT / -- volume (0-7), music low-pass filter (OFF/1X/2X/3X), COLORS / TILT /
-- GBC FX / ZOOM / VOID FILL / VIDEO MODE, and the MODS row that opens -- GBC FX / VIDEO MODE, and the MODS row that opens the mod manager.
-- the mod manager.
-- Rows are descriptors fed through the ui.options.rows hook, so mods can -- Rows are descriptors fed through the ui.options.rows hook, so mods can
-- add their own; CANCEL is appended after the hook and stays fixed on the -- add their own; CANCEL is appended after the hook and stays fixed on the
-- bottom line like pokered's. -- bottom line like pokered's.
local PaletteFX = require("src.render.PaletteFX") local PaletteFX = require("src.render.PaletteFX")
local Pipelines = require("src.render.Pipelines")
local Tilt = require("src.render.Tilt") local Tilt = require("src.render.Tilt")
local GBCFX = require("src.render.GBCFX") local GBCFX = require("src.render.GBCFX")
local Zoom = require("src.render.Zoom")
local TileRenderer = require("src.render.TileRenderer")
local GameSpeed = require("src.core.GameSpeed") local GameSpeed = require("src.core.GameSpeed")
local VideoMode = require("src.core.VideoMode") local VideoMode = require("src.core.VideoMode")
local FrameCap = require("src.core.FrameCap") local FrameCap = require("src.core.FrameCap")
local Logger = require("src.core.Logger") local Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime") local Runtime = require("src.mods.Runtime")
local OptionRows = require("src.ui.OptionRows") local OptionRows = require("src.ui.OptionRows")
local Renderer = require("src.render.Renderer")
local OptionsMenu = {} local OptionsMenu = {}
OptionsMenu.__index = OptionsMenu OptionsMenu.__index = OptionsMenu
OptionsMenu.isOpaque = true OptionsMenu.isOpaque = true
-- Opaque full-screen menu: own MEWMON so opening OPTION from the title
-- (or over the overworld) does not inherit TitleState's LOGO1 band -- that
-- zone covers UI rows 8-9, which is the third options box label line
-- (pink "MODS" strip when Blue's ROM LOGO1 white is {255,239,255}).
function OptionsMenu:sgbPalettes(game)
return PaletteFX.wholeNamed(game.data, "MEWMON")
end
-- TextSpeedOptionData frame delays with the original labels -- TextSpeedOptionData frame delays with the original labels
local SPEEDS = { { 1, "FAST" }, { 3, "MEDIUM" }, { 5, "SLOW" } } local SPEEDS = { { 1, "FAST" }, { 3, "MEDIUM" }, { 5, "SLOW" } }
-- no-loader fallback for the ruleset row, same pair BattleState keeps -- no-loader fallback for the ruleset row, same pair BattleState keeps
@@ -115,7 +102,7 @@ local function sameRows(_, rows) return rows end
-- the vanilla rows as descriptors; each step body is the old per-index -- the vanilla rows as descriptors; each step body is the old per-index
-- ladder's, so the save.options mutations are unchanged -- ladder's, so the save.options mutations are unchanged
local function buildRows(game) local function buildRows(game)
local rows = { return {
{ id = "textSpeed", label = "TEXT SPEED", { id = "textSpeed", label = "TEXT SPEED",
value = function(g) return SPEEDS[speedIndex(g)][2] end, value = function(g) return SPEEDS[speedIndex(g)][2] end,
step = function(g) step = function(g)
@@ -194,15 +181,6 @@ local function buildRows(game)
local o = g.save.options local o = g.save.options
o.tilt = wrapIndex((o.tilt or 0) + dir, 4) o.tilt = wrapIndex((o.tilt or 0) + dir, 4)
Tilt.setLevel(o.tilt) 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 return true
end }, end },
{ id = "gbcfx", label = "GBC FX", { id = "gbcfx", label = "GBC FX",
@@ -215,37 +193,6 @@ local function buildRows(game)
GBCFX.setLevel(o.gbcfx) GBCFX.setLevel(o.gbcfx)
return true return true
end }, end },
{ id = "zoom", label = "ZOOM",
value = function(g)
return Zoom.offsetLabel(g.save.options.zoom or 0)
end,
step = function(g, dir)
local o = g.save.options
local S = Renderer:fitScale()
local lo, hi = Zoom.offsetRange(S)
local off = (o.zoom or 0) + dir
if off > hi then off = lo
elseif off < lo then off = hi end
o.zoom = off
Zoom.offset = off
return true
end },
{ id = "voidFill", label = "VOID FILL",
value = function(g)
return TileRenderer.voidFillLabel(g.save.options.voidFill)
end,
step = function(g, dir)
local o = g.save.options
local modes = TileRenderer.VOID_FILLS
local cur = o.voidFill or "trees"
local i = 1
for idx, m in ipairs(modes) do
if m == cur then i = idx; break end
end
o.voidFill = modes[wrapIndex(i - 1 + dir, #modes) + 1]
TileRenderer.setVoidFill(o.voidFill)
return true
end },
{ id = "videoMode", label = "VIDEO MODE", { id = "videoMode", label = "VIDEO MODE",
value = function(g) value = function(g)
return VideoMode.modeLabel(g.save.options.videoMode) return VideoMode.modeLabel(g.save.options.videoMode)
@@ -297,35 +244,6 @@ local function buildRows(game)
require("src.ui.Screens").push(g, "BindingsMenu") require("src.ui.Screens").push(g, "BindingsMenu")
end }, end },
} }
-- issue #136: hide GBC FX on Android/iOS -- the present shader soft-bricks
if not GBCFX.isSupported() then
local filtered = {}
for _, row in ipairs(rows) do
if row.id ~= "gbcfx" then filtered[#filtered + 1] = row end
end
rows = filtered
end
-- 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 end
function OptionsMenu.new(game) function OptionsMenu.new(game)
+17 -72
View File
@@ -1,22 +1,16 @@
-- Party menu: list the party, choose a member. -- Party menu: list the party, choose a member.
-- Modes: -- Modes:
-- default: A -> submenu (STATS / SWITCH / field moves) -- default: A -> submenu (STATS / SWITCH order / CANCEL)
-- opts.onSwitch + opts.battle (voluntary PKMN): A -> SWITCH / STATS / -- opts.onSwitch: A -> hand the chosen mon to the callback (battle
-- CANCEL (core.asm PartyMenuOrRockOrRun), then onSwitch on SWITCH -- switch, item targeting via opts.pickOnly)
-- opts.onSwitch + opts.forceSwitch: A -> onSwitch immediately
-- (ChooseNextMon / SHIFT free-switch)
-- opts.pickOnly + opts.onSwitch: A -> onSwitch (item / script target)
-- opts.onCancel: fired when the menu closes without a pick (B) -- opts.onCancel: fired when the menu closes without a pick (B)
-- Pops itself on B. -- Pops itself on B.
local Assets = require("src.render.Assets")
local Font = require("src.render.Font") local Font = require("src.render.Font")
local Logger = require("src.core.Logger") local Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime") local Runtime = require("src.mods.Runtime")
local Screens = require("src.ui.Screens") local Screens = require("src.ui.Screens")
local Theme = require("src.ui.Theme") local Theme = require("src.ui.Theme")
local FieldDefaults = require("src.world.FieldDefaults")
local Map = require("src.world.Map")
local PartyMenu = {} local PartyMenu = {}
PartyMenu.__index = PartyMenu PartyMenu.__index = PartyMenu
@@ -75,32 +69,13 @@ local function drawIcon(game, mon, x, y, selected, counter)
local icons = game.data.icons local icons = game.data.icons
if not icons then return end if not icons then return end
local def = game.data.pokemon[mon.species] local def = game.data.pokemon[mon.species]
-- Per-species override first: the icons registry folds into -- byDex is the vanilla lookup, but the icons registry can bring the table
-- icons.bySpecies, and a pokemon record may carry its own `icon` field. -- into existence on its own, so it may be the only key present
-- Either is a built-in icon name (resolved through icons.icons) or a local name = def and def.dex and icons.byDex and icons.byDex[def.dex]
-- { image = <path>, frames? } table pointing at bundled art. Falling local path = name and icons.icons[name]
-- through to icons.byDex[def.dex] keeps the vanilla dex-indexed default;
-- without the override a modded or dex-renumbered species could never
-- change its menu icon.
local entry = (icons.bySpecies and icons.bySpecies[mon.species])
or (def and def.icon)
local name, path
if type(entry) == "string" then
name = entry
path = icons.icons and icons.icons[entry]
elseif type(entry) == "table" then
path = entry.image
end
if not path then
name = def and def.dex and icons.byDex and icons.byDex[def.dex]
path = name and icons.icons and icons.icons[name]
end
if not path then return end if not path then return end
if iconImages[path] == nil then if iconImages[path] == nil then
-- resolve through Assets so an overrides/ or transform-derived icon local ok, img = pcall(love.graphics.newImage, path)
-- (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))
iconImages[path] = ok and img or false iconImages[path] = ok and img or false
end end
local img = iconImages[path] local img = iconImages[path]
@@ -132,7 +107,6 @@ function PartyMenu.new(game, opts)
self.onSwitch = opts.onSwitch self.onSwitch = opts.onSwitch
self.onCancel = opts.onCancel self.onCancel = opts.onCancel
self.pickOnly = opts.pickOnly self.pickOnly = opts.pickOnly
self.forceSwitch = opts.forceSwitch
self.battle = opts.battle self.battle = opts.battle
self.party = opts.party -- link battles pass their clamped copies self.party = opts.party -- link battles pass their clamped copies
self.swapFrom = nil self.swapFrom = nil
@@ -164,17 +138,7 @@ function PartyMenu:update(dt)
-- hook-injected entries carry a callback instead of an action id -- hook-injected entries carry a callback instead of an action id
entry.onSelect(mon, self.game) entry.onSelect(mon, self.game)
elseif action == "stats" then elseif action == "stats" then
-- battle and field alike return to the party list afterwards
-- (core.asm .partyMenuWasSelected)
Screens.push(self.game, "SummaryMenu", mon) 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 elseif action == "switch" then
self.swapFrom = self.index self.swapFrom = self.index
elseif action == "fly" then elseif action == "fly" then
@@ -218,13 +182,12 @@ function PartyMenu:update(dt)
-- and music return first (PlayDefaultMusic + -- and music return first (PlayDefaultMusic +
-- LoadWalkingPlayerSpriteGraphics), the menu closes with the -- LoadWalkingPlayerSpriteGraphics), the menu closes with the
-- GBPalWhiteOutWithDelay3 blink, and the simulated pad press -- GBPalWhiteOutWithDelay3 blink, and the simulated pad press
-- steps the player forward onto land (or across a connection -- steps the player forward onto land
-- strip when the shore is the next map's edge)
self.game.stack:pop() self.game.stack:pop()
ow.player.surfing = false ow.player.surfing = false
require("src.core.Music").setSurfing(self.game.data, false) require("src.core.Music").setSurfing(self.game.data, false)
self.game.stack:push(Transition.whiteFlash(self.game, nil, function() self.game.stack:push(Transition.whiteFlash(self.game, nil, function()
ow:stepForwardOrCrossEdge(ow.player.facing) ow:scriptMove(ow.player, ow.player.facing, 1)
end)) end))
return return
end end
@@ -314,9 +277,7 @@ function PartyMenu:update(dt)
if ow and heal then if ow and heal then
self.game.stack:push(Transition.whiteFlash(self.game, nil, function() self.game.stack:push(Transition.whiteFlash(self.game, nil, function()
require("src.core.Sound").play(self.game.data, "Teleport_Exit1") require("src.core.Sound").play(self.game.data, "Teleport_Exit1")
-- EnterMapAnim on arrival (HandleFlyWarpOrDungeonWarp sets ow:warpToHealPoint()
-- BIT_FLY_WARP); blackouts must not pass arrive="teleport"
ow:warpToHealPoint(nil, { arrive = "teleport" })
end)) end))
end end
return return
@@ -359,35 +320,20 @@ function PartyMenu:update(dt)
require("src.core.Sound").play(self.game.data, "Swap") require("src.core.Sound").play(self.game.data, "Swap")
end end
self.swapFrom = nil 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.game.stack:pop()
self.onSwitch(mon) self.onSwitch(mon)
else else
self.submenu = true self.submenu = true
self.subIndex = 1 self.subIndex = 1
local items
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 -- STATS/SWITCH plus this mon's field moves (start_sub_menus.asm
-- builds the same dynamic list) -- builds the same dynamic list)
items = { { label = "STATS", action = "stats" }, local items = { { label = "STATS", action = "stats" },
{ label = "SWITCH", action = "switch" } } { label = "SWITCH", action = "switch" } }
-- Field moves (HMs/TMs) are usable out of battle even when the mon local ow = self.game.overworld
-- is fainted -- Gen 1 does not require HP for Cut/Fly/Surf/etc. if not self.battle and ow and mon.hp > 0 then
-- Battle still excludes this list via `not self.battle`. Softboiled
-- can appear for a fainted user; its heal transfer then no-ops.
if not self.battle and ow then
-- FLY/TELEPORT: CheckIfInOutsideMap (OVERWORLD + PLATEAU —
-- Route 23 / Indigo Plateau outdoor), not OVERWORLD alone (#83)
local outside = Map.isOutside(ow.map.def,
FieldDefaults.field(self.game.data, "outsideTilesets"))
for _, mv in ipairs(mon.moves) do for _, mv in ipairs(mon.moves) do
if mv.id == "FLY" and outside if mv.id == "FLY" and ow.map.def.tileset == "OVERWORLD"
and self.game.save.inventory.THUNDERBADGE then and self.game.save.inventory.THUNDERBADGE then
table.insert(items, { label = "FLY", action = "fly" }) table.insert(items, { label = "FLY", action = "fly" })
elseif mv.id == "FLASH" and ow.dark elseif mv.id == "FLASH" and ow.dark
@@ -406,7 +352,7 @@ function PartyMenu:update(dt)
table.insert(items, { label = "STRENGTH", action = "strength" }) table.insert(items, { label = "STRENGTH", action = "strength" })
elseif mv.id == "SOFTBOILED" then elseif mv.id == "SOFTBOILED" then
table.insert(items, { label = "SOFTBOILED", action = "softboiled" }) table.insert(items, { label = "SOFTBOILED", action = "softboiled" })
elseif mv.id == "TELEPORT" and outside then elseif mv.id == "TELEPORT" and ow.map.def.tileset == "OVERWORLD" then
-- TELEPORT works only OUTDOORS (start_sub_menus.asm -- TELEPORT works only OUTDOORS (start_sub_menus.asm
-- .teleport -> CheckIfInOutsideMap); dark maps don't -- .teleport -> CheckIfInOutsideMap); dark maps don't
-- block it -- block it
@@ -421,7 +367,6 @@ function PartyMenu:update(dt)
end end
end end
end end
end
local ctx = { battle = self.battle, overworld = ow } local ctx = { battle = self.battle, overworld = ow }
local hooked = Runtime.call("ui.party.submenu", sameItems, local hooked = Runtime.call("ui.party.submenu", sameItems,
self.game, items, mon, ctx) self.game, items, mon, ctx)
+4 -8
View File
@@ -1,7 +1,6 @@
-- The player's item-storage PC (engine/menus/players_pc.asm): -- The player's item-storage PC (engine/menus/players_pc.asm):
-- WITHDRAW ITEM / DEPOSIT ITEM / TOSS ITEM / LOG OFF over -- WITHDRAW ITEM / DEPOSIT ITEM / TOSS ITEM / LOG OFF over
-- game.save.pcItems ({ ITEM_ID = count }; New Game seeds { POTION = 1 }, -- game.save.pcItems ({ ITEM_ID = count }, created lazily). Withdraw and
-- older saves may still be empty until first deposit). Withdraw and
-- deposit ask "How many?" via the quantity selector (key items always -- deposit ask "How many?" via the quantity selector (key items always
-- move one); toss discards after a YES/NO confirm. Follows the -- move one); toss discards after a YES/NO confirm. Follows the
-- BoxMenu/BagMenu list idioms. -- BoxMenu/BagMenu list idioms.
@@ -71,7 +70,6 @@ end
local function withdraw(game) local function withdraw(game)
local pc = game.save.pcItems local pc = game.save.pcItems
game.stack:push(ListMenu.new(game, "WITHDRAW ITEM", buildItems(game, pc), { game.stack:push(ListMenu.new(game, "WITHDRAW ITEM", buildItems(game, pc), {
messageBox = true,
onChoose = function(item, list) onChoose = function(item, list)
askQuantity(game, list, pc[item.value] or 1, item.value, function(qty) askQuantity(game, list, pc[item.value] or 1, item.value, function(qty)
local Bag = require("src.inventory.Bag") local Bag = require("src.inventory.Bag")
@@ -108,7 +106,6 @@ local function deposit(game)
if not Bag.isBadge(id) then depositable[id] = count end if not Bag.isBadge(id) then depositable[id] = count end
end end
game.stack:push(ListMenu.new(game, "DEPOSIT ITEM", buildItems(game, depositable), { game.stack:push(ListMenu.new(game, "DEPOSIT ITEM", buildItems(game, depositable), {
messageBox = true,
onChoose = function(item, list) onChoose = function(item, list)
askQuantity(game, list, inv[item.value] or 1, item.value, function(qty) askQuantity(game, list, inv[item.value] or 1, item.value, function(qty)
if pcFull(game, pc, item.value) then if pcFull(game, pc, item.value) then
@@ -128,7 +125,6 @@ end
local function toss(game) local function toss(game)
local pc = game.save.pcItems local pc = game.save.pcItems
game.stack:push(ListMenu.new(game, "TOSS ITEM", buildItems(game, pc), { game.stack:push(ListMenu.new(game, "TOSS ITEM", buildItems(game, pc), {
messageBox = true,
onChoose = function(item, list) onChoose = function(item, list)
local def = game.data.items[item.value] local def = game.data.items[item.value]
if (def and def.keyItem) or item.value:find("^HM_") then if (def and def.keyItem) or item.value:find("^HM_") then
@@ -164,9 +160,9 @@ function PlayerPC.new(game)
{ label = "DEPOSIT ITEM", onSelect = function() deposit(game) end }, { label = "DEPOSIT ITEM", onSelect = function() deposit(game) end },
{ label = "TOSS ITEM", onSelect = function() toss(game) end }, { label = "TOSS ITEM", onSelect = function() toss(game) end },
{ label = "LOG OFF" }, { label = "LOG OFF" },
-- silent PC session (BIT_NO_MENU_BUTTON_SOUND); players_pc.asm -- the whole PC session runs silent (BIT_NO_MENU_BUTTON_SOUND,
-- PlayersPCMenu TextBoxBorder (0,0) b=8 c=14 → 16x10 -- engine/menus/players_pc.asm PlayersPCMenu)
}, { tx = 0, ty = 0, tw = 16, th = 10, noSound = true }) }, { tx = 3, ty = 0, tw = 17, th = 10, noSound = true })
end end
return PlayerPC return PlayerPC
+4 -8
View File
@@ -40,19 +40,15 @@ function QuantityBox:update(dt)
end end
function QuantityBox:draw() function QuantityBox:draw()
-- DisplayChooseQuantityMenu (home/list_menu.asm): non-priced box at local w = self.unitPrice and 11 or 7
-- hlcoord 15,9 (interior 3x1); priced at hlcoord 7,9 (interior 11x1). local tx = 20 - w - 1
-- TextBoxBorder adds the frame, so outer size is +2 on each axis. Font.drawBox(tx, 13, w, 3)
local tw = self.unitPrice and 13 or 5
local tx = self.unitPrice and 7 or 15
local ty = 9
Font.drawBox(tx, ty, tw, 3)
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
local s = ("×%02d"):format(self.qty) -- the multiply glyph tile local s = ("×%02d"):format(self.qty) -- the multiply glyph tile
if self.unitPrice then if self.unitPrice then
s = s .. (" ¥%d"):format(self.qty * self.unitPrice) s = s .. (" ¥%d"):format(self.qty * self.unitPrice)
end end
Font.draw(s, (tx + 1) * 8, (ty + 1) * 8) Font.draw(s, (tx + 1) * 8, 14 * 8)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
end end
+3 -7
View File
@@ -84,17 +84,13 @@ local function buy(game, stock)
end end
local function sell(game) local function sell(game)
-- Sell list is ITEMLISTMENU with wPrintItemPrices cleared
-- (pokemart.asm .sellMenuLoop): name + quantity only. Price shows
-- in the quantity chooser. Stuffing "xN" into the label next to a
-- right-aligned ¥ price made long names overlap (issue #116).
local items = {} local items = {}
for _, id in ipairs(Bag.order(game.save)) do for _, id in ipairs(Bag.order(game.save)) do
local def = game.data.items[id] local def = game.data.items[id]
table.insert(items, { table.insert(items, {
value = id, value = id,
label = def and def.name or id, label = (def and def.name or id) .. " x" .. game.save.inventory[id],
right = "x" .. game.save.inventory[id], right = ("¥%d"):format(def and math.floor(def.price / 2) or 0),
}) })
end end
local greet = txt(game, "_PokemartBuyingGreetingText", "Take your time.") local greet = txt(game, "_PokemartBuyingGreetingText", "Take your time.")
@@ -132,7 +128,7 @@ local function sell(game)
Bag.remove(game.save, item.value, qty) Bag.remove(game.save, item.value, qty)
local left = game.save.inventory[item.value] local left = game.save.inventory[item.value]
if left then if left then
item.right = "x" .. left item.label = def.name .. " x" .. left
else else
list:removeCurrent() list:removeCurrent()
end end
+1 -11
View File
@@ -7,7 +7,6 @@
local Font = require("src.render.Font") local Font = require("src.render.Font")
local Logger = require("src.core.Logger") local Logger = require("src.core.Logger")
local Menu = require("src.ui.Menu") local Menu = require("src.ui.Menu")
local Renderer = require("src.render.Renderer")
local Runtime = require("src.mods.Runtime") local Runtime = require("src.mods.Runtime")
local Screens = require("src.ui.Screens") local Screens = require("src.ui.Screens")
@@ -118,20 +117,11 @@ function StartMenu.new(game)
-- the start menu's mask is PAD_DOWN | PAD_UP | PAD_START | PAD_B | PAD_A -- the start menu's mask is PAD_DOWN | PAD_UP | PAD_START | PAD_B | PAD_A
-- (engine/menus/draw_start_menu.asm), so START closes it back to the -- (engine/menus/draw_start_menu.asm), so START closes it back to the
-- overworld -- unlike most menus, whose masks omit PAD_START. -- overworld -- unlike most menus, whose masks omit PAD_START.
--
-- item count isn't fixed: POKéDEX/LINK/MODS come and go with save state,
-- and mods can append their own rows through the hook above, so the
-- double-spaced box (the original's style) can grow past the 18-tile
-- canvas. Cap it at however many rows actually fit and scroll the rest,
-- with Menu's moreArrow showing while there's more below.
local rowStep = 2
local maxVisible = math.floor((Renderer.HEIGHT / 8 - 2) / rowStep)
local menu = Menu.new(game, items, local menu = Menu.new(game, items,
{ tx = 9, ty = 0, tw = 11, maxVisible = maxVisible, startCloses = true }) { tx = 9, ty = 0, tw = 11, th = #items * 2 + 2, startCloses = true })
-- the cursor position survives closing the menu -- the cursor position survives closing the menu
-- (wBattleAndStartSavedMenuItem, home/start_menu.asm) -- (wBattleAndStartSavedMenuItem, home/start_menu.asm)
menu.index = math.min(game.save.startMenuIndex or 1, #items) menu.index = math.min(game.save.startMenuIndex or 1, #items)
menu:clampScroll()
local baseUpdate = menu.update local baseUpdate = menu.update
menu.update = function(self, dt) menu.update = function(self, dt)
baseUpdate(self, dt) baseUpdate(self, dt)
+3 -7
View File
@@ -115,16 +115,12 @@ function SummaryMenu:draw()
drawLineBox(19, 1, 6, 10) drawLineBox(19, 1, 6, 10)
Font.draw("EXP POINTS", 72, 24) Font.draw("EXP POINTS", 72, 24)
Font.draw(("%d"):format(mon.exp), 96, 32) Font.draw(("%d"):format(mon.exp), 96, 32)
-- StatusScreen2: "LEVEL UP" at (9,5); next-exp PrintNumber 7 cols Font.draw("LEVEL UP", 72, 44)
-- at (7,6); space at (14,6); PrintLevel at (16,6). The old
-- "%d to L%d" string at x=88 overflowed the DrawLineBox edge.
Font.draw("LEVEL UP", 72, 40)
local Growth = require("src.pokemon.Growth") local Growth = require("src.pokemon.Growth")
local nextExp = mon.level < 100 local nextExp = mon.level < 100
and (Growth.expForLevel(def.growthRate, mon.level + 1) - mon.exp) or 0 and (Growth.expForLevel(def.growthRate, mon.level + 1) - mon.exp) or 0
Font.draw(("%7d"):format(math.max(0, nextExp)), 56, 48) Font.draw(("%d to L%d"):format(math.max(0, nextExp),
HudTiles.tile(0x6E, 128, 48) -- <LV> math.min(100, mon.level + 1)), 88, 52)
Font.draw(tostring(math.min(100, mon.level + 1)), 136, 48)
Font.drawBox(0, 8, 20, 10) Font.drawBox(0, 8, 20, 10)
for i = 1, 4 do for i = 1, 4 do
local mv = mon.moves[i] local mv = mon.moves[i]
+1 -2
View File
@@ -15,8 +15,7 @@ local Theme = {
cols = Renderer.WIDTH / 8, cols = Renderer.WIDTH / 8,
rows = Renderer.HEIGHT / 8, rows = Renderer.HEIGHT / 8,
textBox = { tx = 0, ty = 12, tw = 20, th = 6, maxCols = 18 }, textBox = { tx = 0, ty = 12, tw = 20, th = 6, maxCols = 18 },
-- InitYesNoTextBoxParameters / AskName: hlcoord 14, 7 (YES_NO_MENU 4x3) choiceBox = { tx = 0, ty = 7, tw = 6, th = 5 },
choiceBox = { tx = 14, ty = 7, tw = 6, th = 5 },
} }
function Theme.load(data) function Theme.load(data)
+5 -33
View File
@@ -13,35 +13,13 @@ TitleState.isOpaque = true
-- SGB title zones (PalPacket_Titlescreen): the logo rows get LOGO2, -- SGB title zones (PalPacket_Titlescreen): the logo rows get LOGO2,
-- the version-ribbon band LOGO1, the rest MEWMON. -- the version-ribbon band LOGO1, the rest MEWMON.
--
-- The CONTINUE / NEW GAME menu and the continue-info box sit inside those
-- LOGO bands. pokered's MainMenu clears the title and runs
-- RunDefaultPaletteCommand so black UI ink stays black; this port keeps the
-- title art visible underneath, so without an overlay those boxes inherit
-- LOGO2 blue / LOGO1 red (issue #133). A trailing trueColor zone leaves the
-- overlay's DMG black unshaded while the logo and title mon keep title pals.
--
-- ROM SuperPal whites are often {255,239,255}. Under RED++, LOGO2/MEWMON
-- come from the GBC pack (pure white) while Blue's LOGO1 stays on the ROM
-- pack (#128), so the version-ribbon row reads as a pink band. Force that
-- slot to pure white; ink colors (Blue/Red "Version" text) stay intact.
local function withPureWhite(pal)
if not pal then return nil end
return { { 255, 255, 255 }, pal[2], pal[3], pal[4] }
end
function TitleState:sgbPalettes(game) function TitleState:sgbPalettes(game)
local P = require("src.render.PaletteFX") local P = require("src.render.PaletteFX")
local z = { local z = {
P.zone(P.pal(game.data, "LOGO2"), 0, 0, 19, 7), P.zone(P.pal(game.data, "LOGO2"), 0, 0, 19, 7),
P.zone(withPureWhite(P.pal(game.data, "LOGO1")), 0, 8, 19, 9), P.zone(P.pal(game.data, "LOGO1"), 0, 8, 19, 9),
P.zone(P.pal(game.data, "MEWMON"), 0, 10, 19, 17), P.zone(P.pal(game.data, "MEWMON"), 0, 10, 19, 17),
} }
local top = game.stack and game.stack:top()
local box = top and top.titleUiBox
if box then
z[#z + 1] = P.trueColorZone(box[1], box[2], box[3], box[4])
end
return z[3] and z or nil return z[3] and z or nil
end end
@@ -141,11 +119,8 @@ local ContinueInfo = {}
ContinueInfo.__index = ContinueInfo ContinueInfo.__index = ContinueInfo
function ContinueInfo.new(title, save) function ContinueInfo.new(title, save)
-- box at (4,7), 16x10 tiles -- see ContinueInfo:draw / DisplayContinueGameInfo return setmetatable({ title = title, game = title.game, save = save },
return setmetatable({ ContinueInfo)
title = title, game = title.game, save = save,
titleUiBox = { 4, 7, 19, 16 },
}, ContinueInfo)
end end
function ContinueInfo:update(dt) function ContinueInfo:update(dt)
@@ -209,11 +184,8 @@ function TitleState:openMenu()
love.event.quit() love.event.quit()
end end
end }) end })
local th = #items * 2 + 2 game.stack:push(Menu.new(game, items,
local menu = Menu.new(game, items, { tx = 0, ty = 0, tw = 13, th = th }) { tx = 0, ty = 0, tw = 13, th = #items * 2 + 2 }))
-- full-width title LOGO zones would recolor this box; see sgbPalettes
menu.titleUiBox = { 0, 0, 12, th - 1 }
game.stack:push(menu)
end end
function TitleState:update(dt) function TitleState:update(dt)
+41 -423
View File
@@ -1,7 +1,8 @@
-- InternalClockTradeAnim (engine/movie/trade.asm): cable-trade cinematic -- Link/in-game trade cinematic (engine/movie/trade.asm, trade2.asm):
-- used by in-game NPC trades and internally-clocked link trades. -- the traded POKéMON rises away with its cry and a goodbye, then the
-- received one descends with its cry and "take good care" text.
-- A skips the slide animations ahead. Calls onDone() after popping.
local Font = require("src.render.Font")
local Sound = require("src.core.Sound") local Sound = require("src.core.Sound")
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
@@ -9,22 +10,13 @@ local TradeAnim = {}
TradeAnim.__index = TradeAnim TradeAnim.__index = TradeAnim
TradeAnim.isOpaque = true TradeAnim.isOpaque = true
-- SGB: generic whole-screen palette (SET_PAL_GENERIC)
function TradeAnim:sgbPalettes(game) function TradeAnim:sgbPalettes(game)
return require("src.render.PaletteFX").wholeNamed(game.data, "MEWMON") return require("src.render.PaletteFX").wholeNamed(game.data, "MEWMON")
end end
local DEFAULT_ART = { local SLIDE_FRAMES = 90
gameBoy = "assets/generated/trade/game_boy.png", local REST_Y = 44 -- resting top of the sprite, roughly screen centre
openCable = "assets/generated/trade/open_cable.png",
cableHoriz = "assets/generated/trade/cable_horiz.png",
cableConn = "assets/generated/trade/cable_conn.png",
cableVert = "assets/generated/trade/cable_vert.png",
cableCorner = "assets/generated/trade/cable_corner.png",
cableEnd = "assets/generated/trade/cable_end.png",
cableBall = "assets/generated/trade/cable_ball.png",
cableBallAlt = "assets/generated/trade/cable_ball_alt.png",
bubble = "assets/generated/trade/bubble.png",
}
local function tryImage(path) local function tryImage(path)
if not path then return nil end if not path then return nil end
@@ -37,46 +29,11 @@ local function nameOf(game, mon)
return mon.nickname or (def and def.name) or mon.species return mon.nickname or (def and def.name) or mon.species
end end
local function speciesName(game, mon)
local def = game.data.pokemon[mon.species]
return (def and def.name) or mon.species
end
local function dexOf(game, mon)
local def = game.data.pokemon[mon.species]
return def and def.dex or 0
end
local function spriteOf(game, mon) local function spriteOf(game, mon)
local def = game.data.pokemon[mon.species] local def = game.data.pokemon[mon.species]
return tryImage(def and def.spriteFront) return tryImage(def and def.spriteFront)
end end
local function expand(game, key, subs)
local raw = game.data.text and game.data.text[key]
if not raw then return key end
for token, value in pairs(subs or {}) do
raw = raw:gsub("{" .. token .. "}", value)
end
return TextBox.substitute(game, raw)
end
-- InternalClockTradeFuncSequence
local SEQ = {
"show_player",
"open_cable",
"ball_enter",
"transfer_lr",
"delay",
"went_to",
"for_sends",
"farewell",
"transfer_rl",
"open_cable2",
"show_enemy",
"done",
}
function TradeAnim.new(game, opts) function TradeAnim.new(game, opts)
opts = opts or {} opts = opts or {}
local self = setmetatable({}, TradeAnim) local self = setmetatable({}, TradeAnim)
@@ -84,401 +41,62 @@ function TradeAnim.new(game, opts)
self.sent = opts.sent self.sent = opts.sent
self.received = opts.received self.received = opts.received
self.onDone = opts.onDone self.onDone = opts.onDone
self.enemyName = opts.enemyName or (self.received and self.received.ot) or "TRAINER"
self.playerName = (game.save.player and game.save.player.name) or "RED"
self.playerOt = opts.playerOt or self.playerName
self.playerOtId = opts.playerOtId
or (self.sent and self.sent.otId)
or (game.save.player and game.save.player.id)
or 0
self.enemyOtId = opts.enemyOtId
or (self.received and self.received.otId)
or love.math.random(0, 65535)
local art = (game.data.field and game.data.field.tradeArt) or DEFAULT_ART
self.img = {
gameBoy = tryImage(art.gameBoy or DEFAULT_ART.gameBoy),
openCable = tryImage(art.openCable or DEFAULT_ART.openCable),
cableHoriz = tryImage(art.cableHoriz or DEFAULT_ART.cableHoriz),
cableConn = tryImage(art.cableConn or DEFAULT_ART.cableConn),
cableVert = tryImage(art.cableVert or DEFAULT_ART.cableVert),
cableCorner = tryImage(art.cableCorner or DEFAULT_ART.cableCorner),
cableEnd = tryImage(art.cableEnd or DEFAULT_ART.cableEnd),
cableBall = tryImage(art.cableBall or DEFAULT_ART.cableBall),
cableBallAlt = tryImage(art.cableBallAlt or DEFAULT_ART.cableBallAlt),
bubble = tryImage(art.bubble or DEFAULT_ART.bubble),
}
self.sentSprite = spriteOf(game, self.sent) self.sentSprite = spriteOf(game, self.sent)
self.recvSprite = spriteOf(game, self.received) self.receivedSprite = spriteOf(game, self.received)
self.phase = "out"
self.seq = 1
self.phase = SEQ[1]
self.t = 0 self.t = 0
self.scx = 0
self.ballX = 0
self.ballY = 0
self.monX = 0
self.monY = 0
self.flash = false
self.monVisible = true
self.waitingText = false
self.cableFlash = false
return self return self
end end
function TradeAnim:enter() function TradeAnim:enter()
Sound.play(self.game.data, "Trade_Machine") Sound.playCry(self.game.data, self.sent.species)
end
function TradeAnim:advance()
self.seq = self.seq + 1
self.phase = SEQ[self.seq] or "done"
self.t = 0
self.scx = 0
self.sub = nil
self.flash = false
self.cableFlash = false
self.poof = nil
if self.phase == "done" then
self.game.stack:pop()
if self.onDone then self.onDone() end
elseif self.phase == "show_enemy" then
self.monVisible = false
elseif self.phase == "transfer_lr" then
-- wBaseCoord $54, $1c OAM -> screen (76, 12)
self.monX, self.monY = 76, 12
elseif self.phase == "transfer_rl" then
-- wBaseCoord $64, $44 OAM -> screen (92, 52), right GB on screen
self.monX, self.monY = 92, 52
self.scx = 160
elseif self.phase == "ball_enter" then
-- lb bc, $20, $60: b = Y, c = X (Trade_AnimateBallEnteringLinkCable)
self.ballX, self.ballY = 0x60, 0x20
elseif self.phase == "open_cable" or self.phase == "open_cable2" then
-- SCX $a0 -> $f0: cable slides in from the right, open end rests at x64
self.scx = 0x50
Sound.play(self.game.data, "Heal_HP")
end
end
function TradeAnim:say(text, delay, thenFn)
self.waitingText = true
self.game.stack:push(TextBox.new(self.game, text, function()
self.waitingText = false
if thenFn then thenFn() else self:advance() end
end, { auto = { delay = delay or 80 } }))
end
function TradeAnim:skipHeld()
return self.game.input:wasPressed("a") or self.game.input:isDown("a")
end end
function TradeAnim:update(dt) function TradeAnim:update(dt)
if self.waitingText or self.phase == "done" then return end local input = self.game.input
local skip = self.game.input:wasPressed("a") if self.phase == "out" or self.phase == "in" then
self.t = self.t + 1 self.t = self.t + 1
local p = self.phase if input:wasPressed("a") then self.t = SLIDE_FRAMES end
if self.t < SLIDE_FRAMES then return end
if p == "show_player" then if self.phase == "out" then
-- slide from SCX $86 -> 0, hold 80, poof, cry (Trade_ShowPlayerMon) self.phase = "goodbye"
if not self.sub then self.sub = "slide" end self.game.stack:push(TextBox.new(self.game,
if self.sub == "slide" then ("Goodbye %s!"):format(nameOf(self.game, self.sent)),
self.scx = math.max(0, 0x86 - self.t * 2) function()
if skip or self.scx <= 0 then self.phase = "in"
self.scx = 0
self.sub = "hold"
self.t = 0 self.t = 0
end
elseif self.sub == "hold" then
if skip or self.t >= 80 then
self.sub = "poof"
self.t = 0
self.monVisible = false
Sound.play(self.game.data, "Ball_Poof")
end
elseif self.sub == "poof" then
self.poof = math.max(0, 16 - self.t)
if skip or self.t >= 16 then
Sound.playCry(self.game.data, self.sent.species)
self.sub = nil
self:advance()
end
end
elseif p == "open_cable" or p == "open_cable2" then
-- 20 steps of 4px, matching the SCX loop
self.scx = math.max(0, 0x50 - self.t * 4)
if skip or self.scx <= 0 then
self.scx = 0
self:advance()
end
elseif p == "ball_enter" then
-- TRADE_BALL_SHAKE then ball rides the cable; X from $60 toward $a0
if self.t < 20 then
if skip then self.t = 20 end
return
end
local step = self.t - 20
if step % 3 == 0 then
self.ballX = 0x60 + math.floor(step / 3) * 4
self.flash = not self.flash
if self.ballX < 0xA0 then Sound.play(self.game.data, "Tink") end
end
if skip then self.ballX = 0xA0 end
if self.ballX >= 0xA0 then self:advance() end
elseif p == "transfer_lr" then
-- scroll left GB off while the mon rides the cable, then the sprite
-- itself moves right and down into the right GB (Trade_AnimMonMoveVertical)
if self.t <= 80 then
self.scx = math.min(160, self.t * 2)
self.monX, self.monY = 76, 12
elseif self.t <= 80 + 32 then
self.monX = 76 + math.floor((self.t - 80) / 8) * 4
elseif self.t <= 80 + 64 then
self.monX = 92
self.monY = 12 + math.floor((self.t - 112) / 8) * 10
else
self:advance()
return
end
if self.t % 8 == 0 then self.cableFlash = not self.cableFlash end
if skip then self:advance() end
elseif p == "transfer_rl" then
-- mirror: sprite climbs out of the right GB, then the screen scrolls
-- back to the left GB
if self.t <= 32 then
self.scx = 160
self.monX, self.monY = 92, 52 - math.floor(self.t / 8) * 10
elseif self.t <= 64 then
self.monX = 92 - math.floor((self.t - 32) / 8) * 4
self.monY = 12
elseif self.t <= 64 + 80 then
self.scx = math.max(0, 160 - (self.t - 64) * 2)
self.monX, self.monY = 76, 12
else
self:advance()
return
end
if self.t % 8 == 0 then self.cableFlash = not self.cableFlash end
if skip then self:advance() end
elseif p == "delay" then
if skip or self.t >= 100 then self:advance() end
elseif p == "went_to" then
local text = expand(self.game, "_TradeWentToText", {
["RAM:wStringBuffer"] = speciesName(self.game, self.sent),
["RAM:wLinkEnemyTrainerName"] = self.enemyName,
})
self:say(text, 200)
elseif p == "for_sends" then
local a = expand(self.game, "_TradeForText", {
["RAM:wStringBuffer"] = speciesName(self.game, self.sent),
})
local b = expand(self.game, "_TradeSendsText", {
["RAM:wLinkEnemyTrainerName"] = self.enemyName,
["RAM:wNameBuffer"] = nameOf(self.game, self.received),
})
self.waitingText = true
self.game.stack:push(TextBox.new(self.game, a, function()
self.game.stack:push(TextBox.new(self.game, b, function()
self.waitingText = false
self:advance()
end, { auto = { delay = 80 } }))
end, { auto = { delay = 80 } }))
elseif p == "farewell" then
local a = expand(self.game, "_TradeWavesFarewellText", {
["RAM:wLinkEnemyTrainerName"] = self.enemyName,
})
local b = expand(self.game, "_TradeTransferredText", {
["RAM:wNameBuffer"] = nameOf(self.game, self.received),
})
self.waitingText = true
self.game.stack:push(TextBox.new(self.game, a, function()
self.game.stack:push(TextBox.new(self.game, b, function()
self.waitingText = false
self:advance()
end, { auto = { delay = 80 } }))
end, { auto = { delay = 80 } }))
elseif p == "show_enemy" then
if self.t == 1 then
Sound.play(self.game.data, "Ball_Poof")
self.monVisible = true
elseif self.t == 20 then
Sound.playCry(self.game.data, self.received.species) Sound.playCry(self.game.data, self.received.species)
elseif self.t >= 120 or skip then end))
local text = expand(self.game, "_TradeTakeCareText", {
["RAM:wNameBuffer"] = nameOf(self.game, self.received),
})
self:say(text, 80)
end
end
end
local function drawCableHoriz(self, y, x0, x1)
if self.img.cableHoriz then
love.graphics.draw(self.img.cableHoriz, x0 - (self.scx % 8), y)
else else
love.graphics.setColor(0.2, 0.2, 0.2, 1) self.phase = "takecare"
love.graphics.rectangle("fill", x0, y + 1, math.max(0, x1 - x0), 6) self.game.stack:push(TextBox.new(self.game,
love.graphics.setColor(1, 1, 1, 1) ("Take good care\nof %s!"):format(nameOf(self.game, self.received)),
end function()
end self.game.stack:pop()
if self.onDone then self.onDone() end
function TradeAnim:drawMonInfo(mon, ot, otId, boxTy) end))
-- Trade_PrintPlayerMonInfoText: rows +0/+2/+4/+6 from the box top; the
-- No. line replaces part of the top border (hlcoord 5, 0 in trade2.asm)
Font.drawBox(4, boxTy, 12, 8)
local y0 = boxTy * 8
local no = ("No.%03d"):format(dexOf(self.game, mon))
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 56, y0, Font.width(no), 8)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(no, 56, y0)
Font.draw(speciesName(self.game, mon), 40, y0 + 16)
Font.draw("OT/" .. (ot or "????"), 40, y0 + 32)
Font.draw(("IDNo.%05d"):format(otId or 0), 40, y0 + 48)
love.graphics.setColor(1, 1, 1, 1)
end
function TradeAnim:drawIconInBubble(mon, x, y)
local spr = (mon.species == self.received.species) and self.recvSprite or self.sentSprite
if spr then
local sw, sh = spr:getDimensions()
local s = 16 / math.max(sw, sh)
love.graphics.draw(spr, x, y, 0, s, s)
else
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("fill", x + 4, y + 4, 8, 8)
love.graphics.setColor(1, 1, 1, 1)
end
if self.img.bubble then
if not self.bubbleQuad then
local iw, ih = self.img.bubble:getDimensions()
self.bubbleQuad = love.graphics.newQuad(0, 0, 16, 16, iw, ih)
self.bubbleQuadAlt = ih >= 32
and love.graphics.newQuad(0, 16, 16, 16, iw, ih)
or self.bubbleQuad
end
local q = self.cableFlash and self.bubbleQuadAlt or self.bubbleQuad
love.graphics.draw(self.img.bubble, q, x - 8, y - 8)
end
end
function TradeAnim:drawGameBoy(x, y)
if self.img.gameBoy then
love.graphics.draw(self.img.gameBoy, x, y)
else
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("line", x, y, 48, 64)
love.graphics.setColor(1, 1, 1, 1)
end
end
function TradeAnim:drawLeftGB()
-- cable from GB to the right edge
if self.img.cableConn then
love.graphics.draw(self.img.cableConn, 88, 32)
end
drawCableHoriz(self, 32, 96, 160)
self:drawGameBoy(40, 24)
Font.drawBox(4, 12, 9, 4)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(self.playerName, 40, 112)
love.graphics.setColor(1, 1, 1, 1)
end
function TradeAnim:drawRightGB()
drawCableHoriz(self, 32, 0, 112)
if self.img.cableCorner then love.graphics.draw(self.img.cableCorner, 112, 32) end
if self.img.cableVert then
for i = 1, 4 do
love.graphics.draw(self.img.cableVert, 120, 40 + (i - 1) * 8)
end end
end end
if self.img.cableEnd then love.graphics.draw(self.img.cableEnd, 112, 72) end
if self.img.cableConn then love.graphics.draw(self.img.cableConn, 104, 72) end
self:drawGameBoy(56, 64)
Font.drawBox(6, 0, 9, 4)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(self.enemyName, 56, 16)
love.graphics.setColor(1, 1, 1, 1)
end end
function TradeAnim:draw() function TradeAnim:draw()
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144) love.graphics.rectangle("fill", 0, 0, 160, 144)
local p = self.phase local sprite, y
if self.phase == "out" then
if p == "show_player" then -- the sent mon rises up and off the screen
love.graphics.push() sprite = self.sentSprite
love.graphics.translate(-self.scx, 0) y = REST_Y - math.floor((self.t / SLIDE_FRAMES) * (REST_Y + 60))
-- mon pic on the BG at hlcoord 7, 2; info box on the window at elseif self.phase == "in" or self.phase == "takecare" then
-- hWY $50, so it sits in the bottom half of the screen -- the received mon descends into place
if self.monVisible and self.sentSprite then sprite = self.receivedSprite
love.graphics.draw(self.sentSprite, 56, 16) local t = self.phase == "in" and self.t or SLIDE_FRAMES
y = -60 + math.floor((t / SLIDE_FRAMES) * (REST_Y + 60))
end end
self:drawMonInfo(self.sent, self.playerOt, self.playerOtId, 10) if sprite and y then
love.graphics.pop() local w = sprite:getWidth()
if self.poof and self.poof > 0 then love.graphics.draw(sprite, math.floor((160 - w) / 2), y)
love.graphics.setColor(0, 0, 0, self.poof / 16)
love.graphics.circle("fill", 80, 40, 20 - (self.poof or 0))
love.graphics.setColor(1, 1, 1, 1)
end end
elseif p == "open_cable" or p == "open_cable2" then
love.graphics.push()
love.graphics.translate(self.scx, 0)
if self.img.openCable then
love.graphics.draw(self.img.openCable, 64, 16) -- open end at SCX $f0
end
love.graphics.pop()
elseif p == "ball_enter" then
if self.img.openCable then
love.graphics.draw(self.img.openCable, 64, 16)
end
-- OAM coords carry a (+8, +16) hardware offset
local ball = self.flash and (self.img.cableBallAlt or self.img.cableBall)
or self.img.cableBall
if ball then
love.graphics.draw(ball, self.ballX - 8, self.ballY - 16)
else
love.graphics.setColor(0, 0, 0, 1)
love.graphics.circle("fill", self.ballX, self.ballY - 8, 6)
love.graphics.setColor(1, 1, 1, 1)
end
elseif p == "transfer_lr" or p == "transfer_rl" then
-- two-screen world: left GB scene at x0, right GB scene at x160;
-- the mon icon stays in screen space like the OAM sprite it ports
love.graphics.push()
love.graphics.translate(-self.scx, 0)
self:drawLeftGB()
love.graphics.translate(160, 0)
self:drawRightGB()
love.graphics.pop()
local mon = (p == "transfer_lr") and self.sent or self.received
self:drawIconInBubble(mon, self.monX, self.monY)
if self.cableFlash then
love.graphics.setColor(1, 1, 1, 0.15)
love.graphics.rectangle("fill", 0, 32, 160, 8)
love.graphics.setColor(1, 1, 1, 1)
end
elseif p == "show_enemy" then
if self.monVisible and self.recvSprite then
love.graphics.draw(self.recvSprite, 56, 16)
end
self:drawMonInfo(self.received, self.enemyName, self.enemyOtId, 10)
end
-- went_to / for_sends / farewell / delay: cleared window; TextBox draws
end end
return TradeAnim return TradeAnim
+7 -7
View File
@@ -34,8 +34,7 @@ function TrainerCard.new(game)
local self = setmetatable({ game = game }, TrainerCard) local self = setmetatable({ game = game }, TrainerCard)
local img = tryImage("assets/generated/trainer_card/badges.png") local img = tryImage("assets/generated/trainer_card/badges.png")
if img then if img then
-- badges.2bpp is 8 stacked [face, badge] pairs (DrawBadges FaceBadgeTiles) -- 8 pairs of [gym leader face, badge]
self.faces = { img = img, quads = quads16(img, 8, 32, 0, 0) }
self.badges = { img = img, quads = quads16(img, 8, 32, 0, 16) } self.badges = { img = img, quads = quads16(img, 8, 32, 0, 16) }
end end
local nums = tryImage("assets/generated/trainer_card/badge_numbers.png") local nums = tryImage("assets/generated/trainer_card/badge_numbers.png")
@@ -125,7 +124,7 @@ function TrainerCard:draw()
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
end end
-- numbered badge grid (rows 11-17): face by default, badge when owned -- numbered badge grid (rows 11-17): earned solid, unearned dimmed
self:frameBox(0, 11, 20, 7) self:frameBox(0, 11, 20, 7)
local badges = Badges.list(self.game.data) local badges = Badges.list(self.game.data)
for i = 1, #badges do for i = 1, #badges do
@@ -137,11 +136,12 @@ function TrainerCard:draw()
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(self.nums.img, self.nums.quads[i - 1], tx, ty) love.graphics.draw(self.nums.img, self.nums.quads[i - 1], tx, ty)
end end
if self.faces and self.faces.quads[i - 1] then if self.badges and self.badges.quads[i - 1]
and save.inventory[Badges.itemFor(badges[i])] then
-- unearned badge slots stay blank (DrawBadges)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
local owned = save.inventory[Badges.itemFor(badges[i])] love.graphics.draw(self.badges.img, self.badges.quads[i - 1],
local sheet = owned and self.badges or self.faces tx + 4, ty + 6)
love.graphics.draw(sheet.img, sheet.quads[i - 1], tx + 4, ty + 6)
end end
end end
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
-263
View File
@@ -1,263 +0,0 @@
-- The boot shell: the heart of the self-updater. A fused build, before it
-- runs the game bundled inside it, looks in its save directory for a newer
-- payload (a downloaded gen1recomp-X.Y.Z.love), and if one is present and
-- runnable, mounts it over the bundled source and chainloads it -- so the
-- binary shipped once can keep updating the Lua it runs without a reinstall.
--
-- Only a fused build self-updates. A dev / source checkout IS the game, so
-- Boot.run is a no-op there.
--
-- Three pieces, deliberately layered so the risky part is small and the
-- decision part is testable:
-- * Boot.select -- pure: given probed candidates + the bundled version,
-- decide what to run and what to delete. No love.*.
-- * Boot.probePayload-- read one archive's advertised version, isolated.
-- * Boot.run -- orchestrates: crash-guard, enumerate, select, and
-- (if a payload wins) mount + chainload with full
-- rollback on any failure so the bundled game always
-- boots.
--
-- Known limitation: the bundled love.run keeps driving the frame loop after a
-- handoff (it has already returned its stepper to LÖVE; redefining the global
-- love.run does nothing to the running one). A payload that must change
-- love.run itself therefore requires a minShell bump so an older shell refuses
-- to chainload it.
local Semver = require("src.update.Semver")
local Boot = {}
-- Save-directory layout (identity "pokemon-love2d"), per the shared contract.
local PAYLOAD_DIR = "updates"
local PENDING = "updates/pending.txt"
-- Isolated mountpoint used only to peek at a candidate's Version.lua, so its
-- copy never collides with the running source's copy at "/".
local PROBE_MOUNT = "__pokeport_probe"
-- Downloaded payloads are named gen1recomp-<X.Y.Z>.love.
local function isPayloadName(name)
return name:match("^gen1recomp%-.+%.love$") ~= nil
end
-- The love callbacks the payload's main.lua chunk may redefine when it runs.
-- We snapshot these before a handoff and restore them if the handoff fails, so
-- the exact bundled closures (with their intact upvalues) drive the game
-- again. love.run is included: harmless to restore, and it is one of the
-- globals a payload main.lua reassigns.
local CALLBACK_NAMES = {
"load", "update", "draw", "quit", "run",
"keypressed", "keyreleased", "textinput",
"mousepressed", "mousereleased", "mousemoved", "wheelmoved",
"touchpressed", "touchmoved", "touchreleased",
"gamepadpressed", "gamepadreleased", "gamepadaxis", "joystickremoved",
"focus", "visible", "resize", "filedropped", "directorydropped",
"errorhandler", "threaderror", "lowmemory",
}
local function snapshotCallbacks()
local snap = {}
for _, k in ipairs(CALLBACK_NAMES) do snap[k] = love[k] end
return snap
end
local function restoreCallbacks(snap)
for _, k in ipairs(CALLBACK_NAMES) do love[k] = snap[k] end
end
-- Drop every bundled Lua module the payload must be allowed to re-resolve: all
-- src.* modules plus the main/conf chunks. conf.lua cached the bundled
-- Version into package.loaded["src.core.Version"]; without this purge the next
-- require would hand back the bundled copy instead of the payload's. Setting
-- existing fields to nil during a pairs traversal is explicitly permitted.
local function purgeBundledModules()
for key in pairs(package.loaded) do
if key:match("^src%.") or key == "main" or key == "conf" then
package.loaded[key] = nil
end
end
end
-- Boot.probePayload(rel) -> { engine = string, minShell = number } | nil, err
--
-- Mount the archive at rel (a save-directory-relative path) on an isolated
-- mountpoint, read its src/core/Version.lua by executing the source with
-- loadstring (NEVER require -- we must not cache or run it as a module), then
-- unmount. Version.lua is zero-require, so running its chunk is safe.
function Boot.probePayload(rel)
if not love.filesystem.mount(rel, PROBE_MOUNT) then
return nil, "could not mount " .. tostring(rel)
end
local chunkPath = PROBE_MOUNT .. "/src/core/Version.lua"
local ok, result = pcall(function()
local src = love.filesystem.read(chunkPath)
if not src then error("Version.lua missing", 0) end
local chunk = loadstring(src, "@" .. chunkPath)
if not chunk then error("Version.lua would not compile", 0) end
return chunk()
end)
love.filesystem.unmount(rel)
if not ok then return nil, tostring(result) end
local v = result
if type(v) ~= "table" or type(v.engine) ~= "string" then
return nil, "payload has no usable Version table"
end
return { engine = v.engine, minShell = tonumber(v.minShell) or 1 }
end
-- Boot.select(candidates, bundledEngine, bundledShell) -> chosen | nil, toDelete
--
-- Pure (no love.*): decide which payload to run and which to delete.
-- candidates is a list of { name = , engine = , minShell = }.
-- * chosen: the highest engine that is STRICTLY newer than bundledEngine and
-- whose minShell <= bundledShell (a payload the running shell can host).
-- * toDelete: stale payloads -- engine <= bundled (old or the same as what we
-- already ship), or superseded by the chosen one (not newer than chosen).
-- A payload newer than the chosen one but unrunnable here (minShell too
-- high) is kept: a future shell upgrade may be able to run it.
function Boot.select(candidates, bundledEngine, bundledShell)
local chosen
for _, c in ipairs(candidates) do
local newer = Semver.compare(c.engine, bundledEngine) > 0
local runnable = (c.minShell or 1) <= bundledShell
if newer and runnable then
if not chosen or Semver.compare(c.engine, chosen.engine) > 0 then
chosen = c
end
end
end
local toDelete = {}
for _, c in ipairs(candidates) do
if not (chosen and c.name == chosen.name) then
local stale = Semver.compare(c.engine, bundledEngine) <= 0
if chosen and Semver.compare(c.engine, chosen.engine) <= 0 then
stale = true
end
if stale then toDelete[#toDelete + 1] = c.name end
end
end
return chosen and chosen.name or nil, toDelete
end
-- Mount the chosen payload and hand control to it. Returns true when the
-- payload is live and has completed its own love.load; false (with full
-- rollback) on any failure, so the caller runs the bundled game instead.
local function chainload(name, args)
local rel = PAYLOAD_DIR .. "/" .. name
-- Crash marker: if we die between here and clearing it, the next boot's
-- crash guard distrusts this payload and deletes it.
love.filesystem.write(PENDING, name)
-- Prepend-mount the payload at "/" (appendToPath = false) so its files win
-- over the fused source for every subsequent require / love.filesystem read.
if not love.filesystem.mount(rel, "/", false) then
love.filesystem.remove(PENDING)
return false
end
local snapshot = snapshotCallbacks()
purgeBundledModules()
_G.POKEPORT_PAYLOAD_MOUNTED = true
-- Chainload: run the payload's main.lua (redefines the love callbacks from
-- the NEW code), then call its love.load. The new love.load calls Boot.run
-- again, which no-ops via the flag set above.
local ok, err = pcall(function()
local chunk = assert(love.filesystem.load("main.lua"))
chunk()
love.load(args)
end)
if not ok then
-- Handoff failed after mounting. Unwind everything so the bundled game
-- boots cleanly: clear the flag, unmount the payload, purge any payload
-- modules it cached (so bundled requires reload from source), restore the
-- bundled love callbacks with their intact upvalues, and drop the marker.
-- Delete the payload too: it failed deterministically once, so leaving it
-- would re-select and re-fail it on every boot forever.
print("update: payload handoff failed, reverting to bundled: " .. tostring(err))
_G.POKEPORT_PAYLOAD_MOUNTED = nil
pcall(love.filesystem.unmount, rel)
purgeBundledModules()
restoreCallbacks(snapshot)
love.filesystem.remove(rel)
love.filesystem.remove(PENDING)
return false
end
-- Success: the payload owns the game now. Drop the marker and tell the
-- caller to stop so the bundled love.load does not run on top of it.
love.filesystem.remove(PENDING)
return true
end
-- Everything after the fused / flag guards, wrapped so an unexpected error in
-- enumeration or selection can never crash the boot.
local function runInner(args)
-- Crash guard first: a pending.txt naming a payload means a previous boot
-- crashed mid-handoff. Distrust that payload -- delete it and the marker --
-- then continue (we may still pick an older valid payload, or fall through
-- to the bundled game).
local pending = love.filesystem.read(PENDING)
if pending then
pending = pending:gsub("%s+$", "")
if pending ~= "" then
love.filesystem.remove(PAYLOAD_DIR .. "/" .. pending)
end
love.filesystem.remove(PENDING)
end
-- Enumerate and probe every payload in updates/.
local candidates = {}
if love.filesystem.getInfo(PAYLOAD_DIR, "directory") then
for _, entry in ipairs(love.filesystem.getDirectoryItems(PAYLOAD_DIR)) do
if isPayloadName(entry) then
local info = Boot.probePayload(PAYLOAD_DIR .. "/" .. entry)
if info then
candidates[#candidates + 1] = {
name = entry,
engine = info.engine,
minShell = info.minShell,
}
end
end
end
end
local Version = require("src.core.Version")
local chosen, toDelete = Boot.select(candidates, Version.engine, Version.shell)
for _, victim in ipairs(toDelete) do
love.filesystem.remove(PAYLOAD_DIR .. "/" .. victim)
end
if not chosen then return false end
return chainload(chosen, args)
end
-- Boot.run(args) -> boolean
--
-- The first line of love.load. True means a payload was mounted and
-- chainloaded and the caller must return immediately; false means boot the
-- bundled game as normal.
function Boot.run(args)
-- Dev / source checkouts never self-update.
if not (love.filesystem.isFused and love.filesystem.isFused()) then
return false
end
-- The chainloaded love.load calls Boot.run again; the flag makes it a no-op.
if _G.POKEPORT_PAYLOAD_MOUNTED then return false end
local ok, result = pcall(runInner, args)
if not ok then
-- An error escaped before any handoff mount (chainload cleans up after
-- itself), so state is still clean. Never crash the boot.
return false
end
return result
end
return Boot
-177
View File
@@ -1,177 +0,0 @@
-- Async release-check and payload-download for the self-update flow.
--
-- The heavy lifting (curl calls, sha256 verification, the Boot gate) happens
-- on a background love.thread worker (src/update/check_worker.lua); this module
-- is only the thin main-thread state machine the UI polls. Two channels carry
-- the conversation:
-- "update_check_cmd" main -> worker: { cmd = "check" | "download" | "quit" }
-- "update_check_state" worker -> main: { status, latest, progress, error }
--
-- Nothing here ever blocks or throws into the game loop: when love.thread is
-- absent (the headless test stub) or the worker cannot run (no curl, Android),
-- state() simply reports "error" and the UI hides itself. See the shared
-- contract in the task brief for the status vocabulary and the file layout.
--
-- The release-JSON extraction and the sums parsing are exported as pure
-- functions (no love.* calls) so plain-Lua tests can cover them, and so the
-- worker can reuse the exact same code path via love.filesystem.load.
local Check = {}
Check.REPO = "bryanthaboi/pokemon-gen1-recomp-project"
local CMD = "update_check_cmd"
local STATE = "update_check_state"
-- ---------------------------------------------------------------------------
-- pure helpers (no love.*) -- also used inside the worker
-- ---------------------------------------------------------------------------
-- Find the release asset named exactly `name`, returning its download URL and
-- byte size (or nil when the release has no such asset).
function Check.pickAsset(assets, name)
if type(assets) ~= "table" then return nil end
for _, a in ipairs(assets) do
if type(a) == "table" and a.name == name then
return { url = a.browser_download_url, size = tonumber(a.size) }
end
end
return nil
end
local function stripV(tag)
return (tostring(tag):gsub("^[vV]", ""))
end
-- Decode a GitHub "releases/latest" response into just the fields the updater
-- needs. Returns { version, payloadName, payload, sums } where payload/sums are
-- { url, size } tables (or nil when that asset is missing), or nil, err when the
-- document is not a release with a strict X.Y.Z tag. Json is injected so the
-- worker can pass a filesystem-loaded codec; on the main thread / in tests it
-- falls back to require.
function Check.parseRelease(jsonText, Json)
Json = Json or require("src.link.Json")
local doc = Json.decode(jsonText)
if type(doc) ~= "table" or not doc.tag_name then
return nil, "no tag_name in release json"
end
local version = stripV(doc.tag_name)
if not version:match("^%d+%.%d+%.%d+$") then
return nil, "release tag is not X.Y.Z: " .. tostring(doc.tag_name)
end
local payloadName = "gen1recomp-" .. version .. ".love"
return {
version = version,
payloadName = payloadName,
payload = Check.pickAsset(doc.assets, payloadName),
sums = Check.pickAsset(doc.assets, "sha256sums.txt"),
}
end
-- Parse a shasum -a 256 file ("<hex> <filename>", bare filenames). With a
-- `target` argument returns just that file's hash (or nil); otherwise returns
-- the whole name -> hash map. Tolerates the "*" binary marker and "./" prefix.
function Check.parseSums(text, target)
local map = {}
for line in tostring(text):gmatch("[^\r\n]+") do
local hash, file = line:match("^(%x+)%s+%*?(%S+)")
if hash and file then
map[(file:gsub("^%./", ""))] = hash:lower()
end
end
if target ~= nil then return map[target] end
return map
end
-- ---------------------------------------------------------------------------
-- main-thread state machine
-- ---------------------------------------------------------------------------
function Check.releaseUrl()
return "https://github.com/" .. Check.REPO .. "/releases/latest"
end
local worker -- the love.thread, once started
local cmdCh, stateCh -- the two channels
local workerReady -- nil = untried, true = running, false = unavailable
local requested -- a check has been asked for this session
local cache = { status = "idle" } -- newest snapshot from the worker
local function ensureWorker()
if workerReady ~= nil then return workerReady end
if not (love and love.thread and love.thread.newThread) then
workerReady = false
return false
end
local ok, th = pcall(love.thread.newThread, "src/update/check_worker.lua")
if not ok or not th then
workerReady = false
return false
end
cmdCh = love.thread.getChannel(CMD)
stateCh = love.thread.getChannel(STATE)
if not pcall(function() th:start() end) then
workerReady = false
return false
end
worker = th
workerReady = true
return true
end
-- Pull every pending snapshot off the state channel (keeping the newest) and
-- surface a worker crash as a soft error the UI can hide on.
local function drain()
if stateCh then
local msg = stateCh:pop()
while msg do
cache = msg
msg = stateCh:pop()
end
end
if worker then
local err = worker:getError()
if err then
cache = { status = "error", error = tostring(err) }
end
end
end
-- Begin (or, on a prior error, retry) an async check. Safe to call every frame:
-- once a check is in flight or has reached a terminal state it is a no-op.
function Check.start()
drain()
if cache.status == "checking" or cache.status == "downloading" then return end
if requested and cache.status ~= "error" and cache.status ~= "idle" then return end
if not ensureWorker() then
cache = { status = "error", error = "background threads unavailable" }
return
end
requested = true
cache = { status = "checking" }
cmdCh:push({ cmd = "check" })
end
-- Current snapshot: { status, latest, progress, error }. status is one of
-- idle | checking | uptodate | available | downloading | ready | needs_full | error.
function Check.state()
drain()
return {
status = cache.status or "idle",
latest = cache.latest,
progress = cache.progress,
error = cache.error,
}
end
-- Start downloading the payload announced by an "available" check. A no-op in
-- any other state (the worker still holds the release info from the check).
function Check.download()
drain()
if not cmdCh then return end
if cache.status ~= "available" then return end
cache = { status = "downloading", latest = cache.latest, progress = 0 }
cmdCh:push({ cmd = "download" })
end
return Check

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