Files
gen1recomp/src/core/game3/map.lua
T

525 lines
17 KiB
Lua

-- Game3 map loader. Owns Sevii enter: player, collision, EventObjects, Space scripts.
-- Talk/interact is Field.interact. Do not call host setMap/warpToMapId here —
-- MAPSETUP.WARP races ON_FRAME and wipes applymovement tracks (Bill intro).
local MapIds = require("src.core.game3.map_ids")
local ModRuntime = require("src.mods.Runtime")
local Map = {}
Map.current = nil
Map._announced = nil
Map.neighbors = {}
Map._loadedLayouts = {}
Map._def = nil
Map._currentDef = nil
function Map.currentDef()
return Map._def or Map._currentDef
end
local function host_map_def(game, mapId)
local data = game and game.data
return data and data.maps and data.maps[mapId]
end
local function host_world(game)
return game and (game.overworld or game.world)
end
function Map.loadNeighborsDepth1(game, primaryDef)
Map.neighbors = {}
if not primaryDef or type(primaryDef.connections) ~= "table" then
return Map.neighbors
end
local data = game and game.data and game.data.maps
if not data then return Map.neighbors end
for dir, conn in pairs(primaryDef.connections) do
local mid = type(conn) == "table" and conn.map or conn
if type(mid) == "string" and data[mid] then
local def = data[mid]
Map.ensureMidLayout(game, mid, def)
Map.neighbors[dir] = {
map = mid,
mapId = mid,
def = def,
offset = type(conn) == "table" and (conn.offset or 0) or 0,
}
Map._loadedLayouts[mid] = true
end
end
return Map.neighbors
end
local function cell_size(def)
local L = def and def.midLayout
if L and L.width and L.height then return L.width or 0, L.height or 0 end
return (tonumber(def and def.width) or 0) * 2, (tonumber(def and def.height) or 0) * 2
end
Map.world = {}
Map._worldRoot = nil
Map._worldReachW = -1
Map._worldReachH = -1
Map.WORLD_HOPS = 2
function Map.computeWorld(maps, rootId, hops, reachW, reachH, ensure)
local out = {}
local rootDef = maps and maps[rootId]
if not rootDef then return out end
ensure = ensure or function() end
ensure(rootId, rootDef)
local rootW, rootH = cell_size(rootDef)
local placed = { [rootId] = true }
local queue = { { id = rootId, def = rootDef, ox = 0, oy = 0, hops = 0 } }
local qi = 1
local function inReach(def, ox, oy)
if not (reachW and reachH) then return false end
local w, h = cell_size(def)
return ox + w > -reachW and ox < rootW + reachW
and oy + h > -reachH and oy < rootH + reachH
end
while queue[qi] do
local cur = queue[qi]
qi = qi + 1
local curW, curH = cell_size(cur.def)
for dir, conn in pairs(cur.def.connections or {}) do
local destId = type(conn) == "table" and conn.map or conn
local destDef = type(destId) == "string" and maps[destId] or nil
if destDef and destDef ~= rootDef and not placed[destId] then
local offset = (type(conn) == "table" and tonumber(conn.offset) or 0) or 0
ensure(destId, destDef)
local destW, destH = cell_size(destDef)
local ox, oy
if dir == "north" or dir == "up" then
ox, oy = offset, -destH
elseif dir == "south" or dir == "down" then
ox, oy = offset, curH
elseif dir == "west" or dir == "left" then
ox, oy = -destW, offset
elseif dir == "east" or dir == "right" then
ox, oy = curW, offset
end
if ox then
ox, oy = cur.ox + ox, cur.oy + oy
local reach = inReach(destDef, ox, oy)
if cur.hops + 1 <= (hops or 0) or reach then
placed[destId] = true
out[#out + 1] = { id = destId, def = destDef, ox = ox, oy = oy }
if cur.hops + 1 < (hops or 0) or reach then
queue[#queue + 1] = {
id = destId, def = destDef, ox = ox, oy = oy, hops = cur.hops + 1,
}
end
end
end
end
end
end
return out
end
function Map.refreshWorld(game, reachW, reachH, rootId)
rootId = rootId or Map.current
local maps = game and game.data and game.data.maps
if not (rootId and maps) then
Map.world = {}
Map._worldRoot = nil
return Map.world
end
reachW = math.floor(tonumber(reachW) or 0)
reachH = math.floor(tonumber(reachH) or 0)
if Map._worldRoot == rootId
and Map._worldReachW == reachW and Map._worldReachH == reachH then
return Map.world
end
Map.world = Map.computeWorld(maps, rootId, Map.WORLD_HOPS, reachW, reachH,
function(id, def)
Map.ensureMidLayout(game, id, def)
end)
for _, entry in ipairs(Map.world) do
Map._loadedLayouts[entry.id] = true
end
Map._worldRoot = rootId
Map._worldReachW = reachW
Map._worldReachH = reachH
local FieldView = package.loaded["src.core.game3.field_view"]
if FieldView then FieldView._nativeDirty = true end
return Map.world
end
function Map.overscanSlices()
local slices = {}
for dir, n in pairs(Map.neighbors) do
slices[#slices + 1] = { dir = dir, mapId = n.map or n.mapId, offset = n.offset }
end
return slices
end
local function neighborFor(cardinal)
local n = Map.neighbors
if not n then return nil end
-- Dataset keys are north/south/west/east; some callers use up/down/left/right.
if cardinal == "north" or cardinal == "up" then
return n.north or n.up
elseif cardinal == "south" or cardinal == "down" then
return n.south or n.down
elseif cardinal == "west" or cardinal == "left" then
return n.west or n.left
elseif cardinal == "east" or cardinal == "right" then
return n.east or n.right
end
return n[cardinal]
end
--- Resolve a cell in current-map space, sampling connected neighbors when OOB
-- (pret VMap connection fill). Returns mid, sourcePair. OOB with no neighbor
-- falls through to primary border tiling.
function Map.worldMidAt(cx, cy, primaryDef)
local layout = primaryDef and primaryDef.midLayout
if not layout then return 0, nil end
local w, h = layout.width or 0, layout.height or 0
local primaryPair = layout.pair or primaryDef.pair
local function fromNeighbor(n, nx, ny)
if not n or not n.def then return nil end
local L = n.def.midLayout
if not L then return nil end
if nx < 0 or ny < 0 or nx >= (L.width or 0) or ny >= (L.height or 0) then
return nil
end
local pair = L.pair or n.def.pair
return L:midAt(nx, ny), pair or primaryPair
end
if cx >= 0 and cy >= 0 and cx < w and cy < h then
return layout:midAt(cx, cy), primaryPair
end
if cy < 0 then
local n = neighborFor("north")
if n then
local offset = tonumber(n.offset) or 0
local L = n.def and n.def.midLayout
local nh = L and L.height or 0
local mid, pair = fromNeighbor(n, cx - offset, nh + cy)
if mid ~= nil then return mid, pair end
end
elseif cy >= h then
local n = neighborFor("south")
if n then
local offset = tonumber(n.offset) or 0
local mid, pair = fromNeighbor(n, cx - offset, cy - h)
if mid ~= nil then return mid, pair end
end
end
if cx < 0 then
local n = neighborFor("west")
if n then
local offset = tonumber(n.offset) or 0
local L = n.def and n.def.midLayout
local nw = L and L.width or 0
local mid, pair = fromNeighbor(n, nw + cx, cy - offset)
if mid ~= nil then return mid, pair end
end
elseif cx >= w then
local n = neighborFor("east")
if n then
local offset = tonumber(n.offset) or 0
local mid, pair = fromNeighbor(n, cx - w, cy - offset)
if mid ~= nil then return mid, pair end
end
end
for _, entry in ipairs(Map.world) do
local L = entry.def ~= primaryDef and entry.def and entry.def.midLayout
if L then
local nx, ny = cx - entry.ox, cy - entry.oy
if nx >= 0 and ny >= 0 and nx < (L.width or 0) and ny < (L.height or 0) then
return L:midAt(nx, ny), L.pair or entry.def.pair or primaryPair
end
end
end
return layout:midAt(cx, cy), primaryPair, true
end
--- Ensure mapDef.midLayout is bound (lazy; Dataset.hydrate usually did this).
function Map.ensureMidLayout(game, mapId, def)
def = def or host_map_def(game, mapId)
if not def then return nil end
if def.midLayout then return def.midLayout end
local Dataset = require("src.core.game3.dataset")
if Dataset.attachMidLayouts and game and game.data and game.data.maps then
Dataset.attachMidLayouts({ [mapId] = def })
end
return def.midLayout
end
--- Load a Sevii map under game3 ownership (pret enter order).
-- 1) Bind game3 player + collision + EventObjects
-- 2) Objects.loadMap then Space.runEnterScripts (ON_TRANSITION → ON_FRAME)
function Map.load(mod, game, mapId, opts)
opts = opts or {}
if not MapIds.isGame3Map(mapId) then
return nil, "not a game3 map"
end
-- pret RestartWildEncounterImmunitySteps on LoadMap / LoadMapFromWarp: every
-- map entry restarts the wild encounter grace period. Unconditional, so the
-- seamless connection crossing between two routes resets it too.
do
local okE, Encounters = pcall(require, "src.core.game3.encounters")
if okE and Encounters and Encounters.resetRateModifiers then
Encounters.resetRateModifiers()
end
end
local Ghosts = require("src.core.game3.ghosts")
local fromMapId = Map._announced
if Map.current and Map.current ~= mapId then
Ghosts.capture(Map.current)
end
if fromMapId and fromMapId ~= mapId and ModRuntime.wants("map.exited") then
ModRuntime.emit("map.exited", { mapId = fromMapId, toMapId = mapId })
end
Map._announced = mapId
Map.current = mapId
Map._loadedLayouts = { [mapId] = true }
local def = host_map_def(game, mapId)
if not def then
local okD, Dataset = pcall(require, "src.core.game3.dataset")
if okD and Dataset and Dataset.map then
def = Dataset.map(mapId)
end
end
Map.ensureMidLayout(game, mapId, def)
Map._def = def
Map._currentDef = def
-- pokefirered/src/overworld.c:878 GetAdjustedInitialTransitionFlags
local keepBike = false
do
local Player = require("src.core.game3.player")
if Player.biking then
local allowed = def and def.bikingAllowed
if allowed ~= nil then
-- pokefirered/src/overworld.c:948 Overworld_IsBikingAllowed
keepBike = (tonumber(allowed) or 0) ~= 0
else
local pair = def and (def.pair or (def.midLayout and def.midLayout.pair))
keepBike = type(pair) == "string" and pair:find("outdoor", 1, true) ~= nil
end
Player.biking = keepBike
end
end
if opts.depth1Connections ~= false then
Map.loadNeighborsDepth1(game, def)
else
Map.neighbors = {}
end
local world = host_world(game)
local x = tonumber(opts.x) or 0
local y = tonumber(opts.y) or 0
local facing = opts.facing or "down"
local Runtime = require("src.core.game3.runtime")
if not Runtime.isActive or not Runtime.isActive() then
if Runtime.ensureActiveForMap then
Runtime.ensureActiveForMap(mod, game, mapId)
end
end
local session = Runtime.getSession and Runtime.getSession()
if session then
session.map = mapId
session.x = x
session.y = y
session.facing = facing
end
-- Keep save.position current for ferry exit / host save without setMap.
local save = game and game.save
if save then
save.position = save.position or {}
save.position.map = mapId
save.position.x = x
save.position.y = y
save.position.facing = facing
end
local Player = require("src.core.game3.player")
if opts.seamless then
-- Connection remap (pret LoadMapFromCameraTransition): keep mid-step motion.
-- Caller parks one cell before landing and sets target toward landing.
Player.cellX = x
Player.cellY = y
Player.px = x * 16
Player.py = y * 16
Player.facing = facing
else
Player.reset(x, y, facing)
end
-- pokefirered/src/overworld.c:2145 SetPlayerAvatarTransitionFlags
Player.biking = keepBike
Player.syncSavePosition(game)
local okFv, FieldView = pcall(require, "src.core.game3.field_view")
if okFv and FieldView then FieldView._nativeDirty = true end
-- Scripts/events before spawn so Objects.loadMap sees mapDef.objects.
local Space = package.loaded["src.core.game3.scripting.space"]
or require("src.core.game3.scripting.space")
if Space.ensureBundle then Space.ensureBundle(mod or Runtime._mod) end
if def and Space.attachEventsToMaps and game and game.data and game.data.maps then
Space.attachEventsToMaps({ [mapId] = def }, Space.bundle)
end
-- pokefirered/src/fieldmap.c:93
require("src.core.game3.field").clearMetatiles(def and def.midLayout)
local Collision = require("src.core.game3.collision")
if def then Collision.bindMap(game, mapId, def) end
-- pret GroundEffect_SpawnOnTallGrass when warping onto grass.
if not opts.seamless then
local onGrass = Collision.isGrass and Collision.isGrass(Player.cellX, Player.cellY)
local okE, Encounters = pcall(require, "src.core.game3.encounters")
if okE and Encounters and Encounters.noteGrass then
Encounters.noteGrass(onGrass)
end
local okFx, FieldEffects = pcall(require, "src.core.game3.field_effects")
if okFx and FieldEffects then
if onGrass and FieldEffects.tallGrassAt then
FieldEffects.tallGrassAt(Player.cellX, Player.cellY, true)
elseif FieldEffects.clearTallGrass then
FieldEffects.clearTallGrass()
end
end
end
-- Activate scripts (flag store) → spawn destination NPCs → ON_TRANSITION.
-- pret order: never run setobjectxyperm against the previous map's localIds
-- (Pallet sign-lady localId=1 was teleporting Mom on FR_PLAYERS_HOUSE_1F).
local Field = require("src.core.game3.field")
if not opts.seamless then
Field.lock()
end
local Objects = require("src.core.game3.objects")
-- pokefirered/src/overworld.c:800
if fromMapId and (fromMapId ~= mapId or opts.heal) then
require("src.core.game3.vs_seeker").mapReset(session)
end
if Space and Space.activate then
Space.activate(mod or Runtime._mod, mapId, game, world)
end
-- pokefirered/src/overworld.c:805
local savedFlash = session and tonumber(session.flashLevel)
if okFv and FieldView and FieldView.setDefaultFlashLevel then
FieldView.setDefaultFlashLevel(game, mapId)
-- pokefirered/src/overworld.c:1691 CB2_ContinueSavedGame
if savedFlash and (opts.enterVia or Map._nextEnterVia) == "continue" then
FieldView.setFlashLevel(savedFlash)
end
end
if def then
Objects.loadMap(game, mapId, def)
Ghosts.adopt(mapId)
end
-- pokefirered/src/overworld.c:806
if not opts.seamless then
require("src.core.game3.audio").setSavedSong(nil)
end
if fromMapId == mapId then
if opts.reason and ModRuntime.wants("map.reloaded") then
ModRuntime.emit("map.reloaded", { mapId = mapId, reason = opts.reason or "reload" })
end
elseif ModRuntime.wants("map.entered") then
ModRuntime.emit("map.entered", {
mapId = mapId, map = def, fromMapId = fromMapId,
via = opts.via or (opts.seamless and "connection")
or (opts.heal and "respawn") or (fromMapId and "warp" or "boot"),
})
end
-- pokefirered/src/overworld.c:1717
local enterVia = opts.enterVia or Map._nextEnterVia
Map._nextEnterVia = nil
if Space and Space.runEnterScripts then
Space.runEnterScripts(mod or Runtime._mod, mapId, game, world,
{ seamless = opts.seamless, enterVia = enterVia })
elseif Space and Space.onMapEnter then
Space.onMapEnter(mod or Runtime._mod, mapId, game, world)
end
-- Map BGM from extract index / header music.
do
local Audio = require("src.core.game3.audio")
local music = def and def.music
if not music and Audio._pack and Audio._pack.index and Audio._pack.index.mapSongs then
music = Audio._pack.index.mapSongs[mapId]
end
if music and music ~= 0xFFFF then
-- pokefirered/src/overworld.c:1039
local id = (not opts.seamless and Audio._savedSong) or music
Audio.playMapSong(id, { mapSong = music })
end
end
-- Location change overlay (pokefirered/src/overworld.c:785, 1687, 1922)
-- Strict arbiter: gMapHeader.showMapName == TRUE. If 0/false, strictly suppress popup.
-- overworld.c:1913 gives a changed map section with a FOREST preview screen
-- precedence over the popup; pret gates that on a real warp, not a connection.
do
local currSec = def and (def.regionMapSectionId or def.region_map_section_id)
local lastSec = Map._lastSectionId
local showFlag = def and (def.showMapName or def.show_map_name)
local previewed = false
if currSec and not opts.seamless and lastSec ~= currSec then
local okPreview, MapPreviewScreen = pcall(require, "src.ui.game3.map_preview_screen")
if okPreview and MapPreviewScreen then
MapPreviewScreen.dismiss()
previewed = MapPreviewScreen.show(currSec) == true
end
end
local okPop, MapNamePopup = pcall(require, "src.ui.game3.map_name_popup")
if okPop and MapNamePopup then
if previewed then
MapNamePopup.dismiss()
elseif showFlag == 0 or showFlag == false then
MapNamePopup.dismiss()
elseif showFlag == 1 or showFlag == true then
if lastSec == nil or lastSec ~= currSec or not opts.seamless then
MapNamePopup.show(def)
end
end
end
if def and def.regionMapSectionId then
Map._lastSectionId = def.regionMapSectionId
end
end
if not opts.seamless then
if not Space._pendingOnFrame
and not (Space.vm and Space.vm.isRunning and Space.vm:isRunning()) then
Field.unlock()
end
end
return {
mapId = mapId,
neighbors = Map.neighbors,
overscan = Map.overscanSlices(),
}
end
function Map.loadedLayoutCount()
local n = 0
for _ in pairs(Map._loadedLayouts) do n = n + 1 end
return n
end
return Map