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>
This commit is contained in:
264
README.md
264
README.md
@@ -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.
|
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
|
**Lib-ID:** lib-core.maps
|
||||||
**Requires:** (none)
|
**Requires:** (none)
|
||||||
**Tags:** maps, tile-grid, walkability, tilemap
|
**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.
|
**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, 0–3 = 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 (0–3). 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
|
## Conventions
|
||||||
|
|
||||||
- Pixel-coords + tile-coords kept distinct: `size`/`tiles` index in tile-units; `tile_size` is the conversion to pixels.
|
- 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).
|
- `tile_at` is bounds-checked: out-of-bounds returns `nil` (not error).
|
||||||
- Y-down-positive per ADR-0031.
|
- 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
|
## Consumer pattern
|
||||||
|
|
||||||
```lua
|
```lua
|
||||||
@@ -146,6 +389,24 @@ end
|
|||||||
|
|
||||||
## CHANGELOG
|
## 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
|
### v0.1.2
|
||||||
- Sprite-mode in draw_map: tilemap-tiles with `texture` atlas-id render
|
- Sprite-mode in draw_map: tilemap-tiles with `texture` atlas-id render
|
||||||
via engine.render.draw_sprite_transform; tiles without `texture` fall
|
via engine.render.draw_sprite_transform; tiles without `texture` fall
|
||||||
@@ -169,6 +430,7 @@ end
|
|||||||
|
|
||||||
## References
|
## 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`
|
- 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`
|
- Architecture: `meta/docs/architecture/map-topology.md`
|
||||||
- ADR-0001 (engine knows verbs, libs bring nouns)
|
- ADR-0001 (engine knows verbs, libs bring nouns)
|
||||||
|
|||||||
21
init.lua
21
init.lua
@@ -275,6 +275,25 @@ end
|
|||||||
|
|
||||||
local function build_map_v2(t_map, resolved_atlases)
|
local function build_map_v2(t_map, resolved_atlases)
|
||||||
local size = t_map.size
|
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 {
|
return {
|
||||||
id = t_map.id,
|
id = t_map.id,
|
||||||
schema_version = 2,
|
schema_version = 2,
|
||||||
@@ -282,7 +301,7 @@ local function build_map_v2(t_map, resolved_atlases)
|
|||||||
tile_size = resolved_atlases[1].tile_size, -- assume uniform; first atlas wins
|
tile_size = resolved_atlases[1].tile_size, -- assume uniform; first atlas wins
|
||||||
atlases = resolved_atlases,
|
atlases = resolved_atlases,
|
||||||
atlas_aliases = t_map.atlases, -- alias strings, parallel to atlases
|
atlas_aliases = t_map.atlases, -- alias strings, parallel to atlases
|
||||||
layers = t_map.layers or {},
|
layers = layers,
|
||||||
roof = t_map.roof,
|
roof = t_map.roof,
|
||||||
-- DEPRECATED-MVP stubs
|
-- DEPRECATED-MVP stubs
|
||||||
walls = {},
|
walls = {},
|
||||||
|
|||||||
Reference in New Issue
Block a user