Exposes what the v3 vertex/material render path computes internally:
per (layer, x, y), returns {slot, rot, flip} for material cells,
nil for non-material. Override entry wins (normalised via existing
normalize_override_entry); otherwise resolves through
compute_cell_bitmask_v3 + SLOT_LOOKUP.
Primary consumer: debug-overlay tooling (map-editor 0.2.0c.3) that
labels each rendered cell with its blob-14 slot identity for
authoring verification.
1887 lines
76 KiB
Lua
1887 lines
76 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",
|
|
}
|
|
|
|
-- 47-Blob Autotile (Schema-v3 vertex-painted layers, 0.5.0c)
|
|
--
|
|
-- The cell-state for an autotile cell is derived from the layer's
|
|
-- vertex grid using the "any-corner" rule: cell (x, y) is material
|
|
-- iff any of its 4 corner vertices (x, y), (x+1, y), (x, y+1), or
|
|
-- (x+1, y+1) is painted. A single painted vertex thus produces a
|
|
-- 2x2 mini-blob of material cells centred on the vertex.
|
|
--
|
|
-- Given the derived per-cell material-state, each cell computes its
|
|
-- 8-bit neighbour bitmask (clockwise from north, LSB-first): N, NE,
|
|
-- E, SE, S, SW, W, NW. The blob-gating rule then zeroes a diagonal
|
|
-- bit unless both adjacent cardinal bits are set. The resulting
|
|
-- 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.
|
|
-- =====================================================================
|
|
|
|
-- 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.
|
|
local function compute_cell_bitmask_v3(vertices, w, h, x, y)
|
|
local b = 0
|
|
if cell_has_material(vertices, w, h, x, y - 1) then b = b | 0x01 end -- N
|
|
if cell_has_material(vertices, w, h, x + 1, y - 1) then b = b | 0x02 end -- NE
|
|
if cell_has_material(vertices, w, h, x + 1, y ) then b = b | 0x04 end -- E
|
|
if cell_has_material(vertices, w, h, x + 1, y + 1) then b = b | 0x08 end -- SE
|
|
if cell_has_material(vertices, w, h, x, y + 1) then b = b | 0x10 end -- S
|
|
if cell_has_material(vertices, w, h, x - 1, y + 1) then b = b | 0x20 end -- SW
|
|
if cell_has_material(vertices, w, h, x - 1, y ) then b = b | 0x40 end -- W
|
|
if cell_has_material(vertices, w, h, x - 1, y - 1) then b = b | 0x80 end -- NW
|
|
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
|