diff --git a/init.lua b/init.lua index 19e6457..be11b5f 100644 --- a/init.lua +++ b/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: { : { 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_ +-- 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 "", 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 "")) 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 "")) + 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 diff --git a/manifest.lib b/manifest.lib index 5036e36..55317cf 100644 --- a/manifest.lib +++ b/manifest.lib @@ -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"}