Compare commits

..

8 Commits

Author SHA1 Message Date
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 192 additions and 15 deletions

View File

@@ -131,3 +131,25 @@ future engine + lib slice.
All previous hotkeys (Tab, S, E, R, H, 0, D, G, I, Esc) continue to 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 work; their bindings are documented in the items where applicable
and in the in-editor Cheatsheet modal. 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.

161
init.lua
View File

@@ -1,4 +1,4 @@
-- sporel-module-map-editor v0.3.0 -- sporel-module-map-editor v0.3.1
-- Interactive map editor — Rev 4 UX architecture (see design paper -- Interactive map editor — Rev 4 UX architecture (see design paper
-- 2026-05-28-autotile-blob-styles-design.md §11 + plan stub -- 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 -- 2026-05-28-map-editor-blob-v2.md). 0.2.0b adds the bottom palette
@@ -133,13 +133,23 @@ local state = (function()
function M.is_picker_open() return state.picker_open end function M.is_picker_open() return state.picker_open end
-- 0.2.0a: binary mode switch (Auto-Tile vs Direct). -- 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.get_mode() return state.mode end
function M.set_mode(m) 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 end
function M.toggle_mode() function M.cycle_mode()
state.mode = (state.mode == "auto-tile") and "direct" or "auto-tile" 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 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) function M.is_modal_open(id)
if id == nil then return state.modal_open ~= nil end if id == nil then return state.modal_open ~= nil end
@@ -295,6 +305,19 @@ local actions = (function()
state.mark_dirty() state.mark_dirty()
end 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 -- Hotkey-driven actions
-- ===================================================================== -- =====================================================================
@@ -377,7 +400,7 @@ local ui = (function()
-- Menu bar -- Menu bar
local MENU_BAR_H = 24 local MENU_BAR_H = 24
local MENU_LABEL_PAD_X = 12 local MENU_LABEL_PAD_X = 12
local MODE_PILL_W = 130 local MODE_PILL_W = 195
local MODE_PILL_H = 18 local MODE_PILL_H = 18
local MODE_PILL_SEG_W = 65 local MODE_PILL_SEG_W = 65
local MODE_PILL_MARGIN = 4 local MODE_PILL_MARGIN = 4
@@ -548,6 +571,9 @@ local ui = (function()
{ type = "toggle", label = "Auto-Tile", { type = "toggle", label = "Auto-Tile",
is_on = function() return mode_is("auto-tile") end, is_on = function() return mode_is("auto-tile") end,
fire = function() actions.set_mode("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", { type = "toggle", label = "Direct", hotkey = "Tab",
is_on = function() return mode_is("direct") end, is_on = function() return mode_is("direct") end,
fire = function() actions.set_mode("direct") end }, fire = function() actions.set_mode("direct") end },
@@ -587,7 +613,7 @@ local ui = (function()
-- Cheatsheet content (rendered into the modal). -- Cheatsheet content (rendered into the modal).
local CHEATSHEET = { 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="I", action="Show / hide this cheatsheet" },
{ key="S", action="Save current map to work.map.json" }, { key="S", action="Save current map to work.map.json" },
{ key="E", action="Erase tile at mouse cell" }, { key="E", action="Erase tile at mouse cell" },
@@ -655,21 +681,26 @@ local ui = (function()
local y = (MENU_BAR_H - MODE_PILL_H) / 2 local y = (MENU_BAR_H - MODE_PILL_H) / 2
return { return {
x = x, y = y, x = x, y = y,
auto = { x = x, y = y, w = MODE_PILL_SEG_W, h = MODE_PILL_H, mode = "auto-tile" }, 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 end
local function draw_mode_pill() local function draw_mode_pill()
local lay = mode_pill_layout() local lay = mode_pill_layout()
local current = state.get_mode() 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 active = (current == seg.mode)
local bg = active and COL_BUTTON_BG_ACTIVE or COL_BUTTON_BG local bg = active and COL_BUTTON_BG_ACTIVE or COL_BUTTON_BG
local fg = active and COL_TEXT_ACTIVE or COL_TEXT 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(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)) 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)) engine.render.draw_text(label, seg.x + 8, seg.y + 3, 11, rgba(fg, 0xFF))
end end
end end
@@ -1274,6 +1305,22 @@ local ui = (function()
engine.render.draw_rect_lines(px - 6, py - 6, 12, 12, engine.render.draw_rect_lines(px - 6, py - 6, 12, 12,
rgba(COL_VERTEX_SNAP, 0xFF)) rgba(COL_VERTEX_SNAP, 0xFF))
end 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 else
-- Direct mode: subtle render-cell grid only — that's the -- Direct mode: subtle render-cell grid only — that's the
-- grid LMB strokes write to. -- grid LMB strokes write to.
@@ -1297,7 +1344,7 @@ local ui = (function()
-- Mode-pill -- Mode-pill
local pill = mode_pill_layout() 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 in_rect(mx, my, seg.x, seg.y, seg.w, seg.h) then
if button == "left" then if button == "left" then
actions.set_mode(seg.mode) actions.set_mode(seg.mode)
@@ -1431,6 +1478,8 @@ local ui = (function()
M._VIEW_ITEMS = VIEW_ITEMS M._VIEW_ITEMS = VIEW_ITEMS
M._EDIT_ITEMS = EDIT_ITEMS M._EDIT_ITEMS = EDIT_ITEMS
M._LAYER_PANEL_ROWS = LAYER_PANEL_ROWS 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 return M
end)() end)()
@@ -1464,7 +1513,7 @@ input.bind("reset_transform", { "0" }) -- 0.2.0c
input.bind("toggle_debug_overlay", { "d" }) -- 0.2.0c.3 input.bind("toggle_debug_overlay", { "d" }) -- 0.2.0c.3
input.bind("toggle_world_overlay", { "g" }) -- 0.2.0c.4 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") engine.print("map-editor v0.3.1: 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. -- 0.3.0: self-tests (Spec §7.1). Run once on the first CI frame.
@@ -1541,6 +1590,85 @@ local function self_test()
engine.test.equals(state.is_layer_visible(lname), not vis_before, engine.test.equals(state.is_layer_visible(lname), not vis_before,
"t7: layer visibility flipped via state.toggle_layer_visible") "t7: layer visibility flipped via state.toggle_layer_visible")
state.toggle_layer_visible(lname) 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 end
-- ===================================================================== -- =====================================================================
@@ -1678,6 +1806,15 @@ function update(ctx, dt)
if snap then if snap then
actions.paint_vertex_at(snap.vx, snap.vy, dp == "paint") actions.paint_vertex_at(snap.vx, snap.vy, dp == "paint")
end 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 else
local cell = state.get_mouse_cell() local cell = state.get_mouse_cell()
if cell then if cell then

17
manifest.launcher.md Normal file
View File

@@ -0,0 +1,17 @@
---
{"summary":"Map editor — vertical layer-chip overlay + atlas-tile picker","author":"calic","tags":["tool","map-editor"],"teaser_images":[]}
---
# Map Editor
Click-to-paint tile editor with collapsible atlas/tile-picker. Vertical layer-chip overlay (left), atlas+tile picker (bottom-left), status chip (bottom-right).
## Controls
- Mouse-left on grid: paint
- Mouse-left on layer-chip: switch active layer
- Mouse-right on layer-chip: toggle visibility
- E: erase mode
- R: rotate
- S: save
- ESC: quit to launcher

View File

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