diff --git a/init.lua b/init.lua index 0f159c1..6490f2b 100644 --- a/init.lua +++ b/init.lua @@ -1,5 +1,11 @@ --- sporel-module-map-editor v0.1.0 --- Interactive map editor — see docs/superpowers/specs/2026-05-23-sporel-module-map-editor-design.md +-- 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 @@ -11,6 +17,13 @@ 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) -- ===================================================================== @@ -27,14 +40,16 @@ local state = (function() 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? - mouse_cell = nil, -- {x, y} or nil if mouse off-map + 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 } -- ===================================================================== @@ -82,6 +97,21 @@ local state = (function() 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 @@ -98,9 +128,11 @@ local state = (function() 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 @@ -193,59 +225,95 @@ local actions = (function() 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 (was src/ui.lua) +-- 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 = {} -- ===================================================================== - -- Constants + -- Layout constants -- ===================================================================== - local CHIP_SIZE = 32 -- layer chip W/H in pixels - local CHIP_MARGIN = 4 -- gap between chips - local CHIP_ALPHA_ACTIVE = 0xFF - local CHIP_ALPHA_INACTIVE = 0x78 -- 120/255 - local CHIP_ALPHA_HOVER = 0xC8 -- 200/255 + -- 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 - local PICKER_COLLAPSED_W = 80 - local PICKER_COLLAPSED_H = 80 - local PICKER_EXPANDED_W = 320 - local PICKER_EXPANDED_H = 240 - local PICKER_MARGIN = 8 - local TILE_THUMB_SIZE = 48 + -- 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 - local STATUS_W = 80 - local STATUS_H = 24 - local STATUS_MARGIN = 8 + -- Status chip (bottom-right, mouse cell + dirty) + local STATUS_W = 200 + local STATUS_H = 22 + local STATUS_MARGIN = 8 - -- Layer order (top of screen = top of z-stack). Roof above with divider. - local LAYER_CHIPS = { - { name="roof_toggle", label="Rf", is_roof=true }, - { name="canopy", label="Ca" }, - { name="upper_wall", label="Uw" }, - { name="wall", label="Wa" }, - { name="lower_wall", label="Lw" }, - -- entity-slot divider rendered between Lw and Ts - { name="topsurface", label="Ts" }, - { name="surface", label="Sf" }, - { name="subsurface", label="Sb" }, - { name="foundation", label="Fo" }, + -- 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 (▶)" }, } - local COLOR_CHIP_BG = 0x202020 -- RGB (alpha added at draw) - local COLOR_CHIP_BG_ACT = 0xE0E0E0 - local COLOR_CHIP_TEXT = 0xFFFFFF - local COLOR_CHIP_TEXT_ACT = 0x202020 - local COLOR_CHIP_BORDER = 0xFFFFFF - local COLOR_DIVIDER = 0xFFFFFF - local COLOR_STRIKE = 0xFF4040 -- hidden-layer overlay line - local COLOR_PICKER_BG = 0x303030 - local COLOR_STATUS_BG = 0x303030 - local COLOR_STATUS_TEXT = 0xFFFFFF -- ===================================================================== -- Helpers @@ -259,198 +327,195 @@ local ui = (function() return mx >= x and mx < x + w and my >= y and my < y + h end - -- Layer-chip layout: vertical stack on the left edge, starting at y_top. - -- Returns: array of { name, label, is_roof, x, y, w, h, is_divider_after? } - local function chip_layout() + -- ===================================================================== + -- 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 = CHIP_MARGIN - local y = CHIP_MARGIN - for i, chip in ipairs(LAYER_CHIPS) do - local entry = { - name=chip.name, label=chip.label, is_roof=chip.is_roof, - x=x, y=y, w=CHIP_SIZE, h=CHIP_SIZE, + 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, } - -- Roof-Toggle gets a thicker gap below it - if chip.is_roof then - entry.gap_after = CHIP_SIZE / 2 - elseif chip.name == "lower_wall" then - entry.gap_after = CHIP_SIZE / 2 -- entity-slot divider - entry.divider_after = true - else - entry.gap_after = CHIP_MARGIN - end - out[i] = entry - y = y + entry.h + entry.gap_after + x = x + TOOLBAR_BUTTON_W + TOOLBAR_BUTTON_GAP end return out end - -- Picker layout: collapsed = bottom-left corner; expanded = grows upward+right. - local function picker_layout() + local function side_panel_layout() local screen_w, screen_h = engine.window.size() - if state.is_picker_open() then - return { - x = PICKER_MARGIN + CHIP_SIZE + CHIP_MARGIN, -- right of layer-chips - y = screen_h - PICKER_EXPANDED_H - PICKER_MARGIN, - w = PICKER_EXPANDED_W, - h = PICKER_EXPANDED_H, - } - else - return { - x = PICKER_MARGIN + CHIP_SIZE + CHIP_MARGIN, - y = screen_h - PICKER_COLLAPSED_H - PICKER_MARGIN, - w = PICKER_COLLAPSED_W, - h = PICKER_COLLAPSED_H, + 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 = screen_w - STATUS_W - STATUS_MARGIN, + 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 -- ===================================================================== - local function draw_chip(chip, mx, my) - -- State decisions - local is_active - if chip.is_roof then - is_active = state.is_roof_mode() - else - is_active = (state.get_active_layer() == chip.name) - end - local is_hover = in_rect(mx, my, chip.x, chip.y, chip.w, chip.h) - local alpha + -- 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 - alpha = CHIP_ALPHA_ACTIVE - elseif is_hover then - alpha = CHIP_ALPHA_HOVER - else - alpha = CHIP_ALPHA_INACTIVE + engine.render.draw_rect_lines(b.x, b.y, b.w, b.h, rgba(COL_BUTTON_BORDER, 0xFF)) end - local bg_color = is_active and rgba(COLOR_CHIP_BG_ACT, alpha) - or rgba(COLOR_CHIP_BG, alpha) - local text_color = is_active and rgba(COLOR_CHIP_TEXT_ACT, 0xFF) - or rgba(COLOR_CHIP_TEXT, 0xFF) - -- Background - engine.render.draw_rect(chip.x, chip.y, chip.w, chip.h, bg_color) - -- Border (active only) - if is_active then - engine.render.draw_rect_lines(chip.x, chip.y, chip.w, chip.h, rgba(COLOR_CHIP_BORDER, 0xFF)) + 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 - -- Label text — centered (approximate using fixed-width assumption) - local text_x = chip.x + (chip.w / 2) - 8 - local text_y = chip.y + (chip.h / 2) - 8 - engine.render.draw_text(chip.label, text_x, text_y, 14, text_color) - -- Hidden overlay (strikethrough for invisible layers) - if not chip.is_roof and not state.is_layer_visible(chip.name) then - engine.render.draw_line(chip.x, chip.y, chip.x + chip.w, chip.y + chip.h, - rgba(COLOR_STRIKE, 0xFF), 2) + 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 - -- Entity-slot divider below lower_wall - if chip.divider_after then - local dy = chip.y + chip.h + (chip.gap_after / 2) - -- Dashed: 4 short segments - for seg = 0, 3 do - local sx = chip.x + seg * 9 - engine.render.draw_line(sx, dy, sx + 6, dy, rgba(COLOR_DIVIDER, 0xFF), 1) + -- 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 - end - end - - local function draw_layer_chips(mx, my) - for _, chip in ipairs(chip_layout()) do - draw_chip(chip, mx, my) - end - end - - -- Placeholder tile thumbnail (v0.1.0 limitation: lib-core.maps doesn't - -- expose per-tile UVs to Lua; v0.2.0 task). - local function draw_tile_thumb(atlas_idx, tile_id, x, y, size) - engine.render.draw_rect(x, y, size, size, rgba(0x606060, 0xFF)) - engine.render.draw_text("t" .. tile_id, x + 4, y + size/2 - 6, 10, rgba(0xFFFFFF, 0xFF)) - end - - -- Returns a list of tile IDs for the given atlas. Placeholder for v0.1.0: - -- lib-core.maps does not yet expose atlas.tile_ids() — return just the - -- currently-active tile so the expanded picker has something to render. - local function atlas_tile_list(atlas_idx) - return { state.get_active_tile() } - end - - local function draw_picker(mx, my) - local p = picker_layout() - engine.render.draw_rect(p.x, p.y, p.w, p.h, rgba(COLOR_PICKER_BG, 0xE0)) - engine.render.draw_rect_lines(p.x, p.y, p.w, p.h, rgba(0xFFFFFF, 0xFF)) - - if not state.is_picker_open() then - -- Collapsed view: thumbnail of active tile + truncated labels - local atlas_idx = state.get_active_atlas() - local tile_id = state.get_active_tile() - local atlas_id = maps.atlas_id_at(atlas_idx) or "?" - -- Active tile thumbnail (top of chip) - draw_tile_thumb(atlas_idx, tile_id, - p.x + 16, p.y + 8, TILE_THUMB_SIZE) - -- Labels (bottom) - local atlas_label = atlas_id:sub(1, 8) .. (atlas_id:len() > 8 and "…" or "") - engine.render.draw_text(atlas_label, p.x + 4, p.y + 60, 10, rgba(0xCCCCCC, 0xFF)) - return + -- 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 - -- Expanded view: atlas row at top, tile grid below - engine.render.draw_text("Atlas:", p.x + 8, p.y + 4, 11, rgba(0xFFFFFF, 0xFF)) - local atlas_count = maps.atlas_count() - local atlas_x = p.x + 8 - for i = 0, atlas_count - 1 do - local label = maps.atlas_id_at(i) or "?" - local short = label:sub(1, 6) - local color = (i == state.get_active_atlas()) - and rgba(0xFFFFFF, 0xFF) or rgba(0x808080, 0xFF) - engine.render.draw_text("["..short.."]", atlas_x, p.y + 20, 11, color) - atlas_x = atlas_x + 60 -- crude spacing - end - - -- Tile grid (below the atlas row) - engine.render.draw_text("Tiles:", p.x + 8, p.y + 40, 11, rgba(0xFFFFFF, 0xFF)) - local active_atlas = state.get_active_atlas() - local tiles = atlas_tile_list(active_atlas) - local grid_x = p.x + 8 - local grid_y = p.y + 60 - for i, tid in ipairs(tiles) do - local col = (i - 1) % 5 - local row = math.floor((i - 1) / 5) - local tx = grid_x + col * (TILE_THUMB_SIZE + 4) - local ty = grid_y + row * (TILE_THUMB_SIZE + 4) - draw_tile_thumb(active_atlas, tid, tx, ty, TILE_THUMB_SIZE) - if tid == state.get_active_tile() then - engine.render.draw_rect_lines(tx, ty, TILE_THUMB_SIZE, TILE_THUMB_SIZE, - rgba(0xFFFF00, 0xFF)) - end - 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(COLOR_STATUS_BG, 0xC0)) - engine.render.draw_rect_lines(s.x, s.y, s.w, s.h, rgba(0xFFFFFF, 0xFF)) + 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 - if cell then - cell_str = string.format("(%d,%d)", cell.x, cell.y) - else - cell_str = "(-,-)" + 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 - local dirty_str = state.is_dirty() and "*" or " " - engine.render.draw_text(cell_str .. " " .. dirty_str, s.x + 4, s.y + 4, 12, - rgba(COLOR_STATUS_TEXT, 0xFF)) end -- ===================================================================== @@ -459,50 +524,66 @@ local ui = (function() function M.draw() local mx, my = engine.input.get_mouse_pos() - draw_layer_chips(mx, my) - draw_picker(mx, my) + 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) - local p = picker_layout() - -- Picker: check expanded panel first (largest area) - if in_rect(mx, my, p.x, p.y, p.w, p.h) then - if state.is_picker_open() then - -- TODO: hit-test individual tile/atlas slots inside the panel - -- For v0.1.0, any click inside expanded picker closes it. - actions.toggle_picker() - return true - else - actions.toggle_picker() - return true - end + -- Cheatsheet modal absorbs all clicks while open (any click closes it) + if state.is_cheatsheet_open() then + actions.toggle_cheatsheet() + return true end - -- Layer chips - for _, chip in ipairs(chip_layout()) do - if in_rect(mx, my, chip.x, chip.y, chip.w, chip.h) then + -- 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 - if chip.is_roof then - actions.toggle_roof_mode() - else - actions.set_active_layer(chip.name) - end - elseif button == "right" then - if not chip.is_roof then - actions.toggle_layer_visible(chip.name) - end + dispatch_toolbar_button(b.id) end return true end end - -- Status chip — no-op fallback + -- 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 -- consumed but no action + return true end return false -- click falls through to map-area handler in init.lua @@ -529,17 +610,29 @@ 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("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: ready. Hotkeys: E=erase, R=rotate, S=save, ESC=quit") +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() @@ -573,6 +666,14 @@ function update(ctx, dt) 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") diff --git a/manifest.module b/manifest.module index 91b59c2..f0d26fb 100644 --- a/manifest.module +++ b/manifest.module @@ -1,6 +1,6 @@ { "id": "map-editor", - "version": "0.1.0", + "version": "0.2.0a", "kind": "module", "api": "^0.1", "ci_frames": 30, @@ -9,7 +9,7 @@ "fa_terrain_v1": "lib-asset.prototype-fa-starter" }, "deps": [ - {"id":"lib-core.maps","version":"0.5.1"}, + {"id":"lib-core.maps","version":"0.5.2"}, {"id":"lib-core.render","version":"0.1.0"}, {"id":"lib-core.camera","version":"0.3.0"}, {"id":"lib-core.input","version":"0.4.0"},