From c5192eaea448820536c9c353b4a02cae3c777557 Mon Sep 17 00:00:00 2001 From: AverageConsumer <35539970+AverageConsumer@users.noreply.github.com> Date: Thu, 13 Aug 2026 22:08:00 +0200 Subject: [PATCH] feat(mods): expose map overviews in Gold --- docs/mod-api-gen2-compat.md | 2 + docs/modding.md | 3 +- src/mods/Gen2Compat.lua | 2 +- src/world/MapOverview.lua | 84 ++++++++++++++++++++++++ src/world/WorldAPI.lua | 76 +-------------------- src/world/gen2/WorldAPI.lua | 27 ++++++++ tests/engine/world_map_overview_test.lua | 47 +++++++++++++ 7 files changed, 166 insertions(+), 75 deletions(-) create mode 100644 src/world/MapOverview.lua diff --git a/docs/mod-api-gen2-compat.md b/docs/mod-api-gen2-compat.md index 62ec6316..fbb61d6e 100644 --- a/docs/mod-api-gen2-compat.md +++ b/docs/mod-api-gen2-compat.md @@ -474,6 +474,8 @@ Generation-agnostic; nothing to adapt. (`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 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 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 diff --git a/docs/modding.md b/docs/modding.md index 36fb1387..e3a45fa2 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -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. `markers` contains active `{ kind, x, y }` points in map-cell coordinates for `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 diff --git a/src/mods/Gen2Compat.lua b/src/mods/Gen2Compat.lua index f336e61c..aa319967 100644 --- a/src/mods/Gen2Compat.lua +++ b/src/mods/Gen2Compat.lua @@ -736,7 +736,7 @@ COVERAGE["src.pokemon.Boxes"] = { COVERAGE["src.world.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", warned = "setFlag getFlag", absent = "", diff --git a/src/world/MapOverview.lua b/src/world/MapOverview.lua new file mode 100644 index 00000000..0065de3e --- /dev/null +++ b/src/world/MapOverview.lua @@ -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 diff --git a/src/world/WorldAPI.lua b/src/world/WorldAPI.lua index 7aadea3c..64283bea 100644 --- a/src/world/WorldAPI.lua +++ b/src/world/WorldAPI.lua @@ -6,8 +6,8 @@ -- 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 MapOverview = require("src.world.MapOverview") local Party = require("src.pokemon.Party") local Runtime = require("src.mods.Runtime") @@ -15,59 +15,6 @@ 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 @@ -147,16 +94,7 @@ end 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 map, markers = ow.map, {} local def = map.def or {} for _, warp in ipairs(def.warps or {}) do 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 } 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 } + return MapOverview.build(map, markers) end -- opts.arrive = "fly" | "teleport" picks the arrival FX; anything else diff --git a/src/world/gen2/WorldAPI.lua b/src/world/gen2/WorldAPI.lua index efcf28d2..09830721 100644 --- a/src/world/gen2/WorldAPI.lua +++ b/src/world/gen2/WorldAPI.lua @@ -25,6 +25,8 @@ 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 WorldAPI = {} WorldAPI.__index = WorldAPI @@ -50,6 +52,31 @@ function WorldAPI:current() facing = p and p.facing } 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) diff --git a/tests/engine/world_map_overview_test.lua b/tests/engine/world_map_overview_test.lua index 32c540a2..c337329d 100644 --- a/tests/engine/world_map_overview_test.lua +++ b/tests/engine/world_map_overview_test.lua @@ -3,6 +3,7 @@ package.path = "./?.lua;./?/init.lua;" .. package.path local T = require("tests.harness") local Assets = require("src.render.Assets") local WorldAPI = require("src.world.WorldAPI") +local WorldAPI2 = require("src.world.gen2.WorldAPI") Assets.imageData = function() 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(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 = { id = "TEST_MAP", widthCells = 2, heightCells = 2, def = { @@ -63,6 +68,48 @@ overview = api:mapOverview() T.eq(#overview.markers, 1, "collected items disappear from the overview") 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 } overview = api:mapOverview() T.eq(overview.tileWidth, 4, "tile overview reports its width")