compute_cell_bitmask_v3 now reads the 8-bit neighbour bitmask from the render-cell's own 4 corner paint-tiles (cardinal bit set iff at least one of the 2 paint-tiles on that edge is painted; diagonal bit set iff the corner paint-tile is painted), not from the any-corner material status of the 8 neighbour render-cells. Pre-0.5.6 used cell-neighbour-material, which let two cells share connectivity across an empty paint-tile gap whenever any unrelated corner of either was painted — producing connected blob shapes where two visually separated 2x2 islands were expected. Atlas, paint storage, override sublayer, public API and the slot lookup table all unchanged. Doc comment and README painting-model section rewritten to describe the dual-grid offset explicitly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
538 lines
22 KiB
Markdown
538 lines
22 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.6
|
||
**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`.
|
||
|
||
## 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)
|