-- ===================================================================== -- lib-core.maps — Single Tile-Grid Map Lib (P.0) -- See: meta/docs/superpowers/specs/2026-05-09-p0-lib-maps-design.md -- -- Forward-compat stubs (DEPRECATED-MVP) for: multi-map-graph, -- lifecycle-states, walls, sprite-fields, generators, save-integration, -- cross-lib tilemap resolution, override-field-merge, sub-require. -- ===================================================================== -- Module-private state local map_registry = {} -- map_id -> Map local tilemap_registry = {} -- full_tilemap_id -> Tilemap local current_map_id = nil -- ===================================================================== -- Internal helpers -- ===================================================================== -- Splits "lib-x.foo" -> "lib-x", "foo" (split at FIRST dot only). -- Returns (nil, full_id) if no dot present. local function split_namespaced_id(full_id) local dot = string.find(full_id, ".", 1, true) if not dot then return nil, full_id end return string.sub(full_id, 1, dot-1), string.sub(full_id, dot+1) end -- "demo_tilemap" -> ".demo_tilemap" -- "lib-x.foo" -> "lib-x.foo" (cross-lib path; checked at load) local function resolve_tilemap_id(ref) if string.find(ref, ".", 1, true) then return ref end return engine.module.id() .. "." .. ref end -- Schema-validation helper: reads required field with type-check. local function require_field(t, key, expected_type, source) local v = t[key] if v == nil then error(string.format("maps.load: schema violation in %s: missing required field '%s'", source, key)) end if type(v) ~= expected_type then error(string.format("maps.load: schema violation in %s: field '%s' must be %s, got %s", source, key, expected_type, type(v))) end return v end local function validate_map_table(t, source) require_field(t, "id", "string", source) require_field(t, "tilemap", "string", source) local size = require_field(t, "size", "table", source) require_field(size, "w", "number", source .. ".size") require_field(size, "h", "number", source .. ".size") local tiles = require_field(t, "tiles", "table", source) local expected = size.w * size.h if #tiles ~= expected then error(string.format("maps.load: schema violation in %s: tiles array length %d != size.w * size.h (%d)", source, #tiles, expected)) end end local function validate_tilemap_table(t, source, expected_local_name) require_field(t, "id", "string", source) if t.id ~= expected_local_name then error(string.format("maps.load: tilemap manifest id '%s' mismatches filename-stem '%s' in %s", t.id, expected_local_name, source)) end require_field(t, "tile_size", "number", source) local tiles = require_field(t, "tiles", "table", source) for i, entry in ipairs(tiles) do if type(entry) ~= "table" then error(string.format("maps.load: tilemap %s tiles[%d] must be table", source, i)) end require_field(entry, "id", "string", source .. ".tiles[" .. i .. "]") require_field(entry, "walkable", "boolean", source .. ".tiles[" .. i .. "]") end end local function load_tilemap(full_id) if tilemap_registry[full_id] then return tilemap_registry[full_id] end local lib_id, local_name = split_namespaced_id(full_id) local current_lib = engine.module.id() if lib_id ~= current_lib then -- DEPRECATED-MVP: cross-lib tilemap resolution deferred to render-slice error(string.format("maps.load: cross-lib tilemap resolution deferred [DEPRECATED-MVP]; tilemap '%s' not from current module '%s'", full_id, current_lib)) end local path = "assets/tiles/" .. local_name .. ".tilemap.json" local raw = engine.asset.load_json(path) validate_tilemap_table(raw, path, local_name) local tilemap = { id = full_id, tile_size = raw.tile_size, tiles = raw.tiles, } tilemap_registry[full_id] = tilemap return tilemap end local function build_map(t_map, tilemap) -- Verify each tile-id is in palette range for i, tid in ipairs(t_map.tiles) do if type(tid) ~= "number" or tid < 1 or tid > #tilemap.tiles then error(string.format("maps.load: tile-id %s at index %d exceeds palette size %d (in map '%s')", tostring(tid), i, #tilemap.tiles, t_map.id)) end end return { id = t_map.id, size = t_map.size, tile_size = t_map.tile_size or tilemap.tile_size, tiles = t_map.tiles, -- shallow-ref tilemap = tilemap, -- shallow-ref -- DEPRECATED-MVP: forward-compat stubs (multi-map slice fills) walls = {}, regions = {}, edges = {}, state = "Active", pinned = false, } end -- ===================================================================== -- Public API -- ===================================================================== local M = {} function M.load(path) local raw = engine.asset.load_json(path) validate_map_table(raw, path) local full_id = resolve_tilemap_id(raw.tilemap) local tilemap = load_tilemap(full_id) local map = build_map(raw, tilemap) if map_registry[map.id] then error(string.format("maps.load: map-id '%s' already registered", map.id)) end map_registry[map.id] = map return map.id end -- Programmatic creation (tests, future procedural-map generators). -- Caller must provide a fully-built tilemap-table (not a path/id ref). function M.create(t) if type(t.tilemap_table) ~= "table" then error("maps.create: tilemap_table required (use maps.load for JSON path)") end local map = build_map(t, t.tilemap_table) if map_registry[map.id] then error(string.format("maps.create: map-id '%s' already registered", map.id)) end map_registry[map.id] = map return map.id end function M.size(map_id) local id = map_id or current_map_id if not id then error("maps.size: no current map") end return map_registry[id].size end function M.tile_size(map_id) local id = map_id or current_map_id if not id then error("maps.tile_size: no current map") end return map_registry[id].tile_size end -- Arity-flex sugar: tile_at(tx, ty) uses current_map_id; tile_at(map_id, tx, ty) -- is explicit. Both forms accept nil map_id and fall back to current_map_id. function M.tile_at(a, b, c) local map_id, tx, ty if c == nil then map_id, tx, ty = current_map_id, a, b else map_id, tx, ty = a, b, c if map_id == nil then map_id = current_map_id end end if not map_id then error("maps.tile_at: no current map; call set_current() first or pass map_id") end local m = map_registry[map_id] if not m then error(string.format("maps.tile_at: unknown map-id '%s'", tostring(map_id))) end if tx < 0 or tx >= m.size.w or ty < 0 or ty >= m.size.h then return nil end local palette_id = m.tiles[ty * m.size.w + tx + 1] return m.tilemap.tiles[palette_id] end function M.is_walkable(a, b, c) local t = M.tile_at(a, b, c) if t == nil then return false end return t.walkable == true end function M.tilemap_id(map_id) local id = map_id or current_map_id return map_registry[id].tilemap.id end function M.current() return current_map_id end function M.set_current(map_id) if not map_registry[map_id] then error(string.format("maps.set_current: unknown map-id '%s'", tostring(map_id))) end current_map_id = map_id end function M.list() local out = {} for id, _ in pairs(map_registry) do out[#out+1] = id end return out end -- Forward-compat stubs (DEPRECATED-MVP — implemented in later slices) function M.state(map_id) -- DEPRECATED-MVP: lifecycle states (Virgin/Inert/Passive/Active/Pinned) — multi-map slice return "Active" end function M.pin(map_id, reason) -- DEPRECATED-MVP: world.pin_map mechanic — multi-map slice engine.warn("maps.pin: deferred to map-topology lifecycle slice") end return M