initial commit

This commit is contained in:
bryanthaboi
2026-07-17 20:30:02 -04:00
commit a5d2e77e7d
298 changed files with 100561 additions and 0 deletions
+75
View File
@@ -0,0 +1,75 @@
-- Movement permission checks: tile passability (from generated collision
-- data), map bounds, and entity occupancy.
local Collision = {}
local DELTA = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } }
Collision.DELTA = DELTA
function Collision.target(cx, cy, dir)
local d = DELTA[dir]
return cx + d[1], cy + d[2]
end
-- entities: array of anything with cellX/cellY (and optional targetX/targetY
-- while mid-step, so nobody walks into a cell being entered).
function Collision.occupied(entities, cx, cy, ignore)
for _, e in ipairs(entities) do
if e ~= ignore then
if (e.cellX == cx and e.cellY == cy) or
(e.targetX == cx and e.targetY == cy) then
return e
end
end
end
return nil
end
-- Tile-pair (elevation) collisions: certain tile pairs can't be crossed
-- in a given tileset (cave/forest ledges). data set via Collision.load.
local tilePairs = nil
function Collision.load(data)
tilePairs = data.field and data.field.tilePairs or { land = {}, water = {} }
end
local function pairBlocked(map, mover, sx, sy, tx, ty)
if not tilePairs then return false end
local list = mover.surfing and tilePairs.water or tilePairs.land
if not list or #list == 0 then return false end
local tileset = map.def.tileset
local a = map:cellTile(sx, sy)
local b = map:cellTile(tx, ty)
for _, p in ipairs(list) do
if p.tileset == tileset
and ((p.a == a and p.b == b) or (p.a == b and p.b == a)) then
return true
end
end
return false
end
-- Returns true when the mover may step from (cx,cy) toward dir.
-- Out-of-bounds is blocked here; the OverworldController handles map
-- connections and edge warps before asking.
function Collision.canMove(map, entities, mover, dir)
local tx, ty = Collision.target(mover.cellX, mover.cellY, dir)
if not map:inBounds(tx, ty) then
return false, "bounds"
end
if not map:isWalkableCell(tx, ty) then
-- surfers may ride water cells
if not (mover.surfing and map:isWaterCell(tx, ty)) then
return false, "tile"
end
end
if pairBlocked(map, mover, mover.cellX, mover.cellY, tx, ty) then
return false, "tile"
end
if Collision.occupied(entities, tx, ty, mover) then
return false, "entity"
end
return true
end
return Collision
+95
View File
@@ -0,0 +1,95 @@
-- ShakeElevator (pokered engine/overworld/elevator.asm), run after
-- DisplayElevatorFloorMenu picks a floor (the elevator map script fires
-- it via BIT_CUR_MAP_USED_ELEVATOR):
--
-- * lead-in: ShakeElevator's two ShakeElevatorRedrawRow calls (each
-- ends in Delay3) plus its own Delay3 are 9 frames; the SilphCo /
-- RocketHideout ...ShakeScripts prefix one more Delay3 (12 total)
-- while Celadon's farjps straight in (9). The row redraws
-- themselves are a VRAM patch with no port equivalent -- only their
-- delays are kept.
-- * SFX_STOP_ALL_MUSIC: the map theme cuts out for the ride.
-- * 100 loop iterations, 2 frames each (`ld b, 100` / `ld c, 2` +
-- DelayFrames): `e ^= $fe` flips e between $01 and $ff, so
-- hSCY = rest + e alternates -1 / +1 around the resting scroll
-- (first offset -1), and SFX_COLLISION plays every iteration.
-- * hSCY restored, SFX_STOP_ALL_MUSIC again, then SFX_SAFARI_ZONE_PA
-- plays and .musicLoop busy-waits on wChannelSoundIDs+CHAN5 until
-- it ends.
-- * UpdateSprites + PlayDefaultMusic: the map theme restarts.
--
-- SCY scrolls the BG layer only -- OAM sprites stay put -- so this
-- state drives ow.bgShakeY, which OverworldState:drawWorld adds to the
-- tile layers and not to the sprites. While it sits on the stack the
-- overworld below neither updates nor takes input, like the original's
-- blocking loop.
local Sound = require("src.core.Sound")
local ElevatorShake = {}
ElevatorShake.__index = ElevatorShake
local CYCLES = 100 -- ld b, 100
local FRAMES_PER_CYCLE = 2 -- ld c, 2 / call DelayFrames
-- opts.preFrames: lead-in delay frames (12 Silph/Rocket, 9 Celadon);
-- opts.onDone: called once the ride is over (the floor warp)
function ElevatorShake.new(game, ow, opts)
opts = opts or {}
return setmetatable({
game = game,
ow = ow,
preFrames = opts.preFrames or 12,
onDone = opts.onDone,
phase = "pre",
frames = 0,
offset = 1, -- ld e, $1; the first `xor $fe` flips it to -1
}, ElevatorShake)
end
function ElevatorShake:update()
if self.phase == "pre" then
if self.frames < self.preFrames then
self.frames = self.frames + 1
return
end
-- SFX_STOP_ALL_MUSIC: the theme stops just before the first scroll
-- write, in the same frame slice
require("src.core.Music").stop()
self.phase = "shake"
self.frames = 0
end
if self.phase == "shake" then
if self.frames % FRAMES_PER_CYCLE == 0 then
-- one .shakeLoop iteration: flip the offset, write the scroll,
-- retrigger SFX_COLLISION
self.offset = -self.offset
self.ow.bgShakeY = self.offset
Sound.play(self.game.data, "Collision")
end
self.frames = self.frames + 1
if self.frames >= CYCLES * FRAMES_PER_CYCLE then
-- ld a, d / ldh [hSCY], a: back to the resting scroll, then the
-- arrival chime
self.ow.bgShakeY = 0
if Sound.stop then Sound.stop("Collision") end -- SFX_STOP_ALL_MUSIC
Sound.play(self.game.data, "Safari_Zone_PA")
self.phase = "pa"
end
return
end
-- .musicLoop: hold until SFX_SAFARI_ZONE_PA finishes (headless the
-- sound never starts, so this resolves on the next frame)
if Sound.isPlaying and Sound.isPlaying("Safari_Zone_PA") then return end
require("src.core.Music").restoreMap(self.game.data) -- PlayDefaultMusic
self.game.stack:pop()
if self.onDone then self.onDone() end
end
-- safety: never leave a scroll offset behind if popped early
-- (e.g. Game:returnToTitle popping the whole stack)
function ElevatorShake:exit()
if self.ow then self.ow.bgShakeY = 0 end
end
return ElevatorShake
+30
View File
@@ -0,0 +1,30 @@
-- Wild encounters from generated encounter tables.
-- Gen 1: on each step into a grass/water cell, a battle starts when
-- rand(0..255) < map encounter rate; the slot is picked with the original
-- probability buckets.
local Encounter = {}
-- cumulative slot thresholds out of 256 (engine/battle/wild_encounters.asm)
local SLOT_BUCKETS = { 51, 102, 141, 166, 191, 216, 229, 242, 253, 256 }
function Encounter.roll(encounterDef, rng)
rng = rng or love.math.random
if not encounterDef then return nil end
local grass = encounterDef.grass
if not grass or grass.rate == 0 then return nil end
if rng(0, 255) >= grass.rate then return nil end
local pick = rng(0, 255)
for i, threshold in ipairs(SLOT_BUCKETS) do
if pick < threshold then
local slot = grass.slots[i]
if slot then
return { species = slot.species, level = slot.level }
end
return nil
end
end
return nil
end
return Encounter
+119
View File
@@ -0,0 +1,119 @@
-- Runtime map built from generated data. All queries use "cells": the
-- 16x16 walk grid (2x2 tiles). A map is width x height blocks; each block
-- is 2x2 cells (4x4 tiles).
--
-- Collision follows the original engine: a cell is passable when the
-- BOTTOM-LEFT 8x8 tile of the cell is in the tileset's walkable list
-- (pokered checks the tile at the sprite's feet). Doors, warp tiles and
-- grass use the same convention.
local Map = {}
Map.__index = Map
function Map.new(def, tilesetDef)
local self = setmetatable({}, Map)
self.def = def
self.tileset = tilesetDef
self.id = def.id
self.widthCells = def.width * 2
self.heightCells = def.height * 2
self.walkable = {}
for _, t in ipairs(tilesetDef.walkable) do self.walkable[t] = true end
self.doorTiles = {}
for _, t in ipairs(tilesetDef.doorTiles or {}) do self.doorTiles[t] = true end
self.warpTiles = {}
for _, t in ipairs(tilesetDef.warpTiles or {}) do self.warpTiles[t] = true end
self.warpAt = {}
for i, w in ipairs(def.warps) do
self.warpAt[w.y * self.widthCells + w.x] = { index = i, def = w }
end
self.signAt = {}
for _, s in ipairs(def.signs) do
self.signAt[s.y * self.widthCells + s.x] = s
end
return self
end
function Map:blockAt(bx, by)
if bx < 0 or by < 0 or bx >= self.def.width or by >= self.def.height then
return self.def.borderBlock
end
return self.def.blocks[by * self.def.width + bx + 1]
end
-- tile id at tile coordinates (8px grid), border-extended
function Map:tileAt(tx, ty)
local bx, by = math.floor(tx / 4), math.floor(ty / 4)
local block = self.tileset.blocks[self:blockAt(bx, by) + 1]
local ix = (ty % 4) * 4 + (tx % 4) + 1
return block[ix]
end
-- the collision tile of a cell: bottom-left 8x8 tile
function Map:cellTile(cx, cy)
return self:tileAt(cx * 2, cy * 2 + 1)
end
function Map:inBounds(cx, cy)
return cx >= 0 and cy >= 0 and cx < self.widthCells and cy < self.heightCells
end
function Map:isWalkableCell(cx, cy)
return self.walkable[self:cellTile(cx, cy)] or false
end
function Map:isGrassCell(cx, cy)
local grass = self.tileset.grassTile
return grass ~= nil and self:cellTile(cx, cy) == grass
end
-- Water and eastern-shore tiles (item_effects.asm IsNextTileShoreOrWater,
-- home/overworld.asm CollisionCheckOnWater): $14 everywhere; the shore
-- tiles $32 and $48 (Safari Zone) everywhere EXCEPT the SHIP_PORT
-- tileset, where $32 is the dock's boarding platform (a land tile).
-- Tileset membership in water_tilesets.asm is checked by the caller.
function Map:isWaterCell(cx, cy)
local t = self:cellTile(cx, cy)
if t == 0x14 then return true end
if self.def.tileset == "SHIP_PORT" then return false end
return t == 0x32 or t == 0x48
end
-- Replace a block (Cut trees); the caller rebuilds the renderer.
function Map:setBlock(bx, by, block)
if bx < 0 or by < 0 or bx >= self.def.width or by >= self.def.height then
return
end
self.def.blocks[by * self.def.width + bx + 1] = block
end
-- true if the cell's collision tile is a door or warp-activating tile
function Map:isWarpTileCell(cx, cy)
local t = self:cellTile(cx, cy)
return self.doorTiles[t] or self.warpTiles[t] or false
end
-- counter tiles allow talking to NPCs across them (mart clerks, nurses)
function Map:isCounterCell(cx, cy)
local t = self:cellTile(cx, cy)
for _, c in ipairs(self.tileset.counterTiles or {}) do
if c == t then return true end
end
return false
end
function Map:warpAtCell(cx, cy)
return self.warpAt[cy * self.widthCells + cx]
end
function Map:signAtCell(cx, cy)
return self.signAt[cy * self.widthCells + cx]
end
function Map:connection(dir)
return self.def.connections and self.def.connections[dir]
end
return Map
+30
View File
@@ -0,0 +1,30 @@
-- Builds runtime Map objects (and their tile SpriteBatches) from generated
-- data, cached by map id.
local Map = require("src.world.Map")
local TileRenderer = require("src.render.TileRenderer")
local MapLoader = {}
local cache = {}
function MapLoader.load(data, mapId)
if cache[mapId] then return cache[mapId] end
local def = data.maps[mapId]
assert(def, "unknown map: " .. tostring(mapId))
local tilesetDef = data.tilesets[def.tileset]
assert(tilesetDef, "unknown tileset: " .. tostring(def.tileset))
-- warp tiles are stored per tileset macro name; the generated tilesets
-- module carries them in the tileset entry itself
local map = Map.new(def, tilesetDef)
map.renderer = TileRenderer.new(map)
cache[mapId] = map
return map
end
function MapLoader.clearCache()
cache = {}
end
return MapLoader
+109
View File
@@ -0,0 +1,109 @@
-- Map object (NPC/item) built from a generated object_event entry.
-- STAY objects keep their facing; WALK objects wander randomly within the
-- roam constraint (ANY_DIR / UP_DOWN / LEFT_RIGHT), like the original.
local Collision = require("src.world.Collision")
local SpriteRenderer = require("src.render.SpriteRenderer")
local NPC = {}
NPC.__index = NPC
local STEP_FRAMES = 16
local FACING_FROM_RANGE = {
DOWN = "down", UP = "up", LEFT = "left", RIGHT = "right",
}
local ROAM_DIRS = {
ANY_DIR = { "up", "down", "left", "right" },
UP_DOWN = { "up", "down" },
LEFT_RIGHT = { "left", "right" },
}
function NPC.new(data, mapId, objDef)
local self = setmetatable({}, NPC)
self.def = objDef
self.id = string.format("%s_obj_%d", mapId, objDef.index)
local spriteDef = data.sprites[objDef.sprite]
assert(spriteDef, "unknown sprite " .. tostring(objDef.sprite))
self.sprite = SpriteRenderer.new(spriteDef)
-- object_event coordinates are already walk-grid cells
self.cellX, self.cellY = objDef.x, objDef.y
self.px, self.py = self.cellX * 16, self.cellY * 16
self.facing = FACING_FROM_RANGE[objDef.range] or "down"
self.moving = false
self.progress = 0
self.stepFlip = false
self.frozen = false -- scripts freeze NPCs while talking
self.wanders = objDef.movement == "WALK"
self.roamDirs = ROAM_DIRS[objDef.range] or ROAM_DIRS.ANY_DIR
self.timer = love.math.random(30, 120)
return self
end
function NPC:facePlayer(player)
local dx = player.cellX - self.cellX
local dy = player.cellY - self.cellY
if math.abs(dx) > math.abs(dy) then
self.facing = dx > 0 and "right" or "left"
else
self.facing = dy > 0 and "down" or "up"
end
end
function NPC:update(map, entities)
if self.moving then
self.progress = self.progress + 1
-- NPC_CHANGE_FACING: animate the walk cycle in place, no translation
-- (movement.asm ChangeFacingDirection zeroes the delta); px/py stay
-- pinned to the current cell while walkPhase() cycles.
if self.marching then
if self.progress >= STEP_FRAMES then
self.progress = 0
self.moving = false
self.marching = false
self.stepFlip = not self.stepFlip
end
return
end
local d = Collision.DELTA[self.facing]
self.px = self.cellX * 16 + d[1] * self.progress
self.py = self.cellY * 16 + d[2] * self.progress
if self.progress >= STEP_FRAMES then
self.cellX, self.cellY = self.targetX, self.targetY
self.targetX, self.targetY = nil, nil
self.px, self.py = self.cellX * 16, self.cellY * 16
self.moving = false
self.stepFlip = not self.stepFlip
end
return
end
if self.frozen or not self.wanders then return end
self.timer = self.timer - 1
if self.timer > 0 then return end
self.timer = love.math.random(30, 180)
local dir = self.roamDirs[love.math.random(#self.roamDirs)]
self.facing = dir
if love.math.random() < 0.5 then return end -- sometimes just turn
-- never wander onto warps, so NPCs don't walk out of the map
local tx, ty = Collision.target(self.cellX, self.cellY, dir)
if map:warpAtCell(tx, ty) then return end
if Collision.canMove(map, entities, self, dir) then
self.targetX, self.targetY = tx, ty
self.moving = true
self.progress = 0
end
end
function NPC:walkPhase()
if not self.moving then return 0 end
local p = self.progress % 16
return (p >= 4 and p < 12) and 1 or 0
end
function NPC:draw(camX, camY)
self.sprite:draw(self.px, self.py, camX, camY, self.facing,
self:walkPhase(), self.stepFlip)
end
return NPC
File diff suppressed because it is too large Load Diff
+145
View File
@@ -0,0 +1,145 @@
-- The player: tile-grid movement with pixel interpolation, faithful to the
-- original's feel: facing changes on a short tap, movement is tile-by-tile
-- at 1px per frame (16 frames per step), input locked while stepping.
local Collision = require("src.world.Collision")
local SpriteRenderer = require("src.render.SpriteRenderer")
local Player = {}
Player.__index = Player
local STEP_FRAMES = 16
-- a turn in place holds for the ~2 frames the original spends on the
-- extra OverworldLoop pass (home/overworld.asm .handleDirectionButtonPress
-- returns to the loop without moving after a direction change)
local TURN_FRAMES = 2
function Player.new(data, cx, cy, facing)
local self = setmetatable({}, Player)
self.sprite = SpriteRenderer.new(data.sprites.SPRITE_RED)
-- the original surfs on the Seel sprite
-- (LoadSurfingPlayerSpriteGraphics, home/overworld.asm)
if data.sprites.SPRITE_SEEL then
self.surfSprite = SpriteRenderer.new(data.sprites.SPRITE_SEEL)
end
-- and cycles on the red_bike sheet (LoadPlayerSpriteGraphics)
if data.sprites.SPRITE_RED_BIKE then
self.bikeSprite = SpriteRenderer.new(data.sprites.SPRITE_RED_BIKE)
end
-- the ledge-hop shadow quarter-tile (gfx/overworld/shadow.png,
-- LedgeHoppingShadow, engine/overworld/ledges.asm)
local fx = data.field and data.field.overworldFx
if fx and fx.shadow then
local ok, img = pcall(love.graphics.newImage, fx.shadow.path)
self.shadowImg = ok and img or nil
end
self.cellX, self.cellY = cx, cy
self.px, self.py = cx * 16, cy * 16
self.facing = facing or "down"
self.moving = false
self.progress = 0
self.stepFlip = false
self.turnTimer = 0
self.inputLocked = false
return self
end
function Player:position()
return self.cellX, self.cellY
end
-- Attempt to start a step; returns "moved"|"turned"|"blocked"|nil.
function Player:tryMove(dir, map, entities)
if self.moving or self.inputLocked then return nil end
if self.facing ~= dir then
self.facing = dir
self.turnTimer = TURN_FRAMES
return "turned"
end
if self.turnTimer > 0 then return nil end
local ok, why = Collision.canMove(map, entities, self, dir)
if not ok then
return "blocked", why
end
local tx, ty = Collision.target(self.cellX, self.cellY, dir)
self.targetX, self.targetY = tx, ty
self.moving = true
self.progress = 0
-- the bicycle doubles walking speed (8 frames per step)
local save = require("src.core.Game").save
self.stepFramesCur = (save and save.onBike) and 8 or STEP_FRAMES
return "moved"
end
-- Advance one fixed step; returns true when a step just completed.
function Player:update()
if self.turnTimer > 0 then
self.turnTimer = self.turnTimer - 1
end
if not self.moving then return false end
local stepLen = self.stepFramesCur or STEP_FRAMES
self.progress = self.progress + 1
local d = Collision.DELTA[self.facing]
local px = math.floor(self.progress * 16 / stepLen)
self.px = self.cellX * 16 + d[1] * px
self.py = self.cellY * 16 + d[2] * px
if self.progress >= stepLen then
self.cellX, self.cellY = self.targetX, self.targetY
self.targetX, self.targetY = nil, nil
self.px, self.py = self.cellX * 16, self.cellY * 16
self.moving = false
self.stepFlip = not self.stepFlip
return true
end
return false
end
function Player:facingCell()
return Collision.target(self.cellX, self.cellY, self.facing)
end
function Player:walkPhase()
if not self.moving then return 0 end
-- walk frame during the middle of the step
local p = self.progress % 16
return (p >= 4 and p < 12) and 1 or 0
end
local SPIN_ORDER = { "down", "left", "up", "right" }
function Player:draw(camX, camY)
local py = self.py
-- ledge hops arc (set for 2 cells by the ledge handler); surfing bobs
if self.hopFrames and self.hopFrames > 0 then
self.hopFrames = self.hopFrames - 1
local t = 1 - self.hopFrames / (self.hopTotal or 32)
py = py - math.floor(10 * math.sin(t * math.pi) + 0.5)
-- the shadow stays on the ground under the jumper: one 8x8 tile
-- mirrored into a 2x2 block (normal/XFLIP/YFLIP/both) whose top-left
-- is 8px below the sprite's standing top-left (LoadHoppingShadowOAM +
-- LedgeHoppingShadowOAMBlock, engine/overworld/ledges.asm)
if self.shadowImg then
local sx = math.floor(self.px - camX)
local sy = math.floor(self.py - camY) - 4 + 8
love.graphics.draw(self.shadowImg, sx, sy)
love.graphics.draw(self.shadowImg, sx + 16, sy, 0, -1, 1)
love.graphics.draw(self.shadowImg, sx, sy + 16, 0, 1, -1)
love.graphics.draw(self.shadowImg, sx + 16, sy + 16, 0, -1, -1)
end
elseif self.surfing then
self.bobTimer = ((self.bobTimer or 0) + 1) % 32
py = py + (self.bobTimer < 16 and 0 or 1)
end
local facing = self.facing
if self.spinning then
-- spinner tiles whirl the sprite (PlayerSpinningFacingOrder)
self.spinTimer = (self.spinTimer or 0) + 1
facing = SPIN_ORDER[math.floor(self.spinTimer / 4) % 4 + 1]
end
local sprite = (self.surfing and self.surfSprite)
or (self.onBike and self.bikeSprite) or self.sprite
sprite:draw(self.px, py, camX, camY, facing,
self:walkPhase(), self.stepFlip)
end
return Player
+111
View File
@@ -0,0 +1,111 @@
-- Warp resolution. A warp fires when:
-- * the player finishes a step onto a warp cell whose collision tile is a
-- door tile or warp tile (stairs, doors, mats, cave entrances), or
-- * the player stands on a warp cell and tries to walk off the map edge
-- (exit carpets at the bottom of interiors), or
-- * the player stands on a warp cell and the "extra" check passes -- on
-- arrival with the d-pad held, or on a blocked step (route-gate
-- doorways, the Vermilion dock entrance, ...).
-- This mirrors pokered's CheckWarpsNoCollision / CheckWarpsCollision /
-- ExtraWarpCheck (home/overworld.asm).
local Warp = {}
-- Returns the warp entry to take when arriving at (cx,cy), or nil.
function Warp.onArrive(map, cx, cy)
local w = map:warpAtCell(cx, cy)
if w and map:isWarpTileCell(cx, cy) then
return w
end
return nil
end
local function inList(list, v)
for _, x in ipairs(list) do
if x == v then return true end
end
return false
end
-- ExtraWarpCheck: may the player standing at (cx,cy) facing dir warp
-- without a door/warp tile underfoot? On the carpet maps/tilesets the
-- tile in FRONT of the player must be a warp-carpet tile for the facing
-- direction (IsWarpTileInFrontOfPlayer; SS_ANNE_BOW tests one hardcoded
-- tile instead); everywhere else the player must face the map edge
-- (IsPlayerFacingEdgeOfMap). carpets = field.warpCarpets.
function Warp.extraCheck(map, carpets, cx, cy, dir)
local Collision = require("src.world.Collision")
local facingEdge =
(dir == "up" and cy == 0)
or (dir == "down" and cy == map.heightCells - 1)
or (dir == "left" and cx == 0)
or (dir == "right" and cx == map.widthCells - 1)
if not carpets then return facingEdge end
-- the map exceptions are tested before the tileset (ExtraWarpCheck)
local useCarpet
if inList(carpets.edgeMaps, map.id) then
useCarpet = false
elseif inList(carpets.function2Maps, map.id) then
useCarpet = true
else
useCarpet = inList(carpets.function2Tilesets, map.def.tileset)
end
if not useCarpet then return facingEdge end
local tx, ty = Collision.target(cx, cy, dir)
local front = map:cellTile(tx, ty)
if map.id == carpets.ssAnneBow.map then
return front == carpets.ssAnneBow.tile
end
return inList(carpets.tiles[dir], front)
end
-- Returns the warp entry when standing on (cx,cy) and the extra check
-- passes toward dir (fired from a blocked step, or on arrival with the
-- d-pad held).
function Warp.onCollision(map, carpets, cx, cy, dir)
local w = map:warpAtCell(cx, cy)
if w and Warp.extraCheck(map, carpets, cx, cy, dir) then
return w
end
return nil
end
-- Returns the warp entry when standing on (cx,cy) and moving toward dir
-- takes the player out of bounds.
function Warp.onEdge(map, cx, cy, dir)
local w = map:warpAtCell(cx, cy)
if not w then return nil end
local Collision = require("src.world.Collision")
local tx, ty = Collision.target(cx, cy, dir)
if not map:inBounds(tx, ty) then
return w
end
return nil
end
-- Resolve a warp's destination to map id + cell. LAST_MAP destinations
-- (returning from an interior) resolve against the remembered outdoor
-- map; the landing cell is that map's warp entry named by the warp id
-- (wDestinationWarpID placement -- two-sided route gates land you on
-- the side you exit, not where you entered).
function Warp.destination(data, warpDef, lastMap)
local destMap = warpDef.destMap
if destMap == "LAST_MAP" then
assert(lastMap, "LAST_MAP warp with no remembered outdoor map")
destMap = lastMap.id
local destDef = data.maps[destMap]
local dw = destDef and destDef.warps[warpDef.destWarp]
if dw then
return destMap, dw.x, dw.y
end
-- out-of-range data: fall back to where the player entered
return destMap, lastMap.x, lastMap.y
end
local destDef = data.maps[destMap]
assert(destDef, "warp to unknown map " .. tostring(destMap))
local dw = destDef.warps[warpDef.destWarp]
assert(dw, ("warp to %s#%d out of range"):format(destMap, warpDef.destWarp))
return destMap, dw.x, dw.y
end
return Warp