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