Files
sporel-lib-core.maps/init.lua
Axel Meyer b5621c5d08 fix(maps): v0.5.6 — derive bitmask from cell's own corner paint-tiles
compute_cell_bitmask_v3 now reads the 8-bit neighbour bitmask from
the render-cell's own 4 corner paint-tiles (cardinal bit set iff
at least one of the 2 paint-tiles on that edge is painted; diagonal
bit set iff the corner paint-tile is painted), not from the
any-corner material status of the 8 neighbour render-cells.

Pre-0.5.6 used cell-neighbour-material, which let two cells share
connectivity across an empty paint-tile gap whenever any unrelated
corner of either was painted — producing connected blob shapes
where two visually separated 2x2 islands were expected.

Atlas, paint storage, override sublayer, public API and the slot
lookup table all unchanged. Doc comment and README painting-model
section rewritten to describe the dual-grid offset explicitly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 17:19:26 +02:00

1919 lines
77 KiB
Lua

-- =====================================================================
-- 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: { <name>: { 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
-- Any-corner rule: cell (x, y) is material iff at least one of its 4
-- corner vertices is painted. A single painted vertex affects the
-- 2x2 cells surrounding it. Out-of-grid cells are non-material.
local function cell_has_material(vertices, w, h, x, y)
if x < 0 or x >= w or y < 0 or y >= h then return false 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) given
-- the layer's vertex grid + map size. Bit layout (clockwise from N):
-- 0=N, 1=NE, 2=E, 3=SE, 4=S, 5=SW, 6=W, 7=NW.
--
-- 0.5.6: bits derive from the render-cell's own 4 corner map-tiles
-- (= the 4 surrounding vertices in dual-grid terms), not from the
-- material status of the 8 neighbour cells. A cardinal bit is set iff
-- at least one of the 2 vertices on that edge is painted; a diagonal
-- bit is set iff the corner vertex in that direction is painted.
-- Pre-0.5.6 used cell-neighbour-material, which made two cells with a
-- shared unpainted edge see each other as material whenever any other
-- corner of either was painted — producing connected blobs across
-- visually-empty map-tile gaps. See plan
-- 2026-05-29-painting-model-rethink for the discovery.
local function compute_cell_bitmask_v3(vertices, w, h, x, y)
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
local b = 0
if TL or TR then b = b | 0x01 end -- N edge: TL or TR painted
if TR then b = b | 0x02 end -- NE corner: TR painted
if TR or BR then b = b | 0x04 end -- E edge: TR or BR painted
if BR then b = b | 0x08 end -- SE corner: BR painted
if BL or BR then b = b | 0x10 end -- S edge: BL or BR painted
if BL then b = b | 0x20 end -- SW corner: BL painted
if TL or BL then b = b | 0x40 end -- W edge: TL or BL painted
if TL then b = b | 0x80 end -- NW corner: TL painted
return apply_blob_gating(b)
end
-- Build slot -> tile_id index for an atlas. Tiles named slot_NN_<rest>
-- 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, map.size.w, map.size.h, x, y) then
return false
end
local bitmask = compute_cell_bitmask_v3(layer.vertices, 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
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 `<current-module-id>.`.
-- 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" -> "<current-module-id>.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 "<unnamed>", 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 "<unnamed>", 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, 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, 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, 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 "<unnamed>"))
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 "<unnamed>"))
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, m.size.w, m.size.h, x, y) then return nil end
local bitmask = compute_cell_bitmask_v3(layer.vertices, 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, 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
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
-- <asset-lib>/assets/atlases/<atlas_id>/ 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