Files
gen1recomp/src/world/gen2/WorldAPI.lua
T
2026-08-14 21:39:18 +02:00

432 lines
16 KiB
Lua

-- mod.world for Gen 2 (Gold): the same facade src/world/WorldAPI.lua gives a
-- mod under Gen 1, resolved against src/world/gen2/World.lua instead of the
-- Gen 1 overworld state. One name, one method set, two arms -- a mod that
-- declares gen2compat calls mod.world:current() and does not care which game
-- it is running on.
--
-- Two structural differences show through, and both are reported rather than
-- faked:
--
-- * Gold's World is not a stack state. It hangs off the service owner as
-- game.world for the whole run, so there is no stack scan here; a menu or
-- a battle pushed over the world does not hide it.
--
-- * Gen 2 event flags are NUMBERS (wEventFlags is a bitfield, and the cart's
-- EVENT_* constants are indices into it), where Gen 1 flags are string
-- keys in save.flags. setFlag/getFlag therefore take a numeric id here
-- and say so when handed a string, rather than silently writing a key the
-- bitfield cannot hold.
--
-- Anything Gen 2 has no equivalent for at all returns nil plus a reason, the
-- same shape the Gen 1 arm uses for "no overworld". A dual-generation mod
-- checks the second return and degrades; it never crashes and never gets a
-- silent no-op.
local Logger = require("src.core.Logger")
local Movement = require("src.script.gen2.Movement")
local Runtime = require("src.mods.Runtime")
local HiddenItems = require("src.world.gen2.HiddenItems")
local MapOverview = require("src.world.MapOverview")
local Bike = require("src.world.gen2.Bike")
local FieldMoves = require("src.world.gen2.FieldMoves")
local Permissions = require("src.world.gen2.Permissions")
local WorldAPI = {}
WorldAPI.__index = WorldAPI
local NO_OVERWORLD = "no overworld"
local RODS = { "OLD_ROD", "GOOD_ROD", "SUPER_ROD" }
function WorldAPI.new(game, modId)
return setmetatable({ game = game, modId = modId }, WorldAPI)
end
-- the live World, or nil while the boot cinema is still up
function WorldAPI:overworld()
local world = self.game and self.game.world
if world and world.map then return world end
return nil
end
function WorldAPI:current()
local world = self:overworld()
if not world or not world.map then return nil, NO_OVERWORLD end
local p = world.player
return { mapId = world.map.id, x = p and p.cellX, y = p and p.cellY,
facing = p and p.facing }
end
local function itemLabel(game, id)
local def = game and game.data and game.data.items
and game.data.items[id]
return (def and def.name) or id
end
-- The same field-item contract as Gen 1, resolved through Gold's own bike,
-- collision and fishing rules.
function WorldAPI:availableFieldActions()
local world, game, out = self:overworld(), self.game, {}
if not (world and game and game.save and world.map and world.player)
or not world:acceptsMenuInput() then return out end
local inventory = game.save.inventory or {}
if (inventory.BICYCLE or 0) > 0 then
local bike = Bike.tryBike({
state = world.playerState,
environment = world.map.def and world.map.def.environment,
collision = world:playerCollision(),
alwaysOnBike = world:alwaysOnBike(),
})
if bike == "mount" or bike == "dismount" then
out[#out + 1] = { id = "bicycle",
label = bike == "dismount" and "BIKE OFF" or "BICYCLE" }
end
end
local context = world:fieldContext()
if not FieldMoves.isSurfing(world.playerState)
and Permissions.isWater(context.facingColl) then
local rods = {}
for _, id in ipairs(RODS) do
if (inventory[id] or 0) > 0 then
rods[#rods + 1] = { id = id, label = itemLabel(game, id) }
end
end
if #rods > 0 then
out[#out + 1] = { id = "fish", label = "FISH", rods = rods }
end
end
return out
end
function WorldAPI:useFieldAction(id, opts)
local world = self:overworld()
if not world then return nil, NO_OVERWORLD end
if not world:acceptsMenuInput() then return nil, "world is busy" end
local found
for _, action in ipairs(self:availableFieldActions()) do
if action.id == id then found = action break end
end
if not found then return nil, "field action unavailable" end
if id == "bicycle" then
local outcome = world:useFieldItem("BICYCLE")
if outcome and outcome ~= "nowhere" then return true end
elseif id == "fish" then
local rod = opts and opts.rod
if not rod and #found.rods == 1 then rod = found.rods[1].id end
for _, choice in ipairs(found.rods) do
if choice.id == rod then
local outcome = world:useFieldItem(rod)
if outcome and outcome ~= "nowhere" then return true end
break
end
end
return nil, "fishing rod unavailable"
end
return nil, "field action unavailable"
end
-- The same read-only minimap contract as Gen 1, with Gold's object/event
-- visibility rules supplying the semantic markers.
function WorldAPI:mapOverview()
local world = self:overworld()
if not world or not world.map then return nil, NO_OVERWORLD end
local map, def, markers = world.map, world.map.def or {}, {}
for _, warp in ipairs(def.warps or {}) do
markers[#markers + 1] = { kind = "warp", x = warp.x, y = warp.y }
end
local visible = {}
for _, npc in ipairs(world.npcs or {}) do
if npc.def then visible[npc.def] = true end
end
for _, obj in ipairs(def.objects or {}) do
local item = obj.itemball and obj.itemball.item
if item and item ~= "0" and item ~= 0 and visible[obj] then
markers[#markers + 1] = { kind = "item", x = obj.x, y = obj.y }
end
end
for _, item in ipairs(HiddenItems.unfound(def, world.events)) do
markers[#markers + 1] = { kind = "hidden", x = item.x, y = item.y }
end
return MapOverview.build(map, markers)
end
-- opts is accepted for signature parity with the Gen 1 arm; Gold's arrival FX
-- come from the map setup method, so opts.arrive has nothing to select yet.
function WorldAPI:warpTo(mapId, x, y, facing, opts)
local world = self:overworld()
if not world then return nil, NO_OVERWORLD end
if not (world.maps and world.maps[mapId]) then
return nil, "unknown map: " .. tostring(mapId)
end
if not (x and y) then return nil, "warpTo needs x and y" end
local ok = world:warpToMapId(mapId, x, y,
facing or (world.player and world.player.facing) or "down")
if not ok then return nil, world.status or "warp failed" end
return true
end
-- Gen 2 has no save.objectToggles: an object's visibility IS its
-- MAPOBJECT_EVENT_FLAG, which lives in the event bitfield and is therefore
-- already persistent and already re-read by the next LoadObjectMasks. Setting
-- the flag is the whole operation; appear/disappear additionally take it off
-- the live map when the map is the active one.
--
-- objRef is the object's 1-based index in the map's object list, or its name
-- when the extracted map carries one.
function WorldAPI:toggleObject(mapId, objRef, visible)
local world = self:overworld()
if not world then return nil, NO_OVERWORLD end
if not (world.map and world.map.id == mapId) then
-- the flag is per object, and the object list only resolves for a loaded
-- map, so an off-map toggle has nothing to name
return nil, "map is not active: " .. tostring(mapId)
end
local def = world.map.def
local objects = def and def.objects
if not objects then return nil, "map has no objects" end
local index
for i, obj in ipairs(objects) do
if i == objRef or obj.name == objRef then
-- def.objects is keyed by the object's own index, and World:objectEntity
-- reads it back as objectId - 1, so the id is that key plus one
index = obj.index or i
break
end
end
if not index then
return nil, "no such object: " .. tostring(objRef)
end
local objectId = index + 1
if visible then world:appearObject(objectId) else world:disappearObject(objectId) end
Runtime.emit("world.object_toggled",
{ mapId = mapId, objName = objRef, visible = visible and true or false })
return true
end
-- id is a numeric EVENT_* index into wEventFlags. A string is the Gen 1
-- habit and cannot work here, so it is refused with the reason rather than
-- stored somewhere the engine never looks.
local function flagId(id)
if type(id) == "number" then return id end
return nil, ("Gen 2 event flags are numeric ids, got %s (%s)")
:format(type(id), tostring(id))
end
function WorldAPI:setFlag(id, value)
local world = self:overworld()
if not world or not world.events then return nil, NO_OVERWORLD end
local numeric, err = flagId(id)
if not numeric then return nil, err end
world.events:set(numeric, value and true or false)
return true
end
function WorldAPI:getFlag(id)
local world = self:overworld()
if not world or not world.events then return nil, NO_OVERWORLD end
local numeric, err = flagId(id)
if not numeric then return nil, err end
return world.events:get(numeric)
end
-- active map only, same contract as the Gen 1 arm: this mutates the loaded
-- block data and rebuilds the view. `block` is a block id.
function WorldAPI:replaceBlock(bx, by, block)
local world = self:overworld()
if not world or not world.map then return nil, NO_OVERWORLD end
world:changeBlock(bx, by, block)
return true
end
local UNSUPPORTED = "not supported in Gen 2 yet"
-- objDef uses the same shape as an extracted map's objects list (sprite, x, y,
-- movement, hours, ...), which is the Gen 1 arm's contract too. Runtime
-- objects are not serialized; a mod respawns them on map.entered.
function WorldAPI:spawnNpc(mapId, objDef)
local world = self:overworld()
if not world 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 world:addRuntimeObject(mapId, copy, self.modId)
end
function WorldAPI:removeNpc(npcId)
local world = self:overworld()
if not world then return nil, NO_OVERWORLD end
return world:removeRuntimeObject(npcId, self.modId)
end
-- a handle onto a live NPC. Scripted movement does carry over: it compiles to
-- the cart's own movement stream and rides World:beginMovement, the same path
-- an `applymovement` in a map script takes -- so a mod's walk is frozen,
-- stepped and released exactly like a scripted one. Only spawning does not.
local Handle = {}
Handle.__index = Handle
-- One movement stream at a time is the engine's own limit (World.moveState is
-- a single slot), so a second call while one is running is refused rather than
-- silently replacing the first and stranding its onDone.
function Handle:scriptMove(dir, tiles, onDone)
local world = self.world
if world.moveState then return nil, "a movement is already running" end
local step = Movement.stepByte(dir)
if not step then return nil, "unknown direction: " .. tostring(dir) end
local bytes = {}
for _ = 1, math.max(0, tiles or 1) do bytes[#bytes + 1] = step end
bytes[#bytes + 1] = Movement.STEP_END
world:beginMovement(self.objectId, bytes, onDone)
return true
end
-- Gen 1's marchInPlace is step_sleep-with-animation; the Gen 2 stream has no
-- single byte for it, and faking one out of turn bytes would march the wrong
-- way. Left explicit rather than approximated.
function Handle:marchInPlace()
return nil, UNSUPPORTED
end
function Handle:face(dir)
self.npc:scriptFace(dir)
return true
end
function Handle:position()
return self.npc.cellX, self.npc.cellY
end
function WorldAPI:npc(mapId, indexOrName)
local world = self:overworld()
if not world then return nil, NO_OVERWORLD end
if world.map and world.map.id ~= mapId then return nil, "map is not active" end
for _, npc in ipairs(world.npcs or {}) do
local def = npc.def
if def and (def.index == indexOrName or def.name == indexOrName) then
return setmetatable(
{ world = world, npc = npc, objectId = (def.index or 0) + 1 }, Handle)
end
end
return nil, "no such object: " .. tostring(indexOrName)
end
-- The Gen 2 VM runs the cart's own bytecode out of data/generated/scripts.lua,
-- not the Gen 1 runner's `{ "command", ... }` rows, so there is no row list to
-- hand it. What a mod actually reaches for out of that vocabulary is a small
-- set of verbs that Gold has its own entry points for, so those are driven
-- directly here, one row at a time, and anything else is refused BY NAME
-- before the first row runs -- a half-run queue is the failure mode this
-- facade exists to avoid. The full list is src/script/Commands.lua; these
-- five are the ones with a Gen 2 home.
local VERBS = {}
-- start_battle "wild" species level. Gold's own grass step ends in
-- World:startBattle with a Mon (src/world/gen2/World.lua:4021), so this is
-- that call with the mon built from the mod's species and level. The trainer
-- arm needs a party out of the extracted trainer table and an OPP_CLASS the
-- mod cannot name, so only the wild arm is served.
function VERBS.start_battle(api, row, resume)
local world = api:overworld()
local kind = row[2]
if kind ~= "wild" then
return nil, "only start_battle \"wild\" is supported in Gen 2"
end
local game = world.game
local mon = require("src.battle.gen2.Mon").new(
game and game.data, row[3], tonumber(row[4]) or 5)
if not mon then return nil, "unknown species: " .. tostring(row[3]) end
local save = game and game.save
if save then
save.pokedex = save.pokedex or { seen = {}, caught = {} }
save.pokedex.seen[mon.species] = true
end
world:startBattle({ wild = mon }, function() resume() end)
return true
end
function VERBS.warp(api, row, resume)
local ok, err = api:warpTo(row[2], row[3], row[4], row[5])
if not ok then return nil, err end
resume()
return true
end
function VERBS.text(api, row, resume)
local world = api:overworld()
world:showText(tostring(row[2] or ""), function() resume() end)
return true
end
function VERBS.setflag(api, row, resume)
local ok, err = api:setFlag(row[2], true)
if not ok then return nil, err end
resume()
return true
end
function VERBS.clearflag(api, row, resume)
local ok, err = api:setFlag(row[2], false)
if not ok then return nil, err end
resume()
return true
end
-- Rows run in order, each one resuming the next from its own completion
-- callback, so a battle or a text box blocks the queue the way it blocks the
-- Gen 1 runner's coroutine. One queue at a time, for the reason
-- Handle:scriptMove refuses a second movement.
function WorldAPI:queueScript(rows, extra)
local world = self:overworld()
if not world then return nil, NO_OVERWORLD end
if type(rows) ~= "table" then return nil, "queueScript wants a row list" end
if self.queue then return nil, "a script is already running" end
for i, row in ipairs(rows) do
local name = type(row) == "table" and row[1]
if not VERBS[name] then
return nil, ("unsupported script command in Gen 2: %s (row %d)")
:format(tostring(name), i)
end
end
self.queue = true
local pc = 0
local step
local function finish(err)
self.queue = nil
if err then
Logger.warn("[%s] queueScript stopped: %s", tostring(self.modId), err)
end
if extra and extra.onDone then extra.onDone(err == nil) end
end
step = function()
pc = pc + 1
local row = rows[pc]
if not row then return finish(nil) end
local ok, err = VERBS[row[1]](self, row, function() step() end)
if not ok then finish(err or "row failed") end
end
step()
return true
end
-- Gold's maps come from one table loaded at World:load, so there is no
-- per-map instance cache to drop. Reloading the active map is the part that
-- carries meaning, and reloadMapBadWarp is exactly the cart's own
-- "load this map again where you stand" (MAPSETUP_BADWARP).
function WorldAPI:invalidateMap(mapId)
local world = self:overworld()
if not world then return nil, NO_OVERWORLD end
if not (world.map and world.map.id == mapId) then return true end
local ok, err = pcall(world.reloadMapBadWarp, world)
if not ok then
Logger.warn("[%s] invalidateMap %s failed: %s", tostring(self.modId),
tostring(mapId), tostring(err))
return nil, tostring(err)
end
Runtime.emit("map.reloaded", { mapId = mapId, reason = "invalidate" })
return true
end
return WorldAPI