Compare commits

...

15 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
4 changed files with 487 additions and 109 deletions

View File

@@ -1,11 +1,19 @@
# sporel-module-map-editor
Interactive map editor for Sporel `.map.json` files. v0.2 series ships
the Rev 4 UX architecture (see design paper
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 with mode-switch + actions, right Layers side-panel, bottom
palette strip of material slot thumbnails, mouse drag-paint, and two
mutually-exclusive paint modes (Auto-Tile vs Direct).
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
@@ -131,3 +139,53 @@ 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).

439
init.lua
View File

@@ -1,4 +1,4 @@
-- sporel-module-map-editor v0.3.0
-- sporel-module-map-editor v0.4.0
-- Interactive map editor — Rev 4 UX architecture (see design paper
-- 2026-05-28-autotile-blob-styles-design.md §11 + plan stub
-- 2026-05-28-map-editor-blob-v2.md). 0.2.0b adds the bottom palette
@@ -22,7 +22,14 @@
-- active layer renders "S<slot>r<rot>" (and "f" when flip=1) via the
-- new lib-core.maps 0.5.5 cell_material_slot getter. Inline today;
-- extract to lib-sporel.debug-overlay when a 2nd consumer appears.
-- Decal/Entity sub-selectors + Material-Properties modal land in 0.2.0d.
-- 0.4.0 migrates the top-toolbar / right side-panel / bottom palette
-- to lib-core.panel v0.3.0 persistent widgets (z_tier="normal",
-- chromeless, input_block="self"). Canvas + status + modals stay
-- module-rendered. Canvas-paint is gated on
-- panel.point_in_any_panel(mx, my) so panel-widget clicks no longer
-- bleed through to drag-paint. Removed Window > Layers Panel + Palette
-- toggle items (panel-lib manages visibility; persistent widgets are
-- always on).
--
-- All editor logic in this single file: engine sandbox only allows
-- require() for declared lib-deps; multi-file modules either use
@@ -33,6 +40,7 @@
local maps = require("lib-core.maps")
local camera = require("lib-core.camera")
local input = require("lib-core.input")
local panel = require("lib-core.panel")
-- CI-mode auto-exit (matches vagrant-skeleton pattern). Manifest declares
-- ci_frames=30 but engine does not auto-enforce for kind:"module"; we count
@@ -70,8 +78,9 @@ local state = (function()
submenu_open = nil, -- 0.3.0: label of the open submenu item within menu_open
modal_open = nil, -- 0.3.0: nil | <modal_id> (single-stack)
modal_text_input = "", -- 0.3.0: scratch buffer for modal text inputs
show_layers_panel = true, -- 0.3.0: Window > Layers Panel toggle
show_palette = true, -- 0.3.0: Window > Palette toggle
-- 0.4.0: visibility of layers-panel + palette is now managed by
-- lib-core.panel (persistent widgets). The Window-menu items
-- flip panel.is_open("map-editor.{layers,palette}") directly.
mouse_cell = nil, -- {x, y} or nil if mouse off-map
snap_vertex = nil, -- 0.2.0b: {vx, vy} or nil (auto-tile mode snap target)
drag_paint = nil, -- 0.2.0c.1: nil | "paint" | "erase" (active drag-stroke mode)
@@ -133,13 +142,23 @@ local state = (function()
function M.is_picker_open() return state.picker_open end
-- 0.2.0a: binary mode switch (Auto-Tile vs Direct).
-- CT6: extended to three-way cycle (auto-tile / cell-tile / direct).
function M.get_mode() return state.mode end
function M.set_mode(m)
if m == "auto-tile" or m == "direct" then state.mode = m end
if m == "auto-tile" or m == "cell-tile" or m == "direct" then state.mode = m end
end
function M.toggle_mode()
state.mode = (state.mode == "auto-tile") and "direct" or "auto-tile"
function M.cycle_mode()
if state.mode == "auto-tile" then
state.mode = "cell-tile"
elseif state.mode == "cell-tile" then
state.mode = "direct"
else
state.mode = "auto-tile"
end
end
-- Backwards-compat alias (still called by the input binding and the
-- toolbar mode toggle in older code paths; both now cycle 3 modes).
M.toggle_mode = M.cycle_mode
function M.is_modal_open(id)
if id == nil then return state.modal_open ~= nil end
@@ -169,11 +188,9 @@ local state = (function()
function M.get_submenu_open() return state.submenu_open end
function M.close_submenu() state.submenu_open = nil end
function M.is_layers_panel_shown() return state.show_layers_panel end
function M.toggle_layers_panel() state.show_layers_panel = not state.show_layers_panel end
function M.is_palette_shown() return state.show_palette end
function M.toggle_palette() state.show_palette = not state.show_palette end
-- 0.4.0: layers-panel + palette visibility moved to lib-core.panel
-- (persistent widgets managed via panel.is_open/toggle on the
-- "map-editor.layers" + "map-editor.palette" widget ids).
function M.set_mouse_cell(x, y)
if x == nil then
@@ -221,8 +238,6 @@ local state = (function()
state.submenu_open = nil
state.modal_open = nil
state.modal_text_input = ""
state.show_layers_panel = true
state.show_palette = true
state.debug_overlay = false
state.world_overlay = true
for name in pairs(state.layer_visible) do
@@ -295,6 +310,19 @@ local actions = (function()
state.mark_dirty()
end
-- 0.3.1: Cell-Tile mode paint actions. Write the per-cell material
-- flag via lib-core.maps; the renderer's 47-blob branch picks the
-- slot.
function M.paint_cell_material_at(cell_x, cell_y)
maps.set_cell_material(state.get_active_layer(), cell_x, cell_y, true)
state.mark_dirty()
end
function M.erase_cell_material_at(cell_x, cell_y)
maps.set_cell_material(state.get_active_layer(), cell_x, cell_y, false)
state.mark_dirty()
end
-- =====================================================================
-- Hotkey-driven actions
-- =====================================================================
@@ -377,7 +405,7 @@ local ui = (function()
-- Menu bar
local MENU_BAR_H = 24
local MENU_LABEL_PAD_X = 12
local MODE_PILL_W = 130
local MODE_PILL_W = 195
local MODE_PILL_H = 18
local MODE_PILL_SEG_W = 65
local MODE_PILL_MARGIN = 4
@@ -548,6 +576,9 @@ local ui = (function()
{ type = "toggle", label = "Auto-Tile",
is_on = function() return mode_is("auto-tile") end,
fire = function() actions.set_mode("auto-tile") end },
{ type = "toggle", label = "Cell-Tile",
is_on = function() return mode_is("cell-tile") end,
fire = function() actions.set_mode("cell-tile") end },
{ type = "toggle", label = "Direct", hotkey = "Tab",
is_on = function() return mode_is("direct") end,
fire = function() actions.set_mode("direct") end },
@@ -560,13 +591,15 @@ local ui = (function()
end
end
-- 0.4.0: panel-lib manages widget visibility via panel.is_open/toggle.
-- The Window-menu items now flip the persistent widgets' open-state.
local WINDOW_ITEMS = {
{ type = "toggle", label = "Layers Panel",
is_on = function() return state.is_layers_panel_shown() end,
fire = function() state.toggle_layers_panel() end },
is_on = function() return panel.is_open("map-editor.layers") end,
fire = function() panel.toggle("map-editor.layers") end },
{ type = "toggle", label = "Palette",
is_on = function() return state.is_palette_shown() end,
fire = function() state.toggle_palette() end },
is_on = function() return panel.is_open("map-editor.palette") end,
fire = function() panel.toggle("map-editor.palette") end },
}
local HELP_ITEMS = {
@@ -587,7 +620,7 @@ local ui = (function()
-- Cheatsheet content (rendered into the modal).
local CHEATSHEET = {
{ key="Tab", action="Toggle mode (Auto-Tile / Direct)" },
{ key="Tab", action="Cycle mode (Auto-Tile / Cell-Tile / Direct)" },
{ key="I", action="Show / hide this cheatsheet" },
{ key="S", action="Save current map to work.map.json" },
{ key="E", action="Erase tile at mouse cell" },
@@ -656,20 +689,25 @@ local ui = (function()
return {
x = x, y = y,
auto = { x = x, y = y, w = MODE_PILL_SEG_W, h = MODE_PILL_H, mode = "auto-tile" },
direct = { x = x + MODE_PILL_SEG_W, y = y, w = MODE_PILL_SEG_W, h = MODE_PILL_H, mode = "direct" },
cell = { x = x + MODE_PILL_SEG_W, y = y, w = MODE_PILL_SEG_W, h = MODE_PILL_H, mode = "cell-tile" },
direct = { x = x + MODE_PILL_SEG_W * 2, y = y, w = MODE_PILL_SEG_W, h = MODE_PILL_H, mode = "direct" },
}
end
local function draw_mode_pill()
local lay = mode_pill_layout()
local current = state.get_mode()
for _, seg in ipairs({ lay.auto, lay.direct }) do
for _, seg in ipairs({ lay.auto, lay.cell, lay.direct }) do
local active = (current == seg.mode)
local bg = active and COL_BUTTON_BG_ACTIVE or COL_BUTTON_BG
local fg = active and COL_TEXT_ACTIVE or COL_TEXT
engine.render.draw_rect(seg.x, seg.y, seg.w, seg.h, rgba(bg, 0xFF))
engine.render.draw_rect_lines(seg.x, seg.y, seg.w, seg.h, rgba(COL_BUTTON_BORDER, 0x40))
local label = (seg.mode == "auto-tile") and "Auto-Tile" or "Direct"
local label
if seg.mode == "auto-tile" then label = "Auto-Tile"
elseif seg.mode == "cell-tile" then label = "Cell-Tile"
else label = "Direct"
end
engine.render.draw_text(label, seg.x + 8, seg.y + 3, 11, rgba(fg, 0xFF))
end
end
@@ -1180,10 +1218,18 @@ local ui = (function()
end
-- =====================================================================
-- Public API
-- Public API (v0.4.0 split for panel-lib persistent widgets)
-- =====================================================================
--
-- The 3 persistent widgets (toolbar / layers / palette) get their own
-- render + handle_input pair. M.draw() now only paints the chrome that
-- is NOT a panel-widget: the bottom-left status chip and any open
-- modal overlay. Modals stay module-rendered + module-routed (they
-- are top-tier transient and need to fully absorb input via the
-- existing dispatch_modal_click path).
function M.draw()
-- Toolbar widget: menu-bar + mode-pill + open-dropdown render.
function M.draw_toolbar_widget(_ctx)
local mx, my = engine.input.get_mouse_pos()
draw_menu_bar(mx, my)
local open_name = state.get_menu_open()
@@ -1196,12 +1242,25 @@ local ui = (function()
end
end
draw_mode_pill()
if state.is_layers_panel_shown() then
end
-- Layers widget: right-side panel with per-layer rows + roof toggle.
function M.draw_layers_widget(_ctx)
local mx, my = engine.input.get_mouse_pos()
draw_side_panel(mx, my)
end
if state.is_palette_shown() then
-- Palette widget: bottom strip with tile-thumbnail swatches.
function M.draw_palette_widget(_ctx)
local mx, my = engine.input.get_mouse_pos()
draw_palette(mx, my)
end
-- M.draw() draws non-widget chrome: status chip + modal overlay.
-- Called by init.lua's render() AFTER panel.render(), so modals
-- correctly appear on top of all widget panels.
function M.draw()
local mx, my = engine.input.get_mouse_pos()
draw_status(mx, my)
local mid = state.get_modal_open()
if mid and MODALS[mid] then
@@ -1274,6 +1333,22 @@ local ui = (function()
engine.render.draw_rect_lines(px - 6, py - 6, 12, 12,
rgba(COL_VERTEX_SNAP, 0xFF))
end
elseif mode == "cell-tile" then
-- Cell-tile mode: faint render-cell grid (same density as Direct).
local grid_col = rgba(COL_VERTEX_GRID, 0x40)
for x = 0, map_w do
engine.render.draw_line(x * t_size, 0, x * t_size, map_h * t_size, grid_col)
end
for y = 0, map_h do
engine.render.draw_line(0, y * t_size, map_w * t_size, y * t_size, grid_col)
end
-- Hover-cell highlight: 1-px outline around the cursor cell.
local c = state.get_mouse_cell()
if c then
engine.render.draw_rect_lines(
c.x * t_size, c.y * t_size, t_size, t_size,
rgba(COL_VERTEX_SNAP, 0xFF))
end
else
-- Direct mode: subtle render-cell grid only — that's the
-- grid LMB strokes write to.
@@ -1287,17 +1362,25 @@ local ui = (function()
end
end
-- Hit-test mouse click. Returns true if the click was consumed by UI.
-- button: "left" | "right"
function M.handle_click(mx, my, button)
-- Modal absorbs all clicks while open.
if dispatch_modal_click(mx, my, button) then
return true
-- v0.4.0: hit-test split into per-widget handlers + a module-level
-- modal dispatcher. The 3 widget panels (toolbar / layers / palette)
-- get their own handle_input fns; the modal-click flow stays at
-- the M.update level since modals are NOT panel widgets.
--- M.handle_modal_click(mx, my, button) — returns true if modal
--- consumed the click. Called BEFORE panel.update() so a modal can
--- swallow clicks before they reach widget dispatch.
function M.handle_modal_click(mx, my, button)
return dispatch_modal_click(mx, my, button)
end
--- M.handle_toolbar_click(mx, my, button) — handles menu-bar +
--- mode-pill + dropdown clicks. Wired into the toolbar widget's
--- handle_input via panel-lib. Returns true if consumed.
function M.handle_toolbar_click(mx, my, button)
-- Mode-pill
local pill = mode_pill_layout()
for _, seg in ipairs({ pill.auto, pill.direct }) do
for _, seg in ipairs({ pill.auto, pill.cell, pill.direct }) do
if in_rect(mx, my, seg.x, seg.y, seg.w, seg.h) then
if button == "left" then
actions.set_mode(seg.mode)
@@ -1379,25 +1462,17 @@ local ui = (function()
end
end
-- Click outside the dropdown closes it (no return — fall through so
-- the click can also hit other UI like canvas).
-- Click outside the dropdown closes it (no return — fall
-- through so other UI can still hit-test the click).
state.close_menu()
end
-- Bottom palette swatches: left = select slot
if state.is_palette_shown() then
for _, sw in ipairs(palette_swatches_layout()) do
if in_rect(mx, my, sw.x, sw.y, sw.w, sw.h) then
if button == "left" then
actions.set_active_tile(sw.slot_id)
end
return true
end
end
return false
end
-- Side-panel layer rows: left = set active; right = toggle visibility
if state.is_layers_panel_shown() then
--- M.handle_layers_click(mx, my, button) — left = set active layer;
--- right = toggle visibility. Wired into the layers widget.
function M.handle_layers_click(mx, my, button)
for _, row in ipairs(side_panel_rows_layout()) do
if in_rect(mx, my, row.eye_x, row.eye_y, row.eye_w, row.eye_h) then
if button == "left" then
@@ -1414,16 +1489,30 @@ local ui = (function()
return true
end
end
return false
end
-- Status chip — no-op fallback to claim the click area
local s = status_layout()
if in_rect(mx, my, s.x, s.y, s.w, s.h) then
--- M.handle_palette_click(mx, my, button) — left = select slot.
--- Wired into the palette widget.
function M.handle_palette_click(mx, my, button)
for _, sw in ipairs(palette_swatches_layout()) do
if in_rect(mx, my, sw.x, sw.y, sw.w, sw.h) then
if button == "left" then
actions.set_active_tile(sw.slot_id)
end
return true
end
return false -- click falls through to map-area handler in init.lua
end
return false
end
-- Expose layout fns for the widget custom layout-fns registered in
-- the Setup section. Each widget needs its bounds to match the
-- absolute screen coords the existing draw_* helpers use, so we
-- expose the layout fns directly.
M._side_panel_layout = side_panel_layout
M._palette_layout = palette_layout
M._MENU_BAR_H_const = MENU_BAR_H
-- Expose internal tables needed by self_test (test-only; not part of
-- the public draw/click API).
@@ -1431,6 +1520,8 @@ local ui = (function()
M._VIEW_ITEMS = VIEW_ITEMS
M._EDIT_ITEMS = EDIT_ITEMS
M._LAYER_PANEL_ROWS = LAYER_PANEL_ROWS
M._mode_pill_layout = mode_pill_layout
M._MODE_PILL_SEG_W = MODE_PILL_SEG_W
return M
end)()
@@ -1464,7 +1555,88 @@ input.bind("reset_transform", { "0" }) -- 0.2.0c
input.bind("toggle_debug_overlay", { "d" }) -- 0.2.0c.3
input.bind("toggle_world_overlay", { "g" }) -- 0.2.0c.4
engine.print("map-editor v0.3.0: ready. Tab=mode, I=help, G=grid, D=debug, S=save, E=erase, R=rotate, H=flip, 0=reset, ESC=quit / close")
-- =====================================================================
-- 0.4.0: panel-lib persistent widget registration (W.2)
-- =====================================================================
-- Three persistent + chromeless + input_block="self" widgets cover the
-- top toolbar, right-side layers panel, and bottom palette strip. Each
-- widget's bounds match what the existing draw_* helpers expect — they
-- still draw at absolute screen coords via engine.window.size(). The
-- panel-lib's chrome (bg/border/title/X) is suppressed via
-- chromeless=true; the widgets draw their own visual style.
--
-- Map-canvas is NOT a widget. Module renders it directly inside
-- camera.begin/finish; module's drag-paint dispatch is gated on
-- panel.point_in_any_panel(mx, my) so widget clicks never bleed
-- through into canvas paint.
-- Toolbar bounds = top strip (menu-bar height + room for open
-- dropdowns). Use a custom layout fn that covers the union of
-- menu-bar + worst-case dropdown extent.
local TOOLBAR_MAX_H = 290 -- menu-bar (24) + max dropdown rows (12 * 22 + pad)
local toolbar_widget = {
title = "Toolbar",
pause_on_open = false,
render = function(ctx) ui.draw_toolbar_widget(ctx) end,
handle_input = function(_ctx, event)
if event.kind == "click" then
ui.handle_toolbar_click(event.x, event.y, event.button)
end
end,
}
panel.register("map-editor.toolbar", toolbar_widget, {
layout = function(sw, _sh)
return { x = 0, y = 0, w = sw, h = TOOLBAR_MAX_H }
end,
z_tier = "normal",
persistent = true,
input_block = "self",
chromeless = true,
})
local layers_widget = {
title = "Layers",
pause_on_open = false,
render = function(ctx) ui.draw_layers_widget(ctx) end,
handle_input = function(_ctx, event)
if event.kind == "click" then
ui.handle_layers_click(event.x, event.y, event.button)
end
end,
}
panel.register("map-editor.layers", layers_widget, {
layout = function(_sw, _sh)
local p = ui._side_panel_layout()
return { x = p.x, y = p.y, w = p.w, h = p.h }
end,
z_tier = "normal",
persistent = true,
input_block = "self",
chromeless = true,
})
local palette_widget = {
title = "Palette",
pause_on_open = false,
render = function(ctx) ui.draw_palette_widget(ctx) end,
handle_input = function(_ctx, event)
if event.kind == "click" then
ui.handle_palette_click(event.x, event.y, event.button)
end
end,
}
panel.register("map-editor.palette", palette_widget, {
layout = function(_sw, _sh)
local p = ui._palette_layout()
return { x = p.x, y = p.y, w = p.w, h = p.h }
end,
z_tier = "normal",
persistent = true,
input_block = "self",
chromeless = true,
})
engine.print("map-editor v0.4.0: ready. Tab=cycle mode (Auto/Cell/Direct), I=help, G=grid, D=debug, S=save, E=erase, R=rotate, H=flip, 0=reset, ESC=quit / close")
-- =====================================================================
-- 0.3.0: self-tests (Spec §7.1). Run once on the first CI frame.
@@ -1478,7 +1650,9 @@ local function self_test()
-- t1: opening a menu then clicking outside closes it.
state.set_menu_open("File")
engine.test.equals(state.get_menu_open(), "File", "t1: menu opened by setter")
ui.handle_click(2, MENU_BAR_H + 10, "left")
-- 0.4.0: handle_toolbar_click + handle_modal_click split out of
-- handle_click; call the toolbar-specific path here.
ui.handle_toolbar_click(2, MENU_BAR_H + 10, "left")
engine.test.equals(state.get_menu_open(), nil, "t1: outside click closes menu")
-- t2: toggle item flips bound state.
@@ -1500,7 +1674,12 @@ local function self_test()
"t3: cheatsheet modal id set")
engine.test.equals(state.get_menu_open(), nil,
"t3: menu closed when modal opens")
ui.handle_click(2, MENU_BAR_H + 10, "left")
-- 0.4.0: modal-priority is enforced by handle_modal_click (called
-- before panel.update() in M.update). Verify it returns true so
-- a click would be swallowed before reaching the toolbar widget.
local modal_consumed = ui.handle_modal_click(2, MENU_BAR_H + 10, "left")
engine.test.equals(modal_consumed, true,
"t3: modal swallows clicks while open")
engine.test.equals(state.get_modal_open(), "cheatsheet",
"t3: outside click does not close modal")
state.close_modal()
@@ -1541,6 +1720,85 @@ local function self_test()
engine.test.equals(state.is_layer_visible(lname), not vis_before,
"t7: layer visibility flipped via state.toggle_layer_visible")
state.toggle_layer_visible(lname)
-- t8: state.set_mode accepts "cell-tile"
state.set_mode("cell-tile")
engine.test.equals(state.get_mode(), "cell-tile",
"t8: set_mode('cell-tile') sets mode")
-- t9: mode_pill_layout returns 3 segments at consecutive x positions
local pill = ui._mode_pill_layout and ui._mode_pill_layout() or nil
engine.test.assert(pill ~= nil,
"t9: mode_pill_layout accessible from self_test")
engine.test.assert(pill.cell ~= nil and pill.direct ~= nil,
"t9: pill has all three segments (auto, cell, direct)")
engine.test.equals(pill.cell.x - pill.auto.x, ui._MODE_PILL_SEG_W,
"t9: cell segment sits one SEG_W right of auto")
engine.test.equals(pill.direct.x - pill.cell.x, ui._MODE_PILL_SEG_W,
"t9: direct segment sits one SEG_W right of cell")
-- t12: cycle_mode cycles auto -> cell -> direct -> auto
state.set_mode("auto-tile")
state.cycle_mode()
engine.test.equals(state.get_mode(), "cell-tile", "t12: cycle auto -> cell")
state.cycle_mode()
engine.test.equals(state.get_mode(), "direct", "t12: cycle cell -> direct")
state.cycle_mode()
engine.test.equals(state.get_mode(), "auto-tile", "t12: cycle direct -> auto")
-- t10: paint_cell_material_at writes the flag readable by
-- maps.get_cell_material; cell-tile mode dispatches LMB to it.
state.set_mode("cell-tile")
local layer = state.get_active_layer()
-- Clear first to avoid pollution from earlier tests
if maps.get_cell_material(layer, 5, 5) then
maps.set_cell_material(layer, 5, 5, false)
end
actions.paint_cell_material_at(5, 5)
engine.test.equals(maps.get_cell_material(layer, 5, 5), true,
"t10: paint_cell_material_at sets the flag")
actions.erase_cell_material_at(5, 5)
engine.test.equals(maps.get_cell_material(layer, 5, 5), false,
"t10: erase_cell_material_at clears the flag")
-- t11: Cycle Rotation / Toggle Flip / Reset Transform are disabled
-- in both auto-tile AND cell-tile modes; enabled only in direct.
local target_labels = { "Cycle Rotation", "Toggle Flip", "Reset Transform" }
for _, label in ipairs(target_labels) do
state.set_mode("auto-tile")
for _, it in ipairs(ui._EDIT_ITEMS) do
if it.label == label then
local d = it.disabled
local is_disabled = (type(d) == "function") and d() or (d == true)
engine.test.equals(is_disabled, true,
"t11: " .. label .. " disabled in auto-tile mode")
break
end
end
state.set_mode("cell-tile")
for _, it in ipairs(ui._EDIT_ITEMS) do
if it.label == label then
local d = it.disabled
local is_disabled = (type(d) == "function") and d() or (d == true)
engine.test.equals(is_disabled, true,
"t11: " .. label .. " disabled in cell-tile mode")
break
end
end
state.set_mode("direct")
for _, it in ipairs(ui._EDIT_ITEMS) do
if it.label == label then
local d = it.disabled
local is_disabled = (type(d) == "function") and d() or (d == true)
engine.test.equals(is_disabled, false,
"t11: " .. label .. " enabled in direct mode")
break
end
end
end
-- t11 teardown: restore auto-tile so we don't leave state in direct
state.set_mode("auto-tile")
end
-- =====================================================================
@@ -1644,24 +1902,48 @@ function update(ctx, dt)
actions.toggle_world_overlay()
end
-- Mouse clicks + drag: hit-test UI on press; if consumed we never
-- enter drag-mode for this stroke. Otherwise the press initiates a
-- drag-stroke whose paint/erase action repeats for every subsequent
-- snap_vertex (or cell) while the button stays down. set_vertex /
-- set_override are idempotent + no-op when state is unchanged so
-- repeating per-frame is cheap.
if engine.input.was_mouse_pressed(engine.input.MOUSE_LEFT) then
local consumed = ui.handle_click(mx, my, "left")
if not consumed then
-- 0.4.0: input dispatch is split between module-level + panel-lib:
-- 1. handle_modal_click: module-level, ALWAYS runs first (modals
-- are top-most overlays and not panel widgets).
-- 2. panel.update(dt): routes clicks to persistent widgets via
-- their input_block="self" hit-tests. Widget handle_input
-- delegates to ui.handle_toolbar_click / handle_layers_click /
-- handle_palette_click.
-- 3. Canvas drag-paint: gated on (no modal AND point not inside
-- any panel widget) to prevent widget-clicks bleeding through
-- to the map canvas underneath.
--
-- Modal precedence: when a modal is open, we still consume the
-- click here (so it doesn't reach panel widgets), but we DON'T
-- start a drag-paint. panel.update still runs but its dispatch
-- routes the press as normal (mostly a no-op for modals since the
-- modal isn't a widget).
local lmb_pressed = engine.input.was_mouse_pressed(engine.input.MOUSE_LEFT)
local rmb_pressed = engine.input.was_mouse_pressed(engine.input.MOUSE_RIGHT)
local modal_open = state.is_modal_open()
if lmb_pressed and modal_open then
ui.handle_modal_click(mx, my, "left")
end
if rmb_pressed and modal_open then
ui.handle_modal_click(mx, my, "right")
end
-- Panel-lib dispatch: reads engine.input mouse state directly,
-- emits click + wheel events to widget handle_input fns. Widgets
-- routed by tier (top → normal → hud) with input_block="self".
panel.update(dt)
-- Canvas drag-paint trigger. Only initiate when no modal open AND
-- the click did not land within any panel widget area. Drag-paint
-- still continues per-frame as long as the mouse button stays
-- down (no per-frame panel-bounds check — the originating press
-- already established the click as a canvas-stroke).
if lmb_pressed and not modal_open and not panel.point_in_any_panel(mx, my) then
state.set_drag_paint("paint")
end
end
if engine.input.was_mouse_pressed(engine.input.MOUSE_RIGHT) then
local consumed = ui.handle_click(mx, my, "right")
if not consumed then
if rmb_pressed and not modal_open and not panel.point_in_any_panel(mx, my) then
state.set_drag_paint("erase")
end
end
-- Per-frame drag-paint dispatch (Auto-Tile uses snap_vertex; Direct
-- uses mouse_cell). Stops as soon as the originating button releases.
@@ -1678,6 +1960,15 @@ function update(ctx, dt)
if snap then
actions.paint_vertex_at(snap.vx, snap.vy, dp == "paint")
end
elseif state.get_mode() == "cell-tile" then
local cell = state.get_mouse_cell()
if cell then
if dp == "paint" then
actions.paint_cell_material_at(cell.x, cell.y)
else
actions.erase_cell_material_at(cell.x, cell.y)
end
end
else
local cell = state.get_mouse_cell()
if cell then
@@ -1700,5 +1991,11 @@ function render(ctx)
ui.draw_world_overlay(m_size.w, m_size.h, t_size, state.get_snap_vertex())
ui.draw_debug_overlay(m_size.w, m_size.h, t_size)
camera.finish()
ui.draw() -- screen-space overlays after camera ends
-- 0.4.0: panel.render() draws the 3 persistent widgets (toolbar +
-- layers + palette) on top of the canvas. ui.draw() then draws the
-- remaining non-widget chrome (status chip + open modal overlay)
-- ON TOP of the widgets — modals are last so they appear above any
-- widget panel.
panel.render()
ui.draw()
end

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,6 +1,6 @@
{
"id": "map-editor",
"version": "0.3.0",
"version": "0.4.0",
"kind": "module",
"api": "^0.1",
"ci_frames": 30,
@@ -9,10 +9,12 @@
"blob_rect_stone": "lib-asset.prototype-blob-geom"
},
"deps": [
{"id":"lib-core.maps","version":"0.5.6"},
{"id":"lib-core.render","version":"0.1.0"},
{"id":"lib-core.maps","version":"0.5.7"},
{"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.input","version":"0.4.0"},
{"id":"lib-core.panel","version":"0.4.0"},
{"id":"lib-asset.prototype-blob-geom","version":"0.1.0"}
]
}