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>
1919 lines
77 KiB
Lua
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
|