maps v0.5.0 — schema-v3 + autotile + override + render-opt
Five sub-slices closed in-session 2026-05-28 against the design at
sporel-meta/docs/design/2026-05-28-autotile-blob-styles-design.md.
0.5.0a — schema-v3 + auto-upgrade
validate_map_table_v3 + build_map_v3 + serialize_map_v3 +
upgrade_v2_to_v3 (no-op shape transformation). load() chains
v1 -> v2 -> v3 transparent. All runtime `m.schema_version`-
comparisons switched from `== 2`/`~= 2` to `>= 2`/`< 2` so v3
inherits v2 behaviour for existing code paths. Per-layer optional
fields (material, vertices, overrides) accepted by validator,
preserved on round-trip serialize, ignored by renderer until
0.5.0c/d wire them in.
0.5.0b — multi-layer terrain rendering validated
Existing LAYER_ORDER_PRE/POST_ENTITIES loop already covered multi-
layer; sub-slice validated end-to-end against a v3 map with three
non-empty layers in vagrant-skeleton.
0.5.0c — vertex-painted autotile path
~150 LOC autotile section. rotate_bitmask_90cw + mirror_bitmask_h
+ apply_blob_gating helpers. SLOT_LOOKUP[bitmask] -> {slot, rot,
flip} precomputed at module-init via D4-orbit walk over 14
canonical patterns. cell_has_material (any-corner rule),
compute_cell_bitmask_v3 (8-neighbour bitmask + blob-gating),
atlas_slot_index (slot_NN_* tile-name parser, cached per atlas).
build_map_v3 exposes atlas_by_alias for O(1) layer.material ->
atlas lookup. draw_layer branches on layer.vertices+material:
autotile path computes bitmask, looks up slot, draws with rot+flip
via draw_sprite_transform. Legacy tiles[] path unchanged. cell_gid
returns sentinel 1 for vertex-painted material cells so
is_walkable / blocks_walk / blocks_sight stay transparent.
0.5.0d — sparse override sublayer
Override map keyed "x:y" -> slot_id (0..13). Override implies
cell-has-material (restores the 1-cell-isolated case the any-corner
rule cannot express). Renders canonical slot in default orientation
(no rot/flip), bypassing bitmask compute. Public APIs:
set_override / clear_override / get_override with validation
(v3-only, valid layer, in-bounds, integer 0..13). Internal helpers
require_v3_layer + check_coords.
0.5.0e — render optimisation
LAYER_Z + LAYER_ORDER_TOP_DOWN constants. m._layer_has_content
precomputed in build_map_v3 (per-layer scan: tiles[] non-zero OR
vertices painted OR overrides set); draw_map_pre/post_entities
skip empty layers cheaply. m._opaque_ceiling per-cell topmost-
opaque z-index, lazily built via ensure_opaque_ceiling. Opaque
heuristic (light): vertex-painted cell opaque iff bitmask resolves
to slot 13 OR override == slot 13. Legacy tiles[] reads
atlas.tile.opaque (currently always nil pending atlas-baker E2
alpha-analysis; safe-conservative default = not opaque). draw_layer
skips cells where ceiling > current layer z. Writes nil out
_opaque_ceiling for lazy rebuild.
manifest.lib bumped 0.4.0 -> 0.5.0. Consumer modules bumped in
their own commits (sporel-lib-core.render, sporel-module-vagrant-
skeleton, sporel-module-map-editor[-test]).
Closure-gate per sub-slice: milestone-check.sh build + ctest +
test-all-modules GREEN; SPOREL_CI=1 vagrant-skeleton smoke rc=0
with 60 render_frame_ok and no magenta-placeholders.
Detailed sub-slice closure logs live in
sporel-meta/docs/superpowers/plans/2026-05-28-map-schema-v3.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
640
init.lua
640
init.lua
@@ -53,9 +53,13 @@ local function decode_gid(gid)
|
||||
end
|
||||
|
||||
-- =====================================================================
|
||||
-- Schema v1→v2 Auto-Upgrade (transparent beim Load)
|
||||
-- Schema v1→v2→v3 Auto-Upgrade (transparent beim Load)
|
||||
-- v1: { id, tilemap, size, tiles[], tile_rotations? }
|
||||
-- v2: { schema_version, id, size, atlases[], layers: { surface: { tiles[] } }, roof? }
|
||||
-- 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)
|
||||
@@ -86,8 +90,23 @@ local function upgrade_v1_to_v2(v1)
|
||||
}
|
||||
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 both v1 and v2 validators)
|
||||
-- Schema-validation helper (used by all version-specific validators)
|
||||
-- =====================================================================
|
||||
|
||||
-- Schema-validation helper: reads required field with type-check.
|
||||
@@ -104,6 +123,245 @@ local function require_field(t, key, expected_type, source)
|
||||
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
|
||||
|
||||
-- 0.5.0e: predicate "is cell (x, y) opaque on layer_name". Used by the
|
||||
-- opaque-ceiling cache. Heuristics for now:
|
||||
-- * Vertex-painted layer: opaque iff cell is material AND its bitmask
|
||||
-- resolves to slot 13 (full solid). An override that forces slot 13
|
||||
-- also counts. Other slots have transparent regions so they
|
||||
-- can't be a ceiling.
|
||||
-- * Legacy tiles[] path: opaque iff the resolved atlas tile carries
|
||||
-- an explicit `opaque == true` flag. Atlases don't ship this flag
|
||||
-- today (deferred to atlas-baker E2 alpha-analysis), so this path
|
||||
-- defaults to non-opaque. Safe-conservative: extra draws, never
|
||||
-- missing draws.
|
||||
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
|
||||
local override_slot = layer.overrides and layer.overrides[x .. ":" .. y]
|
||||
if override_slot ~= nil then
|
||||
return override_slot == 13
|
||||
end
|
||||
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)
|
||||
return bitmask == 0xFF
|
||||
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
|
||||
-- =====================================================================
|
||||
@@ -123,6 +381,8 @@ local VALID_LAYER_NAMES = {
|
||||
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
|
||||
@@ -167,6 +427,71 @@ local function validate_map_table_v2(t, source)
|
||||
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 and type(layer_data.overrides) ~= "table" then
|
||||
error(string.format("maps.load: schema violation in %s: overrides must be table", lsrc))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if t.roof ~= nil then
|
||||
if type(t.roof) ~= "table" then
|
||||
error(string.format("maps.load: %s: roof must be array", source))
|
||||
end
|
||||
if #t.roof ~= expected then
|
||||
error(string.format("maps.load: %s: roof length %d != %d", source, #t.roof, expected))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- =====================================================================
|
||||
-- Internal helpers
|
||||
-- =====================================================================
|
||||
@@ -341,6 +666,80 @@ local function build_map_v2(t_map, resolved_atlases)
|
||||
}
|
||||
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.
|
||||
@@ -356,9 +755,93 @@ local function draw_layer(m, 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_slot = overrides and overrides[x .. ":" .. y]
|
||||
local has_material = override_slot ~= 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_slot ~= nil then
|
||||
-- Forced slot: render in canonical orientation, no transform.
|
||||
slot_index, rot, flip = override_slot, 0, 0
|
||||
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)
|
||||
@@ -389,6 +872,7 @@ local function draw_layer(m, layer_name)
|
||||
engine.render.draw_rect(px, py, ts, ts, MISSING_ASSET_COLOR)
|
||||
end
|
||||
end
|
||||
::tiles_continue::
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -435,7 +919,11 @@ function M.load(path)
|
||||
raw = upgrade_v1_to_v2(raw)
|
||||
engine.print(string.format("maps.load: auto-upgraded v1 map '%s' to v2", raw.id or "<unnamed>"))
|
||||
end
|
||||
validate_map_table_v2(raw, path)
|
||||
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 = {}
|
||||
@@ -444,7 +932,7 @@ function M.load(path)
|
||||
resolved_atlases[i] = load_tilemap(full_id)
|
||||
end
|
||||
|
||||
local map = build_map_v2(raw, resolved_atlases)
|
||||
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
|
||||
@@ -510,7 +998,7 @@ function M.tile_at(a, b, c)
|
||||
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
|
||||
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
|
||||
@@ -532,7 +1020,7 @@ function M.is_walkable(a, b, c)
|
||||
error("maps.is_walkable: no current map")
|
||||
end
|
||||
local m = map_registry[map_id]
|
||||
if m.schema_version ~= 2 then
|
||||
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
|
||||
@@ -580,7 +1068,7 @@ 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
|
||||
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
|
||||
@@ -609,7 +1097,7 @@ 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
|
||||
if m.schema_version < 2 then return 1 end -- v1 (pre-upgrade) always 1
|
||||
return #m.atlases
|
||||
end
|
||||
|
||||
@@ -634,7 +1122,21 @@ function M.cell_gid(layer_name, x, y, map_id)
|
||||
local m = map_registry[id]
|
||||
if not m.layers or not m.layers[layer_name] then return 0 end
|
||||
if x < 0 or x >= m.size.w or y < 0 or y >= m.size.h then return 0 end
|
||||
return m.layers[layer_name].tiles[y * m.size.w + x + 1] or 0
|
||||
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
|
||||
|
||||
-- =====================================================================
|
||||
@@ -678,6 +1180,82 @@ function M.set_cell_gid(layer_name, x, y, gid, map_id)
|
||||
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. Stored as sparse map keyed "x:y" -> slot_id.
|
||||
|
||||
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
|
||||
|
||||
function M.set_override(layer_name, x, y, slot_id, 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")
|
||||
if type(slot_id) ~= "number" or slot_id < 0 or slot_id > 13
|
||||
or slot_id ~= math.floor(slot_id) then
|
||||
error(string.format("maps.set_override: slot_id %s must be integer 0..13",
|
||||
tostring(slot_id)))
|
||||
end
|
||||
if layer.overrides == nil then layer.overrides = {} end
|
||||
layer.overrides[x .. ":" .. y] = slot_id
|
||||
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
|
||||
|
||||
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
|
||||
return layer.overrides[x .. ":" .. y]
|
||||
end
|
||||
|
||||
function M.set_roof(x, y, value, map_id)
|
||||
@@ -785,9 +1363,9 @@ local LAYER_DISK_ORDER = {
|
||||
"lower_wall", "wall", "upper_wall", "canopy",
|
||||
}
|
||||
|
||||
local function serialize_map_v2(map)
|
||||
local function serialize_map_v3(map)
|
||||
local out = {
|
||||
schema_version = 2,
|
||||
schema_version = 3,
|
||||
id = map.id,
|
||||
size = { w = map.size.w, h = map.size.h },
|
||||
atlases = {},
|
||||
@@ -800,9 +1378,17 @@ local function serialize_map_v2(map)
|
||||
for _, layer_name in ipairs(LAYER_DISK_ORDER) do
|
||||
local layer = map.layers[layer_name]
|
||||
if layer then
|
||||
local tiles_copy = {}
|
||||
for i = 1, #layer.tiles do tiles_copy[i] = layer.tiles[i] end
|
||||
out.layers[layer_name] = { tiles = tiles_copy }
|
||||
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
|
||||
@@ -815,7 +1401,7 @@ end
|
||||
|
||||
function M.save_to_disk(map_id, path)
|
||||
local map = require_map(map_id)
|
||||
local json_str = serialize_map_v2(map)
|
||||
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)",
|
||||
@@ -851,7 +1437,7 @@ function M.load_textures(asset_aliases)
|
||||
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
|
||||
if m.schema_version < 2 then
|
||||
error("maps.load_textures: only schema-v2 maps supported in v0.3.0+")
|
||||
end
|
||||
|
||||
@@ -959,7 +1545,7 @@ 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
|
||||
if m.schema_version < 2 then
|
||||
-- v1 (legacy): only surface conceptually
|
||||
fn("surface")
|
||||
return
|
||||
@@ -973,7 +1559,7 @@ 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
|
||||
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
|
||||
@@ -983,12 +1569,15 @@ 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
|
||||
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] then draw_layer(m, name) end
|
||||
if m.layers[name] and (has == nil or has[name]) then
|
||||
draw_layer(m, name)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -996,9 +1585,12 @@ 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
|
||||
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] then draw_layer(m, name) end
|
||||
if m.layers[name] and (has == nil or has[name]) then
|
||||
draw_layer(m, name)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1022,6 +1614,8 @@ 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
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"id":"lib-core.maps","version":"0.4.0","api_min":"0.1"}
|
||||
{"id":"lib-core.maps","version":"0.5.0","api_min":"0.1"}
|
||||
|
||||
Reference in New Issue
Block a user