Compare commits

...

19 Commits

Author SHA1 Message Date
Axel Meyer
6e42a034f0 chore(maps): bump to 0.5.7; README cell-tile path subsection
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 22:37:33 +02:00
Axel Meyer
6e0d39e1a7 feat(maps): save_to_disk emits cells_material conditionally
cells_material is added to the per-layer JSON only when at least
one entry is non-zero, so v0.5.6-vintage maps with no cell-tile
content write byte-identical output through the 0.5.7
serializer. Maps with cell-tile content round-trip the array
through save and reload.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 22:30:16 +02:00
Axel Meyer
5351923dd6 feat(maps): per-cell-origin bitmask branch unlocks 47-blob
compute_cell_bitmask_v3 now branches: cells where
cells_material is set use the classical 47-blob 8-neighbour rule
on effective material; vertex-only cells continue to use the
FIX-A 4-own-corner rule (v0.5.6 dual-grid). The 8-neighbour
branch evaluates each neighbour through cell_has_material so
cell-tile material and vertex-tile material both count as
neighbours.

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 at
in-layer mode boundaries is documented behaviour, not a bug.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 22:13:26 +02:00
Axel Meyer
c5039e5bf2 fix(maps): set_cell_material invalidates _dirty + _opaque_ceiling
Mirrors the cache-invalidation that set_vertex and set_cell_gid
do. Without this, a cell-tile material write would update the
storage but the opaque-ceiling cache could continue to report a
stale state until something else invalidated it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 22:04:05 +02:00
Axel Meyer
89d68d7668 feat(maps): set_cell_material / get_cell_material public APIs
Mirrors the existing set_vertex / get_vertex pattern. Bounds and
unknown-layer guards raise the same shape of error as the v3
write-API family. Lazy-allocates layer.cells_material on first
write so maps that never use cell-tile painting stay byte-clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 21:50:56 +02:00
Axel Meyer
9a5ef5e832 feat(maps): v3 cells_material field foundation
Adds the optional layer.cells_material array to the v3 schema
validator, the per-cell read helper cells_material_at, the
lazy-allocator ensure_cells_material, and extends
cell_has_material plus compute_cell_bitmask_v3 to consult it.

No public-API change yet; cell_has_material's signature gains a
cells_material parameter that all internal callers thread through.
A layer with no cells_material entry remains behaviourally
identical to v0.5.6. Public APIs and the bitmask branch land in
later commits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 21:44:23 +02:00
Axel Meyer
b5621c5d08 fix(maps): v0.5.6 — derive bitmask from cell's own corner paint-tiles
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>
2026-06-01 17:19:26 +02:00
calic
e3b6e38c5a feat(maps): v0.5.5 — cell_material_slot public getter
Exposes what the v3 vertex/material render path computes internally:
per (layer, x, y), returns {slot, rot, flip} for material cells,
nil for non-material. Override entry wins (normalised via existing
normalize_override_entry); otherwise resolves through
compute_cell_bitmask_v3 + SLOT_LOOKUP.

Primary consumer: debug-overlay tooling (map-editor 0.2.0c.3) that
labels each rendered cell with its blob-14 slot identity for
authoring verification.
2026-05-29 09:56:45 +02:00
calic
b4c0e0f4b5 docs(README): bump header 0.2.0 -> 0.5.4 + add v0.3-0.5.4 API summary
Adds schema-version table, v3 painting model (vertex grid + any-corner
rule + blob-14 SLOT_LOOKUP + override sublayer), and a per-version
summary of APIs added between 0.3.0 and 0.5.4. Original v0.2.0 entries
remain in their existing layout below the new summary.
2026-05-29 09:33:29 +02:00
calic
be6d419061 feat(maps): v0.5.4 — atlas_diffuse_handle + atlas_tile_size_px + atlas_tile_uv
Public accessors for palette-like consumers (map-editor 0.2.0c.2)
that need to render real tile thumbnails. All three return nil when
the atlas-idx is out of range, the slot has no tile in the atlas,
or the texture failed to load (headless test env).

atlas_tile_uv resolves the slot via the same `slot_NN_` regex used
internally by the v3 vertex/material render path, so callers see
the same slot -> tile_id mapping the renderer uses.
2026-05-29 01:37:38 +02:00
calic
9bd4e5a415 fix(maps): v0.5.3 — refresh atlas_by_alias after load_textures
build_map_v3 caches an alias->atlas dict (atlas_by_alias) at map-load
time, pointing at the resolved stubs from load_tilemap. load_textures
later REPLACES m.atlases[i] in-place with the populated record
(diffuse_texture_handle + per-tile UVs from the lib-asset's
tiles.atlas.json) but never updated atlas_by_alias.

Effect: the v3 vertex/material render path resolves
m.atlas_by_alias[layer.material] to the PRE-load stub, finds no
diffuse handle, and short-circuits to the MISSING_ASSET_COLOR
fallback — every material-bound cell renders solid yellow regardless
of the bitmask/slot computation.

Fix: after each m.atlases[atlas_idx] = ... write in load_textures,
also overwrite m.atlas_by_alias[atlas_alias] with the new record.
Guarded on `if m.atlas_by_alias` so v2 maps (no autotile path) are
unaffected.

Found by the editor's freshly-bound work map after 0.2.0b.1 — vagrant
was unaffected by the bug because its hand-authored map was loaded
fine on the very first frame, masking the issue.
2026-05-29 01:27:49 +02:00
calic
714ec57010 feat(maps): v0.5.2 — set_vertex / get_vertex public APIs
Vertex-grid write/read for the autotile painting path. The map-editor
0.2.0a Auto-Tile mode is the primary consumer; painting a single
vertex causes up to 4 surrounding cells to flip to material via the
any-corner rule. Coordinate-checks reject OOB writes; grid is lazily
allocated on first paint.
2026-05-28 23:51:39 +02:00
calic
d0f4d17b65 maps v0.5.1 — opaque-flag + manifest fields + override format
Three small follow-ups bundled into a single version bump:

1. Real tile.opaque consumption (cell_is_opaque_on_layer)
   v0.5.0e shipped a slot-13 heuristic for vertex-painted layers
   because the atlas-baker wasn't computing real opacity. Atlas-baker
   v0.2.0 now sets tile.opaque via alpha-analysis (all-alpha-255
   detection). Both vertex-painted and tiles[] paths now resolve the
   actual slot and read its tile.opaque from atlas metadata. Pre-
   v0.2.0 atlases without the flag report not-opaque (safe-
   conservative: extra draws, no missed cells).

2. Manifest-schema patch (reserved fields, accept-but-ignore)
   Type-checked acceptance of forthcoming manifest fields the
   validator will need before consumers can ship them in real
   manifests without breaking changes:
     map-level:    z_level (int), z_below (string)
     per-layer:    base_color (string #RRGGBB), tint_override (table),
                   collision_policy (table), traversal_modes (table),
                   foundation_mode (array)
   Future slices (lib-core.maps v0.6 multi-z-level, lib-core.actor
   movement-modes, etc.) wire the fields into actual behaviour.

3. Override format extension {slot, rot, flip} (design paper §15.1)
   Override entries now accept EITHER a bare integer slot_id (compact
   canonical-orientation form, backwards-compat with 0.5.0d) OR an
   object `{slot, rot?, flip?}` for explicit-transform placement
   (unblocks map-editor v0.2's Direct-mode Transform controls).

   New normalize_override_entry helper converts disk-form to runtime
   {slot, rot, flip}. Validator (validate_map_table_v3) type-checks
   per-entry. set_override accepts either form and auto-stores as
   compact (bare-int) when rot+flip both 0, object form otherwise —
   minimises disk diff for the common canonical case. get_override
   returns normalized form regardless of how the entry is stored.
   draw_layer + cell_is_opaque_on_layer updated to read the
   normalized form.

Closure-gate: milestone-check.sh ctest + test-all-modules GREEN.
SPOREL_CI=1 vagrant-skeleton rc=0 with 60 render_frame_ok; no
magenta-placeholders, no render-hook errors. Existing override-cells
(vagrant's 3 wall pillars) still render correctly through the new
normalize path; opaque-ceiling cache now reads real atlas data.

Consumer dep-bumps for v0.5.1 land in their own commits per repo:
sporel-lib-core.render, sporel-module-vagrant-skeleton, sporel-
module-map-editor[-test].

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 23:09:24 +02:00
calic
f8994c7ffa maps v0.5.0 — schema-v3 + autotile + override + render-opt
Five sub-slices closed in-session 2026-05-28 against the design at
sporel-meta/docs/design/2026-05-28-autotile-blob-styles-design.md.

0.5.0a — schema-v3 + auto-upgrade
  validate_map_table_v3 + build_map_v3 + serialize_map_v3 +
  upgrade_v2_to_v3 (no-op shape transformation). load() chains
  v1 -> v2 -> v3 transparent. All runtime `m.schema_version`-
  comparisons switched from `== 2`/`~= 2` to `>= 2`/`< 2` so v3
  inherits v2 behaviour for existing code paths. Per-layer optional
  fields (material, vertices, overrides) accepted by validator,
  preserved on round-trip serialize, ignored by renderer until
  0.5.0c/d wire them in.

0.5.0b — multi-layer terrain rendering validated
  Existing LAYER_ORDER_PRE/POST_ENTITIES loop already covered multi-
  layer; sub-slice validated end-to-end against a v3 map with three
  non-empty layers in vagrant-skeleton.

0.5.0c — vertex-painted autotile path
  ~150 LOC autotile section. rotate_bitmask_90cw + mirror_bitmask_h
  + apply_blob_gating helpers. SLOT_LOOKUP[bitmask] -> {slot, rot,
  flip} precomputed at module-init via D4-orbit walk over 14
  canonical patterns. cell_has_material (any-corner rule),
  compute_cell_bitmask_v3 (8-neighbour bitmask + blob-gating),
  atlas_slot_index (slot_NN_* tile-name parser, cached per atlas).
  build_map_v3 exposes atlas_by_alias for O(1) layer.material ->
  atlas lookup. draw_layer branches on layer.vertices+material:
  autotile path computes bitmask, looks up slot, draws with rot+flip
  via draw_sprite_transform. Legacy tiles[] path unchanged. cell_gid
  returns sentinel 1 for vertex-painted material cells so
  is_walkable / blocks_walk / blocks_sight stay transparent.

0.5.0d — sparse override sublayer
  Override map keyed "x:y" -> slot_id (0..13). Override implies
  cell-has-material (restores the 1-cell-isolated case the any-corner
  rule cannot express). Renders canonical slot in default orientation
  (no rot/flip), bypassing bitmask compute. Public APIs:
  set_override / clear_override / get_override with validation
  (v3-only, valid layer, in-bounds, integer 0..13). Internal helpers
  require_v3_layer + check_coords.

0.5.0e — render optimisation
  LAYER_Z + LAYER_ORDER_TOP_DOWN constants. m._layer_has_content
  precomputed in build_map_v3 (per-layer scan: tiles[] non-zero OR
  vertices painted OR overrides set); draw_map_pre/post_entities
  skip empty layers cheaply. m._opaque_ceiling per-cell topmost-
  opaque z-index, lazily built via ensure_opaque_ceiling. Opaque
  heuristic (light): vertex-painted cell opaque iff bitmask resolves
  to slot 13 OR override == slot 13. Legacy tiles[] reads
  atlas.tile.opaque (currently always nil pending atlas-baker E2
  alpha-analysis; safe-conservative default = not opaque). draw_layer
  skips cells where ceiling > current layer z. Writes nil out
  _opaque_ceiling for lazy rebuild.

manifest.lib bumped 0.4.0 -> 0.5.0. Consumer modules bumped in
their own commits (sporel-lib-core.render, sporel-module-vagrant-
skeleton, sporel-module-map-editor[-test]).

Closure-gate per sub-slice: milestone-check.sh build + ctest +
test-all-modules GREEN; SPOREL_CI=1 vagrant-skeleton smoke rc=0
with 60 render_frame_ok and no magenta-placeholders.

Detailed sub-slice closure logs live in
sporel-meta/docs/superpowers/plans/2026-05-28-map-schema-v3.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 20:48:47 +02:00
Axel Meyer
5cf07c8549 Bump lib-core.maps to v0.4.0 and document the write APIs
Three new public functions (set_cell_gid, set_roof, save_to_disk)
documented in the README alongside the existing read API. Purely
additive bump — existing consumers continue to compile and run
unchanged.
2026-05-24 00:51:09 +02:00
Axel Meyer
d80c0ecb5f Add save_to_disk plus hand-rolled JSON serializer
Reverses the internal resolved-atlas representation back to the
v2 on-disk shape (atlas_aliases as strings, only whitelisted layers
emitted, roof key omitted when nil). Hand-rolled JSON stringifier
because the engine exposes no cjson Lua binding and load_json has
no symmetric save_json counterpart. Output is byte-deterministic
(sorted object keys) and pretty-printed with 2-space indent.
2026-05-24 00:43:25 +02:00
Axel Meyer
84d1980c18 Add set_roof write API to lib-core.maps
Allocates the roof array on-demand if the map didn't have one,
validates value in {0, 1}, and bounds-checks the target cell.
2026-05-24 00:34:14 +02:00
Axel Meyer
45b1f93363 Add set_cell_gid write API to lib-core.maps
Validates layer name against VALID_LAYER_NAMES, bounds-checks the
target cell, auto-allocates the layer table if the caller writes
to a previously-empty layer. Marks the map as dirty for callers
that track in-memory mutations.
2026-05-24 00:25:06 +02:00
Axel Meyer
b8705c0ec7 Render magenta placeholder when atlas is unloaded
When a consumer renders via maps.draw_map_pre/post_entities without
having called maps.load_textures (or when asset-alias resolution
failed), the per-atlas diffuse_texture_handle is nil. The previous
draw_layer passed nil to draw_sprite_transform and crashed. Now it
falls back to engine.render.draw_rect with the classic magenta
missing-asset color so the consumer sees the gap visually instead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 00:15:00 +02:00
3 changed files with 1348 additions and 27 deletions

129
README.md
View File

@@ -1,11 +1,75 @@
# lib-core.maps
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.
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.2.0
**Version:** 0.5.7
**Lib-ID:** lib-core.maps
**Requires:** (none)
**Tags:** maps, tile-grid, walkability, tilemap
**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
@@ -20,6 +84,51 @@ graph LR
## 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`
@@ -286,6 +395,20 @@ maps.draw_map_post_entities()
**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.

1240
init.lua

File diff suppressed because it is too large Load Diff

View File

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