Compare commits
6 Commits
f06ef5e2cb
...
2734efa1a9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2734efa1a9 | ||
|
|
340dd5f700 | ||
|
|
46d1e78758 | ||
|
|
51138e257f | ||
|
|
6eacde3eaa | ||
|
|
e745be8d07 |
22
README.md
22
README.md
@@ -131,3 +131,25 @@ 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.
|
||||
|
||||
161
init.lua
161
init.lua
@@ -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
|
||||
-- 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
|
||||
@@ -133,13 +133,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
|
||||
@@ -295,6 +305,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 +400,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 +571,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 },
|
||||
@@ -587,7 +613,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" },
|
||||
@@ -655,21 +681,26 @@ local ui = (function()
|
||||
local y = (MENU_BAR_H - MODE_PILL_H) / 2
|
||||
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" },
|
||||
auto = { x = x, y = y, w = MODE_PILL_SEG_W, h = MODE_PILL_H, mode = "auto-tile" },
|
||||
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
|
||||
@@ -1274,6 +1305,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.
|
||||
@@ -1297,7 +1344,7 @@ local ui = (function()
|
||||
|
||||
-- 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)
|
||||
@@ -1431,6 +1478,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 +1513,7 @@ 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")
|
||||
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.
|
||||
@@ -1541,6 +1590,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
|
||||
|
||||
-- =====================================================================
|
||||
@@ -1678,6 +1806,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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "map-editor",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"kind": "module",
|
||||
"api": "^0.1",
|
||||
"ci_frames": 30,
|
||||
@@ -9,7 +9,7 @@
|
||||
"blob_rect_stone": "lib-asset.prototype-blob-geom"
|
||||
},
|
||||
"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.camera","version":"0.3.0"},
|
||||
{"id":"lib-core.input","version":"0.4.0"},
|
||||
|
||||
Reference in New Issue
Block a user