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",