mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 08:21:02 +02:00
ff9992dff8
checkBoulderPush had an isWarpTileCell escape hatch that let a boulder be pushed onto any door/warp tile, walkable or not. In pokered, CheckForCollisionWhenPushingBoulder walks the same wTilesetCollisionPtr list as player movement (CheckTilePassable) -- there is no hole/warp exception, so a boulder can never land on a cell the player cannot walk onto. The known push targets (CAVERN holes, Victory Road switches) are walkable tiles in their tileset's coll list already, so removing the escape hatch only stops pushing boulders into walls. Fixes #754
4728 lines
198 KiB
Lua
4728 lines
198 KiB
Lua
-- The overworld state: renders the current map (plus connected map
|
|
-- strips), runs the player, NPCs, warps, connections, encounters, ledges,
|
|
-- surfing, Cut trees, trainer sight lines, and dispatches interactions to
|
|
-- map scripts (data/scripts/), marts, nurses or extracted text.
|
|
|
|
local Assets = require("src.render.Assets")
|
|
local Camera = require("src.render.Camera")
|
|
local Collision = require("src.world.Collision")
|
|
local Encounter = require("src.world.Encounter")
|
|
local FieldDefaults = require("src.world.FieldDefaults")
|
|
local Logger = require("src.core.Logger")
|
|
local Map = require("src.world.Map")
|
|
local MapLoader = require("src.world.MapLoader")
|
|
local NPC = require("src.world.NPC")
|
|
local PaletteFX = require("src.render.PaletteFX")
|
|
local Pipelines = require("src.render.Pipelines")
|
|
local Player = require("src.world.Player")
|
|
local Runtime = require("src.mods.Runtime")
|
|
local Screens = require("src.ui.Screens")
|
|
local ScriptRunner = require("src.script.ScriptRunner")
|
|
local Tilt = require("src.render.Tilt")
|
|
local TextBox = require("src.render.TextBox")
|
|
local Transition = require("src.render.Transition")
|
|
local Warp = require("src.world.Warp")
|
|
local Zoom = require("src.render.Zoom")
|
|
local Strings = require("src.core.Strings")
|
|
|
|
-- isOverworld marks the live world state for WorldAPI's stack scan
|
|
local OverworldState = { isOpaque = true, isOverworld = true }
|
|
|
|
local Game -- set on enter (avoids circular require at load time)
|
|
|
|
local mapScripts -- registry of hand-ported map scripts
|
|
|
|
local COMPASS = { up = "north", down = "south", left = "west", right = "east" }
|
|
local DIRVEC = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } }
|
|
|
|
-- healing machine ball screen positions (PokeCenterOAMData dbsprite
|
|
-- rows are raw shadow-OAM bytes, so the hardware's -8/-16 OAM origin
|
|
-- applies: screen = tile*8 + pixel offset - 8/16); [3] = OAM_XFLIP
|
|
local HEAL_BALL_XY = {
|
|
{ 40, 27 }, { 48, 27, true },
|
|
{ 40, 32 }, { 48, 32, 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 }
|
|
|
|
-- Fishing rod placement (FishingRodOAM, engine/overworld/player_animations
|
|
-- .asm). Those dbsprite rows are raw shadow-OAM bytes like HEAL_BALL_XY
|
|
-- above (screen = tile*8 + pixel - 8/16), measured against the player
|
|
-- sprite's fixed screen spot: ResetPlayerSpriteData parks it at $3c/$40
|
|
-- (home/reset_player_sprite.asm), i.e. screen (64,60). So what ports over
|
|
-- is the delta from the sprite's top-left, which SpriteRenderer:draw puts at
|
|
-- (px, py - 4). `tile` indexes the three stacked 8x8 tiles of
|
|
-- assets/generated/fx/fishing_rod.png: FishingRodOAM only ever draws $fd
|
|
-- (row 0, up/down) and $fe (row 1, left/right), and RIGHT is the LEFT tile
|
|
-- x-flipped. Blitting the whole 8x24 sheet is what drew the rod as a
|
|
-- garbage strip (#321).
|
|
local ROD_OAM = {
|
|
down = { dx = 4, dy = 15, tile = 0 }, -- dbsprite 9, 11, 4, 3, $fd
|
|
up = { dx = 4, dy = -8, tile = 0 }, -- dbsprite 9, 8, 4, 4, $fd
|
|
left = { dx = -8, dy = 4, tile = 1 }, -- dbsprite 8, 10, 0, 0, $fe
|
|
right = { dx = 16, dy = 4, tile = 1, flip = true }, -- dbsprite 11, 10, 0, 0, $fe, XFLIP
|
|
}
|
|
|
|
-- field.darkMaps (home/overworld.asm's dark-map check): the floors that run
|
|
-- with wMapPalOffset = 6 until FLASH
|
|
local function isDarkMap(mapId)
|
|
local darkDef = Game.data.field.darkMaps
|
|
for _, m in ipairs(darkDef and darkDef.maps or {}) do
|
|
if m == mapId then return true end
|
|
end
|
|
return false
|
|
end
|
|
|
|
-- object_event spawn filter (toggleable_objects, items taken, beaten
|
|
-- static encounters), shared by the current map's real NPCs and the
|
|
-- visual-only ghosts on connected neighbor maps
|
|
local function objectVisible(save, mapId, obj)
|
|
local toggles = save.objectToggles and save.objectToggles[mapId] or {}
|
|
local visible = not obj.hidden
|
|
if obj.name and toggles[obj.name] ~= nil then
|
|
visible = toggles[obj.name]
|
|
end
|
|
if obj.item and save.itemsTaken
|
|
and save.itemsTaken[mapId .. "_obj_" .. obj.index] then
|
|
visible = false
|
|
end
|
|
if obj.pokemon and save.defeatedTrainers[mapId .. "_obj_" .. obj.index] then
|
|
visible = false
|
|
end
|
|
return visible
|
|
end
|
|
OverworldState.objectVisible = objectVisible -- exposed for tests + reuse
|
|
|
|
-- NPC instance pool: one NPC object per map object, keyed by the
|
|
-- NPC.id format ("<mapId>_obj_<index>"). The same instance serves as
|
|
-- a neighbor-map ghost and as the real NPC once that map is entered,
|
|
-- so positions/facings carry across connection seams.
|
|
local function pooledNPC(pool, data, mapId, obj)
|
|
local key = mapId .. "_obj_" .. obj.index
|
|
local npc = pool[key]
|
|
if not npc then
|
|
npc = NPC.new(data, mapId, obj)
|
|
pool[key] = npc
|
|
if Runtime.wants("world.npc_spawned") then
|
|
Runtime.emit("world.npc_spawned",
|
|
{ mapId = mapId, npcId = key, runtime = obj.runtime == true })
|
|
end
|
|
end
|
|
return npc
|
|
end
|
|
OverworldState.pooledNPC = pooledNPC -- exposed for tests
|
|
|
|
-- connection hops rendered around the current map: two, so
|
|
-- corner-adjacent maps (connections of connections) don't pop in and
|
|
-- out of the survey zoom at the seams (constants.world.neighborHops)
|
|
local NEIGHBOR_HOPS = 2
|
|
|
|
-- Neighbor placement (pure; exposed for tests): walk the connection
|
|
-- graph `hops` connections out, composing the strip offsets, deduped
|
|
-- by map id (BFS, so a direct connection always wins over a two-hop
|
|
-- path). Offsets are world pixels; connection offsets are in blocks
|
|
-- (32 px), the same alignment the connection macro encodes
|
|
-- (macros/scripts/maps.asm: _x = offset * -2 walk cells for
|
|
-- north/south, _y = offset * -2 for west/east).
|
|
-- reachW/reachH (optional, world pixels): with a full zoom-out the view
|
|
-- shows far more world than the fixed hop count covers, so any map whose
|
|
-- body could overlap the current map's rect inflated by the view
|
|
-- half-extents joins the set (and keeps the walk going) regardless of how
|
|
-- many connections away it sits -- otherwise far map bodies pop between
|
|
-- real tiles and the border filler when a crossing re-roots the BFS.
|
|
function OverworldState.computeNeighbors(maps, rootId, hops, reachW, reachH)
|
|
local out = {}
|
|
local rootDef = maps[rootId]
|
|
local placed = { [rootId] = true }
|
|
local queue = { { def = rootDef, ox = 0, oy = 0, hops = 0 } }
|
|
local qi = 1
|
|
local function inReach(def, ox, oy)
|
|
if not (reachW and reachH and rootDef) then return false end
|
|
return ox + def.width * 32 > -reachW
|
|
and ox < rootDef.width * 32 + reachW
|
|
and oy + def.height * 32 > -reachH
|
|
and oy < rootDef.height * 32 + reachH
|
|
end
|
|
while queue[qi] do
|
|
local cur = queue[qi]
|
|
qi = qi + 1
|
|
for dir, conn in pairs(cur.def.connections or {}) do
|
|
local destDef = maps[conn.map]
|
|
if destDef and not placed[conn.map] then
|
|
placed[conn.map] = true
|
|
local ox, oy
|
|
if dir == "north" then
|
|
ox, oy = conn.offset * 32, -destDef.height * 32
|
|
elseif dir == "south" then
|
|
ox, oy = conn.offset * 32, cur.def.height * 32
|
|
elseif dir == "west" then
|
|
ox, oy = -destDef.width * 32, conn.offset * 32
|
|
else
|
|
ox, oy = cur.def.width * 32, conn.offset * 32
|
|
end
|
|
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 })
|
|
if cur.hops + 1 < hops or inReach(destDef, ox, oy) then
|
|
table.insert(queue,
|
|
{ def = destDef, ox = ox, oy = oy,
|
|
hops = cur.hops + 1 })
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
return out
|
|
end
|
|
|
|
function OverworldState:enter(mapId, x, y, facing)
|
|
Game = require("src.core.Game")
|
|
Game.overworld = self
|
|
Collision.load(Game.data) -- tile-pair (elevation) collisions
|
|
Encounter.load(Game.data) -- constants.encounterBuckets
|
|
mapScripts = require("data.scripts.init")
|
|
self.camera = Camera.new()
|
|
self.runner = ScriptRunner.new(Game, self)
|
|
self.scriptMoves = {}
|
|
self.pendingScripts = {}
|
|
self.parallelRunners = {}
|
|
self.parallelQueue = {}
|
|
self.npcMoveLocks = {}
|
|
self.marchers = {}
|
|
-- one-shot trainer-engagement state: must not survive a save/load or
|
|
-- a fresh entry, or a stale flag can freeze player input forever
|
|
self.engaging = false
|
|
self.emote = nil
|
|
-- survives save/load: a loaded game may start inside a building whose
|
|
-- exit mat is a LAST_MAP warp
|
|
self.lastOutdoor = Game.save.lastOutdoor
|
|
self:setMap(mapId, x, y, facing, { via = "boot" })
|
|
-- boot/load: derive the flag from the tile the save left us standing on,
|
|
-- like MapEntryAfterBattle's IsPlayerStandingOnWarp, so a game saved on a
|
|
-- door mat can still walk straight back out (issue #378)
|
|
self:refreshStandingOnWarp()
|
|
end
|
|
|
|
-- Silph Co card key doors + Rocket Hideout elevator gates: 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; scripts/RocketHideoutB1F.asm +
|
|
-- RocketHideoutB4F.asm ...DoorCallbackScript, closed blocks $54/$2d over
|
|
-- the lift doorway). A door opens on its single `event`, or on `events`
|
|
-- when every listed flag must be set (Rocket Hideout B4F's lift gate
|
|
-- needs both guard trainers beaten -- CheckBothEventsSet). The callbacks
|
|
-- run whenever BIT_CUR_MAP_LOADED_1 is set, which is map load AND the end
|
|
-- of a battle on that map (home/trainers.asm EndTrainerBattle), so the
|
|
-- gate opens with SFX_GO_INSIDE the moment the last guard falls (#372).
|
|
function OverworldState:stampClosedDoors()
|
|
local closedDoors = FieldDefaults.fieldValue(Game.data, "cardKeyDoors",
|
|
"closedDoors")
|
|
local floorDoors = self.map and closedDoors and closedDoors[self.map.id]
|
|
if not floorDoors then return end
|
|
local stamped, unlocked = false, false
|
|
for _, door in ipairs(floorDoors) do
|
|
local open
|
|
if door.events then
|
|
open = true
|
|
for _, ev in ipairs(door.events) do
|
|
if not Game.save.flags[ev] then open = false break end
|
|
end
|
|
else
|
|
open = Game.save.flags[door.event]
|
|
end
|
|
local want = open 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
|
|
if open then unlocked = true end
|
|
end
|
|
end
|
|
if stamped then self.map.renderer:rebuild() end
|
|
if unlocked then require("src.core.Sound").play(Game.data, "Go_Inside") end
|
|
end
|
|
|
|
function OverworldState:setMap(mapId, x, y, facing, opts)
|
|
local fromMapId = self.map and self.map.id
|
|
if fromMapId then
|
|
Runtime.emit("map.exited", { mapId = fromMapId, toMapId = mapId })
|
|
end
|
|
-- ambient choreography is per-map: parallel runners die here, and the
|
|
-- departing map's queued scripts go with them unless the enqueuer
|
|
-- asked to persist across the warp
|
|
if self.parallelRunners then
|
|
for i = #self.parallelRunners, 1, -1 do
|
|
self:killParallel(self.parallelRunners[i])
|
|
end
|
|
self.parallelQueue = {}
|
|
end
|
|
self.marchers = {}
|
|
local queue = self.pendingScripts
|
|
if queue then
|
|
for i = #queue, 1, -1 do
|
|
local entry = queue[i]
|
|
if entry.mapId ~= mapId
|
|
and not (entry.extra and entry.extra.persistAcrossWarp) then
|
|
table.remove(queue, i)
|
|
end
|
|
end
|
|
end
|
|
-- a scripted tile-anim override lasts until map change
|
|
if self.tileAnimOverride then
|
|
self.tileAnimOverride.tileset.animation = self.tileAnimOverride.animation
|
|
self.tileAnimOverride = nil
|
|
end
|
|
-- ADVANCED bakes colour into the tileset atlas, so the dark-cave shift has
|
|
-- to be armed before that atlas is built for this map (#383); self.dark is
|
|
-- settled below, once the map record is in hand.
|
|
if PaletteFX.setDarkWorld(isDarkMap(mapId) and not Game.save.flashLit)
|
|
and PaletteFX.usesGbcPack() then
|
|
MapLoader.invalidateAll()
|
|
end
|
|
self.map = MapLoader.load(Game.data, mapId)
|
|
-- STRENGTH deactivates on every real map load (home/overworld.asm
|
|
-- EnterMap -> ResetUsingStrengthOutOfBattleBit clears BIT_STRENGTH_ACTIVE
|
|
-- of wStatusFlags1). setMap is the single choke point for every map-id
|
|
-- change -- warps and seamless connection crossings alike -- so an
|
|
-- unconditional reset here reproduces that default clear path. It is
|
|
-- deliberately NOT part of Game.save: the flag lives in plain WRAM, not
|
|
-- SRAM, so it must not survive a save/load. Not reset in afterBattle:
|
|
-- pokered keeps STRENGTH across a same-map battle return (EnterMap skips
|
|
-- the reset when BIT_BATTLE_OVER_OR_BLACKOUT is set).
|
|
self.strengthActive = false
|
|
-- Cut trees grow back when the map reloads (like the original)
|
|
if self.cutBlocks and self.cutBlocks[mapId] then
|
|
for _, c in ipairs(self.cutBlocks[mapId]) do
|
|
self.map:setBlock(c.bx, c.by, c.block)
|
|
end
|
|
self.map.renderer:rebuild()
|
|
self.cutBlocks[mapId] = nil
|
|
end
|
|
self:stampClosedDoors()
|
|
-- forced dismount only where riding is disallowed (IsBikeRidingAllowed,
|
|
-- home/overworld.asm: bike_riding_tilesets.asm tilesets plus the
|
|
-- ROUTE_23/INDIGO_PLATEAU map exceptions)
|
|
if Game.save.onBike and not self:bikeAllowed(mapId) then
|
|
Game.save.onBike = false
|
|
end
|
|
-- the Route 16/18 gate map scripts clear the Cycling Road's
|
|
-- BIT_ALWAYS_ON_BIKE every frame (scripts/Route16Gate1F.asm /
|
|
-- Route18Gate1F.asm `res BIT_ALWAYS_ON_BIKE`); entering the gate is
|
|
-- the walking exit from the forced-bike stretch
|
|
for _, m in ipairs(FieldDefaults.fieldValue(Game.data, "forcedMovement",
|
|
"clearMaps") or {}) do
|
|
if m == mapId then Game.save.forcedBike = nil break end
|
|
end
|
|
-- leaving the Safari Zone maps ends any running Safari game
|
|
if Game.save.safari and not Map.inRegion(self.map.def, "SAFARI", "SAFARI_ZONE") then
|
|
Game.save.safari = nil
|
|
end
|
|
-- Rock Tunnel darkness (wMapPalOffset, home/overworld.asm): dark
|
|
-- until FLASH is used; the light persists between the tunnel floors
|
|
-- and resets once outside
|
|
if isDarkMap(mapId) then
|
|
self:setDark(not Game.save.flashLit)
|
|
else
|
|
Game.save.flashLit = nil
|
|
self:setDark(false)
|
|
end
|
|
if Game.data.field.flyWarps[mapId] then
|
|
Game.save.visited = Game.save.visited or {}
|
|
Game.save.visited[mapId] = true
|
|
end
|
|
-- NPC instances persist across connection crossings in self.npcPool
|
|
-- (keyed by NPC.id): a neighbor map's wandering ghosts ARE the
|
|
-- objects that become the real NPCs when the player crosses the
|
|
-- seam, so nothing snaps back to its spawn point in view of the
|
|
-- survey zoom. Warps rebuild from scratch, like the original's
|
|
-- per-entry sprite init (home/overworld.asm LoadMapHeader
|
|
-- .loadSpriteData).
|
|
if not (opts and opts.seamless and self.npcPool) then
|
|
self.npcPool = {}
|
|
end
|
|
self.npcs = {}
|
|
for _, obj in ipairs(self.map.def.objects or {}) do
|
|
if objectVisible(Game.save, mapId, obj) then
|
|
local npc = pooledNPC(self.npcPool, Game.data, mapId, obj)
|
|
npc.frozen = false
|
|
table.insert(self.npcs, npc)
|
|
end
|
|
end
|
|
if self.player then
|
|
self.player.cellX, self.player.cellY = x, y
|
|
self.player.px, self.player.py = x * 16, y * 16
|
|
self.player.facing = facing or self.player.facing
|
|
self.player.moving = false
|
|
self.player.targetX, self.player.targetY = nil, nil
|
|
else
|
|
self.player = Player.new(Game.data, x, y, facing)
|
|
end
|
|
-- boot only: the original persists the surf state. wWalkBikeSurfState
|
|
-- (ram/wram.asm) lives inside wMainDataStart..wMainDataEnd, which
|
|
-- engine/menus/save.asm block-copies into sMainData on save and back out
|
|
-- on load (sram.asm declares sMainData as `ds wMainDataEnd -
|
|
-- wMainDataStart`), and Continue never clears it -- the only `xor a /
|
|
-- ld [wWalkBikeSurfState], a` on that path is the cable club's. Restore
|
|
-- it here, before Music.playMap reads it below and before
|
|
-- PikachuFollower.onMapEntered, matching LoadMapData calling
|
|
-- LoadPlayerSpriteGraphics ahead of PlayDefaultMusic (home/overworld.asm,
|
|
-- home/audio.asm). Without this the player resumed on foot on a water
|
|
-- cell, which softlocks here: Collision.canMove (src/world/Collision.lua)
|
|
-- picks land tile-pairs whenever mover.surfing is falsy, and land
|
|
-- tile-pairs never permit stepping off a water cell (#536). Same
|
|
-- boot-only shape as the refreshStandingOnWarp door-mat restore (#378).
|
|
if opts and opts.via == "boot" then
|
|
local ps = Game.save and Game.save.player
|
|
if ps and ps.surfing ~= nil then
|
|
self.player.surfing = ps.surfing and true or false
|
|
else
|
|
-- saves written before #536 carry no flag. Map:isWaterCell alone is
|
|
-- not self-sufficient (see src/world/Map.lua: water and shore share
|
|
-- one lookup, and no tileset stamps waterTiles, so tile $14 -- a
|
|
-- walkable floor in HOUSE/GATE/LOBBY/MANSION/MUSEUM -- reads as
|
|
-- water), so gate it the way facingIsShoreOrWater does and require a
|
|
-- cell you could not be standing on upright.
|
|
self.player.surfing = self:tilesetHasWater()
|
|
and not self.map:isWalkableCell(x, y)
|
|
and self.map:isWaterCell(x, y)
|
|
end
|
|
-- re-derive from the live party: a reloaded save with the SURF-Pikachu
|
|
-- since deposited should not render the Pikachu sheet.
|
|
-- ponytail: re-derived rather than persisted.
|
|
self:syncSurfingPikachu()
|
|
end
|
|
-- crossConnection re-arms this after setMap; clear so a warp/reload
|
|
-- cannot leave a stale deferred PlayMapMusic pending
|
|
self.pendingSeamMusic = nil
|
|
self.entities = { self.player }
|
|
for _, n in ipairs(self.npcs) do table.insert(self.entities, n) end
|
|
-- Yellow's companion Pikachu trails the player (never in
|
|
-- self.entities: it does not block movement, pikachu_follow.asm)
|
|
require("src.world.PikachuFollower").onMapEntered(Game, self, opts)
|
|
|
|
-- opts.keepMusic: the Oak-escort warp keeps MUSIC_MEET_PROF_OAK
|
|
-- playing into the lab (BIT_NO_MAP_MUSIC in wStatusFlags7);
|
|
-- keepMusicOnce is the play_music opts.keep one-shot of the same bit
|
|
local keepMusic = (opts and opts.keepMusic) or self.keepMusicOnce
|
|
self.keepMusicOnce = nil
|
|
if not keepMusic then
|
|
require("src.core.Music").playMap(Game.data, mapId, Game.save.onBike,
|
|
self.player.surfing)
|
|
end
|
|
|
|
-- forced bike/surf tiles fire the moment the player is placed on the
|
|
-- map, like EnterMap's unconditional CheckForceBikeOrSurf farcall
|
|
-- (home/overworld.asm) -- a warp can land directly on one (the Route
|
|
-- 16/18 gate exits), and the scripted door-mat walkout that follows
|
|
-- suppresses onStepComplete, so waiting for a plain step never mounts
|
|
self:checkForcedMovement()
|
|
-- Seafoam B4F's map script pushes off the B3F stair warps every frame
|
|
-- while the upper plugs are out (SeafoamIslandsB4FDefaultScript); the
|
|
-- B3F/B4F force-surf mouths also arm their MOVE_OBJECT current scripts
|
|
-- from CheckForceBikeOrSurf. Re-check here so a warp-in does not sit
|
|
-- idle on those cells waiting for a player step.
|
|
self:checkSeafoamCurrent()
|
|
|
|
-- snap the camera immediately: the overworld doesn't update while a
|
|
-- Transition is on top, so a stale camera would show the new map at
|
|
-- the old scroll position for the whole fade-in
|
|
self.camera:follow(self.player.px, self.player.py,
|
|
Game.renderer:worldViewSize())
|
|
|
|
-- fires before the onEnter chain so a listener sees the map in the same
|
|
-- state the map script does
|
|
Runtime.emit("map.entered", {
|
|
mapId = mapId, map = self.map, fromMapId = fromMapId,
|
|
via = (opts and opts.via)
|
|
or (opts and opts.seamless and "connection")
|
|
or (fromMapId and "warp" or "boot"),
|
|
})
|
|
|
|
-- map-enter hooks (hand-ported map scripts, e.g. Victory Road barriers).
|
|
-- fromMapId lets elevators seed a valid walk-out floor when the ROM
|
|
-- car warps still point at a missing map (Silph's UNUSED_MAP_ED) and
|
|
-- the player B-cancels the floor menu without .UpdateWarp.
|
|
local hooks = mapScripts.get(mapId)
|
|
if hooks and hooks.onEnter then
|
|
hooks.onEnter(Game, self, fromMapId)
|
|
end
|
|
|
|
self:rebuildNeighbors()
|
|
Logger.info("map: %s at (%d,%d)", mapId, x, y)
|
|
-- Route22Gate_Script rewrites wLastMap from the player's Y on entry
|
|
-- too (not only on step), so a save/load mid-gate keeps exits correct
|
|
self:syncLastMapRewrite()
|
|
end
|
|
|
|
-- Neighbor maps drawn at the composed connection offsets: at least the
|
|
-- configured hop count out (the GB only ever streamed a 32px strip of
|
|
-- the single directly connected map -- home/overworld.asm .loadNewMap),
|
|
-- widened to everything the current view size can show so a full
|
|
-- zoom-out never runs past the rendered set. Re-run whenever the view
|
|
-- grows (zoom/resize), not only on setMap.
|
|
--
|
|
-- Neighbors are built eagerly here. A TileRenderer is now a light object --
|
|
-- the tile layer draws windowed to the camera, so nothing per-map is
|
|
-- constructed up front (see TileRenderer) -- so there is no build cost to
|
|
-- amortize and no prefetch race to lose at a seam. That is what the old
|
|
-- one-per-frame streaming queue existed to hide, and it is gone.
|
|
function OverworldState:rebuildNeighbors()
|
|
local mapId = self.map.id
|
|
self.neighbors = {}
|
|
local hops = FieldDefaults.world(Game.data, "neighborHops") or NEIGHBOR_HOPS
|
|
local vw, vh = Game.renderer:worldViewSize()
|
|
self.neighborViewW, self.neighborViewH = vw, vh
|
|
-- resident set the eviction pass must never touch: the current map plus
|
|
-- every drawn neighbor
|
|
local keep = { [mapId] = true }
|
|
for _, n in ipairs(OverworldState.computeNeighbors(Game.data.maps, mapId,
|
|
hops,
|
|
math.floor(vw / 2) + 64,
|
|
math.floor(vh / 2) + 64)) do
|
|
keep[n.id] = true
|
|
local m = MapLoader.load(Game.data, n.id)
|
|
table.insert(self.neighbors, { map = m, ox = n.ox, oy = n.oy })
|
|
end
|
|
-- bound resident memory: drop maps behind us that are neither current nor
|
|
-- a drawn neighbor, releasing their window batch / border image / atlas
|
|
MapLoader.trim(keep)
|
|
|
|
-- visual-only NPCs on connected maps (survey zoom): same spawn filter
|
|
-- as a real map entry, but they never join self.entities -- no sight
|
|
-- lines, triggers, dialogue or player collision. Instances are
|
|
-- shared with the real-NPC pool, so positions carry across the seam.
|
|
self.ghosts = {}
|
|
for _, nb in ipairs(self.neighbors) do
|
|
local peers = {}
|
|
for _, obj in ipairs(nb.map.def.objects or {}) do
|
|
if objectVisible(Game.save, nb.map.id, obj) then
|
|
local npc = pooledNPC(self.npcPool, Game.data, nb.map.id, obj)
|
|
table.insert(peers, npc)
|
|
table.insert(self.ghosts,
|
|
{ npc = npc, map = nb.map, ox = nb.ox, oy = nb.oy,
|
|
peers = peers })
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- SGB overworld palette (engine/gfx/palettes.asm SetPal_Overworld):
|
|
-- towns use their own palette, routes PAL_ROUTE, interiors the town or
|
|
-- route they are in (wLastMap = our lastOutdoor), with tileset and
|
|
-- Elite Four special cases -- all of it field.palettes now.
|
|
|
|
-- one rung of the cascade: byMap, then byTileset, then byPrefix. Returns
|
|
-- nil when the map matches nothing, which is what sends the lookup on to
|
|
-- the last-outdoor memory.
|
|
local function paletteLookup(palettes, mapId, tileset)
|
|
local byMap = palettes.byMap
|
|
if byMap and byMap[mapId] then return byMap[mapId] end
|
|
local byTileset = palettes.byTileset
|
|
if byTileset and tileset and byTileset[tileset] then return byTileset[tileset] end
|
|
for _, row in ipairs(palettes.byPrefix or {}) do
|
|
if row.prefix and mapId:find(row.prefix, 1, true) == 1 then return row.palette end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
-- name -> name so the map.palette chain has a vanilla link to wrap
|
|
local function samePalette(name) return name end
|
|
local function sameTod(tod) return tod end
|
|
|
|
-- world.tod default: always DAY. A day/night mod returns "NIGHT",
|
|
-- "MORNING", etc.; the result is cached on the overworld and handed to
|
|
-- map.palette as ctx.tod so palette swaps can key off the period.
|
|
function OverworldState:timeOfDay()
|
|
local tod = self.tod or "DAY"
|
|
if not Runtime.wantsHook("world.tod") then return tod end
|
|
local map = self.map
|
|
local nextTod = Runtime.call("world.tod", sameTod, tod, {
|
|
map = map,
|
|
mapId = map and map.id,
|
|
x = self.player and self.player.cellX,
|
|
y = self.player and self.player.cellY,
|
|
steps = self.todSteps or 0,
|
|
})
|
|
if type(nextTod) ~= "string" or nextTod == "" then nextTod = tod end
|
|
if nextTod ~= tod then
|
|
self.tod = nextTod
|
|
if Runtime.wants("world.tod_changed") then
|
|
Runtime.emit("world.tod_changed", {
|
|
tod = nextTod, previous = tod, mapId = map and map.id,
|
|
})
|
|
end
|
|
else
|
|
self.tod = nextTod
|
|
end
|
|
return self.tod
|
|
end
|
|
|
|
function OverworldState:paletteNameFor(map)
|
|
local palettes = FieldDefaults.field(Game.data, "palettes")
|
|
local name = map.def.palette or paletteLookup(palettes, map.id, map.def.tileset)
|
|
if not name then
|
|
-- Interiors inherit the outdoor map they sit in. Before the player has
|
|
-- been outdoors at all, that is wLastMap's zero-fill -- map 0,
|
|
-- PALLET_TOWN -- and NOT the spawn: the vanilla spawn (REDS_HOUSE_2F)
|
|
-- is itself an interior and would fall through to the ROUTE default.
|
|
-- defaultHeal derives the same zero-fill map (wLastBlackoutMap shares
|
|
-- the reasoning) and lets a total conversion redirect it.
|
|
local boot = (Game.data.field and Game.data.field.boot) or {}
|
|
local last = self.lastOutdoor and self.lastOutdoor.id
|
|
or require("src.core.SaveData").defaultHeal(boot).map
|
|
local lastDef = last and Game.data.maps[last]
|
|
name = (last and paletteLookup(palettes, last, lastDef and lastDef.tileset))
|
|
or palettes.default
|
|
end
|
|
local tod = self:timeOfDay()
|
|
if not Runtime.wantsHook("map.palette") then return name end
|
|
return Runtime.call("map.palette", samePalette, name, map, { tod = tod })
|
|
end
|
|
|
|
-- UI-pass palette (text boxes and menus tint with the current map). OG RED
|
|
-- resolves every name to the one global red BG palette inside PaletteFX.pal,
|
|
-- so this needs no mode-specific branch.
|
|
--
|
|
-- TalkToPikachu's framed frontpic is the one exception: pokeyellow
|
|
-- LoadOverworldPikachuFrontpicPalettes loads the map pal as slot 0 and
|
|
-- PAL_PIKACHU_PORTRAIT as slot 1, then ATTR_BLK's the 5x5 pic at
|
|
-- (7,6)-(11,10) onto slot 1 (engine/gfx/palettes.asm:345-391). Without
|
|
-- that zone the pic wears the route/town palette and looks washed out.
|
|
function OverworldState:sgbPalettes()
|
|
local PaletteFX = require("src.render.PaletteFX")
|
|
local mapName = self:paletteNameFor(self.map)
|
|
if self.emote and self.emote.pikaPic then
|
|
local base = PaletteFX.pal(Game.data, mapName)
|
|
if not base then return nil end
|
|
local zones = { PaletteFX.whole(base) }
|
|
local portrait = PaletteFX.pal(Game.data, "PIKACHU_PORTRAIT")
|
|
if portrait then
|
|
zones[#zones + 1] = PaletteFX.zone(portrait, 7, 6, 11, 10)
|
|
end
|
|
return zones
|
|
end
|
|
return PaletteFX.wholeNamed(Game.data, mapName)
|
|
end
|
|
|
|
-- World-pass palette zones in world-canvas pixels: each visible map
|
|
-- area keeps its own SGB palette (a deliberate step past the original,
|
|
-- which recolored the whole screen per map -- see the survey zoom
|
|
-- entry in docs/known-differences.md). Border fill inherits the
|
|
-- current map's palette.
|
|
--
|
|
-- RED++ true overworld coloring does NOT go through this zone/shader
|
|
-- system at all: TileRenderer bakes real per-tile GBC colors straight into
|
|
-- a recolored tileset atlas (see TileRenderer's gbcAtlas), and
|
|
-- SpriteRenderer bakes sprites' OBP colors the same way, so the world
|
|
-- canvas is already final RGB by the time this runs. Returning an EMPTY
|
|
-- list here (when the current map has that baked atlas) skips the shader
|
|
-- entirely -- Renderer:endFrame's blit sees zoneList[1] == nil and falls
|
|
-- back to a plain, unshaded draw. Returning plain `nil` would NOT do this:
|
|
-- endFrame treats a nil worldZones as "no world-specific zones, reuse the
|
|
-- UI pass's zones" (sgbPalettes' whole-screen named-palette zone), which
|
|
-- would re-run the DMG shade-remap over already-true-color pixels using
|
|
-- an unrelated 4-color palette -- exactly the "colors are wrong" bug this
|
|
-- fixes.
|
|
function OverworldState:sgbWorldZones()
|
|
local PaletteFX = require("src.render.PaletteFX")
|
|
if PaletteFX.usesGbcPack() and self.map.renderer and self.map.renderer.gbcAtlas then
|
|
return {}
|
|
end
|
|
local base = PaletteFX.pal(Game.data, self:paletteNameFor(self.map))
|
|
if not base then return nil end
|
|
local vw, vh = Game.renderer:worldViewSize()
|
|
local cam = self.camera
|
|
local zones = { { colors = base, x = 0, y = 0, w = vw, h = vh } }
|
|
for _, nb in ipairs(self.neighbors) do
|
|
local colors = PaletteFX.pal(Game.data, self:paletteNameFor(nb.map))
|
|
if colors then
|
|
table.insert(zones, { colors = colors,
|
|
x = math.floor(nb.ox - cam.x),
|
|
y = math.floor(nb.oy - cam.y),
|
|
w = nb.map.def.width * 32,
|
|
h = nb.map.def.height * 32 })
|
|
end
|
|
end
|
|
return zones
|
|
end
|
|
|
|
-- wMapPalOffset, the one piece of state both halves of the darkness read:
|
|
-- drawWorld arms PaletteFX.DARK_BGP off self.dark for the shade-remapped
|
|
-- modes, and PaletteFX.setDarkWorld feeds the bakes ADVANCED does instead of
|
|
-- shading (tileset atlas, sprite sheets) plus their cache keys. A bake cannot
|
|
-- be re-shaded in place, so a change there rebuilds every resident map --
|
|
-- every dark floor, not just this one, since FLASH lights them all (#383).
|
|
function OverworldState:setDark(on)
|
|
on = on and true or false
|
|
self.dark = on
|
|
if PaletteFX.setDarkWorld(on) and PaletteFX.usesGbcPack() and self.map then
|
|
MapLoader.invalidateAll()
|
|
self:reloadMap(self.map.id, "dark")
|
|
end
|
|
end
|
|
|
|
function OverworldState:npcByIndex(index)
|
|
for _, n in ipairs(self.npcs) do
|
|
if n.def.index == index then return n end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
-- Bike riding allowlist (field.bikeRiding, from bike_riding_tilesets.asm
|
|
-- + IsBikeRidingAllowed's map exceptions); BagMenu's mount check reads
|
|
-- the same table.
|
|
function OverworldState:bikeAllowed(mapId)
|
|
local br = Game.data.field.bikeRiding
|
|
if not br then return Map.isOutdoor(self.map.def) end
|
|
for _, m in ipairs(br.maps) do
|
|
if m == mapId then return true end
|
|
end
|
|
for _, t in ipairs(br.tilesets) do
|
|
if t == self.map.def.tileset then return true end
|
|
end
|
|
return false
|
|
end
|
|
|
|
-- The battle transition's dungeon wipe uses the explicit map lists in
|
|
-- data/maps/dungeon_maps.asm (field.dungeonTransitionMaps): singles plus
|
|
-- inclusive map-id ranges -- faithful to the original's omissions
|
|
-- (Victory Road 2F/3F, the Rocket Hideout, Diglett's Cave, ... miss out).
|
|
function OverworldState:isDungeonTransitionMap()
|
|
local dm = Game.data.field.dungeonTransitionMaps
|
|
if not dm then return false end
|
|
for _, m in ipairs(dm.maps) do
|
|
if m == self.map.id then return true end
|
|
end
|
|
local idx = self.map.def.index
|
|
for _, r in ipairs(dm.ranges) do
|
|
local first = Game.data.maps[r.first]
|
|
local last = Game.data.maps[r.last]
|
|
if first and last and idx >= first.index and idx <= last.index then
|
|
return true
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
|
|
-- Start a battle behind the into-battle transition: flash, then the
|
|
-- wipe picked by trainer/level/dungeon (GetBattleTransitionID).
|
|
function OverworldState:pushBattle(battle)
|
|
local BattleTransition = require("src.render.BattleTransition")
|
|
local lead
|
|
for _, mon in ipairs(Game.save.party) do
|
|
if mon.hp > 0 then lead = mon break end
|
|
end
|
|
local enemyLevel = battle.enemy and battle.enemy.mon and battle.enemy.mon.level or 0
|
|
-- the battle theme starts with the wipe, not after it
|
|
-- (audio/play_battle_music.asm runs before the transition)
|
|
if battle.computeMusicKind then
|
|
require("src.core.Music").playBattle(Game.data, battle:computeMusicKind())
|
|
end
|
|
|
|
-- The fade back in from white on the way out is BattleState:finish()'s
|
|
-- job now -- the one choke point every battle passes through on exit,
|
|
-- guaranteed regardless of which caller pushed the battle -- so this
|
|
-- function only owns the entry wipe.
|
|
Game.stack:push(BattleTransition.new(Game, function()
|
|
Game.stack:push(battle)
|
|
end, {
|
|
trainer = battle.kind == "trainer",
|
|
stronger = lead ~= nil and enemyLevel >= lead.level + 3,
|
|
dungeon = self:isDungeonTransitionMap(),
|
|
}))
|
|
end
|
|
|
|
-- -------------------------------------------------------------------------
|
|
-- update
|
|
-- -------------------------------------------------------------------------
|
|
|
|
-- Queue a script for a map's onEnter hook to run once it is safe to. A
|
|
-- map load (setMap -> onEnter) can happen mid-warp, while the triggering
|
|
-- warp command's runner is still suspended-alive; starting a runner there
|
|
-- would trip ScriptRunner:run's assert(not isRunning()). So onEnter stashes
|
|
-- the script here and update() drains the FIFO head once the world is
|
|
-- idle, one script per idle frame.
|
|
function OverworldState:queueScript(script, extra)
|
|
local queue = self.pendingScripts
|
|
if not queue then
|
|
queue = {}
|
|
self.pendingScripts = queue
|
|
end
|
|
queue[#queue + 1] = { script = script, extra = extra,
|
|
mapId = self.map and self.map.id }
|
|
-- a runaway-loop tripwire, not a hard cap
|
|
if #queue > 16 then
|
|
Logger.warn("queueScript: %d scripts pending on %s",
|
|
#queue, tostring(self.map and self.map.id))
|
|
end
|
|
end
|
|
|
|
function OverworldState:drainPendingScripts()
|
|
local queue = self.pendingScripts
|
|
if queue and queue[1] and not self.transitioning
|
|
and not self.runner:isRunning() and #self.scriptMoves == 0 then
|
|
local pending = table.remove(queue, 1)
|
|
self.runner:run(pending.script, pending.extra)
|
|
end
|
|
end
|
|
|
|
-- Start a background script in one of the bounded parallel slots (09
|
|
-- §4.6); overflow waits FIFO-style behind the slots. rowsOrRef is a row
|
|
-- array or "MAP_ID/name" naming a map_scripts `scripts` entry.
|
|
local PARALLEL_SLOTS = 4
|
|
|
|
function OverworldState:startParallel(rowsOrRef, extra)
|
|
local rows = rowsOrRef
|
|
if type(rowsOrRef) == "string" then
|
|
local MapScripts = require("src.script.MapScripts")
|
|
local mapId, name = rowsOrRef:match("^([^/]+)/(.+)$")
|
|
rows = mapId and MapScripts.namedScript(mapId, name)
|
|
if not rows then
|
|
Logger.warn("run_parallel: no script '%s'", tostring(rowsOrRef))
|
|
return
|
|
end
|
|
-- a named entry belongs to its contribution: a caller with no
|
|
-- attribution of its own runs it as the owner
|
|
if not (extra and extra.source) then
|
|
local source = MapScripts.namedSource(mapId, name)
|
|
if source then
|
|
extra = extra or {}
|
|
extra.source = source
|
|
end
|
|
end
|
|
end
|
|
local queue = self.parallelQueue
|
|
if not queue then
|
|
queue = {}
|
|
self.parallelQueue = queue
|
|
end
|
|
queue[#queue + 1] = { rows = rows, extra = extra }
|
|
if #queue > 16 then
|
|
Logger.warn("run_parallel: %d scripts waiting for a slot", #queue)
|
|
end
|
|
end
|
|
|
|
function OverworldState:killParallel(runner)
|
|
runner.co = nil
|
|
for i, live in ipairs(self.parallelRunners or {}) do
|
|
if live == runner then
|
|
table.remove(self.parallelRunners, i)
|
|
break
|
|
end
|
|
end
|
|
for entity, holder in pairs(self.npcMoveLocks or {}) do
|
|
if holder == runner then self.npcMoveLocks[entity] = nil end
|
|
end
|
|
end
|
|
|
|
-- Parallel runners tick after the main runner and never touch the input
|
|
-- lockout: isRunning() checks consult only self.runner, exactly as
|
|
-- before. Dead runners free their slot and their NPC move locks.
|
|
function OverworldState:updateParallel()
|
|
local pool = self.parallelRunners
|
|
if not pool then return end
|
|
for i = #pool, 1, -1 do
|
|
if not pool[i]:isRunning() then
|
|
self:killParallel(pool[i])
|
|
end
|
|
end
|
|
local queue = self.parallelQueue
|
|
while queue and queue[1] and #pool < PARALLEL_SLOTS do
|
|
local next_ = table.remove(queue, 1)
|
|
local runner = ScriptRunner.new(Game, self)
|
|
runner.parallel = true
|
|
pool[#pool + 1] = runner
|
|
runner:run(next_.rows, next_.extra)
|
|
end
|
|
for _, runner in ipairs(pool) do runner:update() end
|
|
end
|
|
|
|
function OverworldState:update(dt)
|
|
-- deferred cutscene launch (see queueScript): run a queued script only
|
|
-- once the triggering warp's transition has finished, its runner has gone
|
|
-- dead, and no scripted walk is mid-step. This is how the HALL_OF_FAME
|
|
-- room cutscene starts a frame after the Champions Room warp completes.
|
|
self:drainPendingScripts()
|
|
self.runner:update()
|
|
self:updateParallel()
|
|
-- keep the player sprite in sync with the bike state (the drawer
|
|
-- picks the red_bike sheet while riding)
|
|
self.player.onBike = Game.save.onBike
|
|
-- the rendered neighbor set depends on the view size; zooming out (or
|
|
-- resizing) past what setMap computed re-runs the walk in place
|
|
if self.map and (self.neighborViewW or 0) > 0 then
|
|
local vw, vh = Game.renderer:worldViewSize()
|
|
if vw ~= self.neighborViewW or vh ~= self.neighborViewH then
|
|
self:rebuildNeighbors()
|
|
end
|
|
end
|
|
if self.dustAnim then
|
|
local da = self.dustAnim
|
|
da.frames = da.frames - 1
|
|
if da.frames <= 0 then
|
|
self.dustAnim = nil
|
|
if da.onDone then da.onDone() end
|
|
end
|
|
end
|
|
if self.cutAnim then
|
|
local ca = self.cutAnim
|
|
ca.frames = ca.frames - 1
|
|
if ca.frames <= 0 then
|
|
self.cutAnim = nil
|
|
if ca.onDone then ca.onDone() end
|
|
end
|
|
end
|
|
-- fishing pose tail: the rod is already gone, the pose holds for the
|
|
-- frames the original spends unwinding the item menu (#384)
|
|
if self.fishPose then
|
|
self.fishPose = self.fishPose - 1
|
|
if self.fishPose <= 0 then
|
|
self.fishPose = nil
|
|
self.player.fishing = nil
|
|
end
|
|
end
|
|
-- Yellow's companion hopping up onto the Poke Center counter owns the
|
|
-- world for its arc, the same way the heal machine below does (#417)
|
|
if self.pikaHop then
|
|
require("src.world.PikachuFollower").updateHop(self)
|
|
return
|
|
end
|
|
if self.healAnim then
|
|
local ha = self.healAnim
|
|
local ev = OverworldState.stepHealAnim(ha)
|
|
if ev == "ball" then
|
|
require("src.core.Sound").play(Game.data, "Healing_Machine")
|
|
elseif ev == "jingle" then
|
|
-- playOnce restores the map theme when the jingle ends; we no longer
|
|
-- block the fighting-fit text on that (#157)
|
|
require("src.core.Music").playOnce(Game.data, "Music_PkmnHealed")
|
|
elseif ev == "done" then
|
|
local done = ha.onDone
|
|
self.healAnim = nil
|
|
if done then done() end
|
|
end
|
|
return
|
|
end
|
|
if self.flyAnim then
|
|
self.flyAnim.frames = self.flyAnim.frames - 1
|
|
if self.flyAnim.frames <= 0 then
|
|
self.flyAnim = nil
|
|
self.player.inputLocked = false
|
|
local d = self.flyDest
|
|
self.flyDest = nil
|
|
if d then
|
|
-- the bird carries the player in on landing, with its own
|
|
-- SFX_FLY (EnterMapAnim .flyAnimation)
|
|
self.arriveWarp = "fly"
|
|
self:startWarpTo(d.map, d.x, d.y, "down", nil, { via = "fly" })
|
|
end
|
|
return
|
|
end
|
|
end
|
|
|
|
-- Dig/Teleport/Escape-Rope departure spin (beginTeleportOut). The sprite
|
|
-- spins UP out of the map before the fade (player_animations.asm
|
|
-- _LeaveMapAnim -> PlayerSpinWhileMovingUp + SFX_TELEPORT_EXIT_1), the
|
|
-- mirror of Fly's flyAnim lead-in above. Only when the spin finishes does
|
|
-- warpToHealPoint push the fade + warp, so the arrival spin-down lands the
|
|
-- player OUTSIDE the last Pokemon Center door (#196). player.spinFrames
|
|
-- decrements in lockstep in Player:update, so the rising spin ends here too.
|
|
if self.teleportOut then
|
|
self.teleportOut.frames = self.teleportOut.frames - 1
|
|
if self.teleportOut.frames <= 0 then
|
|
local onDone = self.teleportOut.onDone
|
|
self.teleportOut = nil
|
|
self.player.spinning = false
|
|
self.player.spinFrames = nil
|
|
self.player.spinRise = nil
|
|
self.player.inputLocked = false
|
|
self:warpToHealPoint(onDone, { arrive = "teleport" })
|
|
return
|
|
end
|
|
end
|
|
|
|
-- delayed one-shot SFX (the teleport-in spin's second note)
|
|
if self.delaySfx then
|
|
self.delaySfx.frames = self.delaySfx.frames - 1
|
|
if self.delaySfx.frames <= 0 then
|
|
require("src.core.Sound").play(Game.data, self.delaySfx.key)
|
|
self.delaySfx = nil
|
|
end
|
|
end
|
|
|
|
-- the emotion-bubble pause holds the world for a beat
|
|
if self.emote then
|
|
self.emote.frames = self.emote.frames - 1
|
|
-- PikaPicAnimTimerAndJoypad (engine/pikachu/pikachu_pic_animation.asm)
|
|
-- cuts a pikapic beat short on A or B; the "!" bubble hold has no such
|
|
-- check, so only the pikapic marks itself skippable (#424)
|
|
local cut = self.emote.skippable
|
|
and (Game.input:wasPressed("a") or Game.input:wasPressed("b"))
|
|
if cut or self.emote.frames <= 0 then
|
|
local done = self.emote.onDone
|
|
self.emote = nil
|
|
if done then done() end
|
|
end
|
|
self.player:update()
|
|
return
|
|
end
|
|
|
|
for _, npc in ipairs(self.npcs) do
|
|
npc:update(self.map, self.entities)
|
|
end
|
|
require("src.world.PikachuFollower").update(Game, self)
|
|
|
|
for _, g in ipairs(self.ghosts) do
|
|
g.npc:update(g.map, g.peers)
|
|
end
|
|
|
|
self:updateScriptMoves()
|
|
|
|
-- emote is included: a cutscene hold queued from a scriptMove onDone
|
|
-- (e.g. Oak's lab Delay3 after his entry walk) is assigned mid-frame,
|
|
-- after the early emote return above already missed it. Without this,
|
|
-- one frame of handleInput can sneak through -- holding UP during the
|
|
-- escort then walks an extra tile before PlayerEntryMovementRLE, and
|
|
-- the player lands on desk Oak.
|
|
local scripted = self.runner:isRunning() or #self.scriptMoves > 0
|
|
or self.engaging or self.emote or self.teleportOut
|
|
if not scripted and not self.transitioning then
|
|
self:checkTrainerSight()
|
|
-- CheckFightingMapTrainers (home/trainers.asm) zeroes hJoyHeld and
|
|
-- sets wJoyIgnore the instant a trainer engages, before the loop's
|
|
-- direction handling (JoypadOverworld runs the map script first) --
|
|
-- the player can never start another step after being spotted.
|
|
scripted = self.runner:isRunning() or #self.scriptMoves > 0
|
|
or self.engaging or self.emote or self.teleportOut
|
|
end
|
|
if not scripted and not self.transitioning then
|
|
self:handleInput()
|
|
end
|
|
|
|
local stepped = self.player:update()
|
|
-- the warp-arrival cell goes stale the instant the player's real cell
|
|
-- leaves it, scripted walk-outs included -- pokered re-checks warps
|
|
-- after simulated steps too (CheckWarpsNoCollision), so a forced
|
|
-- door-mat exit must not leave the door permanently inert
|
|
local entry = self.warpEntryCell
|
|
if entry and (self.player.cellX ~= entry.x or self.player.cellY ~= entry.y) then
|
|
self.warpEntryCell = nil
|
|
end
|
|
-- deferred PlayMapMusic from crossConnection (issue #93)
|
|
if stepped and self.pendingSeamMusic then
|
|
local mapId = self.pendingSeamMusic
|
|
self.pendingSeamMusic = nil
|
|
if mapId == self.map.id then
|
|
require("src.core.Music").playMap(Game.data, mapId, Game.save.onBike,
|
|
self.player.surfing)
|
|
end
|
|
end
|
|
if stepped and not scripted then
|
|
self:onStepComplete()
|
|
end
|
|
|
|
self.camera:follow(self.player.px, self.player.py,
|
|
Game.renderer:worldViewSize())
|
|
|
|
-- pan_camera offset rides on top of the follow; the ramp resumes its
|
|
-- runner when it lands
|
|
local pan = self.cameraPan
|
|
if pan then
|
|
if pan.frames then
|
|
pan.t = pan.t + 1
|
|
local k = math.min(1, pan.t / pan.frames)
|
|
pan.ox = pan.fromX + (pan.toX - pan.fromX) * k
|
|
pan.oy = pan.fromY + (pan.toY - pan.fromY) * k
|
|
if pan.t >= pan.frames then
|
|
pan.frames = nil
|
|
local done = pan.onDone
|
|
pan.onDone = nil
|
|
if done then done() end
|
|
end
|
|
end
|
|
self.camera.x = self.camera.x + pan.ox
|
|
self.camera.y = self.camera.y + pan.oy
|
|
end
|
|
end
|
|
|
|
-- any direction currently held (hJoyHeld & PAD_CTRL_PAD)
|
|
function OverworldState:dirHeld()
|
|
local input = Game.input
|
|
return input:isDown("up") or input:isDown("down")
|
|
or input:isDown("left") or input:isDown("right")
|
|
end
|
|
|
|
-- BIT_STANDING_ON_WARP (wMovementFlags): the warp under the player's feet may
|
|
-- only fire from a collision -- the blocked-step warp (handleInput) and the
|
|
-- map-edge exit (checkEdgeExit) -- while this flag is set. pokered clears it
|
|
-- on every completed step, sets it again when that step lands on a warp
|
|
-- square, then clears it once more when the square is a warp-activating tile
|
|
-- that is not also a door tile (CheckWarpsNoCollisionLoop ->
|
|
-- IsPlayerStandingOnDoorTileOrWarpTile, engine/overworld/player_state.asm);
|
|
-- onStepComplete maintains it. ClearVariablesOnEnterMap does not clear
|
|
-- wMovementFlags, so the flag rides through the warp itself: a house door
|
|
-- tile ($1B) leaves it set, so you land on the interior mat still able to
|
|
-- walk back out on that same tile (issue #378), while a staircase tile
|
|
-- ($1A/$1C) clears it and cannot bounce you between floors (issue #230).
|
|
function OverworldState:canCollisionWarp()
|
|
return self.standingOnWarp == true
|
|
end
|
|
|
|
-- Re-derive the flag from the tile under the player, the way a completed step
|
|
-- does (and the way MapEntryAfterBattle's IsPlayerStandingOnWarp does after a
|
|
-- battle): a door tile keeps it, a stair/ladder warp tile clears it.
|
|
function OverworldState:refreshStandingOnWarp()
|
|
local p = self.player
|
|
self.standingOnWarp = false
|
|
if self.map:warpAtCell(p.cellX, p.cellY)
|
|
and not (self.map:isWarpTileCell(p.cellX, p.cellY)
|
|
and not self.map:isDoorTileCell(p.cellX, p.cellY)) then
|
|
self.standingOnWarp = true
|
|
end
|
|
end
|
|
|
|
function OverworldState:handleInput()
|
|
local input = Game.input
|
|
|
|
-- the wall-bonk SFX cooldown ticks with any held direction, step or not
|
|
-- (it is a port invention, not part of JoypadOverworld, so the
|
|
-- wWalkCounter gate below must not freeze it mid-step)
|
|
if self:dirHeld() then
|
|
self.bumpCooldown = math.max(0, (self.bumpCooldown or 0) - 1)
|
|
end
|
|
|
|
-- OverworldLoop (home/overworld.asm) gates ALL of JoypadOverworld on
|
|
-- wWalkCounter == 0 ("if the player sprite has not yet completed the
|
|
-- walking animation" it jumps straight to .moveAhead): A, START and
|
|
-- direction initiation are only ever ACTED ON while the player stands on
|
|
-- a tile. Without this gate a mid-step A/START pushed its TextBox/
|
|
-- StartMenu right there and froze Red between tiles, mid-animation
|
|
-- (#286). Held directions need no buffering -- isDown below picks them
|
|
-- up on the landing frame.
|
|
--
|
|
-- The original defers the poll rather than discarding it, though. Joypad
|
|
-- (engine/joypad.asm _Joypad) computes hJoyPressed against hJoyLast and
|
|
-- advances hJoyLast only when something calls it; the mid-step path never
|
|
-- does, and vblank's per-frame ReadJoypad refreshes hJoyInput alone.
|
|
-- hJoyLast is frozen for the whole animation, so a button pressed
|
|
-- mid-step and STILL HELD when the step lands reads as a fresh press at
|
|
-- the next poll -- one released before then is genuinely lost. Dropping
|
|
-- the edge outright made START a coin flip on the Cycling Road roll,
|
|
-- where the pull below re-arms a step on the single idle frame in
|
|
-- bikeStepFrames (#525).
|
|
if self.player.moving then
|
|
local held = self.joyLatch
|
|
if not held then held = {}; self.joyLatch = held end
|
|
if input:wasPressed("a") then held.a = true end
|
|
if input:wasPressed("start") then held.start = true end
|
|
return
|
|
end
|
|
local latch = self.joyLatch
|
|
self.joyLatch = nil
|
|
|
|
if input:wasPressed("a") or (latch and latch.a and input:isDown("a")) then
|
|
self:interact()
|
|
return
|
|
end
|
|
if input:wasPressed("start")
|
|
or (latch and latch.start and input:isDown("start")) then
|
|
require("src.core.Sound").play(Game.data, "Start_Menu")
|
|
Screens.push(Game, "StartMenu")
|
|
return
|
|
end
|
|
|
|
for _, dir in ipairs({ "up", "down", "left", "right" }) do
|
|
if input:isDown(dir) then
|
|
if not self.player.moving and self.player.facing == dir then
|
|
if self:checkEdgeExit(dir) then return end
|
|
if self:checkLedgeHop(dir) then return end
|
|
if self:checkBoulderPush(dir) then return end
|
|
end
|
|
local result, why = self.player:tryMove(dir, self.map, self.entities)
|
|
-- a collision while standing on a warp square fires the warp when the
|
|
-- extra check passes (CheckWarpsCollision: route-gate doorways, dock
|
|
-- entrances, ...), and only while BIT_STANDING_ON_WARP is set (issue
|
|
-- #230), which the map-edge path guards the same way.
|
|
if result == "blocked" and self:canCollisionWarp() then
|
|
local w = Warp.onCollision(self.map, Game.data.field.warpCarpets,
|
|
self.player.cellX, self.player.cellY, dir)
|
|
if w then
|
|
self:takeWarp(w.def)
|
|
return result
|
|
end
|
|
end
|
|
if result == "blocked" and why ~= "entity" then
|
|
if (self.bumpCooldown or 0) <= 0 then
|
|
require("src.core.Sound").play(Game.data, "Collision")
|
|
self.bumpCooldown = 16
|
|
end
|
|
end
|
|
return result
|
|
end
|
|
end
|
|
|
|
-- .noDirectionButtonsPressed (home/overworld.asm) is the only place that
|
|
-- sets wCheckFor180DegreeTurn, so reaching this line -- a poll that found
|
|
-- no direction held -- is what re-arms the next turn in place. The early
|
|
-- returns above (mid-step, A, START) skip it exactly as the original's
|
|
-- jumps to .moveAhead and .displayDialogue do (#415).
|
|
self.player.turnArmed = true
|
|
|
|
-- Cycling Road's downhill pull: with no d-pad held the bike rolls
|
|
-- south (home/overworld.asm JoypadOverworld's simulated PAD_DOWN).
|
|
-- The mask there is PAD_CTRL_PAD | PAD_B | PAD_A, so HOLDING A or B
|
|
-- brakes exactly like a held direction: what the Route 17 sign
|
|
-- promises ("Press the A or B Button to stay in place") and what the
|
|
-- edge-only wasPressed("a") above can never deliver, since a press
|
|
-- stalls the roll for one frame only (issue #255).
|
|
local fm = Game.data.field.forcedMovement
|
|
local braking = input:isDown("a") or input:isDown("b")
|
|
if fm and Game.save.onBike and not braking and not self.player.moving then
|
|
for _, m in ipairs(fm.slopeMaps or {}) do
|
|
if m == self.map.id then
|
|
self.player.facing = "down"
|
|
self.player:tryMove("down", self.map, self.entities)
|
|
return
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Strength boulders (engine/overworld/push_boulder.asm TryPushingBoulder):
|
|
-- walking into one with STRENGTH in the party pushes it one cell, but
|
|
-- only on the second consecutive push attempt (BIT_TRIED_PUSH_BOULDER);
|
|
-- SFX_PUSH_BOULDER when the push starts, dust puff + SFX_CUT after.
|
|
function OverworldState:checkBoulderPush(dir)
|
|
local p = self.player
|
|
local fx, fy = Collision.target(p.cellX, p.cellY, dir)
|
|
local npc = self:npcAtCell(fx, fy)
|
|
if not npc or not Map.isPushable(npc.def) or npc.moving then
|
|
self.boulderTried = nil -- pokered resets when no boulder is in front
|
|
return false
|
|
end
|
|
-- BIT_STRENGTH_ACTIVE (wStatusFlags1): set only by the party-menu
|
|
-- STRENGTH action on this map and cleared on every map load.
|
|
-- push_boulder.asm TryPushingBoulder gates on nothing else -- it never
|
|
-- re-checks the party's moves or badges at push time, so once STRENGTH
|
|
-- is activated any party member can push (even if the STRENGTH-knowing
|
|
-- mon is later boxed/swapped out).
|
|
if not self.strengthActive then return false end
|
|
if self.boulderTried ~= npc then
|
|
self.boulderTried = npc
|
|
return false -- first attempt only arms the push
|
|
end
|
|
local bx, by = Collision.target(fx, fy, dir)
|
|
if not self.map:inBounds(bx, by) then self.boulderTried = nil return false end
|
|
-- CheckForCollisionWhenPushingBoulder uses the same walkable check as
|
|
-- player movement (CheckTilePassable walks the same wTilesetCollisionPtr
|
|
-- list) -- there is no hole/warp escape hatch in the original, so a
|
|
-- boulder can never be pushed onto a cell the player cannot walk onto.
|
|
-- The known push targets (CAVERN $22 holes, Victory Road switches) are
|
|
-- walkable tiles in their tileset's coll list already, so removing the
|
|
-- port's isWarpTileCell exception only stops wall pushes (#754).
|
|
if not self.map:isWalkableCell(bx, by) then
|
|
self.boulderTried = nil
|
|
return false
|
|
end
|
|
if Collision.occupied(self.entities, bx, by, npc) then
|
|
self.boulderTried = nil
|
|
return false
|
|
end
|
|
require("src.core.Sound").play(Game.data, "Push_Boulder")
|
|
self:scriptMove(npc, dir, 1, function()
|
|
self.boulderTried = nil
|
|
-- dust smoke + SFX_CUT once the boulder settles (DoBoulderDustAnimation)
|
|
self:startDustAnim(fx, fy, function()
|
|
require("src.core.Sound").play(Game.data, "Cut")
|
|
end)
|
|
if self:boulderIntoHole(npc) then return end
|
|
Runtime.emit("world.boulder_moved", { mapId = self.map.id, npcId = npc.id,
|
|
x = npc.cellX, y = npc.cellY })
|
|
local hooks = mapScripts.get(self.map.id)
|
|
if hooks and hooks.onBoulderMoved then
|
|
hooks.onBoulderMoved(Game, self, npc)
|
|
end
|
|
end)
|
|
return true
|
|
end
|
|
|
|
-- The dust puff (engine/overworld/dust_smoke.asm AnimateBoulderDust):
|
|
-- the 8x8 smoke tile drawn as a 2x2 block over the vacated cell,
|
|
-- flickering for 8 steps of ~4 frames.
|
|
function OverworldState:startDustAnim(cx, cy, onDone)
|
|
self.dustAnim = { x = cx, y = cy, frames = 32, onDone = onDone }
|
|
end
|
|
|
|
-- Ledge hops (data/tilesets/ledge_tiles.asm): standing tile + ledge tile
|
|
-- in front + matching input direction -> jump two cells.
|
|
function OverworldState:checkLedgeHop(dir)
|
|
local p = self.player
|
|
local tileset = self.map.def.tileset
|
|
local standing = self.map:cellTile(p.cellX, p.cellY)
|
|
local fx, fy = Collision.target(p.cellX, p.cellY, dir)
|
|
if not self.map:inBounds(fx, fy) then return false end
|
|
local front = self.map:cellTile(fx, fy)
|
|
-- a row without a tileset applies everywhere; the vanilla rows are all
|
|
-- OVERWORLD, which is what the deleted hard gate used to say
|
|
for _, ledge in ipairs(Game.data.field.ledges) do
|
|
if (ledge.tileset or "OVERWORLD") == tileset
|
|
and ledge.facing == dir and ledge.input == dir
|
|
and ledge.standingTile == standing and ledge.ledgeTile == front then
|
|
local lx, ly = Collision.target(fx, fy, dir)
|
|
if not self.map:inBounds(lx, ly) then
|
|
-- The landing is on the CONNECTED map. pokered never checks where a
|
|
-- hop lands (engine/overworld/ledges.asm HandleLedges just simulates
|
|
-- two presses in the hop direction) and the connection strip is
|
|
-- loaded, so ROUTE_4's bottom-row ledge at (12,17)/(13,17) really
|
|
-- does drop onto ROUTE_3 row 0 (south connection, offset -25 ->
|
|
-- destX = curX + 50; ROUTE_3 (62,0)/(63,0) are walkable $39/$23):
|
|
-- the one-way shortcut off the Mt Moon plaza that the in-bounds gate
|
|
-- was silently refusing, which is issue #223. Validate the seam
|
|
-- cell the way crossConnection does, hop the first cell onto the
|
|
-- ledge tile, and hand the second to checkEdgeExit, which owns the
|
|
-- crossing.
|
|
local dest, ts, cx, cy = self:connectionLanding(dir)
|
|
if not (dest and Map.defPassable(dest, ts, cx, cy, p.surfing)) then
|
|
return false
|
|
end
|
|
require("src.core.Sound").play(Game.data, "Ledge")
|
|
p.hopFrames, p.hopTotal = 32, 32 -- jump arc (cosmetic)
|
|
self:scriptMove(p, dir, 1, function() self:checkEdgeExit(dir) end)
|
|
return true
|
|
end
|
|
if not Collision.occupied(self.entities, lx, ly, p)
|
|
and self.map:isWalkableCell(lx, ly) then
|
|
require("src.core.Sound").play(Game.data, "Ledge")
|
|
p.hopFrames, p.hopTotal = 32, 32 -- jump arc (cosmetic)
|
|
self:scriptMove(p, dir, 2)
|
|
return true
|
|
end
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
|
|
-- walking off the map edge: connection crossing or edge warp (exit mats)
|
|
function OverworldState:checkEdgeExit(dir)
|
|
local p = self.player
|
|
local tx, ty = Collision.target(p.cellX, p.cellY, dir)
|
|
if self.map:inBounds(tx, ty) then return false end
|
|
|
|
local w = Warp.onEdge(self.map, p.cellX, p.cellY, dir)
|
|
if w then
|
|
-- ...but only with BIT_STANDING_ON_WARP set: a staircase tile clears it,
|
|
-- so pushing into the edge beside one bonks (SFX + walk-in-place) instead
|
|
-- of bouncing floors (issue #230), while the door mat you warped in on
|
|
-- keeps it and exits on that same tile (issue #378).
|
|
if not self:canCollisionWarp() then return false end
|
|
self:takeWarp(w.def)
|
|
return true
|
|
end
|
|
|
|
local conn = self.map:connection(COMPASS[dir])
|
|
if conn then
|
|
return self:crossConnection(dir, conn)
|
|
end
|
|
return false
|
|
end
|
|
|
|
-- Landing cell on the connected map for a step off this map's edge in
|
|
-- `dir` (same math as crossConnection). Returns destDef, tilesetDef, x, y
|
|
-- or nil when there is no usable connection.
|
|
function OverworldState:connectionLanding(dir)
|
|
local conn = self.map:connection(COMPASS[dir])
|
|
if not conn then return nil end
|
|
local dest = Game.data.maps[conn.map]
|
|
if not dest then return nil end
|
|
local ts = Game.data.tilesets[dest.tileset]
|
|
if not ts then return nil end
|
|
local p = self.player
|
|
local destW, destH = dest.width * 2, dest.height * 2
|
|
local x, y
|
|
if dir == "up" then
|
|
x, y = p.cellX - conn.offset * 2, destH - 1
|
|
elseif dir == "down" then
|
|
x, y = p.cellX - conn.offset * 2, 0
|
|
elseif dir == "left" then
|
|
x, y = destW - 1, p.cellY - conn.offset * 2
|
|
else
|
|
x, y = 0, p.cellY - conn.offset * 2
|
|
end
|
|
x = math.max(0, math.min(destW - 1, x))
|
|
y = math.max(0, math.min(destH - 1, y))
|
|
return dest, ts, x, y, conn
|
|
end
|
|
|
|
-- Map connections: the connected map's strip offset is in blocks; arriving
|
|
-- coordinates follow destX = curX - offset*2 (see docs/extraction-notes.md).
|
|
-- The crossing scrolls continuously: the map data swaps while the player
|
|
-- is placed one cell before the entry point (their old world position,
|
|
-- which the neighbor strips render identically) and walks the seam step.
|
|
function OverworldState:crossConnection(dir, conn)
|
|
local dest, ts, x, y = self:connectionLanding(dir)
|
|
if not dest then
|
|
Logger.warn("connection to unknown map %s", tostring(conn and conn.map))
|
|
return false
|
|
end
|
|
local p = self.player
|
|
-- pokered's collision check reads the NEIGHBOR strip's tile bytes, so
|
|
-- stepping off the edge onto a solid tile of the connected map bumps
|
|
-- exactly like an in-map wall. Without this read, Pallet's south
|
|
-- shore (land at x2-3) walked straight onto ROUTE_21 (3,0) -- a
|
|
-- collision tile -- stranding the player on a cell no walk can leave.
|
|
if not Map.defPassable(dest, ts, x, y, p.surfing) then
|
|
return false
|
|
end
|
|
-- keepMusic: defer PlayMapMusic until the seam step lands. Starting a
|
|
-- new chip song inside setMap used to hitch the render thread (~200ms)
|
|
-- so FixedStep catch-up ate the walk frames (issue #93). Threaded synth
|
|
-- removed most of that hitch; discarding catch-up + deferring the song
|
|
-- still protects the visible step when neighbor rebuild or the sync
|
|
-- fallback stalls, and avoids the rare one-frame volume spike from a
|
|
-- song swap mid-step.
|
|
-- Yellow's follower crosses the seam as one continuous walk, so hand the
|
|
-- live instance through setMap (which rebuilds self.npcs) instead of
|
|
-- letting it respawn behind the player (#427)
|
|
local PikachuFollower = require("src.world.PikachuFollower")
|
|
local pika = PikachuFollower.current(self)
|
|
local fromX, fromY = p.cellX, p.cellY
|
|
self:setMap(conn.map, x, y, p.facing,
|
|
{ seamless = true, keepMusic = true, keepPikachu = pika })
|
|
self.pendingSeamMusic = conn.map
|
|
-- place the player one cell before the seam (their old world spot,
|
|
-- which the neighbor strip renders identically) and start the step
|
|
-- into the new map RIGHT NOW so there is no one-frame stall at the
|
|
-- boundary (updateScriptMoves already ran this frame; kicking the
|
|
-- move here lets player:update animate the first pixel immediately)
|
|
local d = DIRVEC[dir]
|
|
p.cellX, p.cellY = x - d[1], y - d[2]
|
|
p.px, p.py = p.cellX * 16, p.cellY * 16
|
|
-- same translation for the follower and the cell it is chasing
|
|
PikachuFollower.rebase(self, p.cellX - fromX, p.cellY - fromY)
|
|
self.camera:follow(p.px, p.py)
|
|
p.facing = dir
|
|
p.targetX, p.targetY = x, y
|
|
p.moving = true
|
|
p.progress = 0
|
|
-- fresh walk-cycle clock so the seam step always shows leg frames
|
|
-- (mid-cycle stand phase would otherwise look like a slide)
|
|
p.animClock = 0
|
|
p.stepFramesCur = Game.save.onBike
|
|
and (FieldDefaults.world(Game.data, "bikeStepFrames") or 8)
|
|
or (FieldDefaults.world(Game.data, "stepFrames") or 16)
|
|
require("src.core.FixedStep"):discardCatchup()
|
|
return true
|
|
end
|
|
|
|
-- ItemUseSurfboard's simulated pad press: step onto the facing cell, or
|
|
-- cross a map connection when that cell is off this map's edge (Cinnabar
|
|
-- east coast -> Route 20 water, and the reverse dismount ashore).
|
|
function OverworldState:stepForwardOrCrossEdge(dir)
|
|
dir = dir or self.player.facing
|
|
local fx, fy = Collision.target(self.player.cellX, self.player.cellY, dir)
|
|
if not self.map:inBounds(fx, fy) then
|
|
return self:checkEdgeExit(dir)
|
|
end
|
|
self:scriptMove(self.player, dir, 1)
|
|
return true
|
|
end
|
|
|
|
-- IsNextTileShoreOrWater across a connection strip: pokered loads the
|
|
-- neighbor's tiles into the border, so wTileInFrontOfPlayer is the
|
|
-- connected map's tile even when the facing cell is off this map.
|
|
-- Shore/water classification still uses THIS map's tileset rules
|
|
-- (SHIP_PORT's $32 dock exception), matching the asm.
|
|
function OverworldState:facingIsShoreOrWater()
|
|
if not self:tilesetHasWater() then return false end
|
|
local fx, fy = self.player:facingCell()
|
|
if self.map:inBounds(fx, fy) then
|
|
return self.map:isWaterCell(fx, fy)
|
|
end
|
|
local dest, ts, x, y = self:connectionLanding(self.player.facing)
|
|
if not dest then return false end
|
|
local tile = Map.defCellTile(dest, ts, x, y)
|
|
if tile == nil then return false end
|
|
return self.map.waterTiles[tile] or false
|
|
end
|
|
|
|
-- tryToStopSurfing land check, including a land landing across a map
|
|
-- connection (surf off Cinnabar's east coast water back onto the coast).
|
|
function OverworldState:facingIsLandDismount()
|
|
local p = self.player
|
|
local fx, fy = p:facingCell()
|
|
if self.map:inBounds(fx, fy) then
|
|
return self.map:isWalkableCell(fx, fy)
|
|
and Collision.canMove(self.map, self.entities, p, p.facing)
|
|
end
|
|
local dest, ts, x, y = self:connectionLanding(p.facing)
|
|
if not dest then return false end
|
|
if not Map.defIsWalkableCell(dest, ts, x, y) then return false end
|
|
-- IsSpriteInFrontOfPlayer2: no current-map sprite can sit past the edge
|
|
return not Collision.occupied(self.entities, fx, fy, p)
|
|
end
|
|
|
|
-- -------------------------------------------------------------------------
|
|
-- interactions
|
|
-- -------------------------------------------------------------------------
|
|
|
|
-- HM field moves are gated by badges like the original
|
|
-- (constants.hmBadges; distinct from constants.hmMoves, the forget gate).
|
|
-- Gen 1 allows field use from fainted party members (party menu + name
|
|
-- lookup for Cut/Surf messages); do not require mon.hp > 0 here.
|
|
local function partyKnowsVanilla(moveId)
|
|
local gate = (FieldDefaults.constant(Game.data, "hmBadges") or {})[moveId]
|
|
local badge = gate and gate.badge
|
|
if badge and not Game.save.inventory[badge] then
|
|
return nil
|
|
end
|
|
for _, mon in ipairs(Game.save.party) do
|
|
for _, mv in ipairs(mon.moves) do
|
|
if mv.id == moveId then return mon end
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
function OverworldState:partyKnows(moveId)
|
|
-- a mod may unlock a field move another way (an HM in the bag, a rental
|
|
-- mon); next_ is the whole vanilla check, so calling it first keeps
|
|
-- vanilla answers winning
|
|
if Runtime.wantsHook("fieldmove.eligibility") then
|
|
return Runtime.call("fieldmove.eligibility", partyKnowsVanilla, moveId,
|
|
{ save = Game.save, data = Game.data })
|
|
end
|
|
return partyKnowsVanilla(moveId)
|
|
end
|
|
|
|
-- IsSurfingPikachuInParty (home/map_objects.asm): when the SURF-mon
|
|
-- is a Pikachu, pose() renders the Pikachu surf sprite. Called at
|
|
-- every surf-state change so a reloaded save picks the right sheet
|
|
-- after a party change. No-op when not surfing.
|
|
function OverworldState:syncSurfingPikachu()
|
|
local p = self.player
|
|
if not p then return end
|
|
if not p.surfing then
|
|
p.surfingPikachu = false
|
|
return
|
|
end
|
|
local mon = self:partyKnows("SURF")
|
|
p.surfingPikachu = mon ~= nil and mon.species == "PIKACHU" or false
|
|
end
|
|
|
|
-- The rejection loop shared by the Good and Super Rods
|
|
-- (item_effects.asm ItemUseGoodRod .RandomLoop / ReadSuperRodData): an
|
|
-- odd random byte is no bite; otherwise a 2-bit pick rerolls until it
|
|
-- lands inside the group, so the bite odds are size/(size+4)
|
|
-- (1/3 for the Good Rod's pair, up to 1/2 for 4-mon Super Rod groups).
|
|
local function rollFishingGroup(group)
|
|
while true do
|
|
local r = love.math.random(0, 255)
|
|
if r % 2 == 1 then return nil end
|
|
local pick = math.floor(r / 2) % 4
|
|
if pick < #group then
|
|
local slot = group[pick + 1]
|
|
return { species = slot.species, level = slot.level }
|
|
end
|
|
end
|
|
end
|
|
|
|
-- field.fishing: `always` hooks that catch every time (the Old Rod),
|
|
-- `pool` a fixed candidate list, `perMap` the field key holding per-map
|
|
-- groups. The rejection-loop odds above stay engine behavior.
|
|
local function fishingPool(data, rod, mapId)
|
|
local def = (FieldDefaults.field(data, "fishing") or {})[rod]
|
|
if not def then return nil end
|
|
if def.pool then return def.pool end
|
|
if def.perMap then
|
|
local groups = data.field[def.perMap]
|
|
return groups and groups[mapId]
|
|
end
|
|
return nil, def.always
|
|
end
|
|
|
|
local function catchFrom(pool, always)
|
|
if always then return { species = always.species, level = always.level } end
|
|
if pool and #pool > 0 then return rollFishingGroup(pool) end
|
|
return nil
|
|
end
|
|
|
|
-- Fishing (engine/items/item_effects.asm FishingInit + engine/overworld):
|
|
-- Old Rod always hooks a L5 Magikarp; Good Rod bites ~1/3 for
|
|
-- Goldeen/Poliwag L10; Super Rod uses the map's extracted fishing group
|
|
-- (no group means "Not even a nibble!").
|
|
function OverworldState:goFishing(rod)
|
|
local pool, always = fishingPool(Game.data, rod, self.map.id)
|
|
local enc
|
|
if Runtime.wantsHook("encounter.fishing") then
|
|
-- the chain may inspect or replace the candidate list before the roll
|
|
enc = Runtime.call("encounter.fishing", function(_, _, candidates)
|
|
return catchFrom(candidates, always)
|
|
end, rod, self.map.id, pool)
|
|
else
|
|
enc = catchFrom(pool, always)
|
|
end
|
|
-- the bobber waits a beat before the verdict (the original's
|
|
-- FishingInit dot animation); the rod pose draws in the meantime
|
|
self.fishing = { facing = self.player.facing }
|
|
self.player.fishing = true
|
|
Game.stack:push(TextBox.new(Game, ". . .", function()
|
|
-- FishingAnim (engine/overworld/player_animations.asm) holds
|
|
-- BIT_LEDGE_OR_FISHING -- the rod OAM and the fishing pose -- through
|
|
-- PrintText and only clears it once the verdict box is done, so the rod
|
|
-- must NOT vanish with the dots box (#321).
|
|
if not enc then
|
|
Game.stack:push(TextBox.new(Game, Strings("Not even a nibble!"), function()
|
|
-- the rod OAM goes out with the verdict box (res BIT_LEDGE_OR_FISHING
|
|
-- straight after PrintText) but the player keeps the patched tiles
|
|
-- until the overworld reloads them a few frames later
|
|
-- (RestoreScreenTilesAndReloadTilePatterns, home/palettes.asm ->
|
|
-- ReloadMapSpriteTilePatterns, home/reload_sprites.asm) -- #384
|
|
self.fishing = nil
|
|
self.fishPose = 10
|
|
end))
|
|
return
|
|
end
|
|
Game.stack:push(TextBox.new(Game, Strings("Oh!\nIt's a bite!"), function()
|
|
-- the bite goes straight into battle, which reloads the sprite tiles
|
|
self.fishing = nil
|
|
self.player.fishing = nil
|
|
local BattleState = require("src.battle.BattleState")
|
|
local battle = BattleState.newWild(Game, enc.species, enc.level, { hooked = true })
|
|
if Game.save.safari and Map.inRegion(self.map.def, "SAFARI", "SAFARI_ZONE") then
|
|
battle:makeSafari(Game.save.safari)
|
|
end
|
|
battle.onFinish = function(result) self:afterBattle(result, battle) end
|
|
self:pushBattle(battle)
|
|
end))
|
|
end))
|
|
end
|
|
|
|
-- Fly to a visited town (called from the party menu).
|
|
function OverworldState:flyTo(mapId)
|
|
local spot = Game.data.field.flyWarps[mapId]
|
|
if not spot then return end
|
|
require("src.core.Sound").play(Game.data, "Fly")
|
|
Game.save.onBike = false
|
|
Game.save.forcedBike = nil -- HandleFlyWarpOrDungeonWarp res BIT_ALWAYS_ON_BIKE
|
|
self.player.surfing = false
|
|
self:syncSurfingPikachu()
|
|
-- the bird carries the player off westward before the warp
|
|
-- (engine/overworld/player_animations.asm LoadBirdSpriteGraphics)
|
|
self.flyAnim = { frames = 48 }
|
|
self.player.inputLocked = true
|
|
self.flyDest = { map = mapId, x = spot.x, y = spot.y }
|
|
end
|
|
|
|
-- Dig / Teleport / Escape Rope departure animation, then land OUTSIDE the
|
|
-- last Pokemon Center door like Fly (#196). pokered's _LeaveMapAnim
|
|
-- (engine/overworld/player_animations.asm) plays SFX_TELEPORT_EXIT_1 and
|
|
-- spins the player while it rises up off the map (PlayerSpinWhileMovingUp)
|
|
-- before the palettes fade; Fly's bird lead-in (flyTo/flyAnim) is the
|
|
-- analogous departure this mirrors. When the spin finishes (the teleportOut
|
|
-- countdown in OverworldState:update), warpToHealPoint pushes the fade + warp
|
|
-- with arrive="teleport" so the sprite spins back DOWN in front of the town
|
|
-- PC door. Shared by the party-menu DIG/TELEPORT action and BagMenu's
|
|
-- ESCAPE ROPE so all three animate identically.
|
|
function OverworldState:beginTeleportOut(onDone)
|
|
if not Game.save.lastHeal then
|
|
-- a save that has never visited a Pokemon Center has no heal point to
|
|
-- warp to; skip the animation entirely (matches the old guard that did
|
|
-- nothing when lastHeal was absent) instead of spinning into a nil warp
|
|
if onDone then onDone() end
|
|
return
|
|
end
|
|
require("src.core.Sound").play(Game.data, "Teleport_Exit1")
|
|
self.player.surfing = false
|
|
self:syncSurfingPikachu()
|
|
self.player.inputLocked = true
|
|
-- rising spin: the mirror of the arrival spin-drop set in startWarpTo, so
|
|
-- spinRise lifts the sprite (Player:pose) while spinFrames counts down
|
|
self.player.spinning = true
|
|
self.player.spinTimer = 0
|
|
self.player.spinFrames = 48
|
|
self.player.spinTotal = 48
|
|
self.player.spinRise = true
|
|
self.teleportOut = { frames = 48, onDone = onDone }
|
|
end
|
|
|
|
function OverworldState:npcAtCell(cx, cy)
|
|
for _, npc in ipairs(self.npcs) do
|
|
if (npc.cellX == cx and npc.cellY == cy) or
|
|
(npc.targetX == cx and npc.targetY == cy) then
|
|
return npc
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
-- what the A press resolved to, for world.interacted's listeners
|
|
local function interacted(self, fx, fy, kind, target)
|
|
Runtime.emit("world.interacted", { mapId = self.map.id, x = fx, y = fy,
|
|
kind = kind, target = target })
|
|
end
|
|
|
|
function OverworldState:interact()
|
|
local p = self.player
|
|
local fx, fy = p:facingCell()
|
|
|
|
local npc = self:npcAtCell(fx, fy)
|
|
if not npc and self.map:isCounterCell(fx, fy) then
|
|
-- talk across counters (mart clerks, nurses); uses the tileset's
|
|
-- counter tiles from tileset_headers.asm
|
|
local fx2, fy2 = Collision.target(fx, fy, p.facing)
|
|
npc = self:npcAtCell(fx2, fy2)
|
|
end
|
|
if npc then
|
|
if npc.pikachuFollower then
|
|
-- the companion answers directly (TalkToPikachu), no map text id --
|
|
-- and it answers mid-step too. pikachu_follow.asm walks the follower
|
|
-- on the player's own step clock, so the original never has it
|
|
-- mid-tile while the player stands; this port's follow is a frame
|
|
-- late (the npc loop runs before Player:update lands the step), so
|
|
-- the not-moving gate used to eat the A press in the frames right
|
|
-- after landing -- exactly when you turn round to face it (#407).
|
|
-- talk() lands the follower on its cell first.
|
|
require("src.world.PikachuFollower").talk(Game, self, npc)
|
|
elseif not npc.moving then
|
|
self:talkTo(npc)
|
|
end
|
|
interacted(self, fx, fy, "npc", npc)
|
|
return
|
|
end
|
|
|
|
local sign = self.map:signAtCell(fx, fy)
|
|
if sign then
|
|
self:showMapText(sign.text, nil)
|
|
interacted(self, fx, fy, "sign", sign)
|
|
return
|
|
end
|
|
|
|
-- Silph Co card key doors (engine/events/card_key.asm)
|
|
if self:tryCardKeyDoor(fx, fy) then
|
|
interacted(self, fx, fy, "door")
|
|
return
|
|
end
|
|
|
|
-- hidden items / coins / slot machines / PC tiles / bench guys /
|
|
-- gym statues / trash cans (data/events/hidden_events.asm)
|
|
if self:tryHiddenObject(fx, fy) then
|
|
interacted(self, fx, fy, "hidden")
|
|
return
|
|
end
|
|
|
|
-- No overworld A-press hook for field moves: pokered has no such hook
|
|
-- anywhere -- CUT and SURF (like FLY/FLASH/DIG/TELEPORT/STRENGTH) are
|
|
-- only ever chosen from the party menu's per-mon field-move submenu
|
|
-- (start_sub_menus.asm .outOfBattleMovePointers), and only succeed if
|
|
-- the player happens to be facing a cuttable tree / water at the moment
|
|
-- of selection. See PartyMenu's cut/surf actions -> useCutFieldMove /
|
|
-- useSurfFieldMove below.
|
|
|
|
-- map-script interact hook (hand-ported hidden events like the
|
|
-- museum fossil exhibits)
|
|
local hooks = mapScripts.get(self.map.id)
|
|
if hooks and hooks.onInteract and hooks.onInteract(Game, self, fx, fy) then
|
|
interacted(self, fx, fy, "script")
|
|
return
|
|
end
|
|
|
|
-- tileset-generic reads (PrintBookshelfText): facing up into a
|
|
-- bookshelf/statue/shelf tile prints its stock line
|
|
if self:tryBookshelf(fx, fy) then
|
|
interacted(self, fx, fy, "bookshelf")
|
|
return
|
|
end
|
|
interacted(self, fx, fy, "none")
|
|
end
|
|
|
|
-- field.bookshelves (data/tilesets/bookshelf_tile_ids.asm): tileset id +
|
|
-- collision tile -> what to show. Only fires facing up, like the
|
|
-- original. An entry carries `kind` (one of the five vanilla flavors),
|
|
-- `text` (a data.text key) or `screen` (a state module to push).
|
|
function OverworldState:tryBookshelf(fx, fy)
|
|
if self.player.facing ~= "up" then return false end
|
|
if not self.map:inBounds(fx, fy) then return false end
|
|
local shelves = FieldDefaults.field(Game.data, "bookshelves")
|
|
local table_ = shelves and shelves[self.map.def.tileset]
|
|
if not table_ then return false end
|
|
local entry = table_[self.map:cellTile(fx, fy)]
|
|
if not entry then return false end
|
|
local t = Game.data.text
|
|
if entry.text then
|
|
Game.stack:push(TextBox.new(Game, t[entry.text] or entry.text))
|
|
return true
|
|
end
|
|
if entry.screen then
|
|
-- Blue's house shelf opens the TOWN MAP (TownMapText)
|
|
pcall(Screens.push, Game, entry.screen)
|
|
return true
|
|
end
|
|
local kind = entry.kind
|
|
if kind == "books" then
|
|
-- Celadon Mansion's Diglett sculpture (book_or_sculpture.asm):
|
|
-- MANSION tileset + faced cell's top-left tile $38
|
|
if self.map.def.tileset == "MANSION"
|
|
and self.map:tileAt(fx * 2, fy * 2) == 0x38 then
|
|
Game.stack:push(TextBox.new(Game, t._DiglettSculptureText
|
|
or Strings("It's a sculpture\nof DIGLETT.")))
|
|
return true
|
|
end
|
|
Game.stack:push(TextBox.new(Game, t._PokemonBooksText
|
|
or Strings("Crammed full of\nPOKéMON books!")))
|
|
elseif kind == "stuff" then
|
|
Game.stack:push(TextBox.new(Game, t._PokemonStuffText
|
|
or Strings("There's a slew of\nPOKéMON stuff!")))
|
|
elseif kind == "elevator" then
|
|
Game.stack:push(TextBox.new(Game, t._ElevatorText
|
|
or Strings("An elevator!")))
|
|
elseif kind == "statues" then
|
|
-- IndigoPlateauStatues: the plaque, then one of the two lines
|
|
-- keyed by the statue's column (XCoord bit 0)
|
|
local line = (self.player.cellX % 2 == 0) and t._IndigoPlateauStatuesText2
|
|
or t._IndigoPlateauStatuesText3
|
|
Game.stack:push(TextBox.new(Game,
|
|
(t._IndigoPlateauStatuesText1 or Strings("INDIGO PLATEAU")) .. "\f"
|
|
.. (line or Strings("POKéMON LEAGUE HQ"))))
|
|
end
|
|
return true
|
|
end
|
|
|
|
-- Bench guys are the one hidden-event family whose extracted label is a
|
|
-- wrapper rather than the string itself: bench_guys.asm defines e.g.
|
|
-- PewterCityPokecenterBenchGuyText:: as `text_far _PewterCityPokecenterGuyText`,
|
|
-- so data/generated/field.lua carries the wrapper name while the text sits
|
|
-- under the far label. The two only coincide for Mt Moon and the Celadon
|
|
-- hotel, which is why every other bench guy answered with silence (#248).
|
|
-- pokered's own names are irregular here (Cerulean/Lavender/Vermilion drop
|
|
-- "City", Cinnabar drops "Island"), so this is a table, not a transform.
|
|
local BENCH_GUY_TEXT = {
|
|
ViridianCityPokecenterBenchGuyText = "_ViridianCityPokecenterGuyText",
|
|
PewterCityPokecenterBenchGuyText = "_PewterCityPokecenterGuyText",
|
|
CeruleanCityPokecenterBenchGuyText = "_CeruleanPokecenterGuyText",
|
|
LavenderCityPokecenterBenchGuyText = "_LavenderPokecenterGuyText",
|
|
VermilionCityPokecenterBenchGuyText = "_VermilionPokecenterGuyText",
|
|
CeladonCityPokecenterBenchGuyText = "_CeladonCityPokecenterGuyText",
|
|
FuchsiaCityPokecenterBenchGuyText = "_FuchsiaCityPokecenterGuyText",
|
|
CinnabarIslandPokecenterBenchGuyText = "_CinnabarPokecenterGuyText",
|
|
RockTunnelPokecenterBenchGuyText = "_RockTunnelPokecenterGuyText",
|
|
}
|
|
|
|
-- SaffronCityPokecenterBenchGuyText is text_asm: he complains about ROCKET
|
|
-- until EVENT_BEAT_SILPH_CO_GIOVANNI, then thanks you for clearing them out.
|
|
-- Takes data/save rather than reading the Game upvalue so tests can resolve
|
|
-- every label in the table without standing a whole overworld up.
|
|
function OverworldState.benchGuyText(data, save, label)
|
|
if not label then return nil end
|
|
if label == "SaffronCityPokecenterBenchGuyText" then
|
|
local key = (save and save.flags and save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI)
|
|
and "_SaffronCityPokecenterGuyText2"
|
|
or "_SaffronCityPokecenterGuyText1"
|
|
return data.text[key]
|
|
end
|
|
-- the direct name first, so a cache whose extractor already resolved the
|
|
-- far label keeps working without consulting the table
|
|
return data.text["_" .. label] or data.text[BENCH_GUY_TEXT[label] or ""]
|
|
end
|
|
|
|
-- Hidden events at the faced cell (data/events/hidden_events.asm):
|
|
-- HiddenItems give their item once, HiddenCoins fill the COIN CASE,
|
|
-- StartSlotMachine seats open the minigame. Taken spots persist in
|
|
-- save.hiddenTaken.
|
|
function OverworldState:tryHiddenObject(fx, fy)
|
|
local field = Game.data.field
|
|
local save = Game.save
|
|
local key = self.map.id .. "_" .. fx .. "_" .. fy
|
|
|
|
for _, h in ipairs(field.hiddenItems and field.hiddenItems[self.map.id] or {}) do
|
|
if h.x == fx and h.y == fy then
|
|
save.hiddenTaken = save.hiddenTaken or {}
|
|
if save.hiddenTaken[key] then return false end
|
|
if not require("src.inventory.Bag").add(save, h.item, 1, Game.data) then
|
|
Game.stack:push(TextBox.new(Game, Strings("You can't carry\nany more items!")))
|
|
return true
|
|
end
|
|
save.hiddenTaken[key] = true
|
|
local name = Game.data.items[h.item] and Game.data.items[h.item].name or h.item
|
|
-- hidden items always play SFX_GET_ITEM_2 (hidden_items.asm)
|
|
require("src.core.Sound").play(Game.data, "Get_Item2")
|
|
Game.stack:push(TextBox.new(Game,
|
|
Strings("%s found\n%s!", save.player.name, name)))
|
|
return true
|
|
end
|
|
end
|
|
|
|
for _, h in ipairs(field.hiddenCoins and field.hiddenCoins[self.map.id] or {}) do
|
|
if h.x == fx and h.y == fy then
|
|
save.hiddenTaken = save.hiddenTaken or {}
|
|
if save.hiddenTaken[key] then return false end
|
|
if not save.inventory.COIN_CASE then return false end
|
|
save.hiddenTaken[key] = true
|
|
save.coins = math.min(9999, (save.coins or 0) + h.coins)
|
|
require("src.core.Sound").play(Game.data, "Get_Item2")
|
|
Game.stack:push(TextBox.new(Game,
|
|
Strings("%s found\n%d coins!", save.player.name, h.coins)))
|
|
return true
|
|
end
|
|
end
|
|
|
|
-- broken-machine and can't-play texts are pokered's exact strings
|
|
-- (_GameCornerOutOfOrderText etc., data/text/text_2.asm)
|
|
local txt = Game.data.text or {}
|
|
for seatIndex, h in ipairs(field.slotMachines and field.slotMachines[self.map.id] or {}) do
|
|
if h.x == fx and h.y == fy then
|
|
if h.state == "out_of_order" then
|
|
Game.stack:push(TextBox.new(Game, txt._GameCornerOutOfOrderText
|
|
or Strings("OUT OF ORDER\nThis is broken.")))
|
|
elseif h.state == "out_to_lunch" then
|
|
Game.stack:push(TextBox.new(Game, txt._GameCornerOutToLunchText
|
|
or Strings("OUT TO LUNCH\nThis is reserved.")))
|
|
elseif h.state == "keys" then
|
|
Game.stack:push(TextBox.new(Game, txt._GameCornerSomeonesKeysText
|
|
or Strings("Someone's keys!\nThey'll be back.")))
|
|
elseif not save.inventory.COIN_CASE then
|
|
Game.stack:push(TextBox.new(Game, txt._GameCornerCoinCaseText
|
|
or Strings("A COIN CASE is\nrequired!")))
|
|
elseif (save.coins or 0) == 0 then
|
|
-- AbleToPlaySlotsCheck: a COIN CASE with no coins can't play
|
|
Game.stack:push(TextBox.new(Game, txt._GameCornerNoCoinsText
|
|
or Strings("You don't have\nany coins!")))
|
|
else
|
|
-- one machine per visit is secretly lucky
|
|
-- (wLuckySlotHiddenEventIndex, engine/slots/game_corner_slots.asm)
|
|
Screens.push(Game, "SlotMachine", seatIndex == self.luckySlot)
|
|
end
|
|
return true
|
|
end
|
|
end
|
|
|
|
-- Bill's cell-separator PC (data/events/hidden_events.asm: hidden_event
|
|
-- 1,4 BillsHousePC SPRITE_FACING_UP)
|
|
if self.map.id == "BILLS_HOUSE" and fx == 1 and fy == 4
|
|
and self.player.facing == "up" then
|
|
self:billsHousePC()
|
|
return true
|
|
end
|
|
|
|
local extras = field.hiddenExtras
|
|
if not extras then return false end
|
|
local facing = self.player.facing
|
|
|
|
-- Pokémon Center PCs and other PC tiles
|
|
for _, h in ipairs(extras.pcTiles[self.map.id] or {}) do
|
|
if h.x == fx and h.y == fy and (not h.facing or h.facing == facing) then
|
|
if self.map.id == "REDS_HOUSE_2F" then
|
|
-- The player's bedroom PC is the one location in Red/Blue whose PC
|
|
-- callback is OpenRedsPC (engine/events/hidden_objects/players_pc.asm),
|
|
-- which runs the PlayerPC predef directly -- item storage, no
|
|
-- SOMEONE'S/BILL'S PC main menu (DisplayPCMainMenu). Every other
|
|
-- pcTile is a Pokémon Center-style PC that shows the multi-PC menu. (#228)
|
|
require("src.core.Sound").play(Game.data, "Turn_On_PC")
|
|
Screens.push(Game, "PlayerPC")
|
|
else
|
|
self:openPC()
|
|
end
|
|
return true
|
|
end
|
|
end
|
|
|
|
-- Bench guys (data/events/bench_guys.asm). A hidden_event's fourth byte
|
|
-- is wHiddenEventFunctionArgument, not a facing gate -- pokered's own
|
|
-- macro comment says the SPRITE_FACING_* values parked there "do not
|
|
-- actually prevent the player from interacting with them in any
|
|
-- direction" (data/events/hidden_events.asm). The facing that does decide
|
|
-- a bench guy is PrintBenchGuyText's own test against BenchGuyTextPointers,
|
|
-- SPRITE_FACING_LEFT for all twelve seats, which the manifest carries as
|
|
-- `textFacing`. Gating on the hidden_event byte instead silenced the four
|
|
-- seats that store SPRITE_FACING_UP (Vermilion, Saffron, Fuchsia,
|
|
-- Cinnabar): (0,4) is the bench wall cell and can only ever be faced from
|
|
-- the right (#488).
|
|
for _, h in ipairs(extras.benchGuys[self.map.id] or {}) do
|
|
local want = h.textFacing or h.facing
|
|
if h.x == fx and h.y == fy and (not want or want == facing) then
|
|
local text = OverworldState.benchGuyText(Game.data, save, h.text)
|
|
if text then
|
|
Game.stack:push(TextBox.new(Game, text))
|
|
return true
|
|
end
|
|
end
|
|
end
|
|
|
|
-- gym statues (engine/events/hidden_events/gym_statues.asm): show
|
|
-- the gym plaque; the player's name joins the winners once the
|
|
-- badge is earned
|
|
for _, h in ipairs(extras.gymStatues[self.map.id] or {}) do
|
|
if h.x == fx and h.y == fy and facing == "up" then
|
|
local gym = require("data.scripts.gyms")[self.map.id]
|
|
if gym then
|
|
local key = save.inventory[gym.badge] and "_GymStatueText2" or "_GymStatueText1"
|
|
local text = Game.data.text[key]
|
|
or Strings("{RAM}\nPOKéMON GYM\nLEADER: {RAM}")
|
|
text = text:gsub("{RAM:wGymCityName}", gym.city)
|
|
:gsub("{RAM:wGymLeaderName}", gym.leader)
|
|
Game.stack:push(TextBox.new(Game, text))
|
|
return true
|
|
end
|
|
end
|
|
end
|
|
|
|
-- PrintTrashText: SS Anne kitchen + Vermilion Gym non-puzzle can
|
|
for _, h in ipairs(extras.printTrash and extras.printTrash[self.map.id] or {}) do
|
|
if h.x == fx and h.y == fy then
|
|
Game.stack:push(TextBox.new(Game, txt._VermilionGymTrashText
|
|
or Strings("Nope, there's\nonly trash here.")))
|
|
return true
|
|
end
|
|
end
|
|
|
|
-- the Vermilion Gym trash can lock puzzle
|
|
if self.map.id == "VERMILION_GYM" then
|
|
for _, h in ipairs(extras.trashCans.cans or {}) do
|
|
if h.x == fx and h.y == fy then
|
|
self:trashCanSwitch(h.can)
|
|
return true
|
|
end
|
|
end
|
|
end
|
|
|
|
return false
|
|
end
|
|
|
|
-- Card key doors: on the Silph Co maps, facing a locked-door tile with
|
|
-- the CARD KEY replaces the door block with the open one
|
|
-- (engine/events/card_key.asm PrintCardKeyText).
|
|
function OverworldState:tryCardKeyDoor(fx, fy)
|
|
local ck = Game.data.field.cardKeyDoors
|
|
if not ck then return false end
|
|
local onList = false
|
|
for _, m in ipairs(ck.maps) do
|
|
if m == self.map.id then onList = true break end
|
|
end
|
|
if not onList or not self.map:inBounds(fx, fy) then return false end
|
|
local tile = self.map:cellTile(fx, fy)
|
|
local openBlock
|
|
if self.map.id == "SILPH_CO_11F" then
|
|
if tile == ck.silphCo11F.doorTile then openBlock = ck.silphCo11F.openBlock end
|
|
else
|
|
for _, t in ipairs(ck.doorTiles) do
|
|
if tile == t then openBlock = ck.openBlock break end
|
|
end
|
|
end
|
|
if not openBlock then return false end
|
|
local t = Game.data.text
|
|
if not Game.save.inventory.CARD_KEY then
|
|
Game.stack:push(TextBox.new(Game,
|
|
t._CardKeyFailText or Strings("Darn! It needs a\nCARD KEY!")))
|
|
return true
|
|
end
|
|
require("src.core.Sound").play(Game.data, "Go_Inside")
|
|
local bx, by = math.floor(fx / 2), math.floor(fy / 2)
|
|
self:replaceBlock(bx, by, openBlock)
|
|
-- opened doors stay open across reloads (the per-door unlock events
|
|
-- the floors' gate callbacks check, EVENT_SILPH_CO_n_UNLOCKED_DOOR*)
|
|
local closedDoors = FieldDefaults.fieldValue(Game.data, "cardKeyDoors",
|
|
"closedDoors")
|
|
for _, door in ipairs(closedDoors and closedDoors[self.map.id] or {}) do
|
|
if door.bx == bx and door.by == by then
|
|
Game.save.flags[door.event] = true
|
|
break
|
|
end
|
|
end
|
|
Game.stack:push(TextBox.new(Game,
|
|
(t._CardKeySuccessText1 or Strings("Bingo!"))
|
|
.. (t._CardKeySuccessText2 or Strings("\nThe CARD KEY\nopened the door!"))))
|
|
return true
|
|
end
|
|
|
|
-- The Vermilion Gym trash can puzzle
|
|
-- (engine/events/hidden_events/vermilion_gym_trash.asm GymTrashScript):
|
|
-- the first switch hides in a random even can, rolled on every
|
|
-- Vermilion City map load (scripts/VermilionCity.asm VermilionCity_Script
|
|
-- .setFirstLockTrashCanIndex -- see M.VERMILION_CITY.onEnter in
|
|
-- data/scripts/story.lua) and re-rolled on every failed second-can
|
|
-- guess; the second switch is drawn from the GymTrashCans candidate
|
|
-- table (bug included). Opening both unlocks the door block at (2,2)
|
|
-- (scripts/VermilionGym.asm VermilionGymSetDoorTile).
|
|
function OverworldState:trashCanSwitch(canIndex)
|
|
local t = Game.data.text
|
|
local save = Game.save
|
|
local tc = Game.data.field.hiddenExtras.trashCans
|
|
local trashText = t._VermilionGymTrashText or Strings("Nope, there's\nonly trash here.")
|
|
-- "Don't do the trash can puzzle if it's already been done."
|
|
if save.flags.EVENT_2ND_LOCK_OPENED then
|
|
Game.stack:push(TextBox.new(Game, trashText))
|
|
return
|
|
end
|
|
save.trashPuzzle = save.trashPuzzle or {}
|
|
local puz = save.trashPuzzle
|
|
if puz.opened1 then
|
|
-- migrate mid-puzzle saves from before the port tracked the real
|
|
-- EVENT_1ST_LOCK_OPENED flag
|
|
save.flags.EVENT_1ST_LOCK_OPENED = true
|
|
puz.opened1 = nil
|
|
end
|
|
if not puz.first then
|
|
-- normally rolled by Vermilion City's map load (the only way in);
|
|
-- covers saves from before that hook and debug warps straight in
|
|
puz.first = love.math.random(0, 7) * 2 -- Random & $0e: even cans
|
|
end
|
|
if not save.flags.EVENT_1ST_LOCK_OPENED then
|
|
if canIndex ~= puz.first then
|
|
Game.stack:push(TextBox.new(Game, trashText))
|
|
return
|
|
end
|
|
-- .openFirstLock: SetEvent EVENT_1ST_LOCK_OPENED, then pick where
|
|
-- the second switch hides. GymTrashCans rows are `mask,
|
|
-- cand1..cand4` where the mask doubles as the candidate count
|
|
-- (2, 3 or 4). The asm ANDs the mask with a random byte (its
|
|
-- nibble swap is distribution-neutral) and uses `result - 1` as a
|
|
-- byte offset into the candidates:
|
|
-- mask 3: result 1-3 -> candidate 1-3
|
|
-- mask 2: result 2 -> candidate 2 (candidate 1 unreachable)
|
|
-- mask 4: result 4 -> candidate 4 (candidates 1-3 unreachable)
|
|
-- result 0: `dec a` underflows to $ff and the read lands on the
|
|
-- ROM bank's zero padding, so the second switch lands in can 0
|
|
-- regardless of adjacency (the documented GymTrashCans bug)
|
|
save.flags.EVENT_1ST_LOCK_OPENED = true
|
|
local adj = tc.adjacent[puz.first]
|
|
local masked = require("bit").band(love.math.random(0, 255), #adj)
|
|
puz.second = masked == 0 and 0 or adj[masked]
|
|
-- VermilionGymTrashSuccessText1's text_asm tail plays SFX_SWITCH only
|
|
-- after the text has printed (text_far ...; text_asm;
|
|
-- WaitForSoundToFinish; PlaySound SFX_SWITCH; WaitForSoundToFinish),
|
|
-- and DisplayTextID's WaitForTextScrollButtonPress then holds the box
|
|
-- until the player dismisses it -- so the beep belongs on close, not
|
|
-- open.
|
|
Game.stack:push(TextBox.new(Game,
|
|
t._VermilionGymTrashSuccessText1
|
|
or Strings("Hey! There's a\nswitch under the\ntrash!\fThe 1st electric\nlock opened!"),
|
|
function() require("src.core.Sound").play(Game.data, "Switch") end))
|
|
return
|
|
end
|
|
-- .trySecondLock
|
|
if canIndex == puz.second then
|
|
-- .openSecondLock: only VermilionGymTrashSuccessText3 prints
|
|
-- (SuccessText2 is unused in pokered)
|
|
save.flags.EVENT_2ND_LOCK_OPENED = true
|
|
-- the clear floor block opens the doors (VermilionGymSetDoorTile)
|
|
local door = FieldDefaults.fieldValue(Game.data, "hiddenExtras",
|
|
"trashCans", "doorBlock")
|
|
self:replaceBlock(door.bx, door.by, door.block)
|
|
-- SuccessText3's text_asm tail plays SFX_GO_INSIDE after the text
|
|
-- prints, so the beep fires as the box closes, not as it opens.
|
|
Game.stack:push(TextBox.new(Game,
|
|
t._VermilionGymTrashSuccessText3
|
|
or Strings("The 2nd electric\nlock opened!\fThe motorized door\nopened!"),
|
|
function() require("src.core.Sound").play(Game.data, "Go_Inside") end))
|
|
else
|
|
-- wrong can: ResetEvent EVENT_1ST_LOCK_OPENED and immediately
|
|
-- re-roll the first switch (Random & $e)
|
|
save.flags.EVENT_1ST_LOCK_OPENED = nil
|
|
puz.first = love.math.random(0, 7) * 2
|
|
puz.second = nil
|
|
-- VermilionGymTrashFailText's text_asm tail plays SFX_DENIED after the
|
|
-- text prints, so the beep fires as the box closes, not as it opens.
|
|
Game.stack:push(TextBox.new(Game,
|
|
t._VermilionGymTrashFailText
|
|
or Strings("Nope! There's\nonly trash here.\fHey! The electric\nlocks were reset!"),
|
|
function() require("src.core.Sound").play(Game.data, "Denied") end))
|
|
end
|
|
end
|
|
|
|
-- Bill's House PC (engine/events/hidden_events/bills_house_pc.asm
|
|
-- BillsHousePC). Check order matches pokered:
|
|
-- 1) EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING -> Eevee collection list
|
|
-- 2) EVENT_USED_CELL_SEPARATOR_ON_BILL -> teleporter monitor text
|
|
-- 3) EVENT_BILL_SAID_USE_CELL_SEPARATOR -> cell-separator cutscene
|
|
-- 4) else -> teleporter monitor text
|
|
-- Leaving after the SS Ticket (Route25ToggleBillsScript) arms (1).
|
|
function OverworldState:billsHousePC()
|
|
local t = Game.data.text
|
|
local flags = Game.save.flags
|
|
if flags.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING then
|
|
self:billsHousePokemonList()
|
|
return
|
|
end
|
|
if flags.EVENT_USED_CELL_SEPARATOR_ON_BILL
|
|
or not flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR then
|
|
Game.stack:push(TextBox.new(Game, t._BillsHouseMonitorText
|
|
or Strings("TELEPORTER is\ndisplayed on the\nPC monitor.")))
|
|
return
|
|
end
|
|
require("src.core.Music").stop()
|
|
Game.stack:push(TextBox.new(Game, t._BillsHouseInitiatedText
|
|
or Strings("{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
|
|
|
|
-- BillsHousePokemonList: EEVEE / FLAREON / JOLTEON / VAPOREON + CANCEL;
|
|
-- picking one runs DisplayPokedex (DexEntryMenu) and returns to the list.
|
|
function OverworldState:billsHousePokemonList()
|
|
local t = Game.data.text
|
|
local Menu = require("src.ui.Menu")
|
|
local function openList()
|
|
local species = { "EEVEE", "FLAREON", "JOLTEON", "VAPOREON" }
|
|
local items = {}
|
|
for _, id in ipairs(species) do
|
|
local def = Game.data.pokemon[id]
|
|
table.insert(items, {
|
|
label = (def and def.name) or id,
|
|
keepOpen = true,
|
|
onSelect = function()
|
|
local dex = Game.save.pokedex
|
|
if dex then dex.seen[id] = true end
|
|
Screens.push(Game, "DexEntryMenu", id)
|
|
end,
|
|
})
|
|
end
|
|
table.insert(items, { label = Strings("CANCEL") })
|
|
-- TextBoxBorder b=10,c=9 at (0,0) -> total tw=11, th=12
|
|
Game.stack:push(Menu.new(Game, items,
|
|
{ tx = 0, ty = 0, tw = 11, th = 12 }))
|
|
end
|
|
Game.stack:push(TextBox.new(Game, t._BillsHousePokemonListText1
|
|
or Strings("BILL's favorite\nPOKéMON list!"), openList))
|
|
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. The Eevee
|
|
-- PC list arms later, on the first Route 25 load after the ticket
|
|
-- (EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING).
|
|
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")
|
|
require("src.world.PikachuFollower").onBillExitedMachine(Game, self)
|
|
local function done()
|
|
Game.save.flags.EVENT_MET_BILL = true
|
|
Game.save.flags.EVENT_MET_BILL_2 = true
|
|
require("src.core.Music").playMap(Game.data, self.map.id,
|
|
Game.save.onBike, self.player.surfing)
|
|
end
|
|
local bill
|
|
for _, n in ipairs(self.npcs) do
|
|
if n.def and n.def.name == "BILLSHOUSE_BILL1" then bill = n break end
|
|
end
|
|
if not (bill and self.map.id == "BILLS_HOUSE") then
|
|
done()
|
|
return
|
|
end
|
|
bill.cellX, bill.cellY = 1, 2
|
|
bill.px, bill.py = 16, 32
|
|
bill.facing = "down"
|
|
self:scriptMove(bill, "down", 1, function()
|
|
self:scriptMove(bill, "right", 3, function()
|
|
self:scriptMove(bill, "down", 1, done)
|
|
end)
|
|
end)
|
|
end
|
|
|
|
-- Any hidden item still unfound NEAR the player? (the ITEMFINDER,
|
|
-- engine/items/itemfinder.asm HiddenItemNear: coord > clamp0(player-5)
|
|
-- and coord <= player+4 (Y) / player+5 (X) -- the clamp excludes
|
|
-- coordinate 0 whenever the player coordinate is <= 4, like the original)
|
|
function OverworldState:hasHiddenItemLeft()
|
|
local list = Game.data.field.hiddenItems and Game.data.field.hiddenItems[self.map.id]
|
|
if not list then return false end
|
|
local taken = Game.save.hiddenTaken or {}
|
|
local px, py = self.player.cellX, self.player.cellY
|
|
local function near(c, v, hiAdd)
|
|
return v > math.max(c - 5, 0) and v <= c + hiAdd
|
|
end
|
|
for _, h in ipairs(list) do
|
|
if not taken[self.map.id .. "_" .. h.x .. "_" .. h.y]
|
|
and near(py, h.y, 4) and near(px, h.x, 5) then
|
|
return true
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
|
|
function OverworldState:tilesetHasWater()
|
|
for _, t in ipairs(Game.data.field.waterTilesets) do
|
|
if t == self.map.def.tileset then return true end
|
|
end
|
|
return false
|
|
end
|
|
|
|
-- field.seafoam[map].surfBlocked: cells where SURF is refused until the
|
|
-- listed events fire (IsSurfingAllowed's SEAFOAM_ISLANDS_B4F stairs case)
|
|
function OverworldState:surfBlockedHere()
|
|
local blocked = FieldDefaults.fieldValue(Game.data, "seafoam", self.map.id,
|
|
"surfBlocked")
|
|
if not blocked then return false end
|
|
local p = self.player
|
|
for _, cell in ipairs(blocked) do
|
|
if p.cellX == cell.x and p.cellY == cell.y then
|
|
local cleared = true
|
|
for _, e in ipairs(cell.untilEvents or {}) do
|
|
if not Game.save.flags[e] then cleared = false break end
|
|
end
|
|
if not cleared then return true end
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
|
|
-- Gen 1 has no confirmation prompt: using SURF gets straight on
|
|
-- (_SurfingGotOnText, item_effects.asm .surf). Called from the party
|
|
-- menu's SURF action (via useSurfFieldMove) once the facing tile has been
|
|
-- confirmed to be water -- there is no overworld A-press hook. onClose is
|
|
-- that menu's own close, called when the got-on text ends (see below).
|
|
function OverworldState:trySurf(fx, fy, onClose)
|
|
local mon = self:partyKnows("SURF")
|
|
if not mon then return end
|
|
local name = mon.nickname or Game.data.pokemon[mon.species].name
|
|
local p = self.player
|
|
local text = (Game.data.text._SurfingGotOnText or Strings("{PLAYER} got on\n{RAM:wNameBuffer}!"))
|
|
:gsub("{RAM:wNameBuffer}", name)
|
|
-- UseItem prints the got-on text with the party menu still on screen and
|
|
-- GBPalWhiteOutWithDelay3 + .goBackToMap only run after it
|
|
-- (start_sub_menus.asm .surf), so the text reads over the menu and the
|
|
-- blink is the menu closing, not a flashbang on the empty map (#320,
|
|
-- #385). The mount rides the blink, so nothing paddles on land.
|
|
Game.stack:push(TextBox.new(Game, text, function()
|
|
if onClose then onClose() end
|
|
p.surfing = true
|
|
self:syncSurfingPikachu()
|
|
require("src.core.Music").setSurfing(Game.data, true)
|
|
Game.stack:push(require("src.render.Transition").whiteFlash(Game, nil,
|
|
function() self:stepForwardOrCrossEdge(p.facing) end))
|
|
end))
|
|
end
|
|
|
|
function OverworldState:tryCut(fx, fy)
|
|
-- UsedCut (engine/overworld/cut.asm) gates on the TILESET before
|
|
-- anything else: only OVERWORLD (tree tile $3d) and GYM (plant tile
|
|
-- $50) have cuttable anything. Matching raw block ids alone
|
|
-- false-positives on every other tileset -- block ids are only
|
|
-- meaningful within one tileset, so Route 23 (PLATEAU) had blocks
|
|
-- matching a swap's `before`, and applying it wrote a block id that
|
|
-- does not exist in PLATEAU's block table: the renderer indexed nil
|
|
-- and the game crashed. The same false match is what made the bot
|
|
-- chain-cut "ornamental bushes" around Saffron and Celadon.
|
|
local ts = self.map.def.tileset
|
|
local tile = self.map:cellTile(fx, fy)
|
|
local isGrass = (ts == "OVERWORLD" and tile == 0x52)
|
|
if not ((ts == "OVERWORLD" and tile == 0x3d)
|
|
or (ts == "GYM" and tile == 0x50)
|
|
or isGrass) then
|
|
return false
|
|
end
|
|
local bx, by = math.floor(fx / 2), math.floor(fy / 2)
|
|
local block = self.map:blockAt(bx, by)
|
|
local swap
|
|
for _, sw in ipairs(Game.data.field.cutTreeSwaps) do
|
|
if sw.before == block then swap = sw break end
|
|
end
|
|
if not swap or (not isGrass and self.map:isWalkableCell(fx, fy)) then return false end
|
|
local mon = self:partyKnows("CUT")
|
|
if not mon then return false end
|
|
-- gen 1 confirms nothing (engine/overworld/cut.asm UsedCut): the
|
|
-- _UsedCutText message, then the tree vanishes with dust + SFX_CUT
|
|
local name = mon.nickname or Game.data.pokemon[mon.species].name
|
|
local text = (Game.data.text._UsedCutText or Strings("{RAM:wNameBuffer} hacked\naway with CUT!"))
|
|
:gsub("{RAM:wNameBuffer}", name)
|
|
Game.stack:push(TextBox.new(Game, text, function()
|
|
self.cutBlocks = self.cutBlocks or {}
|
|
self.cutBlocks[self.map.id] = self.cutBlocks[self.map.id] or {}
|
|
table.insert(self.cutBlocks[self.map.id],
|
|
{ bx = bx, by = by, block = block })
|
|
self.map:setBlock(bx, by, swap.after)
|
|
self.map.renderer:rebuild()
|
|
local finish = function()
|
|
require("src.core.Sound").play(Game.data, "Cut")
|
|
end
|
|
if isGrass then
|
|
-- AnimCut .grass: tall grass gets the leaf-swirl / dust puff, not
|
|
-- the tree-split slide
|
|
self:startDustAnim(fx, fy, finish)
|
|
elseif ts == "OVERWORLD" then
|
|
-- the tree splits in half and slides apart (AnimCut .cutTreeLoop);
|
|
-- the GYM plant keeps the shared dust/leaf puff
|
|
self:startCutTreeAnim(fx, fy, finish)
|
|
else
|
|
self:startDustAnim(fx, fy, finish)
|
|
end
|
|
end))
|
|
return true
|
|
end
|
|
|
|
-- The cut-tree split (engine/overworld/cut.asm InitCutAnimOAM +
|
|
-- engine/overworld/cut2.asm AnimCut): the tree sprite's top half slides
|
|
-- +1px and its bottom half -1px per frame for 8 frames, flickering,
|
|
-- before the swapped block shows through. Falls back to the dust puff
|
|
-- when the extracted tree sprite is unavailable.
|
|
function OverworldState:startCutTreeAnim(cx, cy, onDone)
|
|
local fxDef = Game.data.field.overworldFx
|
|
if not (fxDef and fxDef.cutTree) then
|
|
return self:startDustAnim(cx, cy, onDone)
|
|
end
|
|
self.cutAnim = { x = cx, y = cy, frames = 8, total = 8, onDone = onDone }
|
|
end
|
|
|
|
-- Party-menu SURF entry (start_sub_menus.asm .surf): badge-check SOULBADGE,
|
|
-- farcall IsSurfingAllowed, then UseItem(SURFBOARD) -> ItemUseSurfboard
|
|
-- (item_effects.asm), which either tries to dismount (already surfing) or
|
|
-- runs IsNextTileShoreOrWater on the tile the player is FACING and jumps
|
|
-- to SurfingAttemptFailed (_NoSurfingHereText) if it isn't water. This is
|
|
-- a side-effect-free check that reports which text/flow the caller should
|
|
-- use; the actual mount happens in trySurf on "ok". Returns:
|
|
-- "no_badge" -> SOULBADGE missing / no SURF mon (_NewBadgeRequiredText)
|
|
-- "forced_bike" -> on the Cycling Road (_CyclingIsFunText)
|
|
-- "current" -> Seafoam B4F stairs before the boulders (_CurrentTooFastText)
|
|
-- "dismount" -> already surfing, facing dry land; caller steps forward
|
|
-- "no_place" -> already surfing, nowhere to land (_SurfingNoPlaceToGetOffText)
|
|
-- "no_water" -> not facing water (_NoSurfingHereText)
|
|
-- "ok" -> facing water; caller may call trySurf(fx, fy)
|
|
function OverworldState:useSurfFieldMove()
|
|
if not self:partyKnows("SURF") then return "no_badge" end
|
|
local p = self.player
|
|
-- IsSurfingAllowed (engine/overworld/field_move_messages.asm): surfing
|
|
-- is refused while BIT_ALWAYS_ON_BIKE of wStatusFlags6 is set (the
|
|
-- Cycling Road, armed by the forced-bike tiles and cleared by the
|
|
-- Route 16/18 gate scripts / fly + dungeon warps / blackouts), and on
|
|
-- SEAFOAM_ISLANDS_B4F standing on the stairs square (dbmapcoord 7,11)
|
|
-- until both EVENT_SEAFOAM4_BOULDER*_DOWN_HOLE events are set.
|
|
if Game.save.forcedBike then return "forced_bike" end
|
|
if self:surfBlockedHere() then return "current" end
|
|
if p.surfing then
|
|
-- ItemUseSurfboard .tryToStopSurfing: blocked by a sprite in front
|
|
-- (IsSpriteInFrontOfPlayer2), a water tile-pair collision, or a
|
|
-- facing tile that isn't in the tileset's land-passable list;
|
|
-- otherwise the player walks forward off the water. Facing a land
|
|
-- cell across a map connection (Cinnabar east coast) counts too --
|
|
-- pokered reads that landing from the connection strip.
|
|
if self:facingIsLandDismount() then
|
|
return "dismount"
|
|
end
|
|
return "no_place"
|
|
end
|
|
-- IsNextTileShoreOrWater, including connection-strip water (issue #125)
|
|
if not self:facingIsShoreOrWater() then
|
|
return "no_water"
|
|
end
|
|
return "ok"
|
|
end
|
|
|
|
-- Party-menu CUT entry (start_sub_menus.asm .cut -> predef UsedCut,
|
|
-- engine/overworld/cut.asm): badge-check CASCADEBADGE then check the tile
|
|
-- the player is FACING against the tileset's cut-tree ids; _NothingToCutText
|
|
-- (and .loop back to the submenu) if it isn't cuttable. Side-effect-free
|
|
-- check mirroring useSurfFieldMove; tryCut does the actual cut on "ok".
|
|
-- Returns:
|
|
-- "no_badge" -> CASCADEBADGE missing / no CUT mon (_NewBadgeRequiredText)
|
|
-- "nothing" -> not facing a cuttable tree (_NothingToCutText)
|
|
-- "ok" -> facing a cuttable tree; caller may call tryCut(fx, fy)
|
|
function OverworldState:useCutFieldMove()
|
|
if not self:partyKnows("CUT") then return "no_badge" end
|
|
local fx, fy = self.player:facingCell()
|
|
if not self.map:inBounds(fx, fy) then return "nothing" end
|
|
-- same tileset/tile gate as tryCut (UsedCut, engine/overworld/cut.asm):
|
|
-- a tree BLOCK also contains fence/path cells, and facing those is
|
|
-- "nothing to cut" in vanilla
|
|
local ts = self.map.def.tileset
|
|
local tile = self.map:cellTile(fx, fy)
|
|
local isGrass = (ts == "OVERWORLD" and tile == 0x52)
|
|
if not ((ts == "OVERWORLD" and tile == 0x3d)
|
|
or (ts == "GYM" and tile == 0x50)
|
|
or isGrass) then
|
|
return "nothing"
|
|
end
|
|
local bx, by = math.floor(fx / 2), math.floor(fy / 2)
|
|
local block = self.map:blockAt(bx, by)
|
|
local swap
|
|
for _, sw in ipairs(Game.data.field.cutTreeSwaps) do
|
|
if sw.before == block then swap = sw break end
|
|
end
|
|
if not swap or (not isGrass and self.map:isWalkableCell(fx, fy)) then return "nothing" end
|
|
return "ok"
|
|
end
|
|
|
|
function OverworldState:talkTo(npc)
|
|
npc.frozen = true
|
|
local unfreeze = function() npc.frozen = false end
|
|
local d = npc.def
|
|
|
|
-- hand-ported scripts always win
|
|
if mapScripts.talkScript(self.map.id, d.text) then
|
|
self:showMapText(d.text, npc, unfreeze)
|
|
return
|
|
end
|
|
|
|
-- item balls (object_event item argument). A payload id of "0" is
|
|
-- pokered's ITEM_NONE sentinel: the ROM object sets the 0x80 "has item"
|
|
-- bit but names item 0, so it is a plain text object, not an item ball
|
|
-- (e.g. Blue's House wall Town Map / walking Daisy, #11). Lua treats
|
|
-- the string "0" as truthy, so screen it out and fall through to text.
|
|
if d.item and d.item ~= "0" and d.item ~= 0 then
|
|
if not require("src.inventory.Bag").add(Game.save, d.item, 1, Game.data) then
|
|
Game.stack:push(TextBox.new(Game, Strings("You can't carry\nany more items!")))
|
|
return
|
|
end
|
|
Game.save.itemsTaken = Game.save.itemsTaken or {}
|
|
Game.save.itemsTaken[npc.id] = true
|
|
for i, n in ipairs(self.npcs) do
|
|
if n == npc then table.remove(self.npcs, i) break end
|
|
end
|
|
for i, e in ipairs(self.entities) do
|
|
if e == npc then table.remove(self.entities, i) break end
|
|
end
|
|
local name = Game.data.items[d.item] and Game.data.items[d.item].name or d.item
|
|
local ddef = Game.data.items[d.item]
|
|
require("src.core.Sound").play(Game.data,
|
|
(ddef and ddef.keyItem) and "Get_Key_Item" or "Get_Item1")
|
|
Game.stack:push(TextBox.new(Game,
|
|
Strings("%s found\n%s!", Game.save.player.name, name)))
|
|
return
|
|
end
|
|
|
|
-- static wild encounters (object_event species+level args: the
|
|
-- legendary birds, Mewtwo, the Vermilion Machop, ...)
|
|
if d.pokemon then
|
|
npc:facePlayer(self.player)
|
|
local text = select(1, Game.data:resolveText(self.map.def.label, d.text))
|
|
or Strings("Gyaoo!")
|
|
local BattleState = require("src.battle.BattleState")
|
|
Game.stack:push(TextBox.new(Game, text, function()
|
|
local battle = BattleState.newWild(Game, d.pokemon, d.level)
|
|
battle.onFinish = function(result)
|
|
if result ~= "lose" and result ~= "run" then
|
|
Game.save.defeatedTrainers[npc.id] = true
|
|
for i, n in ipairs(self.npcs) do
|
|
if n == npc then table.remove(self.npcs, i) break end
|
|
end
|
|
for i, e in ipairs(self.entities) do
|
|
if e == npc then table.remove(self.entities, i) break end
|
|
end
|
|
end
|
|
self:afterBattle(result, battle)
|
|
unfreeze()
|
|
end
|
|
self:pushBattle(battle)
|
|
end))
|
|
return
|
|
end
|
|
|
|
-- generic trainers (object_event trainer args + extracted headers)
|
|
if d.trainerClass and not self:trainerDefeated(npc) then
|
|
npc:facePlayer(self.player)
|
|
self:engageTrainer(npc, unfreeze)
|
|
return
|
|
end
|
|
if d.trainerClass and self:trainerDefeated(npc) then
|
|
local header = Game.data:trainerHeader(self.map.def.label, d.index)
|
|
local after = header and header.after and Game.data.text[header.after]
|
|
if after then
|
|
npc:facePlayer(self.player)
|
|
Game.stack:push(TextBox.new(Game, after, unfreeze))
|
|
return
|
|
end
|
|
end
|
|
|
|
-- marts / nurses / PCs via TX_SCRIPT markers
|
|
local entry = Game.data:textEntry(self.map.def.label, d.text)
|
|
if entry then
|
|
if entry.mart then
|
|
npc:facePlayer(self.player)
|
|
Game.stack:push(TextBox.new(Game, Strings("Hi there!\nMay I help you?"), function()
|
|
Screens.push(Game, "ShopMenu", entry.mart)
|
|
unfreeze()
|
|
end))
|
|
return
|
|
end
|
|
if entry.nurse then
|
|
npc:facePlayer(self.player)
|
|
self:nurseHeal(unfreeze, npc)
|
|
return
|
|
end
|
|
if entry.pc then
|
|
self:openPC(unfreeze)
|
|
return
|
|
end
|
|
if entry.cableClub then
|
|
npc:facePlayer(self.player)
|
|
self:cableClubReceptionist(unfreeze)
|
|
return
|
|
end
|
|
end
|
|
|
|
self:showMapText(d.text, npc, unfreeze)
|
|
end
|
|
|
|
local function sameItems(_, items) return items end
|
|
|
|
-- The Pokémon Center PC: BILL's PC (boxes), the player's item storage,
|
|
-- and PROF.OAK's dex rating (engine/menus/players_pc.asm,
|
|
-- engine/events/pokedex_rating.asm). The assembled entries run through
|
|
-- the ui.pc.items hook; LOG OFF is appended after it so a mod cannot
|
|
-- orphan the exit.
|
|
function OverworldState:openPC(onDone)
|
|
require("src.core.Sound").play(Game.data, "Turn_On_PC")
|
|
local Menu = require("src.ui.Menu")
|
|
local done = onDone or function() end
|
|
local flags = Game.save.flags or {}
|
|
local items = {}
|
|
|
|
-- the box PC reads "SOMEONE'S PC" until you meet Bill, then "BILL'S PC"
|
|
-- (engine/menus/pokemon_pc.asm gates on EVENT_MET_BILL; we reach that
|
|
-- when Bill hands over the SS Ticket)
|
|
local metBill = flags.EVENT_MET_BILL or flags.EVENT_GOT_SS_TICKET
|
|
-- keepOpen so B in the sub-PC returns here instead of exiting the
|
|
-- PC session (#695); the sub-PC screens (BoxMenu, PlayerPC) already
|
|
-- use keepOpen for their own rows, matching the original ROM's flow
|
|
-- where the main menu stays underneath.
|
|
table.insert(items, {
|
|
label = metBill and "BILL'S PC" or Strings("SOMEONE'S PC"),
|
|
keepOpen = true,
|
|
onSelect = function()
|
|
require("src.core.Sound").play(Game.data, "Enter_PC")
|
|
Screens.push(Game, "BoxMenu")
|
|
done()
|
|
end,
|
|
})
|
|
|
|
-- the player's item storage is always available
|
|
table.insert(items, {
|
|
label = (Game.save.player.name or "RED") .. "'s PC",
|
|
keepOpen = true,
|
|
onSelect = function()
|
|
Screens.push(Game, "PlayerPC")
|
|
done()
|
|
end,
|
|
})
|
|
|
|
-- Prof. Oak's dex rating only appears once you have the Pokédex
|
|
if flags.EVENT_GOT_POKEDEX then
|
|
table.insert(items, {
|
|
label = Strings("PROF.OAK's PC"),
|
|
keepOpen = true,
|
|
onSelect = function()
|
|
self:openOaksPC(done)
|
|
end,
|
|
})
|
|
end
|
|
|
|
local hooked = Runtime.call("ui.pc.items", sameItems, Game, items)
|
|
if type(hooked) == "table" then
|
|
items = hooked
|
|
else
|
|
Logger.error("ui.pc.items returned %s; keeping the vanilla items",
|
|
type(hooked))
|
|
end
|
|
|
|
local logOff = function()
|
|
require("src.core.Sound").play(Game.data, "Turn_Off_PC")
|
|
done()
|
|
end
|
|
table.insert(items, { label = Strings("LOG OFF"), onSelect = logOff })
|
|
-- pokered sets BIT_NO_MENU_BUTTON_SOUND for the whole PC session
|
|
-- (engine/overworld/pokecenter_pc.asm / player_pc.asm); DisplayPCMainMenu
|
|
-- calls TextBoxBorder with c=14 (interior width, +2 for the border), so
|
|
-- tw here (total width) is 16
|
|
Game.stack:push(Menu.new(Game, items,
|
|
{ tx = 0, ty = 0, tw = 16, th = #items * 2 + 2, onCancel = logOff,
|
|
noSound = true }))
|
|
end
|
|
|
|
-- The PROF. OAK's PC session (engine/menus/oaks_pc.asm OpenOaksPC): the
|
|
-- access text, "Want to get your #DEX rated?" with a YES/NO, then the
|
|
-- rating, and "Closed link to PROF.OAK's PC." before control returns -- the
|
|
-- intro and closing links the launcher skipped, jingle ordering aside (#576).
|
|
function OverworldState:openOaksPC(onDone)
|
|
local done = onDone or function() end
|
|
local text = Game.data.text or {}
|
|
local accessed = text._AccessedOaksPCText
|
|
or Strings("Accessed PROF.\nOAK's PC.\fAccessed POKéDEX\nRating System.")
|
|
local rated = text._GetDexRatedText
|
|
or Strings("Want to get your\nPOKéDEX rated?")
|
|
local closed = text._ClosedOaksPCText
|
|
or Strings("Closed link to\nPROF.OAK's PC.")
|
|
local function close()
|
|
Game.stack:push(TextBox.new(Game, closed, done))
|
|
end
|
|
Game.stack:push(TextBox.new(Game, accessed, function()
|
|
-- _GetDexRatedText ends with `done`, so the YES/NO pops as soon as the
|
|
-- text has typed out, with no button wait in between (YesNoChoice)
|
|
Game.stack:push(TextBox.new(Game, rated, nil, {
|
|
choice = function(yes)
|
|
if not yes then
|
|
close()
|
|
return
|
|
end
|
|
self:dexRating(close)
|
|
end,
|
|
}))
|
|
end))
|
|
end
|
|
|
|
-- Prof. Oak's dex rating service (engine/events/pokedex_rating.asm):
|
|
-- the completion line with seen AND owned counts, then the per-decade
|
|
-- rating text.
|
|
function OverworldState:dexRating(onDone)
|
|
local seen, owned = 0, 0
|
|
for _ in pairs(Game.save.pokedex.seen or {}) do seen = seen + 1 end
|
|
for _ in pairs(Game.save.pokedex.owned or {}) do owned = owned + 1 end
|
|
local key
|
|
if owned >= 150 then
|
|
key = "_DexRatingText_Own150To151"
|
|
else
|
|
local lo = math.floor(owned / 10) * 10
|
|
key = ("_DexRatingText_Own%dTo%d"):format(lo, lo + 9)
|
|
end
|
|
local rating = Game.data.text[key] or Strings("Keep it up!")
|
|
local completion = Game.data.text._DexCompletionText
|
|
or Strings("POKéDEX comp-\nletion is:\f{NUM:hDexRatingNumMonsSeen} POKéMON seen\n{NUM:hDexRatingNumMonsOwned} POKéMON owned\fPROF.OAK's\nRating:")
|
|
completion = completion
|
|
:gsub("{NUM:hDexRatingNumMonsSeen[^}]*}", tostring(seen))
|
|
:gsub("{NUM:hDexRatingNumMonsOwned[^}]*}", tostring(owned))
|
|
-- DisplayDexRating prints the completion line, then the tier text, and
|
|
-- only then plays the rating jingle and waits for a button -- the fanfare
|
|
-- must not pre-empt the evaluation it celebrates (#576). auto.wait hands
|
|
-- the box to the plain A/B path once the jingle has sounded.
|
|
Game.stack:push(TextBox.new(Game, completion .. "\f" .. rating, onDone, {
|
|
auto = { wait = true, sound = function()
|
|
return require("src.core.Sound").play(Game.data, "Pokedex_Rating")
|
|
end },
|
|
}))
|
|
end
|
|
|
|
-- AnimateHealingMachine (engine/overworld/healing_machine.asm): balls
|
|
-- every 30 frames, then jingle + FlashSprite8Times (8 x 10). #157: skip
|
|
-- pokered's post-flash .waitLoop2 / DelayFrames 32 so fighting-fit is
|
|
-- immediate; jingle still plays and restoreMap runs when it ends.
|
|
function OverworldState.stepHealAnim(ha)
|
|
ha.timer = ha.timer + 1
|
|
ha.phase = ha.phase or "balls"
|
|
if ha.phase == "balls" then
|
|
-- .partyLoop: a ball lights with the machine sfx, then 30 frames
|
|
if ha.lit == 0 or ha.timer >= 30 then
|
|
ha.timer = 0
|
|
if ha.lit < ha.balls then
|
|
ha.lit = ha.lit + 1
|
|
return "ball"
|
|
end
|
|
ha.phase = "flash"
|
|
ha.flashes = 0
|
|
return "jingle"
|
|
end
|
|
elseif ha.phase == "flash" then
|
|
-- FlashSprite8Times: xor the OBJ palette every 10 frames, 8 times
|
|
if ha.timer >= 10 then
|
|
ha.timer = 0
|
|
ha.visible = not ha.visible
|
|
ha.flashes = ha.flashes + 1
|
|
if ha.flashes >= 8 then
|
|
ha.visible = true
|
|
ha.phase = "done"
|
|
return "done"
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Nurse dialogue uses the real engine strings (data/text/text_4.asm via
|
|
-- engine/events/pokecenter.asm): welcome (plus "Shall we heal" the first
|
|
-- time), a YES/NO, then the machine animation between "we need your
|
|
-- POKéMON" and "fighting fit".
|
|
function OverworldState:nurseHeal(onDone, npc)
|
|
local t = Game.data.text
|
|
local bye = t._PokemonCenterFarewellText or Strings("We hope to see\nyou again!")
|
|
local hello = t._PokemonCenterWelcomeText
|
|
or Strings("Welcome to our\nPOKéMON CENTER!")
|
|
if not Game.save.usedPokecenter then
|
|
Game.save.usedPokecenter = true -- BIT_USED_POKECENTER
|
|
hello = hello .. "\f"
|
|
.. (t._ShallWeHealYourPokemonText or Strings("Shall we heal your\nPOKéMON?"))
|
|
end
|
|
-- Yellow's companion has its own beat threaded through this sequence
|
|
local Follower = require("src.world.PikachuFollower")
|
|
Game.stack:push(TextBox.new(Game, hello, nil, { choice = function(yes)
|
|
if not yes then
|
|
Game.stack:push(TextBox.new(Game, bye, onDone))
|
|
return
|
|
end
|
|
local need = t._NeedYourPokemonText or Strings("OK. We'll need\nyour POKéMON.")
|
|
-- accepting the heal sends the companion up onto the counter to Nurse
|
|
-- Joy first: pokecenter.asm runs `callfar PikachuWalksToNurseJoy`
|
|
-- between SetLastBlackoutMap and NeedYourPokemonText, and the hop has
|
|
-- to finish before the text box goes up because only the top state
|
|
-- updates. No follower (or not Yellow) calls straight through (#417).
|
|
Follower.hopToCounter(self, function()
|
|
Game.stack:push(TextBox.new(Game, need, function()
|
|
-- the nurse turns to the machine, the map music stops, and the
|
|
-- party heals before the machine runs (predef HealParty)
|
|
if npc then npc.facing = "left" end
|
|
-- DisablePikachuOverworldSpriteDrawing: Pikachu goes behind the
|
|
-- counter with the party for the machine animation
|
|
Follower.setVisible(self, false)
|
|
require("src.core.Music").stop()
|
|
local Pokemon = require("src.pokemon.Pokemon")
|
|
for _, mon in ipairs(Game.save.party) do
|
|
Pokemon.heal(mon)
|
|
end
|
|
Game.save.lastHeal = { -- SetLastBlackoutMap
|
|
map = self.map.id, x = self.player.cellX, y = self.player.cellY,
|
|
-- the town door of this interior, for LAST_MAP exits after a
|
|
-- blackout/ESCAPE ROPE warp here
|
|
outdoor = self.lastOutdoor
|
|
and { id = self.lastOutdoor.id, x = self.lastOutdoor.x, y = self.lastOutdoor.y }
|
|
or nil,
|
|
}
|
|
self.healAnim = { balls = #Game.save.party, lit = 0, timer = 0,
|
|
visible = true,
|
|
-- map anchor: the player's cell when healing
|
|
-- began (the GB's fixed screen coords assume it
|
|
-- BG-aligned at (64,64))
|
|
px = self.player.cellX * 16,
|
|
py = self.player.cellY * 16 }
|
|
self.healAnim.onDone = function()
|
|
-- EnablePikachuOverworldSpriteDrawing, before the fighting-fit
|
|
-- line: it comes back on the counter facing the player
|
|
Follower.setVisible(self, true)
|
|
if npc then npc:facePlayer(self.player) end
|
|
self:finishNurseHeal(bye, onDone)
|
|
end
|
|
end))
|
|
end)
|
|
end }))
|
|
end
|
|
|
|
function OverworldState:finishNurseHeal(bye, onDone)
|
|
local t = Game.data.text
|
|
local fit = t._PokemonFightingFitText or Strings("Your POKéMON are\nfighting fit!")
|
|
Game.stack:push(TextBox.new(Game, fit .. "\f" .. bye, onDone))
|
|
end
|
|
|
|
-- The Cable Club link receptionist (TX_SCRIPT_CABLE_CLUB_RECEPTIONIST ->
|
|
-- CableClubNPC, engine/link/cable_club_npc.asm): the welcome line, then
|
|
-- without the POKéDEX she's still "making preparations"; with it she asks
|
|
-- to apply (YES/NO), saves the game (SaveGameData + SFX_SAVE) and opens
|
|
-- the link. The port's enet link menu (src/link/LinkState.lua) stands in
|
|
-- for the original serial handshake; declining prints "Please come again!"
|
|
function OverworldState:cableClubReceptionist(onDone)
|
|
local t = Game.data.text
|
|
local welcome = t._CableClubNPCWelcomeText or Strings("Welcome to the\nCable Club!")
|
|
if not Game.save.flags.EVENT_GOT_POKEDEX then
|
|
-- CableClubNPC .didNotConnect path before the pokedex
|
|
Game.stack:push(TextBox.new(Game, welcome .. "\f"
|
|
.. (t._CableClubNPCMakingPreparationsText
|
|
or Strings("We're making\npreparations.\vPlease wait.")), onDone))
|
|
return
|
|
end
|
|
local apply = t._CableClubNPCPleaseApplyHereHaveToSaveText
|
|
or Strings("Please apply here.\fBefore opening\nthe link, we have\vto save the game.")
|
|
Game.stack:push(TextBox.new(Game, welcome .. "\f" .. apply, nil,
|
|
{ choice = function(yes)
|
|
if not yes then
|
|
Game.stack:push(TextBox.new(Game,
|
|
t._CableClubNPCPleaseComeAgainText or Strings("Please come\nagain!"), onDone))
|
|
return
|
|
end
|
|
Game:writeSave()
|
|
require("src.core.Sound").play(Game.data, "Save")
|
|
local ok, LinkState = pcall(require, "src.link.LinkState")
|
|
if ok and LinkState then
|
|
Game.stack:push(LinkState.new(Game))
|
|
end
|
|
if onDone then onDone() end
|
|
end }))
|
|
end
|
|
|
|
-- -------------------------------------------------------------------------
|
|
-- trainers
|
|
-- -------------------------------------------------------------------------
|
|
|
|
function OverworldState:trainerDefeated(npc)
|
|
if Game.save.defeatedTrainers[npc.id] then return true end
|
|
local header = Game.data:trainerHeader(self.map.def.label, npc.def.index)
|
|
if header and header.event and Game.save.flags[header.event] then
|
|
return true
|
|
end
|
|
return false
|
|
end
|
|
|
|
-- Run the pre-battle text -> battle -> won text -> flags sequence.
|
|
function OverworldState:engageTrainer(npc, onDone)
|
|
local d = npc.def
|
|
Runtime.emit("world.trainer_engaged", { npc = npc, trainerClass = d.trainerClass,
|
|
partyIndex = d.trainerParty })
|
|
local header = Game.data:trainerHeader(self.map.def.label, d.index)
|
|
local battleText = header and header.battle and Game.data.text[header.battle]
|
|
if not battleText then
|
|
battleText = select(1, Game.data:resolveText(self.map.def.label, d.text))
|
|
or Strings("I like shorts!\nThey're comfy and\neasy to wear!")
|
|
end
|
|
local wonText = header and header.won and Game.data.text[header.won]
|
|
|
|
local BattleState = require("src.battle.BattleState")
|
|
Game.stack:push(TextBox.new(Game, battleText, function()
|
|
local battle = BattleState.newTrainer(Game, d.trainerClass, d.trainerParty)
|
|
-- PrintEndBattleText (home/trainers.asm:341) is called from
|
|
-- TrainerBattleVictory (engine/battle/core.asm:942), i.e. ON the battle
|
|
-- screen once ScrollTrainerPicAfterBattle has brought the beaten trainer
|
|
-- back, and before MoneyForWinningText -- not in the overworld after the
|
|
-- battle screen has torn down. Handing the line to the battle also
|
|
-- stops a post-battle evolution being sandwiched between two overworld
|
|
-- cuts (#282). Substituted here because BattleState:say takes finished
|
|
-- text, while TextBox expanded the {PLAYER}/{RIVAL} tokens itself.
|
|
battle.endBattleText = wonText and TextBox.substitute(Game, wonText) or nil
|
|
battle.onFinish = function(result)
|
|
if result == "win" then
|
|
Game.save.defeatedTrainers[npc.id] = true
|
|
if header and header.event then
|
|
Game.save.flags[header.event] = true
|
|
end
|
|
-- checkVictoryRewards pushes the badge/prize box and starts the map's
|
|
-- onVictory script UNDER whatever runs next, so the player still sees
|
|
-- EndBattle (now inside the battle), then the reward, then AfterBattle
|
|
self:checkVictoryRewards(d.trainerClass, d.trainerParty)
|
|
self:afterBattle(result, battle)
|
|
if onDone then onDone() end
|
|
else
|
|
self:afterBattle(result, battle)
|
|
if onDone then onDone() end
|
|
end
|
|
end
|
|
self:pushBattle(battle)
|
|
end))
|
|
end
|
|
|
|
-- Badges/items awarded after specific battles (data/scripts/victories.lua).
|
|
-- `deactivate` retires unfought gym/dojo trainers the way the originals'
|
|
-- SetEvent / SetEventRange do after the leader victory.
|
|
-- `hide` is { { mapId, objName }, ... } -- HideObject on those toggles
|
|
-- (e.g. Brock victory clears PEWTERCITY_YOUNGSTER / ROUTE22_RIVAL1).
|
|
function OverworldState:checkVictoryRewards(trainerClass, partyIndex)
|
|
local victories = require("data.scripts.victories")
|
|
local reward = victories[trainerClass .. "#" .. tostring(partyIndex or 1)]
|
|
if not reward then return self:runVictoryHook() end
|
|
if reward.flag then
|
|
if Game.save.flags[reward.flag] then return self:runVictoryHook() end
|
|
Game.save.flags[reward.flag] = true
|
|
end
|
|
if reward.deactivate then
|
|
for _, flag in ipairs(reward.deactivate) do
|
|
Game.save.flags[flag] = true
|
|
end
|
|
end
|
|
if reward.hide then
|
|
local Commands = require("src.script.Commands")
|
|
local ctx = { game = Game, save = Game.save, overworld = self }
|
|
for _, entry in ipairs(reward.hide) do
|
|
Commands.hide_object(ctx, entry[1], entry[2])
|
|
end
|
|
end
|
|
if reward.badge then
|
|
Game.save.inventory[reward.badge] = 1
|
|
end
|
|
if reward.item then
|
|
local inv = Game.save.inventory
|
|
inv[reward.item] = (inv[reward.item] or 0) + 1
|
|
local idef = Game.data.items[reward.item]
|
|
-- GiveItem -> CopyToStringBuffer for "{RAM:wStringBuffer}" received texts
|
|
Game.stringBuffer = idef and idef.name or reward.item
|
|
end
|
|
local lines = {}
|
|
if reward.dialogue then
|
|
local text = Game.data.text or {}
|
|
for _, label in ipairs(reward.dialogue) do
|
|
if text[label] and text[label] ~= "" then
|
|
table.insert(lines, text[label])
|
|
end
|
|
end
|
|
elseif reward.badge or reward.item then
|
|
if reward.badge then
|
|
local name = Game.data.items[reward.badge] and Game.data.items[reward.badge].name
|
|
or reward.badge
|
|
table.insert(lines, Strings("%s received\nthe %s!", Game.save.player.name, name))
|
|
end
|
|
if reward.item then
|
|
local name = Game.stringBuffer or reward.item
|
|
table.insert(lines, Strings("%s received\n%s!", Game.save.player.name, name))
|
|
end
|
|
end
|
|
if #lines > 0 then
|
|
Game.stack:push(TextBox.new(Game, table.concat(lines, "\f")))
|
|
end
|
|
self:runVictoryHook()
|
|
end
|
|
|
|
-- pokered reloads the map after every battle, re-running the map
|
|
-- script (e.g. LoreleiShowOrHideExitBlock); this hook is the port's
|
|
-- equivalent so seals/toggles refresh without leaving the map
|
|
function OverworldState:runVictoryHook()
|
|
local hooks = mapScripts.get(self.map.id)
|
|
if hooks and hooks.onVictory then hooks.onVictory(Game, self) end
|
|
end
|
|
|
|
-- pokered player sprite is fixed at screen ($40, $3c). TrainerEngage reads
|
|
-- the NPC's 8-bit SPRITESTATEDATA1 X/Y pixels and CalcDifference; engage
|
|
-- distance is stored as range<<4 (pixels). There is no tile LOS check for
|
|
-- interposed NPCs / walls -- but unsigned 8-bit Y makes a sprite exactly 4
|
|
-- tiles north of the player sit at Y=$fc, so |$3c-$fc|=$c0 and a range-4
|
|
-- DOWN trainer does not engage that tile (Route 9 Bug Catcher / issue #76).
|
|
-- Off-screen sprites (IMAGEINDEX=$ff) never engage: without that gate, the
|
|
-- same 8-bit wrap makes far same-row trainers look in-range (#153/#183).
|
|
local PLAYER_SCREEN_X, PLAYER_SCREEN_Y = 0x40, 0x3c
|
|
local function u8(n) return n % 256 end
|
|
local function calcDiff(a, b)
|
|
a, b = u8(a), u8(b)
|
|
return a >= b and a - b or b - a
|
|
end
|
|
local function trainerSightPixelDist(npc, player, horizontal)
|
|
if horizontal then
|
|
return calcDiff(PLAYER_SCREEN_X,
|
|
u8(PLAYER_SCREEN_X + (npc.cellX - player.cellX) * 16))
|
|
end
|
|
return calcDiff(PLAYER_SCREEN_Y,
|
|
u8(PLAYER_SCREEN_Y + (npc.cellY - player.cellY) * 16))
|
|
end
|
|
|
|
-- CheckSpriteAvailability (movement.asm): wXCoord/wYCoord = player - 4;
|
|
-- visible when sprite is in [wCoord, wCoord + SCREEN_*/2 - 1] (GB 10x9).
|
|
local function trainerSpriteOnScreen(npc, player)
|
|
local dx = npc.cellX - player.cellX
|
|
local dy = npc.cellY - player.cellY
|
|
return dx >= -4 and dx <= 5 and dy >= -4 and dy <= 4
|
|
end
|
|
|
|
-- STAY trainers with a facing spot the player crossing their line of
|
|
-- sight (range from the extracted trainer headers), walk up and battle.
|
|
function OverworldState:checkTrainerSight()
|
|
if self.player.moving or self.engaging then return end
|
|
if Game.stack:top() ~= self then return end
|
|
local p = self.player
|
|
for _, npc in ipairs(self.npcs) do
|
|
local d = npc.def
|
|
-- CheckFightingMapTrainers engages ANY aligned trainer sprite,
|
|
-- walkers included (they sight between steps)
|
|
if d.trainerClass and not npc.moving
|
|
and not self:trainerDefeated(npc)
|
|
and not mapScripts.talkScript(self.map.id, d.text)
|
|
and trainerSpriteOnScreen(npc, p) then
|
|
local header = Game.data:trainerHeader(self.map.def.label, d.index)
|
|
local range = header and header.range or 0
|
|
local vec = DIRVEC[npc.facing]
|
|
if range > 0 and vec then
|
|
local dist, horizontal
|
|
if vec[1] ~= 0 and npc.cellY == p.cellY then
|
|
dist = (p.cellX - npc.cellX) * vec[1]
|
|
horizontal = true
|
|
elseif vec[2] ~= 0 and npc.cellX == p.cellX then
|
|
dist = (p.cellY - npc.cellY) * vec[2]
|
|
horizontal = false
|
|
end
|
|
-- Screen-pixel range (CheckSpriteCanSeePlayer), not cell count:
|
|
-- same facing-line rule as before, but the $fc Y quirk excludes the
|
|
-- 4-tiles-north tile that cell math would still count as in range.
|
|
if dist and dist >= 1 then
|
|
local pixelDist = trainerSightPixelDist(npc, p, horizontal)
|
|
if pixelDist > 0 and pixelDist <= range * 16 then
|
|
self:startTrainerApproach(npc, dist)
|
|
return
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- data/trainers/encounter_types.asm
|
|
local FEMALE_TRAINERS = {
|
|
OPP_LASS = true, OPP_JR_TRAINER_F = true, OPP_BEAUTY = true,
|
|
OPP_COOLTRAINER_F = true,
|
|
}
|
|
local EVIL_TRAINERS = {
|
|
OPP_UNUSED_JUGGLER = true, OPP_GAMBLER = true, OPP_ROCKER = true,
|
|
OPP_JUGGLER = true, OPP_CHIEF = true, OPP_SCIENTIST = true,
|
|
OPP_GIOVANNI = true, OPP_ROCKET = true,
|
|
}
|
|
|
|
function OverworldState:startTrainerApproach(npc, dist)
|
|
self.engaging = true
|
|
npc.frozen = true
|
|
-- the encounter sting (PlayTrainerMusic): evil / female / male by
|
|
-- class; rivals and gym leaders keep their own music
|
|
local cls = npc.def.trainerClass
|
|
if cls and not cls:find("RIVAL") then
|
|
local theme = EVIL_TRAINERS[cls] and "Music_MeetEvilTrainer"
|
|
or FEMALE_TRAINERS[cls] and "Music_MeetFemaleTrainer"
|
|
or "Music_MeetMaleTrainer"
|
|
require("src.core.Music").play(Game.data, theme)
|
|
end
|
|
local function fight()
|
|
self:engageTrainer(npc, function()
|
|
npc.frozen = false
|
|
self.engaging = false
|
|
end)
|
|
end
|
|
-- the "!" bubble pause before the walk-up (EmotionBubble holds the
|
|
-- world for 60 frames, engine/overworld/emotion_bubbles.asm)
|
|
self.emote = {
|
|
npc = npc, frames = 60,
|
|
onDone = function()
|
|
if dist > 1 then
|
|
self:scriptMove(npc, npc.facing, dist - 1, fight)
|
|
else
|
|
fight()
|
|
end
|
|
end,
|
|
}
|
|
end
|
|
|
|
-- Dispatch a TEXT_* constant: hand-ported script first, then extracted text.
|
|
function OverworldState:showMapText(textConst, npc, onDone)
|
|
local mapLabel = self.map.def.label
|
|
local script = mapScripts.talkScript(self.map.id, textConst)
|
|
if script then
|
|
if npc then npc:facePlayer(self.player) end
|
|
if type(script) == "function" then
|
|
-- Lua talk handlers for logic that doesn't fit command rows
|
|
script(Game, self, npc, onDone or function() end)
|
|
return
|
|
end
|
|
-- the winning contribution's rows run as their owner (09 §4.4): mod:
|
|
-- field routing, strict dispatch and error reports all read the source
|
|
self.runner:run(script, { npc = npc, onDone = onDone,
|
|
source = mapScripts.talkSource(self.map.id, textConst) })
|
|
return
|
|
end
|
|
local text, needsAsm = Game.data:resolveText(mapLabel, textConst)
|
|
if text then
|
|
if needsAsm then
|
|
Logger.warn("%s/%s uses text_asm; showing plain text (port a script in data/scripts/)",
|
|
mapLabel, textConst)
|
|
end
|
|
if npc then npc:facePlayer(self.player) end
|
|
Game.stack:push(TextBox.new(Game, text, onDone))
|
|
else
|
|
Logger.warn("no text for %s/%s", mapLabel, textConst)
|
|
if onDone then onDone() end
|
|
end
|
|
end
|
|
|
|
-- -------------------------------------------------------------------------
|
|
-- step events
|
|
-- Field poison (engine/events/poison.asm ApplyOutOfBattlePoisonDamage):
|
|
-- every 4th step, 1 HP per poisoned mon; the BG flickers dark with
|
|
-- SFX_POISONED; fainted mons get their message; a whole-party faint
|
|
-- blacks out like a lost battle. Returns true when the step should
|
|
-- stop (a text box is up).
|
|
function OverworldState:applyFieldPoison()
|
|
local save = Game.save
|
|
local interval = FieldDefaults.world(Game.data, "poisonStepInterval") or 4
|
|
save.poisonSteps = ((save.poisonSteps or 0) + 1) % interval
|
|
if save.poisonSteps ~= 0 then return false end
|
|
local damage = FieldDefaults.world(Game.data, "poisonDamage") or 1
|
|
local anyPoisoned, fainted = false, {}
|
|
for _, mon in ipairs(save.party) do
|
|
if mon.status == "PSN" and mon.hp > 0 then
|
|
anyPoisoned = true
|
|
mon.hp = mon.hp - damage
|
|
if mon.hp <= 0 then
|
|
mon.hp = 0
|
|
mon.status = nil -- the original clears status on the faint
|
|
table.insert(fainted, mon)
|
|
-- callfar_ModifyPikachuHappiness PIKAHAPPY_PSNFNT (poison.asm)
|
|
require("src.world.PikachuFollower")
|
|
.modifyHappiness(save, "PSNFNT", mon)
|
|
end
|
|
end
|
|
end
|
|
if not anyPoisoned then return false end
|
|
require("src.core.Sound").play(Game.data, "Poisoned")
|
|
self.poisonFlash = 12
|
|
local queue = {}
|
|
for _, mon in ipairs(fainted) do
|
|
local name = mon.nickname or Game.data.pokemon[mon.species].name
|
|
table.insert(queue, Strings("%s\nfainted!", name))
|
|
end
|
|
local alive = false
|
|
for _, mon in ipairs(save.party) do
|
|
if mon.hp > 0 then alive = true break end
|
|
end
|
|
local function showNext()
|
|
local msg = table.remove(queue, 1)
|
|
if msg then
|
|
Game.stack:push(TextBox.new(Game, msg, showNext))
|
|
return
|
|
end
|
|
if not alive then
|
|
Game.stack:push(TextBox.new(Game,
|
|
Strings("%s blacked\nout!", save.player.name), function()
|
|
local Pokemon = require("src.pokemon.Pokemon")
|
|
for _, mon in ipairs(save.party) do Pokemon.heal(mon) end
|
|
save.money = math.floor(save.money
|
|
/ (FieldDefaults.world(Game.data, "blackoutMoneyDivisor") or 2))
|
|
Runtime.emit("world.blacked_out",
|
|
{ save = save, healTarget = self:healPoint() })
|
|
self:warpToHealPoint()
|
|
end))
|
|
end
|
|
end
|
|
if #queue > 0 or not alive then
|
|
showNext()
|
|
return true
|
|
end
|
|
return false
|
|
end
|
|
|
|
-- -------------------------------------------------------------------------
|
|
|
|
-- the two vanilla links the encounter chains wrap, hoisted so an empty
|
|
-- chain allocates no closure
|
|
local function rollVanilla(encDef, ctx) return Encounter.roll(encDef, ctx.rng) end
|
|
local function sameEncounter(enc) return enc end
|
|
|
|
-- The wild pick, wrapped in encounter.roll (returns nil to suppress, a
|
|
-- table without calling next to force) and then encounter.species (which
|
|
-- transforms a non-nil roll before repel filtering). With no wrapper on
|
|
-- either name this is the bare Encounter.roll, same RNG draws and all.
|
|
function OverworldState:rollEncounter(encDef, terrain)
|
|
if not (Runtime.wantsHook("encounter.roll")
|
|
or Runtime.wantsHook("encounter.species")) then
|
|
return Encounter.roll(encDef)
|
|
end
|
|
local ctx = { mapId = self.map.id, terrain = terrain, rng = love.math.random }
|
|
local enc = Runtime.call("encounter.roll", rollVanilla, encDef, ctx)
|
|
if enc then
|
|
enc = Runtime.call("encounter.species", sameEncounter, enc, ctx)
|
|
end
|
|
return enc
|
|
end
|
|
|
|
function OverworldState:onStepComplete()
|
|
local p = self.player
|
|
self.todSteps = (self.todSteps or 0) + 1
|
|
-- UpdatePikachuHappinessAndMood rides the step counter (poison.asm)
|
|
require("src.world.PikachuFollower").onStep(Game.save)
|
|
-- re-evaluate day/night so a step-based clock can fire world.tod_changed;
|
|
-- paletteNameFor reads self.tod on the next paint
|
|
if Runtime.wantsHook("world.tod") then
|
|
self:timeOfDay()
|
|
end
|
|
|
|
-- hot path: the payload is only built when something is listening
|
|
if Runtime.wants("world.stepped") then
|
|
Runtime.emit("world.stepped", { mapId = self.map.id, x = p.cellX, y = p.cellY,
|
|
tile = self.map:cellTile(p.cellX, p.cellY),
|
|
tod = self.tod })
|
|
end
|
|
|
|
-- dismounting a surf: landing on a walkable cell ends it
|
|
if p.surfing and self.map:isWalkableCell(p.cellX, p.cellY) then
|
|
p.surfing = false
|
|
self:syncSurfingPikachu()
|
|
require("src.core.Music").setSurfing(Game.data, false)
|
|
end
|
|
|
|
-- Route 22 Gate rewrites LAST_MAP by Y before warps/guards fire
|
|
self:syncLastMapRewrite()
|
|
|
|
-- hand-ported step triggers (Pallet intro, Saffron gate guards, ...)
|
|
local hooks = mapScripts.get(self.map.id)
|
|
if hooks and hooks.onStep then
|
|
if hooks.onStep(Game, self, p.cellX, p.cellY) then
|
|
return
|
|
end
|
|
end
|
|
|
|
-- spinner arrow tiles (Viridian Gym, Rocket Hideout)
|
|
if self:checkSpinner() then return end
|
|
|
|
-- badge-check guards (Route 22 gate / Route 23)
|
|
if self:checkBadgeGate() then return end
|
|
|
|
-- forced bike/surf tiles + the Seafoam surf currents
|
|
if self:checkForcedMovement() then return end
|
|
if self:checkSeafoamCurrent() then return end
|
|
|
|
-- the Safari game step counter (engine/events/hidden_events/safari_game.asm)
|
|
if self:safariStep() then return end
|
|
|
|
-- day-care: the boarded Pokémon gains 1 exp per step (like the original)
|
|
if Game.save.daycare and Game.save.daycare.mon then
|
|
Game.save.daycare.steps = (Game.save.daycare.steps or 0)
|
|
+ (FieldDefaults.world(Game.data, "daycareExpPerStep") or 1)
|
|
end
|
|
|
|
-- out-of-battle poison (engine/events/poison.asm): every 4th step
|
|
-- each poisoned mon loses 1 HP, with the screen flicker + sound
|
|
if self:applyFieldPoison() then return end
|
|
|
|
self.boulderTried = nil -- a completed step ends any armed boulder push
|
|
|
|
-- arriving on a door/warp tile warp; a non-door warp square also fires
|
|
-- when the extra check passes and the d-pad is held
|
|
-- (CheckWarpsNoCollision)
|
|
-- The cell we warped in on is inert until we step off it: standing on it,
|
|
-- or being walked back onto it before leaving, does not re-fire (see
|
|
-- warpEntryCell where it is set). Once we are on any other cell it clears
|
|
-- and every warp is live again.
|
|
local entry = self.warpEntryCell
|
|
if entry and (p.cellX ~= entry.x or p.cellY ~= entry.y) then
|
|
self.warpEntryCell = nil
|
|
entry = nil
|
|
end
|
|
-- The arrival disable is POSITIONAL: warpEntryCell above is the whole
|
|
-- test. Consuming a completed step with a one-shot "just warped" counter
|
|
-- instead swallowed the warp under the player's feet, which is why a
|
|
-- second ladder one cell from the first did nothing (Seafoam B3F has warp
|
|
-- tiles on (25,3) and (25,4)) -- issue #265. pokered has no such counter:
|
|
-- every completed step runs CheckWarpsNoCollision (home/overworld.asm).
|
|
-- That same step is where BIT_STANDING_ON_WARP is maintained: cleared
|
|
-- before the check (home/overworld.asm:324), set again while standing on a
|
|
-- warp square, then cleared once more when the square is a warp-activating
|
|
-- tile that is not a door tile (IsPlayerStandingOnDoorTileOrWarpTile).
|
|
self:refreshStandingOnWarp()
|
|
if entry then
|
|
-- still standing on the warp we arrived through; do not re-trigger it
|
|
else
|
|
-- CheckWarpsNoCollision: door/warp tiles fire immediately; otherwise
|
|
-- ExtraWarpCheck must pass AND either a d-pad is held or BIT_FORCED_WARP
|
|
-- is set (Seafoam B3F currents -- home/overworld.asm).
|
|
local w = Warp.onArrive(self.map, p.cellX, p.cellY)
|
|
if not w and (self:dirHeld() or self.forcedWarp) then
|
|
w = Warp.onCollision(self.map, Game.data.field.warpCarpets,
|
|
p.cellX, p.cellY, p.facing)
|
|
end
|
|
if w then
|
|
self:takeWarp(w.def)
|
|
return
|
|
end
|
|
end
|
|
|
|
if Game.save.repelSteps and Game.save.repelSteps > 0 then
|
|
Game.save.repelSteps = Game.save.repelSteps - 1
|
|
if Game.save.repelSteps == 0 then
|
|
-- no encounter on the exact wear-off step (wild_encounters.asm
|
|
-- .lastRepelStep returns CantEncounter)
|
|
Game.stack:push(TextBox.new(Game, Strings("REPEL's effect\nwore off.")))
|
|
return
|
|
end
|
|
end
|
|
|
|
-- wild encounters in grass, on water while surfing, or -- on indoor
|
|
-- maps whose tileset is not FOREST -- on EVERY tile
|
|
-- (wild_encounters.asm: caves, towers, the Mansion, Power Plant)
|
|
local encDef = Game.data.encounters[self.map.id]
|
|
local enc
|
|
local indoor = Game.data.field.indoorEncounters
|
|
if p.surfing and encDef and encDef.water and self.map:isWaterCell(p.cellX, p.cellY) then
|
|
enc = self:rollEncounter({ grass = encDef.water }, "water")
|
|
elseif self.map:isGrassCell(p.cellX, p.cellY) then
|
|
enc = self:rollEncounter(encDef, "grass")
|
|
elseif indoor and self.map.def.index >= indoor.firstIndoorMap
|
|
and self.map.def.tileset ~= indoor.excludedTileset then
|
|
enc = self:rollEncounter(encDef, "indoor")
|
|
end
|
|
if enc then
|
|
-- REPEL blocks wild mons weaker than the lead
|
|
local lead = Game.save.party[1]
|
|
if Game.save.repelSteps and Game.save.repelSteps > 0
|
|
and lead and enc.level < lead.level then
|
|
return
|
|
end
|
|
local BattleState = require("src.battle.BattleState")
|
|
local battle = BattleState.newWild(Game, enc.species, enc.level)
|
|
-- map.ghostBattles: unidentifiable without the named item (the
|
|
-- Pokemon Tower's Silph Scope)
|
|
local ghost = Map.ghostBattles(self.map.def)
|
|
if ghost and not (ghost.unlessItem and Game.save.inventory[ghost.unlessItem]) then
|
|
battle:makeGhost()
|
|
end
|
|
-- Safari game encounters use the BALL/BAIT/ROCK/RUN menu
|
|
if Game.save.safari and Map.inRegion(self.map.def, "SAFARI", "SAFARI_ZONE") then
|
|
battle:makeSafari(Game.save.safari)
|
|
end
|
|
battle.onFinish = function(result) self:afterBattle(result, battle) end
|
|
self:pushBattle(battle)
|
|
return
|
|
end
|
|
end
|
|
|
|
-- Spinner arrow tiles (scripts/{ViridianGym,RocketHideoutB2F,B3F}.asm
|
|
-- via field.spinners): landing on one plays the arrow SFX and slides the
|
|
-- player along the extracted movement list; the landing cell may be
|
|
-- another arrow, which chains.
|
|
function OverworldState:checkSpinner()
|
|
local list = Game.data.field.spinners and Game.data.field.spinners[self.map.id]
|
|
if not list then return false end
|
|
local p = self.player
|
|
for _, sp in ipairs(list) do
|
|
if sp.x == p.cellX and sp.y == p.cellY then
|
|
require("src.core.Sound").play(Game.data, "Arrow_Tiles")
|
|
self:runSpinnerMoves(sp.moves, 1)
|
|
return true
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
|
|
function OverworldState:runSpinnerMoves(moves, i)
|
|
local mv = moves[i]
|
|
if not mv then
|
|
self.player.spinning = false
|
|
-- Scripted steps skip onStepComplete while they run; once the RLE
|
|
-- finishes, re-enter the normal landing pipeline so chained spinners,
|
|
-- Seafoam currents, and CheckWarpsNoCollision (incl. BIT_FORCED_WARP)
|
|
-- see the tile we stopped on -- same as pokered after simulated joypad.
|
|
self:onStepComplete()
|
|
return
|
|
end
|
|
self.player.spinning = true -- spin the sprite while sliding
|
|
self:scriptMove(self.player, mv.dir, mv.count, function()
|
|
self:runSpinnerMoves(moves, i + 1)
|
|
end)
|
|
end
|
|
|
|
-- Badge-check guards (scripts/Route22Gate.asm, scripts/Route23.asm via
|
|
-- field.badgeGates): stepping on a guard row without the badge turns
|
|
-- you back; with it, the guard waves you through once.
|
|
|
|
-- field.lastMapRewrites: maps that rewrite wLastMap from the player's
|
|
-- position every frame. Rules are ordered, first match wins, the last row
|
|
-- is the default -- pokered Route22Gate_Script is Y < 4 -> ROUTE_23, else
|
|
-- ROUTE_22, which is what makes the gate's north exit leave onto Route 23.
|
|
-- All four of its door warps are LAST_MAP.
|
|
function OverworldState.rewrittenLastMap(rewrite, cellX, cellY)
|
|
local value = rewrite.axis == "x" and cellX or cellY
|
|
for _, rule in ipairs(rewrite.rules or {}) do
|
|
if (rule.below == nil or value < rule.below)
|
|
and (rule.atLeast == nil or value >= rule.atLeast) then
|
|
return rule.map
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
function OverworldState:syncLastMapRewrite()
|
|
if not self.map then return end
|
|
local rewrites = FieldDefaults.field(Game.data, "lastMapRewrites")
|
|
local rewrite = rewrites and rewrites[self.map.id]
|
|
if not rewrite then return end
|
|
local id = OverworldState.rewrittenLastMap(rewrite, self.player.cellX,
|
|
self.player.cellY)
|
|
if not id or (self.lastOutdoor and self.lastOutdoor.id == id) then return end
|
|
local warps = Game.data.maps[id] and Game.data.maps[id].warps
|
|
local w = warps and warps[1]
|
|
self:rememberOutdoor(id, w and w.x or 0, w and w.y or 0)
|
|
end
|
|
|
|
-- field.badgeGates is keyed by map; the record's shape picks the rule.
|
|
-- `coords` is the Route 22 gate's single checkpoint (one-shot pass text),
|
|
-- `guards` the Route 23 ladder of per-row guards.
|
|
function OverworldState:checkBadgeGate()
|
|
local gates = Game.data.field.badgeGates
|
|
local g = gates and gates[self.map.id]
|
|
if not g then return false end
|
|
local p = self.player
|
|
local t = Game.data.text
|
|
|
|
if g.coords then
|
|
local passedFlag = FieldDefaults.fieldValue(Game.data, "badgeGates",
|
|
self.map.id, "passedFlag")
|
|
or ("PASSED_" .. self.map.id)
|
|
for _, c in ipairs(g.coords) do
|
|
if p.cellX == c.x and p.cellY == c.y then
|
|
if Game.save.inventory[g.badge] then
|
|
if not Game.save.flags[passedFlag] then
|
|
Game.save.flags[passedFlag] = true
|
|
-- Route22GateGuardGoRightAheadText plays sound_get_item_1
|
|
require("src.core.Sound").play(Game.data, "Get_Item1")
|
|
Game.stack:push(TextBox.new(Game,
|
|
t["_" .. g.passText] or Strings("Go right ahead!")))
|
|
end
|
|
return false
|
|
end
|
|
-- Route22GateGuardNoBoulderbadgeText plays SFX_DENIED
|
|
require("src.core.Sound").play(Game.data, "Denied")
|
|
Game.stack:push(TextBox.new(Game,
|
|
(t["_" .. g.failText] or Strings("You don't have the\nBOULDERBADGE yet!"))
|
|
.. (t._Route22GateGuardICantLetYouPassText or ""), function()
|
|
self:scriptMove(p, "down", 1)
|
|
end))
|
|
return true
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
|
|
if g.guards then
|
|
for _, guard in ipairs(g.guards) do
|
|
if p.cellY == guard.y and (not guard.maxX or p.cellX <= guard.maxX)
|
|
and not Game.save.flags[guard.event] then
|
|
local badgeName = Game.data.items[guard.badge]
|
|
and Game.data.items[guard.badge].name or guard.badge
|
|
if Game.save.inventory[guard.badge] then
|
|
Game.save.flags[guard.event] = true
|
|
-- Route23OhThatIsTheBadgeText plays sound_get_item_1
|
|
require("src.core.Sound").play(Game.data, "Get_Item1")
|
|
local text = (t["_" .. g.passText] or
|
|
Strings("Oh! That is the\n{RAM}!")):gsub("{RAM:wNameBuffer}", badgeName)
|
|
Game.stack:push(TextBox.new(Game, text))
|
|
return false
|
|
end
|
|
-- Route23YouDontHaveTheBadgeYetText plays SFX_DENIED
|
|
require("src.core.Sound").play(Game.data, "Denied")
|
|
local text = (t["_" .. g.failText] or
|
|
Strings("You don't have the\n{RAM} yet!")):gsub("{RAM:wNameBuffer}", badgeName)
|
|
Game.stack:push(TextBox.new(Game, text, function()
|
|
self:scriptMove(p, "down", 1)
|
|
end))
|
|
return true
|
|
end
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
|
|
-- Forced bike/surf tiles (data/maps/force_bike_surf.asm): the Cycling
|
|
-- Road entrances force you onto the BICYCLE (or turn you back without
|
|
-- one); the Seafoam current mouths force surfing.
|
|
function OverworldState:checkForcedMovement()
|
|
local fm = Game.data.field.forcedMovement
|
|
if not fm then return false end
|
|
local p = self.player
|
|
for _, tile in ipairs(fm.tiles[self.map.id] or {}) do
|
|
if p.cellX == tile.x and p.cellY == tile.y then
|
|
if tile.mode == "bike" then
|
|
-- CheckForceBikeOrSurf (engine/overworld/player_state.asm) also
|
|
-- sets BIT_ALWAYS_ON_BIKE of wStatusFlags6 here -- the flag
|
|
-- IsSurfingAllowed reads to refuse SURF on the Cycling Road.
|
|
-- Cleared by the Route 16/18 gate scripts, fly/dungeon warps and
|
|
-- blackouts (see setMap / flyTo / warpToHealPoint).
|
|
if Game.save.onBike then
|
|
Game.save.forcedBike = true
|
|
return false
|
|
end
|
|
if (Game.save.inventory.BICYCLE or 0) > 0 then
|
|
-- CheckForceBikeOrSurf mounts silently; _CyclingIsFunText only
|
|
-- exists as IsSurfingAllowed's refusal (engine/overworld/
|
|
-- field_move_messages.asm), never as a mount message.
|
|
Game.save.onBike = true
|
|
Game.save.forcedBike = true
|
|
require("src.core.Music").playMap(Game.data, self.map.id, true)
|
|
else
|
|
Game.stack:push(TextBox.new(Game, Strings("You need a\nBICYCLE for the\nCycling Road!"),
|
|
function()
|
|
local back = ({ up = "down", down = "up",
|
|
left = "right", right = "left" })[p.facing]
|
|
self:scriptMove(p, back, 1)
|
|
end))
|
|
return true
|
|
end
|
|
elseif tile.mode == "surf" then
|
|
p.surfing = true
|
|
self:syncSurfingPikachu()
|
|
require("src.core.Music").setSurfing(Game.data, true)
|
|
end
|
|
return false
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
|
|
-- The Seafoam Islands surf currents (scripts/SeafoamIslandsB3F/B4F.asm
|
|
-- via field.seafoam): while the plug boulders aren't down, the water
|
|
-- drags the player along the extracted movement lists; the B4F pool
|
|
-- edge pushes you back up until the B3F boulders fall.
|
|
function OverworldState:checkSeafoamCurrent()
|
|
local sf = Game.data.field.seafoam and Game.data.field.seafoam[self.map.id]
|
|
if not sf then return false end
|
|
local p = self.player
|
|
local function allSet(events)
|
|
for _, e in ipairs(events or {}) do
|
|
if not Game.save.flags[e] then return false end
|
|
end
|
|
return true
|
|
end
|
|
|
|
if sf.forcedExit and p.surfing and not allSet(sf.forcedExit.activeUntilEvents) then
|
|
for _, c in ipairs(sf.forcedExit.coords) do
|
|
if p.cellX == c.x and p.cellY == c.y then
|
|
-- SeafoamIslandsB4FDefaultScript: res BIT_FORCED_WARP before the
|
|
-- push so the B3F stair warps underfoot cannot bounce you back.
|
|
self.forcedWarp = false
|
|
require("src.core.Sound").play(Game.data, "Collision")
|
|
self:scriptMove(p, "up", c.y == 17 and 2 or 1)
|
|
return true
|
|
end
|
|
end
|
|
end
|
|
|
|
if not p.surfing then return false end
|
|
local active = {}
|
|
if not allSet(sf.currentsDisabledByEvents) then
|
|
for _, c in ipairs(sf.currents or {}) do table.insert(active, c) end
|
|
end
|
|
if sf.entryCurrent then
|
|
local plugged = true
|
|
for _, h in ipairs((sf.pluggedByHolesOn or {}).holes or {}) do
|
|
if not Game.save.flags[h.boulderEvent] then plugged = false end
|
|
end
|
|
if not plugged then table.insert(active, sf.entryCurrent) end
|
|
end
|
|
for _, c in ipairs(active) do
|
|
if p.cellX == c.x and p.cellY == c.y then
|
|
-- SeafoamIslandsB3F.asm sets BIT_FORCED_WARP before DecodeRLEList so
|
|
-- the south-edge water stairs auto-warp when the current ends.
|
|
if FieldDefaults.fieldValue(Game.data, "seafoam", self.map.id,
|
|
"setsForcedWarp") then
|
|
self.forcedWarp = true
|
|
end
|
|
self:runSpinnerMoves(c.moves, 1)
|
|
return true
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
|
|
-- Boulder holes (Seafoam4HolesCoords etc.): a boulder pushed onto a
|
|
-- hole falls to the floor below, permanently plugging a current.
|
|
function OverworldState:seafoamHolesFor(mapId)
|
|
local out = {}
|
|
for owner, sf in pairs(Game.data.field.seafoam or {}) do
|
|
if owner == mapId then
|
|
for _, h in ipairs(sf.holes or {}) do
|
|
table.insert(out, { hole = h, destMap = sf.holeDestination })
|
|
end
|
|
end
|
|
if sf.pluggedByHolesOn and sf.pluggedByHolesOn.map == mapId then
|
|
for _, h in ipairs(sf.pluggedByHolesOn.holes or {}) do
|
|
table.insert(out, { hole = h, destMap = owner })
|
|
end
|
|
end
|
|
end
|
|
return out
|
|
end
|
|
|
|
-- toggleable_objects.asm names (TOGGLE_SEAFOAM_ISLANDS_B3F_BOULDER_1)
|
|
-- vs object_event const names (SEAFOAMISLANDSB3F_BOULDER1)
|
|
local function toggleToObjectName(mapId, toggleName)
|
|
local prefix = "TOGGLE_" .. mapId .. "_"
|
|
if toggleName:sub(1, #prefix) ~= prefix then return nil end
|
|
return mapId:gsub("_", "") .. "_" .. toggleName:sub(#prefix + 1):gsub("_", "")
|
|
end
|
|
|
|
function OverworldState:boulderIntoHole(npc)
|
|
for _, entry in ipairs(self:seafoamHolesFor(self.map.id)) do
|
|
local h = entry.hole
|
|
if npc.cellX == h.x and npc.cellY == h.y then
|
|
require("src.core.Sound").play(Game.data, "Faint_Thud")
|
|
Game.save.flags[h.boulderEvent] = true
|
|
local toggles = Game.save.objectToggles or {}
|
|
Game.save.objectToggles = toggles
|
|
if h.hideObject then
|
|
local name = toggleToObjectName(self.map.id, h.hideObject)
|
|
if name then
|
|
toggles[self.map.id] = toggles[self.map.id] or {}
|
|
toggles[self.map.id][name] = false
|
|
end
|
|
end
|
|
if h.showObject and entry.destMap then
|
|
local name = toggleToObjectName(entry.destMap, h.showObject)
|
|
if name then
|
|
toggles[entry.destMap] = toggles[entry.destMap] or {}
|
|
toggles[entry.destMap][name] = true
|
|
end
|
|
end
|
|
for i = #self.npcs, 1, -1 do
|
|
if self.npcs[i] == npc then table.remove(self.npcs, i) end
|
|
end
|
|
for i = #self.entities, 1, -1 do
|
|
if self.entities[i] == npc then table.remove(self.entities, i) end
|
|
end
|
|
Game.stack:push(TextBox.new(Game, Strings("The boulder fell\nthrough the hole!")))
|
|
return true
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
|
|
-- Safari game step/ball bookkeeping. 502 steps per ¥500 game; running
|
|
-- out of steps (or balls, checked after battles) ends the game and
|
|
-- returns to the gate (engine/events/hidden_events/safari_game.asm).
|
|
-- The oracle gates on EVENT_IN_SAFARI_ZONE, not the current map (see
|
|
-- home/overworld.asm:307-310); that flag is set right before the
|
|
-- entrance auto-walk off SAFARI_ZONE_GATE and cleared only when the
|
|
-- player returns to the gate (or uses an Escape Rope), so every
|
|
-- interior Safari Zone map -- the 4 zone quadrants plus the 4 rest
|
|
-- houses plus the secret house -- counts, and the gate itself never
|
|
-- does.
|
|
-- field.safari.stepMaps
|
|
function OverworldState:inSafariStepZone()
|
|
for _, m in ipairs(FieldDefaults.fieldValue(Game.data, "safari", "stepMaps") or {}) do
|
|
if m == self.map.id then return true end
|
|
end
|
|
return false
|
|
end
|
|
|
|
function OverworldState:safariStep()
|
|
local st = Game.save.safari
|
|
if not st or not self:inSafariStepZone() then return false end
|
|
st.steps = st.steps - 1
|
|
if st.steps > 0 then return false end
|
|
self:safariGameOver(Strings("PA: Ding-dong!\nTime's up!"))
|
|
return true
|
|
end
|
|
|
|
function OverworldState:safariGameOver(text)
|
|
require("src.core.Sound").play(Game.data, "Safari_Zone_PA")
|
|
Game.save.safari = nil
|
|
local t = Game.data.text
|
|
Game.stack:push(TextBox.new(Game,
|
|
(text or "") .. "\f" .. (t._GameOverText or Strings("PA: Your SAFARI\nGAME is over!")),
|
|
function()
|
|
local exit_ = FieldDefaults.fieldValue(Game.data, "safari", "exitWarp")
|
|
self:startWarpTo(exit_.map, exit_.x, exit_.y, exit_.facing or "down")
|
|
end))
|
|
end
|
|
|
|
-- Blackouts return to the last heal point; evolutions run after battles.
|
|
-- battle is optional; when given, Oak's Lab OPP_RIVAL1 losses skip the
|
|
-- blackout (pret HandlePlayerBlackOut) so the map script can HealParty.
|
|
function OverworldState:afterBattle(result, battle)
|
|
local lead = Game.save.party[1]
|
|
Logger.info("battle over: %s (lead %s %d/%d)", tostring(result),
|
|
lead and lead.species or "-", lead and lead.hp or 0,
|
|
lead and lead.stats.hp or 0)
|
|
local Evolution = require("src.pokemon.Evolution")
|
|
local function evolutions()
|
|
-- Only mons that gained a level this battle (EXP.ALL included).
|
|
-- Scanning the whole party re-offered B-cancelled evolutions forever (#213).
|
|
Evolution.checkParty(Game, nil, battle and battle.leveledUp)
|
|
end
|
|
if result == "lose" then
|
|
local oaksLabRival = battle and battle.oppClass == "OPP_RIVAL1"
|
|
and self.map and self.map.id == "OAKS_LAB"
|
|
if oaksLabRival then
|
|
-- stay in the lab; OaksLabRivalEndBattleScript heals and continues
|
|
evolutions()
|
|
return
|
|
end
|
|
-- blackout: revive the party at the last heal point; half the
|
|
-- money is lost (like the original)
|
|
local Pokemon = require("src.pokemon.Pokemon")
|
|
for _, mon in ipairs(Game.save.party) do
|
|
Pokemon.heal(mon)
|
|
end
|
|
Game.save.money = math.floor(Game.save.money
|
|
/ (FieldDefaults.world(Game.data, "blackoutMoneyDivisor") or 2))
|
|
Runtime.emit("world.blacked_out",
|
|
{ save = Game.save, healTarget = self:healPoint() })
|
|
self:warpToHealPoint(evolutions)
|
|
else
|
|
-- EndTrainerBattle sets BIT_CUR_MAP_LOADED_1 (home/trainers.asm), which
|
|
-- re-runs the floor's door callback: beating the last Rocket Hideout guard
|
|
-- opens the lift gate without leaving the map (#372)
|
|
self:stampClosedDoors()
|
|
-- throwing the last SAFARI BALL ends the game
|
|
if Game.save.safari and Game.save.safari.balls <= 0 then
|
|
self:safariGameOver(Strings("PA: You're out of\nSAFARI BALLs!"))
|
|
end
|
|
evolutions()
|
|
end
|
|
end
|
|
|
|
-- -------------------------------------------------------------------------
|
|
-- warps
|
|
-- -------------------------------------------------------------------------
|
|
|
|
-- field.boot: where a save with no heal point of its own returns to. The
|
|
-- lastHeal record wins; otherwise the new game's own spawn cell.
|
|
function OverworldState:healPoint()
|
|
local boot = Game.data.field.boot or {}
|
|
return Game.save.lastHeal or boot.lastHeal
|
|
or { map = boot.startMap, x = boot.startX, y = boot.startY }
|
|
end
|
|
|
|
function OverworldState:takeWarp(warpDef)
|
|
local last = self.lastOutdoor
|
|
if warpDef.destMap == "LAST_MAP" and not last then
|
|
-- old saves / unexpected states: never crash on an exit mat, fall
|
|
-- back to the heal point's town door (or the boot spawn)
|
|
Logger.warn("LAST_MAP warp with no remembered outdoor map; using heal point")
|
|
local heal = self:healPoint()
|
|
last = heal.outdoor or { id = heal.map, x = heal.x, y = heal.y }
|
|
end
|
|
local fromMap = self.map.id
|
|
local destMap, x, y = Warp.destination(Game.data, warpDef, last)
|
|
Runtime.emit("player.warped", { fromMap = fromMap, toMap = destMap,
|
|
x = x, y = y, warp = warpDef })
|
|
-- facing carries across the warp (leaving a gate sideways keeps you
|
|
-- walking sideways; house exit mats are stepped onto facing down)
|
|
local facing = self.player.facing
|
|
-- warp pads and fall-through holes are not doors (WarpFound2
|
|
-- .indoorMaps: IsPlayerStandingOnWarpPadOrHole routes them through
|
|
-- LeaveMapAnim/EnterMapAnim instead of the door SFX)
|
|
local pad = self.map.warpPadOrHoleAt
|
|
and self.map:warpPadOrHoleAt(self.player.cellX, self.player.cellY)
|
|
if pad == "pad" then
|
|
-- teleporter: spin out with the exit SFX, spin back in on arrival
|
|
-- (player_animations.asm _LeaveMapAnim / EnterMapAnim)
|
|
require("src.core.Sound").play(Game.data, "Teleport_Exit1")
|
|
self.player.spinning = true
|
|
self.player.spinTimer = 0
|
|
self.arriveWarp = "teleport"
|
|
self:startWarpTo(destMap, x, y, facing)
|
|
return
|
|
elseif pad == "hole" then
|
|
-- falling through a hole: Faint_Fall plays while the player drops,
|
|
-- matching the boulder-hole Faint_Thud at line 3618 (#694)
|
|
require("src.core.Sound").play(Game.data, "Faint_Fall")
|
|
self:startWarpTo(destMap, x, y, facing)
|
|
return
|
|
end
|
|
self.doorWarp = true -- door SFX + PlayerStepOutFromDoor walk-out
|
|
self:startWarpTo(destMap, x, y, facing)
|
|
end
|
|
|
|
-- Remember the outdoor side for LAST_MAP exits (pokered's wLastMap).
|
|
function OverworldState:rememberOutdoor(id, x, y)
|
|
self.lastOutdoor = { id = id, x = x, y = y }
|
|
Game.save.lastOutdoor = self.lastOutdoor
|
|
end
|
|
|
|
-- Warp to the last heal point (blackout, ESCAPE ROPE, DIG/TELEPORT).
|
|
-- The heal point is usually an interior, so LAST_MAP exits are re-pointed
|
|
-- at its remembered town door rather than wherever the player left from.
|
|
--
|
|
-- opts.arrive = "teleport" for Dig/Teleport/Escape Rope (LeaveMapAnim /
|
|
-- EnterMapAnim). Blackouts omit it: pret HandleBlackOut only
|
|
-- GBFadeOutToBlack + PrepareForSpecialWarp + SpecialEnterMap, and never
|
|
-- sets BIT_FLY_WARP / BIT_DUNGEON_WARP, so EnterMap never runs EnterMapAnim.
|
|
function OverworldState:warpToHealPoint(onDone, opts)
|
|
local heal = self:healPoint()
|
|
self.player.surfing = false
|
|
self:syncSurfingPikachu()
|
|
-- HandleFlyWarpOrDungeonWarp + DisplayPlayerBlackedOutText both clear
|
|
-- BIT_ALWAYS_ON_BIKE (home/overworld.asm / home/text_script.asm)
|
|
Game.save.forcedBike = nil
|
|
local map, x, y = heal.map, heal.x, heal.y
|
|
local teleport = opts and opts.arrive == "teleport"
|
|
if teleport then
|
|
self.arriveWarp = "teleport"
|
|
-- Dig/Teleport/Escape Rope land OUTSIDE at the last Pokemon Center TOWN
|
|
-- door, like Fly (#196) -- NOT the interior heal cell a blackout returns
|
|
-- to. pret routes escape-warp and blackout both through wLastBlackoutMap
|
|
-- (both appear inside in front of the nurse), but this port has decided
|
|
-- the escape-warp destination is the town PC door. Prefer the canonical
|
|
-- Fly landing (field.flyWarps, one tile south of the PC door warp), else
|
|
-- the remembered outdoor door cell; fall back to the interior heal cell
|
|
-- only for an old save with no recorded outdoor.
|
|
local out = heal.outdoor
|
|
if out then
|
|
local fw = (Game.data.field.flyWarps or {})[out.id]
|
|
map = out.id
|
|
x = fw and fw.x or out.x
|
|
y = fw and fw.y or out.y
|
|
end
|
|
end
|
|
self:startWarpTo(map, x, y, "down", onDone)
|
|
-- Blackouts land at the interior heal cell, so re-point LAST_MAP exits at
|
|
-- the remembered town door. The teleport branch already lands ON that
|
|
-- outdoor map, so startWarpTo remembers it on the next exit; re-pointing
|
|
-- here would wrongly steer exits away from where the player now stands.
|
|
if heal.outdoor and not teleport then
|
|
self:rememberOutdoor(heal.outdoor.id, heal.outdoor.x, heal.outdoor.y)
|
|
end
|
|
end
|
|
|
|
-- opts.keepMusic: scripted warps mid-cutscene keep the current song
|
|
-- playing across the map change, like BIT_NO_MAP_MUSIC (wStatusFlags7)
|
|
-- does for the Oak escort (engine/overworld/auto_movement.asm
|
|
-- PalletMovementScript_OakMoveLeft sets it; scripts/OaksLab.asm
|
|
-- OaksLabFollowedOakScript clears it and calls PlayDefaultMusic).
|
|
function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts)
|
|
-- ANY transition off an outdoor map remembers the outdoor side, so
|
|
-- scripted warps (the Oak walk-in) keep LAST_MAP exits working.
|
|
-- CheckIfInOutsideMap (home/overworld.asm) treats PLATEAU (Route 23 /
|
|
-- Indigo Plateau) as outside too, alongside OVERWORLD -- without it,
|
|
-- LAST_MAP exits taken off Route 23/Indigo Plateau (the Route 22 Gate
|
|
-- back door, the Indigo Plateau lobby doors) resolve against a stale
|
|
-- remembered map instead.
|
|
if Map.isOutside(self.map.def, FieldDefaults.field(Game.data, "outsideTilesets"))
|
|
and mapId ~= self.map.id then
|
|
self:rememberOutdoor(self.map.id, self.player.cellX, self.player.cellY)
|
|
end
|
|
self.transitioning = true
|
|
local doorWarp = self.doorWarp
|
|
self.doorWarp = nil
|
|
local arriveWarp = self.arriveWarp
|
|
self.arriveWarp = nil
|
|
Game.stack:push(Transition.new(Game, function()
|
|
self:setMap(mapId, x, y, facing or "down", opts)
|
|
-- The warp we land ON stays inert for the completed-step check until we
|
|
-- physically step off it, so a warp whose destination cell is itself a
|
|
-- warp cannot bounce us straight back (elevator cars, stacked stair/door
|
|
-- mats). BIT_STANDING_ON_WARP is deliberately NOT touched here:
|
|
-- ClearVariablesOnEnterMap leaves wMovementFlags alone, so the flag the
|
|
-- departing tile set rides through the warp (issue #378).
|
|
self.warpEntryCell = { x = x, y = y }
|
|
-- Fly/Teleport/Dig/Escape-Rope landings poof the player back in
|
|
-- (player_animations.asm EnterMapAnim). Blackouts and ordinary
|
|
-- door warps never take this branch.
|
|
if arriveWarp == "fly" then
|
|
require("src.core.Sound").play(Game.data, "Fly")
|
|
elseif arriveWarp == "teleport" then
|
|
require("src.core.Sound").play(Game.data, "Teleport_Enter1")
|
|
-- ENTER_2 caps the spin-down a moment later
|
|
self.delaySfx = { frames = 40, key = "Teleport_Enter2" }
|
|
-- the sprite spins down into place (EnterMapAnim
|
|
-- PlayerSpinWhileMovingDown), not just the SFX
|
|
self.player.spinning = true
|
|
self.player.spinTimer = 0
|
|
self.player.spinFrames = 48
|
|
self.player.spinTotal = 48
|
|
self.player.spinDrop = true
|
|
end
|
|
if doorWarp then
|
|
local outdoor = Map.isOutdoor(self.map.def)
|
|
require("src.core.Sound").play(Game.data,
|
|
outdoor and "Go_Outside" or "Go_Inside")
|
|
-- PlayerStepOutFromDoor (engine/overworld/auto_movement.asm): any
|
|
-- warp that lands on a door tile auto-steps south once, indoor or
|
|
-- outdoor. Auto-walk leaves the mat, so the arrival disable
|
|
-- (warpEntryCell) is unnecessary -- and would let you stand on the
|
|
-- door without re-entering if you hold back into it.
|
|
-- The walk-out is a simulated d-pad press (wSimulatedJoypadStates),
|
|
-- not a forced move, so it obeys collision: on a landing with a
|
|
-- solid cell south of the door (the mansion stair landings back
|
|
-- onto shelves) the step bumps and the player stays on the door,
|
|
-- arrival disable intact, instead of clipping into the wall.
|
|
if self.map:isDoorTileCell(self.player.cellX, self.player.cellY) then
|
|
if Collision.canMove(self.map, self.entities, self.player, "down") then
|
|
self.warpEntryCell = nil
|
|
self:scriptMove(self.player, "down", 1)
|
|
else
|
|
self.player.facing = "down"
|
|
end
|
|
end
|
|
end
|
|
end, function()
|
|
self.transitioning = false
|
|
if onDone then onDone() end
|
|
end))
|
|
end
|
|
|
|
-- Re-read a map record after its data changed (WorldAPI:invalidateMap,
|
|
-- dev-mode hot reload). The neighbors go too: their strips render the
|
|
-- same tileset. When the active map is the one that changed, the player
|
|
-- is clamped back in bounds, the NPC pool is reused so runtime handles
|
|
-- survive, and the tile-pair table is re-read. keepMusic: a reload is not a
|
|
-- map entry. Its counterpart ReloadMapData (home/reload_tiles.asm) only
|
|
-- re-reads the map view and the tileset tile patterns after the Pokedex /
|
|
-- start menu / PC clobbered VRAM; map music starts from LoadMapData alone
|
|
-- (home/overworld.asm, gated on BIT_NO_MAP_MUSIC). Whatever is playing
|
|
-- belongs to the state on top, so a COLORS cycle during a battle
|
|
-- (PaletteFX.setMode reloads the live map to rebuild its baked atlas) must
|
|
-- not drop the route theme over the battle song (#484). The out-of-bounds
|
|
-- fallback below is a real map change and keeps its map music.
|
|
function OverworldState:reloadMap(mapId, reason)
|
|
MapLoader.invalidate(mapId)
|
|
for _, nb in ipairs(self.neighbors or {}) do MapLoader.invalidate(nb.map.id) end
|
|
if self.map and self.map.id == mapId then
|
|
local p = self.player
|
|
local x, y, facing = p.cellX, p.cellY, p.facing
|
|
Collision.load(Game.data)
|
|
self:setMap(mapId, x, y, facing,
|
|
{ seamless = true, via = "reload", keepMusic = true })
|
|
if not self.map:inBounds(x, y) then
|
|
local heal = self:healPoint()
|
|
Logger.warn("map %s reloaded out from under the player; sending to %s",
|
|
mapId, tostring(heal.map))
|
|
self:setMap(heal.map, heal.x, heal.y, "down", { via = "reload" })
|
|
end
|
|
end
|
|
Runtime.emit("map.reloaded", { mapId = mapId, reason = reason or "invalidate" })
|
|
end
|
|
|
|
-- Append a runtime object to a map record and, when that map is live,
|
|
-- instantiate it through the shared pool so it crosses seams like an
|
|
-- imported object. Runtime objects are never serialized into map data.
|
|
function OverworldState:addRuntimeObject(mapId, objDef, owner)
|
|
local def = Game.data.maps[mapId]
|
|
if not def then return nil, "unknown map: " .. tostring(mapId) end
|
|
def.objects = def.objects or {}
|
|
local index = 0
|
|
for _, obj in ipairs(def.objects) do
|
|
if (obj.index or 0) > index then index = obj.index end
|
|
end
|
|
objDef.index = index + 1
|
|
objDef.runtime = true
|
|
objDef.owner = owner
|
|
table.insert(def.objects, objDef)
|
|
local npcId = mapId .. "_obj_" .. objDef.index
|
|
if self.map and self.map.id == mapId and self.npcPool then
|
|
local npc = pooledNPC(self.npcPool, Game.data, mapId, objDef)
|
|
npc.frozen = false
|
|
table.insert(self.npcs, npc)
|
|
table.insert(self.entities, npc)
|
|
end
|
|
return npcId
|
|
end
|
|
|
|
-- Drop a runtime object again; imported objects are refused, and so is
|
|
-- another mod's.
|
|
function OverworldState:removeRuntimeObject(npcId, owner)
|
|
for mapId, def in pairs(Game.data.maps) do
|
|
for i, obj in ipairs(def.objects or {}) do
|
|
if obj.runtime and mapId .. "_obj_" .. obj.index == npcId then
|
|
if owner ~= nil and obj.owner ~= owner then
|
|
return nil, "not owned by " .. tostring(owner)
|
|
end
|
|
table.remove(def.objects, i)
|
|
if self.npcPool then self.npcPool[npcId] = nil end
|
|
for _, list in ipairs({ self.npcs or {}, self.entities or {} }) do
|
|
for j = #list, 1, -1 do
|
|
if list[j].id == npcId then table.remove(list, j) end
|
|
end
|
|
end
|
|
return true
|
|
end
|
|
end
|
|
end
|
|
return nil, "no runtime object " .. tostring(npcId)
|
|
end
|
|
|
|
-- Replace a map block (Victory Road barriers, Cut trees) and redraw.
|
|
function OverworldState:replaceBlock(bx, by, block)
|
|
self.map:setBlock(bx, by, block)
|
|
self.map.renderer:rebuild()
|
|
Runtime.emit("world.block_replaced",
|
|
{ mapId = self.map.id, bx = bx, by = by, block = block })
|
|
end
|
|
|
|
-- -------------------------------------------------------------------------
|
|
-- scripted movement
|
|
-- -------------------------------------------------------------------------
|
|
|
|
function OverworldState:scriptMove(entity, dir, tiles, onDone)
|
|
table.insert(self.scriptMoves, {
|
|
entity = entity, dir = dir, remaining = tiles, onDone = onDone,
|
|
})
|
|
end
|
|
|
|
-- A step-in-place beat: the entity plays one walk-cycle animation (16
|
|
-- frames) without translating, keeping its current facing. Ports the
|
|
-- NPC_CHANGE_FACING movement byte (engine/overworld/movement.asm
|
|
-- ChangeFacingDirection -> zero-delta TryWalking), used for Oak marching
|
|
-- on the lab door mat at the tail of RLEList_ProfOakWalkToLab.
|
|
function OverworldState:marchInPlace(entity, onDone)
|
|
table.insert(self.scriptMoves, {
|
|
entity = entity, inPlace = true, remaining = 1, onDone = onDone,
|
|
})
|
|
end
|
|
|
|
-- Advance scripted moves in two phases so a chained step (a new move
|
|
-- queued by a completing move's onDone) begins the SAME frame the
|
|
-- previous one ends -- back-to-back 16-frame tiles like the GB's
|
|
-- simulated-joypad / NPC scripted movement, with no idle frame between
|
|
-- tiles. Phase 1 retires finished moves (which may chain new ones);
|
|
-- phase 2 then starts every not-yet-moving move.
|
|
function OverworldState:updateScriptMoves()
|
|
local i = 1
|
|
while i <= #self.scriptMoves do
|
|
local mv = self.scriptMoves[i]
|
|
if not mv.entity.moving and mv.remaining <= 0 then
|
|
table.remove(self.scriptMoves, i)
|
|
if mv.onDone then mv.onDone() end
|
|
-- don't advance i: a move chained by onDone may now sit at i
|
|
else
|
|
i = i + 1
|
|
end
|
|
end
|
|
for _, mv in ipairs(self.scriptMoves) do
|
|
local e = mv.entity
|
|
if not e.moving and mv.remaining > 0 then
|
|
if mv.inPlace then
|
|
e.moving = true
|
|
e.marching = true
|
|
e.progress = 0
|
|
else
|
|
e.facing = mv.dir
|
|
local tx, ty = Collision.target(e.cellX, e.cellY, mv.dir)
|
|
e.targetX, e.targetY = tx, ty
|
|
e.moving = true
|
|
e.progress = 0
|
|
end
|
|
mv.remaining = mv.remaining - 1
|
|
end
|
|
end
|
|
-- march_in_place toggles: re-arm the in-place cycle each time it ends.
|
|
-- Not a scriptMove, so an ambient marcher never trips the input lockout.
|
|
for entity in pairs(self.marchers or {}) do
|
|
if not entity.moving then
|
|
entity.moving = true
|
|
entity.marching = true
|
|
entity.progress = 0
|
|
end
|
|
end
|
|
end
|
|
|
|
-- -------------------------------------------------------------------------
|
|
-- draw / save
|
|
-- -------------------------------------------------------------------------
|
|
|
|
function OverworldState:draw()
|
|
Game.renderer:beginWorldPass()
|
|
self:drawWorld()
|
|
Game.renderer:endWorldPass()
|
|
self:drawUI()
|
|
end
|
|
|
|
-- The emote sheet is OBJ art (engine/overworld/emotion_bubbles.asm builds the
|
|
-- bubble out of shadow OAM), so it renders through OBP0, and GBPalNormal
|
|
-- (home/palettes.asm:20-26 `ld a, %11010000 ; 3100 / ldh [rOBP0], a`) holds
|
|
-- OBP0 at "3100": OBJ color 1 shows as shade 0, color 2 as shade 1, color 3
|
|
-- as shade 3. Blitting the raw sheet skipped that lift and left the "!"
|
|
-- bubble's interior (color 1) at DMG shade 1 grey instead of white (#505).
|
|
-- Same CPU-remap bake as SpriteRenderer.getObpImage and PartyMenu's obpIcon,
|
|
-- and it resolves through Assets so a mod's emotes.png override still wins.
|
|
-- Color 0's alpha (a tRNS entry on the extracted png) is what keys the
|
|
-- bubble's corners out, so carry it through untouched.
|
|
local function obpEmoteImage(path)
|
|
if not (love.image and love.image.newImageData) then
|
|
return love.graphics.newImage(Assets.resolve(path)) -- headless stub
|
|
end
|
|
local id = Assets.imageData(path)
|
|
id:mapPixel(function(_, _, r, _, _, a)
|
|
local v = 0
|
|
if r > 0.5 then v = 1 -- OBJ colors 0 and 1 -> shade 0
|
|
elseif r > 0.17 then v = 170 / 255 -- OBJ color 2 -> shade 1
|
|
end -- OBJ color 3 -> shade 3
|
|
return v, v, v, a
|
|
end)
|
|
return love.graphics.newImage(id)
|
|
end
|
|
|
|
-- The SGB palette a tilt-mode billboard at flat foot (fx, fy) sits under.
|
|
-- World zones are rectangles in flat world-canvas space (the current map's
|
|
-- base fills the view; neighbour maps stack on top), so the last zone that
|
|
-- contains the foot wins -- the same later-zone-on-top priority the flat
|
|
-- blit's scissoring gives. nil when there are no zones (headless / stale
|
|
-- palettes), which leaves the billboard uncolorized.
|
|
local function zoneColorsAt(zones, fx, fy)
|
|
if not zones then return nil end
|
|
for i = #zones, 1, -1 do
|
|
local z = zones[i]
|
|
if fx >= z.x and fx < z.x + z.w and fy >= z.y and fy < z.y + z.h then
|
|
return z.colors
|
|
end
|
|
end
|
|
return zones[1] and zones[1].colors or nil
|
|
end
|
|
|
|
-- Draw a standing thing as an upright billboard (tilt mode only). ONLY the
|
|
-- ground tilts: a standing thing draws UPRIGHT and UNSCALED -- pixel-identical
|
|
-- to flat mode (same crisp nearest-neighbour art, nothing sheared, resized or
|
|
-- clipped). The single thing tilt changes about it is its on-screen anchor:
|
|
-- its foot (fx, fy -- the baseline centre of its cell, in world-canvas
|
|
-- pixels) moves to where that ground point projects, Tilt.groundPoint(fx,fy).
|
|
-- depthScale is deliberately ignored for sizing. `colors` is the SGB palette
|
|
-- of the map the foot stands on: the flat path colorizes the whole world
|
|
-- canvas at blit time, but the upright canvas composites with no zone pass,
|
|
-- so each billboard carries its own colorization here. `keyed` selects the
|
|
-- color-0-keyed palette variant (tall-grass feet overdraw, which must show the
|
|
-- sprite through the tile's white gaps) over the plain one (sprites, FX
|
|
-- overlays). drawFn issues the actual draws in flat world-canvas coordinates;
|
|
-- the transform just slides them from the flat foot onto the projected anchor.
|
|
function OverworldState:billboard(fx, fy, vw, vh, colors, keyed, drawFn)
|
|
local sx, sy = Tilt.groundPoint(fx, fy, vw, vh)
|
|
local shader = colors and (keyed and PaletteFX.keyedShader()
|
|
or PaletteFX.shader()) or nil
|
|
if shader then
|
|
PaletteFX.sendColors(shader, colors)
|
|
love.graphics.setShader(shader)
|
|
end
|
|
love.graphics.push()
|
|
love.graphics.translate(sx - fx, sy - fy)
|
|
drawFn()
|
|
love.graphics.pop()
|
|
if shader then love.graphics.setShader() end
|
|
end
|
|
|
|
function OverworldState:drawWorld()
|
|
-- Dark-map BG shade shift, armed for the whole frame before anything draws.
|
|
-- home/fade.asm's LoadGBPal writes ONE rBGP for the screen, so terrain, the
|
|
-- characters standing on it and any dialog over them darken together (#322);
|
|
-- Renderer:beginFrame cleared it, so a battle or a full-screen menu -- which
|
|
-- draws with no map beneath it -- stays lit exactly like
|
|
-- init_battle_variables.asm's `ld [wMapPalOffset], a` leaves the original.
|
|
PaletteFX.setShadeMap(self.dark and PaletteFX.DARK_BGP or nil)
|
|
-- advance the water/flower tile animation (runs under dialogs too).
|
|
-- TileRenderer.tick uses wall-clock 60Hz steps so display refresh rate
|
|
-- does not speed or slow the cycle (issue #4).
|
|
require("src.render.TileRenderer").tick()
|
|
-- let the renderer know whether a spinner puzzle is currently sliding
|
|
-- the player, so it can flicker the arrow tiles between the blur and
|
|
-- static graphic (engine/overworld/spinners.asm LoadSpinnerArrowTiles)
|
|
require("src.render.TileRenderer").setSpinning(self.player.spinning)
|
|
local cam = self.camera
|
|
-- ShakeElevator's oscillation (engine/overworld/elevator.asm) writes
|
|
-- hSCY, which scrolls the BG layer only -- tiles bounce while OAM
|
|
-- sprites stay put. ElevatorShake drives bgShakeY; zero elsewhere.
|
|
local bgY = cam.y + (self.bgShakeY or 0)
|
|
-- border block tiled behind everything the ring doesn't reach
|
|
local vw, vh = Game.renderer:worldViewSize()
|
|
-- Only things that actually stand (player, NPCs, ghosts, items and the FX
|
|
-- attached to them) leave the ground canvas to billboard upright in a
|
|
-- separate pass anchored to the projected ground (:billboard). Everything
|
|
-- else -- map tiles, which includes buildings/trees/fences/signs, since in
|
|
-- Gen 1 those are background tiles rather than sprites -- draws into the
|
|
-- one ground canvas exactly as in flat mode and tilts with it as a single
|
|
-- rigid plane (Renderer projects that whole canvas through the mesh when
|
|
-- tilt is active). So the ground draw calls below never change with tilt;
|
|
-- only the sprite/FX draw path below them branches. The sorts below only
|
|
-- reorder (no draws), so they run once for both paths.
|
|
-- A render pipeline (src/render/Pipelines.lua) replaces the ground draw
|
|
-- entirely with geometry of its own, so it is decided before tilt and
|
|
-- wins over it. It falls back to the tilt/flat path whenever it cannot
|
|
-- run this frame -- headless, a driver with no depth canvas, or a mod
|
|
-- that threw -- so no caller ever sees a blank frame.
|
|
local pipelineId = Pipelines.worldPipeline()
|
|
local tilt = (not pipelineId) and Tilt.active()
|
|
-- the pipeline's finished world image, once it has run; nil keeps every
|
|
-- path below on the vanilla flat/tilt draw
|
|
local override
|
|
if not pipelineId then
|
|
self.map.renderer:drawBorderFill(cam.x, bgY, vw, vh)
|
|
self.map.renderer:draw(cam.x, bgY, vw, vh)
|
|
for _, nb in ipairs(self.neighbors) do
|
|
nb.map.renderer:drawMapOnly(cam.x - nb.ox, bgY - nb.oy, vw, vh)
|
|
end
|
|
end
|
|
-- per-billboard SGB palette source; only needed (and only paid for) when
|
|
-- tilting. nil headless / on stale palettes -> billboards go uncolorized.
|
|
local zones = tilt and self.sgbWorldZones and self:sgbWorldZones() or nil
|
|
|
|
-- ghost NPCs on neighbor maps, y-sorted among themselves
|
|
table.sort(self.ghosts,
|
|
function(a, b) return a.npc.py + a.oy < b.npc.py + b.oy end)
|
|
table.sort(self.entities, function(a, b) return a.py < b.py end)
|
|
|
|
-- === shared FX draw bodies ==========================================
|
|
-- Each draws at flat world-canvas offsets; the tilt path wraps the
|
|
-- standing ones in an upright billboard, the flat path calls them inline
|
|
-- in their historical order. (Bodies are byte-identical to the pre-tilt
|
|
-- inline code, so the flat draw sequence is unchanged.)
|
|
|
|
-- the Pokémon Center heal machine (PokeCenterOAMData): the monitor
|
|
-- tile over the machine's screen and one ball per healed mon in two
|
|
-- mirrored columns, all blinking during the jingle flash. The GB
|
|
-- draws it at fixed screen coords with the player's cell BG-aligned
|
|
-- at (64,64); anchoring those coords to where the player stood keeps
|
|
-- the overlay on the machine at any zoom.
|
|
local function fxHeal()
|
|
if not self.healAnim then return end
|
|
local ha = self.healAnim
|
|
local fxDef = Game.data.field.overworldFx
|
|
if self.healMachineImg == nil and fxDef and fxDef.healMachine then
|
|
local ok, img = pcall(love.graphics.newImage, fxDef.healMachine.path)
|
|
self.healMachineImg = ok and img or false
|
|
end
|
|
local img = self.healMachineImg
|
|
if img then
|
|
if not self.healMachineQuads then
|
|
local w, h = img:getWidth(), img:getHeight()
|
|
self.healMachineQuads = {
|
|
love.graphics.newQuad(0, 0, 8, 8, w, h), -- monitor ($7c)
|
|
love.graphics.newQuad(0, 8, 8, 8, w, h), -- ball ($7d)
|
|
}
|
|
end
|
|
-- the jingle flash recolors the machine sprites in place
|
|
-- (FlashSprite8Times XORs rOBP1; the sprites never disappear):
|
|
-- ha.visible == false is the flashed half of each beat, drawn with
|
|
-- the light/dark shades swapped instead of skipped
|
|
local shader
|
|
if not ha.visible then
|
|
shader = PaletteFX.shader()
|
|
if shader then
|
|
PaletteFX.sendColors(shader,
|
|
PaletteFX.permute(PaletteFX.GRAYS, HEAL_FLASH_MAP))
|
|
love.graphics.setShader(shader)
|
|
end
|
|
end
|
|
-- TileRenderer windows with -floor(cam), so the overlay must use the
|
|
-- same snap or a fractional camera (odd fill/tilt view sizes) parks
|
|
-- the balls a pixel off the machine tiles
|
|
local ox = ha.px - 64 - math.floor(cam.x)
|
|
local oy = ha.py - 64 - math.floor(cam.y)
|
|
love.graphics.setColor(1, 1, 1, 1)
|
|
love.graphics.draw(img, self.healMachineQuads[1], ox + 44, oy + 20)
|
|
for i = 1, math.min(ha.lit, #HEAL_BALL_XY) do
|
|
local b = HEAL_BALL_XY[i]
|
|
if b[3] then -- right column: OAM_XFLIP
|
|
love.graphics.draw(img, self.healMachineQuads[2],
|
|
ox + b[1] + 8, oy + b[2], 0, -1, 1)
|
|
else
|
|
love.graphics.draw(img, self.healMachineQuads[2],
|
|
ox + b[1], oy + b[2])
|
|
end
|
|
end
|
|
if shader then love.graphics.setShader() end
|
|
end
|
|
end
|
|
|
|
-- the Cut/boulder dust puff: the smoke tile drawn 2x2 over the cell,
|
|
-- flickering (AnimateBoulderDust XORs the OBJ palette every step)
|
|
local function fxDust()
|
|
if not self.dustAnim then return end
|
|
local fxDef = Game.data.field.overworldFx
|
|
local smoke = fxDef and fxDef.smoke
|
|
if smoke then
|
|
if self.smokeImg == nil then
|
|
local ok, img = pcall(love.graphics.newImage, smoke.path)
|
|
self.smokeImg = ok and img or false
|
|
end
|
|
if self.smokeImg then
|
|
local da = self.dustAnim
|
|
local dx = da.x * 16 - cam.x
|
|
local dy = da.y * 16 - cam.y
|
|
local flicker = math.floor(da.frames / 4) % 2 == 0
|
|
love.graphics.setColor(1, 1, 1, flicker and 1 or 0.55)
|
|
for i = 0, 1 do
|
|
for j = 0, 1 do
|
|
love.graphics.draw(self.smokeImg, dx + i * 8, dy + j * 8)
|
|
end
|
|
end
|
|
love.graphics.setColor(1, 1, 1, 1)
|
|
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
|
|
local function fxEmote()
|
|
if not (self.emote and self.emote.npc) then return end
|
|
-- bubble = false is a silent hold (a Pikachu emotion that plays a
|
|
-- cry with no bubble still pauses the world for its beat)
|
|
if self.emote.bubble == false then return end
|
|
local npc = self.emote.npc
|
|
local ex = npc.px - cam.x + 4
|
|
local ey = npc.py - cam.y - 14
|
|
local bubble = Game.data.field.emotionBubbles
|
|
local drawn = false
|
|
if bubble and bubble.path then
|
|
local ok, img = pcall(function()
|
|
self.emoteImg = self.emoteImg or obpEmoteImage(bubble.path)
|
|
return self.emoteImg
|
|
end)
|
|
-- EXCLAMATION_BUBBLE is index 0 -> first crop; the emote command
|
|
-- picks question/happy crops instead
|
|
local bi = self.emote.bubble or 1
|
|
local rect = bubble.bubbles and bubble.bubbles[bi]
|
|
if ok and img and rect then
|
|
love.graphics.setColor(1, 1, 1, 1)
|
|
-- one Quad per bubble crop, cached: this draws every frame the "!"
|
|
-- (or the emote-command crops) is up, so a fresh Quad here churned
|
|
-- the GC. The bubble set is small and fixed, so the cache is bounded.
|
|
self.emoteQuads = self.emoteQuads or {}
|
|
local q = self.emoteQuads[bi]
|
|
if not q then
|
|
q = love.graphics.newQuad(rect.x, rect.y, rect.w, rect.h,
|
|
img:getDimensions())
|
|
self.emoteQuads[bi] = q
|
|
end
|
|
love.graphics.draw(img, q, ex, ey)
|
|
drawn = true
|
|
end
|
|
end
|
|
if not drawn then
|
|
local Font = require("src.render.Font")
|
|
love.graphics.setColor(1, 1, 1, 1)
|
|
love.graphics.rectangle("fill", ex, ey, 10, 12)
|
|
love.graphics.setColor(0, 0, 0, 1)
|
|
love.graphics.rectangle("line", ex + 0.5, ey + 0.5, 10, 12)
|
|
Font.draw("!", ex + 1, ey + 2)
|
|
love.graphics.setColor(1, 1, 1, 1)
|
|
end
|
|
end
|
|
|
|
-- the FLY bird sweeping off with the player
|
|
local function fxBird()
|
|
if not self.flyAnim then return end
|
|
local birdId = FieldDefaults.fieldValue(Game.data, "playerSprites", "fly")
|
|
if not self.birdSprite and birdId and Game.data.sprites[birdId] then
|
|
local SR = require("src.render.SpriteRenderer")
|
|
self.birdSprite = SR.new(Game.data.sprites[birdId])
|
|
end
|
|
if self.birdSprite then
|
|
local t = 48 - self.flyAnim.frames
|
|
local bx = self.player.px - t * 4
|
|
local by = self.player.py - math.floor(t * 1.5)
|
|
love.graphics.setColor(1, 1, 1, 1)
|
|
self.birdSprite:draw(bx, by, cam.x, cam.y, "left",
|
|
math.floor(t / 4) % 2, false)
|
|
end
|
|
end
|
|
|
|
-- fishing pose: the rod tile over the faced water (gfx/fishing.asm)
|
|
local function fxRod()
|
|
if not self.fishing then return end
|
|
local fx = Game.data.field.overworldFx
|
|
local rod = fx and fx.fishingRod
|
|
if rod then
|
|
if self.rodImg == nil then
|
|
local ok, img = pcall(love.graphics.newImage, rod.path)
|
|
self.rodImg = ok and img or false
|
|
end
|
|
if self.rodImg then
|
|
local p = self.player
|
|
local oam = ROD_OAM[self.fishing.facing] or ROD_OAM.down
|
|
if not self.rodQuads then
|
|
-- one quad per 8x8 tile of the stacked sheet (ROD_OAM.tile)
|
|
local iw, ih = self.rodImg:getDimensions()
|
|
self.rodQuads = {}
|
|
for i = 0, math.floor(ih / 8) - 1 do
|
|
self.rodQuads[i] = love.graphics.newQuad(0, i * 8, 8, 8, iw, ih)
|
|
end
|
|
end
|
|
local quad = self.rodQuads[oam.tile]
|
|
-- the sprite's top-left is 4px above its cell (SpriteRenderer:draw)
|
|
local rx = p.px - cam.x + oam.dx
|
|
local ry = p.py - cam.y - 4 + oam.dy
|
|
love.graphics.setColor(1, 1, 1, 1)
|
|
if quad and oam.flip then
|
|
love.graphics.draw(self.rodImg, quad, rx + 8, ry, 0, -1, 1)
|
|
elseif quad then
|
|
love.graphics.draw(self.rodImg, quad, rx, ry)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
if pipelineId then
|
|
-- === PIPELINE PATH: a mod owns the world pass. ======================
|
|
-- It renders terrain and characters however it likes and hands back one
|
|
-- window-resolution image; the field FX stay ordinary 2D draws
|
|
-- composited on top by ctx.drawFx, each anchored to where its ground
|
|
-- point projects under the pipeline's own camera. That is the direct
|
|
-- analogue of what :billboard does for tilt, and it keeps exactly one
|
|
-- copy of every effect: the closures above are the ones that run.
|
|
local pw, ph = love.graphics.getDimensions()
|
|
local pscale = Zoom.scale(Game.renderer:fitScale())
|
|
local ctx = {
|
|
state = self, cam = cam, vw = vw, vh = vh, bgY = bgY,
|
|
width = pw, height = ph, scale = pscale,
|
|
level = Pipelines.level(pipelineId),
|
|
-- the SGB world palette a map draws under; nil in the true-colour
|
|
-- modes, whose art is already baked (and must not be re-mapped)
|
|
paletteFor = function(map)
|
|
return PaletteFX.pal(Game.data, self:paletteNameFor(map or self.map))
|
|
end,
|
|
spriteColors = function(map)
|
|
if PaletteFX.usesGbcPack() then return nil end
|
|
return PaletteFX.pal(Game.data, self:paletteNameFor(map or self.map))
|
|
end,
|
|
fx = { heal = fxHeal, dust = fxDust, cutTree = fxCutTree,
|
|
emote = fxEmote, bird = fxBird, rod = fxRod },
|
|
}
|
|
-- Draw every active field FX into the finished scene. `project(wx, wy)`
|
|
-- maps a world point to canvas pixels (nil when it is behind the
|
|
-- camera) and `scale` is canvas pixels per world pixel; the pipeline
|
|
-- owns the camera, this owns where each effect belongs and how the
|
|
-- closures' flat coordinates are slid onto the projected anchor.
|
|
-- Deliberately unscaled by depth, like :billboard: an effect keeps its
|
|
-- crisp authored size and only its anchor moves.
|
|
ctx.drawFx = function(project, scale)
|
|
scale = scale or pscale
|
|
local colors = ctx.spriteColors()
|
|
local function at(drawFn, wx, wy)
|
|
if not drawFn then return end
|
|
local sx, sy = project(wx, wy)
|
|
if not sx then return end -- behind the camera
|
|
local shader = colors and PaletteFX.shader() or nil
|
|
if shader then
|
|
PaletteFX.sendColors(shader, colors)
|
|
love.graphics.setShader(shader)
|
|
end
|
|
-- the closures draw relative to the flat foot; slide that onto the
|
|
-- projected anchor, in world-pixel units inside the scaled transform
|
|
local fx, fy = wx - cam.x, wy - cam.y
|
|
love.graphics.push()
|
|
love.graphics.scale(scale, scale)
|
|
love.graphics.translate(sx / scale - fx, sy / scale - fy)
|
|
drawFn()
|
|
love.graphics.pop()
|
|
if shader then love.graphics.setShader() end
|
|
end
|
|
-- ground-hugging effects sit on the cell they belong to
|
|
if self.dustAnim then
|
|
at(fxDust, self.dustAnim.x * 16 + 8, self.dustAnim.y * 16 + 8)
|
|
end
|
|
if self.cutAnim then
|
|
at(fxCutTree, self.cutAnim.x * 16 + 8, self.cutAnim.y * 16 + 16)
|
|
end
|
|
if self.healAnim then
|
|
at(fxHeal, self.healAnim.px + 8, self.healAnim.py + 16)
|
|
end
|
|
-- standing effects anchor at the foot of whoever they belong to
|
|
if self.emote and self.emote.npc then
|
|
at(fxEmote, self.emote.npc.px + 8, self.emote.npc.py + 16)
|
|
end
|
|
if self.flyAnim then
|
|
at(fxBird, self.player.px + 8, self.player.py + 16)
|
|
end
|
|
if self.fishing then
|
|
at(fxRod, self.player.px + 8, self.player.py + 16)
|
|
end
|
|
end
|
|
override = Pipelines.drawWorld(pipelineId, ctx)
|
|
-- world post-processes (a miniature-diorama blur, a colour grade) fold
|
|
-- over the finished scene here, so they never touch the UI drawn on top
|
|
if override then
|
|
override = Pipelines.worldPresent(override, ctx)
|
|
end
|
|
Game.renderer:setWorldOverride(override)
|
|
if not override then
|
|
-- The pipeline declined this frame (nothing to draw, or it threw and
|
|
-- was retired). The ground pass was skipped on its behalf above, so
|
|
-- draw it now and fall through to the flat path below rather than
|
|
-- compositing an empty canvas.
|
|
self.map.renderer:drawBorderFill(cam.x, bgY, vw, vh)
|
|
self.map.renderer:draw(cam.x, bgY, vw, vh)
|
|
for _, nb in ipairs(self.neighbors) do
|
|
nb.map.renderer:drawMapOnly(cam.x - nb.ox, bgY - nb.oy, vw, vh)
|
|
end
|
|
end
|
|
end
|
|
|
|
if override then
|
|
-- the pipeline owns the whole frame; nothing else draws into the world
|
|
elseif not tilt then
|
|
-- === FLAT PATH: everything into the one world canvas, as before =====
|
|
-- OBP-baked sprites replay after the zone pass in OG RED mode, so their
|
|
-- grass feet-overdraw must replay over them too, colorized with the
|
|
-- current map's palette (see PaletteFX.markSpriteRedraw). SGB no longer
|
|
-- takes that path -- its characters are colorized by the zone just like
|
|
-- the ground under them (#301) -- so there the first overdraw is already
|
|
-- the final one.
|
|
local grassColors = PaletteFX.usesSpriteObp()
|
|
and PaletteFX.pal(Game.data, self:paletteNameFor(self.map)) or nil
|
|
for _, g in ipairs(self.ghosts) do
|
|
g.npc:draw(cam.x - g.ox, cam.y - g.oy)
|
|
end
|
|
for _, e in ipairs(self.entities) do
|
|
if not (self.flyAnim and e == self.player) then
|
|
e:draw(cam.x, cam.y)
|
|
-- tall grass overdraws the sprite's feet (GB sprite priority);
|
|
-- the overdraw is BG tiles, so it rides the shake offset too
|
|
love.graphics.setColor(1, 1, 1, 1)
|
|
if self.map:isGrassCell(e.cellX, e.cellY) then
|
|
self.map.renderer:drawCellBottom(e.cellX, e.cellY, cam.x, bgY)
|
|
if grassColors then
|
|
self.map.renderer:markCellBottomRedraw(e.cellX, e.cellY,
|
|
cam.x, bgY, grassColors)
|
|
end
|
|
end
|
|
if e.targetX and self.map:isGrassCell(e.targetX, e.targetY) then
|
|
self.map.renderer:drawCellBottom(e.targetX, e.targetY, cam.x, bgY)
|
|
if grassColors then
|
|
self.map.renderer:markCellBottomRedraw(e.targetX, e.targetY,
|
|
cam.x, bgY, grassColors)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
fxHeal()
|
|
fxDust()
|
|
fxCutTree()
|
|
fxEmote()
|
|
fxBird()
|
|
fxRod()
|
|
else
|
|
-- === TILT PATH: ground-hugging FX stay on the projected ground, all
|
|
-- standing things billboard upright over it in a separate pass. ======
|
|
-- Dust / cut / the Poké Center heal overlay hug the BG (the heal
|
|
-- machine is a tileset graphic; its OAM balls must ride that plane or
|
|
-- they float off the machine once the ground foreshortens). Flat mode
|
|
-- draws them last, over the sprites, in the same canvas; here the two
|
|
-- layers are separate and composited ground-under-upright, so drawing
|
|
-- them now into the still-active ground canvas is order-equivalent.
|
|
fxHeal()
|
|
fxDust()
|
|
fxCutTree()
|
|
|
|
Game.renderer:beginUprightPass()
|
|
|
|
-- One y-sorted list of ALL upright billboards -- sprites (player, NPCs,
|
|
-- ghosts) -- keyed on baseline world y (the foot / base row). Farther
|
|
-- rows project higher/smaller, so back-to-front is just ascending
|
|
-- baseline y.
|
|
local items = {}
|
|
for _, g in ipairs(self.ghosts) do
|
|
items[#items + 1] = { y = g.npc.py + g.oy + 16, kind = "ghost", g = g }
|
|
end
|
|
for _, e in ipairs(self.entities) do
|
|
if not (self.flyAnim and e == self.player) then
|
|
items[#items + 1] = { y = e.py + 16, kind = "entity", e = e }
|
|
end
|
|
end
|
|
table.sort(items, function(a, b) return a.y < b.y end)
|
|
|
|
for _, it in ipairs(items) do
|
|
if it.kind == "ghost" then
|
|
-- ghosts billboard just like real entities (foot offset folds in the
|
|
-- neighbour map's ox/oy that ghost draws already apply via the camera)
|
|
local g = it.g
|
|
local fx = g.npc.px - cam.x + g.ox + 8
|
|
local fy = g.npc.py - cam.y + g.oy + 16
|
|
self:billboard(fx, fy, vw, vh, zoneColorsAt(zones, fx, fy), false,
|
|
function() g.npc:draw(cam.x - g.ox, cam.y - g.oy) end)
|
|
else
|
|
local e = it.e
|
|
local fx = e.px - cam.x + 8
|
|
local fy = e.py - cam.y + 16
|
|
local colors = zoneColorsAt(zones, fx, fy)
|
|
self:billboard(fx, fy, vw, vh, colors, false,
|
|
function() e:draw(cam.x, cam.y) end)
|
|
-- tall-grass feet overdraw glued to the sprite: same anchor + depth
|
|
-- so it keeps hiding the feet, color-0-keyed palette so its white
|
|
-- gaps still show the sprite through (drawCellBottomRaw lets the
|
|
-- billboard own the shader; bgY keeps the elevator-shake offset).
|
|
if self.map:isGrassCell(e.cellX, e.cellY) then
|
|
self:billboard(fx, fy, vw, vh, colors, true, function()
|
|
love.graphics.setColor(1, 1, 1, 1)
|
|
self.map.renderer:drawCellBottomRaw(e.cellX, e.cellY, cam.x, bgY)
|
|
end)
|
|
end
|
|
if e.targetX and self.map:isGrassCell(e.targetX, e.targetY) then
|
|
self:billboard(fx, fy, vw, vh, colors, true, function()
|
|
love.graphics.setColor(1, 1, 1, 1)
|
|
self.map.renderer:drawCellBottomRaw(e.targetX, e.targetY, cam.x, bgY)
|
|
end)
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Standing world FX: each billboards at the ground foot of the
|
|
-- character it belongs to, so it stays upright over the tilted ground.
|
|
-- emote bubble -> the spotting NPC's foot (rides above its head)
|
|
-- fly bird, rod -> the player's foot
|
|
-- (heal machine is ground-hugging -- drawn above with dust/cut)
|
|
if self.emote and self.emote.npc then
|
|
local fx = self.emote.npc.px - cam.x + 8
|
|
local fy = self.emote.npc.py - cam.y + 16
|
|
self:billboard(fx, fy, vw, vh, zoneColorsAt(zones, fx, fy), false, fxEmote)
|
|
end
|
|
if self.flyAnim then
|
|
local fx = self.player.px - cam.x + 8
|
|
local fy = self.player.py - cam.y + 16
|
|
self:billboard(fx, fy, vw, vh, zoneColorsAt(zones, fx, fy), false, fxBird)
|
|
end
|
|
if self.fishing then
|
|
local fx = self.player.px - cam.x + 8
|
|
local fy = self.player.py - cam.y + 16
|
|
self:billboard(fx, fy, vw, vh, zoneColorsAt(zones, fx, fy), false, fxRod)
|
|
end
|
|
|
|
Game.renderer:endUprightPass()
|
|
end
|
|
|
|
end
|
|
|
|
-- screen-space overlays: drawn to the UI canvas at normal scale
|
|
function OverworldState:drawUI()
|
|
-- TalkToPikachu's picture box (engine/pikachu/pikachu_pic_animation.asm
|
|
-- PlacePikapicTextBoxBorder: TextBoxBorder at (6,5) with b,c = 5,5, so a
|
|
-- 7x7 box holding the 5x5 pic at (7,6) -- PikaAnimTilemap_1). The
|
|
-- script's base frame is ripped as pikachu/pikapic_N.png (#561) but the
|
|
-- pikaframe overlays on top of it are not, so PikachuFollower
|
|
-- .picLift lifts the base on the runs that draw the alternate pose, and the
|
|
-- script's own duration times the beat (#407, #424). Palette zone
|
|
-- PAL_PIKACHU_PORTRAIT covers (7,6)-(11,10) via sgbPalettes above.
|
|
if self.emote and self.emote.pikaPic then
|
|
require("src.render.Font").drawBox(6, 5, 7, 7)
|
|
-- one image per path, cached: this draws every frame of the hold, and
|
|
-- a mod skin can move the path between talks
|
|
if self.pikaPicPath ~= self.emote.pikaPic then
|
|
local ok, loaded = pcall(love.graphics.newImage, self.emote.pikaPic)
|
|
self.pikaPicImg = ok and loaded or nil
|
|
self.pikaPicPath = self.emote.pikaPic
|
|
end
|
|
local img = self.pikaPicImg
|
|
if img then
|
|
love.graphics.setColor(1, 1, 1, 1)
|
|
local w, h = img:getDimensions()
|
|
local lift = require("src.world.PikachuFollower").picLift(self.emote)
|
|
love.graphics.draw(img, math.floor(56 + (40 - w) / 2),
|
|
math.floor(48 + (40 - h) / 2) - lift)
|
|
end
|
|
end
|
|
|
|
-- poison step flicker (ChangeBGPalColor0_4Frames: dark for two
|
|
-- 4-frame pulses)
|
|
if self.poisonFlash and self.poisonFlash > 0 then
|
|
self.poisonFlash = self.poisonFlash - 1
|
|
local pulse = math.floor(self.poisonFlash / 4) % 2 == 1
|
|
if pulse then
|
|
love.graphics.setColor(0, 0, 0, 0.45)
|
|
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
|
love.graphics.setColor(1, 1, 1, 1)
|
|
end
|
|
end
|
|
end
|
|
|
|
function OverworldState:captureSave(save)
|
|
save.player.map = self.map.id
|
|
save.player.x = self.player.cellX
|
|
save.player.y = self.player.cellY
|
|
save.player.facing = self.player.facing
|
|
-- wWalkBikeSurfState (ram/wram.asm) sits inside the wMainDataStart..
|
|
-- wMainDataEnd range engine/menus/save.asm block-copies into sMainData,
|
|
-- so the original saves and restores the surf state; setMap's boot path
|
|
-- reads this back (#536).
|
|
save.player.surfing = self.player.surfing and true or false
|
|
end
|
|
|
|
return OverworldState
|