Compare commits

...

11 Commits

Author SHA1 Message Date
Axel Meyer
6b2dc22227 Document v0.2.0 API and schema-v2 map format
Update README.md to version 0.2.0: add API entries for the 16 new
public functions (encode_gid, decode_gid, upgrade_v1_to_v2,
validate_map_table_v2, atlas_count, atlas_id_at, has_layer, is_indoor,
cell_gid, tile_at_layer, blocks_walk, blocks_sight,
iterate_layers_pre_entities, iterate_layers_post_entities,
draw_map_pre_entities, draw_map_post_entities), add the schema-v2
reference section documenting layer-stack, packed-u32 GID bit layout,
auto-migration, and a v0.2.0 changelog entry.

Fix tile-id OOB validation regression in build_map_v2: after v1->v2
upgrade, packed GIDs were not checked against atlas palette sizes.
Restore the check in build_map_v2 so loading a map with an out-of-range
tile_id still produces a fatal error with the same message pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:33:27 +02:00
Axel Meyer
caf29b314d Make load_textures iterate atlases for v2 maps
M.load now auto-upgrades v1 maps to v2 shape (no m.tilemap field), but
load_textures still dereferenced m.tilemap unconditionally and crashed on
every v2 consumer. Extract per-tilemap loop into a helper and dispatch by
schema_version.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:18:34 +02:00
Axel Meyer
fffaddc190 Bump version to 0.2.0 for multi-layer schema support
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:13:57 +02:00
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
Axel Meyer
53c9fe4c0c Add v2 gameplay queries: walkable / blocks_walk / blocks_sight
Replace the v1 is_walkable stub with a v2-aware version that requires
surface tile present and no blocking wall layer cell. Add blocks_walk
(wall-layer OOB-safe check) and blocks_sight (wall + upper_wall with
per-tile blocks_sight override). v1 path unchanged via schema_version
branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:50:59 +02:00
Axel Meyer
15fd68670e Add tile_at_layer and route legacy tile_at to surface layer
Introduces M.tile_at_layer(layer_name, x, y, map_id) for direct per-layer
tile lookup with proper atlas resolution. Refactors the v2 branch of M.tile_at
to delegate to tile_at_layer("surface", ...) instead of duplicating the GID
decode logic inline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:48:07 +02:00
Axel Meyer
880c67fef3 Expose cell_gid query for multi-atlas v2 maps
Adds M.cell_gid(layer_name, x, y, map_id) to read raw GID values from
named layers, enabling callers to decode atlas/tile/rotation from any
layer without going through tile_at().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:43:02 +02:00
Axel Meyer
6af36e9c6e Load schema-v2 maps with multi-layer storage and roof field
Replace the v1-only M.load path with a unified v2 load path that
auto-upgrades v1 JSON on read. Add build_map_v2 to produce map records
with atlases[], layers{}, and roof arrays. Add atlas_count, atlas_id_at,
has_layer, and is_indoor to the public API. Update tile_at to decode GIDs
from the surface layer for v2 maps, keeping M.create/build_map for
programmatic callers that supply a v1-style tilemap_table directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:37:41 +02:00
Axel Meyer
1562ee0068 feat: validate schema-v2 map tables with layer-name whitelist
Adds validate_map_table_v2 — enforces schema_version=2, required
fields (id, size, atlases), non-empty atlases[], per-layer tile-count
check against w*h, and a VALID_LAYER_NAMES whitelist that logs a
warning for unknown layers instead of rejecting them.

Moves require_field before the v2 validator so it is in scope at
call time. engine.warn replaced with engine.print (no warn binding).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:26:48 +02:00
Axel Meyer
0985141fb9 feat: add v1->v2 schema auto-upgrade for map tables
Converts legacy single-tilemap maps (tiles[], tile_rotations[]) into the
schema-v2 format (atlases[], layers.surface with packed GID cells).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:20:32 +02:00
Axel Meyer
446d37fb0e feat: add packed-u32 GID encoding helpers for schema-v2 maps
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>
2026-05-21 15:15:34 +02:00
3 changed files with 729 additions and 62 deletions

264
README.md
View File

@@ -2,7 +2,7 @@
Single tile-grid map implementation. Loads JSON map + tilemap files, registers maps by id, and provides tile-fetch + walkability + size queries with current-map sugar.
**Version:** 0.1.2
**Version:** 0.2.0
**Lib-ID:** lib-core.maps
**Requires:** (none)
**Tags:** maps, tile-grid, walkability, tilemap
@@ -119,6 +119,173 @@ maps.load_textures({ terrain = "lib-core.terrain-assets" })
**Description:** DEPRECATED-MVP stub. Emits a warn in v0.1.x. Pinning prevents lifecycle-eviction in the multi-map slice.
### `maps.encode_gid(atlas_index, tile_id, rotation?)`
**Syntax:** `maps.encode_gid(atlas_index: integer, tile_id: integer, rotation?: integer) -> integer`
**Example:**
```lua
local gid = maps.encode_gid(0, 42, 1) -- atlas 0, tile 42, rotation 90°
```
**Description:** Packs atlas_index (8 bits), tile_id (20 bits), and rotation (2 bits, 03 = 0°/90°/180°/270°) into a single u32 GID. `rotation` defaults to 0 when omitted. Bit layout: `[31..24 atlas_index][23..4 tile_id][3..2 rotation][1..0 reserved]`.
### `maps.decode_gid(gid)`
**Syntax:** `maps.decode_gid(gid: integer) -> atlas_index: integer, tile_id: integer, rotation: integer`
**Example:**
```lua
local ai, tid, rot = maps.decode_gid(gid)
```
**Description:** Unpacks a packed-u32 GID into its three components: atlas_index, tile_id, and rotation (03). Inverse of `encode_gid`.
### `maps.upgrade_v1_to_v2(v1_table)`
**Syntax:** `maps.upgrade_v1_to_v2(v1_table: table) -> table`
**Example:**
```lua
local v2 = maps.upgrade_v1_to_v2(old_map)
```
**Description:** Converts a schema-v1 map table to schema-v2 format. The v1 flat tile array is placed into the `surface` layer with GIDs encoded against `atlases[1]`. Called automatically by `maps.load` and `maps.create` when the schema_version field is absent or equals 1; consumers normally do not need to call this directly.
### `maps.validate_map_table_v2(table, source)`
**Syntax:** `maps.validate_map_table_v2(table: table, source: string) -> void`
**Description:** Validates a v2 map table for required fields (schema_version, id, size, atlases, layers). Errors with the `source` string as context when the table is malformed. Called internally by `load` and `create`; available for use in tests and generators.
### `maps.atlas_count(map_id?)`
**Syntax:** `maps.atlas_count(map_id: string | nil) -> integer`
**Example:**
```lua
local n = maps.atlas_count() -- count atlases on current map
```
**Description:** Returns the number of atlas entries in the map's `atlases` array. Falls back to current-map when `map_id` is nil.
### `maps.atlas_id_at(idx, map_id?)`
**Syntax:** `maps.atlas_id_at(idx: integer, map_id: string | nil) -> string`
**Example:**
```lua
local id = maps.atlas_id_at(1) -- fully-qualified atlas id at index 1
```
**Description:** Returns the fully-qualified atlas id at 1-based index `idx` in the map's atlases array. Falls back to current-map when `map_id` is nil. Errors if index is out of range.
### `maps.has_layer(layer_name, map_id?)`
**Syntax:** `maps.has_layer(layer_name: string, map_id: string | nil) -> boolean`
**Example:**
```lua
if maps.has_layer("canopy") then
-- render canopy layer
end
```
**Description:** Returns `true` if the map has a layer with the given name. Falls back to current-map when `map_id` is nil. Valid layer names: `foundation`, `subsurface`, `surface`, `topsurface`, `lower_wall`, `wall`, `upper_wall`, `canopy`, `roof`.
### `maps.is_indoor(x, y, map_id?)`
**Syntax:** `maps.is_indoor(x: integer, y: integer, map_id: string | nil) -> boolean`
**Example:**
```lua
if maps.is_indoor(tx, ty) then
-- apply indoor lighting
end
```
**Description:** Returns `true` if the roof metadata layer marks the cell at `(x, y)` as indoor. Falls back to current-map when `map_id` is nil. Returns `false` if the map has no roof layer or the cell is out of bounds.
### `maps.cell_gid(layer_name, x, y, map_id?)`
**Syntax:** `maps.cell_gid(layer_name: string, x: integer, y: integer, map_id: string | nil) -> integer`
**Example:**
```lua
local gid = maps.cell_gid("surface", 5, 3)
local ai, tid, rot = maps.decode_gid(gid)
```
**Description:** Returns the packed-u32 GID stored at cell `(x, y)` in the named layer. Returns 0 for empty cells and out-of-bounds positions. Falls back to current-map when `map_id` is nil.
### `maps.tile_at_layer(layer_name, x, y, map_id?)`
**Syntax:** `maps.tile_at_layer(layer_name: string, x: integer, y: integer, map_id: string | nil) -> table | nil`
**Example:**
```lua
local tile = maps.tile_at_layer("surface", 5, 3)
if tile then engine.print(tile.id) end
```
**Description:** Returns the resolved tile record from the atlas referenced by the cell's GID in the named layer, or `nil` if the cell is empty or out of bounds. Falls back to current-map when `map_id` is nil. Equivalent to `maps.tile_at` but with an explicit layer argument.
### `maps.blocks_walk(x, y, map_id?)`
**Syntax:** `maps.blocks_walk(x: integer, y: integer, map_id: string | nil) -> boolean`
**Example:**
```lua
if maps.blocks_walk(tx, ty) then
-- cell is impassable
end
```
**Description:** Returns `true` if any tile in the walk-relevant layers (`surface`, `lower_wall`, `wall`) at `(x, y)` has `walkable == false`. Out-of-bounds returns `true` (blocked). Falls back to current-map when `map_id` is nil. Supersedes `is_walkable` for v2 maps.
### `maps.blocks_sight(x, y, map_id?)`
**Syntax:** `maps.blocks_sight(x: integer, y: integer, map_id: string | nil) -> boolean`
**Example:**
```lua
if maps.blocks_sight(tx, ty) then
-- cell blocks line of sight
end
```
**Description:** Returns `true` if any tile at `(x, y)` across all relevant layers has `blocks_sight == true`. Falls back to current-map when `map_id` is nil. Returns `true` for out-of-bounds positions.
### `maps.iterate_layers_pre_entities(fn, map_id?)`
**Syntax:** `maps.iterate_layers_pre_entities(fn: function(layer_name: string), map_id: string | nil) -> void`
**Example:**
```lua
maps.iterate_layers_pre_entities(function(layer_name)
-- draw the layer
end)
```
**Description:** Calls `fn` once for each visual layer that renders before the entity slot, in draw order: `foundation`, `subsurface`, `surface`, `topsurface`. Falls back to current-map when `map_id` is nil. Skips layers not present on the map.
### `maps.iterate_layers_post_entities(fn, map_id?)`
**Syntax:** `maps.iterate_layers_post_entities(fn: function(layer_name: string), map_id: string | nil) -> void`
**Example:**
```lua
maps.iterate_layers_post_entities(function(layer_name)
-- draw the layer above entities
end)
```
**Description:** Calls `fn` once for each visual layer that renders after the entity slot, in draw order: `lower_wall`, `wall`, `upper_wall`, `canopy`. Falls back to current-map when `map_id` is nil. Skips layers not present on the map.
### `maps.draw_map_pre_entities()`
**Syntax:** `maps.draw_map_pre_entities() -> void`
**Example:**
```lua
-- in on_draw:
maps.draw_map_pre_entities()
-- draw entities here
maps.draw_map_post_entities()
```
**Description:** Draws all pre-entity layers of the current map (`foundation` through `topsurface`) using `engine.render.draw_sprite_transform` for textured tiles and `engine.render.draw_rect` as fallback. No-op if no current map. Replaces the single-pass `draw_map` for consumers that need to interleave entity rendering.
### `maps.draw_map_post_entities()`
**Syntax:** `maps.draw_map_post_entities() -> void`
**Description:** Draws all post-entity layers of the current map (`lower_wall` through `canopy`). Must be called after entity rendering when using the split draw model. No-op if no current map.
## Conventions
- Pixel-coords + tile-coords kept distinct: `size`/`tiles` index in tile-units; `tile_size` is the conversion to pixels.
@@ -126,6 +293,82 @@ maps.load_textures({ terrain = "lib-core.terrain-assets" })
- `tile_at` is bounds-checked: out-of-bounds returns `nil` (not error).
- Y-down-positive per ADR-0031.
## Schema (v2)
Schema-v2 is the canonical map format as of v0.2.0. Schema-v1 maps are
auto-migrated to v2 transparently in `maps.load` and `maps.create`.
### Top-level structure
```json
{
"schema_version": 2,
"id": "my-map",
"size": { "w": 16, "h": 16 },
"tile_size": { "w": 16, "h": 16 },
"atlases": [
{ "id": "lib-core.terrain-assets", "alias": "terrain" }
],
"layers": {
"foundation": [/* w*h packed u32 GIDs */],
"subsurface": [],
"surface": [/* */],
"topsurface": [],
"lower_wall": [],
"wall": [],
"upper_wall": [],
"canopy": []
},
"roof": [/* w*h booleans: true = indoor */]
}
```
All layer arrays are `w * h` elements, row-major (Y-down-positive). Empty
arrays or omitted keys mean the layer is absent. The `roof` array is a
flat boolean array (not a layer slot); `is_indoor` reads from it.
### Layer-name whitelist and gameplay semantics
| Layer | Z-order | Semantics |
|---|:---:|---|
| `foundation` | 1 | Bottom-most ground fill (deep floor, pit bottom, water bed) |
| `subsurface` | 2 | Sub-floor details (rubble, cables, sub-water objects) |
| `surface` | 3 | Primary floor / ground layer — walkability is keyed here |
| `topsurface` | 4 | Floor overlays (rugs, puddles, decals on the ground) |
| *(entities)* | — | Entity slot — rendered between topsurface and lower_wall |
| `lower_wall` | 5 | Wall bases, furniture bases, low obstacles |
| `wall` | 6 | Main wall bodies, furniture, doors |
| `upper_wall` | 7 | Wall tops, window frames, upper furniture details |
| `canopy` | 8 | Roof fringe, tree canopy, overhead overlays |
| `roof` | — | Metadata only (not rendered); indoor mask for lighting |
### Packed-u32 GID bit layout
Each cell in a layer array is a single 32-bit unsigned integer:
```
Bit: 31 24 23 4 3 2 1 0
[atlas:8 ] [tile_id:20 ] [rot:2] [res:2]
```
- **atlas** (bits 31..24): 0-based index into the map's `atlases` array.
- **tile_id** (bits 23..4): tile index within the atlas.
- **rot** (bits 3..2): rotation in 90° steps — 0=0°, 1=90°, 2=180°, 3=270°.
- **res** (bits 1..0): reserved, must be 0.
Encode: `gid = (atlas << 24) | (tile_id << 4) | (rot << 2)`
Decode: `atlas = gid >> 24`, `tile_id = (gid >> 4) & 0xFFFFF`, `rot = (gid >> 2) & 0x3`
A GID of 0 means "empty cell" (no tile).
### Auto-migration v1 → v2
When `maps.load` or `maps.create` receives a map without `schema_version`
or with `schema_version == 1`, `maps.upgrade_v1_to_v2` is called
automatically. The v1 flat `tiles` array is placed into the `surface`
layer with GIDs encoded against `atlases[1]`. Existing consumers see v2
data transparently; no code changes required.
## Consumer pattern
```lua
@@ -146,6 +389,24 @@ end
## CHANGELOG
### v0.2.0
- Schema-v2 multi-layer maps with named layer slots (foundation,
subsurface, surface, topsurface, lower_wall, wall, upper_wall, canopy)
plus a roof metadata layer.
- Packed-u32 GID encoding per cell: bits 31..24 atlas_index, 23..4
tile_id, 3..2 rotation, 1..0 reserved.
- Multi-atlas-per-map: atlases[] array, GIDs reference into it.
- Auto-migration of v1 maps to v2 on load — existing consumers see
v2 data transparently.
- New per-layer query APIs: tile_at_layer, cell_gid, has_layer.
- New gameplay queries: blocks_walk, blocks_sight, is_indoor.
- New layer-iteration APIs: iterate_layers_pre_entities,
iterate_layers_post_entities, draw_map_pre_entities,
draw_map_post_entities. Entity slot is between topsurface and
lower_wall.
- Backward-compat: draw_map and tile_at delegate to the v2 API
using the surface layer for legacy consumers.
### v0.1.2
- Sprite-mode in draw_map: tilemap-tiles with `texture` atlas-id render
via engine.render.draw_sprite_transform; tiles without `texture` fall
@@ -169,6 +430,7 @@ end
## References
- Spec v0.2.0 (M.1): `meta/docs/superpowers/specs/2026-05-21-map-multi-layer-design.md`
- Spec v0.1.0 (P.0): `meta/docs/superpowers/specs/2026-05-09-p0-lib-maps-design.md`
- Architecture: `meta/docs/architecture/map-topology.md`
- ADR-0001 (engine knows verbs, libs bring nouns)

525
init.lua
View File

@@ -12,6 +12,152 @@ 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
-- =====================================================================
@@ -38,20 +184,6 @@ local function resolve_tilemap_id(ref)
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)
@@ -141,6 +273,119 @@ local function build_map(t_map, tilemap)
}
end
local function build_map_v2(t_map, resolved_atlases)
local size = t_map.size
-- Validate that each non-zero GID references a tile_id within its atlas's palette.
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 "<unnamed>", layer_name))
end
end
end
end
end
end
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 = layers,
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
-- =====================================================================
@@ -149,10 +394,20 @@ 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 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
@@ -186,6 +441,20 @@ function M.tile_size(map_id)
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)
@@ -203,22 +472,81 @@ function M.tile_at(a, b, c)
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
-- 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 t = M.tile_at(a, b, c)
if t == nil then return false end
return t.walkable == true
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
return map_registry[id].tilemap.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()
@@ -240,18 +568,53 @@ function M.list()
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
local function load_textures_for_tilemap(tm, asset_aliases)
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
@@ -277,37 +640,74 @@ function M.load_textures(asset_aliases)
end
end
function M.draw_map()
function M.load_textures(asset_aliases)
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
if map_id == nil then
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
for _, tm in ipairs(m.atlases) do
load_textures_for_tilemap(tm, asset_aliases)
end
return
end
-- v1 legacy: kept for M.create with manual v1 table
load_textures_for_tilemap(m.tilemap, asset_aliases)
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)
@@ -321,4 +721,9 @@ function M.pin(map_id, reason)
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

View File

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