Files
sporel-module-map-editor/init.lua
Calic 2734efa1a9 chore(map-editor): bump to v0.3.1; cheatsheet + README sync
Bumps the module version to v0.3.1 (lib-core.maps dep pin was
already bumped to 0.5.7 in CT6 to allow the editor to load).
Updates the file header, startup banner, cheatsheet Tab entry
(now mentions all three modes), and appends a Cell-Tile Mode
subsection to the README.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 23:10:30 +02:00

1842 lines
76 KiB
Lua

-- sporel-module-map-editor v0.3.1
-- 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 "S<slot>r<rot>" (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 | <category_name> (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 | <modal_id> (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
-- 0.3.1: Cell-Tile mode paint actions. Write the per-cell material
-- flag via lib-core.maps; the renderer's 47-blob branch picks the
-- slot.
function M.paint_cell_material_at(cell_x, cell_y)
maps.set_cell_material(state.get_active_layer(), cell_x, cell_y, true)
state.mark_dirty()
end
function M.erase_cell_material_at(cell_x, cell_y)
maps.set_cell_material(state.get_active_layer(), cell_x, cell_y, false)
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="Cycle mode (Auto-Tile / Cell-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-<os.time>.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
elseif mode == "cell-tile" then
-- Cell-tile mode: faint render-cell grid (same density as Direct).
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
-- Hover-cell highlight: 1-px outline around the cursor cell.
local c = state.get_mouse_cell()
if c then
engine.render.draw_rect_lines(
c.x * t_size, c.y * t_size, t_size, t_size,
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.1: ready. Tab=cycle mode (Auto/Cell/Direct), 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")
-- t10: paint_cell_material_at writes the flag readable by
-- maps.get_cell_material; cell-tile mode dispatches LMB to it.
state.set_mode("cell-tile")
local layer = state.get_active_layer()
-- Clear first to avoid pollution from earlier tests
if maps.get_cell_material(layer, 5, 5) then
maps.set_cell_material(layer, 5, 5, false)
end
actions.paint_cell_material_at(5, 5)
engine.test.equals(maps.get_cell_material(layer, 5, 5), true,
"t10: paint_cell_material_at sets the flag")
actions.erase_cell_material_at(5, 5)
engine.test.equals(maps.get_cell_material(layer, 5, 5), false,
"t10: erase_cell_material_at clears the flag")
-- t11: Cycle Rotation / Toggle Flip / Reset Transform are disabled
-- in both auto-tile AND cell-tile modes; enabled only in direct.
local target_labels = { "Cycle Rotation", "Toggle Flip", "Reset Transform" }
for _, label in ipairs(target_labels) do
state.set_mode("auto-tile")
for _, it in ipairs(ui._EDIT_ITEMS) do
if it.label == label then
local d = it.disabled
local is_disabled = (type(d) == "function") and d() or (d == true)
engine.test.equals(is_disabled, true,
"t11: " .. label .. " disabled in auto-tile mode")
break
end
end
state.set_mode("cell-tile")
for _, it in ipairs(ui._EDIT_ITEMS) do
if it.label == label then
local d = it.disabled
local is_disabled = (type(d) == "function") and d() or (d == true)
engine.test.equals(is_disabled, true,
"t11: " .. label .. " disabled in cell-tile mode")
break
end
end
state.set_mode("direct")
for _, it in ipairs(ui._EDIT_ITEMS) do
if it.label == label then
local d = it.disabled
local is_disabled = (type(d) == "function") and d() or (d == true)
engine.test.equals(is_disabled, false,
"t11: " .. label .. " enabled in direct mode")
break
end
end
end
-- t11 teardown: restore auto-tile so we don't leave state in direct
state.set_mode("auto-tile")
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
elseif state.get_mode() == "cell-tile" then
local cell = state.get_mouse_cell()
if cell then
if dp == "paint" then
actions.paint_cell_material_at(cell.x, cell.y)
else
actions.erase_cell_material_at(cell.x, cell.y)
end
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