Files
gen1recomp/src/world/WorldAPI.lua
T
2026-08-10 23:25:54 +02:00

372 lines
14 KiB
Lua

-- 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 Assets = require("src.render.Assets")
local MapLoader = require("src.world.MapLoader")
local Party = require("src.pokemon.Party")
local Runtime = require("src.mods.Runtime")
local WorldAPI = {}
WorldAPI.__index = WorldAPI
local NO_OVERWORLD = "no overworld"
local overviewShades = {}
Assets.register(function() overviewShades = {} end)
local function shadeDigit(sum, pixelCount)
return tostring(math.max(0, math.min(3,
math.floor((1 - sum / pixelCount) * 3 + 0.5))))
end
local function mapTileRows(map)
local tileset = map.tileset
if not (tileset and tileset.image and tileset.tilesPerRow) then return nil end
local cached = overviewShades[tileset.image]
if not cached then
local ok, pixels = pcall(Assets.imageData, tileset.image)
if not ok then return nil end
cached = { pixels = pixels, shades = {} }
overviewShades[tileset.image] = cached
end
local rows, detailRows, perRow = {}, {}, tileset.tilesPerRow
for ty = 0, map.heightCells * 2 - 1 do
local row, detailTop, detailBottom = {}, {}, {}
for tx = 0, map.widthCells * 2 - 1 do
local tile = map:tileAt(tx, ty)
local shades = cached.shades[tile]
if shades == nil then
local sums = { 0, 0, 0, 0 }
local ox, oy = (tile % perRow) * 8, math.floor(tile / perRow) * 8
for py = 0, 7 do
for px = 0, 7 do
local r, g, b = cached.pixels:getPixel(ox + px, oy + py)
local quadrant = math.floor(py / 4) * 2 + math.floor(px / 4) + 1
sums[quadrant] = sums[quadrant]
+ r * 0.2126 + g * 0.7152 + b * 0.0722
end
end
shades = {
shadeDigit(sums[1] + sums[2] + sums[3] + sums[4], 64),
shadeDigit(sums[1], 16), shadeDigit(sums[2], 16),
shadeDigit(sums[3], 16), shadeDigit(sums[4], 16),
}
cached.shades[tile] = shades
end
row[#row + 1] = shades[1]
detailTop[#detailTop + 1] = shades[2] .. shades[3]
detailBottom[#detailBottom + 1] = shades[4] .. shades[5]
end
rows[#rows + 1] = table.concat(row)
detailRows[#detailRows + 1] = table.concat(detailTop)
detailRows[#detailRows + 1] = table.concat(detailBottom)
end
return rows, detailRows
end
local function acceptsMenuInput(game, ow)
local stack = game and game.stack
local runner = ow and ow.runner
return ow and stack and stack.top and stack:top() == ow
and not ow.transitioning and not ow.flyAnim and not ow.teleportOut
and not ow.engaging and not ow.emote and not ow.pikaHop and not ow.healAnim
and not (ow.player and (ow.player.moving or ow.player.inputLocked))
and not (runner and runner.isRunning and runner:isRunning())
and #(ow.scriptMoves or {}) == 0
end
local function validPartySlot(party, slot)
return type(slot) == "number" and slot == math.floor(slot)
and party[slot] ~= nil
end
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
-- Companion UIs may offer party ordering while the player is in free roam.
-- The same guard that makes opening a menu safe keeps scripts, transitions,
-- movement and screens above the overworld from observing a mid-action swap.
function WorldAPI:canReorderParty()
local game, ow = self.game, self:overworld()
local party = game and game.save and game.save.party or {}
return #party > 1 and not not acceptsMenuInput(game, ow)
end
function WorldAPI:reorderParty(fromSlot, toSlot)
local game, ow = self.game, self:overworld()
if not ow then return nil, NO_OVERWORLD end
if not acceptsMenuInput(game, ow) then return nil, "world is busy" end
local party = game.save and game.save.party or {}
if not validPartySlot(party, fromSlot)
or not validPartySlot(party, toSlot) then
return nil, "invalid party slot"
end
if fromSlot ~= toSlot then
party[fromSlot], party[toSlot] = party[toSlot], party[fromSlot]
require("src.core.Sound").play(game.data, "Swap")
end
return true
end
-- A compact, read-only view of the active map for minimaps and companion UIs.
-- `rows` describes collision terrain; optional `tileRows` reduces each real
-- 8x8 map tile to its average Game Boy shade ("0" lightest, "3" darkest).
-- `tileDetailRows` preserves one shade per 4x4 quadrant. Markers identify
-- exits and item spots that are still active without exposing world internals.
function WorldAPI:mapOverview()
local ow = self:overworld()
if not ow or not ow.map then return nil, NO_OVERWORLD end
local map, rows, markers = ow.map, {}, {}
for y = 0, map.heightCells - 1 do
local row = {}
for x = 0, map.widthCells - 1 do
row[#row + 1] = map:isWarpTileCell(x, y) and "+"
or map:isWaterCell(x, y) and "~"
or map:isWalkableCell(x, y) and "." or " "
end
rows[#rows + 1] = table.concat(row)
end
local def = map.def or {}
for _, warp in ipairs(def.warps or {}) do
markers[#markers + 1] = { kind = "warp", x = warp.x, y = warp.y }
end
local game, save = self.game, self.game.save or {}
for _, obj in ipairs(def.objects or {}) do
if obj.item and obj.item ~= "0" and obj.item ~= 0
and ow.objectVisible(save, map.id, obj) then
markers[#markers + 1] = { kind = "item", x = obj.x, y = obj.y }
end
end
local hidden = game.data and game.data.field and game.data.field.hiddenItems
for _, item in ipairs(hidden and hidden[map.id] or {}) do
local key = map.id .. "_" .. item.x .. "_" .. item.y
if not (save.hiddenTaken and save.hiddenTaken[key]) then
markers[#markers + 1] = { kind = "hidden", x = item.x, y = item.y }
end
end
local tileRows, tileDetailRows = mapTileRows(map)
return { mapId = map.id, width = map.widthCells,
height = map.heightCells, rows = rows, markers = markers,
tileRows = tileRows,
tileWidth = tileRows and map.widthCells * 2,
tileHeight = tileRows and map.heightCells * 2,
tileDetailRows = tileDetailRows,
tileDetailWidth = tileDetailRows and map.widthCells * 4,
tileDetailHeight = tileDetailRows and map.heightCells * 4 }
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
-- The supported way to start a wild encounter. Hand-rolling this -- build a
-- BattleState, push it -- silently costs evolutions and blackout-on-loss
-- (both hang off onFinish -> afterBattle) plus the entry wipe and battle
-- theme (both owned by pushBattle). Nothing raises when they are missing.
function WorldAPI:startWildBattle(species, level)
local ow = self:overworld()
if not ow then return nil, NO_OVERWORLD end
if not self.game.data.pokemon[species] then
return nil, "unknown species: " .. tostring(species)
end
-- Pokemon.new writes the level through verbatim -- into level, the stat
-- calc and the exp curve -- so a fraction has to be refused here rather
-- than round somewhere downstream. The % test also catches NaN, which
-- passes both range comparisons.
level = tonumber(level)
if not level or level % 1 ~= 0 or level < 1 or level > 100 then
return nil, "level must be a whole number 1..100"
end
-- overworld() resolves the world from UNDER whatever sits on top of it,
-- so from a battle hook this would otherwise stack a second battle over
-- the live one -- and on a loss its afterBattle blacks out and warps
-- with the outer battle still on the stack.
local BattleTransition = require("src.render.BattleTransition")
for _, state in ipairs(self.game.stack and self.game.stack.states or {}) do
if state.awardExp or getmetatable(state) == BattleTransition then
return nil, "a battle is already running"
end
end
if ow.transitioning then return nil, "the world is mid-warp" end
-- BattleState.newWild marks the species SEEN before it reports an empty
-- party, so the party check comes first: a refused call must not leave a
-- Pokedex entry behind.
local save = self.game.save
if not (save and Party.firstHealthy(save.party or {})) then
return nil, "no healthy party"
end
local battle = require("src.battle.BattleState")
.newWild(self.game, species, level)
battle.onFinish = function(result) ow:afterBattle(result, battle) end
ow:pushBattle(battle)
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