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:
206
init.lua
206
init.lua
@@ -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
|
||||
local cell = state.get_mouse_cell()
|
||||
actions.paint_cell_at(cell.x, cell.y)
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user