bug squashing

# Closed issues

CLOSES #17: Incorrect Character Visuals (Only with GBC filter)
CLOSES #23: Could you allow the player to change the order of the moves
CLOSES #24: Evolution music not playing during evolution
CLOSES #26: Standing on door glitch
CLOSES #27: Battle Intro text automatically continues
CLOSES #29: Visual bug when zoomed out
CLOSES #32: Team Rocket recruiter doesn't battle with you unless you speak with him first
CLOSES #33: Developer/Debug Console
CLOSES #35: Professor Oak's introduction Inaccuracies
CLOSES #36: Missing Pokemon Dex entries when picking starter + Rival Pathing issues
CLOSES #39: Guy who stops player from skipping brock doesn't bring you to brock's gym + doesn't leave once you've beaten brock
CLOSES #40: Bill cutscene is broken
CLOSES #41: Ticket guy failing to be a Ticket guy
CLOSES #42: S.S. anne odd behavior + Missing sailing away animation
CLOSES #43: Dig Attack animation appears to be glitched
CLOSES #44: Pokeball flashing doesn't appear to be accurate
CLOSES #45: Inaccurate Cut Animation
CLOSES #46: Dugtrio i caught in diglett cave has two of the same move
CLOSES #47: Rival ignores player in Lavender Tower
CLOSES #48: Healing pad in lavender tower does not function
CLOSES #49: Incorrect dialogue with parched security guard
CLOSES #50: (Game Breaking!) Rocket grunt guarding poster refuses to move
CLOSES #51: Badges showing up as items I can deposit in PC
CLOSES #52: Visual bug on Celadon Department Store roof (Red Filter)
CLOSES #54: Visual issue on route 15 + Fuchsia City
CLOSES #56: Running animation missing
CLOSES #57: Safari Zone does not display steps while you are inside of it
CLOSES #58: (Game Breaking!) Softlock at cycling road gate
CLOSES #59: Bike visual issues
CLOSES #60: Cycling road not forcing you to get on your bike
CLOSES #61: No keycard doors in Silph Co.
CLOSES #63: Missing teleporter animation
CLOSES #64: Inaccurate spinning
CLOSES #65: Reimplement unused Silph Co. Chief and Professor Oak trainer battles
This commit is contained in:
bryanthaboi
2026-07-22 08:18:13 -04:00
parent 2c2a5a4220
commit 15029d811f
38 changed files with 1318 additions and 134 deletions
+3
View File
@@ -1,13 +1,16 @@
function love.conf(t) function love.conf(t)
local editor = os.getenv("POKEPORT_EDITOR") == "1" local editor = os.getenv("POKEPORT_EDITOR") == "1"
local developer = os.getenv("POKEPORT_DEV") == "1"
if arg then if arg then
for _, a in ipairs(arg) do for _, a in ipairs(arg) do
if a == "--editor" then editor = true end if a == "--editor" then editor = true end
if a == "--developer" then developer = true end
end end
end end
-- main.lua runs in the same Lua state right after conf.lua; stash the -- main.lua runs in the same Lua state right after conf.lua; stash the
-- decision in a global so it doesn't need to reparse `arg`. -- decision in a global so it doesn't need to reparse `arg`.
_G.POKEPORT_EDITOR_MODE = editor _G.POKEPORT_EDITOR_MODE = editor
_G.POKEPORT_DEV_MODE = developer
if editor then if editor then
t.identity = os.getenv("POKEPORT_IDENTITY") or "pokemon-love2d-editor" t.identity = os.getenv("POKEPORT_IDENTITY") or "pokemon-love2d-editor"
+26
View File
@@ -0,0 +1,26 @@
-- Celadon Chief House (scripts/CeladonChiefHouse.asm). The CHIEF is a
-- talk-only NPC in the original ROM, and ChiefData (data/trainers/
-- parties.asm) is an empty, unreferenced trainer entry. After the HALL
-- OF FAME (EVENT_BEAT_CHAMPION_RIVAL) the CHIEF puts up a fight, using a
-- reconstructed party for the otherwise-unused OPP_CHIEF class.
return {
talk = {
TEXT_CELADONCHIEFHOUSE_CHIEF = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_BEAT_CHAMPION_RIVAL" }, -- 2
{ "jump_if_false", 12 }, -- 3
{ "check_flag", "EVENT_BEAT_CELADON_CHIEF" }, -- 4
{ "jump_if_true", 12 }, -- 5
{ "show_text", "So you've come to\nshut down my\noperation?\f"
.. "TEAM ROCKET's\nCHIEF won't go\ndown so easy!" }, -- 6
{ "start_battle", "trainer", "OPP_CHIEF", 1 }, -- 7
{ "jump_if_false", "end" }, -- 8
{ "set_flag", "EVENT_BEAT_CELADON_CHIEF" }, -- 9
{ "show_text", "Gah! Even the\nCHIEF is no match\nfor you!\f"
.. "TEAM ROCKET is\nfinished for\ngood!" }, -- 10
{ "jump", "end" }, -- 11
{ "show_text", "_CeladonChiefHouseChiefText" }, -- 12
},
},
}
+5 -4
View File
@@ -5,10 +5,11 @@
-- The escort choreography (SUPER_NERD1 walking the player to the -- The escort choreography (SUPER_NERD1 walking the player to the
-- museum, YOUNGSTER walking the player to the gym) is scripted NPC -- museum, YOUNGSTER walking the player to the gym) is scripted NPC
-- movement + a wPewterCityCurScript state machine that steers the -- movement + a wPewterCityCurScript state machine that steers the
-- player off-map; that part is already covered on this map by -- player off-map; the YOUNGSTER's escort to the gym is handled on this
-- story5.lua's onStep gate (walks the player back a step at the -- map by story5.lua's onStep gate at the east exit (before
-- east exit before EVENT_BEAT_BROCK). Here we only port the real -- EVENT_BEAT_BROCK), while the SUPER_NERD1 museum escort is not ported.
-- YES/NO-branched flavor text these NPCs speak when talked to. -- Here we only port the real YES/NO-branched flavor text these NPCs
-- speak when talked to.
local M = {} local M = {}
+20 -17
View File
@@ -19,32 +19,35 @@ local function starterBall(askText, species, choseFlag, ownBall,
rivalBallX, rivalBall) rivalBallX, rivalBall)
return { return {
{ "check_flag", "EVENT_GOT_STARTER" }, -- 1 { "check_flag", "EVENT_GOT_STARTER" }, -- 1
{ "jump_if_true", 19 }, -- 2 { "jump_if_true", 20 }, -- 2
-- no picking until Oak has walked you in (OaksLabScript gating) -- no picking until Oak has walked you in (OaksLabScript gating)
{ "check_flag", "EVENT_FOLLOWED_OAK_INTO_LAB" }, -- 3 { "check_flag", "EVENT_FOLLOWED_OAK_INTO_LAB" }, -- 3
{ "jump_if_false", 19 }, -- 4 { "jump_if_false", 20 }, -- 4
{ "ask", askText }, -- 5 -- the Pokédex "new species" entry shows before the ask (predef
{ "jump_if_false", 20 }, -- 6 -- StarterDex ahead of OaksLabYouWant...Text)
{ "give_pokemon", species, 5 }, -- 7 { "push_screen", "DexEntryMenu", species }, -- 5
{ "set_flag", "EVENT_GOT_STARTER" }, -- 8 { "ask", askText }, -- 6
{ "set_flag", choseFlag }, -- 9 { "jump_if_false", 21 }, -- 7
{ "give_pokemon", species, 5 }, -- 8
{ "set_flag", "EVENT_GOT_STARTER" }, -- 9
{ "set_flag", choseFlag }, -- 10
-- POKé BALLs are not handed out here in the original -- Oak gives -- POKé BALLs are not handed out here in the original -- Oak gives
-- them later, at OaksLabOak1Text's .give_poke_balls beat once the -- them later, at OaksLabOak1Text's .give_poke_balls beat once the
-- player has beaten the Route 22 rival (see TEXT_OAKSLAB_OAK1 below) -- player has beaten the Route 22 rival (see TEXT_OAKSLAB_OAK1 below)
{ "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 10 { "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 11
{ "hide_object", "OAKS_LAB", ownBall }, -- 11 { "hide_object", "OAKS_LAB", ownBall }, -- 12
-- the rival walks to the countering ball (around the furniture) -- the rival walks to the countering ball (around the furniture)
{ "move_npc_to", 1, rivalBallX, 4 }, -- 12 { "move_npc_to", 1, rivalBallX, 4 }, -- 13
{ "face_object", 1, "up" }, -- 13 { "face_object", 1, "up" }, -- 14
{ "show_text", "_OaksLabRivalIllTakeThisOneText" }, -- 14 { "show_text", "_OaksLabRivalIllTakeThisOneText" }, -- 15
{ "hide_object", "OAKS_LAB", rivalBall }, -- 15 { "hide_object", "OAKS_LAB", rivalBall }, -- 16
{ "show_text", "_OaksLabRivalReceivedMonText", { "show_text", "_OaksLabRivalReceivedMonText",
{ RAM = rivalBall == "OAKSLAB_CHARMANDER_POKE_BALL" and "CHARMANDER" { RAM = rivalBall == "OAKSLAB_CHARMANDER_POKE_BALL" and "CHARMANDER"
or rivalBall == "OAKSLAB_SQUIRTLE_POKE_BALL" and "SQUIRTLE" or rivalBall == "OAKSLAB_SQUIRTLE_POKE_BALL" and "SQUIRTLE"
or "BULBASAUR" } }, -- 16 or "BULBASAUR" } }, -- 17
{ "jump", 20 }, -- 17 { "jump", 21 }, -- 18
{ "jump", 20 }, -- 18 (spacer) { "jump", 21 }, -- 19 (spacer)
{ "show_text", "_OaksLabThoseArePokeBallsText" }, -- 19 { "show_text", "_OaksLabThoseArePokeBallsText" }, -- 20
} }
end end
+29 -5
View File
@@ -10,15 +10,39 @@
-- The girl, fisher and the four signs resolve automatically through the -- The girl, fisher and the four signs resolve automatically through the
-- extracted text pointers (no script needed). -- extracted text pointers (no script needed).
-- Once the HALL OF FAME has been reached (EVENT_BEAT_CHAMPION_RIVAL, set
-- alongside Commands.record_hall_of_fame) Oak offers the ProfOakData
-- battle (data/trainers/parties.asm) -- three teams picked by the
-- player's starter, mirroring the rival's type-advantage lineup, that go
-- unused in the original ROM.
return { return {
talk = { talk = {
TEXT_PALLETTOWN_OAK = { TEXT_PALLETTOWN_OAK = {
{ "face_player" }, -- 1 { "face_player" }, -- 1
{ "check_flag", "EVENT_GOT_STARTER" }, -- 2 { "check_flag", "EVENT_BEAT_CHAMPION_RIVAL" }, -- 2
{ "jump_if_true", 6 }, -- 3 { "jump_if_false", 20 }, -- 3
{ "show_text", "_PalletTownOakHeyWaitDontGoOutText" },-- 4 { "check_flag", "EVENT_BEAT_PROF_OAK" }, -- 4
{ "jump", 7 }, -- 5 { "jump_if_true", 20 }, -- 5
{ "show_text", "_PalletTownOakItsUnsafeText" }, -- 6 { "show_text", "OAK: So you want\nto test your\nskills on me?\f"
.. "Very well! Let\nme show you what\na real trainer\ncan do!" }, -- 6
{ "check_flag", "EVENT_CHOSE_BULBASAUR" }, -- 7
{ "jump_if_false", 11 }, -- 8
{ "start_battle", "trainer", "OPP_PROF_OAK", 3 }, -- 9 CHARIZARD
{ "jump", 16 }, -- 10
{ "check_flag", "EVENT_CHOSE_SQUIRTLE" }, -- 11
{ "jump_if_false", 15 }, -- 12
{ "start_battle", "trainer", "OPP_PROF_OAK", 2 }, -- 13 VENUSAUR
{ "jump", 16 }, -- 14
{ "start_battle", "trainer", "OPP_PROF_OAK", 1 }, -- 15 BLASTOISE
{ "jump_if_false", "end" }, -- 16
{ "set_flag", "EVENT_BEAT_PROF_OAK" }, -- 17
{ "show_text", "OAK: Impressive!\nYou truly are a\nPOKéMON MASTER!" }, -- 18
{ "jump", "end" }, -- 19
{ "check_flag", "EVENT_GOT_STARTER" }, -- 20
{ "jump_if_true", 24 }, -- 21
{ "show_text", "_PalletTownOakHeyWaitDontGoOutText" }, -- 22
{ "jump", "end" }, -- 23
{ "show_text", "_PalletTownOakItsUnsafeText" }, -- 24
}, },
}, },
} }
+140 -26
View File
@@ -133,22 +133,70 @@ M.BLUES_HOUSE = {
M.BILLS_HOUSE = { M.BILLS_HOUSE = {
talk = { talk = {
TEXT_BILLSHOUSE_BILL_POKEMON = { -- BillsHouseBillPokemonText: "I'm not a POKéMON!", a YES/NO choice
{ "check_flag", "EVENT_GOT_SS_TICKET" }, -- 1 -- (NO only adds "No, you gotta help!" before rejoining the YES
{ "jump_if_true", 13 }, -- 2 -- path), then the "get in the TELEPORTER" line and the monster
{ "show_text", "_BillsHouseBillImNotAPokemonText" }, -- 3 -- walking into the cell-separator machine
{ "show_text", "_BillsHouseBillNoYouGottaHelpText" }, -- 4 -- (BillsHousePokemonWalkToMachineScript: up 3, or around the player
-- the cell-separator PC throws its switch -- when they stand in the way facing down), where it is hidden and
-- (bills_house_pc.asm BillsHouseInitiatedText: SFX_SWITCH) -- EVENT_BILL_SAID_USE_CELL_SEPARATOR arms the PC at (1,4) -- see
{ "play_sound", "Switch" }, -- 5 -- OverworldState.billsHousePC for the separator itself.
{ "show_text", "_BillsHouseBillThankYouText" }, -- 6 TEXT_BILLSHOUSE_BILL_POKEMON = function(game, ow, npc, done)
local TextBox = require("src.render.TextBox")
local t = game.data.text
local function toMachine()
game.stack:push(TextBox.new(game,
t._BillsHouseBillUseSeparationSystemText
or "When I'm in the\nTELEPORTER, run\nthe Cell\nSeparation System!", function()
local function entered()
local Commands = require("src.script.Commands")
Commands.hide_object({ game = game, save = game.save,
overworld = ow },
"BILLS_HOUSE", "BILLSHOUSE_BILL_POKEMON")
game.save.flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = true
done()
end
if ow.player.facing == "down" then
-- the player is standing on his straight path: walk around
-- (.PokemonWalkAroundPlayerMovement)
ow:scriptMove(npc, "right", 1, function()
ow:scriptMove(npc, "up", 2, function()
ow:scriptMove(npc, "left", 1, function()
ow:scriptMove(npc, "up", 1, entered)
end)
end)
end)
else
ow:scriptMove(npc, "up", 3, entered)
end
end))
end
game.stack:push(TextBox.new(game,
t._BillsHouseBillImNotAPokemonText or "Hey! I'm not a\nPOKéMON!",
nil, { choice = function(yes)
if yes then
toMachine()
else
game.stack:push(TextBox.new(game,
t._BillsHouseBillNoYouGottaHelpText
or "No! You gotta\nhelp me!", toMachine))
end
end }))
end,
-- BillsHouseBillSSTicketText (human Bill after the separation)
TEXT_BILLSHOUSE_BILL_SS_TICKET = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_GOT_SS_TICKET" }, -- 2
{ "jump_if_true", 12 }, -- 3
{ "show_text", "_BillsHouseBillThankYouText" }, -- 4
-- pokered gives first (GiveItem fills wStringBuffer), then prints -- pokered gives first (GiveItem fills wStringBuffer), then prints
-- the received text that reads it (scripts/BillsHouse.asm; the -- the received text that reads it (scripts/BillsHouse.asm; the
-- item id is S_S_TICKET in generated items.lua -- keyItem, so the -- item id is S_S_TICKET in generated items.lua -- keyItem, so the
-- sound_get_key_item jingle plays like BillsHouse.asm:196) -- sound_get_key_item jingle plays like BillsHouse.asm:196)
{ "give_item", "S_S_TICKET", 1, false }, -- 7 { "give_item", "S_S_TICKET", 1, false }, -- 5
{ "show_text", "_SSTicketReceivedText" }, -- 8 { "show_text", "_SSTicketReceivedText" }, -- 6
{ "set_flag", "EVENT_GOT_SS_TICKET" }, -- 9 { "set_flag", "EVENT_GOT_SS_TICKET" }, -- 7
-- The two Cerulean guards are a SWAP PAIR, not scenery -- The two Cerulean guards are a SWAP PAIR, not scenery
-- (BillsHouse.asm:174-178): handing over the ticket shows GUARD1 at -- (BillsHouse.asm:174-178): handing over the ticket shows GUARD1 at
-- (28,12) and hides GUARD2 at (27,12). This matters far more than it -- (28,12) and hides GUARD2 at (27,12). This matters far more than it
@@ -156,16 +204,36 @@ M.BILLS_HOUSE = {
-- house's south door at (27,11), and that house is one of the two -- house's south door at (27,11), and that house is one of the two
-- ways through the fence that splits Cerulean in half (the badge -- ways through the fence that splits Cerulean in half (the badge
-- house is the other). Leaving GUARD2 up forever severs the city -- -- house is the other). Leaving GUARD2 up forever severs the city --
-- the gym/mart half can never reach the Route 5 exit -- which is -- the gym/mart half can never reach the Route 5 exit.
-- exactly what stranded the bot after it beat Misty.
-- Same swap fires after the TM28 Rocket (CeruleanCity_2.asm -- Same swap fires after the TM28 Rocket (CeruleanCity_2.asm
-- CeruleanHideRocket), so either route opens the path. -- CeruleanHideRocket), so either route opens the path.
{ "show_object", "CERULEAN_CITY", "CERULEANCITY_GUARD1" }, -- 10 { "show_object", "CERULEAN_CITY", "CERULEANCITY_GUARD1" }, -- 8
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_GUARD2" }, -- 11 { "hide_object", "CERULEAN_CITY", "CERULEANCITY_GUARD2" }, -- 9
{ "jump", 14 }, -- 12 { "show_text", "_BillsHouseBillWhyDontYouGoInsteadOfMeText" }, -- 10
{ "show_text", "_BillsHouseBillCheckOutMyRarePokemonText" }, -- 13 { "jump", 13 }, -- 11
{ "show_text", "_BillsHouseBillWhyDontYouGoInsteadOfMeText" }, -- 12
},
TEXT_BILLSHOUSE_BILL_CHECK_OUT_MY_RARE_POKEMON = {
{ "face_player" }, -- 1
{ "show_text", "_BillsHouseBillCheckOutMyRarePokemonText" }, -- 2
}, },
}, },
-- repair saves that already got the ticket under the old collapsed
-- script (the monster never hidden, human Bill never shown)
onEnter = function(game, ow)
if game.save.flags.EVENT_GOT_SS_TICKET
and not game.save.flags.EVENT_USED_CELL_SEPARATOR_ON_BILL then
local Commands = require("src.script.Commands")
local ctx = { game = game, save = game.save, overworld = ow }
Commands.hide_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL_POKEMON")
Commands.show_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL1")
game.save.flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = true
game.save.flags.EVENT_USED_CELL_SEPARATOR_ON_BILL = true
game.save.flags.EVENT_MET_BILL = true
game.save.flags.EVENT_MET_BILL_2 = true
end
end,
} }
-- ------------------------------------------------------------------- -- -------------------------------------------------------------------
@@ -186,17 +254,48 @@ M.VERMILION_CITY = {
game.save.trashPuzzle = puz game.save.trashPuzzle = puz
puz.first = love.math.random(0, 7) * 2 puz.first = love.math.random(0, 7) * 2
end, end,
-- VermilionCityDefaultScript's per-frame SSAnneTicketCheckCoords check:
-- the unguarded cell (18,30) just west of the sailor leads straight
-- onto the dock warp, so stepping onto it heading for the dock gets
-- ticket-checked (and turned back once the ship has sailed) without
-- the player ever pressing A. The sailor himself never disappears.
onStep = function(game, ow, x, y)
if x ~= 18 or y ~= 30 then return false end
if ow.player.facing ~= "down" then return false end
local f = game.save.flags
local t = game.data.text
local TextBox = require("src.render.TextBox")
if f.EVENT_SS_ANNE_LEFT then
game.stack:push(TextBox.new(game,
t._VermilionCitySailor1ShipSetSailText or "The ship set sail.",
function() ow:scriptMove(ow.player, "up", 1) end))
return true
end
if (game.save.inventory.S_S_TICKET or 0) > 0 then return false end
game.stack:push(TextBox.new(game,
(t._VermilionCitySailor1WelcomeToSSAnneText or "Welcome to S.S.\nANNE!")
.. "\f"
.. (t._VermilionCitySailor1YouNeedATicketText
or "You need a ticket\nto get aboard."),
function() ow:scriptMove(ow.player, "up", 1) end))
return true
end,
talk = { talk = {
-- the sailor guarding the dock gangway -- the sailor guarding the dock gangway (VermilionCitySailor1Text):
-- flashing the ticket just lets you through -- he never hides, and
-- once the ship has sailed he only reports it gone
TEXT_VERMILIONCITY_SAILOR1 = { TEXT_VERMILIONCITY_SAILOR1 = {
{ "face_player" }, -- 1 { "face_player" }, -- 1
{ "show_text", "_VermilionCitySailor1WelcomeToSSAnneText" }, -- 2 { "check_flag", "EVENT_SS_ANNE_LEFT" }, -- 2
{ "check_item", "S_S_TICKET" }, -- 3 { "jump_if_true", 11 }, -- 3
{ "jump_if_false", 8 }, -- 4 { "show_text", "_VermilionCitySailor1WelcomeToSSAnneText" }, -- 4
{ "show_text", "_VermilionCitySailor1FlashedTicketText" }, -- 5 { "check_item", "S_S_TICKET" }, -- 5
{ "hide_object", "VERMILION_CITY", "VERMILIONCITY_SAILOR1" }, -- 6 { "jump_if_false", 9 }, -- 6
{ "jump", 9 }, -- 7 { "show_text", "_VermilionCitySailor1FlashedTicketText" }, -- 7
{ "show_text", "_VermilionCitySailor1YouNeedATicketText" }, -- 8 { "jump", 12 }, -- 8
{ "show_text", "_VermilionCitySailor1YouNeedATicketText" }, -- 9
{ "jump", 12 }, -- 10
{ "show_text", "_VermilionCitySailor1ShipSetSailText" }, -- 11
}, },
}, },
} }
@@ -665,6 +764,21 @@ M.POKEMON_TOWER_2F = {
{ "show_text", "_PokemonTower2FRivalHowsYourDexText" }, -- 10 { "show_text", "_PokemonTower2FRivalHowsYourDexText" }, -- 10
}, },
}, },
-- PokemonTower2FDefaultScript: walking past the rival's tile forces
-- the encounter (ArePlayerCoordsInArray on (15,5)/(14,6)) -- he never
-- waits to be talked to
onStep = function(game, ow, x, y)
if game.save.flags.EVENT_BEAT_POKEMON_TOWER_RIVAL then return false end
if not ((x == 15 and y == 5) or (x == 14 and y == 6)) then return false end
if ow.runner:isRunning() then return false end
local rival = ow:npcByIndex(1)
if not rival then return false end
ow.player.facing = (x == 15) and "left" or "up"
require("src.core.Music").play(game.data, "Music_MeetRival")
ow.runner:run(M.POKEMON_TOWER_2F.talk.TEXT_POKEMONTOWER2F_RIVAL,
{ npc = rival })
return true
end,
} }
-- ------------------------------------------------------------------- -- -------------------------------------------------------------------
+11 -3
View File
@@ -307,9 +307,13 @@ local function saffronGate(guardText, triggers, horizontal)
return return
end end
if takeGuardDrink(game) then if takeGuardDrink(game) then
game.stack:push(TextBox.new(game,
t._SaffronGateGuardImParchedText or "Whoa, boy!\nI'm parched!",
function()
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
(t._SaffronGateGuardYouCanGoOnThroughText or (t._SaffronGateGuardYouCanGoOnThroughText or
"Thanks! You can\ngo on through!"), done)) "You can go on\nthrough!"), done))
end))
return return
end end
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
@@ -343,9 +347,13 @@ local function saffronGate(guardText, triggers, horizontal)
-- short way) was unreachable, and the bot could not get to Lavender -- short way) was unreachable, and the bot could not get to Lavender
-- for the POKE_FLUTE at all. -- for the POKE_FLUTE at all.
if takeGuardDrink(game) then if takeGuardDrink(game) then
game.stack:push(TextBox.new(game,
t._SaffronGateGuardImParchedText or "Whoa, boy!\nI'm parched!",
function()
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
(t._SaffronGateGuardYouCanGoOnThroughText or (t._SaffronGateGuardYouCanGoOnThroughText or
"Thanks! You can\ngo on through!"))) "You can go on\nthrough!")))
end))
return true return true
end end
local back local back
@@ -355,7 +363,7 @@ local function saffronGate(guardText, triggers, horizontal)
back = ow.player.facing == "up" and "down" or "up" back = ow.player.facing == "up" and "down" or "up"
end end
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
t._SaffronGateGuardImParchedText or "I'm parched...\nNo entry until\nI get a drink!", t._SaffronGateGuardGeeImThirstyText or "Gee, I'm thirsty\nthough!\nThe road's closed.",
function() function()
ow:scriptMove(ow.player, back, 1) ow:scriptMove(ow.player, back, 1)
end)) end))
+69 -4
View File
@@ -317,6 +317,33 @@ M.GAME_CORNER = {
done() done()
end)) end))
end, end,
-- the grunt guarding the poster (GameCornerRocketText /
-- GameCornerRocketBattleScript / GameCornerRocketExitScript): after
-- losing he warns the BOSS and leaves the floor for good, freeing
-- the tile in front of the hideout switch
TEXT_GAMECORNER_ROCKET = function(game, ow, npc, done)
if ow:trainerDefeated(npc) then
done()
return
end
ow:engageTrainer(npc, function()
if not ow:trainerDefeated(npc) then
done()
return
end
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game,
game.data.text._GameCornerRocketAfterBattleText
or "Our hideout might\nbe discovered! I\nbetter tell BOSS!",
function()
local Commands = require("src.script.Commands")
Commands.hide_object({ game = game, save = game.save,
overworld = ow },
"GAME_CORNER", "GAMECORNER_ROCKET")
done()
end))
end)
end,
TEXT_GAMECORNER_CLERK1 = function(game, ow, npc, done) TEXT_GAMECORNER_CLERK1 = function(game, ow, npc, done)
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
local ChoiceBox = require("src.ui.ChoiceBox") local ChoiceBox = require("src.ui.ChoiceBox")
@@ -425,18 +452,56 @@ M.GAME_CORNER_PRIZE_ROOM = {
-- and the player steps off the dock, the ship sets sail. -- and the player steps off the dock, the ship sets sail.
-- ------------------------------------------------------------------- -- -------------------------------------------------------------------
-- the ship's hull/deck blocks (block cols 5-8, rows 1-2) and the water
-- that replaces them once she sails (the surrounding blocks of each row)
local DOCK_SHIP_BLOCKS = {
{ bx = 5, by = 1, water = 1 }, { bx = 6, by = 1, water = 1 },
{ bx = 7, by = 1, water = 1 }, { bx = 8, by = 1, water = 1 },
{ bx = 5, by = 2, water = 13 }, { bx = 6, by = 2, water = 13 },
{ bx = 7, by = 2, water = 13 }, { bx = 8, by = 2, water = 13 },
}
M.VERMILION_DOCK = { M.VERMILION_DOCK = {
onEnter = function(game, ow) onEnter = function(game, ow)
if game.save.flags.EVENT_SS_ANNE_LEFT then local f = game.save.flags
if f.EVENT_SS_ANNE_LEFT then
-- the ship is long gone: erase her right away, and anyone who
-- still lands here is sent back out past the guard
for _, b in ipairs(DOCK_SHIP_BLOCKS) do
ow.map:setBlock(b.bx, b.by, b.water)
end
ow.map.renderer:rebuild()
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
game.data.text._VermilionCitySailor1ShipSetSailText game.data.text._VermilionCitySailor1ShipSetSailText
or "The ship set sail.", function() or "The ship set sail.", function()
ow:startWarpTo("VERMILION_CITY", 19, 30, "up") ow:startWarpTo("VERMILION_CITY", 18, 29, "up")
end)) end))
elseif game.save.flags.EVENT_GOT_HM01 then elseif f.EVENT_GOT_HM01 and ow.player.cellY == 2 then
game.save.flags.EVENT_SS_ANNE_LEFT = true -- VermilionDockSSAnneLeavesScript: only stepping OFF the ship
-- triggers the departure (wDestinationWarpID == 1 in pokered) --
-- the horn blows, smoke puffs drift off the funnel, the ship is
-- erased to open water, and the player is walked off the dock
-- into the city past the guard (VermilionCity's
-- SCRIPT_VERMILIONCITY_PLAYER_EXIT_SHIP walk)
f.EVENT_SS_ANNE_LEFT = true
require("src.core.Music").stop()
require("src.core.Sound").play(game.data, "SS_Anne_Horn") require("src.core.Sound").play(game.data, "SS_Anne_Horn")
local function puff(n, cx)
if n <= 0 then return end
ow:startDustAnim(cx, 1, function() puff(n - 1, cx + 2) end)
end
puff(3, 15)
local rows = {}
rows[#rows + 1] = { "wait", 100 }
for _, b in ipairs(DOCK_SHIP_BLOCKS) do
rows[#rows + 1] = { "replace_block", b.bx, b.by, b.water }
end
rows[#rows + 1] = { "wait", 30 }
rows[#rows + 1] = { "move_player", "up", 2 }
rows[#rows + 1] = { "warp", "VERMILION_CITY", 18, 31, "up" }
rows[#rows + 1] = { "move_player", "up", 2 }
ow:queueScript(rows)
end end
end, end,
} }
+21
View File
@@ -457,6 +457,25 @@ M.ROUTE_24 = {
battleOrDone() battleOrDone()
end, end,
}, },
-- Route24DefaultScript forces TEXT_ROUTE24_COOLTRAINER_M1 whenever the
-- player stands on (10,15) in front of the recruiter while
-- EVENT_GOT_NUGGET is unset (dbmapcoord 10,15), independent of facing
-- or input -- he has no trainer header, so sight never engages him.
onStep = function(game, ow, x, y)
if game.save.flags.EVENT_GOT_NUGGET then return false end
if not (x == 10 and y == 15) then return false end
if ow.runner:isRunning() or ow.engaging then return false end
local recruiter
for _, npc in ipairs(ow.npcs) do
if npc.def and npc.def.name == "ROUTE24_COOLTRAINER_M1" then
recruiter = npc
break
end
end
if not recruiter then return false end
ow:showMapText("TEXT_ROUTE24_COOLTRAINER_M1", recruiter)
return true
end,
} }
-- ------------------------------------------------------------------- -- -------------------------------------------------------------------
@@ -723,4 +742,6 @@ M.LANCES_ROOM = {
end, end,
} }
M.CELADON_CHIEF_HOUSE = require("data.scripts.celadon_chief_house")
return M return M
+35 -5
View File
@@ -259,21 +259,39 @@ M.CINNABAR_ISLAND = {
end, end,
} }
-- Pewter's youngster stops you leaving east before Brock is beaten -- Pewter's youngster stops you leaving east before Brock is beaten and
-- (scripts/PewterCity.asm PewterCityCheckPlayerLeavingEastScript; the -- escorts you to the gym (scripts/PewterCity.asm
-- original escorts you to the gym, we walk you back a step) -- PewterCityCheckPlayerLeavingEastScript /
-- PewterCityYoungsterShowsPlayerGymScript, engine/events/pewter_guys.asm
-- PewterGymGuyCoords): the "follow me" lines, then the player is walked
-- west along the road to the front of the PEWTER_GYM door at (16,17).
M.PEWTER_CITY = { M.PEWTER_CITY = {
onStep = function(game, ow, x, y) onStep = function(game, ow, x, y)
if game.save.flags.EVENT_BEAT_BROCK then return false end if game.save.flags.EVENT_BEAT_BROCK then return false end
if ow.runner:isRunning() or #ow.scriptMoves > 0 then return false end
if not inCoords({ { 35, 17 }, { 36, 17 }, { 37, 18 }, { 37, 19 } }, x, y) then if not inCoords({ { 35, 17 }, { 36, 17 }, { 37, 18 }, { 37, 19 } }, x, y) then
return false return false
end end
local t = text(game) local t = text(game)
-- walk up onto the road row (y=17), west to one tile east of the gym
-- door, then drop below the door and turn to face it
local steps = {}
for _ = 1, y - 17 do steps[#steps + 1] = "up" end
for _ = 1, x - 17 do steps[#steps + 1] = "left" end
steps[#steps + 1] = "down"
steps[#steps + 1] = "left"
local function walk(i)
if not steps[i] then
ow.player.facing = "up"
return
end
ow:scriptMove(ow.player, steps[i], 1, function() walk(i + 1) end)
end
push(game, t._PewterCityYoungsterYoureATrainerFollowMeText push(game, t._PewterCityYoungsterYoureATrainerFollowMeText
or "Hey! You're a\ntrainer, right?", function() or "Hey! You're a\ntrainer, right?", function()
push(game, t._PewterCityYoungsterGoTakeOnBrockText push(game, t._PewterCityYoungsterGoTakeOnBrockText
or "Go take on BROCK\nat the GYM first!", function() or "Go take on BROCK\nat the GYM first!", function()
ow:scriptMove(ow.player, "left", 1) walk(1)
end) end)
end) end)
return true return true
@@ -453,10 +471,22 @@ local function bikeGateGuard(coords, stopText, explainText)
return function(game, ow, x, y) return function(game, ow, x, y)
if game.save.inventory.BICYCLE then return false end if game.save.inventory.BICYCLE then return false end
if not inCoords(coords, x, y) then return false end if not inCoords(coords, x, y) then return false end
-- walk the player up to the tile beside the counter, no further:
-- (matchedY - closestY) tiles, 0 when already next to it
-- (Route16Gate1FDefaultScript's wCoordIndex-1). Forcing a fixed one
-- tile up from the counter-adjacent row shoved the player onto the
-- impassable desk and boxed them in.
local closestY = coords[1][2]
for _, c in ipairs(coords) do
if c[2] < closestY then closestY = c[2] end
end
local dist = y - closestY
local t = text(game) local t = text(game)
push(game, t[stopText] or "Hey! Wait up!", function() push(game, t[stopText] or "Hey! Wait up!", function()
push(game, t[explainText] or "You need a\nBICYCLE for\nCYCLING ROAD!", function() push(game, t[explainText] or "You need a\nBICYCLE for\nCYCLING ROAD!", function()
ow:scriptMove(ow.player, "up", 1) if dist > 0 then
ow:scriptMove(ow.player, "up", dist)
end
end) end)
end) end)
return true return true
+34 -1
View File
@@ -6,4 +6,37 @@ work for true parity. Faithfully-ported behavior is documented in
docs/behavior-porting-notes.md; deliberate additions beyond the original docs/behavior-porting-notes.md; deliberate additions beyond the original
are in docs/new-features.md. are in docs/new-features.md.
None currently. ## Reimplemented unused Prof. Oak and Rocket Chief battles
The original ROM defines trainer data for `PROF_OAK` and `CHIEF`
(`data/trainers/parties.asm`) but never attaches either to an NPC, so
both battles are unreachable in the real game. This project makes them
fightable after the Hall of Fame:
- Prof. Oak battles you in Pallet Town once `EVENT_BEAT_CHAMPION_RIVAL`
is set, using `ProfOakData`'s three starter-matched teams (the team is
picked by the type that counters your starter, mirroring the rival).
- The Celadon Game Corner Chief battles you in his house post-game.
`ChiefData` is empty in the ROM, so `OPP_CHIEF` is given a
reconstructed party.
This is an intentional divergence: neither battle can be triggered in the
original game.
## Reimplemented unused Silph Co. card-key doors
`engine/events/card_key.asm` and the unused `CardKeyTable1/2/3` coordinate
lists (`data/events/card_key_coords.asm`) describe locked doors for Silph
Co. floors 2F-11F, but no retail `.blk` map layout ever places the closed
door block at those coordinates, so the card key check is dead code in
the original game. This project stamps the closed door block (`$54`/`$5f`
on floors 2F-10F, `$20` on 11F) over each of the 20 door coordinates on
map load, and swaps it for the open block once that door's
`EVENT_SILPH_CO_n_UNLOCKED_DOORn` flag is set (using the key from a Team
Rocket grunt, as in the original's unused design).
This is an intentional divergence: the doors are not visible or
functional in the original game. The door layout lives in
`tools/rom_manifest.json` (`field.cardKeyDoors.closedDoors`), hand-ported
since no retail ROM data encodes it; `src/import/RomExtractor.lua` copies
it straight through on ROM import.
+30
View File
@@ -17,3 +17,33 @@ Regenerate the reference straight into a wiki checkout:
```sh ```sh
luajit tools/gen_registry_docs.lua ../pokemon-gen1-recomp-project.wiki luajit tools/gen_registry_docs.lua ../pokemon-gen1-recomp-project.wiki
``` ```
## Developer console
Boot with developer mode on to unlock the in-game console and hot-reload
hotkeys. Either set `POKEPORT_DEV=1` in the environment or pass
`--developer` on the command line:
```sh
love . --developer
```
While developer mode is active:
- `` ` `` (backtick) opens the console overlay — a Lua REPL with `game`,
`data` and `mods` in scope. Press `` ` `` again to close it.
- `F5` hot-reloads mods and asset caches without restarting.
The console understands these verbs (anything else is evaluated as Lua):
- `warp MAP [x y]` — teleport to a map (default cell 5,5).
- `give ID [n|level]` — add an item (count) or a Pokémon (level).
- `flag NAME [on|off]` — read or set an event flag.
- `party` — dump the current party.
- `mods` — list loaded mods and their state.
- `reload` — hot-reload mods (same as `F5`).
- `trace PAT | trace off` — trace events/hooks matching a glob pattern.
- `help` — list the verbs.
Developer mode also arms the mod loader's dev tripwire, which flags mods
that reach outside their permission set.
+2
View File
@@ -48,6 +48,8 @@ function love.load(args)
for i, a in ipairs(args or {}) do for i, a in ipairs(args or {}) do
if a == "--editor" then if a == "--editor" then
editorMode = true editorMode = true
elseif a == "--developer" then
_G.POKEPORT_DEV_MODE = true
elseif a == "--save" and args[i + 1] and args[i + 1] ~= "" then elseif a == "--save" and args[i + 1] and args[i + 1] ~= "" then
savePath = args[i + 1] savePath = args[i + 1]
elseif a == "--speed" and tonumber(args[i + 1]) then elseif a == "--speed" and tonumber(args[i + 1]) then
+56 -4
View File
@@ -635,7 +635,6 @@ function BattleState:startMessage(item)
self.total = self.total + #codes self.total = self.total + #codes
end end
self.charIndex = 0 self.charIndex = 0
self.holdTimer = nil
end end
function BattleState:updateQueue() function BattleState:updateQueue()
@@ -786,9 +785,8 @@ function BattleState:updateQueue()
if self.charIndex < self.total then if self.charIndex < self.total then
self.charIndex = math.min(self.total, self.charIndex + 2) self.charIndex = math.min(self.total, self.charIndex + 2)
else else
self.holdTimer = (self.holdTimer or 40) - 1
local input = self.game.input local input = self.game.input
if self.holdTimer <= 0 or input:wasPressed("a") or input:wasPressed("b") then if input:wasPressed("a") or input:wasPressed("b") then
self.current = nil self.current = nil
end end
end end
@@ -968,6 +966,25 @@ function BattleState:playerHasPP()
return false return false
end end
function BattleState:swapMoves(i, j)
if i == j then return end
local moves = self.player.curMoves
local a, b = moves[i], moves[j]
if not (a and b) then return end
moves[i], moves[j] = b, a
local stored = self.player.mon and self.player.mon.moves
if stored and stored ~= moves and stored[i] and stored[j] then
stored[i], stored[j] = stored[j], stored[i]
end
local disabled = self.player.disabledSlot
if disabled == i then
self.player.disabledSlot = j
elseif disabled == j then
self.player.disabledSlot = i
end
require("src.core.Sound").play(self.data, "Swap")
end
function BattleState:update(dt) function BattleState:update(dt)
self.frame = self.frame + 1 self.frame = self.frame + 1
self:updateFx() self:updateFx()
@@ -1081,6 +1098,7 @@ function BattleState:update(dt)
end end
self.phase = "moveSelect" self.phase = "moveSelect"
self.moveIndex = math.min(self.moveIndex, #self.player.curMoves) self.moveIndex = math.min(self.moveIndex, #self.player.curMoves)
self.moveSwapIndex = nil
elseif choice == "run" then elseif choice == "run" then
self:tryRun() self:tryRun()
elseif choice == "item" then elseif choice == "item" then
@@ -1098,9 +1116,22 @@ function BattleState:update(dt)
self.moveIndex = self.moveIndex > 1 and self.moveIndex - 1 or #moves self.moveIndex = self.moveIndex > 1 and self.moveIndex - 1 or #moves
elseif input:wasPressed("down") then elseif input:wasPressed("down") then
self.moveIndex = self.moveIndex < #moves and self.moveIndex + 1 or 1 self.moveIndex = self.moveIndex < #moves and self.moveIndex + 1 or 1
elseif input:wasPressed("select") then
if self.moveSwapIndex then
self:swapMoves(self.moveSwapIndex, self.moveIndex)
self.moveSwapIndex = nil
else
self.moveSwapIndex = self.moveIndex
end
elseif input:wasPressed("b") then elseif input:wasPressed("b") then
self.moveSwapIndex = nil
self.phase = "menu" self.phase = "menu"
elseif input:wasPressed("a") then elseif input:wasPressed("a") then
if self.moveSwapIndex then
self:swapMoves(self.moveSwapIndex, self.moveIndex)
self.moveSwapIndex = nil
return
end
local mv = moves[self.moveIndex] local mv = moves[self.moveIndex]
if self.player.disabledSlot == self.moveIndex then if self.player.disabledSlot == self.moveIndex then
self:say("The move is\ndisabled!") self:say("The move is\ndisabled!")
@@ -2265,6 +2296,7 @@ function BattleState:performMove(user, target, moveInst, isCalled)
-- falling back to the id tables (Fly AND Dig go semi-invulnerable: -- falling back to the id tables (Fly AND Dig go semi-invulnerable:
-- ChargeEffect sets INVULNERABLE for both) -- ChargeEffect sets INVULNERABLE for both)
if record and record.charge and not releasing then if record and record.charge and not releasing then
self:cancelMoveAnim()
user.charging = moveInst user.charging = moveInst
user.chargeReady = true user.chargeReady = true
local invulnerable = move.semiInvulnerable local invulnerable = move.semiInvulnerable
@@ -2274,6 +2306,15 @@ function BattleState:performMove(user, target, moveInst, isCalled)
if invulnerable then if invulnerable then
user.invulnerable = true user.invulnerable = true
end end
local chargeAnim = record.charge.anim
if move.id == "DIG" then
chargeAnim = "SLIDE_DOWN_ANIM"
elseif record.charge.enemyAnim and not user.isPlayer then
chargeAnim = record.charge.enemyAnim
end
if chargeAnim then
self:animNext(chargeAnim, user.isPlayer)
end
local chargeText = move.chargeText or CHARGE_TEXT[move.id] local chargeText = move.chargeText or CHARGE_TEXT[move.id]
or "%s\nis charging up!" or "%s\nis charging up!"
self:sayNext(chargeText:format(displayName(user))) self:sayNext(chargeText:format(displayName(user)))
@@ -2749,6 +2790,7 @@ function BattleState:safariAction(choice)
local playerName = self.game.save.player.name local playerName = self.game.save.player.name
if choice == "run" then if choice == "run" then
require("src.core.Sound").play(self.data, "Run")
self:say("Got away safely!") self:say("Got away safely!")
self.result = "run" self.result = "run"
self.afterQueue = "finish" self.afterQueue = "finish"
@@ -2830,6 +2872,12 @@ function BattleState:safariEnemyTurn()
end end
if fled then if fled then
self:sayNext(("Wild %s\nran!"):format(self.enemy.name)) self:sayNext(("Wild %s\nran!"):format(self.enemy.name))
self:actNext(function()
require("src.core.Sound").play(self.data, "Run")
startPicKind(self:picFxFor(self.enemy), "slideOff")
end)
self.nextInsert = (self.nextInsert or 0) + 1
table.insert(self.queue, self.nextInsert, { wait = 24 })
self.result = "run" self.result = "run"
self.afterQueue = "finish" self.afterQueue = "finish"
end end
@@ -3826,7 +3874,11 @@ function BattleState:drawTextArea()
for i, mv in ipairs(self.player.curMoves) do for i, mv in ipairs(self.player.curMoves) do
Font.draw(self.data.moves[mv.id].name, 48, 96 + i * 8) Font.draw(self.data.moves[mv.id].name, 48, 96 + i * 8)
end end
Font.drawCode(0xED, 40, 96 + self.moveIndex * 8) Font.drawCode((self.moveSwapIndex == self.moveIndex) and 0xEC or 0xED,
40, 96 + self.moveIndex * 8)
if self.moveSwapIndex and self.moveSwapIndex ~= self.moveIndex then
Font.drawCode(0xEC, 40, 96 + self.moveSwapIndex * 8)
end
local sel = self.player.curMoves[self.moveIndex] local sel = self.player.curMoves[self.moveIndex]
if sel then if sel then
if self.player.disabledSlot == self.moveIndex then if self.player.disabledSlot == self.moveIndex then
+2 -2
View File
@@ -516,8 +516,8 @@ MoveEffects.full = {
-- charge moves: first turn just charges; Fly AND Dig go -- charge moves: first turn just charges; Fly AND Dig go
-- semi-invulnerable (ChargeEffect sets INVULNERABLE for both) -- semi-invulnerable (ChargeEffect sets INVULNERABLE for both)
CHARGE_EFFECT = { charge = {} }, CHARGE_EFFECT = { charge = { anim = "XSTATITEM_ANIM", enemyAnim = "XSTATITEM_DUPLICATE_ANIM" } },
FLY_EFFECT = { charge = { invulnerable = true } }, FLY_EFFECT = { charge = { invulnerable = true, anim = "TELEPORT" } },
TRAPPING_EFFECT = { TRAPPING_EFFECT = {
-- TrappingEffect runs BEFORE the hit test and clears the target's -- TrappingEffect runs BEFORE the hit test and clears the target's
+1 -1
View File
@@ -17,7 +17,7 @@ local Game = {}
-- dev-mode gate for the F5/backtick hotkeys; false keeps every src/dev -- dev-mode gate for the F5/backtick hotkeys; false keeps every src/dev
-- module unloaded, so a player boot never touches a byte of dev code -- module unloaded, so a player boot never touches a byte of dev code
local devMode = os.getenv("POKEPORT_DEV") == "1" local devMode = os.getenv("POKEPORT_DEV") == "1" or _G.POKEPORT_DEV_MODE == true
-- the boot screen ids (field.boot.screens); a plain function so the -- the boot screen ids (field.boot.screens); a plain function so the
-- headless harness can borrow makeTitleState onto a stub game -- headless harness can borrow makeTitleState onto a stub game
+1
View File
@@ -114,6 +114,7 @@ local SPECIAL = {
oakRoute = "Music_Routes2", oakRoute = "Music_Routes2",
bike = "Music_BikeRiding", bike = "Music_BikeRiding",
surf = "Music_Surfing", surf = "Music_Surfing",
evolution = "Music_SafariZone",
} }
-- the label a scene role resolves to; call sites keep their own presence -- the label a scene role resolves to; call sites keep their own presence
+41 -3
View File
@@ -1253,14 +1253,24 @@ function RomExtractor:extractTrainers()
"battle/trainers/" .. picture.imageBase .. ".png") "battle/trainers/" .. picture.imageBase .. ".png")
written[picture.imageBase] = true written[picture.imageBase] = true
end end
local parties = self:trainerParties(
pointers.bank, partyStarts[index], partyEnds[index])
-- ChiefData is empty in the ROM (the Celadon Chief battle is unused/
-- cut content); tools/rom_manifest.json carries a hand-authored party
-- for the trainers this project reimplements, since no ROM data exists
-- to extract for them.
if #parties == 0 then
local override = self.manifest.trainerPartyOverrides
and self.manifest.trainerPartyOverrides[trainerId]
if override then parties = { copy(override) } end
end
out[trainerId] = { out[trainerId] = {
id = trainerId, index = index, name = decodedNames[index], id = trainerId, index = index, name = decodedNames[index],
source = "ROM:TrainerDataPointers", source = "ROM:TrainerDataPointers",
pic = picture and picture.path or nil, pic = picture and picture.path or nil,
baseMoney = math.floor(Rom.bcd(rawMoney) / 100), baseMoney = math.floor(Rom.bcd(rawMoney) / 100),
aiMods = aiMods[index], aiMods = aiMods[index],
parties = self:trainerParties( parties = parties,
pointers.bank, partyStarts[index], partyEnds[index]),
} }
self:tick("Trainers", index, #order) self:tick("Trainers", index, #order)
end end
@@ -1426,7 +1436,7 @@ end
function RomExtractor:extractField() function RomExtractor:extractField()
self:beginStage("Interface artwork") self:beginStage("Interface artwork")
local done, total = 0, 48 local done, total = 0, 49
local function tick() local function tick()
done = done + 1 done = done + 1
self:tick("Interface artwork", math.min(done, total), total) self:tick("Interface artwork", math.min(done, total), total)
@@ -1576,6 +1586,34 @@ function RomExtractor:extractField()
"fx/" .. spec[4], { transparent = true }) "fx/" .. spec[4], { transparent = true })
tick() tick()
end end
-- The cuttable tree's own sprite: InitCutAnimOAM (engine/overworld/cut.asm)
-- copies Overworld_GFX tiles $2d-$2e (top half) and $3d-$3e (bottom half)
-- into the OAM tiles the Cut animation slides apart, so this comes out of
-- the OVERWORLD tileset's own graphics blob rather than a named symbol.
do
local overworldIndex
for i, name in ipairs(self.manifest.constants.tilesetOrder) do
if name == "OVERWORLD" then overworldIndex = i break end
end
assert(overworldIndex, "OVERWORLD tileset not found in tilesetOrder")
local tilesetHeaders = self:symbol("Tilesets")
local rowAddress = tilesetHeaders.address + (overworldIndex - 1) * 12
local gfxBank = self.rom:byte(tilesetHeaders.bank, rowAddress)
local gfxPointer = self.rom:word(tilesetHeaders.bank, rowAddress + 3)
local cutTree = ImageWriter.blank(16, 16, 1, 1, 1, 0)
for _, spec in ipairs({
{ 0x2d, 0, 0 }, { 0x2e, 8, 0 }, { 0x3d, 0, 8 }, { 0x3e, 8, 8 },
}) do
local tileIndex, dx, dy = spec[1], spec[2], spec[3]
local tile = ImageWriter.decode2bpp(
self.rom:bytes(gfxBank, gfxPointer + tileIndex * 16, 16),
8, 8, true)
ImageWriter.blit(cutTree, tile, dx, dy)
end
self:save(cutTree, "fx/cut_tree.png"); tick()
end
self:raw2bpp("BattleTransitionTile", 8, 8, self:raw2bpp("BattleTransitionTile", 8, 8,
"fx/battle_transition.png"); tick() "fx/battle_transition.png"); tick()
self:raw2bpp("PokedexTileGraphics", 24, 48, self:raw2bpp("PokedexTileGraphics", 24, 48,
+12 -4
View File
@@ -2,7 +2,7 @@ local RomImporter = {}
RomImporter.__index = RomImporter RomImporter.__index = RomImporter
local ROM_SHA1 = "ea9bcae617fdf159b045185467ae58b2e4a48b9a" local ROM_SHA1 = "ea9bcae617fdf159b045185467ae58b2e4a48b9a"
local CACHE_MARKER = "rom-cache-v5:" .. ROM_SHA1 local CACHE_MARKER = "rom-cache-v6:" .. ROM_SHA1
local MARKER_PATH = "rom-cache.complete" local MARKER_PATH = "rom-cache.complete"
local COMMUNITY_URL = "https://bois.icu" local COMMUNITY_URL = "https://bois.icu"
local TRUST_WARNING = "if you did not get this from bryanthaboi's github " .. local TRUST_WARNING = "if you did not get this from bryanthaboi's github " ..
@@ -136,13 +136,20 @@ local function chooseRom()
end end
function RomImporter.new(onComplete) function RomImporter.new(onComplete)
local previousMarker = love.filesystem.read(MARKER_PATH)
local returning = previousMarker ~= nil and previousMarker ~= CACHE_MARKER
return setmetatable({ return setmetatable({
onComplete = onComplete, onComplete = onComplete,
logo = love.graphics.newImage("assets/logo/logo.png"), logo = love.graphics.newImage("assets/logo/logo.png"),
bcg = love.graphics.newImage("assets/logo/bcg.png"), bcg = love.graphics.newImage("assets/logo/bcg.png"),
state = "waiting", state = "waiting",
status = "Choose or drop a Pokemon Red ROM", returning = returning,
detail = "The ROM is verified before any files are created.", status = returning and "More assets are needed from your ROM"
or "Choose or drop a Pokemon Red ROM",
detail = returning
and "This update pulls a few more things from your ROM. "
.. "Please re-import it to continue (it's quick)."
or "The ROM is verified before any files are created.",
progress = 0, progress = 0,
stageCurrent = 0, stageCurrent = 0,
stageTotal = 1, stageTotal = 1,
@@ -304,7 +311,8 @@ function RomImporter:draw()
height * 0.075, height * 0.075,
0, logoScale, logoScale) 0, logoScale, logoScale)
setColor255(74, 88, 72) setColor255(74, 88, 72)
printCentered("FIRST RUN", height * 0.205, smallFont, width) printCentered(self.returning and "UPDATE REQUIRED" or "FIRST RUN",
height * 0.205, smallFont, width)
local zoneY, zoneH = height * 0.29, math.min(180, height * 0.31) local zoneY, zoneH = height * 0.29, math.min(180, height * 0.31)
setColor255(215, 220, 202) setColor255(215, 220, 202)
+4
View File
@@ -11,6 +11,10 @@ local function isBadge(id)
return id:find("BADGE", 1, true) ~= nil return id:find("BADGE", 1, true) ~= nil
end end
-- exported so item lists that share save.inventory (e.g. the PC deposit
-- menu) can exclude badges the same way the bag does
Bag.isBadge = isBadge
function Bag.slots(save) function Bag.slots(save)
local n = 0 local n = 0
for id in pairs(save.inventory) do for id in pairs(save.inventory) do
+3 -1
View File
@@ -129,7 +129,9 @@ end
-- tripwire on for tests that cannot set the environment. -- tripwire on for tests that cannot set the environment.
function Loader.new(opts) function Loader.new(opts)
local dev = opts and opts.dev local dev = opts and opts.dev
if dev == nil then dev = os.getenv("POKEPORT_DEV") == "1" end if dev == nil then
dev = os.getenv("POKEPORT_DEV") == "1" or _G.POKEPORT_DEV_MODE == true
end
local self = setmetatable({ local self = setmetatable({
mods = {}, loaded = {}, errors = {}, initialized = false, mods = {}, loaded = {}, errors = {}, initialized = false,
events = Events.new(), hooks = Hooks.new(), content = {}, assets = {}, events = Events.new(), hooks = Hooks.new(), content = {}, assets = {},
+6 -1
View File
@@ -9,6 +9,7 @@
-- "trade" | "manual" | <mod>, item = id?, ... }), wrapped by the -- "trade" | "manual" | <mod>, item = id?, ... }), wrapped by the
-- evolution.check hook so a mod can cancel or force any evolution. -- evolution.check hook so a mod can cancel or force any evolution.
local Music = require("src.core.Music")
local Runtime = require("src.mods.Runtime") local Runtime = require("src.mods.Runtime")
local Screens = require("src.ui.Screens") local Screens = require("src.ui.Screens")
local Stats = require("src.pokemon.Stats") local Stats = require("src.pokemon.Stats")
@@ -112,11 +113,15 @@ function Evolution.evolve(game, mon, newSpecies, onDone, via)
Screens.push(game, "EvolutionState", mon, newSpecies, onDone) Screens.push(game, "EvolutionState", mon, newSpecies, onDone)
return return
end end
Music.play(game.data, Music.special(game.data, "evolution"))
local oldName = mon.nickname or game.data.pokemon[mon.species].name local oldName = mon.nickname or game.data.pokemon[mon.species].name
Evolution.apply(game, mon, newSpecies, via) Evolution.apply(game, mon, newSpecies, via)
local msg = ("What?\n%s is\nevolving!\fCongratulations!\nYour %s\nevolved into\n%s!") local msg = ("What?\n%s is\nevolving!\fCongratulations!\nYour %s\nevolved into\n%s!")
:format(oldName, oldName, game.data.pokemon[newSpecies].name) :format(oldName, oldName, game.data.pokemon[newSpecies].name)
game.stack:push(TextBox.new(game, msg, onDone)) game.stack:push(TextBox.new(game, msg, function()
Music.restoreMap(game.data)
if onDone then onDone() end
end))
end end
-- Entry point for mods whose methods fire outside the vanilla moments -- Entry point for mods whose methods fire outside the vanilla moments
+8 -2
View File
@@ -9,12 +9,18 @@ local Pokemon = {}
-- keeping the most recent four (engine/pokemon/learn_move.asm behavior). -- keeping the most recent four (engine/pokemon/learn_move.asm behavior).
function Pokemon.movesAtLevel(speciesDef, level) function Pokemon.movesAtLevel(speciesDef, level)
local moves = {} local moves = {}
local function add(id)
for _, existing in ipairs(moves) do
if existing == id then return end
end
table.insert(moves, id)
end
for _, m in ipairs(speciesDef.level1Moves) do for _, m in ipairs(speciesDef.level1Moves) do
table.insert(moves, m) add(m)
end end
for _, entry in ipairs(speciesDef.learnset) do for _, entry in ipairs(speciesDef.learnset) do
if entry.level <= level then if entry.level <= level then
table.insert(moves, entry.move) add(entry.move)
end end
end end
while #moves > 4 do while #moves > 4 do
+77
View File
@@ -141,6 +141,54 @@ function PaletteFX.usesGbcPack(mode)
return mode == "redpp" return mode == "redpp"
end end
-- Per-object overworld sprite coloring (ColorOverworldSprite) applies in
-- plain GBC mode too, not only under the RED++ pack: without it the
-- whole-map zone shader paints characters with whatever two mid shades
-- the terrain palette defines. RED++ handles sprites through the baked
-- usesGbcPack() path in SpriteRenderer; this names the modes where the
-- OBP bake plus the post-zone redraw (below) stand in for real OBJ
-- palettes over a shader-colorized background.
function PaletteFX.usesSpriteObp(mode)
mode = mode or PaletteFX.mode
return mode == "gbc"
end
-- ------- post-zone sprite redraw (GBC mode)
--
-- In GBC mode the world canvas still runs through the per-map zone
-- shade-remap shader, which would corrupt an OBP-baked sprite's true-color
-- pixels. So SpriteRenderer draws the baked sprite into the canvas (its
-- pixels come out zone-tinted there) AND records the draw here;
-- Renderer:endFrame replays the list on top of the finished zone pass,
-- scaled into screen space -- the GBC's OBJ-over-BG compositing, one draw
-- late. Entries carrying `colors` are re-colorized draws (the tall-grass
-- feet overdraw, which must keep hiding sprite feet) issued through the
-- color-0-keyed shade-remap shader. World pass only; cleared per frame.
local spriteRedraws = {}
function PaletteFX.clearSpriteRedraws()
for i = #spriteRedraws, 1, -1 do spriteRedraws[i] = nil end
end
function PaletteFX.markSpriteRedraw(image, quad, x, y, sx, colors, keyed)
if currentPass ~= "world" then return end
spriteRedraws[#spriteRedraws + 1] =
{ image = image, quad = quad, x = x, y = y, sx = sx or 1,
colors = colors, keyed = keyed }
end
-- whether a draw issued right now would land in the redraw list -- the
-- OBP bake is only correct when the replay can restore it after the zone
-- pass (tilt's upright pass colorizes per-billboard instead, so sprites
-- there keep the raw sheet)
function PaletteFX.spriteRedrawPassActive()
return currentPass == "world"
end
function PaletteFX.spriteRedraws()
return spriteRedraws
end
-- Active named-palette table for COLORS: RED++ uses data/palettes_gbc.lua, -- Active named-palette table for COLORS: RED++ uses data/palettes_gbc.lua,
-- everything else uses the ROM-imported data.palettes. -- everything else uses the ROM-imported data.palettes.
function PaletteFX.pack(data) function PaletteFX.pack(data)
@@ -223,6 +271,29 @@ local TILE_GROUP_EXCEPTIONS = {
CELADON_MART_1F = { tiles = { [0x07] = true, [0x08] = true, CELADON_MART_1F = { tiles = { [0x07] = true, [0x08] = true,
[0x17] = true, [0x18] = true }, group = 4 }, [0x17] = true, [0x18] = true }, group = 4 },
} }
-- keyed by tileset id (applies on every map that uses it), consulted after
-- the per-map table above
local TILESET_GROUP_EXCEPTIONS = {
-- tile $22 (the hollow-square grave marker) -> GRAY: the extracted pack
-- files it under the bright blue family, which makes a purely
-- decorative floor marker read as an interactive pad
CEMETERY = { tiles = { [0x22] = true }, group = 0 },
}
-- pokered-gbc's lobby.bst repoints the Celadon roof table's flat top
-- (block 29, cells 5/6/9/10) at a duplicate tile ($5a, BROWN) so the
-- tabletop and the checkerboard floor -- both raw tile $37 -- can take
-- different palettes; the vanilla-derived blockset shares the one tile
-- id, so the RED++ atlas path re-creates the duplicate: the alias slot
-- is baked as a copy of `tile` in `group`'s colors, and the listed
-- 0-based block cells draw the alias instead of the shared tile.
PaletteFX.TILE_ALIASES = {
CELADON_MART_ROOF = {
{ block = 29, cells = { [5] = true, [6] = true, [9] = true, [10] = true },
tile = 0x37, alias = 0x5a, group = 5 },
},
}
local ROOF_GROUP = 6 local ROOF_GROUP = 6
local ROUTE_6_SAFFRON = { mapId = "ROUTE_6", useMapId = "SAFFRON_CITY", cellYBelow = 2 } local ROUTE_6_SAFFRON = { mapId = "ROUTE_6", useMapId = "SAFFRON_CITY", cellYBelow = 2 }
@@ -244,6 +315,8 @@ function PaletteFX.worldGroupAt(tileset, mapId, tileId)
if not groups then return nil end if not groups then return nil end
local exc = TILE_GROUP_EXCEPTIONS[mapId] local exc = TILE_GROUP_EXCEPTIONS[mapId]
if exc and exc.tiles[tileId] then return exc.group end if exc and exc.tiles[tileId] then return exc.group end
exc = TILESET_GROUP_EXCEPTIONS[tileset]
if exc and exc.tiles[tileId] then return exc.group end
return groups[tileId] or 7 -- TEXT: tile ids past the tileset's 96 (menus) return groups[tileId] or 7 -- TEXT: tile ids past the tileset's 96 (menus)
end end
@@ -293,6 +366,10 @@ function PaletteFX.spriteObp(spriteDef, seed)
local src = spriteDef and spriteDef.source local src = spriteDef and spriteDef.source
if not (w and src) then return nil end if not (w and src) then return nil end
local idx = tonumber(src:match("%[(%d+)%]")) local idx = tonumber(src:match("%[(%d+)%]"))
-- RedBikeSprite loads outside SpriteSheetPointerTable
-- (LoadBikePlayerSpriteGraphics), so its source carries no bracketed
-- index; it wears the player's own palette, same as SPRITE_RED
if not idx and src:find("RedBikeSprite", 1, true) then idx = 0 end
local group = idx and w.spriteAssignment[idx] local group = idx and w.spriteAssignment[idx]
if group == nil then return nil end if group == nil then return nil end
if group == "random" then if group == "random" then
+32 -1
View File
@@ -62,8 +62,10 @@ end
function Renderer:beginFrame(transparent) function Renderer:beginFrame(transparent)
self.worldActive = false self.worldActive = false
self.uprightActive = false self.uprightActive = false
-- last frame's trueColor rects go before anything draws this one -- last frame's trueColor rects and sprite redraws go before anything
-- draws this one
PaletteFX.clearTrueColor() PaletteFX.clearTrueColor()
PaletteFX.clearSpriteRedraws()
PaletteFX.setPass("ui") PaletteFX.setPass("ui")
love.graphics.setCanvas(self.canvas) love.graphics.setCanvas(self.canvas)
if transparent then if transparent then
@@ -363,6 +365,35 @@ function Renderer:endFrame(zones, worldZones)
else else
blit(self.worldCanvas, s, zones, S, wox, woy, 0, 0, ww, wh) blit(self.worldCanvas, s, zones, S, wox, woy, 0, 0, ww, wh)
end end
-- OBP-baked overworld sprites replay on top of the zone pass (GBC
-- mode per-object coloring; see PaletteFX.markSpriteRedraw). Grass
-- feet-overdraw entries carry `colors` and re-colorize through the
-- color-0-keyed shade-remap shader so they keep hiding sprite feet.
local redraws = PaletteFX.spriteRedraws()
if redraws[1] then
love.graphics.setColor(1, 1, 1, 1)
love.graphics.setScissor(0, 0, ww, wh)
local activeShader = nil
for _, r in ipairs(redraws) do
local wanted = r.colors
and (r.keyed and PaletteFX.keyedShader() or PaletteFX.shader())
or nil
if wanted ~= activeShader then
activeShader = wanted
love.graphics.setShader(wanted)
end
if wanted then PaletteFX.sendColors(wanted, r.colors) end
if r.quad then
love.graphics.draw(r.image, r.quad, wox + r.x * s, woy + r.y * s,
0, s * r.sx, s)
else
love.graphics.draw(r.image, wox + r.x * s, woy + r.y * s,
0, s * r.sx, s)
end
end
if activeShader then love.graphics.setShader() end
love.graphics.setScissor()
end
end end
-- Composite the tilt upright pass over the ground (projected or, in the -- Composite the tilt upright pass over the ground (projected or, in the
-- rare no-shader fallback, flat). It already carries its billboards' -- rare no-shader fallback, flat). It already carries its billboards'
+22 -6
View File
@@ -85,10 +85,21 @@ end
-- facing: down/up/left/right; walkPhase: 0 stand, 1 walk; flip: alternate -- facing: down/up/left/right; walkPhase: 0 stand, 1 walk; flip: alternate
-- steps mirror the walk frame for up/down (GB uses OAM flip for this). -- steps mirror the walk frame for up/down (GB uses OAM flip for this).
local function blitFrame(image, quad, x, y, flip, redraw)
if flip then
love.graphics.draw(image, quad, x + 16, y, 0, -1, 1)
if redraw then PaletteFX.markSpriteRedraw(image, quad, x + 16, y, -1) end
else
love.graphics.draw(image, quad, x, y)
if redraw then PaletteFX.markSpriteRedraw(image, quad, x, y, 1) end
end
end
function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip) function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip)
local x = math.floor(px - camX) local x = math.floor(px - camX)
local y = math.floor(py - camY) - 4 local y = math.floor(py - camY) - 4
local image = self.image local image = self.image
local redraw = false
-- full-color art claims its 16x16 cell out of the shade-remap pass -- full-color art claims its 16x16 cell out of the shade-remap pass
if self.def.trueColor then if self.def.trueColor then
PaletteFX.markTrueColor(x, y, 16, 16) PaletteFX.markTrueColor(x, y, 16, 16)
@@ -102,12 +113,21 @@ function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip)
if colors then if colors then
image = getObpImage(self.def.image, colors, group) image = getObpImage(self.def.image, colors, group)
end end
elseif PaletteFX.usesSpriteObp() and PaletteFX.spriteRedrawPassActive() then
-- plain GBC: the terrain zone shader still runs over the world canvas,
-- so the baked sprite is also queued for a post-zone redraw
-- (PaletteFX.markSpriteRedraw) that restores its own OBP colors on top
local colors, group = PaletteFX.spriteObp(self.def, self.seed)
if colors then
image = getObpImage(self.def.image, colors, group)
redraw = true
end
end end
-- single-frame sprites (item balls, fossils...) have one fixed pose; -- single-frame sprites (item balls, fossils...) have one fixed pose;
-- still 3-frame sprites turn to face (the nurse at her machine, -- still 3-frame sprites turn to face (the nurse at her machine,
-- facePlayer on STAY NPCs) but never show walk frames -- facePlayer on STAY NPCs) but never show walk frames
if self.def.frames <= 1 then if self.def.frames <= 1 then
love.graphics.draw(image, self.frames[0], x, y) blitFrame(image, self.frames[0], x, y, false, redraw)
return return
end end
local frame = (self.def.walker and walkPhase == 1) local frame = (self.def.walker and walkPhase == 1)
@@ -119,11 +139,7 @@ function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip)
flip = true flip = true
end end
local quad = self.frames[frame] or self.frames[0] local quad = self.frames[frame] or self.frames[0]
if flip then blitFrame(image, quad, x, y, flip, redraw)
love.graphics.draw(image, quad, x + 16, y, 0, -1, 1)
else
love.graphics.draw(image, quad, x, y)
end
end end
return SpriteRenderer return SpriteRenderer
+63 -3
View File
@@ -347,9 +347,10 @@ local function getGbcAtlas(imagePath, tilesetId, mapId, perRow, data)
if groupColors then if groupColors then
local src = Assets.imageData(imagePath) local src = Assets.imageData(imagePath)
local iw, ih = src:getDimensions() local iw, ih = src:getDimensions()
local total = (iw / 8) * (ih / 8)
local out = love.image.newImageData(iw, ih) local out = love.image.newImageData(iw, ih)
local tileColors = {} local tileColors = {}
for t = 0, (iw / 8) * (ih / 8) - 1 do for t = 0, total - 1 do
local colors = tileColors[t] local colors = tileColors[t]
if colors == nil then if colors == nil then
local group = PaletteFX.worldGroupAt(tilesetId, mapId, t) local group = PaletteFX.worldGroupAt(tilesetId, mapId, t)
@@ -366,6 +367,25 @@ local function getGbcAtlas(imagePath, tilesetId, mapId, perRow, data)
end end
end end
end end
-- duplicate-tile aliases: bake a copy of a shared tile graphic into
-- a spare slot under a different palette group, so block cells that
-- draw the alias can color apart from cells sharing the raw tile
for _, al in ipairs(PaletteFX.TILE_ALIASES and PaletteFX.TILE_ALIASES[mapId] or {}) do
if al.alias < total then
local colors = groupColors[al.group + 1]
local sxo = (al.tile % perRow) * 8
local syo = math.floor(al.tile / perRow) * 8
local dxo = (al.alias % perRow) * 8
local dyo = math.floor(al.alias / perRow) * 8
for py = 0, 7 do
for px = 0, 7 do
local r, g, b, a = src:getPixel(sxo + px, syo + py)
r, g, b, a = recolorSample(r, g, b, a, colors)
out:setPixel(dxo + px, dyo + py, r, g, b, a)
end
end
end
end
img = love.graphics.newImage(out) img = love.graphics.newImage(out)
end end
end end
@@ -429,14 +449,38 @@ function TileRenderer.new(map, data)
end end
end end
-- duplicate-tile alias remap (RED++ atlas only): [blockId][0-based cell]
-- -> alias tile id (see PaletteFX.TILE_ALIASES / getGbcAtlas's bake)
local aliasMap
if gbcCtx then
for _, al in ipairs(PaletteFX.TILE_ALIASES and PaletteFX.TILE_ALIASES[map.id] or {}) do
aliasMap = aliasMap or {}
local cells = aliasMap[al.block] or {}
for ci in pairs(al.cells) do cells[ci] = al.alias end
aliasMap[al.block] = cells
end
end
for by = -BORDER_BLOCKS, hB + BORDER_BLOCKS - 1 do for by = -BORDER_BLOCKS, hB + BORDER_BLOCKS - 1 do
for bx = -BORDER_BLOCKS, wB + BORDER_BLOCKS - 1 do for bx = -BORDER_BLOCKS, wB + BORDER_BLOCKS - 1 do
local inside = bx >= 0 and by >= 0 and bx < wB and by < hB local inside = bx >= 0 and by >= 0 and bx < wB and by < hB
local batch = inside and self.mapBatch or self.ringBatch local batch = inside and self.mapBatch or self.ringBatch
local block = map.tileset.blocks[map:blockAt(bx, by) + 1] -- beyond-edge ring cells use the same override drawBorderFill does,
-- so the ring and the far background fill agree (OVERWORLD maps
-- whose raw border_block is water still ring with the tree wall)
local blockId = inside and map:blockAt(bx, by) or borderBlockFor(map)
local block = map.tileset.blocks[blockId + 1]
if not block then
-- a tileset without the tree-wall block keeps its own border
blockId = map:blockAt(bx, by)
block = map.tileset.blocks[blockId + 1]
end
local remap = aliasMap and aliasMap[blockId]
for ty = 0, 3 do for ty = 0, 3 do
for tx = 0, 3 do for tx = 0, 3 do
local tile = block[ty * 4 + tx + 1] local ci = ty * 4 + tx
local tile = block[ci + 1]
if remap and remap[ci] then tile = remap[ci] end
local quad = self.quads[tile] local quad = self.quads[tile]
if quad then if quad then
batch:add(quad, bx * 32 + tx * 8, by * 32 + ty * 8) batch:add(quad, bx * 32 + tx * 8, by * 32 + ty * 8)
@@ -556,6 +600,22 @@ function TileRenderer:drawCellBottom(cx, cy, camX, camY)
if shader then love.graphics.setShader() end if shader then love.graphics.setShader() end
end end
-- queue the same bottom tile row for the post-zone sprite-redraw pass
-- (GBC mode: OBP-baked sprites replay after the zone shader, so the
-- grass patch that hides their feet must replay over them, colorized
-- with the map's palette and color-0 keyed)
function TileRenderer:markCellBottomRedraw(cx, cy, camX, camY, colors)
local ty = cy * 2 + 1
for i = 0, 1 do
local tx = cx * 2 + i
local quad = self.quads[self.map:tileAt(tx, ty)]
if quad then
PaletteFX.markSpriteRedraw(self.image, quad, tx * 8 - math.floor(camX),
ty * 8 - math.floor(camY), 1, colors, true)
end
end
end
-- animated overdraw at the current step; bodyOnly skips the ring -- animated overdraw at the current step; bodyOnly skips the ring
-- positions (connected maps draw body-only) -- positions (connected maps draw body-only)
function TileRenderer:drawAnimated(camX, camY, bodyOnly) function TileRenderer:drawAnimated(camX, camY, bodyOnly)
+16 -4
View File
@@ -5,6 +5,7 @@
-- Conditionals: check_flag stores its result in ctx.lastCheck; -- Conditionals: check_flag stores its result in ctx.lastCheck;
-- jump_if_true/jump_if_false jump to an absolute row index. -- jump_if_true/jump_if_false jump to an absolute row index.
local Collision = require("src.world.Collision")
local Flags = require("src.script.Flags") local Flags = require("src.script.Flags")
local Logger = require("src.core.Logger") local Logger = require("src.core.Logger")
local Screens = require("src.ui.Screens") local Screens = require("src.ui.Screens")
@@ -269,7 +270,10 @@ end
local DIRS4 = { { 0, -1, "up" }, { 0, 1, "down" }, local DIRS4 = { { 0, -1, "up" }, { 0, 1, "down" },
{ -1, 0, "left" }, { 1, 0, "right" } } { -1, 0, "left" }, { 1, 0, "right" } }
local function bfsPath(map, sx, sy, tx, ty) -- entities/mover let the walk route around other entities' cells (the
-- player especially), so a scripted NPC never clips through them; the
-- target cell is exempt so an NPC can still walk up onto its goal
local function bfsPath(map, sx, sy, tx, ty, entities, mover)
local key = function(x, y) return y * 1000 + x end local key = function(x, y) return y * 1000 + x end
local prev = { [key(sx, sy)] = false } local prev = { [key(sx, sy)] = false }
local queue = { { sx, sy } } local queue = { { sx, sy } }
@@ -289,8 +293,11 @@ local function bfsPath(map, sx, sy, tx, ty)
for _, d in ipairs(DIRS4) do for _, d in ipairs(DIRS4) do
local nx, ny = cx + d[1], cy + d[2] local nx, ny = cx + d[1], cy + d[2]
local nk = key(nx, ny) local nk = key(nx, ny)
local isTarget = nx == tx and ny == ty
if prev[nk] == nil and map:inBounds(nx, ny) if prev[nk] == nil and map:inBounds(nx, ny)
and (map:isWalkableCell(nx, ny) or (nx == tx and ny == ty)) then and (map:isWalkableCell(nx, ny) or isTarget)
and (isTarget or not entities
or not Collision.occupied(entities, nx, ny, mover)) then
prev[nk] = { cx, cy, d[3] } prev[nk] = { cx, cy, d[3] }
table.insert(queue, { nx, ny }) table.insert(queue, { nx, ny })
end end
@@ -303,7 +310,7 @@ function Commands.move_npc_to(ctx, objIndex, tx, ty)
local ow = ctx.overworld local ow = ctx.overworld
local npc = ow:npcByIndex(objIndex) local npc = ow:npcByIndex(objIndex)
if not npc then return end if not npc then return end
local path = bfsPath(ow.map, npc.cellX, npc.cellY, tx, ty) local path = bfsPath(ow.map, npc.cellX, npc.cellY, tx, ty, ow.entities, npc)
if not path then if not path then
Logger.warn("move_npc_to: no path to (%d,%d)", tx, ty) Logger.warn("move_npc_to: no path to (%d,%d)", tx, ty)
return return
@@ -823,7 +830,12 @@ end
-- registry and block until the screen pops itself -- registry and block until the screen pops itself
function Commands.push_screen(ctx, screenId, args) function Commands.push_screen(ctx, screenId, args)
local screens = ctx.game.data.screens local screens = ctx.game.data.screens
if not (screens and screens[screenId]) then local known = screens and screens[screenId] ~= nil
if not known then
-- builtin engine screens (src/ui/<id>.lua) resolve through Screens too
known = pcall(Screens.get, ctx.game, screenId)
end
if not known then
error(("push_screen: unknown screen '%s'"):format(tostring(screenId)), 0) error(("push_screen: unknown screen '%s'"):format(tostring(screenId)), 0)
end end
local runner = ctx.runner local runner = ctx.runner
+3
View File
@@ -5,6 +5,7 @@
-- has no cancel; the flash always completes). -- has no cancel; the flash always completes).
local Font = require("src.render.Font") local Font = require("src.render.Font")
local Music = require("src.core.Music")
local EvolutionState = {} local EvolutionState = {}
EvolutionState.__index = EvolutionState EvolutionState.__index = EvolutionState
@@ -39,6 +40,7 @@ function EvolutionState.new(game, mon, newSpecies, onDone)
self.newSprite = frontSprite(game, newSpecies) self.newSprite = frontSprite(game, newSpecies)
self.t = 0 self.t = 0
self.done = false self.done = false
Music.play(game.data, Music.special(game.data, "evolution"))
return self return self
end end
@@ -57,6 +59,7 @@ function EvolutionState:update(dt)
("Congratulations!\nYour %s\nevolved into\n%s!") ("Congratulations!\nYour %s\nevolved into\n%s!")
:format(self.oldName, newName), :format(self.oldName, newName),
function() function()
Music.restoreMap(game.data)
game.stack:pop() -- the evolution screen itself game.stack:pop() -- the evolution screen itself
if self.onDone then self.onDone() end if self.onDone then self.onDone() end
end)) end))
+53 -3
View File
@@ -16,6 +16,11 @@ local OakSpeech = {}
OakSpeech.__index = OakSpeech OakSpeech.__index = OakSpeech
OakSpeech.isOpaque = true OakSpeech.isOpaque = true
-- FadeInIntroPic runs a 6-step palette fade; MovePicLeft wipes the mon
-- sprite in from the right. Both play out before the beat's text prints.
local FADE_FRAMES = 24
local WIPE_FRAMES = 32
-- naming presets are boot config (field.boot.namePresets), which a total -- naming presets are boot config (field.boot.namePresets), which a total
-- conversion replaces; the Red/Blue lists remain the fallback -- conversion replaces; the Red/Blue lists remain the fallback
local function namePresets(game, who, fallback) local function namePresets(game, who, fallback)
@@ -93,16 +98,23 @@ function OakSpeech:say(key, next)
end end
local STEPS = { local STEPS = {
-- 1. Oak's welcome -- 1. Oak's welcome (Oak's pic fades in first, FadeInIntroPic)
function(self) function(self)
self.pic = self.oakPic self.pic = self.oakPic
self:revealPic("fade", function()
self:say("_OakSpeechText1", function() self:advance() end) self:say("_OakSpeechText1", function() self:advance() end)
end)
end, end,
-- 2. NIDORINO show-off, with its cry -- 2. NIDORINO show-off: the front sprite is mirrored horizontally
-- (LoadFlippedFrontSpriteByMonIndex) and wipes in from the right
-- (MovePicLeft), then its cry sounds and the text prints
function(self) function(self)
self.pic = self.demoPic self.pic = self.demoPic
self.picFlip = true
self:revealPic("wipe", function()
Sound.playCry(self.game.data, self.demoSpecies) Sound.playCry(self.game.data, self.demoSpecies)
self:say("_OakSpeechText2A", function() self:advance() end) self:say("_OakSpeechText2A", function() self:advance() end)
end)
end, end,
-- 3. the rest of the world-of-POKéMON spiel -- 3. the rest of the world-of-POKéMON spiel
function(self) function(self)
@@ -175,8 +187,21 @@ function OakSpeech:lastPageLines(key)
return ok and lines or nil return ok and lines or nil
end end
-- Reveal the current pic over `dur` frames, then run `next`. Ticked from
-- update() while OakSpeech is the top state (before the beat's text box is
-- pushed), so the fade/wipe plays out ahead of the text like the ROM.
function OakSpeech:revealPic(kind, next)
self.picReveal = {
kind = kind,
t = 0,
dur = kind == "fade" and FADE_FRAMES or WIPE_FRAMES,
next = next,
}
end
function OakSpeech:advance() function OakSpeech:advance()
self.step = self.step + 1 self.step = self.step + 1
self.picFlip = false
local fn = STEPS[self.step] local fn = STEPS[self.step]
if fn then if fn then
fn(self) fn(self)
@@ -205,6 +230,15 @@ end
-- ClearScreenArea / wUpdateSpritesEnabled; ld c, 50) -- ClearScreenArea / wUpdateSpritesEnabled; ld c, 50)
-- frames 79-102 GBFadeOutToWhite (3 palettes x 8 frames) -- frames 79-102 GBFadeOutToWhite (3 palettes x 8 frames)
function OakSpeech:update(dt) function OakSpeech:update(dt)
local r = self.picReveal
if r then
r.t = r.t + 1
if r.t >= r.dur then
self.picReveal = nil
if r.next then r.next() end
end
return
end
if not self.shrink then return end if not self.shrink then return end
local s = self.shrink local s = self.shrink
s.frame = s.frame + 1 s.frame = s.frame + 1
@@ -238,7 +272,23 @@ function OakSpeech:draw()
local w, h = self.pic:getDimensions() local w, h = self.pic:getDimensions()
local x = 48 + math.floor((8 - w / 8) / 2) * 8 local x = 48 + math.floor((8 - w / 8) / 2) * 8
local y = 32 + (7 - h / 8) * 8 local y = 32 + (7 - h / 8) * 8
love.graphics.draw(self.pic, x, y) local reveal = self.picReveal
local off = 0
if reveal and reveal.kind == "fade" then
-- FadeInIntroPic: ramp the pic's alpha up over the fade window
love.graphics.setColor(1, 1, 1, math.min(1, reveal.t / reveal.dur))
elseif reveal and reveal.kind == "wipe" then
-- MovePicLeft: the pic slides in from the right edge to its spot
off = math.floor((160 - x) * (1 - math.min(1, reveal.t / reveal.dur)))
end
if self.picFlip then
-- LoadFlippedFrontSpriteByMonIndex mirrors the front sprite
-- horizontally (wSpriteFlipped): draw with a negative x scale
love.graphics.draw(self.pic, x + off + w, y, 0, -1, 1)
else
love.graphics.draw(self.pic, x + off, y)
end
love.graphics.setColor(1, 1, 1, 1)
end end
if self.walkVisible and self.walkSheet then if self.walkVisible and self.walkSheet then
-- ResetPlayerSpriteData: Y screen pos $3c, X screen pos $40 -- ResetPlayerSpriteData: Y screen pos $3c, X screen pos $40
+7 -1
View File
@@ -99,7 +99,13 @@ end
local function deposit(game) local function deposit(game)
local pc = game.save.pcItems local pc = game.save.pcItems
local inv = game.save.inventory local inv = game.save.inventory
game.stack:push(ListMenu.new(game, "DEPOSIT ITEM", buildItems(game, inv), { local Bag = require("src.inventory.Bag")
-- badges live in save.inventory alongside items but are not depositable
local depositable = {}
for id, count in pairs(inv) do
if not Bag.isBadge(id) then depositable[id] = count end
end
game.stack:push(ListMenu.new(game, "DEPOSIT ITEM", buildItems(game, depositable), {
onChoose = function(item, list) onChoose = function(item, list)
askQuantity(game, list, inv[item.value] or 1, item.value, function(qty) askQuantity(game, list, inv[item.value] or 1, item.value, function(qty)
if pcFull(game, pc, item.value) then if pcFull(game, pc, item.value) then
+19
View File
@@ -4,6 +4,7 @@
-- item list runs through the ui.start_menu.items hook before the menu -- item list runs through the ui.start_menu.items hook before the menu
-- opens, so mods insert or remove rows without patching this file. -- opens, so mods insert or remove rows without patching this file.
local Font = require("src.render.Font")
local Logger = require("src.core.Logger") local Logger = require("src.core.Logger")
local Menu = require("src.ui.Menu") local Menu = require("src.ui.Menu")
local Runtime = require("src.mods.Runtime") local Runtime = require("src.mods.Runtime")
@@ -126,6 +127,24 @@ function StartMenu.new(game)
baseUpdate(self, dt) baseUpdate(self, dt)
game.save.startMenuIndex = self.index game.save.startMenuIndex = self.index
end end
-- inside the Safari Zone the start menu also shows remaining steps and
-- SAFARI BALLs (PrintSafariZoneSteps): a 9x5 border at the top-left with
-- "steps/500" and "BALL xx"
if game.save.safari then
local baseDraw = menu.draw
menu.draw = function(self)
baseDraw(self)
local safari = game.save.safari
Font.drawBox(0, 0, 9, 5)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(("%3d"):format(math.floor(safari.steps or 0)), 8, 8)
Font.draw("/500", 32, 8)
Font.draw("BALL", 8, 24)
Font.draw(("%2d"):format(math.floor(safari.balls or 0)), 48, 24)
love.graphics.setColor(1, 1, 1, 1)
end
end
return menu return menu
end end
+17
View File
@@ -24,6 +24,15 @@ local NO_SHORE_TILESETS = { SHIP_PORT = true }
-- what counts as "outside" for the wLastMap memory (CheckIfInOutsideMap) -- what counts as "outside" for the wLastMap memory (CheckIfInOutsideMap)
local OUTSIDE_TILESETS = { "OVERWORLD", "PLATEAU" } local OUTSIDE_TILESETS = { "OVERWORLD", "PLATEAU" }
-- warp pads and fall-through holes (data/tilesets/warp_pad_hole_tile_ids
-- .asm WarpPadAndHoleData); a tileset record carrying warpPadTiles
-- ({ [tileId] = "pad"|"hole" }) wins over these vanilla rows
local WARP_PAD_TILES = {
FACILITY = { [0x20] = "pad", [0x11] = "hole" },
CAVERN = { [0x22] = "hole" },
INTERIOR = { [0x55] = "pad" },
}
local function hashSet(list, into) local function hashSet(list, into)
for _, t in ipairs(list) do into[t] = true end for _, t in ipairs(list) do into[t] = true end
return into return into
@@ -199,6 +208,14 @@ function Map:isWarpTileCell(cx, cy)
return self.doorTiles[t] or self.warpTiles[t] or false return self.doorTiles[t] or self.warpTiles[t] or false
end end
-- "pad"/"hole" when the cell's collision tile is a teleporter warp pad or
-- a fall-through hole (IsPlayerStandingOnWarpPadOrHole), nil otherwise
function Map:warpPadOrHoleAt(cx, cy)
local table_ = self.tileset.warpPadTiles or WARP_PAD_TILES[self.def.tileset]
if not table_ then return nil end
return table_[self:cellTile(cx, cy)]
end
-- counter tiles allow talking to NPCs across them (mart clerks, nurses) -- counter tiles allow talking to NPCs across them (mart clerks, nurses)
function Map:isCounterCell(cx, cy) function Map:isCounterCell(cx, cy)
local t = self:cellTile(cx, cy) local t = self:cellTile(cx, cy)
+288 -17
View File
@@ -40,6 +40,10 @@ local HEAL_BALL_XY = {
{ 40, 37 }, { 48, 37, true }, { 40, 37 }, { 48, 37, true },
} }
-- the healing machine's flash beat (FlashSprite8Times: rOBP1 ^= $28)
-- swaps the two middle shades of the monitor/ball art in place
local HEAL_FLASH_MAP = { [0] = 0, [1] = 2, [2] = 1, [3] = 3 }
-- object_event spawn filter (toggleable_objects, items taken, beaten -- object_event spawn filter (toggleable_objects, items taken, beaten
-- static encounters), shared by the current map's real NPCs and the -- static encounters), shared by the current map's real NPCs and the
-- visual-only ghosts on connected neighbor maps -- visual-only ghosts on connected neighbor maps
@@ -91,11 +95,25 @@ local NEIGHBOR_HOPS = 2
-- (32 px), the same alignment the connection macro encodes -- (32 px), the same alignment the connection macro encodes
-- (macros/scripts/maps.asm: _x = offset * -2 walk cells for -- (macros/scripts/maps.asm: _x = offset * -2 walk cells for
-- north/south, _y = offset * -2 for west/east). -- north/south, _y = offset * -2 for west/east).
function OverworldState.computeNeighbors(maps, rootId, hops) -- reachW/reachH (optional, world pixels): with a full zoom-out the view
-- shows far more world than the fixed hop count covers, so any map whose
-- body could overlap the current map's rect inflated by the view
-- half-extents joins the set (and keeps the walk going) regardless of how
-- many connections away it sits -- otherwise far map bodies pop between
-- real tiles and the border filler when a crossing re-roots the BFS.
function OverworldState.computeNeighbors(maps, rootId, hops, reachW, reachH)
local out = {} local out = {}
local rootDef = maps[rootId]
local placed = { [rootId] = true } local placed = { [rootId] = true }
local queue = { { def = maps[rootId], ox = 0, oy = 0, hops = 0 } } local queue = { { def = rootDef, ox = 0, oy = 0, hops = 0 } }
local qi = 1 local qi = 1
local function inReach(def, ox, oy)
if not (reachW and reachH and rootDef) then return false end
return ox + def.width * 32 > -reachW
and ox < rootDef.width * 32 + reachW
and oy + def.height * 32 > -reachH
and oy < rootDef.height * 32 + reachH
end
while queue[qi] do while queue[qi] do
local cur = queue[qi] local cur = queue[qi]
qi = qi + 1 qi = qi + 1
@@ -114,8 +132,9 @@ function OverworldState.computeNeighbors(maps, rootId, hops)
ox, oy = cur.def.width * 32, conn.offset * 32 ox, oy = cur.def.width * 32, conn.offset * 32
end end
ox, oy = cur.ox + ox, cur.oy + oy ox, oy = cur.ox + ox, cur.oy + oy
if cur.hops + 1 <= hops or inReach(destDef, ox, oy) then
table.insert(out, { id = conn.map, ox = ox, oy = oy }) table.insert(out, { id = conn.map, ox = ox, oy = oy })
if cur.hops + 1 < hops then if cur.hops + 1 < hops or inReach(destDef, ox, oy) then
table.insert(queue, table.insert(queue,
{ def = destDef, ox = ox, oy = oy, { def = destDef, ox = ox, oy = oy,
hops = cur.hops + 1 }) hops = cur.hops + 1 })
@@ -123,6 +142,7 @@ function OverworldState.computeNeighbors(maps, rootId, hops)
end end
end end
end end
end
return out return out
end end
@@ -200,6 +220,24 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
self.map.renderer:rebuild() self.map.renderer:rebuild()
self.cutBlocks[mapId] = nil self.cutBlocks[mapId] = nil
end end
-- Silph Co card key doors: the .blk layouts ship with the doorways
-- open; each floor's map script stamps the closed door block on load
-- until its unlock event is set (scripts/SilphCo2F.asm
-- SilphCo2FGateCallbackScript et al., closed blocks $54/$5f/$20)
local closedDoors = FieldDefaults.fieldValue(Game.data, "cardKeyDoors",
"closedDoors")
local floorDoors = closedDoors and closedDoors[mapId]
if floorDoors then
local stamped = false
for _, door in ipairs(floorDoors) do
local want = Game.save.flags[door.event] and door.open or door.block
if self.map:blockAt(door.bx, door.by) ~= want then
self.map:setBlock(door.bx, door.by, want)
stamped = true
end
end
if stamped then self.map.renderer:rebuild() end
end
-- forced dismount only where riding is disallowed (IsBikeRidingAllowed, -- forced dismount only where riding is disallowed (IsBikeRidingAllowed,
-- home/overworld.asm: bike_riding_tilesets.asm tilesets plus the -- home/overworld.asm: bike_riding_tilesets.asm tilesets plus the
-- ROUTE_23/INDIGO_PLATEAU map exceptions) -- ROUTE_23/INDIGO_PLATEAU map exceptions)
@@ -278,6 +316,13 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
self.player.surfing) self.player.surfing)
end end
-- forced bike/surf tiles fire the moment the player is placed on the
-- map, like EnterMap's unconditional CheckForceBikeOrSurf farcall
-- (home/overworld.asm) -- a warp can land directly on one (the Route
-- 16/18 gate exits), and the scripted door-mat walkout that follows
-- suppresses onStepComplete, so waiting for a plain step never mounts
self:checkForcedMovement()
-- snap the camera immediately: the overworld doesn't update while a -- snap the camera immediately: the overworld doesn't update while a
-- Transition is on top, so a stale camera would show the new map at -- Transition is on top, so a stale camera would show the new map at
-- the old scroll position for the whole fade-in -- the old scroll position for the whole fade-in
@@ -299,13 +344,29 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
hooks.onEnter(Game, self) hooks.onEnter(Game, self)
end end
-- neighbor maps drawn at the composed connection offsets, two hops self:rebuildNeighbors()
-- out (the GB only ever streamed a 32px strip of the single Logger.info("map: %s at (%d,%d)", mapId, x, y)
-- directly connected map -- home/overworld.asm .loadNewMap) -- Route22Gate_Script rewrites wLastMap from the player's Y on entry
-- too (not only on step), so a save/load mid-gate keeps exits correct
self:syncLastMapRewrite()
end
-- Neighbor maps drawn at the composed connection offsets: at least the
-- configured hop count out (the GB only ever streamed a 32px strip of
-- the single directly connected map -- home/overworld.asm .loadNewMap),
-- widened to everything the current view size can show so a full
-- zoom-out never runs past the rendered set. Re-run whenever the view
-- grows (zoom/resize), not only on setMap.
function OverworldState:rebuildNeighbors()
local mapId = self.map.id
self.neighbors = {} self.neighbors = {}
local hops = FieldDefaults.world(Game.data, "neighborHops") or NEIGHBOR_HOPS local hops = FieldDefaults.world(Game.data, "neighborHops") or NEIGHBOR_HOPS
for _, n in ipairs(OverworldState.computeNeighbors(Game.data.maps, local vw, vh = Game.renderer:worldViewSize()
mapId, hops)) do self.neighborViewW, self.neighborViewH = vw, vh
for _, n in ipairs(OverworldState.computeNeighbors(Game.data.maps, mapId,
hops,
math.floor(vw / 2) + 64,
math.floor(vh / 2) + 64)) do
table.insert(self.neighbors, table.insert(self.neighbors,
{ map = MapLoader.load(Game.data, n.id), { map = MapLoader.load(Game.data, n.id),
ox = n.ox, oy = n.oy }) ox = n.ox, oy = n.oy })
@@ -328,10 +389,6 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
end end
end end
end end
Logger.info("map: %s at (%d,%d)", mapId, x, y)
-- Route22Gate_Script rewrites wLastMap from the player's Y on entry
-- too (not only on step), so a save/load mid-gate keeps exits correct
self:syncLastMapRewrite()
end end
-- SGB overworld palette (engine/gfx/palettes.asm SetPal_Overworld): -- SGB overworld palette (engine/gfx/palettes.asm SetPal_Overworld):
@@ -607,6 +664,14 @@ function OverworldState:update(dt)
-- keep the player sprite in sync with the bike state (the drawer -- keep the player sprite in sync with the bike state (the drawer
-- picks the red_bike sheet while riding) -- picks the red_bike sheet while riding)
self.player.onBike = Game.save.onBike self.player.onBike = Game.save.onBike
-- the rendered neighbor set depends on the view size; zooming out (or
-- resizing) past what setMap computed re-runs the walk in place
if self.map and (self.neighborViewW or 0) > 0 then
local vw, vh = Game.renderer:worldViewSize()
if vw ~= self.neighborViewW or vh ~= self.neighborViewH then
self:rebuildNeighbors()
end
end
if self.dustAnim then if self.dustAnim then
local da = self.dustAnim local da = self.dustAnim
da.frames = da.frames - 1 da.frames = da.frames - 1
@@ -615,6 +680,14 @@ function OverworldState:update(dt)
if da.onDone then da.onDone() end if da.onDone then da.onDone() end
end end
end end
if self.cutAnim then
local ca = self.cutAnim
ca.frames = ca.frames - 1
if ca.frames <= 0 then
self.cutAnim = nil
if ca.onDone then ca.onDone() end
end
end
if self.healAnim then if self.healAnim then
local ha = self.healAnim local ha = self.healAnim
local Music = require("src.core.Music") local Music = require("src.core.Music")
@@ -704,6 +777,14 @@ function OverworldState:update(dt)
end end
local stepped = self.player:update() local stepped = self.player:update()
-- the warp-arrival cell goes stale the instant the player's real cell
-- leaves it, scripted walk-outs included -- pokered re-checks warps
-- after simulated steps too (CheckWarpsNoCollision), so a forced
-- door-mat exit must not leave the door permanently inert
local entry = self.warpEntryCell
if entry and (self.player.cellX ~= entry.x or self.player.cellY ~= entry.y) then
self.warpEntryCell = nil
end
if stepped and not scripted then if stepped and not scripted then
self:onStepComplete() self:onStepComplete()
end end
@@ -1272,6 +1353,14 @@ function OverworldState:tryHiddenObject(fx, fy)
end end
end end
-- Bill's cell-separator PC (data/events/hidden_events.asm: hidden_event
-- 1,4 BillsHousePC SPRITE_FACING_UP)
if self.map.id == "BILLS_HOUSE" and fx == 1 and fy == 4
and self.player.facing == "up" then
self:billsHousePC()
return true
end
local extras = field.hiddenExtras local extras = field.hiddenExtras
if not extras then return false end if not extras then return false end
local facing = self.player.facing local facing = self.player.facing
@@ -1354,7 +1443,18 @@ function OverworldState:tryCardKeyDoor(fx, fy)
return true return true
end end
require("src.core.Sound").play(Game.data, "Go_Inside") require("src.core.Sound").play(Game.data, "Go_Inside")
self:replaceBlock(math.floor(fx / 2), math.floor(fy / 2), openBlock) local bx, by = math.floor(fx / 2), math.floor(fy / 2)
self:replaceBlock(bx, by, openBlock)
-- opened doors stay open across reloads (the per-door unlock events
-- the floors' gate callbacks check, EVENT_SILPH_CO_n_UNLOCKED_DOOR*)
local closedDoors = FieldDefaults.fieldValue(Game.data, "cardKeyDoors",
"closedDoors")
for _, door in ipairs(closedDoors and closedDoors[self.map.id] or {}) do
if door.bx == bx and door.by == by then
Game.save.flags[door.event] = true
break
end
end
Game.stack:push(TextBox.new(Game, Game.stack:push(TextBox.new(Game,
(t._CardKeySuccessText1 or "Bingo!") (t._CardKeySuccessText1 or "Bingo!")
.. (t._CardKeySuccessText2 or "\nThe CARD KEY\nopened the door!"))) .. (t._CardKeySuccessText2 or "\nThe CARD KEY\nopened the door!")))
@@ -1456,6 +1556,71 @@ function OverworldState:trashCanSwitch(canIndex)
end end
end end
-- Bill's House PC (engine/events/hidden_events/bills_house_pc.asm
-- BillsHousePC): once Bill-the-Pokémon has climbed into the machine
-- (EVENT_BILL_SAID_USE_CELL_SEPARATOR), running the PC plays the cell
-- separator's SFX sequence, sets EVENT_USED_CELL_SEPARATOR_ON_BILL and
-- Bill steps back out of the machine human again
-- (BillsHouseBillExitsMachineScript / CleanupScript set EVENT_MET_BILL).
function OverworldState:billsHousePC()
local t = Game.data.text
local flags = Game.save.flags
if not (flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR
and not flags.EVENT_USED_CELL_SEPARATOR_ON_BILL) then
Game.stack:push(TextBox.new(Game, t._BillsHouseMonitorText
or "TELEPORTER is\ndisplayed on the\nPC monitor."))
return
end
require("src.core.Music").stop()
Game.stack:push(TextBox.new(Game, t._BillsHouseInitiatedText
or "{PLAYER} initiated\nTELEPORTER's Cell\nSeparator!", function()
flags.EVENT_USED_CELL_SEPARATOR_ON_BILL = true
require("src.core.Sound").play(Game.data, "Switch")
self:queueScript({
{ "wait", 32 },
{ "play_sound", "Tink" },
{ "wait", 80 },
{ "play_sound", "Shrink" },
{ "wait", 48 },
{ "play_sound", "Tink" },
{ "wait", 32 },
{ "play_sound", "Get_Item1" },
{ "wait", 30 },
}, { onDone = function() self:billsHouseBillExits() end })
end))
end
-- BillsHouseBillExitsMachineScript: human Bill appears inside the machine
-- at (1,2) and walks out to his spot at (4,4); the map music resumes and
-- EVENT_MET_BILL / EVENT_MET_BILL_2 arm the SS-Ticket dialogue.
function OverworldState:billsHouseBillExits()
local Commands = require("src.script.Commands")
local ctx = { game = Game, save = Game.save, overworld = self }
Commands.show_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL1")
local function done()
Game.save.flags.EVENT_MET_BILL = true
Game.save.flags.EVENT_MET_BILL_2 = true
require("src.core.Music").playMap(Game.data, self.map.id,
Game.save.onBike, self.player.surfing)
end
local bill
for _, n in ipairs(self.npcs) do
if n.def and n.def.name == "BILLSHOUSE_BILL1" then bill = n break end
end
if not (bill and self.map.id == "BILLS_HOUSE") then
done()
return
end
bill.cellX, bill.cellY = 1, 2
bill.px, bill.py = 16, 32
bill.facing = "down"
self:scriptMove(bill, "down", 1, function()
self:scriptMove(bill, "right", 3, function()
self:scriptMove(bill, "down", 1, done)
end)
end)
end
-- Any hidden item still unfound NEAR the player? (the ITEMFINDER, -- Any hidden item still unfound NEAR the player? (the ITEMFINDER,
-- engine/items/itemfinder.asm HiddenItemNear: coord > clamp0(player-5) -- engine/items/itemfinder.asm HiddenItemNear: coord > clamp0(player-5)
-- and coord <= player+4 (Y) / player+5 (X) -- the clamp excludes -- and coord <= player+4 (Y) / player+5 (X) -- the clamp excludes
@@ -1568,13 +1733,33 @@ function OverworldState:tryCut(fx, fy)
{ bx = bx, by = by, block = block }) { bx = bx, by = by, block = block })
self.map:setBlock(bx, by, swap.after) self.map:setBlock(bx, by, swap.after)
self.map.renderer:rebuild() self.map.renderer:rebuild()
self:startDustAnim(fx, fy, function() local finish = function()
require("src.core.Sound").play(Game.data, "Cut") require("src.core.Sound").play(Game.data, "Cut")
end) end
if ts == "OVERWORLD" then
-- the tree splits in half and slides apart (AnimCut .cutTreeLoop);
-- the GYM plant keeps the shared dust/leaf puff
self:startCutTreeAnim(fx, fy, finish)
else
self:startDustAnim(fx, fy, finish)
end
end)) end))
return true return true
end end
-- The cut-tree split (engine/overworld/cut.asm InitCutAnimOAM +
-- engine/overworld/cut2.asm AnimCut): the tree sprite's top half slides
-- +1px and its bottom half -1px per frame for 8 frames, flickering,
-- before the swapped block shows through. Falls back to the dust puff
-- when the extracted tree sprite is unavailable.
function OverworldState:startCutTreeAnim(cx, cy, onDone)
local fxDef = Game.data.field.overworldFx
if not (fxDef and fxDef.cutTree) then
return self:startDustAnim(cx, cy, onDone)
end
self.cutAnim = { x = cx, y = cy, frames = 8, total = 8, onDone = onDone }
end
-- Party-menu SURF entry (start_sub_menus.asm .surf): badge-check SOULBADGE, -- Party-menu SURF entry (start_sub_menus.asm .surf): badge-check SOULBADGE,
-- farcall IsSurfingAllowed, then UseItem(SURFBOARD) -> ItemUseSurfboard -- farcall IsSurfingAllowed, then UseItem(SURFBOARD) -> ItemUseSurfboard
-- (item_effects.asm), which either tries to dismount (already surfing) or -- (item_effects.asm), which either tries to dismount (already surfing) or
@@ -2805,6 +2990,25 @@ function OverworldState:takeWarp(warpDef)
-- facing carries across the warp (leaving a gate sideways keeps you -- facing carries across the warp (leaving a gate sideways keeps you
-- walking sideways; house exit mats are stepped onto facing down) -- walking sideways; house exit mats are stepped onto facing down)
local facing = self.player.facing local facing = self.player.facing
-- warp pads and fall-through holes are not doors (WarpFound2
-- .indoorMaps: IsPlayerStandingOnWarpPadOrHole routes them through
-- LeaveMapAnim/EnterMapAnim instead of the door SFX)
local pad = self.map.warpPadOrHoleAt
and self.map:warpPadOrHoleAt(self.player.cellX, self.player.cellY)
if pad == "pad" then
-- teleporter: spin out with the exit SFX, spin back in on arrival
-- (player_animations.asm _LeaveMapAnim / EnterMapAnim)
require("src.core.Sound").play(Game.data, "Teleport_Exit1")
self.player.spinning = true
self.player.spinTimer = 0
self.arriveWarp = "teleport"
self:startWarpTo(destMap, x, y, facing)
return
elseif pad == "hole" then
-- falling through a hole: no door SFX, no walk-out step
self:startWarpTo(destMap, x, y, facing)
return
end
self.doorWarp = true -- door SFX + outdoor walk-out step self.doorWarp = true -- door SFX + outdoor walk-out step
self:startWarpTo(destMap, x, y, facing) self:startWarpTo(destMap, x, y, facing)
end end
@@ -2874,6 +3078,13 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts)
require("src.core.Sound").play(Game.data, "Teleport_Enter1") require("src.core.Sound").play(Game.data, "Teleport_Enter1")
-- ENTER_2 caps the spin-down a moment later -- ENTER_2 caps the spin-down a moment later
self.delaySfx = { frames = 40, key = "Teleport_Enter2" } self.delaySfx = { frames = 40, key = "Teleport_Enter2" }
-- the sprite spins down into place (EnterMapAnim
-- PlayerSpinWhileMovingDown), not just the SFX
self.player.spinning = true
self.player.spinTimer = 0
self.player.spinFrames = 48
self.player.spinTotal = 48
self.player.spinDrop = true
end end
if doorWarp then if doorWarp then
local outdoor = Map.isOutdoor(self.map.def) local outdoor = Map.isOutdoor(self.map.def)
@@ -3146,7 +3357,7 @@ function OverworldState:drawWorld()
-- at (64,64); anchoring those coords to where the player stood keeps -- at (64,64); anchoring those coords to where the player stood keeps
-- the overlay on the machine at any zoom. -- the overlay on the machine at any zoom.
local function fxHeal() local function fxHeal()
if not (self.healAnim and self.healAnim.visible) then return end if not self.healAnim then return end
local ha = self.healAnim local ha = self.healAnim
local fxDef = Game.data.field.overworldFx local fxDef = Game.data.field.overworldFx
if self.healMachineImg == nil and fxDef and fxDef.healMachine then if self.healMachineImg == nil and fxDef and fxDef.healMachine then
@@ -3162,6 +3373,19 @@ function OverworldState:drawWorld()
love.graphics.newQuad(0, 8, 8, 8, w, h), -- ball ($7d) love.graphics.newQuad(0, 8, 8, 8, w, h), -- ball ($7d)
} }
end end
-- the jingle flash recolors the machine sprites in place
-- (FlashSprite8Times XORs rOBP1; the sprites never disappear):
-- ha.visible == false is the flashed half of each beat, drawn with
-- the light/dark shades swapped instead of skipped
local shader
if not ha.visible then
shader = PaletteFX.shader()
if shader then
PaletteFX.sendColors(shader,
PaletteFX.permute(PaletteFX.GRAYS, HEAL_FLASH_MAP))
love.graphics.setShader(shader)
end
end
local ox = ha.px - 64 - cam.x local ox = ha.px - 64 - cam.x
local oy = ha.py - 64 - cam.y local oy = ha.py - 64 - cam.y
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
@@ -3176,6 +3400,7 @@ function OverworldState:drawWorld()
ox + b[1], oy + b[2]) ox + b[1], oy + b[2])
end end
end end
if shader then love.graphics.setShader() end
end end
end end
@@ -3206,6 +3431,37 @@ function OverworldState:drawWorld()
end end
end end
-- the cut tree splitting apart (AnimCut): top half slides right,
-- bottom half slides left, 1px per frame, flickering as they go
local function fxCutTree()
if not self.cutAnim then return end
local fxDef = Game.data.field.overworldFx
local tree = fxDef and fxDef.cutTree
if not tree then return end
if self.cutTreeImg == nil then
local ok, img = pcall(love.graphics.newImage, tree.path)
self.cutTreeImg = ok and img or false
end
local img = self.cutTreeImg
if not img then return end
if not self.cutTreeQuads then
local w, h = img:getWidth(), img:getHeight()
self.cutTreeQuads = {
love.graphics.newQuad(0, 0, 16, 8, w, h), -- top half
love.graphics.newQuad(0, 8, 16, 8, w, h), -- bottom half
}
end
local ca = self.cutAnim
local off = (ca.total or 8) - ca.frames
local dx = ca.x * 16 - cam.x
local dy = ca.y * 16 - cam.y
local flicker = ca.frames % 2 == 0
love.graphics.setColor(1, 1, 1, flicker and 1 or 0.55)
love.graphics.draw(img, self.cutTreeQuads[1], dx + off, dy)
love.graphics.draw(img, self.cutTreeQuads[2], dx - off, dy + 8)
love.graphics.setColor(1, 1, 1, 1)
end
-- the "!" bubble above a trainer who spotted the player -- the "!" bubble above a trainer who spotted the player
local function fxEmote() local function fxEmote()
if not (self.emote and self.emote.npc) then return end if not (self.emote and self.emote.npc) then return end
@@ -3297,6 +3553,11 @@ function OverworldState:drawWorld()
if not tilt then if not tilt then
-- === FLAT PATH: everything into the one world canvas, as before ===== -- === FLAT PATH: everything into the one world canvas, as before =====
-- OBP-baked sprites replay after the zone pass in GBC mode, so their
-- grass feet-overdraw must replay over them too, colorized with the
-- current map's palette (see PaletteFX.markSpriteRedraw)
local grassColors = PaletteFX.usesSpriteObp()
and PaletteFX.pal(Game.data, self:paletteNameFor(self.map)) or nil
for _, g in ipairs(self.ghosts) do for _, g in ipairs(self.ghosts) do
g.npc:draw(cam.x - g.ox, cam.y - g.oy) g.npc:draw(cam.x - g.ox, cam.y - g.oy)
end end
@@ -3308,14 +3569,23 @@ function OverworldState:drawWorld()
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
if self.map:isGrassCell(e.cellX, e.cellY) then if self.map:isGrassCell(e.cellX, e.cellY) then
self.map.renderer:drawCellBottom(e.cellX, e.cellY, cam.x, bgY) self.map.renderer:drawCellBottom(e.cellX, e.cellY, cam.x, bgY)
if grassColors then
self.map.renderer:markCellBottomRedraw(e.cellX, e.cellY,
cam.x, bgY, grassColors)
end
end end
if e.targetX and self.map:isGrassCell(e.targetX, e.targetY) then if e.targetX and self.map:isGrassCell(e.targetX, e.targetY) then
self.map.renderer:drawCellBottom(e.targetX, e.targetY, cam.x, bgY) self.map.renderer:drawCellBottom(e.targetX, e.targetY, cam.x, bgY)
if grassColors then
self.map.renderer:markCellBottomRedraw(e.targetX, e.targetY,
cam.x, bgY, grassColors)
end
end end
end end
end end
fxHeal() fxHeal()
fxDust() fxDust()
fxCutTree()
fxEmote() fxEmote()
fxDark() fxDark()
fxBird() fxBird()
@@ -3329,6 +3599,7 @@ function OverworldState:drawWorld()
-- layers are separate and composited ground-under-upright, so drawing -- layers are separate and composited ground-under-upright, so drawing
-- it now into the still-active ground canvas is order-equivalent. -- it now into the still-active ground canvas is order-equivalent.
fxDust() fxDust()
fxCutTree()
Game.renderer:beginUprightPass() Game.renderer:beginUprightPass()
@@ -3389,7 +3660,7 @@ function OverworldState:drawWorld()
-- the ground in front of where the player was) -- the ground in front of where the player was)
-- emote bubble -> the spotting NPC's foot (rides above its head) -- emote bubble -> the spotting NPC's foot (rides above its head)
-- fly bird, rod -> the player's foot -- fly bird, rod -> the player's foot
if self.healAnim and self.healAnim.visible then if self.healAnim then
local fx = self.healAnim.px - cam.x + 8 local fx = self.healAnim.px - cam.x + 8
local fy = self.healAnim.py - cam.y + 16 local fy = self.healAnim.py - cam.y + 16
self:billboard(fx, fy, vw, vh, zoneColorsAt(zones, fx, fy), false, fxHeal) self:billboard(fx, fy, vw, vh, zoneColorsAt(zones, fx, fy), false, fxHeal)
+29 -6
View File
@@ -84,9 +84,21 @@ function Player:update()
if self.turnTimer > 0 then if self.turnTimer > 0 then
self.turnTimer = self.turnTimer - 1 self.turnTimer = self.turnTimer - 1
end end
if self.spinFrames then
self.spinFrames = self.spinFrames - 1
if self.spinFrames <= 0 then
self.spinFrames = nil
self.spinDrop = nil
self.spinning = false
end
end
if not self.moving then return false end if not self.moving then return false end
local stepLen = self.stepFramesCur or self.stepFrames or STEP_FRAMES local stepLen = self.stepFramesCur or self.stepFrames or STEP_FRAMES
self.progress = self.progress + 1 self.progress = self.progress + 1
-- the walk-cycle clock ticks once per real frame while moving, so the
-- leg cadence stays constant when the bike halves stepFramesCur (only
-- translation speed doubles, like UpdatePlayerSprite's frame counters)
self.animClock = (self.animClock or 0) + 1
local d = Collision.DELTA[self.facing] local d = Collision.DELTA[self.facing]
local px = math.floor(self.progress * 16 / stepLen) local px = math.floor(self.progress * 16 / stepLen)
self.px = self.cellX * 16 + d[1] * px self.px = self.cellX * 16 + d[1] * px
@@ -108,8 +120,8 @@ end
function Player:walkPhase() function Player:walkPhase()
if not self.moving then return 0 end if not self.moving then return 0 end
-- walk frame during the middle of the step -- walk frame during the middle of each 16-frame animation cycle
local p = self.progress % 16 local p = (self.animClock or self.progress) % 16
return (p >= 4 and p < 12) and 1 or 0 return (p >= 4 and p < 12) and 1 or 0
end end
@@ -139,15 +151,26 @@ function Player:draw(camX, camY)
py = py + (self.bobTimer < 16 and 0 or 1) py = py + (self.bobTimer < 16 and 0 or 1)
end end
local facing = self.facing local facing = self.facing
local phase = self:walkPhase()
-- alternate walk cycles mirror the up/down frame; derived from the
-- fixed-rate animation clock so the bike's shorter steps don't double
-- the leg cadence
local flip = math.floor((self.animClock or 0) / 16) % 2 == 1
if self.spinning then if self.spinning then
-- spinner tiles whirl the sprite (PlayerSpinningFacingOrder) -- spinner tiles whirl the sprite on its standing pose, one facing
-- per frame (LoadSpinnerArrowTiles runs every OverworldLoop frame)
self.spinTimer = (self.spinTimer or 0) + 1 self.spinTimer = (self.spinTimer or 0) + 1
facing = SPIN_ORDER[math.floor(self.spinTimer / 4) % 4 + 1] facing = SPIN_ORDER[self.spinTimer % 4 + 1]
phase, flip = 0, false
-- teleport arrivals spin the sprite down into place
-- (EnterMapAnim PlayerSpinWhileMovingDown)
if self.spinFrames and self.spinDrop then
py = py - math.floor(self.spinFrames * 24 / (self.spinTotal or 64))
end
end end
local sprite = (self.surfing and self.surfSprite) local sprite = (self.surfing and self.surfSprite)
or (self.onBike and self.bikeSprite) or self.sprite or (self.onBike and self.bikeSprite) or self.sprite
sprite:draw(self.px, py, camX, camY, facing, sprite:draw(self.px, py, camX, camY, facing, phase, flip)
self:walkPhase(), self.stepFlip)
end end
return Player return Player
+51
View File
@@ -303,12 +303,63 @@ def parse_card_key_doors(pokered):
util.die(f"card_key.asm: {needle!r} not found (door tiles/blocks changed?)") util.die(f"card_key.asm: {needle!r} not found (door tiles/blocks changed?)")
if n_doors != 22 or len(maps) != 10: if n_doors != 22 or len(maps) != 10:
util.die(f"card key doors: expected 22 doors / 10 maps, got {n_doors}/{len(maps)}") util.die(f"card key doors: expected 22 doors / 10 maps, got {n_doors}/{len(maps)}")
# closedDoors is hand-ported, not extracted: no retail .blk layout places
# a closed-door block at any of the coordinates above (the feature is
# unused/cut in the original game), so there is no ROM or disassembly
# source to derive this from. It restores that cut content by stamping
# facility.bst blocks 0x54/0x5f (2F-10F) or interior.bst block 0x20
# (11F) over each door on map load, opened by that door's
# EVENT_SILPH_CO_n_UNLOCKED_DOORn flag. Kept in sync by hand with
# tools/rom_manifest.json's field.cardKeyDoors.closedDoors.
closed_doors = {
"SILPH_CO_2F": [
{"block": 0x54, "bx": 2, "by": 2, "event": "EVENT_SILPH_CO_2_UNLOCKED_DOOR1", "open": 0x0e},
{"block": 0x54, "bx": 2, "by": 5, "event": "EVENT_SILPH_CO_2_UNLOCKED_DOOR2", "open": 0x0e},
],
"SILPH_CO_3F": [
{"block": 0x5f, "bx": 4, "by": 4, "event": "EVENT_SILPH_CO_3_UNLOCKED_DOOR1", "open": 0x0e},
{"block": 0x5f, "bx": 8, "by": 4, "event": "EVENT_SILPH_CO_3_UNLOCKED_DOOR2", "open": 0x0e},
],
"SILPH_CO_4F": [
{"block": 0x54, "bx": 2, "by": 6, "event": "EVENT_SILPH_CO_4_UNLOCKED_DOOR1", "open": 0x0e},
{"block": 0x54, "bx": 6, "by": 4, "event": "EVENT_SILPH_CO_4_UNLOCKED_DOOR2", "open": 0x0e},
],
"SILPH_CO_5F": [
{"block": 0x5f, "bx": 3, "by": 2, "event": "EVENT_SILPH_CO_5_UNLOCKED_DOOR1", "open": 0x0e},
{"block": 0x5f, "bx": 3, "by": 6, "event": "EVENT_SILPH_CO_5_UNLOCKED_DOOR2", "open": 0x0e},
{"block": 0x5f, "bx": 7, "by": 5, "event": "EVENT_SILPH_CO_5_UNLOCKED_DOOR3", "open": 0x0e},
],
"SILPH_CO_6F": [
{"block": 0x5f, "bx": 2, "by": 6, "event": "EVENT_SILPH_CO_6_UNLOCKED_DOOR", "open": 0x0e},
],
"SILPH_CO_7F": [
{"block": 0x54, "bx": 5, "by": 3, "event": "EVENT_SILPH_CO_7_UNLOCKED_DOOR1", "open": 0x0e},
{"block": 0x54, "bx": 10, "by": 2, "event": "EVENT_SILPH_CO_7_UNLOCKED_DOOR2", "open": 0x0e},
{"block": 0x54, "bx": 10, "by": 6, "event": "EVENT_SILPH_CO_7_UNLOCKED_DOOR3", "open": 0x0e},
],
"SILPH_CO_8F": [
{"block": 0x5f, "bx": 3, "by": 4, "event": "EVENT_SILPH_CO_8_UNLOCKED_DOOR", "open": 0x0e},
],
"SILPH_CO_9F": [
{"block": 0x5f, "bx": 1, "by": 4, "event": "EVENT_SILPH_CO_9_UNLOCKED_DOOR1", "open": 0x0e},
{"block": 0x54, "bx": 9, "by": 2, "event": "EVENT_SILPH_CO_9_UNLOCKED_DOOR2", "open": 0x0e},
{"block": 0x54, "bx": 9, "by": 5, "event": "EVENT_SILPH_CO_9_UNLOCKED_DOOR3", "open": 0x0e},
{"block": 0x5f, "bx": 5, "by": 6, "event": "EVENT_SILPH_CO_9_UNLOCKED_DOOR4", "open": 0x0e},
],
"SILPH_CO_10F": [
{"block": 0x54, "bx": 5, "by": 4, "event": "EVENT_SILPH_CO_10_UNLOCKED_DOOR", "open": 0x0e},
],
"SILPH_CO_11F": [
{"block": 0x20, "bx": 3, "by": 6, "event": "EVENT_SILPH_CO_11_UNLOCKED_DOOR", "open": 0x03},
],
}
return { return {
"maps": maps, # maps where the engine checks for doors "maps": maps, # maps where the engine checks for doors
"doors": doors, # tile coords; block coord = floor(coord/2) "doors": doors, # tile coords; block coord = floor(coord/2)
"doorTiles": [0x18, 0x24], # locked-door tile ids (FACILITY tileset) "doorTiles": [0x18, 0x24], # locked-door tile ids (FACILITY tileset)
"openBlock": 0x0e, # block written over the door's block "openBlock": 0x0e, # block written over the door's block
"silphCo11F": {"doorTile": 0x5e, "openBlock": 0x03}, "silphCo11F": {"doorTile": 0x5e, "openBlock": 0x03},
"closedDoors": closed_doors,
} }
+16
View File
@@ -642,6 +642,22 @@ def extract_overworld_fx(pokered, assets_dir):
"path": f"assets/generated/fx/{base}.png", "path": f"assets/generated/fx/{base}.png",
"width": size[0], "height": size[1], "source": rel, "width": size[0], "height": size[1], "source": rel,
} }
# The cuttable tree's own OAM sprite: InitCutAnimOAM copies
# Overworld_GFX tiles $2d-$2e (top half) and $3d-$3e (bottom half)
# into the sprite tiles the Cut animation slides apart, so the crop
# comes straight out of the overworld tileset sheet (16 tiles/row:
# $2d sits at column 13, row 2).
tree_src = Image.open(os.path.join(pokered, "gfx/tilesets/overworld.png"))
tree = _convert_image(tree_src.crop((104, 16, 120, 32)),
transparent_color0=True)
_save_png(tree, os.path.join(assets_dir, "fx", "cut_tree.png"))
out["cutTree"] = {
"path": "assets/generated/fx/cut_tree.png",
"width": 16, "height": 16,
"source": "gfx/tilesets/overworld.png tiles $2d-$2e/$3d-$3e "
"(engine/overworld/cut.asm InitCutAnimOAM)",
}
return out return out
+56
View File
@@ -4253,6 +4253,48 @@
] ]
}, },
"cardKeyDoors": { "cardKeyDoors": {
"closedDoors": {
"SILPH_CO_2F": [
{ "block": 84, "bx": 2, "by": 2, "event": "EVENT_SILPH_CO_2_UNLOCKED_DOOR1", "open": 14 },
{ "block": 84, "bx": 2, "by": 5, "event": "EVENT_SILPH_CO_2_UNLOCKED_DOOR2", "open": 14 }
],
"SILPH_CO_3F": [
{ "block": 95, "bx": 4, "by": 4, "event": "EVENT_SILPH_CO_3_UNLOCKED_DOOR1", "open": 14 },
{ "block": 95, "bx": 8, "by": 4, "event": "EVENT_SILPH_CO_3_UNLOCKED_DOOR2", "open": 14 }
],
"SILPH_CO_4F": [
{ "block": 84, "bx": 2, "by": 6, "event": "EVENT_SILPH_CO_4_UNLOCKED_DOOR1", "open": 14 },
{ "block": 84, "bx": 6, "by": 4, "event": "EVENT_SILPH_CO_4_UNLOCKED_DOOR2", "open": 14 }
],
"SILPH_CO_5F": [
{ "block": 95, "bx": 3, "by": 2, "event": "EVENT_SILPH_CO_5_UNLOCKED_DOOR1", "open": 14 },
{ "block": 95, "bx": 3, "by": 6, "event": "EVENT_SILPH_CO_5_UNLOCKED_DOOR2", "open": 14 },
{ "block": 95, "bx": 7, "by": 5, "event": "EVENT_SILPH_CO_5_UNLOCKED_DOOR3", "open": 14 }
],
"SILPH_CO_6F": [
{ "block": 95, "bx": 2, "by": 6, "event": "EVENT_SILPH_CO_6_UNLOCKED_DOOR", "open": 14 }
],
"SILPH_CO_7F": [
{ "block": 84, "bx": 5, "by": 3, "event": "EVENT_SILPH_CO_7_UNLOCKED_DOOR1", "open": 14 },
{ "block": 84, "bx": 10, "by": 2, "event": "EVENT_SILPH_CO_7_UNLOCKED_DOOR2", "open": 14 },
{ "block": 84, "bx": 10, "by": 6, "event": "EVENT_SILPH_CO_7_UNLOCKED_DOOR3", "open": 14 }
],
"SILPH_CO_8F": [
{ "block": 95, "bx": 3, "by": 4, "event": "EVENT_SILPH_CO_8_UNLOCKED_DOOR", "open": 14 }
],
"SILPH_CO_9F": [
{ "block": 95, "bx": 1, "by": 4, "event": "EVENT_SILPH_CO_9_UNLOCKED_DOOR1", "open": 14 },
{ "block": 84, "bx": 9, "by": 2, "event": "EVENT_SILPH_CO_9_UNLOCKED_DOOR2", "open": 14 },
{ "block": 84, "bx": 9, "by": 5, "event": "EVENT_SILPH_CO_9_UNLOCKED_DOOR3", "open": 14 },
{ "block": 95, "bx": 5, "by": 6, "event": "EVENT_SILPH_CO_9_UNLOCKED_DOOR4", "open": 14 }
],
"SILPH_CO_10F": [
{ "block": 84, "bx": 5, "by": 4, "event": "EVENT_SILPH_CO_10_UNLOCKED_DOOR", "open": 14 }
],
"SILPH_CO_11F": [
{ "block": 32, "bx": 3, "by": 6, "event": "EVENT_SILPH_CO_11_UNLOCKED_DOOR", "open": 3 }
]
},
"doorTiles": [ "doorTiles": [
24, 24,
36 36
@@ -6373,6 +6415,11 @@
"path": "assets/generated/fx/battle_transition.png", "path": "assets/generated/fx/battle_transition.png",
"width": 8 "width": 8
}, },
"cutTree": {
"height": 16,
"path": "assets/generated/fx/cut_tree.png",
"width": 16
},
"fishingRod": { "fishingRod": {
"height": 24, "height": 24,
"path": "assets/generated/fx/fishing_rod.png", "path": "assets/generated/fx/fishing_rod.png",
@@ -44689,6 +44736,15 @@
"TRI_ATTACK", "TRI_ATTACK",
"SUBSTITUTE" "SUBSTITUTE"
], ],
"trainerPartyOverrides": {
"OPP_CHIEF": [
{ "level": 41, "species": "MACHOKE" },
{ "level": 41, "species": "GOLBAT" },
{ "level": 43, "species": "MAROWAK" },
{ "level": 43, "species": "WEEZING" },
{ "level": 46, "species": "PERSIAN" }
]
},
"trainerPics": [ "trainerPics": [
{ {
"imageBase": "youngster", "imageBase": "youngster",