-- mod.world: the supported way for mod code to act on the running -- overworld. Every method resolves the live OverworldState by scanning -- the state stack for the isOverworld marker and returns nil, "no -- overworld" when none is up -- called from the title screen this is a -- quiet no-op, never a crash. Reaching into OverworldState internals -- stays unsupported; anything a mod legitimately needs belongs here. local Logger = require("src.core.Logger") local MapLoader = require("src.world.MapLoader") local Runtime = require("src.mods.Runtime") local WorldAPI = {} WorldAPI.__index = WorldAPI local NO_OVERWORLD = "no overworld" function WorldAPI.new(game, modId) return setmetatable({ game = game, modId = modId }, WorldAPI) end -- the live overworld, or nil. Game.overworld is the fast path; the stack -- scan is the authority, so a state pushed over the world (a battle, a -- menu) still resolves to the world underneath it. function WorldAPI:overworld() local game = self.game local stack = game and game.stack local states = stack and stack.states if states then for i = #states, 1, -1 do if states[i].isOverworld then return states[i] end end end local ow = game and game.overworld if ow and ow.isOverworld and ow.map then return ow end return nil end function WorldAPI:current() local ow = self:overworld() if not ow or not ow.map then return nil, NO_OVERWORLD end local p = ow.player return { mapId = ow.map.id, x = p and p.cellX, y = p and p.cellY, facing = p and p.facing } end -- opts.arrive = "fly" | "teleport" picks the arrival FX; anything else -- lands the player without one, like a scripted warp. function WorldAPI:warpTo(mapId, x, y, facing, opts) local ow = self:overworld() if not ow then return nil, NO_OVERWORLD end if not self.game.data.maps[mapId] then return nil, "unknown map: " .. tostring(mapId) end if opts and (opts.arrive == "fly" or opts.arrive == "teleport") then ow.arriveWarp = opts.arrive end ow:startWarpTo(mapId, x, y, facing or "down", opts and opts.onDone, { via = "warp", keepMusic = opts and opts.keepMusic }) return true end -- save.objectToggles is the same store the spawn filter reads, so a toggle -- on an inactive map takes effect the next time it is entered. function WorldAPI:toggleObject(mapId, objName, visible) local save = self.game and self.game.save if not save then return nil, "no save" end save.objectToggles = save.objectToggles or {} save.objectToggles[mapId] = save.objectToggles[mapId] or {} save.objectToggles[mapId][objName] = visible and true or false Runtime.emit("world.object_toggled", { mapId = mapId, objName = objName, visible = visible and true or false }) local ow = self:overworld() if ow and ow.map and ow.map.id == mapId then ow:setMap(mapId, ow.player.cellX, ow.player.cellY, ow.player.facing, { seamless = true, via = "reload", keepMusic = true }) end return true end function WorldAPI:setFlag(name, value) local save = self.game and self.game.save if not save or not save.flags then return nil, "no save" end save.flags[name] = value return true end function WorldAPI:getFlag(name) local save = self.game and self.game.save return save and save.flags and save.flags[name] end -- active map only: this mutates the runtime Map and rebuilds the renderer. -- A layout change that must survive a reload belongs in a maps patch. function WorldAPI:replaceBlock(bx, by, block) local ow = self:overworld() if not ow or not ow.map then return nil, NO_OVERWORLD end ow:replaceBlock(bx, by, block) return true end -- objDef uses the same shape as maps[].objects. Runtime objects are not -- serialized: a permanent NPC belongs in a maps patch, this is for -- scripted and dynamic actors the mod re-spawns on map.entered. function WorldAPI:spawnNpc(mapId, objDef) local ow = self:overworld() if not ow then return nil, NO_OVERWORLD end if type(objDef) ~= "table" then return nil, "objDef must be a table" end local copy = {} for k, v in pairs(objDef) do copy[k] = v end return ow:addRuntimeObject(mapId, copy, self.modId) end function WorldAPI:removeNpc(npcId) local ow = self:overworld() if not ow then return nil, NO_OVERWORLD end return ow:removeRuntimeObject(npcId, self.modId) end -- a handle onto a live NPC: scriptMove / marchInPlace / face, which is -- everything the scripted-movement queue exposes local Handle = {} Handle.__index = Handle function Handle:scriptMove(dir, tiles, onDone) self.ow:scriptMove(self.npc, dir, tiles or 1, onDone) return true end function Handle:marchInPlace(onDone) self.ow:marchInPlace(self.npc, onDone) return true end function Handle:face(dir) self.npc.facing = dir return true end function Handle:position() return self.npc.cellX, self.npc.cellY end function WorldAPI:npc(mapId, indexOrName) local ow = self:overworld() if not ow then return nil, NO_OVERWORLD end if ow.map and ow.map.id ~= mapId then return nil, "map is not active" end for _, npc in ipairs(ow.npcs or {}) do if npc.def.index == indexOrName or npc.def.name == indexOrName or npc.id == indexOrName then return setmetatable({ ow = ow, npc = npc, id = npc.id }, Handle) end end return nil, "no such object: " .. tostring(indexOrName) end -- FIFO queueing is owned by the script runner; until it lands this runs -- the rows when nothing else is running and refuses otherwise, so a mod -- never silently loses a script. function WorldAPI:queueScript(rows, extra) local ow = self:overworld() if not ow or not ow.runner then return nil, NO_OVERWORLD end if ow.runner:isRunning() then return nil, "a script is already running" end ow.runner:run(rows, extra) return true end -- drop a map's cached instance so the next load re-reads its record; when -- it is the active map the world reloads around the player in place function WorldAPI:invalidateMap(mapId) local ow = self:overworld() if not ow then local had = MapLoader.invalidate(mapId) Runtime.emit("map.reloaded", { mapId = mapId, reason = "invalidate" }) return had end local ok, err = pcall(ow.reloadMap, ow, mapId, "invalidate") if not ok then Logger.warn("[%s] invalidateMap %s failed: %s", tostring(self.modId), tostring(mapId), tostring(err)) return nil, tostring(err) end return true end return WorldAPI