Compare commits

..

15 Commits

Author SHA1 Message Date
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
72 changed files with 2759 additions and 566 deletions
+17 -5
View File
@@ -1,7 +1,7 @@
name: Release name: Release
# Builds the macOS and Windows desktop apps on the self-hosted Mac runner # Builds the macOS, Windows, and Linux desktop apps plus an Android APK
# and publishes them as a GitHub Release. # on the self-hosted Mac runner, and publishes them as a GitHub Release.
# #
# Versioning: # Versioning:
# - First ever release is 0.1.0. # - First ever release is 0.1.0.
@@ -152,14 +152,20 @@ jobs:
echo "Identities available to codesign:" echo "Identities available to codesign:"
security find-identity -v -p codesigning "$KEYCHAIN_PATH" security find-identity -v -p codesigning "$KEYCHAIN_PATH"
- name: Build macOS + Windows - name: Build macOS + Windows + Linux
run: | run: |
set -euo pipefail set -euo pipefail
# Sign in-build (identity auto-detected from the temp keychain); # Sign in-build (identity auto-detected from the temp keychain);
# notarize separately below so it uses secret credentials, not a # 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 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 - name: Notarize & staple macOS app
run: | run: |
set -euo pipefail set -euo pipefail
@@ -204,6 +210,10 @@ jobs:
mkdir -p "$outdir" mkdir -p "$outdir"
cp "dist/mac/PokemonRed-macos.zip" "$outdir/PokemonRed-${v}-macos.zip" cp "dist/mac/PokemonRed-macos.zip" "$outdir/PokemonRed-${v}-macos.zip"
cp "dist/win/PokemonRed-win64.zip" "$outdir/PokemonRed-${v}-windows.zip" cp "dist/win/PokemonRed-win64.zip" "$outdir/PokemonRed-${v}-windows.zip"
cp "dist/linux/PokemonRed-linux.zip" "$outdir/PokemonRed-${v}-linux.zip"
apk="$(find dist/android/debug -name '*.apk' | head -1)"
[ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/debug"; exit 1; }
cp "$apk" "$outdir/PokemonRed-${v}-android.apk"
ls -lh "$outdir" ls -lh "$outdir"
- name: Publish GitHub Release - name: Publish GitHub Release
@@ -254,7 +264,9 @@ jobs:
--title "$v" \ --title "$v" \
--notes "$notes" \ --notes "$notes" \
"dist/release/PokemonRed-${v}-macos.zip" \ "dist/release/PokemonRed-${v}-macos.zip" \
"dist/release/PokemonRed-${v}-windows.zip" "dist/release/PokemonRed-${v}-windows.zip" \
"dist/release/PokemonRed-${v}-linux.zip" \
"dist/release/PokemonRed-${v}-android.apk"
echo "Published release $tag" echo "Published release $tag"
+23
View File
@@ -45,6 +45,29 @@ then `love .` for later launches. Windows PowerShell scripts, the optional
developer data build, test suites, and cache management are covered in 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). [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 ## Modding
The game ships a native mod platform: content registries, events and hooks, The game ships a native mod platform: content registries, events and hooks,
+22 -4
View File
@@ -1,13 +1,16 @@
function love.conf(t) function love.conf(t)
local editor = os.getenv("POKEPORT_EDITOR") == "1" local editor = os.getenv("POKEPORT_EDITOR") == "1"
local developer = os.getenv("POKEPORT_DEV") == "1"
if arg then if arg then
for _, a in ipairs(arg) do for _, a in ipairs(arg) do
if a == "--editor" then editor = true end if a == "--editor" then editor = true end
if a == "--developer" then developer = true end
end end
end end
-- main.lua runs in the same Lua state right after conf.lua; stash the -- 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`. -- decision in a global so it doesn't need to reparse `arg`.
_G.POKEPORT_EDITOR_MODE = editor _G.POKEPORT_EDITOR_MODE = editor
_G.POKEPORT_DEV_MODE = developer
if editor then if editor then
t.identity = os.getenv("POKEPORT_IDENTITY") or "pokemon-love2d-editor" t.identity = os.getenv("POKEPORT_IDENTITY") or "pokemon-love2d-editor"
@@ -34,14 +37,29 @@ function love.conf(t)
local osName = love._os local osName = love._os
local mobile = osName == "Android" or osName == "iOS" local mobile = osName == "Android" or osName == "iOS"
if mobile then if mobile then
-- On Android/iOS, width/height aspect picks portrait vs landscape -- resizable is what unlocks orientation. SDL's Android backend, given no
-- (fullscreen alone is not enough). Use a tall portrait size; the -- SDL_HINT_ORIENTATIONS (LÖVE sets none), calls setRequestedOrientation
-- OS then resizes to the real display. highdpi is required for -- at window creation -- FULL_SENSOR when the window is resizable (rotates
-- Retina iOS (Android always behaves as highdpi). -- freely to portrait or landscape), otherwise locked to the window's w/h
-- aspect. So a non-resizable tall window forced portrait; resizable lets
-- the game follow the device. The renderer letterboxes the 160x144
-- viewport into whatever size results, and touch input is gesture-based,
-- so both orientations just work. iOS follows the Info.plist orientations
-- (see mobile/ios/overlays/love-ios.plist, now portrait + landscape).
t.window.resizable = true
-- Starting size is a tall portrait hint; the OS resizes to the real
-- display and rotations resize again. highdpi is required for Retina iOS
-- (Android always behaves as highdpi).
t.window.width = 1080 t.window.width = 1080
t.window.height = 1920 t.window.height = 1920
t.window.fullscreen = true t.window.fullscreen = true
t.window.highdpi = 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 else
t.window.resizable = true t.window.resizable = true
end 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 -4
View File
@@ -5,10 +5,11 @@
-- The escort choreography (SUPER_NERD1 walking the player to the -- The escort choreography (SUPER_NERD1 walking the player to the
-- museum, YOUNGSTER walking the player to the gym) is scripted NPC -- museum, YOUNGSTER walking the player to the gym) is scripted NPC
-- movement + a wPewterCityCurScript state machine that steers the -- movement + a wPewterCityCurScript state machine that steers the
-- player off-map; that part is already covered on this map by -- player off-map; the YOUNGSTER's escort to the gym is handled on this
-- story5.lua's onStep gate (walks the player back a step at the -- map by story5.lua's onStep gate at the east exit (before
-- east exit before EVENT_BEAT_BROCK). Here we only port the real -- EVENT_BEAT_BROCK), while the SUPER_NERD1 museum escort is not ported.
-- YES/NO-branched flavor text these NPCs speak when talked to. -- Here we only port the real YES/NO-branched flavor text these NPCs
-- speak when talked to.
local M = {} local M = {}
+20 -17
View File
@@ -19,32 +19,35 @@ local function starterBall(askText, species, choseFlag, ownBall,
rivalBallX, rivalBall) rivalBallX, rivalBall)
return { return {
{ "check_flag", "EVENT_GOT_STARTER" }, -- 1 { "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) -- no picking until Oak has walked you in (OaksLabScript gating)
{ "check_flag", "EVENT_FOLLOWED_OAK_INTO_LAB" }, -- 3 { "check_flag", "EVENT_FOLLOWED_OAK_INTO_LAB" }, -- 3
{ "jump_if_false", 19 }, -- 4 { "jump_if_false", 20 }, -- 4
{ "ask", askText }, -- 5 -- the Pokédex "new species" entry shows before the ask (predef
{ "jump_if_false", 20 }, -- 6 -- StarterDex ahead of OaksLabYouWant...Text)
{ "give_pokemon", species, 5 }, -- 7 { "push_screen", "DexEntryMenu", species }, -- 5
{ "set_flag", "EVENT_GOT_STARTER" }, -- 8 { "ask", askText }, -- 6
{ "set_flag", choseFlag }, -- 9 { "jump_if_false", 21 }, -- 7
{ "give_pokemon", species, 5 }, -- 8
{ "set_flag", "EVENT_GOT_STARTER" }, -- 9
{ "set_flag", choseFlag }, -- 10
-- POKé BALLs are not handed out here in the original -- Oak gives -- POKé BALLs are not handed out here in the original -- Oak gives
-- them later, at OaksLabOak1Text's .give_poke_balls beat once the -- them later, at OaksLabOak1Text's .give_poke_balls beat once the
-- player has beaten the Route 22 rival (see TEXT_OAKSLAB_OAK1 below) -- player has beaten the Route 22 rival (see TEXT_OAKSLAB_OAK1 below)
{ "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 10 { "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 11
{ "hide_object", "OAKS_LAB", ownBall }, -- 11 { "hide_object", "OAKS_LAB", ownBall }, -- 12
-- the rival walks to the countering ball (around the furniture) -- the rival walks to the countering ball (around the furniture)
{ "move_npc_to", 1, rivalBallX, 4 }, -- 12 { "move_npc_to", 1, rivalBallX, 4 }, -- 13
{ "face_object", 1, "up" }, -- 13 { "face_object", 1, "up" }, -- 14
{ "show_text", "_OaksLabRivalIllTakeThisOneText" }, -- 14 { "show_text", "_OaksLabRivalIllTakeThisOneText" }, -- 15
{ "hide_object", "OAKS_LAB", rivalBall }, -- 15 { "hide_object", "OAKS_LAB", rivalBall }, -- 16
{ "show_text", "_OaksLabRivalReceivedMonText", { "show_text", "_OaksLabRivalReceivedMonText",
{ RAM = rivalBall == "OAKSLAB_CHARMANDER_POKE_BALL" and "CHARMANDER" { RAM = rivalBall == "OAKSLAB_CHARMANDER_POKE_BALL" and "CHARMANDER"
or rivalBall == "OAKSLAB_SQUIRTLE_POKE_BALL" and "SQUIRTLE" or rivalBall == "OAKSLAB_SQUIRTLE_POKE_BALL" and "SQUIRTLE"
or "BULBASAUR" } }, -- 16 or "BULBASAUR" } }, -- 17
{ "jump", 20 }, -- 17 { "jump", 21 }, -- 18
{ "jump", 20 }, -- 18 (spacer) { "jump", 21 }, -- 19 (spacer)
{ "show_text", "_OaksLabThoseArePokeBallsText" }, -- 19 { "show_text", "_OaksLabThoseArePokeBallsText" }, -- 20
} }
end end
+29 -5
View File
@@ -10,15 +10,39 @@
-- The girl, fisher and the four signs resolve automatically through the -- The girl, fisher and the four signs resolve automatically through the
-- extracted text pointers (no script needed). -- 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 { return {
talk = { talk = {
TEXT_PALLETTOWN_OAK = { TEXT_PALLETTOWN_OAK = {
{ "face_player" }, -- 1 { "face_player" }, -- 1
{ "check_flag", "EVENT_GOT_STARTER" }, -- 2 { "check_flag", "EVENT_BEAT_CHAMPION_RIVAL" }, -- 2
{ "jump_if_true", 6 }, -- 3 { "jump_if_false", 20 }, -- 3
{ "show_text", "_PalletTownOakHeyWaitDontGoOutText" },-- 4 { "check_flag", "EVENT_BEAT_PROF_OAK" }, -- 4
{ "jump", 7 }, -- 5 { "jump_if_true", 20 }, -- 5
{ "show_text", "_PalletTownOakItsUnsafeText" }, -- 6 { "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
}, },
}, },
} }
+140 -26
View File
@@ -133,22 +133,70 @@ M.BLUES_HOUSE = {
M.BILLS_HOUSE = { M.BILLS_HOUSE = {
talk = { talk = {
TEXT_BILLSHOUSE_BILL_POKEMON = { -- BillsHouseBillPokemonText: "I'm not a POKéMON!", a YES/NO choice
{ "check_flag", "EVENT_GOT_SS_TICKET" }, -- 1 -- (NO only adds "No, you gotta help!" before rejoining the YES
{ "jump_if_true", 13 }, -- 2 -- path), then the "get in the TELEPORTER" line and the monster
{ "show_text", "_BillsHouseBillImNotAPokemonText" }, -- 3 -- walking into the cell-separator machine
{ "show_text", "_BillsHouseBillNoYouGottaHelpText" }, -- 4 -- (BillsHousePokemonWalkToMachineScript: up 3, or around the player
-- the cell-separator PC throws its switch -- when they stand in the way facing down), where it is hidden and
-- (bills_house_pc.asm BillsHouseInitiatedText: SFX_SWITCH) -- EVENT_BILL_SAID_USE_CELL_SEPARATOR arms the PC at (1,4) -- see
{ "play_sound", "Switch" }, -- 5 -- OverworldState.billsHousePC for the separator itself.
{ "show_text", "_BillsHouseBillThankYouText" }, -- 6 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 -- pokered gives first (GiveItem fills wStringBuffer), then prints
-- the received text that reads it (scripts/BillsHouse.asm; the -- the received text that reads it (scripts/BillsHouse.asm; the
-- item id is S_S_TICKET in generated items.lua -- keyItem, so the -- item id is S_S_TICKET in generated items.lua -- keyItem, so the
-- sound_get_key_item jingle plays like BillsHouse.asm:196) -- sound_get_key_item jingle plays like BillsHouse.asm:196)
{ "give_item", "S_S_TICKET", 1, false }, -- 7 { "give_item", "S_S_TICKET", 1, false }, -- 5
{ "show_text", "_SSTicketReceivedText" }, -- 8 { "show_text", "_SSTicketReceivedText" }, -- 6
{ "set_flag", "EVENT_GOT_SS_TICKET" }, -- 9 { "set_flag", "EVENT_GOT_SS_TICKET" }, -- 7
-- The two Cerulean guards are a SWAP PAIR, not scenery -- The two Cerulean guards are a SWAP PAIR, not scenery
-- (BillsHouse.asm:174-178): handing over the ticket shows GUARD1 at -- (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 -- (28,12) and hides GUARD2 at (27,12). This matters far more than it
@@ -156,16 +204,36 @@ M.BILLS_HOUSE = {
-- house's south door at (27,11), and that house is one of the two -- 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 -- ways through the fence that splits Cerulean in half (the badge
-- house is the other). Leaving GUARD2 up forever severs the city -- -- house is the other). Leaving GUARD2 up forever severs the city --
-- the gym/mart half can never reach the Route 5 exit -- which is -- the gym/mart half can never reach the Route 5 exit.
-- exactly what stranded the bot after it beat Misty.
-- Same swap fires after the TM28 Rocket (CeruleanCity_2.asm -- Same swap fires after the TM28 Rocket (CeruleanCity_2.asm
-- CeruleanHideRocket), so either route opens the path. -- CeruleanHideRocket), so either route opens the path.
{ "show_object", "CERULEAN_CITY", "CERULEANCITY_GUARD1" }, -- 10 { "show_object", "CERULEAN_CITY", "CERULEANCITY_GUARD1" }, -- 8
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_GUARD2" }, -- 11 { "hide_object", "CERULEAN_CITY", "CERULEANCITY_GUARD2" }, -- 9
{ "jump", 14 }, -- 12 { "show_text", "_BillsHouseBillWhyDontYouGoInsteadOfMeText" }, -- 10
{ "show_text", "_BillsHouseBillCheckOutMyRarePokemonText" }, -- 13 { "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)
if game.save.flags.EVENT_GOT_SS_TICKET
and not game.save.flags.EVENT_USED_CELL_SEPARATOR_ON_BILL then
local Commands = require("src.script.Commands")
local ctx = { game = game, save = game.save, overworld = ow }
Commands.hide_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL_POKEMON")
Commands.show_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL1")
game.save.flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = true
game.save.flags.EVENT_USED_CELL_SEPARATOR_ON_BILL = true
game.save.flags.EVENT_MET_BILL = true
game.save.flags.EVENT_MET_BILL_2 = true
end
end,
} }
-- ------------------------------------------------------------------- -- -------------------------------------------------------------------
@@ -186,17 +254,48 @@ M.VERMILION_CITY = {
game.save.trashPuzzle = puz game.save.trashPuzzle = puz
puz.first = love.math.random(0, 7) * 2 puz.first = love.math.random(0, 7) * 2
end, end,
-- VermilionCityDefaultScript's per-frame SSAnneTicketCheckCoords check:
-- the unguarded cell (18,30) just west of the sailor leads straight
-- onto the dock warp, so stepping onto it heading for the dock gets
-- ticket-checked (and turned back once the ship has sailed) without
-- the player ever pressing A. The sailor himself never disappears.
onStep = function(game, ow, x, y)
if x ~= 18 or y ~= 30 then return false end
if ow.player.facing ~= "down" then return false end
local f = game.save.flags
local t = game.data.text
local TextBox = require("src.render.TextBox")
if f.EVENT_SS_ANNE_LEFT then
game.stack:push(TextBox.new(game,
t._VermilionCitySailor1ShipSetSailText or "The ship set sail.",
function() ow:scriptMove(ow.player, "up", 1) end))
return true
end
if (game.save.inventory.S_S_TICKET or 0) > 0 then return false end
game.stack:push(TextBox.new(game,
(t._VermilionCitySailor1WelcomeToSSAnneText or "Welcome to S.S.\nANNE!")
.. "\f"
.. (t._VermilionCitySailor1YouNeedATicketText
or "You need a ticket\nto get aboard."),
function() ow:scriptMove(ow.player, "up", 1) end))
return true
end,
talk = { 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 = { TEXT_VERMILIONCITY_SAILOR1 = {
{ "face_player" }, -- 1 { "face_player" }, -- 1
{ "show_text", "_VermilionCitySailor1WelcomeToSSAnneText" }, -- 2 { "check_flag", "EVENT_SS_ANNE_LEFT" }, -- 2
{ "check_item", "S_S_TICKET" }, -- 3 { "jump_if_true", 11 }, -- 3
{ "jump_if_false", 8 }, -- 4 { "show_text", "_VermilionCitySailor1WelcomeToSSAnneText" }, -- 4
{ "show_text", "_VermilionCitySailor1FlashedTicketText" }, -- 5 { "check_item", "S_S_TICKET" }, -- 5
{ "hide_object", "VERMILION_CITY", "VERMILIONCITY_SAILOR1" }, -- 6 { "jump_if_false", 9 }, -- 6
{ "jump", 9 }, -- 7 { "show_text", "_VermilionCitySailor1FlashedTicketText" }, -- 7
{ "show_text", "_VermilionCitySailor1YouNeedATicketText" }, -- 8 { "jump", 12 }, -- 8
{ "show_text", "_VermilionCitySailor1YouNeedATicketText" }, -- 9
{ "jump", 12 }, -- 10
{ "show_text", "_VermilionCitySailor1ShipSetSailText" }, -- 11
}, },
}, },
} }
@@ -665,6 +764,21 @@ M.POKEMON_TOWER_2F = {
{ "show_text", "_PokemonTower2FRivalHowsYourDexText" }, -- 10 { "show_text", "_PokemonTower2FRivalHowsYourDexText" }, -- 10
}, },
}, },
-- 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(M.POKEMON_TOWER_2F.talk.TEXT_POKEMONTOWER2F_RIVAL,
{ npc = rival })
return true
end,
} }
-- ------------------------------------------------------------------- -- -------------------------------------------------------------------
+11 -3
View File
@@ -307,9 +307,13 @@ local function saffronGate(guardText, triggers, horizontal)
return return
end end
if takeGuardDrink(game) then if takeGuardDrink(game) then
game.stack:push(TextBox.new(game,
t._SaffronGateGuardImParchedText or "Whoa, boy!\nI'm parched!",
function()
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
(t._SaffronGateGuardYouCanGoOnThroughText or (t._SaffronGateGuardYouCanGoOnThroughText or
"Thanks! You can\ngo on through!"), done)) "You can go on\nthrough!"), done))
end))
return return
end end
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
@@ -343,9 +347,13 @@ local function saffronGate(guardText, triggers, horizontal)
-- short way) was unreachable, and the bot could not get to Lavender -- short way) was unreachable, and the bot could not get to Lavender
-- for the POKE_FLUTE at all. -- for the POKE_FLUTE at all.
if takeGuardDrink(game) then if takeGuardDrink(game) then
game.stack:push(TextBox.new(game,
t._SaffronGateGuardImParchedText or "Whoa, boy!\nI'm parched!",
function()
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
(t._SaffronGateGuardYouCanGoOnThroughText or (t._SaffronGateGuardYouCanGoOnThroughText or
"Thanks! You can\ngo on through!"))) "You can go on\nthrough!")))
end))
return true return true
end end
local back local back
@@ -355,7 +363,7 @@ local function saffronGate(guardText, triggers, horizontal)
back = ow.player.facing == "up" and "down" or "up" back = ow.player.facing == "up" and "down" or "up"
end end
game.stack:push(TextBox.new(game, 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() function()
ow:scriptMove(ow.player, back, 1) ow:scriptMove(ow.player, back, 1)
end)) end))
+69 -4
View File
@@ -317,6 +317,33 @@ M.GAME_CORNER = {
done() done()
end)) end))
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)
if ow:trainerDefeated(npc) then
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()
local Commands = require("src.script.Commands")
Commands.hide_object({ game = game, save = game.save,
overworld = ow },
"GAME_CORNER", "GAMECORNER_ROCKET")
done()
end))
end)
end,
TEXT_GAMECORNER_CLERK1 = function(game, ow, npc, done) TEXT_GAMECORNER_CLERK1 = function(game, ow, npc, done)
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
local ChoiceBox = require("src.ui.ChoiceBox") local ChoiceBox = require("src.ui.ChoiceBox")
@@ -425,18 +452,56 @@ M.GAME_CORNER_PRIZE_ROOM = {
-- and the player steps off the dock, the ship sets sail. -- 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 = { M.VERMILION_DOCK = {
onEnter = function(game, ow) onEnter = function(game, ow)
if game.save.flags.EVENT_SS_ANNE_LEFT then local f = game.save.flags
if f.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") local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
game.data.text._VermilionCitySailor1ShipSetSailText game.data.text._VermilionCitySailor1ShipSetSailText
or "The ship set sail.", function() or "The ship set sail.", function()
ow:startWarpTo("VERMILION_CITY", 19, 30, "up") ow:startWarpTo("VERMILION_CITY", 18, 29, "up")
end)) end))
elseif game.save.flags.EVENT_GOT_HM01 then elseif f.EVENT_GOT_HM01 and ow.player.cellY == 2 then
game.save.flags.EVENT_SS_ANNE_LEFT = true -- VermilionDockSSAnneLeavesScript: only stepping OFF the ship
-- triggers the departure (wDestinationWarpID == 1 in pokered) --
-- the horn blows, smoke puffs drift off the funnel, the ship is
-- erased to open water, and the player is walked off the dock
-- into the city past the guard (VermilionCity's
-- SCRIPT_VERMILIONCITY_PLAYER_EXIT_SHIP walk)
f.EVENT_SS_ANNE_LEFT = true
require("src.core.Music").stop()
require("src.core.Sound").play(game.data, "SS_Anne_Horn") require("src.core.Sound").play(game.data, "SS_Anne_Horn")
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 }
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 }
rows[#rows + 1] = { "warp", "VERMILION_CITY", 18, 31, "up" }
rows[#rows + 1] = { "move_player", "up", 2 }
ow:queueScript(rows)
end end
end, end,
} }
+21
View File
@@ -457,6 +457,25 @@ M.ROUTE_24 = {
battleOrDone() battleOrDone()
end, 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,
} }
-- ------------------------------------------------------------------- -- -------------------------------------------------------------------
@@ -723,4 +742,6 @@ M.LANCES_ROOM = {
end, end,
} }
M.CELADON_CHIEF_HOUSE = require("data.scripts.celadon_chief_house")
return M return M
+35 -5
View File
@@ -259,21 +259,39 @@ M.CINNABAR_ISLAND = {
end, end,
} }
-- Pewter's youngster stops you leaving east before Brock is beaten -- Pewter's youngster stops you leaving east before Brock is beaten and
-- (scripts/PewterCity.asm PewterCityCheckPlayerLeavingEastScript; the -- escorts you to the gym (scripts/PewterCity.asm
-- original escorts you to the gym, we walk you back a step) -- PewterCityCheckPlayerLeavingEastScript /
-- PewterCityYoungsterShowsPlayerGymScript, engine/events/pewter_guys.asm
-- PewterGymGuyCoords): the "follow me" lines, then the player is walked
-- west along the road to the front of the PEWTER_GYM door at (16,17).
M.PEWTER_CITY = { M.PEWTER_CITY = {
onStep = function(game, ow, x, y) onStep = function(game, ow, x, y)
if game.save.flags.EVENT_BEAT_BROCK then return false end if game.save.flags.EVENT_BEAT_BROCK then return false end
if ow.runner:isRunning() or #ow.scriptMoves > 0 then return false end
if not inCoords({ { 35, 17 }, { 36, 17 }, { 37, 18 }, { 37, 19 } }, x, y) then if not inCoords({ { 35, 17 }, { 36, 17 }, { 37, 18 }, { 37, 19 } }, x, y) then
return false return false
end end
local t = text(game) local t = text(game)
-- walk up onto the road row (y=17), west to one tile east of the gym
-- door, then drop below the door and turn to face it
local steps = {}
for _ = 1, y - 17 do steps[#steps + 1] = "up" end
for _ = 1, x - 17 do steps[#steps + 1] = "left" end
steps[#steps + 1] = "down"
steps[#steps + 1] = "left"
local function walk(i)
if not steps[i] then
ow.player.facing = "up"
return
end
ow:scriptMove(ow.player, steps[i], 1, function() walk(i + 1) end)
end
push(game, t._PewterCityYoungsterYoureATrainerFollowMeText push(game, t._PewterCityYoungsterYoureATrainerFollowMeText
or "Hey! You're a\ntrainer, right?", function() or "Hey! You're a\ntrainer, right?", function()
push(game, t._PewterCityYoungsterGoTakeOnBrockText push(game, t._PewterCityYoungsterGoTakeOnBrockText
or "Go take on BROCK\nat the GYM first!", function() or "Go take on BROCK\nat the GYM first!", function()
ow:scriptMove(ow.player, "left", 1) walk(1)
end) end)
end) end)
return true return true
@@ -453,10 +471,22 @@ local function bikeGateGuard(coords, stopText, explainText)
return function(game, ow, x, y) return function(game, ow, x, y)
if game.save.inventory.BICYCLE then return false end if game.save.inventory.BICYCLE then return false end
if not inCoords(coords, x, y) 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) local t = text(game)
push(game, t[stopText] or "Hey! Wait up!", function() push(game, t[stopText] or "Hey! Wait up!", function()
push(game, t[explainText] or "You need a\nBICYCLE for\nCYCLING ROAD!", 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)
end) end)
return true return true
+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 docs/behavior-porting-notes.md; deliberate additions beyond the original
are in docs/new-features.md. 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 ```sh
luajit tools/gen_registry_docs.lua ../pokemon-gen1-recomp-project.wiki 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.
+27
View File
@@ -48,6 +48,8 @@ function love.load(args)
for i, a in ipairs(args or {}) do for i, a in ipairs(args or {}) do
if a == "--editor" then if a == "--editor" then
editorMode = true editorMode = true
elseif a == "--developer" then
_G.POKEPORT_DEV_MODE = true
elseif a == "--save" and args[i + 1] and args[i + 1] ~= "" then elseif a == "--save" and args[i + 1] and args[i + 1] ~= "" then
savePath = args[i + 1] savePath = args[i + 1]
elseif a == "--speed" and tonumber(args[i + 1]) then elseif a == "--speed" and tonumber(args[i + 1]) then
@@ -171,6 +173,31 @@ function love.gamepadaxis(joystick, axis, value)
Game:gamepadaxis(joystick, axis, value) Game:gamepadaxis(joystick, axis, value)
end 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) function love.touchpressed(id, x, y, dx, dy, pressure)
if editorMode then return end if editorMode then return end
if Importer then return Importer:mousepressed(x, y, 1) end if Importer then return Importer:mousepressed(x, y, 1) end
+15 -19
View File
@@ -27,17 +27,17 @@ refresh is safe, just rebuild.
## Build ## Build
```bash ```bash
# Debug APK (default Android debug keystore) # Build the APK
scripts/build_android.sh scripts/build_android.sh
# Release APK (signing is manual, see below) # Build the APK, setting app.version_name/app.version_code to match a release
scripts/build_android.sh --release scripts/build_android.sh --version 0.2.5
# Zip game.love + branding only (no Android SDK required) # Zip game.love + branding only (no Android SDK required)
scripts/build_android.sh --package-only 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/`, The embedded `game.love` deliberately excludes `data/generated/`,
`assets/generated/`, and any ROM. It contains the first-boot Lua importer and `assets/generated/`, and any ROM. It contains the first-boot Lua importer and
@@ -59,12 +59,10 @@ Set `ANDROID_SDK_ROOT` (or `ANDROID_HOME`), or let the script write
`local.properties` when it finds `~/Library/Android/sdk`. `local.properties` when it finds `~/Library/Android/sdk`.
Gradle flavor used: **`embedNoRecord`** (game fused into the APK, no microphone). Gradle flavor used: **`embedNoRecord`** (game fused into the APK, no microphone).
Build task: `assembleEmbedNoRecordDebug`.
- Debug task: `assembleEmbedNoRecordDebug` The APK lands under `app/build/outputs/apk/embedNoRecord/debug/`.
- Release task: `assembleEmbedNoRecordRelease` `scripts/build_android.sh` also copies it to `dist/android/debug/`.
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}/`.
### Payload path ### Payload path
@@ -79,17 +77,15 @@ scripts, tests, and mobile build sources are excluded.
| `app.application_id` | `com.theboisclub.pokemonred` | | `app.application_id` | `com.theboisclub.pokemonred` |
| `app.name` | Pokemon Red | | `app.name` | Pokemon Red |
| `app.orientation` | `portrait` | | `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 | | 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 ## Signing
- **Debug**: default Android debug keystore (no setup). Signed with the default Android keystore (no setup required).
- **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
```
+3 -1
View File
@@ -6,7 +6,9 @@
#app.name=LÖVE for Android #app.name=LÖVE for Android
app.application_id=com.theboisclub.pokemonred app.application_id=com.theboisclub.pokemonred
app.orientation=portrait # fullUser: allow every orientation the player's device permits (portrait and
# landscape), honouring their auto-rotate lock. Was "portrait" (locked).
app.orientation=fullUser
app.version_code=32 app.version_code=32
app.version_name=11.5a app.version_name=11.5a
@@ -1,4 +1,8 @@
*.[oa] *.[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 *.so
*.obj *.obj
*.lib *.lib
@@ -36,6 +36,7 @@ demos
*~ *~
Makefile* Makefile*
config* config*
!src/common/config.h
libtool libtool
/platform/unix/ar-lib /platform/unix/ar-lib
/platform/unix/compile /platform/unix/compile
@@ -183,6 +183,18 @@ void vibrate(double seconds)
env->DeleteLocalRef(activity); 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 * Helper functions for the filesystem module
*/ */
@@ -59,6 +59,14 @@ bool openURL(const std::string &url);
void vibrate(double seconds); 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 * 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; 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()) && if (!love::android::directoryExists(save_path_full.c_str()) &&
!love::android::mkdir(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()); 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_writedir = getDriveRoot(save_path_full);
std::string temp_createdir = skipDriveRoot(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 // 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 // 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 // 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 #endif
} }
bool System::pickFile() const
{
#ifdef LOVE_ANDROID
return love::android::showFilePicker();
#else
return false;
#endif
}
bool System::hasBackgroundMusic() const bool System::hasBackgroundMusic() const
{ {
#if defined(LOVE_ANDROID) #if defined(LOVE_ANDROID)
@@ -106,6 +106,15 @@ public:
*/ */
virtual void vibrate(double seconds) const; 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. * Gets if the user is playing music on background.
* Throws an exception on unsupported platforms. * Throws an exception on unsupported platforms.
@@ -95,6 +95,12 @@ int w_vibrate(lua_State *L)
return 0; return 0;
} }
int w_pickFile(lua_State *L)
{
luax_pushboolean(L, instance()->pickFile());
return 1;
}
int w_hasBackgroundMusic(lua_State *L) int w_hasBackgroundMusic(lua_State *L)
{ {
lua_pushboolean(L, instance()->hasBackgroundMusic()); lua_pushboolean(L, instance()->hasBackgroundMusic());
@@ -110,6 +116,7 @@ static const luaL_Reg functions[] =
{ "getPowerInfo", w_getPowerInfo }, { "getPowerInfo", w_getPowerInfo },
{ "openURL", w_openURL }, { "openURL", w_openURL },
{ "vibrate", w_vibrate }, { "vibrate", w_vibrate },
{ "pickFile", w_pickFile },
{ "hasBackgroundMusic", w_hasBackgroundMusic }, { "hasBackgroundMusic", w_hasBackgroundMusic },
{ 0, 0 } { 0, 0 }
}; };
@@ -61,6 +61,13 @@ public class GameActivity extends SDLActivity {
protected final int[] recordAudioRequestDummy = new int[1]; protected final int[] recordAudioRequestDummy = new int[1];
public static final int EXTERNAL_STORAGE_REQUEST_CODE = 2; public static final int EXTERNAL_STORAGE_REQUEST_CODE = 2;
public static final int RECORD_AUDIO_REQUEST_CODE = 3; 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 immersiveActive = false;
private static boolean needToCopyGameInArchive = false; private static boolean needToCopyGameInArchive = false;
private boolean storagePermissionUnnecessary = false; private boolean storagePermissionUnnecessary = false;
@@ -332,6 +339,64 @@ public class GameActivity extends SDLActivity {
return openURL(url) == 0; 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. * Copies a given file from the assets folder to the destination.
* *
+5
View File
@@ -62,10 +62,15 @@
<key>UISupportedInterfaceOrientations</key> <key>UISupportedInterfaceOrientations</key>
<array> <array>
<string>UIInterfaceOrientationPortrait</string> <string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array> </array>
<key>UISupportedInterfaceOrientations~ipad</key> <key>UISupportedInterfaceOrientations~ipad</key>
<array> <array>
<string>UIInterfaceOrientationPortrait</string> <string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array> </array>
<key>UTExportedTypeDeclarations</key> <key>UTExportedTypeDeclarations</key>
<array> <array>
+96 -14
View File
@@ -1,15 +1,17 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Packages the LÖVE2D Pokémon Red port into distributable macOS and # Packages the LÖVE2D Pokémon Red port into distributable macOS, Windows,
# Windows builds. Runs entirely on macOS (no cross-compiling needed, # and Linux builds. Runs entirely on macOS (no cross-compiling needed,
# the Windows build reuses LÖVE's prebuilt win64 binaries). # 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] # [--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 # Output: dist/mac/PokemonRed-macos.zip
# dist/win/PokemonRed-win64.zip # dist/win/PokemonRed-win64.zip
# dist/android/{debug,release}/*.apk (full gradle output stays under # dist/linux/PokemonRed-linux.zip (fused x86_64 AppImage)
# dist/android/debug/*.apk (full gradle output stays under
# mobile/android/app/build/outputs/apk/embedNoRecord/) # mobile/android/app/build/outputs/apk/embedNoRecord/)
# dist/ios/<Config>-<sdk>/PokemonRed.app (full xcodebuild output stays # dist/ios/<Config>-<sdk>/PokemonRed.app (full xcodebuild output stays
# under mobile/ios/build/Build/Products/) # under mobile/ios/build/Build/Products/)
@@ -27,11 +29,11 @@ APP_NAME="PokemonRed"
BUNDLE_ID="com.theboisclub.pokemonred" BUNDLE_ID="com.theboisclub.pokemonred"
LOVE_VERSION="11.5" LOVE_VERSION="11.5"
VERSION="$(git -C "$ROOT" rev-parse --short HEAD 2>/dev/null || echo dev)" VERSION="$(git -C "$ROOT" rev-parse --short HEAD 2>/dev/null || echo dev)"
VERSION_EXPLICIT=false
IDENTITY="" IDENTITY=""
TARGET="all" TARGET="all"
NOTARY_PROFILE="notary-profile" NOTARY_PROFILE="notary-profile"
NOTARIZE=true NOTARIZE=true
ANDROID_RELEASE=false
IOS_RELEASE=false IOS_RELEASE=false
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; } say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
@@ -40,18 +42,18 @@ fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
while [ $# -gt 0 ]; do while [ $# -gt 0 ]; do
case "$1" in case "$1" in
mac|win|android|ios|all) TARGET="$1" ;; mac|win|linux|android|ios|all) TARGET="$1" ;;
--version) VERSION="$2"; shift ;; --version) VERSION="$2"; VERSION_EXPLICIT=true; shift ;;
--identity) IDENTITY="$2"; shift ;; --identity) IDENTITY="$2"; shift ;;
--notary-profile) NOTARY_PROFILE="$2"; shift ;; --notary-profile) NOTARY_PROFILE="$2"; shift ;;
--no-notarize) NOTARIZE=false ;; --no-notarize) NOTARIZE=false ;;
--release) ANDROID_RELEASE=true; IOS_RELEASE=true ;; --release) IOS_RELEASE=true ;;
*) fail "unknown argument: $1" ;; *) fail "unknown argument: $1" ;;
esac esac
shift shift
done done
mkdir -p "$CACHE" "$WORK" "$DIST/mac" "$DIST/win" mkdir -p "$CACHE" "$WORK" "$DIST/mac" "$DIST/win" "$DIST/linux"
# --------------------------------------------------------------- game.love # --------------------------------------------------------------- game.love
say "packing game.love" say "packing game.love"
@@ -139,11 +141,20 @@ build_win() {
say "building Windows (win64) app" say "building Windows (win64) app"
local zip_name="love-$LOVE_VERSION-win64.zip" local zip_name="love-$LOVE_VERSION-win64.zip"
local love_zip="$CACHE/$zip_name" 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 if [ ! -f "$love_zip" ]; then
say "downloading LÖVE $LOVE_VERSION win64 binaries" say "downloading LÖVE $LOVE_VERSION win64 binaries"
curl -fL --progress-bar \ curl -fL --progress-bar \
"https://github.com/love2d/love/releases/download/$LOVE_VERSION/$zip_name" \ "https://github.com/love2d/love/releases/download/$LOVE_VERSION/$zip_name" \
-o "$love_zip" || fail "download failed, check LOVE_VERSION or your network" -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 fi
local extract_dir="$WORK/love-win64" local extract_dir="$WORK/love-win64"
@@ -167,12 +178,82 @@ build_win() {
say "Windows build: $zip_out" 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 # --------------------------------------------------------------- Android
build_android() { build_android() {
say "building Android (delegating to scripts/build_android.sh)" say "building Android (delegating to scripts/build_android.sh)"
local args=() local args=()
if [ "$ANDROID_RELEASE" = true ]; then if [ "$VERSION_EXPLICIT" = true ]; then
args+=(--release) args+=(--version "$VERSION")
fi fi
"$ROOT/scripts/build_android.sh" ${args[@]+"${args[@]}"} "$ROOT/scripts/build_android.sh" ${args[@]+"${args[@]}"}
} }
@@ -190,9 +271,10 @@ build_ios() {
case "$TARGET" in case "$TARGET" in
mac) build_mac ;; mac) build_mac ;;
win) build_win ;; win) build_win ;;
linux) build_linux ;;
android) build_android ;; android) build_android ;;
ios) build_ios ;; ios) build_ios ;;
all) build_mac; build_win ;; all) build_mac; build_win; build_linux ;;
esac esac
case "$TARGET" in case "$TARGET" in
+28 -30
View File
@@ -1,10 +1,9 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Packages the LÖVE2D Pokémon Red port into an Android APK via love-android 11.5a. # 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) # --version X.Y.Z set app.version_name / app.version_code (else left as-is)
# --release assembleEmbedNoRecordRelease (requires out-of-band signing)
# --package-only zip game.love + apply branding; skip gradle # --package-only zip game.love + apply branding; skip gradle
# #
# Prerequisites: # Prerequisites:
@@ -13,9 +12,8 @@
# - JDK 17 # - JDK 17
# #
# Output (after gradle): # 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/debug/*.apk
# mobile/android/app/build/outputs/apk/embedNoRecord/release/*.apk
set -euo pipefail set -euo pipefail
@@ -29,7 +27,7 @@ APPLICATION_ID="com.theboisclub.pokemonred"
LOVE_ANDROID_VERSION="11.5a" LOVE_ANDROID_VERSION="11.5a"
NDK_VERSION="25.2.9519653" NDK_VERSION="25.2.9519653"
RELEASE=false VERSION=""
PACKAGE_ONLY=false PACKAGE_ONLY=false
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; } 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 while [ $# -gt 0 ]; do
case "$1" in case "$1" in
--release) RELEASE=true ;; --version) VERSION="$2"; shift ;;
--package-only) PACKAGE_ONLY=true ;; --package-only) PACKAGE_ONLY=true ;;
-h|--help) -h|--help)
sed -n '2,20p' "$0" sed -n '2,20p' "$0"
exit 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 esac
shift shift
done 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 # --------------------------------------------------------------- preconditions
if [ ! -f "$ANDROID_DIR/settings.gradle" ] || [ ! -f "$ANDROID_DIR/gradlew" ]; then if [ ! -f "$ANDROID_DIR/settings.gradle" ] || [ ! -f "$ANDROID_DIR/gradlew" ]; then
fail "love-android not found at mobile/android/. fail "love-android not found at mobile/android/.
@@ -74,10 +84,10 @@ apply_android_branding() {
say "applying Android branding (gradle.properties + permission trim)" 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 import pathlib, re, sys
path = pathlib.Path(sys.argv[1]) 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() text = path.read_text()
def set_prop(text, key, value): 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 = re.sub(r"(?m)^app\.name_byte_array=.*\n?", "", text)
text = set_prop(text, "app.name", name) text = set_prop(text, "app.name", name)
text = set_prop(text, "app.application_id", app_id) text = set_prop(text, "app.application_id", app_id)
text = set_prop(text, "app.orientation", "portrait") text = set_prop(text, "app.orientation", "fullUser")
if version:
text = set_prop(text, "app.version_name", version)
text = set_prop(text, "app.version_code", version_code)
path.write_text(text) path.write_text(text)
PY PY
@@ -178,16 +191,8 @@ require_android_sdk() {
# --------------------------------------------------------------- gradle # --------------------------------------------------------------- gradle
run_gradle() { run_gradle() {
local task local task="assembleEmbedNoRecordDebug"
if $RELEASE; then say "building APK ($task)"
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
if ! ( if ! (
cd "$ANDROID_DIR" cd "$ANDROID_DIR"
@@ -199,19 +204,12 @@ run_gradle() {
You can still iterate on the .love payload with: scripts/build_android.sh --package-only" You can still iterate on the .love payload with: scripts/build_android.sh --package-only"
fi fi
local out_dir local out_dir="$ANDROID_DIR/app/build/outputs/apk/embedNoRecord/debug"
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
if [ -d "$out_dir" ]; then if [ -d "$out_dir" ]; then
say "APK output:" say "APK output:"
find "$out_dir" -name '*.apk' -exec ls -lh {} \; find "$out_dir" -name '*.apk' -exec ls -lh {} \;
local flavor="debug" local dist_dir="$DIST/debug"
$RELEASE && flavor="release"
local dist_dir="$DIST/$flavor"
rm -rf "$dist_dir" rm -rf "$dist_dir"
mkdir -p "$dist_dir" mkdir -p "$dist_dir"
find "$out_dir" -name '*.apk' -exec cp {} "$dist_dir/" \; find "$out_dir" -name '*.apk' -exec cp {} "$dist_dir/" \;
+1 -1
View File
@@ -155,7 +155,7 @@ require_ios_libraries
apply_ios_branding() { apply_ios_branding() {
[ -f "$OVERLAY_PLIST" ] || fail "missing overlay plist: $OVERLAY_PLIST" [ -f "$OVERLAY_PLIST" ] || fail "missing overlay plist: $OVERLAY_PLIST"
local dest="$XCODE_DIR/ios/love-ios.plist" local dest="$XCODE_DIR/ios/love-ios.plist"
say "applying iOS branding (portrait-only Info.plist, display name)" say "applying iOS branding (portrait + landscape Info.plist, display name)"
cp "$OVERLAY_PLIST" "$dest" cp "$OVERLAY_PLIST" "$dest"
} }
+72 -11
View File
@@ -635,7 +635,6 @@ function BattleState:startMessage(item)
self.total = self.total + #codes self.total = self.total + #codes
end end
self.charIndex = 0 self.charIndex = 0
self.holdTimer = nil
end end
function BattleState:updateQueue() function BattleState:updateQueue()
@@ -786,9 +785,8 @@ function BattleState:updateQueue()
if self.charIndex < self.total then if self.charIndex < self.total then
self.charIndex = math.min(self.total, self.charIndex + 2) self.charIndex = math.min(self.total, self.charIndex + 2)
else else
self.holdTimer = (self.holdTimer or 40) - 1
local input = self.game.input 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 self.current = nil
end end
end end
@@ -968,6 +966,25 @@ function BattleState:playerHasPP()
return false return false
end 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) function BattleState:update(dt)
self.frame = self.frame + 1 self.frame = self.frame + 1
self:updateFx() self:updateFx()
@@ -1081,6 +1098,7 @@ function BattleState:update(dt)
end end
self.phase = "moveSelect" self.phase = "moveSelect"
self.moveIndex = math.min(self.moveIndex, #self.player.curMoves) self.moveIndex = math.min(self.moveIndex, #self.player.curMoves)
self.moveSwapIndex = nil
elseif choice == "run" then elseif choice == "run" then
self:tryRun() self:tryRun()
elseif choice == "item" then elseif choice == "item" then
@@ -1098,9 +1116,22 @@ function BattleState:update(dt)
self.moveIndex = self.moveIndex > 1 and self.moveIndex - 1 or #moves self.moveIndex = self.moveIndex > 1 and self.moveIndex - 1 or #moves
elseif input:wasPressed("down") then elseif input:wasPressed("down") then
self.moveIndex = self.moveIndex < #moves and self.moveIndex + 1 or 1 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 elseif input:wasPressed("b") then
self.moveSwapIndex = nil
self.phase = "menu" self.phase = "menu"
elseif input:wasPressed("a") then 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] local mv = moves[self.moveIndex]
if self.player.disabledSlot == self.moveIndex then if self.player.disabledSlot == self.moveIndex then
self:say("The move is\ndisabled!") self:say("The move is\ndisabled!")
@@ -1501,10 +1532,16 @@ function BattleState:endOfTurn()
-- that sets the flag also zeroes the counter, so a stale value is -- that sets the flag also zeroes the counter, so a stale value is
-- unobservable (a switch or cure downgrades Toxic to plain poison). -- unobservable (a switch or cure downgrades Toxic to plain poison).
self.sideToxic = self.sideToxic or {} self.sideToxic = self.sideToxic or {}
for _, pair in ipairs({ { self.player, self.enemy, "player" }, -- a battler whose opponent was already knocked out by a move this turn
{ self.enemy, self.player, "enemy" } }) do -- skips its own residual (HandlePoisonBurnLeechSeed is bypassed when the
local b, opp, side = pair[1], pair[2], pair[3] -- move faints the target); snapshot before residual so one side's
if b.mon.hp > 0 then -- 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) local msgs = Status.residual(b, opp, self)
for _, m in ipairs(msgs) do self:sayNext(prefixEnemy(m, b)) end for _, m in ipairs(msgs) do self:sayNext(prefixEnemy(m, b)) end
if #msgs > 0 then self:drainNext() end -- poison/burn/seed HP moved if #msgs > 0 then self:drainNext() end -- poison/burn/seed HP moved
@@ -2265,6 +2302,7 @@ function BattleState:performMove(user, target, moveInst, isCalled)
-- falling back to the id tables (Fly AND Dig go semi-invulnerable: -- falling back to the id tables (Fly AND Dig go semi-invulnerable:
-- ChargeEffect sets INVULNERABLE for both) -- ChargeEffect sets INVULNERABLE for both)
if record and record.charge and not releasing then if record and record.charge and not releasing then
self:cancelMoveAnim()
user.charging = moveInst user.charging = moveInst
user.chargeReady = true user.chargeReady = true
local invulnerable = move.semiInvulnerable local invulnerable = move.semiInvulnerable
@@ -2274,6 +2312,15 @@ function BattleState:performMove(user, target, moveInst, isCalled)
if invulnerable then if invulnerable then
user.invulnerable = true user.invulnerable = true
end 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] local chargeText = move.chargeText or CHARGE_TEXT[move.id]
or "%s\nis charging up!" or "%s\nis charging up!"
self:sayNext(chargeText:format(displayName(user))) self:sayNext(chargeText:format(displayName(user)))
@@ -2401,6 +2448,9 @@ end
function BattleState:onFaint(battler) function BattleState:onFaint(battler)
if battler.faintQueued then return end if battler.faintQueued then return end
battler.faintQueued = true battler.faintQueued = true
if battler.isPlayer and self.participants then
self.participants[battler.mon] = nil
end
Runtime.emit("battle.fainted", { battle = self, battler = battler }) Runtime.emit("battle.fainted", { battle = self, battler = battler })
-- the faint slide + cry ride the queue (after the move animation and -- the faint slide + cry ride the queue (after the move animation and
-- the HP-bar drain, pokered's order); the slide finishes before the -- the HP-bar drain, pokered's order); the slide finishes before the
@@ -2437,9 +2487,9 @@ function BattleState:enemyMonFainted()
-- exp is split among the mons that fought this enemy -- exp is split among the mons that fought this enemy
-- (engine/battle/experience.asm); traded mons earn x1.5; each -- (engine/battle/experience.asm); traded mons earn x1.5; each
-- participant gets the full stat exp -- participant gets the full stat exp
-- the divisor counts EVERY participant, fainted ones included -- a mon that fainted mid-fight has had its gain-exp flag cleared
-- (DivideExpDataByNumMonsGainingExp keeps their flag bits); only the -- (RemoveFaintedPlayerMon), so it drops out of the divisor and only
-- living ones are actually paid -- the surviving participants are counted and paid
local participants, alive = 0, {} local participants, alive = 0, {}
for _, mon in ipairs(self.game.save.party) do for _, mon in ipairs(self.game.save.party) do
if self.participants and self.participants[mon] then if self.participants and self.participants[mon] then
@@ -2749,6 +2799,7 @@ function BattleState:safariAction(choice)
local playerName = self.game.save.player.name local playerName = self.game.save.player.name
if choice == "run" then if choice == "run" then
require("src.core.Sound").play(self.data, "Run")
self:say("Got away safely!") self:say("Got away safely!")
self.result = "run" self.result = "run"
self.afterQueue = "finish" self.afterQueue = "finish"
@@ -2830,6 +2881,12 @@ function BattleState:safariEnemyTurn()
end end
if fled then if fled then
self:sayNext(("Wild %s\nran!"):format(self.enemy.name)) 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.result = "run"
self.afterQueue = "finish" self.afterQueue = "finish"
end end
@@ -3826,7 +3883,11 @@ function BattleState:drawTextArea()
for i, mv in ipairs(self.player.curMoves) do for i, mv in ipairs(self.player.curMoves) do
Font.draw(self.data.moves[mv.id].name, 48, 96 + i * 8) Font.draw(self.data.moves[mv.id].name, 48, 96 + i * 8)
end 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] local sel = self.player.curMoves[self.moveIndex]
if sel then if sel then
if self.player.disabledSlot == self.moveIndex then if self.player.disabledSlot == self.moveIndex then
+2 -2
View File
@@ -516,8 +516,8 @@ MoveEffects.full = {
-- charge moves: first turn just charges; Fly AND Dig go -- charge moves: first turn just charges; Fly AND Dig go
-- semi-invulnerable (ChargeEffect sets INVULNERABLE for both) -- semi-invulnerable (ChargeEffect sets INVULNERABLE for both)
CHARGE_EFFECT = { charge = {} }, CHARGE_EFFECT = { charge = { anim = "XSTATITEM_ANIM", enemyAnim = "XSTATITEM_DUPLICATE_ANIM" } },
FLY_EFFECT = { charge = { invulnerable = true } }, FLY_EFFECT = { charge = { invulnerable = true, anim = "TELEPORT" } },
TRAPPING_EFFECT = { TRAPPING_EFFECT = {
-- TrappingEffect runs BEFORE the hit test and clears the target's -- TrappingEffect runs BEFORE the hit test and clears the target's
+37 -2
View File
@@ -17,7 +17,7 @@ local Game = {}
-- dev-mode gate for the F5/backtick hotkeys; false keeps every src/dev -- 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 -- 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 -- the boot screen ids (field.boot.screens); a plain function so the
-- headless harness can borrow makeTitleState onto a stub game -- headless harness can borrow makeTitleState onto a stub game
@@ -283,10 +283,22 @@ function Game:keypressed(key)
self:zoomStep(1) self:zoomStep(1)
return return
elseif key == "2" then elseif key == "2" then
-- cycle COLORS (GBC / OG / OG INV / GBC INV / CLASSIC); always on -- 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") local PaletteFX = require("src.render.PaletteFX")
self.save.options.colors = PaletteFX.cycleMode() self.save.options.colors = PaletteFX.cycleMode()
self:writeOptions() self:writeOptions()
end
return return
elseif key == "3" then elseif key == "3" then
-- cycle TILT OFF → 15 → 35 → 50 → OFF (mnemonic: 3D), free-roam only -- cycle TILT OFF → 15 → 35 → 50 → OFF (mnemonic: 3D), free-roam only
@@ -337,6 +349,28 @@ function Game:gamepadaxis(joystick, axis, value)
Input:gamepadaxis(joystick, axis, value) Input:gamepadaxis(joystick, axis, value)
end 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) function Game:touchpressed(id, x, y)
TouchInput:touchpressed(id, x, y) TouchInput:touchpressed(id, x, y)
end end
@@ -399,6 +433,7 @@ function Game:applyOptions(opts)
require("src.render.Tilt").applyOptions(opts) require("src.render.Tilt").applyOptions(opts)
require("src.render.GBCFX").applyOptions(opts) require("src.render.GBCFX").applyOptions(opts)
require("src.core.VideoMode").applyOptions(opts) require("src.core.VideoMode").applyOptions(opts)
Input:applyBindings(opts.bindings)
end end
function Game:restoreSave(loaded, recovered) function Game:restoreSave(loaded, recovered)
+101 -15
View File
@@ -3,7 +3,7 @@
local Input = {} local Input = {}
local BINDINGS = { local DEFAULT_BINDINGS = {
up = "up", w = "up", up = "up", w = "up",
down = "down", s = "down", down = "down", s = "down",
left = "left", a = "left", left = "left", a = "left",
@@ -18,8 +18,11 @@ local BINDINGS = {
-- Escape = start for desktop friendliness. -- Escape = start for desktop friendliness.
-- LÖVE's standard gamepad mapping (SDL game controller DB), consistent -- LÖVE's standard gamepad mapping (SDL game controller DB), consistent
-- across Xbox/PlayStation/generic controllers on desktop and mobile. -- across Xbox/PlayStation/generic controllers on desktop and mobile. Some
local GAMEPAD_BINDINGS = { -- 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", dpup = "up", dpdown = "down", dpleft = "left", dpright = "right",
a = "a", b = "b", a = "a", b = "b",
start = "start", back = "select", start = "start", back = "select",
@@ -32,48 +35,131 @@ local STICK_ON = 0.5
local STICK_OFF = 0.3 local STICK_OFF = 0.3
function Input:init() 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.state = {}
self.pressQueue = {} self.pressQueue = {}
self.pressed = {} self.pressed = {}
self.sources = {}
self.stickAxis = { x = 0, y = 0 } self.stickAxis = { x = 0, y = 0 }
self.stickDir = nil self.stickDir = nil
end end
function Input:keypressed(key) -- Multiple physical sources (W + Up, d-pad + stick, etc.) can claim the
local btn = BINDINGS[key] -- same GB button. Track them individually so releasing one doesn't clear
if btn then -- 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) table.insert(self.pressQueue, btn)
end end
self.state[btn] = true
end end
function Input:keyreleased(key) local function release(self, btn, source)
local btn = BINDINGS[key] local sources = self.sources[btn]
if btn then 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 self.state[btn] = false
end end
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. -- 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() function Input:step()
self.pressed = {} self.pressed = {}
for _, btn in ipairs(self.pressQueue) do for _, btn in ipairs(self.pressQueue) do
self.pressed[btn] = true self.pressed[btn] = true
local sources = self.sources[btn]
if sources == nil then
-- synthetic pressQueue inject (tests/drivers): no live source map
self.state[btn] = true 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 end
self.pressQueue = {} self.pressQueue = {}
end end
function Input:gamepadpressed(joystick, button) function Input:gamepadpressed(joystick, button)
local btn = GAMEPAD_BINDINGS[button] local btn = self.padBindings[button]
if btn then if btn then
table.insert(self.pressQueue, btn) press(self, btn, "pad:" .. button)
end end
end end
function Input:gamepadreleased(joystick, button) function Input:gamepadreleased(joystick, button)
local btn = GAMEPAD_BINDINGS[button] local btn = self.padBindings[button]
if btn then if btn then
self.state[btn] = false release(self, btn, "pad:" .. button)
end end
end end
@@ -103,10 +189,10 @@ function Input:gamepadaxis(joystick, axis, value)
if newDir ~= self.stickDir then if newDir ~= self.stickDir then
if self.stickDir then if self.stickDir then
self.state[self.stickDir] = false release(self, self.stickDir, "stick")
end end
if newDir then if newDir then
table.insert(self.pressQueue, newDir) press(self, newDir, "stick")
end end
self.stickDir = newDir self.stickDir = newDir
end end
+1
View File
@@ -114,6 +114,7 @@ local SPECIAL = {
oakRoute = "Music_Routes2", oakRoute = "Music_Routes2",
bike = "Music_BikeRiding", bike = "Music_BikeRiding",
surf = "Music_Surfing", surf = "Music_Surfing",
evolution = "Music_SafariZone",
} }
-- the label a scene role resolves to; call sites keep their own presence -- the label a scene role resolves to; call sites keep their own presence
+132 -5
View File
@@ -27,6 +27,133 @@ local OPTIONS_FILENAME = "options.lua"
local BACKUP_FILENAME = FILENAME .. ".bak" local BACKUP_FILENAME = FILENAME .. ".bak"
local TMP_FILENAME = FILENAME .. ".tmp" local TMP_FILENAME = FILENAME .. ".tmp"
-- ------- 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 PokemonRed.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 -- Port + original Options menu defaults. Missing keys on load are filled
-- from this table so old options.lua files stay compatible. -- from this table so old options.lua files stay compatible.
function SaveData.defaultOptions() function SaveData.defaultOptions()
@@ -94,7 +221,7 @@ end
-- love.filesystem, so the mod loader's injected filesystem can carry the -- love.filesystem, so the mod loader's injected filesystem can carry the
-- options round-trip headless (no love global). -- options round-trip headless (no love global).
function SaveData.saveOptions(opts, fs) function SaveData.saveOptions(opts, fs)
fs = fs or love.filesystem fs = persistFs(fs)
opts = SaveData.mergeOptions(opts) opts = SaveData.mergeOptions(opts)
-- modOptions is per-mod nested state: fold the on-disk sub-tree -- modOptions is per-mod nested state: fold the on-disk sub-tree
-- underneath (newest value winning per key) so one caller's partial -- underneath (newest value winning per key) so one caller's partial
@@ -123,7 +250,7 @@ function SaveData.saveOptions(opts, fs)
end end
function SaveData.loadOptions(fs) function SaveData.loadOptions(fs)
fs = fs or love.filesystem fs = persistFs(fs)
local data, err = readTable(fs, OPTIONS_FILENAME) local data, err = readTable(fs, OPTIONS_FILENAME)
if not data then if not data then
if fs.getInfo(OPTIONS_FILENAME) then if fs.getInfo(OPTIONS_FILENAME) then
@@ -311,7 +438,7 @@ end)
-- afterwards either way -- afterwards either way
SaveData.addCoreMigration(1, function(save) SaveData.addCoreMigration(1, function(save)
if type(save.options) == "table" 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) SaveData.saveOptions(save.options)
end end
end) end)
@@ -342,7 +469,7 @@ function SaveData.save(data, mods)
if k ~= "options" then gameOnly[k] = v end if k ~= "options" then gameOnly[k] = v end
end end
local encoded = SaveSerializer.encode(gameOnly) local encoded = SaveSerializer.encode(gameOnly)
local fs = love.filesystem local fs = persistFs(nil)
if fs.getInfo(FILENAME) then if fs.getInfo(FILENAME) then
local prev = fs.read(FILENAME) local prev = fs.read(FILENAME)
if prev then fs.write(BACKUP_FILENAME, prev) end if prev then fs.write(BACKUP_FILENAME, prev) end
@@ -371,7 +498,7 @@ end
-- or corrupt and a staged/backup copy was promoted; Game surfaces the -- or corrupt and a staged/backup copy was promoted; Game surfaces the
-- recovery on the load report -- recovery on the load report
function SaveData.load() function SaveData.load()
local fs = love.filesystem local fs = persistFs(nil)
local data, err = readTable(fs, FILENAME) local data, err = readTable(fs, FILENAME)
local recovered local recovered
if not data then if not data then
+15
View File
@@ -75,6 +75,21 @@ function TouchInput:init()
self.selectFired = false -- one SELECT per two-finger gesture cluster self.selectFired = false -- one SELECT per two-finger gesture cluster
end 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) local function pulse(self, key)
Input:keypressed(key) Input:keypressed(key)
self.armed[#self.armed + 1] = key self.armed[#self.armed + 1] = key
+232
View File
@@ -0,0 +1,232 @@
-- 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)
-- 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)
local okr, ret = pcall(fn, d, "", 1)
return okr and ret ~= 0
end
break
end
end
end
return physfsMountFn
end
local function mountReadable(dir)
local fn = resolveMount()
if not fn then return false end
return fn(dir)
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)
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)
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)
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)
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)
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
return CacheFs
+6 -6
View File
@@ -126,14 +126,14 @@ function ImageWriter.columnsToRows(raw, tilesWide, tilesHigh, bytesPerTile)
end end
function ImageWriter.save(image, path) function ImageWriter.save(image, path)
local parent = path:match("^(.*)/[^/]+$")
if parent then
local ok, err = love.filesystem.createDirectory(parent)
if not ok then error("could not create " .. parent .. ": " .. tostring(err)) end
end
local ok, fileData = pcall(image.encode, image, "png") local ok, fileData = pcall(image.encode, image, "png")
if not ok then error("could not encode " .. path .. ": " .. tostring(fileData)) end if not ok then error("could not encode " .. path .. ": " .. tostring(fileData)) end
local written, writeError = love.filesystem.write(path, fileData) -- CacheFs routes this to the OS save directory (normal builds) or straight
-- into the game folder (portable installs), creating parent directories as
-- needed. io.* needs the bytes as a string; love.filesystem would also
-- take the FileData, but getString() keeps one code path.
local CacheFs = require("src.import.CacheFs")
local written, writeError = CacheFs.write(path, fileData:getString())
if not written then if not written then
error("could not write " .. path .. ": " .. tostring(writeError)) error("could not write " .. path .. ": " .. tostring(writeError))
end end
+5 -6
View File
@@ -87,12 +87,11 @@ function LuaWriter.encode(value)
end end
function LuaWriter.write(path, value) function LuaWriter.write(path, value)
local parent = path:match("^(.*)/[^/]+$") -- CacheFs routes this to the OS save directory (normal builds) or straight
if parent then -- into the game folder (portable installs); it also creates the parent
local ok, err = love.filesystem.createDirectory(parent) -- directories. See src/import/CacheFs.lua.
if not ok then error("could not create " .. parent .. ": " .. tostring(err)) end local CacheFs = require("src.import.CacheFs")
end local ok, err = CacheFs.write(path, LuaWriter.encode(value))
local ok, err = love.filesystem.write(path, LuaWriter.encode(value))
if not ok then error("could not write " .. path .. ": " .. tostring(err)) end if not ok then error("could not write " .. path .. ": " .. tostring(err)) end
end end
+46 -7
View File
@@ -1253,14 +1253,24 @@ function RomExtractor:extractTrainers()
"battle/trainers/" .. picture.imageBase .. ".png") "battle/trainers/" .. picture.imageBase .. ".png")
written[picture.imageBase] = true written[picture.imageBase] = true
end 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] = { out[trainerId] = {
id = trainerId, index = index, name = decodedNames[index], id = trainerId, index = index, name = decodedNames[index],
source = "ROM:TrainerDataPointers", source = "ROM:TrainerDataPointers",
pic = picture and picture.path or nil, pic = picture and picture.path or nil,
baseMoney = math.floor(Rom.bcd(rawMoney) / 100), baseMoney = math.floor(Rom.bcd(rawMoney) / 100),
aiMods = aiMods[index], aiMods = aiMods[index],
parties = self:trainerParties( parties = parties,
pointers.bank, partyStarts[index], partyEnds[index]),
} }
self:tick("Trainers", index, #order) self:tick("Trainers", index, #order)
end end
@@ -1426,7 +1436,7 @@ end
function RomExtractor:extractField() function RomExtractor:extractField()
self:beginStage("Interface artwork") self:beginStage("Interface artwork")
local done, total = 0, 48 local done, total = 0, 49
local function tick() local function tick()
done = done + 1 done = done + 1
self:tick("Interface artwork", math.min(done, total), total) self:tick("Interface artwork", math.min(done, total), total)
@@ -1576,6 +1586,34 @@ function RomExtractor:extractField()
"fx/" .. spec[4], { transparent = true }) "fx/" .. spec[4], { transparent = true })
tick() tick()
end 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, self:raw2bpp("BattleTransitionTile", 8, 8,
"fx/battle_transition.png"); tick() "fx/battle_transition.png"); tick()
self:raw2bpp("PokedexTileGraphics", 24, 48, self:raw2bpp("PokedexTileGraphics", 24, 48,
@@ -1631,10 +1669,11 @@ function RomExtractor:extractAudio()
chunks[index] = self.rom.data:sub(first, first + 0x3FFF) chunks[index] = self.rom.data:sub(first, first + 0x3FFF)
self:tick("Sound programs", index, #bankOrder + 2) self:tick("Sound programs", index, #bankOrder + 2)
end end
local ok, writeError = love.filesystem.createDirectory( -- CacheFs (not love.filesystem directly) so a portable install lands this
"assets/generated/audio") -- in the game folder with the rest of the cache; it creates the parent
if ok == false then error("could not create audio cache: " .. tostring(writeError)) end -- directory too.
ok, writeError = love.filesystem.write( local CacheFs = require("src.import.CacheFs")
local ok, writeError = CacheFs.write(
"assets/generated/audio/programs.bin", table.concat(chunks)) "assets/generated/audio/programs.bin", table.concat(chunks))
if not ok then error("could not write audio programs: " .. tostring(writeError)) end if not ok then error("could not write audio programs: " .. tostring(writeError)) end
+144 -13
View File
@@ -2,7 +2,7 @@ local RomImporter = {}
RomImporter.__index = RomImporter RomImporter.__index = RomImporter
local ROM_SHA1 = "ea9bcae617fdf159b045185467ae58b2e4a48b9a" local ROM_SHA1 = "ea9bcae617fdf159b045185467ae58b2e4a48b9a"
local CACHE_MARKER = "rom-cache-v5:" .. ROM_SHA1 local CACHE_MARKER = "rom-cache-v7:" .. ROM_SHA1
local MARKER_PATH = "rom-cache.complete" local MARKER_PATH = "rom-cache.complete"
local COMMUNITY_URL = "https://bois.icu" local COMMUNITY_URL = "https://bois.icu"
local TRUST_WARNING = "if you did not get this from bryanthaboi's github " .. local TRUST_WARNING = "if you did not get this from bryanthaboi's github " ..
@@ -24,8 +24,11 @@ local REQUIRED_FILES = {
} }
local function allRequiredFilesExist() local function allRequiredFilesExist()
-- CacheFs.exists checks the game folder directly for a portable install,
-- otherwise the save directory through love.filesystem.
local CacheFs = require("src.import.CacheFs")
for _, path in ipairs(REQUIRED_FILES) do for _, path in ipairs(REQUIRED_FILES) do
if not love.filesystem.getInfo(path, "file") then return false end if not CacheFs.exists(path) then return false end
end end
return true return true
end end
@@ -38,12 +41,21 @@ local function sourceTreeHasData()
return real == love.filesystem.getSource() return real == love.filesystem.getSource()
end end
function RomImporter.isReady() -- ------- ROM cache location
if sourceTreeHasData() then return true end --
return love.filesystem.read(MARKER_PATH) == CACHE_MARKER -- The extracted cache (data/generated, assets/generated) plus the
and allRequiredFilesExist() -- rom-cache.complete marker normally live in LÖVE's per-user OS save
end -- directory. A portable install instead keeps them in the game folder next
-- to the executable (the folder holding portable.txt), so nothing is left on
-- the host machine. Every cache write/read/remove goes through CacheFs,
-- which writes that folder with io.* and makes it readable (mounting it via
-- PhysFS for a fused build) -- there is no mirror step and no per-file
-- os.execute (issue #74: that flashed a console window per file on Windows
-- and froze the app).
-- Remove a cache subtree from the OS save directory. The realDirectory
-- guard keeps this from ever deleting the game folder (portable installs
-- read the cache from there) or a developer's checked-out source tree.
local function removeTree(path) local function removeTree(path)
local info = love.filesystem.getInfo(path) local info = love.filesystem.getInfo(path)
if not info then return end if not info then return end
@@ -63,6 +75,48 @@ local function removeTree(path)
end end
end end
-- Portable installs read the cache from the game folder. Any copy an
-- earlier non-portable run -- or the pre-#74 build, which always wrote the
-- cache to the save directory and only mirrored it out -- left behind would
-- shadow it, because physfs searches the save directory before the source.
-- Clear it out once, and only when a remnant is actually present so a clean
-- install pays nothing.
local saveDirPurged = false
local function purgeSaveDirCache()
if saveDirPurged then return end
saveDirPurged = true
local saveDir = love.filesystem.getSaveDirectory()
local function saveDirHas(rel)
local f = io.open(saveDir .. "/" .. rel, "rb")
if not f then return false end
f:close()
return true
end
if not (saveDirHas(MARKER_PATH) or saveDirHas(REQUIRED_FILES[1])) then
return
end
removeTree("data/generated")
removeTree("assets/generated")
love.filesystem.remove(MARKER_PATH)
end
function RomImporter.isReady()
local CacheFs = require("src.import.CacheFs")
if CacheFs.root() then
-- Portable: the cache lives in the game folder next to the executable
-- (mounted onto the read path for a fused build). Drop any stale
-- save-directory copy that would otherwise shadow it at runtime -- and,
-- for a source run, hide the game folder from sourceTreeHasData below.
purgeSaveDirCache()
end
-- Generated data sitting in the physfs source -- a developer checkout, a
-- Python/bootstrap build, or a source-run portable import -- is always
-- current (as it has always been). A fused portable install is not the
-- source, so it falls through to the version-marker gate.
if sourceTreeHasData() then return true end
return CacheFs.read(MARKER_PATH) == CACHE_MARKER and allRequiredFilesExist()
end
local function decodeManifest() local function decodeManifest()
local raw, readError = love.filesystem.read("tools/rom_manifest.json") local raw, readError = love.filesystem.read("tools/rom_manifest.json")
if not raw then error("ROM import metadata is missing: " .. tostring(readError)) end if not raw then error("ROM import metadata is missing: " .. tostring(readError)) end
@@ -110,6 +164,22 @@ local function commandOutput(command)
return result ~= "" and result or nil return result ~= "" and result or nil
end end
-- LOVE 11.5 on Android has no native file picker (love.window.showFileDialog
-- is a LOVE 12 nightly-only addition) and never fires love.filedropped, so
-- neither desktop path below works there. conf.lua points the Android save
-- directory at the app's external-files folder instead (readable/writable
-- via USB or a file manager, no runtime permission needed), and this scans
-- it directly through love.filesystem -- already mounted at the physfs
-- root, so no io.* absolute-path handling is needed.
local function scanForRom()
for _, name in ipairs(love.filesystem.getDirectoryItems("")) do
if name:lower():match("%.gb$") and love.filesystem.getInfo(name, "file") then
return name
end
end
return nil
end
local function chooseRom() local function chooseRom()
local platform = love.system.getOS() local platform = love.system.getOS()
if platform == "OS X" then if platform == "OS X" then
@@ -136,19 +206,52 @@ local function chooseRom()
end end
function RomImporter.new(onComplete) function RomImporter.new(onComplete)
return setmetatable({ local previousMarker = require("src.import.CacheFs").read(MARKER_PATH)
local returning = previousMarker ~= nil and previousMarker ~= CACHE_MARKER
local android = love.system.getOS() == "Android"
local self = setmetatable({
onComplete = onComplete, onComplete = onComplete,
logo = love.graphics.newImage("assets/logo/logo.png"), logo = love.graphics.newImage("assets/logo/logo.png"),
bcg = love.graphics.newImage("assets/logo/bcg.png"), bcg = love.graphics.newImage("assets/logo/bcg.png"),
state = "waiting", state = "waiting",
status = "Choose or drop a Pokemon Red ROM", returning = returning,
detail = "The ROM is verified before any files are created.", android = android,
status = returning and "More assets are needed from your ROM"
or "Choose or drop a Pokemon Red ROM",
detail = returning
and "This update pulls a few more things from your ROM. "
.. "Please re-import it to continue (it's quick)."
or "The ROM is verified before any files are created.",
progress = 0, progress = 0,
stageCurrent = 0, stageCurrent = 0,
stageTotal = 1, stageTotal = 1,
pulse = 0, pulse = 0,
button = {}, button = {},
}, RomImporter) }, RomImporter)
if android then
self.status = returning and "More ROM assets needed" or "Get your Pokemon Red ROM (.gb) in"
self.detail = "Tap Choose ROM to pick your file"
local name = scanForRom()
if name then
self:startData(love.filesystem.read(name), name)
end
end
return self
end
-- The system picker runs as a separate top activity, so LOVE's own
-- love.focus/love.visible pause while it's up (see main.lua) -- once the
-- player returns here with a file picked, GameActivity has already copied
-- it into the folder scanForRom checks, so a rescan on refocus picks it up
-- without the player needing to tap the button again.
function RomImporter:focus(f)
if not (f and self.android and self.state ~= "working") then return end
local name = scanForRom()
if name then
self:startData(love.filesystem.read(name), name)
end
end end
function RomImporter:setError(message) function RomImporter:setError(message)
@@ -185,9 +288,15 @@ function RomImporter:startData(data, displayName)
end end
self.status = "Preparing private game data" self.status = "Preparing private game data"
coroutine.yield() coroutine.yield()
-- Clear any previous cache from both possible homes: the save directory
-- (removeTree) and, for a portable install, the game folder (CacheFs).
local CacheFs = require("src.import.CacheFs")
removeTree("data/generated") removeTree("data/generated")
removeTree("assets/generated") removeTree("assets/generated")
love.filesystem.remove(MARKER_PATH) love.filesystem.remove(MARKER_PATH)
CacheFs.removeTree("data/generated")
CacheFs.removeTree("assets/generated")
CacheFs.remove(MARKER_PATH)
local manifest = decodeManifest() local manifest = decodeManifest()
local RomExtractor = require("src.import.RomExtractor") local RomExtractor = require("src.import.RomExtractor")
@@ -202,7 +311,11 @@ function RomImporter:startData(data, displayName)
extractor:run() extractor:run()
self.romData = nil self.romData = nil
collectgarbage("collect") collectgarbage("collect")
local ok, writeError = love.filesystem.write(MARKER_PATH, CACHE_MARKER) -- Written last: the marker is what isReady() checks, so it must only
-- appear once every required file is in place. CacheFs puts it beside
-- the cache -- the game folder for a portable install, else the save
-- directory.
local ok, writeError = CacheFs.write(MARKER_PATH, CACHE_MARKER)
if not ok then error("could not finish the private cache: " .. tostring(writeError)) end if not ok then error("could not finish the private cache: " .. tostring(writeError)) end
self.state = "complete" self.state = "complete"
self.status = "Ready" self.status = "Ready"
@@ -234,6 +347,22 @@ end
function RomImporter:choose() function RomImporter:choose()
if self.state == "working" then return end if self.state == "working" then return end
if self.android then
local name = scanForRom()
if name then
self:startData(love.filesystem.read(name), name)
elseif not love.system.pickFile() then
-- Picker unavailable (API < 19, or no document-picker app installed):
-- fall back to the USB folder-drop path. Not setError(): that status
-- text ("could not be imported") reads as a rejected file, not "none
-- found yet" -- and detail only renders 3 wrapped lines, so the path
-- again gets the line to itself.
self.state = "waiting"
self.status = "No picker available, copy your ROM into:"
self.detail = love.filesystem.getSaveDirectory()
end
return
end
local path = chooseRom() local path = chooseRom()
if path then if path then
self:startPath(path) self:startPath(path)
@@ -304,7 +433,8 @@ function RomImporter:draw()
height * 0.075, height * 0.075,
0, logoScale, logoScale) 0, logoScale, logoScale)
setColor255(74, 88, 72) setColor255(74, 88, 72)
printCentered("FIRST RUN", height * 0.205, smallFont, width) printCentered(self.returning and "UPDATE REQUIRED" or "FIRST RUN",
height * 0.205, smallFont, width)
local zoneY, zoneH = height * 0.29, math.min(180, height * 0.31) local zoneY, zoneH = height * 0.29, math.min(180, height * 0.31)
setColor255(215, 220, 202) setColor255(215, 220, 202)
@@ -347,7 +477,8 @@ function RomImporter:draw()
buttonWidth, "center") buttonWidth, "center")
setColor255(74, 88, 72) setColor255(74, 88, 72)
love.graphics.setFont(smallFont) love.graphics.setFont(smallFont)
love.graphics.printf("or drop the .gb file here", love.graphics.printf(
self.android and "or copy the .gb via USB" or "or drop the .gb file here",
0, buttonY + buttonHeight + 12, width, "center") 0, buttonY + buttonHeight + 12, width, "center")
end end
+4
View File
@@ -11,6 +11,10 @@ local function isBadge(id)
return id:find("BADGE", 1, true) ~= nil return id:find("BADGE", 1, true) ~= nil
end 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) function Bag.slots(save)
local n = 0 local n = 0
for id in pairs(save.inventory) do for id in pairs(save.inventory) do
+3 -1
View File
@@ -129,7 +129,9 @@ end
-- tripwire on for tests that cannot set the environment. -- tripwire on for tests that cannot set the environment.
function Loader.new(opts) function Loader.new(opts)
local dev = opts and opts.dev 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({ local self = setmetatable({
mods = {}, loaded = {}, errors = {}, initialized = false, mods = {}, loaded = {}, errors = {}, initialized = false,
events = Events.new(), hooks = Hooks.new(), content = {}, assets = {}, events = Events.new(), hooks = Hooks.new(), content = {}, assets = {},
+6 -1
View File
@@ -9,6 +9,7 @@
-- "trade" | "manual" | <mod>, item = id?, ... }), wrapped by the -- "trade" | "manual" | <mod>, item = id?, ... }), wrapped by the
-- evolution.check hook so a mod can cancel or force any evolution. -- 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 Runtime = require("src.mods.Runtime")
local Screens = require("src.ui.Screens") local Screens = require("src.ui.Screens")
local Stats = require("src.pokemon.Stats") 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) Screens.push(game, "EvolutionState", mon, newSpecies, onDone)
return return
end end
Music.play(game.data, Music.special(game.data, "evolution"))
local oldName = mon.nickname or game.data.pokemon[mon.species].name local oldName = mon.nickname or game.data.pokemon[mon.species].name
Evolution.apply(game, mon, newSpecies, via) Evolution.apply(game, mon, newSpecies, via)
local msg = ("What?\n%s is\nevolving!\fCongratulations!\nYour %s\nevolved into\n%s!") local msg = ("What?\n%s is\nevolving!\fCongratulations!\nYour %s\nevolved into\n%s!")
:format(oldName, oldName, game.data.pokemon[newSpecies].name) :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 end
-- Entry point for mods whose methods fire outside the vanilla moments -- 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). -- keeping the most recent four (engine/pokemon/learn_move.asm behavior).
function Pokemon.movesAtLevel(speciesDef, level) function Pokemon.movesAtLevel(speciesDef, level)
local moves = {} 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 for _, m in ipairs(speciesDef.level1Moves) do
table.insert(moves, m) add(m)
end end
for _, entry in ipairs(speciesDef.learnset) do for _, entry in ipairs(speciesDef.learnset) do
if entry.level <= level then if entry.level <= level then
table.insert(moves, entry.move) add(entry.move)
end end
end end
while #moves > 4 do while #moves > 4 do
+77
View File
@@ -141,6 +141,54 @@ function PaletteFX.usesGbcPack(mode)
return mode == "redpp" return mode == "redpp"
end end
-- Per-object overworld sprite coloring (ColorOverworldSprite) applies in
-- plain GBC mode too, not only under the RED++ pack: without it the
-- whole-map zone shader paints characters with whatever two mid shades
-- the terrain palette defines. RED++ handles sprites through the baked
-- usesGbcPack() path in SpriteRenderer; this names the modes where the
-- OBP bake plus the post-zone redraw (below) stand in for real OBJ
-- palettes over a shader-colorized background.
function PaletteFX.usesSpriteObp(mode)
mode = mode or PaletteFX.mode
return mode == "gbc"
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, -- Active named-palette table for COLORS: RED++ uses data/palettes_gbc.lua,
-- everything else uses the ROM-imported data.palettes. -- everything else uses the ROM-imported data.palettes.
function PaletteFX.pack(data) function PaletteFX.pack(data)
@@ -223,6 +271,29 @@ local TILE_GROUP_EXCEPTIONS = {
CELADON_MART_1F = { tiles = { [0x07] = true, [0x08] = true, CELADON_MART_1F = { tiles = { [0x07] = true, [0x08] = true,
[0x17] = true, [0x18] = true }, group = 4 }, [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 roof 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.
PaletteFX.TILE_ALIASES = {
CELADON_MART_ROOF = {
{ block = 29, cells = { [5] = true, [6] = true, [9] = true, [10] = true },
tile = 0x37, alias = 0x5a, group = 5 },
},
}
local ROOF_GROUP = 6 local ROOF_GROUP = 6
local ROUTE_6_SAFFRON = { mapId = "ROUTE_6", useMapId = "SAFFRON_CITY", cellYBelow = 2 } local ROUTE_6_SAFFRON = { mapId = "ROUTE_6", useMapId = "SAFFRON_CITY", cellYBelow = 2 }
@@ -244,6 +315,8 @@ function PaletteFX.worldGroupAt(tileset, mapId, tileId)
if not groups then return nil end if not groups then return nil end
local exc = TILE_GROUP_EXCEPTIONS[mapId] local exc = TILE_GROUP_EXCEPTIONS[mapId]
if exc and exc.tiles[tileId] then return exc.group end 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) return groups[tileId] or 7 -- TEXT: tile ids past the tileset's 96 (menus)
end end
@@ -293,6 +366,10 @@ function PaletteFX.spriteObp(spriteDef, seed)
local src = spriteDef and spriteDef.source local src = spriteDef and spriteDef.source
if not (w and src) then return nil end if not (w and src) then return nil end
local idx = tonumber(src:match("%[(%d+)%]")) 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] local group = idx and w.spriteAssignment[idx]
if group == nil then return nil end if group == nil then return nil end
if group == "random" then if group == "random" then
+32 -1
View File
@@ -62,8 +62,10 @@ end
function Renderer:beginFrame(transparent) function Renderer:beginFrame(transparent)
self.worldActive = false self.worldActive = false
self.uprightActive = false self.uprightActive = false
-- last frame's trueColor rects go before anything draws this one -- last frame's trueColor rects and sprite redraws go before anything
-- draws this one
PaletteFX.clearTrueColor() PaletteFX.clearTrueColor()
PaletteFX.clearSpriteRedraws()
PaletteFX.setPass("ui") PaletteFX.setPass("ui")
love.graphics.setCanvas(self.canvas) love.graphics.setCanvas(self.canvas)
if transparent then if transparent then
@@ -363,6 +365,35 @@ function Renderer:endFrame(zones, worldZones)
else else
blit(self.worldCanvas, s, zones, S, wox, woy, 0, 0, ww, wh) blit(self.worldCanvas, s, zones, S, wox, woy, 0, 0, ww, wh)
end 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 end
-- Composite the tilt upright pass over the ground (projected or, in the -- Composite the tilt upright pass over the ground (projected or, in the
-- rare no-shader fallback, flat). It already carries its billboards' -- rare no-shader fallback, flat). It already carries its billboards'
+22 -6
View File
@@ -85,10 +85,21 @@ end
-- facing: down/up/left/right; walkPhase: 0 stand, 1 walk; flip: alternate -- facing: down/up/left/right; walkPhase: 0 stand, 1 walk; flip: alternate
-- steps mirror the walk frame for up/down (GB uses OAM flip for this). -- steps mirror the walk frame for up/down (GB uses OAM flip for this).
local function blitFrame(image, quad, x, y, flip, redraw)
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) function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip)
local x = math.floor(px - camX) local x = math.floor(px - camX)
local y = math.floor(py - camY) - 4 local y = math.floor(py - camY) - 4
local image = self.image local image = self.image
local redraw = false
-- full-color art claims its 16x16 cell out of the shade-remap pass -- full-color art claims its 16x16 cell out of the shade-remap pass
if self.def.trueColor then if self.def.trueColor then
PaletteFX.markTrueColor(x, y, 16, 16) PaletteFX.markTrueColor(x, y, 16, 16)
@@ -102,12 +113,21 @@ function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip)
if colors then if colors then
image = getObpImage(self.def.image, colors, group) image = getObpImage(self.def.image, colors, group)
end end
elseif PaletteFX.usesSpriteObp() and PaletteFX.spriteRedrawPassActive() then
-- plain GBC: the terrain zone shader still runs over the world canvas,
-- so the baked sprite is also queued for a post-zone redraw
-- (PaletteFX.markSpriteRedraw) that restores its own OBP colors on top
local colors, group = PaletteFX.spriteObp(self.def, self.seed)
if colors then
image = getObpImage(self.def.image, colors, group)
redraw = true
end
end end
-- single-frame sprites (item balls, fossils...) have one fixed pose; -- single-frame sprites (item balls, fossils...) have one fixed pose;
-- still 3-frame sprites turn to face (the nurse at her machine, -- still 3-frame sprites turn to face (the nurse at her machine,
-- facePlayer on STAY NPCs) but never show walk frames -- facePlayer on STAY NPCs) but never show walk frames
if self.def.frames <= 1 then 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 return
end end
local frame = (self.def.walker and walkPhase == 1) local frame = (self.def.walker and walkPhase == 1)
@@ -119,11 +139,7 @@ function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip)
flip = true flip = true
end end
local quad = self.frames[frame] or self.frames[0] local quad = self.frames[frame] or self.frames[0]
if flip then blitFrame(image, quad, x, y, flip, redraw)
love.graphics.draw(image, quad, x + 16, y, 0, -1, 1)
else
love.graphics.draw(image, quad, x, y)
end
end end
return SpriteRenderer return SpriteRenderer
+63 -3
View File
@@ -347,9 +347,10 @@ local function getGbcAtlas(imagePath, tilesetId, mapId, perRow, data)
if groupColors then if groupColors then
local src = Assets.imageData(imagePath) local src = Assets.imageData(imagePath)
local iw, ih = src:getDimensions() local iw, ih = src:getDimensions()
local total = (iw / 8) * (ih / 8)
local out = love.image.newImageData(iw, ih) local out = love.image.newImageData(iw, ih)
local tileColors = {} local tileColors = {}
for t = 0, (iw / 8) * (ih / 8) - 1 do for t = 0, total - 1 do
local colors = tileColors[t] local colors = tileColors[t]
if colors == nil then if colors == nil then
local group = PaletteFX.worldGroupAt(tilesetId, mapId, t) local group = PaletteFX.worldGroupAt(tilesetId, mapId, t)
@@ -366,6 +367,25 @@ local function getGbcAtlas(imagePath, tilesetId, mapId, perRow, data)
end end
end 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) img = love.graphics.newImage(out)
end end
end end
@@ -429,14 +449,38 @@ function TileRenderer.new(map, data)
end 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
for by = -BORDER_BLOCKS, hB + BORDER_BLOCKS - 1 do for by = -BORDER_BLOCKS, hB + BORDER_BLOCKS - 1 do
for bx = -BORDER_BLOCKS, wB + 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 inside = bx >= 0 and by >= 0 and bx < wB and by < hB
local batch = inside and self.mapBatch or self.ringBatch local batch = inside and self.mapBatch or self.ringBatch
local block = map.tileset.blocks[map:blockAt(bx, by) + 1] -- beyond-edge ring cells use the same override drawBorderFill does,
-- so the ring and the far background fill agree (OVERWORLD maps
-- whose raw border_block is water still ring with the tree wall)
local blockId = inside and map:blockAt(bx, by) or borderBlockFor(map)
local block = map.tileset.blocks[blockId + 1]
if not block then
-- a tileset without the tree-wall block keeps its own border
blockId = map:blockAt(bx, by)
block = map.tileset.blocks[blockId + 1]
end
local remap = aliasMap and aliasMap[blockId]
for ty = 0, 3 do for ty = 0, 3 do
for tx = 0, 3 do for tx = 0, 3 do
local tile = block[ty * 4 + tx + 1] local ci = ty * 4 + tx
local tile = block[ci + 1]
if remap and remap[ci] then tile = remap[ci] end
local quad = self.quads[tile] local quad = self.quads[tile]
if quad then if quad then
batch:add(quad, bx * 32 + tx * 8, by * 32 + ty * 8) batch:add(quad, bx * 32 + tx * 8, by * 32 + ty * 8)
@@ -556,6 +600,22 @@ function TileRenderer:drawCellBottom(cx, cy, camX, camY)
if shader then love.graphics.setShader() end if shader then love.graphics.setShader() end
end end
-- 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
-- animated overdraw at the current step; bodyOnly skips the ring -- animated overdraw at the current step; bodyOnly skips the ring
-- positions (connected maps draw body-only) -- positions (connected maps draw body-only)
function TileRenderer:drawAnimated(camX, camY, bodyOnly) function TileRenderer:drawAnimated(camX, camY, bodyOnly)
+22 -5
View File
@@ -5,6 +5,7 @@
-- Conditionals: check_flag stores its result in ctx.lastCheck; -- Conditionals: check_flag stores its result in ctx.lastCheck;
-- jump_if_true/jump_if_false jump to an absolute row index. -- 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 Flags = require("src.script.Flags")
local Logger = require("src.core.Logger") local Logger = require("src.core.Logger")
local Screens = require("src.ui.Screens") local Screens = require("src.ui.Screens")
@@ -269,7 +270,10 @@ end
local DIRS4 = { { 0, -1, "up" }, { 0, 1, "down" }, local DIRS4 = { { 0, -1, "up" }, { 0, 1, "down" },
{ -1, 0, "left" }, { 1, 0, "right" } } { -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 key = function(x, y) return y * 1000 + x end
local prev = { [key(sx, sy)] = false } local prev = { [key(sx, sy)] = false }
local queue = { { sx, sy } } local queue = { { sx, sy } }
@@ -289,8 +293,11 @@ local function bfsPath(map, sx, sy, tx, ty)
for _, d in ipairs(DIRS4) do for _, d in ipairs(DIRS4) do
local nx, ny = cx + d[1], cy + d[2] local nx, ny = cx + d[1], cy + d[2]
local nk = key(nx, ny) local nk = key(nx, ny)
local isTarget = nx == tx and ny == ty
if prev[nk] == nil and map:inBounds(nx, ny) 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] } prev[nk] = { cx, cy, d[3] }
table.insert(queue, { nx, ny }) table.insert(queue, { nx, ny })
end end
@@ -303,7 +310,7 @@ function Commands.move_npc_to(ctx, objIndex, tx, ty)
local ow = ctx.overworld local ow = ctx.overworld
local npc = ow:npcByIndex(objIndex) local npc = ow:npcByIndex(objIndex)
if not npc then return end if not npc then return end
local path = bfsPath(ow.map, npc.cellX, npc.cellY, tx, ty) local path = bfsPath(ow.map, npc.cellX, npc.cellY, tx, ty, ow.entities, npc)
if not path then if not path then
Logger.warn("move_npc_to: no path to (%d,%d)", tx, ty) Logger.warn("move_npc_to: no path to (%d,%d)", tx, ty)
return return
@@ -390,6 +397,7 @@ local function toggleObject(ctx, mapId, objName, visible)
-- positions mid-cutscene) -- positions mid-cutscene)
local ow = ctx.overworld local ow = ctx.overworld
if not ow or ow.map.id ~= mapId then return end if not ow or ow.map.id ~= mapId then return end
ow.npcPool = ow.npcPool or {}
if visible then if visible then
for _, n in ipairs(ow.npcs) do for _, n in ipairs(ow.npcs) do
if n.def.name == objName then return end 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) local npc = NPC.new(ctx.game.data, mapId, obj)
table.insert(ow.npcs, npc) table.insert(ow.npcs, npc)
table.insert(ow.entities, npc) table.insert(ow.entities, npc)
ow.npcPool[npc.id] = npc
return return
end end
end end
else else
for i = #ow.npcs, 1, -1 do 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 end
for i = #ow.entities, 1, -1 do for i = #ow.entities, 1, -1 do
local e = ow.entities[i] local e = ow.entities[i]
@@ -823,7 +835,12 @@ end
-- registry and block until the screen pops itself -- registry and block until the screen pops itself
function Commands.push_screen(ctx, screenId, args) function Commands.push_screen(ctx, screenId, args)
local screens = ctx.game.data.screens 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) error(("push_screen: unknown screen '%s'"):format(tostring(screenId)), 0)
end end
local runner = ctx.runner local runner = ctx.runner
+18 -3
View File
@@ -296,9 +296,7 @@ local function useOn(game, battle, id, target, list, moveIndex)
showMessages(game, payload) -- failed showMessages(game, payload) -- failed
end end
local function useItem(game, battle, id, list) local function pickTargetAndUse(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 -- pick a target from the party
-- the ETHERs and PP UP open the move menu after picking a mon -- the ETHERs and PP UP open the move menu after picking a mon
-- (ItemUsePPRestore / ItemUsePPUp); the ELIXERs hit every move -- (ItemUsePPRestore / ItemUsePPUp); the ELIXERs hit every move
@@ -327,6 +325,23 @@ local function useItem(game, battle, id, list)
})) }))
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
-- 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 else
useOn(game, battle, id, nil, list) useOn(game, battle, id, nil, list)
end end
+4 -2
View File
@@ -1,11 +1,12 @@
-- Rebinding over the logical Game Boy buttons (gap C2's file-12 half, -- 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" -- 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 -- capture and the captured key or pad button lands in
-- save.options.bindings -- the overlay src/core/Bindings.lua -- save.options.bindings, which Input:applyBindings layers over its fixed
-- (04-mod-api-core) reads back over Input's fixed map. -- default map (see src/core/Input.lua and Game:applyOptions).
local Font = require("src.render.Font") local Font = require("src.render.Font")
local ListMenu = require("src.ui.ListMenu") local ListMenu = require("src.ui.ListMenu")
local Input = require("src.core.Input")
local BindingsMenu = setmetatable({}, { __index = ListMenu }) local BindingsMenu = setmetatable({}, { __index = ListMenu })
BindingsMenu.__index = BindingsMenu BindingsMenu.__index = BindingsMenu
@@ -78,6 +79,7 @@ function BindingsMenu:storeBinding(slot, value)
b[slot] = value b[slot] = value
opts.bindings[item.button.id] = b opts.bindings[item.button.id] = b
item.right = boundKey(opts.bindings, item.button):upper() item.right = boundKey(opts.bindings, item.button):upper()
Input:applyBindings(opts.bindings)
if game.writeOptions then game:writeOptions() end if game.writeOptions then game:writeOptions() end
end end
+3
View File
@@ -5,6 +5,7 @@
-- has no cancel; the flash always completes). -- has no cancel; the flash always completes).
local Font = require("src.render.Font") local Font = require("src.render.Font")
local Music = require("src.core.Music")
local EvolutionState = {} local EvolutionState = {}
EvolutionState.__index = EvolutionState EvolutionState.__index = EvolutionState
@@ -39,6 +40,7 @@ function EvolutionState.new(game, mon, newSpecies, onDone)
self.newSprite = frontSprite(game, newSpecies) self.newSprite = frontSprite(game, newSpecies)
self.t = 0 self.t = 0
self.done = false self.done = false
Music.play(game.data, Music.special(game.data, "evolution"))
return self return self
end end
@@ -57,6 +59,7 @@ function EvolutionState:update(dt)
("Congratulations!\nYour %s\nevolved into\n%s!") ("Congratulations!\nYour %s\nevolved into\n%s!")
:format(self.oldName, newName), :format(self.oldName, newName),
function() function()
Music.restoreMap(game.data)
game.stack:pop() -- the evolution screen itself game.stack:pop() -- the evolution screen itself
if self.onDone then self.onDone() end if self.onDone then self.onDone() end
end)) end))
+53 -3
View File
@@ -16,6 +16,11 @@ local OakSpeech = {}
OakSpeech.__index = OakSpeech OakSpeech.__index = OakSpeech
OakSpeech.isOpaque = true 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 -- naming presets are boot config (field.boot.namePresets), which a total
-- conversion replaces; the Red/Blue lists remain the fallback -- conversion replaces; the Red/Blue lists remain the fallback
local function namePresets(game, who, fallback) local function namePresets(game, who, fallback)
@@ -93,16 +98,23 @@ function OakSpeech:say(key, next)
end end
local STEPS = { local STEPS = {
-- 1. Oak's welcome -- 1. Oak's welcome (Oak's pic fades in first, FadeInIntroPic)
function(self) function(self)
self.pic = self.oakPic self.pic = self.oakPic
self:revealPic("fade", function()
self:say("_OakSpeechText1", function() self:advance() end) self:say("_OakSpeechText1", function() self:advance() end)
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) function(self)
self.pic = self.demoPic self.pic = self.demoPic
self.picFlip = true
self:revealPic("wipe", function()
Sound.playCry(self.game.data, self.demoSpecies) Sound.playCry(self.game.data, self.demoSpecies)
self:say("_OakSpeechText2A", function() self:advance() end) self:say("_OakSpeechText2A", function() self:advance() end)
end)
end, end,
-- 3. the rest of the world-of-POKéMON spiel -- 3. the rest of the world-of-POKéMON spiel
function(self) function(self)
@@ -175,8 +187,21 @@ function OakSpeech:lastPageLines(key)
return ok and lines or nil return ok and lines or nil
end 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() function OakSpeech:advance()
self.step = self.step + 1 self.step = self.step + 1
self.picFlip = false
local fn = STEPS[self.step] local fn = STEPS[self.step]
if fn then if fn then
fn(self) fn(self)
@@ -205,6 +230,15 @@ end
-- ClearScreenArea / wUpdateSpritesEnabled; ld c, 50) -- ClearScreenArea / wUpdateSpritesEnabled; ld c, 50)
-- frames 79-102 GBFadeOutToWhite (3 palettes x 8 frames) -- frames 79-102 GBFadeOutToWhite (3 palettes x 8 frames)
function OakSpeech:update(dt) 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 if not self.shrink then return end
local s = self.shrink local s = self.shrink
s.frame = s.frame + 1 s.frame = s.frame + 1
@@ -238,7 +272,23 @@ function OakSpeech:draw()
local w, h = self.pic:getDimensions() local w, h = self.pic:getDimensions()
local x = 48 + math.floor((8 - w / 8) / 2) * 8 local x = 48 + math.floor((8 - w / 8) / 2) * 8
local y = 32 + (7 - h / 8) * 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 end
if self.walkVisible and self.walkSheet then if self.walkVisible and self.walkSheet then
-- ResetPlayerSpriteData: Y screen pos $3c, X screen pos $40 -- ResetPlayerSpriteData: Y screen pos $3c, X screen pos $40
+7 -1
View File
@@ -99,7 +99,13 @@ end
local function deposit(game) local function deposit(game)
local pc = game.save.pcItems local pc = game.save.pcItems
local inv = game.save.inventory 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) onChoose = function(item, list)
askQuantity(game, list, inv[item.value] or 1, item.value, function(qty) askQuantity(game, list, inv[item.value] or 1, item.value, function(qty)
if pcFull(game, pc, item.value) then if pcFull(game, pc, item.value) then
+19
View File
@@ -4,6 +4,7 @@
-- item list runs through the ui.start_menu.items hook before the menu -- item list runs through the ui.start_menu.items hook before the menu
-- opens, so mods insert or remove rows without patching this file. -- opens, so mods insert or remove rows without patching this file.
local Font = require("src.render.Font")
local Logger = require("src.core.Logger") local Logger = require("src.core.Logger")
local Menu = require("src.ui.Menu") local Menu = require("src.ui.Menu")
local Runtime = require("src.mods.Runtime") local Runtime = require("src.mods.Runtime")
@@ -126,6 +127,24 @@ function StartMenu.new(game)
baseUpdate(self, dt) baseUpdate(self, dt)
game.save.startMenuIndex = self.index game.save.startMenuIndex = self.index
end 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 return menu
end end
+17
View File
@@ -24,6 +24,15 @@ local NO_SHORE_TILESETS = { SHIP_PORT = true }
-- what counts as "outside" for the wLastMap memory (CheckIfInOutsideMap) -- what counts as "outside" for the wLastMap memory (CheckIfInOutsideMap)
local OUTSIDE_TILESETS = { "OVERWORLD", "PLATEAU" } 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) local function hashSet(list, into)
for _, t in ipairs(list) do into[t] = true end for _, t in ipairs(list) do into[t] = true end
return into return into
@@ -199,6 +208,14 @@ function Map:isWarpTileCell(cx, cy)
return self.doorTiles[t] or self.warpTiles[t] or false return self.doorTiles[t] or self.warpTiles[t] or false
end 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) -- counter tiles allow talking to NPCs across them (mart clerks, nurses)
function Map:isCounterCell(cx, cy) function Map:isCounterCell(cx, cy)
local t = self:cellTile(cx, cy) local t = self:cellTile(cx, cy)
+310 -24
View File
@@ -40,6 +40,10 @@ local HEAL_BALL_XY = {
{ 40, 37 }, { 48, 37, true }, { 40, 37 }, { 48, 37, true },
} }
-- the healing machine's flash beat (FlashSprite8Times: rOBP1 ^= $28)
-- swaps the two middle shades of the monitor/ball art in place
local HEAL_FLASH_MAP = { [0] = 0, [1] = 2, [2] = 1, [3] = 3 }
-- object_event spawn filter (toggleable_objects, items taken, beaten -- object_event spawn filter (toggleable_objects, items taken, beaten
-- static encounters), shared by the current map's real NPCs and the -- static encounters), shared by the current map's real NPCs and the
-- visual-only ghosts on connected neighbor maps -- visual-only ghosts on connected neighbor maps
@@ -91,11 +95,25 @@ local NEIGHBOR_HOPS = 2
-- (32 px), the same alignment the connection macro encodes -- (32 px), the same alignment the connection macro encodes
-- (macros/scripts/maps.asm: _x = offset * -2 walk cells for -- (macros/scripts/maps.asm: _x = offset * -2 walk cells for
-- north/south, _y = offset * -2 for west/east). -- north/south, _y = offset * -2 for west/east).
function OverworldState.computeNeighbors(maps, rootId, hops) -- reachW/reachH (optional, world pixels): with a full zoom-out the view
-- shows far more world than the fixed hop count covers, so any map whose
-- body could overlap the current map's rect inflated by the view
-- half-extents joins the set (and keeps the walk going) regardless of how
-- many connections away it sits -- otherwise far map bodies pop between
-- real tiles and the border filler when a crossing re-roots the BFS.
function OverworldState.computeNeighbors(maps, rootId, hops, reachW, reachH)
local out = {} local out = {}
local rootDef = maps[rootId]
local placed = { [rootId] = true } local placed = { [rootId] = true }
local queue = { { def = maps[rootId], ox = 0, oy = 0, hops = 0 } } local queue = { { def = rootDef, ox = 0, oy = 0, hops = 0 } }
local qi = 1 local qi = 1
local function inReach(def, ox, oy)
if not (reachW and reachH and rootDef) then return false end
return ox + def.width * 32 > -reachW
and ox < rootDef.width * 32 + reachW
and oy + def.height * 32 > -reachH
and oy < rootDef.height * 32 + reachH
end
while queue[qi] do while queue[qi] do
local cur = queue[qi] local cur = queue[qi]
qi = qi + 1 qi = qi + 1
@@ -114,8 +132,9 @@ function OverworldState.computeNeighbors(maps, rootId, hops)
ox, oy = cur.def.width * 32, conn.offset * 32 ox, oy = cur.def.width * 32, conn.offset * 32
end end
ox, oy = cur.ox + ox, cur.oy + oy ox, oy = cur.ox + ox, cur.oy + oy
if cur.hops + 1 <= hops or inReach(destDef, ox, oy) then
table.insert(out, { id = conn.map, ox = ox, oy = oy }) table.insert(out, { id = conn.map, ox = ox, oy = oy })
if cur.hops + 1 < hops then if cur.hops + 1 < hops or inReach(destDef, ox, oy) then
table.insert(queue, table.insert(queue,
{ def = destDef, ox = ox, oy = oy, { def = destDef, ox = ox, oy = oy,
hops = cur.hops + 1 }) hops = cur.hops + 1 })
@@ -123,6 +142,7 @@ function OverworldState.computeNeighbors(maps, rootId, hops)
end end
end end
end end
end
return out return out
end end
@@ -200,6 +220,24 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
self.map.renderer:rebuild() self.map.renderer:rebuild()
self.cutBlocks[mapId] = nil self.cutBlocks[mapId] = nil
end end
-- Silph Co card key doors: the .blk layouts ship with the doorways
-- open; each floor's map script stamps the closed door block on load
-- until its unlock event is set (scripts/SilphCo2F.asm
-- SilphCo2FGateCallbackScript et al., closed blocks $54/$5f/$20)
local closedDoors = FieldDefaults.fieldValue(Game.data, "cardKeyDoors",
"closedDoors")
local floorDoors = closedDoors and closedDoors[mapId]
if floorDoors then
local stamped = false
for _, door in ipairs(floorDoors) do
local want = Game.save.flags[door.event] and door.open or door.block
if self.map:blockAt(door.bx, door.by) ~= want then
self.map:setBlock(door.bx, door.by, want)
stamped = true
end
end
if stamped then self.map.renderer:rebuild() end
end
-- forced dismount only where riding is disallowed (IsBikeRidingAllowed, -- forced dismount only where riding is disallowed (IsBikeRidingAllowed,
-- home/overworld.asm: bike_riding_tilesets.asm tilesets plus the -- home/overworld.asm: bike_riding_tilesets.asm tilesets plus the
-- ROUTE_23/INDIGO_PLATEAU map exceptions) -- ROUTE_23/INDIGO_PLATEAU map exceptions)
@@ -278,6 +316,13 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
self.player.surfing) self.player.surfing)
end end
-- forced bike/surf tiles fire the moment the player is placed on the
-- map, like EnterMap's unconditional CheckForceBikeOrSurf farcall
-- (home/overworld.asm) -- a warp can land directly on one (the Route
-- 16/18 gate exits), and the scripted door-mat walkout that follows
-- suppresses onStepComplete, so waiting for a plain step never mounts
self:checkForcedMovement()
-- snap the camera immediately: the overworld doesn't update while a -- snap the camera immediately: the overworld doesn't update while a
-- Transition is on top, so a stale camera would show the new map at -- Transition is on top, so a stale camera would show the new map at
-- the old scroll position for the whole fade-in -- the old scroll position for the whole fade-in
@@ -299,13 +344,29 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
hooks.onEnter(Game, self) hooks.onEnter(Game, self)
end end
-- neighbor maps drawn at the composed connection offsets, two hops self:rebuildNeighbors()
-- out (the GB only ever streamed a 32px strip of the single Logger.info("map: %s at (%d,%d)", mapId, x, y)
-- directly connected map -- home/overworld.asm .loadNewMap) -- Route22Gate_Script rewrites wLastMap from the player's Y on entry
-- too (not only on step), so a save/load mid-gate keeps exits correct
self:syncLastMapRewrite()
end
-- Neighbor maps drawn at the composed connection offsets: at least the
-- configured hop count out (the GB only ever streamed a 32px strip of
-- the single directly connected map -- home/overworld.asm .loadNewMap),
-- widened to everything the current view size can show so a full
-- zoom-out never runs past the rendered set. Re-run whenever the view
-- grows (zoom/resize), not only on setMap.
function OverworldState:rebuildNeighbors()
local mapId = self.map.id
self.neighbors = {} self.neighbors = {}
local hops = FieldDefaults.world(Game.data, "neighborHops") or NEIGHBOR_HOPS local hops = FieldDefaults.world(Game.data, "neighborHops") or NEIGHBOR_HOPS
for _, n in ipairs(OverworldState.computeNeighbors(Game.data.maps, local vw, vh = Game.renderer:worldViewSize()
mapId, hops)) do self.neighborViewW, self.neighborViewH = vw, vh
for _, n in ipairs(OverworldState.computeNeighbors(Game.data.maps, mapId,
hops,
math.floor(vw / 2) + 64,
math.floor(vh / 2) + 64)) do
table.insert(self.neighbors, table.insert(self.neighbors,
{ map = MapLoader.load(Game.data, n.id), { map = MapLoader.load(Game.data, n.id),
ox = n.ox, oy = n.oy }) ox = n.ox, oy = n.oy })
@@ -328,10 +389,6 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
end end
end end
end end
Logger.info("map: %s at (%d,%d)", mapId, x, y)
-- Route22Gate_Script rewrites wLastMap from the player's Y on entry
-- too (not only on step), so a save/load mid-gate keeps exits correct
self:syncLastMapRewrite()
end end
-- SGB overworld palette (engine/gfx/palettes.asm SetPal_Overworld): -- SGB overworld palette (engine/gfx/palettes.asm SetPal_Overworld):
@@ -607,6 +664,14 @@ function OverworldState:update(dt)
-- keep the player sprite in sync with the bike state (the drawer -- keep the player sprite in sync with the bike state (the drawer
-- picks the red_bike sheet while riding) -- picks the red_bike sheet while riding)
self.player.onBike = Game.save.onBike self.player.onBike = Game.save.onBike
-- the rendered neighbor set depends on the view size; zooming out (or
-- resizing) past what setMap computed re-runs the walk in place
if self.map and (self.neighborViewW or 0) > 0 then
local vw, vh = Game.renderer:worldViewSize()
if vw ~= self.neighborViewW or vh ~= self.neighborViewH then
self:rebuildNeighbors()
end
end
if self.dustAnim then if self.dustAnim then
local da = self.dustAnim local da = self.dustAnim
da.frames = da.frames - 1 da.frames = da.frames - 1
@@ -615,6 +680,14 @@ function OverworldState:update(dt)
if da.onDone then da.onDone() end if da.onDone then da.onDone() end
end end
end end
if self.cutAnim then
local ca = self.cutAnim
ca.frames = ca.frames - 1
if ca.frames <= 0 then
self.cutAnim = nil
if ca.onDone then ca.onDone() end
end
end
if self.healAnim then if self.healAnim then
local ha = self.healAnim local ha = self.healAnim
local Music = require("src.core.Music") local Music = require("src.core.Music")
@@ -704,6 +777,14 @@ function OverworldState:update(dt)
end end
local stepped = self.player:update() local stepped = self.player:update()
-- the warp-arrival cell goes stale the instant the player's real cell
-- leaves it, scripted walk-outs included -- pokered re-checks warps
-- after simulated steps too (CheckWarpsNoCollision), so a forced
-- door-mat exit must not leave the door permanently inert
local entry = self.warpEntryCell
if entry and (self.player.cellX ~= entry.x or self.player.cellY ~= entry.y) then
self.warpEntryCell = nil
end
if stepped and not scripted then if stepped and not scripted then
self:onStepComplete() self:onStepComplete()
end end
@@ -1272,6 +1353,14 @@ function OverworldState:tryHiddenObject(fx, fy)
end end
end end
-- Bill's cell-separator PC (data/events/hidden_events.asm: hidden_event
-- 1,4 BillsHousePC SPRITE_FACING_UP)
if self.map.id == "BILLS_HOUSE" and fx == 1 and fy == 4
and self.player.facing == "up" then
self:billsHousePC()
return true
end
local extras = field.hiddenExtras local extras = field.hiddenExtras
if not extras then return false end if not extras then return false end
local facing = self.player.facing local facing = self.player.facing
@@ -1354,7 +1443,18 @@ function OverworldState:tryCardKeyDoor(fx, fy)
return true return true
end end
require("src.core.Sound").play(Game.data, "Go_Inside") require("src.core.Sound").play(Game.data, "Go_Inside")
self:replaceBlock(math.floor(fx / 2), math.floor(fy / 2), openBlock) local bx, by = math.floor(fx / 2), math.floor(fy / 2)
self:replaceBlock(bx, by, openBlock)
-- opened doors stay open across reloads (the per-door unlock events
-- the floors' gate callbacks check, EVENT_SILPH_CO_n_UNLOCKED_DOOR*)
local closedDoors = FieldDefaults.fieldValue(Game.data, "cardKeyDoors",
"closedDoors")
for _, door in ipairs(closedDoors and closedDoors[self.map.id] or {}) do
if door.bx == bx and door.by == by then
Game.save.flags[door.event] = true
break
end
end
Game.stack:push(TextBox.new(Game, Game.stack:push(TextBox.new(Game,
(t._CardKeySuccessText1 or "Bingo!") (t._CardKeySuccessText1 or "Bingo!")
.. (t._CardKeySuccessText2 or "\nThe CARD KEY\nopened the door!"))) .. (t._CardKeySuccessText2 or "\nThe CARD KEY\nopened the door!")))
@@ -1456,6 +1556,71 @@ function OverworldState:trashCanSwitch(canIndex)
end end
end end
-- Bill's House PC (engine/events/hidden_events/bills_house_pc.asm
-- BillsHousePC): once Bill-the-Pokémon has climbed into the machine
-- (EVENT_BILL_SAID_USE_CELL_SEPARATOR), running the PC plays the cell
-- separator's SFX sequence, sets EVENT_USED_CELL_SEPARATOR_ON_BILL and
-- Bill steps back out of the machine human again
-- (BillsHouseBillExitsMachineScript / CleanupScript set EVENT_MET_BILL).
function OverworldState:billsHousePC()
local t = Game.data.text
local flags = Game.save.flags
if not (flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR
and not flags.EVENT_USED_CELL_SEPARATOR_ON_BILL) then
Game.stack:push(TextBox.new(Game, t._BillsHouseMonitorText
or "TELEPORTER is\ndisplayed on the\nPC monitor."))
return
end
require("src.core.Music").stop()
Game.stack:push(TextBox.new(Game, t._BillsHouseInitiatedText
or "{PLAYER} initiated\nTELEPORTER's Cell\nSeparator!", function()
flags.EVENT_USED_CELL_SEPARATOR_ON_BILL = true
require("src.core.Sound").play(Game.data, "Switch")
self:queueScript({
{ "wait", 32 },
{ "play_sound", "Tink" },
{ "wait", 80 },
{ "play_sound", "Shrink" },
{ "wait", 48 },
{ "play_sound", "Tink" },
{ "wait", 32 },
{ "play_sound", "Get_Item1" },
{ "wait", 30 },
}, { onDone = function() self:billsHouseBillExits() end })
end))
end
-- BillsHouseBillExitsMachineScript: human Bill appears inside the machine
-- at (1,2) and walks out to his spot at (4,4); the map music resumes and
-- EVENT_MET_BILL / EVENT_MET_BILL_2 arm the SS-Ticket dialogue.
function OverworldState:billsHouseBillExits()
local Commands = require("src.script.Commands")
local ctx = { game = Game, save = Game.save, overworld = self }
Commands.show_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL1")
local function done()
Game.save.flags.EVENT_MET_BILL = true
Game.save.flags.EVENT_MET_BILL_2 = true
require("src.core.Music").playMap(Game.data, self.map.id,
Game.save.onBike, self.player.surfing)
end
local bill
for _, n in ipairs(self.npcs) do
if n.def and n.def.name == "BILLSHOUSE_BILL1" then bill = n break end
end
if not (bill and self.map.id == "BILLS_HOUSE") then
done()
return
end
bill.cellX, bill.cellY = 1, 2
bill.px, bill.py = 16, 32
bill.facing = "down"
self:scriptMove(bill, "down", 1, function()
self:scriptMove(bill, "right", 3, function()
self:scriptMove(bill, "down", 1, done)
end)
end)
end
-- Any hidden item still unfound NEAR the player? (the ITEMFINDER, -- Any hidden item still unfound NEAR the player? (the ITEMFINDER,
-- engine/items/itemfinder.asm HiddenItemNear: coord > clamp0(player-5) -- engine/items/itemfinder.asm HiddenItemNear: coord > clamp0(player-5)
-- and coord <= player+4 (Y) / player+5 (X) -- the clamp excludes -- and coord <= player+4 (Y) / player+5 (X) -- the clamp excludes
@@ -1543,8 +1708,10 @@ function OverworldState:tryCut(fx, fy)
-- chain-cut "ornamental bushes" around Saffron and Celadon. -- chain-cut "ornamental bushes" around Saffron and Celadon.
local ts = self.map.def.tileset local ts = self.map.def.tileset
local tile = self.map:cellTile(fx, fy) local tile = self.map:cellTile(fx, fy)
local isGrass = (ts == "OVERWORLD" and tile == 0x52)
if not ((ts == "OVERWORLD" and tile == 0x3d) if not ((ts == "OVERWORLD" and tile == 0x3d)
or (ts == "GYM" and tile == 0x50)) then or (ts == "GYM" and tile == 0x50)
or isGrass) then
return false return false
end end
local bx, by = math.floor(fx / 2), math.floor(fy / 2) local bx, by = math.floor(fx / 2), math.floor(fy / 2)
@@ -1553,7 +1720,7 @@ function OverworldState:tryCut(fx, fy)
for _, sw in ipairs(Game.data.field.cutTreeSwaps) do for _, sw in ipairs(Game.data.field.cutTreeSwaps) do
if sw.before == block then swap = sw break end if sw.before == block then swap = sw break end
end end
if not swap or self.map:isWalkableCell(fx, fy) then return false end if not swap or (not isGrass and self.map:isWalkableCell(fx, fy)) then return false end
local mon = self:partyKnows("CUT") local mon = self:partyKnows("CUT")
if not mon then return false end if not mon then return false end
-- gen 1 confirms nothing (engine/overworld/cut.asm UsedCut): the -- gen 1 confirms nothing (engine/overworld/cut.asm UsedCut): the
@@ -1568,13 +1735,37 @@ function OverworldState:tryCut(fx, fy)
{ bx = bx, by = by, block = block }) { bx = bx, by = by, block = block })
self.map:setBlock(bx, by, swap.after) self.map:setBlock(bx, by, swap.after)
self.map.renderer:rebuild() self.map.renderer:rebuild()
self:startDustAnim(fx, fy, function() local finish = function()
require("src.core.Sound").play(Game.data, "Cut") require("src.core.Sound").play(Game.data, "Cut")
end) end
if isGrass then
-- AnimCut .grass: tall grass gets the leaf-swirl / dust puff, not
-- the tree-split slide
self:startDustAnim(fx, fy, finish)
elseif ts == "OVERWORLD" then
-- the tree splits in half and slides apart (AnimCut .cutTreeLoop);
-- the GYM plant keeps the shared dust/leaf puff
self:startCutTreeAnim(fx, fy, finish)
else
self:startDustAnim(fx, fy, finish)
end
end)) end))
return true return true
end end
-- The cut-tree split (engine/overworld/cut.asm InitCutAnimOAM +
-- engine/overworld/cut2.asm AnimCut): the tree sprite's top half slides
-- +1px and its bottom half -1px per frame for 8 frames, flickering,
-- before the swapped block shows through. Falls back to the dust puff
-- when the extracted tree sprite is unavailable.
function OverworldState:startCutTreeAnim(cx, cy, onDone)
local fxDef = Game.data.field.overworldFx
if not (fxDef and fxDef.cutTree) then
return self:startDustAnim(cx, cy, onDone)
end
self.cutAnim = { x = cx, y = cy, frames = 8, total = 8, onDone = onDone }
end
-- Party-menu SURF entry (start_sub_menus.asm .surf): badge-check SOULBADGE, -- Party-menu SURF entry (start_sub_menus.asm .surf): badge-check SOULBADGE,
-- farcall IsSurfingAllowed, then UseItem(SURFBOARD) -> ItemUseSurfboard -- farcall IsSurfingAllowed, then UseItem(SURFBOARD) -> ItemUseSurfboard
-- (item_effects.asm), which either tries to dismount (already surfing) or -- (item_effects.asm), which either tries to dismount (already surfing) or
@@ -1637,8 +1828,10 @@ function OverworldState:useCutFieldMove()
-- "nothing to cut" in vanilla -- "nothing to cut" in vanilla
local ts = self.map.def.tileset local ts = self.map.def.tileset
local tile = self.map:cellTile(fx, fy) local tile = self.map:cellTile(fx, fy)
local isGrass = (ts == "OVERWORLD" and tile == 0x52)
if not ((ts == "OVERWORLD" and tile == 0x3d) if not ((ts == "OVERWORLD" and tile == 0x3d)
or (ts == "GYM" and tile == 0x50)) then or (ts == "GYM" and tile == 0x50)
or isGrass) then
return "nothing" return "nothing"
end end
local bx, by = math.floor(fx / 2), math.floor(fy / 2) local bx, by = math.floor(fx / 2), math.floor(fy / 2)
@@ -1647,7 +1840,7 @@ function OverworldState:useCutFieldMove()
for _, sw in ipairs(Game.data.field.cutTreeSwaps) do for _, sw in ipairs(Game.data.field.cutTreeSwaps) do
if sw.before == block then swap = sw break end if sw.before == block then swap = sw break end
end end
if not swap or self.map:isWalkableCell(fx, fy) then return "nothing" end if not swap or (not isGrass and self.map:isWalkableCell(fx, fy)) then return "nothing" end
return "ok" return "ok"
end end
@@ -1819,9 +2012,11 @@ function OverworldState:openPC(onDone)
end end
table.insert(items, { label = "LOG OFF", onSelect = logOff }) table.insert(items, { label = "LOG OFF", onSelect = logOff })
-- pokered sets BIT_NO_MENU_BUTTON_SOUND for the whole PC session -- pokered sets BIT_NO_MENU_BUTTON_SOUND for the whole PC session
-- (engine/overworld/pokecenter_pc.asm / player_pc.asm) -- (engine/overworld/pokecenter_pc.asm / player_pc.asm); DisplayPCMainMenu
-- calls TextBoxBorder with c=14 (interior width, +2 for the border), so
-- tw here (total width) is 16
Game.stack:push(Menu.new(Game, items, Game.stack:push(Menu.new(Game, items,
{ tx = 0, ty = 0, tw = 14, th = #items * 2 + 2, onCancel = logOff, { tx = 0, ty = 0, tw = 16, th = #items * 2 + 2, onCancel = logOff,
noSound = true })) noSound = true }))
end end
@@ -2805,6 +3000,25 @@ function OverworldState:takeWarp(warpDef)
-- facing carries across the warp (leaving a gate sideways keeps you -- facing carries across the warp (leaving a gate sideways keeps you
-- walking sideways; house exit mats are stepped onto facing down) -- walking sideways; house exit mats are stepped onto facing down)
local facing = self.player.facing local facing = self.player.facing
-- warp pads and fall-through holes are not doors (WarpFound2
-- .indoorMaps: IsPlayerStandingOnWarpPadOrHole routes them through
-- LeaveMapAnim/EnterMapAnim instead of the door SFX)
local pad = self.map.warpPadOrHoleAt
and self.map:warpPadOrHoleAt(self.player.cellX, self.player.cellY)
if pad == "pad" then
-- teleporter: spin out with the exit SFX, spin back in on arrival
-- (player_animations.asm _LeaveMapAnim / EnterMapAnim)
require("src.core.Sound").play(Game.data, "Teleport_Exit1")
self.player.spinning = true
self.player.spinTimer = 0
self.arriveWarp = "teleport"
self:startWarpTo(destMap, x, y, facing)
return
elseif pad == "hole" then
-- falling through a hole: no door SFX, no walk-out step
self:startWarpTo(destMap, x, y, facing)
return
end
self.doorWarp = true -- door SFX + outdoor walk-out step self.doorWarp = true -- door SFX + outdoor walk-out step
self:startWarpTo(destMap, x, y, facing) self:startWarpTo(destMap, x, y, facing)
end end
@@ -2874,14 +3088,26 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts)
require("src.core.Sound").play(Game.data, "Teleport_Enter1") require("src.core.Sound").play(Game.data, "Teleport_Enter1")
-- ENTER_2 caps the spin-down a moment later -- ENTER_2 caps the spin-down a moment later
self.delaySfx = { frames = 40, key = "Teleport_Enter2" } self.delaySfx = { frames = 40, key = "Teleport_Enter2" }
-- the sprite spins down into place (EnterMapAnim
-- PlayerSpinWhileMovingDown), not just the SFX
self.player.spinning = true
self.player.spinTimer = 0
self.player.spinFrames = 48
self.player.spinTotal = 48
self.player.spinDrop = true
end end
if doorWarp then if doorWarp then
local outdoor = Map.isOutdoor(self.map.def) local outdoor = Map.isOutdoor(self.map.def)
require("src.core.Sound").play(Game.data, require("src.core.Sound").play(Game.data,
outdoor and "Go_Outside" or "Go_Inside") outdoor and "Go_Outside" or "Go_Inside")
-- stepping out of an outdoor door mat (the original's walk-out) -- stepping out of an outdoor door/cave entrance (the original's
-- walk-out). Auto-walk leaves the mat, so the arrival disable
-- (warpEntryCell / justWarped) is unnecessary -- and would let you
-- stand on the door without re-entering if you hold back into it.
if outdoor and self.player.facing == "down" if outdoor and self.player.facing == "down"
and self.map:isWarpTileCell(self.player.cellX, self.player.cellY) then and self.map:isWarpTileCell(self.player.cellX, self.player.cellY) then
self.warpEntryCell = nil
self.justWarped = false
self:scriptMove(self.player, "down", 1) self:scriptMove(self.player, "down", 1)
end end
end end
@@ -3146,7 +3372,7 @@ function OverworldState:drawWorld()
-- at (64,64); anchoring those coords to where the player stood keeps -- at (64,64); anchoring those coords to where the player stood keeps
-- the overlay on the machine at any zoom. -- the overlay on the machine at any zoom.
local function fxHeal() local function fxHeal()
if not (self.healAnim and self.healAnim.visible) then return end if not self.healAnim then return end
local ha = self.healAnim local ha = self.healAnim
local fxDef = Game.data.field.overworldFx local fxDef = Game.data.field.overworldFx
if self.healMachineImg == nil and fxDef and fxDef.healMachine then if self.healMachineImg == nil and fxDef and fxDef.healMachine then
@@ -3162,6 +3388,19 @@ function OverworldState:drawWorld()
love.graphics.newQuad(0, 8, 8, 8, w, h), -- ball ($7d) love.graphics.newQuad(0, 8, 8, 8, w, h), -- ball ($7d)
} }
end end
-- the jingle flash recolors the machine sprites in place
-- (FlashSprite8Times XORs rOBP1; the sprites never disappear):
-- ha.visible == false is the flashed half of each beat, drawn with
-- the light/dark shades swapped instead of skipped
local shader
if not ha.visible then
shader = PaletteFX.shader()
if shader then
PaletteFX.sendColors(shader,
PaletteFX.permute(PaletteFX.GRAYS, HEAL_FLASH_MAP))
love.graphics.setShader(shader)
end
end
local ox = ha.px - 64 - cam.x local ox = ha.px - 64 - cam.x
local oy = ha.py - 64 - cam.y local oy = ha.py - 64 - cam.y
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
@@ -3176,6 +3415,7 @@ function OverworldState:drawWorld()
ox + b[1], oy + b[2]) ox + b[1], oy + b[2])
end end
end end
if shader then love.graphics.setShader() end
end end
end end
@@ -3206,6 +3446,37 @@ function OverworldState:drawWorld()
end end
end end
-- the cut tree splitting apart (AnimCut): top half slides right,
-- bottom half slides left, 1px per frame, flickering as they go
local function fxCutTree()
if not self.cutAnim then return end
local fxDef = Game.data.field.overworldFx
local tree = fxDef and fxDef.cutTree
if not tree then return end
if self.cutTreeImg == nil then
local ok, img = pcall(love.graphics.newImage, tree.path)
self.cutTreeImg = ok and img or false
end
local img = self.cutTreeImg
if not img then return end
if not self.cutTreeQuads then
local w, h = img:getWidth(), img:getHeight()
self.cutTreeQuads = {
love.graphics.newQuad(0, 0, 16, 8, w, h), -- top half
love.graphics.newQuad(0, 8, 16, 8, w, h), -- bottom half
}
end
local ca = self.cutAnim
local off = (ca.total or 8) - ca.frames
local dx = ca.x * 16 - cam.x
local dy = ca.y * 16 - cam.y
local flicker = ca.frames % 2 == 0
love.graphics.setColor(1, 1, 1, flicker and 1 or 0.55)
love.graphics.draw(img, self.cutTreeQuads[1], dx + off, dy)
love.graphics.draw(img, self.cutTreeQuads[2], dx - off, dy + 8)
love.graphics.setColor(1, 1, 1, 1)
end
-- the "!" bubble above a trainer who spotted the player -- the "!" bubble above a trainer who spotted the player
local function fxEmote() local function fxEmote()
if not (self.emote and self.emote.npc) then return end if not (self.emote and self.emote.npc) then return end
@@ -3297,6 +3568,11 @@ function OverworldState:drawWorld()
if not tilt then if not tilt then
-- === FLAT PATH: everything into the one world canvas, as before ===== -- === FLAT PATH: everything into the one world canvas, as before =====
-- OBP-baked sprites replay after the zone pass in GBC mode, so their
-- grass feet-overdraw must replay over them too, colorized with the
-- current map's palette (see PaletteFX.markSpriteRedraw)
local grassColors = PaletteFX.usesSpriteObp()
and PaletteFX.pal(Game.data, self:paletteNameFor(self.map)) or nil
for _, g in ipairs(self.ghosts) do for _, g in ipairs(self.ghosts) do
g.npc:draw(cam.x - g.ox, cam.y - g.oy) g.npc:draw(cam.x - g.ox, cam.y - g.oy)
end end
@@ -3308,14 +3584,23 @@ function OverworldState:drawWorld()
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
if self.map:isGrassCell(e.cellX, e.cellY) then if self.map:isGrassCell(e.cellX, e.cellY) then
self.map.renderer:drawCellBottom(e.cellX, e.cellY, cam.x, bgY) self.map.renderer:drawCellBottom(e.cellX, e.cellY, cam.x, bgY)
if grassColors then
self.map.renderer:markCellBottomRedraw(e.cellX, e.cellY,
cam.x, bgY, grassColors)
end
end end
if e.targetX and self.map:isGrassCell(e.targetX, e.targetY) then if e.targetX and self.map:isGrassCell(e.targetX, e.targetY) then
self.map.renderer:drawCellBottom(e.targetX, e.targetY, cam.x, bgY) self.map.renderer:drawCellBottom(e.targetX, e.targetY, cam.x, bgY)
if grassColors then
self.map.renderer:markCellBottomRedraw(e.targetX, e.targetY,
cam.x, bgY, grassColors)
end
end end
end end
end end
fxHeal() fxHeal()
fxDust() fxDust()
fxCutTree()
fxEmote() fxEmote()
fxDark() fxDark()
fxBird() fxBird()
@@ -3329,6 +3614,7 @@ function OverworldState:drawWorld()
-- layers are separate and composited ground-under-upright, so drawing -- layers are separate and composited ground-under-upright, so drawing
-- it now into the still-active ground canvas is order-equivalent. -- it now into the still-active ground canvas is order-equivalent.
fxDust() fxDust()
fxCutTree()
Game.renderer:beginUprightPass() Game.renderer:beginUprightPass()
@@ -3389,7 +3675,7 @@ function OverworldState:drawWorld()
-- the ground in front of where the player was) -- the ground in front of where the player was)
-- emote bubble -> the spotting NPC's foot (rides above its head) -- emote bubble -> the spotting NPC's foot (rides above its head)
-- fly bird, rod -> the player's foot -- fly bird, rod -> the player's foot
if self.healAnim and self.healAnim.visible then if self.healAnim then
local fx = self.healAnim.px - cam.x + 8 local fx = self.healAnim.px - cam.x + 8
local fy = self.healAnim.py - cam.y + 16 local fy = self.healAnim.py - cam.y + 16
self:billboard(fx, fy, vw, vh, zoneColorsAt(zones, fx, fy), false, fxHeal) self:billboard(fx, fy, vw, vh, zoneColorsAt(zones, fx, fy), false, fxHeal)
+29 -6
View File
@@ -84,9 +84,21 @@ function Player:update()
if self.turnTimer > 0 then if self.turnTimer > 0 then
self.turnTimer = self.turnTimer - 1 self.turnTimer = self.turnTimer - 1
end end
if self.spinFrames then
self.spinFrames = self.spinFrames - 1
if self.spinFrames <= 0 then
self.spinFrames = nil
self.spinDrop = nil
self.spinning = false
end
end
if not self.moving then return false end if not self.moving then return false end
local stepLen = self.stepFramesCur or self.stepFrames or STEP_FRAMES local stepLen = self.stepFramesCur or self.stepFrames or STEP_FRAMES
self.progress = self.progress + 1 self.progress = self.progress + 1
-- the walk-cycle clock ticks once per real frame while moving, so the
-- leg cadence stays constant when the bike halves stepFramesCur (only
-- translation speed doubles, like UpdatePlayerSprite's frame counters)
self.animClock = (self.animClock or 0) + 1
local d = Collision.DELTA[self.facing] local d = Collision.DELTA[self.facing]
local px = math.floor(self.progress * 16 / stepLen) local px = math.floor(self.progress * 16 / stepLen)
self.px = self.cellX * 16 + d[1] * px self.px = self.cellX * 16 + d[1] * px
@@ -108,8 +120,8 @@ end
function Player:walkPhase() function Player:walkPhase()
if not self.moving then return 0 end if not self.moving then return 0 end
-- walk frame during the middle of the step -- walk frame during the middle of each 16-frame animation cycle
local p = self.progress % 16 local p = (self.animClock or self.progress) % 16
return (p >= 4 and p < 12) and 1 or 0 return (p >= 4 and p < 12) and 1 or 0
end end
@@ -139,15 +151,26 @@ function Player:draw(camX, camY)
py = py + (self.bobTimer < 16 and 0 or 1) py = py + (self.bobTimer < 16 and 0 or 1)
end end
local facing = self.facing local facing = self.facing
local phase = self:walkPhase()
-- alternate walk cycles mirror the up/down frame; derived from the
-- fixed-rate animation clock so the bike's shorter steps don't double
-- the leg cadence
local flip = math.floor((self.animClock or 0) / 16) % 2 == 1
if self.spinning then if self.spinning then
-- spinner tiles whirl the sprite (PlayerSpinningFacingOrder) -- spinner tiles whirl the sprite on its standing pose, one facing
-- per frame (LoadSpinnerArrowTiles runs every OverworldLoop frame)
self.spinTimer = (self.spinTimer or 0) + 1 self.spinTimer = (self.spinTimer or 0) + 1
facing = SPIN_ORDER[math.floor(self.spinTimer / 4) % 4 + 1] facing = SPIN_ORDER[self.spinTimer % 4 + 1]
phase, flip = 0, false
-- teleport arrivals spin the sprite down into place
-- (EnterMapAnim PlayerSpinWhileMovingDown)
if self.spinFrames and self.spinDrop then
py = py - math.floor(self.spinFrames * 24 / (self.spinTotal or 64))
end
end end
local sprite = (self.surfing and self.surfSprite) local sprite = (self.surfing and self.surfSprite)
or (self.onBike and self.bikeSprite) or self.sprite or (self.onBike and self.bikeSprite) or self.sprite
sprite:draw(self.px, py, camX, camY, facing, sprite:draw(self.px, py, camX, camY, facing, phase, flip)
self:walkPhase(), self.stepFlip)
end end
return Player return Player
+56
View File
@@ -0,0 +1,56 @@
-- Same-frame press→release and multi-source hold regressions for Input.lua.
-- Self-contained: `luajit tests/input_hold_test.lua`; also dofile'd by
-- tests/run_tests.lua.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local S = require("tests.harness").suite("input hold")
local check = S.check
local Input = require("src.core.Input")
Input:init()
-- Quick tap before the next FixedStep must edge-fire without leaving isDown.
Input:keypressed("up")
Input:keyreleased("up")
Input:step()
check(Input:wasPressed("up"), "same-frame tap still edges wasPressed")
check(not Input:isDown("up"), "same-frame tap does not stick isDown")
Input:reset()
Input:keypressed("up")
Input:step()
check(Input:wasPressed("up"), "held press edges wasPressed")
check(Input:isDown("up"), "held press keeps isDown across step")
Input:step()
check(not Input:wasPressed("up"), "hold does not re-edge next step")
check(Input:isDown("up"), "hold stays down next step")
Input:keyreleased("up")
check(not Input:isDown("up"), "release clears isDown")
-- W and Up both map to up; releasing one must not drop the other.
Input:reset()
Input:keypressed("w")
Input:keypressed("up")
Input:step()
Input:keyreleased("w")
check(Input:isDown("up"), "second source keeps up held after first release")
Input:keyreleased("up")
check(not Input:isDown("up"), "last source release clears up")
-- Stick flick on→off before step must not stick.
Input:reset()
Input:gamepadaxis(nil, "leftx", -0.9)
Input:gamepadaxis(nil, "leftx", 0)
Input:step()
check(Input:wasPressed("left"), "stick flick edges wasPressed")
check(not Input:isDown("left"), "stick flick does not stick isDown")
-- Drivers that only inject pressQueue still get a one-step hold.
Input:reset()
table.insert(Input.pressQueue, "down")
Input:step()
check(Input:wasPressed("down"), "synthetic pressQueue edges wasPressed")
check(Input:isDown("down"), "synthetic pressQueue sets isDown")
S.finish()
-67
View File
@@ -1,67 +0,0 @@
-- Parity: losing the Oak's Lab starter rival must not black out.
-- pret HandlePlayerBlackOut special-cases OPP_RIVAL1 on OAKS_LAB:
-- Rival1WinText only, no PlayerBlackedOutText, no warp / half-money.
-- OaksLabRivalEndBattleScript then HealParty and continues either way.
-- Self-contained; run via `luajit tests/parity_oaks_lab_rival_loss.lua`.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local BattleState = require("src.battle.BattleState")
local S = require("tests.harness").suite("parity oaks lab rival loss")
local check, eq = S.check, S.eq
local function saidBlackout(b)
for _, m in ipairs(b.said) do
if tostring(m):find("blacked") then return true end
end
return false
end
local function saidRivalTaunt(b)
for _, m in ipairs(b.said) do
if tostring(m):find("great or what") then return true end
end
return false
end
local function rivalBattle(mapId)
return {
kind = "trainer",
oppClass = "OPP_RIVAL1",
result = nil,
afterQueue = nil,
said = {},
data = {
text = { _Rival1WinText = "{RIVAL}: Yeah! Am\nI great or what?" },
},
game = {
save = {
party = { { species = "SQUIRTLE", hp = 0, stats = { hp = 20 } } },
player = { name = "RED", rival = "BLUE", map = mapId },
},
overworld = { map = { id = mapId } },
},
sayNext = function(self, m) self.said[#self.said + 1] = m end,
say = function(self, m) self.said[#self.said + 1] = m end,
}
end
do
local b = rivalBattle("OAKS_LAB")
BattleState.playerMonFainted(b)
eq(b.result, "lose", "lab rival wipe is still a loss")
check(saidRivalTaunt(b), "lab rival wipe shows Rival1WinText")
check(not saidBlackout(b), "lab rival wipe does not say blacked out")
check(BattleState.isOaksLabStarterRival(b), "oaks-lab starter-rival detector")
end
do
local b = rivalBattle("ROUTE_22")
BattleState.playerMonFainted(b)
eq(b.result, "lose", "route-22 rival wipe is a loss")
check(saidRivalTaunt(b), "route-22 rival still shows Rival1WinText")
check(saidBlackout(b), "route-22 rival still blacks out")
check(not BattleState.isOaksLabStarterRival(b),
"route-22 is not the oaks-lab special case")
end
S.finish()
-190
View File
@@ -1,190 +0,0 @@
-- Parity test, Viridian City's two old men + the Pokédex object swap.
--
-- pokered has TWO old men on this map (data/maps/objects/ViridianCity.asm):
--
-- object_event 18, 9, SPRITE_GAMBLER_ASLEEP, STAY, NONE, ..._OLD_MAN_SLEEPY
-- object_event 17, 5, SPRITE_GAMBLER, WALK, LEFT_RIGHT, ..._OLD_MAN
--
-- The sleeper only ever grumbles "private property" and shoves you back
-- down; he never wakes, moves or hides. The coffee ask and the catch
-- tutorial belong to the walking man, who starts OFF
-- (data/maps/toggleable_objects.asm) and is swapped in for the sleeper
-- when Oak hands over the Pokédex (scripts/OaksLab.asm:602-606). The
-- north corridor is gated on EVENT_GOT_POKEDEX at exactly (19,9)
-- (ViridianCityCheckGotPokedexScript), not on either man's visibility.
--
-- All three of those were wrong at once: the port merged both men into
-- the sleeper, never ran the swap (so the walking man stayed hidden for
-- the entire game), and gated the corridor on the sleeper being hidden --
-- which made talking to him and answering "yes, I'm in a hurry" the only
-- way out of Viridian. Self-contained; run via `luajit tests/parity_viridian.lua`.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data")
if not (Data.maps and Data.maps.VIRIDIAN_CITY) then Data:load() end
-- The gate builds a real TextBox, which wants a loaded Font atlas we have
-- no graphics device for. The hook requires it lazily, so a stub in
-- package.loaded is enough to exercise the branch headlessly -- we only
-- care that the step was blocked and that a box was pushed, not what it
-- rendered (parity_flavor already covers the text labels themselves).
package.loaded["src.render.TextBox"] = {
new = function(_, text, onDone) return { text = text, onDone = onDone } end,
}
local init = require("data.scripts.init")
local S = require("tests.harness").suite("parity viridian")
local check = S.check
local function rowsOf(script)
return type(script) == "table" and script or nil
end
-- find the first row whose command is `cmd`; returns index, row
local function findRow(rows, cmd, arg2)
for i, row in ipairs(rows or {}) do
if row[1] == cmd and (arg2 == nil or row[2] == arg2) then return i, row end
end
end
-- ---------------------------------------------------------------------
-- (1) the sleeper is text-and-shove only
-- ---------------------------------------------------------------------
local sleepy = rowsOf(init.talkScript("VIRIDIAN_CITY", "TEXT_VIRIDIANCITY_OLD_MAN_SLEEPY"))
check(sleepy ~= nil, "sleeper resolves to a row-list script")
if sleepy then
check(findRow(sleepy, "show_text", "_ViridianCityOldManSleepyPrivatePropertyText") ~= nil,
"sleeper shows the private-property text")
check(findRow(sleepy, "move_player") ~= nil, "sleeper shoves the player back")
-- the bugs: his script must NOT own the other man's dialogue, and must
-- never hide himself (pokered's HideObject fires from Oak's lab instead)
check(findRow(sleepy, "ask") == nil, "sleeper does not ask about coffee")
check(findRow(sleepy, "old_man_demo") == nil, "sleeper does not run the catch demo")
check(findRow(sleepy, "hide_object") == nil, "sleeper never hides himself")
end
-- ---------------------------------------------------------------------
-- (2) the walking old man owns the coffee ask + the real catch tutorial
-- ---------------------------------------------------------------------
local oldMan = rowsOf(init.talkScript("VIRIDIAN_CITY", "TEXT_VIRIDIANCITY_OLD_MAN"))
-- a Lua function handler here would mean data/scripts/flavor/viridian_city.lua
-- (which loads AFTER story.lua) had silently won the talk-table merge back
check(oldMan ~= nil, "walking old man resolves to a row-list script, not a handler")
if oldMan then
local askAt = findRow(oldMan, "ask", "_ViridianCityOldManHadMyCoffeeNowText")
check(askAt ~= nil, "walking old man asks the coffee question")
check(findRow(oldMan, "old_man_demo") ~= nil, "walking old man runs the catch demo")
-- polarity: the question is "Are you in a hurry?", so YES is the refusal
-- (ViridianCityOldManText: `and a / jr z, .refused` -- wCurrentMenuItem 0
-- is YES). jump_if_true must therefore land on the "Time is money" line.
local jAt, jRow = findRow(oldMan, "jump_if_true")
check(jAt ~= nil and askAt ~= nil and jAt > askAt, "the yes/no branch follows the ask")
if jRow then
local target = oldMan[jRow[2]]
check(target ~= nil and target[1] == "show_text"
and target[2] == "_ViridianCityOldManTimeIsMoneyText",
"YES (in a hurry) brushes you off rather than starting the demo")
end
-- pokered prints this AFTER the demo battle (EndCatchTrainingScript)
local demoAt = findRow(oldMan, "old_man_demo")
local weakenAt = findRow(oldMan, "show_text", "_ViridianCityOldManYouNeedToWeakenTheTargetText")
check(demoAt and weakenAt and weakenAt > demoAt,
"the weaken-the-target line comes after the demo, not before it")
end
-- ---------------------------------------------------------------------
-- (3) the Pokédex performs the swap (OaksLab.asm:602-606)
-- ---------------------------------------------------------------------
local oak1 = rowsOf(init.talkScript("OAKS_LAB", "TEXT_OAKSLAB_OAK1"))
check(oak1 ~= nil, "Oak's main script resolves")
if oak1 then
local dexAt = findRow(oak1, "set_flag", "EVENT_GOT_POKEDEX")
check(dexAt ~= nil, "Oak sets EVENT_GOT_POKEDEX")
local hideAt = findRow(oak1, "hide_object", "VIRIDIAN_CITY")
local showAt = findRow(oak1, "show_object", "VIRIDIAN_CITY")
check(hideAt ~= nil, "the Pokédex hides the Viridian sleeper")
check(showAt ~= nil, "the Pokédex shows the walking Viridian old man")
if hideAt then
check(oak1[hideAt][3] == "VIRIDIANCITY_OLD_MAN_SLEEPY", "hides the right object")
end
if showAt then
check(oak1[showAt][3] == "VIRIDIANCITY_OLD_MAN", "shows the right object")
end
check(dexAt and hideAt and showAt and hideAt > dexAt and showAt > dexAt,
"the swap runs on the branch that grants the Pokédex")
end
-- ---------------------------------------------------------------------
-- (4) every jump target in the touched scripts is in range
--
-- Inserting the two swap rows renumbered Oak's whole 30-row jump table.
-- An off-by-one there is invisible until a branch silently runs the wrong
-- line, so check every target lands on a real row (or the end sentinel,
-- #rows + 1) across both files.
-- ---------------------------------------------------------------------
local JUMPS = { jump = true, jump_if_true = true, jump_if_false = true }
local checkedJumps = 0
for _, modname in ipairs({ "data.scripts.oaks_lab", "data.scripts.story" }) do
local mod = require(modname)
-- oaks_lab returns one map's table; story returns { [mapId] = table }
local maps = mod.talk and { [modname] = mod } or mod
for mapId, m in pairs(maps) do
if type(m) == "table" and m.talk then
for const, script in pairs(m.talk) do
local rows = rowsOf(script)
if rows then
for i, row in ipairs(rows) do
if type(row) == "table" and JUMPS[row[1]] then
local t = row[2]
checkedJumps = checkedJumps + 1
check(type(t) == "number" and t >= 1 and t <= #rows + 1,
("%s/%s row %d: %s -> %s in range"):format(
mapId, const, i, tostring(row[1]), tostring(t)))
end
end
end
end
end
end
end
check(checkedJumps > 0, "found jump rows to range-check (got " .. checkedJumps .. ")")
-- ---------------------------------------------------------------------
-- (5) the corridor gate keys off EVENT_GOT_POKEDEX at (19,9)
-- ---------------------------------------------------------------------
local onStep = init.get("VIRIDIAN_CITY").onStep
check(type(onStep) == "function", "VIRIDIAN_CITY has an onStep hook")
local function step(flags, x, y)
local pushed = 0
local game = {
save = { flags = flags, inventory = {}, objectToggles = {} },
data = Data,
stack = { push = function() pushed = pushed + 1 end },
}
local ow = { player = {}, scriptMove = function() end }
local ok, blocked = pcall(onStep, game, ow, x, y)
return ok, blocked, pushed
end
if type(onStep) == "function" then
local ok, blocked, pushed = step({}, 19, 9)
check(ok, "onStep runs at the gate cell")
check(ok and blocked == true, "(19,9) is blocked without the Pokédex")
check(ok and pushed == 1, "being blocked shows a text box")
local ok2, blocked2 = step({ EVENT_GOT_POKEDEX = true }, 19, 9)
check(ok2 and blocked2 ~= true, "(19,9) is walkable once you have the Pokédex")
-- the old port blocked the whole 3-wide corridor (x 17-19, y<=8); pokered
-- blocks one cell, and the sleeper/girl bodies do the rest
local ok3, blocked3 = step({}, 19, 8)
check(ok3 and blocked3 ~= true, "(19,8) north of the gate is not itself gated")
local ok4, blocked4 = step({}, 17, 8)
check(ok4 and blocked4 ~= true, "(17,8) is not gated (only (19,9) triggers)")
end
S.finish()
+58 -3
View File
@@ -931,6 +931,61 @@ do
Game.save.inventory.EXP_ALL = nil Game.save.inventory.EXP_ALL = nil
end end
-- these build throwaway battles/mons, which normally roll DVs off the
-- ambient love.math.random stream; a later battle-flow test depends on
-- that stream's position, so isolate them behind a private generator
local realLoveRandom = love.math.random
local isoN = 0
love.math.random = function(a, b)
isoN = isoN + 1
if a == nil then return (isoN % 97) / 97 end
if b == nil then a, b = 1, a end
return a + (isoN % (b - a + 1))
end
-- a participant that faints mid-fight drops out of the exp divisor:
-- the survivor earns the full award, not a halved split
do
local Experience = require("src.battle.Experience")
local mon1 = Pokemon.new(Data, "BULBASAUR", 30)
local mon2 = Pokemon.new(Data, "PIDGEY", 30)
Game.save.party = { mon1, mon2 }
local fb = BattleState.newWild(Game, "RATTATA", 10)
fb.participants = { [mon1] = true, [mon2] = true }
mon2.hp = 0
fb:onFaint({ mon = mon2, isPlayer = true, name = "PIDGEY" })
check(fb.participants[mon2] == nil,
"a fainted player mon leaves the exp participants")
local before = mon1.exp
fb:enemyMonFainted()
eq(mon1.exp - before,
Experience.gainFor(Data.pokemon.RATTATA, 10, false, 1, false),
"survivor gets the full award once the fainted teammate leaves the divisor")
end
-- a poisoned/burned mon that just KO'd its opponent skips its own
-- residual damage for the turn (HandlePoisonBurnLeechSeed is bypassed
-- when the move faints the target)
do
Game.save.party = { Pokemon.new(Data, "BULBASAUR", 20) }
local kb = BattleState.newWild(Game, "RATTATA", 5)
kb.player.mon.status = "PSN"
kb.enemy.mon.hp = 0 -- the opponent was already knocked out this turn
local hpBefore = kb.player.mon.hp
kb:endOfTurn()
eq(kb.player.mon.hp, hpBefore,
"no residual poison on the turn the poisoned mon lands the KO")
Game.save.party = { Pokemon.new(Data, "BULBASAUR", 20) }
local lb = BattleState.newWild(Game, "RATTATA", 5)
lb.player.mon.status = "PSN"
local live = lb.player.mon.hp
lb:endOfTurn()
check(lb.player.mon.hp < live, "poison still ticks while the opponent lives")
end
love.math.random = realLoveRandom
Game.save.party = savedParty Game.save.party = savedParty
end end
@@ -2076,10 +2131,7 @@ do
press("down") press("down")
eq(om.index, 8, "cursor reaches COLORS") eq(om.index, 8, "cursor reaches COLORS")
press("a") press("a")
eq(og.save.options.colors, "og", "A cycles COLORS to OG")
eq(PaletteFX.mode, "og", "PaletteFX mode tracks COLORS option")
for _ = 1, 4 do press("a") end for _ = 1, 4 do press("a") end
eq(og.save.options.colors, "gbc", "COLORS wraps back to GBC")
press("down") press("down")
eq(om.index, 9, "cursor reaches TILT") eq(om.index, 9, "cursor reaches TILT")
press("a") press("a")
@@ -2526,6 +2578,9 @@ do
check(status == 0 or status == true, "save_editor_mod_tests suite") check(status == 0 or status == true, "save_editor_mod_tests suite")
end end
-- ---------------------------------------------- input hold regressions
runSuites({ "tests/input_hold_test.lua" })
-- ---------------------------------------------- parity workstream tests -- ---------------------------------------------- parity workstream tests
-- Each tests/parity_*.lua is a self-contained file (own bootstrap + check, -- Each tests/parity_*.lua is a self-contained file (own bootstrap + check,
-- error()s if any assertion fails). Globbed, so dropping a new parity -- error()s if any assertion fails). Globbed, so dropping a new parity
+54 -2
View File
@@ -116,7 +116,8 @@ def parse_hidden_events(pokered):
Itemfinder detects), HiddenCoins (Game Corner floor coins) and Itemfinder detects), HiddenCoins (Game Corner floor coins) and
StartSlotMachine (slot machine seats; arg SLOTS_* marks broken ones). StartSlotMachine (slot machine seats; arg SLOTS_* marks broken ones).
Also collects the engine text hooks that the port implements natively: Also collects the engine text hooks that the port implements natively:
OpenPokemonCenterPC, PrintBenchGuyText, GymStatues and the Vermilion OpenPokemonCenterPC / OpenRedsPC (the player's storage PC in the
bedroom), PrintBenchGuyText, GymStatues and the Vermilion
Gym GymTrashScript cans (arg = [wGymTrashCanIndex]). For those the Gym GymTrashScript cans (arg = [wGymTrashCanIndex]). For those the
fourth macro argument is the facing direction required to trigger the fourth macro argument is the facing direction required to trigger the
event, except GymTrashScript where it is the can index. event, except GymTrashScript where it is the can index.
@@ -153,7 +154,7 @@ def parse_hidden_events(pokered):
elif arg == "SLOTS_SOMEONESKEYS": elif arg == "SLOTS_SOMEONESKEYS":
state = "keys" state = "keys"
slots.setdefault(current, []).append({"x": x, "y": y, "state": state}) slots.setdefault(current, []).append({"x": x, "y": y, "state": state})
elif func == "OpenPokemonCenterPC": elif func == "OpenPokemonCenterPC" or func == "OpenRedsPC":
extras["pcTiles"].setdefault(current, []).append( extras["pcTiles"].setdefault(current, []).append(
{"x": x, "y": y, "facing": DIRS.get(arg, arg)}) {"x": x, "y": y, "facing": DIRS.get(arg, arg)})
elif func == "PrintBenchGuyText": elif func == "PrintBenchGuyText":
@@ -303,12 +304,63 @@ def parse_card_key_doors(pokered):
util.die(f"card_key.asm: {needle!r} not found (door tiles/blocks changed?)") util.die(f"card_key.asm: {needle!r} not found (door tiles/blocks changed?)")
if n_doors != 22 or len(maps) != 10: if n_doors != 22 or len(maps) != 10:
util.die(f"card key doors: expected 22 doors / 10 maps, got {n_doors}/{len(maps)}") util.die(f"card key doors: expected 22 doors / 10 maps, got {n_doors}/{len(maps)}")
# closedDoors is hand-ported, not extracted: no retail .blk layout places
# a closed-door block at any of the coordinates above (the feature is
# unused/cut in the original game), so there is no ROM or disassembly
# source to derive this from. It restores that cut content by stamping
# facility.bst blocks 0x54/0x5f (2F-10F) or interior.bst block 0x20
# (11F) over each door on map load, opened by that door's
# EVENT_SILPH_CO_n_UNLOCKED_DOORn flag. Kept in sync by hand with
# tools/rom_manifest.json's field.cardKeyDoors.closedDoors.
closed_doors = {
"SILPH_CO_2F": [
{"block": 0x54, "bx": 2, "by": 2, "event": "EVENT_SILPH_CO_2_UNLOCKED_DOOR1", "open": 0x0e},
{"block": 0x54, "bx": 2, "by": 5, "event": "EVENT_SILPH_CO_2_UNLOCKED_DOOR2", "open": 0x0e},
],
"SILPH_CO_3F": [
{"block": 0x5f, "bx": 4, "by": 4, "event": "EVENT_SILPH_CO_3_UNLOCKED_DOOR1", "open": 0x0e},
{"block": 0x5f, "bx": 8, "by": 4, "event": "EVENT_SILPH_CO_3_UNLOCKED_DOOR2", "open": 0x0e},
],
"SILPH_CO_4F": [
{"block": 0x54, "bx": 2, "by": 6, "event": "EVENT_SILPH_CO_4_UNLOCKED_DOOR1", "open": 0x0e},
{"block": 0x54, "bx": 6, "by": 4, "event": "EVENT_SILPH_CO_4_UNLOCKED_DOOR2", "open": 0x0e},
],
"SILPH_CO_5F": [
{"block": 0x5f, "bx": 3, "by": 2, "event": "EVENT_SILPH_CO_5_UNLOCKED_DOOR1", "open": 0x0e},
{"block": 0x5f, "bx": 3, "by": 6, "event": "EVENT_SILPH_CO_5_UNLOCKED_DOOR2", "open": 0x0e},
{"block": 0x5f, "bx": 7, "by": 5, "event": "EVENT_SILPH_CO_5_UNLOCKED_DOOR3", "open": 0x0e},
],
"SILPH_CO_6F": [
{"block": 0x5f, "bx": 2, "by": 6, "event": "EVENT_SILPH_CO_6_UNLOCKED_DOOR", "open": 0x0e},
],
"SILPH_CO_7F": [
{"block": 0x54, "bx": 5, "by": 3, "event": "EVENT_SILPH_CO_7_UNLOCKED_DOOR1", "open": 0x0e},
{"block": 0x54, "bx": 10, "by": 2, "event": "EVENT_SILPH_CO_7_UNLOCKED_DOOR2", "open": 0x0e},
{"block": 0x54, "bx": 10, "by": 6, "event": "EVENT_SILPH_CO_7_UNLOCKED_DOOR3", "open": 0x0e},
],
"SILPH_CO_8F": [
{"block": 0x5f, "bx": 3, "by": 4, "event": "EVENT_SILPH_CO_8_UNLOCKED_DOOR", "open": 0x0e},
],
"SILPH_CO_9F": [
{"block": 0x5f, "bx": 1, "by": 4, "event": "EVENT_SILPH_CO_9_UNLOCKED_DOOR1", "open": 0x0e},
{"block": 0x54, "bx": 9, "by": 2, "event": "EVENT_SILPH_CO_9_UNLOCKED_DOOR2", "open": 0x0e},
{"block": 0x54, "bx": 9, "by": 5, "event": "EVENT_SILPH_CO_9_UNLOCKED_DOOR3", "open": 0x0e},
{"block": 0x5f, "bx": 5, "by": 6, "event": "EVENT_SILPH_CO_9_UNLOCKED_DOOR4", "open": 0x0e},
],
"SILPH_CO_10F": [
{"block": 0x54, "bx": 5, "by": 4, "event": "EVENT_SILPH_CO_10_UNLOCKED_DOOR", "open": 0x0e},
],
"SILPH_CO_11F": [
{"block": 0x20, "bx": 3, "by": 6, "event": "EVENT_SILPH_CO_11_UNLOCKED_DOOR", "open": 0x03},
],
}
return { return {
"maps": maps, # maps where the engine checks for doors "maps": maps, # maps where the engine checks for doors
"doors": doors, # tile coords; block coord = floor(coord/2) "doors": doors, # tile coords; block coord = floor(coord/2)
"doorTiles": [0x18, 0x24], # locked-door tile ids (FACILITY tileset) "doorTiles": [0x18, 0x24], # locked-door tile ids (FACILITY tileset)
"openBlock": 0x0e, # block written over the door's block "openBlock": 0x0e, # block written over the door's block
"silphCo11F": {"doorTile": 0x5e, "openBlock": 0x03}, "silphCo11F": {"doorTile": 0x5e, "openBlock": 0x03},
"closedDoors": closed_doors,
} }
+16
View File
@@ -642,6 +642,22 @@ def extract_overworld_fx(pokered, assets_dir):
"path": f"assets/generated/fx/{base}.png", "path": f"assets/generated/fx/{base}.png",
"width": size[0], "height": size[1], "source": rel, "width": size[0], "height": size[1], "source": rel,
} }
# The cuttable tree's own OAM sprite: InitCutAnimOAM copies
# Overworld_GFX tiles $2d-$2e (top half) and $3d-$3e (bottom half)
# into the sprite tiles the Cut animation slides apart, so the crop
# comes straight out of the overworld tileset sheet (16 tiles/row:
# $2d sits at column 13, row 2).
tree_src = Image.open(os.path.join(pokered, "gfx/tilesets/overworld.png"))
tree = _convert_image(tree_src.crop((104, 16, 120, 32)),
transparent_color0=True)
_save_png(tree, os.path.join(assets_dir, "fx", "cut_tree.png"))
out["cutTree"] = {
"path": "assets/generated/fx/cut_tree.png",
"width": 16, "height": 16,
"source": "gfx/tilesets/overworld.png tiles $2d-$2e/$3d-$3e "
"(engine/overworld/cut.asm InitCutAnimOAM)",
}
return out return out
+63
View File
@@ -4253,6 +4253,48 @@
] ]
}, },
"cardKeyDoors": { "cardKeyDoors": {
"closedDoors": {
"SILPH_CO_2F": [
{ "block": 84, "bx": 2, "by": 2, "event": "EVENT_SILPH_CO_2_UNLOCKED_DOOR1", "open": 14 },
{ "block": 84, "bx": 2, "by": 5, "event": "EVENT_SILPH_CO_2_UNLOCKED_DOOR2", "open": 14 }
],
"SILPH_CO_3F": [
{ "block": 95, "bx": 4, "by": 4, "event": "EVENT_SILPH_CO_3_UNLOCKED_DOOR1", "open": 14 },
{ "block": 95, "bx": 8, "by": 4, "event": "EVENT_SILPH_CO_3_UNLOCKED_DOOR2", "open": 14 }
],
"SILPH_CO_4F": [
{ "block": 84, "bx": 2, "by": 6, "event": "EVENT_SILPH_CO_4_UNLOCKED_DOOR1", "open": 14 },
{ "block": 84, "bx": 6, "by": 4, "event": "EVENT_SILPH_CO_4_UNLOCKED_DOOR2", "open": 14 }
],
"SILPH_CO_5F": [
{ "block": 95, "bx": 3, "by": 2, "event": "EVENT_SILPH_CO_5_UNLOCKED_DOOR1", "open": 14 },
{ "block": 95, "bx": 3, "by": 6, "event": "EVENT_SILPH_CO_5_UNLOCKED_DOOR2", "open": 14 },
{ "block": 95, "bx": 7, "by": 5, "event": "EVENT_SILPH_CO_5_UNLOCKED_DOOR3", "open": 14 }
],
"SILPH_CO_6F": [
{ "block": 95, "bx": 2, "by": 6, "event": "EVENT_SILPH_CO_6_UNLOCKED_DOOR", "open": 14 }
],
"SILPH_CO_7F": [
{ "block": 84, "bx": 5, "by": 3, "event": "EVENT_SILPH_CO_7_UNLOCKED_DOOR1", "open": 14 },
{ "block": 84, "bx": 10, "by": 2, "event": "EVENT_SILPH_CO_7_UNLOCKED_DOOR2", "open": 14 },
{ "block": 84, "bx": 10, "by": 6, "event": "EVENT_SILPH_CO_7_UNLOCKED_DOOR3", "open": 14 }
],
"SILPH_CO_8F": [
{ "block": 95, "bx": 3, "by": 4, "event": "EVENT_SILPH_CO_8_UNLOCKED_DOOR", "open": 14 }
],
"SILPH_CO_9F": [
{ "block": 95, "bx": 1, "by": 4, "event": "EVENT_SILPH_CO_9_UNLOCKED_DOOR1", "open": 14 },
{ "block": 84, "bx": 9, "by": 2, "event": "EVENT_SILPH_CO_9_UNLOCKED_DOOR2", "open": 14 },
{ "block": 84, "bx": 9, "by": 5, "event": "EVENT_SILPH_CO_9_UNLOCKED_DOOR3", "open": 14 },
{ "block": 95, "bx": 5, "by": 6, "event": "EVENT_SILPH_CO_9_UNLOCKED_DOOR4", "open": 14 }
],
"SILPH_CO_10F": [
{ "block": 84, "bx": 5, "by": 4, "event": "EVENT_SILPH_CO_10_UNLOCKED_DOOR", "open": 14 }
],
"SILPH_CO_11F": [
{ "block": 32, "bx": 3, "by": 6, "event": "EVENT_SILPH_CO_11_UNLOCKED_DOOR", "open": 3 }
]
},
"doorTiles": [ "doorTiles": [
24, 24,
36 36
@@ -5656,6 +5698,13 @@
"y": 3 "y": 3
} }
], ],
"REDS_HOUSE_2F": [
{
"facing": "up",
"x": 0,
"y": 1
}
],
"ROCK_TUNNEL_POKECENTER": [ "ROCK_TUNNEL_POKECENTER": [
{ {
"facing": "up", "facing": "up",
@@ -6373,6 +6422,11 @@
"path": "assets/generated/fx/battle_transition.png", "path": "assets/generated/fx/battle_transition.png",
"width": 8 "width": 8
}, },
"cutTree": {
"height": 16,
"path": "assets/generated/fx/cut_tree.png",
"width": 16
},
"fishingRod": { "fishingRod": {
"height": 24, "height": 24,
"path": "assets/generated/fx/fishing_rod.png", "path": "assets/generated/fx/fishing_rod.png",
@@ -44689,6 +44743,15 @@
"TRI_ATTACK", "TRI_ATTACK",
"SUBSTITUTE" "SUBSTITUTE"
], ],
"trainerPartyOverrides": {
"OPP_CHIEF": [
{ "level": 41, "species": "MACHOKE" },
{ "level": 41, "species": "GOLBAT" },
{ "level": 43, "species": "MAROWAK" },
{ "level": 43, "species": "WEEZING" },
{ "level": 46, "species": "PERSIAN" }
]
},
"trainerPics": [ "trainerPics": [
{ {
"imageBase": "youngster", "imageBase": "youngster",