diff --git a/init.lua b/init.lua index 6490f2b..060a33e 100644 --- a/init.lua +++ b/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 diff --git a/manifest.module b/manifest.module index f0d26fb..f195a11 100644 --- a/manifest.module +++ b/manifest.module @@ -1,6 +1,6 @@ { "id": "map-editor", - "version": "0.2.0a", + "version": "0.2.0b", "kind": "module", "api": "^0.1", "ci_frames": 30,