-- sporel-module-map-editor v0.3.0 -- 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) menu_open = nil, -- 0.3.0: nil | (e.g. "File") submenu_open = nil, -- 0.3.0: label of the open submenu item within menu_open modal_open = nil, -- 0.3.0: nil | (single-stack) modal_text_input = "", -- 0.3.0: scratch buffer for modal text inputs show_layers_panel = true, -- 0.3.0: Window > Layers Panel toggle show_palette = true, -- 0.3.0: Window > Palette toggle 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 world_overlay = true, -- 0.2.0c.4: G toggles render-grid + map-grid + coords } -- ===================================================================== -- 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). -- 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 == "cell-tile" or m == "direct" then state.mode = m end end 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 return state.modal_open == id end function M.open_modal(id) state.modal_open = id end function M.close_modal() state.modal_open = nil end function M.get_modal_open() return state.modal_open end -- 0.3.0: modal text-input buffer accessors function M.get_modal_input() return state.modal_text_input end function M.set_modal_input(s) state.modal_text_input = s end function M.append_modal_input(s) state.modal_text_input = state.modal_text_input .. s end function M.backspace_modal_input() local s = state.modal_text_input state.modal_text_input = s:sub(1, -2) end function M.set_menu_open(name) state.menu_open = name end function M.get_menu_open() return state.menu_open end function M.close_menu() state.menu_open = nil state.submenu_open = nil end function M.set_submenu_open(label) state.submenu_open = label end function M.get_submenu_open() return state.submenu_open end function M.close_submenu() state.submenu_open = nil end function M.is_layers_panel_shown() return state.show_layers_panel end function M.toggle_layers_panel() state.show_layers_panel = not state.show_layers_panel end function M.is_palette_shown() return state.show_palette end function M.toggle_palette() state.show_palette = not state.show_palette 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 function M.toggle_world_overlay() state.world_overlay = not state.world_overlay end function M.is_world_overlay() return state.world_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.mouse_cell = nil state.snap_vertex = nil state.drag_paint = nil state.menu_open = nil state.submenu_open = nil state.modal_open = nil state.modal_text_input = "" state.show_layers_panel = true state.show_palette = true state.debug_overlay = false state.world_overlay = true 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.open_cheatsheet() state.open_modal("cheatsheet") end function M.close_modal() state.close_modal() end function M.close_menu() state.close_menu() 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 function M.toggle_world_overlay() state.toggle_world_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 -- ===================================================================== -- Menu bar local MENU_BAR_H = 24 local MENU_LABEL_PAD_X = 12 local MODE_PILL_W = 195 local MODE_PILL_H = 18 local MODE_PILL_SEG_W = 65 local MODE_PILL_MARGIN = 4 -- Dropdown local DROPDOWN_PAD_Y = 4 local DROPDOWN_ITEM_H = 22 local DROPDOWN_INDICATOR_W = 18 local DROPDOWN_HOTKEY_W = 60 local DROPDOWN_BORDER = 1 local DROPDOWN_MIN_W = 180 local DROPDOWN_PAD_X = 10 -- 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 local COL_CHECK = 0x60E060 local COL_TEXT_DISABLED = 0x606068 -- 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", } -- Item-arrays for the dropdown system (T9). -- mode_is must be declared before the arrays so the disabled-callbacks -- capture it as a proper upvalue (Lua resolves upvalues at closure -- creation time). local function mode_is(m) return state.get_mode() == m end local FILE_ITEMS = { { type = "modal", label = "New Map", modal_id = "new_map", disabled = true }, { type = "modal", label = "Open Map", modal_id = "open_map", disabled = true }, { type = "action", label = "Save", hotkey = "S", fire = function() actions.save() end }, { type = "modal", label = "Save As", modal_id = "save_as" }, { type = "separator" }, { type = "action", label = "Quit", hotkey = "Esc", fire = function() engine.exit() end }, } local EDIT_ITEMS = { { type = "action", label = "Undo", hotkey = "Ctrl+Z", disabled = true, fire = function() end }, { type = "action", label = "Redo", hotkey = "Ctrl+Y", disabled = true, fire = function() end }, { type = "separator" }, { type = "action", label = "Cycle Rotation", hotkey = "R", disabled = function() return not mode_is("direct") end, fire = function() actions.cycle_rotation() end }, { type = "action", label = "Toggle Flip", hotkey = "H", disabled = function() return not mode_is("direct") end, fire = function() actions.toggle_flip() end }, { type = "action", label = "Reset Transform", hotkey = "0", disabled = function() return not mode_is("direct") end, fire = function() actions.reset_transform() end }, { type = "separator" }, { type = "action", label = "Erase Cell", hotkey = "E", fire = function() local c = state.get_mouse_cell() if c then actions.erase_cell_at(c.x, c.y) end end }, } local VIEW_ITEMS = { { type = "toggle", label = "World Overlay", hotkey = "G", is_on = function() return state.is_world_overlay() end, fire = function() actions.toggle_world_overlay() end }, { type = "toggle", label = "Debug Overlay", hotkey = "D", is_on = function() return state.is_debug_overlay() end, fire = function() actions.toggle_debug_overlay() end }, { type = "separator" }, { type = "toggle", label = "Roof Mode", is_on = function() return state.is_roof_mode() end, fire = function() actions.toggle_roof_mode() end }, { type = "separator" }, { type = "submenu", label = "Layers", items = nil }, -- populated in T10 } local MAP_ITEMS = { { type = "modal", label = "Map Properties", modal_id = "map_properties" }, { type = "separator" }, { type = "action", label = "Reload from Disk", fire = function() local id = maps.load(state.get_map_path()) maps.set_current(id) state.mark_clean() end }, } local TOOLS_ITEMS = { { type = "submenu", label = "Mode", items = nil }, -- populated in T10 } do local layer_items = {} for _, name in ipairs(LAYER_PANEL_ROWS) do local layer_name = name layer_items[#layer_items + 1] = { type = "toggle", label = name, is_on = function() return state.is_layer_visible(layer_name) end, fire = function() state.toggle_layer_visible(layer_name) end, } end for _, it in ipairs(VIEW_ITEMS) do if it.type == "submenu" and it.label == "Layers" then it.items = layer_items break end end end do local mode_items = { { 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 }, } for _, it in ipairs(TOOLS_ITEMS) do if it.type == "submenu" and it.label == "Mode" then it.items = mode_items break end end end local WINDOW_ITEMS = { { type = "toggle", label = "Layers Panel", is_on = function() return state.is_layers_panel_shown() end, fire = function() state.toggle_layers_panel() end }, { type = "toggle", label = "Palette", is_on = function() return state.is_palette_shown() end, fire = function() state.toggle_palette() end }, } local HELP_ITEMS = { { type = "modal", label = "Cheatsheet", hotkey = "I", modal_id = "cheatsheet" }, { type = "separator" }, { type = "modal", label = "About", modal_id = "about" }, } local MENU_CATEGORIES = { { name = "File", items = FILE_ITEMS }, { name = "Edit", items = EDIT_ITEMS }, { name = "View", items = VIEW_ITEMS }, { name = "Map", items = MAP_ITEMS }, { name = "Tools", items = TOOLS_ITEMS }, { name = "Window", items = WINDOW_ITEMS }, { name = "Help", items = HELP_ITEMS }, } -- 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 only)" }, { key="H", action="Toggle tile flip 0<->1 (Direct only)" }, { key="0", action="Reset transform (rot=0, flip=0)" }, { key="D", action="Toggle debug overlay (per-cell slot/rot labels)" }, { key="G", action="Toggle world overlay" }, { key="Esc", action="Close modal / close menu / quit" }, { key="LMB on menu category", action="Open dropdown" }, { key="LMB-drag canvas (Auto-Tile)", action="Stroke-paint map-tiles (any-corner fill)" }, { key="RMB-drag canvas (Auto-Tile)", action="Stroke-clear map-tiles" }, { 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" }, } -- ===================================================================== -- Modal framework (0.3.0) -- Each modal: { title, w, h, render(mx, my), on_click(mx, my, btn) -> close? } -- Single-stack: only state.modal_open at a time. Esc + click-outside -- handled centrally in the dispatcher. -- ===================================================================== local MODALS = {} -- populated below -- Dispatcher returns true if the click was consumed (always true when a -- modal is open — modals fully absorb input). local function dispatch_modal_click(mx, my, button) local id = state.get_modal_open() if not id then return false end local m = MODALS[id] if not m then -- Defensive: unknown id, close it. state.close_modal() return true end local close = m.on_click(mx, my, button) if close then state.close_modal() end return true end -- ===================================================================== -- 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). -- ===================================================================== -- 0.3.0: Mode-pill anchored to the right end of the menu bar. local function mode_pill_layout() local screen_w = engine.window.size() local x = screen_w - MODE_PILL_MARGIN - MODE_PILL_W 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" }, 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.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 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 -- Returns the hit-rect table for each menu-bar category label. -- Uses engine.render.measure_text (exposed in P.3.4). local function menu_bar_categories_layout() local out = {} local x = MENU_LABEL_PAD_X local font_size = 12 for _, cat in ipairs(MENU_CATEGORIES) do local label_w = engine.render.measure_text(cat.name, font_size) local hit_w = label_w + MENU_LABEL_PAD_X * 2 out[#out + 1] = { name = cat.name, items = cat.items, x = x, y = 0, w = hit_w, h = MENU_BAR_H, label_x = x + MENU_LABEL_PAD_X, } x = x + hit_w end return out end local function dropdown_width_for(items) local w = DROPDOWN_MIN_W for _, it in ipairs(items) do if it.type ~= "separator" then local label_w = engine.render.measure_text(it.label or "", 12) local needed = DROPDOWN_INDICATOR_W + label_w + DROPDOWN_HOTKEY_W + 2 * DROPDOWN_PAD_X if needed > w then w = needed end end end return w end local function dropdown_layout(cat_layout) local items = cat_layout.items local w = dropdown_width_for(items) local rows = {} local y = cat_layout.y + cat_layout.h + DROPDOWN_PAD_Y for i, it in ipairs(items) do local row_h = (it.type == "separator") and 6 or DROPDOWN_ITEM_H rows[i] = { item = it, x = cat_layout.x, y = y, w = w, h = row_h } y = y + row_h end return { x = cat_layout.x, y = cat_layout.y + cat_layout.h + DROPDOWN_PAD_Y, w = w, h = y - (cat_layout.y + cat_layout.h + DROPDOWN_PAD_Y) + DROPDOWN_PAD_Y, rows = rows, } end local function item_is_disabled(item) local d = item.disabled if type(d) == "function" then return d() end return d == true end local function draw_dropdown_item(row, hovered) local it = row.item if it.type == "separator" then engine.render.draw_line(row.x + 6, row.y + 3, row.x + row.w - 6, row.y + 3, rgba(COL_TEXT_MUTED, 0x60)) return end local disabled = item_is_disabled(it) if hovered and not disabled then engine.render.draw_rect(row.x, row.y, row.w, row.h, rgba(COL_ROW_HOVER, 0xFF)) end local text_col = disabled and COL_TEXT_DISABLED or COL_TEXT -- Left indicator gutter (toggle: [v] / [ ]) if it.type == "toggle" then local on = it.is_on and it.is_on() or false engine.render.draw_text(on and "[v]" or "[ ]", row.x + 4, row.y + 5, 11, rgba(on and COL_CHECK or COL_TEXT_MUTED, 0xFF)) end -- Label local label = it.label or "" if it.type == "modal" then label = label .. "..." end engine.render.draw_text(label, row.x + DROPDOWN_INDICATOR_W + 2, row.y + 5, 12, rgba(text_col, 0xFF)) -- Right column: hotkey or submenu chevron if it.type == "submenu" then engine.render.draw_text(">", row.x + row.w - DROPDOWN_PAD_X - 6, row.y + 5, 12, rgba(COL_TEXT_MUTED, 0xFF)) elseif it.hotkey then engine.render.draw_text(it.hotkey, row.x + row.w - DROPDOWN_HOTKEY_W, row.y + 5, 11, rgba(COL_TEXT_MUTED, 0xFF)) end end local function draw_dropdown(cat_layout, mx, my) local dd = dropdown_layout(cat_layout) engine.render.draw_rect(dd.x, dd.y, dd.w, dd.h, rgba(COL_MODAL_BG, 0xFA)) engine.render.draw_rect_lines(dd.x, dd.y, dd.w, dd.h, rgba(COL_MODAL_BORDER, 0xFF)) for _, row in ipairs(dd.rows) do local hovered = in_rect(mx, my, row.x, row.y, row.w, row.h) draw_dropdown_item(row, hovered) end -- Submenu render (one level deep) local sub_label = state.get_submenu_open() if sub_label then for _, row in ipairs(dd.rows) do local it = row.item if it.type == "submenu" and it.label == sub_label and it.items then local sub_w = dropdown_width_for(it.items) local sub_x = dd.x + dd.w local sub_y = row.y local sub_h = 0 for _, si in ipairs(it.items) do sub_h = sub_h + ((si.type == "separator") and 6 or DROPDOWN_ITEM_H) end sub_h = sub_h + DROPDOWN_PAD_Y * 2 engine.render.draw_rect(sub_x, sub_y, sub_w, sub_h, rgba(COL_MODAL_BG, 0xFA)) engine.render.draw_rect_lines(sub_x, sub_y, sub_w, sub_h, rgba(COL_MODAL_BORDER, 0xFF)) local sy = sub_y + DROPDOWN_PAD_Y for _, si in ipairs(it.items) do local row_h = (si.type == "separator") and 6 or DROPDOWN_ITEM_H local sub_row = { item = si, x = sub_x, y = sy, w = sub_w, h = row_h } local hovered = in_rect(mx, my, sub_row.x, sub_row.y, sub_row.w, sub_row.h) draw_dropdown_item(sub_row, hovered) sy = sy + row_h end break end end end end local function fire_item(it) if item_is_disabled(it) then return false end -- absorbed, no close if it.type == "action" then if it.fire then it.fire() end return true -- close menu elseif it.type == "modal" then if it.modal_id then state.open_modal(it.modal_id) end return true elseif it.type == "toggle" then if it.fire then it.fire() end return true elseif it.type == "submenu" then return false -- handled separately end return false end local function draw_menu_bar(mx, my) local screen_w = engine.window.size() engine.render.draw_rect(0, 0, screen_w, MENU_BAR_H, rgba(COL_TOOLBAR_BG, 0xFF)) local open = state.get_menu_open() for _, cat in ipairs(menu_bar_categories_layout()) do local hovered = in_rect(mx, my, cat.x, cat.y, cat.w, cat.h) local active = (open == cat.name) if active or hovered then engine.render.draw_rect(cat.x, cat.y, cat.w, cat.h, rgba(active and COL_BUTTON_BG_ACTIVE or COL_BUTTON_BG_HOVER, 0xFF)) end local fg = active and COL_TEXT_ACTIVE or COL_TEXT engine.render.draw_text(cat.name, cat.label_x, 5, 12, rgba(fg, 0xFF)) end end local function side_panel_layout() local screen_w, screen_h = engine.window.size() return { x = screen_w - SIDE_PANEL_W, y = MENU_BAR_H, w = SIDE_PANEL_W, h = screen_h - MENU_BAR_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 -- MODALS entries (defined here, after helpers + layout fns are declared, -- so closures can reference rgba / cheatsheet_layout as upvalues). MODALS.cheatsheet = { title = "Hotkeys (I to close)", w = CHEAT_W, h = CHEAT_H, render = function(mx, my) 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, on_click = function(mx, my, button) local c = cheatsheet_layout() return in_rect(mx, my, c.x, c.y, c.w, c.h) -- click inside closes end, } -- 0.3.0: save_as — text input degraded (no poll_char API exposed to Lua); -- filename is auto-generated as untitled-.map.json on Save. MODALS.save_as = { title = "Save As", w = 360, h = 140, render = function(mx, my) local screen_w, screen_h = engine.window.size() local x = (screen_w - 360) / 2 local y = (screen_h - 140) / 2 engine.render.draw_rect(x, y, 360, 140, rgba(COL_MODAL_BG, 0xF0)) engine.render.draw_rect_lines(x, y, 360, 140, rgba(COL_MODAL_BORDER, 0xFF)) engine.render.draw_text("Save As", x + 12, y + 10, 14, rgba(COL_TEXT, 0xFF)) engine.render.draw_text("Will save as:", x + 12, y + 40, 11, rgba(COL_TEXT_MUTED, 0xFF)) local preview = string.format("untitled-%d.map.json", os.time()) engine.render.draw_rect(x + 12, y + 58, 336, 22, rgba(COL_BUTTON_BG, 0xFF)) engine.render.draw_text(preview, x + 18, y + 62, 11, rgba(COL_TEXT_MUTED, 0xFF)) engine.render.draw_rect(x + 200, y + 100, 70, 24, rgba(COL_BUTTON_BG, 0xFF)) engine.render.draw_rect_lines(x + 200, y + 100, 70, 24, rgba(COL_BUTTON_BORDER, 0xFF)) engine.render.draw_text("Save", x + 220, y + 106, 12, rgba(COL_TEXT, 0xFF)) engine.render.draw_rect(x + 278, y + 100, 70, 24, rgba(COL_BUTTON_BG, 0xFF)) engine.render.draw_rect_lines(x + 278, y + 100, 70, 24, rgba(COL_BUTTON_BORDER, 0xFF)) engine.render.draw_text("Cancel", x + 292, y + 106, 12, rgba(COL_TEXT, 0xFF)) end, on_click = function(mx, my, button) local screen_w, screen_h = engine.window.size() local x = (screen_w - 360) / 2 local y = (screen_h - 140) / 2 if in_rect(mx, my, x + 200, y + 100, 70, 24) then local name = string.format("untitled-%d.map.json", os.time()) maps.save_to_disk(state.get_map_id(), "maps/" .. name) return true end if in_rect(mx, my, x + 278, y + 100, 70, 24) then return true end return false end, } -- 0.3.0: map_properties — read-only inspector (map id, size, atlas list). MODALS.map_properties = { title = "Map Properties", w = 420, h = 320, render = function(mx, my) local screen_w, screen_h = engine.window.size() local x = (screen_w - 420) / 2 local y = (screen_h - 320) / 2 engine.render.draw_rect(x, y, 420, 320, rgba(COL_MODAL_BG, 0xF0)) engine.render.draw_rect_lines(x, y, 420, 320, rgba(COL_MODAL_BORDER, 0xFF)) engine.render.draw_text("Map Properties", x + 12, y + 10, 14, rgba(COL_TEXT, 0xFF)) local id = state.get_map_id() or "(none)" engine.render.draw_text("ID: " .. id, x + 12, y + 40, 11, rgba(COL_TEXT, 0xFF)) local sz = maps.size() local size_str = sz and (sz.w .. " x " .. sz.h) or "(unknown)" engine.render.draw_text("Size: " .. size_str, x + 12, y + 60, 11, rgba(COL_TEXT, 0xFF)) local ac = maps.atlas_count() or 0 engine.render.draw_text("Atlases: " .. tostring(ac), x + 12, y + 80, 11, rgba(COL_TEXT, 0xFF)) for i = 0, ac - 1 do engine.render.draw_text(" [" .. i .. "] " .. (maps.atlas_id_at(i) or "?"), x + 12, y + 100 + i * 16, 11, rgba(COL_TEXT_MUTED, 0xFF)) end engine.render.draw_rect(x + 340, y + 280, 70, 24, rgba(COL_BUTTON_BG, 0xFF)) engine.render.draw_rect_lines(x + 340, y + 280, 70, 24, rgba(COL_BUTTON_BORDER, 0xFF)) engine.render.draw_text("Close", x + 358, y + 286, 12, rgba(COL_TEXT, 0xFF)) end, on_click = function(mx, my, button) local screen_w, screen_h = engine.window.size() local x = (screen_w - 420) / 2 local y = (screen_h - 320) / 2 if in_rect(mx, my, x + 340, y + 280, 70, 24) then return true end return false end, } -- 0.3.0: about — static text only. MODALS.about = { title = "About", w = 320, h = 200, render = function(mx, my) local screen_w, screen_h = engine.window.size() local x = (screen_w - 320) / 2 local y = (screen_h - 200) / 2 engine.render.draw_rect(x, y, 320, 200, rgba(COL_MODAL_BG, 0xF0)) engine.render.draw_rect_lines(x, y, 320, 200, rgba(COL_MODAL_BORDER, 0xFF)) engine.render.draw_text("Sporel Map Editor", x + 12, y + 14, 14, rgba(COL_TEXT, 0xFF)) engine.render.draw_text("Module: map-editor v0.3.0", x + 12, y + 44, 11, rgba(COL_TEXT, 0xFF)) engine.render.draw_text("Maps lib: lib-core.maps v0.5.6", x + 12, y + 62, 11, rgba(COL_TEXT, 0xFF)) engine.render.draw_rect(x + 240, y + 160, 70, 24, rgba(COL_BUTTON_BG, 0xFF)) engine.render.draw_rect_lines(x + 240, y + 160, 70, 24, rgba(COL_BUTTON_BORDER, 0xFF)) engine.render.draw_text("Close", x + 258, y + 166, 12, rgba(COL_TEXT, 0xFF)) end, on_click = function(mx, my, button) local screen_w, screen_h = engine.window.size() local x = (screen_w - 320) / 2 local y = (screen_h - 200) / 2 if in_rect(mx, my, x + 240, y + 160, 70, 24) then return true end return false end, } -- ===================================================================== -- Drawing -- ===================================================================== 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 -- ===================================================================== -- Public API -- ===================================================================== function M.draw() local mx, my = engine.input.get_mouse_pos() draw_menu_bar(mx, my) local open_name = state.get_menu_open() if open_name then for _, cat in ipairs(menu_bar_categories_layout()) do if cat.name == open_name then draw_dropdown(cat, mx, my) break end end end draw_mode_pill() if state.is_layers_panel_shown() then draw_side_panel(mx, my) end if state.is_palette_shown() then draw_palette(mx, my) end draw_status(mx, my) local mid = state.get_modal_open() if mid and MODALS[mid] then MODALS[mid].render(mx, my) end 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. -- 0.2.0c.4: G-toggle gates the overlay; the displayed grid is -- mode-aware. Auto-tile mode paints map-tiles, so we show the -- map-grid (paint-tile boundaries, offset by half a tile from -- render-cell boundaries) plus the vertex dots and snap target. -- Direct mode paints cells, so we show the render-cell grid with -- a lighter stroke and skip the dots / snap. -- snap = { vx, vy } or nil when the cursor is off-map. function M.draw_world_overlay(map_w, map_h, t_size, snap) if not state.is_world_overlay() then return end local mode = state.get_mode() if mode == "auto-tile" then -- Map-grid lines: boundaries between adjacent map-tiles, which -- in dual-grid sit at half-tile offsets from render-cell -- boundaries (= at render-cell centres). local map_col = rgba(COL_VERTEX_GRID, 0x60) local half = t_size / 2 for x = 0, map_w - 1 do local px = x * t_size + half engine.render.draw_line(px, 0, px, map_h * t_size, map_col) end for y = 0, map_h - 1 do local py = y * t_size + half engine.render.draw_line(0, py, map_w * t_size, py, map_col) end -- Vertex dots (paint snap-targets, one per map-tile centre). 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 (where LMB will paint). 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 else -- Direct mode: subtle render-cell grid only — that's the -- grid LMB strokes write to. local cell_col = rgba(COL_VERTEX_GRID, 0x25) for x = 0, map_w do engine.render.draw_line(x * t_size, 0, x * t_size, map_h * t_size, cell_col) end for y = 0, map_h do engine.render.draw_line(0, y * t_size, map_w * t_size, y * t_size, cell_col) end 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) -- Modal absorbs all clicks while open. if dispatch_modal_click(mx, my, button) then return true end -- Mode-pill local pill = mode_pill_layout() 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) end return true end end -- Menu bar top-level labels for _, cat in ipairs(menu_bar_categories_layout()) do if in_rect(mx, my, cat.x, cat.y, cat.w, cat.h) then if button == "left" then if state.get_menu_open() == cat.name then state.close_menu() else state.set_menu_open(cat.name) end end return true end end -- Dropdown items local open_name = state.get_menu_open() if open_name then for _, cat in ipairs(menu_bar_categories_layout()) do if cat.name == open_name then local dd = dropdown_layout(cat) if in_rect(mx, my, dd.x, dd.y, dd.w, dd.h) then if button == "left" then for _, row in ipairs(dd.rows) do if in_rect(mx, my, row.x, row.y, row.w, row.h) then if row.item.type == "submenu" then state.set_submenu_open(row.item.label) elseif row.item.type ~= "separator" then if fire_item(row.item) then state.close_menu() end end return true end end end return true -- empty area inside dropdown, absorbed end break end end -- Submenu items (one level deep) local sub_label = state.get_submenu_open() if sub_label then for _, cat in ipairs(menu_bar_categories_layout()) do if cat.name == open_name then local dd2 = dropdown_layout(cat) for _, row in ipairs(dd2.rows) do local it = row.item if it.type == "submenu" and it.label == sub_label and it.items then local sub_x = dd2.x + dd2.w local sub_y = row.y local sy = sub_y + DROPDOWN_PAD_Y for _, si in ipairs(it.items) do local row_h = (si.type == "separator") and 6 or DROPDOWN_ITEM_H if in_rect(mx, my, sub_x, sy, dropdown_width_for(it.items), row_h) then if si.type ~= "separator" then if fire_item(si) then state.close_menu() end end return true end sy = sy + row_h end break end end break end end end -- Click outside the dropdown closes it (no return — fall through so -- the click can also hit other UI like canvas). state.close_menu() end -- Bottom palette swatches: left = select slot if state.is_palette_shown() then 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 end -- Side-panel layer rows: left = set active; right = toggle visibility if state.is_layers_panel_shown() then 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 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 -- Expose internal tables needed by self_test (test-only; not part of -- the public draw/click API). M._MENU_BAR_H = MENU_BAR_H 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)() -- ===================================================================== -- 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 input.bind("toggle_world_overlay", { "g" }) -- 0.2.0c.4 engine.print("map-editor v0.3.0: ready. Tab=cycle mode, 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. -- ===================================================================== local function self_test() local MENU_BAR_H = ui._MENU_BAR_H local VIEW_ITEMS = ui._VIEW_ITEMS local EDIT_ITEMS = ui._EDIT_ITEMS local LAYER_PANEL_ROWS = ui._LAYER_PANEL_ROWS -- t1: opening a menu then clicking outside closes it. state.set_menu_open("File") engine.test.equals(state.get_menu_open(), "File", "t1: menu opened by setter") ui.handle_click(2, MENU_BAR_H + 10, "left") engine.test.equals(state.get_menu_open(), nil, "t1: outside click closes menu") -- t2: toggle item flips bound state. local before = state.is_world_overlay() for _, it in ipairs(VIEW_ITEMS) do if it.label == "World Overlay" then it.fire(); break end end engine.test.equals(state.is_world_overlay(), not before, "t2: World Overlay toggle flips state") for _, it in ipairs(VIEW_ITEMS) do if it.label == "World Overlay" then it.fire(); break end end -- t3: modal open sets modal_open and closes menu. state.set_menu_open("Help") state.open_modal("cheatsheet") state.close_menu() engine.test.equals(state.get_modal_open(), "cheatsheet", "t3: cheatsheet modal id set") engine.test.equals(state.get_menu_open(), nil, "t3: menu closed when modal opens") ui.handle_click(2, MENU_BAR_H + 10, "left") engine.test.equals(state.get_modal_open(), "cheatsheet", "t3: outside click does not close modal") state.close_modal() -- t4: disabled item ignores click. local rot_before = state.get_active_rot() state.set_mode("auto-tile") for _, it in ipairs(EDIT_ITEMS) do if it.label == "Cycle Rotation" then local d = it.disabled local is_disabled = (type(d) == "function") and d() or (d == true) engine.test.equals(is_disabled, true, "t4: Cycle Rotation disabled in auto-tile mode") break end end engine.test.equals(state.get_active_rot(), rot_before, "t4: rotation unchanged because item was disabled") -- t5: mode-pill clicks switch mode. state.set_mode("auto-tile") actions.set_mode("direct") engine.test.equals(state.get_mode(), "direct", "t5: set_mode(direct) sets mode") actions.set_mode("auto-tile") engine.test.equals(state.get_mode(), "auto-tile", "t5: set_mode(auto-tile) sets mode") -- t6: switching categories updates menu_open. state.set_menu_open("File") state.set_menu_open("Edit") engine.test.equals(state.get_menu_open(), "Edit", "t6: re-set menu_open switches active category") state.close_menu() -- t7: View > Layers submenu toggle flips layer visibility. local lname = LAYER_PANEL_ROWS[1] local vis_before = state.is_layer_visible(lname) state.toggle_layer_visible(lname) 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") end -- ===================================================================== -- Frame hooks -- ===================================================================== function update(ctx, dt) -- CI-mode: run self-tests once on the first frame, then count to limit. if CI_MODE then if ci_frame_count == 0 then self_test() if engine.test.failures and engine.test.failures() > 0 then engine.print(string.format( "map-editor: SELF-TEST FAILED %d assertions", engine.test.failures())) engine.exit(1) return end engine.print("map-editor: self-test PASS") end 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 -- Esc precedence: modal → dropdown → quit if input.was_action_pressed("quit") then if state.is_modal_open() then actions.close_modal() elseif state.get_menu_open() then actions.close_menu() else engine.exit() return end 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 if state.is_modal_open("cheatsheet") then actions.close_modal() else actions.open_cheatsheet() end 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 if input.was_action_pressed("toggle_world_overlay") then actions.toggle_world_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