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.
This commit is contained in:
calic
2026-05-29 00:03:42 +02:00
parent 35d9940d63
commit 25e838c54f
2 changed files with 191 additions and 17 deletions

202
init.lua
View File

@@ -1,11 +1,13 @@
-- sporel-module-map-editor v0.2.0a
-- sporel-module-map-editor v0.2.0b
-- 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). v0.2.0a sub-slice ships the
-- layout reorg (right Layers panel + top toolbar) + mode-switch
-- state + ?-cheatsheet modal — paint behaviour unchanged from v0.1.
-- Bottom palette + autotile painting + direct-mode placement land
-- in 0.2.0b/c/d sub-slices.
-- 2026-05-28-map-editor-blob-v2.md). v0.2.0b adds the bottom palette
-- strip (14 numbered swatches, no real tile thumbnails yet — that
-- needs a lib-core.maps tile-UV getter) + Auto-Tile vertex painting
-- via maps.set_vertex + a vertex-grid overlay. Direct-mode painting
-- still uses the v0.1 cell-paint path.
-- Transform controls + Direct-Override + Decal/Entity sub-selectors
-- land in 0.2.0c/d.
--
-- All editor logic in this single file: engine sandbox only allows
-- require() for declared lib-deps; multi-file modules either use
@@ -50,6 +52,7 @@ local state = (function()
picker_open = false, -- atlas+tile picker expanded? (legacy 0.1.0)
cheatsheet_open = false, -- 0.2.0a: ?-modal toggled?
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)
}
-- =====================================================================
@@ -121,6 +124,15 @@ local state = (function()
end
function M.get_mouse_cell() return state.mouse_cell end
function M.set_snap_vertex(vx, vy)
if vx == nil then
state.snap_vertex = nil
else
state.snap_vertex = { vx = vx, vy = vy }
end
end
function M.get_snap_vertex() return state.snap_vertex end
-- Used by tests to reset between asserts
function M.reset()
state.map_id = nil
@@ -134,6 +146,7 @@ local state = (function()
state.picker_open = false
state.cheatsheet_open = false
state.mouse_cell = nil
state.snap_vertex = nil
for name in pairs(state.layer_visible) do
state.layer_visible[name] = true
end
@@ -175,6 +188,14 @@ local actions = (function()
state.mark_dirty()
end
-- 0.2.0b: Auto-Tile painting via vertex grid. set_vertex(layer, vx, vy, val)
-- causes the renderer to flip up-to-4 surrounding cells to material on
-- the next draw (any-corner rule). painted=true → fill; false → clear.
function M.paint_vertex_at(vx, vy, painted)
maps.set_vertex(state.get_active_layer(), vx, vy, painted)
state.mark_dirty()
end
-- =====================================================================
-- Hotkey-driven actions
-- =====================================================================
@@ -257,6 +278,16 @@ local ui = (function()
local LAYER_ROW_GAP = 2
local EYE_BOX = 18
-- Bottom palette strip (above the status chip). 14 swatches packed
-- horizontally; each one click-selects state.active_tile = slot_id.
-- 0.2.0b ships nummerierte Farb-Swatches as a placeholder for real
-- tile thumbnails (those need a tile-UV getter on lib-core.maps).
local PALETTE_H = 64
local PALETTE_SWATCH_W = 48
local PALETTE_SWATCH_GAP = 4
local PALETTE_PADDING = 6
local PALETTE_SLOTS = 14 -- matches S-V2E2-RM-Blob baseline
-- Status chip (bottom-right, mouse cell + dirty)
local STATUS_W = 200
local STATUS_H = 22
@@ -281,6 +312,22 @@ local ui = (function()
local COL_HIDDEN_OVERLAY = 0xFF4040
local COL_MODAL_BG = 0x101014
local COL_MODAL_BORDER = 0xFFFFFF
local COL_PALETTE_BG = 0x18181C
local COL_SWATCH_BORDER = 0x606060
local COL_SWATCH_SELECTED = 0xFFD060
local COL_VERTEX_GRID = 0xFFFFFF
local COL_VERTEX_DOT = 0xFFB040
local COL_VERTEX_SNAP = 0x40FF60
-- HSV-derived swatch colors (one per slot, 1..14). Pre-computed so
-- the palette has visually distinct previews while real tile
-- thumbnails are not yet wired up.
local SWATCH_COLORS = {
0xE04040, 0xE08040, 0xE0C040, 0xA0E040,
0x40E040, 0x40E0A0, 0x40C0E0, 0x4080E0,
0x4040E0, 0xA040E0, 0xE040C0, 0xE04080,
0xA0A0A0, 0x606060,
}
-- Layers panel rendered top-down per LAYER_ORDER_TOP_DOWN from
-- lib-core.maps; matches the renderer's draw order so the topmost
@@ -309,9 +356,12 @@ local ui = (function()
{ key="E", action="Erase tile at mouse cell" },
{ key="R", action="Cycle tile rotation 0->90->180->270" },
{ key="Esc", action="Quit editor" },
{ key="LMB", action="Paint at mouse cell (uses active layer + tile)" },
{ key="RMB on Layer row", action="Toggle layer visibility (👁/⊘)" },
{ key="LMB on Layer row", action="Set as active layer (▶)" },
{ key="LMB on canvas (Auto-Tile)", action="Paint vertex on active layer (any-corner fill)" },
{ key="RMB on canvas (Auto-Tile)", action="Clear vertex on active layer" },
{ key="LMB on canvas (Direct)", action="Paint cell with active slot + rotation" },
{ key="LMB on palette swatch", action="Select slot (1-14) as active tile" },
{ key="RMB on Layer row", action="Toggle layer visibility" },
{ key="LMB on Layer row", action="Set as active layer" },
}
@@ -362,7 +412,7 @@ local ui = (function()
x = screen_w - SIDE_PANEL_W,
y = TOOLBAR_H,
w = SIDE_PANEL_W,
h = screen_h - TOOLBAR_H,
h = screen_h - TOOLBAR_H - PALETTE_H,
}
end
@@ -389,11 +439,39 @@ local ui = (function()
return out
end
local function palette_layout()
local screen_w, screen_h = engine.window.size()
return {
x = 0,
y = screen_h - PALETTE_H,
w = screen_w - SIDE_PANEL_W,
h = PALETTE_H,
}
end
-- Returns array of { slot_id, x, y, w, h } for each swatch in the
-- palette strip, packed left-to-right.
local function palette_swatches_layout()
local p = palette_layout()
local out = {}
local x = p.x + PALETTE_PADDING
local y = p.y + PALETTE_PADDING
local h = p.h - 2 * PALETTE_PADDING
for slot = 1, PALETTE_SLOTS do
out[slot] = {
slot_id = slot,
x = x, y = y, w = PALETTE_SWATCH_W, h = h,
}
x = x + PALETTE_SWATCH_W + PALETTE_SWATCH_GAP
end
return out
end
local function status_layout()
local screen_w, screen_h = engine.window.size()
return {
x = STATUS_MARGIN,
y = screen_h - STATUS_H - STATUS_MARGIN,
y = screen_h - PALETTE_H - STATUS_H - STATUS_MARGIN,
w = STATUS_W,
h = STATUS_H,
}
@@ -489,6 +567,31 @@ local ui = (function()
rgba(state.is_roof_mode() and COL_TEXT_ACTIVE or COL_TEXT_MUTED, 0xFF))
end
local function draw_palette(mx, my)
local p = palette_layout()
engine.render.draw_rect(p.x, p.y, p.w, p.h, rgba(COL_PALETTE_BG, 0xFF))
engine.render.draw_text("Palette (slot 1-14)", p.x + 6, p.y + 4, 11,
rgba(COL_TEXT_MUTED, 0xFF))
local selected_slot = state.get_active_tile()
for _, sw in ipairs(palette_swatches_layout()) do
local is_selected = (sw.slot_id == selected_slot)
local is_hover = in_rect(mx, my, sw.x, sw.y, sw.w, sw.h)
-- Swatch body in the per-slot color.
engine.render.draw_rect(sw.x, sw.y, sw.w, sw.h,
rgba(SWATCH_COLORS[sw.slot_id] or 0x808080, 0xFF))
-- Border: highlighted gold when selected, dim when not.
local border_col = is_selected and COL_SWATCH_SELECTED
or is_hover and COL_TEXT
or COL_SWATCH_BORDER
engine.render.draw_rect_lines(sw.x, sw.y, sw.w, sw.h,
rgba(border_col, 0xFF))
-- Slot number label in the corner.
engine.render.draw_text(tostring(sw.slot_id), sw.x + 4, sw.y + 4, 11,
rgba(COL_TEXT, 0xE0))
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(COL_TOOLBAR_BG, 0xC0))
@@ -526,10 +629,46 @@ local ui = (function()
local mx, my = engine.input.get_mouse_pos()
draw_top_toolbar(mx, my)
draw_side_panel(mx, my)
draw_palette(mx, my)
draw_status(mx, my)
draw_cheatsheet(mx, my) -- last so it overlays everything when open
end
-- World-space overlay rendered between maps.draw_map and camera.finish.
-- Auto-Tile mode only — shows the cell grid + vertex dots + snapped
-- vertex highlight so the painter can see where a click will land.
-- snap = { vx, vy } or nil when the cursor is off-map.
function M.draw_world_overlay(map_w, map_h, t_size, snap)
if state.get_mode() ~= "auto-tile" then return end
-- Grid lines (1 px) at every cell boundary.
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
-- Vertex dots (small 4 px squares centered on each vertex).
local dot_col = rgba(COL_VERTEX_DOT, 0xC0)
for vy = 0, map_h do
for vx = 0, map_w do
local cx = vx * t_size - 2
local cy = vy * t_size - 2
engine.render.draw_rect(cx, cy, 4, 4, dot_col)
end
end
-- Snapped vertex highlight (larger ring around the snap target).
if snap then
local px = snap.vx * t_size
local py = snap.vy * t_size
engine.render.draw_rect_lines(px - 6, py - 6, 12, 12,
rgba(COL_VERTEX_SNAP, 0xFF))
end
end
-- Dispatch a toolbar-button click to the appropriate action.
local function dispatch_toolbar_button(id)
if id == "mode_auto" then actions.set_mode("auto-tile")
@@ -562,6 +701,16 @@ local ui = (function()
end
end
-- Bottom palette swatches: left = select slot
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
-- Side-panel layer rows: left = set active; right = toggle visibility
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
@@ -617,7 +766,7 @@ input.bind("save_map", { "s" })
input.bind("toggle_mode", { "tab" }) -- 0.2.0a
input.bind("toggle_cheatsheet", { "i" }) -- 0.2.0a — `i` for Info/help (no shifted-key support in input lib)
engine.print("map-editor v0.2.0a: ready. Tab=mode, I=help, S=save, E=erase, R=rotate, ESC=quit")
engine.print("map-editor v0.2.0b: ready. Tab=mode, I=help, S=save, E=erase, R=rotate, ESC=quit")
-- =====================================================================
-- Frame hooks
@@ -650,6 +799,21 @@ function update(ctx, dt)
state.set_mouse_cell(nil)
end
-- 0.2.0b: snap to the nearest vertex (within the (W+1)x(H+1) grid).
-- math.floor((wx + ts/2) / ts) rounds to the nearest vertex index.
-- Only published while the cursor is over the map area (else nil).
if state.get_mode() == "auto-tile" then
local vx = math.floor((wx + t_size / 2) / t_size)
local vy = math.floor((wy + t_size / 2) / t_size)
if vx >= 0 and vy >= 0 and vx <= m_size.w and vy <= m_size.h then
state.set_snap_vertex(vx, vy)
else
state.set_snap_vertex(nil)
end
else
state.set_snap_vertex(nil)
end
-- Hotkey: rotate
if input.was_action_pressed("rotate") then
actions.cycle_rotation()
@@ -677,13 +841,22 @@ function update(ctx, dt)
-- Mouse clicks: hit-test UI first, then map area
if engine.input.was_mouse_pressed(engine.input.MOUSE_LEFT) then
local consumed = ui.handle_click(mx, my, "left")
if not consumed and state.get_mouse_cell() then
if not consumed then
if state.get_mode() == "auto-tile" then
local snap = state.get_snap_vertex()
if snap then actions.paint_vertex_at(snap.vx, snap.vy, true) end
elseif state.get_mouse_cell() then
local cell = state.get_mouse_cell()
actions.paint_cell_at(cell.x, cell.y)
end
end
end
if engine.input.was_mouse_pressed(engine.input.MOUSE_RIGHT) then
ui.handle_click(mx, my, "right")
local consumed = ui.handle_click(mx, my, "right")
if not consumed and state.get_mode() == "auto-tile" then
local snap = state.get_snap_vertex()
if snap then actions.paint_vertex_at(snap.vx, snap.vy, false) end
end
end
camera.update(dt)
@@ -692,6 +865,7 @@ end
function render(ctx)
camera.begin()
maps.draw_map()
ui.draw_world_overlay(m_size.w, m_size.h, t_size, state.get_snap_vertex())
camera.finish()
ui.draw() -- screen-space overlays after camera ends
end

View File

@@ -1,6 +1,6 @@
{
"id": "map-editor",
"version": "0.2.0a",
"version": "0.2.0b",
"kind": "module",
"api": "^0.1",
"ci_frames": 30,