Compare commits
6 Commits
b5621c5d08
...
6e42a034f0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e42a034f0 | ||
|
|
6e0d39e1a7 | ||
|
|
5351923dd6 | ||
|
|
c5039e5bf2 | ||
|
|
89d68d7668 | ||
|
|
9a5ef5e832 |
26
README.md
26
README.md
@@ -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) -->
|
||||||
|
|||||||
178
init.lua
178
init.lua
@@ -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 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
|
local b = 0
|
||||||
if TL or TR then b = b | 0x01 end -- N edge: TL or TR painted
|
if cells_material_at(cells_material, w, h, x, y) ~= 0 then
|
||||||
if TR then b = b | 0x02 end -- NE corner: TR painted
|
-- 47-blob branch: 8 neighbour effective-material bits.
|
||||||
if TR or BR then b = b | 0x04 end -- E edge: TR or BR painted
|
if cell_has_material(vertices, cells_material, w, h, x, y - 1) then b = b | 0x01 end
|
||||||
if BR then b = b | 0x08 end -- SE corner: BR painted
|
if cell_has_material(vertices, cells_material, w, h, x + 1, y - 1) then b = b | 0x02 end
|
||||||
if BL or BR then b = b | 0x10 end -- S edge: BL or BR painted
|
if cell_has_material(vertices, cells_material, w, h, x + 1, y ) then b = b | 0x04 end
|
||||||
if BL then b = b | 0x20 end -- SW corner: BL painted
|
if cell_has_material(vertices, cells_material, w, h, x + 1, y + 1) then b = b | 0x08 end
|
||||||
if TL or BL then b = b | 0x40 end -- W edge: TL or BL painted
|
if cell_has_material(vertices, cells_material, w, h, x, y + 1) then b = b | 0x10 end
|
||||||
if TL then b = b | 0x80 end -- NW corner: TL painted
|
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 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
|
||||||
|
if TL or TR then b = b | 0x01 end -- N
|
||||||
|
if TR then b = b | 0x02 end -- NE
|
||||||
|
if TR or BR then b = b | 0x04 end -- E
|
||||||
|
if BR then b = b | 0x08 end -- SE
|
||||||
|
if BL or BR then b = b | 0x10 end -- S
|
||||||
|
if BL then b = b | 0x20 end -- SW
|
||||||
|
if TL or BL then b = b | 0x40 end -- W
|
||||||
|
if TL then b = b | 0x80 end -- NW
|
||||||
|
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
|
||||||
|
|||||||
@@ -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"}
|
||||||
Reference in New Issue
Block a user