Layout (per design paper §11): - Top toolbar with Mode buttons (Auto-Tile / Direct), Save, Erase, Rotate, Help. - Right Layers side-panel (8 rows, top-down draw order) as single source of truth for visibility + active layer — replaces the old left-edge layer chips. - Bottom status bar. State + actions: - mode = "auto-tile" | "direct" (Tab toggles). - cheatsheet_open modal toggle (I key, dimmed overlay + key/action table). - SPOREL_CI=1 auto-exits after 30 frames (matches vagrant pattern; ci_frames in manifest is not engine-enforced for kind:"module"). Paint behaviour itself is unchanged from 0.1.0 — bottom palette + the real Auto-Tile / Direct paint loops land in 0.2.0b/c/d. deps: lib-core.maps 0.5.1 -> 0.5.2 (consumes set_vertex / get_vertex).
698 lines
26 KiB
Lua
698 lines
26 KiB
Lua
-- sporel-module-map-editor v0.2.0a
|
|
-- 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). v0.2.0a sub-slice ships the
|
|
-- layout reorg (right Layers panel + top toolbar) + mode-switch
|
|
-- state + ?-cheatsheet modal — paint behaviour unchanged from v0.1.
|
|
-- Bottom palette + autotile painting + direct-mode placement land
|
|
-- in 0.2.0b/c/d sub-slices.
|
|
--
|
|
-- 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
|
|
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)
|
|
cheatsheet_open = false, -- 0.2.0a: ?-modal toggled?
|
|
mouse_cell = nil, -- {x, y} or nil if mouse off-map
|
|
}
|
|
|
|
-- =====================================================================
|
|
-- 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
|
|
|
|
function M.toggle_roof_mode()
|
|
state.roof_mode = not state.roof_mode
|
|
end
|
|
function M.is_roof_mode() return state.roof_mode end
|
|
|
|
function M.toggle_layer_visible(name)
|
|
state.layer_visible[name] = not state.layer_visible[name]
|
|
end
|
|
function M.is_layer_visible(name) return state.layer_visible[name] ~= false end
|
|
|
|
function M.mark_dirty() state.dirty = true end
|
|
function M.mark_clean() state.dirty = false end
|
|
function M.is_dirty() return state.dirty end
|
|
|
|
function M.toggle_picker_open()
|
|
state.picker_open = not state.picker_open
|
|
end
|
|
function M.is_picker_open() return state.picker_open end
|
|
|
|
-- 0.2.0a: binary mode switch (Auto-Tile vs Direct).
|
|
function M.get_mode() return state.mode end
|
|
function M.set_mode(m)
|
|
if m == "auto-tile" or m == "direct" then state.mode = m end
|
|
end
|
|
function M.toggle_mode()
|
|
state.mode = (state.mode == "auto-tile") and "direct" or "auto-tile"
|
|
end
|
|
|
|
-- 0.2.0a: ?-modal cheatsheet visibility.
|
|
function M.is_cheatsheet_open() return state.cheatsheet_open end
|
|
function M.toggle_cheatsheet()
|
|
state.cheatsheet_open = not state.cheatsheet_open
|
|
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
|
|
|
|
-- 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.mode = "auto-tile"
|
|
state.roof_mode = false
|
|
state.dirty = false
|
|
state.picker_open = false
|
|
state.cheatsheet_open = false
|
|
state.mouse_cell = nil
|
|
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
|
|
|
|
-- =====================================================================
|
|
-- 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.toggle_cheatsheet() state.toggle_cheatsheet() 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
|
|
-- =====================================================================
|
|
-- Top toolbar (full-width, mode-switch + action buttons)
|
|
local TOOLBAR_H = 32
|
|
local TOOLBAR_BUTTON_W = 88
|
|
local TOOLBAR_BUTTON_H = 24
|
|
local TOOLBAR_BUTTON_GAP = 6
|
|
local TOOLBAR_PADDING = 4
|
|
|
|
-- Side panel (right edge, layers vis + active)
|
|
local SIDE_PANEL_W = 160
|
|
local LAYER_ROW_H = 22
|
|
local LAYER_ROW_GAP = 2
|
|
local EYE_BOX = 18
|
|
|
|
-- Status chip (bottom-right, mouse cell + dirty)
|
|
local STATUS_W = 200
|
|
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
|
|
|
|
-- 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",
|
|
}
|
|
|
|
-- Top-toolbar buttons (left-to-right). Action ids dispatched in
|
|
-- handle_click; hotkey labels rendered on the button face.
|
|
local TOOLBAR_BUTTONS = {
|
|
{ id="mode_auto", label="Auto-Tile", hotkey="Tab", group="mode" },
|
|
{ id="mode_direct", label="Direct", hotkey="Tab", group="mode" },
|
|
{ id="save", label="Save", hotkey="S" },
|
|
{ id="erase", label="Erase", hotkey="E" },
|
|
{ id="rotate", label="Rotate", hotkey="R" },
|
|
{ id="help", label="Help", hotkey="I" },
|
|
}
|
|
|
|
-- Cheatsheet content (rendered into the modal).
|
|
local CHEATSHEET = {
|
|
{ key="Tab", action="Toggle mode (Auto-Tile / Direct)" },
|
|
{ key="I", action="Show / hide this cheatsheet" },
|
|
{ key="S", action="Save current map to work.map.json" },
|
|
{ key="E", action="Erase tile at mouse cell" },
|
|
{ key="R", action="Cycle tile rotation 0->90->180->270" },
|
|
{ key="Esc", action="Quit editor" },
|
|
{ key="LMB", action="Paint at mouse cell (uses active layer + tile)" },
|
|
{ key="RMB on Layer row", action="Toggle layer visibility (👁/⊘)" },
|
|
{ key="LMB on Layer row", action="Set as active layer (▶)" },
|
|
}
|
|
|
|
|
|
-- =====================================================================
|
|
-- Helpers
|
|
-- =====================================================================
|
|
|
|
local function rgba(rgb, alpha)
|
|
return (rgb << 8) | (alpha & 0xFF)
|
|
end
|
|
|
|
local function in_rect(mx, my, x, y, w, h)
|
|
return mx >= x and mx < x + w and my >= y and my < y + h
|
|
end
|
|
|
|
-- =====================================================================
|
|
-- Layout computers (mode-aware; recomputed per frame for screen
|
|
-- resize robustness — engine.window.size() is cheap).
|
|
-- =====================================================================
|
|
|
|
local function toolbar_layout()
|
|
local screen_w = engine.window.size()
|
|
return { x = 0, y = 0, w = screen_w, h = TOOLBAR_H }
|
|
end
|
|
|
|
-- Returns array of { id, label, hotkey, group, x, y, w, h } for each
|
|
-- toolbar button, packed left-to-right within the toolbar rect.
|
|
local function toolbar_buttons_layout()
|
|
local out = {}
|
|
local x = TOOLBAR_PADDING
|
|
local y = (TOOLBAR_H - TOOLBAR_BUTTON_H) / 2
|
|
for i, b in ipairs(TOOLBAR_BUTTONS) do
|
|
out[i] = {
|
|
id = b.id,
|
|
label = b.label,
|
|
hotkey = b.hotkey,
|
|
group = b.group,
|
|
x = x, y = y, w = TOOLBAR_BUTTON_W, h = TOOLBAR_BUTTON_H,
|
|
}
|
|
x = x + TOOLBAR_BUTTON_W + TOOLBAR_BUTTON_GAP
|
|
end
|
|
return out
|
|
end
|
|
|
|
local function side_panel_layout()
|
|
local screen_w, screen_h = engine.window.size()
|
|
return {
|
|
x = screen_w - SIDE_PANEL_W,
|
|
y = TOOLBAR_H,
|
|
w = SIDE_PANEL_W,
|
|
h = screen_h - TOOLBAR_H,
|
|
}
|
|
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 status_layout()
|
|
local screen_w, screen_h = engine.window.size()
|
|
return {
|
|
x = STATUS_MARGIN,
|
|
y = screen_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
|
|
|
|
-- =====================================================================
|
|
-- Drawing
|
|
-- =====================================================================
|
|
|
|
-- Decide whether a toolbar button is in the "active" highlighted
|
|
-- state — only mode-group buttons match the current mode.
|
|
local function button_is_active(b)
|
|
if b.group ~= "mode" then return false end
|
|
if b.id == "mode_auto" and state.get_mode() == "auto-tile" then return true end
|
|
if b.id == "mode_direct" and state.get_mode() == "direct" then return true end
|
|
return false
|
|
end
|
|
|
|
local function draw_button(b, mx, my)
|
|
local is_active = button_is_active(b)
|
|
local is_hover = in_rect(mx, my, b.x, b.y, b.w, b.h)
|
|
local bg = is_active and COL_BUTTON_BG_ACTIVE
|
|
or is_hover and COL_BUTTON_BG_HOVER
|
|
or COL_BUTTON_BG
|
|
local text_col = is_active and COL_TEXT_ACTIVE or COL_TEXT
|
|
engine.render.draw_rect(b.x, b.y, b.w, b.h, rgba(bg, 0xFF))
|
|
if is_active then
|
|
engine.render.draw_rect_lines(b.x, b.y, b.w, b.h, rgba(COL_BUTTON_BORDER, 0xFF))
|
|
end
|
|
engine.render.draw_text(b.label, b.x + 6, b.y + 4, 12, rgba(text_col, 0xFF))
|
|
if b.hotkey then
|
|
engine.render.draw_text("(" .. b.hotkey .. ")", b.x + 6, b.y + 14, 9,
|
|
rgba(text_col, 0xC0))
|
|
end
|
|
end
|
|
|
|
local function draw_top_toolbar(mx, my)
|
|
local t = toolbar_layout()
|
|
engine.render.draw_rect(t.x, t.y, t.w, t.h, rgba(COL_TOOLBAR_BG, 0xFF))
|
|
for _, b in ipairs(toolbar_buttons_layout()) do
|
|
draw_button(b, mx, my)
|
|
end
|
|
-- Mode + active-layer label in the toolbar's right area
|
|
local label = string.format("Mode: %s Layer: %s",
|
|
state.get_mode(), state.get_active_layer())
|
|
local screen_w = engine.window.size()
|
|
engine.render.draw_text(label, screen_w - SIDE_PANEL_W - 240, 10, 12,
|
|
rgba(COL_TEXT_MUTED, 0xFF))
|
|
end
|
|
|
|
local function draw_side_panel(mx, my)
|
|
local p = side_panel_layout()
|
|
engine.render.draw_rect(p.x, p.y, p.w, p.h, rgba(COL_PANEL_BG, 0xFF))
|
|
engine.render.draw_text("Layers", p.x + 6, p.y + 4, 11, rgba(COL_TEXT_MUTED, 0xFF))
|
|
|
|
for _, row in ipairs(side_panel_rows_layout()) do
|
|
local is_active = (state.get_active_layer() == row.name)
|
|
local is_hover = in_rect(mx, my, row.x, row.y, row.w, row.h)
|
|
local is_visible = state.is_layer_visible(row.name)
|
|
-- Row background (active highlight or hover dim)
|
|
if is_active then
|
|
engine.render.draw_rect(row.x, row.y, row.w, row.h, rgba(COL_ROW_ACTIVE, 0xFF))
|
|
elseif is_hover then
|
|
engine.render.draw_rect(row.x, row.y, row.w, row.h, rgba(COL_ROW_HOVER, 0xFF))
|
|
end
|
|
-- Eye-icon box
|
|
engine.render.draw_rect_lines(row.eye_x, row.eye_y, row.eye_w, row.eye_h,
|
|
rgba(COL_TEXT_MUTED, 0xFF))
|
|
local eye_label = is_visible and "+" or "x"
|
|
engine.render.draw_text(eye_label, row.eye_x + 5, row.eye_y + 3, 12,
|
|
rgba(is_visible and COL_TEXT or COL_HIDDEN_OVERLAY, 0xFF))
|
|
-- Layer name (with active marker)
|
|
local marker = is_active and ">" or " "
|
|
engine.render.draw_text(marker .. " " .. row.name,
|
|
row.eye_x + row.eye_w + 6, row.y + 4, 12, rgba(COL_TEXT, 0xFF))
|
|
end
|
|
|
|
-- Roof toggle below the layers list
|
|
local last = side_panel_rows_layout()[#LAYER_PANEL_ROWS]
|
|
local roof_y = last.y + last.h + 12
|
|
local roof_label = state.is_roof_mode() and "Roof: [ON]" or "Roof: [OFF]"
|
|
engine.render.draw_text(roof_label, p.x + 6, roof_y, 12,
|
|
rgba(state.is_roof_mode() and COL_TEXT_ACTIVE or COL_TEXT_MUTED, 0xFF))
|
|
end
|
|
|
|
local function draw_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 ""
|
|
engine.render.draw_text(cell_str .. dirty_str, s.x + 4, s.y + 4, 12,
|
|
rgba(COL_TEXT, 0xFF))
|
|
end
|
|
|
|
local function draw_cheatsheet(mx, my)
|
|
if not state.is_cheatsheet_open() then return end
|
|
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
|
|
|
|
-- =====================================================================
|
|
-- Public API
|
|
-- =====================================================================
|
|
|
|
function M.draw()
|
|
local mx, my = engine.input.get_mouse_pos()
|
|
draw_top_toolbar(mx, my)
|
|
draw_side_panel(mx, my)
|
|
draw_status(mx, my)
|
|
draw_cheatsheet(mx, my) -- last so it overlays everything when open
|
|
end
|
|
|
|
-- Dispatch a toolbar-button click to the appropriate action.
|
|
local function dispatch_toolbar_button(id)
|
|
if id == "mode_auto" then actions.set_mode("auto-tile")
|
|
elseif id == "mode_direct" then actions.set_mode("direct")
|
|
elseif id == "save" then actions.save()
|
|
elseif id == "erase" then
|
|
local cell = state.get_mouse_cell()
|
|
if cell then actions.erase_cell_at(cell.x, cell.y) end
|
|
elseif id == "rotate" then actions.cycle_rotation()
|
|
elseif id == "help" then actions.toggle_cheatsheet()
|
|
end
|
|
end
|
|
|
|
-- Hit-test mouse click. Returns true if the click was consumed by UI.
|
|
-- button: "left" | "right"
|
|
function M.handle_click(mx, my, button)
|
|
-- Cheatsheet modal absorbs all clicks while open (any click closes it)
|
|
if state.is_cheatsheet_open() then
|
|
actions.toggle_cheatsheet()
|
|
return true
|
|
end
|
|
|
|
-- Top toolbar buttons
|
|
for _, b in ipairs(toolbar_buttons_layout()) do
|
|
if in_rect(mx, my, b.x, b.y, b.w, b.h) then
|
|
if button == "left" then
|
|
dispatch_toolbar_button(b.id)
|
|
end
|
|
return true
|
|
end
|
|
end
|
|
|
|
-- Side-panel layer rows: left = set active; right = toggle visibility
|
|
for _, row in ipairs(side_panel_rows_layout()) do
|
|
if in_rect(mx, my, row.eye_x, row.eye_y, row.eye_w, row.eye_h) then
|
|
if button == "left" then
|
|
actions.toggle_layer_visible(row.name)
|
|
end
|
|
return true
|
|
end
|
|
if in_rect(mx, my, row.x, row.y, row.w, row.h) then
|
|
if button == "left" then
|
|
actions.set_active_layer(row.name)
|
|
elseif button == "right" then
|
|
actions.toggle_layer_visible(row.name)
|
|
end
|
|
return true
|
|
end
|
|
end
|
|
|
|
-- Status chip — no-op fallback to claim the click area
|
|
local s = status_layout()
|
|
if in_rect(mx, my, s.x, s.y, s.w, s.h) then
|
|
return true
|
|
end
|
|
|
|
return false -- click falls through to map-area handler in init.lua
|
|
end
|
|
|
|
return M
|
|
end)()
|
|
|
|
-- =====================================================================
|
|
-- Setup + Frame hooks (was the rest of original init.lua)
|
|
-- =====================================================================
|
|
local WORK_PATH = "maps/work.map.json"
|
|
local work_map_id = maps.load(WORK_PATH)
|
|
maps.set_current(work_map_id)
|
|
maps.load_textures(engine.module.asset_aliases())
|
|
state.set_map(work_map_id, WORK_PATH)
|
|
|
|
-- Center camera on the map
|
|
local m_size = maps.size()
|
|
local t_size = maps.tile_size()
|
|
camera.set_target((m_size.w * t_size) / 2, (m_size.h * t_size) / 2)
|
|
camera.set_zoom(1.0)
|
|
|
|
-- =====================================================================
|
|
-- Input bindings
|
|
-- =====================================================================
|
|
input.bind("quit", { "escape" })
|
|
input.bind("erase", { "e" })
|
|
input.bind("rotate", { "r" })
|
|
input.bind("save_map", { "s" })
|
|
input.bind("toggle_mode", { "tab" }) -- 0.2.0a
|
|
input.bind("toggle_cheatsheet", { "i" }) -- 0.2.0a — `i` for Info/help (no shifted-key support in input lib)
|
|
|
|
engine.print("map-editor v0.2.0a: ready. Tab=mode, I=help, S=save, E=erase, R=rotate, ESC=quit")
|
|
|
|
-- =====================================================================
|
|
-- Frame hooks
|
|
-- =====================================================================
|
|
function update(ctx, dt)
|
|
-- CI-mode auto-exit (smoke harness)
|
|
if CI_MODE then
|
|
ci_frame_count = ci_frame_count + 1
|
|
if ci_frame_count >= CI_FRAME_LIMIT then
|
|
engine.print(string.format("map-editor: ci_frames_ok=%d", ci_frame_count))
|
|
engine.exit(0)
|
|
return
|
|
end
|
|
end
|
|
|
|
-- Quit
|
|
if input.was_action_pressed("quit") then
|
|
engine.exit()
|
|
return
|
|
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
|
|
|
|
-- 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
|
|
actions.toggle_cheatsheet()
|
|
end
|
|
|
|
-- Mouse clicks: hit-test UI first, then map area
|
|
if engine.input.was_mouse_pressed(engine.input.MOUSE_LEFT) then
|
|
local consumed = ui.handle_click(mx, my, "left")
|
|
if not consumed and state.get_mouse_cell() then
|
|
local cell = state.get_mouse_cell()
|
|
actions.paint_cell_at(cell.x, cell.y)
|
|
end
|
|
end
|
|
if engine.input.was_mouse_pressed(engine.input.MOUSE_RIGHT) then
|
|
ui.handle_click(mx, my, "right")
|
|
end
|
|
|
|
camera.update(dt)
|
|
end
|
|
|
|
function render(ctx)
|
|
camera.begin()
|
|
maps.draw_map()
|
|
camera.finish()
|
|
ui.draw() -- screen-space overlays after camera ends
|
|
end
|