562 lines
24 KiB
Markdown
562 lines
24 KiB
Markdown
# lib-core.maps
|
||
|
||
Multi-layer tile-grid map implementation with vertex-painted autotile
|
||
(blob-14), sparse per-cell overrides, packed-u32 GID legacy path, atlas
|
||
loading + UV resolution, walkability + sight-blocking queries, and a
|
||
v3 multi-layer render pipeline with opaque-ceiling cache.
|
||
|
||
**Version:** 0.5.7
|
||
**Lib-ID:** lib-core.maps
|
||
**Requires:** (none)
|
||
**Tags:** maps, tile-grid, multi-layer, vertex-painting, autotile, blob-14, override, walkability, tilemap
|
||
|
||
## Schema versions
|
||
|
||
| Version | Highlights |
|
||
|---|---|
|
||
| v1 | Single-layer `tiles[]` + tilemap path. Loaded but auto-upgraded to v2 on `maps.load`. |
|
||
| v2 | Multi-atlas + multi-layer with packed-u32 GIDs (atlas_idx + tile_id + rotation). `set_cell_gid`, `save_to_disk`. |
|
||
| v3 | Per-layer `material` (atlas-alias), optional `vertices` ((W+1)·(H+1) grid), optional `overrides` ({"x:y": int OR {slot, rot, flip}}). Renderer derives slot/rot/flip from neighbour-bitmask blob-gating; overrides force-place specific orientations. |
|
||
|
||
## Painting model (v3 autotile, dual-grid)
|
||
|
||
Two grids offset by half a tile. The PAINT grid (called `vertices` in
|
||
storage, "map-tiles" in design docs) is what users paint. The RENDER
|
||
grid (called `cells`) is offset by (+0.5, +0.5) tile and is where
|
||
sprites sit. Each render-cell spans 4 surrounding paint-tiles which
|
||
act as its 4 corners (TL, TR, BL, BR).
|
||
|
||
- **Paint grid**: (W+1)·(H+1) cells. Any-corner rule: render-cell
|
||
(x,y) is material iff any of its 4 corner paint-tiles is painted.
|
||
- **Bitmask + SLOT_LOOKUP** (0.5.6 dual-grid native): each material
|
||
render-cell's 8-bit neighbour-bitmask (clockwise from N) is derived
|
||
from its own 4 corner paint-tiles — cardinal bit set iff ≥1 of the
|
||
edge's 2 paint-tiles painted, diagonal bit set iff the corner
|
||
paint-tile is painted. Blob-gating then zeroes diagonals whose 2
|
||
adjacent cardinals are not both set. The gated bitmask maps to one
|
||
of 14 canonical slots × {0,1,2,3} rotation × {0,1} flip via a
|
||
D4-orbit table.
|
||
- **Override sublayer**: sparse `{"x:y": slot}` or `{"x:y": {slot, rot,
|
||
flip}}`. Takes precedence over the bitmask-derived slot AND forces
|
||
material-presence on the cell.
|
||
|
||
**0.5.6 fix:** pre-0.5.6 derived the bitmask from the material status
|
||
of the 8 neighbour render-cells, which violated dual-grid semantics —
|
||
two cells whose shared edge had no painted paint-tiles still saw each
|
||
other as material whenever any unrelated corner of either was painted,
|
||
producing connected blobs across visually empty paint-tile gaps. See
|
||
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:start (auto-generated; do not edit) -->
|
||
```mermaid
|
||
graph LR
|
||
this["lib-core.maps"]
|
||
engine["engine.*"]
|
||
this --> engine
|
||
```
|
||
<!-- topology:end -->
|
||
|
||
## API
|
||
|
||
> **Note:** the API section below was authored against v0.2.0 and is
|
||
> being progressively updated. Entries marked `[v0.x added]` are the
|
||
> additions since 0.2.0. See `init.lua` source for the full surface.
|
||
|
||
### APIs added v0.3.0 — v0.5.4 (summary)
|
||
|
||
- **v0.3.0** — atlas-baker integration: `load_textures(asset_aliases)`
|
||
rewrite for M.2 atlas format; height-field API; atlas-bootstrap
|
||
stub when tilemap JSON missing.
|
||
- **v0.4.0** — write-APIs for editors / procedural-gen:
|
||
- `set_cell_gid(layer_name, x, y, gid, map_id?)`
|
||
- `set_roof(x, y, value, map_id?)`
|
||
- `save_to_disk(map_id, path)`
|
||
- **v0.5.0a-e** — schema-v3 multi-layer terrain stack:
|
||
- 8 layer slots (`foundation`, `subsurface`, `surface`, `topsurface`,
|
||
`lower_wall`, `wall`, `upper_wall`, `canopy`)
|
||
- `LAYER_Z` + `LAYER_ORDER_TOP_DOWN` constants
|
||
- Vertex-painted autotile renderer (any-corner + blob-14 SLOT_LOOKUP)
|
||
- Empty-layer + opaque-ceiling caches for render-opt
|
||
- **v0.5.0d** — sparse override sublayer:
|
||
- `set_override(layer_name, x, y, slot_or_entry, map_id?)`
|
||
- `clear_override(layer_name, x, y, map_id?)`
|
||
- `get_override(layer_name, x, y, map_id?)`
|
||
- **v0.5.1** — override-entry format extension to `{slot, rot, flip}`
|
||
(object form, backwards-compat with bare integer). Auto-compacts to
|
||
bare int when canonical orientation (rot=0+flip=0). `tile.opaque`
|
||
flag consumed from atlas-baker v0.2.0 alpha-analysis.
|
||
- **v0.5.2** — public vertex-grid write APIs:
|
||
- `set_vertex(layer_name, vx, vy, painted, map_id?)` — any-corner
|
||
rule fills up to 4 cells; lazy-allocates grid on first paint
|
||
- `get_vertex(layer_name, vx, vy, map_id?) -> bool`
|
||
- **v0.5.3** — bugfix: `load_textures` now refreshes the
|
||
`atlas_by_alias` dict after replacing `m.atlases[i]`, fixing the
|
||
v3 vertex/material render path which was resolving through the
|
||
pre-load stub (no texture handle → MISSING_ASSET_COLOR fallback).
|
||
- **v0.5.4** — public atlas accessors for palette consumers:
|
||
- `atlas_diffuse_handle(atlas_idx, map_id?)` — raylib texture handle
|
||
- `atlas_tile_size_px(atlas_idx, map_id?)` — int, usually 64
|
||
- `atlas_tile_uv(atlas_idx, slot, map_id?)` — `{x, y, w, h}` in
|
||
atlas pixel coords, or `nil`. Resolves slot via the same
|
||
`slot_NN_` name regex used internally by the renderer.
|
||
|
||
### Original v0.2.0 entries
|
||
|
||
|
||
### `maps.load(path)`
|
||
**Syntax:** `maps.load(path: string) -> string`
|
||
|
||
**Example:**
|
||
```lua
|
||
local id = maps.load("maps/forest.json")
|
||
maps.set_current(id)
|
||
```
|
||
|
||
**Description:** Loads a JSON map-file from disk, resolves + loads its referenced tilemap (local module-tilemap-id or fully-qualified), validates the map-table, and registers it. Returns the map-id. Errors if the id is already registered.
|
||
|
||
### `maps.create(t)`
|
||
**Syntax:** `maps.create(t: {id: string, tilemap_table: table, ...}) -> string`
|
||
|
||
**Example:**
|
||
```lua
|
||
local id = maps.create({
|
||
id = "test-tiny",
|
||
size = { w = 4, h = 4 },
|
||
tile_size = { w = 16, h = 16 },
|
||
tilemap_table = my_tilemap,
|
||
tiles = { 0,0,0,0, 0,1,1,0, 0,1,1,0, 0,0,0,0 },
|
||
})
|
||
```
|
||
|
||
**Description:** Programmatic creation for tests + procedural generators. Caller must supply a fully-built `tilemap_table` (not a path/id reference). Otherwise identical to `load`.
|
||
|
||
### `maps.size(map_id)`
|
||
**Syntax:** `maps.size(map_id: string | nil) -> {w: integer, h: integer}`
|
||
|
||
**Description:** Returns map-size in tiles. Falls back to current-map when `map_id` is nil. Errors if no current map.
|
||
|
||
### `maps.tile_size(map_id)`
|
||
**Syntax:** `maps.tile_size(map_id: string | nil) -> {w: integer, h: integer}`
|
||
|
||
**Description:** Returns tile-size in pixels. Falls back to current-map when nil.
|
||
|
||
### `maps.tile_at(a, b, c)`
|
||
**Syntax:** `maps.tile_at(map_id: string, tx: integer, ty: integer) -> table | nil` *(also: `maps.tile_at(tx, ty)` uses current-map)*
|
||
|
||
**Example:**
|
||
```lua
|
||
local tile = maps.tile_at(5, 3)
|
||
if tile then engine.print(tile.id) end
|
||
```
|
||
|
||
**Description:** Arity-flex sugar: `tile_at(tx, ty)` uses current-map; `tile_at(map_id, tx, ty)` is explicit. Returns the resolved tile-record from the tilemap, or `nil` if out-of-bounds.
|
||
|
||
### `maps.is_walkable(a, b, c)`
|
||
**Syntax:** `maps.is_walkable(map_id: string, tx: integer, ty: integer) -> bool` *(also: `is_walkable(tx, ty)` uses current-map)*
|
||
|
||
**Description:** Convenience: returns `true` iff the tile exists and has `walkable == true`. Out-of-bounds is `false`.
|
||
|
||
### `maps.tilemap_id(map_id)`
|
||
**Syntax:** `maps.tilemap_id(map_id: string | nil) -> string`
|
||
|
||
**Description:** Returns the fully-qualified tilemap-id referenced by a map.
|
||
|
||
### `maps.current()`
|
||
**Syntax:** `maps.current() -> string | nil`
|
||
|
||
**Description:** Returns the currently-active map-id, or `nil` if none.
|
||
|
||
### `maps.set_current(map_id)`
|
||
**Syntax:** `maps.set_current(map_id: string) -> void`
|
||
|
||
**Description:** Switches the current-map pointer. Errors if `map_id` is not registered.
|
||
|
||
### `maps.list()`
|
||
**Syntax:** `maps.list() -> string[]`
|
||
|
||
**Description:** Returns an array of all registered map-ids.
|
||
|
||
### `maps.load_textures(asset_aliases)`
|
||
**Syntax:** `maps.load_textures(asset_aliases: {[string]: string}) -> void`
|
||
|
||
**Example:**
|
||
```lua
|
||
maps.set_current(map_id)
|
||
maps.load_textures({ terrain = "lib-core.terrain-assets" })
|
||
```
|
||
|
||
**Description:** Resolves the current tilemap's tile `texture` atlas-ids to texture-handles via the asset-lib indirection. `asset_aliases` maps the tilemap's `asset_pack` alias-key to a lib-id; the lib's `assets/atlas.json` is read to locate each texture file. Populates `tile.texture_handle` on each tile in-place. No-op if the tilemap has no `asset_pack`. Call once at module init after `set_current`; do not call repeatedly (texture handles are not auto-cached).
|
||
|
||
### `maps.draw_map()`
|
||
**Syntax:** `maps.draw_map() -> void`
|
||
|
||
**Description:** Draws the current map grid. Tiles with a resolved `texture_handle` render via `engine.render.draw_sprite_transform` with rotation about the tile center (using the map's optional `tile_rotations` parallel array). Tiles without a texture handle fall back to `engine.render.draw_rect` with `tile.color` (Phase 1 mode). No-op if no current map.
|
||
|
||
### `maps.state(map_id)`
|
||
**Syntax:** `maps.state(map_id: string) -> string`
|
||
|
||
**Description:** DEPRECATED-MVP stub. Always returns `"Active"` in v0.1.x. Full lifecycle (`Virgin`/`Inert`/`Passive`/`Active`/`Pinned`) lands in the multi-map slice.
|
||
|
||
### `maps.pin(map_id, reason)`
|
||
**Syntax:** `maps.pin(map_id: string, reason: string) -> void`
|
||
|
||
**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.
|
||
|
||
### Write APIs (v0.4.0+)
|
||
|
||
#### `maps.set_cell_gid(layer_name, x, y, gid, map_id?)`
|
||
|
||
Writes a single cell into the named layer of the current (or named) map. The layer must be one of `VALID_LAYER_NAMES`; bounds are checked against `map.size`. If the layer does not yet exist on the map it is allocated and initialised to all-zero before the write. Sets an internal `_dirty` flag so callers (e.g. the map-editor) can track unsaved changes.
|
||
|
||
#### `maps.set_roof(x, y, value, map_id?)`
|
||
|
||
Writes a single roof flag (`0` or `1`) at the named cell. Allocates the roof array on demand if the map did not previously have one. Same bounds-check as `set_cell_gid`. Throws on values other than 0 or 1.
|
||
|
||
#### `maps.save_to_disk(map_id, path)`
|
||
|
||
Serialises the in-memory map to v2 JSON and writes it to `path`. Output is pretty-printed with 2-space indent and is byte-deterministic for the same map state (sorted object keys, fixed array order). Reverses the internal atlas-resolution back to atlas-ID strings on disk. Resets the `_dirty` flag on success.
|
||
|
||
## Conventions
|
||
|
||
- Pixel-coords + tile-coords kept distinct: `size`/`tiles` index in tile-units; `tile_size` is the conversion to pixels.
|
||
- Tilemap-ids may be local (`<this-module-id>.<name>`) or fully-qualified; `load()` resolves both.
|
||
- `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
|
||
local maps = require("lib-core.maps")
|
||
|
||
local id = maps.load("maps/forest.json")
|
||
maps.set_current(id)
|
||
|
||
local sz = maps.size()
|
||
for ty = 0, sz.h - 1 do
|
||
for tx = 0, sz.w - 1 do
|
||
if not maps.is_walkable(tx, ty) then
|
||
-- mark blocked cell
|
||
end
|
||
end
|
||
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
|
||
back to color-rect render (Phase 1 mode).
|
||
- Map-data tile_rotations parallel array support: per-cell 90-degree
|
||
rotation in {0, 90, 180, 270}, applied at draw via rotation about
|
||
the tile center. Optional; absent means all-zero.
|
||
- New API: maps.load_textures(asset_aliases) resolves the current
|
||
tilemap's tile atlas-ids to texture-handles via the asset-lib
|
||
indirection. Mirrors the puppet.load_textures pattern.
|
||
- Tilemap schema additions: tilemap.asset_pack (alias-key),
|
||
tile.texture (atlas-id).
|
||
- Backward-compat: existing Phase 1 color-only tilemaps render
|
||
unchanged.
|
||
|
||
### v0.1.1
|
||
- Tilemap-id resolution handles module-ids with dots (prefix-match instead of first-dot split).
|
||
|
||
### v0.1.0 (P.0)
|
||
- Initial release: load/create + tile_at + is_walkable + current-map management.
|
||
|
||
## 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)
|
||
- ADR-0031 (pixel-convention: Y-down-positive)
|
||
- ADR-0038 (API-Doc-Convention)
|