Compare commits

...

34 Commits

Author SHA1 Message Date
bryanthaboi 3a55c0b1a5 more and more bugs 2026-07-24 09:24:55 -04:00
bryanthaboi e62ad3f2a4 Lorelei, Bruno, and Agatha now push their AfterBattle text right after a win 2026-07-24 09:04:08 -04:00
bryanthaboi b40df05d2a more buggies 2026-07-24 09:00:19 -04:00
bryanthaboi 5312e34997 buggies 2026-07-24 08:45:16 -04:00
bryanthaboi f0b53e28ab main menu scrollable when over 8 items 2026-07-24 08:29:09 -04:00
bryanthaboi 8278b209b4 Squadhing som bug (#130)
* squashy squash

* squassshheee

CLOSES #128
CLOSES #114
CLOSES #113
CLOSES #111
CLOSES #110
CLOSES #109
CLOSES #107
CLOSES #106
CLOSES #105
CLOSES #90
CLOSES #103

* brock guy fix

CLOSES #39
2026-07-23 16:55:03 -04:00
bryanthaboi 1c4515eaaa squashy squash (#127) 2026-07-23 15:42:54 -04:00
bryanthaboi c04b93ce1c pokecenter fix (#126) 2026-07-23 15:17:20 -04:00
bryanthaboi 2c9970a643 Frame Cap (#124) 2026-07-23 14:35:26 -04:00
bryanthaboi 352a9a0eb8 Merge branch 'graphics-and-stutters-fixes' 2026-07-23 12:20:00 -04:00
bryanthaboi 5533785364 Create gen1recomp_cover.png 2026-07-23 12:19:47 -04:00
bryanthaboi 11f4f5570e Merge pull request #108 from bryanthaboi/graphics-and-stutters-fixes
MAJOR UPDATE
2026-07-23 11:51:59 -04:00
bryanthaboi 6239387643 renamed 2026-07-23 11:49:45 -04:00
bryanthaboi 4a50cb0728 hopefulyl a soltuion to broken end game saves 2026-07-23 11:29:58 -04:00
bryanthaboi 78aa1f3c0a add blue 2026-07-23 11:14:40 -04:00
bryanthaboi 5128b840b1 launcher spruce p2 2026-07-23 09:44:09 -04:00
bryanthaboi da14b7ee3f lauuncher spruce up part 1 2026-07-23 09:13:00 -04:00
bryanthaboi 2dbe21babf map stutter p2 2026-07-23 08:29:04 -04:00
bryanthaboi 7a85862602 graphics and stutters part 1 2026-07-23 07:59:09 -04:00
bryanthaboi 09b12a7cf0 Fixes portable windows and unlocks orientation on android
Fixes portable windows
and unlocks orientation on android
2026-07-22 15:44:14 -04:00
bryanthaboi e2f2b432ff bazinga moment 2 2026-07-22 15:42:19 -04:00
bryanthaboi 8c274d969e Merge pull request #77 from bryanthaboi/portablemodefix
PORTABLE MODE
2026-07-22 14:20:00 -04:00
bryanthaboi 4c7633b56e PORTABLE MODE 2026-07-22 14:18:39 -04:00
bryanthaboi 943ae23e73 Update build.sh 2026-07-22 13:03:53 -04:00
bryanthaboi 839cf19088 bzinga 2026-07-22 12:28:51 -04:00
bryanthaboi 5019ba2caf Merge pull request #71 from bryanthaboi/bug-squashing
another attempt
2026-07-22 10:56:20 -04:00
bryanthaboi 0b267fc536 another attempt 2026-07-22 10:55:46 -04:00
bryanthaboi 290bde1c39 Merge pull request #70 from bryanthaboi/bug-squashing
android build fix
2026-07-22 10:51:02 -04:00
bryanthaboi 7e92e54669 android build fix 2026-07-22 10:50:23 -04:00
bryanthaboi b89b895962 squashing BUGS and adding COOL stuff
squashing BUGS and adding COOL stuff
2026-07-22 10:43:06 -04:00
bryanthaboi 4311955c43 input sticky bug 2026-07-22 10:38:37 -04:00
bryanthaboi 747d9acad1 linux and android builds 2026-07-22 10:08:20 -04:00
bryanthaboi 5041125751 more bugs squashed + portable mode
CLOSES #28: PC in the beginning of the game isn't interactable (The one in your house)
CLOSES #34: Bug when calculating exp after one or many fainted team members.
CLOSES #37: No Grass Cutting
CLOSES #38: Blind TMs
CLOSES #53: Portable Mode
CLOSES #55: changing palletes with hot key
CLOSES #62: Poison status damage issue.
2026-07-22 09:45:23 -04:00
bryanthaboi 15029d811f bug squashing
# Closed issues

CLOSES #17: Incorrect Character Visuals (Only with GBC filter)
CLOSES #23: Could you allow the player to change the order of the moves
CLOSES #24: Evolution music not playing during evolution
CLOSES #26: Standing on door glitch
CLOSES #27: Battle Intro text automatically continues
CLOSES #29: Visual bug when zoomed out
CLOSES #32: Team Rocket recruiter doesn't battle with you unless you speak with him first
CLOSES #33: Developer/Debug Console
CLOSES #35: Professor Oak's introduction Inaccuracies
CLOSES #36: Missing Pokemon Dex entries when picking starter + Rival Pathing issues
CLOSES #39: Guy who stops player from skipping brock doesn't bring you to brock's gym + doesn't leave once you've beaten brock
CLOSES #40: Bill cutscene is broken
CLOSES #41: Ticket guy failing to be a Ticket guy
CLOSES #42: S.S. anne odd behavior + Missing sailing away animation
CLOSES #43: Dig Attack animation appears to be glitched
CLOSES #44: Pokeball flashing doesn't appear to be accurate
CLOSES #45: Inaccurate Cut Animation
CLOSES #46: Dugtrio i caught in diglett cave has two of the same move
CLOSES #47: Rival ignores player in Lavender Tower
CLOSES #48: Healing pad in lavender tower does not function
CLOSES #49: Incorrect dialogue with parched security guard
CLOSES #50: (Game Breaking!) Rocket grunt guarding poster refuses to move
CLOSES #51: Badges showing up as items I can deposit in PC
CLOSES #52: Visual bug on Celadon Department Store roof (Red Filter)
CLOSES #54: Visual issue on route 15 + Fuchsia City
CLOSES #56: Running animation missing
CLOSES #57: Safari Zone does not display steps while you are inside of it
CLOSES #58: (Game Breaking!) Softlock at cycling road gate
CLOSES #59: Bike visual issues
CLOSES #60: Cycling road not forcing you to get on your bike
CLOSES #61: No keycard doors in Silph Co.
CLOSES #63: Missing teleporter animation
CLOSES #64: Inaccurate spinning
CLOSES #65: Reimplement unused Silph Co. Chief and Professor Oak trainer battles
2026-07-22 08:18:13 -04:00
156 changed files with 57269 additions and 2351 deletions
+22 -10
View File
@@ -1,7 +1,7 @@
name: Release
# Builds the macOS and Windows desktop apps on the self-hosted Mac runner
# and publishes them as a GitHub Release.
# Builds the macOS, Windows, and Linux desktop apps plus an Android APK
# on the self-hosted Mac runner, and publishes them as a GitHub Release.
#
# Versioning:
# - First ever release is 0.1.0.
@@ -152,14 +152,20 @@ jobs:
echo "Identities available to codesign:"
security find-identity -v -p codesigning "$KEYCHAIN_PATH"
- name: Build macOS + Windows
- name: Build macOS + Windows + Linux
run: |
set -euo pipefail
# Sign in-build (identity auto-detected from the temp keychain);
# notarize separately below so it uses secret credentials, not a
# login-keychain profile.
# login-keychain profile. "all" also builds the Linux AppImage,
# which needs no signing/notarization.
scripts/build.sh all --version "${{ steps.ver.outputs.version }}" --no-notarize
- name: Build Android
run: |
set -euo pipefail
scripts/build_android.sh --version "${{ steps.ver.outputs.version }}"
- name: Notarize & staple macOS app
run: |
set -euo pipefail
@@ -171,8 +177,8 @@ jobs:
set -a; . "$ci_dir/notary.env"; set +a
echo "::add-mask::$APPLE_APP_PASSWORD"
app=".bazinga/work/PokemonRed.app"
zip="dist/mac/PokemonRed-macos.zip"
app=".bazinga/work/gen1recomp.app"
zip="dist/mac/gen1recomp-macos.zip"
[ -d "$app" ] || { echo "::error::signed app not found at $app"; exit 1; }
if [ -z "${APPLE_ID:-}" ] || [ -z "${APPLE_APP_PASSWORD:-}" ] || [ -z "${APPLE_TEAM_ID:-}" ]; then
echo "::error::notary.env is missing APPLE_ID / APPLE_APP_PASSWORD / APPLE_TEAM_ID."
@@ -202,8 +208,12 @@ jobs:
outdir="dist/release"
rm -rf "$outdir"
mkdir -p "$outdir"
cp "dist/mac/PokemonRed-macos.zip" "$outdir/PokemonRed-${v}-macos.zip"
cp "dist/win/PokemonRed-win64.zip" "$outdir/PokemonRed-${v}-windows.zip"
cp "dist/mac/gen1recomp-macos.zip" "$outdir/gen1recomp-${v}-macos.zip"
cp "dist/win/gen1recomp-win64.zip" "$outdir/gen1recomp-${v}-windows.zip"
cp "dist/linux/gen1recomp-linux.zip" "$outdir/gen1recomp-${v}-linux.zip"
apk="$(find dist/android/debug -name '*.apk' | head -1)"
[ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/debug"; exit 1; }
cp "$apk" "$outdir/gen1recomp-${v}-android.apk"
ls -lh "$outdir"
- name: Publish GitHub Release
@@ -253,8 +263,10 @@ jobs:
--target "$GITHUB_SHA" \
--title "$v" \
--notes "$notes" \
"dist/release/PokemonRed-${v}-macos.zip" \
"dist/release/PokemonRed-${v}-windows.zip"
"dist/release/gen1recomp-${v}-macos.zip" \
"dist/release/gen1recomp-${v}-windows.zip" \
"dist/release/gen1recomp-${v}-linux.zip" \
"dist/release/gen1recomp-${v}-android.apk"
echo "Published release $tag"
+39 -15
View File
@@ -1,18 +1,19 @@
# Pokemon Gen 1 Recompilation Project
A native LÖVE2D recreation of Pokemon Red. The engine and map behavior are
hand-written Lua; game data and graphics are decoded from a ROM supplied by
the player.
A native LÖVE2D recreation of Pokemon Red and Blue. The engine and map
behavior are hand-written Lua; game data and graphics are decoded from a ROM
supplied by the player.
SUPPORT AND ANNOUNCEMENTS: [Discord](https://bois.icu)
This project does not include a ROM, emulate the Game Boy, transpile assembly,
or download a disassembly. A canonical US Pokemon Red ROM is the only game
content input.
or download a disassembly. A canonical US Pokemon Red or Blue ROM is the only
game content input.
The ROM is verified, used during import, and then released from memory. It is
not copied into the cache. Later launches load the private generated cache and
do not ask for the ROM again.
do not ask for the ROM again. Red and Blue can both be imported and played
side by side.
## Quick Start
@@ -20,8 +21,12 @@ Open the desktop app. On first boot, choose your legally obtained `.gb` file
or drop it onto the window. Import takes a few seconds and the game starts
automatically.
Only the canonical 1 MiB US Red ROM is accepted. The importer verifies SHA-1
`ea9bcae617fdf159b045185467ae58b2e4a48b9a` before creating any game data.
Only the canonical 1 MiB US Red and Blue ROMs are accepted. The importer
verifies SHA-1 before creating any game data:
- Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a`
- Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2`
The packaged app contains neither a ROM nor pre-extracted game data. Music,
sound effects, and cries are synthesized while the game runs from compact
audio channel programs copied out of the verified ROM.
@@ -33,11 +38,11 @@ B; Escape opens START. F1 saves and F2 loads. Controllers are supported.
## Running From Source
Requires LÖVE 11.x. Place the ROM in the project folder and double-click
`Play-Mac.command` or `Play-Windows.bat`, or run:
Requires LÖVE 11.x. Place a Red or Blue ROM in the project folder and
double-click `Play-Mac.command` or `Play-Windows.bat`, or run:
```sh
scripts/setup.sh --rom "/path/to/Pokemon Red.gb"
scripts/setup.sh --rom "/path/to/Pokemon Red.gb" # or Pokemon Blue.gb
scripts/run.sh
```
@@ -45,6 +50,29 @@ then `love .` for later launches. Windows PowerShell scripts, the optional
developer data build, test suites, and cache management are covered in
[Developer Setup](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Guide-Developer-Setup).
## Portable Mode
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
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 app
(next to `PokemonRed.app`/`.exe`, or next to `main.lua`/`conf.lua` when
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:
- `save.lua`, `save.lua.bak`, and `options.lua` are read from and written to
that same folder instead of the OS save directory.
- A ROM import writes the generated `data/generated` and `assets/generated`
cache straight into that folder too (nothing is left in the OS save
directory), so a later launch reuses it 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
already written to either location is touched automatically, so copy files
over yourself if you want to carry existing progress across the switch.
## Modding
The game ships a native mod platform: content registries, events and hooks,
@@ -84,7 +112,3 @@ request with real detail is one that can actually get built.
This project would not be possible without [pret](https://github.com/pret) >
the pret band of decompiling maniacs > and their
[pokered](https://github.com/pret/pokered) disassembly.
## Wanna Support My Work?
[![Buy Me a Coffee](https://img.shields.io/badge/Buy_Me_a_Coffee-FFDD00?style=for-the-badge&logo=buy-me-a-coffee&logoColor=black)](https://buymeacoffee.com/bryanthaboi)
Binary file not shown.

After

Width:  |  Height:  |  Size: 753 KiB

+29 -7
View File
@@ -1,13 +1,16 @@
function love.conf(t)
local editor = os.getenv("POKEPORT_EDITOR") == "1"
local developer = os.getenv("POKEPORT_DEV") == "1"
if arg then
for _, a in ipairs(arg) do
if a == "--editor" then editor = true end
if a == "--developer" then developer = true end
end
end
-- main.lua runs in the same Lua state right after conf.lua; stash the
-- decision in a global so it doesn't need to reparse `arg`.
_G.POKEPORT_EDITOR_MODE = editor
_G.POKEPORT_DEV_MODE = developer
if editor then
t.identity = os.getenv("POKEPORT_IDENTITY") or "pokemon-love2d-editor"
@@ -20,9 +23,13 @@ function love.conf(t)
-- back to the plain title if the source is not mounted yet
local ok, Version = pcall(require, "src.core.Version")
t.window.title = ok and Version.title()
or "Pokemon Red (Gen 1 Recompilation Project)"
t.window.width = 160 * 4
t.window.height = 144 * 4
or "gen1recomp"
-- Open at the launcher's design size (the split-screen ROM selector is
-- laid out for 1024x768). The window is resizable and the 160x144 game
-- canvas letterboxes into whatever size it ends up, so this only sets the
-- starting size, not the game's resolution.
t.window.width = 1024
t.window.height = 768
end
t.version = "11.5"
t.window.vsync = 1
@@ -34,14 +41,29 @@ function love.conf(t)
local osName = love._os
local mobile = osName == "Android" or osName == "iOS"
if mobile then
-- On Android/iOS, width/height aspect picks portrait vs landscape
-- (fullscreen alone is not enough). Use a tall portrait size; the
-- OS then resizes to the real display. highdpi is required for
-- Retina iOS (Android always behaves as highdpi).
-- resizable is what unlocks orientation. SDL's Android backend, given no
-- SDL_HINT_ORIENTATIONS (LÖVE sets none), calls setRequestedOrientation
-- at window creation -- FULL_SENSOR when the window is resizable (rotates
-- 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.height = 1920
t.window.fullscreen = true
t.window.highdpi = true
-- Android only (irrelevant on iOS): puts the save directory under the
-- app's external-files folder, which is readable/writable via USB or a
-- file manager with no runtime permission, so RomImporter can ask the
-- player to copy their ROM there instead of needing a native file
-- picker (LOVE 11.5 on Android has none -- see src/import/RomImporter.lua).
t.externalstorage = osName == "Android"
else
t.window.resizable = true
end
+26
View File
@@ -0,0 +1,26 @@
-- Celadon Chief House (scripts/CeladonChiefHouse.asm). The CHIEF is a
-- talk-only NPC in the original ROM, and ChiefData (data/trainers/
-- parties.asm) is an empty, unreferenced trainer entry. After the HALL
-- OF FAME (EVENT_BEAT_CHAMPION_RIVAL) the CHIEF puts up a fight, using a
-- reconstructed party for the otherwise-unused OPP_CHIEF class.
return {
talk = {
TEXT_CELADONCHIEFHOUSE_CHIEF = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_BEAT_CHAMPION_RIVAL" }, -- 2
{ "jump_if_false", 12 }, -- 3
{ "check_flag", "EVENT_BEAT_CELADON_CHIEF" }, -- 4
{ "jump_if_true", 12 }, -- 5
{ "show_text", "So you've come to\nshut down my\noperation?\f"
.. "TEAM ROCKET's\nCHIEF won't go\ndown so easy!" }, -- 6
{ "start_battle", "trainer", "OPP_CHIEF", 1 }, -- 7
{ "jump_if_false", "end" }, -- 8
{ "set_flag", "EVENT_BEAT_CELADON_CHIEF" }, -- 9
{ "show_text", "Gah! Even the\nCHIEF is no match\nfor you!\f"
.. "TEAM ROCKET is\nfinished for\ngood!" }, -- 10
{ "jump", "end" }, -- 11
{ "show_text", "_CeladonChiefHouseChiefText" }, -- 12
},
},
}
+5 -17
View File
@@ -1,14 +1,11 @@
-- Pewter City flavor dialogue (pokered/scripts/PewterCity.asm).
-- PewterCity_TextPointers text_asm bodies for the SUPER_NERD1 museum
-- guide, SUPER_NERD2 garden nerd, and the leaving-east YOUNGSTER.
-- guide and SUPER_NERD2 garden nerd.
--
-- The escort choreography (SUPER_NERD1 walking the player to the
-- museum, YOUNGSTER walking the player to the gym) is scripted NPC
-- movement + a wPewterCityCurScript state machine that steers the
-- player off-map; that part is already covered on this map by
-- story5.lua's onStep gate (walks the player back a step at the
-- east exit before EVENT_BEAT_BROCK). Here we only port the real
-- YES/NO-branched flavor text these NPCs speak when talked to.
-- The YOUNGSTER's gym escort (talk + east-exit onStep) lives in
-- story5.lua so the lockstep RLE walk is not overwritten by this
-- flavor merge. SUPER_NERD1's museum escort is not ported; only the
-- YES/NO-branched flavor text is here.
local M = {}
@@ -60,15 +57,6 @@ M.PEWTER_CITY = {
end)
end,
-- PewterCityYoungsterText (scripts/PewterCity.asm): the "follow
-- me" line the youngster says when the player is stopped from
-- leaving Pewter east before beating Brock; the actual gate/step
-- block is handled by story5.lua's onStep for this map.
TEXT_PEWTERCITY_YOUNGSTER = function(game, ow, npc, done)
local t = text(game)
push(game, t._PewterCityYoungsterYoureATrainerFollowMeText
or "You're a trainer\nright? BROCK's\nlooking for new\nchallengers!\nFollow me!", done)
end,
},
}
+22
View File
@@ -0,0 +1,22 @@
-- Red's House 2F (pokered data/events/hidden_events.asm +
-- engine/events/hidden_objects PrintRedSNESText).
--
-- The bedroom SNES is a hidden_event at (3, 5) with ANY_FACING that
-- prints _RedBedroomSNESText. field.py extracts OpenRedsPC on this map
-- but skips PrintRedSNESText, so interaction was a no-op (#135).
local TextBox = require("src.render.TextBox")
return {
REDS_HOUSE_2F = {
-- hidden_events.asm:
-- hidden_event 3, 5, PrintRedSNESText, ANY_FACING
onInteract = function(game, ow, fx, fy)
if fx ~= 3 or fy ~= 5 then return false end
local text = game.data.text._RedBedroomSNESText
or "{PLAYER} is\nplaying the SNES!"
game.stack:push(TextBox.new(game, text))
return true
end,
},
}
+16 -12
View File
@@ -5,19 +5,23 @@ return {
SILPH_CO_9F = {
talk = {
-- SilphCo9FNurseText (pokered/scripts/SilphCo9F.asm):
-- before EVENT_BEAT_SILPH_CO_GIOVANNI: heals the party and shows
-- "You look tired..." then "Don't give up!"; after the event, just
-- says thanks. Nurse texts are not in data/generated/text.lua, so
-- the exact pokered/text/SilphCo9F.asm strings are used as literals.
-- before EVENT_BEAT_SILPH_CO_GIOVANNI: heals the party, white fade
-- (Delay3 between out/in; no Music_PkmnHealed), then "Don't give
-- up!"; after the event, just says thanks. Nurse texts are not in
-- data/generated/text.lua, so the exact pokered/text/SilphCo9F.asm
-- strings are used as literals.
TEXT_SILPHCO9F_NURSE = {
{ "face_player" },
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
{ "jump_if_true", 8 },
{ "show_text", "You look tired!\nYou should take a\nquick nap!" },
{ "heal_party" },
{ "show_text", "Don't give up!" },
{ "jump", 9 },
{ "show_text", "Thank you so\nmuch!" },
{ "face_player" }, -- 1
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" }, -- 2
{ "jump_if_true", 11 }, -- 3
{ "show_text", "You look tired!\nYou should take a\nquick nap!" }, -- 4
{ "heal_party" }, -- 5
{ "fade", "out", "white" }, -- 6
{ "wait", 3 }, -- 7 Delay3
{ "fade", "in", "white" }, -- 8
{ "show_text", "Don't give up!" }, -- 9
{ "jump", "end" }, -- 10
{ "show_text", "Thank you so\nmuch!" }, -- 11
},
},
},
+1
View File
@@ -28,6 +28,7 @@ local files = {
"data.scripts.flavor.pokemon_fan_club",
"data.scripts.flavor.power_plant",
"data.scripts.flavor.reds_house_1f",
"data.scripts.flavor.reds_house_2f",
"data.scripts.flavor.route11_gate_2f",
"data.scripts.flavor.route18_gate_2f",
"data.scripts.flavor.route_12_gate_2f",
+66 -40
View File
@@ -19,32 +19,41 @@ local function starterBall(askText, species, choseFlag, ownBall,
rivalBallX, rivalBall)
return {
{ "check_flag", "EVENT_GOT_STARTER" }, -- 1
{ "jump_if_true", 19 }, -- 2
{ "jump_if_true", 20 }, -- 2
-- no picking until Oak has walked you in (OaksLabScript gating)
{ "check_flag", "EVENT_FOLLOWED_OAK_INTO_LAB" }, -- 3
{ "jump_if_false", 19 }, -- 4
{ "ask", askText }, -- 5
{ "jump_if_false", 20 }, -- 6
{ "give_pokemon", species, 5 }, -- 7
{ "set_flag", "EVENT_GOT_STARTER" }, -- 8
{ "set_flag", choseFlag }, -- 9
{ "jump_if_false", 20 }, -- 4
-- the Pokédex "new species" entry shows before the ask (predef
-- StarterDex ahead of OaksLabYouWant...Text). StarterDex temporarily
-- sets the owned bits so ShowPokedexData prints height/weight/text;
-- forceOwned is that bypass without mutating save.pokedex.owned.
{ "push_screen", "DexEntryMenu",
{ species = species, forceOwned = true } }, -- 5
{ "ask", askText }, -- 6
{ "jump_if_false", 21 }, -- 7
-- OaksLab.asm prints ReceivedMon then AddPartyMon (AskName lives
-- inside give_pokemon). Show the received text first so the
-- nickname prompt follows "you got X", matching Gen1.
{ "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 8
{ "give_pokemon", species, 5 }, -- 9
{ "set_flag", "EVENT_GOT_STARTER" }, -- 10
{ "set_flag", choseFlag }, -- 11
-- POKé BALLs are not handed out here in the original -- Oak gives
-- them later, at OaksLabOak1Text's .give_poke_balls beat once the
-- player has beaten the Route 22 rival (see TEXT_OAKSLAB_OAK1 below)
{ "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 10
{ "hide_object", "OAKS_LAB", ownBall }, -- 11
{ "hide_object", "OAKS_LAB", ownBall }, -- 12
-- the rival walks to the countering ball (around the furniture)
{ "move_npc_to", 1, rivalBallX, 4 }, -- 12
{ "face_object", 1, "up" }, -- 13
{ "show_text", "_OaksLabRivalIllTakeThisOneText" }, -- 14
{ "hide_object", "OAKS_LAB", rivalBall }, -- 15
{ "move_npc_to", 1, rivalBallX, 4 }, -- 13
{ "face_object", 1, "up" }, -- 14
{ "show_text", "_OaksLabRivalIllTakeThisOneText" }, -- 15
{ "hide_object", "OAKS_LAB", rivalBall }, -- 16
{ "show_text", "_OaksLabRivalReceivedMonText",
{ RAM = rivalBall == "OAKSLAB_CHARMANDER_POKE_BALL" and "CHARMANDER"
or rivalBall == "OAKSLAB_SQUIRTLE_POKE_BALL" and "SQUIRTLE"
or "BULBASAUR" } }, -- 16
{ "jump", 20 }, -- 17
{ "jump", 20 }, -- 18 (spacer)
{ "show_text", "_OaksLabThoseArePokeBallsText" }, -- 19
or "BULBASAUR" } }, -- 17
{ "jump", 21 }, -- 18
{ "jump", 21 }, -- 19 (spacer)
{ "show_text", "_OaksLabThoseArePokeBallsText" }, -- 20
}
end
@@ -58,40 +67,44 @@ return {
TEXT_OAKSLAB_OAK1 = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_GOT_OAKS_PARCEL" }, -- 2
{ "jump_if_false", 14 }, -- 3
{ "jump_if_false", 16 }, -- 3
{ "check_flag", "EVENT_OAK_GOT_PARCEL" }, -- 4
{ "jump_if_true", 14 }, -- 5
{ "jump_if_true", 16 }, -- 5
{ "show_text", "_OaksLabOak1DeliverParcelText" }, -- 6
{ "take_item", "OAKS_PARCEL", 1 }, -- 7
{ "set_flag", "EVENT_OAK_GOT_PARCEL" }, -- 8
{ "show_text", "_OaksLabOak1PokemonAroundTheWorldText" }, -- 9
{ "set_flag", "EVENT_GOT_POKEDEX" }, -- 10
-- OaksLab.asm OakGivesPokedex: HideObject TOGGLE_POKEDEX_1/2
-- so the table sprites leave with the gift (#106).
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX1" }, -- 11
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX2" }, -- 12
-- the Pokédex swaps Viridian's two old men (OaksLab.asm:602-606:
-- HideObject TOGGLE_LYING_OLD_MAN / ShowObject TOGGLE_OLD_MAN).
-- Until this ran, the walking man at (17,5) -- who owns the coffee
-- ask and the catch tutorial -- stayed OFF for the whole game
-- (toggleable_objects.asm seeds him OFF, the sleeper ON).
{ "hide_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY" }, -- 11
{ "show_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN" }, -- 12
{ "jump", 32 }, -- 13
{ "check_flag", "EVENT_GOT_STARTER" }, -- 14
{ "jump_if_false", 29 }, -- 15
{ "check_item", "POKE_BALL" }, -- 16
{ "jump_if_true", 27 }, -- 17
{ "check_flag", "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE" }, -- 18
{ "jump_if_false", 31 }, -- 19
{ "check_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 20
{ "jump_if_true", 27 }, -- 21
{ "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 22
{ "give_item", "POKE_BALL", 5, false }, -- 23
{ "show_text", "_OaksLabOak1ReceivedPokeballsText" }, -- 24
{ "show_text", "_OaksLabGivePokeballsExplanationText" }, -- 25
{ "jump", 32 }, -- 26
{ "show_text", "_OaksLabOak1ComeSeeMeSometimesText" }, -- 27
{ "jump", 32 }, -- 28
{ "show_text", "_OaksLabOak1WhichPokemonDoYouWantText" }, -- 29
{ "jump", 32 }, -- 30
{ "show_text", "_OaksLabOak1RaiseYourYoungPokemonText" }, -- 31 (32 = end)
{ "hide_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY" }, -- 13
{ "show_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN" }, -- 14
{ "jump", "end" }, -- 15
{ "check_flag", "EVENT_GOT_STARTER" }, -- 16
{ "jump_if_false", 31 }, -- 17
{ "check_item", "POKE_BALL" }, -- 18
{ "jump_if_true", 29 }, -- 19
{ "check_flag", "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE" }, -- 20
{ "jump_if_false", 33 }, -- 21
{ "check_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 22
{ "jump_if_true", 29 }, -- 23
{ "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 24
{ "give_item", "POKE_BALL", 5, false }, -- 25
{ "show_text", "_OaksLabOak1ReceivedPokeballsText" }, -- 26
{ "show_text", "_OaksLabGivePokeballsExplanationText" }, -- 27
{ "jump", "end" }, -- 28
{ "show_text", "_OaksLabOak1ComeSeeMeSometimesText" }, -- 29
{ "jump", "end" }, -- 30
{ "show_text", "_OaksLabOak1WhichPokemonDoYouWantText" }, -- 31
{ "jump", "end" }, -- 32
{ "show_text", "_OaksLabOak1RaiseYourYoungPokemonText" }, -- 33
},
TEXT_OAKSLAB_CHARMANDER_POKE_BALL =
@@ -137,6 +150,19 @@ return {
},
},
-- Saves that got the Pokédex before #106 never wrote objectToggles for
-- the table sprites; re-entering the lab applies the same HideObject
-- the gift script now does (OaksLab.asm OakGivesPokedex).
onEnter = function(game, ow)
if not (game.save.flags and game.save.flags.EVENT_GOT_POKEDEX) then
return
end
local Commands = require("src.script.Commands")
local ctx = { save = game.save, game = game, overworld = ow }
Commands.hide_object(ctx, "OAKS_LAB", "OAKSLAB_POKEDEX1")
Commands.hide_object(ctx, "OAKS_LAB", "OAKSLAB_POKEDEX2")
end,
-- Oak stops you leaving without a starter; the rival stops you on
-- the way out for the first battle (scripts/OaksLab.asm
-- OaksLabScript8 / OaksLabRivalChallenge)
+30 -6
View File
@@ -10,15 +10,39 @@
-- The girl, fisher and the four signs resolve automatically through the
-- extracted text pointers (no script needed).
-- Once the HALL OF FAME has been reached (EVENT_BEAT_CHAMPION_RIVAL, set
-- alongside Commands.record_hall_of_fame) Oak offers the ProfOakData
-- battle (data/trainers/parties.asm) -- three teams picked by the
-- player's starter, mirroring the rival's type-advantage lineup, that go
-- unused in the original ROM.
return {
talk = {
TEXT_PALLETTOWN_OAK = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_GOT_STARTER" }, -- 2
{ "jump_if_true", 6 }, -- 3
{ "show_text", "_PalletTownOakHeyWaitDontGoOutText" },-- 4
{ "jump", 7 }, -- 5
{ "show_text", "_PalletTownOakItsUnsafeText" }, -- 6
{ "face_player" }, -- 1
{ "check_flag", "EVENT_BEAT_CHAMPION_RIVAL" }, -- 2
{ "jump_if_false", 20 }, -- 3
{ "check_flag", "EVENT_BEAT_PROF_OAK" }, -- 4
{ "jump_if_true", 20 }, -- 5
{ "show_text", "OAK: So you want\nto test your\nskills on me?\f"
.. "Very well! Let\nme show you what\na real trainer\ncan do!" }, -- 6
{ "check_flag", "EVENT_CHOSE_BULBASAUR" }, -- 7
{ "jump_if_false", 11 }, -- 8
{ "start_battle", "trainer", "OPP_PROF_OAK", 3 }, -- 9 CHARIZARD
{ "jump", 16 }, -- 10
{ "check_flag", "EVENT_CHOSE_SQUIRTLE" }, -- 11
{ "jump_if_false", 15 }, -- 12
{ "start_battle", "trainer", "OPP_PROF_OAK", 2 }, -- 13 VENUSAUR
{ "jump", 16 }, -- 14
{ "start_battle", "trainer", "OPP_PROF_OAK", 1 }, -- 15 BLASTOISE
{ "jump_if_false", "end" }, -- 16
{ "set_flag", "EVENT_BEAT_PROF_OAK" }, -- 17
{ "show_text", "OAK: Impressive!\nYou truly are a\nPOKéMON MASTER!" }, -- 18
{ "jump", "end" }, -- 19
{ "check_flag", "EVENT_GOT_STARTER" }, -- 20
{ "jump_if_true", 24 }, -- 21
{ "show_text", "_PalletTownOakHeyWaitDontGoOutText" }, -- 22
{ "jump", "end" }, -- 23
{ "show_text", "_PalletTownOakItsUnsafeText" }, -- 24
},
},
}
+15 -7
View File
@@ -1,15 +1,23 @@
-- Hand-ported from pret/pokered scripts/RedsHouse1F.asm.
-- Mom (RedsHouse1FMomText, text_asm) heals the party and shows the
-- "you should rest" / "looking great" dialogue. The intro "wake up"
-- branch is tied to the unported intro cutscene, so the heal path is used.
-- Mom (RedsHouse1FMomText): pre-starter shows the wake-up / Oak tip;
-- after EVENT_GOT_STARTER, RedsHouse1FMomHealScript fades to white,
-- heals, plays MUSIC_PKMN_HEALED, fades back, then "looking great".
return {
talk = {
TEXT_REDSHOUSE1F_MOM = {
{ "face_player" },
{ "show_text", "_RedsHouse1FMomYouShouldRestText" },
{ "heal_party" },
{ "show_text", "_RedsHouse1FMomLookingGreatText" },
{ "face_player" }, -- 1
{ "check_flag", "EVENT_GOT_STARTER" }, -- 2
{ "jump_if_true", 6 }, -- 3
{ "show_text", "_RedsHouse1FMomWakeUpText" }, -- 4
{ "jump", "end" }, -- 5
-- RedsHouse1FMomHealScript
{ "show_text", "_RedsHouse1FMomYouShouldRestText" }, -- 6
{ "fade", "out", "white" }, -- 7 GBFadeOutToWhite
{ "heal_party" }, -- 8
{ "play_once", "Music_PkmnHealed" }, -- 9 wait + restore map
{ "fade", "in", "white" }, -- 10 GBFadeInFromWhite
{ "show_text", "_RedsHouse1FMomLookingGreatText" }, -- 11
},
},
}
+367 -81
View File
@@ -133,22 +133,70 @@ M.BLUES_HOUSE = {
M.BILLS_HOUSE = {
talk = {
TEXT_BILLSHOUSE_BILL_POKEMON = {
{ "check_flag", "EVENT_GOT_SS_TICKET" }, -- 1
{ "jump_if_true", 13 }, -- 2
{ "show_text", "_BillsHouseBillImNotAPokemonText" }, -- 3
{ "show_text", "_BillsHouseBillNoYouGottaHelpText" }, -- 4
-- the cell-separator PC throws its switch
-- (bills_house_pc.asm BillsHouseInitiatedText: SFX_SWITCH)
{ "play_sound", "Switch" }, -- 5
{ "show_text", "_BillsHouseBillThankYouText" }, -- 6
-- BillsHouseBillPokemonText: "I'm not a POKéMON!", a YES/NO choice
-- (NO only adds "No, you gotta help!" before rejoining the YES
-- path), then the "get in the TELEPORTER" line and the monster
-- walking into the cell-separator machine
-- (BillsHousePokemonWalkToMachineScript: up 3, or around the player
-- when they stand in the way facing down), where it is hidden and
-- EVENT_BILL_SAID_USE_CELL_SEPARATOR arms the PC at (1,4) -- see
-- OverworldState.billsHousePC for the separator itself.
TEXT_BILLSHOUSE_BILL_POKEMON = function(game, ow, npc, done)
local TextBox = require("src.render.TextBox")
local t = game.data.text
local function toMachine()
game.stack:push(TextBox.new(game,
t._BillsHouseBillUseSeparationSystemText
or "When I'm in the\nTELEPORTER, run\nthe Cell\nSeparation System!", function()
local function entered()
local Commands = require("src.script.Commands")
Commands.hide_object({ game = game, save = game.save,
overworld = ow },
"BILLS_HOUSE", "BILLSHOUSE_BILL_POKEMON")
game.save.flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = true
done()
end
if ow.player.facing == "down" then
-- the player is standing on his straight path: walk around
-- (.PokemonWalkAroundPlayerMovement)
ow:scriptMove(npc, "right", 1, function()
ow:scriptMove(npc, "up", 2, function()
ow:scriptMove(npc, "left", 1, function()
ow:scriptMove(npc, "up", 1, entered)
end)
end)
end)
else
ow:scriptMove(npc, "up", 3, entered)
end
end))
end
game.stack:push(TextBox.new(game,
t._BillsHouseBillImNotAPokemonText or "Hey! I'm not a\nPOKéMON!",
nil, { choice = function(yes)
if yes then
toMachine()
else
game.stack:push(TextBox.new(game,
t._BillsHouseBillNoYouGottaHelpText
or "No! You gotta\nhelp me!", toMachine))
end
end }))
end,
-- BillsHouseBillSSTicketText (human Bill after the separation)
TEXT_BILLSHOUSE_BILL_SS_TICKET = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_GOT_SS_TICKET" }, -- 2
{ "jump_if_true", 12 }, -- 3
{ "show_text", "_BillsHouseBillThankYouText" }, -- 4
-- pokered gives first (GiveItem fills wStringBuffer), then prints
-- the received text that reads it (scripts/BillsHouse.asm; the
-- item id is S_S_TICKET in generated items.lua -- keyItem, so the
-- sound_get_key_item jingle plays like BillsHouse.asm:196)
{ "give_item", "S_S_TICKET", 1, false }, -- 7
{ "show_text", "_SSTicketReceivedText" }, -- 8
{ "set_flag", "EVENT_GOT_SS_TICKET" }, -- 9
{ "give_item", "S_S_TICKET", 1, false }, -- 5
{ "show_text", "_SSTicketReceivedText" }, -- 6
{ "set_flag", "EVENT_GOT_SS_TICKET" }, -- 7
-- The two Cerulean guards are a SWAP PAIR, not scenery
-- (BillsHouse.asm:174-178): handing over the ticket shows GUARD1 at
-- (28,12) and hides GUARD2 at (27,12). This matters far more than it
@@ -156,16 +204,74 @@ M.BILLS_HOUSE = {
-- house's south door at (27,11), and that house is one of the two
-- ways through the fence that splits Cerulean in half (the badge
-- house is the other). Leaving GUARD2 up forever severs the city --
-- the gym/mart half can never reach the Route 5 exit -- which is
-- exactly what stranded the bot after it beat Misty.
-- the gym/mart half can never reach the Route 5 exit.
-- Same swap fires after the TM28 Rocket (CeruleanCity_2.asm
-- CeruleanHideRocket), so either route opens the path.
{ "show_object", "CERULEAN_CITY", "CERULEANCITY_GUARD1" }, -- 10
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_GUARD2" }, -- 11
{ "jump", 14 }, -- 12
{ "show_text", "_BillsHouseBillCheckOutMyRarePokemonText" }, -- 13
{ "show_object", "CERULEAN_CITY", "CERULEANCITY_GUARD1" }, -- 8
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_GUARD2" }, -- 9
{ "show_text", "_BillsHouseBillWhyDontYouGoInsteadOfMeText" }, -- 10
{ "jump", 13 }, -- 11
{ "show_text", "_BillsHouseBillWhyDontYouGoInsteadOfMeText" }, -- 12
},
TEXT_BILLSHOUSE_BILL_CHECK_OUT_MY_RARE_POKEMON = {
{ "face_player" }, -- 1
{ "show_text", "_BillsHouseBillCheckOutMyRarePokemonText" }, -- 2
},
},
-- repair saves that already got the ticket under the old collapsed
-- script (the monster never hidden, human Bill never shown)
onEnter = function(game, ow)
local flags = game.save.flags
if flags.EVENT_GOT_SS_TICKET
and not flags.EVENT_USED_CELL_SEPARATOR_ON_BILL then
local Commands = require("src.script.Commands")
local ctx = { game = game, save = game.save, overworld = ow }
Commands.hide_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL_POKEMON")
Commands.show_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL1")
flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = true
flags.EVENT_USED_CELL_SEPARATOR_ON_BILL = true
flags.EVENT_MET_BILL = true
flags.EVENT_MET_BILL_2 = true
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,
}
-- -------------------------------------------------------------------
@@ -186,17 +292,63 @@ M.VERMILION_CITY = {
game.save.trashPuzzle = puz
puz.first = love.math.random(0, 7) * 2
end,
-- VermilionCityDefaultScript's per-frame SSAnneTicketCheckCoords check:
-- the unguarded cell (18,30) just west of the sailor leads straight
-- onto the dock warp. Stepping onto it facing down always runs the
-- sailor dialog (DisplayTextID TEXT_VERMILIONCITY_SAILOR1); only a
-- ticket while the ship is still docked lets the player continue --
-- otherwise they are walked back up. The sailor himself never hides.
onStep = function(game, ow, x, y)
if x ~= 18 or y ~= 30 then return false end
if ow.player.facing ~= "down" then return false end
local Flags = require("src.script.Flags")
local t = game.data.text
local TextBox = require("src.render.TextBox")
local shipLeft = Flags.get(game.save, "EVENT_SS_ANNE_LEFT")
local hasTicket = (game.save.inventory.S_S_TICKET or 0) > 0
if shipLeft then
game.stack:push(TextBox.new(game,
t._VermilionCitySailor1ShipSetSailText or "The ship set sail.",
function() ow:scriptMove(ow.player, "up", 1) end))
return true
end
-- Walk-past is never facing-right / inFrontOfOrBehindGuardCoords, so
-- VermilionCitySailor1Text always takes .greet_player_and_check_ticket:
-- DoYouHaveATicket, then FlashedTicket (allow through) or YouNeedATicket
-- (walk back). Returning true while the box is up also blocks the
-- dock warp on this step.
local ask = t._VermilionCitySailor1DoYouHaveATicketText
or "Welcome to S.S.\nANNE!\fExcuse me, do you\nhave a ticket?"
if hasTicket then
game.stack:push(TextBox.new(game,
ask .. "\f"
.. (t._VermilionCitySailor1FlashedTicketText
or "{PLAYER} flashed\nthe S.S.TICKET!")))
return true
end
game.stack:push(TextBox.new(game,
ask .. "\f"
.. (t._VermilionCitySailor1YouNeedATicketText
or "You need a ticket\nto get aboard."),
function() ow:scriptMove(ow.player, "up", 1) end))
return true
end,
talk = {
-- the sailor guarding the dock gangway
-- the sailor guarding the dock gangway (VermilionCitySailor1Text):
-- flashing the ticket just lets you through -- he never hides, and
-- once the ship has sailed he only reports it gone
TEXT_VERMILIONCITY_SAILOR1 = {
{ "face_player" }, -- 1
{ "show_text", "_VermilionCitySailor1WelcomeToSSAnneText" }, -- 2
{ "check_item", "S_S_TICKET" }, -- 3
{ "jump_if_false", 8 }, -- 4
{ "show_text", "_VermilionCitySailor1FlashedTicketText" }, -- 5
{ "hide_object", "VERMILION_CITY", "VERMILIONCITY_SAILOR1" }, -- 6
{ "jump", 9 }, -- 7
{ "show_text", "_VermilionCitySailor1YouNeedATicketText" }, -- 8
{ "check_flag", "EVENT_SS_ANNE_LEFT" }, -- 2
{ "jump_if_true", 11 }, -- 3
{ "show_text", "_VermilionCitySailor1DoYouHaveATicketText" }, -- 4
{ "check_item", "S_S_TICKET" }, -- 5
{ "jump_if_false", 9 }, -- 6
{ "show_text", "_VermilionCitySailor1FlashedTicketText" }, -- 7
{ "jump", 12 }, -- 8
{ "show_text", "_VermilionCitySailor1YouNeedATicketText" }, -- 9
{ "jump", 12 }, -- 10
{ "show_text", "_VermilionCitySailor1ShipSetSailText" }, -- 11
},
},
}
@@ -219,18 +371,21 @@ M.SS_ANNE_2F = {
M.SS_ANNE_CAPTAINS_ROOM = {
talk = {
-- SSAnneCaptainsRoomCaptainText: after the rub line's text_asm tail,
-- pokered plays MUSIC_PKMN_HEALED (scripts/SSAnneCaptainsRoom.asm).
TEXT_SSANNECAPTAINSROOM_CAPTAIN = {
{ "check_flag", "EVENT_GOT_HM01" }, -- 1
{ "jump_if_true", 9 }, -- 2
{ "jump_if_true", 10 }, -- 2
{ "show_text", "_SSAnneCaptainsRoomRubCaptainsBackText" }, -- 3
{ "show_text", "_SSAnneCaptainsRoomCaptainIFeelMuchBetterText" }, -- 4
{ "play_once", "Music_PkmnHealed" }, -- 4
{ "show_text", "_SSAnneCaptainsRoomCaptainIFeelMuchBetterText" }, -- 5
-- give-then-print like scripts/SSAnneCaptainsRoom.asm (GiveItem
-- fills wStringBuffer; the received text reads it)
{ "give_item", "HM_CUT", 1, false }, -- 5
{ "show_text", "_SSAnneCaptainsRoomCaptainReceivedHM01Text" }, -- 6
{ "set_flag", "EVENT_GOT_HM01" }, -- 7
{ "jump", 10 }, -- 8
{ "show_text", "_SSAnneCaptainsRoomCaptainNotSickAnymoreText" }, -- 9
{ "give_item", "HM_CUT", 1, false }, -- 6
{ "show_text", "_SSAnneCaptainsRoomCaptainReceivedHM01Text" }, -- 7
{ "set_flag", "EVENT_GOT_HM01" }, -- 8
{ "jump", 11 }, -- 9
{ "show_text", "_SSAnneCaptainsRoomCaptainNotSickAnymoreText" }, -- 10
},
},
}
@@ -461,7 +616,8 @@ M.SILPH_CO_11F = {
-- -------------------------------------------------------------------
-- Victory Road boulder switches (scripts/VictoryRoad1F/2F/3F.asm):
-- a boulder resting on a switch removes a barrier block; the 3F hole
-- drops a boulder down to the 2F switch.
-- drops a boulder down to the 2F switch, and also dungeon-warps the
-- player (IsPlayerOnDungeonWarp + DungeonWarpList/Data -> 2F at 22,16).
-- -------------------------------------------------------------------
local function boulderAt(ow, x, y)
@@ -527,6 +683,18 @@ M.VICTORY_ROAD_3F = {
Commands.show_object(ctx, "VICTORY_ROAD_2F", "VICTORYROAD2F_BOULDER")
end
end,
-- scripts/VictoryRoad3F.asm VictoryRoad3FDefaultScript: the same hole
-- is a dungeon warp for the player (wDungeonWarpDestinationMap =
-- VICTORY_ROAD_2F, wWhichDungeonWarp = 2 -> DungeonWarpData 22,16).
-- Mirrors POKEMON_MANSION_3F.onStep; CAVERN $22 is walkable so the
-- fall is onStep, not a collision block.
onStep = function(game, ow, x, y)
if x == 23 and y == 15 then
ow:startWarpTo("VICTORY_ROAD_2F", 22, 16, ow.player.facing)
return true
end
return false
end,
}
-- -------------------------------------------------------------------
@@ -539,6 +707,16 @@ M.VICTORY_ROAD_3F = {
-- IndigoPlateauLobby.asm) so the champion is re-fightable on rematches,
-- like pokered's re-entry cutscene. EVENT_BEAT_CHAMPION_RIVAL stays set
-- forever (postgame gates like the Cerulean cave guard read it).
--
-- pokered forces the fight on map entry: Agatha's victory arms
-- SCRIPT_CHAMPIONSROOM_PLAYER_ENTERS (scripts/AgathasRoom.asm), and
-- ChampionsRoomPlayerEntersScript then runs RivalEntrance_RLEMovement
-- (up 1, right 1, up 3) before ChampionsRoomRivalReadyToBattleScript.
-- The rival object has no trainer header / sight range, so without that
-- entrance script the player can walk past (issue #99). We arm on the
-- run flag instead of Agatha's victory bit: same observable effect for
-- first clear and Indigo rematches, without a cross-map script pointer.
--
-- scripts/ChampionsRoom.asm ChampionsRoomRivalDefeatedScript ->
-- OakArrivesScript -> OakCongratulatesPlayerScript ->
-- OakDisappointedWithRivalScript -> OakComeWithMeScript -> OakExitsScript,
@@ -547,43 +725,69 @@ M.VICTORY_ROAD_3F = {
-- itself is NOT run here: it belongs to the HALL_OF_FAME room script
-- (scripts/HallOfFame.asm), so we set a one-shot marker and warp; the room
-- onEnter (M.HALL_OF_FAME below) drives the HoF Oak speech + record.
local championsRoomRivalScript = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 2
{ "jump_if_true", 25 }, -- 3 past end
{ "show_text", "_ChampionsRoomRivalIntroText" }, -- 4
{ "rival_battle", "OPP_RIVAL3", 1 }, -- 5
{ "jump_if_false", 25 }, -- 6 past end
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 7
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL" }, -- 8
-- ChampionsRoomRivalDefeatedScript re-displays TEXT_CHAMPIONSROOM_RIVAL,
-- whose text_asm takes the EVENT_BEAT_CHAMPION_RIVAL branch =
-- _ChampionsRoomRivalAfterBattleText (the in-battle _RivalDefeatedText
-- is the port's generic "<PLAYER> defeated BLUE!" engine line instead).
{ "show_text", "_ChampionsRoomRivalAfterBattleText" }, -- 9
-- ChampionsRoomOakArrivesScript: Music_Cities1AlternateTempo
-- (Cities1, kept into HALL_OF_FAME like BIT_NO_MAP_MUSIC after
-- defeating RIVAL3), then Oak's "{PLAYER}!" + reveal + walk in
{ "play_music", "Music_Cities1", { keep = true } }, -- 10
{ "show_text", "_ChampionsRoomOakText" }, -- 11
{ "show_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 12
{ "move_npc", 2, "up", 5 }, -- 13 OakEntranceAfterVictoryMovement
-- OakCongratulatesPlayerScript: rival faces left, Oak faces down
{ "face_object", 1, "left" }, -- 14
{ "face_object", 2, "down" }, -- 15
{ "show_text", "_ChampionsRoomOakCongratulatesPlayerText" }, -- 16
-- OakDisappointedWithRivalScript: Oak turns to the rival (right)
{ "face_object", 2, "right" }, -- 17
{ "show_text", "_ChampionsRoomOakDisappointedWithRivalText" }, -- 18
-- OakComeWithMeScript: Oak faces down again, then exits up
{ "face_object", 2, "down" }, -- 19
{ "show_text", "_ChampionsRoomOakComeWithMeText" }, -- 20
{ "move_npc", 2, "up", 2 }, -- 21 OakExitChampionsRoomMovement
{ "hide_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 22
-- hand the induction off to the HALL_OF_FAME room (consumed by its
-- onEnter), then warp up into it (destWarp 1 lands at (4,7) facing up)
{ "set_field", "pendingHallOfFame", true }, -- 23
{ "warp", "HALL_OF_FAME", 4, 7, "up" }, -- 24
}
M.CHAMPIONS_ROOM = {
onEnter = function(game, ow)
if game.save.flags.EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN then return end
-- Lance entrance warps land at y=7; HoF return warps land at y=0.
-- Only the south entry should run ChampionsRoomPlayerEntersScript.
if ow.player.cellY < 7 then return end
local rival
for _, npc in ipairs(ow.npcs) do
if npc.def and npc.def.name == "CHAMPIONSROOM_RIVAL" then
rival = npc
break
end
end
-- RivalEntrance_RLEMovement, then the battle/Oak script (queued
-- separately so talk-script jump indices stay 1-based as written).
ow:queueScript({
{ "move_player", "up", 1 },
{ "move_player", "right", 1 },
{ "move_player", "up", 3 },
})
ow:queueScript(championsRoomRivalScript, { npc = rival })
end,
talk = {
TEXT_CHAMPIONSROOM_RIVAL = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 2
{ "jump_if_true", 24 }, -- 3
{ "show_text", "_ChampionsRoomRivalIntroText" }, -- 4
{ "rival_battle", "OPP_RIVAL3", 1 }, -- 5
{ "jump_if_false", 24 }, -- 6
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 7
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL" }, -- 8
-- ChampionsRoomRivalDefeatedScript re-displays TEXT_CHAMPIONSROOM_RIVAL,
-- whose text_asm takes the EVENT_BEAT_CHAMPION_RIVAL branch =
-- _ChampionsRoomRivalAfterBattleText (the in-battle _RivalDefeatedText
-- is the port's generic "<PLAYER> defeated BLUE!" engine line instead).
{ "show_text", "_ChampionsRoomRivalAfterBattleText" }, -- 9
-- ChampionsRoomOakArrivesScript: Oak's "{PLAYER}!" then reveal + walk in
{ "show_text", "_ChampionsRoomOakText" }, -- 10
{ "show_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 11
{ "move_npc", 2, "up", 5 }, -- 12 OakEntranceAfterVictoryMovement (3,7)->(3,2)
-- OakCongratulatesPlayerScript: rival faces left, Oak faces down
{ "face_object", 1, "left" }, -- 13
{ "face_object", 2, "down" }, -- 14
{ "show_text", "_ChampionsRoomOakCongratulatesPlayerText" }, -- 15
-- OakDisappointedWithRivalScript: Oak turns to the rival (right)
{ "face_object", 2, "right" }, -- 16
{ "show_text", "_ChampionsRoomOakDisappointedWithRivalText" }, -- 17
-- OakComeWithMeScript: Oak faces down again, then exits up
{ "face_object", 2, "down" }, -- 18
{ "show_text", "_ChampionsRoomOakComeWithMeText" }, -- 19
{ "move_npc", 2, "up", 2 }, -- 20 OakExitChampionsRoomMovement (3,2)->(3,0)
{ "hide_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 21
-- hand the induction off to the HALL_OF_FAME room (consumed by its
-- onEnter), then warp up into it (destWarp 1 lands at (4,7) facing up)
{ "set_field", "pendingHallOfFame", true }, -- 22
{ "warp", "HALL_OF_FAME", 4, 7, "up" }, -- 23
},
TEXT_CHAMPIONSROOM_RIVAL = championsRoomRivalScript,
},
}
@@ -613,6 +817,29 @@ M.HALL_OF_FAME = {
local toggles = game.save.objectToggles and game.save.objectToggles.HALL_OF_FAME
if toggles then toggles.HALLOFFAME_OAK = nil end
-- Make the Hall of Fame recording machine interactable. The console
-- juts from the north wall as the two solid cells (4,1) and (5,1); a
-- sign on each lets the player face the machine (from below, or from
-- either side) and press A to run the TEXT_HALLOFFAME_PC talk script.
-- maps.lua is generated from the ROM and gitignored, so the sign is
-- injected here (a tracked script) rather than baked into map data.
local def = ow.map.def
def.signs = def.signs or {}
local present = false
for _, s in ipairs(def.signs) do
if s.text == "TEXT_HALLOFFAME_PC" then present = true break end
end
if not present then
table.insert(def.signs, { text = "TEXT_HALLOFFAME_PC", x = 4, y = 1 })
table.insert(def.signs, { text = "TEXT_HALLOFFAME_PC", x = 5, y = 1 })
end
-- the live Map instance built its signAt lookup from def.signs before
-- this hook ran, so rebuild it (idempotent) to pick up the injection
ow.map.signAt = {}
for _, s in ipairs(def.signs) do
ow.map.signAt[s.y * ow.map.widthCells + s.x] = s
end
if not game.save.pendingHallOfFame then return end
game.save.pendingHallOfFame = false
ow:queueScript({
@@ -626,6 +853,21 @@ M.HALL_OF_FAME = {
{ "record_hall_of_fame" }, -- predef HallOfFamePC: induction + credits
})
end,
talk = {
-- The recording machine doubles as a "warp home" PC: a YES/NO prompt
-- that teleports back to the new-game bedroom spawn (special_warps.asm
-- NewGameWarp: REDS_HOUSE_2F, 3, 6, facing down). Fabricated
-- convenience -- there is no such prompt in the original ROM.
-- Heal + remember_outdoor so house LAST_MAP mats land in Pallet, not
-- Indigo Plateau (issue #103 escape path for already-stuck saves).
TEXT_HALLOFFAME_PC = {
{ "ask", "Return to\nPALLET TOWN?" }, -- 1 YES/NO -> lastCheck
{ "jump_if_false", "end" }, -- 2 NO: back away
{ "heal_party" }, -- 3
{ "remember_outdoor", "PALLET_TOWN", 5, 6 }, -- 4 LAST_MAP -> Pallet door
{ "warp", "REDS_HOUSE_2F", 3, 6, "down" }, -- 5 YES: home to your room
},
},
}
-- -------------------------------------------------------------------
@@ -650,21 +892,65 @@ M.CERULEAN_CITY = {
},
}
-- PokemonTower2F.asm: after the battle, PokemonTower2FDefeatedRivalScript
-- walks him out (RightThenDown vs DownThenRight from EVENT_POKEMON_TOWER_
-- RIVAL_ON_LEFT) then HideObject TOGGLE_POKEMON_TOWER_2F_RIVAL. Player
-- at (15,5) is the ON_LEFT case.
local TOWER_RIVAL_EXIT_RIGHT_THEN_DOWN =
{ "right", "down", "down", "right", "down", "down", "right", "right" }
local TOWER_RIVAL_EXIT_DOWN_THEN_RIGHT =
{ "down", "down", "right", "right", "right", "right", "down", "down" }
local function pokemonTower2FRivalScript(playerX)
local exitDirs = (playerX == 15)
and TOWER_RIVAL_EXIT_DOWN_THEN_RIGHT
or TOWER_RIVAL_EXIT_RIGHT_THEN_DOWN
return {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 2
{ "jump_if_true", 12 }, -- 3
{ "show_text", "_PokemonTower2FRivalWhatBringsYouHereText" }, -- 4
{ "rival_battle", "OPP_RIVAL2", 4 }, -- 5
{ "jump_if_false", "end" }, -- 6 loss: stay
{ "set_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 7
{ "show_text", "_PokemonTower2FRivalDefeatedText" }, -- 8
{ "walk_npc", 1, exitDirs }, -- 9
{ "hide_object", "POKEMON_TOWER_2F", "POKEMONTOWER2F_RIVAL" }, -- 10
{ "jump", "end" }, -- 11
{ "show_text", "_PokemonTower2FRivalHowsYourDexText" }, -- 12
}
end
M.POKEMON_TOWER_2F = {
rivalScript = pokemonTower2FRivalScript,
talk = {
TEXT_POKEMONTOWER2F_RIVAL = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 2
{ "jump_if_true", 10 }, -- 3
{ "show_text", "_PokemonTower2FRivalWhatBringsYouHereText" }, -- 4
{ "rival_battle", "OPP_RIVAL2", 4 }, -- 5
{ "jump_if_false", 11 }, -- 6
{ "set_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 7
{ "show_text", "_PokemonTower2FRivalDefeatedText" }, -- 8
{ "jump", 11 }, -- 9
{ "show_text", "_PokemonTower2FRivalHowsYourDexText" }, -- 10
},
TEXT_POKEMONTOWER2F_RIVAL = function(game, ow, npc, done)
ow.runner:run(pokemonTower2FRivalScript(ow.player.cellX),
{ npc = npc, onDone = done })
end,
},
-- Saves that beat him before the exit walk was ported still have the
-- flag but a visible rival; hide on enter like BillsHouse repairs.
onEnter = function(game, ow)
if not game.save.flags.EVENT_BEAT_POKEMON_TOWER_RIVAL then return end
local Commands = require("src.script.Commands")
Commands.hide_object({ game = game, save = game.save, overworld = ow },
"POKEMON_TOWER_2F", "POKEMONTOWER2F_RIVAL")
end,
-- PokemonTower2FDefaultScript: walking past the rival's tile forces
-- the encounter (ArePlayerCoordsInArray on (15,5)/(14,6)) -- he never
-- waits to be talked to
onStep = function(game, ow, x, y)
if game.save.flags.EVENT_BEAT_POKEMON_TOWER_RIVAL then return false end
if not ((x == 15 and y == 5) or (x == 14 and y == 6)) then return false end
if ow.runner:isRunning() then return false end
local rival = ow:npcByIndex(1)
if not rival then return false end
ow.player.facing = (x == 15) and "left" or "up"
require("src.core.Music").play(game.data, "Music_MeetRival")
ow.runner:run(pokemonTower2FRivalScript(x), { npc = rival })
return true
end,
}
-- -------------------------------------------------------------------
+154 -49
View File
@@ -308,8 +308,12 @@ local function saffronGate(guardText, triggers, horizontal)
end
if takeGuardDrink(game) then
game.stack:push(TextBox.new(game,
(t._SaffronGateGuardYouCanGoOnThroughText or
"Thanks! You can\ngo on through!"), done))
t._SaffronGateGuardImParchedText or "Whoa, boy!\nI'm parched!",
function()
game.stack:push(TextBox.new(game,
(t._SaffronGateGuardYouCanGoOnThroughText or
"You can go on\nthrough!"), done))
end))
return
end
game.stack:push(TextBox.new(game,
@@ -344,8 +348,12 @@ local function saffronGate(guardText, triggers, horizontal)
-- for the POKE_FLUTE at all.
if takeGuardDrink(game) then
game.stack:push(TextBox.new(game,
(t._SaffronGateGuardYouCanGoOnThroughText or
"Thanks! You can\ngo on through!")))
t._SaffronGateGuardImParchedText or "Whoa, boy!\nI'm parched!",
function()
game.stack:push(TextBox.new(game,
(t._SaffronGateGuardYouCanGoOnThroughText or
"You can go on\nthrough!")))
end))
return true
end
local back
@@ -355,7 +363,7 @@ local function saffronGate(guardText, triggers, horizontal)
back = ow.player.facing == "up" and "down" or "up"
end
game.stack:push(TextBox.new(game,
t._SaffronGateGuardImParchedText or "I'm parched...\nNo entry until\nI get a drink!",
t._SaffronGateGuardGeeImThirstyText or "Gee, I'm thirsty\nthough!\nThe road's closed.",
function()
ow:scriptMove(ow.player, back, 1)
end))
@@ -711,73 +719,170 @@ M.CINNABAR_LAB_FOSSIL_ROOM = {
-- -------------------------------------------------------------------
-- Day-care (scripts/Daycare.asm): the boarded Pokémon earns 1 exp per
-- step; the fee is ¥100 plus ¥100 per level gained.
--
-- #118: do not raise mon.level until a paid retrieve (pokered reverts
-- wDayCareMonBoxLevel on .leaveMonInDayCare). Fold pending steps into
-- mon.exp once and clear them so a second talk cannot re-apply the same
-- walk. Fill {RAM:wNameBuffer}/{RAM:wDayCareMonName}/{NUM:...} here —
-- TextBox.TOKENS.RAM only knows wStringBuffer.
-- -------------------------------------------------------------------
local function fillDaycareText(s, subs)
s = s:gsub("{PLAYER}", subs.player or "")
s = s:gsub("{RAM:([^}]*)}", function(name) return subs[name] or "" end)
-- extractor NUM spans may include flags after a comma; keep the name
s = s:gsub("{NUM:([%w_]+)[^}]*}", function(name)
return tostring(subs[name] or "0")
end)
return s
end
M.DAYCARE = {
talk = {
TEXT_DAYCARE_GENTLEMAN = function(game, ow, npc, done)
local TextBox = require("src.render.TextBox")
local ChoiceBox = require("src.ui.ChoiceBox")
local t = game.data.text
local dc = game.save.daycare
local playerName = game.save.player and game.save.player.name or "RED"
local function monName(mon)
local def = game.data.pokemon[mon.species]
return mon.nickname or (def and def.name) or mon.species
end
if dc and dc.mon then
local Growth = require("src.pokemon.Growth")
local Stats = require("src.pokemon.Stats")
local Party = require("src.pokemon.Party")
local mon = dc.mon
local def = game.data.pokemon[mon.species]
mon.exp = mon.exp + (dc.steps or 0)
local newLevel = math.min(100, Growth.levelForExp(def.growthRate, mon.exp))
local fee = 100 + (newLevel - mon.level) * 100
local grew = newLevel > mon.level
mon.level = newLevel
mon.stats = Stats.calc(def, mon.level, mon.dvs, mon.statExp)
mon.hp = mon.stats.hp
local msg = grew and (t._DaycareGentlemanMonHasGrownText or "It's grown a lot!")
or "Back already?"
game.stack:push(TextBox.new(game,
msg .. ("\fThe fee is ¥%d.\nGet it back?"):format(fee), function()
game.stack:push(ChoiceBox.new(game, function(yes)
if yes and game.save.money >= fee then
-- Apply deferred step-exp once (OverworldController only bumps
-- daycare.steps). Clearing prevents double-count on re-talk.
mon.exp = (mon.exp or 0) + (dc.steps or 0)
dc.steps = 0
-- depositLevel mirrors wDayCareMonBoxLevel: fee baseline that
-- must survive a declined retrieve. Fall back to mon.level for
-- older saves that predate the field.
if dc.depositLevel == nil then dc.depositLevel = mon.level end
local startLevel = dc.depositLevel
local newLevel = Growth.levelForExp(def and def.growthRate, mon.exp)
if newLevel >= 100 then
newLevel = 100
if def then
mon.exp = Growth.expForLevel(def.growthRate, 100)
end
end
local levelsGrown = math.max(0, newLevel - startLevel)
local fee = 100 + levelsGrown * 100
local name = monName(mon)
local subs = {
player = playerName,
wNameBuffer = name,
wDayCareMonName = name,
wDayCareNumLevelsGrown = levelsGrown,
wDayCareTotalCost = fee,
}
local statusText = levelsGrown > 0
and (t._DaycareGentlemanMonHasGrownText
or "Your {RAM:wNameBuffer}\nhas grown a lot!\fBy level, it's\ngrown by {NUM:wDayCareNumLevelsGrown, 1, 3}!\fAren't I great?")
or (t._DaycareGentlemanMonNeedsMoreTimeText
or "Back already?\nYour {RAM:wNameBuffer}\nneeds some more\ntime with me.")
game.stack:push(TextBox.new(game, fillDaycareText(statusText, subs), function()
if #game.save.party >= Party.MAX then
game.stack:push(TextBox.new(game,
t._DaycareGentlemanNoRoomForMonText
or "You have no room\nfor this POKéMON!", done))
return
end
game.stack:push(TextBox.new(game,
fillDaycareText(
t._DaycareGentlemanOweMoneyText
or "You owe me ¥{NUM:wDayCareTotalCost, 2 | LEADING_ZEROES | LEFT_ALIGN}\nfor the return\nof this POKéMON.",
subs),
nil, { choice = function(yes)
if not yes then
-- .leaveMonInDayCare: revert any transient level bump
mon.level = startLevel
game.stack:push(TextBox.new(game,
(t._DaycareGentlemanAllRightThenText or "All right then,\n")
.. (t._DaycareGentlemanComeAgainText or "come again."),
done))
return
end
if (game.save.money or 0) < fee then
mon.level = startLevel
game.stack:push(TextBox.new(game,
t._DaycareGentlemanNotEnoughMoneyText
or "Hey, you don't\nhave enough ¥!", done))
return
end
game.save.money = game.save.money - fee
mon.level = newLevel
if def then
mon.stats = Stats.calc(def, mon.level, mon.dvs, mon.statExp)
mon.hp = mon.stats.hp
end
table.insert(game.save.party, mon)
game.save.daycare = nil
game.stack:push(TextBox.new(game,
t._DaycareGentlemanGotMonBackText or "Here you go!", done))
else
game.stack:push(TextBox.new(game,
yes and (t._DaycareGentlemanOweMoneyText or "You owe me money!")
or "Come again!", done))
end
end))
t._DaycareGentlemanHeresYourMonText
or "Thank you! Here's\nyour POKéMON!", function()
game.stack:push(TextBox.new(game,
fillDaycareText(
t._DaycareGentlemanGotMonBackText
or "{PLAYER} got\n{RAM:wDayCareMonName} back!",
subs), done))
end))
end }))
end))
return
end
if #game.save.party < 2 then
game.stack:push(TextBox.new(game,
"You only have one\nPOKéMON with you!", done))
return
end
game.stack:push(TextBox.new(game,
t._DaycareGentlemanIntroText or "I can raise a\nPOKéMON for you.", function()
game.stack:push(ChoiceBox.new(game, function(yes)
if not yes then done() return end
local PartyMenu = require("src.ui.PartyMenu")
game.stack:push(PartyMenu.new(game, {
pickOnly = true,
onSwitch = function(mon)
for i, m in ipairs(game.save.party) do
if m == mon then table.remove(game.save.party, i) break end
end
game.save.daycare = { mon = mon, steps = 0 }
game.stack:push(TextBox.new(game,
t._DaycareGentlemanWillLookAfterMonText or
"Fine, I'll look\nafter it a while!", done))
end,
}))
end))
end))
t._DaycareGentlemanIntroText
or "I run a DAYCARE.\nWould you like me\nto raise one of\nyour POKéMON?",
nil, { choice = function(yes)
if not yes then
game.stack:push(TextBox.new(game,
t._DaycareGentlemanComeAgainText or "come again.", done))
return
end
if #game.save.party < 2 then
game.stack:push(TextBox.new(game,
t._DaycareGentlemanOnlyHaveOneMonText
or "You only have one\nPOKéMON with you.", done))
return
end
game.stack:push(TextBox.new(game,
t._DaycareGentlemanWhichMonText or "Which POKéMON\nshould I raise?",
function()
local PartyMenu = require("src.ui.PartyMenu")
game.stack:push(PartyMenu.new(game, {
pickOnly = true,
onSwitch = function(mon)
for i, m in ipairs(game.save.party) do
if m == mon then table.remove(game.save.party, i) break end
end
local name = monName(mon)
-- depositLevel = wDayCareMonBoxLevel at deposit time
game.save.daycare = {
mon = mon, steps = 0, depositLevel = mon.level,
}
game.stack:push(TextBox.new(game,
fillDaycareText(
t._DaycareGentlemanWillLookAfterMonText
or "Fine, I'll look\nafter {RAM:wNameBuffer}\nfor a while.",
{ player = playerName, wNameBuffer = name }),
function()
game.stack:push(TextBox.new(game,
t._DaycareGentlemanComeSeeMeInAWhileText
or "Come see me in\na while.", done))
end))
end,
}))
end))
end }))
end,
},
}
+323 -48
View File
@@ -61,6 +61,50 @@ M.ROUTE_12_SUPER_ROD_HOUSE = {
M.ROUTE_12_SUPER_ROD_HOUSE.talk.TEXT_ROUTE12SUPERRODHOUSE_FISHING_GURU[9] =
{ "show_text", "_Route12SuperRodHouseFishingGuruTryFishingText" }
-- -------------------------------------------------------------------
-- Pokemon Tower 5F purified zone (scripts/PokemonTower5F.asm
-- PokemonTower5FDefaultScript): the 2x2 center pad heals the party once
-- per visit. EVENT_IN_PURIFIED_ZONE latches until the player steps off;
-- while on the pad the map script also sets BIT_NO_BATTLES (we return
-- true from onStep so wild encounters are skipped the same way).
-- -------------------------------------------------------------------
local TOWER_5F_PURIFIED = {
[10 * 256 + 8] = true, [11 * 256 + 8] = true,
[10 * 256 + 9] = true, [11 * 256 + 9] = true,
}
-- HealParty -> GBFadeOutToWhite -> Delay3 -> Delay3 -> GBFadeInFromWhite
-- -> TEXT_POKEMONTOWER5F_PURIFIEDZONE (no Music_PkmnHealed).
local TOWER_5F_HEAL = {
{ "heal_party" },
{ "fade", "out", "white" },
{ "wait", 3 },
{ "wait", 3 },
{ "fade", "in", "white" },
{ "show_text", "_PokemonTower5FPurifiedZoneText" },
}
M.POKEMON_TOWER_5F = {
onStep = function(game, ow, x, y)
if not TOWER_5F_PURIFIED[x * 256 + y] then
game.save.flags.EVENT_IN_PURIFIED_ZONE = nil
return false
end
if game.save.flags.EVENT_IN_PURIFIED_ZONE then
return true
end
if ow.runner and ow.runner:isRunning() then return false end
game.save.flags.EVENT_IN_PURIFIED_ZONE = true
if ow.runner then
ow.runner:run(TOWER_5F_HEAL)
elseif ow.queueScript then
ow:queueScript(TOWER_5F_HEAL)
end
return true
end,
}
-- -------------------------------------------------------------------
-- The ghost Marowak (scripts/PokemonTower6F.asm): blocks the stairs at
-- (10,16) until defeated.
@@ -150,18 +194,20 @@ local WALK_DIRVEC = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right =
local WALK_OPP = { up = "down", down = "up", left = "right", right = "left" }
local WALK_ORDER = { "up", "down", "left", "right" }
local function elevatorWalkOut(ow, floor)
local m, p = ow.map, ow.player
-- .UpdateWarp is run twice, so BOTH car warp entries get the same
-- (warp id, map id): point every exit warp at the picked floor's
-- elevator-door warp (the reciprocal warp found while building the
-- menu). The car map's def is shared generated data, but its own
-- warps are only ever read from inside the car, and this rewrite runs
-- on every ride before the walk-out fires, so it is self-correcting.
for _, w in ipairs(m.def.warps) do
-- .UpdateWarp: point EVERY car exit warp at the same floor's elevator
-- door (warp id, map id). Shared generated map data, but the car's
-- warps are only read from inside the car; rides rewrite them again.
local function elevatorSetExit(ow, floor)
if not floor then return end
for _, w in ipairs(ow.map.def.warps) do
w.destMap = floor.map
w.destWarp = floor.warpIdx
end
end
local function elevatorWalkOut(ow, floor)
local m, p = ow.map, ow.player
elevatorSetExit(ow, floor)
-- leave by the exit tile under the player (they warped in onto one),
-- else the nearest
local door
@@ -199,37 +245,68 @@ local function elevatorWalkOut(ow, floor)
end)
end
local function elevatorFloors(elevatorMapId, game)
local floors = {}
for mapId, def in pairs(game.data.maps) do
for i, w in ipairs(def.warps) do
if w.destMap == elevatorMapId then
-- short floor token pokered actually prints, e.g.
-- SILPH_CO_10F -> "10F", ROCKET_HIDEOUT_B2F -> "B2F"
local token = mapId:match("_([^_]+)$") or mapId
-- warpIdx: this floor's warp back into the elevator IS the
-- warp the car's rewritten exit lands on (the reciprocal
-- pair), matching wElevatorWarpMaps' (warp id, map id)
table.insert(floors,
{ map = mapId, x = w.x, y = w.y, token = token, warpIdx = i })
break
end
end
end
-- numeric floor order (SilphCoElevatorFloors' FLOOR_1F..FLOOR_11F),
-- not lexicographic -- otherwise 10F/11F sort before 2F..9F
table.sort(floors, function(a, b)
return (tonumber(a.token:match("%d+")) or 0) <
(tonumber(b.token:match("%d+")) or 0)
end)
return floors
end
local function elevatorSeedExit(ow, floors, fromMapId)
-- Seed a walk-out destination before the menu (or key-gate text):
-- entry floor when known, else the first listed floor (1F). Choosing
-- a floor still rewrites via elevatorWalkOut; B-cancel / no-key leave
-- keeps this seed so walking out of the car cannot hit a missing ROM
-- placeholder (#123) or the car's static default floor (#90: Rocket
-- Hideout defaults to B1F even when entered from B2F/B4F).
local exitFloor = floors[1]
if fromMapId then
for _, f in ipairs(floors) do
if f.map == fromMapId then exitFloor = f break end
end
end
elevatorSetExit(ow, exitFloor)
return exitFloor
end
local function elevator(elevatorMapId, keyGate, preFrames)
return {
onEnter = function(game, ow)
-- fromMapId: the floor the player just left (setMap passes it), so a
-- B-cancel can still walk out onto a real map. Silph's ROM car warps
-- default to UNUSED_MAP_ED, which is not in Data.maps -- Warp.resolve
-- asserted and hard-crashed (#123).
onEnter = function(game, ow, fromMapId)
local floors = elevatorFloors(elevatorMapId, game)
elevatorSeedExit(ow, floors, fromMapId)
-- Rocket Hideout: without LIFT_KEY the panel only prints the need-
-- a-key line (scripts/RocketHideoutElevator.asm). Exit warps are
-- still seeded above so walking out returns to the entry floor
-- instead of the car's ROM default (B1F) — #90 / #105.
if keyGate and not game.save.inventory[keyGate.item] then
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game,
game.data.text[keyGate.text] or "It appears to\nneed a key."))
return
end
local floors = {}
for mapId, def in pairs(game.data.maps) do
for i, w in ipairs(def.warps) do
if w.destMap == elevatorMapId then
-- short floor token pokered actually prints, e.g.
-- SILPH_CO_10F -> "10F", ROCKET_HIDEOUT_B2F -> "B2F"
local token = mapId:match("_([^_]+)$") or mapId
-- warpIdx: this floor's warp back into the elevator IS the
-- warp the car's rewritten exit lands on (the reciprocal
-- pair), matching wElevatorWarpMaps' (warp id, map id)
table.insert(floors,
{ map = mapId, x = w.x, y = w.y, token = token, warpIdx = i })
break
end
end
end
-- numeric floor order (SilphCoElevatorFloors' FLOOR_1F..FLOOR_11F),
-- not lexicographic -- otherwise 10F/11F sort before 2F..9F
table.sort(floors, function(a, b)
return (tonumber(a.token:match("%d+")) or 0) <
(tonumber(b.token:match("%d+")) or 0)
end)
local items = {}
for _, f in ipairs(floors) do
table.insert(items, { label = f.token, value = f })
@@ -262,7 +339,8 @@ local function elevator(elevatorMapId, keyGate, preFrames)
end,
onCancel = function()
-- DisplayElevatorFloorMenu: `ret c` on B -- no warp, nothing
-- happens, the player just stays in the car
-- happens, the player just stays in the car (exit warps were
-- already seeded to the entry floor above)
end,
}))
end,
@@ -274,6 +352,98 @@ M.CELADON_MART_ELEVATOR = elevator("CELADON_MART_ELEVATOR", nil, 9)
M.ROCKET_HIDEOUT_ELEVATOR = elevator("ROCKET_HIDEOUT_ELEVATOR",
{ item = "LIFT_KEY", text = "_RocketHideoutElevatorAppearsToNeedKeyText" })
-- -------------------------------------------------------------------
-- Rocket Hideout B4F (scripts/RocketHideoutB4F.asm):
-- Rocket3's after-battle text_asm drops the LIFT KEY item ball
-- (CheckAndSetEvent EVENT_ROCKET_DROPPED_LIFT_KEY / ShowObject
-- TOGGLE_ROCKET_HIDEOUT_B4F_ITEM_5). Both start hidden in the map
-- objects; without this talk side-effect the key never appears (#90,
-- #105).
-- Giovanni's post-battle script likewise ShowObject's the Silph Scope
-- after the hope-we-meet-again line (TOGGLE_ROCKET_HIDEOUT_B4F_ITEM_4).
-- -------------------------------------------------------------------
M.ROCKET_HIDEOUT_B4F = {
talk = {
TEXT_ROCKETHIDEOUTB4F_ROCKET3 = function(game, ow, npc, done)
if not ow:trainerDefeated(npc) then
ow:engageTrainer(npc, done)
return
end
local TextBox = require("src.render.TextBox")
local t = game.data.text
game.stack:push(TextBox.new(game,
t._RocketHideoutB4FRocket3AfterBattleText
or "Oh no! I dropped\nthe LIFT KEY!",
function()
-- CheckAndSetEvent EVENT_ROCKET_DROPPED_LIFT_KEY: first talk
-- after the win reveals the ball; later talks only reprint.
if not game.save.flags.EVENT_ROCKET_DROPPED_LIFT_KEY then
game.save.flags.EVENT_ROCKET_DROPPED_LIFT_KEY = true
local Commands = require("src.script.Commands")
Commands.show_object(
{ game = game, save = game.save, overworld = ow },
"ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_LIFT_KEY")
end
done()
end))
end,
TEXT_ROCKETHIDEOUTB4F_GIOVANNI = function(game, ow, npc, done)
-- Giovanni has no trainer-header row (def_trainers 2); his text_asm
-- owns both the engage and the BeatGiovanniScript aftermath.
if ow:trainerDefeated(npc)
or game.save.flags.EVENT_BEAT_ROCKET_HIDEOUT_GIOVANNI then
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game,
game.data.text._RocketHideoutB4FGiovanniHopeWeMeetAgainText
or "I hope we meet\nagain...", done))
return
end
local TextBox = require("src.render.TextBox")
local BattleState = require("src.battle.BattleState")
local t = game.data.text
local impressed = t._RocketHideoutB4FGiovanniImpressedYouGotHereText
or "So! I must say, I\nam impressed you\ngot here!"
local cannotBe = t._RocketHideoutB4FGiovanniWhatCannotBeText
or "WHAT!\nThis cannot be!"
local hope = t._RocketHideoutB4FGiovanniHopeWeMeetAgainText
or "I hope we meet\nagain..."
game.stack:push(TextBox.new(game, impressed, function()
local battle = BattleState.newTrainer(game, "OPP_GIOVANNI", 1)
battle.onFinish = function(result)
if result ~= "win" then
ow:afterBattle(result, battle)
done()
return
end
game.save.defeatedTrainers[npc.id] = true
game.save.flags.EVENT_BEAT_ROCKET_HIDEOUT_GIOVANNI = true
-- End-battle "WHAT!" then BeatGiovanniScript's hope text,
-- fade, HideObject Giovanni, ShowObject Silph Scope.
game.stack:push(TextBox.new(game, cannotBe, function()
game.stack:push(TextBox.new(game, hope, function()
local Transition = require("src.render.Transition")
game.stack:push(Transition.new(game, function()
local Commands = require("src.script.Commands")
local ctx = { game = game, save = game.save, overworld = ow }
Commands.hide_object(ctx, "ROCKET_HIDEOUT_B4F",
"ROCKETHIDEOUTB4F_GIOVANNI")
Commands.show_object(ctx, "ROCKET_HIDEOUT_B4F",
"ROCKETHIDEOUTB4F_SILPH_SCOPE")
end, function()
ow:afterBattle(result, battle)
done()
end))
end))
end))
end
ow:pushBattle(battle)
end))
end,
},
}
-- -------------------------------------------------------------------
-- Game Corner coins, prizes, and the rocket-poster switch that reveals
-- the hideout stairs (scripts/GameCorner.asm, data/events/prizes.asm +
@@ -286,14 +456,23 @@ M.GAME_CORNER = {
-- EVENT_FOUND_ROCKET_HIDEOUT is unset, $43 after)
onEnter = function(game, ow)
local poster = game.data.field.gameCornerPoster
if not poster then return end
local block = game.save.flags[poster.event] and poster.openBlock
or poster.closedBlock
ow:replaceBlock(poster.x, poster.y, block)
if poster then
local block = game.save.flags[poster.event] and poster.openBlock
or poster.closedBlock
ow:replaceBlock(poster.x, poster.y, block)
end
-- pick this visit's lucky slot machine
-- (wLuckySlotHiddenEventIndex, engine/slots/game_corner_slots2.asm)
local seats = game.data.field.slotMachines.GAME_CORNER
ow.luckySlot = love.math.random(1, #seats)
-- #131: pre-#50 saves beat the poster grunt (defeatedTrainers) but
-- never hid him; clear the tile if he is already marked defeated
local rocketId = "GAME_CORNER_obj_11"
if game.save.defeatedTrainers and game.save.defeatedTrainers[rocketId] then
local Commands = require("src.script.Commands")
Commands.hide_object({ game = game, save = game.save, overworld = ow },
"GAME_CORNER", "GAMECORNER_ROCKET")
end
end,
talk = {
-- the poster bg event: pressing A reveals the hidden switch
@@ -317,6 +496,39 @@ M.GAME_CORNER = {
done()
end))
end,
-- the grunt guarding the poster (GameCornerRocketText /
-- GameCornerRocketBattleScript / GameCornerRocketExitScript): after
-- losing he warns the BOSS and leaves the floor for good, freeing
-- the tile in front of the hideout switch
TEXT_GAMECORNER_ROCKET = function(game, ow, npc, done)
local Commands = require("src.script.Commands")
local function hideRocket()
Commands.hide_object({ game = game, save = game.save,
overworld = ow },
"GAME_CORNER", "GAMECORNER_ROCKET")
end
-- already beaten: hide anyway so pre-#50 saves that only have
-- defeatedTrainers (no objectToggles hide) clear the poster tile
if ow:trainerDefeated(npc) then
hideRocket()
done()
return
end
ow:engageTrainer(npc, function()
if not ow:trainerDefeated(npc) then
done()
return
end
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game,
game.data.text._GameCornerRocketAfterBattleText
or "Our hideout might\nbe discovered! I\nbetter tell BOSS!",
function()
hideRocket()
done()
end))
end)
end,
TEXT_GAMECORNER_CLERK1 = function(game, ow, npc, done)
local TextBox = require("src.render.TextBox")
local ChoiceBox = require("src.ui.ChoiceBox")
@@ -363,24 +575,42 @@ M.GAME_CORNER = {
},
}
-- Red-version prize lists (data/events/prizes.asm, prize_mon_levels.asm)
local PRIZES = {
-- Game Corner prize lists (data/events/prizes.asm, prize_mon_levels.asm).
-- The six mon prizes differ between Red and Blue; the three TM prizes are
-- identical, so they are shared and appended to each version's mon list.
local PRIZE_TMS = {
{ kind = "item", item = "TM_DRAGON_RAGE", cost = 3300 },
{ kind = "item", item = "TM_HYPER_BEAM", cost = 5500 },
{ kind = "item", item = "TM_SUBSTITUTE", cost = 7700 },
}
local RED_PRIZES = {
{ kind = "mon", species = "ABRA", level = 9, cost = 180 },
{ kind = "mon", species = "CLEFAIRY", level = 8, cost = 500 },
{ kind = "mon", species = "NIDORINA", level = 17, cost = 1200 },
{ kind = "mon", species = "DRATINI", level = 18, cost = 2800 },
{ kind = "mon", species = "SCYTHER", level = 25, cost = 5500 },
{ kind = "mon", species = "PORYGON", level = 26, cost = 9999 },
{ kind = "item", item = "TM_DRAGON_RAGE", cost = 3300 },
{ kind = "item", item = "TM_HYPER_BEAM", cost = 5500 },
{ kind = "item", item = "TM_SUBSTITUTE", cost = 7700 },
PRIZE_TMS[1], PRIZE_TMS[2], PRIZE_TMS[3],
}
local BLUE_PRIZES = {
{ kind = "mon", species = "ABRA", level = 6, cost = 120 },
{ kind = "mon", species = "CLEFAIRY", level = 12, cost = 750 },
{ kind = "mon", species = "NIDORINO", level = 17, cost = 1200 },
{ kind = "mon", species = "PINSIR", level = 20, cost = 2500 },
{ kind = "mon", species = "DRATINI", level = 24, cost = 4600 },
{ kind = "mon", species = "PORYGON", level = 18, cost = 6500 },
PRIZE_TMS[1], PRIZE_TMS[2], PRIZE_TMS[3],
}
local function activePrizes()
return require("src.core.GameVersion").isBlue() and BLUE_PRIZES or RED_PRIZES
end
local function prizeCounter(game, ow, npc, done)
local ListMenu = require("src.ui.ListMenu")
local Commands = require("src.script.Commands")
local items = {}
for _, p in ipairs(PRIZES) do
for _, p in ipairs(activePrizes()) do
local label
if p.kind == "mon" then
label = ("%s L%d"):format(game.data.pokemon[p.species].name, p.level)
@@ -425,18 +655,63 @@ M.GAME_CORNER_PRIZE_ROOM = {
-- and the player steps off the dock, the ship sets sail.
-- -------------------------------------------------------------------
-- the ship's hull/deck blocks (block cols 5-8, rows 1-2) and the water
-- that replaces them once she sails (the surrounding blocks of each row)
local DOCK_SHIP_BLOCKS = {
{ bx = 5, by = 1, water = 1 }, { bx = 6, by = 1, water = 1 },
{ bx = 7, by = 1, water = 1 }, { bx = 8, by = 1, water = 1 },
{ bx = 5, by = 2, water = 13 }, { bx = 6, by = 2, water = 13 },
{ bx = 7, by = 2, water = 13 }, { bx = 8, by = 2, water = 13 },
}
M.VERMILION_DOCK = {
onEnter = function(game, ow)
if game.save.flags.EVENT_SS_ANNE_LEFT then
local Flags = require("src.script.Flags")
local f = game.save.flags
if Flags.get(game.save, "EVENT_SS_ANNE_LEFT") then
-- the ship is long gone: erase her right away, and anyone who
-- still lands here is sent back out past the guard
for _, b in ipairs(DOCK_SHIP_BLOCKS) do
ow.map:setBlock(b.bx, b.by, b.water)
end
ow.map.renderer:rebuild()
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game,
game.data.text._VermilionCitySailor1ShipSetSailText
or "The ship set sail.", function()
ow:startWarpTo("VERMILION_CITY", 19, 30, "up")
ow:startWarpTo("VERMILION_CITY", 18, 29, "up")
end))
elseif game.save.flags.EVENT_GOT_HM01 then
game.save.flags.EVENT_SS_ANNE_LEFT = true
require("src.core.Sound").play(game.data, "SS_Anne_Horn")
elseif f.EVENT_GOT_HM01 and ow.player.cellY == 2 then
-- VermilionDockSSAnneLeavesScript: only stepping OFF the ship
-- triggers the departure (wDestinationWarpID == 1 in pokered) --
-- Music_Surfing plays for the sail-away cutscene, smoke puffs
-- drift off the funnel, the horn blows, the ship is erased to
-- open water, and the player is walked off the dock into the
-- city past the guard (VermilionCity's
-- SCRIPT_VERMILIONCITY_PLAYER_EXIT_SHIP walk)
Flags.set(game.save, "EVENT_SS_ANNE_LEFT")
local Music = require("src.core.Music")
Music.stop()
Music.play(game.data, "Music_Surfing")
local function puff(n, cx)
if n <= 0 then return end
ow:startDustAnim(cx, 1, function() puff(n - 1, cx + 2) end)
end
puff(3, 15)
local rows = {}
rows[#rows + 1] = { "wait", 100 }
rows[#rows + 1] = { "play_sound", "SS_Anne_Horn" }
for _, b in ipairs(DOCK_SHIP_BLOCKS) do
rows[#rows + 1] = { "replace_block", b.bx, b.by, b.water }
end
rows[#rows + 1] = { "wait", 30 }
rows[#rows + 1] = { "move_player", "up", 2 }
-- keep Music_Surfing across the city warp (pokered stays on it
-- through the exit-ship walk)
rows[#rows + 1] = { "play_music", "Music_Surfing", { keep = true } }
rows[#rows + 1] = { "warp", "VERMILION_CITY", 18, 31, "up" }
rows[#rows + 1] = { "move_player", "up", 2 }
ow:queueScript(rows)
end
end,
}
+89 -18
View File
@@ -457,6 +457,25 @@ M.ROUTE_24 = {
battleOrDone()
end,
},
-- Route24DefaultScript forces TEXT_ROUTE24_COOLTRAINER_M1 whenever the
-- player stands on (10,15) in front of the recruiter while
-- EVENT_GOT_NUGGET is unset (dbmapcoord 10,15), independent of facing
-- or input -- he has no trainer header, so sight never engages him.
onStep = function(game, ow, x, y)
if game.save.flags.EVENT_GOT_NUGGET then return false end
if not (x == 10 and y == 15) then return false end
if ow.runner:isRunning() or ow.engaging then return false end
local recruiter
for _, npc in ipairs(ow.npcs) do
if npc.def and npc.def.name == "ROUTE24_COOLTRAINER_M1" then
recruiter = npc
break
end
end
if not recruiter then return false end
ow:showMapText("TEXT_ROUTE24_COOLTRAINER_M1", recruiter)
return true
end,
}
-- -------------------------------------------------------------------
@@ -626,12 +645,44 @@ local function e4ExitSeal(flag, closedBlock, openBlock, dontRunText, autoFlag)
}
end
-- Lorelei/Bruno/Agatha EndBattleScript (pokered): EndTrainerBattle then
-- DisplayTextID -> TalkToTrainer, which prints the AfterBattle text on a
-- win. engageTrainer only shows the won line, so wrap talk like Lance /
-- Game Corner Rocket and push header.after immediately after a win.
local function e4LeaderTalk(afterLabel)
return function(game, ow, npc, done)
done = done or function() end
if ow:trainerDefeated(npc) then
local after = text(game)[afterLabel]
if after then push(game, after, done) else done() end
return
end
ow:engageTrainer(npc, function()
if not ow:trainerDefeated(npc) then
done()
return
end
local after = text(game)[afterLabel]
if after then push(game, after, done) else done() end
end)
end
end
M.LORELEIS_ROOM = e4ExitSeal("EVENT_BEAT_LORELEIS_ROOM_TRAINER_0", 0x24, 0x05,
"_LoreleisRoomLoreleiDontRunAwayText", "EVENT_AUTOWALKED_INTO_LORELEIS_ROOM")
M.LORELEIS_ROOM.talk = {
TEXT_LORELEISROOM_LORELEI = e4LeaderTalk("_LoreleisRoomLoreleiAfterBattleText"),
}
M.BRUNOS_ROOM = e4ExitSeal("EVENT_BEAT_BRUNOS_ROOM_TRAINER_0", 0x24, 0x05,
"_BrunosRoomBrunoDontRunAwayText", "EVENT_AUTOWALKED_INTO_BRUNOS_ROOM")
M.BRUNOS_ROOM.talk = {
TEXT_BRUNOSROOM_BRUNO = e4LeaderTalk("_BrunoAfterBattleText"),
}
M.AGATHAS_ROOM = e4ExitSeal("EVENT_BEAT_AGATHAS_ROOM_TRAINER_0", 0x3b, 0x0e,
"_AgathasRoomAgathaDontRunAwayText", "EVENT_AUTOWALKED_INTO_AGATHAS_ROOM")
M.AGATHAS_ROOM.talk = {
TEXT_AGATHASROOM_AGATHA = e4LeaderTalk("_AgathaAfterBattleText"),
}
-- -------------------------------------------------------------------
-- Lance's room (scripts/LancesRoom.asm). Unlike the other three E4
@@ -653,13 +704,21 @@ M.AGATHAS_ROOM = e4ExitSeal("EVENT_BEAT_AGATHAS_ROOM_TRAINER_0", 0x3b, 0x0e,
-- EVENT_LANCES_ROOM_LOCK_DOOR: first crossing seals
-- the door behind the player with SFX_GO_INSIDE
-- (24,16) the entrance staircase -> WalkToLance: an auto-walk
-- (up 12, left 12, down 7, left 6) landing on (6,11).
-- It marches straight across the room's water decor:
-- pokered's CollisionCheckOnLand skips collision
-- entirely while simulated joypad states run, and our
-- scriptMove is collision-free the same way.
-- landing on (6,11). Vanilla WalkToLance_RLEList is
-- up 12 / left 12 / down 7 / left 6 and skips collision
-- through void tiles; our camera follows that literally
-- and briefly shows the empty upper chamber (reads as
-- "Gary's room"). We keep the same landing cell but
-- route along the open-door floor corridor instead.
-- -------------------------------------------------------------------
-- Floor corridor (24,16) -> (6,11) with EVENT_LANCES_ROOM_LOCK_DOOR
-- unset (entrance blocks $31/$32). Exposed for parity tests.
local LANCE_WALK_IN = {
{ "down", 2 }, { "left", 6 }, { "down", 5 }, { "left", 10 },
{ "up", 9 }, { "left", 2 }, { "up", 3 },
}
local function lanceEntranceBlocks(game, ow)
local locked = game.save.flags.EVENT_LANCES_ROOM_LOCK_DOOR
ow:replaceBlock(2, 6, locked and 0x72 or 0x31)
@@ -674,21 +733,24 @@ local function lanceLockDoor(game, ow)
end
local function lanceWalkIn(game, ow)
ow:scriptMove(ow.player, "up", 12, function()
ow:scriptMove(ow.player, "left", 12, function()
ow:scriptMove(ow.player, "down", 7, function()
ow:scriptMove(ow.player, "left", 6, function()
-- the walk lands on (6,11); vanilla's per-frame coord poll
-- then locks the door at once. scriptMove landings do not
-- fire onStep, so lock here.
lanceLockDoor(game, ow)
end)
end)
end)
end)
local i = 0
local function step()
i = i + 1
local seg = LANCE_WALK_IN[i]
if not seg then
-- lands on (6,11); vanilla's per-frame coord poll then locks the
-- door at once. scriptMove landings do not fire onStep, so lock
-- here.
lanceLockDoor(game, ow)
return
end
ow:scriptMove(ow.player, seg[1], seg[2], step)
end
step()
end
M.LANCES_ROOM = {
walkInRoute = LANCE_WALK_IN,
onEnter = function(game, ow)
lanceEntranceBlocks(game, ow)
-- the warp arrival lands ON the staircase trigger, and onStep only
@@ -708,7 +770,14 @@ M.LANCES_ROOM = {
end
if not lance or ow:trainerDefeated(lance) then return false end
lance:facePlayer(ow.player)
ow:engageTrainer(lance, function() end)
-- LancesRoomLanceEndBattleScript DisplayTextID -> TalkToTrainer
-- after-battle text (rival became champion first). engageTrainer
-- only shows the won line, so push the after text on a win.
ow:engageTrainer(lance, function()
if not ow:trainerDefeated(lance) then return end
local after = text(game)._LancesRoomLanceAfterBattleText
if after then push(game, after) end
end)
return true
end
if (x == 5 or x == 6) and y == 11 then
@@ -723,4 +792,6 @@ M.LANCES_ROOM = {
end,
}
M.CELADON_CHIEF_HOUSE = require("data.scripts.celadon_chief_house")
return M
+184 -12
View File
@@ -259,23 +259,183 @@ M.CINNABAR_ISLAND = {
end,
}
-- Pewter's youngster stops you leaving east before Brock is beaten
-- (scripts/PewterCity.asm PewterCityCheckPlayerLeavingEastScript; the
-- original escorts you to the gym, we walk you back a step)
-- Pewter's youngster stops you leaving east before Brock is beaten and
-- escorts you to the gym (scripts/PewterCity.asm
-- PewterCityCheckPlayerLeavingEastScript /
-- PewterCityYoungsterShowsPlayerGymScript, engine/overworld/auto_movement.asm
-- PewterGymGuyMovementScriptPointerTable, engine/events/pewter_guys.asm
-- PewterGymGuyCoords). Same lockstep style as Oak's lab escort: the
-- youngster walks RLEList_PewterGymGuy while the player plays the
-- reverse of RLEList_PewterGymPlayer with a PewterGuys positioning
-- preamble. Ends at (11,18) / (12,18) by the gym, not phasing through
-- the building.
local pewterEscort = {}
-- RLEList_PewterGymGuy (NPC directions play forward)
pewterEscort.guySteps = {
"down", "down",
"left", "left", "left", "left", "left", "left", "left", "left",
"left", "left", "left", "left", "left", "left", "left",
"up", "up", "up", "up", "up",
"left", "left", "left", "left", "left", "left", "left", "left",
"left", "left", "left",
"down", "down", "down", "down", "down",
"right", "right", "right",
}
-- Walk home: reverse of guySteps with opposite facings (gym → spawn).
do
local opp = { up = "down", down = "up", left = "right", right = "left" }
local ret = {}
for i = #pewterEscort.guySteps, 1, -1 do
ret[#ret + 1] = opp[pewterEscort.guySteps[i]]
end
pewterEscort.guyReturnSteps = ret
end
-- RLEList_PewterGymPlayer before reverse / PewterGuys (NO_INPUT, RIGHT×2,
-- DOWN×5, LEFT×11, UP×5, LEFT×15)
pewterEscort.playerRle = {
"NO",
"right", "right",
"down", "down", "down", "down", "down",
"left", "left", "left", "left", "left", "left", "left", "left",
"left", "left", "left",
"up", "up", "up", "up", "up",
"left", "left", "left", "left", "left", "left", "left", "left",
"left", "left", "left", "left", "left", "left", "left",
}
-- PewterGymGuyCoords: (x, y) -> positioning moves written after the RLE
-- (played in reverse; $00 pauses are one overworld frame ≈ 1/8 tile for
-- the NPC, so eight of them ≈ one guy head-start step)
pewterEscort.preambles = {
["34,16"] = { "left", "down", "down", "right" },
["35,17"] = { "left", "down", "right", "left" },
["37,18"] = { "left", "left", "left",
"NO", "NO", "NO", "NO", "NO", "NO", "NO", "NO" },
["37,19"] = { "left", "left", "up", "left" },
["36,17"] = { "left", "down", "left",
"NO", "NO", "NO", "NO", "NO", "NO", "NO", "NO" },
}
-- Realized player path for a trigger tile: PewterGuys overwrites the
-- last RLE byte and appends the preamble, then simulated joypad plays
-- high→low (reverse). Leading NO×8 collapses to guyHeadStart=1; the
-- trailing end-of-list NO is dropped (one-frame pause).
function pewterEscort.playerPlan(x, y)
local pre = pewterEscort.preambles[x .. "," .. y]
if not pre then return nil end
local buf = {}
for i, d in ipairs(pewterEscort.playerRle) do buf[i] = d end
buf[#buf] = pre[1]
for i = 2, #pre do buf[#buf + 1] = pre[i] end
local path = {}
for i = #buf, 1, -1 do path[#path + 1] = buf[i] end
local head = 0
while path[head + 1] == "NO" do head = head + 1 end
local tail = #path
while tail > head and path[tail] == "NO" do tail = tail - 1 end
local steps = {}
for i = head + 1, tail do steps[#steps + 1] = path[i] end
return { steps = steps, guyHeadStart = math.floor(head / 8) }
end
local function pewterGymEscort(game, ow)
if ow.runner:isRunning() or #ow.scriptMoves > 0 then return end
local x, y = ow.player.cellX, ow.player.cellY
local plan = pewterEscort.playerPlan(x, y)
local Music = require("src.core.Music")
local t = text(game)
local follow = t._PewterCityYoungsterYoureATrainerFollowMeText
or "You're a trainer\nright? BROCK's\nlooking for new\nchallengers!\nFollow me!"
-- PewterGuys only has entries for five tiles; an unmatched talk tile
-- (e.g. (36,16) east of him) just gets the follow-me line, same as a
-- failed coords lookup would refuse to arm the walk.
if not plan then
push(game, follow)
return
end
local guy = ow:npcByIndex(5) -- PEWTERCITY_YOUNGSTER
local guySteps = pewterEscort.guySteps
local head = plan.guyHeadStart
-- After the walk: face the player, restore map music, "Go take on
-- BROCK", then retrace RLEList_PewterGymGuy back to his spawn (35,16).
-- (pokered teleports him via MovementData_PewterGymGuyExit; we walk
-- the same route home instead. Brock victory still HideObject's him.)
local function walkHome()
if not guy then return end
local ret = pewterEscort.guyReturnSteps
local i = 0
local function tick()
i = i + 1
if not ret[i] then
guy.facing = "down"
return
end
ow:scriptMove(guy, ret[i], 1, tick)
end
tick()
end
local function afterWalk()
if guy then guy.facing = "left" end
Music.playMap(game.data, "PEWTER_CITY")
push(game, t._PewterCityYoungsterGoTakeOnBrockText
or "Go take on BROCK\nat the GYM first!", walkHome)
end
local function lockstep()
local i = 0
local function tick()
i = i + 1
local ps = plan.steps[i]
if not ps then
afterWalk()
return
end
local gs = guySteps[head + i]
if guy and gs then ow:scriptMove(guy, gs, 1) end
ow:scriptMove(ow.player, ps, 1, tick)
end
tick()
end
local function beginWalk()
Music.play(game.data, "Music_MuseumGuy")
if guy and head > 0 then
local h = 0
local function headTick()
h = h + 1
if h > head then lockstep(); return end
ow:scriptMove(guy, guySteps[h], 1, headTick)
end
headTick()
else
lockstep()
end
end
push(game, follow, beginWalk)
end
M.PEWTER_CITY = {
escort = pewterEscort,
-- PewterCityYoungsterText: talking also arms the gym escort script
talk = {
TEXT_PEWTERCITY_YOUNGSTER = function(game, ow, npc, done)
pewterGymEscort(game, ow)
if done then done() end
end,
},
onStep = function(game, ow, x, y)
if game.save.flags.EVENT_BEAT_BROCK then return false end
if ow.runner:isRunning() or #ow.scriptMoves > 0 then return false end
if not inCoords({ { 35, 17 }, { 36, 17 }, { 37, 18 }, { 37, 19 } }, x, y) then
return false
end
local t = text(game)
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()
ow:scriptMove(ow.player, "left", 1)
end)
end)
pewterGymEscort(game, ow)
return true
end,
}
@@ -453,10 +613,22 @@ local function bikeGateGuard(coords, stopText, explainText)
return function(game, ow, x, y)
if game.save.inventory.BICYCLE then return false end
if not inCoords(coords, x, y) then return false end
-- walk the player up to the tile beside the counter, no further:
-- (matchedY - closestY) tiles, 0 when already next to it
-- (Route16Gate1FDefaultScript's wCoordIndex-1). Forcing a fixed one
-- tile up from the counter-adjacent row shoved the player onto the
-- impassable desk and boxed them in.
local closestY = coords[1][2]
for _, c in ipairs(coords) do
if c[2] < closestY then closestY = c[2] end
end
local dist = y - closestY
local t = text(game)
push(game, t[stopText] or "Hey! Wait up!", function()
push(game, t[explainText] or "You need a\nBICYCLE for\nCYCLING ROAD!", function()
ow:scriptMove(ow.player, "up", 1)
if dist > 0 then
ow:scriptMove(ow.player, "up", dist)
end
end)
end)
return true
+11 -3
View File
@@ -2,8 +2,9 @@
-- "OPP_CLASS#partyIndex" (the object_event trainer args). Hand-ported
-- from the leaders'/bosses' text_asm victory scripts:
-- gym badges: scripts/PewterGym.asm ... ViridianGym.asm
-- Rocket Hideout Giovanni: his Silph Scope is an item ball next to him
-- (data/maps/objects/RocketHideoutB4F.asm), so no reward entry needed.
-- Rocket Hideout Giovanni: Silph Scope is a hidden item ball revealed by
-- ShowObject in RocketHideoutB4FBeatGiovanniScript (ported as the
-- TEXT_ROCKETHIDEOUTB4F_GIOVANNI talk handler in story3.lua).
-- The TM each gym leader hands out afterwards is also ported.
--
-- `deactivate` lists the EVENT_BEAT_* flags each gym's victory script
@@ -21,9 +22,16 @@ local function range(prefix, first, last)
end
return {
-- PewterGym.asm .gymVictory also HideObject TOGGLE_GYM_GUY
-- (PEWTERCITY_YOUNGSTER) and TOGGLE_ROUTE_22_RIVAL_1 so the east-exit
-- escort NPC and the first Route 22 rival stay gone after the badge.
["OPP_BROCK#1"] = { badge = "BOULDERBADGE", flag = "EVENT_BEAT_BROCK",
item = "TM_BIDE",
deactivate = { "EVENT_BEAT_PEWTER_GYM_TRAINER_0" } },
deactivate = { "EVENT_BEAT_PEWTER_GYM_TRAINER_0" },
hide = {
{ "PEWTER_CITY", "PEWTERCITY_YOUNGSTER" },
{ "ROUTE_22", "ROUTE22_RIVAL1" },
} },
["OPP_MISTY#1"] = { badge = "CASCADEBADGE", flag = "EVENT_BEAT_MISTY",
item = "TM_BUBBLEBEAM",
deactivate = range("EVENT_BEAT_CERULEAN_GYM_TRAINER_", 0, 1) },
+34 -1
View File
@@ -6,4 +6,37 @@ work for true parity. Faithfully-ported behavior is documented in
docs/behavior-porting-notes.md; deliberate additions beyond the original
are in docs/new-features.md.
None currently.
## Reimplemented unused Prof. Oak and Rocket Chief battles
The original ROM defines trainer data for `PROF_OAK` and `CHIEF`
(`data/trainers/parties.asm`) but never attaches either to an NPC, so
both battles are unreachable in the real game. This project makes them
fightable after the Hall of Fame:
- Prof. Oak battles you in Pallet Town once `EVENT_BEAT_CHAMPION_RIVAL`
is set, using `ProfOakData`'s three starter-matched teams (the team is
picked by the type that counters your starter, mirroring the rival).
- The Celadon Game Corner Chief battles you in his house post-game.
`ChiefData` is empty in the ROM, so `OPP_CHIEF` is given a
reconstructed party.
This is an intentional divergence: neither battle can be triggered in the
original game.
## Reimplemented unused Silph Co. card-key doors
`engine/events/card_key.asm` and the unused `CardKeyTable1/2/3` coordinate
lists (`data/events/card_key_coords.asm`) describe locked doors for Silph
Co. floors 2F-11F, but no retail `.blk` map layout ever places the closed
door block at those coordinates, so the card key check is dead code in
the original game. This project stamps the closed door block (`$54`/`$5f`
on floors 2F-10F, `$20` on 11F) over each of the 20 door coordinates on
map load, and swaps it for the open block once that door's
`EVENT_SILPH_CO_n_UNLOCKED_DOORn` flag is set (using the key from a Team
Rocket grunt, as in the original's unused design).
This is an intentional divergence: the doors are not visible or
functional in the original game. The door layout lives in
`tools/rom_manifest.json` (`field.cardKeyDoors.closedDoors`), hand-ported
since no retail ROM data encodes it; `src/import/RomExtractor.lua` copies
it straight through on ROM import.
+30
View File
@@ -17,3 +17,33 @@ Regenerate the reference straight into a wiki checkout:
```sh
luajit tools/gen_registry_docs.lua ../pokemon-gen1-recomp-project.wiki
```
## Developer console
Boot with developer mode on to unlock the in-game console and hot-reload
hotkeys. Either set `POKEPORT_DEV=1` in the environment or pass
`--developer` on the command line:
```sh
love . --developer
```
While developer mode is active:
- `` ` `` (backtick) opens the console overlay — a Lua REPL with `game`,
`data` and `mods` in scope. Press `` ` `` again to close it.
- `F5` hot-reloads mods and asset caches without restarting.
The console understands these verbs (anything else is evaluated as Lua):
- `warp MAP [x y]` — teleport to a map (default cell 5,5).
- `give ID [n|level]` — add an item (count) or a Pokémon (level).
- `flag NAME [on|off]` — read or set an event flag.
- `party` — dump the current party.
- `mods` — list loaded mods and their state.
- `reload` — hot-reload mods (same as `F5`).
- `trace PAT | trace off` — trace events/hooks matching a glob pattern.
- `help` — list the verbs.
Developer mode also arms the mod loader's dev tripwire, which flags mods
that reach outside their permission set.
+29 -12
View File
@@ -42,8 +42,10 @@ Game Boy equivalent:
rows above the player recede and rows below come toward the viewer. Only
things that actually *stand* on the ground draw as upright billboards,
unscaled and pixel-identical to flat mode: the player, NPCs, item balls,
and the screen-anchored FX attached to them (heal machine glow, emote
bubbles, the fishing rod, the FLY bird). An earlier revision tried
and the standing FX attached to them (emote bubbles, the fishing rod,
the FLY bird). The Poké Center heal-machine overlay stays on the ground
plane with the machine tiles (it is OAM glued to a BG graphic, not a
standing sprite). An earlier revision tried
billboarding buildings/trees/signs too (cutting them out of the ground
per hand-curated per-tileset tables); that chased an endless tail of
special cases, dense tree canopy, fences fused into grass, building
@@ -68,19 +70,31 @@ Game Boy equivalent:
## Colors mode
The `2` key (and the Options menu COLORS row) cycles the global shade-remap
display mode through **GBC → OG → OG INV → GBC INV → CLASSIC → GBC**:
The `2` key (and the Options menu COLORS row) cycles the display mode
through **OG RED → SGB → RED++ → OG → OG INV → SGB INV → CLASSIC → OG RED**.
The first three are the real colorizations; the rest are DMG-shade novelties:
- **GBC** (default): current SGB / GBC zone palettes.
- **OG RED**: the Game Boy Color boot-ROM look for Pokemon Red -- one global
red BG palette + one green OBJ palette, every map, no per-map variation
(Pokemon Red has no CGB code, so on a GBC the boot ROM colors it globally).
The player/NPCs stay green over the red terrain via the OBP bake +
post-zone redraw (`PaletteFX.GBC_BG` / `GBC_OBJ`).
- **SGB** (default): the per-map Super Game Boy region palettes
(`data/sgb/sgb_palettes.asm`). Sprites tint with the region palette, as on
real SGB. (This is the mode formerly mislabeled "GBC".)
- **RED++**: pokered-gbc SuperPalettes -- real per-tile GBC coloring plus
per-species mon colors (`data/palettes_gbc.lua`).
- **OG**: force the four DMG grays (colorization off).
- **OG INV**: inverted DMG grays.
- **GBC INV**: each SGB zone palette with shade order reversed.
- **SGB INV**: each SGB zone palette with shade order reversed.
- **CLASSIC**: original Game Boy pea-soup greens
(`#9BBC0F` / `#8BAC0F` / `#306230` / `#0F380F`).
The transform is applied centrally in `PaletteFX.sendColors`, so it covers
overworld, menus, battles, and tilt upright billboards. Persisted as
`save.options.colors`.
The shade-remap transform is applied centrally in `PaletteFX.sendColors`, so
it covers overworld, menus, battles, and tilt upright billboards. OG RED's
global BG palette is supplied by `OverworldState:overworldBgColors` (per-map
override in the overworld pass). Persisted as `save.options.colors`; the
`gbc` / `gbc_inv` save ids are kept for back-compat under the new labels.
## GBC FX
@@ -132,7 +146,10 @@ migrated once into `options.lua` on load.
- Music / SFX volume
- Music Filter
- OG GLITCHES on / off (Gen 1 quirks vs. modern-clean battle rules)
- COLORS (GBC / RED++ / OG / OG INV / GBC INV / CLASSIC), also hotkey `2`
(RED++ uses pokered-gbc SuperPalettes + per-species mon colors)
- COLORS (OG RED / SGB / RED++ / OG / OG INV / SGB INV / CLASSIC), also
hotkey `2` (OG RED = GBC boot-ROM look; RED++ uses pokered-gbc
SuperPalettes + per-species mon colors)
- TILT (OFF / 15 / 35 / 50), also hotkey `3` while free-roaming
- GBC FX (OFF / 1 / 2 / 3 / 4), also hotkey `5`
- 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`).
+153 -13
View File
@@ -26,7 +26,19 @@ local function scriptedIterations()
return math.max(1, math.floor(require("src.core.GameSpeed").clamp(speedOverride)))
end
local function bootGame()
local function bootGame(version)
-- The launcher hands us the chosen game (Red / Blue); scripted and headless
-- runs fall back to POKEPORT_VERSION, then Red. Set the active version and
-- overlay its extracted cache BEFORE anything requires generated data, so
-- data/generated + assets/generated resolve to that version's files.
local GameVersion = require("src.core.GameVersion")
GameVersion.set(version or os.getenv("POKEPORT_VERSION") or "red")
require("src.import.CacheFs").mountVersion(GameVersion.get())
if love.window and love.window.setTitle then
local Version = require("src.core.Version")
love.window.setTitle(Version.title(
GameVersion.info().displayName .. " (Gen 1 Recompilation Project)"))
end
Game = require("src.core.Game")
Game:load()
if os.getenv("POKEPORT_AUTOPILOT") then
@@ -48,6 +60,8 @@ function love.load(args)
for i, a in ipairs(args or {}) do
if a == "--editor" then
editorMode = true
elseif a == "--developer" then
_G.POKEPORT_DEV_MODE = true
elseif a == "--save" and args[i + 1] and args[i + 1] ~= "" then
savePath = args[i + 1]
elseif a == "--speed" and tonumber(args[i + 1]) then
@@ -66,20 +80,46 @@ function love.load(args)
end
local RomImporter = require("src.import.RomImporter")
if os.getenv("POKEPORT_FORCE_IMPORT") == "1" or not RomImporter.isReady() then
Importer = RomImporter.new(function()
if os.getenv("POKEPORT_IMPORT_ONLY") == "1" then
love.event.quit()
return
end
Importer = nil
bootGame()
end)
local importPath = os.getenv("POKEPORT_IMPORT_ROM")
if importPath then Importer:startPath(importPath) end
local forceImport = os.getenv("POKEPORT_FORCE_IMPORT") == "1"
local importPath = os.getenv("POKEPORT_IMPORT_ROM")
-- Scripted / headless runs pick their game from POKEPORT_VERSION (default
-- Red); the launcher's per-column choice does not apply to them.
local scriptedVersion = os.getenv("POKEPORT_VERSION") or "red"
local ready = RomImporter.isReady(scriptedVersion)
-- Scripted / headless runs have to reach the game with no human pressing
-- Play: an autopilot, a frame driver, an import-only build step, or an
-- explicit ROM path all bypass the interactive launcher and keep today's
-- import-then-boot (or boot-straight-in) behavior.
local scripted = os.getenv("POKEPORT_AUTOPILOT") or os.getenv("POKEPORT_DRIVER")
or os.getenv("POKEPORT_IMPORT_ONLY") == "1" or importPath ~= nil
if scripted then
if forceImport or not ready then
-- The importer detects the dropped/loaded ROM's version by SHA-1 and
-- passes it to onComplete; boot that version.
Importer = RomImporter.new(function(version)
if os.getenv("POKEPORT_IMPORT_ONLY") == "1" then
love.event.quit()
return
end
Importer = nil
bootGame(version or scriptedVersion)
end)
if importPath then Importer:startPath(importPath) end
return
end
bootGame(scriptedVersion)
return
end
bootGame()
-- Interactive: the launcher always runs. Red and Blue are each live: a
-- column shows Play when that game's ROM is already imported, or Choose ROM
-- / drag-drop when it is not (Yellow is still a placeholder). Any dropped
-- .gb is routed to Red or Blue by its SHA-1; pressing Play boots that game.
Importer = RomImporter.new(function(version)
Importer = nil
bootGame(version)
end, { launcher = true, forceImport = forceImport })
end
function love.update(dt)
@@ -171,6 +211,31 @@ function love.gamepadaxis(joystick, axis, value)
Game:gamepadaxis(joystick, axis, value)
end
function love.joystickremoved(joystick)
if editorMode then return end
if Importer then return end
Game:joystickremoved(joystick)
end
-- f is true on focus gained, false on focus lost (e.g. alt-tab). A held
-- direction's key-up can be delivered to the OS instead of the game while
-- unfocused, so reset input on either transition rather than trust it.
function love.focus(f)
if editorMode then return end
if Importer then
if Importer.focus then Importer:focus(f) end
return
end
Game:focus(f)
end
-- v is true when the window becomes visible again, false on minimize.
function love.visible(v)
if editorMode then return end
if Importer then return end
Game:visible(v)
end
function love.touchpressed(id, x, y, dx, dy, pressure)
if editorMode then return end
if Importer then return Importer:mousepressed(x, y, 1) end
@@ -234,3 +299,78 @@ function love.filedropped(file)
end
if Importer then Importer:filedropped(file) end
end
local function pacingEnabled()
if os.getenv("POKEPORT_AUTOPILOT") then return false end
if os.getenv("POKEPORT_DRIVER") then return false end
if os.getenv("POKEPORT_IMPORT_ONLY") == "1" then return false end
return true
end
function love.run()
if love.load then love.load(love.arg.parseGameArguments(arg), arg) end
-- don't let love.load's cost land in the first frame's dt
if love.timer then love.timer.step() end
local FrameCap = require("src.core.FrameCap")
local paced = pacingEnabled()
-- The deadline the next present() should not beat. Carried forward one
-- budget per frame so pacing stays even instead of drifting with the
-- per-frame sleep-granularity jitter.
local nextFrame = love.timer and love.timer.getTime() or 0
local dt = 0
return function()
-- process events
if love.event then
love.event.pump()
for name, a, b, c, d, e, f in love.event.poll() do
if name == "quit" then
if not love.quit or not love.quit() then
return a or 0
end
end
love.handlers[name](a, b, c, d, e, f)
end
end
-- update dt
if love.timer then dt = love.timer.step() end
-- call update and draw
if love.update then love.update(dt) end
if love.graphics and love.graphics.isActive() then
love.graphics.origin()
love.graphics.clear(love.graphics.getBackgroundColor())
if love.draw then love.draw() end
love.graphics.present()
end
if love.timer then
if paced then
-- Sleep out the remainder of the frame budget, measured from the
-- carried deadline, in small chunks so the OS timer stays
-- responsive. vsync is untouched: when it already paces slower
-- than the cap the remainder is <= 0 and this rounds to a no-op.
local budget = 1 / FrameCap.current
nextFrame = nextFrame + budget
local now = love.timer.getTime()
-- A stall (alt-tab, a GC pause, a blocked import) can leave the
-- deadline more than a full budget in the past; re-anchor to now so
-- we pace the next frame rather than burst uncapped to catch up.
if now - nextFrame > budget then
nextFrame = now
end
while true do
local remaining = nextFrame - love.timer.getTime()
if remaining <= 0 then break end
love.timer.sleep(remaining < 0.001 and remaining or 0.001)
end
else
love.timer.sleep(0.001)
end
end
end
end
+15 -19
View File
@@ -27,17 +27,17 @@ refresh is safe, just rebuild.
## Build
```bash
# Debug APK (default Android debug keystore)
# Build the APK
scripts/build_android.sh
# Release APK (signing is manual, see below)
scripts/build_android.sh --release
# Build the APK, setting app.version_name/app.version_code to match a release
scripts/build_android.sh --version 0.2.5
# Zip game.love + branding only (no Android SDK required)
scripts/build_android.sh --package-only
```
Or via `scripts/build.sh android`.
Or via `scripts/build.sh android [--version X.Y.Z]`.
The embedded `game.love` deliberately excludes `data/generated/`,
`assets/generated/`, and any ROM. It contains the first-boot Lua importer and
@@ -59,12 +59,10 @@ Set `ANDROID_SDK_ROOT` (or `ANDROID_HOME`), or let the script write
`local.properties` when it finds `~/Library/Android/sdk`.
Gradle flavor used: **`embedNoRecord`** (game fused into the APK, no microphone).
Build task: `assembleEmbedNoRecordDebug`.
- Debug task: `assembleEmbedNoRecordDebug`
- Release task: `assembleEmbedNoRecordRelease`
APKs land under `app/build/outputs/apk/embedNoRecord/{debug,release}/`.
`scripts/build_android.sh` also copies the built APK(s) to `dist/android/{debug,release}/`.
The APK lands under `app/build/outputs/apk/embedNoRecord/debug/`.
`scripts/build_android.sh` also copies it to `dist/android/debug/`.
### Payload path
@@ -79,17 +77,15 @@ scripts, tests, and mobile build sources are excluded.
| `app.application_id` | `com.theboisclub.pokemonred` |
| `app.name` | Pokemon Red |
| `app.orientation` | `portrait` |
| `app.version_name` / `app.version_code` | set from `--version X.Y.Z` (code = major*10000 + minor*100 + patch); left as-is if `--version` is omitted |
| Permissions | INTERNET / RECORD_AUDIO / WRITE_EXTERNAL_STORAGE stripped; VIBRATE + BLUETOOTH kept |
## Releases
`.github/workflows/release.yml` builds the APK with `--version` set to the
release version and publishes it alongside the macOS/Windows/Linux builds as
`PokemonRed-<version>-android.apk`.
## Signing
- **Debug**: default Android debug keystore (no setup).
- **Release**: out-of-band. Create a keystore yourself and wire it into
`app/build.gradle`, **do not commit keystores or passwords**.
Example (placeholder only):
```bash
keytool -genkey -v -keystore /path/to/pokemonred-release.jks \
-alias pokemonred -keyalg RSA -keysize 2048 -validity 10000
```
Signed with the default Android keystore (no setup required).
+3 -1
View File
@@ -6,7 +6,9 @@
#app.name=LÖVE for Android
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_name=11.5a
@@ -1,4 +1,8 @@
*.[oa]
# Prebuilt vendored static libs Android.mk links against directly (not
# built by the Gradle/NDK build itself) -- must stay tracked or a fresh
# checkout fails ndk-build with "LOCAL_SRC_FILES points to a missing file".
!android/*/libluajit.a
*.so
*.obj
*.lib
@@ -36,6 +36,7 @@ demos
*~
Makefile*
config*
!src/common/config.h
libtool
/platform/unix/ar-lib
/platform/unix/compile
@@ -183,6 +183,18 @@ void vibrate(double seconds)
env->DeleteLocalRef(activity);
}
bool showFilePicker()
{
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = env->FindClass("org/love2d/android/GameActivity");
jmethodID method = env->GetStaticMethodID(activity, "showRomFilePicker", "()Z");
jboolean result = env->CallStaticBooleanMethod(activity, method);
env->DeleteLocalRef(activity);
return result;
}
/*
* Helper functions for the filesystem module
*/
@@ -59,6 +59,14 @@ bool openURL(const std::string &url);
void vibrate(double seconds);
/**
* Shows the system's "pick a document" UI (Storage Access Framework).
* Returns true if the picker was launched; the picked file (if any) is
* copied asynchronously by GameActivity.onActivityResult into the app's
* external save directory, not returned here -- see src/import/RomImporter.lua.
**/
bool showFilePicker();
/*
* Helper functions for the filesystem module
*/
@@ -0,0 +1,178 @@
/**
* Copyright (c) 2006-2023 LOVE Development Team
*
* This software is provided 'as-is', without any express or implied
* warranty. In no event will the authors be held liable for any damages
* arising from the use of this software.
*
* Permission is granted to anyone to use this software for any purpose,
* including commercial applications, and to alter it and redistribute it
* freely, subject to the following restrictions:
*
* 1. The origin of this software must not be misrepresented; you must not
* claim that you wrote the original software. If you use this software
* in a product, an acknowledgment in the product documentation would be
* appreciated but is not required.
* 2. Altered source versions must be plainly marked as such, and must not be
* misrepresented as being the original software.
* 3. This notice may not be removed or altered from any source distribution.
**/
#ifndef LOVE_CONFIG_H
#define LOVE_CONFIG_H
// Platform stuff.
#if defined(WIN32) || defined(_WIN32)
# define LOVE_WINDOWS 1
// If _USING_V110_SDK71_ is defined it means we are using the xp toolset.
# if defined(_MSC_VER) && (_MSC_VER >= 1700) && !_USING_V110_SDK71_
# include <winapifamily.h>
# if WINAPI_FAMILY_PARTITION(WINAPI_PARTITION_APP) && !WINAPI_FAMILY_PARTITION(WINAPI_PARTITION_DESKTOP)
# define LOVE_WINDOWS_UWP 1
# define LOVE_NO_MODPLUG 1
# define LOVE_NOMPG123 1
# endif
# endif
#endif
#if defined(linux) || defined(__linux) || defined(__linux__)
# define LOVE_LINUX 1
#endif
#if defined(__ANDROID__)
# define LOVE_ANDROID 1
#endif
#if defined(__APPLE__)
# include <TargetConditionals.h>
# if TARGET_OS_IPHONE
# define LOVE_IOS 1
# elif TARGET_OS_MAC
# define LOVE_MACOSX 1
# endif
#endif
#if defined(__FreeBSD__) || defined(__NetBSD__) || defined(__OpenBSD__)
// I know it's not linux, but it seems most "linux-only" code is bsd-compatible
# define LOVE_LINUX 1
#endif
// Endianness.
#if defined(__ppc__) || defined(__ppc) || defined(__powerpc__) || defined(__powerpc)
# define LOVE_BIG_ENDIAN 1
#else
# define LOVE_LITTLE_ENDIAN 1
#endif
// SSE instructions.
#if defined(__SSE__)
# define LOVE_SIMD_SSE
#elif defined(_MSC_VER)
# if defined(_M_AMD64) || defined(_M_X64)
# define LOVE_SIMD_SSE
# elif _M_IX86_FP
# define LOVE_SIMD_SSE
# endif
#endif
// NEON instructions.
#if defined(__ARM_NEON)
# define LOVE_SIMD_NEON
#endif
// Warnings.
#ifndef _CRT_SECURE_NO_WARNINGS
# define _CRT_SECURE_NO_WARNINGS
#endif
// Preferably, and ironically, this macro should go unused.
#ifndef LOVE_UNUSED
# define LOVE_UNUSED(x) (void)sizeof(x)
#endif
// Warn on unused return values
#ifdef __GNUC__
# define LOVE_WARN_UNUSED __attribute__((warn_unused_result))
#elif _MSC_VER
# define LOVE_WARN_UNUSED _Check_return_
#else
# define LOVE_WARN_UNUSED
#endif
#ifndef LOVE_BUILD
# define LOVE_BUILD
# define LOVE_BUILD_STANDALONE
# define LOVE_BUILD_EXE
//# define LOVE_BUILD_DLL
#endif
// DLL-stuff.
#if defined(_MSC_VER) || defined(__MINGW32__)
# define LOVE_EXPORT __declspec(dllexport)
#elif defined(__GNUC__) || defined(__clang__)
# define LOVE_EXPORT __attribute__((visibility("default")))
#else
# define LOVE_EXPORT
#endif
#if defined(LOVE_WINDOWS)
#ifndef LOVE_WINDOWS_UWP
# define LOVE_LEGENDARY_CONSOLE_IO_HACK
#endif // LOVE_WINDOWS_UWP
#ifndef __MINGW32__
# define NOMINMAX
#endif
#endif
#if defined(LOVE_MACOSX) || defined(LOVE_IOS)
# define LOVE_LEGENDARY_APP_ARGV_HACK
#endif
#if defined(LOVE_ANDROID) || defined(LOVE_IOS)
# define LOVE_LEGENDARY_ACCELEROMETER_AS_JOYSTICK_HACK
#endif
// Autotools config.h
#ifdef HAVE_CONFIG_H
# include <../config.h>
# undef VERSION
# ifdef WORDS_BIGENDIAN
# undef LOVE_LITTLE_ENDIAN
# define LOVE_BIG_ENDIAN 1
# else
# undef LOVE_BIG_ENDIAN
# define LOVE_LITTLE_ENDIAN 1
# endif
#else
# define LOVE_ENABLE_LOVE
# define LOVE_ENABLE_AUDIO
# define LOVE_ENABLE_DATA
# define LOVE_ENABLE_EVENT
# define LOVE_ENABLE_FILESYSTEM
# define LOVE_ENABLE_FONT
# define LOVE_ENABLE_GRAPHICS
# define LOVE_ENABLE_IMAGE
# define LOVE_ENABLE_JOYSTICK
# define LOVE_ENABLE_KEYBOARD
# define LOVE_ENABLE_MATH
# define LOVE_ENABLE_MOUSE
# define LOVE_ENABLE_PHYSICS
# define LOVE_ENABLE_SOUND
# define LOVE_ENABLE_SYSTEM
# define LOVE_ENABLE_THREAD
# define LOVE_ENABLE_TIMER
# define LOVE_ENABLE_TOUCH
# define LOVE_ENABLE_VIDEO
# define LOVE_ENABLE_WINDOW
# define LOVE_ENABLE_ENET
# define LOVE_ENABLE_LUASOCKET
# define LOVE_ENABLE_LUA53
#endif
// Check we have a sane configuration
#if !defined(LOVE_WINDOWS) && !defined(LOVE_LINUX) && !defined(LOVE_IOS) && !defined(LOVE_MACOSX) && !defined(LOVE_ANDROID)
# error Could not detect target platform
#endif
#if !defined(LOVE_LITTLE_ENDIAN) && !defined(LOVE_BIG_ENDIAN)
# error Could not detect endianness
#endif
#endif // LOVE_CONFIG_H
@@ -186,6 +186,18 @@ bool Filesystem::setIdentity(const char *ident, bool appendToPath)
save_path_full = storage_path + std::string("/save/") + save_identity;
// love::android::mkdir is a single mkdir(), not mkdir -p: on a genuinely
// first-ever launch (nothing has touched this app's external-files dir
// before) save_directory doesn't exist yet either, so creating
// save_path_full in one step fails with ENOENT and PHYSFS_mount below
// silently never mounts anything for the rest of this process -- not
// just the save dir, but everything routed through it (save.lua/
// options.lua, the ROM-derived asset cache, RomImporter's Android
// folder scan). Ensure each level exists in order instead.
if (!love::android::directoryExists(save_directory.c_str()) &&
!love::android::mkdir(save_directory.c_str()))
SDL_Log("Error: Could not create save directory %s!", save_directory.c_str());
if (!love::android::directoryExists(save_path_full.c_str()) &&
!love::android::mkdir(save_path_full.c_str()))
SDL_Log("Error: Could not create save directory %s!", save_path_full.c_str());
@@ -338,6 +350,26 @@ bool Filesystem::setupWriteDirectory()
std::string temp_writedir = getDriveRoot(save_path_full);
std::string temp_createdir = skipDriveRoot(save_path_full);
#ifdef LOVE_ANDROID
// getUserDirectory() falls back to $HOME/getpwuid() (physfs_platform_posix.c),
// which is meaningless on Android and unrelated to save_path_full (an
// SDL_AndroidGet*StoragePath() subdirectory -- see setIdentity above), so
// the generic check below never matches and falls through to setting the
// write dir to the drive root ("/"), which no Android app can write to.
// Anchor to the real Android storage root instead.
std::string androidStorageRoot = isAndroidSaveExternal()
? SDL_AndroidGetExternalStoragePath() : SDL_AndroidGetInternalStoragePath();
if (save_path_full.find(androidStorageRoot) == 0)
{
temp_writedir = androidStorageRoot;
temp_createdir = save_path_full.substr(androidStorageRoot.length());
size_t startpos = temp_createdir.find_first_not_of('/');
if (startpos != std::string::npos)
temp_createdir = temp_createdir.substr(startpos);
}
else
#endif
// On some sandboxed platforms, physfs will break when its write directory
// is the root of the drive and it tries to create a folder (even if the
// folder's path is in a writable location.) If the user's home folder is
@@ -180,6 +180,15 @@ void System::vibrate(double seconds) const
#endif
}
bool System::pickFile() const
{
#ifdef LOVE_ANDROID
return love::android::showFilePicker();
#else
return false;
#endif
}
bool System::hasBackgroundMusic() const
{
#if defined(LOVE_ANDROID)
@@ -106,6 +106,15 @@ public:
*/
virtual void vibrate(double seconds) const;
/**
* Shows the platform's native "pick a file" UI, if one is available.
* Android only for now; the result (if any) is not returned here -- see
* love::android::showFilePicker and src/import/RomImporter.lua.
*
* @return Whether the picker was shown.
**/
virtual bool pickFile() const;
/**
* Gets if the user is playing music on background.
* Throws an exception on unsupported platforms.
@@ -95,6 +95,12 @@ int w_vibrate(lua_State *L)
return 0;
}
int w_pickFile(lua_State *L)
{
luax_pushboolean(L, instance()->pickFile());
return 1;
}
int w_hasBackgroundMusic(lua_State *L)
{
lua_pushboolean(L, instance()->hasBackgroundMusic());
@@ -110,6 +116,7 @@ static const luaL_Reg functions[] =
{ "getPowerInfo", w_getPowerInfo },
{ "openURL", w_openURL },
{ "vibrate", w_vibrate },
{ "pickFile", w_pickFile },
{ "hasBackgroundMusic", w_hasBackgroundMusic },
{ 0, 0 }
};
@@ -61,6 +61,13 @@ public class GameActivity extends SDLActivity {
protected final int[] recordAudioRequestDummy = new int[1];
public static final int EXTERNAL_STORAGE_REQUEST_CODE = 2;
public static final int RECORD_AUDIO_REQUEST_CODE = 3;
public static final int ROM_PICKER_REQUEST_CODE = 4;
// Mirrors conf.lua's t.identity ("pokemon-love2d"): where the picked ROM
// is dropped so RomImporter's existing folder scan finds it -- see
// src/import/RomImporter.lua and Filesystem::setIdentity (sets Android's
// save directory to getExternalFilesDir()/save/<identity>).
private static final String ROM_SAVE_IDENTITY = "pokemon-love2d";
private static final String PICKED_ROM_FILENAME = "picked_rom.gb";
private static boolean immersiveActive = false;
private static boolean needToCopyGameInArchive = false;
private boolean storagePermissionUnnecessary = false;
@@ -332,6 +339,64 @@ public class GameActivity extends SDLActivity {
return openURL(url) == 0;
}
/**
* Shows the system document picker (Storage Access Framework) so the
* player can pick their ROM from anywhere (Downloads, Drive, etc.)
* without needing to know where the app's external files folder is.
* Requires API 19+ (ACTION_OPEN_DOCUMENT); the picked file (if any)
* arrives later in onActivityResult, not synchronously here.
*/
@Keep
public static boolean showRomFilePicker() {
if (android.os.Build.VERSION.SDK_INT < 19) return false;
GameActivity self = (GameActivity) mSingleton;
if (self == null) return false;
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType("*/*");
try {
self.startActivityForResult(intent, ROM_PICKER_REQUEST_CODE);
return true;
} catch (Exception e) {
Log.d("GameActivity", "could not open ROM file picker: " + e.getMessage());
return false;
}
}
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode != ROM_PICKER_REQUEST_CODE) return;
if (resultCode != RESULT_OK || data == null || data.getData() == null) {
Log.d("GameActivity", "ROM picker returned no file (cancelled?)");
return;
}
Uri uri = data.getData();
File destDir = new File(new File(getExternalFilesDir(null), "save"), ROM_SAVE_IDENTITY);
if (!destDir.exists() && !destDir.mkdirs()) {
Log.d("GameActivity", "could not create " + destDir);
return;
}
File destFile = new File(destDir, PICKED_ROM_FILENAME);
InputStream source;
try {
source = getContentResolver().openInputStream(uri);
} catch (FileNotFoundException e) {
Log.d("GameActivity", "could not open picked ROM: " + e.getMessage());
return;
}
if (source == null) {
Log.d("GameActivity", "ContentResolver returned no stream for picked ROM");
return;
}
if (!copyAssetFile(source, destFile.getPath())) {
Log.d("GameActivity", "could not copy picked ROM to " + destFile);
}
}
/**
* Copies a given file from the assets folder to the destination.
*
+5
View File
@@ -62,10 +62,15 @@
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UTExportedTypeDeclarations</key>
<array>
+101 -19
View File
@@ -1,17 +1,19 @@
#!/usr/bin/env bash
# Packages the LÖVE2D Pokémon Red port into distributable macOS and
# Windows builds. Runs entirely on macOS (no cross-compiling needed,
# the Windows build reuses LÖVE's prebuilt win64 binaries).
# Packages the LÖVE2D Pokémon Red port into distributable macOS, Windows,
# and Linux builds. Runs entirely on macOS (no cross-compiling needed,
# the Windows and Linux builds reuse LÖVE's prebuilt win64 / AppImage
# binaries, fusing our game.love onto them the same way love.exe does).
#
# Usage: scripts/build.sh [mac|win|android|ios|all] [--version X.Y.Z] [--identity "Developer ID Application: ..."]
# Usage: scripts/build.sh [mac|win|linux|android|ios|all] [--version X.Y.Z] [--identity "Developer ID Application: ..."]
# [--notary-profile NAME] [--no-notarize]
# [--release] # android/ios: release config instead of debug
# [--release] # ios only: release config instead of debug
#
# Output: dist/mac/PokemonRed-macos.zip
# dist/win/PokemonRed-win64.zip
# dist/android/{debug,release}/*.apk (full gradle output stays under
# Output: dist/mac/gen1recomp-macos.zip
# dist/win/gen1recomp-win64.zip
# dist/linux/gen1recomp-linux.zip (fused x86_64 AppImage)
# dist/android/debug/*.apk (full gradle output stays under
# mobile/android/app/build/outputs/apk/embedNoRecord/)
# dist/ios/<Config>-<sdk>/PokemonRed.app (full xcodebuild output stays
# dist/ios/<Config>-<sdk>/gen1recomp.app (full xcodebuild output stays
# under mobile/ios/build/Build/Products/)
set -euo pipefail
@@ -23,15 +25,15 @@ WORK="$HERE/work"
DIST="$ROOT/dist"
ENTITLEMENTS="$ROOT/scripts/macos-entitlements.plist"
APP_NAME="PokemonRed"
APP_NAME="gen1recomp"
BUNDLE_ID="com.theboisclub.pokemonred"
LOVE_VERSION="11.5"
VERSION="$(git -C "$ROOT" rev-parse --short HEAD 2>/dev/null || echo dev)"
VERSION_EXPLICIT=false
IDENTITY=""
TARGET="all"
NOTARY_PROFILE="notary-profile"
NOTARIZE=true
ANDROID_RELEASE=false
IOS_RELEASE=false
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
@@ -40,25 +42,25 @@ fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
while [ $# -gt 0 ]; do
case "$1" in
mac|win|android|ios|all) TARGET="$1" ;;
--version) VERSION="$2"; shift ;;
mac|win|linux|android|ios|all) TARGET="$1" ;;
--version) VERSION="$2"; VERSION_EXPLICIT=true; shift ;;
--identity) IDENTITY="$2"; shift ;;
--notary-profile) NOTARY_PROFILE="$2"; shift ;;
--no-notarize) NOTARIZE=false ;;
--release) ANDROID_RELEASE=true; IOS_RELEASE=true ;;
--release) IOS_RELEASE=true ;;
*) fail "unknown argument: $1" ;;
esac
shift
done
mkdir -p "$CACHE" "$WORK" "$DIST/mac" "$DIST/win"
mkdir -p "$CACHE" "$WORK" "$DIST/mac" "$DIST/win" "$DIST/linux"
# --------------------------------------------------------------- game.love
say "packing game.love"
LOVE_FILE="$WORK/game.love"
rm -f "$LOVE_FILE"
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
main.lua conf.lua src data assets tools/rom_manifest.json \
main.lua conf.lua src data assets tools/rom_manifest.json tools/rom_manifest_blue.json \
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
if unzip -Z1 "$LOVE_FILE" \
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
@@ -139,11 +141,20 @@ build_win() {
say "building Windows (win64) app"
local zip_name="love-$LOVE_VERSION-win64.zip"
local love_zip="$CACHE/$zip_name"
# A cache hit only checks existence, not validity -- a prior run truncated
# by a network drop mid-download (curl still leaves the partial file if
# the exit code slips through) would otherwise be reused forever.
if [ -f "$love_zip" ] && ! unzip -tqq "$love_zip" >/dev/null 2>&1; then
warn "cached $zip_name is not a valid zip, removing and re-downloading"
rm -f "$love_zip"
fi
if [ ! -f "$love_zip" ]; then
say "downloading LÖVE $LOVE_VERSION win64 binaries"
curl -fL --progress-bar \
"https://github.com/love2d/love/releases/download/$LOVE_VERSION/$zip_name" \
-o "$love_zip" || fail "download failed, check LOVE_VERSION or your network"
unzip -tqq "$love_zip" >/dev/null 2>&1 \
|| fail "downloaded $zip_name is not a valid zip (truncated download?)"
fi
local extract_dir="$WORK/love-win64"
@@ -167,12 +178,82 @@ build_win() {
say "Windows build: $zip_out"
}
# --------------------------------------------------------------- Linux
build_linux() {
say "building Linux (x86_64 AppImage) app"
local appimage_name="love-$LOVE_VERSION-x86_64.AppImage"
local love_appimage="$CACHE/$appimage_name"
# Same cache-validity gap as the win64 zip above: an AppImage is just an
# ELF, so check the magic bytes before trusting a cached copy is complete.
if [ -f "$love_appimage" ] && [ "$(head -c 4 "$love_appimage" | od -An -tx1 | tr -d ' \n')" != "7f454c46" ]; then
warn "cached $appimage_name is not a valid ELF binary, removing and re-downloading"
rm -f "$love_appimage"
fi
if [ ! -f "$love_appimage" ]; then
say "downloading LÖVE $LOVE_VERSION Linux AppImage"
curl -fL --progress-bar \
"https://github.com/love2d/love/releases/download/$LOVE_VERSION/$appimage_name" \
-o "$love_appimage" || fail "download failed, check LOVE_VERSION or your network"
[ "$(head -c 4 "$love_appimage" | od -An -tx1 | tr -d ' \n')" = "7f454c46" ] \
|| fail "downloaded $appimage_name is not a valid ELF binary (truncated download?)"
fi
chmod +x "$love_appimage"
# The Windows-style `cat love.exe game.love` fusion does NOT work here:
# an AppImage is a small runtime ELF with a squashfs appended, and at
# launch the runtime mounts the squashfs and executes bin/love from
# *inside* it -- bytes appended to the outer file are never read, so
# users would just get vanilla LÖVE's no-game screen. Instead, unpack
# the squashfs, drop game.love in, point AppRun's FUSE_PATH hook at it
# (the hook ships commented-out in LÖVE's official AppImage), and glue
# runtime + repacked squashfs back together.
command -v unsquashfs >/dev/null && command -v mksquashfs >/dev/null \
|| fail "squashfs tools not found; install with: brew install squashfs"
# The squashfs starts right where the ELF ends:
# e_shoff + e_shnum * e_shentsize (all little-endian in the ELF64 header).
local e_shoff e_shentsize e_shnum sfs_offset
e_shoff=$(od -An -j40 -N8 -tu8 "$love_appimage" | tr -d ' ')
e_shentsize=$(od -An -j58 -N2 -tu2 "$love_appimage" | tr -d ' ')
e_shnum=$(od -An -j60 -N2 -tu2 "$love_appimage" | tr -d ' ')
sfs_offset=$((e_shoff + e_shentsize * e_shnum))
[ "$(dd if="$love_appimage" bs=1 skip="$sfs_offset" count=4 2>/dev/null)" = "hsqs" ] \
|| fail "no squashfs superblock at computed offset $sfs_offset (unexpected AppImage layout)"
local appdir="$WORK/linux-appdir"
rm -rf "$appdir"
unsquashfs -q -no-xattrs -o "$sfs_offset" -d "$appdir" "$love_appimage" >/dev/null
cp "$LOVE_FILE" "$appdir/game.love"
sed -i '' 's|^#FUSE_PATH="$APPDIR/my_game.love"$|FUSE_PATH="$APPDIR/game.love"|' "$appdir/AppRun"
grep -q '^FUSE_PATH="\$APPDIR/game.love"$' "$appdir/AppRun" \
|| fail "failed to enable FUSE_PATH in AppRun (upstream AppRun changed?)"
# Match the upstream image's compression (gzip, 128K blocks) so the
# bundled runtime can read it.
local sfs_out="$WORK/game.squashfs"
rm -f "$sfs_out"
mksquashfs "$appdir" "$sfs_out" \
-comp gzip -b 131072 -noappend -all-root -no-xattrs -quiet >/dev/null
local out_bin="$WORK/$APP_NAME-x86_64.AppImage"
rm -f "$out_bin"
head -c "$sfs_offset" "$love_appimage" > "$out_bin"
cat "$sfs_out" >> "$out_bin"
chmod +x "$out_bin"
local zip_out="$DIST/linux/$APP_NAME-linux.zip"
rm -f "$zip_out"
(cd "$WORK" && zip -q -9 -j "$zip_out" "$(basename "$out_bin")")
say "Linux build: $zip_out"
}
# --------------------------------------------------------------- Android
build_android() {
say "building Android (delegating to scripts/build_android.sh)"
local args=()
if [ "$ANDROID_RELEASE" = true ]; then
args+=(--release)
if [ "$VERSION_EXPLICIT" = true ]; then
args+=(--version "$VERSION")
fi
"$ROOT/scripts/build_android.sh" ${args[@]+"${args[@]}"}
}
@@ -190,9 +271,10 @@ build_ios() {
case "$TARGET" in
mac) build_mac ;;
win) build_win ;;
linux) build_linux ;;
android) build_android ;;
ios) build_ios ;;
all) build_mac; build_win ;;
all) build_mac; build_win; build_linux ;;
esac
case "$TARGET" in
+31 -33
View File
@@ -1,11 +1,10 @@
#!/usr/bin/env bash
# Packages the LÖVE2D Pokémon Red port into an Android APK via love-android 11.5a.
#
# Usage: scripts/build_android.sh [--release] [--package-only]
# Usage: scripts/build_android.sh [--version X.Y.Z] [--package-only]
#
# (default) assembleEmbedNoRecordDebug (debug keystore)
# --release assembleEmbedNoRecordRelease (requires out-of-band signing)
# --package-only zip game.love + apply branding; skip gradle
# --version X.Y.Z set app.version_name / app.version_code (else left as-is)
# --package-only zip game.love + apply branding; skip gradle
#
# Prerequisites:
# - mobile/android vendored love-android tree at tag 11.5a (in-repo; see mobile/ANDROID.md)
@@ -13,9 +12,8 @@
# - JDK 17
#
# Output (after gradle):
# dist/android/{debug,release}/*.apk (convenience copy)
# dist/android/debug/*.apk (convenience copy)
# mobile/android/app/build/outputs/apk/embedNoRecord/debug/*.apk
# mobile/android/app/build/outputs/apk/embedNoRecord/release/*.apk
set -euo pipefail
@@ -24,12 +22,12 @@ ANDROID_DIR="$ROOT/mobile/android"
EMBED_ASSETS="$ANDROID_DIR/app/src/embed/assets"
LOVE_FILE="$EMBED_ASSETS/game.love"
DIST="$ROOT/dist/android"
APP_NAME="Pokemon Red"
APP_NAME="gen1recomp"
APPLICATION_ID="com.theboisclub.pokemonred"
LOVE_ANDROID_VERSION="11.5a"
NDK_VERSION="25.2.9519653"
RELEASE=false
VERSION=""
PACKAGE_ONLY=false
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
@@ -38,17 +36,29 @@ fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
while [ $# -gt 0 ]; do
case "$1" in
--release) RELEASE=true ;;
--version) VERSION="$2"; shift ;;
--package-only) PACKAGE_ONLY=true ;;
-h|--help)
sed -n '2,20p' "$0"
exit 0
;;
*) fail "unknown argument: $1 (try --release or --package-only)" ;;
*) fail "unknown argument: $1 (try --version X.Y.Z or --package-only)" ;;
esac
shift
done
VERSION_CODE=""
if [ -n "$VERSION" ]; then
if ! printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
fail "invalid --version '$VERSION' (expected X.Y.Z)"
fi
major="${VERSION%%.*}"
rest="${VERSION#*.}"
minor="${rest%%.*}"
patch="${rest##*.}"
VERSION_CODE=$((major * 10000 + minor * 100 + patch))
fi
# --------------------------------------------------------------- preconditions
if [ ! -f "$ANDROID_DIR/settings.gradle" ] || [ ! -f "$ANDROID_DIR/gradlew" ]; then
fail "love-android not found at mobile/android/.
@@ -74,10 +84,10 @@ apply_android_branding() {
say "applying Android branding (gradle.properties + permission trim)"
python3 - "$props" "$APPLICATION_ID" "$APP_NAME" <<'PY'
python3 - "$props" "$APPLICATION_ID" "$APP_NAME" "$VERSION" "$VERSION_CODE" <<'PY'
import pathlib, re, sys
path = pathlib.Path(sys.argv[1])
app_id, name = sys.argv[2], sys.argv[3]
app_id, name, version, version_code = sys.argv[2], sys.argv[3], sys.argv[4], sys.argv[5]
text = path.read_text()
def set_prop(text, key, value):
@@ -91,7 +101,10 @@ def set_prop(text, key, value):
text = re.sub(r"(?m)^app\.name_byte_array=.*\n?", "", text)
text = set_prop(text, "app.name", name)
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:
text = set_prop(text, "app.version_name", version)
text = set_prop(text, "app.version_code", version_code)
path.write_text(text)
PY
@@ -124,7 +137,7 @@ pack_game_love() {
mkdir -p "$EMBED_ASSETS"
rm -f "$LOVE_FILE"
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
main.lua conf.lua src data assets tools/rom_manifest.json \
main.lua conf.lua src data assets tools/rom_manifest.json tools/rom_manifest_blue.json \
-x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
-x 'data/generated/*' -x 'assets/generated/*')
if unzip -Z1 "$LOVE_FILE" \
@@ -178,16 +191,8 @@ require_android_sdk() {
# --------------------------------------------------------------- gradle
run_gradle() {
local task
if $RELEASE; then
task="assembleEmbedNoRecordRelease"
say "building release APK ($task)"
warn "release signing is out-of-band, see mobile/ANDROID.md (Signing)."
warn "without a signingConfig, assembleRelease may produce an unsigned APK or fail."
else
task="assembleEmbedNoRecordDebug"
say "building debug APK ($task), uses the default Android debug keystore"
fi
local task="assembleEmbedNoRecordDebug"
say "building APK ($task)"
if ! (
cd "$ANDROID_DIR"
@@ -199,19 +204,12 @@ run_gradle() {
You can still iterate on the .love payload with: scripts/build_android.sh --package-only"
fi
local out_dir
if $RELEASE; then
out_dir="$ANDROID_DIR/app/build/outputs/apk/embedNoRecord/release"
else
out_dir="$ANDROID_DIR/app/build/outputs/apk/embedNoRecord/debug"
fi
local out_dir="$ANDROID_DIR/app/build/outputs/apk/embedNoRecord/debug"
if [ -d "$out_dir" ]; then
say "APK output:"
find "$out_dir" -name '*.apk' -exec ls -lh {} \;
local flavor="debug"
$RELEASE && flavor="release"
local dist_dir="$DIST/$flavor"
local dist_dir="$DIST/debug"
rm -rf "$dist_dir"
mkdir -p "$dist_dir"
find "$out_dir" -name '*.apk' -exec cp {} "$dist_dir/" \;
+6 -6
View File
@@ -15,8 +15,8 @@
# - mobile/ios/love-src/ (see --fetch / mobile/ios/README.md)
# - prebuilt iOS libraries under love-src/platform/xcode/ios/libraries/
#
# Output: dist/ios/<Config>-<sdk>/PokemonRed.app (convenience copy)
# mobile/ios/build/Build/Products/<Config>-<sdk>/PokemonRed.app
# Output: dist/ios/<Config>-<sdk>/gen1recomp.app (convenience copy)
# mobile/ios/build/Build/Products/<Config>-<sdk>/gen1recomp.app
set -euo pipefail
@@ -33,8 +33,8 @@ RESOURCES_DIR="$XCODE_DIR/ios/resources"
LOVE_FILE="$RESOURCES_DIR/game.love"
LIBS_DIR="$XCODE_DIR/ios/libraries"
APP_NAME="PokemonRed"
DISPLAY_NAME="Pokemon Red"
APP_NAME="gen1recomp"
DISPLAY_NAME="gen1recomp"
BUNDLE_ID="com.theboisclub.pokemonred"
LOVE_VERSION="$(tr -d '[:space:]' < "$IOS_DIR/LOVE_VERSION" 2>/dev/null || echo 11.5)"
IOS_SOURCE_ZIP="love-${LOVE_VERSION}-ios-source.zip"
@@ -155,7 +155,7 @@ require_ios_libraries
apply_ios_branding() {
[ -f "$OVERLAY_PLIST" ] || fail "missing overlay plist: $OVERLAY_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"
}
@@ -166,7 +166,7 @@ pack_game_love() {
rm -f "$LOVE_FILE"
# Same payload as scripts/build.sh / build_android.sh: game sources only.
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
main.lua conf.lua src data assets tools/rom_manifest.json \
main.lua conf.lua src data assets tools/rom_manifest.json tools/rom_manifest_blue.json \
-x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
-x 'data/generated/*' -x 'assets/generated/*')
if unzip -Z1 "$LOVE_FILE" \
+19
View File
@@ -144,6 +144,25 @@ function AnimPlayer.new(data)
}, AnimPlayer)
end
-- Release the tilesheet images and quads this player built. They live in
-- per-instance caches (a fresh AnimPlayer is made per battle), so unlike a
-- shared module cache they are dead the moment the battle ends -- freeing
-- them here instead of waiting on a GC finalizer keeps grinding battles
-- from piling orphaned VRAM up faster than the (Lua-heap-triggered) GC
-- reclaims it.
function AnimPlayer:release()
for _, img in pairs(self.images) do
if img and img.release then pcall(img.release, img) end
end
self.images = {}
for _, byTile in pairs(self.quads) do
for _, q in pairs(byTile) do
if q and q.release then pcall(q.release, q) end
end
end
self.quads = {}
end
function AnimPlayer:warnOnce(key, fmt, ...)
if not self.warned[key] then
self.warned[key] = true
+318 -61
View File
@@ -52,6 +52,10 @@ local imageCache = {}
-- fully transparent rows below a pic's content (the extracted 32x32 back
-- pics carry baked-in padding); used to sit the pic flush on the text box
local imagePadBottom = {}
-- fully transparent columns left of a pic's content; at 2x (back pics)
-- this is subtracted from hlcoord 1,5 so opaque pixels match hardware,
-- where those columns were white-on-white rather than shifted content
local imagePadLeft = {}
-- image -> { path, pal } so palette-fade variants (see fadeImage) can be
-- rebuilt for any battle pic, whatever code loaded it
local imageMeta = {}
@@ -63,7 +67,7 @@ local function getImage(path, pal, trueColor)
if trueColor then pal = nil end
local key = pal and (path .. "#" .. pal.name) or path
if not imageCache[key] then
local img, pad = nil, 0
local img, pad, padL = nil, 0, 0
if love.image and love.image.newImageData then
local id = Assets.imageData(path)
if pal then
@@ -86,13 +90,25 @@ local function getImage(path, pal, trueColor)
if opaque then break end
bottom = bottom - 1
end
local left = 0
while left < w do
local opaque = false
for y = 0, h - 1 do
local _, _, _, a = id:getPixel(left, y)
if a > 0 then opaque = true break end
end
if opaque then break end
left = left + 1
end
img = love.graphics.newImage(id)
pad = h - 1 - bottom
padL = left
else
img = Assets.image(path) -- headless stub: no pixel access
end
imageCache[key] = img
imagePadBottom[img] = pad
imagePadLeft[img] = padL
imageMeta[img] = { path = path, pal = pal, trueColor = trueColor or nil }
end
return imageCache[key]
@@ -101,7 +117,7 @@ end
-- hot reload: the next getImage re-resolves every pic through the asset
-- search path and re-measures its ground padding
function BattleState.invalidate()
imageCache, imagePadBottom, imageMeta = {}, {}, {}
imageCache, imagePadBottom, imagePadLeft, imageMeta = {}, {}, {}, {}
end
Assets.register(BattleState.invalidate)
@@ -258,6 +274,10 @@ local function makeBattler(data, mon, isPlayer, save)
}
end
-- LinkBattle builds clamped copies with save=nil (no badge boosts); wild
-- and trainer constructors pass the live save for the player side.
BattleState.makeBattler = makeBattler
-- The battle pic for `species` on the given side (back pic for the
-- player side, front pic for the enemy side), tinted PAL_GRAYMON --
-- the same path makeBattler uses, but forced gray -- since this is only
@@ -635,7 +655,6 @@ function BattleState:startMessage(item)
self.total = self.total + #codes
end
self.charIndex = 0
self.holdTimer = nil
end
function BattleState:updateQueue()
@@ -786,9 +805,8 @@ function BattleState:updateQueue()
if self.charIndex < self.total then
self.charIndex = math.min(self.total, self.charIndex + 2)
else
self.holdTimer = (self.holdTimer or 40) - 1
local input = self.game.input
if self.holdTimer <= 0 or input:wasPressed("a") or input:wasPressed("b") then
if input:wasPressed("a") or input:wasPressed("b") then
self.current = nil
end
end
@@ -834,7 +852,7 @@ function BattleState:computeMusicKind()
return "final"
elseif isBoss or (self.trainer and self.trainer.id == "OPP_LANCE") then
return "gym"
elseif self.kind == "trainer" then
elseif self.kind == "trainer" or self.kind == "link" then
return "trainer"
end
return "wild"
@@ -896,6 +914,17 @@ function BattleState:enter()
self.showEnemyTrainer = false
self:startGrowIn(self.enemy)
end)
elseif self.kind == "link" then
-- Colosseum has no foe trainer pic, but the enemy mon still grows
-- out of the ball after "X sent out Y!" (not the wild "already there"
-- intro that LinkBattle previously inherited from newWild).
self.enemySendingOut = true
self:say(("%s sent\nout %s!"):format(self.opponentName or "FOE",
self.enemy.name))
self:act(function()
self.enemySendingOut = false
self:startGrowIn(self.enemy)
end)
end
if not self.ghost then
-- the enemy's cry plays as it appears (data/pokemon/cries.asm)
@@ -936,22 +965,53 @@ end
-- (end_of_battle.asm clears wLowHealthAlarm when a battle ends)
function BattleState:exit()
require("src.core.Sound").stopLoop("Low_Health_Alarm")
-- Free this battle's own GPU objects now rather than waiting on a GC
-- finalizer: the two full-screen wavy-effect canvases (colorMode) and
-- the AnimPlayer's per-instance tilesheet images/quads. The shared
-- module caches (imageCache/imagePadBottom, keyed by path+palette) are
-- reused by the next battle, so they are deliberately left alone -- only
-- the per-instance objects, which are dead once this battle is popped,
-- are released here.
local function rel(o) if o and o.release then pcall(o.release, o) end end
rel(self.bgCanvas); self.bgCanvas = nil
rel(self.waveCanvas); self.waveCanvas = nil
self.colorFxReady = nil
if self.animPlayer and self.animPlayer.release then
self.animPlayer:release()
end
end
-- An action the battler is locked into (bypasses the menu), or nil.
function BattleState:lockedAction(battler)
-- End a trapping sequence (USING_TRAPPING_MOVE). SendOutMon clears the
-- foe's bit (core.asm:1761-1762); EnemySendOutFirstMon clears the
-- player's (core.asm:1314-1315). Any switch frees the other side.
local function clearTrapping(battler)
if not battler then return end
battler.trappingTurns = nil
battler.trapMove = nil
battler.trapDamage = nil
end
-- Actions that skip DisplayBattleMenu entirely (core.asm:300-310):
-- recharge, Rage, thrash, charge. Bide / trapping / being held do NOT
-- skip the menu -- the player can still item/switch (and must press
-- FIGHT to continue a trapping sequence).
function BattleState:menuLockedAction(battler)
if battler.mustRecharge then return { special = "recharge" } end
if battler.charging then return battler.charging end
if battler.thrashTurns and battler.thrashTurns > 0 then return battler.thrashMove end
if battler.rageMove then return battler.rageMove end
return nil
end
-- After FIGHT: skip MoveSelectionMenu (core.asm:320-329). Own
-- trapping/Bide continues; foe trapping forces CANNOT_MOVE ($ff).
function BattleState:fightLockedAction(battler)
if battler.trappingTurns and battler.trappingTurns > 0 then
return { special = "trapping" }
end
if battler.bideTurns then return { special = "bide" } end
if battler.rageMove then return battler.rageMove end
-- held in place while the OPPONENT's trapping move is running
-- (core.asm:316-322 reads the live USING_TRAPPING_MOVE bit, so a
-- trap ended early by paralysis/faint frees the victim immediately);
-- boundTurns is a mirror kept for Status.beforeMove's held check
-- held while the OPPONENT's trapping bit is set (live mirror so a
-- trap ended early by paralysis/faint frees the victim immediately)
local opp = battler.isPlayer and self.enemy or self.player
battler.boundTurns = opp and opp.trappingTurns
and math.max(1, opp.trappingTurns) or nil
@@ -961,6 +1021,11 @@ function BattleState:lockedAction(battler)
return nil
end
-- Full lock for AI / callers that need any forced action.
function BattleState:lockedAction(battler)
return self:menuLockedAction(battler) or self:fightLockedAction(battler)
end
function BattleState:playerHasPP()
for i, mv in ipairs(self.player.curMoves) do
if mv.pp > 0 and self.player.disabledSlot ~= i then return true end
@@ -968,6 +1033,25 @@ function BattleState:playerHasPP()
return false
end
function BattleState:swapMoves(i, j)
if i == j then return end
local moves = self.player.curMoves
local a, b = moves[i], moves[j]
if not (a and b) then return end
moves[i], moves[j] = b, a
local stored = self.player.mon and self.player.mon.moves
if stored and stored ~= moves and stored[i] and stored[j] then
stored[i], stored[j] = stored[j], stored[i]
end
local disabled = self.player.disabledSlot
if disabled == i then
self.player.disabledSlot = j
elseif disabled == j then
self.player.disabledSlot = i
end
require("src.core.Sound").play(self.data, "Swap")
end
function BattleState:update(dt)
self.frame = self.frame + 1
self:updateFx()
@@ -1048,8 +1132,9 @@ function BattleState:update(dt)
if not (self.player.mustRecharge or self.player.rageMove) then
self.player.flinched, self.enemy.flinched = false, false
end
-- locked multi-turn actions skip the menu entirely
local locked = self:lockedAction(self.player)
-- only recharge/Rage/thrash/charge skip DisplayBattleMenu; trapping
-- victims (and wrappers) still get FIGHT/PKMN/ITEM/RUN (core.asm:312)
local locked = self:menuLockedAction(self.player)
if locked then
self:resolveTurn(locked)
return
@@ -1073,6 +1158,13 @@ function BattleState:update(dt)
end)
self:act(function() self:endOfTurn() end)
elseif choice == "fight" then
-- After the menu: own trapping/Bide or foe Wrap skips the move
-- list and forces the locked action (core.asm:320-329)
local fightLock = self:fightLockedAction(self.player)
if fightLock then
self:resolveTurn(fightLock)
return
end
if not self:playerHasPP() then
-- _NoMovesLeftText, then Struggle engages
self:say(("%s has no\nmoves left!"):format(self.player.name))
@@ -1081,6 +1173,7 @@ function BattleState:update(dt)
end
self.phase = "moveSelect"
self.moveIndex = math.min(self.moveIndex, #self.player.curMoves)
self.moveSwapIndex = nil
elseif choice == "run" then
self:tryRun()
elseif choice == "item" then
@@ -1098,9 +1191,22 @@ function BattleState:update(dt)
self.moveIndex = self.moveIndex > 1 and self.moveIndex - 1 or #moves
elseif input:wasPressed("down") then
self.moveIndex = self.moveIndex < #moves and self.moveIndex + 1 or 1
elseif input:wasPressed("select") then
if self.moveSwapIndex then
self:swapMoves(self.moveSwapIndex, self.moveIndex)
self.moveSwapIndex = nil
else
self.moveSwapIndex = self.moveIndex
end
elseif input:wasPressed("b") then
self.moveSwapIndex = nil
self.phase = "menu"
elseif input:wasPressed("a") then
if self.moveSwapIndex then
self:swapMoves(self.moveSwapIndex, self.moveIndex)
self.moveSwapIndex = nil
return
end
local mv = moves[self.moveIndex]
if self.player.disabledSlot == self.moveIndex then
self:say("The move is\ndisabled!")
@@ -1469,6 +1575,9 @@ function BattleState:resolveSwitch(newMon)
self:restoreMimicked(self.player) -- the battle copy leaves with it
local previous = self.player
self.player = makeBattler(self.data, newMon, true, self.game.save)
-- SendOutMon (core.asm:1761-1762): player's send-out clears the
-- foe's USING_TRAPPING_MOVE -- Wrap/Bind/etc. ends on any switch
clearTrapping(self.enemy)
self:syncSides()
Runtime.emit("battle.battler_switched", {
battle = self, side = self.sides[1], battler = self.player,
@@ -1501,10 +1610,16 @@ function BattleState:endOfTurn()
-- that sets the flag also zeroes the counter, so a stale value is
-- unobservable (a switch or cure downgrades Toxic to plain poison).
self.sideToxic = self.sideToxic or {}
for _, pair in ipairs({ { self.player, self.enemy, "player" },
{ self.enemy, self.player, "enemy" } }) do
local b, opp, side = pair[1], pair[2], pair[3]
if b.mon.hp > 0 then
-- a battler whose opponent was already knocked out by a move this turn
-- skips its own residual (HandlePoisonBurnLeechSeed is bypassed when the
-- move faints the target); snapshot before residual so one side's
-- residual faint can't suppress the other's
local playerAlive = self.player.mon.hp > 0
local enemyAlive = self.enemy.mon.hp > 0
for _, pair in ipairs({ { self.player, self.enemy, "player", enemyAlive },
{ self.enemy, self.player, "enemy", playerAlive } }) do
local b, opp, side, oppAlive = pair[1], pair[2], pair[3], pair[4]
if b.mon.hp > 0 and oppAlive then
local msgs = Status.residual(b, opp, self)
for _, m in ipairs(msgs) do self:sayNext(prefixEnemy(m, b)) end
if #msgs > 0 then self:drainNext() end -- poison/burn/seed HP moved
@@ -1568,10 +1683,19 @@ end
-- only after MoveHitTest / the effect lands (HandleIfPlayerMoveMissed skips
-- it on a miss unless EXPLODE_EFFECT); we insert early for blink attachment
-- and peel it back on miss/fail paths.
-- Dig/Fly charge leaves the user pic hidden (SLIDE_DOWN / TELEPORT); the
-- second-turn DIG/FLY anim restores it via SE_SLIDE_MON_UP / SE_SHOW_MON_PIC.
-- Cancelling that row on miss/immune would otherwise leave the digger
-- invisible until another anim's resetPicFx (#100).
function BattleState:cancelMoveAnim()
local row = self.moveAnimRow
if not row then return end
self.moveAnimRow = nil
if row.anim == "DIG" or row.anim == "FLY" then
local user = row.attackerIsPlayer and self.player or self.enemy
local pf = user and self.picFx and self.picFx[user]
if pf then pf.hidden = nil end
end
for i, item in ipairs(self.queue) do
if item == row then
table.remove(self.queue, i)
@@ -1635,14 +1759,23 @@ end
-- transient pic effects reset when a new animation row starts (each
-- PlayAnimation redraws from a clean slate); `minimized` survives --
-- the minimize sprite replaces the pic DATA, so redraws keep it until
-- the pic is reloaded (switch/Transform/ChangeMonPic)
-- the minimize sprite replaces the pic DATA until reload. Dig/Fly's
-- charge hide is a cleared tilemap that must survive until SE_SHOW_* /
-- SE_SLIDE_MON_UP (or cancelMoveAnim on a missed Dig/Fly release):
-- clearing it here made Dig pop in before emerge and wrap/bounce (#100).
-- Other hides (Acid Armor, etc.) still clear so the next anim restores.
function BattleState:resetPicFx()
if not self.picFx then return end
for _, pf in pairs(self.picFx) do
local digFly = self.animName == "DIG" or self.animName == "FLY"
local digFlyUser = digFly and (self.animAttackerIsPlayer
and self.player or self.enemy) or nil
for battler, pf in pairs(self.picFx) do
pf.kind, pf.t = nil, nil
pf.ox, pf.oy = 0, 0
pf.hidden = nil
local keepHide = battler.invulnerable or battler == digFlyUser
if not keepHide then
pf.hidden = nil
end
end
end
@@ -2043,6 +2176,8 @@ function BattleState:executeAction(user, target, action)
local oldName = self.enemy.name
self.enemyIndex = action.index
self.enemy = makeBattler(self.data, self.enemyParty[action.index], false)
-- EnemySendOutFirstMon (core.asm:1314-1315): clears player's trap
clearTrapping(self.player)
self:syncSides()
Runtime.emit("battle.battler_switched", {
battle = self, side = self.sides[2], battler = self.enemy,
@@ -2093,6 +2228,43 @@ function BattleState:executeAction(user, target, action)
self:performMove(user, target, action, false)
end
-- Sleep / confusion onomatopoeia from Check*StatusConditions
-- (core.asm): side-specific SLP_*/CONF_* anims, not the Rest/Amnesia
-- move rows. Player sleep plays the anim before FastAsleepText;
-- enemy sleep and both confusion sides print the text first.
function BattleState:statusOnomatopoeia(user, kind)
local isPlayer = user.isPlayer
local anim
if kind == "sleep" then
anim = isPlayer and "SLP_PLAYER_ANIM" or "SLP_ANIM"
else
anim = isPlayer and "CONF_PLAYER_ANIM" or "CONF_ANIM"
end
local text = kind == "sleep"
and (displayName(user) .. "\nis fast asleep!")
or (displayName(user) .. "\nis confused!")
if kind == "sleep" and isPlayer then
self:animNext(anim, isPlayer)
self:sayNext(text)
else
self:sayNext(text)
self:animNext(anim, isPlayer)
end
end
-- Queue status text (+ sleep/confusion FX when the line matches).
-- Wake / snap-out / flinch / etc. stay text-only.
function BattleState:sayStatusMsg(user, msg)
local text = prefixEnemy(msg, user)
if msg:find("is fast asleep!", 1, true) then
self:statusOnomatopoeia(user, "sleep")
elseif msg:find("is confused!", 1, true) then
self:statusOnomatopoeia(user, "confused")
else
self:sayNext(text)
end
end
-- The pre-recharge slice of CheckPlayerStatusConditions (core.asm:
-- 3328-3382): sleep -> freeze -> held-in-place -> flinch, each losing
-- the turn WITHOUT consuming the recharge flag. The disable/confusion/
@@ -2111,7 +2283,7 @@ function BattleState:preRechargeChecks(user, target)
mon.status = nil
self:sayNext(displayName(user) .. "\nwoke up!")
else
self:sayNext(displayName(user) .. "\nis fast asleep!")
self:statusOnomatopoeia(user, "sleep")
end
return true
end
@@ -2138,7 +2310,7 @@ end
-- returns true when the user's action is interrupted.
function BattleState:statusInterrupt(user, target)
local canMove, msgs, selfHit = Status.beforeMove(user, self.rng, self)
for _, m in ipairs(msgs) do self:sayNext(prefixEnemy(m, user)) end
for _, m in ipairs(msgs) do self:sayStatusMsg(user, m) end
if selfHit then
-- confusion self-hit (core.asm:3428-3434): clears everything in
-- status1 except CONFUSED, then HandleSelfConfusionDamage deals a
@@ -2215,11 +2387,16 @@ function BattleState:performMove(user, target, moveInst, isCalled)
user.charging, user.chargeReady, user.invulnerable = nil, nil, nil
end
-- PP: not for continuations, struggle, or called moves
-- PP: not for continuations, struggle, called moves, or (under
-- gen1_faithful) wild/trainer enemies — pokered DecrementPP only ever
-- mutates wBattleMonPP / party PP (engine/battle/decrement_pp.asm).
local isContinuation = releasing
or (user.thrashTurns and user.thrashTurns > 0 and moveInst == user.thrashMove)
or moveInst == user.rageMove
if not isContinuation and not moveInst.struggle and not isCalled then
local enemyUnlimited = not user.isPlayer
and self.ruleset and self.ruleset.enemyUnlimitedPP
if not isContinuation and not moveInst.struggle and not isCalled
and not enemyUnlimited then
moveInst.pp = math.max(0, moveInst.pp - 1)
end
@@ -2265,6 +2442,7 @@ function BattleState:performMove(user, target, moveInst, isCalled)
-- falling back to the id tables (Fly AND Dig go semi-invulnerable:
-- ChargeEffect sets INVULNERABLE for both)
if record and record.charge and not releasing then
self:cancelMoveAnim()
user.charging = moveInst
user.chargeReady = true
local invulnerable = move.semiInvulnerable
@@ -2274,6 +2452,15 @@ function BattleState:performMove(user, target, moveInst, isCalled)
if invulnerable then
user.invulnerable = true
end
local chargeAnim = record.charge.anim
if move.id == "DIG" then
chargeAnim = "SLIDE_DOWN_ANIM"
elseif record.charge.enemyAnim and not user.isPlayer then
chargeAnim = record.charge.enemyAnim
end
if chargeAnim then
self:animNext(chargeAnim, user.isPlayer)
end
local chargeText = move.chargeText or CHARGE_TEXT[move.id]
or "%s\nis charging up!"
self:sayNext(chargeText:format(displayName(user)))
@@ -2401,6 +2588,9 @@ end
function BattleState:onFaint(battler)
if battler.faintQueued then return end
battler.faintQueued = true
if battler.isPlayer and self.participants then
self.participants[battler.mon] = nil
end
Runtime.emit("battle.fainted", { battle = self, battler = battler })
-- the faint slide + cry ride the queue (after the move animation and
-- the HP-bar drain, pokered's order); the slide finishes before the
@@ -2437,9 +2627,9 @@ function BattleState:enemyMonFainted()
-- exp is split among the mons that fought this enemy
-- (engine/battle/experience.asm); traded mons earn x1.5; each
-- participant gets the full stat exp
-- the divisor counts EVERY participant, fainted ones included
-- (DivideExpDataByNumMonsGainingExp keeps their flag bits); only the
-- living ones are actually paid
-- a mon that fainted mid-fight has had its gain-exp flag cleared
-- (RemoveFaintedPlayerMon), so it drops out of the divisor and only
-- the surviving participants are counted and paid
local participants, alive = 0, {}
for _, mon in ipairs(self.game.save.party) do
if self.participants and self.participants[mon] then
@@ -2510,8 +2700,20 @@ function BattleState:enemyMonFainted()
self.participants = {}
if self.kind == "trainer" then
if self.enemyIndex < #self.enemyParty then
self.enemyIndex = self.enemyIndex + 1
-- EnemySendOutFirstMon / AnyEnemyPokemonAliveCheck (core.asm): scan
-- the whole enemy party for the first mon with HP left. Blindly
-- doing enemyIndex+1 softlocks after an AI switch (Agatha): a later
-- slot can already be fainted, so the empty-HP mon comes out, the
-- FIGHT menu returns, and executeAction no-ops on target.hp <= 0.
local nextIndex
for i, mon in ipairs(self.enemyParty) do
if mon.hp > 0 then
nextIndex = i
break
end
end
if nextIndex then
self.enemyIndex = nextIndex
-- SHIFT battle style (the default): announce the next mon and
-- offer a free switch (SET skips the prompt)
local nextMon = self.enemyParty[self.enemyIndex]
@@ -2535,6 +2737,7 @@ function BattleState:enemyMonFainted()
if mon ~= self.player.mon and mon.hp > 0 then
local previous = self.player
self.player = makeBattler(self.data, mon, true, game.save)
clearTrapping(self.enemy) -- SendOutMon clears foe trap
self:syncSides()
Runtime.emit("battle.battler_switched", {
battle = self, side = self.sides[1],
@@ -2560,6 +2763,8 @@ function BattleState:enemyMonFainted()
self:act(function()
local previous = self.enemy
self.enemy = makeBattler(self.data, self.enemyParty[self.enemyIndex], false)
-- EnemySendOutFirstMon (core.asm:1314-1315): clears player's trap
clearTrapping(self.player)
self:syncSides()
Runtime.emit("battle.battler_switched", {
battle = self, side = self.sides[2], battler = self.enemy,
@@ -2713,6 +2918,7 @@ function BattleState:openReplacementMenu()
self:restoreMimicked(self.player)
local previous = self.player
self.player = makeBattler(self.data, mon, true, game.save)
clearTrapping(self.enemy) -- SendOutMon clears foe trap
self:syncSides()
Runtime.emit("battle.battler_switched", {
battle = self, side = self.sides[1], battler = self.player,
@@ -2749,6 +2955,7 @@ function BattleState:safariAction(choice)
local playerName = self.game.save.player.name
if choice == "run" then
require("src.core.Sound").play(self.data, "Run")
self:say("Got away safely!")
self.result = "run"
self.afterQueue = "finish"
@@ -2830,6 +3037,12 @@ function BattleState:safariEnemyTurn()
end
if fled then
self:sayNext(("Wild %s\nran!"):format(self.enemy.name))
self:actNext(function()
require("src.core.Sound").play(self.data, "Run")
startPicKind(self:picFxFor(self.enemy), "slideOff")
end)
self.nextInsert = (self.nextInsert or 0) + 1
table.insert(self.queue, self.nextInsert, { wait = 24 })
self.result = "run"
self.afterQueue = "finish"
end
@@ -3403,17 +3616,16 @@ function BattleState:drawBattlerPic(battler, x, y, scale)
love.graphics.draw(img, quad, x + ox, y + oy, 0, scale, scale)
end
elseif k == "slideUp" then
-- AnimationSlideMonUp: cyclic upward wrap, one row per 2 frames
local scroll = 8 * math.min(7, math.floor(t / 2) + 1)
local src = math.floor(scroll / scale) % h
if src == 0 then
love.graphics.draw(img, x + ox, y, 0, scale, scale)
else
local top = love.graphics.newQuad(0, src, w, h - src, w, h)
love.graphics.draw(img, top, x + ox, y, 0, scale, scale)
local bottom = love.graphics.newQuad(0, 0, w, src, w, h)
love.graphics.draw(img, bottom, x + ox, y + (h - src) * scale,
0, scale, scale)
-- AnimationSlideMonUp (animations.asm): 7 row steps x 2f. After Dig's
-- SLIDE_DOWN the tilemap is blank; each step fills the next bottom
-- row so the mon emerges from underground. A cyclic wrap of a full
-- pic looked like a bounce at Dig's end (#100).
local step = math.min(7, math.floor((t - 1) / 2) + 1)
local visible = math.floor(h * step / 7)
if visible > 0 then
local quad = love.graphics.newQuad(0, h - visible, w, visible, w, h)
love.graphics.draw(img, quad, x + ox,
y + (h - visible) * scale, 0, scale, scale)
end
elseif xscale < 1 then
-- AnimationSquishMonPic: columns collapse toward the middle
@@ -3494,12 +3706,28 @@ function BattleState:sgbBattlePals()
if placeholder or not b then return pals.MEWMON or pals.GREENBAR end
return PaletteFX.monPal(self.data, b.mon.species) or pals.MEWMON
end
return {
local out = {
[0] = bar(self.player),
[1] = bar(self.enemy),
[2] = mon(self.player, self.showPlayerBack or self.safari or self.demo),
[3] = mon(self.enemy, self.showEnemyTrainer),
}
-- OG RED: the Game Boy Color drew the whole battle from one BG palette --
-- white paper, black ink -- so every zone shares the same background and
-- outline; only the two mid shades differ per element (green HP bar, red
-- mon pic). The bar/base zones otherwise carry the SGB off-white
-- (255,239,255) as color 0 while the mon zones (monPal -> GBC_BG) carry a
-- true white, which is what drew a white box around each pic on the pink
-- field. Snap every zone's color 0/3 to the global GBC white/black; the
-- mid shades (and the green bar the user prefers) stay untouched.
if PaletteFX.mode == "ogred" then
local white, black = PaletteFX.GBC_BG[1], PaletteFX.GBC_BG[4]
for i = 0, 3 do
local c = out[i]
out[i] = { white, c[2], c[3], black }
end
end
return out
end
-- the SGB palette covering a screen pixel (BlkPacket_Battle regions)
@@ -3609,6 +3837,20 @@ function BattleState:drawAnimLayer(colorized)
end
end
-- Front/trainer pics: LoadUncompressedSpriteData centers the sprite in
-- a 7x7 tile buffer, then CopyUncompressedPicToTilemap places that
-- buffer at hlcoord 12,0. Horizontal pad is floor((8-w)/2) tiles;
-- vertical pad is (7-h) -- bottom-aligned inside the 7x7.
local function enemyPicXY(img, slide, sx, sy)
local tw = math.floor(img:getWidth() / 8)
local th = math.floor(img:getHeight() / 8)
if tw < 1 then tw = 1 elseif tw > 7 then tw = 7 end
if th < 1 then th = 1 elseif th > 7 then th = 7 end
local hPad = math.floor((8 - tw) / 2)
local vPad = 7 - th
return 96 + 8 * hPad - slide + sx, 8 * vPad + sy
end
-- the two mon pics (or the trainer/back pics), offset by the window
-- shake -- on the GB the pics are BG tiles, so they move with it
function BattleState:drawPicsLayer(slide, sx, sy)
@@ -3626,19 +3868,18 @@ function BattleState:drawPicsLayer(slide, sx, sy)
g.intersectScissor(0, 0, 160, clipY)
clipped = true
end
-- Enemy: front sprite top-right (GB: pic at hlcoord 12,0).
-- Enemy: front sprite in the 7x7 slot at hlcoord 12,0.
if self.showEnemyTrainer and self.trainerPic then
-- the enemy trainer pic holds the mon slot until the send-out
local img = self:picImage(self.trainerPic)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(img, 160 - 8 - img:getWidth() - slide + sx,
math.max(0, 48 - img:getHeight()) + sy)
local ex, ey = enemyPicXY(img, slide, sx, sy)
love.graphics.draw(img, ex, ey)
elseif self.enemy and self.enemy.sprite and not self.enemyHidden
and not self.enemySendingOut and not self:fxHidden(self.enemy) then
local img = self:picImage(self.enemy.sprite)
love.graphics.setColor(1, 1, 1, 1)
local ex = 160 - 8 - img:getWidth() - slide + sx
local ey = math.max(0, 48 - img:getHeight()) + sy
local ex, ey = enemyPicXY(img, slide, sx, sy)
local gs = self:growInScale(self.enemy)
if gs then
-- AnimateSendingOutMon: the downscaled pic keeps its bottom edge
@@ -3652,15 +3893,18 @@ function BattleState:drawPicsLayer(slide, sx, sy)
end
end
-- Player: back sprite bottom-left (2x like the GB, feet near y=100).
-- Player: back sprite at hlcoord 1,5 (x=8), 2x like the GB, feet at y=96.
-- Left transparent columns (matted white) are pulled back so opaque
-- pixels land where hardware's white-on-white columns left them.
local hidePlayer = self.safari or self.demo
if self.showPlayerBack and self.playerBackPic then
-- Red's (or the old man's) back pic until "Go!"; it stays up for
-- the whole safari / catch-demo battle like the original
local img = self:picImage(self.playerBackPic)
local pad = imagePadBottom[self.playerBackPic] or 0
local padL = imagePadLeft[self.playerBackPic] or 0
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(img, 16 + slide + sx,
love.graphics.draw(img, 8 - padL * 2 + slide + sx,
96 - (img:getHeight() - pad) * 2 + sy, 0, 2, 2)
elseif self.player and self.player.sprite and not hidePlayer
and not self.sendingOut and not self:fxHidden(self.player) then
@@ -3668,17 +3912,19 @@ function BattleState:drawPicsLayer(slide, sx, sy)
love.graphics.setColor(1, 1, 1, 1)
-- feet flush on the text box top (y=96), ignoring baked-in padding
local pad = imagePadBottom[self.player.sprite] or 0
local padL = imagePadLeft[self.player.sprite] or 0
local px = 8 - padL * 2 + sx
local gs = self:growInScale(self.player)
if gs then
-- the player-side AnimateSendingOutMon grow (after the poof,
-- core.asm:1757-1762): feet pinned at y=96, center at x=16+w
-- core.asm:1757-1762): feet pinned at y=96, center at x=8+w
if gs > 0 then
love.graphics.draw(img, 16 + img:getWidth() * (1 - gs) + sx,
love.graphics.draw(img, px + img:getWidth() * (1 - gs),
96 - (img:getHeight() - pad) * 2 * gs + sy,
0, 2 * gs, 2 * gs)
end
else
self:drawBattlerPic(self.player, 16 + sx,
self:drawBattlerPic(self.player, px,
96 - (img:getHeight() - pad) * 2 + sy, 2)
end
end
@@ -3733,16 +3979,22 @@ function BattleState:drawHUDs(slide)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(("BALLx%2d"):format(self.safari.balls), 88, 72)
end
-- trainer-battle party pokeball rows during the intro
-- trainer/link party pokeball rows during the intro
-- (SetupPlayerAndEnemyPokeballs, draw_hud_pokeball_gfx.asm)
if self.kind == "trainer" and (self.showEnemyTrainer or self.showPlayerBack)
and slide == 0 then
local showIntroBalls = slide == 0 and (
(self.kind == "trainer" and (self.showEnemyTrainer or self.showPlayerBack))
or (self.kind == "link" and (self.showPlayerBack or self.enemySendingOut))
)
if showIntroBalls then
love.graphics.setColor(1, 1, 1, 1)
if self.showEnemyTrainer and self.enemyParty then
if self.enemyParty and (
(self.kind == "trainer" and self.showEnemyTrainer)
or (self.kind == "link" and self.enemySendingOut)
) then
self:drawBallRow(self.enemyParty, 64, 16, -8)
end
if self.showPlayerBack then
self:drawBallRow(self.game.save.party, 88, 80, 8)
self:drawBallRow(self.playerParty or self.game.save.party, 88, 80, 8)
end
end
local hidePlayer = self.safari or self.demo
@@ -3776,7 +4028,8 @@ function BattleState:drawTextArea()
if self.phase == "messages" and self.current then
local shown = 0
for li, codes in ipairs(self.lines) do
local y = 104 + li * 8
-- battle text uses every other tile row (hlcoord *,14 / *,16)
local y = 112 + (li - 1) * 16
for i = 1, #codes do
if shown >= self.charIndex then break end
Font.drawCode(codes[i], 8 + (i - 1) * 8, y)
@@ -3826,7 +4079,11 @@ function BattleState:drawTextArea()
for i, mv in ipairs(self.player.curMoves) do
Font.draw(self.data.moves[mv.id].name, 48, 96 + i * 8)
end
Font.drawCode(0xED, 40, 96 + self.moveIndex * 8)
Font.drawCode((self.moveSwapIndex == self.moveIndex) and 0xEC or 0xED,
40, 96 + self.moveIndex * 8)
if self.moveSwapIndex and self.moveSwapIndex ~= self.moveIndex then
Font.drawCode(0xEC, 40, 96 + self.moveSwapIndex * 8)
end
local sel = self.player.curMoves[self.moveIndex]
if sel then
if self.player.disabledSlot == self.moveIndex then
+36 -27
View File
@@ -176,25 +176,51 @@ function EffectRegistry.runDamaging(battle, ctx, record)
end
battle.lastDamage = dmg -- wDamage (shared by both sides, read by Counter)
-- the hit blink + damage sound ride the queue behind the animation:
-- on the move's anim row when one was announced, else on a bare hit
-- row (thrash/rage continuations), placed BEFORE the drain rows the
-- hits loop inserts so the blink precedes the bar drain
local hitRow = battle.moveAnimRow
if not hitRow then
battle.nextInsert = (battle.nextInsert or 0) + 1
hitRow = { hitRow = true }
table.insert(battle.queue, battle.nextInsert, hitRow)
end
-- the hit blink + damage sound ride each animation row, placed BEFORE
-- that hit's drain so the blink precedes the bar. Multi-hit moves
-- replay PlayMoveAnimation per strike (pokered: GetPlayerAnimationType
-- / GetEnemyAnimationType loop on wNumAttacksLeft); hit 1 reuses the
-- announcement-time moveAnimRow, later hits queue fresh anim rows.
-- Thrash/rage continuations have no announcement anim -- a bare
-- hitRow carries the blink instead.
local hitSfx = info.typeMult > 10 and "Super_Effective"
or info.typeMult < 10 and "Not_Very_Effective" or "Damage"
local hitFx = { sfx = hitSfx,
blink = battle:animationsOn() and target or nil }
local totalDealt = 0
local landed, brokeSub = 0, false
for h = 1, hits do
if target.mon.hp <= 0 then break end
local hitRow
if h == 1 then
hitRow = battle.moveAnimRow
if not hitRow then
battle.nextInsert = (battle.nextInsert or 0) + 1
hitRow = { hitRow = true }
table.insert(battle.queue, battle.nextInsert, hitRow)
end
else
battle.nextInsert = (battle.nextInsert or 0) + 1
hitRow = { anim = move.id, attackerIsPlayer = user.isPlayer }
table.insert(battle.queue, battle.nextInsert, hitRow)
end
local hadSub = target.substituteHP ~= nil
local dealt = battle:applyDamage(target, dmg)
totalDealt = totalDealt + dealt
landed = h
if dealt > 0 then hitRow.hit = hitFx end
-- PrintCriticalOHKOText + DisplayEffectiveness run inside the
-- multi-hit loop (core.asm .moveDidNotMiss before the jump back
-- to GetPlayerAnimationType), so crit/effectiveness reprint on
-- every strike -- damage was only rolled once
if info.crit then battle:sayNext("Critical hit!") end
if info.ohko then battle:sayNext("One-hit KO!") end
if info.typeMult > 10 then
battle:sayNext("It's super\neffective!")
elseif info.typeMult < 10 then
battle:sayNext("It's not very\neffective...")
end
if Runtime.wants("battle.damage_dealt") then
Runtime.emit("battle.damage_dealt", {
battle = battle, user = user, target = target, move = move,
@@ -208,23 +234,6 @@ function EffectRegistry.runDamaging(battle, ctx, record)
end
end
hits = landed > 0 and landed or hits
if totalDealt > 0 then
-- the original's per-hit sound: normal / super / not-very-effective
local hitSfx = info.typeMult > 10 and "Super_Effective"
or info.typeMult < 10 and "Not_Very_Effective" or "Damage"
hitRow.hit = { sfx = hitSfx,
blink = battle:animationsOn() and target or nil }
end
-- PrintCriticalOHKOText prints "Critical hit!"/"One-hit KO!" right
-- after the damage lands, BEFORE DisplayEffectiveness (core.asm
-- .moveDidNotMiss); the multi-hit count follows the last hit
if info.crit then battle:sayNext("Critical hit!") end
if info.ohko then battle:sayNext("One-hit KO!") end
if info.typeMult > 10 then
battle:sayNext("It's super\neffective!")
elseif info.typeMult < 10 then
battle:sayNext("It's not very\neffective...")
end
if hits > 1 then
-- player: _MultiHitText; enemy: _HitXTimesText (always plural)
if user.isPlayer then
+2 -2
View File
@@ -516,8 +516,8 @@ MoveEffects.full = {
-- charge moves: first turn just charges; Fly AND Dig go
-- semi-invulnerable (ChargeEffect sets INVULNERABLE for both)
CHARGE_EFFECT = { charge = {} },
FLY_EFFECT = { charge = { invulnerable = true } },
CHARGE_EFFECT = { charge = { anim = "XSTATITEM_ANIM", enemyAnim = "XSTATITEM_DUPLICATE_ANIM" } },
FLY_EFFECT = { charge = { invulnerable = true, anim = "TELEPORT" } },
TRAPPING_EFFECT = {
-- TrappingEffect runs BEFORE the hit test and clears the target's
+7 -2
View File
@@ -15,7 +15,8 @@
-- (super-effective) or adds 1 (not-effective when a better move exists);
-- the MINIMUM-scored move is chosen, ties broken uniformly among the
-- tied minima (core.asm:2971-3002). A non-minimal move is never
-- selectable. Respects PP, Disable and Transform/Mimic move overrides.
-- selectable. Respects Disable (and PP only when the ruleset depletes
-- enemy PP — Gen 1 AI never reads wEnemyMonPP).
local TypeChart = require("src.battle.TypeChart")
@@ -212,9 +213,13 @@ end
function TrainerAI.chooseMove(battler, rng, battle)
rng = rng or love.math.random
-- Gen 1: SelectEnemyMove never consults wEnemyMonPP; Struggle only when
-- every move slot is missing/disabled (core.asm:2957-2999). modern_clean
-- depletes enemy PP and falls back to Struggle when none remain.
local unlimited = battle and battle.ruleset and battle.ruleset.enemyUnlimitedPP
local usable = {}
for i, mv in ipairs(battler.curMoves) do
if mv.pp > 0 and battler.disabledSlot ~= i then
if battler.disabledSlot ~= i and (unlimited or mv.pp > 0) then
table.insert(usable, mv)
end
end
+3
View File
@@ -13,4 +13,7 @@ return {
randMax = 255,
-- Focus Energy famously QUARTERS the crit rate instead of x4
focusEnergyBug = true,
-- Wild/trainer enemies never spend PP (DecrementPP only touches the
-- player side in pokered). They therefore never Struggle from empty PP.
enemyUnlimitedPP = true,
}
+2
View File
@@ -9,4 +9,6 @@ return {
randMin = 217,
randMax = 255,
focusEnergyBug = false,
-- Gen 2+ style: AI opponents deplete PP and can Struggle when empty.
enemyUnlimitedPP = false,
}
+316 -801
View File
File diff suppressed because it is too large Load Diff
+760
View File
@@ -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
+8
View File
@@ -27,4 +27,12 @@ function FixedStep:update(dt)
end
end
-- Drop any pending catch-up steps. A hitch inside one logic step (map
-- seam setMap / song start) makes the next real-time dt huge; without this
-- the while-loop above would advance many walk frames before the next
-- draw, which looks like a slide with no leg animation (issue #93).
function FixedStep:discardCatchup()
self.accum = 0
end
return FixedStep
+72
View File
@@ -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
+69 -7
View File
@@ -17,7 +17,7 @@ local Game = {}
-- dev-mode gate for the F5/backtick hotkeys; false keeps every src/dev
-- module unloaded, so a player boot never touches a byte of dev code
local devMode = os.getenv("POKEPORT_DEV") == "1"
local devMode = os.getenv("POKEPORT_DEV") == "1" or _G.POKEPORT_DEV_MODE == true
-- the boot screen ids (field.boot.screens); a plain function so the
-- headless harness can borrow makeTitleState onto a stub game
@@ -99,7 +99,12 @@ end
-- total conversion overrides. Threaded into SaveData so persistence stays
-- free of a Data dependency.
function Game:bootConfig()
return self.data and self.data.field and self.data.field.boot
local boot = self.data and self.data.field and self.data.field.boot
-- stamp the running game version onto the boot config so a New Game records
-- it (SaveData.newGame reads boot.version); this is what routes a Blue
-- playthrough to save_blue.lua and Blue's version-gated content
if boot then boot.version = require("src.core.GameVersion").get() end
return boot
end
-- the title screen with its NEW GAME / CONTINUE wiring; used at boot
@@ -194,6 +199,13 @@ function Game:update(dt)
-- frame dt (not the fixed logic step) for a smooth ~0.25s glide.
require("src.render.Tilt").update(dt)
pcall(function() require("src.core.DiscordPresence").update(dt) end)
-- Steady-state memory backstop: advance the incremental collector one
-- small step every rendered frame. The heavy GPU objects are now freed
-- explicitly (map eviction, battle exit, canvas/renderer swaps), so this
-- only has to keep ordinary Lua-heap garbage (per-frame tables/closures)
-- from drifting upward over a long session, and to spread collection out
-- so the default lazy schedule never batches it into a visible pause.
if collectgarbage then collectgarbage("step", 1) end
end
-- render.zones' identity default: unhooked, the zone list reaches the blit
@@ -283,10 +295,22 @@ function Game:keypressed(key)
self:zoomStep(1)
return
elseif key == "2" then
-- cycle COLORS (GBC / OG / OG INV / GBC INV / CLASSIC); always on
local PaletteFX = require("src.render.PaletteFX")
self.save.options.colors = PaletteFX.cycleMode()
self:writeOptions()
-- cycle COLORS (GBC / OG / OG INV / GBC INV / CLASSIC); the pack change
-- forces Game.overworld:reloadMap, which rebuilds the live NPC array, so
-- hold it while a warp/transition or an on-screen scripted cutscene is
-- driving the overworld rather than tear the escort's NPCs out mid-move
local ow = self.overworld
local top = self.stack:top()
local busy = ow and (ow.transitioning
or (top == ow and (
(ow.runner and ow.runner.isRunning and ow.runner:isRunning())
or (ow.scriptMoves and #ow.scriptMoves > 0)
or ow.engaging or ow.emote)))
if not busy then
local PaletteFX = require("src.render.PaletteFX")
self.save.options.colors = PaletteFX.cycleMode()
self:writeOptions()
end
return
elseif key == "3" then
-- cycle TILT OFF → 15 → 35 → 50 → OFF (mnemonic: 3D), free-roam only
@@ -298,7 +322,9 @@ function Game:keypressed(key)
return
elseif key == "5" then
-- cycle GBC FX OFF → 1 → 2 → 3 → 4 (unlit-GBC ladder); always on
-- desktop. Mobile refuses the present shader (issue #136).
local GBCFX = require("src.render.GBCFX")
if not GBCFX.isSupported() then return end
self.save.options.gbcfx = GBCFX.cycle()
self:writeOptions()
return
@@ -337,6 +363,28 @@ function Game:gamepadaxis(joystick, axis, value)
Input:gamepadaxis(joystick, axis, value)
end
-- Window focus/visibility flips: a release due while unfocused/hidden can
-- be swallowed by the OS. Reset on both edges -- gaining focus with a
-- physically held key won't re-fire keypressed, so trusting leftover
-- state is worse than asking the player to re-press.
function Game:focus(f)
Input:reset()
TouchInput:reset()
end
function Game:visible(v)
Input:reset()
TouchInput:reset()
end
-- A disconnected/dropped controller can't send the button-up for whatever
-- it was holding, so drop all input state rather than try to guess which
-- flags it owned.
function Game:joystickremoved(joystick)
Input:reset()
TouchInput:reset()
end
function Game:touchpressed(id, x, y)
TouchInput:touchpressed(id, x, y)
end
@@ -397,8 +445,15 @@ function Game:applyOptions(opts)
if Sound.applyOptions then Sound.applyOptions(opts) end
require("src.render.PaletteFX").applyOptions(opts)
require("src.render.Tilt").applyOptions(opts)
require("src.render.GBCFX").applyOptions(opts)
-- returns true when a persisted GBC FX level was cleared on mobile
local gbcCleared = require("src.render.GBCFX").applyOptions(opts)
require("src.core.VideoMode").applyOptions(opts)
-- normalizes a nil/garbage cap to the 60 default, so old saves with no
-- fpsCap key pace at the standard rate (issue #88)
require("src.core.FrameCap").applyOptions(opts)
Input:applyBindings(opts.bindings)
-- heal soft-bricked APK installs that already saved gbcfx > 0 (#136)
if gbcCleared then self:writeOptions() end
end
function Game:restoreSave(loaded, recovered)
@@ -410,6 +465,13 @@ function Game:restoreSave(loaded, recovered)
-- SaveData.load and skip on the format guard
local activeMods = self.modStatus and self.modStatus.loaded
SaveData.runMigrations(loaded, self.mods and self.mods.migrations, activeMods)
-- Issue #103: 0.1.11 softlocks left CONTINUE in HALL_OF_FAME with
-- lastOutdoor on Indigo. One-shot relocate + heal before validate.
if SaveData.needsPostGameRescue(loaded) then
SaveData.applyPostGameHome(loaded, self:bootConfig())
local Pokemon = require("src.pokemon.Pokemon")
for _, mon in ipairs(loaded.party or {}) do Pokemon.heal(mon) end
end
local modsDiff = SaveData.modsDiff(loaded, activeMods)
local report = SaveData.validate(loaded, self.data)
report.recovered = recovered
+77
View File
@@ -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
+107 -16
View File
@@ -3,7 +3,7 @@
local Input = {}
local BINDINGS = {
local DEFAULT_BINDINGS = {
up = "up", w = "up",
down = "down", s = "down",
left = "left", a = "left",
@@ -11,15 +11,23 @@ local BINDINGS = {
z = "a", ["return"] = "a", space = "a",
x = "b", backspace = "b",
["kpenter"] = "start", escape = "start",
-- Select: fight-menu move reorder + bag item reorder. Tab is the
-- discoverable default (shown in CONTROLS); both shifts stay as
-- aliases so Right-Shift muscle memory from older builds still works.
tab = "select",
rshift = "select",
lshift = "select",
}
-- keys that map to "start" but also to "a" would conflict; keep Enter = a,
-- Escape = start for desktop friendliness.
-- LÖVE's standard gamepad mapping (SDL game controller DB), consistent
-- across Xbox/PlayStation/generic controllers on desktop and mobile.
local GAMEPAD_BINDINGS = {
-- across Xbox/PlayStation/generic controllers on desktop and mobile. Some
-- third-party pads report their own SDL mapping for a given physical
-- button (e.g. Select/Back/View on off-brand XInput pads), which is what
-- src/ui/BindingsMenu.lua's rebinding is for -- see applyBindings below.
local DEFAULT_GAMEPAD_BINDINGS = {
dpup = "up", dpdown = "down", dpleft = "left", dpright = "right",
a = "a", b = "b",
start = "start", back = "select",
@@ -32,48 +40,131 @@ local STICK_ON = 0.5
local STICK_OFF = 0.3
function Input:init()
self:applyBindings(nil)
self:reset()
end
-- Layers a player's rebind choices (save.options.bindings, written by
-- src/ui/BindingsMenu.lua) on top of the defaults above. A rebind adds an
-- extra way to trigger that action instead of replacing the default key,
-- so e.g. Z/Enter/Space all still press A even after binding a 4th key to
-- it. Call whenever options load or change (see Game:applyOptions and
-- BindingsMenu:storeBinding) -- without this the menu records a choice
-- that never actually reaches gameplay.
function Input:applyBindings(overlay)
local keys, pads = {}, {}
for key, action in pairs(DEFAULT_BINDINGS) do keys[key] = action end
for button, action in pairs(DEFAULT_GAMEPAD_BINDINGS) do pads[button] = action end
for actionId, binding in pairs(overlay or {}) do
if type(binding) == "table" then
if binding.key then keys[binding.key] = actionId end
if binding.pad then pads[binding.pad] = actionId end
elseif type(binding) == "string" then
keys[binding] = actionId
end
end
self.keyBindings = keys
self.padBindings = pads
end
-- Purely event-driven state (press sets true, release sets false) has no
-- fallback if a release event never arrives -- focus loss, a minimized
-- window, or a disconnected gamepad can all swallow the key-up/button-up
-- that would have cleared a held direction. Called from Game on those
-- transitions so a stuck flag can't outlive them.
function Input:reset()
self.state = {}
self.pressQueue = {}
self.pressed = {}
self.sources = {}
self.stickAxis = { x = 0, y = 0 }
self.stickDir = nil
end
function Input:keypressed(key)
local btn = BINDINGS[key]
if btn then
-- Multiple physical sources (W + Up, d-pad + stick, etc.) can claim the
-- same GB button. Track them individually so releasing one doesn't clear
-- a hold another source still owns, and so a press+release that both land
-- before the next FixedStep can't be revived when step() drains the queue.
local function press(self, btn, source)
local sources = self.sources[btn]
if not sources then
sources = {}
self.sources[btn] = sources
end
if not sources[source] then
sources[source] = true
table.insert(self.pressQueue, btn)
end
self.state[btn] = true
end
function Input:keyreleased(key)
local btn = BINDINGS[key]
if btn then
local function release(self, btn, source)
local sources = self.sources[btn]
if sources then
sources[source] = nil
if next(sources) == nil then
-- Leave an empty table (not nil) so step() can tell a real
-- source was released before the queue drained, versus a
-- synthetic pressQueue inject that never had sources at all.
self.state[btn] = false
end
else
self.state[btn] = false
end
end
function Input:keypressed(key)
local btn = self.keyBindings[key]
if btn then
press(self, btn, "key:" .. key)
end
end
function Input:keyreleased(key)
local btn = self.keyBindings[key]
if btn then
release(self, btn, "key:" .. key)
end
end
-- Called once per fixed step: promote queued presses to this step's edges.
-- Hold state is owned by live sources (updated in press/release), not
-- re-asserted here -- otherwise a same-frame press→release leaves the
-- button stuck on after the queue drains.
-- Synthetic injects (tests/drivers writing pressQueue directly, with no
-- source entry) still set state so scripted holds keep working.
function Input:step()
self.pressed = {}
for _, btn in ipairs(self.pressQueue) do
self.pressed[btn] = true
self.state[btn] = true
local sources = self.sources[btn]
if sources == nil then
-- synthetic pressQueue inject (tests/drivers): no live source map
self.state[btn] = true
elseif next(sources) ~= nil then
self.state[btn] = true
end
-- sources == {}: real press fully released before this step — keep up
end
for btn, sources in pairs(self.sources) do
if next(sources) == nil then
self.sources[btn] = nil
end
end
self.pressQueue = {}
end
function Input:gamepadpressed(joystick, button)
local btn = GAMEPAD_BINDINGS[button]
local btn = self.padBindings[button]
if btn then
table.insert(self.pressQueue, btn)
press(self, btn, "pad:" .. button)
end
end
function Input:gamepadreleased(joystick, button)
local btn = GAMEPAD_BINDINGS[button]
local btn = self.padBindings[button]
if btn then
self.state[btn] = false
release(self, btn, "pad:" .. button)
end
end
@@ -103,10 +194,10 @@ function Input:gamepadaxis(joystick, axis, value)
if newDir ~= self.stickDir then
if self.stickDir then
self.state[self.stickDir] = false
release(self, self.stickDir, "stick")
end
if newDir then
table.insert(self.pressQueue, newDir)
press(self, newDir, "stick")
end
self.stickDir = newDir
end
+19 -8
View File
@@ -114,6 +114,7 @@ local SPECIAL = {
oakRoute = "Music_Routes2",
bike = "Music_BikeRiding",
surf = "Music_Surfing",
evolution = "Music_SafariZone",
}
-- the label a scene role resolves to; call sites keep their own presence
@@ -253,6 +254,7 @@ function Music.stop()
require("src.core.ChipAudio").stopMusic()
state.current, state.source, state.loopSource, state.fade = nil, nil, nil, nil
state.chip = false
state.pendingRestore = nil
if previous and Runtime.wants("music.stopped") then
Runtime.emit("music.stopped", { song = previous })
end
@@ -346,18 +348,25 @@ end
function Music.playOnce(data, song)
if not songDef(data, song) then return false end
Music.play(data, song, false, { reason = "once" })
-- play() can no-op (hook silence, failed def); only arm restore when
-- the jingle actually became current
if state.current ~= song then return false end
state.pendingRestore = true
return true
end
-- is a playOnce jingle still sounding? (AnimateHealingMachine's
-- .waitLoop2 holds the healing machine until MUSIC_PKMN_HEALED ends)
local function chipAwaitingFirstBuffer()
return state.chip
and require("src.core.ChipAudio").awaitingFirstBuffer()
end
-- is a playOnce jingle still in flight? (AnimateHealingMachine's
-- .waitLoop2 / Mom heal / captain rub hold until MUSIC_PKMN_HEALED ends.)
-- pendingRestore stays set from playOnce until Music.update restores the
-- map theme, covering the threaded chip "empty QueueableSource" window
-- where Source:isPlaying is briefly false before the first buffer lands.
function Music.oneShotPlaying()
if not state.pendingRestore then return false end
local src = state.source
if not src then return false end
local ok, playing = pcall(src.isPlaying, src)
return ok and playing or false
return state.pendingRestore == true
end
function Music.restoreMap(data)
@@ -440,8 +449,10 @@ function Music.update(data)
state.source = loopSrc
pcall(loopSrc.play, loopSrc)
end
-- do not treat "threaded source still waiting on its first buffer" as
-- ended, or playOnce jingles get restored over before they can sound
if state.pendingRestore and sourceStopped(state.source)
and not state.loopSource then
and not state.loopSource and not chipAwaitingFirstBuffer() then
Music.restoreMap(data)
end
end
+229 -11
View File
@@ -18,14 +18,157 @@ local Semver = require("src.mods.Semver")
local Boxes = require("src.pokemon.Boxes")
local Bag = require("src.inventory.Bag")
local GameVersion = require("src.core.GameVersion")
local SaveData = {}
local FILENAME = "save.lua"
-- Progress files carry the game-version suffix so Red and Blue saves coexist:
-- Red keeps save.lua / .bak / .tmp exactly as before; Blue is save_blue.lua
-- (+ .bak/.tmp). options.lua is deliberately shared across versions (it holds
-- global preferences and the mod enable-state, not per-playthrough data).
local OPTIONS_FILENAME = "options.lua"
-- one rolling backup plus the staged-write witness; load promotes either
-- when the main file is missing or fails to parse
local BACKUP_FILENAME = FILENAME .. ".bak"
local TMP_FILENAME = FILENAME .. ".tmp"
-- Main / backup / staged-witness names for a version (defaults to the active
-- one). The backup is a rolling copy and .tmp is the staged-write witness;
-- load promotes either when the main file is missing or fails to parse.
local function saveNames(version)
local main = "save" .. GameVersion.saveSuffix(version) .. ".lua"
return main, main .. ".bak", main .. ".tmp"
end
-- The main save filename for a version -- used by the title screen's
-- CONTINUE gate so it looks for the right game's save.
function SaveData.saveFilename(version)
local main = saveNames(version)
return main
end
-- ------- portable mode
-- LÖVE's save directory is always the OS per-user path derived from the
-- identity (conf.lua), so it can't be relocated at runtime. Portable mode
-- instead drops a plain-Lua io.* filesystem next to the game whenever a
-- `portable.txt` marker sits beside the executable/source, letting a USB
-- copy carry its own save.lua/options.lua (and, through options.lua, the
-- mod enable-state) rather than leaving them on the host machine.
local PORTABLE_MARKER = "portable.txt"
local SEP = package.config:sub(1, 1)
local portableChecked = false
local portableBase = false -- resolved base dir when active, else false
local portableFsCache = nil
local function pathExists(path)
local f = io.open(path, "rb")
if not f then return false end
f:close()
return true
end
-- an io.* filesystem exposing the love.filesystem subset the save/options
-- round-trip needs (getInfo/read/write/remove), rooted at `dir`
local function makePortableFs(dir)
local function full(name) return dir .. SEP .. name end
return {
getInfo = function(name)
if not pathExists(full(name)) then return nil end
return { type = "file" }
end,
read = function(name)
local f = io.open(full(name), "rb")
if not f then return nil, "no file: " .. name end
local data = f:read("*a")
f:close()
return data
end,
write = function(name, data)
local f, err = io.open(full(name), "wb")
if not f then return false, err end
f:write(data)
f:close()
return true
end,
remove = function(name)
os.remove(full(name))
return true
end,
}
end
local function detectPortable()
if portableChecked then return portableBase end
portableChecked = true
portableBase = false
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 appDir = appContainer(src) or appContainer(sbd)
if appDir then candidates[#candidates + 1] = appDir end
if sbd then candidates[#candidates + 1] = sbd end
if src then candidates[#candidates + 1] = src end
for _, base in ipairs(candidates) do
if base ~= "" and pathExists(base .. SEP .. PORTABLE_MARKER) then
portableBase = base
break
end
end
return portableBase
end
function SaveData.isPortable()
return detectPortable() ~= false
end
-- the raw portable-folder path (for callers building their own nested
-- paths, e.g. the ROM-derived asset cache), or nil when portable mode
-- is off
function SaveData.portableBaseDir()
return detectPortable() or nil
end
-- the io.* filesystem for the active portable folder, or nil when off
function SaveData.portableFs()
local base = detectPortable()
if not base then return nil end
if not portableFsCache then portableFsCache = makePortableFs(base) end
return portableFsCache
end
-- Resolve the filesystem a persistent read/write should land on: an
-- explicitly injected non-love fs (headless tests, the mod loader's stub)
-- always wins; otherwise portable mode reroutes off the OS save directory.
local function persistFs(fs)
if fs and love and love.filesystem and fs ~= love.filesystem then
return fs
end
return SaveData.portableFs() or fs or (love and love.filesystem)
end
-- Port + original Options menu defaults. Missing keys on load are filled
-- from this table so old options.lua files stay compatible.
@@ -49,6 +192,8 @@ function SaveData.defaultOptions()
gbcfx = 0,
-- windowed | borderless (desktop fullscreen); ignored on mobile
videoMode = "windowed",
-- hard render frame-rate cap; render-only pacing (issue #88, FrameCap.lua)
fpsCap = 60,
-- Native mod enablement is an installation option, not save-slot data.
-- Missing entries mean enabled so newly installed mods work by default.
mods = {},
@@ -94,7 +239,7 @@ end
-- love.filesystem, so the mod loader's injected filesystem can carry the
-- options round-trip headless (no love global).
function SaveData.saveOptions(opts, fs)
fs = fs or love.filesystem
fs = persistFs(fs)
opts = SaveData.mergeOptions(opts)
-- modOptions is per-mod nested state: fold the on-disk sub-tree
-- underneath (newest value winning per key) so one caller's partial
@@ -123,7 +268,7 @@ function SaveData.saveOptions(opts, fs)
end
function SaveData.loadOptions(fs)
fs = fs or love.filesystem
fs = persistFs(fs)
local data, err = readTable(fs, OPTIONS_FILENAME)
if not data then
if fs.getInfo(OPTIONS_FILENAME) then
@@ -311,7 +456,7 @@ end)
-- afterwards either way
SaveData.addCoreMigration(1, function(save)
if type(save.options) == "table"
and not love.filesystem.getInfo(OPTIONS_FILENAME) then
and not persistFs(nil).getInfo(OPTIONS_FILENAME) then
SaveData.saveOptions(save.options)
end
end)
@@ -322,6 +467,36 @@ SaveData.addCoreMigration(1, function(save)
Boxes.ensure(save)
end)
-- game version (Red vs Blue) prep: saves written before Blue support
-- existed carry no `version` tag, and Red is the only game that ever
-- shipped, so default every untagged save to Red. from=2 so it catches
-- every pre-bump save (format 1 and 2) and is skipped once re-stamped to
-- the current format.
SaveData.addCoreMigration(2, function(save)
if not save.version then
save.version = "red"
end
end)
-- #131 / follow-up to #50: Game Corner poster grunt used to stay on the
-- floor after defeat (defeatedTrainers only). The #50 script now hides
-- him via objectToggles, but saves that already beat him never got the
-- toggle -- he still blocks the hideout switch. from=3 so every pre-4
-- save reconciles once, then is skipped after re-stamp.
SaveData.addCoreMigration(3, function(save)
local defeated = save.defeatedTrainers
if not defeated or not defeated["GAME_CORNER_obj_11"] then return end
save.objectToggles = save.objectToggles or {}
local mapToggles = save.objectToggles.GAME_CORNER
if not mapToggles then
mapToggles = {}
save.objectToggles.GAME_CORNER = mapToggles
end
if mapToggles.GAMECORNER_ROCKET ~= false then
mapToggles.GAMECORNER_ROCKET = false
end
end)
-- ------- write
-- Game progress only; options are written separately via saveOptions.
@@ -331,6 +506,9 @@ end)
-- itself rolls the last good save into .bak and stages the new bytes as
-- a .tmp witness before the swap, so a crash mid-write is recoverable.
function SaveData.save(data, mods)
-- write to the file matching this save's own version, not just the active
-- one, so a Blue playthrough always lands in save_blue.lua
local FILENAME, BACKUP_FILENAME, TMP_FILENAME = saveNames(data.version)
if data.options then
SaveData.saveOptions(data.options)
end
@@ -342,7 +520,7 @@ function SaveData.save(data, mods)
if k ~= "options" then gameOnly[k] = v end
end
local encoded = SaveSerializer.encode(gameOnly)
local fs = love.filesystem
local fs = persistFs(nil)
if fs.getInfo(FILENAME) then
local prev = fs.read(FILENAME)
if prev then fs.write(BACKUP_FILENAME, prev) end
@@ -370,8 +548,11 @@ end
-- returns the parsed save plus "tmp"/"bak" when the main file was gone
-- or corrupt and a staged/backup copy was promoted; Game surfaces the
-- recovery on the load report
function SaveData.load()
local fs = love.filesystem
function SaveData.load(version)
-- version defaults to the active game (set at boot from the launcher);
-- an explicit version lets callers/tests load a specific game's save.
local FILENAME, BACKUP_FILENAME, TMP_FILENAME = saveNames(version)
local fs = persistFs(nil)
local data, err = readTable(fs, FILENAME)
local recovered
if not data then
@@ -651,6 +832,36 @@ function SaveData.defaultHeal(boot)
return { map = map, x = boot.startX or 3, y = boot.startY or 6 }
end
-- Post-credits home (issue #103). pokered left the player in HALL_OF_FAME
-- after jp Init; this port places CONTINUE at the NewGameWarp bedroom and
-- retargets LAST_MAP exits (Red's house mats) at the heal-point town so
-- leaving the house does not dump the player back at Indigo Plateau.
-- Marks postGameHomeOk so a later intentional HoF save is not relocated.
function SaveData.applyPostGameHome(save, boot)
boot = type(boot) == "table" and boot or {}
local heal = SaveData.defaultHeal(boot)
save.lastHeal = { map = heal.map, x = heal.x, y = heal.y }
save.lastOutdoor = { id = heal.map, x = heal.x, y = heal.y }
save.player = save.player or {}
save.player.map = boot.startMap or "REDS_HOUSE_2F"
save.player.x = boot.startX or 3
save.player.y = boot.startY or 6
save.player.facing = boot.startFacing or "down"
save.postGameHomeOk = true
return heal
end
-- Softlocked 0.1.11 saves: still standing in HALL_OF_FAME after credits,
-- with lastOutdoor on Indigo. One-shot rescue on CONTINUE.
function SaveData.needsPostGameRescue(save)
if not (save and save.player and save.player.map == "HALL_OF_FAME") then
return false
end
if save.postGameHomeOk then return false end
local hof = save.hallOfFame
return type(hof) == "table" and #hof > 0
end
function SaveData.newGame(boot)
boot = type(boot) == "table" and boot or {}
local map = boot.startMap or "REDS_HOUSE_2F"
@@ -658,6 +869,9 @@ function SaveData.newGame(boot)
local heal = SaveData.defaultHeal(boot)
local save = {
meta = { format = Version.saveFormat, mods = {} },
-- which game this playthrough is (Red vs Blue). Only Red ships today;
-- boot carries the choice once Blue support lands.
version = boot.version or "red",
player = {
map = map,
x = x,
@@ -671,6 +885,10 @@ function SaveData.newGame(boot)
},
flags = {},
inventory = {},
-- Vanilla Gen1 seeds one Potion in the player's item PC
-- (wBoxItems / players_pc.asm); existing saves keep whatever they
-- already have — this only applies to New Game.
pcItems = { POTION = 1 },
party = {},
box = {},
money = boot.startMoney or 3000,
+16 -1
View File
@@ -48,7 +48,7 @@ local KEY = {
a = "z",
b = "x",
start = "escape",
select = "rshift",
select = "tab",
}
local function nowMs()
@@ -75,6 +75,21 @@ function TouchInput:init()
self.selectFired = false -- one SELECT per two-finger gesture cluster
end
-- LÖVE has no touchcancelled event, so a touch interrupted by the OS (app
-- backgrounded mid-touch, a system gesture stealing the finger) never fires
-- touchreleased and would otherwise strand its direction held forever.
-- Called from Game alongside Input:reset() on focus/visibility loss.
function TouchInput:reset()
for _, touch in pairs(self.touches) do
releaseDir(self, touch)
end
self.touches = {}
self.pendingA = nil
self.armed = {}
self.autoRelease = {}
self.selectFired = false
end
local function pulse(self, key)
Input:keypressed(key)
self.armed[#self.armed + 1] = key
+3 -3
View File
@@ -7,13 +7,13 @@ local Version = {
engine = "1.0.0", -- game/engine release (semver triple)
modApi = 2, -- mod API major (manifest `api`)
linkProtocol = 2, -- link handshake wire version (Handshake.PROTOCOL)
saveFormat = 2, -- save.meta.format
saveFormat = 4, -- save.meta.format
cache = "rom-cache-v5", -- ROM import cache generation (RomImporter marker)
}
-- "Pokemon Red (Gen 1 Recompilation Project) v1.0.0"
-- "gen1recomp v1.0.0"
function Version.title(base)
return (base or "Pokemon Red (Gen 1 Recompilation Project)")
return (base or "gen1recomp")
.. " v" .. Version.engine
end
+96
View File
@@ -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
+283
View File
@@ -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
+6 -6
View File
@@ -126,14 +126,14 @@ function ImageWriter.columnsToRows(raw, tilesWide, tilesHigh, bytesPerTile)
end
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")
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
error("could not write " .. path .. ": " .. tostring(writeError))
end
+5 -6
View File
@@ -87,12 +87,11 @@ function LuaWriter.encode(value)
end
function LuaWriter.write(path, value)
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, err = love.filesystem.write(path, LuaWriter.encode(value))
-- CacheFs routes this to the OS save directory (normal builds) or straight
-- into the game folder (portable installs); it also creates the parent
-- directories. See src/import/CacheFs.lua.
local CacheFs = require("src.import.CacheFs")
local ok, err = CacheFs.write(path, LuaWriter.encode(value))
if not ok then error("could not write " .. path .. ": " .. tostring(err)) end
end
+46 -7
View File
@@ -1253,14 +1253,24 @@ function RomExtractor:extractTrainers()
"battle/trainers/" .. picture.imageBase .. ".png")
written[picture.imageBase] = true
end
local parties = self:trainerParties(
pointers.bank, partyStarts[index], partyEnds[index])
-- ChiefData is empty in the ROM (the Celadon Chief battle is unused/
-- cut content); tools/rom_manifest.json carries a hand-authored party
-- for the trainers this project reimplements, since no ROM data exists
-- to extract for them.
if #parties == 0 then
local override = self.manifest.trainerPartyOverrides
and self.manifest.trainerPartyOverrides[trainerId]
if override then parties = { copy(override) } end
end
out[trainerId] = {
id = trainerId, index = index, name = decodedNames[index],
source = "ROM:TrainerDataPointers",
pic = picture and picture.path or nil,
baseMoney = math.floor(Rom.bcd(rawMoney) / 100),
aiMods = aiMods[index],
parties = self:trainerParties(
pointers.bank, partyStarts[index], partyEnds[index]),
parties = parties,
}
self:tick("Trainers", index, #order)
end
@@ -1426,7 +1436,7 @@ end
function RomExtractor:extractField()
self:beginStage("Interface artwork")
local done, total = 0, 48
local done, total = 0, 49
local function tick()
done = done + 1
self:tick("Interface artwork", math.min(done, total), total)
@@ -1576,6 +1586,34 @@ function RomExtractor:extractField()
"fx/" .. spec[4], { transparent = true })
tick()
end
-- The cuttable tree's own sprite: InitCutAnimOAM (engine/overworld/cut.asm)
-- copies Overworld_GFX tiles $2d-$2e (top half) and $3d-$3e (bottom half)
-- into the OAM tiles the Cut animation slides apart, so this comes out of
-- the OVERWORLD tileset's own graphics blob rather than a named symbol.
do
local overworldIndex
for i, name in ipairs(self.manifest.constants.tilesetOrder) do
if name == "OVERWORLD" then overworldIndex = i break end
end
assert(overworldIndex, "OVERWORLD tileset not found in tilesetOrder")
local tilesetHeaders = self:symbol("Tilesets")
local rowAddress = tilesetHeaders.address + (overworldIndex - 1) * 12
local gfxBank = self.rom:byte(tilesetHeaders.bank, rowAddress)
local gfxPointer = self.rom:word(tilesetHeaders.bank, rowAddress + 3)
local cutTree = ImageWriter.blank(16, 16, 1, 1, 1, 0)
for _, spec in ipairs({
{ 0x2d, 0, 0 }, { 0x2e, 8, 0 }, { 0x3d, 0, 8 }, { 0x3e, 8, 8 },
}) do
local tileIndex, dx, dy = spec[1], spec[2], spec[3]
local tile = ImageWriter.decode2bpp(
self.rom:bytes(gfxBank, gfxPointer + tileIndex * 16, 16),
8, 8, true)
ImageWriter.blit(cutTree, tile, dx, dy)
end
self:save(cutTree, "fx/cut_tree.png"); tick()
end
self:raw2bpp("BattleTransitionTile", 8, 8,
"fx/battle_transition.png"); tick()
self:raw2bpp("PokedexTileGraphics", 24, 48,
@@ -1631,10 +1669,11 @@ function RomExtractor:extractAudio()
chunks[index] = self.rom.data:sub(first, first + 0x3FFF)
self:tick("Sound programs", index, #bankOrder + 2)
end
local ok, writeError = love.filesystem.createDirectory(
"assets/generated/audio")
if ok == false then error("could not create audio cache: " .. tostring(writeError)) end
ok, writeError = love.filesystem.write(
-- CacheFs (not love.filesystem directly) so a portable install lands this
-- in the game folder with the rest of the cache; it creates the parent
-- directory too.
local CacheFs = require("src.import.CacheFs")
local ok, writeError = CacheFs.write(
"assets/generated/audio/programs.bin", table.concat(chunks))
if not ok then error("could not write audio programs: " .. tostring(writeError)) end
+858 -162
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -11,6 +11,10 @@ local function isBadge(id)
return id:find("BADGE", 1, true) ~= nil
end
-- exported so item lists that share save.inventory (e.g. the PC deposit
-- menu) can exclude badges the same way the bag does
Bag.isBadge = isBadge
function Bag.slots(save)
local n = 0
for id in pairs(save.inventory) do
+49 -27
View File
@@ -36,18 +36,13 @@ local function makeRng(seed)
end
end
-- battler builder shared by both sides -- NO badge boosts, so both
-- machines compute identical stats
-- Battlers go through BattleState.makeBattler so pics get the same
-- Assets.resolve + SGB/GBC palette + padBottom path as wild/trainer
-- battles. save=nil skips badge boosts so both machines keep identical
-- stats (Gen 1 cable battles famously kept badges; we still diverge).
local function mkBattler(data, mon, isPlayer)
local def = data.pokemon[mon.species]
local ok, img = pcall(love.graphics.newImage,
isPlayer and def.spriteBack or def.spriteFront)
return {
mon = mon, def = def, isPlayer = isPlayer, stages = {},
name = mon.nickname or def.name,
curStats = mon.stats, curTypes = def.types, curMoves = mon.moves,
sprite = ok and img or nil,
}
local BattleState = require("src.battle.BattleState")
return BattleState.makeBattler(data, mon, isPlayer, nil)
end
-- canonical (host-side-first) state hash, unchanged since v1: it stays on
@@ -213,6 +208,8 @@ function LinkBattle.new(game, net, opts)
self.player = mkBattler(game.data, myParty[1], true)
self.enemy = mkBattler(game.data, theirParty[1], false)
self.enemyParty = theirParty
self.playerParty = myParty -- intro ball row uses the clamped copies
self.opponentName = theirName
self.introText = ("%s wants\nto battle!"):format(theirName)
self.remoteHashes = {}
self.localHashes = {}
@@ -235,6 +232,43 @@ function LinkBattle.new(game, net, opts)
return nil
end
-- player-side SendOutMon (poof + grow-in + cry); mirrors BattleState
local function sendOutPlayer(s, mon)
local previous = s.player
s.player = mkBattler(game.data, mon, true)
s:syncSides()
Runtime.emit("battle.battler_switched", {
battle = s, side = s.sides[1], battler = s.player, previous = previous,
})
s.sendingOut = true
s:sayNext(s:sendOutText(s.player.name))
s:animNext("POOF_ANIM", false)
s:actNext(function()
s.sendingOut = false
s:startGrowIn(s.player)
require("src.core.Sound").playCry(s.data, s.player.mon.species)
end)
end
-- enemy-side EnemySendOut (grow-in + cry; no poof)
local function sendOutEnemy(s, mon)
local previous = s.enemy
s.enemy = mkBattler(game.data, mon, false)
s:syncSides()
Runtime.emit("battle.battler_switched", {
battle = s, side = s.sides[2], battler = s.enemy, previous = previous,
})
s.enemySendingOut = true
s:sayNext(("%s sent\nout %s!"):format(theirName, s.enemy.name))
s:actNext(function()
s.enemySendingOut = false
s:startGrowIn(s.enemy)
s:actNext(function()
require("src.core.Sound").playCry(s.data, s.enemy.mon.species)
end)
end)
end
-- decode a remote action message against the enemy battler
local function decodeTheirAction(s, msg)
if msg.kind == "move" then
@@ -306,17 +340,11 @@ function LinkBattle.new(game, net, opts)
-- switches happen before attacks (both may switch)
if myMsg.kind == "switch" then
local idx = myMsg.index
s:act(function()
s.player = mkBattler(game.data, myParty[idx], true)
s:sayNext(("Go! %s!"):format(s.player.name))
end)
s:act(function() sendOutPlayer(s, myParty[idx]) end)
myAction = nil
end
if theirSwitch then
s:act(function()
s.enemy = mkBattler(game.data, theirParty[theirSwitch], false)
s:sayNext(("%s sent\nout %s!"):format(theirName, s.enemy.name))
end)
s:act(function() sendOutEnemy(s, theirParty[theirSwitch]) end)
end
s:act(function()
@@ -452,10 +480,7 @@ function LinkBattle.new(game, net, opts)
self.playerMonFainted = function(s)
for _, mon in ipairs(myParty) do
if mon.hp > 0 then
s:act(function()
s.player = mkBattler(game.data, mon, true)
s:sayNext(("Go! %s!"):format(s.player.name))
end)
s:act(function() sendOutPlayer(s, mon) end)
return
end
end
@@ -468,10 +493,7 @@ function LinkBattle.new(game, net, opts)
self.enemyMonFainted = function(s)
for _, mon in ipairs(theirParty) do
if mon.hp > 0 then
s:act(function()
s.enemy = mkBattler(game.data, mon, false)
s:sayNext(("%s sent\nout %s!"):format(theirName, s.enemy.name))
end)
s:act(function() sendOutEnemy(s, mon) end)
return
end
end
+3 -1
View File
@@ -129,7 +129,9 @@ end
-- tripwire on for tests that cannot set the environment.
function Loader.new(opts)
local dev = opts and opts.dev
if dev == nil then dev = os.getenv("POKEPORT_DEV") == "1" end
if dev == nil then
dev = os.getenv("POKEPORT_DEV") == "1" or _G.POKEPORT_DEV_MODE == true
end
local self = setmetatable({
mods = {}, loaded = {}, errors = {}, initialized = false,
events = Events.new(), hooks = Hooks.new(), content = {}, assets = {},
+6
View File
@@ -18,6 +18,12 @@ ManagerState.isOpaque = true
-- Game:keypressed recognizes a directly-pushed instance
ManagerState.screenId = "ManagerState"
-- Same as OptionsMenu: without this, title LOGO zones leak through when
-- MODS is opened from the title-screen options path.
function ManagerState:sgbPalettes(game)
return require("src.render.PaletteFX").wholeNamed(game.data, "MEWMON")
end
-- the charmap has no * ~ + < > glyphs, so the status gutter uses what it
-- does have: staged-awaiting-restart, disabled, errored, dep-unhealthy
local GLYPH = { staged = ".", disabled = "-", errored = "!", blocked = "?" }
+6 -2
View File
@@ -872,11 +872,15 @@ R.palettes = {
}
-- keyed by species id, unlike the vanilla byDex array: a species past the
-- end of the dex gets an icon without punching a hole in the list
-- end of the dex gets an icon without punching a hole in the list. The party
-- menu (src/ui/PartyMenu.lua) reads this per-species entry before the vanilla
-- dex-indexed default. The value is a built-in icon NAME -- one of BALL, BIRD,
-- BUG, FAIRY, GRASS, HELIX, MON, QUADRUPED, SNAKE, WATER (uppercase) -- or a
-- { image = <bundled file path>, frames? } table of your own art.
R.icons = {
semantics = "record", target = "icons.bySpecies",
value = f.union{ f.str, f.rec{ image = f.path, frames = f.opt(f.int(1)) } },
example = 'mod.content.icons:register("MISSINGNO", { image = "glitch.png" })',
example = 'mod.content.icons:register("MODMON", "QUADRUPED") -- a built-in name, or { image = mod.assets:path("icon.png"), frames = 2 }',
}
-- glyph codes are not bytes: the vanilla pages sit at $60/$80 but a
+6 -1
View File
@@ -9,6 +9,7 @@
-- "trade" | "manual" | <mod>, item = id?, ... }), wrapped by the
-- evolution.check hook so a mod can cancel or force any evolution.
local Music = require("src.core.Music")
local Runtime = require("src.mods.Runtime")
local Screens = require("src.ui.Screens")
local Stats = require("src.pokemon.Stats")
@@ -112,11 +113,15 @@ function Evolution.evolve(game, mon, newSpecies, onDone, via)
Screens.push(game, "EvolutionState", mon, newSpecies, onDone)
return
end
Music.play(game.data, Music.special(game.data, "evolution"))
local oldName = mon.nickname or game.data.pokemon[mon.species].name
Evolution.apply(game, mon, newSpecies, via)
local msg = ("What?\n%s is\nevolving!\fCongratulations!\nYour %s\nevolved into\n%s!")
:format(oldName, oldName, game.data.pokemon[newSpecies].name)
game.stack:push(TextBox.new(game, msg, onDone))
game.stack:push(TextBox.new(game, msg, function()
Music.restoreMap(game.data)
if onDone then onDone() end
end))
end
-- Entry point for mods whose methods fire outside the vanilla moments
+8 -2
View File
@@ -9,12 +9,18 @@ local Pokemon = {}
-- keeping the most recent four (engine/pokemon/learn_move.asm behavior).
function Pokemon.movesAtLevel(speciesDef, level)
local moves = {}
local function add(id)
for _, existing in ipairs(moves) do
if existing == id then return end
end
table.insert(moves, id)
end
for _, m in ipairs(speciesDef.level1Moves) do
table.insert(moves, m)
add(m)
end
for _, entry in ipairs(speciesDef.learnset) do
if entry.level <= level then
table.insert(moves, entry.move)
add(entry.move)
end
end
while #moves > 4 do
+11
View File
@@ -148,6 +148,17 @@ function Font.advanceOf(code)
return page and page.advance or GLYPH
end
-- Pixel width of a string (glyph advances, not UTF-8 byte length).
-- Multi-byte charmap entries like "¥" are one glyph; callers that
-- right-align with `#text * 8` mis-place them.
function Font.width(text)
local w = 0
for _, code in ipairs(Font.encode(text)) do
w = w + Font.advanceOf(code)
end
return w
end
-- Draw a plain single-line string at pixel (x, y). Returns the width
-- drawn, which is #codes * 8 for every fixed-width page.
function Font.draw(text, x, y)
+29 -1
View File
@@ -22,6 +22,15 @@ GBCFX.level = 0
local shader -- false = unavailable (headless / no shader support)
-- Mobile GPUs often compile this pass but present a black frame, and the
-- level persists in options.lua -- soft-bricking the APK until a manual
-- edit (issue #136). Desktop is unchanged; Android/iOS refuse the effect.
function GBCFX.isSupported()
if not love or not love.system or not love.system.getOS then return true end
local osName = love.system.getOS()
return osName ~= "Android" and osName ~= "iOS"
end
-- GLSL 1.20-compatible (no array initializers; wavelength terms and the
-- shadow blur are unrolled by hand).
local SHADER_SRC = [[
@@ -214,6 +223,7 @@ vec4 effect(vec4 color, Image tex, vec2 tc, vec2 pc)
GBCFX.SHADER_SRC = SHADER_SRC -- exposed for the standalone compile check
function GBCFX.shader()
if not GBCFX.isSupported() then return nil end
if shader == nil then
local ok, sh = pcall(love.graphics.newShader, SHADER_SRC)
shader = ok and sh or false
@@ -222,6 +232,10 @@ function GBCFX.shader()
end
function GBCFX.setLevel(level)
if not GBCFX.isSupported() then
GBCFX.level = 0
return
end
level = math.floor(tonumber(level) or 0)
if level < 0 then level = 0 end
if level > 4 then level = 4 end
@@ -230,12 +244,26 @@ end
-- Advance OFF → 1 → 2 → 3 → 4 → OFF. Returns the new level.
function GBCFX.cycle()
if not GBCFX.isSupported() then
GBCFX.level = 0
return 0
end
GBCFX.setLevel((GBCFX.level + 1) % 5)
return GBCFX.level
end
-- Apply opts.gbcfx. On unsupported platforms force OFF and clear a
-- persisted non-zero value so boot recovers from a soft-brick. Returns
-- true when opts was sanitized (caller should persist options.lua).
function GBCFX.applyOptions(opts)
if not GBCFX.isSupported() then
local had = opts and (tonumber(opts.gbcfx) or 0) ~= 0
if opts then opts.gbcfx = 0 end
GBCFX.level = 0
return had and true or false
end
GBCFX.setLevel(opts and opts.gbcfx or 0)
return false
end
function GBCFX.levelLabel(level)
@@ -243,7 +271,7 @@ function GBCFX.levelLabel(level)
end
function GBCFX.active()
return GBCFX.level > 0 and GBCFX.shader() ~= nil
return GBCFX.isSupported() and GBCFX.level > 0 and GBCFX.shader() ~= nil
end
-- Draw `canvas` fullscreen through the GBC FX shader into the current
+163 -7
View File
@@ -10,16 +10,23 @@
-- RED++ swaps the named-palette pack for pokered-gbc SuperPalettes
-- (data/palettes_gbc.lua), including per-species mon colors.
local GameVersion = require("src.core.GameVersion")
local PaletteFX = {}
local shader -- false = unavailable (headless / no shader support)
local gbcPack -- false = missing; nil = not loaded yet
-- Cycle order matches OptionsMenu / hotkey 2
PaletteFX.MODES = { "gbc", "redpp", "og", "og_inv", "gbc_inv", "classic" }
-- Cycle order matches OptionsMenu / hotkey 2. The three real colorizations
-- come first (OG RED = GBC hardware, SGB = per-map Super Game Boy, RED++ =
-- pokered-gbc per-tile), then the DMG-shade novelty modes.
PaletteFX.MODES = { "ogred", "gbc", "redpp", "og", "og_inv", "gbc_inv", "classic" }
-- `gbc`/`gbc_inv` keep their save-value ids for back-compat; their LABELS are
-- "SGB"/"SGB INV" because that is what the mode actually is (the old "GBC"
-- label was a misnomer -- it never was the real Game Boy Color palette).
PaletteFX.MODE_LABELS = {
gbc = "GBC", redpp = "RED++", og = "OG", og_inv = "OG INV",
gbc_inv = "GBC INV", classic = "CLASSIC",
ogred = "OG RED", gbc = "SGB", redpp = "RED++", og = "OG",
og_inv = "OG INV", gbc_inv = "SGB INV", classic = "CLASSIC",
}
PaletteFX.mode = "gbc"
@@ -28,6 +35,40 @@ PaletteFX.CLASSIC = {
{ 155, 188, 15 }, { 139, 172, 15 }, { 48, 98, 48 }, { 15, 56, 15 },
}
-- OG RED: the Game Boy Color boot-ROM auto-palette for Pokemon Red. Pokemon
-- Red ships no CGB code (pokered's wOnCGB is hardwired 0), so on a Game Boy
-- Color the boot ROM colorizes it with ONE global palette pair -- a red
-- background and green objects -- applied to the whole game with no per-map
-- variation (that variety was the Super Game Boy's doing, i.e. SGB mode).
-- Lightest shade first, matching the SGB palette tables. Values verified
-- against hardware captures of Pallet Town and Oak's Lab.
PaletteFX.GBC_BG = {
{ 255, 255, 255 }, { 255, 132, 132 }, { 148, 58, 58 }, { 0, 0, 0 },
}
PaletteFX.GBC_OBJ = {
{ 255, 255, 255 }, { 123, 255, 49 }, { 0, 132, 0 }, { 0, 0, 0 },
}
-- OG BLUE: Pokemon Blue's Game Boy Color boot-ROM auto-palette. Same
-- one-global-pair scheme as OG RED (Blue also ships no CGB code), but the
-- boot ROM colorizes the background blue instead of red -- so "OG RED" for a
-- Blue playthrough is white -> light blue -> dark blue -> black, mirroring
-- GBC_BG channel-for-channel so the blue reads at the same brightness. The
-- OBJ (sprite) palette stays the same green, matching how Red and Blue share
-- the green-character look on a Game Boy Color.
PaletteFX.GBC_BG_BLUE = {
{ 255, 255, 255 }, { 132, 132, 255 }, { 58, 58, 148 }, { 0, 0, 0 },
}
-- The active game's OG boot-ROM background palette: blue for a Blue
-- playthrough, red otherwise. White (index 1) and black (index 4) are
-- identical across versions, so callers that only touch the endpoints
-- (e.g. BattleState's zone white/black snap) need no version branch.
function PaletteFX.ogBg()
if GameVersion.isBlue() then return PaletteFX.GBC_BG_BLUE end
return PaletteFX.GBC_BG
end
local INV_MAP = { [0] = 3, [1] = 2, [2] = 1, [3] = 0 }
function PaletteFX.shader()
@@ -141,6 +182,56 @@ function PaletteFX.usesGbcPack(mode)
return mode == "redpp"
end
-- Whether the active mode bakes a per-OBJ palette onto overworld sprites
-- (the OBP bake + post-zone redraw path). ONLY OG RED does: it wears the
-- GBC boot-ROM green object palette (PaletteFX.GBC_OBJ) so the player and
-- NPCs stay green over the red background, exactly like Pokemon Red on a
-- Game Boy Color. SGB mode deliberately does NOT: an SGB OBJ carries no
-- palette of its own, so the characters tint with the whole-map region
-- palette along with the terrain (the Super Game Boy never colored Pokemon
-- Red's sprites separately -- baking a per-sprite palette there was the
-- "reds coloring on the player/NPCs" bug). RED++ colors sprites through
-- the usesGbcPack() path in SpriteRenderer instead.
function PaletteFX.usesSpriteObp(mode)
return (mode or PaletteFX.mode) == "ogred"
end
-- ------- post-zone sprite redraw (GBC mode)
--
-- In GBC mode the world canvas still runs through the per-map zone
-- shade-remap shader, which would corrupt an OBP-baked sprite's true-color
-- pixels. So SpriteRenderer draws the baked sprite into the canvas (its
-- pixels come out zone-tinted there) AND records the draw here;
-- Renderer:endFrame replays the list on top of the finished zone pass,
-- scaled into screen space -- the GBC's OBJ-over-BG compositing, one draw
-- late. Entries carrying `colors` are re-colorized draws (the tall-grass
-- feet overdraw, which must keep hiding sprite feet) issued through the
-- color-0-keyed shade-remap shader. World pass only; cleared per frame.
local spriteRedraws = {}
function PaletteFX.clearSpriteRedraws()
for i = #spriteRedraws, 1, -1 do spriteRedraws[i] = nil end
end
function PaletteFX.markSpriteRedraw(image, quad, x, y, sx, colors, keyed)
if currentPass ~= "world" then return end
spriteRedraws[#spriteRedraws + 1] =
{ image = image, quad = quad, x = x, y = y, sx = sx or 1,
colors = colors, keyed = keyed }
end
-- whether a draw issued right now would land in the redraw list -- the
-- OBP bake is only correct when the replay can restore it after the zone
-- pass (tilt's upright pass colorizes per-billboard instead, so sprites
-- there keep the raw sheet)
function PaletteFX.spriteRedrawPassActive()
return currentPass == "world"
end
function PaletteFX.spriteRedraws()
return spriteRedraws
end
-- Active named-palette table for COLORS: RED++ uses data/palettes_gbc.lua,
-- everything else uses the ROM-imported data.palettes.
function PaletteFX.pack(data)
@@ -151,14 +242,38 @@ function PaletteFX.pack(data)
return data and data.palettes or nil
end
-- SuperPalettes that differ between Red and Blue (pokered data/sgb/
-- sgb_palettes.asm IF DEF(_RED)/_BLUE). data/palettes_gbc.lua is the
-- Red-derived pokered-gbc pack, so under RED++ a Blue playthrough must
-- read these from the ROM-imported table or the title ribbon stays red
-- and the Game Corner reels keep Red's pink (issue #128).
local BLUE_VERSIONED = {
LOGO1 = true, SLOTS2 = true, SLOTS3 = true, SLOTS4 = true,
}
local function romNamedPal(data, name)
local p = data and data.palettes
return p and p.palettes and p.palettes[name]
end
-- named palette from the active pack (nil on stale builds / missing name).
-- RED++ falls back to the ROM pack for names the gbc table omits (rare).
-- OG RED short-circuits EVERY name to the one global GBC boot-ROM BG palette
-- (the hardware had a single BGP for the whole game), so terrain zones,
-- battle HP bars / text, and menu boxes all come out red -- everything a
-- background tile drew. Objects do not come through here (they bake
-- GBC_OBJ green), so this stays a BG-only hook.
function PaletteFX.pal(data, name)
if PaletteFX.mode == "ogred" then return PaletteFX.ogBg() end
if GameVersion.isBlue() and BLUE_VERSIONED[name] then
local fromRom = romNamedPal(data, name)
if fromRom then return fromRom end
end
local p = PaletteFX.pack(data)
local c = p and p.palettes[name]
if c then return c end
if PaletteFX.usesGbcPack() and data and data.palettes then
return data.palettes.palettes[name]
if PaletteFX.usesGbcPack() then
return romNamedPal(data, name)
end
return nil
end
@@ -169,6 +284,11 @@ end
-- Transformed mon's pic is tinted gray, not the copied species' own
-- SGB color). RED++ uses per-species pals from mon_palettes.asm.
function PaletteFX.monPal(data, species, transformed)
-- OG RED: a battle mon pic is a BG tile on the Game Boy Color (drawn into
-- the tilemap, colored by BGP), so it wears the global red BG palette, not
-- a per-species one -- matching the hardware capture where both mons are
-- red/pink on the white field.
if PaletteFX.mode == "ogred" then return PaletteFX.ogBg() end
local p = PaletteFX.pack(data)
if not p then return nil end
if transformed then
@@ -223,6 +343,32 @@ local TILE_GROUP_EXCEPTIONS = {
CELADON_MART_1F = { tiles = { [0x07] = true, [0x08] = true,
[0x17] = true, [0x18] = true }, group = 4 },
}
-- keyed by tileset id (applies on every map that uses it), consulted after
-- the per-map table above
local TILESET_GROUP_EXCEPTIONS = {
-- tile $22 (the hollow-square grave marker) -> GRAY: the extracted pack
-- files it under the bright blue family, which makes a purely
-- decorative floor marker read as an interactive pad
CEMETERY = { tiles = { [0x22] = true }, group = 0 },
}
-- pokered-gbc's lobby.bst repoints the Celadon LOBBY table's flat top
-- (block 29, cells 5/6/9/10) at a duplicate tile ($5a, BROWN) so the
-- tabletop and the checkerboard floor -- both raw tile $37 -- can take
-- different palettes; the vanilla-derived blockset shares the one tile
-- id, so the RED++ atlas path re-creates the duplicate: the alias slot
-- is baked as a copy of `tile` in `group`'s colors, and the listed
-- 0-based block cells draw the alias instead of the shared tile.
-- Same block appears on CELADON_MART_ROOF (#52) and CELADON_DINER (#84).
local LOBBY_TABLE_TOP_ALIAS = {
{ block = 29, cells = { [5] = true, [6] = true, [9] = true, [10] = true },
tile = 0x37, alias = 0x5a, group = 5 },
}
PaletteFX.TILE_ALIASES = {
CELADON_MART_ROOF = LOBBY_TABLE_TOP_ALIAS,
CELADON_DINER = LOBBY_TABLE_TOP_ALIAS,
}
local ROOF_GROUP = 6
local ROUTE_6_SAFFRON = { mapId = "ROUTE_6", useMapId = "SAFFRON_CITY", cellYBelow = 2 }
@@ -244,6 +390,8 @@ function PaletteFX.worldGroupAt(tileset, mapId, tileId)
if not groups then return nil end
local exc = TILE_GROUP_EXCEPTIONS[mapId]
if exc and exc.tiles[tileId] then return exc.group end
exc = TILESET_GROUP_EXCEPTIONS[tileset]
if exc and exc.tiles[tileId] then return exc.group end
return groups[tileId] or 7 -- TEXT: tile ids past the tileset's 96 (menus)
end
@@ -293,6 +441,10 @@ function PaletteFX.spriteObp(spriteDef, seed)
local src = spriteDef and spriteDef.source
if not (w and src) then return nil end
local idx = tonumber(src:match("%[(%d+)%]"))
-- RedBikeSprite loads outside SpriteSheetPointerTable
-- (LoadBikePlayerSpriteGraphics), so its source carries no bracketed
-- index; it wears the player's own palette, same as SPRITE_RED
if not idx and src:find("RedBikeSprite", 1, true) then idx = 0 end
local group = idx and w.spriteAssignment[idx]
if group == nil then return nil end
if group == "random" then
@@ -381,7 +533,11 @@ function PaletteFX.applyOptions(opts)
end
function PaletteFX.modeLabel(mode)
return PaletteFX.MODE_LABELS[mode or PaletteFX.mode] or "GBC"
mode = mode or PaletteFX.mode
-- The GBC boot-ROM mode wears the running game's name: it is red for Red and
-- blue for Blue (see ogBg), so a Blue playthrough shows "OG BLUE".
if mode == "ogred" and GameVersion.isBlue() then return "OG BLUE" end
return PaletteFX.MODE_LABELS[mode] or "GBC"
end
-- When a state exposes no SGB zones but COLORS needs a forced palette
+100 -16
View File
@@ -23,6 +23,29 @@ Renderer.HEIGHT = 144
-- endFrame composites the padded canvas back with a matching offset.
Renderer.UPRIGHT_MARGIN = 160
-- LOVE units + framebuffer pixels + the live unit→pixel ratio.
-- Android's DisplayMetrics.density (love.graphics.getDPIScale) is often
-- non-integer (1.5, 2.75, …). Integer scaling in units then maps each GB
-- pixel to a fractional number of framebuffer pixels → shimmer, uneven /
-- non-square "pixels", and movement judder (issue #87). Always derive the
-- crisp integer scale from the drawable pixel size; draw with (pixels/dpi)
-- so the GPU lands on whole framebuffer pixels. Desktop dpi=1 is unchanged.
local function displayMetrics()
local ww, wh = love.graphics.getDimensions()
local pw, ph = ww, wh
if love.graphics.getPixelDimensions then
pw, ph = love.graphics.getPixelDimensions()
end
local dpi = 1
if ww > 0 and pw > 0 then
dpi = pw / ww
elseif love.graphics.getDPIScale then
dpi = love.graphics.getDPIScale()
end
if not dpi or dpi < 1e-6 then dpi = 1 end
return ww, wh, pw, ph, dpi
end
function Renderer:init()
self.canvas = love.graphics.newCanvas(self.WIDTH, self.HEIGHT)
self.canvas:setFilter("nearest", "nearest")
@@ -36,10 +59,12 @@ function Renderer:init()
self.uprightActive = false
end
-- integer scale that fits the GB UI viewport in the window
-- Integer framebuffer pixels per GB pixel that fit the window. Zoom /
-- GBCFX / callers treat this as the crisp scale; endFrame converts to LOVE
-- units via / dpi when drawing.
function Renderer:fitScale()
local ww, wh = love.graphics.getDimensions()
return math.max(1, math.floor(math.min(ww / self.WIDTH, wh / self.HEIGHT)))
local _, _, pw, ph = displayMetrics()
return math.max(1, math.floor(math.min(pw / self.WIDTH, ph / self.HEIGHT)))
end
-- world-pass canvas size in world pixels: enough to fill the window at s'.
@@ -48,9 +73,13 @@ end
-- background peeking at the receded top/bottom corners; flat mode returns
-- exactly today's size (growth factor is 1 when tilt is inactive).
function Renderer:worldViewSize()
local ww, wh = love.graphics.getDimensions()
local s = Zoom.scale(self:fitScale())
local ww, wh, _, _, dpi = displayMetrics()
local s = Zoom.scale(self:fitScale()) / dpi
local vw, vh = Zoom.fillViewSize(s, ww, wh)
-- Even sizes keep Camera:follow on integer pixels (viewW/2 is integral),
-- so unfloored FX/sprite math cannot phase-shimmer against the tile layer.
if vw % 2 ~= 0 then vw = vw + 1 end
if vh % 2 ~= 0 then vh = vh + 1 end
if Tilt.active() then
local g = Tilt.viewGrowth()
vw, vh = math.ceil(vw * g), math.ceil(vh * g)
@@ -62,8 +91,13 @@ end
function Renderer:beginFrame(transparent)
self.worldActive = false
self.uprightActive = false
-- last frame's trueColor rects go before anything draws this one
-- 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
-- draws this one
PaletteFX.clearTrueColor()
PaletteFX.clearSpriteRedraws()
PaletteFX.setPass("ui")
love.graphics.setCanvas(self.canvas)
if transparent then
@@ -77,6 +111,10 @@ function Renderer:beginWorldPass()
local vw, vh = self:worldViewSize()
if not self.worldCanvas or self.worldCanvas:getWidth() ~= vw
or self.worldCanvas:getHeight() ~= vh then
-- free the old canvas before replacing it: a zoom/tilt tween changes
-- the view size every frame, so without this the superseded canvases
-- pile up in VRAM until a GC finalizer happens to run
if self.worldCanvas and self.worldCanvas.release then self.worldCanvas:release() end
self.worldCanvas = love.graphics.newCanvas(vw, vh)
self.worldCanvas:setFilter("nearest", "nearest")
end
@@ -105,6 +143,7 @@ function Renderer:beginUprightPass()
local cw, ch = vw + 2 * M, vh + 2 * M
if not self.uprightCanvas or self.uprightCanvas:getWidth() ~= cw
or self.uprightCanvas:getHeight() ~= ch then
if self.uprightCanvas and self.uprightCanvas.release then self.uprightCanvas:release() end
self.uprightCanvas = love.graphics.newCanvas(cw, ch)
self.uprightCanvas:setFilter("nearest", "nearest")
end
@@ -198,6 +237,7 @@ function Renderer:drawTiltedWorld(zoneList, s, wox, woy, target)
-- 2x for extra crispness.
if not self.tiltCanvas or self.tiltCanvas:getWidth() ~= wvw
or self.tiltCanvas:getHeight() ~= wvh then
if self.tiltCanvas and self.tiltCanvas.release then self.tiltCanvas:release() end
self.tiltCanvas = love.graphics.newCanvas(wvw, wvh)
self.tiltCanvas:setFilter("linear", "linear")
end
@@ -278,11 +318,14 @@ end
-- presented through the GBC FX shader as a final pass.
function Renderer:endFrame(zones, worldZones)
love.graphics.setCanvas()
local ww, wh = love.graphics.getDimensions()
local S = self:fitScale()
local ww, wh, pw, ph, dpi = displayMetrics()
-- Sp = integer framebuffer pixels per GB pixel; S = LOVE-unit draw scale.
local Sp = self:fitScale()
local S = Sp / dpi
local vpw, vph = self.WIDTH * S, self.HEIGHT * S
local ox = math.floor((ww - vpw) / 2)
local oy = math.floor((wh - vph) / 2)
-- Snap the letterbox origin to a framebuffer pixel, then convert to units.
local ox = math.floor((pw - self.WIDTH * Sp) / 2) / dpi
local oy = math.floor((ph - self.HEIGHT * Sp) / 2) / dpi
local GBCFX = require("src.render.GBCFX")
-- Forced mono/Classic modes still need a whole-screen zone when a state
-- exposes no SGB packets (raw DMG canvas), so sendColors can remap.
@@ -310,9 +353,9 @@ function Renderer:endFrame(zones, worldZones)
love.graphics.rectangle("fill", 0, 0, ww, wh)
love.graphics.setColor(1, 1, 1, 1)
-- blit `canvas` at integer `scale` into origin (bx, by), scissored to
-- blit `canvas` at `scale` (LOVE units) into origin (bx, by), scissored to
-- the (boxX, boxY, boxW, boxH) screen rect. zoneScale converts zone
-- coords (canvas-space) into screen pixels.
-- coords (canvas-space) into screen units.
local function blit(canvas, scale, zoneList, zoneScale, bx, by, boxX, boxY, boxW, boxH)
local shader = zoneList and zoneList[1] and PaletteFX.shader() or nil
if not shader then
@@ -344,11 +387,12 @@ function Renderer:endFrame(zones, worldZones)
end
if self.worldActive then
local s = Zoom.scale(S)
local sp = Zoom.scale(Sp)
local s = sp / dpi
local wvw = self.worldCanvas:getWidth()
local wvh = self.worldCanvas:getHeight()
local wox = math.floor((ww - wvw * s) / 2)
local woy = math.floor((wh - wvh * s) / 2)
local wox = math.floor((pw - wvw * sp) / 2) / dpi
local woy = math.floor((ph - wvh * sp) / 2) / dpi
-- Tilt mode projects the ground world pass through the perspective mesh
-- (SGB zones baked in beforehand -- see drawTiltedWorld -- so no zone
-- scissoring here). drawTiltedWorld returns false when tilt is off or
@@ -363,6 +407,35 @@ function Renderer:endFrame(zones, worldZones)
else
blit(self.worldCanvas, s, zones, S, wox, woy, 0, 0, ww, wh)
end
-- OBP-baked overworld sprites replay on top of the zone pass (GBC
-- mode per-object coloring; see PaletteFX.markSpriteRedraw). Grass
-- feet-overdraw entries carry `colors` and re-colorize through the
-- color-0-keyed shade-remap shader so they keep hiding sprite feet.
local redraws = PaletteFX.spriteRedraws()
if redraws[1] then
love.graphics.setColor(1, 1, 1, 1)
love.graphics.setScissor(0, 0, ww, wh)
local activeShader = nil
for _, r in ipairs(redraws) do
local wanted = r.colors
and (r.keyed and PaletteFX.keyedShader() or PaletteFX.shader())
or nil
if wanted ~= activeShader then
activeShader = wanted
love.graphics.setShader(wanted)
end
if wanted then PaletteFX.sendColors(wanted, r.colors) end
if r.quad then
love.graphics.draw(r.image, r.quad, wox + r.x * s, woy + r.y * s,
0, s * r.sx, s)
else
love.graphics.draw(r.image, wox + r.x * s, woy + r.y * s,
0, s * r.sx, s)
end
end
if activeShader then love.graphics.setShader() end
love.graphics.setScissor()
end
end
-- Composite the tilt upright pass over the ground (projected or, in the
-- rare no-shader fallback, flat). It already carries its billboards'
@@ -377,13 +450,24 @@ function Renderer:endFrame(zones, worldZones)
love.graphics.draw(self.uprightCanvas, wox - M * s, woy - M * s, 0, s, s)
love.graphics.setScissor()
end
-- Screen-space warp fade (Transition) over the full world composite so
-- survey zoom / tilt edges darken with the center, not only the 160x144
-- UI letterbox. Drawn before the UI blit so menus above a fade still
-- composite normally if one is ever stacked that way.
local fade = self.worldFadeAlpha
if fade and fade > 0 then
love.graphics.setColor(0, 0, 0, fade)
love.graphics.rectangle("fill", 0, 0, ww, wh)
love.graphics.setColor(1, 1, 1, 1)
end
end
-- UI stays in the classic centered GB letterbox
blit(self.canvas, S, zones, S, ox, oy, ox, oy, vpw, vph)
if present then
love.graphics.setCanvas()
GBCFX.present(present, S)
-- shader grid/shadow math is in framebuffer pixels
GBCFX.present(present, Sp)
end
self.worldActive = false
self.uprightActive = false
+20 -6
View File
@@ -85,10 +85,21 @@ end
-- facing: down/up/left/right; walkPhase: 0 stand, 1 walk; flip: alternate
-- steps mirror the walk frame for up/down (GB uses OAM flip for this).
local function blitFrame(image, quad, x, y, flip, redraw)
if flip then
love.graphics.draw(image, quad, x + 16, y, 0, -1, 1)
if redraw then PaletteFX.markSpriteRedraw(image, quad, x + 16, y, -1) end
else
love.graphics.draw(image, quad, x, y)
if redraw then PaletteFX.markSpriteRedraw(image, quad, x, y, 1) end
end
end
function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip)
local x = math.floor(px - camX)
local y = math.floor(py - camY) - 4
local image = self.image
local redraw = false
-- full-color art claims its 16x16 cell out of the shade-remap pass
if self.def.trueColor then
PaletteFX.markTrueColor(x, y, 16, 16)
@@ -102,12 +113,19 @@ function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip)
if colors then
image = getObpImage(self.def.image, colors, group)
end
elseif PaletteFX.usesSpriteObp() and PaletteFX.spriteRedrawPassActive() then
-- OG RED (GBC boot-ROM look): every OBJ wears the one global green
-- object palette. The red BG zone shader still runs over the world
-- canvas, so the baked sprite is queued for a post-zone redraw
-- (PaletteFX.markSpriteRedraw) that restores its green pixels on top.
image = getObpImage(self.def.image, PaletteFX.GBC_OBJ, "gbcobj")
redraw = true
end
-- single-frame sprites (item balls, fossils...) have one fixed pose;
-- still 3-frame sprites turn to face (the nurse at her machine,
-- facePlayer on STAY NPCs) but never show walk frames
if self.def.frames <= 1 then
love.graphics.draw(image, self.frames[0], x, y)
blitFrame(image, self.frames[0], x, y, false, redraw)
return
end
local frame = (self.def.walker and walkPhase == 1)
@@ -119,11 +137,7 @@ function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip)
flip = true
end
local quad = self.frames[frame] or self.frames[0]
if flip then
love.graphics.draw(image, quad, x + 16, y, 0, -1, 1)
else
love.graphics.draw(image, quad, x, y)
end
blitFrame(image, quad, x, y, flip, redraw)
end
return SpriteRenderer
+234 -83
View File
@@ -55,8 +55,29 @@ local FLOWER_IMAGES = {
local SPINNER_STRIP = "assets/generated/tilesets/spinners.png"
local animFrame = 0
function TileRenderer.tick()
animFrame = animFrame + 1
local animAccum = 0
local ANIM_STEP = 1 / 60 -- Game Boy logic rate (matches FixedStep.STEP)
-- Advance water/flower/spinner tile animation. Called from the overworld
-- draw path so it keeps running under dialogs (overworld update does not),
-- but consumes wall-clock dt into 60Hz steps so high/low display refresh
-- no longer speeds up or slows the cycle (issue #4).
-- tick() / tick(nil) with no love.timer.getDelta (headless tests) still
-- advances exactly one frame per call.
function TileRenderer.tick(dt)
if dt == nil and love and love.timer and love.timer.getDelta then
dt = love.timer.getDelta()
end
if dt == nil then
animFrame = animFrame + 1
return
end
-- Cap catch-up so a long stall cannot jump many water/flower periods
animAccum = math.min(animAccum + dt, 0.25)
while animAccum >= ANIM_STEP do
animAccum = animAccum - ANIM_STEP
animFrame = animFrame + 1
end
end
-- ------------------------------------------------------------------
@@ -90,8 +111,8 @@ function TileRenderer.setSpinning(active)
end
-- true while the spinner arrow tiles should show the 'blur' graphic; false
-- means draw nothing extra (the static mapBatch/ringBatch tile shows
-- through, matching the asm's restore-to-original behavior). The 8-tick
-- means draw nothing extra (the static window tile shows through,
-- matching the asm's restore-to-original behavior). The 8-tick
-- half-period approximates one GB movement step (2px/frame); this is a
-- deliberate approximation of wSimulatedJoypadStatesIndex bit-0 parity, not
-- a cycle-accurate replication -- the port's tweened scriptMove has no
@@ -347,9 +368,10 @@ local function getGbcAtlas(imagePath, tilesetId, mapId, perRow, data)
if groupColors then
local src = Assets.imageData(imagePath)
local iw, ih = src:getDimensions()
local total = (iw / 8) * (ih / 8)
local out = love.image.newImageData(iw, ih)
local tileColors = {}
for t = 0, (iw / 8) * (ih / 8) - 1 do
for t = 0, total - 1 do
local colors = tileColors[t]
if colors == nil then
local group = PaletteFX.worldGroupAt(tilesetId, mapId, t)
@@ -366,6 +388,25 @@ local function getGbcAtlas(imagePath, tilesetId, mapId, perRow, data)
end
end
end
-- duplicate-tile aliases: bake a copy of a shared tile graphic into
-- a spare slot under a different palette group, so block cells that
-- draw the alias can color apart from cells sharing the raw tile
for _, al in ipairs(PaletteFX.TILE_ALIASES and PaletteFX.TILE_ALIASES[mapId] or {}) do
if al.alias < total then
local colors = groupColors[al.group + 1]
local sxo = (al.tile % perRow) * 8
local syo = math.floor(al.tile / perRow) * 8
local dxo = (al.alias % perRow) * 8
local dyo = math.floor(al.alias / perRow) * 8
for py = 0, 7 do
for px = 0, 7 do
local r, g, b, a = src:getPixel(sxo + px, syo + py)
r, g, b, a = recolorSample(r, g, b, a, colors)
out:setPixel(dxo + px, dyo + py, r, g, b, a)
end
end
end
end
img = love.graphics.newImage(out)
end
end
@@ -406,22 +447,24 @@ function TileRenderer.new(map, data)
end
local def = map.def
local wB, hB = def.width, def.height
-- two batches: the border-block ring around the map, and the map body.
-- Connected-map strips draw body-only on top of this map's ring.
local total = (wB + 2 * BORDER_BLOCKS) * (hB + 2 * BORDER_BLOCKS) * 16
self.ringBatch = love.graphics.newSpriteBatch(self.image, total, "static")
self.mapBatch = love.graphics.newSpriteBatch(self.image, wB * hB * 16, "static")
-- animated tiles overdraw the static batches each frame. Entry order
-- decides which one claims a tile listed twice, so the vanilla defaults
-- keep the old water-then-flower-then-spinner precedence.
-- The map body measured in 8px tiles (each block is 4x4 tiles). The tile
-- layer is drawn windowed to the camera (see :ensureWindow) instead of
-- baked into a whole-map SpriteBatch, so a map becoming visible -- a warp,
-- a connection seam -- costs nothing to "build": there is no per-map batch
-- construction that scales with map size, which is what stuttered.
self.bodyTilesW = def.width * 4
self.bodyTilesH = def.height * 4
-- Animated tiles overdraw the static window each frame. Only the per-entry
-- render spec (textures/sequence/gate) is kept here; the animated cells are
-- gathered per camera window in :ensureWindow, so nothing here scales with
-- map size either. Entry order decides which entry claims a tile listed
-- twice (the vanilla water-then-flower-then-spinner precedence).
local anims, claimedBy = {}, {}
local declared = map.tileset.animatedTiles
or TileRenderer.defaultAnimatedTiles(map.tileset)
for _, spec in ipairs(declared) do
local anim = buildAnim(spec, map.tileset.image, perRow, self.quads, gbcCtx)
if anim then
anim.cells = {}
anims[#anims + 1] = anim
for _, tile in ipairs(anim.tiles) do
if claimedBy[tile] == nil then claimedBy[tile] = anim end
@@ -429,53 +472,20 @@ function TileRenderer.new(map, data)
end
end
for by = -BORDER_BLOCKS, hB + BORDER_BLOCKS - 1 do
for bx = -BORDER_BLOCKS, wB + BORDER_BLOCKS - 1 do
local inside = bx >= 0 and by >= 0 and bx < wB and by < hB
local batch = inside and self.mapBatch or self.ringBatch
local block = map.tileset.blocks[map:blockAt(bx, by) + 1]
for ty = 0, 3 do
for tx = 0, 3 do
local tile = block[ty * 4 + tx + 1]
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
-- duplicate-tile alias remap (RED++ atlas only): [blockId][0-based cell]
-- -> alias tile id (see PaletteFX.TILE_ALIASES / getGbcAtlas's bake)
local aliasMap
if gbcCtx then
for _, al in ipairs(PaletteFX.TILE_ALIASES and PaletteFX.TILE_ALIASES[map.id] or {}) do
aliasMap = aliasMap or {}
local cells = aliasMap[al.block] or {}
for ci in pairs(al.cells) do cells[ci] = al.alias end
aliasMap[al.block] = cells
end
end
-- animated overdraw batches: the full set (ring + body) for the
-- current map, and a body-only set for connected-map drawing --
-- a neighbor's water ring must never overdraw this map's tiles.
-- `quadFor`, when given, looks up a per-entry quad (used by toggle
-- entries, whose texture is a full tileset-atlas clone rather than a
-- single-tile image like the hshift/frames variants).
local function animBatches(entries, image, quadFor)
if #entries == 0 then return nil, nil end
local all = love.graphics.newSpriteBatch(image, #entries, "static")
local body
for _, c in ipairs(entries) do
if quadFor then all:add(quadFor(c[4]), c[1], c[2]) else all:add(c[1], c[2]) end
if c[3] then
body = body or love.graphics.newSpriteBatch(image, #entries, "static")
if quadFor then body:add(quadFor(c[4]), c[1], c[2]) else body:add(c[1], c[2]) end
end
end
return all, body
end
for _, anim in ipairs(anims) do
anim.batch, anim.bodyBatch =
animBatches(anim.cells, anim.textures[1], anim.quadFor)
anim.cells = nil
end
self.aliasMap = aliasMap
self.anims = anims
self.claimedBy = claimedBy
-- a repeating 32x32 image of the border block, tiled behind
-- everything the 3-block ring doesn't cover (the survey zoom sees
@@ -510,8 +520,16 @@ function TileRenderer:drawBorderFill(camX, camY, vw, vh)
if not self.borderFill then return end
if self.trueColor then PaletteFX.markTrueColor(0, 0, vw, vh) end
local x, y = math.floor(camX), math.floor(camY)
local quad = love.graphics.newQuad(x, y, vw, vh, 32, 32)
love.graphics.draw(self.borderFill, quad, 0, 0)
-- one reused Quad per renderer, mutated in place: this runs every
-- overworld frame, so allocating a fresh Quad here churned the GC
local q = self.borderQuad
if q then
q:setViewport(x, y, vw, vh, 32, 32)
else
q = love.graphics.newQuad(x, y, vw, vh, 32, 32)
self.borderQuad = q
end
love.graphics.draw(self.borderFill, q, 0, 0)
end
-- GB OBJ-to-BG priority: sprites show through BG color 0 and hide under
@@ -556,19 +574,114 @@ function TileRenderer:drawCellBottom(cx, cy, camX, camY)
if shader then love.graphics.setShader() end
end
-- animated overdraw at the current step; bodyOnly skips the ring
-- positions (connected maps draw body-only)
function TileRenderer:drawAnimated(camX, camY, bodyOnly)
-- queue the same bottom tile row for the post-zone sprite-redraw pass
-- (GBC mode: OBP-baked sprites replay after the zone shader, so the
-- grass patch that hides their feet must replay over them, colorized
-- with the map's palette and color-0 keyed)
function TileRenderer:markCellBottomRedraw(cx, cy, camX, camY, colors)
local ty = cy * 2 + 1
for i = 0, 1 do
local tx = cx * 2 + i
local quad = self.quads[self.map:tileAt(tx, ty)]
if quad then
PaletteFX.markSpriteRedraw(self.image, quad, tx * 8 - math.floor(camX),
ty * 8 - math.floor(camY), 1, colors, true)
end
end
end
-- Window cover for the static tile layer. Refill the reusable window batch
-- (and the per-entry animated batches) only when the camera has scrolled past
-- what they already cover; a small margin keeps small scrolls free. Cost
-- scales with the view, never the map -- crossing a seam or warping in builds
-- nothing. The beyond-body area (what the old 3-block ring drew) is painted
-- by :drawBorderFill, whose world-aligned border-block tiling is identical
-- there, so only body tiles are gathered here.
local WINDOW_MARGIN = 8 -- tiles of slack kept around the view between refills
function TileRenderer:ensureWindow(camX, camY, vw, vh)
local W, H = self.bodyTilesW, self.bodyTilesH
vw = vw or W * 8 -- a nil view (headless draw) means the whole body
vh = vh or H * 8
-- visible body-tile range (8px tiles), clamped to the map body
local tx0 = math.min(W, math.max(0, math.floor(camX / 8)))
local ty0 = math.min(H, math.max(0, math.floor(camY / 8)))
local tx1 = math.max(0, math.min(W, math.floor((camX + vw) / 8) + 1))
local ty1 = math.max(0, math.min(H, math.floor((camY + vh) / 8) + 1))
local win = self.win
if win and tx0 >= win.tx0 and ty0 >= win.ty0
and tx1 <= win.tx1 and ty1 <= win.ty1 then
return -- still inside the last fill
end
-- refill with margin so the next few scrolled pixels stay covered
tx0 = math.max(0, tx0 - WINDOW_MARGIN)
ty0 = math.max(0, ty0 - WINDOW_MARGIN)
tx1 = math.min(W, tx1 + WINDOW_MARGIN)
ty1 = math.min(H, ty1 + WINDOW_MARGIN)
if not self.winBatch then
self.winBatch = love.graphics.newSpriteBatch(self.image, 1024, "dynamic")
end
self.winBatch:clear()
local anims = self.anims
for _, anim in ipairs(anims) do
if not anim.batch then
anim.batch = love.graphics.newSpriteBatch(anim.textures[1], 256, "dynamic")
end
anim.batch:clear()
end
local map, quads = self.map, self.quads
local claimedBy, aliasMap = self.claimedBy, self.aliasMap
for ty = ty0, ty1 - 1 do
local by = math.floor(ty / 4)
local ty4 = ty % 4
for tx = tx0, tx1 - 1 do
local blockId = map:blockAt(math.floor(tx / 4), by)
local block = map.tileset.blocks[blockId + 1]
if block then
local ci = ty4 * 4 + (tx % 4)
local tile = block[ci + 1]
local remap = aliasMap and aliasMap[blockId]
if remap and remap[ci] then tile = remap[ci] end
local wx, wy = tx * 8, ty * 8
local quad = quads[tile]
if quad then self.winBatch:add(quad, wx, wy) end
local anim = claimedBy[tile]
if anim then
if anim.quadFor then
anim.batch:add(anim.quadFor(tile), wx, wy)
else
anim.batch:add(wx, wy)
end
end
end
end
end
self.win = { tx0 = tx0, ty0 = ty0, tx1 = tx1, ty1 = ty1 }
end
-- draw the static tile window, then its animated overdraw, at the camera offset
function TileRenderer:drawWindow(camX, camY, vw, vh)
self:ensureWindow(camX, camY, vw, vh)
if self.winBatch then
love.graphics.draw(self.winBatch, -math.floor(camX), -math.floor(camY))
end
self:drawAnimated(camX, camY)
end
-- animated overdraw at the current step, over the static window batch. The
-- cells were gathered for the current camera window by :ensureWindow, so this
-- only ever touches on-screen animated tiles.
function TileRenderer:drawAnimated(camX, camY)
local anims = self.anims
if not anims then return end
local x, y = -math.floor(camX), -math.floor(camY)
for _, anim in ipairs(anims) do
local batch = bodyOnly and anim.bodyBatch or anim.batch
local batch = anim.batch
if batch then
if anim.gate then
-- a gated entry has only the two frames the asm has (patch /
-- restore-to-static); when the gate is shut draw nothing so the
-- already-static mapBatch/ringBatch tile shows through unchanged
-- already-static window tile shows through unchanged
if gateOpen(anim.gate) then love.graphics.draw(batch, x, y) end
else
local step = math.floor(animFrame / anim.period) % #anim.sequence + 1
@@ -589,30 +702,68 @@ function TileRenderer:markTrueColor(camX, camY, blocks)
(def.height + 2 * blocks) * 32)
end
function TileRenderer:draw(camX, camY)
function TileRenderer:draw(camX, camY, vw, vh)
if self.trueColor then self:markTrueColor(camX, camY, BORDER_BLOCKS) end
love.graphics.draw(self.ringBatch, -math.floor(camX), -math.floor(camY))
love.graphics.draw(self.mapBatch, -math.floor(camX), -math.floor(camY))
self:drawAnimated(camX, camY)
self:drawWindow(camX, camY, vw, vh)
end
-- body only, for connected-map strips
function TileRenderer:drawMapOnly(camX, camY)
-- body only, for connected-map strips. Identical to :draw now that the
-- border ring is served by :drawBorderFill for the current map too -- the
-- only remaining difference is the trueColor mark extent.
function TileRenderer:drawMapOnly(camX, camY, vw, vh)
if self.trueColor then self:markTrueColor(camX, camY, 0) end
love.graphics.draw(self.mapBatch, -math.floor(camX), -math.floor(camY))
self:drawAnimated(camX, camY, true)
self:drawWindow(camX, camY, vw, vh)
end
-- rebuild after a block change (Cut trees)
local function safeRelease(o)
if o and o.release then pcall(o.release, o) end
end
-- Release only the GPU objects this instance built and uniquely owns: the
-- two SpriteBatches, the border-fill image and its quad, the per-tile
-- quads, and the animated-overdraw batches. Deliberately leaves the
-- tileset atlas (self.image, shared through Assets/imageCache) and the
-- animation textures (shared module caches) alone -- other maps still use
-- them. Used by :rebuild before it swaps in fresh batches, and by
-- :release on eviction.
function TileRenderer:releaseBatches()
safeRelease(self.winBatch); self.winBatch = nil
safeRelease(self.borderFill); self.borderFill = nil
safeRelease(self.borderQuad); self.borderQuad = nil
self.win = nil
if self.quads then
for _, q in pairs(self.quads) do safeRelease(q) end
self.quads = nil
end
if self.anims then
for _, a in ipairs(self.anims) do
safeRelease(a.batch); a.batch = nil
-- a.textures are shared, module-cached: never released here
end
end
end
-- Full teardown for eviction (MapLoader.evict): the owned batches, plus
-- the RED++ per-map recolored atlas, which -- unlike the plain tileset
-- atlas -- is unique to this map (gbcAtlasCache is keyed by map id).
function TileRenderer:release()
self:releaseBatches()
if self.gbcAtlas and self.image then
local key = self.map.tileset.image .. "#gbc:" .. self.map.id
if gbcAtlasCache[key] == self.image then gbcAtlasCache[key] = nil end
safeRelease(self.image)
self.image = nil
self.gbcAtlas = nil
end
self.anims = nil
end
-- rebuild after a block change (Cut trees, card-key doors). The tile layer
-- is read live from the map on every window fill, so a block swap only needs
-- the cached window dropped -- the next draw re-reads the changed blocks. No
-- SpriteBatch is reconstructed; that is the whole point of the windowed draw.
function TileRenderer:rebuild()
local fresh = TileRenderer.new(self.map, self.data)
self.image = fresh.image
self.gbcAtlas = fresh.gbcAtlas
self.quads = fresh.quads
self.ringBatch = fresh.ringBatch
self.mapBatch = fresh.mapBatch
self.anims = fresh.anims
self.borderFill = fresh.borderFill
self.win = nil
end
-- drop every atlas and every derived animation texture so the next
+10
View File
@@ -57,6 +57,16 @@ end
function Transition:draw()
local alpha = self.t / self.frames
if self.phase == "in" then alpha = 1 - alpha end
-- Survey zoom draws the overworld into a window-filling world canvas
-- while the UI pass stays the classic 160x144 letterbox. A rect on the
-- UI canvas only darkens that center box (issue #121); when the world
-- pass ran this frame, hand the alpha to Renderer:endFrame so it paints
-- a screen-space overlay over the full composite instead.
local r = self.game and self.game.renderer
if r and r.worldActive then
r.worldFadeAlpha = alpha
return
end
love.graphics.setColor(0, 0, 0, alpha)
love.graphics.rectangle("fill", 0, 0, 160, 144)
love.graphics.setColor(1, 1, 1, 1)
+159 -27
View File
@@ -5,6 +5,7 @@
-- Conditionals: check_flag stores its result in ctx.lastCheck;
-- jump_if_true/jump_if_false jump to an absolute row index.
local Collision = require("src.world.Collision")
local Flags = require("src.script.Flags")
local Logger = require("src.core.Logger")
local Screens = require("src.ui.Screens")
@@ -269,7 +270,10 @@ end
local DIRS4 = { { 0, -1, "up" }, { 0, 1, "down" },
{ -1, 0, "left" }, { 1, 0, "right" } }
local function bfsPath(map, sx, sy, tx, ty)
-- entities/mover let the walk route around other entities' cells (the
-- player especially), so a scripted NPC never clips through them; the
-- target cell is exempt so an NPC can still walk up onto its goal
local function bfsPath(map, sx, sy, tx, ty, entities, mover)
local key = function(x, y) return y * 1000 + x end
local prev = { [key(sx, sy)] = false }
local queue = { { sx, sy } }
@@ -289,8 +293,11 @@ local function bfsPath(map, sx, sy, tx, ty)
for _, d in ipairs(DIRS4) do
local nx, ny = cx + d[1], cy + d[2]
local nk = key(nx, ny)
local isTarget = nx == tx and ny == ty
if prev[nk] == nil and map:inBounds(nx, ny)
and (map:isWalkableCell(nx, ny) or (nx == tx and ny == ty)) then
and (map:isWalkableCell(nx, ny) or isTarget)
and (isTarget or not entities
or not Collision.occupied(entities, nx, ny, mover)) then
prev[nk] = { cx, cy, d[3] }
table.insert(queue, { nx, ny })
end
@@ -303,7 +310,7 @@ function Commands.move_npc_to(ctx, objIndex, tx, ty)
local ow = ctx.overworld
local npc = ow:npcByIndex(objIndex)
if not npc then return end
local path = bfsPath(ow.map, npc.cellX, npc.cellY, tx, ty)
local path = bfsPath(ow.map, npc.cellX, npc.cellY, tx, ty, ow.entities, npc)
if not path then
Logger.warn("move_npc_to: no path to (%d,%d)", tx, ty)
return
@@ -390,6 +397,7 @@ local function toggleObject(ctx, mapId, objName, visible)
-- positions mid-cutscene)
local ow = ctx.overworld
if not ow or ow.map.id ~= mapId then return end
ow.npcPool = ow.npcPool or {}
if visible then
for _, n in ipairs(ow.npcs) do
if n.def.name == objName then return end
@@ -400,12 +408,16 @@ local function toggleObject(ctx, mapId, objName, visible)
local npc = NPC.new(ctx.game.data, mapId, obj)
table.insert(ow.npcs, npc)
table.insert(ow.entities, npc)
ow.npcPool[npc.id] = npc
return
end
end
else
for i = #ow.npcs, 1, -1 do
if ow.npcs[i].def.name == objName then table.remove(ow.npcs, i) end
if ow.npcs[i].def.name == objName then
ow.npcPool[ow.npcs[i].id] = nil
table.remove(ow.npcs, i)
end
end
for i = #ow.entities, 1, -1 do
local e = ow.entities[i]
@@ -426,6 +438,20 @@ function Commands.play_sound(ctx, soundId)
require("src.core.Sound").play(ctx.game.data, soundId)
end
-- play_once <songId>: one-shot jingle (Music_PkmnHealed, etc.); blocks
-- until it finishes so heal-rest scripts (Mom, captain text_asm) match
-- Gen1's wait-on-channel loop. The map theme resumes when it ends
-- (Music.playOnce / pendingRestore).
function Commands.play_once(ctx, songId)
local Music = require("src.core.Music")
if not Music.playOnce(ctx.game.data, songId) then return end
local runner = ctx.runner
runner.waitingCheck = function()
return not Music.oneShotPlaying()
end
runner:yield()
end
-- play_cry <species>: PlayCry (home/audio.asm). The text_asm bodies that
-- use it run text_far (a no-button-wait "...@" string) -> PlayCry ->
-- WaitForSoundToFinish, all within the same text ID -- the cry only starts
@@ -458,11 +484,51 @@ function Commands.heal_party(ctx)
end
end
-- AskName (engine/menus/naming_screen.asm): yes/no then NamingScreen.
-- AddPartyMon only offers this for party mons (box deposits skip it).
-- Preserves ctx.lastCheck so GivePokemon's carry is not clobbered by the
-- yes/no result (scripts jump_if_false on give failure afterwards).
local function askNickname(ctx, mon)
local runner = ctx.runner
if not runner then return end
local success = ctx.lastCheck
local name = ctx.game.stringBuffer
or (ctx.game.data.pokemon[mon.species]
and ctx.game.data.pokemon[mon.species].name)
or mon.species
-- Prefer the real text label; fall back to the BattleState wording.
if ctx.game.data.text and ctx.game.data.text._DoYouWantToNicknameText then
Commands.show_text(ctx, "_DoYouWantToNicknameText", { RAM = name })
else
Commands.show_text(ctx, ("Do you want to\ngive a nickname\nto %s?"):format(name))
end
local ChoiceBox = require("src.ui.ChoiceBox")
ctx.game.stack:push(ChoiceBox.new(ctx.game, function(yes)
if not yes then
ctx.lastCheck = success
runner:resume()
return
end
Screens.push(ctx.game, "NamingScreen", {
title = "NICKNAME?", maxLen = 10,
onDone = function(nick)
if nick and #nick > 0 then mon.nickname = nick end
ctx.lastCheck = success
runner:resume()
end,
})
end))
runner:yield()
ctx.lastCheck = success
end
-- give_pokemon <species> <level>: _GivePokemon (engine/events/
-- give_pokemon.asm) -- party first, then the box. ctx.lastCheck gets
-- the asm's carry: true when the mon was given, false when both the
-- party and every box are full (that .boxFull path leaves the giver's
-- script able to offer again later, e.g. the Celadon Eevee ball).
-- Party adds run AskName (AddPartyMon) when a script runner is present;
-- mods that pre-set gift.nickname skip the prompt.
function Commands.give_pokemon(ctx, species, level)
-- Native mods can transform a gift before the Pokémon object is created.
-- This is intentionally an event rather than a special-case starter hook:
@@ -479,7 +545,8 @@ function Commands.give_pokemon(ctx, species, level)
ctx.game.stringBuffer = ctx.game.data.pokemon[species].name or species
ctx.pendingPokemonName = species
require("src.battle.BattleState").stampOT(ctx.save, mon)
if not Party.add(ctx.save.party, mon) then
local addedToParty = Party.add(ctx.save.party, mon)
if not addedToParty then
if not require("src.pokemon.Boxes").deposit(ctx.save, mon) then
ctx.lastCheck = false
return
@@ -491,18 +558,40 @@ function Commands.give_pokemon(ctx, species, level)
dex.owned[species] = true
end
ctx.lastCheck = true
-- AddPartyMon AskName: party only; skip box deposits, mod-set nicknames,
-- and callback-style callers that have no script runner to yield on.
if addedToParty and not gift.nickname and ctx.runner then
askNickname(ctx, mon)
end
end
function Commands.give_money(ctx, amount)
ctx.save.money = math.max(0, ctx.save.money + amount)
end
-- Point LAST_MAP exits at an outdoor door (pokered wLastMap). Keeps the
-- live overworld memory in sync so a scripted home warp from the HoF PC
-- does not leave Red's house mats aimed at Indigo Plateau (#103).
function Commands.remember_outdoor(ctx, mapId, x, y)
local outdoor = { id = mapId, x = x, y = y }
ctx.save.lastOutdoor = outdoor
if ctx.overworld and ctx.overworld.rememberOutdoor then
ctx.overworld:rememberOutdoor(mapId, x, y)
elseif ctx.overworld then
ctx.overworld.lastOutdoor = outdoor
end
end
-- Hall of Fame: snapshot the winning party (SaveHallOfFameTeams inside
-- AnimateHallOfFame), run the induction showcase and the end credits,
-- autosave while THE END is up, then soft-reset to the title -- the whole
-- predef HallOfFamePC + tail of HallOfFameResetEventsAndSaveScript
-- (engine/movie/hall_of_fame.asm, engine/movie/credits.asm,
-- scripts/HallOfFame.asm).
--
-- Departure from pokered: CONTINUE lands in the NewGameWarp bedroom with
-- a healed party and lastOutdoor on Pallet Town, instead of remaining
-- softlocked in HALL_OF_FAME with LAST_MAP still aimed at Indigo (#103).
function Commands.record_hall_of_fame(ctx)
ctx.save.hallOfFame = ctx.save.hallOfFame or {}
local entry = {}
@@ -518,17 +607,25 @@ function Commands.record_hall_of_fame(ctx)
Screens.push(game, "Credits", function()
runner:resume()
end, function()
-- THE END is on screen: HallOfFameResetEventsAndSaveScript sets
-- wLastBlackoutMap := PALLET_TOWN and runs SaveGameData, so the
-- save keeps the player standing in the HALL_OF_FAME room. (The
-- E4 room-script/event resets that precede the save in pokered are
-- the Indigo lobby's re-entry reset here, data/scripts/story6.lua.)
-- pokered writes PALLET_TOWN here as a literal, not as "the spawn" --
-- the vanilla spawn is REDS_HOUSE_2F. SaveData.defaultHeal carries
-- that split (and lets a total conversion redirect it).
-- THE END is on screen: heal, place the player at post-game home,
-- retarget LAST_MAP, then SaveGameData. (E4 room-script/event
-- resets that precede the save in pokered are the Indigo lobby's
-- re-entry reset here, data/scripts/story6.lua.)
local SaveData = require("src.core.SaveData")
local Pokemon = require("src.pokemon.Pokemon")
local boot = game.data.field and game.data.field.boot or {}
ctx.save.lastHeal = require("src.core.SaveData").defaultHeal(boot)
for _, mon in ipairs(ctx.save.party or {}) do
Pokemon.heal(mon)
end
SaveData.applyPostGameHome(ctx.save, boot)
if game.overworld then
game.overworld.lastOutdoor = ctx.save.lastOutdoor
end
if game.writeSave then game:writeSave() end
-- writeSave's captureSave re-stamps the live HALL_OF_FAME coords;
-- re-apply home and persist so CONTINUE resumes in the bedroom.
SaveData.applyPostGameHome(ctx.save, boot)
SaveData.save(ctx.save)
end)
end)
runner:yield()
@@ -823,7 +920,12 @@ end
-- registry and block until the screen pops itself
function Commands.push_screen(ctx, screenId, args)
local screens = ctx.game.data.screens
if not (screens and screens[screenId]) then
local known = screens and screens[screenId] ~= nil
if not known then
-- builtin engine screens (src/ui/<id>.lua) resolve through Screens too
known = pcall(Screens.get, ctx.game, screenId)
end
if not known then
error(("push_screen: unknown screen '%s'"):format(tostring(screenId)), 0)
end
local runner = ctx.runner
@@ -838,15 +940,21 @@ function Commands.push_screen(ctx, screenId, args)
runner:yield()
end
-- fade "out"|"in" [frames]: screen fade without warping (the Transition
-- ramp startWarpTo uses, split in two). "out" pushes a black overlay
-- that stays up; the held state keeps ticking the runner's frame-waits
-- so a script can wait/replace_block under it. "in" ramps it away.
-- fade "out"|"in" [frames|"white"|"black"] [frames|"white"|"black"]:
-- screen fade without warping (the Transition ramp startWarpTo uses,
-- split in two). "out" pushes an overlay that stays up; the held state
-- keeps ticking the runner's frame-waits so a script can wait /
-- heal_party / play_once under it. "in" ramps it away.
-- Color defaults to black (warp-style); "white" matches GBFadeOutToWhite
-- / GBFadeInFromWhite (Mom heal, Silph Co. nurse). White defaults to
-- 24 frames (3 palettes x 8); black defaults to 12 (Transition).
local FadeOverlay = {}
FadeOverlay.__index = FadeOverlay
function FadeOverlay.new(game, ow)
return setmetatable({ game = game, ow = ow, alpha = 0 }, FadeOverlay)
function FadeOverlay.new(game, ow, color)
return setmetatable({
game = game, ow = ow, alpha = 0, color = color or "black",
}, FadeOverlay)
end
function FadeOverlay:update()
@@ -872,28 +980,51 @@ function FadeOverlay:update()
end
function FadeOverlay:draw()
love.graphics.setColor(0, 0, 0, self.alpha)
if self.color == "white" then
love.graphics.setColor(1, 1, 1, self.alpha)
else
love.graphics.setColor(0, 0, 0, self.alpha)
end
love.graphics.rectangle("fill", 0, 0, 160, 144)
love.graphics.setColor(1, 1, 1, 1)
end
function Commands.fade(ctx, dir, frames)
local function parseFadeArgs(a, b)
local frames, color
if type(a) == "string" then
color = a
if type(b) == "number" then frames = b end
elseif type(a) == "number" then
frames = a
if type(b) == "string" then color = b end
end
color = color or "black"
if not frames then
frames = (color == "white") and 24 or 12
end
return frames, color
end
function Commands.fade(ctx, dir, framesOrColor, colorOrFrames)
local ow = ctx.overworld
if not ow then return end
local runner = ctx.runner
frames = frames or 12 -- Transition's ramp length
local frames, color = parseFadeArgs(framesOrColor, colorOrFrames)
local overlay = ow.fadeOverlay
if dir == "out" then
if not overlay then
overlay = FadeOverlay.new(ctx.game, ow)
overlay = FadeOverlay.new(ctx.game, ow, color)
ow.fadeOverlay = overlay
ctx.game.stack:push(overlay)
else
overlay.color = color
end
overlay.ramp = { from = overlay.alpha, to = 1, frames = frames, t = 0,
onDone = function() runner:resume() end }
runner:yield()
elseif dir == "in" then
if not overlay then return end
overlay.color = color or overlay.color
overlay.ramp = { from = overlay.alpha, to = 0, frames = frames, t = 0,
onDone = function() runner:resume() end }
runner:yield()
@@ -981,9 +1112,10 @@ for _, verb in ipairs({ "show_text", "ask", "choice", "start_battle", "warp",
end
for _, verb in ipairs({ "show_text", "ask", "choice", "start_battle", "warp",
"open_mart", "trade", "push_screen", "record_hall_of_fame",
"old_man_demo", "static_battle", "rival_battle", "give_item", "wait",
"old_man_demo", "static_battle", "rival_battle", "give_item",
"give_pokemon", "wait",
"wait_flag", "move_player", "move_npc", "move_npc_to", "walk_npc",
"emote", "fade", "pan_camera" }) do
"emote", "fade", "pan_camera", "play_once" }) do
local meta = Commands.meta[verb] or {}
Commands.meta[verb] = meta
meta.blocking = true
+46 -29
View File
@@ -216,7 +216,9 @@ local function useOn(game, battle, id, target, list, moveIndex)
consume(game, id)
require("src.core.Sound").play(game.data, "Teleport_Exit1")
ow.player.surfing = false
ow:warpToHealPoint()
-- EnterMapAnim on arrival (BIT_ESCAPE_WARP / special warp path);
-- blackouts omit arrive="teleport" (HandleBlackOut has no LeaveMapAnim)
ow:warpToHealPoint(nil, { arrive = "teleport" })
else
showMessages(game, { "OAK: " .. game.save.player.name
.. "!\nThis isn't the\ntime to use that!" })
@@ -296,37 +298,52 @@ local function useOn(game, battle, id, target, list, moveIndex)
showMessages(game, payload) -- failed
end
local function pickTargetAndUse(game, battle, id, list)
-- pick a target from the party
-- the ETHERs and PP UP open the move menu after picking a mon
-- (ItemUsePPRestore / ItemUsePPUp); the ELIXERs hit every move
local wantsMove = id == "ETHER" or id == "MAX_ETHER" or id == "PP_UP"
require("src.ui.Screens").push(game, "PartyMenu", {
pickOnly = true,
onSwitch = function(mon)
if not wantsMove then
useOn(game, battle, id, mon, list)
return
end
local rows = {}
for mi, mv in ipairs(mon.moves) do
local mdef = game.data.moves[mv.id]
table.insert(rows, {
value = mi,
label = mdef and mdef.name or mv.id,
right = ("%d"):format(mv.pp),
})
end
game.stack:push(ListMenu.new(game, "Which move?", rows, {
onChoose = function(row, l)
l:close()
useOn(game, battle, id, mon, list, row.value)
end,
}))
end,
})
end
local function useItem(game, battle, id, list)
local def = game.data.items[id]
if ItemEffects.needsTarget(id, def) and not ItemEffects.isBall(id) then
-- pick a target from the party
-- the ETHERs and PP UP open the move menu after picking a mon
-- (ItemUsePPRestore / ItemUsePPUp); the ELIXERs hit every move
local wantsMove = id == "ETHER" or id == "MAX_ETHER" or id == "PP_UP"
require("src.ui.Screens").push(game, "PartyMenu", {
pickOnly = true,
onSwitch = function(mon)
if not wantsMove then
useOn(game, battle, id, mon, list)
return
end
local rows = {}
for mi, mv in ipairs(mon.moves) do
local mdef = game.data.moves[mv.id]
table.insert(rows, {
value = mi,
label = mdef and mdef.name or mv.id,
right = ("%d"):format(mv.pp),
})
end
game.stack:push(ListMenu.new(game, "Which move?", rows, {
onChoose = function(row, l)
l:close()
useOn(game, battle, id, mon, list, row.value)
end,
}))
end,
})
-- TMs/HMs boot up and announce their move before the target picker
-- (ItemUseTMHM: BootedUpTMText / BootedUpHMText + TeachMachineMoveText)
if def and def.machine then
local moveDef = game.data.moves[def.machine.move]
local moveName = moveDef and moveDef.name or def.machine.move
local booted = def.machine.kind == "HM"
and "Booted up an HM!" or "Booted up a TM!"
showMessages(game, { booted, ("It contained\n%s!"):format(moveName) },
function() pickTargetAndUse(game, battle, id, list) end)
return
end
pickTargetAndUse(game, battle, id, list)
else
useOn(game, battle, id, nil, list)
end
+26 -6
View File
@@ -1,16 +1,19 @@
-- Rebinding over the logical Game Boy buttons (gap C2's file-12 half,
-- 12-ui-extensibility 4.4): one row per button, A arms a "PRESS A BUTTON"
-- capture and the captured key or pad button lands in
-- save.options.bindings -- the overlay src/core/Bindings.lua
-- (04-mod-api-core) reads back over Input's fixed map.
-- save.options.bindings, which Input:applyBindings layers over its fixed
-- default map (see src/core/Input.lua and Game:applyOptions).
local Font = require("src.render.Font")
local ListMenu = require("src.ui.ListMenu")
local Input = require("src.core.Input")
local BindingsMenu = setmetatable({}, { __index = ListMenu })
BindingsMenu.__index = BindingsMenu
-- Input.lua's map, primary key first where several keys share a button
-- Input.lua's map, primary key first where several keys share a button.
-- `pad` is the default SDL gamecontroller button (see Input.lua); shown
-- on the SELECT row so controller Back/View is discoverable (#73).
local BUTTONS = {
{ id = "up", label = "UP", key = "up" },
{ id = "down", label = "DOWN", key = "down" },
@@ -19,7 +22,7 @@ local BUTTONS = {
{ id = "a", label = "A", key = "z" },
{ id = "b", label = "B", key = "x" },
{ id = "start", label = "START", key = "escape" },
{ id = "select", label = "SELECT", key = "rshift" },
{ id = "select", label = "SELECT", key = "tab", pad = "back" },
}
-- a binding is a plain key string or { key, pad }; absent = the fixed
@@ -31,13 +34,29 @@ local function boundKey(overlay, def)
return def.key
end
local function boundPad(overlay, def)
local b = overlay and overlay[def.id]
if type(b) == "table" and b.pad then return b.pad end
return def.pad
end
-- Key column for every row. SELECT also appends "/PAD" (default BACK)
-- so controller Select/View is visible without opening a second legend.
local function boundRight(overlay, def)
local key = boundKey(overlay, def)
if def.id ~= "select" then return key:upper() end
local pad = boundPad(overlay, def)
if pad then return (key .. "/" .. pad):upper() end
return key:upper()
end
function BindingsMenu.new(game)
local overlay = game.save and game.save.options
and game.save.options.bindings
local items = {}
for i, def in ipairs(BUTTONS) do
items[i] = { label = def.label,
right = boundKey(overlay, def):upper(), button = def }
right = boundRight(overlay, def), button = def }
end
local self = setmetatable(ListMenu.new(game, "CONTROLS", items, {}),
BindingsMenu)
@@ -77,7 +96,8 @@ function BindingsMenu:storeBinding(slot, value)
end
b[slot] = value
opts.bindings[item.button.id] = b
item.right = boundKey(opts.bindings, item.button):upper()
item.right = boundRight(opts.bindings, item.button)
Input:applyBindings(opts.bindings)
if game.writeOptions then game:writeOptions() end
end
+5 -1
View File
@@ -145,6 +145,10 @@ end
function BoxMenu.new(game)
Boxes.ensure(game.save)
-- bills_pc.asm BillsPCMenu: TextBoxBorder at (0,0) with interior
-- 12x10 → total 14x12. "CHANGE BOX" is 10 tiles and needs the
-- full interior (cursor col + label); the old tw=12 right-side box
-- drew the final glyph on the border.
return Menu.new(game, {
{ label = "WITHDRAW", onSelect = function() withdraw(game) end },
{ label = "DEPOSIT", onSelect = function() deposit(game) end },
@@ -153,7 +157,7 @@ function BoxMenu.new(game)
{ label = "SEE YA!" },
-- Bill's PC runs silent end to end (BIT_NO_MENU_BUTTON_SOUND,
-- engine/menus/pokemon_pc.asm)
}, { tx = 8, ty = 0, tw = 12, th = 12, noSound = true })
}, { tx = 0, ty = 0, tw = 14, th = 12, noSound = true })
end
return BoxMenu
+2 -1
View File
@@ -217,7 +217,8 @@ function Credits:update(dt)
self.timer = FADE_FRAMES
elseif self.phase == "end_fade" then
-- Credits returns to HallOfFameResetEventsAndSaveScript here: the
-- save happens now, then 5 x 120 DelayFrames before the button wait
-- save (plus post-game home relocate, #103) happens now, then
-- 5 x 120 DelayFrames before the button wait
if self.onTheEnd then self.onTheEnd() end
self.phase = "end_hold"
self.timer = 600
+23 -4
View File
@@ -1,5 +1,11 @@
-- Pokédex entry page: front sprite, kind, height/weight and the real
-- dex description (data/pokemon/dex_entries.asm + dex_text.asm).
--
-- `species` may be a species id string, or a table
-- `{ species = id, forceOwned = true }`. forceOwned mirrors pret's
-- StarterDex (engine/events/starter_dex.asm), which temporarily sets the
-- owned bit so Oak's lab ball previews show height/weight/description
-- without permanently marking the mon owned.
local Font = require("src.render.Font")
@@ -16,8 +22,17 @@ function DexEntryMenu:sgbPalettes(game)
P.zone(P.monPal(game.data, self.def and self.def.id), 1, 1, 8, 8) }
end
function DexEntryMenu.new(game, species)
local self = setmetatable({ game = game }, DexEntryMenu)
local function resolveArgs(speciesOrOpts)
if type(speciesOrOpts) == "table" then
return speciesOrOpts.species or speciesOrOpts[1],
speciesOrOpts.forceOwned and true or false
end
return speciesOrOpts, false
end
function DexEntryMenu.new(game, speciesOrOpts)
local species, forceOwned = resolveArgs(speciesOrOpts)
local self = setmetatable({ game = game, forceOwned = forceOwned }, DexEntryMenu)
self.def = game.data.pokemon[species]
local ok, img = pcall(love.graphics.newImage, self.def.spriteFront)
self.sprite = ok and img or nil
@@ -42,12 +57,16 @@ function DexEntryMenu:draw()
love.graphics.setColor(0, 0, 0, 1)
Font.draw(def.name, 72, 8)
local e = def.dexEntry or {}
Font.draw((e.kind or "?") .. " POKéMON", 72, 20)
-- English R/B prints only the kind string (hlcoord 9,4 PlaceString).
-- PokeText ("#"/POKéMON) is an unreferenced JPN leftover in pokedex.asm;
-- appending " POKéMON" here clipped longer kinds ("LIZARD POKé").
Font.draw(e.kind or "?", 72, 20)
-- same number width as the list (constants.dexDigits), so a dex past 999
-- prints the extra digit everywhere at once
local digits = (self.game.data.constants or {}).dexDigits or 3
Font.draw(("No.%0" .. digits .. "d"):format(def.dex or 0), 72, 32)
local owned = self.game.save.pokedex and self.game.save.pokedex.owned[def.id]
local owned = self.forceOwned
or (self.game.save.pokedex and self.game.save.pokedex.owned[def.id])
-- height/weight print only once owned, like the description
-- (pokedex.asm: "if the pokemon has not been owned, don't print the
-- height, weight, or description")
+3
View File
@@ -5,6 +5,7 @@
-- has no cancel; the flash always completes).
local Font = require("src.render.Font")
local Music = require("src.core.Music")
local EvolutionState = {}
EvolutionState.__index = EvolutionState
@@ -39,6 +40,7 @@ function EvolutionState.new(game, mon, newSpecies, onDone)
self.newSprite = frontSprite(game, newSpecies)
self.t = 0
self.done = false
Music.play(game.data, Music.special(game.data, "evolution"))
return self
end
@@ -57,6 +59,7 @@ function EvolutionState:update(dt)
("Congratulations!\nYour %s\nevolved into\n%s!")
:format(self.oldName, newName),
function()
Music.restoreMap(game.data)
game.stack:pop() -- the evolution screen itself
if self.onDone then self.onDone() end
end))
+204 -79
View File
@@ -1,14 +1,16 @@
-- Hall of Fame induction (engine/movie/hall_of_fame.asm): each party
-- member's front sprite scrolls onto the screen (HoFShowMonOrPlayer's
-- .ScrollPic), then its name/level shows and its cry plays
-- (HoFDisplayAndRecordMonInfo). After the last mon, HoFDisplayPlayerStats
-- shows the trainer name, play time, money and Prof. Oak's dex rating.
-- Plays Music_HallOfFame when the audio data has it. Calls onDone() after
-- popping itself.
-- member's front sprite scrolls onto the right side of the screen
-- (HoFShowMonOrPlayer's .ScrollPic), then HoFDisplayMonInfo draws the
-- left-side LEVEL/TYPE box, plays the cry, holds, and pops the bottom
-- "HALL OF FAME" text box before fading to the next mon. After the
-- party, the player pic scrolls in and HoFDisplayPlayerStats shows the
-- name/time/money boxes plus the dex rating. Plays Music_HallOfFame
-- when the audio data has it. Calls onDone() after popping itself.
local Font = require("src.render.Font")
local Music = require("src.core.Music")
local Sound = require("src.core.Sound")
local TypeChart = require("src.battle.TypeChart")
local HallOfFame = {}
HallOfFame.__index = HallOfFame
@@ -17,6 +19,10 @@ HallOfFame.isOpaque = true
-- SGB: SetPal_PokemonWholeScreen for the mon on display
function HallOfFame:sgbPalettes(game)
local P = require("src.render.PaletteFX")
if self.phase == "player" or self.phase == "player_stats"
or self.phase == "player_dex" or self.phase == "player_rating" then
return P.wholeNamed(game.data, "MEWMON")
end
local mon = game.save.party[self.index or 0]
if mon then
local c = P.monPal(game.data, mon.species)
@@ -26,17 +32,20 @@ function HallOfFame:sgbPalettes(game)
return P.wholeNamed(game.data, "MEWMON")
end
local MON_FRAMES = 150 -- ~2.5s per inductee (A advances early)
-- HoFShowMonOrPlayer's .ScrollPic: hSCX is nudged by e = 4px per
-- DelayFrame (doubled on SGB) until it settles. The back pic (an
-- enlarged, blurred 2x scale of the back sprite) sweeps right-to-left
-- and off the left edge first; tracing the actual hSCX/hSCY math shows
-- the real front pic that follows enters from the *left* edge and
-- slides *right* into its resting tile, at that same 4px/frame rate --
-- that's the half we port here (the back-pic wipe is a VRAM/scroll-
-- register trick with no equivalent in this sprite-based renderer).
-- DelayFrame (doubled on SGB) until it settles. The front pic rests at
-- hlcoord 12,5 (engine/movie/hall_of_fame.asm HoFLoadMonPlayerPicTileIDs).
local SCROLL_SPEED = 4 -- px/frame @ 60fps
local PIC_X, PIC_Y = 12 * 8, 5 * 8
-- After HoFDisplayAndRecordMonInfo: 80 DelayFrames, then the bottom
-- HALL OF FAME box for 180 DelayFrames, then GBFadeOutToWhite.
local INFO_HOLD = 80
local HOF_HOLD = 180
local FADE_FRAMES = 20
-- HoFPrintTextAndDelay after each dex line
local DEX_HOLD = 120
local function tryImage(path)
if not path then return nil end
@@ -57,7 +66,7 @@ local function drawTextBlock(text, x, y, maxY)
for line in (text:gsub("\v", "\n"):gsub("\f", "\n") .. "\n"):gmatch("(.-)\n") do
if maxY and y > maxY then break end
Font.draw(line, x, y)
y = y + 10
y = y + 8
end
return y
end
@@ -70,6 +79,10 @@ function HallOfFame.new(game, onDone)
self.timer = 0
self.phase = "mons"
self.sprites = {} -- species -> image or false
self.playerPic = tryImage("assets/generated/trainer_card/red.png")
self.scrollX = PIC_X
self.showHofBanner = false
self.fade = 0
return self
end
@@ -84,16 +97,21 @@ end
function HallOfFame:nextMon()
self.index = self.index + 1
local mon = self.game.save.party[self.index]
self.showHofBanner = false
self.fade = 0
if mon then
self.timer = MON_FRAMES
self.phase = "mons"
self.timer = INFO_HOLD
Sound.playCry(self.game.data, mon.species)
-- scroll the new inductee's pic in from the left (see SCROLL_SPEED)
local sprite = self:spriteFor(mon.species)
local w = sprite and sprite:getWidth() or 0
self.scrollRestX = math.floor((160 - w) / 2)
local w = sprite and sprite:getWidth() or 56
self.scrollX = -w
else
self.phase = "congrats"
-- HoFShowMonOrPlayer with wHoFMonOrPlayer = player
self.phase = "player"
self.timer = 0
local w = self.playerPic and self.playerPic:getWidth() or 56
self.scrollX = -w
end
end
@@ -117,77 +135,184 @@ function HallOfFame:dexSeenOwned()
return seen, owned
end
function HallOfFame:update(dt)
local input = self.game.input
if self.phase == "mons" then
if self.scrollX and self.scrollX < self.scrollRestX then
self.scrollX = math.min(self.scrollRestX, self.scrollX + SCROLL_SPEED)
end
self.timer = self.timer - 1
if input:wasPressed("a") or self.timer <= 0 then
self:nextMon()
end
elseif input:wasPressed("a") then
Sound.play(self.game.data, "Press_AB")
self.game.stack:pop()
if self.onDone then self.onDone() end
function HallOfFame:advanceMonPhase()
if self.phase == "mons" and not self.showHofBanner then
-- 80-frame info hold done: TextBoxBorder at (2,13) + "HALL OF FAME"
self.showHofBanner = true
self.timer = HOF_HOLD
elseif self.phase == "mons" then
self.phase = "fade"
self.timer = FADE_FRAMES
self.fade = 0
elseif self.phase == "fade" then
self:nextMon()
end
end
function HallOfFame:draw()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
love.graphics.setColor(0, 0, 0, 1)
if self.phase == "mons" then
Font.draw("HALL OF FAME", (160 - 12 * 8) / 2, 8)
local mon = self.game.save.party[self.index]
if mon then
local def = self.game.data.pokemon[mon.species]
love.graphics.setColor(1, 1, 1, 1)
local sprite = self:spriteFor(mon.species)
if sprite then
local w, h = sprite:getDimensions()
love.graphics.draw(sprite, self.scrollX or math.floor((160 - w) / 2), 96 - h)
end
love.graphics.setColor(0, 0, 0, 1)
local name = mon.nickname or (def and def.name) or mon.species
Font.draw(name, 32, 108)
Font.draw((":L%d"):format(mon.level), 112, 108)
end
else
-- HoFDisplayPlayerStats (no "HALL OF FAME" banner here -- the real
-- screen is a fresh ClearScreen): trainer name, play time, money,
-- then the POKéDEX seen/owned tally and Prof. Oak's rating text,
-- using the same real save-data fields as TrainerCard.lua
-- (save.player.name/playTime/money) and PokedexMenu.lua/
-- OverworldController:dexRating (save.pokedex.seen/owned).
local save = self.game.save
local text = self.game.data.text or {}
local y = 8
Font.draw(save.player.name or "RED", 8, y)
y = y + 16
local t = math.floor(save.playTime or 0)
Font.draw(("PLAY TIME %3d:%02d"):format(math.floor(t / 3600),
math.floor(t / 60) % 60), 8, y)
y = y + 12
Font.draw(("MONEY ¥%d"):format(save.money or 0), 8, y)
y = y + 16
function HallOfFame:update(dt)
local input = self.game.input
local skip = input:wasPressed("a")
if self.phase == "mons" or self.phase == "fade" then
if self.phase == "mons" and self.scrollX < PIC_X then
self.scrollX = math.min(PIC_X, self.scrollX + SCROLL_SPEED)
return
end
if self.phase == "fade" then
self.timer = self.timer - 1
self.fade = 1 - math.max(0, self.timer) / FADE_FRAMES
if self.timer <= 0 or skip then self:advanceMonPhase() end
return
end
self.timer = self.timer - 1
if skip or self.timer <= 0 then
self:advanceMonPhase()
end
elseif self.phase == "player" then
if self.scrollX < PIC_X then
self.scrollX = math.min(PIC_X, self.scrollX + SCROLL_SPEED)
return
end
self.phase = "player_stats"
self.timer = DEX_HOLD
elseif self.phase == "player_stats" then
-- name / play time / money boxes are up; then DexSeenOwnedText
self.timer = self.timer - 1
if skip or self.timer <= 0 then
self.phase = "player_dex"
self.timer = DEX_HOLD
end
elseif self.phase == "player_dex" then
self.timer = self.timer - 1
if skip or self.timer <= 0 then
self.phase = "player_rating"
self.timer = DEX_HOLD
end
elseif self.phase == "player_rating" then
self.timer = self.timer - 1
if skip or self.timer <= 0 then
-- HoFFadeOutScreenAndMusic -> Credits lead-in (no A wait here)
self.game.stack:pop()
if self.onDone then self.onDone() end
end
end
end
-- HoFDisplayMonInfo: TextBoxBorder (0,2) b=9,c=10 + LEVEL/TYPE labels
function HallOfFame:drawMonInfo(mon)
local def = self.game.data.pokemon[mon.species]
Font.drawBox(0, 2, 12, 11)
love.graphics.setColor(0, 0, 0, 1)
local name = mon.nickname or (def and def.name) or mon.species
Font.draw(name, 1 * 8, 4 * 8)
Font.draw("LEVEL/", 2 * 8, 6 * 8)
Font.draw("TYPE1/", 2 * 8, 7 * 8)
local t1 = def and def.types and def.types[1]
local t2 = def and def.types and def.types[2]
local dual = t2 and t2 ~= t1
if dual then
Font.draw("TYPE2/", 2 * 8, 8 * 8)
end
-- PrintLevelCommon at (8,7): bare level digits (no <LV> tile here)
Font.draw(tostring(mon.level), 8 * 8, 7 * 8)
-- PrintMonType at (3,9) / +2 rows for type 2
if t1 then
Font.draw(TypeChart.displayName(t1), 3 * 8, 9 * 8)
end
if dual then
Font.draw(TypeChart.displayName(t2), 3 * 8, 11 * 8)
end
end
-- Bottom HALL OF FAME banner: TextBoxBorder (2,13) b=3,c=14
function HallOfFame:drawHofBanner()
Font.drawBox(2, 13, 16, 5)
love.graphics.setColor(0, 0, 0, 1)
Font.draw("HALL OF FAME", 4 * 8, 15 * 8)
end
function HallOfFame:drawPic(img)
if not img then return end
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(img, self.scrollX or PIC_X, PIC_Y)
end
-- HoFDisplayPlayerStats boxes + labels (player pic already on the right)
function HallOfFame:drawPlayerStats()
local save = self.game.save
-- name box: TextBoxBorder (5,0) b=2,c=9 → drawBox(5,0,11,4)
Font.drawBox(5, 0, 11, 4)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(save.player.name or "RED", 7 * 8, 2 * 8)
-- play time / money box: TextBoxBorder (0,4) b=6,c=10 → drawBox(0,4,12,8)
Font.drawBox(0, 4, 12, 8)
love.graphics.setColor(0, 0, 0, 1)
Font.draw("PLAY TIME", 1 * 8, 6 * 8)
local t = math.floor(save.playTime or 0)
Font.draw(("%3d:%02d"):format(math.floor(t / 3600), math.floor(t / 60) % 60),
5 * 8, 7 * 8)
Font.draw("MONEY", 1 * 8, 9 * 8)
-- PrintBCDNumber with MONEY_SIGN; port uses ¥ like TrainerCard
Font.draw(("¥%d"):format(save.money or 0), 4 * 8, 10 * 8)
end
function HallOfFame:drawDexBox(kind)
local save = self.game.save
local text = self.game.data.text or {}
Font.drawBox(0, 12, 20, 6)
love.graphics.setColor(0, 0, 0, 1)
if kind == "seen" then
local seen, owned = self:dexSeenOwned()
local seenOwned = text._DexSeenOwnedText
or "POKéDEX Seen:{NUM:wDexRatingNumMonsSeen, 1, 3}\n Owned:{NUM:wDexRatingNumMonsOwned, 1, 3}"
seenOwned = seenOwned
:gsub("{NUM:wDexRatingNumMonsSeen[^}]*}", tostring(seen))
:gsub("{NUM:wDexRatingNumMonsOwned[^}]*}", tostring(owned))
y = drawTextBlock(seenOwned, 8, y) + 6
drawTextBlock(seenOwned, 1 * 8, 14 * 8, 17 * 8)
else
local _, owned = self:dexSeenOwned()
local ratingHeader = (text._DexRatingText or "POKéDEX Rating{COLON}"):gsub("{COLON}", ":")
Font.draw(ratingHeader, 8, y)
y = y + 12
Font.draw(ratingHeader, 1 * 8, 14 * 8)
local rating = text[dexRatingKey(owned)] or "Keep it up!"
drawTextBlock(rating, 8, y, 136)
drawTextBlock(rating, 1 * 8, 15 * 8, 17 * 8)
end
end
function HallOfFame:draw()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
if self.phase == "mons" or self.phase == "fade" then
local mon = self.game.save.party[self.index]
if mon then
self:drawPic(self:spriteFor(mon.species))
if self.scrollX >= PIC_X then
self:drawMonInfo(mon)
if self.showHofBanner then
self:drawHofBanner()
end
end
end
if self.phase == "fade" and self.fade > 0 then
love.graphics.setColor(1, 1, 1, self.fade)
love.graphics.rectangle("fill", 0, 0, 160, 144)
end
elseif self.phase == "player" then
self:drawPic(self.playerPic)
elseif self.phase == "player_stats" then
self:drawPic(self.playerPic)
self:drawPlayerStats()
elseif self.phase == "player_dex" then
self:drawPic(self.playerPic)
self:drawPlayerStats()
self:drawDexBox("seen")
elseif self.phase == "player_rating" then
self:drawPic(self.playerPic)
self:drawPlayerStats()
self:drawDexBox("rating")
end
love.graphics.setColor(1, 1, 1, 1)
end
+13 -3
View File
@@ -117,7 +117,7 @@ function ListMenu:draw()
love.graphics.setColor(0, 0, 0, 1)
end
if item.right then
Font.draw(item.right, 160 - 8 - #item.right * 8, y)
Font.draw(item.right, 160 - 8 - Font.width(item.right), y)
end
if i == self.index then
-- hollowIndex: a chosen row keeps the hollow '▷' left behind by
@@ -136,7 +136,7 @@ function ListMenu:draw()
Font.drawBox(11, 0, 9, 3)
love.graphics.setColor(0, 0, 0, 1)
local money = ("¥%d"):format(self.money and self.money() or 0)
Font.draw(money, 152 - #money * 8, 8)
Font.draw(money, 152 - Font.width(money), 8)
-- the clerk's line in the standard bottom text box; long prompts
-- wrap and keep their last two lines, like the GB's scrolled box
Font.drawBox(0, 12, 20, 6)
@@ -153,7 +153,17 @@ function ListMenu:draw()
end
end
elseif self.footer then
Font.draw(self.footer, 8, 136)
-- PC deposit/withdraw footers use "\n"; draw the last two lines so
-- long item names are not clipped at the screen edge (#115).
local flat = {}
for _, page in ipairs(require("src.render.TextBox").paginate(self.footer)) do
for _, line in ipairs(page) do flat[#flat + 1] = line end
end
local y = (#flat >= 2) and 120 or 136
for i = math.max(1, #flat - 1), #flat do
Font.draw(flat[i], 8, y)
y = y + 16
end
end
love.graphics.setColor(1, 1, 1, 1)
end
+43 -4
View File
@@ -19,7 +19,16 @@ function Menu.new(game, items, opts)
self.tx = opts.tx or 10
self.ty = opts.ty or 0
self.tw = opts.tw or 10
self.th = opts.th or (#items * 2 + 2)
self.rowStep = opts.rowStep or 2
-- maxVisible: cap the box to this many rows and scroll the rest instead
-- of growing past it (e.g. the start menu, whose row count varies with
-- save state and mod hooks); nil/unset keeps every caller's old
-- behavior of sizing the box to fit all items.
self.maxVisible = opts.maxVisible
self.scroll = 0
local visible = (self.maxVisible and math.min(self.maxVisible, #items))
or #items
self.th = opts.th or (visible * self.rowStep + 2)
self.cancelable = opts.cancelable ~= false
-- Whether START closes the menu. In pokered a menu responds only to the
-- keys in its wMenuWatchedKeys mask; the common PAD_A | PAD_B (and the
@@ -31,9 +40,25 @@ function Menu.new(game, items, opts)
-- BIT_NO_MENU_BUTTON_SOUND (wMiscFlags): the PC session runs its
-- menus silent (home/window.asm HandleMenuInput_)
self.noSound = opts.noSound or false
self:clampScroll()
return self
end
-- keeps self.index inside the visible [scroll+1, scroll+maxVisible] window;
-- callers that move self.index directly (e.g. restoring a saved cursor
-- position) should call this afterwards to scroll it into view
function Menu:clampScroll()
if not (self.maxVisible and #self.items > self.maxVisible) then
self.scroll = 0
return
end
if self.index - self.scroll > self.maxVisible then
self.scroll = self.index - self.maxVisible
elseif self.index - self.scroll < 1 then
self.scroll = self.index - 1
end
end
function Menu:update(dt)
local input = self.game.input
if input:wasPressed("up") then
@@ -61,15 +86,29 @@ function Menu:update(dt)
self.game.stack:pop()
if self.onCancel then self.onCancel() end
end
self:clampScroll()
end
function Menu:draw()
Font.drawBox(self.tx, self.ty, self.tw, self.th)
love.graphics.setColor(0, 0, 0, 1)
for i, item in ipairs(self.items) do
Font.draw(item.label, (self.tx + 2) * 8, (self.ty + i * 2 - 1) * 8)
local visible = (self.maxVisible and math.min(self.maxVisible, #self.items))
or #self.items
for row = 1, visible do
local item = self.items[self.scroll + row]
if not item then break end
Font.draw(item.label, (self.tx + 2) * 8,
(self.ty + row * self.rowStep - (self.rowStep - 1)) * 8)
end
local cursorRow = self.index - self.scroll
Font.drawCode(Theme.cursor, (self.tx + 1) * 8,
(self.ty + cursorRow * self.rowStep - (self.rowStep - 1)) * 8)
-- moreArrow ($EE): the same "more below" glyph OptionRows/ManagerState
-- use, sat on the bottom border like TextBox's page-advance cursor
if self.maxVisible and self.scroll + self.maxVisible < #self.items then
Font.drawCode(Theme.moreArrow, (self.tx + self.tw - 2) * 8,
(self.ty + self.th - 2) * 8)
end
Font.drawCode(Theme.cursor, (self.tx + 1) * 8, (self.ty + self.index * 2 - 1) * 8)
love.graphics.setColor(1, 1, 1, 1)
end
+56 -6
View File
@@ -16,6 +16,11 @@ local OakSpeech = {}
OakSpeech.__index = OakSpeech
OakSpeech.isOpaque = true
-- FadeInIntroPic runs a 6-step palette fade; MovePicLeft wipes the mon
-- sprite in from the right. Both play out before the beat's text prints.
local FADE_FRAMES = 24
local WIPE_FRAMES = 32
-- naming presets are boot config (field.boot.namePresets), which a total
-- conversion replaces; the Red/Blue lists remain the fallback
local function namePresets(game, who, fallback)
@@ -93,16 +98,23 @@ function OakSpeech:say(key, next)
end
local STEPS = {
-- 1. Oak's welcome
-- 1. Oak's welcome (Oak's pic fades in first, FadeInIntroPic)
function(self)
self.pic = self.oakPic
self:say("_OakSpeechText1", function() self:advance() end)
self:revealPic("fade", function()
self:say("_OakSpeechText1", function() self:advance() end)
end)
end,
-- 2. NIDORINO show-off, with its cry
-- 2. NIDORINO show-off: the front sprite is mirrored horizontally
-- (LoadFlippedFrontSpriteByMonIndex) and wipes in from the right
-- (MovePicLeft), then its cry sounds and the text prints
function(self)
self.pic = self.demoPic
Sound.playCry(self.game.data, self.demoSpecies)
self:say("_OakSpeechText2A", function() self:advance() end)
self.picFlip = true
self:revealPic("wipe", function()
Sound.playCry(self.game.data, self.demoSpecies)
self:say("_OakSpeechText2A", function() self:advance() end)
end)
end,
-- 3. the rest of the world-of-POKéMON spiel
function(self)
@@ -175,8 +187,21 @@ function OakSpeech:lastPageLines(key)
return ok and lines or nil
end
-- Reveal the current pic over `dur` frames, then run `next`. Ticked from
-- update() while OakSpeech is the top state (before the beat's text box is
-- pushed), so the fade/wipe plays out ahead of the text like the ROM.
function OakSpeech:revealPic(kind, next)
self.picReveal = {
kind = kind,
t = 0,
dur = kind == "fade" and FADE_FRAMES or WIPE_FRAMES,
next = next,
}
end
function OakSpeech:advance()
self.step = self.step + 1
self.picFlip = false
local fn = STEPS[self.step]
if fn then
fn(self)
@@ -205,6 +230,15 @@ end
-- ClearScreenArea / wUpdateSpritesEnabled; ld c, 50)
-- frames 79-102 GBFadeOutToWhite (3 palettes x 8 frames)
function OakSpeech:update(dt)
local r = self.picReveal
if r then
r.t = r.t + 1
if r.t >= r.dur then
self.picReveal = nil
if r.next then r.next() end
end
return
end
if not self.shrink then return end
local s = self.shrink
s.frame = s.frame + 1
@@ -238,7 +272,23 @@ function OakSpeech:draw()
local w, h = self.pic:getDimensions()
local x = 48 + math.floor((8 - w / 8) / 2) * 8
local y = 32 + (7 - h / 8) * 8
love.graphics.draw(self.pic, x, y)
local reveal = self.picReveal
local off = 0
if reveal and reveal.kind == "fade" then
-- FadeInIntroPic: ramp the pic's alpha up over the fade window
love.graphics.setColor(1, 1, 1, math.min(1, reveal.t / reveal.dur))
elseif reveal and reveal.kind == "wipe" then
-- MovePicLeft: the pic slides in from the right edge to its spot
off = math.floor((160 - x) * (1 - math.min(1, reveal.t / reveal.dur)))
end
if self.picFlip then
-- LoadFlippedFrontSpriteByMonIndex mirrors the front sprite
-- horizontally (wSpriteFlipped): draw with a negative x scale
love.graphics.draw(self.pic, x + off + w, y, 0, -1, 1)
else
love.graphics.draw(self.pic, x + off, y)
end
love.graphics.setColor(1, 1, 1, 1)
end
if self.walkVisible and self.walkSheet then
-- ResetPlayerSpriteData: Y screen pos $3c, X screen pos $40
+32 -1
View File
@@ -13,6 +13,7 @@ local Tilt = require("src.render.Tilt")
local GBCFX = require("src.render.GBCFX")
local GameSpeed = require("src.core.GameSpeed")
local VideoMode = require("src.core.VideoMode")
local FrameCap = require("src.core.FrameCap")
local Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime")
local OptionRows = require("src.ui.OptionRows")
@@ -21,6 +22,14 @@ local OptionsMenu = {}
OptionsMenu.__index = OptionsMenu
OptionsMenu.isOpaque = true
-- Opaque full-screen menu: own MEWMON so opening OPTION from the title
-- (or over the overworld) does not inherit TitleState's LOGO1 band -- that
-- zone covers UI rows 8-9, which is the third options box label line
-- (pink "MODS" strip when Blue's ROM LOGO1 white is {255,239,255}).
function OptionsMenu:sgbPalettes(game)
return PaletteFX.wholeNamed(game.data, "MEWMON")
end
-- TextSpeedOptionData frame delays with the original labels
local SPEEDS = { { 1, "FAST" }, { 3, "MEDIUM" }, { 5, "SLOW" } }
-- no-loader fallback for the ruleset row, same pair BattleState keeps
@@ -101,7 +110,7 @@ local function sameRows(_, rows) return rows end
-- the vanilla rows as descriptors; each step body is the old per-index
-- ladder's, so the save.options mutations are unchanged
local function buildRows(game)
return {
local rows = {
{ id = "textSpeed", label = "TEXT SPEED",
value = function(g) return SPEEDS[speedIndex(g)][2] end,
step = function(g)
@@ -202,6 +211,19 @@ local function buildRows(game)
VideoMode.apply(o.videoMode)
return true
end },
-- hard render cap (issue #88): bounds the present rate so a
-- driver-forced vsync-off run cannot spin at thousands of FPS. Logic
-- is fixed-step off dt, so this touches presentation only.
{ id = "fpsCap", label = "MAX FPS",
value = function(g)
return FrameCap.label(g.save.options.fpsCap)
end,
step = function(g, dir)
local o = g.save.options
o.fpsCap = FrameCap.cycle(o.fpsCap, dir)
FrameCap.apply(o.fpsCap)
return true
end },
-- fast-forward the logic clock only; music and sfx keep their tempo
-- (src/core/GameSpeed.lua), so this is safe to leave on
{ id = "speed", label = "GAME SPEED",
@@ -230,6 +252,15 @@ local function buildRows(game)
require("src.ui.Screens").push(g, "BindingsMenu")
end },
}
-- issue #136: hide GBC FX on Android/iOS -- the present shader soft-bricks
if not GBCFX.isSupported() then
local filtered = {}
for _, row in ipairs(rows) do
if row.id ~= "gbcfx" then filtered[#filtered + 1] = row end
end
rows = filtered
end
return rows
end
function OptionsMenu.new(game)
+39 -10
View File
@@ -11,6 +11,8 @@ local Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime")
local Screens = require("src.ui.Screens")
local Theme = require("src.ui.Theme")
local FieldDefaults = require("src.world.FieldDefaults")
local Map = require("src.world.Map")
local PartyMenu = {}
PartyMenu.__index = PartyMenu
@@ -69,10 +71,26 @@ local function drawIcon(game, mon, x, y, selected, counter)
local icons = game.data.icons
if not icons then return end
local def = game.data.pokemon[mon.species]
-- byDex is the vanilla lookup, but the icons registry can bring the table
-- into existence on its own, so it may be the only key present
local name = def and def.dex and icons.byDex and icons.byDex[def.dex]
local path = name and icons.icons[name]
-- Per-species override first: the icons registry folds into
-- icons.bySpecies, and a pokemon record may carry its own `icon` field.
-- Either is a built-in icon name (resolved through icons.icons) or a
-- { image = <path>, frames? } table pointing at bundled art. Falling
-- through to icons.byDex[def.dex] keeps the vanilla dex-indexed default;
-- without the override a modded or dex-renumbered species could never
-- change its menu icon.
local entry = (icons.bySpecies and icons.bySpecies[mon.species])
or (def and def.icon)
local name, path
if type(entry) == "string" then
name = entry
path = icons.icons and icons.icons[entry]
elseif type(entry) == "table" then
path = entry.image
end
if not path then
name = def and def.dex and icons.byDex and icons.byDex[def.dex]
path = name and icons.icons and icons.icons[name]
end
if not path then return end
if iconImages[path] == nil then
local ok, img = pcall(love.graphics.newImage, path)
@@ -182,12 +200,13 @@ function PartyMenu:update(dt)
-- and music return first (PlayDefaultMusic +
-- LoadWalkingPlayerSpriteGraphics), the menu closes with the
-- GBPalWhiteOutWithDelay3 blink, and the simulated pad press
-- steps the player forward onto land
-- steps the player forward onto land (or across a connection
-- strip when the shore is the next map's edge)
self.game.stack:pop()
ow.player.surfing = false
require("src.core.Music").setSurfing(self.game.data, false)
self.game.stack:push(Transition.whiteFlash(self.game, nil, function()
ow:scriptMove(ow.player, ow.player.facing, 1)
ow:stepForwardOrCrossEdge(ow.player.facing)
end))
return
end
@@ -277,7 +296,9 @@ function PartyMenu:update(dt)
if ow and heal then
self.game.stack:push(Transition.whiteFlash(self.game, nil, function()
require("src.core.Sound").play(self.game.data, "Teleport_Exit1")
ow:warpToHealPoint()
-- EnterMapAnim on arrival (HandleFlyWarpOrDungeonWarp sets
-- BIT_FLY_WARP); blackouts must not pass arrive="teleport"
ow:warpToHealPoint(nil, { arrive = "teleport" })
end))
end
return
@@ -331,9 +352,17 @@ function PartyMenu:update(dt)
local items = { { label = "STATS", action = "stats" },
{ label = "SWITCH", action = "switch" } }
local ow = self.game.overworld
if not self.battle and ow and mon.hp > 0 then
-- Field moves (HMs/TMs) are usable out of battle even when the mon
-- is fainted -- Gen 1 does not require HP for Cut/Fly/Surf/etc.
-- Battle still excludes this list via `not self.battle`. Softboiled
-- can appear for a fainted user; its heal transfer then no-ops.
if not self.battle and ow then
-- FLY/TELEPORT: CheckIfInOutsideMap (OVERWORLD + PLATEAU —
-- Route 23 / Indigo Plateau outdoor), not OVERWORLD alone (#83)
local outside = Map.isOutside(ow.map.def,
FieldDefaults.field(self.game.data, "outsideTilesets"))
for _, mv in ipairs(mon.moves) do
if mv.id == "FLY" and ow.map.def.tileset == "OVERWORLD"
if mv.id == "FLY" and outside
and self.game.save.inventory.THUNDERBADGE then
table.insert(items, { label = "FLY", action = "fly" })
elseif mv.id == "FLASH" and ow.dark
@@ -352,7 +381,7 @@ function PartyMenu:update(dt)
table.insert(items, { label = "STRENGTH", action = "strength" })
elseif mv.id == "SOFTBOILED" then
table.insert(items, { label = "SOFTBOILED", action = "softboiled" })
elseif mv.id == "TELEPORT" and ow.map.def.tileset == "OVERWORLD" then
elseif mv.id == "TELEPORT" and outside then
-- TELEPORT works only OUTDOORS (start_sub_menus.asm
-- .teleport -> CheckIfInOutsideMap); dark maps don't
-- block it
+9 -2
View File
@@ -1,6 +1,7 @@
-- The player's item-storage PC (engine/menus/players_pc.asm):
-- WITHDRAW ITEM / DEPOSIT ITEM / TOSS ITEM / LOG OFF over
-- game.save.pcItems ({ ITEM_ID = count }, created lazily). Withdraw and
-- game.save.pcItems ({ ITEM_ID = count }; New Game seeds { POTION = 1 },
-- older saves may still be empty until first deposit). Withdraw and
-- deposit ask "How many?" via the quantity selector (key items always
-- move one); toss discards after a YES/NO confirm. Follows the
-- BoxMenu/BagMenu list idioms.
@@ -99,7 +100,13 @@ end
local function deposit(game)
local pc = game.save.pcItems
local inv = game.save.inventory
game.stack:push(ListMenu.new(game, "DEPOSIT ITEM", buildItems(game, inv), {
local Bag = require("src.inventory.Bag")
-- badges live in save.inventory alongside items but are not depositable
local depositable = {}
for id, count in pairs(inv) do
if not Bag.isBadge(id) then depositable[id] = count end
end
game.stack:push(ListMenu.new(game, "DEPOSIT ITEM", buildItems(game, depositable), {
onChoose = function(item, list)
askQuantity(game, list, inv[item.value] or 1, item.value, function(qty)
if pcFull(game, pc, item.value) then
+7 -3
View File
@@ -84,13 +84,17 @@ local function buy(game, stock)
end
local function sell(game)
-- Sell list is ITEMLISTMENU with wPrintItemPrices cleared
-- (pokemart.asm .sellMenuLoop): name + quantity only. Price shows
-- in the quantity chooser. Stuffing "xN" into the label next to a
-- right-aligned ¥ price made long names overlap (issue #116).
local items = {}
for _, id in ipairs(Bag.order(game.save)) do
local def = game.data.items[id]
table.insert(items, {
value = id,
label = (def and def.name or id) .. " x" .. game.save.inventory[id],
right = ("¥%d"):format(def and math.floor(def.price / 2) or 0),
label = def and def.name or id,
right = "x" .. game.save.inventory[id],
})
end
local greet = txt(game, "_PokemartBuyingGreetingText", "Take your time.")
@@ -128,7 +132,7 @@ local function sell(game)
Bag.remove(game.save, item.value, qty)
local left = game.save.inventory[item.value]
if left then
item.label = def.name .. " x" .. left
item.right = "x" .. left
else
list:removeCurrent()
end
+30 -1
View File
@@ -4,8 +4,10 @@
-- item list runs through the ui.start_menu.items hook before the menu
-- opens, so mods insert or remove rows without patching this file.
local Font = require("src.render.Font")
local Logger = require("src.core.Logger")
local Menu = require("src.ui.Menu")
local Renderer = require("src.render.Renderer")
local Runtime = require("src.mods.Runtime")
local Screens = require("src.ui.Screens")
@@ -116,16 +118,43 @@ function StartMenu.new(game)
-- the start menu's mask is PAD_DOWN | PAD_UP | PAD_START | PAD_B | PAD_A
-- (engine/menus/draw_start_menu.asm), so START closes it back to the
-- overworld -- unlike most menus, whose masks omit PAD_START.
--
-- item count isn't fixed: POKéDEX/LINK/MODS come and go with save state,
-- and mods can append their own rows through the hook above, so the
-- double-spaced box (the original's style) can grow past the 18-tile
-- canvas. Cap it at however many rows actually fit and scroll the rest,
-- with Menu's moreArrow showing while there's more below.
local rowStep = 2
local maxVisible = math.floor((Renderer.HEIGHT / 8 - 2) / rowStep)
local menu = Menu.new(game, items,
{ tx = 9, ty = 0, tw = 11, th = #items * 2 + 2, startCloses = true })
{ tx = 9, ty = 0, tw = 11, maxVisible = maxVisible, startCloses = true })
-- the cursor position survives closing the menu
-- (wBattleAndStartSavedMenuItem, home/start_menu.asm)
menu.index = math.min(game.save.startMenuIndex or 1, #items)
menu:clampScroll()
local baseUpdate = menu.update
menu.update = function(self, dt)
baseUpdate(self, dt)
game.save.startMenuIndex = self.index
end
-- inside the Safari Zone the start menu also shows remaining steps and
-- SAFARI BALLs (PrintSafariZoneSteps): a 9x5 border at the top-left with
-- "steps/500" and "BALL xx"
if game.save.safari then
local baseDraw = menu.draw
menu.draw = function(self)
baseDraw(self)
local safari = game.save.safari
Font.drawBox(0, 0, 9, 5)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(("%3d"):format(math.floor(safari.steps or 0)), 8, 8)
Font.draw("/500", 32, 8)
Font.draw("BALL", 8, 24)
Font.draw(("%2d"):format(math.floor(safari.balls or 0)), 48, 24)
love.graphics.setColor(1, 1, 1, 1)
end
end
return menu
end
+7 -3
View File
@@ -115,12 +115,16 @@ function SummaryMenu:draw()
drawLineBox(19, 1, 6, 10)
Font.draw("EXP POINTS", 72, 24)
Font.draw(("%d"):format(mon.exp), 96, 32)
Font.draw("LEVEL UP", 72, 44)
-- StatusScreen2: "LEVEL UP" at (9,5); next-exp PrintNumber 7 cols
-- at (7,6); space at (14,6); PrintLevel at (16,6). The old
-- "%d to L%d" string at x=88 overflowed the DrawLineBox edge.
Font.draw("LEVEL UP", 72, 40)
local Growth = require("src.pokemon.Growth")
local nextExp = mon.level < 100
and (Growth.expForLevel(def.growthRate, mon.level + 1) - mon.exp) or 0
Font.draw(("%d to L%d"):format(math.max(0, nextExp),
math.min(100, mon.level + 1)), 88, 52)
Font.draw(("%7d"):format(math.max(0, nextExp)), 56, 48)
HudTiles.tile(0x6E, 128, 48) -- <LV>
Font.draw(tostring(math.min(100, mon.level + 1)), 136, 48)
Font.drawBox(0, 8, 20, 10)
for i = 1, 4 do
local mv = mon.moves[i]
+65 -14
View File
@@ -5,6 +5,7 @@
local Font = require("src.render.Font")
local Music = require("src.core.Music")
local GameVersion = require("src.core.GameVersion")
local TitleState = {}
TitleState.__index = TitleState
@@ -12,13 +13,35 @@ TitleState.isOpaque = true
-- SGB title zones (PalPacket_Titlescreen): the logo rows get LOGO2,
-- the version-ribbon band LOGO1, the rest MEWMON.
--
-- The CONTINUE / NEW GAME menu and the continue-info box sit inside those
-- LOGO bands. pokered's MainMenu clears the title and runs
-- RunDefaultPaletteCommand so black UI ink stays black; this port keeps the
-- title art visible underneath, so without an overlay those boxes inherit
-- LOGO2 blue / LOGO1 red (issue #133). A trailing trueColor zone leaves the
-- overlay's DMG black unshaded while the logo and title mon keep title pals.
--
-- ROM SuperPal whites are often {255,239,255}. Under RED++, LOGO2/MEWMON
-- come from the GBC pack (pure white) while Blue's LOGO1 stays on the ROM
-- pack (#128), so the version-ribbon row reads as a pink band. Force that
-- slot to pure white; ink colors (Blue/Red "Version" text) stay intact.
local function withPureWhite(pal)
if not pal then return nil end
return { { 255, 255, 255 }, pal[2], pal[3], pal[4] }
end
function TitleState:sgbPalettes(game)
local P = require("src.render.PaletteFX")
local z = {
P.zone(P.pal(game.data, "LOGO2"), 0, 0, 19, 7),
P.zone(P.pal(game.data, "LOGO1"), 0, 8, 19, 9),
P.zone(withPureWhite(P.pal(game.data, "LOGO1")), 0, 8, 19, 9),
P.zone(P.pal(game.data, "MEWMON"), 0, 10, 19, 17),
}
local top = game.stack and game.stack:top()
local box = top and top.titleUiBox
if box then
z[#z + 1] = P.trueColorZone(box[1], box[2], box[3], box[4])
end
return z[3] and z or nil
end
@@ -30,6 +53,13 @@ local CYCLE_SPECIES = {
"PIKACHU", "CLEFAIRY", "RHYDON", "ABRA", "GASTLY", "DITTO",
"PIDGEOTTO", "ONIX", "PONYTA", "MAGIKARP",
}
-- Blue's TitleMons (data/pokemon/title_mons.asm, _BLUE branch): STARTER2 is
-- Squirtle, STARTER1 Charmander, STARTER3 Bulbasaur.
local BLUE_CYCLE_SPECIES = {
"SQUIRTLE", "CHARMANDER", "BULBASAUR", "MANKEY", "HITMONLEE",
"VULPIX", "CHANSEY", "AERODACTYL", "JOLTEON", "SNORLAX",
"GLOOM", "POLIWAG", "DODUO", "PORYGON", "GENGAR", "RAICHU",
}
local CYCLE_FRAMES = 240 -- the original waits ~4s between picks
local function tryImage(path)
@@ -61,9 +91,13 @@ function TitleState.new(game, opts)
self.version = tryImage(imagePath(title.versionRibbon or title.version)
or "assets/generated/title/red_version.png")
self.player = tryImage("assets/generated/title/player.png")
self.blue = GameVersion.isBlue()
-- Blue cycles its own title mons and prints its ribbon contiguously; a
-- field.title.cycleSpecies override (mods / total conversions) still wins.
local defaultCycle = self.blue and BLUE_CYCLE_SPECIES or CYCLE_SPECIES
self.cycleSpecies = (type(title.cycleSpecies) == "table"
and #title.cycleSpecies > 0)
and title.cycleSpecies or CYCLE_SPECIES
and title.cycleSpecies or defaultCycle
self.sprites = {} -- species -> image or false (load failed)
self.cycleIndex = 1
self.timer = 0
@@ -92,8 +126,10 @@ end
local function hasSave()
local ok, info = pcall(function()
-- the active game's save file (save.lua for Red, save_blue.lua for Blue)
local name = require("src.core.SaveData").saveFilename(GameVersion.get())
return love.filesystem and love.filesystem.getInfo
and love.filesystem.getInfo("save.lua") or nil
and love.filesystem.getInfo(name) or nil
end)
return ok and info ~= nil
end
@@ -105,8 +141,11 @@ local ContinueInfo = {}
ContinueInfo.__index = ContinueInfo
function ContinueInfo.new(title, save)
return setmetatable({ title = title, game = title.game, save = save },
ContinueInfo)
-- box at (4,7), 16x10 tiles -- see ContinueInfo:draw / DisplayContinueGameInfo
return setmetatable({
title = title, game = title.game, save = save,
titleUiBox = { 4, 7, 19, 16 },
}, ContinueInfo)
end
function ContinueInfo:update(dt)
@@ -170,8 +209,11 @@ function TitleState:openMenu()
love.event.quit()
end
end })
game.stack:push(Menu.new(game, items,
{ tx = 0, ty = 0, tw = 13, th = #items * 2 + 2 }))
local th = #items * 2 + 2
local menu = Menu.new(game, items, { tx = 0, ty = 0, tw = 13, th = th })
-- full-width title LOGO zones would recolor this box; see sgbPalettes
menu.titleUiBox = { 0, 0, 12, th - 1 }
game.stack:push(menu)
end
function TitleState:update(dt)
@@ -211,17 +253,26 @@ function TitleState:draw()
love.graphics.draw(self.logo, 16, 8)
else
love.graphics.setColor(0, 0, 0, 1)
Font.draw("POKéMON RED", (160 - 11 * 8) / 2, 24)
Font.draw(self.blue and "POKéMON BLUE" or "POKéMON RED",
(160 - 12 * 8) / 2, 24)
love.graphics.setColor(1, 1, 1, 1)
end
if self.version then
-- the strip holds Red+Green+Version glyphs; the tilemap prints
-- tiles $60,$61 ("Red"), a space, then $65-$69 ("Version")
local iw, ih = self.version:getDimensions()
love.graphics.draw(self.version,
love.graphics.newQuad(0, 0, 16, 8, iw, ih), 56, 64)
love.graphics.draw(self.version,
love.graphics.newQuad(40, 0, 40, 8, iw, ih), 80, 64)
if self.blue then
-- Blue prints its ribbon contiguously ("Blue Version", hlcoord 7,8).
-- The extracted strip packs those eight glyph tiles into image tiles
-- 0..7 (tiles 8..9 are blank), so draw that 64px run at px (56, 64).
love.graphics.draw(self.version,
love.graphics.newQuad(0, 0, 64, 8, iw, ih), 56, 64)
else
-- Red's strip holds Red+Green+Version glyphs; the tilemap prints
-- tiles $60,$61 ("Red"), a space, then $65-$69 ("Version").
love.graphics.draw(self.version,
love.graphics.newQuad(0, 0, 16, 8, iw, ih), 56, 64)
love.graphics.draw(self.version,
love.graphics.newQuad(40, 0, 40, 8, iw, ih), 80, 64)
end
end
local sprite = self:currentSprite()
if sprite then
+4 -1
View File
@@ -120,8 +120,11 @@ FieldDefaults.FIELD = {
doorBlock = { bx = 2, by = 2, block = 5 } },
},
-- IsSurfingAllowed refuses SURF on the B4F stairs square until both
-- plug boulders are down (engine/overworld/field_move_messages.asm)
-- plug boulders are down (engine/overworld/field_move_messages.asm).
-- B3F currents set BIT_FORCED_WARP so the south-edge water warps fire
-- without a held d-pad (scripts/SeafoamIslandsB3F.asm).
seafoam = {
SEAFOAM_ISLANDS_B3F = { setsForcedWarp = true },
SEAFOAM_ISLANDS_B4F = {
surfBlocked = { { x = 7, y = 11, untilEvents = {
"EVENT_SEAFOAM4_BOULDER1_DOWN_HOLE",
+64 -26
View File
@@ -24,11 +24,65 @@ local NO_SHORE_TILESETS = { SHIP_PORT = true }
-- what counts as "outside" for the wLastMap memory (CheckIfInOutsideMap)
local OUTSIDE_TILESETS = { "OVERWORLD", "PLATEAU" }
-- warp pads and fall-through holes (data/tilesets/warp_pad_hole_tile_ids
-- .asm WarpPadAndHoleData); a tileset record carrying warpPadTiles
-- ({ [tileId] = "pad"|"hole" }) wins over these vanilla rows
local WARP_PAD_TILES = {
FACILITY = { [0x20] = "pad", [0x11] = "hole" },
CAVERN = { [0x22] = "hole" },
INTERIOR = { [0x55] = "pad" },
}
local function hashSet(list, into)
for _, t in ipairs(list) do into[t] = true end
return into
end
-- Collision tile (bottom-left 8x8) of a cell on an UNLOADED map def --
-- the connected neighbor during an edge crossing. pokered's
-- GetTileAndCoordsInFrontOfPlayer / collision checks read the neighbor
-- strip's tile bytes the same way.
function Map.defCellTile(def, tilesetDef, cx, cy)
if not (def and tilesetDef and tilesetDef.blocks) then return nil end
local tx, ty = cx * 2, cy * 2 + 1
local bx, by = math.floor(tx / 4), math.floor(ty / 4)
local id
if bx < 0 or by < 0 or bx >= def.width or by >= def.height then
id = def.borderBlock
else
id = def.blocks[by * def.width + bx + 1]
end
local block = tilesetDef.blocks[(id or 0) + 1]
if not block then return nil end
return block[(ty % 4) * 4 + (tx % 4) + 1]
end
local function defWaterTileSet(def, tilesetDef)
local water = {}
hashSet(tilesetDef.waterTiles or WATER_TILES, water)
local shore = tilesetDef.shoreTiles
if shore == nil and not NO_SHORE_TILESETS[def.tileset] then shore = SHORE_TILES end
hashSet(shore or {}, water)
return water
end
-- Water/shore on an unloaded map def (same tile ids as Map:isWaterCell).
function Map.defIsWaterCell(def, tilesetDef, cx, cy)
local tile = Map.defCellTile(def, tilesetDef, cx, cy)
if tile == nil then return false end
return defWaterTileSet(def, tilesetDef)[tile] or false
end
function Map.defIsWalkableCell(def, tilesetDef, cx, cy)
if not (tilesetDef and tilesetDef.walkable) then return false end
local tile = Map.defCellTile(def, tilesetDef, cx, cy)
if tile == nil then return false end
for _, t in ipairs(tilesetDef.walkable) do
if t == tile then return true end
end
return false
end
-- Passability of a cell of an UNLOADED map def -- the connected neighbor
-- during an edge crossing. pokered's collision check reads the neighbor
-- strip's tile bytes, so a step off the map edge onto a solid tile of
@@ -43,32 +97,8 @@ function Map.defPassable(def, tilesetDef, cx, cy, surfing)
if not (def and tilesetDef and tilesetDef.blocks and tilesetDef.walkable) then
return false
end
local tx, ty = cx * 2, cy * 2 + 1
local bx, by = math.floor(tx / 4), math.floor(ty / 4)
local id
if bx < 0 or by < 0 or bx >= def.width or by >= def.height then
id = def.borderBlock
else
id = def.blocks[by * def.width + bx + 1]
end
local block = tilesetDef.blocks[(id or 0) + 1]
if not block then return false end
local tile = block[(ty % 4) * 4 + (tx % 4) + 1]
for _, t in ipairs(tilesetDef.walkable) do
if t == tile then return true end
end
if surfing then
for _, t in ipairs(tilesetDef.waterTiles or WATER_TILES) do
if t == tile then return true end
end
local shore = tilesetDef.shoreTiles
if shore == nil and not NO_SHORE_TILESETS[def.tileset] then
shore = SHORE_TILES
end
for _, t in ipairs(shore or {}) do
if t == tile then return true end
end
end
if Map.defIsWalkableCell(def, tilesetDef, cx, cy) then return true end
if surfing and Map.defIsWaterCell(def, tilesetDef, cx, cy) then return true end
return false
end
@@ -199,6 +229,14 @@ function Map:isWarpTileCell(cx, cy)
return self.doorTiles[t] or self.warpTiles[t] or false
end
-- "pad"/"hole" when the cell's collision tile is a teleporter warp pad or
-- a fall-through hole (IsPlayerStandingOnWarpPadOrHole), nil otherwise
function Map:warpPadOrHoleAt(cx, cy)
local table_ = self.tileset.warpPadTiles or WARP_PAD_TILES[self.def.tileset]
if not table_ then return nil end
return table_[self:cellTile(cx, cy)]
end
-- counter tiles allow talking to NPCs across them (mart clerks, nurses)
function Map:isCounterCell(cx, cy)
local t = self:cellTile(cx, cy)

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