From 15029d811f795abd1e9a0ff28557922d58b61acb Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Wed, 22 Jul 2026 08:18:13 -0400 Subject: [PATCH 1/4] bug squashing # Closed issues CLOSES #17: Incorrect Character Visuals (Only with GBC filter) CLOSES #23: Could you allow the player to change the order of the moves CLOSES #24: Evolution music not playing during evolution CLOSES #26: Standing on door glitch CLOSES #27: Battle Intro text automatically continues CLOSES #29: Visual bug when zoomed out CLOSES #32: Team Rocket recruiter doesn't battle with you unless you speak with him first CLOSES #33: Developer/Debug Console CLOSES #35: Professor Oak's introduction Inaccuracies CLOSES #36: Missing Pokemon Dex entries when picking starter + Rival Pathing issues CLOSES #39: Guy who stops player from skipping brock doesn't bring you to brock's gym + doesn't leave once you've beaten brock CLOSES #40: Bill cutscene is broken CLOSES #41: Ticket guy failing to be a Ticket guy CLOSES #42: S.S. anne odd behavior + Missing sailing away animation CLOSES #43: Dig Attack animation appears to be glitched CLOSES #44: Pokeball flashing doesn't appear to be accurate CLOSES #45: Inaccurate Cut Animation CLOSES #46: Dugtrio i caught in diglett cave has two of the same move CLOSES #47: Rival ignores player in Lavender Tower CLOSES #48: Healing pad in lavender tower does not function CLOSES #49: Incorrect dialogue with parched security guard CLOSES #50: (Game Breaking!) Rocket grunt guarding poster refuses to move CLOSES #51: Badges showing up as items I can deposit in PC CLOSES #52: Visual bug on Celadon Department Store roof (Red Filter) CLOSES #54: Visual issue on route 15 + Fuchsia City CLOSES #56: Running animation missing CLOSES #57: Safari Zone does not display steps while you are inside of it CLOSES #58: (Game Breaking!) Softlock at cycling road gate CLOSES #59: Bike visual issues CLOSES #60: Cycling road not forcing you to get on your bike CLOSES #61: No keycard doors in Silph Co. CLOSES #63: Missing teleporter animation CLOSES #64: Inaccurate spinning CLOSES #65: Reimplement unused Silph Co. Chief and Professor Oak trainer battles --- conf.lua | 3 + data/scripts/celadon_chief_house.lua | 26 +++ data/scripts/flavor/pewter_city.lua | 9 +- data/scripts/oaks_lab.lua | 37 ++-- data/scripts/pallet_town.lua | 36 ++- data/scripts/story.lua | 166 +++++++++++--- data/scripts/story2.lua | 18 +- data/scripts/story3.lua | 73 ++++++- data/scripts/story4.lua | 21 ++ data/scripts/story5.lua | 40 +++- docs/known-differences.md | 35 ++- docs/modding.md | 30 +++ main.lua | 2 + src/battle/BattleState.lua | 60 ++++- src/battle/MoveEffects.lua | 4 +- src/core/Game.lua | 2 +- src/core/Music.lua | 1 + src/import/RomExtractor.lua | 44 +++- src/import/RomImporter.lua | 16 +- src/inventory/Bag.lua | 4 + src/mods/Loader.lua | 4 +- src/pokemon/Evolution.lua | 7 +- src/pokemon/Pokemon.lua | 10 +- src/render/PaletteFX.lua | 77 +++++++ src/render/Renderer.lua | 33 ++- src/render/SpriteRenderer.lua | 28 ++- src/render/TileRenderer.lua | 66 +++++- src/script/Commands.lua | 20 +- src/ui/EvolutionState.lua | 3 + src/ui/OakSpeech.lua | 62 +++++- src/ui/PlayerPC.lua | 8 +- src/ui/StartMenu.lua | 19 ++ src/world/Map.lua | 17 ++ src/world/OverworldController.lua | 313 +++++++++++++++++++++++++-- src/world/Player.lua | 35 ++- tools/extract/field.py | 51 +++++ tools/extract/gfx.py | 16 ++ tools/rom_manifest.json | 56 +++++ 38 files changed, 1318 insertions(+), 134 deletions(-) create mode 100644 data/scripts/celadon_chief_house.lua diff --git a/conf.lua b/conf.lua index 601969e5..38924550 100644 --- a/conf.lua +++ b/conf.lua @@ -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" diff --git a/data/scripts/celadon_chief_house.lua b/data/scripts/celadon_chief_house.lua new file mode 100644 index 00000000..d13dbc71 --- /dev/null +++ b/data/scripts/celadon_chief_house.lua @@ -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 + }, + }, +} diff --git a/data/scripts/flavor/pewter_city.lua b/data/scripts/flavor/pewter_city.lua index 1d08023c..e01f0e87 100644 --- a/data/scripts/flavor/pewter_city.lua +++ b/data/scripts/flavor/pewter_city.lua @@ -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 = {} diff --git a/data/scripts/oaks_lab.lua b/data/scripts/oaks_lab.lua index fb8b0e81..e7fc8dcb 100644 --- a/data/scripts/oaks_lab.lua +++ b/data/scripts/oaks_lab.lua @@ -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 diff --git a/data/scripts/pallet_town.lua b/data/scripts/pallet_town.lua index e4ac3352..f28f86a1 100644 --- a/data/scripts/pallet_town.lua +++ b/data/scripts/pallet_town.lua @@ -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 }, }, } diff --git a/data/scripts/story.lua b/data/scripts/story.lua index 9fde7de6..1ba8c796 100644 --- a/data/scripts/story.lua +++ b/data/scripts/story.lua @@ -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 }, }, } @@ -665,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, } -- ------------------------------------------------------------------- diff --git a/data/scripts/story2.lua b/data/scripts/story2.lua index 963b8d9a..25dc89ef 100644 --- a/data/scripts/story2.lua +++ b/data/scripts/story2.lua @@ -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)) diff --git a/data/scripts/story3.lua b/data/scripts/story3.lua index 3bd12027..fa45def0 100644 --- a/data/scripts/story3.lua +++ b/data/scripts/story3.lua @@ -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, } diff --git a/data/scripts/story4.lua b/data/scripts/story4.lua index c5342a08..3be2bce4 100644 --- a/data/scripts/story4.lua +++ b/data/scripts/story4.lua @@ -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, } -- ------------------------------------------------------------------- @@ -723,4 +742,6 @@ M.LANCES_ROOM = { end, } +M.CELADON_CHIEF_HOUSE = require("data.scripts.celadon_chief_house") + return M diff --git a/data/scripts/story5.lua b/data/scripts/story5.lua index 7027e765..bd2c55f3 100644 --- a/data/scripts/story5.lua +++ b/data/scripts/story5.lua @@ -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 diff --git a/docs/known-differences.md b/docs/known-differences.md index 92a2fff8..f0f1b78e 100644 --- a/docs/known-differences.md +++ b/docs/known-differences.md @@ -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. diff --git a/docs/modding.md b/docs/modding.md index b7f02581..222ddead 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -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. diff --git a/main.lua b/main.lua index 739003ce..ef8d0241 100644 --- a/main.lua +++ b/main.lua @@ -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 diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 44363626..90225f10 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -635,7 +635,6 @@ function BattleState:startMessage(item) self.total = self.total + #codes end self.charIndex = 0 - self.holdTimer = nil end function BattleState:updateQueue() @@ -786,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 @@ -968,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() @@ -1081,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 @@ -1098,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!") @@ -2265,6 +2296,7 @@ function BattleState:performMove(user, target, moveInst, isCalled) -- falling back to the id tables (Fly AND Dig go semi-invulnerable: -- ChargeEffect sets INVULNERABLE for both) if record and record.charge and not releasing then + self:cancelMoveAnim() user.charging = moveInst user.chargeReady = true local invulnerable = move.semiInvulnerable @@ -2274,6 +2306,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))) @@ -2749,6 +2790,7 @@ function BattleState:safariAction(choice) local playerName = self.game.save.player.name if choice == "run" then + require("src.core.Sound").play(self.data, "Run") self:say("Got away safely!") self.result = "run" self.afterQueue = "finish" @@ -2830,6 +2872,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 @@ -3826,7 +3874,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 diff --git a/src/battle/MoveEffects.lua b/src/battle/MoveEffects.lua index 183966f0..cc29ef58 100644 --- a/src/battle/MoveEffects.lua +++ b/src/battle/MoveEffects.lua @@ -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 diff --git a/src/core/Game.lua b/src/core/Game.lua index 83508a4f..caffd037 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -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 diff --git a/src/core/Music.lua b/src/core/Music.lua index 8a55269f..f394eec2 100644 --- a/src/core/Music.lua +++ b/src/core/Music.lua @@ -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 diff --git a/src/import/RomExtractor.lua b/src/import/RomExtractor.lua index 322bacd8..1d63613e 100644 --- a/src/import/RomExtractor.lua +++ b/src/import/RomExtractor.lua @@ -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, diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 8a5dbe68..722190af 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -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-v6:" .. 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 " .. @@ -136,13 +136,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, @@ -304,7 +311,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) diff --git a/src/inventory/Bag.lua b/src/inventory/Bag.lua index e04a596c..bd5e8f6a 100644 --- a/src/inventory/Bag.lua +++ b/src/inventory/Bag.lua @@ -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 diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index b6f9a7ea..7ff91285 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -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 = {}, diff --git a/src/pokemon/Evolution.lua b/src/pokemon/Evolution.lua index 30de704b..f7685c7d 100644 --- a/src/pokemon/Evolution.lua +++ b/src/pokemon/Evolution.lua @@ -9,6 +9,7 @@ -- "trade" | "manual" | , 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 diff --git a/src/pokemon/Pokemon.lua b/src/pokemon/Pokemon.lua index ba253293..23017407 100644 --- a/src/pokemon/Pokemon.lua +++ b/src/pokemon/Pokemon.lua @@ -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 diff --git a/src/render/PaletteFX.lua b/src/render/PaletteFX.lua index 0d65b52c..64d4d560 100644 --- a/src/render/PaletteFX.lua +++ b/src/render/PaletteFX.lua @@ -141,6 +141,54 @@ function PaletteFX.usesGbcPack(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) @@ -223,6 +271,29 @@ local TILE_GROUP_EXCEPTIONS = { CELADON_MART_1F = { tiles = { [0x07] = true, [0x08] = true, [0x17] = true, [0x18] = true }, group = 4 }, } + +-- keyed by tileset id (applies on every map that uses it), consulted after +-- the per-map table above +local TILESET_GROUP_EXCEPTIONS = { + -- tile $22 (the hollow-square grave marker) -> GRAY: the extracted pack + -- files it under the bright blue family, which makes a purely + -- decorative floor marker read as an interactive pad + CEMETERY = { tiles = { [0x22] = true }, group = 0 }, +} + +-- pokered-gbc's lobby.bst repoints the Celadon 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 } @@ -244,6 +315,8 @@ function PaletteFX.worldGroupAt(tileset, mapId, tileId) if not groups then return nil end local exc = TILE_GROUP_EXCEPTIONS[mapId] if exc and exc.tiles[tileId] then return exc.group end + exc = TILESET_GROUP_EXCEPTIONS[tileset] + if exc and exc.tiles[tileId] then return exc.group end return groups[tileId] or 7 -- TEXT: tile ids past the tileset's 96 (menus) end @@ -293,6 +366,10 @@ function PaletteFX.spriteObp(spriteDef, seed) local src = spriteDef and spriteDef.source if not (w and src) then return nil end local idx = tonumber(src:match("%[(%d+)%]")) + -- RedBikeSprite loads outside SpriteSheetPointerTable + -- (LoadBikePlayerSpriteGraphics), so its source carries no bracketed + -- index; it wears the player's own palette, same as SPRITE_RED + if not idx and src:find("RedBikeSprite", 1, true) then idx = 0 end local group = idx and w.spriteAssignment[idx] if group == nil then return nil end if group == "random" then diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index 46f7cc67..4120efdc 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -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' diff --git a/src/render/SpriteRenderer.lua b/src/render/SpriteRenderer.lua index f4c73923..9a3443c1 100644 --- a/src/render/SpriteRenderer.lua +++ b/src/render/SpriteRenderer.lua @@ -85,10 +85,21 @@ end -- facing: down/up/left/right; walkPhase: 0 stand, 1 walk; flip: alternate -- steps mirror the walk frame for up/down (GB uses OAM flip for this). +local function blitFrame(image, quad, x, y, flip, redraw) + if flip then + love.graphics.draw(image, quad, x + 16, y, 0, -1, 1) + if redraw then PaletteFX.markSpriteRedraw(image, quad, x + 16, y, -1) end + else + love.graphics.draw(image, quad, x, y) + if redraw then PaletteFX.markSpriteRedraw(image, quad, x, y, 1) end + end +end + function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip) local x = math.floor(px - camX) local y = math.floor(py - camY) - 4 local image = self.image + local redraw = false -- full-color art claims its 16x16 cell out of the shade-remap pass if self.def.trueColor then PaletteFX.markTrueColor(x, y, 16, 16) @@ -102,12 +113,21 @@ function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip) if colors then image = getObpImage(self.def.image, colors, group) end + elseif PaletteFX.usesSpriteObp() and PaletteFX.spriteRedrawPassActive() then + -- 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(image, self.frames[0], x, y) + blitFrame(image, self.frames[0], x, y, false, redraw) return end local frame = (self.def.walker and walkPhase == 1) @@ -119,11 +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(image, quad, x + 16, y, 0, -1, 1) - else - love.graphics.draw(image, quad, x, y) - end + blitFrame(image, quad, x, y, flip, redraw) end return SpriteRenderer diff --git a/src/render/TileRenderer.lua b/src/render/TileRenderer.lua index b5197cbf..d2a923a0 100644 --- a/src/render/TileRenderer.lua +++ b/src/render/TileRenderer.lua @@ -347,9 +347,10 @@ local function getGbcAtlas(imagePath, tilesetId, mapId, perRow, data) if groupColors then local src = Assets.imageData(imagePath) local iw, ih = src:getDimensions() + local total = (iw / 8) * (ih / 8) local out = love.image.newImageData(iw, ih) local tileColors = {} - for t = 0, (iw / 8) * (ih / 8) - 1 do + for t = 0, total - 1 do local colors = tileColors[t] if colors == nil then local group = PaletteFX.worldGroupAt(tilesetId, mapId, t) @@ -366,6 +367,25 @@ local function getGbcAtlas(imagePath, tilesetId, mapId, perRow, data) end end end + -- duplicate-tile aliases: bake a copy of a shared tile graphic into + -- a spare slot under a different palette group, so block cells that + -- draw the alias can color apart from cells sharing the raw tile + for _, al in ipairs(PaletteFX.TILE_ALIASES and PaletteFX.TILE_ALIASES[mapId] or {}) do + if al.alias < total then + local colors = groupColors[al.group + 1] + local sxo = (al.tile % perRow) * 8 + local syo = math.floor(al.tile / perRow) * 8 + local dxo = (al.alias % perRow) * 8 + local dyo = math.floor(al.alias / perRow) * 8 + for py = 0, 7 do + for px = 0, 7 do + local r, g, b, a = src:getPixel(sxo + px, syo + py) + r, g, b, a = recolorSample(r, g, b, a, colors) + out:setPixel(dxo + px, dyo + py, r, g, b, a) + end + end + end + end img = love.graphics.newImage(out) end end @@ -429,14 +449,38 @@ function TileRenderer.new(map, data) 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) @@ -556,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) diff --git a/src/script/Commands.lua b/src/script/Commands.lua index e12cadc1..8731ff09 100644 --- a/src/script/Commands.lua +++ b/src/script/Commands.lua @@ -5,6 +5,7 @@ -- Conditionals: check_flag stores its result in ctx.lastCheck; -- jump_if_true/jump_if_false jump to an absolute row index. +local Collision = require("src.world.Collision") local Flags = require("src.script.Flags") local Logger = require("src.core.Logger") local Screens = require("src.ui.Screens") @@ -269,7 +270,10 @@ end local DIRS4 = { { 0, -1, "up" }, { 0, 1, "down" }, { -1, 0, "left" }, { 1, 0, "right" } } -local function bfsPath(map, sx, sy, tx, ty) +-- entities/mover let the walk route around other entities' cells (the +-- player especially), so a scripted NPC never clips through them; the +-- target cell is exempt so an NPC can still walk up onto its goal +local function bfsPath(map, sx, sy, tx, ty, entities, mover) local key = function(x, y) return y * 1000 + x end local prev = { [key(sx, sy)] = false } local queue = { { sx, sy } } @@ -289,8 +293,11 @@ local function bfsPath(map, sx, sy, tx, ty) for _, d in ipairs(DIRS4) do local nx, ny = cx + d[1], cy + d[2] local nk = key(nx, ny) + local isTarget = nx == tx and ny == ty if prev[nk] == nil and map:inBounds(nx, ny) - and (map:isWalkableCell(nx, ny) or (nx == tx and ny == ty)) then + and (map:isWalkableCell(nx, ny) or isTarget) + and (isTarget or not entities + or not Collision.occupied(entities, nx, ny, mover)) then prev[nk] = { cx, cy, d[3] } table.insert(queue, { nx, ny }) end @@ -303,7 +310,7 @@ function Commands.move_npc_to(ctx, objIndex, tx, ty) local ow = ctx.overworld local npc = ow:npcByIndex(objIndex) if not npc then return end - local path = bfsPath(ow.map, npc.cellX, npc.cellY, tx, ty) + local path = bfsPath(ow.map, npc.cellX, npc.cellY, tx, ty, ow.entities, npc) if not path then Logger.warn("move_npc_to: no path to (%d,%d)", tx, ty) return @@ -823,7 +830,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/.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 diff --git a/src/ui/EvolutionState.lua b/src/ui/EvolutionState.lua index fb434023..6d3e21a1 100644 --- a/src/ui/EvolutionState.lua +++ b/src/ui/EvolutionState.lua @@ -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)) diff --git a/src/ui/OakSpeech.lua b/src/ui/OakSpeech.lua index b9d04074..b71df3a0 100644 --- a/src/ui/OakSpeech.lua +++ b/src/ui/OakSpeech.lua @@ -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 diff --git a/src/ui/PlayerPC.lua b/src/ui/PlayerPC.lua index 9467d5fb..b765b91f 100644 --- a/src/ui/PlayerPC.lua +++ b/src/ui/PlayerPC.lua @@ -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 diff --git a/src/ui/StartMenu.lua b/src/ui/StartMenu.lua index 36210813..35eb5640 100644 --- a/src/ui/StartMenu.lua +++ b/src/ui/StartMenu.lua @@ -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 diff --git a/src/world/Map.lua b/src/world/Map.lua index 714653bd..bcf5e105 100644 --- a/src/world/Map.lua +++ b/src/world/Map.lua @@ -24,6 +24,15 @@ 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 @@ -199,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) diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 199dbde8..90bc747d 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -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): @@ -607,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 @@ -615,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") @@ -704,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 @@ -1272,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 @@ -1354,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!"))) @@ -1456,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 @@ -1568,13 +1733,33 @@ 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 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 @@ -2805,6 +2990,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 @@ -2874,6 +3078,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) @@ -3146,7 +3357,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 @@ -3162,6 +3373,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) @@ -3176,6 +3400,7 @@ function OverworldState:drawWorld() ox + b[1], oy + b[2]) end end + if shader then love.graphics.setShader() end end end @@ -3206,6 +3431,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 @@ -3297,6 +3553,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 @@ -3308,14 +3569,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() @@ -3329,6 +3599,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() @@ -3389,7 +3660,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) diff --git a/src/world/Player.lua b/src/world/Player.lua index 483d2ca2..7a1b4454 100644 --- a/src/world/Player.lua +++ b/src/world/Player.lua @@ -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 diff --git a/tools/extract/field.py b/tools/extract/field.py index c3223695..b0b2260a 100644 --- a/tools/extract/field.py +++ b/tools/extract/field.py @@ -303,12 +303,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, } diff --git a/tools/extract/gfx.py b/tools/extract/gfx.py index 8b307fd9..e36a9f9a 100644 --- a/tools/extract/gfx.py +++ b/tools/extract/gfx.py @@ -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 diff --git a/tools/rom_manifest.json b/tools/rom_manifest.json index b64064dc..6a99f564 100644 --- a/tools/rom_manifest.json +++ b/tools/rom_manifest.json @@ -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 @@ -6373,6 +6415,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", @@ -44689,6 +44736,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", From 5041125751e5f48f2156d2f43a0abce4aa892c08 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Wed, 22 Jul 2026 09:45:23 -0400 Subject: [PATCH 2/4] more bugs squashed + portable mode CLOSES #28: PC in the beginning of the game isn't interactable (The one in your house) CLOSES #34: Bug when calculating exp after one or many fainted team members. CLOSES #37: No Grass Cutting CLOSES #38: Blind TMs CLOSES #53: Portable Mode CLOSES #55: changing palletes with hot key CLOSES #62: Poison status damage issue. --- README.md | 21 +++ src/battle/BattleState.lua | 23 +++- src/core/Game.lua | 20 ++- src/core/SaveData.lua | 111 +++++++++++++++- src/import/RomImporter.lua | 123 ++++++++++++++++- src/script/Commands.lua | 7 +- src/ui/BagMenu.lua | 71 ++++++---- src/world/OverworldController.lua | 24 +++- tests/parity_oaks_lab_rival_loss.lua | 67 ---------- tests/parity_viridian.lua | 190 --------------------------- tests/run_tests.lua | 58 +++++++- tools/extract/field.py | 5 +- tools/rom_manifest.json | 7 + 13 files changed, 412 insertions(+), 315 deletions(-) delete mode 100644 tests/parity_oaks_lab_rival_loss.lua delete mode 100644 tests/parity_viridian.lua diff --git a/README.md b/README.md index 4cac9638..32782c25 100644 --- a/README.md +++ b/README.md @@ -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, diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 90225f10..e4e3500f 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -1532,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 @@ -2442,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 @@ -2478,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 diff --git a/src/core/Game.lua b/src/core/Game.lua index caffd037..940dd720 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -283,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 diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 56b540be..7d4ec4f9 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -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() @@ -94,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 @@ -123,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 @@ -311,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) @@ -342,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 @@ -371,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 diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 722190af..a2c3a132 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -2,7 +2,7 @@ local RomImporter = {} RomImporter.__index = RomImporter local ROM_SHA1 = "ea9bcae617fdf159b045185467ae58b2e4a48b9a" -local CACHE_MARKER = "rom-cache-v6:" .. 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 @@ -211,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..." diff --git a/src/script/Commands.lua b/src/script/Commands.lua index 8731ff09..63e11515 100644 --- a/src/script/Commands.lua +++ b/src/script/Commands.lua @@ -397,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 @@ -407,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] diff --git a/src/ui/BagMenu.lua b/src/ui/BagMenu.lua index fc637eed..cdbc0990 100644 --- a/src/ui/BagMenu.lua +++ b/src/ui/BagMenu.lua @@ -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 diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 90bc747d..11169be8 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -1708,8 +1708,10 @@ function OverworldState:tryCut(fx, fy) -- 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)) then + or (ts == "GYM" and tile == 0x50) + or isGrass) then return false end local bx, by = math.floor(fx / 2), math.floor(fy / 2) @@ -1718,7 +1720,7 @@ function OverworldState:tryCut(fx, fy) 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 @@ -1736,7 +1738,11 @@ function OverworldState:tryCut(fx, fy) local finish = function() require("src.core.Sound").play(Game.data, "Cut") end - if ts == "OVERWORLD" then + 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) @@ -1822,8 +1828,10 @@ function OverworldState:useCutFieldMove() -- "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)) then + or (ts == "GYM" and tile == 0x50) + or isGrass) then return "nothing" end local bx, by = math.floor(fx / 2), math.floor(fy / 2) @@ -1832,7 +1840,7 @@ function OverworldState:useCutFieldMove() 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 @@ -2004,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 diff --git a/tests/parity_oaks_lab_rival_loss.lua b/tests/parity_oaks_lab_rival_loss.lua deleted file mode 100644 index d05c69a9..00000000 --- a/tests/parity_oaks_lab_rival_loss.lua +++ /dev/null @@ -1,67 +0,0 @@ --- Parity: losing the Oak's Lab starter rival must not black out. --- pret HandlePlayerBlackOut special-cases OPP_RIVAL1 on OAKS_LAB: --- Rival1WinText only, no PlayerBlackedOutText, no warp / half-money. --- OaksLabRivalEndBattleScript then HealParty and continues either way. --- Self-contained; run via `luajit tests/parity_oaks_lab_rival_loss.lua`. -package.path = "./?.lua;./?/init.lua;" .. package.path -if not _G.love then _G.love = require("tests.love_stub") end -local BattleState = require("src.battle.BattleState") -local S = require("tests.harness").suite("parity oaks lab rival loss") -local check, eq = S.check, S.eq - -local function saidBlackout(b) - for _, m in ipairs(b.said) do - if tostring(m):find("blacked") then return true end - end - return false -end - -local function saidRivalTaunt(b) - for _, m in ipairs(b.said) do - if tostring(m):find("great or what") then return true end - end - return false -end - -local function rivalBattle(mapId) - return { - kind = "trainer", - oppClass = "OPP_RIVAL1", - result = nil, - afterQueue = nil, - said = {}, - data = { - text = { _Rival1WinText = "{RIVAL}: Yeah! Am\nI great or what?" }, - }, - game = { - save = { - party = { { species = "SQUIRTLE", hp = 0, stats = { hp = 20 } } }, - player = { name = "RED", rival = "BLUE", map = mapId }, - }, - overworld = { map = { id = mapId } }, - }, - sayNext = function(self, m) self.said[#self.said + 1] = m end, - say = function(self, m) self.said[#self.said + 1] = m end, - } -end - -do - local b = rivalBattle("OAKS_LAB") - BattleState.playerMonFainted(b) - eq(b.result, "lose", "lab rival wipe is still a loss") - check(saidRivalTaunt(b), "lab rival wipe shows Rival1WinText") - check(not saidBlackout(b), "lab rival wipe does not say blacked out") - check(BattleState.isOaksLabStarterRival(b), "oaks-lab starter-rival detector") -end - -do - local b = rivalBattle("ROUTE_22") - BattleState.playerMonFainted(b) - eq(b.result, "lose", "route-22 rival wipe is a loss") - check(saidRivalTaunt(b), "route-22 rival still shows Rival1WinText") - check(saidBlackout(b), "route-22 rival still blacks out") - check(not BattleState.isOaksLabStarterRival(b), - "route-22 is not the oaks-lab special case") -end - -S.finish() diff --git a/tests/parity_viridian.lua b/tests/parity_viridian.lua deleted file mode 100644 index 370118b5..00000000 --- a/tests/parity_viridian.lua +++ /dev/null @@ -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() diff --git a/tests/run_tests.lua b/tests/run_tests.lua index 85b08d76..81b6e3ce 100644 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -931,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 @@ -2076,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") diff --git a/tools/extract/field.py b/tools/extract/field.py index b0b2260a..c3281a51 100644 --- a/tools/extract/field.py +++ b/tools/extract/field.py @@ -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": diff --git a/tools/rom_manifest.json b/tools/rom_manifest.json index 6a99f564..e82c41a6 100644 --- a/tools/rom_manifest.json +++ b/tools/rom_manifest.json @@ -5698,6 +5698,13 @@ "y": 3 } ], + "REDS_HOUSE_2F": [ + { + "facing": "up", + "x": 0, + "y": 1 + } + ], "ROCK_TUNNEL_POKECENTER": [ { "facing": "up", From 747d9acad16ea961368a8d2dcc422da90b743d96 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Wed, 22 Jul 2026 10:08:20 -0400 Subject: [PATCH 3/4] linux and android builds --- .github/workflows/release.yml | 22 ++++++++++--- mobile/ANDROID.md | 34 +++++++++----------- scripts/build.sh | 59 ++++++++++++++++++++++++++--------- scripts/build_android.sh | 58 +++++++++++++++++----------------- 4 files changed, 105 insertions(+), 68 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c6489640..d2fcad57 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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" diff --git a/mobile/ANDROID.md b/mobile/ANDROID.md index 4a34d573..559c1ecd 100644 --- a/mobile/ANDROID.md +++ b/mobile/ANDROID.md @@ -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--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). diff --git a/scripts/build.sh b/scripts/build.sh index 3b34bb31..6e915928 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -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/-/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 diff --git a/scripts/build_android.sh b/scripts/build_android.sh index fd13c088..b9ee09d6 100755 --- a/scripts/build_android.sh +++ b/scripts/build_android.sh @@ -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/" \; From 4311955c438fda3c53d28fda320c37aff0f2cc43 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Wed, 22 Jul 2026 10:38:37 -0400 Subject: [PATCH 4/4] input sticky bug --- main.lua | 22 ++++++++++++++++++++++ src/core/Game.lua | 22 ++++++++++++++++++++++ src/core/Input.lua | 9 +++++++++ src/core/TouchInput.lua | 15 +++++++++++++++ 4 files changed, 68 insertions(+) diff --git a/main.lua b/main.lua index ef8d0241..a2e7a300 100644 --- a/main.lua +++ b/main.lua @@ -173,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 diff --git a/src/core/Game.lua b/src/core/Game.lua index 940dd720..91bd2b98 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -349,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 diff --git a/src/core/Input.lua b/src/core/Input.lua index 5bd19ee4..4088a98a 100644 --- a/src/core/Input.lua +++ b/src/core/Input.lua @@ -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 = {} diff --git a/src/core/TouchInput.lua b/src/core/TouchInput.lua index 9e4a350f..984c79b6 100644 --- a/src/core/TouchInput.lua +++ b/src/core/TouchInput.lua @@ -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