Files
sporel-lib-core.maps/init.lua
Axel Meyer 8cec7b6812 Iterate layers in z-order with entity-slot split; multi-layer draw_map
Adds LAYER_ORDER_PRE/POST_ENTITIES constants, iterate_layers_pre/post_entities
helpers, draw_layer and draw_v1_legacy locals, and draw_map_pre/post_entities
public functions. draw_map becomes a backward-compat wrapper over both.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:54:27 +02:00

700 lines
25 KiB
Lua

-- =====================================================================
-- lib-core.maps — Single Tile-Grid Map Lib (P.0)
-- See: meta/docs/superpowers/specs/2026-05-09-p0-lib-maps-design.md
--
-- Forward-compat stubs (DEPRECATED-MVP) for: multi-map-graph,
-- lifecycle-states, walls, sprite-fields, generators, save-integration,
-- cross-lib tilemap resolution, override-field-merge, sub-require.
-- =====================================================================
-- Module-private state
local map_registry = {} -- map_id -> Map
local tilemap_registry = {} -- full_tilemap_id -> Tilemap
local current_map_id = nil
-- =====================================================================
-- Packed-u32 GID encoding (Schema-v2)
-- Bit-Layout:
-- Bits 31..24 : atlas_index (8 bits, 0..255)
-- Bits 23..4 : tile_id (20 bits, 0..1_048_575)
-- Bits 3..2 : rotation (2 bits, 0..3, n * 90° CW)
-- Bits 1..0 : reserved (must be 0 in v2; future flip-h/v)
-- gid == 0 means empty cell (atlas 0 + tile 0 reserved as null).
-- =====================================================================
local GID_ATLAS_SHIFT = 24
local GID_TILE_SHIFT = 4
local GID_ROT_SHIFT = 2
local GID_ATLAS_MASK = 0xFF -- 8 bits
local GID_TILE_MASK = 0xFFFFF -- 20 bits
local GID_ROT_MASK = 0x3 -- 2 bits
local function encode_gid(atlas_index, tile_id, rotation)
rotation = rotation or 0
return (atlas_index << GID_ATLAS_SHIFT)
| (tile_id << GID_TILE_SHIFT)
| (rotation << GID_ROT_SHIFT)
end
local function decode_gid(gid)
local atlas_index = (gid >> GID_ATLAS_SHIFT) & GID_ATLAS_MASK
local tile_id = (gid >> GID_TILE_SHIFT) & GID_TILE_MASK
local rotation = (gid >> GID_ROT_SHIFT) & GID_ROT_MASK
return atlas_index, tile_id, rotation
end
-- =====================================================================
-- Schema v1→v2 Auto-Upgrade (transparent beim Load)
-- v1: { id, tilemap, size, tiles[], tile_rotations? }
-- v2: { schema_version, id, size, atlases[], layers: { surface: { tiles[] } }, roof? }
-- =====================================================================
local function upgrade_v1_to_v2(v1)
local atlas_id = v1.tilemap or "default_tilemap"
local v1_tiles = v1.tiles or {}
local v1_rots = v1.tile_rotations or {}
local gids = {}
for i = 1, #v1_tiles do
local tile_id = v1_tiles[i]
local rot_deg = v1_rots[i] or 0
local rot_quad = math.floor(rot_deg / 90) % 4
if tile_id == 0 then
gids[i] = 0
else
gids[i] = encode_gid(0, tile_id, rot_quad)
end
end
return {
schema_version = 2,
id = v1.id,
size = v1.size,
atlases = { atlas_id },
layers = {
surface = { tiles = gids }
}
}
end
-- =====================================================================
-- Schema-validation helper (used by both v1 and v2 validators)
-- =====================================================================
-- Schema-validation helper: reads required field with type-check.
local function require_field(t, key, expected_type, source)
local v = t[key]
if v == nil then
error(string.format("maps.load: schema violation in %s: missing required field '%s'",
source, key))
end
if type(v) ~= expected_type then
error(string.format("maps.load: schema violation in %s: field '%s' must be %s, got %s",
source, key, expected_type, type(v)))
end
return v
end
-- =====================================================================
-- Schema-v2 Layer-Whitelist + Validation
-- =====================================================================
-- Layer z-order (back-to-front) split at entity slot
local LAYER_ORDER_PRE_ENTITIES = { "foundation", "subsurface", "surface", "topsurface" }
local LAYER_ORDER_POST_ENTITIES = { "lower_wall", "wall", "upper_wall", "canopy" }
local VALID_LAYER_NAMES = {
foundation = true,
subsurface = true,
surface = true,
topsurface = true,
lower_wall = true,
wall = true,
upper_wall = true,
canopy = true,
}
local function validate_map_table_v2(t, source)
require_field(t, "schema_version", "number", source)
if t.schema_version ~= 2 then
error(string.format("maps.load: schema_version %d not supported (expected 2)",
t.schema_version))
end
require_field(t, "id", "string", source)
local size = require_field(t, "size", "table", source)
require_field(size, "w", "number", source .. ".size")
require_field(size, "h", "number", source .. ".size")
local expected = size.w * size.h
local atlases = require_field(t, "atlases", "table", source)
if #atlases == 0 then
error(string.format("maps.load: schema violation in %s: atlases[] must be non-empty", source))
end
if #atlases > 256 then
error(string.format("maps.load: schema violation in %s: atlases[] has %d entries, max 256", source, #atlases))
end
local layers = t.layers or {}
for layer_name, layer_data in pairs(layers) do
if not VALID_LAYER_NAMES[layer_name] then
engine.print(string.format("maps.load: %s: ignoring unknown layer '%s'", source, layer_name))
else
local tiles = require_field(layer_data, "tiles", "table",
source .. ".layers." .. layer_name)
if #tiles ~= expected then
error(string.format("maps.load: schema violation in %s.layers.%s: tiles length %d != %d",
source, layer_name, #tiles, expected))
end
end
end
if t.roof ~= nil then
if type(t.roof) ~= "table" then
error(string.format("maps.load: %s: roof must be array", source))
end
if #t.roof ~= expected then
error(string.format("maps.load: %s: roof length %d != %d", source, #t.roof, expected))
end
end
end
-- =====================================================================
-- Internal helpers
-- =====================================================================
-- Checks whether `full_id` is a tilemap belonging to the current module.
-- Module-IDs can themselves contain dots (e.g. `lib-core.maps-test`), so a
-- naive first-dot-split is wrong. Match by prefix `<current-module-id>.`.
-- Returns (is_local, local_name) — local_name is nil if not local.
local function split_local_tilemap(full_id)
local mod = engine.module.id()
local prefix = mod .. "."
if string.sub(full_id, 1, #prefix) == prefix then
return true, string.sub(full_id, #prefix + 1)
end
return false, nil
end
-- "demo_tilemap" -> "<current-module-id>.demo_tilemap"
-- "lib-x.foo" -> "lib-x.foo" (cross-lib path; checked at load)
local function resolve_tilemap_id(ref)
if string.find(ref, ".", 1, true) then
return ref
end
return engine.module.id() .. "." .. ref
end
local function validate_map_table(t, source)
require_field(t, "id", "string", source)
require_field(t, "tilemap", "string", source)
local size = require_field(t, "size", "table", source)
require_field(size, "w", "number", source .. ".size")
require_field(size, "h", "number", source .. ".size")
local tiles = require_field(t, "tiles", "table", source)
local expected = size.w * size.h
if #tiles ~= expected then
error(string.format("maps.load: schema violation in %s: tiles array length %d != size.w * size.h (%d)",
source, #tiles, expected))
end
end
local function validate_tilemap_table(t, source, expected_local_name)
require_field(t, "id", "string", source)
if t.id ~= expected_local_name then
error(string.format("maps.load: tilemap manifest id '%s' mismatches filename-stem '%s' in %s",
t.id, expected_local_name, source))
end
require_field(t, "tile_size", "number", source)
local tiles = require_field(t, "tiles", "table", source)
for i, entry in ipairs(tiles) do
if type(entry) ~= "table" then
error(string.format("maps.load: tilemap %s tiles[%d] must be table", source, i))
end
require_field(entry, "id", "string", source .. ".tiles[" .. i .. "]")
require_field(entry, "walkable", "boolean", source .. ".tiles[" .. i .. "]")
end
end
local function load_tilemap(full_id)
if tilemap_registry[full_id] then
return tilemap_registry[full_id]
end
local is_local, local_name = split_local_tilemap(full_id)
if not is_local then
-- DEPRECATED-MVP: cross-lib tilemap resolution deferred to render-slice
error(string.format("maps.load: cross-lib tilemap resolution deferred [DEPRECATED-MVP]; tilemap '%s' not from current module '%s'",
full_id, engine.module.id()))
end
local path = "assets/tiles/" .. local_name .. ".tilemap.json"
local raw = engine.asset.load_json(path)
validate_tilemap_table(raw, path, local_name)
-- Cook tiles: copy raw fields and add texture + texture_handle slots.
local cooked = {}
for i, t in ipairs(raw.tiles) do
cooked[i] = {
id = t.id,
walkable = (t.walkable == true),
color = t.color, -- color-mode fallback
texture = t.texture, -- optional atlas-id
texture_handle = nil, -- populated by load_textures
}
end
local tilemap = {
id = full_id,
tile_size = raw.tile_size,
asset_pack = raw.asset_pack, -- optional alias-key
tiles = cooked,
}
tilemap_registry[full_id] = tilemap
return tilemap
end
local function build_map(t_map, tilemap)
-- Verify each tile-id is in palette range
for i, tid in ipairs(t_map.tiles) do
if type(tid) ~= "number" or tid < 1 or tid > #tilemap.tiles then
error(string.format("maps.load: tile-id %s at index %d exceeds palette size %d (in map '%s')",
tostring(tid), i, #tilemap.tiles, t_map.id))
end
end
return {
id = t_map.id,
size = t_map.size,
tile_size = t_map.tile_size or tilemap.tile_size,
tiles = t_map.tiles, -- shallow-ref
tile_rotations = t_map.tile_rotations, -- optional parallel array (NEW)
tilemap = tilemap, -- shallow-ref
-- DEPRECATED-MVP: forward-compat stubs (multi-map slice fills)
walls = {},
regions = {},
edges = {},
state = "Active",
pinned = false,
}
end
local function build_map_v2(t_map, resolved_atlases)
local size = t_map.size
return {
id = t_map.id,
schema_version = 2,
size = size,
tile_size = resolved_atlases[1].tile_size, -- assume uniform; first atlas wins
atlases = resolved_atlases,
atlas_aliases = t_map.atlases, -- alias strings, parallel to atlases
layers = t_map.layers or {},
roof = t_map.roof,
-- DEPRECATED-MVP stubs
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.
-- =====================================================================
local function draw_layer(m, layer_name)
local layer = m.layers[layer_name]
if not layer then return end
local sz = m.size
local ts = m.tile_size
for y = 0, sz.h - 1 do
for x = 0, sz.w - 1 do
local idx = y * sz.w + x + 1
local gid = layer.tiles[idx] or 0
if gid ~= 0 then
local atlas_idx, tile_id, rot_quad = decode_gid(gid)
local atlas = m.atlases[atlas_idx + 1]
if atlas then
local tile = atlas.tiles[tile_id]
if tile then
local px = x * ts
local py = y * ts
local rot_deg = rot_quad * 90.0
if tile.texture_handle then
engine.render.draw_sprite_transform(
tile.texture_handle,
px + ts / 2, py + ts / 2,
math.rad(rot_deg),
1.0, 1.0,
ts / 2, ts / 2,
0xFFFFFFFF
)
else
local c = tile.color or { 100, 100, 100 }
engine.render.draw_rect(px, py, ts, ts,
engine.render.rgb(c[1], c[2], c[3]))
end
end
end
end
end
end
end
local function draw_v1_legacy(m)
local tm = m.tilemap
if not tm then return end
local ts = tm.tile_size
for y = 0, m.size.h - 1 do
for x = 0, m.size.w - 1 do
local idx = y * m.size.w + x + 1
local tile_id = m.tiles[idx]
local tile = tm.tiles[tile_id]
local rot_deg = (m.tile_rotations and m.tile_rotations[idx]) or 0
local px = x * ts
local py = y * ts
if tile.texture_handle then
engine.render.draw_sprite_transform(
tile.texture_handle,
px + ts / 2, py + ts / 2,
math.rad(rot_deg),
1.0, 1.0,
ts / 2, ts / 2,
0xFFFFFFFF
)
else
local c = tile.color or { 100, 100, 100 }
engine.render.draw_rect(px, py, ts, ts,
engine.render.rgb(c[1], c[2], c[3]))
end
end
end
end
-- =====================================================================
-- Public API
-- =====================================================================
local M = {}
function M.load(path)
local raw = engine.asset.load_json(path)
if raw.schema_version == nil or raw.schema_version == 1 then
raw = upgrade_v1_to_v2(raw)
engine.print(string.format("maps.load: auto-upgraded v1 map '%s' to v2", raw.id or "<unnamed>"))
end
validate_map_table_v2(raw, path)
-- Resolve atlas-aliases (each entry is a tilemap-id, possibly local or fully-qualified)
local resolved_atlases = {}
for i, alias in ipairs(raw.atlases) do
local full_id = resolve_tilemap_id(alias)
resolved_atlases[i] = load_tilemap(full_id)
end
local map = build_map_v2(raw, resolved_atlases)
if map_registry[map.id] then
error(string.format("maps.load: map-id '%s' already registered", map.id))
end
map_registry[map.id] = map
return map.id
end
-- Programmatic creation (tests, future procedural-map generators).
-- Caller must provide a fully-built tilemap-table (not a path/id ref).
function M.create(t)
if type(t.tilemap_table) ~= "table" then
error("maps.create: tilemap_table required (use maps.load for JSON path)")
end
local map = build_map(t, t.tilemap_table)
if map_registry[map.id] then
error(string.format("maps.create: map-id '%s' already registered", map.id))
end
map_registry[map.id] = map
return map.id
end
function M.size(map_id)
local id = map_id or current_map_id
if not id then error("maps.size: no current map") end
return map_registry[id].size
end
function M.tile_size(map_id)
local id = map_id or current_map_id
if not id then error("maps.tile_size: no current map") end
return map_registry[id].tile_size
end
function M.tile_at_layer(layer_name, x, y, map_id)
local id = map_id or current_map_id
if not id then error("maps.tile_at_layer: no current map") end
local m = map_registry[id]
if x < 0 or x >= m.size.w or y < 0 or y >= m.size.h then return nil end
if not m.layers or not m.layers[layer_name] then return nil end
local gid = m.layers[layer_name].tiles[y * m.size.w + x + 1] or 0
if gid == 0 then return nil end
local atlas_idx, tile_id, _rot = decode_gid(gid)
local atlas = m.atlases[atlas_idx + 1]
if not atlas then return nil end
return atlas.tiles[tile_id]
end
-- Arity-flex sugar: tile_at(tx, ty) uses current_map_id; tile_at(map_id, tx, ty)
-- is explicit. Both forms accept nil map_id and fall back to current_map_id.
function M.tile_at(a, b, c)
local map_id, tx, ty
if c == nil then
map_id, tx, ty = current_map_id, a, b
else
map_id, tx, ty = a, b, c
if map_id == nil then map_id = current_map_id end
end
if not map_id then
error("maps.tile_at: no current map; call set_current() first or pass map_id")
end
local m = map_registry[map_id]
if not m then
error(string.format("maps.tile_at: unknown map-id '%s'", tostring(map_id)))
end
-- v2 path: query surface layer for back-compat
if m.schema_version == 2 then
return M.tile_at_layer("surface", tx, ty, map_id)
end
-- v1 path (legacy): kept for any non-upgraded maps that bypass M.load
if tx < 0 or tx >= m.size.w or ty < 0 or ty >= m.size.h then return nil end
local palette_id = m.tiles[ty * m.size.w + tx + 1]
return m.tilemap.tiles[palette_id]
end
-- v2 walkability: surface non-empty AND no blocking wall
function M.is_walkable(a, b, c)
local map_id, tx, ty
if c == nil then
map_id, tx, ty = current_map_id, a, b
else
map_id, tx, ty = a, b, c
if map_id == nil then map_id = current_map_id end
end
if not map_id then
error("maps.is_walkable: no current map")
end
local m = map_registry[map_id]
if m.schema_version ~= 2 then
-- v1 fallback: surface-only walkability via legacy tile_at
local t = M.tile_at(map_id, tx, ty)
if t == nil then return false end
return t.walkable == true
end
-- v2: needs surface tile present AND lower_wall/wall absent
local surface = M.tile_at_layer("surface", tx, ty, map_id)
if surface == nil then return false end
if M.cell_gid("lower_wall", tx, ty, map_id) ~= 0 then return false end
if M.cell_gid("wall", tx, ty, map_id) ~= 0 then return false end
return surface.walkable == true
end
function M.blocks_walk(x, y, map_id)
local id = map_id or current_map_id
if not id then error("maps.blocks_walk: no current map") end
local m = map_registry[id]
if x < 0 or x >= m.size.w or y < 0 or y >= m.size.h then return false end
if M.cell_gid("lower_wall", x, y, id) ~= 0 then return true end
if M.cell_gid("wall", x, y, id) ~= 0 then return true end
return false
end
function M.blocks_sight(x, y, map_id)
local id = map_id or current_map_id
if not id then error("maps.blocks_sight: no current map") end
local m = map_registry[id]
if x < 0 or x >= m.size.w or y < 0 or y >= m.size.h then return false end
if M.cell_gid("wall", x, y, id) ~= 0 then return true end
-- upper_wall: per-tile blocks_sight flag in atlas metadata; default true for walls
local up_gid = M.cell_gid("upper_wall", x, y, id)
if up_gid ~= 0 then
local atlas_idx, tile_id, _rot = decode_gid(up_gid)
local atlas = m.atlases[atlas_idx + 1]
local tile = atlas and atlas.tiles[tile_id]
if tile and tile.blocks_sight == false then
return false
end
return true
end
return false
end
function M.tilemap_id(map_id)
local id = map_id or current_map_id
local m = map_registry[id]
-- v2: return first atlas alias (backwards-compat for single-atlas maps)
if m.schema_version == 2 then
return m.atlas_aliases and m.atlas_aliases[1] or (m.atlases[1] and m.atlases[1].id)
end
return m.tilemap.id
end
function M.current()
return current_map_id
end
function M.set_current(map_id)
if not map_registry[map_id] then
error(string.format("maps.set_current: unknown map-id '%s'", tostring(map_id)))
end
current_map_id = map_id
end
function M.list()
local out = {}
for id, _ in pairs(map_registry) do
out[#out+1] = id
end
return out
end
function M.atlas_count(map_id)
local id = map_id or current_map_id
if not id then error("maps.atlas_count: no current map") end
local m = map_registry[id]
if m.schema_version ~= 2 then return 1 end -- v1 (pre-upgrade) always 1
return #m.atlases
end
function M.atlas_id_at(idx, map_id)
local id = map_id or current_map_id
if not id then error("maps.atlas_id_at: no current map") end
local m = map_registry[id]
return m.atlas_aliases and m.atlas_aliases[idx + 1]
or m.atlases[idx + 1] and m.atlases[idx + 1].id
end
function M.has_layer(layer_name, map_id)
local id = map_id or current_map_id
if not id then error("maps.has_layer: no current map") end
local m = map_registry[id]
return m.layers and m.layers[layer_name] ~= nil
end
function M.cell_gid(layer_name, x, y, map_id)
local id = map_id or current_map_id
if not id then error("maps.cell_gid: no current map") end
local m = map_registry[id]
if not m.layers or not m.layers[layer_name] then return 0 end
if x < 0 or x >= m.size.w or y < 0 or y >= m.size.h then return 0 end
return m.layers[layer_name].tiles[y * m.size.w + x + 1] or 0
end
function M.is_indoor(x, y, map_id)
local id = map_id or current_map_id
if not id then error("maps.is_indoor: no current map") end
local m = map_registry[id]
if not m.roof then return false end
if x < 0 or x >= m.size.w or y < 0 or y >= m.size.h then return false end
return m.roof[y * m.size.w + x + 1] == 1
end
-- ====================================================================
-- Resolve tilemap-tile atlas-ids to texture-handles via asset-lib.
-- Operates on the current map's tilemap; call after maps.set_current.
-- asset_aliases: { [alias-key] = asset-lib-id } from module's manifest.
-- ====================================================================
function M.load_textures(asset_aliases)
local map_id = M.current()
if map_id == nil then
error("maps.load_textures: no current map; call maps.set_current first")
end
local m = map_registry[map_id]
local tm = m.tilemap
if tm.asset_pack == nil then return end -- color-only tilemap, no textures
local lib_id = asset_aliases[tm.asset_pack]
if lib_id == nil then
error("maps.load_textures: asset-pack alias '" .. tm.asset_pack
.. "' not in asset_aliases")
end
local atlas_path = lib_id .. "/assets/atlas.json"
local atlas = engine.asset.load_json(atlas_path)
local pack = atlas[tm.asset_pack]
if pack == nil then
error("maps.load_textures: asset_pack '" .. tm.asset_pack
.. "' not declared in atlas of '" .. lib_id .. "'")
end
for _, tile in ipairs(tm.tiles) do
if tile.texture then
local entry = pack[tile.texture]
if entry == nil then
error("maps.load_textures: atlas-id '" .. tile.texture
.. "' not in asset_pack '" .. tm.asset_pack .. "'")
end
tile.texture_handle = engine.asset.load_texture(lib_id .. "/assets/" .. entry.file)
end
end
end
function M.iterate_layers_pre_entities(fn, map_id)
local id = map_id or current_map_id
if not id then error("maps.iterate_layers_pre_entities: no current map") end
local m = map_registry[id]
if m.schema_version ~= 2 then
-- v1 (legacy): only surface conceptually
fn("surface")
return
end
for _, name in ipairs(LAYER_ORDER_PRE_ENTITIES) do
if m.layers[name] then fn(name) end
end
end
function M.iterate_layers_post_entities(fn, map_id)
local id = map_id or current_map_id
if not id then error("maps.iterate_layers_post_entities: no current map") end
local m = map_registry[id]
if m.schema_version ~= 2 then return end -- v1 has nothing post
for _, name in ipairs(LAYER_ORDER_POST_ENTITIES) do
if m.layers[name] then fn(name) end
end
end
function M.draw_map_pre_entities()
local id = current_map_id
if id == nil then return end
local m = map_registry[id]
if m.schema_version ~= 2 then
draw_v1_legacy(m)
return
end
for _, name in ipairs(LAYER_ORDER_PRE_ENTITIES) do
if m.layers[name] then draw_layer(m, name) end
end
end
function M.draw_map_post_entities()
local id = current_map_id
if id == nil then return end
local m = map_registry[id]
if m.schema_version ~= 2 then return end
for _, name in ipairs(LAYER_ORDER_POST_ENTITIES) do
if m.layers[name] then draw_layer(m, name) end
end
end
-- Backward-compat: draw_map renders everything (pre + post, no entity slot)
function M.draw_map()
M.draw_map_pre_entities()
M.draw_map_post_entities()
end
-- Forward-compat stubs (DEPRECATED-MVP — implemented in later slices)
function M.state(map_id)
-- DEPRECATED-MVP: lifecycle states (Virgin/Inert/Passive/Active/Pinned) — multi-map slice
return "Active"
end
function M.pin(map_id, reason)
-- DEPRECATED-MVP: world.pin_map mechanic — multi-map slice
engine.warn("maps.pin: deferred to map-topology lifecycle slice")
end
M.encode_gid = encode_gid
M.decode_gid = decode_gid
M.upgrade_v1_to_v2 = upgrade_v1_to_v2
M.validate_map_table_v2 = validate_map_table_v2
return M