-- ===================================================================== -- 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→v3 Auto-Upgrade (transparent beim Load) -- v1: { id, tilemap, size, tiles[], tile_rotations? } -- v2: { schema_version, id, size, atlases[], layers: { : { tiles[] } }, roof? } -- v3: v2 + per-layer optional { material, vertices, overrides } for -- future autotile + force-override paths. tiles[] remains the legacy -- cell-grid representation; vertices+overrides are forward-compat -- stubs not yet wired into the renderer (lib v0.5.0a). -- ===================================================================== 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 v2→v3: no-op shape transformation for 0.5.0a. v3 adds three -- optional per-layer fields (material, vertices, overrides) that the -- renderer does not yet honour. Just bumps the version so the v3 -- validator + runtime path accept the map. local function upgrade_v2_to_v3(v2) return { schema_version = 3, id = v2.id, size = v2.size, atlases = v2.atlases, layers = v2.layers, roof = v2.roof, } end -- ===================================================================== -- Schema-validation helper (used by all version-specific 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 -- ===================================================================== -- 0.5.0e: layer z-index for the opaque-ceiling-cache. Higher = drawn -- later = on top. Cells whose opaque-ceiling z is GREATER than the -- current layer's z can be skipped (a higher opaque layer covers -- whatever this layer would have drawn). Values are 1-based so 0 can -- mean "no opaque ceiling at this cell". Declared early because -- cell_is_opaque_on_layer + compute_opaque_ceiling_cache (defined -- below) reference these as upvalues. local LAYER_Z = { foundation = 1, subsurface = 2, surface = 3, topsurface = 4, lower_wall = 5, wall = 6, upper_wall = 7, canopy = 8, } local LAYER_ORDER_TOP_DOWN = { "canopy", "upper_wall", "wall", "lower_wall", "topsurface", "surface", "subsurface", "foundation", } -- Dual-Grid Autotile (Schema-v3 vertex-painted layers, 0.5.0c) -- -- Two grids, offset by half a tile. The PAINT grid (called `vertices` -- in code, "map-tiles" in design docs) stores material flags at -- integer positions. The RENDER grid (called `cells`) is offset by -- (+0.5, +0.5) tile from the paint grid and is where sprites sit. -- Each render-cell (x, y) spans 4 surrounding paint-tiles, which act -- as its 4 corner vertices: TL=(x,y), TR=(x+1,y), BL=(x,y+1), -- BR=(x+1,y+1). -- -- Material rule ("any-corner"): render-cell (x, y) is material iff -- any of its 4 corner paint-tiles is painted. A single painted tile -- thus produces a 2x2 of material render-cells centred on it. -- -- Bitmask rule (0.5.6, dual-grid native): each render-cell's 8-bit -- neighbour bitmask (clockwise from N: N, NE, E, SE, S, SW, W, NW) -- is derived from its own 4 corner paint-tiles. A cardinal bit is -- set iff at least one of the 2 paint-tiles on that edge is painted; -- a diagonal bit is set iff the corner paint-tile in that direction -- is painted. Then blob-gating zeroes any diagonal bit whose 2 -- adjacent cardinal bits are not both set. The gated bitmask -- resolves through SLOT_LOOKUP into one of the 14 canonical -- S-V2E2-RM-Blob slots plus the rotation + flip that transforms the -- canonical sprite into the rendered one. -- -- Pre-0.5.6 derived the bitmask from the material status of the 8 -- neighbour render-cells, which violated dual-grid semantics: two -- render-cells with an empty shared edge could see each other as -- material whenever any unrelated corner of either was painted, -- producing connected blobs across visually empty paint-tile gaps. -- ===================================================================== -- Canonical 14 slots, keyed by their lowest-numbered representative -- bitmask (matches `prototype-blob-geom` slot enumeration §2.2 of the -- design paper). local CANONICAL_SLOT_OF_BITMASK = { [0x00] = 0, -- isolated [0x01] = 1, -- end (N) [0x05] = 2, -- corner_open (N+E, no diag) [0x07] = 3, -- corner_full (N+E+NE) [0x11] = 4, -- straight (N+S) [0x15] = 5, -- tee_open (N+E+S, no diags) [0x17] = 6, -- tee_half (N+E+S + NE) [0x1F] = 7, -- tee_full (N+E+S + NE + SE) [0x55] = 8, -- cross_open (4 cardinals) [0x57] = 9, -- cross_q1 (+1 diag NE) [0x5F] = 10, -- cross_q2adj (+NE +SE) [0x77] = 11, -- cross_q2opp (+NE +SW) [0x7F] = 12, -- cross_q3 (+NE +SE +SW) [0xFF] = 13, -- solid } -- Rotate the 8-bit bitmask by 90 degrees clockwise. N->E, E->S, S->W, -- W->N (and diagonals shift one step CW too). In bit terms each bit -- moves by +2 positions modulo 8. local function rotate_bitmask_90cw(b) return ((b << 2) | (b >> 6)) & 0xFF end -- Mirror the 8-bit bitmask across the vertical axis. N stays, S stays, -- E<->W, NE<->NW, SE<->SW. local MIRROR_SRC_TO_DST = { [0] = 0, [1] = 7, [2] = 6, [3] = 5, [4] = 4, [5] = 3, [6] = 2, [7] = 1 } local function mirror_bitmask_h(b) local out = 0 for src = 0, 7 do if ((b >> src) & 1) == 1 then out = out | (1 << MIRROR_SRC_TO_DST[src]) end end return out end -- Apply blob-gating: zero each diagonal bit unless both adjacent -- cardinals are set. NE needs N+E; SE needs S+E; SW needs S+W; -- NW needs N+W. local function apply_blob_gating(b) local N = (b >> 0) & 1 local E = (b >> 2) & 1 local S = (b >> 4) & 1 local W = (b >> 6) & 1 local out = b if N == 0 or E == 0 then out = out & ~(1 << 1) end if S == 0 or E == 0 then out = out & ~(1 << 3) end if S == 0 or W == 0 then out = out & ~(1 << 5) end if N == 0 or W == 0 then out = out & ~(1 << 7) end return out end -- Build SLOT_LOOKUP at module-init. For each of the 14 canonical -- patterns C, walk all 8 D4 transforms (4 rotations x mirror) and -- record { slot, rot, flip } at the transformed bitmask. The renderer -- then does a direct bitmask -> draw-params lookup at draw time. local SLOT_LOOKUP = {} for canonical_bitmask, slot_index in pairs(CANONICAL_SLOT_OF_BITMASK) do for flip = 0, 1 do local p_flipped = canonical_bitmask if flip == 1 then p_flipped = mirror_bitmask_h(p_flipped) end local p = p_flipped for rot = 0, 3 do if SLOT_LOOKUP[p] == nil then SLOT_LOOKUP[p] = { slot = slot_index, rot = rot, flip = flip } end p = rotate_bitmask_90cw(p) end end end -- Read a vertex flag from a vertex-grid. The grid is (w+1) x (h+1) -- stored row-major. Out-of-bounds vertices read as 0 (unpainted). local function vertex_at(vertices, vw, vh, vx, vy) if vx < 0 or vx >= vw or vy < 0 or vy >= vh then return 0 end local v = vertices[vy * vw + vx + 1] if v == nil or v == 0 or v == false then return 0 end return 1 end -- 0.5.7: read the cell-tile material flag at (cx, cy). Returns 0 if -- layer.cells_material is absent or the entry is unset/false. local function cells_material_at(cells_material, w, h, cx, cy) if cells_material == nil then return 0 end if cx < 0 or cx >= w or cy < 0 or cy >= h then return 0 end local v = cells_material[cy * w + cx + 1] if v == nil or v == 0 or v == false then return 0 end return 1 end -- 0.5.7: lazy-allocate the per-cell material array on a layer. Returns -- the (now-guaranteed-present) array. Safe to call repeatedly. local function ensure_cells_material(layer, w, h) if layer.cells_material == nil then local n = w * h local cm = {} for i = 1, n do cm[i] = 0 end layer.cells_material = cm end return layer.cells_material end -- Any-corner rule + 0.5.7 cell-tile rule: cell (x, y) is material iff -- (any of its 4 corner vertices is painted) OR (cells_material[x,y] is -- set). Out-of-grid cells are non-material. local function cell_has_material(vertices, cells_material, w, h, x, y) if x < 0 or x >= w or y < 0 or y >= h then return false end if cells_material_at(cells_material, w, h, x, y) ~= 0 then return true end local vw = w + 1 local vh = h + 1 if vertex_at(vertices, vw, vh, x, y ) ~= 0 then return true end if vertex_at(vertices, vw, vh, x + 1, y ) ~= 0 then return true end if vertex_at(vertices, vw, vh, x, y + 1) ~= 0 then return true end if vertex_at(vertices, vw, vh, x + 1, y + 1) ~= 0 then return true end return false end -- Returns the blob-gated 8-bit neighbour bitmask for cell (x, y). -- Bit layout (clockwise from N, LSB-first): N, NE, E, SE, S, SW, W, NW. -- -- 0.5.7: per-cell-origin branch. Cells whose material origin is -- cells_material[x, y] use the classical 47-blob 8-neighbour rule on -- effective material (vertex-derived OR cell-derived). Vertex-only -- cells continue to use the FIX-A 4-own-corner rule (v0.5.6 dual-grid -- semantics, 5 reachable atlas slots). -- -- Pre-condition: cell_has_material(vertices, cells_material, w, h, x, y) -- returned true for this cell. local function compute_cell_bitmask_v3(vertices, cells_material, w, h, x, y) local b = 0 if cells_material_at(cells_material, w, h, x, y) ~= 0 then -- 47-blob branch: 8 neighbour effective-material bits. if cell_has_material(vertices, cells_material, w, h, x, y - 1) then b = b | 0x01 end if cell_has_material(vertices, cells_material, w, h, x + 1, y - 1) then b = b | 0x02 end if cell_has_material(vertices, cells_material, w, h, x + 1, y ) then b = b | 0x04 end if cell_has_material(vertices, cells_material, w, h, x + 1, y + 1) then b = b | 0x08 end if cell_has_material(vertices, cells_material, w, h, x, y + 1) then b = b | 0x10 end if cell_has_material(vertices, cells_material, w, h, x - 1, y + 1) then b = b | 0x20 end if cell_has_material(vertices, cells_material, w, h, x - 1, y ) then b = b | 0x40 end if cell_has_material(vertices, cells_material, w, h, x - 1, y - 1) then b = b | 0x80 end else -- FIX-A 4-corner-vertex branch (v0.5.6 dual-grid, unchanged). local vw = w + 1 local vh = h + 1 local TL = vertex_at(vertices, vw, vh, x, y ) ~= 0 local TR = vertex_at(vertices, vw, vh, x + 1, y ) ~= 0 local BL = vertex_at(vertices, vw, vh, x, y + 1) ~= 0 local BR = vertex_at(vertices, vw, vh, x + 1, y + 1) ~= 0 if TL or TR then b = b | 0x01 end -- N if TR then b = b | 0x02 end -- NE if TR or BR then b = b | 0x04 end -- E if BR then b = b | 0x08 end -- SE if BL or BR then b = b | 0x10 end -- S if BL then b = b | 0x20 end -- SW if TL or BL then b = b | 0x40 end -- W if TL then b = b | 0x80 end -- NW end return apply_blob_gating(b) end -- Build slot -> tile_id index for an atlas. Tiles named slot_NN_ -- contribute to the index. Cached on the atlas record to avoid repeat -- parsing. local function build_slot_index(atlas) local idx = {} for tile_id, t in pairs(atlas.tiles) do if t.name then local slot_str = string.match(t.name, "^slot_(%d+)_") if slot_str then idx[tonumber(slot_str)] = tile_id end end end return idx end local function atlas_slot_index(atlas) if atlas._slot_index == nil then atlas._slot_index = build_slot_index(atlas) end return atlas._slot_index end -- v0.5.1 override-format helper: normalize a stored override entry to -- {slot, rot, flip}. Disk-compact form is a bare integer slot_id (back- -- wards-compat with 0.5.0d); object form `{slot, rot?, flip?}` ships -- rotation+flip explicitly for direct-mode terrain-override placement -- with non-canonical orientation. Returns nil for malformed input. local function normalize_override_entry(entry) if type(entry) == "number" then return { slot = entry, rot = 0, flip = 0 } end if type(entry) == "table" and type(entry.slot) == "number" then return { slot = entry.slot, rot = entry.rot or 0, flip = entry.flip or 0, } end return nil end -- Predicate "is cell (x, y) opaque on layer_name". Used by the -- opaque-ceiling cache. Reads the actual atlas tile.opaque flag set -- by atlas-baker v0.2.0+ alpha-analysis (was previously a slot-13 -- heuristic in 0.5.0e — replaced now that the real data is available). -- -- Pre-v0.2.0 atlases without the opaque flag set will report all- -- not-opaque, harmlessly forcing the renderer to draw layers it could -- have skipped. Safe-conservative. local function cell_is_opaque_on_layer(map, layer_name, x, y) local layer = map.layers[layer_name] if not layer then return false end if layer.vertices then -- Resolve the slot that would render at this cell (override -- first, else bitmask), then look up the actual tile.opaque -- flag in the layer's material atlas. local atlas = layer.material and map.atlas_by_alias and map.atlas_by_alias[layer.material] if not atlas then return false end local override_entry = layer.overrides and layer.overrides[x .. ":" .. y] local slot if override_entry ~= nil then local norm = normalize_override_entry(override_entry) if not norm then return false end slot = norm.slot else if not cell_has_material(layer.vertices, layer.cells_material, map.size.w, map.size.h, x, y) then return false end local bitmask = compute_cell_bitmask_v3(layer.vertices, layer.cells_material, map.size.w, map.size.h, x, y) local rec = SLOT_LOOKUP[bitmask] if not rec then return false end slot = rec.slot end local tile_id = atlas_slot_index(atlas)[slot] local tile = tile_id and atlas.tiles[tile_id] return tile and tile.opaque == true or false end if layer.tiles then local gid = layer.tiles[y * map.size.w + x + 1] or 0 if gid == 0 then return false end local atlas_idx, tile_id, _rot = decode_gid(gid) local atlas = map.atlases[atlas_idx + 1] if not atlas then return false end local tile = atlas.tiles[tile_id] return tile and tile.opaque == true or false end return false end -- Compute per-cell opaque-ceiling. For each cell, return the z-index -- of the topmost layer with an opaque tile, or 0 for no ceiling. local function compute_opaque_ceiling_cache(map) local cells = map.size.w * map.size.h local cache = {} for i = 1, cells do cache[i] = 0 end for _, layer_name in ipairs(LAYER_ORDER_TOP_DOWN) do local z = LAYER_Z[layer_name] for y = 0, map.size.h - 1 do for x = 0, map.size.w - 1 do local idx = y * map.size.w + x + 1 if cache[idx] == 0 and cell_is_opaque_on_layer(map, layer_name, x, y) then cache[idx] = z end end end end return cache end -- Lazily compute (or return cached) opaque-ceiling for a map. Writes -- nil out the cache; the next render call rebuilds it. local function ensure_opaque_ceiling(map) if map._opaque_ceiling == nil then map._opaque_ceiling = compute_opaque_ceiling_cache(map) end return map._opaque_ceiling 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, } -- (Moved up earlier — see autotile section) 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 -- v3 validator. Same as v2 except: -- * schema_version must be 3 -- * per-layer 'material' (string), 'vertices' (array), 'overrides' (table) -- are optional. If present they must be the right type. Renderer does -- not yet honour them (forward-compat for 0.5.0c/d). local function validate_map_table_v3(t, source) require_field(t, "schema_version", "number", source) if t.schema_version ~= 3 then error(string.format("maps.load: schema_version %d not supported (expected 3)", 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 lsrc = source .. ".layers." .. layer_name -- tiles[] is the legacy cell-grid representation (kept from v2). -- Required for any non-empty layer in 0.5.0a (autotile path comes in 0.5.0c). if layer_data.tiles ~= nil then if type(layer_data.tiles) ~= "table" then error(string.format("maps.load: schema violation in %s: tiles must be array", lsrc)) end if #layer_data.tiles ~= expected then error(string.format("maps.load: schema violation in %s: tiles length %d != %d", lsrc, #layer_data.tiles, expected)) end end -- Forward-compat fields (0.5.0c/d will wire them into the renderer). if layer_data.material ~= nil and type(layer_data.material) ~= "string" then error(string.format("maps.load: schema violation in %s: material must be string", lsrc)) end if layer_data.vertices ~= nil and type(layer_data.vertices) ~= "table" then error(string.format("maps.load: schema violation in %s: vertices must be array", lsrc)) end -- 0.5.7: optional cells_material (W*H bool array, lazy-allocated) if layer_data.cells_material ~= nil then if type(layer_data.cells_material) ~= "table" then error(string.format("maps.load: schema violation in %s: layer '%s' field 'cells_material' must be table, got %s", source, layer_name, type(layer_data.cells_material))) end local expected_cm = size.w * size.h if #layer_data.cells_material ~= expected_cm then error(string.format("maps.load: schema violation in %s: layer '%s' field 'cells_material' length %d != W*H %d", source, layer_name, #layer_data.cells_material, expected_cm)) end end if layer_data.overrides ~= nil then if type(layer_data.overrides) ~= "table" then error(string.format("maps.load: schema violation in %s: overrides must be table", lsrc)) end -- v0.5.1: each override entry is either a bare int (compact -- canonical-orientation form) or an object {slot, rot?, flip?} -- for explicit-transform placement. Validate per-entry. for k, v in pairs(layer_data.overrides) do if type(v) == "number" then if v ~= math.floor(v) or v < 0 or v > 13 then error(string.format( "maps.load: schema violation in %s.overrides[%s]: bare slot %s must be integer 0..13", lsrc, tostring(k), tostring(v))) end elseif type(v) == "table" then if type(v.slot) ~= "number" or v.slot ~= math.floor(v.slot) or v.slot < 0 or v.slot > 13 then error(string.format( "maps.load: schema violation in %s.overrides[%s]: object must have slot integer 0..13", lsrc, tostring(k))) end if v.rot ~= nil and (type(v.rot) ~= "number" or v.rot < 0 or v.rot > 3) then error(string.format( "maps.load: schema violation in %s.overrides[%s]: rot must be 0..3", lsrc, tostring(k))) end if v.flip ~= nil and v.flip ~= 0 and v.flip ~= 1 then error(string.format( "maps.load: schema violation in %s.overrides[%s]: flip must be 0 or 1", lsrc, tostring(k))) end else error(string.format( "maps.load: schema violation in %s.overrides[%s]: value must be integer slot or {slot, rot?, flip?} object", lsrc, tostring(k))) end end end -- Reserved per-layer fields (design paper §8 + §14b + §15.1). -- Accept-but-ignore today; future slices wire them into the -- renderer / collision-resolver / movement-mode system. -- Type-checks here catch malformed values early instead of -- letting them survive as silent garbage. if layer_data.base_color ~= nil and type(layer_data.base_color) ~= "string" then error(string.format("maps.load: schema violation in %s: base_color must be string (#RRGGBB)", lsrc)) end if layer_data.tint_override ~= nil and type(layer_data.tint_override) ~= "table" then error(string.format("maps.load: schema violation in %s: tint_override must be table", lsrc)) end if layer_data.collision_policy ~= nil and type(layer_data.collision_policy) ~= "table" then error(string.format("maps.load: schema violation in %s: collision_policy must be table", lsrc)) end if layer_data.traversal_modes ~= nil and type(layer_data.traversal_modes) ~= "table" then error(string.format("maps.load: schema violation in %s: traversal_modes must be table", lsrc)) end if layer_data.foundation_mode ~= nil and type(layer_data.foundation_mode) ~= "table" then error(string.format("maps.load: schema violation in %s: foundation_mode must be array (per-cell 0/1)", lsrc)) end end end -- Reserved map-level fields for §14c multi-z-level support. -- Accept-but-ignore today; future lib-core.maps v0.6 wires them -- into a recursive load+link + foundation-reveal-composite pipeline. if t.z_level ~= nil and type(t.z_level) ~= "number" then error(string.format("maps.load: schema violation in %s: z_level must be integer", source)) end if t.z_below ~= nil and type(t.z_below) ~= "string" then error(string.format("maps.load: schema violation in %s: z_below must be string map-id reference", source)) 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 -- v3 runtime build: structurally identical to v2 today (tiles[] is still -- the cell-grid representation). material/vertices/overrides per layer are -- preserved on the runtime record but not yet consumed by the renderer. local function build_map_v3(t_map, resolved_atlases) local size = t_map.size 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 -- Build alias->atlas lookup for the v3 autotile path (layer.material -- field is an atlas-alias string). local atlas_by_alias = {} for i, alias in ipairs(t_map.atlases or {}) do atlas_by_alias[alias] = resolved_atlases[i] end -- 0.5.0e: precompute per-layer has-content bitmap so the renderer can -- skip layers that are present in the map JSON but contain no -- non-empty data. Layers absent from t_map.layers are simply not in -- this dict (effectively false). local layer_has_content = {} local expected_cells = size.w * size.h for layer_name, layer_data in pairs(layers) do if VALID_LAYER_NAMES[layer_name] then local has = false if layer_data.tiles then for i = 1, expected_cells do if (layer_data.tiles[i] or 0) ~= 0 then has = true; break end end end if not has and layer_data.vertices then for i = 1, #layer_data.vertices do local v = layer_data.vertices[i] if v == 1 or v == true then has = true; break end end end if not has and layer_data.overrides then for _ in pairs(layer_data.overrides) do has = true; break end end layer_has_content[layer_name] = has end end return { id = t_map.id, schema_version = 3, size = size, tile_size = resolved_atlases[1].tile_size, atlases = resolved_atlases, atlas_aliases = t_map.atlases, atlas_by_alias = atlas_by_alias, layers = layers, -- shallow-ref; preserves material/vertices/overrides if present _layer_has_content = layer_has_content, roof = t_map.roof, 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 -- 0.5.0e: opaque-ceiling cache lets us skip cells where a higher -- layer fully covers what would be drawn here. Lazily built on -- first render after load or after a write invalidates it. -- TEMPORARILY DISABLED while debugging vagrant nil-index regression. local layer_z = LAYER_Z[layer_name] local ceiling = layer_z and ensure_opaque_ceiling(m) or nil -- v3 autotile path: layer has a vertex grid + a material atlas -- reference. Each cell's tile + transform is computed from the -- 8-bit blob bitmask against the canonical 47->14 lookup. Empty -- cells (no-corner-painted) skip rendering entirely. Sparse -- overrides take precedence over the bitmask path (0.5.0d) and -- also force material-presence at the cell even when the vertex -- grid would otherwise leave it empty. if layer.vertices and layer.material then local atlas = m.atlas_by_alias and m.atlas_by_alias[layer.material] local overrides = layer.overrides if atlas == nil or atlas.diffuse_texture_handle == nil then for y = 0, sz.h - 1 do for x = 0, sz.w - 1 do local has_ovr = overrides and overrides[x .. ":" .. y] ~= nil if has_ovr or cell_has_material(layer.vertices, layer.cells_material, sz.w, sz.h, x, y) then engine.render.draw_rect(x * ts, y * ts, ts, ts, MISSING_ASSET_COLOR) end end end return end local slot_idx = atlas_slot_index(atlas) for y = 0, sz.h - 1 do for x = 0, sz.w - 1 do local idx = y * sz.w + x + 1 if ceiling and layer_z and ceiling[idx] > layer_z then goto continue -- higher opaque layer covers this cell end local override_entry = overrides and overrides[x .. ":" .. y] local has_material = override_entry ~= nil or cell_has_material(layer.vertices, layer.cells_material, sz.w, sz.h, x, y) if not has_material then goto continue end local slot_index, rot, flip if override_entry ~= nil then -- v0.5.1: override entry can be bare int (canonical -- orientation, backwards-compat) or {slot, rot, flip} -- object for explicit transform placement. local norm = normalize_override_entry(override_entry) if norm then slot_index, rot, flip = norm.slot, norm.rot, norm.flip end else local bitmask = compute_cell_bitmask_v3(layer.vertices, layer.cells_material, sz.w, sz.h, x, y) local rec = SLOT_LOOKUP[bitmask] if rec then slot_index, rot, flip = rec.slot, rec.rot, rec.flip end end local tile_id = slot_index and slot_idx[slot_index] or nil local tile = tile_id and atlas.tiles[tile_id] or nil local px = x * ts local py = y * ts if tile and tile.uv then local rot_deg = rot * 90.0 local scale_x = ts / tile.uv.w local scale_y = ts / tile.uv.h if flip == 1 then scale_x = -scale_x end engine.render.draw_sprite_transform( atlas.diffuse_texture_handle, px + ts / 2, py + ts / 2, math.rad(rot_deg), scale_x, scale_y, ts / 2, ts / 2, 0xFFFFFFFF, tile.uv.x, tile.uv.y, tile.uv.w, tile.uv.h ) else engine.render.draw_rect(px, py, ts, ts, MISSING_ASSET_COLOR) end ::continue:: end end return end -- Legacy v2 tiles[] path (cell-grid of packed-u32 GIDs). if not layer.tiles then return end for y = 0, sz.h - 1 do for x = 0, sz.w - 1 do local idx = y * sz.w + x + 1 if ceiling and layer_z and ceiling[idx] > layer_z then goto tiles_continue -- 0.5.0e opaque-ceiling skip end 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 ::tiles_continue:: 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 if raw.schema_version == 2 then raw = upgrade_v2_to_v3(raw) engine.print(string.format("maps.load: auto-upgraded v2 map '%s' to v3", raw.id or "")) end validate_map_table_v3(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_v3(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 -- 0.5.4: Public accessors for palette-like consumers (map-editor) that -- need to render real tile thumbnails. All three return nil when the -- atlas-idx is out of range, the slot has no tile in the atlas, or the -- texture failed to load (headless test env). function M.atlas_diffuse_handle(atlas_idx, map_id) local id = map_id or current_map_id if not id then error("maps.atlas_diffuse_handle: no current map") end local m = map_registry[id] local atlas = m.atlases and m.atlases[atlas_idx + 1] return atlas and atlas.diffuse_texture_handle or nil end function M.atlas_tile_size_px(atlas_idx, map_id) local id = map_id or current_map_id if not id then error("maps.atlas_tile_size_px: no current map") end local m = map_registry[id] local atlas = m.atlases and m.atlases[atlas_idx + 1] return atlas and atlas.tile_size_px or nil end -- 0.5.5: public version of what the v3 vertex/material render path -- computes internally. Returns {slot, rot, flip} for a material cell -- (override entry takes precedence, else vertex-bitmask-derived), or -- nil when the cell is non-material / the layer is non-material-bound. -- Primary consumer: debug-overlay tooling that wants to label each -- rendered cell with its blob-14 slot identity. function M.cell_material_slot(layer_name, x, y, map_id) local id = map_id or current_map_id if not id then error("maps.cell_material_slot: no current map") end local m = map_registry[id] if not m.layers or not m.layers[layer_name] then return nil end local layer = m.layers[layer_name] if not layer.material then return nil end if x < 0 or x >= m.size.w or y < 0 or y >= m.size.h then return nil end -- Override wins (sparse force-slot map) local entry = layer.overrides and layer.overrides[x .. ":" .. y] if entry ~= nil then return normalize_override_entry(entry) end -- Vertex-derived bitmask path (only if the layer has a vertex grid) if not layer.vertices then return nil end if not cell_has_material(layer.vertices, layer.cells_material, m.size.w, m.size.h, x, y) then return nil end local bitmask = compute_cell_bitmask_v3(layer.vertices, layer.cells_material, m.size.w, m.size.h, x, y) return SLOT_LOOKUP[bitmask] end function M.atlas_tile_uv(atlas_idx, slot, map_id) local id = map_id or current_map_id if not id then error("maps.atlas_tile_uv: no current map") end local m = map_registry[id] local atlas = m.atlases and m.atlases[atlas_idx + 1] if not atlas or not atlas.tiles then return nil end local slot_idx = atlas_slot_index(atlas) local tile_id = slot_idx[slot] local tile = tile_id and atlas.tiles[tile_id] return tile and tile.uv or nil 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 local layer = m.layers[layer_name] -- v3 vertex-painted layer: return a sentinel non-zero for presence -- checks. callers that need the encoded GID (e.g. blocks_sight per- -- tile flag) get the safe default behaviour because decode_gid(1) -- yields a missing tile-record. Overrides (sparse force-slot map) -- also count as material-presence so is_walkable + blocks_walk + -- blocks_sight treat override-placed cells consistently with -- vertex-derived cells. if layer.vertices and not layer.tiles then if layer.overrides and layer.overrides[x .. ":" .. y] ~= nil then return 1 end return cell_has_material(layer.vertices, layer.cells_material, m.size.w, m.size.h, x, y) and 1 or 0 end return layer.tiles and layer.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 -- 0.5.0e: incremental layer-content cache invalidation. -- Writes that ADD content (gid != 0) mark the layer present; -- writes that clear (gid == 0) leave the cache as-is — the worst -- case is one extra iteration of an empty layer, harmless. if gid ~= 0 and map._layer_has_content then map._layer_has_content[layer_name] = true end -- Opaque-ceiling cache also depends on this cell; full nil-out and -- lazy rebuild on next render is simplest + correct. map._opaque_ceiling = nil end -- v3 vertex-painted-layer override APIs. -- 0.5.0d: override forces a specific canonical slot (0..13) at a cell, -- bypassing the bitmask autotile result. Override implies -- cell-has-material regardless of the vertex-grid state. -- 0.5.1: override entry can be bare integer slot_id (canonical orientation, -- compact-form, backwards-compat) OR object {slot, rot?, flip?} -- for explicit-transform direct-mode placement. -- Stored as sparse map keyed "x:y" -> entry. local function require_v3_layer(map, layer_name, fn_name) if map.schema_version < 3 then error(string.format("%s: schema-v3 only; current map is v%d", fn_name, map.schema_version)) end if not VALID_LAYER_NAMES[layer_name] then error(string.format("%s: unknown layer '%s' (valid: %s)", fn_name, layer_name, table.concat(get_valid_layer_names_list(), ", "))) end local layer = map.layers[layer_name] if not layer then error(string.format("%s: layer '%s' not present on map '%s'", fn_name, layer_name, map.id)) end return layer end local function check_coords(map, x, y, fn_name) if x < 0 or y < 0 or x >= map.size.w or y >= map.size.h then error(string.format("%s: cell (%d,%d) out of bounds for size %dx%d", fn_name, x, y, map.size.w, map.size.h)) end end -- 0.5.1: accepts either bare slot_id (compact form) or table -- {slot, rot?, flip?} (explicit transform). Auto-stores as compact form -- when rot+flip are both 0, else stores as object — minimises disk diff -- for the common canonical-orientation case. function M.set_override(layer_name, x, y, slot_or_entry, map_id) local map = require_map(map_id) check_coords(map, x, y, "maps.set_override") local layer = require_v3_layer(map, layer_name, "maps.set_override") local stored if type(slot_or_entry) == "number" then if slot_or_entry < 0 or slot_or_entry > 13 or slot_or_entry ~= math.floor(slot_or_entry) then error(string.format("maps.set_override: slot %s must be integer 0..13", tostring(slot_or_entry))) end stored = slot_or_entry elseif type(slot_or_entry) == "table" then local slot = slot_or_entry.slot local rot = slot_or_entry.rot or 0 local flip = slot_or_entry.flip or 0 if type(slot) ~= "number" or slot < 0 or slot > 13 or slot ~= math.floor(slot) then error(string.format("maps.set_override: object.slot %s must be integer 0..13", tostring(slot))) end if type(rot) ~= "number" or rot < 0 or rot > 3 or rot ~= math.floor(rot) then error(string.format("maps.set_override: object.rot %s must be integer 0..3", tostring(rot))) end if flip ~= 0 and flip ~= 1 then error(string.format("maps.set_override: object.flip %s must be 0 or 1", tostring(flip))) end if rot == 0 and flip == 0 then stored = slot -- canonical orientation -> compact form else stored = { slot = slot, rot = rot, flip = flip } end else error(string.format("maps.set_override: must be integer slot or {slot, rot?, flip?} object, got %s", type(slot_or_entry))) end if layer.overrides == nil then layer.overrides = {} end layer.overrides[x .. ":" .. y] = stored map._dirty = true if map._layer_has_content then map._layer_has_content[layer_name] = true end map._opaque_ceiling = nil end function M.clear_override(layer_name, x, y, map_id) local map = require_map(map_id) check_coords(map, x, y, "maps.clear_override") local layer = require_v3_layer(map, layer_name, "maps.clear_override") if layer.overrides then layer.overrides[x .. ":" .. y] = nil map._dirty = true map._opaque_ceiling = nil end end -- 0.5.1: returns normalized form {slot, rot, flip} regardless of how -- the entry is stored on disk (bare-int compact OR object form). Returns -- nil when no override is set. Callers that need the on-disk shape -- should consult layer.overrides directly. function M.get_override(layer_name, x, y, map_id) local map = require_map(map_id) check_coords(map, x, y, "maps.get_override") local layer = require_v3_layer(map, layer_name, "maps.get_override") if layer.overrides == nil then return nil end local entry = layer.overrides[x .. ":" .. y] if entry == nil then return nil end return normalize_override_entry(entry) end -- 0.5.2: vertex-grid write APIs for the autotile painting path. -- Vertices live on a (W+1) x (H+1) grid; painting a single vertex -- "fills in" the 4 cells around it per the any-corner rule (cell -- becomes material). The map-editor's Auto-Tile mode is the -- primary consumer. local function check_vertex_coords(map, vx, vy, fn_name) local vw = map.size.w + 1 local vh = map.size.h + 1 if vx < 0 or vy < 0 or vx >= vw or vy >= vh then error(string.format("%s: vertex (%d,%d) out of bounds for vertex-grid %dx%d", fn_name, vx, vy, vw, vh)) end end local function ensure_vertex_grid(layer, map) if layer.vertices == nil then local vw = map.size.w + 1 local vh = map.size.h + 1 local grid = {} for i = 1, vw * vh do grid[i] = 0 end layer.vertices = grid end return layer.vertices end function M.set_vertex(layer_name, vx, vy, painted, map_id) local map = require_map(map_id) check_vertex_coords(map, vx, vy, "maps.set_vertex") local layer = require_v3_layer(map, layer_name, "maps.set_vertex") local v = ensure_vertex_grid(layer, map) local vw = map.size.w + 1 local idx = vy * vw + vx + 1 local new_val = (painted == true or painted == 1) and 1 or 0 if v[idx] ~= new_val then v[idx] = new_val map._dirty = true map._opaque_ceiling = nil -- vertex change may affect opaque ceiling -- _layer_has_content goes true on paint; never auto-cleared -- on erase (stale-true is harmless). if new_val == 1 and map._layer_has_content then map._layer_has_content[layer_name] = true end end end function M.get_vertex(layer_name, vx, vy, map_id) local map = require_map(map_id) check_vertex_coords(map, vx, vy, "maps.get_vertex") local layer = require_v3_layer(map, layer_name, "maps.get_vertex") if layer.vertices == nil then return false end local vw = map.size.w + 1 local v = layer.vertices[vy * vw + vx + 1] return v == 1 or v == true end -- 0.5.7: set/clear the per-cell material flag at (cx, cy) on the named -- layer. painted: bool. Lazy-allocates layer.cells_material on first -- write. Out-of-bounds raises a bounds error. Unknown layer raises a -- layer-not-found error. function M.set_cell_material(layer_name, cx, cy, painted, map_id) local m = require_map(map_id) local layer = m.layers[layer_name] if not layer then error(string.format("maps.set_cell_material: no layer '%s'", layer_name)) end local w, h = m.size.w, m.size.h if cx < 0 or cx >= w or cy < 0 or cy >= h then error(string.format("maps.set_cell_material: out of bounds (%d, %d) for %dx%d map", cx, cy, w, h)) end local cm = ensure_cells_material(layer, w, h) cm[cy * w + cx + 1] = painted and 1 or 0 m._dirty = true m._opaque_ceiling = nil end -- 0.5.7: read the per-cell material flag. Returns false if -- layer.cells_material is absent or the entry is unset. Does NOT -- include vertex-derived material — call cell_material_slot or render -- if you need the effective state. function M.get_cell_material(layer_name, cx, cy, map_id) local m = require_map(map_id) local layer = m.layers[layer_name] if not layer then error(string.format("maps.get_cell_material: no layer '%s'", layer_name)) end local w, h = m.size.w, m.size.h if cx < 0 or cx >= w or cy < 0 or cy >= h then return false end if layer.cells_material == nil then return false end local v = layer.cells_material[cy * w + cx + 1] return v ~= nil and v ~= 0 and v ~= false 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_v3(map) local out = { schema_version = 3, 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 layer_out = {} if layer.tiles then local tiles_copy = {} for i = 1, #layer.tiles do tiles_copy[i] = layer.tiles[i] end layer_out.tiles = tiles_copy end -- Preserve forward-compat fields if the loaded map had them set. if layer.material ~= nil then layer_out.material = layer.material end if layer.vertices ~= nil then layer_out.vertices = layer.vertices end if layer.overrides ~= nil then layer_out.overrides = layer.overrides end out.layers[layer_name] = layer_out 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_v3(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, } -- v0.5.3: keep atlas_by_alias in sync with the replaced atlas -- record. Built at load time off the pre-load stub; without this -- refresh the v3 vertex/material render path resolves through -- atlas_by_alias to the stub and falls back to MISSING_ASSET_COLOR. if m.atlas_by_alias then m.atlas_by_alias[atlas_alias] = m.atlases[atlas_idx] end 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 local has = m._layer_has_content for _, name in ipairs(LAYER_ORDER_PRE_ENTITIES) do if m.layers[name] and (has == nil or has[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 local has = m._layer_has_content for _, name in ipairs(LAYER_ORDER_POST_ENTITIES) do if m.layers[name] and (has == nil or has[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.upgrade_v2_to_v3 = upgrade_v2_to_v3 M.validate_map_table_v2 = validate_map_table_v2 M.validate_map_table_v3 = validate_map_table_v3 return M