-- 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 Flags = require("src.script.Flags") local GameVersion = require("src.core.GameVersion") local GbcPalette = require("src.render.GbcPalette") 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 Renderer = require("src.render.Renderer") local Runtime = require("src.mods.Runtime") local Screens = require("src.ui.Screens") local ScriptRunner = require("src.script.ScriptRunner") local Theme = require("src.ui.Theme") 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 romText = require("src.core.RomText") local Strings = require("src.core.Strings") -- isOverworld marks the live world state for WorldAPI's stack scan local OverworldState = { isOpaque = true, isOverworld = true } -- The pipeline's own target, not the playfield: a pipeline with a render -- scale draws smaller and lets endFrame scale it up. local function overrideCanvasHeight() local bound = love.graphics.getCanvas() if bound and not bound.getHeight then bound = bound[1] end -- several bound if bound and not bound.getHeight then bound = bound.canvas end -- one face of one return bound and bound.getHeight and bound:getHeight() end -- Run a block of field FX the way the composite expects them. Where endFrame -- mirrors the override, these are the one part of that canvas the mirror would -- invert rather than right: the pipeline's pass went in pre-flipped, these are -- ordinary 2D. project() already answers in screen rows, so mirroring the -- block about the bound canvas is the whole correction, and project is left -- alone -- fixing it too would move each anchor twice. Exposed for tests. local function withOverrideMirror(draw) local height = Renderer.mirrorsWorldOverride() and overrideCanvasHeight() if not height then return draw() end love.graphics.push() love.graphics.translate(0, height) love.graphics.scale(1, -1) draw() love.graphics.pop() end OverworldState.withOverrideMirror = withOverrideMirror 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 } } -- pokered's wNumberOfNoRandomBattleStepsLeft: three completed steps -- after a wild battle before another random battle can start. local WILD_ENCOUNTER_GRACE_STEPS = 3 -- Fly animation coord paths (engine/overworld/player_animations.asm): -- y/x pairs in GB screen pixels, one pair every 3 frames (DoFlyAnimation's -- Delay3). The port anchors a path on the player's own position instead -- of the GB screen center: FLY_ANCHOR is the pair where the original has -- the player's sprite, so path1 starts exactly on the player. local FLY_ANCHOR = { 0x3C, 0x48 } -- StopMusic's fade-out control byte and the 7 volume steps it waits out -- engine/overworld/player_animations.asm:123, home/fade_audio.asm:36 local FLY_FADE_CONTROL = 4 local FLY_FADE_FRAMES = FLY_FADE_CONTROL * 7 local FLY_PATH1 = { -- FlyAnimationScreenCoords1: up and off to the right { 0x3C, 0x48 }, { 0x3C, 0x50 }, { 0x3B, 0x58 }, { 0x3A, 0x60 }, { 0x39, 0x68 }, { 0x37, 0x70 }, { 0x37, 0x78 }, { 0x33, 0x80 }, { 0x30, 0x88 }, { 0x2D, 0x90 }, { 0x2A, 0x98 }, { 0x27, 0xA0 }, } local FLY_PATH2 = { -- FlyAnimationScreenCoords2: out over the top-left; -- the 11th step reads the ($F0,$00) terminator, fully off screen { 0x1A, 0x90 }, { 0x19, 0x80 }, { 0x17, 0x70 }, { 0x15, 0x60 }, { 0x12, 0x50 }, { 0x0F, 0x40 }, { 0x0C, 0x30 }, { 0x09, 0x20 }, { 0x05, 0x10 }, { 0x00, 0x00 }, { -16, 0x00 }, } -- FlyAnimationEnterScreenCoords: in from off the top-right. Its own last -- pair is ($3C,$40), so the arrival anchors there and lands on the player. local FLY_ARRIVE_ANCHOR = { 0x3C, 0x40 } local FLY_PATH_IN = { { 0x05, 0x98 }, { 0x0F, 0x90 }, { 0x18, 0x88 }, { 0x20, 0x80 }, { 0x27, 0x78 }, { 0x2D, 0x70 }, { 0x32, 0x68 }, { 0x36, 0x60 }, { 0x39, 0x58 }, { 0x3B, 0x50 }, { 0x3C, 0x48 }, { 0x3C, 0x40 }, } -- engine/overworld/player_animations.asm:126 local TELEPORT_OUT_HOLDS = {} for d = 15, 0, -1 do TELEPORT_OUT_HOLDS[#TELEPORT_OUT_HOLDS + 1] = d end for i = 1, 6 do TELEPORT_OUT_HOLDS[#TELEPORT_OUT_HOLDS + 1] = i < 6 and 3 or 0 end local TELEPORT_OUT_INPLACE = 16 local TELEPORT_OUT_FRAMES = 135 local TELEPORT_OUT_SFX = { [4] = "Teleport_Exit2", [8] = "Teleport_Exit2", [12] = "Teleport_Exit2", [TELEPORT_OUT_INPLACE] = "Teleport_Exit1", } -- engine/overworld/player_animations.asm:104-118 local WARP_PAD_OUT_HOLDS = { 3, 3, 3, 3, 3, 0 } local WARP_PAD_OUT_FRAMES = 15 -- counting 0 up to 8 -- engine/overworld/player_animations.asm:20 local TELEPORT_IN_HOLDS = { 3, 3, 3, 3, 3, 0, 1, 2, 3, 4, 5, 6, 7, 0 } local TELEPORT_IN_FRAMES = 43 -- engine/overworld/player_animations.asm:22-25 local TELEPORT_IN_PAD_HOLDS = { 3, 3, 3, 3, 3, 3, 0 } local TELEPORT_IN_PAD_FRAMES = 18 local BOLT_WHITE_VEIL = { 1, 0.8 } local SPIN_DOWN_STEPS = 6 -- engine/overworld/player_animations.asm:41-45 local HOLE_IN_HOLDS = { 3, 3, 3, 3, 3, 0 } local HOLE_IN_FRAMES = 15 -- 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 } -- engine/overworld/healing_machine.asm:54 local HEAL_FLASH_MAP_GBC = { [0] = 0, [1] = 0, [2] = 1, [3] = 2 } -- engine/overworld/healing_machine.asm:13-14 local HEAL_OBP1_MAP = { [0] = 0, [1] = 0, [2] = 2, [3] = 3 } -- engine/overworld/healing_machine.asm:50 local HEAL_OBP1_FLASH_MAP = { [0] = 0, [1] = 2, [2] = 0, [3] = 3 } -- engine/overworld/healing_machine.asm:74 local function healMachineShader(visible) local base = PaletteFX.usesGbcPack() and PaletteFX.healMachineObp() or nil local ogColors = nil if not base and PaletteFX.usesSpriteObp() then ogColors = PaletteFX.ogObj() base = ogColors and PaletteFX.permute(ogColors, visible and HEAL_OBP1_MAP or HEAL_OBP1_FLASH_MAP) end if not base and visible then return nil end local shader = PaletteFX.shader() if not shader then return nil end local colors = base or PaletteFX.GRAYS if not visible and not ogColors then colors = PaletteFX.permute(colors, base and HEAL_FLASH_MAP_GBC or HEAL_FLASH_MAP) end PaletteFX.sendColors(shader, colors) love.graphics.setShader(shader) return shader, ogColors and colors or nil end -- scripts/VermilionDock.asm:39 VermilionDockSSAnneLeavesScript: her hull is -- the four blocks (5..8, 1..2) of VermilionDock.blk local SS_ANNE_BLOCK = { x = 5, y = 1, w = 4, h = 2 } -- scripts/VermilionDock.asm:164 VermilionDock_SyncScrollWithLY splits rSCX at -- LY $50, so her top 16px -- the player's own cell row -- never scrolls local SS_ANNE_KEEP_PX = 16 -- scripts/VermilionDock.asm:79 `ld e, $8`: eight 16px columns, and each -- .delay_between_drifts pass is eight frames per pixel local SS_ANNE_SAIL_PX, SS_ANNE_PX_FRAMES = 128, 8 -- scripts/VermilionDock.asm:182 VermilionDock_EraseSSAnne: block 1 is the -- shoreline row she sat in, block 13 the open water below it local SS_ANNE_WATER = { 1, 13 } -- scripts/VermilionDock.asm:142 VermilionDock_EmitSmokePuff: a puff per -- column off the front smokestack, drifting east 2px per drift step local SS_ANNE_SMOKE = { dx = 64, dy = 20, drift = 2, every = 16, count = 5 } -- scripts/VermilionDock.asm:65 `ldh [rOBP1], a` with a = 0: the puff is white local SS_ANNE_SMOKE_MAP = { [0] = 0, [1] = 0, [2] = 0, [3] = 0 } -- 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 the vanilla -- SpriteRenderer:draw puts at (px, py - 4); custom frame anchors move that -- origin while keeping these offsets frame-relative. `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 ("_obj_"). 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 local function nearestWalkableCell(map, x, y) for r = 1, 8 do for dy = -r, r do for dx = -r, r do if math.abs(dx) == r or math.abs(dy) == r then local nx, ny = x + dx, y + dy if map:inBounds(nx, ny) and map:isWalkableCell(nx, ny) then return nx, ny end end end end end end -- 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:exit() self.map = nil self.neighbors = nil end function OverworldState:enter(mapId, x, y, facing, opts) 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 self.fishing = nil self.cancelledTrainerSight = nil -- volatile WRAM state in pokered; never serialize across save/load self.wildEncounterGraceSteps = 0 -- 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, opts or { 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 -- ...and floors the running version has no callback for: Yellow's B4F -- lift gate stands open from the first visit, since Jessie & James take -- the two guard slots there and set neither guard flag (#650) local skipMaps = FieldDefaults.fieldValue(Game.data, "cardKeyDoors", "skipMaps") local skipped = skipMaps and skipMaps[GameVersion.get()] if skipped and skipped[self.map.id] 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 and not (opts and opts.checkpoint) 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 = {} self.shipAnim = nil self.spinnerSliding = nil 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) -- EnterMap -> ClearVariablesOnEnterMap zeroes wStepCounter -- (engine/overworld/clear_variables.asm:7), but a connection crossing -- never reaches EnterMap (home/overworld.asm:675 .loadNewMap) if not (opts and opts.seamless) then Game.save.poisonSteps = 0 end -- 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 -- MarkTownVisitedAndLoadToggleableObjects marks towns only (cp -- FIRST_ROUTE_MAP): the fly-warp table also carries the ROUTE_4/ROUTE_10 -- Pokemon Centers and the dungeon escape spots, and entering those never -- sets a wTownVisitedFlag bit, so it must not set save.visited either (#788) local mapDef = Game.data.maps[mapId] if Game.data.field.flyWarps[mapId] and mapDef and Map.isFlyTown(mapDef) 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 a map the player is not entering keeps its ghosts alive -- in view of the survey zoom. The map being entered does not -- -- crossing a seam runs .loadNewMap -> LoadMapHeader, whose -- .loadSpriteData zeroes the sprite state data and re-seeds every -- SPRITESTATEDATA2_MAPY/MAPX from the map header's object data -- (home/overworld.asm), so an NPC who walked up to the player stands -- on her spawn cell again the next time that map loads (#1028). Only -- the save-side spawn flags survive, and those live in Game.save, not -- here. Warps rebuild the whole pool from scratch; a seam crossing arms -- the re-seed and applyPendingSpawnResets lands it off camera (#1755). if not (opts and opts.seamless and self.npcPool) then self.npcPool = {} elseif fromMapId ~= mapId then for _, obj in ipairs(self.map.def.objects or {}) do local npc = self.npcPool[mapId .. "_obj_" .. obj.index] if npc then npc.pendingSpawnReset = true end end 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 opts and opts.via == "boot" and self.map:inBounds(x, y) and not self.map:isWalkableCell(x, y) and not self.map:isWaterCell(x, y) then local rx, ry = nearestWalkableCell(self.map, x, y) if rx then Logger.warn("saved position %s (%d,%d) is not walkable; moved to (%d,%d)", mapId, x, y, rx, ry) x, y = rx, ry 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 self.player.slopeMap = self:onSlopeMap(mapId) -- 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 local dungeonWarp = opts and opts.dungeonWarp if dungeonWarp then -- home/overworld.asm:788 self.player.surfing = false Game.save.onBike = false Game.save.forcedBike = nil self:syncSurfingPikachu() end self.pendingEnterMapTail = dungeonWarp or nil -- crossConnection re-arms this after setMap; clear so a warp/reload -- cannot leave a stale deferred PlayMapMusic pending self.pendingSeamMusic = nil self.seamPalette = 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). -- true = fresh map entry: the follower spawns under the player and -- walks out of the warp, not beside him (#863) require("src.world.PikachuFollower").onMapEntered(Game, self, opts, true) -- opts.keepMusic preserves the Oak-escort song across the lab warp, -- matching BIT_NO_MAP_MUSIC: MUSIC_MUSEUM_GUY in Yellow and -- MUSIC_MEET_PROF_OAK in Red/Blue. keepMusicOnce is the equivalent -- one-shot set by play_music opts.keep. local keepMusic = (opts and opts.keepMusic) or self.keepMusicOnce self.keepMusicOnce = nil -- home/overworld.asm ln 2340, player_animations.asm ln 64 if opts and opts.via == "fly" then keepMusic = true end -- home/overworld.asm:2341 if dungeonWarp then keepMusic = true end if not keepMusic then -- ..(home/overworld.asm ln 2346) local Music = require("src.core.Music") -- opts.freshBoot: switch instantly instead of cross-fading, like every -- other map's PlayDefaultMusic on real hardware -- set only by -- Game.lua's hard state teleports (onContinue, New Game, F2, -- restoreCheckpointSave). Deliberately separate from opts.via == -- "boot" itself: dev tooling (src/dev/Console.lua's warp verb, -- src/dev/HotReload.lua's reloadMap) reuses that same default for the -- surf-restore/fresh-npc-pool branches above and must keep the -- ordinary crossfade. local fade = Music.MAP_FADE if opts and opts.freshBoot then fade = nil end Music.playMap(Game.data, mapId, Game.save.onBike, self.player.surfing, fade) 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 if not (opts and (opts.checkpoint or opts.dungeonWarp)) then self:checkForcedMovement() end -- 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. if not (opts and (opts.checkpoint or opts.dungeonWarp)) then self:checkSeafoamCurrent() end -- 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 if not (opts and opts.checkpoint) then 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"), }) end -- 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. self.prizeWindow = nil if not (opts and opts.checkpoint) then -- home/overworld.asm:33 self.noNpcFacePlayer = nil local hooks = mapScripts.get(mapId) if hooks and hooks.onEnter then hooks.onEnter(Game, self, fromMapId) end 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 if not (opts and opts.checkpoint) then self:syncLastMapRewrite() end -- home/overworld.asm:1821 (JoypadOverworld runs RunMapScript every frame); -- scripts/Route16Gate1F.asm:16, Route5Gate.asm:19, Route22Gate.asm:21 if opts and opts.freshBoot and not opts.checkpoint then local standing = mapScripts and mapScripts.get(mapId) if not (standing and standing.onStep and standing.onStep(Game, self, self.player.cellX, self.player.cellY)) then self:checkBadgeGate() end end 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) self.preparingLiveMaps = true Runtime.emit("world.live_maps_preparing", { mapId = mapId, maps = self:liveMaps() }) self.preparingLiveMaps = nil self:rebuildGhosts() Runtime.emit("world.live_maps_updated", { mapId = mapId, maps = self:liveMaps() }) end function OverworldState:liveMaps() local out = {} if not self.map then return out end out[1] = { mapId = self.map.id, ox = 0, oy = 0, active = true } for _, nb in ipairs(self.neighbors or {}) do out[#out + 1] = { mapId = nb.map.id, ox = nb.ox, oy = nb.oy, active = false } end return out end function OverworldState:isNeighborMap(mapId) for _, nb in ipairs(self.neighbors or {}) do if nb.map.id == mapId then return nb end end return nil end function OverworldState:rebuildGhosts() -- 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 -- the deferred half of the seam re-seed armed in setMap (#1755): the cart's -- connected map has no sprites to be seen snapping -- home/overworld.asm:2133 function OverworldState:applyPendingSpawnResets() local pool = self.npcPool if not (pool and self.camera) then return end local due for _, npc in pairs(pool) do if npc.pendingSpawnReset then due = due or {} due[npc] = true end end if not due then return end local cam = self.camera local vw, vh = Game.renderer:worldViewSize() local function onCamera(px, py) return px + 16 > cam.x - 16 and px < cam.x + vw + 16 and py + 16 > cam.y - 16 and py < cam.y + vh + 16 end for _, mv in ipairs(self.scriptMoves or {}) do due[mv.entity] = nil end for entity in pairs(self.marchers or {}) do due[entity] = nil end for _, npc in ipairs(self.npcs or {}) do if due[npc] and onCamera(npc.px, npc.py) then due[npc] = nil end end for _, g in ipairs(self.ghosts or {}) do if due[g.npc] and onCamera(g.npc.px + g.ox, g.npc.py + g.oy) then due[g.npc] = nil end end for npc in pairs(due) do npc.pendingSpawnReset = nil npc:resetToSpawn() 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 -- home/overworld.asm:675 function OverworldState:screenPaletteName() if self.seamPalette and not OverworldState.perMapWorldPalettes() then return self.seamPalette end return self:paletteNameFor(self.map) 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:screenPaletteName() 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 -- `ld de, BlkPacket_WholeScreen` (engine/gfx/palettes.asm:178) -- -- 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. -- engine/gfx/palettes.asm:178 function OverworldState.perMapWorldPalettes() return (Zoom.offset or 0) < 0 end 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:screenPaletteName()) 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 } } if not OverworldState.perMapWorldPalettes() then return zones end 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 -- Field-item entry points keep presentation and state transitions in the -- owning world instead of asking a supported facade to reproduce either one. function OverworldState:useBicycle() local name = Game.save.player.name if Game.save.onBike then if Game.save.forcedBike then return false end Game.save.onBike = false require("src.core.Music").playMap(Game.data, self.map.id, false) Game.stack:push(TextBox.new(Game, Strings("%s got off\nthe BICYCLE.", name))) elseif self:bikeAllowed(self.map.id) and not self.player.surfing then Game.save.onBike = true require("src.core.Music").playMap(Game.data, self.map.id, true) Game.stack:push(TextBox.new(Game, Strings("%s got on\nthe BICYCLE!", name))) else return false end return true end -- Field-move entry points keep presentation and state transitions in the -- overworld. The party menu and supported mod facade both call these, so a -- shortcut cannot drift from the game's own move flow. function OverworldState:useFlashFieldMove(onClose) -- A FLASH used in daylight must not carry into the next dark map. Lighting -- also happens before the blink: setDark may rebake an ADVANCED atlas, and -- putting that work in WhiteFlash:onDone caused the frozen white frame in -- #610. local wasDark = self.dark if wasDark then Game.save.flashLit = true end Game.stack:push(TextBox.new(Game, Game.data.text._FlashLightsAreaText or Strings("A blinding FLASH\nlights the area!"), function() if onClose then onClose() end if wasDark then self:setDark(false) end Game.stack:push(Transition.whiteFlash(Game)) end)) return true end function OverworldState:useStrengthFieldMove(mon, onClose) mon = mon or self:partyKnows("STRENGTH") if not mon then return false end local def = Game.data.pokemon[mon.species] local name = mon.nickname or def.name self.strengthActive = true local first = (Game.data.text._UsedStrengthText or Strings("{RAM:wNameBuffer} used\nSTRENGTH.")) :gsub("{RAM:wNameBuffer}", name) local second = (Game.data.text._CanMoveBouldersText or Strings("{RAM:wNameBuffer} can\nmove boulders.")) :gsub("{RAM:wNameBuffer}", name) Game.stack:push(TextBox.new(Game, first, function() Game.stack:push(TextBox.new(Game, second, function() if onClose then onClose() end Game.stack:push(Transition.whiteFlash(Game)) end)) end, { auto = { sound = function() return require("src.core.Sound").playCry(Game.data, mon.species) end } })) return true end function OverworldState:useSoftboiledFieldMove(user, target) local heal = user and user.stats and math.floor(user.stats.hp / 5) or 0 if not user or not user.stats or not target or not target.stats or target == user or target.hp <= 0 or target.hp >= target.stats.hp or user.hp <= heal then Game.stack:push(TextBox.new(Game, romText(Game.data, "_ItemUseNoEffectText", "It won't have\nany effect."))) return false end local before = target.hp user.hp = user.hp - heal target.hp = math.min(target.stats.hp, target.hp + heal) require("src.core.Sound").play(Game.data, "Heal_HP") local def = Game.data.pokemon[target.species] -- _PotionText's second slot is the recovered amount, same as -- ItemEffects.lua's potion message -- the engine fallback never shows it Game.stack:push(TextBox.new(Game, romText(Game.data, "_PotionText", "%s's HP\nwas restored!", target.nickname or def.name, target.hp - before))) return true 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, trainerNpc) 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.playBattleTheme then battle:playBattleTheme() 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. -- engine/battle/battle_transitions.asm:28 self.battleOamKeep = trainerNpc or false self.wipeSpritesFn = function() self:drawWipeSprites() end Game.stack:push(BattleTransition.new(Game, function() self.battleOamKeep = nil self.wipeSpritesFn = nil Game.stack:push(battle) end, { trainer = battle.kind == "trainer", stronger = lead ~= nil and enemyLevel >= lead.level + 3, dungeon = self:isDungeonTransitionMap(), })) end -- engine/battle/battle_transitions.asm:28 function OverworldState:oamCulled(e) if self.battleOamKeep == nil then return false end return e ~= self.player and e ~= self.battleOamKeep 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) self.battleOamKeep = nil self.wipeSpritesFn = nil -- 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 self.player.slopeMap = self:onSlopeMap() -- 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.step then local k = math.min(8, math.floor((da.total - da.frames) / 3) + 1) da.ox, da.oy, da.faded = da.step[1] * k, da.step[2] * k, k % 2 == 1 end if da.frames <= 0 then self.dustAnim = nil if da.onDone then da.onDone() end end end self:tickPoisonFlash() -- scripts/VermilionDock.asm:80 .shift_columns_up if self.shipAnim and not self.shipAnim.gone then local sa = self.shipAnim sa.frames = sa.frames + 1 if sa.frames >= SS_ANNE_PX_FRAMES then sa.frames = 0 sa.off = sa.off + 1 if sa.off % SS_ANNE_SMOKE.every == 1 and #sa.puffs < SS_ANNE_SMOKE.count then sa.puffs[#sa.puffs + 1] = { x = sa.px - sa.off + SS_ANNE_SMOKE.dx, y = sa.py + SS_ANNE_SMOKE.dy } end for _, p in ipairs(sa.puffs) do p.x = p.x + SS_ANNE_SMOKE.drift end if sa.off >= SS_ANNE_SAIL_PX then sa.gone, sa.puffs = true, {} local done = sa.onDone sa.onDone = nil if done then done() end end end end if self.cutAnim then local ca = self.cutAnim ca.frames = ca.frames - 1 if ca.frames <= 0 then self.cutAnim = nil if ca.onDone then ca.onDone() end end end -- 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 -- StopMusic busy-waits on the fade before LoadBirdSpriteGraphics, so the -- world holds and the bird is not on screen yet -- home/overworld.asm:772 if self.flyFade then self.flyFade = self.flyFade - 1 if self.flyFade <= 0 then self.flyFade = nil self.flyAnim = { phase = "flap", t = 0 } end self.player:update() return end if self.flyAnim then -- DoFlyAnimation runs one coord pair every Delay3 (3 frames); the -- in-place flap is 8 pairs, then the two paths with a 40-frame beat -- while the bird is parked off screen between them local anim = self.flyAnim anim.t = anim.t + 1 if anim.phase == "flap" and anim.t >= 8 * 3 then anim.phase, anim.t = "path1", 0 require("src.core.Sound").play(Game.data, "Fly") elseif anim.phase == "path1" and anim.t >= #FLY_PATH1 * 3 then anim.phase, anim.t = "hold", 0 elseif anim.phase == "hold" and anim.t >= 40 then anim.phase, anim.t = "path2", 0 elseif anim.phase == "path2" and anim.t >= #FLY_PATH2 * 3 then self.flyAnim = nil 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" -- keep the sprite hidden through the warp fade-out (#916): flyAnim -- just went nil but flyArrive is not armed until startWarpTo's -- midpoint, and the overworld keeps drawing beneath the veil, so -- without this the trainer pops back in at the old cell for 32 frames self.playerHidden = true self:startWarpTo(d.map, d.x, d.y, "down", nil, { via = "fly" }) else self.playerHidden = false self.player.inputLocked = false end return end end if self.flyArrive then -- EnterMapAnim .flyAnimation: one swoop in from the top-right, then -- LoadPlayerSpriteGraphics -- the player reappears where it lands self.flyArrive.t = self.flyArrive.t + 1 if self.flyArrive.t >= #FLY_PATH_IN * 3 then self.flyArrive = nil self.player.inputLocked = false -- engine/overworld/player_animations.asm ln 64 require("src.core.Music").playMap( Game.data, self.map.id, Game.save.onBike, self.player.surfing, nil) end end -- EnterMapAnim's .done tail re-enables the companion once the swoop or the -- spin-down has landed (player_animations.asm:40) if self.pikachuWarpHidden and not (self.flyAnim or self.flyArrive or self.teleportOut or self.transitioning or self.player.spinning or self.holeFall or self.holeArrive) then self:showPikachuAfterWarp() 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 -- engine/overworld/player_animations.asm:136 local step = self.player.spinStep or 0 if step ~= self.teleportOut.step then self.teleportOut.step = step local key = (self.teleportOut.sfx or TELEPORT_OUT_SFX)[step] if key then require("src.core.Sound").play(Game.data, key) end end self.teleportOut.frames = self.teleportOut.frames - 1 if self.teleportOut.frames <= 0 then local onDone = self.teleportOut.onDone local go = self.teleportOut.go self.teleportOut = nil self.player.spinning = false self.player.spinFrames = nil self.player.spinRise = nil self.player.spinHolds = nil self.player.spinStep = nil self.player.spinRiseFrom = nil self.player.inputLocked = false -- keep the sprite hidden through the warp fade-out (#916): the spin is -- over but the arrival spin-drop is not armed until startWarpTo's -- midpoint, so without this the standing trainer shows under the veil self.playerHidden = true if go then go() else self:warpToHealPoint(onDone, { arrive = "teleport" }) end return end end -- fades to white -- engine/overworld/player_animations.asm:204-228 if self.holeFall then self.holeFall.frames = self.holeFall.frames - 1 if self.holeFall.frames <= 0 then local f = self.holeFall self.holeFall = nil self.player.holeSink = nil self.playerHidden = true self.arriveWarp = "hole" self:startWarpTo(f.map, f.x, f.y, f.facing, nil, { dungeonWarp = true }) return end end -- offscreen, then drops him down -- player_animations.asm:41-45 if self.holeArrive then self.holeArrive.frames = self.holeArrive.frames - 1 if self.holeArrive.frames <= 0 then self.holeArrive = nil self.playerHidden = false self.player.spinning = true -- engine/overworld/player_animations.asm:90 self.player.spinImageIndex = 3 self.player.spinTimer = 0 self.player.spinFrames = HOLE_IN_FRAMES self.player.spinTotal = HOLE_IN_FRAMES self.player.spinDrop = true self.player.spinHolds = HOLE_IN_HOLDS self.player.spinStep = 0 self.player.spinHold = HOLE_IN_HOLDS[1] self.player.spinDropSteps = SPIN_DOWN_STEPS self.spinArrive = true 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 if self.emote.boltAt then require("src.world.PikachuFollower").tickBolt(Game, self, self.emote) end 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.hopLand or 0) > 0 or self.engaging or self.emote or self.teleportOut or self.flyAnim or self.flyArrive or self.spinArrive or self.holeFall or self.holeArrive or self.holeFall or self.holeArrive or (self.dustAnim and self.dustAnim.boulder) 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.hopLand or 0) > 0 or self.engaging or self.emote or self.teleportOut or self.flyAnim or self.flyArrive or self.spinArrive or self.holeFall or self.holeArrive or (self.dustAnim and self.dustAnim.boulder) end -- a scriptMove's onDone can push a text box on the frame it retires, and -- DisplayTextID owns the loop from there (home/text_script.asm:3) if not scripted and not self.transitioning and Game.stack:top() == self then self:handleInput() end if (self.hopLand or 0) > 0 then self.hopLand = self.hopLand - 1 end local stepped = self.player:update() -- engine/overworld/player_animations.asm:329 if self.spinArrive and not self.player.spinFrames then self.spinArrive = nil self.player.inputLocked = false if self.pendingEnterMapTail then self.pendingEnterMapTail = nil -- home/overworld.asm:31 self:checkForcedMovement() self:checkSeafoamCurrent() end end -- 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 -- home/overworld.asm:679 if stepped then self.seamPalette = 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 -- ..(home/overworld.asm ln 677) local Music = require("src.core.Music") Music.playMap(Game.data, mapId, Game.save.onBike, self.player.surfing, Music.MAP_FADE) end end if stepped and not scripted then self:onStepComplete() end self.camera:follow(self.player.px, self.player.py, Game.renderer:worldViewSize()) self:applyPendingSpawnResets() -- 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 = self.player:tryMove(dir, self.map, self.entities) if result == "moved" then self:stopSurfingOntoLand() end -- 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 -- CollisionCheckOnLand (home/overworld.asm): a sprite takes the same -- .collision branch as an impassable tile (#960) if result == "blocked" 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 braking = input:isDown("a") or input:isDown("b") if Game.save.onBike and not braking and not self.player.moving and self:onSlopeMap() then -- does -- home/overworld.asm:1817 self.player.facing = "down" if self:checkEdgeExit("down") then return end if self:checkLedgeHop("down") then return end if self:checkBoulderPush("down") then return end local result = self.player:tryMove("down", self.map, self.entities) if result == "blocked" and self:canCollisionWarp() then local w = Warp.onCollision(self.map, Game.data.field.warpCarpets, self.player.cellX, self.player.cellY, "down") if w then self:takeWarp(w.def) end end return end end -- JoypadOverworld both make -- home/overworld.asm:382 function OverworldState:onSlopeMap(mapId) local fm = Game.data.field.forcedMovement mapId = mapId or (self.map and self.map.id) for _, m in ipairs((fm and fm.slopeMaps) or {}) do if m == mapId then return true end end return false 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) -- IsSpriteInFrontOfPlayer (home/overworld.asm) hands TryPushingBoulder -- the LOWEST sprite index standing on the faced cell, so in the original a -- second sprite parked on the boulder's cell hides the boulder from the -- push path for the rest of the map visit. Pick the pushable sprite out of -- the cell instead: a scripted walk-up that lands a trainer on the boulder -- must not brick it permanently (#809). local npc = self:pushableAtCell(fx, fy) if not npc 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(bx, by, function() require("src.core.Sound").play(Game.data, "Cut") end, dir) 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 -- engine/overworld/dust_smoke.asm:1 function OverworldState:startDustAnim(cx, cy, onDone, dir) -- engine/overworld/dust_smoke.asm:59 local step = ({ down = { 0, -1 }, up = { 0, 1 }, left = { 1, 0 }, right = { -1, 0 } })[dir or ""] if step then self.dustAnim = { x = cx, y = cy, frames = 24, total = 24, step = step, boulder = true, ox = step[1], oy = step[2], faded = true, onDone = onDone } else self.dustAnim = { x = cx, y = cy, frames = 32, onDone = onDone } end end -- scripts/VermilionDock.asm:39 VermilionDockSSAnneLeavesScript: snapshot her -- hull tiles, flood her box with water, and slide the snapshot west from there function OverworldState:startSsAnneDeparture(onDone) local map = self.map local tx0, ty0 = SS_ANNE_BLOCK.x * 4, SS_ANNE_BLOCK.y * 4 local tiles = {} for row = 1, SS_ANNE_BLOCK.h * 4 do local r = {} for col = 1, SS_ANNE_BLOCK.w * 4 do r[col] = map:tileAt(tx0 + col - 1, ty0 + row - 1) end tiles[row] = r end for bx = SS_ANNE_BLOCK.x, SS_ANNE_BLOCK.x + SS_ANNE_BLOCK.w - 1 do for by = SS_ANNE_BLOCK.y, SS_ANNE_BLOCK.y + SS_ANNE_BLOCK.h - 1 do map:setBlock(bx, by, SS_ANNE_WATER[by - SS_ANNE_BLOCK.y + 1] or 13) end end map.renderer:rebuild() self.shipAnim = { px = tx0 * 8, py = ty0 * 8, tiles = tiles, off = 0, frames = 0, puffs = {}, onDone = onDone } end -- scripts/VermilionDock.asm:164: the rSCX split keeps her top 16px (the -- shoreline row and the gangway under the player) put while the rest sails function OverworldState:drawShipAnim(camX, camY) local sa = self.shipAnim if not sa then return end local renderer = self.map.renderer local img, quads = renderer.image, renderer.quads local ox, oy = -math.floor(camX), -math.floor(camY) local keep = SS_ANNE_KEEP_PX / 8 love.graphics.setColor(1, 1, 1, 1) for row = 1, #sa.tiles do if row <= keep or not sa.gone then local slide = row > keep and sa.off or 0 local wy = sa.py + (row - 1) * 8 + oy for col = 1, #sa.tiles[row] do local quad = quads[sa.tiles[row][col]] if quad then love.graphics.draw(img, quad, sa.px + (col - 1) * 8 - slide + ox, wy) end end end end if #sa.puffs == 0 then return end local fxDef = Game.data.field.overworldFx local smoke = fxDef and fxDef.smoke if not smoke then return end if self.smokeImg == nil then local ok, image = pcall(love.graphics.newImage, smoke.path) self.smokeImg = ok and image or false end if not self.smokeImg then return end local shader = PaletteFX.shader() if shader then PaletteFX.sendColors(shader, PaletteFX.permute(PaletteFX.GRAYS, SS_ANNE_SMOKE_MAP)) love.graphics.setShader(shader) end for _, p in ipairs(sa.puffs) do for i = 0, 1 do for j = 0, 1 do love.graphics.draw(self.smokeImg, p.x + i * 8 + ox, p.y + j * 8 + oy) end end end if shader then love.graphics.setShader() end 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.ledgeHop = true -- BIT_LEDGE_OR_FISHING, no bike speedup mid-hop local hop = p:stepLength() * 2 p.hopFrames, p.hopTotal = hop, hop -- jump arc (cosmetic) self:scriptMove(p, dir, 1, function() self:checkEdgeExit(dir) self:finishLedgeHop() 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.ledgeHop = true -- BIT_LEDGE_OR_FISHING, no bike speedup mid-hop local hop = p:stepLength() * 2 p.hopFrames, p.hopTotal = hop, hop -- jump arc (cosmetic) self:scriptMove(p, dir, 2, function() self:finishLedgeHop() end) return true end end end return false end -- _HandleMidJump .finishedJump lands with UpdateSprites + Delay3 before it -- clears the joypad bytes -- engine/overworld/player_animations.asm:509 function OverworldState:finishLedgeHop() self.player.ledgeHop = nil self.hopLand = 3 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 local seamPal = self:paletteNameFor(self.map) self:setMap(conn.map, x, y, p.facing, { seamless = true, keepMusic = true, keepPikachu = pika }) self.pendingSeamMusic = conn.map self.seamPalette = seamPal -- 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 = p:stepLength(dir) self:stopSurfingOntoLand() 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 -- _LeaveMapAnim drops the companion's sprite before the animation starts and -- EnterMapAnim only puts it back once landed -- home/pikachu.asm:1, :10 function OverworldState:hidePikachuForWarp() self.pikachuWarpHidden = true require("src.world.PikachuFollower").setVisible(self, false) end function OverworldState:showPikachuAfterWarp() self.pikachuWarpHidden = nil self.pikachuTrail = { x = self.player.cellX, y = self.player.cellY } local Follower = require("src.world.PikachuFollower") local state = self.pikachuShowState self.pikachuShowState = nil local npc = Follower.current(self) if npc and not state then npc.cellX, npc.cellY = self.player.cellX, self.player.cellY npc.px, npc.py = npc.cellX * 16, npc.cellY * 16 npc.targetX, npc.targetY = nil, nil npc.goalX, npc.goalY = nil, nil npc.moving = false end Follower.setVisible(self, true) if state then Follower.placeAtSpawnState(self, state) end 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 -- engine/items/item_effects.asm:1893, engine/overworld/player_animations.asm:378 local FISH_ANIM = { used = 80, cast = 10, wait = 100, shake = 30, shakeStep = 3, bubble = 60 } local FISH_CAST_AT = FISH_ANIM.used + FISH_ANIM.cast local FISH_VERDICT_AT = FISH_CAST_AT + FISH_ANIM.wait local FISH_SHAKE_END = FISH_VERDICT_AT + FISH_ANIM.shake -- engine/overworld/player_animations.asm:378 function OverworldState.fishAnimFrames(bite) if bite then return FISH_SHAKE_END + FISH_ANIM.bubble end return FISH_VERDICT_AT end -- engine/overworld/player_animations.asm:378, :452 function OverworldState.stepFishAnim(fa) fa.frames = (fa.frames or 0) + 1 local n = fa.frames if n == FISH_CAST_AT then return "cast" end if n < FISH_VERDICT_AT then return nil end if not fa.bite then return n == FISH_VERDICT_AT and "verdict" or nil end if n <= FISH_SHAKE_END then return ((n - FISH_VERDICT_AT) % FISH_ANIM.shakeStep == 1) and "shake" or nil end if n == FISH_SHAKE_END + 1 then return "bubble" end if n == FISH_SHAKE_END + FISH_ANIM.bubble then return "verdict" end return nil end -- engine/overworld/player_animations.asm:378 function OverworldState:tickFishAnim(fa) local ev = OverworldState.stepFishAnim(fa) if ev == "cast" then self.fishing = { facing = self.player.facing } self.player.fishing = true elseif ev == "shake" then fa.dy = (fa.dy == 1) and 0 or 1 self.player.fishShakeDy = fa.dy elseif ev == "bubble" then self.player.fishShakeDy = nil -- engine/overworld/player_animations.asm:424 if self.fishing and self.fishing.facing == "up" then self.fishing.hideRod = true end -- engine/overworld/emotion_bubbles.asm:1 self.emote = { npc = self.player, frames = FISH_ANIM.bubble, bubble = 1 } elseif ev == "verdict" then self.emote = nil self.player.fishShakeDy = nil if self.fishing then self.fishing.hideRod = nil end end return ev 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) if GameVersion.isYellow() then Game.save.pikachuEmotionModifier = 2 Game.save.pikachuMood = 0x81 end 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 -- engine/items/item_effects.asm:2855 local nothingHere = rod == "SUPER_ROD" and pool == nil local def = (Game.data.items or {})[rod] local used = require("src.inventory.ItemEffects") .itemUseLine(Game.data, Game.save, (def and def.name) or rod) local fa = { frames = 0, bite = enc ~= nil } -- engine/items/item_effects.asm:1893 Game.stack:push(TextBox.new(Game, used, function() self:fishVerdict(enc, nothingHere) end, { auto = { wait = false, delay = OverworldState.fishAnimFrames(fa.bite), sound = function() require("src.core.Sound").play(Game.data, "Heal_Ailment") end, tick = function() self:tickFishAnim(fa) end, } })) end function OverworldState:fishVerdict(enc, nothingHere) if not enc then -- engine/overworld/player_animations.asm:463 local text = nothingHere and romText(Game.data, "_NothingHereText", "Looks like there's\nnothing here.") or romText(Game.data, "_NoNibbleText", "Not even a nibble!") Game.stack:push(TextBox.new(Game, text, function() self.fishing = nil self.fishPose = 10 end)) return end Game.stack:push(TextBox.new(Game, romText(Game.data, "_ItsABiteText", "Oh!\nIt's a bite!"), function() 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 function OverworldState:useFishingRod(rod) if self.player.surfing or not self:facingIsShoreOrWater() then return false end self:goFishing(rod) return true 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 Game.save.onBike = false Game.save.forcedBike = nil -- HandleFlyWarpOrDungeonWarp res BIT_ALWAYS_ON_BIKE self.player.surfing = false self:syncSurfingPikachu() self:hidePikachuForWarp() -- _LeaveMapAnim .flyAnimation: the bird flaps in place (8 x Delay3), -- then SFX_FLY and the up-right path, a 40-frame beat off screen, and -- the exit over the top-left -- the warp fades only once the bird is -- gone (#702). fxBird draws it; the player hides for the whole flight. -- engine/overworld/player_animations.asm:123, home/overworld.asm:772 require("src.core.Music").fadeOut(FLY_FADE_CONTROL) self.flyFade = FLY_FADE_FRAMES 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 -- StopMusic sits above both _LeaveMapAnim branches -- engine/overworld/player_animations.asm:123 require("src.core.Music").fadeOut(FLY_FADE_CONTROL) -- engine/overworld/player_animations.asm:136 require("src.core.Sound").play(Game.data, "Teleport_Exit2") self.player.surfing = false self:syncSurfingPikachu() self:hidePikachuForWarp() 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 = TELEPORT_OUT_FRAMES self.player.spinTotal = TELEPORT_OUT_FRAMES self.player.spinRise = true self.player.spinHolds = TELEPORT_OUT_HOLDS self.player.spinStep = 0 self.player.spinHold = TELEPORT_OUT_HOLDS[1] self.player.spinRiseFrom = TELEPORT_OUT_INPLACE - 1 self.teleportOut = { frames = TELEPORT_OUT_FRAMES, onDone = onDone, step = 0 } 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 -- The Strength boulder on a cell, ignoring anything else standing there. -- npcAtCell returns whichever object the map listed first, which is only -- well defined while at most one sprite occupies a cell; scripted walks -- (TrainerWalkUpToPlayer) can break that, and the push path must still find -- the boulder underneath (#809). function OverworldState:pushableAtCell(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)) and Map.isPushable(npc.def) then return npc end end return nil end -- world.talk's fallthrough, hoisted so the A press does not build a closure -- on every press just to have one to hand a hook nobody may have wrapped local function vanillaTalk(ow, target) ow:talkTo(target) 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 -- world.talk: the A press on an object, before the map's text tables -- get it. A runtime object a mod spawned (WorldAPI:spawnNpc) carries -- no TEXT_* id, so the vanilla path has nothing to say for it; a mod -- that owns the object wraps this and simply does not call next(). -- Everything else falls straight through to talkTo as before. Runtime.call("world.talk", vanillaTalk, self, 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 -- engine/events/hidden_events/town_map.asm:1 Game.stack:push(TextBox.new(Game, t[entry.text] or t._TownMapText or romText(Game.data, "_TownMapText", "A TOWN MAP."), function() pcall(Screens.push, Game, entry.screen) end)) 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 romText(Game.data, "_DiglettSculptureText", "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 romText(Game.data, "_IndigoPlateauStatuesText1", "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 -- HiddenCoins pays a BCD constant chosen by the argument, and the 40 case -- falls into .bcd20 -- engine/events/hidden_items.asm:79 function OverworldState.hiddenCoinPayout(amount) amount = tonumber(amount) or 0 if amount == 10 then return 10 end if amount == 20 or amount == 40 then return 20 end return 100 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 -- hidden_items.asm FoundHiddenItemText: the find is announced first, -- then GiveItem's .bagFull branch prints _HiddenItemBagFullText and -- leaves the spot unfound; _CantCarryMoreText is the Toss line (#872) local name = Game.data.items[h.item] and Game.data.items[h.item].name or h.item Game.stack:push(TextBox.new(Game, romText(Game.data, "_FoundHiddenItemText", "%s found\n%s!", save.player.name, name) .. "\f" .. romText(Game.data, "_HiddenItemBagFullText", "But, {PLAYER} has\nno more room for\vother 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, and FoundHiddenItemText's -- text_asm tail runs it as PlaySoundWaitForCurrent + -- WaitForSoundToFinish once the box has printed (hidden_items.asm) Game.stack:push(TextBox.new(Game, romText(Game.data, "_FoundHiddenItemText", "%s found\n%s!", save.player.name, name), nil, TextBox.soundOpts(Game, "Get_Item2"))) 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 {} -- data/events/hidden_events.asm:262 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 romText(Game.data, "_GameCornerOutOfOrderText", "OUT OF ORDER\nThis is broken."))) elseif h.state == "out_to_lunch" then Game.stack:push(TextBox.new(Game, txt._GameCornerOutToLunchText or romText(Game.data, "_GameCornerOutToLunchText", "OUT TO LUNCH\nThis is reserved."))) elseif h.state == "keys" then Game.stack:push(TextBox.new(Game, txt._GameCornerSomeonesKeysText or romText(Game.data, "_GameCornerSomeonesKeysText", "Someone's keys!\nThey'll be back."))) elseif not save.inventory.COIN_CASE then Game.stack:push(TextBox.new(Game, txt._GameCornerCoinCaseText or romText(Game.data, "_GameCornerCoinCaseText", "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 romText(Game.data, "_GameCornerNoCoinsText", "You don't have\nany coins!"))) else -- one machine per visit is secretly lucky -- (wLuckySlotHiddenEventIndex, engine/slots/game_corner_slots.asm) local lucky = seatIndex == self.luckySlot -- PromptUserToPlaySlots: engine/slots/slot_machine.asm:9-23 Game.stack:push(TextBox.new(Game, txt._PlaySlotMachineText or romText(Game.data, "_PlaySlotMachineText", "A slot machine!\nWant to play?"), nil, { choice = function(yes) if not yes then return end self.emote = { npc = self.player, frames = 60, bubble = 3, onDone = function() Screens.push(Game, "SlotMachine", lucky) end, } end, })) end 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 local paid = OverworldState.hiddenCoinPayout(h.coins) save.coins = math.min(9999, (save.coins or 0) + paid) Game.stack:push(TextBox.new(Game, Strings("%s found\n%d coins!", save.player.name, paid), nil, TextBox.soundOpts(Game, "Get_Item2"))) 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 -- OpenRedsPC (engine/events/hidden_objects/players_pc.asm) runs the -- PlayerPC predef directly, no DisplayPCMainMenu (#228) require("src.core.Sound").play(Game.data, "Turn_On_PC") -- engine/menus/players_pc.asm:16 Game.stack:push(TextBox.new(Game, (Game.data.text or {})._TurnedOnPC2Text or romText(Game.data, "_TurnedOnPC2Text", "{PLAYER} turned on\nthe PC."), function() -- direct access: ExitPlayerPC rings SFX_TURN_OFF_PC (players_pc.asm, #960) Screens.push(Game, "PlayerPC", { direct = true }) end, { instant = true })) 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 romText(Game.data, "_VermilionGymTrashText", "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 romText(Game.data, "_CardKeyFailText", "Darn! It needs a\nCARD KEY!"))) return true end local bx, by = math.floor(fx / 2), math.floor(fy / 2) -- ../pokered/engine/events/card_key.asm:29-56 local function openDoor() self:replaceBlock(bx, by, openBlock) 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 Flags.set(Game.save, door.event) break end end require("src.core.Sound").play(Game.data, "Go_Inside") end -- ../pokered/engine/events/card_key.asm:63-67 Game.stack:push(TextBox.new(Game, TextBox.strip(t._CardKeySuccessText1 or Strings("Bingo!")) .. TextBox.PAUSE .. (t._CardKeySuccessText2 or romText(Game.data, "_CardKeySuccessText2", "\nThe CARD KEY\nopened the door!")), openDoor, { pauseSounds = { "Get_Item1" }, pauseSoundWait = true })) 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 romText(Game.data, "_VermilionGymTrashText", "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] -- engine/events/hidden_events/vermilion_gym_trash.asm:130 (text_asm tail: -- SFX_SWITCH once the text has printed, before the button wait) (#1702) Game.stack:push(TextBox.new(Game, t._VermilionGymTrashSuccessText1 or Strings("Hey! There's a\nswitch under the\ntrash!\fThe 1st electric\nlock opened!"), nil, TextBox.soundOpts(Game, "Switch"))) 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") -- engine/events/hidden_events/vermilion_gym_trash.asm:153 beeps as the -- text finishes; scripts/VermilionGym.asm:30 beeps on the swap (#1702) 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") self:replaceBlock(door.bx, door.by, door.block) end, TextBox.soundOpts(Game, "Go_Inside"))) 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 -- engine/events/hidden_events/vermilion_gym_trash.asm:162 (text_asm tail: -- SFX_DENIED once the text has printed, before the button wait) (#1702) Game.stack:push(TextBox.new(Game, t._VermilionGymTrashFailText or Strings("Nope! There's\nonly trash here.\fHey! The electric\nlocks were reset!"), nil, TextBox.soundOpts(Game, "Denied"))) 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 romText(Game.data, "_BillsHouseMonitorText", "TELEPORTER is\ndisplayed on the\nPC monitor."))) return end Game.stack:push(TextBox.new(Game, t._BillsHouseInitiatedText or Strings("{PLAYER} initiated\nTELEPORTER's Cell\nSeparator!"), function() -- BillsHouseInitiatedText's text_asm stops the music only after the -- prompt button -- bills_house_pc.asm:55 require("src.core.Music").stop() self:queueScript({ { "wait", 16 }, { "play_sound", "Switch" }, { "wait_sound" }, { "wait", 60 }, -- .doCellSeparator -- bills_house_pc.asm:18 { "wait", 32 }, { "play_sound", "Tink" }, { "wait_sound" }, { "wait", 80 }, { "play_sound", "Shrink" }, { "wait_sound" }, { "wait", 48 }, { "play_sound", "Tink" }, { "wait_sound" }, { "wait", 32 }, { "play_sound", "Get_Item1" }, { "wait_sound" }, }, { onDone = function() -- bills_house_pc.asm:38 require("src.core.Music").playMap(Game.data, self.map.id, Game.save.onBike, self.player.surfing) -- bills_house_pc.asm:39 flags.EVENT_USED_CELL_SEPARATOR_ON_BILL = true 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); 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") local bill local function done() Game.save.flags.EVENT_MET_BILL = true Game.save.flags.EVENT_MET_BILL_2 = true self:billsHouseSSTicketScene(bill) end -- scripts/BillsHouse.asm:81 self.emote = { frames = 8, bubble = false, onDone = function() require("src.world.PikachuFollower").onBillExitedMachine(Game, self) end } 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 -- pokeyellow scripts/BillsHouse.asm:211, :232 function OverworldState:billsHouseSSTicketScene(bill) if not GameVersion.isYellow() then return end local function talk() self.player.facing = "up" if bill then bill.facing = "down" end self:showMapText("TEXT_BILLSHOUSE_BILL_SS_TICKET", bill) end if not bill then talk() return end -- RLE_1e219, pokeyellow scripts/BillsHouse.asm:228 self:scriptMove(self.player, "right", 3, talk) 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. -- walking / biking / surfing is ONE state byte in the original: -- ItemUseSurfboard (engine/items/item_effects.asm) writes 2 over -- whatever wWalkBikeSurfState held, so mounting a surf ends the bike -- outright -- no bike step cadence in Player:tryMove and no bike -- theme on the water (#846). Music.playMap re-picks the override -- with BOTH flags, which setSurfing alone cannot do: effectiveMapSong -- (src/core/Music.lua) prefers state.onBike over state.surfing. Game.save.onBike = false local Music = require("src.core.Music") -- engine/items/item_effects.asm:686 if self.map then Music.playMap(Game.data, self.map.id, false, true) else Music.setSurfing(Game.data, true) end Game.stack:push(TextBox.new(Game, text, function() if onClose then onClose() end p.surfing = true self:syncSurfingPikachu() Game.stack:push(require("src.render.Transition").whiteFlash(Game, nil, function() self:stepForwardOrCrossEdge(p.facing) end)) end)) end function OverworldState:stopSurfing(onClose) if onClose then onClose() end self.player.surfing = false require("src.core.Music").setSurfing(Game.data, false) Game.stack:push(Transition.whiteFlash(Game, nil, function() self:stepForwardOrCrossEdge(self.player.facing) end)) end -- home/overworld.asm:1934 function OverworldState:stopSurfingOntoLand() local p = self.player if not (p.surfing and p.moving and p.targetX) then return end if not self.map:isWalkableCell(p.targetX, p.targetY) then return end p.surfing = false self:syncSurfingPikachu() require("src.core.Music").setSurfing(Game.data, false) 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 -- pick_up_item.asm .BagFull prints _NoMoreRoomForItemText, not the -- Toss-screen _CantCarryMoreText; Yellow announces the find first, -- then the refusal (#872) local noRoom = romText(Game.data, "_NoMoreRoomForItemText", "No more room for\nitems!") if GameVersion.isYellow() then local name = Game.data.items[d.item] and Game.data.items[d.item].name or d.item noRoom = romText(Game.data, "_FoundItemText", "%s found\n%s!", Game.save.player.name, name) .. "\f" .. noRoom end Game.stack:push(TextBox.new(Game, noRoom)) 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] -- FoundItemText: text_far, sound_get_item_1, text_end (pick_up_item.asm) Game.stack:push(TextBox.new(Game, romText(Game.data, "_FoundItemText", "%s found\n%s!", Game.save.player.name, name), nil, TextBox.soundOpts(Game, (ddef and ddef.keyItem) and "Get_Key_Item" or "Get_Item1"))) return end -- static wild encounters (object_event species+level args: the -- legendary birds, Mewtwo, the Vermilion Machop, ...) if d.pokemon then self:makeNpcFacePlayer(npc) 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 self:makeNpcFacePlayer(npc) 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 self:makeNpcFacePlayer(npc) 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 self:makeNpcFacePlayer(npc) -- the greeting stays in the box under the menu, so ShopMenu owns it -- now -- home/text_script.asm:143 Screens.push(Game, "ShopMenu", entry.mart) unfreeze() return end if entry.nurse then self:makeNpcFacePlayer(npc) self:nurseHeal(unfreeze, npc) return end if entry.pc then self:openPC(unfreeze) return end if entry.cableClub then self:makeNpcFacePlayer(npc) 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. local boxPcLabel = metBill and Strings.source("BILL'S PC") or Strings.source("SOMEONE'S PC") table.insert(items, { label = boxPcLabel, keepOpen = true, onSelect = function() require("src.core.Sound").play(Game.data, "Enter_PC") -- engine/menus/pc.asm:73 BillsPC prints the access text before the farcall local accessed = metBill and romText(Game.data, "_AccessedBillsPCText", "Accessed BILL's\nPC.\fAccessed POKéMON\nStorage System.") or romText(Game.data, "_AccessedSomeonesPCText", "Accessed someone's\nPC.\fAccessed POKéMON\nStorage System.") Game.stack:push(TextBox.new(Game, accessed, function() Screens.push(Game, "BoxMenu") end)) done() end, }) -- the player's item storage is always available local playerName = Game.save.player.name or "RED" local playerPcLabel = Strings.source("%s's PC"):format(playerName) table.insert(items, { label = playerPcLabel, keepOpen = true, onSelect = function() -- pc.asm .playersPC plays SFX_ENTER_PC then prints AccessedMyPCText -- before the farcall (engine/menus/pc.asm:54, #960) require("src.core.Sound").play(Game.data, "Enter_PC") Game.stack:push(TextBox.new(Game, romText(Game.data, "_AccessedMyPCText", "Accessed my PC.\fAccessed Item\nStorage System."), function() Screens.push(Game, "PlayerPC") end)) 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.source("PROF.OAK's PC"), keepOpen = true, onSelect = function() -- pc.asm OaksPC plays SFX_ENTER_PC before the farcall (#960) require("src.core.Sound").play(Game.data, "Enter_PC") self:openOaksPC(done) end, }) -- engine/pokemon/bills_pc.asm:48-60 PKMN LEAGUE row (#1566) if #(Game.save.hallOfFame or {}) > 0 then table.insert(items, { label = Strings.source("LEAGUE"), keepOpen = true, onSelect = function() -- pc.asm PKMNLeague plays SFX_ENTER_PC, then PKMNLeaguePC prints -- AccessedHoFPCText (engine/menus/pc.asm:67, league_pc.asm:2) require("src.core.Sound").play(Game.data, "Enter_PC") Game.stack:push(TextBox.new(Game, romText(Game.data, "_AccessedHoFPCText", "Accessed POKéMON\nLEAGUE's site.\fAccessed the HALL\nOF FAME List."), function() Screens.push(Game, "LeaguePC") end)) done() end, }) 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 -- Hooks identify the vanilla rows by their English source labels. Delay -- localization until after ui.pc.items has inspected/reordered/replaced -- them, then translate any source labels it chose to retain. local translatedLabels = { [boxPcLabel] = function() return metBill and Strings("BILL'S PC") or Strings("SOMEONE'S PC") end, [playerPcLabel] = function() return Strings("%s's PC", playerName) end, ["PROF.OAK's PC"] = function() return Strings("PROF.OAK's PC") end, ["LEAGUE"] = function() return Strings("LEAGUE") end, } for _, item in ipairs(items) do local translate = translatedLabels[item.label] if translate then item.label = translate() end 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 }) -- BIT_NO_MENU_BUTTON_SOUND for the whole PC session; DisplayPCMainMenu's -- TextBoxBorder c=14 interior -> tw 16 (engine/overworld/pokecenter_pc.asm) local menu = Menu.new(Game, items, { tx = 0, ty = 0, tw = 16, th = #items * 2 + 2, onCancel = logOff, noSound = true }) -- engine/menus/pc.asm:5 Game.stack:push(TextBox.new(Game, (Game.data.text or {})._TurnedOnPC1Text or romText(Game.data, "_TurnedOnPC1Text", "{PLAYER} turned on\nthe PC."), function() Game.stack:push(menu) end)) 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 if self.map.id == "PEWTER_POKECENTER" and self.pikachuPewterSleepScene then Game.stack:push(TextBox.new(Game, t._LooksContentText or Strings("PIKACHU looks\ncontent."), onDone)) return end local bye = t._PokemonCenterFarewellText or romText(Game.data, "_PokemonCenterFarewellText", "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") -- YesNoChoicePokeCenter draws HEAL/CANCEL, not YES/NO (home/yes_no.asm:21) 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() local yellow = GameVersion.isYellow() local pika = yellow and Follower.current(self) ~= nil local function hold(frames, next) self.emote = { npc = npc, frames = frames, bubble = false, onDone = next } end local function machine() -- the map music stops, and the party heals before the machine -- runs (predef HealParty) 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() if pika and npc then -- pokeyellow engine/events/pokecenter.asm:64 npc.facing = "up" hold(64, function() -- pokeyellow engine/events/pokecenter.asm:67 Follower.setVisible(self, true) -- pokeyellow engine/events/pokecenter.asm:69 npc.facing = "down" hold(6, function() self:finishNurseHeal(bye, onDone, npc) end) end) return end -- 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, npc) end end local function turnLeft() -- pokeyellow engine/events/pokecenter.asm:47 if npc then npc.facing = "left" end -- pokeyellow engine/events/pokecenter.asm:49 -- engine/events/pokecenter.asm:23 hold(yellow and 36 or 3, machine) end if not yellow then turnLeft() return end -- pokeyellow engine/events/pokecenter.asm:39 hold(64, function() if not pika then turnLeft() return end -- pokeyellow engine/events/pokecenter.asm:43 Follower.setVisible(self, false) -- pokeyellow engine/events/pokecenter.asm:100 if npc then npc.facing = "up" end hold(64, turnLeft) end) end)) end) end, choiceLabels = { Strings("HEAL"), Strings("CANCEL") }, choiceBox = Theme.healCancelBox })) end -- pokecenter.asm bows the nurse between the two PrintText calls (#995) function OverworldState:finishNurseHeal(bye, onDone, npc) local t = Game.data.text local fit = t._PokemonFightingFitText or Strings("Your POKéMON are\nfighting fit!") Game.stack:push(TextBox.new(Game, fit, function() local function farewell() Game.stack:push(TextBox.new(Game, bye, function() if npc then npc:facePlayer(self.player) end if onDone then onDone() end end)) end if not npc then farewell() return end local yellow = GameVersion.isYellow() local function bow() -- engine/events/pokecenter.asm:36 -- pokeyellow engine/events/pokecenter.asm:82 npc.frameOverride = yellow and 3 or 1 -- bubble = false is the silent world hold, this port's DelayFrames self.emote = { npc = npc, frames = yellow and 40 or 20, bubble = false, onDone = function() npc.frameOverride = nil npc:facePlayer(self.player) farewell() end } end if not yellow then bow() return end -- pokeyellow engine/events/pokecenter.asm:75 -- pokeyellow engine/events/pokecenter.asm:79 local Follower = require("src.world.PikachuFollower") self.emote = { npc = npc, bubble = false, onDone = bow, frames = (Follower.starterInParty(Game.save, true) and 6 or 0) + 3 } end)) 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 if self.map.id == "PEWTER_POKECENTER" and self.pikachuPewterSleepScene then Game.stack:push(TextBox.new(Game, t._LooksContentText or Strings("PIKACHU looks\ncontent."), onDone)) return end local welcome = t._CableClubNPCWelcomeText or romText(Game.data, "_CableClubNPCWelcomeText", "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 romText(Game.data, "_CableClubNPCMakingPreparationsText", "We're making\npreparations.\vPlease wait.")), onDone)) return end local apply = t._CableClubNPCPleaseApplyHereHaveToSaveText or romText(Game.data, "_CableClubNPCPleaseApplyHereHaveToSaveText", "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 romText(Game.data, "_CableClubNPCPleaseComeAgainText", "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 -- 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, } -- PlayTrainerMusic (home/trainers.asm:399) picks the encounter sting from -- the engaged class: evil list, then female list, then male by default. -- The rivals `ret z` out of it and keep the MUSIC_MEET_RIVAL their own -- scripts start (data/scripts/oaks_lab.lua, story5.lua). Its other gate, -- wGymLeaderNo, is not a leader test: that byte aliases wLoneAttackNo -- (ram/wram.asm:1264), is cleared on every map entry -- (engine/overworld/clear_variables.asm:8), and each gym script writes it -- only AFTER its own `call EngageMapTrainer` (scripts/PewterGym.asm:122), -- so leaders do get the sting and nothing on a map can be suppressed by it -- before the leader is beaten. Returns nil when the class gets no sting. local function meetTrainerTheme(cls) if not cls or cls:find("RIVAL") then return nil end return EVIL_TRAINERS[cls] and "Music_MeetEvilTrainer" or FEMALE_TRAINERS[cls] and "Music_MeetFemaleTrainer" or "Music_MeetMaleTrainer" end -- Public pre-trainer gate. A mod may retain continueBattle while a registered -- preparation screen is on top, then resume once with an optional ordered -- save-party index scope. The hook is cold on a no-mod boot. function OverworldState.prepareTrainerBattle(game, context, startBattle, cancelBattle) if not Runtime.wantsHook("trainer.before_battle") then startBattle() return false end local started = false local function continueBattle(options) if started then return false end started = true if type(options) == "table" and options.cancel == true then if cancelBattle then cancelBattle() end else startBattle(options) end return true end local deferred = Runtime.call("trainer.before_battle", function() return false end, game, context, continueBattle) if deferred ~= true and not started then continueBattle() end return deferred == true end -- Run the pre-battle text -> battle -> won text -> flags sequence. -- skipBattleText is for map scripts shaped like SilphCo11FDefaultScript -- (scripts/SilphCo11F.asm), which DisplayTextID the challenge line BEFORE -- the approach walk and then EngageMapTrainer with no further text: the -- caller already showed the box, so the battle starts without a second -- one (#869). function OverworldState:engageTrainer(npc, onDone, endBattleText, skipBattleText, endBattleSound, endBattleIsReward, endBattleSoundPage) 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 -- `endBattleText` is a caller-supplied stand-in for header.won: the -- text_asm trainers that hand their loss line to the battle through -- SaveEndBattleTextPointers (scripts/GameCorner.asm GameCornerRocketText -- passes _GameCornerRocketBattleEndText, "Dang!") have no def_trainers -- header for the extractor to read, so their script passes the finished -- line here and it still lands where PrintEndBattleText puts it -- between -- TrainerDefeatedText and MoneyForWinningText, on the battle screen (#862). local wonText = endBattleText or (header and header.won and Game.data.text[header.won]) local BattleState = require("src.battle.BattleState") local stingPlayed = false -- home/trainers.asm:109 prints, :123 engages (BIT_SEEN_BY_TRAINER = -- self.engaging), then home/text_script.asm:96 waits for A (#764, #1683) local function playMeetSting() if stingPlayed or self.engaging then return end stingPlayed = true local theme = meetTrainerTheme(d.trainerClass) if theme then require("src.core.Music").play(Game.data, theme) end end local function startBattle(options) self.cancelledTrainerSight = nil playMeetSting() local battle = BattleState.newTrainer(Game, d.trainerClass, d.trainerParty, options) battle.checkpointOrigin = { kind = "trainer_encounter", map = self.map.id, npcId = npc.id, trainerClass = d.trainerClass, partyIndex = d.trainerParty or 1, event = header and header.event or nil, } -- 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 -- scripts/PewterGym.asm:156-159 battle.endBattleSound = endBattleText ~= nil and endBattleSound or nil battle.endBattleSoundPage = endBattleText ~= nil and endBattleSoundPage or nil -- one truth for both checkVictoryRewards call sites; endBattleIsReward -- = false marks an armed line that is NOT the victories dialogue (#1606) battle.rewardDialogueShown = endBattleText ~= nil and endBattleIsReward ~= false battle.onFinish = function(result) if result == "win" then Game.save.defeatedTrainers[npc.id] = true if header and header.event then Flags.set(Game.save, header.event) 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, battle.rewardDialogueShown) self:afterBattle(result, battle) if onDone then onDone() end else self:afterBattle(result, battle) if onDone then onDone() end end end self:pushBattle(battle, npc) end local function prepareBattle() if not Runtime.wantsHook("trainer.before_battle") then startBattle() return end OverworldState.prepareTrainerBattle(Game, { trainerClass = d.trainerClass, partyIndex = d.trainerParty or 1, mapId = self.map.id, npcId = npc.id, }, startBattle, function() if self.player then self.cancelledTrainerSight = { npcId = npc.id, playerX = self.player.cellX, playerY = self.player.cellY, } end if onDone then onDone() end end) end if skipBattleText then prepareBattle() else Game.stack:push(TextBox.new(Game, battleText, prepareBattle, { auto = { wait = true, delay = 0, sound = playMeetSting } })) end end -- Shared GiveItem step for the victory rewards (pokered home/give.asm): -- the item goes through the bag's capacity check, and only a successful -- add sets the reward's gotFlag (EVENT_GOT_TM*) and copies the item name -- into wStringBuffer for the "{RAM:wStringBuffer}" received texts. local function giveVictoryItem(reward) if not require("src.inventory.Bag").add(Game.save, reward.item, 1, Game.data) then return false end if reward.gotFlag then Flags.set(Game.save, reward.gotFlag) end local idef = Game.data.items[reward.item] Game.stringBuffer = idef and idef.name or reward.item return true end -- A gym's reward text is not one box. Every gym script carries a sound -- command right after the FIRST label of each reward group -- (scripts/PewterGym.asm PewterGymBrockReceivedBoulderBadgeText's -- sound_level_up, PewterGymReceivedTM34Text's sound_get_item_1, and the -- equivalents in the other seven), and home/text.asm TextCommand_SOUND -- plays it only once that page has typed out, then blocks on -- WaitForSoundToFinish before the next page prints. So the pages -- accumulate and split into separate boxes at the sound points, each box -- chained off the previous one's button press. local function rewardChain() local chain = { boxes = {}, pending = {} } function chain.flush(sound) if #chain.pending > 0 then table.insert(chain.boxes, { text = table.concat(chain.pending, "\f"), sound = sound }) chain.pending = {} end end -- one reward group; `sound` rides its first page, where the scripts put it function chain.add(labels, sound) local text = Game.data.text or {} local n = 0 for _, label in ipairs(labels or {}) do if text[label] and text[label] ~= "" then table.insert(chain.pending, text[label]) n = n + 1 if n == 1 and sound then chain.flush(sound) end end end end -- the synthetic stand-in line a reward with no `dialogue` shows function chain.line(str, sound) table.insert(chain.pending, str) if sound then chain.flush(sound) end end function chain.push(done) chain.flush() local function step(i) local box = chain.boxes[i] if not box then if done then done() end return end local opts = box.sound and TextBox.soundOpts(Game, box.sound) or nil Game.stack:push(TextBox.new(Game, box.text, function() step(i + 1) end, opts)) end step(1) end return chain 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). -- `shownOnBattleScreen`: `dialogue` already rode the battle screen as the -- armed end-battle line (scripts/CeruleanGym.asm:113) function OverworldState:checkVictoryRewards(trainerClass, partyIndex, shownOnBattleScreen) local victories = require("data.scripts.victories") local rewardKey = trainerClass .. "#" .. tostring(partyIndex or 1) local reward = victories[rewardKey] if not reward then return self:runVictoryHook() end if reward.flag then if Game.save.flags[reward.flag] then return self:runVictoryHook() end Flags.set(Game.save, reward.flag) end if reward.deactivate then for _, flag in ipairs(reward.deactivate) do Flags.set(Game.save, flag) 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 local tmGiven = false if reward.item then -- pokered GiveItem (home/give.asm): AddItemToInventory first, and a -- full bag (jr nc, .BagFull) skips the received lines for the "make -- room" text, leaving EVENT_GOT_TM* unset so the leader's talk script -- retries the hand-over later (offerGymTm via gyms.lua) tmGiven = giveVictoryItem(reward) end local chain = rewardChain() if reward.dialogue then if not shownOnBattleScreen then chain.add(reward.dialogue, victories.badgeSoundFor(rewardKey)) end if reward.item then chain.add(reward.tmPre) if tmGiven then chain.add(reward.tmDialogue, reward.tmSound) else chain.add({ reward.noRoom }) 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 chain.line(Strings("%s received\nthe %s!", Game.save.player.name, name), victories.badgeSoundFor(rewardKey)) end if tmGiven then local name = Game.stringBuffer or reward.item chain.line(Strings("%s received\n%s!", Game.save.player.name, name), reward.tmSound) end end chain.push() self:runVictoryHook() end -- A beaten leader re-running their ReceiveTM script when the bag was full -- at the victory (pokered's middle branch, e.g. PewterGymBrockText -- CheckEventReuseA EVENT_GOT_TM34 -> call PewterGymScriptReceiveTM34). -- The script's lead-in lines (tmPre: badge info / "Wait! Take this!") -- show again, then the same GiveItem check decides between the received -- lines and the "make room" text. function OverworldState:offerGymTm(reward, done) local chain = rewardChain() chain.add(reward.tmPre) if giveVictoryItem(reward) then chain.add(reward.tmDialogue, reward.tmSound) else chain.add({ reward.noRoom }) end chain.push(done) 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 -- a map contribution opts a trainer out of CheckFightingMapTrainers with -- `noSight = { TEXT_... = true }` -- home/trainers.asm:129 local view = mapScripts.get and mapScripts.get(self.map.id) local noSight = view and view.noSight local cancelled = self.cancelledTrainerSight if cancelled and (cancelled.playerX ~= p.cellX or cancelled.playerY ~= p.cellY) then self.cancelledTrainerSight = nil cancelled = nil end 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 (cancelled and cancelled.npcId == npc.id) and not self:trainerDefeated(npc) and not (noSight and d.text and noSight[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 function OverworldState:startTrainerApproach(npc, dist) self.engaging = true npc.frozen = true -- TrainerEngage (engine/overworld/trainer_sight.asm:224) sets -- BIT_SEEN_BY_TRAINER and calls EngageMapTrainer before the "!" bubble, -- so the sighting sting starts ahead of the walk-up; engageTrainer sees -- self.engaging and does not restart it (#764) local theme = meetTrainerTheme(npc.def.trainerClass) if theme then 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() -- TrainerWalkUpToPlayer (engine/overworld/trainer_sight.asm) writes -- dist-1 NPC_MOVEMENT_* bytes and hands them to MoveSprite, and every -- scripted step skips collision entirely (CanWalkOntoTile, -- engine/overworld/movement.asm: "always allow walking if the -- movement is scripted"), so the original marches the trainer straight -- through a Strength boulder sitting on the sight line. Stop one cell -- short of the boulder instead: two sprites on one cell is a state the -- push path cannot represent, and the walk-up is the one scripted move -- the player can steer a boulder into (#809). local steps = dist - 1 local cx, cy = npc.cellX, npc.cellY for i = 1, steps do cx, cy = Collision.target(cx, cy, npc.facing) if self:pushableAtCell(cx, cy) then steps = i - 1 break end end if steps > 0 then self:scriptMove(npc, npc.facing, steps, fight) else fight() end end, } end -- engine/overworld/movement.asm:406-414 function OverworldState:makeNpcFacePlayer(npc) if not npc or self.noNpcFacePlayer then return end npc:facePlayer(self.player) 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 local suppressed = npc and self.noNpcFacePlayer self:makeNpcFacePlayer(npc) -- home/text_script.asm:139 local finish = onDone onDone = function() if suppressed then self:makeNpcFacePlayer(npc) end if finish then finish() end 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, checkpointOnDone = finish and "release_npc" or nil, 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 self:makeNpcFacePlayer(npc) 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 -- engine/gfx/screen_effects.asm:7-8 function OverworldState:tickPoisonFlash() if (self.poisonFlash or 0) > 0 then self.poisonFlash = self.poisonFlash - 1 end end -- engine/events/poison.asm:57, :93 function OverworldState:poisonFlashLive() if (self.poisonFlash or 0) <= 0 then return false end return not (Game and Game.stack) or Game.stack:top() == self end function OverworldState:poisonShadeMap() if not self:poisonFlashLive() then return nil end if PaletteFX.usesGbcPack() and self.map and self.map.renderer and self.map.renderer.gbcAtlas then return nil end return PaletteFX.POISON_BGP end -- 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 -- engine/events/poison.asm:75-91 .countPoisonedLoop local stillPoisoned = false for _, mon in ipairs(save.party) do if mon.status == "PSN" then stillPoisoned = true break end end if stillPoisoned then require("src.core.Sound").play(Game.data, "Poisoned") -- engine/gfx/screen_effects.asm:7-8 self.poisonFlash = 4 end local queue = {} for _, mon in ipairs(fainted) do local name = mon.nickname or Game.data.pokemon[mon.species].name table.insert(queue, romText(Game.data, "_PokemonFaintedText", "%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)) local lm, lx, ly = self:escapeWarpTarget() local landing = { map = lm, x = lx, y = ly } Runtime.emit("world.blacked_out", { save = save, healTarget = self:healPoint(), landing = landing }) self:warpToHealPoint(nil, { landing = landing }) 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 -- Defaulted: a state built without the constructor (a mod harness, a test -- fixture) reaches this before :234 ever ran, and nil > 0 threw the step. local grace = self.wildEncounterGraceSteps or 0 if grace > 0 then self.wildEncounterGraceSteps = grace - 1 end -- TryDoWildEncounter's first guard is `ld a, [wNPCMovementScriptPointerTable -- Num] / and a / ret nz` (engine/battle/wild_encounters.asm:3-9): a step the -- player did not take never rolls, which is why Oak's escort walks to the lab -- through Pallet's grass without being jumped. local runner = self.runner local scripted = (runner and runner.isRunning and runner:isRunning()) or #(self.scriptMoves or {}) > 0 or self.engaging or self.emote or self.teleportOut local suppressWildEncounter = grace > 0 or scripted and true or false 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, romText(Game.data, "_RepelWoreOffText", "REPEL's effect\nwore off."))) return end end -- wild encounters in grass, on water while surfing (at the map's water -- rate, which is 0 on every indoor map), or -- on indoor maps whose -- tileset is not FOREST -- on every other tile -- (wild_encounters.asm: caves, towers, the Mansion, Power Plant) -- The cooldown is checked after all other step processing so repel and -- movement systems continue to advance during the protected steps. if suppressWildEncounter then return end local encDef = Game.data.encounters[self.map.id] local enc local indoor = Game.data.field.indoorEncounters if self.map:isGrassCell(p.cellX, p.cellY) then enc = self:rollEncounter(encDef, "grass") elseif p.surfing and self.map:isWaterCell(p.cellX, p.cellY) then enc = self:rollEncounter({ grass = encDef and encDef.water }, "water") 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) battle.checkpointOrigin = { kind = "wild_encounter", map = self.map.id, } -- 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, noSpin) local mv = moves[i] if not mv then self.player.spinning = false self.spinnerSliding = nil -- 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 if not noSpin then self.player.spinning = true -- home/overworld.asm:268-273 self.spinnerSliding = true end self:scriptMove(self.player, mv.dir, mv.count, function() self:runSpinnerMoves(moves, i + 1, noSpin) 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 Flags.set(Game.save, passedFlag) -- Route22GateGuardGoRightAheadText carries sound_get_item_1 Game.stack:push(TextBox.new(Game, t["_" .. g.passText] or Strings("Go right ahead!"), nil, TextBox.soundOpts(Game, "Get_Item1"))) 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, nil, { collide = true }) 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 Flags.set(Game.save, guard.event) -- Route23OhThatIsTheBadgeText carries sound_get_item_1 local text = (t["_" .. g.passText] or Strings("Oh! That is the\n{RAM}!")):gsub("{RAM:wNameBuffer}", badgeName) Game.stack:push(TextBox.new(Game, text, nil, TextBox.soundOpts(Game, "Get_Item1"))) 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, nil, { collide = true }) 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, nil, { collide = true }) end)) return true end elseif tile.mode == "surf" then -- scripts/SeafoamIslandsB4F.asm writes wWalkBikeSurfState = 2 and -- jp ForceBikeOrSurf, so a forced surf clears the bike state the -- same way the party-menu mount does (#846) p.surfing = true Game.save.onBike = false self:syncSurfingPikachu() require("src.core.Music").playMap(Game.data, self.map.id, false, 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") -- home/overworld.asm:1891 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, true) 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 Flags.set(Game.save, h.boulderEvent) 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 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(romText(Game.data, "_TimesUpText", "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 -- SetEvent EVENT_SAFARI_GAME_OVER -- engine/events/hidden_events/safari_game.asm:54 Game.save.safariGameOver = true local t = Game.data.text Game.stack:push(TextBox.new(Game, (text or "") .. "\f" .. (t._GameOverText or romText(Game.data, "_GameOverText", "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) -- .battleOccurred tail-jumps to EnterMap (home/overworld.asm:353), so -- ClearVariablesOnEnterMap zeroes wStepCounter on every battle return Game.save.poisonSteps = 0 if battle and battle.kind == "wild" then self.wildEncounterGraceSteps = WILD_ENCOUNTER_GRACE_STEPS end 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) 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 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)) local lm, lx, ly = self:escapeWarpTarget() local landing = { map = lm, x = lx, y = ly } Runtime.emit("world.blacked_out", { save = Game.save, healTarget = self:healPoint(), landing = landing }) self:warpToHealPoint(nil, { landing = landing }) 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 end end -- Rebind the data-only continuation attached to a supported battle checkpoint. -- The overworld was reconstructed first, so transient input/NPC freezes from -- the original encounter are intentionally not resumed. function OverworldState:restoreBattleContinuation(battle, origin) local game = battle and battle.game if not game or type(origin) ~= "table" or not self.map or origin.map ~= self.map.id then return false end if origin.kind == "wild_encounter" and battle.kind == "wild" then battle.onFinish = function(result) self:afterBattle(result, battle) end return true end if origin.kind == "script_battle" then if origin.battleKind ~= battle.kind or (battle.kind == "trainer" and (origin.trainerClass ~= battle.oppClass or origin.partyIndex ~= (battle.partyIndex or 1))) or type(origin.script) ~= "table" or type(origin.pc) ~= "number" then return false end local npc = origin.npcId and self.npcPool and self.npcPool[origin.npcId] or nil if origin.npcId and not npc then return false end battle.onFinish = function(result) local runner = self.runner if not runner or runner:isRunning() then runner = ScriptRunner.new(game, self) self.runner = runner end runner:run(origin.script, { npc = npc, source = origin.source, resumeBattle = { result = result, battle = battle }, }, origin.pc) end battle.checkpointScriptContinuation = true return true end if origin.kind ~= "trainer_encounter" or battle.kind ~= "trainer" or origin.trainerClass ~= battle.oppClass or origin.partyIndex ~= (battle.partyIndex or 1) or type(origin.npcId) ~= "string" then return false end battle.onFinish = function(result) if result == "win" then game.save.defeatedTrainers[origin.npcId] = true if origin.event then Flags.set(game.save, origin.event) end self:checkVictoryRewards(battle.oppClass, battle.partyIndex, battle.rewardDialogueShown) end self:afterBattle(result, battle) self.engaging = false local npc = self.npcPool and self.npcPool[origin.npcId] if npc then npc.frozen = false end end return true 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 -- home/overworld.asm:476,503,507 local spawn = require("src.world.PikachuFollower").warpSpawnState( Map.isOutside(self.map.def, FieldDefaults.field(Game.data, "outsideTilesets")), fromMap, destMap, warpDef.destMap == "LAST_MAP", 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) -- engine/overworld/player_animations.asm:104-118 require("src.core.Sound").play(Game.data, "Teleport_Exit1") self:hidePikachuForWarp() self.arriveWarp = "teleport" self.player.inputLocked = true self.player.spinning = true self.player.spinTimer = 0 self.player.spinFrames = WARP_PAD_OUT_FRAMES self.player.spinTotal = WARP_PAD_OUT_FRAMES self.player.spinRise = true self.player.spinHolds = WARP_PAD_OUT_HOLDS self.player.spinStep = 0 self.player.spinHold = WARP_PAD_OUT_HOLDS[1] self.player.spinRiseFrom = -1 self.teleportOut = { frames = WARP_PAD_OUT_FRAMES, step = 0, sfx = {}, go = function() self:startWarpTo(destMap, x, y, facing, nil, { pikachuSpawn = spawn }) end } return elseif pad == "hole" then self:fallThroughHole(destMap, x, y, facing) return end self.doorWarp = true -- door SFX + PlayerStepOutFromDoor walk-out self:startWarpTo(destMap, x, y, facing, nil, { pikachuSpawn = spawn }) end -- (engine/overworld/player_animations.asm:204-228) function OverworldState:fallThroughHole(mapId, x, y, facing) self.player.inputLocked = true self:hidePikachuForWarp() self.player.holeSink = true self.holeFall = { frames = 2, map = mapId, x = x, y = y, facing = facing or self.player.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 -- engine/events/black_out.asm:39-43, engine/overworld/special_warps.asm:71-129 function OverworldState:escapeWarpTarget() local heal = self:healPoint() local out = heal.outdoor or { id = heal.map, x = heal.x, y = heal.y } local fw = (Game.data.field.flyWarps or {})[out.id] local outX = fw and fw.x or out.x local outY = fw and fw.y or out.y local outDef = Game.data.maps[out.id] if not (outDef and outX and outY and Map.isOutside(outDef, FieldDefaults.field(Game.data, "outsideTilesets"))) then local zeroFill = require("src.core.SaveData") .defaultHeal(Game.data.field.boot) return zeroFill.map, zeroFill.x, zeroFill.y end return out.id, outX, outY end -- Warp to the last heal point (blackout, ESCAPE ROPE, DIG/TELEPORT). -- engine/events/black_out.asm:42, home/overworld.asm:23-30 (#96) function OverworldState:warpToHealPoint(onDone, opts) 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 if opts and opts.arrive == "teleport" then self.arriveWarp = "teleport" end local landing = opts and opts.landing local map, x, y if landing and landing.map then map, x, y = landing.map, landing.x, landing.y else map, x, y = self:escapeWarpTarget() end self:startWarpTo(map, x, y, "down", onDone) -- PrepareForSpecialWarp (engine/overworld/special_warps.asm:1-29) writes the self:rememberOutdoor(map, x, y) 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 if self.spinArrive then self.spinArrive = nil self.player.inputLocked = false end -- PlayMapChangeSound (home/overworld.asm) plays before the tail-called -- GBFadeOutToBlack, so the SFX starts with the fade (#961) if doorWarp then -- the source map -- home/overworld.asm:689-703 local rule = FieldDefaults.field(Game.data, "mapChangeSound") or {} local exempt = false -- pokeyellow home/overworld.asm:667-671 if GameVersion.isYellow() then for _, ts in ipairs(rule.exemptTilesets or {}) do if ts == self.map.def.tileset then exempt = true end end end local cue = "Go_Outside" if not exempt and self.map:tileAt(self.player.cellX * 2, self.player.cellY * 2) == (rule.doorTile or 0x0B) then cue = "Go_Inside" end require("src.core.Sound").play(Game.data, cue) end -- Fly/Teleport/Dig/Escape Rope: GBFadeOutToWhite / GBFadeInFromWhite -- (player_animations.asm). Door warps stay black with no fade-in (#1644). local Timing = require("src.core.Timing") local specialWarp = arriveWarp == "fly" or arriveWarp == "teleport" or arriveWarp == "hole" local fadeOpts = specialWarp and { color = { 1, 1, 1 }, frames = Timing.FADE_OUT_TO_WHITE, framesIn = Timing.FADE_IN_FROM_WHITE, } or nil -- home/overworld.asm:681 if not specialWarp and self.dark then fadeOpts = { frames = 0 } end Game.stack:push(Transition.new(Game, function() self:setMap(mapId, x, y, facing or "down", opts) -- the departure-side hide from flyAnim/teleportOut ends here, on the new -- map; the arrival arms its own cover (flyArrive / spinDrop) a few lines -- down, so the player is never drawable mid-fade nor standing bare on the -- landing frame (#916) self.playerHidden = false -- setMap respawned a follower under the player; _LeaveMapAnim's Func_1510 -- suppression runs until EnterMapAnim lands (home/pikachu.asm:1) if self.pikachuWarpHidden then require("src.world.PikachuFollower").setVisible(self, false) -- engine/overworld/player_animations.asm:37,48,65 if arriveWarp == "hole" then self.pikachuShowState = 0 elseif arriveWarp == "fly" then self.pikachuShowState = 1 elseif arriveWarp == "teleport" then self.pikachuShowState = opts and opts.pikachuSpawn or 1 end end -- 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") -- EnterMapAnim .flyAnimation: the bird swoops in off the top-right -- edge and the player reappears where it lands (#702); the input -- lock from flyTo releases when the swoop finishes self.flyArrive = { t = 0 } elseif arriveWarp == "teleport" then require("src.core.Sound").play(Game.data, "Teleport_Enter1") -- engine/overworld/player_animations.asm:83-85 self.delaySfx = { frames = HOLE_IN_FRAMES, key = "Teleport_Enter2" } -- the sprite spins down into place (EnterMapAnim -- PlayerSpinWhileMovingDown), not just the SFX -- engine/overworld/player_animations.asm:22-25 local onPad = self.map.warpPadOrHoleAt and self.map:warpPadOrHoleAt(x, y) == "pad" local holds = onPad and TELEPORT_IN_PAD_HOLDS or TELEPORT_IN_HOLDS local frames = onPad and TELEPORT_IN_PAD_FRAMES or TELEPORT_IN_FRAMES self.player.spinning = true self.player.spinTimer = 0 self.player.spinFrames = frames self.player.spinTotal = frames self.player.spinDrop = true self.player.spinHolds = holds self.player.spinStep = 0 self.player.spinHold = holds[1] self.player.spinDropSteps = SPIN_DOWN_STEPS -- engine/overworld/player_animations.asm:19 self.spinArrive = true self.player.inputLocked = true elseif arriveWarp == "hole" then -- engine/overworld/player_animations.asm:12 require("src.core.Sound").play(Game.data, "Teleport_Enter1") self.playerHidden = true self.holeArrive = { frames = Timing.DUNGEON_WARP_ARRIVAL } self.player.inputLocked = true end if doorWarp then -- 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, not specialWarp, fadeOpts)) 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) elseif not self.preparingLiveMaps and self.npcPool and self:isNeighborMap(mapId) then self:rebuildGhosts() 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 if not self.preparingLiveMaps and self.npcPool and self:isNeighborMap(mapId) then self:rebuildGhosts() 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, opts) table.insert(self.scriptMoves, { entity = entity, dir = dir, remaining = tiles, onDone = onDone, collide = opts and opts.collide or nil, }) end -- A step-in-place beat: the entity plays one walk-cycle animation (32 -- 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 32-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 mv.remaining = mv.remaining - 1 elseif mv.collide and not Collision.canMove(self.map, self.entities, e, mv.dir) then -- home/overworld.asm:1224 e.facing = mv.dir mv.remaining = 0 else e.facing = mv.dir local tx, ty = Collision.target(e.cellX, e.cellY, mv.dir) e.targetX, e.targetY = tx, ty -- a simulated d-pad press runs at the CURRENT walk/bike speed, not -- whatever the last real step left behind -- home/overworld.asm:276 if e.stepLength then e.stepFramesCur = e:stepLength() end e.moving = true e.progress = 0 mv.remaining = mv.remaining - 1 end 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 -- ------------------------------------------------------------------------- -- home/fade.asm:58 function OverworldState.warpFade() local top = Game and Game.stack and Game.stack.top and Game.stack:top() if top and top.bgp and top.shadeMapFor then return top end return nil end function OverworldState:bakedWorldColors() return PaletteFX.usesGbcPack() and self.map and self.map.renderer and self.map.renderer.gbcAtlas and true or false end -- pokeyellow engine/gfx/palettes.asm:799 -- pokeyellow home/fade.asm:75-77 function OverworldState:warpFadeUniforms(byte) local pack = PaletteFX.worldPack() if not pack then return nil end local bg, seen = {}, {} local function addMap(map) local tileset = map and map.def and map.def.tileset if not tileset or seen[tileset] then return end seen[tileset] = true local groups = PaletteFX.worldGroupColors(Game.data, tileset, map.id, self.player and self.player.cellY) for _, colors in ipairs(groups or {}) do bg[#bg + 1] = colors end end addMap(self.map) if not bg[1] then return nil end for group = 0, 7 do local colors = (pack.spritePalettes or {})[group] if colors then bg[#bg + 1] = PaletteFX.darkObp(colors, group) end end for _, nb in ipairs(self.neighbors or {}) do addMap(nb.map) end return GbcPalette.remapUniforms(bg, byte) end -- home/fade.asm:58 function OverworldState:drawWorldFaded() local fade = OverworldState.warpFade() if not (fade and self:bakedWorldColors()) then return false end local byte = fade:bgp() if not Transition.shadeMapFor(byte) then return false end if Tilt.active() or Pipelines.worldPipeline() then return false end if GbcPalette.mode ~= "gbc" or GbcPalette.customRamp then return false end if not GbcPalette.remapShader() then return false end local target = Game.renderer and Game.renderer.worldCanvas if not target then return false end local w, h = target:getWidth(), target:getHeight() local scratch = self.fadeCanvas if scratch and (scratch:getWidth() ~= w or scratch:getHeight() ~= h) then if scratch.release then scratch:release() end scratch, self.fadeCanvas = nil, nil end if not scratch then local ok, made = pcall(love.graphics.newCanvas, w, h) if not ok or not made then return false end made:setFilter("nearest", "nearest") scratch = made self.fadeCanvas = scratch end local uniforms = self:warpFadeUniforms(byte) if not uniforms then return false end local G = love.graphics local previous = G.getCanvas() G.push() G.origin() G.setCanvas(scratch) G.clear(1, 1, 1, 1) self:drawWorld() G.setCanvas(previous) G.pop() if not GbcPalette.useRemapUniforms(uniforms) then return false end G.setColor(1, 1, 1, 1) G.draw(scratch, 0, 0) GbcPalette.clear() fade.paletteStepped = true return true end function OverworldState:draw() Game.renderer:beginWorldPass() if not self:drawWorldFaded() then self:drawWorld() end 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 -- engine/battle/battle_transitions.asm:28 function OverworldState:drawWipeSprites() local cam = self.camera local ogObp = PaletteFX.usesSpriteObp() local zw = not ogObp and self:sgbWorldZones() or nil local prevPass = PaletteFX.pass() if ogObp then PaletteFX.setPass("world") end local function replay(e) local colors = zoneColorsAt(zw, e.px - cam.x + 8, e.py - cam.y + 16) local shader = colors and PaletteFX.shader() or nil if shader then PaletteFX.sendColors(shader, colors) love.graphics.setShader(shader) end love.graphics.setColor(1, 1, 1, 1) e:draw(cam.x, cam.y) if shader then love.graphics.setShader() end end local ok, err = pcall(function() local keep = self.battleOamKeep if keep and keep.draw then replay(keep) end if not (self.flyAnim or self.flyArrive or self.playerHidden) then replay(self.player) end end) if ogObp then PaletteFX.setPass(prevPass) end if not ok then error(err, 0) end 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. -- -- BATTLE BG "world" is the one case where a map DOES draw in a battle's -- frame (Game.drawBaseInStack), and the shift armed here reached the -- battle's own colorize pass, so an un-flashed Rock Tunnel battle came out -- with FadePal2 over its pics, HUD and text (#773). The battle zeroes -- wMapPalOffset for its whole run and restores it on the way out -- (engine/battle/core.asm InitBattleCommon push/pop), so the map behind it -- goes lit too for as long as the battle is up -- which is what the -- original's saved offset means. local battleOverWorld = Game and Game.stack and Game.worldBgBattleInStack(Game.stack) -- home/fade.asm:66 -- home/overworld.asm:684 local fade = OverworldState.warpFade() local fadeBgp = fade and fade:bgp() local fadeMap = fadeBgp and Transition.shadeMapFor(fadeBgp) if fadeBgp and not self:bakedWorldColors() then PaletteFX.setShadeMap(fadeMap) -- home/fade.asm:52-58 -- home/fade.asm:65-73 local fadeObp = fade.obp0 and fade:obp0() or fadeBgp PaletteFX.setFadeObp(Transition.shadeMapFor(fadeObp)) fade.paletteStepped = true elseif self.emote and self.emote.bgp and not battleOverWorld then -- engine/pikachu/pikachu_pic_animation.asm:847 PaletteFX.setShadeMap(Transition.shadeMapFor(self.emote.bgp)) else PaletteFX.setShadeMap((self.dark and not battleOverWorld) and PaletteFX.DARK_BGP or self:poisonShadeMap()) end -- 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.spinnerSliding or false) 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) -- engine/battle/battle_transitions.asm:28 if self.battleOamKeep ~= nil and Game.renderer then Game.renderer.wipeSprites = self.wipeSpritesFn end -- 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 self:drawShipAnim(cam.x, bgY) 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) if a.py ~= b.py then return a.py < b.py end -- a fresh warp spawn parks the follower on the player's own cell -- until it trails out; the tie must draw it under him, never on -- top (#863) return a.pikachuFollower == true and b.pikachuFollower ~= true 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 local shader, redrawColors = healMachineShader(ha.visible) local mark = redrawColors and PaletteFX.spriteRedrawPassActive() -- 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) if mark then PaletteFX.markSpriteRedraw(img, self.healMachineQuads[1], ox + 44, oy + 20, 1, redrawColors) end 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) if mark then PaletteFX.markSpriteRedraw(img, self.healMachineQuads[2], ox + b[1] + 8, oy + b[2], -1, redrawColors) end else love.graphics.draw(img, self.healMachineQuads[2], ox + b[1], oy + b[2]) if mark then PaletteFX.markSpriteRedraw(img, self.healMachineQuads[2], ox + b[1], oy + b[2], 1, redrawColors) end 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 + (da.ox or 0) - cam.x local dy = da.y * 16 + (da.oy or 0) - cam.y local flicker = math.floor(da.frames / 4) % 2 == 0 if da.boulder then flicker = not da.faded end 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 -- engine/overworld/emotion_bubbles.asm:41 local ex = npc.px - cam.x local ey = npc.py - cam.y - 20 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) -- engine/overworld/emotion_bubbles.asm:18 if PaletteFX.usesSpriteObp() and PaletteFX.spriteRedrawPassActive() then PaletteFX.markSpriteRedraw(img, q, ex, ey, 1) end 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() local anim = self.flyAnim or self.flyArrive if not anim 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 not self.birdSprite then return end -- DoFlyAnimation: the bird flaps its wings every Delay3; each path is -- anchored on the player's cell (FLY_ANCHOR / FLY_ARRIVE_ANCHOR) so -- the flight rides any screen position, and it faces its travel -- direction (rightward travel flips the left-drawn sheet) local phase = anim.phase or "arrive" if phase == "hold" then return end -- parked off screen between paths local path, anchor, facing if phase == "path1" then path, anchor, facing = FLY_PATH1, FLY_ANCHOR, "right" elseif phase == "path2" then path, anchor, facing = FLY_PATH2, FLY_ANCHOR, "left" elseif phase == "arrive" then path, anchor, facing = FLY_PATH_IN, FLY_ARRIVE_ANCHOR, "left" end local step = math.floor(anim.t / 3) local sx, sy if path then local pair = path[math.min(#path, step + 1)] sx, sy = pair[2] - anchor[2], pair[1] - anchor[1] else sx, sy = 0, 0 -- the in-place flap sits on the player facing = "right" end love.graphics.setColor(1, 1, 1, 1) self.birdSprite:draw(self.player.px + sx, self.player.py + sy, cam.x, cam.y, facing, step % 2, false) end -- fishing pose: the rod tile over the faced water (gfx/fishing.asm) local function fxRod() if not self.fishing then return end -- engine/overworld/player_animations.asm:424 if self.fishing.hideRod 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] -- Place the rod against the active sprite's anchored top-left. The -- vanilla result is still (px-cam, py-cam-4), while custom larger -- sheets keep the rod attached to their feet. -- Fishing always uses the on-foot player sheet; read its fields -- directly so this FX pass does not advance pose-side animation. local sprite, px, py = p.sprite, p.px, p.py local sx, sy = sprite:getScreenOrigin(px, py, cam.x, cam.y) local rx = sx + oam.dx -- engine/overworld/player_animations.asm:453 local ry = sy + oam.dy + (p.fishShakeDy or 0) 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 = Game.renderer:playfieldRect() 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 withOverrideMirror(function() -- ground-hugging effects sit on the cell they belong to if self.dustAnim then local da = self.dustAnim at(fxDust, da.x * 16 + 8 + (da.ox or 0), da.y * 16 + 8 + (da.oy or 0)) 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) 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 self:drawShipAnim(cam.x, bgY) 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 local boulderDust = self.dustAnim and self.dustAnim.boulder if boulderDust then fxDust() end for _, g in ipairs(self.ghosts) do if self.battleOamKeep == nil then g.npc:draw(cam.x - g.ox, cam.y - g.oy) end end for _, e in ipairs(self.entities) do if not ((self.flyAnim or self.flyArrive or self.playerHidden) and e == self.player) and not self:oamCulled(e) 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() if not boulderDust then fxDust() end 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 if self.battleOamKeep == nil then items[#items + 1] = { y = g.npc.py + g.oy + 16, kind = "ghost", g = g } end end for _, e in ipairs(self.entities) do if not ((self.flyAnim or self.flyArrive or self.playerHidden) and e == self.player) and not self:oamCulled(e) 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 or self.flyArrive 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() -- engine/events/prize_menu.asm:24-28 if self.prizeWindow then require("src.ui.PrizeCounter").drawWindow( Game, self.prizeWindow.prizes, self.prizeWindow.index) end -- 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 -- engine/gfx/screen_effects.asm:1-12 if self:poisonFlashLive() then local r = Game and Game.renderer local map = self:poisonShadeMap() if PaletteFX.shader() and map then -- home/fade.asm:66 if not PaletteFX.shadeMap() then PaletteFX.setShadeMap(map) end elseif r then r.screenVeil = { 0, 0.45 } else 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 -- engine/pikachu/pikachu_pic_animation.asm:847 local bolt = self.emote and self.emote.bgp if bolt and bolt ~= 0xE4 and Game and Game.renderer and (self:bakedWorldColors() or not PaletteFX.shader()) then Game.renderer.screenVeil = BOLT_WHITE_VEIL 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