-- sporel-module-map-editor v0.2.0c.3 -- 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 -- 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. 0.2.0b.1 migrates -- work.map.json to schema-v3 + binds surface/wall layers to the -- blob_rect_stone material so Auto-Tile painting actually renders. -- 0.2.0c adds Transform controls (Flip H, Reset) + Direct-mode -- override paint that writes maps.set_override with the current -- {slot, rot, flip} on a material-bound terrain layer. Single -- `flip` flag (0/1) combined with rot (0..3) covers all 8 D4 -- orientations; H+V buttons are redundant so we ship just one. -- 0.2.0c.1 adds drag-paint: LMB-hold strokes a line of paints, RMB-hold -- a line of erases (both in Auto-Tile and Direct mode). UI clicks on -- toolbar / palette / side-panel never enter drag-mode. -- 0.2.0c.2 makes the palette show real tile thumbnails via the new -- lib-core.maps 0.5.4 atlas-handle / tile-UV getters. Falls back to -- the legacy colour swatch when atlas isn't loaded. -- 0.2.0c.3 adds the D-toggle debug overlay: per material cell on the -- active layer renders "Sr" (and "f" when flip=1) via the -- new lib-core.maps 0.5.5 cell_material_slot getter. Inline today; -- extract to lib-sporel.debug-overlay when a 2nd consumer appears. -- Decal/Entity sub-selectors + Material-Properties modal land in 0.2.0d. -- -- All editor logic in this single file: engine sandbox only allows -- require() for declared lib-deps; multi-file modules either use -- dofile (causes state divergence for shared singletons) or inline -- everything (vagrant-skeleton pattern). Inlined here via IIFE-wrapped -- local module tables. local maps = require("lib-core.maps") local camera = require("lib-core.camera") local input = require("lib-core.input") -- CI-mode auto-exit (matches vagrant-skeleton pattern). Manifest declares -- ci_frames=30 but engine does not auto-enforce for kind:"module"; we count -- frames ourselves when SPOREL_CI=1 and call engine.exit(0) on the 30th. local CI_MODE = (os.getenv("SPOREL_CI") == "1") local CI_FRAME_LIMIT = 30 local ci_frame_count = 0 -- ===================================================================== -- state (was src/state.lua) -- ===================================================================== local state = (function() local M = {} -- ===================================================================== -- State (module-singleton) -- ===================================================================== local state = { map_id = nil, -- set by init.lua after maps.load() map_path = "maps/work.map.json", active_layer = "surface", -- one of VALID_LAYER_NAMES active_atlas = 0, -- atlas_index 0..N-1 active_tile = 1, -- tile_id active_rot = 0, -- 0..3 active_flip = 0, -- 0.2.0c: 0 or 1 (single flip; combined with rot covers all 8 D4 orientations) mode = "auto-tile", -- 0.2.0a: "auto-tile" | "direct" roof_mode = false, -- if true, paint targets roof layer_visible = { foundation=true, subsurface=true, surface=true, topsurface=true, lower_wall=true, wall=true, upper_wall=true, canopy=true, }, dirty = false, 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) drag_paint = nil, -- 0.2.0c.1: nil | "paint" | "erase" (active drag-stroke mode) debug_overlay = false, -- 0.2.0c.3: D toggles per-cell slot/rot annotations } -- ===================================================================== -- Setters / getters (encapsulate state for testability) -- ===================================================================== function M.set_map(map_id, path) state.map_id = map_id state.map_path = path end function M.get_map_id() return state.map_id end function M.get_map_path() return state.map_path end function M.set_active_layer(name) state.active_layer = name end function M.get_active_layer() return state.active_layer end function M.set_active_atlas(idx) state.active_atlas = idx end function M.get_active_atlas() return state.active_atlas end function M.set_active_tile(id) state.active_tile = id end function M.get_active_tile() return state.active_tile end function M.cycle_rotation() state.active_rot = (state.active_rot + 1) % 4 end function M.get_active_rot() return state.active_rot end -- 0.2.0c: transform state for Direct-mode override paint. function M.toggle_flip() state.active_flip = (state.active_flip == 0) and 1 or 0 end function M.get_active_flip() return state.active_flip end function M.reset_transform() state.active_rot = 0 state.active_flip = 0 end function M.toggle_roof_mode() state.roof_mode = not state.roof_mode end function M.is_roof_mode() return state.roof_mode end function M.toggle_layer_visible(name) state.layer_visible[name] = not state.layer_visible[name] end function M.is_layer_visible(name) return state.layer_visible[name] ~= false end function M.mark_dirty() state.dirty = true end function M.mark_clean() state.dirty = false end function M.is_dirty() return state.dirty end function M.toggle_picker_open() state.picker_open = not state.picker_open end function M.is_picker_open() return state.picker_open end -- 0.2.0a: binary mode switch (Auto-Tile vs 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 end function M.toggle_mode() state.mode = (state.mode == "auto-tile") and "direct" or "auto-tile" end -- 0.2.0a: ?-modal cheatsheet visibility. function M.is_cheatsheet_open() return state.cheatsheet_open end function M.toggle_cheatsheet() state.cheatsheet_open = not state.cheatsheet_open end function M.set_mouse_cell(x, y) if x == nil then state.mouse_cell = nil else state.mouse_cell = { x = x, y = y } end 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 function M.set_drag_paint(mode) state.drag_paint = mode end -- 0.2.0c.1 function M.get_drag_paint() return state.drag_paint end function M.toggle_debug_overlay() state.debug_overlay = not state.debug_overlay end function M.is_debug_overlay() return state.debug_overlay end -- Used by tests to reset between asserts function M.reset() state.map_id = nil state.active_layer = "surface" state.active_atlas = 0 state.active_tile = 1 state.active_rot = 0 state.active_flip = 0 state.mode = "auto-tile" state.roof_mode = false state.dirty = false state.picker_open = false state.cheatsheet_open = false state.mouse_cell = nil state.snap_vertex = nil state.drag_paint = nil state.debug_overlay = false for name in pairs(state.layer_visible) do state.layer_visible[name] = true end end return M end)() -- ===================================================================== -- actions (was src/actions.lua) -- ===================================================================== local actions = (function() local M = {} -- ===================================================================== -- Cell painting -- ===================================================================== function M.paint_cell_at(cell_x, cell_y) if state.is_roof_mode() then maps.set_roof(cell_x, cell_y, 1) else local gid = maps.encode_gid( state.get_active_atlas(), state.get_active_tile(), state.get_active_rot() ) maps.set_cell_gid(state.get_active_layer(), cell_x, cell_y, gid) end state.mark_dirty() end function M.erase_cell_at(cell_x, cell_y) if state.is_roof_mode() then maps.set_roof(cell_x, cell_y, 0) else maps.set_cell_gid(state.get_active_layer(), cell_x, cell_y, 0) end 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 -- 0.2.0c: Direct-mode override paint on a material-bound terrain layer. -- Converts palette tile (1..14) -> lib slot (0..13). Writes bare slot -- in canonical orientation (rot=0+flip=0), else {slot, rot, flip} object. function M.paint_override_at(cell_x, cell_y) local slot = state.get_active_tile() - 1 -- palette 1..14 -> lib slot 0..13 local rot = state.get_active_rot() local flip = state.get_active_flip() if rot == 0 and flip == 0 then maps.set_override(state.get_active_layer(), cell_x, cell_y, slot) else maps.set_override(state.get_active_layer(), cell_x, cell_y, { slot = slot, rot = rot, flip = flip }) end state.mark_dirty() end function M.erase_override_at(cell_x, cell_y) maps.clear_override(state.get_active_layer(), cell_x, cell_y) state.mark_dirty() end -- ===================================================================== -- Hotkey-driven actions -- ===================================================================== function M.cycle_rotation() state.cycle_rotation() end function M.save() local id = state.get_map_id() local path = state.get_map_path() if not id or not path then engine.print("map-editor: cannot save — no map loaded") return end maps.save_to_disk(id, path) state.mark_clean() engine.print(string.format("map-editor: saved '%s' to %s", id, path)) end -- ===================================================================== -- UI-driven actions (called by ui.lua hit-tests) -- ===================================================================== function M.set_active_layer(layer_name) state.set_active_layer(layer_name) end function M.toggle_layer_visible(layer_name) state.toggle_layer_visible(layer_name) end function M.toggle_roof_mode() state.toggle_roof_mode() end function M.set_active_atlas(atlas_idx) state.set_active_atlas(atlas_idx) -- Reset to first tile of the new atlas state.set_active_tile(1) end function M.set_active_tile(tile_id) state.set_active_tile(tile_id) end function M.toggle_picker() state.toggle_picker_open() end -- 0.2.0a actions function M.toggle_mode() state.toggle_mode() end function M.set_mode(m) state.set_mode(m) end function M.toggle_cheatsheet() state.toggle_cheatsheet() end -- 0.2.0c actions function M.toggle_flip() state.toggle_flip() end function M.reset_transform() state.reset_transform() end -- 0.2.0c.3 actions function M.toggle_debug_overlay() state.toggle_debug_overlay() end return M end)() -- ===================================================================== -- ui (Rev 4 v0.2.0a — right-side Layers panel + top toolbar + cheat- -- sheet modal. Bottom palette comes in 0.2.0b; for now the legacy -- picker is hidden — paint behaviour unchanged.) -- ===================================================================== local ui = (function() local M = {} -- ===================================================================== -- Layout constants -- ===================================================================== -- Top toolbar (full-width, mode-switch + action buttons) local TOOLBAR_H = 32 local TOOLBAR_BUTTON_W = 88 local TOOLBAR_BUTTON_H = 24 local TOOLBAR_BUTTON_GAP = 6 local TOOLBAR_PADDING = 4 -- Side panel (right edge, layers vis + active) local SIDE_PANEL_W = 160 local LAYER_ROW_H = 22 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 = 320 local STATUS_H = 22 local STATUS_MARGIN = 8 -- Cheatsheet modal (centered overlay) local CHEAT_W = 380 local CHEAT_H = 280 -- Colours local COL_TOOLBAR_BG = 0x202024 local COL_PANEL_BG = 0x202024 local COL_BUTTON_BG = 0x303034 local COL_BUTTON_BG_HOVER = 0x40404A local COL_BUTTON_BG_ACTIVE = 0xE0E0E0 local COL_BUTTON_BORDER = 0xFFFFFF local COL_TEXT = 0xFFFFFF local COL_TEXT_ACTIVE = 0x202024 local COL_TEXT_MUTED = 0xA0A0A0 local COL_ROW_HOVER = 0x383844 local COL_ROW_ACTIVE = 0x504070 -- subtle purple for the active layer row 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 -- row corresponds to the topmost-rendered layer. local LAYER_PANEL_ROWS = { "canopy", "upper_wall", "wall", "lower_wall", "topsurface", "surface", "subsurface", "foundation", } -- Top-toolbar buttons (left-to-right). Action ids dispatched in -- handle_click; hotkey labels rendered on the button face. local TOOLBAR_BUTTONS = { { id="mode_auto", label="Auto-Tile", hotkey="Tab", group="mode" }, { id="mode_direct", label="Direct", hotkey="Tab", group="mode" }, { id="save", label="Save", hotkey="S" }, { id="erase", label="Erase", hotkey="E" }, { id="rotate", label="Rotate", hotkey="R" }, { id="flip", label="Flip", hotkey="H" }, { id="reset_xform", label="Reset", hotkey="0" }, { id="help", label="Help", hotkey="I" }, } -- Cheatsheet content (rendered into the modal). local CHEATSHEET = { { key="Tab", action="Toggle mode (Auto-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" }, { key="R", action="Cycle tile rotation 0->90->180->270 (Direct-mode override)" }, { key="H", action="Toggle tile flip 0<->1 (Direct-mode override)" }, { key="0", action="Reset transform (rot=0, flip=0)" }, { key="D", action="Toggle debug overlay (per-cell slot/rot labels)" }, { key="Esc", action="Quit editor" }, { key="LMB-drag canvas (Auto-Tile)", action="Stroke-paint vertices (any-corner fill)" }, { key="RMB-drag canvas (Auto-Tile)", action="Stroke-clear vertices" }, { key="LMB-drag canvas (Direct)", action="Stroke-paint cells (override with slot+rot+flip)" }, { key="RMB-drag canvas (Direct)", action="Stroke-clear overrides" }, { 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" }, } -- ===================================================================== -- Helpers -- ===================================================================== local function rgba(rgb, alpha) return (rgb << 8) | (alpha & 0xFF) end local function in_rect(mx, my, x, y, w, h) return mx >= x and mx < x + w and my >= y and my < y + h end -- ===================================================================== -- Layout computers (mode-aware; recomputed per frame for screen -- resize robustness — engine.window.size() is cheap). -- ===================================================================== local function toolbar_layout() local screen_w = engine.window.size() return { x = 0, y = 0, w = screen_w, h = TOOLBAR_H } end -- Returns array of { id, label, hotkey, group, x, y, w, h } for each -- toolbar button, packed left-to-right within the toolbar rect. local function toolbar_buttons_layout() local out = {} local x = TOOLBAR_PADDING local y = (TOOLBAR_H - TOOLBAR_BUTTON_H) / 2 for i, b in ipairs(TOOLBAR_BUTTONS) do out[i] = { id = b.id, label = b.label, hotkey = b.hotkey, group = b.group, x = x, y = y, w = TOOLBAR_BUTTON_W, h = TOOLBAR_BUTTON_H, } x = x + TOOLBAR_BUTTON_W + TOOLBAR_BUTTON_GAP end return out end local function side_panel_layout() local screen_w, screen_h = engine.window.size() return { x = screen_w - SIDE_PANEL_W, y = TOOLBAR_H, w = SIDE_PANEL_W, h = screen_h - TOOLBAR_H - PALETTE_H, } end -- Returns array of { name, x, y, w, h, eye_x, eye_y, eye_w, eye_h } -- for each layer row in the side panel. local function side_panel_rows_layout() local panel = side_panel_layout() local out = {} local row_y = panel.y + 6 for i, name in ipairs(LAYER_PANEL_ROWS) do out[i] = { name = name, x = panel.x + 4, y = row_y, w = panel.w - 8, h = LAYER_ROW_H, eye_x = panel.x + 6, eye_y = row_y + (LAYER_ROW_H - EYE_BOX) / 2, eye_w = EYE_BOX, eye_h = EYE_BOX, } row_y = row_y + LAYER_ROW_H + LAYER_ROW_GAP end 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 - PALETTE_H - STATUS_H - STATUS_MARGIN, w = STATUS_W, h = STATUS_H, } end local function cheatsheet_layout() local screen_w, screen_h = engine.window.size() return { x = (screen_w - CHEAT_W) / 2, y = (screen_h - CHEAT_H) / 2, w = CHEAT_W, h = CHEAT_H, } end -- ===================================================================== -- Drawing -- ===================================================================== -- Decide whether a toolbar button is in the "active" highlighted -- state — only mode-group buttons match the current mode. local function button_is_active(b) if b.group ~= "mode" then return false end if b.id == "mode_auto" and state.get_mode() == "auto-tile" then return true end if b.id == "mode_direct" and state.get_mode() == "direct" then return true end return false end local function draw_button(b, mx, my) local is_active = button_is_active(b) local is_hover = in_rect(mx, my, b.x, b.y, b.w, b.h) local bg = is_active and COL_BUTTON_BG_ACTIVE or is_hover and COL_BUTTON_BG_HOVER or COL_BUTTON_BG local text_col = is_active and COL_TEXT_ACTIVE or COL_TEXT engine.render.draw_rect(b.x, b.y, b.w, b.h, rgba(bg, 0xFF)) if is_active then engine.render.draw_rect_lines(b.x, b.y, b.w, b.h, rgba(COL_BUTTON_BORDER, 0xFF)) end engine.render.draw_text(b.label, b.x + 6, b.y + 4, 12, rgba(text_col, 0xFF)) if b.hotkey then engine.render.draw_text("(" .. b.hotkey .. ")", b.x + 6, b.y + 14, 9, rgba(text_col, 0xC0)) end end local function draw_top_toolbar(mx, my) local t = toolbar_layout() engine.render.draw_rect(t.x, t.y, t.w, t.h, rgba(COL_TOOLBAR_BG, 0xFF)) for _, b in ipairs(toolbar_buttons_layout()) do draw_button(b, mx, my) end -- Mode + active-layer label in the toolbar's right area local label = string.format("Mode: %s Layer: %s", state.get_mode(), state.get_active_layer()) local screen_w = engine.window.size() engine.render.draw_text(label, screen_w - SIDE_PANEL_W - 240, 10, 12, rgba(COL_TEXT_MUTED, 0xFF)) end local function draw_side_panel(mx, my) local p = side_panel_layout() engine.render.draw_rect(p.x, p.y, p.w, p.h, rgba(COL_PANEL_BG, 0xFF)) engine.render.draw_text("Layers", p.x + 6, p.y + 4, 11, rgba(COL_TEXT_MUTED, 0xFF)) for _, row in ipairs(side_panel_rows_layout()) do local is_active = (state.get_active_layer() == row.name) local is_hover = in_rect(mx, my, row.x, row.y, row.w, row.h) local is_visible = state.is_layer_visible(row.name) -- Row background (active highlight or hover dim) if is_active then engine.render.draw_rect(row.x, row.y, row.w, row.h, rgba(COL_ROW_ACTIVE, 0xFF)) elseif is_hover then engine.render.draw_rect(row.x, row.y, row.w, row.h, rgba(COL_ROW_HOVER, 0xFF)) end -- Eye-icon box engine.render.draw_rect_lines(row.eye_x, row.eye_y, row.eye_w, row.eye_h, rgba(COL_TEXT_MUTED, 0xFF)) local eye_label = is_visible and "+" or "x" engine.render.draw_text(eye_label, row.eye_x + 5, row.eye_y + 3, 12, rgba(is_visible and COL_TEXT or COL_HIDDEN_OVERLAY, 0xFF)) -- Layer name (with active marker) local marker = is_active and ">" or " " engine.render.draw_text(marker .. " " .. row.name, row.eye_x + row.eye_w + 6, row.y + 4, 12, rgba(COL_TEXT, 0xFF)) end -- Roof toggle below the layers list local last = side_panel_rows_layout()[#LAYER_PANEL_ROWS] local roof_y = last.y + last.h + 12 local roof_label = state.is_roof_mode() and "Roof: [ON]" or "Roof: [OFF]" engine.render.draw_text(roof_label, p.x + 6, roof_y, 12, 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)) -- 0.2.0c.2: real tile thumbnails via maps.atlas_tile_uv. Atlas -- index 0 (single-atlas maps); falls back to the colour swatch -- if the atlas / tile UV / texture isn't available. local atlas_idx = state.get_active_atlas() local tex = maps.atlas_diffuse_handle(atlas_idx) 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) -- Always draw a dark BG so transparent atlas tiles read. engine.render.draw_rect(sw.x, sw.y, sw.w, sw.h, rgba(0x282830, 0xFF)) -- Tile thumbnail: scale the atlas sub-tex to fit the swatch. local uv = tex and maps.atlas_tile_uv(atlas_idx, sw.slot_id - 1) if tex and uv then local scale_x = sw.w / uv.w local scale_y = sw.h / uv.h engine.render.draw_sprite_transform( tex, sw.x + sw.w / 2, sw.y + sw.h / 2, 0, scale_x, scale_y, uv.w / 2, uv.h / 2, 0xFFFFFFFF, uv.x, uv.y, uv.w, uv.h ) else -- Fallback: legacy coloured placeholder. engine.render.draw_rect(sw.x, sw.y, sw.w, sw.h, rgba(SWATCH_COLORS[sw.slot_id] or 0x808080, 0xFF)) end -- Border + label on top. 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)) 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)) engine.render.draw_rect_lines(s.x, s.y, s.w, s.h, rgba(COL_TEXT_MUTED, 0xFF)) local cell = state.get_mouse_cell() local cell_str = cell and string.format("Mouse: (%d,%d)", cell.x, cell.y) or "Mouse: (-,-)" local dirty_str = state.is_dirty() and " * unsaved" or "" -- 0.2.0c: append the current direct-paint transform state. local xform_str = string.format(" slot=%d rot=%d flip=%d", state.get_active_tile(), state.get_active_rot(), state.get_active_flip()) engine.render.draw_text(cell_str .. dirty_str .. xform_str, s.x + 4, s.y + 4, 12, rgba(COL_TEXT, 0xFF)) end local function draw_cheatsheet(mx, my) if not state.is_cheatsheet_open() then return end local c = cheatsheet_layout() -- Dim the whole screen behind the modal local screen_w, screen_h = engine.window.size() engine.render.draw_rect(0, 0, screen_w, screen_h, rgba(0x000000, 0xA0)) engine.render.draw_rect(c.x, c.y, c.w, c.h, rgba(COL_MODAL_BG, 0xFF)) engine.render.draw_rect_lines(c.x, c.y, c.w, c.h, rgba(COL_MODAL_BORDER, 0xFF)) engine.render.draw_text("Hotkeys (I to close)", c.x + 12, c.y + 10, 14, rgba(COL_TEXT, 0xFF)) local row_y = c.y + 38 for _, entry in ipairs(CHEATSHEET) do engine.render.draw_text(entry.key, c.x + 16, row_y, 12, rgba(COL_TEXT, 0xFF)) engine.render.draw_text(entry.action, c.x + 140, row_y, 12, rgba(COL_TEXT_MUTED, 0xFF)) row_y = row_y + 20 end end -- ===================================================================== -- Public API -- ===================================================================== function M.draw() 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 -- 0.2.0c.3 debug overlay: per-cell slot/rot annotation. Independent -- of mode toggle — useful in both Auto-Tile (verify bitmask path) -- and Direct (verify override write). Iterates the active layer. function M.draw_debug_overlay(map_w, map_h, t_size) if not state.is_debug_overlay() then return end local layer = state.get_active_layer() for y = 0, map_h - 1 do for x = 0, map_w - 1 do local rec = maps.cell_material_slot(layer, x, y) if rec then -- text at cell center, e.g. "S7r1" or "S13r0f1" local txt = string.format("S%dr%d%s", rec.slot, rec.rot, rec.flip == 1 and "f" or "") engine.render.draw_text(txt, x * t_size + 4, y * t_size + 4, 9, rgba(COL_VERTEX_SNAP, 0xFF)) end end end 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") elseif id == "mode_direct" then actions.set_mode("direct") elseif id == "save" then actions.save() elseif id == "erase" then local cell = state.get_mouse_cell() if cell then actions.erase_cell_at(cell.x, cell.y) end elseif id == "rotate" then actions.cycle_rotation() elseif id == "flip" then actions.toggle_flip() elseif id == "reset_xform" then actions.reset_transform() elseif id == "help" then actions.toggle_cheatsheet() end end -- Hit-test mouse click. Returns true if the click was consumed by UI. -- button: "left" | "right" function M.handle_click(mx, my, button) -- Cheatsheet modal absorbs all clicks while open (any click closes it) if state.is_cheatsheet_open() then actions.toggle_cheatsheet() return true end -- Top toolbar buttons for _, b in ipairs(toolbar_buttons_layout()) do if in_rect(mx, my, b.x, b.y, b.w, b.h) then if button == "left" then dispatch_toolbar_button(b.id) end return true 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 if button == "left" then actions.toggle_layer_visible(row.name) end return true end if in_rect(mx, my, row.x, row.y, row.w, row.h) then if button == "left" then actions.set_active_layer(row.name) elseif button == "right" then actions.toggle_layer_visible(row.name) end return true end end -- Status chip — no-op fallback to claim the click area local s = status_layout() if in_rect(mx, my, s.x, s.y, s.w, s.h) then return true end return false -- click falls through to map-area handler in init.lua end return M end)() -- ===================================================================== -- Setup + Frame hooks (was the rest of original init.lua) -- ===================================================================== local WORK_PATH = "maps/work.map.json" local work_map_id = maps.load(WORK_PATH) maps.set_current(work_map_id) maps.load_textures(engine.module.asset_aliases()) state.set_map(work_map_id, WORK_PATH) -- Center camera on the map local m_size = maps.size() local t_size = maps.tile_size() camera.set_target((m_size.w * t_size) / 2, (m_size.h * t_size) / 2) camera.set_zoom(1.0) -- ===================================================================== -- Input bindings -- ===================================================================== input.bind("quit", { "escape" }) input.bind("erase", { "e" }) input.bind("rotate", { "r" }) 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) input.bind("flip", { "h" }) -- 0.2.0c input.bind("reset_transform", { "0" }) -- 0.2.0c input.bind("toggle_debug_overlay", { "d" }) -- 0.2.0c.3 engine.print("map-editor v0.2.0c.3: ready. Tab=mode, I=help, D=debug, S=save, E=erase, R=rotate, H=flip, 0=reset, ESC=quit") -- ===================================================================== -- Frame hooks -- ===================================================================== function update(ctx, dt) -- CI-mode auto-exit (smoke harness) if CI_MODE then ci_frame_count = ci_frame_count + 1 if ci_frame_count >= CI_FRAME_LIMIT then engine.print(string.format("map-editor: ci_frames_ok=%d", ci_frame_count)) engine.exit(0) return end end -- Quit if input.was_action_pressed("quit") then engine.exit() return end -- Update mouse-cell tracking for status chip local mx, my = engine.input.get_mouse_pos() local wx, wy = camera.screen_to_world(mx, my) local cell_x = math.floor(wx / t_size) local cell_y = math.floor(wy / t_size) if cell_x >= 0 and cell_y >= 0 and cell_x < m_size.w and cell_y < m_size.h then state.set_mouse_cell(cell_x, cell_y) else 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() end -- Hotkey: save if input.was_action_pressed("save_map") then actions.save() end -- Hotkey: erase (uses current mouse-hover cell) if input.was_action_pressed("erase") then local cell = state.get_mouse_cell() if cell then actions.erase_cell_at(cell.x, cell.y) end end -- 0.2.0a hotkeys: mode toggle + cheatsheet modal if input.was_action_pressed("toggle_mode") then actions.toggle_mode() end if input.was_action_pressed("toggle_cheatsheet") then actions.toggle_cheatsheet() end -- 0.2.0c hotkeys: transform controls (Direct-mode override paint) if input.was_action_pressed("flip") then actions.toggle_flip() end if input.was_action_pressed("reset_transform") then actions.reset_transform() end if input.was_action_pressed("toggle_debug_overlay") then actions.toggle_debug_overlay() end -- Mouse clicks + drag: hit-test UI on press; if consumed we never -- enter drag-mode for this stroke. Otherwise the press initiates a -- drag-stroke whose paint/erase action repeats for every subsequent -- snap_vertex (or cell) while the button stays down. set_vertex / -- set_override are idempotent + no-op when state is unchanged so -- repeating per-frame is cheap. if engine.input.was_mouse_pressed(engine.input.MOUSE_LEFT) then local consumed = ui.handle_click(mx, my, "left") if not consumed then state.set_drag_paint("paint") end end if engine.input.was_mouse_pressed(engine.input.MOUSE_RIGHT) then local consumed = ui.handle_click(mx, my, "right") if not consumed then state.set_drag_paint("erase") end end -- Per-frame drag-paint dispatch (Auto-Tile uses snap_vertex; Direct -- uses mouse_cell). Stops as soon as the originating button releases. local dp = state.get_drag_paint() if dp then local lmb_down = engine.input.is_mouse_down(engine.input.MOUSE_LEFT) local rmb_down = engine.input.is_mouse_down(engine.input.MOUSE_RIGHT) local active = (dp == "paint" and lmb_down) or (dp == "erase" and rmb_down) if not active then state.set_drag_paint(nil) else if state.get_mode() == "auto-tile" then local snap = state.get_snap_vertex() if snap then actions.paint_vertex_at(snap.vx, snap.vy, dp == "paint") end else local cell = state.get_mouse_cell() if cell then if dp == "paint" then actions.paint_override_at(cell.x, cell.y) else actions.erase_override_at(cell.x, cell.y) end end end end end camera.update(dt) 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()) ui.draw_debug_overlay(m_size.w, m_size.h, t_size) camera.finish() ui.draw() -- screen-space overlays after camera ends end