diff --git a/docs/modding.md b/docs/modding.md index 8bbb7dde..3fd5bc51 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -35,6 +35,16 @@ An edited vanilla map becomes a `mod.content.maps:patch` carrying only the fields that moved; a new map becomes a `:register`. See `docs/new-features.md` and the extension's own README. +## Read-only map overviews + +`mod.world:mapOverview()` returns collision `rows` at map-cell resolution, +optional visual `tileRows` at 2x resolution, and optional `tileDetailRows` at +4x resolution. Visual rows contain Game Boy shades from `"0"` (lightest) to +`"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. + ## Rendering pipelines Most registries hand the engine *content*. `render_pipelines` hands it diff --git a/src/world/WorldAPI.lua b/src/world/WorldAPI.lua index dadf91d6..e0a5d9db 100644 --- a/src/world/WorldAPI.lua +++ b/src/world/WorldAPI.lua @@ -18,6 +18,11 @@ 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 @@ -28,30 +33,39 @@ local function mapTileRows(map) cached = { pixels = pixels, shades = {} } overviewShades[tileset.image] = cached end - local rows, perRow = {}, tileset.tilesPerRow + local rows, detailRows, perRow = {}, {}, tileset.tilesPerRow for ty = 0, map.heightCells * 2 - 1 do - local row = {} + local row, detailTop, detailBottom = {}, {}, {} for tx = 0, map.widthCells * 2 - 1 do local tile = map:tileAt(tx, ty) - local shade = cached.shades[tile] - if shade == nil then - local sum = 0 + 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) - sum = sum + r * 0.2126 + g * 0.7152 + b * 0.0722 + 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 - shade = tostring(math.max(0, math.min(3, - math.floor((1 - sum / 64) * 3 + 0.5)))) - cached.shades[tile] = shade + 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] = shade + 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 + return rows, detailRows end function WorldAPI.new(game, modId) @@ -86,10 +100,12 @@ 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 = ow.map, {} + local map, rows, markers = ow.map, {}, {} for y = 0, map.heightCells - 1 do local row = {} for x = 0, map.widthCells - 1 do @@ -99,11 +115,33 @@ function WorldAPI:mapOverview() end rows[#rows + 1] = table.concat(row) end - local tileRows = mapTileRows(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 } + 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, tileRows = tileRows, + height = map.heightCells, rows = rows, markers = markers, + tileRows = tileRows, tileWidth = tileRows and map.widthCells * 2, - tileHeight = tileRows and map.heightCells * 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 diff --git a/tests/engine/world_map_overview_test.lua b/tests/engine/world_map_overview_test.lua index d0a4b825..32c540a2 100644 --- a/tests/engine/world_map_overview_test.lua +++ b/tests/engine/world_map_overview_test.lua @@ -5,8 +5,9 @@ local Assets = require("src.render.Assets") local WorldAPI = require("src.world.WorldAPI") Assets.imageData = function() - return { getPixel = function(_, x) - local shade = x < 8 and 1 or 0 + return { getPixel = function(_, x, y) + local shade = x >= 8 and 0 or ({ 1, 0, 2 / 3, 1 / 3 })[ + math.floor(y / 4) * 2 + math.floor(x / 4) + 1] return shade, shade, shade, 1 end } end @@ -16,15 +17,34 @@ 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 map = { id = "TEST_MAP", widthCells = 2, heightCells = 2 } +local map = { + id = "TEST_MAP", widthCells = 2, heightCells = 2, + def = { + warps = { { x = 1, y = 0 } }, + objects = { { index = 1, x = 0, y = 1, item = "POTION" } }, + }, +} function map:isWarpTileCell(x, y) return x == 1 and y == 0 end function map:isWaterCell(x, y) return x == 0 and y == 1 end function map:isWalkableCell(x, y) return x == 0 and y == 0 end function map:tileAt(x) return x % 2 end -api = WorldAPI.new({ stack = { states = { - { isOverworld = true, map = map }, -} } }, "tester") +local save = {} +local world = { + isOverworld = true, + map = map, + objectVisible = function(s, mapId, obj) + return not (s.itemsTaken and s.itemsTaken[mapId .. "_obj_" .. obj.index]) + end, +} +local game = { + save = save, + data = { field = { hiddenItems = { + TEST_MAP = { { x = 1, y = 1, item = "NUGGET" } }, + } } }, + stack = { states = { world } }, +} +api = WorldAPI.new(game, "tester") overview = api:mapOverview() T.eq(overview.mapId, "TEST_MAP", "map overview identifies the active map") T.eq(overview.width, 2, "map overview reports its width") @@ -32,11 +52,27 @@ T.eq(overview.height, 2, "map overview reports its height") T.eq(overview.rows[1], ".+", "walkable land and warps are distinct") T.eq(overview.rows[2], "~ ", "water and blocked terrain are distinct") T.eq(overview.tileRows, nil, "tile overview is optional") +T.eq(#overview.markers, 3, "active exits and untaken items are marked") +T.eq(overview.markers[1].kind, "warp", "warp marker is semantic") +T.eq(overview.markers[2].kind, "item", "visible item marker is semantic") +T.eq(overview.markers[3].kind, "hidden", "hidden item marker is semantic") + +save.itemsTaken = { TEST_MAP_obj_1 = true } +save.hiddenTaken = { TEST_MAP_1_1 = true } +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") map.tileset = { image = "test.png", tilesPerRow = 2 } overview = api:mapOverview() T.eq(overview.tileWidth, 4, "tile overview reports its width") T.eq(overview.tileHeight, 4, "tile overview reports its height") -T.eq(overview.tileRows[1], "0303", "tile overview preserves map shading") +T.eq(overview.tileRows[1], "2323", "tile overview preserves average shading") +T.eq(overview.tileDetailWidth, 8, "detail overview reports its width") +T.eq(overview.tileDetailHeight, 8, "detail overview reports its height") +T.eq(overview.tileDetailRows[1], "03330333", + "detail overview preserves top tile quadrants") +T.eq(overview.tileDetailRows[2], "12331233", + "detail overview preserves bottom tile quadrants") T.finish("world map overview")