encode_gid / decode_gid pack atlas_index (8 bits), tile_id (20 bits), and rotation (2 bits) into a single u32; gid == 0 is reserved as the empty-cell sentinel. Lua 5.4 native bitwise operators used throughout. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
359 lines
13 KiB
Lua
359 lines
13 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
|
|
|
|
-- =====================================================================
|
|
-- 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
|
|
|
|
-- 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
|
|
|
|
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
|
|
|
|
-- =====================================================================
|
|
-- Public API
|
|
-- =====================================================================
|
|
|
|
local M = {}
|
|
|
|
function M.load(path)
|
|
local raw = engine.asset.load_json(path)
|
|
validate_map_table(raw, path)
|
|
local full_id = resolve_tilemap_id(raw.tilemap)
|
|
local tilemap = load_tilemap(full_id)
|
|
local map = build_map(raw, tilemap)
|
|
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
|
|
|
|
-- 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
|
|
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
|
|
|
|
function M.is_walkable(a, b, c)
|
|
local t = M.tile_at(a, b, c)
|
|
if t == nil then return false end
|
|
return t.walkable == true
|
|
end
|
|
|
|
function M.tilemap_id(map_id)
|
|
local id = map_id or current_map_id
|
|
return map_registry[id].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
|
|
|
|
-- ====================================================================
|
|
-- 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.draw_map()
|
|
local map_id = M.current()
|
|
if map_id == nil then return end
|
|
local m = map_registry[map_id]
|
|
local tm = m.tilemap
|
|
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
|
|
-- Sprite mode: draw_sprite_transform with rotation about tile center.
|
|
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
|
|
-- Color fallback (Phase 1 mode).
|
|
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
|
|
|
|
-- 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
|
|
|
|
return M
|