Migrate the top toolbar, right-side Layers panel, and bottom palette strip to lib-core.panel v0.3.0 persistent widgets (W.2 of the panel-window-manager plan): map-editor.toolbar -- top strip (full width, ~290 px tall) map-editor.layers -- right side-panel (160 px wide) map-editor.palette -- bottom strip (full width, 64 px tall) All three registered as persistent=true (open from module-init, not dismissed by ESC), input_block='self' (hit-test claims clicks within own bounds; misses fall through to canvas), and chromeless=true (panel-lib skips its title-bar/border/X — widgets keep their own visual style). Existing draw_menu_bar / draw_side_panel / draw_palette and handle_click logic is wrapped via thin widget render + handle_input fns (M.draw_toolbar_widget / M.handle_toolbar_click etc.); no behavioural change to the widget interiors. Map-canvas remains module-rendered. Canvas drag-paint is now gated on panel.point_in_any_panel(mx, my) so widget clicks no longer bleed through into map paint. Removed state.show_layers_panel / show_palette and their toggle helpers (panel-lib manages widget visibility; Window menu items now flip panel.is_open/toggle on the widget ids directly). Modals stay module-rendered + module-routed (handled by handle_modal_click before panel.update). Manifest bumped 0.3.4 -> 0.4.0; lib-core.panel dep added at 0.3.0.
2002 lines
83 KiB
Lua
2002 lines
83 KiB
Lua
-- sporel-module-map-editor v0.4.0
|
|
-- Interactive map editor — Rev 4 UX architecture (see design paper
|
|
-- 2026-05-28-autotile-blob-styles-design.md §11 + plan stub
|
|
-- 2026-05-28-map-editor-blob-v2.md). 0.2.0b adds the bottom palette
|
|
-- strip (14 numbered swatches, no real tile thumbnails yet — that
|
|
-- needs a lib-core.maps tile-UV getter) + Auto-Tile vertex painting
|
|
-- via maps.set_vertex + a vertex-grid overlay. 0.2.0b.1 migrates
|
|
-- work.map.json to schema-v3 + binds surface/wall layers to the
|
|
-- blob_rect_stone material so Auto-Tile painting actually renders.
|
|
-- 0.2.0c adds Transform controls (Flip H, Reset) + Direct-mode
|
|
-- override paint that writes maps.set_override with the current
|
|
-- {slot, rot, flip} on a material-bound terrain layer. Single
|
|
-- `flip` flag (0/1) combined with rot (0..3) covers all 8 D4
|
|
-- orientations; H+V buttons are redundant so we ship just one.
|
|
-- 0.2.0c.1 adds drag-paint: LMB-hold strokes a line of paints, RMB-hold
|
|
-- a line of erases (both in Auto-Tile and Direct mode). UI clicks on
|
|
-- toolbar / palette / side-panel never enter drag-mode.
|
|
-- 0.2.0c.2 makes the palette show real tile thumbnails via the new
|
|
-- lib-core.maps 0.5.4 atlas-handle / tile-UV getters. Falls back to
|
|
-- the legacy colour swatch when atlas isn't loaded.
|
|
-- 0.2.0c.3 adds the D-toggle debug overlay: per material cell on the
|
|
-- active layer renders "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.
|
|
-- 0.4.0 migrates the top-toolbar / right side-panel / bottom palette
|
|
-- to lib-core.panel v0.3.0 persistent widgets (z_tier="normal",
|
|
-- chromeless, input_block="self"). Canvas + status + modals stay
|
|
-- module-rendered. Canvas-paint is gated on
|
|
-- panel.point_in_any_panel(mx, my) so panel-widget clicks no longer
|
|
-- bleed through to drag-paint. Removed Window > Layers Panel + Palette
|
|
-- toggle items (panel-lib manages visibility; persistent widgets are
|
|
-- always on).
|
|
--
|
|
-- 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")
|
|
local panel = require("lib-core.panel")
|
|
|
|
-- 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
|
|
-- 0.4.0: visibility of layers-panel + palette is now managed by
|
|
-- lib-core.panel (persistent widgets). The Window-menu items
|
|
-- flip panel.is_open("map-editor.{layers,palette}") directly.
|
|
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
|
|
|
|
-- 0.4.0: layers-panel + palette visibility moved to lib-core.panel
|
|
-- (persistent widgets managed via panel.is_open/toggle on the
|
|
-- "map-editor.layers" + "map-editor.palette" widget ids).
|
|
|
|
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.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
|
|
|
|
-- 0.4.0: panel-lib manages widget visibility via panel.is_open/toggle.
|
|
-- The Window-menu items now flip the persistent widgets' open-state.
|
|
local WINDOW_ITEMS = {
|
|
{ type = "toggle", label = "Layers Panel",
|
|
is_on = function() return panel.is_open("map-editor.layers") end,
|
|
fire = function() panel.toggle("map-editor.layers") end },
|
|
{ type = "toggle", label = "Palette",
|
|
is_on = function() return panel.is_open("map-editor.palette") end,
|
|
fire = function() panel.toggle("map-editor.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 (v0.4.0 split for panel-lib persistent widgets)
|
|
-- =====================================================================
|
|
--
|
|
-- The 3 persistent widgets (toolbar / layers / palette) get their own
|
|
-- render + handle_input pair. M.draw() now only paints the chrome that
|
|
-- is NOT a panel-widget: the bottom-left status chip and any open
|
|
-- modal overlay. Modals stay module-rendered + module-routed (they
|
|
-- are top-tier transient and need to fully absorb input via the
|
|
-- existing dispatch_modal_click path).
|
|
|
|
-- Toolbar widget: menu-bar + mode-pill + open-dropdown render.
|
|
function M.draw_toolbar_widget(_ctx)
|
|
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()
|
|
end
|
|
|
|
-- Layers widget: right-side panel with per-layer rows + roof toggle.
|
|
function M.draw_layers_widget(_ctx)
|
|
local mx, my = engine.input.get_mouse_pos()
|
|
draw_side_panel(mx, my)
|
|
end
|
|
|
|
-- Palette widget: bottom strip with tile-thumbnail swatches.
|
|
function M.draw_palette_widget(_ctx)
|
|
local mx, my = engine.input.get_mouse_pos()
|
|
draw_palette(mx, my)
|
|
end
|
|
|
|
-- M.draw() draws non-widget chrome: status chip + modal overlay.
|
|
-- Called by init.lua's render() AFTER panel.render(), so modals
|
|
-- correctly appear on top of all widget panels.
|
|
function M.draw()
|
|
local mx, my = engine.input.get_mouse_pos()
|
|
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
|
|
|
|
-- v0.4.0: hit-test split into per-widget handlers + a module-level
|
|
-- modal dispatcher. The 3 widget panels (toolbar / layers / palette)
|
|
-- get their own handle_input fns; the modal-click flow stays at
|
|
-- the M.update level since modals are NOT panel widgets.
|
|
|
|
--- M.handle_modal_click(mx, my, button) — returns true if modal
|
|
--- consumed the click. Called BEFORE panel.update() so a modal can
|
|
--- swallow clicks before they reach widget dispatch.
|
|
function M.handle_modal_click(mx, my, button)
|
|
return dispatch_modal_click(mx, my, button)
|
|
end
|
|
|
|
--- M.handle_toolbar_click(mx, my, button) — handles menu-bar +
|
|
--- mode-pill + dropdown clicks. Wired into the toolbar widget's
|
|
--- handle_input via panel-lib. Returns true if consumed.
|
|
function M.handle_toolbar_click(mx, my, button)
|
|
-- 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 other UI can still hit-test the click).
|
|
state.close_menu()
|
|
end
|
|
|
|
return false
|
|
end
|
|
|
|
--- M.handle_layers_click(mx, my, button) — left = set active layer;
|
|
--- right = toggle visibility. Wired into the layers widget.
|
|
function M.handle_layers_click(mx, my, button)
|
|
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
|
|
return false
|
|
end
|
|
|
|
--- M.handle_palette_click(mx, my, button) — left = select slot.
|
|
--- Wired into the palette widget.
|
|
function M.handle_palette_click(mx, my, button)
|
|
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
|
|
return false
|
|
end
|
|
|
|
-- Expose layout fns for the widget custom layout-fns registered in
|
|
-- the Setup section. Each widget needs its bounds to match the
|
|
-- absolute screen coords the existing draw_* helpers use, so we
|
|
-- expose the layout fns directly.
|
|
M._side_panel_layout = side_panel_layout
|
|
M._palette_layout = palette_layout
|
|
M._MENU_BAR_H_const = MENU_BAR_H
|
|
|
|
-- 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
|
|
|
|
-- =====================================================================
|
|
-- 0.4.0: panel-lib persistent widget registration (W.2)
|
|
-- =====================================================================
|
|
-- Three persistent + chromeless + input_block="self" widgets cover the
|
|
-- top toolbar, right-side layers panel, and bottom palette strip. Each
|
|
-- widget's bounds match what the existing draw_* helpers expect — they
|
|
-- still draw at absolute screen coords via engine.window.size(). The
|
|
-- panel-lib's chrome (bg/border/title/X) is suppressed via
|
|
-- chromeless=true; the widgets draw their own visual style.
|
|
--
|
|
-- Map-canvas is NOT a widget. Module renders it directly inside
|
|
-- camera.begin/finish; module's drag-paint dispatch is gated on
|
|
-- panel.point_in_any_panel(mx, my) so widget clicks never bleed
|
|
-- through into canvas paint.
|
|
|
|
-- Toolbar bounds = top strip (menu-bar height + room for open
|
|
-- dropdowns). Use a custom layout fn that covers the union of
|
|
-- menu-bar + worst-case dropdown extent.
|
|
local TOOLBAR_MAX_H = 290 -- menu-bar (24) + max dropdown rows (12 * 22 + pad)
|
|
local toolbar_widget = {
|
|
title = "Toolbar",
|
|
pause_on_open = false,
|
|
render = function(ctx) ui.draw_toolbar_widget(ctx) end,
|
|
handle_input = function(_ctx, event)
|
|
if event.kind == "click" then
|
|
ui.handle_toolbar_click(event.x, event.y, event.button)
|
|
end
|
|
end,
|
|
}
|
|
panel.register("map-editor.toolbar", toolbar_widget, {
|
|
layout = function(sw, _sh)
|
|
return { x = 0, y = 0, w = sw, h = TOOLBAR_MAX_H }
|
|
end,
|
|
z_tier = "normal",
|
|
persistent = true,
|
|
input_block = "self",
|
|
chromeless = true,
|
|
})
|
|
|
|
local layers_widget = {
|
|
title = "Layers",
|
|
pause_on_open = false,
|
|
render = function(ctx) ui.draw_layers_widget(ctx) end,
|
|
handle_input = function(_ctx, event)
|
|
if event.kind == "click" then
|
|
ui.handle_layers_click(event.x, event.y, event.button)
|
|
end
|
|
end,
|
|
}
|
|
panel.register("map-editor.layers", layers_widget, {
|
|
layout = function(_sw, _sh)
|
|
local p = ui._side_panel_layout()
|
|
return { x = p.x, y = p.y, w = p.w, h = p.h }
|
|
end,
|
|
z_tier = "normal",
|
|
persistent = true,
|
|
input_block = "self",
|
|
chromeless = true,
|
|
})
|
|
|
|
local palette_widget = {
|
|
title = "Palette",
|
|
pause_on_open = false,
|
|
render = function(ctx) ui.draw_palette_widget(ctx) end,
|
|
handle_input = function(_ctx, event)
|
|
if event.kind == "click" then
|
|
ui.handle_palette_click(event.x, event.y, event.button)
|
|
end
|
|
end,
|
|
}
|
|
panel.register("map-editor.palette", palette_widget, {
|
|
layout = function(_sw, _sh)
|
|
local p = ui._palette_layout()
|
|
return { x = p.x, y = p.y, w = p.w, h = p.h }
|
|
end,
|
|
z_tier = "normal",
|
|
persistent = true,
|
|
input_block = "self",
|
|
chromeless = true,
|
|
})
|
|
|
|
engine.print("map-editor v0.4.0: 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")
|
|
-- 0.4.0: handle_toolbar_click + handle_modal_click split out of
|
|
-- handle_click; call the toolbar-specific path here.
|
|
ui.handle_toolbar_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")
|
|
-- 0.4.0: modal-priority is enforced by handle_modal_click (called
|
|
-- before panel.update() in M.update). Verify it returns true so
|
|
-- a click would be swallowed before reaching the toolbar widget.
|
|
local modal_consumed = ui.handle_modal_click(2, MENU_BAR_H + 10, "left")
|
|
engine.test.equals(modal_consumed, true,
|
|
"t3: modal swallows clicks while open")
|
|
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
|
|
|
|
-- 0.4.0: input dispatch is split between module-level + panel-lib:
|
|
-- 1. handle_modal_click: module-level, ALWAYS runs first (modals
|
|
-- are top-most overlays and not panel widgets).
|
|
-- 2. panel.update(dt): routes clicks to persistent widgets via
|
|
-- their input_block="self" hit-tests. Widget handle_input
|
|
-- delegates to ui.handle_toolbar_click / handle_layers_click /
|
|
-- handle_palette_click.
|
|
-- 3. Canvas drag-paint: gated on (no modal AND point not inside
|
|
-- any panel widget) to prevent widget-clicks bleeding through
|
|
-- to the map canvas underneath.
|
|
--
|
|
-- Modal precedence: when a modal is open, we still consume the
|
|
-- click here (so it doesn't reach panel widgets), but we DON'T
|
|
-- start a drag-paint. panel.update still runs but its dispatch
|
|
-- routes the press as normal (mostly a no-op for modals since the
|
|
-- modal isn't a widget).
|
|
local lmb_pressed = engine.input.was_mouse_pressed(engine.input.MOUSE_LEFT)
|
|
local rmb_pressed = engine.input.was_mouse_pressed(engine.input.MOUSE_RIGHT)
|
|
local modal_open = state.is_modal_open()
|
|
if lmb_pressed and modal_open then
|
|
ui.handle_modal_click(mx, my, "left")
|
|
end
|
|
if rmb_pressed and modal_open then
|
|
ui.handle_modal_click(mx, my, "right")
|
|
end
|
|
|
|
-- Panel-lib dispatch: reads engine.input mouse state directly,
|
|
-- emits click + wheel events to widget handle_input fns. Widgets
|
|
-- routed by tier (top → normal → hud) with input_block="self".
|
|
panel.update(dt)
|
|
|
|
-- Canvas drag-paint trigger. Only initiate when no modal open AND
|
|
-- the click did not land within any panel widget area. Drag-paint
|
|
-- still continues per-frame as long as the mouse button stays
|
|
-- down (no per-frame panel-bounds check — the originating press
|
|
-- already established the click as a canvas-stroke).
|
|
if lmb_pressed and not modal_open and not panel.point_in_any_panel(mx, my) then
|
|
state.set_drag_paint("paint")
|
|
end
|
|
if rmb_pressed and not modal_open and not panel.point_in_any_panel(mx, my) then
|
|
state.set_drag_paint("erase")
|
|
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()
|
|
-- 0.4.0: panel.render() draws the 3 persistent widgets (toolbar +
|
|
-- layers + palette) on top of the canvas. ui.draw() then draws the
|
|
-- remaining non-widget chrome (status chip + open modal overlay)
|
|
-- ON TOP of the widgets — modals are last so they appear above any
|
|
-- widget panel.
|
|
panel.render()
|
|
ui.draw()
|
|
end
|