mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-16 08:11:35 +02:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a2aa9ce637 | |||
| d683be205b | |||
| d2f6735b0a | |||
| 1c4515eaaa | |||
| bfa68e6f7d | |||
| c04b93ce1c | |||
| 2c9970a643 | |||
| 352a9a0eb8 | |||
| 5533785364 | |||
| 11f4f5570e | |||
| 6239387643 | |||
| 4a50cb0728 | |||
| 78aa1f3c0a | |||
| 5128b840b1 | |||
| da14b7ee3f | |||
| 2dbe21babf | |||
| 7a85862602 | |||
| 09b12a7cf0 | |||
| e2f2b432ff | |||
| 8c274d969e | |||
| 4c7633b56e |
@@ -177,8 +177,8 @@ jobs:
|
|||||||
set -a; . "$ci_dir/notary.env"; set +a
|
set -a; . "$ci_dir/notary.env"; set +a
|
||||||
echo "::add-mask::$APPLE_APP_PASSWORD"
|
echo "::add-mask::$APPLE_APP_PASSWORD"
|
||||||
|
|
||||||
app=".bazinga/work/PokemonRed.app"
|
app=".bazinga/work/gen1recomp.app"
|
||||||
zip="dist/mac/PokemonRed-macos.zip"
|
zip="dist/mac/gen1recomp-macos.zip"
|
||||||
[ -d "$app" ] || { echo "::error::signed app not found at $app"; exit 1; }
|
[ -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
|
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."
|
echo "::error::notary.env is missing APPLE_ID / APPLE_APP_PASSWORD / APPLE_TEAM_ID."
|
||||||
@@ -208,12 +208,12 @@ jobs:
|
|||||||
outdir="dist/release"
|
outdir="dist/release"
|
||||||
rm -rf "$outdir"
|
rm -rf "$outdir"
|
||||||
mkdir -p "$outdir"
|
mkdir -p "$outdir"
|
||||||
cp "dist/mac/PokemonRed-macos.zip" "$outdir/PokemonRed-${v}-macos.zip"
|
cp "dist/mac/gen1recomp-macos.zip" "$outdir/gen1recomp-${v}-macos.zip"
|
||||||
cp "dist/win/PokemonRed-win64.zip" "$outdir/PokemonRed-${v}-windows.zip"
|
cp "dist/win/gen1recomp-win64.zip" "$outdir/gen1recomp-${v}-windows.zip"
|
||||||
cp "dist/linux/PokemonRed-linux.zip" "$outdir/PokemonRed-${v}-linux.zip"
|
cp "dist/linux/gen1recomp-linux.zip" "$outdir/gen1recomp-${v}-linux.zip"
|
||||||
apk="$(find dist/android/debug -name '*.apk' | head -1)"
|
apk="$(find dist/android/debug -name '*.apk' | head -1)"
|
||||||
[ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/debug"; exit 1; }
|
[ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/debug"; exit 1; }
|
||||||
cp "$apk" "$outdir/PokemonRed-${v}-android.apk"
|
cp "$apk" "$outdir/gen1recomp-${v}-android.apk"
|
||||||
ls -lh "$outdir"
|
ls -lh "$outdir"
|
||||||
|
|
||||||
- name: Publish GitHub Release
|
- name: Publish GitHub Release
|
||||||
@@ -263,10 +263,10 @@ jobs:
|
|||||||
--target "$GITHUB_SHA" \
|
--target "$GITHUB_SHA" \
|
||||||
--title "$v" \
|
--title "$v" \
|
||||||
--notes "$notes" \
|
--notes "$notes" \
|
||||||
"dist/release/PokemonRed-${v}-macos.zip" \
|
"dist/release/gen1recomp-${v}-macos.zip" \
|
||||||
"dist/release/PokemonRed-${v}-windows.zip" \
|
"dist/release/gen1recomp-${v}-windows.zip" \
|
||||||
"dist/release/PokemonRed-${v}-linux.zip" \
|
"dist/release/gen1recomp-${v}-linux.zip" \
|
||||||
"dist/release/PokemonRed-${v}-android.apk"
|
"dist/release/gen1recomp-${v}-android.apk"
|
||||||
|
|
||||||
echo "Published release $tag"
|
echo "Published release $tag"
|
||||||
|
|
||||||
|
|||||||
@@ -50,18 +50,20 @@ developer data build, test suites, and cache management are covered in
|
|||||||
By default the game keeps your save, options, and the private ROM-derived
|
By default the game keeps your save, options, and the private ROM-derived
|
||||||
data cache in your OS's normal per-user app data folder. To keep everything
|
data cache in your OS's normal per-user app data folder. To keep everything
|
||||||
next to the game instead (handy for a USB stick or portable drive you carry
|
next to the game instead (handy for a USB stick or portable drive you carry
|
||||||
between computers), drop an empty file named `portable.txt` next to the
|
between computers), drop an empty file named `portable.txt` next to the app
|
||||||
executable (or next to `main.lua`/`conf.lua` when running from source), then
|
(next to `PokemonRed.app`/`.exe`, or next to `main.lua`/`conf.lua` when
|
||||||
launch the game.
|
running from source), then launch the game. Portable mode is desktop-only
|
||||||
|
(Windows, Linux, macOS); it has no effect on Android or iOS, where the app
|
||||||
|
runs from a read-only package.
|
||||||
|
|
||||||
With `portable.txt` present:
|
With `portable.txt` present:
|
||||||
|
|
||||||
- `save.lua`, `save.lua.bak`, and `options.lua` are read from and written to
|
- `save.lua`, `save.lua.bak`, and `options.lua` are read from and written to
|
||||||
that same folder instead of the OS save directory.
|
that same folder instead of the OS save directory.
|
||||||
- After a ROM import, the generated `data/generated` and `assets/generated`
|
- A ROM import writes the generated `data/generated` and `assets/generated`
|
||||||
cache is copied into that folder too, so a later launch (even on a
|
cache straight into that folder too (nothing is left in the OS save
|
||||||
different computer, as long as the same folder comes along) reuses it
|
directory), so a later launch reuses it without asking for the ROM again
|
||||||
without asking for the ROM again.
|
even on a different computer, as long as the same folder comes along.
|
||||||
- Deleting `portable.txt` switches back to the normal OS save directory; nothing
|
- Deleting `portable.txt` switches back to the normal OS save directory; nothing
|
||||||
already written to either location is touched automatically, so copy files
|
already written to either location is touched automatically, so copy files
|
||||||
over yourself if you want to carry existing progress across the switch.
|
over yourself if you want to carry existing progress across the switch.
|
||||||
|
|||||||
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
|
-- back to the plain title if the source is not mounted yet
|
||||||
local ok, Version = pcall(require, "src.core.Version")
|
local ok, Version = pcall(require, "src.core.Version")
|
||||||
t.window.title = ok and Version.title()
|
t.window.title = ok and Version.title()
|
||||||
or "Pokemon Red (Gen 1 Recompilation Project)"
|
or "gen1recomp"
|
||||||
t.window.width = 160 * 4
|
-- Open at the launcher's design size (the split-screen ROM selector is
|
||||||
t.window.height = 144 * 4
|
-- 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
|
end
|
||||||
t.version = "11.5"
|
t.version = "11.5"
|
||||||
t.window.vsync = 1
|
t.window.vsync = 1
|
||||||
@@ -37,10 +41,19 @@ function love.conf(t)
|
|||||||
local osName = love._os
|
local osName = love._os
|
||||||
local mobile = osName == "Android" or osName == "iOS"
|
local mobile = osName == "Android" or osName == "iOS"
|
||||||
if mobile then
|
if mobile then
|
||||||
-- On Android/iOS, width/height aspect picks portrait vs landscape
|
-- resizable is what unlocks orientation. SDL's Android backend, given no
|
||||||
-- (fullscreen alone is not enough). Use a tall portrait size; the
|
-- SDL_HINT_ORIENTATIONS (LÖVE sets none), calls setRequestedOrientation
|
||||||
-- OS then resizes to the real display. highdpi is required for
|
-- at window creation -- FULL_SENSOR when the window is resizable (rotates
|
||||||
-- Retina iOS (Android always behaves as highdpi).
|
-- freely to portrait or landscape), otherwise locked to the window's w/h
|
||||||
|
-- aspect. So a non-resizable tall window forced portrait; resizable lets
|
||||||
|
-- the game follow the device. The renderer letterboxes the 160x144
|
||||||
|
-- viewport into whatever size results, and touch input is gesture-based,
|
||||||
|
-- so both orientations just work. iOS follows the Info.plist orientations
|
||||||
|
-- (see mobile/ios/overlays/love-ios.plist, now portrait + landscape).
|
||||||
|
t.window.resizable = true
|
||||||
|
-- Starting size is a tall portrait hint; the OS resizes to the real
|
||||||
|
-- display and rotations resize again. highdpi is required for Retina iOS
|
||||||
|
-- (Android always behaves as highdpi).
|
||||||
t.window.width = 1080
|
t.window.width = 1080
|
||||||
t.window.height = 1920
|
t.window.height = 1920
|
||||||
t.window.fullscreen = true
|
t.window.fullscreen = true
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
-- Pewter City flavor dialogue (pokered/scripts/PewterCity.asm).
|
-- Pewter City flavor dialogue (pokered/scripts/PewterCity.asm).
|
||||||
-- PewterCity_TextPointers text_asm bodies for the SUPER_NERD1 museum
|
-- PewterCity_TextPointers text_asm bodies for the SUPER_NERD1 museum
|
||||||
-- guide, 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
|
-- The YOUNGSTER's gym escort (talk + east-exit onStep) lives in
|
||||||
-- museum, YOUNGSTER walking the player to the gym) is scripted NPC
|
-- story5.lua so the lockstep RLE walk is not overwritten by this
|
||||||
-- movement + a wPewterCityCurScript state machine that steers the
|
-- flavor merge. SUPER_NERD1's museum escort is not ported; only the
|
||||||
-- player off-map; the YOUNGSTER's escort to the gym is handled on this
|
-- YES/NO-branched flavor text is here.
|
||||||
-- map by story5.lua's onStep gate at the east exit (before
|
|
||||||
-- EVENT_BEAT_BROCK), while the SUPER_NERD1 museum escort is not ported.
|
|
||||||
-- Here we only port the real YES/NO-branched flavor text these NPCs
|
|
||||||
-- speak when talked to.
|
|
||||||
|
|
||||||
local M = {}
|
local M = {}
|
||||||
|
|
||||||
@@ -61,15 +57,6 @@ M.PEWTER_CITY = {
|
|||||||
end)
|
end)
|
||||||
end,
|
end,
|
||||||
|
|
||||||
-- PewterCityYoungsterText (scripts/PewterCity.asm): the "follow
|
|
||||||
-- me" line the youngster says when the player is stopped from
|
|
||||||
-- leaving Pewter east before beating Brock; the actual gate/step
|
|
||||||
-- block is handled by story5.lua's onStep for this map.
|
|
||||||
TEXT_PEWTERCITY_YOUNGSTER = function(game, ow, npc, done)
|
|
||||||
local t = text(game)
|
|
||||||
push(game, t._PewterCityYoungsterYoureATrainerFollowMeText
|
|
||||||
or "You're a trainer\nright? BROCK's\nlooking for new\nchallengers!\nFollow me!", done)
|
|
||||||
end,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,19 +5,23 @@ return {
|
|||||||
SILPH_CO_9F = {
|
SILPH_CO_9F = {
|
||||||
talk = {
|
talk = {
|
||||||
-- SilphCo9FNurseText (pokered/scripts/SilphCo9F.asm):
|
-- SilphCo9FNurseText (pokered/scripts/SilphCo9F.asm):
|
||||||
-- before EVENT_BEAT_SILPH_CO_GIOVANNI: heals the party and shows
|
-- before EVENT_BEAT_SILPH_CO_GIOVANNI: heals the party, white fade
|
||||||
-- "You look tired..." then "Don't give up!"; after the event, just
|
-- (Delay3 between out/in; no Music_PkmnHealed), then "Don't give
|
||||||
-- says thanks. Nurse texts are not in data/generated/text.lua, so
|
-- up!"; after the event, just says thanks. Nurse texts are not in
|
||||||
-- the exact pokered/text/SilphCo9F.asm strings are used as literals.
|
-- data/generated/text.lua, so the exact pokered/text/SilphCo9F.asm
|
||||||
|
-- strings are used as literals.
|
||||||
TEXT_SILPHCO9F_NURSE = {
|
TEXT_SILPHCO9F_NURSE = {
|
||||||
{ "face_player" },
|
{ "face_player" }, -- 1
|
||||||
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
|
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" }, -- 2
|
||||||
{ "jump_if_true", 8 },
|
{ "jump_if_true", 11 }, -- 3
|
||||||
{ "show_text", "You look tired!\nYou should take a\nquick nap!" },
|
{ "show_text", "You look tired!\nYou should take a\nquick nap!" }, -- 4
|
||||||
{ "heal_party" },
|
{ "heal_party" }, -- 5
|
||||||
{ "show_text", "Don't give up!" },
|
{ "fade", "out", "white" }, -- 6
|
||||||
{ "jump", 9 },
|
{ "wait", 3 }, -- 7 Delay3
|
||||||
{ "show_text", "Thank you so\nmuch!" },
|
{ "fade", "in", "white" }, -- 8
|
||||||
|
{ "show_text", "Don't give up!" }, -- 9
|
||||||
|
{ "jump", "end" }, -- 10
|
||||||
|
{ "show_text", "Thank you so\nmuch!" }, -- 11
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
+45
-25
@@ -24,8 +24,11 @@ local function starterBall(askText, species, choseFlag, ownBall,
|
|||||||
{ "check_flag", "EVENT_FOLLOWED_OAK_INTO_LAB" }, -- 3
|
{ "check_flag", "EVENT_FOLLOWED_OAK_INTO_LAB" }, -- 3
|
||||||
{ "jump_if_false", 20 }, -- 4
|
{ "jump_if_false", 20 }, -- 4
|
||||||
-- the Pokédex "new species" entry shows before the ask (predef
|
-- the Pokédex "new species" entry shows before the ask (predef
|
||||||
-- StarterDex ahead of OaksLabYouWant...Text)
|
-- StarterDex ahead of OaksLabYouWant...Text). StarterDex temporarily
|
||||||
{ "push_screen", "DexEntryMenu", species }, -- 5
|
-- 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
|
{ "ask", askText }, -- 6
|
||||||
{ "jump_if_false", 21 }, -- 7
|
{ "jump_if_false", 21 }, -- 7
|
||||||
{ "give_pokemon", species, 5 }, -- 8
|
{ "give_pokemon", species, 5 }, -- 8
|
||||||
@@ -61,40 +64,44 @@ return {
|
|||||||
TEXT_OAKSLAB_OAK1 = {
|
TEXT_OAKSLAB_OAK1 = {
|
||||||
{ "face_player" }, -- 1
|
{ "face_player" }, -- 1
|
||||||
{ "check_flag", "EVENT_GOT_OAKS_PARCEL" }, -- 2
|
{ "check_flag", "EVENT_GOT_OAKS_PARCEL" }, -- 2
|
||||||
{ "jump_if_false", 14 }, -- 3
|
{ "jump_if_false", 16 }, -- 3
|
||||||
{ "check_flag", "EVENT_OAK_GOT_PARCEL" }, -- 4
|
{ "check_flag", "EVENT_OAK_GOT_PARCEL" }, -- 4
|
||||||
{ "jump_if_true", 14 }, -- 5
|
{ "jump_if_true", 16 }, -- 5
|
||||||
{ "show_text", "_OaksLabOak1DeliverParcelText" }, -- 6
|
{ "show_text", "_OaksLabOak1DeliverParcelText" }, -- 6
|
||||||
{ "take_item", "OAKS_PARCEL", 1 }, -- 7
|
{ "take_item", "OAKS_PARCEL", 1 }, -- 7
|
||||||
{ "set_flag", "EVENT_OAK_GOT_PARCEL" }, -- 8
|
{ "set_flag", "EVENT_OAK_GOT_PARCEL" }, -- 8
|
||||||
{ "show_text", "_OaksLabOak1PokemonAroundTheWorldText" }, -- 9
|
{ "show_text", "_OaksLabOak1PokemonAroundTheWorldText" }, -- 9
|
||||||
{ "set_flag", "EVENT_GOT_POKEDEX" }, -- 10
|
{ "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:
|
-- the Pokédex swaps Viridian's two old men (OaksLab.asm:602-606:
|
||||||
-- HideObject TOGGLE_LYING_OLD_MAN / ShowObject TOGGLE_OLD_MAN).
|
-- HideObject TOGGLE_LYING_OLD_MAN / ShowObject TOGGLE_OLD_MAN).
|
||||||
-- Until this ran, the walking man at (17,5) -- who owns the coffee
|
-- Until this ran, the walking man at (17,5) -- who owns the coffee
|
||||||
-- ask and the catch tutorial -- stayed OFF for the whole game
|
-- ask and the catch tutorial -- stayed OFF for the whole game
|
||||||
-- (toggleable_objects.asm seeds him OFF, the sleeper ON).
|
-- (toggleable_objects.asm seeds him OFF, the sleeper ON).
|
||||||
{ "hide_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY" }, -- 11
|
{ "hide_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY" }, -- 13
|
||||||
{ "show_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN" }, -- 12
|
{ "show_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN" }, -- 14
|
||||||
{ "jump", 32 }, -- 13
|
{ "jump", "end" }, -- 15
|
||||||
{ "check_flag", "EVENT_GOT_STARTER" }, -- 14
|
{ "check_flag", "EVENT_GOT_STARTER" }, -- 16
|
||||||
{ "jump_if_false", 29 }, -- 15
|
{ "jump_if_false", 31 }, -- 17
|
||||||
{ "check_item", "POKE_BALL" }, -- 16
|
{ "check_item", "POKE_BALL" }, -- 18
|
||||||
{ "jump_if_true", 27 }, -- 17
|
{ "jump_if_true", 29 }, -- 19
|
||||||
{ "check_flag", "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE" }, -- 18
|
{ "check_flag", "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE" }, -- 20
|
||||||
{ "jump_if_false", 31 }, -- 19
|
{ "jump_if_false", 33 }, -- 21
|
||||||
{ "check_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 20
|
{ "check_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 22
|
||||||
{ "jump_if_true", 27 }, -- 21
|
{ "jump_if_true", 29 }, -- 23
|
||||||
{ "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 22
|
{ "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 24
|
||||||
{ "give_item", "POKE_BALL", 5, false }, -- 23
|
{ "give_item", "POKE_BALL", 5, false }, -- 25
|
||||||
{ "show_text", "_OaksLabOak1ReceivedPokeballsText" }, -- 24
|
{ "show_text", "_OaksLabOak1ReceivedPokeballsText" }, -- 26
|
||||||
{ "show_text", "_OaksLabGivePokeballsExplanationText" }, -- 25
|
{ "show_text", "_OaksLabGivePokeballsExplanationText" }, -- 27
|
||||||
{ "jump", 32 }, -- 26
|
{ "jump", "end" }, -- 28
|
||||||
{ "show_text", "_OaksLabOak1ComeSeeMeSometimesText" }, -- 27
|
{ "show_text", "_OaksLabOak1ComeSeeMeSometimesText" }, -- 29
|
||||||
{ "jump", 32 }, -- 28
|
{ "jump", "end" }, -- 30
|
||||||
{ "show_text", "_OaksLabOak1WhichPokemonDoYouWantText" }, -- 29
|
{ "show_text", "_OaksLabOak1WhichPokemonDoYouWantText" }, -- 31
|
||||||
{ "jump", 32 }, -- 30
|
{ "jump", "end" }, -- 32
|
||||||
{ "show_text", "_OaksLabOak1RaiseYourYoungPokemonText" }, -- 31 (32 = end)
|
{ "show_text", "_OaksLabOak1RaiseYourYoungPokemonText" }, -- 33
|
||||||
},
|
},
|
||||||
|
|
||||||
TEXT_OAKSLAB_CHARMANDER_POKE_BALL =
|
TEXT_OAKSLAB_CHARMANDER_POKE_BALL =
|
||||||
@@ -140,6 +147,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
|
-- Oak stops you leaving without a starter; the rival stops you on
|
||||||
-- the way out for the first battle (scripts/OaksLab.asm
|
-- the way out for the first battle (scripts/OaksLab.asm
|
||||||
-- OaksLabScript8 / OaksLabRivalChallenge)
|
-- OaksLabScript8 / OaksLabRivalChallenge)
|
||||||
|
|||||||
@@ -1,15 +1,23 @@
|
|||||||
-- Hand-ported from pret/pokered scripts/RedsHouse1F.asm.
|
-- Hand-ported from pret/pokered scripts/RedsHouse1F.asm.
|
||||||
-- Mom (RedsHouse1FMomText, text_asm) heals the party and shows the
|
-- Mom (RedsHouse1FMomText): pre-starter shows the wake-up / Oak tip;
|
||||||
-- "you should rest" / "looking great" dialogue. The intro "wake up"
|
-- after EVENT_GOT_STARTER, RedsHouse1FMomHealScript fades to white,
|
||||||
-- branch is tied to the unported intro cutscene, so the heal path is used.
|
-- heals, plays MUSIC_PKMN_HEALED, fades back, then "looking great".
|
||||||
|
|
||||||
return {
|
return {
|
||||||
talk = {
|
talk = {
|
||||||
TEXT_REDSHOUSE1F_MOM = {
|
TEXT_REDSHOUSE1F_MOM = {
|
||||||
{ "face_player" },
|
{ "face_player" }, -- 1
|
||||||
{ "show_text", "_RedsHouse1FMomYouShouldRestText" },
|
{ "check_flag", "EVENT_GOT_STARTER" }, -- 2
|
||||||
{ "heal_party" },
|
{ "jump_if_true", 6 }, -- 3
|
||||||
{ "show_text", "_RedsHouse1FMomLookingGreatText" },
|
{ "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
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
+159
-36
@@ -222,17 +222,55 @@ M.BILLS_HOUSE = {
|
|||||||
-- repair saves that already got the ticket under the old collapsed
|
-- repair saves that already got the ticket under the old collapsed
|
||||||
-- script (the monster never hidden, human Bill never shown)
|
-- script (the monster never hidden, human Bill never shown)
|
||||||
onEnter = function(game, ow)
|
onEnter = function(game, ow)
|
||||||
if game.save.flags.EVENT_GOT_SS_TICKET
|
local flags = game.save.flags
|
||||||
and not game.save.flags.EVENT_USED_CELL_SEPARATOR_ON_BILL then
|
if flags.EVENT_GOT_SS_TICKET
|
||||||
|
and not flags.EVENT_USED_CELL_SEPARATOR_ON_BILL then
|
||||||
local Commands = require("src.script.Commands")
|
local Commands = require("src.script.Commands")
|
||||||
local ctx = { game = game, save = game.save, overworld = ow }
|
local ctx = { game = game, save = game.save, overworld = ow }
|
||||||
Commands.hide_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL_POKEMON")
|
Commands.hide_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL_POKEMON")
|
||||||
Commands.show_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL1")
|
Commands.show_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL1")
|
||||||
game.save.flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = true
|
flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = true
|
||||||
game.save.flags.EVENT_USED_CELL_SEPARATOR_ON_BILL = true
|
flags.EVENT_USED_CELL_SEPARATOR_ON_BILL = true
|
||||||
game.save.flags.EVENT_MET_BILL = true
|
flags.EVENT_MET_BILL = true
|
||||||
game.save.flags.EVENT_MET_BILL_2 = true
|
flags.EVENT_MET_BILL_2 = true
|
||||||
end
|
end
|
||||||
|
-- After Route25ToggleBillsScript, BILL2 is the visible human Bill
|
||||||
|
-- ("check out my rare POKéMON"). Re-apply on enter so a mid-house
|
||||||
|
-- load still matches the toggles if the pool was rebuilt.
|
||||||
|
if flags.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING then
|
||||||
|
local Commands = require("src.script.Commands")
|
||||||
|
local ctx = { game = game, save = game.save, overworld = ow }
|
||||||
|
Commands.hide_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL_POKEMON")
|
||||||
|
Commands.hide_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL1")
|
||||||
|
Commands.show_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL2")
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
|
||||||
|
-- -------------------------------------------------------------------
|
||||||
|
-- Route 25 outside Bill's house (scripts/Route25.asm
|
||||||
|
-- Route25ToggleBillsScript): leaving after the SS Ticket arms the
|
||||||
|
-- Eevee PC list and swaps human Bill to his post-help dialogue NPC.
|
||||||
|
-- Leaving mid-quest (Bill still in the machine) puts the monster back.
|
||||||
|
-- -------------------------------------------------------------------
|
||||||
|
|
||||||
|
M.ROUTE_25 = {
|
||||||
|
onEnter = function(game, ow)
|
||||||
|
local flags = game.save.flags
|
||||||
|
if flags.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING then return end
|
||||||
|
local Commands = require("src.script.Commands")
|
||||||
|
local ctx = { game = game, save = game.save, overworld = ow }
|
||||||
|
if not flags.EVENT_MET_BILL_2 then
|
||||||
|
flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = nil
|
||||||
|
Commands.show_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL_POKEMON")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if not flags.EVENT_GOT_SS_TICKET then return end
|
||||||
|
flags.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING = true
|
||||||
|
-- TOGGLE_NUGGET_BRIDGE_GUY (ROUTE24_COOLTRAINER_M1)
|
||||||
|
Commands.hide_object(ctx, "ROUTE_24", "ROUTE24_COOLTRAINER_M1")
|
||||||
|
Commands.hide_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL1")
|
||||||
|
Commands.show_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL2")
|
||||||
end,
|
end,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,25 +294,40 @@ M.VERMILION_CITY = {
|
|||||||
end,
|
end,
|
||||||
-- VermilionCityDefaultScript's per-frame SSAnneTicketCheckCoords check:
|
-- VermilionCityDefaultScript's per-frame SSAnneTicketCheckCoords check:
|
||||||
-- the unguarded cell (18,30) just west of the sailor leads straight
|
-- the unguarded cell (18,30) just west of the sailor leads straight
|
||||||
-- onto the dock warp, so stepping onto it heading for the dock gets
|
-- onto the dock warp. Stepping onto it facing down always runs the
|
||||||
-- ticket-checked (and turned back once the ship has sailed) without
|
-- sailor dialog (DisplayTextID TEXT_VERMILIONCITY_SAILOR1); only a
|
||||||
-- the player ever pressing A. The sailor himself never disappears.
|
-- 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)
|
onStep = function(game, ow, x, y)
|
||||||
if x ~= 18 or y ~= 30 then return false end
|
if x ~= 18 or y ~= 30 then return false end
|
||||||
if ow.player.facing ~= "down" then return false end
|
if ow.player.facing ~= "down" then return false end
|
||||||
local f = game.save.flags
|
local Flags = require("src.script.Flags")
|
||||||
local t = game.data.text
|
local t = game.data.text
|
||||||
local TextBox = require("src.render.TextBox")
|
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,
|
game.stack:push(TextBox.new(game,
|
||||||
t._VermilionCitySailor1ShipSetSailText or "The ship set sail.",
|
t._VermilionCitySailor1ShipSetSailText or "The ship set sail.",
|
||||||
function() ow:scriptMove(ow.player, "up", 1) end))
|
function() ow:scriptMove(ow.player, "up", 1) end))
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
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,
|
game.stack:push(TextBox.new(game,
|
||||||
(t._VermilionCitySailor1WelcomeToSSAnneText or "Welcome to S.S.\nANNE!")
|
ask .. "\f"
|
||||||
.. "\f"
|
|
||||||
.. (t._VermilionCitySailor1YouNeedATicketText
|
.. (t._VermilionCitySailor1YouNeedATicketText
|
||||||
or "You need a ticket\nto get aboard."),
|
or "You need a ticket\nto get aboard."),
|
||||||
function() ow:scriptMove(ow.player, "up", 1) end))
|
function() ow:scriptMove(ow.player, "up", 1) end))
|
||||||
@@ -288,7 +341,7 @@ M.VERMILION_CITY = {
|
|||||||
{ "face_player" }, -- 1
|
{ "face_player" }, -- 1
|
||||||
{ "check_flag", "EVENT_SS_ANNE_LEFT" }, -- 2
|
{ "check_flag", "EVENT_SS_ANNE_LEFT" }, -- 2
|
||||||
{ "jump_if_true", 11 }, -- 3
|
{ "jump_if_true", 11 }, -- 3
|
||||||
{ "show_text", "_VermilionCitySailor1WelcomeToSSAnneText" }, -- 4
|
{ "show_text", "_VermilionCitySailor1DoYouHaveATicketText" }, -- 4
|
||||||
{ "check_item", "S_S_TICKET" }, -- 5
|
{ "check_item", "S_S_TICKET" }, -- 5
|
||||||
{ "jump_if_false", 9 }, -- 6
|
{ "jump_if_false", 9 }, -- 6
|
||||||
{ "show_text", "_VermilionCitySailor1FlashedTicketText" }, -- 7
|
{ "show_text", "_VermilionCitySailor1FlashedTicketText" }, -- 7
|
||||||
@@ -318,18 +371,21 @@ M.SS_ANNE_2F = {
|
|||||||
|
|
||||||
M.SS_ANNE_CAPTAINS_ROOM = {
|
M.SS_ANNE_CAPTAINS_ROOM = {
|
||||||
talk = {
|
talk = {
|
||||||
|
-- SSAnneCaptainsRoomCaptainText: after the rub line's text_asm tail,
|
||||||
|
-- pokered plays MUSIC_PKMN_HEALED (scripts/SSAnneCaptainsRoom.asm).
|
||||||
TEXT_SSANNECAPTAINSROOM_CAPTAIN = {
|
TEXT_SSANNECAPTAINSROOM_CAPTAIN = {
|
||||||
{ "check_flag", "EVENT_GOT_HM01" }, -- 1
|
{ "check_flag", "EVENT_GOT_HM01" }, -- 1
|
||||||
{ "jump_if_true", 9 }, -- 2
|
{ "jump_if_true", 10 }, -- 2
|
||||||
{ "show_text", "_SSAnneCaptainsRoomRubCaptainsBackText" }, -- 3
|
{ "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
|
-- give-then-print like scripts/SSAnneCaptainsRoom.asm (GiveItem
|
||||||
-- fills wStringBuffer; the received text reads it)
|
-- fills wStringBuffer; the received text reads it)
|
||||||
{ "give_item", "HM_CUT", 1, false }, -- 5
|
{ "give_item", "HM_CUT", 1, false }, -- 6
|
||||||
{ "show_text", "_SSAnneCaptainsRoomCaptainReceivedHM01Text" }, -- 6
|
{ "show_text", "_SSAnneCaptainsRoomCaptainReceivedHM01Text" }, -- 7
|
||||||
{ "set_flag", "EVENT_GOT_HM01" }, -- 7
|
{ "set_flag", "EVENT_GOT_HM01" }, -- 8
|
||||||
{ "jump", 10 }, -- 8
|
{ "jump", 11 }, -- 9
|
||||||
{ "show_text", "_SSAnneCaptainsRoomCaptainNotSickAnymoreText" }, -- 9
|
{ "show_text", "_SSAnneCaptainsRoomCaptainNotSickAnymoreText" }, -- 10
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -712,6 +768,29 @@ M.HALL_OF_FAME = {
|
|||||||
local toggles = game.save.objectToggles and game.save.objectToggles.HALL_OF_FAME
|
local toggles = game.save.objectToggles and game.save.objectToggles.HALL_OF_FAME
|
||||||
if toggles then toggles.HALLOFFAME_OAK = nil end
|
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
|
if not game.save.pendingHallOfFame then return end
|
||||||
game.save.pendingHallOfFame = false
|
game.save.pendingHallOfFame = false
|
||||||
ow:queueScript({
|
ow:queueScript({
|
||||||
@@ -725,6 +804,21 @@ M.HALL_OF_FAME = {
|
|||||||
{ "record_hall_of_fame" }, -- predef HallOfFamePC: induction + credits
|
{ "record_hall_of_fame" }, -- predef HallOfFamePC: induction + credits
|
||||||
})
|
})
|
||||||
end,
|
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 +843,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 = {
|
M.POKEMON_TOWER_2F = {
|
||||||
|
rivalScript = pokemonTower2FRivalScript,
|
||||||
talk = {
|
talk = {
|
||||||
TEXT_POKEMONTOWER2F_RIVAL = {
|
TEXT_POKEMONTOWER2F_RIVAL = function(game, ow, npc, done)
|
||||||
{ "face_player" }, -- 1
|
ow.runner:run(pokemonTower2FRivalScript(ow.player.cellX),
|
||||||
{ "check_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 2
|
{ npc = npc, onDone = done })
|
||||||
{ "jump_if_true", 10 }, -- 3
|
end,
|
||||||
{ "show_text", "_PokemonTower2FRivalWhatBringsYouHereText" }, -- 4
|
|
||||||
{ "rival_battle", "OPP_RIVAL2", 4 }, -- 5
|
|
||||||
{ "jump_if_false", 11 }, -- 6
|
|
||||||
{ "set_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 7
|
|
||||||
{ "show_text", "_PokemonTower2FRivalDefeatedText" }, -- 8
|
|
||||||
{ "jump", 11 }, -- 9
|
|
||||||
{ "show_text", "_PokemonTower2FRivalHowsYourDexText" }, -- 10
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
-- Saves that beat him before the exit walk was ported still have the
|
||||||
|
-- flag but a visible rival; hide on enter like BillsHouse repairs.
|
||||||
|
onEnter = function(game, ow)
|
||||||
|
if not game.save.flags.EVENT_BEAT_POKEMON_TOWER_RIVAL then return end
|
||||||
|
local Commands = require("src.script.Commands")
|
||||||
|
Commands.hide_object({ game = game, save = game.save, overworld = ow },
|
||||||
|
"POKEMON_TOWER_2F", "POKEMONTOWER2F_RIVAL")
|
||||||
|
end,
|
||||||
-- PokemonTower2FDefaultScript: walking past the rival's tile forces
|
-- PokemonTower2FDefaultScript: walking past the rival's tile forces
|
||||||
-- the encounter (ArePlayerCoordsInArray on (15,5)/(14,6)) -- he never
|
-- the encounter (ArePlayerCoordsInArray on (15,5)/(14,6)) -- he never
|
||||||
-- waits to be talked to
|
-- waits to be talked to
|
||||||
@@ -775,8 +899,7 @@ M.POKEMON_TOWER_2F = {
|
|||||||
if not rival then return false end
|
if not rival then return false end
|
||||||
ow.player.facing = (x == 15) and "left" or "up"
|
ow.player.facing = (x == 15) and "left" or "up"
|
||||||
require("src.core.Music").play(game.data, "Music_MeetRival")
|
require("src.core.Music").play(game.data, "Music_MeetRival")
|
||||||
ow.runner:run(M.POKEMON_TOWER_2F.talk.TEXT_POKEMONTOWER2F_RIVAL,
|
ow.runner:run(pokemonTower2FRivalScript(x), { npc = rival })
|
||||||
{ npc = rival })
|
|
||||||
return true
|
return true
|
||||||
end,
|
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
|
-- Day-care (scripts/Daycare.asm): the boarded Pokémon earns 1 exp per
|
||||||
-- step; the fee is ¥100 plus ¥100 per level gained.
|
-- step; the fee is ¥100 plus ¥100 per level gained.
|
||||||
|
--
|
||||||
|
-- #118: do not raise mon.level until a paid retrieve (pokered reverts
|
||||||
|
-- wDayCareMonBoxLevel on .leaveMonInDayCare). Fold pending steps into
|
||||||
|
-- mon.exp once and clear them so a second talk cannot re-apply the same
|
||||||
|
-- walk. Fill {RAM:wNameBuffer}/{RAM:wDayCareMonName}/{NUM:...} here —
|
||||||
|
-- TextBox.TOKENS.RAM only knows wStringBuffer.
|
||||||
-- -------------------------------------------------------------------
|
-- -------------------------------------------------------------------
|
||||||
|
|
||||||
|
local function fillDaycareText(s, subs)
|
||||||
|
s = s:gsub("{PLAYER}", subs.player or "")
|
||||||
|
s = s:gsub("{RAM:([^}]*)}", function(name) return subs[name] or "" end)
|
||||||
|
-- extractor NUM spans may include flags after a comma; keep the name
|
||||||
|
s = s:gsub("{NUM:([%w_]+)[^}]*}", function(name)
|
||||||
|
return tostring(subs[name] or "0")
|
||||||
|
end)
|
||||||
|
return s
|
||||||
|
end
|
||||||
|
|
||||||
M.DAYCARE = {
|
M.DAYCARE = {
|
||||||
talk = {
|
talk = {
|
||||||
TEXT_DAYCARE_GENTLEMAN = function(game, ow, npc, done)
|
TEXT_DAYCARE_GENTLEMAN = function(game, ow, npc, done)
|
||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
|
||||||
local t = game.data.text
|
local t = game.data.text
|
||||||
local dc = game.save.daycare
|
local dc = game.save.daycare
|
||||||
|
local playerName = game.save.player and game.save.player.name or "RED"
|
||||||
|
|
||||||
|
local function monName(mon)
|
||||||
|
local def = game.data.pokemon[mon.species]
|
||||||
|
return mon.nickname or (def and def.name) or mon.species
|
||||||
|
end
|
||||||
|
|
||||||
if dc and dc.mon then
|
if dc and dc.mon then
|
||||||
local Growth = require("src.pokemon.Growth")
|
local Growth = require("src.pokemon.Growth")
|
||||||
local Stats = require("src.pokemon.Stats")
|
local Stats = require("src.pokemon.Stats")
|
||||||
|
local Party = require("src.pokemon.Party")
|
||||||
local mon = dc.mon
|
local mon = dc.mon
|
||||||
local def = game.data.pokemon[mon.species]
|
local def = game.data.pokemon[mon.species]
|
||||||
mon.exp = mon.exp + (dc.steps or 0)
|
-- Apply deferred step-exp once (OverworldController only bumps
|
||||||
local newLevel = math.min(100, Growth.levelForExp(def.growthRate, mon.exp))
|
-- daycare.steps). Clearing prevents double-count on re-talk.
|
||||||
local fee = 100 + (newLevel - mon.level) * 100
|
mon.exp = (mon.exp or 0) + (dc.steps or 0)
|
||||||
local grew = newLevel > mon.level
|
dc.steps = 0
|
||||||
mon.level = newLevel
|
-- depositLevel mirrors wDayCareMonBoxLevel: fee baseline that
|
||||||
mon.stats = Stats.calc(def, mon.level, mon.dvs, mon.statExp)
|
-- must survive a declined retrieve. Fall back to mon.level for
|
||||||
mon.hp = mon.stats.hp
|
-- older saves that predate the field.
|
||||||
local msg = grew and (t._DaycareGentlemanMonHasGrownText or "It's grown a lot!")
|
if dc.depositLevel == nil then dc.depositLevel = mon.level end
|
||||||
or "Back already?"
|
local startLevel = dc.depositLevel
|
||||||
game.stack:push(TextBox.new(game,
|
local newLevel = Growth.levelForExp(def and def.growthRate, mon.exp)
|
||||||
msg .. ("\fThe fee is ¥%d.\nGet it back?"):format(fee), function()
|
if newLevel >= 100 then
|
||||||
game.stack:push(ChoiceBox.new(game, function(yes)
|
newLevel = 100
|
||||||
if yes and game.save.money >= fee then
|
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
|
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)
|
table.insert(game.save.party, mon)
|
||||||
game.save.daycare = nil
|
game.save.daycare = nil
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
t._DaycareGentlemanGotMonBackText or "Here you go!", done))
|
t._DaycareGentlemanHeresYourMonText
|
||||||
else
|
or "Thank you! Here's\nyour POKéMON!", function()
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
yes and (t._DaycareGentlemanOweMoneyText or "You owe me money!")
|
fillDaycareText(
|
||||||
or "Come again!", done))
|
t._DaycareGentlemanGotMonBackText
|
||||||
end
|
or "{PLAYER} got\n{RAM:wDayCareMonName} back!",
|
||||||
end))
|
subs), done))
|
||||||
|
end))
|
||||||
|
end }))
|
||||||
end))
|
end))
|
||||||
return
|
return
|
||||||
end
|
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,
|
game.stack:push(TextBox.new(game,
|
||||||
t._DaycareGentlemanIntroText or "I can raise a\nPOKéMON for you.", function()
|
t._DaycareGentlemanIntroText
|
||||||
game.stack:push(ChoiceBox.new(game, function(yes)
|
or "I run a DAYCARE.\nWould you like me\nto raise one of\nyour POKéMON?",
|
||||||
if not yes then done() return end
|
nil, { choice = function(yes)
|
||||||
local PartyMenu = require("src.ui.PartyMenu")
|
if not yes then
|
||||||
game.stack:push(PartyMenu.new(game, {
|
game.stack:push(TextBox.new(game,
|
||||||
pickOnly = true,
|
t._DaycareGentlemanComeAgainText or "come again.", done))
|
||||||
onSwitch = function(mon)
|
return
|
||||||
for i, m in ipairs(game.save.party) do
|
end
|
||||||
if m == mon then table.remove(game.save.party, i) break end
|
if #game.save.party < 2 then
|
||||||
end
|
game.stack:push(TextBox.new(game,
|
||||||
game.save.daycare = { mon = mon, steps = 0 }
|
t._DaycareGentlemanOnlyHaveOneMonText
|
||||||
game.stack:push(TextBox.new(game,
|
or "You only have one\nPOKéMON with you.", done))
|
||||||
t._DaycareGentlemanWillLookAfterMonText or
|
return
|
||||||
"Fine, I'll look\nafter it a while!", done))
|
end
|
||||||
end,
|
game.stack:push(TextBox.new(game,
|
||||||
}))
|
t._DaycareGentlemanWhichMonText or "Which POKéMON\nshould I raise?",
|
||||||
end))
|
function()
|
||||||
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
|
||||||
|
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,
|
end,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
+197
-46
@@ -150,18 +150,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_OPP = { up = "down", down = "up", left = "right", right = "left" }
|
||||||
local WALK_ORDER = { "up", "down", "left", "right" }
|
local WALK_ORDER = { "up", "down", "left", "right" }
|
||||||
|
|
||||||
local function elevatorWalkOut(ow, floor)
|
-- .UpdateWarp: point EVERY car exit warp at the same floor's elevator
|
||||||
local m, p = ow.map, ow.player
|
-- door (warp id, map id). Shared generated map data, but the car's
|
||||||
-- .UpdateWarp is run twice, so BOTH car warp entries get the same
|
-- warps are only read from inside the car; rides rewrite them again.
|
||||||
-- (warp id, map id): point every exit warp at the picked floor's
|
local function elevatorSetExit(ow, floor)
|
||||||
-- elevator-door warp (the reciprocal warp found while building the
|
if not floor then return end
|
||||||
-- menu). The car map's def is shared generated data, but its own
|
for _, w in ipairs(ow.map.def.warps) do
|
||||||
-- warps are only ever read from inside the car, and this rewrite runs
|
|
||||||
-- on every ride before the walk-out fires, so it is self-correcting.
|
|
||||||
for _, w in ipairs(m.def.warps) do
|
|
||||||
w.destMap = floor.map
|
w.destMap = floor.map
|
||||||
w.destWarp = floor.warpIdx
|
w.destWarp = floor.warpIdx
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function elevatorWalkOut(ow, floor)
|
||||||
|
local m, p = ow.map, ow.player
|
||||||
|
elevatorSetExit(ow, floor)
|
||||||
-- leave by the exit tile under the player (they warped in onto one),
|
-- leave by the exit tile under the player (they warped in onto one),
|
||||||
-- else the nearest
|
-- else the nearest
|
||||||
local door
|
local door
|
||||||
@@ -199,37 +201,68 @@ local function elevatorWalkOut(ow, floor)
|
|||||||
end)
|
end)
|
||||||
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)
|
local function elevator(elevatorMapId, keyGate, preFrames)
|
||||||
return {
|
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
|
if keyGate and not game.save.inventory[keyGate.item] then
|
||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
game.data.text[keyGate.text] or "It appears to\nneed a key."))
|
game.data.text[keyGate.text] or "It appears to\nneed a key."))
|
||||||
return
|
return
|
||||||
end
|
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 = {}
|
local items = {}
|
||||||
for _, f in ipairs(floors) do
|
for _, f in ipairs(floors) do
|
||||||
table.insert(items, { label = f.token, value = f })
|
table.insert(items, { label = f.token, value = f })
|
||||||
@@ -262,7 +295,8 @@ local function elevator(elevatorMapId, keyGate, preFrames)
|
|||||||
end,
|
end,
|
||||||
onCancel = function()
|
onCancel = function()
|
||||||
-- DisplayElevatorFloorMenu: `ret c` on B -- no warp, nothing
|
-- DisplayElevatorFloorMenu: `ret c` on B -- no warp, nothing
|
||||||
-- happens, the player just stays in the car
|
-- happens, the player just stays in the car (exit warps were
|
||||||
|
-- already seeded to the entry floor above)
|
||||||
end,
|
end,
|
||||||
}))
|
}))
|
||||||
end,
|
end,
|
||||||
@@ -274,6 +308,98 @@ M.CELADON_MART_ELEVATOR = elevator("CELADON_MART_ELEVATOR", nil, 9)
|
|||||||
M.ROCKET_HIDEOUT_ELEVATOR = elevator("ROCKET_HIDEOUT_ELEVATOR",
|
M.ROCKET_HIDEOUT_ELEVATOR = elevator("ROCKET_HIDEOUT_ELEVATOR",
|
||||||
{ item = "LIFT_KEY", text = "_RocketHideoutElevatorAppearsToNeedKeyText" })
|
{ item = "LIFT_KEY", text = "_RocketHideoutElevatorAppearsToNeedKeyText" })
|
||||||
|
|
||||||
|
-- -------------------------------------------------------------------
|
||||||
|
-- Rocket Hideout B4F (scripts/RocketHideoutB4F.asm):
|
||||||
|
-- Rocket3's after-battle text_asm drops the LIFT KEY item ball
|
||||||
|
-- (CheckAndSetEvent EVENT_ROCKET_DROPPED_LIFT_KEY / ShowObject
|
||||||
|
-- TOGGLE_ROCKET_HIDEOUT_B4F_ITEM_5). Both start hidden in the map
|
||||||
|
-- objects; without this talk side-effect the key never appears (#90,
|
||||||
|
-- #105).
|
||||||
|
-- Giovanni's post-battle script likewise ShowObject's the Silph Scope
|
||||||
|
-- after the hope-we-meet-again line (TOGGLE_ROCKET_HIDEOUT_B4F_ITEM_4).
|
||||||
|
-- -------------------------------------------------------------------
|
||||||
|
|
||||||
|
M.ROCKET_HIDEOUT_B4F = {
|
||||||
|
talk = {
|
||||||
|
TEXT_ROCKETHIDEOUTB4F_ROCKET3 = function(game, ow, npc, done)
|
||||||
|
if not ow:trainerDefeated(npc) then
|
||||||
|
ow:engageTrainer(npc, done)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local TextBox = require("src.render.TextBox")
|
||||||
|
local t = game.data.text
|
||||||
|
game.stack:push(TextBox.new(game,
|
||||||
|
t._RocketHideoutB4FRocket3AfterBattleText
|
||||||
|
or "Oh no! I dropped\nthe LIFT KEY!",
|
||||||
|
function()
|
||||||
|
-- CheckAndSetEvent EVENT_ROCKET_DROPPED_LIFT_KEY: first talk
|
||||||
|
-- after the win reveals the ball; later talks only reprint.
|
||||||
|
if not game.save.flags.EVENT_ROCKET_DROPPED_LIFT_KEY then
|
||||||
|
game.save.flags.EVENT_ROCKET_DROPPED_LIFT_KEY = true
|
||||||
|
local Commands = require("src.script.Commands")
|
||||||
|
Commands.show_object(
|
||||||
|
{ game = game, save = game.save, overworld = ow },
|
||||||
|
"ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_LIFT_KEY")
|
||||||
|
end
|
||||||
|
done()
|
||||||
|
end))
|
||||||
|
end,
|
||||||
|
|
||||||
|
TEXT_ROCKETHIDEOUTB4F_GIOVANNI = function(game, ow, npc, done)
|
||||||
|
-- Giovanni has no trainer-header row (def_trainers 2); his text_asm
|
||||||
|
-- owns both the engage and the BeatGiovanniScript aftermath.
|
||||||
|
if ow:trainerDefeated(npc)
|
||||||
|
or game.save.flags.EVENT_BEAT_ROCKET_HIDEOUT_GIOVANNI then
|
||||||
|
local TextBox = require("src.render.TextBox")
|
||||||
|
game.stack:push(TextBox.new(game,
|
||||||
|
game.data.text._RocketHideoutB4FGiovanniHopeWeMeetAgainText
|
||||||
|
or "I hope we meet\nagain...", done))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local TextBox = require("src.render.TextBox")
|
||||||
|
local BattleState = require("src.battle.BattleState")
|
||||||
|
local t = game.data.text
|
||||||
|
local impressed = t._RocketHideoutB4FGiovanniImpressedYouGotHereText
|
||||||
|
or "So! I must say, I\nam impressed you\ngot here!"
|
||||||
|
local cannotBe = t._RocketHideoutB4FGiovanniWhatCannotBeText
|
||||||
|
or "WHAT!\nThis cannot be!"
|
||||||
|
local hope = t._RocketHideoutB4FGiovanniHopeWeMeetAgainText
|
||||||
|
or "I hope we meet\nagain..."
|
||||||
|
game.stack:push(TextBox.new(game, impressed, function()
|
||||||
|
local battle = BattleState.newTrainer(game, "OPP_GIOVANNI", 1)
|
||||||
|
battle.onFinish = function(result)
|
||||||
|
if result ~= "win" then
|
||||||
|
ow:afterBattle(result, battle)
|
||||||
|
done()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
game.save.defeatedTrainers[npc.id] = true
|
||||||
|
game.save.flags.EVENT_BEAT_ROCKET_HIDEOUT_GIOVANNI = true
|
||||||
|
-- End-battle "WHAT!" then BeatGiovanniScript's hope text,
|
||||||
|
-- fade, HideObject Giovanni, ShowObject Silph Scope.
|
||||||
|
game.stack:push(TextBox.new(game, cannotBe, function()
|
||||||
|
game.stack:push(TextBox.new(game, hope, function()
|
||||||
|
local Transition = require("src.render.Transition")
|
||||||
|
game.stack:push(Transition.new(game, function()
|
||||||
|
local Commands = require("src.script.Commands")
|
||||||
|
local ctx = { game = game, save = game.save, overworld = ow }
|
||||||
|
Commands.hide_object(ctx, "ROCKET_HIDEOUT_B4F",
|
||||||
|
"ROCKETHIDEOUTB4F_GIOVANNI")
|
||||||
|
Commands.show_object(ctx, "ROCKET_HIDEOUT_B4F",
|
||||||
|
"ROCKETHIDEOUTB4F_SILPH_SCOPE")
|
||||||
|
end, function()
|
||||||
|
ow:afterBattle(result, battle)
|
||||||
|
done()
|
||||||
|
end))
|
||||||
|
end))
|
||||||
|
end))
|
||||||
|
end
|
||||||
|
ow:pushBattle(battle)
|
||||||
|
end))
|
||||||
|
end,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
-- -------------------------------------------------------------------
|
-- -------------------------------------------------------------------
|
||||||
-- Game Corner coins, prizes, and the rocket-poster switch that reveals
|
-- Game Corner coins, prizes, and the rocket-poster switch that reveals
|
||||||
-- the hideout stairs (scripts/GameCorner.asm, data/events/prizes.asm +
|
-- the hideout stairs (scripts/GameCorner.asm, data/events/prizes.asm +
|
||||||
@@ -390,24 +516,42 @@ M.GAME_CORNER = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
-- Red-version prize lists (data/events/prizes.asm, prize_mon_levels.asm)
|
-- Game Corner prize lists (data/events/prizes.asm, prize_mon_levels.asm).
|
||||||
local PRIZES = {
|
-- 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 = "ABRA", level = 9, cost = 180 },
|
||||||
{ kind = "mon", species = "CLEFAIRY", level = 8, cost = 500 },
|
{ kind = "mon", species = "CLEFAIRY", level = 8, cost = 500 },
|
||||||
{ kind = "mon", species = "NIDORINA", level = 17, cost = 1200 },
|
{ kind = "mon", species = "NIDORINA", level = 17, cost = 1200 },
|
||||||
{ kind = "mon", species = "DRATINI", level = 18, cost = 2800 },
|
{ kind = "mon", species = "DRATINI", level = 18, cost = 2800 },
|
||||||
{ kind = "mon", species = "SCYTHER", level = 25, cost = 5500 },
|
{ kind = "mon", species = "SCYTHER", level = 25, cost = 5500 },
|
||||||
{ kind = "mon", species = "PORYGON", level = 26, cost = 9999 },
|
{ kind = "mon", species = "PORYGON", level = 26, cost = 9999 },
|
||||||
{ kind = "item", item = "TM_DRAGON_RAGE", cost = 3300 },
|
PRIZE_TMS[1], PRIZE_TMS[2], PRIZE_TMS[3],
|
||||||
{ kind = "item", item = "TM_HYPER_BEAM", cost = 5500 },
|
|
||||||
{ kind = "item", item = "TM_SUBSTITUTE", cost = 7700 },
|
|
||||||
}
|
}
|
||||||
|
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 function prizeCounter(game, ow, npc, done)
|
||||||
local ListMenu = require("src.ui.ListMenu")
|
local ListMenu = require("src.ui.ListMenu")
|
||||||
local Commands = require("src.script.Commands")
|
local Commands = require("src.script.Commands")
|
||||||
local items = {}
|
local items = {}
|
||||||
for _, p in ipairs(PRIZES) do
|
for _, p in ipairs(activePrizes()) do
|
||||||
local label
|
local label
|
||||||
if p.kind == "mon" then
|
if p.kind == "mon" then
|
||||||
label = ("%s L%d"):format(game.data.pokemon[p.species].name, p.level)
|
label = ("%s L%d"):format(game.data.pokemon[p.species].name, p.level)
|
||||||
@@ -463,8 +607,9 @@ local DOCK_SHIP_BLOCKS = {
|
|||||||
|
|
||||||
M.VERMILION_DOCK = {
|
M.VERMILION_DOCK = {
|
||||||
onEnter = function(game, ow)
|
onEnter = function(game, ow)
|
||||||
|
local Flags = require("src.script.Flags")
|
||||||
local f = game.save.flags
|
local f = game.save.flags
|
||||||
if 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
|
-- the ship is long gone: erase her right away, and anyone who
|
||||||
-- still lands here is sent back out past the guard
|
-- still lands here is sent back out past the guard
|
||||||
for _, b in ipairs(DOCK_SHIP_BLOCKS) do
|
for _, b in ipairs(DOCK_SHIP_BLOCKS) do
|
||||||
@@ -480,13 +625,15 @@ M.VERMILION_DOCK = {
|
|||||||
elseif f.EVENT_GOT_HM01 and ow.player.cellY == 2 then
|
elseif f.EVENT_GOT_HM01 and ow.player.cellY == 2 then
|
||||||
-- VermilionDockSSAnneLeavesScript: only stepping OFF the ship
|
-- VermilionDockSSAnneLeavesScript: only stepping OFF the ship
|
||||||
-- triggers the departure (wDestinationWarpID == 1 in pokered) --
|
-- triggers the departure (wDestinationWarpID == 1 in pokered) --
|
||||||
-- the horn blows, smoke puffs drift off the funnel, the ship is
|
-- Music_Surfing plays for the sail-away cutscene, smoke puffs
|
||||||
-- erased to open water, and the player is walked off the dock
|
-- drift off the funnel, the horn blows, the ship is erased to
|
||||||
-- into the city past the guard (VermilionCity's
|
-- open water, and the player is walked off the dock into the
|
||||||
|
-- city past the guard (VermilionCity's
|
||||||
-- SCRIPT_VERMILIONCITY_PLAYER_EXIT_SHIP walk)
|
-- SCRIPT_VERMILIONCITY_PLAYER_EXIT_SHIP walk)
|
||||||
f.EVENT_SS_ANNE_LEFT = true
|
Flags.set(game.save, "EVENT_SS_ANNE_LEFT")
|
||||||
require("src.core.Music").stop()
|
local Music = require("src.core.Music")
|
||||||
require("src.core.Sound").play(game.data, "SS_Anne_Horn")
|
Music.stop()
|
||||||
|
Music.play(game.data, "Music_Surfing")
|
||||||
local function puff(n, cx)
|
local function puff(n, cx)
|
||||||
if n <= 0 then return end
|
if n <= 0 then return end
|
||||||
ow:startDustAnim(cx, 1, function() puff(n - 1, cx + 2) end)
|
ow:startDustAnim(cx, 1, function() puff(n - 1, cx + 2) end)
|
||||||
@@ -494,11 +641,15 @@ M.VERMILION_DOCK = {
|
|||||||
puff(3, 15)
|
puff(3, 15)
|
||||||
local rows = {}
|
local rows = {}
|
||||||
rows[#rows + 1] = { "wait", 100 }
|
rows[#rows + 1] = { "wait", 100 }
|
||||||
|
rows[#rows + 1] = { "play_sound", "SS_Anne_Horn" }
|
||||||
for _, b in ipairs(DOCK_SHIP_BLOCKS) do
|
for _, b in ipairs(DOCK_SHIP_BLOCKS) do
|
||||||
rows[#rows + 1] = { "replace_block", b.bx, b.by, b.water }
|
rows[#rows + 1] = { "replace_block", b.bx, b.by, b.water }
|
||||||
end
|
end
|
||||||
rows[#rows + 1] = { "wait", 30 }
|
rows[#rows + 1] = { "wait", 30 }
|
||||||
rows[#rows + 1] = { "move_player", "up", 2 }
|
rows[#rows + 1] = { "move_player", "up", 2 }
|
||||||
|
-- keep Music_Surfing across the city warp (pokered stays on it
|
||||||
|
-- through the exit-ship walk)
|
||||||
|
rows[#rows + 1] = { "play_music", "Music_Surfing", { keep = true } }
|
||||||
rows[#rows + 1] = { "warp", "VERMILION_CITY", 18, 31, "up" }
|
rows[#rows + 1] = { "warp", "VERMILION_CITY", 18, 31, "up" }
|
||||||
rows[#rows + 1] = { "move_player", "up", 2 }
|
rows[#rows + 1] = { "move_player", "up", 2 }
|
||||||
ow:queueScript(rows)
|
ow:queueScript(rows)
|
||||||
|
|||||||
+167
-25
@@ -262,38 +262,180 @@ M.CINNABAR_ISLAND = {
|
|||||||
-- Pewter's youngster stops you leaving east before Brock is beaten and
|
-- Pewter's youngster stops you leaving east before Brock is beaten and
|
||||||
-- escorts you to the gym (scripts/PewterCity.asm
|
-- escorts you to the gym (scripts/PewterCity.asm
|
||||||
-- PewterCityCheckPlayerLeavingEastScript /
|
-- PewterCityCheckPlayerLeavingEastScript /
|
||||||
-- PewterCityYoungsterShowsPlayerGymScript, engine/events/pewter_guys.asm
|
-- PewterCityYoungsterShowsPlayerGymScript, engine/overworld/auto_movement.asm
|
||||||
-- PewterGymGuyCoords): the "follow me" lines, then the player is walked
|
-- PewterGymGuyMovementScriptPointerTable, engine/events/pewter_guys.asm
|
||||||
-- west along the road to the front of the PEWTER_GYM door at (16,17).
|
-- 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 = {
|
M.PEWTER_CITY = {
|
||||||
|
escort = pewterEscort,
|
||||||
|
-- PewterCityYoungsterText: talking also arms the gym escort script
|
||||||
|
talk = {
|
||||||
|
TEXT_PEWTERCITY_YOUNGSTER = function(game, ow, npc, done)
|
||||||
|
pewterGymEscort(game, ow)
|
||||||
|
if done then done() end
|
||||||
|
end,
|
||||||
|
},
|
||||||
onStep = function(game, ow, x, y)
|
onStep = function(game, ow, x, y)
|
||||||
if game.save.flags.EVENT_BEAT_BROCK then return false end
|
if game.save.flags.EVENT_BEAT_BROCK then return false end
|
||||||
if ow.runner:isRunning() or #ow.scriptMoves > 0 then return false end
|
if ow.runner:isRunning() or #ow.scriptMoves > 0 then return false end
|
||||||
if not inCoords({ { 35, 17 }, { 36, 17 }, { 37, 18 }, { 37, 19 } }, x, y) then
|
if not inCoords({ { 35, 17 }, { 36, 17 }, { 37, 18 }, { 37, 19 } }, x, y) then
|
||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
local t = text(game)
|
pewterGymEscort(game, ow)
|
||||||
-- walk up onto the road row (y=17), west to one tile east of the gym
|
|
||||||
-- door, then drop below the door and turn to face it
|
|
||||||
local steps = {}
|
|
||||||
for _ = 1, y - 17 do steps[#steps + 1] = "up" end
|
|
||||||
for _ = 1, x - 17 do steps[#steps + 1] = "left" end
|
|
||||||
steps[#steps + 1] = "down"
|
|
||||||
steps[#steps + 1] = "left"
|
|
||||||
local function walk(i)
|
|
||||||
if not steps[i] then
|
|
||||||
ow.player.facing = "up"
|
|
||||||
return
|
|
||||||
end
|
|
||||||
ow:scriptMove(ow.player, steps[i], 1, function() walk(i + 1) end)
|
|
||||||
end
|
|
||||||
push(game, t._PewterCityYoungsterYoureATrainerFollowMeText
|
|
||||||
or "Hey! You're a\ntrainer, right?", function()
|
|
||||||
push(game, t._PewterCityYoungsterGoTakeOnBrockText
|
|
||||||
or "Go take on BROCK\nat the GYM first!", function()
|
|
||||||
walk(1)
|
|
||||||
end)
|
|
||||||
end)
|
|
||||||
return true
|
return true
|
||||||
end,
|
end,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
-- "OPP_CLASS#partyIndex" (the object_event trainer args). Hand-ported
|
-- "OPP_CLASS#partyIndex" (the object_event trainer args). Hand-ported
|
||||||
-- from the leaders'/bosses' text_asm victory scripts:
|
-- from the leaders'/bosses' text_asm victory scripts:
|
||||||
-- gym badges: scripts/PewterGym.asm ... ViridianGym.asm
|
-- gym badges: scripts/PewterGym.asm ... ViridianGym.asm
|
||||||
-- Rocket Hideout Giovanni: his Silph Scope is an item ball next to him
|
-- Rocket Hideout Giovanni: Silph Scope is a hidden item ball revealed by
|
||||||
-- (data/maps/objects/RocketHideoutB4F.asm), so no reward entry needed.
|
-- 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.
|
-- The TM each gym leader hands out afterwards is also ported.
|
||||||
--
|
--
|
||||||
-- `deactivate` lists the EVENT_BEAT_* flags each gym's victory script
|
-- `deactivate` lists the EVENT_BEAT_* flags each gym's victory script
|
||||||
@@ -21,9 +22,16 @@ local function range(prefix, first, last)
|
|||||||
end
|
end
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
-- PewterGym.asm .gymVictory also HideObject TOGGLE_GYM_GUY
|
||||||
|
-- (PEWTERCITY_YOUNGSTER) and TOGGLE_ROUTE_22_RIVAL_1 so the east-exit
|
||||||
|
-- escort NPC and the first Route 22 rival stay gone after the badge.
|
||||||
["OPP_BROCK#1"] = { badge = "BOULDERBADGE", flag = "EVENT_BEAT_BROCK",
|
["OPP_BROCK#1"] = { badge = "BOULDERBADGE", flag = "EVENT_BEAT_BROCK",
|
||||||
item = "TM_BIDE",
|
item = "TM_BIDE",
|
||||||
deactivate = { "EVENT_BEAT_PEWTER_GYM_TRAINER_0" } },
|
deactivate = { "EVENT_BEAT_PEWTER_GYM_TRAINER_0" },
|
||||||
|
hide = {
|
||||||
|
{ "PEWTER_CITY", "PEWTERCITY_YOUNGSTER" },
|
||||||
|
{ "ROUTE_22", "ROUTE22_RIVAL1" },
|
||||||
|
} },
|
||||||
["OPP_MISTY#1"] = { badge = "CASCADEBADGE", flag = "EVENT_BEAT_MISTY",
|
["OPP_MISTY#1"] = { badge = "CASCADEBADGE", flag = "EVENT_BEAT_MISTY",
|
||||||
item = "TM_BUBBLEBEAM",
|
item = "TM_BUBBLEBEAM",
|
||||||
deactivate = range("EVENT_BEAT_CERULEAN_GYM_TRAINER_", 0, 1) },
|
deactivate = range("EVENT_BEAT_CERULEAN_GYM_TRAINER_", 0, 1) },
|
||||||
|
|||||||
+28
-11
@@ -42,8 +42,10 @@ Game Boy equivalent:
|
|||||||
rows above the player recede and rows below come toward the viewer. Only
|
rows above the player recede and rows below come toward the viewer. Only
|
||||||
things that actually *stand* on the ground draw as upright billboards,
|
things that actually *stand* on the ground draw as upright billboards,
|
||||||
unscaled and pixel-identical to flat mode: the player, NPCs, item balls,
|
unscaled and pixel-identical to flat mode: the player, NPCs, item balls,
|
||||||
and the screen-anchored FX attached to them (heal machine glow, emote
|
and the standing FX attached to them (emote bubbles, the fishing rod,
|
||||||
bubbles, the fishing rod, the FLY bird). An earlier revision tried
|
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
|
billboarding buildings/trees/signs too (cutting them out of the ground
|
||||||
per hand-curated per-tileset tables); that chased an endless tail of
|
per hand-curated per-tileset tables); that chased an endless tail of
|
||||||
special cases, dense tree canopy, fences fused into grass, building
|
special cases, dense tree canopy, fences fused into grass, building
|
||||||
@@ -68,19 +70,31 @@ Game Boy equivalent:
|
|||||||
|
|
||||||
## Colors mode
|
## Colors mode
|
||||||
|
|
||||||
The `2` key (and the Options menu COLORS row) cycles the global shade-remap
|
The `2` key (and the Options menu COLORS row) cycles the display mode
|
||||||
display mode through **GBC → OG → OG INV → GBC INV → CLASSIC → GBC**:
|
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**: force the four DMG grays (colorization off).
|
||||||
- **OG INV**: inverted DMG grays.
|
- **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
|
- **CLASSIC**: original Game Boy pea-soup greens
|
||||||
(`#9BBC0F` / `#8BAC0F` / `#306230` / `#0F380F`).
|
(`#9BBC0F` / `#8BAC0F` / `#306230` / `#0F380F`).
|
||||||
|
|
||||||
The transform is applied centrally in `PaletteFX.sendColors`, so it covers
|
The shade-remap transform is applied centrally in `PaletteFX.sendColors`, so
|
||||||
overworld, menus, battles, and tilt upright billboards. Persisted as
|
it covers overworld, menus, battles, and tilt upright billboards. OG RED's
|
||||||
`save.options.colors`.
|
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
|
## GBC FX
|
||||||
|
|
||||||
@@ -132,7 +146,10 @@ migrated once into `options.lua` on load.
|
|||||||
- Music / SFX volume
|
- Music / SFX volume
|
||||||
- Music Filter
|
- Music Filter
|
||||||
- OG GLITCHES on / off (Gen 1 quirks vs. modern-clean battle rules)
|
- OG GLITCHES on / off (Gen 1 quirks vs. modern-clean battle rules)
|
||||||
- COLORS (GBC / RED++ / OG / OG INV / GBC INV / CLASSIC), also hotkey `2`
|
- COLORS (OG RED / SGB / RED++ / OG / OG INV / SGB INV / CLASSIC), also
|
||||||
(RED++ uses pokered-gbc SuperPalettes + per-species mon colors)
|
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
|
- TILT (OFF / 15 / 35 / 50), also hotkey `3` while free-roaming
|
||||||
- GBC FX (OFF / 1 / 2 / 3 / 4), also hotkey `5`
|
- GBC FX (OFF / 1 / 2 / 3 / 4), also hotkey `5`
|
||||||
|
- MAX FPS (30 / 40 / 50 / 60 / 75 / 90 / 100 / 120 / 144 / 160, default 60),
|
||||||
|
a hard render frame-rate cap (`save.options.fpsCap`).
|
||||||
@@ -26,7 +26,19 @@ local function scriptedIterations()
|
|||||||
return math.max(1, math.floor(require("src.core.GameSpeed").clamp(speedOverride)))
|
return math.max(1, math.floor(require("src.core.GameSpeed").clamp(speedOverride)))
|
||||||
end
|
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 = require("src.core.Game")
|
||||||
Game:load()
|
Game:load()
|
||||||
if os.getenv("POKEPORT_AUTOPILOT") then
|
if os.getenv("POKEPORT_AUTOPILOT") then
|
||||||
@@ -68,20 +80,46 @@ function love.load(args)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local RomImporter = require("src.import.RomImporter")
|
local RomImporter = require("src.import.RomImporter")
|
||||||
if os.getenv("POKEPORT_FORCE_IMPORT") == "1" or not RomImporter.isReady() then
|
local forceImport = os.getenv("POKEPORT_FORCE_IMPORT") == "1"
|
||||||
Importer = RomImporter.new(function()
|
local importPath = os.getenv("POKEPORT_IMPORT_ROM")
|
||||||
if os.getenv("POKEPORT_IMPORT_ONLY") == "1" then
|
-- Scripted / headless runs pick their game from POKEPORT_VERSION (default
|
||||||
love.event.quit()
|
-- Red); the launcher's per-column choice does not apply to them.
|
||||||
return
|
local scriptedVersion = os.getenv("POKEPORT_VERSION") or "red"
|
||||||
end
|
local ready = RomImporter.isReady(scriptedVersion)
|
||||||
Importer = nil
|
-- Scripted / headless runs have to reach the game with no human pressing
|
||||||
bootGame()
|
-- Play: an autopilot, a frame driver, an import-only build step, or an
|
||||||
end)
|
-- explicit ROM path all bypass the interactive launcher and keep today's
|
||||||
local importPath = os.getenv("POKEPORT_IMPORT_ROM")
|
-- import-then-boot (or boot-straight-in) behavior.
|
||||||
if importPath then Importer:startPath(importPath) end
|
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
|
return
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
function love.update(dt)
|
function love.update(dt)
|
||||||
@@ -261,3 +299,78 @@ function love.filedropped(file)
|
|||||||
end
|
end
|
||||||
if Importer then Importer:filedropped(file) end
|
if Importer then Importer:filedropped(file) end
|
||||||
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,7 +6,9 @@
|
|||||||
#app.name=LÖVE for Android
|
#app.name=LÖVE for Android
|
||||||
|
|
||||||
app.application_id=com.theboisclub.pokemonred
|
app.application_id=com.theboisclub.pokemonred
|
||||||
app.orientation=portrait
|
# fullUser: allow every orientation the player's device permits (portrait and
|
||||||
|
# landscape), honouring their auto-rotate lock. Was "portrait" (locked).
|
||||||
|
app.orientation=fullUser
|
||||||
app.version_code=32
|
app.version_code=32
|
||||||
app.version_name=11.5a
|
app.version_name=11.5a
|
||||||
|
|
||||||
|
|||||||
@@ -62,10 +62,15 @@
|
|||||||
<key>UISupportedInterfaceOrientations</key>
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
<array>
|
<array>
|
||||||
<string>UIInterfaceOrientationPortrait</string>
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
</array>
|
</array>
|
||||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||||
<array>
|
<array>
|
||||||
<string>UIInterfaceOrientationPortrait</string>
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
</array>
|
</array>
|
||||||
<key>UTExportedTypeDeclarations</key>
|
<key>UTExportedTypeDeclarations</key>
|
||||||
<array>
|
<array>
|
||||||
|
|||||||
+6
-6
@@ -8,12 +8,12 @@
|
|||||||
# [--notary-profile NAME] [--no-notarize]
|
# [--notary-profile NAME] [--no-notarize]
|
||||||
# [--release] # ios only: release config instead of debug
|
# [--release] # ios only: release config instead of debug
|
||||||
#
|
#
|
||||||
# Output: dist/mac/PokemonRed-macos.zip
|
# Output: dist/mac/gen1recomp-macos.zip
|
||||||
# dist/win/PokemonRed-win64.zip
|
# dist/win/gen1recomp-win64.zip
|
||||||
# dist/linux/PokemonRed-linux.zip (fused x86_64 AppImage)
|
# dist/linux/gen1recomp-linux.zip (fused x86_64 AppImage)
|
||||||
# dist/android/debug/*.apk (full gradle output stays under
|
# dist/android/debug/*.apk (full gradle output stays under
|
||||||
# mobile/android/app/build/outputs/apk/embedNoRecord/)
|
# 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/)
|
# under mobile/ios/build/Build/Products/)
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -25,7 +25,7 @@ WORK="$HERE/work"
|
|||||||
DIST="$ROOT/dist"
|
DIST="$ROOT/dist"
|
||||||
ENTITLEMENTS="$ROOT/scripts/macos-entitlements.plist"
|
ENTITLEMENTS="$ROOT/scripts/macos-entitlements.plist"
|
||||||
|
|
||||||
APP_NAME="PokemonRed"
|
APP_NAME="gen1recomp"
|
||||||
BUNDLE_ID="com.theboisclub.pokemonred"
|
BUNDLE_ID="com.theboisclub.pokemonred"
|
||||||
LOVE_VERSION="11.5"
|
LOVE_VERSION="11.5"
|
||||||
VERSION="$(git -C "$ROOT" rev-parse --short HEAD 2>/dev/null || echo dev)"
|
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"
|
LOVE_FILE="$WORK/game.love"
|
||||||
rm -f "$LOVE_FILE"
|
rm -f "$LOVE_FILE"
|
||||||
(cd "$ROOT" && zip -q -9 -r "$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/*')
|
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
||||||
if unzip -Z1 "$LOVE_FILE" \
|
if unzip -Z1 "$LOVE_FILE" \
|
||||||
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
|
| 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"
|
EMBED_ASSETS="$ANDROID_DIR/app/src/embed/assets"
|
||||||
LOVE_FILE="$EMBED_ASSETS/game.love"
|
LOVE_FILE="$EMBED_ASSETS/game.love"
|
||||||
DIST="$ROOT/dist/android"
|
DIST="$ROOT/dist/android"
|
||||||
APP_NAME="Pokemon Red"
|
APP_NAME="gen1recomp"
|
||||||
APPLICATION_ID="com.theboisclub.pokemonred"
|
APPLICATION_ID="com.theboisclub.pokemonred"
|
||||||
LOVE_ANDROID_VERSION="11.5a"
|
LOVE_ANDROID_VERSION="11.5a"
|
||||||
NDK_VERSION="25.2.9519653"
|
NDK_VERSION="25.2.9519653"
|
||||||
@@ -101,7 +101,7 @@ def set_prop(text, key, value):
|
|||||||
text = re.sub(r"(?m)^app\.name_byte_array=.*\n?", "", text)
|
text = re.sub(r"(?m)^app\.name_byte_array=.*\n?", "", text)
|
||||||
text = set_prop(text, "app.name", name)
|
text = set_prop(text, "app.name", name)
|
||||||
text = set_prop(text, "app.application_id", app_id)
|
text = set_prop(text, "app.application_id", app_id)
|
||||||
text = set_prop(text, "app.orientation", "portrait")
|
text = set_prop(text, "app.orientation", "fullUser")
|
||||||
if version:
|
if version:
|
||||||
text = set_prop(text, "app.version_name", version)
|
text = set_prop(text, "app.version_name", version)
|
||||||
text = set_prop(text, "app.version_code", version_code)
|
text = set_prop(text, "app.version_code", version_code)
|
||||||
@@ -137,7 +137,7 @@ pack_game_love() {
|
|||||||
mkdir -p "$EMBED_ASSETS"
|
mkdir -p "$EMBED_ASSETS"
|
||||||
rm -f "$LOVE_FILE"
|
rm -f "$LOVE_FILE"
|
||||||
(cd "$ROOT" && zip -q -9 -r "$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 '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
|
||||||
-x 'data/generated/*' -x 'assets/generated/*')
|
-x 'data/generated/*' -x 'assets/generated/*')
|
||||||
if unzip -Z1 "$LOVE_FILE" \
|
if unzip -Z1 "$LOVE_FILE" \
|
||||||
|
|||||||
@@ -15,8 +15,8 @@
|
|||||||
# - mobile/ios/love-src/ (see --fetch / mobile/ios/README.md)
|
# - mobile/ios/love-src/ (see --fetch / mobile/ios/README.md)
|
||||||
# - prebuilt iOS libraries under love-src/platform/xcode/ios/libraries/
|
# - prebuilt iOS libraries under love-src/platform/xcode/ios/libraries/
|
||||||
#
|
#
|
||||||
# Output: dist/ios/<Config>-<sdk>/PokemonRed.app (convenience copy)
|
# Output: dist/ios/<Config>-<sdk>/gen1recomp.app (convenience copy)
|
||||||
# mobile/ios/build/Build/Products/<Config>-<sdk>/PokemonRed.app
|
# mobile/ios/build/Build/Products/<Config>-<sdk>/gen1recomp.app
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -33,8 +33,8 @@ RESOURCES_DIR="$XCODE_DIR/ios/resources"
|
|||||||
LOVE_FILE="$RESOURCES_DIR/game.love"
|
LOVE_FILE="$RESOURCES_DIR/game.love"
|
||||||
LIBS_DIR="$XCODE_DIR/ios/libraries"
|
LIBS_DIR="$XCODE_DIR/ios/libraries"
|
||||||
|
|
||||||
APP_NAME="PokemonRed"
|
APP_NAME="gen1recomp"
|
||||||
DISPLAY_NAME="Pokemon Red"
|
DISPLAY_NAME="gen1recomp"
|
||||||
BUNDLE_ID="com.theboisclub.pokemonred"
|
BUNDLE_ID="com.theboisclub.pokemonred"
|
||||||
LOVE_VERSION="$(tr -d '[:space:]' < "$IOS_DIR/LOVE_VERSION" 2>/dev/null || echo 11.5)"
|
LOVE_VERSION="$(tr -d '[:space:]' < "$IOS_DIR/LOVE_VERSION" 2>/dev/null || echo 11.5)"
|
||||||
IOS_SOURCE_ZIP="love-${LOVE_VERSION}-ios-source.zip"
|
IOS_SOURCE_ZIP="love-${LOVE_VERSION}-ios-source.zip"
|
||||||
@@ -155,7 +155,7 @@ require_ios_libraries
|
|||||||
apply_ios_branding() {
|
apply_ios_branding() {
|
||||||
[ -f "$OVERLAY_PLIST" ] || fail "missing overlay plist: $OVERLAY_PLIST"
|
[ -f "$OVERLAY_PLIST" ] || fail "missing overlay plist: $OVERLAY_PLIST"
|
||||||
local dest="$XCODE_DIR/ios/love-ios.plist"
|
local dest="$XCODE_DIR/ios/love-ios.plist"
|
||||||
say "applying iOS branding (portrait-only Info.plist, display name)"
|
say "applying iOS branding (portrait + landscape Info.plist, display name)"
|
||||||
cp "$OVERLAY_PLIST" "$dest"
|
cp "$OVERLAY_PLIST" "$dest"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,7 +166,7 @@ pack_game_love() {
|
|||||||
rm -f "$LOVE_FILE"
|
rm -f "$LOVE_FILE"
|
||||||
# Same payload as scripts/build.sh / build_android.sh: game sources only.
|
# Same payload as scripts/build.sh / build_android.sh: game sources only.
|
||||||
(cd "$ROOT" && zip -q -9 -r "$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 '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
|
||||||
-x 'data/generated/*' -x 'assets/generated/*')
|
-x 'data/generated/*' -x 'assets/generated/*')
|
||||||
if unzip -Z1 "$LOVE_FILE" \
|
if unzip -Z1 "$LOVE_FILE" \
|
||||||
|
|||||||
@@ -144,6 +144,25 @@ function AnimPlayer.new(data)
|
|||||||
}, AnimPlayer)
|
}, AnimPlayer)
|
||||||
end
|
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, ...)
|
function AnimPlayer:warnOnce(key, fmt, ...)
|
||||||
if not self.warned[key] then
|
if not self.warned[key] then
|
||||||
self.warned[key] = true
|
self.warned[key] = true
|
||||||
|
|||||||
@@ -934,6 +934,20 @@ end
|
|||||||
-- (end_of_battle.asm clears wLowHealthAlarm when a battle ends)
|
-- (end_of_battle.asm clears wLowHealthAlarm when a battle ends)
|
||||||
function BattleState:exit()
|
function BattleState:exit()
|
||||||
require("src.core.Sound").stopLoop("Low_Health_Alarm")
|
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
|
end
|
||||||
|
|
||||||
-- An action the battler is locked into (bypasses the menu), or nil.
|
-- An action the battler is locked into (bypasses the menu), or nil.
|
||||||
@@ -3551,12 +3565,28 @@ function BattleState:sgbBattlePals()
|
|||||||
if placeholder or not b then return pals.MEWMON or pals.GREENBAR end
|
if placeholder or not b then return pals.MEWMON or pals.GREENBAR end
|
||||||
return PaletteFX.monPal(self.data, b.mon.species) or pals.MEWMON
|
return PaletteFX.monPal(self.data, b.mon.species) or pals.MEWMON
|
||||||
end
|
end
|
||||||
return {
|
local out = {
|
||||||
[0] = bar(self.player),
|
[0] = bar(self.player),
|
||||||
[1] = bar(self.enemy),
|
[1] = bar(self.enemy),
|
||||||
[2] = mon(self.player, self.showPlayerBack or self.safari or self.demo),
|
[2] = mon(self.player, self.showPlayerBack or self.safari or self.demo),
|
||||||
[3] = mon(self.enemy, self.showEnemyTrainer),
|
[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
|
end
|
||||||
|
|
||||||
-- the SGB palette covering a screen pixel (BlkPacket_Battle regions)
|
-- the SGB palette covering a screen pixel (BlkPacket_Battle regions)
|
||||||
|
|||||||
+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
|
||||||
@@ -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
|
||||||
+23
-1
@@ -99,7 +99,12 @@ end
|
|||||||
-- total conversion overrides. Threaded into SaveData so persistence stays
|
-- total conversion overrides. Threaded into SaveData so persistence stays
|
||||||
-- free of a Data dependency.
|
-- free of a Data dependency.
|
||||||
function Game:bootConfig()
|
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
|
end
|
||||||
|
|
||||||
-- the title screen with its NEW GAME / CONTINUE wiring; used at boot
|
-- 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.
|
-- frame dt (not the fixed logic step) for a smooth ~0.25s glide.
|
||||||
require("src.render.Tilt").update(dt)
|
require("src.render.Tilt").update(dt)
|
||||||
pcall(function() require("src.core.DiscordPresence").update(dt) end)
|
pcall(function() require("src.core.DiscordPresence").update(dt) end)
|
||||||
|
-- Steady-state memory backstop: advance the incremental collector one
|
||||||
|
-- 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
|
end
|
||||||
|
|
||||||
-- render.zones' identity default: unhooked, the zone list reaches the blit
|
-- render.zones' identity default: unhooked, the zone list reaches the blit
|
||||||
@@ -433,6 +445,9 @@ function Game:applyOptions(opts)
|
|||||||
require("src.render.Tilt").applyOptions(opts)
|
require("src.render.Tilt").applyOptions(opts)
|
||||||
require("src.render.GBCFX").applyOptions(opts)
|
require("src.render.GBCFX").applyOptions(opts)
|
||||||
require("src.core.VideoMode").applyOptions(opts)
|
require("src.core.VideoMode").applyOptions(opts)
|
||||||
|
-- normalizes a nil/garbage cap to the 60 default, so old saves with no
|
||||||
|
-- fpsCap key pace at the standard rate (issue #88)
|
||||||
|
require("src.core.FrameCap").applyOptions(opts)
|
||||||
Input:applyBindings(opts.bindings)
|
Input:applyBindings(opts.bindings)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -445,6 +460,13 @@ function Game:restoreSave(loaded, recovered)
|
|||||||
-- SaveData.load and skip on the format guard
|
-- SaveData.load and skip on the format guard
|
||||||
local activeMods = self.modStatus and self.modStatus.loaded
|
local activeMods = self.modStatus and self.modStatus.loaded
|
||||||
SaveData.runMigrations(loaded, self.mods and self.mods.migrations, activeMods)
|
SaveData.runMigrations(loaded, self.mods and self.mods.migrations, activeMods)
|
||||||
|
-- Issue #103: 0.1.11 softlocks left CONTINUE in HALL_OF_FAME with
|
||||||
|
-- lastOutdoor on Indigo. One-shot relocate + heal before validate.
|
||||||
|
if SaveData.needsPostGameRescue(loaded) then
|
||||||
|
SaveData.applyPostGameHome(loaded, self:bootConfig())
|
||||||
|
local Pokemon = require("src.pokemon.Pokemon")
|
||||||
|
for _, mon in ipairs(loaded.party or {}) do Pokemon.heal(mon) end
|
||||||
|
end
|
||||||
local modsDiff = SaveData.modsDiff(loaded, activeMods)
|
local modsDiff = SaveData.modsDiff(loaded, activeMods)
|
||||||
local report = SaveData.validate(loaded, self.data)
|
local report = SaveData.validate(loaded, self.data)
|
||||||
report.recovered = recovered
|
report.recovered = recovered
|
||||||
|
|||||||
@@ -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
|
||||||
+18
-8
@@ -254,6 +254,7 @@ function Music.stop()
|
|||||||
require("src.core.ChipAudio").stopMusic()
|
require("src.core.ChipAudio").stopMusic()
|
||||||
state.current, state.source, state.loopSource, state.fade = nil, nil, nil, nil
|
state.current, state.source, state.loopSource, state.fade = nil, nil, nil, nil
|
||||||
state.chip = false
|
state.chip = false
|
||||||
|
state.pendingRestore = nil
|
||||||
if previous and Runtime.wants("music.stopped") then
|
if previous and Runtime.wants("music.stopped") then
|
||||||
Runtime.emit("music.stopped", { song = previous })
|
Runtime.emit("music.stopped", { song = previous })
|
||||||
end
|
end
|
||||||
@@ -347,18 +348,25 @@ end
|
|||||||
function Music.playOnce(data, song)
|
function Music.playOnce(data, song)
|
||||||
if not songDef(data, song) then return false end
|
if not songDef(data, song) then return false end
|
||||||
Music.play(data, song, false, { reason = "once" })
|
Music.play(data, song, false, { reason = "once" })
|
||||||
|
-- play() can no-op (hook silence, failed def); only arm restore when
|
||||||
|
-- the jingle actually became current
|
||||||
|
if state.current ~= song then return false end
|
||||||
state.pendingRestore = true
|
state.pendingRestore = true
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
|
|
||||||
-- is a playOnce jingle still sounding? (AnimateHealingMachine's
|
local function chipAwaitingFirstBuffer()
|
||||||
-- .waitLoop2 holds the healing machine until MUSIC_PKMN_HEALED ends)
|
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()
|
function Music.oneShotPlaying()
|
||||||
if not state.pendingRestore then return false end
|
return state.pendingRestore == true
|
||||||
local src = state.source
|
|
||||||
if not src then return false end
|
|
||||||
local ok, playing = pcall(src.isPlaying, src)
|
|
||||||
return ok and playing or false
|
|
||||||
end
|
end
|
||||||
|
|
||||||
function Music.restoreMap(data)
|
function Music.restoreMap(data)
|
||||||
@@ -441,8 +449,10 @@ function Music.update(data)
|
|||||||
state.source = loopSrc
|
state.source = loopSrc
|
||||||
pcall(loopSrc.play, loopSrc)
|
pcall(loopSrc.play, loopSrc)
|
||||||
end
|
end
|
||||||
|
-- do not treat "threaded source still waiting on its first buffer" as
|
||||||
|
-- ended, or playOnce jingles get restored over before they can sound
|
||||||
if state.pendingRestore and sourceStopped(state.source)
|
if state.pendingRestore and sourceStopped(state.source)
|
||||||
and not state.loopSource then
|
and not state.loopSource and not chipAwaitingFirstBuffer() then
|
||||||
Music.restoreMap(data)
|
Music.restoreMap(data)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
+111
-13
@@ -18,14 +18,30 @@ local Semver = require("src.mods.Semver")
|
|||||||
local Boxes = require("src.pokemon.Boxes")
|
local Boxes = require("src.pokemon.Boxes")
|
||||||
local Bag = require("src.inventory.Bag")
|
local Bag = require("src.inventory.Bag")
|
||||||
|
|
||||||
|
local GameVersion = require("src.core.GameVersion")
|
||||||
|
|
||||||
local SaveData = {}
|
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"
|
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
|
-- Main / backup / staged-witness names for a version (defaults to the active
|
||||||
local BACKUP_FILENAME = FILENAME .. ".bak"
|
-- one). The backup is a rolling copy and .tmp is the staged-write witness;
|
||||||
local TMP_FILENAME = FILENAME .. ".tmp"
|
-- 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
|
-- ------- portable mode
|
||||||
-- LÖVE's save directory is always the OS per-user path derived from the
|
-- LÖVE's save directory is always the OS per-user path derived from the
|
||||||
@@ -83,15 +99,41 @@ local function detectPortable()
|
|||||||
portableChecked = true
|
portableChecked = true
|
||||||
portableBase = false
|
portableBase = false
|
||||||
if not (love and love.filesystem) then return false end
|
if not (love and love.filesystem) then return false end
|
||||||
|
-- Desktop only: portable mode carries the save (and, since issue #74, the
|
||||||
|
-- ROM cache) in the game folder next to the executable/source. On
|
||||||
|
-- Android/iOS the source is a read-only package with no such folder, so
|
||||||
|
-- portable mode never applies there.
|
||||||
|
if love.system and love.system.getOS then
|
||||||
|
local osName = love.system.getOS()
|
||||||
|
if osName ~= "Windows" and osName ~= "Linux" and osName ~= "OS X" then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
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 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
|
||||||
|
-- marker. On Windows/Linux the executable is not a bundle, so this is nil
|
||||||
|
-- and the plain source-base directory (next to the .exe/AppImage) is used.
|
||||||
|
local function appContainer(path)
|
||||||
|
local appPath = path and path:match("^(.*%.app)/Contents/")
|
||||||
|
return appPath and appPath:match("^(.*)/[^/]+$") or nil
|
||||||
|
end
|
||||||
|
-- Order: the .app's containing folder (packaged macOS), then the
|
||||||
|
-- source-base directory (next to a packaged .exe/AppImage), then the
|
||||||
|
-- source itself (a `love <gamedir>` run drops portable.txt in the game
|
||||||
|
-- folder). First one holding the marker wins. Built by appending so a
|
||||||
|
-- nil (e.g. no .app in the path) never truncates the ipairs scan.
|
||||||
local candidates = {}
|
local candidates = {}
|
||||||
if love.filesystem.getSourceBaseDirectory then
|
local appDir = appContainer(src) or appContainer(sbd)
|
||||||
candidates[#candidates + 1] = love.filesystem.getSourceBaseDirectory()
|
if appDir then candidates[#candidates + 1] = appDir end
|
||||||
end
|
if sbd then candidates[#candidates + 1] = sbd end
|
||||||
if love.filesystem.getSource then
|
if src then candidates[#candidates + 1] = src end
|
||||||
candidates[#candidates + 1] = love.filesystem.getSource()
|
|
||||||
end
|
|
||||||
for _, base in ipairs(candidates) do
|
for _, base in ipairs(candidates) do
|
||||||
if base and base ~= "" and pathExists(base .. SEP .. PORTABLE_MARKER) then
|
if base ~= "" and pathExists(base .. SEP .. PORTABLE_MARKER) then
|
||||||
portableBase = base
|
portableBase = base
|
||||||
break
|
break
|
||||||
end
|
end
|
||||||
@@ -150,6 +192,8 @@ function SaveData.defaultOptions()
|
|||||||
gbcfx = 0,
|
gbcfx = 0,
|
||||||
-- windowed | borderless (desktop fullscreen); ignored on mobile
|
-- windowed | borderless (desktop fullscreen); ignored on mobile
|
||||||
videoMode = "windowed",
|
videoMode = "windowed",
|
||||||
|
-- hard render frame-rate cap; render-only pacing (issue #88, FrameCap.lua)
|
||||||
|
fpsCap = 60,
|
||||||
-- Native mod enablement is an installation option, not save-slot data.
|
-- Native mod enablement is an installation option, not save-slot data.
|
||||||
-- Missing entries mean enabled so newly installed mods work by default.
|
-- Missing entries mean enabled so newly installed mods work by default.
|
||||||
mods = {},
|
mods = {},
|
||||||
@@ -423,6 +467,17 @@ SaveData.addCoreMigration(1, function(save)
|
|||||||
Boxes.ensure(save)
|
Boxes.ensure(save)
|
||||||
end)
|
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)
|
||||||
|
|
||||||
-- ------- write
|
-- ------- write
|
||||||
|
|
||||||
-- Game progress only; options are written separately via saveOptions.
|
-- Game progress only; options are written separately via saveOptions.
|
||||||
@@ -432,6 +487,9 @@ end)
|
|||||||
-- itself rolls the last good save into .bak and stages the new bytes as
|
-- 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.
|
-- a .tmp witness before the swap, so a crash mid-write is recoverable.
|
||||||
function SaveData.save(data, mods)
|
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
|
if data.options then
|
||||||
SaveData.saveOptions(data.options)
|
SaveData.saveOptions(data.options)
|
||||||
end
|
end
|
||||||
@@ -471,7 +529,10 @@ end
|
|||||||
-- returns the parsed save plus "tmp"/"bak" when the main file was gone
|
-- 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
|
-- or corrupt and a staged/backup copy was promoted; Game surfaces the
|
||||||
-- recovery on the load report
|
-- 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 fs = persistFs(nil)
|
||||||
local data, err = readTable(fs, FILENAME)
|
local data, err = readTable(fs, FILENAME)
|
||||||
local recovered
|
local recovered
|
||||||
@@ -752,6 +813,36 @@ function SaveData.defaultHeal(boot)
|
|||||||
return { map = map, x = boot.startX or 3, y = boot.startY or 6 }
|
return { map = map, x = boot.startX or 3, y = boot.startY or 6 }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Post-credits home (issue #103). pokered left the player in HALL_OF_FAME
|
||||||
|
-- after jp Init; this port places CONTINUE at the NewGameWarp bedroom and
|
||||||
|
-- retargets LAST_MAP exits (Red's house mats) at the heal-point town so
|
||||||
|
-- leaving the house does not dump the player back at Indigo Plateau.
|
||||||
|
-- Marks postGameHomeOk so a later intentional HoF save is not relocated.
|
||||||
|
function SaveData.applyPostGameHome(save, boot)
|
||||||
|
boot = type(boot) == "table" and boot or {}
|
||||||
|
local heal = SaveData.defaultHeal(boot)
|
||||||
|
save.lastHeal = { map = heal.map, x = heal.x, y = heal.y }
|
||||||
|
save.lastOutdoor = { id = heal.map, x = heal.x, y = heal.y }
|
||||||
|
save.player = save.player or {}
|
||||||
|
save.player.map = boot.startMap or "REDS_HOUSE_2F"
|
||||||
|
save.player.x = boot.startX or 3
|
||||||
|
save.player.y = boot.startY or 6
|
||||||
|
save.player.facing = boot.startFacing or "down"
|
||||||
|
save.postGameHomeOk = true
|
||||||
|
return heal
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Softlocked 0.1.11 saves: still standing in HALL_OF_FAME after credits,
|
||||||
|
-- with lastOutdoor on Indigo. One-shot rescue on CONTINUE.
|
||||||
|
function SaveData.needsPostGameRescue(save)
|
||||||
|
if not (save and save.player and save.player.map == "HALL_OF_FAME") then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
if save.postGameHomeOk then return false end
|
||||||
|
local hof = save.hallOfFame
|
||||||
|
return type(hof) == "table" and #hof > 0
|
||||||
|
end
|
||||||
|
|
||||||
function SaveData.newGame(boot)
|
function SaveData.newGame(boot)
|
||||||
boot = type(boot) == "table" and boot or {}
|
boot = type(boot) == "table" and boot or {}
|
||||||
local map = boot.startMap or "REDS_HOUSE_2F"
|
local map = boot.startMap or "REDS_HOUSE_2F"
|
||||||
@@ -759,6 +850,9 @@ function SaveData.newGame(boot)
|
|||||||
local heal = SaveData.defaultHeal(boot)
|
local heal = SaveData.defaultHeal(boot)
|
||||||
local save = {
|
local save = {
|
||||||
meta = { format = Version.saveFormat, mods = {} },
|
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 = {
|
player = {
|
||||||
map = map,
|
map = map,
|
||||||
x = x,
|
x = x,
|
||||||
@@ -772,6 +866,10 @@ function SaveData.newGame(boot)
|
|||||||
},
|
},
|
||||||
flags = {},
|
flags = {},
|
||||||
inventory = {},
|
inventory = {},
|
||||||
|
-- Vanilla Gen1 seeds one Potion in the player's item PC
|
||||||
|
-- (wBoxItems / players_pc.asm); existing saves keep whatever they
|
||||||
|
-- already have — this only applies to New Game.
|
||||||
|
pcItems = { POTION = 1 },
|
||||||
party = {},
|
party = {},
|
||||||
box = {},
|
box = {},
|
||||||
money = boot.startMoney or 3000,
|
money = boot.startMoney or 3000,
|
||||||
|
|||||||
@@ -7,13 +7,13 @@ local Version = {
|
|||||||
engine = "1.0.0", -- game/engine release (semver triple)
|
engine = "1.0.0", -- game/engine release (semver triple)
|
||||||
modApi = 2, -- mod API major (manifest `api`)
|
modApi = 2, -- mod API major (manifest `api`)
|
||||||
linkProtocol = 2, -- link handshake wire version (Handshake.PROTOCOL)
|
linkProtocol = 2, -- link handshake wire version (Handshake.PROTOCOL)
|
||||||
saveFormat = 2, -- save.meta.format
|
saveFormat = 3, -- save.meta.format
|
||||||
cache = "rom-cache-v5", -- ROM import cache generation (RomImporter marker)
|
cache = "rom-cache-v5", -- ROM import cache generation (RomImporter marker)
|
||||||
}
|
}
|
||||||
|
|
||||||
-- "Pokemon Red (Gen 1 Recompilation Project) v1.0.0"
|
-- "gen1recomp v1.0.0"
|
||||||
function Version.title(base)
|
function Version.title(base)
|
||||||
return (base or "Pokemon Red (Gen 1 Recompilation Project)")
|
return (base or "gen1recomp")
|
||||||
.. " v" .. Version.engine
|
.. " v" .. Version.engine
|
||||||
end
|
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
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
-- Routes ROM-derived cache I/O (data/generated, assets/generated and the
|
||||||
|
-- rom-cache.complete marker) to the right place.
|
||||||
|
--
|
||||||
|
-- Normally the cache lives in LÖVE's per-user OS save directory and is
|
||||||
|
-- written through love.filesystem. In portable mode it lives in the game
|
||||||
|
-- folder next to the executable instead (the folder holding portable.txt --
|
||||||
|
-- see SaveData), so nothing is left on the host machine. That folder is
|
||||||
|
-- written with raw io.* (love.filesystem can only write to the save dir) and
|
||||||
|
-- read back through love.filesystem, require and love.graphics.newImage --
|
||||||
|
-- which works because the folder is on the physfs read path:
|
||||||
|
--
|
||||||
|
-- * Source runs (`love <gamedir>`, what the Play-* launchers use): the
|
||||||
|
-- folder IS the physfs source, so it is already readable.
|
||||||
|
-- * Fused builds (the packaged .app/.exe): the folder sits next to the
|
||||||
|
-- executable and is NOT normally readable, so CacheFs mounts it onto the
|
||||||
|
-- read path via PhysFS. love.filesystem.mount refuses external folders,
|
||||||
|
-- but the underlying PHYSFS_mount (exported from love's framework) allows
|
||||||
|
-- them; we call it through LuaJIT's FFI.
|
||||||
|
--
|
||||||
|
-- Directories in the portable folder are created with a plain mkdir syscall
|
||||||
|
-- via FFI rather than os.execute, so importing never flashes a console window
|
||||||
|
-- on Windows (issue #74 -- the old per-file `os.execute("mkdir")` froze the
|
||||||
|
-- app behind a storm of one-frame cmd.exe windows).
|
||||||
|
--
|
||||||
|
-- Portable mode is desktop-only (Windows/Linux/macOS); on Android/iOS the
|
||||||
|
-- source is a read-only package with no game folder to write into, so
|
||||||
|
-- SaveData.isPortable() is false there and this module falls back to the
|
||||||
|
-- ordinary love.filesystem/save-directory behaviour.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
local function resolveMkdir()
|
||||||
|
if mkdirFn ~= nil then return mkdirFn end
|
||||||
|
mkdirFn = false
|
||||||
|
local ok, ffi = pcall(require, "ffi")
|
||||||
|
if not ok then return mkdirFn end
|
||||||
|
if ffi.os == "Windows" then
|
||||||
|
-- kernel32 is reliably resolvable through ffi.C on Windows (the engine
|
||||||
|
-- already binds it in DiscordPresence); CreateDirectoryA returns
|
||||||
|
-- nonzero on success and 0 when the directory already exists -- both
|
||||||
|
-- fine, the result is ignored.
|
||||||
|
pcall(ffi.cdef,
|
||||||
|
"int CreateDirectoryA(const char *lpPathName, void *lpSecurityAttributes);")
|
||||||
|
local resolved = pcall(function() return ffi.C.CreateDirectoryA end)
|
||||||
|
if resolved then
|
||||||
|
mkdirFn = function(path) pcall(ffi.C.CreateDirectoryA, path, nil) end
|
||||||
|
end
|
||||||
|
else
|
||||||
|
pcall(ffi.cdef, "int mkdir(const char *pathname, unsigned int mode);")
|
||||||
|
local resolved = pcall(function() return ffi.C.mkdir end)
|
||||||
|
if resolved then
|
||||||
|
mkdirFn = function(path) pcall(ffi.C.mkdir, path, 493) end -- 0755
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return mkdirFn
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Mount an external directory onto the physfs read path (appended, so the
|
||||||
|
-- game's own source always wins a name clash). Returns true on success.
|
||||||
|
--
|
||||||
|
-- PHYSFS_mount is exported by love's own binary. How ffi finds it differs
|
||||||
|
-- per platform: on macOS/Linux the symbol is in the default namespace, so
|
||||||
|
-- ffi.C resolves it; on Windows it lives in love.dll, which ffi.C does NOT
|
||||||
|
-- search, so love.dll is loaded explicitly with ffi.load("love"). Try the
|
||||||
|
-- default first, then love.
|
||||||
|
local physfsMountFn = nil
|
||||||
|
local function resolveMount()
|
||||||
|
if physfsMountFn ~= nil then return physfsMountFn end
|
||||||
|
physfsMountFn = false
|
||||||
|
local ok, ffi = pcall(require, "ffi")
|
||||||
|
if not ok then return physfsMountFn end
|
||||||
|
pcall(ffi.cdef,
|
||||||
|
"int PHYSFS_mount(const char *newDir, const char *mountPoint, int appendToPath);")
|
||||||
|
local libs = {
|
||||||
|
function() return ffi.C end,
|
||||||
|
function() return ffi.load("love") end,
|
||||||
|
}
|
||||||
|
for _, getlib in ipairs(libs) do
|
||||||
|
local okl, lib = pcall(getlib)
|
||||||
|
if okl and lib then
|
||||||
|
local oks, fn = pcall(function() return lib.PHYSFS_mount end)
|
||||||
|
if oks and fn then
|
||||||
|
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
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return physfsMountFn
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 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, append)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The portable game folder when the cache should live there, else nil.
|
||||||
|
-- Resolved (and, for a fused build, mounted) once and cached. Requires a
|
||||||
|
-- desktop portable install (SaveData) and a working windowless mkdir.
|
||||||
|
local portableRoot = nil
|
||||||
|
local portableResolved = false
|
||||||
|
local function resolvePortableRoot()
|
||||||
|
if portableResolved then return portableRoot end
|
||||||
|
portableResolved = true
|
||||||
|
portableRoot = nil
|
||||||
|
if not resolveMkdir() then return nil end
|
||||||
|
local base = require("src.core.SaveData").portableBaseDir()
|
||||||
|
if not base then return nil end
|
||||||
|
if love.filesystem.getSource and base == love.filesystem.getSource() then
|
||||||
|
-- source run: the folder is already the physfs source
|
||||||
|
portableRoot = base
|
||||||
|
elseif mountReadable(base) then
|
||||||
|
-- fused build: base is next to the executable; mount it so io.* writes
|
||||||
|
-- there are visible to love.filesystem/require/newImage
|
||||||
|
portableRoot = base
|
||||||
|
end
|
||||||
|
return portableRoot
|
||||||
|
end
|
||||||
|
|
||||||
|
function CacheFs.root()
|
||||||
|
return resolvePortableRoot()
|
||||||
|
end
|
||||||
|
|
||||||
|
local function realPath(root, rel)
|
||||||
|
return root .. SEP .. rel:gsub("/", SEP)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- create every parent directory of `rel` under `root` (best effort; an
|
||||||
|
-- already-existing directory is fine, a genuine failure surfaces when the
|
||||||
|
-- subsequent io.open write fails)
|
||||||
|
local function ensureParents(root, rel)
|
||||||
|
local mkdir = resolveMkdir()
|
||||||
|
if not mkdir then return end
|
||||||
|
local parts = {}
|
||||||
|
for part in rel:gmatch("[^/]+") do parts[#parts + 1] = part end
|
||||||
|
local cur = root
|
||||||
|
for i = 1, #parts - 1 do
|
||||||
|
cur = cur .. SEP .. parts[i]
|
||||||
|
mkdir(cur)
|
||||||
|
end
|
||||||
|
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)
|
||||||
|
local f, err = io.open(realPath(root, rel), "wb")
|
||||||
|
if not f then return false, err end
|
||||||
|
f:write(data)
|
||||||
|
f:close()
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
local parent = rel:match("^(.*)/[^/]+$")
|
||||||
|
if parent and not love.filesystem.createDirectory(parent) then
|
||||||
|
local info = love.filesystem.getInfo(parent)
|
||||||
|
local reason = info and ("a " .. info.type .. " already exists there")
|
||||||
|
or "unknown reason"
|
||||||
|
return false, "could not create " .. parent .. ": " .. reason
|
||||||
|
end
|
||||||
|
return love.filesystem.write(rel, data)
|
||||||
|
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")
|
||||||
|
if not f then return nil end
|
||||||
|
local data = f:read("*a")
|
||||||
|
f:close()
|
||||||
|
return data
|
||||||
|
end
|
||||||
|
return love.filesystem.read(rel)
|
||||||
|
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")
|
||||||
|
if not f then return false end
|
||||||
|
f:close()
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
return love.filesystem.getInfo(rel, "file") ~= nil
|
||||||
|
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))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
love.filesystem.remove(rel)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Remove the game-folder copy of a cache subtree before a fresh import, so a
|
||||||
|
-- cache-format bump does not leave orphaned files behind. No-op when the
|
||||||
|
-- portable cache is inactive (the save-directory copy is cleared by
|
||||||
|
-- RomImporter's own removeTree). The tree is enumerated through
|
||||||
|
-- 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)
|
||||||
|
local info = love.filesystem.getInfo(r)
|
||||||
|
if not info then return end
|
||||||
|
if info.type == "directory" then
|
||||||
|
for _, child in ipairs(love.filesystem.getDirectoryItems(r)) do
|
||||||
|
walk(r .. "/" .. child)
|
||||||
|
end
|
||||||
|
else
|
||||||
|
os.remove(realPath(root, r))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
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
|
||||||
@@ -126,14 +126,14 @@ function ImageWriter.columnsToRows(raw, tilesWide, tilesHigh, bytesPerTile)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function ImageWriter.save(image, path)
|
function ImageWriter.save(image, path)
|
||||||
local parent = path:match("^(.*)/[^/]+$")
|
|
||||||
if parent then
|
|
||||||
local ok, err = love.filesystem.createDirectory(parent)
|
|
||||||
if not ok then error("could not create " .. parent .. ": " .. tostring(err)) end
|
|
||||||
end
|
|
||||||
local ok, fileData = pcall(image.encode, image, "png")
|
local ok, fileData = pcall(image.encode, image, "png")
|
||||||
if not ok then error("could not encode " .. path .. ": " .. tostring(fileData)) end
|
if not ok then error("could not encode " .. path .. ": " .. tostring(fileData)) end
|
||||||
local written, writeError = love.filesystem.write(path, fileData)
|
-- CacheFs routes this to the OS save directory (normal builds) or straight
|
||||||
|
-- into the game folder (portable installs), creating parent directories as
|
||||||
|
-- needed. io.* needs the bytes as a string; love.filesystem would also
|
||||||
|
-- take the FileData, but getString() keeps one code path.
|
||||||
|
local CacheFs = require("src.import.CacheFs")
|
||||||
|
local written, writeError = CacheFs.write(path, fileData:getString())
|
||||||
if not written then
|
if not written then
|
||||||
error("could not write " .. path .. ": " .. tostring(writeError))
|
error("could not write " .. path .. ": " .. tostring(writeError))
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -87,17 +87,11 @@ function LuaWriter.encode(value)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function LuaWriter.write(path, value)
|
function LuaWriter.write(path, value)
|
||||||
local parent = path:match("^(.*)/[^/]+$")
|
-- CacheFs routes this to the OS save directory (normal builds) or straight
|
||||||
-- love.filesystem.createDirectory returns a single boolean (no error
|
-- into the game folder (portable installs); it also creates the parent
|
||||||
-- value), so a second return here is always nil; getInfo after a failure
|
-- directories. See src/import/CacheFs.lua.
|
||||||
-- at least reports what is blocking the path (e.g. a file with that name).
|
local CacheFs = require("src.import.CacheFs")
|
||||||
if parent and not love.filesystem.createDirectory(parent) then
|
local ok, err = CacheFs.write(path, LuaWriter.encode(value))
|
||||||
local info = love.filesystem.getInfo(parent)
|
|
||||||
local reason = info and ("a " .. info.type .. " already exists there")
|
|
||||||
or "unknown reason"
|
|
||||||
error("could not create " .. parent .. ": " .. reason)
|
|
||||||
end
|
|
||||||
local ok, err = love.filesystem.write(path, LuaWriter.encode(value))
|
|
||||||
if not ok then error("could not write " .. path .. ": " .. tostring(err)) end
|
if not ok then error("could not write " .. path .. ": " .. tostring(err)) end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -1669,10 +1669,11 @@ function RomExtractor:extractAudio()
|
|||||||
chunks[index] = self.rom.data:sub(first, first + 0x3FFF)
|
chunks[index] = self.rom.data:sub(first, first + 0x3FFF)
|
||||||
self:tick("Sound programs", index, #bankOrder + 2)
|
self:tick("Sound programs", index, #bankOrder + 2)
|
||||||
end
|
end
|
||||||
local ok, writeError = love.filesystem.createDirectory(
|
-- CacheFs (not love.filesystem directly) so a portable install lands this
|
||||||
"assets/generated/audio")
|
-- in the game folder with the rest of the cache; it creates the parent
|
||||||
if ok == false then error("could not create audio cache: " .. tostring(writeError)) end
|
-- directory too.
|
||||||
ok, writeError = love.filesystem.write(
|
local CacheFs = require("src.import.CacheFs")
|
||||||
|
local ok, writeError = CacheFs.write(
|
||||||
"assets/generated/audio/programs.bin", table.concat(chunks))
|
"assets/generated/audio/programs.bin", table.concat(chunks))
|
||||||
if not ok then error("could not write audio programs: " .. tostring(writeError)) end
|
if not ok then error("could not write audio programs: " .. tostring(writeError)) end
|
||||||
|
|
||||||
|
|||||||
+816
-308
File diff suppressed because it is too large
Load Diff
@@ -872,11 +872,15 @@ R.palettes = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
-- keyed by species id, unlike the vanilla byDex array: a species past the
|
-- keyed by species id, unlike the vanilla byDex array: a species past the
|
||||||
-- end of the dex gets an icon without punching a hole in the list
|
-- 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 = {
|
R.icons = {
|
||||||
semantics = "record", target = "icons.bySpecies",
|
semantics = "record", target = "icons.bySpecies",
|
||||||
value = f.union{ f.str, f.rec{ image = f.path, frames = f.opt(f.int(1)) } },
|
value = f.union{ f.str, f.rec{ image = f.path, frames = f.opt(f.int(1)) } },
|
||||||
example = 'mod.content.icons:register("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
|
-- glyph codes are not bytes: the vanilla pages sit at $60/$80 but a
|
||||||
|
|||||||
@@ -148,6 +148,17 @@ function Font.advanceOf(code)
|
|||||||
return page and page.advance or GLYPH
|
return page and page.advance or GLYPH
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Pixel width of a string (glyph advances, not UTF-8 byte length).
|
||||||
|
-- Multi-byte charmap entries like "¥" are one glyph; callers that
|
||||||
|
-- right-align with `#text * 8` mis-place them.
|
||||||
|
function Font.width(text)
|
||||||
|
local w = 0
|
||||||
|
for _, code in ipairs(Font.encode(text)) do
|
||||||
|
w = w + Font.advanceOf(code)
|
||||||
|
end
|
||||||
|
return w
|
||||||
|
end
|
||||||
|
|
||||||
-- Draw a plain single-line string at pixel (x, y). Returns the width
|
-- Draw a plain single-line string at pixel (x, y). Returns the width
|
||||||
-- drawn, which is #codes * 8 for every fixed-width page.
|
-- drawn, which is #codes * 8 for every fixed-width page.
|
||||||
function Font.draw(text, x, y)
|
function Font.draw(text, x, y)
|
||||||
|
|||||||
+92
-16
@@ -10,16 +10,23 @@
|
|||||||
-- RED++ swaps the named-palette pack for pokered-gbc SuperPalettes
|
-- RED++ swaps the named-palette pack for pokered-gbc SuperPalettes
|
||||||
-- (data/palettes_gbc.lua), including per-species mon colors.
|
-- (data/palettes_gbc.lua), including per-species mon colors.
|
||||||
|
|
||||||
|
local GameVersion = require("src.core.GameVersion")
|
||||||
|
|
||||||
local PaletteFX = {}
|
local PaletteFX = {}
|
||||||
|
|
||||||
local shader -- false = unavailable (headless / no shader support)
|
local shader -- false = unavailable (headless / no shader support)
|
||||||
local gbcPack -- false = missing; nil = not loaded yet
|
local gbcPack -- false = missing; nil = not loaded yet
|
||||||
|
|
||||||
-- Cycle order matches OptionsMenu / hotkey 2
|
-- Cycle order matches OptionsMenu / hotkey 2. The three real colorizations
|
||||||
PaletteFX.MODES = { "gbc", "redpp", "og", "og_inv", "gbc_inv", "classic" }
|
-- 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 = {
|
PaletteFX.MODE_LABELS = {
|
||||||
gbc = "GBC", redpp = "RED++", og = "OG", og_inv = "OG INV",
|
ogred = "OG RED", gbc = "SGB", redpp = "RED++", og = "OG",
|
||||||
gbc_inv = "GBC INV", classic = "CLASSIC",
|
og_inv = "OG INV", gbc_inv = "SGB INV", classic = "CLASSIC",
|
||||||
}
|
}
|
||||||
PaletteFX.mode = "gbc"
|
PaletteFX.mode = "gbc"
|
||||||
|
|
||||||
@@ -28,6 +35,40 @@ PaletteFX.CLASSIC = {
|
|||||||
{ 155, 188, 15 }, { 139, 172, 15 }, { 48, 98, 48 }, { 15, 56, 15 },
|
{ 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 }
|
local INV_MAP = { [0] = 3, [1] = 2, [2] = 1, [3] = 0 }
|
||||||
|
|
||||||
function PaletteFX.shader()
|
function PaletteFX.shader()
|
||||||
@@ -141,16 +182,18 @@ function PaletteFX.usesGbcPack(mode)
|
|||||||
return mode == "redpp"
|
return mode == "redpp"
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Per-object overworld sprite coloring (ColorOverworldSprite) applies in
|
-- Whether the active mode bakes a per-OBJ palette onto overworld sprites
|
||||||
-- plain GBC mode too, not only under the RED++ pack: without it the
|
-- (the OBP bake + post-zone redraw path). ONLY OG RED does: it wears the
|
||||||
-- whole-map zone shader paints characters with whatever two mid shades
|
-- GBC boot-ROM green object palette (PaletteFX.GBC_OBJ) so the player and
|
||||||
-- the terrain palette defines. RED++ handles sprites through the baked
|
-- NPCs stay green over the red background, exactly like Pokemon Red on a
|
||||||
-- usesGbcPack() path in SpriteRenderer; this names the modes where the
|
-- Game Boy Color. SGB mode deliberately does NOT: an SGB OBJ carries no
|
||||||
-- OBP bake plus the post-zone redraw (below) stand in for real OBJ
|
-- palette of its own, so the characters tint with the whole-map region
|
||||||
-- palettes over a shader-colorized background.
|
-- 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)
|
function PaletteFX.usesSpriteObp(mode)
|
||||||
mode = mode or PaletteFX.mode
|
return (mode or PaletteFX.mode) == "ogred"
|
||||||
return mode == "gbc"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
-- ------- post-zone sprite redraw (GBC mode)
|
-- ------- post-zone sprite redraw (GBC mode)
|
||||||
@@ -199,14 +242,38 @@ function PaletteFX.pack(data)
|
|||||||
return data and data.palettes or nil
|
return data and data.palettes or nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- SuperPalettes that differ between Red and Blue (pokered data/sgb/
|
||||||
|
-- sgb_palettes.asm IF DEF(_RED)/_BLUE). data/palettes_gbc.lua is the
|
||||||
|
-- Red-derived pokered-gbc pack, so under RED++ a Blue playthrough must
|
||||||
|
-- read these from the ROM-imported table or the title ribbon stays red
|
||||||
|
-- and the Game Corner reels keep Red's pink (issue #128).
|
||||||
|
local BLUE_VERSIONED = {
|
||||||
|
LOGO1 = true, SLOTS2 = true, SLOTS3 = true, SLOTS4 = true,
|
||||||
|
}
|
||||||
|
|
||||||
|
local function romNamedPal(data, name)
|
||||||
|
local p = data and data.palettes
|
||||||
|
return p and p.palettes and p.palettes[name]
|
||||||
|
end
|
||||||
|
|
||||||
-- named palette from the active pack (nil on stale builds / missing name).
|
-- named palette from the active pack (nil on stale builds / missing name).
|
||||||
-- RED++ falls back to the ROM pack for names the gbc table omits (rare).
|
-- RED++ falls back to the ROM pack for names the gbc table omits (rare).
|
||||||
|
-- OG RED short-circuits EVERY name to the one global GBC boot-ROM BG palette
|
||||||
|
-- (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)
|
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 p = PaletteFX.pack(data)
|
||||||
local c = p and p.palettes[name]
|
local c = p and p.palettes[name]
|
||||||
if c then return c end
|
if c then return c end
|
||||||
if PaletteFX.usesGbcPack() and data and data.palettes then
|
if PaletteFX.usesGbcPack() then
|
||||||
return data.palettes.palettes[name]
|
return romNamedPal(data, name)
|
||||||
end
|
end
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
@@ -217,6 +284,11 @@ end
|
|||||||
-- Transformed mon's pic is tinted gray, not the copied species' own
|
-- Transformed mon's pic is tinted gray, not the copied species' own
|
||||||
-- SGB color). RED++ uses per-species pals from mon_palettes.asm.
|
-- SGB color). RED++ uses per-species pals from mon_palettes.asm.
|
||||||
function PaletteFX.monPal(data, species, transformed)
|
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)
|
local p = PaletteFX.pack(data)
|
||||||
if not p then return nil end
|
if not p then return nil end
|
||||||
if transformed then
|
if transformed then
|
||||||
@@ -458,7 +530,11 @@ function PaletteFX.applyOptions(opts)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function PaletteFX.modeLabel(mode)
|
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
|
end
|
||||||
|
|
||||||
-- When a state exposes no SGB zones but COLORS needs a forced palette
|
-- When a state exposes no SGB zones but COLORS needs a forced palette
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ end
|
|||||||
function Renderer:beginFrame(transparent)
|
function Renderer:beginFrame(transparent)
|
||||||
self.worldActive = false
|
self.worldActive = false
|
||||||
self.uprightActive = 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
|
||||||
-- last frame's trueColor rects and sprite redraws go before anything
|
-- last frame's trueColor rects and sprite redraws go before anything
|
||||||
-- draws this one
|
-- draws this one
|
||||||
PaletteFX.clearTrueColor()
|
PaletteFX.clearTrueColor()
|
||||||
@@ -79,6 +82,10 @@ function Renderer:beginWorldPass()
|
|||||||
local vw, vh = self:worldViewSize()
|
local vw, vh = self:worldViewSize()
|
||||||
if not self.worldCanvas or self.worldCanvas:getWidth() ~= vw
|
if not self.worldCanvas or self.worldCanvas:getWidth() ~= vw
|
||||||
or self.worldCanvas:getHeight() ~= vh then
|
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 = love.graphics.newCanvas(vw, vh)
|
||||||
self.worldCanvas:setFilter("nearest", "nearest")
|
self.worldCanvas:setFilter("nearest", "nearest")
|
||||||
end
|
end
|
||||||
@@ -107,6 +114,7 @@ function Renderer:beginUprightPass()
|
|||||||
local cw, ch = vw + 2 * M, vh + 2 * M
|
local cw, ch = vw + 2 * M, vh + 2 * M
|
||||||
if not self.uprightCanvas or self.uprightCanvas:getWidth() ~= cw
|
if not self.uprightCanvas or self.uprightCanvas:getWidth() ~= cw
|
||||||
or self.uprightCanvas:getHeight() ~= ch then
|
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 = love.graphics.newCanvas(cw, ch)
|
||||||
self.uprightCanvas:setFilter("nearest", "nearest")
|
self.uprightCanvas:setFilter("nearest", "nearest")
|
||||||
end
|
end
|
||||||
@@ -200,6 +208,7 @@ function Renderer:drawTiltedWorld(zoneList, s, wox, woy, target)
|
|||||||
-- 2x for extra crispness.
|
-- 2x for extra crispness.
|
||||||
if not self.tiltCanvas or self.tiltCanvas:getWidth() ~= wvw
|
if not self.tiltCanvas or self.tiltCanvas:getWidth() ~= wvw
|
||||||
or self.tiltCanvas:getHeight() ~= wvh then
|
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 = love.graphics.newCanvas(wvw, wvh)
|
||||||
self.tiltCanvas:setFilter("linear", "linear")
|
self.tiltCanvas:setFilter("linear", "linear")
|
||||||
end
|
end
|
||||||
@@ -408,6 +417,16 @@ function Renderer:endFrame(zones, worldZones)
|
|||||||
love.graphics.draw(self.uprightCanvas, wox - M * s, woy - M * s, 0, s, s)
|
love.graphics.draw(self.uprightCanvas, wox - M * s, woy - M * s, 0, s, s)
|
||||||
love.graphics.setScissor()
|
love.graphics.setScissor()
|
||||||
end
|
end
|
||||||
|
-- Screen-space warp fade (Transition) over the full world composite so
|
||||||
|
-- survey zoom / tilt edges darken with the center, not only the 160x144
|
||||||
|
-- UI letterbox. Drawn before the UI blit so menus above a fade still
|
||||||
|
-- composite normally if one is ever stacked that way.
|
||||||
|
local fade = self.worldFadeAlpha
|
||||||
|
if fade and fade > 0 then
|
||||||
|
love.graphics.setColor(0, 0, 0, fade)
|
||||||
|
love.graphics.rectangle("fill", 0, 0, ww, wh)
|
||||||
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
-- UI stays in the classic centered GB letterbox
|
-- UI stays in the classic centered GB letterbox
|
||||||
blit(self.canvas, S, zones, S, ox, oy, ox, oy, vpw, vph)
|
blit(self.canvas, S, zones, S, ox, oy, ox, oy, vpw, vph)
|
||||||
|
|||||||
@@ -114,14 +114,12 @@ function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip)
|
|||||||
image = getObpImage(self.def.image, colors, group)
|
image = getObpImage(self.def.image, colors, group)
|
||||||
end
|
end
|
||||||
elseif PaletteFX.usesSpriteObp() and PaletteFX.spriteRedrawPassActive() then
|
elseif PaletteFX.usesSpriteObp() and PaletteFX.spriteRedrawPassActive() then
|
||||||
-- plain GBC: the terrain zone shader still runs over the world canvas,
|
-- OG RED (GBC boot-ROM look): every OBJ wears the one global green
|
||||||
-- so the baked sprite is also queued for a post-zone redraw
|
-- object palette. The red BG zone shader still runs over the world
|
||||||
-- (PaletteFX.markSpriteRedraw) that restores its own OBP colors on top
|
-- canvas, so the baked sprite is queued for a post-zone redraw
|
||||||
local colors, group = PaletteFX.spriteObp(self.def, self.seed)
|
-- (PaletteFX.markSpriteRedraw) that restores its green pixels on top.
|
||||||
if colors then
|
image = getObpImage(self.def.image, PaletteFX.GBC_OBJ, "gbcobj")
|
||||||
image = getObpImage(self.def.image, colors, group)
|
redraw = true
|
||||||
redraw = true
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
-- single-frame sprites (item balls, fossils...) have one fixed pose;
|
-- single-frame sprites (item balls, fossils...) have one fixed pose;
|
||||||
-- still 3-frame sprites turn to face (the nurse at her machine,
|
-- still 3-frame sprites turn to face (the nurse at her machine,
|
||||||
|
|||||||
+165
-95
@@ -90,8 +90,8 @@ function TileRenderer.setSpinning(active)
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- true while the spinner arrow tiles should show the 'blur' graphic; false
|
-- true while the spinner arrow tiles should show the 'blur' graphic; false
|
||||||
-- means draw nothing extra (the static mapBatch/ringBatch tile shows
|
-- means draw nothing extra (the static window tile shows through,
|
||||||
-- through, matching the asm's restore-to-original behavior). The 8-tick
|
-- matching the asm's restore-to-original behavior). The 8-tick
|
||||||
-- half-period approximates one GB movement step (2px/frame); this is a
|
-- half-period approximates one GB movement step (2px/frame); this is a
|
||||||
-- deliberate approximation of wSimulatedJoypadStatesIndex bit-0 parity, not
|
-- deliberate approximation of wSimulatedJoypadStatesIndex bit-0 parity, not
|
||||||
-- a cycle-accurate replication -- the port's tweened scriptMove has no
|
-- a cycle-accurate replication -- the port's tweened scriptMove has no
|
||||||
@@ -426,22 +426,24 @@ function TileRenderer.new(map, data)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local def = map.def
|
local def = map.def
|
||||||
local wB, hB = def.width, def.height
|
-- The map body measured in 8px tiles (each block is 4x4 tiles). The tile
|
||||||
-- two batches: the border-block ring around the map, and the map body.
|
-- layer is drawn windowed to the camera (see :ensureWindow) instead of
|
||||||
-- Connected-map strips draw body-only on top of this map's ring.
|
-- baked into a whole-map SpriteBatch, so a map becoming visible -- a warp,
|
||||||
local total = (wB + 2 * BORDER_BLOCKS) * (hB + 2 * BORDER_BLOCKS) * 16
|
-- a connection seam -- costs nothing to "build": there is no per-map batch
|
||||||
self.ringBatch = love.graphics.newSpriteBatch(self.image, total, "static")
|
-- construction that scales with map size, which is what stuttered.
|
||||||
self.mapBatch = love.graphics.newSpriteBatch(self.image, wB * hB * 16, "static")
|
self.bodyTilesW = def.width * 4
|
||||||
-- animated tiles overdraw the static batches each frame. Entry order
|
self.bodyTilesH = def.height * 4
|
||||||
-- decides which one claims a tile listed twice, so the vanilla defaults
|
-- Animated tiles overdraw the static window each frame. Only the per-entry
|
||||||
-- keep the old water-then-flower-then-spinner precedence.
|
-- 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 anims, claimedBy = {}, {}
|
||||||
local declared = map.tileset.animatedTiles
|
local declared = map.tileset.animatedTiles
|
||||||
or TileRenderer.defaultAnimatedTiles(map.tileset)
|
or TileRenderer.defaultAnimatedTiles(map.tileset)
|
||||||
for _, spec in ipairs(declared) do
|
for _, spec in ipairs(declared) do
|
||||||
local anim = buildAnim(spec, map.tileset.image, perRow, self.quads, gbcCtx)
|
local anim = buildAnim(spec, map.tileset.image, perRow, self.quads, gbcCtx)
|
||||||
if anim then
|
if anim then
|
||||||
anim.cells = {}
|
|
||||||
anims[#anims + 1] = anim
|
anims[#anims + 1] = anim
|
||||||
for _, tile in ipairs(anim.tiles) do
|
for _, tile in ipairs(anim.tiles) do
|
||||||
if claimedBy[tile] == nil then claimedBy[tile] = anim end
|
if claimedBy[tile] == nil then claimedBy[tile] = anim end
|
||||||
@@ -460,66 +462,9 @@ function TileRenderer.new(map, data)
|
|||||||
aliasMap[al.block] = cells
|
aliasMap[al.block] = cells
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
self.aliasMap = aliasMap
|
||||||
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.anims = anims
|
self.anims = anims
|
||||||
|
self.claimedBy = claimedBy
|
||||||
|
|
||||||
-- a repeating 32x32 image of the border block, tiled behind
|
-- a repeating 32x32 image of the border block, tiled behind
|
||||||
-- everything the 3-block ring doesn't cover (the survey zoom sees
|
-- everything the 3-block ring doesn't cover (the survey zoom sees
|
||||||
@@ -554,8 +499,16 @@ function TileRenderer:drawBorderFill(camX, camY, vw, vh)
|
|||||||
if not self.borderFill then return end
|
if not self.borderFill then return end
|
||||||
if self.trueColor then PaletteFX.markTrueColor(0, 0, vw, vh) end
|
if self.trueColor then PaletteFX.markTrueColor(0, 0, vw, vh) end
|
||||||
local x, y = math.floor(camX), math.floor(camY)
|
local x, y = math.floor(camX), math.floor(camY)
|
||||||
local quad = love.graphics.newQuad(x, y, vw, vh, 32, 32)
|
-- one reused Quad per renderer, mutated in place: this runs every
|
||||||
love.graphics.draw(self.borderFill, quad, 0, 0)
|
-- overworld frame, so allocating a fresh Quad here churned the GC
|
||||||
|
local q = self.borderQuad
|
||||||
|
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
|
end
|
||||||
|
|
||||||
-- GB OBJ-to-BG priority: sprites show through BG color 0 and hide under
|
-- GB OBJ-to-BG priority: sprites show through BG color 0 and hide under
|
||||||
@@ -616,19 +569,98 @@ function TileRenderer:markCellBottomRedraw(cx, cy, camX, camY, colors)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- animated overdraw at the current step; bodyOnly skips the ring
|
-- Window cover for the static tile layer. Refill the reusable window batch
|
||||||
-- positions (connected maps draw body-only)
|
-- (and the per-entry animated batches) only when the camera has scrolled past
|
||||||
function TileRenderer:drawAnimated(camX, camY, bodyOnly)
|
-- 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
|
local anims = self.anims
|
||||||
if not anims then return end
|
if not anims then return end
|
||||||
local x, y = -math.floor(camX), -math.floor(camY)
|
local x, y = -math.floor(camX), -math.floor(camY)
|
||||||
for _, anim in ipairs(anims) do
|
for _, anim in ipairs(anims) do
|
||||||
local batch = bodyOnly and anim.bodyBatch or anim.batch
|
local batch = anim.batch
|
||||||
if batch then
|
if batch then
|
||||||
if anim.gate then
|
if anim.gate then
|
||||||
-- a gated entry has only the two frames the asm has (patch /
|
-- a gated entry has only the two frames the asm has (patch /
|
||||||
-- restore-to-static); when the gate is shut draw nothing so the
|
-- 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
|
if gateOpen(anim.gate) then love.graphics.draw(batch, x, y) end
|
||||||
else
|
else
|
||||||
local step = math.floor(animFrame / anim.period) % #anim.sequence + 1
|
local step = math.floor(animFrame / anim.period) % #anim.sequence + 1
|
||||||
@@ -649,30 +681,68 @@ function TileRenderer:markTrueColor(camX, camY, blocks)
|
|||||||
(def.height + 2 * blocks) * 32)
|
(def.height + 2 * blocks) * 32)
|
||||||
end
|
end
|
||||||
|
|
||||||
function TileRenderer:draw(camX, camY)
|
function TileRenderer:draw(camX, camY, vw, vh)
|
||||||
if self.trueColor then self:markTrueColor(camX, camY, BORDER_BLOCKS) end
|
if self.trueColor then self:markTrueColor(camX, camY, BORDER_BLOCKS) end
|
||||||
love.graphics.draw(self.ringBatch, -math.floor(camX), -math.floor(camY))
|
self:drawWindow(camX, camY, vw, vh)
|
||||||
love.graphics.draw(self.mapBatch, -math.floor(camX), -math.floor(camY))
|
|
||||||
self:drawAnimated(camX, camY)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
-- body only, for connected-map strips
|
-- body only, for connected-map strips. Identical to :draw now that the
|
||||||
function TileRenderer:drawMapOnly(camX, camY)
|
-- 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
|
if self.trueColor then self:markTrueColor(camX, camY, 0) end
|
||||||
love.graphics.draw(self.mapBatch, -math.floor(camX), -math.floor(camY))
|
self:drawWindow(camX, camY, vw, vh)
|
||||||
self:drawAnimated(camX, camY, true)
|
|
||||||
end
|
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
|
||||||
|
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()
|
function TileRenderer:rebuild()
|
||||||
local fresh = TileRenderer.new(self.map, self.data)
|
self.win = nil
|
||||||
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
|
|
||||||
end
|
end
|
||||||
|
|
||||||
-- drop every atlas and every derived animation texture so the next
|
-- drop every atlas and every derived animation texture so the next
|
||||||
|
|||||||
@@ -57,6 +57,16 @@ end
|
|||||||
function Transition:draw()
|
function Transition:draw()
|
||||||
local alpha = self.t / self.frames
|
local alpha = self.t / self.frames
|
||||||
if self.phase == "in" then alpha = 1 - alpha end
|
if self.phase == "in" then alpha = 1 - alpha end
|
||||||
|
-- Survey zoom draws the overworld into a window-filling world canvas
|
||||||
|
-- while the UI pass stays the classic 160x144 letterbox. A rect on the
|
||||||
|
-- UI canvas only darkens that center box (issue #121); when the world
|
||||||
|
-- pass ran this frame, hand the alpha to Renderer:endFrame so it paints
|
||||||
|
-- a screen-space overlay over the full composite instead.
|
||||||
|
local r = self.game and self.game.renderer
|
||||||
|
if r and r.worldActive then
|
||||||
|
r.worldFadeAlpha = alpha
|
||||||
|
return
|
||||||
|
end
|
||||||
love.graphics.setColor(0, 0, 0, alpha)
|
love.graphics.setColor(0, 0, 0, alpha)
|
||||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
|
|||||||
+88
-20
@@ -438,6 +438,20 @@ function Commands.play_sound(ctx, soundId)
|
|||||||
require("src.core.Sound").play(ctx.game.data, soundId)
|
require("src.core.Sound").play(ctx.game.data, soundId)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- play_once <songId>: one-shot jingle (Music_PkmnHealed, etc.); blocks
|
||||||
|
-- until it finishes so heal-rest scripts (Mom, captain text_asm) match
|
||||||
|
-- Gen1's wait-on-channel loop. The map theme resumes when it ends
|
||||||
|
-- (Music.playOnce / pendingRestore).
|
||||||
|
function Commands.play_once(ctx, songId)
|
||||||
|
local Music = require("src.core.Music")
|
||||||
|
if not Music.playOnce(ctx.game.data, songId) then return end
|
||||||
|
local runner = ctx.runner
|
||||||
|
runner.waitingCheck = function()
|
||||||
|
return not Music.oneShotPlaying()
|
||||||
|
end
|
||||||
|
runner:yield()
|
||||||
|
end
|
||||||
|
|
||||||
-- play_cry <species>: PlayCry (home/audio.asm). The text_asm bodies that
|
-- play_cry <species>: PlayCry (home/audio.asm). The text_asm bodies that
|
||||||
-- use it run text_far (a no-button-wait "...@" string) -> PlayCry ->
|
-- use it run text_far (a no-button-wait "...@" string) -> PlayCry ->
|
||||||
-- WaitForSoundToFinish, all within the same text ID -- the cry only starts
|
-- WaitForSoundToFinish, all within the same text ID -- the cry only starts
|
||||||
@@ -509,12 +523,29 @@ function Commands.give_money(ctx, amount)
|
|||||||
ctx.save.money = math.max(0, ctx.save.money + amount)
|
ctx.save.money = math.max(0, ctx.save.money + amount)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Point LAST_MAP exits at an outdoor door (pokered wLastMap). Keeps the
|
||||||
|
-- live overworld memory in sync so a scripted home warp from the HoF PC
|
||||||
|
-- does not leave Red's house mats aimed at Indigo Plateau (#103).
|
||||||
|
function Commands.remember_outdoor(ctx, mapId, x, y)
|
||||||
|
local outdoor = { id = mapId, x = x, y = y }
|
||||||
|
ctx.save.lastOutdoor = outdoor
|
||||||
|
if ctx.overworld and ctx.overworld.rememberOutdoor then
|
||||||
|
ctx.overworld:rememberOutdoor(mapId, x, y)
|
||||||
|
elseif ctx.overworld then
|
||||||
|
ctx.overworld.lastOutdoor = outdoor
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
-- Hall of Fame: snapshot the winning party (SaveHallOfFameTeams inside
|
-- Hall of Fame: snapshot the winning party (SaveHallOfFameTeams inside
|
||||||
-- AnimateHallOfFame), run the induction showcase and the end credits,
|
-- AnimateHallOfFame), run the induction showcase and the end credits,
|
||||||
-- autosave while THE END is up, then soft-reset to the title -- the whole
|
-- autosave while THE END is up, then soft-reset to the title -- the whole
|
||||||
-- predef HallOfFamePC + tail of HallOfFameResetEventsAndSaveScript
|
-- predef HallOfFamePC + tail of HallOfFameResetEventsAndSaveScript
|
||||||
-- (engine/movie/hall_of_fame.asm, engine/movie/credits.asm,
|
-- (engine/movie/hall_of_fame.asm, engine/movie/credits.asm,
|
||||||
-- scripts/HallOfFame.asm).
|
-- scripts/HallOfFame.asm).
|
||||||
|
--
|
||||||
|
-- Departure from pokered: CONTINUE lands in the NewGameWarp bedroom with
|
||||||
|
-- a healed party and lastOutdoor on Pallet Town, instead of remaining
|
||||||
|
-- softlocked in HALL_OF_FAME with LAST_MAP still aimed at Indigo (#103).
|
||||||
function Commands.record_hall_of_fame(ctx)
|
function Commands.record_hall_of_fame(ctx)
|
||||||
ctx.save.hallOfFame = ctx.save.hallOfFame or {}
|
ctx.save.hallOfFame = ctx.save.hallOfFame or {}
|
||||||
local entry = {}
|
local entry = {}
|
||||||
@@ -530,17 +561,25 @@ function Commands.record_hall_of_fame(ctx)
|
|||||||
Screens.push(game, "Credits", function()
|
Screens.push(game, "Credits", function()
|
||||||
runner:resume()
|
runner:resume()
|
||||||
end, function()
|
end, function()
|
||||||
-- THE END is on screen: HallOfFameResetEventsAndSaveScript sets
|
-- THE END is on screen: heal, place the player at post-game home,
|
||||||
-- wLastBlackoutMap := PALLET_TOWN and runs SaveGameData, so the
|
-- retarget LAST_MAP, then SaveGameData. (E4 room-script/event
|
||||||
-- save keeps the player standing in the HALL_OF_FAME room. (The
|
-- resets that precede the save in pokered are the Indigo lobby's
|
||||||
-- E4 room-script/event resets that precede the save in pokered are
|
-- re-entry reset here, data/scripts/story6.lua.)
|
||||||
-- the Indigo lobby's re-entry reset here, data/scripts/story6.lua.)
|
local SaveData = require("src.core.SaveData")
|
||||||
-- pokered writes PALLET_TOWN here as a literal, not as "the spawn" --
|
local Pokemon = require("src.pokemon.Pokemon")
|
||||||
-- the vanilla spawn is REDS_HOUSE_2F. SaveData.defaultHeal carries
|
|
||||||
-- that split (and lets a total conversion redirect it).
|
|
||||||
local boot = game.data.field and game.data.field.boot or {}
|
local boot = game.data.field and game.data.field.boot or {}
|
||||||
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
|
if game.writeSave then game:writeSave() end
|
||||||
|
-- writeSave's captureSave re-stamps the live HALL_OF_FAME coords;
|
||||||
|
-- re-apply home and persist so CONTINUE resumes in the bedroom.
|
||||||
|
SaveData.applyPostGameHome(ctx.save, boot)
|
||||||
|
SaveData.save(ctx.save)
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
runner:yield()
|
runner:yield()
|
||||||
@@ -855,15 +894,21 @@ function Commands.push_screen(ctx, screenId, args)
|
|||||||
runner:yield()
|
runner:yield()
|
||||||
end
|
end
|
||||||
|
|
||||||
-- fade "out"|"in" [frames]: screen fade without warping (the Transition
|
-- fade "out"|"in" [frames|"white"|"black"] [frames|"white"|"black"]:
|
||||||
-- ramp startWarpTo uses, split in two). "out" pushes a black overlay
|
-- screen fade without warping (the Transition ramp startWarpTo uses,
|
||||||
-- that stays up; the held state keeps ticking the runner's frame-waits
|
-- split in two). "out" pushes an overlay that stays up; the held state
|
||||||
-- so a script can wait/replace_block under it. "in" ramps it away.
|
-- 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 = {}
|
local FadeOverlay = {}
|
||||||
FadeOverlay.__index = FadeOverlay
|
FadeOverlay.__index = FadeOverlay
|
||||||
|
|
||||||
function FadeOverlay.new(game, ow)
|
function FadeOverlay.new(game, ow, color)
|
||||||
return setmetatable({ game = game, ow = ow, alpha = 0 }, FadeOverlay)
|
return setmetatable({
|
||||||
|
game = game, ow = ow, alpha = 0, color = color or "black",
|
||||||
|
}, FadeOverlay)
|
||||||
end
|
end
|
||||||
|
|
||||||
function FadeOverlay:update()
|
function FadeOverlay:update()
|
||||||
@@ -889,28 +934,51 @@ function FadeOverlay:update()
|
|||||||
end
|
end
|
||||||
|
|
||||||
function FadeOverlay:draw()
|
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.rectangle("fill", 0, 0, 160, 144)
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
end
|
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
|
local ow = ctx.overworld
|
||||||
if not ow then return end
|
if not ow then return end
|
||||||
local runner = ctx.runner
|
local runner = ctx.runner
|
||||||
frames = frames or 12 -- Transition's ramp length
|
local frames, color = parseFadeArgs(framesOrColor, colorOrFrames)
|
||||||
local overlay = ow.fadeOverlay
|
local overlay = ow.fadeOverlay
|
||||||
if dir == "out" then
|
if dir == "out" then
|
||||||
if not overlay then
|
if not overlay then
|
||||||
overlay = FadeOverlay.new(ctx.game, ow)
|
overlay = FadeOverlay.new(ctx.game, ow, color)
|
||||||
ow.fadeOverlay = overlay
|
ow.fadeOverlay = overlay
|
||||||
ctx.game.stack:push(overlay)
|
ctx.game.stack:push(overlay)
|
||||||
|
else
|
||||||
|
overlay.color = color
|
||||||
end
|
end
|
||||||
overlay.ramp = { from = overlay.alpha, to = 1, frames = frames, t = 0,
|
overlay.ramp = { from = overlay.alpha, to = 1, frames = frames, t = 0,
|
||||||
onDone = function() runner:resume() end }
|
onDone = function() runner:resume() end }
|
||||||
runner:yield()
|
runner:yield()
|
||||||
elseif dir == "in" then
|
elseif dir == "in" then
|
||||||
if not overlay then return end
|
if not overlay then return end
|
||||||
|
overlay.color = color or overlay.color
|
||||||
overlay.ramp = { from = overlay.alpha, to = 0, frames = frames, t = 0,
|
overlay.ramp = { from = overlay.alpha, to = 0, frames = frames, t = 0,
|
||||||
onDone = function() runner:resume() end }
|
onDone = function() runner:resume() end }
|
||||||
runner:yield()
|
runner:yield()
|
||||||
@@ -1000,7 +1068,7 @@ for _, verb in ipairs({ "show_text", "ask", "choice", "start_battle", "warp",
|
|||||||
"open_mart", "trade", "push_screen", "record_hall_of_fame",
|
"open_mart", "trade", "push_screen", "record_hall_of_fame",
|
||||||
"old_man_demo", "static_battle", "rival_battle", "give_item", "wait",
|
"old_man_demo", "static_battle", "rival_battle", "give_item", "wait",
|
||||||
"wait_flag", "move_player", "move_npc", "move_npc_to", "walk_npc",
|
"wait_flag", "move_player", "move_npc", "move_npc_to", "walk_npc",
|
||||||
"emote", "fade", "pan_camera" }) do
|
"emote", "fade", "pan_camera", "play_once" }) do
|
||||||
local meta = Commands.meta[verb] or {}
|
local meta = Commands.meta[verb] or {}
|
||||||
Commands.meta[verb] = meta
|
Commands.meta[verb] = meta
|
||||||
meta.blocking = true
|
meta.blocking = true
|
||||||
|
|||||||
+5
-1
@@ -145,6 +145,10 @@ end
|
|||||||
|
|
||||||
function BoxMenu.new(game)
|
function BoxMenu.new(game)
|
||||||
Boxes.ensure(game.save)
|
Boxes.ensure(game.save)
|
||||||
|
-- bills_pc.asm BillsPCMenu: TextBoxBorder at (0,0) with interior
|
||||||
|
-- 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, {
|
return Menu.new(game, {
|
||||||
{ label = "WITHDRAW", onSelect = function() withdraw(game) end },
|
{ label = "WITHDRAW", onSelect = function() withdraw(game) end },
|
||||||
{ label = "DEPOSIT", onSelect = function() deposit(game) end },
|
{ label = "DEPOSIT", onSelect = function() deposit(game) end },
|
||||||
@@ -153,7 +157,7 @@ function BoxMenu.new(game)
|
|||||||
{ label = "SEE YA!" },
|
{ label = "SEE YA!" },
|
||||||
-- Bill's PC runs silent end to end (BIT_NO_MENU_BUTTON_SOUND,
|
-- Bill's PC runs silent end to end (BIT_NO_MENU_BUTTON_SOUND,
|
||||||
-- engine/menus/pokemon_pc.asm)
|
-- engine/menus/pokemon_pc.asm)
|
||||||
}, { tx = 8, ty = 0, tw = 12, th = 12, noSound = true })
|
}, { tx = 0, ty = 0, tw = 14, th = 12, noSound = true })
|
||||||
end
|
end
|
||||||
|
|
||||||
return BoxMenu
|
return BoxMenu
|
||||||
|
|||||||
+2
-1
@@ -217,7 +217,8 @@ function Credits:update(dt)
|
|||||||
self.timer = FADE_FRAMES
|
self.timer = FADE_FRAMES
|
||||||
elseif self.phase == "end_fade" then
|
elseif self.phase == "end_fade" then
|
||||||
-- Credits returns to HallOfFameResetEventsAndSaveScript here: the
|
-- Credits returns to HallOfFameResetEventsAndSaveScript here: the
|
||||||
-- save 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
|
if self.onTheEnd then self.onTheEnd() end
|
||||||
self.phase = "end_hold"
|
self.phase = "end_hold"
|
||||||
self.timer = 600
|
self.timer = 600
|
||||||
|
|||||||
+23
-4
@@ -1,5 +1,11 @@
|
|||||||
-- Pokédex entry page: front sprite, kind, height/weight and the real
|
-- Pokédex entry page: front sprite, kind, height/weight and the real
|
||||||
-- dex description (data/pokemon/dex_entries.asm + dex_text.asm).
|
-- dex description (data/pokemon/dex_entries.asm + dex_text.asm).
|
||||||
|
--
|
||||||
|
-- `species` may be a species id string, or a table
|
||||||
|
-- `{ species = id, forceOwned = true }`. forceOwned mirrors pret's
|
||||||
|
-- StarterDex (engine/events/starter_dex.asm), which temporarily sets the
|
||||||
|
-- owned bit so Oak's lab ball previews show height/weight/description
|
||||||
|
-- without permanently marking the mon owned.
|
||||||
|
|
||||||
local Font = require("src.render.Font")
|
local Font = require("src.render.Font")
|
||||||
|
|
||||||
@@ -16,8 +22,17 @@ function DexEntryMenu:sgbPalettes(game)
|
|||||||
P.zone(P.monPal(game.data, self.def and self.def.id), 1, 1, 8, 8) }
|
P.zone(P.monPal(game.data, self.def and self.def.id), 1, 1, 8, 8) }
|
||||||
end
|
end
|
||||||
|
|
||||||
function DexEntryMenu.new(game, species)
|
local function resolveArgs(speciesOrOpts)
|
||||||
local self = setmetatable({ game = game }, DexEntryMenu)
|
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]
|
self.def = game.data.pokemon[species]
|
||||||
local ok, img = pcall(love.graphics.newImage, self.def.spriteFront)
|
local ok, img = pcall(love.graphics.newImage, self.def.spriteFront)
|
||||||
self.sprite = ok and img or nil
|
self.sprite = ok and img or nil
|
||||||
@@ -42,12 +57,16 @@ function DexEntryMenu:draw()
|
|||||||
love.graphics.setColor(0, 0, 0, 1)
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
Font.draw(def.name, 72, 8)
|
Font.draw(def.name, 72, 8)
|
||||||
local e = def.dexEntry or {}
|
local e = def.dexEntry or {}
|
||||||
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
|
-- same number width as the list (constants.dexDigits), so a dex past 999
|
||||||
-- prints the extra digit everywhere at once
|
-- prints the extra digit everywhere at once
|
||||||
local digits = (self.game.data.constants or {}).dexDigits or 3
|
local digits = (self.game.data.constants or {}).dexDigits or 3
|
||||||
Font.draw(("No.%0" .. digits .. "d"):format(def.dex or 0), 72, 32)
|
Font.draw(("No.%0" .. digits .. "d"):format(def.dex or 0), 72, 32)
|
||||||
local owned = self.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
|
-- height/weight print only once owned, like the description
|
||||||
-- (pokedex.asm: "if the pokemon has not been owned, don't print the
|
-- (pokedex.asm: "if the pokemon has not been owned, don't print the
|
||||||
-- height, weight, or description")
|
-- height, weight, or description")
|
||||||
|
|||||||
+13
-3
@@ -117,7 +117,7 @@ function ListMenu:draw()
|
|||||||
love.graphics.setColor(0, 0, 0, 1)
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
end
|
end
|
||||||
if item.right then
|
if item.right then
|
||||||
Font.draw(item.right, 160 - 8 - #item.right * 8, y)
|
Font.draw(item.right, 160 - 8 - Font.width(item.right), y)
|
||||||
end
|
end
|
||||||
if i == self.index then
|
if i == self.index then
|
||||||
-- hollowIndex: a chosen row keeps the hollow '▷' left behind by
|
-- hollowIndex: a chosen row keeps the hollow '▷' left behind by
|
||||||
@@ -136,7 +136,7 @@ function ListMenu:draw()
|
|||||||
Font.drawBox(11, 0, 9, 3)
|
Font.drawBox(11, 0, 9, 3)
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
local money = ("¥%d"):format(self.money and self.money() or 0)
|
local money = ("¥%d"):format(self.money and self.money() or 0)
|
||||||
Font.draw(money, 152 - #money * 8, 8)
|
Font.draw(money, 152 - Font.width(money), 8)
|
||||||
-- the clerk's line in the standard bottom text box; long prompts
|
-- 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
|
-- wrap and keep their last two lines, like the GB's scrolled box
|
||||||
Font.drawBox(0, 12, 20, 6)
|
Font.drawBox(0, 12, 20, 6)
|
||||||
@@ -153,7 +153,17 @@ function ListMenu:draw()
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
elseif self.footer then
|
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
|
end
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ local Tilt = require("src.render.Tilt")
|
|||||||
local GBCFX = require("src.render.GBCFX")
|
local GBCFX = require("src.render.GBCFX")
|
||||||
local GameSpeed = require("src.core.GameSpeed")
|
local GameSpeed = require("src.core.GameSpeed")
|
||||||
local VideoMode = require("src.core.VideoMode")
|
local VideoMode = require("src.core.VideoMode")
|
||||||
|
local FrameCap = require("src.core.FrameCap")
|
||||||
local Logger = require("src.core.Logger")
|
local Logger = require("src.core.Logger")
|
||||||
local Runtime = require("src.mods.Runtime")
|
local Runtime = require("src.mods.Runtime")
|
||||||
local OptionRows = require("src.ui.OptionRows")
|
local OptionRows = require("src.ui.OptionRows")
|
||||||
@@ -202,6 +203,19 @@ local function buildRows(game)
|
|||||||
VideoMode.apply(o.videoMode)
|
VideoMode.apply(o.videoMode)
|
||||||
return true
|
return true
|
||||||
end },
|
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
|
-- fast-forward the logic clock only; music and sfx keep their tempo
|
||||||
-- (src/core/GameSpeed.lua), so this is safe to leave on
|
-- (src/core/GameSpeed.lua), so this is safe to leave on
|
||||||
{ id = "speed", label = "GAME SPEED",
|
{ id = "speed", label = "GAME SPEED",
|
||||||
|
|||||||
+28
-7
@@ -69,10 +69,26 @@ local function drawIcon(game, mon, x, y, selected, counter)
|
|||||||
local icons = game.data.icons
|
local icons = game.data.icons
|
||||||
if not icons then return end
|
if not icons then return end
|
||||||
local def = game.data.pokemon[mon.species]
|
local def = game.data.pokemon[mon.species]
|
||||||
-- byDex is the vanilla lookup, but the icons registry can bring the table
|
-- Per-species override first: the icons registry folds into
|
||||||
-- into existence on its own, so it may be the only key present
|
-- icons.bySpecies, and a pokemon record may carry its own `icon` field.
|
||||||
local name = def and def.dex and icons.byDex and icons.byDex[def.dex]
|
-- Either is a built-in icon name (resolved through icons.icons) or a
|
||||||
local path = name and icons.icons[name]
|
-- { 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 not path then return end
|
||||||
if iconImages[path] == nil then
|
if iconImages[path] == nil then
|
||||||
local ok, img = pcall(love.graphics.newImage, path)
|
local ok, img = pcall(love.graphics.newImage, path)
|
||||||
@@ -182,12 +198,13 @@ function PartyMenu:update(dt)
|
|||||||
-- and music return first (PlayDefaultMusic +
|
-- and music return first (PlayDefaultMusic +
|
||||||
-- LoadWalkingPlayerSpriteGraphics), the menu closes with the
|
-- LoadWalkingPlayerSpriteGraphics), the menu closes with the
|
||||||
-- GBPalWhiteOutWithDelay3 blink, and the simulated pad press
|
-- GBPalWhiteOutWithDelay3 blink, and the simulated pad press
|
||||||
-- steps the player forward onto land
|
-- steps the player forward onto land (or across a connection
|
||||||
|
-- strip when the shore is the next map's edge)
|
||||||
self.game.stack:pop()
|
self.game.stack:pop()
|
||||||
ow.player.surfing = false
|
ow.player.surfing = false
|
||||||
require("src.core.Music").setSurfing(self.game.data, false)
|
require("src.core.Music").setSurfing(self.game.data, false)
|
||||||
self.game.stack:push(Transition.whiteFlash(self.game, nil, function()
|
self.game.stack:push(Transition.whiteFlash(self.game, nil, function()
|
||||||
ow:scriptMove(ow.player, ow.player.facing, 1)
|
ow:stepForwardOrCrossEdge(ow.player.facing)
|
||||||
end))
|
end))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -331,7 +348,11 @@ function PartyMenu:update(dt)
|
|||||||
local items = { { label = "STATS", action = "stats" },
|
local items = { { label = "STATS", action = "stats" },
|
||||||
{ label = "SWITCH", action = "switch" } }
|
{ label = "SWITCH", action = "switch" } }
|
||||||
local ow = self.game.overworld
|
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
|
||||||
for _, mv in ipairs(mon.moves) do
|
for _, mv in ipairs(mon.moves) do
|
||||||
if mv.id == "FLY" and ow.map.def.tileset == "OVERWORLD"
|
if mv.id == "FLY" and ow.map.def.tileset == "OVERWORLD"
|
||||||
and self.game.save.inventory.THUNDERBADGE then
|
and self.game.save.inventory.THUNDERBADGE then
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,7 @@
|
|||||||
-- The player's item-storage PC (engine/menus/players_pc.asm):
|
-- The player's item-storage PC (engine/menus/players_pc.asm):
|
||||||
-- WITHDRAW ITEM / DEPOSIT ITEM / TOSS ITEM / LOG OFF over
|
-- WITHDRAW ITEM / DEPOSIT ITEM / TOSS ITEM / LOG OFF over
|
||||||
-- game.save.pcItems ({ ITEM_ID = count }, 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
|
-- deposit ask "How many?" via the quantity selector (key items always
|
||||||
-- move one); toss discards after a YES/NO confirm. Follows the
|
-- move one); toss discards after a YES/NO confirm. Follows the
|
||||||
-- BoxMenu/BagMenu list idioms.
|
-- BoxMenu/BagMenu list idioms.
|
||||||
|
|||||||
+7
-3
@@ -84,13 +84,17 @@ local function buy(game, stock)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local function sell(game)
|
local function sell(game)
|
||||||
|
-- Sell list is ITEMLISTMENU with wPrintItemPrices cleared
|
||||||
|
-- (pokemart.asm .sellMenuLoop): name + quantity only. Price shows
|
||||||
|
-- in the quantity chooser. Stuffing "xN" into the label next to a
|
||||||
|
-- right-aligned ¥ price made long names overlap (issue #116).
|
||||||
local items = {}
|
local items = {}
|
||||||
for _, id in ipairs(Bag.order(game.save)) do
|
for _, id in ipairs(Bag.order(game.save)) do
|
||||||
local def = game.data.items[id]
|
local def = game.data.items[id]
|
||||||
table.insert(items, {
|
table.insert(items, {
|
||||||
value = id,
|
value = id,
|
||||||
label = (def and def.name or id) .. " x" .. game.save.inventory[id],
|
label = def and def.name or id,
|
||||||
right = ("¥%d"):format(def and math.floor(def.price / 2) or 0),
|
right = "x" .. game.save.inventory[id],
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
local greet = txt(game, "_PokemartBuyingGreetingText", "Take your time.")
|
local greet = txt(game, "_PokemartBuyingGreetingText", "Take your time.")
|
||||||
@@ -128,7 +132,7 @@ local function sell(game)
|
|||||||
Bag.remove(game.save, item.value, qty)
|
Bag.remove(game.save, item.value, qty)
|
||||||
local left = game.save.inventory[item.value]
|
local left = game.save.inventory[item.value]
|
||||||
if left then
|
if left then
|
||||||
item.label = def.name .. " x" .. left
|
item.right = "x" .. left
|
||||||
else
|
else
|
||||||
list:removeCurrent()
|
list:removeCurrent()
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -115,12 +115,16 @@ function SummaryMenu:draw()
|
|||||||
drawLineBox(19, 1, 6, 10)
|
drawLineBox(19, 1, 6, 10)
|
||||||
Font.draw("EXP POINTS", 72, 24)
|
Font.draw("EXP POINTS", 72, 24)
|
||||||
Font.draw(("%d"):format(mon.exp), 96, 32)
|
Font.draw(("%d"):format(mon.exp), 96, 32)
|
||||||
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 Growth = require("src.pokemon.Growth")
|
||||||
local nextExp = mon.level < 100
|
local nextExp = mon.level < 100
|
||||||
and (Growth.expForLevel(def.growthRate, mon.level + 1) - mon.exp) or 0
|
and (Growth.expForLevel(def.growthRate, mon.level + 1) - mon.exp) or 0
|
||||||
Font.draw(("%d to L%d"):format(math.max(0, nextExp),
|
Font.draw(("%7d"):format(math.max(0, nextExp)), 56, 48)
|
||||||
math.min(100, mon.level + 1)), 88, 52)
|
HudTiles.tile(0x6E, 128, 48) -- <LV>
|
||||||
|
Font.draw(tostring(math.min(100, mon.level + 1)), 136, 48)
|
||||||
Font.drawBox(0, 8, 20, 10)
|
Font.drawBox(0, 8, 20, 10)
|
||||||
for i = 1, 4 do
|
for i = 1, 4 do
|
||||||
local mv = mon.moves[i]
|
local mv = mon.moves[i]
|
||||||
|
|||||||
+32
-9
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
local Font = require("src.render.Font")
|
local Font = require("src.render.Font")
|
||||||
local Music = require("src.core.Music")
|
local Music = require("src.core.Music")
|
||||||
|
local GameVersion = require("src.core.GameVersion")
|
||||||
|
|
||||||
local TitleState = {}
|
local TitleState = {}
|
||||||
TitleState.__index = TitleState
|
TitleState.__index = TitleState
|
||||||
@@ -30,6 +31,13 @@ local CYCLE_SPECIES = {
|
|||||||
"PIKACHU", "CLEFAIRY", "RHYDON", "ABRA", "GASTLY", "DITTO",
|
"PIKACHU", "CLEFAIRY", "RHYDON", "ABRA", "GASTLY", "DITTO",
|
||||||
"PIDGEOTTO", "ONIX", "PONYTA", "MAGIKARP",
|
"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 CYCLE_FRAMES = 240 -- the original waits ~4s between picks
|
||||||
|
|
||||||
local function tryImage(path)
|
local function tryImage(path)
|
||||||
@@ -61,9 +69,13 @@ function TitleState.new(game, opts)
|
|||||||
self.version = tryImage(imagePath(title.versionRibbon or title.version)
|
self.version = tryImage(imagePath(title.versionRibbon or title.version)
|
||||||
or "assets/generated/title/red_version.png")
|
or "assets/generated/title/red_version.png")
|
||||||
self.player = tryImage("assets/generated/title/player.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"
|
self.cycleSpecies = (type(title.cycleSpecies) == "table"
|
||||||
and #title.cycleSpecies > 0)
|
and #title.cycleSpecies > 0)
|
||||||
and title.cycleSpecies or CYCLE_SPECIES
|
and title.cycleSpecies or defaultCycle
|
||||||
self.sprites = {} -- species -> image or false (load failed)
|
self.sprites = {} -- species -> image or false (load failed)
|
||||||
self.cycleIndex = 1
|
self.cycleIndex = 1
|
||||||
self.timer = 0
|
self.timer = 0
|
||||||
@@ -92,8 +104,10 @@ end
|
|||||||
|
|
||||||
local function hasSave()
|
local function hasSave()
|
||||||
local ok, info = pcall(function()
|
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
|
return love.filesystem and love.filesystem.getInfo
|
||||||
and love.filesystem.getInfo("save.lua") or nil
|
and love.filesystem.getInfo(name) or nil
|
||||||
end)
|
end)
|
||||||
return ok and info ~= nil
|
return ok and info ~= nil
|
||||||
end
|
end
|
||||||
@@ -211,17 +225,26 @@ function TitleState:draw()
|
|||||||
love.graphics.draw(self.logo, 16, 8)
|
love.graphics.draw(self.logo, 16, 8)
|
||||||
else
|
else
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
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)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
end
|
end
|
||||||
if self.version then
|
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()
|
local iw, ih = self.version:getDimensions()
|
||||||
love.graphics.draw(self.version,
|
if self.blue then
|
||||||
love.graphics.newQuad(0, 0, 16, 8, iw, ih), 56, 64)
|
-- Blue prints its ribbon contiguously ("Blue Version", hlcoord 7,8).
|
||||||
love.graphics.draw(self.version,
|
-- The extracted strip packs those eight glyph tiles into image tiles
|
||||||
love.graphics.newQuad(40, 0, 40, 8, iw, ih), 80, 64)
|
-- 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
|
end
|
||||||
local sprite = self:currentSprite()
|
local sprite = self:currentSprite()
|
||||||
if sprite then
|
if sprite then
|
||||||
|
|||||||
+47
-26
@@ -38,6 +38,51 @@ local function hashSet(list, into)
|
|||||||
return into
|
return into
|
||||||
end
|
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
|
-- Passability of a cell of an UNLOADED map def -- the connected neighbor
|
||||||
-- during an edge crossing. pokered's collision check reads the 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
|
-- 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
|
if not (def and tilesetDef and tilesetDef.blocks and tilesetDef.walkable) then
|
||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
local tx, ty = cx * 2, cy * 2 + 1
|
if Map.defIsWalkableCell(def, tilesetDef, cx, cy) then return true end
|
||||||
local bx, by = math.floor(tx / 4), math.floor(ty / 4)
|
if surfing and Map.defIsWaterCell(def, tilesetDef, cx, cy) then return true end
|
||||||
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
|
|
||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
+72
-5
@@ -2,6 +2,13 @@
|
|||||||
-- data, cached by map id. The cache is keyed so a mod that patches one
|
-- 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
|
-- map record after boot (or the dev-mode hot reload) can drop just that
|
||||||
-- entry instead of every map's SpriteBatches.
|
-- 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 Assets = require("src.render.Assets")
|
||||||
local Map = require("src.world.Map")
|
local Map = require("src.world.Map")
|
||||||
@@ -10,9 +17,20 @@ local TileRenderer = require("src.render.TileRenderer")
|
|||||||
local MapLoader = {}
|
local MapLoader = {}
|
||||||
|
|
||||||
local cache = {}
|
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)
|
local function touch(mapId)
|
||||||
if cache[mapId] then return cache[mapId] end
|
accessSeq = accessSeq + 1
|
||||||
|
lru[mapId] = accessSeq
|
||||||
|
end
|
||||||
|
|
||||||
|
local function build(data, mapId)
|
||||||
local def = data.maps[mapId]
|
local def = data.maps[mapId]
|
||||||
assert(def, "unknown map: " .. tostring(mapId) ..
|
assert(def, "unknown map: " .. tostring(mapId) ..
|
||||||
" (not in the maps registry)")
|
" (not in the maps registry)")
|
||||||
@@ -25,26 +43,75 @@ function MapLoader.load(data, mapId)
|
|||||||
local map = Map.new(def, tilesetDef)
|
local map = Map.new(def, tilesetDef)
|
||||||
map.renderer = TileRenderer.new(map, data)
|
map.renderer = TileRenderer.new(map, data)
|
||||||
cache[mapId] = map
|
cache[mapId] = map
|
||||||
|
touch(mapId)
|
||||||
return map
|
return map
|
||||||
end
|
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;
|
-- 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
|
-- callers that must not build a map (invalidation, tests) use this
|
||||||
function MapLoader.cached(mapId)
|
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
|
end
|
||||||
|
|
||||||
-- drop one map so the next load re-reads its record and rebuilds its
|
-- drop one map so the next load re-reads its record and rebuilds its
|
||||||
-- renderer. Callers holding the old instance keep it -- OverworldState
|
-- renderer. Deliberately does NOT release the old renderer: callers
|
||||||
-- re-points self.map itself (WorldAPI:invalidateMap).
|
-- 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)
|
function MapLoader.invalidate(mapId)
|
||||||
local had = cache[mapId] ~= nil
|
local had = cache[mapId] ~= nil
|
||||||
cache[mapId] = nil
|
cache[mapId] = nil
|
||||||
|
lru[mapId] = nil
|
||||||
return had
|
return had
|
||||||
end
|
end
|
||||||
|
|
||||||
function MapLoader.invalidateAll()
|
function MapLoader.invalidateAll()
|
||||||
cache = {}
|
cache = {}
|
||||||
|
lru = {}
|
||||||
end
|
end
|
||||||
|
|
||||||
-- kept as the pre-v2 name
|
-- kept as the pre-v2 name
|
||||||
|
|||||||
@@ -338,10 +338,13 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
|
|||||||
or (fromMapId and "warp" or "boot"),
|
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)
|
local hooks = mapScripts.get(mapId)
|
||||||
if hooks and hooks.onEnter then
|
if hooks and hooks.onEnter then
|
||||||
hooks.onEnter(Game, self)
|
hooks.onEnter(Game, self, fromMapId)
|
||||||
end
|
end
|
||||||
|
|
||||||
self:rebuildNeighbors()
|
self:rebuildNeighbors()
|
||||||
@@ -357,20 +360,32 @@ end
|
|||||||
-- widened to everything the current view size can show so a full
|
-- 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
|
-- zoom-out never runs past the rendered set. Re-run whenever the view
|
||||||
-- grows (zoom/resize), not only on setMap.
|
-- 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()
|
function OverworldState:rebuildNeighbors()
|
||||||
local mapId = self.map.id
|
local mapId = self.map.id
|
||||||
self.neighbors = {}
|
self.neighbors = {}
|
||||||
local hops = FieldDefaults.world(Game.data, "neighborHops") or NEIGHBOR_HOPS
|
local hops = FieldDefaults.world(Game.data, "neighborHops") or NEIGHBOR_HOPS
|
||||||
local vw, vh = Game.renderer:worldViewSize()
|
local vw, vh = Game.renderer:worldViewSize()
|
||||||
self.neighborViewW, self.neighborViewH = vw, vh
|
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,
|
for _, n in ipairs(OverworldState.computeNeighbors(Game.data.maps, mapId,
|
||||||
hops,
|
hops,
|
||||||
math.floor(vw / 2) + 64,
|
math.floor(vw / 2) + 64,
|
||||||
math.floor(vh / 2) + 64)) do
|
math.floor(vh / 2) + 64)) do
|
||||||
table.insert(self.neighbors,
|
keep[n.id] = true
|
||||||
{ map = MapLoader.load(Game.data, n.id),
|
local m = MapLoader.load(Game.data, n.id)
|
||||||
ox = n.ox, oy = n.oy })
|
table.insert(self.neighbors, { map = m, ox = n.ox, oy = n.oy })
|
||||||
end
|
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
|
-- visual-only NPCs on connected maps (survey zoom): same spawn filter
|
||||||
-- as a real map entry, but they never join self.entities -- no sight
|
-- as a real map entry, but they never join self.entities -- no sight
|
||||||
@@ -434,7 +449,9 @@ function OverworldState:paletteNameFor(map)
|
|||||||
return Runtime.call("map.palette", samePalette, name, map)
|
return Runtime.call("map.palette", samePalette, name, map)
|
||||||
end
|
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()
|
function OverworldState:sgbPalettes()
|
||||||
local PaletteFX = require("src.render.PaletteFX")
|
local PaletteFX = require("src.render.PaletteFX")
|
||||||
return PaletteFX.wholeNamed(Game.data, self:paletteNameFor(self.map))
|
return PaletteFX.wholeNamed(Game.data, self:paletteNameFor(self.map))
|
||||||
@@ -986,20 +1003,19 @@ function OverworldState:checkEdgeExit(dir)
|
|||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Map connections: the connected map's strip offset is in blocks; arriving
|
-- Landing cell on the connected map for a step off this map's edge in
|
||||||
-- coordinates follow destX = curX - offset*2 (see docs/extraction-notes.md).
|
-- `dir` (same math as crossConnection). Returns destDef, tilesetDef, x, y
|
||||||
-- The crossing scrolls continuously: the map data swaps while the player
|
-- or nil when there is no usable connection.
|
||||||
-- is placed one cell before the entry point (their old world position,
|
function OverworldState:connectionLanding(dir)
|
||||||
-- which the neighbor strips render identically) and walks the seam step.
|
local conn = self.map:connection(COMPASS[dir])
|
||||||
function OverworldState:crossConnection(dir, conn)
|
if not conn then return nil end
|
||||||
local dest = Game.data.maps[conn.map]
|
local dest = Game.data.maps[conn.map]
|
||||||
if not dest then
|
if not dest then return nil end
|
||||||
Logger.warn("connection to unknown map %s", tostring(conn.map))
|
local ts = Game.data.tilesets[dest.tileset]
|
||||||
return false
|
if not ts then return nil end
|
||||||
end
|
|
||||||
local p = self.player
|
local p = self.player
|
||||||
local x, y = p.cellX, p.cellY
|
|
||||||
local destW, destH = dest.width * 2, dest.height * 2
|
local destW, destH = dest.width * 2, dest.height * 2
|
||||||
|
local x, y
|
||||||
if dir == "up" then
|
if dir == "up" then
|
||||||
x, y = p.cellX - conn.offset * 2, destH - 1
|
x, y = p.cellX - conn.offset * 2, destH - 1
|
||||||
elseif dir == "down" then
|
elseif dir == "down" then
|
||||||
@@ -1011,13 +1027,27 @@ function OverworldState:crossConnection(dir, conn)
|
|||||||
end
|
end
|
||||||
x = math.max(0, math.min(destW - 1, x))
|
x = math.max(0, math.min(destW - 1, x))
|
||||||
y = math.max(0, math.min(destH - 1, y))
|
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
|
-- 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
|
-- 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
|
-- 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
|
-- 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.
|
-- collision tile -- stranding the player on a cell no walk can leave.
|
||||||
if not Map.defPassable(dest, Game.data.tilesets[dest.tileset], x, y,
|
if not Map.defPassable(dest, ts, x, y, p.surfing) then
|
||||||
p.surfing) then
|
|
||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
self:setMap(conn.map, x, y, p.facing, { seamless = true })
|
self:setMap(conn.map, x, y, p.facing, { seamless = true })
|
||||||
@@ -1040,12 +1070,61 @@ function OverworldState:crossConnection(dir, conn)
|
|||||||
return true
|
return true
|
||||||
end
|
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
|
-- interactions
|
||||||
-- -------------------------------------------------------------------------
|
-- -------------------------------------------------------------------------
|
||||||
|
|
||||||
-- HM field moves are gated by badges like the original
|
-- 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)
|
function OverworldState:partyKnows(moveId)
|
||||||
local gate = (FieldDefaults.constant(Game.data, "hmBadges") or {})[moveId]
|
local gate = (FieldDefaults.constant(Game.data, "hmBadges") or {})[moveId]
|
||||||
local badge = gate and gate.badge
|
local badge = gate and gate.badge
|
||||||
@@ -1053,10 +1132,8 @@ function OverworldState:partyKnows(moveId)
|
|||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
for _, mon in ipairs(Game.save.party) do
|
for _, mon in ipairs(Game.save.party) do
|
||||||
if mon.hp > 0 then
|
for _, mv in ipairs(mon.moves) do
|
||||||
for _, mv in ipairs(mon.moves) do
|
if mv.id == moveId then return mon end
|
||||||
if mv.id == moveId then return mon end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
return nil
|
return nil
|
||||||
@@ -1557,16 +1634,21 @@ function OverworldState:trashCanSwitch(canIndex)
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- Bill's House PC (engine/events/hidden_events/bills_house_pc.asm
|
-- Bill's House PC (engine/events/hidden_events/bills_house_pc.asm
|
||||||
-- BillsHousePC): once Bill-the-Pokémon has climbed into the machine
|
-- BillsHousePC). Check order matches pokered:
|
||||||
-- (EVENT_BILL_SAID_USE_CELL_SEPARATOR), running the PC plays the cell
|
-- 1) EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING -> Eevee collection list
|
||||||
-- separator's SFX sequence, sets EVENT_USED_CELL_SEPARATOR_ON_BILL and
|
-- 2) EVENT_USED_CELL_SEPARATOR_ON_BILL -> teleporter monitor text
|
||||||
-- Bill steps back out of the machine human again
|
-- 3) EVENT_BILL_SAID_USE_CELL_SEPARATOR -> cell-separator cutscene
|
||||||
-- (BillsHouseBillExitsMachineScript / CleanupScript set EVENT_MET_BILL).
|
-- 4) else -> teleporter monitor text
|
||||||
|
-- Leaving after the SS Ticket (Route25ToggleBillsScript) arms (1).
|
||||||
function OverworldState:billsHousePC()
|
function OverworldState:billsHousePC()
|
||||||
local t = Game.data.text
|
local t = Game.data.text
|
||||||
local flags = Game.save.flags
|
local flags = Game.save.flags
|
||||||
if not (flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR
|
if flags.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING then
|
||||||
and not flags.EVENT_USED_CELL_SEPARATOR_ON_BILL) 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
|
Game.stack:push(TextBox.new(Game, t._BillsHouseMonitorText
|
||||||
or "TELEPORTER is\ndisplayed on the\nPC monitor."))
|
or "TELEPORTER is\ndisplayed on the\nPC monitor."))
|
||||||
return
|
return
|
||||||
@@ -1590,9 +1672,40 @@ function OverworldState:billsHousePC()
|
|||||||
end))
|
end))
|
||||||
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
|
-- BillsHouseBillExitsMachineScript: human Bill appears inside the machine
|
||||||
-- at (1,2) and walks out to his spot at (4,4); the map music resumes and
|
-- 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()
|
function OverworldState:billsHouseBillExits()
|
||||||
local Commands = require("src.script.Commands")
|
local Commands = require("src.script.Commands")
|
||||||
local ctx = { game = Game, save = Game.save, overworld = self }
|
local ctx = { game = Game, save = Game.save, overworld = self }
|
||||||
@@ -1684,14 +1797,15 @@ function OverworldState:trySurf(fx, fy)
|
|||||||
Game.stack:push(TextBox.new(Game, text, function()
|
Game.stack:push(TextBox.new(Game, text, function()
|
||||||
-- start_sub_menus.asm .surf: UseItem returns (mount + text done),
|
-- start_sub_menus.asm .surf: UseItem returns (mount + text done),
|
||||||
-- then GBPalWhiteOutWithDelay3 blinks before the simulated forward
|
-- 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")
|
local Transition = require("src.render.Transition")
|
||||||
if Transition.whiteFlash then
|
if Transition.whiteFlash then
|
||||||
Game.stack:push(Transition.whiteFlash(Game, nil, function()
|
Game.stack:push(Transition.whiteFlash(Game, nil, function()
|
||||||
self:scriptMove(p, p.facing, 1)
|
self:stepForwardOrCrossEdge(p.facing)
|
||||||
end))
|
end))
|
||||||
else
|
else
|
||||||
self:scriptMove(p, p.facing, 1)
|
self:stepForwardOrCrossEdge(p.facing)
|
||||||
end
|
end
|
||||||
end))
|
end))
|
||||||
end
|
end
|
||||||
@@ -1773,7 +1887,7 @@ end
|
|||||||
-- to SurfingAttemptFailed (_NoSurfingHereText) if it isn't water. This is
|
-- to SurfingAttemptFailed (_NoSurfingHereText) if it isn't water. This is
|
||||||
-- a side-effect-free check that reports which text/flow the caller should
|
-- a side-effect-free check that reports which text/flow the caller should
|
||||||
-- use; the actual mount happens in trySurf on "ok". Returns:
|
-- 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)
|
-- "forced_bike" -> on the Cycling Road (_CyclingIsFunText)
|
||||||
-- "current" -> Seafoam B4F stairs before the boulders (_CurrentTooFastText)
|
-- "current" -> Seafoam B4F stairs before the boulders (_CurrentTooFastText)
|
||||||
-- "dismount" -> already surfing, facing dry land; caller steps forward
|
-- "dismount" -> already surfing, facing dry land; caller steps forward
|
||||||
@@ -1791,20 +1905,20 @@ function OverworldState:useSurfFieldMove()
|
|||||||
-- until both EVENT_SEAFOAM4_BOULDER*_DOWN_HOLE events are set.
|
-- until both EVENT_SEAFOAM4_BOULDER*_DOWN_HOLE events are set.
|
||||||
if Game.save.forcedBike then return "forced_bike" end
|
if Game.save.forcedBike then return "forced_bike" end
|
||||||
if self:surfBlockedHere() then return "current" end
|
if self:surfBlockedHere() then return "current" end
|
||||||
local fx, fy = p:facingCell()
|
|
||||||
if p.surfing then
|
if p.surfing then
|
||||||
-- ItemUseSurfboard .tryToStopSurfing: blocked by a sprite in front
|
-- ItemUseSurfboard .tryToStopSurfing: blocked by a sprite in front
|
||||||
-- (IsSpriteInFrontOfPlayer2), a water tile-pair collision, or a
|
-- (IsSpriteInFrontOfPlayer2), a water tile-pair collision, or a
|
||||||
-- facing tile that isn't in the tileset's land-passable list;
|
-- facing tile that isn't in the tileset's land-passable list;
|
||||||
-- otherwise the player walks forward off the water.
|
-- otherwise the player walks forward off the water. Facing a land
|
||||||
if self.map:inBounds(fx, fy) and self.map:isWalkableCell(fx, fy)
|
-- cell across a map connection (Cinnabar east coast) counts too --
|
||||||
and Collision.canMove(self.map, self.entities, p, p.facing) then
|
-- pokered reads that landing from the connection strip.
|
||||||
|
if self:facingIsLandDismount() then
|
||||||
return "dismount"
|
return "dismount"
|
||||||
end
|
end
|
||||||
return "no_place"
|
return "no_place"
|
||||||
end
|
end
|
||||||
if not self.map:inBounds(fx, fy)
|
-- IsNextTileShoreOrWater, including connection-strip water (issue #125)
|
||||||
or not self.map:isWaterCell(fx, fy) or not self:tilesetHasWater() then
|
if not self:facingIsShoreOrWater() then
|
||||||
return "no_water"
|
return "no_water"
|
||||||
end
|
end
|
||||||
return "ok"
|
return "ok"
|
||||||
@@ -1816,7 +1930,7 @@ end
|
|||||||
-- (and .loop back to the submenu) if it isn't cuttable. Side-effect-free
|
-- (and .loop back to the submenu) if it isn't cuttable. Side-effect-free
|
||||||
-- check mirroring useSurfFieldMove; tryCut does the actual cut on "ok".
|
-- check mirroring useSurfFieldMove; tryCut does the actual cut on "ok".
|
||||||
-- Returns:
|
-- 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)
|
-- "nothing" -> not facing a cuttable tree (_NothingToCutText)
|
||||||
-- "ok" -> facing a cuttable tree; caller may call tryCut(fx, fy)
|
-- "ok" -> facing a cuttable tree; caller may call tryCut(fx, fy)
|
||||||
function OverworldState:useCutFieldMove()
|
function OverworldState:useCutFieldMove()
|
||||||
@@ -2237,6 +2351,8 @@ end
|
|||||||
-- Badges/items awarded after specific battles (data/scripts/victories.lua).
|
-- Badges/items awarded after specific battles (data/scripts/victories.lua).
|
||||||
-- `deactivate` retires unfought gym/dojo trainers the way the originals'
|
-- `deactivate` retires unfought gym/dojo trainers the way the originals'
|
||||||
-- SetEvent / SetEventRange do after the leader victory.
|
-- 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)
|
function OverworldState:checkVictoryRewards(trainerClass, partyIndex)
|
||||||
local victories = require("data.scripts.victories")
|
local victories = require("data.scripts.victories")
|
||||||
local reward = victories[trainerClass .. "#" .. tostring(partyIndex or 1)]
|
local reward = victories[trainerClass .. "#" .. tostring(partyIndex or 1)]
|
||||||
@@ -2250,6 +2366,13 @@ function OverworldState:checkVictoryRewards(trainerClass, partyIndex)
|
|||||||
Game.save.flags[flag] = true
|
Game.save.flags[flag] = true
|
||||||
end
|
end
|
||||||
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 = {}
|
local lines = {}
|
||||||
if reward.badge then
|
if reward.badge then
|
||||||
Game.save.inventory[reward.badge] = 1
|
Game.save.inventory[reward.badge] = 1
|
||||||
@@ -3346,9 +3469,9 @@ function OverworldState:drawWorld()
|
|||||||
-- reorder (no draws), so they run once for both paths.
|
-- reorder (no draws), so they run once for both paths.
|
||||||
local tilt = Tilt.active()
|
local tilt = Tilt.active()
|
||||||
self.map.renderer:drawBorderFill(cam.x, bgY, vw, vh)
|
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
|
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
|
end
|
||||||
-- per-billboard SGB palette source; only needed (and only paid for) when
|
-- per-billboard SGB palette source; only needed (and only paid for) when
|
||||||
-- tilting. nil headless / on stale palettes -> billboards go uncolorized.
|
-- tilting. nil headless / on stale palettes -> billboards go uncolorized.
|
||||||
@@ -3401,8 +3524,11 @@ function OverworldState:drawWorld()
|
|||||||
love.graphics.setShader(shader)
|
love.graphics.setShader(shader)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
local ox = ha.px - 64 - cam.x
|
-- TileRenderer windows with -floor(cam), so the overlay must use the
|
||||||
local oy = ha.py - 64 - cam.y
|
-- 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.setColor(1, 1, 1, 1)
|
||||||
love.graphics.draw(img, self.healMachineQuads[1], ox + 44, oy + 20)
|
love.graphics.draw(img, self.healMachineQuads[1], ox + 44, oy + 20)
|
||||||
for i = 1, math.min(ha.lit, #HEAL_BALL_XY) do
|
for i = 1, math.min(ha.lit, #HEAL_BALL_XY) do
|
||||||
@@ -3492,11 +3618,21 @@ function OverworldState:drawWorld()
|
|||||||
end)
|
end)
|
||||||
-- EXCLAMATION_BUBBLE is index 0 -> first crop; the emote command
|
-- EXCLAMATION_BUBBLE is index 0 -> first crop; the emote command
|
||||||
-- picks question/happy crops instead
|
-- 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
|
if ok and img and rect then
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
love.graphics.draw(img, love.graphics.newQuad(rect.x, rect.y,
|
-- one Quad per bubble crop, cached: this draws every frame the "!"
|
||||||
rect.w, rect.h, img:getDimensions()), ex, ey)
|
-- (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
|
drawn = true
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -3608,11 +3744,13 @@ function OverworldState:drawWorld()
|
|||||||
else
|
else
|
||||||
-- === TILT PATH: ground-hugging FX stay on the projected ground, all
|
-- === TILT PATH: ground-hugging FX stay on the projected ground, all
|
||||||
-- standing things billboard upright over it in a separate pass. ======
|
-- standing things billboard upright over it in a separate pass. ======
|
||||||
-- Dust is ground-hugging smoke -> ground canvas (puts it
|
-- Dust / cut / the Poké Center heal overlay hug the BG (the heal
|
||||||
-- with the flat layer, so it projects with the ground). Flat mode
|
-- machine is a tileset graphic; its OAM balls must ride that plane or
|
||||||
-- draws it last, over the sprites, in the same canvas; here the two
|
-- 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
|
-- 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()
|
fxDust()
|
||||||
fxCutTree()
|
fxCutTree()
|
||||||
|
|
||||||
@@ -3668,18 +3806,11 @@ function OverworldState:drawWorld()
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Screen-anchored world FX : each billboards at the
|
-- Standing world FX: each billboards at the ground foot of the
|
||||||
-- ground foot of the character it belongs to, so it stands upright and
|
-- character it belongs to, so it stays upright over the tilted ground.
|
||||||
-- 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)
|
|
||||||
-- emote bubble -> the spotting NPC's foot (rides above its head)
|
-- emote bubble -> the spotting NPC's foot (rides above its head)
|
||||||
-- fly bird, rod -> the player's foot
|
-- fly bird, rod -> the player's foot
|
||||||
if self.healAnim then
|
-- (heal machine is ground-hugging -- drawn above with dust/cut)
|
||||||
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
|
|
||||||
if self.emote and self.emote.npc then
|
if self.emote and self.emote.npc then
|
||||||
local fx = self.emote.npc.px - cam.x + 8
|
local fx = self.emote.npc.px - cam.x + 8
|
||||||
local fy = self.emote.npc.py - cam.y + 16
|
local fy = self.emote.npc.py - cam.y + 16
|
||||||
|
|||||||
@@ -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,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
|
||||||
+2
-15
@@ -4165,21 +4165,8 @@ function ops.fieldMove(s, where)
|
|||||||
say(("fieldMove %s: nobody in the party knows it"):format(move))
|
say(("fieldMove %s: nobody in the party knows it"):format(move))
|
||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
-- The knower must also be CONSCIOUS: the party submenu hides field
|
-- Field moves work from fainted users in Gen 1 (PartyMenu + partyKnows
|
||||||
-- moves on a fainted mon (vanilla -- partyKnows requires hp > 0), so
|
-- no longer gate on hp > 0). No Poké Center detour needed here.
|
||||||
-- 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
|
|
||||||
-- CUT acts on the tile the player is FACING (OverworldState:tryCut), and
|
-- CUT acts on the tile the player is FACING (OverworldState:tryCut), and
|
||||||
-- a `goto` leaves us facing whatever direction we last stepped. Without
|
-- a `goto` leaves us facing whatever direction we last stepped. Without
|
||||||
-- turning to the tree first the move fires into empty ground, the game
|
-- turning to the tree first the move fires into empty ground, the game
|
||||||
|
|||||||
+8
-1
@@ -40,6 +40,8 @@ stub.graphics = {
|
|||||||
newSpriteBatch = function(image, size)
|
newSpriteBatch = function(image, size)
|
||||||
local batch = { image = image, sprites = {} }
|
local batch = { image = image, sprites = {} }
|
||||||
function batch:add(quad, x, y) table.insert(self.sprites, { quad, x, y }) end
|
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
|
return batch
|
||||||
end,
|
end,
|
||||||
draw = noop, rectangle = noop, setColor = noop, clear = noop,
|
draw = noop, rectangle = noop, setColor = noop, clear = noop,
|
||||||
@@ -125,7 +127,12 @@ stub.sound = {
|
|||||||
|
|
||||||
stub.keyboard = { isDown = function() return false end }
|
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 }
|
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")
|
"a chip song still plays after a file song")
|
||||||
check(not body.playing, "the outgoing file song was stopped")
|
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
|
-- sfx shape dispatch
|
||||||
check(Sound.play(data, "Beep") == nil, "Sound.play returns nothing")
|
check(Sound.play(data, "Beep") == nil, "Sound.play returns nothing")
|
||||||
check(lastSource().file == "assets/beep.wav", "a bare string sfx is a static source")
|
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")
|
"the seeded boot config reproduces the NewGameWarp bedroom spawn")
|
||||||
check(vanillaSave.player.name == "RED" and vanillaSave.player.rival == "BLUE"
|
check(vanillaSave.player.name == "RED" and vanillaSave.player.rival == "BLUE"
|
||||||
and vanillaSave.money == 3000, "the seeded boot config reproduces the Red start")
|
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
|
-- the heal point is deliberately NOT the spawn: wLastBlackoutMap is
|
||||||
-- zero-filled at new game and PALLET_TOWN is map 0
|
-- zero-filled at new game and PALLET_TOWN is map 0
|
||||||
check(vanillaSave.lastHeal.map == "PALLET_TOWN" and vanillaSave.lastHeal.x == 5
|
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
|
check(bareSave.player.map == "REDS_HOUSE_2F" and bareSave.money == 3000
|
||||||
and bareSave.lastHeal.map == "PALLET_TOWN",
|
and bareSave.lastHeal.map == "PALLET_TOWN",
|
||||||
"newGame without a boot config is unchanged")
|
"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", [[
|
local bootData, bootLoader = withMod("total_conversion", [[
|
||||||
return function(mod)
|
return function(mod)
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ love.graphics = {
|
|||||||
newSpriteBatch = function(image, size)
|
newSpriteBatch = function(image, size)
|
||||||
local batch = { image = image, sprites = {} }
|
local batch = { image = image, sprites = {} }
|
||||||
function batch:add(quad, x, y) table.insert(self.sprites, { quad, x, y }) end
|
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
|
function batch:setTexture(tex) self.texture = tex end
|
||||||
return batch
|
return batch
|
||||||
end,
|
end,
|
||||||
@@ -287,6 +288,8 @@ local renderer = TileRenderer.new(map)
|
|||||||
check(#renderer.anims == 1, "a declared animatedTiles entry builds one anim")
|
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].period == 12, "the declared period is honored")
|
||||||
check(#renderer.anims[1].textures == 3, "the declared frame images load")
|
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")
|
check(renderer.anims[1].batch ~= nil, "the animated tile collected cells")
|
||||||
|
|
||||||
-- the vanilla water cycle, driven through the same data path: eight
|
-- the vanilla water cycle, driven through the same data path: eight
|
||||||
@@ -307,6 +310,8 @@ local sea = TileRenderer.new({
|
|||||||
check(#sea.anims == 1, "an OVERWORLD tileset animates its water with no record edit")
|
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")
|
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 = {}
|
local seen = {}
|
||||||
for step = 1, 8 do
|
for step = 1, 8 do
|
||||||
sea:drawAnimated(0, 0)
|
sea:drawAnimated(0, 0)
|
||||||
@@ -613,6 +618,33 @@ check(PaletteFX.pal({ palettes = nil }, "ROUTE") == gbc.palettes.ROUTE,
|
|||||||
"RED++ still has ROUTE (aliased from VIRIDIAN)")
|
"RED++ still has ROUTE (aliased from VIRIDIAN)")
|
||||||
check(PaletteFX.effectiveColors(gbc.palettes.MEWMON) == gbc.palettes.MEWMON,
|
check(PaletteFX.effectiveColors(gbc.palettes.MEWMON) == gbc.palettes.MEWMON,
|
||||||
"RED++ passes zone colors through like GBC")
|
"RED++ passes zone colors through like GBC")
|
||||||
|
-- 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)
|
PaletteFX.setMode(prevMode)
|
||||||
|
|
||||||
-- ------- the transitions registry
|
-- ------- the transitions registry
|
||||||
@@ -642,6 +674,68 @@ check(Transition.new({ data = retimed }).frames == 30,
|
|||||||
check(Transition.new({ data = { transitions = {} } }).frames == 12,
|
check(Transition.new({ data = { transitions = {} } }).frames == 12,
|
||||||
"an unregistered id falls back to the built-in 12 frames")
|
"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
|
||||||
|
|
||||||
-- ------- the transition.style hook
|
-- ------- the transition.style hook
|
||||||
|
|
||||||
local stack = { pop = function() end }
|
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.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.boxes and loaded.boxes[1][1].species == "PIDGEY", "box list folded into boxes[1]")
|
||||||
check(loaded.box == nil, "legacy box key gone")
|
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"])
|
local opts = SaveSerializer.decode(files["options.lua"])
|
||||||
check(opts and opts.musicVol == 2, "embedded options split into options.lua")
|
check(opts and opts.musicVol == 2, "embedded options split into options.lua")
|
||||||
|
|
||||||
@@ -203,7 +204,16 @@ do
|
|||||||
local current = { meta = { format = Version.saveFormat, mods = {} },
|
local current = { meta = { format = Version.saveFormat, mods = {} },
|
||||||
player = { map = "PALLET_TOWN", x = 1, y = 1 } }
|
player = { map = "PALLET_TOWN", x = 1, y = 1 } }
|
||||||
SaveData.runMigrations(current)
|
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")
|
||||||
|
|
||||||
love.filesystem = realFS
|
love.filesystem = realFS
|
||||||
end
|
end
|
||||||
@@ -526,6 +536,32 @@ do
|
|||||||
"newGame starts an empty modData")
|
"newGame starts an empty modData")
|
||||||
check(plain.meta.format == Version.saveFormat and #plain.meta.mods == 0,
|
check(plain.meta.format == Version.saveFormat and #plain.meta.mods == 0,
|
||||||
"newGame stamps a vanilla meta")
|
"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
|
end
|
||||||
|
|
||||||
-- a throwing mod migration is skipped, never fatal: an uncaught error here
|
-- a throwing mod migration is skipped, never fatal: an uncaught error here
|
||||||
|
|||||||
+73
-3
@@ -22,6 +22,7 @@ local StateStack = require("src.core.StateStack")
|
|||||||
local ManagerState = require("src.mods.ManagerState")
|
local ManagerState = require("src.mods.ManagerState")
|
||||||
local ModUI = require("src.ui.ModUI")
|
local ModUI = require("src.ui.ModUI")
|
||||||
local Theme = require("src.ui.Theme")
|
local Theme = require("src.ui.Theme")
|
||||||
|
local FrameCap = require("src.core.FrameCap")
|
||||||
|
|
||||||
local savedEvents, savedHooks, savedErrors =
|
local savedEvents, savedHooks, savedErrors =
|
||||||
Runtime.events, Runtime.hooks, Runtime.errors
|
Runtime.events, Runtime.hooks, Runtime.errors
|
||||||
@@ -205,7 +206,7 @@ end
|
|||||||
local om = OptionsMenu.new(optGame())
|
local om = OptionsMenu.new(optGame())
|
||||||
local WANT_IDS = { "textSpeed", "animations", "battleStyle", "ruleset",
|
local WANT_IDS = { "textSpeed", "animations", "battleStyle", "ruleset",
|
||||||
"musicVol", "sfxVol", "musicFilter", "colors", "tilt",
|
"musicVol", "sfxVol", "musicFilter", "colors", "tilt",
|
||||||
"gbcfx", "videoMode", "speed", "mods", "controls" }
|
"gbcfx", "videoMode", "fpsCap", "speed", "mods", "controls" }
|
||||||
check(#om.rows == #WANT_IDS, "vanilla options row count (plus MODS/CONTROLS)")
|
check(#om.rows == #WANT_IDS, "vanilla options row count (plus MODS/CONTROLS)")
|
||||||
for i, id in ipairs(WANT_IDS) do
|
for i, id in ipairs(WANT_IDS) do
|
||||||
check(om.rows[i].id == id, "options row order: " .. id)
|
check(om.rows[i].id == id, "options row order: " .. id)
|
||||||
@@ -235,10 +236,47 @@ check(om.game.save.options.musicVol == 6, "music volume steps down")
|
|||||||
for _ = 1, 10 do om.rows[5].step(om.game, -1) end
|
for _ = 1, 10 do om.rows[5].step(om.game, -1) end
|
||||||
check(om.game.save.options.musicVol == 0, "music volume clamps at 0")
|
check(om.game.save.options.musicVol == 0, "music volume clamps at 0")
|
||||||
|
|
||||||
|
-- the MAX FPS row cycles the render-cap steps and shows the value plain
|
||||||
|
om.game.save.options.fpsCap = nil
|
||||||
|
check(om.rows[12].value(om.game) == "60",
|
||||||
|
"MAX FPS row defaults to 60 with no saved cap")
|
||||||
|
om.rows[12].step(om.game, 1)
|
||||||
|
check(om.game.save.options.fpsCap == 75, "MAX FPS steps up from 60 to 75")
|
||||||
|
check(om.rows[12].value(om.game) == "75", "the MAX FPS row renders the cap")
|
||||||
|
om.game.save.options.fpsCap = 160
|
||||||
|
om.rows[12].step(om.game, 1)
|
||||||
|
check(om.game.save.options.fpsCap == 30, "MAX FPS wraps past the ceiling to 30")
|
||||||
|
om.rows[12].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
|
-- the MODS row is the manager's discoverable home
|
||||||
local mgGame = optGame()
|
local mgGame = optGame()
|
||||||
om = OptionsMenu.new(mgGame)
|
om = OptionsMenu.new(mgGame)
|
||||||
om.rows[13].activate(mgGame)
|
om.rows[14].activate(mgGame)
|
||||||
check(getmetatable(mgGame.stack:top()) == ManagerState,
|
check(getmetatable(mgGame.stack:top()) == ManagerState,
|
||||||
"the MODS row opens the manager")
|
"the MODS row opens the manager")
|
||||||
check(mgGame.stack:top().screenId == "ManagerState",
|
check(mgGame.stack:top().screenId == "ManagerState",
|
||||||
@@ -248,7 +286,7 @@ check(mgGame.stack:top().screenId == "ManagerState",
|
|||||||
local BindingsMenu = require("src.ui.BindingsMenu")
|
local BindingsMenu = require("src.ui.BindingsMenu")
|
||||||
local cbGame = optGame()
|
local cbGame = optGame()
|
||||||
om = OptionsMenu.new(cbGame)
|
om = OptionsMenu.new(cbGame)
|
||||||
om.rows[14].activate(cbGame)
|
om.rows[15].activate(cbGame)
|
||||||
local bm = cbGame.stack:top()
|
local bm = cbGame.stack:top()
|
||||||
check(getmetatable(bm) == BindingsMenu,
|
check(getmetatable(bm) == BindingsMenu,
|
||||||
"the CONTROLS row opens the rebind list")
|
"the CONTROLS row opens the rebind list")
|
||||||
@@ -437,6 +475,38 @@ check(title.version and title.version.path
|
|||||||
== "assets/generated/title/red_version.png",
|
== "assets/generated/title/red_version.png",
|
||||||
"the shipped version ribbon loads")
|
"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
|
||||||
|
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)
|
||||||
|
check(zones and zones[2] and zones[2].colors == blueLogo1,
|
||||||
|
"Blue title ribbon zone uses ROM LOGO1 under RED++")
|
||||||
|
GameVersion.set("red")
|
||||||
|
zones = TitleState.sgbPalettes(title, game)
|
||||||
|
local gbcLogo1 = PaletteFX.gbcPack().palettes.LOGO1
|
||||||
|
check(zones and zones[2] and zones[2].colors == gbcLogo1,
|
||||||
|
"Red title under RED++ keeps gbc-pack LOGO1 even if ROM has Blue's")
|
||||||
|
GameVersion.set(prevVer)
|
||||||
|
PaletteFX.setMode(prevMode)
|
||||||
|
end
|
||||||
|
|
||||||
local OakSpeech = require("src.ui.OakSpeech")
|
local OakSpeech = require("src.ui.OakSpeech")
|
||||||
local ogame = { data = {
|
local ogame = { data = {
|
||||||
field = { oakSpeech = { music = "X_Song", demoSpecies = "PIKACHU" } },
|
field = { oakSpeech = { music = "X_Song", demoSpecies = "PIKACHU" } },
|
||||||
|
|||||||
@@ -338,6 +338,15 @@ do
|
|||||||
end
|
end
|
||||||
check(pewterNpc and ow:trainerDefeated(pewterNpc),
|
check(pewterNpc and ow:trainerDefeated(pewterNpc),
|
||||||
"unfought Pewter gym trainer is defeated after badge")
|
"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
|
while Game.stack:top() do Game.stack:pop() end
|
||||||
Game.save = SaveData.newGame()
|
Game.save = SaveData.newGame()
|
||||||
|
|||||||
+64
-6
@@ -55,9 +55,27 @@ local function newStack()
|
|||||||
return stack, items
|
return stack, items
|
||||||
end
|
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
|
-- 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)
|
-- ListMenu or, for the keyGated Rocket Hideout without the key, a TextBox).
|
||||||
local function openElevator(mapId, inventory)
|
-- 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)
|
local script = mapScripts.get(mapId)
|
||||||
check(script ~= nil, mapId .. " script registered")
|
check(script ~= nil, mapId .. " script registered")
|
||||||
check(script and script.onEnter ~= nil, mapId .. " has onEnter")
|
check(script and script.onEnter ~= nil, mapId .. " has onEnter")
|
||||||
@@ -98,7 +116,7 @@ local function openElevator(mapId, inventory)
|
|||||||
stack = stack,
|
stack = stack,
|
||||||
}
|
}
|
||||||
sfxCalls = {}
|
sfxCalls = {}
|
||||||
script.onEnter(game, ow)
|
script.onEnter(game, ow, fromMapId)
|
||||||
return items[#items], warpCalls, stack, ow
|
return items[#items], warpCalls, stack, ow
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -130,7 +148,14 @@ end
|
|||||||
-- Silph Co elevator: 11 floors, the double-digit sort/label regression
|
-- Silph Co elevator: 11 floors, the double-digit sort/label regression
|
||||||
-- ===================================================================
|
-- ===================================================================
|
||||||
do
|
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")
|
check(menu ~= nil and getmetatable(menu) == ListMenu, "SILPH_CO_ELEVATOR opens a ListMenu")
|
||||||
if menu then
|
if menu then
|
||||||
eq(#menu.items, 11, "Silph Co elevator lists all 11 floors")
|
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"),
|
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")
|
"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 --
|
-- Cancel: pokered's DisplayElevatorFloorMenu does `ret c` on B --
|
||||||
-- no warp at all.
|
-- no warp at all.
|
||||||
clear(warpCalls)
|
clear(warpCalls)
|
||||||
menu.onCancel()
|
menu.onCancel()
|
||||||
eq(#warpCalls, 0, "Cancel does not warp (bare ret c, no floors[1] fallback)")
|
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 --
|
-- Choose a mid-list floor (5F): pokered never warps on the spot --
|
||||||
-- DisplayElevatorFloorMenu sets BIT_CUR_MAP_USED_ELEVATOR and the
|
-- DisplayElevatorFloorMenu sets BIT_CUR_MAP_USED_ELEVATOR and the
|
||||||
-- map script runs ShakeElevator: a 12-frame lead-in (the script's
|
-- 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.
|
-- cycle, then SFX_SAFARI_ZONE_PA, and only then the floor warp.
|
||||||
clear(warpCalls)
|
clear(warpCalls)
|
||||||
sfxCalls = {}
|
sfxCalls = {}
|
||||||
|
ow.walkSteps = {}
|
||||||
local chosen = menu.items[5] -- "5F"
|
local chosen = menu.items[5] -- "5F"
|
||||||
menu.onChoose(chosen, menu)
|
menu.onChoose(chosen, menu)
|
||||||
eq(#warpCalls, 0, "choosing a floor does not warp on the spot (the shake runs first)")
|
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")
|
eq(warpCalls[1].y, chosen.value.y, "walk-out lands on the chosen floor's y")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
restoreWarps("SILPH_CO_ELEVATOR", silphSnap)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- ===================================================================
|
-- ===================================================================
|
||||||
@@ -234,11 +281,21 @@ end
|
|||||||
-- the digit only), plus the LIFT_KEY gate.
|
-- the digit only), plus the LIFT_KEY gate.
|
||||||
-- ===================================================================
|
-- ===================================================================
|
||||||
do
|
do
|
||||||
-- without the key: text-only, no floor menu
|
local hideoutSnap = snapshotWarps("ROCKET_HIDEOUT_ELEVATOR")
|
||||||
local gated = openElevator("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, "Rocket Hideout without LIFT_KEY still pushes something")
|
||||||
check(gated ~= nil and getmetatable(gated) ~= ListMenu,
|
check(gated ~= nil and getmetatable(gated) ~= ListMenu,
|
||||||
"Rocket Hideout without LIFT_KEY does not open the floor menu")
|
"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
|
-- 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 })
|
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")
|
eq(warpCalls[1].y, chosen.value.y, "Rocket Hideout walk-out lands on the chosen floor y")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
restoreWarps("ROCKET_HIDEOUT_ELEVATOR", hideoutSnap)
|
||||||
end
|
end
|
||||||
|
|
||||||
Sound.play = origSoundPlay
|
Sound.play = origSoundPlay
|
||||||
|
|||||||
@@ -69,6 +69,12 @@ check(runScript(mapScripts.talkScript("OAKS_LAB", "TEXT_OAKSLAB_OAK1")),
|
|||||||
eq(Game.save.inventory.OAKS_PARCEL, nil, "parcel delivered")
|
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_OAK_GOT_PARCEL"), "delivery flag set")
|
||||||
check(Flags.get(Game.save, "EVENT_GOT_POKEDEX"), "Pokedex 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")
|
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
|
-- talking to Oak again before beating the Route 22 rival should fall
|
||||||
@@ -89,4 +95,16 @@ check(runScript(mapScripts.talkScript("OAKS_LAB", "TEXT_OAKSLAB_OAK1")),
|
|||||||
"Oak talk (post-grant) script completes")
|
"Oak talk (post-grant) script completes")
|
||||||
eq(Game.save.inventory.POKE_BALL, 5, "POKe Ball count unchanged on a second talk")
|
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")
|
||||||
|
|
||||||
S.finish()
|
S.finish()
|
||||||
|
|||||||
@@ -408,6 +408,55 @@ check(strFlash ~= page2 and strFlash ~= ow and strFlash.pages == nil,
|
|||||||
for _ = 1, 10 do frame({}) end
|
for _ = 1, 10 do frame({}) end
|
||||||
eq(Game.stack:top(), ow, "back on the map after the blink")
|
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()
|
||||||
|
|
||||||
|
-- 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
|
-- restore the spied constructor so later dofile'd suites are unaffected
|
||||||
TextBox.new = realTextBoxNew
|
TextBox.new = realTextBoxNew
|
||||||
popAll()
|
popAll()
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
-- Parity test, Bill's House PC + Route25ToggleBillsScript (#120).
|
||||||
|
--
|
||||||
|
-- asm sources:
|
||||||
|
-- engine/events/hidden_events/bills_house_pc.asm (BillsHousePC /
|
||||||
|
-- BillsHousePokemonList: after EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING
|
||||||
|
-- the PC opens EEVEE/FLAREON/JOLTEON/VAPOREON; before that the
|
||||||
|
-- teleporter monitor text or cell-separator cutscene)
|
||||||
|
-- scripts/Route25.asm (Route25ToggleBillsScript: first Route 25 load
|
||||||
|
-- after EVENT_MET_BILL_2 + EVENT_GOT_SS_TICKET sets
|
||||||
|
-- EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING, hides BILL1 / nugget-bridge
|
||||||
|
-- guy, shows BILL2; mid-quest leave resets the separator flag)
|
||||||
|
--
|
||||||
|
-- Self-contained: run via `luajit tests/parity_bills_pc.lua`; also
|
||||||
|
-- dofile'd by tests/run_tests.lua's aggregator.
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
if not _G.love then _G.love = require("tests.love_stub") end
|
||||||
|
local Data = require("src.core.Data")
|
||||||
|
if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end
|
||||||
|
local S = require("tests.harness").suite("parity bills pc")
|
||||||
|
local check, eq = S.check, S.eq
|
||||||
|
|
||||||
|
local OW = require("src.world.OverworldController")
|
||||||
|
local SaveData = require("src.core.SaveData")
|
||||||
|
local story = require("data.scripts.story")
|
||||||
|
|
||||||
|
local function getUpvalue(fn, name)
|
||||||
|
local i = 1
|
||||||
|
while true do
|
||||||
|
local n, v = debug.getupvalue(fn, i)
|
||||||
|
if not n then return nil end
|
||||||
|
if n == name then return v end
|
||||||
|
i = i + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local function setUpvalue(fn, name, val)
|
||||||
|
local i = 1
|
||||||
|
while true do
|
||||||
|
local n = debug.getupvalue(fn, i)
|
||||||
|
if not n then return false end
|
||||||
|
if n == name then debug.setupvalue(fn, i, val); return true end
|
||||||
|
i = i + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local pushed = {}
|
||||||
|
local stackStub = {
|
||||||
|
push = function(_, item)
|
||||||
|
pushed[#pushed + 1] = item
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
local textBoxStub = {
|
||||||
|
new = function(_, text, onDone)
|
||||||
|
local box = { kind = "text", text = text, onDone = onDone }
|
||||||
|
if onDone then onDone() end
|
||||||
|
return box
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
local menuStub = {
|
||||||
|
new = function(_, items, opts)
|
||||||
|
return { kind = "menu", items = items, opts = opts or {} }
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
local screensStub = {
|
||||||
|
push = function(_, id, species)
|
||||||
|
pushed[#pushed + 1] = { kind = "screen", id = id, species = species }
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
|
||||||
|
local fakeGame = {
|
||||||
|
data = Data,
|
||||||
|
save = SaveData.newGame(),
|
||||||
|
stack = stackStub,
|
||||||
|
}
|
||||||
|
check(setUpvalue(OW.billsHousePC, "TextBox", textBoxStub), "TextBox upvalue")
|
||||||
|
check(setUpvalue(OW.billsHousePC, "Game", fakeGame), "Game upvalue for PC")
|
||||||
|
-- Screens is only referenced from billsHousePokemonList
|
||||||
|
check(setUpvalue(OW.billsHousePokemonList, "Screens", screensStub),
|
||||||
|
"Screens upvalue on pokemon list")
|
||||||
|
check(setUpvalue(OW.billsHousePokemonList, "Game", fakeGame),
|
||||||
|
"Game upvalue on pokemon list")
|
||||||
|
check(setUpvalue(OW.billsHousePokemonList, "TextBox", textBoxStub),
|
||||||
|
"TextBox upvalue on pokemon list")
|
||||||
|
|
||||||
|
-- Menu is required inside billsHousePokemonList; stub via package.loaded
|
||||||
|
local realMenu = package.loaded["src.ui.Menu"]
|
||||||
|
package.loaded["src.ui.Menu"] = menuStub
|
||||||
|
|
||||||
|
local fakeSelf = setmetatable({
|
||||||
|
queueScript = function() end,
|
||||||
|
billsHouseBillExits = function() end,
|
||||||
|
}, { __index = OW })
|
||||||
|
|
||||||
|
local function resetFlags()
|
||||||
|
fakeGame.save = SaveData.newGame()
|
||||||
|
setUpvalue(OW.billsHousePC, "Game", fakeGame)
|
||||||
|
pushed = {}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function lastPush()
|
||||||
|
return pushed[#pushed]
|
||||||
|
end
|
||||||
|
|
||||||
|
local function runPC()
|
||||||
|
pushed = {}
|
||||||
|
fakeSelf:billsHousePC()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- === 1) default: teleporter monitor text ===
|
||||||
|
resetFlags()
|
||||||
|
runPC()
|
||||||
|
eq(lastPush() and lastPush().kind, "text", "default PC is a text box")
|
||||||
|
check(tostring(lastPush().text):find("TELEPORTER", 1, true)
|
||||||
|
or tostring(lastPush().text):find("displayed on the", 1, true),
|
||||||
|
"default PC shows monitor text")
|
||||||
|
|
||||||
|
-- === 2) Bill in machine, separator not used yet: initiated text ===
|
||||||
|
resetFlags()
|
||||||
|
fakeGame.save.flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = true
|
||||||
|
local musicStopped = false
|
||||||
|
local realMusic = package.loaded["src.core.Music"]
|
||||||
|
package.loaded["src.core.Music"] = {
|
||||||
|
stop = function() musicStopped = true end,
|
||||||
|
playMap = function() end,
|
||||||
|
}
|
||||||
|
local realSound = package.loaded["src.core.Sound"]
|
||||||
|
package.loaded["src.core.Sound"] = {
|
||||||
|
play = function() end,
|
||||||
|
playCry = function() end,
|
||||||
|
}
|
||||||
|
runPC()
|
||||||
|
check(musicStopped, "cell separator stops map music")
|
||||||
|
check(tostring(lastPush().text):find("Cell", 1, true)
|
||||||
|
or tostring(lastPush().text):find("TELEPORTER", 1, true),
|
||||||
|
"separator path prints initiated text")
|
||||||
|
check(fakeGame.save.flags.EVENT_USED_CELL_SEPARATOR_ON_BILL,
|
||||||
|
"separator path sets EVENT_USED_CELL_SEPARATOR_ON_BILL")
|
||||||
|
|
||||||
|
-- === 3) after separator, before leaving: monitor text again ===
|
||||||
|
resetFlags()
|
||||||
|
fakeGame.save.flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = true
|
||||||
|
fakeGame.save.flags.EVENT_USED_CELL_SEPARATOR_ON_BILL = true
|
||||||
|
fakeGame.save.flags.EVENT_MET_BILL = true
|
||||||
|
runPC()
|
||||||
|
check(tostring(lastPush().text):find("TELEPORTER", 1, true)
|
||||||
|
or tostring(lastPush().text):find("displayed on the", 1, true),
|
||||||
|
"post-separator pre-leave PC shows monitor text")
|
||||||
|
|
||||||
|
-- === 4) after leaving: Eevee collection menu ===
|
||||||
|
resetFlags()
|
||||||
|
fakeGame.save.flags.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING = true
|
||||||
|
runPC()
|
||||||
|
local menu
|
||||||
|
for _, p in ipairs(pushed) do
|
||||||
|
if p.kind == "menu" then menu = p break end
|
||||||
|
end
|
||||||
|
check(menu ~= nil, "post-leave PC opens a menu")
|
||||||
|
local labels = {}
|
||||||
|
for _, item in ipairs(menu.items) do labels[#labels + 1] = item.label end
|
||||||
|
eq(table.concat(labels, ","), "EEVEE,FLAREON,JOLTEON,VAPOREON,CANCEL",
|
||||||
|
"Eevee collection lists the four mons + CANCEL")
|
||||||
|
|
||||||
|
-- selecting EEVEE marks seen and opens DexEntryMenu without closing list
|
||||||
|
fakeGame.save.pokedex = { seen = {}, owned = {} }
|
||||||
|
menu.items[1].onSelect()
|
||||||
|
check(fakeGame.save.pokedex.seen.EEVEE, "viewing marks EEVEE seen")
|
||||||
|
local dexPush
|
||||||
|
for i = #pushed, 1, -1 do
|
||||||
|
if pushed[i].kind == "screen" then dexPush = pushed[i] break end
|
||||||
|
end
|
||||||
|
eq(dexPush and dexPush.id, "DexEntryMenu", "selection opens DexEntryMenu")
|
||||||
|
eq(dexPush and dexPush.species, "EEVEE", "DexEntryMenu gets EEVEE")
|
||||||
|
check(menu.items[1].keepOpen, "dex pick keeps the list open")
|
||||||
|
|
||||||
|
-- === 5) Route25ToggleBillsScript ===
|
||||||
|
local toggles = {}
|
||||||
|
local Commands = require("src.script.Commands")
|
||||||
|
local realHide, realShow = Commands.hide_object, Commands.show_object
|
||||||
|
Commands.hide_object = function(_, mapId, name)
|
||||||
|
toggles[mapId .. ":" .. name] = false
|
||||||
|
end
|
||||||
|
Commands.show_object = function(_, mapId, name)
|
||||||
|
toggles[mapId .. ":" .. name] = true
|
||||||
|
end
|
||||||
|
|
||||||
|
local function runRoute25(flags)
|
||||||
|
toggles = {}
|
||||||
|
local save = SaveData.newGame()
|
||||||
|
for k, v in pairs(flags) do save.flags[k] = v end
|
||||||
|
story.ROUTE_25.onEnter({ save = save }, {})
|
||||||
|
return save.flags, toggles
|
||||||
|
end
|
||||||
|
|
||||||
|
local f, t = runRoute25({})
|
||||||
|
check(not f.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING,
|
||||||
|
"no leave flag before meeting Bill")
|
||||||
|
check(t["BILLS_HOUSE:BILLSHOUSE_BILL_POKEMON"] == true,
|
||||||
|
"mid-quest leave restores monster Bill")
|
||||||
|
check(f.EVENT_BILL_SAID_USE_CELL_SEPARATOR == nil,
|
||||||
|
"mid-quest leave clears separator arming flag")
|
||||||
|
|
||||||
|
f, t = runRoute25({ EVENT_MET_BILL_2 = true })
|
||||||
|
check(not f.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING,
|
||||||
|
"MET_BILL_2 without ticket does not arm leave flag")
|
||||||
|
|
||||||
|
f, t = runRoute25({
|
||||||
|
EVENT_MET_BILL_2 = true,
|
||||||
|
EVENT_GOT_SS_TICKET = true,
|
||||||
|
})
|
||||||
|
check(f.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING,
|
||||||
|
"ticket + MET_BILL_2 arms EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING")
|
||||||
|
eq(t["BILLS_HOUSE:BILLSHOUSE_BILL1"], false, "hides SS-Ticket Bill")
|
||||||
|
eq(t["BILLS_HOUSE:BILLSHOUSE_BILL2"], true, "shows rare-POKéMON Bill")
|
||||||
|
eq(t["ROUTE_24:ROUTE24_COOLTRAINER_M1"], false, "hides nugget-bridge guy")
|
||||||
|
|
||||||
|
f, t = runRoute25({
|
||||||
|
EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING = true,
|
||||||
|
EVENT_MET_BILL_2 = true,
|
||||||
|
EVENT_GOT_SS_TICKET = true,
|
||||||
|
})
|
||||||
|
eq(next(t), nil, "already-left Route 25 enter is a no-op")
|
||||||
|
|
||||||
|
Commands.hide_object = realHide
|
||||||
|
Commands.show_object = realShow
|
||||||
|
package.loaded["src.ui.Menu"] = realMenu
|
||||||
|
if realMusic ~= nil then package.loaded["src.core.Music"] = realMusic end
|
||||||
|
if realSound ~= nil then package.loaded["src.core.Sound"] = realSound end
|
||||||
|
|
||||||
|
S.finish()
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
-- Regression (#125): SURF from Cinnabar's east coast into Route 20 water.
|
||||||
|
--
|
||||||
|
-- Cinnabar's easternmost cells are walkable land (tile $39); the water is
|
||||||
|
-- on ROUTE_20 across the east connection. pokered loads that strip into
|
||||||
|
-- the border, so IsNextTileShoreOrWater sees shore tile $32. The port
|
||||||
|
-- must read the connection landing the same way -- an inBounds-only
|
||||||
|
-- water check returns "no_water" and blocks the mount (and the reverse
|
||||||
|
-- party-menu dismount back onto the coast).
|
||||||
|
--
|
||||||
|
-- Self-contained; run via `luajit tests/parity_cinnabar_east_surf.lua`.
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
if not _G.love then _G.love = require("tests.love_stub") end
|
||||||
|
|
||||||
|
local Data = require("src.core.Data")
|
||||||
|
if not (Data.maps and Data.maps.CINNABAR_ISLAND) then Data:load() end
|
||||||
|
|
||||||
|
local Game = require("src.core.Game")
|
||||||
|
local Input = require("src.core.Input")
|
||||||
|
local Map = require("src.world.Map")
|
||||||
|
local MapLoader = require("src.world.MapLoader")
|
||||||
|
local Renderer = require("src.render.Renderer")
|
||||||
|
local SaveData = require("src.core.SaveData")
|
||||||
|
local StateStack = require("src.core.StateStack")
|
||||||
|
local OW = require("src.world.OverworldController")
|
||||||
|
local S = require("tests.harness").suite("parity cinnabar east surf")
|
||||||
|
local check, eq = S.check, S.eq
|
||||||
|
|
||||||
|
local function mkMon(species, ...)
|
||||||
|
local moves = {}
|
||||||
|
for _, id in ipairs({ ... }) do
|
||||||
|
table.insert(moves, { id = id, pp = 10, ppUp = 0 })
|
||||||
|
end
|
||||||
|
return {
|
||||||
|
species = species, level = 30, hp = 50, maxHp = 50,
|
||||||
|
status = 0, moves = moves, nickname = nil,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local cin = MapLoader.load(Data, "CINNABAR_ISLAND")
|
||||||
|
local r20 = MapLoader.load(Data, "ROUTE_20")
|
||||||
|
local r20ts = Data.tilesets[r20.def.tileset]
|
||||||
|
|
||||||
|
-- ground truth: east edge is land, Route 20 west landing is shore/water
|
||||||
|
check(cin:isWalkableCell(19, 8), "Cinnabar (19,8) is walkable coast")
|
||||||
|
check(not cin:isWaterCell(19, 8), "Cinnabar (19,8) is not water")
|
||||||
|
check(not cin:inBounds(20, 8), "facing east from (19,8) is off-map")
|
||||||
|
check(r20:isWaterCell(0, 8), "ROUTE_20 (0,8) is water/shore")
|
||||||
|
check(Map.defIsWaterCell(r20.def, r20ts, 0, 8),
|
||||||
|
"defIsWaterCell agrees on ROUTE_20 (0,8)")
|
||||||
|
check(Map.defIsWalkableCell(cin.def, Data.tilesets[cin.def.tileset], 19, 8),
|
||||||
|
"defIsWalkableCell agrees on Cinnabar coast")
|
||||||
|
|
||||||
|
Game.data = Data
|
||||||
|
Game.input = Input; Input:init()
|
||||||
|
Game.renderer = Renderer; Renderer:init()
|
||||||
|
Game.stack = StateStack
|
||||||
|
StateStack:init()
|
||||||
|
Game.save = SaveData.newGame()
|
||||||
|
Game.save.party = { mkMon("SQUIRTLE", "SURF") }
|
||||||
|
Game.save.inventory = { SOULBADGE = true }
|
||||||
|
Game.overworld = OW
|
||||||
|
|
||||||
|
while Game.stack:top() do Game.stack:pop() end
|
||||||
|
Game.stack:push(OW, "CINNABAR_ISLAND", 19, 8, "right")
|
||||||
|
local ow = Game.stack:top()
|
||||||
|
eq(ow.map.id, "CINNABAR_ISLAND", "start on Cinnabar east coast")
|
||||||
|
eq(ow.player.cellX, 19, "coast x")
|
||||||
|
eq(ow.player.cellY, 8, "coast y")
|
||||||
|
eq(ow.player.facing, "right", "facing east toward Route 20")
|
||||||
|
|
||||||
|
check(ow:facingIsShoreOrWater(),
|
||||||
|
"facingIsShoreOrWater reads Route 20 shore across the seam")
|
||||||
|
eq(ow:useSurfFieldMove(), "ok",
|
||||||
|
"useSurfFieldMove ok facing connection water (#125)")
|
||||||
|
|
||||||
|
-- reverse: surfing on Route 20 west edge, facing Cinnabar land
|
||||||
|
while Game.stack:top() do Game.stack:pop() end
|
||||||
|
Game.stack:push(OW, "ROUTE_20", 0, 8, "left")
|
||||||
|
ow = Game.stack:top()
|
||||||
|
ow.player.surfing = true
|
||||||
|
eq(ow.map.id, "ROUTE_20", "on Route 20 west water")
|
||||||
|
check(ow.map:isWaterCell(0, 8), "standing cell is water")
|
||||||
|
check(ow:facingIsLandDismount(),
|
||||||
|
"facingIsLandDismount sees Cinnabar coast across the seam")
|
||||||
|
eq(ow:useSurfFieldMove(), "dismount",
|
||||||
|
"party-menu SURF dismounts onto Cinnabar east coast")
|
||||||
|
|
||||||
|
-- facing open water while surfing still refuses
|
||||||
|
ow.player.facing = "right"
|
||||||
|
eq(ow:useSurfFieldMove(), "no_place",
|
||||||
|
"surfing + facing open water: no place (unchanged)")
|
||||||
|
|
||||||
|
-- mount step crosses the seam onto Route 20 (surfing already armed)
|
||||||
|
while Game.stack:top() do Game.stack:pop() end
|
||||||
|
Game.stack:push(OW, "CINNABAR_ISLAND", 19, 8, "right")
|
||||||
|
ow = Game.stack:top()
|
||||||
|
ow.player.surfing = true
|
||||||
|
check(ow:stepForwardOrCrossEdge("right"),
|
||||||
|
"stepForwardOrCrossEdge crosses onto Route 20 while surfing")
|
||||||
|
eq(ow.map.id, "ROUTE_20", "map swapped to Route 20 after edge surf step")
|
||||||
|
-- crossConnection parks one cell before the seam and walks in (same as
|
||||||
|
-- a live edge press); the landing target is Route 20 (0,8)
|
||||||
|
eq(ow.player.targetX, 0, "step target is Route 20 west column")
|
||||||
|
eq(ow.player.targetY, 8, "same Y across offset-0 east connection")
|
||||||
|
check(ow.player.moving, "seam step is in progress")
|
||||||
|
|
||||||
|
-- in-map Pallet south shore still works (no false connection match)
|
||||||
|
while Game.stack:top() do Game.stack:pop() end
|
||||||
|
Game.stack:push(OW, "PALLET_TOWN", 4, 13, "down")
|
||||||
|
ow = Game.stack:top()
|
||||||
|
ow.player.surfing = false
|
||||||
|
eq(ow:useSurfFieldMove(), "ok", "in-map water mount still ok")
|
||||||
|
ow.player.facing = "up"
|
||||||
|
eq(ow:useSurfFieldMove(), "no_water", "in-map land still no_water")
|
||||||
|
|
||||||
|
S.finish()
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
-- Parity test for Route 5 Day Care (issue #118).
|
||||||
|
-- Self-contained: `luajit tests/parity_daycare.lua`; also dofile'd by
|
||||||
|
-- tests/run_tests.lua's parity_* aggregator.
|
||||||
|
--
|
||||||
|
-- Covers scripts/Daycare.asm retrieve flow ported in
|
||||||
|
-- data/scripts/story2.lua M.DAYCARE:
|
||||||
|
-- * name substitution in grown / got-back text
|
||||||
|
-- * fee = ¥100 + ¥100 per level gained
|
||||||
|
-- * declining retrieve must not collapse the fee to ¥100 on re-talk
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
if not _G.love then _G.love = require("tests.love_stub") end
|
||||||
|
local Data = require("src.core.Data")
|
||||||
|
if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end
|
||||||
|
local S = require("tests.harness").suite("parity daycare")
|
||||||
|
local check, eq = S.check, S.eq
|
||||||
|
|
||||||
|
local realTextBox = package.loaded["src.render.TextBox"]
|
||||||
|
local shownTexts = {}
|
||||||
|
local choiceAnswer = true
|
||||||
|
package.loaded["src.render.TextBox"] = {
|
||||||
|
new = function(game, text, onDone, opts)
|
||||||
|
table.insert(shownTexts, text)
|
||||||
|
if opts and opts.choice then
|
||||||
|
opts.choice(choiceAnswer)
|
||||||
|
elseif onDone then
|
||||||
|
onDone()
|
||||||
|
end
|
||||||
|
return { text = text }
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
|
||||||
|
local SaveData = require("src.core.SaveData")
|
||||||
|
local Pokemon = require("src.pokemon.Pokemon")
|
||||||
|
local Growth = require("src.pokemon.Growth")
|
||||||
|
local story2 = require("data.scripts.story2")
|
||||||
|
|
||||||
|
check(story2.DAYCARE ~= nil, "DAYCARE registered")
|
||||||
|
check(story2.DAYCARE.talk.TEXT_DAYCARE_GENTLEMAN ~= nil,
|
||||||
|
"DAYCARE gentleman talk handler registered")
|
||||||
|
|
||||||
|
local talkGentleman = story2.DAYCARE.talk.TEXT_DAYCARE_GENTLEMAN
|
||||||
|
|
||||||
|
local function newGame()
|
||||||
|
local save = SaveData.newGame()
|
||||||
|
save.money = 10000
|
||||||
|
local game = { data = Data, save = save, stack = { push = function() end } }
|
||||||
|
return game
|
||||||
|
end
|
||||||
|
|
||||||
|
local function talk(game)
|
||||||
|
shownTexts = {}
|
||||||
|
local doneCalled = false
|
||||||
|
talkGentleman(game, {}, nil, function() doneCalled = true end)
|
||||||
|
return doneCalled
|
||||||
|
end
|
||||||
|
|
||||||
|
local function boardAtLevel(game, species, level, nickname)
|
||||||
|
local mon = Pokemon.new(Data, species, level)
|
||||||
|
if nickname then mon.nickname = nickname end
|
||||||
|
game.save.daycare = { mon = mon, steps = 0 }
|
||||||
|
return mon
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Exp needed to gain `gained` levels from `level` (exclusive of current).
|
||||||
|
local function stepsForLevels(species, level, gained)
|
||||||
|
local def = Data.pokemon[species]
|
||||||
|
local target = Growth.expForLevel(def.growthRate, level + gained)
|
||||||
|
local current = Growth.expForLevel(def.growthRate, level)
|
||||||
|
return target - current
|
||||||
|
end
|
||||||
|
|
||||||
|
-- === 1) grown text names the mon; fee matches levels grown ===
|
||||||
|
do
|
||||||
|
local game = newGame()
|
||||||
|
local mon = boardAtLevel(game, "RATTATA", 5, "SCRAPPY")
|
||||||
|
local gained = 3
|
||||||
|
game.save.daycare.steps = stepsForLevels("RATTATA", 5, gained)
|
||||||
|
choiceAnswer = false -- decline retrieve
|
||||||
|
check(talk(game), "grown+decline talk completes")
|
||||||
|
local grown
|
||||||
|
for _, s in ipairs(shownTexts) do
|
||||||
|
if s:find("grown a lot", 1, true) then grown = s break end
|
||||||
|
end
|
||||||
|
check(grown ~= nil, "shows MonHasGrownText")
|
||||||
|
check(grown:find("SCRAPPY", 1, true), "grown text substitutes wNameBuffer")
|
||||||
|
check(grown:find(tostring(gained), 1, true), "grown text shows levels grown")
|
||||||
|
check(not grown:find("{RAM:", 1, true), "grown text has no leftover RAM tokens")
|
||||||
|
local owe
|
||||||
|
for _, s in ipairs(shownTexts) do
|
||||||
|
if s:find("owe me", 1, true) then owe = s break end
|
||||||
|
end
|
||||||
|
check(owe ~= nil, "shows OweMoneyText")
|
||||||
|
eq(owe:match("¥(%d+)"), tostring(100 + gained * 100),
|
||||||
|
"fee is ¥100 + ¥100 per level gained")
|
||||||
|
eq(game.save.daycare.mon.level, 5,
|
||||||
|
"decline leaves deposit level untouched (BoxLevel baseline)")
|
||||||
|
eq(game.save.daycare.steps, 0, "pending steps folded into exp on talk")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- === 2) re-talk after decline keeps the correct fee (issue #118) ===
|
||||||
|
do
|
||||||
|
local game = newGame()
|
||||||
|
boardAtLevel(game, "RATTATA", 5, "SCRAPPY")
|
||||||
|
local gained = 3
|
||||||
|
game.save.daycare.steps = stepsForLevels("RATTATA", 5, gained)
|
||||||
|
choiceAnswer = false
|
||||||
|
talk(game)
|
||||||
|
choiceAnswer = false
|
||||||
|
check(talk(game), "second decline talk completes")
|
||||||
|
local owe
|
||||||
|
for _, s in ipairs(shownTexts) do
|
||||||
|
if s:find("owe me", 1, true) then owe = s break end
|
||||||
|
end
|
||||||
|
check(owe ~= nil, "re-talk still shows OweMoneyText")
|
||||||
|
eq(owe:match("¥(%d+)"), tostring(100 + gained * 100),
|
||||||
|
"re-talk after decline keeps fee (not ¥100)")
|
||||||
|
eq(game.save.daycare.mon.level, 5, "re-talk still leaves deposit level")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- === 3) paid retrieve raises level, names the mon, clears daycare ===
|
||||||
|
do
|
||||||
|
local game = newGame()
|
||||||
|
boardAtLevel(game, "RATTATA", 5, "SCRAPPY")
|
||||||
|
local gained = 2
|
||||||
|
game.save.daycare.steps = stepsForLevels("RATTATA", 5, gained)
|
||||||
|
local fee = 100 + gained * 100
|
||||||
|
local moneyBefore = game.save.money
|
||||||
|
choiceAnswer = true
|
||||||
|
check(talk(game), "retrieve talk completes")
|
||||||
|
eq(game.save.daycare, nil, "daycare cleared after paid retrieve")
|
||||||
|
eq(#game.save.party, 1, "mon returned to party")
|
||||||
|
eq(game.save.party[1].nickname, "SCRAPPY", "retrieved nickname preserved")
|
||||||
|
eq(game.save.party[1].level, 5 + gained, "level applied only on retrieve")
|
||||||
|
eq(game.save.money, moneyBefore - fee, "money deducted by correct fee")
|
||||||
|
local got
|
||||||
|
for _, s in ipairs(shownTexts) do
|
||||||
|
if s:find("got", 1, true) and s:find("back", 1, true) then got = s break end
|
||||||
|
end
|
||||||
|
check(got ~= nil, "shows GotMonBackText")
|
||||||
|
check(got:find("SCRAPPY", 1, true), "got-back text substitutes wDayCareMonName")
|
||||||
|
check(not got:find("{RAM:", 1, true), "got-back text has no leftover RAM tokens")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- === 4) no levels gained: NeedsMoreTime still names the mon; fee ¥100 ===
|
||||||
|
do
|
||||||
|
local game = newGame()
|
||||||
|
boardAtLevel(game, "RATTATA", 10, "PIP")
|
||||||
|
game.save.daycare.steps = 0
|
||||||
|
choiceAnswer = false
|
||||||
|
check(talk(game), "no-growth talk completes")
|
||||||
|
local needs
|
||||||
|
for _, s in ipairs(shownTexts) do
|
||||||
|
if s:find("Back already", 1, true) then needs = s break end
|
||||||
|
end
|
||||||
|
check(needs ~= nil, "shows MonNeedsMoreTimeText")
|
||||||
|
check(needs and needs:find("PIP", 1, true), "needs-more-time names the mon")
|
||||||
|
local owe
|
||||||
|
for _, s in ipairs(shownTexts) do
|
||||||
|
if s:find("owe me", 1, true) then owe = s break end
|
||||||
|
end
|
||||||
|
eq(owe and owe:match("¥(%d+)"), "100", "no growth still costs base ¥100")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- === 5) depositLevel survives a corrupted mon.level from older buggy talks ===
|
||||||
|
do
|
||||||
|
local game = newGame()
|
||||||
|
local mon = boardAtLevel(game, "RATTATA", 5, "SCRAPPY")
|
||||||
|
local gained = 4
|
||||||
|
game.save.daycare.steps = stepsForLevels("RATTATA", 5, gained)
|
||||||
|
game.save.daycare.depositLevel = 5
|
||||||
|
mon.level = 5 + gained -- simulate pre-#118 raise-on-talk corruption
|
||||||
|
choiceAnswer = false
|
||||||
|
check(talk(game), "depositLevel baseline talk completes")
|
||||||
|
local owe
|
||||||
|
for _, s in ipairs(shownTexts) do
|
||||||
|
if s:find("owe me", 1, true) then owe = s break end
|
||||||
|
end
|
||||||
|
eq(owe and owe:match("¥(%d+)"), tostring(100 + gained * 100),
|
||||||
|
"depositLevel keeps fee correct even if mon.level was raised early")
|
||||||
|
eq(game.save.daycare.mon.level, 5, "decline restores mon.level to depositLevel")
|
||||||
|
end
|
||||||
|
|
||||||
|
package.loaded["src.render.TextBox"] = realTextBox
|
||||||
|
S.finish()
|
||||||
+37
-3
@@ -88,8 +88,12 @@ local SaveData = require("src.core.SaveData")
|
|||||||
local stack2 = newStack()
|
local stack2 = newStack()
|
||||||
local game2 = { data = Data, input = fakeInput, stack = stack2,
|
local game2 = { data = Data, input = fakeInput, stack = stack2,
|
||||||
save = SaveData.newGame() }
|
save = SaveData.newGame() }
|
||||||
game2.save.party = { { species = "PIKACHU", level = 81 } }
|
-- battered party: record_hall_of_fame must heal before the autosave (#103)
|
||||||
|
game2.save.party = { { species = "PIKACHU", level = 81, hp = 1,
|
||||||
|
stats = { hp = 100 }, status = "PAR",
|
||||||
|
moves = { { id = "THUNDERBOLT", pp = 0 } } } }
|
||||||
game2.save.player.map = "HALL_OF_FAME"
|
game2.save.player.map = "HALL_OF_FAME"
|
||||||
|
game2.save.lastOutdoor = { id = "INDIGO_PLATEAU", x = 9, y = 5 }
|
||||||
local wrote = false
|
local wrote = false
|
||||||
function game2:writeSave() wrote = true; SaveData.save(self.save) end
|
function game2:writeSave() wrote = true; SaveData.save(self.save) end
|
||||||
|
|
||||||
@@ -129,9 +133,39 @@ local saved = savedRaw and SaveData.decode(savedRaw) or nil
|
|||||||
check(saved ~= nil, "save.lua written and decodable")
|
check(saved ~= nil, "save.lua written and decodable")
|
||||||
eq(saved and saved.lastHeal and saved.lastHeal.map, "PALLET_TOWN",
|
eq(saved and saved.lastHeal and saved.lastHeal.map, "PALLET_TOWN",
|
||||||
"wLastBlackoutMap := PALLET_TOWN before the save")
|
"wLastBlackoutMap := PALLET_TOWN before the save")
|
||||||
eq(saved and saved.player and saved.player.map, "HALL_OF_FAME",
|
eq(saved and saved.player and saved.player.map, "REDS_HOUSE_2F",
|
||||||
"save keeps the player in the HALL_OF_FAME room")
|
"CONTINUE lands in the bedroom (post-game home, #103)")
|
||||||
|
eq(saved and saved.player and saved.player.x, 3, "bedroom spawn x")
|
||||||
|
eq(saved and saved.player and saved.player.y, 6, "bedroom spawn y")
|
||||||
|
eq(saved and saved.lastOutdoor and saved.lastOutdoor.id, "PALLET_TOWN",
|
||||||
|
"LAST_MAP exits aim at Pallet Town, not Indigo")
|
||||||
|
check(saved and saved.postGameHomeOk, "postGameHomeOk marks the relocate done")
|
||||||
eq(saved and #(saved.hallOfFame or {}), 1, "hall of fame team persisted")
|
eq(saved and #(saved.hallOfFame or {}), 1, "hall of fame team persisted")
|
||||||
|
local healed = saved and saved.party and saved.party[1]
|
||||||
|
local thunderPp = Data.moves and Data.moves.THUNDERBOLT and Data.moves.THUNDERBOLT.pp
|
||||||
|
check(healed and healed.hp == healed.stats.hp and healed.status == nil
|
||||||
|
and healed.moves[1].pp == thunderPp,
|
||||||
|
"party is fully healed in the post-credits save")
|
||||||
|
|
||||||
|
-- house exit mats resolve to Pallet's door, not Indigo
|
||||||
|
local Warp = require("src.world.Warp")
|
||||||
|
local houseExit = { destMap = "LAST_MAP", destWarp = 1, x = 2, y = 7 }
|
||||||
|
local destMap, dx, dy = Warp.destination(Data, houseExit, saved.lastOutdoor)
|
||||||
|
eq(destMap, "PALLET_TOWN", "Red's house LAST_MAP -> PALLET_TOWN")
|
||||||
|
eq(dx, 5, "Pallet door x")
|
||||||
|
eq(dy, 5, "Pallet door y")
|
||||||
|
|
||||||
|
-- stuck-save rescue: HoF + hallOfFame + no postGameHomeOk -> bedroom
|
||||||
|
local stuck = SaveData.newGame()
|
||||||
|
stuck.player.map = "HALL_OF_FAME"
|
||||||
|
stuck.player.x, stuck.player.y = 4, 2
|
||||||
|
stuck.lastOutdoor = { id = "INDIGO_PLATEAU", x = 9, y = 5 }
|
||||||
|
stuck.hallOfFame = { { { species = "PIKACHU", level = 81 } } }
|
||||||
|
check(SaveData.needsPostGameRescue(stuck), "pre-fix softlock save needs rescue")
|
||||||
|
SaveData.applyPostGameHome(stuck, Data.field.boot)
|
||||||
|
check(not SaveData.needsPostGameRescue(stuck), "rescue is one-shot")
|
||||||
|
eq(stuck.player.map, "REDS_HOUSE_2F", "rescue warps home")
|
||||||
|
eq(stuck.lastOutdoor.id, "PALLET_TOWN", "rescue retargets LAST_MAP")
|
||||||
|
|
||||||
-- `jp Init`: everything popped, boot sequence pushed (intro -> title)
|
-- `jp Init`: everything popped, boot sequence pushed (intro -> title)
|
||||||
eq(#stack2.states, 1, "soft reset leaves exactly the boot state")
|
eq(#stack2.states, 1, "soft reset leaves exactly the boot state")
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
-- Parity: Rocket Hideout B4F Lift Key drop (#90 / #105).
|
||||||
|
--
|
||||||
|
-- Oracle: scripts/RocketHideoutB4F.asm RocketHideoutB4FRocket3AfterBattleText
|
||||||
|
-- (PrintText + CheckAndSetEvent EVENT_ROCKET_DROPPED_LIFT_KEY / ShowObject
|
||||||
|
-- TOGGLE_ROCKET_HIDEOUT_B4F_ITEM_5). The LIFT_KEY object_event starts
|
||||||
|
-- hidden; talking to Rocket3 after his defeat reveals it.
|
||||||
|
--
|
||||||
|
-- Self-contained: `luajit tests/parity_lift_key.lua`; also dofile'd by
|
||||||
|
-- tests/run_tests.lua.
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
if not _G.love then _G.love = require("tests.love_stub") end
|
||||||
|
local Data = require("src.core.Data")
|
||||||
|
if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end
|
||||||
|
|
||||||
|
local S = require("tests.harness").suite("parity lift key")
|
||||||
|
local check, eq = S.check, S.eq
|
||||||
|
|
||||||
|
local mapScripts = require("data.scripts.init")
|
||||||
|
local OverworldState = require("src.world.OverworldController")
|
||||||
|
|
||||||
|
local liftKeyObj
|
||||||
|
for _, o in ipairs(Data.maps.ROCKET_HIDEOUT_B4F.objects) do
|
||||||
|
if o.name == "ROCKETHIDEOUTB4F_LIFT_KEY" then liftKeyObj = o break end
|
||||||
|
end
|
||||||
|
check(liftKeyObj and liftKeyObj.hidden == true,
|
||||||
|
"LIFT_KEY object_event is extracted as hidden")
|
||||||
|
|
||||||
|
local script = mapScripts.talkScript("ROCKET_HIDEOUT_B4F",
|
||||||
|
"TEXT_ROCKETHIDEOUTB4F_ROCKET3")
|
||||||
|
check(type(script) == "function",
|
||||||
|
"Rocket3 after-battle drop is a hand-ported talk handler")
|
||||||
|
|
||||||
|
-- Capture TextBox payloads without driving the typewriter / font path.
|
||||||
|
local realTB = package.loaded["src.render.TextBox"]
|
||||||
|
package.loaded["src.render.TextBox"] = {
|
||||||
|
new = function(game, text, done) return { text = text, onDone = done } end,
|
||||||
|
}
|
||||||
|
|
||||||
|
-- Drive the defeated-talk path headless: show after text, then ShowObject.
|
||||||
|
do
|
||||||
|
local pushed, engaged
|
||||||
|
local save = {
|
||||||
|
flags = {},
|
||||||
|
inventory = {},
|
||||||
|
defeatedTrainers = { ROCKET_HIDEOUT_B4F_obj_4 = true },
|
||||||
|
objectToggles = {},
|
||||||
|
}
|
||||||
|
local game = {
|
||||||
|
data = Data,
|
||||||
|
save = save,
|
||||||
|
stack = {
|
||||||
|
push = function(_, box) pushed = box end,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
local ow = {
|
||||||
|
map = { id = "ROCKET_HIDEOUT_B4F", def = Data.maps.ROCKET_HIDEOUT_B4F },
|
||||||
|
npcs = {},
|
||||||
|
entities = {},
|
||||||
|
trainerDefeated = function(_, npc)
|
||||||
|
return save.defeatedTrainers[npc.id] == true
|
||||||
|
end,
|
||||||
|
engageTrainer = function() engaged = true end,
|
||||||
|
}
|
||||||
|
local npc = {
|
||||||
|
id = "ROCKET_HIDEOUT_B4F_obj_4",
|
||||||
|
def = { name = "ROCKETHIDEOUTB4F_ROCKET3", index = 4,
|
||||||
|
text = "TEXT_ROCKETHIDEOUTB4F_ROCKET3",
|
||||||
|
trainerClass = "OPP_ROCKET", trainerParty = 18 },
|
||||||
|
}
|
||||||
|
local doneCalled = false
|
||||||
|
script(game, ow, npc, function() doneCalled = true end)
|
||||||
|
|
||||||
|
check(not engaged, "defeated Rocket3 talk does not re-engage battle")
|
||||||
|
check(pushed ~= nil, "defeated Rocket3 talk pushes the after-battle text")
|
||||||
|
eq(pushed and pushed.text, Data.text._RocketHideoutB4FRocket3AfterBattleText,
|
||||||
|
"after-battle text is the dropped-key line")
|
||||||
|
check(not save.flags.EVENT_ROCKET_DROPPED_LIFT_KEY,
|
||||||
|
"drop flag unset until the text box closes")
|
||||||
|
check(not OverworldState.objectVisible(save, "ROCKET_HIDEOUT_B4F", liftKeyObj),
|
||||||
|
"LIFT_KEY still hidden before the after-battle text finishes")
|
||||||
|
|
||||||
|
-- dismiss the box: CheckAndSetEvent + ShowObject
|
||||||
|
pushed.onDone()
|
||||||
|
check(doneCalled, "talk done() runs after ShowObject")
|
||||||
|
check(save.flags.EVENT_ROCKET_DROPPED_LIFT_KEY == true,
|
||||||
|
"EVENT_ROCKET_DROPPED_LIFT_KEY set on first after-battle talk")
|
||||||
|
check(save.objectToggles.ROCKET_HIDEOUT_B4F
|
||||||
|
and save.objectToggles.ROCKET_HIDEOUT_B4F.ROCKETHIDEOUTB4F_LIFT_KEY == true,
|
||||||
|
"ShowObject toggles ROCKETHIDEOUTB4F_LIFT_KEY visible")
|
||||||
|
check(OverworldState.objectVisible(save, "ROCKET_HIDEOUT_B4F", liftKeyObj),
|
||||||
|
"LIFT_KEY objectVisible after ShowObject")
|
||||||
|
|
||||||
|
-- second talk: reprint only, no double-toggle churn
|
||||||
|
pushed, doneCalled = nil, false
|
||||||
|
local togglesBefore = save.objectToggles.ROCKET_HIDEOUT_B4F.ROCKETHIDEOUTB4F_LIFT_KEY
|
||||||
|
script(game, ow, npc, function() doneCalled = true end)
|
||||||
|
check(pushed ~= nil, "second after-battle talk still shows text")
|
||||||
|
pushed.onDone()
|
||||||
|
check(doneCalled, "second talk done() runs")
|
||||||
|
eq(save.objectToggles.ROCKET_HIDEOUT_B4F.ROCKETHIDEOUTB4F_LIFT_KEY, togglesBefore,
|
||||||
|
"second talk does not re-ShowObject")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Undefeated path still engages the trainer (TalkToTrainer).
|
||||||
|
do
|
||||||
|
local engaged = false
|
||||||
|
local save = { flags = {}, inventory = {}, defeatedTrainers = {} }
|
||||||
|
local game = {
|
||||||
|
data = Data, save = save,
|
||||||
|
stack = { push = function() end },
|
||||||
|
}
|
||||||
|
local ow = {
|
||||||
|
map = { id = "ROCKET_HIDEOUT_B4F" },
|
||||||
|
trainerDefeated = function() return false end,
|
||||||
|
engageTrainer = function(_, npc, done)
|
||||||
|
engaged = true
|
||||||
|
if done then done() end
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
local npc = {
|
||||||
|
id = "ROCKET_HIDEOUT_B4F_obj_4",
|
||||||
|
def = { name = "ROCKETHIDEOUTB4F_ROCKET3", index = 4 },
|
||||||
|
}
|
||||||
|
script(game, ow, npc, function() end)
|
||||||
|
check(engaged, "undefeated Rocket3 talk engages the trainer battle")
|
||||||
|
end
|
||||||
|
|
||||||
|
package.loaded["src.render.TextBox"] = realTB
|
||||||
|
S.finish()
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
-- Parity test: Mom heal rest (#113) and Silph Co. 9F nurse fade.
|
||||||
|
--
|
||||||
|
-- pokered RedsHouse1FMomText / RedsHouse1FMomHealScript:
|
||||||
|
-- pre-starter -> WakeUpText; else YouShouldRest -> GBFadeOutToWhite ->
|
||||||
|
-- HealParty -> MUSIC_PKMN_HEALED (wait) -> GBFadeInFromWhite ->
|
||||||
|
-- LookingGreatText.
|
||||||
|
-- SilphCo9FNurseText: heal then white fade / Delay3 / fade in (no jingle).
|
||||||
|
--
|
||||||
|
-- Self-contained; run via `luajit tests/parity_mom_heal.lua`.
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
if not _G.love then _G.love = require("tests.love_stub") end
|
||||||
|
local S = require("tests.harness").suite("parity mom heal")
|
||||||
|
local check, eq = S.check, S.eq
|
||||||
|
|
||||||
|
local reds = dofile("data/scripts/reds_house.lua")
|
||||||
|
local silph = dofile("data/scripts/flavor/silph_co_9f.lua")
|
||||||
|
|
||||||
|
local function cmds(rows)
|
||||||
|
local out = {}
|
||||||
|
for _, row in ipairs(rows) do out[#out + 1] = row[1] end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
local function find(rows, verb, arg)
|
||||||
|
for i, row in ipairs(rows) do
|
||||||
|
if row[1] == verb and (arg == nil or row[2] == arg) then
|
||||||
|
return i, row
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- --- Mom: pre-starter branch + heal rest sequence
|
||||||
|
do
|
||||||
|
local rows = reds.talk.TEXT_REDSHOUSE1F_MOM
|
||||||
|
check(rows ~= nil, "Mom talk script exists")
|
||||||
|
|
||||||
|
local iFlag = find(rows, "check_flag", "EVENT_GOT_STARTER")
|
||||||
|
check(iFlag, "Mom checks EVENT_GOT_STARTER")
|
||||||
|
local iWake = find(rows, "show_text", "_RedsHouse1FMomWakeUpText")
|
||||||
|
check(iWake and iWake > iFlag, "WakeUpText follows the starter check")
|
||||||
|
local iRest = find(rows, "show_text", "_RedsHouse1FMomYouShouldRestText")
|
||||||
|
check(iRest and iRest > iWake, "heal path is after the wake-up branch")
|
||||||
|
|
||||||
|
local iFadeOut = find(rows, "fade", "out")
|
||||||
|
local iHeal = find(rows, "heal_party")
|
||||||
|
local iJingle = find(rows, "play_once", "Music_PkmnHealed")
|
||||||
|
local iFadeIn = find(rows, "fade", "in")
|
||||||
|
local iGreat = find(rows, "show_text", "_RedsHouse1FMomLookingGreatText")
|
||||||
|
check(iFadeOut and rows[iFadeOut][3] == "white",
|
||||||
|
"Mom fades out to white after rest text")
|
||||||
|
check(iHeal and iHeal > iFadeOut, "heal_party runs under the white fade")
|
||||||
|
check(iJingle and iJingle > iHeal,
|
||||||
|
"Music_PkmnHealed plays after heal_party")
|
||||||
|
check(iFadeIn and rows[iFadeIn][3] == "white" and iFadeIn > iJingle,
|
||||||
|
"Mom fades in from white after the jingle")
|
||||||
|
check(iGreat and iGreat > iFadeIn,
|
||||||
|
"LookingGreatText follows the fade-in")
|
||||||
|
|
||||||
|
-- jump_if_true must land on the heal path (rest text), not wake-up
|
||||||
|
local jumpRow
|
||||||
|
for _, row in ipairs(rows) do
|
||||||
|
if row[1] == "jump_if_true" then jumpRow = row break end
|
||||||
|
end
|
||||||
|
check(jumpRow, "Mom has jump_if_true after starter check")
|
||||||
|
eq(rows[jumpRow[2]][2], "_RedsHouse1FMomYouShouldRestText",
|
||||||
|
"EVENT_GOT_STARTER jumps to the heal rest text")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- --- Silph Co. 9F nurse: white fade, no heal jingle
|
||||||
|
do
|
||||||
|
local rows = silph.SILPH_CO_9F.talk.TEXT_SILPHCO9F_NURSE
|
||||||
|
check(rows ~= nil, "Silph nurse talk script exists")
|
||||||
|
|
||||||
|
local iHeal = find(rows, "heal_party")
|
||||||
|
local iFadeOut = find(rows, "fade", "out")
|
||||||
|
local iWait = find(rows, "wait")
|
||||||
|
local iFadeIn = find(rows, "fade", "in")
|
||||||
|
check(iHeal, "Silph nurse heals the party")
|
||||||
|
check(iFadeOut and rows[iFadeOut][3] == "white" and iFadeOut > iHeal,
|
||||||
|
"Silph nurse fades out to white after heal (pokered order)")
|
||||||
|
check(iWait and rows[iWait][2] == 3 and iWait > iFadeOut,
|
||||||
|
"Silph nurse Delay3 between fades")
|
||||||
|
check(iFadeIn and rows[iFadeIn][3] == "white" and iFadeIn > iWait,
|
||||||
|
"Silph nurse fades in from white")
|
||||||
|
check(not find(rows, "play_once", "Music_PkmnHealed"),
|
||||||
|
"Silph nurse does not play Music_PkmnHealed")
|
||||||
|
|
||||||
|
local sequence = table.concat(cmds(rows), ",")
|
||||||
|
check(sequence:find("heal_party,fade,wait,fade,show_text", 1, true),
|
||||||
|
"Silph heal rest sequence is heal → fade out → wait → fade in → text")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- --- play_once is a blocking command (Mom / captain wait loops)
|
||||||
|
do
|
||||||
|
local Commands = require("src.script.Commands")
|
||||||
|
check(Commands.meta.play_once and Commands.meta.play_once.blocking,
|
||||||
|
"play_once is marked blocking so heal jingles wait to finish")
|
||||||
|
end
|
||||||
|
|
||||||
|
S.finish()
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
-- Parity test: Pewter City youngster gym escort paths.
|
||||||
|
-- Self-contained: run via `luajit tests/parity_pewter_escort.lua`; also
|
||||||
|
-- dofile'd by tests/run_tests.lua's aggregator.
|
||||||
|
--
|
||||||
|
-- Sources: scripts/PewterCity.asm, engine/overworld/auto_movement.asm
|
||||||
|
-- (RLEList_PewterGymPlayer / RLEList_PewterGymGuy),
|
||||||
|
-- engine/events/pewter_guys.asm (PewterGymGuyCoords).
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
if not _G.love then _G.love = require("tests.love_stub") end
|
||||||
|
local Data = require("src.core.Data")
|
||||||
|
if not (Data.maps and Data.maps.PEWTER_CITY) then Data:load() end
|
||||||
|
local S = require("tests.harness").suite("parity pewter escort")
|
||||||
|
local check, eq = S.check, S.eq
|
||||||
|
|
||||||
|
local mapScripts = require("data.scripts.init")
|
||||||
|
local pewter = mapScripts.get("PEWTER_CITY")
|
||||||
|
check(pewter and pewter.escort, "PEWTER_CITY exposes the escort tables")
|
||||||
|
local escort = pewter.escort
|
||||||
|
|
||||||
|
local function joined(t) return table.concat(t, ",") end
|
||||||
|
|
||||||
|
local D = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } }
|
||||||
|
|
||||||
|
eq(#escort.guySteps, 41, "youngster takes 41 steps (RLEList_PewterGymGuy)")
|
||||||
|
eq(joined({ escort.guySteps[1], escort.guySteps[2], escort.guySteps[#escort.guySteps] }),
|
||||||
|
"down,down,right", "guy path starts with DOWN×2 and ends RIGHT")
|
||||||
|
eq(#escort.guyReturnSteps, 41, "return path mirrors the escort")
|
||||||
|
do
|
||||||
|
local opp = { up = "down", down = "up", left = "right", right = "left" }
|
||||||
|
eq(escort.guyReturnSteps[1], opp[escort.guySteps[#escort.guySteps]],
|
||||||
|
"return starts with opposite of escort's last step")
|
||||||
|
local gx, gy = 12, 18
|
||||||
|
for _, d in ipairs(escort.guyReturnSteps) do
|
||||||
|
gx, gy = gx + D[d][1], gy + D[d][2]
|
||||||
|
end
|
||||||
|
check(gx == 35 and gy == 16,
|
||||||
|
"return path from the gym lands on his spawn (35,16)")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- every east-exit / talk tile lands both sprites by the gym road
|
||||||
|
local triggers = { { 35, 17 }, { 36, 17 }, { 37, 18 }, { 37, 19 }, { 34, 16 } }
|
||||||
|
for _, pos in ipairs(triggers) do
|
||||||
|
local px, py = pos[1], pos[2]
|
||||||
|
local plan = escort.playerPlan(px, py)
|
||||||
|
check(plan, ("playerPlan for (%d,%d)"):format(px, py))
|
||||||
|
local gx, gy = 35, 16
|
||||||
|
local gi = 0
|
||||||
|
for _ = 1, plan.guyHeadStart do
|
||||||
|
gi = gi + 1
|
||||||
|
local d = escort.guySteps[gi]
|
||||||
|
gx, gy = gx + D[d][1], gy + D[d][2]
|
||||||
|
end
|
||||||
|
for i, ps in ipairs(plan.steps) do
|
||||||
|
px, py = px + D[ps][1], py + D[ps][2]
|
||||||
|
local gs = escort.guySteps[plan.guyHeadStart + i]
|
||||||
|
if gs then
|
||||||
|
gx, gy = gx + D[gs][1], gy + D[gs][2]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
eq(px, 11, ("player from (%d,%d) ends at x=11"):format(pos[1], pos[2]))
|
||||||
|
eq(py, 18, ("player from (%d,%d) ends at y=18"):format(pos[1], pos[2]))
|
||||||
|
eq(gx, 12, ("guy from (%d,%d) ends at x=12"):format(pos[1], pos[2]))
|
||||||
|
eq(gy, 18, ("guy from (%d,%d) ends at y=18"):format(pos[1], pos[2]))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- the short east-road trigger uses no head-start; the (36,17)/(37,18)
|
||||||
|
-- tiles pause for one guy step (eight NO_INPUT frames)
|
||||||
|
eq(escort.playerPlan(35, 17).guyHeadStart, 0, "(35,17) no head-start")
|
||||||
|
eq(escort.playerPlan(36, 17).guyHeadStart, 1, "(36,17) one-step head-start")
|
||||||
|
eq(escort.playerPlan(37, 18).guyHeadStart, 1, "(37,18) one-step head-start")
|
||||||
|
|
||||||
|
-- path stays off the PEWTER_GYM door warp and the blocked gym building
|
||||||
|
-- cells: sample the (35,17) walk and confirm no step lands on (16,17)
|
||||||
|
do
|
||||||
|
local plan = escort.playerPlan(35, 17)
|
||||||
|
local px, py = 35, 17
|
||||||
|
local gx, gy = 35, 16
|
||||||
|
for i, ps in ipairs(plan.steps) do
|
||||||
|
px, py = px + D[ps][1], py + D[ps][2]
|
||||||
|
local gs = escort.guySteps[i]
|
||||||
|
if gs then gx, gy = gx + D[gs][1], gy + D[gs][2] end
|
||||||
|
check(not (px == 16 and py == 17),
|
||||||
|
("player beat %d does not phase onto gym door"):format(i))
|
||||||
|
check(not (gx == 16 and gy == 17),
|
||||||
|
("guy beat %d does not phase onto gym door"):format(i))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- gym door warp is still where the player is meant to walk afterward
|
||||||
|
local MapLoader = require("src.world.MapLoader")
|
||||||
|
local city = MapLoader.load(Data, "PEWTER_CITY")
|
||||||
|
local w = city:warpAtCell(16, 17)
|
||||||
|
check(w and w.def.destMap == "PEWTER_GYM", "(16,17) is the Pewter Gym door")
|
||||||
|
|
||||||
|
-- object spawn matches pokered object_event 35, 16
|
||||||
|
local young
|
||||||
|
for _, o in ipairs(Data.maps.PEWTER_CITY.objects) do
|
||||||
|
if o.name == "PEWTERCITY_YOUNGSTER" then young = o; break end
|
||||||
|
end
|
||||||
|
check(young and young.x == 35 and young.y == 16,
|
||||||
|
"PEWTERCITY_YOUNGSTER spawns at (35,16)")
|
||||||
|
|
||||||
|
-- Brock victory permanently HideObject's him (PewterGym.asm .gymVictory)
|
||||||
|
do
|
||||||
|
local victories = require("data.scripts.victories")
|
||||||
|
local hide = victories["OPP_BROCK#1"] and victories["OPP_BROCK#1"].hide
|
||||||
|
check(hide, "OPP_BROCK#1 lists objects to hide")
|
||||||
|
local found
|
||||||
|
for _, entry in ipairs(hide or {}) do
|
||||||
|
if entry[1] == "PEWTER_CITY" and entry[2] == "PEWTERCITY_YOUNGSTER" then
|
||||||
|
found = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
check(found, "Brock victory hides PEWTERCITY_YOUNGSTER")
|
||||||
|
end
|
||||||
|
|
||||||
|
S.finish()
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
-- Parity test: Vermilion City S.S. Anne sailor (#122).
|
||||||
|
--
|
||||||
|
-- pokered VermilionCityDefaultScript (scripts/VermilionCity.asm):
|
||||||
|
-- stepping onto SSAnneTicketCheckCoords (18,30) facing down always
|
||||||
|
-- DisplayTextID's the sailor. With a ticket and the ship still docked,
|
||||||
|
-- FlashedTicket plays and the player may continue; without a ticket, or
|
||||||
|
-- after EVENT_SS_ANNE_LEFT, they are walked back up.
|
||||||
|
--
|
||||||
|
-- The port used to early-return when the ticket was in the bag, skipping
|
||||||
|
-- the flash dialog entirely.
|
||||||
|
--
|
||||||
|
-- Self-contained; run via `luajit tests/parity_ss_anne_guard.lua`.
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
if not _G.love then _G.love = require("tests.love_stub") end
|
||||||
|
local S = require("tests.harness").suite("parity ss anne guard")
|
||||||
|
local check, eq = S.check, S.eq
|
||||||
|
|
||||||
|
package.loaded["src.render.TextBox"] = {
|
||||||
|
new = function(_, text, done) return { text = text, done = done } end,
|
||||||
|
}
|
||||||
|
|
||||||
|
local musicCalls = {}
|
||||||
|
package.loaded["src.core.Music"] = {
|
||||||
|
stop = function() musicCalls[#musicCalls + 1] = { "stop" } end,
|
||||||
|
play = function(_, song)
|
||||||
|
musicCalls[#musicCalls + 1] = { "play", song }
|
||||||
|
end,
|
||||||
|
playOnce = function(_, song)
|
||||||
|
musicCalls[#musicCalls + 1] = { "playOnce", song }
|
||||||
|
return true
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
|
||||||
|
local soundCalls = {}
|
||||||
|
package.loaded["src.core.Sound"] = {
|
||||||
|
play = function(_, id) soundCalls[#soundCalls + 1] = id end,
|
||||||
|
}
|
||||||
|
|
||||||
|
local story = dofile("data/scripts/story.lua")
|
||||||
|
local story3 = dofile("data/scripts/story3.lua")
|
||||||
|
local Flags = require("src.script.Flags")
|
||||||
|
|
||||||
|
local function gameWith(opts)
|
||||||
|
local pushed, moved = {}, {}
|
||||||
|
local game = {
|
||||||
|
save = {
|
||||||
|
inventory = opts.inventory or {},
|
||||||
|
flags = opts.flags or {},
|
||||||
|
},
|
||||||
|
data = {
|
||||||
|
text = {
|
||||||
|
_VermilionCitySailor1DoYouHaveATicketText =
|
||||||
|
"Welcome to S.S.\nANNE!\fExcuse me, do you\nhave a ticket?",
|
||||||
|
_VermilionCitySailor1FlashedTicketText =
|
||||||
|
"PLAYER flashed\nthe S.S.TICKET!",
|
||||||
|
_VermilionCitySailor1YouNeedATicketText =
|
||||||
|
"You need a ticket\nto get aboard.",
|
||||||
|
_VermilionCitySailor1ShipSetSailText = "The ship set sail.",
|
||||||
|
_SSAnneCaptainsRoomRubCaptainsBackText = "Rub-rub...",
|
||||||
|
_SSAnneCaptainsRoomCaptainIFeelMuchBetterText = "I feel better!",
|
||||||
|
_SSAnneCaptainsRoomCaptainReceivedHM01Text = "Got HM01!",
|
||||||
|
_SSAnneCaptainsRoomCaptainNotSickAnymoreText = "Not sick.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
stack = { push = function(_, box) pushed[#pushed + 1] = box end },
|
||||||
|
_pushed = pushed,
|
||||||
|
_moved = moved,
|
||||||
|
}
|
||||||
|
return game, pushed, moved
|
||||||
|
end
|
||||||
|
|
||||||
|
local function owWith(moved)
|
||||||
|
return {
|
||||||
|
player = { facing = "down", cellY = 2 },
|
||||||
|
scriptMove = function(_, _, dir, n) moved[#moved + 1] = { dir, n } end,
|
||||||
|
queueScript = function(self, rows) self._queued = rows end,
|
||||||
|
startDustAnim = function() end,
|
||||||
|
map = {
|
||||||
|
setBlock = function() end,
|
||||||
|
renderer = { rebuild = function() end },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local city = story.VERMILION_CITY
|
||||||
|
check(city and city.onStep, "VERMILION_CITY has the sailor coord trigger")
|
||||||
|
|
||||||
|
-- With ticket, ship still docked: flash dialog, do NOT walk back.
|
||||||
|
do
|
||||||
|
local game, pushed, moved = gameWith({ inventory = { S_S_TICKET = 1 } })
|
||||||
|
local ow = owWith(moved)
|
||||||
|
check(city.onStep(game, ow, 18, 30), "ticket: walk-past trigger consumes the step")
|
||||||
|
check(#pushed == 1, "ticket: auto dialog is shown")
|
||||||
|
check(pushed[1].text:find("flashed", 1, true)
|
||||||
|
or pushed[1].text:find("ticket", 1, true),
|
||||||
|
"ticket: flash / ticket-check dialog text")
|
||||||
|
check(#moved == 0, "ticket: player is not walked back")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Without ticket: dialog + walk back.
|
||||||
|
do
|
||||||
|
local game, pushed, moved = gameWith({})
|
||||||
|
local ow = owWith(moved)
|
||||||
|
check(city.onStep(game, ow, 18, 30), "no ticket: trigger fires")
|
||||||
|
check(#pushed == 1, "no ticket: dialog shown")
|
||||||
|
if pushed[1] and pushed[1].done then pushed[1].done() end
|
||||||
|
check(#moved == 1 and moved[1][1] == "up",
|
||||||
|
"no ticket: walked back up after dialog")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- After the ship leaves: ShipSetSail + walk back, even with a ticket.
|
||||||
|
do
|
||||||
|
local game, pushed, moved = gameWith({
|
||||||
|
inventory = { S_S_TICKET = 1 },
|
||||||
|
flags = { EVENT_SS_ANNE_LEFT = true },
|
||||||
|
})
|
||||||
|
local ow = owWith(moved)
|
||||||
|
check(city.onStep(game, ow, 18, 30), "ship left: trigger still fires")
|
||||||
|
eq(pushed[1] and pushed[1].text, "The ship set sail.",
|
||||||
|
"ship left: dialog updates to ShipSetSail")
|
||||||
|
if pushed[1] and pushed[1].done then pushed[1].done() end
|
||||||
|
check(#moved == 1 and moved[1][1] == "up",
|
||||||
|
"ship left: walked back up")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Off-tile / wrong facing: no trigger.
|
||||||
|
do
|
||||||
|
local game, pushed = gameWith({ inventory = { S_S_TICKET = 1 } })
|
||||||
|
local ow = owWith({})
|
||||||
|
eq(city.onStep(game, ow, 18, 29), false, "wrong Y: no trigger")
|
||||||
|
ow.player.facing = "up"
|
||||||
|
eq(city.onStep(game, ow, 18, 30), false, "facing up: no trigger")
|
||||||
|
check(#pushed == 0, "no spurious dialog off the check")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Sailor talk after ship left: ShipSetSail branch (row script).
|
||||||
|
do
|
||||||
|
local ScriptRunner = require("src.script.ScriptRunner")
|
||||||
|
local game = gameWith({ flags = { EVENT_SS_ANNE_LEFT = true } })
|
||||||
|
local rows = city.talk.TEXT_VERMILIONCITY_SAILOR1
|
||||||
|
local runner = ScriptRunner.new(game, nil)
|
||||||
|
runner:run(rows, {})
|
||||||
|
local guard = 0
|
||||||
|
while runner:isRunning() and guard < 200 do
|
||||||
|
guard = guard + 1
|
||||||
|
if game.stack and game._pushed then
|
||||||
|
local box = game._pushed[#game._pushed]
|
||||||
|
if box and box.done then box.done() end
|
||||||
|
end
|
||||||
|
runner:update()
|
||||||
|
end
|
||||||
|
local last = game._pushed[#game._pushed]
|
||||||
|
eq(last and last.text, "The ship set sail.",
|
||||||
|
"talk after ship left shows ShipSetSail")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Departure cutscene: Music_Surfing + EVENT_SS_ANNE_LEFT via Flags.set.
|
||||||
|
do
|
||||||
|
for i = #musicCalls, 1, -1 do musicCalls[i] = nil end
|
||||||
|
local game, _, moved = gameWith({ flags = { EVENT_GOT_HM01 = true } })
|
||||||
|
local ow = owWith(moved)
|
||||||
|
story3.VERMILION_DOCK.onEnter(game, ow)
|
||||||
|
check(Flags.get(game.save, "EVENT_SS_ANNE_LEFT"),
|
||||||
|
"departure sets EVENT_SS_ANNE_LEFT")
|
||||||
|
local sawSurf = false
|
||||||
|
for _, c in ipairs(musicCalls) do
|
||||||
|
if c[1] == "play" and c[2] == "Music_Surfing" then sawSurf = true end
|
||||||
|
end
|
||||||
|
check(sawSurf, "departure plays Music_Surfing")
|
||||||
|
check(ow._queued ~= nil, "departure queues the sail-away script")
|
||||||
|
local kept, horn = false, false
|
||||||
|
for _, row in ipairs(ow._queued or {}) do
|
||||||
|
if row[1] == "play_music" and row[2] == "Music_Surfing"
|
||||||
|
and row[3] and row[3].keep then
|
||||||
|
kept = true
|
||||||
|
end
|
||||||
|
if row[1] == "play_sound" and row[2] == "SS_Anne_Horn" then
|
||||||
|
horn = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
check(kept, "departure keeps Music_Surfing across the city warp")
|
||||||
|
check(horn, "departure queues SS_Anne_Horn")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Captain rub jingle: play_once Music_PkmnHealed sits after the rub text.
|
||||||
|
do
|
||||||
|
local rows = story.SS_ANNE_CAPTAINS_ROOM.talk.TEXT_SSANNECAPTAINSROOM_CAPTAIN
|
||||||
|
local found = false
|
||||||
|
for i, row in ipairs(rows) do
|
||||||
|
if row[1] == "play_once" and row[2] == "Music_PkmnHealed" then
|
||||||
|
found = true
|
||||||
|
check(rows[i - 1] and rows[i - 1][2] == "_SSAnneCaptainsRoomRubCaptainsBackText",
|
||||||
|
"play_once follows the rub-back text")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
check(found, "captain script plays Music_PkmnHealed after the rub")
|
||||||
|
end
|
||||||
|
|
||||||
|
S.finish()
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
-- Parity: Oak's lab starter-ball Pokédex preview (#110).
|
||||||
|
-- pret StarterDex (engine/events/starter_dex.asm) temporarily sets the
|
||||||
|
-- owned bits so ShowPokedexData prints the full entry before the player
|
||||||
|
-- has caught anything. Also: English R/B prints only the kind string
|
||||||
|
-- (no " POKéMON" suffix — that clipped "LIZARD" to "LIZARD POKé").
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
if not _G.love then _G.love = require("tests.love_stub") end
|
||||||
|
local Data = require("src.core.Data")
|
||||||
|
if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end
|
||||||
|
|
||||||
|
local S = require("tests.harness").suite("parity starter dex")
|
||||||
|
local check, eq = S.check, S.eq
|
||||||
|
|
||||||
|
local Font = require("src.render.Font")
|
||||||
|
Font.load(Data)
|
||||||
|
|
||||||
|
local DexEntryMenu = require("src.ui.DexEntryMenu")
|
||||||
|
local SaveData = require("src.core.SaveData")
|
||||||
|
local mapScripts = require("data.scripts.init")
|
||||||
|
|
||||||
|
local function fakeGame()
|
||||||
|
return {
|
||||||
|
data = Data,
|
||||||
|
save = SaveData.newGame(),
|
||||||
|
input = { wasPressed = function() return false end },
|
||||||
|
stack = { pop = function() end },
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function drawCapture(menu)
|
||||||
|
local drawn = {}
|
||||||
|
local saved = Font.draw
|
||||||
|
Font.draw = function(text, x, y)
|
||||||
|
drawn[#drawn + 1] = { text = tostring(text), x = x, y = y }
|
||||||
|
return Font.width(text)
|
||||||
|
end
|
||||||
|
menu:draw()
|
||||||
|
Font.draw = saved
|
||||||
|
return drawn
|
||||||
|
end
|
||||||
|
|
||||||
|
local function findText(drawn, needle)
|
||||||
|
for _, d in ipairs(drawn) do
|
||||||
|
if d.text == needle or d.text:find(needle, 1, true) then return d end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- === 1) unowned entry without forceOwned stays "Data unknown." ===
|
||||||
|
do
|
||||||
|
local game = fakeGame()
|
||||||
|
game.save.pokedex = { seen = {}, owned = {} }
|
||||||
|
local menu = DexEntryMenu.new(game, "CHARMANDER")
|
||||||
|
local drawn = drawCapture(menu)
|
||||||
|
check(findText(drawn, "Data unknown."),
|
||||||
|
"unowned Charmander shows Data unknown without forceOwned")
|
||||||
|
check(not findText(drawn, "Obviously prefers"),
|
||||||
|
"unowned Charmander hides description without forceOwned")
|
||||||
|
check(not findText(drawn, "HT "),
|
||||||
|
"unowned Charmander hides height without forceOwned")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- === 2) forceOwned shows full entry without mutating save ===
|
||||||
|
do
|
||||||
|
local game = fakeGame()
|
||||||
|
game.save.pokedex = { seen = {}, owned = {} }
|
||||||
|
local menu = DexEntryMenu.new(game, { species = "CHARMANDER", forceOwned = true })
|
||||||
|
check(menu.forceOwned, "forceOwned flag sticks on the menu")
|
||||||
|
local drawn = drawCapture(menu)
|
||||||
|
check(findText(drawn, "Obviously prefers"),
|
||||||
|
"forceOwned Charmander shows dex description")
|
||||||
|
check(findText(drawn, "HT "),
|
||||||
|
"forceOwned Charmander shows height")
|
||||||
|
check(not findText(drawn, "Data unknown."),
|
||||||
|
"forceOwned Charmander does not show Data unknown")
|
||||||
|
check(not game.save.pokedex.owned.CHARMANDER,
|
||||||
|
"forceOwned preview does not mark Charmander owned")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- === 3) kind is the bare English string (no POKéMON suffix) ===
|
||||||
|
do
|
||||||
|
local game = fakeGame()
|
||||||
|
game.save.pokedex = { seen = {}, owned = { CHARMANDER = true } }
|
||||||
|
local menu = DexEntryMenu.new(game, "CHARMANDER")
|
||||||
|
local drawn = drawCapture(menu)
|
||||||
|
local kind = findText(drawn, "LIZARD")
|
||||||
|
check(kind and kind.text == "LIZARD",
|
||||||
|
"kind draws as LIZARD only (English R/B PlaceString)")
|
||||||
|
check(not findText(drawn, "POKéMON"),
|
||||||
|
"kind line does not append POKéMON")
|
||||||
|
check(kind.x + Font.width(kind.text) <= 160,
|
||||||
|
"LIZARD kind fits on-screen (no clip)")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- === 4) Oak's lab starter scripts request forceOwned ===
|
||||||
|
do
|
||||||
|
local balls = {
|
||||||
|
"TEXT_OAKSLAB_CHARMANDER_POKE_BALL",
|
||||||
|
"TEXT_OAKSLAB_SQUIRTLE_POKE_BALL",
|
||||||
|
"TEXT_OAKSLAB_BULBASAUR_POKE_BALL",
|
||||||
|
}
|
||||||
|
for _, textId in ipairs(balls) do
|
||||||
|
local script = mapScripts.talkScript("OAKS_LAB", textId)
|
||||||
|
local found
|
||||||
|
for _, row in ipairs(script) do
|
||||||
|
if row[1] == "push_screen" and row[2] == "DexEntryMenu" then
|
||||||
|
found = row[3]
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
check(type(found) == "table" and found.forceOwned == true
|
||||||
|
and type(found.species) == "string",
|
||||||
|
textId .. " pushes DexEntryMenu with forceOwned")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
S.finish()
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
-- Parity test: Pokémon Tower 2F rival leaves after defeat (#111).
|
||||||
|
--
|
||||||
|
-- pokered PokemonTower2FDefeatedRivalScript (scripts/PokemonTower2F.asm):
|
||||||
|
-- after EVENT_BEAT_POKEMON_TOWER_RIVAL, walk RightThenDown or
|
||||||
|
-- DownThenRight (EVENT_POKEMON_TOWER_RIVAL_ON_LEFT when the player is on
|
||||||
|
-- (15,5)), then HideObject TOGGLE_POKEMON_TOWER_2F_RIVAL. The port used
|
||||||
|
-- to end after DefeatedText, so he stayed and HowsYourDex could re-fire.
|
||||||
|
--
|
||||||
|
-- Self-contained; run via `luajit tests/parity_tower_rival.lua`.
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
if not _G.love then _G.love = require("tests.love_stub") end
|
||||||
|
local S = require("tests.harness").suite("parity tower rival")
|
||||||
|
local check, eq = S.check, S.eq
|
||||||
|
|
||||||
|
package.loaded["src.render.TextBox"] = {
|
||||||
|
new = function(_, text, done) return { text = text, done = done } end,
|
||||||
|
}
|
||||||
|
package.loaded["src.core.Music"] = {
|
||||||
|
play = function() end,
|
||||||
|
playOnce = function() return true end,
|
||||||
|
stop = function() end,
|
||||||
|
}
|
||||||
|
|
||||||
|
local story = dofile("data/scripts/story.lua")
|
||||||
|
local tower = story.POKEMON_TOWER_2F
|
||||||
|
check(tower ~= nil, "POKEMON_TOWER_2F map script exists")
|
||||||
|
check(type(tower.rivalScript) == "function", "rivalScript helper exported")
|
||||||
|
check(type(tower.onStep) == "function", "onStep ambush exists")
|
||||||
|
check(type(tower.onEnter) == "function", "onEnter hide repair exists")
|
||||||
|
|
||||||
|
local RIGHT_THEN_DOWN =
|
||||||
|
{ "right", "down", "down", "right", "down", "down", "right", "right" }
|
||||||
|
local DOWN_THEN_RIGHT =
|
||||||
|
{ "down", "down", "right", "right", "right", "right", "down", "down" }
|
||||||
|
|
||||||
|
local function dirsEqual(a, b)
|
||||||
|
if type(a) ~= "table" or #a ~= #b then return false end
|
||||||
|
for i = 1, #b do if a[i] ~= b[i] then return false end end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
local function findWalk(rows)
|
||||||
|
for _, r in ipairs(rows) do
|
||||||
|
if r[1] == "walk_npc" then return r end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- (1) both approach tiles pick the pokered exit movement
|
||||||
|
do
|
||||||
|
local below = tower.rivalScript(14)
|
||||||
|
local left = tower.rivalScript(15)
|
||||||
|
local wBelow, wLeft = findWalk(below), findWalk(left)
|
||||||
|
check(wBelow ~= nil, "player-below script has walk_npc")
|
||||||
|
check(wLeft ~= nil, "rival-on-left script has walk_npc")
|
||||||
|
eq(wBelow[2], 1, "walk targets object index 1 (rival)")
|
||||||
|
check(dirsEqual(wBelow[3], RIGHT_THEN_DOWN),
|
||||||
|
"player at x=14 uses RightThenDownMovement")
|
||||||
|
check(dirsEqual(wLeft[3], DOWN_THEN_RIGHT),
|
||||||
|
"player at x=15 uses DownThenRightMovement")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- (2) win path: flag, defeat text, walk, hide -- in order
|
||||||
|
do
|
||||||
|
local rows = tower.rivalScript(14)
|
||||||
|
local preds = {
|
||||||
|
{ "set_flag EVENT_BEAT_POKEMON_TOWER_RIVAL",
|
||||||
|
function(r) return r[1] == "set_flag"
|
||||||
|
and r[2] == "EVENT_BEAT_POKEMON_TOWER_RIVAL" end },
|
||||||
|
{ "show_text DefeatedText",
|
||||||
|
function(r) return r[1] == "show_text"
|
||||||
|
and r[2] == "_PokemonTower2FRivalDefeatedText" end },
|
||||||
|
{ "walk_npc exit",
|
||||||
|
function(r) return r[1] == "walk_npc" end },
|
||||||
|
{ "hide_object POKEMONTOWER2F_RIVAL",
|
||||||
|
function(r) return r[1] == "hide_object"
|
||||||
|
and r[2] == "POKEMON_TOWER_2F"
|
||||||
|
and r[3] == "POKEMONTOWER2F_RIVAL" end },
|
||||||
|
}
|
||||||
|
local pi = 1
|
||||||
|
for _, r in ipairs(rows) do
|
||||||
|
if pi <= #preds and preds[pi][2](r) then pi = pi + 1 end
|
||||||
|
end
|
||||||
|
for i = 1, #preds do
|
||||||
|
check(i < pi, "rival script has, in order: " .. preds[i][1])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- (3) onStep: only the encounter coords, and not after the beat flag
|
||||||
|
do
|
||||||
|
local ran
|
||||||
|
local game = { save = { flags = {} }, data = {} }
|
||||||
|
local ow = {
|
||||||
|
runner = {
|
||||||
|
isRunning = function() return false end,
|
||||||
|
run = function(_, rows) ran = rows end,
|
||||||
|
},
|
||||||
|
player = { facing = "down" },
|
||||||
|
npcByIndex = function() return { def = { name = "POKEMONTOWER2F_RIVAL" } } end,
|
||||||
|
}
|
||||||
|
check(tower.onStep(game, ow, 15, 5), "onStep fires on (15,5)")
|
||||||
|
check(ran ~= nil and findWalk(ran) ~= nil, "onStep runs exit-walk script")
|
||||||
|
check(dirsEqual(findWalk(ran)[3], DOWN_THEN_RIGHT),
|
||||||
|
"onStep (15,5) picks DownThenRight")
|
||||||
|
eq(ow.player.facing, "left", "onStep (15,5) faces the rival")
|
||||||
|
|
||||||
|
ran = nil
|
||||||
|
check(tower.onStep(game, ow, 14, 6), "onStep fires on (14,6)")
|
||||||
|
check(dirsEqual(findWalk(ran)[3], RIGHT_THEN_DOWN),
|
||||||
|
"onStep (14,6) picks RightThenDown")
|
||||||
|
|
||||||
|
check(not tower.onStep(game, ow, 14, 5), "onStep ignores rival's own tile")
|
||||||
|
game.save.flags.EVENT_BEAT_POKEMON_TOWER_RIVAL = true
|
||||||
|
check(not tower.onStep(game, ow, 15, 5), "beaten: onStep is inert")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- (4) onEnter hides a stuck rival once the beat flag is set
|
||||||
|
do
|
||||||
|
local Commands = require("src.script.Commands")
|
||||||
|
local hidden = {}
|
||||||
|
local realHide = Commands.hide_object
|
||||||
|
Commands.hide_object = function(_, mapId, name)
|
||||||
|
hidden[#hidden + 1] = { mapId, name }
|
||||||
|
end
|
||||||
|
|
||||||
|
tower.onEnter({ save = { flags = {} } }, {})
|
||||||
|
eq(#hidden, 0, "onEnter: no hide while unbeaten")
|
||||||
|
|
||||||
|
tower.onEnter({
|
||||||
|
save = { flags = { EVENT_BEAT_POKEMON_TOWER_RIVAL = true } },
|
||||||
|
}, {})
|
||||||
|
eq(#hidden, 1, "onEnter: hide once beaten")
|
||||||
|
eq(hidden[1][1], "POKEMON_TOWER_2F", "onEnter hide map")
|
||||||
|
eq(hidden[1][2], "POKEMONTOWER2F_RIVAL", "onEnter hide object")
|
||||||
|
|
||||||
|
Commands.hide_object = realHide
|
||||||
|
end
|
||||||
|
|
||||||
|
S.finish()
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
-- #114: launcher must restore the arrow cursor when leaving for boot.
|
||||||
|
-- Self-contained: `luajit tests/rom_importer_cursor_test.lua`; also dofile'd
|
||||||
|
-- by tests/run_tests.lua.
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
if not _G.love then _G.love = require("tests.love_stub") end
|
||||||
|
|
||||||
|
local S = require("tests.harness").suite("rom importer cursor")
|
||||||
|
local eq = S.eq
|
||||||
|
|
||||||
|
local currentCursor = "arrow"
|
||||||
|
love.mouse.isCursorSupported = function() return true end
|
||||||
|
love.mouse.getSystemCursor = function(name) return name end
|
||||||
|
love.mouse.setCursor = function(c) currentCursor = c or "arrow" end
|
||||||
|
|
||||||
|
local RomImporter = require("src.import.RomImporter")
|
||||||
|
|
||||||
|
local booted = nil
|
||||||
|
local ri = setmetatable({
|
||||||
|
android = false,
|
||||||
|
workState = nil,
|
||||||
|
ready = { red = true, blue = false },
|
||||||
|
onComplete = function(version) booted = version end,
|
||||||
|
}, RomImporter)
|
||||||
|
|
||||||
|
-- Simulate leaving Play while the hand cursor is still active (hover).
|
||||||
|
currentCursor = "hand"
|
||||||
|
ri:play("red")
|
||||||
|
eq(booted, "red", "play boots the chosen version")
|
||||||
|
eq(currentCursor, "arrow", "play restores the arrow cursor before boot")
|
||||||
|
|
||||||
|
-- Android / unsupported cursors must not error.
|
||||||
|
booted = nil
|
||||||
|
currentCursor = "hand"
|
||||||
|
ri.android = true
|
||||||
|
ri:play("red")
|
||||||
|
eq(booted, "red", "android play still boots")
|
||||||
|
eq(currentCursor, "hand", "android play leaves the cursor alone")
|
||||||
|
|
||||||
|
S.finish()
|
||||||
+101
-7
@@ -1895,6 +1895,87 @@ do
|
|||||||
eq(#StateStack.states, depth0, "mart menu unwound cleanly")
|
eq(#StateStack.states, depth0, "mart menu unwound cleanly")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------- UI text layout (#115/#116/#119)
|
||||||
|
-- Nested function so LuaJIT's 200-local main-chunk limit is not hit.
|
||||||
|
;(function()
|
||||||
|
local BoxMenu = require("src.ui.BoxMenu")
|
||||||
|
local box = BoxMenu.new(Game)
|
||||||
|
check(box.tw >= 14, "Bill's PC menu is wide enough for CHANGE BOX")
|
||||||
|
local labelTiles = 2 + #"CHANGE BOX" -- cursor gap + label
|
||||||
|
check(box.tx + labelTiles <= box.tx + box.tw - 1,
|
||||||
|
"CHANGE BOX fits inside the Bill's PC border")
|
||||||
|
|
||||||
|
-- ¥ is one glyph (charmap 0xF0) but two UTF-8 bytes; right-align must
|
||||||
|
-- use Font.width, not #string.
|
||||||
|
eq(Font.width("¥300"), 4 * 8, "Font.width counts yen as one glyph")
|
||||||
|
check(Font.width("¥300") < #"¥300" * 8,
|
||||||
|
"byte-length right-align would shift yen prices left")
|
||||||
|
|
||||||
|
-- Sell list: name + quantity column only (no ¥ price on the row).
|
||||||
|
Game.save.inventory = { GREAT_BALL = 5, HYPER_POTION = 99 }
|
||||||
|
local sellShop = require("src.ui.ShopMenu").new(Game, { "POTION" }, function() end)
|
||||||
|
StateStack:push(sellShop)
|
||||||
|
Input.pressed = { down = true }; StateStack:update(1 / 60); Input.pressed = {}
|
||||||
|
Input.pressed = { a = true }; StateStack:update(1 / 60); Input.pressed = {}
|
||||||
|
local sellList = StateStack:top()
|
||||||
|
local foundHyper
|
||||||
|
for _, it in ipairs(sellList.items or {}) do
|
||||||
|
if it.value == "HYPER_POTION" then
|
||||||
|
foundHyper = it
|
||||||
|
local nameEnd = 16 + Font.width(it.label)
|
||||||
|
local rightX = 160 - 8 - Font.width(it.right)
|
||||||
|
check(nameEnd <= rightX,
|
||||||
|
"sell HYPER POTION name does not overlap quantity")
|
||||||
|
check(not tostring(it.right):find("¥", 1, true),
|
||||||
|
"sell list keeps prices out of the right column")
|
||||||
|
check(tostring(it.label):find("x", 1, true) == nil,
|
||||||
|
"sell list does not glue quantity into the name")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
check(foundHyper, "sell list includes HYPER POTION")
|
||||||
|
StateStack:pop() -- sell list
|
||||||
|
StateStack:pop() -- shop
|
||||||
|
Game.save.inventory = {}
|
||||||
|
|
||||||
|
-- Status screen page 2: next-level line stays left of the line-box edge.
|
||||||
|
local Pokemon = require("src.pokemon.Pokemon")
|
||||||
|
local mon = Pokemon.new(Data, "RAICHU", 27)
|
||||||
|
local SummaryMenu = require("src.ui.SummaryMenu")
|
||||||
|
local HudTiles = require("src.render.HudTiles")
|
||||||
|
local drawn = {}
|
||||||
|
local savedDraw, savedTile = Font.draw, HudTiles.tile
|
||||||
|
Font.draw = function(text, x, y)
|
||||||
|
drawn[#drawn + 1] = { text = tostring(text), x = x, y = y }
|
||||||
|
return Font.width(text)
|
||||||
|
end
|
||||||
|
HudTiles.tile = function(code, x, y)
|
||||||
|
drawn[#drawn + 1] = { tile = code, x = x, y = y }
|
||||||
|
end
|
||||||
|
local summary = SummaryMenu.new(Game, mon)
|
||||||
|
summary.page = 2
|
||||||
|
summary:draw()
|
||||||
|
Font.draw = savedDraw
|
||||||
|
HudTiles.tile = savedTile
|
||||||
|
local nextExp, lvTile, lvNum
|
||||||
|
for _, d in ipairs(drawn) do
|
||||||
|
if d.text == "LEVEL UP" then
|
||||||
|
eq(d.y, 40, "LEVEL UP sits on tile row 5")
|
||||||
|
elseif type(d.text) == "string" and d.text:match("^%s*%d+$") and d.y == 48 and d.x == 56 then
|
||||||
|
nextExp = d
|
||||||
|
elseif d.tile == 0x6E and d.y == 48 then
|
||||||
|
lvTile = d
|
||||||
|
elseif d.text == "28" and d.y == 48 then
|
||||||
|
lvNum = d
|
||||||
|
end
|
||||||
|
end
|
||||||
|
check(nextExp, "next-exp prints at (7,6)")
|
||||||
|
check(lvTile and lvTile.x == 128, "next level uses the <LV> tile at col 16")
|
||||||
|
check(lvNum and lvNum.x == 136, "next level digits follow <LV>")
|
||||||
|
local edge = 19 * 8 -- DrawLineBox vertical at col 19
|
||||||
|
check(lvNum.x + Font.width(lvNum.text) <= edge,
|
||||||
|
"next level digits stay left of the status line-box")
|
||||||
|
end)()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
-- ================= BUGS.md batch: battle-victory-music =================
|
-- ================= BUGS.md batch: battle-victory-music =================
|
||||||
@@ -2091,6 +2172,7 @@ do
|
|||||||
local GBCFX = require("src.render.GBCFX")
|
local GBCFX = require("src.render.GBCFX")
|
||||||
local GameSpeed = require("src.core.GameSpeed")
|
local GameSpeed = require("src.core.GameSpeed")
|
||||||
local VideoMode = require("src.core.VideoMode")
|
local VideoMode = require("src.core.VideoMode")
|
||||||
|
local FrameCap = require("src.core.FrameCap")
|
||||||
local SD = require("src.core.SaveData")
|
local SD = require("src.core.SaveData")
|
||||||
-- Isolate from earlier save/options writes in this suite
|
-- Isolate from earlier save/options writes in this suite
|
||||||
SD.saveOptions(SD.defaultOptions())
|
SD.saveOptions(SD.defaultOptions())
|
||||||
@@ -2155,7 +2237,16 @@ do
|
|||||||
eq(og.save.options.videoMode, "windowed",
|
eq(og.save.options.videoMode, "windowed",
|
||||||
"VIDEO MODE wraps back to WINDOWED")
|
"VIDEO MODE wraps back to WINDOWED")
|
||||||
press("down")
|
press("down")
|
||||||
eq(om.index, 12, "cursor reaches GAME SPEED")
|
eq(om.index, 12, "cursor reaches MAX FPS")
|
||||||
|
press("a")
|
||||||
|
eq(og.save.options.fpsCap, 75, "A cycles MAX FPS up from 60 to 75")
|
||||||
|
eq(FrameCap.current, 75, "the live render cap tracks the MAX FPS option")
|
||||||
|
-- Driven by the step list rather than a literal press count, like GAME
|
||||||
|
-- SPEED below: a full loop of #STEPS presses returns to the 60 default.
|
||||||
|
for _ = 1, #FrameCap.STEPS - 1 do press("a") end
|
||||||
|
eq(og.save.options.fpsCap, 60, "MAX FPS wraps back to 60")
|
||||||
|
press("down")
|
||||||
|
eq(om.index, 13, "cursor reaches GAME SPEED")
|
||||||
press("a")
|
press("a")
|
||||||
eq(og.save.options.speed, 2, "A cycles GAME SPEED to 2X")
|
eq(og.save.options.speed, 2, "A cycles GAME SPEED to 2X")
|
||||||
-- Driven by the level list rather than a literal press count: adding a
|
-- Driven by the level list rather than a literal press count: adding a
|
||||||
@@ -2164,19 +2255,19 @@ do
|
|||||||
for _ = 1, #GameSpeed.LEVELS - 1 do press("a") end
|
for _ = 1, #GameSpeed.LEVELS - 1 do press("a") end
|
||||||
eq(og.save.options.speed, 1, "GAME SPEED wraps back to NORMAL")
|
eq(og.save.options.speed, 1, "GAME SPEED wraps back to NORMAL")
|
||||||
press("down")
|
press("down")
|
||||||
eq(om.index, 13, "cursor reaches MODS")
|
eq(om.index, 14, "cursor reaches MODS")
|
||||||
press("down")
|
press("down")
|
||||||
eq(om.index, 14, "cursor reaches CONTROLS")
|
eq(om.index, 15, "cursor reaches CONTROLS")
|
||||||
press("down")
|
press("down")
|
||||||
eq(om.index, 15, "CANCEL stays the fixed final row")
|
eq(om.index, 16, "CANCEL stays the fixed final row")
|
||||||
eq(om.scroll, 10, "CANCEL keeps the last option boxes on screen")
|
eq(om.scroll, 11, "CANCEL keeps the last option boxes on screen")
|
||||||
om:draw() -- smoke: scrolled layout draws under the headless stub
|
om:draw() -- smoke: scrolled layout draws under the headless stub
|
||||||
press("a")
|
press("a")
|
||||||
check(popped, "A on CANCEL closes the options menu")
|
check(popped, "A on CANCEL closes the options menu")
|
||||||
local om2 = OptionsMenu.new(og)
|
local om2 = OptionsMenu.new(og)
|
||||||
OInput.pressed = { up = true }; om2:update(1 / 60); OInput.pressed = {}
|
OInput.pressed = { up = true }; om2:update(1 / 60); OInput.pressed = {}
|
||||||
eq(om2.index, 15, "up from the top wraps to CANCEL")
|
eq(om2.index, 16, "up from the top wraps to CANCEL")
|
||||||
eq(om2.scroll, 10, "wrapping to CANCEL scrolls to the tail")
|
eq(om2.scroll, 11, "wrapping to CANCEL scrolls to the tail")
|
||||||
-- headless-safe: no love.audio, setters only update internal state
|
-- headless-safe: no love.audio, setters only update internal state
|
||||||
require("src.core.Music").applyOptions(og.save.options)
|
require("src.core.Music").applyOptions(og.save.options)
|
||||||
require("src.core.Sound").applyOptions(og.save.options)
|
require("src.core.Sound").applyOptions(og.save.options)
|
||||||
@@ -2581,6 +2672,9 @@ end
|
|||||||
-- ---------------------------------------------- input hold regressions
|
-- ---------------------------------------------- input hold regressions
|
||||||
runSuites({ "tests/input_hold_test.lua" })
|
runSuites({ "tests/input_hold_test.lua" })
|
||||||
|
|
||||||
|
-- ---------------------------------------------- launcher cursor (#114)
|
||||||
|
runSuites({ "tests/rom_importer_cursor_test.lua" })
|
||||||
|
|
||||||
-- ---------------------------------------------- parity workstream tests
|
-- ---------------------------------------------- parity workstream tests
|
||||||
-- Each tests/parity_*.lua is a self-contained file (own bootstrap + check,
|
-- Each tests/parity_*.lua is a self-contained file (own bootstrap + check,
|
||||||
-- error()s if any assertion fails). Globbed, so dropping a new parity
|
-- error()s if any assertion fails). Globbed, so dropping a new parity
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Derive the Pokemon Blue import manifest from the shipped Red manifest.
|
||||||
|
|
||||||
|
Red and Blue are assembled from one pokered source tree; the two ROMs are
|
||||||
|
byte-identical except for a small set of version-gated regions (wild
|
||||||
|
encounters, SGB/GBC palettes, the title ribbon, credits text, preset names).
|
||||||
|
Everything the manifest carries is either identical between the versions or
|
||||||
|
derivable, so rather than re-parsing pokered from scratch -- which would drag
|
||||||
|
in unrelated drift between the checked-out source and the *shipped* Red
|
||||||
|
manifest -- we take the shipped Red manifest verbatim and override only the
|
||||||
|
fields that genuinely differ for Blue:
|
||||||
|
|
||||||
|
* romSha1 -- Blue's ROM hash.
|
||||||
|
* symbols -- the 23 map-header symbols in bank $1D that shift by one
|
||||||
|
byte in Blue (everything else, audio included, is at the
|
||||||
|
same address). Re-sourced from pokeblue.sym.
|
||||||
|
* field.presetNames -- player/rival name presets swap (Blue's player is
|
||||||
|
BLUE/GARY/JOHN, rival RED/ASH/JACK).
|
||||||
|
* field.credits -- the "BLUE VERSION STAFF" title line (and any staff
|
||||||
|
reordering) parsed with _BLUE defined.
|
||||||
|
|
||||||
|
Every other field -- maps, trainers, text, audio addresses, trainer party
|
||||||
|
overrides, tilesets, etc. -- is inherited unchanged, so Blue behaves exactly
|
||||||
|
like Red wherever the two games are identical. The version-specific *content*
|
||||||
|
that is not in the manifest (wild Pokemon, palette colours, the ribbon
|
||||||
|
graphic, credits strings) is decoded from the Blue ROM at import time, which
|
||||||
|
works because the symbol addresses above now point at Blue's data.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import copy
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
from extract import field, util # noqa: E402
|
||||||
|
from rom_data import CANONICAL_BLUE_SHA1, SymbolTable # noqa: E402
|
||||||
|
|
||||||
|
DEV = "/Users/bryanbassett/Documents/development"
|
||||||
|
DEFAULT_RED = os.path.join(os.path.dirname(__file__), "rom_manifest.json")
|
||||||
|
DEFAULT_OUT = os.path.join(os.path.dirname(__file__), "rom_manifest_blue.json")
|
||||||
|
DEFAULT_POKERED = os.path.join(DEV, "pokered")
|
||||||
|
DEFAULT_SYMBOLS = os.path.join(DEV, "decprep/pokered-symbols/pokeblue.sym")
|
||||||
|
|
||||||
|
|
||||||
|
def derive(red, pokered, symbols_path):
|
||||||
|
"""Return the Blue manifest derived from the Red manifest dict."""
|
||||||
|
blue = copy.deepcopy(red)
|
||||||
|
blue["romSha1"] = CANONICAL_BLUE_SHA1
|
||||||
|
|
||||||
|
# Re-source every symbol the manifest already references from Blue's .sym.
|
||||||
|
# The name set is identical between versions; only 23 bank-$1D map headers
|
||||||
|
# actually move, but resolving the whole set keeps this robust to future
|
||||||
|
# shifts and fails loudly if pokeblue.sym is ever missing a name.
|
||||||
|
blue_symbols = SymbolTable(symbols_path)
|
||||||
|
resolved, missing = {}, []
|
||||||
|
for name in red["symbols"]:
|
||||||
|
symbol = blue_symbols.by_name.get(name)
|
||||||
|
if symbol is None:
|
||||||
|
missing.append(name)
|
||||||
|
continue
|
||||||
|
resolved[name] = [symbol.bank, symbol.address]
|
||||||
|
if missing:
|
||||||
|
raise SystemExit(
|
||||||
|
"pokeblue.sym is missing symbols the manifest needs: "
|
||||||
|
+ ", ".join(sorted(missing)[:10])
|
||||||
|
+ (" ..." if len(missing) > 10 else ""))
|
||||||
|
blue["symbols"] = resolved
|
||||||
|
|
||||||
|
# Version-gated field bits. Calling the parsers directly (rather than
|
||||||
|
# through field_metadata) sidesteps field_metadata's Red-only sanity
|
||||||
|
# checks, which is exactly what we want for a Blue build.
|
||||||
|
saved = util.ASM_DEFINES
|
||||||
|
util.ASM_DEFINES = {"_BLUE"}
|
||||||
|
try:
|
||||||
|
blue["field"]["presetNames"] = field.parse_preset_names(pokered)
|
||||||
|
blue["field"]["credits"] = field.parse_credits(pokered)
|
||||||
|
finally:
|
||||||
|
util.ASM_DEFINES = saved
|
||||||
|
|
||||||
|
# Sanity: Blue's presets must be the swapped set, and the credits banner
|
||||||
|
# must say BLUE. A silent Red-through here would be a hard-to-spot bug.
|
||||||
|
presets = blue["field"]["presetNames"]
|
||||||
|
if "BLUE" not in presets["player"] or "RED" not in presets["rival"]:
|
||||||
|
raise SystemExit("Blue preset-name parse did not swap player/rival")
|
||||||
|
banner = blue["field"]["credits"]["screens"][0]["lines"][1]["text"]
|
||||||
|
if banner != "BLUE VERSION STAFF":
|
||||||
|
raise SystemExit(f"Blue credits banner is {banner!r}, expected BLUE")
|
||||||
|
|
||||||
|
return blue
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--red", default=DEFAULT_RED,
|
||||||
|
help="shipped Red manifest to derive from")
|
||||||
|
parser.add_argument("--pokered", default=DEFAULT_POKERED,
|
||||||
|
help="pokered source checkout (for _BLUE field bits)")
|
||||||
|
parser.add_argument("--symbols", default=DEFAULT_SYMBOLS,
|
||||||
|
help="pokeblue.sym symbol file")
|
||||||
|
parser.add_argument("--out", default=DEFAULT_OUT)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
pokered = os.path.abspath(args.pokered)
|
||||||
|
if not os.path.isfile(os.path.join(pokered, "main.asm")):
|
||||||
|
raise SystemExit(f"{pokered} is not a pokered checkout")
|
||||||
|
with open(args.red, encoding="utf-8") as f:
|
||||||
|
red = json.load(f)
|
||||||
|
|
||||||
|
blue = derive(red, pokered, os.path.abspath(args.symbols))
|
||||||
|
with open(args.out, "w", encoding="utf-8", newline="\n") as f:
|
||||||
|
json.dump(blue, f, ensure_ascii=False, indent=2, sort_keys=True)
|
||||||
|
f.write("\n")
|
||||||
|
print(f"wrote {args.out}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -9,6 +9,7 @@ from dataclasses import dataclass
|
|||||||
|
|
||||||
|
|
||||||
CANONICAL_RED_SHA1 = "ea9bcae617fdf159b045185467ae58b2e4a48b9a"
|
CANONICAL_RED_SHA1 = "ea9bcae617fdf159b045185467ae58b2e4a48b9a"
|
||||||
|
CANONICAL_BLUE_SHA1 = "d7037c83e1ae5b39bde3c30787637ba1d4c48ce2"
|
||||||
ROM_BANK_SIZE = 0x4000
|
ROM_BANK_SIZE = 0x4000
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user