mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-13 00:40:59 +02:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0b267fc536 | |||
| 7e92e54669 | |||
| 4311955c43 | |||
| 747d9acad1 | |||
| 5041125751 | |||
| 15029d811f | |||
| 2c2a5a4220 | |||
| f64666c6ce | |||
| 3f4aaccbf5 | |||
| 20ad4e6c41 | |||
| 60b3cebdce | |||
| 9468ffccdf | |||
| 948ae8abbd |
@@ -1,7 +1,7 @@
|
||||
name: Release
|
||||
|
||||
# Builds the macOS and Windows desktop apps on the self-hosted Mac runner
|
||||
# and publishes them as a GitHub Release.
|
||||
# Builds the macOS, Windows, and Linux desktop apps plus an Android APK
|
||||
# on the self-hosted Mac runner, and publishes them as a GitHub Release.
|
||||
#
|
||||
# Versioning:
|
||||
# - First ever release is 0.1.0.
|
||||
@@ -152,14 +152,20 @@ jobs:
|
||||
echo "Identities available to codesign:"
|
||||
security find-identity -v -p codesigning "$KEYCHAIN_PATH"
|
||||
|
||||
- name: Build macOS + Windows
|
||||
- name: Build macOS + Windows + Linux
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Sign in-build (identity auto-detected from the temp keychain);
|
||||
# notarize separately below so it uses secret credentials, not a
|
||||
# login-keychain profile.
|
||||
# login-keychain profile. "all" also builds the Linux AppImage,
|
||||
# which needs no signing/notarization.
|
||||
scripts/build.sh all --version "${{ steps.ver.outputs.version }}" --no-notarize
|
||||
|
||||
- name: Build Android
|
||||
run: |
|
||||
set -euo pipefail
|
||||
scripts/build_android.sh --version "${{ steps.ver.outputs.version }}"
|
||||
|
||||
- name: Notarize & staple macOS app
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -204,6 +210,10 @@ jobs:
|
||||
mkdir -p "$outdir"
|
||||
cp "dist/mac/PokemonRed-macos.zip" "$outdir/PokemonRed-${v}-macos.zip"
|
||||
cp "dist/win/PokemonRed-win64.zip" "$outdir/PokemonRed-${v}-windows.zip"
|
||||
cp "dist/linux/PokemonRed-linux.zip" "$outdir/PokemonRed-${v}-linux.zip"
|
||||
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"
|
||||
|
||||
- name: Publish GitHub Release
|
||||
@@ -254,7 +264,9 @@ jobs:
|
||||
--title "$v" \
|
||||
--notes "$notes" \
|
||||
"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"
|
||||
|
||||
|
||||
@@ -45,6 +45,27 @@ then `love .` for later launches. Windows PowerShell scripts, the optional
|
||||
developer data build, test suites, and cache management are covered in
|
||||
[Developer Setup](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Guide-Developer-Setup).
|
||||
|
||||
## Portable Mode
|
||||
|
||||
By default the game keeps your save, options, and the private ROM-derived
|
||||
data cache in your OS's normal per-user app data folder. To keep everything
|
||||
next to the game instead (handy for a USB stick or portable drive you carry
|
||||
between computers), drop an empty file named `portable.txt` next to the
|
||||
executable (or next to `main.lua`/`conf.lua` when running from source), then
|
||||
launch the game.
|
||||
|
||||
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.
|
||||
- After a ROM import, the generated `data/generated` and `assets/generated`
|
||||
cache is copied into that folder too, so a later launch (even on a
|
||||
different computer, as long as the same folder comes along) reuses it
|
||||
without asking for the ROM again.
|
||||
- Deleting `portable.txt` switches back to the normal OS save directory; nothing
|
||||
already written to either location is touched automatically, so copy files
|
||||
over yourself if you want to carry existing progress across the switch.
|
||||
|
||||
## Modding
|
||||
|
||||
The game ships a native mod platform: content registries, events and hooks,
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
function love.conf(t)
|
||||
local editor = os.getenv("POKEPORT_EDITOR") == "1"
|
||||
local developer = os.getenv("POKEPORT_DEV") == "1"
|
||||
if arg then
|
||||
for _, a in ipairs(arg) do
|
||||
if a == "--editor" then editor = true end
|
||||
if a == "--developer" then developer = true end
|
||||
end
|
||||
end
|
||||
-- main.lua runs in the same Lua state right after conf.lua; stash the
|
||||
-- decision in a global so it doesn't need to reparse `arg`.
|
||||
_G.POKEPORT_EDITOR_MODE = editor
|
||||
_G.POKEPORT_DEV_MODE = developer
|
||||
|
||||
if editor then
|
||||
t.identity = os.getenv("POKEPORT_IDENTITY") or "pokemon-love2d-editor"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
-- museum, YOUNGSTER walking the player to the gym) is scripted NPC
|
||||
-- movement + a wPewterCityCurScript state machine that steers the
|
||||
-- player off-map; that part is already covered on this map by
|
||||
-- story5.lua's onStep gate (walks the player back a step at the
|
||||
-- east exit before EVENT_BEAT_BROCK). Here we only port the real
|
||||
-- YES/NO-branched flavor text these NPCs speak when talked to.
|
||||
-- player off-map; the YOUNGSTER's escort to the gym is handled on this
|
||||
-- map by story5.lua's onStep gate at the east exit (before
|
||||
-- EVENT_BEAT_BROCK), while the SUPER_NERD1 museum escort is not ported.
|
||||
-- Here we only port the real YES/NO-branched flavor text these NPCs
|
||||
-- speak when talked to.
|
||||
|
||||
local M = {}
|
||||
|
||||
|
||||
+43
-35
@@ -10,8 +10,8 @@
|
||||
-- starter -> taunt + battle OPP_RIVAL1 with the counter-pick party
|
||||
-- (player Bulbasaur -> rival Charmander etc., parties 1/2/3 =
|
||||
-- Squirtle/Bulbasaur/Charmander in data/trainers/parties.asm);
|
||||
-- afterwards he gloats or sulks and marches out of the lab
|
||||
-- (OaksLabRivalBattleEndScript).
|
||||
-- afterwards HealParty + flag always, then he gloats or sulks and
|
||||
-- marches out (OaksLabRivalEndBattleScript). A loss does not black out.
|
||||
|
||||
-- ball objects: CHARMANDER (6,3), SQUIRTLE (7,3), BULBASAUR (8,3);
|
||||
-- rival = object 1 at (4,3). rivalBallX is the counter-pick's column.
|
||||
@@ -19,32 +19,35 @@ local function starterBall(askText, species, choseFlag, ownBall,
|
||||
rivalBallX, rivalBall)
|
||||
return {
|
||||
{ "check_flag", "EVENT_GOT_STARTER" }, -- 1
|
||||
{ "jump_if_true", 19 }, -- 2
|
||||
{ "jump_if_true", 20 }, -- 2
|
||||
-- no picking until Oak has walked you in (OaksLabScript gating)
|
||||
{ "check_flag", "EVENT_FOLLOWED_OAK_INTO_LAB" }, -- 3
|
||||
{ "jump_if_false", 19 }, -- 4
|
||||
{ "ask", askText }, -- 5
|
||||
{ "jump_if_false", 20 }, -- 6
|
||||
{ "give_pokemon", species, 5 }, -- 7
|
||||
{ "set_flag", "EVENT_GOT_STARTER" }, -- 8
|
||||
{ "set_flag", choseFlag }, -- 9
|
||||
{ "jump_if_false", 20 }, -- 4
|
||||
-- the Pokédex "new species" entry shows before the ask (predef
|
||||
-- StarterDex ahead of OaksLabYouWant...Text)
|
||||
{ "push_screen", "DexEntryMenu", species }, -- 5
|
||||
{ "ask", askText }, -- 6
|
||||
{ "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
|
||||
-- them later, at OaksLabOak1Text's .give_poke_balls beat once the
|
||||
-- player has beaten the Route 22 rival (see TEXT_OAKSLAB_OAK1 below)
|
||||
{ "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 10
|
||||
{ "hide_object", "OAKS_LAB", ownBall }, -- 11
|
||||
{ "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 11
|
||||
{ "hide_object", "OAKS_LAB", ownBall }, -- 12
|
||||
-- the rival walks to the countering ball (around the furniture)
|
||||
{ "move_npc_to", 1, rivalBallX, 4 }, -- 12
|
||||
{ "face_object", 1, "up" }, -- 13
|
||||
{ "show_text", "_OaksLabRivalIllTakeThisOneText" }, -- 14
|
||||
{ "hide_object", "OAKS_LAB", rivalBall }, -- 15
|
||||
{ "move_npc_to", 1, rivalBallX, 4 }, -- 13
|
||||
{ "face_object", 1, "up" }, -- 14
|
||||
{ "show_text", "_OaksLabRivalIllTakeThisOneText" }, -- 15
|
||||
{ "hide_object", "OAKS_LAB", rivalBall }, -- 16
|
||||
{ "show_text", "_OaksLabRivalReceivedMonText",
|
||||
{ RAM = rivalBall == "OAKSLAB_CHARMANDER_POKE_BALL" and "CHARMANDER"
|
||||
or rivalBall == "OAKSLAB_SQUIRTLE_POKE_BALL" and "SQUIRTLE"
|
||||
or "BULBASAUR" } }, -- 16
|
||||
{ "jump", 20 }, -- 17
|
||||
{ "jump", 20 }, -- 18 (spacer)
|
||||
{ "show_text", "_OaksLabThoseArePokeBallsText" }, -- 19
|
||||
or "BULBASAUR" } }, -- 17
|
||||
{ "jump", 21 }, -- 18
|
||||
{ "jump", 21 }, -- 19 (spacer)
|
||||
{ "show_text", "_OaksLabThoseArePokeBallsText" }, -- 20
|
||||
}
|
||||
end
|
||||
|
||||
@@ -107,9 +110,9 @@ return {
|
||||
TEXT_OAKSLAB_RIVAL = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_GOT_STARTER" }, -- 2
|
||||
{ "jump_if_false", 20 }, -- 3
|
||||
{ "jump_if_false", 21 }, -- 3
|
||||
{ "check_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" }, -- 4
|
||||
{ "jump_if_true", 18 }, -- 5
|
||||
{ "jump_if_true", 19 }, -- 5
|
||||
{ "show_text", "_OaksLabRivalMyPokemonLooksStrongerText" }, -- 6
|
||||
{ "check_flag", "EVENT_CHOSE_BULBASAUR" }, -- 7
|
||||
{ "jump_if_false", 11 }, -- 8
|
||||
@@ -120,18 +123,20 @@ return {
|
||||
{ "start_battle", "trainer", "OPP_RIVAL1", 2 }, -- 13 Bulbasaur
|
||||
{ "jump", 16 }, -- 14
|
||||
{ "start_battle", "trainer", "OPP_RIVAL1", 1 }, -- 15 Squirtle
|
||||
{ "set_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" }, -- 16
|
||||
{ "jump", 21 }, -- 17
|
||||
{ "show_text", "_OaksLabRivalFedUpWithWaitingText" }, -- 18
|
||||
{ "jump", 25 }, -- 19
|
||||
{ "show_text", "_OaksLabRivalGrampsIsntAroundText" }, -- 20
|
||||
-- battle aftermath: on a win the rival sulks and marches out
|
||||
-- (OaksLabRivalBattleEnd); on a loss the blackout already warped
|
||||
-- us away, so the script just ends
|
||||
{ "jump_if_false", 25 }, -- 21
|
||||
{ "show_text", "_OaksLabRivalIPickedTheWrongPokemonText" }, -- 22
|
||||
{ "move_npc_to", 1, 4, 11 }, -- 23
|
||||
{ "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" }, -- 24 (25 = end)
|
||||
-- OaksLabRivalEndBattleScript: HealParty + flag, then exit either way
|
||||
{ "heal_party" }, -- 16
|
||||
{ "set_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" }, -- 17
|
||||
{ "jump", 23 }, -- 18
|
||||
{ "show_text", "_OaksLabRivalFedUpWithWaitingText" }, -- 19
|
||||
{ "jump", "end" }, -- 20
|
||||
{ "show_text", "_OaksLabRivalGrampsIsntAroundText" }, -- 21
|
||||
{ "jump", "end" }, -- 22
|
||||
-- win: sulk text then exit; loss: Rival1WinText already played in
|
||||
-- battle (HandlePlayerBlackOut), so skip straight to the walk-out
|
||||
{ "jump_if_false", 25 }, -- 23
|
||||
{ "show_text", "_OaksLabRivalIPickedTheWrongPokemonText" }, -- 24
|
||||
{ "move_npc_to", 1, 4, 11 }, -- 25
|
||||
{ "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" }, -- 26
|
||||
},
|
||||
},
|
||||
|
||||
@@ -178,9 +183,12 @@ return {
|
||||
local party = flags.EVENT_CHOSE_BULBASAUR and 3
|
||||
or flags.EVENT_CHOSE_SQUIRTLE and 2 or 1
|
||||
table.insert(rows, { "start_battle", "trainer", "OPP_RIVAL1", party })
|
||||
-- OaksLabRivalEndBattleScript: heal + flag on win or loss; no blackout
|
||||
table.insert(rows, { "heal_party" })
|
||||
table.insert(rows, { "set_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" })
|
||||
-- a loss blacks out to another map: end the script there
|
||||
table.insert(rows, { "jump_if_false", base + 7 })
|
||||
-- win: sulk text then exit; loss jumps to the walk-out (taunt was
|
||||
-- already shown in-battle via Rival1WinText)
|
||||
table.insert(rows, { "jump_if_false", base + 6 })
|
||||
table.insert(rows, { "show_text", "_OaksLabRivalIPickedTheWrongPokemonText" })
|
||||
table.insert(rows, { "move_npc_to", 1, 4, 11 })
|
||||
table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" })
|
||||
|
||||
@@ -10,15 +10,39 @@
|
||||
-- The girl, fisher and the four signs resolve automatically through the
|
||||
-- extracted text pointers (no script needed).
|
||||
|
||||
-- Once the HALL OF FAME has been reached (EVENT_BEAT_CHAMPION_RIVAL, set
|
||||
-- alongside Commands.record_hall_of_fame) Oak offers the ProfOakData
|
||||
-- battle (data/trainers/parties.asm) -- three teams picked by the
|
||||
-- player's starter, mirroring the rival's type-advantage lineup, that go
|
||||
-- unused in the original ROM.
|
||||
return {
|
||||
talk = {
|
||||
TEXT_PALLETTOWN_OAK = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_GOT_STARTER" }, -- 2
|
||||
{ "jump_if_true", 6 }, -- 3
|
||||
{ "show_text", "_PalletTownOakHeyWaitDontGoOutText" },-- 4
|
||||
{ "jump", 7 }, -- 5
|
||||
{ "show_text", "_PalletTownOakItsUnsafeText" }, -- 6
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_BEAT_CHAMPION_RIVAL" }, -- 2
|
||||
{ "jump_if_false", 20 }, -- 3
|
||||
{ "check_flag", "EVENT_BEAT_PROF_OAK" }, -- 4
|
||||
{ "jump_if_true", 20 }, -- 5
|
||||
{ "show_text", "OAK: So you want\nto test your\nskills on me?\f"
|
||||
.. "Very well! Let\nme show you what\na real trainer\ncan do!" }, -- 6
|
||||
{ "check_flag", "EVENT_CHOSE_BULBASAUR" }, -- 7
|
||||
{ "jump_if_false", 11 }, -- 8
|
||||
{ "start_battle", "trainer", "OPP_PROF_OAK", 3 }, -- 9 CHARIZARD
|
||||
{ "jump", 16 }, -- 10
|
||||
{ "check_flag", "EVENT_CHOSE_SQUIRTLE" }, -- 11
|
||||
{ "jump_if_false", 15 }, -- 12
|
||||
{ "start_battle", "trainer", "OPP_PROF_OAK", 2 }, -- 13 VENUSAUR
|
||||
{ "jump", 16 }, -- 14
|
||||
{ "start_battle", "trainer", "OPP_PROF_OAK", 1 }, -- 15 BLASTOISE
|
||||
{ "jump_if_false", "end" }, -- 16
|
||||
{ "set_flag", "EVENT_BEAT_PROF_OAK" }, -- 17
|
||||
{ "show_text", "OAK: Impressive!\nYou truly are a\nPOKéMON MASTER!" }, -- 18
|
||||
{ "jump", "end" }, -- 19
|
||||
{ "check_flag", "EVENT_GOT_STARTER" }, -- 20
|
||||
{ "jump_if_true", 24 }, -- 21
|
||||
{ "show_text", "_PalletTownOakHeyWaitDontGoOutText" }, -- 22
|
||||
{ "jump", "end" }, -- 23
|
||||
{ "show_text", "_PalletTownOakItsUnsafeText" }, -- 24
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
+195
-27
@@ -133,22 +133,70 @@ M.BLUES_HOUSE = {
|
||||
|
||||
M.BILLS_HOUSE = {
|
||||
talk = {
|
||||
TEXT_BILLSHOUSE_BILL_POKEMON = {
|
||||
{ "check_flag", "EVENT_GOT_SS_TICKET" }, -- 1
|
||||
{ "jump_if_true", 13 }, -- 2
|
||||
{ "show_text", "_BillsHouseBillImNotAPokemonText" }, -- 3
|
||||
{ "show_text", "_BillsHouseBillNoYouGottaHelpText" }, -- 4
|
||||
-- the cell-separator PC throws its switch
|
||||
-- (bills_house_pc.asm BillsHouseInitiatedText: SFX_SWITCH)
|
||||
{ "play_sound", "Switch" }, -- 5
|
||||
{ "show_text", "_BillsHouseBillThankYouText" }, -- 6
|
||||
-- BillsHouseBillPokemonText: "I'm not a POKéMON!", a YES/NO choice
|
||||
-- (NO only adds "No, you gotta help!" before rejoining the YES
|
||||
-- path), then the "get in the TELEPORTER" line and the monster
|
||||
-- walking into the cell-separator machine
|
||||
-- (BillsHousePokemonWalkToMachineScript: up 3, or around the player
|
||||
-- when they stand in the way facing down), where it is hidden and
|
||||
-- EVENT_BILL_SAID_USE_CELL_SEPARATOR arms the PC at (1,4) -- see
|
||||
-- OverworldState.billsHousePC for the separator itself.
|
||||
TEXT_BILLSHOUSE_BILL_POKEMON = function(game, ow, npc, done)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local t = game.data.text
|
||||
local function toMachine()
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._BillsHouseBillUseSeparationSystemText
|
||||
or "When I'm in the\nTELEPORTER, run\nthe Cell\nSeparation System!", function()
|
||||
local function entered()
|
||||
local Commands = require("src.script.Commands")
|
||||
Commands.hide_object({ game = game, save = game.save,
|
||||
overworld = ow },
|
||||
"BILLS_HOUSE", "BILLSHOUSE_BILL_POKEMON")
|
||||
game.save.flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = true
|
||||
done()
|
||||
end
|
||||
if ow.player.facing == "down" then
|
||||
-- the player is standing on his straight path: walk around
|
||||
-- (.PokemonWalkAroundPlayerMovement)
|
||||
ow:scriptMove(npc, "right", 1, function()
|
||||
ow:scriptMove(npc, "up", 2, function()
|
||||
ow:scriptMove(npc, "left", 1, function()
|
||||
ow:scriptMove(npc, "up", 1, entered)
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
else
|
||||
ow:scriptMove(npc, "up", 3, entered)
|
||||
end
|
||||
end))
|
||||
end
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._BillsHouseBillImNotAPokemonText or "Hey! I'm not a\nPOKéMON!",
|
||||
nil, { choice = function(yes)
|
||||
if yes then
|
||||
toMachine()
|
||||
else
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._BillsHouseBillNoYouGottaHelpText
|
||||
or "No! You gotta\nhelp me!", toMachine))
|
||||
end
|
||||
end }))
|
||||
end,
|
||||
|
||||
-- BillsHouseBillSSTicketText (human Bill after the separation)
|
||||
TEXT_BILLSHOUSE_BILL_SS_TICKET = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_GOT_SS_TICKET" }, -- 2
|
||||
{ "jump_if_true", 12 }, -- 3
|
||||
{ "show_text", "_BillsHouseBillThankYouText" }, -- 4
|
||||
-- pokered gives first (GiveItem fills wStringBuffer), then prints
|
||||
-- the received text that reads it (scripts/BillsHouse.asm; the
|
||||
-- item id is S_S_TICKET in generated items.lua -- keyItem, so the
|
||||
-- sound_get_key_item jingle plays like BillsHouse.asm:196)
|
||||
{ "give_item", "S_S_TICKET", 1, false }, -- 7
|
||||
{ "show_text", "_SSTicketReceivedText" }, -- 8
|
||||
{ "set_flag", "EVENT_GOT_SS_TICKET" }, -- 9
|
||||
{ "give_item", "S_S_TICKET", 1, false }, -- 5
|
||||
{ "show_text", "_SSTicketReceivedText" }, -- 6
|
||||
{ "set_flag", "EVENT_GOT_SS_TICKET" }, -- 7
|
||||
-- The two Cerulean guards are a SWAP PAIR, not scenery
|
||||
-- (BillsHouse.asm:174-178): handing over the ticket shows GUARD1 at
|
||||
-- (28,12) and hides GUARD2 at (27,12). This matters far more than it
|
||||
@@ -156,16 +204,36 @@ M.BILLS_HOUSE = {
|
||||
-- house's south door at (27,11), and that house is one of the two
|
||||
-- ways through the fence that splits Cerulean in half (the badge
|
||||
-- house is the other). Leaving GUARD2 up forever severs the city --
|
||||
-- the gym/mart half can never reach the Route 5 exit -- which is
|
||||
-- exactly what stranded the bot after it beat Misty.
|
||||
-- the gym/mart half can never reach the Route 5 exit.
|
||||
-- Same swap fires after the TM28 Rocket (CeruleanCity_2.asm
|
||||
-- CeruleanHideRocket), so either route opens the path.
|
||||
{ "show_object", "CERULEAN_CITY", "CERULEANCITY_GUARD1" }, -- 10
|
||||
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_GUARD2" }, -- 11
|
||||
{ "jump", 14 }, -- 12
|
||||
{ "show_text", "_BillsHouseBillCheckOutMyRarePokemonText" }, -- 13
|
||||
{ "show_object", "CERULEAN_CITY", "CERULEANCITY_GUARD1" }, -- 8
|
||||
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_GUARD2" }, -- 9
|
||||
{ "show_text", "_BillsHouseBillWhyDontYouGoInsteadOfMeText" }, -- 10
|
||||
{ "jump", 13 }, -- 11
|
||||
{ "show_text", "_BillsHouseBillWhyDontYouGoInsteadOfMeText" }, -- 12
|
||||
},
|
||||
|
||||
TEXT_BILLSHOUSE_BILL_CHECK_OUT_MY_RARE_POKEMON = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "show_text", "_BillsHouseBillCheckOutMyRarePokemonText" }, -- 2
|
||||
},
|
||||
},
|
||||
-- repair saves that already got the ticket under the old collapsed
|
||||
-- script (the monster never hidden, human Bill never shown)
|
||||
onEnter = function(game, ow)
|
||||
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
|
||||
puz.first = love.math.random(0, 7) * 2
|
||||
end,
|
||||
-- VermilionCityDefaultScript's per-frame SSAnneTicketCheckCoords check:
|
||||
-- the unguarded cell (18,30) just west of the sailor leads straight
|
||||
-- onto the dock warp, 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 = {
|
||||
-- the sailor guarding the dock gangway
|
||||
-- the sailor guarding the dock gangway (VermilionCitySailor1Text):
|
||||
-- flashing the ticket just lets you through -- he never hides, and
|
||||
-- once the ship has sailed he only reports it gone
|
||||
TEXT_VERMILIONCITY_SAILOR1 = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "show_text", "_VermilionCitySailor1WelcomeToSSAnneText" }, -- 2
|
||||
{ "check_item", "S_S_TICKET" }, -- 3
|
||||
{ "jump_if_false", 8 }, -- 4
|
||||
{ "show_text", "_VermilionCitySailor1FlashedTicketText" }, -- 5
|
||||
{ "hide_object", "VERMILION_CITY", "VERMILIONCITY_SAILOR1" }, -- 6
|
||||
{ "jump", 9 }, -- 7
|
||||
{ "show_text", "_VermilionCitySailor1YouNeedATicketText" }, -- 8
|
||||
{ "check_flag", "EVENT_SS_ANNE_LEFT" }, -- 2
|
||||
{ "jump_if_true", 11 }, -- 3
|
||||
{ "show_text", "_VermilionCitySailor1WelcomeToSSAnneText" }, -- 4
|
||||
{ "check_item", "S_S_TICKET" }, -- 5
|
||||
{ "jump_if_false", 9 }, -- 6
|
||||
{ "show_text", "_VermilionCitySailor1FlashedTicketText" }, -- 7
|
||||
{ "jump", 12 }, -- 8
|
||||
{ "show_text", "_VermilionCitySailor1YouNeedATicketText" }, -- 9
|
||||
{ "jump", 12 }, -- 10
|
||||
{ "show_text", "_VermilionCitySailor1ShipSetSailText" }, -- 11
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -252,7 +351,17 @@ M.POKEMON_TOWER_7F = {
|
||||
{ "show_object", "MR_FUJIS_HOUSE", "MRFUJISHOUSE_MR_FUJI" }, -- 5
|
||||
{ "hide_object", "SAFFRON_CITY", "SAFFRONCITY_ROCKET8" }, -- 6
|
||||
{ "show_object", "SAFFRON_CITY", "SAFFRONCITY_ROCKET9" }, -- 7
|
||||
{ "warp", "MR_FUJIS_HOUSE", 3, 3, "down" }, -- 8
|
||||
-- pokered warps to wDestinationWarpID $1 (0-based) -- the house's
|
||||
-- SECOND warp, the door mat at (3,7) -- facing UP
|
||||
-- (PokemonTower7FWarpToMrFujiHouseScript: SPRITE_FACING_UP +
|
||||
-- hWarpDestinationMap MR_FUJIS_HOUSE). Landing (3,3) instead put
|
||||
-- the player at the Pokédex table, and the route's first waypoint
|
||||
-- (3,7) then stepped onto a LIVE door mat and exited the house
|
||||
-- before ever talking to Fuji -- so the POKE_FLUTE was never
|
||||
-- collected and the Route 16 SNORLAX sealed the map. The arrival
|
||||
-- mat itself is inert until stepped off (warpEntryCell), which is
|
||||
-- what makes the vanilla coordinates safe.
|
||||
{ "warp", "MR_FUJIS_HOUSE", 3, 7, "up" }, -- 8
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -387,6 +496,50 @@ M.WARDENS_HOUSE = {
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
M.SILPH_CO_11F = {
|
||||
-- Giovanni's battle is a COORDINATE TRIGGER, not a talk.
|
||||
-- SilphCo11FDefaultScript (scripts/SilphCo11F.asm) checks
|
||||
-- .PlayerCoordsArray -- (6,13) and (7,12) -- every frame while
|
||||
-- EVENT_BEAT_SILPH_CO_GIOVANNI is unset: standing there shows his text,
|
||||
-- walks him three tiles down (.GiovanniMovement), and starts the fight.
|
||||
-- He also has no trainer-header entry, so sight engagement never fires
|
||||
-- either. Without this hook he was a talk-only statue four tiles away
|
||||
-- from anything the route (or a vanilla-faithful player walking the same
|
||||
-- line) would touch, and the whole Silph ending -- the flag, the Master
|
||||
-- Ball, the Saffron streets clearing -- silently never happened.
|
||||
--
|
||||
-- engageTrainer shows TEXT_SILPHCO11F_GIOVANNI as the battle text and,
|
||||
-- via victories.lua OPP_GIOVANNI#2, sets the event on a win; a loss
|
||||
-- sets nothing, so the trigger re-arms exactly as vanilla does.
|
||||
onStep = function(game, ow, x, y)
|
||||
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then return false end
|
||||
if not ((x == 6 and y == 13) or (x == 7 and y == 12)) then return false end
|
||||
local gio
|
||||
for _, npc in ipairs(ow.npcs) do
|
||||
if npc.def and npc.def.name == "SILPHCO11F_GIOVANNI" then gio = npc break end
|
||||
end
|
||||
if not gio or ow:trainerDefeated(gio) then return false end
|
||||
ow:scriptMove(gio, "down", 3, function()
|
||||
gio:facePlayer(ow.player)
|
||||
ow:engageTrainer(gio, function()
|
||||
-- SilphCo11FTeamRocketLeavesScript: Giovanni leaves the floor
|
||||
-- after the loss (the street rockets are handled by
|
||||
-- M.SAFFRON_CITY.onEnter in story4.lua).
|
||||
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { game = game, save = game.save, overworld = ow }
|
||||
Commands.hide_object(ctx, "SILPH_CO_11F", "SILPHCO11F_GIOVANNI")
|
||||
end
|
||||
end)
|
||||
end)
|
||||
return true
|
||||
end,
|
||||
onEnter = function(game, ow)
|
||||
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { game = game, save = game.save, overworld = ow }
|
||||
Commands.hide_object(ctx, "SILPH_CO_11F", "SILPHCO11F_GIOVANNI")
|
||||
end
|
||||
end,
|
||||
talk = {
|
||||
TEXT_SILPHCO11F_SILPH_PRESIDENT = {
|
||||
{ "face_player" }, -- 1
|
||||
@@ -611,6 +764,21 @@ M.POKEMON_TOWER_2F = {
|
||||
{ "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,
|
||||
}
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
+49
-7
@@ -308,8 +308,12 @@ local function saffronGate(guardText, triggers, horizontal)
|
||||
end
|
||||
if takeGuardDrink(game) then
|
||||
game.stack:push(TextBox.new(game,
|
||||
(t._SaffronGateGuardYouCanGoOnThroughText or
|
||||
"Thanks! You can\ngo on through!"), done))
|
||||
t._SaffronGateGuardImParchedText or "Whoa, boy!\nI'm parched!",
|
||||
function()
|
||||
game.stack:push(TextBox.new(game,
|
||||
(t._SaffronGateGuardYouCanGoOnThroughText or
|
||||
"You can go on\nthrough!"), done))
|
||||
end))
|
||||
return
|
||||
end
|
||||
game.stack:push(TextBox.new(game,
|
||||
@@ -344,8 +348,12 @@ local function saffronGate(guardText, triggers, horizontal)
|
||||
-- for the POKE_FLUTE at all.
|
||||
if takeGuardDrink(game) then
|
||||
game.stack:push(TextBox.new(game,
|
||||
(t._SaffronGateGuardYouCanGoOnThroughText or
|
||||
"Thanks! You can\ngo on through!")))
|
||||
t._SaffronGateGuardImParchedText or "Whoa, boy!\nI'm parched!",
|
||||
function()
|
||||
game.stack:push(TextBox.new(game,
|
||||
(t._SaffronGateGuardYouCanGoOnThroughText or
|
||||
"You can go on\nthrough!")))
|
||||
end))
|
||||
return true
|
||||
end
|
||||
local back
|
||||
@@ -355,7 +363,7 @@ local function saffronGate(guardText, triggers, horizontal)
|
||||
back = ow.player.facing == "up" and "down" or "up"
|
||||
end
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._SaffronGateGuardImParchedText or "I'm parched...\nNo entry until\nI get a drink!",
|
||||
t._SaffronGateGuardGeeImThirstyText or "Gee, I'm thirsty\nthough!\nThe road's closed.",
|
||||
function()
|
||||
ow:scriptMove(ow.player, back, 1)
|
||||
end))
|
||||
@@ -417,6 +425,26 @@ M.BIKE_SHOP = {
|
||||
-- the Cinnabar lab (the wait is skipped).
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
-- The Super Nerd (object index 1) claims both fossils and blocks the way
|
||||
-- to them. In scripts/MtMoonB2F.asm he isn't a sight-line trainer: his
|
||||
-- header carries no range, so MtMoonB2FDefaultScript force-triggers the
|
||||
-- battle the instant the player steps onto (13,8) -- the chokepoint tile
|
||||
-- to his right -- and reaching for a fossil intercepts you too.
|
||||
local function superNerdBeaten(ow)
|
||||
local nerd = ow:npcByIndex(1)
|
||||
return not nerd or ow:trainerDefeated(nerd)
|
||||
end
|
||||
|
||||
local function engageSuperNerd(game, ow, onDone)
|
||||
local nerd = ow:npcByIndex(1)
|
||||
if not nerd or ow:trainerDefeated(nerd) then
|
||||
if onDone then onDone() end
|
||||
return
|
||||
end
|
||||
nerd:facePlayer(ow.player)
|
||||
ow:engageTrainer(nerd, onDone)
|
||||
end
|
||||
|
||||
local function mtMoonFossil(itemId, otherName)
|
||||
return function(game, ow, npc, done)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
@@ -425,7 +453,13 @@ local function mtMoonFossil(itemId, otherName)
|
||||
game.stack:push(TextBox.new(game, "You already took\na fossil.", done))
|
||||
return
|
||||
end
|
||||
game.stack:push(TextBox.new(game, "You found a\nfossil! Take it?", function()
|
||||
-- can't grab a fossil until the Super Nerd is beaten -- he intercepts
|
||||
if not superNerdBeaten(ow) then
|
||||
engageSuperNerd(game, ow, done)
|
||||
return
|
||||
end
|
||||
local name = game.data.items[itemId].name
|
||||
game.stack:push(TextBox.new(game, ("You want the\n%s?"):format(name), function()
|
||||
game.stack:push(ChoiceBox.new(game, function(yes)
|
||||
if not yes then done() return end
|
||||
game.save.inventory[itemId] = 1
|
||||
@@ -434,7 +468,6 @@ local function mtMoonFossil(itemId, otherName)
|
||||
local ctx = { save = game.save, overworld = ow, game = game }
|
||||
Commands.hide_object(ctx, "MT_MOON_B2F", npc.def.name)
|
||||
Commands.hide_object(ctx, "MT_MOON_B2F", otherName)
|
||||
local name = game.data.items[itemId].name
|
||||
game.stack:push(TextBox.new(game,
|
||||
("%s got the\n%s!"):format(game.save.player.name, name), done))
|
||||
end))
|
||||
@@ -443,6 +476,15 @@ local function mtMoonFossil(itemId, otherName)
|
||||
end
|
||||
|
||||
M.MT_MOON_B2F = {
|
||||
-- MtMoonB2FDefaultScript forces the Super Nerd battle when the player
|
||||
-- steps onto (13,8), the tile beside him guarding the fossils.
|
||||
onStep = function(game, ow, x, y)
|
||||
if x == 13 and y == 8 and not superNerdBeaten(ow) then
|
||||
engageSuperNerd(game, ow, nil)
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end,
|
||||
talk = {
|
||||
TEXT_MTMOONB2F_DOME_FOSSIL = mtMoonFossil("DOME_FOSSIL", "MTMOONB2F_HELIX_FOSSIL"),
|
||||
TEXT_MTMOONB2F_HELIX_FOSSIL = mtMoonFossil("HELIX_FOSSIL", "MTMOONB2F_DOME_FOSSIL"),
|
||||
|
||||
+70
-5
@@ -104,7 +104,7 @@ M.POKEMON_TOWER_6F = {
|
||||
-- immediately re-fires.
|
||||
ow:scriptMove(ow.player, "right", 1)
|
||||
end
|
||||
ow:afterBattle(result)
|
||||
ow:afterBattle(result, battle)
|
||||
end
|
||||
game.stack:push(battle)
|
||||
end))
|
||||
@@ -317,6 +317,33 @@ M.GAME_CORNER = {
|
||||
done()
|
||||
end))
|
||||
end,
|
||||
-- the grunt guarding the poster (GameCornerRocketText /
|
||||
-- GameCornerRocketBattleScript / GameCornerRocketExitScript): after
|
||||
-- losing he warns the BOSS and leaves the floor for good, freeing
|
||||
-- the tile in front of the hideout switch
|
||||
TEXT_GAMECORNER_ROCKET = function(game, ow, npc, done)
|
||||
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)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
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.
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
-- the ship's hull/deck blocks (block cols 5-8, rows 1-2) and the water
|
||||
-- that replaces them once she sails (the surrounding blocks of each row)
|
||||
local DOCK_SHIP_BLOCKS = {
|
||||
{ bx = 5, by = 1, water = 1 }, { bx = 6, by = 1, water = 1 },
|
||||
{ bx = 7, by = 1, water = 1 }, { bx = 8, by = 1, water = 1 },
|
||||
{ bx = 5, by = 2, water = 13 }, { bx = 6, by = 2, water = 13 },
|
||||
{ bx = 7, by = 2, water = 13 }, { bx = 8, by = 2, water = 13 },
|
||||
}
|
||||
|
||||
M.VERMILION_DOCK = {
|
||||
onEnter = function(game, ow)
|
||||
if game.save.flags.EVENT_SS_ANNE_LEFT then
|
||||
local 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")
|
||||
game.stack:push(TextBox.new(game,
|
||||
game.data.text._VermilionCitySailor1ShipSetSailText
|
||||
or "The ship set sail.", function()
|
||||
ow:startWarpTo("VERMILION_CITY", 19, 30, "up")
|
||||
ow:startWarpTo("VERMILION_CITY", 18, 29, "up")
|
||||
end))
|
||||
elseif game.save.flags.EVENT_GOT_HM01 then
|
||||
game.save.flags.EVENT_SS_ANNE_LEFT = true
|
||||
elseif f.EVENT_GOT_HM01 and ow.player.cellY == 2 then
|
||||
-- VermilionDockSSAnneLeavesScript: only stepping OFF the ship
|
||||
-- triggers the departure (wDestinationWarpID == 1 in pokered) --
|
||||
-- the horn blows, smoke puffs drift off the funnel, the ship is
|
||||
-- erased to open water, and the player is walked off the dock
|
||||
-- into the city past the guard (VermilionCity's
|
||||
-- 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")
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -457,6 +457,25 @@ M.ROUTE_24 = {
|
||||
battleOrDone()
|
||||
end,
|
||||
},
|
||||
-- Route24DefaultScript forces TEXT_ROUTE24_COOLTRAINER_M1 whenever the
|
||||
-- player stands on (10,15) in front of the recruiter while
|
||||
-- EVENT_GOT_NUGGET is unset (dbmapcoord 10,15), independent of facing
|
||||
-- or input -- he has no trainer header, so sight never engages him.
|
||||
onStep = function(game, ow, x, y)
|
||||
if game.save.flags.EVENT_GOT_NUGGET then return false end
|
||||
if not (x == 10 and y == 15) then return false end
|
||||
if ow.runner:isRunning() or ow.engaging then return false end
|
||||
local recruiter
|
||||
for _, npc in ipairs(ow.npcs) do
|
||||
if npc.def and npc.def.name == "ROUTE24_COOLTRAINER_M1" then
|
||||
recruiter = npc
|
||||
break
|
||||
end
|
||||
end
|
||||
if not recruiter then return false end
|
||||
ow:showMapText("TEXT_ROUTE24_COOLTRAINER_M1", recruiter)
|
||||
return true
|
||||
end,
|
||||
}
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
@@ -633,4 +652,96 @@ M.BRUNOS_ROOM = e4ExitSeal("EVENT_BEAT_BRUNOS_ROOM_TRAINER_0", 0x24, 0x05,
|
||||
M.AGATHAS_ROOM = e4ExitSeal("EVENT_BEAT_AGATHAS_ROOM_TRAINER_0", 0x3b, 0x0e,
|
||||
"_AgathasRoomAgathaDontRunAwayText", "EVENT_AUTOWALKED_INTO_AGATHAS_ROOM")
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
-- Lance's room (scripts/LancesRoom.asm). Unlike the other three E4
|
||||
-- rooms this one gates its ENTRANCE, not its exit: the .blk ships with
|
||||
-- the arena doorway CLOSED (blocks $72/$73 at block (2,6)/(3,6), cells
|
||||
-- (4-7,12-13)), and LanceShowOrHideEntranceBlocks OPENS it ($31/$32)
|
||||
-- on every map load while EVENT_LANCES_ROOM_LOCK_DOOR is unset.
|
||||
-- Without this script the doorway never opened, so the whole arena --
|
||||
-- Lance AND both CHAMPIONS_ROOM warps at (5,0)/(6,0) -- was sealed off
|
||||
-- from the entrance hall and the league dead-ended here ("goto (6,11)
|
||||
-- unreachable on LANCES_ROOM").
|
||||
--
|
||||
-- LancesRoomDefaultScript's coordinate triggers, all inert once
|
||||
-- EVENT_BEAT_LANCE is set:
|
||||
-- (5,1)/(6,2) beside Lance -> his battle starts (a coordinate
|
||||
-- trigger, not a talk; victories.lua OPP_LANCE#1 sets
|
||||
-- EVENT_BEAT_LANCE on the win, so a loss re-arms)
|
||||
-- (5,11)/(6,11) the doorway -> CheckAndSetEvent
|
||||
-- EVENT_LANCES_ROOM_LOCK_DOOR: first crossing seals
|
||||
-- the door behind the player with SFX_GO_INSIDE
|
||||
-- (24,16) the entrance staircase -> WalkToLance: an auto-walk
|
||||
-- (up 12, left 12, down 7, left 6) landing on (6,11).
|
||||
-- It marches straight across the room's water decor:
|
||||
-- pokered's CollisionCheckOnLand skips collision
|
||||
-- entirely while simulated joypad states run, and our
|
||||
-- scriptMove is collision-free the same way.
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
local function lanceEntranceBlocks(game, ow)
|
||||
local locked = game.save.flags.EVENT_LANCES_ROOM_LOCK_DOOR
|
||||
ow:replaceBlock(2, 6, locked and 0x72 or 0x31)
|
||||
ow:replaceBlock(3, 6, locked and 0x73 or 0x32)
|
||||
end
|
||||
|
||||
local function lanceLockDoor(game, ow)
|
||||
if game.save.flags.EVENT_LANCES_ROOM_LOCK_DOOR then return end
|
||||
game.save.flags.EVENT_LANCES_ROOM_LOCK_DOOR = true
|
||||
require("src.core.Sound").play(game.data, "Go_Inside")
|
||||
lanceEntranceBlocks(game, ow)
|
||||
end
|
||||
|
||||
local function lanceWalkIn(game, ow)
|
||||
ow:scriptMove(ow.player, "up", 12, function()
|
||||
ow:scriptMove(ow.player, "left", 12, function()
|
||||
ow:scriptMove(ow.player, "down", 7, function()
|
||||
ow:scriptMove(ow.player, "left", 6, function()
|
||||
-- the walk lands on (6,11); vanilla's per-frame coord poll
|
||||
-- then locks the door at once. scriptMove landings do not
|
||||
-- fire onStep, so lock here.
|
||||
lanceLockDoor(game, ow)
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
M.LANCES_ROOM = {
|
||||
onEnter = function(game, ow)
|
||||
lanceEntranceBlocks(game, ow)
|
||||
-- the warp arrival lands ON the staircase trigger, and onStep only
|
||||
-- fires for completed steps -- start the walk-in here, the same
|
||||
-- way Lorelei's auto walk-in runs from its onEnter
|
||||
if not game.save.flags.EVENT_BEAT_LANCE
|
||||
and ow.player.cellX == 24 and ow.player.cellY == 16 then
|
||||
lanceWalkIn(game, ow)
|
||||
end
|
||||
end,
|
||||
onStep = function(game, ow, x, y)
|
||||
if game.save.flags.EVENT_BEAT_LANCE then return false end
|
||||
if (x == 5 and y == 1) or (x == 6 and y == 2) then
|
||||
local lance
|
||||
for _, npc in ipairs(ow.npcs) do
|
||||
if npc.def and npc.def.name == "LANCESROOM_LANCE" then lance = npc break end
|
||||
end
|
||||
if not lance or ow:trainerDefeated(lance) then return false end
|
||||
lance:facePlayer(ow.player)
|
||||
ow:engageTrainer(lance, function() end)
|
||||
return true
|
||||
end
|
||||
if (x == 5 or x == 6) and y == 11 then
|
||||
lanceLockDoor(game, ow)
|
||||
return false
|
||||
end
|
||||
if x == 24 and y == 16 then
|
||||
lanceWalkIn(game, ow)
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end,
|
||||
}
|
||||
|
||||
M.CELADON_CHIEF_HOUSE = require("data.scripts.celadon_chief_house")
|
||||
|
||||
return M
|
||||
|
||||
+35
-5
@@ -259,21 +259,39 @@ M.CINNABAR_ISLAND = {
|
||||
end,
|
||||
}
|
||||
|
||||
-- Pewter's youngster stops you leaving east before Brock is beaten
|
||||
-- (scripts/PewterCity.asm PewterCityCheckPlayerLeavingEastScript; the
|
||||
-- original escorts you to the gym, we walk you back a step)
|
||||
-- Pewter's youngster stops you leaving east before Brock is beaten and
|
||||
-- escorts you to the gym (scripts/PewterCity.asm
|
||||
-- PewterCityCheckPlayerLeavingEastScript /
|
||||
-- PewterCityYoungsterShowsPlayerGymScript, engine/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 = {
|
||||
onStep = function(game, ow, x, y)
|
||||
if game.save.flags.EVENT_BEAT_BROCK then return false end
|
||||
if ow.runner:isRunning() or #ow.scriptMoves > 0 then return false end
|
||||
if not inCoords({ { 35, 17 }, { 36, 17 }, { 37, 18 }, { 37, 19 } }, x, y) then
|
||||
return false
|
||||
end
|
||||
local t = text(game)
|
||||
-- walk up onto the road row (y=17), west to one tile east of the gym
|
||||
-- door, then drop below the door and turn to face it
|
||||
local steps = {}
|
||||
for _ = 1, y - 17 do steps[#steps + 1] = "up" end
|
||||
for _ = 1, x - 17 do steps[#steps + 1] = "left" end
|
||||
steps[#steps + 1] = "down"
|
||||
steps[#steps + 1] = "left"
|
||||
local function walk(i)
|
||||
if not steps[i] then
|
||||
ow.player.facing = "up"
|
||||
return
|
||||
end
|
||||
ow:scriptMove(ow.player, steps[i], 1, function() walk(i + 1) end)
|
||||
end
|
||||
push(game, t._PewterCityYoungsterYoureATrainerFollowMeText
|
||||
or "Hey! You're a\ntrainer, right?", function()
|
||||
push(game, t._PewterCityYoungsterGoTakeOnBrockText
|
||||
or "Go take on BROCK\nat the GYM first!", function()
|
||||
ow:scriptMove(ow.player, "left", 1)
|
||||
walk(1)
|
||||
end)
|
||||
end)
|
||||
return true
|
||||
@@ -453,10 +471,22 @@ local function bikeGateGuard(coords, stopText, explainText)
|
||||
return function(game, ow, x, y)
|
||||
if game.save.inventory.BICYCLE then return false end
|
||||
if not inCoords(coords, x, y) then return false end
|
||||
-- walk the player up to the tile beside the counter, no further:
|
||||
-- (matchedY - closestY) tiles, 0 when already next to it
|
||||
-- (Route16Gate1FDefaultScript's wCoordIndex-1). Forcing a fixed one
|
||||
-- tile up from the counter-adjacent row shoved the player onto the
|
||||
-- impassable desk and boxed them in.
|
||||
local closestY = coords[1][2]
|
||||
for _, c in ipairs(coords) do
|
||||
if c[2] < closestY then closestY = c[2] end
|
||||
end
|
||||
local dist = y - closestY
|
||||
local t = text(game)
|
||||
push(game, t[stopText] or "Hey! Wait up!", function()
|
||||
push(game, t[explainText] or "You need a\nBICYCLE for\nCYCLING ROAD!", function()
|
||||
ow:scriptMove(ow.player, "up", 1)
|
||||
if dist > 0 then
|
||||
ow:scriptMove(ow.player, "up", dist)
|
||||
end
|
||||
end)
|
||||
end)
|
||||
return true
|
||||
|
||||
@@ -5,24 +5,46 @@
|
||||
-- Rocket Hideout Giovanni: his Silph Scope is an item ball next to him
|
||||
-- (data/maps/objects/RocketHideoutB4F.asm), so no reward entry needed.
|
||||
-- The TM each gym leader hands out afterwards is also ported.
|
||||
--
|
||||
-- `deactivate` lists the EVENT_BEAT_* flags each gym's victory script
|
||||
-- sets to retire unfought non-leader trainers (PewterGym.asm
|
||||
-- "; deactivate gym trainers" / SetEventRange in the other gyms, and
|
||||
-- FightingDojo.asm SetEventRange EVENT_BEAT_KARATE_MASTER ..
|
||||
-- EVENT_BEAT_FIGHTING_DOJO_TRAINER_3).
|
||||
|
||||
local function range(prefix, first, last)
|
||||
local t = {}
|
||||
for i = first, last do
|
||||
t[#t + 1] = prefix .. i
|
||||
end
|
||||
return t
|
||||
end
|
||||
|
||||
return {
|
||||
["OPP_BROCK#1"] = { badge = "BOULDERBADGE", flag = "EVENT_BEAT_BROCK",
|
||||
item = "TM_BIDE" },
|
||||
item = "TM_BIDE",
|
||||
deactivate = { "EVENT_BEAT_PEWTER_GYM_TRAINER_0" } },
|
||||
["OPP_MISTY#1"] = { badge = "CASCADEBADGE", flag = "EVENT_BEAT_MISTY",
|
||||
item = "TM_BUBBLEBEAM" },
|
||||
item = "TM_BUBBLEBEAM",
|
||||
deactivate = range("EVENT_BEAT_CERULEAN_GYM_TRAINER_", 0, 1) },
|
||||
["OPP_LT_SURGE#1"] = { badge = "THUNDERBADGE", flag = "EVENT_BEAT_LT_SURGE",
|
||||
item = "TM_THUNDERBOLT" },
|
||||
item = "TM_THUNDERBOLT",
|
||||
deactivate = range("EVENT_BEAT_VERMILION_GYM_TRAINER_", 0, 2) },
|
||||
["OPP_ERIKA#1"] = { badge = "RAINBOWBADGE", flag = "EVENT_BEAT_ERIKA",
|
||||
item = "TM_MEGA_DRAIN" },
|
||||
item = "TM_MEGA_DRAIN",
|
||||
deactivate = range("EVENT_BEAT_CELADON_GYM_TRAINER_", 0, 6) },
|
||||
["OPP_KOGA#1"] = { badge = "SOULBADGE", flag = "EVENT_BEAT_KOGA",
|
||||
item = "TM_TOXIC" },
|
||||
item = "TM_TOXIC",
|
||||
deactivate = range("EVENT_BEAT_FUCHSIA_GYM_TRAINER_", 0, 5) },
|
||||
["OPP_SABRINA#1"] = { badge = "MARSHBADGE", flag = "EVENT_BEAT_SABRINA",
|
||||
item = "TM_PSYWAVE" },
|
||||
item = "TM_PSYWAVE",
|
||||
deactivate = range("EVENT_BEAT_SAFFRON_GYM_TRAINER_", 0, 6) },
|
||||
["OPP_BLAINE#1"] = { badge = "VOLCANOBADGE", flag = "EVENT_BEAT_BLAINE",
|
||||
item = "TM_FIRE_BLAST" },
|
||||
item = "TM_FIRE_BLAST",
|
||||
deactivate = range("EVENT_BEAT_CINNABAR_GYM_TRAINER_", 0, 6) },
|
||||
["OPP_GIOVANNI#3"] = { badge = "EARTHBADGE", flag = "EVENT_BEAT_GIOVANNI",
|
||||
item = "TM_FISSURE" },
|
||||
item = "TM_FISSURE",
|
||||
deactivate = range("EVENT_BEAT_VIRIDIAN_GYM_TRAINER_", 0, 7) },
|
||||
|
||||
-- Silph Co. Giovanni: unlocks the president's Master Ball gift
|
||||
["OPP_GIOVANNI#2"] = { flag = "EVENT_BEAT_SILPH_CO_GIOVANNI" },
|
||||
@@ -31,7 +53,8 @@ return {
|
||||
-- FightingDojoKarateMasterPostBattleScript sets EVENT_BEAT_KARATE_MASTER,
|
||||
-- which gates the HITMONLEE/HITMONCHAN gift). OPP_BLACKBELT party 1 is
|
||||
-- only him (data/maps/objects/FightingDojo.asm).
|
||||
["OPP_BLACKBELT#1"] = { flag = "EVENT_BEAT_KARATE_MASTER" },
|
||||
["OPP_BLACKBELT#1"] = { flag = "EVENT_BEAT_KARATE_MASTER",
|
||||
deactivate = range("EVENT_BEAT_FIGHTING_DOJO_TRAINER_", 0, 3) },
|
||||
|
||||
-- Elite Four progress flags (their rooms' door logic isn't ported, but
|
||||
-- the flags make the Hall of Fame checkable)
|
||||
|
||||
@@ -6,4 +6,37 @@ work for true parity. Faithfully-ported behavior is documented in
|
||||
docs/behavior-porting-notes.md; deliberate additions beyond the original
|
||||
are in docs/new-features.md.
|
||||
|
||||
None currently.
|
||||
## Reimplemented unused Prof. Oak and Rocket Chief battles
|
||||
|
||||
The original ROM defines trainer data for `PROF_OAK` and `CHIEF`
|
||||
(`data/trainers/parties.asm`) but never attaches either to an NPC, so
|
||||
both battles are unreachable in the real game. This project makes them
|
||||
fightable after the Hall of Fame:
|
||||
|
||||
- Prof. Oak battles you in Pallet Town once `EVENT_BEAT_CHAMPION_RIVAL`
|
||||
is set, using `ProfOakData`'s three starter-matched teams (the team is
|
||||
picked by the type that counters your starter, mirroring the rival).
|
||||
- The Celadon Game Corner Chief battles you in his house post-game.
|
||||
`ChiefData` is empty in the ROM, so `OPP_CHIEF` is given a
|
||||
reconstructed party.
|
||||
|
||||
This is an intentional divergence: neither battle can be triggered in the
|
||||
original game.
|
||||
|
||||
## Reimplemented unused Silph Co. card-key doors
|
||||
|
||||
`engine/events/card_key.asm` and the unused `CardKeyTable1/2/3` coordinate
|
||||
lists (`data/events/card_key_coords.asm`) describe locked doors for Silph
|
||||
Co. floors 2F-11F, but no retail `.blk` map layout ever places the closed
|
||||
door block at those coordinates, so the card key check is dead code in
|
||||
the original game. This project stamps the closed door block (`$54`/`$5f`
|
||||
on floors 2F-10F, `$20` on 11F) over each of the 20 door coordinates on
|
||||
map load, and swaps it for the open block once that door's
|
||||
`EVENT_SILPH_CO_n_UNLOCKED_DOORn` flag is set (using the key from a Team
|
||||
Rocket grunt, as in the original's unused design).
|
||||
|
||||
This is an intentional divergence: the doors are not visible or
|
||||
functional in the original game. The door layout lives in
|
||||
`tools/rom_manifest.json` (`field.cardKeyDoors.closedDoors`), hand-ported
|
||||
since no retail ROM data encodes it; `src/import/RomExtractor.lua` copies
|
||||
it straight through on ROM import.
|
||||
|
||||
@@ -17,3 +17,33 @@ Regenerate the reference straight into a wiki checkout:
|
||||
```sh
|
||||
luajit tools/gen_registry_docs.lua ../pokemon-gen1-recomp-project.wiki
|
||||
```
|
||||
|
||||
## Developer console
|
||||
|
||||
Boot with developer mode on to unlock the in-game console and hot-reload
|
||||
hotkeys. Either set `POKEPORT_DEV=1` in the environment or pass
|
||||
`--developer` on the command line:
|
||||
|
||||
```sh
|
||||
love . --developer
|
||||
```
|
||||
|
||||
While developer mode is active:
|
||||
|
||||
- `` ` `` (backtick) opens the console overlay — a Lua REPL with `game`,
|
||||
`data` and `mods` in scope. Press `` ` `` again to close it.
|
||||
- `F5` hot-reloads mods and asset caches without restarting.
|
||||
|
||||
The console understands these verbs (anything else is evaluated as Lua):
|
||||
|
||||
- `warp MAP [x y]` — teleport to a map (default cell 5,5).
|
||||
- `give ID [n|level]` — add an item (count) or a Pokémon (level).
|
||||
- `flag NAME [on|off]` — read or set an event flag.
|
||||
- `party` — dump the current party.
|
||||
- `mods` — list loaded mods and their state.
|
||||
- `reload` — hot-reload mods (same as `F5`).
|
||||
- `trace PAT | trace off` — trace events/hooks matching a glob pattern.
|
||||
- `help` — list the verbs.
|
||||
|
||||
Developer mode also arms the mod loader's dev tripwire, which flags mods
|
||||
that reach outside their permission set.
|
||||
|
||||
@@ -132,6 +132,7 @@ migrated once into `options.lua` on load.
|
||||
- Music / SFX volume
|
||||
- Music Filter
|
||||
- OG GLITCHES on / off (Gen 1 quirks vs. modern-clean battle rules)
|
||||
- COLORS (GBC / OG / OG INV / GBC INV / CLASSIC), also hotkey `2`
|
||||
- COLORS (GBC / RED++ / OG / OG INV / GBC INV / CLASSIC), also hotkey `2`
|
||||
(RED++ uses pokered-gbc SuperPalettes + per-species mon colors)
|
||||
- TILT (OFF / 15 / 35 / 50), also hotkey `3` while free-roaming
|
||||
- GBC FX (OFF / 1 / 2 / 3 / 4), also hotkey `5`
|
||||
@@ -48,6 +48,8 @@ function love.load(args)
|
||||
for i, a in ipairs(args or {}) do
|
||||
if a == "--editor" then
|
||||
editorMode = true
|
||||
elseif a == "--developer" then
|
||||
_G.POKEPORT_DEV_MODE = true
|
||||
elseif a == "--save" and args[i + 1] and args[i + 1] ~= "" then
|
||||
savePath = args[i + 1]
|
||||
elseif a == "--speed" and tonumber(args[i + 1]) then
|
||||
@@ -171,6 +173,28 @@ function love.gamepadaxis(joystick, axis, value)
|
||||
Game:gamepadaxis(joystick, axis, value)
|
||||
end
|
||||
|
||||
function love.joystickremoved(joystick)
|
||||
if editorMode then return end
|
||||
if Importer then return end
|
||||
Game:joystickremoved(joystick)
|
||||
end
|
||||
|
||||
-- f is true on focus gained, false on focus lost (e.g. alt-tab). A held
|
||||
-- direction's key-up can be delivered to the OS instead of the game while
|
||||
-- unfocused, so reset input on either transition rather than trust it.
|
||||
function love.focus(f)
|
||||
if editorMode then return end
|
||||
if Importer then return end
|
||||
Game:focus(f)
|
||||
end
|
||||
|
||||
-- v is true when the window becomes visible again, false on minimize.
|
||||
function love.visible(v)
|
||||
if editorMode then return end
|
||||
if Importer then return end
|
||||
Game:visible(v)
|
||||
end
|
||||
|
||||
function love.touchpressed(id, x, y, dx, dy, pressure)
|
||||
if editorMode then return end
|
||||
if Importer then return Importer:mousepressed(x, y, 1) end
|
||||
@@ -223,6 +247,9 @@ function love.quit()
|
||||
if editorMode and EditorApp.quit then
|
||||
return EditorApp.quit() -- return true to abort quit
|
||||
end
|
||||
pcall(function()
|
||||
require("src.core.DiscordPresence").shutdown()
|
||||
end)
|
||||
end
|
||||
|
||||
function love.filedropped(file)
|
||||
|
||||
+15
-19
@@ -27,17 +27,17 @@ refresh is safe, just rebuild.
|
||||
## Build
|
||||
|
||||
```bash
|
||||
# Debug APK (default Android debug keystore)
|
||||
# Build the APK
|
||||
scripts/build_android.sh
|
||||
|
||||
# Release APK (signing is manual, see below)
|
||||
scripts/build_android.sh --release
|
||||
# Build the APK, setting app.version_name/app.version_code to match a release
|
||||
scripts/build_android.sh --version 0.2.5
|
||||
|
||||
# Zip game.love + branding only (no Android SDK required)
|
||||
scripts/build_android.sh --package-only
|
||||
```
|
||||
|
||||
Or via `scripts/build.sh android`.
|
||||
Or via `scripts/build.sh android [--version X.Y.Z]`.
|
||||
|
||||
The embedded `game.love` deliberately excludes `data/generated/`,
|
||||
`assets/generated/`, and any ROM. It contains the first-boot Lua importer and
|
||||
@@ -59,12 +59,10 @@ Set `ANDROID_SDK_ROOT` (or `ANDROID_HOME`), or let the script write
|
||||
`local.properties` when it finds `~/Library/Android/sdk`.
|
||||
|
||||
Gradle flavor used: **`embedNoRecord`** (game fused into the APK, no microphone).
|
||||
Build task: `assembleEmbedNoRecordDebug`.
|
||||
|
||||
- Debug task: `assembleEmbedNoRecordDebug`
|
||||
- Release task: `assembleEmbedNoRecordRelease`
|
||||
|
||||
APKs land under `app/build/outputs/apk/embedNoRecord/{debug,release}/`.
|
||||
`scripts/build_android.sh` also copies the built APK(s) to `dist/android/{debug,release}/`.
|
||||
The APK lands under `app/build/outputs/apk/embedNoRecord/debug/`.
|
||||
`scripts/build_android.sh` also copies it to `dist/android/debug/`.
|
||||
|
||||
### Payload path
|
||||
|
||||
@@ -79,17 +77,15 @@ scripts, tests, and mobile build sources are excluded.
|
||||
| `app.application_id` | `com.theboisclub.pokemonred` |
|
||||
| `app.name` | Pokemon Red |
|
||||
| `app.orientation` | `portrait` |
|
||||
| `app.version_name` / `app.version_code` | set from `--version X.Y.Z` (code = major*10000 + minor*100 + patch); left as-is if `--version` is omitted |
|
||||
| Permissions | INTERNET / RECORD_AUDIO / WRITE_EXTERNAL_STORAGE stripped; VIBRATE + BLUETOOTH kept |
|
||||
|
||||
## Releases
|
||||
|
||||
`.github/workflows/release.yml` builds the APK with `--version` set to the
|
||||
release version and publishes it alongside the macOS/Windows/Linux builds as
|
||||
`PokemonRed-<version>-android.apk`.
|
||||
|
||||
## Signing
|
||||
|
||||
- **Debug**: default Android debug keystore (no setup).
|
||||
- **Release**: out-of-band. Create a keystore yourself and wire it into
|
||||
`app/build.gradle`, **do not commit keystores or passwords**.
|
||||
|
||||
Example (placeholder only):
|
||||
|
||||
```bash
|
||||
keytool -genkey -v -keystore /path/to/pokemonred-release.jks \
|
||||
-alias pokemonred -keyalg RSA -keysize 2048 -validity 10000
|
||||
```
|
||||
Signed with the default Android keystore (no setup required).
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
*.[oa]
|
||||
# Prebuilt vendored static libs Android.mk links against directly (not
|
||||
# built by the Gradle/NDK build itself) -- must stay tracked or a fresh
|
||||
# checkout fails ndk-build with "LOCAL_SRC_FILES points to a missing file".
|
||||
!android/*/libluajit.a
|
||||
*.so
|
||||
*.obj
|
||||
*.lib
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -36,6 +36,7 @@ demos
|
||||
*~
|
||||
Makefile*
|
||||
config*
|
||||
!src/common/config.h
|
||||
libtool
|
||||
/platform/unix/ar-lib
|
||||
/platform/unix/compile
|
||||
|
||||
@@ -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
|
||||
+45
-14
@@ -1,15 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
# Packages the LÖVE2D Pokémon Red port into distributable macOS and
|
||||
# Windows builds. Runs entirely on macOS (no cross-compiling needed,
|
||||
# the Windows build reuses LÖVE's prebuilt win64 binaries).
|
||||
# Packages the LÖVE2D Pokémon Red port into distributable macOS, Windows,
|
||||
# and Linux builds. Runs entirely on macOS (no cross-compiling needed,
|
||||
# the Windows and Linux builds reuse LÖVE's prebuilt win64 / AppImage
|
||||
# binaries, fusing our game.love onto them the same way love.exe does).
|
||||
#
|
||||
# Usage: scripts/build.sh [mac|win|android|ios|all] [--version X.Y.Z] [--identity "Developer ID Application: ..."]
|
||||
# Usage: scripts/build.sh [mac|win|linux|android|ios|all] [--version X.Y.Z] [--identity "Developer ID Application: ..."]
|
||||
# [--notary-profile NAME] [--no-notarize]
|
||||
# [--release] # android/ios: release config instead of debug
|
||||
# [--release] # ios only: release config instead of debug
|
||||
#
|
||||
# Output: dist/mac/PokemonRed-macos.zip
|
||||
# dist/win/PokemonRed-win64.zip
|
||||
# dist/android/{debug,release}/*.apk (full gradle output stays under
|
||||
# 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/)
|
||||
# dist/ios/<Config>-<sdk>/PokemonRed.app (full xcodebuild output stays
|
||||
# under mobile/ios/build/Build/Products/)
|
||||
@@ -27,11 +29,11 @@ APP_NAME="PokemonRed"
|
||||
BUNDLE_ID="com.theboisclub.pokemonred"
|
||||
LOVE_VERSION="11.5"
|
||||
VERSION="$(git -C "$ROOT" rev-parse --short HEAD 2>/dev/null || echo dev)"
|
||||
VERSION_EXPLICIT=false
|
||||
IDENTITY=""
|
||||
TARGET="all"
|
||||
NOTARY_PROFILE="notary-profile"
|
||||
NOTARIZE=true
|
||||
ANDROID_RELEASE=false
|
||||
IOS_RELEASE=false
|
||||
|
||||
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
||||
@@ -40,18 +42,18 @@ fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
mac|win|android|ios|all) TARGET="$1" ;;
|
||||
--version) VERSION="$2"; shift ;;
|
||||
mac|win|linux|android|ios|all) TARGET="$1" ;;
|
||||
--version) VERSION="$2"; VERSION_EXPLICIT=true; shift ;;
|
||||
--identity) IDENTITY="$2"; shift ;;
|
||||
--notary-profile) NOTARY_PROFILE="$2"; shift ;;
|
||||
--no-notarize) NOTARIZE=false ;;
|
||||
--release) ANDROID_RELEASE=true; IOS_RELEASE=true ;;
|
||||
--release) IOS_RELEASE=true ;;
|
||||
*) fail "unknown argument: $1" ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
mkdir -p "$CACHE" "$WORK" "$DIST/mac" "$DIST/win"
|
||||
mkdir -p "$CACHE" "$WORK" "$DIST/mac" "$DIST/win" "$DIST/linux"
|
||||
|
||||
# --------------------------------------------------------------- game.love
|
||||
say "packing game.love"
|
||||
@@ -167,12 +169,40 @@ build_win() {
|
||||
say "Windows build: $zip_out"
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------- Linux
|
||||
build_linux() {
|
||||
say "building Linux (x86_64 AppImage) app"
|
||||
local appimage_name="love-$LOVE_VERSION-x86_64.AppImage"
|
||||
local love_appimage="$CACHE/$appimage_name"
|
||||
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"
|
||||
fi
|
||||
chmod +x "$love_appimage"
|
||||
|
||||
# Same fusion trick as the Windows exe: love looks for a zip appended to
|
||||
# its own running binary, and an AppImage is just an ELF executable, so
|
||||
# concatenating game.love onto it works the same way `cat love.exe
|
||||
# game.love` does on Windows.
|
||||
local out_bin="$WORK/$APP_NAME-x86_64.AppImage"
|
||||
rm -f "$out_bin"
|
||||
cat "$love_appimage" "$LOVE_FILE" > "$out_bin"
|
||||
chmod +x "$out_bin"
|
||||
|
||||
local zip_out="$DIST/linux/$APP_NAME-linux.zip"
|
||||
rm -f "$zip_out"
|
||||
(cd "$WORK" && zip -q -9 -j "$zip_out" "$(basename "$out_bin")")
|
||||
say "Linux build: $zip_out"
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------- Android
|
||||
build_android() {
|
||||
say "building Android (delegating to scripts/build_android.sh)"
|
||||
local args=()
|
||||
if [ "$ANDROID_RELEASE" = true ]; then
|
||||
args+=(--release)
|
||||
if [ "$VERSION_EXPLICIT" = true ]; then
|
||||
args+=(--version "$VERSION")
|
||||
fi
|
||||
"$ROOT/scripts/build_android.sh" ${args[@]+"${args[@]}"}
|
||||
}
|
||||
@@ -190,9 +220,10 @@ build_ios() {
|
||||
case "$TARGET" in
|
||||
mac) build_mac ;;
|
||||
win) build_win ;;
|
||||
linux) build_linux ;;
|
||||
android) build_android ;;
|
||||
ios) build_ios ;;
|
||||
all) build_mac; build_win ;;
|
||||
all) build_mac; build_win; build_linux ;;
|
||||
esac
|
||||
|
||||
case "$TARGET" in
|
||||
|
||||
+28
-30
@@ -1,11 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
# Packages the LÖVE2D Pokémon Red port into an Android APK via love-android 11.5a.
|
||||
#
|
||||
# Usage: scripts/build_android.sh [--release] [--package-only]
|
||||
# Usage: scripts/build_android.sh [--version X.Y.Z] [--package-only]
|
||||
#
|
||||
# (default) assembleEmbedNoRecordDebug (debug keystore)
|
||||
# --release assembleEmbedNoRecordRelease (requires out-of-band signing)
|
||||
# --package-only zip game.love + apply branding; skip gradle
|
||||
# --version X.Y.Z set app.version_name / app.version_code (else left as-is)
|
||||
# --package-only zip game.love + apply branding; skip gradle
|
||||
#
|
||||
# Prerequisites:
|
||||
# - mobile/android vendored love-android tree at tag 11.5a (in-repo; see mobile/ANDROID.md)
|
||||
@@ -13,9 +12,8 @@
|
||||
# - JDK 17
|
||||
#
|
||||
# Output (after gradle):
|
||||
# dist/android/{debug,release}/*.apk (convenience copy)
|
||||
# dist/android/debug/*.apk (convenience copy)
|
||||
# mobile/android/app/build/outputs/apk/embedNoRecord/debug/*.apk
|
||||
# mobile/android/app/build/outputs/apk/embedNoRecord/release/*.apk
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -29,7 +27,7 @@ APPLICATION_ID="com.theboisclub.pokemonred"
|
||||
LOVE_ANDROID_VERSION="11.5a"
|
||||
NDK_VERSION="25.2.9519653"
|
||||
|
||||
RELEASE=false
|
||||
VERSION=""
|
||||
PACKAGE_ONLY=false
|
||||
|
||||
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
||||
@@ -38,17 +36,29 @@ fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--release) RELEASE=true ;;
|
||||
--version) VERSION="$2"; shift ;;
|
||||
--package-only) PACKAGE_ONLY=true ;;
|
||||
-h|--help)
|
||||
sed -n '2,20p' "$0"
|
||||
exit 0
|
||||
;;
|
||||
*) fail "unknown argument: $1 (try --release or --package-only)" ;;
|
||||
*) fail "unknown argument: $1 (try --version X.Y.Z or --package-only)" ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
VERSION_CODE=""
|
||||
if [ -n "$VERSION" ]; then
|
||||
if ! printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
fail "invalid --version '$VERSION' (expected X.Y.Z)"
|
||||
fi
|
||||
major="${VERSION%%.*}"
|
||||
rest="${VERSION#*.}"
|
||||
minor="${rest%%.*}"
|
||||
patch="${rest##*.}"
|
||||
VERSION_CODE=$((major * 10000 + minor * 100 + patch))
|
||||
fi
|
||||
|
||||
# --------------------------------------------------------------- preconditions
|
||||
if [ ! -f "$ANDROID_DIR/settings.gradle" ] || [ ! -f "$ANDROID_DIR/gradlew" ]; then
|
||||
fail "love-android not found at mobile/android/.
|
||||
@@ -74,10 +84,10 @@ apply_android_branding() {
|
||||
|
||||
say "applying Android branding (gradle.properties + permission trim)"
|
||||
|
||||
python3 - "$props" "$APPLICATION_ID" "$APP_NAME" <<'PY'
|
||||
python3 - "$props" "$APPLICATION_ID" "$APP_NAME" "$VERSION" "$VERSION_CODE" <<'PY'
|
||||
import pathlib, re, sys
|
||||
path = pathlib.Path(sys.argv[1])
|
||||
app_id, name = sys.argv[2], sys.argv[3]
|
||||
app_id, name, version, version_code = sys.argv[2], sys.argv[3], sys.argv[4], sys.argv[5]
|
||||
text = path.read_text()
|
||||
|
||||
def set_prop(text, key, value):
|
||||
@@ -92,6 +102,9 @@ text = re.sub(r"(?m)^app\.name_byte_array=.*\n?", "", text)
|
||||
text = set_prop(text, "app.name", name)
|
||||
text = set_prop(text, "app.application_id", app_id)
|
||||
text = set_prop(text, "app.orientation", "portrait")
|
||||
if version:
|
||||
text = set_prop(text, "app.version_name", version)
|
||||
text = set_prop(text, "app.version_code", version_code)
|
||||
path.write_text(text)
|
||||
PY
|
||||
|
||||
@@ -178,16 +191,8 @@ require_android_sdk() {
|
||||
|
||||
# --------------------------------------------------------------- gradle
|
||||
run_gradle() {
|
||||
local task
|
||||
if $RELEASE; then
|
||||
task="assembleEmbedNoRecordRelease"
|
||||
say "building release APK ($task)"
|
||||
warn "release signing is out-of-band, see mobile/ANDROID.md (Signing)."
|
||||
warn "without a signingConfig, assembleRelease may produce an unsigned APK or fail."
|
||||
else
|
||||
task="assembleEmbedNoRecordDebug"
|
||||
say "building debug APK ($task), uses the default Android debug keystore"
|
||||
fi
|
||||
local task="assembleEmbedNoRecordDebug"
|
||||
say "building APK ($task)"
|
||||
|
||||
if ! (
|
||||
cd "$ANDROID_DIR"
|
||||
@@ -199,19 +204,12 @@ run_gradle() {
|
||||
You can still iterate on the .love payload with: scripts/build_android.sh --package-only"
|
||||
fi
|
||||
|
||||
local out_dir
|
||||
if $RELEASE; then
|
||||
out_dir="$ANDROID_DIR/app/build/outputs/apk/embedNoRecord/release"
|
||||
else
|
||||
out_dir="$ANDROID_DIR/app/build/outputs/apk/embedNoRecord/debug"
|
||||
fi
|
||||
local out_dir="$ANDROID_DIR/app/build/outputs/apk/embedNoRecord/debug"
|
||||
if [ -d "$out_dir" ]; then
|
||||
say "APK output:"
|
||||
find "$out_dir" -name '*.apk' -exec ls -lh {} \;
|
||||
|
||||
local flavor="debug"
|
||||
$RELEASE && flavor="release"
|
||||
local dist_dir="$DIST/$flavor"
|
||||
local dist_dir="$DIST/debug"
|
||||
rm -rf "$dist_dir"
|
||||
mkdir -p "$dist_dir"
|
||||
find "$out_dir" -name '*.apk' -exec cp {} "$dist_dir/" \;
|
||||
|
||||
+192
-35
@@ -106,19 +106,25 @@ end
|
||||
|
||||
Assets.register(BattleState.invalidate)
|
||||
|
||||
-- the species' SGB palette (data/pokemon/palettes.asm), or nil
|
||||
-- the species' SGB palette (active COLORS pack), or nil
|
||||
local function monPalette(data, species)
|
||||
local p = data.palettes
|
||||
local name = p and p.pokemon[species]
|
||||
local colors = name and p.palettes[name]
|
||||
return colors and { name = name, colors = colors } or nil
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local colors = PaletteFX.monPal(data, species)
|
||||
if not colors then return nil end
|
||||
local name = PaletteFX.monPalName(data, species)
|
||||
-- prefix so GBC vs RED++ cache keys don't collide on shared names
|
||||
if PaletteFX.usesGbcPack() then name = "redpp:" .. name end
|
||||
return { name = name, colors = colors }
|
||||
end
|
||||
|
||||
-- a named palette from data/generated/palettes.lua as a getImage pal
|
||||
-- a named palette from the active COLORS pack as a getImage pal
|
||||
local function namedPalette(data, name)
|
||||
local p = data.palettes
|
||||
local colors = p and p.palettes[name]
|
||||
return colors and { name = name, colors = colors } or nil
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local colors = PaletteFX.pal(data, name)
|
||||
if not colors then return nil end
|
||||
local key = name
|
||||
if PaletteFX.usesGbcPack() then key = "redpp:" .. name end
|
||||
return { name = key, colors = colors }
|
||||
end
|
||||
|
||||
-- The battle-BGP fade variant of a pic (AnimationFlashScreen and the
|
||||
@@ -264,8 +270,10 @@ function BattleState:speciesSprite(species, isPlayerSide)
|
||||
if not def then return nil end
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local colors = PaletteFX.monPal(self.data, species, true)
|
||||
local name = "GRAYMON"
|
||||
if PaletteFX.usesGbcPack() then name = "redpp:GRAYMON" end
|
||||
return getImage(isPlayerSide and def.spriteBack or def.spriteFront,
|
||||
colors and { name = "GRAYMON", colors = colors } or nil,
|
||||
colors and { name = name, colors = colors } or nil,
|
||||
def.trueColor)
|
||||
end
|
||||
|
||||
@@ -414,8 +422,17 @@ end
|
||||
function BattleState.newTrainer(game, oppClass, partyIndex)
|
||||
local self = newBattle(game)
|
||||
self.kind = "trainer"
|
||||
self.oppClass = oppClass
|
||||
self.trainer = game.data.trainers[oppClass]
|
||||
assert(self.trainer, "unknown trainer class " .. tostring(oppClass))
|
||||
-- pret GetTrainerName_: RIVAL1/2/3 copy wRivalName into wTrainerName
|
||||
-- instead of TrainerNames ("RIVAL1" etc.). Overlay so we don't mutate
|
||||
-- the shared data table.
|
||||
if oppClass == "OPP_RIVAL1" or oppClass == "OPP_RIVAL2"
|
||||
or oppClass == "OPP_RIVAL3" then
|
||||
local rivalName = (game.save.player and game.save.player.rival) or "BLUE"
|
||||
self.trainer = setmetatable({ name = rivalName }, { __index = self.trainer })
|
||||
end
|
||||
self.enemyAIMods = self.trainer.aiMods
|
||||
local partyDef = self.trainer.parties[partyIndex or 1]
|
||||
assert(partyDef, ("trainer %s has no party %s"):format(oppClass, tostring(partyIndex)))
|
||||
@@ -618,7 +635,6 @@ function BattleState:startMessage(item)
|
||||
self.total = self.total + #codes
|
||||
end
|
||||
self.charIndex = 0
|
||||
self.holdTimer = nil
|
||||
end
|
||||
|
||||
function BattleState:updateQueue()
|
||||
@@ -769,9 +785,8 @@ function BattleState:updateQueue()
|
||||
if self.charIndex < self.total then
|
||||
self.charIndex = math.min(self.total, self.charIndex + 2)
|
||||
else
|
||||
self.holdTimer = (self.holdTimer or 40) - 1
|
||||
local input = self.game.input
|
||||
if self.holdTimer <= 0 or input:wasPressed("a") or input:wasPressed("b") then
|
||||
if input:wasPressed("a") or input:wasPressed("b") then
|
||||
self.current = nil
|
||||
end
|
||||
end
|
||||
@@ -951,6 +966,25 @@ function BattleState:playerHasPP()
|
||||
return false
|
||||
end
|
||||
|
||||
function BattleState:swapMoves(i, j)
|
||||
if i == j then return end
|
||||
local moves = self.player.curMoves
|
||||
local a, b = moves[i], moves[j]
|
||||
if not (a and b) then return end
|
||||
moves[i], moves[j] = b, a
|
||||
local stored = self.player.mon and self.player.mon.moves
|
||||
if stored and stored ~= moves and stored[i] and stored[j] then
|
||||
stored[i], stored[j] = stored[j], stored[i]
|
||||
end
|
||||
local disabled = self.player.disabledSlot
|
||||
if disabled == i then
|
||||
self.player.disabledSlot = j
|
||||
elseif disabled == j then
|
||||
self.player.disabledSlot = i
|
||||
end
|
||||
require("src.core.Sound").play(self.data, "Swap")
|
||||
end
|
||||
|
||||
function BattleState:update(dt)
|
||||
self.frame = self.frame + 1
|
||||
self:updateFx()
|
||||
@@ -1064,6 +1098,7 @@ function BattleState:update(dt)
|
||||
end
|
||||
self.phase = "moveSelect"
|
||||
self.moveIndex = math.min(self.moveIndex, #self.player.curMoves)
|
||||
self.moveSwapIndex = nil
|
||||
elseif choice == "run" then
|
||||
self:tryRun()
|
||||
elseif choice == "item" then
|
||||
@@ -1081,9 +1116,22 @@ function BattleState:update(dt)
|
||||
self.moveIndex = self.moveIndex > 1 and self.moveIndex - 1 or #moves
|
||||
elseif input:wasPressed("down") then
|
||||
self.moveIndex = self.moveIndex < #moves and self.moveIndex + 1 or 1
|
||||
elseif input:wasPressed("select") then
|
||||
if self.moveSwapIndex then
|
||||
self:swapMoves(self.moveSwapIndex, self.moveIndex)
|
||||
self.moveSwapIndex = nil
|
||||
else
|
||||
self.moveSwapIndex = self.moveIndex
|
||||
end
|
||||
elseif input:wasPressed("b") then
|
||||
self.moveSwapIndex = nil
|
||||
self.phase = "menu"
|
||||
elseif input:wasPressed("a") then
|
||||
if self.moveSwapIndex then
|
||||
self:swapMoves(self.moveSwapIndex, self.moveIndex)
|
||||
self.moveSwapIndex = nil
|
||||
return
|
||||
end
|
||||
local mv = moves[self.moveIndex]
|
||||
if self.player.disabledSlot == self.moveIndex then
|
||||
self:say("The move is\ndisabled!")
|
||||
@@ -1484,10 +1532,16 @@ function BattleState:endOfTurn()
|
||||
-- that sets the flag also zeroes the counter, so a stale value is
|
||||
-- unobservable (a switch or cure downgrades Toxic to plain poison).
|
||||
self.sideToxic = self.sideToxic or {}
|
||||
for _, pair in ipairs({ { self.player, self.enemy, "player" },
|
||||
{ self.enemy, self.player, "enemy" } }) do
|
||||
local b, opp, side = pair[1], pair[2], pair[3]
|
||||
if b.mon.hp > 0 then
|
||||
-- a battler whose opponent was already knocked out by a move this turn
|
||||
-- skips its own residual (HandlePoisonBurnLeechSeed is bypassed when the
|
||||
-- move faints the target); snapshot before residual so one side's
|
||||
-- residual faint can't suppress the other's
|
||||
local playerAlive = self.player.mon.hp > 0
|
||||
local enemyAlive = self.enemy.mon.hp > 0
|
||||
for _, pair in ipairs({ { self.player, self.enemy, "player", enemyAlive },
|
||||
{ self.enemy, self.player, "enemy", playerAlive } }) do
|
||||
local b, opp, side, oppAlive = pair[1], pair[2], pair[3], pair[4]
|
||||
if b.mon.hp > 0 and oppAlive then
|
||||
local msgs = Status.residual(b, opp, self)
|
||||
for _, m in ipairs(msgs) do self:sayNext(prefixEnemy(m, b)) end
|
||||
if #msgs > 0 then self:drainNext() end -- poison/burn/seed HP moved
|
||||
@@ -1547,6 +1601,25 @@ function BattleState:animationsOn()
|
||||
return not o or o.animations ~= false
|
||||
end
|
||||
|
||||
-- Drop the announcement-time move-anim row. Gen 1 queues PlayMoveAnimation
|
||||
-- only after MoveHitTest / the effect lands (HandleIfPlayerMoveMissed skips
|
||||
-- it on a miss unless EXPLODE_EFFECT); we insert early for blink attachment
|
||||
-- and peel it back on miss/fail paths.
|
||||
function BattleState:cancelMoveAnim()
|
||||
local row = self.moveAnimRow
|
||||
if not row then return end
|
||||
self.moveAnimRow = nil
|
||||
for i, item in ipairs(self.queue) do
|
||||
if item == row then
|
||||
table.remove(self.queue, i)
|
||||
if self.nextInsert and i <= self.nextInsert then
|
||||
self.nextInsert = self.nextInsert - 1
|
||||
end
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- special-effect (SE_*) implementations. Palette effects are BGP
|
||||
-- shade maps ({[i] = shade color index i displays as}); on the SGB the
|
||||
@@ -2151,6 +2224,20 @@ end
|
||||
-- Decomposed into a staged pipeline over the merged move_effects record:
|
||||
-- announcement -> callsMove -> charge -> perform -> primary run -> the
|
||||
-- damaging pipeline (EffectRegistry.runDamaging).
|
||||
|
||||
-- Gen 1 status/stat primary effects call PlayCurrentMoveAnimation only
|
||||
-- after they land; these failure texts print with no animation.
|
||||
local function primaryEffectFailed(msgs)
|
||||
if not msgs or #msgs == 0 then return true end
|
||||
local m = msgs[1]
|
||||
if m == "But, it failed!" or m == "Nothing happened!" then return true end
|
||||
if m:find("didn't affect", 1, true) then return true end
|
||||
if m:find("is unaffected", 1, true) then return true end
|
||||
if m:find("protected by MIST", 1, true) then return true end
|
||||
if m:find("Already", 1, true) then return true end
|
||||
return false
|
||||
end
|
||||
|
||||
function BattleState:performMove(user, target, moveInst, isCalled)
|
||||
local move = self:moveDef(moveInst)
|
||||
if not move then
|
||||
@@ -2198,6 +2285,11 @@ function BattleState:performMove(user, target, moveInst, isCalled)
|
||||
-- already said its failure text
|
||||
if record and record.callsMove then
|
||||
local pick = record.callsMove(ctx)
|
||||
-- Mirror Move never plays its own anim (MetronomePickMove does;
|
||||
-- MirrorMoveCopyMove only reloads the copied move or prints fail)
|
||||
if move.id == "MIRROR_MOVE" or not pick then
|
||||
self:cancelMoveAnim()
|
||||
end
|
||||
if pick then
|
||||
self:performMove(user, target, { id = pick, pp = 1 }, true)
|
||||
end
|
||||
@@ -2210,6 +2302,7 @@ function BattleState:performMove(user, target, moveInst, isCalled)
|
||||
-- falling back to the id tables (Fly AND Dig go semi-invulnerable:
|
||||
-- ChargeEffect sets INVULNERABLE for both)
|
||||
if record and record.charge and not releasing then
|
||||
self:cancelMoveAnim()
|
||||
user.charging = moveInst
|
||||
user.chargeReady = true
|
||||
local invulnerable = move.semiInvulnerable
|
||||
@@ -2219,6 +2312,15 @@ function BattleState:performMove(user, target, moveInst, isCalled)
|
||||
if invulnerable then
|
||||
user.invulnerable = true
|
||||
end
|
||||
local chargeAnim = record.charge.anim
|
||||
if move.id == "DIG" then
|
||||
chargeAnim = "SLIDE_DOWN_ANIM"
|
||||
elseif record.charge.enemyAnim and not user.isPlayer then
|
||||
chargeAnim = record.charge.enemyAnim
|
||||
end
|
||||
if chargeAnim then
|
||||
self:animNext(chargeAnim, user.isPlayer)
|
||||
end
|
||||
local chargeText = move.chargeText or CHARGE_TEXT[move.id]
|
||||
or "%s\nis charging up!"
|
||||
self:sayNext(chargeText:format(displayName(user)))
|
||||
@@ -2240,10 +2342,19 @@ function BattleState:performMove(user, target, moveInst, isCalled)
|
||||
if record.accuracyChecked
|
||||
and (target.invulnerable
|
||||
or not self:accuracyRoll(move, user, target)) then
|
||||
-- SleepEffect/PoisonEffect/... call PlayCurrentMoveAnimation only
|
||||
-- after the effect lands; a miss skips it
|
||||
self:cancelMoveAnim()
|
||||
self:sayNext(("%s's\nattack missed!"):format(displayName(user)))
|
||||
return
|
||||
end
|
||||
for _, m in ipairs(record.run(ctx)) do
|
||||
local msgs = record.run(ctx)
|
||||
-- Gen 1 status/stat effects animate only when they take effect
|
||||
-- (AlreadyAsleep / NothingHappened / ButItFailed print with no anim)
|
||||
if primaryEffectFailed(msgs) then
|
||||
self:cancelMoveAnim()
|
||||
end
|
||||
for _, m in ipairs(msgs) do
|
||||
self:sayNext(m)
|
||||
end
|
||||
self:drainNext() -- REST/RECOVER/SOFTBOILED move the user's bar
|
||||
@@ -2251,6 +2362,7 @@ function BattleState:performMove(user, target, moveInst, isCalled)
|
||||
end
|
||||
if move.power == 0 and not (record and record.kind == "full") then
|
||||
MoveEffects.warnUnknown(move.effect)
|
||||
self:cancelMoveAnim()
|
||||
self:sayNext("But, it failed!")
|
||||
return
|
||||
end
|
||||
@@ -2290,6 +2402,7 @@ function BattleState:continueBide(user, target)
|
||||
local dmg = (user.bideDamage or 0) * 2
|
||||
user.bideTurns, user.bideDamage = nil, nil
|
||||
if dmg <= 0 then
|
||||
self:cancelMoveAnim()
|
||||
self:sayNext("But, it failed!")
|
||||
return
|
||||
end
|
||||
@@ -2335,6 +2448,9 @@ end
|
||||
function BattleState:onFaint(battler)
|
||||
if battler.faintQueued then return end
|
||||
battler.faintQueued = true
|
||||
if battler.isPlayer and self.participants then
|
||||
self.participants[battler.mon] = nil
|
||||
end
|
||||
Runtime.emit("battle.fainted", { battle = self, battler = battler })
|
||||
-- the faint slide + cry ride the queue (after the move animation and
|
||||
-- the HP-bar drain, pokered's order); the slide finishes before the
|
||||
@@ -2371,9 +2487,9 @@ function BattleState:enemyMonFainted()
|
||||
-- exp is split among the mons that fought this enemy
|
||||
-- (engine/battle/experience.asm); traded mons earn x1.5; each
|
||||
-- participant gets the full stat exp
|
||||
-- the divisor counts EVERY participant, fainted ones included
|
||||
-- (DivideExpDataByNumMonsGainingExp keeps their flag bits); only the
|
||||
-- living ones are actually paid
|
||||
-- a mon that fainted mid-fight has had its gain-exp flag cleared
|
||||
-- (RemoveFaintedPlayerMon), so it drops out of the divisor and only
|
||||
-- the surviving participants are counted and paid
|
||||
local participants, alive = 0, {}
|
||||
for _, mon in ipairs(self.game.save.party) do
|
||||
if self.participants and self.participants[mon] then
|
||||
@@ -2558,6 +2674,22 @@ function BattleState:learnMove(mon, moveId)
|
||||
end)
|
||||
end
|
||||
|
||||
-- Map the battle was fought on (overworld wins; save.player.map is fallback).
|
||||
function BattleState.currentMapId(self)
|
||||
local game = self.game
|
||||
local ow = game and game.overworld
|
||||
if ow and ow.map then return ow.map.id end
|
||||
local player = game and game.save and game.save.player
|
||||
return player and player.map
|
||||
end
|
||||
|
||||
-- pret HandlePlayerBlackOut: OPP_RIVAL1 in OAKS_LAB prints Rival1WinText
|
||||
-- and returns without blacking out. OaksLabRivalEndBattleScript then heals.
|
||||
function BattleState.isOaksLabStarterRival(self)
|
||||
return self.oppClass == "OPP_RIVAL1"
|
||||
and BattleState.currentMapId(self) == "OAKS_LAB"
|
||||
end
|
||||
|
||||
function BattleState:playerMonFainted()
|
||||
local nextMon = Party.firstHealthy(self.game.save.party)
|
||||
-- Being out of useable POKéMON blacks you out even when the battle was
|
||||
@@ -2570,9 +2702,20 @@ function BattleState:playerMonFainted()
|
||||
-- aborts with "no healthy party"), and it is not reachable in pokered:
|
||||
-- HandlePlayerMonFainted runs the player-side check on its own, so
|
||||
-- losing your last mon always blacks you out whatever the enemy did.
|
||||
-- Exception: the Oak's Lab starter rival (HandlePlayerBlackOut).
|
||||
if not nextMon and self.result ~= "lose" then
|
||||
self:sayNext(("%s is out of\nuseable POKéMON!"):format(self.game.save.player.name))
|
||||
self:sayNext(("%s blacked\nout!"):format(self.game.save.player.name))
|
||||
if self.oppClass == "OPP_RIVAL1" then
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local raw = (self.data.text and self.data.text._Rival1WinText)
|
||||
or "{RIVAL}: Yeah! Am\nI great or what?"
|
||||
self:sayNext(TextBox.substitute(self.game, raw))
|
||||
end
|
||||
-- Oak's Lab starter rival: Rival1WinText only (no blackout lines).
|
||||
-- Any other wipe, including Route 22 RIVAL1, still blacks out.
|
||||
if not BattleState.isOaksLabStarterRival(self) then
|
||||
self:sayNext(("%s is out of\nuseable POKéMON!"):format(self.game.save.player.name))
|
||||
self:sayNext(("%s blacked\nout!"):format(self.game.save.player.name))
|
||||
end
|
||||
self.result = "lose"
|
||||
self.afterQueue = "finish"
|
||||
return
|
||||
@@ -2656,6 +2799,7 @@ function BattleState:safariAction(choice)
|
||||
local playerName = self.game.save.player.name
|
||||
|
||||
if choice == "run" then
|
||||
require("src.core.Sound").play(self.data, "Run")
|
||||
self:say("Got away safely!")
|
||||
self.result = "run"
|
||||
self.afterQueue = "finish"
|
||||
@@ -2737,6 +2881,12 @@ function BattleState:safariEnemyTurn()
|
||||
end
|
||||
if fled then
|
||||
self:sayNext(("Wild %s\nran!"):format(self.enemy.name))
|
||||
self:actNext(function()
|
||||
require("src.core.Sound").play(self.data, "Run")
|
||||
startPicKind(self:picFxFor(self.enemy), "slideOff")
|
||||
end)
|
||||
self.nextInsert = (self.nextInsert or 0) + 1
|
||||
table.insert(self.queue, self.nextInsert, { wait = 24 })
|
||||
self.result = "run"
|
||||
self.afterQueue = "finish"
|
||||
end
|
||||
@@ -3037,11 +3187,13 @@ function BattleState:finish()
|
||||
return
|
||||
end
|
||||
-- Invariant: a battle can never hand the overworld a party with nothing
|
||||
-- healthy in it. afterBattle only revives and warps to the heal point on
|
||||
-- "lose", so any other result here strands the player at 0 HP with no way
|
||||
-- back -- an unrecoverable state, not merely a wrong one. playerMonFainted
|
||||
-- is the path that should have caught this; if we land here it did not, so
|
||||
-- say so rather than silently papering over it.
|
||||
-- healthy in it -- except the Oak's Lab starter rival, where pret skips
|
||||
-- the blackout and OaksLabRivalEndBattleScript HealParty's immediately.
|
||||
-- afterBattle only revives and warps to the heal point on a normal
|
||||
-- "lose", so any other result here strands the player at 0 HP with no
|
||||
-- way back -- an unrecoverable state, not merely a wrong one.
|
||||
-- playerMonFainted is the path that should have caught this; if we land
|
||||
-- here it did not, so say so rather than silently papering over it.
|
||||
if self.result ~= "lose" and not Party.firstHealthy(self.game.save.party) then
|
||||
Logger.warn("battle finished %s with no healthy party; forcing blackout",
|
||||
tostring(self.result))
|
||||
@@ -3364,9 +3516,10 @@ function BattleState:colorMode()
|
||||
if self.colorFxReady == nil then
|
||||
local ready = false
|
||||
local g = love and love.graphics
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
if g and g.newCanvas and g.setScissor and g.setShader and g.getCanvas
|
||||
and love.image and self.data.palettes
|
||||
and require("src.render.PaletteFX").shader() then
|
||||
and love.image and PaletteFX.pack(self.data)
|
||||
and PaletteFX.shader() then
|
||||
local ok1, bg = pcall(g.newCanvas, 160, 144)
|
||||
local ok2, wv = pcall(g.newCanvas, 160, 144)
|
||||
if ok1 and ok2 and bg and wv then
|
||||
@@ -3385,9 +3538,10 @@ end
|
||||
-- PAL_MEWMON (= MonsterPalettes[0]) while a side still shows its
|
||||
-- trainer/back pic (the species bytes are 0 then).
|
||||
function BattleState:sgbBattlePals()
|
||||
local pals = self.data.palettes and self.data.palettes.palettes
|
||||
if not pals then return nil end
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local pack = PaletteFX.pack(self.data)
|
||||
local pals = pack and pack.palettes
|
||||
if not pals then return nil end
|
||||
local function bar(b)
|
||||
if not b then return pals.GREENBAR end
|
||||
local hp = b.shownHP or b.mon.hp
|
||||
@@ -3395,8 +3549,7 @@ function BattleState:sgbBattlePals()
|
||||
end
|
||||
local function mon(b, placeholder)
|
||||
if placeholder or not b then return pals.MEWMON or pals.GREENBAR end
|
||||
local name = self.data.palettes.pokemon[b.mon.species]
|
||||
return pals[name] or pals.MEWMON
|
||||
return PaletteFX.monPal(self.data, b.mon.species) or pals.MEWMON
|
||||
end
|
||||
return {
|
||||
[0] = bar(self.player),
|
||||
@@ -3730,7 +3883,11 @@ function BattleState:drawTextArea()
|
||||
for i, mv in ipairs(self.player.curMoves) do
|
||||
Font.draw(self.data.moves[mv.id].name, 48, 96 + i * 8)
|
||||
end
|
||||
Font.drawCode(0xED, 40, 96 + self.moveIndex * 8)
|
||||
Font.drawCode((self.moveSwapIndex == self.moveIndex) and 0xEC or 0xED,
|
||||
40, 96 + self.moveIndex * 8)
|
||||
if self.moveSwapIndex and self.moveSwapIndex ~= self.moveIndex then
|
||||
Font.drawCode(0xEC, 40, 96 + self.moveSwapIndex * 8)
|
||||
end
|
||||
local sel = self.player.curMoves[self.moveIndex]
|
||||
if sel then
|
||||
if self.player.disabledSlot == self.moveIndex then
|
||||
|
||||
@@ -89,6 +89,8 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
-- Swift ignores semi-invulnerability (MoveHitTest returns hit for
|
||||
-- SWIFT_EFFECT before the INVULNERABLE check)
|
||||
if target.invulnerable and not neverMiss then
|
||||
-- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed)
|
||||
if not (record and record.explode) then battle:cancelMoveAnim() end
|
||||
battle:sayNext(("%s's\nattack missed!"):format(displayName(user)))
|
||||
return
|
||||
end
|
||||
@@ -97,6 +99,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
if record and record.gate then
|
||||
local ok, failMsg = record.gate(ctx)
|
||||
if not ok then
|
||||
battle:cancelMoveAnim()
|
||||
if failMsg then battle:sayNext(failMsg) end
|
||||
return
|
||||
end
|
||||
@@ -108,6 +111,8 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
|
||||
if not neverMiss then
|
||||
if not battle:accuracyRoll(move, user, target) then
|
||||
-- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed)
|
||||
if not (record and record.explode) then battle:cancelMoveAnim() end
|
||||
battle:sayNext(("%s's\nattack missed!"):format(displayName(user)))
|
||||
-- Jump Kick crash, Explode self-destruct
|
||||
if record and record.onMiss then record.onMiss(ctx, "accuracy") end
|
||||
@@ -134,6 +139,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
end
|
||||
end
|
||||
if not counterable or (battle.lastDamage or 0) == 0 then
|
||||
battle:cancelMoveAnim()
|
||||
battle:sayNext(("%s's\nattack missed!"):format(displayName(user)))
|
||||
return
|
||||
end
|
||||
@@ -144,6 +150,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
-- failed with that text already chosen
|
||||
local chosen, extra = record.chooseDamage(ctx)
|
||||
if not chosen then
|
||||
battle:cancelMoveAnim()
|
||||
if extra then battle:sayNext(extra) end
|
||||
return
|
||||
end
|
||||
@@ -154,12 +161,15 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
end
|
||||
|
||||
if info.typeMult == 0 then
|
||||
-- type immunity zeros damage and sets wMoveMissed in Gen 1, so no anim
|
||||
if not (record and record.explode) then battle:cancelMoveAnim() end
|
||||
battle:sayNext(("It doesn't affect\n%s!"):format(displayName(target)))
|
||||
if record and record.onMiss then record.onMiss(ctx, "immune") end
|
||||
return
|
||||
end
|
||||
if info.missed then
|
||||
-- 0.25x floored the damage to zero: the original registers a miss
|
||||
if not (record and record.explode) then battle:cancelMoveAnim() end
|
||||
battle:sayNext(("%s's\nattack missed!"):format(displayName(user)))
|
||||
if record and record.onMiss then record.onMiss(ctx, "floored") end
|
||||
return
|
||||
|
||||
@@ -516,8 +516,8 @@ MoveEffects.full = {
|
||||
|
||||
-- charge moves: first turn just charges; Fly AND Dig go
|
||||
-- semi-invulnerable (ChargeEffect sets INVULNERABLE for both)
|
||||
CHARGE_EFFECT = { charge = {} },
|
||||
FLY_EFFECT = { charge = { invulnerable = true } },
|
||||
CHARGE_EFFECT = { charge = { anim = "XSTATITEM_ANIM", enemyAnim = "XSTATITEM_DUPLICATE_ANIM" } },
|
||||
FLY_EFFECT = { charge = { invulnerable = true, anim = "TELEPORT" } },
|
||||
|
||||
TRAPPING_EFFECT = {
|
||||
-- TrappingEffect runs BEFORE the hit test and clears the target's
|
||||
@@ -616,6 +616,7 @@ MoveEffects.full = {
|
||||
-- roll is below opponentLevel/4. Teleport's failure text is "But
|
||||
-- it failed!", Roar/Whirlwind's is DidntAffectText; in trainer
|
||||
-- battles Teleport fails and Roar/Whirlwind are "unaffected".
|
||||
-- Fail paths DelayFrames then print -- no PlayCurrentMoveAnimation.
|
||||
perform = function(ctx)
|
||||
local battle, user, target, move = ctx.battle, ctx.user, ctx.target, ctx.move
|
||||
if battle.kind == "wild" then
|
||||
@@ -635,13 +636,17 @@ MoveEffects.full = {
|
||||
battle.result = "run"
|
||||
battle.afterQueue = "finish"
|
||||
elseif move.id == "TELEPORT" then
|
||||
battle:cancelMoveAnim()
|
||||
ctx.say("But, it failed!")
|
||||
else
|
||||
battle:cancelMoveAnim()
|
||||
ctx.say(("It didn't affect\n%s!"):format(displayName(target)))
|
||||
end
|
||||
elseif move.id == "TELEPORT" then
|
||||
battle:cancelMoveAnim()
|
||||
ctx.say("But, it failed!")
|
||||
else
|
||||
battle:cancelMoveAnim()
|
||||
ctx.say(("%s\nis unaffected!"):format(displayName(target)))
|
||||
end
|
||||
end,
|
||||
|
||||
@@ -97,6 +97,30 @@ function Data:seedDefaults()
|
||||
-- the overworld's Kanto literals, same fill-if-absent contract; required
|
||||
-- here rather than at the top so core keeps out of src/world at load time
|
||||
require("src.world.FieldDefaults").seed(self)
|
||||
-- Cinnabar's quiz trainers are text_asm (no def_trainers), so the
|
||||
-- extractor never writes headers for them. Seed the EVENT_BEAT_* /
|
||||
-- after-battle rows so Blaine's SetEventRange deactivation and talk
|
||||
-- after-text work like the other gyms (scripts/CinnabarGym.asm).
|
||||
self:seedCinnabarGymTrainerHeaders()
|
||||
end
|
||||
|
||||
function Data:seedCinnabarGymTrainerHeaders()
|
||||
local headers = self.trainer_headers
|
||||
if not headers or headers.CinnabarGym then return end
|
||||
local gym = {}
|
||||
for i = 0, 6 do
|
||||
local n = i + 1
|
||||
-- object indices 2..8 are SUPER_NERD1..7; range 0 -- they only
|
||||
-- engage via talk / wrong quiz answer, never sight lines
|
||||
gym[i + 2] = {
|
||||
event = "EVENT_BEAT_CINNABAR_GYM_TRAINER_" .. i,
|
||||
range = 0,
|
||||
battle = "_CinnabarGymSuperNerd" .. n .. "BattleText",
|
||||
won = "_CinnabarGymSuperNerd" .. n .. "EndBattleText",
|
||||
after = "_CinnabarGymSuperNerd" .. n .. "AfterBattleText",
|
||||
}
|
||||
end
|
||||
headers.CinnabarGym = gym
|
||||
end
|
||||
|
||||
-- POKEPORT_DATA_DIR points a test runner at another dataset root (the
|
||||
|
||||
@@ -0,0 +1,658 @@
|
||||
-- Discord Rich Presence via the local Discord IPC pipe. Shows the player's
|
||||
-- current map (and battle status) on their Discord profile. Desktop only;
|
||||
-- no-ops on mobile, headless runs, or when Discord is not running.
|
||||
--
|
||||
-- Application ID is the only credential needed for presence. The Discord
|
||||
-- Interactions public key is unused here (that is for verifying bot HTTP
|
||||
-- payloads, not Rich Presence).
|
||||
|
||||
local Json = require("src.link.Json")
|
||||
local Logger = require("src.core.Logger")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
|
||||
local DiscordPresence = {
|
||||
APP_ID = "1529183141267374262",
|
||||
}
|
||||
|
||||
local OP_HANDSHAKE, OP_FRAME, OP_CLOSE = 0, 1, 2
|
||||
|
||||
local MIN_UPDATE_INTERVAL = 2.0
|
||||
local RECONNECT_INTERVAL = 30.0
|
||||
|
||||
local state = {
|
||||
enabled = false,
|
||||
connected = false,
|
||||
socket = nil, -- Windows: file handle; Unix: ffi sockfd
|
||||
isWindows = false,
|
||||
ffi = nil,
|
||||
ffiReady = false,
|
||||
startedAt = 0,
|
||||
dirty = false,
|
||||
lastSentKey = nil,
|
||||
lastSendAt = -1e9,
|
||||
nextReconnectAt = 0,
|
||||
location = nil, -- display name
|
||||
mapId = nil,
|
||||
activity = "menu", -- menu | exploring | battle
|
||||
battleLabel = nil,
|
||||
unsubs = {},
|
||||
pid = nil,
|
||||
loggedAbsent = false, -- one quiet note when Discord isn't running
|
||||
}
|
||||
|
||||
local function disabledByEnv()
|
||||
return os.getenv("POKEPORT_NO_DISCORD") == "1"
|
||||
or os.getenv("POKEPORT_AUTOPILOT") ~= nil
|
||||
or os.getenv("POKEPORT_DRIVER") ~= nil
|
||||
end
|
||||
|
||||
local function isDesktop()
|
||||
if not love or not love.system or not love.system.getOS then return false end
|
||||
local osName = love.system.getOS()
|
||||
return osName == "OS X" or osName == "Windows" or osName == "Linux"
|
||||
end
|
||||
|
||||
local function packU32(n)
|
||||
n = math.floor(n) % 0x100000000
|
||||
local b1 = n % 256; n = math.floor(n / 256)
|
||||
local b2 = n % 256; n = math.floor(n / 256)
|
||||
local b3 = n % 256; n = math.floor(n / 256)
|
||||
local b4 = n % 256
|
||||
return string.char(b1, b2, b3, b4)
|
||||
end
|
||||
|
||||
local function unpackU32(s)
|
||||
local b1, b2, b3, b4 = s:byte(1, 4)
|
||||
return b1 + b2 * 256 + b3 * 65536 + b4 * 16777216
|
||||
end
|
||||
|
||||
local function nonce()
|
||||
return string.format("%08x-%04x-4%03x-%04x-%04x%08x",
|
||||
math.random(0, 0xffffffff),
|
||||
math.random(0, 0xffff),
|
||||
math.random(0, 0xfff),
|
||||
math.random(0, 0x3fff) + 0x8000,
|
||||
math.random(0, 0xffff),
|
||||
math.random(0, 0xffffffff))
|
||||
end
|
||||
|
||||
local function resolvePid()
|
||||
if state.pid then return state.pid end
|
||||
local ffi = state.ffi
|
||||
if ffi then
|
||||
if state.isWindows then
|
||||
pcall(ffi.cdef, "unsigned long GetCurrentProcessId(void);")
|
||||
local ok, p = pcall(function() return ffi.C.GetCurrentProcessId() end)
|
||||
if ok and p and tonumber(p) and tonumber(p) > 0 then
|
||||
state.pid = tonumber(p)
|
||||
return state.pid
|
||||
end
|
||||
else
|
||||
pcall(ffi.cdef, "int getpid(void);")
|
||||
local ok, p = pcall(function() return ffi.C.getpid() end)
|
||||
if ok and p and tonumber(p) and tonumber(p) > 0 then
|
||||
state.pid = tonumber(p)
|
||||
return state.pid
|
||||
end
|
||||
end
|
||||
end
|
||||
state.pid = 1000 + math.random(1, 8999)
|
||||
return state.pid
|
||||
end
|
||||
|
||||
local function ensureFfi()
|
||||
if state.ffiReady then return state.ffi end
|
||||
if state.ffi == false then return nil end
|
||||
local ok, ffi = pcall(require, "ffi")
|
||||
if not ok or not ffi then
|
||||
state.ffi = false
|
||||
return nil
|
||||
end
|
||||
state.ffi = ffi
|
||||
if state.isWindows then
|
||||
state.ffiReady = true
|
||||
return ffi
|
||||
end
|
||||
-- Darwin sockaddr_un has sun_len + uint8_t family; Linux uses u16 family.
|
||||
local osName = love.system.getOS()
|
||||
local cdef
|
||||
if osName == "OS X" then
|
||||
cdef = [[
|
||||
typedef unsigned int socklen_t;
|
||||
typedef int ssize_t;
|
||||
struct sockaddr_un {
|
||||
uint8_t sun_len;
|
||||
uint8_t sun_family;
|
||||
char sun_path[104];
|
||||
};
|
||||
struct timeval { long tv_sec; int tv_usec; };
|
||||
int socket(int domain, int type, int protocol);
|
||||
int connect(int sockfd, const void *addr, socklen_t addrlen);
|
||||
int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);
|
||||
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
|
||||
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
|
||||
int close(int fd);
|
||||
]]
|
||||
else
|
||||
cdef = [[
|
||||
typedef unsigned int socklen_t;
|
||||
typedef int ssize_t;
|
||||
struct sockaddr_un {
|
||||
unsigned short sun_family;
|
||||
char sun_path[108];
|
||||
};
|
||||
struct timeval { long tv_sec; long tv_usec; };
|
||||
int socket(int domain, int type, int protocol);
|
||||
int connect(int sockfd, const void *addr, socklen_t addrlen);
|
||||
int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);
|
||||
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
|
||||
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
|
||||
int close(int fd);
|
||||
]]
|
||||
end
|
||||
-- cdef may already be registered from a prior boot; either way we need
|
||||
-- sockaddr_un usable before claiming readiness
|
||||
pcall(ffi.cdef, cdef)
|
||||
local ready = pcall(function() return ffi.new("struct sockaddr_un") end)
|
||||
if not ready then
|
||||
state.ffi = false
|
||||
state.ffiReady = false
|
||||
return nil
|
||||
end
|
||||
state.ffiReady = true
|
||||
return ffi
|
||||
end
|
||||
|
||||
local function closeSocket()
|
||||
local sock = state.socket
|
||||
state.socket = nil
|
||||
state.connected = false
|
||||
if not sock then return end
|
||||
if state.isWindows then
|
||||
pcall(function() sock:close() end)
|
||||
elseif state.ffi then
|
||||
pcall(state.ffi.C.close, sock)
|
||||
end
|
||||
end
|
||||
|
||||
local function writeRaw(bytes)
|
||||
if not state.socket then return false end
|
||||
if state.isWindows then
|
||||
local ok = pcall(function()
|
||||
state.socket:seek("end")
|
||||
local _, writeErr = state.socket:write(bytes)
|
||||
state.socket:flush()
|
||||
if writeErr then error(writeErr) end
|
||||
end)
|
||||
return ok
|
||||
end
|
||||
local ffi = state.ffi
|
||||
if not ffi then return false end
|
||||
local ok, sent = pcall(ffi.C.send, state.socket, bytes, #bytes, 0)
|
||||
return ok and sent == #bytes
|
||||
end
|
||||
|
||||
local function sendFrame(opcode, payload)
|
||||
local body = payload or ""
|
||||
local ok, frame = pcall(function()
|
||||
return packU32(opcode) .. packU32(#body) .. body
|
||||
end)
|
||||
if not ok or not frame then return false end
|
||||
return writeRaw(frame)
|
||||
end
|
||||
|
||||
local function readExact(n)
|
||||
if not state.socket or n <= 0 then return nil end
|
||||
if state.isWindows then
|
||||
local ok, data = pcall(function() return state.socket:read(n) end)
|
||||
if not ok or not data or #data < n then return nil end
|
||||
return data
|
||||
end
|
||||
local ffi = state.ffi
|
||||
if not ffi then return nil end
|
||||
local ok, data = pcall(function()
|
||||
local buf = ffi.new("char[?]", n)
|
||||
local got = 0
|
||||
while got < n do
|
||||
local nread = ffi.C.recv(state.socket, buf + got, n - got, 0)
|
||||
if not nread or nread <= 0 then return nil end
|
||||
got = got + tonumber(nread)
|
||||
end
|
||||
return ffi.string(buf, n)
|
||||
end)
|
||||
if not ok then return nil end
|
||||
return data
|
||||
end
|
||||
|
||||
local function receiveFrame()
|
||||
local header = readExact(8)
|
||||
if not header then return nil end
|
||||
local ok, opcode, length = pcall(function()
|
||||
return unpackU32(header:sub(1, 4)), unpackU32(header:sub(5, 8))
|
||||
end)
|
||||
if not ok or not opcode or not length or length < 0 or length > 65536 then
|
||||
return nil
|
||||
end
|
||||
local data = length > 0 and readExact(length) or ""
|
||||
if data == nil then return nil end
|
||||
return opcode, data
|
||||
end
|
||||
|
||||
local function unixSocketPaths()
|
||||
local paths = {}
|
||||
local envs = { "XDG_RUNTIME_DIR", "TMPDIR", "TMP", "TEMP" }
|
||||
local bases = {}
|
||||
for _, key in ipairs(envs) do
|
||||
local v = os.getenv(key)
|
||||
if v and v ~= "" then
|
||||
if v:sub(-1) == "/" then v = v:sub(1, -2) end
|
||||
bases[#bases + 1] = v
|
||||
end
|
||||
end
|
||||
bases[#bases + 1] = "/tmp"
|
||||
local suffixes = {
|
||||
"",
|
||||
"/app/com.discordapp.Discord",
|
||||
"/app/com.discordapp.DiscordCanary",
|
||||
"/app/com.discordapp.DiscordPTB",
|
||||
"/snap.discord",
|
||||
"/snap.discord-canary",
|
||||
}
|
||||
local seen = {}
|
||||
for i = 0, 9 do
|
||||
local name = "discord-ipc-" .. i
|
||||
for _, base in ipairs(bases) do
|
||||
for _, suffix in ipairs(suffixes) do
|
||||
local path = base .. suffix .. "/" .. name
|
||||
if not seen[path] then
|
||||
seen[path] = true
|
||||
paths[#paths + 1] = path
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return paths
|
||||
end
|
||||
|
||||
local function setSocketTimeout(fd, seconds)
|
||||
local ffi = state.ffi
|
||||
if not ffi or not fd then return end
|
||||
pcall(function()
|
||||
local tv = ffi.new("struct timeval")
|
||||
tv.tv_sec = seconds
|
||||
tv.tv_usec = 0
|
||||
-- SOL_SOCKET / SO_RCVTIMEO / SO_SNDTIMEO differ by OS
|
||||
local sol, rcv, snd
|
||||
if love.system.getOS() == "OS X" then
|
||||
sol, rcv, snd = 0xffff, 0x1006, 0x1005
|
||||
else
|
||||
sol, rcv, snd = 1, 20, 21
|
||||
end
|
||||
ffi.C.setsockopt(fd, sol, rcv, tv, ffi.sizeof(tv))
|
||||
ffi.C.setsockopt(fd, sol, snd, tv, ffi.sizeof(tv))
|
||||
end)
|
||||
end
|
||||
|
||||
local function connectUnix()
|
||||
local ffi = ensureFfi()
|
||||
if not ffi then return false end
|
||||
local darwin = love.system.getOS() == "OS X"
|
||||
local maxPath = darwin and 103 or 107
|
||||
-- AF_UNIX=1, SOCK_STREAM=1 on Linux/macOS
|
||||
local pathsOk, paths = pcall(unixSocketPaths)
|
||||
if not pathsOk or not paths then return false end
|
||||
for _, path in ipairs(paths) do
|
||||
if #path <= maxPath then
|
||||
local ok, connected = pcall(function()
|
||||
local fd = ffi.C.socket(1, 1, 0)
|
||||
if not fd or fd < 0 then return false end
|
||||
local addr = ffi.new("struct sockaddr_un")
|
||||
addr.sun_family = 1
|
||||
if darwin then
|
||||
-- SUN_LEN: sizeof(sun_len+sun_family) + path + NUL
|
||||
addr.sun_len = 2 + #path + 1
|
||||
end
|
||||
ffi.copy(addr.sun_path, path)
|
||||
local rc = ffi.C.connect(fd, ffi.cast("void*", addr), ffi.sizeof(addr))
|
||||
if rc == 0 then
|
||||
setSocketTimeout(fd, 1) -- handshake must not freeze the game
|
||||
state.socket = fd
|
||||
return true
|
||||
end
|
||||
ffi.C.close(fd)
|
||||
return false
|
||||
end)
|
||||
if ok and connected then return true end
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function connectWindows()
|
||||
for i = 0, 9 do
|
||||
local path = "\\\\.\\pipe\\discord-ipc-" .. i
|
||||
local ok, file = pcall(io.open, path, "r+")
|
||||
if ok and file then
|
||||
state.socket = file
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function handshake()
|
||||
local ok, payload = pcall(Json.encode, { v = 1, client_id = DiscordPresence.APP_ID })
|
||||
if not ok or not payload then return false end
|
||||
if not sendFrame(OP_HANDSHAKE, payload) then return false end
|
||||
-- Discord echoes READY as a FRAME. A timeout/missing client is a soft miss.
|
||||
local opcode = receiveFrame()
|
||||
if opcode == OP_FRAME then return true end
|
||||
-- Windows named pipes have no easy read timeout: if the write succeeded
|
||||
-- and Discord is present, proceed optimistically so boot never blocks.
|
||||
if state.isWindows then return true end
|
||||
return false
|
||||
end
|
||||
|
||||
local function noteAbsent()
|
||||
if state.loggedAbsent then return end
|
||||
state.loggedAbsent = true
|
||||
-- info, not warn: missing Discord is normal for many players
|
||||
Logger.info("discord: rich presence unavailable (Discord not running)")
|
||||
end
|
||||
|
||||
local function connect()
|
||||
local ok, result = pcall(function()
|
||||
closeSocket()
|
||||
local linked
|
||||
if state.isWindows then
|
||||
linked = connectWindows()
|
||||
else
|
||||
linked = connectUnix()
|
||||
end
|
||||
if not linked then return false end
|
||||
if not handshake() then
|
||||
closeSocket()
|
||||
return false
|
||||
end
|
||||
state.connected = true
|
||||
state.dirty = true
|
||||
state.lastSentKey = nil
|
||||
state.loggedAbsent = false
|
||||
Logger.info("discord: rich presence connected")
|
||||
return true
|
||||
end)
|
||||
if not ok then
|
||||
closeSocket()
|
||||
return false
|
||||
end
|
||||
return result and true or false
|
||||
end
|
||||
|
||||
local function locationName(game, mapId)
|
||||
if not mapId then return nil end
|
||||
local field = game and game.data and game.data.field
|
||||
local townMap = field and field.townMap
|
||||
local locations = townMap and (townMap.locations or townMap)
|
||||
local entry = type(locations) == "table" and locations[mapId]
|
||||
if type(entry) == "table" then
|
||||
local name = entry.name or entry.label
|
||||
if type(name) == "string" and name ~= "" then return name end
|
||||
end
|
||||
local def = game and game.data and game.data.maps and game.data.maps[mapId]
|
||||
if def and type(def.label) == "string" and def.label ~= "" then
|
||||
-- PalletTown -> Pallet Town
|
||||
return def.label:gsub("(%l)(%u)", "%1 %2")
|
||||
end
|
||||
return tostring(mapId):gsub("_", " ")
|
||||
end
|
||||
|
||||
local function speciesName(game, species)
|
||||
if not species or not game or not game.data or not game.data.pokemon then
|
||||
return nil
|
||||
end
|
||||
local def = game.data.pokemon[species]
|
||||
return def and def.name or tostring(species):gsub("_", " ")
|
||||
end
|
||||
|
||||
local function buildActivity()
|
||||
local details, activityState
|
||||
if state.activity == "battle" then
|
||||
details = state.battleLabel or "In battle"
|
||||
activityState = state.location and (state.location) or "Kanto"
|
||||
elseif state.activity == "exploring" and state.location then
|
||||
details = state.location
|
||||
activityState = "Exploring the overworld"
|
||||
else
|
||||
details = state.location or "Kanto"
|
||||
activityState = "At the title screen"
|
||||
end
|
||||
|
||||
return {
|
||||
details = details,
|
||||
state = activityState,
|
||||
timestamps = { start = state.startedAt },
|
||||
assets = {
|
||||
large_image = "logo",
|
||||
large_text = "Pokemon Gen1Recomp",
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
local function activityKey(activity)
|
||||
return (activity.details or "") .. "|" .. (activity.state or "")
|
||||
end
|
||||
|
||||
local function flush(force)
|
||||
if not state.enabled or not state.connected then return end
|
||||
local ok = pcall(function()
|
||||
local now = love.timer.getTime()
|
||||
if not force and (now - state.lastSendAt) < MIN_UPDATE_INTERVAL then
|
||||
state.dirty = true
|
||||
return
|
||||
end
|
||||
local activity = buildActivity()
|
||||
local key = activityKey(activity)
|
||||
if not force and key == state.lastSentKey then
|
||||
state.dirty = false
|
||||
return
|
||||
end
|
||||
local payload = Json.encode({
|
||||
cmd = "SET_ACTIVITY",
|
||||
args = {
|
||||
pid = resolvePid(),
|
||||
activity = activity,
|
||||
},
|
||||
nonce = nonce(),
|
||||
})
|
||||
if not sendFrame(OP_FRAME, payload) then
|
||||
closeSocket()
|
||||
state.nextReconnectAt = now + RECONNECT_INTERVAL
|
||||
return
|
||||
end
|
||||
state.lastSentKey = key
|
||||
state.lastSendAt = now
|
||||
state.dirty = false
|
||||
end)
|
||||
if not ok then
|
||||
closeSocket()
|
||||
local now = love and love.timer and love.timer.getTime and love.timer.getTime() or 0
|
||||
state.nextReconnectAt = now + RECONNECT_INTERVAL
|
||||
end
|
||||
end
|
||||
|
||||
local function setPresence(fields)
|
||||
-- event listeners must never throw into the overworld/battle path
|
||||
pcall(function()
|
||||
if fields.location ~= nil then state.location = fields.location end
|
||||
if fields.mapId ~= nil then state.mapId = fields.mapId end
|
||||
if fields.activity ~= nil then state.activity = fields.activity end
|
||||
if fields.clearBattle then
|
||||
state.battleLabel = nil
|
||||
elseif fields.battleLabel ~= nil then
|
||||
state.battleLabel = fields.battleLabel
|
||||
end
|
||||
state.dirty = true
|
||||
flush(false)
|
||||
end)
|
||||
end
|
||||
|
||||
local function subscribe(game)
|
||||
local events = Runtime.events
|
||||
if not events or not events.on then return end
|
||||
|
||||
local function track(name, fn)
|
||||
state.unsubs[#state.unsubs + 1] = events:on(name, fn, 0, "discord")
|
||||
end
|
||||
|
||||
track("map.entered", function(ev)
|
||||
local mapId = ev and ev.mapId
|
||||
setPresence({
|
||||
mapId = mapId,
|
||||
location = locationName(game, mapId),
|
||||
activity = "exploring",
|
||||
clearBattle = true,
|
||||
})
|
||||
end)
|
||||
|
||||
track("battle.started", function(ev)
|
||||
local battle = ev and ev.battle
|
||||
local label = "In battle"
|
||||
if ev and ev.kind == "wild" then
|
||||
local name = speciesName(game, ev.species)
|
||||
if name then
|
||||
label = "Battling wild " .. name
|
||||
if ev.level then label = label .. " Lv" .. tostring(ev.level) end
|
||||
else
|
||||
label = "Wild battle"
|
||||
end
|
||||
elseif ev and ev.kind == "trainer" then
|
||||
local tname = battle and battle.trainer and battle.trainer.name
|
||||
label = tname and ("Battling " .. tname) or "Trainer battle"
|
||||
elseif ev and ev.kind == "link" then
|
||||
label = "Link battle"
|
||||
end
|
||||
local mapId = state.mapId
|
||||
or (game.save and game.save.player and game.save.player.map)
|
||||
setPresence({
|
||||
activity = "battle",
|
||||
battleLabel = label,
|
||||
location = locationName(game, mapId) or state.location,
|
||||
mapId = mapId,
|
||||
})
|
||||
end)
|
||||
|
||||
track("battle.ended", function()
|
||||
local mapId = state.mapId
|
||||
or (game.save and game.save.player and game.save.player.map)
|
||||
setPresence({
|
||||
activity = mapId and "exploring" or "menu",
|
||||
clearBattle = true,
|
||||
location = locationName(game, mapId) or state.location,
|
||||
mapId = mapId,
|
||||
})
|
||||
end)
|
||||
|
||||
track("screen.pushed", function(ev)
|
||||
local s = ev and ev.state
|
||||
local id = s and s.screenId
|
||||
if not id and s and s.onNewGame then id = "TitleState" end
|
||||
if id == "TitleState" or id == "IntroMovie" then
|
||||
state.mapId = nil
|
||||
setPresence({
|
||||
activity = "menu",
|
||||
location = id == "IntroMovie" and "Watching the intro" or "Title screen",
|
||||
clearBattle = true,
|
||||
})
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
function DiscordPresence.init(game)
|
||||
local ok, err = pcall(function()
|
||||
DiscordPresence.shutdown()
|
||||
if disabledByEnv() or not isDesktop() then return end
|
||||
|
||||
state.enabled = true
|
||||
state.isWindows = love.system.getOS() == "Windows"
|
||||
state.startedAt = os.time()
|
||||
state.activity = "menu"
|
||||
state.location = "Title screen"
|
||||
state.mapId = nil
|
||||
state.battleLabel = nil
|
||||
state.dirty = true
|
||||
state.nextReconnectAt = 0
|
||||
state.loggedAbsent = false
|
||||
|
||||
if not state.isWindows and not ensureFfi() then
|
||||
-- no FFI / broken cdef: stay disabled, never throw
|
||||
state.enabled = false
|
||||
return
|
||||
end
|
||||
|
||||
subscribe(game)
|
||||
if connect() then
|
||||
flush(true)
|
||||
else
|
||||
noteAbsent()
|
||||
local now = (love.timer and love.timer.getTime and love.timer.getTime()) or 0
|
||||
state.nextReconnectAt = now + RECONNECT_INTERVAL
|
||||
end
|
||||
end)
|
||||
if not ok then
|
||||
closeSocket()
|
||||
state.enabled = false
|
||||
-- still soft: a broken presence stack must not abort boot
|
||||
Logger.info("discord: rich presence disabled (%s)", tostring(err))
|
||||
end
|
||||
end
|
||||
|
||||
function DiscordPresence.update(_dt)
|
||||
if not state.enabled then return end
|
||||
pcall(function()
|
||||
local now = love.timer.getTime()
|
||||
if not state.connected then
|
||||
if now >= state.nextReconnectAt then
|
||||
state.nextReconnectAt = now + RECONNECT_INTERVAL
|
||||
if connect() then
|
||||
flush(true)
|
||||
else
|
||||
noteAbsent()
|
||||
end
|
||||
end
|
||||
return
|
||||
end
|
||||
if state.dirty then flush(false) end
|
||||
end)
|
||||
end
|
||||
|
||||
function DiscordPresence.shutdown()
|
||||
pcall(function()
|
||||
for _, unsub in ipairs(state.unsubs) do
|
||||
pcall(unsub)
|
||||
end
|
||||
state.unsubs = {}
|
||||
if state.connected then
|
||||
pcall(function()
|
||||
-- omit activity to clear the presence
|
||||
sendFrame(OP_FRAME, Json.encode({
|
||||
cmd = "SET_ACTIVITY",
|
||||
args = { pid = resolvePid() },
|
||||
nonce = nonce(),
|
||||
}))
|
||||
sendFrame(OP_CLOSE, "{}")
|
||||
end)
|
||||
end
|
||||
closeSocket()
|
||||
end)
|
||||
state.enabled = false
|
||||
state.dirty = false
|
||||
state.lastSentKey = nil
|
||||
state.connected = false
|
||||
state.socket = nil
|
||||
end
|
||||
|
||||
-- test / debug helpers
|
||||
DiscordPresence._state = state
|
||||
DiscordPresence.locationName = locationName
|
||||
|
||||
return DiscordPresence
|
||||
@@ -12,8 +12,15 @@ function FixedStep:init(callback)
|
||||
self.callback = callback
|
||||
end
|
||||
|
||||
-- The anti-spiral clamp doubles as a steps-per-frame ceiling (0.25s = 15
|
||||
-- steps), which silently throttled the high fast-forward levels: at 100X
|
||||
-- a 60fps frame wants ~100 steps. Game:update raises this to fit the
|
||||
-- current speed target; a stall still cannot snowball past one frame's
|
||||
-- intended budget.
|
||||
FixedStep.maxAccum = MAX_ACCUM
|
||||
|
||||
function FixedStep:update(dt)
|
||||
self.accum = math.min(self.accum + dt, MAX_ACCUM)
|
||||
self.accum = math.min(self.accum + dt, self.maxAccum or MAX_ACCUM)
|
||||
while self.accum >= self.STEP do
|
||||
self.accum = self.accum - self.STEP
|
||||
self.callback(self.STEP)
|
||||
|
||||
+53
-7
@@ -17,7 +17,7 @@ local Game = {}
|
||||
|
||||
-- dev-mode gate for the F5/backtick hotkeys; false keeps every src/dev
|
||||
-- module unloaded, so a player boot never touches a byte of dev code
|
||||
local devMode = os.getenv("POKEPORT_DEV") == "1"
|
||||
local devMode = os.getenv("POKEPORT_DEV") == "1" or _G.POKEPORT_DEV_MODE == true
|
||||
|
||||
-- the boot screen ids (field.boot.screens); a plain function so the
|
||||
-- headless harness can borrow makeTitleState onto a stub game
|
||||
@@ -68,6 +68,10 @@ function Game:load()
|
||||
local OverworldState = require("src.world.OverworldController")
|
||||
self.overworld = OverworldState
|
||||
|
||||
-- Discord Rich Presence: map name / battle status on the player's profile.
|
||||
-- Soft-fail: missing Discord / IPC errors must never block boot.
|
||||
pcall(function() require("src.core.DiscordPresence").init(self) end)
|
||||
|
||||
-- every service is up but nothing is on the stack yet; this payload is
|
||||
-- the sanctioned way for a mod to obtain the Game object
|
||||
ModRuntime.emit("game.ready", { game = self })
|
||||
@@ -103,7 +107,7 @@ end
|
||||
function Game:makeTitleState()
|
||||
local OverworldState = require("src.world.OverworldController")
|
||||
local factory = Screens.get(self, bootScreens(self).title or "TitleState")
|
||||
return factory.new(self, {
|
||||
local title = factory.new(self, {
|
||||
onNewGame = function()
|
||||
while self.stack:top() do self.stack:pop() end
|
||||
-- New Game keeps the standalone options.lua preferences
|
||||
@@ -126,6 +130,8 @@ function Game:makeTitleState()
|
||||
end
|
||||
end,
|
||||
})
|
||||
title.screenId = title.screenId or "TitleState"
|
||||
return title
|
||||
end
|
||||
|
||||
-- QUIT from the START menu: back to the title like a power-cycle,
|
||||
@@ -170,7 +176,11 @@ function Game:update(dt)
|
||||
-- deferred A and edge pulses land in Input's press queue for this step.
|
||||
TouchInput:update(dt)
|
||||
-- Fast-forward scales only the logic clock (see src/core/GameSpeed.lua).
|
||||
FixedStep:update(dt * self:logicSpeed())
|
||||
-- Give the accumulator room for one full frame at the current speed,
|
||||
-- or the anti-spiral clamp quietly caps every level above ~15X.
|
||||
local speed = self:logicSpeed()
|
||||
FixedStep.maxAccum = math.max(0.25, speed * FixedStep.STEP * 1.5)
|
||||
FixedStep:update(dt * speed)
|
||||
-- Audio runs off real time at a fixed 60Hz regardless of game speed or
|
||||
-- display refresh, so fades and chip synthesis keep their intended tempo
|
||||
-- whether we are at 1X, 10X, or running with vsync disabled.
|
||||
@@ -183,6 +193,7 @@ function Game:update(dt)
|
||||
-- Overworld tilt toggle tween: presentational, so it runs on the real
|
||||
-- frame dt (not the fixed logic step) for a smooth ~0.25s glide.
|
||||
require("src.render.Tilt").update(dt)
|
||||
pcall(function() require("src.core.DiscordPresence").update(dt) end)
|
||||
end
|
||||
|
||||
-- render.zones' identity default: unhooked, the zone list reaches the blit
|
||||
@@ -272,10 +283,22 @@ function Game:keypressed(key)
|
||||
self:zoomStep(1)
|
||||
return
|
||||
elseif key == "2" then
|
||||
-- cycle COLORS (GBC / OG / OG INV / GBC INV / CLASSIC); always on
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
self.save.options.colors = PaletteFX.cycleMode()
|
||||
self:writeOptions()
|
||||
-- cycle COLORS (GBC / OG / OG INV / GBC INV / CLASSIC); the pack change
|
||||
-- forces Game.overworld:reloadMap, which rebuilds the live NPC array, so
|
||||
-- hold it while a warp/transition or an on-screen scripted cutscene is
|
||||
-- driving the overworld rather than tear the escort's NPCs out mid-move
|
||||
local ow = self.overworld
|
||||
local top = self.stack:top()
|
||||
local busy = ow and (ow.transitioning
|
||||
or (top == ow and (
|
||||
(ow.runner and ow.runner.isRunning and ow.runner:isRunning())
|
||||
or (ow.scriptMoves and #ow.scriptMoves > 0)
|
||||
or ow.engaging or ow.emote)))
|
||||
if not busy then
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
self.save.options.colors = PaletteFX.cycleMode()
|
||||
self:writeOptions()
|
||||
end
|
||||
return
|
||||
elseif key == "3" then
|
||||
-- cycle TILT OFF → 15 → 35 → 50 → OFF (mnemonic: 3D), free-roam only
|
||||
@@ -326,6 +349,28 @@ function Game:gamepadaxis(joystick, axis, value)
|
||||
Input:gamepadaxis(joystick, axis, value)
|
||||
end
|
||||
|
||||
-- Window lost focus or got minimized: any release event due while it was
|
||||
-- unfocused/hidden can be swallowed by the OS instead of delivered here,
|
||||
-- which would otherwise leave a held direction stuck on.
|
||||
function Game:focus(f)
|
||||
if f then return end
|
||||
Input:reset()
|
||||
TouchInput:reset()
|
||||
end
|
||||
|
||||
function Game:visible(v)
|
||||
if v then return end
|
||||
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()
|
||||
end
|
||||
|
||||
function Game:touchpressed(id, x, y)
|
||||
TouchInput:touchpressed(id, x, y)
|
||||
end
|
||||
@@ -387,6 +432,7 @@ function Game:applyOptions(opts)
|
||||
require("src.render.PaletteFX").applyOptions(opts)
|
||||
require("src.render.Tilt").applyOptions(opts)
|
||||
require("src.render.GBCFX").applyOptions(opts)
|
||||
require("src.core.VideoMode").applyOptions(opts)
|
||||
end
|
||||
|
||||
function Game:restoreSave(loaded, recovered)
|
||||
|
||||
@@ -18,7 +18,7 @@ local GameSpeed = {}
|
||||
-- attempt is long enough that the iteration loop, not the engine, is the
|
||||
-- bottleneck. Vsync caps how much a real frame can do, so past 10X the
|
||||
-- multiplier is increasingly a ceiling rather than a rate.
|
||||
GameSpeed.LEVELS = { 1, 2, 4, 10, 20, 30, 50, 75 }
|
||||
GameSpeed.LEVELS = { 1, 2, 4, 10, 20, 30, 50, 75, 100,200 }
|
||||
GameSpeed.DEFAULT = 1
|
||||
|
||||
function GameSpeed.levelLabel(v)
|
||||
|
||||
@@ -32,6 +32,15 @@ local STICK_ON = 0.5
|
||||
local STICK_OFF = 0.3
|
||||
|
||||
function Input:init()
|
||||
self:reset()
|
||||
end
|
||||
|
||||
-- Purely event-driven state (press sets true, release sets false) has no
|
||||
-- fallback if a release event never arrives -- focus loss, a minimized
|
||||
-- window, or a disconnected gamepad can all swallow the key-up/button-up
|
||||
-- that would have cleared a held direction. Called from Game on those
|
||||
-- transitions so a stuck flag can't outlive them.
|
||||
function Input:reset()
|
||||
self.state = {}
|
||||
self.pressQueue = {}
|
||||
self.pressed = {}
|
||||
|
||||
@@ -114,6 +114,7 @@ local SPECIAL = {
|
||||
oakRoute = "Music_Routes2",
|
||||
bike = "Music_BikeRiding",
|
||||
surf = "Music_Surfing",
|
||||
evolution = "Music_SafariZone",
|
||||
}
|
||||
|
||||
-- the label a scene role resolves to; call sites keep their own presence
|
||||
|
||||
+108
-5
@@ -27,6 +27,107 @@ local OPTIONS_FILENAME = "options.lua"
|
||||
local BACKUP_FILENAME = FILENAME .. ".bak"
|
||||
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
|
||||
local candidates = {}
|
||||
if love.filesystem.getSourceBaseDirectory then
|
||||
candidates[#candidates + 1] = love.filesystem.getSourceBaseDirectory()
|
||||
end
|
||||
if love.filesystem.getSource then
|
||||
candidates[#candidates + 1] = love.filesystem.getSource()
|
||||
end
|
||||
for _, base in ipairs(candidates) do
|
||||
if base and base ~= "" and pathExists(base .. SEP .. PORTABLE_MARKER) then
|
||||
portableBase = base
|
||||
break
|
||||
end
|
||||
end
|
||||
return portableBase
|
||||
end
|
||||
|
||||
function SaveData.isPortable()
|
||||
return detectPortable() ~= false
|
||||
end
|
||||
|
||||
-- the raw portable-folder path (for callers building their own nested
|
||||
-- paths, e.g. the ROM-derived asset cache), or nil when portable mode
|
||||
-- is off
|
||||
function SaveData.portableBaseDir()
|
||||
return detectPortable() or nil
|
||||
end
|
||||
|
||||
-- the io.* filesystem for the active portable folder, or nil when off
|
||||
function SaveData.portableFs()
|
||||
local base = detectPortable()
|
||||
if not base then return nil end
|
||||
if not portableFsCache then portableFsCache = makePortableFs(base) end
|
||||
return portableFsCache
|
||||
end
|
||||
|
||||
-- Resolve the filesystem a persistent read/write should land on: an
|
||||
-- explicitly injected non-love fs (headless tests, the mod loader's stub)
|
||||
-- always wins; otherwise portable mode reroutes off the OS save directory.
|
||||
local function persistFs(fs)
|
||||
if fs and love and love.filesystem and fs ~= love.filesystem then
|
||||
return fs
|
||||
end
|
||||
return SaveData.portableFs() or fs or (love and love.filesystem)
|
||||
end
|
||||
|
||||
-- Port + original Options menu defaults. Missing keys on load are filled
|
||||
-- from this table so old options.lua files stay compatible.
|
||||
function SaveData.defaultOptions()
|
||||
@@ -47,6 +148,8 @@ function SaveData.defaultOptions()
|
||||
colors = "gbc",
|
||||
tilt = 0,
|
||||
gbcfx = 0,
|
||||
-- windowed | borderless (desktop fullscreen); ignored on mobile
|
||||
videoMode = "windowed",
|
||||
-- Native mod enablement is an installation option, not save-slot data.
|
||||
-- Missing entries mean enabled so newly installed mods work by default.
|
||||
mods = {},
|
||||
@@ -92,7 +195,7 @@ end
|
||||
-- love.filesystem, so the mod loader's injected filesystem can carry the
|
||||
-- options round-trip headless (no love global).
|
||||
function SaveData.saveOptions(opts, fs)
|
||||
fs = fs or love.filesystem
|
||||
fs = persistFs(fs)
|
||||
opts = SaveData.mergeOptions(opts)
|
||||
-- modOptions is per-mod nested state: fold the on-disk sub-tree
|
||||
-- underneath (newest value winning per key) so one caller's partial
|
||||
@@ -121,7 +224,7 @@ function SaveData.saveOptions(opts, fs)
|
||||
end
|
||||
|
||||
function SaveData.loadOptions(fs)
|
||||
fs = fs or love.filesystem
|
||||
fs = persistFs(fs)
|
||||
local data, err = readTable(fs, OPTIONS_FILENAME)
|
||||
if not data then
|
||||
if fs.getInfo(OPTIONS_FILENAME) then
|
||||
@@ -309,7 +412,7 @@ end)
|
||||
-- afterwards either way
|
||||
SaveData.addCoreMigration(1, function(save)
|
||||
if type(save.options) == "table"
|
||||
and not love.filesystem.getInfo(OPTIONS_FILENAME) then
|
||||
and not persistFs(nil).getInfo(OPTIONS_FILENAME) then
|
||||
SaveData.saveOptions(save.options)
|
||||
end
|
||||
end)
|
||||
@@ -340,7 +443,7 @@ function SaveData.save(data, mods)
|
||||
if k ~= "options" then gameOnly[k] = v end
|
||||
end
|
||||
local encoded = SaveSerializer.encode(gameOnly)
|
||||
local fs = love.filesystem
|
||||
local fs = persistFs(nil)
|
||||
if fs.getInfo(FILENAME) then
|
||||
local prev = fs.read(FILENAME)
|
||||
if prev then fs.write(BACKUP_FILENAME, prev) end
|
||||
@@ -369,7 +472,7 @@ end
|
||||
-- or corrupt and a staged/backup copy was promoted; Game surfaces the
|
||||
-- recovery on the load report
|
||||
function SaveData.load()
|
||||
local fs = love.filesystem
|
||||
local fs = persistFs(nil)
|
||||
local data, err = readTable(fs, FILENAME)
|
||||
local recovered
|
||||
if not data then
|
||||
|
||||
@@ -75,6 +75,21 @@ function TouchInput:init()
|
||||
self.selectFired = false -- one SELECT per two-finger gesture cluster
|
||||
end
|
||||
|
||||
-- LÖVE has no touchcancelled event, so a touch interrupted by the OS (app
|
||||
-- backgrounded mid-touch, a system gesture stealing the finger) never fires
|
||||
-- touchreleased and would otherwise strand its direction held forever.
|
||||
-- Called from Game alongside Input:reset() on focus/visibility loss.
|
||||
function TouchInput:reset()
|
||||
for _, touch in pairs(self.touches) do
|
||||
releaseDir(self, touch)
|
||||
end
|
||||
self.touches = {}
|
||||
self.pendingA = nil
|
||||
self.armed = {}
|
||||
self.autoRelease = {}
|
||||
self.selectFired = false
|
||||
end
|
||||
|
||||
local function pulse(self, key)
|
||||
Input:keypressed(key)
|
||||
self.armed[#self.armed + 1] = key
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
-- Windowed vs borderless (desktop) fullscreen.
|
||||
--
|
||||
-- Persisted as save.options.videoMode. Applied from OptionsMenu and on
|
||||
-- boot via Game:applyOptions. No-ops on mobile and in headless stubs
|
||||
-- that lack love.window.
|
||||
|
||||
local VideoMode = {}
|
||||
|
||||
VideoMode.MODES = { "windowed", "borderless" }
|
||||
VideoMode.DEFAULT = "windowed"
|
||||
|
||||
local LABELS = {
|
||||
windowed = "WINDOWED",
|
||||
borderless = "BORDERLESS",
|
||||
}
|
||||
|
||||
function VideoMode.normalize(mode)
|
||||
if mode == "borderless" then return "borderless" end
|
||||
return VideoMode.DEFAULT
|
||||
end
|
||||
|
||||
function VideoMode.modeLabel(mode)
|
||||
return LABELS[VideoMode.normalize(mode)] or LABELS[VideoMode.DEFAULT]
|
||||
end
|
||||
|
||||
function VideoMode.isMobile()
|
||||
if not love or not love.system or not love.system.getOS then return false end
|
||||
local osName = love.system.getOS()
|
||||
return osName == "Android" or osName == "iOS"
|
||||
end
|
||||
|
||||
-- Cycle windowed <-> borderless (dir ignored; two modes).
|
||||
function VideoMode.cycle(mode, _dir)
|
||||
return VideoMode.normalize(mode) == "borderless" and "windowed" or "borderless"
|
||||
end
|
||||
|
||||
-- Push the mode into the live window. Safe when love.window is missing.
|
||||
function VideoMode.apply(mode)
|
||||
if VideoMode.isMobile() then return end
|
||||
if not love or not love.window or not love.window.setFullscreen then return end
|
||||
mode = VideoMode.normalize(mode)
|
||||
if mode == "borderless" then
|
||||
-- "desktop" = borderless fullscreen matching the display
|
||||
love.window.setFullscreen(true, "desktop")
|
||||
else
|
||||
love.window.setFullscreen(false)
|
||||
end
|
||||
end
|
||||
|
||||
function VideoMode.applyOptions(opts)
|
||||
VideoMode.apply(opts and opts.videoMode)
|
||||
end
|
||||
|
||||
return VideoMode
|
||||
@@ -1253,14 +1253,24 @@ function RomExtractor:extractTrainers()
|
||||
"battle/trainers/" .. picture.imageBase .. ".png")
|
||||
written[picture.imageBase] = true
|
||||
end
|
||||
local parties = self:trainerParties(
|
||||
pointers.bank, partyStarts[index], partyEnds[index])
|
||||
-- ChiefData is empty in the ROM (the Celadon Chief battle is unused/
|
||||
-- cut content); tools/rom_manifest.json carries a hand-authored party
|
||||
-- for the trainers this project reimplements, since no ROM data exists
|
||||
-- to extract for them.
|
||||
if #parties == 0 then
|
||||
local override = self.manifest.trainerPartyOverrides
|
||||
and self.manifest.trainerPartyOverrides[trainerId]
|
||||
if override then parties = { copy(override) } end
|
||||
end
|
||||
out[trainerId] = {
|
||||
id = trainerId, index = index, name = decodedNames[index],
|
||||
source = "ROM:TrainerDataPointers",
|
||||
pic = picture and picture.path or nil,
|
||||
baseMoney = math.floor(Rom.bcd(rawMoney) / 100),
|
||||
aiMods = aiMods[index],
|
||||
parties = self:trainerParties(
|
||||
pointers.bank, partyStarts[index], partyEnds[index]),
|
||||
parties = parties,
|
||||
}
|
||||
self:tick("Trainers", index, #order)
|
||||
end
|
||||
@@ -1426,7 +1436,7 @@ end
|
||||
|
||||
function RomExtractor:extractField()
|
||||
self:beginStage("Interface artwork")
|
||||
local done, total = 0, 48
|
||||
local done, total = 0, 49
|
||||
local function tick()
|
||||
done = done + 1
|
||||
self:tick("Interface artwork", math.min(done, total), total)
|
||||
@@ -1576,6 +1586,34 @@ function RomExtractor:extractField()
|
||||
"fx/" .. spec[4], { transparent = true })
|
||||
tick()
|
||||
end
|
||||
|
||||
-- The cuttable tree's own sprite: InitCutAnimOAM (engine/overworld/cut.asm)
|
||||
-- copies Overworld_GFX tiles $2d-$2e (top half) and $3d-$3e (bottom half)
|
||||
-- into the OAM tiles the Cut animation slides apart, so this comes out of
|
||||
-- the OVERWORLD tileset's own graphics blob rather than a named symbol.
|
||||
do
|
||||
local overworldIndex
|
||||
for i, name in ipairs(self.manifest.constants.tilesetOrder) do
|
||||
if name == "OVERWORLD" then overworldIndex = i break end
|
||||
end
|
||||
assert(overworldIndex, "OVERWORLD tileset not found in tilesetOrder")
|
||||
local tilesetHeaders = self:symbol("Tilesets")
|
||||
local rowAddress = tilesetHeaders.address + (overworldIndex - 1) * 12
|
||||
local gfxBank = self.rom:byte(tilesetHeaders.bank, rowAddress)
|
||||
local gfxPointer = self.rom:word(tilesetHeaders.bank, rowAddress + 3)
|
||||
local cutTree = ImageWriter.blank(16, 16, 1, 1, 1, 0)
|
||||
for _, spec in ipairs({
|
||||
{ 0x2d, 0, 0 }, { 0x2e, 8, 0 }, { 0x3d, 0, 8 }, { 0x3e, 8, 8 },
|
||||
}) do
|
||||
local tileIndex, dx, dy = spec[1], spec[2], spec[3]
|
||||
local tile = ImageWriter.decode2bpp(
|
||||
self.rom:bytes(gfxBank, gfxPointer + tileIndex * 16, 16),
|
||||
8, 8, true)
|
||||
ImageWriter.blit(cutTree, tile, dx, dy)
|
||||
end
|
||||
self:save(cutTree, "fx/cut_tree.png"); tick()
|
||||
end
|
||||
|
||||
self:raw2bpp("BattleTransitionTile", 8, 8,
|
||||
"fx/battle_transition.png"); tick()
|
||||
self:raw2bpp("PokedexTileGraphics", 24, 48,
|
||||
|
||||
+133
-4
@@ -2,7 +2,7 @@ local RomImporter = {}
|
||||
RomImporter.__index = RomImporter
|
||||
|
||||
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 COMMUNITY_URL = "https://bois.icu"
|
||||
local TRUST_WARNING = "if you did not get this from bryanthaboi's github " ..
|
||||
@@ -38,8 +38,124 @@ local function sourceTreeHasData()
|
||||
return real == love.filesystem.getSource()
|
||||
end
|
||||
|
||||
-- ------- portable ROM-derived asset cache
|
||||
--
|
||||
-- The extracted cache (data/generated, assets/generated) is written
|
||||
-- exclusively through love.filesystem.write, which always targets the OS
|
||||
-- save directory -- it cannot be redirected to an arbitrary folder. So a
|
||||
-- portable install mirrors the cache both ways instead: after a fresh
|
||||
-- import, every generated file is copied out to the portable folder
|
||||
-- (SaveData.portableFs's io.* companion); on a later boot -- possibly on a
|
||||
-- different machine sharing the same USB copy -- a matching portable
|
||||
-- cache is copied back into the save directory before the normal
|
||||
-- isReady() check runs, so nothing downstream needs to know the cache
|
||||
-- ever lived anywhere but the save directory.
|
||||
local PORTABLE_CACHE_DIRS = { "data/generated", "assets/generated" }
|
||||
local PORTABLE_MANIFEST_NAME = "portable_cache_manifest.txt"
|
||||
local PORTABLE_SEP = package.config:sub(1, 1)
|
||||
|
||||
local function walkLoveDir(dir, out)
|
||||
out = out or {}
|
||||
for _, name in ipairs(love.filesystem.getDirectoryItems(dir)) do
|
||||
local full = dir .. "/" .. name
|
||||
local info = love.filesystem.getInfo(full)
|
||||
if info and info.type == "directory" then
|
||||
walkLoveDir(full, out)
|
||||
elseif info and info.type == "file" then
|
||||
out[#out + 1] = full
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
local function portablePath(base, relPath)
|
||||
return base .. PORTABLE_SEP .. relPath:gsub("/", PORTABLE_SEP)
|
||||
end
|
||||
|
||||
local function ensurePortableDir(fullDirPath)
|
||||
if love.system.getOS() == "Windows" then
|
||||
os.execute(('mkdir "%s" 2>NUL'):format(fullDirPath))
|
||||
else
|
||||
os.execute(("mkdir -p '%s' 2>/dev/null"):format(fullDirPath))
|
||||
end
|
||||
end
|
||||
|
||||
-- copies data/generated + assets/generated out to the portable folder
|
||||
-- after a fresh import; a plain-text manifest travels alongside so a
|
||||
-- later sync-in knows exactly which files to copy back without needing
|
||||
-- to list an arbitrary external directory (io.* has no listdir)
|
||||
local function syncCacheToPortable()
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local base = SaveData.portableBaseDir()
|
||||
if not base then return end
|
||||
local manifest = {}
|
||||
for _, dir in ipairs(PORTABLE_CACHE_DIRS) do
|
||||
if love.filesystem.getInfo(dir, "directory") then
|
||||
for _, relPath in ipairs(walkLoveDir(dir)) do
|
||||
local data = love.filesystem.read(relPath)
|
||||
if data then
|
||||
local outPath = portablePath(base, relPath)
|
||||
local outDir = outPath:match("^(.*)" .. PORTABLE_SEP .. "[^" .. PORTABLE_SEP .. "]+$")
|
||||
if outDir then ensurePortableDir(outDir) end
|
||||
local f, err = io.open(outPath, "wb")
|
||||
if f then
|
||||
f:write(data)
|
||||
f:close()
|
||||
manifest[#manifest + 1] = relPath
|
||||
else
|
||||
require("src.core.Logger").error(
|
||||
"portable cache: could not write %s: %s", outPath, tostring(err))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
local mf = io.open(base .. PORTABLE_SEP .. PORTABLE_MANIFEST_NAME, "wb")
|
||||
if mf then
|
||||
mf:write(table.concat(manifest, "\n"))
|
||||
mf:close()
|
||||
end
|
||||
local mk = io.open(base .. PORTABLE_SEP .. MARKER_PATH, "wb")
|
||||
if mk then
|
||||
mk:write(CACHE_MARKER)
|
||||
mk:close()
|
||||
end
|
||||
end
|
||||
|
||||
-- copies a matching portable cache back into the save directory before
|
||||
-- isReady() runs its normal check; a mismatched or missing marker means
|
||||
-- either no portable cache exists yet or it belongs to an older build, so
|
||||
-- it is left alone and a fresh import proceeds as usual
|
||||
local function syncCacheFromPortable()
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local base = SaveData.portableBaseDir()
|
||||
if not base then return end
|
||||
local markerFile = io.open(base .. PORTABLE_SEP .. MARKER_PATH, "rb")
|
||||
if not markerFile then return end
|
||||
local marker = markerFile:read("*a")
|
||||
markerFile:close()
|
||||
if marker ~= CACHE_MARKER then return end
|
||||
local manifestFile = io.open(base .. PORTABLE_SEP .. PORTABLE_MANIFEST_NAME, "rb")
|
||||
if not manifestFile then return end
|
||||
local manifestBody = manifestFile:read("*a")
|
||||
manifestFile:close()
|
||||
for relPath in manifestBody:gmatch("[^\r\n]+") do
|
||||
local f = io.open(portablePath(base, relPath), "rb")
|
||||
if f then
|
||||
local data = f:read("*a")
|
||||
f:close()
|
||||
love.filesystem.write(relPath, data)
|
||||
end
|
||||
end
|
||||
love.filesystem.write(MARKER_PATH, CACHE_MARKER)
|
||||
end
|
||||
|
||||
function RomImporter.isReady()
|
||||
if sourceTreeHasData() then return true end
|
||||
if love.filesystem.read(MARKER_PATH) ~= CACHE_MARKER
|
||||
and require("src.core.SaveData").isPortable() then
|
||||
syncCacheFromPortable()
|
||||
end
|
||||
return love.filesystem.read(MARKER_PATH) == CACHE_MARKER
|
||||
and allRequiredFilesExist()
|
||||
end
|
||||
@@ -136,13 +252,20 @@ local function chooseRom()
|
||||
end
|
||||
|
||||
function RomImporter.new(onComplete)
|
||||
local previousMarker = love.filesystem.read(MARKER_PATH)
|
||||
local returning = previousMarker ~= nil and previousMarker ~= CACHE_MARKER
|
||||
return setmetatable({
|
||||
onComplete = onComplete,
|
||||
logo = love.graphics.newImage("assets/logo/logo.png"),
|
||||
bcg = love.graphics.newImage("assets/logo/bcg.png"),
|
||||
state = "waiting",
|
||||
status = "Choose or drop a Pokemon Red ROM",
|
||||
detail = "The ROM is verified before any files are created.",
|
||||
returning = returning,
|
||||
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,
|
||||
stageCurrent = 0,
|
||||
stageTotal = 1,
|
||||
@@ -204,6 +327,11 @@ function RomImporter:startData(data, displayName)
|
||||
collectgarbage("collect")
|
||||
local ok, writeError = love.filesystem.write(MARKER_PATH, CACHE_MARKER)
|
||||
if not ok then error("could not finish the private cache: " .. tostring(writeError)) end
|
||||
if require("src.core.SaveData").isPortable() then
|
||||
self.status = "Copying data to the portable folder"
|
||||
coroutine.yield()
|
||||
syncCacheToPortable()
|
||||
end
|
||||
self.state = "complete"
|
||||
self.status = "Ready"
|
||||
self.detail = "Starting Pokemon Red..."
|
||||
@@ -304,7 +432,8 @@ function RomImporter:draw()
|
||||
height * 0.075,
|
||||
0, logoScale, logoScale)
|
||||
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)
|
||||
setColor255(215, 220, 202)
|
||||
|
||||
@@ -11,6 +11,10 @@ local function isBadge(id)
|
||||
return id:find("BADGE", 1, true) ~= nil
|
||||
end
|
||||
|
||||
-- exported so item lists that share save.inventory (e.g. the PC deposit
|
||||
-- menu) can exclude badges the same way the bag does
|
||||
Bag.isBadge = isBadge
|
||||
|
||||
function Bag.slots(save)
|
||||
local n = 0
|
||||
for id in pairs(save.inventory) do
|
||||
|
||||
+3
-1
@@ -129,7 +129,9 @@ end
|
||||
-- tripwire on for tests that cannot set the environment.
|
||||
function Loader.new(opts)
|
||||
local dev = opts and opts.dev
|
||||
if dev == nil then dev = os.getenv("POKEPORT_DEV") == "1" end
|
||||
if dev == nil then
|
||||
dev = os.getenv("POKEPORT_DEV") == "1" or _G.POKEPORT_DEV_MODE == true
|
||||
end
|
||||
local self = setmetatable({
|
||||
mods = {}, loaded = {}, errors = {}, initialized = false,
|
||||
events = Events.new(), hooks = Hooks.new(), content = {}, assets = {},
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
-- "trade" | "manual" | <mod>, item = id?, ... }), wrapped by the
|
||||
-- evolution.check hook so a mod can cancel or force any evolution.
|
||||
|
||||
local Music = require("src.core.Music")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local Stats = require("src.pokemon.Stats")
|
||||
@@ -112,11 +113,15 @@ function Evolution.evolve(game, mon, newSpecies, onDone, via)
|
||||
Screens.push(game, "EvolutionState", mon, newSpecies, onDone)
|
||||
return
|
||||
end
|
||||
Music.play(game.data, Music.special(game.data, "evolution"))
|
||||
local oldName = mon.nickname or game.data.pokemon[mon.species].name
|
||||
Evolution.apply(game, mon, newSpecies, via)
|
||||
local msg = ("What?\n%s is\nevolving!\fCongratulations!\nYour %s\nevolved into\n%s!")
|
||||
:format(oldName, oldName, game.data.pokemon[newSpecies].name)
|
||||
game.stack:push(TextBox.new(game, msg, onDone))
|
||||
game.stack:push(TextBox.new(game, msg, function()
|
||||
Music.restoreMap(game.data)
|
||||
if onDone then onDone() end
|
||||
end))
|
||||
end
|
||||
|
||||
-- Entry point for mods whose methods fire outside the vanilla moments
|
||||
|
||||
@@ -9,12 +9,18 @@ local Pokemon = {}
|
||||
-- keeping the most recent four (engine/pokemon/learn_move.asm behavior).
|
||||
function Pokemon.movesAtLevel(speciesDef, level)
|
||||
local moves = {}
|
||||
local function add(id)
|
||||
for _, existing in ipairs(moves) do
|
||||
if existing == id then return end
|
||||
end
|
||||
table.insert(moves, id)
|
||||
end
|
||||
for _, m in ipairs(speciesDef.level1Moves) do
|
||||
table.insert(moves, m)
|
||||
add(m)
|
||||
end
|
||||
for _, entry in ipairs(speciesDef.learnset) do
|
||||
if entry.level <= level then
|
||||
table.insert(moves, entry.move)
|
||||
add(entry.move)
|
||||
end
|
||||
end
|
||||
while #moves > 4 do
|
||||
|
||||
@@ -86,10 +86,11 @@ function HudTiles.drawHPBar(data, tx, ty, mon, barType)
|
||||
px = math.max(1, math.floor(mon.hp * 48 / mon.stats.hp))
|
||||
end
|
||||
local tint
|
||||
local pals = data.palettes
|
||||
if pals then
|
||||
local name = px >= 27 and "GREENBAR" or px >= 10 and "YELLOWBAR" or "REDBAR"
|
||||
local c = pals.palettes[name][3] -- GB color 2 is the fill shade
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local name = px >= 27 and "GREENBAR" or px >= 10 and "YELLOWBAR" or "REDBAR"
|
||||
local colors = PaletteFX.pal(data, name)
|
||||
if colors then
|
||||
local c = colors[3] -- GB color 2 is the fill shade
|
||||
-- the fill pixels are the 2/3-gray shade; divide so they land on
|
||||
-- the palette color exactly (the black outline stays black)
|
||||
tint = { math.min(1, c[1] / 170), math.min(1, c[2] / 170),
|
||||
|
||||
+277
-14
@@ -5,17 +5,20 @@
|
||||
-- then drawn once per zone through a shader that remaps the four DMG
|
||||
-- shades to that zone's palette.
|
||||
--
|
||||
-- Port display option: COLORS (GBC / OG / OG INV / GBC INV / CLASSIC)
|
||||
-- Port display option: COLORS (GBC / RED++ / OG / OG INV / GBC INV / CLASSIC)
|
||||
-- transforms every zone's palette at send time via effectiveColors.
|
||||
-- RED++ swaps the named-palette pack for pokered-gbc SuperPalettes
|
||||
-- (data/palettes_gbc.lua), including per-species mon colors.
|
||||
|
||||
local PaletteFX = {}
|
||||
|
||||
local shader -- false = unavailable (headless / no shader support)
|
||||
local gbcPack -- false = missing; nil = not loaded yet
|
||||
|
||||
-- Cycle order matches OptionsMenu / hotkey 2
|
||||
PaletteFX.MODES = { "gbc", "og", "og_inv", "gbc_inv", "classic" }
|
||||
PaletteFX.MODES = { "gbc", "redpp", "og", "og_inv", "gbc_inv", "classic" }
|
||||
PaletteFX.MODE_LABELS = {
|
||||
gbc = "GBC", og = "OG", og_inv = "OG INV",
|
||||
gbc = "GBC", redpp = "RED++", og = "OG", og_inv = "OG INV",
|
||||
gbc_inv = "GBC INV", classic = "CLASSIC",
|
||||
}
|
||||
PaletteFX.mode = "gbc"
|
||||
@@ -124,22 +127,258 @@ function PaletteFX.whole(colors)
|
||||
return PaletteFX.zone(colors, 0, 0, 19, 17)
|
||||
end
|
||||
|
||||
-- named palette from data/generated/palettes.lua (nil on stale builds)
|
||||
-- Red++ / pokered-gbc SuperPalette pack (committed; optional if absent).
|
||||
function PaletteFX.gbcPack()
|
||||
if gbcPack == nil then
|
||||
local ok, pack = pcall(require, "data.palettes_gbc")
|
||||
gbcPack = ok and pack or false
|
||||
end
|
||||
return gbcPack or nil
|
||||
end
|
||||
|
||||
function PaletteFX.usesGbcPack(mode)
|
||||
mode = mode or PaletteFX.mode
|
||||
return mode == "redpp"
|
||||
end
|
||||
|
||||
-- Per-object overworld sprite coloring (ColorOverworldSprite) applies in
|
||||
-- plain GBC mode too, not only under the RED++ pack: without it the
|
||||
-- whole-map zone shader paints characters with whatever two mid shades
|
||||
-- the terrain palette defines. RED++ handles sprites through the baked
|
||||
-- usesGbcPack() path in SpriteRenderer; this names the modes where the
|
||||
-- OBP bake plus the post-zone redraw (below) stand in for real OBJ
|
||||
-- palettes over a shader-colorized background.
|
||||
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,
|
||||
-- everything else uses the ROM-imported data.palettes.
|
||||
function PaletteFX.pack(data)
|
||||
if PaletteFX.usesGbcPack() then
|
||||
local g = PaletteFX.gbcPack()
|
||||
if g then return g end
|
||||
end
|
||||
return data and data.palettes or nil
|
||||
end
|
||||
|
||||
-- named palette from the active pack (nil on stale builds / missing name).
|
||||
-- RED++ falls back to the ROM pack for names the gbc table omits (rare).
|
||||
function PaletteFX.pal(data, name)
|
||||
local p = data.palettes
|
||||
return p and p.palettes[name] or nil
|
||||
local p = PaletteFX.pack(data)
|
||||
local c = p and p.palettes[name]
|
||||
if c then return c end
|
||||
if PaletteFX.usesGbcPack() and data and data.palettes then
|
||||
return data.palettes.palettes[name]
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- the species' palette (data/pokemon/palettes.asm), MEWMON for unknowns.
|
||||
-- transformed forces PAL_GRAYMON (Ditto's palette) regardless of species
|
||||
-- (engine/gfx/palettes.asm DeterminePaletteID: bit TRANSFORMED, a; a
|
||||
-- Transformed mon's pic is tinted gray, not the copied species' own
|
||||
-- SGB color).
|
||||
-- SGB color). RED++ uses per-species pals from mon_palettes.asm.
|
||||
function PaletteFX.monPal(data, species, transformed)
|
||||
local p = data.palettes
|
||||
local p = PaletteFX.pack(data)
|
||||
if not p then return nil end
|
||||
if transformed then return p.palettes.GRAYMON end
|
||||
return p.palettes[p.pokemon[species] or "MEWMON"]
|
||||
if transformed then
|
||||
return p.palettes.GRAYMON
|
||||
or (data and data.palettes and data.palettes.palettes.GRAYMON)
|
||||
end
|
||||
local name = p.pokemon[species] or "MEWMON"
|
||||
local c = p.palettes[name]
|
||||
if c then return c end
|
||||
if PaletteFX.usesGbcPack() and data and data.palettes then
|
||||
name = data.palettes.pokemon[species] or "MEWMON"
|
||||
return data.palettes.palettes[name]
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- palette name a species currently resolves to (for image-cache keys)
|
||||
function PaletteFX.monPalName(data, species, transformed)
|
||||
if transformed then return "GRAYMON" end
|
||||
local p = PaletteFX.pack(data)
|
||||
if p and p.pokemon[species] then return p.pokemon[species] end
|
||||
if data and data.palettes and data.palettes.pokemon[species] then
|
||||
return data.palettes.pokemon[species]
|
||||
end
|
||||
return "MEWMON"
|
||||
end
|
||||
|
||||
-- ------- true GBC overworld coloring (color/loadpalettes.asm,
|
||||
-- color/data/*, color/sprites.asm ColorOverworldSprite) -------------------
|
||||
--
|
||||
-- RED++ pairs its named-palette battle/mon colors above with pokered-gbc's
|
||||
-- real per-tile system: LoadTilesetPalette assigns one of 8 four-color BG
|
||||
-- palettes to every tile GRAPHIC in a tileset (by tile id, not by map
|
||||
-- position), and LoadTownPalette swaps just the ROOF slot (index 6) per
|
||||
-- town/route. `data/palettes_gbc.lua`'s `world` table holds the extracted
|
||||
-- data (tools/extract/palettes.py extract_gbc_world); these queries are
|
||||
-- mode-independent (only check the pack exists) so TileRenderer can
|
||||
-- precompute geometry once regardless of the active COLORS mode -- callers
|
||||
-- that resolve to actual on-screen COLOR should gate on usesGbcPack()
|
||||
-- themselves, the same way they already gate other RED++-only behavior.
|
||||
--
|
||||
-- LoadTilesetPalette's 3 hardcoded single-tile fixes (Celadon Mart) and
|
||||
-- LoadTownPalette's Route 6/Saffron y<2 roof split are control flow, not
|
||||
-- data, so they are not in the extracted pack -- they live here instead.
|
||||
local TILE_GROUP_EXCEPTIONS = {
|
||||
-- tile ids $4b-$4f -> BLUE (outside sky, seen through the mart's roof)
|
||||
CELADON_MART_ROOF = { tiles = { [0x4b] = true, [0x4c] = true, [0x4d] = true,
|
||||
[0x4e] = true, [0x4f] = true }, group = 3 },
|
||||
-- tile $37 -> BROWN (counter miscoloration fix)
|
||||
CELADON_MART_3F = { tiles = { [0x37] = true }, group = 5 },
|
||||
-- tiles $07/$08/$17/$18 -> YELLOW (bench, blue by default)
|
||||
CELADON_MART_1F = { tiles = { [0x07] = true, [0x08] = true,
|
||||
[0x17] = true, [0x18] = true }, group = 4 },
|
||||
}
|
||||
|
||||
-- keyed by tileset id (applies on every map that uses it), consulted after
|
||||
-- the per-map table above
|
||||
local TILESET_GROUP_EXCEPTIONS = {
|
||||
-- tile $22 (the hollow-square grave marker) -> GRAY: the extracted pack
|
||||
-- files it under the bright blue family, which makes a purely
|
||||
-- decorative floor marker read as an interactive pad
|
||||
CEMETERY = { tiles = { [0x22] = true }, group = 0 },
|
||||
}
|
||||
|
||||
-- pokered-gbc's lobby.bst repoints the Celadon 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 ROUTE_6_SAFFRON = { mapId = "ROUTE_6", useMapId = "SAFFRON_CITY", cellYBelow = 2 }
|
||||
|
||||
-- whether the extracted pack has real per-tile GBC data for this tileset
|
||||
-- (false for a mod tileset with no pokered-gbc counterpart, or when the
|
||||
-- pack failed to load at all)
|
||||
function PaletteFX.hasWorldTileset(tileset)
|
||||
local pack = PaletteFX.gbcPack()
|
||||
local w = pack and pack.world
|
||||
return (w and w.tileGroups[tileset]) ~= nil
|
||||
end
|
||||
|
||||
-- the palette-group (0-7) a tile GRAPHIC id resolves to in this tileset,
|
||||
-- with the current map's tile-id exceptions (if any) applied first
|
||||
function PaletteFX.worldGroupAt(tileset, mapId, tileId)
|
||||
local pack = PaletteFX.gbcPack()
|
||||
local w = pack and pack.world
|
||||
local groups = w and w.tileGroups[tileset]
|
||||
if not groups then return nil end
|
||||
local exc = TILE_GROUP_EXCEPTIONS[mapId]
|
||||
if exc and exc.tiles[tileId] then return exc.group end
|
||||
exc = TILESET_GROUP_EXCEPTIONS[tileset]
|
||||
if exc and exc.tiles[tileId] then return exc.group end
|
||||
return groups[tileId] or 7 -- TEXT: tile ids past the tileset's 96 (menus)
|
||||
end
|
||||
|
||||
-- this tileset's resolved 8-entry {r,g,b}x4 palette array, with the ROOF
|
||||
-- slot swapped to the current town/route (Route 6's north end uses
|
||||
-- Saffron's roof colors while the player stands in its top 2 cell rows,
|
||||
-- like pokered's wYCoord check -- data is Game.data, for the map lookup)
|
||||
function PaletteFX.worldGroupColors(data, tileset, mapId, playerCellY)
|
||||
local pack = PaletteFX.gbcPack()
|
||||
local w = pack and pack.world
|
||||
local base = w and w.groupColors[tileset]
|
||||
if not base then return nil end
|
||||
if not w.roofGroup[tileset] then return base end
|
||||
local roofMapId = mapId
|
||||
if mapId == ROUTE_6_SAFFRON.mapId and playerCellY
|
||||
and playerCellY < ROUTE_6_SAFFRON.cellYBelow then
|
||||
roofMapId = ROUTE_6_SAFFRON.useMapId
|
||||
end
|
||||
local roofMap = data and data.maps and data.maps[roofMapId]
|
||||
local roof = roofMap and w.roofByMapIndex[roofMap.index]
|
||||
if not roof then return base end
|
||||
local out = {}
|
||||
for i = 1, 8 do out[i] = base[i] end
|
||||
-- LoadTownPalette only overwrites W2_BgPaletteData + $32, i.e. colors 1
|
||||
-- and 2 (0-indexed) of the 4-color ROOF slot -- color 0 (background,
|
||||
-- typically the sky-through-gaps white) and color 3 (outline black) keep
|
||||
-- the tileset's own OUTDOOR_ROOF/INDOOR_ROOF base, only the roof
|
||||
-- material's 2 middle shades are town-specific
|
||||
local base4 = base[ROOF_GROUP + 1]
|
||||
out[ROOF_GROUP + 1] = { base4[1], roof[1], roof[2], base4[4] }
|
||||
return out
|
||||
end
|
||||
|
||||
-- an overworld sprite's resolved 4-color OBJ palette (ColorOverworldSprite),
|
||||
-- or nil when unassigned/unavailable, plus the resolved group index (for
|
||||
-- callers that want a stable cache key without hashing the colors table).
|
||||
-- spriteDef carries the ROM picture-id crosswalk in its `source` field
|
||||
-- ("ROM:SpriteSheetPointerTable[N]"); seed (any stable per-instance value,
|
||||
-- e.g. an NPC's `id`) resolves the "random" sentinel -- a deliberate
|
||||
-- approximation of ColorOverworldSprite's per-OAM-slot pseudo-random pick
|
||||
-- (`swap a; and 3` on the sprite's OAM offset, which has no equivalent
|
||||
-- here): a stable hash instead, so the same NPC instance always shows the
|
||||
-- same one of the 4 SPR_PAL_* colors.
|
||||
function PaletteFX.spriteObp(spriteDef, seed)
|
||||
local pack = PaletteFX.gbcPack()
|
||||
local w = pack and pack.world
|
||||
local src = spriteDef and spriteDef.source
|
||||
if not (w and src) then return nil end
|
||||
local idx = tonumber(src:match("%[(%d+)%]"))
|
||||
-- RedBikeSprite loads outside SpriteSheetPointerTable
|
||||
-- (LoadBikePlayerSpriteGraphics), so its source carries no bracketed
|
||||
-- index; it wears the player's own palette, same as SPRITE_RED
|
||||
if not idx and src:find("RedBikeSprite", 1, true) then idx = 0 end
|
||||
local group = idx and w.spriteAssignment[idx]
|
||||
if group == nil then return nil end
|
||||
if group == "random" then
|
||||
local h = 0
|
||||
seed = tostring(seed or "")
|
||||
for i = 1, #seed do h = (h * 31 + seed:byte(i)) % 4294967296 end
|
||||
group = h % 4
|
||||
end
|
||||
return w.spritePalettes[group], group
|
||||
end
|
||||
|
||||
-- GetHealthBarColor (home/palettes.asm) on the standard 48px bar
|
||||
@@ -173,13 +412,35 @@ function PaletteFX.permute(colors, map)
|
||||
end
|
||||
|
||||
function PaletteFX.setMode(mode)
|
||||
local prev = PaletteFX.mode
|
||||
local ok = false
|
||||
for _, m in ipairs(PaletteFX.MODES) do
|
||||
if m == mode then
|
||||
PaletteFX.mode = mode
|
||||
return
|
||||
ok = true
|
||||
break
|
||||
end
|
||||
end
|
||||
PaletteFX.mode = "gbc"
|
||||
if not ok then PaletteFX.mode = "gbc" end
|
||||
-- battle pics and overworld sprites bake the active pack into ImageData;
|
||||
-- drop those caches when the pack (or any COLORS mode) changes so the
|
||||
-- next draw re-tints
|
||||
if prev ~= PaletteFX.mode then
|
||||
pcall(function() require("src.battle.BattleState").invalidate() end)
|
||||
pcall(function() require("src.render.SpriteRenderer").invalidate() end)
|
||||
-- RED++'s baked tileset atlas (TileRenderer.getGbcAtlas) is built once
|
||||
-- per loaded map, so a mode toggle needs every cached Map/TileRenderer
|
||||
-- dropped and the currently-visible one rebuilt in place -- otherwise
|
||||
-- the on-screen map keeps its stale (wrong-mode) atlas until the next
|
||||
-- map transition happens to reload it.
|
||||
pcall(function()
|
||||
require("src.world.MapLoader").invalidateAll()
|
||||
local Game = require("src.core.Game")
|
||||
if Game.overworld and Game.overworld.map and Game.overworld.reloadMap then
|
||||
Game.overworld:reloadMap(Game.overworld.map.id, "colors")
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
function PaletteFX.cycleMode()
|
||||
@@ -188,7 +449,7 @@ function PaletteFX.cycleMode()
|
||||
for i, m in ipairs(PaletteFX.MODES) do
|
||||
if m == cur then idx = i; break end
|
||||
end
|
||||
PaletteFX.mode = PaletteFX.MODES[idx % #PaletteFX.MODES + 1]
|
||||
PaletteFX.setMode(PaletteFX.MODES[idx % #PaletteFX.MODES + 1])
|
||||
return PaletteFX.mode
|
||||
end
|
||||
|
||||
@@ -202,7 +463,7 @@ end
|
||||
|
||||
-- When a state exposes no SGB zones but COLORS needs a forced palette
|
||||
-- (OG / OG INV / CLASSIC), invent a whole-screen zone so the shade-remap
|
||||
-- shader still runs. GBC / GBC INV leave nil alone (raw DMG canvas).
|
||||
-- shader still runs. GBC / RED++ / GBC INV leave nil alone (raw DMG canvas).
|
||||
function PaletteFX.ensureZones(zones)
|
||||
if zones and zones[1] then return zones end
|
||||
local mode = PaletteFX.mode or "gbc"
|
||||
@@ -213,6 +474,8 @@ function PaletteFX.ensureZones(zones)
|
||||
end
|
||||
|
||||
-- Transform a 4-color palette for the active COLORS display mode.
|
||||
-- GBC and RED++ pass the zone colors through (RED++ already swapped the
|
||||
-- pack in pal/monPal); OG* / CLASSIC replace; GBC INV permutes shades.
|
||||
function PaletteFX.effectiveColors(c)
|
||||
if not c then return nil end
|
||||
local mode = PaletteFX.mode or "gbc"
|
||||
|
||||
+32
-1
@@ -62,8 +62,10 @@ end
|
||||
function Renderer:beginFrame(transparent)
|
||||
self.worldActive = 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.clearSpriteRedraws()
|
||||
PaletteFX.setPass("ui")
|
||||
love.graphics.setCanvas(self.canvas)
|
||||
if transparent then
|
||||
@@ -363,6 +365,35 @@ function Renderer:endFrame(zones, worldZones)
|
||||
else
|
||||
blit(self.worldCanvas, s, zones, S, wox, woy, 0, 0, ww, wh)
|
||||
end
|
||||
-- OBP-baked overworld sprites replay on top of the zone pass (GBC
|
||||
-- mode per-object coloring; see PaletteFX.markSpriteRedraw). Grass
|
||||
-- feet-overdraw entries carry `colors` and re-colorize through the
|
||||
-- color-0-keyed shade-remap shader so they keep hiding sprite feet.
|
||||
local redraws = PaletteFX.spriteRedraws()
|
||||
if redraws[1] then
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.setScissor(0, 0, ww, wh)
|
||||
local activeShader = nil
|
||||
for _, r in ipairs(redraws) do
|
||||
local wanted = r.colors
|
||||
and (r.keyed and PaletteFX.keyedShader() or PaletteFX.shader())
|
||||
or nil
|
||||
if wanted ~= activeShader then
|
||||
activeShader = wanted
|
||||
love.graphics.setShader(wanted)
|
||||
end
|
||||
if wanted then PaletteFX.sendColors(wanted, r.colors) end
|
||||
if r.quad then
|
||||
love.graphics.draw(r.image, r.quad, wox + r.x * s, woy + r.y * s,
|
||||
0, s * r.sx, s)
|
||||
else
|
||||
love.graphics.draw(r.image, wox + r.x * s, woy + r.y * s,
|
||||
0, s * r.sx, s)
|
||||
end
|
||||
end
|
||||
if activeShader then love.graphics.setShader() end
|
||||
love.graphics.setScissor()
|
||||
end
|
||||
end
|
||||
-- Composite the tilt upright pass over the ground (projected or, in the
|
||||
-- rare no-shader fallback, flat). It already carries its billboards'
|
||||
|
||||
@@ -18,10 +18,49 @@ local function getImage(path)
|
||||
return imageCache[path]
|
||||
end
|
||||
|
||||
-- RED++ overworld sprite OBJ-palette recolor (color/sprites.asm
|
||||
-- ColorOverworldSprite), baked into an ImageData like BattleState's mon-pic
|
||||
-- palette bake (src/battle/BattleState.lua getImage): CPU-remap the 4 DMG
|
||||
-- shades to the resolved OBP colors, cached per (image path, group).
|
||||
--
|
||||
-- Sprite sheets carry no real alpha (every pixel, including the
|
||||
-- background, is opaque -- confirmed by sampling the extracted PNGs): the
|
||||
-- "transparent" look in every other draw path is a coincidence of the
|
||||
-- whole-canvas shade-remap shader, where shade 0 (white) happens to map to
|
||||
-- a similarly light color in whatever terrain zone the sprite stands over.
|
||||
-- That coincidence breaks once terrain is colored per-tile instead of one
|
||||
-- flat color per map (different tiles can have very different color-0s),
|
||||
-- so shade 0 is keyed to alpha 0 here explicitly -- matching real GBC OBJ
|
||||
-- hardware, where sprite palette index 0 is unconditionally transparent
|
||||
-- (same rule TileRenderer's getColor0KeyShader documents for tall grass).
|
||||
local obpCache = {}
|
||||
|
||||
local function getObpImage(path, colors, group)
|
||||
local key = path .. "#obp" .. group
|
||||
if not obpCache[key] then
|
||||
local img
|
||||
if love.image and love.image.newImageData then
|
||||
local id = Assets.imageData(path)
|
||||
id:mapPixel(function(_, _, r, g, b, a)
|
||||
if a == 0 then return r, g, b, a end
|
||||
if r > 0.83 then return r, g, b, 0 end -- OBJ color 0: always transparent
|
||||
local col = r > 0.5 and colors[2] or r > 0.17 and colors[3] or colors[4]
|
||||
return col[1] / 255, col[2] / 255, col[3] / 255, a
|
||||
end)
|
||||
img = love.graphics.newImage(id)
|
||||
else
|
||||
img = getImage(path) -- headless stub: no pixel access
|
||||
end
|
||||
obpCache[key] = img
|
||||
end
|
||||
return obpCache[key]
|
||||
end
|
||||
|
||||
-- hot reload drops the sheets; live instances hold their own image, so
|
||||
-- the world rebuilds them (MapLoader.invalidateAll) rather than this
|
||||
function SpriteRenderer.invalidate()
|
||||
imageCache = {}
|
||||
obpCache = {}
|
||||
end
|
||||
|
||||
Assets.register(SpriteRenderer.invalidate)
|
||||
@@ -29,9 +68,12 @@ Assets.register(SpriteRenderer.invalidate)
|
||||
local STAND = { down = 0, up = 1, left = 2, right = 2 }
|
||||
local WALK = { down = 3, up = 4, left = 5, right = 5 }
|
||||
|
||||
function SpriteRenderer.new(spriteDef)
|
||||
-- seed: any stable per-instance value (e.g. an NPC's `id`) used to resolve
|
||||
-- RED++'s per-instance "random" OBP sentinel (PaletteFX.spriteObp)
|
||||
function SpriteRenderer.new(spriteDef, seed)
|
||||
local self = setmetatable({}, SpriteRenderer)
|
||||
self.def = spriteDef
|
||||
self.seed = seed
|
||||
self.image = getImage(spriteDef.image)
|
||||
local iw, ih = self.image:getDimensions()
|
||||
self.frames = {}
|
||||
@@ -43,16 +85,49 @@ end
|
||||
|
||||
-- facing: down/up/left/right; walkPhase: 0 stand, 1 walk; flip: alternate
|
||||
-- steps mirror the walk frame for up/down (GB uses OAM flip for this).
|
||||
local function blitFrame(image, quad, x, y, flip, redraw)
|
||||
if flip then
|
||||
love.graphics.draw(image, quad, x + 16, y, 0, -1, 1)
|
||||
if redraw then PaletteFX.markSpriteRedraw(image, quad, x + 16, y, -1) end
|
||||
else
|
||||
love.graphics.draw(image, quad, x, y)
|
||||
if redraw then PaletteFX.markSpriteRedraw(image, quad, x, y, 1) end
|
||||
end
|
||||
end
|
||||
|
||||
function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip)
|
||||
local x = math.floor(px - camX)
|
||||
local y = math.floor(py - camY) - 4
|
||||
local image = self.image
|
||||
local redraw = false
|
||||
-- full-color art claims its 16x16 cell out of the shade-remap pass
|
||||
if self.def.trueColor then PaletteFX.markTrueColor(x, y, 16, 16) end
|
||||
if self.def.trueColor then
|
||||
PaletteFX.markTrueColor(x, y, 16, 16)
|
||||
elseif PaletteFX.usesGbcPack() then
|
||||
-- RED++: the world canvas is already true-color (TileRenderer bakes
|
||||
-- terrain, this bakes the sprite) and the world pass runs unshaded
|
||||
-- (OverworldState.sgbWorldZones), so this draws like any normal sprite
|
||||
-- -- opaque character pixels over a real-alpha-transparent background,
|
||||
-- no trueColor rect needed (there is no shader left to exempt it from).
|
||||
local colors, group = PaletteFX.spriteObp(self.def, self.seed)
|
||||
if colors then
|
||||
image = getObpImage(self.def.image, colors, group)
|
||||
end
|
||||
elseif PaletteFX.usesSpriteObp() and PaletteFX.spriteRedrawPassActive() then
|
||||
-- plain GBC: the terrain zone shader still runs over the world canvas,
|
||||
-- so the baked sprite is also queued for a post-zone redraw
|
||||
-- (PaletteFX.markSpriteRedraw) that restores its own OBP colors on top
|
||||
local colors, group = PaletteFX.spriteObp(self.def, self.seed)
|
||||
if colors then
|
||||
image = getObpImage(self.def.image, colors, group)
|
||||
redraw = true
|
||||
end
|
||||
end
|
||||
-- single-frame sprites (item balls, fossils...) have one fixed pose;
|
||||
-- still 3-frame sprites turn to face (the nurse at her machine,
|
||||
-- facePlayer on STAY NPCs) but never show walk frames
|
||||
if self.def.frames <= 1 then
|
||||
love.graphics.draw(self.image, self.frames[0], x, y)
|
||||
blitFrame(image, self.frames[0], x, y, false, redraw)
|
||||
return
|
||||
end
|
||||
local frame = (self.def.walker and walkPhase == 1)
|
||||
@@ -64,11 +139,7 @@ function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip)
|
||||
flip = true
|
||||
end
|
||||
local quad = self.frames[frame] or self.frames[0]
|
||||
if flip then
|
||||
love.graphics.draw(self.image, quad, x + 16, y, 0, -1, 1)
|
||||
else
|
||||
love.graphics.draw(self.image, quad, x, y)
|
||||
end
|
||||
blitFrame(image, quad, x, y, flip, redraw)
|
||||
end
|
||||
|
||||
return SpriteRenderer
|
||||
|
||||
+195
-14
@@ -108,10 +108,23 @@ end
|
||||
-- the water/flower branches did before they were data.
|
||||
-- ------------------------------------------------------------------
|
||||
|
||||
-- the 8 shifted variants of one tile (built once per sheet + tile id)
|
||||
-- shade 0-3 -> one of `colors`' 4 entries (same cutoffs PaletteFX's shader
|
||||
-- uses), alpha passed through unchanged; nil colors leaves r,g,b as-is.
|
||||
-- Shared by the whole-atlas bake (getGbcAtlas) and the animated-tile
|
||||
-- variants below, so water/flowers/spinners match the static tiles around
|
||||
-- them under RED++ instead of showing their un-recolored grayscale.
|
||||
local function recolorSample(r, g, b, a, colors)
|
||||
if not (colors and a > 0) then return r, g, b, a end
|
||||
local col = r > 0.83 and colors[1] or r > 0.5 and colors[2]
|
||||
or r > 0.17 and colors[3] or colors[4]
|
||||
return col[1] / 255, col[2] / 255, col[3] / 255, a
|
||||
end
|
||||
|
||||
-- the 8 shifted variants of one tile (built once per sheet + tile id [+
|
||||
-- gbcKey, when `colors` recolors it for RED++ -- see buildAnim])
|
||||
local shiftVariants = {}
|
||||
local function getShiftVariants(tilesetImagePath, perRow, tile)
|
||||
local key = tilesetImagePath .. "#" .. tile
|
||||
local function getShiftVariants(tilesetImagePath, perRow, tile, colors, gbcKey)
|
||||
local key = tilesetImagePath .. "#" .. tile .. (gbcKey or "")
|
||||
if shiftVariants[key] ~= nil then return shiftVariants[key] end
|
||||
if not (love.image and love.image.newImageData) then
|
||||
shiftVariants[key] = false
|
||||
@@ -126,6 +139,7 @@ local function getShiftVariants(tilesetImagePath, perRow, tile)
|
||||
for y = 0, 7 do
|
||||
for x = 0, 7 do
|
||||
local r, g, b, a = id:getPixel(sx + x, sy + y)
|
||||
r, g, b, a = recolorSample(r, g, b, a, colors)
|
||||
v:setPixel((x + o) % 8, y, r, g, b, a)
|
||||
end
|
||||
end
|
||||
@@ -136,12 +150,27 @@ local function getShiftVariants(tilesetImagePath, perRow, tile)
|
||||
end
|
||||
|
||||
local frameImages = {}
|
||||
local function getFrameImages(paths)
|
||||
local key = table.concat(paths, "|")
|
||||
local function getFrameImages(paths, colors, gbcKey)
|
||||
local key = table.concat(paths, "|") .. (gbcKey or "")
|
||||
if frameImages[key] ~= nil then return frameImages[key] end
|
||||
local out = {}
|
||||
for i, path in ipairs(paths) do
|
||||
local ok, img = pcall(getImage, path)
|
||||
local ok, img = pcall(function()
|
||||
if not (colors and love.image and love.image.newImageData) then
|
||||
return getImage(path)
|
||||
end
|
||||
local id = Assets.imageData(path)
|
||||
local w, h = id:getDimensions()
|
||||
local out2 = love.image.newImageData(w, h)
|
||||
for y = 0, h - 1 do
|
||||
for x = 0, w - 1 do
|
||||
local r, g, b, a = id:getPixel(x, y)
|
||||
r, g, b, a = recolorSample(r, g, b, a, colors)
|
||||
out2:setPixel(x, y, r, g, b, a)
|
||||
end
|
||||
end
|
||||
return love.graphics.newImage(out2)
|
||||
end)
|
||||
if not ok then
|
||||
frameImages[key] = false
|
||||
return false
|
||||
@@ -237,17 +266,33 @@ end
|
||||
-- one entry's runtime form: the tile ids it claims, the textures a step
|
||||
-- picks from, and either a step sequence (hshift/frames) or a gate
|
||||
-- (toggle). nil when the entry's pixels could not be built.
|
||||
local function buildAnim(spec, tilesetImagePath, perRow, quads)
|
||||
--
|
||||
-- gbc, when present (RED++ with a baked atlas -- see getGbcAtlas), recolors
|
||||
-- hshift/frames entries (water/flowers) the same way the atlas bakes their
|
||||
-- static tile, so they match their surroundings instead of showing raw
|
||||
-- grayscale over an otherwise fully-colored map. The "toggle" kind
|
||||
-- (spinner puzzle blur, gfx/overworld/spinners.png) is a whole-atlas clone
|
||||
-- built from the ORIGINAL grayscale atlas, not worth recoloring for a rare,
|
||||
-- gameplay-gated blur -- it is skipped under gbc, same as the buildAnim
|
||||
-- caller already does for a texture-build failure (the static, correctly-
|
||||
-- colored tile shows through unanimated).
|
||||
local function buildAnim(spec, tilesetImagePath, perRow, quads, gbc)
|
||||
local tiles = spec.tiles
|
||||
if not tiles then
|
||||
if spec.tile == nil then return nil end
|
||||
tiles = { spec.tile }
|
||||
end
|
||||
local period = spec.period or ANIM_PERIOD
|
||||
local colors
|
||||
if gbc then
|
||||
local group = PaletteFX.worldGroupAt(gbc.tilesetId, gbc.mapId, tiles[1])
|
||||
colors = group and gbc.groupColors[group + 1]
|
||||
end
|
||||
if spec.kind == "hshift" then
|
||||
local offsets = spec.offsets
|
||||
if not offsets or #offsets == 0 then return nil end
|
||||
local textures = getShiftVariants(tilesetImagePath, perRow, tiles[1])
|
||||
local textures = getShiftVariants(tilesetImagePath, perRow, tiles[1],
|
||||
colors, gbc and gbc.key)
|
||||
if not textures then return nil end
|
||||
local sequence = {}
|
||||
for i, offset in ipairs(offsets) do sequence[i] = offset + 1 end
|
||||
@@ -256,11 +301,12 @@ local function buildAnim(spec, tilesetImagePath, perRow, quads)
|
||||
elseif spec.kind == "frames" then
|
||||
local sequence = spec.sequence
|
||||
if not (spec.images and sequence and #sequence > 0) then return nil end
|
||||
local textures = getFrameImages(spec.images)
|
||||
local textures = getFrameImages(spec.images, colors, gbc and gbc.key)
|
||||
if not textures then return nil end
|
||||
return { tiles = tiles, textures = textures, sequence = sequence,
|
||||
period = period }
|
||||
elseif spec.kind == "toggle" then
|
||||
if gbc then return nil end
|
||||
local image = getToggleImage(spec, tilesetImagePath, perRow)
|
||||
if not image then return nil end
|
||||
-- the patch texture is a whole-atlas clone, so each cell needs the
|
||||
@@ -271,10 +317,102 @@ local function buildAnim(spec, tilesetImagePath, perRow, quads)
|
||||
return nil
|
||||
end
|
||||
|
||||
function TileRenderer.new(map)
|
||||
-- True GBC overworld coloring (COLORS=RED++): recolor the WHOLE tileset
|
||||
-- atlas once, per (tileset image, map), rather than trying to retrofit the
|
||||
-- SGB zone/shade-remap-shader post-process (built for a handful of coarse
|
||||
-- screen regions) into per-tile precision -- pokered-gbc's real model is
|
||||
-- "one of 8 four-color BG palettes baked per tile GRAPHIC"
|
||||
-- (color/loadpalettes.asm LoadTilesetPalette), which is exactly a
|
||||
-- recolored atlas, not a shader pass. Every existing draw path (batches,
|
||||
-- quads, border fill) then just works unmodified, with no shader at
|
||||
-- draw time; OverworldState.sgbWorldZones skips the shade-remap zone pass
|
||||
-- entirely when this is active (re-running it over already-true-color
|
||||
-- pixels would corrupt them), and SpriteRenderer's own OBP bake composites
|
||||
-- on top with ordinary alpha blending -- no trueColor exemption needed,
|
||||
-- because there is no shader left for it to be exempted from.
|
||||
--
|
||||
-- Only the ROOF group (index 6, OVERWORLD/PLATEAU only) varies by town
|
||||
-- (LoadTownPalette); Route 6's mid-map Saffron-roof y<2 split is not
|
||||
-- reproduced (it would need a rebuild on crossing the boundary for two
|
||||
-- tile-rows of one route -- not worth the complexity), so it bakes with
|
||||
-- the route's own default roof (Vermilion's) throughout.
|
||||
local gbcAtlasCache = {}
|
||||
|
||||
local function getGbcAtlas(imagePath, tilesetId, mapId, perRow, data)
|
||||
local key = imagePath .. "#gbc:" .. mapId
|
||||
if gbcAtlasCache[key] ~= nil then return gbcAtlasCache[key] or nil end
|
||||
local img = false
|
||||
if love.image and love.image.newImageData then
|
||||
local groupColors = PaletteFX.worldGroupColors(data, tilesetId, mapId, nil)
|
||||
if groupColors then
|
||||
local src = Assets.imageData(imagePath)
|
||||
local iw, ih = src:getDimensions()
|
||||
local total = (iw / 8) * (ih / 8)
|
||||
local out = love.image.newImageData(iw, ih)
|
||||
local tileColors = {}
|
||||
for t = 0, total - 1 do
|
||||
local colors = tileColors[t]
|
||||
if colors == nil then
|
||||
local group = PaletteFX.worldGroupAt(tilesetId, mapId, t)
|
||||
colors = (group and groupColors[group + 1]) or false
|
||||
tileColors[t] = colors
|
||||
end
|
||||
local ox, oy = (t % perRow) * 8, math.floor(t / perRow) * 8
|
||||
for py = 0, 7 do
|
||||
for px = 0, 7 do
|
||||
local sx, sy = ox + px, oy + py
|
||||
local r, g, b, a = src:getPixel(sx, sy)
|
||||
r, g, b, a = recolorSample(r, g, b, a, colors)
|
||||
out:setPixel(sx, sy, r, g, b, a)
|
||||
end
|
||||
end
|
||||
end
|
||||
-- duplicate-tile aliases: bake a copy of a shared tile graphic into
|
||||
-- a spare slot under a different palette group, so block cells that
|
||||
-- draw the alias can color apart from cells sharing the raw tile
|
||||
for _, al in ipairs(PaletteFX.TILE_ALIASES and PaletteFX.TILE_ALIASES[mapId] or {}) do
|
||||
if al.alias < total then
|
||||
local colors = groupColors[al.group + 1]
|
||||
local sxo = (al.tile % perRow) * 8
|
||||
local syo = math.floor(al.tile / perRow) * 8
|
||||
local dxo = (al.alias % perRow) * 8
|
||||
local dyo = math.floor(al.alias / perRow) * 8
|
||||
for py = 0, 7 do
|
||||
for px = 0, 7 do
|
||||
local r, g, b, a = src:getPixel(sxo + px, syo + py)
|
||||
r, g, b, a = recolorSample(r, g, b, a, colors)
|
||||
out:setPixel(dxo + px, dyo + py, r, g, b, a)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
img = love.graphics.newImage(out)
|
||||
end
|
||||
end
|
||||
gbcAtlasCache[key] = img
|
||||
return img or nil
|
||||
end
|
||||
|
||||
-- data: Game.data (threaded through explicitly, not required lazily, so
|
||||
-- headless tests that build a map from a plain local table still work)
|
||||
function TileRenderer.new(map, data)
|
||||
local self = setmetatable({}, TileRenderer)
|
||||
self.map = map
|
||||
self.data = data
|
||||
self.image = getImage(map.tileset.image)
|
||||
local gbcCtx
|
||||
if data and PaletteFX.usesGbcPack() and PaletteFX.hasWorldTileset(map.tileset.id) then
|
||||
local gbc = getGbcAtlas(map.tileset.image, map.tileset.id, map.id,
|
||||
map.tileset.tilesPerRow, data)
|
||||
if gbc then
|
||||
self.image = gbc
|
||||
self.gbcAtlas = true
|
||||
-- also recolors the animated water/flower entries below, so they
|
||||
-- match the atlas's static tiles instead of showing raw grayscale
|
||||
gbcCtx = { tilesetId = map.tileset.id, mapId = map.id, key = "#gbc:" .. map.id,
|
||||
groupColors = PaletteFX.worldGroupColors(data, map.tileset.id, map.id, nil) }
|
||||
end
|
||||
end
|
||||
-- a full-color atlas colors everything it paints, ring and border fill
|
||||
-- included, so every draw entry point claims its rect out of the pass
|
||||
self.trueColor = map.tileset.trueColor or nil
|
||||
@@ -301,7 +439,7 @@ function TileRenderer.new(map)
|
||||
local declared = map.tileset.animatedTiles
|
||||
or TileRenderer.defaultAnimatedTiles(map.tileset)
|
||||
for _, spec in ipairs(declared) do
|
||||
local anim = buildAnim(spec, map.tileset.image, perRow, self.quads)
|
||||
local anim = buildAnim(spec, map.tileset.image, perRow, self.quads, gbcCtx)
|
||||
if anim then
|
||||
anim.cells = {}
|
||||
anims[#anims + 1] = anim
|
||||
@@ -311,14 +449,38 @@ function TileRenderer.new(map)
|
||||
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 bx = -BORDER_BLOCKS, wB + BORDER_BLOCKS - 1 do
|
||||
local inside = bx >= 0 and by >= 0 and bx < wB and by < hB
|
||||
local batch = inside and self.mapBatch or self.ringBatch
|
||||
local block = map.tileset.blocks[map:blockAt(bx, by) + 1]
|
||||
-- beyond-edge ring cells use the same override drawBorderFill does,
|
||||
-- so the ring and the far background fill agree (OVERWORLD maps
|
||||
-- whose raw border_block is water still ring with the tree wall)
|
||||
local blockId = inside and map:blockAt(bx, by) or borderBlockFor(map)
|
||||
local block = map.tileset.blocks[blockId + 1]
|
||||
if not block then
|
||||
-- a tileset without the tree-wall block keeps its own border
|
||||
blockId = map:blockAt(bx, by)
|
||||
block = map.tileset.blocks[blockId + 1]
|
||||
end
|
||||
local remap = aliasMap and aliasMap[blockId]
|
||||
for ty = 0, 3 do
|
||||
for tx = 0, 3 do
|
||||
local 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]
|
||||
if quad then
|
||||
batch:add(quad, bx * 32 + tx * 8, by * 32 + ty * 8)
|
||||
@@ -438,6 +600,22 @@ function TileRenderer:drawCellBottom(cx, cy, camX, camY)
|
||||
if shader then love.graphics.setShader() 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
|
||||
-- positions (connected maps draw body-only)
|
||||
function TileRenderer:drawAnimated(camX, camY, bodyOnly)
|
||||
@@ -487,7 +665,10 @@ end
|
||||
|
||||
-- rebuild after a block change (Cut trees)
|
||||
function TileRenderer:rebuild()
|
||||
local fresh = TileRenderer.new(self.map)
|
||||
local fresh = TileRenderer.new(self.map, self.data)
|
||||
self.image = fresh.image
|
||||
self.gbcAtlas = fresh.gbcAtlas
|
||||
self.quads = fresh.quads
|
||||
self.ringBatch = fresh.ringBatch
|
||||
self.mapBatch = fresh.mapBatch
|
||||
self.anims = fresh.anims
|
||||
|
||||
+23
-6
@@ -5,6 +5,7 @@
|
||||
-- Conditionals: check_flag stores its result in ctx.lastCheck;
|
||||
-- jump_if_true/jump_if_false jump to an absolute row index.
|
||||
|
||||
local Collision = require("src.world.Collision")
|
||||
local Flags = require("src.script.Flags")
|
||||
local Logger = require("src.core.Logger")
|
||||
local Screens = require("src.ui.Screens")
|
||||
@@ -225,7 +226,7 @@ function Commands.start_battle(ctx, kind, a, b)
|
||||
ctx.lastBattleResult = result
|
||||
ctx.lastCheck = result == "win"
|
||||
if ctx.overworld then
|
||||
ctx.overworld:afterBattle(result)
|
||||
ctx.overworld:afterBattle(result, battle)
|
||||
end
|
||||
runner:resume()
|
||||
end
|
||||
@@ -269,7 +270,10 @@ end
|
||||
local DIRS4 = { { 0, -1, "up" }, { 0, 1, "down" },
|
||||
{ -1, 0, "left" }, { 1, 0, "right" } }
|
||||
|
||||
local function bfsPath(map, sx, sy, tx, ty)
|
||||
-- entities/mover let the walk route around other entities' cells (the
|
||||
-- player especially), so a scripted NPC never clips through them; the
|
||||
-- target cell is exempt so an NPC can still walk up onto its goal
|
||||
local function bfsPath(map, sx, sy, tx, ty, entities, mover)
|
||||
local key = function(x, y) return y * 1000 + x end
|
||||
local prev = { [key(sx, sy)] = false }
|
||||
local queue = { { sx, sy } }
|
||||
@@ -289,8 +293,11 @@ local function bfsPath(map, sx, sy, tx, ty)
|
||||
for _, d in ipairs(DIRS4) do
|
||||
local nx, ny = cx + d[1], cy + d[2]
|
||||
local nk = key(nx, ny)
|
||||
local isTarget = nx == tx and ny == ty
|
||||
if prev[nk] == nil and map:inBounds(nx, ny)
|
||||
and (map:isWalkableCell(nx, ny) or (nx == tx and ny == ty)) then
|
||||
and (map:isWalkableCell(nx, ny) or isTarget)
|
||||
and (isTarget or not entities
|
||||
or not Collision.occupied(entities, nx, ny, mover)) then
|
||||
prev[nk] = { cx, cy, d[3] }
|
||||
table.insert(queue, { nx, ny })
|
||||
end
|
||||
@@ -303,7 +310,7 @@ function Commands.move_npc_to(ctx, objIndex, tx, ty)
|
||||
local ow = ctx.overworld
|
||||
local npc = ow:npcByIndex(objIndex)
|
||||
if not npc then return end
|
||||
local path = bfsPath(ow.map, npc.cellX, npc.cellY, tx, ty)
|
||||
local path = bfsPath(ow.map, npc.cellX, npc.cellY, tx, ty, ow.entities, npc)
|
||||
if not path then
|
||||
Logger.warn("move_npc_to: no path to (%d,%d)", tx, ty)
|
||||
return
|
||||
@@ -390,6 +397,7 @@ local function toggleObject(ctx, mapId, objName, visible)
|
||||
-- positions mid-cutscene)
|
||||
local ow = ctx.overworld
|
||||
if not ow or ow.map.id ~= mapId then return end
|
||||
ow.npcPool = ow.npcPool or {}
|
||||
if visible then
|
||||
for _, n in ipairs(ow.npcs) do
|
||||
if n.def.name == objName then return end
|
||||
@@ -400,12 +408,16 @@ local function toggleObject(ctx, mapId, objName, visible)
|
||||
local npc = NPC.new(ctx.game.data, mapId, obj)
|
||||
table.insert(ow.npcs, npc)
|
||||
table.insert(ow.entities, npc)
|
||||
ow.npcPool[npc.id] = npc
|
||||
return
|
||||
end
|
||||
end
|
||||
else
|
||||
for i = #ow.npcs, 1, -1 do
|
||||
if ow.npcs[i].def.name == objName then table.remove(ow.npcs, i) end
|
||||
if ow.npcs[i].def.name == objName then
|
||||
ow.npcPool[ow.npcs[i].id] = nil
|
||||
table.remove(ow.npcs, i)
|
||||
end
|
||||
end
|
||||
for i = #ow.entities, 1, -1 do
|
||||
local e = ow.entities[i]
|
||||
@@ -823,7 +835,12 @@ end
|
||||
-- registry and block until the screen pops itself
|
||||
function Commands.push_screen(ctx, screenId, args)
|
||||
local screens = ctx.game.data.screens
|
||||
if not (screens and screens[screenId]) then
|
||||
local known = screens and screens[screenId] ~= nil
|
||||
if not known then
|
||||
-- builtin engine screens (src/ui/<id>.lua) resolve through Screens too
|
||||
known = pcall(Screens.get, ctx.game, screenId)
|
||||
end
|
||||
if not known then
|
||||
error(("push_screen: unknown screen '%s'"):format(tostring(screenId)), 0)
|
||||
end
|
||||
local runner = ctx.runner
|
||||
|
||||
+43
-28
@@ -296,37 +296,52 @@ local function useOn(game, battle, id, target, list, moveIndex)
|
||||
showMessages(game, payload) -- failed
|
||||
end
|
||||
|
||||
local function pickTargetAndUse(game, battle, id, list)
|
||||
-- pick a target from the party
|
||||
-- the ETHERs and PP UP open the move menu after picking a mon
|
||||
-- (ItemUsePPRestore / ItemUsePPUp); the ELIXERs hit every move
|
||||
local wantsMove = id == "ETHER" or id == "MAX_ETHER" or id == "PP_UP"
|
||||
require("src.ui.Screens").push(game, "PartyMenu", {
|
||||
pickOnly = true,
|
||||
onSwitch = function(mon)
|
||||
if not wantsMove then
|
||||
useOn(game, battle, id, mon, list)
|
||||
return
|
||||
end
|
||||
local rows = {}
|
||||
for mi, mv in ipairs(mon.moves) do
|
||||
local mdef = game.data.moves[mv.id]
|
||||
table.insert(rows, {
|
||||
value = mi,
|
||||
label = mdef and mdef.name or mv.id,
|
||||
right = ("%d"):format(mv.pp),
|
||||
})
|
||||
end
|
||||
game.stack:push(ListMenu.new(game, "Which move?", rows, {
|
||||
onChoose = function(row, l)
|
||||
l:close()
|
||||
useOn(game, battle, id, mon, list, row.value)
|
||||
end,
|
||||
}))
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
local function useItem(game, battle, id, list)
|
||||
local def = game.data.items[id]
|
||||
if ItemEffects.needsTarget(id, def) and not ItemEffects.isBall(id) then
|
||||
-- pick a target from the party
|
||||
-- the ETHERs and PP UP open the move menu after picking a mon
|
||||
-- (ItemUsePPRestore / ItemUsePPUp); the ELIXERs hit every move
|
||||
local wantsMove = id == "ETHER" or id == "MAX_ETHER" or id == "PP_UP"
|
||||
require("src.ui.Screens").push(game, "PartyMenu", {
|
||||
pickOnly = true,
|
||||
onSwitch = function(mon)
|
||||
if not wantsMove then
|
||||
useOn(game, battle, id, mon, list)
|
||||
return
|
||||
end
|
||||
local rows = {}
|
||||
for mi, mv in ipairs(mon.moves) do
|
||||
local mdef = game.data.moves[mv.id]
|
||||
table.insert(rows, {
|
||||
value = mi,
|
||||
label = mdef and mdef.name or mv.id,
|
||||
right = ("%d"):format(mv.pp),
|
||||
})
|
||||
end
|
||||
game.stack:push(ListMenu.new(game, "Which move?", rows, {
|
||||
onChoose = function(row, l)
|
||||
l:close()
|
||||
useOn(game, battle, id, mon, list, row.value)
|
||||
end,
|
||||
}))
|
||||
end,
|
||||
})
|
||||
-- TMs/HMs boot up and announce their move before the target picker
|
||||
-- (ItemUseTMHM: BootedUpTMText / BootedUpHMText + TeachMachineMoveText)
|
||||
if def and def.machine then
|
||||
local moveDef = game.data.moves[def.machine.move]
|
||||
local moveName = moveDef and moveDef.name or def.machine.move
|
||||
local booted = def.machine.kind == "HM"
|
||||
and "Booted up an HM!" or "Booted up a TM!"
|
||||
showMessages(game, { booted, ("It contained\n%s!"):format(moveName) },
|
||||
function() pickTargetAndUse(game, battle, id, list) end)
|
||||
return
|
||||
end
|
||||
pickTargetAndUse(game, battle, id, list)
|
||||
else
|
||||
useOn(game, battle, id, nil, list)
|
||||
end
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
-- has no cancel; the flash always completes).
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
local Music = require("src.core.Music")
|
||||
|
||||
local EvolutionState = {}
|
||||
EvolutionState.__index = EvolutionState
|
||||
@@ -39,6 +40,7 @@ function EvolutionState.new(game, mon, newSpecies, onDone)
|
||||
self.newSprite = frontSprite(game, newSpecies)
|
||||
self.t = 0
|
||||
self.done = false
|
||||
Music.play(game.data, Music.special(game.data, "evolution"))
|
||||
return self
|
||||
end
|
||||
|
||||
@@ -57,6 +59,7 @@ function EvolutionState:update(dt)
|
||||
("Congratulations!\nYour %s\nevolved into\n%s!")
|
||||
:format(self.oldName, newName),
|
||||
function()
|
||||
Music.restoreMap(game.data)
|
||||
game.stack:pop() -- the evolution screen itself
|
||||
if self.onDone then self.onDone() end
|
||||
end))
|
||||
|
||||
+56
-6
@@ -16,6 +16,11 @@ local OakSpeech = {}
|
||||
OakSpeech.__index = OakSpeech
|
||||
OakSpeech.isOpaque = true
|
||||
|
||||
-- FadeInIntroPic runs a 6-step palette fade; MovePicLeft wipes the mon
|
||||
-- sprite in from the right. Both play out before the beat's text prints.
|
||||
local FADE_FRAMES = 24
|
||||
local WIPE_FRAMES = 32
|
||||
|
||||
-- naming presets are boot config (field.boot.namePresets), which a total
|
||||
-- conversion replaces; the Red/Blue lists remain the fallback
|
||||
local function namePresets(game, who, fallback)
|
||||
@@ -93,16 +98,23 @@ function OakSpeech:say(key, next)
|
||||
end
|
||||
|
||||
local STEPS = {
|
||||
-- 1. Oak's welcome
|
||||
-- 1. Oak's welcome (Oak's pic fades in first, FadeInIntroPic)
|
||||
function(self)
|
||||
self.pic = self.oakPic
|
||||
self:say("_OakSpeechText1", function() self:advance() end)
|
||||
self:revealPic("fade", function()
|
||||
self:say("_OakSpeechText1", function() self:advance() end)
|
||||
end)
|
||||
end,
|
||||
-- 2. NIDORINO show-off, with its cry
|
||||
-- 2. NIDORINO show-off: the front sprite is mirrored horizontally
|
||||
-- (LoadFlippedFrontSpriteByMonIndex) and wipes in from the right
|
||||
-- (MovePicLeft), then its cry sounds and the text prints
|
||||
function(self)
|
||||
self.pic = self.demoPic
|
||||
Sound.playCry(self.game.data, self.demoSpecies)
|
||||
self:say("_OakSpeechText2A", function() self:advance() end)
|
||||
self.picFlip = true
|
||||
self:revealPic("wipe", function()
|
||||
Sound.playCry(self.game.data, self.demoSpecies)
|
||||
self:say("_OakSpeechText2A", function() self:advance() end)
|
||||
end)
|
||||
end,
|
||||
-- 3. the rest of the world-of-POKéMON spiel
|
||||
function(self)
|
||||
@@ -175,8 +187,21 @@ function OakSpeech:lastPageLines(key)
|
||||
return ok and lines or nil
|
||||
end
|
||||
|
||||
-- Reveal the current pic over `dur` frames, then run `next`. Ticked from
|
||||
-- update() while OakSpeech is the top state (before the beat's text box is
|
||||
-- pushed), so the fade/wipe plays out ahead of the text like the ROM.
|
||||
function OakSpeech:revealPic(kind, next)
|
||||
self.picReveal = {
|
||||
kind = kind,
|
||||
t = 0,
|
||||
dur = kind == "fade" and FADE_FRAMES or WIPE_FRAMES,
|
||||
next = next,
|
||||
}
|
||||
end
|
||||
|
||||
function OakSpeech:advance()
|
||||
self.step = self.step + 1
|
||||
self.picFlip = false
|
||||
local fn = STEPS[self.step]
|
||||
if fn then
|
||||
fn(self)
|
||||
@@ -205,6 +230,15 @@ end
|
||||
-- ClearScreenArea / wUpdateSpritesEnabled; ld c, 50)
|
||||
-- frames 79-102 GBFadeOutToWhite (3 palettes x 8 frames)
|
||||
function OakSpeech:update(dt)
|
||||
local r = self.picReveal
|
||||
if r then
|
||||
r.t = r.t + 1
|
||||
if r.t >= r.dur then
|
||||
self.picReveal = nil
|
||||
if r.next then r.next() end
|
||||
end
|
||||
return
|
||||
end
|
||||
if not self.shrink then return end
|
||||
local s = self.shrink
|
||||
s.frame = s.frame + 1
|
||||
@@ -238,7 +272,23 @@ function OakSpeech:draw()
|
||||
local w, h = self.pic:getDimensions()
|
||||
local x = 48 + math.floor((8 - w / 8) / 2) * 8
|
||||
local y = 32 + (7 - h / 8) * 8
|
||||
love.graphics.draw(self.pic, x, y)
|
||||
local reveal = self.picReveal
|
||||
local off = 0
|
||||
if reveal and reveal.kind == "fade" then
|
||||
-- FadeInIntroPic: ramp the pic's alpha up over the fade window
|
||||
love.graphics.setColor(1, 1, 1, math.min(1, reveal.t / reveal.dur))
|
||||
elseif reveal and reveal.kind == "wipe" then
|
||||
-- MovePicLeft: the pic slides in from the right edge to its spot
|
||||
off = math.floor((160 - x) * (1 - math.min(1, reveal.t / reveal.dur)))
|
||||
end
|
||||
if self.picFlip then
|
||||
-- LoadFlippedFrontSpriteByMonIndex mirrors the front sprite
|
||||
-- horizontally (wSpriteFlipped): draw with a negative x scale
|
||||
love.graphics.draw(self.pic, x + off + w, y, 0, -1, 1)
|
||||
else
|
||||
love.graphics.draw(self.pic, x + off, y)
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
if self.walkVisible and self.walkSheet then
|
||||
-- ResetPlayerSpriteData: Y screen pos $3c, X screen pos $40
|
||||
|
||||
+12
-1
@@ -3,7 +3,7 @@
|
||||
-- (cycles the merged rulesets registry; gen1_faithful keeps the original
|
||||
-- quirks), plus the port's audio rows and display rows: music/SFX
|
||||
-- volume (0-7), music low-pass filter (OFF/1X/2X/3X), COLORS / TILT /
|
||||
-- GBC FX, and the MODS row that opens the mod manager.
|
||||
-- GBC FX / VIDEO MODE, and the MODS row that opens the mod manager.
|
||||
-- Rows are descriptors fed through the ui.options.rows hook, so mods can
|
||||
-- add their own; CANCEL is appended after the hook and stays fixed on the
|
||||
-- bottom line like pokered's.
|
||||
@@ -12,6 +12,7 @@ local PaletteFX = require("src.render.PaletteFX")
|
||||
local Tilt = require("src.render.Tilt")
|
||||
local GBCFX = require("src.render.GBCFX")
|
||||
local GameSpeed = require("src.core.GameSpeed")
|
||||
local VideoMode = require("src.core.VideoMode")
|
||||
local Logger = require("src.core.Logger")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local OptionRows = require("src.ui.OptionRows")
|
||||
@@ -191,6 +192,16 @@ local function buildRows(game)
|
||||
GBCFX.setLevel(o.gbcfx)
|
||||
return true
|
||||
end },
|
||||
{ id = "videoMode", label = "VIDEO MODE",
|
||||
value = function(g)
|
||||
return VideoMode.modeLabel(g.save.options.videoMode)
|
||||
end,
|
||||
step = function(g, dir)
|
||||
local o = g.save.options
|
||||
o.videoMode = VideoMode.cycle(o.videoMode, dir)
|
||||
VideoMode.apply(o.videoMode)
|
||||
return true
|
||||
end },
|
||||
-- fast-forward the logic clock only; music and sfx keep their tempo
|
||||
-- (src/core/GameSpeed.lua), so this is safe to leave on
|
||||
{ id = "speed", label = "GAME SPEED",
|
||||
|
||||
+7
-1
@@ -99,7 +99,13 @@ end
|
||||
local function deposit(game)
|
||||
local pc = game.save.pcItems
|
||||
local inv = game.save.inventory
|
||||
game.stack:push(ListMenu.new(game, "DEPOSIT ITEM", buildItems(game, inv), {
|
||||
local Bag = require("src.inventory.Bag")
|
||||
-- badges live in save.inventory alongside items but are not depositable
|
||||
local depositable = {}
|
||||
for id, count in pairs(inv) do
|
||||
if not Bag.isBadge(id) then depositable[id] = count end
|
||||
end
|
||||
game.stack:push(ListMenu.new(game, "DEPOSIT ITEM", buildItems(game, depositable), {
|
||||
onChoose = function(item, list)
|
||||
askQuantity(game, list, inv[item.value] or 1, item.value, function(qty)
|
||||
if pcFull(game, pc, item.value) then
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
-- item list runs through the ui.start_menu.items hook before the menu
|
||||
-- opens, so mods insert or remove rows without patching this file.
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
local Logger = require("src.core.Logger")
|
||||
local Menu = require("src.ui.Menu")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
@@ -126,6 +127,24 @@ function StartMenu.new(game)
|
||||
baseUpdate(self, dt)
|
||||
game.save.startMenuIndex = self.index
|
||||
end
|
||||
|
||||
-- inside the Safari Zone the start menu also shows remaining steps and
|
||||
-- SAFARI BALLs (PrintSafariZoneSteps): a 9x5 border at the top-left with
|
||||
-- "steps/500" and "BALL xx"
|
||||
if game.save.safari then
|
||||
local baseDraw = menu.draw
|
||||
menu.draw = function(self)
|
||||
baseDraw(self)
|
||||
local safari = game.save.safari
|
||||
Font.drawBox(0, 0, 9, 5)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(("%3d"):format(math.floor(safari.steps or 0)), 8, 8)
|
||||
Font.draw("/500", 32, 8)
|
||||
Font.draw("BALL", 8, 24)
|
||||
Font.draw(("%2d"):format(math.floor(safari.balls or 0)), 48, 24)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
end
|
||||
return menu
|
||||
end
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
-- Title screen (engine/movie/title.asm + engine/menus/main_menu.asm):
|
||||
-- the logo (or a text fallback while the asset is missing), a cycling
|
||||
-- Pokémon front sprite, the copyright line, and the CONTINUE / NEW GAME
|
||||
-- / OPTION main menu on START or A.
|
||||
-- / OPTION / EXIT GAME main menu on START or A.
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
local Music = require("src.core.Music")
|
||||
@@ -165,6 +165,11 @@ function TitleState:openMenu()
|
||||
table.insert(items, { label = "OPTION", onSelect = function()
|
||||
require("src.ui.Screens").push(game, "OptionsMenu")
|
||||
end })
|
||||
table.insert(items, { label = "EXIT GAME", onSelect = function()
|
||||
if love.event and love.event.quit then
|
||||
love.event.quit()
|
||||
end
|
||||
end })
|
||||
game.stack:push(Menu.new(game, items,
|
||||
{ tx = 0, ty = 0, tw = 13, th = #items * 2 + 2 }))
|
||||
end
|
||||
|
||||
@@ -92,6 +92,12 @@ local LAST_MAP_REWRITES = {
|
||||
UNDERGROUND_PATH_ROUTE_6 = { rules = { { map = "ROUTE_6" } } },
|
||||
UNDERGROUND_PATH_ROUTE_7 = { rules = { { map = "ROUTE_7" } } },
|
||||
UNDERGROUND_PATH_ROUTE_8 = { rules = { { map = "ROUTE_8" } } },
|
||||
-- DiglettsCaveRoute{2,11}_Script are the same pattern for the cave's two
|
||||
-- entrance houses. Without these, crossing the cave and taking the far
|
||||
-- house's LAST_MAP door warps you back to the route you ENTERED from --
|
||||
-- the whole cave was a one-way trip that always returned to its start.
|
||||
DIGLETTS_CAVE_ROUTE_2 = { rules = { { map = "ROUTE_2" } } },
|
||||
DIGLETTS_CAVE_ROUTE_11 = { rules = { { map = "ROUTE_11" } } },
|
||||
}
|
||||
|
||||
FieldDefaults.FIELD = {
|
||||
|
||||
@@ -24,11 +24,63 @@ local NO_SHORE_TILESETS = { SHIP_PORT = true }
|
||||
-- what counts as "outside" for the wLastMap memory (CheckIfInOutsideMap)
|
||||
local OUTSIDE_TILESETS = { "OVERWORLD", "PLATEAU" }
|
||||
|
||||
-- warp pads and fall-through holes (data/tilesets/warp_pad_hole_tile_ids
|
||||
-- .asm WarpPadAndHoleData); a tileset record carrying warpPadTiles
|
||||
-- ({ [tileId] = "pad"|"hole" }) wins over these vanilla rows
|
||||
local WARP_PAD_TILES = {
|
||||
FACILITY = { [0x20] = "pad", [0x11] = "hole" },
|
||||
CAVERN = { [0x22] = "hole" },
|
||||
INTERIOR = { [0x55] = "pad" },
|
||||
}
|
||||
|
||||
local function hashSet(list, into)
|
||||
for _, t in ipairs(list) do into[t] = true end
|
||||
return into
|
||||
end
|
||||
|
||||
-- Passability of a cell of an UNLOADED map def -- the connected neighbor
|
||||
-- during an edge crossing. pokered's collision check reads the neighbor
|
||||
-- strip's tile bytes, so a step off the map edge onto a solid tile of
|
||||
-- the connected map bumps exactly like an in-map wall; the port needs
|
||||
-- the same read without building the whole Map. Same math as cellTile
|
||||
-- on the raw blocks, honoring the surf rule (water/shore passable only
|
||||
-- while surfing, same fallbacks as Map.new).
|
||||
function Map.defPassable(def, tilesetDef, cx, cy, surfing)
|
||||
-- Fail closed: a missing tileset used to return true and re-open the
|
||||
-- Pallet south-shore stranding (cross onto ROUTE_21 solids). No data
|
||||
-- means we cannot prove the landing is safe, so the step bumps.
|
||||
if not (def and tilesetDef and tilesetDef.blocks and tilesetDef.walkable) then
|
||||
return false
|
||||
end
|
||||
local tx, ty = cx * 2, cy * 2 + 1
|
||||
local bx, by = math.floor(tx / 4), math.floor(ty / 4)
|
||||
local id
|
||||
if bx < 0 or by < 0 or bx >= def.width or by >= def.height then
|
||||
id = def.borderBlock
|
||||
else
|
||||
id = def.blocks[by * def.width + bx + 1]
|
||||
end
|
||||
local block = tilesetDef.blocks[(id or 0) + 1]
|
||||
if not block then return false end
|
||||
local tile = block[(ty % 4) * 4 + (tx % 4) + 1]
|
||||
for _, t in ipairs(tilesetDef.walkable) do
|
||||
if t == tile then return true end
|
||||
end
|
||||
if surfing then
|
||||
for _, t in ipairs(tilesetDef.waterTiles or WATER_TILES) do
|
||||
if t == tile then return true end
|
||||
end
|
||||
local shore = tilesetDef.shoreTiles
|
||||
if shore == nil and not NO_SHORE_TILESETS[def.tileset] then
|
||||
shore = SHORE_TILES
|
||||
end
|
||||
for _, t in ipairs(shore or {}) do
|
||||
if t == tile then return true end
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
function Map.new(def, tilesetDef)
|
||||
local self = setmetatable({}, Map)
|
||||
self.def = def
|
||||
@@ -156,6 +208,14 @@ function Map:isWarpTileCell(cx, cy)
|
||||
return self.doorTiles[t] or self.warpTiles[t] or false
|
||||
end
|
||||
|
||||
-- "pad"/"hole" when the cell's collision tile is a teleporter warp pad or
|
||||
-- a fall-through hole (IsPlayerStandingOnWarpPadOrHole), nil otherwise
|
||||
function Map:warpPadOrHoleAt(cx, cy)
|
||||
local table_ = self.tileset.warpPadTiles or WARP_PAD_TILES[self.def.tileset]
|
||||
if not table_ then return nil end
|
||||
return table_[self:cellTile(cx, cy)]
|
||||
end
|
||||
|
||||
-- counter tiles allow talking to NPCs across them (mart clerks, nurses)
|
||||
function Map:isCounterCell(cx, cy)
|
||||
local t = self:cellTile(cx, cy)
|
||||
|
||||
@@ -23,7 +23,7 @@ function MapLoader.load(data, mapId)
|
||||
-- warp tiles are stored per tileset macro name; the generated tilesets
|
||||
-- module carries them in the tileset entry itself
|
||||
local map = Map.new(def, tilesetDef)
|
||||
map.renderer = TileRenderer.new(map)
|
||||
map.renderer = TileRenderer.new(map, data)
|
||||
cache[mapId] = map
|
||||
return map
|
||||
end
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ function NPC.new(data, mapId, objDef)
|
||||
self.id = string.format("%s_obj_%d", mapId, objDef.index)
|
||||
local spriteDef = data.sprites[objDef.sprite]
|
||||
assert(spriteDef, "unknown sprite " .. tostring(objDef.sprite))
|
||||
self.sprite = SpriteRenderer.new(spriteDef)
|
||||
self.sprite = SpriteRenderer.new(spriteDef, self.id)
|
||||
-- object_event coordinates are already walk-grid cells
|
||||
self.cellX, self.cellY = objDef.x, objDef.y
|
||||
self.px, self.py = self.cellX * 16, self.cellY * 16
|
||||
|
||||
@@ -40,6 +40,10 @@ local HEAL_BALL_XY = {
|
||||
{ 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
|
||||
-- static encounters), shared by the current map's real NPCs and the
|
||||
-- visual-only ghosts on connected neighbor maps
|
||||
@@ -91,11 +95,25 @@ local NEIGHBOR_HOPS = 2
|
||||
-- (32 px), the same alignment the connection macro encodes
|
||||
-- (macros/scripts/maps.asm: _x = offset * -2 walk cells for
|
||||
-- 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 rootDef = maps[rootId]
|
||||
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 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
|
||||
local cur = queue[qi]
|
||||
qi = qi + 1
|
||||
@@ -114,11 +132,13 @@ function OverworldState.computeNeighbors(maps, rootId, hops)
|
||||
ox, oy = cur.def.width * 32, conn.offset * 32
|
||||
end
|
||||
ox, oy = cur.ox + ox, cur.oy + oy
|
||||
table.insert(out, { id = conn.map, ox = ox, oy = oy })
|
||||
if cur.hops + 1 < hops then
|
||||
table.insert(queue,
|
||||
{ def = destDef, ox = ox, oy = oy,
|
||||
hops = cur.hops + 1 })
|
||||
if cur.hops + 1 <= hops or inReach(destDef, ox, oy) then
|
||||
table.insert(out, { id = conn.map, ox = ox, oy = oy })
|
||||
if cur.hops + 1 < hops or inReach(destDef, ox, oy) then
|
||||
table.insert(queue,
|
||||
{ def = destDef, ox = ox, oy = oy,
|
||||
hops = cur.hops + 1 })
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -200,6 +220,24 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
|
||||
self.map.renderer:rebuild()
|
||||
self.cutBlocks[mapId] = nil
|
||||
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,
|
||||
-- home/overworld.asm: bike_riding_tilesets.asm tilesets plus the
|
||||
-- ROUTE_23/INDIGO_PLATEAU map exceptions)
|
||||
@@ -278,6 +316,13 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
|
||||
self.player.surfing)
|
||||
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
|
||||
-- Transition is on top, so a stale camera would show the new map at
|
||||
-- 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)
|
||||
end
|
||||
|
||||
-- neighbor maps drawn at the composed connection offsets, two hops
|
||||
-- out (the GB only ever streamed a 32px strip of the single
|
||||
-- directly connected map -- home/overworld.asm .loadNewMap)
|
||||
self:rebuildNeighbors()
|
||||
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
|
||||
|
||||
-- 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 = {}
|
||||
local hops = FieldDefaults.world(Game.data, "neighborHops") or NEIGHBOR_HOPS
|
||||
for _, n in ipairs(OverworldState.computeNeighbors(Game.data.maps,
|
||||
mapId, hops)) do
|
||||
local vw, vh = Game.renderer:worldViewSize()
|
||||
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,
|
||||
{ map = MapLoader.load(Game.data, n.id),
|
||||
ox = n.ox, oy = n.oy })
|
||||
@@ -328,10 +389,6 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
|
||||
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
|
||||
|
||||
-- SGB overworld palette (engine/gfx/palettes.asm SetPal_Overworld):
|
||||
@@ -388,8 +445,25 @@ end
|
||||
-- which recolored the whole screen per map -- see the survey zoom
|
||||
-- entry in docs/known-differences.md). Border fill inherits the
|
||||
-- current map's palette.
|
||||
--
|
||||
-- RED++ true overworld coloring does NOT go through this zone/shader
|
||||
-- system at all: TileRenderer bakes real per-tile GBC colors straight into
|
||||
-- a recolored tileset atlas (see TileRenderer's gbcAtlas), and
|
||||
-- SpriteRenderer bakes sprites' OBP colors the same way, so the world
|
||||
-- canvas is already final RGB by the time this runs. Returning an EMPTY
|
||||
-- list here (when the current map has that baked atlas) skips the shader
|
||||
-- entirely -- Renderer:endFrame's blit sees zoneList[1] == nil and falls
|
||||
-- back to a plain, unshaded draw. Returning plain `nil` would NOT do this:
|
||||
-- endFrame treats a nil worldZones as "no world-specific zones, reuse the
|
||||
-- UI pass's zones" (sgbPalettes' whole-screen named-palette zone), which
|
||||
-- would re-run the DMG shade-remap over already-true-color pixels using
|
||||
-- an unrelated 4-color palette -- exactly the "colors are wrong" bug this
|
||||
-- fixes.
|
||||
function OverworldState:sgbWorldZones()
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
if PaletteFX.usesGbcPack() and self.map.renderer and self.map.renderer.gbcAtlas then
|
||||
return {}
|
||||
end
|
||||
local base = PaletteFX.pal(Game.data, self:paletteNameFor(self.map))
|
||||
if not base then return nil end
|
||||
local vw, vh = Game.renderer:worldViewSize()
|
||||
@@ -590,6 +664,14 @@ function OverworldState:update(dt)
|
||||
-- keep the player sprite in sync with the bike state (the drawer
|
||||
-- picks the red_bike sheet while riding)
|
||||
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
|
||||
local da = self.dustAnim
|
||||
da.frames = da.frames - 1
|
||||
@@ -598,6 +680,14 @@ function OverworldState:update(dt)
|
||||
if da.onDone then da.onDone() 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
|
||||
local ha = self.healAnim
|
||||
local Music = require("src.core.Music")
|
||||
@@ -687,6 +777,14 @@ function OverworldState:update(dt)
|
||||
end
|
||||
|
||||
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
|
||||
self:onStepComplete()
|
||||
end
|
||||
@@ -883,8 +981,7 @@ function OverworldState:checkEdgeExit(dir)
|
||||
|
||||
local conn = self.map:connection(COMPASS[dir])
|
||||
if conn then
|
||||
self:crossConnection(dir, conn)
|
||||
return true
|
||||
return self:crossConnection(dir, conn)
|
||||
end
|
||||
return false
|
||||
end
|
||||
@@ -898,7 +995,7 @@ function OverworldState:crossConnection(dir, conn)
|
||||
local dest = Game.data.maps[conn.map]
|
||||
if not dest then
|
||||
Logger.warn("connection to unknown map %s", tostring(conn.map))
|
||||
return
|
||||
return false
|
||||
end
|
||||
local p = self.player
|
||||
local x, y = p.cellX, p.cellY
|
||||
@@ -914,6 +1011,15 @@ function OverworldState:crossConnection(dir, conn)
|
||||
end
|
||||
x = math.max(0, math.min(destW - 1, x))
|
||||
y = math.max(0, math.min(destH - 1, y))
|
||||
-- pokered's collision check reads the NEIGHBOR strip's tile bytes, so
|
||||
-- stepping off the edge onto a solid tile of the connected map bumps
|
||||
-- exactly like an in-map wall. Without this read, Pallet's south
|
||||
-- shore (land at x2-3) walked straight onto ROUTE_21 (3,0) -- a
|
||||
-- collision tile -- stranding the player on a cell no walk can leave.
|
||||
if not Map.defPassable(dest, Game.data.tilesets[dest.tileset], x, y,
|
||||
p.surfing) then
|
||||
return false
|
||||
end
|
||||
self:setMap(conn.map, x, y, p.facing, { seamless = true })
|
||||
-- place the player one cell before the seam (their old world spot,
|
||||
-- which the neighbor strip renders identically) and start the step
|
||||
@@ -931,6 +1037,7 @@ function OverworldState:crossConnection(dir, conn)
|
||||
p.stepFramesCur = Game.save.onBike
|
||||
and (FieldDefaults.world(Game.data, "bikeStepFrames") or 8)
|
||||
or (FieldDefaults.world(Game.data, "stepFrames") or 16)
|
||||
return true
|
||||
end
|
||||
|
||||
-- -------------------------------------------------------------------------
|
||||
@@ -1022,7 +1129,7 @@ function OverworldState:goFishing(rod)
|
||||
if Game.save.safari and Map.inRegion(self.map.def, "SAFARI", "SAFARI_ZONE") then
|
||||
battle:makeSafari(Game.save.safari)
|
||||
end
|
||||
battle.onFinish = function(result) self:afterBattle(result) end
|
||||
battle.onFinish = function(result) self:afterBattle(result, battle) end
|
||||
self:pushBattle(battle)
|
||||
end))
|
||||
end))
|
||||
@@ -1246,6 +1353,14 @@ function OverworldState:tryHiddenObject(fx, fy)
|
||||
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
|
||||
if not extras then return false end
|
||||
local facing = self.player.facing
|
||||
@@ -1328,7 +1443,18 @@ function OverworldState:tryCardKeyDoor(fx, fy)
|
||||
return true
|
||||
end
|
||||
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,
|
||||
(t._CardKeySuccessText1 or "Bingo!")
|
||||
.. (t._CardKeySuccessText2 or "\nThe CARD KEY\nopened the door!")))
|
||||
@@ -1430,6 +1556,71 @@ function OverworldState:trashCanSwitch(canIndex)
|
||||
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,
|
||||
-- engine/items/itemfinder.asm HiddenItemNear: coord > clamp0(player-5)
|
||||
-- and coord <= player+4 (Y) / player+5 (X) -- the clamp excludes
|
||||
@@ -1506,13 +1697,30 @@ function OverworldState:trySurf(fx, fy)
|
||||
end
|
||||
|
||||
function OverworldState:tryCut(fx, fy)
|
||||
-- UsedCut (engine/overworld/cut.asm) gates on the TILESET before
|
||||
-- anything else: only OVERWORLD (tree tile $3d) and GYM (plant tile
|
||||
-- $50) have cuttable anything. Matching raw block ids alone
|
||||
-- false-positives on every other tileset -- block ids are only
|
||||
-- meaningful within one tileset, so Route 23 (PLATEAU) had blocks
|
||||
-- matching a swap's `before`, and applying it wrote a block id that
|
||||
-- does not exist in PLATEAU's block table: the renderer indexed nil
|
||||
-- and the game crashed. The same false match is what made the bot
|
||||
-- chain-cut "ornamental bushes" around Saffron and Celadon.
|
||||
local ts = self.map.def.tileset
|
||||
local tile = self.map:cellTile(fx, fy)
|
||||
local isGrass = (ts == "OVERWORLD" and tile == 0x52)
|
||||
if not ((ts == "OVERWORLD" and tile == 0x3d)
|
||||
or (ts == "GYM" and tile == 0x50)
|
||||
or isGrass) then
|
||||
return false
|
||||
end
|
||||
local bx, by = math.floor(fx / 2), math.floor(fy / 2)
|
||||
local block = self.map:blockAt(bx, by)
|
||||
local swap
|
||||
for _, sw in ipairs(Game.data.field.cutTreeSwaps) do
|
||||
if sw.before == block then swap = sw break 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")
|
||||
if not mon then return false end
|
||||
-- gen 1 confirms nothing (engine/overworld/cut.asm UsedCut): the
|
||||
@@ -1527,13 +1735,37 @@ function OverworldState:tryCut(fx, fy)
|
||||
{ bx = bx, by = by, block = block })
|
||||
self.map:setBlock(bx, by, swap.after)
|
||||
self.map.renderer:rebuild()
|
||||
self:startDustAnim(fx, fy, function()
|
||||
local finish = function()
|
||||
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))
|
||||
return true
|
||||
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,
|
||||
-- farcall IsSurfingAllowed, then UseItem(SURFBOARD) -> ItemUseSurfboard
|
||||
-- (item_effects.asm), which either tries to dismount (already surfing) or
|
||||
@@ -1591,13 +1823,24 @@ function OverworldState:useCutFieldMove()
|
||||
if not self:partyKnows("CUT") then return "no_badge" end
|
||||
local fx, fy = self.player:facingCell()
|
||||
if not self.map:inBounds(fx, fy) then return "nothing" end
|
||||
-- same tileset/tile gate as tryCut (UsedCut, engine/overworld/cut.asm):
|
||||
-- a tree BLOCK also contains fence/path cells, and facing those is
|
||||
-- "nothing to cut" in vanilla
|
||||
local ts = self.map.def.tileset
|
||||
local tile = self.map:cellTile(fx, fy)
|
||||
local isGrass = (ts == "OVERWORLD" and tile == 0x52)
|
||||
if not ((ts == "OVERWORLD" and tile == 0x3d)
|
||||
or (ts == "GYM" and tile == 0x50)
|
||||
or isGrass) then
|
||||
return "nothing"
|
||||
end
|
||||
local bx, by = math.floor(fx / 2), math.floor(fy / 2)
|
||||
local block = self.map:blockAt(bx, by)
|
||||
local swap
|
||||
for _, sw in ipairs(Game.data.field.cutTreeSwaps) do
|
||||
if sw.before == block then swap = sw break 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"
|
||||
end
|
||||
|
||||
@@ -1654,7 +1897,7 @@ function OverworldState:talkTo(npc)
|
||||
if e == npc then table.remove(self.entities, i) break end
|
||||
end
|
||||
end
|
||||
self:afterBattle(result)
|
||||
self:afterBattle(result, battle)
|
||||
unfreeze()
|
||||
end
|
||||
self:pushBattle(battle)
|
||||
@@ -1769,9 +2012,11 @@ function OverworldState:openPC(onDone)
|
||||
end
|
||||
table.insert(items, { label = "LOG OFF", onSelect = logOff })
|
||||
-- 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,
|
||||
{ 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 }))
|
||||
end
|
||||
|
||||
@@ -1972,7 +2217,7 @@ function OverworldState:engageTrainer(npc, onDone)
|
||||
end
|
||||
self:checkVictoryRewards(d.trainerClass, d.trainerParty)
|
||||
local after = function()
|
||||
self:afterBattle(result)
|
||||
self:afterBattle(result, battle)
|
||||
if onDone then onDone() end
|
||||
end
|
||||
if wonText then
|
||||
@@ -1981,7 +2226,7 @@ function OverworldState:engageTrainer(npc, onDone)
|
||||
after()
|
||||
end
|
||||
else
|
||||
self:afterBattle(result)
|
||||
self:afterBattle(result, battle)
|
||||
if onDone then onDone() end
|
||||
end
|
||||
end
|
||||
@@ -1990,6 +2235,8 @@ function OverworldState:engageTrainer(npc, onDone)
|
||||
end
|
||||
|
||||
-- Badges/items awarded after specific battles (data/scripts/victories.lua).
|
||||
-- `deactivate` retires unfought gym/dojo trainers the way the originals'
|
||||
-- SetEvent / SetEventRange do after the leader victory.
|
||||
function OverworldState:checkVictoryRewards(trainerClass, partyIndex)
|
||||
local victories = require("data.scripts.victories")
|
||||
local reward = victories[trainerClass .. "#" .. tostring(partyIndex or 1)]
|
||||
@@ -1998,6 +2245,11 @@ function OverworldState:checkVictoryRewards(trainerClass, partyIndex)
|
||||
if Game.save.flags[reward.flag] then return self:runVictoryHook() end
|
||||
Game.save.flags[reward.flag] = true
|
||||
end
|
||||
if reward.deactivate then
|
||||
for _, flag in ipairs(reward.deactivate) do
|
||||
Game.save.flags[flag] = true
|
||||
end
|
||||
end
|
||||
local lines = {}
|
||||
if reward.badge then
|
||||
Game.save.inventory[reward.badge] = 1
|
||||
@@ -2348,7 +2600,7 @@ function OverworldState:onStepComplete()
|
||||
if Game.save.safari and Map.inRegion(self.map.def, "SAFARI", "SAFARI_ZONE") then
|
||||
battle:makeSafari(Game.save.safari)
|
||||
end
|
||||
battle.onFinish = function(result) self:afterBattle(result) end
|
||||
battle.onFinish = function(result) self:afterBattle(result, battle) end
|
||||
self:pushBattle(battle)
|
||||
return
|
||||
end
|
||||
@@ -2681,7 +2933,9 @@ function OverworldState:safariGameOver(text)
|
||||
end
|
||||
|
||||
-- Blackouts return to the last heal point; evolutions run after battles.
|
||||
function OverworldState:afterBattle(result)
|
||||
-- battle is optional; when given, Oak's Lab OPP_RIVAL1 losses skip the
|
||||
-- blackout (pret HandlePlayerBlackOut) so the map script can HealParty.
|
||||
function OverworldState:afterBattle(result, battle)
|
||||
local lead = Game.save.party[1]
|
||||
Logger.info("battle over: %s (lead %s %d/%d)", tostring(result),
|
||||
lead and lead.species or "-", lead and lead.hp or 0,
|
||||
@@ -2691,6 +2945,13 @@ function OverworldState:afterBattle(result)
|
||||
Evolution.checkParty(Game)
|
||||
end
|
||||
if result == "lose" then
|
||||
local oaksLabRival = battle and battle.oppClass == "OPP_RIVAL1"
|
||||
and self.map and self.map.id == "OAKS_LAB"
|
||||
if oaksLabRival then
|
||||
-- stay in the lab; OaksLabRivalEndBattleScript heals and continues
|
||||
evolutions()
|
||||
return
|
||||
end
|
||||
-- blackout: revive the party at the last heal point; half the
|
||||
-- money is lost (like the original)
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
@@ -2739,6 +3000,25 @@ function OverworldState:takeWarp(warpDef)
|
||||
-- facing carries across the warp (leaving a gate sideways keeps you
|
||||
-- walking sideways; house exit mats are stepped onto facing down)
|
||||
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:startWarpTo(destMap, x, y, facing)
|
||||
end
|
||||
@@ -2808,6 +3088,13 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts)
|
||||
require("src.core.Sound").play(Game.data, "Teleport_Enter1")
|
||||
-- ENTER_2 caps the spin-down a moment later
|
||||
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
|
||||
if doorWarp then
|
||||
local outdoor = Map.isOutdoor(self.map.def)
|
||||
@@ -3080,7 +3367,7 @@ function OverworldState:drawWorld()
|
||||
-- at (64,64); anchoring those coords to where the player stood keeps
|
||||
-- the overlay on the machine at any zoom.
|
||||
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 fxDef = Game.data.field.overworldFx
|
||||
if self.healMachineImg == nil and fxDef and fxDef.healMachine then
|
||||
@@ -3096,6 +3383,19 @@ function OverworldState:drawWorld()
|
||||
love.graphics.newQuad(0, 8, 8, 8, w, h), -- ball ($7d)
|
||||
}
|
||||
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 oy = ha.py - 64 - cam.y
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
@@ -3110,6 +3410,7 @@ function OverworldState:drawWorld()
|
||||
ox + b[1], oy + b[2])
|
||||
end
|
||||
end
|
||||
if shader then love.graphics.setShader() end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3140,6 +3441,37 @@ function OverworldState:drawWorld()
|
||||
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
|
||||
local function fxEmote()
|
||||
if not (self.emote and self.emote.npc) then return end
|
||||
@@ -3231,6 +3563,11 @@ function OverworldState:drawWorld()
|
||||
|
||||
if not tilt then
|
||||
-- === 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
|
||||
g.npc:draw(cam.x - g.ox, cam.y - g.oy)
|
||||
end
|
||||
@@ -3242,14 +3579,23 @@ function OverworldState:drawWorld()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
if self.map:isGrassCell(e.cellX, e.cellY) then
|
||||
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
|
||||
if e.targetX and self.map:isGrassCell(e.targetX, e.targetY) then
|
||||
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
|
||||
fxHeal()
|
||||
fxDust()
|
||||
fxCutTree()
|
||||
fxEmote()
|
||||
fxDark()
|
||||
fxBird()
|
||||
@@ -3263,6 +3609,7 @@ function OverworldState:drawWorld()
|
||||
-- layers are separate and composited ground-under-upright, so drawing
|
||||
-- it now into the still-active ground canvas is order-equivalent.
|
||||
fxDust()
|
||||
fxCutTree()
|
||||
|
||||
Game.renderer:beginUprightPass()
|
||||
|
||||
@@ -3323,7 +3670,7 @@ function OverworldState:drawWorld()
|
||||
-- the ground in front of where the player was)
|
||||
-- emote bubble -> the spotting NPC's foot (rides above its head)
|
||||
-- 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 fy = self.healAnim.py - cam.y + 16
|
||||
self:billboard(fx, fy, vw, vh, zoneColorsAt(zones, fx, fy), false, fxHeal)
|
||||
|
||||
+32
-9
@@ -26,12 +26,12 @@ function Player.new(data, cx, cy, facing)
|
||||
local walkId = FieldDefaults.fieldValue(data, "playerSprites", "walk")
|
||||
local surfId = FieldDefaults.fieldValue(data, "playerSprites", "surf")
|
||||
local bikeId = FieldDefaults.fieldValue(data, "playerSprites", "bike")
|
||||
self.sprite = SpriteRenderer.new(data.sprites[walkId])
|
||||
self.sprite = SpriteRenderer.new(data.sprites[walkId], "player")
|
||||
if surfId and data.sprites[surfId] then
|
||||
self.surfSprite = SpriteRenderer.new(data.sprites[surfId])
|
||||
self.surfSprite = SpriteRenderer.new(data.sprites[surfId], "player")
|
||||
end
|
||||
if bikeId and data.sprites[bikeId] then
|
||||
self.bikeSprite = SpriteRenderer.new(data.sprites[bikeId])
|
||||
self.bikeSprite = SpriteRenderer.new(data.sprites[bikeId], "player")
|
||||
end
|
||||
-- the ledge-hop shadow quarter-tile (gfx/overworld/shadow.png,
|
||||
-- LedgeHoppingShadow, engine/overworld/ledges.asm)
|
||||
@@ -84,9 +84,21 @@ function Player:update()
|
||||
if self.turnTimer > 0 then
|
||||
self.turnTimer = self.turnTimer - 1
|
||||
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
|
||||
local stepLen = self.stepFramesCur or self.stepFrames or STEP_FRAMES
|
||||
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 px = math.floor(self.progress * 16 / stepLen)
|
||||
self.px = self.cellX * 16 + d[1] * px
|
||||
@@ -108,8 +120,8 @@ end
|
||||
|
||||
function Player:walkPhase()
|
||||
if not self.moving then return 0 end
|
||||
-- walk frame during the middle of the step
|
||||
local p = self.progress % 16
|
||||
-- walk frame during the middle of each 16-frame animation cycle
|
||||
local p = (self.animClock or self.progress) % 16
|
||||
return (p >= 4 and p < 12) and 1 or 0
|
||||
end
|
||||
|
||||
@@ -139,15 +151,26 @@ function Player:draw(camX, camY)
|
||||
py = py + (self.bobTimer < 16 and 0 or 1)
|
||||
end
|
||||
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
|
||||
-- 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
|
||||
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
|
||||
local sprite = (self.surfing and self.surfSprite)
|
||||
or (self.onBike and self.bikeSprite) or self.sprite
|
||||
sprite:draw(self.px, py, camX, camY, facing,
|
||||
self:walkPhase(), self.stepFlip)
|
||||
sprite:draw(self.px, py, camX, camY, facing, phase, flip)
|
||||
end
|
||||
|
||||
return Player
|
||||
|
||||
+1921
-81
File diff suppressed because it is too large
Load Diff
@@ -598,6 +598,23 @@ check(PaletteFX.monPal(palData, "TESTMON") == monPalette,
|
||||
check(PaletteFX.monPal(palData, "UNKNOWN") == nil,
|
||||
"an unmapped species falls through to MEWMON (absent here)")
|
||||
|
||||
-- RED++ pack: per-species SuperPalettes from data/palettes_gbc.lua
|
||||
local prevMode = PaletteFX.mode
|
||||
PaletteFX.setMode("redpp")
|
||||
check(PaletteFX.usesGbcPack(), "redpp mode selects the gbc pack")
|
||||
local gbc = PaletteFX.gbcPack()
|
||||
check(gbc ~= nil and gbc.palettes.BULBASAUR ~= nil,
|
||||
"data/palettes_gbc.lua ships per-species pals")
|
||||
check(PaletteFX.monPalName({ palettes = nil }, "BULBASAUR") == "BULBASAUR",
|
||||
"RED++ monPalName resolves to the species palette id")
|
||||
check(PaletteFX.monPal({ palettes = nil }, "BULBASAUR") == gbc.palettes.BULBASAUR,
|
||||
"RED++ monPal reads the species colors without a ROM pack")
|
||||
check(PaletteFX.pal({ palettes = nil }, "ROUTE") == gbc.palettes.ROUTE,
|
||||
"RED++ still has ROUTE (aliased from VIRIDIAN)")
|
||||
check(PaletteFX.effectiveColors(gbc.palettes.MEWMON) == gbc.palettes.MEWMON,
|
||||
"RED++ passes zone colors through like GBC")
|
||||
PaletteFX.setMode(prevMode)
|
||||
|
||||
-- ------- the transitions registry
|
||||
|
||||
local transitions = Registry.new("transitions", Schemas.REGISTRIES.transitions)
|
||||
|
||||
@@ -205,7 +205,7 @@ end
|
||||
local om = OptionsMenu.new(optGame())
|
||||
local WANT_IDS = { "textSpeed", "animations", "battleStyle", "ruleset",
|
||||
"musicVol", "sfxVol", "musicFilter", "colors", "tilt",
|
||||
"gbcfx", "speed", "mods", "controls" }
|
||||
"gbcfx", "videoMode", "speed", "mods", "controls" }
|
||||
check(#om.rows == #WANT_IDS, "vanilla options row count (plus MODS/CONTROLS)")
|
||||
for i, id in ipairs(WANT_IDS) do
|
||||
check(om.rows[i].id == id, "options row order: " .. id)
|
||||
@@ -238,7 +238,7 @@ check(om.game.save.options.musicVol == 0, "music volume clamps at 0")
|
||||
-- the MODS row is the manager's discoverable home
|
||||
local mgGame = optGame()
|
||||
om = OptionsMenu.new(mgGame)
|
||||
om.rows[12].activate(mgGame)
|
||||
om.rows[13].activate(mgGame)
|
||||
check(getmetatable(mgGame.stack:top()) == ManagerState,
|
||||
"the MODS row opens the manager")
|
||||
check(mgGame.stack:top().screenId == "ManagerState",
|
||||
@@ -248,7 +248,7 @@ check(mgGame.stack:top().screenId == "ManagerState",
|
||||
local BindingsMenu = require("src.ui.BindingsMenu")
|
||||
local cbGame = optGame()
|
||||
om = OptionsMenu.new(cbGame)
|
||||
om.rows[13].activate(cbGame)
|
||||
om.rows[14].activate(cbGame)
|
||||
local bm = cbGame.stack:top()
|
||||
check(getmetatable(bm) == BindingsMenu,
|
||||
"the CONTROLS row opens the rebind list")
|
||||
|
||||
@@ -263,4 +263,103 @@ do
|
||||
package.loaded["src.render.TextBox"] = realTB
|
||||
end
|
||||
|
||||
-- (7) gym / dojo leader victory deactivates unfought non-leader trainers
|
||||
-- (PewterGym.asm "; deactivate gym trainers" / SetEventRange in the
|
||||
-- other gyms; FightingDojo.asm SetEventRange through TRAINER_3).
|
||||
do
|
||||
local victories = require("data.scripts.victories")
|
||||
local expected = {
|
||||
["OPP_BROCK#1"] = { "EVENT_BEAT_PEWTER_GYM_TRAINER_0" },
|
||||
["OPP_MISTY#1"] = { "EVENT_BEAT_CERULEAN_GYM_TRAINER_0",
|
||||
"EVENT_BEAT_CERULEAN_GYM_TRAINER_1" },
|
||||
["OPP_LT_SURGE#1"] = { "EVENT_BEAT_VERMILION_GYM_TRAINER_0",
|
||||
"EVENT_BEAT_VERMILION_GYM_TRAINER_1",
|
||||
"EVENT_BEAT_VERMILION_GYM_TRAINER_2" },
|
||||
["OPP_ERIKA#1"] = 7,
|
||||
["OPP_KOGA#1"] = 6,
|
||||
["OPP_SABRINA#1"] = 7,
|
||||
["OPP_BLAINE#1"] = 7,
|
||||
["OPP_GIOVANNI#3"] = 8,
|
||||
["OPP_BLACKBELT#1"] = 4,
|
||||
}
|
||||
for key, want in pairs(expected) do
|
||||
local d = victories[key] and victories[key].deactivate
|
||||
check(d ~= nil, key .. " lists trainers to deactivate")
|
||||
if type(want) == "number" then
|
||||
eq(#d, want, key .. " deactivates " .. want .. " trainers")
|
||||
else
|
||||
for i, flag in ipairs(want) do
|
||||
eq(d[i], flag, key .. " deactivate[" .. i .. "]")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Cinnabar trainers have no extracted def_trainers; seeded headers let
|
||||
-- EVENT_BEAT_CINNABAR_GYM_TRAINER_* satisfy trainerDefeated without
|
||||
-- stamping defeatedTrainers (which would also open the quiz gates).
|
||||
Data:seedCinnabarGymTrainerHeaders()
|
||||
check(Data.trainer_headers.CinnabarGym ~= nil,
|
||||
"CinnabarGym trainer headers seeded for deactivate flags")
|
||||
local nerd1 = Data:trainerHeader("CinnabarGym", 2)
|
||||
check(nerd1 and nerd1.event == "EVENT_BEAT_CINNABAR_GYM_TRAINER_0"
|
||||
and nerd1.range == 0,
|
||||
"Cinnabar SUPER_NERD1 header: event + no sight range")
|
||||
|
||||
require("src.render.Font").load(Data)
|
||||
local Game = require("src.core.Game")
|
||||
local Input = require("src.core.Input")
|
||||
local StateStack = require("src.core.StateStack")
|
||||
local Renderer = require("src.render.Renderer")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local OW = require("src.world.OverworldController")
|
||||
local realTB = package.loaded["src.render.TextBox"]
|
||||
package.loaded["src.render.TextBox"] = {
|
||||
new = function(game, text, done) return { text = text, onDone = done } end,
|
||||
}
|
||||
Game.data = Data
|
||||
Game.input = Input; Input:init()
|
||||
Game.renderer = Renderer; Renderer:init()
|
||||
Game.stack = StateStack; StateStack:init()
|
||||
while Game.stack:top() do Game.stack:pop() end
|
||||
Game.save = SaveData.newGame()
|
||||
Game.save.flags = {}
|
||||
Game.save.inventory = {}
|
||||
Game.save.defeatedTrainers = {}
|
||||
Game.stack:push(OW, "PEWTER_GYM", 4, 13, "up")
|
||||
local ow = Game.stack:top()
|
||||
ow:checkVictoryRewards("OPP_BROCK", 1)
|
||||
check(Game.save.flags.EVENT_BEAT_BROCK, "Brock victory sets EVENT_BEAT_BROCK")
|
||||
check(Game.save.inventory.BOULDERBADGE == 1, "Brock victory awards BOULDERBADGE")
|
||||
check(Game.save.flags.EVENT_BEAT_PEWTER_GYM_TRAINER_0,
|
||||
"Brock victory deactivates the Pewter Gym Cooltrainer")
|
||||
local pewterNpc
|
||||
for _, npc in ipairs(ow.npcs) do
|
||||
if npc.def.index == 2 then pewterNpc = npc break end
|
||||
end
|
||||
check(pewterNpc and ow:trainerDefeated(pewterNpc),
|
||||
"unfought Pewter gym trainer is defeated after badge")
|
||||
|
||||
while Game.stack:top() do Game.stack:pop() end
|
||||
Game.save = SaveData.newGame()
|
||||
Game.save.flags = {}
|
||||
Game.save.inventory = {}
|
||||
Game.save.defeatedTrainers = {}
|
||||
Game.stack:push(OW, "CINNABAR_GYM", 16, 15, "up")
|
||||
ow = Game.stack:top()
|
||||
ow:checkVictoryRewards("OPP_BLAINE", 1)
|
||||
check(Game.save.flags.EVENT_BEAT_CINNABAR_GYM_TRAINER_3,
|
||||
"Blaine victory deactivates Cinnabar quiz trainers")
|
||||
check(next(Game.save.defeatedTrainers) == nil,
|
||||
"Blaine deactivate does not stamp defeatedTrainers (gates stay put)")
|
||||
local cinnabarNpc
|
||||
for _, npc in ipairs(ow.npcs) do
|
||||
if npc.def.index == 5 then cinnabarNpc = npc break end
|
||||
end
|
||||
check(cinnabarNpc and ow:trainerDefeated(cinnabarNpc),
|
||||
"unfought Cinnabar trainer is defeated via seeded header event")
|
||||
|
||||
while Game.stack:top() do Game.stack:pop() end
|
||||
package.loaded["src.render.TextBox"] = realTB
|
||||
end
|
||||
|
||||
S.finish()
|
||||
|
||||
+16
-3
@@ -192,21 +192,34 @@ check(actsOn.cut and actsOn.surf and actsOn.strength,
|
||||
"CUT/SURF/STRENGTH submenu entries appear once the badges are held")
|
||||
|
||||
-- ===========================================================================
|
||||
-- I: CUT from the party menu (CERULEAN_CITY cut tree @18,28, stand @17,28)
|
||||
-- I: CUT from the party menu. The Cerulean tree BLOCK (50, at block 9,14)
|
||||
-- spans two unwalkable cells: the fence half at (18,28) (tile $50) and the
|
||||
-- tree itself at (19,28) (tile $3d). UsedCut (engine/overworld/cut.asm)
|
||||
-- checks the facing TILE, so only the tree cell cuts -- stand above it at
|
||||
-- (19,27) facing down, the way a vanilla player does.
|
||||
-- ===========================================================================
|
||||
Game.save.party = { mkMon("BULBASAUR", "CUT") }
|
||||
Game.save.inventory = { CASCADEBADGE = true }
|
||||
ow = pushOW("CERULEAN_CITY", 17, 28, "right")
|
||||
ow = pushOW("CERULEAN_CITY", 19, 27, "down")
|
||||
check(ow.map:blockAt(9, 14) == 50, "cut tree block (50) present before CUT")
|
||||
|
||||
eq(ow:useCutFieldMove(), "ok", "useCutFieldMove ok when facing a cut tree")
|
||||
ow.player.facing = "up"
|
||||
eq(ow:useCutFieldMove(), "nothing", "useCutFieldMove nothing when not facing a tree")
|
||||
ow.player.facing = "right"
|
||||
ow.player.facing = "down"
|
||||
Game.save.inventory.CASCADEBADGE = nil
|
||||
eq(ow:useCutFieldMove(), "no_badge", "useCutFieldMove no_badge without CASCADEBADGE")
|
||||
Game.save.inventory.CASCADEBADGE = true
|
||||
|
||||
-- the fence half of the same tree block is NOT cuttable (tile $50 on
|
||||
-- OVERWORLD, not the $3d tree tile)
|
||||
popToOW()
|
||||
ow = pushOW("CERULEAN_CITY", 17, 28, "right")
|
||||
eq(ow:useCutFieldMove(), "nothing",
|
||||
"useCutFieldMove nothing when facing the tree block's fence cell")
|
||||
popToOW()
|
||||
ow = pushOW("CERULEAN_CITY", 19, 27, "down")
|
||||
|
||||
-- success path: facing the tree -> _UsedCutText, menu closes, tree replaced
|
||||
clearCaptured()
|
||||
local pmCut = PartyMenu.new(Game)
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
-- Regression: map-edge crossings must read the NEIGHBOR map's collision.
|
||||
--
|
||||
-- Without that read, Pallet Town's south shore land spit (cells x=2,3 at
|
||||
-- y=17) walked straight onto ROUTE_21 (2,0)/(3,0) -- solid tiles. From
|
||||
-- (2,0) a further step south lands on walkable (2,1), after which the
|
||||
-- solid edge cell blocks the only path back to the seam, so the player
|
||||
-- cannot return north. pokered's CollisionCheckOnLand reads the neighbor
|
||||
-- strip's tile bytes and bumps; the port must do the same via
|
||||
-- Map.defPassable before crossConnection commits.
|
||||
--
|
||||
-- Self-contained; run via `luajit tests/parity_connection_collision.lua`.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local Data = require("src.core.Data")
|
||||
if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end
|
||||
|
||||
local Game = require("src.core.Game")
|
||||
local Input = require("src.core.Input")
|
||||
local Map = require("src.world.Map")
|
||||
local MapLoader = require("src.world.MapLoader")
|
||||
local Renderer = require("src.render.Renderer")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local StateStack = require("src.core.StateStack")
|
||||
local OW = require("src.world.OverworldController")
|
||||
local S = require("tests.harness").suite("parity connection collision")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
local pallet = MapLoader.load(Data, "PALLET_TOWN")
|
||||
local route21 = MapLoader.load(Data, "ROUTE_21")
|
||||
local ts = Data.tilesets[route21.def.tileset]
|
||||
|
||||
-- ground truth: the spit is land, the Route 21 landings are solid, and
|
||||
-- the water column next to it is surf-only
|
||||
for _, x in ipairs({ 2, 3 }) do
|
||||
check(pallet:isWalkableCell(x, 17),
|
||||
("Pallet south shore (%d,17) is walkable land"):format(x))
|
||||
check(not route21:isWalkableCell(x, 0),
|
||||
("ROUTE_21 (%d,0) is a solid landing"):format(x))
|
||||
check(not Map.defPassable(route21.def, ts, x, 0, false),
|
||||
("defPassable refuses land->solid at ROUTE_21 (%d,0)"):format(x))
|
||||
end
|
||||
check(route21:isWaterCell(5, 0), "ROUTE_21 (5,0) is water")
|
||||
check(not Map.defPassable(route21.def, ts, 5, 0, false),
|
||||
"water landing without Surf is refused")
|
||||
check(Map.defPassable(route21.def, ts, 5, 0, true),
|
||||
"water landing with Surf is allowed")
|
||||
check(not Map.defPassable(route21.def, nil, 2, 0, false),
|
||||
"missing tileset fails closed (no permissive fallback)")
|
||||
|
||||
-- Live edge: standing on the spit and pressing into the seam must bump,
|
||||
-- not swap maps. Pallet -> Route 1 north still crosses on grass.
|
||||
Game.data = Data
|
||||
Game.input = Input; Input:init()
|
||||
Game.renderer = Renderer; Renderer:init()
|
||||
Game.stack = StateStack
|
||||
StateStack:init()
|
||||
Game.save = SaveData.newGame()
|
||||
Game.overworld = OW
|
||||
|
||||
while Game.stack:top() do Game.stack:pop() end
|
||||
Game.stack:push(OW, "PALLET_TOWN", 2, 17, "down")
|
||||
local ow = Game.stack:top()
|
||||
eq(ow.map.id, "PALLET_TOWN", "start on Pallet south shore")
|
||||
eq(ow.player.cellX, 2, "spit x")
|
||||
eq(ow.player.cellY, 17, "spit y")
|
||||
|
||||
local conn = ow.map:connection("south")
|
||||
check(conn and conn.map == "ROUTE_21", "Pallet south connects to ROUTE_21")
|
||||
check(ow:crossConnection("down", conn) == false,
|
||||
"crossConnection bumps on ROUTE_21 solid landing")
|
||||
eq(ow.map.id, "PALLET_TOWN", "still on Pallet after the refused cross")
|
||||
eq(ow.player.cellX, 2, "x unchanged after bump")
|
||||
eq(ow.player.cellY, 17, "y unchanged after bump")
|
||||
|
||||
-- surfing the water column still crosses
|
||||
ow.player.cellX, ow.player.cellY = 5, 17
|
||||
ow.player.px, ow.player.py = 5 * 16, 17 * 16
|
||||
ow.player.surfing = true
|
||||
ow.player.moving = false
|
||||
check(ow:crossConnection("down", conn) == true,
|
||||
"surfing Pallet water still crosses onto ROUTE_21")
|
||||
eq(ow.map.id, "ROUTE_21", "surf cross lands on ROUTE_21")
|
||||
|
||||
-- north seam onto Route 1 grass still works on foot
|
||||
while Game.stack:top() do Game.stack:pop() end
|
||||
Game.stack:push(OW, "PALLET_TOWN", 10, 0, "up")
|
||||
ow = Game.stack:top()
|
||||
local north = ow.map:connection("north")
|
||||
check(north and north.map == "ROUTE_1", "Pallet north connects to ROUTE_1")
|
||||
check(ow:crossConnection("up", north) == true,
|
||||
"Pallet -> Route 1 grass crossing still allowed")
|
||||
eq(ow.map.id, "ROUTE_1", "north cross lands on ROUTE_1")
|
||||
|
||||
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()
|
||||
+138
-12
@@ -38,6 +38,19 @@ check(pallet:isWarpTileCell(5, 5), "Red's house door is a door tile")
|
||||
local w = pallet:warpAtCell(5, 5)
|
||||
eq(w.def.destMap, "REDS_HOUSE_1F", "door warp goes to Red's house")
|
||||
|
||||
-- Pallet south shore spit: land that faces ROUTE_21 solids. Crossing
|
||||
-- without reading the neighbor tile stranded players (see
|
||||
-- tests/parity_connection_collision.lua).
|
||||
local Map = require("src.world.Map")
|
||||
local route21Def = Data.maps.ROUTE_21
|
||||
local route21Tileset = Data.tilesets[route21Def.tileset]
|
||||
check(pallet:isWalkableCell(2, 17), "Pallet south shore spit (2,17) walkable")
|
||||
check(pallet:isWalkableCell(3, 17), "Pallet south shore spit (3,17) walkable")
|
||||
check(not Map.defPassable(route21Def, route21Tileset, 2, 0, false),
|
||||
"ROUTE_21 (2,0) refuses a land edge cross")
|
||||
check(not Map.defPassable(route21Def, route21Tileset, 3, 0, false),
|
||||
"ROUTE_21 (3,0) refuses a land edge cross")
|
||||
|
||||
-- signs
|
||||
local sign = pallet:signAtCell(13, 13)
|
||||
eq(sign.text, "TEXT_PALLETTOWN_OAKSLAB_SIGN", "Oak's lab sign at (13,13)")
|
||||
@@ -731,6 +744,9 @@ do
|
||||
{ map = "UNDERGROUND_PATH_ROUTE_6", route = "ROUTE_6", x = 17, y = 13 },
|
||||
{ map = "UNDERGROUND_PATH_ROUTE_7", route = "ROUTE_7", x = 5, y = 13 },
|
||||
{ map = "UNDERGROUND_PATH_ROUTE_8", route = "ROUTE_8", x = 13, y = 3 },
|
||||
-- DiglettsCaveRoute{2,11}_Script: same pattern for the cave's two ends
|
||||
{ map = "DIGLETTS_CAVE_ROUTE_2", route = "ROUTE_2", x = 12, y = 9 },
|
||||
{ map = "DIGLETTS_CAVE_ROUTE_11", route = "ROUTE_11", x = 4, y = 5 },
|
||||
}
|
||||
for _, c in ipairs(cases) do
|
||||
local rewrite = rewrites[c.map]
|
||||
@@ -851,6 +867,11 @@ do
|
||||
ab.rng = mkseq({ 255 }) -- the 1/256 miss
|
||||
ab:performMove(ab.player, ab.enemy, { id = "THUNDER_WAVE", pp = 10 })
|
||||
eq(ab.enemy.mon.status, nil, "THUNDER WAVE misses on the 255 roll")
|
||||
local function hasAnim(b)
|
||||
for _, r in ipairs(b.queue) do if r.anim then return true end end
|
||||
return false
|
||||
end
|
||||
check(not hasAnim(ab), "a missed THUNDER WAVE plays no move animation")
|
||||
ab.rng = mkseq({ 254 })
|
||||
ab:performMove(ab.player, ab.enemy, { id = "THUNDER_WAVE", pp = 10 })
|
||||
eq(ab.enemy.mon.status, "PAR", "THUNDER WAVE lands on the 254 roll")
|
||||
@@ -861,6 +882,33 @@ do
|
||||
eq(sbst.player.stages.attack, 1, "SHARPEN skips the accuracy roll")
|
||||
end
|
||||
|
||||
-- HandleIfPlayerMoveMissed: skip PlayMoveAnimation on a miss
|
||||
-- (unless EXPLODE_EFFECT)
|
||||
do
|
||||
Game.save.party = { Pokemon.new(Data, "BULBASAUR", 20) }
|
||||
local function hasAnim(b)
|
||||
for _, r in ipairs(b.queue) do if r.anim then return true end end
|
||||
return false
|
||||
end
|
||||
local function sawMiss(b)
|
||||
for _, r in ipairs(b.queue) do
|
||||
if r.text and r.text:find("attack missed!", 1, true) then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
local mb = BattleState.newWild(Game, "RATTATA", 5)
|
||||
mb.rng = function(a, b) return b end -- accuracy 255: miss
|
||||
mb:performMove(mb.player, mb.enemy, { id = "TACKLE", pp = 10 })
|
||||
check(sawMiss(mb), "TACKLE miss prints AttackMissedText")
|
||||
check(not hasAnim(mb), "a missed TACKLE plays no move animation")
|
||||
eq(mb.enemy.mon.hp, mb.enemy.mon.stats.hp, "a missed TACKLE deals no damage")
|
||||
|
||||
local hb = BattleState.newWild(Game, "RATTATA", 5)
|
||||
hb.rng = function(a, b) return a end -- hit
|
||||
hb:performMove(hb.player, hb.enemy, { id = "TACKLE", pp = 10 })
|
||||
check(hasAnim(hb), "a landing TACKLE still queues its move animation")
|
||||
end
|
||||
|
||||
-- #14: EXP.ALL second pass inherits the participant divisor and skips
|
||||
-- fainted mons
|
||||
do
|
||||
@@ -883,6 +931,61 @@ do
|
||||
Game.save.inventory.EXP_ALL = nil
|
||||
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
|
||||
end
|
||||
|
||||
@@ -1097,6 +1200,20 @@ do
|
||||
tb.trainer.name)),
|
||||
"trainer defeat uses '<PLAYER> defeated <TRAINER>!'")
|
||||
|
||||
-- pret GetTrainerName_: rival classes show wRivalName, not "RIVAL1"
|
||||
do
|
||||
local savedRival = Game.save.player.rival
|
||||
Game.save.player.rival = "GARY"
|
||||
Game.save.party = { Pokemon.new(Data, "BULBASAUR", 30) }
|
||||
local rb = BattleState.newTrainer(Game, "OPP_RIVAL1", 1)
|
||||
eq(rb.trainer.name, "GARY", "rival battle uses saved rival name")
|
||||
check(rb.introText:find("GARY", 1, true),
|
||||
"rival intro wants-to-fight uses rival name")
|
||||
check(Data.trainers.OPP_RIVAL1.name == "RIVAL1",
|
||||
"shared trainer data keeps the RIVAL1 placeholder")
|
||||
Game.save.player.rival = savedRival
|
||||
end
|
||||
|
||||
-- PartyMenu onCancel fires when backing out without a pick
|
||||
do
|
||||
local PartyMenu = require("src.ui.PartyMenu")
|
||||
@@ -1964,8 +2081,8 @@ do
|
||||
-- option boxes (the port rows plus the MODS/CONTROLS entries) through a 4-box
|
||||
-- viewport with a $EE ▼ marker; MUSIC VOL / SFX VOL clamp at 0..7 like
|
||||
-- the text-speed cursor clamps at its ends (.pressedLeftInTextSpeed),
|
||||
-- MUSIC FILTER cycles OFF/1X/2X/3X, and COLORS / TILT / GBC FX cycle
|
||||
-- their display modes.
|
||||
-- MUSIC FILTER cycles OFF/1X/2X/3X, and COLORS / TILT / GBC FX / VIDEO MODE
|
||||
-- cycle their display modes.
|
||||
do
|
||||
local OptionsMenu = require("src.ui.OptionsMenu")
|
||||
local OInput = require("src.core.Input")
|
||||
@@ -1973,6 +2090,7 @@ do
|
||||
local Tilt = require("src.render.Tilt")
|
||||
local GBCFX = require("src.render.GBCFX")
|
||||
local GameSpeed = require("src.core.GameSpeed")
|
||||
local VideoMode = require("src.core.VideoMode")
|
||||
local SD = require("src.core.SaveData")
|
||||
-- Isolate from earlier save/options writes in this suite
|
||||
SD.saveOptions(SD.defaultOptions())
|
||||
@@ -1991,6 +2109,8 @@ do
|
||||
eq(og.save.options.colors, "gbc", "new saves default COLORS to GBC")
|
||||
eq(og.save.options.tilt, 0, "new saves default TILT to OFF")
|
||||
eq(og.save.options.gbcfx, 0, "new saves default GBC FX to OFF")
|
||||
eq(og.save.options.videoMode, "windowed",
|
||||
"new saves default VIDEO MODE to WINDOWED")
|
||||
eq(om.scroll, 0, "options viewport starts at the top")
|
||||
for _ = 1, 4 do press("down") end
|
||||
eq(om.index, 5, "cursor reaches MUSIC VOL")
|
||||
@@ -2011,10 +2131,7 @@ do
|
||||
press("down")
|
||||
eq(om.index, 8, "cursor reaches COLORS")
|
||||
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
|
||||
eq(og.save.options.colors, "gbc", "COLORS wraps back to GBC")
|
||||
press("down")
|
||||
eq(om.index, 9, "cursor reaches TILT")
|
||||
press("a")
|
||||
@@ -2030,7 +2147,15 @@ do
|
||||
for _ = 1, 4 do press("a") end
|
||||
eq(og.save.options.gbcfx, 0, "GBC FX wraps back to OFF")
|
||||
press("down")
|
||||
eq(om.index, 11, "cursor reaches GAME SPEED")
|
||||
eq(om.index, 11, "cursor reaches VIDEO MODE")
|
||||
press("a")
|
||||
eq(og.save.options.videoMode, "borderless",
|
||||
"A cycles VIDEO MODE to BORDERLESS")
|
||||
press("a")
|
||||
eq(og.save.options.videoMode, "windowed",
|
||||
"VIDEO MODE wraps back to WINDOWED")
|
||||
press("down")
|
||||
eq(om.index, 12, "cursor reaches GAME SPEED")
|
||||
press("a")
|
||||
eq(og.save.options.speed, 2, "A cycles GAME SPEED to 2X")
|
||||
-- Driven by the level list rather than a literal press count: adding a
|
||||
@@ -2039,25 +2164,26 @@ do
|
||||
for _ = 1, #GameSpeed.LEVELS - 1 do press("a") end
|
||||
eq(og.save.options.speed, 1, "GAME SPEED wraps back to NORMAL")
|
||||
press("down")
|
||||
eq(om.index, 12, "cursor reaches MODS")
|
||||
eq(om.index, 13, "cursor reaches MODS")
|
||||
press("down")
|
||||
eq(om.index, 13, "cursor reaches CONTROLS")
|
||||
eq(om.index, 14, "cursor reaches CONTROLS")
|
||||
press("down")
|
||||
eq(om.index, 14, "CANCEL stays the fixed final row")
|
||||
eq(om.scroll, 9, "CANCEL keeps the last option boxes on screen")
|
||||
eq(om.index, 15, "CANCEL stays the fixed final row")
|
||||
eq(om.scroll, 10, "CANCEL keeps the last option boxes on screen")
|
||||
om:draw() -- smoke: scrolled layout draws under the headless stub
|
||||
press("a")
|
||||
check(popped, "A on CANCEL closes the options menu")
|
||||
local om2 = OptionsMenu.new(og)
|
||||
OInput.pressed = { up = true }; om2:update(1 / 60); OInput.pressed = {}
|
||||
eq(om2.index, 14, "up from the top wraps to CANCEL")
|
||||
eq(om2.scroll, 9, "wrapping to CANCEL scrolls to the tail")
|
||||
eq(om2.index, 15, "up from the top wraps to CANCEL")
|
||||
eq(om2.scroll, 10, "wrapping to CANCEL scrolls to the tail")
|
||||
-- headless-safe: no love.audio, setters only update internal state
|
||||
require("src.core.Music").applyOptions(og.save.options)
|
||||
require("src.core.Sound").applyOptions(og.save.options)
|
||||
PaletteFX.applyOptions(og.save.options)
|
||||
Tilt.applyOptions(og.save.options)
|
||||
GBCFX.applyOptions(og.save.options)
|
||||
VideoMode.applyOptions(og.save.options)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
+54
-2
@@ -116,7 +116,8 @@ def parse_hidden_events(pokered):
|
||||
Itemfinder detects), HiddenCoins (Game Corner floor coins) and
|
||||
StartSlotMachine (slot machine seats; arg SLOTS_* marks broken ones).
|
||||
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
|
||||
fourth macro argument is the facing direction required to trigger the
|
||||
event, except GymTrashScript where it is the can index.
|
||||
@@ -153,7 +154,7 @@ def parse_hidden_events(pokered):
|
||||
elif arg == "SLOTS_SOMEONESKEYS":
|
||||
state = "keys"
|
||||
slots.setdefault(current, []).append({"x": x, "y": y, "state": state})
|
||||
elif func == "OpenPokemonCenterPC":
|
||||
elif func == "OpenPokemonCenterPC" or func == "OpenRedsPC":
|
||||
extras["pcTiles"].setdefault(current, []).append(
|
||||
{"x": x, "y": y, "facing": DIRS.get(arg, arg)})
|
||||
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?)")
|
||||
if n_doors != 22 or len(maps) != 10:
|
||||
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 {
|
||||
"maps": maps, # maps where the engine checks for doors
|
||||
"doors": doors, # tile coords; block coord = floor(coord/2)
|
||||
"doorTiles": [0x18, 0x24], # locked-door tile ids (FACILITY tileset)
|
||||
"openBlock": 0x0e, # block written over the door's block
|
||||
"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",
|
||||
"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
|
||||
|
||||
|
||||
|
||||
+475
-3
@@ -1,6 +1,6 @@
|
||||
"""Extract the Super Game Boy colorization palettes.
|
||||
|
||||
Sources:
|
||||
Sources (vanilla pokered):
|
||||
data/sgb/sgb_palettes.asm SuperPalettes: 4 colors per PAL_* entry,
|
||||
5-bit RGB (IF DEF(_RED) variants are used;
|
||||
this is a Red port)
|
||||
@@ -8,17 +8,60 @@ Sources:
|
||||
in Pokédex order with species names in the
|
||||
line comments
|
||||
|
||||
Sources (pokered-gbc / Red++ SuperPalettes, optional COLORS mode):
|
||||
data/super_palettes.asm multi-line RGB + ; 0xNN: PAL_NAME comments;
|
||||
includes per-species pals under GEN_2_GRAPHICS
|
||||
data/mon_palettes.asm MonsterPalettes: GEN_2 per-species PAL_* ids
|
||||
|
||||
Output: data/generated/palettes.lua
|
||||
palettes[NAME] = { {r,g,b} x4 } with 8-bit components, color 0 first
|
||||
pokemon[SPECIES] = NAME (PAL_ prefix stripped)
|
||||
|
||||
Output: data/palettes_gbc.lua (committed; not wiped by ROM import)
|
||||
same shape, plus per-species palette entries (BULBASAUR, …), plus a
|
||||
`world` table (below) for true overworld tile/roof/sprite coloring.
|
||||
|
||||
The HP bar fill is GB color 2 of PAL_GREENBAR / PAL_YELLOWBAR /
|
||||
PAL_REDBAR; the thresholds live in home/palettes.asm GetHealthBarColor
|
||||
(>= 27 pixels green, >= 10 yellow, else red).
|
||||
|
||||
Sources (pokered-gbc color/**, the real GBC overworld coloring engine):
|
||||
color/data/map_palette_assignments.asm per tileset: 96 tile-graphic-id
|
||||
-> palette-group (0-7) bytes
|
||||
color/data/map_palette_sets.asm per tileset: 8 group -> palette-
|
||||
constant bytes (positional)
|
||||
color/data/map_palettes.asm palette-constant -> 4 RGB colors
|
||||
color/data/roofpalettes.asm per pokered map ID (positional,
|
||||
matches map.def.index): roof
|
||||
2-color override + the labeled
|
||||
RGB blocks it points at
|
||||
color/data/spritepalettes.asm 8 four-color OBJ palettes
|
||||
color/sprites.asm SpritePaletteAssignments: 1-based
|
||||
picture ID -> OBJ palette 0-3, or
|
||||
the "db 4" random-per-instance
|
||||
sentinel (ColorOverworldSprite)
|
||||
|
||||
Output `world` table shape:
|
||||
world.tileGroups[TILESET][tileId 0-95] = group index 0-7
|
||||
world.groupColors[TILESET][group 0-7] = { {r,g,b} x4 }
|
||||
world.roofGroup[TILESET] = the town-overridable group index (OVERWORLD/
|
||||
PLATEAU only)
|
||||
world.roofByMapIndex[mapIndex] = { {r,g,b} x2 } (matches map.def.index)
|
||||
world.spritePalettes[0-7] = { {r,g,b} x4 }
|
||||
world.spriteAssignment[pictureIndex 0-based] = group 0-3 or "random"
|
||||
(pictureIndex matches the N in a sprite def's
|
||||
`source = "ROM:SpriteSheetPointerTable[N]"`)
|
||||
|
||||
The 3 hardcoded Celadon Mart tile-group exceptions and the Route 6/Saffron
|
||||
roof y-split (LoadTilesetPalette / LoadTownPalette control flow, not data)
|
||||
are NOT extracted here -- they live as a small named table in
|
||||
src/render/PaletteFX.lua next to the code that consumes `world`.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
from . import util
|
||||
|
||||
@@ -28,6 +71,11 @@ def _scale5(v):
|
||||
return round(int(v) * 255 / 31)
|
||||
|
||||
|
||||
def _rgb8(nums12):
|
||||
return [[_scale5(nums12[i]), _scale5(nums12[i + 1]), _scale5(nums12[i + 2])]
|
||||
for i in range(0, 12, 3)]
|
||||
|
||||
|
||||
def _read_raw(path):
|
||||
"""Raw lines WITH comments (palette / species names live in them)."""
|
||||
with open(path, encoding="utf-8") as f:
|
||||
@@ -54,8 +102,7 @@ def extract(pokered, out_dir):
|
||||
if len(nums) != 12:
|
||||
util.die(f"sgb_palettes.asm:{lineno}: expected 12 components, got {len(nums)}")
|
||||
name = m.group(2)
|
||||
palettes[name] = [[_scale5(nums[i]), _scale5(nums[i + 1]), _scale5(nums[i + 2])]
|
||||
for i in range(0, 12, 3)]
|
||||
palettes[name] = _rgb8(nums)
|
||||
order.append(name)
|
||||
|
||||
# ---- MonsterPalettes -------------------------------------------------
|
||||
@@ -91,3 +138,428 @@ def extract(pokered, out_dir):
|
||||
header="SGB colorization: 4 8-bit RGB colors per palette (color 0\n"
|
||||
"first) and the per-species palette assignment.")
|
||||
return palettes, mon_pals
|
||||
|
||||
|
||||
def _parse_gbc_super_palettes(path):
|
||||
"""pokered-gbc data/super_palettes.asm: ; 0xNN: PAL_NAME then 4x RGB lines."""
|
||||
palettes = {}
|
||||
order = []
|
||||
pending = None
|
||||
buf = []
|
||||
for lineno, line in _read_raw(path):
|
||||
s = line.strip()
|
||||
m = re.match(r";\s*0x[0-9a-fA-F]+:\s*PAL_(\w+)", s)
|
||||
if m:
|
||||
pending = m.group(1)
|
||||
buf = []
|
||||
continue
|
||||
m = re.match(r"RGB\s+([\d,\s]+)", s)
|
||||
if not m or not pending:
|
||||
continue
|
||||
nums = [n for n in re.split(r"[,\s]+", m.group(1).strip()) if n]
|
||||
buf.extend(nums)
|
||||
if len(buf) < 12:
|
||||
continue
|
||||
if len(buf) != 12:
|
||||
util.die(f"{path}:{lineno}: expected 12 components for PAL_{pending}, "
|
||||
f"got {len(buf)}")
|
||||
palettes[pending] = _rgb8(buf)
|
||||
order.append(pending)
|
||||
pending = None
|
||||
buf = []
|
||||
if pending:
|
||||
util.die(f"{path}: incomplete palette PAL_{pending}")
|
||||
return palettes, order
|
||||
|
||||
|
||||
def _parse_gbc_monster_palettes(path):
|
||||
"""GEN_2_GRAPHICS MonsterPalettes paired with ELSE-branch species comments."""
|
||||
lines = _read_raw(path)
|
||||
# species order from the vanilla ELSE comments (dex order + MISSINGNO)
|
||||
species_order = []
|
||||
in_else = False
|
||||
for _, line in lines:
|
||||
s = line.strip()
|
||||
if s.startswith("ELSE"):
|
||||
in_else = True
|
||||
continue
|
||||
if s.startswith("ENDC"):
|
||||
break
|
||||
if not in_else:
|
||||
continue
|
||||
m = re.match(r"db\s+PAL_\w+\s*;\s*(\w+)", s)
|
||||
if m:
|
||||
species_order.append(m.group(1))
|
||||
|
||||
gen2_pals = []
|
||||
in_gen2 = False
|
||||
for lineno, line in lines:
|
||||
s = line.strip()
|
||||
if s.startswith("MonsterPalettes:"):
|
||||
continue
|
||||
if s.startswith("IF GEN_2_GRAPHICS"):
|
||||
in_gen2 = True
|
||||
continue
|
||||
if s.startswith("ELSE") or s.startswith("TrainerPalettes:"):
|
||||
break
|
||||
if not in_gen2:
|
||||
continue
|
||||
m = re.match(r"db\s+PAL_(\w+)", s)
|
||||
if m:
|
||||
gen2_pals.append(m.group(1))
|
||||
|
||||
if len(species_order) != len(gen2_pals):
|
||||
util.die(f"{path}: GEN_2 MonsterPalettes ({len(gen2_pals)}) != "
|
||||
f"ELSE species comments ({len(species_order)})")
|
||||
mon_pals = {}
|
||||
for species, pal in zip(species_order, gen2_pals):
|
||||
if species != "MISSINGNO":
|
||||
mon_pals[species] = pal
|
||||
return mon_pals
|
||||
|
||||
|
||||
# ---- color/** overworld attribute data ------------------------------------
|
||||
#
|
||||
# Two tiny fixed enums, hand-transcribed rather than parsed: both are
|
||||
# `EQU`/`const` lists that never change (they are the hardware-shaped slot
|
||||
# numbering, not game content), and each uses a different const-declaration
|
||||
# style that isn't worth a bespoke parser for 4-8 entries.
|
||||
# color/data/map_palette_constants.asm:69-78 (`const_value = 0` block)
|
||||
_GROUP_NAMES = ["GRAY", "RED", "GREEN", "BLUE", "YELLOW", "BROWN", "ROOF", "TEXT"]
|
||||
_GROUP_INDEX = {name: i for i, name in enumerate(_GROUP_NAMES)}
|
||||
# color/sprites.asm:15-18 (`SPR_PAL_* EQU n`)
|
||||
_SPR_PAL = {"SPR_PAL_ORANGE": 0, "SPR_PAL_BLUE": 1, "SPR_PAL_GREEN": 2, "SPR_PAL_BROWN": 3}
|
||||
|
||||
|
||||
def _rgbN(nums):
|
||||
return [[_scale5(nums[i]), _scale5(nums[i + 1]), _scale5(nums[i + 2])]
|
||||
for i in range(0, len(nums), 3)]
|
||||
|
||||
|
||||
def _parse_rgb_table(path, header_re, n_nums):
|
||||
"""name -> n_nums/3 RGB colors, from blocks of `header_re` + RGB lines
|
||||
(one or more `RGB r,g,b[,r,g,b...]` lines per block, however they're
|
||||
wrapped -- matches both the 1-color-per-line and 4-colors-per-line
|
||||
styles pokered-gbc uses in different files)."""
|
||||
out = {}
|
||||
order = []
|
||||
pending = None
|
||||
buf = []
|
||||
for lineno, line in _read_raw(path):
|
||||
s = line.strip()
|
||||
m = header_re.match(s)
|
||||
if m:
|
||||
if pending:
|
||||
util.die(f"{path}:{lineno}: incomplete RGB block for "
|
||||
f"{pending} ({len(buf)}/{n_nums})")
|
||||
pending = m.group(1)
|
||||
buf = []
|
||||
continue
|
||||
m = re.match(r"RGB\s+([\d,\s]+)", s)
|
||||
if not m or not pending:
|
||||
continue
|
||||
nums = [n for n in re.split(r"[,\s]+", m.group(1).strip()) if n]
|
||||
buf.extend(nums)
|
||||
if len(buf) < n_nums:
|
||||
continue
|
||||
if len(buf) != n_nums:
|
||||
util.die(f"{path}:{lineno}: expected {n_nums} components for "
|
||||
f"{pending}, got {len(buf)}")
|
||||
out[pending] = _rgbN(buf)
|
||||
order.append(pending)
|
||||
pending = None
|
||||
buf = []
|
||||
if pending:
|
||||
util.die(f"{path}: incomplete RGB block for {pending}")
|
||||
return out, order
|
||||
|
||||
|
||||
_TILESET_HEADER_RE = re.compile(r"^;\s*([A-Z][A-Z0-9_]*)\s*$")
|
||||
|
||||
|
||||
def _parse_map_palette_assignments(path):
|
||||
"""color/data/map_palette_assignments.asm: per tileset, 96 tile-graphic
|
||||
palette-group bytes (0-7, symbolic), 16 per line / 6 lines."""
|
||||
tile_groups = {}
|
||||
order = []
|
||||
current = None
|
||||
vals = []
|
||||
|
||||
def flush(lineno):
|
||||
if len(vals) != 96:
|
||||
util.die(f"{path}:{lineno}: {current} has {len(vals)} tile "
|
||||
f"groups (want 96)")
|
||||
tile_groups[current] = vals[:]
|
||||
order.append(current)
|
||||
|
||||
for lineno, line in _read_raw(path):
|
||||
s = line.strip()
|
||||
m = _TILESET_HEADER_RE.match(s)
|
||||
if m:
|
||||
if current:
|
||||
flush(lineno)
|
||||
current, vals = m.group(1), []
|
||||
continue
|
||||
m = re.match(r"db\s+(.*)$", s)
|
||||
if not m or not current:
|
||||
continue
|
||||
for tok in m.group(1).split(","):
|
||||
tok = tok.strip()
|
||||
if tok not in _GROUP_INDEX:
|
||||
util.die(f"{path}:{lineno}: unknown palette group {tok!r}")
|
||||
vals.append(_GROUP_INDEX[tok])
|
||||
if current:
|
||||
flush("EOF")
|
||||
return tile_groups, order
|
||||
|
||||
|
||||
def _parse_map_palette_sets(path):
|
||||
"""color/data/map_palette_sets.asm: per tileset, 8 palette-constant
|
||||
names, positional (index = the palette-group 0-7 above)."""
|
||||
sets = {}
|
||||
order = []
|
||||
current = None
|
||||
vals = []
|
||||
|
||||
def flush(lineno):
|
||||
if len(vals) != 8:
|
||||
util.die(f"{path}:{lineno}: {current} has {len(vals)} palette "
|
||||
f"sets (want 8)")
|
||||
sets[current] = vals[:]
|
||||
order.append(current)
|
||||
|
||||
for lineno, line in _read_raw(path):
|
||||
s = line.strip()
|
||||
m = _TILESET_HEADER_RE.match(s)
|
||||
if m:
|
||||
if current:
|
||||
flush(lineno)
|
||||
current, vals = m.group(1), []
|
||||
continue
|
||||
m = re.match(r"db\s+(\w+)", s)
|
||||
if m and current:
|
||||
vals.append(m.group(1))
|
||||
if current:
|
||||
flush("EOF")
|
||||
return sets, order
|
||||
|
||||
|
||||
def _parse_roof_order(path):
|
||||
"""color/data/roofpalettes.asm's RoofPalettes: dw list, positional by
|
||||
pokered map ID (matches map.def.index in the recomp project)."""
|
||||
names = []
|
||||
in_table = False
|
||||
for lineno, line in _read_raw(path):
|
||||
s = line.strip()
|
||||
if s.startswith("RoofPalettes:"):
|
||||
in_table = True
|
||||
continue
|
||||
if not in_table:
|
||||
continue
|
||||
m = re.match(r"dw\s+(\w+)", s)
|
||||
if m:
|
||||
names.append(m.group(1))
|
||||
continue
|
||||
if s == "":
|
||||
continue
|
||||
break # first non-dw, non-blank line (a Label:) ends the table
|
||||
if not names:
|
||||
util.die(f"{path}: RoofPalettes table not found or empty")
|
||||
return names
|
||||
|
||||
|
||||
def _parse_sprite_assignments(path):
|
||||
"""color/sprites.asm SpritePaletteAssignments: 1-based picture ID (from
|
||||
the '; 0xNN: SPRITE_NAME' comment) -> OBJ palette 0-3, or "random" for
|
||||
the "db 4" per-instance sentinel (ColorOverworldSprite). Returned keyed
|
||||
0-based (pictureId - 1), matching a recomp sprite def's
|
||||
`source = "ROM:SpriteSheetPointerTable[N]"` index."""
|
||||
assignments = {}
|
||||
in_table = False
|
||||
pending_id = None
|
||||
header_re = re.compile(r"^;\s*0x([0-9a-fA-F]+):")
|
||||
for lineno, line in _read_raw(path):
|
||||
s = line.strip()
|
||||
if s.startswith("SpritePaletteAssignments:"):
|
||||
in_table = True
|
||||
continue
|
||||
if not in_table:
|
||||
continue
|
||||
m = header_re.match(s)
|
||||
if m:
|
||||
pending_id = int(m.group(1), 16)
|
||||
continue
|
||||
m = re.match(r"db\s+(\w+)", s)
|
||||
if m and pending_id is not None:
|
||||
tok = m.group(1)
|
||||
if tok == "4":
|
||||
assignments[pending_id - 1] = "random"
|
||||
elif tok in _SPR_PAL:
|
||||
assignments[pending_id - 1] = _SPR_PAL[tok]
|
||||
else:
|
||||
util.die(f"{path}:{lineno}: unknown sprite palette {tok!r}")
|
||||
pending_id = None
|
||||
continue
|
||||
if s and not s.startswith(";"):
|
||||
break # next label (AnimationTileset1Palettes:) ends the table
|
||||
if not assignments:
|
||||
util.die(f"{path}: SpritePaletteAssignments not found or empty")
|
||||
return assignments
|
||||
|
||||
|
||||
def extract_gbc_world(pokered_gbc):
|
||||
"""pokered-gbc color/** -> the `world` table (see module docstring):
|
||||
real per-tile GBC BG-palette groups, per-town roof overrides, and
|
||||
overworld sprite OBJ palettes, for true overworld parity under
|
||||
COLORS=RED++ (LoadTilesetPalette / LoadTownPalette / ColorOverworldSprite)."""
|
||||
base = os.path.join(pokered_gbc, "color")
|
||||
|
||||
tile_groups, ts_order = _parse_map_palette_assignments(
|
||||
os.path.join(base, "data/map_palette_assignments.asm"))
|
||||
pal_sets, ts_order2 = _parse_map_palette_sets(
|
||||
os.path.join(base, "data/map_palette_sets.asm"))
|
||||
if set(ts_order) != set(ts_order2):
|
||||
util.die("map_palette_assignments.asm / map_palette_sets.asm "
|
||||
"tileset lists disagree")
|
||||
if len(ts_order) < 20:
|
||||
util.die(f"map_palette_assignments.asm: parsed only {len(ts_order)} "
|
||||
f"tilesets (want ~24)")
|
||||
|
||||
map_palettes, _ = _parse_rgb_table(
|
||||
os.path.join(base, "data/map_palettes.asm"),
|
||||
re.compile(r"^;\s*0x[0-9a-fA-F]+:\s*(\w+)\s*$"), 12)
|
||||
|
||||
group_colors = {}
|
||||
for ts in ts_order:
|
||||
colors = []
|
||||
for slot, const in enumerate(pal_sets[ts]):
|
||||
if const not in map_palettes:
|
||||
util.die(f"map_palette_sets.asm: {ts} slot {slot}: unknown "
|
||||
f"palette constant {const}")
|
||||
colors.append(map_palettes[const])
|
||||
group_colors[ts] = colors
|
||||
|
||||
# Only these two tilesets are ever town-roof-swapped
|
||||
# (LoadTilesetPalette: `cp 0` / `cp PLATEAU` before calling
|
||||
# LoadTownPalette) -- the ROOF slot for every other tileset is static.
|
||||
roof_group = {ts: _GROUP_INDEX["ROOF"]
|
||||
for ts in ("OVERWORLD", "PLATEAU") if ts in group_colors}
|
||||
|
||||
roof_path = os.path.join(base, "data/roofpalettes.asm")
|
||||
roof_colors, _ = _parse_rgb_table(
|
||||
roof_path, re.compile(r"^(?!RoofPalettes)(\w+):\s*$"), 6)
|
||||
roof_order = _parse_roof_order(roof_path)
|
||||
roof_by_index = {}
|
||||
for i, name in enumerate(roof_order):
|
||||
if name not in roof_colors:
|
||||
util.die(f"{roof_path}: RoofPalettes references unknown label "
|
||||
f"{name}")
|
||||
roof_by_index[i] = roof_colors[name]
|
||||
|
||||
sprite_path = os.path.join(base, "data/spritepalettes.asm")
|
||||
raw_sprite_pals, _ = _parse_rgb_table(
|
||||
sprite_path, re.compile(r"^;\s*(\d+)\s*$"), 12)
|
||||
sprite_palettes = {int(k): v for k, v in raw_sprite_pals.items()}
|
||||
if len(sprite_palettes) != 8:
|
||||
util.die(f"{sprite_path}: parsed {len(sprite_palettes)} sprite "
|
||||
f"palettes (want 8)")
|
||||
|
||||
sprite_assignment = _parse_sprite_assignments(
|
||||
os.path.join(base, "sprites.asm"))
|
||||
if len(sprite_assignment) != 72:
|
||||
util.die(f"sprites.asm: parsed {len(sprite_assignment)} sprite "
|
||||
f"palette assignments (want 72)")
|
||||
|
||||
tile_groups_out = {ts: {i: v for i, v in enumerate(vals)}
|
||||
for ts, vals in tile_groups.items()}
|
||||
|
||||
return {
|
||||
"tileGroups": tile_groups_out,
|
||||
"groupColors": group_colors,
|
||||
"roofGroup": roof_group,
|
||||
"roofByMapIndex": roof_by_index,
|
||||
"spritePalettes": sprite_palettes,
|
||||
"spriteAssignment": sprite_assignment,
|
||||
}
|
||||
|
||||
|
||||
def extract_gbc(pokered_gbc, out_path):
|
||||
"""Build the Red++ / pokered-gbc SuperPalette pack used by COLORS=RED++."""
|
||||
super_path = os.path.join(pokered_gbc, "data/super_palettes.asm")
|
||||
mon_path = os.path.join(pokered_gbc, "data/mon_palettes.asm")
|
||||
if not os.path.isfile(super_path):
|
||||
util.die(f"missing {super_path}")
|
||||
if not os.path.isfile(mon_path):
|
||||
util.die(f"missing {mon_path}")
|
||||
|
||||
palettes, order = _parse_gbc_super_palettes(super_path)
|
||||
# pret spelling vs Red++ British spelling
|
||||
if "GREYMON" in palettes and "GRAYMON" not in palettes:
|
||||
palettes["GRAYMON"] = palettes["GREYMON"]
|
||||
order.append("GRAYMON")
|
||||
# Overworld still asks for ROUTE / PALLET; Red++ dropped those SGB
|
||||
# entries (color/data handles overworld). Alias to closest town pals.
|
||||
if "ROUTE" not in palettes and "VIRIDIAN" in palettes:
|
||||
palettes["ROUTE"] = palettes["VIRIDIAN"]
|
||||
order.append("ROUTE")
|
||||
if "PALLET" not in palettes and "PEWTER" in palettes:
|
||||
palettes["PALLET"] = palettes["PEWTER"]
|
||||
order.append("PALLET")
|
||||
|
||||
mon_pals = _parse_gbc_monster_palettes(mon_path)
|
||||
if len(mon_pals) != 151:
|
||||
util.die(f"mon_palettes.asm: parsed {len(mon_pals)} species (want 151)")
|
||||
for species, pal in mon_pals.items():
|
||||
if pal not in palettes:
|
||||
util.die(f"mon_palettes.asm: unknown palette PAL_{pal} for {species}")
|
||||
for name in ("MEWMON", "GREENBAR", "YELLOWBAR", "REDBAR", "GRAYMON"):
|
||||
if name not in palettes:
|
||||
util.die(f"super_palettes.asm: PAL_{name} missing")
|
||||
|
||||
world = extract_gbc_world(pokered_gbc)
|
||||
|
||||
# write_lua stamps "Generated by tools/build_data.py"; keep that header
|
||||
# but point the source field at the gbc tree.
|
||||
util.write_lua(
|
||||
out_path,
|
||||
{"source": "pokered-gbc data/super_palettes.asm + data/mon_palettes.asm"
|
||||
" + color/**",
|
||||
"palettes": palettes,
|
||||
"order": order,
|
||||
"pokemon": mon_pals,
|
||||
"world": world},
|
||||
header="Red++ / pokered-gbc SuperPalettes (COLORS=RED++): 4 8-bit RGB\n"
|
||||
"colors per palette (color 0 first), including per-species\n"
|
||||
"pals and the GEN_2 MonsterPalettes assignment, plus `world`\n"
|
||||
"(true overworld tile/roof/sprite GBC coloring -- see this\n"
|
||||
"file's module docstring for its shape).")
|
||||
return palettes, mon_pals
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
|
||||
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
p_van = sub.add_parser("vanilla", help="extract pret/pokered SGB palettes")
|
||||
p_van.add_argument("--pokered", required=True)
|
||||
p_van.add_argument("--out-dir", default="data/generated")
|
||||
|
||||
p_gbc = sub.add_parser("gbc", help="extract pokered-gbc SuperPalettes")
|
||||
p_gbc.add_argument("--pokered-gbc", required=True)
|
||||
p_gbc.add_argument("--out", default="data/palettes_gbc.lua")
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
if args.cmd == "vanilla":
|
||||
extract(args.pokered, args.out_dir)
|
||||
else:
|
||||
extract_gbc(args.pokered_gbc, args.out)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# python3 -m extract.palettes (from tools/) or python3 tools/extract/palettes.py
|
||||
if __package__ is None:
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
__package__ = "extract"
|
||||
from extract import util as util # noqa: F811
|
||||
raise SystemExit(main())
|
||||
|
||||
@@ -4253,6 +4253,48 @@
|
||||
]
|
||||
},
|
||||
"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": [
|
||||
24,
|
||||
36
|
||||
@@ -5656,6 +5698,13 @@
|
||||
"y": 3
|
||||
}
|
||||
],
|
||||
"REDS_HOUSE_2F": [
|
||||
{
|
||||
"facing": "up",
|
||||
"x": 0,
|
||||
"y": 1
|
||||
}
|
||||
],
|
||||
"ROCK_TUNNEL_POKECENTER": [
|
||||
{
|
||||
"facing": "up",
|
||||
@@ -6373,6 +6422,11 @@
|
||||
"path": "assets/generated/fx/battle_transition.png",
|
||||
"width": 8
|
||||
},
|
||||
"cutTree": {
|
||||
"height": 16,
|
||||
"path": "assets/generated/fx/cut_tree.png",
|
||||
"width": 16
|
||||
},
|
||||
"fishingRod": {
|
||||
"height": 24,
|
||||
"path": "assets/generated/fx/fishing_rod.png",
|
||||
@@ -42219,6 +42273,12 @@
|
||||
}
|
||||
},
|
||||
"MtMoonB2F": {
|
||||
"1": {
|
||||
"after": "_MtMoonB2FSuperNerdTheresAPokemonLabText",
|
||||
"battle": "_MtMoonB2FSuperNerdTheyreBothMineText",
|
||||
"event": "EVENT_BEAT_MT_MOON_3_SUPER_NERD",
|
||||
"won": "_MtMoonB2fSuperNerdEachTakeOneText"
|
||||
},
|
||||
"2": {
|
||||
"after": "_MtMoonB2FRocket1AfterBattleText",
|
||||
"battle": "_MtMoonB2FRocket1BattleText",
|
||||
@@ -44683,6 +44743,15 @@
|
||||
"TRI_ATTACK",
|
||||
"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": [
|
||||
{
|
||||
"imageBase": "youngster",
|
||||
|
||||
Reference in New Issue
Block a user