feat(mods): expose map overviews in Gold

This commit is contained in:
AverageConsumer
2026-08-13 22:08:00 +02:00
parent 0b96ac0ab6
commit c5192eaea4
7 changed files with 166 additions and 75 deletions
+2
View File
@@ -474,6 +474,8 @@ Generation-agnostic; nothing to adapt.
(`src/world/gen2/WorldAPI.lua`). Two differences show through and are (`src/world/gen2/WorldAPI.lua`). Two differences show through and are
documented on the module: Gold's world is not a stack state, and Gen 2 event documented on the module: Gold's world is not a stack state, and Gen 2 event
flags are numeric ids into `wEventFlags` rather than string keys. flags are numeric ids into `wEventFlags` rather than string keys.
`mapOverview` returns the same read-only terrain, tile-shading, and marker
shape, using Gold's live object masks and event flags to omit collected items.
`spawnNpc` / `removeNpc` append onto the map def's own object list, the way the `spawnNpc` / `removeNpc` append onto the map def's own object list, the way the
Gen 1 arm does, so a spawned actor is pooled, drawn, walked and talked to like Gen 1 arm does, so a spawned actor is pooled, drawn, walked and talked to like
an extracted one and survives a map reload; it is not serialized, so a mod an extracted one and survives a map reload; it is not serialized, so a mod
+2 -1
View File
@@ -66,7 +66,8 @@ optional visual `tileRows` at 2x resolution, and optional `tileDetailRows` at
`"3"` (darkest); their matching width and height fields describe the grid. `"3"` (darkest); their matching width and height fields describe the grid.
`markers` contains active `{ kind, x, y }` points in map-cell coordinates for `markers` contains active `{ kind, x, y }` points in map-cell coordinates for
`warp`, visible `item`, and untaken `hidden` locations. All fields are `warp`, visible `item`, and untaken `hidden` locations. All fields are
read-only snapshots; mods choose which layers to render. read-only snapshots; mods choose which layers to render. Red and Gold expose
the same contract while applying their own object and event visibility rules.
## Party ordering ## Party ordering
+1 -1
View File
@@ -736,7 +736,7 @@ COVERAGE["src.pokemon.Boxes"] = {
COVERAGE["src.world.WorldAPI"] = { COVERAGE["src.world.WorldAPI"] = {
kind = "alias", target = "src.world.gen2.WorldAPI", kind = "alias", target = "src.world.gen2.WorldAPI",
backed = "new __index overworld current warpTo toggleObject replaceBlock " backed = "new __index overworld current mapOverview warpTo toggleObject replaceBlock "
.. "spawnNpc removeNpc npc queueScript invalidateMap", .. "spawnNpc removeNpc npc queueScript invalidateMap",
warned = "setFlag getFlag", warned = "setFlag getFlag",
absent = "", absent = "",
+84
View File
@@ -0,0 +1,84 @@
-- Generation-neutral minimap rasterization. WorldAPI arms own semantic
-- markers because object/event visibility differs; terrain and tile shading
-- share one output contract.
local Assets = require("src.render.Assets")
local MapOverview = {}
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 tileRows(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
function MapOverview.build(map, markers)
local rows = {}
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 tiles, detail = tileRows(map)
return { mapId = map.id, width = map.widthCells,
height = map.heightCells, rows = rows, markers = markers,
tileRows = tiles,
tileWidth = tiles and map.widthCells * 2,
tileHeight = tiles and map.heightCells * 2,
tileDetailRows = detail,
tileDetailWidth = detail and map.widthCells * 4,
tileDetailHeight = detail and map.heightCells * 4 }
end
return MapOverview
+3 -73
View File
@@ -6,8 +6,8 @@
-- stays unsupported; anything a mod legitimately needs belongs here. -- stays unsupported; anything a mod legitimately needs belongs here.
local Logger = require("src.core.Logger") local Logger = require("src.core.Logger")
local Assets = require("src.render.Assets")
local MapLoader = require("src.world.MapLoader") local MapLoader = require("src.world.MapLoader")
local MapOverview = require("src.world.MapOverview")
local Party = require("src.pokemon.Party") local Party = require("src.pokemon.Party")
local Runtime = require("src.mods.Runtime") local Runtime = require("src.mods.Runtime")
@@ -15,59 +15,6 @@ local WorldAPI = {}
WorldAPI.__index = WorldAPI WorldAPI.__index = WorldAPI
local NO_OVERWORLD = "no overworld" 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 function acceptsMenuInput(game, ow)
local stack = game and game.stack local stack = game and game.stack
@@ -147,16 +94,7 @@ end
function WorldAPI:mapOverview() function WorldAPI:mapOverview()
local ow = self:overworld() local ow = self:overworld()
if not ow or not ow.map then return nil, NO_OVERWORLD end if not ow or not ow.map then return nil, NO_OVERWORLD end
local map, rows, markers = ow.map, {}, {} local map, 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 {} local def = map.def or {}
for _, warp in ipairs(def.warps or {}) do for _, warp in ipairs(def.warps or {}) do
markers[#markers + 1] = { kind = "warp", x = warp.x, y = warp.y } markers[#markers + 1] = { kind = "warp", x = warp.x, y = warp.y }
@@ -175,15 +113,7 @@ function WorldAPI:mapOverview()
markers[#markers + 1] = { kind = "hidden", x = item.x, y = item.y } markers[#markers + 1] = { kind = "hidden", x = item.x, y = item.y }
end end
end end
local tileRows, tileDetailRows = mapTileRows(map) return MapOverview.build(map, markers)
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 end
-- opts.arrive = "fly" | "teleport" picks the arrival FX; anything else -- opts.arrive = "fly" | "teleport" picks the arrival FX; anything else
+27
View File
@@ -25,6 +25,8 @@
local Logger = require("src.core.Logger") local Logger = require("src.core.Logger")
local Movement = require("src.script.gen2.Movement") local Movement = require("src.script.gen2.Movement")
local Runtime = require("src.mods.Runtime") local Runtime = require("src.mods.Runtime")
local HiddenItems = require("src.world.gen2.HiddenItems")
local MapOverview = require("src.world.MapOverview")
local WorldAPI = {} local WorldAPI = {}
WorldAPI.__index = WorldAPI WorldAPI.__index = WorldAPI
@@ -50,6 +52,31 @@ function WorldAPI:current()
facing = p and p.facing } facing = p and p.facing }
end 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 -- 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. -- come from the map setup method, so opts.arrive has nothing to select yet.
function WorldAPI:warpTo(mapId, x, y, facing, opts) function WorldAPI:warpTo(mapId, x, y, facing, opts)
+47
View File
@@ -3,6 +3,7 @@ package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness") local T = require("tests.harness")
local Assets = require("src.render.Assets") local Assets = require("src.render.Assets")
local WorldAPI = require("src.world.WorldAPI") local WorldAPI = require("src.world.WorldAPI")
local WorldAPI2 = require("src.world.gen2.WorldAPI")
Assets.imageData = function() Assets.imageData = function()
return { getPixel = function(_, x, y) return { getPixel = function(_, x, y)
@@ -17,6 +18,10 @@ local overview, err = api:mapOverview()
T.eq(overview, nil, "map overview is unavailable outside the overworld") T.eq(overview, nil, "map overview is unavailable outside the overworld")
T.eq(err, "no overworld", "map overview reports why it is unavailable") T.eq(err, "no overworld", "map overview reports why it is unavailable")
local overview2, err2 = WorldAPI2.new({}, "tester"):mapOverview()
T.eq(overview2, nil, "Gen 2 map overview is unavailable outside the overworld")
T.eq(err2, "no overworld", "Gen 2 map overview reports why it is unavailable")
local map = { local map = {
id = "TEST_MAP", widthCells = 2, heightCells = 2, id = "TEST_MAP", widthCells = 2, heightCells = 2,
def = { def = {
@@ -63,6 +68,48 @@ overview = api:mapOverview()
T.eq(#overview.markers, 1, "collected items disappear from the overview") T.eq(#overview.markers, 1, "collected items disappear from the overview")
T.eq(overview.markers[1].kind, "warp", "exits remain after collecting items") T.eq(overview.markers[1].kind, "warp", "exits remain after collecting items")
local ball = { x = 0, y = 1, itemball = { item = 15, quantity = 1 } }
local gen2Map = {
id = "GEN2_MAP", widthCells = 2, heightCells = 2,
tileset = { image = "test.png", tilesPerRow = 2 },
def = {
warps = { { x = 1, y = 0 } },
objects = { ball },
bgEvents = { {
x = 1, y = 1, kind = 7,
hiddenItem = { item = 30, event = 123 },
} },
},
}
function gen2Map:isWarpTileCell(x, y) return x == 1 and y == 0 end
function gen2Map:isWaterCell(x, y) return x == 0 and y == 1 end
function gen2Map:isWalkableCell(x, y) return x == 0 and y == 0 end
function gen2Map:tileAt(x) return x % 2 end
local found = {}
local gen2World = {
isOverworld = true,
map = gen2Map,
npcs = { {}, { def = ball } },
events = { get = function(_, event) return found[event] end },
}
api = WorldAPI2.new({ save = {}, data = {}, world = gen2World }, "tester")
overview = api:mapOverview()
T.eq(overview.rows[1], ".+", "Gen 2 uses the shared terrain contract")
T.eq(overview.rows[2], "~ ", "Gen 2 water and blocked terrain are distinct")
T.eq(overview.tileDetailRows[1], "03330333",
"Gen 2 exposes the same 4x4 tile shading detail")
T.eq(#overview.markers, 3,
"Gen 2 exits, visible item balls, and hidden items are marked")
T.eq(overview.markers[2].kind, "item", "Gen 2 item balls are semantic")
T.eq(overview.markers[3].kind, "hidden", "Gen 2 hidden items are semantic")
gen2World.npcs = {}
found[123] = true
overview = api:mapOverview()
T.eq(#overview.markers, 1,
"collected Gen 2 items disappear from the overview")
api = WorldAPI.new(game, "tester")
map.tileset = { image = "test.png", tilesPerRow = 2 } map.tileset = { image = "test.png", tilesPerRow = 2 }
overview = api:mapOverview() overview = api:mapOverview()
T.eq(overview.tileWidth, 4, "tile overview reports its width") T.eq(overview.tileWidth, 4, "tile overview reports its width")