-- ===================================================================== -- 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: resolve and return map by id, or raise. local function require_map(map_id) local id = map_id or current_map_id if not id then error("maps: no current map set") end local m = map_registry[id] if not m then error(string.format("maps: no map registered with id '%s'", id)) end return m end -- ===================================================================== -- Packed-u32 GID encoding (Schema-v2) -- Bit-Layout: -- Bits 31..24 : atlas_index (8 bits, 0..255) -- Bits 23..4 : tile_id (20 bits, 0..1_048_575) -- Bits 3..2 : rotation (2 bits, 0..3, n * 90° CW) -- Bits 1..0 : reserved (must be 0 in v2; future flip-h/v) -- gid == 0 means empty cell (atlas 0 + tile 0 reserved as null). -- ===================================================================== local GID_ATLAS_SHIFT = 24 local GID_TILE_SHIFT = 4 local GID_ROT_SHIFT = 2 local GID_ATLAS_MASK = 0xFF -- 8 bits local GID_TILE_MASK = 0xFFFFF -- 20 bits local GID_ROT_MASK = 0x3 -- 2 bits local function encode_gid(atlas_index, tile_id, rotation) rotation = rotation or 0 return (atlas_index << GID_ATLAS_SHIFT) | (tile_id << GID_TILE_SHIFT) | (rotation << GID_ROT_SHIFT) end local function decode_gid(gid) local atlas_index = (gid >> GID_ATLAS_SHIFT) & GID_ATLAS_MASK local tile_id = (gid >> GID_TILE_SHIFT) & GID_TILE_MASK local rotation = (gid >> GID_ROT_SHIFT) & GID_ROT_MASK return atlas_index, tile_id, rotation end -- ===================================================================== -- Schema v1→v2 Auto-Upgrade (transparent beim Load) -- v1: { id, tilemap, size, tiles[], tile_rotations? } -- v2: { schema_version, id, size, atlases[], layers: { surface: { tiles[] } }, roof? } -- ===================================================================== local function upgrade_v1_to_v2(v1) local atlas_id = v1.tilemap or "default_tilemap" local v1_tiles = v1.tiles or {} local v1_rots = v1.tile_rotations or {} local gids = {} for i = 1, #v1_tiles do local tile_id = v1_tiles[i] local rot_deg = v1_rots[i] or 0 local rot_quad = math.floor(rot_deg / 90) % 4 if tile_id == 0 then gids[i] = 0 else gids[i] = encode_gid(0, tile_id, rot_quad) end end return { schema_version = 2, id = v1.id, size = v1.size, atlases = { atlas_id }, layers = { surface = { tiles = gids } } } end -- ===================================================================== -- Schema-validation helper (used by both v1 and v2 validators) -- ===================================================================== -- 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 -- ===================================================================== -- Schema-v2 Layer-Whitelist + Validation -- ===================================================================== -- Layer z-order (back-to-front) split at entity slot local LAYER_ORDER_PRE_ENTITIES = { "foundation", "subsurface", "surface", "topsurface" } local LAYER_ORDER_POST_ENTITIES = { "lower_wall", "wall", "upper_wall", "canopy" } local VALID_LAYER_NAMES = { foundation = true, subsurface = true, surface = true, topsurface = true, lower_wall = true, wall = true, upper_wall = true, canopy = true, } local function validate_map_table_v2(t, source) require_field(t, "schema_version", "number", source) if t.schema_version ~= 2 then error(string.format("maps.load: schema_version %d not supported (expected 2)", t.schema_version)) end require_field(t, "id", "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 expected = size.w * size.h local atlases = require_field(t, "atlases", "table", source) if #atlases == 0 then error(string.format("maps.load: schema violation in %s: atlases[] must be non-empty", source)) end if #atlases > 256 then error(string.format("maps.load: schema violation in %s: atlases[] has %d entries, max 256", source, #atlases)) end local layers = t.layers or {} for layer_name, layer_data in pairs(layers) do if not VALID_LAYER_NAMES[layer_name] then engine.print(string.format("maps.load: %s: ignoring unknown layer '%s'", source, layer_name)) else local tiles = require_field(layer_data, "tiles", "table", source .. ".layers." .. layer_name) if #tiles ~= expected then error(string.format("maps.load: schema violation in %s.layers.%s: tiles length %d != %d", source, layer_name, #tiles, expected)) end end end if t.roof ~= nil then if type(t.roof) ~= "table" then error(string.format("maps.load: %s: roof must be array", source)) end if #t.roof ~= expected then error(string.format("maps.load: %s: roof length %d != %d", source, #t.roof, expected)) end end end -- ===================================================================== -- Internal helpers -- ===================================================================== -- Checks whether `full_id` is a tilemap belonging to the current module. -- Module-IDs can themselves contain dots (e.g. `lib-core.maps-test`), so a -- naive first-dot-split is wrong. Match by prefix `.`. -- Returns (is_local, local_name) — local_name is nil if not local. local function split_local_tilemap(full_id) local mod = engine.module.id() local prefix = mod .. "." if string.sub(full_id, 1, #prefix) == prefix then return true, string.sub(full_id, #prefix + 1) end return false, nil 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 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 is_local, local_name = split_local_tilemap(full_id) if not is_local 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, engine.module.id())) end -- Try legacy tilemap JSON first (M.1 path; present in pre-M.2 assets). -- Fall back to an atlas-bootstrap stub when the file is absent (M.2 path): -- load_textures will replace this stub with the real atlas data. local path = "assets/tiles/" .. local_name .. ".tilemap.json" local ok, raw = pcall(engine.asset.load_json, path) if ok then validate_tilemap_table(raw, path, local_name) -- Cook tiles: copy raw fields and add texture + texture_handle slots. local cooked = {} for i, t in ipairs(raw.tiles) do cooked[i] = { id = t.id, walkable = (t.walkable == true), color = t.color, -- color-mode fallback texture = t.texture, -- optional atlas-id texture_handle = nil, -- populated by load_textures } end local tilemap = { id = full_id, tile_size = raw.tile_size, asset_pack = raw.asset_pack, -- optional alias-key tiles = cooked, } tilemap_registry[full_id] = tilemap return tilemap end -- M.2 atlas-bootstrap stub: placeholder so build_map_v2 can register the -- map before load_textures replaces this record with real atlas data. -- GID validation is intentionally relaxed (large stub palette). engine.print(string.format( "maps.load: tilemap JSON not found (%s); using atlas-bootstrap stub for '%s' (call load_textures to populate)", path, full_id)) local stub_tiles = {} for i = 1, 4096 do stub_tiles[i] = { id = i, name = "", walkable = false } end local stub = { id = full_id, tile_size = 32, tiles = stub_tiles, } tilemap_registry[full_id] = stub return stub 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 tile_rotations = t_map.tile_rotations, -- optional parallel array (NEW) tilemap = tilemap, -- shallow-ref -- DEPRECATED-MVP: forward-compat stubs (multi-map slice fills) walls = {}, regions = {}, edges = {}, state = "Active", pinned = false, } end local function build_map_v2(t_map, resolved_atlases) local size = t_map.size -- Validate that each non-zero GID references a tile_id within its atlas's palette. local layers = t_map.layers or {} for layer_name, layer_data in pairs(layers) do if VALID_LAYER_NAMES[layer_name] and layer_data.tiles then for i, gid in ipairs(layer_data.tiles) do if gid ~= 0 then local atlas_idx, tile_id, _rot = decode_gid(gid) local atlas = resolved_atlases[atlas_idx + 1] if atlas then if tile_id < 1 or tile_id > #atlas.tiles then error(string.format( "maps.load: tile-id %d at index %d exceeds palette size %d (in map '%s', layer '%s')", tile_id, i, #atlas.tiles, t_map.id or "", layer_name)) end end end end end end return { id = t_map.id, schema_version = 2, size = size, tile_size = resolved_atlases[1].tile_size, -- assume uniform; first atlas wins atlases = resolved_atlases, atlas_aliases = t_map.atlases, -- alias strings, parallel to atlases layers = layers, roof = t_map.roof, -- DEPRECATED-MVP stubs walls = {}, regions = {}, edges = {}, state = "Active", pinned = false, } end -- ===================================================================== -- Internal render helpers (draw_layer, draw_v1_legacy) -- These must be declared before the public draw_map* functions that call them. -- ===================================================================== -- Internal: draw all cells in one layer using atlas-handle + UV sampling. -- (M.2: replaces per-tile texture_handle pattern with atlas-level handle.) -- Magenta-placeholder color (RGBA8888 0xFF00FFFF) for missing-asset draw paths. local MISSING_ASSET_COLOR = 0xFFFF00FF local function draw_layer(m, layer_name) local layer = m.layers[layer_name] if not layer then return end local sz = m.size local ts = m.tile_size for y = 0, sz.h - 1 do for x = 0, sz.w - 1 do local idx = y * sz.w + x + 1 local gid = layer.tiles[idx] or 0 if gid ~= 0 then local atlas_idx, tile_id, rot_quad = decode_gid(gid) local atlas = m.atlases[atlas_idx + 1] local px = x * ts local py = y * ts if atlas and atlas.diffuse_texture_handle then local tile = atlas.tiles[tile_id] if tile and tile.uv then local rot_deg = rot_quad * 90.0 engine.render.draw_sprite_transform( atlas.diffuse_texture_handle, px + ts / 2, py + ts / 2, math.rad(rot_deg), ts / tile.uv.w, ts / tile.uv.h, ts / 2, ts / 2, 0xFFFFFFFF, tile.uv.x, tile.uv.y, tile.uv.w, tile.uv.h ) else -- Tile-record missing or no uv: magenta placeholder. engine.render.draw_rect(px, py, ts, ts, MISSING_ASSET_COLOR) end else -- Atlas not loaded (load_textures was never called, or asset -- alias resolution returned nil): magenta placeholder so the -- consumer sees the missing-asset visually instead of crashing. engine.render.draw_rect(px, py, ts, ts, MISSING_ASSET_COLOR) end end end end end local function draw_v1_legacy(m) local tm = m.tilemap if not tm then return end local ts = tm.tile_size for y = 0, m.size.h - 1 do for x = 0, m.size.w - 1 do local idx = y * m.size.w + x + 1 local tile_id = m.tiles[idx] local tile = tm.tiles[tile_id] local rot_deg = (m.tile_rotations and m.tile_rotations[idx]) or 0 local px = x * ts local py = y * ts if tile.texture_handle then engine.render.draw_sprite_transform( tile.texture_handle, px + ts / 2, py + ts / 2, math.rad(rot_deg), 1.0, 1.0, ts / 2, ts / 2, 0xFFFFFFFF ) else local c = tile.color or { 100, 100, 100 } engine.render.draw_rect(px, py, ts, ts, engine.render.rgb(c[1], c[2], c[3])) end end end end -- ===================================================================== -- Public API -- ===================================================================== local M = {} function M.load(path) local raw = engine.asset.load_json(path) if raw.schema_version == nil or raw.schema_version == 1 then raw = upgrade_v1_to_v2(raw) engine.print(string.format("maps.load: auto-upgraded v1 map '%s' to v2", raw.id or "")) end validate_map_table_v2(raw, path) -- Resolve atlas-aliases (each entry is a tilemap-id, possibly local or fully-qualified) local resolved_atlases = {} for i, alias in ipairs(raw.atlases) do local full_id = resolve_tilemap_id(alias) resolved_atlases[i] = load_tilemap(full_id) end local map = build_map_v2(raw, resolved_atlases) 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 function M.tile_at_layer(layer_name, x, y, map_id) local id = map_id or current_map_id if not id then error("maps.tile_at_layer: no current map") end local m = map_registry[id] if x < 0 or x >= m.size.w or y < 0 or y >= m.size.h then return nil end if not m.layers or not m.layers[layer_name] then return nil end local gid = m.layers[layer_name].tiles[y * m.size.w + x + 1] or 0 if gid == 0 then return nil end local atlas_idx, tile_id, _rot = decode_gid(gid) local atlas = m.atlases[atlas_idx + 1] if not atlas then return nil end return atlas.tiles[tile_id] 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 -- v2 path: query surface layer for back-compat if m.schema_version == 2 then return M.tile_at_layer("surface", tx, ty, map_id) end -- v1 path (legacy): kept for any non-upgraded maps that bypass M.load 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 -- v2 walkability: surface non-empty AND no blocking wall function M.is_walkable(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.is_walkable: no current map") end local m = map_registry[map_id] if m.schema_version ~= 2 then -- v1 fallback: surface-only walkability via legacy tile_at local t = M.tile_at(map_id, tx, ty) if t == nil then return false end return t.walkable == true end -- v2: needs surface tile present AND lower_wall/wall absent local surface = M.tile_at_layer("surface", tx, ty, map_id) if surface == nil then return false end if M.cell_gid("lower_wall", tx, ty, map_id) ~= 0 then return false end if M.cell_gid("wall", tx, ty, map_id) ~= 0 then return false end return surface.walkable == true end function M.blocks_walk(x, y, map_id) local id = map_id or current_map_id if not id then error("maps.blocks_walk: no current map") end local m = map_registry[id] if x < 0 or x >= m.size.w or y < 0 or y >= m.size.h then return false end if M.cell_gid("lower_wall", x, y, id) ~= 0 then return true end if M.cell_gid("wall", x, y, id) ~= 0 then return true end return false end function M.blocks_sight(x, y, map_id) local id = map_id or current_map_id if not id then error("maps.blocks_sight: no current map") end local m = map_registry[id] if x < 0 or x >= m.size.w or y < 0 or y >= m.size.h then return false end if M.cell_gid("wall", x, y, id) ~= 0 then return true end -- upper_wall: per-tile blocks_sight flag in atlas metadata; default true for walls local up_gid = M.cell_gid("upper_wall", x, y, id) if up_gid ~= 0 then local atlas_idx, tile_id, _rot = decode_gid(up_gid) local atlas = m.atlases[atlas_idx + 1] local tile = atlas and atlas.tiles[tile_id] if tile and tile.blocks_sight == false then return false end return true end return false end function M.tilemap_id(map_id) local id = map_id or current_map_id local m = map_registry[id] -- v2: return first atlas alias (backwards-compat for single-atlas maps) if m.schema_version == 2 then return m.atlas_aliases and m.atlas_aliases[1] or (m.atlases[1] and m.atlases[1].id) end return m.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 function M.atlas_count(map_id) local id = map_id or current_map_id if not id then error("maps.atlas_count: no current map") end local m = map_registry[id] if m.schema_version ~= 2 then return 1 end -- v1 (pre-upgrade) always 1 return #m.atlases end function M.atlas_id_at(idx, map_id) local id = map_id or current_map_id if not id then error("maps.atlas_id_at: no current map") end local m = map_registry[id] return m.atlas_aliases and m.atlas_aliases[idx + 1] or m.atlases[idx + 1] and m.atlases[idx + 1].id end function M.has_layer(layer_name, map_id) local id = map_id or current_map_id if not id then error("maps.has_layer: no current map") end local m = map_registry[id] return m.layers and m.layers[layer_name] ~= nil end function M.cell_gid(layer_name, x, y, map_id) local id = map_id or current_map_id if not id then error("maps.cell_gid: no current map") end local m = map_registry[id] if not m.layers or not m.layers[layer_name] then return 0 end if x < 0 or x >= m.size.w or y < 0 or y >= m.size.h then return 0 end return m.layers[layer_name].tiles[y * m.size.w + x + 1] or 0 end -- ===================================================================== -- Write APIs (v0.4.0) -- ===================================================================== -- Helper: gather valid layer names for error messages. local function get_valid_layer_names_list() local names = {} for name, _ in pairs(VALID_LAYER_NAMES) do table.insert(names, name) end table.sort(names) return names end -- Internal: get-or-allocate a layer table for in-place writes. local function get_or_alloc_layer(map, layer_name) if not VALID_LAYER_NAMES[layer_name] then error(string.format("maps: unknown layer '%s' (valid: %s)", layer_name, table.concat(get_valid_layer_names_list(), ", "))) end local layer = map.layers[layer_name] if not layer then local cell_count = map.size.w * map.size.h local tiles = {} for i = 1, cell_count do tiles[i] = 0 end layer = { tiles = tiles } map.layers[layer_name] = layer end return layer end function M.set_cell_gid(layer_name, x, y, gid, map_id) local map = require_map(map_id) if x < 0 or y < 0 or x >= map.size.w or y >= map.size.h then error(string.format("maps.set_cell_gid: cell (%d,%d) out of bounds for size %dx%d", x, y, map.size.w, map.size.h)) end local layer = get_or_alloc_layer(map, layer_name) local idx = y * map.size.w + x + 1 layer.tiles[idx] = gid map._dirty = true end function M.set_roof(x, y, value, map_id) local map = require_map(map_id) if x < 0 or y < 0 or x >= map.size.w or y >= map.size.h then error(string.format("maps.set_roof: cell (%d,%d) out of bounds for size %dx%d", x, y, map.size.w, map.size.h)) end if value ~= 0 and value ~= 1 then error(string.format("maps.set_roof: value %s must be 0 or 1", tostring(value))) end if not map.roof then local cell_count = map.size.w * map.size.h map.roof = {} for i = 1, cell_count do map.roof[i] = 0 end end local idx = y * map.size.w + x + 1 map.roof[idx] = value map._dirty = true end -- ===================================================================== -- Internal JSON pretty-printer (hand-rolled, 2-space indent). -- Supports: nil/null, bool, number (int + float), string, array, object. -- Deterministic key ordering (sorted) so saves are byte-stable. -- ===================================================================== local json_value -- forward declaration local json_table -- forward declaration local function json_escape_string(s) local replacements = { ['"'] = '\\"', ['\\'] = '\\\\', ['\n'] = '\\n', ['\r'] = '\\r', ['\t'] = '\\t', ['\b'] = '\\b', ['\f'] = '\\f', } return '"' .. s:gsub('[%z\1-\31"\\]', function(c) return replacements[c] or string.format('\\u%04x', string.byte(c)) end) .. '"' end json_value = function(v, indent) if v == nil then return "null" end local t = type(v) if t == "boolean" then return v and "true" or "false" end if t == "number" then if v ~= v then return "null" end -- NaN if v == math.huge or v == -math.huge then return "null" end if math.type(v) == "integer" or v == math.floor(v) then return string.format("%d", v) end return string.format("%.17g", v) end if t == "string" then return json_escape_string(v) end if t == "table" then return json_table(v, indent) end error("json: cannot serialize value of type " .. t) end json_table = function(t, indent) -- Detect array vs object by checking integer keys 1..n local n = #t local is_array = n > 0 if is_array then for k, _ in pairs(t) do if type(k) ~= "number" then is_array = false; break end end end local indent_next = indent .. " " if is_array then if n == 0 then return "[]" end local parts = {} for i = 1, n do parts[i] = indent_next .. json_value(t[i], indent_next) end return "[\n" .. table.concat(parts, ",\n") .. "\n" .. indent .. "]" end -- Object: collect & sort keys for determinism local keys = {} for k, _ in pairs(t) do table.insert(keys, tostring(k)) end table.sort(keys) if #keys == 0 then return "{}" end local parts = {} for _, k in ipairs(keys) do parts[#parts + 1] = indent_next .. json_escape_string(k) .. ": " .. json_value(t[k], indent_next) end return "{\n" .. table.concat(parts, ",\n") .. "\n" .. indent .. "}" end -- ===================================================================== -- v2-Map serialization: reverse the internal resolved shape back to the -- on-disk JSON schema (atlases as ID strings, layers in canonical order). -- ===================================================================== local LAYER_DISK_ORDER = { "foundation", "subsurface", "surface", "topsurface", "lower_wall", "wall", "upper_wall", "canopy", } local function serialize_map_v2(map) local out = { schema_version = 2, id = map.id, size = { w = map.size.w, h = map.size.h }, atlases = {}, layers = {}, } -- Use atlas_aliases (raw string IDs from disk) for round-trip fidelity. for i, alias in ipairs(map.atlas_aliases) do out.atlases[i] = alias end for _, layer_name in ipairs(LAYER_DISK_ORDER) do local layer = map.layers[layer_name] if layer then local tiles_copy = {} for i = 1, #layer.tiles do tiles_copy[i] = layer.tiles[i] end out.layers[layer_name] = { tiles = tiles_copy } end end if map.roof then local roof_copy = {} for i = 1, #map.roof do roof_copy[i] = map.roof[i] end out.roof = roof_copy end return json_value(out, "") .. "\n" end function M.save_to_disk(map_id, path) local map = require_map(map_id) local json_str = serialize_map_v2(map) local f, err = io.open(path, "w") if not f then error(string.format("maps.save_to_disk: cannot open '%s' (%s)", path, err or "?")) end f:write(json_str) f:close() map._dirty = false end function M.is_indoor(x, y, map_id) local id = map_id or current_map_id if not id then error("maps.is_indoor: no current map") end local m = map_registry[id] if not m.roof then return false end if x < 0 or x >= m.size.w or y < 0 or y >= m.size.h then return false end return m.roof[y * m.size.w + x + 1] == 1 end -- ===================================================================== -- Atlas-Loading (M.2: paired-PNG diffuse + height atlases) -- Replaces the M.1-era tilemap-indexed loader. Each atlas-set lives at -- /assets/atlases// with four files: -- tiles.diffuse.atlas.png — RGBA8888 packed atlas -- tiles.height.atlas.png — L8 grayscale heightmap -- tiles.atlas.json — metadata + per-tile UV-rects -- tiles.atlas.lock.json — name -> id stable bindings (not loaded -- at runtime; only used by the baker) -- ===================================================================== function M.load_textures(asset_aliases) local map_id = M.current() if map_id == nil then error("maps.load_textures: no current map; call maps.set_current first") end local m = map_registry[map_id] if m.schema_version ~= 2 then error("maps.load_textures: only schema-v2 maps supported in v0.3.0+") end for atlas_idx, atlas_alias in ipairs(m.atlas_aliases) do local lib_id = asset_aliases[atlas_alias] if lib_id == nil then error(string.format( "maps.load_textures: asset-alias '%s' not in module asset_aliases", atlas_alias)) end local base = lib_id .. "/assets/atlases/" .. atlas_alias local meta = engine.asset.load_json(base .. "/tiles.atlas.json") if meta.atlas_id ~= atlas_alias then error(string.format( "maps.load_textures: atlas_id mismatch in %s (json has '%s')", base, meta.atlas_id)) end -- Build tile-record dictionary keyed by integer ID local tiles_by_id = {} for _, t in ipairs(meta.tiles) do tiles_by_id[t.id] = { id = t.id, name = t.name, walkable = (t.walkable == true), uv = { x = t.uv[1], y = t.uv[2], w = t.uv[3], h = t.uv[4] }, blocks_sight = t.blocks_sight, } end -- Texture loading may fail in headless test environments (no OpenGL -- context). Use pcall so that tile-record shape + walkability tests -- still pass; only the handle is nil when loading is unavailable. local ok_d, diffuse_h = pcall(engine.asset.load_texture, base .. "/tiles.diffuse.atlas.png") local ok_h, height_h = pcall(engine.asset.load_texture, base .. "/tiles.height.atlas.png") m.atlases[atlas_idx] = { id = atlas_alias, atlas_id = meta.atlas_id, atlas_size_px = meta.atlas_size_px, tile_size_px = meta.tile_size_px, tiles = tiles_by_id, diffuse_texture_handle = ok_d and diffuse_h or nil, height_texture_handle = ok_h and height_h or nil, } end -- Update m.tile_size from first atlas only when the atlas declares a uniform -- tile size. Variable-size atlases (tile_size_px = null) keep the tile_size -- that was set by the stub or tilemap JSON at load time (typically 32). if m.atlases[1] and m.atlases[1].tile_size_px ~= nil then m.tile_size = m.atlases[1].tile_size_px end end -- Returns the diffuse atlas-texture-handle for a layer, IF all cells in -- the layer reference the same atlas-index. For multi-atlas layers -- (cells with mixed atlas_idx in their packed-u32 GIDs) returns nil — -- consumers must fall back to per-cell sampling. (M.2) function M.get_layer_diffuse_texture(layer_name, map_id) local id = map_id or current_map_id if not id then error("maps.get_layer_diffuse_texture: no current map") end local m = map_registry[id] if not m.layers or not m.layers[layer_name] then return nil end local tiles = m.layers[layer_name].tiles local atlas_idx = nil for i = 1, #tiles do local gid = tiles[i] or 0 if gid ~= 0 then local a = (gid >> 24) & 0xFF if atlas_idx == nil then atlas_idx = a elseif atlas_idx ~= a then return nil -- mixed atlases end end end if atlas_idx == nil then return nil end -- empty layer local atlas = m.atlases[atlas_idx + 1] return atlas and atlas.diffuse_texture_handle or nil end -- Same as above but for the height-channel. (M.2) function M.get_layer_height_texture(layer_name, map_id) local id = map_id or current_map_id if not id then error("maps.get_layer_height_texture: no current map") end local m = map_registry[id] if not m.layers or not m.layers[layer_name] then return nil end local tiles = m.layers[layer_name].tiles local atlas_idx = nil for i = 1, #tiles do local gid = tiles[i] or 0 if gid ~= 0 then local a = (gid >> 24) & 0xFF if atlas_idx == nil then atlas_idx = a elseif atlas_idx ~= a then return nil end end end if atlas_idx == nil then return nil end local atlas = m.atlases[atlas_idx + 1] return atlas and atlas.height_texture_handle or nil end function M.iterate_layers_pre_entities(fn, map_id) local id = map_id or current_map_id if not id then error("maps.iterate_layers_pre_entities: no current map") end local m = map_registry[id] if m.schema_version ~= 2 then -- v1 (legacy): only surface conceptually fn("surface") return end for _, name in ipairs(LAYER_ORDER_PRE_ENTITIES) do if m.layers[name] then fn(name) end end end function M.iterate_layers_post_entities(fn, map_id) local id = map_id or current_map_id if not id then error("maps.iterate_layers_post_entities: no current map") end local m = map_registry[id] if m.schema_version ~= 2 then return end -- v1 has nothing post for _, name in ipairs(LAYER_ORDER_POST_ENTITIES) do if m.layers[name] then fn(name) end end end function M.draw_map_pre_entities() local id = current_map_id if id == nil then return end local m = map_registry[id] if m.schema_version ~= 2 then draw_v1_legacy(m) return end for _, name in ipairs(LAYER_ORDER_PRE_ENTITIES) do if m.layers[name] then draw_layer(m, name) end end end function M.draw_map_post_entities() local id = current_map_id if id == nil then return end local m = map_registry[id] if m.schema_version ~= 2 then return end for _, name in ipairs(LAYER_ORDER_POST_ENTITIES) do if m.layers[name] then draw_layer(m, name) end end end -- Backward-compat: draw_map renders everything (pre + post, no entity slot) function M.draw_map() M.draw_map_pre_entities() M.draw_map_post_entities() 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 M.encode_gid = encode_gid M.decode_gid = decode_gid M.upgrade_v1_to_v2 = upgrade_v1_to_v2 M.validate_map_table_v2 = validate_map_table_v2 return M