Compare commits

..

48 Commits

Author SHA1 Message Date
calic
7c9e481a6c fix(deps): cascade composition 0.4.0 + crafting 0.5.0 pins
Phase J (2026-08-03) bumped lib-core.composition 0.3.0->0.4.0 and
lib-core.crafting 0.3.0->0.5.0, but only vagrant-skeleton and
lib-core.crafting themselves were updated. Dependent manifests kept the
old pins, so dep-fetcher saw disagreeing pins on the same lib and
refused to start both spine-prototype (ENGINE_ERR_LIB_DEP_MISSING,
rc=19) and vagrant-skeleton ("dep check failed", conflict on
lib-core.composition + lib-core.crafting).

Unify all pins on the on-disk versions. No code changes required:
composition 0.4.0 is purely additive (the quality/condition loud-error
was removed; no signature changed), and crafting-display only consumes
crafting.can_craft + crafting.list_recipes, both still present, and
never referenced the `form` field retired by ADR-0055.

README "Requires:" lines synced to match the manifests (Gate 3).

milestone-check.sh: GREEN (build, ctest, smoke, devwrap-ci,
test-all-modules + anomaly scan). vagrant-skeleton headless run clean
at 60 CI frames.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 11:27:22 +02:00
calic
f1cddc3948 chore(deps): bump lib-core.render to 0.3.0 (cascade — additive sprite_scale)
No code change. v0.3.0 adds an optional sprite_scale property on
draw_entities path-1; map-editor doesn't set it, so default 1.0
preserves v0.2.0 behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-21 11:44:28 +02:00
Calic
75655be72d docs: CR-5 — README+launcher-card auf v0.4.0 (3 Paint-Modi), Requires-Block ergaenzt
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:47:57 +00:00
Calic
4b8abc8502 chore: bump lib-core.panel dep to 0.4.0
Engine resolver does exact strcmp on dep versions, so consumer manifests
must pin the new lib-core.panel 0.4.0 to keep load-time resolution green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-15 03:05:30 +02:00
Calic
176305e35b feat: v0.4.0 migrate to lib-core.panel persistent widgets
Migrate the top toolbar, right-side Layers panel, and bottom palette
strip to lib-core.panel v0.3.0 persistent widgets (W.2 of the
panel-window-manager plan):

  map-editor.toolbar -- top strip (full width, ~290 px tall)
  map-editor.layers  -- right side-panel (160 px wide)
  map-editor.palette -- bottom strip (full width, 64 px tall)

All three registered as persistent=true (open from module-init, not
dismissed by ESC), input_block='self' (hit-test claims clicks within
own bounds; misses fall through to canvas), and chromeless=true
(panel-lib skips its title-bar/border/X — widgets keep their own
visual style).

Existing draw_menu_bar / draw_side_panel / draw_palette and
handle_click logic is wrapped via thin widget render + handle_input
fns (M.draw_toolbar_widget / M.handle_toolbar_click etc.); no
behavioural change to the widget interiors.

Map-canvas remains module-rendered. Canvas drag-paint is now gated on
panel.point_in_any_panel(mx, my) so widget clicks no longer bleed
through into map paint.

Removed state.show_layers_panel / show_palette and their toggle
helpers (panel-lib manages widget visibility; Window menu items now
flip panel.is_open/toggle on the widget ids directly). Modals stay
module-rendered + module-routed (handled by handle_modal_click
before panel.update).

Manifest bumped 0.3.4 -> 0.4.0; lib-core.panel dep added at 0.3.0.
2026-06-15 02:26:20 +02:00
Calic
a150dd8dc5 chore: bump lib-core.composition dep to 0.3.0
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-14 11:57:40 +02:00
Calic
61e7d5192f chore: bump lib-core.composition dep to 0.2.0 2026-06-13 14:07:46 +02:00
Calic
451da04cbd feat: ship manifest.launcher.md for master-detail launcher 2026-06-11 07:57:58 +02:00
Calic
bde1f7acfa chore(deps): bump lib-core.render 0.1.0 to 0.2.0, declare lib-core.composition
render 0.2.0 added a transitive dep on lib-core.composition.
Engine resolver is strict-strcmp (no semver expansion), so
consumers must declare every transitive lib in deps[].
2026-06-10 23:28:52 +02:00
Calic
2734efa1a9 chore(map-editor): bump to v0.3.1; cheatsheet + README sync
Bumps the module version to v0.3.1 (lib-core.maps dep pin was
already bumped to 0.5.7 in CT6 to allow the editor to load).
Updates the file header, startup banner, cheatsheet Tab entry
(now mentions all three modes), and appends a Cell-Tile Mode
subsection to the README.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 23:10:30 +02:00
Calic
340dd5f700 test(map-editor): t11 confirms Edit-menu disable covers Cell-Tile
The existing `disabled = function() return not mode_is("direct")
end` predicate already covers Cell-Tile mode (which is also
non-direct). Self-test t11 enumerates the three transform items
across all three modes and confirms each is disabled in
auto-tile AND cell-tile, enabled only in direct.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 23:07:31 +02:00
Calic
46d1e78758 feat(map-editor): world-overlay variant for Cell-Tile mode
draw_world_overlay gains a cell-tile branch that renders a faint
render-cell grid and a 1-px hover-cell outline around the cursor
cell. The existing G-toggle continues to gate the entire overlay.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 23:05:22 +02:00
Calic
51138e257f feat(map-editor): paint actions + LMB/RMB routing for Cell-Tile
paint_cell_material_at and erase_cell_material_at wrap
maps.set_cell_material with a mark_dirty. The canvas-click
dispatch in M.handle_click and the per-frame drag handler both
route through these actions when state.mode == "cell-tile". Self-
test t10 confirms a paint round-trips through maps.get_cell_material.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 23:02:53 +02:00
Calic
6eacde3eaa feat(map-editor): mode-pill 3 segments for Auto/Cell/Direct
MODE_PILL_W widens 130 -> 195 (3 x 65). mode_pill_layout returns
the new cell segment between auto and direct; draw_mode_pill
iterates the new triple; the click-handler dispatches the cell
segment to actions.set_mode("cell-tile"). Self-test t9 confirms
the three segments sit at consecutive SEG_W offsets.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 23:00:12 +02:00
Calic
e745be8d07 feat(map-editor): cell-tile as third state.mode + Tools menu entry
state.set_mode now accepts "cell-tile"; toggle_mode is renamed
cycle_mode (with a backwards-compat alias) and now advances
auto-tile -> cell-tile -> direct -> auto-tile. Tools > Mode
submenu gains a third entry between Auto-Tile and Direct.
Self-test gains t8 (set/get round-trip) and t12 (cycle order).
Startup banner updated to "Tab=cycle mode". Manifest dep pin
bumped to lib-core.maps 0.5.7 to match installed version.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 22:56:04 +02:00
Calic
f06ef5e2cb test(map-editor): seven menu-UI assertions on first CI frame
Adds a self_test() entry-point that the editor invokes once when
SPOREL_CI=1 is set, exercising the seven §7.1 acceptance checks
from the menu-UI spec: open/close, toggle flip, modal absorb,
disabled-item no-op, mode switch, category switch, and layer
visibility submenu round-trip. Any failed engine.test assertion
exits with non-zero.

Fixes cheatsheet modal on_click to close only on inside-bounds
click (was closing on any click, breaking t3 outside-click-absorb
assertion).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 20:14:29 +02:00
Calic
398d3b4dd9 chore(map-editor): bump to v0.3.0; cheatsheet + README sync
Updates the in-editor Cheatsheet to reflect the new G binding and
the Esc cascade; bumps manifest.module and the file header to
v0.3.0; adds a Menu UI section to README.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 20:10:32 +02:00
Calic
1945e91441 refactor(map-editor): retire legacy toolbar
Removes the eight-button top toolbar, its layout helpers, click
dispatcher and all TOOLBAR_* constants. Side-panel and palette
offsets now anchor to MENU_BAR_H. Mode-pill no longer overlaps a
parallel toolbar element.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 20:07:57 +02:00
Calic
107eade2fa feat(map-editor): wire Window toggles to hide side panel + palette
Both render and click handlers are now gated by the
is_layers_panel_shown / is_palette_shown predicates, so the new
Window menu toggles produce visible effects in the editor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 20:04:55 +02:00
Calic
f2123ba79c feat(map-editor): save_as, map_properties, about modals
Three new modal entries plugged into the unified modal stack.
save_as accepts a typed filename and writes via
maps.save_to_disk; map_properties shows current map id, size and
atlas list as a read-only inspector; about is static text only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 20:02:17 +02:00
Calic
9213d3f91b feat(map-editor): submenu support — View > Layers, Tools > Mode
One level of submenu nesting. View > Layers exposes all eight
terrain-stack layers as toggles, sharing state with the right-side
Layers panel. Tools > Mode mirrors the Mode-pill's auto/direct
toggle with a Tab hotkey shown on Direct. Submenu opens by clicking
the parent item; clicking elsewhere closes everything.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 19:58:30 +02:00
Calic
211056ac8c feat(map-editor): fill all seven menu categories with initial items
Items in File / Edit / View / Map / Tools / Window / Help. New Map
and Open Map appear as disabled placeholders pending a v3-aware
maps.create and engine.asset.list_dir. Undo/Redo are disabled
pending an edit-stack. Cycle Rotation / Toggle Flip / Reset
Transform disable themselves in Auto-Tile mode via a mode_is()
callback. Submenus for View > Layers and Tools > Mode are
declared but their items array stays nil until the submenu render
slice.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 19:55:06 +02:00
Calic
b5afe35df8 feat(map-editor): dropdown click dispatch
Routes clicks on dropdown items to their per-type handler:
action.fire, open_modal for modal items, toggle.fire for toggles.
Disabled items absorb without closing; separators are inert.
Clicks outside the open dropdown close the menu and fall through to
canvas handling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 19:51:58 +02:00
Calic
2c3015685c feat(map-editor): dropdown render for action/modal/toggle/separator
Renders the open dropdown for the active menu category with a
three-column item layout (indicator | label | hotkey). Toggle items
show their bound is_on() state with a [v]/[ ] indicator, modal
items append "...", separators draw as thin rules, disabled items
render in a muted colour. Submenu chevron is wired but submenus
themselves land in a later slice.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 19:49:53 +02:00
Calic
2eebd3d2d5 feat(map-editor): menu-bar top-level labels render + click
Adds the seven category labels (File, Edit, View, Map, Tools,
Window, Help) at the top of the screen with hover highlight and
click-to-open / click-to-close behaviour. Item arrays are still
empty stubs; dropdown rendering lands in the next slice.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 19:47:11 +02:00
Calic
1a9daa8ce1 feat(map-editor): Mode-pill render + click handler
Sticky two-segment pill anchored to the menu bar's right edge.
Click on a segment calls actions.set_mode; Tab hotkey continues to
toggle and the pill reflects the new state next frame. Pill
visually overlaps the legacy toolbar in this slice; the toolbar is
removed in a later commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 19:43:39 +02:00
Calic
6956919414 feat(map-editor): Esc cascades modal -> menu -> quit
Esc no longer immediately exits the editor; it first dismisses any
open modal, then any open dropdown menu, and only falls through to
engine.exit() when no overlay is up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 19:41:51 +02:00
Calic
15159d7fd5 refactor(map-editor): generalize cheatsheet into MODALS framework
Introduces a single-stack modal table where each entry declares
title, dimensions, render fn and on_click fn. The existing
Cheatsheet becomes the framework's first entry; behaviour is
preserved exactly. Dispatcher absorbs clicks while any modal is
open and routes them to the modal's on_click for close-decision.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 19:38:42 +02:00
Calic
e373aa8a1f refactor(map-editor): unify modal/menu state, drop cheatsheet_open
Adds menu_open, modal_open, show_layers_panel, show_palette to the
editor's state singleton; removes the now-redundant cheatsheet_open
flag. All cheatsheet-toggle paths route through the new generic
modal_open API. Visible behaviour unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 19:31:00 +02:00
Calic
1bf5f46924 feat(map-editor): layout constants for menu-bar UI
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 19:26:17 +02:00
Calic
5a3f633ab2 feat(map-editor): v0.2.0c.4 — G-toggle mode-aware world overlay
Adds a G key that toggles the world-space overlay on and off
(default on). The overlay adapts to the active painting mode:

- Auto-Tile mode shows the map-grid (paint-tile boundaries, which
  sit at half-tile offsets from the render-cell boundaries in
  dual-grid), the per-map-tile vertex dots, and the snapped
  paint-target highlight.
- Direct mode shows only a subtle render-cell grid, since LMB
  strokes write directly to render-cells in that mode.

Bumps the lib-core.maps dep pin to 0.5.6 to pick up the dual-grid
bitmask fix that makes the auto-tile output match the dual-grid
mental model.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 17:19:30 +02:00
calic
7df21d62fa chore(gitignore): ignore local screenshots/ from debug-ipc 2026-05-29 11:47:06 +02:00
calic
cf5771a573 feat(map-editor): v0.2.0c.3 — debug overlay (D toggle, per-cell slot/rot)
Diagnostic for the dual-grid + blob-14 painting model: D-key toggles
a per-cell text annotation rendered over the map. For each material
cell on the active layer, draws "S<slot>r<rot>" (and "f" when flip=1)
in green at the cell's top-left, sourced from the new lib-core.maps
0.5.5 cell_material_slot getter.

Use case: confirm that the renderer actually picks distinct slots
(corner_full / tee_full / cross / etc.) for painted regions rather
than always picking slot 13 solid — the rectilinear style fills enough
of each cell that visually adjacent slots 3/7/13 are hard to
distinguish without numerical labels.

Inline today; plan-stub
`docs/superpowers/plans/2026-05-29-debug-overlay-extract-to-lib.md`
deferred until a 2nd consumer (vagrant debug-render, puppet-editor,
etc.) appears.

Also bumps lib-core.maps dep 0.5.4 -> 0.5.5.
2026-05-29 09:56:52 +02:00
calic
c66e670336 docs(README): rewrite for v0.2 Rev 4 UI + drag-paint 2026-05-29 09:33:26 +02:00
calic
70b841c8d6 feat(map-editor): v0.2.0c.2 — palette uses real tile thumbnails
Replaces the placeholder colour swatches with actual atlas sub-tex
draws via the new lib-core.maps 0.5.4 getters:
 - atlas_diffuse_handle(atlas_idx) -> raylib Texture2D handle
 - atlas_tile_uv(atlas_idx, slot)  -> {x, y, w, h} or nil

Renders each slot 1..14 (lib slot 0..13) scaled to fit the swatch
rect with draw_sprite_transform. Falls back to the legacy colour
square + slot-number label when the atlas isn't loaded (headless,
material missing, etc.).

Drives off state.get_active_atlas() (defaults to 0 — fine for the
single-atlas work map; multi-atlas selector is a future slice).

Also bumps lib-core.maps dep 0.5.3 -> 0.5.4.
2026-05-29 01:37:45 +02:00
calic
c0a1bcf162 chore(deps): bump lib-core.maps 0.5.2 -> 0.5.3 2026-05-29 01:29:20 +02:00
calic
77832c07b2 feat(map-editor): v0.2.0c.1 — drag-paint strokes
LMB-hold strokes a line of paints (Auto-Tile: each hovered vertex;
Direct: each hovered cell as an override). RMB-hold strokes a line
of erases (clear_vertex / clear_override). On mouse-press inside the
UI (toolbar / palette / side-panel) the drag never enters paint-mode
so toolbar interaction stays unaffected.

State machine:
 - press(button) outside UI -> drag_paint = "paint"|"erase"
 - while drag_paint and corresponding button still down -> dispatch
   paint at current snap_vertex / mouse_cell each frame
 - button release -> drag_paint = nil

Idempotency: maps.set_vertex / set_override / clear_* are no-op when
the target state is unchanged, so the per-frame repaint is cheap and
doesn't grow the JSON on save.
2026-05-29 01:05:39 +02:00
calic
30f38d771f feat(map-editor): v0.2.0c — transform controls + Direct-mode override paint
State + actions:
 - active_flip (0/1) joins active_rot (0..3). Combined the two cover
   all 8 D4 orientations; single Flip button is sufficient (H+V
   would be redundant).
 - toggle_flip / reset_transform / paint_override_at /
   erase_override_at.

Toolbar:
 - New Flip (H) + Reset (0) buttons next to Rotate (R).
 - Status chip extended with `slot=N rot=N flip=N` indicator.

Paint:
 - Direct-mode + LMB on canvas now calls maps.set_override on the
   active material-bound terrain layer. Bare slot int when canonical
   (rot=0+flip=0), {slot, rot, flip} object otherwise — matches the
   v0.5.1 override-format extension. Palette slot 1..14 maps to lib
   slot 0..13 (palette is user-friendly 1-based; lib API is 0-based).
 - Direct-mode + RMB on canvas calls maps.clear_override.
 - Auto-Tile mode unchanged; transform state is ignored there (the
   renderer derives slot/rot/flip from the vertex bitmask).

Hotkeys:
 - h -> flip (new)
 - 0 -> reset_transform (new)
 - r continues to cycle rotation CW; Shift+R for CCW is out because
   lib-core.input has no modifier-combo support (deferred).

Cheatsheet rows updated.
2026-05-29 00:46:08 +02:00
calic
497c139727 fix(map-editor): v0.2.0b.1 — bind blob_rect_stone material so Auto-Tile renders
work.map.json was still schema-v2 with the legacy fa_terrain_v1
atlas — neither has a `material` field on its layers, so the
renderer's vertex/blob path never fired and Auto-Tile painting had
no visible effect (the set_vertex writes landed but the renderer
fell back to the cell-tile path that doesn't consume them).

Migration:
 - schema_version 2 -> 3
 - size 4x4 -> 16x16 (room to paint)
 - atlases: fa_terrain_v1 -> blob_rect_stone (S-V2E2-RM-Blob 14-slot
   atlas from lib-asset.prototype-blob-geom)
 - layers: surface + wall, both with "material": "blob_rect_stone";
   vertices+overrides start empty (lib-core.maps lazy-allocates on
   first set_vertex)

Manifest:
 - asset_aliases: fa_terrain_v1 -> blob_rect_stone (lib-asset.
   prototype-blob-geom)
 - deps: drop prototype-fa-starter, add prototype-blob-geom 0.1.0
2026-05-29 00:34:12 +02:00
calic
25e838c54f feat(map-editor): v0.2.0b — bottom palette + Auto-Tile vertex paint
UI:
 - Bottom palette strip (above status, left of side-panel): 14
   coloured swatches numbered 1-14. Click selects state.active_tile.
 - World-space overlay (Auto-Tile mode only): cell grid + vertex
   dots + snapped-vertex highlight ring.
 - Side-panel + status repositioned to clear the palette band.
 - Cheatsheet updated with the new LMB/RMB canvas + palette rows.

Paint:
 - Auto-Tile mode: LMB on canvas -> maps.set_vertex(active_layer,
   snap.vx, snap.vy, true); RMB -> set_vertex(..., false). Snap is
   computed in update() by rounding world coords to the nearest
   (W+1)x(H+1) vertex; off-map cursor publishes nil.
 - Direct mode: unchanged from 0.2.0a (paint_cell_at with active
   slot + rotation).

Notes:
 - Palette swatches are placeholder solid colours, not real tile
   thumbnails — that needs a tile-UV getter on lib-core.maps, which
   is out of scope for this sub-slice.
 - Transform controls, Direct-Override transform writes, and the
   Decal/Entity sub-selectors land in 0.2.0c/d.
2026-05-29 00:03:42 +02:00
calic
35d9940d63 feat(map-editor): v0.2.0a — Rev 4 layout reorg + mode switch + cheatsheet
Layout (per design paper §11):
 - Top toolbar with Mode buttons (Auto-Tile / Direct), Save, Erase,
   Rotate, Help.
 - Right Layers side-panel (8 rows, top-down draw order) as single
   source of truth for visibility + active layer — replaces the old
   left-edge layer chips.
 - Bottom status bar.

State + actions:
 - mode = "auto-tile" | "direct" (Tab toggles).
 - cheatsheet_open modal toggle (I key, dimmed overlay + key/action
   table).
 - SPOREL_CI=1 auto-exits after 30 frames (matches vagrant pattern;
   ci_frames in manifest is not engine-enforced for kind:"module").

Paint behaviour itself is unchanged from 0.1.0 — bottom palette + the
real Auto-Tile / Direct paint loops land in 0.2.0b/c/d.

deps: lib-core.maps 0.5.1 -> 0.5.2 (consumes set_vertex / get_vertex).
2026-05-28 23:56:02 +02:00
calic
8dbb5b9d9c deps: bump lib-core.maps 0.5.0 -> 0.5.1
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 23:11:09 +02:00
calic
34f8b85d57 deps: bump lib-core.maps 0.4.0 -> 0.5.0
Required by the schema-v3 work in lib-core.maps. The map-editor
v0.1 stays on the legacy tiles[] path and continues to function
unchanged through the auto-upgrade v1->v2->v3 chain on load.

Full migration to v3's vertex-painting + override-UI lands in the
map-editor v0.2 slice
(sporel-meta/docs/superpowers/plans/2026-05-28-map-editor-blob-v2.md).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 20:50:21 +02:00
Calic
8f5e59a9f7 Drop stale engine-binding-missing NOTE comments
The draw_rect_lines and draw_line bindings exist now; the inline
NOTE comments warning about missing bindings are stale.
2026-05-24 03:21:19 +02:00
Calic
e87948a3ca Wrap map draw in camera.begin/finish so world-space transforms apply
Without the camera scope, maps.draw_map() rendered the map at
screen coords starting at (0,0) regardless of camera.set_target,
and screen_to_world's inverse produced bogus cell coords (mouse
in screen-center mapped to top-left tile). Spine-prototype's
render() shows the right pattern: world-space draws inside
camera.begin/finish, screen-space overlays after.
2026-05-24 03:17:16 +02:00
Calic
2e25833a93 Rename draw() hook to render() so the engine actually calls it
The engine module-lifecycle looks up HOOK_RENDER by the global
name 'render' (engine_module_lifecycle.c:166). The previous draw()
function was a global with no caller, so the editor's UI never
rendered — the engine fell back to its 'no render hook defined'
placeholder.
2026-05-24 03:12:27 +02:00
Calic
b5fee40406 Match module id to directory name convention
Other module manifests (spine-prototype, vagrant-skeleton, etc.) use
the short directory name as the id, not the long 'sporel-module-X'
form which is reserved for the Gitea slug. The mismatch caused the
launcher script (which exports SPOREL_MODULE=map-editor) to fail
with ENGINE_ERR_MODULE_NOT_FOUND.
2026-05-24 03:08:31 +02:00
Calic
66997be86b Inline state/actions/ui into init.lua
The engine's Lua sandbox rejects require() for non-declared-dep
module names like 'src.state'. Multi-file modules in this codebase
either inline everything (spine-prototype, vagrant-skeleton) or
use dofile (lib-sporel.launcher). For three modules that share a
state singleton, dofile would cause state divergence. Cleanest
fix is to inline all three into init.lua via IIFE-wrapped local
module tables, preserving the file-internal namespacing while
satisfying the sandbox.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 03:08:03 +02:00
9 changed files with 2136 additions and 563 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
# Screenshots from debug-ipc screenshot.take method (written to <cwd>/screenshots/)
/screenshots/

181
README.md
View File

@@ -1,10 +1,24 @@
# sporel-module-map-editor # sporel-module-map-editor
Interactive map editor for v2 Sporel map files. Click-to-paint, vertical layer-chip overlay on the left edge, collapsible atlas+tile picker bottom-left. Interactive map editor for Sporel `.map.json` files. Aufsetzend auf der
Rev-4-UX-Architektur (siehe Design-Paper
`design/2026-05-28-autotile-blob-styles-design.md` §11 in `sporel-meta`):
Top-Toolbar mit Mode-Switch + Actions, rechtes Layers-Side-Panel, untere
Palette-Strip mit Material-Slot-Thumbnails, Maus-Drag-Paint und drei
Paint-Modi (Auto-Tile / Direct / Cell-Tile, mit Tab umschaltbar). Die
v0.3.0- bis v0.4.0-Iterationen sind eingeflossen: v0.3.0 ersetzte die
Toolbar durch eine Menu-Bar, v0.3.1 ergänzte den Cell-Tile-Modus, und
v0.4.0 migrierte Toolbar, Layers-Panel und Palette auf
`lib-core.panel`-Persistent-Widgets (Details siehe unten).
**Version:** 0.4.0
**Module-ID:** map-editor
**Requires:** lib-core.maps v0.5.7, lib-core.render v0.2.0, lib-core.composition v0.4.0, lib-core.camera v0.3.0, lib-core.input v0.4.0, lib-core.panel v0.4.0, lib-asset.prototype-blob-geom v0.1.0
## Run ## Run
Default — edit the module's in-place work map: Default — edit the module's in-place work map (`maps/work.map.json`,
a 16×16 schema-v3 map bound to the `blob_rect_stone` material):
```bash ```bash
bash scripts/run-map-editor.sh bash scripts/run-map-editor.sh
@@ -16,31 +30,162 @@ With a target — copy a real map in, edit, copy back out:
bash scripts/run-map-editor.sh ~/Projects/Sporel/sporel-modules/spine-prototype/maps/demo.map.json bash scripts/run-map-editor.sh ~/Projects/Sporel/sporel-modules/spine-prototype/maps/demo.map.json
``` ```
Or via the install layer (per `install-reference.sh`):
```text
Doubleclick C:/Games/Sporel/bin/Sporel.exe → launcher → map-editor
```
## UI ## UI
- **Layer chips** (left edge, vertical): top-of-stack down. `Rf` at top toggles roof-edit mode; the other eight chips select the active layer. - **Top toolbar** (full-width): `Auto-Tile`/`Direct` mode buttons (mutually
- **Left-click** = set active layer (or toggle roof mode) exclusive, Tab toggles), `Save` (S), `Erase` (E), `Rotate` (R),
- **Right-click** = toggle layer visibility (Rf chip ignores right-click) `Flip` (H), `Reset` (0), `Help` (I). Each button shows its hotkey
- **Atlas + tile picker** (bottom-left): collapsed chip shows the active tile. Click to expand; click again or outside to collapse. label inline. The active mode is highlighted.
- **Status chip** (bottom-right): current mouse-cell coordinate plus a `*` when the map has unsaved changes. - **Right Layers side-panel** (8 rows top-down: `canopy`, `upper_wall`,
`wall`, `lower_wall`, `topsurface`, `surface`, `subsurface`,
`foundation`). Per row: visibility eye + active marker + layer name.
- **Left-click name** → set active layer
- **Left-click eye** → toggle layer visibility
- **Right-click row** → also toggles visibility
- **Bottom palette strip**: 14 swatches (slots 1-14). Real tile
thumbnails rendered from the active atlas via
`maps.atlas_tile_uv`. Active slot has a gold border.
- **Left-click** → set active slot
- **Status chip** (bottom-left of palette band): mouse-cell, dirty flag
(`*` when unsaved), and current transform indicator
(`slot=N rot=N flip=N`) for Direct-mode awareness.
- **Auto-Tile-mode world overlay**: cell grid (1 px), vertex dots (4 px
squares), and a snapped-vertex highlight ring (green) at the nearest
vertex to the cursor.
- **Cheatsheet modal** (I): centered dim-overlay listing all hotkeys
+ LMB/RMB drag semantics.
## Modes
| Mode | LMB on canvas | RMB on canvas | Notes |
|---|---|---|---|
| **Auto-Tile** | Paints vertex at snap target — `maps.set_vertex(layer, vx, vy, true)`. Any-corner rule flips up to 4 surrounding cells to material; renderer derives slot/rot/flip from neighbour bitmask. | Clears vertex — `set_vertex(..., false)` | Transform controls ignored (slot is derived from neighbours). |
| **Direct** | Writes per-cell override — `maps.set_override(layer, x, y, {slot, rot, flip})`. Bare slot int when canonical (rot=0+flip=0). | Clears override — `maps.clear_override(layer, x, y)` | Honours active Rot/Flip from toolbar. Use to force-place a specific orientation that the auto-derived slot wouldn't pick. |
Both modes support **drag-paint**: hold LMB (or RMB) and stroke across
the canvas to apply the action at every hovered vertex/cell. UI hits
on the toolbar / side-panel / palette never enter drag-mode.
## Hotkeys ## Hotkeys
| Key | Action | | Key | Action |
|---|---| |---|---|
| `Left click` on map | Paint cell with active tile (or set roof flag in roof-mode) | | `Tab` | Toggle mode (Auto-Tile ↔ Direct) |
| `E` | Erase the cell under the mouse | | `I` | Show / hide cheatsheet |
| `R` | Cycle rotation 0 → 1 → 2 → 3 → 0 | | `S` | Save current map to `work.map.json` |
| `S` | Save to disk (overwrites the work map) | | `E` | Erase tile at mouse cell |
| `R` | Cycle rotation 0 → 1 → 2 → 3 → 0 (Direct-mode override) |
| `H` | Toggle flip 0 ↔ 1 (Direct-mode override) |
| `0` | Reset transform (rot=0, flip=0) |
| `Esc` | Quit | | `Esc` | Quit |
| `LMB-drag` | Stroke-paint (mode-dependent) |
| `RMB-drag` | Stroke-clear |
## Limitations (v0.1.0) ## Headless / smoke
- Tile thumbnails in the picker render as gray placeholders with tile-IDs as text — lib-core.maps v0.4.0 does not expose per-tile UVs to Lua, so visual thumbnails are a v0.2.0 task. `ci_frames = 30` in `manifest.module`. The editor reads
- Atlas list in the picker is purely informational; if a map declares multiple atlases, the active atlas is set from `state.lua` defaults at startup and cannot yet be switched via UI (v0.2.0 hit-tests inside the expanded picker). `SPOREL_CI=1` and auto-exits after 30 frames so the smoke harness
- Engine bindings `engine.render.draw_rect_lines` and `engine.render.draw_line` are not yet registered — active-chip borders, hidden-layer strikethroughs, and the entity-slot divider will trigger runtime errors when their code paths execute. Adding the bindings is a small engine-side follow-up. can run it without a window-close click.
- No undo/redo, no multi-tile brush, no entity placement, no heightmap editing — see the design spec §1 for the full out-of-scope list.
## Spec ```bash
SPOREL_CI=1 ./Sporel.exe --module=map-editor # exits with rc=0 after ~0.5 s
```
`docs/superpowers/specs/2026-05-23-sporel-module-map-editor-design.md` in the `sporel-meta` repo. ## Limitations (current scope)
- **Single-atlas palette**: the palette draws from `atlas_idx=0` only.
Multi-atlas selector is a future slice. Workaround: keep one material
atlas per map for now.
- **Single Flip flag**: design paper mentions Flip-H + Flip-V as
separate controls, but the override format only stores one `flip`
field; combined with `rot` (0-3) the single flip covers all 8 D4
orientations, so the second button would be redundant.
- **No modifier hotkeys**: `lib-core.input` doesn't expose modifier
combos, so Shift+R for CCW rotation isn't supported. Workaround:
press R three times.
- **No Decal / Entity sub-selectors**: Direct mode on non-terrain
layers is unimplemented (lands in `0.2.0d`).
- **No Material-Properties modal**: `base_color` re-bake trigger
+ tint editor are deferred (also `0.2.0d`).
- **No multi-vertex brush sizes / procedural fill / undo-redo** — see
design paper §11 "Out of scope" for the full list.
## Plan + spec
- Design paper: `docs/design/2026-05-28-autotile-blob-styles-design.md`
§11 in `sporel-meta`
- Plan: `docs/superpowers/plans/2026-05-28-map-editor-blob-v2.md`
## Menu UI (v0.3.0)
Top toolbar replaced with a seven-category menu bar (File, Edit, View,
Map, Tools, Window, Help) plus a sticky two-segment Mode-pill on the
right end. Item types: action, modal, toggle, submenu, separator;
items can be conditionally disabled. A single-stack modal framework
hosts the existing Cheatsheet (I) and three new modals: Save As, Map
Properties (read-only), About. The Esc key cascades modal -> menu ->
quit.
New Map and Open Map appear in the File menu as disabled placeholders
pending a v3-aware maps.create and an engine.asset.list_dir in a
future engine + lib slice.
All previous hotkeys (Tab, S, E, R, H, 0, D, G, I, Esc) continue to
work; their bindings are documented in the items where applicable
and in the in-editor Cheatsheet modal.
## Cell-Tile Mode (v0.3.1)
Third painting mechanic alongside Auto-Tile and Direct. LMB paints a
per-cell material flag at the cursor cell; RMB clears it. The
renderer uses the classical 47-blob 8-neighbour bitmask for
cell-tile cells, unlocking the 9 atlas slots that Auto-Tile's
dual-grid 4-corner rule structurally cannot reach (isolated, end,
corner_open, straight, tee_open, tee_half, cross_open, cross_q1,
cross_q2adj).
Auto-Tile (vertex-paint, 5 reachable slots, dual-grid look) and
Direct (manual override placement, all 14 slots via explicit pick)
are unchanged. Tab cycles through all three modes.
For clean visuals, use a single painting mode per layer. Mixing
Vertex-Tile and Cell-Tile material in the same layer is allowed but
produces an asymmetric seam at the boundary because vertex-tile
cells are structurally blind to their cell-tile neighbours.
See `sporel-meta/docs/superpowers/specs/2026-06-01-map-editor-cell-tile-mode-design.md`
for the full design rationale and the Boris-taxonomy background.
## Panel-Lib Migration (v0.4.0)
The top toolbar, right-side Layers panel, and bottom palette strip
have been migrated to `lib-core.panel` v0.3.0 persistent widgets
(W.2 of the panel-window-manager plan):
| Widget ID | Layout slot | Role |
|-----------|-------------|------|
| `map-editor.toolbar` | top strip (full width, 290 px) | Menu bar + mode-pill + open-dropdown |
| `map-editor.layers` | right side-panel (160 px wide) | Layer rows + roof toggle |
| `map-editor.palette` | bottom strip (full width, 64 px) | Tile-thumbnail swatches |
All three are registered as `persistent=true` (open from module-init,
not dismissed by ESC), `input_block="self"` (hit-test claims clicks
within own bounds; misses fall through to canvas), and `chromeless=true`
(panel-lib skips its title-bar / border / close-X — the widgets paint
their own existing style). The map-canvas is NOT a panel widget — it
remains module-rendered between `camera.begin/finish`. Canvas drag-paint
is gated on `panel.point_in_any_panel(mx, my)` so widget clicks no
longer bleed through into the underlying map cells.
The Window menu's `Layers Panel` / `Palette` toggles now flip the
persistent widgets' open-state via `panel.toggle(id)` directly.
Modals (cheatsheet, save-as, map-properties, about) and the bottom-left
status chip stay module-rendered (drawn AFTER `panel.render()` so they
appear on top of widget panels).

1960
init.lua

File diff suppressed because it is too large Load Diff

21
manifest.launcher.md Normal file
View File

@@ -0,0 +1,21 @@
---
{"summary":"Map editor — menu-bar toolbar, three paint modes, Layers-Panel + Palette","author":"calic","tags":["tool","map-editor"],"teaser_images":[]}
---
# Map Editor
Click-to-paint tile editor with a top menu-bar (File / Edit / View / Map / Tools / Window / Help) plus Mode-pill, a right-side Layers-Panel and a bottom Palette-Strip with atlas-tile thumbnails. Three paint modes — Auto-Tile, Direct and Cell-Tile — cycle with Tab.
## Controls
- Mouse-left on canvas: paint (mode-dependent)
- Mouse-right on canvas: clear/erase
- Mouse-left on layer row: switch active layer
- Mouse-right on layer row: toggle visibility
- Mouse-left on palette swatch: select active slot
- Tab: cycle paint mode (Auto-Tile / Direct / Cell-Tile)
- E: erase tile at cursor
- R: rotate · H: flip · 0: reset transform
- S: save
- I: cheatsheet
- ESC: close modal / menu, else quit to launcher

View File

@@ -1,18 +1,20 @@
{ {
"id": "sporel-module-map-editor", "id": "map-editor",
"version": "0.1.0", "version": "0.4.0",
"kind": "module", "kind": "module",
"api": "^0.1", "api": "^0.1",
"ci_frames": 30, "ci_frames": 30,
"entry_point": "init.lua", "entry_point": "init.lua",
"asset_aliases": { "asset_aliases": {
"fa_terrain_v1": "lib-asset.prototype-fa-starter" "blob_rect_stone": "lib-asset.prototype-blob-geom"
}, },
"deps": [ "deps": [
{"id":"lib-core.maps","version":"0.4.0"}, {"id":"lib-core.maps","version":"0.5.7"},
{"id":"lib-core.render","version":"0.1.0"}, {"id":"lib-core.render","version":"0.3.0"},
{"id":"lib-core.composition","version":"0.4.0"},
{"id":"lib-core.camera","version":"0.3.0"}, {"id":"lib-core.camera","version":"0.3.0"},
{"id":"lib-core.input","version":"0.4.0"}, {"id":"lib-core.input","version":"0.4.0"},
{"id":"lib-asset.prototype-fa-starter","version":"0.2.0"} {"id":"lib-core.panel","version":"0.4.0"},
{"id":"lib-asset.prototype-blob-geom","version":"0.1.0"}
] ]
} }

View File

@@ -1,11 +1,10 @@
{ {
"schema_version": 2, "schema_version": 3,
"id": "work", "id": "work",
"size": { "w": 4, "h": 4 }, "size": { "w": 16, "h": 16 },
"atlases": ["fa_terrain_v1"], "atlases": [ "blob_rect_stone" ],
"layers": { "layers": {
"surface": { "surface": { "material": "blob_rect_stone" },
"tiles": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] "wall": { "material": "blob_rect_stone" }
} }
}
} }

View File

@@ -1,86 +0,0 @@
-- src/actions.lua
-- High-level edit operations. Combines state mutations with lib-core.maps writes.
local maps = require("lib-core.maps")
local state = require("src.state")
local M = {}
-- =====================================================================
-- Cell painting
-- =====================================================================
function M.paint_cell_at(cell_x, cell_y)
if state.is_roof_mode() then
maps.set_roof(cell_x, cell_y, 1)
else
local gid = maps.encode_gid(
state.get_active_atlas(),
state.get_active_tile(),
state.get_active_rot()
)
maps.set_cell_gid(state.get_active_layer(), cell_x, cell_y, gid)
end
state.mark_dirty()
end
function M.erase_cell_at(cell_x, cell_y)
if state.is_roof_mode() then
maps.set_roof(cell_x, cell_y, 0)
else
maps.set_cell_gid(state.get_active_layer(), cell_x, cell_y, 0)
end
state.mark_dirty()
end
-- =====================================================================
-- Hotkey-driven actions
-- =====================================================================
function M.cycle_rotation()
state.cycle_rotation()
end
function M.save()
local id = state.get_map_id()
local path = state.get_map_path()
if not id or not path then
engine.print("map-editor: cannot save — no map loaded")
return
end
maps.save_to_disk(id, path)
state.mark_clean()
engine.print(string.format("map-editor: saved '%s' to %s", id, path))
end
-- =====================================================================
-- UI-driven actions (called by ui.lua hit-tests)
-- =====================================================================
function M.set_active_layer(layer_name)
state.set_active_layer(layer_name)
end
function M.toggle_layer_visible(layer_name)
state.toggle_layer_visible(layer_name)
end
function M.toggle_roof_mode()
state.toggle_roof_mode()
end
function M.set_active_atlas(atlas_idx)
state.set_active_atlas(atlas_idx)
-- Reset to first tile of the new atlas
state.set_active_tile(1)
end
function M.set_active_tile(tile_id)
state.set_active_tile(tile_id)
end
function M.toggle_picker()
state.toggle_picker_open()
end
return M

View File

@@ -1,96 +0,0 @@
-- src/state.lua
-- Editor state singleton + accessors.
local M = {}
-- =====================================================================
-- State (module-singleton)
-- =====================================================================
local state = {
map_id = nil, -- set by init.lua after maps.load()
map_path = "maps/work.map.json",
active_layer = "surface", -- one of VALID_LAYER_NAMES
active_atlas = 0, -- atlas_index 0..N-1
active_tile = 1, -- tile_id
active_rot = 0, -- 0..3
roof_mode = false, -- if true, paint targets roof
layer_visible = {
foundation=true, subsurface=true, surface=true, topsurface=true,
lower_wall=true, wall=true, upper_wall=true, canopy=true,
},
dirty = false,
picker_open = false, -- atlas+tile picker expanded?
mouse_cell = nil, -- {x, y} or nil if mouse off-map
}
-- =====================================================================
-- Setters / getters (encapsulate state for testability)
-- =====================================================================
function M.set_map(map_id, path)
state.map_id = map_id
state.map_path = path
end
function M.get_map_id() return state.map_id end
function M.get_map_path() return state.map_path end
function M.set_active_layer(name) state.active_layer = name end
function M.get_active_layer() return state.active_layer end
function M.set_active_atlas(idx) state.active_atlas = idx end
function M.get_active_atlas() return state.active_atlas end
function M.set_active_tile(id) state.active_tile = id end
function M.get_active_tile() return state.active_tile end
function M.cycle_rotation()
state.active_rot = (state.active_rot + 1) % 4
end
function M.get_active_rot() return state.active_rot end
function M.toggle_roof_mode()
state.roof_mode = not state.roof_mode
end
function M.is_roof_mode() return state.roof_mode end
function M.toggle_layer_visible(name)
state.layer_visible[name] = not state.layer_visible[name]
end
function M.is_layer_visible(name) return state.layer_visible[name] ~= false end
function M.mark_dirty() state.dirty = true end
function M.mark_clean() state.dirty = false end
function M.is_dirty() return state.dirty end
function M.toggle_picker_open()
state.picker_open = not state.picker_open
end
function M.is_picker_open() return state.picker_open end
function M.set_mouse_cell(x, y)
if x == nil then
state.mouse_cell = nil
else
state.mouse_cell = { x = x, y = y }
end
end
function M.get_mouse_cell() return state.mouse_cell end
-- Used by tests to reset between asserts
function M.reset()
state.map_id = nil
state.active_layer = "surface"
state.active_atlas = 0
state.active_tile = 1
state.active_rot = 0
state.roof_mode = false
state.dirty = false
state.picker_open = false
state.mouse_cell = nil
for name in pairs(state.layer_visible) do
state.layer_visible[name] = true
end
end
return M

View File

@@ -1,324 +0,0 @@
-- src/ui.lua
-- Immediate-mode overlay UI: layer-chips, atlas+tile picker, status-chip.
-- Renders + hit-tests per frame. No retained widget state — read straight
-- from state.lua on every render and dispatch via actions.lua.
local maps = require("lib-core.maps")
local state = require("src.state")
local actions = require("src.actions")
local M = {}
-- =====================================================================
-- Constants
-- =====================================================================
local CHIP_SIZE = 32 -- layer chip W/H in pixels
local CHIP_MARGIN = 4 -- gap between chips
local CHIP_ALPHA_ACTIVE = 0xFF
local CHIP_ALPHA_INACTIVE = 0x78 -- 120/255
local CHIP_ALPHA_HOVER = 0xC8 -- 200/255
local PICKER_COLLAPSED_W = 80
local PICKER_COLLAPSED_H = 80
local PICKER_EXPANDED_W = 320
local PICKER_EXPANDED_H = 240
local PICKER_MARGIN = 8
local TILE_THUMB_SIZE = 48
local STATUS_W = 80
local STATUS_H = 24
local STATUS_MARGIN = 8
-- Layer order (top of screen = top of z-stack). Roof above with divider.
local LAYER_CHIPS = {
{ name="roof_toggle", label="Rf", is_roof=true },
{ name="canopy", label="Ca" },
{ name="upper_wall", label="Uw" },
{ name="wall", label="Wa" },
{ name="lower_wall", label="Lw" },
-- entity-slot divider rendered between Lw and Ts
{ name="topsurface", label="Ts" },
{ name="surface", label="Sf" },
{ name="subsurface", label="Sb" },
{ name="foundation", label="Fo" },
}
local COLOR_CHIP_BG = 0x202020 -- RGB (alpha added at draw)
local COLOR_CHIP_BG_ACT = 0xE0E0E0
local COLOR_CHIP_TEXT = 0xFFFFFF
local COLOR_CHIP_TEXT_ACT = 0x202020
local COLOR_CHIP_BORDER = 0xFFFFFF
local COLOR_DIVIDER = 0xFFFFFF
local COLOR_STRIKE = 0xFF4040 -- hidden-layer overlay line
local COLOR_PICKER_BG = 0x303030
local COLOR_STATUS_BG = 0x303030
local COLOR_STATUS_TEXT = 0xFFFFFF
-- =====================================================================
-- Helpers
-- =====================================================================
local function rgba(rgb, alpha)
return (rgb << 8) | (alpha & 0xFF)
end
local function in_rect(mx, my, x, y, w, h)
return mx >= x and mx < x + w and my >= y and my < y + h
end
-- Layer-chip layout: vertical stack on the left edge, starting at y_top.
-- Returns: array of { name, label, is_roof, x, y, w, h, is_divider_after? }
local function chip_layout()
local out = {}
local x = CHIP_MARGIN
local y = CHIP_MARGIN
for i, chip in ipairs(LAYER_CHIPS) do
local entry = {
name=chip.name, label=chip.label, is_roof=chip.is_roof,
x=x, y=y, w=CHIP_SIZE, h=CHIP_SIZE,
}
-- Roof-Toggle gets a thicker gap below it
if chip.is_roof then
entry.gap_after = CHIP_SIZE / 2
elseif chip.name == "lower_wall" then
entry.gap_after = CHIP_SIZE / 2 -- entity-slot divider
entry.divider_after = true
else
entry.gap_after = CHIP_MARGIN
end
out[i] = entry
y = y + entry.h + entry.gap_after
end
return out
end
-- Picker layout: collapsed = bottom-left corner; expanded = grows upward+right.
local function picker_layout()
local screen_w, screen_h = engine.window.size()
if state.is_picker_open() then
return {
x = PICKER_MARGIN + CHIP_SIZE + CHIP_MARGIN, -- right of layer-chips
y = screen_h - PICKER_EXPANDED_H - PICKER_MARGIN,
w = PICKER_EXPANDED_W,
h = PICKER_EXPANDED_H,
}
else
return {
x = PICKER_MARGIN + CHIP_SIZE + CHIP_MARGIN,
y = screen_h - PICKER_COLLAPSED_H - PICKER_MARGIN,
w = PICKER_COLLAPSED_W,
h = PICKER_COLLAPSED_H,
}
end
end
local function status_layout()
local screen_w, screen_h = engine.window.size()
return {
x = screen_w - STATUS_W - STATUS_MARGIN,
y = screen_h - STATUS_H - STATUS_MARGIN,
w = STATUS_W,
h = STATUS_H,
}
end
-- =====================================================================
-- Drawing
-- =====================================================================
local function draw_chip(chip, mx, my)
-- State decisions
local is_active
if chip.is_roof then
is_active = state.is_roof_mode()
else
is_active = (state.get_active_layer() == chip.name)
end
local is_hover = in_rect(mx, my, chip.x, chip.y, chip.w, chip.h)
local alpha
if is_active then
alpha = CHIP_ALPHA_ACTIVE
elseif is_hover then
alpha = CHIP_ALPHA_HOVER
else
alpha = CHIP_ALPHA_INACTIVE
end
local bg_color = is_active and rgba(COLOR_CHIP_BG_ACT, alpha)
or rgba(COLOR_CHIP_BG, alpha)
local text_color = is_active and rgba(COLOR_CHIP_TEXT_ACT, 0xFF)
or rgba(COLOR_CHIP_TEXT, 0xFF)
-- Background
engine.render.draw_rect(chip.x, chip.y, chip.w, chip.h, bg_color)
-- Border (active only)
-- NOTE: engine.render.draw_rect_lines does not exist yet (needs engine-binding-task)
if is_active then
engine.render.draw_rect_lines(chip.x, chip.y, chip.w, chip.h, rgba(COLOR_CHIP_BORDER, 0xFF))
end
-- Label text — centered (approximate using fixed-width assumption)
local text_x = chip.x + (chip.w / 2) - 8
local text_y = chip.y + (chip.h / 2) - 8
engine.render.draw_text(chip.label, text_x, text_y, 14, text_color)
-- Hidden overlay (strikethrough for invisible layers)
-- NOTE: engine.render.draw_line does not exist yet (needs engine-binding-task)
if not chip.is_roof and not state.is_layer_visible(chip.name) then
engine.render.draw_line(chip.x, chip.y, chip.x + chip.w, chip.y + chip.h,
rgba(COLOR_STRIKE, 0xFF), 2)
end
-- Entity-slot divider below lower_wall
if chip.divider_after then
local dy = chip.y + chip.h + (chip.gap_after / 2)
-- Dashed: 4 short segments
-- NOTE: engine.render.draw_line does not exist yet (needs engine-binding-task)
for seg = 0, 3 do
local sx = chip.x + seg * 9
engine.render.draw_line(sx, dy, sx + 6, dy, rgba(COLOR_DIVIDER, 0xFF), 1)
end
end
end
local function draw_layer_chips(mx, my)
for _, chip in ipairs(chip_layout()) do
draw_chip(chip, mx, my)
end
end
-- Placeholder tile thumbnail (v0.1.0 limitation: lib-core.maps doesn't
-- expose per-tile UVs to Lua; v0.2.0 task).
local function draw_tile_thumb(atlas_idx, tile_id, x, y, size)
engine.render.draw_rect(x, y, size, size, rgba(0x606060, 0xFF))
engine.render.draw_text("t" .. tile_id, x + 4, y + size/2 - 6, 10, rgba(0xFFFFFF, 0xFF))
end
-- Returns a list of tile IDs for the given atlas. Placeholder for v0.1.0:
-- lib-core.maps does not yet expose atlas.tile_ids() — return just the
-- currently-active tile so the expanded picker has something to render.
local function atlas_tile_list(atlas_idx)
return { state.get_active_tile() }
end
local function draw_picker(mx, my)
local p = picker_layout()
engine.render.draw_rect(p.x, p.y, p.w, p.h, rgba(COLOR_PICKER_BG, 0xE0))
-- NOTE: engine.render.draw_rect_lines does not exist yet (needs engine-binding-task)
engine.render.draw_rect_lines(p.x, p.y, p.w, p.h, rgba(0xFFFFFF, 0xFF))
if not state.is_picker_open() then
-- Collapsed view: thumbnail of active tile + truncated labels
local atlas_idx = state.get_active_atlas()
local tile_id = state.get_active_tile()
local atlas_id = maps.atlas_id_at(atlas_idx) or "?"
-- Active tile thumbnail (top of chip)
draw_tile_thumb(atlas_idx, tile_id,
p.x + 16, p.y + 8, TILE_THUMB_SIZE)
-- Labels (bottom)
local atlas_label = atlas_id:sub(1, 8) .. (atlas_id:len() > 8 and "" or "")
engine.render.draw_text(atlas_label, p.x + 4, p.y + 60, 10, rgba(0xCCCCCC, 0xFF))
return
end
-- Expanded view: atlas row at top, tile grid below
engine.render.draw_text("Atlas:", p.x + 8, p.y + 4, 11, rgba(0xFFFFFF, 0xFF))
local atlas_count = maps.atlas_count()
local atlas_x = p.x + 8
for i = 0, atlas_count - 1 do
local label = maps.atlas_id_at(i) or "?"
local short = label:sub(1, 6)
local color = (i == state.get_active_atlas())
and rgba(0xFFFFFF, 0xFF) or rgba(0x808080, 0xFF)
engine.render.draw_text("["..short.."]", atlas_x, p.y + 20, 11, color)
atlas_x = atlas_x + 60 -- crude spacing
end
-- Tile grid (below the atlas row)
engine.render.draw_text("Tiles:", p.x + 8, p.y + 40, 11, rgba(0xFFFFFF, 0xFF))
local active_atlas = state.get_active_atlas()
local tiles = atlas_tile_list(active_atlas)
local grid_x = p.x + 8
local grid_y = p.y + 60
for i, tid in ipairs(tiles) do
local col = (i - 1) % 5
local row = math.floor((i - 1) / 5)
local tx = grid_x + col * (TILE_THUMB_SIZE + 4)
local ty = grid_y + row * (TILE_THUMB_SIZE + 4)
draw_tile_thumb(active_atlas, tid, tx, ty, TILE_THUMB_SIZE)
if tid == state.get_active_tile() then
-- NOTE: engine.render.draw_rect_lines does not exist yet (needs engine-binding-task)
engine.render.draw_rect_lines(tx, ty, TILE_THUMB_SIZE, TILE_THUMB_SIZE,
rgba(0xFFFF00, 0xFF))
end
end
end
local function draw_status(mx, my)
local s = status_layout()
engine.render.draw_rect(s.x, s.y, s.w, s.h, rgba(COLOR_STATUS_BG, 0xC0))
-- NOTE: engine.render.draw_rect_lines does not exist yet (needs engine-binding-task)
engine.render.draw_rect_lines(s.x, s.y, s.w, s.h, rgba(0xFFFFFF, 0xFF))
local cell = state.get_mouse_cell()
local cell_str
if cell then
cell_str = string.format("(%d,%d)", cell.x, cell.y)
else
cell_str = "(-,-)"
end
local dirty_str = state.is_dirty() and "*" or " "
engine.render.draw_text(cell_str .. " " .. dirty_str, s.x + 4, s.y + 4, 12,
rgba(COLOR_STATUS_TEXT, 0xFF))
end
-- =====================================================================
-- Public API
-- =====================================================================
function M.draw()
local mx, my = engine.input.get_mouse_pos()
draw_layer_chips(mx, my)
draw_picker(mx, my)
draw_status(mx, my)
end
-- Hit-test mouse click. Returns true if the click was consumed by UI.
-- button: "left" | "right"
function M.handle_click(mx, my, button)
local p = picker_layout()
-- Picker: check expanded panel first (largest area)
if in_rect(mx, my, p.x, p.y, p.w, p.h) then
if state.is_picker_open() then
-- TODO: hit-test individual tile/atlas slots inside the panel
-- For v0.1.0, any click inside expanded picker closes it.
actions.toggle_picker()
return true
else
actions.toggle_picker()
return true
end
end
-- Layer chips
for _, chip in ipairs(chip_layout()) do
if in_rect(mx, my, chip.x, chip.y, chip.w, chip.h) then
if button == "left" then
if chip.is_roof then
actions.toggle_roof_mode()
else
actions.set_active_layer(chip.name)
end
elseif button == "right" then
if not chip.is_roof then
actions.toggle_layer_visible(chip.name)
end
end
return true
end
end
-- Status chip — no-op fallback
local s = status_layout()
if in_rect(mx, my, s.x, s.y, s.w, s.h) then
return true -- consumed but no action
end
return false -- click falls through to map-area handler in init.lua
end
return M