mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-23 14:07:34 +02:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 09b12a7cf0 | |||
| e2f2b432ff | |||
| 8c274d969e | |||
| 4c7633b56e | |||
| 943ae23e73 | |||
| 839cf19088 | |||
| 5019ba2caf | |||
| 0b267fc536 | |||
| 290bde1c39 | |||
| 7e92e54669 | |||
| b89b895962 | |||
| 4311955c43 | |||
| 747d9acad1 | |||
| 5041125751 | |||
| 15029d811f |
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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/" \;
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -126,14 +126,14 @@ function ImageWriter.columnsToRows(raw, tilesWide, tilesHigh, bytesPerTile)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function ImageWriter.save(image, path)
|
function ImageWriter.save(image, path)
|
||||||
local parent = path:match("^(.*)/[^/]+$")
|
|
||||||
if parent then
|
|
||||||
local ok, err = love.filesystem.createDirectory(parent)
|
|
||||||
if not ok then error("could not create " .. parent .. ": " .. tostring(err)) end
|
|
||||||
end
|
|
||||||
local ok, fileData = pcall(image.encode, image, "png")
|
local ok, fileData = pcall(image.encode, image, "png")
|
||||||
if not ok then error("could not encode " .. path .. ": " .. tostring(fileData)) end
|
if not ok then error("could not encode " .. path .. ": " .. tostring(fileData)) end
|
||||||
local written, writeError = love.filesystem.write(path, fileData)
|
-- CacheFs routes this to the OS save directory (normal builds) or straight
|
||||||
|
-- into the game folder (portable installs), creating parent directories as
|
||||||
|
-- needed. io.* needs the bytes as a string; love.filesystem would also
|
||||||
|
-- take the FileData, but getString() keeps one code path.
|
||||||
|
local CacheFs = require("src.import.CacheFs")
|
||||||
|
local written, writeError = CacheFs.write(path, fileData:getString())
|
||||||
if not written then
|
if not written then
|
||||||
error("could not write " .. path .. ": " .. tostring(writeError))
|
error("could not write " .. path .. ": " .. tostring(writeError))
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -87,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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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 = {},
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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'
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
|
||||||
@@ -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
@@ -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
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user