Files
sporel-module-map-editor/init.lua
Calic 2c3015685c feat(map-editor): dropdown render for action/modal/toggle/separator
Renders the open dropdown for the active menu category with a
three-column item layout (indicator | label | hotkey). Toggle items
show their bound is_on() state with a [v]/[ ] indicator, modal
items append "...", separators draw as thin rules, disabled items
render in a muted colour. Submenu chevron is wired but submenus
themselves land in a later slice.

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

1354 lines
54 KiB
Lua

-- sporel-module-map-editor v0.2.0c.4
-- 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")
modal_open = nil, -- 0.3.0: nil | <modal_id> (single-stack)
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).
function M.get_mode() return state.mode end
function M.set_mode(m)
if m == "auto-tile" or m == "direct" then state.mode = m end
end
function M.toggle_mode()
state.mode = (state.mode == "auto-tile") and "direct" or "auto-tile"
end
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
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 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.modal_open = nil
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 (replaces TOOLBAR_H block in v0.3.0)
local MENU_BAR_H = 24
local MENU_LABEL_PAD_X = 12
local MODE_PILL_W = 130
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
-- Top toolbar (full-width, mode-switch + action buttons)
local TOOLBAR_H = 32
local TOOLBAR_BUTTON_W = 88
local TOOLBAR_BUTTON_H = 24
local TOOLBAR_BUTTON_GAP = 6
local TOOLBAR_PADDING = 4
-- Side panel (right edge, layers vis + active)
local SIDE_PANEL_W = 160
local LAYER_ROW_H = 22
local LAYER_ROW_GAP = 2
local EYE_BOX = 18
-- Bottom palette strip (above the status chip). 14 swatches packed
-- horizontally; each one click-selects state.active_tile = slot_id.
-- 0.2.0b ships nummerierte Farb-Swatches as a placeholder for real
-- tile thumbnails (those need a tile-UV getter on lib-core.maps).
local PALETTE_H = 64
local PALETTE_SWATCH_W = 48
local PALETTE_SWATCH_GAP = 4
local PALETTE_PADDING = 6
local PALETTE_SLOTS = 14 -- matches S-V2E2-RM-Blob baseline
-- Status chip (bottom-right, mouse cell + dirty)
local STATUS_W = 320
local STATUS_H = 22
local STATUS_MARGIN = 8
-- Cheatsheet modal (centered overlay)
local CHEAT_W = 380
local CHEAT_H = 280
-- Colours
local COL_TOOLBAR_BG = 0x202024
local COL_PANEL_BG = 0x202024
local COL_BUTTON_BG = 0x303034
local COL_BUTTON_BG_HOVER = 0x40404A
local COL_BUTTON_BG_ACTIVE = 0xE0E0E0
local COL_BUTTON_BORDER = 0xFFFFFF
local COL_TEXT = 0xFFFFFF
local COL_TEXT_ACTIVE = 0x202024
local COL_TEXT_MUTED = 0xA0A0A0
local COL_ROW_HOVER = 0x383844
local COL_ROW_ACTIVE = 0x504070 -- subtle purple for the active layer row
local COL_HIDDEN_OVERLAY = 0xFF4040
local COL_MODAL_BG = 0x101014
local COL_MODAL_BORDER = 0xFFFFFF
local COL_PALETTE_BG = 0x18181C
local COL_SWATCH_BORDER = 0x606060
local COL_SWATCH_SELECTED = 0xFFD060
local COL_VERTEX_GRID = 0xFFFFFF
local COL_VERTEX_DOT = 0xFFB040
local COL_VERTEX_SNAP = 0x40FF60
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-array stubs for the dropdown system. Filled in T9.
local FILE_ITEMS = {}
local EDIT_ITEMS = {}
local VIEW_ITEMS = {}
local MAP_ITEMS = {}
local TOOLS_ITEMS = {}
local WINDOW_ITEMS = {}
local HELP_ITEMS = {}
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 },
}
-- Top-toolbar buttons (left-to-right). Action ids dispatched in
-- handle_click; hotkey labels rendered on the button face.
local TOOLBAR_BUTTONS = {
{ id="mode_auto", label="Auto-Tile", hotkey="Tab", group="mode" },
{ id="mode_direct", label="Direct", hotkey="Tab", group="mode" },
{ id="save", label="Save", hotkey="S" },
{ id="erase", label="Erase", hotkey="E" },
{ id="rotate", label="Rotate", hotkey="R" },
{ id="flip", label="Flip", hotkey="H" },
{ id="reset_xform", label="Reset", hotkey="0" },
{ id="help", label="Help", hotkey="I" },
}
-- Cheatsheet content (rendered into the modal).
local CHEATSHEET = {
{ key="Tab", action="Toggle mode (Auto-Tile / Direct)" },
{ key="I", action="Show / hide this cheatsheet" },
{ key="S", action="Save current map to work.map.json" },
{ key="E", action="Erase tile at mouse cell" },
{ key="R", action="Cycle tile rotation 0->90->180->270 (Direct-mode override)" },
{ key="H", action="Toggle tile flip 0<->1 (Direct-mode override)" },
{ key="0", action="Reset transform (rot=0, flip=0)" },
{ key="D", action="Toggle debug overlay (per-cell slot/rot labels)" },
{ key="G", action="Toggle world overlay (Auto-Tile: map-grid + dots; Direct: cell-grid)" },
{ key="Esc", action="Quit editor" },
{ key="LMB-drag canvas (Auto-Tile)", action="Stroke-paint vertices (any-corner fill)" },
{ key="RMB-drag canvas (Auto-Tile)", action="Stroke-clear vertices" },
{ key="LMB-drag canvas (Direct)", action="Stroke-paint cells (override with slot+rot+flip)" },
{ key="RMB-drag canvas (Direct)", action="Stroke-clear overrides" },
{ key="LMB on palette swatch", action="Select slot (1-14) as active tile" },
{ key="RMB on Layer row", action="Toggle layer visibility" },
{ key="LMB on Layer row", action="Set as active layer" },
}
-- =====================================================================
-- 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).
-- =====================================================================
local function toolbar_layout()
local screen_w = engine.window.size()
return { x = 0, y = 0, w = screen_w, h = TOOLBAR_H }
end
-- Returns array of { id, label, hotkey, group, x, y, w, h } for each
-- toolbar button, packed left-to-right within the toolbar rect.
local function toolbar_buttons_layout()
local out = {}
local x = TOOLBAR_PADDING
local y = (TOOLBAR_H - TOOLBAR_BUTTON_H) / 2
for i, b in ipairs(TOOLBAR_BUTTONS) do
out[i] = {
id = b.id,
label = b.label,
hotkey = b.hotkey,
group = b.group,
x = x, y = y, w = TOOLBAR_BUTTON_W, h = TOOLBAR_BUTTON_H,
}
x = x + TOOLBAR_BUTTON_W + TOOLBAR_BUTTON_GAP
end
return out
end
-- 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" },
direct = { x = x + MODE_PILL_SEG_W, 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.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 = (seg.mode == "auto-tile") and "Auto-Tile" or "Direct"
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
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 = TOOLBAR_H,
w = SIDE_PANEL_W,
h = screen_h - TOOLBAR_H - PALETTE_H,
}
end
-- Returns array of { name, x, y, w, h, eye_x, eye_y, eye_w, eye_h }
-- for each layer row in the side panel.
local function side_panel_rows_layout()
local panel = side_panel_layout()
local out = {}
local row_y = panel.y + 6
for i, name in ipairs(LAYER_PANEL_ROWS) do
out[i] = {
name = name,
x = panel.x + 4,
y = row_y,
w = panel.w - 8,
h = LAYER_ROW_H,
eye_x = panel.x + 6,
eye_y = row_y + (LAYER_ROW_H - EYE_BOX) / 2,
eye_w = EYE_BOX,
eye_h = EYE_BOX,
}
row_y = row_y + LAYER_ROW_H + LAYER_ROW_GAP
end
return out
end
local function palette_layout()
local screen_w, screen_h = engine.window.size()
return {
x = 0,
y = screen_h - PALETTE_H,
w = screen_w - SIDE_PANEL_W,
h = PALETTE_H,
}
end
-- Returns array of { slot_id, x, y, w, h } for each swatch in the
-- palette strip, packed left-to-right.
local function palette_swatches_layout()
local p = palette_layout()
local out = {}
local x = p.x + PALETTE_PADDING
local y = p.y + PALETTE_PADDING
local h = p.h - 2 * PALETTE_PADDING
for slot = 1, PALETTE_SLOTS do
out[slot] = {
slot_id = slot,
x = x, y = y, w = PALETTE_SWATCH_W, h = h,
}
x = x + PALETTE_SWATCH_W + PALETTE_SWATCH_GAP
end
return out
end
local function status_layout()
local screen_w, screen_h = engine.window.size()
return {
x = STATUS_MARGIN,
y = screen_h - PALETTE_H - STATUS_H - STATUS_MARGIN,
w = STATUS_W,
h = STATUS_H,
}
end
local function cheatsheet_layout()
local screen_w, screen_h = engine.window.size()
return {
x = (screen_w - CHEAT_W) / 2,
y = (screen_h - CHEAT_H) / 2,
w = CHEAT_W,
h = CHEAT_H,
}
end
-- 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)
return true -- any click closes
end,
}
-- =====================================================================
-- Drawing
-- =====================================================================
-- Decide whether a toolbar button is in the "active" highlighted
-- state — only mode-group buttons match the current mode.
local function button_is_active(b)
if b.group ~= "mode" then return false end
if b.id == "mode_auto" and state.get_mode() == "auto-tile" then return true end
if b.id == "mode_direct" and state.get_mode() == "direct" then return true end
return false
end
local function draw_button(b, mx, my)
local is_active = button_is_active(b)
local is_hover = in_rect(mx, my, b.x, b.y, b.w, b.h)
local bg = is_active and COL_BUTTON_BG_ACTIVE
or is_hover and COL_BUTTON_BG_HOVER
or COL_BUTTON_BG
local text_col = is_active and COL_TEXT_ACTIVE or COL_TEXT
engine.render.draw_rect(b.x, b.y, b.w, b.h, rgba(bg, 0xFF))
if is_active then
engine.render.draw_rect_lines(b.x, b.y, b.w, b.h, rgba(COL_BUTTON_BORDER, 0xFF))
end
engine.render.draw_text(b.label, b.x + 6, b.y + 4, 12, rgba(text_col, 0xFF))
if b.hotkey then
engine.render.draw_text("(" .. b.hotkey .. ")", b.x + 6, b.y + 14, 9,
rgba(text_col, 0xC0))
end
end
local function draw_top_toolbar(mx, my)
local t = toolbar_layout()
engine.render.draw_rect(t.x, t.y, t.w, t.h, rgba(COL_TOOLBAR_BG, 0xFF))
for _, b in ipairs(toolbar_buttons_layout()) do
draw_button(b, mx, my)
end
-- Mode + active-layer label in the toolbar's right area
local label = string.format("Mode: %s Layer: %s",
state.get_mode(), state.get_active_layer())
local screen_w = engine.window.size()
engine.render.draw_text(label, screen_w - SIDE_PANEL_W - 240, 10, 12,
rgba(COL_TEXT_MUTED, 0xFF))
end
local function draw_side_panel(mx, my)
local p = side_panel_layout()
engine.render.draw_rect(p.x, p.y, p.w, p.h, rgba(COL_PANEL_BG, 0xFF))
engine.render.draw_text("Layers", p.x + 6, p.y + 4, 11, rgba(COL_TEXT_MUTED, 0xFF))
for _, row in ipairs(side_panel_rows_layout()) do
local is_active = (state.get_active_layer() == row.name)
local is_hover = in_rect(mx, my, row.x, row.y, row.w, row.h)
local is_visible = state.is_layer_visible(row.name)
-- Row background (active highlight or hover dim)
if is_active then
engine.render.draw_rect(row.x, row.y, row.w, row.h, rgba(COL_ROW_ACTIVE, 0xFF))
elseif is_hover then
engine.render.draw_rect(row.x, row.y, row.w, row.h, rgba(COL_ROW_HOVER, 0xFF))
end
-- Eye-icon box
engine.render.draw_rect_lines(row.eye_x, row.eye_y, row.eye_w, row.eye_h,
rgba(COL_TEXT_MUTED, 0xFF))
local eye_label = is_visible and "+" or "x"
engine.render.draw_text(eye_label, row.eye_x + 5, row.eye_y + 3, 12,
rgba(is_visible and COL_TEXT or COL_HIDDEN_OVERLAY, 0xFF))
-- Layer name (with active marker)
local marker = is_active and ">" or " "
engine.render.draw_text(marker .. " " .. row.name,
row.eye_x + row.eye_w + 6, row.y + 4, 12, rgba(COL_TEXT, 0xFF))
end
-- Roof toggle below the layers list
local last = side_panel_rows_layout()[#LAYER_PANEL_ROWS]
local roof_y = last.y + last.h + 12
local roof_label = state.is_roof_mode() and "Roof: [ON]" or "Roof: [OFF]"
engine.render.draw_text(roof_label, p.x + 6, roof_y, 12,
rgba(state.is_roof_mode() and COL_TEXT_ACTIVE or COL_TEXT_MUTED, 0xFF))
end
local function draw_palette(mx, my)
local p = palette_layout()
engine.render.draw_rect(p.x, p.y, p.w, p.h, rgba(COL_PALETTE_BG, 0xFF))
engine.render.draw_text("Palette (slot 1-14)", p.x + 6, p.y + 4, 11,
rgba(COL_TEXT_MUTED, 0xFF))
-- 0.2.0c.2: real tile thumbnails via maps.atlas_tile_uv. Atlas
-- index 0 (single-atlas maps); falls back to the colour swatch
-- if the atlas / tile UV / texture isn't available.
local atlas_idx = state.get_active_atlas()
local tex = maps.atlas_diffuse_handle(atlas_idx)
local selected_slot = state.get_active_tile()
for _, sw in ipairs(palette_swatches_layout()) do
local is_selected = (sw.slot_id == selected_slot)
local is_hover = in_rect(mx, my, sw.x, sw.y, sw.w, sw.h)
-- Always draw a dark BG so transparent atlas tiles read.
engine.render.draw_rect(sw.x, sw.y, sw.w, sw.h, rgba(0x282830, 0xFF))
-- Tile thumbnail: scale the atlas sub-tex to fit the swatch.
local uv = tex and maps.atlas_tile_uv(atlas_idx, sw.slot_id - 1)
if tex and uv then
local scale_x = sw.w / uv.w
local scale_y = sw.h / uv.h
engine.render.draw_sprite_transform(
tex,
sw.x + sw.w / 2, sw.y + sw.h / 2,
0, scale_x, scale_y,
uv.w / 2, uv.h / 2,
0xFFFFFFFF,
uv.x, uv.y, uv.w, uv.h
)
else
-- Fallback: legacy coloured placeholder.
engine.render.draw_rect(sw.x, sw.y, sw.w, sw.h,
rgba(SWATCH_COLORS[sw.slot_id] or 0x808080, 0xFF))
end
-- Border + label on top.
local border_col = is_selected and COL_SWATCH_SELECTED
or is_hover and COL_TEXT
or COL_SWATCH_BORDER
engine.render.draw_rect_lines(sw.x, sw.y, sw.w, sw.h,
rgba(border_col, 0xFF))
engine.render.draw_text(tostring(sw.slot_id), sw.x + 4, sw.y + 4, 11,
rgba(COL_TEXT, 0xE0))
end
end
local function draw_status(mx, my)
local s = status_layout()
engine.render.draw_rect(s.x, s.y, s.w, s.h, rgba(COL_TOOLBAR_BG, 0xC0))
engine.render.draw_rect_lines(s.x, s.y, s.w, s.h, rgba(COL_TEXT_MUTED, 0xFF))
local cell = state.get_mouse_cell()
local cell_str = cell and string.format("Mouse: (%d,%d)", cell.x, cell.y) or "Mouse: (-,-)"
local dirty_str = state.is_dirty() and " * unsaved" or ""
-- 0.2.0c: append the current direct-paint transform state.
local xform_str = string.format(" slot=%d rot=%d flip=%d",
state.get_active_tile(), state.get_active_rot(), state.get_active_flip())
engine.render.draw_text(cell_str .. dirty_str .. xform_str, s.x + 4, s.y + 4, 12,
rgba(COL_TEXT, 0xFF))
end
-- =====================================================================
-- 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_top_toolbar(mx, my)
draw_mode_pill()
draw_side_panel(mx, my)
draw_palette(mx, my)
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
-- Dispatch a toolbar-button click to the appropriate action.
local function dispatch_toolbar_button(id)
if id == "mode_auto" then actions.set_mode("auto-tile")
elseif id == "mode_direct" then actions.set_mode("direct")
elseif id == "save" then actions.save()
elseif id == "erase" then
local cell = state.get_mouse_cell()
if cell then actions.erase_cell_at(cell.x, cell.y) end
elseif id == "rotate" then actions.cycle_rotation()
elseif id == "flip" then actions.toggle_flip()
elseif id == "reset_xform" then actions.reset_transform()
elseif id == "help" then actions.open_cheatsheet()
end
end
-- Hit-test mouse click. Returns true if the click was consumed by UI.
-- button: "left" | "right"
function M.handle_click(mx, my, button)
-- 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.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
-- Click anywhere outside the menu bar closes any open dropdown
if state.get_menu_open() and my >= MENU_BAR_H then
state.close_menu()
-- Fall through (do not return) so the click can hit something else.
end
-- Top toolbar buttons
for _, b in ipairs(toolbar_buttons_layout()) do
if in_rect(mx, my, b.x, b.y, b.w, b.h) then
if button == "left" then
dispatch_toolbar_button(b.id)
end
return true
end
end
-- Bottom palette swatches: left = select slot
for _, sw in ipairs(palette_swatches_layout()) do
if in_rect(mx, my, sw.x, sw.y, sw.w, sw.h) then
if button == "left" then
actions.set_active_tile(sw.slot_id)
end
return true
end
end
-- Side-panel layer rows: left = set active; right = toggle visibility
for _, row in ipairs(side_panel_rows_layout()) do
if in_rect(mx, my, row.eye_x, row.eye_y, row.eye_w, row.eye_h) then
if button == "left" then
actions.toggle_layer_visible(row.name)
end
return true
end
if in_rect(mx, my, row.x, row.y, row.w, row.h) then
if button == "left" then
actions.set_active_layer(row.name)
elseif button == "right" then
actions.toggle_layer_visible(row.name)
end
return true
end
end
-- Status chip — no-op fallback to claim the click area
local s = status_layout()
if in_rect(mx, my, s.x, s.y, s.w, s.h) then
return true
end
return false -- click falls through to map-area handler in init.lua
end
return M
end)()
-- =====================================================================
-- Setup + Frame hooks (was the rest of original init.lua)
-- =====================================================================
local WORK_PATH = "maps/work.map.json"
local work_map_id = maps.load(WORK_PATH)
maps.set_current(work_map_id)
maps.load_textures(engine.module.asset_aliases())
state.set_map(work_map_id, WORK_PATH)
-- Center camera on the map
local m_size = maps.size()
local t_size = maps.tile_size()
camera.set_target((m_size.w * t_size) / 2, (m_size.h * t_size) / 2)
camera.set_zoom(1.0)
-- =====================================================================
-- Input bindings
-- =====================================================================
input.bind("quit", { "escape" })
input.bind("erase", { "e" })
input.bind("rotate", { "r" })
input.bind("save_map", { "s" })
input.bind("toggle_mode", { "tab" }) -- 0.2.0a
input.bind("toggle_cheatsheet", { "i" }) -- 0.2.0a — `i` for Info/help (no shifted-key support in input lib)
input.bind("flip", { "h" }) -- 0.2.0c
input.bind("reset_transform", { "0" }) -- 0.2.0c
input.bind("toggle_debug_overlay", { "d" }) -- 0.2.0c.3
input.bind("toggle_world_overlay", { "g" }) -- 0.2.0c.4
engine.print("map-editor v0.2.0c.4: ready. Tab=mode, I=help, G=grid, D=debug, S=save, E=erase, R=rotate, H=flip, 0=reset, ESC=quit")
-- =====================================================================
-- Frame hooks
-- =====================================================================
function update(ctx, dt)
-- CI-mode auto-exit (smoke harness)
if CI_MODE then
ci_frame_count = ci_frame_count + 1
if ci_frame_count >= CI_FRAME_LIMIT then
engine.print(string.format("map-editor: ci_frames_ok=%d", ci_frame_count))
engine.exit(0)
return
end
end
-- 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