mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-15 07:41:21 +02:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 895e904d75 | |||
| 1ce9e32dd1 |
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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?
|
||||||
|
[](https://buymeacoffee.com/bryanthaboi)
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 753 KiB After Width: | Height: | Size: 370 KiB |
@@ -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 = {
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
-- Red's House 2F (pokered data/events/hidden_events.asm +
|
|
||||||
-- engine/events/hidden_objects PrintRedSNESText).
|
|
||||||
--
|
|
||||||
-- The bedroom SNES is a hidden_event at (3, 5) with ANY_FACING that
|
|
||||||
-- prints _RedBedroomSNESText. field.py extracts OpenRedsPC on this map
|
|
||||||
-- but skips PrintRedSNESText, so interaction was a no-op (#135).
|
|
||||||
|
|
||||||
local TextBox = require("src.render.TextBox")
|
|
||||||
|
|
||||||
return {
|
|
||||||
REDS_HOUSE_2F = {
|
|
||||||
-- hidden_events.asm:
|
|
||||||
-- hidden_event 3, 5, PrintRedSNESText, ANY_FACING
|
|
||||||
onInteract = function(game, ow, fx, fy)
|
|
||||||
if fx ~= 3 or fy ~= 5 then return false end
|
|
||||||
local text = game.data.text._RedBedroomSNESText
|
|
||||||
or "{PLAYER} is\nplaying the SNES!"
|
|
||||||
game.stack:push(TextBox.new(game, text))
|
|
||||||
return true
|
|
||||||
end,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -5,23 +5,19 @@ return {
|
|||||||
SILPH_CO_9F = {
|
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
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
+62
-207
@@ -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
|
|
||||||
-- walks him out (RightThenDown vs DownThenRight from EVENT_POKEMON_TOWER_
|
|
||||||
-- RIVAL_ON_LEFT) then HideObject TOGGLE_POKEMON_TOWER_2F_RIVAL. Player
|
|
||||||
-- at (15,5) is the ON_LEFT case.
|
|
||||||
local TOWER_RIVAL_EXIT_RIGHT_THEN_DOWN =
|
|
||||||
{ "right", "down", "down", "right", "down", "down", "right", "right" }
|
|
||||||
local TOWER_RIVAL_EXIT_DOWN_THEN_RIGHT =
|
|
||||||
{ "down", "down", "right", "right", "right", "right", "down", "down" }
|
|
||||||
|
|
||||||
local function pokemonTower2FRivalScript(playerX)
|
|
||||||
local exitDirs = (playerX == 15)
|
|
||||||
and TOWER_RIVAL_EXIT_DOWN_THEN_RIGHT
|
|
||||||
or TOWER_RIVAL_EXIT_RIGHT_THEN_DOWN
|
|
||||||
return {
|
|
||||||
{ "face_player" }, -- 1
|
|
||||||
{ "check_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 2
|
|
||||||
{ "jump_if_true", 12 }, -- 3
|
|
||||||
{ "show_text", "_PokemonTower2FRivalWhatBringsYouHereText" }, -- 4
|
|
||||||
{ "rival_battle", "OPP_RIVAL2", 4 }, -- 5
|
|
||||||
{ "jump_if_false", "end" }, -- 6 loss: stay
|
|
||||||
{ "set_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 7
|
|
||||||
{ "show_text", "_PokemonTower2FRivalDefeatedText" }, -- 8
|
|
||||||
{ "walk_npc", 1, exitDirs }, -- 9
|
|
||||||
{ "hide_object", "POKEMON_TOWER_2F", "POKEMONTOWER2F_RIVAL" }, -- 10
|
|
||||||
{ "jump", "end" }, -- 11
|
|
||||||
{ "show_text", "_PokemonTower2FRivalHowsYourDexText" }, -- 12
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
M.POKEMON_TOWER_2F = {
|
M.POKEMON_TOWER_2F = {
|
||||||
rivalScript = pokemonTower2FRivalScript,
|
|
||||||
talk = {
|
talk = {
|
||||||
TEXT_POKEMONTOWER2F_RIVAL = function(game, ow, npc, done)
|
TEXT_POKEMONTOWER2F_RIVAL = {
|
||||||
ow.runner:run(pokemonTower2FRivalScript(ow.player.cellX),
|
{ "face_player" }, -- 1
|
||||||
{ npc = npc, onDone = done })
|
{ "check_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 2
|
||||||
end,
|
{ "jump_if_true", 10 }, -- 3
|
||||||
|
{ "show_text", "_PokemonTower2FRivalWhatBringsYouHereText" }, -- 4
|
||||||
|
{ "rival_battle", "OPP_RIVAL2", 4 }, -- 5
|
||||||
|
{ "jump_if_false", 11 }, -- 6
|
||||||
|
{ "set_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 7
|
||||||
|
{ "show_text", "_PokemonTower2FRivalDefeatedText" }, -- 8
|
||||||
|
{ "jump", 11 }, -- 9
|
||||||
|
{ "show_text", "_PokemonTower2FRivalHowsYourDexText" }, -- 10
|
||||||
|
},
|
||||||
},
|
},
|
||||||
-- Saves that beat him before the exit walk was ported still have the
|
|
||||||
-- flag but a visible rival; hide on enter like BillsHouse repairs.
|
|
||||||
onEnter = function(game, ow)
|
|
||||||
if not game.save.flags.EVENT_BEAT_POKEMON_TOWER_RIVAL then return end
|
|
||||||
local Commands = require("src.script.Commands")
|
|
||||||
Commands.hide_object({ game = game, save = game.save, overworld = ow },
|
|
||||||
"POKEMON_TOWER_2F", "POKEMONTOWER2F_RIVAL")
|
|
||||||
end,
|
|
||||||
-- PokemonTower2FDefaultScript: walking past the rival's tile forces
|
-- 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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" },
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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.
|
|
||||||
@@ -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
@@ -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": [],
|
||||||
|
|||||||
@@ -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": [],
|
||||||
|
|||||||
@@ -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": [],
|
||||||
|
|||||||
@@ -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": [],
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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,25 +1686,16 @@ 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
|
||||||
-- side; the SE_*_ENEMY_* variants run through CallWithTurnFlipped
|
-- side; the SE_*_ENEMY_* variants run through CallWithTurnFlipped
|
||||||
@@ -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,
|
||||||
@@ -2278,47 +2143,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
|
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
|
|
||||||
|
|
||||||
-- The pre-recharge slice of CheckPlayerStatusConditions (core.asm:
|
-- The pre-recharge slice of CheckPlayerStatusConditions (core.asm:
|
||||||
-- 3328-3382): sleep -> freeze -> held-in-place -> flinch, each losing
|
-- 3328-3382): sleep -> freeze -> held-in-place -> flinch, each losing
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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,
|
|
||||||
["0"] = 246,
|
|
||||||
["1"] = 247,
|
|
||||||
["2"] = 248,
|
|
||||||
["3"] = 249,
|
|
||||||
["4"] = 250,
|
|
||||||
["5"] = 251,
|
|
||||||
["6"] = 252,
|
|
||||||
["7"] = 253,
|
|
||||||
["8"] = 254,
|
|
||||||
["9"] = 255,
|
|
||||||
["?"] = 230,
|
|
||||||
["゙"] = 229,
|
|
||||||
["゚"] = 228,
|
|
||||||
},
|
|
||||||
source = "pokered constants/charmap.asm",
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
+24
-185
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
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
|
|
||||||
|
|
||||||
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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)),
|
||||||
|
function()
|
||||||
|
self.game.stack:pop()
|
||||||
|
if self.onDone then self.onDone() end
|
||||||
|
end))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function TradeAnim:drawMonInfo(mon, ot, otId, boxTy)
|
|
||||||
-- 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
|
|
||||||
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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
Reference in New Issue
Block a user