Bumps the module version to v0.3.1 (lib-core.maps dep pin was already bumped to 0.5.7 in CT6 to allow the editor to load). Updates the file header, startup banner, cheatsheet Tab entry (now mentions all three modes), and appends a Cell-Tile Mode subsection to the README. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1842 lines
76 KiB
Lua
1842 lines
76 KiB
Lua
-- sporel-module-map-editor v0.3.1
|
|
-- Interactive map editor — Rev 4 UX architecture (see design paper
|
|
-- 2026-05-28-autotile-blob-styles-design.md §11 + plan stub
|
|
-- 2026-05-28-map-editor-blob-v2.md). 0.2.0b adds the bottom palette
|
|
-- strip (14 numbered swatches, no real tile thumbnails yet — that
|
|
-- needs a lib-core.maps tile-UV getter) + Auto-Tile vertex painting
|
|
-- via maps.set_vertex + a vertex-grid overlay. 0.2.0b.1 migrates
|
|
-- work.map.json to schema-v3 + binds surface/wall layers to the
|
|
-- blob_rect_stone material so Auto-Tile painting actually renders.
|
|
-- 0.2.0c adds Transform controls (Flip H, Reset) + Direct-mode
|
|
-- override paint that writes maps.set_override with the current
|
|
-- {slot, rot, flip} on a material-bound terrain layer. Single
|
|
-- `flip` flag (0/1) combined with rot (0..3) covers all 8 D4
|
|
-- orientations; H+V buttons are redundant so we ship just one.
|
|
-- 0.2.0c.1 adds drag-paint: LMB-hold strokes a line of paints, RMB-hold
|
|
-- a line of erases (both in Auto-Tile and Direct mode). UI clicks on
|
|
-- toolbar / palette / side-panel never enter drag-mode.
|
|
-- 0.2.0c.2 makes the palette show real tile thumbnails via the new
|
|
-- lib-core.maps 0.5.4 atlas-handle / tile-UV getters. Falls back to
|
|
-- the legacy colour swatch when atlas isn't loaded.
|
|
-- 0.2.0c.3 adds the D-toggle debug overlay: per material cell on the
|
|
-- active layer renders "S<slot>r<rot>" (and "f" when flip=1) via the
|
|
-- new lib-core.maps 0.5.5 cell_material_slot getter. Inline today;
|
|
-- extract to lib-sporel.debug-overlay when a 2nd consumer appears.
|
|
-- Decal/Entity sub-selectors + Material-Properties modal land in 0.2.0d.
|
|
--
|
|
-- All editor logic in this single file: engine sandbox only allows
|
|
-- require() for declared lib-deps; multi-file modules either use
|
|
-- dofile (causes state divergence for shared singletons) or inline
|
|
-- everything (vagrant-skeleton pattern). Inlined here via IIFE-wrapped
|
|
-- local module tables.
|
|
|
|
local maps = require("lib-core.maps")
|
|
local camera = require("lib-core.camera")
|
|
local input = require("lib-core.input")
|
|
|
|
-- CI-mode auto-exit (matches vagrant-skeleton pattern). Manifest declares
|
|
-- ci_frames=30 but engine does not auto-enforce for kind:"module"; we count
|
|
-- frames ourselves when SPOREL_CI=1 and call engine.exit(0) on the 30th.
|
|
local CI_MODE = (os.getenv("SPOREL_CI") == "1")
|
|
local CI_FRAME_LIMIT = 30
|
|
local ci_frame_count = 0
|
|
|
|
-- =====================================================================
|
|
-- state (was src/state.lua)
|
|
-- =====================================================================
|
|
local state = (function()
|
|
local M = {}
|
|
|
|
-- =====================================================================
|
|
-- State (module-singleton)
|
|
-- =====================================================================
|
|
local state = {
|
|
map_id = nil, -- set by init.lua after maps.load()
|
|
map_path = "maps/work.map.json",
|
|
active_layer = "surface", -- one of VALID_LAYER_NAMES
|
|
active_atlas = 0, -- atlas_index 0..N-1
|
|
active_tile = 1, -- tile_id
|
|
active_rot = 0, -- 0..3
|
|
active_flip = 0, -- 0.2.0c: 0 or 1 (single flip; combined with rot covers all 8 D4 orientations)
|
|
mode = "auto-tile", -- 0.2.0a: "auto-tile" | "direct"
|
|
roof_mode = false, -- if true, paint targets roof
|
|
layer_visible = {
|
|
foundation=true, subsurface=true, surface=true, topsurface=true,
|
|
lower_wall=true, wall=true, upper_wall=true, canopy=true,
|
|
},
|
|
dirty = false,
|
|
picker_open = false, -- atlas+tile picker expanded? (legacy 0.1.0)
|
|
menu_open = nil, -- 0.3.0: nil | <category_name> (e.g. "File")
|
|
submenu_open = nil, -- 0.3.0: label of the open submenu item within menu_open
|
|
modal_open = nil, -- 0.3.0: nil | <modal_id> (single-stack)
|
|
modal_text_input = "", -- 0.3.0: scratch buffer for modal text inputs
|
|
show_layers_panel = true, -- 0.3.0: Window > Layers Panel toggle
|
|
show_palette = true, -- 0.3.0: Window > Palette toggle
|
|
mouse_cell = nil, -- {x, y} or nil if mouse off-map
|
|
snap_vertex = nil, -- 0.2.0b: {vx, vy} or nil (auto-tile mode snap target)
|
|
drag_paint = nil, -- 0.2.0c.1: nil | "paint" | "erase" (active drag-stroke mode)
|
|
debug_overlay = false, -- 0.2.0c.3: D toggles per-cell slot/rot annotations
|
|
world_overlay = true, -- 0.2.0c.4: G toggles render-grid + map-grid + coords
|
|
}
|
|
|
|
-- =====================================================================
|
|
-- Setters / getters (encapsulate state for testability)
|
|
-- =====================================================================
|
|
|
|
function M.set_map(map_id, path)
|
|
state.map_id = map_id
|
|
state.map_path = path
|
|
end
|
|
|
|
function M.get_map_id() return state.map_id end
|
|
function M.get_map_path() return state.map_path end
|
|
|
|
function M.set_active_layer(name) state.active_layer = name end
|
|
function M.get_active_layer() return state.active_layer end
|
|
|
|
function M.set_active_atlas(idx) state.active_atlas = idx end
|
|
function M.get_active_atlas() return state.active_atlas end
|
|
|
|
function M.set_active_tile(id) state.active_tile = id end
|
|
function M.get_active_tile() return state.active_tile end
|
|
|
|
function M.cycle_rotation()
|
|
state.active_rot = (state.active_rot + 1) % 4
|
|
end
|
|
function M.get_active_rot() return state.active_rot end
|
|
|
|
-- 0.2.0c: transform state for Direct-mode override paint.
|
|
function M.toggle_flip() state.active_flip = (state.active_flip == 0) and 1 or 0 end
|
|
function M.get_active_flip() return state.active_flip end
|
|
function M.reset_transform()
|
|
state.active_rot = 0
|
|
state.active_flip = 0
|
|
end
|
|
|
|
function M.toggle_roof_mode()
|
|
state.roof_mode = not state.roof_mode
|
|
end
|
|
function M.is_roof_mode() return state.roof_mode end
|
|
|
|
function M.toggle_layer_visible(name)
|
|
state.layer_visible[name] = not state.layer_visible[name]
|
|
end
|
|
function M.is_layer_visible(name) return state.layer_visible[name] ~= false end
|
|
|
|
function M.mark_dirty() state.dirty = true end
|
|
function M.mark_clean() state.dirty = false end
|
|
function M.is_dirty() return state.dirty end
|
|
|
|
function M.toggle_picker_open()
|
|
state.picker_open = not state.picker_open
|
|
end
|
|
function M.is_picker_open() return state.picker_open end
|
|
|
|
-- 0.2.0a: binary mode switch (Auto-Tile vs Direct).
|
|
-- CT6: extended to three-way cycle (auto-tile / cell-tile / direct).
|
|
function M.get_mode() return state.mode end
|
|
function M.set_mode(m)
|
|
if m == "auto-tile" or m == "cell-tile" or m == "direct" then state.mode = m end
|
|
end
|
|
function M.cycle_mode()
|
|
if state.mode == "auto-tile" then
|
|
state.mode = "cell-tile"
|
|
elseif state.mode == "cell-tile" then
|
|
state.mode = "direct"
|
|
else
|
|
state.mode = "auto-tile"
|
|
end
|
|
end
|
|
-- Backwards-compat alias (still called by the input binding and the
|
|
-- toolbar mode toggle in older code paths; both now cycle 3 modes).
|
|
M.toggle_mode = M.cycle_mode
|
|
|
|
function M.is_modal_open(id)
|
|
if id == nil then return state.modal_open ~= nil end
|
|
return state.modal_open == id
|
|
end
|
|
function M.open_modal(id) state.modal_open = id end
|
|
function M.close_modal() state.modal_open = nil end
|
|
function M.get_modal_open() return state.modal_open end
|
|
|
|
-- 0.3.0: modal text-input buffer accessors
|
|
function M.get_modal_input() return state.modal_text_input end
|
|
function M.set_modal_input(s) state.modal_text_input = s end
|
|
function M.append_modal_input(s) state.modal_text_input = state.modal_text_input .. s end
|
|
function M.backspace_modal_input()
|
|
local s = state.modal_text_input
|
|
state.modal_text_input = s:sub(1, -2)
|
|
end
|
|
|
|
function M.set_menu_open(name) state.menu_open = name end
|
|
function M.get_menu_open() return state.menu_open end
|
|
function M.close_menu()
|
|
state.menu_open = nil
|
|
state.submenu_open = nil
|
|
end
|
|
|
|
function M.set_submenu_open(label) state.submenu_open = label end
|
|
function M.get_submenu_open() return state.submenu_open end
|
|
function M.close_submenu() state.submenu_open = nil end
|
|
|
|
function M.is_layers_panel_shown() return state.show_layers_panel end
|
|
function M.toggle_layers_panel() state.show_layers_panel = not state.show_layers_panel end
|
|
|
|
function M.is_palette_shown() return state.show_palette end
|
|
function M.toggle_palette() state.show_palette = not state.show_palette end
|
|
|
|
function M.set_mouse_cell(x, y)
|
|
if x == nil then
|
|
state.mouse_cell = nil
|
|
else
|
|
state.mouse_cell = { x = x, y = y }
|
|
end
|
|
end
|
|
function M.get_mouse_cell() return state.mouse_cell end
|
|
|
|
function M.set_snap_vertex(vx, vy)
|
|
if vx == nil then
|
|
state.snap_vertex = nil
|
|
else
|
|
state.snap_vertex = { vx = vx, vy = vy }
|
|
end
|
|
end
|
|
function M.get_snap_vertex() return state.snap_vertex end
|
|
|
|
function M.set_drag_paint(mode) state.drag_paint = mode end -- 0.2.0c.1
|
|
function M.get_drag_paint() return state.drag_paint end
|
|
|
|
function M.toggle_debug_overlay() state.debug_overlay = not state.debug_overlay end
|
|
function M.is_debug_overlay() return state.debug_overlay end
|
|
|
|
function M.toggle_world_overlay() state.world_overlay = not state.world_overlay end
|
|
function M.is_world_overlay() return state.world_overlay end
|
|
|
|
-- Used by tests to reset between asserts
|
|
function M.reset()
|
|
state.map_id = nil
|
|
state.active_layer = "surface"
|
|
state.active_atlas = 0
|
|
state.active_tile = 1
|
|
state.active_rot = 0
|
|
state.active_flip = 0
|
|
state.mode = "auto-tile"
|
|
state.roof_mode = false
|
|
state.dirty = false
|
|
state.picker_open = false
|
|
state.mouse_cell = nil
|
|
state.snap_vertex = nil
|
|
state.drag_paint = nil
|
|
state.menu_open = nil
|
|
state.submenu_open = nil
|
|
state.modal_open = nil
|
|
state.modal_text_input = ""
|
|
state.show_layers_panel = true
|
|
state.show_palette = true
|
|
state.debug_overlay = false
|
|
state.world_overlay = true
|
|
for name in pairs(state.layer_visible) do
|
|
state.layer_visible[name] = true
|
|
end
|
|
end
|
|
|
|
return M
|
|
end)()
|
|
|
|
-- =====================================================================
|
|
-- actions (was src/actions.lua)
|
|
-- =====================================================================
|
|
local actions = (function()
|
|
local M = {}
|
|
|
|
-- =====================================================================
|
|
-- Cell painting
|
|
-- =====================================================================
|
|
|
|
function M.paint_cell_at(cell_x, cell_y)
|
|
if state.is_roof_mode() then
|
|
maps.set_roof(cell_x, cell_y, 1)
|
|
else
|
|
local gid = maps.encode_gid(
|
|
state.get_active_atlas(),
|
|
state.get_active_tile(),
|
|
state.get_active_rot()
|
|
)
|
|
maps.set_cell_gid(state.get_active_layer(), cell_x, cell_y, gid)
|
|
end
|
|
state.mark_dirty()
|
|
end
|
|
|
|
function M.erase_cell_at(cell_x, cell_y)
|
|
if state.is_roof_mode() then
|
|
maps.set_roof(cell_x, cell_y, 0)
|
|
else
|
|
maps.set_cell_gid(state.get_active_layer(), cell_x, cell_y, 0)
|
|
end
|
|
state.mark_dirty()
|
|
end
|
|
|
|
-- 0.2.0b: Auto-Tile painting via vertex grid. set_vertex(layer, vx, vy, val)
|
|
-- causes the renderer to flip up-to-4 surrounding cells to material on
|
|
-- the next draw (any-corner rule). painted=true → fill; false → clear.
|
|
function M.paint_vertex_at(vx, vy, painted)
|
|
maps.set_vertex(state.get_active_layer(), vx, vy, painted)
|
|
state.mark_dirty()
|
|
end
|
|
|
|
-- 0.2.0c: Direct-mode override paint on a material-bound terrain layer.
|
|
-- Converts palette tile (1..14) -> lib slot (0..13). Writes bare slot
|
|
-- in canonical orientation (rot=0+flip=0), else {slot, rot, flip} object.
|
|
function M.paint_override_at(cell_x, cell_y)
|
|
local slot = state.get_active_tile() - 1 -- palette 1..14 -> lib slot 0..13
|
|
local rot = state.get_active_rot()
|
|
local flip = state.get_active_flip()
|
|
if rot == 0 and flip == 0 then
|
|
maps.set_override(state.get_active_layer(), cell_x, cell_y, slot)
|
|
else
|
|
maps.set_override(state.get_active_layer(), cell_x, cell_y,
|
|
{ slot = slot, rot = rot, flip = flip })
|
|
end
|
|
state.mark_dirty()
|
|
end
|
|
|
|
function M.erase_override_at(cell_x, cell_y)
|
|
maps.clear_override(state.get_active_layer(), cell_x, cell_y)
|
|
state.mark_dirty()
|
|
end
|
|
|
|
-- 0.3.1: Cell-Tile mode paint actions. Write the per-cell material
|
|
-- flag via lib-core.maps; the renderer's 47-blob branch picks the
|
|
-- slot.
|
|
function M.paint_cell_material_at(cell_x, cell_y)
|
|
maps.set_cell_material(state.get_active_layer(), cell_x, cell_y, true)
|
|
state.mark_dirty()
|
|
end
|
|
|
|
function M.erase_cell_material_at(cell_x, cell_y)
|
|
maps.set_cell_material(state.get_active_layer(), cell_x, cell_y, false)
|
|
state.mark_dirty()
|
|
end
|
|
|
|
-- =====================================================================
|
|
-- Hotkey-driven actions
|
|
-- =====================================================================
|
|
|
|
function M.cycle_rotation()
|
|
state.cycle_rotation()
|
|
end
|
|
|
|
function M.save()
|
|
local id = state.get_map_id()
|
|
local path = state.get_map_path()
|
|
if not id or not path then
|
|
engine.print("map-editor: cannot save — no map loaded")
|
|
return
|
|
end
|
|
maps.save_to_disk(id, path)
|
|
state.mark_clean()
|
|
engine.print(string.format("map-editor: saved '%s' to %s", id, path))
|
|
end
|
|
|
|
-- =====================================================================
|
|
-- UI-driven actions (called by ui.lua hit-tests)
|
|
-- =====================================================================
|
|
|
|
function M.set_active_layer(layer_name)
|
|
state.set_active_layer(layer_name)
|
|
end
|
|
|
|
function M.toggle_layer_visible(layer_name)
|
|
state.toggle_layer_visible(layer_name)
|
|
end
|
|
|
|
function M.toggle_roof_mode()
|
|
state.toggle_roof_mode()
|
|
end
|
|
|
|
function M.set_active_atlas(atlas_idx)
|
|
state.set_active_atlas(atlas_idx)
|
|
-- Reset to first tile of the new atlas
|
|
state.set_active_tile(1)
|
|
end
|
|
|
|
function M.set_active_tile(tile_id)
|
|
state.set_active_tile(tile_id)
|
|
end
|
|
|
|
function M.toggle_picker()
|
|
state.toggle_picker_open()
|
|
end
|
|
|
|
-- 0.2.0a actions
|
|
function M.toggle_mode() state.toggle_mode() end
|
|
function M.set_mode(m) state.set_mode(m) end
|
|
function M.open_cheatsheet() state.open_modal("cheatsheet") end
|
|
function M.close_modal() state.close_modal() end
|
|
function M.close_menu() state.close_menu() end
|
|
|
|
-- 0.2.0c actions
|
|
function M.toggle_flip() state.toggle_flip() end
|
|
function M.reset_transform() state.reset_transform() end
|
|
|
|
-- 0.2.0c.3 actions
|
|
function M.toggle_debug_overlay() state.toggle_debug_overlay() end
|
|
function M.toggle_world_overlay() state.toggle_world_overlay() end
|
|
|
|
return M
|
|
end)()
|
|
|
|
-- =====================================================================
|
|
-- ui (Rev 4 v0.2.0a — right-side Layers panel + top toolbar + cheat-
|
|
-- sheet modal. Bottom palette comes in 0.2.0b; for now the legacy
|
|
-- picker is hidden — paint behaviour unchanged.)
|
|
-- =====================================================================
|
|
local ui = (function()
|
|
local M = {}
|
|
|
|
-- =====================================================================
|
|
-- Layout constants
|
|
-- =====================================================================
|
|
-- Menu bar
|
|
local MENU_BAR_H = 24
|
|
local MENU_LABEL_PAD_X = 12
|
|
local MODE_PILL_W = 195
|
|
local MODE_PILL_H = 18
|
|
local MODE_PILL_SEG_W = 65
|
|
local MODE_PILL_MARGIN = 4
|
|
|
|
-- Dropdown
|
|
local DROPDOWN_PAD_Y = 4
|
|
local DROPDOWN_ITEM_H = 22
|
|
local DROPDOWN_INDICATOR_W = 18
|
|
local DROPDOWN_HOTKEY_W = 60
|
|
local DROPDOWN_BORDER = 1
|
|
local DROPDOWN_MIN_W = 180
|
|
local DROPDOWN_PAD_X = 10
|
|
|
|
-- Side panel (right edge, layers vis + active)
|
|
local SIDE_PANEL_W = 160
|
|
local LAYER_ROW_H = 22
|
|
local LAYER_ROW_GAP = 2
|
|
local EYE_BOX = 18
|
|
|
|
-- Bottom palette strip (above the status chip). 14 swatches packed
|
|
-- horizontally; each one click-selects state.active_tile = slot_id.
|
|
-- 0.2.0b ships nummerierte Farb-Swatches as a placeholder for real
|
|
-- tile thumbnails (those need a tile-UV getter on lib-core.maps).
|
|
local PALETTE_H = 64
|
|
local PALETTE_SWATCH_W = 48
|
|
local PALETTE_SWATCH_GAP = 4
|
|
local PALETTE_PADDING = 6
|
|
local PALETTE_SLOTS = 14 -- matches S-V2E2-RM-Blob baseline
|
|
|
|
-- Status chip (bottom-right, mouse cell + dirty)
|
|
local STATUS_W = 320
|
|
local STATUS_H = 22
|
|
local STATUS_MARGIN = 8
|
|
|
|
-- Cheatsheet modal (centered overlay)
|
|
local CHEAT_W = 380
|
|
local CHEAT_H = 280
|
|
|
|
-- Colours
|
|
local COL_TOOLBAR_BG = 0x202024
|
|
local COL_PANEL_BG = 0x202024
|
|
local COL_BUTTON_BG = 0x303034
|
|
local COL_BUTTON_BG_HOVER = 0x40404A
|
|
local COL_BUTTON_BG_ACTIVE = 0xE0E0E0
|
|
local COL_BUTTON_BORDER = 0xFFFFFF
|
|
local COL_TEXT = 0xFFFFFF
|
|
local COL_TEXT_ACTIVE = 0x202024
|
|
local COL_TEXT_MUTED = 0xA0A0A0
|
|
local COL_ROW_HOVER = 0x383844
|
|
local COL_ROW_ACTIVE = 0x504070 -- subtle purple for the active layer row
|
|
local COL_HIDDEN_OVERLAY = 0xFF4040
|
|
local COL_MODAL_BG = 0x101014
|
|
local COL_MODAL_BORDER = 0xFFFFFF
|
|
local COL_PALETTE_BG = 0x18181C
|
|
local COL_SWATCH_BORDER = 0x606060
|
|
local COL_SWATCH_SELECTED = 0xFFD060
|
|
local COL_VERTEX_GRID = 0xFFFFFF
|
|
local COL_VERTEX_DOT = 0xFFB040
|
|
local COL_VERTEX_SNAP = 0x40FF60
|
|
local COL_CHECK = 0x60E060
|
|
local COL_TEXT_DISABLED = 0x606068
|
|
|
|
-- HSV-derived swatch colors (one per slot, 1..14). Pre-computed so
|
|
-- the palette has visually distinct previews while real tile
|
|
-- thumbnails are not yet wired up.
|
|
local SWATCH_COLORS = {
|
|
0xE04040, 0xE08040, 0xE0C040, 0xA0E040,
|
|
0x40E040, 0x40E0A0, 0x40C0E0, 0x4080E0,
|
|
0x4040E0, 0xA040E0, 0xE040C0, 0xE04080,
|
|
0xA0A0A0, 0x606060,
|
|
}
|
|
|
|
-- Layers panel rendered top-down per LAYER_ORDER_TOP_DOWN from
|
|
-- lib-core.maps; matches the renderer's draw order so the topmost
|
|
-- row corresponds to the topmost-rendered layer.
|
|
local LAYER_PANEL_ROWS = {
|
|
"canopy", "upper_wall", "wall", "lower_wall",
|
|
"topsurface", "surface", "subsurface", "foundation",
|
|
}
|
|
|
|
-- Item-arrays for the dropdown system (T9).
|
|
-- mode_is must be declared before the arrays so the disabled-callbacks
|
|
-- capture it as a proper upvalue (Lua resolves upvalues at closure
|
|
-- creation time).
|
|
local function mode_is(m) return state.get_mode() == m end
|
|
|
|
local FILE_ITEMS = {
|
|
{ type = "modal", label = "New Map", modal_id = "new_map", disabled = true },
|
|
{ type = "modal", label = "Open Map", modal_id = "open_map", disabled = true },
|
|
{ type = "action", label = "Save", hotkey = "S", fire = function() actions.save() end },
|
|
{ type = "modal", label = "Save As", modal_id = "save_as" },
|
|
{ type = "separator" },
|
|
{ type = "action", label = "Quit", hotkey = "Esc", fire = function() engine.exit() end },
|
|
}
|
|
|
|
local EDIT_ITEMS = {
|
|
{ type = "action", label = "Undo", hotkey = "Ctrl+Z", disabled = true, fire = function() end },
|
|
{ type = "action", label = "Redo", hotkey = "Ctrl+Y", disabled = true, fire = function() end },
|
|
{ type = "separator" },
|
|
{ type = "action", label = "Cycle Rotation", hotkey = "R",
|
|
disabled = function() return not mode_is("direct") end,
|
|
fire = function() actions.cycle_rotation() end },
|
|
{ type = "action", label = "Toggle Flip", hotkey = "H",
|
|
disabled = function() return not mode_is("direct") end,
|
|
fire = function() actions.toggle_flip() end },
|
|
{ type = "action", label = "Reset Transform", hotkey = "0",
|
|
disabled = function() return not mode_is("direct") end,
|
|
fire = function() actions.reset_transform() end },
|
|
{ type = "separator" },
|
|
{ type = "action", label = "Erase Cell", hotkey = "E",
|
|
fire = function()
|
|
local c = state.get_mouse_cell()
|
|
if c then actions.erase_cell_at(c.x, c.y) end
|
|
end },
|
|
}
|
|
|
|
local VIEW_ITEMS = {
|
|
{ type = "toggle", label = "World Overlay", hotkey = "G",
|
|
is_on = function() return state.is_world_overlay() end,
|
|
fire = function() actions.toggle_world_overlay() end },
|
|
{ type = "toggle", label = "Debug Overlay", hotkey = "D",
|
|
is_on = function() return state.is_debug_overlay() end,
|
|
fire = function() actions.toggle_debug_overlay() end },
|
|
{ type = "separator" },
|
|
{ type = "toggle", label = "Roof Mode",
|
|
is_on = function() return state.is_roof_mode() end,
|
|
fire = function() actions.toggle_roof_mode() end },
|
|
{ type = "separator" },
|
|
{ type = "submenu", label = "Layers", items = nil }, -- populated in T10
|
|
}
|
|
|
|
local MAP_ITEMS = {
|
|
{ type = "modal", label = "Map Properties", modal_id = "map_properties" },
|
|
{ type = "separator" },
|
|
{ type = "action", label = "Reload from Disk",
|
|
fire = function()
|
|
local id = maps.load(state.get_map_path())
|
|
maps.set_current(id)
|
|
state.mark_clean()
|
|
end },
|
|
}
|
|
|
|
local TOOLS_ITEMS = {
|
|
{ type = "submenu", label = "Mode", items = nil }, -- populated in T10
|
|
}
|
|
|
|
do
|
|
local layer_items = {}
|
|
for _, name in ipairs(LAYER_PANEL_ROWS) do
|
|
local layer_name = name
|
|
layer_items[#layer_items + 1] = {
|
|
type = "toggle",
|
|
label = name,
|
|
is_on = function() return state.is_layer_visible(layer_name) end,
|
|
fire = function() state.toggle_layer_visible(layer_name) end,
|
|
}
|
|
end
|
|
for _, it in ipairs(VIEW_ITEMS) do
|
|
if it.type == "submenu" and it.label == "Layers" then
|
|
it.items = layer_items
|
|
break
|
|
end
|
|
end
|
|
end
|
|
|
|
do
|
|
local mode_items = {
|
|
{ type = "toggle", label = "Auto-Tile",
|
|
is_on = function() return mode_is("auto-tile") end,
|
|
fire = function() actions.set_mode("auto-tile") end },
|
|
{ type = "toggle", label = "Cell-Tile",
|
|
is_on = function() return mode_is("cell-tile") end,
|
|
fire = function() actions.set_mode("cell-tile") end },
|
|
{ type = "toggle", label = "Direct", hotkey = "Tab",
|
|
is_on = function() return mode_is("direct") end,
|
|
fire = function() actions.set_mode("direct") end },
|
|
}
|
|
for _, it in ipairs(TOOLS_ITEMS) do
|
|
if it.type == "submenu" and it.label == "Mode" then
|
|
it.items = mode_items
|
|
break
|
|
end
|
|
end
|
|
end
|
|
|
|
local WINDOW_ITEMS = {
|
|
{ type = "toggle", label = "Layers Panel",
|
|
is_on = function() return state.is_layers_panel_shown() end,
|
|
fire = function() state.toggle_layers_panel() end },
|
|
{ type = "toggle", label = "Palette",
|
|
is_on = function() return state.is_palette_shown() end,
|
|
fire = function() state.toggle_palette() end },
|
|
}
|
|
|
|
local HELP_ITEMS = {
|
|
{ type = "modal", label = "Cheatsheet", hotkey = "I", modal_id = "cheatsheet" },
|
|
{ type = "separator" },
|
|
{ type = "modal", label = "About", modal_id = "about" },
|
|
}
|
|
|
|
local MENU_CATEGORIES = {
|
|
{ name = "File", items = FILE_ITEMS },
|
|
{ name = "Edit", items = EDIT_ITEMS },
|
|
{ name = "View", items = VIEW_ITEMS },
|
|
{ name = "Map", items = MAP_ITEMS },
|
|
{ name = "Tools", items = TOOLS_ITEMS },
|
|
{ name = "Window", items = WINDOW_ITEMS },
|
|
{ name = "Help", items = HELP_ITEMS },
|
|
}
|
|
|
|
-- Cheatsheet content (rendered into the modal).
|
|
local CHEATSHEET = {
|
|
{ key="Tab", action="Cycle mode (Auto-Tile / Cell-Tile / Direct)" },
|
|
{ key="I", action="Show / hide this cheatsheet" },
|
|
{ key="S", action="Save current map to work.map.json" },
|
|
{ key="E", action="Erase tile at mouse cell" },
|
|
{ key="R", action="Cycle tile rotation 0->90->180->270 (Direct only)" },
|
|
{ key="H", action="Toggle tile flip 0<->1 (Direct only)" },
|
|
{ key="0", action="Reset transform (rot=0, flip=0)" },
|
|
{ key="D", action="Toggle debug overlay (per-cell slot/rot labels)" },
|
|
{ key="G", action="Toggle world overlay" },
|
|
{ key="Esc", action="Close modal / close menu / quit" },
|
|
{ key="LMB on menu category", action="Open dropdown" },
|
|
{ key="LMB-drag canvas (Auto-Tile)", action="Stroke-paint map-tiles (any-corner fill)" },
|
|
{ key="RMB-drag canvas (Auto-Tile)", action="Stroke-clear map-tiles" },
|
|
{ key="LMB-drag canvas (Direct)", action="Stroke-paint cells (override with slot+rot+flip)" },
|
|
{ key="RMB-drag canvas (Direct)", action="Stroke-clear overrides" },
|
|
{ key="LMB on palette swatch", action="Select slot (1-14) as active tile" },
|
|
{ key="RMB on Layer row", action="Toggle layer visibility" },
|
|
{ key="LMB on Layer row", action="Set as active layer" },
|
|
}
|
|
|
|
-- =====================================================================
|
|
-- Modal framework (0.3.0)
|
|
-- Each modal: { title, w, h, render(mx, my), on_click(mx, my, btn) -> close? }
|
|
-- Single-stack: only state.modal_open at a time. Esc + click-outside
|
|
-- handled centrally in the dispatcher.
|
|
-- =====================================================================
|
|
local MODALS = {} -- populated below
|
|
|
|
-- Dispatcher returns true if the click was consumed (always true when a
|
|
-- modal is open — modals fully absorb input).
|
|
local function dispatch_modal_click(mx, my, button)
|
|
local id = state.get_modal_open()
|
|
if not id then return false end
|
|
local m = MODALS[id]
|
|
if not m then
|
|
-- Defensive: unknown id, close it.
|
|
state.close_modal()
|
|
return true
|
|
end
|
|
local close = m.on_click(mx, my, button)
|
|
if close then state.close_modal() end
|
|
return true
|
|
end
|
|
|
|
-- =====================================================================
|
|
-- Helpers
|
|
-- =====================================================================
|
|
|
|
local function rgba(rgb, alpha)
|
|
return (rgb << 8) | (alpha & 0xFF)
|
|
end
|
|
|
|
local function in_rect(mx, my, x, y, w, h)
|
|
return mx >= x and mx < x + w and my >= y and my < y + h
|
|
end
|
|
|
|
-- =====================================================================
|
|
-- Layout computers (mode-aware; recomputed per frame for screen
|
|
-- resize robustness — engine.window.size() is cheap).
|
|
-- =====================================================================
|
|
|
|
-- 0.3.0: Mode-pill anchored to the right end of the menu bar.
|
|
local function mode_pill_layout()
|
|
local screen_w = engine.window.size()
|
|
local x = screen_w - MODE_PILL_MARGIN - MODE_PILL_W
|
|
local y = (MENU_BAR_H - MODE_PILL_H) / 2
|
|
return {
|
|
x = x, y = y,
|
|
auto = { x = x, y = y, w = MODE_PILL_SEG_W, h = MODE_PILL_H, mode = "auto-tile" },
|
|
cell = { x = x + MODE_PILL_SEG_W, y = y, w = MODE_PILL_SEG_W, h = MODE_PILL_H, mode = "cell-tile" },
|
|
direct = { x = x + MODE_PILL_SEG_W * 2, y = y, w = MODE_PILL_SEG_W, h = MODE_PILL_H, mode = "direct" },
|
|
}
|
|
end
|
|
|
|
local function draw_mode_pill()
|
|
local lay = mode_pill_layout()
|
|
local current = state.get_mode()
|
|
for _, seg in ipairs({ lay.auto, lay.cell, lay.direct }) do
|
|
local active = (current == seg.mode)
|
|
local bg = active and COL_BUTTON_BG_ACTIVE or COL_BUTTON_BG
|
|
local fg = active and COL_TEXT_ACTIVE or COL_TEXT
|
|
engine.render.draw_rect(seg.x, seg.y, seg.w, seg.h, rgba(bg, 0xFF))
|
|
engine.render.draw_rect_lines(seg.x, seg.y, seg.w, seg.h, rgba(COL_BUTTON_BORDER, 0x40))
|
|
local label
|
|
if seg.mode == "auto-tile" then label = "Auto-Tile"
|
|
elseif seg.mode == "cell-tile" then label = "Cell-Tile"
|
|
else label = "Direct"
|
|
end
|
|
engine.render.draw_text(label, seg.x + 8, seg.y + 3, 11, rgba(fg, 0xFF))
|
|
end
|
|
end
|
|
|
|
-- Returns the hit-rect table for each menu-bar category label.
|
|
-- Uses engine.render.measure_text (exposed in P.3.4).
|
|
local function menu_bar_categories_layout()
|
|
local out = {}
|
|
local x = MENU_LABEL_PAD_X
|
|
local font_size = 12
|
|
for _, cat in ipairs(MENU_CATEGORIES) do
|
|
local label_w = engine.render.measure_text(cat.name, font_size)
|
|
local hit_w = label_w + MENU_LABEL_PAD_X * 2
|
|
out[#out + 1] = {
|
|
name = cat.name,
|
|
items = cat.items,
|
|
x = x,
|
|
y = 0,
|
|
w = hit_w,
|
|
h = MENU_BAR_H,
|
|
label_x = x + MENU_LABEL_PAD_X,
|
|
}
|
|
x = x + hit_w
|
|
end
|
|
return out
|
|
end
|
|
|
|
local function dropdown_width_for(items)
|
|
local w = DROPDOWN_MIN_W
|
|
for _, it in ipairs(items) do
|
|
if it.type ~= "separator" then
|
|
local label_w = engine.render.measure_text(it.label or "", 12)
|
|
local needed = DROPDOWN_INDICATOR_W + label_w + DROPDOWN_HOTKEY_W + 2 * DROPDOWN_PAD_X
|
|
if needed > w then w = needed end
|
|
end
|
|
end
|
|
return w
|
|
end
|
|
|
|
local function dropdown_layout(cat_layout)
|
|
local items = cat_layout.items
|
|
local w = dropdown_width_for(items)
|
|
local rows = {}
|
|
local y = cat_layout.y + cat_layout.h + DROPDOWN_PAD_Y
|
|
for i, it in ipairs(items) do
|
|
local row_h = (it.type == "separator") and 6 or DROPDOWN_ITEM_H
|
|
rows[i] = { item = it, x = cat_layout.x, y = y, w = w, h = row_h }
|
|
y = y + row_h
|
|
end
|
|
return {
|
|
x = cat_layout.x,
|
|
y = cat_layout.y + cat_layout.h + DROPDOWN_PAD_Y,
|
|
w = w,
|
|
h = y - (cat_layout.y + cat_layout.h + DROPDOWN_PAD_Y) + DROPDOWN_PAD_Y,
|
|
rows = rows,
|
|
}
|
|
end
|
|
|
|
local function item_is_disabled(item)
|
|
local d = item.disabled
|
|
if type(d) == "function" then return d() end
|
|
return d == true
|
|
end
|
|
|
|
local function draw_dropdown_item(row, hovered)
|
|
local it = row.item
|
|
if it.type == "separator" then
|
|
engine.render.draw_line(row.x + 6, row.y + 3,
|
|
row.x + row.w - 6, row.y + 3,
|
|
rgba(COL_TEXT_MUTED, 0x60))
|
|
return
|
|
end
|
|
local disabled = item_is_disabled(it)
|
|
if hovered and not disabled then
|
|
engine.render.draw_rect(row.x, row.y, row.w, row.h,
|
|
rgba(COL_ROW_HOVER, 0xFF))
|
|
end
|
|
local text_col = disabled and COL_TEXT_DISABLED or COL_TEXT
|
|
-- Left indicator gutter (toggle: [v] / [ ])
|
|
if it.type == "toggle" then
|
|
local on = it.is_on and it.is_on() or false
|
|
engine.render.draw_text(on and "[v]" or "[ ]",
|
|
row.x + 4, row.y + 5, 11,
|
|
rgba(on and COL_CHECK or COL_TEXT_MUTED, 0xFF))
|
|
end
|
|
-- Label
|
|
local label = it.label or ""
|
|
if it.type == "modal" then label = label .. "..." end
|
|
engine.render.draw_text(label,
|
|
row.x + DROPDOWN_INDICATOR_W + 2, row.y + 5, 12,
|
|
rgba(text_col, 0xFF))
|
|
-- Right column: hotkey or submenu chevron
|
|
if it.type == "submenu" then
|
|
engine.render.draw_text(">",
|
|
row.x + row.w - DROPDOWN_PAD_X - 6, row.y + 5, 12,
|
|
rgba(COL_TEXT_MUTED, 0xFF))
|
|
elseif it.hotkey then
|
|
engine.render.draw_text(it.hotkey,
|
|
row.x + row.w - DROPDOWN_HOTKEY_W, row.y + 5, 11,
|
|
rgba(COL_TEXT_MUTED, 0xFF))
|
|
end
|
|
end
|
|
|
|
local function draw_dropdown(cat_layout, mx, my)
|
|
local dd = dropdown_layout(cat_layout)
|
|
engine.render.draw_rect(dd.x, dd.y, dd.w, dd.h, rgba(COL_MODAL_BG, 0xFA))
|
|
engine.render.draw_rect_lines(dd.x, dd.y, dd.w, dd.h, rgba(COL_MODAL_BORDER, 0xFF))
|
|
for _, row in ipairs(dd.rows) do
|
|
local hovered = in_rect(mx, my, row.x, row.y, row.w, row.h)
|
|
draw_dropdown_item(row, hovered)
|
|
end
|
|
|
|
-- Submenu render (one level deep)
|
|
local sub_label = state.get_submenu_open()
|
|
if sub_label then
|
|
for _, row in ipairs(dd.rows) do
|
|
local it = row.item
|
|
if it.type == "submenu" and it.label == sub_label and it.items then
|
|
local sub_w = dropdown_width_for(it.items)
|
|
local sub_x = dd.x + dd.w
|
|
local sub_y = row.y
|
|
local sub_h = 0
|
|
for _, si in ipairs(it.items) do
|
|
sub_h = sub_h + ((si.type == "separator") and 6 or DROPDOWN_ITEM_H)
|
|
end
|
|
sub_h = sub_h + DROPDOWN_PAD_Y * 2
|
|
engine.render.draw_rect(sub_x, sub_y, sub_w, sub_h,
|
|
rgba(COL_MODAL_BG, 0xFA))
|
|
engine.render.draw_rect_lines(sub_x, sub_y, sub_w, sub_h,
|
|
rgba(COL_MODAL_BORDER, 0xFF))
|
|
local sy = sub_y + DROPDOWN_PAD_Y
|
|
for _, si in ipairs(it.items) do
|
|
local row_h = (si.type == "separator") and 6 or DROPDOWN_ITEM_H
|
|
local sub_row = { item = si, x = sub_x, y = sy, w = sub_w, h = row_h }
|
|
local hovered = in_rect(mx, my, sub_row.x, sub_row.y, sub_row.w, sub_row.h)
|
|
draw_dropdown_item(sub_row, hovered)
|
|
sy = sy + row_h
|
|
end
|
|
break
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
local function fire_item(it)
|
|
if item_is_disabled(it) then return false end -- absorbed, no close
|
|
if it.type == "action" then
|
|
if it.fire then it.fire() end
|
|
return true -- close menu
|
|
elseif it.type == "modal" then
|
|
if it.modal_id then state.open_modal(it.modal_id) end
|
|
return true
|
|
elseif it.type == "toggle" then
|
|
if it.fire then it.fire() end
|
|
return true
|
|
elseif it.type == "submenu" then
|
|
return false -- handled separately
|
|
end
|
|
return false
|
|
end
|
|
|
|
local function draw_menu_bar(mx, my)
|
|
local screen_w = engine.window.size()
|
|
engine.render.draw_rect(0, 0, screen_w, MENU_BAR_H, rgba(COL_TOOLBAR_BG, 0xFF))
|
|
local open = state.get_menu_open()
|
|
for _, cat in ipairs(menu_bar_categories_layout()) do
|
|
local hovered = in_rect(mx, my, cat.x, cat.y, cat.w, cat.h)
|
|
local active = (open == cat.name)
|
|
if active or hovered then
|
|
engine.render.draw_rect(cat.x, cat.y, cat.w, cat.h,
|
|
rgba(active and COL_BUTTON_BG_ACTIVE or COL_BUTTON_BG_HOVER, 0xFF))
|
|
end
|
|
local fg = active and COL_TEXT_ACTIVE or COL_TEXT
|
|
engine.render.draw_text(cat.name, cat.label_x, 5, 12, rgba(fg, 0xFF))
|
|
end
|
|
end
|
|
|
|
local function side_panel_layout()
|
|
local screen_w, screen_h = engine.window.size()
|
|
return {
|
|
x = screen_w - SIDE_PANEL_W,
|
|
y = MENU_BAR_H,
|
|
w = SIDE_PANEL_W,
|
|
h = screen_h - MENU_BAR_H - PALETTE_H,
|
|
}
|
|
end
|
|
|
|
-- Returns array of { name, x, y, w, h, eye_x, eye_y, eye_w, eye_h }
|
|
-- for each layer row in the side panel.
|
|
local function side_panel_rows_layout()
|
|
local panel = side_panel_layout()
|
|
local out = {}
|
|
local row_y = panel.y + 6
|
|
for i, name in ipairs(LAYER_PANEL_ROWS) do
|
|
out[i] = {
|
|
name = name,
|
|
x = panel.x + 4,
|
|
y = row_y,
|
|
w = panel.w - 8,
|
|
h = LAYER_ROW_H,
|
|
eye_x = panel.x + 6,
|
|
eye_y = row_y + (LAYER_ROW_H - EYE_BOX) / 2,
|
|
eye_w = EYE_BOX,
|
|
eye_h = EYE_BOX,
|
|
}
|
|
row_y = row_y + LAYER_ROW_H + LAYER_ROW_GAP
|
|
end
|
|
return out
|
|
end
|
|
|
|
local function palette_layout()
|
|
local screen_w, screen_h = engine.window.size()
|
|
return {
|
|
x = 0,
|
|
y = screen_h - PALETTE_H,
|
|
w = screen_w - SIDE_PANEL_W,
|
|
h = PALETTE_H,
|
|
}
|
|
end
|
|
|
|
-- Returns array of { slot_id, x, y, w, h } for each swatch in the
|
|
-- palette strip, packed left-to-right.
|
|
local function palette_swatches_layout()
|
|
local p = palette_layout()
|
|
local out = {}
|
|
local x = p.x + PALETTE_PADDING
|
|
local y = p.y + PALETTE_PADDING
|
|
local h = p.h - 2 * PALETTE_PADDING
|
|
for slot = 1, PALETTE_SLOTS do
|
|
out[slot] = {
|
|
slot_id = slot,
|
|
x = x, y = y, w = PALETTE_SWATCH_W, h = h,
|
|
}
|
|
x = x + PALETTE_SWATCH_W + PALETTE_SWATCH_GAP
|
|
end
|
|
return out
|
|
end
|
|
|
|
local function status_layout()
|
|
local screen_w, screen_h = engine.window.size()
|
|
return {
|
|
x = STATUS_MARGIN,
|
|
y = screen_h - PALETTE_H - STATUS_H - STATUS_MARGIN,
|
|
w = STATUS_W,
|
|
h = STATUS_H,
|
|
}
|
|
end
|
|
|
|
local function cheatsheet_layout()
|
|
local screen_w, screen_h = engine.window.size()
|
|
return {
|
|
x = (screen_w - CHEAT_W) / 2,
|
|
y = (screen_h - CHEAT_H) / 2,
|
|
w = CHEAT_W,
|
|
h = CHEAT_H,
|
|
}
|
|
end
|
|
|
|
-- MODALS entries (defined here, after helpers + layout fns are declared,
|
|
-- so closures can reference rgba / cheatsheet_layout as upvalues).
|
|
MODALS.cheatsheet = {
|
|
title = "Hotkeys (I to close)",
|
|
w = CHEAT_W,
|
|
h = CHEAT_H,
|
|
render = function(mx, my)
|
|
local c = cheatsheet_layout()
|
|
-- Dim the whole screen behind the modal
|
|
local screen_w, screen_h = engine.window.size()
|
|
engine.render.draw_rect(0, 0, screen_w, screen_h, rgba(0x000000, 0xA0))
|
|
engine.render.draw_rect(c.x, c.y, c.w, c.h, rgba(COL_MODAL_BG, 0xFF))
|
|
engine.render.draw_rect_lines(c.x, c.y, c.w, c.h, rgba(COL_MODAL_BORDER, 0xFF))
|
|
engine.render.draw_text("Hotkeys (I to close)", c.x + 12, c.y + 10, 14,
|
|
rgba(COL_TEXT, 0xFF))
|
|
local row_y = c.y + 38
|
|
for _, entry in ipairs(CHEATSHEET) do
|
|
engine.render.draw_text(entry.key, c.x + 16, row_y, 12, rgba(COL_TEXT, 0xFF))
|
|
engine.render.draw_text(entry.action, c.x + 140, row_y, 12, rgba(COL_TEXT_MUTED, 0xFF))
|
|
row_y = row_y + 20
|
|
end
|
|
end,
|
|
on_click = function(mx, my, button)
|
|
local c = cheatsheet_layout()
|
|
return in_rect(mx, my, c.x, c.y, c.w, c.h) -- click inside closes
|
|
end,
|
|
}
|
|
|
|
-- 0.3.0: save_as — text input degraded (no poll_char API exposed to Lua);
|
|
-- filename is auto-generated as untitled-<os.time>.map.json on Save.
|
|
MODALS.save_as = {
|
|
title = "Save As",
|
|
w = 360,
|
|
h = 140,
|
|
render = function(mx, my)
|
|
local screen_w, screen_h = engine.window.size()
|
|
local x = (screen_w - 360) / 2
|
|
local y = (screen_h - 140) / 2
|
|
engine.render.draw_rect(x, y, 360, 140, rgba(COL_MODAL_BG, 0xF0))
|
|
engine.render.draw_rect_lines(x, y, 360, 140, rgba(COL_MODAL_BORDER, 0xFF))
|
|
engine.render.draw_text("Save As", x + 12, y + 10, 14, rgba(COL_TEXT, 0xFF))
|
|
engine.render.draw_text("Will save as:",
|
|
x + 12, y + 40, 11, rgba(COL_TEXT_MUTED, 0xFF))
|
|
local preview = string.format("untitled-%d.map.json", os.time())
|
|
engine.render.draw_rect(x + 12, y + 58, 336, 22, rgba(COL_BUTTON_BG, 0xFF))
|
|
engine.render.draw_text(preview, x + 18, y + 62, 11, rgba(COL_TEXT_MUTED, 0xFF))
|
|
engine.render.draw_rect(x + 200, y + 100, 70, 24,
|
|
rgba(COL_BUTTON_BG, 0xFF))
|
|
engine.render.draw_rect_lines(x + 200, y + 100, 70, 24,
|
|
rgba(COL_BUTTON_BORDER, 0xFF))
|
|
engine.render.draw_text("Save", x + 220, y + 106, 12, rgba(COL_TEXT, 0xFF))
|
|
engine.render.draw_rect(x + 278, y + 100, 70, 24,
|
|
rgba(COL_BUTTON_BG, 0xFF))
|
|
engine.render.draw_rect_lines(x + 278, y + 100, 70, 24,
|
|
rgba(COL_BUTTON_BORDER, 0xFF))
|
|
engine.render.draw_text("Cancel", x + 292, y + 106, 12, rgba(COL_TEXT, 0xFF))
|
|
end,
|
|
on_click = function(mx, my, button)
|
|
local screen_w, screen_h = engine.window.size()
|
|
local x = (screen_w - 360) / 2
|
|
local y = (screen_h - 140) / 2
|
|
if in_rect(mx, my, x + 200, y + 100, 70, 24) then
|
|
local name = string.format("untitled-%d.map.json", os.time())
|
|
maps.save_to_disk(state.get_map_id(), "maps/" .. name)
|
|
return true
|
|
end
|
|
if in_rect(mx, my, x + 278, y + 100, 70, 24) then
|
|
return true
|
|
end
|
|
return false
|
|
end,
|
|
}
|
|
|
|
-- 0.3.0: map_properties — read-only inspector (map id, size, atlas list).
|
|
MODALS.map_properties = {
|
|
title = "Map Properties",
|
|
w = 420,
|
|
h = 320,
|
|
render = function(mx, my)
|
|
local screen_w, screen_h = engine.window.size()
|
|
local x = (screen_w - 420) / 2
|
|
local y = (screen_h - 320) / 2
|
|
engine.render.draw_rect(x, y, 420, 320, rgba(COL_MODAL_BG, 0xF0))
|
|
engine.render.draw_rect_lines(x, y, 420, 320, rgba(COL_MODAL_BORDER, 0xFF))
|
|
engine.render.draw_text("Map Properties", x + 12, y + 10, 14,
|
|
rgba(COL_TEXT, 0xFF))
|
|
local id = state.get_map_id() or "(none)"
|
|
engine.render.draw_text("ID: " .. id, x + 12, y + 40, 11,
|
|
rgba(COL_TEXT, 0xFF))
|
|
local sz = maps.size()
|
|
local size_str = sz and (sz.w .. " x " .. sz.h) or "(unknown)"
|
|
engine.render.draw_text("Size: " .. size_str, x + 12, y + 60, 11,
|
|
rgba(COL_TEXT, 0xFF))
|
|
local ac = maps.atlas_count() or 0
|
|
engine.render.draw_text("Atlases: " .. tostring(ac),
|
|
x + 12, y + 80, 11, rgba(COL_TEXT, 0xFF))
|
|
for i = 0, ac - 1 do
|
|
engine.render.draw_text(" [" .. i .. "] " .. (maps.atlas_id_at(i) or "?"),
|
|
x + 12, y + 100 + i * 16, 11, rgba(COL_TEXT_MUTED, 0xFF))
|
|
end
|
|
engine.render.draw_rect(x + 340, y + 280, 70, 24,
|
|
rgba(COL_BUTTON_BG, 0xFF))
|
|
engine.render.draw_rect_lines(x + 340, y + 280, 70, 24,
|
|
rgba(COL_BUTTON_BORDER, 0xFF))
|
|
engine.render.draw_text("Close", x + 358, y + 286, 12, rgba(COL_TEXT, 0xFF))
|
|
end,
|
|
on_click = function(mx, my, button)
|
|
local screen_w, screen_h = engine.window.size()
|
|
local x = (screen_w - 420) / 2
|
|
local y = (screen_h - 320) / 2
|
|
if in_rect(mx, my, x + 340, y + 280, 70, 24) then
|
|
return true
|
|
end
|
|
return false
|
|
end,
|
|
}
|
|
|
|
-- 0.3.0: about — static text only.
|
|
MODALS.about = {
|
|
title = "About",
|
|
w = 320,
|
|
h = 200,
|
|
render = function(mx, my)
|
|
local screen_w, screen_h = engine.window.size()
|
|
local x = (screen_w - 320) / 2
|
|
local y = (screen_h - 200) / 2
|
|
engine.render.draw_rect(x, y, 320, 200, rgba(COL_MODAL_BG, 0xF0))
|
|
engine.render.draw_rect_lines(x, y, 320, 200, rgba(COL_MODAL_BORDER, 0xFF))
|
|
engine.render.draw_text("Sporel Map Editor",
|
|
x + 12, y + 14, 14, rgba(COL_TEXT, 0xFF))
|
|
engine.render.draw_text("Module: map-editor v0.3.0",
|
|
x + 12, y + 44, 11, rgba(COL_TEXT, 0xFF))
|
|
engine.render.draw_text("Maps lib: lib-core.maps v0.5.6",
|
|
x + 12, y + 62, 11, rgba(COL_TEXT, 0xFF))
|
|
engine.render.draw_rect(x + 240, y + 160, 70, 24,
|
|
rgba(COL_BUTTON_BG, 0xFF))
|
|
engine.render.draw_rect_lines(x + 240, y + 160, 70, 24,
|
|
rgba(COL_BUTTON_BORDER, 0xFF))
|
|
engine.render.draw_text("Close", x + 258, y + 166, 12, rgba(COL_TEXT, 0xFF))
|
|
end,
|
|
on_click = function(mx, my, button)
|
|
local screen_w, screen_h = engine.window.size()
|
|
local x = (screen_w - 320) / 2
|
|
local y = (screen_h - 200) / 2
|
|
if in_rect(mx, my, x + 240, y + 160, 70, 24) then
|
|
return true
|
|
end
|
|
return false
|
|
end,
|
|
}
|
|
|
|
-- =====================================================================
|
|
-- Drawing
|
|
-- =====================================================================
|
|
|
|
local function draw_side_panel(mx, my)
|
|
local p = side_panel_layout()
|
|
engine.render.draw_rect(p.x, p.y, p.w, p.h, rgba(COL_PANEL_BG, 0xFF))
|
|
engine.render.draw_text("Layers", p.x + 6, p.y + 4, 11, rgba(COL_TEXT_MUTED, 0xFF))
|
|
|
|
for _, row in ipairs(side_panel_rows_layout()) do
|
|
local is_active = (state.get_active_layer() == row.name)
|
|
local is_hover = in_rect(mx, my, row.x, row.y, row.w, row.h)
|
|
local is_visible = state.is_layer_visible(row.name)
|
|
-- Row background (active highlight or hover dim)
|
|
if is_active then
|
|
engine.render.draw_rect(row.x, row.y, row.w, row.h, rgba(COL_ROW_ACTIVE, 0xFF))
|
|
elseif is_hover then
|
|
engine.render.draw_rect(row.x, row.y, row.w, row.h, rgba(COL_ROW_HOVER, 0xFF))
|
|
end
|
|
-- Eye-icon box
|
|
engine.render.draw_rect_lines(row.eye_x, row.eye_y, row.eye_w, row.eye_h,
|
|
rgba(COL_TEXT_MUTED, 0xFF))
|
|
local eye_label = is_visible and "+" or "x"
|
|
engine.render.draw_text(eye_label, row.eye_x + 5, row.eye_y + 3, 12,
|
|
rgba(is_visible and COL_TEXT or COL_HIDDEN_OVERLAY, 0xFF))
|
|
-- Layer name (with active marker)
|
|
local marker = is_active and ">" or " "
|
|
engine.render.draw_text(marker .. " " .. row.name,
|
|
row.eye_x + row.eye_w + 6, row.y + 4, 12, rgba(COL_TEXT, 0xFF))
|
|
end
|
|
|
|
-- Roof toggle below the layers list
|
|
local last = side_panel_rows_layout()[#LAYER_PANEL_ROWS]
|
|
local roof_y = last.y + last.h + 12
|
|
local roof_label = state.is_roof_mode() and "Roof: [ON]" or "Roof: [OFF]"
|
|
engine.render.draw_text(roof_label, p.x + 6, roof_y, 12,
|
|
rgba(state.is_roof_mode() and COL_TEXT_ACTIVE or COL_TEXT_MUTED, 0xFF))
|
|
end
|
|
|
|
local function draw_palette(mx, my)
|
|
local p = palette_layout()
|
|
engine.render.draw_rect(p.x, p.y, p.w, p.h, rgba(COL_PALETTE_BG, 0xFF))
|
|
engine.render.draw_text("Palette (slot 1-14)", p.x + 6, p.y + 4, 11,
|
|
rgba(COL_TEXT_MUTED, 0xFF))
|
|
|
|
-- 0.2.0c.2: real tile thumbnails via maps.atlas_tile_uv. Atlas
|
|
-- index 0 (single-atlas maps); falls back to the colour swatch
|
|
-- if the atlas / tile UV / texture isn't available.
|
|
local atlas_idx = state.get_active_atlas()
|
|
local tex = maps.atlas_diffuse_handle(atlas_idx)
|
|
|
|
local selected_slot = state.get_active_tile()
|
|
for _, sw in ipairs(palette_swatches_layout()) do
|
|
local is_selected = (sw.slot_id == selected_slot)
|
|
local is_hover = in_rect(mx, my, sw.x, sw.y, sw.w, sw.h)
|
|
-- Always draw a dark BG so transparent atlas tiles read.
|
|
engine.render.draw_rect(sw.x, sw.y, sw.w, sw.h, rgba(0x282830, 0xFF))
|
|
-- Tile thumbnail: scale the atlas sub-tex to fit the swatch.
|
|
local uv = tex and maps.atlas_tile_uv(atlas_idx, sw.slot_id - 1)
|
|
if tex and uv then
|
|
local scale_x = sw.w / uv.w
|
|
local scale_y = sw.h / uv.h
|
|
engine.render.draw_sprite_transform(
|
|
tex,
|
|
sw.x + sw.w / 2, sw.y + sw.h / 2,
|
|
0, scale_x, scale_y,
|
|
uv.w / 2, uv.h / 2,
|
|
0xFFFFFFFF,
|
|
uv.x, uv.y, uv.w, uv.h
|
|
)
|
|
else
|
|
-- Fallback: legacy coloured placeholder.
|
|
engine.render.draw_rect(sw.x, sw.y, sw.w, sw.h,
|
|
rgba(SWATCH_COLORS[sw.slot_id] or 0x808080, 0xFF))
|
|
end
|
|
-- Border + label on top.
|
|
local border_col = is_selected and COL_SWATCH_SELECTED
|
|
or is_hover and COL_TEXT
|
|
or COL_SWATCH_BORDER
|
|
engine.render.draw_rect_lines(sw.x, sw.y, sw.w, sw.h,
|
|
rgba(border_col, 0xFF))
|
|
engine.render.draw_text(tostring(sw.slot_id), sw.x + 4, sw.y + 4, 11,
|
|
rgba(COL_TEXT, 0xE0))
|
|
end
|
|
end
|
|
|
|
local function draw_status(mx, my)
|
|
local s = status_layout()
|
|
engine.render.draw_rect(s.x, s.y, s.w, s.h, rgba(COL_TOOLBAR_BG, 0xC0))
|
|
engine.render.draw_rect_lines(s.x, s.y, s.w, s.h, rgba(COL_TEXT_MUTED, 0xFF))
|
|
local cell = state.get_mouse_cell()
|
|
local cell_str = cell and string.format("Mouse: (%d,%d)", cell.x, cell.y) or "Mouse: (-,-)"
|
|
local dirty_str = state.is_dirty() and " * unsaved" or ""
|
|
-- 0.2.0c: append the current direct-paint transform state.
|
|
local xform_str = string.format(" slot=%d rot=%d flip=%d",
|
|
state.get_active_tile(), state.get_active_rot(), state.get_active_flip())
|
|
engine.render.draw_text(cell_str .. dirty_str .. xform_str, s.x + 4, s.y + 4, 12,
|
|
rgba(COL_TEXT, 0xFF))
|
|
end
|
|
|
|
-- =====================================================================
|
|
-- Public API
|
|
-- =====================================================================
|
|
|
|
function M.draw()
|
|
local mx, my = engine.input.get_mouse_pos()
|
|
draw_menu_bar(mx, my)
|
|
local open_name = state.get_menu_open()
|
|
if open_name then
|
|
for _, cat in ipairs(menu_bar_categories_layout()) do
|
|
if cat.name == open_name then
|
|
draw_dropdown(cat, mx, my)
|
|
break
|
|
end
|
|
end
|
|
end
|
|
draw_mode_pill()
|
|
if state.is_layers_panel_shown() then
|
|
draw_side_panel(mx, my)
|
|
end
|
|
if state.is_palette_shown() then
|
|
draw_palette(mx, my)
|
|
end
|
|
draw_status(mx, my)
|
|
local mid = state.get_modal_open()
|
|
if mid and MODALS[mid] then
|
|
MODALS[mid].render(mx, my)
|
|
end
|
|
end
|
|
|
|
-- 0.2.0c.3 debug overlay: per-cell slot/rot annotation. Independent
|
|
-- of mode toggle — useful in both Auto-Tile (verify bitmask path)
|
|
-- and Direct (verify override write). Iterates the active layer.
|
|
function M.draw_debug_overlay(map_w, map_h, t_size)
|
|
if not state.is_debug_overlay() then return end
|
|
local layer = state.get_active_layer()
|
|
for y = 0, map_h - 1 do
|
|
for x = 0, map_w - 1 do
|
|
local rec = maps.cell_material_slot(layer, x, y)
|
|
if rec then
|
|
-- text at cell center, e.g. "S7r1" or "S13r0f1"
|
|
local txt = string.format("S%dr%d%s",
|
|
rec.slot, rec.rot, rec.flip == 1 and "f" or "")
|
|
engine.render.draw_text(txt,
|
|
x * t_size + 4, y * t_size + 4, 9,
|
|
rgba(COL_VERTEX_SNAP, 0xFF))
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- World-space overlay rendered between maps.draw_map and camera.finish.
|
|
-- 0.2.0c.4: G-toggle gates the overlay; the displayed grid is
|
|
-- mode-aware. Auto-tile mode paints map-tiles, so we show the
|
|
-- map-grid (paint-tile boundaries, offset by half a tile from
|
|
-- render-cell boundaries) plus the vertex dots and snap target.
|
|
-- Direct mode paints cells, so we show the render-cell grid with
|
|
-- a lighter stroke and skip the dots / snap.
|
|
-- snap = { vx, vy } or nil when the cursor is off-map.
|
|
function M.draw_world_overlay(map_w, map_h, t_size, snap)
|
|
if not state.is_world_overlay() then return end
|
|
local mode = state.get_mode()
|
|
|
|
if mode == "auto-tile" then
|
|
-- Map-grid lines: boundaries between adjacent map-tiles, which
|
|
-- in dual-grid sit at half-tile offsets from render-cell
|
|
-- boundaries (= at render-cell centres).
|
|
local map_col = rgba(COL_VERTEX_GRID, 0x60)
|
|
local half = t_size / 2
|
|
for x = 0, map_w - 1 do
|
|
local px = x * t_size + half
|
|
engine.render.draw_line(px, 0, px, map_h * t_size, map_col)
|
|
end
|
|
for y = 0, map_h - 1 do
|
|
local py = y * t_size + half
|
|
engine.render.draw_line(0, py, map_w * t_size, py, map_col)
|
|
end
|
|
|
|
-- Vertex dots (paint snap-targets, one per map-tile centre).
|
|
local dot_col = rgba(COL_VERTEX_DOT, 0xC0)
|
|
for vy = 0, map_h do
|
|
for vx = 0, map_w do
|
|
local cx = vx * t_size - 2
|
|
local cy = vy * t_size - 2
|
|
engine.render.draw_rect(cx, cy, 4, 4, dot_col)
|
|
end
|
|
end
|
|
|
|
-- Snapped vertex highlight (where LMB will paint).
|
|
if snap then
|
|
local px = snap.vx * t_size
|
|
local py = snap.vy * t_size
|
|
engine.render.draw_rect_lines(px - 6, py - 6, 12, 12,
|
|
rgba(COL_VERTEX_SNAP, 0xFF))
|
|
end
|
|
elseif mode == "cell-tile" then
|
|
-- Cell-tile mode: faint render-cell grid (same density as Direct).
|
|
local grid_col = rgba(COL_VERTEX_GRID, 0x40)
|
|
for x = 0, map_w do
|
|
engine.render.draw_line(x * t_size, 0, x * t_size, map_h * t_size, grid_col)
|
|
end
|
|
for y = 0, map_h do
|
|
engine.render.draw_line(0, y * t_size, map_w * t_size, y * t_size, grid_col)
|
|
end
|
|
-- Hover-cell highlight: 1-px outline around the cursor cell.
|
|
local c = state.get_mouse_cell()
|
|
if c then
|
|
engine.render.draw_rect_lines(
|
|
c.x * t_size, c.y * t_size, t_size, t_size,
|
|
rgba(COL_VERTEX_SNAP, 0xFF))
|
|
end
|
|
else
|
|
-- Direct mode: subtle render-cell grid only — that's the
|
|
-- grid LMB strokes write to.
|
|
local cell_col = rgba(COL_VERTEX_GRID, 0x25)
|
|
for x = 0, map_w do
|
|
engine.render.draw_line(x * t_size, 0, x * t_size, map_h * t_size, cell_col)
|
|
end
|
|
for y = 0, map_h do
|
|
engine.render.draw_line(0, y * t_size, map_w * t_size, y * t_size, cell_col)
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Hit-test mouse click. Returns true if the click was consumed by UI.
|
|
-- button: "left" | "right"
|
|
function M.handle_click(mx, my, button)
|
|
-- Modal absorbs all clicks while open.
|
|
if dispatch_modal_click(mx, my, button) then
|
|
return true
|
|
end
|
|
|
|
-- Mode-pill
|
|
local pill = mode_pill_layout()
|
|
for _, seg in ipairs({ pill.auto, pill.cell, pill.direct }) do
|
|
if in_rect(mx, my, seg.x, seg.y, seg.w, seg.h) then
|
|
if button == "left" then
|
|
actions.set_mode(seg.mode)
|
|
end
|
|
return true
|
|
end
|
|
end
|
|
|
|
-- Menu bar top-level labels
|
|
for _, cat in ipairs(menu_bar_categories_layout()) do
|
|
if in_rect(mx, my, cat.x, cat.y, cat.w, cat.h) then
|
|
if button == "left" then
|
|
if state.get_menu_open() == cat.name then
|
|
state.close_menu()
|
|
else
|
|
state.set_menu_open(cat.name)
|
|
end
|
|
end
|
|
return true
|
|
end
|
|
end
|
|
|
|
-- Dropdown items
|
|
local open_name = state.get_menu_open()
|
|
if open_name then
|
|
for _, cat in ipairs(menu_bar_categories_layout()) do
|
|
if cat.name == open_name then
|
|
local dd = dropdown_layout(cat)
|
|
if in_rect(mx, my, dd.x, dd.y, dd.w, dd.h) then
|
|
if button == "left" then
|
|
for _, row in ipairs(dd.rows) do
|
|
if in_rect(mx, my, row.x, row.y, row.w, row.h) then
|
|
if row.item.type == "submenu" then
|
|
state.set_submenu_open(row.item.label)
|
|
elseif row.item.type ~= "separator" then
|
|
if fire_item(row.item) then
|
|
state.close_menu()
|
|
end
|
|
end
|
|
return true
|
|
end
|
|
end
|
|
end
|
|
return true -- empty area inside dropdown, absorbed
|
|
end
|
|
break
|
|
end
|
|
end
|
|
|
|
-- Submenu items (one level deep)
|
|
local sub_label = state.get_submenu_open()
|
|
if sub_label then
|
|
for _, cat in ipairs(menu_bar_categories_layout()) do
|
|
if cat.name == open_name then
|
|
local dd2 = dropdown_layout(cat)
|
|
for _, row in ipairs(dd2.rows) do
|
|
local it = row.item
|
|
if it.type == "submenu" and it.label == sub_label and it.items then
|
|
local sub_x = dd2.x + dd2.w
|
|
local sub_y = row.y
|
|
local sy = sub_y + DROPDOWN_PAD_Y
|
|
for _, si in ipairs(it.items) do
|
|
local row_h = (si.type == "separator") and 6 or DROPDOWN_ITEM_H
|
|
if in_rect(mx, my, sub_x, sy, dropdown_width_for(it.items), row_h) then
|
|
if si.type ~= "separator" then
|
|
if fire_item(si) then
|
|
state.close_menu()
|
|
end
|
|
end
|
|
return true
|
|
end
|
|
sy = sy + row_h
|
|
end
|
|
break
|
|
end
|
|
end
|
|
break
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Click outside the dropdown closes it (no return — fall through so
|
|
-- the click can also hit other UI like canvas).
|
|
state.close_menu()
|
|
end
|
|
|
|
-- Bottom palette swatches: left = select slot
|
|
if state.is_palette_shown() then
|
|
for _, sw in ipairs(palette_swatches_layout()) do
|
|
if in_rect(mx, my, sw.x, sw.y, sw.w, sw.h) then
|
|
if button == "left" then
|
|
actions.set_active_tile(sw.slot_id)
|
|
end
|
|
return true
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Side-panel layer rows: left = set active; right = toggle visibility
|
|
if state.is_layers_panel_shown() then
|
|
for _, row in ipairs(side_panel_rows_layout()) do
|
|
if in_rect(mx, my, row.eye_x, row.eye_y, row.eye_w, row.eye_h) then
|
|
if button == "left" then
|
|
actions.toggle_layer_visible(row.name)
|
|
end
|
|
return true
|
|
end
|
|
if in_rect(mx, my, row.x, row.y, row.w, row.h) then
|
|
if button == "left" then
|
|
actions.set_active_layer(row.name)
|
|
elseif button == "right" then
|
|
actions.toggle_layer_visible(row.name)
|
|
end
|
|
return true
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Status chip — no-op fallback to claim the click area
|
|
local s = status_layout()
|
|
if in_rect(mx, my, s.x, s.y, s.w, s.h) then
|
|
return true
|
|
end
|
|
|
|
return false -- click falls through to map-area handler in init.lua
|
|
end
|
|
|
|
-- Expose internal tables needed by self_test (test-only; not part of
|
|
-- the public draw/click API).
|
|
M._MENU_BAR_H = MENU_BAR_H
|
|
M._VIEW_ITEMS = VIEW_ITEMS
|
|
M._EDIT_ITEMS = EDIT_ITEMS
|
|
M._LAYER_PANEL_ROWS = LAYER_PANEL_ROWS
|
|
M._mode_pill_layout = mode_pill_layout
|
|
M._MODE_PILL_SEG_W = MODE_PILL_SEG_W
|
|
|
|
return M
|
|
end)()
|
|
|
|
-- =====================================================================
|
|
-- Setup + Frame hooks (was the rest of original init.lua)
|
|
-- =====================================================================
|
|
local WORK_PATH = "maps/work.map.json"
|
|
local work_map_id = maps.load(WORK_PATH)
|
|
maps.set_current(work_map_id)
|
|
maps.load_textures(engine.module.asset_aliases())
|
|
state.set_map(work_map_id, WORK_PATH)
|
|
|
|
-- Center camera on the map
|
|
local m_size = maps.size()
|
|
local t_size = maps.tile_size()
|
|
camera.set_target((m_size.w * t_size) / 2, (m_size.h * t_size) / 2)
|
|
camera.set_zoom(1.0)
|
|
|
|
-- =====================================================================
|
|
-- Input bindings
|
|
-- =====================================================================
|
|
input.bind("quit", { "escape" })
|
|
input.bind("erase", { "e" })
|
|
input.bind("rotate", { "r" })
|
|
input.bind("save_map", { "s" })
|
|
input.bind("toggle_mode", { "tab" }) -- 0.2.0a
|
|
input.bind("toggle_cheatsheet", { "i" }) -- 0.2.0a — `i` for Info/help (no shifted-key support in input lib)
|
|
input.bind("flip", { "h" }) -- 0.2.0c
|
|
input.bind("reset_transform", { "0" }) -- 0.2.0c
|
|
input.bind("toggle_debug_overlay", { "d" }) -- 0.2.0c.3
|
|
input.bind("toggle_world_overlay", { "g" }) -- 0.2.0c.4
|
|
|
|
engine.print("map-editor v0.3.1: ready. Tab=cycle mode (Auto/Cell/Direct), I=help, G=grid, D=debug, S=save, E=erase, R=rotate, H=flip, 0=reset, ESC=quit / close")
|
|
|
|
-- =====================================================================
|
|
-- 0.3.0: self-tests (Spec §7.1). Run once on the first CI frame.
|
|
-- =====================================================================
|
|
local function self_test()
|
|
local MENU_BAR_H = ui._MENU_BAR_H
|
|
local VIEW_ITEMS = ui._VIEW_ITEMS
|
|
local EDIT_ITEMS = ui._EDIT_ITEMS
|
|
local LAYER_PANEL_ROWS = ui._LAYER_PANEL_ROWS
|
|
|
|
-- t1: opening a menu then clicking outside closes it.
|
|
state.set_menu_open("File")
|
|
engine.test.equals(state.get_menu_open(), "File", "t1: menu opened by setter")
|
|
ui.handle_click(2, MENU_BAR_H + 10, "left")
|
|
engine.test.equals(state.get_menu_open(), nil, "t1: outside click closes menu")
|
|
|
|
-- t2: toggle item flips bound state.
|
|
local before = state.is_world_overlay()
|
|
for _, it in ipairs(VIEW_ITEMS) do
|
|
if it.label == "World Overlay" then it.fire(); break end
|
|
end
|
|
engine.test.equals(state.is_world_overlay(), not before,
|
|
"t2: World Overlay toggle flips state")
|
|
for _, it in ipairs(VIEW_ITEMS) do
|
|
if it.label == "World Overlay" then it.fire(); break end
|
|
end
|
|
|
|
-- t3: modal open sets modal_open and closes menu.
|
|
state.set_menu_open("Help")
|
|
state.open_modal("cheatsheet")
|
|
state.close_menu()
|
|
engine.test.equals(state.get_modal_open(), "cheatsheet",
|
|
"t3: cheatsheet modal id set")
|
|
engine.test.equals(state.get_menu_open(), nil,
|
|
"t3: menu closed when modal opens")
|
|
ui.handle_click(2, MENU_BAR_H + 10, "left")
|
|
engine.test.equals(state.get_modal_open(), "cheatsheet",
|
|
"t3: outside click does not close modal")
|
|
state.close_modal()
|
|
|
|
-- t4: disabled item ignores click.
|
|
local rot_before = state.get_active_rot()
|
|
state.set_mode("auto-tile")
|
|
for _, it in ipairs(EDIT_ITEMS) do
|
|
if it.label == "Cycle Rotation" then
|
|
local d = it.disabled
|
|
local is_disabled = (type(d) == "function") and d() or (d == true)
|
|
engine.test.equals(is_disabled, true,
|
|
"t4: Cycle Rotation disabled in auto-tile mode")
|
|
break
|
|
end
|
|
end
|
|
engine.test.equals(state.get_active_rot(), rot_before,
|
|
"t4: rotation unchanged because item was disabled")
|
|
|
|
-- t5: mode-pill clicks switch mode.
|
|
state.set_mode("auto-tile")
|
|
actions.set_mode("direct")
|
|
engine.test.equals(state.get_mode(), "direct", "t5: set_mode(direct) sets mode")
|
|
actions.set_mode("auto-tile")
|
|
engine.test.equals(state.get_mode(), "auto-tile", "t5: set_mode(auto-tile) sets mode")
|
|
|
|
-- t6: switching categories updates menu_open.
|
|
state.set_menu_open("File")
|
|
state.set_menu_open("Edit")
|
|
engine.test.equals(state.get_menu_open(), "Edit",
|
|
"t6: re-set menu_open switches active category")
|
|
state.close_menu()
|
|
|
|
-- t7: View > Layers submenu toggle flips layer visibility.
|
|
local lname = LAYER_PANEL_ROWS[1]
|
|
local vis_before = state.is_layer_visible(lname)
|
|
state.toggle_layer_visible(lname)
|
|
engine.test.equals(state.is_layer_visible(lname), not vis_before,
|
|
"t7: layer visibility flipped via state.toggle_layer_visible")
|
|
state.toggle_layer_visible(lname)
|
|
|
|
-- t8: state.set_mode accepts "cell-tile"
|
|
state.set_mode("cell-tile")
|
|
engine.test.equals(state.get_mode(), "cell-tile",
|
|
"t8: set_mode('cell-tile') sets mode")
|
|
|
|
-- t9: mode_pill_layout returns 3 segments at consecutive x positions
|
|
local pill = ui._mode_pill_layout and ui._mode_pill_layout() or nil
|
|
engine.test.assert(pill ~= nil,
|
|
"t9: mode_pill_layout accessible from self_test")
|
|
engine.test.assert(pill.cell ~= nil and pill.direct ~= nil,
|
|
"t9: pill has all three segments (auto, cell, direct)")
|
|
engine.test.equals(pill.cell.x - pill.auto.x, ui._MODE_PILL_SEG_W,
|
|
"t9: cell segment sits one SEG_W right of auto")
|
|
engine.test.equals(pill.direct.x - pill.cell.x, ui._MODE_PILL_SEG_W,
|
|
"t9: direct segment sits one SEG_W right of cell")
|
|
|
|
-- t12: cycle_mode cycles auto -> cell -> direct -> auto
|
|
state.set_mode("auto-tile")
|
|
state.cycle_mode()
|
|
engine.test.equals(state.get_mode(), "cell-tile", "t12: cycle auto -> cell")
|
|
state.cycle_mode()
|
|
engine.test.equals(state.get_mode(), "direct", "t12: cycle cell -> direct")
|
|
state.cycle_mode()
|
|
engine.test.equals(state.get_mode(), "auto-tile", "t12: cycle direct -> auto")
|
|
|
|
-- t10: paint_cell_material_at writes the flag readable by
|
|
-- maps.get_cell_material; cell-tile mode dispatches LMB to it.
|
|
state.set_mode("cell-tile")
|
|
local layer = state.get_active_layer()
|
|
-- Clear first to avoid pollution from earlier tests
|
|
if maps.get_cell_material(layer, 5, 5) then
|
|
maps.set_cell_material(layer, 5, 5, false)
|
|
end
|
|
actions.paint_cell_material_at(5, 5)
|
|
engine.test.equals(maps.get_cell_material(layer, 5, 5), true,
|
|
"t10: paint_cell_material_at sets the flag")
|
|
actions.erase_cell_material_at(5, 5)
|
|
engine.test.equals(maps.get_cell_material(layer, 5, 5), false,
|
|
"t10: erase_cell_material_at clears the flag")
|
|
|
|
-- t11: Cycle Rotation / Toggle Flip / Reset Transform are disabled
|
|
-- in both auto-tile AND cell-tile modes; enabled only in direct.
|
|
local target_labels = { "Cycle Rotation", "Toggle Flip", "Reset Transform" }
|
|
for _, label in ipairs(target_labels) do
|
|
state.set_mode("auto-tile")
|
|
for _, it in ipairs(ui._EDIT_ITEMS) do
|
|
if it.label == label then
|
|
local d = it.disabled
|
|
local is_disabled = (type(d) == "function") and d() or (d == true)
|
|
engine.test.equals(is_disabled, true,
|
|
"t11: " .. label .. " disabled in auto-tile mode")
|
|
break
|
|
end
|
|
end
|
|
state.set_mode("cell-tile")
|
|
for _, it in ipairs(ui._EDIT_ITEMS) do
|
|
if it.label == label then
|
|
local d = it.disabled
|
|
local is_disabled = (type(d) == "function") and d() or (d == true)
|
|
engine.test.equals(is_disabled, true,
|
|
"t11: " .. label .. " disabled in cell-tile mode")
|
|
break
|
|
end
|
|
end
|
|
state.set_mode("direct")
|
|
for _, it in ipairs(ui._EDIT_ITEMS) do
|
|
if it.label == label then
|
|
local d = it.disabled
|
|
local is_disabled = (type(d) == "function") and d() or (d == true)
|
|
engine.test.equals(is_disabled, false,
|
|
"t11: " .. label .. " enabled in direct mode")
|
|
break
|
|
end
|
|
end
|
|
end
|
|
|
|
-- t11 teardown: restore auto-tile so we don't leave state in direct
|
|
state.set_mode("auto-tile")
|
|
end
|
|
|
|
-- =====================================================================
|
|
-- Frame hooks
|
|
-- =====================================================================
|
|
function update(ctx, dt)
|
|
-- CI-mode: run self-tests once on the first frame, then count to limit.
|
|
if CI_MODE then
|
|
if ci_frame_count == 0 then
|
|
self_test()
|
|
if engine.test.failures and engine.test.failures() > 0 then
|
|
engine.print(string.format(
|
|
"map-editor: SELF-TEST FAILED %d assertions",
|
|
engine.test.failures()))
|
|
engine.exit(1)
|
|
return
|
|
end
|
|
engine.print("map-editor: self-test PASS")
|
|
end
|
|
ci_frame_count = ci_frame_count + 1
|
|
if ci_frame_count >= CI_FRAME_LIMIT then
|
|
engine.print(string.format("map-editor: ci_frames_ok=%d", ci_frame_count))
|
|
engine.exit(0)
|
|
return
|
|
end
|
|
end
|
|
|
|
-- Esc precedence: modal → dropdown → quit
|
|
if input.was_action_pressed("quit") then
|
|
if state.is_modal_open() then
|
|
actions.close_modal()
|
|
elseif state.get_menu_open() then
|
|
actions.close_menu()
|
|
else
|
|
engine.exit()
|
|
return
|
|
end
|
|
end
|
|
|
|
-- Update mouse-cell tracking for status chip
|
|
local mx, my = engine.input.get_mouse_pos()
|
|
local wx, wy = camera.screen_to_world(mx, my)
|
|
local cell_x = math.floor(wx / t_size)
|
|
local cell_y = math.floor(wy / t_size)
|
|
if cell_x >= 0 and cell_y >= 0 and cell_x < m_size.w and cell_y < m_size.h then
|
|
state.set_mouse_cell(cell_x, cell_y)
|
|
else
|
|
state.set_mouse_cell(nil)
|
|
end
|
|
|
|
-- 0.2.0b: snap to the nearest vertex (within the (W+1)x(H+1) grid).
|
|
-- math.floor((wx + ts/2) / ts) rounds to the nearest vertex index.
|
|
-- Only published while the cursor is over the map area (else nil).
|
|
if state.get_mode() == "auto-tile" then
|
|
local vx = math.floor((wx + t_size / 2) / t_size)
|
|
local vy = math.floor((wy + t_size / 2) / t_size)
|
|
if vx >= 0 and vy >= 0 and vx <= m_size.w and vy <= m_size.h then
|
|
state.set_snap_vertex(vx, vy)
|
|
else
|
|
state.set_snap_vertex(nil)
|
|
end
|
|
else
|
|
state.set_snap_vertex(nil)
|
|
end
|
|
|
|
-- Hotkey: rotate
|
|
if input.was_action_pressed("rotate") then
|
|
actions.cycle_rotation()
|
|
end
|
|
|
|
-- Hotkey: save
|
|
if input.was_action_pressed("save_map") then
|
|
actions.save()
|
|
end
|
|
|
|
-- Hotkey: erase (uses current mouse-hover cell)
|
|
if input.was_action_pressed("erase") then
|
|
local cell = state.get_mouse_cell()
|
|
if cell then actions.erase_cell_at(cell.x, cell.y) end
|
|
end
|
|
|
|
-- 0.2.0a hotkeys: mode toggle + cheatsheet modal
|
|
if input.was_action_pressed("toggle_mode") then
|
|
actions.toggle_mode()
|
|
end
|
|
if input.was_action_pressed("toggle_cheatsheet") then
|
|
if state.is_modal_open("cheatsheet") then actions.close_modal() else actions.open_cheatsheet() end
|
|
end
|
|
|
|
-- 0.2.0c hotkeys: transform controls (Direct-mode override paint)
|
|
if input.was_action_pressed("flip") then
|
|
actions.toggle_flip()
|
|
end
|
|
if input.was_action_pressed("reset_transform") then
|
|
actions.reset_transform()
|
|
end
|
|
if input.was_action_pressed("toggle_debug_overlay") then
|
|
actions.toggle_debug_overlay()
|
|
end
|
|
if input.was_action_pressed("toggle_world_overlay") then
|
|
actions.toggle_world_overlay()
|
|
end
|
|
|
|
-- Mouse clicks + drag: hit-test UI on press; if consumed we never
|
|
-- enter drag-mode for this stroke. Otherwise the press initiates a
|
|
-- drag-stroke whose paint/erase action repeats for every subsequent
|
|
-- snap_vertex (or cell) while the button stays down. set_vertex /
|
|
-- set_override are idempotent + no-op when state is unchanged so
|
|
-- repeating per-frame is cheap.
|
|
if engine.input.was_mouse_pressed(engine.input.MOUSE_LEFT) then
|
|
local consumed = ui.handle_click(mx, my, "left")
|
|
if not consumed then
|
|
state.set_drag_paint("paint")
|
|
end
|
|
end
|
|
if engine.input.was_mouse_pressed(engine.input.MOUSE_RIGHT) then
|
|
local consumed = ui.handle_click(mx, my, "right")
|
|
if not consumed then
|
|
state.set_drag_paint("erase")
|
|
end
|
|
end
|
|
|
|
-- Per-frame drag-paint dispatch (Auto-Tile uses snap_vertex; Direct
|
|
-- uses mouse_cell). Stops as soon as the originating button releases.
|
|
local dp = state.get_drag_paint()
|
|
if dp then
|
|
local lmb_down = engine.input.is_mouse_down(engine.input.MOUSE_LEFT)
|
|
local rmb_down = engine.input.is_mouse_down(engine.input.MOUSE_RIGHT)
|
|
local active = (dp == "paint" and lmb_down) or (dp == "erase" and rmb_down)
|
|
if not active then
|
|
state.set_drag_paint(nil)
|
|
else
|
|
if state.get_mode() == "auto-tile" then
|
|
local snap = state.get_snap_vertex()
|
|
if snap then
|
|
actions.paint_vertex_at(snap.vx, snap.vy, dp == "paint")
|
|
end
|
|
elseif state.get_mode() == "cell-tile" then
|
|
local cell = state.get_mouse_cell()
|
|
if cell then
|
|
if dp == "paint" then
|
|
actions.paint_cell_material_at(cell.x, cell.y)
|
|
else
|
|
actions.erase_cell_material_at(cell.x, cell.y)
|
|
end
|
|
end
|
|
else
|
|
local cell = state.get_mouse_cell()
|
|
if cell then
|
|
if dp == "paint" then
|
|
actions.paint_override_at(cell.x, cell.y)
|
|
else
|
|
actions.erase_override_at(cell.x, cell.y)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
camera.update(dt)
|
|
end
|
|
|
|
function render(ctx)
|
|
camera.begin()
|
|
maps.draw_map()
|
|
ui.draw_world_overlay(m_size.w, m_size.h, t_size, state.get_snap_vertex())
|
|
ui.draw_debug_overlay(m_size.w, m_size.h, t_size)
|
|
camera.finish()
|
|
ui.draw() -- screen-space overlays after camera ends
|
|
end
|