mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 16:31:05 +02:00
Compare commits
19 Commits
v0.1.11
...
online-play
| Author | SHA1 | Date | |
|---|---|---|---|
| 35f5c9513c | |||
| 13b58dc51e | |||
| 6306975352 | |||
| 819150f50f | |||
| bfba1f7bb7 | |||
| 8278b209b4 | |||
| 1c4515eaaa | |||
| c04b93ce1c | |||
| 2c9970a643 | |||
| 352a9a0eb8 | |||
| 5533785364 | |||
| 11f4f5570e | |||
| 6239387643 | |||
| 4a50cb0728 | |||
| 78aa1f3c0a | |||
| 5128b840b1 | |||
| da14b7ee3f | |||
| 2dbe21babf | |||
| 7a85862602 |
@@ -177,8 +177,8 @@ jobs:
|
||||
set -a; . "$ci_dir/notary.env"; set +a
|
||||
echo "::add-mask::$APPLE_APP_PASSWORD"
|
||||
|
||||
app=".bazinga/work/PokemonRed.app"
|
||||
zip="dist/mac/PokemonRed-macos.zip"
|
||||
app=".bazinga/work/gen1recomp.app"
|
||||
zip="dist/mac/gen1recomp-macos.zip"
|
||||
[ -d "$app" ] || { echo "::error::signed app not found at $app"; exit 1; }
|
||||
if [ -z "${APPLE_ID:-}" ] || [ -z "${APPLE_APP_PASSWORD:-}" ] || [ -z "${APPLE_TEAM_ID:-}" ]; then
|
||||
echo "::error::notary.env is missing APPLE_ID / APPLE_APP_PASSWORD / APPLE_TEAM_ID."
|
||||
@@ -208,12 +208,12 @@ jobs:
|
||||
outdir="dist/release"
|
||||
rm -rf "$outdir"
|
||||
mkdir -p "$outdir"
|
||||
cp "dist/mac/PokemonRed-macos.zip" "$outdir/PokemonRed-${v}-macos.zip"
|
||||
cp "dist/win/PokemonRed-win64.zip" "$outdir/PokemonRed-${v}-windows.zip"
|
||||
cp "dist/linux/PokemonRed-linux.zip" "$outdir/PokemonRed-${v}-linux.zip"
|
||||
cp "dist/mac/gen1recomp-macos.zip" "$outdir/gen1recomp-${v}-macos.zip"
|
||||
cp "dist/win/gen1recomp-win64.zip" "$outdir/gen1recomp-${v}-windows.zip"
|
||||
cp "dist/linux/gen1recomp-linux.zip" "$outdir/gen1recomp-${v}-linux.zip"
|
||||
apk="$(find dist/android/debug -name '*.apk' | head -1)"
|
||||
[ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/debug"; exit 1; }
|
||||
cp "$apk" "$outdir/PokemonRed-${v}-android.apk"
|
||||
cp "$apk" "$outdir/gen1recomp-${v}-android.apk"
|
||||
ls -lh "$outdir"
|
||||
|
||||
- name: Publish GitHub Release
|
||||
@@ -263,10 +263,10 @@ jobs:
|
||||
--target "$GITHUB_SHA" \
|
||||
--title "$v" \
|
||||
--notes "$notes" \
|
||||
"dist/release/PokemonRed-${v}-macos.zip" \
|
||||
"dist/release/PokemonRed-${v}-windows.zip" \
|
||||
"dist/release/PokemonRed-${v}-linux.zip" \
|
||||
"dist/release/PokemonRed-${v}-android.apk"
|
||||
"dist/release/gen1recomp-${v}-macos.zip" \
|
||||
"dist/release/gen1recomp-${v}-windows.zip" \
|
||||
"dist/release/gen1recomp-${v}-linux.zip" \
|
||||
"dist/release/gen1recomp-${v}-android.apk"
|
||||
|
||||
echo "Published release $tag"
|
||||
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
# Pokemon Gen 1 Recompilation Project
|
||||
|
||||
A native LÖVE2D recreation of Pokemon Red. The engine and map behavior are
|
||||
hand-written Lua; game data and graphics are decoded from a ROM supplied by
|
||||
the player.
|
||||
A native LÖVE2D recreation of Pokemon Red and Blue. The engine and map
|
||||
behavior are hand-written Lua; game data and graphics are decoded from a ROM
|
||||
supplied by the player.
|
||||
|
||||
SUPPORT AND ANNOUNCEMENTS: [Discord](https://bois.icu)
|
||||
|
||||
This project does not include a ROM, emulate the Game Boy, transpile assembly,
|
||||
or download a disassembly. A canonical US Pokemon Red ROM is the only game
|
||||
content input.
|
||||
or download a disassembly. A canonical US Pokemon Red or Blue ROM is the only
|
||||
game content input.
|
||||
|
||||
The ROM is verified, used during import, and then released from memory. It is
|
||||
not copied into the cache. Later launches load the private generated cache and
|
||||
do not ask for the ROM again.
|
||||
do not ask for the ROM again. Red and Blue can both be imported and played
|
||||
side by side.
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -20,24 +21,52 @@ Open the desktop app. On first boot, choose your legally obtained `.gb` file
|
||||
or drop it onto the window. Import takes a few seconds and the game starts
|
||||
automatically.
|
||||
|
||||
Only the canonical 1 MiB US Red ROM is accepted. The importer verifies SHA-1
|
||||
`ea9bcae617fdf159b045185467ae58b2e4a48b9a` before creating any game data.
|
||||
Only the canonical 1 MiB US Red and Blue ROMs are accepted. The importer
|
||||
verifies SHA-1 before creating any game data:
|
||||
|
||||
- Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a`
|
||||
- Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2`
|
||||
|
||||
The packaged app contains neither a ROM nor pre-extracted game data. Music,
|
||||
sound effects, and cries are synthesized while the game runs from compact
|
||||
audio channel programs copied out of the verified ROM.
|
||||
|
||||
## Controls
|
||||
|
||||
arrow keys or WASD move; Z, Enter, or Space is A; X or Backspace is
|
||||
B; Escape opens START. F1 saves and F2 loads. Controllers are supported.
|
||||
| Action | Keyboard | Controller |
|
||||
|--------|----------|------------|
|
||||
| Move | Arrow keys / WASD | D-pad / left stick |
|
||||
| A | Z / Enter / Space | A |
|
||||
| B | X / Backspace | B |
|
||||
| Start | Escape | Start |
|
||||
| Select | Tab / Shift | Back / Select |
|
||||
|
||||
Rebind any of these in-game under **OPTIONS → CONTROLS**. Controllers are
|
||||
supported out of the box.
|
||||
|
||||
### Hotkeys
|
||||
|
||||
| Key | What it does |
|
||||
|-----|----------------|
|
||||
| `-` / `=` | Zoom out / in (overworld; also mouse wheel) |
|
||||
| `2` | Cycle COLORS |
|
||||
| `3` | Cycle TILT (free-roam overworld) |
|
||||
| `4` | Cycle ZOOM through every level (free-roam overworld) |
|
||||
| `5` | Cycle GBC FX |
|
||||
| `F1` | Save |
|
||||
| `F2` | Load |
|
||||
| `F10` | Open / close the mod manager |
|
||||
|
||||
COLORS, TILT, ZOOM, GBC FX, and VOID FILL are also in the Options menu
|
||||
and persist in `options.lua`.
|
||||
|
||||
## Running From Source
|
||||
|
||||
Requires LÖVE 11.x. Place the ROM in the project folder and double-click
|
||||
`Play-Mac.command` or `Play-Windows.bat`, or run:
|
||||
Requires LÖVE 11.x. Place a Red or Blue ROM in the project folder and
|
||||
double-click `Play-Mac.command` or `Play-Windows.bat`, or run:
|
||||
|
||||
```sh
|
||||
scripts/setup.sh --rom "/path/to/Pokemon Red.gb"
|
||||
scripts/setup.sh --rom "/path/to/Pokemon Red.gb" # or Pokemon Blue.gb
|
||||
scripts/run.sh
|
||||
```
|
||||
|
||||
@@ -107,7 +136,3 @@ request with real detail is one that can actually get built.
|
||||
This project would not be possible without [pret](https://github.com/pret) >
|
||||
the pret band of decompiling maniacs > and their
|
||||
[pokered](https://github.com/pret/pokered) disassembly.
|
||||
|
||||
## Wanna Support My Work?
|
||||
[](https://buymeacoffee.com/bryanthaboi)
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 753 KiB |
@@ -23,9 +23,13 @@ function love.conf(t)
|
||||
-- back to the plain title if the source is not mounted yet
|
||||
local ok, Version = pcall(require, "src.core.Version")
|
||||
t.window.title = ok and Version.title()
|
||||
or "Pokemon Red (Gen 1 Recompilation Project)"
|
||||
t.window.width = 160 * 4
|
||||
t.window.height = 144 * 4
|
||||
or "gen1recomp"
|
||||
-- Open at the launcher's design size (the split-screen ROM selector is
|
||||
-- laid out for 1024x768). The window is resizable and the 160x144 game
|
||||
-- canvas letterboxes into whatever size it ends up, so this only sets the
|
||||
-- starting size, not the game's resolution.
|
||||
t.window.width = 1024
|
||||
t.window.height = 768
|
||||
end
|
||||
t.version = "11.5"
|
||||
t.window.vsync = 1
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
-- Pewter City flavor dialogue (pokered/scripts/PewterCity.asm).
|
||||
-- PewterCity_TextPointers text_asm bodies for the SUPER_NERD1 museum
|
||||
-- guide, SUPER_NERD2 garden nerd, and the leaving-east YOUNGSTER.
|
||||
-- guide and SUPER_NERD2 garden nerd.
|
||||
--
|
||||
-- The escort choreography (SUPER_NERD1 walking the player to the
|
||||
-- museum, YOUNGSTER walking the player to the gym) is scripted NPC
|
||||
-- movement + a wPewterCityCurScript state machine that steers the
|
||||
-- player off-map; the YOUNGSTER's escort to the gym is handled on this
|
||||
-- map by story5.lua's onStep gate at the east exit (before
|
||||
-- EVENT_BEAT_BROCK), while the SUPER_NERD1 museum escort is not ported.
|
||||
-- Here we only port the real YES/NO-branched flavor text these NPCs
|
||||
-- speak when talked to.
|
||||
-- The YOUNGSTER's gym escort (talk + east-exit onStep) lives in
|
||||
-- story5.lua so the lockstep RLE walk is not overwritten by this
|
||||
-- flavor merge. SUPER_NERD1's museum escort is not ported; only the
|
||||
-- YES/NO-branched flavor text is here.
|
||||
|
||||
local M = {}
|
||||
|
||||
@@ -61,15 +57,6 @@ M.PEWTER_CITY = {
|
||||
end)
|
||||
end,
|
||||
|
||||
-- PewterCityYoungsterText (scripts/PewterCity.asm): the "follow
|
||||
-- me" line the youngster says when the player is stopped from
|
||||
-- leaving Pewter east before beating Brock; the actual gate/step
|
||||
-- block is handled by story5.lua's onStep for this map.
|
||||
TEXT_PEWTERCITY_YOUNGSTER = function(game, ow, npc, done)
|
||||
local t = text(game)
|
||||
push(game, t._PewterCityYoungsterYoureATrainerFollowMeText
|
||||
or "You're a trainer\nright? BROCK's\nlooking for new\nchallengers!\nFollow me!", done)
|
||||
end,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
-- Red's House 2F (pokered data/events/hidden_events.asm +
|
||||
-- engine/events/hidden_objects PrintRedSNESText).
|
||||
--
|
||||
-- The bedroom SNES is a hidden_event at (3, 5) with ANY_FACING that
|
||||
-- prints _RedBedroomSNESText. field.py extracts OpenRedsPC on this map
|
||||
-- but skips PrintRedSNESText, so interaction was a no-op (#135).
|
||||
|
||||
local TextBox = require("src.render.TextBox")
|
||||
|
||||
return {
|
||||
REDS_HOUSE_2F = {
|
||||
-- hidden_events.asm:
|
||||
-- hidden_event 3, 5, PrintRedSNESText, ANY_FACING
|
||||
onInteract = function(game, ow, fx, fy)
|
||||
if fx ~= 3 or fy ~= 5 then return false end
|
||||
local text = game.data.text._RedBedroomSNESText
|
||||
or "{PLAYER} is\nplaying the SNES!"
|
||||
game.stack:push(TextBox.new(game, text))
|
||||
return true
|
||||
end,
|
||||
},
|
||||
}
|
||||
@@ -5,19 +5,23 @@ return {
|
||||
SILPH_CO_9F = {
|
||||
talk = {
|
||||
-- SilphCo9FNurseText (pokered/scripts/SilphCo9F.asm):
|
||||
-- before EVENT_BEAT_SILPH_CO_GIOVANNI: heals the party and shows
|
||||
-- "You look tired..." then "Don't give up!"; after the event, just
|
||||
-- says thanks. Nurse texts are not in data/generated/text.lua, so
|
||||
-- the exact pokered/text/SilphCo9F.asm strings are used as literals.
|
||||
-- before EVENT_BEAT_SILPH_CO_GIOVANNI: heals the party, white fade
|
||||
-- (Delay3 between out/in; no Music_PkmnHealed), then "Don't give
|
||||
-- up!"; after the event, just says thanks. Nurse texts are not in
|
||||
-- data/generated/text.lua, so the exact pokered/text/SilphCo9F.asm
|
||||
-- strings are used as literals.
|
||||
TEXT_SILPHCO9F_NURSE = {
|
||||
{ "face_player" },
|
||||
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
|
||||
{ "jump_if_true", 8 },
|
||||
{ "show_text", "You look tired!\nYou should take a\nquick nap!" },
|
||||
{ "heal_party" },
|
||||
{ "show_text", "Don't give up!" },
|
||||
{ "jump", 9 },
|
||||
{ "show_text", "Thank you so\nmuch!" },
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" }, -- 2
|
||||
{ "jump_if_true", 11 }, -- 3
|
||||
{ "show_text", "You look tired!\nYou should take a\nquick nap!" }, -- 4
|
||||
{ "heal_party" }, -- 5
|
||||
{ "fade", "out", "white" }, -- 6
|
||||
{ "wait", 3 }, -- 7 Delay3
|
||||
{ "fade", "in", "white" }, -- 8
|
||||
{ "show_text", "Don't give up!" }, -- 9
|
||||
{ "jump", "end" }, -- 10
|
||||
{ "show_text", "Thank you so\nmuch!" }, -- 11
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -28,6 +28,7 @@ local files = {
|
||||
"data.scripts.flavor.pokemon_fan_club",
|
||||
"data.scripts.flavor.power_plant",
|
||||
"data.scripts.flavor.reds_house_1f",
|
||||
"data.scripts.flavor.reds_house_2f",
|
||||
"data.scripts.flavor.route11_gate_2f",
|
||||
"data.scripts.flavor.route18_gate_2f",
|
||||
"data.scripts.flavor.route_12_gate_2f",
|
||||
|
||||
+52
-29
@@ -24,17 +24,23 @@ local function starterBall(askText, species, choseFlag, ownBall,
|
||||
{ "check_flag", "EVENT_FOLLOWED_OAK_INTO_LAB" }, -- 3
|
||||
{ "jump_if_false", 20 }, -- 4
|
||||
-- the Pokédex "new species" entry shows before the ask (predef
|
||||
-- StarterDex ahead of OaksLabYouWant...Text)
|
||||
{ "push_screen", "DexEntryMenu", species }, -- 5
|
||||
-- StarterDex ahead of OaksLabYouWant...Text). StarterDex temporarily
|
||||
-- sets the owned bits so ShowPokedexData prints height/weight/text;
|
||||
-- forceOwned is that bypass without mutating save.pokedex.owned.
|
||||
{ "push_screen", "DexEntryMenu",
|
||||
{ species = species, forceOwned = true } }, -- 5
|
||||
{ "ask", askText }, -- 6
|
||||
{ "jump_if_false", 21 }, -- 7
|
||||
{ "give_pokemon", species, 5 }, -- 8
|
||||
{ "set_flag", "EVENT_GOT_STARTER" }, -- 9
|
||||
{ "set_flag", choseFlag }, -- 10
|
||||
-- OaksLab.asm prints ReceivedMon then AddPartyMon (AskName lives
|
||||
-- inside give_pokemon). Show the received text first so the
|
||||
-- nickname prompt follows "you got X", matching Gen1.
|
||||
{ "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 8
|
||||
{ "give_pokemon", species, 5 }, -- 9
|
||||
{ "set_flag", "EVENT_GOT_STARTER" }, -- 10
|
||||
{ "set_flag", choseFlag }, -- 11
|
||||
-- POKé BALLs are not handed out here in the original -- Oak gives
|
||||
-- them later, at OaksLabOak1Text's .give_poke_balls beat once the
|
||||
-- player has beaten the Route 22 rival (see TEXT_OAKSLAB_OAK1 below)
|
||||
{ "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 11
|
||||
{ "hide_object", "OAKS_LAB", ownBall }, -- 12
|
||||
-- the rival walks to the countering ball (around the furniture)
|
||||
{ "move_npc_to", 1, rivalBallX, 4 }, -- 13
|
||||
@@ -61,40 +67,44 @@ return {
|
||||
TEXT_OAKSLAB_OAK1 = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_GOT_OAKS_PARCEL" }, -- 2
|
||||
{ "jump_if_false", 14 }, -- 3
|
||||
{ "jump_if_false", 16 }, -- 3
|
||||
{ "check_flag", "EVENT_OAK_GOT_PARCEL" }, -- 4
|
||||
{ "jump_if_true", 14 }, -- 5
|
||||
{ "jump_if_true", 16 }, -- 5
|
||||
{ "show_text", "_OaksLabOak1DeliverParcelText" }, -- 6
|
||||
{ "take_item", "OAKS_PARCEL", 1 }, -- 7
|
||||
{ "set_flag", "EVENT_OAK_GOT_PARCEL" }, -- 8
|
||||
{ "show_text", "_OaksLabOak1PokemonAroundTheWorldText" }, -- 9
|
||||
{ "set_flag", "EVENT_GOT_POKEDEX" }, -- 10
|
||||
-- OaksLab.asm OakGivesPokedex: HideObject TOGGLE_POKEDEX_1/2
|
||||
-- so the table sprites leave with the gift (#106).
|
||||
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX1" }, -- 11
|
||||
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX2" }, -- 12
|
||||
-- the Pokédex swaps Viridian's two old men (OaksLab.asm:602-606:
|
||||
-- HideObject TOGGLE_LYING_OLD_MAN / ShowObject TOGGLE_OLD_MAN).
|
||||
-- Until this ran, the walking man at (17,5) -- who owns the coffee
|
||||
-- ask and the catch tutorial -- stayed OFF for the whole game
|
||||
-- (toggleable_objects.asm seeds him OFF, the sleeper ON).
|
||||
{ "hide_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY" }, -- 11
|
||||
{ "show_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN" }, -- 12
|
||||
{ "jump", 32 }, -- 13
|
||||
{ "check_flag", "EVENT_GOT_STARTER" }, -- 14
|
||||
{ "jump_if_false", 29 }, -- 15
|
||||
{ "check_item", "POKE_BALL" }, -- 16
|
||||
{ "jump_if_true", 27 }, -- 17
|
||||
{ "check_flag", "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE" }, -- 18
|
||||
{ "jump_if_false", 31 }, -- 19
|
||||
{ "check_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 20
|
||||
{ "jump_if_true", 27 }, -- 21
|
||||
{ "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 22
|
||||
{ "give_item", "POKE_BALL", 5, false }, -- 23
|
||||
{ "show_text", "_OaksLabOak1ReceivedPokeballsText" }, -- 24
|
||||
{ "show_text", "_OaksLabGivePokeballsExplanationText" }, -- 25
|
||||
{ "jump", 32 }, -- 26
|
||||
{ "show_text", "_OaksLabOak1ComeSeeMeSometimesText" }, -- 27
|
||||
{ "jump", 32 }, -- 28
|
||||
{ "show_text", "_OaksLabOak1WhichPokemonDoYouWantText" }, -- 29
|
||||
{ "jump", 32 }, -- 30
|
||||
{ "show_text", "_OaksLabOak1RaiseYourYoungPokemonText" }, -- 31 (32 = end)
|
||||
{ "hide_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY" }, -- 13
|
||||
{ "show_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN" }, -- 14
|
||||
{ "jump", "end" }, -- 15
|
||||
{ "check_flag", "EVENT_GOT_STARTER" }, -- 16
|
||||
{ "jump_if_false", 31 }, -- 17
|
||||
{ "check_item", "POKE_BALL" }, -- 18
|
||||
{ "jump_if_true", 29 }, -- 19
|
||||
{ "check_flag", "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE" }, -- 20
|
||||
{ "jump_if_false", 33 }, -- 21
|
||||
{ "check_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 22
|
||||
{ "jump_if_true", 29 }, -- 23
|
||||
{ "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 24
|
||||
{ "give_item", "POKE_BALL", 5, false }, -- 25
|
||||
{ "show_text", "_OaksLabOak1ReceivedPokeballsText" }, -- 26
|
||||
{ "show_text", "_OaksLabGivePokeballsExplanationText" }, -- 27
|
||||
{ "jump", "end" }, -- 28
|
||||
{ "show_text", "_OaksLabOak1ComeSeeMeSometimesText" }, -- 29
|
||||
{ "jump", "end" }, -- 30
|
||||
{ "show_text", "_OaksLabOak1WhichPokemonDoYouWantText" }, -- 31
|
||||
{ "jump", "end" }, -- 32
|
||||
{ "show_text", "_OaksLabOak1RaiseYourYoungPokemonText" }, -- 33
|
||||
},
|
||||
|
||||
TEXT_OAKSLAB_CHARMANDER_POKE_BALL =
|
||||
@@ -140,6 +150,19 @@ return {
|
||||
},
|
||||
},
|
||||
|
||||
-- Saves that got the Pokédex before #106 never wrote objectToggles for
|
||||
-- the table sprites; re-entering the lab applies the same HideObject
|
||||
-- the gift script now does (OaksLab.asm OakGivesPokedex).
|
||||
onEnter = function(game, ow)
|
||||
if not (game.save.flags and game.save.flags.EVENT_GOT_POKEDEX) then
|
||||
return
|
||||
end
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { save = game.save, game = game, overworld = ow }
|
||||
Commands.hide_object(ctx, "OAKS_LAB", "OAKSLAB_POKEDEX1")
|
||||
Commands.hide_object(ctx, "OAKS_LAB", "OAKSLAB_POKEDEX2")
|
||||
end,
|
||||
|
||||
-- Oak stops you leaving without a starter; the rival stops you on
|
||||
-- the way out for the first battle (scripts/OaksLab.asm
|
||||
-- OaksLabScript8 / OaksLabRivalChallenge)
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
-- Hand-ported from pret/pokered scripts/RedsHouse1F.asm.
|
||||
-- Mom (RedsHouse1FMomText, text_asm) heals the party and shows the
|
||||
-- "you should rest" / "looking great" dialogue. The intro "wake up"
|
||||
-- branch is tied to the unported intro cutscene, so the heal path is used.
|
||||
-- Mom (RedsHouse1FMomText): pre-starter shows the wake-up / Oak tip;
|
||||
-- after EVENT_GOT_STARTER, RedsHouse1FMomHealScript fades to white,
|
||||
-- heals, plays MUSIC_PKMN_HEALED, fades back, then "looking great".
|
||||
|
||||
return {
|
||||
talk = {
|
||||
TEXT_REDSHOUSE1F_MOM = {
|
||||
{ "face_player" },
|
||||
{ "show_text", "_RedsHouse1FMomYouShouldRestText" },
|
||||
{ "heal_party" },
|
||||
{ "show_text", "_RedsHouse1FMomLookingGreatText" },
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_GOT_STARTER" }, -- 2
|
||||
{ "jump_if_true", 6 }, -- 3
|
||||
{ "show_text", "_RedsHouse1FMomWakeUpText" }, -- 4
|
||||
{ "jump", "end" }, -- 5
|
||||
-- RedsHouse1FMomHealScript
|
||||
{ "show_text", "_RedsHouse1FMomYouShouldRestText" }, -- 6
|
||||
{ "fade", "out", "white" }, -- 7 GBFadeOutToWhite
|
||||
{ "heal_party" }, -- 8
|
||||
{ "play_once", "Music_PkmnHealed" }, -- 9 wait + restore map
|
||||
{ "fade", "in", "white" }, -- 10 GBFadeInFromWhite
|
||||
{ "show_text", "_RedsHouse1FMomLookingGreatText" }, -- 11
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
+244
-72
@@ -222,17 +222,55 @@ M.BILLS_HOUSE = {
|
||||
-- repair saves that already got the ticket under the old collapsed
|
||||
-- script (the monster never hidden, human Bill never shown)
|
||||
onEnter = function(game, ow)
|
||||
if game.save.flags.EVENT_GOT_SS_TICKET
|
||||
and not game.save.flags.EVENT_USED_CELL_SEPARATOR_ON_BILL then
|
||||
local flags = game.save.flags
|
||||
if flags.EVENT_GOT_SS_TICKET
|
||||
and not flags.EVENT_USED_CELL_SEPARATOR_ON_BILL then
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { game = game, save = game.save, overworld = ow }
|
||||
Commands.hide_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL_POKEMON")
|
||||
Commands.show_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL1")
|
||||
game.save.flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = true
|
||||
game.save.flags.EVENT_USED_CELL_SEPARATOR_ON_BILL = true
|
||||
game.save.flags.EVENT_MET_BILL = true
|
||||
game.save.flags.EVENT_MET_BILL_2 = true
|
||||
flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = true
|
||||
flags.EVENT_USED_CELL_SEPARATOR_ON_BILL = true
|
||||
flags.EVENT_MET_BILL = true
|
||||
flags.EVENT_MET_BILL_2 = true
|
||||
end
|
||||
-- After Route25ToggleBillsScript, BILL2 is the visible human Bill
|
||||
-- ("check out my rare POKéMON"). Re-apply on enter so a mid-house
|
||||
-- load still matches the toggles if the pool was rebuilt.
|
||||
if flags.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING then
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { game = game, save = game.save, overworld = ow }
|
||||
Commands.hide_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL_POKEMON")
|
||||
Commands.hide_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL1")
|
||||
Commands.show_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL2")
|
||||
end
|
||||
end,
|
||||
}
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
-- Route 25 outside Bill's house (scripts/Route25.asm
|
||||
-- Route25ToggleBillsScript): leaving after the SS Ticket arms the
|
||||
-- Eevee PC list and swaps human Bill to his post-help dialogue NPC.
|
||||
-- Leaving mid-quest (Bill still in the machine) puts the monster back.
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
M.ROUTE_25 = {
|
||||
onEnter = function(game, ow)
|
||||
local flags = game.save.flags
|
||||
if flags.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING then return end
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { game = game, save = game.save, overworld = ow }
|
||||
if not flags.EVENT_MET_BILL_2 then
|
||||
flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = nil
|
||||
Commands.show_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL_POKEMON")
|
||||
return
|
||||
end
|
||||
if not flags.EVENT_GOT_SS_TICKET then return end
|
||||
flags.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING = true
|
||||
-- TOGGLE_NUGGET_BRIDGE_GUY (ROUTE24_COOLTRAINER_M1)
|
||||
Commands.hide_object(ctx, "ROUTE_24", "ROUTE24_COOLTRAINER_M1")
|
||||
Commands.hide_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL1")
|
||||
Commands.show_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL2")
|
||||
end,
|
||||
}
|
||||
|
||||
@@ -256,25 +294,40 @@ M.VERMILION_CITY = {
|
||||
end,
|
||||
-- VermilionCityDefaultScript's per-frame SSAnneTicketCheckCoords check:
|
||||
-- the unguarded cell (18,30) just west of the sailor leads straight
|
||||
-- onto the dock warp, so stepping onto it heading for the dock gets
|
||||
-- ticket-checked (and turned back once the ship has sailed) without
|
||||
-- the player ever pressing A. The sailor himself never disappears.
|
||||
-- onto the dock warp. Stepping onto it facing down always runs the
|
||||
-- sailor dialog (DisplayTextID TEXT_VERMILIONCITY_SAILOR1); only a
|
||||
-- ticket while the ship is still docked lets the player continue --
|
||||
-- otherwise they are walked back up. The sailor himself never hides.
|
||||
onStep = function(game, ow, x, y)
|
||||
if x ~= 18 or y ~= 30 then return false end
|
||||
if ow.player.facing ~= "down" then return false end
|
||||
local f = game.save.flags
|
||||
local Flags = require("src.script.Flags")
|
||||
local t = game.data.text
|
||||
local TextBox = require("src.render.TextBox")
|
||||
if f.EVENT_SS_ANNE_LEFT then
|
||||
local shipLeft = Flags.get(game.save, "EVENT_SS_ANNE_LEFT")
|
||||
local hasTicket = (game.save.inventory.S_S_TICKET or 0) > 0
|
||||
if shipLeft then
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._VermilionCitySailor1ShipSetSailText or "The ship set sail.",
|
||||
function() ow:scriptMove(ow.player, "up", 1) end))
|
||||
return true
|
||||
end
|
||||
if (game.save.inventory.S_S_TICKET or 0) > 0 then return false end
|
||||
-- Walk-past is never facing-right / inFrontOfOrBehindGuardCoords, so
|
||||
-- VermilionCitySailor1Text always takes .greet_player_and_check_ticket:
|
||||
-- DoYouHaveATicket, then FlashedTicket (allow through) or YouNeedATicket
|
||||
-- (walk back). Returning true while the box is up also blocks the
|
||||
-- dock warp on this step.
|
||||
local ask = t._VermilionCitySailor1DoYouHaveATicketText
|
||||
or "Welcome to S.S.\nANNE!\fExcuse me, do you\nhave a ticket?"
|
||||
if hasTicket then
|
||||
game.stack:push(TextBox.new(game,
|
||||
ask .. "\f"
|
||||
.. (t._VermilionCitySailor1FlashedTicketText
|
||||
or "{PLAYER} flashed\nthe S.S.TICKET!")))
|
||||
return true
|
||||
end
|
||||
game.stack:push(TextBox.new(game,
|
||||
(t._VermilionCitySailor1WelcomeToSSAnneText or "Welcome to S.S.\nANNE!")
|
||||
.. "\f"
|
||||
ask .. "\f"
|
||||
.. (t._VermilionCitySailor1YouNeedATicketText
|
||||
or "You need a ticket\nto get aboard."),
|
||||
function() ow:scriptMove(ow.player, "up", 1) end))
|
||||
@@ -288,7 +341,7 @@ M.VERMILION_CITY = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_SS_ANNE_LEFT" }, -- 2
|
||||
{ "jump_if_true", 11 }, -- 3
|
||||
{ "show_text", "_VermilionCitySailor1WelcomeToSSAnneText" }, -- 4
|
||||
{ "show_text", "_VermilionCitySailor1DoYouHaveATicketText" }, -- 4
|
||||
{ "check_item", "S_S_TICKET" }, -- 5
|
||||
{ "jump_if_false", 9 }, -- 6
|
||||
{ "show_text", "_VermilionCitySailor1FlashedTicketText" }, -- 7
|
||||
@@ -318,18 +371,21 @@ M.SS_ANNE_2F = {
|
||||
|
||||
M.SS_ANNE_CAPTAINS_ROOM = {
|
||||
talk = {
|
||||
-- SSAnneCaptainsRoomCaptainText: after the rub line's text_asm tail,
|
||||
-- pokered plays MUSIC_PKMN_HEALED (scripts/SSAnneCaptainsRoom.asm).
|
||||
TEXT_SSANNECAPTAINSROOM_CAPTAIN = {
|
||||
{ "check_flag", "EVENT_GOT_HM01" }, -- 1
|
||||
{ "jump_if_true", 9 }, -- 2
|
||||
{ "jump_if_true", 10 }, -- 2
|
||||
{ "show_text", "_SSAnneCaptainsRoomRubCaptainsBackText" }, -- 3
|
||||
{ "show_text", "_SSAnneCaptainsRoomCaptainIFeelMuchBetterText" }, -- 4
|
||||
{ "play_once", "Music_PkmnHealed" }, -- 4
|
||||
{ "show_text", "_SSAnneCaptainsRoomCaptainIFeelMuchBetterText" }, -- 5
|
||||
-- give-then-print like scripts/SSAnneCaptainsRoom.asm (GiveItem
|
||||
-- fills wStringBuffer; the received text reads it)
|
||||
{ "give_item", "HM_CUT", 1, false }, -- 5
|
||||
{ "show_text", "_SSAnneCaptainsRoomCaptainReceivedHM01Text" }, -- 6
|
||||
{ "set_flag", "EVENT_GOT_HM01" }, -- 7
|
||||
{ "jump", 10 }, -- 8
|
||||
{ "show_text", "_SSAnneCaptainsRoomCaptainNotSickAnymoreText" }, -- 9
|
||||
{ "give_item", "HM_CUT", 1, false }, -- 6
|
||||
{ "show_text", "_SSAnneCaptainsRoomCaptainReceivedHM01Text" }, -- 7
|
||||
{ "set_flag", "EVENT_GOT_HM01" }, -- 8
|
||||
{ "jump", 11 }, -- 9
|
||||
{ "show_text", "_SSAnneCaptainsRoomCaptainNotSickAnymoreText" }, -- 10
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -560,7 +616,8 @@ M.SILPH_CO_11F = {
|
||||
-- -------------------------------------------------------------------
|
||||
-- Victory Road boulder switches (scripts/VictoryRoad1F/2F/3F.asm):
|
||||
-- a boulder resting on a switch removes a barrier block; the 3F hole
|
||||
-- drops a boulder down to the 2F switch.
|
||||
-- drops a boulder down to the 2F switch, and also dungeon-warps the
|
||||
-- player (IsPlayerOnDungeonWarp + DungeonWarpList/Data -> 2F at 22,16).
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
local function boulderAt(ow, x, y)
|
||||
@@ -626,6 +683,18 @@ M.VICTORY_ROAD_3F = {
|
||||
Commands.show_object(ctx, "VICTORY_ROAD_2F", "VICTORYROAD2F_BOULDER")
|
||||
end
|
||||
end,
|
||||
-- scripts/VictoryRoad3F.asm VictoryRoad3FDefaultScript: the same hole
|
||||
-- is a dungeon warp for the player (wDungeonWarpDestinationMap =
|
||||
-- VICTORY_ROAD_2F, wWhichDungeonWarp = 2 -> DungeonWarpData 22,16).
|
||||
-- Mirrors POKEMON_MANSION_3F.onStep; CAVERN $22 is walkable so the
|
||||
-- fall is onStep, not a collision block.
|
||||
onStep = function(game, ow, x, y)
|
||||
if x == 23 and y == 15 then
|
||||
ow:startWarpTo("VICTORY_ROAD_2F", 22, 16, ow.player.facing)
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end,
|
||||
}
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
@@ -638,6 +707,16 @@ M.VICTORY_ROAD_3F = {
|
||||
-- IndigoPlateauLobby.asm) so the champion is re-fightable on rematches,
|
||||
-- like pokered's re-entry cutscene. EVENT_BEAT_CHAMPION_RIVAL stays set
|
||||
-- forever (postgame gates like the Cerulean cave guard read it).
|
||||
--
|
||||
-- pokered forces the fight on map entry: Agatha's victory arms
|
||||
-- SCRIPT_CHAMPIONSROOM_PLAYER_ENTERS (scripts/AgathasRoom.asm), and
|
||||
-- ChampionsRoomPlayerEntersScript then runs RivalEntrance_RLEMovement
|
||||
-- (up 1, right 1, up 3) before ChampionsRoomRivalReadyToBattleScript.
|
||||
-- The rival object has no trainer header / sight range, so without that
|
||||
-- entrance script the player can walk past (issue #99). We arm on the
|
||||
-- run flag instead of Agatha's victory bit: same observable effect for
|
||||
-- first clear and Indigo rematches, without a cross-map script pointer.
|
||||
--
|
||||
-- scripts/ChampionsRoom.asm ChampionsRoomRivalDefeatedScript ->
|
||||
-- OakArrivesScript -> OakCongratulatesPlayerScript ->
|
||||
-- OakDisappointedWithRivalScript -> OakComeWithMeScript -> OakExitsScript,
|
||||
@@ -646,43 +725,69 @@ M.VICTORY_ROAD_3F = {
|
||||
-- itself is NOT run here: it belongs to the HALL_OF_FAME room script
|
||||
-- (scripts/HallOfFame.asm), so we set a one-shot marker and warp; the room
|
||||
-- onEnter (M.HALL_OF_FAME below) drives the HoF Oak speech + record.
|
||||
local championsRoomRivalScript = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 2
|
||||
{ "jump_if_true", 25 }, -- 3 past end
|
||||
{ "show_text", "_ChampionsRoomRivalIntroText" }, -- 4
|
||||
{ "rival_battle", "OPP_RIVAL3", 1 }, -- 5
|
||||
{ "jump_if_false", 25 }, -- 6 past end
|
||||
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 7
|
||||
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL" }, -- 8
|
||||
-- ChampionsRoomRivalDefeatedScript re-displays TEXT_CHAMPIONSROOM_RIVAL,
|
||||
-- whose text_asm takes the EVENT_BEAT_CHAMPION_RIVAL branch =
|
||||
-- _ChampionsRoomRivalAfterBattleText (the in-battle _RivalDefeatedText
|
||||
-- is the port's generic "<PLAYER> defeated BLUE!" engine line instead).
|
||||
{ "show_text", "_ChampionsRoomRivalAfterBattleText" }, -- 9
|
||||
-- ChampionsRoomOakArrivesScript: Music_Cities1AlternateTempo
|
||||
-- (Cities1, kept into HALL_OF_FAME like BIT_NO_MAP_MUSIC after
|
||||
-- defeating RIVAL3), then Oak's "{PLAYER}!" + reveal + walk in
|
||||
{ "play_music", "Music_Cities1", { keep = true } }, -- 10
|
||||
{ "show_text", "_ChampionsRoomOakText" }, -- 11
|
||||
{ "show_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 12
|
||||
{ "move_npc", 2, "up", 5 }, -- 13 OakEntranceAfterVictoryMovement
|
||||
-- OakCongratulatesPlayerScript: rival faces left, Oak faces down
|
||||
{ "face_object", 1, "left" }, -- 14
|
||||
{ "face_object", 2, "down" }, -- 15
|
||||
{ "show_text", "_ChampionsRoomOakCongratulatesPlayerText" }, -- 16
|
||||
-- OakDisappointedWithRivalScript: Oak turns to the rival (right)
|
||||
{ "face_object", 2, "right" }, -- 17
|
||||
{ "show_text", "_ChampionsRoomOakDisappointedWithRivalText" }, -- 18
|
||||
-- OakComeWithMeScript: Oak faces down again, then exits up
|
||||
{ "face_object", 2, "down" }, -- 19
|
||||
{ "show_text", "_ChampionsRoomOakComeWithMeText" }, -- 20
|
||||
{ "move_npc", 2, "up", 2 }, -- 21 OakExitChampionsRoomMovement
|
||||
{ "hide_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 22
|
||||
-- hand the induction off to the HALL_OF_FAME room (consumed by its
|
||||
-- onEnter), then warp up into it (destWarp 1 lands at (4,7) facing up)
|
||||
{ "set_field", "pendingHallOfFame", true }, -- 23
|
||||
{ "warp", "HALL_OF_FAME", 4, 7, "up" }, -- 24
|
||||
}
|
||||
|
||||
M.CHAMPIONS_ROOM = {
|
||||
onEnter = function(game, ow)
|
||||
if game.save.flags.EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN then return end
|
||||
-- Lance entrance warps land at y=7; HoF return warps land at y=0.
|
||||
-- Only the south entry should run ChampionsRoomPlayerEntersScript.
|
||||
if ow.player.cellY < 7 then return end
|
||||
local rival
|
||||
for _, npc in ipairs(ow.npcs) do
|
||||
if npc.def and npc.def.name == "CHAMPIONSROOM_RIVAL" then
|
||||
rival = npc
|
||||
break
|
||||
end
|
||||
end
|
||||
-- RivalEntrance_RLEMovement, then the battle/Oak script (queued
|
||||
-- separately so talk-script jump indices stay 1-based as written).
|
||||
ow:queueScript({
|
||||
{ "move_player", "up", 1 },
|
||||
{ "move_player", "right", 1 },
|
||||
{ "move_player", "up", 3 },
|
||||
})
|
||||
ow:queueScript(championsRoomRivalScript, { npc = rival })
|
||||
end,
|
||||
talk = {
|
||||
TEXT_CHAMPIONSROOM_RIVAL = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 2
|
||||
{ "jump_if_true", 24 }, -- 3
|
||||
{ "show_text", "_ChampionsRoomRivalIntroText" }, -- 4
|
||||
{ "rival_battle", "OPP_RIVAL3", 1 }, -- 5
|
||||
{ "jump_if_false", 24 }, -- 6
|
||||
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 7
|
||||
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL" }, -- 8
|
||||
-- ChampionsRoomRivalDefeatedScript re-displays TEXT_CHAMPIONSROOM_RIVAL,
|
||||
-- whose text_asm takes the EVENT_BEAT_CHAMPION_RIVAL branch =
|
||||
-- _ChampionsRoomRivalAfterBattleText (the in-battle _RivalDefeatedText
|
||||
-- is the port's generic "<PLAYER> defeated BLUE!" engine line instead).
|
||||
{ "show_text", "_ChampionsRoomRivalAfterBattleText" }, -- 9
|
||||
-- ChampionsRoomOakArrivesScript: Oak's "{PLAYER}!" then reveal + walk in
|
||||
{ "show_text", "_ChampionsRoomOakText" }, -- 10
|
||||
{ "show_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 11
|
||||
{ "move_npc", 2, "up", 5 }, -- 12 OakEntranceAfterVictoryMovement (3,7)->(3,2)
|
||||
-- OakCongratulatesPlayerScript: rival faces left, Oak faces down
|
||||
{ "face_object", 1, "left" }, -- 13
|
||||
{ "face_object", 2, "down" }, -- 14
|
||||
{ "show_text", "_ChampionsRoomOakCongratulatesPlayerText" }, -- 15
|
||||
-- OakDisappointedWithRivalScript: Oak turns to the rival (right)
|
||||
{ "face_object", 2, "right" }, -- 16
|
||||
{ "show_text", "_ChampionsRoomOakDisappointedWithRivalText" }, -- 17
|
||||
-- OakComeWithMeScript: Oak faces down again, then exits up
|
||||
{ "face_object", 2, "down" }, -- 18
|
||||
{ "show_text", "_ChampionsRoomOakComeWithMeText" }, -- 19
|
||||
{ "move_npc", 2, "up", 2 }, -- 20 OakExitChampionsRoomMovement (3,2)->(3,0)
|
||||
{ "hide_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 21
|
||||
-- hand the induction off to the HALL_OF_FAME room (consumed by its
|
||||
-- onEnter), then warp up into it (destWarp 1 lands at (4,7) facing up)
|
||||
{ "set_field", "pendingHallOfFame", true }, -- 22
|
||||
{ "warp", "HALL_OF_FAME", 4, 7, "up" }, -- 23
|
||||
},
|
||||
TEXT_CHAMPIONSROOM_RIVAL = championsRoomRivalScript,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -712,6 +817,29 @@ M.HALL_OF_FAME = {
|
||||
local toggles = game.save.objectToggles and game.save.objectToggles.HALL_OF_FAME
|
||||
if toggles then toggles.HALLOFFAME_OAK = nil end
|
||||
|
||||
-- Make the Hall of Fame recording machine interactable. The console
|
||||
-- juts from the north wall as the two solid cells (4,1) and (5,1); a
|
||||
-- sign on each lets the player face the machine (from below, or from
|
||||
-- either side) and press A to run the TEXT_HALLOFFAME_PC talk script.
|
||||
-- maps.lua is generated from the ROM and gitignored, so the sign is
|
||||
-- injected here (a tracked script) rather than baked into map data.
|
||||
local def = ow.map.def
|
||||
def.signs = def.signs or {}
|
||||
local present = false
|
||||
for _, s in ipairs(def.signs) do
|
||||
if s.text == "TEXT_HALLOFFAME_PC" then present = true break end
|
||||
end
|
||||
if not present then
|
||||
table.insert(def.signs, { text = "TEXT_HALLOFFAME_PC", x = 4, y = 1 })
|
||||
table.insert(def.signs, { text = "TEXT_HALLOFFAME_PC", x = 5, y = 1 })
|
||||
end
|
||||
-- the live Map instance built its signAt lookup from def.signs before
|
||||
-- this hook ran, so rebuild it (idempotent) to pick up the injection
|
||||
ow.map.signAt = {}
|
||||
for _, s in ipairs(def.signs) do
|
||||
ow.map.signAt[s.y * ow.map.widthCells + s.x] = s
|
||||
end
|
||||
|
||||
if not game.save.pendingHallOfFame then return end
|
||||
game.save.pendingHallOfFame = false
|
||||
ow:queueScript({
|
||||
@@ -725,6 +853,21 @@ M.HALL_OF_FAME = {
|
||||
{ "record_hall_of_fame" }, -- predef HallOfFamePC: induction + credits
|
||||
})
|
||||
end,
|
||||
talk = {
|
||||
-- The recording machine doubles as a "warp home" PC: a YES/NO prompt
|
||||
-- that teleports back to the new-game bedroom spawn (special_warps.asm
|
||||
-- NewGameWarp: REDS_HOUSE_2F, 3, 6, facing down). Fabricated
|
||||
-- convenience -- there is no such prompt in the original ROM.
|
||||
-- Heal + remember_outdoor so house LAST_MAP mats land in Pallet, not
|
||||
-- Indigo Plateau (issue #103 escape path for already-stuck saves).
|
||||
TEXT_HALLOFFAME_PC = {
|
||||
{ "ask", "Return to\nPALLET TOWN?" }, -- 1 YES/NO -> lastCheck
|
||||
{ "jump_if_false", "end" }, -- 2 NO: back away
|
||||
{ "heal_party" }, -- 3
|
||||
{ "remember_outdoor", "PALLET_TOWN", 5, 6 }, -- 4 LAST_MAP -> Pallet door
|
||||
{ "warp", "REDS_HOUSE_2F", 3, 6, "down" }, -- 5 YES: home to your room
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
@@ -749,21 +892,51 @@ M.CERULEAN_CITY = {
|
||||
},
|
||||
}
|
||||
|
||||
-- PokemonTower2F.asm: after the battle, PokemonTower2FDefeatedRivalScript
|
||||
-- walks him out (RightThenDown vs DownThenRight from EVENT_POKEMON_TOWER_
|
||||
-- RIVAL_ON_LEFT) then HideObject TOGGLE_POKEMON_TOWER_2F_RIVAL. Player
|
||||
-- at (15,5) is the ON_LEFT case.
|
||||
local TOWER_RIVAL_EXIT_RIGHT_THEN_DOWN =
|
||||
{ "right", "down", "down", "right", "down", "down", "right", "right" }
|
||||
local TOWER_RIVAL_EXIT_DOWN_THEN_RIGHT =
|
||||
{ "down", "down", "right", "right", "right", "right", "down", "down" }
|
||||
|
||||
local function pokemonTower2FRivalScript(playerX)
|
||||
local exitDirs = (playerX == 15)
|
||||
and TOWER_RIVAL_EXIT_DOWN_THEN_RIGHT
|
||||
or TOWER_RIVAL_EXIT_RIGHT_THEN_DOWN
|
||||
return {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 2
|
||||
{ "jump_if_true", 12 }, -- 3
|
||||
{ "show_text", "_PokemonTower2FRivalWhatBringsYouHereText" }, -- 4
|
||||
{ "rival_battle", "OPP_RIVAL2", 4 }, -- 5
|
||||
{ "jump_if_false", "end" }, -- 6 loss: stay
|
||||
{ "set_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 7
|
||||
{ "show_text", "_PokemonTower2FRivalDefeatedText" }, -- 8
|
||||
{ "walk_npc", 1, exitDirs }, -- 9
|
||||
{ "hide_object", "POKEMON_TOWER_2F", "POKEMONTOWER2F_RIVAL" }, -- 10
|
||||
{ "jump", "end" }, -- 11
|
||||
{ "show_text", "_PokemonTower2FRivalHowsYourDexText" }, -- 12
|
||||
}
|
||||
end
|
||||
|
||||
M.POKEMON_TOWER_2F = {
|
||||
rivalScript = pokemonTower2FRivalScript,
|
||||
talk = {
|
||||
TEXT_POKEMONTOWER2F_RIVAL = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 2
|
||||
{ "jump_if_true", 10 }, -- 3
|
||||
{ "show_text", "_PokemonTower2FRivalWhatBringsYouHereText" }, -- 4
|
||||
{ "rival_battle", "OPP_RIVAL2", 4 }, -- 5
|
||||
{ "jump_if_false", 11 }, -- 6
|
||||
{ "set_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 7
|
||||
{ "show_text", "_PokemonTower2FRivalDefeatedText" }, -- 8
|
||||
{ "jump", 11 }, -- 9
|
||||
{ "show_text", "_PokemonTower2FRivalHowsYourDexText" }, -- 10
|
||||
},
|
||||
TEXT_POKEMONTOWER2F_RIVAL = function(game, ow, npc, done)
|
||||
ow.runner:run(pokemonTower2FRivalScript(ow.player.cellX),
|
||||
{ npc = npc, onDone = done })
|
||||
end,
|
||||
},
|
||||
-- Saves that beat him before the exit walk was ported still have the
|
||||
-- flag but a visible rival; hide on enter like BillsHouse repairs.
|
||||
onEnter = function(game, ow)
|
||||
if not game.save.flags.EVENT_BEAT_POKEMON_TOWER_RIVAL then return end
|
||||
local Commands = require("src.script.Commands")
|
||||
Commands.hide_object({ game = game, save = game.save, overworld = ow },
|
||||
"POKEMON_TOWER_2F", "POKEMONTOWER2F_RIVAL")
|
||||
end,
|
||||
-- PokemonTower2FDefaultScript: walking past the rival's tile forces
|
||||
-- the encounter (ArePlayerCoordsInArray on (15,5)/(14,6)) -- he never
|
||||
-- waits to be talked to
|
||||
@@ -775,8 +948,7 @@ M.POKEMON_TOWER_2F = {
|
||||
if not rival then return false end
|
||||
ow.player.facing = (x == 15) and "left" or "up"
|
||||
require("src.core.Music").play(game.data, "Music_MeetRival")
|
||||
ow.runner:run(M.POKEMON_TOWER_2F.talk.TEXT_POKEMONTOWER2F_RIVAL,
|
||||
{ npc = rival })
|
||||
ow.runner:run(pokemonTower2FRivalScript(x), { npc = rival })
|
||||
return true
|
||||
end,
|
||||
}
|
||||
|
||||
+141
-44
@@ -719,73 +719,170 @@ M.CINNABAR_LAB_FOSSIL_ROOM = {
|
||||
-- -------------------------------------------------------------------
|
||||
-- Day-care (scripts/Daycare.asm): the boarded Pokémon earns 1 exp per
|
||||
-- step; the fee is ¥100 plus ¥100 per level gained.
|
||||
--
|
||||
-- #118: do not raise mon.level until a paid retrieve (pokered reverts
|
||||
-- wDayCareMonBoxLevel on .leaveMonInDayCare). Fold pending steps into
|
||||
-- mon.exp once and clear them so a second talk cannot re-apply the same
|
||||
-- walk. Fill {RAM:wNameBuffer}/{RAM:wDayCareMonName}/{NUM:...} here —
|
||||
-- TextBox.TOKENS.RAM only knows wStringBuffer.
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
local function fillDaycareText(s, subs)
|
||||
s = s:gsub("{PLAYER}", subs.player or "")
|
||||
s = s:gsub("{RAM:([^}]*)}", function(name) return subs[name] or "" end)
|
||||
-- extractor NUM spans may include flags after a comma; keep the name
|
||||
s = s:gsub("{NUM:([%w_]+)[^}]*}", function(name)
|
||||
return tostring(subs[name] or "0")
|
||||
end)
|
||||
return s
|
||||
end
|
||||
|
||||
M.DAYCARE = {
|
||||
talk = {
|
||||
TEXT_DAYCARE_GENTLEMAN = function(game, ow, npc, done)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
local t = game.data.text
|
||||
local dc = game.save.daycare
|
||||
local playerName = game.save.player and game.save.player.name or "RED"
|
||||
|
||||
local function monName(mon)
|
||||
local def = game.data.pokemon[mon.species]
|
||||
return mon.nickname or (def and def.name) or mon.species
|
||||
end
|
||||
|
||||
if dc and dc.mon then
|
||||
local Growth = require("src.pokemon.Growth")
|
||||
local Stats = require("src.pokemon.Stats")
|
||||
local Party = require("src.pokemon.Party")
|
||||
local mon = dc.mon
|
||||
local def = game.data.pokemon[mon.species]
|
||||
mon.exp = mon.exp + (dc.steps or 0)
|
||||
local newLevel = math.min(100, Growth.levelForExp(def.growthRate, mon.exp))
|
||||
local fee = 100 + (newLevel - mon.level) * 100
|
||||
local grew = newLevel > mon.level
|
||||
mon.level = newLevel
|
||||
mon.stats = Stats.calc(def, mon.level, mon.dvs, mon.statExp)
|
||||
mon.hp = mon.stats.hp
|
||||
local msg = grew and (t._DaycareGentlemanMonHasGrownText or "It's grown a lot!")
|
||||
or "Back already?"
|
||||
game.stack:push(TextBox.new(game,
|
||||
msg .. ("\fThe fee is ¥%d.\nGet it back?"):format(fee), function()
|
||||
game.stack:push(ChoiceBox.new(game, function(yes)
|
||||
if yes and game.save.money >= fee then
|
||||
-- Apply deferred step-exp once (OverworldController only bumps
|
||||
-- daycare.steps). Clearing prevents double-count on re-talk.
|
||||
mon.exp = (mon.exp or 0) + (dc.steps or 0)
|
||||
dc.steps = 0
|
||||
-- depositLevel mirrors wDayCareMonBoxLevel: fee baseline that
|
||||
-- must survive a declined retrieve. Fall back to mon.level for
|
||||
-- older saves that predate the field.
|
||||
if dc.depositLevel == nil then dc.depositLevel = mon.level end
|
||||
local startLevel = dc.depositLevel
|
||||
local newLevel = Growth.levelForExp(def and def.growthRate, mon.exp)
|
||||
if newLevel >= 100 then
|
||||
newLevel = 100
|
||||
if def then
|
||||
mon.exp = Growth.expForLevel(def.growthRate, 100)
|
||||
end
|
||||
end
|
||||
local levelsGrown = math.max(0, newLevel - startLevel)
|
||||
local fee = 100 + levelsGrown * 100
|
||||
local name = monName(mon)
|
||||
local subs = {
|
||||
player = playerName,
|
||||
wNameBuffer = name,
|
||||
wDayCareMonName = name,
|
||||
wDayCareNumLevelsGrown = levelsGrown,
|
||||
wDayCareTotalCost = fee,
|
||||
}
|
||||
local statusText = levelsGrown > 0
|
||||
and (t._DaycareGentlemanMonHasGrownText
|
||||
or "Your {RAM:wNameBuffer}\nhas grown a lot!\fBy level, it's\ngrown by {NUM:wDayCareNumLevelsGrown, 1, 3}!\fAren't I great?")
|
||||
or (t._DaycareGentlemanMonNeedsMoreTimeText
|
||||
or "Back already?\nYour {RAM:wNameBuffer}\nneeds some more\ntime with me.")
|
||||
|
||||
game.stack:push(TextBox.new(game, fillDaycareText(statusText, subs), function()
|
||||
if #game.save.party >= Party.MAX then
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._DaycareGentlemanNoRoomForMonText
|
||||
or "You have no room\nfor this POKéMON!", done))
|
||||
return
|
||||
end
|
||||
game.stack:push(TextBox.new(game,
|
||||
fillDaycareText(
|
||||
t._DaycareGentlemanOweMoneyText
|
||||
or "You owe me ¥{NUM:wDayCareTotalCost, 2 | LEADING_ZEROES | LEFT_ALIGN}\nfor the return\nof this POKéMON.",
|
||||
subs),
|
||||
nil, { choice = function(yes)
|
||||
if not yes then
|
||||
-- .leaveMonInDayCare: revert any transient level bump
|
||||
mon.level = startLevel
|
||||
game.stack:push(TextBox.new(game,
|
||||
(t._DaycareGentlemanAllRightThenText or "All right then,\n")
|
||||
.. (t._DaycareGentlemanComeAgainText or "come again."),
|
||||
done))
|
||||
return
|
||||
end
|
||||
if (game.save.money or 0) < fee then
|
||||
mon.level = startLevel
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._DaycareGentlemanNotEnoughMoneyText
|
||||
or "Hey, you don't\nhave enough ¥!", done))
|
||||
return
|
||||
end
|
||||
game.save.money = game.save.money - fee
|
||||
mon.level = newLevel
|
||||
if def then
|
||||
mon.stats = Stats.calc(def, mon.level, mon.dvs, mon.statExp)
|
||||
mon.hp = mon.stats.hp
|
||||
end
|
||||
table.insert(game.save.party, mon)
|
||||
game.save.daycare = nil
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._DaycareGentlemanGotMonBackText or "Here you go!", done))
|
||||
else
|
||||
game.stack:push(TextBox.new(game,
|
||||
yes and (t._DaycareGentlemanOweMoneyText or "You owe me money!")
|
||||
or "Come again!", done))
|
||||
end
|
||||
end))
|
||||
t._DaycareGentlemanHeresYourMonText
|
||||
or "Thank you! Here's\nyour POKéMON!", function()
|
||||
game.stack:push(TextBox.new(game,
|
||||
fillDaycareText(
|
||||
t._DaycareGentlemanGotMonBackText
|
||||
or "{PLAYER} got\n{RAM:wDayCareMonName} back!",
|
||||
subs), done))
|
||||
end))
|
||||
end }))
|
||||
end))
|
||||
return
|
||||
end
|
||||
|
||||
if #game.save.party < 2 then
|
||||
game.stack:push(TextBox.new(game,
|
||||
"You only have one\nPOKéMON with you!", done))
|
||||
return
|
||||
end
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._DaycareGentlemanIntroText or "I can raise a\nPOKéMON for you.", function()
|
||||
game.stack:push(ChoiceBox.new(game, function(yes)
|
||||
if not yes then done() return end
|
||||
local PartyMenu = require("src.ui.PartyMenu")
|
||||
game.stack:push(PartyMenu.new(game, {
|
||||
pickOnly = true,
|
||||
onSwitch = function(mon)
|
||||
for i, m in ipairs(game.save.party) do
|
||||
if m == mon then table.remove(game.save.party, i) break end
|
||||
end
|
||||
game.save.daycare = { mon = mon, steps = 0 }
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._DaycareGentlemanWillLookAfterMonText or
|
||||
"Fine, I'll look\nafter it a while!", done))
|
||||
end,
|
||||
}))
|
||||
end))
|
||||
end))
|
||||
t._DaycareGentlemanIntroText
|
||||
or "I run a DAYCARE.\nWould you like me\nto raise one of\nyour POKéMON?",
|
||||
nil, { choice = function(yes)
|
||||
if not yes then
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._DaycareGentlemanComeAgainText or "come again.", done))
|
||||
return
|
||||
end
|
||||
if #game.save.party < 2 then
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._DaycareGentlemanOnlyHaveOneMonText
|
||||
or "You only have one\nPOKéMON with you.", done))
|
||||
return
|
||||
end
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._DaycareGentlemanWhichMonText or "Which POKéMON\nshould I raise?",
|
||||
function()
|
||||
local PartyMenu = require("src.ui.PartyMenu")
|
||||
game.stack:push(PartyMenu.new(game, {
|
||||
pickOnly = true,
|
||||
onSwitch = function(mon)
|
||||
for i, m in ipairs(game.save.party) do
|
||||
if m == mon then table.remove(game.save.party, i) break end
|
||||
end
|
||||
local name = monName(mon)
|
||||
-- depositLevel = wDayCareMonBoxLevel at deposit time
|
||||
game.save.daycare = {
|
||||
mon = mon, steps = 0, depositLevel = mon.level,
|
||||
}
|
||||
game.stack:push(TextBox.new(game,
|
||||
fillDaycareText(
|
||||
t._DaycareGentlemanWillLookAfterMonText
|
||||
or "Fine, I'll look\nafter {RAM:wNameBuffer}\nfor a while.",
|
||||
{ player = playerName, wNameBuffer = name }),
|
||||
function()
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._DaycareGentlemanComeSeeMeInAWhileText
|
||||
or "Come see me in\na while.", done))
|
||||
end))
|
||||
end,
|
||||
}))
|
||||
end))
|
||||
end }))
|
||||
end,
|
||||
},
|
||||
}
|
||||
|
||||
+264
-54
@@ -61,6 +61,50 @@ M.ROUTE_12_SUPER_ROD_HOUSE = {
|
||||
M.ROUTE_12_SUPER_ROD_HOUSE.talk.TEXT_ROUTE12SUPERRODHOUSE_FISHING_GURU[9] =
|
||||
{ "show_text", "_Route12SuperRodHouseFishingGuruTryFishingText" }
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
-- Pokemon Tower 5F purified zone (scripts/PokemonTower5F.asm
|
||||
-- PokemonTower5FDefaultScript): the 2x2 center pad heals the party once
|
||||
-- per visit. EVENT_IN_PURIFIED_ZONE latches until the player steps off;
|
||||
-- while on the pad the map script also sets BIT_NO_BATTLES (we return
|
||||
-- true from onStep so wild encounters are skipped the same way).
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
local TOWER_5F_PURIFIED = {
|
||||
[10 * 256 + 8] = true, [11 * 256 + 8] = true,
|
||||
[10 * 256 + 9] = true, [11 * 256 + 9] = true,
|
||||
}
|
||||
|
||||
-- HealParty -> GBFadeOutToWhite -> Delay3 -> Delay3 -> GBFadeInFromWhite
|
||||
-- -> TEXT_POKEMONTOWER5F_PURIFIEDZONE (no Music_PkmnHealed).
|
||||
local TOWER_5F_HEAL = {
|
||||
{ "heal_party" },
|
||||
{ "fade", "out", "white" },
|
||||
{ "wait", 3 },
|
||||
{ "wait", 3 },
|
||||
{ "fade", "in", "white" },
|
||||
{ "show_text", "_PokemonTower5FPurifiedZoneText" },
|
||||
}
|
||||
|
||||
M.POKEMON_TOWER_5F = {
|
||||
onStep = function(game, ow, x, y)
|
||||
if not TOWER_5F_PURIFIED[x * 256 + y] then
|
||||
game.save.flags.EVENT_IN_PURIFIED_ZONE = nil
|
||||
return false
|
||||
end
|
||||
if game.save.flags.EVENT_IN_PURIFIED_ZONE then
|
||||
return true
|
||||
end
|
||||
if ow.runner and ow.runner:isRunning() then return false end
|
||||
game.save.flags.EVENT_IN_PURIFIED_ZONE = true
|
||||
if ow.runner then
|
||||
ow.runner:run(TOWER_5F_HEAL)
|
||||
elseif ow.queueScript then
|
||||
ow:queueScript(TOWER_5F_HEAL)
|
||||
end
|
||||
return true
|
||||
end,
|
||||
}
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
-- The ghost Marowak (scripts/PokemonTower6F.asm): blocks the stairs at
|
||||
-- (10,16) until defeated.
|
||||
@@ -150,18 +194,20 @@ local WALK_DIRVEC = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right =
|
||||
local WALK_OPP = { up = "down", down = "up", left = "right", right = "left" }
|
||||
local WALK_ORDER = { "up", "down", "left", "right" }
|
||||
|
||||
local function elevatorWalkOut(ow, floor)
|
||||
local m, p = ow.map, ow.player
|
||||
-- .UpdateWarp is run twice, so BOTH car warp entries get the same
|
||||
-- (warp id, map id): point every exit warp at the picked floor's
|
||||
-- elevator-door warp (the reciprocal warp found while building the
|
||||
-- menu). The car map's def is shared generated data, but its own
|
||||
-- warps are only ever read from inside the car, and this rewrite runs
|
||||
-- on every ride before the walk-out fires, so it is self-correcting.
|
||||
for _, w in ipairs(m.def.warps) do
|
||||
-- .UpdateWarp: point EVERY car exit warp at the same floor's elevator
|
||||
-- door (warp id, map id). Shared generated map data, but the car's
|
||||
-- warps are only read from inside the car; rides rewrite them again.
|
||||
local function elevatorSetExit(ow, floor)
|
||||
if not floor then return end
|
||||
for _, w in ipairs(ow.map.def.warps) do
|
||||
w.destMap = floor.map
|
||||
w.destWarp = floor.warpIdx
|
||||
end
|
||||
end
|
||||
|
||||
local function elevatorWalkOut(ow, floor)
|
||||
local m, p = ow.map, ow.player
|
||||
elevatorSetExit(ow, floor)
|
||||
-- leave by the exit tile under the player (they warped in onto one),
|
||||
-- else the nearest
|
||||
local door
|
||||
@@ -199,37 +245,68 @@ local function elevatorWalkOut(ow, floor)
|
||||
end)
|
||||
end
|
||||
|
||||
local function elevatorFloors(elevatorMapId, game)
|
||||
local floors = {}
|
||||
for mapId, def in pairs(game.data.maps) do
|
||||
for i, w in ipairs(def.warps) do
|
||||
if w.destMap == elevatorMapId then
|
||||
-- short floor token pokered actually prints, e.g.
|
||||
-- SILPH_CO_10F -> "10F", ROCKET_HIDEOUT_B2F -> "B2F"
|
||||
local token = mapId:match("_([^_]+)$") or mapId
|
||||
-- warpIdx: this floor's warp back into the elevator IS the
|
||||
-- warp the car's rewritten exit lands on (the reciprocal
|
||||
-- pair), matching wElevatorWarpMaps' (warp id, map id)
|
||||
table.insert(floors,
|
||||
{ map = mapId, x = w.x, y = w.y, token = token, warpIdx = i })
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
-- numeric floor order (SilphCoElevatorFloors' FLOOR_1F..FLOOR_11F),
|
||||
-- not lexicographic -- otherwise 10F/11F sort before 2F..9F
|
||||
table.sort(floors, function(a, b)
|
||||
return (tonumber(a.token:match("%d+")) or 0) <
|
||||
(tonumber(b.token:match("%d+")) or 0)
|
||||
end)
|
||||
return floors
|
||||
end
|
||||
|
||||
local function elevatorSeedExit(ow, floors, fromMapId)
|
||||
-- Seed a walk-out destination before the menu (or key-gate text):
|
||||
-- entry floor when known, else the first listed floor (1F). Choosing
|
||||
-- a floor still rewrites via elevatorWalkOut; B-cancel / no-key leave
|
||||
-- keeps this seed so walking out of the car cannot hit a missing ROM
|
||||
-- placeholder (#123) or the car's static default floor (#90: Rocket
|
||||
-- Hideout defaults to B1F even when entered from B2F/B4F).
|
||||
local exitFloor = floors[1]
|
||||
if fromMapId then
|
||||
for _, f in ipairs(floors) do
|
||||
if f.map == fromMapId then exitFloor = f break end
|
||||
end
|
||||
end
|
||||
elevatorSetExit(ow, exitFloor)
|
||||
return exitFloor
|
||||
end
|
||||
|
||||
local function elevator(elevatorMapId, keyGate, preFrames)
|
||||
return {
|
||||
onEnter = function(game, ow)
|
||||
-- fromMapId: the floor the player just left (setMap passes it), so a
|
||||
-- B-cancel can still walk out onto a real map. Silph's ROM car warps
|
||||
-- default to UNUSED_MAP_ED, which is not in Data.maps -- Warp.resolve
|
||||
-- asserted and hard-crashed (#123).
|
||||
onEnter = function(game, ow, fromMapId)
|
||||
local floors = elevatorFloors(elevatorMapId, game)
|
||||
elevatorSeedExit(ow, floors, fromMapId)
|
||||
-- Rocket Hideout: without LIFT_KEY the panel only prints the need-
|
||||
-- a-key line (scripts/RocketHideoutElevator.asm). Exit warps are
|
||||
-- still seeded above so walking out returns to the entry floor
|
||||
-- instead of the car's ROM default (B1F) — #90 / #105.
|
||||
if keyGate and not game.save.inventory[keyGate.item] then
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game,
|
||||
game.data.text[keyGate.text] or "It appears to\nneed a key."))
|
||||
return
|
||||
end
|
||||
local floors = {}
|
||||
for mapId, def in pairs(game.data.maps) do
|
||||
for i, w in ipairs(def.warps) do
|
||||
if w.destMap == elevatorMapId then
|
||||
-- short floor token pokered actually prints, e.g.
|
||||
-- SILPH_CO_10F -> "10F", ROCKET_HIDEOUT_B2F -> "B2F"
|
||||
local token = mapId:match("_([^_]+)$") or mapId
|
||||
-- warpIdx: this floor's warp back into the elevator IS the
|
||||
-- warp the car's rewritten exit lands on (the reciprocal
|
||||
-- pair), matching wElevatorWarpMaps' (warp id, map id)
|
||||
table.insert(floors,
|
||||
{ map = mapId, x = w.x, y = w.y, token = token, warpIdx = i })
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
-- numeric floor order (SilphCoElevatorFloors' FLOOR_1F..FLOOR_11F),
|
||||
-- not lexicographic -- otherwise 10F/11F sort before 2F..9F
|
||||
table.sort(floors, function(a, b)
|
||||
return (tonumber(a.token:match("%d+")) or 0) <
|
||||
(tonumber(b.token:match("%d+")) or 0)
|
||||
end)
|
||||
local items = {}
|
||||
for _, f in ipairs(floors) do
|
||||
table.insert(items, { label = f.token, value = f })
|
||||
@@ -262,7 +339,8 @@ local function elevator(elevatorMapId, keyGate, preFrames)
|
||||
end,
|
||||
onCancel = function()
|
||||
-- DisplayElevatorFloorMenu: `ret c` on B -- no warp, nothing
|
||||
-- happens, the player just stays in the car
|
||||
-- happens, the player just stays in the car (exit warps were
|
||||
-- already seeded to the entry floor above)
|
||||
end,
|
||||
}))
|
||||
end,
|
||||
@@ -274,6 +352,98 @@ M.CELADON_MART_ELEVATOR = elevator("CELADON_MART_ELEVATOR", nil, 9)
|
||||
M.ROCKET_HIDEOUT_ELEVATOR = elevator("ROCKET_HIDEOUT_ELEVATOR",
|
||||
{ item = "LIFT_KEY", text = "_RocketHideoutElevatorAppearsToNeedKeyText" })
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
-- Rocket Hideout B4F (scripts/RocketHideoutB4F.asm):
|
||||
-- Rocket3's after-battle text_asm drops the LIFT KEY item ball
|
||||
-- (CheckAndSetEvent EVENT_ROCKET_DROPPED_LIFT_KEY / ShowObject
|
||||
-- TOGGLE_ROCKET_HIDEOUT_B4F_ITEM_5). Both start hidden in the map
|
||||
-- objects; without this talk side-effect the key never appears (#90,
|
||||
-- #105).
|
||||
-- Giovanni's post-battle script likewise ShowObject's the Silph Scope
|
||||
-- after the hope-we-meet-again line (TOGGLE_ROCKET_HIDEOUT_B4F_ITEM_4).
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
M.ROCKET_HIDEOUT_B4F = {
|
||||
talk = {
|
||||
TEXT_ROCKETHIDEOUTB4F_ROCKET3 = function(game, ow, npc, done)
|
||||
if not ow:trainerDefeated(npc) then
|
||||
ow:engageTrainer(npc, done)
|
||||
return
|
||||
end
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local t = game.data.text
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._RocketHideoutB4FRocket3AfterBattleText
|
||||
or "Oh no! I dropped\nthe LIFT KEY!",
|
||||
function()
|
||||
-- CheckAndSetEvent EVENT_ROCKET_DROPPED_LIFT_KEY: first talk
|
||||
-- after the win reveals the ball; later talks only reprint.
|
||||
if not game.save.flags.EVENT_ROCKET_DROPPED_LIFT_KEY then
|
||||
game.save.flags.EVENT_ROCKET_DROPPED_LIFT_KEY = true
|
||||
local Commands = require("src.script.Commands")
|
||||
Commands.show_object(
|
||||
{ game = game, save = game.save, overworld = ow },
|
||||
"ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_LIFT_KEY")
|
||||
end
|
||||
done()
|
||||
end))
|
||||
end,
|
||||
|
||||
TEXT_ROCKETHIDEOUTB4F_GIOVANNI = function(game, ow, npc, done)
|
||||
-- Giovanni has no trainer-header row (def_trainers 2); his text_asm
|
||||
-- owns both the engage and the BeatGiovanniScript aftermath.
|
||||
if ow:trainerDefeated(npc)
|
||||
or game.save.flags.EVENT_BEAT_ROCKET_HIDEOUT_GIOVANNI then
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game,
|
||||
game.data.text._RocketHideoutB4FGiovanniHopeWeMeetAgainText
|
||||
or "I hope we meet\nagain...", done))
|
||||
return
|
||||
end
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local t = game.data.text
|
||||
local impressed = t._RocketHideoutB4FGiovanniImpressedYouGotHereText
|
||||
or "So! I must say, I\nam impressed you\ngot here!"
|
||||
local cannotBe = t._RocketHideoutB4FGiovanniWhatCannotBeText
|
||||
or "WHAT!\nThis cannot be!"
|
||||
local hope = t._RocketHideoutB4FGiovanniHopeWeMeetAgainText
|
||||
or "I hope we meet\nagain..."
|
||||
game.stack:push(TextBox.new(game, impressed, function()
|
||||
local battle = BattleState.newTrainer(game, "OPP_GIOVANNI", 1)
|
||||
battle.onFinish = function(result)
|
||||
if result ~= "win" then
|
||||
ow:afterBattle(result, battle)
|
||||
done()
|
||||
return
|
||||
end
|
||||
game.save.defeatedTrainers[npc.id] = true
|
||||
game.save.flags.EVENT_BEAT_ROCKET_HIDEOUT_GIOVANNI = true
|
||||
-- End-battle "WHAT!" then BeatGiovanniScript's hope text,
|
||||
-- fade, HideObject Giovanni, ShowObject Silph Scope.
|
||||
game.stack:push(TextBox.new(game, cannotBe, function()
|
||||
game.stack:push(TextBox.new(game, hope, function()
|
||||
local Transition = require("src.render.Transition")
|
||||
game.stack:push(Transition.new(game, function()
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { game = game, save = game.save, overworld = ow }
|
||||
Commands.hide_object(ctx, "ROCKET_HIDEOUT_B4F",
|
||||
"ROCKETHIDEOUTB4F_GIOVANNI")
|
||||
Commands.show_object(ctx, "ROCKET_HIDEOUT_B4F",
|
||||
"ROCKETHIDEOUTB4F_SILPH_SCOPE")
|
||||
end, function()
|
||||
ow:afterBattle(result, battle)
|
||||
done()
|
||||
end))
|
||||
end))
|
||||
end))
|
||||
end
|
||||
ow:pushBattle(battle)
|
||||
end))
|
||||
end,
|
||||
},
|
||||
}
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
-- Game Corner coins, prizes, and the rocket-poster switch that reveals
|
||||
-- the hideout stairs (scripts/GameCorner.asm, data/events/prizes.asm +
|
||||
@@ -286,14 +456,23 @@ M.GAME_CORNER = {
|
||||
-- EVENT_FOUND_ROCKET_HIDEOUT is unset, $43 after)
|
||||
onEnter = function(game, ow)
|
||||
local poster = game.data.field.gameCornerPoster
|
||||
if not poster then return end
|
||||
local block = game.save.flags[poster.event] and poster.openBlock
|
||||
or poster.closedBlock
|
||||
ow:replaceBlock(poster.x, poster.y, block)
|
||||
if poster then
|
||||
local block = game.save.flags[poster.event] and poster.openBlock
|
||||
or poster.closedBlock
|
||||
ow:replaceBlock(poster.x, poster.y, block)
|
||||
end
|
||||
-- pick this visit's lucky slot machine
|
||||
-- (wLuckySlotHiddenEventIndex, engine/slots/game_corner_slots2.asm)
|
||||
local seats = game.data.field.slotMachines.GAME_CORNER
|
||||
ow.luckySlot = love.math.random(1, #seats)
|
||||
-- #131: pre-#50 saves beat the poster grunt (defeatedTrainers) but
|
||||
-- never hid him; clear the tile if he is already marked defeated
|
||||
local rocketId = "GAME_CORNER_obj_11"
|
||||
if game.save.defeatedTrainers and game.save.defeatedTrainers[rocketId] then
|
||||
local Commands = require("src.script.Commands")
|
||||
Commands.hide_object({ game = game, save = game.save, overworld = ow },
|
||||
"GAME_CORNER", "GAMECORNER_ROCKET")
|
||||
end
|
||||
end,
|
||||
talk = {
|
||||
-- the poster bg event: pressing A reveals the hidden switch
|
||||
@@ -322,7 +501,16 @@ M.GAME_CORNER = {
|
||||
-- losing he warns the BOSS and leaves the floor for good, freeing
|
||||
-- the tile in front of the hideout switch
|
||||
TEXT_GAMECORNER_ROCKET = function(game, ow, npc, done)
|
||||
local Commands = require("src.script.Commands")
|
||||
local function hideRocket()
|
||||
Commands.hide_object({ game = game, save = game.save,
|
||||
overworld = ow },
|
||||
"GAME_CORNER", "GAMECORNER_ROCKET")
|
||||
end
|
||||
-- already beaten: hide anyway so pre-#50 saves that only have
|
||||
-- defeatedTrainers (no objectToggles hide) clear the poster tile
|
||||
if ow:trainerDefeated(npc) then
|
||||
hideRocket()
|
||||
done()
|
||||
return
|
||||
end
|
||||
@@ -336,10 +524,7 @@ M.GAME_CORNER = {
|
||||
game.data.text._GameCornerRocketAfterBattleText
|
||||
or "Our hideout might\nbe discovered! I\nbetter tell BOSS!",
|
||||
function()
|
||||
local Commands = require("src.script.Commands")
|
||||
Commands.hide_object({ game = game, save = game.save,
|
||||
overworld = ow },
|
||||
"GAME_CORNER", "GAMECORNER_ROCKET")
|
||||
hideRocket()
|
||||
done()
|
||||
end))
|
||||
end)
|
||||
@@ -390,24 +575,42 @@ M.GAME_CORNER = {
|
||||
},
|
||||
}
|
||||
|
||||
-- Red-version prize lists (data/events/prizes.asm, prize_mon_levels.asm)
|
||||
local PRIZES = {
|
||||
-- Game Corner prize lists (data/events/prizes.asm, prize_mon_levels.asm).
|
||||
-- The six mon prizes differ between Red and Blue; the three TM prizes are
|
||||
-- identical, so they are shared and appended to each version's mon list.
|
||||
local PRIZE_TMS = {
|
||||
{ kind = "item", item = "TM_DRAGON_RAGE", cost = 3300 },
|
||||
{ kind = "item", item = "TM_HYPER_BEAM", cost = 5500 },
|
||||
{ kind = "item", item = "TM_SUBSTITUTE", cost = 7700 },
|
||||
}
|
||||
local RED_PRIZES = {
|
||||
{ kind = "mon", species = "ABRA", level = 9, cost = 180 },
|
||||
{ kind = "mon", species = "CLEFAIRY", level = 8, cost = 500 },
|
||||
{ kind = "mon", species = "NIDORINA", level = 17, cost = 1200 },
|
||||
{ kind = "mon", species = "DRATINI", level = 18, cost = 2800 },
|
||||
{ kind = "mon", species = "SCYTHER", level = 25, cost = 5500 },
|
||||
{ kind = "mon", species = "PORYGON", level = 26, cost = 9999 },
|
||||
{ kind = "item", item = "TM_DRAGON_RAGE", cost = 3300 },
|
||||
{ kind = "item", item = "TM_HYPER_BEAM", cost = 5500 },
|
||||
{ kind = "item", item = "TM_SUBSTITUTE", cost = 7700 },
|
||||
PRIZE_TMS[1], PRIZE_TMS[2], PRIZE_TMS[3],
|
||||
}
|
||||
local BLUE_PRIZES = {
|
||||
{ kind = "mon", species = "ABRA", level = 6, cost = 120 },
|
||||
{ kind = "mon", species = "CLEFAIRY", level = 12, cost = 750 },
|
||||
{ kind = "mon", species = "NIDORINO", level = 17, cost = 1200 },
|
||||
{ kind = "mon", species = "PINSIR", level = 20, cost = 2500 },
|
||||
{ kind = "mon", species = "DRATINI", level = 24, cost = 4600 },
|
||||
{ kind = "mon", species = "PORYGON", level = 18, cost = 6500 },
|
||||
PRIZE_TMS[1], PRIZE_TMS[2], PRIZE_TMS[3],
|
||||
}
|
||||
|
||||
local function activePrizes()
|
||||
return require("src.core.GameVersion").isBlue() and BLUE_PRIZES or RED_PRIZES
|
||||
end
|
||||
|
||||
local function prizeCounter(game, ow, npc, done)
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
local Commands = require("src.script.Commands")
|
||||
local items = {}
|
||||
for _, p in ipairs(PRIZES) do
|
||||
for _, p in ipairs(activePrizes()) do
|
||||
local label
|
||||
if p.kind == "mon" then
|
||||
label = ("%s L%d"):format(game.data.pokemon[p.species].name, p.level)
|
||||
@@ -463,8 +666,9 @@ local DOCK_SHIP_BLOCKS = {
|
||||
|
||||
M.VERMILION_DOCK = {
|
||||
onEnter = function(game, ow)
|
||||
local Flags = require("src.script.Flags")
|
||||
local f = game.save.flags
|
||||
if f.EVENT_SS_ANNE_LEFT then
|
||||
if Flags.get(game.save, "EVENT_SS_ANNE_LEFT") then
|
||||
-- the ship is long gone: erase her right away, and anyone who
|
||||
-- still lands here is sent back out past the guard
|
||||
for _, b in ipairs(DOCK_SHIP_BLOCKS) do
|
||||
@@ -480,13 +684,15 @@ M.VERMILION_DOCK = {
|
||||
elseif f.EVENT_GOT_HM01 and ow.player.cellY == 2 then
|
||||
-- VermilionDockSSAnneLeavesScript: only stepping OFF the ship
|
||||
-- triggers the departure (wDestinationWarpID == 1 in pokered) --
|
||||
-- the horn blows, smoke puffs drift off the funnel, the ship is
|
||||
-- erased to open water, and the player is walked off the dock
|
||||
-- into the city past the guard (VermilionCity's
|
||||
-- Music_Surfing plays for the sail-away cutscene, smoke puffs
|
||||
-- drift off the funnel, the horn blows, the ship is erased to
|
||||
-- open water, and the player is walked off the dock into the
|
||||
-- city past the guard (VermilionCity's
|
||||
-- SCRIPT_VERMILIONCITY_PLAYER_EXIT_SHIP walk)
|
||||
f.EVENT_SS_ANNE_LEFT = true
|
||||
require("src.core.Music").stop()
|
||||
require("src.core.Sound").play(game.data, "SS_Anne_Horn")
|
||||
Flags.set(game.save, "EVENT_SS_ANNE_LEFT")
|
||||
local Music = require("src.core.Music")
|
||||
Music.stop()
|
||||
Music.play(game.data, "Music_Surfing")
|
||||
local function puff(n, cx)
|
||||
if n <= 0 then return end
|
||||
ow:startDustAnim(cx, 1, function() puff(n - 1, cx + 2) end)
|
||||
@@ -494,11 +700,15 @@ M.VERMILION_DOCK = {
|
||||
puff(3, 15)
|
||||
local rows = {}
|
||||
rows[#rows + 1] = { "wait", 100 }
|
||||
rows[#rows + 1] = { "play_sound", "SS_Anne_Horn" }
|
||||
for _, b in ipairs(DOCK_SHIP_BLOCKS) do
|
||||
rows[#rows + 1] = { "replace_block", b.bx, b.by, b.water }
|
||||
end
|
||||
rows[#rows + 1] = { "wait", 30 }
|
||||
rows[#rows + 1] = { "move_player", "up", 2 }
|
||||
-- keep Music_Surfing across the city warp (pokered stays on it
|
||||
-- through the exit-ship walk)
|
||||
rows[#rows + 1] = { "play_music", "Music_Surfing", { keep = true } }
|
||||
rows[#rows + 1] = { "warp", "VERMILION_CITY", 18, 31, "up" }
|
||||
rows[#rows + 1] = { "move_player", "up", 2 }
|
||||
ow:queueScript(rows)
|
||||
|
||||
+68
-18
@@ -645,12 +645,44 @@ local function e4ExitSeal(flag, closedBlock, openBlock, dontRunText, autoFlag)
|
||||
}
|
||||
end
|
||||
|
||||
-- Lorelei/Bruno/Agatha EndBattleScript (pokered): EndTrainerBattle then
|
||||
-- DisplayTextID -> TalkToTrainer, which prints the AfterBattle text on a
|
||||
-- win. engageTrainer only shows the won line, so wrap talk like Lance /
|
||||
-- Game Corner Rocket and push header.after immediately after a win.
|
||||
local function e4LeaderTalk(afterLabel)
|
||||
return function(game, ow, npc, done)
|
||||
done = done or function() end
|
||||
if ow:trainerDefeated(npc) then
|
||||
local after = text(game)[afterLabel]
|
||||
if after then push(game, after, done) else done() end
|
||||
return
|
||||
end
|
||||
ow:engageTrainer(npc, function()
|
||||
if not ow:trainerDefeated(npc) then
|
||||
done()
|
||||
return
|
||||
end
|
||||
local after = text(game)[afterLabel]
|
||||
if after then push(game, after, done) else done() end
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
M.LORELEIS_ROOM = e4ExitSeal("EVENT_BEAT_LORELEIS_ROOM_TRAINER_0", 0x24, 0x05,
|
||||
"_LoreleisRoomLoreleiDontRunAwayText", "EVENT_AUTOWALKED_INTO_LORELEIS_ROOM")
|
||||
M.LORELEIS_ROOM.talk = {
|
||||
TEXT_LORELEISROOM_LORELEI = e4LeaderTalk("_LoreleisRoomLoreleiAfterBattleText"),
|
||||
}
|
||||
M.BRUNOS_ROOM = e4ExitSeal("EVENT_BEAT_BRUNOS_ROOM_TRAINER_0", 0x24, 0x05,
|
||||
"_BrunosRoomBrunoDontRunAwayText", "EVENT_AUTOWALKED_INTO_BRUNOS_ROOM")
|
||||
M.BRUNOS_ROOM.talk = {
|
||||
TEXT_BRUNOSROOM_BRUNO = e4LeaderTalk("_BrunoAfterBattleText"),
|
||||
}
|
||||
M.AGATHAS_ROOM = e4ExitSeal("EVENT_BEAT_AGATHAS_ROOM_TRAINER_0", 0x3b, 0x0e,
|
||||
"_AgathasRoomAgathaDontRunAwayText", "EVENT_AUTOWALKED_INTO_AGATHAS_ROOM")
|
||||
M.AGATHAS_ROOM.talk = {
|
||||
TEXT_AGATHASROOM_AGATHA = e4LeaderTalk("_AgathaAfterBattleText"),
|
||||
}
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
-- Lance's room (scripts/LancesRoom.asm). Unlike the other three E4
|
||||
@@ -672,13 +704,21 @@ M.AGATHAS_ROOM = e4ExitSeal("EVENT_BEAT_AGATHAS_ROOM_TRAINER_0", 0x3b, 0x0e,
|
||||
-- EVENT_LANCES_ROOM_LOCK_DOOR: first crossing seals
|
||||
-- the door behind the player with SFX_GO_INSIDE
|
||||
-- (24,16) the entrance staircase -> WalkToLance: an auto-walk
|
||||
-- (up 12, left 12, down 7, left 6) landing on (6,11).
|
||||
-- It marches straight across the room's water decor:
|
||||
-- pokered's CollisionCheckOnLand skips collision
|
||||
-- entirely while simulated joypad states run, and our
|
||||
-- scriptMove is collision-free the same way.
|
||||
-- landing on (6,11). Vanilla WalkToLance_RLEList is
|
||||
-- up 12 / left 12 / down 7 / left 6 and skips collision
|
||||
-- through void tiles; our camera follows that literally
|
||||
-- and briefly shows the empty upper chamber (reads as
|
||||
-- "Gary's room"). We keep the same landing cell but
|
||||
-- route along the open-door floor corridor instead.
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
-- Floor corridor (24,16) -> (6,11) with EVENT_LANCES_ROOM_LOCK_DOOR
|
||||
-- unset (entrance blocks $31/$32). Exposed for parity tests.
|
||||
local LANCE_WALK_IN = {
|
||||
{ "down", 2 }, { "left", 6 }, { "down", 5 }, { "left", 10 },
|
||||
{ "up", 9 }, { "left", 2 }, { "up", 3 },
|
||||
}
|
||||
|
||||
local function lanceEntranceBlocks(game, ow)
|
||||
local locked = game.save.flags.EVENT_LANCES_ROOM_LOCK_DOOR
|
||||
ow:replaceBlock(2, 6, locked and 0x72 or 0x31)
|
||||
@@ -693,21 +733,24 @@ local function lanceLockDoor(game, ow)
|
||||
end
|
||||
|
||||
local function lanceWalkIn(game, ow)
|
||||
ow:scriptMove(ow.player, "up", 12, function()
|
||||
ow:scriptMove(ow.player, "left", 12, function()
|
||||
ow:scriptMove(ow.player, "down", 7, function()
|
||||
ow:scriptMove(ow.player, "left", 6, function()
|
||||
-- the walk lands on (6,11); vanilla's per-frame coord poll
|
||||
-- then locks the door at once. scriptMove landings do not
|
||||
-- fire onStep, so lock here.
|
||||
lanceLockDoor(game, ow)
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
local i = 0
|
||||
local function step()
|
||||
i = i + 1
|
||||
local seg = LANCE_WALK_IN[i]
|
||||
if not seg then
|
||||
-- lands on (6,11); vanilla's per-frame coord poll then locks the
|
||||
-- door at once. scriptMove landings do not fire onStep, so lock
|
||||
-- here.
|
||||
lanceLockDoor(game, ow)
|
||||
return
|
||||
end
|
||||
ow:scriptMove(ow.player, seg[1], seg[2], step)
|
||||
end
|
||||
step()
|
||||
end
|
||||
|
||||
M.LANCES_ROOM = {
|
||||
walkInRoute = LANCE_WALK_IN,
|
||||
onEnter = function(game, ow)
|
||||
lanceEntranceBlocks(game, ow)
|
||||
-- the warp arrival lands ON the staircase trigger, and onStep only
|
||||
@@ -727,7 +770,14 @@ M.LANCES_ROOM = {
|
||||
end
|
||||
if not lance or ow:trainerDefeated(lance) then return false end
|
||||
lance:facePlayer(ow.player)
|
||||
ow:engageTrainer(lance, function() end)
|
||||
-- LancesRoomLanceEndBattleScript DisplayTextID -> TalkToTrainer
|
||||
-- after-battle text (rival became champion first). engageTrainer
|
||||
-- only shows the won line, so push the after text on a win.
|
||||
ow:engageTrainer(lance, function()
|
||||
if not ow:trainerDefeated(lance) then return end
|
||||
local after = text(game)._LancesRoomLanceAfterBattleText
|
||||
if after then push(game, after) end
|
||||
end)
|
||||
return true
|
||||
end
|
||||
if (x == 5 or x == 6) and y == 11 then
|
||||
|
||||
+167
-25
@@ -262,38 +262,180 @@ M.CINNABAR_ISLAND = {
|
||||
-- Pewter's youngster stops you leaving east before Brock is beaten and
|
||||
-- escorts you to the gym (scripts/PewterCity.asm
|
||||
-- PewterCityCheckPlayerLeavingEastScript /
|
||||
-- PewterCityYoungsterShowsPlayerGymScript, engine/events/pewter_guys.asm
|
||||
-- PewterGymGuyCoords): the "follow me" lines, then the player is walked
|
||||
-- west along the road to the front of the PEWTER_GYM door at (16,17).
|
||||
-- PewterCityYoungsterShowsPlayerGymScript, engine/overworld/auto_movement.asm
|
||||
-- PewterGymGuyMovementScriptPointerTable, engine/events/pewter_guys.asm
|
||||
-- PewterGymGuyCoords). Same lockstep style as Oak's lab escort: the
|
||||
-- youngster walks RLEList_PewterGymGuy while the player plays the
|
||||
-- reverse of RLEList_PewterGymPlayer with a PewterGuys positioning
|
||||
-- preamble. Ends at (11,18) / (12,18) by the gym, not phasing through
|
||||
-- the building.
|
||||
local pewterEscort = {}
|
||||
|
||||
-- RLEList_PewterGymGuy (NPC directions play forward)
|
||||
pewterEscort.guySteps = {
|
||||
"down", "down",
|
||||
"left", "left", "left", "left", "left", "left", "left", "left",
|
||||
"left", "left", "left", "left", "left", "left", "left",
|
||||
"up", "up", "up", "up", "up",
|
||||
"left", "left", "left", "left", "left", "left", "left", "left",
|
||||
"left", "left", "left",
|
||||
"down", "down", "down", "down", "down",
|
||||
"right", "right", "right",
|
||||
}
|
||||
|
||||
-- Walk home: reverse of guySteps with opposite facings (gym → spawn).
|
||||
do
|
||||
local opp = { up = "down", down = "up", left = "right", right = "left" }
|
||||
local ret = {}
|
||||
for i = #pewterEscort.guySteps, 1, -1 do
|
||||
ret[#ret + 1] = opp[pewterEscort.guySteps[i]]
|
||||
end
|
||||
pewterEscort.guyReturnSteps = ret
|
||||
end
|
||||
|
||||
-- RLEList_PewterGymPlayer before reverse / PewterGuys (NO_INPUT, RIGHT×2,
|
||||
-- DOWN×5, LEFT×11, UP×5, LEFT×15)
|
||||
pewterEscort.playerRle = {
|
||||
"NO",
|
||||
"right", "right",
|
||||
"down", "down", "down", "down", "down",
|
||||
"left", "left", "left", "left", "left", "left", "left", "left",
|
||||
"left", "left", "left",
|
||||
"up", "up", "up", "up", "up",
|
||||
"left", "left", "left", "left", "left", "left", "left", "left",
|
||||
"left", "left", "left", "left", "left", "left", "left",
|
||||
}
|
||||
|
||||
-- PewterGymGuyCoords: (x, y) -> positioning moves written after the RLE
|
||||
-- (played in reverse; $00 pauses are one overworld frame ≈ 1/8 tile for
|
||||
-- the NPC, so eight of them ≈ one guy head-start step)
|
||||
pewterEscort.preambles = {
|
||||
["34,16"] = { "left", "down", "down", "right" },
|
||||
["35,17"] = { "left", "down", "right", "left" },
|
||||
["37,18"] = { "left", "left", "left",
|
||||
"NO", "NO", "NO", "NO", "NO", "NO", "NO", "NO" },
|
||||
["37,19"] = { "left", "left", "up", "left" },
|
||||
["36,17"] = { "left", "down", "left",
|
||||
"NO", "NO", "NO", "NO", "NO", "NO", "NO", "NO" },
|
||||
}
|
||||
|
||||
-- Realized player path for a trigger tile: PewterGuys overwrites the
|
||||
-- last RLE byte and appends the preamble, then simulated joypad plays
|
||||
-- high→low (reverse). Leading NO×8 collapses to guyHeadStart=1; the
|
||||
-- trailing end-of-list NO is dropped (one-frame pause).
|
||||
function pewterEscort.playerPlan(x, y)
|
||||
local pre = pewterEscort.preambles[x .. "," .. y]
|
||||
if not pre then return nil end
|
||||
local buf = {}
|
||||
for i, d in ipairs(pewterEscort.playerRle) do buf[i] = d end
|
||||
buf[#buf] = pre[1]
|
||||
for i = 2, #pre do buf[#buf + 1] = pre[i] end
|
||||
local path = {}
|
||||
for i = #buf, 1, -1 do path[#path + 1] = buf[i] end
|
||||
local head = 0
|
||||
while path[head + 1] == "NO" do head = head + 1 end
|
||||
local tail = #path
|
||||
while tail > head and path[tail] == "NO" do tail = tail - 1 end
|
||||
local steps = {}
|
||||
for i = head + 1, tail do steps[#steps + 1] = path[i] end
|
||||
return { steps = steps, guyHeadStart = math.floor(head / 8) }
|
||||
end
|
||||
|
||||
local function pewterGymEscort(game, ow)
|
||||
if ow.runner:isRunning() or #ow.scriptMoves > 0 then return end
|
||||
local x, y = ow.player.cellX, ow.player.cellY
|
||||
local plan = pewterEscort.playerPlan(x, y)
|
||||
local Music = require("src.core.Music")
|
||||
local t = text(game)
|
||||
local follow = t._PewterCityYoungsterYoureATrainerFollowMeText
|
||||
or "You're a trainer\nright? BROCK's\nlooking for new\nchallengers!\nFollow me!"
|
||||
-- PewterGuys only has entries for five tiles; an unmatched talk tile
|
||||
-- (e.g. (36,16) east of him) just gets the follow-me line, same as a
|
||||
-- failed coords lookup would refuse to arm the walk.
|
||||
if not plan then
|
||||
push(game, follow)
|
||||
return
|
||||
end
|
||||
local guy = ow:npcByIndex(5) -- PEWTERCITY_YOUNGSTER
|
||||
local guySteps = pewterEscort.guySteps
|
||||
local head = plan.guyHeadStart
|
||||
|
||||
-- After the walk: face the player, restore map music, "Go take on
|
||||
-- BROCK", then retrace RLEList_PewterGymGuy back to his spawn (35,16).
|
||||
-- (pokered teleports him via MovementData_PewterGymGuyExit; we walk
|
||||
-- the same route home instead. Brock victory still HideObject's him.)
|
||||
local function walkHome()
|
||||
if not guy then return end
|
||||
local ret = pewterEscort.guyReturnSteps
|
||||
local i = 0
|
||||
local function tick()
|
||||
i = i + 1
|
||||
if not ret[i] then
|
||||
guy.facing = "down"
|
||||
return
|
||||
end
|
||||
ow:scriptMove(guy, ret[i], 1, tick)
|
||||
end
|
||||
tick()
|
||||
end
|
||||
|
||||
local function afterWalk()
|
||||
if guy then guy.facing = "left" end
|
||||
Music.playMap(game.data, "PEWTER_CITY")
|
||||
push(game, t._PewterCityYoungsterGoTakeOnBrockText
|
||||
or "Go take on BROCK\nat the GYM first!", walkHome)
|
||||
end
|
||||
|
||||
local function lockstep()
|
||||
local i = 0
|
||||
local function tick()
|
||||
i = i + 1
|
||||
local ps = plan.steps[i]
|
||||
if not ps then
|
||||
afterWalk()
|
||||
return
|
||||
end
|
||||
local gs = guySteps[head + i]
|
||||
if guy and gs then ow:scriptMove(guy, gs, 1) end
|
||||
ow:scriptMove(ow.player, ps, 1, tick)
|
||||
end
|
||||
tick()
|
||||
end
|
||||
|
||||
local function beginWalk()
|
||||
Music.play(game.data, "Music_MuseumGuy")
|
||||
if guy and head > 0 then
|
||||
local h = 0
|
||||
local function headTick()
|
||||
h = h + 1
|
||||
if h > head then lockstep(); return end
|
||||
ow:scriptMove(guy, guySteps[h], 1, headTick)
|
||||
end
|
||||
headTick()
|
||||
else
|
||||
lockstep()
|
||||
end
|
||||
end
|
||||
|
||||
push(game, follow, beginWalk)
|
||||
end
|
||||
|
||||
M.PEWTER_CITY = {
|
||||
escort = pewterEscort,
|
||||
-- PewterCityYoungsterText: talking also arms the gym escort script
|
||||
talk = {
|
||||
TEXT_PEWTERCITY_YOUNGSTER = function(game, ow, npc, done)
|
||||
pewterGymEscort(game, ow)
|
||||
if done then done() end
|
||||
end,
|
||||
},
|
||||
onStep = function(game, ow, x, y)
|
||||
if game.save.flags.EVENT_BEAT_BROCK then return false end
|
||||
if ow.runner:isRunning() or #ow.scriptMoves > 0 then return false end
|
||||
if not inCoords({ { 35, 17 }, { 36, 17 }, { 37, 18 }, { 37, 19 } }, x, y) then
|
||||
return false
|
||||
end
|
||||
local t = text(game)
|
||||
-- walk up onto the road row (y=17), west to one tile east of the gym
|
||||
-- door, then drop below the door and turn to face it
|
||||
local steps = {}
|
||||
for _ = 1, y - 17 do steps[#steps + 1] = "up" end
|
||||
for _ = 1, x - 17 do steps[#steps + 1] = "left" end
|
||||
steps[#steps + 1] = "down"
|
||||
steps[#steps + 1] = "left"
|
||||
local function walk(i)
|
||||
if not steps[i] then
|
||||
ow.player.facing = "up"
|
||||
return
|
||||
end
|
||||
ow:scriptMove(ow.player, steps[i], 1, function() walk(i + 1) end)
|
||||
end
|
||||
push(game, t._PewterCityYoungsterYoureATrainerFollowMeText
|
||||
or "Hey! You're a\ntrainer, right?", function()
|
||||
push(game, t._PewterCityYoungsterGoTakeOnBrockText
|
||||
or "Go take on BROCK\nat the GYM first!", function()
|
||||
walk(1)
|
||||
end)
|
||||
end)
|
||||
pewterGymEscort(game, ow)
|
||||
return true
|
||||
end,
|
||||
}
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
-- "OPP_CLASS#partyIndex" (the object_event trainer args). Hand-ported
|
||||
-- from the leaders'/bosses' text_asm victory scripts:
|
||||
-- gym badges: scripts/PewterGym.asm ... ViridianGym.asm
|
||||
-- Rocket Hideout Giovanni: his Silph Scope is an item ball next to him
|
||||
-- (data/maps/objects/RocketHideoutB4F.asm), so no reward entry needed.
|
||||
-- Rocket Hideout Giovanni: Silph Scope is a hidden item ball revealed by
|
||||
-- ShowObject in RocketHideoutB4FBeatGiovanniScript (ported as the
|
||||
-- TEXT_ROCKETHIDEOUTB4F_GIOVANNI talk handler in story3.lua).
|
||||
-- The TM each gym leader hands out afterwards is also ported.
|
||||
--
|
||||
-- `deactivate` lists the EVENT_BEAT_* flags each gym's victory script
|
||||
@@ -21,9 +22,16 @@ local function range(prefix, first, last)
|
||||
end
|
||||
|
||||
return {
|
||||
-- PewterGym.asm .gymVictory also HideObject TOGGLE_GYM_GUY
|
||||
-- (PEWTERCITY_YOUNGSTER) and TOGGLE_ROUTE_22_RIVAL_1 so the east-exit
|
||||
-- escort NPC and the first Route 22 rival stay gone after the badge.
|
||||
["OPP_BROCK#1"] = { badge = "BOULDERBADGE", flag = "EVENT_BEAT_BROCK",
|
||||
item = "TM_BIDE",
|
||||
deactivate = { "EVENT_BEAT_PEWTER_GYM_TRAINER_0" } },
|
||||
deactivate = { "EVENT_BEAT_PEWTER_GYM_TRAINER_0" },
|
||||
hide = {
|
||||
{ "PEWTER_CITY", "PEWTERCITY_YOUNGSTER" },
|
||||
{ "ROUTE_22", "ROUTE22_RIVAL1" },
|
||||
} },
|
||||
["OPP_MISTY#1"] = { badge = "CASCADEBADGE", flag = "EVENT_BEAT_MISTY",
|
||||
item = "TM_BUBBLEBEAM",
|
||||
deactivate = range("EVENT_BEAT_CERULEAN_GYM_TRAINER_", 0, 1) },
|
||||
|
||||
+66
-22
@@ -8,27 +8,42 @@ behavior is in docs/behavior-porting-notes.md.
|
||||
|
||||
## Survey zoom
|
||||
|
||||
The mouse wheel (or `-`/`=`) zooms the overworld between 1 pixel per world
|
||||
pixel (full survey) and 2× the window fit scale (close-up), in crisp
|
||||
integer steps. This has no Game Boy equivalent:
|
||||
The mouse wheel (or `-`/`=`), the Options **ZOOM** row, or hotkey `4`
|
||||
zooms the overworld between 1 pixel per world pixel (full survey) and 2×
|
||||
the window fit scale (close-up), in crisp integer steps. This has no Game
|
||||
Boy equivalent:
|
||||
|
||||
- Connected maps render their full bodies, and their NPCs appear as
|
||||
visual-only "ghosts", they wander but have no sight lines, triggers,
|
||||
dialogue, or collision until the map is actually entered.
|
||||
- Menus, text boxes, and battles draw at normal scale on top of the
|
||||
zoomed world. Zoom input is ignored while a script, menu, or battle is
|
||||
active; the zoom level persists across warps and is never saved.
|
||||
- Beyond the border ring the border block repeats indefinitely (interiors
|
||||
stay black, seaside towns stay water, except OVERWORLD-tileset maps,
|
||||
whose beyond-edge space fills with the solid tree wall instead of the
|
||||
per-map border block), and each visible map area is colorized with its
|
||||
own SGB palette (the original recolored the whole screen per map).
|
||||
active; the zoom offset is persisted as `save.options.zoom` (default
|
||||
`0` = FIT) and survives New Game via `options.lua`.
|
||||
- Hotkey `4` ticks through every integer zoom level (survey → FIT →
|
||||
close-up → wrap). The Options row shows `FIT` / `OUTn` / `INn`.
|
||||
- Beyond the border ring the void fill repeats indefinitely (see VOID
|
||||
FILL below); interiors keep their own border block. Each visible map
|
||||
area is colorized with its own SGB palette (the original recolored the
|
||||
whole screen per map).
|
||||
- Neighbor maps load two connection hops out so corner-adjacent maps
|
||||
don't pop in and out, and ghost NPCs share instances with the real ones
|
||||
so their wander positions persist across seamless connection crossings
|
||||
(a warp or fresh map entry still respawns everything at its script
|
||||
position, like the original's per-entry sprite init).
|
||||
|
||||
## VOID FILL
|
||||
|
||||
The Options **VOID FILL** row picks what paints the infinite beyond-edge
|
||||
space on OVERWORLD-tileset maps during survey zoom:
|
||||
|
||||
- **TREES** (default): solid tree wall block `$0F`.
|
||||
- **WATER**: animated water tile `$14` (same hshift cycle as on-map water).
|
||||
- **BLACK**: solid black.
|
||||
|
||||
Other tilesets are unchanged (house/cave borders stay as authored).
|
||||
Persisted as `save.options.voidFill`.
|
||||
|
||||
## Tilt mode
|
||||
|
||||
The `3` key (and the Options menu TILT row) cycles a visual-only perspective
|
||||
@@ -42,8 +57,10 @@ Game Boy equivalent:
|
||||
rows above the player recede and rows below come toward the viewer. Only
|
||||
things that actually *stand* on the ground draw as upright billboards,
|
||||
unscaled and pixel-identical to flat mode: the player, NPCs, item balls,
|
||||
and the screen-anchored FX attached to them (heal machine glow, emote
|
||||
bubbles, the fishing rod, the FLY bird). An earlier revision tried
|
||||
and the standing FX attached to them (emote bubbles, the fishing rod,
|
||||
the FLY bird). The Poké Center heal-machine overlay stays on the ground
|
||||
plane with the machine tiles (it is OAM glued to a BG graphic, not a
|
||||
standing sprite). An earlier revision tried
|
||||
billboarding buildings/trees/signs too (cutting them out of the ground
|
||||
per hand-curated per-tileset tables); that chased an endless tail of
|
||||
special cases, dense tree canopy, fences fused into grass, building
|
||||
@@ -68,19 +85,31 @@ Game Boy equivalent:
|
||||
|
||||
## Colors mode
|
||||
|
||||
The `2` key (and the Options menu COLORS row) cycles the global shade-remap
|
||||
display mode through **GBC → OG → OG INV → GBC INV → CLASSIC → GBC**:
|
||||
The `2` key (and the Options menu COLORS row) cycles the display mode
|
||||
through **OG RED → SGB → RED++ → OG → OG INV → SGB INV → CLASSIC → OG RED**.
|
||||
The first three are the real colorizations; the rest are DMG-shade novelties:
|
||||
|
||||
- **GBC** (default): current SGB / GBC zone palettes.
|
||||
- **OG RED**: the Game Boy Color boot-ROM look for Pokemon Red -- one global
|
||||
red BG palette + one green OBJ palette, every map, no per-map variation
|
||||
(Pokemon Red has no CGB code, so on a GBC the boot ROM colors it globally).
|
||||
The player/NPCs stay green over the red terrain via the OBP bake +
|
||||
post-zone redraw (`PaletteFX.GBC_BG` / `GBC_OBJ`).
|
||||
- **SGB** (default): the per-map Super Game Boy region palettes
|
||||
(`data/sgb/sgb_palettes.asm`). Sprites tint with the region palette, as on
|
||||
real SGB. (This is the mode formerly mislabeled "GBC".)
|
||||
- **RED++**: pokered-gbc SuperPalettes -- real per-tile GBC coloring plus
|
||||
per-species mon colors (`data/palettes_gbc.lua`).
|
||||
- **OG**: force the four DMG grays (colorization off).
|
||||
- **OG INV**: inverted DMG grays.
|
||||
- **GBC INV**: each SGB zone palette with shade order reversed.
|
||||
- **SGB INV**: each SGB zone palette with shade order reversed.
|
||||
- **CLASSIC**: original Game Boy pea-soup greens
|
||||
(`#9BBC0F` / `#8BAC0F` / `#306230` / `#0F380F`).
|
||||
|
||||
The transform is applied centrally in `PaletteFX.sendColors`, so it covers
|
||||
overworld, menus, battles, and tilt upright billboards. Persisted as
|
||||
`save.options.colors`.
|
||||
The shade-remap transform is applied centrally in `PaletteFX.sendColors`, so
|
||||
it covers overworld, menus, battles, and tilt upright billboards. OG RED's
|
||||
global BG palette is supplied by `OverworldState:overworldBgColors` (per-map
|
||||
override in the overworld pass). Persisted as `save.options.colors`; the
|
||||
`gbc` / `gbc_inv` save ids are kept for back-compat under the new labels.
|
||||
|
||||
## GBC FX
|
||||
|
||||
@@ -125,14 +154,29 @@ Nidorino-vs-Gengar attract scene) mirror the original.
|
||||
Options persist in a standalone `options.lua` (separate from the game
|
||||
progress `save.lua`), so audio/display/battle preferences survive New Game
|
||||
and aren't wiped when a save slot is cleared. Changing a row in the Options
|
||||
menu or cycling hotkeys `2`/`3`/`5` writes immediately; an in-game save also
|
||||
menu or cycling hotkeys `2`/`3`/`4`/`5` writes immediately; an in-game save also
|
||||
flushes the live options. Old saves that still embed an `options` table are
|
||||
migrated once into `options.lua` on load.
|
||||
|
||||
- Music / SFX volume
|
||||
- Music Filter
|
||||
- OG GLITCHES on / off (Gen 1 quirks vs. modern-clean battle rules)
|
||||
- COLORS (GBC / RED++ / OG / OG INV / GBC INV / CLASSIC), also hotkey `2`
|
||||
(RED++ uses pokered-gbc SuperPalettes + per-species mon colors)
|
||||
- COLORS (OG RED / SGB / RED++ / OG / OG INV / SGB INV / CLASSIC), also
|
||||
hotkey `2` (OG RED = GBC boot-ROM look; RED++ uses pokered-gbc
|
||||
SuperPalettes + per-species mon colors)
|
||||
- TILT (OFF / 15 / 35 / 50), also hotkey `3` while free-roaming
|
||||
- GBC FX (OFF / 1 / 2 / 3 / 4), also hotkey `5`
|
||||
- ZOOM (FIT / OUTn / INn), also hotkey `4` while free-roaming; wheel and
|
||||
`-`/`=` step one level and save
|
||||
- VOID FILL (TREES / WATER / BLACK) for OVERWORLD beyond-edge space
|
||||
- GBC FX (OFF / 1 / 2 / 3 / 4), also hotkey `5`
|
||||
- MAX FPS (30 / 40 / 50 / 60 / 75 / 90 / 100 / 120 / 144 / 160, default 60),
|
||||
a hard render frame-rate cap (`save.options.fpsCap`).
|
||||
|
||||
## Battle transition cascade + white battle letterbox
|
||||
|
||||
Into-battle wipes still run the original eight styles inside the classic
|
||||
160×144 letterbox. On wide/tall windows (survey zoom), matching black 8×8
|
||||
blocks cascade outward from that square into the surrounding world so the
|
||||
void outside the OG wipe fills in lockstep. Once the battle state is up,
|
||||
letterbox voids around the battle canvas fill **white** instead of black
|
||||
so the whole window reads as one continuous battle screen.
|
||||
@@ -26,7 +26,19 @@ local function scriptedIterations()
|
||||
return math.max(1, math.floor(require("src.core.GameSpeed").clamp(speedOverride)))
|
||||
end
|
||||
|
||||
local function bootGame()
|
||||
local function bootGame(version)
|
||||
-- The launcher hands us the chosen game (Red / Blue); scripted and headless
|
||||
-- runs fall back to POKEPORT_VERSION, then Red. Set the active version and
|
||||
-- overlay its extracted cache BEFORE anything requires generated data, so
|
||||
-- data/generated + assets/generated resolve to that version's files.
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
GameVersion.set(version or os.getenv("POKEPORT_VERSION") or "red")
|
||||
require("src.import.CacheFs").mountVersion(GameVersion.get())
|
||||
if love.window and love.window.setTitle then
|
||||
local Version = require("src.core.Version")
|
||||
love.window.setTitle(Version.title(
|
||||
GameVersion.info().displayName .. " (Gen 1 Recompilation Project)"))
|
||||
end
|
||||
Game = require("src.core.Game")
|
||||
Game:load()
|
||||
if os.getenv("POKEPORT_AUTOPILOT") then
|
||||
@@ -68,20 +80,46 @@ function love.load(args)
|
||||
end
|
||||
|
||||
local RomImporter = require("src.import.RomImporter")
|
||||
if os.getenv("POKEPORT_FORCE_IMPORT") == "1" or not RomImporter.isReady() then
|
||||
Importer = RomImporter.new(function()
|
||||
if os.getenv("POKEPORT_IMPORT_ONLY") == "1" then
|
||||
love.event.quit()
|
||||
return
|
||||
end
|
||||
Importer = nil
|
||||
bootGame()
|
||||
end)
|
||||
local importPath = os.getenv("POKEPORT_IMPORT_ROM")
|
||||
if importPath then Importer:startPath(importPath) end
|
||||
local forceImport = os.getenv("POKEPORT_FORCE_IMPORT") == "1"
|
||||
local importPath = os.getenv("POKEPORT_IMPORT_ROM")
|
||||
-- Scripted / headless runs pick their game from POKEPORT_VERSION (default
|
||||
-- Red); the launcher's per-column choice does not apply to them.
|
||||
local scriptedVersion = os.getenv("POKEPORT_VERSION") or "red"
|
||||
local ready = RomImporter.isReady(scriptedVersion)
|
||||
-- Scripted / headless runs have to reach the game with no human pressing
|
||||
-- Play: an autopilot, a frame driver, an import-only build step, or an
|
||||
-- explicit ROM path all bypass the interactive launcher and keep today's
|
||||
-- import-then-boot (or boot-straight-in) behavior.
|
||||
local scripted = os.getenv("POKEPORT_AUTOPILOT") or os.getenv("POKEPORT_DRIVER")
|
||||
or os.getenv("POKEPORT_IMPORT_ONLY") == "1" or importPath ~= nil
|
||||
|
||||
if scripted then
|
||||
if forceImport or not ready then
|
||||
-- The importer detects the dropped/loaded ROM's version by SHA-1 and
|
||||
-- passes it to onComplete; boot that version.
|
||||
Importer = RomImporter.new(function(version)
|
||||
if os.getenv("POKEPORT_IMPORT_ONLY") == "1" then
|
||||
love.event.quit()
|
||||
return
|
||||
end
|
||||
Importer = nil
|
||||
bootGame(version or scriptedVersion)
|
||||
end)
|
||||
if importPath then Importer:startPath(importPath) end
|
||||
return
|
||||
end
|
||||
bootGame(scriptedVersion)
|
||||
return
|
||||
end
|
||||
bootGame()
|
||||
|
||||
-- Interactive: the launcher always runs. Red and Blue are each live: a
|
||||
-- column shows Play when that game's ROM is already imported, or Choose ROM
|
||||
-- / drag-drop when it is not (Yellow is still a placeholder). Any dropped
|
||||
-- .gb is routed to Red or Blue by its SHA-1; pressing Play boots that game.
|
||||
Importer = RomImporter.new(function(version)
|
||||
Importer = nil
|
||||
bootGame(version)
|
||||
end, { launcher = true, forceImport = forceImport })
|
||||
end
|
||||
|
||||
function love.update(dt)
|
||||
@@ -261,3 +299,78 @@ function love.filedropped(file)
|
||||
end
|
||||
if Importer then Importer:filedropped(file) end
|
||||
end
|
||||
|
||||
local function pacingEnabled()
|
||||
if os.getenv("POKEPORT_AUTOPILOT") then return false end
|
||||
if os.getenv("POKEPORT_DRIVER") then return false end
|
||||
if os.getenv("POKEPORT_IMPORT_ONLY") == "1" then return false end
|
||||
return true
|
||||
end
|
||||
|
||||
function love.run()
|
||||
if love.load then love.load(love.arg.parseGameArguments(arg), arg) end
|
||||
|
||||
-- don't let love.load's cost land in the first frame's dt
|
||||
if love.timer then love.timer.step() end
|
||||
|
||||
local FrameCap = require("src.core.FrameCap")
|
||||
local paced = pacingEnabled()
|
||||
-- The deadline the next present() should not beat. Carried forward one
|
||||
-- budget per frame so pacing stays even instead of drifting with the
|
||||
-- per-frame sleep-granularity jitter.
|
||||
local nextFrame = love.timer and love.timer.getTime() or 0
|
||||
local dt = 0
|
||||
|
||||
return function()
|
||||
-- process events
|
||||
if love.event then
|
||||
love.event.pump()
|
||||
for name, a, b, c, d, e, f in love.event.poll() do
|
||||
if name == "quit" then
|
||||
if not love.quit or not love.quit() then
|
||||
return a or 0
|
||||
end
|
||||
end
|
||||
love.handlers[name](a, b, c, d, e, f)
|
||||
end
|
||||
end
|
||||
|
||||
-- update dt
|
||||
if love.timer then dt = love.timer.step() end
|
||||
|
||||
-- call update and draw
|
||||
if love.update then love.update(dt) end
|
||||
|
||||
if love.graphics and love.graphics.isActive() then
|
||||
love.graphics.origin()
|
||||
love.graphics.clear(love.graphics.getBackgroundColor())
|
||||
if love.draw then love.draw() end
|
||||
love.graphics.present()
|
||||
end
|
||||
|
||||
if love.timer then
|
||||
if paced then
|
||||
-- Sleep out the remainder of the frame budget, measured from the
|
||||
-- carried deadline, in small chunks so the OS timer stays
|
||||
-- responsive. vsync is untouched: when it already paces slower
|
||||
-- than the cap the remainder is <= 0 and this rounds to a no-op.
|
||||
local budget = 1 / FrameCap.current
|
||||
nextFrame = nextFrame + budget
|
||||
local now = love.timer.getTime()
|
||||
-- A stall (alt-tab, a GC pause, a blocked import) can leave the
|
||||
-- deadline more than a full budget in the past; re-anchor to now so
|
||||
-- we pace the next frame rather than burst uncapped to catch up.
|
||||
if now - nextFrame > budget then
|
||||
nextFrame = now
|
||||
end
|
||||
while true do
|
||||
local remaining = nextFrame - love.timer.getTime()
|
||||
if remaining <= 0 then break end
|
||||
love.timer.sleep(remaining < 0.001 and remaining or 0.001)
|
||||
end
|
||||
else
|
||||
love.timer.sleep(0.001)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
+6
-6
@@ -8,12 +8,12 @@
|
||||
# [--notary-profile NAME] [--no-notarize]
|
||||
# [--release] # ios only: release config instead of debug
|
||||
#
|
||||
# Output: dist/mac/PokemonRed-macos.zip
|
||||
# dist/win/PokemonRed-win64.zip
|
||||
# dist/linux/PokemonRed-linux.zip (fused x86_64 AppImage)
|
||||
# Output: dist/mac/gen1recomp-macos.zip
|
||||
# dist/win/gen1recomp-win64.zip
|
||||
# dist/linux/gen1recomp-linux.zip (fused x86_64 AppImage)
|
||||
# dist/android/debug/*.apk (full gradle output stays under
|
||||
# mobile/android/app/build/outputs/apk/embedNoRecord/)
|
||||
# dist/ios/<Config>-<sdk>/PokemonRed.app (full xcodebuild output stays
|
||||
# dist/ios/<Config>-<sdk>/gen1recomp.app (full xcodebuild output stays
|
||||
# under mobile/ios/build/Build/Products/)
|
||||
|
||||
set -euo pipefail
|
||||
@@ -25,7 +25,7 @@ WORK="$HERE/work"
|
||||
DIST="$ROOT/dist"
|
||||
ENTITLEMENTS="$ROOT/scripts/macos-entitlements.plist"
|
||||
|
||||
APP_NAME="PokemonRed"
|
||||
APP_NAME="gen1recomp"
|
||||
BUNDLE_ID="com.theboisclub.pokemonred"
|
||||
LOVE_VERSION="11.5"
|
||||
VERSION="$(git -C "$ROOT" rev-parse --short HEAD 2>/dev/null || echo dev)"
|
||||
@@ -60,7 +60,7 @@ say "packing game.love"
|
||||
LOVE_FILE="$WORK/game.love"
|
||||
rm -f "$LOVE_FILE"
|
||||
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
|
||||
main.lua conf.lua src data assets tools/rom_manifest.json \
|
||||
main.lua conf.lua src data assets tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
||||
if unzip -Z1 "$LOVE_FILE" \
|
||||
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
|
||||
|
||||
@@ -22,7 +22,7 @@ ANDROID_DIR="$ROOT/mobile/android"
|
||||
EMBED_ASSETS="$ANDROID_DIR/app/src/embed/assets"
|
||||
LOVE_FILE="$EMBED_ASSETS/game.love"
|
||||
DIST="$ROOT/dist/android"
|
||||
APP_NAME="Pokemon Red"
|
||||
APP_NAME="gen1recomp"
|
||||
APPLICATION_ID="com.theboisclub.pokemonred"
|
||||
LOVE_ANDROID_VERSION="11.5a"
|
||||
NDK_VERSION="25.2.9519653"
|
||||
@@ -137,7 +137,7 @@ pack_game_love() {
|
||||
mkdir -p "$EMBED_ASSETS"
|
||||
rm -f "$LOVE_FILE"
|
||||
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
|
||||
main.lua conf.lua src data assets tools/rom_manifest.json \
|
||||
main.lua conf.lua src data assets tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
-x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
|
||||
-x 'data/generated/*' -x 'assets/generated/*')
|
||||
if unzip -Z1 "$LOVE_FILE" \
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
# - mobile/ios/love-src/ (see --fetch / mobile/ios/README.md)
|
||||
# - prebuilt iOS libraries under love-src/platform/xcode/ios/libraries/
|
||||
#
|
||||
# Output: dist/ios/<Config>-<sdk>/PokemonRed.app (convenience copy)
|
||||
# mobile/ios/build/Build/Products/<Config>-<sdk>/PokemonRed.app
|
||||
# Output: dist/ios/<Config>-<sdk>/gen1recomp.app (convenience copy)
|
||||
# mobile/ios/build/Build/Products/<Config>-<sdk>/gen1recomp.app
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -33,8 +33,8 @@ RESOURCES_DIR="$XCODE_DIR/ios/resources"
|
||||
LOVE_FILE="$RESOURCES_DIR/game.love"
|
||||
LIBS_DIR="$XCODE_DIR/ios/libraries"
|
||||
|
||||
APP_NAME="PokemonRed"
|
||||
DISPLAY_NAME="Pokemon Red"
|
||||
APP_NAME="gen1recomp"
|
||||
DISPLAY_NAME="gen1recomp"
|
||||
BUNDLE_ID="com.theboisclub.pokemonred"
|
||||
LOVE_VERSION="$(tr -d '[:space:]' < "$IOS_DIR/LOVE_VERSION" 2>/dev/null || echo 11.5)"
|
||||
IOS_SOURCE_ZIP="love-${LOVE_VERSION}-ios-source.zip"
|
||||
@@ -166,7 +166,7 @@ pack_game_love() {
|
||||
rm -f "$LOVE_FILE"
|
||||
# Same payload as scripts/build.sh / build_android.sh: game sources only.
|
||||
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
|
||||
main.lua conf.lua src data assets tools/rom_manifest.json \
|
||||
main.lua conf.lua src data assets tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
-x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
|
||||
-x 'data/generated/*' -x 'assets/generated/*')
|
||||
if unzip -Z1 "$LOVE_FILE" \
|
||||
|
||||
@@ -144,6 +144,25 @@ function AnimPlayer.new(data)
|
||||
}, AnimPlayer)
|
||||
end
|
||||
|
||||
-- Release the tilesheet images and quads this player built. They live in
|
||||
-- per-instance caches (a fresh AnimPlayer is made per battle), so unlike a
|
||||
-- shared module cache they are dead the moment the battle ends -- freeing
|
||||
-- them here instead of waiting on a GC finalizer keeps grinding battles
|
||||
-- from piling orphaned VRAM up faster than the (Lua-heap-triggered) GC
|
||||
-- reclaims it.
|
||||
function AnimPlayer:release()
|
||||
for _, img in pairs(self.images) do
|
||||
if img and img.release then pcall(img.release, img) end
|
||||
end
|
||||
self.images = {}
|
||||
for _, byTile in pairs(self.quads) do
|
||||
for _, q in pairs(byTile) do
|
||||
if q and q.release then pcall(q.release, q) end
|
||||
end
|
||||
end
|
||||
self.quads = {}
|
||||
end
|
||||
|
||||
function AnimPlayer:warnOnce(key, fmt, ...)
|
||||
if not self.warned[key] then
|
||||
self.warned[key] = true
|
||||
|
||||
+256
-50
@@ -30,6 +30,9 @@ local TypeChart = require("src.battle.TypeChart")
|
||||
local BattleState = {}
|
||||
BattleState.__index = BattleState
|
||||
BattleState.isOpaque = true
|
||||
-- Letterbox voids around the 160x144 battle canvas fill white so the
|
||||
-- window reads as one continuous battle screen (no black bars).
|
||||
BattleState.letterboxWhite = true
|
||||
|
||||
-- Battle colors itself per-pixel (species pics + HP bar tints), so the
|
||||
-- SGB whole-screen remap must not run over it.
|
||||
@@ -52,6 +55,10 @@ local imageCache = {}
|
||||
-- fully transparent rows below a pic's content (the extracted 32x32 back
|
||||
-- pics carry baked-in padding); used to sit the pic flush on the text box
|
||||
local imagePadBottom = {}
|
||||
-- fully transparent columns left of a pic's content; at 2x (back pics)
|
||||
-- this is subtracted from hlcoord 1,5 so opaque pixels match hardware,
|
||||
-- where those columns were white-on-white rather than shifted content
|
||||
local imagePadLeft = {}
|
||||
-- image -> { path, pal } so palette-fade variants (see fadeImage) can be
|
||||
-- rebuilt for any battle pic, whatever code loaded it
|
||||
local imageMeta = {}
|
||||
@@ -63,7 +70,7 @@ local function getImage(path, pal, trueColor)
|
||||
if trueColor then pal = nil end
|
||||
local key = pal and (path .. "#" .. pal.name) or path
|
||||
if not imageCache[key] then
|
||||
local img, pad = nil, 0
|
||||
local img, pad, padL = nil, 0, 0
|
||||
if love.image and love.image.newImageData then
|
||||
local id = Assets.imageData(path)
|
||||
if pal then
|
||||
@@ -86,13 +93,25 @@ local function getImage(path, pal, trueColor)
|
||||
if opaque then break end
|
||||
bottom = bottom - 1
|
||||
end
|
||||
local left = 0
|
||||
while left < w do
|
||||
local opaque = false
|
||||
for y = 0, h - 1 do
|
||||
local _, _, _, a = id:getPixel(left, y)
|
||||
if a > 0 then opaque = true break end
|
||||
end
|
||||
if opaque then break end
|
||||
left = left + 1
|
||||
end
|
||||
img = love.graphics.newImage(id)
|
||||
pad = h - 1 - bottom
|
||||
padL = left
|
||||
else
|
||||
img = Assets.image(path) -- headless stub: no pixel access
|
||||
end
|
||||
imageCache[key] = img
|
||||
imagePadBottom[img] = pad
|
||||
imagePadLeft[img] = padL
|
||||
imageMeta[img] = { path = path, pal = pal, trueColor = trueColor or nil }
|
||||
end
|
||||
return imageCache[key]
|
||||
@@ -101,7 +120,7 @@ end
|
||||
-- hot reload: the next getImage re-resolves every pic through the asset
|
||||
-- search path and re-measures its ground padding
|
||||
function BattleState.invalidate()
|
||||
imageCache, imagePadBottom, imageMeta = {}, {}, {}
|
||||
imageCache, imagePadBottom, imagePadLeft, imageMeta = {}, {}, {}, {}
|
||||
end
|
||||
|
||||
Assets.register(BattleState.invalidate)
|
||||
@@ -258,6 +277,10 @@ local function makeBattler(data, mon, isPlayer, save)
|
||||
}
|
||||
end
|
||||
|
||||
-- LinkBattle builds clamped copies with save=nil (no badge boosts); wild
|
||||
-- and trainer constructors pass the live save for the player side.
|
||||
BattleState.makeBattler = makeBattler
|
||||
|
||||
-- The battle pic for `species` on the given side (back pic for the
|
||||
-- player side, front pic for the enemy side), tinted PAL_GRAYMON --
|
||||
-- the same path makeBattler uses, but forced gray -- since this is only
|
||||
@@ -832,7 +855,7 @@ function BattleState:computeMusicKind()
|
||||
return "final"
|
||||
elseif isBoss or (self.trainer and self.trainer.id == "OPP_LANCE") then
|
||||
return "gym"
|
||||
elseif self.kind == "trainer" then
|
||||
elseif self.kind == "trainer" or self.kind == "link" then
|
||||
return "trainer"
|
||||
end
|
||||
return "wild"
|
||||
@@ -894,6 +917,17 @@ function BattleState:enter()
|
||||
self.showEnemyTrainer = false
|
||||
self:startGrowIn(self.enemy)
|
||||
end)
|
||||
elseif self.kind == "link" then
|
||||
-- Colosseum has no foe trainer pic, but the enemy mon still grows
|
||||
-- out of the ball after "X sent out Y!" (not the wild "already there"
|
||||
-- intro that LinkBattle previously inherited from newWild).
|
||||
self.enemySendingOut = true
|
||||
self:say(("%s sent\nout %s!"):format(self.opponentName or "FOE",
|
||||
self.enemy.name))
|
||||
self:act(function()
|
||||
self.enemySendingOut = false
|
||||
self:startGrowIn(self.enemy)
|
||||
end)
|
||||
end
|
||||
if not self.ghost then
|
||||
-- the enemy's cry plays as it appears (data/pokemon/cries.asm)
|
||||
@@ -934,22 +968,53 @@ end
|
||||
-- (end_of_battle.asm clears wLowHealthAlarm when a battle ends)
|
||||
function BattleState:exit()
|
||||
require("src.core.Sound").stopLoop("Low_Health_Alarm")
|
||||
-- Free this battle's own GPU objects now rather than waiting on a GC
|
||||
-- finalizer: the two full-screen wavy-effect canvases (colorMode) and
|
||||
-- the AnimPlayer's per-instance tilesheet images/quads. The shared
|
||||
-- module caches (imageCache/imagePadBottom, keyed by path+palette) are
|
||||
-- reused by the next battle, so they are deliberately left alone -- only
|
||||
-- the per-instance objects, which are dead once this battle is popped,
|
||||
-- are released here.
|
||||
local function rel(o) if o and o.release then pcall(o.release, o) end end
|
||||
rel(self.bgCanvas); self.bgCanvas = nil
|
||||
rel(self.waveCanvas); self.waveCanvas = nil
|
||||
self.colorFxReady = nil
|
||||
if self.animPlayer and self.animPlayer.release then
|
||||
self.animPlayer:release()
|
||||
end
|
||||
end
|
||||
|
||||
-- An action the battler is locked into (bypasses the menu), or nil.
|
||||
function BattleState:lockedAction(battler)
|
||||
-- End a trapping sequence (USING_TRAPPING_MOVE). SendOutMon clears the
|
||||
-- foe's bit (core.asm:1761-1762); EnemySendOutFirstMon clears the
|
||||
-- player's (core.asm:1314-1315). Any switch frees the other side.
|
||||
local function clearTrapping(battler)
|
||||
if not battler then return end
|
||||
battler.trappingTurns = nil
|
||||
battler.trapMove = nil
|
||||
battler.trapDamage = nil
|
||||
end
|
||||
|
||||
-- Actions that skip DisplayBattleMenu entirely (core.asm:300-310):
|
||||
-- recharge, Rage, thrash, charge. Bide / trapping / being held do NOT
|
||||
-- skip the menu -- the player can still item/switch (and must press
|
||||
-- FIGHT to continue a trapping sequence).
|
||||
function BattleState:menuLockedAction(battler)
|
||||
if battler.mustRecharge then return { special = "recharge" } end
|
||||
if battler.charging then return battler.charging end
|
||||
if battler.thrashTurns and battler.thrashTurns > 0 then return battler.thrashMove end
|
||||
if battler.rageMove then return battler.rageMove end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- After FIGHT: skip MoveSelectionMenu (core.asm:320-329). Own
|
||||
-- trapping/Bide continues; foe trapping forces CANNOT_MOVE ($ff).
|
||||
function BattleState:fightLockedAction(battler)
|
||||
if battler.trappingTurns and battler.trappingTurns > 0 then
|
||||
return { special = "trapping" }
|
||||
end
|
||||
if battler.bideTurns then return { special = "bide" } end
|
||||
if battler.rageMove then return battler.rageMove end
|
||||
-- held in place while the OPPONENT's trapping move is running
|
||||
-- (core.asm:316-322 reads the live USING_TRAPPING_MOVE bit, so a
|
||||
-- trap ended early by paralysis/faint frees the victim immediately);
|
||||
-- boundTurns is a mirror kept for Status.beforeMove's held check
|
||||
-- held while the OPPONENT's trapping bit is set (live mirror so a
|
||||
-- trap ended early by paralysis/faint frees the victim immediately)
|
||||
local opp = battler.isPlayer and self.enemy or self.player
|
||||
battler.boundTurns = opp and opp.trappingTurns
|
||||
and math.max(1, opp.trappingTurns) or nil
|
||||
@@ -959,6 +1024,11 @@ function BattleState:lockedAction(battler)
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Full lock for AI / callers that need any forced action.
|
||||
function BattleState:lockedAction(battler)
|
||||
return self:menuLockedAction(battler) or self:fightLockedAction(battler)
|
||||
end
|
||||
|
||||
function BattleState:playerHasPP()
|
||||
for i, mv in ipairs(self.player.curMoves) do
|
||||
if mv.pp > 0 and self.player.disabledSlot ~= i then return true end
|
||||
@@ -1065,8 +1135,9 @@ function BattleState:update(dt)
|
||||
if not (self.player.mustRecharge or self.player.rageMove) then
|
||||
self.player.flinched, self.enemy.flinched = false, false
|
||||
end
|
||||
-- locked multi-turn actions skip the menu entirely
|
||||
local locked = self:lockedAction(self.player)
|
||||
-- only recharge/Rage/thrash/charge skip DisplayBattleMenu; trapping
|
||||
-- victims (and wrappers) still get FIGHT/PKMN/ITEM/RUN (core.asm:312)
|
||||
local locked = self:menuLockedAction(self.player)
|
||||
if locked then
|
||||
self:resolveTurn(locked)
|
||||
return
|
||||
@@ -1090,6 +1161,13 @@ function BattleState:update(dt)
|
||||
end)
|
||||
self:act(function() self:endOfTurn() end)
|
||||
elseif choice == "fight" then
|
||||
-- After the menu: own trapping/Bide or foe Wrap skips the move
|
||||
-- list and forces the locked action (core.asm:320-329)
|
||||
local fightLock = self:fightLockedAction(self.player)
|
||||
if fightLock then
|
||||
self:resolveTurn(fightLock)
|
||||
return
|
||||
end
|
||||
if not self:playerHasPP() then
|
||||
-- _NoMovesLeftText, then Struggle engages
|
||||
self:say(("%s has no\nmoves left!"):format(self.player.name))
|
||||
@@ -1500,6 +1578,9 @@ function BattleState:resolveSwitch(newMon)
|
||||
self:restoreMimicked(self.player) -- the battle copy leaves with it
|
||||
local previous = self.player
|
||||
self.player = makeBattler(self.data, newMon, true, self.game.save)
|
||||
-- SendOutMon (core.asm:1761-1762): player's send-out clears the
|
||||
-- foe's USING_TRAPPING_MOVE -- Wrap/Bind/etc. ends on any switch
|
||||
clearTrapping(self.enemy)
|
||||
self:syncSides()
|
||||
Runtime.emit("battle.battler_switched", {
|
||||
battle = self, side = self.sides[1], battler = self.player,
|
||||
@@ -1605,10 +1686,19 @@ end
|
||||
-- only after MoveHitTest / the effect lands (HandleIfPlayerMoveMissed skips
|
||||
-- it on a miss unless EXPLODE_EFFECT); we insert early for blink attachment
|
||||
-- and peel it back on miss/fail paths.
|
||||
-- Dig/Fly charge leaves the user pic hidden (SLIDE_DOWN / TELEPORT); the
|
||||
-- second-turn DIG/FLY anim restores it via SE_SLIDE_MON_UP / SE_SHOW_MON_PIC.
|
||||
-- Cancelling that row on miss/immune would otherwise leave the digger
|
||||
-- invisible until another anim's resetPicFx (#100).
|
||||
function BattleState:cancelMoveAnim()
|
||||
local row = self.moveAnimRow
|
||||
if not row then return end
|
||||
self.moveAnimRow = nil
|
||||
if row.anim == "DIG" or row.anim == "FLY" then
|
||||
local user = row.attackerIsPlayer and self.player or self.enemy
|
||||
local pf = user and self.picFx and self.picFx[user]
|
||||
if pf then pf.hidden = nil end
|
||||
end
|
||||
for i, item in ipairs(self.queue) do
|
||||
if item == row then
|
||||
table.remove(self.queue, i)
|
||||
@@ -1672,14 +1762,23 @@ end
|
||||
|
||||
-- transient pic effects reset when a new animation row starts (each
|
||||
-- PlayAnimation redraws from a clean slate); `minimized` survives --
|
||||
-- the minimize sprite replaces the pic DATA, so redraws keep it until
|
||||
-- the pic is reloaded (switch/Transform/ChangeMonPic)
|
||||
-- the minimize sprite replaces the pic DATA until reload. Dig/Fly's
|
||||
-- charge hide is a cleared tilemap that must survive until SE_SHOW_* /
|
||||
-- SE_SLIDE_MON_UP (or cancelMoveAnim on a missed Dig/Fly release):
|
||||
-- clearing it here made Dig pop in before emerge and wrap/bounce (#100).
|
||||
-- Other hides (Acid Armor, etc.) still clear so the next anim restores.
|
||||
function BattleState:resetPicFx()
|
||||
if not self.picFx then return end
|
||||
for _, pf in pairs(self.picFx) do
|
||||
local digFly = self.animName == "DIG" or self.animName == "FLY"
|
||||
local digFlyUser = digFly and (self.animAttackerIsPlayer
|
||||
and self.player or self.enemy) or nil
|
||||
for battler, pf in pairs(self.picFx) do
|
||||
pf.kind, pf.t = nil, nil
|
||||
pf.ox, pf.oy = 0, 0
|
||||
pf.hidden = nil
|
||||
local keepHide = battler.invulnerable or battler == digFlyUser
|
||||
if not keepHide then
|
||||
pf.hidden = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2080,6 +2179,8 @@ function BattleState:executeAction(user, target, action)
|
||||
local oldName = self.enemy.name
|
||||
self.enemyIndex = action.index
|
||||
self.enemy = makeBattler(self.data, self.enemyParty[action.index], false)
|
||||
-- EnemySendOutFirstMon (core.asm:1314-1315): clears player's trap
|
||||
clearTrapping(self.player)
|
||||
self:syncSides()
|
||||
Runtime.emit("battle.battler_switched", {
|
||||
battle = self, side = self.sides[2], battler = self.enemy,
|
||||
@@ -2130,6 +2231,43 @@ function BattleState:executeAction(user, target, action)
|
||||
self:performMove(user, target, action, false)
|
||||
end
|
||||
|
||||
-- Sleep / confusion onomatopoeia from Check*StatusConditions
|
||||
-- (core.asm): side-specific SLP_*/CONF_* anims, not the Rest/Amnesia
|
||||
-- move rows. Player sleep plays the anim before FastAsleepText;
|
||||
-- enemy sleep and both confusion sides print the text first.
|
||||
function BattleState:statusOnomatopoeia(user, kind)
|
||||
local isPlayer = user.isPlayer
|
||||
local anim
|
||||
if kind == "sleep" then
|
||||
anim = isPlayer and "SLP_PLAYER_ANIM" or "SLP_ANIM"
|
||||
else
|
||||
anim = isPlayer and "CONF_PLAYER_ANIM" or "CONF_ANIM"
|
||||
end
|
||||
local text = kind == "sleep"
|
||||
and (displayName(user) .. "\nis fast asleep!")
|
||||
or (displayName(user) .. "\nis confused!")
|
||||
if kind == "sleep" and isPlayer then
|
||||
self:animNext(anim, isPlayer)
|
||||
self:sayNext(text)
|
||||
else
|
||||
self:sayNext(text)
|
||||
self:animNext(anim, isPlayer)
|
||||
end
|
||||
end
|
||||
|
||||
-- Queue status text (+ sleep/confusion FX when the line matches).
|
||||
-- Wake / snap-out / flinch / etc. stay text-only.
|
||||
function BattleState:sayStatusMsg(user, msg)
|
||||
local text = prefixEnemy(msg, user)
|
||||
if msg:find("is fast asleep!", 1, true) then
|
||||
self:statusOnomatopoeia(user, "sleep")
|
||||
elseif msg:find("is confused!", 1, true) then
|
||||
self:statusOnomatopoeia(user, "confused")
|
||||
else
|
||||
self:sayNext(text)
|
||||
end
|
||||
end
|
||||
|
||||
-- The pre-recharge slice of CheckPlayerStatusConditions (core.asm:
|
||||
-- 3328-3382): sleep -> freeze -> held-in-place -> flinch, each losing
|
||||
-- the turn WITHOUT consuming the recharge flag. The disable/confusion/
|
||||
@@ -2148,7 +2286,7 @@ function BattleState:preRechargeChecks(user, target)
|
||||
mon.status = nil
|
||||
self:sayNext(displayName(user) .. "\nwoke up!")
|
||||
else
|
||||
self:sayNext(displayName(user) .. "\nis fast asleep!")
|
||||
self:statusOnomatopoeia(user, "sleep")
|
||||
end
|
||||
return true
|
||||
end
|
||||
@@ -2175,7 +2313,7 @@ end
|
||||
-- returns true when the user's action is interrupted.
|
||||
function BattleState:statusInterrupt(user, target)
|
||||
local canMove, msgs, selfHit = Status.beforeMove(user, self.rng, self)
|
||||
for _, m in ipairs(msgs) do self:sayNext(prefixEnemy(m, user)) end
|
||||
for _, m in ipairs(msgs) do self:sayStatusMsg(user, m) end
|
||||
if selfHit then
|
||||
-- confusion self-hit (core.asm:3428-3434): clears everything in
|
||||
-- status1 except CONFUSED, then HandleSelfConfusionDamage deals a
|
||||
@@ -2252,11 +2390,23 @@ function BattleState:performMove(user, target, moveInst, isCalled)
|
||||
user.charging, user.chargeReady, user.invulnerable = nil, nil, nil
|
||||
end
|
||||
|
||||
-- PP: not for continuations, struggle, or called moves
|
||||
-- PP: not for continuations, struggle, called moves, or (under
|
||||
-- gen1_faithful) wild/trainer enemies — pokered DecrementPP only ever
|
||||
-- mutates wBattleMonPP / party PP (engine/battle/decrement_pp.asm).
|
||||
-- That rule doesn't apply in a link battle: "the enemy" there is a real
|
||||
-- human peer independently tracking their own PP the normal way, not an
|
||||
-- AI Gen 1 never bothered to decrement -- applying it made each side
|
||||
-- silently skip decrementing the OTHER side's PP for the move it just
|
||||
-- used, so both simulations' party state diverged by exactly 1 PP on
|
||||
-- the very first move either side made, failing the lockstep hash
|
||||
-- check turn 1 of literally every link battle.
|
||||
local isContinuation = releasing
|
||||
or (user.thrashTurns and user.thrashTurns > 0 and moveInst == user.thrashMove)
|
||||
or moveInst == user.rageMove
|
||||
if not isContinuation and not moveInst.struggle and not isCalled then
|
||||
local enemyUnlimited = not user.isPlayer and self.kind ~= "link"
|
||||
and self.ruleset and self.ruleset.enemyUnlimitedPP
|
||||
if not isContinuation and not moveInst.struggle and not isCalled
|
||||
and not enemyUnlimited then
|
||||
moveInst.pp = math.max(0, moveInst.pp - 1)
|
||||
end
|
||||
|
||||
@@ -2560,8 +2710,20 @@ function BattleState:enemyMonFainted()
|
||||
self.participants = {}
|
||||
|
||||
if self.kind == "trainer" then
|
||||
if self.enemyIndex < #self.enemyParty then
|
||||
self.enemyIndex = self.enemyIndex + 1
|
||||
-- EnemySendOutFirstMon / AnyEnemyPokemonAliveCheck (core.asm): scan
|
||||
-- the whole enemy party for the first mon with HP left. Blindly
|
||||
-- doing enemyIndex+1 softlocks after an AI switch (Agatha): a later
|
||||
-- slot can already be fainted, so the empty-HP mon comes out, the
|
||||
-- FIGHT menu returns, and executeAction no-ops on target.hp <= 0.
|
||||
local nextIndex
|
||||
for i, mon in ipairs(self.enemyParty) do
|
||||
if mon.hp > 0 then
|
||||
nextIndex = i
|
||||
break
|
||||
end
|
||||
end
|
||||
if nextIndex then
|
||||
self.enemyIndex = nextIndex
|
||||
-- SHIFT battle style (the default): announce the next mon and
|
||||
-- offer a free switch (SET skips the prompt)
|
||||
local nextMon = self.enemyParty[self.enemyIndex]
|
||||
@@ -2585,6 +2747,7 @@ function BattleState:enemyMonFainted()
|
||||
if mon ~= self.player.mon and mon.hp > 0 then
|
||||
local previous = self.player
|
||||
self.player = makeBattler(self.data, mon, true, game.save)
|
||||
clearTrapping(self.enemy) -- SendOutMon clears foe trap
|
||||
self:syncSides()
|
||||
Runtime.emit("battle.battler_switched", {
|
||||
battle = self, side = self.sides[1],
|
||||
@@ -2610,6 +2773,8 @@ function BattleState:enemyMonFainted()
|
||||
self:act(function()
|
||||
local previous = self.enemy
|
||||
self.enemy = makeBattler(self.data, self.enemyParty[self.enemyIndex], false)
|
||||
-- EnemySendOutFirstMon (core.asm:1314-1315): clears player's trap
|
||||
clearTrapping(self.player)
|
||||
self:syncSides()
|
||||
Runtime.emit("battle.battler_switched", {
|
||||
battle = self, side = self.sides[2], battler = self.enemy,
|
||||
@@ -2763,6 +2928,7 @@ function BattleState:openReplacementMenu()
|
||||
self:restoreMimicked(self.player)
|
||||
local previous = self.player
|
||||
self.player = makeBattler(self.data, mon, true, game.save)
|
||||
clearTrapping(self.enemy) -- SendOutMon clears foe trap
|
||||
self:syncSides()
|
||||
Runtime.emit("battle.battler_switched", {
|
||||
battle = self, side = self.sides[1], battler = self.player,
|
||||
@@ -3460,17 +3626,16 @@ function BattleState:drawBattlerPic(battler, x, y, scale)
|
||||
love.graphics.draw(img, quad, x + ox, y + oy, 0, scale, scale)
|
||||
end
|
||||
elseif k == "slideUp" then
|
||||
-- AnimationSlideMonUp: cyclic upward wrap, one row per 2 frames
|
||||
local scroll = 8 * math.min(7, math.floor(t / 2) + 1)
|
||||
local src = math.floor(scroll / scale) % h
|
||||
if src == 0 then
|
||||
love.graphics.draw(img, x + ox, y, 0, scale, scale)
|
||||
else
|
||||
local top = love.graphics.newQuad(0, src, w, h - src, w, h)
|
||||
love.graphics.draw(img, top, x + ox, y, 0, scale, scale)
|
||||
local bottom = love.graphics.newQuad(0, 0, w, src, w, h)
|
||||
love.graphics.draw(img, bottom, x + ox, y + (h - src) * scale,
|
||||
0, scale, scale)
|
||||
-- AnimationSlideMonUp (animations.asm): 7 row steps x 2f. After Dig's
|
||||
-- SLIDE_DOWN the tilemap is blank; each step fills the next bottom
|
||||
-- row so the mon emerges from underground. A cyclic wrap of a full
|
||||
-- pic looked like a bounce at Dig's end (#100).
|
||||
local step = math.min(7, math.floor((t - 1) / 2) + 1)
|
||||
local visible = math.floor(h * step / 7)
|
||||
if visible > 0 then
|
||||
local quad = love.graphics.newQuad(0, h - visible, w, visible, w, h)
|
||||
love.graphics.draw(img, quad, x + ox,
|
||||
y + (h - visible) * scale, 0, scale, scale)
|
||||
end
|
||||
elseif xscale < 1 then
|
||||
-- AnimationSquishMonPic: columns collapse toward the middle
|
||||
@@ -3551,12 +3716,28 @@ function BattleState:sgbBattlePals()
|
||||
if placeholder or not b then return pals.MEWMON or pals.GREENBAR end
|
||||
return PaletteFX.monPal(self.data, b.mon.species) or pals.MEWMON
|
||||
end
|
||||
return {
|
||||
local out = {
|
||||
[0] = bar(self.player),
|
||||
[1] = bar(self.enemy),
|
||||
[2] = mon(self.player, self.showPlayerBack or self.safari or self.demo),
|
||||
[3] = mon(self.enemy, self.showEnemyTrainer),
|
||||
}
|
||||
-- OG RED: the Game Boy Color drew the whole battle from one BG palette --
|
||||
-- white paper, black ink -- so every zone shares the same background and
|
||||
-- outline; only the two mid shades differ per element (green HP bar, red
|
||||
-- mon pic). The bar/base zones otherwise carry the SGB off-white
|
||||
-- (255,239,255) as color 0 while the mon zones (monPal -> GBC_BG) carry a
|
||||
-- true white, which is what drew a white box around each pic on the pink
|
||||
-- field. Snap every zone's color 0/3 to the global GBC white/black; the
|
||||
-- mid shades (and the green bar the user prefers) stay untouched.
|
||||
if PaletteFX.mode == "ogred" then
|
||||
local white, black = PaletteFX.GBC_BG[1], PaletteFX.GBC_BG[4]
|
||||
for i = 0, 3 do
|
||||
local c = out[i]
|
||||
out[i] = { white, c[2], c[3], black }
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- the SGB palette covering a screen pixel (BlkPacket_Battle regions)
|
||||
@@ -3666,6 +3847,20 @@ function BattleState:drawAnimLayer(colorized)
|
||||
end
|
||||
end
|
||||
|
||||
-- Front/trainer pics: LoadUncompressedSpriteData centers the sprite in
|
||||
-- a 7x7 tile buffer, then CopyUncompressedPicToTilemap places that
|
||||
-- buffer at hlcoord 12,0. Horizontal pad is floor((8-w)/2) tiles;
|
||||
-- vertical pad is (7-h) -- bottom-aligned inside the 7x7.
|
||||
local function enemyPicXY(img, slide, sx, sy)
|
||||
local tw = math.floor(img:getWidth() / 8)
|
||||
local th = math.floor(img:getHeight() / 8)
|
||||
if tw < 1 then tw = 1 elseif tw > 7 then tw = 7 end
|
||||
if th < 1 then th = 1 elseif th > 7 then th = 7 end
|
||||
local hPad = math.floor((8 - tw) / 2)
|
||||
local vPad = 7 - th
|
||||
return 96 + 8 * hPad - slide + sx, 8 * vPad + sy
|
||||
end
|
||||
|
||||
-- the two mon pics (or the trainer/back pics), offset by the window
|
||||
-- shake -- on the GB the pics are BG tiles, so they move with it
|
||||
function BattleState:drawPicsLayer(slide, sx, sy)
|
||||
@@ -3683,19 +3878,18 @@ function BattleState:drawPicsLayer(slide, sx, sy)
|
||||
g.intersectScissor(0, 0, 160, clipY)
|
||||
clipped = true
|
||||
end
|
||||
-- Enemy: front sprite top-right (GB: pic at hlcoord 12,0).
|
||||
-- Enemy: front sprite in the 7x7 slot at hlcoord 12,0.
|
||||
if self.showEnemyTrainer and self.trainerPic then
|
||||
-- the enemy trainer pic holds the mon slot until the send-out
|
||||
local img = self:picImage(self.trainerPic)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(img, 160 - 8 - img:getWidth() - slide + sx,
|
||||
math.max(0, 48 - img:getHeight()) + sy)
|
||||
local ex, ey = enemyPicXY(img, slide, sx, sy)
|
||||
love.graphics.draw(img, ex, ey)
|
||||
elseif self.enemy and self.enemy.sprite and not self.enemyHidden
|
||||
and not self.enemySendingOut and not self:fxHidden(self.enemy) then
|
||||
local img = self:picImage(self.enemy.sprite)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
local ex = 160 - 8 - img:getWidth() - slide + sx
|
||||
local ey = math.max(0, 48 - img:getHeight()) + sy
|
||||
local ex, ey = enemyPicXY(img, slide, sx, sy)
|
||||
local gs = self:growInScale(self.enemy)
|
||||
if gs then
|
||||
-- AnimateSendingOutMon: the downscaled pic keeps its bottom edge
|
||||
@@ -3709,15 +3903,18 @@ function BattleState:drawPicsLayer(slide, sx, sy)
|
||||
end
|
||||
end
|
||||
|
||||
-- Player: back sprite bottom-left (2x like the GB, feet near y=100).
|
||||
-- Player: back sprite at hlcoord 1,5 (x=8), 2x like the GB, feet at y=96.
|
||||
-- Left transparent columns (matted white) are pulled back so opaque
|
||||
-- pixels land where hardware's white-on-white columns left them.
|
||||
local hidePlayer = self.safari or self.demo
|
||||
if self.showPlayerBack and self.playerBackPic then
|
||||
-- Red's (or the old man's) back pic until "Go!"; it stays up for
|
||||
-- the whole safari / catch-demo battle like the original
|
||||
local img = self:picImage(self.playerBackPic)
|
||||
local pad = imagePadBottom[self.playerBackPic] or 0
|
||||
local padL = imagePadLeft[self.playerBackPic] or 0
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(img, 16 + slide + sx,
|
||||
love.graphics.draw(img, 8 - padL * 2 + slide + sx,
|
||||
96 - (img:getHeight() - pad) * 2 + sy, 0, 2, 2)
|
||||
elseif self.player and self.player.sprite and not hidePlayer
|
||||
and not self.sendingOut and not self:fxHidden(self.player) then
|
||||
@@ -3725,17 +3922,19 @@ function BattleState:drawPicsLayer(slide, sx, sy)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
-- feet flush on the text box top (y=96), ignoring baked-in padding
|
||||
local pad = imagePadBottom[self.player.sprite] or 0
|
||||
local padL = imagePadLeft[self.player.sprite] or 0
|
||||
local px = 8 - padL * 2 + sx
|
||||
local gs = self:growInScale(self.player)
|
||||
if gs then
|
||||
-- the player-side AnimateSendingOutMon grow (after the poof,
|
||||
-- core.asm:1757-1762): feet pinned at y=96, center at x=16+w
|
||||
-- core.asm:1757-1762): feet pinned at y=96, center at x=8+w
|
||||
if gs > 0 then
|
||||
love.graphics.draw(img, 16 + img:getWidth() * (1 - gs) + sx,
|
||||
love.graphics.draw(img, px + img:getWidth() * (1 - gs),
|
||||
96 - (img:getHeight() - pad) * 2 * gs + sy,
|
||||
0, 2 * gs, 2 * gs)
|
||||
end
|
||||
else
|
||||
self:drawBattlerPic(self.player, 16 + sx,
|
||||
self:drawBattlerPic(self.player, px,
|
||||
96 - (img:getHeight() - pad) * 2 + sy, 2)
|
||||
end
|
||||
end
|
||||
@@ -3790,16 +3989,22 @@ function BattleState:drawHUDs(slide)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(("BALLx%2d"):format(self.safari.balls), 88, 72)
|
||||
end
|
||||
-- trainer-battle party pokeball rows during the intro
|
||||
-- trainer/link party pokeball rows during the intro
|
||||
-- (SetupPlayerAndEnemyPokeballs, draw_hud_pokeball_gfx.asm)
|
||||
if self.kind == "trainer" and (self.showEnemyTrainer or self.showPlayerBack)
|
||||
and slide == 0 then
|
||||
local showIntroBalls = slide == 0 and (
|
||||
(self.kind == "trainer" and (self.showEnemyTrainer or self.showPlayerBack))
|
||||
or (self.kind == "link" and (self.showPlayerBack or self.enemySendingOut))
|
||||
)
|
||||
if showIntroBalls then
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
if self.showEnemyTrainer and self.enemyParty then
|
||||
if self.enemyParty and (
|
||||
(self.kind == "trainer" and self.showEnemyTrainer)
|
||||
or (self.kind == "link" and self.enemySendingOut)
|
||||
) then
|
||||
self:drawBallRow(self.enemyParty, 64, 16, -8)
|
||||
end
|
||||
if self.showPlayerBack then
|
||||
self:drawBallRow(self.game.save.party, 88, 80, 8)
|
||||
self:drawBallRow(self.playerParty or self.game.save.party, 88, 80, 8)
|
||||
end
|
||||
end
|
||||
local hidePlayer = self.safari or self.demo
|
||||
@@ -3833,7 +4038,8 @@ function BattleState:drawTextArea()
|
||||
if self.phase == "messages" and self.current then
|
||||
local shown = 0
|
||||
for li, codes in ipairs(self.lines) do
|
||||
local y = 104 + li * 8
|
||||
-- battle text uses every other tile row (hlcoord *,14 / *,16)
|
||||
local y = 112 + (li - 1) * 16
|
||||
for i = 1, #codes do
|
||||
if shown >= self.charIndex then break end
|
||||
Font.drawCode(codes[i], 8 + (i - 1) * 8, y)
|
||||
|
||||
@@ -176,25 +176,51 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
end
|
||||
battle.lastDamage = dmg -- wDamage (shared by both sides, read by Counter)
|
||||
|
||||
-- the hit blink + damage sound ride the queue behind the animation:
|
||||
-- on the move's anim row when one was announced, else on a bare hit
|
||||
-- row (thrash/rage continuations), placed BEFORE the drain rows the
|
||||
-- hits loop inserts so the blink precedes the bar drain
|
||||
local hitRow = battle.moveAnimRow
|
||||
if not hitRow then
|
||||
battle.nextInsert = (battle.nextInsert or 0) + 1
|
||||
hitRow = { hitRow = true }
|
||||
table.insert(battle.queue, battle.nextInsert, hitRow)
|
||||
end
|
||||
-- the hit blink + damage sound ride each animation row, placed BEFORE
|
||||
-- that hit's drain so the blink precedes the bar. Multi-hit moves
|
||||
-- replay PlayMoveAnimation per strike (pokered: GetPlayerAnimationType
|
||||
-- / GetEnemyAnimationType loop on wNumAttacksLeft); hit 1 reuses the
|
||||
-- announcement-time moveAnimRow, later hits queue fresh anim rows.
|
||||
-- Thrash/rage continuations have no announcement anim -- a bare
|
||||
-- hitRow carries the blink instead.
|
||||
local hitSfx = info.typeMult > 10 and "Super_Effective"
|
||||
or info.typeMult < 10 and "Not_Very_Effective" or "Damage"
|
||||
local hitFx = { sfx = hitSfx,
|
||||
blink = battle:animationsOn() and target or nil }
|
||||
|
||||
local totalDealt = 0
|
||||
local landed, brokeSub = 0, false
|
||||
for h = 1, hits do
|
||||
if target.mon.hp <= 0 then break end
|
||||
local hitRow
|
||||
if h == 1 then
|
||||
hitRow = battle.moveAnimRow
|
||||
if not hitRow then
|
||||
battle.nextInsert = (battle.nextInsert or 0) + 1
|
||||
hitRow = { hitRow = true }
|
||||
table.insert(battle.queue, battle.nextInsert, hitRow)
|
||||
end
|
||||
else
|
||||
battle.nextInsert = (battle.nextInsert or 0) + 1
|
||||
hitRow = { anim = move.id, attackerIsPlayer = user.isPlayer }
|
||||
table.insert(battle.queue, battle.nextInsert, hitRow)
|
||||
end
|
||||
local hadSub = target.substituteHP ~= nil
|
||||
local dealt = battle:applyDamage(target, dmg)
|
||||
totalDealt = totalDealt + dealt
|
||||
landed = h
|
||||
if dealt > 0 then hitRow.hit = hitFx end
|
||||
-- PrintCriticalOHKOText + DisplayEffectiveness run inside the
|
||||
-- multi-hit loop (core.asm .moveDidNotMiss before the jump back
|
||||
-- to GetPlayerAnimationType), so crit/effectiveness reprint on
|
||||
-- every strike -- damage was only rolled once
|
||||
if info.crit then battle:sayNext("Critical hit!") end
|
||||
if info.ohko then battle:sayNext("One-hit KO!") end
|
||||
if info.typeMult > 10 then
|
||||
battle:sayNext("It's super\neffective!")
|
||||
elseif info.typeMult < 10 then
|
||||
battle:sayNext("It's not very\neffective...")
|
||||
end
|
||||
if Runtime.wants("battle.damage_dealt") then
|
||||
Runtime.emit("battle.damage_dealt", {
|
||||
battle = battle, user = user, target = target, move = move,
|
||||
@@ -208,23 +234,6 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
end
|
||||
end
|
||||
hits = landed > 0 and landed or hits
|
||||
if totalDealt > 0 then
|
||||
-- the original's per-hit sound: normal / super / not-very-effective
|
||||
local hitSfx = info.typeMult > 10 and "Super_Effective"
|
||||
or info.typeMult < 10 and "Not_Very_Effective" or "Damage"
|
||||
hitRow.hit = { sfx = hitSfx,
|
||||
blink = battle:animationsOn() and target or nil }
|
||||
end
|
||||
-- PrintCriticalOHKOText prints "Critical hit!"/"One-hit KO!" right
|
||||
-- after the damage lands, BEFORE DisplayEffectiveness (core.asm
|
||||
-- .moveDidNotMiss); the multi-hit count follows the last hit
|
||||
if info.crit then battle:sayNext("Critical hit!") end
|
||||
if info.ohko then battle:sayNext("One-hit KO!") end
|
||||
if info.typeMult > 10 then
|
||||
battle:sayNext("It's super\neffective!")
|
||||
elseif info.typeMult < 10 then
|
||||
battle:sayNext("It's not very\neffective...")
|
||||
end
|
||||
if hits > 1 then
|
||||
-- player: _MultiHitText; enemy: _HitXTimesText (always plural)
|
||||
if user.isPlayer then
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
-- (super-effective) or adds 1 (not-effective when a better move exists);
|
||||
-- the MINIMUM-scored move is chosen, ties broken uniformly among the
|
||||
-- tied minima (core.asm:2971-3002). A non-minimal move is never
|
||||
-- selectable. Respects PP, Disable and Transform/Mimic move overrides.
|
||||
-- selectable. Respects Disable (and PP only when the ruleset depletes
|
||||
-- enemy PP — Gen 1 AI never reads wEnemyMonPP).
|
||||
|
||||
local TypeChart = require("src.battle.TypeChart")
|
||||
|
||||
@@ -212,9 +213,13 @@ end
|
||||
|
||||
function TrainerAI.chooseMove(battler, rng, battle)
|
||||
rng = rng or love.math.random
|
||||
-- Gen 1: SelectEnemyMove never consults wEnemyMonPP; Struggle only when
|
||||
-- every move slot is missing/disabled (core.asm:2957-2999). modern_clean
|
||||
-- depletes enemy PP and falls back to Struggle when none remain.
|
||||
local unlimited = battle and battle.ruleset and battle.ruleset.enemyUnlimitedPP
|
||||
local usable = {}
|
||||
for i, mv in ipairs(battler.curMoves) do
|
||||
if mv.pp > 0 and battler.disabledSlot ~= i then
|
||||
if battler.disabledSlot ~= i and (unlimited or mv.pp > 0) then
|
||||
table.insert(usable, mv)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -13,4 +13,7 @@ return {
|
||||
randMax = 255,
|
||||
-- Focus Energy famously QUARTERS the crit rate instead of x4
|
||||
focusEnergyBug = true,
|
||||
-- Wild/trainer enemies never spend PP (DecrementPP only touches the
|
||||
-- player side in pokered). They therefore never Struggle from empty PP.
|
||||
enemyUnlimitedPP = true,
|
||||
}
|
||||
|
||||
@@ -9,4 +9,6 @@ return {
|
||||
randMin = 217,
|
||||
randMax = 255,
|
||||
focusEnergyBug = false,
|
||||
-- Gen 2+ style: AI opponents deplete PP and can Struggle when empty.
|
||||
enemyUnlimitedPP = false,
|
||||
}
|
||||
|
||||
+316
-801
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,760 @@
|
||||
-- Pure Game Boy audio synthesis (the DMG/GBC channel-program interpreter and
|
||||
-- PCM renderer), factored out of ChipAudio so it can run on EITHER the main
|
||||
-- thread (SFX/cries, and the synchronous music fallback) or the ChipAudio
|
||||
-- worker thread (src/core/chip_worker.lua), which is where map/battle music is
|
||||
-- synthesized so a song change never stutters the render thread.
|
||||
--
|
||||
-- Deliberately depends ONLY on `bit`, love.sound and love.filesystem: no
|
||||
-- love.audio (Sources are a playback concern the caller owns) and no
|
||||
-- src.render.Assets (hot-reload registration stays in ChipAudio). Both of
|
||||
-- those are unavailable or main-thread-only inside a love.thread worker, so
|
||||
-- keeping them out is what lets the same synth code run in the worker.
|
||||
|
||||
local bit = require("bit")
|
||||
|
||||
local ChipSynth = {}
|
||||
|
||||
local SAMPLE_RATE = 22050
|
||||
local TICKS_PER_SECOND = 15360
|
||||
local FRAME_TICKS = 256
|
||||
local GB_CLOCK = 4194304
|
||||
|
||||
-- one 4096-sample stereo SoundData is the unit both the worker hands off and
|
||||
-- the synchronous fallback queues; the source keeps MUSIC_BUFFER_COUNT of them
|
||||
-- (~6s) for stall tolerance (window resize, a long GC pause)
|
||||
local MUSIC_BUFFER_SAMPLES = 4096
|
||||
local MUSIC_BUFFER_COUNT = 32
|
||||
|
||||
ChipSynth.SAMPLE_RATE = SAMPLE_RATE
|
||||
ChipSynth.MUSIC_BUFFER_SAMPLES = MUSIC_BUFFER_SAMPLES
|
||||
ChipSynth.MUSIC_BUFFER_COUNT = MUSIC_BUFFER_COUNT
|
||||
|
||||
local PITCHES = {
|
||||
0xF82C, 0xF89D, 0xF907, 0xF96B, 0xF9CA, 0xFA23,
|
||||
0xFA77, 0xFAC7, 0xFB12, 0xFB58, 0xFB9B, 0xFBDA,
|
||||
}
|
||||
local DUTY = { [0] = 0.125, [1] = 0.25, [2] = 0.5, [3] = 0.75 }
|
||||
local WAVE_LEVEL = { [0] = 0, [1] = 1, [2] = 0.5, [3] = 0.25 }
|
||||
local NOISE_DIVISORS = {
|
||||
[0] = 8, [1] = 16, [2] = 32, [3] = 48,
|
||||
[4] = 64, [5] = 80, [6] = 96, [7] = 112,
|
||||
}
|
||||
|
||||
local function snapTicks(ticks)
|
||||
return math.floor((ticks * 735 + 256) / 512)
|
||||
end
|
||||
|
||||
local cachedProgramFile
|
||||
local cachedBanks
|
||||
|
||||
local function loadBanks(data)
|
||||
local audio = data.audio
|
||||
if cachedProgramFile == audio.programFile and cachedBanks then
|
||||
return cachedBanks
|
||||
end
|
||||
local raw, readError = love.filesystem.read(audio.programFile)
|
||||
if not raw then error("could not read sound programs: " .. tostring(readError)) end
|
||||
local banks = {}
|
||||
for index, bank in ipairs(audio.bankOrder) do
|
||||
local first = (index - 1) * 0x4000 + 1
|
||||
banks[bank] = raw:sub(first, first + 0x3FFF)
|
||||
end
|
||||
cachedProgramFile, cachedBanks = audio.programFile, banks
|
||||
return banks
|
||||
end
|
||||
|
||||
-- drop the single-slot bank cache; the worker keeps its own copy of this
|
||||
-- module's state, so ChipAudio.invalidate must reach it via a worker message
|
||||
function ChipSynth.invalidateBanks()
|
||||
cachedProgramFile, cachedBanks = nil, nil
|
||||
end
|
||||
|
||||
-- A def-local program (ChipAsm output) is mounted as pseudo-bank 0 next to
|
||||
-- the ROM banks, so the 0x4000-window byte reader and every call/loop
|
||||
-- target work unchanged. The ROM's own cached bank table is never touched
|
||||
-- because bank 0 differs per def, and a blob that carries its own waves and
|
||||
-- drums renders even where programs.bin is unreadable.
|
||||
local function engineBanks(data, chip)
|
||||
if not chip then return loadBanks(data) end
|
||||
local banks = {}
|
||||
local ok, romBanks = pcall(loadBanks, data)
|
||||
if ok then
|
||||
for bank, bytes in pairs(romBanks) do banks[bank] = bytes end
|
||||
end
|
||||
banks[0] = chip.blob
|
||||
return banks
|
||||
end
|
||||
|
||||
local function romByte(banks, bank, address)
|
||||
local bytes = assert(banks[bank], "uncached audio bank " .. tostring(bank))
|
||||
local value = bytes:byte(address - 0x4000 + 1)
|
||||
if not value then
|
||||
error(("audio read outside bank %02X:%04X"):format(bank, address))
|
||||
end
|
||||
return value
|
||||
end
|
||||
|
||||
local function romWord(banks, bank, address)
|
||||
return romByte(banks, bank, address)
|
||||
+ romByte(banks, bank, address + 1) * 0x100
|
||||
end
|
||||
|
||||
local function headerChannels(banks, header)
|
||||
local channels = {}
|
||||
local address = header.address
|
||||
local first = romByte(banks, header.bank, address)
|
||||
local count = bit.rshift(bit.band(first, 0xF0), 6) + 1
|
||||
for _ = 1, count do
|
||||
local descriptor = romByte(banks, header.bank, address)
|
||||
channels[#channels + 1] = {
|
||||
number = bit.band(descriptor, 0x0F) + 1,
|
||||
address = romWord(banks, header.bank, address + 1),
|
||||
}
|
||||
address = address + 3
|
||||
end
|
||||
return channels
|
||||
end
|
||||
|
||||
local function fadeValue(nibble)
|
||||
if bit.band(nibble, 8) ~= 0 then return -bit.band(nibble, 7) end
|
||||
return nibble
|
||||
end
|
||||
|
||||
local Channel = {}
|
||||
Channel.__index = Channel
|
||||
|
||||
function Channel.new(engine, spec, options)
|
||||
options = options or {}
|
||||
local hardware = (spec.number - 1) % 4 + 1
|
||||
local isSfxChannel = spec.number > 4
|
||||
return setmetatable({
|
||||
engine = engine,
|
||||
bank = options.bank,
|
||||
address = spec.address,
|
||||
number = spec.number,
|
||||
hardware = hardware,
|
||||
wave = hardware == 3,
|
||||
noise = hardware == 4,
|
||||
sfx = isSfxChannel,
|
||||
executeMusic = not isSfxChannel,
|
||||
allowLoops = options.allowLoops ~= false,
|
||||
frequencyOffset = options.frequencyOffset or 0,
|
||||
frameTicks = options.frameTicks or FRAME_TICKS,
|
||||
speed = 12,
|
||||
volume = 12,
|
||||
fade = 0,
|
||||
duty = 0.5,
|
||||
octave = 4,
|
||||
waveInstrument = 0,
|
||||
waveLevel = 1,
|
||||
perfectPitch = false,
|
||||
vibrato = nil,
|
||||
pendingSlide = nil,
|
||||
sweep = nil,
|
||||
callStack = {},
|
||||
loopCounts = {},
|
||||
event = nil,
|
||||
ended = false,
|
||||
phase = 0,
|
||||
noiseLfsr = 0x7FFF,
|
||||
noiseClock = 0,
|
||||
timeTicks = 0,
|
||||
}, Channel)
|
||||
end
|
||||
|
||||
function Channel:byte()
|
||||
local value = romByte(self.engine.banks, self.bank, self.address)
|
||||
self.address = self.address + 1
|
||||
return value
|
||||
end
|
||||
|
||||
function Channel:word()
|
||||
local value = romWord(self.engine.banks, self.bank, self.address)
|
||||
self.address = self.address + 2
|
||||
return value
|
||||
end
|
||||
|
||||
function Channel:frequency(note, octave)
|
||||
local signed = PITCHES[note + 1] - 0x10000
|
||||
local register = bit.band(
|
||||
bit.arshift(signed, math.max(0, (octave or self.octave) - 1)), 0x7FF)
|
||||
if self.perfectPitch then register = bit.band(register + 1, 0x7FF) end
|
||||
return bit.band(register + self.frequencyOffset, 0x7FF)
|
||||
end
|
||||
|
||||
function Channel:durationTicks(length)
|
||||
local tempo = self.sfx and self.frameTicks or self.engine.tempo
|
||||
local speed = self.sfx and (self.executeMusic and self.speed or 1)
|
||||
or self.speed
|
||||
return length * speed * tempo
|
||||
end
|
||||
|
||||
function Channel:timedEvent(event, ticks)
|
||||
local first = snapTicks(self.timeTicks)
|
||||
self.timeTicks = self.timeTicks + ticks
|
||||
event.duration = ticks / TICKS_PER_SECOND
|
||||
event.samples = snapTicks(self.timeTicks) - first
|
||||
event.sample = 0
|
||||
event.elapsed = 0
|
||||
return event
|
||||
end
|
||||
|
||||
function Channel:pan()
|
||||
local mask = bit.lshift(1, self.hardware - 1)
|
||||
return bit.band(bit.rshift(self.engine.pan, 4), mask) ~= 0,
|
||||
bit.band(self.engine.pan, mask) ~= 0
|
||||
end
|
||||
|
||||
function Channel:tone(ticks, register, volume, fade)
|
||||
if register >= 0x800 then
|
||||
return self:timedEvent({ silence = true }, ticks)
|
||||
end
|
||||
local duration = ticks / TICKS_PER_SECOND
|
||||
local panLeft, panRight = self:pan()
|
||||
local slide
|
||||
if self.pendingSlide then
|
||||
slide = {
|
||||
target = self.pendingSlide.target,
|
||||
frames = math.max(1, duration * 60 - self.pendingSlide.length),
|
||||
}
|
||||
self.pendingSlide = nil
|
||||
end
|
||||
return self:timedEvent({
|
||||
register = register,
|
||||
volume = volume == nil and self.volume or volume,
|
||||
fade = fade == nil and self.fade or fade,
|
||||
duty = self.duty,
|
||||
wave = self.wave,
|
||||
waveInstrument = self.waveInstrument,
|
||||
waveLevel = self.waveLevel,
|
||||
vibrato = slide and nil or self.vibrato,
|
||||
slide = slide,
|
||||
sweep = self.sfx and self.hardware == 1 and self.sweep or nil,
|
||||
panLeft = panLeft,
|
||||
panRight = panRight,
|
||||
}, ticks)
|
||||
end
|
||||
|
||||
function Channel:noiseEvent(ticks, volume, fade, parameter)
|
||||
local panLeft, panRight = self:pan()
|
||||
return self:timedEvent({
|
||||
noise = true,
|
||||
volume = volume or self.volume,
|
||||
fade = fade or 0,
|
||||
noiseParameter = parameter,
|
||||
panLeft = panLeft, panRight = panRight,
|
||||
}, ticks)
|
||||
end
|
||||
|
||||
function Channel:drumEvent(ticks, instrument)
|
||||
local panLeft, panRight = self:pan()
|
||||
return self:timedEvent({
|
||||
noise = true,
|
||||
drum = self.engine:noiseInstrument(instrument),
|
||||
panLeft = panLeft,
|
||||
panRight = panRight,
|
||||
}, ticks)
|
||||
end
|
||||
|
||||
function Channel:silenceEvent(ticks)
|
||||
return self:timedEvent({ silence = true }, ticks)
|
||||
end
|
||||
|
||||
function Channel:nextEvent()
|
||||
if self.ended then return nil end
|
||||
for _ = 1, 100000 do
|
||||
local commandAddress = self.address
|
||||
local command = self:byte()
|
||||
|
||||
if (self.executeMusic or not self.sfx) and command < 0xC0 then
|
||||
local note = bit.rshift(command, 4)
|
||||
local length = bit.band(command, 0x0F) + 1
|
||||
if self.noise then
|
||||
local instrument = note
|
||||
if command >= 0xB0 then instrument = self:byte() end
|
||||
return self:drumEvent(self:durationTicks(length), instrument)
|
||||
end
|
||||
return self:tone(self:durationTicks(length), self:frequency(note))
|
||||
elseif command >= 0xC0 and command < 0xD0 then
|
||||
local length = bit.band(command, 0x0F) + 1
|
||||
return self:silenceEvent(self:durationTicks(length))
|
||||
elseif command >= 0xD0 and command < 0xE0 then
|
||||
self.speed = bit.band(command, 0x0F)
|
||||
if not self.noise then
|
||||
local packed = self:byte()
|
||||
if self.wave then
|
||||
self.waveLevel = WAVE_LEVEL[bit.band(bit.rshift(packed, 4), 3)]
|
||||
self.waveInstrument = bit.band(packed, 0x0F)
|
||||
else
|
||||
self.volume = bit.rshift(packed, 4)
|
||||
self.fade = fadeValue(bit.band(packed, 0x0F))
|
||||
end
|
||||
end
|
||||
elseif command >= 0xE0 and command <= 0xE7 then
|
||||
self.octave = 8 - bit.band(command, 7)
|
||||
elseif command == 0xE8 then
|
||||
self.perfectPitch = not self.perfectPitch
|
||||
elseif command == 0xE9 then
|
||||
-- Unused command.
|
||||
elseif command == 0xEA then
|
||||
local delay, packed = self:byte(), self:byte()
|
||||
local depth = bit.rshift(packed, 4)
|
||||
if depth == 0 then
|
||||
self.vibrato = nil
|
||||
else
|
||||
self.vibrato = {
|
||||
delay = delay,
|
||||
above = bit.rshift(depth, 1) + bit.band(depth, 1),
|
||||
below = bit.rshift(depth, 1),
|
||||
rate = bit.band(packed, 0x0F),
|
||||
}
|
||||
end
|
||||
elseif command == 0xEB then
|
||||
local length, packed = self:byte(), self:byte()
|
||||
local octave = 8 - bit.rshift(packed, 4)
|
||||
self.pendingSlide = {
|
||||
length = length,
|
||||
target = self:frequency(bit.band(packed, 0x0F), octave),
|
||||
}
|
||||
elseif command == 0xEC then
|
||||
self.duty = DUTY[bit.band(self:byte(), 3)] or 0.5
|
||||
elseif command == 0xED then
|
||||
self.engine.tempo = self:byte() * 0x100 + self:byte()
|
||||
elseif command == 0xEE then
|
||||
self.engine.pan = self:byte()
|
||||
elseif command == 0xEF or command == 0xF0 then
|
||||
self:byte()
|
||||
elseif command == 0xF8 then
|
||||
self.executeMusic = not self.executeMusic
|
||||
elseif command == 0xFC then
|
||||
local packed = self:byte()
|
||||
self.duty = {
|
||||
DUTY[bit.band(bit.rshift(packed, 6), 3)],
|
||||
DUTY[bit.band(bit.rshift(packed, 4), 3)],
|
||||
DUTY[bit.band(bit.rshift(packed, 2), 3)],
|
||||
DUTY[bit.band(packed, 3)],
|
||||
}
|
||||
elseif command == 0xFD then
|
||||
self.callStack[#self.callStack + 1] = self.address + 2
|
||||
self.address = self:word()
|
||||
elseif command == 0xFE then
|
||||
local count, target = self:byte(), self:word()
|
||||
if count == 0 then
|
||||
if self.allowLoops then
|
||||
self.address = target
|
||||
else
|
||||
self.ended = true
|
||||
return nil
|
||||
end
|
||||
else
|
||||
local remaining = self.loopCounts[commandAddress]
|
||||
if remaining == nil then remaining = count end
|
||||
remaining = remaining - 1
|
||||
if remaining > 0 then
|
||||
self.loopCounts[commandAddress] = remaining
|
||||
self.address = target
|
||||
else
|
||||
self.loopCounts[commandAddress] = nil
|
||||
end
|
||||
end
|
||||
elseif command == 0xFF then
|
||||
local returnAddress = table.remove(self.callStack)
|
||||
if returnAddress then
|
||||
self.address = returnAddress
|
||||
else
|
||||
self.ended = true
|
||||
return nil
|
||||
end
|
||||
elseif self.sfx and command >= 0x20 and command < 0x30 then
|
||||
local length = bit.band(command, 0x0F) + 1
|
||||
local packed = self:byte()
|
||||
local volume = bit.rshift(packed, 4)
|
||||
local fade = fadeValue(bit.band(packed, 0x0F))
|
||||
if self.noise then
|
||||
local parameter = self:byte()
|
||||
return self:noiseEvent(
|
||||
self:durationTicks(length), volume, fade, parameter)
|
||||
end
|
||||
local register = bit.band(self:word() + self.frequencyOffset, 0x7FF)
|
||||
return self:tone(self:durationTicks(length), register, volume, fade)
|
||||
elseif command == 0x10 then
|
||||
local packed = self:byte()
|
||||
self.sweep = {
|
||||
pace = bit.band(bit.rshift(packed, 4), 7),
|
||||
subtract = bit.band(packed, 8) ~= 0,
|
||||
shift = bit.band(packed, 7),
|
||||
}
|
||||
else
|
||||
self.ended = true
|
||||
return nil
|
||||
end
|
||||
end
|
||||
self.ended = true
|
||||
return nil
|
||||
end
|
||||
|
||||
local function envelopeVolume(volume, fade, elapsed)
|
||||
if fade == 0 then return volume end
|
||||
local steps = math.floor(elapsed / (math.abs(fade) / 64))
|
||||
if fade > 0 then return math.max(0, volume - steps) end
|
||||
return math.min(15, volume + steps)
|
||||
end
|
||||
|
||||
function Channel:resetNoise()
|
||||
self.noiseLfsr = 0x7FFF
|
||||
self.noiseClock = 0
|
||||
end
|
||||
|
||||
function Channel:clockNoise(width7)
|
||||
local feedback = bit.bxor(
|
||||
bit.band(self.noiseLfsr, 1),
|
||||
bit.band(bit.rshift(self.noiseLfsr, 1), 1))
|
||||
self.noiseLfsr = bit.bor(
|
||||
bit.rshift(self.noiseLfsr, 1),
|
||||
bit.lshift(feedback, 14))
|
||||
if width7 then
|
||||
self.noiseLfsr = bit.bor(
|
||||
bit.band(self.noiseLfsr, bit.bnot(0x40)),
|
||||
bit.lshift(feedback, 6))
|
||||
end
|
||||
end
|
||||
|
||||
function Channel:sampleNoise(parameter)
|
||||
parameter = parameter or 0
|
||||
local divisor = NOISE_DIVISORS[bit.band(parameter, 7)]
|
||||
local shift = bit.rshift(parameter, 4)
|
||||
local output = bit.band(self.noiseLfsr, 1) == 0 and 1 or -1
|
||||
if shift >= 14 then return output end
|
||||
local cycles = GB_CLOCK / divisor / (2 ^ shift) / SAMPLE_RATE
|
||||
local width7 = bit.band(parameter, 8) ~= 0
|
||||
local remaining = cycles
|
||||
local area = 0
|
||||
|
||||
while remaining > 0 do
|
||||
local untilClock = 1 - self.noiseClock
|
||||
local span = math.min(remaining, untilClock)
|
||||
output = bit.band(self.noiseLfsr, 1) == 0 and 1 or -1
|
||||
area = area + output * span
|
||||
self.noiseClock = self.noiseClock + span
|
||||
remaining = remaining - span
|
||||
if self.noiseClock >= 1 - 1e-12 then
|
||||
self.noiseClock = 0
|
||||
self:clockNoise(width7)
|
||||
end
|
||||
end
|
||||
|
||||
return area / cycles
|
||||
end
|
||||
|
||||
local function sweepCalculation(register, sweep)
|
||||
local delta = math.floor(register / (2 ^ sweep.shift))
|
||||
if sweep.subtract then return register - delta end
|
||||
return register + delta
|
||||
end
|
||||
|
||||
local function sweptRegister(register, sweep, elapsed)
|
||||
if not sweep or sweep.shift == 0 then return register end
|
||||
local nextRegister = sweepCalculation(register, sweep)
|
||||
if nextRegister > 0x7FF or nextRegister < 0 then return nil end
|
||||
if sweep.pace == 0 then return register end
|
||||
|
||||
local iterations = math.floor(elapsed * 128 / sweep.pace)
|
||||
for _ = 1, iterations do
|
||||
register = nextRegister
|
||||
nextRegister = sweepCalculation(register, sweep)
|
||||
if nextRegister > 0x7FF or nextRegister < 0 then return nil end
|
||||
end
|
||||
return register
|
||||
end
|
||||
|
||||
function Channel:sampleDrum(event, sampleIndex)
|
||||
local index = event.drumSegmentIndex or 1
|
||||
local segment = event.drum[index]
|
||||
while segment and sampleIndex >= segment.endSample do
|
||||
index = index + 1
|
||||
segment = event.drum[index]
|
||||
end
|
||||
if not segment or sampleIndex < segment.startSample then return 0 end
|
||||
if event.drumSegmentIndex ~= index then
|
||||
event.drumSegmentIndex = index
|
||||
self:resetNoise()
|
||||
end
|
||||
local elapsed = (sampleIndex - segment.startSample) / SAMPLE_RATE
|
||||
local volume = envelopeVolume(segment.volume, segment.fade, elapsed)
|
||||
return self:sampleNoise(segment.parameter) * volume / 15 * 0.35
|
||||
end
|
||||
|
||||
function Channel:sample()
|
||||
while not self.ended
|
||||
and (not self.event or self.event.sample >= self.event.samples) do
|
||||
self.event = self:nextEvent()
|
||||
self.phase = 0
|
||||
self:resetNoise()
|
||||
end
|
||||
local event = self.event
|
||||
if not event then return 0 end
|
||||
local sampleIndex = event.sample
|
||||
event.elapsed = sampleIndex / SAMPLE_RATE
|
||||
event.sample = sampleIndex + 1
|
||||
if event.silence then return 0 end
|
||||
|
||||
if event.drum then return self:sampleDrum(event, sampleIndex) end
|
||||
local volume = envelopeVolume(
|
||||
event.volume or 0, event.fade or 0, event.elapsed)
|
||||
if event.noise then
|
||||
return self:sampleNoise(event.noiseParameter) * volume / 15 * 0.35
|
||||
end
|
||||
|
||||
local register = event.register
|
||||
local frame = math.floor(event.elapsed * 60)
|
||||
if event.sweep then
|
||||
register = sweptRegister(register, event.sweep, event.elapsed)
|
||||
if not register then return 0 end
|
||||
elseif event.slide then
|
||||
local amount = math.min(1, frame / event.slide.frames)
|
||||
register = register + (event.slide.target - register) * amount
|
||||
elseif event.vibrato and frame >= event.vibrato.delay then
|
||||
local vibrato = event.vibrato
|
||||
local toggles = math.floor(
|
||||
(frame - vibrato.delay + 1) / (vibrato.rate + 1))
|
||||
if toggles > 0 then
|
||||
local low = bit.band(register, 0xFF)
|
||||
local high = bit.band(register, 0x700)
|
||||
if bit.band(toggles, 1) ~= 0 then
|
||||
register = high + math.min(0xFF, low + vibrato.above)
|
||||
else
|
||||
register = high + math.max(0, low - vibrato.below)
|
||||
end
|
||||
end
|
||||
end
|
||||
local frequency = 131072 / (2048 - math.min(register, 2047))
|
||||
if event.wave then frequency = frequency * 0.5 end
|
||||
local phase = self.phase
|
||||
self.phase = (phase + frequency / SAMPLE_RATE) % 1
|
||||
if event.wave then
|
||||
local wave = self.engine.waves[
|
||||
math.min(event.waveInstrument + 1, #self.engine.waves)]
|
||||
-- a def-local program may omit its wave table entirely
|
||||
if not wave then return 0 end
|
||||
local index = math.min(32, math.floor(phase * 32) + 1)
|
||||
return wave[index] * event.waveLevel * 0.55
|
||||
end
|
||||
local duty = event.duty
|
||||
if type(duty) == "table" then
|
||||
duty = duty[frame % 4 + 1]
|
||||
end
|
||||
return (phase < duty and 1 or -1) * volume / 15 * 0.5
|
||||
end
|
||||
|
||||
local Engine = {}
|
||||
Engine.__index = Engine
|
||||
|
||||
function Engine:noiseInstrument(number)
|
||||
-- a def-local drum wins over the ROM engine's table for that id
|
||||
local custom = self.customDrums and self.customDrums[number]
|
||||
if custom then return custom end
|
||||
local cached = self.noiseInstruments[number]
|
||||
if cached then return cached end
|
||||
|
||||
local header = self.noiseHeaders[tostring(number)]
|
||||
local segments = {}
|
||||
if header then
|
||||
local spec = headerChannels(self.banks, header)[1]
|
||||
local address = spec and spec.address
|
||||
local ticks = 0
|
||||
for _ = 1, 64 do
|
||||
local command = romByte(self.banks, header.bank, address)
|
||||
address = address + 1
|
||||
if command == 0xFF then break end
|
||||
if command < 0x20 or command >= 0x30 then
|
||||
error(("unsupported drum command %02X at %02X:%04X")
|
||||
:format(command, header.bank, address - 1))
|
||||
end
|
||||
local packed = romByte(self.banks, header.bank, address)
|
||||
local parameter = romByte(self.banks, header.bank, address + 1)
|
||||
address = address + 2
|
||||
local duration = (bit.band(command, 0x0F) + 1) * FRAME_TICKS
|
||||
segments[#segments + 1] = {
|
||||
startSample = snapTicks(ticks),
|
||||
endSample = snapTicks(ticks + duration),
|
||||
volume = bit.rshift(packed, 4),
|
||||
fade = fadeValue(bit.band(packed, 0x0F)),
|
||||
parameter = parameter,
|
||||
}
|
||||
ticks = ticks + duration
|
||||
end
|
||||
end
|
||||
|
||||
self.noiseInstruments[number] = segments
|
||||
return segments
|
||||
end
|
||||
|
||||
local function readWaves(banks, audio, engineNumber)
|
||||
local spec = audio.waveBanks[tostring(engineNumber)]
|
||||
local waves = {}
|
||||
for wave = 0, 4 do
|
||||
local values = {}
|
||||
for byteIndex = 0, 15 do
|
||||
local packed = romByte(
|
||||
banks, spec.bank, spec.address + wave * 16 + byteIndex)
|
||||
values[#values + 1] = (bit.rshift(packed, 4) - 7.5) / 7.5
|
||||
values[#values + 1] = (bit.band(packed, 0x0F) - 7.5) / 7.5
|
||||
end
|
||||
waves[#waves + 1] = values
|
||||
end
|
||||
local values = {}
|
||||
for byteIndex = 0, 15 do
|
||||
local packed = romByte(
|
||||
banks, spec.bank, spec.address + 5 * 16 + byteIndex)
|
||||
values[#values + 1] = (bit.rshift(packed, 4) - 7.5) / 7.5
|
||||
values[#values + 1] = (bit.band(packed, 0x0F) - 7.5) / 7.5
|
||||
end
|
||||
for _ = 1, 4 do waves[#waves + 1] = values end
|
||||
return waves
|
||||
end
|
||||
|
||||
-- def-local waves are authored either as raw 0-15 nibbles (the ROM's own
|
||||
-- units) or as the -1..1 samples readWaves produces; the synth wants the
|
||||
-- latter
|
||||
local function normalizeWaves(source)
|
||||
local waves = {}
|
||||
for index, values in ipairs(source) do
|
||||
local nibbles = false
|
||||
for _, value in ipairs(values) do
|
||||
if value > 1 or value < -1 then nibbles = true break end
|
||||
end
|
||||
local wave = {}
|
||||
for position, value in ipairs(values) do
|
||||
wave[position] = nibbles and (value - 7.5) / 7.5 or value
|
||||
end
|
||||
waves[index] = wave
|
||||
end
|
||||
return waves
|
||||
end
|
||||
|
||||
function Engine.new(data, header, options)
|
||||
options = options or {}
|
||||
local audio = data.audio or {}
|
||||
-- shape dispatch: a def-local chip program supplies its own channels and
|
||||
-- may supply its own waves/drums, falling back to a ROM engine's tables
|
||||
local chip = header.chip
|
||||
local banks = engineBanks(data, chip)
|
||||
local engineNumber = chip and (chip.engine or 1) or header.engine
|
||||
local waves
|
||||
if chip and chip.waves then
|
||||
waves = normalizeWaves(chip.waves)
|
||||
elseif chip then
|
||||
local ok, romWaves = pcall(readWaves, banks, audio, engineNumber)
|
||||
waves = ok and romWaves or {}
|
||||
else
|
||||
waves = readWaves(banks, audio, engineNumber)
|
||||
end
|
||||
local engine = setmetatable({
|
||||
banks = banks,
|
||||
tempo = 0x100,
|
||||
pan = 0xFF,
|
||||
waves = waves,
|
||||
noiseHeaders = audio.noiseHeaders
|
||||
and audio.noiseHeaders[tostring(engineNumber)] or {},
|
||||
customDrums = chip and chip.drums or nil,
|
||||
noiseInstruments = {},
|
||||
channels = {},
|
||||
}, Engine)
|
||||
for _, spec in ipairs(chip and chip.channels
|
||||
or headerChannels(banks, header)) do
|
||||
local frameTicks = options.frameTicks
|
||||
local hardware = (spec.number - 1) % 4 + 1
|
||||
if hardware == 4 then
|
||||
frameTicks = FRAME_TICKS
|
||||
elseif options.cryLength then
|
||||
frameTicks = 0x80 + options.cryLength
|
||||
end
|
||||
engine.channels[#engine.channels + 1] = Channel.new(engine, spec, {
|
||||
bank = chip and 0 or header.bank,
|
||||
sfx = options.sfx,
|
||||
allowLoops = options.allowLoops,
|
||||
frequencyOffset = options.frequencyOffset,
|
||||
frameTicks = frameTicks,
|
||||
})
|
||||
end
|
||||
return engine
|
||||
end
|
||||
|
||||
function Engine:finished()
|
||||
for _, channel in ipairs(self.channels) do
|
||||
if not channel.ended or channel.event then return false end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
function Engine:sample()
|
||||
local value = 0
|
||||
for _, channel in ipairs(self.channels) do value = value + channel:sample() end
|
||||
return math.max(-1, math.min(1, value * 0.5))
|
||||
end
|
||||
|
||||
function Engine:sampleStereo()
|
||||
local left, right = 0, 0
|
||||
for _, channel in ipairs(self.channels) do
|
||||
local value = channel:sample()
|
||||
local event = channel.event
|
||||
if not event or event.panLeft ~= false then left = left + value end
|
||||
if not event or event.panRight ~= false then right = right + value end
|
||||
end
|
||||
return math.max(-1, math.min(1, left * 0.5)),
|
||||
math.max(-1, math.min(1, right * 0.5))
|
||||
end
|
||||
|
||||
function Engine:sampleChannel(number)
|
||||
local selected = 0
|
||||
for _, channel in ipairs(self.channels) do
|
||||
local value = channel:sample()
|
||||
if channel.number == number then selected = value end
|
||||
end
|
||||
return math.max(-1, math.min(1, selected * 0.5))
|
||||
end
|
||||
|
||||
-- render `samples` frames into a fresh SoundData (mono or stereo). love.sound
|
||||
-- is available on worker threads, so this is the hand-off unit the worker
|
||||
-- produces and the main thread queues.
|
||||
local function soundData(engine, samples, channels)
|
||||
local result = love.sound.newSoundData(samples, SAMPLE_RATE, 16, channels)
|
||||
for index = 0, samples - 1 do
|
||||
if channels == 2 then
|
||||
local left, right = engine:sampleStereo()
|
||||
result:setSample(index, 1, left)
|
||||
result:setSample(index, 2, right)
|
||||
else
|
||||
result:setSample(index, engine:sample())
|
||||
end
|
||||
end
|
||||
return result
|
||||
end
|
||||
|
||||
-- Render a one-shot effect (SFX/cry) to a mono SoundData, or nil when it is
|
||||
-- too short to be audible. The caller wraps it in a static love.audio.Source
|
||||
-- (a playback concern, hence not done here).
|
||||
local function renderEffectData(data, header, options)
|
||||
if not header then return nil end
|
||||
options = options or {}
|
||||
options.sfx = true
|
||||
options.allowLoops = false
|
||||
local engine = Engine.new(data, header, options)
|
||||
local maximum = SAMPLE_RATE * 5
|
||||
local values = {}
|
||||
local count = 0
|
||||
while count < maximum and not engine:finished() do
|
||||
count = count + 1
|
||||
values[count] = engine:sample()
|
||||
end
|
||||
if count < math.floor(SAMPLE_RATE / 100) then return nil end
|
||||
local result = love.sound.newSoundData(count, SAMPLE_RATE, 16, 1)
|
||||
for index = 1, count do result:setSample(index - 1, values[index]) end
|
||||
return result
|
||||
end
|
||||
|
||||
ChipSynth.newEngine = Engine.new
|
||||
ChipSynth.soundData = soundData
|
||||
ChipSynth.renderEffectData = renderEffectData
|
||||
|
||||
return ChipSynth
|
||||
@@ -27,4 +27,12 @@ function FixedStep:update(dt)
|
||||
end
|
||||
end
|
||||
|
||||
-- Drop any pending catch-up steps. A hitch inside one logic step (map
|
||||
-- seam setMap / song start) makes the next real-time dt huge; without this
|
||||
-- the while-loop above would advance many walk frames before the next
|
||||
-- draw, which looks like a slide with no leg animation (issue #93).
|
||||
function FixedStep:discardCatchup()
|
||||
self.accum = 0
|
||||
end
|
||||
|
||||
return FixedStep
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
-- Render frame-rate cap. With a driver control panel forcing
|
||||
-- vsync off, the 160x144 game is trivially cheap and love.run will present
|
||||
-- thousands of frames a second; over hours that cooks the graphics driver
|
||||
-- until a restart, and it wastes power whenever the window is left open in
|
||||
-- the background. A hard cap bounds the present rate. Render-only: game
|
||||
-- logic is fixed-step off dt (src/core/FixedStep.lua), so pacing present()
|
||||
-- changes nothing about timing, audio, or determinism.
|
||||
--
|
||||
-- Persisted as save.options.fpsCap; applied from OptionsMenu and on boot
|
||||
-- via Game:applyOptions. main.lua's love.run reads FrameCap.current each
|
||||
-- frame for its sleep budget. The module never touches love.timer itself,
|
||||
-- so it stays safe under the headless test stub.
|
||||
|
||||
local FrameCap = {}
|
||||
|
||||
-- Selectable steps: the normal framerate stops between the floor and the
|
||||
-- ceiling. STEPS[1] == MIN and STEPS[#STEPS] == MAX, so the nearest-step
|
||||
-- snap in normalize doubles as the clamp. Cycling past the last wraps.
|
||||
FrameCap.STEPS = { 30, 40, 50, 60, 75, 90, 100, 120, 144, 160 }
|
||||
FrameCap.MIN = 30
|
||||
FrameCap.MAX = 160
|
||||
FrameCap.DEFAULT = 60
|
||||
|
||||
-- The live cap the run loop paces to. Defaults so the launcher and the
|
||||
-- save editor are paced before any save applies its stored option.
|
||||
FrameCap.current = FrameCap.DEFAULT
|
||||
|
||||
-- Nearest valid step for an arbitrary value (a hand-edited options.lua or
|
||||
-- an old save with no fpsCap key), so a bad number degrades to something
|
||||
-- sane; nil / non-numbers fall back to the default. A value below MIN or
|
||||
-- above MAX snaps to that end, since MIN/MAX are the first/last steps.
|
||||
function FrameCap.normalize(value)
|
||||
value = tonumber(value)
|
||||
if not value then return FrameCap.DEFAULT end
|
||||
local best, bestDiff = FrameCap.DEFAULT, math.huge
|
||||
for _, step in ipairs(FrameCap.STEPS) do
|
||||
local diff = math.abs(step - value)
|
||||
if diff < bestDiff then best, bestDiff = step, diff end
|
||||
end
|
||||
return best
|
||||
end
|
||||
|
||||
-- plain numeric text for the options row (e.g. "60")
|
||||
function FrameCap.label(value)
|
||||
return tostring(FrameCap.normalize(value))
|
||||
end
|
||||
|
||||
-- cycle to the next/previous step, wrapping (the options row idiom)
|
||||
function FrameCap.cycle(value, dir)
|
||||
local steps = FrameCap.STEPS
|
||||
local snapped = FrameCap.normalize(value)
|
||||
local cur = 1
|
||||
for i, step in ipairs(steps) do
|
||||
if step == snapped then cur = i break end
|
||||
end
|
||||
local nextIdx = (cur - 1 + (dir or 1)) % #steps + 1
|
||||
return steps[nextIdx]
|
||||
end
|
||||
|
||||
-- Store the chosen cap as the live value the run loop paces to. Never
|
||||
-- touches love.timer, so it is safe headless -- the loop just reads the
|
||||
-- number back. Returns the normalized value it stored.
|
||||
function FrameCap.apply(value)
|
||||
FrameCap.current = FrameCap.normalize(value)
|
||||
return FrameCap.current
|
||||
end
|
||||
|
||||
function FrameCap.applyOptions(opts)
|
||||
FrameCap.apply(opts and opts.fpsCap)
|
||||
end
|
||||
|
||||
return FrameCap
|
||||
+44
-3
@@ -99,7 +99,12 @@ end
|
||||
-- total conversion overrides. Threaded into SaveData so persistence stays
|
||||
-- free of a Data dependency.
|
||||
function Game:bootConfig()
|
||||
return self.data and self.data.field and self.data.field.boot
|
||||
local boot = self.data and self.data.field and self.data.field.boot
|
||||
-- stamp the running game version onto the boot config so a New Game records
|
||||
-- it (SaveData.newGame reads boot.version); this is what routes a Blue
|
||||
-- playthrough to save_blue.lua and Blue's version-gated content
|
||||
if boot then boot.version = require("src.core.GameVersion").get() end
|
||||
return boot
|
||||
end
|
||||
|
||||
-- the title screen with its NEW GAME / CONTINUE wiring; used at boot
|
||||
@@ -194,6 +199,13 @@ function Game:update(dt)
|
||||
-- frame dt (not the fixed logic step) for a smooth ~0.25s glide.
|
||||
require("src.render.Tilt").update(dt)
|
||||
pcall(function() require("src.core.DiscordPresence").update(dt) end)
|
||||
-- Steady-state memory backstop: advance the incremental collector one
|
||||
-- small step every rendered frame. The heavy GPU objects are now freed
|
||||
-- explicitly (map eviction, battle exit, canvas/renderer swaps), so this
|
||||
-- only has to keep ordinary Lua-heap garbage (per-frame tables/closures)
|
||||
-- from drifting upward over a long session, and to spread collection out
|
||||
-- so the default lazy schedule never batches it into a visible pause.
|
||||
if collectgarbage then collectgarbage("step", 1) end
|
||||
end
|
||||
|
||||
-- render.zones' identity default: unhooked, the zone list reaches the blit
|
||||
@@ -234,7 +246,11 @@ end
|
||||
function Game:zoomStep(delta)
|
||||
local Zoom = require("src.render.Zoom")
|
||||
if not Zoom.gateOK(self.stack:top(), self.overworld) then return end
|
||||
Zoom.step(delta, Renderer:fitScale())
|
||||
local offset = Zoom.step(delta, Renderer:fitScale())
|
||||
if self.save and self.save.options then
|
||||
self.save.options.zoom = offset
|
||||
self:writeOptions()
|
||||
end
|
||||
end
|
||||
|
||||
function Game:wheelmoved(_, dy)
|
||||
@@ -308,9 +324,19 @@ function Game:keypressed(key)
|
||||
self:writeOptions()
|
||||
end
|
||||
return
|
||||
elseif key == "4" then
|
||||
-- cycle ZOOM through every integer level (survey → FIT → close-up → wrap)
|
||||
local Zoom = require("src.render.Zoom")
|
||||
if Zoom.gateOK(self.stack:top(), self.overworld) then
|
||||
self.save.options.zoom = Zoom.cycle(Renderer:fitScale())
|
||||
self:writeOptions()
|
||||
end
|
||||
return
|
||||
elseif key == "5" then
|
||||
-- cycle GBC FX OFF → 1 → 2 → 3 → 4 (unlit-GBC ladder); always on
|
||||
-- desktop. Mobile refuses the present shader (issue #136).
|
||||
local GBCFX = require("src.render.GBCFX")
|
||||
if not GBCFX.isSupported() then return end
|
||||
self.save.options.gbcfx = GBCFX.cycle()
|
||||
self:writeOptions()
|
||||
return
|
||||
@@ -431,9 +457,17 @@ function Game:applyOptions(opts)
|
||||
if Sound.applyOptions then Sound.applyOptions(opts) end
|
||||
require("src.render.PaletteFX").applyOptions(opts)
|
||||
require("src.render.Tilt").applyOptions(opts)
|
||||
require("src.render.GBCFX").applyOptions(opts)
|
||||
require("src.render.Zoom").applyOptions(opts)
|
||||
require("src.render.TileRenderer").applyOptions(opts)
|
||||
-- returns true when a persisted GBC FX level was cleared on mobile
|
||||
local gbcCleared = require("src.render.GBCFX").applyOptions(opts)
|
||||
require("src.core.VideoMode").applyOptions(opts)
|
||||
-- normalizes a nil/garbage cap to the 60 default, so old saves with no
|
||||
-- fpsCap key pace at the standard rate (issue #88)
|
||||
require("src.core.FrameCap").applyOptions(opts)
|
||||
Input:applyBindings(opts.bindings)
|
||||
-- heal soft-bricked APK installs that already saved gbcfx > 0 (#136)
|
||||
if gbcCleared then self:writeOptions() end
|
||||
end
|
||||
|
||||
function Game:restoreSave(loaded, recovered)
|
||||
@@ -445,6 +479,13 @@ function Game:restoreSave(loaded, recovered)
|
||||
-- SaveData.load and skip on the format guard
|
||||
local activeMods = self.modStatus and self.modStatus.loaded
|
||||
SaveData.runMigrations(loaded, self.mods and self.mods.migrations, activeMods)
|
||||
-- Issue #103: 0.1.11 softlocks left CONTINUE in HALL_OF_FAME with
|
||||
-- lastOutdoor on Indigo. One-shot relocate + heal before validate.
|
||||
if SaveData.needsPostGameRescue(loaded) then
|
||||
SaveData.applyPostGameHome(loaded, self:bootConfig())
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
for _, mon in ipairs(loaded.party or {}) do Pokemon.heal(mon) end
|
||||
end
|
||||
local modsDiff = SaveData.modsDiff(loaded, activeMods)
|
||||
local report = SaveData.validate(loaded, self.data)
|
||||
report.recovered = recovered
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
-- Which Gen-1 game this process is running: Red (the historical default) or
|
||||
-- Blue. One source of truth for everything that differs by version -- the
|
||||
-- accepted ROM hash, the import manifest, where the extracted cache lives,
|
||||
-- and the save-file suffix -- so the importer, cache mount, SaveData, title
|
||||
-- screen and palette all agree.
|
||||
--
|
||||
-- Red keeps every un-suffixed path it always used (save.lua, the root cache),
|
||||
-- so existing installs are untouched; Blue is namespaced under blue/ and
|
||||
-- _blue so both can be imported and played side by side.
|
||||
--
|
||||
-- Zero requires, so it loads during love.conf and under plain Lua for tools
|
||||
-- and tests. The active version is a process-global set once at boot from
|
||||
-- the launcher's column choice (main.lua); it defaults to Red.
|
||||
|
||||
local GameVersion = {}
|
||||
|
||||
GameVersion.VERSIONS = {
|
||||
red = {
|
||||
id = "red",
|
||||
label = "Red",
|
||||
displayName = "Pokemon Red",
|
||||
sha1 = "ea9bcae617fdf159b045185467ae58b2e4a48b9a",
|
||||
manifest = "tools/rom_manifest.json",
|
||||
cachePrefix = "", -- Red owns the cache root (backwards compatible)
|
||||
saveSuffix = "", -- save.lua / save.lua.bak / save.lua.tmp
|
||||
},
|
||||
blue = {
|
||||
id = "blue",
|
||||
label = "Blue",
|
||||
displayName = "Pokemon Blue",
|
||||
sha1 = "d7037c83e1ae5b39bde3c30787637ba1d4c48ce2",
|
||||
manifest = "tools/rom_manifest_blue.json",
|
||||
cachePrefix = "blue/", -- blue/data/generated, blue/assets/generated
|
||||
saveSuffix = "_blue", -- save_blue.lua / .bak / .tmp
|
||||
},
|
||||
}
|
||||
|
||||
-- Launcher column order (Yellow is still a placeholder, handled by the UI).
|
||||
GameVersion.ORDER = { "red", "blue" }
|
||||
|
||||
GameVersion.current = "red"
|
||||
|
||||
function GameVersion.set(id)
|
||||
GameVersion.current = GameVersion.VERSIONS[id] and id or "red"
|
||||
return GameVersion.current
|
||||
end
|
||||
|
||||
function GameVersion.get()
|
||||
return GameVersion.current
|
||||
end
|
||||
|
||||
function GameVersion.isBlue()
|
||||
return GameVersion.current == "blue"
|
||||
end
|
||||
|
||||
-- Metadata for a version id, defaulting to the active one.
|
||||
function GameVersion.info(id)
|
||||
return GameVersion.VERSIONS[id or GameVersion.current]
|
||||
end
|
||||
|
||||
function GameVersion.saveSuffix(id)
|
||||
return GameVersion.info(id).saveSuffix
|
||||
end
|
||||
|
||||
function GameVersion.cachePrefix(id)
|
||||
return GameVersion.info(id).cachePrefix
|
||||
end
|
||||
|
||||
-- The version a ROM belongs to, by its SHA-1, or nil for an unknown ROM.
|
||||
function GameVersion.forSha1(sha1)
|
||||
for id, info in pairs(GameVersion.VERSIONS) do
|
||||
if info.sha1 == sha1 then return id end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
return GameVersion
|
||||
@@ -11,7 +11,12 @@ local DEFAULT_BINDINGS = {
|
||||
z = "a", ["return"] = "a", space = "a",
|
||||
x = "b", backspace = "b",
|
||||
["kpenter"] = "start", escape = "start",
|
||||
-- Select: fight-menu move reorder + bag item reorder. Tab is the
|
||||
-- discoverable default (shown in CONTROLS); both shifts stay as
|
||||
-- aliases so Right-Shift muscle memory from older builds still works.
|
||||
tab = "select",
|
||||
rshift = "select",
|
||||
lshift = "select",
|
||||
}
|
||||
|
||||
-- keys that map to "start" but also to "a" would conflict; keep Enter = a,
|
||||
|
||||
+18
-8
@@ -254,6 +254,7 @@ function Music.stop()
|
||||
require("src.core.ChipAudio").stopMusic()
|
||||
state.current, state.source, state.loopSource, state.fade = nil, nil, nil, nil
|
||||
state.chip = false
|
||||
state.pendingRestore = nil
|
||||
if previous and Runtime.wants("music.stopped") then
|
||||
Runtime.emit("music.stopped", { song = previous })
|
||||
end
|
||||
@@ -347,18 +348,25 @@ end
|
||||
function Music.playOnce(data, song)
|
||||
if not songDef(data, song) then return false end
|
||||
Music.play(data, song, false, { reason = "once" })
|
||||
-- play() can no-op (hook silence, failed def); only arm restore when
|
||||
-- the jingle actually became current
|
||||
if state.current ~= song then return false end
|
||||
state.pendingRestore = true
|
||||
return true
|
||||
end
|
||||
|
||||
-- is a playOnce jingle still sounding? (AnimateHealingMachine's
|
||||
-- .waitLoop2 holds the healing machine until MUSIC_PKMN_HEALED ends)
|
||||
local function chipAwaitingFirstBuffer()
|
||||
return state.chip
|
||||
and require("src.core.ChipAudio").awaitingFirstBuffer()
|
||||
end
|
||||
|
||||
-- is a playOnce jingle still in flight? (AnimateHealingMachine's
|
||||
-- .waitLoop2 / Mom heal / captain rub hold until MUSIC_PKMN_HEALED ends.)
|
||||
-- pendingRestore stays set from playOnce until Music.update restores the
|
||||
-- map theme, covering the threaded chip "empty QueueableSource" window
|
||||
-- where Source:isPlaying is briefly false before the first buffer lands.
|
||||
function Music.oneShotPlaying()
|
||||
if not state.pendingRestore then return false end
|
||||
local src = state.source
|
||||
if not src then return false end
|
||||
local ok, playing = pcall(src.isPlaying, src)
|
||||
return ok and playing or false
|
||||
return state.pendingRestore == true
|
||||
end
|
||||
|
||||
function Music.restoreMap(data)
|
||||
@@ -441,8 +449,10 @@ function Music.update(data)
|
||||
state.source = loopSrc
|
||||
pcall(loopSrc.play, loopSrc)
|
||||
end
|
||||
-- do not treat "threaded source still waiting on its first buffer" as
|
||||
-- ended, or playOnce jingles get restored over before they can sound
|
||||
if state.pendingRestore and sourceStopped(state.source)
|
||||
and not state.loopSource then
|
||||
and not state.loopSource and not chipAwaitingFirstBuffer() then
|
||||
Music.restoreMap(data)
|
||||
end
|
||||
end
|
||||
|
||||
+103
-8
@@ -18,14 +18,30 @@ local Semver = require("src.mods.Semver")
|
||||
local Boxes = require("src.pokemon.Boxes")
|
||||
local Bag = require("src.inventory.Bag")
|
||||
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
|
||||
local SaveData = {}
|
||||
|
||||
local FILENAME = "save.lua"
|
||||
-- Progress files carry the game-version suffix so Red and Blue saves coexist:
|
||||
-- Red keeps save.lua / .bak / .tmp exactly as before; Blue is save_blue.lua
|
||||
-- (+ .bak/.tmp). options.lua is deliberately shared across versions (it holds
|
||||
-- global preferences and the mod enable-state, not per-playthrough data).
|
||||
local OPTIONS_FILENAME = "options.lua"
|
||||
-- one rolling backup plus the staged-write witness; load promotes either
|
||||
-- when the main file is missing or fails to parse
|
||||
local BACKUP_FILENAME = FILENAME .. ".bak"
|
||||
local TMP_FILENAME = FILENAME .. ".tmp"
|
||||
|
||||
-- Main / backup / staged-witness names for a version (defaults to the active
|
||||
-- one). The backup is a rolling copy and .tmp is the staged-write witness;
|
||||
-- load promotes either when the main file is missing or fails to parse.
|
||||
local function saveNames(version)
|
||||
local main = "save" .. GameVersion.saveSuffix(version) .. ".lua"
|
||||
return main, main .. ".bak", main .. ".tmp"
|
||||
end
|
||||
|
||||
-- The main save filename for a version -- used by the title screen's
|
||||
-- CONTINUE gate so it looks for the right game's save.
|
||||
function SaveData.saveFilename(version)
|
||||
local main = saveNames(version)
|
||||
return main
|
||||
end
|
||||
|
||||
-- ------- portable mode
|
||||
-- LÖVE's save directory is always the OS per-user path derived from the
|
||||
@@ -96,7 +112,7 @@ local function detectPortable()
|
||||
local src = love.filesystem.getSource and love.filesystem.getSource()
|
||||
local sbd = love.filesystem.getSourceBaseDirectory
|
||||
and love.filesystem.getSourceBaseDirectory()
|
||||
-- A packaged macOS build nests the game inside PokemonRed.app/Contents/
|
||||
-- A packaged macOS build nests the game inside gen1recomp.app/Contents/
|
||||
-- Resources, so getSource()/getSourceBaseDirectory() point INSIDE the
|
||||
-- bundle -- not where the player drops portable.txt (next to the .app).
|
||||
-- Recover the folder containing the .app so a packaged app finds its
|
||||
@@ -170,12 +186,18 @@ function SaveData.defaultOptions()
|
||||
musicFilter = 0,
|
||||
-- logic fast-forward multiplier; audio is unaffected (GameSpeed.lua)
|
||||
speed = 1,
|
||||
-- port display options (OptionsMenu / hotkeys 2/3/5)
|
||||
-- port display options (OptionsMenu / hotkeys 2/3/4/5)
|
||||
colors = "gbc",
|
||||
tilt = 0,
|
||||
gbcfx = 0,
|
||||
-- survey zoom offset from window fit scale (0 = FIT); see Zoom.lua
|
||||
zoom = 0,
|
||||
-- OVERWORLD beyond-edge fill: trees | water | black
|
||||
voidFill = "trees",
|
||||
-- windowed | borderless (desktop fullscreen); ignored on mobile
|
||||
videoMode = "windowed",
|
||||
-- hard render frame-rate cap; render-only pacing (issue #88, FrameCap.lua)
|
||||
fpsCap = 60,
|
||||
-- Native mod enablement is an installation option, not save-slot data.
|
||||
-- Missing entries mean enabled so newly installed mods work by default.
|
||||
mods = {},
|
||||
@@ -449,6 +471,36 @@ SaveData.addCoreMigration(1, function(save)
|
||||
Boxes.ensure(save)
|
||||
end)
|
||||
|
||||
-- game version (Red vs Blue) prep: saves written before Blue support
|
||||
-- existed carry no `version` tag, and Red is the only game that ever
|
||||
-- shipped, so default every untagged save to Red. from=2 so it catches
|
||||
-- every pre-bump save (format 1 and 2) and is skipped once re-stamped to
|
||||
-- the current format.
|
||||
SaveData.addCoreMigration(2, function(save)
|
||||
if not save.version then
|
||||
save.version = "red"
|
||||
end
|
||||
end)
|
||||
|
||||
-- #131 / follow-up to #50: Game Corner poster grunt used to stay on the
|
||||
-- floor after defeat (defeatedTrainers only). The #50 script now hides
|
||||
-- him via objectToggles, but saves that already beat him never got the
|
||||
-- toggle -- he still blocks the hideout switch. from=3 so every pre-4
|
||||
-- save reconciles once, then is skipped after re-stamp.
|
||||
SaveData.addCoreMigration(3, function(save)
|
||||
local defeated = save.defeatedTrainers
|
||||
if not defeated or not defeated["GAME_CORNER_obj_11"] then return end
|
||||
save.objectToggles = save.objectToggles or {}
|
||||
local mapToggles = save.objectToggles.GAME_CORNER
|
||||
if not mapToggles then
|
||||
mapToggles = {}
|
||||
save.objectToggles.GAME_CORNER = mapToggles
|
||||
end
|
||||
if mapToggles.GAMECORNER_ROCKET ~= false then
|
||||
mapToggles.GAMECORNER_ROCKET = false
|
||||
end
|
||||
end)
|
||||
|
||||
-- ------- write
|
||||
|
||||
-- Game progress only; options are written separately via saveOptions.
|
||||
@@ -458,6 +510,9 @@ end)
|
||||
-- itself rolls the last good save into .bak and stages the new bytes as
|
||||
-- a .tmp witness before the swap, so a crash mid-write is recoverable.
|
||||
function SaveData.save(data, mods)
|
||||
-- write to the file matching this save's own version, not just the active
|
||||
-- one, so a Blue playthrough always lands in save_blue.lua
|
||||
local FILENAME, BACKUP_FILENAME, TMP_FILENAME = saveNames(data.version)
|
||||
if data.options then
|
||||
SaveData.saveOptions(data.options)
|
||||
end
|
||||
@@ -497,7 +552,10 @@ end
|
||||
-- returns the parsed save plus "tmp"/"bak" when the main file was gone
|
||||
-- or corrupt and a staged/backup copy was promoted; Game surfaces the
|
||||
-- recovery on the load report
|
||||
function SaveData.load()
|
||||
function SaveData.load(version)
|
||||
-- version defaults to the active game (set at boot from the launcher);
|
||||
-- an explicit version lets callers/tests load a specific game's save.
|
||||
local FILENAME, BACKUP_FILENAME, TMP_FILENAME = saveNames(version)
|
||||
local fs = persistFs(nil)
|
||||
local data, err = readTable(fs, FILENAME)
|
||||
local recovered
|
||||
@@ -778,6 +836,36 @@ function SaveData.defaultHeal(boot)
|
||||
return { map = map, x = boot.startX or 3, y = boot.startY or 6 }
|
||||
end
|
||||
|
||||
-- Post-credits home (issue #103). pokered left the player in HALL_OF_FAME
|
||||
-- after jp Init; this port places CONTINUE at the NewGameWarp bedroom and
|
||||
-- retargets LAST_MAP exits (Red's house mats) at the heal-point town so
|
||||
-- leaving the house does not dump the player back at Indigo Plateau.
|
||||
-- Marks postGameHomeOk so a later intentional HoF save is not relocated.
|
||||
function SaveData.applyPostGameHome(save, boot)
|
||||
boot = type(boot) == "table" and boot or {}
|
||||
local heal = SaveData.defaultHeal(boot)
|
||||
save.lastHeal = { map = heal.map, x = heal.x, y = heal.y }
|
||||
save.lastOutdoor = { id = heal.map, x = heal.x, y = heal.y }
|
||||
save.player = save.player or {}
|
||||
save.player.map = boot.startMap or "REDS_HOUSE_2F"
|
||||
save.player.x = boot.startX or 3
|
||||
save.player.y = boot.startY or 6
|
||||
save.player.facing = boot.startFacing or "down"
|
||||
save.postGameHomeOk = true
|
||||
return heal
|
||||
end
|
||||
|
||||
-- Softlocked 0.1.11 saves: still standing in HALL_OF_FAME after credits,
|
||||
-- with lastOutdoor on Indigo. One-shot rescue on CONTINUE.
|
||||
function SaveData.needsPostGameRescue(save)
|
||||
if not (save and save.player and save.player.map == "HALL_OF_FAME") then
|
||||
return false
|
||||
end
|
||||
if save.postGameHomeOk then return false end
|
||||
local hof = save.hallOfFame
|
||||
return type(hof) == "table" and #hof > 0
|
||||
end
|
||||
|
||||
function SaveData.newGame(boot)
|
||||
boot = type(boot) == "table" and boot or {}
|
||||
local map = boot.startMap or "REDS_HOUSE_2F"
|
||||
@@ -785,6 +873,9 @@ function SaveData.newGame(boot)
|
||||
local heal = SaveData.defaultHeal(boot)
|
||||
local save = {
|
||||
meta = { format = Version.saveFormat, mods = {} },
|
||||
-- which game this playthrough is (Red vs Blue). Only Red ships today;
|
||||
-- boot carries the choice once Blue support lands.
|
||||
version = boot.version or "red",
|
||||
player = {
|
||||
map = map,
|
||||
x = x,
|
||||
@@ -798,6 +889,10 @@ function SaveData.newGame(boot)
|
||||
},
|
||||
flags = {},
|
||||
inventory = {},
|
||||
-- Vanilla Gen1 seeds one Potion in the player's item PC
|
||||
-- (wBoxItems / players_pc.asm); existing saves keep whatever they
|
||||
-- already have — this only applies to New Game.
|
||||
pcItems = { POTION = 1 },
|
||||
party = {},
|
||||
box = {},
|
||||
money = boot.startMoney or 3000,
|
||||
|
||||
@@ -48,7 +48,7 @@ local KEY = {
|
||||
a = "z",
|
||||
b = "x",
|
||||
start = "escape",
|
||||
select = "rshift",
|
||||
select = "tab",
|
||||
}
|
||||
|
||||
local function nowMs()
|
||||
|
||||
@@ -7,13 +7,13 @@ local Version = {
|
||||
engine = "1.0.0", -- game/engine release (semver triple)
|
||||
modApi = 2, -- mod API major (manifest `api`)
|
||||
linkProtocol = 2, -- link handshake wire version (Handshake.PROTOCOL)
|
||||
saveFormat = 2, -- save.meta.format
|
||||
saveFormat = 4, -- save.meta.format
|
||||
cache = "rom-cache-v5", -- ROM import cache generation (RomImporter marker)
|
||||
}
|
||||
|
||||
-- "Pokemon Red (Gen 1 Recompilation Project) v1.0.0"
|
||||
-- "gen1recomp v1.0.0"
|
||||
function Version.title(base)
|
||||
return (base or "Pokemon Red (Gen 1 Recompilation Project)")
|
||||
return (base or "gen1recomp")
|
||||
.. " v" .. Version.engine
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
-- ChipAudio synthesis worker (love.thread). Runs the Game Boy audio synth
|
||||
-- (src/core/ChipSynth.lua) off the main thread so a map/battle song change
|
||||
-- never stutters the render thread: filling the ~6s playback queue from
|
||||
-- scratch is ~200ms of Lua synthesis, and this is where it now happens.
|
||||
--
|
||||
-- Protocol -- main thread pushes command tables onto the "chipaudio_cmd"
|
||||
-- channel and drains produced buffers off "chipaudio_out":
|
||||
-- cmd = "play" { gen, header, allowLoops, audio } start a song
|
||||
-- cmd = "stop" halt production
|
||||
-- cmd = "invalidate" drop the bank cache
|
||||
-- cmd = "quit" end the thread
|
||||
-- out buffers are tagged with the play's `gen` so the main thread can
|
||||
-- discard anything left over from a superseded song:
|
||||
-- { gen, sd = SoundData } one rendered buffer
|
||||
-- { gen, done = true } the song ended (non-looping jingle finished)
|
||||
-- { gen, error = msg } build/synth failed; main logs and gives up
|
||||
|
||||
require("love.thread")
|
||||
require("love.timer")
|
||||
require("love.sound")
|
||||
require("love.filesystem")
|
||||
|
||||
-- Load the synth explicitly via love.filesystem (a fresh thread Lua state does
|
||||
-- not necessarily carry the package searcher that resolves "src.core..."):
|
||||
local ChipSynth = assert(love.filesystem.load("src/core/ChipSynth.lua"))()
|
||||
|
||||
local cmdCh = love.thread.getChannel("chipaudio_cmd")
|
||||
local outCh = love.thread.getChannel("chipaudio_out")
|
||||
|
||||
local BUF = ChipSynth.MUSIC_BUFFER_SAMPLES
|
||||
-- how many finished buffers may sit in the hand-off channel before the worker
|
||||
-- pauses. The deep (~6s) playback depth lives in the main-thread Source; this
|
||||
-- only bounds the worker's look-ahead (and its memory) between drains.
|
||||
local LOOKAHEAD = 8
|
||||
|
||||
local gen = nil -- active song generation, or nil when stopped
|
||||
local engine = nil -- the ChipSynth engine producing the current song
|
||||
local finished = false -- the current song ran out (non-looping)
|
||||
local data = nil -- { audio = <slim audio tables> } for ROM bank/wave reads
|
||||
|
||||
local function handle(cmd)
|
||||
if cmd.cmd == "play" then
|
||||
gen = cmd.gen
|
||||
finished = false
|
||||
engine = nil
|
||||
outCh:clear() -- drop any buffers left from the previous song
|
||||
data = { audio = cmd.audio }
|
||||
local ok, eng = pcall(ChipSynth.newEngine, data, cmd.header,
|
||||
{ allowLoops = cmd.allowLoops })
|
||||
if ok then
|
||||
engine = eng
|
||||
else
|
||||
outCh:push({ gen = gen, error = tostring(eng) })
|
||||
finished = true
|
||||
end
|
||||
elseif cmd.cmd == "stop" then
|
||||
gen = nil
|
||||
engine = nil
|
||||
finished = false
|
||||
outCh:clear()
|
||||
elseif cmd.cmd == "invalidate" then
|
||||
ChipSynth.invalidateBanks()
|
||||
elseif cmd.cmd == "quit" then
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
while true do
|
||||
-- drain every pending command first, so a stop/new-play is seen promptly
|
||||
local quit = false
|
||||
local cmd = cmdCh:pop()
|
||||
while cmd do
|
||||
if handle(cmd) then quit = true end
|
||||
cmd = cmdCh:pop()
|
||||
end
|
||||
if quit then break end
|
||||
|
||||
if engine and not finished and gen and outCh:getCount() < LOOKAHEAD then
|
||||
local activeGen = gen
|
||||
local ok, sd = pcall(ChipSynth.soundData, engine, BUF, 2)
|
||||
if not ok then
|
||||
outCh:push({ gen = activeGen, error = tostring(sd) })
|
||||
finished = true
|
||||
else
|
||||
outCh:push({ gen = activeGen, sd = sd })
|
||||
if engine:finished() then
|
||||
outCh:push({ gen = activeGen, done = true })
|
||||
finished = true
|
||||
end
|
||||
end
|
||||
else
|
||||
-- nothing to do (idle, or the look-ahead is full): yield the core
|
||||
love.timer.sleep(0.001)
|
||||
end
|
||||
end
|
||||
+55
-4
@@ -31,6 +31,20 @@ local CacheFs = {}
|
||||
|
||||
local SEP = package.config:sub(1, 1)
|
||||
|
||||
-- Cache-relative paths are prefixed with this before every read/write, so a
|
||||
-- Blue import lands in blue/ (see src.core.GameVersion) while a Red import
|
||||
-- keeps the historical root. The launcher sets it per import / per readiness
|
||||
-- check; it stays "" for Red. Runtime *reads* (require / newImage) do NOT go
|
||||
-- through here -- CacheFs.mountVersion overlays the active version's subtree
|
||||
-- onto the un-prefixed paths instead.
|
||||
CacheFs.prefix = ""
|
||||
|
||||
local function withPrefix(rel)
|
||||
local p = CacheFs.prefix
|
||||
if p == nil or p == "" then return rel end
|
||||
return p .. rel
|
||||
end
|
||||
|
||||
-- lazily-resolved windowless mkdir: function(absolutePath) or false when
|
||||
-- FFI is unavailable (the cache then stays on the save directory)
|
||||
local mkdirFn = nil
|
||||
@@ -86,8 +100,9 @@ local function resolveMount()
|
||||
if okl and lib then
|
||||
local oks, fn = pcall(function() return lib.PHYSFS_mount end)
|
||||
if oks and fn then
|
||||
physfsMountFn = function(d)
|
||||
local okr, ret = pcall(fn, d, "", 1)
|
||||
physfsMountFn = function(d, append)
|
||||
if append == nil then append = true end
|
||||
local okr, ret = pcall(fn, d, "", append and 1 or 0)
|
||||
return okr and ret ~= 0
|
||||
end
|
||||
break
|
||||
@@ -97,10 +112,14 @@ local function resolveMount()
|
||||
return physfsMountFn
|
||||
end
|
||||
|
||||
local function mountReadable(dir)
|
||||
-- append (default true): the game's own source wins a name clash, matching
|
||||
-- how the portable cache root has always been mounted. Pass false to
|
||||
-- prepend, so the mounted tree wins -- used to overlay the active version's
|
||||
-- cache on top of the root (Red) copy and the source.
|
||||
local function mountReadable(dir, append)
|
||||
local fn = resolveMount()
|
||||
if not fn then return false end
|
||||
return fn(dir)
|
||||
return fn(dir, append)
|
||||
end
|
||||
|
||||
-- The portable game folder when the cache should live there, else nil.
|
||||
@@ -152,6 +171,7 @@ end
|
||||
-- write cache-relative `rel` (forward-slash path) with the given bytes;
|
||||
-- returns ok, err like love.filesystem.write
|
||||
function CacheFs.write(rel, data)
|
||||
rel = withPrefix(rel)
|
||||
local root = CacheFs.root()
|
||||
if root then
|
||||
ensureParents(root, rel)
|
||||
@@ -173,6 +193,7 @@ end
|
||||
|
||||
-- read cache-relative `rel`; returns the bytes or nil
|
||||
function CacheFs.read(rel)
|
||||
rel = withPrefix(rel)
|
||||
local root = CacheFs.root()
|
||||
if root then
|
||||
local f = io.open(realPath(root, rel), "rb")
|
||||
@@ -186,6 +207,7 @@ end
|
||||
|
||||
-- does cache-relative `rel` exist as a file?
|
||||
function CacheFs.exists(rel)
|
||||
rel = withPrefix(rel)
|
||||
local root = CacheFs.root()
|
||||
if root then
|
||||
local f = io.open(realPath(root, rel), "rb")
|
||||
@@ -198,6 +220,7 @@ end
|
||||
|
||||
-- remove a single cache-relative file
|
||||
function CacheFs.remove(rel)
|
||||
rel = withPrefix(rel)
|
||||
local root = CacheFs.root()
|
||||
if root then
|
||||
os.remove(realPath(root, rel))
|
||||
@@ -213,6 +236,7 @@ end
|
||||
-- love.filesystem (the game folder is mounted) and the real files deleted
|
||||
-- with os.remove; empty directories are harmless and left in place.
|
||||
function CacheFs.removeTree(rel)
|
||||
rel = withPrefix(rel)
|
||||
local root = CacheFs.root()
|
||||
if not root then return end
|
||||
local function walk(r)
|
||||
@@ -229,4 +253,31 @@ function CacheFs.removeTree(rel)
|
||||
walk(rel)
|
||||
end
|
||||
|
||||
-- Overlay the active version's extracted cache onto the un-prefixed read
|
||||
-- paths, so require("data.generated.*") and love.graphics.newImage(
|
||||
-- "assets/generated/*") resolve to that version's files. Red lives at the
|
||||
-- cache root and needs nothing; Blue lives under blue/ and is *prepended* so
|
||||
-- it wins over any Red copy at the root and over the game source. Called
|
||||
-- once at boot, before Game:load (main.lua). Returns true when nothing was
|
||||
-- needed or the mount succeeded.
|
||||
function CacheFs.mountVersion(version)
|
||||
local prefix = require("src.core.GameVersion").cachePrefix(version)
|
||||
if prefix == "" then return true end -- Red: already at the root
|
||||
local sub = prefix:gsub("/+$", "") -- "blue/" -> "blue"
|
||||
-- The cache root is the portable game folder when active, else LÖVE's OS
|
||||
-- save directory (where love.filesystem wrote blue/...).
|
||||
local base = CacheFs.root()
|
||||
if not base and love.filesystem.getSaveDirectory then
|
||||
base = love.filesystem.getSaveDirectory()
|
||||
end
|
||||
if not base then return false end
|
||||
if mountReadable(base .. SEP .. sub, false) then return true end
|
||||
-- Fallback when FFI/PHYSFS_mount is unavailable: LÖVE can mount a folder
|
||||
-- that lives in the save directory by name (prepended: appendToPath=false).
|
||||
if love.filesystem.mount then
|
||||
return love.filesystem.mount(sub, "", false)
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
return CacheFs
|
||||
|
||||
+769
-204
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,44 @@
|
||||
-- Shared 6-slot room-code entry widget: the digit-scrub interaction
|
||||
-- LinkState's own `ipDigits`/`addrPos` already uses for IP entry, over the
|
||||
-- Crockford-32-style alphabet pokeserver room/tournament codes are drawn
|
||||
-- from (23456789ABCDEFGHJKMNPQRSTUVWXYZ -- no 0/O/1/I/L, so a code read
|
||||
-- aloud or handwritten never has to be checked twice).
|
||||
|
||||
local CodeEntry = {}
|
||||
|
||||
CodeEntry.CHARSET = "23456789ABCDEFGHJKMNPQRSTUVWXYZ"
|
||||
CodeEntry.LENGTH = 6
|
||||
|
||||
function CodeEntry.new()
|
||||
local chars = {}
|
||||
for i = 1, CodeEntry.LENGTH do chars[i] = 1 end -- index into CHARSET, 1-based
|
||||
return { chars = chars, pos = 1 }
|
||||
end
|
||||
|
||||
local N = #CodeEntry.CHARSET
|
||||
|
||||
function CodeEntry.up(state)
|
||||
state.chars[state.pos] = state.chars[state.pos] % N + 1
|
||||
end
|
||||
|
||||
function CodeEntry.down(state)
|
||||
state.chars[state.pos] = (state.chars[state.pos] - 2) % N + 1
|
||||
end
|
||||
|
||||
function CodeEntry.left(state)
|
||||
state.pos = math.max(1, state.pos - 1)
|
||||
end
|
||||
|
||||
function CodeEntry.right(state)
|
||||
state.pos = math.min(CodeEntry.LENGTH, state.pos + 1)
|
||||
end
|
||||
|
||||
function CodeEntry.text(state)
|
||||
local out = {}
|
||||
for i = 1, CodeEntry.LENGTH do
|
||||
out[i] = CodeEntry.CHARSET:sub(state.chars[i], state.chars[i])
|
||||
end
|
||||
return table.concat(out)
|
||||
end
|
||||
|
||||
return CodeEntry
|
||||
@@ -67,6 +67,17 @@ function Handshake.linkModified(game)
|
||||
return false
|
||||
end
|
||||
|
||||
-- online play (the relay-based online match / tournament flows in
|
||||
-- LinkState/Tournament) meets strangers, not a coordinating friend, so it
|
||||
-- skips the LAN path's per-peer compatibility negotiation entirely and
|
||||
-- just requires vanilla on both ends: no mod-added Pokemon, no surprises.
|
||||
-- Mods only ever get baked in at boot (Loader:load), so this is a gate on
|
||||
-- attempting to go online, not a live mod toggle -- the player disables
|
||||
-- mods via the mod manager and relaunches.
|
||||
function Handshake.onlineAllowed(game)
|
||||
return #Handshake.mods(game) == 0
|
||||
end
|
||||
|
||||
-- mode is nil on the guest: it pairs and announces itself before the host
|
||||
-- has picked, and compatibility is decided from the two hellos, not the mode
|
||||
function Handshake.hello(game, mode)
|
||||
|
||||
+386
-56
@@ -15,6 +15,7 @@
|
||||
-- them in link battles).
|
||||
|
||||
local Fingerprint = require("src.link.Fingerprint")
|
||||
local Font = require("src.render.Font")
|
||||
local Handshake = require("src.link.Handshake")
|
||||
local Logger = require("src.core.Logger")
|
||||
local Protocol = require("src.link.Protocol")
|
||||
@@ -36,18 +37,45 @@ local function makeRng(seed)
|
||||
end
|
||||
end
|
||||
|
||||
-- battler builder shared by both sides -- NO badge boosts, so both
|
||||
-- machines compute identical stats
|
||||
-- Battlers go through BattleState.makeBattler so pics get the same
|
||||
-- Assets.resolve + SGB/GBC palette + padBottom path as wild/trainer
|
||||
-- battles. save=nil skips badge boosts so both machines keep identical
|
||||
-- stats (Gen 1 cable battles famously kept badges; we still diverge).
|
||||
local function mkBattler(data, mon, isPlayer)
|
||||
local def = data.pokemon[mon.species]
|
||||
local ok, img = pcall(love.graphics.newImage,
|
||||
isPlayer and def.spriteBack or def.spriteFront)
|
||||
return {
|
||||
mon = mon, def = def, isPlayer = isPlayer, stages = {},
|
||||
name = mon.nickname or def.name,
|
||||
curStats = mon.stats, curTypes = def.types, curMoves = mon.moves,
|
||||
sprite = ok and img or nil,
|
||||
}
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
return BattleState.makeBattler(data, mon, isPlayer, nil)
|
||||
end
|
||||
|
||||
-- shared by LinkBattle.new (myParty/theirParty) and LinkBattle.newSpectator
|
||||
-- (hostParty/guestParty): pack->unpack clamp so every perspective watching
|
||||
-- a given match holds identical mon copies. errFmt(packedMon, why) builds
|
||||
-- the message shown when strict mode refuses an unrebuildable mon.
|
||||
local function unpackParty(game, packed, unpackOpts, errFmt)
|
||||
local out = {}
|
||||
for _, p in ipairs(packed or {}) do
|
||||
local mon, why = Protocol.unpackMon(game.data, p, unpackOpts)
|
||||
if mon then
|
||||
table.insert(out, mon)
|
||||
elseif unpackOpts.strict then
|
||||
return nil, errFmt(p, why)
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- decode a wire action message against whichever battler it belongs to
|
||||
-- (the opponent's, from a real participant's perspective; either side's,
|
||||
-- from a spectator's)
|
||||
local function decodeWireAction(s, msg, battler)
|
||||
if msg.kind == "move" then
|
||||
local slot = math.max(1, math.min(#battler.curMoves, math.floor(msg.slot or 1)))
|
||||
return battler.curMoves[slot]
|
||||
elseif msg.kind == "struggle" then
|
||||
return { id = "STRUGGLE", pp = 1, struggle = true }
|
||||
elseif msg.kind == "locked" then
|
||||
return s:lockedAction(battler)
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- canonical (host-side-first) state hash, unchanged since v1: it stays on
|
||||
@@ -155,25 +183,14 @@ function LinkBattle.new(game, net, opts)
|
||||
-- both parties pass through the same pack->unpack clamp on both
|
||||
-- machines, so the copies are identical everywhere
|
||||
local unpackOpts = { strict = opts.strict or false }
|
||||
local myParty, theirParty = {}, {}
|
||||
for _, p in ipairs(opts.myParty or {}) do
|
||||
local mon = Protocol.unpackMon(game.data, p, unpackOpts)
|
||||
if mon then
|
||||
table.insert(myParty, mon)
|
||||
elseif unpackOpts.strict then
|
||||
return nil, ("Your %s can't\nbattle on the\nother game."):format(
|
||||
tostring(p.species))
|
||||
end
|
||||
end
|
||||
for _, p in ipairs(opts.theirParty or {}) do
|
||||
local mon, why = Protocol.unpackMon(game.data, p, unpackOpts)
|
||||
if mon then
|
||||
table.insert(theirParty, mon)
|
||||
elseif unpackOpts.strict then
|
||||
return nil, ("Their %s isn't\nin this game.\n(%s)"):format(
|
||||
tostring(p.species), tostring(why))
|
||||
end
|
||||
end
|
||||
local myParty, myErr = unpackParty(game, opts.myParty, unpackOpts, function(p)
|
||||
return ("Your %s can't\nbattle on the\nother game."):format(tostring(p.species))
|
||||
end)
|
||||
if not myParty then return nil, myErr end
|
||||
local theirParty, theirErr = unpackParty(game, opts.theirParty, unpackOpts, function(p, why)
|
||||
return ("Their %s isn't\nin this game.\n(%s)"):format(tostring(p.species), tostring(why))
|
||||
end)
|
||||
if not theirParty then return nil, theirErr end
|
||||
if #myParty == 0 or #theirParty == 0 then
|
||||
Logger.warn("link: empty party on one side")
|
||||
end
|
||||
@@ -213,6 +230,8 @@ function LinkBattle.new(game, net, opts)
|
||||
self.player = mkBattler(game.data, myParty[1], true)
|
||||
self.enemy = mkBattler(game.data, theirParty[1], false)
|
||||
self.enemyParty = theirParty
|
||||
self.playerParty = myParty -- intro ball row uses the clamped copies
|
||||
self.opponentName = theirName
|
||||
self.introText = ("%s wants\nto battle!"):format(theirName)
|
||||
self.remoteHashes = {}
|
||||
self.localHashes = {}
|
||||
@@ -230,22 +249,62 @@ function LinkBattle.new(game, net, opts)
|
||||
if text then s:say(text) end
|
||||
end
|
||||
|
||||
-- a tournament shot clock (opts.turnLimit) costs the slow player
|
||||
-- specifically, unlike RUN or a desync -- both of which stay a draw --
|
||||
-- so it needs its own result rather than reusing endAsDraw
|
||||
local function endWithResult(s, result, text)
|
||||
if s.linkEnded then return end
|
||||
s.result = result
|
||||
s.afterQueue = "finish"
|
||||
s.phase = "messages"
|
||||
if text then s:say(text) end
|
||||
end
|
||||
|
||||
local function orderMove(action)
|
||||
if action and action.id then return game.data.moves[action.id] end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- player-side SendOutMon (poof + grow-in + cry); mirrors BattleState
|
||||
local function sendOutPlayer(s, mon)
|
||||
local previous = s.player
|
||||
s.player = mkBattler(game.data, mon, true)
|
||||
s:syncSides()
|
||||
Runtime.emit("battle.battler_switched", {
|
||||
battle = s, side = s.sides[1], battler = s.player, previous = previous,
|
||||
})
|
||||
s.sendingOut = true
|
||||
s:sayNext(s:sendOutText(s.player.name))
|
||||
s:animNext("POOF_ANIM", false)
|
||||
s:actNext(function()
|
||||
s.sendingOut = false
|
||||
s:startGrowIn(s.player)
|
||||
require("src.core.Sound").playCry(s.data, s.player.mon.species)
|
||||
end)
|
||||
end
|
||||
|
||||
-- enemy-side EnemySendOut (grow-in + cry; no poof)
|
||||
local function sendOutEnemy(s, mon)
|
||||
local previous = s.enemy
|
||||
s.enemy = mkBattler(game.data, mon, false)
|
||||
s:syncSides()
|
||||
Runtime.emit("battle.battler_switched", {
|
||||
battle = s, side = s.sides[2], battler = s.enemy, previous = previous,
|
||||
})
|
||||
s.enemySendingOut = true
|
||||
s:sayNext(("%s sent\nout %s!"):format(theirName, s.enemy.name))
|
||||
s:actNext(function()
|
||||
s.enemySendingOut = false
|
||||
s:startGrowIn(s.enemy)
|
||||
s:actNext(function()
|
||||
require("src.core.Sound").playCry(s.data, s.enemy.mon.species)
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
-- decode a remote action message against the enemy battler
|
||||
local function decodeTheirAction(s, msg)
|
||||
if msg.kind == "move" then
|
||||
local slot = math.max(1, math.min(#s.enemy.curMoves, math.floor(msg.slot or 1)))
|
||||
return s.enemy.curMoves[slot]
|
||||
elseif msg.kind == "struggle" then
|
||||
return { id = "STRUGGLE", pp = 1, struggle = true }
|
||||
elseif msg.kind == "locked" then
|
||||
return s:lockedAction(s.enemy)
|
||||
end
|
||||
return nil
|
||||
return decodeWireAction(s, msg, s.enemy)
|
||||
end
|
||||
|
||||
-- with the handshake guaranteeing both games share a link surface, a
|
||||
@@ -306,17 +365,11 @@ function LinkBattle.new(game, net, opts)
|
||||
-- switches happen before attacks (both may switch)
|
||||
if myMsg.kind == "switch" then
|
||||
local idx = myMsg.index
|
||||
s:act(function()
|
||||
s.player = mkBattler(game.data, myParty[idx], true)
|
||||
s:sayNext(("Go! %s!"):format(s.player.name))
|
||||
end)
|
||||
s:act(function() sendOutPlayer(s, myParty[idx]) end)
|
||||
myAction = nil
|
||||
end
|
||||
if theirSwitch then
|
||||
s:act(function()
|
||||
s.enemy = mkBattler(game.data, theirParty[theirSwitch], false)
|
||||
s:sayNext(("%s sent\nout %s!"):format(theirName, s.enemy.name))
|
||||
end)
|
||||
s:act(function() sendOutEnemy(s, theirParty[theirSwitch]) end)
|
||||
end
|
||||
|
||||
s:act(function()
|
||||
@@ -452,10 +505,7 @@ function LinkBattle.new(game, net, opts)
|
||||
self.playerMonFainted = function(s)
|
||||
for _, mon in ipairs(myParty) do
|
||||
if mon.hp > 0 then
|
||||
s:act(function()
|
||||
s.player = mkBattler(game.data, mon, true)
|
||||
s:sayNext(("Go! %s!"):format(s.player.name))
|
||||
end)
|
||||
s:act(function() sendOutPlayer(s, mon) end)
|
||||
return
|
||||
end
|
||||
end
|
||||
@@ -468,10 +518,7 @@ function LinkBattle.new(game, net, opts)
|
||||
self.enemyMonFainted = function(s)
|
||||
for _, mon in ipairs(theirParty) do
|
||||
if mon.hp > 0 then
|
||||
s:act(function()
|
||||
s.enemy = mkBattler(game.data, mon, false)
|
||||
s:sayNext(("%s sent\nout %s!"):format(theirName, s.enemy.name))
|
||||
end)
|
||||
s:act(function() sendOutEnemy(s, mon) end)
|
||||
return
|
||||
end
|
||||
end
|
||||
@@ -498,6 +545,19 @@ function LinkBattle.new(game, net, opts)
|
||||
if not s.result then
|
||||
endAsDraw(s, ("%s left the\nbattle."):format(theirName))
|
||||
end
|
||||
elseif msg.type == "forfeit" then
|
||||
-- the peer's own shot clock ran out; unlike a mutual RUN/desync
|
||||
-- draw, this has a definite winner (us)
|
||||
if not s.result then
|
||||
endWithResult(s, "win", ("%s ran out of\ntime!"):format(theirName))
|
||||
end
|
||||
else
|
||||
-- a tournament control message (bracket_update, the next
|
||||
-- match_start, ...) can arrive while this match is still
|
||||
-- finishing up; Tournament.lua drains this once it regains the
|
||||
-- stack top rather than losing it to this poll loop
|
||||
s.pendingTournamentMessages = s.pendingTournamentMessages or {}
|
||||
table.insert(s.pendingTournamentMessages, msg)
|
||||
end
|
||||
end
|
||||
if net.closed and not s.linkEnded and not s.result then
|
||||
@@ -513,16 +573,48 @@ function LinkBattle.new(game, net, opts)
|
||||
end
|
||||
return
|
||||
end
|
||||
if opts.turnLimit and s.phase == "menu" then
|
||||
if not s.turnClockActive then
|
||||
s.turnClockActive = true
|
||||
s.turnClock = opts.turnLimit
|
||||
end
|
||||
s.turnClock = s.turnClock - dt
|
||||
if s.turnClock <= 0 then
|
||||
s.turnClockActive = false
|
||||
send({ type = "forfeit" })
|
||||
endWithResult(s, "lose", "Time's up! You\nforfeit the match.")
|
||||
end
|
||||
elseif opts.turnLimit then
|
||||
s.turnClockActive = false
|
||||
end
|
||||
baseUpdate(s, dt)
|
||||
end
|
||||
|
||||
if opts.turnLimit then
|
||||
local baseDraw = self.draw
|
||||
self.draw = function(s, ...)
|
||||
baseDraw(s, ...)
|
||||
if s.phase == "menu" and s.turnClockActive then
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
Font.draw(tostring(math.max(0, math.ceil(s.turnClock))), 144, 4)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local baseFinish = self.finish
|
||||
self.finish = function(s)
|
||||
if not s.linkEnded then
|
||||
s.linkEnded = true
|
||||
send({ type = "bye" })
|
||||
end
|
||||
net:close()
|
||||
-- opts.keepNetOpen: a tournament match's `net` is the caller's
|
||||
-- long-lived tournament connection (still needed for the next round,
|
||||
-- spectating, bracket updates, ...), not a dedicated match socket --
|
||||
-- closing it here the way a plain 1v1 link battle does would sever
|
||||
-- the whole tournament, not just this match.
|
||||
if not opts.keepNetOpen then
|
||||
net:close()
|
||||
end
|
||||
if game.linkNet == net then game.linkNet = nil end
|
||||
baseFinish(s)
|
||||
end
|
||||
@@ -530,6 +622,244 @@ function LinkBattle.new(game, net, opts)
|
||||
return self
|
||||
end
|
||||
|
||||
-- A tournament spectator: reconstructs the exact same lockstep battle a
|
||||
-- live match's two real participants are playing, from a copy of the
|
||||
-- traffic the relay fans out to onlookers (see pokeserver's `spectate`
|
||||
-- envelope). No local input drives anything here -- both sides' actions
|
||||
-- arrive over the wire, tagged by which real player sent them -- so it's
|
||||
-- a read-only replay, not a third participant: no hash/desync checking
|
||||
-- (a spectator has nothing to verify against), no shot clock (nothing to
|
||||
-- act on), and `finish` must NOT close `net`, since that's the caller's
|
||||
-- long-lived tournament connection, not a dedicated match socket.
|
||||
--
|
||||
-- opts: { hostParty = packed, guestParty = packed, hostName, guestName,
|
||||
-- seed, verdict, strict }. `self.player` is always the host's battler and
|
||||
-- `self.enemy` the guest's, which is what makes TurnOrder.firstMover's
|
||||
-- tie-break (below) land on the same result the host's own instance
|
||||
-- already computed with invertTie=false.
|
||||
function LinkBattle.newSpectator(game, net, opts)
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local hostName = opts.hostName or "HOST"
|
||||
local guestName = opts.guestName or "GUEST"
|
||||
|
||||
if not Handshake.battleAllowed(opts.verdict) then
|
||||
return nil, "Link battle needs\nthe same mods on\nboth games."
|
||||
end
|
||||
|
||||
local unpackOpts = { strict = opts.strict or false }
|
||||
local hostParty, hostErr = unpackParty(game, opts.hostParty, unpackOpts, function(p)
|
||||
return ("%s's %s can't\nbattle on this\ngame."):format(hostName, tostring(p.species))
|
||||
end)
|
||||
if not hostParty then return nil, hostErr end
|
||||
local guestParty, guestErr = unpackParty(game, opts.guestParty, unpackOpts, function(p, why)
|
||||
return ("%s's %s can't\nbattle on this\ngame.\n(%s)"):format(
|
||||
guestName, tostring(p.species), tostring(why))
|
||||
end)
|
||||
if not guestParty then return nil, guestErr end
|
||||
if #hostParty == 0 or #guestParty == 0 then
|
||||
Logger.warn("link: empty party on one side (spectator)")
|
||||
end
|
||||
|
||||
local self = BattleState.newWild(game, guestParty[1] and guestParty[1].species
|
||||
or "RATTATA", 5)
|
||||
self.kind = "link" -- exact same visual treatment as a real link battle
|
||||
self.spectating = true -- Tournament.lua's marker: don't report a result for this one
|
||||
self.net = net
|
||||
game.linkNet = net
|
||||
self.rng = makeRng(opts.seed or 1)
|
||||
self.player = mkBattler(game.data, hostParty[1], true)
|
||||
self.enemy = mkBattler(game.data, guestParty[1], false)
|
||||
self.enemyParty = guestParty
|
||||
self.playerParty = hostParty
|
||||
self.opponentName = guestName
|
||||
self.introText = ("%s vs %s!"):format(hostName, guestName)
|
||||
|
||||
local function orderMove(action)
|
||||
if action and action.id then return game.data.moves[action.id] end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function endSpectate(s, text)
|
||||
if s.linkEnded then return end
|
||||
s.result = s.result or "ended"
|
||||
s.afterQueue = "finish"
|
||||
s.phase = "messages"
|
||||
if text then s:say(text) end
|
||||
end
|
||||
|
||||
local function sendOutHost(s, mon)
|
||||
local previous = s.player
|
||||
s.player = mkBattler(game.data, mon, true)
|
||||
s:syncSides()
|
||||
Runtime.emit("battle.battler_switched", {
|
||||
battle = s, side = s.sides[1], battler = s.player, previous = previous,
|
||||
})
|
||||
s.sendingOut = true
|
||||
s:sayNext(s:sendOutText(s.player.name))
|
||||
s:animNext("POOF_ANIM", false)
|
||||
s:actNext(function()
|
||||
s.sendingOut = false
|
||||
s:startGrowIn(s.player)
|
||||
require("src.core.Sound").playCry(s.data, s.player.mon.species)
|
||||
end)
|
||||
end
|
||||
|
||||
local function sendOutGuest(s, mon)
|
||||
local previous = s.enemy
|
||||
s.enemy = mkBattler(game.data, mon, false)
|
||||
s:syncSides()
|
||||
Runtime.emit("battle.battler_switched", {
|
||||
battle = s, side = s.sides[2], battler = s.enemy, previous = previous,
|
||||
})
|
||||
s.enemySendingOut = true
|
||||
s:sayNext(("%s sent\nout %s!"):format(guestName, s.enemy.name))
|
||||
s:actNext(function()
|
||||
s.enemySendingOut = false
|
||||
s:startGrowIn(s.enemy)
|
||||
s:actNext(function()
|
||||
require("src.core.Sound").playCry(s.data, s.enemy.mon.species)
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
local function resolveSpecTurn(s, hostMsg, guestMsg)
|
||||
if hostMsg.kind == "run" or guestMsg.kind == "run" then
|
||||
endSpectate(s, "The match ended.")
|
||||
return
|
||||
end
|
||||
s.phase = "messages"
|
||||
s.afterQueue = "linkNext"
|
||||
s.turnCount = (s.turnCount or 0) + 1
|
||||
|
||||
if hostMsg.kind == "switch" then
|
||||
local idx = hostMsg.index
|
||||
s:act(function() sendOutHost(s, hostParty[idx]) end)
|
||||
end
|
||||
if guestMsg.kind == "switch" then
|
||||
local idx = guestMsg.index
|
||||
s:act(function() sendOutGuest(s, guestParty[idx]) end)
|
||||
end
|
||||
|
||||
s:act(function()
|
||||
local hostAction = hostMsg.kind ~= "switch" and hostMsg.kind ~= "run"
|
||||
and decodeWireAction(s, hostMsg, s.player) or nil
|
||||
local guestAction = guestMsg.kind ~= "switch" and guestMsg.kind ~= "run"
|
||||
and decodeWireAction(s, guestMsg, s.enemy) or nil
|
||||
Runtime.emit("battle.turn_started", {
|
||||
battle = s, turn = s.turnCount,
|
||||
playerAction = hostAction, enemyAction = guestAction,
|
||||
})
|
||||
if hostAction and guestAction then
|
||||
local hostMove, guestMove = orderMove(hostAction), orderMove(guestAction)
|
||||
local first
|
||||
if Runtime.wantsHook("battle.turn_order") then
|
||||
first = Runtime.call("battle.turn_order", function(a, aMove, b, bMove, c)
|
||||
return TurnOrder.firstMover(a, aMove, b, bMove, c.rng, c.invertTie)
|
||||
end, s.player, hostMove, s.enemy, guestMove, { rng = s.rng, invertTie = false })
|
||||
else
|
||||
first = TurnOrder.firstMover(s.player, hostMove, s.enemy, guestMove, s.rng, false)
|
||||
end
|
||||
local order
|
||||
if first then
|
||||
order = { { s.player, s.enemy, hostAction }, { s.enemy, s.player, guestAction } }
|
||||
else
|
||||
order = { { s.enemy, s.player, guestAction }, { s.player, s.enemy, hostAction } }
|
||||
end
|
||||
for _, entry in ipairs(order) do
|
||||
s:act(function() s:executeAction(entry[1], entry[2], entry[3]) end)
|
||||
end
|
||||
elseif hostAction then
|
||||
s:act(function() s:executeAction(s.player, s.enemy, hostAction) end)
|
||||
elseif guestAction then
|
||||
s:act(function() s:executeAction(s.enemy, s.player, guestAction) end)
|
||||
end
|
||||
s:act(function() s:endOfTurn() end)
|
||||
end)
|
||||
end
|
||||
|
||||
self.resolveTurn = function() end -- a spectator's own input never drives anything
|
||||
self.resolveSwitch = function() end
|
||||
self.tryRun = function() end
|
||||
self.openParty = function(s) s.phase = "waitBoth" end
|
||||
|
||||
self.playerMonFainted = function(s)
|
||||
for _, mon in ipairs(hostParty) do
|
||||
if mon.hp > 0 then
|
||||
s:act(function() sendOutHost(s, mon) end)
|
||||
return
|
||||
end
|
||||
end
|
||||
s:sayNext(("%s is out of\nPOKéMON!\f%s wins!"):format(hostName, guestName))
|
||||
s.result = "guestWin"
|
||||
s.afterQueue = "finish"
|
||||
end
|
||||
|
||||
self.enemyMonFainted = function(s)
|
||||
for _, mon in ipairs(guestParty) do
|
||||
if mon.hp > 0 then
|
||||
s:act(function() sendOutGuest(s, mon) end)
|
||||
return
|
||||
end
|
||||
end
|
||||
s:sayNext(("%s is out of\nPOKéMON!\f%s wins!"):format(guestName, hostName))
|
||||
s.result = "hostWin"
|
||||
s.afterQueue = "finish"
|
||||
end
|
||||
|
||||
self.hostMsg, self.guestMsg = nil, nil
|
||||
local baseUpdate = self.update
|
||||
self.update = function(s, dt)
|
||||
net:update()
|
||||
for _, msg in ipairs(net:poll()) do
|
||||
if msg.type == "spectate" then
|
||||
local inner = msg.msg
|
||||
if inner.type == "action" then
|
||||
if msg.side == "host" then s.hostMsg = inner else s.guestMsg = inner end
|
||||
if s.hostMsg and s.guestMsg then
|
||||
local h, g = s.hostMsg, s.guestMsg
|
||||
s.hostMsg, s.guestMsg = nil, nil
|
||||
resolveSpecTurn(s, h, g)
|
||||
end
|
||||
elseif inner.type == "bye" or inner.type == "forfeit" then
|
||||
if not s.result then endSpectate(s, "The match ended.") end
|
||||
end
|
||||
-- "hello"/"party"/"hash" ride along too (Tournament.lua already
|
||||
-- consumed hello/party before building this battle); none of them
|
||||
-- need any action here
|
||||
else
|
||||
-- same reasoning as the real-participant loop above: don't lose a
|
||||
-- bracket_update/match_start_spectate that arrives mid-match
|
||||
s.pendingTournamentMessages = s.pendingTournamentMessages or {}
|
||||
table.insert(s.pendingTournamentMessages, msg)
|
||||
end
|
||||
end
|
||||
if net.closed and not s.linkEnded and not s.result then
|
||||
endSpectate(s)
|
||||
end
|
||||
if s.phase == "messages" and s.afterQueue == "linkNext" then
|
||||
if not s:updateQueue() then
|
||||
s.afterQueue = "waitBoth"
|
||||
s.phase = "waitBoth"
|
||||
end
|
||||
return
|
||||
end
|
||||
if s.phase == "menu" or s.phase == "waitBoth" then
|
||||
return -- frozen between resolved turns; never a real decision here
|
||||
end
|
||||
baseUpdate(s, dt)
|
||||
end
|
||||
|
||||
local baseFinish = self.finish
|
||||
self.finish = function(s)
|
||||
s.linkEnded = true
|
||||
if game.linkNet == net then game.linkNet = nil end
|
||||
baseFinish(s) -- deliberately doesn't touch net: it's the caller's
|
||||
-- tournament connection, still needed after this match
|
||||
end
|
||||
|
||||
return self
|
||||
end
|
||||
|
||||
-- backwards-compatible entry points (LinkState passes role explicitly)
|
||||
function LinkBattle.newHost(game, net, opts)
|
||||
opts.role = "host"
|
||||
|
||||
+134
-5
@@ -2,6 +2,7 @@
|
||||
-- the other joins by typing that address in. Direct peer-to-peer over
|
||||
-- lua-enet (bundled with LÖVE), no relay server.
|
||||
|
||||
local CodeEntry = require("src.link.CodeEntry")
|
||||
local Font = require("src.render.Font")
|
||||
local Handshake = require("src.link.Handshake")
|
||||
local Net = require("src.link.Net")
|
||||
@@ -16,6 +17,12 @@ LinkState.isOpaque = true
|
||||
|
||||
local CURSOR = 0xED
|
||||
|
||||
-- stages before any Net object is meaningfully "this session's link" --
|
||||
-- .net can still be a leftover failed attempt sitting on self, so error/
|
||||
-- closed checks below skip these rather than keying off self.net's
|
||||
-- presence alone
|
||||
local PRE_CONNECT_STAGES = { menu = true, lanMenu = true, onlineMenu = true }
|
||||
|
||||
-- how long the host waits for a v2 hello before deciding the peer predates
|
||||
-- the handshake (a pre-mod guest sends nothing until it hears the mode)
|
||||
local HELLO_GRACE = 2
|
||||
@@ -119,25 +126,54 @@ function LinkState:update(dt)
|
||||
local input = self.game.input
|
||||
if self.net then
|
||||
self.net:update()
|
||||
if self.net.error and self.stage ~= "menu" then
|
||||
if self.net.error and not PRE_CONNECT_STAGES[self.stage] then
|
||||
self:exitWith("Link error:\n" .. self.net.error:sub(1, 60))
|
||||
return
|
||||
end
|
||||
-- the peer vanished without a bye (only once the inbox is drained,
|
||||
-- so a final message travelling with the disconnect still counts)
|
||||
if self.net.closed and #self.net.inbox == 0
|
||||
and self.stage ~= "menu" and self.stage ~= "addrEntry"
|
||||
and self.stage ~= "notice" and self.stage ~= "battleRunning" then
|
||||
and not PRE_CONNECT_STAGES[self.stage] and self.stage ~= "addrEntry"
|
||||
and self.stage ~= "codeEntry" and self.stage ~= "notice"
|
||||
and self.stage ~= "battleRunning" then
|
||||
self:exitWith("The link was\nbroken.")
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
if self.stage == "menu" then
|
||||
if input:wasPressed("down") then
|
||||
self.index = self.index % 3 + 1
|
||||
elseif input:wasPressed("up") then
|
||||
self.index = (self.index - 2) % 3 + 1
|
||||
elseif input:wasPressed("b") then
|
||||
self:exitWith(nil)
|
||||
elseif input:wasPressed("a") then
|
||||
if self.index == 1 then
|
||||
self.stage = "lanMenu"
|
||||
self.index = 1
|
||||
elseif self.index == 2 or self.index == 3 then
|
||||
if not Handshake.onlineAllowed(self.game) then
|
||||
self:exitWith("Online play needs\nno mods enabled.\fDisable them in\nSTART > MODS.")
|
||||
return
|
||||
end
|
||||
if self.index == 2 then
|
||||
self.stage = "onlineMenu"
|
||||
else
|
||||
local Tournament = require("src.link.Tournament")
|
||||
self.game.stack:pop()
|
||||
self.game.stack:push(Tournament.new(self.game))
|
||||
end
|
||||
self.index = 1
|
||||
end
|
||||
end
|
||||
|
||||
elseif self.stage == "lanMenu" then
|
||||
if input:wasPressed("up") or input:wasPressed("down") then
|
||||
self.index = self.index == 1 and 2 or 1
|
||||
elseif input:wasPressed("b") then
|
||||
self:exitWith(nil)
|
||||
self.stage = "menu"
|
||||
self.index = 1
|
||||
elseif input:wasPressed("a") then
|
||||
self.net = Net.new()
|
||||
if self.index == 1 then
|
||||
@@ -151,6 +187,62 @@ function LinkState:update(dt)
|
||||
end
|
||||
end
|
||||
|
||||
elseif self.stage == "onlineMenu" then
|
||||
if input:wasPressed("up") or input:wasPressed("down") then
|
||||
self.index = self.index == 1 and 2 or 1
|
||||
elseif input:wasPressed("b") then
|
||||
self.stage = "menu"
|
||||
self.index = 2
|
||||
elseif input:wasPressed("a") then
|
||||
if self.index == 1 then
|
||||
self.net = Net.new()
|
||||
if self.net:hostOnline() then
|
||||
self.stage = "onlineHosting"
|
||||
else
|
||||
self:exitWith("Link error:\n" .. (self.net.error or "?"))
|
||||
end
|
||||
else
|
||||
self.stage = "codeEntry"
|
||||
self.codeEntry = CodeEntry.new()
|
||||
end
|
||||
end
|
||||
|
||||
elseif self.stage == "onlineHosting" then
|
||||
if input:wasPressed("b") then self:exitWith(nil) return end
|
||||
if self.net.paired then
|
||||
self.stage = "modeSelect"
|
||||
self.index = 1
|
||||
end
|
||||
|
||||
elseif self.stage == "codeEntry" then
|
||||
if input:wasPressed("b") then
|
||||
self.stage = "onlineMenu"
|
||||
self.index = 2
|
||||
elseif input:wasPressed("up") then
|
||||
CodeEntry.up(self.codeEntry)
|
||||
elseif input:wasPressed("down") then
|
||||
CodeEntry.down(self.codeEntry)
|
||||
elseif input:wasPressed("left") then
|
||||
CodeEntry.left(self.codeEntry)
|
||||
elseif input:wasPressed("right") then
|
||||
CodeEntry.right(self.codeEntry)
|
||||
elseif input:wasPressed("a") then
|
||||
local code = CodeEntry.text(self.codeEntry)
|
||||
self.net = Net.new()
|
||||
if self.net:joinOnline(nil, code) then
|
||||
self.stage = "onlineJoining"
|
||||
else
|
||||
self:exitWith("Link error:\n" .. (self.net.error or "?"))
|
||||
end
|
||||
end
|
||||
|
||||
elseif self.stage == "onlineJoining" then
|
||||
if input:wasPressed("b") then self:exitWith(nil) return end
|
||||
if self.net.paired then
|
||||
self.stage = "waitMode"
|
||||
self:sendHello(nil) -- the host owns the mode; this is just who we are
|
||||
end
|
||||
|
||||
elseif self.stage == "hosting" then
|
||||
if input:wasPressed("b") then self:exitWith(nil) return end
|
||||
if self.net.paired then
|
||||
@@ -389,12 +481,49 @@ end
|
||||
|
||||
function LinkState:draw()
|
||||
if self.stage == "menu" then
|
||||
drawTitle("LINK CABLE CLUB")
|
||||
drawTitle("BOIS CLUB LIVE")
|
||||
Font.draw("LINK CABLE (LAN)", 32, 44)
|
||||
Font.draw("ONLINE MATCH", 32, 60)
|
||||
Font.draw("TOURNAMENT", 32, 76)
|
||||
Font.drawCode(CURSOR, 24, 44 + (self.index - 1) * 16)
|
||||
|
||||
elseif self.stage == "lanMenu" then
|
||||
drawTitle("LINK CABLE (LAN)")
|
||||
Font.draw("HOST A GAME", 32, 48)
|
||||
Font.draw("JOIN A GAME", 32, 68)
|
||||
Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68)
|
||||
Font.draw("UDP port " .. Net.defaultPort(), 8, 128)
|
||||
|
||||
elseif self.stage == "onlineMenu" then
|
||||
drawTitle("ONLINE MATCH")
|
||||
Font.draw("HOST ONLINE", 32, 48)
|
||||
Font.draw("JOIN ONLINE", 32, 68)
|
||||
Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68)
|
||||
|
||||
elseif self.stage == "onlineHosting" then
|
||||
drawTitle("HOSTING ONLINE")
|
||||
Font.draw("Tell your friend", 16, 40)
|
||||
Font.draw("the code:", 16, 52)
|
||||
Font.draw(self.net.code or "??????", 32, 68)
|
||||
Font.draw("Waiting for join...", 8, 96)
|
||||
|
||||
elseif self.stage == "codeEntry" then
|
||||
drawTitle("ENTER CODE")
|
||||
for i = 1, CodeEntry.LENGTH do
|
||||
local x = 16 + (i - 1) * 16
|
||||
local ch = CodeEntry.CHARSET:sub(self.codeEntry.chars[i], self.codeEntry.chars[i])
|
||||
Font.draw(ch, x, 64)
|
||||
if i == self.codeEntry.pos then
|
||||
Font.drawCode(0xEE, x, 76) -- ▼ under the active slot
|
||||
end
|
||||
end
|
||||
Font.draw("A: connect B: back", 8, 128)
|
||||
|
||||
elseif self.stage == "onlineJoining" then
|
||||
drawTitle("CONNECTING...")
|
||||
Font.draw("Calling...", 8, 56)
|
||||
Font.draw(self.net.target or "", 8, 72)
|
||||
|
||||
elseif self.stage == "hosting" then
|
||||
drawTitle("HOSTING")
|
||||
Font.draw("Friend joins at:", 16, 48)
|
||||
|
||||
+170
-1
@@ -14,6 +14,15 @@
|
||||
-- Plain luajit (headless tests) has no enet; Net.available() reports
|
||||
-- that, and Net.loopbackPair() returns two in-memory ends with the
|
||||
-- same API so the protocol/battle logic stays testable offline.
|
||||
--
|
||||
-- A second backend, alongside the ENet one above, talks plain TCP to a
|
||||
-- pokeserver relay instead of direct peer-to-peer: both sides dial out
|
||||
-- to a public server (works through NAT with no hole-punching), the host
|
||||
-- gets a 6-character room code instead of an IP, and the server forwards
|
||||
-- messages between them. Same newline-delimited JSON on the wire, same
|
||||
-- Net API (host/join/send/update/poll/.paired/.closed/.error) -- see
|
||||
-- Net:hostOnline/Net:joinOnline below. LinkState/LinkBattle/Protocol don't
|
||||
-- know or care which backend is in play.
|
||||
|
||||
local Json = require("src.link.Json")
|
||||
local Logger = require("src.core.Logger")
|
||||
@@ -21,10 +30,14 @@ local Logger = require("src.core.Logger")
|
||||
local hasEnet, enet = pcall(require, "enet")
|
||||
if not hasEnet then enet = nil end
|
||||
|
||||
local hasSocket, socket = pcall(require, "socket")
|
||||
if not hasSocket then socket = nil end
|
||||
|
||||
local Net = {}
|
||||
Net.__index = Net
|
||||
|
||||
Net.DEFAULT_PORT = 7777
|
||||
Net.DEFAULT_RELAY_ADDRESS = "147.182.215.255:7778"
|
||||
|
||||
function Net.available()
|
||||
return enet ~= nil
|
||||
@@ -34,6 +47,10 @@ function Net.defaultPort()
|
||||
return tonumber(os.getenv("POKEPORT_LINK_PORT") or "") or Net.DEFAULT_PORT
|
||||
end
|
||||
|
||||
function Net.defaultRelayAddress()
|
||||
return os.getenv("POKEPORT_RELAY_ADDR") or Net.DEFAULT_RELAY_ADDRESS
|
||||
end
|
||||
|
||||
-- monotonic-ish clock for the join timeout
|
||||
local function now()
|
||||
if love and love.timer and love.timer.getTime then
|
||||
@@ -64,13 +81,17 @@ function Net.new()
|
||||
enetHost = nil, -- our enet host object (both ends have one)
|
||||
peer = nil, -- the connected remote peer
|
||||
inbox = {},
|
||||
outbox = {}, -- messages queued before pairing completes
|
||||
outbox = {}, -- messages queued before pairing completes (enet only)
|
||||
paired = false,
|
||||
address = nil, -- host: "ip:port" the other player types in
|
||||
error = nil,
|
||||
closed = false,
|
||||
mode = nil,
|
||||
joinTimeout = 10,
|
||||
tcpSocket = nil, -- relay backend: the luasocket TCP connection
|
||||
rxBuf = "", -- relay backend: bytes read but not yet a full line
|
||||
txBuf = "", -- relay backend: bytes queued but not yet written
|
||||
code = nil, -- relay backend, hosting: the room/tournament code
|
||||
}, Net)
|
||||
end
|
||||
|
||||
@@ -133,8 +154,56 @@ function Net:join(address)
|
||||
return true
|
||||
end
|
||||
|
||||
-- opens a TCP connection to a pokeserver relay (blocking connect with a
|
||||
-- short timeout -- this runs once, from a single explicit user action, not
|
||||
-- from a per-frame poll, so blocking briefly is fine). Callers then send
|
||||
-- whatever control message starts their session ({type="host"},
|
||||
-- {type="join",...}, {type="host_tournament",...}, ...); every reply that
|
||||
-- isn't one of the four generic ones below lands in the normal inbox for
|
||||
-- the caller (LinkState, or Tournament.lua) to interpret.
|
||||
function Net:connectTCP(addr)
|
||||
if not socket then
|
||||
self.error = "online play needs luasocket (bundled with LOVE)"
|
||||
return false
|
||||
end
|
||||
local host, port = addr:match("^(.-):(%d+)$")
|
||||
host = host or addr
|
||||
port = tonumber(port) or 7778
|
||||
local tcp = socket.tcp()
|
||||
tcp:settimeout(5)
|
||||
local ok, err = tcp:connect(host, port)
|
||||
if not ok then
|
||||
self.error = ("can't reach relay %s:%d\n(%s)"):format(host, port, tostring(err))
|
||||
return false
|
||||
end
|
||||
tcp:settimeout(0)
|
||||
self.tcpSocket = tcp
|
||||
self.rxBuf = ""
|
||||
self.txBuf = ""
|
||||
return true
|
||||
end
|
||||
|
||||
function Net:hostOnline(addr)
|
||||
if not self:connectTCP(addr or Net.defaultRelayAddress()) then return false end
|
||||
self.mode = "onlineHosting"
|
||||
self:send({ type = "host" })
|
||||
return true
|
||||
end
|
||||
|
||||
function Net:joinOnline(addr, code)
|
||||
if not self:connectTCP(addr or Net.defaultRelayAddress()) then return false end
|
||||
self.mode = "onlineJoining"
|
||||
self.target = code
|
||||
self:send({ type = "join", code = code })
|
||||
return true
|
||||
end
|
||||
|
||||
function Net:send(msg)
|
||||
if self.closed then return end
|
||||
if self.tcpSocket then
|
||||
self.txBuf = self.txBuf .. Json.encode(msg) .. "\n"
|
||||
return
|
||||
end
|
||||
if self.peerEnd then -- loopback: re-encode through json like the wire
|
||||
local decoded = Json.decode(Json.encode(msg))
|
||||
if decoded and not self.peerEnd.closed then
|
||||
@@ -155,8 +224,102 @@ function Net:send(msg)
|
||||
end
|
||||
end
|
||||
|
||||
-- one control message recognized on every relay connection, regardless of
|
||||
-- what it's being used for (a 1v1 room or a tournament): "peer_gone" only
|
||||
-- ever fires for a paired 1v1 room (tournaments signal disconnects through
|
||||
-- bracket_update/tournament_over instead), so it's unambiguous here.
|
||||
local function handleGenericRelayControl(self, msg)
|
||||
if msg.type == "hosted" then
|
||||
self.code = msg.code
|
||||
return true
|
||||
elseif msg.type == "paired" then
|
||||
self.paired = true
|
||||
return true
|
||||
elseif msg.type == "join_error" then
|
||||
self.error = ({
|
||||
not_found = "That code wasn't\nfound.",
|
||||
full = "That game already\nhas two players.",
|
||||
expired = "That code has\nexpired.",
|
||||
})[msg.reason] or ("Couldn't join:\n%s"):format(tostring(msg.reason))
|
||||
self.closed = true
|
||||
return true
|
||||
elseif msg.type == "peer_gone" then
|
||||
self.closed = true
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
function Net:handleTCPLine(line)
|
||||
local msg = Json.decode(line)
|
||||
if not msg then
|
||||
Logger.warn("link: bad relay message %q", line:sub(1, 60))
|
||||
return
|
||||
end
|
||||
if not handleGenericRelayControl(self, msg) then
|
||||
table.insert(self.inbox, msg)
|
||||
end
|
||||
end
|
||||
|
||||
-- pulls every complete "\n"-terminated line out of rxBuf (leaving a
|
||||
-- trailing partial line, if any, for the next call to complete) and hands
|
||||
-- each to handleTCPLine. Pure buffer manipulation, no socket -- factored
|
||||
-- out of updateTCP so the framing logic is testable without a real
|
||||
-- connection (see the relay-path tests in tests/run_link_tests.lua).
|
||||
function Net:drainLines()
|
||||
while true do
|
||||
local nl = self.rxBuf:find("\n", 1, true)
|
||||
if not nl then break end
|
||||
local line = self.rxBuf:sub(1, nl - 1)
|
||||
self.rxBuf = self.rxBuf:sub(nl + 1)
|
||||
if #line > 0 then self:handleTCPLine(line) end
|
||||
end
|
||||
end
|
||||
|
||||
-- non-blocking pump for the relay TCP backend: flush queued writes, drain
|
||||
-- whatever's arrived into complete lines. Uses a byte-count receive
|
||||
-- (rather than the "*l" pattern) because luasocket's "*l" doesn't let a
|
||||
-- non-blocking caller recover the partial line across calls -- a
|
||||
-- byte-count read hands back whatever's available via the third return
|
||||
-- value on timeout, which we can buffer ourselves.
|
||||
function Net:updateTCP()
|
||||
if self.closed then return end
|
||||
local sock = self.tcpSocket
|
||||
if #self.txBuf > 0 then
|
||||
local sent, err, lastByte = sock:send(self.txBuf)
|
||||
if sent then
|
||||
self.txBuf = ""
|
||||
elseif err == "timeout" then
|
||||
self.txBuf = self.txBuf:sub((lastByte or 0) + 1)
|
||||
else
|
||||
self.error = "send failed: " .. tostring(err)
|
||||
self.closed = true
|
||||
return
|
||||
end
|
||||
end
|
||||
while true do
|
||||
local data, err, partial = sock:receive(8192)
|
||||
local chunk = data or partial or ""
|
||||
if #chunk > 0 then self.rxBuf = self.rxBuf .. chunk end
|
||||
if err == "closed" then
|
||||
self.closed = true
|
||||
break
|
||||
elseif err and err ~= "timeout" then
|
||||
self.error = tostring(err)
|
||||
self.closed = true
|
||||
break
|
||||
end
|
||||
if not data then break end -- nothing more buffered this frame
|
||||
end
|
||||
self:drainLines()
|
||||
end
|
||||
|
||||
-- pump enet events; decoded JSON messages are queued for poll()
|
||||
function Net:update()
|
||||
if self.tcpSocket then
|
||||
self:updateTCP()
|
||||
return
|
||||
end
|
||||
if self.peerEnd then return end -- loopback needs no pumping
|
||||
if not self.enetHost or self.closed then return end
|
||||
while true do
|
||||
@@ -219,6 +382,12 @@ function Net:close()
|
||||
self.closed = true
|
||||
return
|
||||
end
|
||||
if self.tcpSocket then
|
||||
pcall(function() self.tcpSocket:close() end)
|
||||
self.tcpSocket = nil
|
||||
self.closed = true
|
||||
return
|
||||
end
|
||||
if self.enetHost then
|
||||
if self.peer and self.paired and not self.closed then
|
||||
-- graceful goodbye: disconnect_later delivers the queued
|
||||
|
||||
@@ -0,0 +1,577 @@
|
||||
-- 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 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 = 5 -- POKEMON, MIN LV, MAX LV, TIMER, PLAYING
|
||||
|
||||
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 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,
|
||||
participating = true }
|
||||
self.settingsIndex = 1
|
||||
self.roster = {}
|
||||
self.spectatorRoster = {}
|
||||
Sound.startLoop(game.data, MUSIC)
|
||||
return self
|
||||
end
|
||||
|
||||
function Tournament:exitWith(message)
|
||||
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),
|
||||
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.participating = msg.participating
|
||||
self.stage = "bracket"
|
||||
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.stage = "bracket"
|
||||
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,
|
||||
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,
|
||||
})
|
||||
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
|
||||
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
|
||||
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", "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",
|
||||
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
|
||||
@@ -18,6 +18,12 @@ ManagerState.isOpaque = true
|
||||
-- Game:keypressed recognizes a directly-pushed instance
|
||||
ManagerState.screenId = "ManagerState"
|
||||
|
||||
-- Same as OptionsMenu: without this, title LOGO zones leak through when
|
||||
-- MODS is opened from the title-screen options path.
|
||||
function ManagerState:sgbPalettes(game)
|
||||
return require("src.render.PaletteFX").wholeNamed(game.data, "MEWMON")
|
||||
end
|
||||
|
||||
-- the charmap has no * ~ + < > glyphs, so the status gutter uses what it
|
||||
-- does have: staged-awaiting-restart, disabled, errored, dep-unhealthy
|
||||
local GLYPH = { staged = ".", disabled = "-", errored = "!", blocked = "?" }
|
||||
|
||||
@@ -872,11 +872,15 @@ R.palettes = {
|
||||
}
|
||||
|
||||
-- keyed by species id, unlike the vanilla byDex array: a species past the
|
||||
-- end of the dex gets an icon without punching a hole in the list
|
||||
-- end of the dex gets an icon without punching a hole in the list. The party
|
||||
-- menu (src/ui/PartyMenu.lua) reads this per-species entry before the vanilla
|
||||
-- dex-indexed default. The value is a built-in icon NAME -- one of BALL, BIRD,
|
||||
-- BUG, FAIRY, GRASS, HELIX, MON, QUADRUPED, SNAKE, WATER (uppercase) -- or a
|
||||
-- { image = <bundled file path>, frames? } table of your own art.
|
||||
R.icons = {
|
||||
semantics = "record", target = "icons.bySpecies",
|
||||
value = f.union{ f.str, f.rec{ image = f.path, frames = f.opt(f.int(1)) } },
|
||||
example = 'mod.content.icons:register("MISSINGNO", { image = "glitch.png" })',
|
||||
example = 'mod.content.icons:register("MODMON", "QUADRUPED") -- a built-in name, or { image = mod.assets:path("icon.png"), frames = 2 }',
|
||||
}
|
||||
|
||||
-- glyph codes are not bytes: the vanilla pages sit at $60/$80 but a
|
||||
|
||||
@@ -230,6 +230,11 @@ function BattleTransition:draw()
|
||||
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
local prog = math.min(1, self.t / self.wipeLen)
|
||||
-- Cascade black 8x8 blocks across the window area *outside* the classic
|
||||
-- 160x144 wipe square, in lockstep with the OG wipe progress. Renderer
|
||||
-- paints them in screen space after the world blit (see endFrame).
|
||||
local renderer = self.game and self.game.renderer
|
||||
if renderer then renderer.battleCascadeProg = prog end
|
||||
local style = self.style
|
||||
|
||||
-- a registered style may draw itself; the eight built-ins do not
|
||||
|
||||
@@ -148,6 +148,17 @@ function Font.advanceOf(code)
|
||||
return page and page.advance or GLYPH
|
||||
end
|
||||
|
||||
-- Pixel width of a string (glyph advances, not UTF-8 byte length).
|
||||
-- Multi-byte charmap entries like "¥" are one glyph; callers that
|
||||
-- right-align with `#text * 8` mis-place them.
|
||||
function Font.width(text)
|
||||
local w = 0
|
||||
for _, code in ipairs(Font.encode(text)) do
|
||||
w = w + Font.advanceOf(code)
|
||||
end
|
||||
return w
|
||||
end
|
||||
|
||||
-- Draw a plain single-line string at pixel (x, y). Returns the width
|
||||
-- drawn, which is #codes * 8 for every fixed-width page.
|
||||
function Font.draw(text, x, y)
|
||||
|
||||
+29
-1
@@ -22,6 +22,15 @@ GBCFX.level = 0
|
||||
|
||||
local shader -- false = unavailable (headless / no shader support)
|
||||
|
||||
-- Mobile GPUs often compile this pass but present a black frame, and the
|
||||
-- level persists in options.lua -- soft-bricking the APK until a manual
|
||||
-- edit (issue #136). Desktop is unchanged; Android/iOS refuse the effect.
|
||||
function GBCFX.isSupported()
|
||||
if not love or not love.system or not love.system.getOS then return true end
|
||||
local osName = love.system.getOS()
|
||||
return osName ~= "Android" and osName ~= "iOS"
|
||||
end
|
||||
|
||||
-- GLSL 1.20-compatible (no array initializers; wavelength terms and the
|
||||
-- shadow blur are unrolled by hand).
|
||||
local SHADER_SRC = [[
|
||||
@@ -214,6 +223,7 @@ vec4 effect(vec4 color, Image tex, vec2 tc, vec2 pc)
|
||||
GBCFX.SHADER_SRC = SHADER_SRC -- exposed for the standalone compile check
|
||||
|
||||
function GBCFX.shader()
|
||||
if not GBCFX.isSupported() then return nil end
|
||||
if shader == nil then
|
||||
local ok, sh = pcall(love.graphics.newShader, SHADER_SRC)
|
||||
shader = ok and sh or false
|
||||
@@ -222,6 +232,10 @@ function GBCFX.shader()
|
||||
end
|
||||
|
||||
function GBCFX.setLevel(level)
|
||||
if not GBCFX.isSupported() then
|
||||
GBCFX.level = 0
|
||||
return
|
||||
end
|
||||
level = math.floor(tonumber(level) or 0)
|
||||
if level < 0 then level = 0 end
|
||||
if level > 4 then level = 4 end
|
||||
@@ -230,12 +244,26 @@ end
|
||||
|
||||
-- Advance OFF → 1 → 2 → 3 → 4 → OFF. Returns the new level.
|
||||
function GBCFX.cycle()
|
||||
if not GBCFX.isSupported() then
|
||||
GBCFX.level = 0
|
||||
return 0
|
||||
end
|
||||
GBCFX.setLevel((GBCFX.level + 1) % 5)
|
||||
return GBCFX.level
|
||||
end
|
||||
|
||||
-- Apply opts.gbcfx. On unsupported platforms force OFF and clear a
|
||||
-- persisted non-zero value so boot recovers from a soft-brick. Returns
|
||||
-- true when opts was sanitized (caller should persist options.lua).
|
||||
function GBCFX.applyOptions(opts)
|
||||
if not GBCFX.isSupported() then
|
||||
local had = opts and (tonumber(opts.gbcfx) or 0) ~= 0
|
||||
if opts then opts.gbcfx = 0 end
|
||||
GBCFX.level = 0
|
||||
return had and true or false
|
||||
end
|
||||
GBCFX.setLevel(opts and opts.gbcfx or 0)
|
||||
return false
|
||||
end
|
||||
|
||||
function GBCFX.levelLabel(level)
|
||||
@@ -243,7 +271,7 @@ function GBCFX.levelLabel(level)
|
||||
end
|
||||
|
||||
function GBCFX.active()
|
||||
return GBCFX.level > 0 and GBCFX.shader() ~= nil
|
||||
return GBCFX.isSupported() and GBCFX.level > 0 and GBCFX.shader() ~= nil
|
||||
end
|
||||
|
||||
-- Draw `canvas` fullscreen through the GBC FX shader into the current
|
||||
|
||||
+116
-21
@@ -10,16 +10,23 @@
|
||||
-- RED++ swaps the named-palette pack for pokered-gbc SuperPalettes
|
||||
-- (data/palettes_gbc.lua), including per-species mon colors.
|
||||
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
|
||||
local PaletteFX = {}
|
||||
|
||||
local shader -- false = unavailable (headless / no shader support)
|
||||
local gbcPack -- false = missing; nil = not loaded yet
|
||||
|
||||
-- Cycle order matches OptionsMenu / hotkey 2
|
||||
PaletteFX.MODES = { "gbc", "redpp", "og", "og_inv", "gbc_inv", "classic" }
|
||||
-- Cycle order matches OptionsMenu / hotkey 2. The three real colorizations
|
||||
-- come first (OG RED = GBC hardware, SGB = per-map Super Game Boy, RED++ =
|
||||
-- pokered-gbc per-tile), then the DMG-shade novelty modes.
|
||||
PaletteFX.MODES = { "ogred", "gbc", "redpp", "og", "og_inv", "gbc_inv", "classic" }
|
||||
-- `gbc`/`gbc_inv` keep their save-value ids for back-compat; their LABELS are
|
||||
-- "SGB"/"SGB INV" because that is what the mode actually is (the old "GBC"
|
||||
-- label was a misnomer -- it never was the real Game Boy Color palette).
|
||||
PaletteFX.MODE_LABELS = {
|
||||
gbc = "GBC", redpp = "RED++", og = "OG", og_inv = "OG INV",
|
||||
gbc_inv = "GBC INV", classic = "CLASSIC",
|
||||
ogred = "OG RED", gbc = "SGB", redpp = "RED++", og = "OG",
|
||||
og_inv = "OG INV", gbc_inv = "SGB INV", classic = "CLASSIC",
|
||||
}
|
||||
PaletteFX.mode = "gbc"
|
||||
|
||||
@@ -28,6 +35,40 @@ PaletteFX.CLASSIC = {
|
||||
{ 155, 188, 15 }, { 139, 172, 15 }, { 48, 98, 48 }, { 15, 56, 15 },
|
||||
}
|
||||
|
||||
-- OG RED: the Game Boy Color boot-ROM auto-palette for Pokemon Red. Pokemon
|
||||
-- Red ships no CGB code (pokered's wOnCGB is hardwired 0), so on a Game Boy
|
||||
-- Color the boot ROM colorizes it with ONE global palette pair -- a red
|
||||
-- background and green objects -- applied to the whole game with no per-map
|
||||
-- variation (that variety was the Super Game Boy's doing, i.e. SGB mode).
|
||||
-- Lightest shade first, matching the SGB palette tables. Values verified
|
||||
-- against hardware captures of Pallet Town and Oak's Lab.
|
||||
PaletteFX.GBC_BG = {
|
||||
{ 255, 255, 255 }, { 255, 132, 132 }, { 148, 58, 58 }, { 0, 0, 0 },
|
||||
}
|
||||
PaletteFX.GBC_OBJ = {
|
||||
{ 255, 255, 255 }, { 123, 255, 49 }, { 0, 132, 0 }, { 0, 0, 0 },
|
||||
}
|
||||
|
||||
-- OG BLUE: Pokemon Blue's Game Boy Color boot-ROM auto-palette. Same
|
||||
-- one-global-pair scheme as OG RED (Blue also ships no CGB code), but the
|
||||
-- boot ROM colorizes the background blue instead of red -- so "OG RED" for a
|
||||
-- Blue playthrough is white -> light blue -> dark blue -> black, mirroring
|
||||
-- GBC_BG channel-for-channel so the blue reads at the same brightness. The
|
||||
-- OBJ (sprite) palette stays the same green, matching how Red and Blue share
|
||||
-- the green-character look on a Game Boy Color.
|
||||
PaletteFX.GBC_BG_BLUE = {
|
||||
{ 255, 255, 255 }, { 132, 132, 255 }, { 58, 58, 148 }, { 0, 0, 0 },
|
||||
}
|
||||
|
||||
-- The active game's OG boot-ROM background palette: blue for a Blue
|
||||
-- playthrough, red otherwise. White (index 1) and black (index 4) are
|
||||
-- identical across versions, so callers that only touch the endpoints
|
||||
-- (e.g. BattleState's zone white/black snap) need no version branch.
|
||||
function PaletteFX.ogBg()
|
||||
if GameVersion.isBlue() then return PaletteFX.GBC_BG_BLUE end
|
||||
return PaletteFX.GBC_BG
|
||||
end
|
||||
|
||||
local INV_MAP = { [0] = 3, [1] = 2, [2] = 1, [3] = 0 }
|
||||
|
||||
function PaletteFX.shader()
|
||||
@@ -141,16 +182,18 @@ function PaletteFX.usesGbcPack(mode)
|
||||
return mode == "redpp"
|
||||
end
|
||||
|
||||
-- Per-object overworld sprite coloring (ColorOverworldSprite) applies in
|
||||
-- plain GBC mode too, not only under the RED++ pack: without it the
|
||||
-- whole-map zone shader paints characters with whatever two mid shades
|
||||
-- the terrain palette defines. RED++ handles sprites through the baked
|
||||
-- usesGbcPack() path in SpriteRenderer; this names the modes where the
|
||||
-- OBP bake plus the post-zone redraw (below) stand in for real OBJ
|
||||
-- palettes over a shader-colorized background.
|
||||
-- Whether the active mode bakes a per-OBJ palette onto overworld sprites
|
||||
-- (the OBP bake + post-zone redraw path). ONLY OG RED does: it wears the
|
||||
-- GBC boot-ROM green object palette (PaletteFX.GBC_OBJ) so the player and
|
||||
-- NPCs stay green over the red background, exactly like Pokemon Red on a
|
||||
-- Game Boy Color. SGB mode deliberately does NOT: an SGB OBJ carries no
|
||||
-- palette of its own, so the characters tint with the whole-map region
|
||||
-- palette along with the terrain (the Super Game Boy never colored Pokemon
|
||||
-- Red's sprites separately -- baking a per-sprite palette there was the
|
||||
-- "reds coloring on the player/NPCs" bug). RED++ colors sprites through
|
||||
-- the usesGbcPack() path in SpriteRenderer instead.
|
||||
function PaletteFX.usesSpriteObp(mode)
|
||||
mode = mode or PaletteFX.mode
|
||||
return mode == "gbc"
|
||||
return (mode or PaletteFX.mode) == "ogred"
|
||||
end
|
||||
|
||||
-- ------- post-zone sprite redraw (GBC mode)
|
||||
@@ -199,14 +242,38 @@ function PaletteFX.pack(data)
|
||||
return data and data.palettes or nil
|
||||
end
|
||||
|
||||
-- SuperPalettes that differ between Red and Blue (pokered data/sgb/
|
||||
-- sgb_palettes.asm IF DEF(_RED)/_BLUE). data/palettes_gbc.lua is the
|
||||
-- Red-derived pokered-gbc pack, so under RED++ a Blue playthrough must
|
||||
-- read these from the ROM-imported table or the title ribbon stays red
|
||||
-- and the Game Corner reels keep Red's pink (issue #128).
|
||||
local BLUE_VERSIONED = {
|
||||
LOGO1 = true, SLOTS2 = true, SLOTS3 = true, SLOTS4 = true,
|
||||
}
|
||||
|
||||
local function romNamedPal(data, name)
|
||||
local p = data and data.palettes
|
||||
return p and p.palettes and p.palettes[name]
|
||||
end
|
||||
|
||||
-- named palette from the active pack (nil on stale builds / missing name).
|
||||
-- RED++ falls back to the ROM pack for names the gbc table omits (rare).
|
||||
-- OG RED short-circuits EVERY name to the one global GBC boot-ROM BG palette
|
||||
-- (the hardware had a single BGP for the whole game), so terrain zones,
|
||||
-- battle HP bars / text, and menu boxes all come out red -- everything a
|
||||
-- background tile drew. Objects do not come through here (they bake
|
||||
-- GBC_OBJ green), so this stays a BG-only hook.
|
||||
function PaletteFX.pal(data, name)
|
||||
if PaletteFX.mode == "ogred" then return PaletteFX.ogBg() end
|
||||
if GameVersion.isBlue() and BLUE_VERSIONED[name] then
|
||||
local fromRom = romNamedPal(data, name)
|
||||
if fromRom then return fromRom end
|
||||
end
|
||||
local p = PaletteFX.pack(data)
|
||||
local c = p and p.palettes[name]
|
||||
if c then return c end
|
||||
if PaletteFX.usesGbcPack() and data and data.palettes then
|
||||
return data.palettes.palettes[name]
|
||||
if PaletteFX.usesGbcPack() then
|
||||
return romNamedPal(data, name)
|
||||
end
|
||||
return nil
|
||||
end
|
||||
@@ -217,12 +284,27 @@ end
|
||||
-- Transformed mon's pic is tinted gray, not the copied species' own
|
||||
-- SGB color). RED++ uses per-species pals from mon_palettes.asm.
|
||||
function PaletteFX.monPal(data, species, transformed)
|
||||
-- OG RED: a battle mon pic is a BG tile on the Game Boy Color (drawn into
|
||||
-- the tilemap, colored by BGP), so it wears the global red BG palette, not
|
||||
-- a per-species one -- matching the hardware capture where both mons are
|
||||
-- red/pink on the white field.
|
||||
if PaletteFX.mode == "ogred" then return PaletteFX.ogBg() end
|
||||
local p = PaletteFX.pack(data)
|
||||
if not p then return nil end
|
||||
if transformed then
|
||||
return p.palettes.GRAYMON
|
||||
or (data and data.palettes and data.palettes.palettes.GRAYMON)
|
||||
end
|
||||
-- a species' own `palette` field (per-record override) wins over the
|
||||
-- vanilla species->name map. Mod-registered palettes live in
|
||||
-- data.palettes.palettes even when the active pack is the RED++ gbc pack,
|
||||
-- so fall back to it when the pack itself doesn't carry the name.
|
||||
local def = data and data.pokemon and data.pokemon[species]
|
||||
if def and def.palette then
|
||||
local pal = p.palettes[def.palette]
|
||||
or (data and data.palettes and data.palettes.palettes[def.palette])
|
||||
if pal then return pal end
|
||||
end
|
||||
local name = p.pokemon[species] or "MEWMON"
|
||||
local c = p.palettes[name]
|
||||
if c then return c end
|
||||
@@ -236,6 +318,12 @@ end
|
||||
-- palette name a species currently resolves to (for image-cache keys)
|
||||
function PaletteFX.monPalName(data, species, transformed)
|
||||
if transformed then return "GRAYMON" end
|
||||
-- honor the per-record palette override, matching monPal
|
||||
local def = data and data.pokemon and data.pokemon[species]
|
||||
if def and def.palette and data.palettes
|
||||
and data.palettes.palettes[def.palette] then
|
||||
return def.palette
|
||||
end
|
||||
local p = PaletteFX.pack(data)
|
||||
if p and p.pokemon[species] then return p.pokemon[species] end
|
||||
if data and data.palettes and data.palettes.pokemon[species] then
|
||||
@@ -281,18 +369,21 @@ local TILESET_GROUP_EXCEPTIONS = {
|
||||
CEMETERY = { tiles = { [0x22] = true }, group = 0 },
|
||||
}
|
||||
|
||||
-- pokered-gbc's lobby.bst repoints the Celadon roof table's flat top
|
||||
-- pokered-gbc's lobby.bst repoints the Celadon LOBBY table's flat top
|
||||
-- (block 29, cells 5/6/9/10) at a duplicate tile ($5a, BROWN) so the
|
||||
-- tabletop and the checkerboard floor -- both raw tile $37 -- can take
|
||||
-- different palettes; the vanilla-derived blockset shares the one tile
|
||||
-- id, so the RED++ atlas path re-creates the duplicate: the alias slot
|
||||
-- is baked as a copy of `tile` in `group`'s colors, and the listed
|
||||
-- 0-based block cells draw the alias instead of the shared tile.
|
||||
-- Same block appears on CELADON_MART_ROOF (#52) and CELADON_DINER (#84).
|
||||
local LOBBY_TABLE_TOP_ALIAS = {
|
||||
{ block = 29, cells = { [5] = true, [6] = true, [9] = true, [10] = true },
|
||||
tile = 0x37, alias = 0x5a, group = 5 },
|
||||
}
|
||||
PaletteFX.TILE_ALIASES = {
|
||||
CELADON_MART_ROOF = {
|
||||
{ block = 29, cells = { [5] = true, [6] = true, [9] = true, [10] = true },
|
||||
tile = 0x37, alias = 0x5a, group = 5 },
|
||||
},
|
||||
CELADON_MART_ROOF = LOBBY_TABLE_TOP_ALIAS,
|
||||
CELADON_DINER = LOBBY_TABLE_TOP_ALIAS,
|
||||
}
|
||||
local ROOF_GROUP = 6
|
||||
local ROUTE_6_SAFFRON = { mapId = "ROUTE_6", useMapId = "SAFFRON_CITY", cellYBelow = 2 }
|
||||
@@ -458,7 +549,11 @@ function PaletteFX.applyOptions(opts)
|
||||
end
|
||||
|
||||
function PaletteFX.modeLabel(mode)
|
||||
return PaletteFX.MODE_LABELS[mode or PaletteFX.mode] or "GBC"
|
||||
mode = mode or PaletteFX.mode
|
||||
-- The GBC boot-ROM mode wears the running game's name: it is red for Red and
|
||||
-- blue for Blue (see ogBg), so a Blue playthrough shows "OG BLUE".
|
||||
if mode == "ogred" and GameVersion.isBlue() then return "OG BLUE" end
|
||||
return PaletteFX.MODE_LABELS[mode] or "GBC"
|
||||
end
|
||||
|
||||
-- When a state exposes no SGB zones but COLORS needs a forced palette
|
||||
|
||||
+129
-16
@@ -23,6 +23,29 @@ Renderer.HEIGHT = 144
|
||||
-- endFrame composites the padded canvas back with a matching offset.
|
||||
Renderer.UPRIGHT_MARGIN = 160
|
||||
|
||||
-- LOVE units + framebuffer pixels + the live unit→pixel ratio.
|
||||
-- Android's DisplayMetrics.density (love.graphics.getDPIScale) is often
|
||||
-- non-integer (1.5, 2.75, …). Integer scaling in units then maps each GB
|
||||
-- pixel to a fractional number of framebuffer pixels → shimmer, uneven /
|
||||
-- non-square "pixels", and movement judder (issue #87). Always derive the
|
||||
-- crisp integer scale from the drawable pixel size; draw with (pixels/dpi)
|
||||
-- so the GPU lands on whole framebuffer pixels. Desktop dpi=1 is unchanged.
|
||||
local function displayMetrics()
|
||||
local ww, wh = love.graphics.getDimensions()
|
||||
local pw, ph = ww, wh
|
||||
if love.graphics.getPixelDimensions then
|
||||
pw, ph = love.graphics.getPixelDimensions()
|
||||
end
|
||||
local dpi = 1
|
||||
if ww > 0 and pw > 0 then
|
||||
dpi = pw / ww
|
||||
elseif love.graphics.getDPIScale then
|
||||
dpi = love.graphics.getDPIScale()
|
||||
end
|
||||
if not dpi or dpi < 1e-6 then dpi = 1 end
|
||||
return ww, wh, pw, ph, dpi
|
||||
end
|
||||
|
||||
function Renderer:init()
|
||||
self.canvas = love.graphics.newCanvas(self.WIDTH, self.HEIGHT)
|
||||
self.canvas:setFilter("nearest", "nearest")
|
||||
@@ -36,10 +59,12 @@ function Renderer:init()
|
||||
self.uprightActive = false
|
||||
end
|
||||
|
||||
-- integer scale that fits the GB UI viewport in the window
|
||||
-- Integer framebuffer pixels per GB pixel that fit the window. Zoom /
|
||||
-- GBCFX / callers treat this as the crisp scale; endFrame converts to LOVE
|
||||
-- units via / dpi when drawing.
|
||||
function Renderer:fitScale()
|
||||
local ww, wh = love.graphics.getDimensions()
|
||||
return math.max(1, math.floor(math.min(ww / self.WIDTH, wh / self.HEIGHT)))
|
||||
local _, _, pw, ph = displayMetrics()
|
||||
return math.max(1, math.floor(math.min(pw / self.WIDTH, ph / self.HEIGHT)))
|
||||
end
|
||||
|
||||
-- world-pass canvas size in world pixels: enough to fill the window at s'.
|
||||
@@ -48,9 +73,13 @@ end
|
||||
-- background peeking at the receded top/bottom corners; flat mode returns
|
||||
-- exactly today's size (growth factor is 1 when tilt is inactive).
|
||||
function Renderer:worldViewSize()
|
||||
local ww, wh = love.graphics.getDimensions()
|
||||
local s = Zoom.scale(self:fitScale())
|
||||
local ww, wh, _, _, dpi = displayMetrics()
|
||||
local s = Zoom.scale(self:fitScale()) / dpi
|
||||
local vw, vh = Zoom.fillViewSize(s, ww, wh)
|
||||
-- Even sizes keep Camera:follow on integer pixels (viewW/2 is integral),
|
||||
-- so unfloored FX/sprite math cannot phase-shimmer against the tile layer.
|
||||
if vw % 2 ~= 0 then vw = vw + 1 end
|
||||
if vh % 2 ~= 0 then vh = vh + 1 end
|
||||
if Tilt.active() then
|
||||
local g = Tilt.viewGrowth()
|
||||
vw, vh = math.ceil(vw * g), math.ceil(vh * g)
|
||||
@@ -62,6 +91,11 @@ end
|
||||
function Renderer:beginFrame(transparent)
|
||||
self.worldActive = false
|
||||
self.uprightActive = false
|
||||
-- warp-fade overlay from Transition (issue #121); cleared each frame so
|
||||
-- a popped transition cannot leave a sticky black veil
|
||||
self.worldFadeAlpha = nil
|
||||
-- battle-transition cascade outside the 160x144 wipe (BattleTransition)
|
||||
self.battleCascadeProg = nil
|
||||
-- last frame's trueColor rects and sprite redraws go before anything
|
||||
-- draws this one
|
||||
PaletteFX.clearTrueColor()
|
||||
@@ -75,10 +109,54 @@ function Renderer:beginFrame(transparent)
|
||||
end
|
||||
end
|
||||
|
||||
-- Black 8x8 (scaled) blocks cascading outward from the classic GB letterbox
|
||||
-- into the surrounding window, matching BattleTransition wipe progress.
|
||||
-- Tiles that sit entirely inside the 160x144 square are left to the OG wipe.
|
||||
function Renderer:drawBattleCascade(prog, ww, wh, ox, oy, vpw, vph, S)
|
||||
if not prog or prog <= 0 then return end
|
||||
local TILE = 8 * S
|
||||
if TILE < 1 then TILE = 1 end
|
||||
local cols = math.ceil(ww / TILE)
|
||||
local rows = math.ceil(wh / TILE)
|
||||
local cx, cy = ox + vpw / 2, oy + vph / 2
|
||||
local order = {}
|
||||
for row = 0, rows - 1 do
|
||||
for col = 0, cols - 1 do
|
||||
local x, y = col * TILE, row * TILE
|
||||
-- any tile with area outside the letterbox participates
|
||||
if x < ox or y < oy or x + TILE > ox + vpw or y + TILE > oy + vph then
|
||||
local dist = math.max(math.abs(x + TILE / 2 - cx),
|
||||
math.abs(y + TILE / 2 - cy))
|
||||
order[#order + 1] = { x, y, dist }
|
||||
end
|
||||
end
|
||||
end
|
||||
if #order == 0 then return end
|
||||
table.sort(order, function(a, b)
|
||||
if a[3] ~= b[3] then return a[3] < b[3] end
|
||||
if a[2] ~= b[2] then return a[2] < b[2] end
|
||||
return a[1] < b[1]
|
||||
end)
|
||||
local n = math.floor(#order * math.min(1, prog) + 1e-6)
|
||||
if prog >= 1 then n = #order end
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
love.graphics.setScissor(0, 0, ww, wh)
|
||||
for i = 1, n do
|
||||
local t = order[i]
|
||||
love.graphics.rectangle("fill", t[1], t[2], TILE, TILE)
|
||||
end
|
||||
love.graphics.setScissor()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
function Renderer:beginWorldPass()
|
||||
local vw, vh = self:worldViewSize()
|
||||
if not self.worldCanvas or self.worldCanvas:getWidth() ~= vw
|
||||
or self.worldCanvas:getHeight() ~= vh then
|
||||
-- free the old canvas before replacing it: a zoom/tilt tween changes
|
||||
-- the view size every frame, so without this the superseded canvases
|
||||
-- pile up in VRAM until a GC finalizer happens to run
|
||||
if self.worldCanvas and self.worldCanvas.release then self.worldCanvas:release() end
|
||||
self.worldCanvas = love.graphics.newCanvas(vw, vh)
|
||||
self.worldCanvas:setFilter("nearest", "nearest")
|
||||
end
|
||||
@@ -107,6 +185,7 @@ function Renderer:beginUprightPass()
|
||||
local cw, ch = vw + 2 * M, vh + 2 * M
|
||||
if not self.uprightCanvas or self.uprightCanvas:getWidth() ~= cw
|
||||
or self.uprightCanvas:getHeight() ~= ch then
|
||||
if self.uprightCanvas and self.uprightCanvas.release then self.uprightCanvas:release() end
|
||||
self.uprightCanvas = love.graphics.newCanvas(cw, ch)
|
||||
self.uprightCanvas:setFilter("nearest", "nearest")
|
||||
end
|
||||
@@ -200,6 +279,7 @@ function Renderer:drawTiltedWorld(zoneList, s, wox, woy, target)
|
||||
-- 2x for extra crispness.
|
||||
if not self.tiltCanvas or self.tiltCanvas:getWidth() ~= wvw
|
||||
or self.tiltCanvas:getHeight() ~= wvh then
|
||||
if self.tiltCanvas and self.tiltCanvas.release then self.tiltCanvas:release() end
|
||||
self.tiltCanvas = love.graphics.newCanvas(wvw, wvh)
|
||||
self.tiltCanvas:setFilter("linear", "linear")
|
||||
end
|
||||
@@ -280,11 +360,14 @@ end
|
||||
-- presented through the GBC FX shader as a final pass.
|
||||
function Renderer:endFrame(zones, worldZones)
|
||||
love.graphics.setCanvas()
|
||||
local ww, wh = love.graphics.getDimensions()
|
||||
local S = self:fitScale()
|
||||
local ww, wh, pw, ph, dpi = displayMetrics()
|
||||
-- Sp = integer framebuffer pixels per GB pixel; S = LOVE-unit draw scale.
|
||||
local Sp = self:fitScale()
|
||||
local S = Sp / dpi
|
||||
local vpw, vph = self.WIDTH * S, self.HEIGHT * S
|
||||
local ox = math.floor((ww - vpw) / 2)
|
||||
local oy = math.floor((wh - vph) / 2)
|
||||
-- Snap the letterbox origin to a framebuffer pixel, then convert to units.
|
||||
local ox = math.floor((pw - self.WIDTH * Sp) / 2) / dpi
|
||||
local oy = math.floor((ph - self.HEIGHT * Sp) / 2) / dpi
|
||||
local GBCFX = require("src.render.GBCFX")
|
||||
-- Forced mono/Classic modes still need a whole-screen zone when a state
|
||||
-- exposes no SGB packets (raw DMG canvas), so sendColors can remap.
|
||||
@@ -308,13 +391,26 @@ function Renderer:endFrame(zones, worldZones)
|
||||
present = self.presentCanvas
|
||||
love.graphics.setCanvas(present)
|
||||
end
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
-- Default letterbox is black. Battle (and any state that opts in via
|
||||
-- letterboxWhite) fills the voids white so the window matches the
|
||||
-- white battle canvas instead of showing black bars.
|
||||
local clearR, clearG, clearB = 0, 0, 0
|
||||
if not self.worldActive then
|
||||
local ok, Game = pcall(require, "src.core.Game")
|
||||
local stack = ok and Game and Game.stack
|
||||
local base = stack and stack.visibleBase and stack:visibleBase()
|
||||
local state = base and stack.states and stack.states[base]
|
||||
if state and state.letterboxWhite then
|
||||
clearR, clearG, clearB = 1, 1, 1
|
||||
end
|
||||
end
|
||||
love.graphics.setColor(clearR, clearG, clearB, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, ww, wh)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
|
||||
-- blit `canvas` at integer `scale` into origin (bx, by), scissored to
|
||||
-- blit `canvas` at `scale` (LOVE units) into origin (bx, by), scissored to
|
||||
-- the (boxX, boxY, boxW, boxH) screen rect. zoneScale converts zone
|
||||
-- coords (canvas-space) into screen pixels.
|
||||
-- coords (canvas-space) into screen units.
|
||||
local function blit(canvas, scale, zoneList, zoneScale, bx, by, boxX, boxY, boxW, boxH)
|
||||
local shader = zoneList and zoneList[1] and PaletteFX.shader() or nil
|
||||
if not shader then
|
||||
@@ -346,11 +442,12 @@ function Renderer:endFrame(zones, worldZones)
|
||||
end
|
||||
|
||||
if self.worldActive then
|
||||
local s = Zoom.scale(S)
|
||||
local sp = Zoom.scale(Sp)
|
||||
local s = sp / dpi
|
||||
local wvw = self.worldCanvas:getWidth()
|
||||
local wvh = self.worldCanvas:getHeight()
|
||||
local wox = math.floor((ww - wvw * s) / 2)
|
||||
local woy = math.floor((wh - wvh * s) / 2)
|
||||
local wox = math.floor((pw - wvw * sp) / 2) / dpi
|
||||
local woy = math.floor((ph - wvh * sp) / 2) / dpi
|
||||
-- Tilt mode projects the ground world pass through the perspective mesh
|
||||
-- (SGB zones baked in beforehand -- see drawTiltedWorld -- so no zone
|
||||
-- scissoring here). drawTiltedWorld returns false when tilt is off or
|
||||
@@ -408,13 +505,29 @@ function Renderer:endFrame(zones, worldZones)
|
||||
love.graphics.draw(self.uprightCanvas, wox - M * s, woy - M * s, 0, s, s)
|
||||
love.graphics.setScissor()
|
||||
end
|
||||
-- Screen-space warp fade (Transition) over the full world composite so
|
||||
-- survey zoom / tilt edges darken with the center, not only the 160x144
|
||||
-- UI letterbox. Drawn before the UI blit so menus above a fade still
|
||||
-- composite normally if one is ever stacked that way.
|
||||
local fade = self.worldFadeAlpha
|
||||
if fade and fade > 0 then
|
||||
love.graphics.setColor(0, 0, 0, fade)
|
||||
love.graphics.rectangle("fill", 0, 0, ww, wh)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
-- Battle transition: cascade black blocks into the area outside the
|
||||
-- classic 160x144 wipe square (world still shows through until filled).
|
||||
if self.battleCascadeProg then
|
||||
self:drawBattleCascade(self.battleCascadeProg, ww, wh, ox, oy, vpw, vph, S)
|
||||
end
|
||||
end
|
||||
-- UI stays in the classic centered GB letterbox
|
||||
blit(self.canvas, S, zones, S, ox, oy, ox, oy, vpw, vph)
|
||||
|
||||
if present then
|
||||
love.graphics.setCanvas()
|
||||
GBCFX.present(present, S)
|
||||
-- shader grid/shadow math is in framebuffer pixels
|
||||
GBCFX.present(present, Sp)
|
||||
end
|
||||
self.worldActive = false
|
||||
self.uprightActive = false
|
||||
|
||||
@@ -114,14 +114,12 @@ function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip)
|
||||
image = getObpImage(self.def.image, colors, group)
|
||||
end
|
||||
elseif PaletteFX.usesSpriteObp() and PaletteFX.spriteRedrawPassActive() then
|
||||
-- plain GBC: the terrain zone shader still runs over the world canvas,
|
||||
-- so the baked sprite is also queued for a post-zone redraw
|
||||
-- (PaletteFX.markSpriteRedraw) that restores its own OBP colors on top
|
||||
local colors, group = PaletteFX.spriteObp(self.def, self.seed)
|
||||
if colors then
|
||||
image = getObpImage(self.def.image, colors, group)
|
||||
redraw = true
|
||||
end
|
||||
-- OG RED (GBC boot-ROM look): every OBJ wears the one global green
|
||||
-- object palette. The red BG zone shader still runs over the world
|
||||
-- canvas, so the baked sprite is queued for a post-zone redraw
|
||||
-- (PaletteFX.markSpriteRedraw) that restores its green pixels on top.
|
||||
image = getObpImage(self.def.image, PaletteFX.GBC_OBJ, "gbcobj")
|
||||
redraw = true
|
||||
end
|
||||
-- single-frame sprites (item balls, fossils...) have one fixed pose;
|
||||
-- still 3-frame sprites turn to face (the nurse at her machine,
|
||||
|
||||
+336
-128
@@ -10,19 +10,57 @@ TileRenderer.__index = TileRenderer
|
||||
|
||||
local BORDER_BLOCKS = 3 -- ring width; > half a screen (2.5 blocks)
|
||||
|
||||
-- OVERWORLD maps fill beyond-edge space with the solid tree wall
|
||||
-- (blockset $0F: four regular-tree metatiles, tiles $40/$41/$50/$51,
|
||||
-- the border block of ViridianCity/CeruleanCity/CeladonCity et al.),
|
||||
-- not each map's own border_block, which can be grass ($0B, the
|
||||
-- CutTreeBlockSwaps $0B->$0A cut-grass block) or water; other
|
||||
-- tilesets keep their designated border (interiors stay black/void)
|
||||
-- OVERWORLD maps fill beyond-edge space from save.options.voidFill:
|
||||
-- trees (default) — solid tree wall $0F (Viridian/Cerulean/Celadon)
|
||||
-- water — solid water $43 (Cinnabar/Route 19 border block)
|
||||
-- black — solid black (no tiled metatile)
|
||||
-- Other tilesets keep their designated border (interiors stay black/void).
|
||||
local TREE_WALL_BLOCK = 0x0F
|
||||
local WATER_BORDER_BLOCK = 0x43
|
||||
TileRenderer.VOID_FILLS = { "trees", "water", "black" }
|
||||
TileRenderer.voidFill = "trees"
|
||||
|
||||
local function borderBlockFor(map)
|
||||
if map.def.tileset == "OVERWORLD" then return TREE_WALL_BLOCK end
|
||||
if map.def.tileset == "OVERWORLD" then
|
||||
local mode = TileRenderer.voidFill or "trees"
|
||||
if mode == "water" then return WATER_BORDER_BLOCK end
|
||||
if mode == "black" then return false end
|
||||
return TREE_WALL_BLOCK
|
||||
end
|
||||
return map.def.borderBlock
|
||||
end
|
||||
TileRenderer.borderBlockFor = borderBlockFor
|
||||
|
||||
function TileRenderer.setVoidFill(mode)
|
||||
local ok = false
|
||||
for _, m in ipairs(TileRenderer.VOID_FILLS) do
|
||||
if m == mode then ok = true; break end
|
||||
end
|
||||
TileRenderer.voidFill = ok and mode or "trees"
|
||||
end
|
||||
|
||||
function TileRenderer.cycleVoidFill()
|
||||
local cur = TileRenderer.voidFill or "trees"
|
||||
local idx = 1
|
||||
for i, m in ipairs(TileRenderer.VOID_FILLS) do
|
||||
if m == cur then idx = i; break end
|
||||
end
|
||||
TileRenderer.setVoidFill(
|
||||
TileRenderer.VOID_FILLS[idx % #TileRenderer.VOID_FILLS + 1])
|
||||
return TileRenderer.voidFill
|
||||
end
|
||||
|
||||
function TileRenderer.applyOptions(opts)
|
||||
TileRenderer.setVoidFill(opts and opts.voidFill or "trees")
|
||||
end
|
||||
|
||||
function TileRenderer.voidFillLabel(mode)
|
||||
mode = mode or TileRenderer.voidFill or "trees"
|
||||
if mode == "water" then return "WATER" end
|
||||
if mode == "black" then return "BLACK" end
|
||||
return "TREES"
|
||||
end
|
||||
|
||||
local imageCache = {}
|
||||
|
||||
local function getImage(path)
|
||||
@@ -55,8 +93,29 @@ local FLOWER_IMAGES = {
|
||||
local SPINNER_STRIP = "assets/generated/tilesets/spinners.png"
|
||||
|
||||
local animFrame = 0
|
||||
function TileRenderer.tick()
|
||||
animFrame = animFrame + 1
|
||||
local animAccum = 0
|
||||
local ANIM_STEP = 1 / 60 -- Game Boy logic rate (matches FixedStep.STEP)
|
||||
|
||||
-- Advance water/flower/spinner tile animation. Called from the overworld
|
||||
-- draw path so it keeps running under dialogs (overworld update does not),
|
||||
-- but consumes wall-clock dt into 60Hz steps so high/low display refresh
|
||||
-- no longer speeds up or slows the cycle (issue #4).
|
||||
-- tick() / tick(nil) with no love.timer.getDelta (headless tests) still
|
||||
-- advances exactly one frame per call.
|
||||
function TileRenderer.tick(dt)
|
||||
if dt == nil and love and love.timer and love.timer.getDelta then
|
||||
dt = love.timer.getDelta()
|
||||
end
|
||||
if dt == nil then
|
||||
animFrame = animFrame + 1
|
||||
return
|
||||
end
|
||||
-- Cap catch-up so a long stall cannot jump many water/flower periods
|
||||
animAccum = math.min(animAccum + dt, 0.25)
|
||||
while animAccum >= ANIM_STEP do
|
||||
animAccum = animAccum - ANIM_STEP
|
||||
animFrame = animFrame + 1
|
||||
end
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
@@ -90,8 +149,8 @@ function TileRenderer.setSpinning(active)
|
||||
end
|
||||
|
||||
-- true while the spinner arrow tiles should show the 'blur' graphic; false
|
||||
-- means draw nothing extra (the static mapBatch/ringBatch tile shows
|
||||
-- through, matching the asm's restore-to-original behavior). The 8-tick
|
||||
-- means draw nothing extra (the static window tile shows through,
|
||||
-- matching the asm's restore-to-original behavior). The 8-tick
|
||||
-- half-period approximates one GB movement step (2px/frame); this is a
|
||||
-- deliberate approximation of wSimulatedJoypadStatesIndex bit-0 parity, not
|
||||
-- a cycle-accurate replication -- the port's tweened scriptMove has no
|
||||
@@ -426,22 +485,24 @@ function TileRenderer.new(map, data)
|
||||
end
|
||||
|
||||
local def = map.def
|
||||
local wB, hB = def.width, def.height
|
||||
-- two batches: the border-block ring around the map, and the map body.
|
||||
-- Connected-map strips draw body-only on top of this map's ring.
|
||||
local total = (wB + 2 * BORDER_BLOCKS) * (hB + 2 * BORDER_BLOCKS) * 16
|
||||
self.ringBatch = love.graphics.newSpriteBatch(self.image, total, "static")
|
||||
self.mapBatch = love.graphics.newSpriteBatch(self.image, wB * hB * 16, "static")
|
||||
-- animated tiles overdraw the static batches each frame. Entry order
|
||||
-- decides which one claims a tile listed twice, so the vanilla defaults
|
||||
-- keep the old water-then-flower-then-spinner precedence.
|
||||
-- The map body measured in 8px tiles (each block is 4x4 tiles). The tile
|
||||
-- layer is drawn windowed to the camera (see :ensureWindow) instead of
|
||||
-- baked into a whole-map SpriteBatch, so a map becoming visible -- a warp,
|
||||
-- a connection seam -- costs nothing to "build": there is no per-map batch
|
||||
-- construction that scales with map size, which is what stuttered.
|
||||
self.bodyTilesW = def.width * 4
|
||||
self.bodyTilesH = def.height * 4
|
||||
-- Animated tiles overdraw the static window each frame. Only the per-entry
|
||||
-- render spec (textures/sequence/gate) is kept here; the animated cells are
|
||||
-- gathered per camera window in :ensureWindow, so nothing here scales with
|
||||
-- map size either. Entry order decides which entry claims a tile listed
|
||||
-- twice (the vanilla water-then-flower-then-spinner precedence).
|
||||
local anims, claimedBy = {}, {}
|
||||
local declared = map.tileset.animatedTiles
|
||||
or TileRenderer.defaultAnimatedTiles(map.tileset)
|
||||
for _, spec in ipairs(declared) do
|
||||
local anim = buildAnim(spec, map.tileset.image, perRow, self.quads, gbcCtx)
|
||||
if anim then
|
||||
anim.cells = {}
|
||||
anims[#anims + 1] = anim
|
||||
for _, tile in ipairs(anim.tiles) do
|
||||
if claimedBy[tile] == nil then claimedBy[tile] = anim end
|
||||
@@ -460,102 +521,129 @@ function TileRenderer.new(map, data)
|
||||
aliasMap[al.block] = cells
|
||||
end
|
||||
end
|
||||
|
||||
for by = -BORDER_BLOCKS, hB + BORDER_BLOCKS - 1 do
|
||||
for bx = -BORDER_BLOCKS, wB + BORDER_BLOCKS - 1 do
|
||||
local inside = bx >= 0 and by >= 0 and bx < wB and by < hB
|
||||
local batch = inside and self.mapBatch or self.ringBatch
|
||||
-- beyond-edge ring cells use the same override drawBorderFill does,
|
||||
-- so the ring and the far background fill agree (OVERWORLD maps
|
||||
-- whose raw border_block is water still ring with the tree wall)
|
||||
local blockId = inside and map:blockAt(bx, by) or borderBlockFor(map)
|
||||
local block = map.tileset.blocks[blockId + 1]
|
||||
if not block then
|
||||
-- a tileset without the tree-wall block keeps its own border
|
||||
blockId = map:blockAt(bx, by)
|
||||
block = map.tileset.blocks[blockId + 1]
|
||||
end
|
||||
local remap = aliasMap and aliasMap[blockId]
|
||||
for ty = 0, 3 do
|
||||
for tx = 0, 3 do
|
||||
local ci = ty * 4 + tx
|
||||
local tile = block[ci + 1]
|
||||
if remap and remap[ci] then tile = remap[ci] end
|
||||
local quad = self.quads[tile]
|
||||
if quad then
|
||||
batch:add(quad, bx * 32 + tx * 8, by * 32 + ty * 8)
|
||||
end
|
||||
local anim = claimedBy[tile]
|
||||
if anim then
|
||||
local cells = anim.cells
|
||||
cells[#cells + 1] = { bx * 32 + tx * 8, by * 32 + ty * 8, inside, tile }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- animated overdraw batches: the full set (ring + body) for the
|
||||
-- current map, and a body-only set for connected-map drawing --
|
||||
-- a neighbor's water ring must never overdraw this map's tiles.
|
||||
-- `quadFor`, when given, looks up a per-entry quad (used by toggle
|
||||
-- entries, whose texture is a full tileset-atlas clone rather than a
|
||||
-- single-tile image like the hshift/frames variants).
|
||||
local function animBatches(entries, image, quadFor)
|
||||
if #entries == 0 then return nil, nil end
|
||||
local all = love.graphics.newSpriteBatch(image, #entries, "static")
|
||||
local body
|
||||
for _, c in ipairs(entries) do
|
||||
if quadFor then all:add(quadFor(c[4]), c[1], c[2]) else all:add(c[1], c[2]) end
|
||||
if c[3] then
|
||||
body = body or love.graphics.newSpriteBatch(image, #entries, "static")
|
||||
if quadFor then body:add(quadFor(c[4]), c[1], c[2]) else body:add(c[1], c[2]) end
|
||||
end
|
||||
end
|
||||
return all, body
|
||||
end
|
||||
for _, anim in ipairs(anims) do
|
||||
anim.batch, anim.bodyBatch =
|
||||
animBatches(anim.cells, anim.textures[1], anim.quadFor)
|
||||
anim.cells = nil
|
||||
end
|
||||
self.aliasMap = aliasMap
|
||||
self.anims = anims
|
||||
self.claimedBy = claimedBy
|
||||
|
||||
-- a repeating 32x32 image of the border block, tiled behind
|
||||
-- everything the 3-block ring doesn't cover (the survey zoom sees
|
||||
-- far past the ring; interiors keep their black border this way)
|
||||
pcall(function()
|
||||
local border = map.tileset.blocks[borderBlockFor(map) + 1]
|
||||
if not border then return end
|
||||
local canvas = love.graphics.newCanvas(32, 32)
|
||||
love.graphics.push("all")
|
||||
love.graphics.setCanvas(canvas)
|
||||
love.graphics.clear(1, 1, 1, 1)
|
||||
for ty = 0, 3 do
|
||||
for tx = 0, 3 do
|
||||
local quad = self.quads[border[ty * 4 + tx + 1]]
|
||||
if quad then love.graphics.draw(self.image, quad, tx * 8, ty * 8) end
|
||||
end
|
||||
end
|
||||
love.graphics.setCanvas()
|
||||
love.graphics.pop()
|
||||
local img = love.graphics.newImage(canvas:newImageData())
|
||||
img:setWrap("repeat", "repeat")
|
||||
img:setFilter("nearest", "nearest")
|
||||
self.borderFill = img
|
||||
end)
|
||||
-- border-fill image is built lazily in :ensureBorderFill so a VOID FILL
|
||||
-- option change can swap trees/water/black without reloading the map
|
||||
self.borderFillMode = nil
|
||||
self.borderFill = nil
|
||||
|
||||
return self
|
||||
end
|
||||
|
||||
-- Bake a static repeating 32x32 of `block` into self.borderFill.
|
||||
local function bakeBorderFill(self, block)
|
||||
local border = self.map.tileset.blocks[block + 1]
|
||||
if not border then return end
|
||||
local canvas = love.graphics.newCanvas(32, 32)
|
||||
love.graphics.push("all")
|
||||
love.graphics.setCanvas(canvas)
|
||||
love.graphics.clear(1, 1, 1, 1)
|
||||
for ty = 0, 3 do
|
||||
for tx = 0, 3 do
|
||||
local quad = self.quads[border[ty * 4 + tx + 1]]
|
||||
if quad then love.graphics.draw(self.image, quad, tx * 8, ty * 8) end
|
||||
end
|
||||
end
|
||||
love.graphics.setCanvas()
|
||||
love.graphics.pop()
|
||||
local img = love.graphics.newImage(canvas:newImageData())
|
||||
img:setWrap("repeat", "repeat")
|
||||
img:setFilter("nearest", "nearest")
|
||||
self.borderFill = img
|
||||
end
|
||||
|
||||
-- WATER void fill: the eight hshift frames of tile $14 (same cycle as map
|
||||
-- water), wrap-tiled so the void scrolls in lockstep with on-map water.
|
||||
local function ensureWaterBorderFill(self)
|
||||
if self.borderWaterTextures then return true end
|
||||
local map = self.map
|
||||
local perRow = map.tileset.tilesPerRow
|
||||
local colors, gbcKey
|
||||
if self.gbcAtlas and self.data then
|
||||
local group = PaletteFX.worldGroupAt(map.tileset.id, map.id, WATER_TILE)
|
||||
local groupColors = PaletteFX.worldGroupColors(
|
||||
self.data, map.tileset.id, map.id, nil)
|
||||
colors = group and groupColors and groupColors[group + 1] or nil
|
||||
gbcKey = "#gbc:" .. map.id
|
||||
end
|
||||
local textures = getShiftVariants(map.tileset.image, perRow, WATER_TILE,
|
||||
colors, gbcKey)
|
||||
if not textures then return false end
|
||||
for _, img in ipairs(textures) do
|
||||
img:setWrap("repeat", "repeat")
|
||||
img:setFilter("nearest", "nearest")
|
||||
end
|
||||
self.borderWaterTextures = textures
|
||||
return true
|
||||
end
|
||||
|
||||
-- (re)build the repeating border image when the VOID FILL mode or tileset
|
||||
-- choice changes. OVERWORLD "black" leaves borderFill nil and draws a
|
||||
-- solid clear; "water" keeps the live hshift textures instead of a bake.
|
||||
function TileRenderer:ensureBorderFill()
|
||||
local block = borderBlockFor(self.map)
|
||||
local mode = block == false and "black"
|
||||
or ((self.map.def.tileset == "OVERWORLD")
|
||||
and (TileRenderer.voidFill or "trees")
|
||||
or "map")
|
||||
local ready = (mode == "black")
|
||||
or (mode == "water" and self.borderWaterTextures)
|
||||
or (mode ~= "water" and mode ~= "black" and self.borderFill)
|
||||
if self.borderFillMode == mode and ready then return end
|
||||
if self.borderFill and self.borderFill.release then
|
||||
pcall(self.borderFill.release, self.borderFill)
|
||||
end
|
||||
self.borderFill = nil
|
||||
-- shared shift-variant cache: drop the reference only, never release
|
||||
self.borderWaterTextures = nil
|
||||
self.borderFillMode = mode
|
||||
if mode == "black" or block == false or block == nil then return end
|
||||
if mode == "water" then
|
||||
if ensureWaterBorderFill(self) then return end
|
||||
-- headless / missing pixels: fall back to the static water block bake
|
||||
end
|
||||
pcall(bakeBorderFill, self, block)
|
||||
end
|
||||
|
||||
-- tile the border block across the whole view (world-aligned so it
|
||||
-- meshes seamlessly with the ring batch)
|
||||
function TileRenderer:drawBorderFill(camX, camY, vw, vh)
|
||||
if not self.borderFill then return end
|
||||
self:ensureBorderFill()
|
||||
if self.borderFillMode == "black" then
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, vw, vh)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
return
|
||||
end
|
||||
if self.trueColor then PaletteFX.markTrueColor(0, 0, vw, vh) end
|
||||
local x, y = math.floor(camX), math.floor(camY)
|
||||
local quad = love.graphics.newQuad(x, y, vw, vh, 32, 32)
|
||||
love.graphics.draw(self.borderFill, quad, 0, 0)
|
||||
-- one reused Quad per renderer, mutated in place: this runs every
|
||||
-- overworld frame, so allocating a fresh Quad here churned the GC
|
||||
local q = self.borderQuad
|
||||
if self.borderFillMode == "water" and self.borderWaterTextures then
|
||||
local step = math.floor(animFrame / ANIM_PERIOD) % #WATER_OFFSETS + 1
|
||||
local tex = self.borderWaterTextures[WATER_OFFSETS[step] + 1]
|
||||
if not tex then return end
|
||||
if q then
|
||||
q:setViewport(x, y, vw, vh, 8, 8)
|
||||
else
|
||||
q = love.graphics.newQuad(x, y, vw, vh, 8, 8)
|
||||
self.borderQuad = q
|
||||
end
|
||||
love.graphics.draw(tex, q, 0, 0)
|
||||
return
|
||||
end
|
||||
if not self.borderFill then return end
|
||||
if q then
|
||||
q:setViewport(x, y, vw, vh, 32, 32)
|
||||
else
|
||||
q = love.graphics.newQuad(x, y, vw, vh, 32, 32)
|
||||
self.borderQuad = q
|
||||
end
|
||||
love.graphics.draw(self.borderFill, q, 0, 0)
|
||||
end
|
||||
|
||||
-- GB OBJ-to-BG priority: sprites show through BG color 0 and hide under
|
||||
@@ -616,19 +704,98 @@ function TileRenderer:markCellBottomRedraw(cx, cy, camX, camY, colors)
|
||||
end
|
||||
end
|
||||
|
||||
-- animated overdraw at the current step; bodyOnly skips the ring
|
||||
-- positions (connected maps draw body-only)
|
||||
function TileRenderer:drawAnimated(camX, camY, bodyOnly)
|
||||
-- Window cover for the static tile layer. Refill the reusable window batch
|
||||
-- (and the per-entry animated batches) only when the camera has scrolled past
|
||||
-- what they already cover; a small margin keeps small scrolls free. Cost
|
||||
-- scales with the view, never the map -- crossing a seam or warping in builds
|
||||
-- nothing. The beyond-body area (what the old 3-block ring drew) is painted
|
||||
-- by :drawBorderFill, whose world-aligned border-block tiling is identical
|
||||
-- there, so only body tiles are gathered here.
|
||||
local WINDOW_MARGIN = 8 -- tiles of slack kept around the view between refills
|
||||
|
||||
function TileRenderer:ensureWindow(camX, camY, vw, vh)
|
||||
local W, H = self.bodyTilesW, self.bodyTilesH
|
||||
vw = vw or W * 8 -- a nil view (headless draw) means the whole body
|
||||
vh = vh or H * 8
|
||||
-- visible body-tile range (8px tiles), clamped to the map body
|
||||
local tx0 = math.min(W, math.max(0, math.floor(camX / 8)))
|
||||
local ty0 = math.min(H, math.max(0, math.floor(camY / 8)))
|
||||
local tx1 = math.max(0, math.min(W, math.floor((camX + vw) / 8) + 1))
|
||||
local ty1 = math.max(0, math.min(H, math.floor((camY + vh) / 8) + 1))
|
||||
local win = self.win
|
||||
if win and tx0 >= win.tx0 and ty0 >= win.ty0
|
||||
and tx1 <= win.tx1 and ty1 <= win.ty1 then
|
||||
return -- still inside the last fill
|
||||
end
|
||||
-- refill with margin so the next few scrolled pixels stay covered
|
||||
tx0 = math.max(0, tx0 - WINDOW_MARGIN)
|
||||
ty0 = math.max(0, ty0 - WINDOW_MARGIN)
|
||||
tx1 = math.min(W, tx1 + WINDOW_MARGIN)
|
||||
ty1 = math.min(H, ty1 + WINDOW_MARGIN)
|
||||
if not self.winBatch then
|
||||
self.winBatch = love.graphics.newSpriteBatch(self.image, 1024, "dynamic")
|
||||
end
|
||||
self.winBatch:clear()
|
||||
local anims = self.anims
|
||||
for _, anim in ipairs(anims) do
|
||||
if not anim.batch then
|
||||
anim.batch = love.graphics.newSpriteBatch(anim.textures[1], 256, "dynamic")
|
||||
end
|
||||
anim.batch:clear()
|
||||
end
|
||||
local map, quads = self.map, self.quads
|
||||
local claimedBy, aliasMap = self.claimedBy, self.aliasMap
|
||||
for ty = ty0, ty1 - 1 do
|
||||
local by = math.floor(ty / 4)
|
||||
local ty4 = ty % 4
|
||||
for tx = tx0, tx1 - 1 do
|
||||
local blockId = map:blockAt(math.floor(tx / 4), by)
|
||||
local block = map.tileset.blocks[blockId + 1]
|
||||
if block then
|
||||
local ci = ty4 * 4 + (tx % 4)
|
||||
local tile = block[ci + 1]
|
||||
local remap = aliasMap and aliasMap[blockId]
|
||||
if remap and remap[ci] then tile = remap[ci] end
|
||||
local wx, wy = tx * 8, ty * 8
|
||||
local quad = quads[tile]
|
||||
if quad then self.winBatch:add(quad, wx, wy) end
|
||||
local anim = claimedBy[tile]
|
||||
if anim then
|
||||
if anim.quadFor then
|
||||
anim.batch:add(anim.quadFor(tile), wx, wy)
|
||||
else
|
||||
anim.batch:add(wx, wy)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
self.win = { tx0 = tx0, ty0 = ty0, tx1 = tx1, ty1 = ty1 }
|
||||
end
|
||||
|
||||
-- draw the static tile window, then its animated overdraw, at the camera offset
|
||||
function TileRenderer:drawWindow(camX, camY, vw, vh)
|
||||
self:ensureWindow(camX, camY, vw, vh)
|
||||
if self.winBatch then
|
||||
love.graphics.draw(self.winBatch, -math.floor(camX), -math.floor(camY))
|
||||
end
|
||||
self:drawAnimated(camX, camY)
|
||||
end
|
||||
|
||||
-- animated overdraw at the current step, over the static window batch. The
|
||||
-- cells were gathered for the current camera window by :ensureWindow, so this
|
||||
-- only ever touches on-screen animated tiles.
|
||||
function TileRenderer:drawAnimated(camX, camY)
|
||||
local anims = self.anims
|
||||
if not anims then return end
|
||||
local x, y = -math.floor(camX), -math.floor(camY)
|
||||
for _, anim in ipairs(anims) do
|
||||
local batch = bodyOnly and anim.bodyBatch or anim.batch
|
||||
local batch = anim.batch
|
||||
if batch then
|
||||
if anim.gate then
|
||||
-- a gated entry has only the two frames the asm has (patch /
|
||||
-- restore-to-static); when the gate is shut draw nothing so the
|
||||
-- already-static mapBatch/ringBatch tile shows through unchanged
|
||||
-- already-static window tile shows through unchanged
|
||||
if gateOpen(anim.gate) then love.graphics.draw(batch, x, y) end
|
||||
else
|
||||
local step = math.floor(animFrame / anim.period) % #anim.sequence + 1
|
||||
@@ -649,30 +816,71 @@ function TileRenderer:markTrueColor(camX, camY, blocks)
|
||||
(def.height + 2 * blocks) * 32)
|
||||
end
|
||||
|
||||
function TileRenderer:draw(camX, camY)
|
||||
function TileRenderer:draw(camX, camY, vw, vh)
|
||||
if self.trueColor then self:markTrueColor(camX, camY, BORDER_BLOCKS) end
|
||||
love.graphics.draw(self.ringBatch, -math.floor(camX), -math.floor(camY))
|
||||
love.graphics.draw(self.mapBatch, -math.floor(camX), -math.floor(camY))
|
||||
self:drawAnimated(camX, camY)
|
||||
self:drawWindow(camX, camY, vw, vh)
|
||||
end
|
||||
|
||||
-- body only, for connected-map strips
|
||||
function TileRenderer:drawMapOnly(camX, camY)
|
||||
-- body only, for connected-map strips. Identical to :draw now that the
|
||||
-- border ring is served by :drawBorderFill for the current map too -- the
|
||||
-- only remaining difference is the trueColor mark extent.
|
||||
function TileRenderer:drawMapOnly(camX, camY, vw, vh)
|
||||
if self.trueColor then self:markTrueColor(camX, camY, 0) end
|
||||
love.graphics.draw(self.mapBatch, -math.floor(camX), -math.floor(camY))
|
||||
self:drawAnimated(camX, camY, true)
|
||||
self:drawWindow(camX, camY, vw, vh)
|
||||
end
|
||||
|
||||
-- rebuild after a block change (Cut trees)
|
||||
local function safeRelease(o)
|
||||
if o and o.release then pcall(o.release, o) end
|
||||
end
|
||||
|
||||
-- Release only the GPU objects this instance built and uniquely owns: the
|
||||
-- two SpriteBatches, the border-fill image and its quad, the per-tile
|
||||
-- quads, and the animated-overdraw batches. Deliberately leaves the
|
||||
-- tileset atlas (self.image, shared through Assets/imageCache) and the
|
||||
-- animation textures (shared module caches) alone -- other maps still use
|
||||
-- them. Used by :rebuild before it swaps in fresh batches, and by
|
||||
-- :release on eviction.
|
||||
function TileRenderer:releaseBatches()
|
||||
safeRelease(self.winBatch); self.winBatch = nil
|
||||
safeRelease(self.borderFill); self.borderFill = nil
|
||||
safeRelease(self.borderQuad); self.borderQuad = nil
|
||||
-- shared shift-variant cache; only drop the reference
|
||||
self.borderWaterTextures = nil
|
||||
self.borderFillMode = nil
|
||||
self.win = nil
|
||||
if self.quads then
|
||||
for _, q in pairs(self.quads) do safeRelease(q) end
|
||||
self.quads = nil
|
||||
end
|
||||
if self.anims then
|
||||
for _, a in ipairs(self.anims) do
|
||||
safeRelease(a.batch); a.batch = nil
|
||||
-- a.textures are shared, module-cached: never released here
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Full teardown for eviction (MapLoader.evict): the owned batches, plus
|
||||
-- the RED++ per-map recolored atlas, which -- unlike the plain tileset
|
||||
-- atlas -- is unique to this map (gbcAtlasCache is keyed by map id).
|
||||
function TileRenderer:release()
|
||||
self:releaseBatches()
|
||||
if self.gbcAtlas and self.image then
|
||||
local key = self.map.tileset.image .. "#gbc:" .. self.map.id
|
||||
if gbcAtlasCache[key] == self.image then gbcAtlasCache[key] = nil end
|
||||
safeRelease(self.image)
|
||||
self.image = nil
|
||||
self.gbcAtlas = nil
|
||||
end
|
||||
self.anims = nil
|
||||
end
|
||||
|
||||
-- rebuild after a block change (Cut trees, card-key doors). The tile layer
|
||||
-- is read live from the map on every window fill, so a block swap only needs
|
||||
-- the cached window dropped -- the next draw re-reads the changed blocks. No
|
||||
-- SpriteBatch is reconstructed; that is the whole point of the windowed draw.
|
||||
function TileRenderer:rebuild()
|
||||
local fresh = TileRenderer.new(self.map, self.data)
|
||||
self.image = fresh.image
|
||||
self.gbcAtlas = fresh.gbcAtlas
|
||||
self.quads = fresh.quads
|
||||
self.ringBatch = fresh.ringBatch
|
||||
self.mapBatch = fresh.mapBatch
|
||||
self.anims = fresh.anims
|
||||
self.borderFill = fresh.borderFill
|
||||
self.win = nil
|
||||
end
|
||||
|
||||
-- drop every atlas and every derived animation texture so the next
|
||||
|
||||
@@ -57,6 +57,16 @@ end
|
||||
function Transition:draw()
|
||||
local alpha = self.t / self.frames
|
||||
if self.phase == "in" then alpha = 1 - alpha end
|
||||
-- Survey zoom draws the overworld into a window-filling world canvas
|
||||
-- while the UI pass stays the classic 160x144 letterbox. A rect on the
|
||||
-- UI canvas only darkens that center box (issue #121); when the world
|
||||
-- pass ran this frame, hand the alpha to Renderer:endFrame so it paints
|
||||
-- a screen-space overlay over the full composite instead.
|
||||
local r = self.game and self.game.renderer
|
||||
if r and r.worldActive then
|
||||
r.worldFadeAlpha = alpha
|
||||
return
|
||||
end
|
||||
love.graphics.setColor(0, 0, 0, alpha)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
|
||||
+41
-5
@@ -1,6 +1,7 @@
|
||||
-- Overworld survey zoom: integer pixels-per-world-pixel scales stepped
|
||||
-- by the mouse wheel. Stored as an offset from the window fit scale S
|
||||
-- so a resize keeps the relative zoom. Session-only; never saved.
|
||||
-- by the mouse wheel, Options ZOOM row, or hotkey `4`. Stored as an
|
||||
-- offset from the window fit scale S so a resize keeps the relative
|
||||
-- zoom. Persisted as save.options.zoom (default 0 = FIT).
|
||||
-- Spec: docs/new-features.md (survey zoom)
|
||||
|
||||
local Zoom = {}
|
||||
@@ -12,16 +13,51 @@ function Zoom.scale(S)
|
||||
return math.max(1, math.min(2 * S, S + Zoom.offset))
|
||||
end
|
||||
|
||||
-- legal offset range for a given fit scale
|
||||
function Zoom.offsetRange(S)
|
||||
S = math.max(1, math.floor(tonumber(S) or 1))
|
||||
return 1 - S, S
|
||||
end
|
||||
|
||||
function Zoom.clampOffset(offset, S)
|
||||
local lo, hi = Zoom.offsetRange(S)
|
||||
offset = math.floor(tonumber(offset) or 0)
|
||||
if offset < lo then return lo end
|
||||
if offset > hi then return hi end
|
||||
return offset
|
||||
end
|
||||
|
||||
function Zoom.step(delta, S)
|
||||
Zoom.offset = Zoom.offset + delta
|
||||
if S + Zoom.offset < 1 then Zoom.offset = 1 - S end
|
||||
if S + Zoom.offset > 2 * S then Zoom.offset = S end
|
||||
Zoom.offset = Zoom.clampOffset(Zoom.offset + delta, S)
|
||||
return Zoom.offset
|
||||
end
|
||||
|
||||
-- Advance one zoom level toward max close-up, then wrap to full survey.
|
||||
-- Returns the new offset.
|
||||
function Zoom.cycle(S)
|
||||
local lo, hi = Zoom.offsetRange(S)
|
||||
local next = Zoom.offset + 1
|
||||
if next > hi then next = lo end
|
||||
Zoom.offset = next
|
||||
return Zoom.offset
|
||||
end
|
||||
|
||||
function Zoom.reset()
|
||||
Zoom.offset = 0
|
||||
end
|
||||
|
||||
function Zoom.applyOptions(opts)
|
||||
Zoom.offset = math.floor(tonumber(opts and opts.zoom) or 0)
|
||||
end
|
||||
|
||||
-- FIT / OUT1 / OUT2 / … / IN1 / IN2 / …
|
||||
function Zoom.offsetLabel(offset)
|
||||
offset = math.floor(tonumber(offset) or 0)
|
||||
if offset == 0 then return "FIT" end
|
||||
if offset < 0 then return "OUT" .. tostring(-offset) end
|
||||
return "IN" .. tostring(offset)
|
||||
end
|
||||
|
||||
-- world pixels covered by a w x h letterbox viewport at fit scale S
|
||||
-- (legacy GB-framed size; prefer fillViewSize for the live world pass)
|
||||
function Zoom.viewSize(S, w, h)
|
||||
|
||||
+137
-22
@@ -438,6 +438,20 @@ function Commands.play_sound(ctx, soundId)
|
||||
require("src.core.Sound").play(ctx.game.data, soundId)
|
||||
end
|
||||
|
||||
-- play_once <songId>: one-shot jingle (Music_PkmnHealed, etc.); blocks
|
||||
-- until it finishes so heal-rest scripts (Mom, captain text_asm) match
|
||||
-- Gen1's wait-on-channel loop. The map theme resumes when it ends
|
||||
-- (Music.playOnce / pendingRestore).
|
||||
function Commands.play_once(ctx, songId)
|
||||
local Music = require("src.core.Music")
|
||||
if not Music.playOnce(ctx.game.data, songId) then return end
|
||||
local runner = ctx.runner
|
||||
runner.waitingCheck = function()
|
||||
return not Music.oneShotPlaying()
|
||||
end
|
||||
runner:yield()
|
||||
end
|
||||
|
||||
-- play_cry <species>: PlayCry (home/audio.asm). The text_asm bodies that
|
||||
-- use it run text_far (a no-button-wait "...@" string) -> PlayCry ->
|
||||
-- WaitForSoundToFinish, all within the same text ID -- the cry only starts
|
||||
@@ -470,11 +484,51 @@ function Commands.heal_party(ctx)
|
||||
end
|
||||
end
|
||||
|
||||
-- AskName (engine/menus/naming_screen.asm): yes/no then NamingScreen.
|
||||
-- AddPartyMon only offers this for party mons (box deposits skip it).
|
||||
-- Preserves ctx.lastCheck so GivePokemon's carry is not clobbered by the
|
||||
-- yes/no result (scripts jump_if_false on give failure afterwards).
|
||||
local function askNickname(ctx, mon)
|
||||
local runner = ctx.runner
|
||||
if not runner then return end
|
||||
local success = ctx.lastCheck
|
||||
local name = ctx.game.stringBuffer
|
||||
or (ctx.game.data.pokemon[mon.species]
|
||||
and ctx.game.data.pokemon[mon.species].name)
|
||||
or mon.species
|
||||
-- Prefer the real text label; fall back to the BattleState wording.
|
||||
if ctx.game.data.text and ctx.game.data.text._DoYouWantToNicknameText then
|
||||
Commands.show_text(ctx, "_DoYouWantToNicknameText", { RAM = name })
|
||||
else
|
||||
Commands.show_text(ctx, ("Do you want to\ngive a nickname\nto %s?"):format(name))
|
||||
end
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
ctx.game.stack:push(ChoiceBox.new(ctx.game, function(yes)
|
||||
if not yes then
|
||||
ctx.lastCheck = success
|
||||
runner:resume()
|
||||
return
|
||||
end
|
||||
Screens.push(ctx.game, "NamingScreen", {
|
||||
title = "NICKNAME?", maxLen = 10,
|
||||
onDone = function(nick)
|
||||
if nick and #nick > 0 then mon.nickname = nick end
|
||||
ctx.lastCheck = success
|
||||
runner:resume()
|
||||
end,
|
||||
})
|
||||
end))
|
||||
runner:yield()
|
||||
ctx.lastCheck = success
|
||||
end
|
||||
|
||||
-- give_pokemon <species> <level>: _GivePokemon (engine/events/
|
||||
-- give_pokemon.asm) -- party first, then the box. ctx.lastCheck gets
|
||||
-- the asm's carry: true when the mon was given, false when both the
|
||||
-- party and every box are full (that .boxFull path leaves the giver's
|
||||
-- script able to offer again later, e.g. the Celadon Eevee ball).
|
||||
-- Party adds run AskName (AddPartyMon) when a script runner is present;
|
||||
-- mods that pre-set gift.nickname skip the prompt.
|
||||
function Commands.give_pokemon(ctx, species, level)
|
||||
-- Native mods can transform a gift before the Pokémon object is created.
|
||||
-- This is intentionally an event rather than a special-case starter hook:
|
||||
@@ -491,7 +545,8 @@ function Commands.give_pokemon(ctx, species, level)
|
||||
ctx.game.stringBuffer = ctx.game.data.pokemon[species].name or species
|
||||
ctx.pendingPokemonName = species
|
||||
require("src.battle.BattleState").stampOT(ctx.save, mon)
|
||||
if not Party.add(ctx.save.party, mon) then
|
||||
local addedToParty = Party.add(ctx.save.party, mon)
|
||||
if not addedToParty then
|
||||
if not require("src.pokemon.Boxes").deposit(ctx.save, mon) then
|
||||
ctx.lastCheck = false
|
||||
return
|
||||
@@ -503,18 +558,40 @@ function Commands.give_pokemon(ctx, species, level)
|
||||
dex.owned[species] = true
|
||||
end
|
||||
ctx.lastCheck = true
|
||||
-- AddPartyMon AskName: party only; skip box deposits, mod-set nicknames,
|
||||
-- and callback-style callers that have no script runner to yield on.
|
||||
if addedToParty and not gift.nickname and ctx.runner then
|
||||
askNickname(ctx, mon)
|
||||
end
|
||||
end
|
||||
|
||||
function Commands.give_money(ctx, amount)
|
||||
ctx.save.money = math.max(0, ctx.save.money + amount)
|
||||
end
|
||||
|
||||
-- Point LAST_MAP exits at an outdoor door (pokered wLastMap). Keeps the
|
||||
-- live overworld memory in sync so a scripted home warp from the HoF PC
|
||||
-- does not leave Red's house mats aimed at Indigo Plateau (#103).
|
||||
function Commands.remember_outdoor(ctx, mapId, x, y)
|
||||
local outdoor = { id = mapId, x = x, y = y }
|
||||
ctx.save.lastOutdoor = outdoor
|
||||
if ctx.overworld and ctx.overworld.rememberOutdoor then
|
||||
ctx.overworld:rememberOutdoor(mapId, x, y)
|
||||
elseif ctx.overworld then
|
||||
ctx.overworld.lastOutdoor = outdoor
|
||||
end
|
||||
end
|
||||
|
||||
-- Hall of Fame: snapshot the winning party (SaveHallOfFameTeams inside
|
||||
-- AnimateHallOfFame), run the induction showcase and the end credits,
|
||||
-- autosave while THE END is up, then soft-reset to the title -- the whole
|
||||
-- predef HallOfFamePC + tail of HallOfFameResetEventsAndSaveScript
|
||||
-- (engine/movie/hall_of_fame.asm, engine/movie/credits.asm,
|
||||
-- scripts/HallOfFame.asm).
|
||||
--
|
||||
-- Departure from pokered: CONTINUE lands in the NewGameWarp bedroom with
|
||||
-- a healed party and lastOutdoor on Pallet Town, instead of remaining
|
||||
-- softlocked in HALL_OF_FAME with LAST_MAP still aimed at Indigo (#103).
|
||||
function Commands.record_hall_of_fame(ctx)
|
||||
ctx.save.hallOfFame = ctx.save.hallOfFame or {}
|
||||
local entry = {}
|
||||
@@ -530,17 +607,25 @@ function Commands.record_hall_of_fame(ctx)
|
||||
Screens.push(game, "Credits", function()
|
||||
runner:resume()
|
||||
end, function()
|
||||
-- THE END is on screen: HallOfFameResetEventsAndSaveScript sets
|
||||
-- wLastBlackoutMap := PALLET_TOWN and runs SaveGameData, so the
|
||||
-- save keeps the player standing in the HALL_OF_FAME room. (The
|
||||
-- E4 room-script/event resets that precede the save in pokered are
|
||||
-- the Indigo lobby's re-entry reset here, data/scripts/story6.lua.)
|
||||
-- pokered writes PALLET_TOWN here as a literal, not as "the spawn" --
|
||||
-- the vanilla spawn is REDS_HOUSE_2F. SaveData.defaultHeal carries
|
||||
-- that split (and lets a total conversion redirect it).
|
||||
-- THE END is on screen: heal, place the player at post-game home,
|
||||
-- retarget LAST_MAP, then SaveGameData. (E4 room-script/event
|
||||
-- resets that precede the save in pokered are the Indigo lobby's
|
||||
-- re-entry reset here, data/scripts/story6.lua.)
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local boot = game.data.field and game.data.field.boot or {}
|
||||
ctx.save.lastHeal = require("src.core.SaveData").defaultHeal(boot)
|
||||
for _, mon in ipairs(ctx.save.party or {}) do
|
||||
Pokemon.heal(mon)
|
||||
end
|
||||
SaveData.applyPostGameHome(ctx.save, boot)
|
||||
if game.overworld then
|
||||
game.overworld.lastOutdoor = ctx.save.lastOutdoor
|
||||
end
|
||||
if game.writeSave then game:writeSave() end
|
||||
-- writeSave's captureSave re-stamps the live HALL_OF_FAME coords;
|
||||
-- re-apply home and persist so CONTINUE resumes in the bedroom.
|
||||
SaveData.applyPostGameHome(ctx.save, boot)
|
||||
SaveData.save(ctx.save)
|
||||
end)
|
||||
end)
|
||||
runner:yield()
|
||||
@@ -855,15 +940,21 @@ function Commands.push_screen(ctx, screenId, args)
|
||||
runner:yield()
|
||||
end
|
||||
|
||||
-- fade "out"|"in" [frames]: screen fade without warping (the Transition
|
||||
-- ramp startWarpTo uses, split in two). "out" pushes a black overlay
|
||||
-- that stays up; the held state keeps ticking the runner's frame-waits
|
||||
-- so a script can wait/replace_block under it. "in" ramps it away.
|
||||
-- fade "out"|"in" [frames|"white"|"black"] [frames|"white"|"black"]:
|
||||
-- screen fade without warping (the Transition ramp startWarpTo uses,
|
||||
-- split in two). "out" pushes an overlay that stays up; the held state
|
||||
-- keeps ticking the runner's frame-waits so a script can wait /
|
||||
-- heal_party / play_once under it. "in" ramps it away.
|
||||
-- Color defaults to black (warp-style); "white" matches GBFadeOutToWhite
|
||||
-- / GBFadeInFromWhite (Mom heal, Silph Co. nurse). White defaults to
|
||||
-- 24 frames (3 palettes x 8); black defaults to 12 (Transition).
|
||||
local FadeOverlay = {}
|
||||
FadeOverlay.__index = FadeOverlay
|
||||
|
||||
function FadeOverlay.new(game, ow)
|
||||
return setmetatable({ game = game, ow = ow, alpha = 0 }, FadeOverlay)
|
||||
function FadeOverlay.new(game, ow, color)
|
||||
return setmetatable({
|
||||
game = game, ow = ow, alpha = 0, color = color or "black",
|
||||
}, FadeOverlay)
|
||||
end
|
||||
|
||||
function FadeOverlay:update()
|
||||
@@ -889,28 +980,51 @@ function FadeOverlay:update()
|
||||
end
|
||||
|
||||
function FadeOverlay:draw()
|
||||
love.graphics.setColor(0, 0, 0, self.alpha)
|
||||
if self.color == "white" then
|
||||
love.graphics.setColor(1, 1, 1, self.alpha)
|
||||
else
|
||||
love.graphics.setColor(0, 0, 0, self.alpha)
|
||||
end
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
function Commands.fade(ctx, dir, frames)
|
||||
local function parseFadeArgs(a, b)
|
||||
local frames, color
|
||||
if type(a) == "string" then
|
||||
color = a
|
||||
if type(b) == "number" then frames = b end
|
||||
elseif type(a) == "number" then
|
||||
frames = a
|
||||
if type(b) == "string" then color = b end
|
||||
end
|
||||
color = color or "black"
|
||||
if not frames then
|
||||
frames = (color == "white") and 24 or 12
|
||||
end
|
||||
return frames, color
|
||||
end
|
||||
|
||||
function Commands.fade(ctx, dir, framesOrColor, colorOrFrames)
|
||||
local ow = ctx.overworld
|
||||
if not ow then return end
|
||||
local runner = ctx.runner
|
||||
frames = frames or 12 -- Transition's ramp length
|
||||
local frames, color = parseFadeArgs(framesOrColor, colorOrFrames)
|
||||
local overlay = ow.fadeOverlay
|
||||
if dir == "out" then
|
||||
if not overlay then
|
||||
overlay = FadeOverlay.new(ctx.game, ow)
|
||||
overlay = FadeOverlay.new(ctx.game, ow, color)
|
||||
ow.fadeOverlay = overlay
|
||||
ctx.game.stack:push(overlay)
|
||||
else
|
||||
overlay.color = color
|
||||
end
|
||||
overlay.ramp = { from = overlay.alpha, to = 1, frames = frames, t = 0,
|
||||
onDone = function() runner:resume() end }
|
||||
runner:yield()
|
||||
elseif dir == "in" then
|
||||
if not overlay then return end
|
||||
overlay.color = color or overlay.color
|
||||
overlay.ramp = { from = overlay.alpha, to = 0, frames = frames, t = 0,
|
||||
onDone = function() runner:resume() end }
|
||||
runner:yield()
|
||||
@@ -998,9 +1112,10 @@ for _, verb in ipairs({ "show_text", "ask", "choice", "start_battle", "warp",
|
||||
end
|
||||
for _, verb in ipairs({ "show_text", "ask", "choice", "start_battle", "warp",
|
||||
"open_mart", "trade", "push_screen", "record_hall_of_fame",
|
||||
"old_man_demo", "static_battle", "rival_battle", "give_item", "wait",
|
||||
"old_man_demo", "static_battle", "rival_battle", "give_item",
|
||||
"give_pokemon", "wait",
|
||||
"wait_flag", "move_player", "move_npc", "move_npc_to", "walk_npc",
|
||||
"emote", "fade", "pan_camera" }) do
|
||||
"emote", "fade", "pan_camera", "play_once" }) do
|
||||
local meta = Commands.meta[verb] or {}
|
||||
Commands.meta[verb] = meta
|
||||
meta.blocking = true
|
||||
|
||||
+3
-1
@@ -216,7 +216,9 @@ local function useOn(game, battle, id, target, list, moveIndex)
|
||||
consume(game, id)
|
||||
require("src.core.Sound").play(game.data, "Teleport_Exit1")
|
||||
ow.player.surfing = false
|
||||
ow:warpToHealPoint()
|
||||
-- EnterMapAnim on arrival (BIT_ESCAPE_WARP / special warp path);
|
||||
-- blackouts omit arrive="teleport" (HandleBlackOut has no LeaveMapAnim)
|
||||
ow:warpToHealPoint(nil, { arrive = "teleport" })
|
||||
else
|
||||
showMessages(game, { "OAK: " .. game.save.player.name
|
||||
.. "!\nThis isn't the\ntime to use that!" })
|
||||
|
||||
+22
-4
@@ -11,7 +11,9 @@ local Input = require("src.core.Input")
|
||||
local BindingsMenu = setmetatable({}, { __index = ListMenu })
|
||||
BindingsMenu.__index = BindingsMenu
|
||||
|
||||
-- Input.lua's map, primary key first where several keys share a button
|
||||
-- Input.lua's map, primary key first where several keys share a button.
|
||||
-- `pad` is the default SDL gamecontroller button (see Input.lua); shown
|
||||
-- on the SELECT row so controller Back/View is discoverable (#73).
|
||||
local BUTTONS = {
|
||||
{ id = "up", label = "UP", key = "up" },
|
||||
{ id = "down", label = "DOWN", key = "down" },
|
||||
@@ -20,7 +22,7 @@ local BUTTONS = {
|
||||
{ id = "a", label = "A", key = "z" },
|
||||
{ id = "b", label = "B", key = "x" },
|
||||
{ id = "start", label = "START", key = "escape" },
|
||||
{ id = "select", label = "SELECT", key = "rshift" },
|
||||
{ id = "select", label = "SELECT", key = "tab", pad = "back" },
|
||||
}
|
||||
|
||||
-- a binding is a plain key string or { key, pad }; absent = the fixed
|
||||
@@ -32,13 +34,29 @@ local function boundKey(overlay, def)
|
||||
return def.key
|
||||
end
|
||||
|
||||
local function boundPad(overlay, def)
|
||||
local b = overlay and overlay[def.id]
|
||||
if type(b) == "table" and b.pad then return b.pad end
|
||||
return def.pad
|
||||
end
|
||||
|
||||
-- Key column for every row. SELECT also appends "/PAD" (default BACK)
|
||||
-- so controller Select/View is visible without opening a second legend.
|
||||
local function boundRight(overlay, def)
|
||||
local key = boundKey(overlay, def)
|
||||
if def.id ~= "select" then return key:upper() end
|
||||
local pad = boundPad(overlay, def)
|
||||
if pad then return (key .. "/" .. pad):upper() end
|
||||
return key:upper()
|
||||
end
|
||||
|
||||
function BindingsMenu.new(game)
|
||||
local overlay = game.save and game.save.options
|
||||
and game.save.options.bindings
|
||||
local items = {}
|
||||
for i, def in ipairs(BUTTONS) do
|
||||
items[i] = { label = def.label,
|
||||
right = boundKey(overlay, def):upper(), button = def }
|
||||
right = boundRight(overlay, def), button = def }
|
||||
end
|
||||
local self = setmetatable(ListMenu.new(game, "CONTROLS", items, {}),
|
||||
BindingsMenu)
|
||||
@@ -78,7 +96,7 @@ function BindingsMenu:storeBinding(slot, value)
|
||||
end
|
||||
b[slot] = value
|
||||
opts.bindings[item.button.id] = b
|
||||
item.right = boundKey(opts.bindings, item.button):upper()
|
||||
item.right = boundRight(opts.bindings, item.button)
|
||||
Input:applyBindings(opts.bindings)
|
||||
if game.writeOptions then game:writeOptions() end
|
||||
end
|
||||
|
||||
+5
-1
@@ -145,6 +145,10 @@ end
|
||||
|
||||
function BoxMenu.new(game)
|
||||
Boxes.ensure(game.save)
|
||||
-- bills_pc.asm BillsPCMenu: TextBoxBorder at (0,0) with interior
|
||||
-- 12x10 → total 14x12. "CHANGE BOX" is 10 tiles and needs the
|
||||
-- full interior (cursor col + label); the old tw=12 right-side box
|
||||
-- drew the final glyph on the border.
|
||||
return Menu.new(game, {
|
||||
{ label = "WITHDRAW", onSelect = function() withdraw(game) end },
|
||||
{ label = "DEPOSIT", onSelect = function() deposit(game) end },
|
||||
@@ -153,7 +157,7 @@ function BoxMenu.new(game)
|
||||
{ label = "SEE YA!" },
|
||||
-- Bill's PC runs silent end to end (BIT_NO_MENU_BUTTON_SOUND,
|
||||
-- engine/menus/pokemon_pc.asm)
|
||||
}, { tx = 8, ty = 0, tw = 12, th = 12, noSound = true })
|
||||
}, { tx = 0, ty = 0, tw = 14, th = 12, noSound = true })
|
||||
end
|
||||
|
||||
return BoxMenu
|
||||
|
||||
+2
-1
@@ -217,7 +217,8 @@ function Credits:update(dt)
|
||||
self.timer = FADE_FRAMES
|
||||
elseif self.phase == "end_fade" then
|
||||
-- Credits returns to HallOfFameResetEventsAndSaveScript here: the
|
||||
-- save happens now, then 5 x 120 DelayFrames before the button wait
|
||||
-- save (plus post-game home relocate, #103) happens now, then
|
||||
-- 5 x 120 DelayFrames before the button wait
|
||||
if self.onTheEnd then self.onTheEnd() end
|
||||
self.phase = "end_hold"
|
||||
self.timer = 600
|
||||
|
||||
+23
-4
@@ -1,5 +1,11 @@
|
||||
-- Pokédex entry page: front sprite, kind, height/weight and the real
|
||||
-- dex description (data/pokemon/dex_entries.asm + dex_text.asm).
|
||||
--
|
||||
-- `species` may be a species id string, or a table
|
||||
-- `{ species = id, forceOwned = true }`. forceOwned mirrors pret's
|
||||
-- StarterDex (engine/events/starter_dex.asm), which temporarily sets the
|
||||
-- owned bit so Oak's lab ball previews show height/weight/description
|
||||
-- without permanently marking the mon owned.
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
|
||||
@@ -16,8 +22,17 @@ function DexEntryMenu:sgbPalettes(game)
|
||||
P.zone(P.monPal(game.data, self.def and self.def.id), 1, 1, 8, 8) }
|
||||
end
|
||||
|
||||
function DexEntryMenu.new(game, species)
|
||||
local self = setmetatable({ game = game }, DexEntryMenu)
|
||||
local function resolveArgs(speciesOrOpts)
|
||||
if type(speciesOrOpts) == "table" then
|
||||
return speciesOrOpts.species or speciesOrOpts[1],
|
||||
speciesOrOpts.forceOwned and true or false
|
||||
end
|
||||
return speciesOrOpts, false
|
||||
end
|
||||
|
||||
function DexEntryMenu.new(game, speciesOrOpts)
|
||||
local species, forceOwned = resolveArgs(speciesOrOpts)
|
||||
local self = setmetatable({ game = game, forceOwned = forceOwned }, DexEntryMenu)
|
||||
self.def = game.data.pokemon[species]
|
||||
local ok, img = pcall(love.graphics.newImage, self.def.spriteFront)
|
||||
self.sprite = ok and img or nil
|
||||
@@ -42,12 +57,16 @@ function DexEntryMenu:draw()
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(def.name, 72, 8)
|
||||
local e = def.dexEntry or {}
|
||||
Font.draw((e.kind or "?") .. " POKéMON", 72, 20)
|
||||
-- English R/B prints only the kind string (hlcoord 9,4 PlaceString).
|
||||
-- PokeText ("#"/POKéMON) is an unreferenced JPN leftover in pokedex.asm;
|
||||
-- appending " POKéMON" here clipped longer kinds ("LIZARD POKé").
|
||||
Font.draw(e.kind or "?", 72, 20)
|
||||
-- same number width as the list (constants.dexDigits), so a dex past 999
|
||||
-- prints the extra digit everywhere at once
|
||||
local digits = (self.game.data.constants or {}).dexDigits or 3
|
||||
Font.draw(("No.%0" .. digits .. "d"):format(def.dex or 0), 72, 32)
|
||||
local owned = self.game.save.pokedex and self.game.save.pokedex.owned[def.id]
|
||||
local owned = self.forceOwned
|
||||
or (self.game.save.pokedex and self.game.save.pokedex.owned[def.id])
|
||||
-- height/weight print only once owned, like the description
|
||||
-- (pokedex.asm: "if the pokemon has not been owned, don't print the
|
||||
-- height, weight, or description")
|
||||
|
||||
+204
-79
@@ -1,14 +1,16 @@
|
||||
-- Hall of Fame induction (engine/movie/hall_of_fame.asm): each party
|
||||
-- member's front sprite scrolls onto the screen (HoFShowMonOrPlayer's
|
||||
-- .ScrollPic), then its name/level shows and its cry plays
|
||||
-- (HoFDisplayAndRecordMonInfo). After the last mon, HoFDisplayPlayerStats
|
||||
-- shows the trainer name, play time, money and Prof. Oak's dex rating.
|
||||
-- Plays Music_HallOfFame when the audio data has it. Calls onDone() after
|
||||
-- popping itself.
|
||||
-- member's front sprite scrolls onto the right side of the screen
|
||||
-- (HoFShowMonOrPlayer's .ScrollPic), then HoFDisplayMonInfo draws the
|
||||
-- left-side LEVEL/TYPE box, plays the cry, holds, and pops the bottom
|
||||
-- "HALL OF FAME" text box before fading to the next mon. After the
|
||||
-- party, the player pic scrolls in and HoFDisplayPlayerStats shows the
|
||||
-- name/time/money boxes plus the dex rating. Plays Music_HallOfFame
|
||||
-- when the audio data has it. Calls onDone() after popping itself.
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
local Music = require("src.core.Music")
|
||||
local Sound = require("src.core.Sound")
|
||||
local TypeChart = require("src.battle.TypeChart")
|
||||
|
||||
local HallOfFame = {}
|
||||
HallOfFame.__index = HallOfFame
|
||||
@@ -17,6 +19,10 @@ HallOfFame.isOpaque = true
|
||||
-- SGB: SetPal_PokemonWholeScreen for the mon on display
|
||||
function HallOfFame:sgbPalettes(game)
|
||||
local P = require("src.render.PaletteFX")
|
||||
if self.phase == "player" or self.phase == "player_stats"
|
||||
or self.phase == "player_dex" or self.phase == "player_rating" then
|
||||
return P.wholeNamed(game.data, "MEWMON")
|
||||
end
|
||||
local mon = game.save.party[self.index or 0]
|
||||
if mon then
|
||||
local c = P.monPal(game.data, mon.species)
|
||||
@@ -26,17 +32,20 @@ function HallOfFame:sgbPalettes(game)
|
||||
return P.wholeNamed(game.data, "MEWMON")
|
||||
end
|
||||
|
||||
local MON_FRAMES = 150 -- ~2.5s per inductee (A advances early)
|
||||
|
||||
-- HoFShowMonOrPlayer's .ScrollPic: hSCX is nudged by e = 4px per
|
||||
-- DelayFrame (doubled on SGB) until it settles. The back pic (an
|
||||
-- enlarged, blurred 2x scale of the back sprite) sweeps right-to-left
|
||||
-- and off the left edge first; tracing the actual hSCX/hSCY math shows
|
||||
-- the real front pic that follows enters from the *left* edge and
|
||||
-- slides *right* into its resting tile, at that same 4px/frame rate --
|
||||
-- that's the half we port here (the back-pic wipe is a VRAM/scroll-
|
||||
-- register trick with no equivalent in this sprite-based renderer).
|
||||
-- DelayFrame (doubled on SGB) until it settles. The front pic rests at
|
||||
-- hlcoord 12,5 (engine/movie/hall_of_fame.asm HoFLoadMonPlayerPicTileIDs).
|
||||
local SCROLL_SPEED = 4 -- px/frame @ 60fps
|
||||
local PIC_X, PIC_Y = 12 * 8, 5 * 8
|
||||
|
||||
-- After HoFDisplayAndRecordMonInfo: 80 DelayFrames, then the bottom
|
||||
-- HALL OF FAME box for 180 DelayFrames, then GBFadeOutToWhite.
|
||||
local INFO_HOLD = 80
|
||||
local HOF_HOLD = 180
|
||||
local FADE_FRAMES = 20
|
||||
|
||||
-- HoFPrintTextAndDelay after each dex line
|
||||
local DEX_HOLD = 120
|
||||
|
||||
local function tryImage(path)
|
||||
if not path then return nil end
|
||||
@@ -57,7 +66,7 @@ local function drawTextBlock(text, x, y, maxY)
|
||||
for line in (text:gsub("\v", "\n"):gsub("\f", "\n") .. "\n"):gmatch("(.-)\n") do
|
||||
if maxY and y > maxY then break end
|
||||
Font.draw(line, x, y)
|
||||
y = y + 10
|
||||
y = y + 8
|
||||
end
|
||||
return y
|
||||
end
|
||||
@@ -70,6 +79,10 @@ function HallOfFame.new(game, onDone)
|
||||
self.timer = 0
|
||||
self.phase = "mons"
|
||||
self.sprites = {} -- species -> image or false
|
||||
self.playerPic = tryImage("assets/generated/trainer_card/red.png")
|
||||
self.scrollX = PIC_X
|
||||
self.showHofBanner = false
|
||||
self.fade = 0
|
||||
return self
|
||||
end
|
||||
|
||||
@@ -84,16 +97,21 @@ end
|
||||
function HallOfFame:nextMon()
|
||||
self.index = self.index + 1
|
||||
local mon = self.game.save.party[self.index]
|
||||
self.showHofBanner = false
|
||||
self.fade = 0
|
||||
if mon then
|
||||
self.timer = MON_FRAMES
|
||||
self.phase = "mons"
|
||||
self.timer = INFO_HOLD
|
||||
Sound.playCry(self.game.data, mon.species)
|
||||
-- scroll the new inductee's pic in from the left (see SCROLL_SPEED)
|
||||
local sprite = self:spriteFor(mon.species)
|
||||
local w = sprite and sprite:getWidth() or 0
|
||||
self.scrollRestX = math.floor((160 - w) / 2)
|
||||
local w = sprite and sprite:getWidth() or 56
|
||||
self.scrollX = -w
|
||||
else
|
||||
self.phase = "congrats"
|
||||
-- HoFShowMonOrPlayer with wHoFMonOrPlayer = player
|
||||
self.phase = "player"
|
||||
self.timer = 0
|
||||
local w = self.playerPic and self.playerPic:getWidth() or 56
|
||||
self.scrollX = -w
|
||||
end
|
||||
end
|
||||
|
||||
@@ -117,77 +135,184 @@ function HallOfFame:dexSeenOwned()
|
||||
return seen, owned
|
||||
end
|
||||
|
||||
function HallOfFame:update(dt)
|
||||
local input = self.game.input
|
||||
if self.phase == "mons" then
|
||||
if self.scrollX and self.scrollX < self.scrollRestX then
|
||||
self.scrollX = math.min(self.scrollRestX, self.scrollX + SCROLL_SPEED)
|
||||
end
|
||||
self.timer = self.timer - 1
|
||||
if input:wasPressed("a") or self.timer <= 0 then
|
||||
self:nextMon()
|
||||
end
|
||||
elseif input:wasPressed("a") then
|
||||
Sound.play(self.game.data, "Press_AB")
|
||||
self.game.stack:pop()
|
||||
if self.onDone then self.onDone() end
|
||||
function HallOfFame:advanceMonPhase()
|
||||
if self.phase == "mons" and not self.showHofBanner then
|
||||
-- 80-frame info hold done: TextBoxBorder at (2,13) + "HALL OF FAME"
|
||||
self.showHofBanner = true
|
||||
self.timer = HOF_HOLD
|
||||
elseif self.phase == "mons" then
|
||||
self.phase = "fade"
|
||||
self.timer = FADE_FRAMES
|
||||
self.fade = 0
|
||||
elseif self.phase == "fade" then
|
||||
self:nextMon()
|
||||
end
|
||||
end
|
||||
|
||||
function HallOfFame:draw()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
if self.phase == "mons" then
|
||||
Font.draw("HALL OF FAME", (160 - 12 * 8) / 2, 8)
|
||||
local mon = self.game.save.party[self.index]
|
||||
if mon then
|
||||
local def = self.game.data.pokemon[mon.species]
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
local sprite = self:spriteFor(mon.species)
|
||||
if sprite then
|
||||
local w, h = sprite:getDimensions()
|
||||
love.graphics.draw(sprite, self.scrollX or math.floor((160 - w) / 2), 96 - h)
|
||||
end
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
local name = mon.nickname or (def and def.name) or mon.species
|
||||
Font.draw(name, 32, 108)
|
||||
Font.draw((":L%d"):format(mon.level), 112, 108)
|
||||
end
|
||||
else
|
||||
-- HoFDisplayPlayerStats (no "HALL OF FAME" banner here -- the real
|
||||
-- screen is a fresh ClearScreen): trainer name, play time, money,
|
||||
-- then the POKéDEX seen/owned tally and Prof. Oak's rating text,
|
||||
-- using the same real save-data fields as TrainerCard.lua
|
||||
-- (save.player.name/playTime/money) and PokedexMenu.lua/
|
||||
-- OverworldController:dexRating (save.pokedex.seen/owned).
|
||||
local save = self.game.save
|
||||
local text = self.game.data.text or {}
|
||||
local y = 8
|
||||
Font.draw(save.player.name or "RED", 8, y)
|
||||
y = y + 16
|
||||
local t = math.floor(save.playTime or 0)
|
||||
Font.draw(("PLAY TIME %3d:%02d"):format(math.floor(t / 3600),
|
||||
math.floor(t / 60) % 60), 8, y)
|
||||
y = y + 12
|
||||
Font.draw(("MONEY ¥%d"):format(save.money or 0), 8, y)
|
||||
y = y + 16
|
||||
function HallOfFame:update(dt)
|
||||
local input = self.game.input
|
||||
local skip = input:wasPressed("a")
|
||||
|
||||
if self.phase == "mons" or self.phase == "fade" then
|
||||
if self.phase == "mons" and self.scrollX < PIC_X then
|
||||
self.scrollX = math.min(PIC_X, self.scrollX + SCROLL_SPEED)
|
||||
return
|
||||
end
|
||||
if self.phase == "fade" then
|
||||
self.timer = self.timer - 1
|
||||
self.fade = 1 - math.max(0, self.timer) / FADE_FRAMES
|
||||
if self.timer <= 0 or skip then self:advanceMonPhase() end
|
||||
return
|
||||
end
|
||||
self.timer = self.timer - 1
|
||||
if skip or self.timer <= 0 then
|
||||
self:advanceMonPhase()
|
||||
end
|
||||
elseif self.phase == "player" then
|
||||
if self.scrollX < PIC_X then
|
||||
self.scrollX = math.min(PIC_X, self.scrollX + SCROLL_SPEED)
|
||||
return
|
||||
end
|
||||
self.phase = "player_stats"
|
||||
self.timer = DEX_HOLD
|
||||
elseif self.phase == "player_stats" then
|
||||
-- name / play time / money boxes are up; then DexSeenOwnedText
|
||||
self.timer = self.timer - 1
|
||||
if skip or self.timer <= 0 then
|
||||
self.phase = "player_dex"
|
||||
self.timer = DEX_HOLD
|
||||
end
|
||||
elseif self.phase == "player_dex" then
|
||||
self.timer = self.timer - 1
|
||||
if skip or self.timer <= 0 then
|
||||
self.phase = "player_rating"
|
||||
self.timer = DEX_HOLD
|
||||
end
|
||||
elseif self.phase == "player_rating" then
|
||||
self.timer = self.timer - 1
|
||||
if skip or self.timer <= 0 then
|
||||
-- HoFFadeOutScreenAndMusic -> Credits lead-in (no A wait here)
|
||||
self.game.stack:pop()
|
||||
if self.onDone then self.onDone() end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- HoFDisplayMonInfo: TextBoxBorder (0,2) b=9,c=10 + LEVEL/TYPE labels
|
||||
function HallOfFame:drawMonInfo(mon)
|
||||
local def = self.game.data.pokemon[mon.species]
|
||||
Font.drawBox(0, 2, 12, 11)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
local name = mon.nickname or (def and def.name) or mon.species
|
||||
Font.draw(name, 1 * 8, 4 * 8)
|
||||
Font.draw("LEVEL/", 2 * 8, 6 * 8)
|
||||
Font.draw("TYPE1/", 2 * 8, 7 * 8)
|
||||
local t1 = def and def.types and def.types[1]
|
||||
local t2 = def and def.types and def.types[2]
|
||||
local dual = t2 and t2 ~= t1
|
||||
if dual then
|
||||
Font.draw("TYPE2/", 2 * 8, 8 * 8)
|
||||
end
|
||||
-- PrintLevelCommon at (8,7): bare level digits (no <LV> tile here)
|
||||
Font.draw(tostring(mon.level), 8 * 8, 7 * 8)
|
||||
-- PrintMonType at (3,9) / +2 rows for type 2
|
||||
if t1 then
|
||||
Font.draw(TypeChart.displayName(t1), 3 * 8, 9 * 8)
|
||||
end
|
||||
if dual then
|
||||
Font.draw(TypeChart.displayName(t2), 3 * 8, 11 * 8)
|
||||
end
|
||||
end
|
||||
|
||||
-- Bottom HALL OF FAME banner: TextBoxBorder (2,13) b=3,c=14
|
||||
function HallOfFame:drawHofBanner()
|
||||
Font.drawBox(2, 13, 16, 5)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw("HALL OF FAME", 4 * 8, 15 * 8)
|
||||
end
|
||||
|
||||
function HallOfFame:drawPic(img)
|
||||
if not img then return end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(img, self.scrollX or PIC_X, PIC_Y)
|
||||
end
|
||||
|
||||
-- HoFDisplayPlayerStats boxes + labels (player pic already on the right)
|
||||
function HallOfFame:drawPlayerStats()
|
||||
local save = self.game.save
|
||||
-- name box: TextBoxBorder (5,0) b=2,c=9 → drawBox(5,0,11,4)
|
||||
Font.drawBox(5, 0, 11, 4)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(save.player.name or "RED", 7 * 8, 2 * 8)
|
||||
|
||||
-- play time / money box: TextBoxBorder (0,4) b=6,c=10 → drawBox(0,4,12,8)
|
||||
Font.drawBox(0, 4, 12, 8)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw("PLAY TIME", 1 * 8, 6 * 8)
|
||||
local t = math.floor(save.playTime or 0)
|
||||
Font.draw(("%3d:%02d"):format(math.floor(t / 3600), math.floor(t / 60) % 60),
|
||||
5 * 8, 7 * 8)
|
||||
Font.draw("MONEY", 1 * 8, 9 * 8)
|
||||
-- PrintBCDNumber with MONEY_SIGN; port uses ¥ like TrainerCard
|
||||
Font.draw(("¥%d"):format(save.money or 0), 4 * 8, 10 * 8)
|
||||
end
|
||||
|
||||
function HallOfFame:drawDexBox(kind)
|
||||
local save = self.game.save
|
||||
local text = self.game.data.text or {}
|
||||
Font.drawBox(0, 12, 20, 6)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
if kind == "seen" then
|
||||
local seen, owned = self:dexSeenOwned()
|
||||
local seenOwned = text._DexSeenOwnedText
|
||||
or "POKéDEX Seen:{NUM:wDexRatingNumMonsSeen, 1, 3}\n Owned:{NUM:wDexRatingNumMonsOwned, 1, 3}"
|
||||
seenOwned = seenOwned
|
||||
:gsub("{NUM:wDexRatingNumMonsSeen[^}]*}", tostring(seen))
|
||||
:gsub("{NUM:wDexRatingNumMonsOwned[^}]*}", tostring(owned))
|
||||
y = drawTextBlock(seenOwned, 8, y) + 6
|
||||
|
||||
drawTextBlock(seenOwned, 1 * 8, 14 * 8, 17 * 8)
|
||||
else
|
||||
local _, owned = self:dexSeenOwned()
|
||||
local ratingHeader = (text._DexRatingText or "POKéDEX Rating{COLON}"):gsub("{COLON}", ":")
|
||||
Font.draw(ratingHeader, 8, y)
|
||||
y = y + 12
|
||||
|
||||
Font.draw(ratingHeader, 1 * 8, 14 * 8)
|
||||
local rating = text[dexRatingKey(owned)] or "Keep it up!"
|
||||
drawTextBlock(rating, 8, y, 136)
|
||||
drawTextBlock(rating, 1 * 8, 15 * 8, 17 * 8)
|
||||
end
|
||||
end
|
||||
|
||||
function HallOfFame:draw()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
|
||||
if self.phase == "mons" or self.phase == "fade" then
|
||||
local mon = self.game.save.party[self.index]
|
||||
if mon then
|
||||
self:drawPic(self:spriteFor(mon.species))
|
||||
if self.scrollX >= PIC_X then
|
||||
self:drawMonInfo(mon)
|
||||
if self.showHofBanner then
|
||||
self:drawHofBanner()
|
||||
end
|
||||
end
|
||||
end
|
||||
if self.phase == "fade" and self.fade > 0 then
|
||||
love.graphics.setColor(1, 1, 1, self.fade)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
end
|
||||
elseif self.phase == "player" then
|
||||
self:drawPic(self.playerPic)
|
||||
elseif self.phase == "player_stats" then
|
||||
self:drawPic(self.playerPic)
|
||||
self:drawPlayerStats()
|
||||
elseif self.phase == "player_dex" then
|
||||
self:drawPic(self.playerPic)
|
||||
self:drawPlayerStats()
|
||||
self:drawDexBox("seen")
|
||||
elseif self.phase == "player_rating" then
|
||||
self:drawPic(self.playerPic)
|
||||
self:drawPlayerStats()
|
||||
self:drawDexBox("rating")
|
||||
end
|
||||
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
|
||||
+13
-3
@@ -117,7 +117,7 @@ function ListMenu:draw()
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
end
|
||||
if item.right then
|
||||
Font.draw(item.right, 160 - 8 - #item.right * 8, y)
|
||||
Font.draw(item.right, 160 - 8 - Font.width(item.right), y)
|
||||
end
|
||||
if i == self.index then
|
||||
-- hollowIndex: a chosen row keeps the hollow '▷' left behind by
|
||||
@@ -136,7 +136,7 @@ function ListMenu:draw()
|
||||
Font.drawBox(11, 0, 9, 3)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
local money = ("¥%d"):format(self.money and self.money() or 0)
|
||||
Font.draw(money, 152 - #money * 8, 8)
|
||||
Font.draw(money, 152 - Font.width(money), 8)
|
||||
-- the clerk's line in the standard bottom text box; long prompts
|
||||
-- wrap and keep their last two lines, like the GB's scrolled box
|
||||
Font.drawBox(0, 12, 20, 6)
|
||||
@@ -153,7 +153,17 @@ function ListMenu:draw()
|
||||
end
|
||||
end
|
||||
elseif self.footer then
|
||||
Font.draw(self.footer, 8, 136)
|
||||
-- PC deposit/withdraw footers use "\n"; draw the last two lines so
|
||||
-- long item names are not clipped at the screen edge (#115).
|
||||
local flat = {}
|
||||
for _, page in ipairs(require("src.render.TextBox").paginate(self.footer)) do
|
||||
for _, line in ipairs(page) do flat[#flat + 1] = line end
|
||||
end
|
||||
local y = (#flat >= 2) and 120 or 136
|
||||
for i = math.max(1, #flat - 1), #flat do
|
||||
Font.draw(flat[i], 8, y)
|
||||
y = y + 16
|
||||
end
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
+43
-4
@@ -19,7 +19,16 @@ function Menu.new(game, items, opts)
|
||||
self.tx = opts.tx or 10
|
||||
self.ty = opts.ty or 0
|
||||
self.tw = opts.tw or 10
|
||||
self.th = opts.th or (#items * 2 + 2)
|
||||
self.rowStep = opts.rowStep or 2
|
||||
-- maxVisible: cap the box to this many rows and scroll the rest instead
|
||||
-- of growing past it (e.g. the start menu, whose row count varies with
|
||||
-- save state and mod hooks); nil/unset keeps every caller's old
|
||||
-- behavior of sizing the box to fit all items.
|
||||
self.maxVisible = opts.maxVisible
|
||||
self.scroll = 0
|
||||
local visible = (self.maxVisible and math.min(self.maxVisible, #items))
|
||||
or #items
|
||||
self.th = opts.th or (visible * self.rowStep + 2)
|
||||
self.cancelable = opts.cancelable ~= false
|
||||
-- Whether START closes the menu. In pokered a menu responds only to the
|
||||
-- keys in its wMenuWatchedKeys mask; the common PAD_A | PAD_B (and the
|
||||
@@ -31,9 +40,25 @@ function Menu.new(game, items, opts)
|
||||
-- BIT_NO_MENU_BUTTON_SOUND (wMiscFlags): the PC session runs its
|
||||
-- menus silent (home/window.asm HandleMenuInput_)
|
||||
self.noSound = opts.noSound or false
|
||||
self:clampScroll()
|
||||
return self
|
||||
end
|
||||
|
||||
-- keeps self.index inside the visible [scroll+1, scroll+maxVisible] window;
|
||||
-- callers that move self.index directly (e.g. restoring a saved cursor
|
||||
-- position) should call this afterwards to scroll it into view
|
||||
function Menu:clampScroll()
|
||||
if not (self.maxVisible and #self.items > self.maxVisible) then
|
||||
self.scroll = 0
|
||||
return
|
||||
end
|
||||
if self.index - self.scroll > self.maxVisible then
|
||||
self.scroll = self.index - self.maxVisible
|
||||
elseif self.index - self.scroll < 1 then
|
||||
self.scroll = self.index - 1
|
||||
end
|
||||
end
|
||||
|
||||
function Menu:update(dt)
|
||||
local input = self.game.input
|
||||
if input:wasPressed("up") then
|
||||
@@ -61,15 +86,29 @@ function Menu:update(dt)
|
||||
self.game.stack:pop()
|
||||
if self.onCancel then self.onCancel() end
|
||||
end
|
||||
self:clampScroll()
|
||||
end
|
||||
|
||||
function Menu:draw()
|
||||
Font.drawBox(self.tx, self.ty, self.tw, self.th)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
for i, item in ipairs(self.items) do
|
||||
Font.draw(item.label, (self.tx + 2) * 8, (self.ty + i * 2 - 1) * 8)
|
||||
local visible = (self.maxVisible and math.min(self.maxVisible, #self.items))
|
||||
or #self.items
|
||||
for row = 1, visible do
|
||||
local item = self.items[self.scroll + row]
|
||||
if not item then break end
|
||||
Font.draw(item.label, (self.tx + 2) * 8,
|
||||
(self.ty + row * self.rowStep - (self.rowStep - 1)) * 8)
|
||||
end
|
||||
local cursorRow = self.index - self.scroll
|
||||
Font.drawCode(Theme.cursor, (self.tx + 1) * 8,
|
||||
(self.ty + cursorRow * self.rowStep - (self.rowStep - 1)) * 8)
|
||||
-- moreArrow ($EE): the same "more below" glyph OptionRows/ManagerState
|
||||
-- use, sat on the bottom border like TextBox's page-advance cursor
|
||||
if self.maxVisible and self.scroll + self.maxVisible < #self.items then
|
||||
Font.drawCode(Theme.moreArrow, (self.tx + self.tw - 2) * 8,
|
||||
(self.ty + self.th - 2) * 8)
|
||||
end
|
||||
Font.drawCode(Theme.cursor, (self.tx + 1) * 8, (self.ty + self.index * 2 - 1) * 8)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
|
||||
+68
-2
@@ -3,7 +3,8 @@
|
||||
-- (cycles the merged rulesets registry; gen1_faithful keeps the original
|
||||
-- quirks), plus the port's audio rows and display rows: music/SFX
|
||||
-- volume (0-7), music low-pass filter (OFF/1X/2X/3X), COLORS / TILT /
|
||||
-- GBC FX / VIDEO MODE, and the MODS row that opens the mod manager.
|
||||
-- GBC FX / ZOOM / VOID FILL / VIDEO MODE, and the MODS row that opens
|
||||
-- the mod manager.
|
||||
-- Rows are descriptors fed through the ui.options.rows hook, so mods can
|
||||
-- add their own; CANCEL is appended after the hook and stays fixed on the
|
||||
-- bottom line like pokered's.
|
||||
@@ -11,16 +12,28 @@
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local Tilt = require("src.render.Tilt")
|
||||
local GBCFX = require("src.render.GBCFX")
|
||||
local Zoom = require("src.render.Zoom")
|
||||
local TileRenderer = require("src.render.TileRenderer")
|
||||
local GameSpeed = require("src.core.GameSpeed")
|
||||
local VideoMode = require("src.core.VideoMode")
|
||||
local FrameCap = require("src.core.FrameCap")
|
||||
local Logger = require("src.core.Logger")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local OptionRows = require("src.ui.OptionRows")
|
||||
local Renderer = require("src.render.Renderer")
|
||||
|
||||
local OptionsMenu = {}
|
||||
OptionsMenu.__index = OptionsMenu
|
||||
OptionsMenu.isOpaque = true
|
||||
|
||||
-- Opaque full-screen menu: own MEWMON so opening OPTION from the title
|
||||
-- (or over the overworld) does not inherit TitleState's LOGO1 band -- that
|
||||
-- zone covers UI rows 8-9, which is the third options box label line
|
||||
-- (pink "MODS" strip when Blue's ROM LOGO1 white is {255,239,255}).
|
||||
function OptionsMenu:sgbPalettes(game)
|
||||
return PaletteFX.wholeNamed(game.data, "MEWMON")
|
||||
end
|
||||
|
||||
-- TextSpeedOptionData frame delays with the original labels
|
||||
local SPEEDS = { { 1, "FAST" }, { 3, "MEDIUM" }, { 5, "SLOW" } }
|
||||
-- no-loader fallback for the ruleset row, same pair BattleState keeps
|
||||
@@ -101,7 +114,7 @@ local function sameRows(_, rows) return rows end
|
||||
-- the vanilla rows as descriptors; each step body is the old per-index
|
||||
-- ladder's, so the save.options mutations are unchanged
|
||||
local function buildRows(game)
|
||||
return {
|
||||
local rows = {
|
||||
{ id = "textSpeed", label = "TEXT SPEED",
|
||||
value = function(g) return SPEEDS[speedIndex(g)][2] end,
|
||||
step = function(g)
|
||||
@@ -192,6 +205,37 @@ local function buildRows(game)
|
||||
GBCFX.setLevel(o.gbcfx)
|
||||
return true
|
||||
end },
|
||||
{ id = "zoom", label = "ZOOM",
|
||||
value = function(g)
|
||||
return Zoom.offsetLabel(g.save.options.zoom or 0)
|
||||
end,
|
||||
step = function(g, dir)
|
||||
local o = g.save.options
|
||||
local S = Renderer:fitScale()
|
||||
local lo, hi = Zoom.offsetRange(S)
|
||||
local off = (o.zoom or 0) + dir
|
||||
if off > hi then off = lo
|
||||
elseif off < lo then off = hi end
|
||||
o.zoom = off
|
||||
Zoom.offset = off
|
||||
return true
|
||||
end },
|
||||
{ id = "voidFill", label = "VOID FILL",
|
||||
value = function(g)
|
||||
return TileRenderer.voidFillLabel(g.save.options.voidFill)
|
||||
end,
|
||||
step = function(g, dir)
|
||||
local o = g.save.options
|
||||
local modes = TileRenderer.VOID_FILLS
|
||||
local cur = o.voidFill or "trees"
|
||||
local i = 1
|
||||
for idx, m in ipairs(modes) do
|
||||
if m == cur then i = idx; break end
|
||||
end
|
||||
o.voidFill = modes[wrapIndex(i - 1 + dir, #modes) + 1]
|
||||
TileRenderer.setVoidFill(o.voidFill)
|
||||
return true
|
||||
end },
|
||||
{ id = "videoMode", label = "VIDEO MODE",
|
||||
value = function(g)
|
||||
return VideoMode.modeLabel(g.save.options.videoMode)
|
||||
@@ -202,6 +246,19 @@ local function buildRows(game)
|
||||
VideoMode.apply(o.videoMode)
|
||||
return true
|
||||
end },
|
||||
-- hard render cap (issue #88): bounds the present rate so a
|
||||
-- driver-forced vsync-off run cannot spin at thousands of FPS. Logic
|
||||
-- is fixed-step off dt, so this touches presentation only.
|
||||
{ id = "fpsCap", label = "MAX FPS",
|
||||
value = function(g)
|
||||
return FrameCap.label(g.save.options.fpsCap)
|
||||
end,
|
||||
step = function(g, dir)
|
||||
local o = g.save.options
|
||||
o.fpsCap = FrameCap.cycle(o.fpsCap, dir)
|
||||
FrameCap.apply(o.fpsCap)
|
||||
return true
|
||||
end },
|
||||
-- fast-forward the logic clock only; music and sfx keep their tempo
|
||||
-- (src/core/GameSpeed.lua), so this is safe to leave on
|
||||
{ id = "speed", label = "GAME SPEED",
|
||||
@@ -230,6 +287,15 @@ local function buildRows(game)
|
||||
require("src.ui.Screens").push(g, "BindingsMenu")
|
||||
end },
|
||||
}
|
||||
-- issue #136: hide GBC FX on Android/iOS -- the present shader soft-bricks
|
||||
if not GBCFX.isSupported() then
|
||||
local filtered = {}
|
||||
for _, row in ipairs(rows) do
|
||||
if row.id ~= "gbcfx" then filtered[#filtered + 1] = row end
|
||||
end
|
||||
rows = filtered
|
||||
end
|
||||
return rows
|
||||
end
|
||||
|
||||
function OptionsMenu.new(game)
|
||||
|
||||
+39
-10
@@ -11,6 +11,8 @@ local Logger = require("src.core.Logger")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local Theme = require("src.ui.Theme")
|
||||
local FieldDefaults = require("src.world.FieldDefaults")
|
||||
local Map = require("src.world.Map")
|
||||
|
||||
local PartyMenu = {}
|
||||
PartyMenu.__index = PartyMenu
|
||||
@@ -69,10 +71,26 @@ local function drawIcon(game, mon, x, y, selected, counter)
|
||||
local icons = game.data.icons
|
||||
if not icons then return end
|
||||
local def = game.data.pokemon[mon.species]
|
||||
-- byDex is the vanilla lookup, but the icons registry can bring the table
|
||||
-- into existence on its own, so it may be the only key present
|
||||
local name = def and def.dex and icons.byDex and icons.byDex[def.dex]
|
||||
local path = name and icons.icons[name]
|
||||
-- Per-species override first: the icons registry folds into
|
||||
-- icons.bySpecies, and a pokemon record may carry its own `icon` field.
|
||||
-- Either is a built-in icon name (resolved through icons.icons) or a
|
||||
-- { image = <path>, frames? } table pointing at bundled art. Falling
|
||||
-- through to icons.byDex[def.dex] keeps the vanilla dex-indexed default;
|
||||
-- without the override a modded or dex-renumbered species could never
|
||||
-- change its menu icon.
|
||||
local entry = (icons.bySpecies and icons.bySpecies[mon.species])
|
||||
or (def and def.icon)
|
||||
local name, path
|
||||
if type(entry) == "string" then
|
||||
name = entry
|
||||
path = icons.icons and icons.icons[entry]
|
||||
elseif type(entry) == "table" then
|
||||
path = entry.image
|
||||
end
|
||||
if not path then
|
||||
name = def and def.dex and icons.byDex and icons.byDex[def.dex]
|
||||
path = name and icons.icons and icons.icons[name]
|
||||
end
|
||||
if not path then return end
|
||||
if iconImages[path] == nil then
|
||||
local ok, img = pcall(love.graphics.newImage, path)
|
||||
@@ -182,12 +200,13 @@ function PartyMenu:update(dt)
|
||||
-- and music return first (PlayDefaultMusic +
|
||||
-- LoadWalkingPlayerSpriteGraphics), the menu closes with the
|
||||
-- GBPalWhiteOutWithDelay3 blink, and the simulated pad press
|
||||
-- steps the player forward onto land
|
||||
-- steps the player forward onto land (or across a connection
|
||||
-- strip when the shore is the next map's edge)
|
||||
self.game.stack:pop()
|
||||
ow.player.surfing = false
|
||||
require("src.core.Music").setSurfing(self.game.data, false)
|
||||
self.game.stack:push(Transition.whiteFlash(self.game, nil, function()
|
||||
ow:scriptMove(ow.player, ow.player.facing, 1)
|
||||
ow:stepForwardOrCrossEdge(ow.player.facing)
|
||||
end))
|
||||
return
|
||||
end
|
||||
@@ -277,7 +296,9 @@ function PartyMenu:update(dt)
|
||||
if ow and heal then
|
||||
self.game.stack:push(Transition.whiteFlash(self.game, nil, function()
|
||||
require("src.core.Sound").play(self.game.data, "Teleport_Exit1")
|
||||
ow:warpToHealPoint()
|
||||
-- EnterMapAnim on arrival (HandleFlyWarpOrDungeonWarp sets
|
||||
-- BIT_FLY_WARP); blackouts must not pass arrive="teleport"
|
||||
ow:warpToHealPoint(nil, { arrive = "teleport" })
|
||||
end))
|
||||
end
|
||||
return
|
||||
@@ -331,9 +352,17 @@ function PartyMenu:update(dt)
|
||||
local items = { { label = "STATS", action = "stats" },
|
||||
{ label = "SWITCH", action = "switch" } }
|
||||
local ow = self.game.overworld
|
||||
if not self.battle and ow and mon.hp > 0 then
|
||||
-- Field moves (HMs/TMs) are usable out of battle even when the mon
|
||||
-- is fainted -- Gen 1 does not require HP for Cut/Fly/Surf/etc.
|
||||
-- Battle still excludes this list via `not self.battle`. Softboiled
|
||||
-- can appear for a fainted user; its heal transfer then no-ops.
|
||||
if not self.battle and ow then
|
||||
-- FLY/TELEPORT: CheckIfInOutsideMap (OVERWORLD + PLATEAU —
|
||||
-- Route 23 / Indigo Plateau outdoor), not OVERWORLD alone (#83)
|
||||
local outside = Map.isOutside(ow.map.def,
|
||||
FieldDefaults.field(self.game.data, "outsideTilesets"))
|
||||
for _, mv in ipairs(mon.moves) do
|
||||
if mv.id == "FLY" and ow.map.def.tileset == "OVERWORLD"
|
||||
if mv.id == "FLY" and outside
|
||||
and self.game.save.inventory.THUNDERBADGE then
|
||||
table.insert(items, { label = "FLY", action = "fly" })
|
||||
elseif mv.id == "FLASH" and ow.dark
|
||||
@@ -352,7 +381,7 @@ function PartyMenu:update(dt)
|
||||
table.insert(items, { label = "STRENGTH", action = "strength" })
|
||||
elseif mv.id == "SOFTBOILED" then
|
||||
table.insert(items, { label = "SOFTBOILED", action = "softboiled" })
|
||||
elseif mv.id == "TELEPORT" and ow.map.def.tileset == "OVERWORLD" then
|
||||
elseif mv.id == "TELEPORT" and outside then
|
||||
-- TELEPORT works only OUTDOORS (start_sub_menus.asm
|
||||
-- .teleport -> CheckIfInOutsideMap); dark maps don't
|
||||
-- block it
|
||||
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
-- The player's item-storage PC (engine/menus/players_pc.asm):
|
||||
-- WITHDRAW ITEM / DEPOSIT ITEM / TOSS ITEM / LOG OFF over
|
||||
-- game.save.pcItems ({ ITEM_ID = count }, created lazily). Withdraw and
|
||||
-- game.save.pcItems ({ ITEM_ID = count }; New Game seeds { POTION = 1 },
|
||||
-- older saves may still be empty until first deposit). Withdraw and
|
||||
-- deposit ask "How many?" via the quantity selector (key items always
|
||||
-- move one); toss discards after a YES/NO confirm. Follows the
|
||||
-- BoxMenu/BagMenu list idioms.
|
||||
|
||||
+7
-3
@@ -84,13 +84,17 @@ local function buy(game, stock)
|
||||
end
|
||||
|
||||
local function sell(game)
|
||||
-- Sell list is ITEMLISTMENU with wPrintItemPrices cleared
|
||||
-- (pokemart.asm .sellMenuLoop): name + quantity only. Price shows
|
||||
-- in the quantity chooser. Stuffing "xN" into the label next to a
|
||||
-- right-aligned ¥ price made long names overlap (issue #116).
|
||||
local items = {}
|
||||
for _, id in ipairs(Bag.order(game.save)) do
|
||||
local def = game.data.items[id]
|
||||
table.insert(items, {
|
||||
value = id,
|
||||
label = (def and def.name or id) .. " x" .. game.save.inventory[id],
|
||||
right = ("¥%d"):format(def and math.floor(def.price / 2) or 0),
|
||||
label = def and def.name or id,
|
||||
right = "x" .. game.save.inventory[id],
|
||||
})
|
||||
end
|
||||
local greet = txt(game, "_PokemartBuyingGreetingText", "Take your time.")
|
||||
@@ -128,7 +132,7 @@ local function sell(game)
|
||||
Bag.remove(game.save, item.value, qty)
|
||||
local left = game.save.inventory[item.value]
|
||||
if left then
|
||||
item.label = def.name .. " x" .. left
|
||||
item.right = "x" .. left
|
||||
else
|
||||
list:removeCurrent()
|
||||
end
|
||||
|
||||
+11
-1
@@ -7,6 +7,7 @@
|
||||
local Font = require("src.render.Font")
|
||||
local Logger = require("src.core.Logger")
|
||||
local Menu = require("src.ui.Menu")
|
||||
local Renderer = require("src.render.Renderer")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Screens = require("src.ui.Screens")
|
||||
|
||||
@@ -117,11 +118,20 @@ function StartMenu.new(game)
|
||||
-- the start menu's mask is PAD_DOWN | PAD_UP | PAD_START | PAD_B | PAD_A
|
||||
-- (engine/menus/draw_start_menu.asm), so START closes it back to the
|
||||
-- overworld -- unlike most menus, whose masks omit PAD_START.
|
||||
--
|
||||
-- item count isn't fixed: POKéDEX/LINK/MODS come and go with save state,
|
||||
-- and mods can append their own rows through the hook above, so the
|
||||
-- double-spaced box (the original's style) can grow past the 18-tile
|
||||
-- canvas. Cap it at however many rows actually fit and scroll the rest,
|
||||
-- with Menu's moreArrow showing while there's more below.
|
||||
local rowStep = 2
|
||||
local maxVisible = math.floor((Renderer.HEIGHT / 8 - 2) / rowStep)
|
||||
local menu = Menu.new(game, items,
|
||||
{ tx = 9, ty = 0, tw = 11, th = #items * 2 + 2, startCloses = true })
|
||||
{ tx = 9, ty = 0, tw = 11, maxVisible = maxVisible, startCloses = true })
|
||||
-- the cursor position survives closing the menu
|
||||
-- (wBattleAndStartSavedMenuItem, home/start_menu.asm)
|
||||
menu.index = math.min(game.save.startMenuIndex or 1, #items)
|
||||
menu:clampScroll()
|
||||
local baseUpdate = menu.update
|
||||
menu.update = function(self, dt)
|
||||
baseUpdate(self, dt)
|
||||
|
||||
@@ -115,12 +115,16 @@ function SummaryMenu:draw()
|
||||
drawLineBox(19, 1, 6, 10)
|
||||
Font.draw("EXP POINTS", 72, 24)
|
||||
Font.draw(("%d"):format(mon.exp), 96, 32)
|
||||
Font.draw("LEVEL UP", 72, 44)
|
||||
-- StatusScreen2: "LEVEL UP" at (9,5); next-exp PrintNumber 7 cols
|
||||
-- at (7,6); space at (14,6); PrintLevel at (16,6). The old
|
||||
-- "%d to L%d" string at x=88 overflowed the DrawLineBox edge.
|
||||
Font.draw("LEVEL UP", 72, 40)
|
||||
local Growth = require("src.pokemon.Growth")
|
||||
local nextExp = mon.level < 100
|
||||
and (Growth.expForLevel(def.growthRate, mon.level + 1) - mon.exp) or 0
|
||||
Font.draw(("%d to L%d"):format(math.max(0, nextExp),
|
||||
math.min(100, mon.level + 1)), 88, 52)
|
||||
Font.draw(("%7d"):format(math.max(0, nextExp)), 56, 48)
|
||||
HudTiles.tile(0x6E, 128, 48) -- <LV>
|
||||
Font.draw(tostring(math.min(100, mon.level + 1)), 136, 48)
|
||||
Font.drawBox(0, 8, 20, 10)
|
||||
for i = 1, 4 do
|
||||
local mv = mon.moves[i]
|
||||
|
||||
+65
-14
@@ -5,6 +5,7 @@
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
local Music = require("src.core.Music")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
|
||||
local TitleState = {}
|
||||
TitleState.__index = TitleState
|
||||
@@ -12,13 +13,35 @@ TitleState.isOpaque = true
|
||||
|
||||
-- SGB title zones (PalPacket_Titlescreen): the logo rows get LOGO2,
|
||||
-- the version-ribbon band LOGO1, the rest MEWMON.
|
||||
--
|
||||
-- The CONTINUE / NEW GAME menu and the continue-info box sit inside those
|
||||
-- LOGO bands. pokered's MainMenu clears the title and runs
|
||||
-- RunDefaultPaletteCommand so black UI ink stays black; this port keeps the
|
||||
-- title art visible underneath, so without an overlay those boxes inherit
|
||||
-- LOGO2 blue / LOGO1 red (issue #133). A trailing trueColor zone leaves the
|
||||
-- overlay's DMG black unshaded while the logo and title mon keep title pals.
|
||||
--
|
||||
-- ROM SuperPal whites are often {255,239,255}. Under RED++, LOGO2/MEWMON
|
||||
-- come from the GBC pack (pure white) while Blue's LOGO1 stays on the ROM
|
||||
-- pack (#128), so the version-ribbon row reads as a pink band. Force that
|
||||
-- slot to pure white; ink colors (Blue/Red "Version" text) stay intact.
|
||||
local function withPureWhite(pal)
|
||||
if not pal then return nil end
|
||||
return { { 255, 255, 255 }, pal[2], pal[3], pal[4] }
|
||||
end
|
||||
|
||||
function TitleState:sgbPalettes(game)
|
||||
local P = require("src.render.PaletteFX")
|
||||
local z = {
|
||||
P.zone(P.pal(game.data, "LOGO2"), 0, 0, 19, 7),
|
||||
P.zone(P.pal(game.data, "LOGO1"), 0, 8, 19, 9),
|
||||
P.zone(withPureWhite(P.pal(game.data, "LOGO1")), 0, 8, 19, 9),
|
||||
P.zone(P.pal(game.data, "MEWMON"), 0, 10, 19, 17),
|
||||
}
|
||||
local top = game.stack and game.stack:top()
|
||||
local box = top and top.titleUiBox
|
||||
if box then
|
||||
z[#z + 1] = P.trueColorZone(box[1], box[2], box[3], box[4])
|
||||
end
|
||||
return z[3] and z or nil
|
||||
end
|
||||
|
||||
@@ -30,6 +53,13 @@ local CYCLE_SPECIES = {
|
||||
"PIKACHU", "CLEFAIRY", "RHYDON", "ABRA", "GASTLY", "DITTO",
|
||||
"PIDGEOTTO", "ONIX", "PONYTA", "MAGIKARP",
|
||||
}
|
||||
-- Blue's TitleMons (data/pokemon/title_mons.asm, _BLUE branch): STARTER2 is
|
||||
-- Squirtle, STARTER1 Charmander, STARTER3 Bulbasaur.
|
||||
local BLUE_CYCLE_SPECIES = {
|
||||
"SQUIRTLE", "CHARMANDER", "BULBASAUR", "MANKEY", "HITMONLEE",
|
||||
"VULPIX", "CHANSEY", "AERODACTYL", "JOLTEON", "SNORLAX",
|
||||
"GLOOM", "POLIWAG", "DODUO", "PORYGON", "GENGAR", "RAICHU",
|
||||
}
|
||||
local CYCLE_FRAMES = 240 -- the original waits ~4s between picks
|
||||
|
||||
local function tryImage(path)
|
||||
@@ -61,9 +91,13 @@ function TitleState.new(game, opts)
|
||||
self.version = tryImage(imagePath(title.versionRibbon or title.version)
|
||||
or "assets/generated/title/red_version.png")
|
||||
self.player = tryImage("assets/generated/title/player.png")
|
||||
self.blue = GameVersion.isBlue()
|
||||
-- Blue cycles its own title mons and prints its ribbon contiguously; a
|
||||
-- field.title.cycleSpecies override (mods / total conversions) still wins.
|
||||
local defaultCycle = self.blue and BLUE_CYCLE_SPECIES or CYCLE_SPECIES
|
||||
self.cycleSpecies = (type(title.cycleSpecies) == "table"
|
||||
and #title.cycleSpecies > 0)
|
||||
and title.cycleSpecies or CYCLE_SPECIES
|
||||
and title.cycleSpecies or defaultCycle
|
||||
self.sprites = {} -- species -> image or false (load failed)
|
||||
self.cycleIndex = 1
|
||||
self.timer = 0
|
||||
@@ -92,8 +126,10 @@ end
|
||||
|
||||
local function hasSave()
|
||||
local ok, info = pcall(function()
|
||||
-- the active game's save file (save.lua for Red, save_blue.lua for Blue)
|
||||
local name = require("src.core.SaveData").saveFilename(GameVersion.get())
|
||||
return love.filesystem and love.filesystem.getInfo
|
||||
and love.filesystem.getInfo("save.lua") or nil
|
||||
and love.filesystem.getInfo(name) or nil
|
||||
end)
|
||||
return ok and info ~= nil
|
||||
end
|
||||
@@ -105,8 +141,11 @@ local ContinueInfo = {}
|
||||
ContinueInfo.__index = ContinueInfo
|
||||
|
||||
function ContinueInfo.new(title, save)
|
||||
return setmetatable({ title = title, game = title.game, save = save },
|
||||
ContinueInfo)
|
||||
-- box at (4,7), 16x10 tiles -- see ContinueInfo:draw / DisplayContinueGameInfo
|
||||
return setmetatable({
|
||||
title = title, game = title.game, save = save,
|
||||
titleUiBox = { 4, 7, 19, 16 },
|
||||
}, ContinueInfo)
|
||||
end
|
||||
|
||||
function ContinueInfo:update(dt)
|
||||
@@ -170,8 +209,11 @@ function TitleState:openMenu()
|
||||
love.event.quit()
|
||||
end
|
||||
end })
|
||||
game.stack:push(Menu.new(game, items,
|
||||
{ tx = 0, ty = 0, tw = 13, th = #items * 2 + 2 }))
|
||||
local th = #items * 2 + 2
|
||||
local menu = Menu.new(game, items, { tx = 0, ty = 0, tw = 13, th = th })
|
||||
-- full-width title LOGO zones would recolor this box; see sgbPalettes
|
||||
menu.titleUiBox = { 0, 0, 12, th - 1 }
|
||||
game.stack:push(menu)
|
||||
end
|
||||
|
||||
function TitleState:update(dt)
|
||||
@@ -211,17 +253,26 @@ function TitleState:draw()
|
||||
love.graphics.draw(self.logo, 16, 8)
|
||||
else
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw("POKéMON RED", (160 - 11 * 8) / 2, 24)
|
||||
Font.draw(self.blue and "POKéMON BLUE" or "POKéMON RED",
|
||||
(160 - 12 * 8) / 2, 24)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
if self.version then
|
||||
-- the strip holds Red+Green+Version glyphs; the tilemap prints
|
||||
-- tiles $60,$61 ("Red"), a space, then $65-$69 ("Version")
|
||||
local iw, ih = self.version:getDimensions()
|
||||
love.graphics.draw(self.version,
|
||||
love.graphics.newQuad(0, 0, 16, 8, iw, ih), 56, 64)
|
||||
love.graphics.draw(self.version,
|
||||
love.graphics.newQuad(40, 0, 40, 8, iw, ih), 80, 64)
|
||||
if self.blue then
|
||||
-- Blue prints its ribbon contiguously ("Blue Version", hlcoord 7,8).
|
||||
-- The extracted strip packs those eight glyph tiles into image tiles
|
||||
-- 0..7 (tiles 8..9 are blank), so draw that 64px run at px (56, 64).
|
||||
love.graphics.draw(self.version,
|
||||
love.graphics.newQuad(0, 0, 64, 8, iw, ih), 56, 64)
|
||||
else
|
||||
-- Red's strip holds Red+Green+Version glyphs; the tilemap prints
|
||||
-- tiles $60,$61 ("Red"), a space, then $65-$69 ("Version").
|
||||
love.graphics.draw(self.version,
|
||||
love.graphics.newQuad(0, 0, 16, 8, iw, ih), 56, 64)
|
||||
love.graphics.draw(self.version,
|
||||
love.graphics.newQuad(40, 0, 40, 8, iw, ih), 80, 64)
|
||||
end
|
||||
end
|
||||
local sprite = self:currentSprite()
|
||||
if sprite then
|
||||
|
||||
@@ -120,8 +120,11 @@ FieldDefaults.FIELD = {
|
||||
doorBlock = { bx = 2, by = 2, block = 5 } },
|
||||
},
|
||||
-- IsSurfingAllowed refuses SURF on the B4F stairs square until both
|
||||
-- plug boulders are down (engine/overworld/field_move_messages.asm)
|
||||
-- plug boulders are down (engine/overworld/field_move_messages.asm).
|
||||
-- B3F currents set BIT_FORCED_WARP so the south-edge water warps fire
|
||||
-- without a held d-pad (scripts/SeafoamIslandsB3F.asm).
|
||||
seafoam = {
|
||||
SEAFOAM_ISLANDS_B3F = { setsForcedWarp = true },
|
||||
SEAFOAM_ISLANDS_B4F = {
|
||||
surfBlocked = { { x = 7, y = 11, untilEvents = {
|
||||
"EVENT_SEAFOAM4_BOULDER1_DOWN_HOLE",
|
||||
|
||||
+47
-26
@@ -38,6 +38,51 @@ local function hashSet(list, into)
|
||||
return into
|
||||
end
|
||||
|
||||
-- Collision tile (bottom-left 8x8) of a cell on an UNLOADED map def --
|
||||
-- the connected neighbor during an edge crossing. pokered's
|
||||
-- GetTileAndCoordsInFrontOfPlayer / collision checks read the neighbor
|
||||
-- strip's tile bytes the same way.
|
||||
function Map.defCellTile(def, tilesetDef, cx, cy)
|
||||
if not (def and tilesetDef and tilesetDef.blocks) then return nil end
|
||||
local tx, ty = cx * 2, cy * 2 + 1
|
||||
local bx, by = math.floor(tx / 4), math.floor(ty / 4)
|
||||
local id
|
||||
if bx < 0 or by < 0 or bx >= def.width or by >= def.height then
|
||||
id = def.borderBlock
|
||||
else
|
||||
id = def.blocks[by * def.width + bx + 1]
|
||||
end
|
||||
local block = tilesetDef.blocks[(id or 0) + 1]
|
||||
if not block then return nil end
|
||||
return block[(ty % 4) * 4 + (tx % 4) + 1]
|
||||
end
|
||||
|
||||
local function defWaterTileSet(def, tilesetDef)
|
||||
local water = {}
|
||||
hashSet(tilesetDef.waterTiles or WATER_TILES, water)
|
||||
local shore = tilesetDef.shoreTiles
|
||||
if shore == nil and not NO_SHORE_TILESETS[def.tileset] then shore = SHORE_TILES end
|
||||
hashSet(shore or {}, water)
|
||||
return water
|
||||
end
|
||||
|
||||
-- Water/shore on an unloaded map def (same tile ids as Map:isWaterCell).
|
||||
function Map.defIsWaterCell(def, tilesetDef, cx, cy)
|
||||
local tile = Map.defCellTile(def, tilesetDef, cx, cy)
|
||||
if tile == nil then return false end
|
||||
return defWaterTileSet(def, tilesetDef)[tile] or false
|
||||
end
|
||||
|
||||
function Map.defIsWalkableCell(def, tilesetDef, cx, cy)
|
||||
if not (tilesetDef and tilesetDef.walkable) then return false end
|
||||
local tile = Map.defCellTile(def, tilesetDef, cx, cy)
|
||||
if tile == nil then return false end
|
||||
for _, t in ipairs(tilesetDef.walkable) do
|
||||
if t == tile then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- Passability of a cell of an UNLOADED map def -- the connected neighbor
|
||||
-- during an edge crossing. pokered's collision check reads the neighbor
|
||||
-- strip's tile bytes, so a step off the map edge onto a solid tile of
|
||||
@@ -52,32 +97,8 @@ function Map.defPassable(def, tilesetDef, cx, cy, surfing)
|
||||
if not (def and tilesetDef and tilesetDef.blocks and tilesetDef.walkable) then
|
||||
return false
|
||||
end
|
||||
local tx, ty = cx * 2, cy * 2 + 1
|
||||
local bx, by = math.floor(tx / 4), math.floor(ty / 4)
|
||||
local id
|
||||
if bx < 0 or by < 0 or bx >= def.width or by >= def.height then
|
||||
id = def.borderBlock
|
||||
else
|
||||
id = def.blocks[by * def.width + bx + 1]
|
||||
end
|
||||
local block = tilesetDef.blocks[(id or 0) + 1]
|
||||
if not block then return false end
|
||||
local tile = block[(ty % 4) * 4 + (tx % 4) + 1]
|
||||
for _, t in ipairs(tilesetDef.walkable) do
|
||||
if t == tile then return true end
|
||||
end
|
||||
if surfing then
|
||||
for _, t in ipairs(tilesetDef.waterTiles or WATER_TILES) do
|
||||
if t == tile then return true end
|
||||
end
|
||||
local shore = tilesetDef.shoreTiles
|
||||
if shore == nil and not NO_SHORE_TILESETS[def.tileset] then
|
||||
shore = SHORE_TILES
|
||||
end
|
||||
for _, t in ipairs(shore or {}) do
|
||||
if t == tile then return true end
|
||||
end
|
||||
end
|
||||
if Map.defIsWalkableCell(def, tilesetDef, cx, cy) then return true end
|
||||
if surfing and Map.defIsWaterCell(def, tilesetDef, cx, cy) then return true end
|
||||
return false
|
||||
end
|
||||
|
||||
|
||||
+72
-5
@@ -2,6 +2,13 @@
|
||||
-- data, cached by map id. The cache is keyed so a mod that patches one
|
||||
-- map record after boot (or the dev-mode hot reload) can drop just that
|
||||
-- entry instead of every map's SpriteBatches.
|
||||
--
|
||||
-- The resident set is trimmed LRU (trim) so exploring a large world never
|
||||
-- holds every visited map's GPU objects at once. Maps are built on demand
|
||||
-- and cheaply: a map's tile layer draws windowed to the camera (see
|
||||
-- TileRenderer), so there is no per-map batch to construct up front and thus
|
||||
-- nothing to stream -- OverworldState:rebuildNeighbors just loads each
|
||||
-- neighbor directly.
|
||||
|
||||
local Assets = require("src.render.Assets")
|
||||
local Map = require("src.world.Map")
|
||||
@@ -10,9 +17,20 @@ local TileRenderer = require("src.render.TileRenderer")
|
||||
local MapLoader = {}
|
||||
|
||||
local cache = {}
|
||||
-- mapId -> monotonic access stamp, for LRU eviction
|
||||
local lru = {}
|
||||
local accessSeq = 0
|
||||
-- max map renderers kept resident. Comfortably above the largest
|
||||
-- current+neighbor set (~15 in dense overworld), so protected maps are
|
||||
-- never the ones evicted; this only caps the lingering trail behind you.
|
||||
local RESIDENT_CAP = 32
|
||||
|
||||
function MapLoader.load(data, mapId)
|
||||
if cache[mapId] then return cache[mapId] end
|
||||
local function touch(mapId)
|
||||
accessSeq = accessSeq + 1
|
||||
lru[mapId] = accessSeq
|
||||
end
|
||||
|
||||
local function build(data, mapId)
|
||||
local def = data.maps[mapId]
|
||||
assert(def, "unknown map: " .. tostring(mapId) ..
|
||||
" (not in the maps registry)")
|
||||
@@ -25,26 +43,75 @@ function MapLoader.load(data, mapId)
|
||||
local map = Map.new(def, tilesetDef)
|
||||
map.renderer = TileRenderer.new(map, data)
|
||||
cache[mapId] = map
|
||||
touch(mapId)
|
||||
return map
|
||||
end
|
||||
|
||||
function MapLoader.load(data, mapId)
|
||||
local m = cache[mapId]
|
||||
if m then touch(mapId); return m end
|
||||
return build(data, mapId)
|
||||
end
|
||||
|
||||
-- the live instance for a map id, or nil when it has not been loaded;
|
||||
-- callers that must not build a map (invalidation, tests) use this
|
||||
function MapLoader.cached(mapId)
|
||||
return cache[mapId]
|
||||
local m = cache[mapId]
|
||||
if m then touch(mapId) end
|
||||
return m
|
||||
end
|
||||
|
||||
-- evict one resident map, releasing its renderer's GPU objects. Callers
|
||||
-- must ensure the map is not the current map and not drawn as a connected
|
||||
-- strip (nothing live may hold its renderer) -- MapLoader.trim guarantees
|
||||
-- this via its `protected` set.
|
||||
function MapLoader.evict(mapId)
|
||||
local m = cache[mapId]
|
||||
if not m then return false end
|
||||
cache[mapId] = nil
|
||||
lru[mapId] = nil
|
||||
local r = m.renderer
|
||||
if r and r.release then pcall(r.release, r) end
|
||||
return true
|
||||
end
|
||||
|
||||
-- keep the resident renderer set bounded. `protected` (mapId -> true) is
|
||||
-- never evicted (the current map and everything drawn as a connected
|
||||
-- strip); the rest is trimmed least-recently-used down to RESIDENT_CAP.
|
||||
function MapLoader.trim(protected)
|
||||
local n = 0
|
||||
for _ in pairs(cache) do n = n + 1 end
|
||||
if n <= RESIDENT_CAP then return end
|
||||
local ids = {}
|
||||
for id in pairs(cache) do
|
||||
if not (protected and protected[id]) then ids[#ids + 1] = id end
|
||||
end
|
||||
table.sort(ids, function(a, b) return (lru[a] or 0) < (lru[b] or 0) end)
|
||||
local over = n - RESIDENT_CAP
|
||||
for _, id in ipairs(ids) do
|
||||
if over <= 0 then break end
|
||||
MapLoader.evict(id)
|
||||
over = over - 1
|
||||
end
|
||||
end
|
||||
|
||||
-- drop one map so the next load re-reads its record and rebuilds its
|
||||
-- renderer. Callers holding the old instance keep it -- OverworldState
|
||||
-- re-points self.map itself (WorldAPI:invalidateMap).
|
||||
-- renderer. Deliberately does NOT release the old renderer: callers
|
||||
-- holding the old instance keep drawing it until they re-point themselves
|
||||
-- (OverworldState re-points self.map / self.neighbors via setMap /
|
||||
-- rebuildNeighbors), so releasing here would free a batch still in use.
|
||||
-- The orphaned instance is reclaimed by GC; MapLoader.evict is the path
|
||||
-- that releases eagerly, and it only runs on maps nothing live holds.
|
||||
function MapLoader.invalidate(mapId)
|
||||
local had = cache[mapId] ~= nil
|
||||
cache[mapId] = nil
|
||||
lru[mapId] = nil
|
||||
return had
|
||||
end
|
||||
|
||||
function MapLoader.invalidateAll()
|
||||
cache = {}
|
||||
lru = {}
|
||||
end
|
||||
|
||||
-- kept as the pre-v2 name
|
||||
|
||||
@@ -303,6 +303,9 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
|
||||
else
|
||||
self.player = Player.new(Game.data, x, y, facing)
|
||||
end
|
||||
-- crossConnection re-arms this after setMap; clear so a warp/reload
|
||||
-- cannot leave a stale deferred PlayMapMusic pending
|
||||
self.pendingSeamMusic = nil
|
||||
self.entities = { self.player }
|
||||
for _, n in ipairs(self.npcs) do table.insert(self.entities, n) end
|
||||
|
||||
@@ -322,6 +325,12 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
|
||||
-- 16/18 gate exits), and the scripted door-mat walkout that follows
|
||||
-- suppresses onStepComplete, so waiting for a plain step never mounts
|
||||
self:checkForcedMovement()
|
||||
-- Seafoam B4F's map script pushes off the B3F stair warps every frame
|
||||
-- while the upper plugs are out (SeafoamIslandsB4FDefaultScript); the
|
||||
-- B3F/B4F force-surf mouths also arm their MOVE_OBJECT current scripts
|
||||
-- from CheckForceBikeOrSurf. Re-check here so a warp-in does not sit
|
||||
-- idle on those cells waiting for a player step.
|
||||
self:checkSeafoamCurrent()
|
||||
|
||||
-- snap the camera immediately: the overworld doesn't update while a
|
||||
-- Transition is on top, so a stale camera would show the new map at
|
||||
@@ -338,10 +347,13 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
|
||||
or (fromMapId and "warp" or "boot"),
|
||||
})
|
||||
|
||||
-- map-enter hooks (hand-ported map scripts, e.g. Victory Road barriers)
|
||||
-- map-enter hooks (hand-ported map scripts, e.g. Victory Road barriers).
|
||||
-- fromMapId lets elevators seed a valid walk-out floor when the ROM
|
||||
-- car warps still point at a missing map (Silph's UNUSED_MAP_ED) and
|
||||
-- the player B-cancels the floor menu without .UpdateWarp.
|
||||
local hooks = mapScripts.get(mapId)
|
||||
if hooks and hooks.onEnter then
|
||||
hooks.onEnter(Game, self)
|
||||
hooks.onEnter(Game, self, fromMapId)
|
||||
end
|
||||
|
||||
self:rebuildNeighbors()
|
||||
@@ -357,20 +369,32 @@ end
|
||||
-- widened to everything the current view size can show so a full
|
||||
-- zoom-out never runs past the rendered set. Re-run whenever the view
|
||||
-- grows (zoom/resize), not only on setMap.
|
||||
--
|
||||
-- Neighbors are built eagerly here. A TileRenderer is now a light object --
|
||||
-- the tile layer draws windowed to the camera, so nothing per-map is
|
||||
-- constructed up front (see TileRenderer) -- so there is no build cost to
|
||||
-- amortize and no prefetch race to lose at a seam. That is what the old
|
||||
-- one-per-frame streaming queue existed to hide, and it is gone.
|
||||
function OverworldState:rebuildNeighbors()
|
||||
local mapId = self.map.id
|
||||
self.neighbors = {}
|
||||
local hops = FieldDefaults.world(Game.data, "neighborHops") or NEIGHBOR_HOPS
|
||||
local vw, vh = Game.renderer:worldViewSize()
|
||||
self.neighborViewW, self.neighborViewH = vw, vh
|
||||
-- resident set the eviction pass must never touch: the current map plus
|
||||
-- every drawn neighbor
|
||||
local keep = { [mapId] = true }
|
||||
for _, n in ipairs(OverworldState.computeNeighbors(Game.data.maps, mapId,
|
||||
hops,
|
||||
math.floor(vw / 2) + 64,
|
||||
math.floor(vh / 2) + 64)) do
|
||||
table.insert(self.neighbors,
|
||||
{ map = MapLoader.load(Game.data, n.id),
|
||||
ox = n.ox, oy = n.oy })
|
||||
keep[n.id] = true
|
||||
local m = MapLoader.load(Game.data, n.id)
|
||||
table.insert(self.neighbors, { map = m, ox = n.ox, oy = n.oy })
|
||||
end
|
||||
-- bound resident memory: drop maps behind us that are neither current nor
|
||||
-- a drawn neighbor, releasing their window batch / border image / atlas
|
||||
MapLoader.trim(keep)
|
||||
|
||||
-- visual-only NPCs on connected maps (survey zoom): same spawn filter
|
||||
-- as a real map entry, but they never join self.entities -- no sight
|
||||
@@ -434,7 +458,9 @@ function OverworldState:paletteNameFor(map)
|
||||
return Runtime.call("map.palette", samePalette, name, map)
|
||||
end
|
||||
|
||||
-- UI-pass palette (text boxes and menus tint with the current map)
|
||||
-- UI-pass palette (text boxes and menus tint with the current map). OG RED
|
||||
-- resolves every name to the one global red BG palette inside PaletteFX.pal,
|
||||
-- so this needs no mode-specific branch.
|
||||
function OverworldState:sgbPalettes()
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
return PaletteFX.wholeNamed(Game.data, self:paletteNameFor(self.map))
|
||||
@@ -785,6 +811,15 @@ function OverworldState:update(dt)
|
||||
if entry and (self.player.cellX ~= entry.x or self.player.cellY ~= entry.y) then
|
||||
self.warpEntryCell = nil
|
||||
end
|
||||
-- deferred PlayMapMusic from crossConnection (issue #93)
|
||||
if stepped and self.pendingSeamMusic then
|
||||
local mapId = self.pendingSeamMusic
|
||||
self.pendingSeamMusic = nil
|
||||
if mapId == self.map.id then
|
||||
require("src.core.Music").playMap(Game.data, mapId, Game.save.onBike,
|
||||
self.player.surfing)
|
||||
end
|
||||
end
|
||||
if stepped and not scripted then
|
||||
self:onStepComplete()
|
||||
end
|
||||
@@ -986,20 +1021,19 @@ function OverworldState:checkEdgeExit(dir)
|
||||
return false
|
||||
end
|
||||
|
||||
-- Map connections: the connected map's strip offset is in blocks; arriving
|
||||
-- coordinates follow destX = curX - offset*2 (see docs/extraction-notes.md).
|
||||
-- The crossing scrolls continuously: the map data swaps while the player
|
||||
-- is placed one cell before the entry point (their old world position,
|
||||
-- which the neighbor strips render identically) and walks the seam step.
|
||||
function OverworldState:crossConnection(dir, conn)
|
||||
-- Landing cell on the connected map for a step off this map's edge in
|
||||
-- `dir` (same math as crossConnection). Returns destDef, tilesetDef, x, y
|
||||
-- or nil when there is no usable connection.
|
||||
function OverworldState:connectionLanding(dir)
|
||||
local conn = self.map:connection(COMPASS[dir])
|
||||
if not conn then return nil end
|
||||
local dest = Game.data.maps[conn.map]
|
||||
if not dest then
|
||||
Logger.warn("connection to unknown map %s", tostring(conn.map))
|
||||
return false
|
||||
end
|
||||
if not dest then return nil end
|
||||
local ts = Game.data.tilesets[dest.tileset]
|
||||
if not ts then return nil end
|
||||
local p = self.player
|
||||
local x, y = p.cellX, p.cellY
|
||||
local destW, destH = dest.width * 2, dest.height * 2
|
||||
local x, y
|
||||
if dir == "up" then
|
||||
x, y = p.cellX - conn.offset * 2, destH - 1
|
||||
elseif dir == "down" then
|
||||
@@ -1011,16 +1045,38 @@ function OverworldState:crossConnection(dir, conn)
|
||||
end
|
||||
x = math.max(0, math.min(destW - 1, x))
|
||||
y = math.max(0, math.min(destH - 1, y))
|
||||
return dest, ts, x, y, conn
|
||||
end
|
||||
|
||||
-- Map connections: the connected map's strip offset is in blocks; arriving
|
||||
-- coordinates follow destX = curX - offset*2 (see docs/extraction-notes.md).
|
||||
-- The crossing scrolls continuously: the map data swaps while the player
|
||||
-- is placed one cell before the entry point (their old world position,
|
||||
-- which the neighbor strips render identically) and walks the seam step.
|
||||
function OverworldState:crossConnection(dir, conn)
|
||||
local dest, ts, x, y = self:connectionLanding(dir)
|
||||
if not dest then
|
||||
Logger.warn("connection to unknown map %s", tostring(conn and conn.map))
|
||||
return false
|
||||
end
|
||||
local p = self.player
|
||||
-- pokered's collision check reads the NEIGHBOR strip's tile bytes, so
|
||||
-- stepping off the edge onto a solid tile of the connected map bumps
|
||||
-- exactly like an in-map wall. Without this read, Pallet's south
|
||||
-- shore (land at x2-3) walked straight onto ROUTE_21 (3,0) -- a
|
||||
-- collision tile -- stranding the player on a cell no walk can leave.
|
||||
if not Map.defPassable(dest, Game.data.tilesets[dest.tileset], x, y,
|
||||
p.surfing) then
|
||||
if not Map.defPassable(dest, ts, x, y, p.surfing) then
|
||||
return false
|
||||
end
|
||||
self:setMap(conn.map, x, y, p.facing, { seamless = true })
|
||||
-- keepMusic: defer PlayMapMusic until the seam step lands. Starting a
|
||||
-- new chip song inside setMap used to hitch the render thread (~200ms)
|
||||
-- so FixedStep catch-up ate the walk frames (issue #93). Threaded synth
|
||||
-- removed most of that hitch; discarding catch-up + deferring the song
|
||||
-- still protects the visible step when neighbor rebuild or the sync
|
||||
-- fallback stalls, and avoids the rare one-frame volume spike from a
|
||||
-- song swap mid-step.
|
||||
self:setMap(conn.map, x, y, p.facing, { seamless = true, keepMusic = true })
|
||||
self.pendingSeamMusic = conn.map
|
||||
-- place the player one cell before the seam (their old world spot,
|
||||
-- which the neighbor strip renders identically) and start the step
|
||||
-- into the new map RIGHT NOW so there is no one-frame stall at the
|
||||
@@ -1034,18 +1090,71 @@ function OverworldState:crossConnection(dir, conn)
|
||||
p.targetX, p.targetY = x, y
|
||||
p.moving = true
|
||||
p.progress = 0
|
||||
-- fresh walk-cycle clock so the seam step always shows leg frames
|
||||
-- (mid-cycle stand phase would otherwise look like a slide)
|
||||
p.animClock = 0
|
||||
p.stepFramesCur = Game.save.onBike
|
||||
and (FieldDefaults.world(Game.data, "bikeStepFrames") or 8)
|
||||
or (FieldDefaults.world(Game.data, "stepFrames") or 16)
|
||||
require("src.core.FixedStep"):discardCatchup()
|
||||
return true
|
||||
end
|
||||
|
||||
-- ItemUseSurfboard's simulated pad press: step onto the facing cell, or
|
||||
-- cross a map connection when that cell is off this map's edge (Cinnabar
|
||||
-- east coast -> Route 20 water, and the reverse dismount ashore).
|
||||
function OverworldState:stepForwardOrCrossEdge(dir)
|
||||
dir = dir or self.player.facing
|
||||
local fx, fy = Collision.target(self.player.cellX, self.player.cellY, dir)
|
||||
if not self.map:inBounds(fx, fy) then
|
||||
return self:checkEdgeExit(dir)
|
||||
end
|
||||
self:scriptMove(self.player, dir, 1)
|
||||
return true
|
||||
end
|
||||
|
||||
-- IsNextTileShoreOrWater across a connection strip: pokered loads the
|
||||
-- neighbor's tiles into the border, so wTileInFrontOfPlayer is the
|
||||
-- connected map's tile even when the facing cell is off this map.
|
||||
-- Shore/water classification still uses THIS map's tileset rules
|
||||
-- (SHIP_PORT's $32 dock exception), matching the asm.
|
||||
function OverworldState:facingIsShoreOrWater()
|
||||
if not self:tilesetHasWater() then return false end
|
||||
local fx, fy = self.player:facingCell()
|
||||
if self.map:inBounds(fx, fy) then
|
||||
return self.map:isWaterCell(fx, fy)
|
||||
end
|
||||
local dest, ts, x, y = self:connectionLanding(self.player.facing)
|
||||
if not dest then return false end
|
||||
local tile = Map.defCellTile(dest, ts, x, y)
|
||||
if tile == nil then return false end
|
||||
return self.map.waterTiles[tile] or false
|
||||
end
|
||||
|
||||
-- tryToStopSurfing land check, including a land landing across a map
|
||||
-- connection (surf off Cinnabar's east coast water back onto the coast).
|
||||
function OverworldState:facingIsLandDismount()
|
||||
local p = self.player
|
||||
local fx, fy = p:facingCell()
|
||||
if self.map:inBounds(fx, fy) then
|
||||
return self.map:isWalkableCell(fx, fy)
|
||||
and Collision.canMove(self.map, self.entities, p, p.facing)
|
||||
end
|
||||
local dest, ts, x, y = self:connectionLanding(p.facing)
|
||||
if not dest then return false end
|
||||
if not Map.defIsWalkableCell(dest, ts, x, y) then return false end
|
||||
-- IsSpriteInFrontOfPlayer2: no current-map sprite can sit past the edge
|
||||
return not Collision.occupied(self.entities, fx, fy, p)
|
||||
end
|
||||
|
||||
-- -------------------------------------------------------------------------
|
||||
-- interactions
|
||||
-- -------------------------------------------------------------------------
|
||||
|
||||
-- HM field moves are gated by badges like the original
|
||||
-- (constants.hmBadges; distinct from constants.hmMoves, the forget gate)
|
||||
-- (constants.hmBadges; distinct from constants.hmMoves, the forget gate).
|
||||
-- Gen 1 allows field use from fainted party members (party menu + name
|
||||
-- lookup for Cut/Surf messages); do not require mon.hp > 0 here.
|
||||
function OverworldState:partyKnows(moveId)
|
||||
local gate = (FieldDefaults.constant(Game.data, "hmBadges") or {})[moveId]
|
||||
local badge = gate and gate.badge
|
||||
@@ -1053,10 +1162,8 @@ function OverworldState:partyKnows(moveId)
|
||||
return nil
|
||||
end
|
||||
for _, mon in ipairs(Game.save.party) do
|
||||
if mon.hp > 0 then
|
||||
for _, mv in ipairs(mon.moves) do
|
||||
if mv.id == moveId then return mon end
|
||||
end
|
||||
for _, mv in ipairs(mon.moves) do
|
||||
if mv.id == moveId then return mon end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
@@ -1557,16 +1664,21 @@ function OverworldState:trashCanSwitch(canIndex)
|
||||
end
|
||||
|
||||
-- Bill's House PC (engine/events/hidden_events/bills_house_pc.asm
|
||||
-- BillsHousePC): once Bill-the-Pokémon has climbed into the machine
|
||||
-- (EVENT_BILL_SAID_USE_CELL_SEPARATOR), running the PC plays the cell
|
||||
-- separator's SFX sequence, sets EVENT_USED_CELL_SEPARATOR_ON_BILL and
|
||||
-- Bill steps back out of the machine human again
|
||||
-- (BillsHouseBillExitsMachineScript / CleanupScript set EVENT_MET_BILL).
|
||||
-- BillsHousePC). Check order matches pokered:
|
||||
-- 1) EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING -> Eevee collection list
|
||||
-- 2) EVENT_USED_CELL_SEPARATOR_ON_BILL -> teleporter monitor text
|
||||
-- 3) EVENT_BILL_SAID_USE_CELL_SEPARATOR -> cell-separator cutscene
|
||||
-- 4) else -> teleporter monitor text
|
||||
-- Leaving after the SS Ticket (Route25ToggleBillsScript) arms (1).
|
||||
function OverworldState:billsHousePC()
|
||||
local t = Game.data.text
|
||||
local flags = Game.save.flags
|
||||
if not (flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR
|
||||
and not flags.EVENT_USED_CELL_SEPARATOR_ON_BILL) then
|
||||
if flags.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING then
|
||||
self:billsHousePokemonList()
|
||||
return
|
||||
end
|
||||
if flags.EVENT_USED_CELL_SEPARATOR_ON_BILL
|
||||
or not flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR then
|
||||
Game.stack:push(TextBox.new(Game, t._BillsHouseMonitorText
|
||||
or "TELEPORTER is\ndisplayed on the\nPC monitor."))
|
||||
return
|
||||
@@ -1590,9 +1702,40 @@ function OverworldState:billsHousePC()
|
||||
end))
|
||||
end
|
||||
|
||||
-- BillsHousePokemonList: EEVEE / FLAREON / JOLTEON / VAPOREON + CANCEL;
|
||||
-- picking one runs DisplayPokedex (DexEntryMenu) and returns to the list.
|
||||
function OverworldState:billsHousePokemonList()
|
||||
local t = Game.data.text
|
||||
local Menu = require("src.ui.Menu")
|
||||
local function openList()
|
||||
local species = { "EEVEE", "FLAREON", "JOLTEON", "VAPOREON" }
|
||||
local items = {}
|
||||
for _, id in ipairs(species) do
|
||||
local def = Game.data.pokemon[id]
|
||||
table.insert(items, {
|
||||
label = (def and def.name) or id,
|
||||
keepOpen = true,
|
||||
onSelect = function()
|
||||
local dex = Game.save.pokedex
|
||||
if dex then dex.seen[id] = true end
|
||||
Screens.push(Game, "DexEntryMenu", id)
|
||||
end,
|
||||
})
|
||||
end
|
||||
table.insert(items, { label = "CANCEL" })
|
||||
-- TextBoxBorder b=10,c=9 at (0,0) -> total tw=11, th=12
|
||||
Game.stack:push(Menu.new(Game, items,
|
||||
{ tx = 0, ty = 0, tw = 11, th = 12 }))
|
||||
end
|
||||
Game.stack:push(TextBox.new(Game, t._BillsHousePokemonListText1
|
||||
or "BILL's favorite\nPOKéMON list!", openList))
|
||||
end
|
||||
|
||||
-- BillsHouseBillExitsMachineScript: human Bill appears inside the machine
|
||||
-- at (1,2) and walks out to his spot at (4,4); the map music resumes and
|
||||
-- EVENT_MET_BILL / EVENT_MET_BILL_2 arm the SS-Ticket dialogue.
|
||||
-- EVENT_MET_BILL / EVENT_MET_BILL_2 arm the SS-Ticket dialogue. The Eevee
|
||||
-- PC list arms later, on the first Route 25 load after the ticket
|
||||
-- (EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING).
|
||||
function OverworldState:billsHouseBillExits()
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { game = Game, save = Game.save, overworld = self }
|
||||
@@ -1684,14 +1827,15 @@ function OverworldState:trySurf(fx, fy)
|
||||
Game.stack:push(TextBox.new(Game, text, function()
|
||||
-- start_sub_menus.asm .surf: UseItem returns (mount + text done),
|
||||
-- then GBPalWhiteOutWithDelay3 blinks before the simulated forward
|
||||
-- press steps onto the water
|
||||
-- press steps onto the water (or across a connection strip, like
|
||||
-- Cinnabar's east coast onto Route 20)
|
||||
local Transition = require("src.render.Transition")
|
||||
if Transition.whiteFlash then
|
||||
Game.stack:push(Transition.whiteFlash(Game, nil, function()
|
||||
self:scriptMove(p, p.facing, 1)
|
||||
self:stepForwardOrCrossEdge(p.facing)
|
||||
end))
|
||||
else
|
||||
self:scriptMove(p, p.facing, 1)
|
||||
self:stepForwardOrCrossEdge(p.facing)
|
||||
end
|
||||
end))
|
||||
end
|
||||
@@ -1773,7 +1917,7 @@ end
|
||||
-- to SurfingAttemptFailed (_NoSurfingHereText) if it isn't water. This is
|
||||
-- a side-effect-free check that reports which text/flow the caller should
|
||||
-- use; the actual mount happens in trySurf on "ok". Returns:
|
||||
-- "no_badge" -> SOULBADGE missing / no live SURF mon (_NewBadgeRequiredText)
|
||||
-- "no_badge" -> SOULBADGE missing / no SURF mon (_NewBadgeRequiredText)
|
||||
-- "forced_bike" -> on the Cycling Road (_CyclingIsFunText)
|
||||
-- "current" -> Seafoam B4F stairs before the boulders (_CurrentTooFastText)
|
||||
-- "dismount" -> already surfing, facing dry land; caller steps forward
|
||||
@@ -1791,20 +1935,20 @@ function OverworldState:useSurfFieldMove()
|
||||
-- until both EVENT_SEAFOAM4_BOULDER*_DOWN_HOLE events are set.
|
||||
if Game.save.forcedBike then return "forced_bike" end
|
||||
if self:surfBlockedHere() then return "current" end
|
||||
local fx, fy = p:facingCell()
|
||||
if p.surfing then
|
||||
-- ItemUseSurfboard .tryToStopSurfing: blocked by a sprite in front
|
||||
-- (IsSpriteInFrontOfPlayer2), a water tile-pair collision, or a
|
||||
-- facing tile that isn't in the tileset's land-passable list;
|
||||
-- otherwise the player walks forward off the water.
|
||||
if self.map:inBounds(fx, fy) and self.map:isWalkableCell(fx, fy)
|
||||
and Collision.canMove(self.map, self.entities, p, p.facing) then
|
||||
-- otherwise the player walks forward off the water. Facing a land
|
||||
-- cell across a map connection (Cinnabar east coast) counts too --
|
||||
-- pokered reads that landing from the connection strip.
|
||||
if self:facingIsLandDismount() then
|
||||
return "dismount"
|
||||
end
|
||||
return "no_place"
|
||||
end
|
||||
if not self.map:inBounds(fx, fy)
|
||||
or not self.map:isWaterCell(fx, fy) or not self:tilesetHasWater() then
|
||||
-- IsNextTileShoreOrWater, including connection-strip water (issue #125)
|
||||
if not self:facingIsShoreOrWater() then
|
||||
return "no_water"
|
||||
end
|
||||
return "ok"
|
||||
@@ -1816,7 +1960,7 @@ end
|
||||
-- (and .loop back to the submenu) if it isn't cuttable. Side-effect-free
|
||||
-- check mirroring useSurfFieldMove; tryCut does the actual cut on "ok".
|
||||
-- Returns:
|
||||
-- "no_badge" -> CASCADEBADGE missing / no live CUT mon (_NewBadgeRequiredText)
|
||||
-- "no_badge" -> CASCADEBADGE missing / no CUT mon (_NewBadgeRequiredText)
|
||||
-- "nothing" -> not facing a cuttable tree (_NothingToCutText)
|
||||
-- "ok" -> facing a cuttable tree; caller may call tryCut(fx, fy)
|
||||
function OverworldState:useCutFieldMove()
|
||||
@@ -2237,6 +2381,8 @@ end
|
||||
-- Badges/items awarded after specific battles (data/scripts/victories.lua).
|
||||
-- `deactivate` retires unfought gym/dojo trainers the way the originals'
|
||||
-- SetEvent / SetEventRange do after the leader victory.
|
||||
-- `hide` is { { mapId, objName }, ... } — HideObject on those toggles
|
||||
-- (e.g. Brock victory clears PEWTERCITY_YOUNGSTER / ROUTE22_RIVAL1).
|
||||
function OverworldState:checkVictoryRewards(trainerClass, partyIndex)
|
||||
local victories = require("data.scripts.victories")
|
||||
local reward = victories[trainerClass .. "#" .. tostring(partyIndex or 1)]
|
||||
@@ -2250,6 +2396,13 @@ function OverworldState:checkVictoryRewards(trainerClass, partyIndex)
|
||||
Game.save.flags[flag] = true
|
||||
end
|
||||
end
|
||||
if reward.hide then
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { game = Game, save = Game.save, overworld = self }
|
||||
for _, entry in ipairs(reward.hide) do
|
||||
Commands.hide_object(ctx, entry[1], entry[2])
|
||||
end
|
||||
end
|
||||
local lines = {}
|
||||
if reward.badge then
|
||||
Game.save.inventory[reward.badge] = 1
|
||||
@@ -2278,6 +2431,27 @@ function OverworldState:runVictoryHook()
|
||||
if hooks and hooks.onVictory then hooks.onVictory(Game, self) end
|
||||
end
|
||||
|
||||
-- pokered player sprite is fixed at screen ($40, $3c). TrainerEngage reads
|
||||
-- the NPC's 8-bit SPRITESTATEDATA1 X/Y pixels and CalcDifference; engage
|
||||
-- distance is stored as range<<4 (pixels). There is no tile LOS check for
|
||||
-- interposed NPCs / walls -- but unsigned 8-bit Y makes a sprite exactly 4
|
||||
-- tiles north of the player sit at Y=$fc, so |$3c-$fc|=$c0 and a range-4
|
||||
-- DOWN trainer does not engage that tile (Route 9 Bug Catcher / issue #76).
|
||||
local PLAYER_SCREEN_X, PLAYER_SCREEN_Y = 0x40, 0x3c
|
||||
local function u8(n) return n % 256 end
|
||||
local function calcDiff(a, b)
|
||||
a, b = u8(a), u8(b)
|
||||
return a >= b and a - b or b - a
|
||||
end
|
||||
local function trainerSightPixelDist(npc, player, horizontal)
|
||||
if horizontal then
|
||||
return calcDiff(PLAYER_SCREEN_X,
|
||||
u8(PLAYER_SCREEN_X + (npc.cellX - player.cellX) * 16))
|
||||
end
|
||||
return calcDiff(PLAYER_SCREEN_Y,
|
||||
u8(PLAYER_SCREEN_Y + (npc.cellY - player.cellY) * 16))
|
||||
end
|
||||
|
||||
-- STAY trainers with a facing spot the player crossing their line of
|
||||
-- sight (range from the extracted trainer headers), walk up and battle.
|
||||
function OverworldState:checkTrainerSight()
|
||||
@@ -2295,22 +2469,23 @@ function OverworldState:checkTrainerSight()
|
||||
local range = header and header.range or 0
|
||||
local vec = DIRVEC[npc.facing]
|
||||
if range > 0 and vec then
|
||||
local dist
|
||||
local dist, horizontal
|
||||
if vec[1] ~= 0 and npc.cellY == p.cellY then
|
||||
dist = (p.cellX - npc.cellX) * vec[1]
|
||||
horizontal = true
|
||||
elseif vec[2] ~= 0 and npc.cellX == p.cellX then
|
||||
dist = (p.cellY - npc.cellY) * vec[2]
|
||||
horizontal = false
|
||||
end
|
||||
-- pokered's TrainerEngage / CheckSpriteCanSeePlayer compares screen
|
||||
-- coordinates only (home/trainers.asm, engine/overworld/
|
||||
-- trainer_sight.asm) -- there is no line-of-sight obstruction check.
|
||||
-- An aligned trainer within range engages through interposed NPCs and
|
||||
-- unwalkable tiles, and the scripted walk-up below (scriptMove) also
|
||||
-- ignores collision, so the trainer simply walks/overlaps through
|
||||
-- anything on the line -- exactly as OAM sprites overlap on hardware.
|
||||
if dist and dist >= 1 and dist <= range then
|
||||
self:startTrainerApproach(npc, dist)
|
||||
return
|
||||
-- Screen-pixel range (CheckSpriteCanSeePlayer), not cell count:
|
||||
-- same facing-line rule as before, but the $fc Y quirk excludes the
|
||||
-- 4-tiles-north tile that cell math would still count as in range.
|
||||
if dist and dist >= 1 then
|
||||
local pixelDist = trainerSightPixelDist(npc, p, horizontal)
|
||||
if pixelDist > 0 and pixelDist <= range * 16 then
|
||||
self:startTrainerApproach(npc, dist)
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -2546,8 +2721,11 @@ function OverworldState:onStepComplete()
|
||||
elseif entry then
|
||||
-- still standing on the warp we arrived through; do not re-trigger it
|
||||
else
|
||||
-- CheckWarpsNoCollision: door/warp tiles fire immediately; otherwise
|
||||
-- ExtraWarpCheck must pass AND either a d-pad is held or BIT_FORCED_WARP
|
||||
-- is set (Seafoam B3F currents — home/overworld.asm).
|
||||
local w = Warp.onArrive(self.map, p.cellX, p.cellY)
|
||||
if not w and self:dirHeld() then
|
||||
if not w and (self:dirHeld() or self.forcedWarp) then
|
||||
w = Warp.onCollision(self.map, Game.data.field.warpCarpets,
|
||||
p.cellX, p.cellY, p.facing)
|
||||
end
|
||||
@@ -2628,9 +2806,11 @@ function OverworldState:runSpinnerMoves(moves, i)
|
||||
local mv = moves[i]
|
||||
if not mv then
|
||||
self.player.spinning = false
|
||||
if not self:checkSpinner() and self.player.surfing then
|
||||
self:checkSeafoamCurrent()
|
||||
end
|
||||
-- Scripted steps skip onStepComplete while they run; once the RLE
|
||||
-- finishes, re-enter the normal landing pipeline so chained spinners,
|
||||
-- Seafoam currents, and CheckWarpsNoCollision (incl. BIT_FORCED_WARP)
|
||||
-- see the tile we stopped on — same as pokered after simulated joypad.
|
||||
self:onStepComplete()
|
||||
return
|
||||
end
|
||||
self.player.spinning = true -- spin the sprite while sliding
|
||||
@@ -2803,6 +2983,9 @@ function OverworldState:checkSeafoamCurrent()
|
||||
if sf.forcedExit and p.surfing and not allSet(sf.forcedExit.activeUntilEvents) then
|
||||
for _, c in ipairs(sf.forcedExit.coords) do
|
||||
if p.cellX == c.x and p.cellY == c.y then
|
||||
-- SeafoamIslandsB4FDefaultScript: res BIT_FORCED_WARP before the
|
||||
-- push so the B3F stair warps underfoot cannot bounce you back.
|
||||
self.forcedWarp = false
|
||||
require("src.core.Sound").play(Game.data, "Collision")
|
||||
self:scriptMove(p, "up", c.y == 17 and 2 or 1)
|
||||
return true
|
||||
@@ -2824,6 +3007,12 @@ function OverworldState:checkSeafoamCurrent()
|
||||
end
|
||||
for _, c in ipairs(active) do
|
||||
if p.cellX == c.x and p.cellY == c.y then
|
||||
-- SeafoamIslandsB3F.asm sets BIT_FORCED_WARP before DecodeRLEList so
|
||||
-- the south-edge water stairs auto-warp when the current ends.
|
||||
if FieldDefaults.fieldValue(Game.data, "seafoam", self.map.id,
|
||||
"setsForcedWarp") then
|
||||
self.forcedWarp = true
|
||||
end
|
||||
self:runSpinnerMoves(c.moves, 1)
|
||||
return true
|
||||
end
|
||||
@@ -3032,16 +3221,20 @@ end
|
||||
-- Warp to the last heal point (blackout, ESCAPE ROPE, DIG/TELEPORT).
|
||||
-- The heal point is usually an interior, so LAST_MAP exits are re-pointed
|
||||
-- at its remembered town door rather than wherever the player left from.
|
||||
function OverworldState:warpToHealPoint(onDone)
|
||||
--
|
||||
-- opts.arrive = "teleport" for Dig/Teleport/Escape Rope (LeaveMapAnim /
|
||||
-- EnterMapAnim). Blackouts omit it: pret HandleBlackOut only
|
||||
-- GBFadeOutToBlack + PrepareForSpecialWarp + SpecialEnterMap, and never
|
||||
-- sets BIT_FLY_WARP / BIT_DUNGEON_WARP, so EnterMap never runs EnterMapAnim.
|
||||
function OverworldState:warpToHealPoint(onDone, opts)
|
||||
local heal = self:healPoint()
|
||||
self.player.surfing = false
|
||||
-- HandleFlyWarpOrDungeonWarp + DisplayPlayerBlackedOutText both clear
|
||||
-- BIT_ALWAYS_ON_BIKE (home/overworld.asm / home/text_script.asm)
|
||||
Game.save.forcedBike = nil
|
||||
-- rematerializing plays the teleport-in poof (EnterMapAnim in
|
||||
-- engine/overworld/player_animations.asm: SFX_TELEPORT_ENTER_1, then
|
||||
-- ENTER_2 after the spin-down); blackouts take this path too
|
||||
self.arriveWarp = "teleport"
|
||||
if opts and opts.arrive == "teleport" then
|
||||
self.arriveWarp = "teleport"
|
||||
end
|
||||
self:startWarpTo(heal.map, heal.x, heal.y, "down", onDone)
|
||||
if heal.outdoor then
|
||||
self:rememberOutdoor(heal.outdoor.id, heal.outdoor.x, heal.outdoor.y)
|
||||
@@ -3079,9 +3272,9 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts)
|
||||
-- one-step justWarped guard, which only skipped the very next frame's
|
||||
-- check and so let a mon walked back onto the pad re-trigger it.
|
||||
self.warpEntryCell = { x = x, y = y }
|
||||
-- Fly/Teleport/Dig/Escape-Rope/blackout landings poof the player
|
||||
-- back in (player_animations.asm EnterMapAnim); ordinary door
|
||||
-- warps never take this branch
|
||||
-- Fly/Teleport/Dig/Escape-Rope landings poof the player back in
|
||||
-- (player_animations.asm EnterMapAnim). Blackouts and ordinary
|
||||
-- door warps never take this branch.
|
||||
if arriveWarp == "fly" then
|
||||
require("src.core.Sound").play(Game.data, "Fly")
|
||||
elseif arriveWarp == "teleport" then
|
||||
@@ -3321,7 +3514,9 @@ function OverworldState:billboard(fx, fy, vw, vh, colors, keyed, drawFn)
|
||||
end
|
||||
|
||||
function OverworldState:drawWorld()
|
||||
-- advance the water/flower tile animation (runs under dialogs too)
|
||||
-- advance the water/flower tile animation (runs under dialogs too).
|
||||
-- TileRenderer.tick uses wall-clock 60Hz steps so display refresh rate
|
||||
-- does not speed or slow the cycle (issue #4).
|
||||
require("src.render.TileRenderer").tick()
|
||||
-- let the renderer know whether a spinner puzzle is currently sliding
|
||||
-- the player, so it can flicker the arrow tiles between the blur and
|
||||
@@ -3346,9 +3541,9 @@ function OverworldState:drawWorld()
|
||||
-- reorder (no draws), so they run once for both paths.
|
||||
local tilt = Tilt.active()
|
||||
self.map.renderer:drawBorderFill(cam.x, bgY, vw, vh)
|
||||
self.map.renderer:draw(cam.x, bgY)
|
||||
self.map.renderer:draw(cam.x, bgY, vw, vh)
|
||||
for _, nb in ipairs(self.neighbors) do
|
||||
nb.map.renderer:drawMapOnly(cam.x - nb.ox, bgY - nb.oy)
|
||||
nb.map.renderer:drawMapOnly(cam.x - nb.ox, bgY - nb.oy, vw, vh)
|
||||
end
|
||||
-- per-billboard SGB palette source; only needed (and only paid for) when
|
||||
-- tilting. nil headless / on stale palettes -> billboards go uncolorized.
|
||||
@@ -3401,8 +3596,11 @@ function OverworldState:drawWorld()
|
||||
love.graphics.setShader(shader)
|
||||
end
|
||||
end
|
||||
local ox = ha.px - 64 - cam.x
|
||||
local oy = ha.py - 64 - cam.y
|
||||
-- TileRenderer windows with -floor(cam), so the overlay must use the
|
||||
-- same snap or a fractional camera (odd fill/tilt view sizes) parks
|
||||
-- the balls a pixel off the machine tiles
|
||||
local ox = ha.px - 64 - math.floor(cam.x)
|
||||
local oy = ha.py - 64 - math.floor(cam.y)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(img, self.healMachineQuads[1], ox + 44, oy + 20)
|
||||
for i = 1, math.min(ha.lit, #HEAL_BALL_XY) do
|
||||
@@ -3492,11 +3690,21 @@ function OverworldState:drawWorld()
|
||||
end)
|
||||
-- EXCLAMATION_BUBBLE is index 0 -> first crop; the emote command
|
||||
-- picks question/happy crops instead
|
||||
local rect = bubble.bubbles and bubble.bubbles[self.emote.bubble or 1]
|
||||
local bi = self.emote.bubble or 1
|
||||
local rect = bubble.bubbles and bubble.bubbles[bi]
|
||||
if ok and img and rect then
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(img, love.graphics.newQuad(rect.x, rect.y,
|
||||
rect.w, rect.h, img:getDimensions()), ex, ey)
|
||||
-- one Quad per bubble crop, cached: this draws every frame the "!"
|
||||
-- (or the emote-command crops) is up, so a fresh Quad here churned
|
||||
-- the GC. The bubble set is small and fixed, so the cache is bounded.
|
||||
self.emoteQuads = self.emoteQuads or {}
|
||||
local q = self.emoteQuads[bi]
|
||||
if not q then
|
||||
q = love.graphics.newQuad(rect.x, rect.y, rect.w, rect.h,
|
||||
img:getDimensions())
|
||||
self.emoteQuads[bi] = q
|
||||
end
|
||||
love.graphics.draw(img, q, ex, ey)
|
||||
drawn = true
|
||||
end
|
||||
end
|
||||
@@ -3608,11 +3816,13 @@ function OverworldState:drawWorld()
|
||||
else
|
||||
-- === TILT PATH: ground-hugging FX stay on the projected ground, all
|
||||
-- standing things billboard upright over it in a separate pass. ======
|
||||
-- Dust is ground-hugging smoke -> ground canvas (puts it
|
||||
-- with the flat layer, so it projects with the ground). Flat mode
|
||||
-- draws it last, over the sprites, in the same canvas; here the two
|
||||
-- Dust / cut / the Poké Center heal overlay hug the BG (the heal
|
||||
-- machine is a tileset graphic; its OAM balls must ride that plane or
|
||||
-- they float off the machine once the ground foreshortens). Flat mode
|
||||
-- draws them last, over the sprites, in the same canvas; here the two
|
||||
-- layers are separate and composited ground-under-upright, so drawing
|
||||
-- it now into the still-active ground canvas is order-equivalent.
|
||||
-- them now into the still-active ground canvas is order-equivalent.
|
||||
fxHeal()
|
||||
fxDust()
|
||||
fxCutTree()
|
||||
|
||||
@@ -3668,18 +3878,11 @@ function OverworldState:drawWorld()
|
||||
end
|
||||
end
|
||||
|
||||
-- Screen-anchored world FX : each billboards at the
|
||||
-- ground foot of the character it belongs to, so it stands upright and
|
||||
-- scales with that character's depth.
|
||||
-- heal machine -> the healed player's foot (the machine stands on
|
||||
-- the ground in front of where the player was)
|
||||
-- Standing world FX: each billboards at the ground foot of the
|
||||
-- character it belongs to, so it stays upright over the tilted ground.
|
||||
-- emote bubble -> the spotting NPC's foot (rides above its head)
|
||||
-- fly bird, rod -> the player's foot
|
||||
if self.healAnim then
|
||||
local fx = self.healAnim.px - cam.x + 8
|
||||
local fy = self.healAnim.py - cam.y + 16
|
||||
self:billboard(fx, fy, vw, vh, zoneColorsAt(zones, fx, fy), false, fxHeal)
|
||||
end
|
||||
-- (heal machine is ground-hugging -- drawn above with dust/cut)
|
||||
if self.emote and self.emote.npc then
|
||||
local fx = self.emote.npc.px - cam.x + 8
|
||||
local fy = self.emote.npc.py - cam.y + 16
|
||||
|
||||
+20
-4
@@ -81,6 +81,14 @@ end
|
||||
|
||||
-- Advance one fixed step; returns true when a step just completed.
|
||||
function Player:update()
|
||||
-- land-frame walk pose lasts only through the draw after completion;
|
||||
-- the next update (idle or a chained step) clears it
|
||||
self.stepLanded = false
|
||||
-- Ledge-hop arc is cosmetic but must track the fixed 60Hz logic step,
|
||||
-- not love.draw's display refresh (issue #4: >59fps ended early).
|
||||
if self.hopFrames and self.hopFrames > 0 then
|
||||
self.hopFrames = self.hopFrames - 1
|
||||
end
|
||||
if self.turnTimer > 0 then
|
||||
self.turnTimer = self.turnTimer - 1
|
||||
end
|
||||
@@ -109,6 +117,11 @@ function Player:update()
|
||||
self.px, self.py = self.cellX * 16, self.cellY * 16
|
||||
self.moving = false
|
||||
self.stepFlip = not self.stepFlip
|
||||
-- keep animClock's pose on this frame (issue #82): bike steps land
|
||||
-- mid-cycle (animClock % 16 == 8), and walkPhase used to snap to
|
||||
-- stand whenever moving cleared — a stand flash every tile on the
|
||||
-- bike, and sometimes after dismount when the clock is desynced
|
||||
self.stepLanded = true
|
||||
return true
|
||||
end
|
||||
return false
|
||||
@@ -119,7 +132,7 @@ function Player:facingCell()
|
||||
end
|
||||
|
||||
function Player:walkPhase()
|
||||
if not self.moving then return 0 end
|
||||
if not self.moving and not self.stepLanded then return 0 end
|
||||
-- walk frame during the middle of each 16-frame animation cycle
|
||||
local p = (self.animClock or self.progress) % 16
|
||||
return (p >= 4 and p < 12) and 1 or 0
|
||||
@@ -129,10 +142,13 @@ local SPIN_ORDER = { "down", "left", "up", "right" }
|
||||
|
||||
function Player:draw(camX, camY)
|
||||
local py = self.py
|
||||
-- ledge hops arc (set for 2 cells by the ledge handler); surfing bobs
|
||||
-- ledge hops arc (set for 2 cells by the ledge handler); surfing bobs.
|
||||
-- hopFrames counts down in Player:update (fixed step), never here.
|
||||
if self.hopFrames and self.hopFrames > 0 then
|
||||
self.hopFrames = self.hopFrames - 1
|
||||
local t = 1 - self.hopFrames / (self.hopTotal or 32)
|
||||
local total = self.hopTotal or 32
|
||||
-- update runs before draw, so remaining N means N steps already
|
||||
-- consumed this hop → t matches the old draw-side post-decrement phase
|
||||
local t = 1 - self.hopFrames / total
|
||||
py = py - math.floor(10 * math.sin(t * math.pi) + 0.5)
|
||||
-- the shadow stays on the ground under the jumper: one 8x8 tile
|
||||
-- mirrored into a 2x2 block (normal/XFLIP/YFLIP/both) whose top-left
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
-- Driver: the Hall of Fame recording-machine "PC". Teleports into the
|
||||
-- HALL_OF_FAME room, faces the console jutting from the north wall, and
|
||||
-- exercises the injected TEXT_HALLOFFAME_PC sign both ways: NO leaves the
|
||||
-- player in the room, YES warps home to the new-game bedroom spawn
|
||||
-- (REDS_HOUSE_2F, 3, 6). Screenshots the prompt and the arrival.
|
||||
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
|
||||
local pass = true
|
||||
local function check(cond, msg)
|
||||
if cond then U.log("PASS: " .. msg) else pass = false; U.log("FAIL: " .. msg) end
|
||||
end
|
||||
local function topIs(mt) return getmetatable(game.stack:top()) == mt end
|
||||
|
||||
-- press A to advance the ask text until the YES/NO box is on top
|
||||
local function openChoice()
|
||||
for _ = 1, 120 do
|
||||
if topIs(ChoiceBox) then return true end
|
||||
if topIs(TextBox) then U.tap(game, "a") end
|
||||
U.wait(3)
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- stand at (4,2) facing up: the console solid cell (4,1) is dead ahead
|
||||
U.teleport(game, "HALL_OF_FAME", 4, 2, "up")
|
||||
local ow = game.overworld
|
||||
local fx, fy = ow.player:facingCell()
|
||||
U.log(("at (%d,%d) facing %s -> facing cell (%d,%d)"):format(
|
||||
ow.player.cellX, ow.player.cellY, ow.player.facing, fx, fy))
|
||||
check(ow.map:signAtCell(fx, fy) ~= nil, "a sign sits on the console tile")
|
||||
|
||||
-- ---- NO: prompt appears, player stays in the Hall of Fame ----
|
||||
U.tap(game, "a") -- interact -> ask
|
||||
U.wait(4)
|
||||
check(openChoice(), "NO run: YES/NO prompt opened")
|
||||
U.shot(game, DIR .. "/hof_pc_1_prompt.png")
|
||||
U.tap(game, "down") -- cursor YES -> NO
|
||||
U.wait(2)
|
||||
U.tap(game, "a") -- confirm NO
|
||||
U.wait(20)
|
||||
check(ow.map.id == "HALL_OF_FAME", "NO keeps the player in HALL_OF_FAME")
|
||||
|
||||
-- ---- YES: warp back to the bedroom spawn ----
|
||||
U.teleport(game, "HALL_OF_FAME", 4, 2, "up")
|
||||
ow = game.overworld
|
||||
U.tap(game, "a")
|
||||
U.wait(4)
|
||||
check(openChoice(), "YES run: YES/NO prompt opened")
|
||||
U.tap(game, "a") -- YES (default cursor)
|
||||
local warped = false
|
||||
for _ = 1, 300 do
|
||||
if ow.map.id == "REDS_HOUSE_2F" and not ow.transitioning then warped = true; break end
|
||||
U.wait(2)
|
||||
end
|
||||
U.log(("after YES: map=%s pos=(%d,%d) facing=%s"):format(
|
||||
ow.map.id, ow.player.cellX, ow.player.cellY, ow.player.facing))
|
||||
U.wait(10)
|
||||
U.shot(game, DIR .. "/hof_pc_2_home.png")
|
||||
check(warped, "YES warps to REDS_HOUSE_2F")
|
||||
check(ow.player.cellX == 3 and ow.player.cellY == 6,
|
||||
"landed on the bedroom spawn (3,6)")
|
||||
check(ow.lastOutdoor and ow.lastOutdoor.id == "PALLET_TOWN",
|
||||
"PC home warp retargets lastOutdoor to Pallet (#103)")
|
||||
local lead = game.save.party and game.save.party[1]
|
||||
if lead and lead.stats then
|
||||
check(lead.hp == lead.stats.hp and lead.status == nil,
|
||||
"PC home warp heals the party")
|
||||
end
|
||||
|
||||
U.log(pass and "RESULT: ALL PASS" or "RESULT: SEE FAILURES ABOVE")
|
||||
end
|
||||
@@ -0,0 +1,63 @@
|
||||
-- Driver: exercises src/link/Net.lua's TCP relay backend for real, inside
|
||||
-- LOVE (real lua-enet/luasocket), against a pokeserver already running at
|
||||
-- 127.0.0.1:7778 (POKEPORT_RELAY_ADDR overrides). Doesn't touch the game
|
||||
-- UI at all -- just proves host/join/relay/close over a real socket.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Net = require("src.link.Net")
|
||||
local addr = os.getenv("POKEPORT_RELAY_ADDR") or "127.0.0.1:7778"
|
||||
|
||||
local host = Net.new()
|
||||
local ok = host:hostOnline(addr)
|
||||
U.log("hostOnline ok:", ok, "error:", host.error)
|
||||
|
||||
local frames = 0
|
||||
while not host.code and not host.error and frames < 180 do
|
||||
host:update()
|
||||
frames = frames + 1
|
||||
coroutine.yield()
|
||||
end
|
||||
U.log("host code:", host.code, "error:", host.error)
|
||||
|
||||
local guest = Net.new()
|
||||
local gok = guest:joinOnline(addr, host.code)
|
||||
U.log("joinOnline ok:", gok, "error:", guest.error)
|
||||
|
||||
frames = 0
|
||||
while (not host.paired or not guest.paired) and frames < 180 do
|
||||
host:update()
|
||||
guest:update()
|
||||
frames = frames + 1
|
||||
coroutine.yield()
|
||||
end
|
||||
U.log("host.paired:", host.paired, "guest.paired:", guest.paired)
|
||||
|
||||
host:send({ type = "hello", name = "RED" })
|
||||
local relayed = nil
|
||||
frames = 0
|
||||
while not relayed and frames < 180 do
|
||||
host:update() -- flushes host's queued send
|
||||
guest:update()
|
||||
for _, msg in ipairs(guest:poll()) do
|
||||
if msg.type == "hello" then relayed = msg end
|
||||
end
|
||||
frames = frames + 1
|
||||
coroutine.yield()
|
||||
end
|
||||
U.log("relayed hello name:", relayed and relayed.name)
|
||||
|
||||
guest:close()
|
||||
frames = 0
|
||||
while not host.closed and frames < 180 do
|
||||
host:update()
|
||||
frames = frames + 1
|
||||
coroutine.yield()
|
||||
end
|
||||
U.log("host saw peer_gone / closed:", host.closed)
|
||||
|
||||
host:close()
|
||||
|
||||
local pass = host.code ~= nil and host.paired and guest.paired
|
||||
and relayed ~= nil and relayed.name == "RED" and host.closed
|
||||
U.log("NET_RELAY_SMOKE:", pass and "PASS" or "FAIL")
|
||||
end
|
||||
@@ -0,0 +1,48 @@
|
||||
-- Visual test: SQUIRTLE (player) vs BULBASAUR (enemy) in OG RED, to match
|
||||
-- the Game Boy Color hardware capture. Run with a display:
|
||||
--
|
||||
-- SHOT_DIR=/tmp/ogred POKEPORT_IDENTITY=pokeport-ogred-shot \
|
||||
-- POKEPORT_DRIVER=tests/drivers/ogred_battle_test.lua love .
|
||||
--
|
||||
-- Captures ogred_0{1..5}_*.png into SHOT_DIR. OG RED is a global palette:
|
||||
-- red BG (terrain, mon pics, HUD, text) + green OBJ (overworld characters,
|
||||
-- battle effects). Battle mon pics are BG tiles, so they come out red/pink
|
||||
-- on the near-white field -- see PaletteFX.GBC_BG / monPal.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "."
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
|
||||
-- Set the SAVED option, not just the live mode: Game:applyOptions re-reads
|
||||
-- save.options.colors, so a bare setMode would get reverted to the default.
|
||||
game.save.options = game.save.options or {}
|
||||
game.save.options.colors = "ogred"
|
||||
PaletteFX.setMode("ogred")
|
||||
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
game.save.party = { Pokemon.new(game.data, "SQUIRTLE", 5) }
|
||||
|
||||
U.teleport(game, "ROUTE_1", 5, 5, "down")
|
||||
local ow = game.overworld
|
||||
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local battle = BattleState.newWild(game, "BULBASAUR", 5)
|
||||
battle.onFinish = function() end
|
||||
ow:pushBattle(battle)
|
||||
|
||||
U.wait(220)
|
||||
U.shot(game, DIR .. "/ogred_01_intro.png")
|
||||
|
||||
for _ = 1, 24 do U.tap(game, "a"); U.wait(6) end
|
||||
U.shot(game, DIR .. "/ogred_02_menu.png")
|
||||
|
||||
U.tap(game, "a"); U.wait(12) -- FIGHT -> move list
|
||||
U.shot(game, DIR .. "/ogred_03_moves.png")
|
||||
|
||||
U.tap(game, "down"); U.wait(6) -- TACKLE -> TAIL WHIP
|
||||
U.tap(game, "a"); U.wait(30)
|
||||
U.shot(game, DIR .. "/ogred_04_tailwhip.png")
|
||||
U.wait(40)
|
||||
U.shot(game, DIR .. "/ogred_05_after.png")
|
||||
U.wait(4)
|
||||
end
|
||||
@@ -0,0 +1,53 @@
|
||||
-- Driver: screenshots the new online-play menu surface (LinkState's
|
||||
-- restructured top menu, the online host/join flow). Pushed directly
|
||||
-- (bypassing Start-menu navigation, matching options_test.lua's
|
||||
-- convention) so it doesn't depend on party/save state.
|
||||
--
|
||||
-- Run with no mods discoverable (mod enable/disable only takes effect at
|
||||
-- the next boot -- see Handshake.onlineAllowed's comment -- so a live
|
||||
-- toggle can't simulate "vanilla" mid-session; the mod directory has to
|
||||
-- actually be absent for this run).
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
|
||||
|
||||
local LinkState = require("src.link.LinkState")
|
||||
|
||||
local function closeAnyOpenScreens()
|
||||
while game.stack:top() and game.stack:top().exitWith do
|
||||
game.stack:top():exitWith(nil)
|
||||
U.wait(2)
|
||||
end
|
||||
end
|
||||
|
||||
-- top menu (3 rows: LAN / ONLINE MATCH / TOURNAMENT)
|
||||
game.stack:push(LinkState.new(game))
|
||||
U.wait(5)
|
||||
U.shot(game, DIR .. "/link_0_top_menu.png")
|
||||
U.tap(game, "down"); U.wait(2)
|
||||
U.shot(game, DIR .. "/link_1_top_menu_online.png")
|
||||
|
||||
-- ONLINE MATCH -> HOST ONLINE (needs pokeserver reachable at the
|
||||
-- default relay address; POKEPORT_RELAY_ADDR overrides)
|
||||
U.tap(game, "a"); U.wait(3) -- into onlineMenu
|
||||
U.shot(game, DIR .. "/link_2_online_menu.png")
|
||||
U.tap(game, "a"); U.wait(20) -- HOST ONLINE -> connect
|
||||
U.shot(game, DIR .. "/link_3_online_hosting.png")
|
||||
|
||||
-- back out, try JOIN ONLINE's code-entry screen
|
||||
closeAnyOpenScreens()
|
||||
game.stack:push(LinkState.new(game))
|
||||
U.wait(5)
|
||||
U.tap(game, "down"); U.wait(2) -- ONLINE MATCH row
|
||||
U.tap(game, "a"); U.wait(3)
|
||||
U.tap(game, "down"); U.wait(2) -- JOIN ONLINE row
|
||||
U.tap(game, "a"); U.wait(3)
|
||||
U.shot(game, DIR .. "/link_4_code_entry.png")
|
||||
U.tap(game, "up"); U.wait(1)
|
||||
U.tap(game, "right"); U.wait(1)
|
||||
U.shot(game, DIR .. "/link_5_code_entry_scrubbed.png")
|
||||
closeAnyOpenScreens()
|
||||
|
||||
U.log("ONLINE_PLAY_DRIVER: done")
|
||||
end
|
||||
+2
-15
@@ -4165,21 +4165,8 @@ function ops.fieldMove(s, where)
|
||||
say(("fieldMove %s: nobody in the party knows it"):format(move))
|
||||
return false
|
||||
end
|
||||
-- The knower must also be CONSCIOUS: the party submenu hides field
|
||||
-- moves on a fainted mon (vanilla -- partyKnows requires hp > 0), so
|
||||
-- the menu walk below finds only STATS/SWITCH and fails. Route 23's
|
||||
-- mandatory surf strip is where this bit: WARTORTLE arrived dead from
|
||||
-- the Viridian Gym attrition, "no SURF entry" x3, the water was never
|
||||
-- crossed, and the whole league skip-cascaded with all eight badges
|
||||
-- in the bag. Heal, fail this attempt loudly, and let the caller's
|
||||
-- retry run the step again with a live user.
|
||||
local knower = (G.save.party or {})[slot]
|
||||
if knower and (knower.hp or 0) <= 0 then
|
||||
say(("fieldMove %s: its only user has fainted -- visiting a centre "
|
||||
.. "before retrying"):format(move))
|
||||
visitPokeCenter(where)
|
||||
return false
|
||||
end
|
||||
-- Field moves work from fainted users in Gen 1 (PartyMenu + partyKnows
|
||||
-- no longer gate on hp > 0). No Poké Center detour needed here.
|
||||
-- CUT acts on the tile the player is FACING (OverworldState:tryCut), and
|
||||
-- a `goto` leaves us facing whatever direction we last stepped. Without
|
||||
-- turning to the tree first the move fires into empty ground, the game
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
-- Driver: joins the tournament tournament_host_test.lua creates, reading
|
||||
-- the code from a shared file since the two LOVE processes otherwise
|
||||
-- can't coordinate. Meant for a fresh throwaway POKEPORT_IDENTITY, so it
|
||||
-- injects a test party directly (mirroring run_link_tests.lua's
|
||||
-- makeFakeGame) rather than playing through a whole new-game intro.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots_tourney"
|
||||
local CODE_FILE = os.getenv("TOURNEY_CODE_FILE") or "/tmp/tourney_code.txt"
|
||||
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
|
||||
|
||||
-- the default tournament rule is exactly 3 Pokemon; overwrite whatever
|
||||
-- this identity's party actually is (safe: a match uses clamped copies)
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
game.save.party = {
|
||||
Pokemon.new(game.data, "BLASTOISE", 50),
|
||||
Pokemon.new(game.data, "GENGAR", 35),
|
||||
Pokemon.new(game.data, "ALAKAZAM", 45),
|
||||
}
|
||||
game.save.player.name = "BLUE"
|
||||
|
||||
local CodeEntry = require("src.link.CodeEntry")
|
||||
local Tournament = require("src.link.Tournament")
|
||||
local t = Tournament.new(game)
|
||||
game.stack:push(t)
|
||||
U.wait(3)
|
||||
|
||||
local code = nil
|
||||
local waited = 0
|
||||
while not code and waited < 1800 do
|
||||
local f = io.open(CODE_FILE, "r")
|
||||
if f then
|
||||
local c = f:read("*l")
|
||||
f:close()
|
||||
if c and #c == 6 then code = c end
|
||||
end
|
||||
U.wait(1)
|
||||
waited = waited + 1
|
||||
end
|
||||
U.log("guest read code:", code)
|
||||
if not code then
|
||||
U.log("TOURNAMENT_GUEST_DRIVER: never saw a code, aborting")
|
||||
return
|
||||
end
|
||||
|
||||
U.tap(game, "down"); U.wait(2) -- JOIN row
|
||||
U.tap(game, "a"); U.wait(3) -- into codeEntry
|
||||
U.shot(game, DIR .. "/guest_0_code_entry.png")
|
||||
for i = 1, CodeEntry.LENGTH do
|
||||
local idx = CodeEntry.CHARSET:find(code:sub(i, i), 1, true)
|
||||
if idx then t.codeEntry.chars[i] = idx end
|
||||
end
|
||||
U.tap(game, "a"); U.wait(3) -- confirm -> startJoining
|
||||
U.shot(game, DIR .. "/guest_1_joining.png")
|
||||
|
||||
waited = 0
|
||||
while #t.roster == 0 and t.stage ~= "done" and waited < 1800 do
|
||||
U.wait(1)
|
||||
waited = waited + 1
|
||||
end
|
||||
U.log("guest roster:", table.concat(t.roster or {}, ","))
|
||||
U.shot(game, DIR .. "/guest_2_roster.png")
|
||||
|
||||
local doneWait = 0
|
||||
while t.stage ~= "done" and doneWait < 5400 do
|
||||
U.tap(game, "a")
|
||||
U.wait(2)
|
||||
doneWait = doneWait + 1
|
||||
end
|
||||
U.shot(game, DIR .. "/guest_3_done.png")
|
||||
U.log("TOURNAMENT_GUEST_DRIVER: champion=", t.champion, "stage=", t.stage)
|
||||
end
|
||||
@@ -0,0 +1,57 @@
|
||||
-- Driver: hosts a real 2-player tournament against a real running
|
||||
-- pokeserver, paired with tournament_guest_test.lua running in a second
|
||||
-- LOVE process. Coordinates over a shared code file (TOURNEY_CODE_FILE)
|
||||
-- since the two processes have no other way to talk before the code
|
||||
-- exists. The default tournament rule is exactly 3 Pokemon, so this
|
||||
-- overwrites whatever party the identity actually has with 3 fixed mons
|
||||
-- -- a tournament match never touches the real save (clamped copies, same
|
||||
-- as any other link battle), so clobbering it here for the test is safe.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots_tourney"
|
||||
local CODE_FILE = os.getenv("TOURNEY_CODE_FILE") or "/tmp/tourney_code.txt"
|
||||
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
|
||||
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
game.save.party = {
|
||||
Pokemon.new(game.data, "CHARIZARD", 50),
|
||||
Pokemon.new(game.data, "PIKACHU", 30),
|
||||
Pokemon.new(game.data, "SNORLAX", 40),
|
||||
}
|
||||
game.save.player.name = "RED"
|
||||
|
||||
local Tournament = require("src.link.Tournament")
|
||||
local t = Tournament.new(game)
|
||||
game.stack:push(t)
|
||||
U.wait(3)
|
||||
|
||||
U.shot(game, DIR .. "/host_0_menu.png")
|
||||
U.tap(game, "a"); U.wait(3) -- HOST -> hostSettings
|
||||
U.shot(game, DIR .. "/host_1_settings.png")
|
||||
U.tap(game, "start"); U.wait(30) -- create with defaults (3 mons, any/any, 6s)
|
||||
U.shot(game, DIR .. "/host_2_hosting.png")
|
||||
U.log("host code:", t.code, "error:", t.net and t.net.error)
|
||||
|
||||
local f = io.open(CODE_FILE, "w")
|
||||
if f then f:write(t.code or ""); f:close() end
|
||||
|
||||
local waited = 0
|
||||
while #t.roster < 2 and waited < 1800 do
|
||||
U.wait(1)
|
||||
waited = waited + 1
|
||||
end
|
||||
U.log("host roster:", table.concat(t.roster, ","))
|
||||
U.shot(game, DIR .. "/host_3_roster.png")
|
||||
|
||||
U.tap(game, "a"); U.wait(1) -- start_tournament
|
||||
U.shot(game, DIR .. "/host_4_bracket.png")
|
||||
|
||||
local doneWait = 0
|
||||
while t.stage ~= "done" and doneWait < 5400 do
|
||||
U.tap(game, "a")
|
||||
U.wait(2)
|
||||
doneWait = doneWait + 1
|
||||
end
|
||||
U.shot(game, DIR .. "/host_5_done.png")
|
||||
U.log("TOURNAMENT_HOST_DRIVER: champion=", t.champion, "stage=", t.stage)
|
||||
end
|
||||
+11
-1
@@ -40,6 +40,8 @@ stub.graphics = {
|
||||
newSpriteBatch = function(image, size)
|
||||
local batch = { image = image, sprites = {} }
|
||||
function batch:add(quad, x, y) table.insert(self.sprites, { quad, x, y }) end
|
||||
function batch:clear() self.sprites = {} end
|
||||
function batch:setTexture(tex) self.texture = tex end
|
||||
return batch
|
||||
end,
|
||||
draw = noop, rectangle = noop, setColor = noop, clear = noop,
|
||||
@@ -50,6 +52,9 @@ stub.graphics = {
|
||||
push = noop, pop = noop, translate = noop, scale = noop,
|
||||
rotate = noop, origin = noop, setShader = noop, setScissor = noop,
|
||||
getDimensions = function() return 640, 576 end,
|
||||
-- dpi=1 desktop default; issue #87 tests override these for Android density
|
||||
getPixelDimensions = function() return 640, 576 end,
|
||||
getDPIScale = function() return 1 end,
|
||||
}
|
||||
|
||||
stub.math = {
|
||||
@@ -125,7 +130,12 @@ stub.sound = {
|
||||
|
||||
stub.keyboard = { isDown = function() return false end }
|
||||
|
||||
stub.mouse = { getPosition = function() return 0, 0 end }
|
||||
stub.mouse = {
|
||||
getPosition = function() return 0, 0 end,
|
||||
isCursorSupported = function() return false end,
|
||||
getSystemCursor = function(name) return name end,
|
||||
setCursor = function() end,
|
||||
}
|
||||
|
||||
stub.timer = { getTime = function() return 0 end }
|
||||
|
||||
|
||||
@@ -344,6 +344,24 @@ check(lastSource().queueable and lastSource().playing,
|
||||
"a chip song still plays after a file song")
|
||||
check(not body.playing, "the outgoing file song was stopped")
|
||||
|
||||
-- playOnce must survive the threaded "empty QueueableSource" window:
|
||||
-- Source:isPlaying is false until the first worker buffer lands, and that
|
||||
-- gap must not look like the jingle already ended (Poké Center heal).
|
||||
data = reset(fixtureData())
|
||||
Music.playMap(data, "PALLET_TOWN", false, false)
|
||||
check(Music.playOnce(data, "Music_Chip"), "playOnce starts a chip jingle")
|
||||
local jingle = lastSource()
|
||||
local clearAwait = ChipAudio._simulateAwaitingFirstBufferForTest()
|
||||
check(clearAwait ~= nil, "test can force the awaiting-first-buffer window")
|
||||
check(Music.oneShotPlaying(),
|
||||
"oneShotPlaying stays true while the first buffer is still in flight")
|
||||
Music.update(data)
|
||||
check(jingle == lastSource() and jingle.queueable,
|
||||
"pendingRestore does not swap the map theme over a pending chip jingle")
|
||||
check(ChipAudio.awaitingFirstBuffer(),
|
||||
"awaitingFirstBuffer reports the forced window")
|
||||
clearAwait()
|
||||
|
||||
-- sfx shape dispatch
|
||||
check(Sound.play(data, "Beep") == nil, "Sound.play returns nothing")
|
||||
check(lastSource().file == "assets/beep.wav", "a bare string sfx is a static source")
|
||||
|
||||
@@ -320,6 +320,9 @@ check(vanillaSave.player.map == "REDS_HOUSE_2F" and vanillaSave.player.x == 3
|
||||
"the seeded boot config reproduces the NewGameWarp bedroom spawn")
|
||||
check(vanillaSave.player.name == "RED" and vanillaSave.player.rival == "BLUE"
|
||||
and vanillaSave.money == 3000, "the seeded boot config reproduces the Red start")
|
||||
-- issue #109: vanilla New Game puts 1 Potion in the player's item PC
|
||||
check(vanillaSave.pcItems and vanillaSave.pcItems.POTION == 1,
|
||||
"new game seeds 1 Potion in the player's PC")
|
||||
-- the heal point is deliberately NOT the spawn: wLastBlackoutMap is
|
||||
-- zero-filled at new game and PALLET_TOWN is map 0
|
||||
check(vanillaSave.lastHeal.map == "PALLET_TOWN" and vanillaSave.lastHeal.x == 5
|
||||
@@ -329,6 +332,8 @@ local bareSave = SaveData.newGame()
|
||||
check(bareSave.player.map == "REDS_HOUSE_2F" and bareSave.money == 3000
|
||||
and bareSave.lastHeal.map == "PALLET_TOWN",
|
||||
"newGame without a boot config is unchanged")
|
||||
check(bareSave.pcItems and bareSave.pcItems.POTION == 1,
|
||||
"newGame without a boot config still seeds the PC Potion")
|
||||
|
||||
local bootData, bootLoader = withMod("total_conversion", [[
|
||||
return function(mod)
|
||||
|
||||
@@ -113,6 +113,7 @@ love.graphics = {
|
||||
newSpriteBatch = function(image, size)
|
||||
local batch = { image = image, sprites = {} }
|
||||
function batch:add(quad, x, y) table.insert(self.sprites, { quad, x, y }) end
|
||||
function batch:clear() self.sprites = {} end
|
||||
function batch:setTexture(tex) self.texture = tex end
|
||||
return batch
|
||||
end,
|
||||
@@ -127,6 +128,8 @@ love.graphics = {
|
||||
translate = noop, scale = noop, rotate = noop, origin = noop,
|
||||
setScissor = noop, getColor = function() return 1, 1, 1, 1 end,
|
||||
getDimensions = function() return 640, 576 end,
|
||||
getPixelDimensions = function() return 640, 576 end,
|
||||
getDPIScale = function() return 1 end,
|
||||
}
|
||||
|
||||
-- Fresh copies of the modules that cache a compiled shader or a page set
|
||||
@@ -287,6 +290,8 @@ local renderer = TileRenderer.new(map)
|
||||
check(#renderer.anims == 1, "a declared animatedTiles entry builds one anim")
|
||||
check(renderer.anims[1].period == 12, "the declared period is honored")
|
||||
check(#renderer.anims[1].textures == 3, "the declared frame images load")
|
||||
-- the camera-window fill gathers the on-screen animated cells into anims[].batch
|
||||
renderer:draw(0, 0)
|
||||
check(renderer.anims[1].batch ~= nil, "the animated tile collected cells")
|
||||
|
||||
-- the vanilla water cycle, driven through the same data path: eight
|
||||
@@ -307,6 +312,8 @@ local sea = TileRenderer.new({
|
||||
check(#sea.anims == 1, "an OVERWORLD tileset animates its water with no record edit")
|
||||
check(#sea.anims[1].textures == 8, "the water entry builds 8 shifted variants")
|
||||
|
||||
-- fill the camera window so the water cells are gathered into the batch
|
||||
sea:draw(0, 0)
|
||||
local seen = {}
|
||||
for step = 1, 8 do
|
||||
sea:drawAnimated(0, 0)
|
||||
@@ -613,6 +620,49 @@ check(PaletteFX.pal({ palettes = nil }, "ROUTE") == gbc.palettes.ROUTE,
|
||||
"RED++ still has ROUTE (aliased from VIRIDIAN)")
|
||||
check(PaletteFX.effectiveColors(gbc.palettes.MEWMON) == gbc.palettes.MEWMON,
|
||||
"RED++ passes zone colors through like GBC")
|
||||
-- issue #84: CELADON_DINER shares LOBBY block 29 (table top) with
|
||||
-- CELADON_MART_ROOF (#52); both need the $37->$5a BROWN alias
|
||||
do
|
||||
local aliases = PaletteFX.TILE_ALIASES
|
||||
local roof = aliases and aliases.CELADON_MART_ROOF
|
||||
local diner = aliases and aliases.CELADON_DINER
|
||||
check(roof ~= nil and diner ~= nil,
|
||||
"CELADON_MART_ROOF and CELADON_DINER both have TILE_ALIASES")
|
||||
check(diner == roof,
|
||||
"diner reuses the same lobby table-top alias as the mart roof")
|
||||
local al = diner and diner[1]
|
||||
check(al and al.block == 29 and al.tile == 0x37 and al.alias == 0x5a
|
||||
and al.group == 5 and al.cells[5] and al.cells[6]
|
||||
and al.cells[9] and al.cells[10],
|
||||
"lobby table-top alias remaps block 29 cells 5/6/9/10")
|
||||
end
|
||||
-- issue #128: RED++'s gbc pack is Red-derived; Blue must keep ROM LOGO1
|
||||
-- (and the Blue-only SLOTS* rows) so the title ribbon is blue, not red
|
||||
do
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local prevVer = GameVersion.get()
|
||||
local blueLogo1 = {
|
||||
{ 255, 239, 255 }, { 247, 247, 140 }, { 173, 0, 33 }, { 115, 156, 239 },
|
||||
}
|
||||
local blueSlots2 = {
|
||||
{ 255, 239, 255 }, { 255, 255, 140 }, { 132, 156, 239 }, { 25, 16, 16 },
|
||||
}
|
||||
local rom = { palettes = { palettes = {
|
||||
LOGO1 = blueLogo1, SLOTS2 = blueSlots2, ROUTE = { { 1, 2, 3 }, { 0, 0, 0 },
|
||||
{ 0, 0, 0 }, { 0, 0, 0 } },
|
||||
} } }
|
||||
GameVersion.set("blue")
|
||||
check(PaletteFX.pal(rom, "LOGO1") == blueLogo1,
|
||||
"Blue + RED++ prefers ROM LOGO1 over the Red gbc pack")
|
||||
check(PaletteFX.pal(rom, "SLOTS2") == blueSlots2,
|
||||
"Blue + RED++ prefers ROM SLOTS2 over the Red gbc pack")
|
||||
check(PaletteFX.pal(rom, "ROUTE") == gbc.palettes.ROUTE,
|
||||
"Blue + RED++ still uses the gbc pack for shared names")
|
||||
GameVersion.set("red")
|
||||
check(PaletteFX.pal(rom, "LOGO1") == gbc.palettes.LOGO1,
|
||||
"Red + RED++ keeps the gbc-pack LOGO1 (title stays red)")
|
||||
GameVersion.set(prevVer)
|
||||
end
|
||||
PaletteFX.setMode(prevMode)
|
||||
|
||||
-- ------- the transitions registry
|
||||
@@ -642,6 +692,159 @@ check(Transition.new({ data = retimed }).frames == 30,
|
||||
check(Transition.new({ data = { transitions = {} } }).frames == 12,
|
||||
"an unregistered id falls back to the built-in 12 frames")
|
||||
|
||||
-- issue #121: with the survey-zoom world pass active, the warp fade must
|
||||
-- darken the full window composite (via Renderer.worldFadeAlpha), not just
|
||||
-- paint a 160x144 rect on the UI letterbox.
|
||||
do
|
||||
local rects = {}
|
||||
local color = { 1, 1, 1, 1 }
|
||||
local savedRect, savedColor = love.graphics.rectangle, love.graphics.setColor
|
||||
love.graphics.rectangle = function(mode, x, y, w, h)
|
||||
rects[#rects + 1] = {
|
||||
mode = mode, x = x, y = y, w = w, h = h,
|
||||
a = color[4], r = color[1],
|
||||
}
|
||||
end
|
||||
love.graphics.setColor = function(r, g, b, a)
|
||||
color[1], color[2], color[3], color[4] = r, g, b, a or 1
|
||||
end
|
||||
|
||||
Renderer:init()
|
||||
local fade = Transition.new({ renderer = Renderer, stack = { pop = noop } })
|
||||
fade.t = 6 -- mid fade-out (12 frames)
|
||||
fade.phase = "out"
|
||||
|
||||
Renderer:beginFrame(true)
|
||||
Renderer:beginWorldPass()
|
||||
Renderer:endWorldPass()
|
||||
check(Renderer.worldActive == true, "world pass stays marked active until endFrame")
|
||||
fade:draw()
|
||||
check(Renderer.worldFadeAlpha == 0.5,
|
||||
"warp fade hands mid-out alpha to the world composite overlay")
|
||||
check(#rects == 0,
|
||||
"warp fade does not paint the 160x144 UI letterbox while the world pass is up")
|
||||
|
||||
resetLog()
|
||||
rects = {}
|
||||
Renderer:endFrame(nil, fullWorldZones())
|
||||
local fadeRect
|
||||
for _, r in ipairs(rects) do
|
||||
-- endFrame's letterbox clear is also a full-window black fill (a == 1);
|
||||
-- the warp overlay is the half-alpha one Transition requested
|
||||
if r.mode == "fill" and r.x == 0 and r.y == 0
|
||||
and r.w == 640 and r.h == 576 and r.r == 0 and r.a == 0.5 then
|
||||
fadeRect = r
|
||||
end
|
||||
end
|
||||
check(fadeRect ~= nil,
|
||||
"endFrame paints the warp fade over the full window at the fade alpha")
|
||||
check(Renderer.worldActive == false, "endFrame clears worldActive")
|
||||
|
||||
-- without a world pass (opaque UI states), keep the classic UI rect
|
||||
Renderer:beginFrame(false)
|
||||
fade.t = 6
|
||||
fade.phase = "out"
|
||||
rects = {}
|
||||
fade:draw()
|
||||
check(Renderer.worldFadeAlpha == nil,
|
||||
"no world pass: warp fade does not set a world overlay")
|
||||
check(#rects == 1 and rects[1].w == 160 and rects[1].h == 144,
|
||||
"no world pass: warp fade still fills the 160x144 UI canvas")
|
||||
|
||||
love.graphics.rectangle, love.graphics.setColor = savedRect, savedColor
|
||||
end
|
||||
|
||||
-- ------- battle transition cascade outside the OG square + white letterbox
|
||||
do
|
||||
local rects = {}
|
||||
local color = { 1, 1, 1, 1 }
|
||||
local savedRect, savedColor = love.graphics.rectangle, love.graphics.setColor
|
||||
local savedGetDims = love.graphics.getDimensions
|
||||
local savedGetPix = love.graphics.getPixelDimensions
|
||||
-- taller-than-fit window so the 160x144 letterbox leaves real voids
|
||||
-- (default 640x576 is an exact 4x fit with nothing outside the square)
|
||||
love.graphics.getDimensions = function() return 800, 600 end
|
||||
love.graphics.getPixelDimensions = function() return 800, 600 end
|
||||
love.graphics.rectangle = function(mode, x, y, w, h)
|
||||
rects[#rects + 1] = {
|
||||
mode = mode, x = x, y = y, w = w, h = h,
|
||||
r = color[1], g = color[2], b = color[3], a = color[4],
|
||||
}
|
||||
end
|
||||
love.graphics.setColor = function(r, g, b, a)
|
||||
color[1], color[2], color[3], color[4] = r, g, b, a or 1
|
||||
end
|
||||
|
||||
Renderer:init()
|
||||
local btGame = { renderer = Renderer, stack = { pop = noop } }
|
||||
local wipe = BattleTransition.new(btGame, nil, {})
|
||||
wipe.phase = "wipe"
|
||||
wipe.t = math.floor(wipe.wipeLen / 2)
|
||||
Renderer:beginFrame(true)
|
||||
Renderer:beginWorldPass()
|
||||
Renderer:endWorldPass()
|
||||
wipe:draw()
|
||||
check(Renderer.battleCascadeProg ~= nil
|
||||
and Renderer.battleCascadeProg > 0
|
||||
and Renderer.battleCascadeProg < 1,
|
||||
"battle wipe publishes mid-progress cascade to the renderer")
|
||||
rects = {}
|
||||
Renderer:endFrame(nil, fullWorldZones())
|
||||
local cascadeTile
|
||||
for _, r in ipairs(rects) do
|
||||
-- fitScale at 800x600 is min(5,4)=4 → 32px tiles outside the letterbox
|
||||
if r.mode == "fill" and r.r == 0 and r.a == 1 and r.w == 32 and r.h == 32 then
|
||||
cascadeTile = r
|
||||
break
|
||||
end
|
||||
end
|
||||
check(cascadeTile ~= nil,
|
||||
"endFrame paints cascading black tiles outside the OG wipe square")
|
||||
love.graphics.rectangle, love.graphics.setColor = savedRect, savedColor
|
||||
love.graphics.getDimensions = savedGetDims
|
||||
love.graphics.getPixelDimensions = savedGetPix
|
||||
end
|
||||
|
||||
do
|
||||
-- BattleState asks for white letterbox voids instead of black bars
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
check(BattleState.letterboxWhite == true,
|
||||
"BattleState opts into white letterbox fill")
|
||||
local rects = {}
|
||||
local color = { 1, 1, 1, 1 }
|
||||
local savedRect, savedColor = love.graphics.rectangle, love.graphics.setColor
|
||||
love.graphics.rectangle = function(mode, x, y, w, h)
|
||||
rects[#rects + 1] = {
|
||||
mode = mode, x = x, y = y, w = w, h = h,
|
||||
r = color[1], g = color[2], b = color[3], a = color[4],
|
||||
}
|
||||
end
|
||||
love.graphics.setColor = function(r, g, b, a)
|
||||
color[1], color[2], color[3], color[4] = r, g, b, a or 1
|
||||
end
|
||||
local Game = require("src.core.Game")
|
||||
local savedStack = Game.stack
|
||||
Game.stack = {
|
||||
states = { { letterboxWhite = true, isOpaque = true } },
|
||||
visibleBase = function() return 1 end,
|
||||
}
|
||||
Renderer:init()
|
||||
Renderer:beginFrame(false)
|
||||
rects = {}
|
||||
Renderer:endFrame(nil, nil)
|
||||
local clear
|
||||
for _, r in ipairs(rects) do
|
||||
if r.mode == "fill" and r.x == 0 and r.y == 0 and r.w == 640 and r.h == 576 then
|
||||
clear = r
|
||||
break
|
||||
end
|
||||
end
|
||||
check(clear and clear.r == 1 and clear.g == 1 and clear.b == 1,
|
||||
"endFrame fills the window white when the visible base wants letterboxWhite")
|
||||
Game.stack = savedStack
|
||||
love.graphics.rectangle, love.graphics.setColor = savedRect, savedColor
|
||||
end
|
||||
|
||||
-- ------- the transition.style hook
|
||||
|
||||
local stack = { pop = function() end }
|
||||
|
||||
@@ -196,6 +196,7 @@ do
|
||||
check(#loaded.meta.mods == 0, "old vanilla save records an empty mod set")
|
||||
check(loaded.boxes and loaded.boxes[1][1].species == "PIDGEY", "box list folded into boxes[1]")
|
||||
check(loaded.box == nil, "legacy box key gone")
|
||||
check(loaded.version == "red", "untagged save defaults to the Red version")
|
||||
local opts = SaveSerializer.decode(files["options.lua"])
|
||||
check(opts and opts.musicVol == 2, "embedded options split into options.lua")
|
||||
|
||||
@@ -203,7 +204,51 @@ do
|
||||
local current = { meta = { format = Version.saveFormat, mods = {} },
|
||||
player = { map = "PALLET_TOWN", x = 1, y = 1 } }
|
||||
SaveData.runMigrations(current)
|
||||
check(current.player.id == nil, "format-2 save skips the id backfill")
|
||||
check(current.player.id == nil, "current-format save skips the id backfill")
|
||||
|
||||
-- a format-2 save (Blue support not yet shipped) gains the Red tag;
|
||||
-- a save that already names a version keeps it
|
||||
local untagged = { meta = { format = 2, mods = {} }, player = {} }
|
||||
SaveData.runMigrations(untagged)
|
||||
check(untagged.version == "red", "pre-version save defaults to Red")
|
||||
local tagged = { meta = { format = 2, mods = {} }, version = "blue", player = {} }
|
||||
SaveData.runMigrations(tagged)
|
||||
check(tagged.version == "blue", "an existing version tag is left untouched")
|
||||
|
||||
-- #131: format-3 save that beat the Game Corner poster grunt before
|
||||
-- hide_object was wired -- defeatedTrainers only, no objectToggles hide
|
||||
local stuckGrunt = {
|
||||
meta = { format = 3, mods = {} },
|
||||
defeatedTrainers = { GAME_CORNER_obj_11 = true },
|
||||
objectToggles = {},
|
||||
flags = {},
|
||||
player = {},
|
||||
}
|
||||
SaveData.runMigrations(stuckGrunt)
|
||||
check(stuckGrunt.objectToggles.GAME_CORNER
|
||||
and stuckGrunt.objectToggles.GAME_CORNER.GAMECORNER_ROCKET == false,
|
||||
"Game Corner rocket hide backfilled from defeatedTrainers")
|
||||
check(stuckGrunt.meta.format == Version.saveFormat,
|
||||
"format-3 hideout migration stamps to current")
|
||||
-- already-hidden stays hidden; undefeated grunt is left alone
|
||||
local alreadyHidden = {
|
||||
meta = { format = 3, mods = {} },
|
||||
defeatedTrainers = { GAME_CORNER_obj_11 = true },
|
||||
objectToggles = { GAME_CORNER = { GAMECORNER_ROCKET = false } },
|
||||
player = {},
|
||||
}
|
||||
SaveData.runMigrations(alreadyHidden)
|
||||
check(alreadyHidden.objectToggles.GAME_CORNER.GAMECORNER_ROCKET == false,
|
||||
"already-hidden rocket toggle is left false")
|
||||
local neverFought = {
|
||||
meta = { format = 3, mods = {} },
|
||||
defeatedTrainers = {},
|
||||
objectToggles = {},
|
||||
player = {},
|
||||
}
|
||||
SaveData.runMigrations(neverFought)
|
||||
check(not neverFought.objectToggles.GAME_CORNER,
|
||||
"undefeated Game Corner rocket is not auto-hidden")
|
||||
|
||||
love.filesystem = realFS
|
||||
end
|
||||
@@ -526,6 +571,32 @@ do
|
||||
"newGame starts an empty modData")
|
||||
check(plain.meta.format == Version.saveFormat and #plain.meta.mods == 0,
|
||||
"newGame stamps a vanilla meta")
|
||||
check(plain.pcItems and plain.pcItems.POTION == 1,
|
||||
"unhooked newGame seeds 1 Potion in pcItems (issue #109)")
|
||||
end
|
||||
|
||||
-- issue #109: loading a pre-fix save that never had pcItems must not
|
||||
-- invent a free Potion (player may already have withdrawn/tossed it, or
|
||||
-- the empty PC is intentional). Seeding is New Game only.
|
||||
do
|
||||
local files = {}
|
||||
love.filesystem = memfs(files)
|
||||
local legacy = {
|
||||
meta = { format = Version.saveFormat, mods = {} },
|
||||
player = { map = "PALLET_TOWN", x = 5, y = 6, facing = "down",
|
||||
name = "RED", rival = "BLUE", id = 1 },
|
||||
flags = {}, inventory = {}, party = {}, box = {}, money = 3000,
|
||||
defeatedTrainers = {}, pokedex = { seen = {}, owned = {} },
|
||||
lastHeal = { map = "PALLET_TOWN", x = 5, y = 6 },
|
||||
options = SaveData.defaultOptions(),
|
||||
}
|
||||
check(legacy.pcItems == nil, "fixture omits pcItems on purpose")
|
||||
check(SaveData.save(legacy), "legacy save without pcItems writes")
|
||||
local loaded = SaveData.load()
|
||||
check(loaded ~= nil, "legacy save loads")
|
||||
check(loaded.pcItems == nil or loaded.pcItems.POTION == nil,
|
||||
"load does not invent a PC Potion for existing saves")
|
||||
love.filesystem = realFS
|
||||
end
|
||||
|
||||
-- a throwing mod migration is skipped, never fatal: an uncaught error here
|
||||
|
||||
+188
-4
@@ -22,6 +22,7 @@ local StateStack = require("src.core.StateStack")
|
||||
local ManagerState = require("src.mods.ManagerState")
|
||||
local ModUI = require("src.ui.ModUI")
|
||||
local Theme = require("src.ui.Theme")
|
||||
local FrameCap = require("src.core.FrameCap")
|
||||
|
||||
local savedEvents, savedHooks, savedErrors =
|
||||
Runtime.events, Runtime.hooks, Runtime.errors
|
||||
@@ -205,7 +206,8 @@ end
|
||||
local om = OptionsMenu.new(optGame())
|
||||
local WANT_IDS = { "textSpeed", "animations", "battleStyle", "ruleset",
|
||||
"musicVol", "sfxVol", "musicFilter", "colors", "tilt",
|
||||
"gbcfx", "videoMode", "speed", "mods", "controls" }
|
||||
"gbcfx", "zoom", "voidFill", "videoMode", "fpsCap",
|
||||
"speed", "mods", "controls" }
|
||||
check(#om.rows == #WANT_IDS, "vanilla options row count (plus MODS/CONTROLS)")
|
||||
for i, id in ipairs(WANT_IDS) do
|
||||
check(om.rows[i].id == id, "options row order: " .. id)
|
||||
@@ -235,10 +237,65 @@ check(om.game.save.options.musicVol == 6, "music volume steps down")
|
||||
for _ = 1, 10 do om.rows[5].step(om.game, -1) end
|
||||
check(om.game.save.options.musicVol == 0, "music volume clamps at 0")
|
||||
|
||||
-- ZOOM / VOID FILL rows
|
||||
local Zoom = require("src.render.Zoom")
|
||||
local TileRenderer = require("src.render.TileRenderer")
|
||||
om.game.save.options.zoom = 0
|
||||
Zoom.offset = 0
|
||||
check(om.rows[11].value(om.game) == "FIT", "ZOOM row shows FIT at offset 0")
|
||||
om.rows[11].step(om.game, 1)
|
||||
check(om.game.save.options.zoom == 1 and Zoom.offset == 1,
|
||||
"ZOOM row steps to IN1")
|
||||
om.rows[12].step(om.game, 1)
|
||||
check(om.game.save.options.voidFill == "water"
|
||||
and TileRenderer.voidFill == "water",
|
||||
"VOID FILL row cycles TREES → WATER")
|
||||
om.rows[12].step(om.game, 1)
|
||||
check(om.game.save.options.voidFill == "black", "VOID FILL steps to BLACK")
|
||||
om.rows[12].step(om.game, 1)
|
||||
check(om.game.save.options.voidFill == "trees", "VOID FILL wraps to TREES")
|
||||
|
||||
-- the MAX FPS row cycles the render-cap steps and shows the value plain
|
||||
om.game.save.options.fpsCap = nil
|
||||
check(om.rows[14].value(om.game) == "60",
|
||||
"MAX FPS row defaults to 60 with no saved cap")
|
||||
om.rows[14].step(om.game, 1)
|
||||
check(om.game.save.options.fpsCap == 75, "MAX FPS steps up from 60 to 75")
|
||||
check(om.rows[14].value(om.game) == "75", "the MAX FPS row renders the cap")
|
||||
om.game.save.options.fpsCap = 160
|
||||
om.rows[14].step(om.game, 1)
|
||||
check(om.game.save.options.fpsCap == 30, "MAX FPS wraps past the ceiling to 30")
|
||||
om.rows[14].step(om.game, -1)
|
||||
check(om.game.save.options.fpsCap == 160, "MAX FPS wraps back down to the ceiling")
|
||||
|
||||
-- ------- FrameCap normalize / cycle (issue #88)
|
||||
check(FrameCap.normalize(nil) == 60, "FrameCap defaults nil to 60")
|
||||
check(FrameCap.normalize("junk") == 60, "FrameCap defaults garbage to 60")
|
||||
check(FrameCap.normalize(60) == 60, "FrameCap keeps an exact step")
|
||||
check(FrameCap.normalize(58) == 60, "FrameCap snaps 58 to the nearest step 60")
|
||||
check(FrameCap.normalize(72) == 75, "FrameCap snaps 72 to the nearest step 75")
|
||||
check(FrameCap.normalize(0) == 30, "FrameCap clamps below the floor to 30")
|
||||
check(FrameCap.normalize(9999) == 160, "FrameCap clamps above the ceiling to 160")
|
||||
check(FrameCap.normalize(30) == 30 and FrameCap.normalize(160) == 160,
|
||||
"FrameCap keeps the exact floor and ceiling")
|
||||
check(FrameCap.label(nil) == "60" and FrameCap.label(144) == "144",
|
||||
"FrameCap.label renders the normalized cap as plain text")
|
||||
check(FrameCap.cycle(60, 1) == 75, "FrameCap cycles 60 up to 75")
|
||||
check(FrameCap.cycle(60, -1) == 50, "FrameCap cycles 60 down to 50")
|
||||
check(FrameCap.cycle(160, 1) == 30, "FrameCap cycle wraps the ceiling to the floor")
|
||||
check(FrameCap.cycle(30, -1) == 160, "FrameCap cycle wraps the floor to the ceiling")
|
||||
check(FrameCap.cycle(nil, 1) == 75,
|
||||
"FrameCap cycle normalizes a nil cap (60) before stepping")
|
||||
-- apply drives the live value the run loop paces to; never touches love.timer
|
||||
FrameCap.apply(144)
|
||||
check(FrameCap.current == 144, "FrameCap.apply stores the live cap")
|
||||
FrameCap.applyOptions({})
|
||||
check(FrameCap.current == 60, "FrameCap.applyOptions defaults a missing key to 60")
|
||||
|
||||
-- the MODS row is the manager's discoverable home
|
||||
local mgGame = optGame()
|
||||
om = OptionsMenu.new(mgGame)
|
||||
om.rows[13].activate(mgGame)
|
||||
om.rows[16].activate(mgGame)
|
||||
check(getmetatable(mgGame.stack:top()) == ManagerState,
|
||||
"the MODS row opens the manager")
|
||||
check(mgGame.stack:top().screenId == "ManagerState",
|
||||
@@ -248,7 +305,7 @@ check(mgGame.stack:top().screenId == "ManagerState",
|
||||
local BindingsMenu = require("src.ui.BindingsMenu")
|
||||
local cbGame = optGame()
|
||||
om = OptionsMenu.new(cbGame)
|
||||
om.rows[14].activate(cbGame)
|
||||
om.rows[17].activate(cbGame)
|
||||
local bm = cbGame.stack:top()
|
||||
check(getmetatable(bm) == BindingsMenu,
|
||||
"the CONTROLS row opens the rebind list")
|
||||
@@ -257,7 +314,8 @@ check(bm.screenId == "BindingsMenu",
|
||||
check(#bm.items == 8, "one row per logical button")
|
||||
check(bm.items[1].label == "UP" and bm.items[1].right == "UP"
|
||||
and bm.items[5].label == "A" and bm.items[5].right == "Z"
|
||||
and bm.items[7].label == "START" and bm.items[7].right == "ESCAPE",
|
||||
and bm.items[7].label == "START" and bm.items[7].right == "ESCAPE"
|
||||
and bm.items[8].label == "SELECT" and bm.items[8].right == "TAB/BACK",
|
||||
"with no rebind the rows mirror the fixed map")
|
||||
check(cbGame.save.options.bindings == nil,
|
||||
"opening the screen alone writes nothing")
|
||||
@@ -437,6 +495,132 @@ check(title.version and title.version.path
|
||||
== "assets/generated/title/red_version.png",
|
||||
"the shipped version ribbon loads")
|
||||
|
||||
-- issue #128: title SGB zones must resolve Blue's LOGO1 (blue ribbon),
|
||||
-- not Red's, when the ROM pack carries Blue SuperPalettes. Color 0 is
|
||||
-- forced to pure white so ROM's {255,239,255} does not paint a pink band
|
||||
-- against GBC-pack LOGO2/MEWMON whites.
|
||||
do
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local prevVer, prevMode = GameVersion.get(), PaletteFX.mode
|
||||
local blueLogo1 = {
|
||||
{ 255, 239, 255 }, { 247, 247, 140 }, { 173, 0, 33 }, { 115, 156, 239 },
|
||||
}
|
||||
local mewmon = {
|
||||
{ 255, 239, 255 }, { 247, 181, 140 }, { 132, 115, 156 }, { 25, 16, 16 },
|
||||
}
|
||||
local logo2 = {
|
||||
{ 255, 239, 255 }, { 247, 247, 140 }, { 148, 148, 197 }, { 58, 58, 132 },
|
||||
}
|
||||
local game = { data = { palettes = { palettes = {
|
||||
LOGO1 = blueLogo1, LOGO2 = logo2, MEWMON = mewmon,
|
||||
} } } }
|
||||
PaletteFX.setMode("redpp")
|
||||
GameVersion.set("blue")
|
||||
local zones = TitleState.sgbPalettes(title, game)
|
||||
local ribbon = zones and zones[2] and zones[2].colors
|
||||
check(ribbon and ribbon[4][1] == 115 and ribbon[4][3] == 239,
|
||||
"Blue title ribbon zone keeps ROM LOGO1 blue ink under RED++")
|
||||
check(ribbon and ribbon[1][1] == 255 and ribbon[1][2] == 255
|
||||
and ribbon[1][3] == 255,
|
||||
"Blue title ribbon white is pure (no pink SGB band)")
|
||||
GameVersion.set("red")
|
||||
zones = TitleState.sgbPalettes(title, game)
|
||||
local gbcLogo1 = PaletteFX.gbcPack().palettes.LOGO1
|
||||
ribbon = zones and zones[2] and zones[2].colors
|
||||
check(ribbon and ribbon[4][1] == gbcLogo1[4][1]
|
||||
and ribbon[4][2] == gbcLogo1[4][2]
|
||||
and ribbon[4][3] == gbcLogo1[4][3],
|
||||
"Red title under RED++ keeps gbc-pack LOGO1 ink even if ROM has Blue's")
|
||||
GameVersion.set(prevVer)
|
||||
PaletteFX.setMode(prevMode)
|
||||
end
|
||||
|
||||
-- issue #133: title menu / continue overlays must not inherit LOGO2/LOGO1
|
||||
-- (blue/red UI ink). A trailing trueColor zone covers the overlay box.
|
||||
do
|
||||
local logo2 = {
|
||||
{ 255, 255, 255 }, { 230, 197, 0 }, { 148, 156, 148 }, { 41, 99, 181 },
|
||||
}
|
||||
local logo1 = {
|
||||
{ 255, 255, 255 }, { 247, 247, 140 }, { 140, 189, 82 }, { 173, 0, 33 },
|
||||
}
|
||||
local mewmon = {
|
||||
{ 255, 239, 255 }, { 247, 181, 140 }, { 132, 115, 156 }, { 25, 16, 16 },
|
||||
}
|
||||
local game = {
|
||||
data = { palettes = { palettes = {
|
||||
LOGO1 = logo1, LOGO2 = logo2, MEWMON = mewmon,
|
||||
} } },
|
||||
stack = newStack(),
|
||||
}
|
||||
local bare = TitleState.sgbPalettes(title, game)
|
||||
check(bare and #bare == 3 and bare[1].colors == logo2,
|
||||
"bare title keeps three LOGO/MEWMON zones")
|
||||
check(not bare[4], "bare title has no overlay trueColor zone")
|
||||
|
||||
game.stack:push(title)
|
||||
-- openMenu needs SaveData/hasSave; stub a no-save menu via the same stamp
|
||||
local Menu = require("src.ui.Menu")
|
||||
local menu = Menu.new(game, { { label = "NEW GAME" } },
|
||||
{ tx = 0, ty = 0, tw = 13, th = 4 })
|
||||
menu.titleUiBox = { 0, 0, 12, 3 }
|
||||
game.stack:push(menu)
|
||||
local withMenu = TitleState.sgbPalettes(title, game)
|
||||
check(withMenu and #withMenu == 4 and withMenu[4].colors == false,
|
||||
"title menu adds a trueColor overlay zone")
|
||||
check(withMenu[4].x == 0 and withMenu[4].y == 0
|
||||
and withMenu[4].w == 13 * 8 and withMenu[4].h == 4 * 8,
|
||||
"menu overlay covers the CONTINUE/NEW GAME box")
|
||||
|
||||
game.stack:pop()
|
||||
game.stack:push({ titleUiBox = { 4, 7, 19, 16 } })
|
||||
local withCont = TitleState.sgbPalettes(title, game)
|
||||
check(withCont and #withCont == 4 and withCont[4].colors == false,
|
||||
"continue-info overlay adds a trueColor zone")
|
||||
check(withCont[4].x == 4 * 8 and withCont[4].y == 7 * 8
|
||||
and withCont[4].w == 16 * 8 and withCont[4].h == 10 * 8,
|
||||
"continue overlay matches DisplayContinueGameInfo's box")
|
||||
|
||||
-- openMenu itself must stamp titleUiBox on the real Menu it pushes
|
||||
while game.stack:top() do game.stack:pop() end
|
||||
title.game = game
|
||||
title:openMenu()
|
||||
local opened = game.stack:top()
|
||||
check(opened and opened.titleUiBox
|
||||
and opened.titleUiBox[1] == 0 and opened.titleUiBox[3] == 12,
|
||||
"openMenu stamps titleUiBox on the pushed Menu")
|
||||
end
|
||||
|
||||
-- Options / mod manager opened from the title must not inherit LOGO1
|
||||
-- (third options box = rows 8-9 would otherwise tint pink).
|
||||
do
|
||||
local OptionsMenu = require("src.ui.OptionsMenu")
|
||||
local ManagerState = require("src.mods.ManagerState")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local mewmon = {
|
||||
{ 255, 255, 255 }, { 239, 156, 107 }, { 115, 33, 165 }, { 0, 0, 0 },
|
||||
}
|
||||
local game = { data = { palettes = { palettes = { MEWMON = mewmon } } },
|
||||
save = { options = {} } }
|
||||
local optZones = OptionsMenu.sgbPalettes(OptionsMenu, game)
|
||||
check(optZones and #optZones == 1 and optZones[1].colors == mewmon,
|
||||
"OptionsMenu owns a whole-screen MEWMON zone")
|
||||
local modZones = ManagerState.sgbPalettes(ManagerState, game)
|
||||
check(modZones and #modZones == 1 and modZones[1].colors == mewmon,
|
||||
"ManagerState owns a whole-screen MEWMON zone")
|
||||
-- stack walk: with Options on top of Title, Game would pick Options
|
||||
local titleZones = TitleState.sgbPalettes(title, {
|
||||
data = { palettes = { palettes = {
|
||||
LOGO1 = { { 255, 239, 255 }, { 1, 2, 3 }, { 4, 5, 6 }, { 115, 156, 239 } },
|
||||
LOGO2 = mewmon, MEWMON = mewmon,
|
||||
} } },
|
||||
})
|
||||
check(titleZones and titleZones[2].colors[1][2] == 255,
|
||||
"title LOGO1 sanitize still pure-white with pink ROM input")
|
||||
check(PaletteFX.wholeNamed, "PaletteFX.wholeNamed still available for menus")
|
||||
end
|
||||
|
||||
local OakSpeech = require("src.ui.OakSpeech")
|
||||
local ogame = { data = {
|
||||
field = { oakSpeech = { music = "X_Song", demoSpecies = "PIKACHU" } },
|
||||
|
||||
@@ -338,6 +338,15 @@ do
|
||||
end
|
||||
check(pewterNpc and ow:trainerDefeated(pewterNpc),
|
||||
"unfought Pewter gym trainer is defeated after badge")
|
||||
-- PewterGym.asm .gymVictory HideObject TOGGLE_GYM_GUY /
|
||||
-- TOGGLE_ROUTE_22_RIVAL_1 (persists via objectToggles)
|
||||
check(Game.save.objectToggles
|
||||
and Game.save.objectToggles.PEWTER_CITY
|
||||
and Game.save.objectToggles.PEWTER_CITY.PEWTERCITY_YOUNGSTER == false,
|
||||
"Brock victory hides PEWTERCITY_YOUNGSTER")
|
||||
check(Game.save.objectToggles.ROUTE_22
|
||||
and Game.save.objectToggles.ROUTE_22.ROUTE22_RIVAL1 == false,
|
||||
"Brock victory hides ROUTE22_RIVAL1")
|
||||
|
||||
while Game.stack:top() do Game.stack:pop() end
|
||||
Game.save = SaveData.newGame()
|
||||
|
||||
+49
-1
@@ -21,12 +21,17 @@ check(hof and type(hof.onEnter) == "function", "HALL_OF_FAME.onEnter is a functi
|
||||
|
||||
local champ = init.get("CHAMPIONS_ROOM")
|
||||
check(champ ~= nil, "CHAMPIONS_ROOM map script registered")
|
||||
check(champ and type(champ.onEnter) == "function",
|
||||
"CHAMPIONS_ROOM.onEnter is a function (forced rival entrance)")
|
||||
local rows = champ and champ.talk and champ.talk.TEXT_CHAMPIONSROOM_RIVAL
|
||||
check(type(rows) == "table", "CHAMPIONS_ROOM.talk.TEXT_CHAMPIONSROOM_RIVAL exists")
|
||||
|
||||
-- (2) the rival cutscene rows contain the pokered beats in order
|
||||
rows = rows or {}
|
||||
local preds = {
|
||||
{ "play_music Music_Cities1 (OakArrives / Music_Cities1AlternateTempo)",
|
||||
function(r) return r[1] == "play_music" and r[2] == "Music_Cities1"
|
||||
and type(r[3]) == "table" and r[3].keep == true end },
|
||||
{ "show_object CHAMPIONSROOM_OAK",
|
||||
function(r) return r[1] == "show_object" and r[3] == "CHAMPIONSROOM_OAK" end },
|
||||
{ "move_npc(2,'up',5) OakEntranceAfterVictoryMovement",
|
||||
@@ -82,7 +87,13 @@ check(champToHof, "CHAMPIONS_ROOM has a warp up into HALL_OF_FAME")
|
||||
-- (5) functional: HALL_OF_FAME.onEnter consumes the one-shot marker and
|
||||
-- queues (does not directly run) the room cutscene.
|
||||
local queued
|
||||
local fakeOw = { queueScript = function(self, script, extra) queued = script; self.pendingScript = { script = script } end }
|
||||
local fakeOw = {
|
||||
map = { def = { signs = {} }, widthCells = 10, signAt = {} },
|
||||
queueScript = function(self, script, extra)
|
||||
queued = script
|
||||
self.pendingScript = { script = script }
|
||||
end,
|
||||
}
|
||||
local fakeGame = { save = { pendingHallOfFame = true } }
|
||||
hof.onEnter(fakeGame, fakeOw)
|
||||
check(queued ~= nil, "HALL_OF_FAME.onEnter queues a cutscene script when marker set")
|
||||
@@ -102,4 +113,41 @@ fakeGame.save.pendingHallOfFame = false
|
||||
hof.onEnter(fakeGame, fakeOw)
|
||||
check(queued == nil, "HALL_OF_FAME.onEnter does not replay once the marker is consumed")
|
||||
|
||||
-- (6) Champions Room forced entrance (ChampionsRoomPlayerEntersScript):
|
||||
-- from Lance (y=7), queue RivalEntrance walk then the rival battle script.
|
||||
local champQueued = {}
|
||||
local champOw = {
|
||||
player = { cellX = 3, cellY = 7 },
|
||||
npcs = { { def = { name = "CHAMPIONSROOM_RIVAL" } } },
|
||||
queueScript = function(_, script, extra)
|
||||
champQueued[#champQueued + 1] = { script = script, extra = extra }
|
||||
end,
|
||||
}
|
||||
local champGame = { save = { flags = {} } }
|
||||
champ.onEnter(champGame, champOw)
|
||||
eq(#champQueued, 2, "CHAMPIONS_ROOM.onEnter queues entrance walk + rival script")
|
||||
local walk = champQueued[1] and champQueued[1].script
|
||||
check(walk and walk[1][1] == "move_player" and walk[1][2] == "up" and walk[1][3] == 1
|
||||
and walk[2][1] == "move_player" and walk[2][2] == "right" and walk[2][3] == 1
|
||||
and walk[3][1] == "move_player" and walk[3][2] == "up" and walk[3][3] == 3,
|
||||
"entrance walk is RivalEntrance_RLEMovement (up 1, right 1, up 3)")
|
||||
check(champQueued[2] and champQueued[2].script == rows,
|
||||
"second queue is TEXT_CHAMPIONSROOM_RIVAL script rows")
|
||||
check(champQueued[2] and champQueued[2].extra
|
||||
and champQueued[2].extra.npc == champOw.npcs[1],
|
||||
"rival script receives the CHAMPIONSROOM_RIVAL npc")
|
||||
|
||||
-- already beaten this run: no forced entrance
|
||||
champQueued = {}
|
||||
champGame.save.flags.EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN = true
|
||||
champ.onEnter(champGame, champOw)
|
||||
eq(#champQueued, 0, "CHAMPIONS_ROOM.onEnter idle after champion beaten this run")
|
||||
|
||||
-- Hall of Fame return (y=0) must not re-trigger even if the run flag is clear
|
||||
champQueued = {}
|
||||
champGame.save.flags.EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN = nil
|
||||
champOw.player.cellY = 0
|
||||
champ.onEnter(champGame, champOw)
|
||||
eq(#champQueued, 0, "CHAMPIONS_ROOM.onEnter ignores Hall of Fame return landing")
|
||||
|
||||
S.finish()
|
||||
|
||||
+64
-6
@@ -55,9 +55,27 @@ local function newStack()
|
||||
return stack, items
|
||||
end
|
||||
|
||||
-- snapshot / restore car warps so tests that seed or ride do not leak
|
||||
-- across cases (Silph's ROM default is UNUSED_MAP_ED)
|
||||
local function snapshotWarps(mapId)
|
||||
local snap = {}
|
||||
for i, w in ipairs(Data.maps[mapId].warps) do
|
||||
snap[i] = { destMap = w.destMap, destWarp = w.destWarp }
|
||||
end
|
||||
return snap
|
||||
end
|
||||
|
||||
local function restoreWarps(mapId, snap)
|
||||
for i, w in ipairs(Data.maps[mapId].warps) do
|
||||
w.destMap, w.destWarp = snap[i].destMap, snap[i].destWarp
|
||||
end
|
||||
end
|
||||
|
||||
-- drives one elevator map's onEnter and returns the pushed state (either a
|
||||
-- ListMenu or, for the keyGated Rocket Hideout without the key, a TextBox)
|
||||
local function openElevator(mapId, inventory)
|
||||
-- ListMenu or, for the keyGated Rocket Hideout without the key, a TextBox).
|
||||
-- fromMapId is the floor the player entered from (OverworldState:setMap
|
||||
-- passes it), used to seed a cancel-safe walk-out destination.
|
||||
local function openElevator(mapId, inventory, fromMapId)
|
||||
local script = mapScripts.get(mapId)
|
||||
check(script ~= nil, mapId .. " script registered")
|
||||
check(script and script.onEnter ~= nil, mapId .. " has onEnter")
|
||||
@@ -98,7 +116,7 @@ local function openElevator(mapId, inventory)
|
||||
stack = stack,
|
||||
}
|
||||
sfxCalls = {}
|
||||
script.onEnter(game, ow)
|
||||
script.onEnter(game, ow, fromMapId)
|
||||
return items[#items], warpCalls, stack, ow
|
||||
end
|
||||
|
||||
@@ -130,7 +148,14 @@ end
|
||||
-- Silph Co elevator: 11 floors, the double-digit sort/label regression
|
||||
-- ===================================================================
|
||||
do
|
||||
local menu, warpCalls, stack, ow = openElevator("SILPH_CO_ELEVATOR")
|
||||
local silphSnap = snapshotWarps("SILPH_CO_ELEVATOR")
|
||||
-- ROM default (UNUSED_MAP_ED) must still be what we start from so the
|
||||
-- cancel-then-exit regression below is real
|
||||
eq(silphSnap[1].destMap, "UNUSED_MAP_ED",
|
||||
"Silph Co ROM car warps still default to UNUSED_MAP_ED")
|
||||
|
||||
local menu, warpCalls, stack, ow =
|
||||
openElevator("SILPH_CO_ELEVATOR", nil, "SILPH_CO_5F")
|
||||
check(menu ~= nil and getmetatable(menu) == ListMenu, "SILPH_CO_ELEVATOR opens a ListMenu")
|
||||
if menu then
|
||||
eq(#menu.items, 11, "Silph Co elevator lists all 11 floors")
|
||||
@@ -143,12 +168,32 @@ do
|
||||
check(menu.items[1].label ~= "SILPH CO 1F" and not menu.items[1].label:find("SILPH"),
|
||||
"Silph Co floor label is the short token, not the full map id")
|
||||
|
||||
-- onEnter seeds the car's exit to the floor we came from so a B-cancel
|
||||
-- cannot leave UNUSED_MAP_ED in place (#123 hard crash on walk-out)
|
||||
eq(ow.map.def.warps[1].destMap, "SILPH_CO_5F",
|
||||
"Silph onEnter seeds exit warps to the entry floor")
|
||||
check(Data.maps[ow.map.def.warps[1].destMap] ~= nil,
|
||||
"seeded Silph exit map exists in Data.maps")
|
||||
|
||||
-- Cancel: pokered's DisplayElevatorFloorMenu does `ret c` on B --
|
||||
-- no warp at all.
|
||||
clear(warpCalls)
|
||||
menu.onCancel()
|
||||
eq(#warpCalls, 0, "Cancel does not warp (bare ret c, no floors[1] fallback)")
|
||||
|
||||
-- #123: after B-cancel, walking out of the car must resolve without
|
||||
-- asserting on the ROM placeholder UNUSED_MAP_ED
|
||||
clear(warpCalls)
|
||||
local okExit, errExit = pcall(function()
|
||||
ow:takeWarp(ow.map.def.warps[1])
|
||||
end)
|
||||
check(okExit, "cancel then exit does not crash: " .. tostring(errExit))
|
||||
eq(#warpCalls, 1, "cancel then exit takes the seeded entry-floor warp")
|
||||
if warpCalls[1] then
|
||||
eq(warpCalls[1].map, "SILPH_CO_5F",
|
||||
"cancel then exit returns to the floor the player entered from")
|
||||
end
|
||||
|
||||
-- Choose a mid-list floor (5F): pokered never warps on the spot --
|
||||
-- DisplayElevatorFloorMenu sets BIT_CUR_MAP_USED_ELEVATOR and the
|
||||
-- map script runs ShakeElevator: a 12-frame lead-in (the script's
|
||||
@@ -157,6 +202,7 @@ do
|
||||
-- cycle, then SFX_SAFARI_ZONE_PA, and only then the floor warp.
|
||||
clear(warpCalls)
|
||||
sfxCalls = {}
|
||||
ow.walkSteps = {}
|
||||
local chosen = menu.items[5] -- "5F"
|
||||
menu.onChoose(chosen, menu)
|
||||
eq(#warpCalls, 0, "choosing a floor does not warp on the spot (the shake runs first)")
|
||||
@@ -184,6 +230,7 @@ do
|
||||
eq(warpCalls[1].y, chosen.value.y, "walk-out lands on the chosen floor's y")
|
||||
end
|
||||
end
|
||||
restoreWarps("SILPH_CO_ELEVATOR", silphSnap)
|
||||
end
|
||||
|
||||
-- ===================================================================
|
||||
@@ -234,11 +281,21 @@ end
|
||||
-- the digit only), plus the LIFT_KEY gate.
|
||||
-- ===================================================================
|
||||
do
|
||||
-- without the key: text-only, no floor menu
|
||||
local gated = openElevator("ROCKET_HIDEOUT_ELEVATOR", {})
|
||||
local hideoutSnap = snapshotWarps("ROCKET_HIDEOUT_ELEVATOR")
|
||||
-- ROM car defaults to B1F; entering from another floor without the key
|
||||
-- must still seed a walk-out back to that floor (#90 / #105).
|
||||
eq(hideoutSnap[1].destMap, "ROCKET_HIDEOUT_B1F",
|
||||
"Rocket Hideout ROM car warps still default to B1F")
|
||||
|
||||
-- without the key: text-only, no floor menu, but exit is seeded
|
||||
local gated, _, _, gatedOw =
|
||||
openElevator("ROCKET_HIDEOUT_ELEVATOR", {}, "ROCKET_HIDEOUT_B2F")
|
||||
check(gated ~= nil, "Rocket Hideout without LIFT_KEY still pushes something")
|
||||
check(gated ~= nil and getmetatable(gated) ~= ListMenu,
|
||||
"Rocket Hideout without LIFT_KEY does not open the floor menu")
|
||||
eq(gatedOw.map.def.warps[1].destMap, "ROCKET_HIDEOUT_B2F",
|
||||
"Rocket Hideout without LIFT_KEY seeds exit warps to the entry floor")
|
||||
restoreWarps("ROCKET_HIDEOUT_ELEVATOR", hideoutSnap)
|
||||
|
||||
-- with the key: full B1F/B2F/B4F menu, same as the other elevators
|
||||
local menu, warpCalls, stack, ow = openElevator("ROCKET_HIDEOUT_ELEVATOR", { LIFT_KEY = 1 })
|
||||
@@ -277,6 +334,7 @@ do
|
||||
eq(warpCalls[1].y, chosen.value.y, "Rocket Hideout walk-out lands on the chosen floor y")
|
||||
end
|
||||
end
|
||||
restoreWarps("ROCKET_HIDEOUT_ELEVATOR", hideoutSnap)
|
||||
end
|
||||
|
||||
Sound.play = origSoundPlay
|
||||
|
||||
+62
-6
@@ -7,6 +7,7 @@
|
||||
-- over later, at TEXT_OAKSLAB_OAK1's .give_poke_balls beat, gated on
|
||||
-- EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE and the one-shot
|
||||
-- EVENT_GOT_POKEBALLS_FROM_OAK flag (data/scripts/oaks_lab.lua).
|
||||
-- Also #137: starter give_pokemon runs AskName (nickname yes/no).
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
local Data = require("src.core.Data")
|
||||
@@ -21,6 +22,8 @@ local StateStack = require("src.core.StateStack")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local ScriptRunner = require("src.script.ScriptRunner")
|
||||
local Flags = require("src.script.Flags")
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
local NamingScreen = require("src.ui.NamingScreen")
|
||||
local mapScripts = require("data.scripts.init")
|
||||
|
||||
Game.data = Data
|
||||
@@ -29,16 +32,15 @@ Game.stack = StateStack; StateStack:init()
|
||||
Game.save = SaveData.newGame()
|
||||
require("src.render.Font").load(Data)
|
||||
|
||||
-- pumps a script coroutine to completion, mashing A through any
|
||||
-- show_text/ask boxes along the way (mirrors tests/run_tests.lua's
|
||||
-- runScript helper for the parcel/pokedex chain)
|
||||
local function runScript(script)
|
||||
-- pumps a script coroutine to completion; pressFn returns the Input.pressed
|
||||
-- table for this frame (default: mash A through text/ask/naming)
|
||||
local function runScript(script, pressFn)
|
||||
local r = ScriptRunner.new(Game, nil)
|
||||
r:run(script, {})
|
||||
local guard = 0
|
||||
while r:isRunning() and guard < 2000 do
|
||||
while r:isRunning() and guard < 4000 do
|
||||
guard = guard + 1
|
||||
Input.pressed = { a = true }
|
||||
Input.pressed = pressFn and pressFn() or { a = true }
|
||||
StateStack:update(1 / 60)
|
||||
r:update()
|
||||
end
|
||||
@@ -58,6 +60,11 @@ check(runScript(mapScripts.talkScript("OAKS_LAB", "TEXT_OAKSLAB_BULBASAUR_POKE_B
|
||||
"starter pick script completes")
|
||||
check(Flags.get(Game.save, "EVENT_GOT_STARTER"), "starter flag set")
|
||||
eq(Game.save.inventory.POKE_BALL, nil, "no POKe BALLs yet right after picking a starter")
|
||||
check(Game.save.party[1] and Game.save.party[1].species == "BULBASAUR",
|
||||
"starter joined the party")
|
||||
-- A-mash accepts the nickname prompt and fills NamingScreen with A's
|
||||
check(Game.save.party[1].nickname == "AAAAAAAAAA",
|
||||
"starter nickname prompt accepted (AskName / #137)")
|
||||
|
||||
-- === 2) the parcel/pokedex beat still doesn't grant POKé BALLs ===
|
||||
check(runScript(mapScripts.talkScript("VIRIDIAN_MART", "TEXT_VIRIDIANMART_CLERK")),
|
||||
@@ -69,6 +76,12 @@ check(runScript(mapScripts.talkScript("OAKS_LAB", "TEXT_OAKSLAB_OAK1")),
|
||||
eq(Game.save.inventory.OAKS_PARCEL, nil, "parcel delivered")
|
||||
check(Flags.get(Game.save, "EVENT_OAK_GOT_PARCEL"), "delivery flag set")
|
||||
check(Flags.get(Game.save, "EVENT_GOT_POKEDEX"), "Pokedex flag set")
|
||||
-- OaksLab.asm OakGivesPokedex: HideObject TOGGLE_POKEDEX_1/2 (#106)
|
||||
local labToggles = Game.save.objectToggles and Game.save.objectToggles.OAKS_LAB
|
||||
check(labToggles and labToggles.OAKSLAB_POKEDEX1 == false,
|
||||
"POKEDEX1 hidden after receiving Pokédex")
|
||||
check(labToggles and labToggles.OAKSLAB_POKEDEX2 == false,
|
||||
"POKEDEX2 hidden after receiving Pokédex")
|
||||
eq(Game.save.inventory.POKE_BALL, nil, "still no POKe BALLs at the pokedex beat")
|
||||
|
||||
-- talking to Oak again before beating the Route 22 rival should fall
|
||||
@@ -89,4 +102,47 @@ check(runScript(mapScripts.talkScript("OAKS_LAB", "TEXT_OAKSLAB_OAK1")),
|
||||
"Oak talk (post-grant) script completes")
|
||||
eq(Game.save.inventory.POKE_BALL, 5, "POKe Ball count unchanged on a second talk")
|
||||
|
||||
-- === 5) onEnter re-hides table Pokédex for pre-#106 saves ===
|
||||
Game.save.objectToggles = { OAKS_LAB = {
|
||||
OAKSLAB_POKEDEX1 = true, OAKSLAB_POKEDEX2 = true,
|
||||
} }
|
||||
local oaksLab = mapScripts.get("OAKS_LAB")
|
||||
check(oaksLab and type(oaksLab.onEnter) == "function", "OAKS_LAB onEnter present")
|
||||
oaksLab.onEnter(Game, nil)
|
||||
local repaired = Game.save.objectToggles.OAKS_LAB
|
||||
check(repaired.OAKSLAB_POKEDEX1 == false
|
||||
and repaired.OAKSLAB_POKEDEX2 == false,
|
||||
"onEnter hides both Pokédex table sprites when EVENT_GOT_POKEDEX is set")
|
||||
|
||||
-- === 6) #137: declining the starter nickname leaves no nickname ===
|
||||
-- Choice boxes in order: (1) "you want X?" YES, (2) nickname YES/NO -> NO
|
||||
Game.save = SaveData.newGame()
|
||||
Flags.set(Game.save, "EVENT_FOLLOWED_OAK_INTO_LAB")
|
||||
local choicesSeen, lastChoice = 0, nil
|
||||
local function declineNickname()
|
||||
local top = StateStack:top()
|
||||
local mt = getmetatable(top)
|
||||
if mt == ChoiceBox and top ~= lastChoice then
|
||||
choicesSeen = choicesSeen + 1
|
||||
lastChoice = top
|
||||
elseif mt ~= ChoiceBox then
|
||||
lastChoice = nil
|
||||
end
|
||||
if mt == ChoiceBox and choicesSeen >= 2 then
|
||||
return { b = true }
|
||||
end
|
||||
if mt == NamingScreen then
|
||||
return { start = true }
|
||||
end
|
||||
return { a = true }
|
||||
end
|
||||
check(runScript(mapScripts.talkScript("OAKS_LAB", "TEXT_OAKSLAB_CHARMANDER_POKE_BALL"),
|
||||
declineNickname),
|
||||
"starter pick with declined nickname completes")
|
||||
check(Flags.get(Game.save, "EVENT_GOT_STARTER"), "declined-nickname path still sets starter flag")
|
||||
eq(Game.save.party[1] and Game.save.party[1].species, "CHARMANDER",
|
||||
"declined-nickname path still gives Charmander")
|
||||
eq(Game.save.party[1] and Game.save.party[1].nickname, nil,
|
||||
"declining nickname leaves the species name")
|
||||
|
||||
S.finish()
|
||||
|
||||
@@ -187,4 +187,55 @@ check(OW.objectVisible(Game.save, "SEAFOAM_ISLANDS_B4F",
|
||||
objOf("SEAFOAM_ISLANDS_B4F", "SEAFOAMISLANDSB4F_BOULDER2")),
|
||||
"SEAFOAMISLANDSB4F_BOULDER2 is visible at the end state")
|
||||
|
||||
-- === issue #129: B3F current auto-warps via BIT_FORCED_WARP ===
|
||||
-- South-edge stairs at (20,17) are water warps (not door/warp tiles), so
|
||||
-- CheckWarpsNoCollision needs BIT_FORCED_WARP (or a held d-pad). B3F
|
||||
-- currents set that bit; after the RLE lands, the warp must fire without
|
||||
-- further player input.
|
||||
do
|
||||
local FieldDefaults = require("src.world.FieldDefaults")
|
||||
check(FieldDefaults.fieldValue(Data, "seafoam", "SEAFOAM_ISLANDS_B3F",
|
||||
"setsForcedWarp") == true,
|
||||
"B3F seafoam setsForcedWarp (BIT_FORCED_WARP) is configured")
|
||||
|
||||
while Game.stack:top() do Game.stack:pop() end
|
||||
Game.save = SaveData.newGame()
|
||||
Game.save.flags = {}
|
||||
-- Landing on the B3F force-surf mouth mounts SURF and arms the current
|
||||
-- (EnterMap CheckForceBikeOrSurf + Seafoam MOVE_OBJECT), including
|
||||
-- BIT_FORCED_WARP for the south-edge stairs.
|
||||
Game.stack:push(OW, "SEAFOAM_ISLANDS_B3F", 18, 7, "down")
|
||||
local cur = Game.stack:top()
|
||||
check(cur.player.surfing == true,
|
||||
"B3F force-surf mouth mounts SURF on map entry")
|
||||
check(cur.forcedWarp == true,
|
||||
"B3F current arms forcedWarp (BIT_FORCED_WARP)")
|
||||
check(#cur.scriptMoves > 0 or cur.player.moving,
|
||||
"unplugged B3F current at (18,7) starts forced surfing RLE")
|
||||
|
||||
-- Fast-forward the scripted current onto the south-edge warp.
|
||||
local guard = 0
|
||||
while guard < 2000 do
|
||||
guard = guard + 1
|
||||
StateStack:update(1 / 60)
|
||||
if cur.map and cur.map.id ~= "SEAFOAM_ISLANDS_B3F" then break end
|
||||
if cur.transitioning then break end
|
||||
end
|
||||
-- Drain the warp Transition onto B4F (and any immediate forcedExit shove).
|
||||
guard = 0
|
||||
while guard < 400 do
|
||||
guard = guard + 1
|
||||
StateStack:update(1 / 60)
|
||||
if Game.stack:top() == cur and cur.map and cur.map.id == "SEAFOAM_ISLANDS_B4F"
|
||||
and not cur.transitioning and #cur.scriptMoves == 0
|
||||
and not cur.player.moving then
|
||||
break
|
||||
end
|
||||
end
|
||||
check(cur.map and cur.map.id == "SEAFOAM_ISLANDS_B4F",
|
||||
"B3F current auto-warps to B4F without a held d-pad (#129)")
|
||||
check(cur.player.cellX == 20 or cur.player.cellX == 21,
|
||||
"forced current lands on the B4F stair-warp column")
|
||||
end
|
||||
|
||||
S.finish()
|
||||
|
||||
@@ -408,6 +408,99 @@ check(strFlash ~= page2 and strFlash ~= ow and strFlash.pages == nil,
|
||||
for _ = 1, 10 do frame({}) end
|
||||
eq(Game.stack:top(), ow, "back on the map after the blink")
|
||||
|
||||
-- ===========================================================================
|
||||
-- #107: fainted Pokémon can still use field moves (party submenu + partyKnows)
|
||||
-- ===========================================================================
|
||||
local fainted = mkMon("PIDGEOTTO", "FLY", "CUT", "STRENGTH", "SURF")
|
||||
fainted.hp = 0
|
||||
Game.save.party = { fainted }
|
||||
Game.save.inventory = {
|
||||
THUNDERBADGE = true, CASCADEBADGE = true,
|
||||
RAINBOWBADGE = true, SOULBADGE = true,
|
||||
}
|
||||
ow = pushOW("PALLET_TOWN", 4, 13, "down")
|
||||
check(ow:partyKnows("SURF") == fainted,
|
||||
"partyKnows finds SURF on a fainted mon")
|
||||
check(ow:partyKnows("CUT") == fainted,
|
||||
"partyKnows finds CUT on a fainted mon")
|
||||
local pmFaint = PartyMenu.new(Game)
|
||||
Game.stack:push(pmFaint)
|
||||
frame({ "a" })
|
||||
local actsFaint = submenuActions(pmFaint)
|
||||
-- FLY needs OVERWORLD tileset (Pallet); CUT/SURF/STRENGTH need badges only
|
||||
check(actsFaint.fly and actsFaint.cut and actsFaint.surf and actsFaint.strength,
|
||||
"fainted mon still lists FLY/CUT/SURF/STRENGTH in the party submenu")
|
||||
popToOW()
|
||||
|
||||
-- ===========================================================================
|
||||
-- #83: FLY/TELEPORT use CheckIfInOutsideMap (OVERWORLD + PLATEAU), so the
|
||||
-- outdoor strip between Victory Road and the Elite Four / Indigo Plateau
|
||||
-- building allows Fly like any other outdoor overworld map.
|
||||
-- ===========================================================================
|
||||
Game.save.party = { mkMon("AERODACTYL", "FLY", "TELEPORT") }
|
||||
Game.save.inventory = { THUNDERBADGE = true }
|
||||
ow = pushOW("INDIGO_PLATEAU", 10, 5, "down")
|
||||
eq(ow.map.def.tileset, "PLATEAU", "Indigo Plateau outdoor uses PLATEAU tileset")
|
||||
local pmIndigo = PartyMenu.new(Game)
|
||||
Game.stack:push(pmIndigo)
|
||||
frame({ "a" })
|
||||
local actsIndigo = submenuActions(pmIndigo)
|
||||
check(actsIndigo.fly, "FLY listed on Indigo Plateau outdoor (PLATEAU)")
|
||||
check(actsIndigo.escape, "TELEPORT listed on Indigo Plateau outdoor (PLATEAU)")
|
||||
popToOW()
|
||||
|
||||
ow = pushOW("ROUTE_23", 10, 6, "down")
|
||||
eq(ow.map.def.tileset, "PLATEAU", "Route 23 uses PLATEAU tileset")
|
||||
local pmR23 = PartyMenu.new(Game)
|
||||
Game.stack:push(pmR23)
|
||||
frame({ "a" })
|
||||
check(submenuActions(pmR23).fly, "FLY listed on Route 23 (PLATEAU)")
|
||||
popToOW()
|
||||
|
||||
-- Indoors at the lobby still blocks FLY/TELEPORT (MART tileset)
|
||||
ow = pushOW("INDIGO_PLATEAU_LOBBY", 7, 8, "down")
|
||||
check(ow.map.def.tileset ~= "OVERWORLD" and ow.map.def.tileset ~= "PLATEAU",
|
||||
"Indigo lobby is not an outside tileset")
|
||||
local pmLobby = PartyMenu.new(Game)
|
||||
Game.stack:push(pmLobby)
|
||||
frame({ "a" })
|
||||
local actsLobby = submenuActions(pmLobby)
|
||||
check(not actsLobby.fly, "FLY omitted inside Indigo Plateau lobby")
|
||||
check(not actsLobby.escape, "TELEPORT omitted inside Indigo Plateau lobby")
|
||||
popToOW()
|
||||
|
||||
-- restore fainted field-move mon for the STRENGTH/SURF cases below
|
||||
Game.save.party = { fainted }
|
||||
Game.save.inventory = {
|
||||
THUNDERBADGE = true, CASCADEBADGE = true,
|
||||
RAINBOWBADGE = true, SOULBADGE = true,
|
||||
}
|
||||
|
||||
-- STRENGTH activation from a fainted user (name text + strengthActive)
|
||||
ow = pushOW("SEAFOAM_ISLANDS_1F", 17, 10, "right")
|
||||
clearCaptured()
|
||||
local pmFaintStr = PartyMenu.new(Game)
|
||||
-- Seafoam is not OVERWORLD, so FLY is omitted: STATS, SWITCH, CUT, STRENGTH, SURF
|
||||
selectSubItem(pmFaintStr, 4)
|
||||
eq(Game.overworld.strengthActive, true,
|
||||
"fainted mon can activate STRENGTH from the party menu")
|
||||
check(sawText("used") and sawText("STRENGTH"),
|
||||
"fainted STRENGTH still prints _UsedStrengthText")
|
||||
drainText()
|
||||
|
||||
-- SURF mount from a fainted user when facing water
|
||||
Game.save.party = { fainted }
|
||||
Game.save.inventory.SOULBADGE = true
|
||||
ow = pushOW("PALLET_TOWN", 4, 13, "down")
|
||||
ow.player.surfing = false
|
||||
eq(ow:useSurfFieldMove(), "ok", "useSurfFieldMove ok with only a fainted SURF mon")
|
||||
clearCaptured()
|
||||
-- submenu order: STATS, SWITCH, FLY, CUT, STRENGTH, SURF (move order on mon)
|
||||
local pmFaintSurf = PartyMenu.new(Game)
|
||||
selectSubItem(pmFaintSurf, 6)
|
||||
eq(ow.player.surfing, true, "fainted mon can SURF from the party menu")
|
||||
check(not onStack(pmFaintSurf), "party menu closes after fainted SURF")
|
||||
|
||||
-- restore the spied constructor so later dofile'd suites are unaffected
|
||||
TextBox.new = realTextBoxNew
|
||||
popAll()
|
||||
|
||||
+78
-1
@@ -79,6 +79,58 @@ do
|
||||
"the queued trap anim row is attributed to the attacker")
|
||||
end
|
||||
|
||||
-- (2b) Issue #140: being held by Wrap must NOT skip DisplayBattleMenu
|
||||
-- (core.asm:312 then 323-329). FIGHT forces CANNOT_MOVE; AI still
|
||||
-- auto-selects bound. A player switch clears the foe's trap bit
|
||||
-- (SendOutMon core.asm:1761-1762).
|
||||
do
|
||||
local tb = freshBattle()
|
||||
tb.enemy.trappingTurns = 2
|
||||
tb.enemy.trapMove = "WRAP"
|
||||
tb.enemy.trapDamage = 5
|
||||
check(tb:menuLockedAction(tb.player) == nil,
|
||||
"a Wrap victim still gets the battle menu")
|
||||
local fight = tb:fightLockedAction(tb.player)
|
||||
check(fight and fight.special == "bound",
|
||||
"FIGHT while wrapped selects CANNOT_MOVE (bound)")
|
||||
-- wrapper continues only after FIGHT, not by skipping the menu
|
||||
check(tb:menuLockedAction(tb.enemy) == nil,
|
||||
"a Wrap user still gets the battle menu")
|
||||
check(tb:fightLockedAction(tb.enemy) and
|
||||
tb:fightLockedAction(tb.enemy).special == "trapping",
|
||||
"FIGHT while wrapping continues the trap")
|
||||
check(tb:lockedAction(tb.player) and tb:lockedAction(tb.player).special == "bound",
|
||||
"lockedAction still reports bound for AI / callers")
|
||||
end
|
||||
do
|
||||
Game.save.party = {
|
||||
Pokemon.new(Data, "BULBASAUR", 20),
|
||||
Pokemon.new(Data, "SQUIRTLE", 20),
|
||||
}
|
||||
local tb = BattleState.newWild(Game, "EKANS", 10)
|
||||
tb.enemy.trappingTurns = 3
|
||||
tb.enemy.trapMove = "WRAP"
|
||||
tb.enemy.trapDamage = 7
|
||||
local acts = {}
|
||||
function tb:act(fn) acts[#acts + 1] = fn end
|
||||
function tb:actNext(fn) acts[#acts + 1] = fn end
|
||||
function tb:sayNext() end
|
||||
function tb:animNext() end
|
||||
function tb:startGrowIn() end
|
||||
function tb:syncSides() end
|
||||
function tb:markParticipant() end
|
||||
function tb:restoreMimicked() end
|
||||
function tb:executeAction() end
|
||||
function tb:endOfTurn() end
|
||||
function tb:enemyAction() return { special = "bound" } end
|
||||
tb:resolveSwitch(Game.save.party[2])
|
||||
acts[1]() -- send-out clears foe trap
|
||||
eq(tb.enemy.trappingTurns, nil, "player switch clears foe Wrap/Bind/etc.")
|
||||
eq(tb.enemy.trapMove, nil, "player switch clears trapMove")
|
||||
check(tb:fightLockedAction(tb.player) == nil,
|
||||
"switch-in is free to choose a move")
|
||||
end
|
||||
|
||||
-- (3) MIMIC runs MID-move (MimicEffect, effects.asm:1203-1273): the
|
||||
-- move executes, MoveHitTest runs, and only on a hit does the player's
|
||||
-- copy menu open (.letPlayerChooseMove) -- the enemy's Mimic and link
|
||||
@@ -169,10 +221,35 @@ do
|
||||
check(fe.chooser == nil, "enemy Mimic never opens a chooser")
|
||||
eq(tbe.enemy.curMoves[1].id, "SAND_ATTACK",
|
||||
"enemy Mimic copies a random player move immediately")
|
||||
eq(tbe.enemy.curMoves[1].pp, 9, "enemy Mimic also keeps the slot's PP")
|
||||
-- Gen 1 never decrements enemy PP, so the copied move inherits Mimic's
|
||||
-- full remaining PP (still 10). Player Mimic would leave 9.
|
||||
eq(tbe.enemy.curMoves[1].pp, 10, "enemy Mimic keeps full slot PP (no enemy drain)")
|
||||
check(fe.anim and hasText(fe, "learned"),
|
||||
"enemy Mimic still plays the animation and learned text")
|
||||
|
||||
-- === #94: gen1_faithful enemies never deplete PP; player still does ===
|
||||
do
|
||||
local tb = freshBattle()
|
||||
check(tb.ruleset.enemyUnlimitedPP,
|
||||
"default ruleset grants enemy unlimited PP")
|
||||
local enemyMove = { id = "TACKLE", pp = 5 }
|
||||
local playerMove = { id = "TACKLE", pp = 5 }
|
||||
tb.queue, tb.nextInsert = {}, 0
|
||||
tb:performMove(tb.enemy, tb.player, enemyMove)
|
||||
eq(enemyMove.pp, 5, "gen1_faithful: enemy move PP is not decremented")
|
||||
tb.queue, tb.nextInsert = {}, 0
|
||||
tb:performMove(tb.player, tb.enemy, playerMove)
|
||||
eq(playerMove.pp, 4, "gen1_faithful: player move PP still decrements")
|
||||
|
||||
-- modern_clean tracks enemy PP (Gen 2+ style)
|
||||
local modern = require("src.battle.rulesets.modern_clean")
|
||||
tb.ruleset = modern
|
||||
local enemyModern = { id = "TACKLE", pp = 5 }
|
||||
tb.queue, tb.nextInsert = {}, 0
|
||||
tb:performMove(tb.enemy, tb.player, enemyModern)
|
||||
eq(enemyModern.pp, 4, "modern_clean: enemy move PP decrements")
|
||||
end
|
||||
|
||||
-- link battle: the player's Mimic rolls random too (no chooser)
|
||||
local tbl = freshBattle()
|
||||
tbl.kind = "link"
|
||||
|
||||
+31
-3
@@ -115,11 +115,39 @@ do
|
||||
end
|
||||
|
||||
-- === STRUGGLE fallback when nothing is usable ===
|
||||
-- modern_clean / no unlimited flag: empty PP forces Struggle.
|
||||
do
|
||||
local aiMon = { curMoves = { { id = "TACKLE", pp = 0 } } }
|
||||
local pick = TrainerAI.chooseMove(aiMon, rngLo, { enemyAIMods = { 1 }, data = Data,
|
||||
player = { mon = {}, curTypes = {} } })
|
||||
check(pick and pick.struggle and pick.id == "STRUGGLE", "STRUGGLE fallback when no PP")
|
||||
local pick = TrainerAI.chooseMove(aiMon, rngLo, {
|
||||
enemyAIMods = { 1 }, data = Data,
|
||||
player = { mon = {}, curTypes = {} },
|
||||
ruleset = { enemyUnlimitedPP = false },
|
||||
})
|
||||
check(pick and pick.struggle and pick.id == "STRUGGLE",
|
||||
"STRUGGLE fallback when no PP (enemy PP tracked)")
|
||||
end
|
||||
|
||||
-- gen1_faithful: AI ignores PP; a 0-PP move is still selectable.
|
||||
do
|
||||
local aiMon = { curMoves = { { id = "TACKLE", pp = 0 } } }
|
||||
local pick = TrainerAI.chooseMove(aiMon, rngLo, {
|
||||
enemyAIMods = { 1 }, data = Data,
|
||||
player = { mon = {}, curTypes = {} },
|
||||
ruleset = { enemyUnlimitedPP = true },
|
||||
})
|
||||
eq(pick and pick.id, "TACKLE",
|
||||
"gen1_faithful: enemy still picks 0-PP moves (no Struggle)")
|
||||
end
|
||||
|
||||
-- Disable with unlimited PP: sole disabled move still yields Struggle.
|
||||
do
|
||||
local aiMon = { curMoves = { { id = "TACKLE", pp = 10 } }, disabledSlot = 1 }
|
||||
local pick = TrainerAI.chooseMove(aiMon, rngLo, {
|
||||
enemyAIMods = {}, data = Data,
|
||||
ruleset = { enemyUnlimitedPP = true },
|
||||
})
|
||||
check(pick and pick.struggle and pick.id == "STRUGGLE",
|
||||
"gen1_faithful: Struggle only when every move is disabled")
|
||||
end
|
||||
|
||||
-- === switchAction off-by-one fix (matches AISwitchIfEnoughMons cp 2) ===
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user