Compare commits

...

6 Commits

Author SHA1 Message Date
Axel Meyer
6e42a034f0 chore(maps): bump to 0.5.7; README cell-tile path subsection
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 22:37:33 +02:00
Axel Meyer
6e0d39e1a7 feat(maps): save_to_disk emits cells_material conditionally
cells_material is added to the per-layer JSON only when at least
one entry is non-zero, so v0.5.6-vintage maps with no cell-tile
content write byte-identical output through the 0.5.7
serializer. Maps with cell-tile content round-trip the array
through save and reload.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 22:30:16 +02:00
Axel Meyer
5351923dd6 feat(maps): per-cell-origin bitmask branch unlocks 47-blob
compute_cell_bitmask_v3 now branches: cells where
cells_material is set use the classical 47-blob 8-neighbour rule
on effective material; vertex-only cells continue to use the
FIX-A 4-own-corner rule (v0.5.6 dual-grid). The 8-neighbour
branch evaluates each neighbour through cell_has_material so
cell-tile material and vertex-tile material both count as
neighbours.

A vertex-only cell adjacent to a cell-tile cell remains
structurally blind to its neighbour because its 4-corner rule
only reads its own corner vertices. This asymmetric seam at
in-layer mode boundaries is documented behaviour, not a bug.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 22:13:26 +02:00
Axel Meyer
c5039e5bf2 fix(maps): set_cell_material invalidates _dirty + _opaque_ceiling
Mirrors the cache-invalidation that set_vertex and set_cell_gid
do. Without this, a cell-tile material write would update the
storage but the opaque-ceiling cache could continue to report a
stale state until something else invalidated it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 22:04:05 +02:00
Axel Meyer
89d68d7668 feat(maps): set_cell_material / get_cell_material public APIs
Mirrors the existing set_vertex / get_vertex pattern. Bounds and
unknown-layer guards raise the same shape of error as the v3
write-API family. Lazy-allocates layer.cells_material on first
write so maps that never use cell-tile painting stay byte-clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 21:50:56 +02:00
Axel Meyer
9a5ef5e832 feat(maps): v3 cells_material field foundation
Adds the optional layer.cells_material array to the v3 schema
validator, the per-cell read helper cells_material_at, the
lazy-allocator ensure_cells_material, and extends
cell_has_material plus compute_cell_bitmask_v3 to consult it.

No public-API change yet; cell_has_material's signature gains a
cells_material parameter that all internal callers thread through.
A layer with no cells_material entry remains behaviourally
identical to v0.5.6. Public APIs and the bitmask branch land in
later commits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 21:44:23 +02:00
3 changed files with 164 additions and 42 deletions

View File

@@ -5,7 +5,7 @@ Multi-layer tile-grid map implementation with vertex-painted autotile
loading + UV resolution, walkability + sight-blocking queries, and a loading + UV resolution, walkability + sight-blocking queries, and a
v3 multi-layer render pipeline with opaque-ceiling cache. v3 multi-layer render pipeline with opaque-ceiling cache.
**Version:** 0.5.6 **Version:** 0.5.7
**Lib-ID:** lib-core.maps **Lib-ID:** lib-core.maps
**Requires:** (none) **Requires:** (none)
**Tags:** maps, tile-grid, multi-layer, vertex-painting, autotile, blob-14, override, walkability, tilemap **Tags:** maps, tile-grid, multi-layer, vertex-painting, autotile, blob-14, override, walkability, tilemap
@@ -47,6 +47,30 @@ other as material whenever any unrelated corner of either was painted,
producing connected blobs across visually empty paint-tile gaps. See producing connected blobs across visually empty paint-tile gaps. See
plan `2026-05-29-painting-model-rethink`. plan `2026-05-29-painting-model-rethink`.
### Cell-Tile material path (0.5.7)
A second, optional painting path: each layer can now also have a
`cells_material` array (W·H bool, lazy-allocated). Cells where
`cells_material[x, y]` is set use the classical 47-blob 8-neighbour
bitmask rule on effective material (vertex-derived OR cell-derived),
unlocking all 14 atlas slots. Cells whose material comes only from
the vertex grid continue to use the FIX-A 4-own-corner rule (5
reachable slots, dual-grid look).
Both paths coexist in the same layer; the bitmask rule is decided
per-cell based on whether `cells_material[x, y]` is true. A
vertex-only cell adjacent to a cell-tile cell remains structurally
blind to its neighbour because its 4-corner rule only reads its own
corner vertices — this asymmetric seam is documented behaviour. For
clean visuals use a single painting path per layer.
Public API: `set_cell_material(layer, x, y, painted, map_id?)` and
`get_cell_material(layer, x, y, map_id?) -> bool`.
The `cells_material` field is optional and absent on every existing
v3 map; save output is byte-identical to 0.5.6 when no cells_material
entries are non-zero.
## Topology ## Topology
<!-- topology:start (auto-generated; do not edit) --> <!-- topology:start (auto-generated; do not edit) -->

168
init.lua
View File

@@ -257,11 +257,34 @@ local function vertex_at(vertices, vw, vh, vx, vy)
return 1 return 1
end end
-- Any-corner rule: cell (x, y) is material iff at least one of its 4 -- 0.5.7: read the cell-tile material flag at (cx, cy). Returns 0 if
-- corner vertices is painted. A single painted vertex affects the -- layer.cells_material is absent or the entry is unset/false.
-- 2x2 cells surrounding it. Out-of-grid cells are non-material. local function cells_material_at(cells_material, w, h, cx, cy)
local function cell_has_material(vertices, w, h, x, y) if cells_material == nil then return 0 end
if cx < 0 or cx >= w or cy < 0 or cy >= h then return 0 end
local v = cells_material[cy * w + cx + 1]
if v == nil or v == 0 or v == false then return 0 end
return 1
end
-- 0.5.7: lazy-allocate the per-cell material array on a layer. Returns
-- the (now-guaranteed-present) array. Safe to call repeatedly.
local function ensure_cells_material(layer, w, h)
if layer.cells_material == nil then
local n = w * h
local cm = {}
for i = 1, n do cm[i] = 0 end
layer.cells_material = cm
end
return layer.cells_material
end
-- Any-corner rule + 0.5.7 cell-tile rule: cell (x, y) is material iff
-- (any of its 4 corner vertices is painted) OR (cells_material[x,y] is
-- set). Out-of-grid cells are non-material.
local function cell_has_material(vertices, cells_material, w, h, x, y)
if x < 0 or x >= w or y < 0 or y >= h then return false end if x < 0 or x >= w or y < 0 or y >= h then return false end
if cells_material_at(cells_material, w, h, x, y) ~= 0 then return true end
local vw = w + 1 local vw = w + 1
local vh = h + 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, y ) ~= 0 then return true end
@@ -271,36 +294,46 @@ local function cell_has_material(vertices, w, h, x, y)
return false return false
end end
-- Returns the blob-gated 8-bit neighbour bitmask for cell (x, y) given -- Returns the blob-gated 8-bit neighbour bitmask for cell (x, y).
-- the layer's vertex grid + map size. Bit layout (clockwise from N): -- Bit layout (clockwise from N, LSB-first): N, NE, E, SE, S, SW, W, NW.
-- 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 -- 0.5.7: per-cell-origin branch. Cells whose material origin is
-- (= the 4 surrounding vertices in dual-grid terms), not from the -- cells_material[x, y] use the classical 47-blob 8-neighbour rule on
-- material status of the 8 neighbour cells. A cardinal bit is set iff -- effective material (vertex-derived OR cell-derived). Vertex-only
-- at least one of the 2 vertices on that edge is painted; a diagonal -- cells continue to use the FIX-A 4-own-corner rule (v0.5.6 dual-grid
-- bit is set iff the corner vertex in that direction is painted. -- semantics, 5 reachable atlas slots).
-- 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 -- Pre-condition: cell_has_material(vertices, cells_material, w, h, x, y)
-- corner of either was painted — producing connected blobs across -- returned true for this cell.
-- visually-empty map-tile gaps. See plan local function compute_cell_bitmask_v3(vertices, cells_material, w, h, x, y)
-- 2026-05-29-painting-model-rethink for the discovery. local b = 0
local function compute_cell_bitmask_v3(vertices, w, h, x, y) if cells_material_at(cells_material, w, h, x, y) ~= 0 then
-- 47-blob branch: 8 neighbour effective-material bits.
if cell_has_material(vertices, cells_material, w, h, x, y - 1) then b = b | 0x01 end
if cell_has_material(vertices, cells_material, w, h, x + 1, y - 1) then b = b | 0x02 end
if cell_has_material(vertices, cells_material, w, h, x + 1, y ) then b = b | 0x04 end
if cell_has_material(vertices, cells_material, w, h, x + 1, y + 1) then b = b | 0x08 end
if cell_has_material(vertices, cells_material, w, h, x, y + 1) then b = b | 0x10 end
if cell_has_material(vertices, cells_material, w, h, x - 1, y + 1) then b = b | 0x20 end
if cell_has_material(vertices, cells_material, w, h, x - 1, y ) then b = b | 0x40 end
if cell_has_material(vertices, cells_material, w, h, x - 1, y - 1) then b = b | 0x80 end
else
-- FIX-A 4-corner-vertex branch (v0.5.6 dual-grid, unchanged).
local vw = w + 1 local vw = w + 1
local vh = h + 1 local vh = h + 1
local TL = vertex_at(vertices, vw, vh, x, y ) ~= 0 local TL = vertex_at(vertices, vw, vh, x, y ) ~= 0
local TR = vertex_at(vertices, vw, vh, x + 1, 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 BL = vertex_at(vertices, vw, vh, x, y + 1) ~= 0
local BR = vertex_at(vertices, vw, vh, x + 1, 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
if TL or TR then b = b | 0x01 end -- N edge: TL or TR painted if TR then b = b | 0x02 end -- NE
if TR then b = b | 0x02 end -- NE corner: TR painted if TR or BR then b = b | 0x04 end -- E
if TR or BR then b = b | 0x04 end -- E edge: TR or BR painted if BR then b = b | 0x08 end -- SE
if BR then b = b | 0x08 end -- SE corner: BR painted if BL or BR then b = b | 0x10 end -- S
if BL or BR then b = b | 0x10 end -- S edge: BL or BR painted if BL then b = b | 0x20 end -- SW
if BL then b = b | 0x20 end -- SW corner: BL painted if TL or BL then b = b | 0x40 end -- W
if TL or BL then b = b | 0x40 end -- W edge: TL or BL painted if TL then b = b | 0x80 end -- NW
if TL then b = b | 0x80 end -- NW corner: TL painted end
return apply_blob_gating(b) return apply_blob_gating(b)
end end
@@ -371,10 +404,10 @@ local function cell_is_opaque_on_layer(map, layer_name, x, y)
if not norm then return false end if not norm then return false end
slot = norm.slot slot = norm.slot
else else
if not cell_has_material(layer.vertices, map.size.w, map.size.h, x, y) then if not cell_has_material(layer.vertices, layer.cells_material, map.size.w, map.size.h, x, y) then
return false return false
end end
local bitmask = compute_cell_bitmask_v3(layer.vertices, map.size.w, map.size.h, x, y) local bitmask = compute_cell_bitmask_v3(layer.vertices, layer.cells_material, map.size.w, map.size.h, x, y)
local rec = SLOT_LOOKUP[bitmask] local rec = SLOT_LOOKUP[bitmask]
if not rec then return false end if not rec then return false end
slot = rec.slot slot = rec.slot
@@ -539,6 +572,18 @@ local function validate_map_table_v3(t, source)
if layer_data.vertices ~= nil and type(layer_data.vertices) ~= "table" then 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)) error(string.format("maps.load: schema violation in %s: vertices must be array", lsrc))
end end
-- 0.5.7: optional cells_material (W*H bool array, lazy-allocated)
if layer_data.cells_material ~= nil then
if type(layer_data.cells_material) ~= "table" then
error(string.format("maps.load: schema violation in %s: layer '%s' field 'cells_material' must be table, got %s",
source, layer_name, type(layer_data.cells_material)))
end
local expected_cm = size.w * size.h
if #layer_data.cells_material ~= expected_cm then
error(string.format("maps.load: schema violation in %s: layer '%s' field 'cells_material' length %d != W*H %d",
source, layer_name, #layer_data.cells_material, expected_cm))
end
end
if layer_data.overrides ~= nil then if layer_data.overrides ~= nil then
if type(layer_data.overrides) ~= "table" then if type(layer_data.overrides) ~= "table" then
error(string.format("maps.load: schema violation in %s: overrides must be table", lsrc)) error(string.format("maps.load: schema violation in %s: overrides must be table", lsrc))
@@ -904,7 +949,7 @@ local function draw_layer(m, layer_name)
for y = 0, sz.h - 1 do for y = 0, sz.h - 1 do
for x = 0, sz.w - 1 do for x = 0, sz.w - 1 do
local has_ovr = overrides and overrides[x .. ":" .. y] ~= nil 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 if has_ovr or cell_has_material(layer.vertices, layer.cells_material, sz.w, sz.h, x, y) then
engine.render.draw_rect(x * ts, y * ts, ts, ts, MISSING_ASSET_COLOR) engine.render.draw_rect(x * ts, y * ts, ts, ts, MISSING_ASSET_COLOR)
end end
end end
@@ -920,7 +965,7 @@ local function draw_layer(m, layer_name)
end end
local override_entry = overrides and overrides[x .. ":" .. y] local override_entry = overrides and overrides[x .. ":" .. y]
local has_material = override_entry ~= nil local has_material = override_entry ~= nil
or cell_has_material(layer.vertices, sz.w, sz.h, x, y) or cell_has_material(layer.vertices, layer.cells_material, sz.w, sz.h, x, y)
if not has_material then if not has_material then
goto continue goto continue
end end
@@ -934,7 +979,7 @@ local function draw_layer(m, layer_name)
slot_index, rot, flip = norm.slot, norm.rot, norm.flip slot_index, rot, flip = norm.slot, norm.rot, norm.flip
end end
else else
local bitmask = compute_cell_bitmask_v3(layer.vertices, sz.w, sz.h, x, y) local bitmask = compute_cell_bitmask_v3(layer.vertices, layer.cells_material, sz.w, sz.h, x, y)
local rec = SLOT_LOOKUP[bitmask] local rec = SLOT_LOOKUP[bitmask]
if rec then if rec then
slot_index, rot, flip = rec.slot, rec.rot, rec.flip slot_index, rot, flip = rec.slot, rec.rot, rec.flip
@@ -1283,8 +1328,8 @@ function M.cell_material_slot(layer_name, x, y, map_id)
end end
-- Vertex-derived bitmask path (only if the layer has a vertex grid) -- Vertex-derived bitmask path (only if the layer has a vertex grid)
if not layer.vertices then return nil end 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 if not cell_has_material(layer.vertices, layer.cells_material, 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) local bitmask = compute_cell_bitmask_v3(layer.vertices, layer.cells_material, m.size.w, m.size.h, x, y)
return SLOT_LOOKUP[bitmask] return SLOT_LOOKUP[bitmask]
end end
@@ -1325,7 +1370,7 @@ function M.cell_gid(layer_name, x, y, map_id)
if layer.overrides and layer.overrides[x .. ":" .. y] ~= nil then if layer.overrides and layer.overrides[x .. ":" .. y] ~= nil then
return 1 return 1
end end
return cell_has_material(layer.vertices, m.size.w, m.size.h, x, y) and 1 or 0 return cell_has_material(layer.vertices, layer.cells_material, m.size.w, m.size.h, x, y) and 1 or 0
end end
return layer.tiles and layer.tiles[y * m.size.w + x + 1] or 0 return layer.tiles and layer.tiles[y * m.size.w + x + 1] or 0
end end
@@ -1548,6 +1593,44 @@ function M.get_vertex(layer_name, vx, vy, map_id)
return v == 1 or v == true return v == 1 or v == true
end end
-- 0.5.7: set/clear the per-cell material flag at (cx, cy) on the named
-- layer. painted: bool. Lazy-allocates layer.cells_material on first
-- write. Out-of-bounds raises a bounds error. Unknown layer raises a
-- layer-not-found error.
function M.set_cell_material(layer_name, cx, cy, painted, map_id)
local m = require_map(map_id)
local layer = m.layers[layer_name]
if not layer then
error(string.format("maps.set_cell_material: no layer '%s'", layer_name))
end
local w, h = m.size.w, m.size.h
if cx < 0 or cx >= w or cy < 0 or cy >= h then
error(string.format("maps.set_cell_material: out of bounds (%d, %d) for %dx%d map",
cx, cy, w, h))
end
local cm = ensure_cells_material(layer, w, h)
cm[cy * w + cx + 1] = painted and 1 or 0
m._dirty = true
m._opaque_ceiling = nil
end
-- 0.5.7: read the per-cell material flag. Returns false if
-- layer.cells_material is absent or the entry is unset. Does NOT
-- include vertex-derived material — call cell_material_slot or render
-- if you need the effective state.
function M.get_cell_material(layer_name, cx, cy, map_id)
local m = require_map(map_id)
local layer = m.layers[layer_name]
if not layer then
error(string.format("maps.get_cell_material: no layer '%s'", layer_name))
end
local w, h = m.size.w, m.size.h
if cx < 0 or cx >= w or cy < 0 or cy >= h then return false end
if layer.cells_material == nil then return false end
local v = layer.cells_material[cy * w + cx + 1]
return v ~= nil and v ~= 0 and v ~= false
end
function M.set_roof(x, y, value, map_id) function M.set_roof(x, y, value, map_id)
local map = require_map(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 if x < 0 or y < 0 or x >= map.size.w or y >= map.size.h then
@@ -1678,6 +1761,21 @@ local function serialize_map_v3(map)
if layer.material ~= nil then layer_out.material = layer.material end if layer.material ~= nil then layer_out.material = layer.material end
if layer.vertices ~= nil then layer_out.vertices = layer.vertices end if layer.vertices ~= nil then layer_out.vertices = layer.vertices end
if layer.overrides ~= nil then layer_out.overrides = layer.overrides end if layer.overrides ~= nil then layer_out.overrides = layer.overrides end
-- 0.5.7: emit cells_material only when at least one entry is
-- non-zero. This preserves byte-identical save output for
-- pre-0.5.7-content maps.
if layer.cells_material then
local any_set = false
for i = 1, #layer.cells_material do
if layer.cells_material[i] and layer.cells_material[i] ~= 0 then
any_set = true
break
end
end
if any_set then
layer_out.cells_material = layer.cells_material
end
end
out.layers[layer_name] = layer_out out.layers[layer_name] = layer_out
end end
end end

View File

@@ -1 +1 @@
{"id":"lib-core.maps","version":"0.5.6","api_min":"0.1"} {"id":"lib-core.maps","version":"0.5.7","api_min":"0.1"}