feat(map-editor): v0.2.0a — Rev 4 layout reorg + mode switch + cheatsheet

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).
This commit is contained in:
calic
2026-05-28 23:56:02 +02:00
parent 8dbb5b9d9c
commit 35d9940d63
2 changed files with 334 additions and 233 deletions

549
init.lua
View File

@@ -1,5 +1,11 @@
-- sporel-module-map-editor v0.1.0 -- sporel-module-map-editor v0.2.0a
-- Interactive map editor — see docs/superpowers/specs/2026-05-23-sporel-module-map-editor-design.md -- 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 -- All editor logic in this single file: engine sandbox only allows
-- require() for declared lib-deps; multi-file modules either use -- 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 camera = require("lib-core.camera")
local input = require("lib-core.input") 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) -- state (was src/state.lua)
-- ===================================================================== -- =====================================================================
@@ -27,13 +40,15 @@ local state = (function()
active_atlas = 0, -- atlas_index 0..N-1 active_atlas = 0, -- atlas_index 0..N-1
active_tile = 1, -- tile_id active_tile = 1, -- tile_id
active_rot = 0, -- 0..3 active_rot = 0, -- 0..3
mode = "auto-tile", -- 0.2.0a: "auto-tile" | "direct"
roof_mode = false, -- if true, paint targets roof roof_mode = false, -- if true, paint targets roof
layer_visible = { layer_visible = {
foundation=true, subsurface=true, surface=true, topsurface=true, foundation=true, subsurface=true, surface=true, topsurface=true,
lower_wall=true, wall=true, upper_wall=true, canopy=true, lower_wall=true, wall=true, upper_wall=true, canopy=true,
}, },
dirty = false, dirty = false,
picker_open = false, -- atlas+tile picker expanded? 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 mouse_cell = nil, -- {x, y} or nil if mouse off-map
} }
@@ -82,6 +97,21 @@ local state = (function()
end end
function M.is_picker_open() return 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) function M.set_mouse_cell(x, y)
if x == nil then if x == nil then
state.mouse_cell = nil state.mouse_cell = nil
@@ -98,9 +128,11 @@ local state = (function()
state.active_atlas = 0 state.active_atlas = 0
state.active_tile = 1 state.active_tile = 1
state.active_rot = 0 state.active_rot = 0
state.mode = "auto-tile"
state.roof_mode = false state.roof_mode = false
state.dirty = false state.dirty = false
state.picker_open = false state.picker_open = false
state.cheatsheet_open = false
state.mouse_cell = nil state.mouse_cell = nil
for name in pairs(state.layer_visible) do for name in pairs(state.layer_visible) do
state.layer_visible[name] = true state.layer_visible[name] = true
@@ -193,59 +225,95 @@ local actions = (function()
state.toggle_picker_open() state.toggle_picker_open()
end 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 return M
end)() 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 ui = (function()
local M = {} local M = {}
-- ===================================================================== -- =====================================================================
-- Constants -- Layout constants
-- ===================================================================== -- =====================================================================
local CHIP_SIZE = 32 -- layer chip W/H in pixels -- Top toolbar (full-width, mode-switch + action buttons)
local CHIP_MARGIN = 4 -- gap between chips local TOOLBAR_H = 32
local CHIP_ALPHA_ACTIVE = 0xFF local TOOLBAR_BUTTON_W = 88
local CHIP_ALPHA_INACTIVE = 0x78 -- 120/255 local TOOLBAR_BUTTON_H = 24
local CHIP_ALPHA_HOVER = 0xC8 -- 200/255 local TOOLBAR_BUTTON_GAP = 6
local TOOLBAR_PADDING = 4
local PICKER_COLLAPSED_W = 80 -- Side panel (right edge, layers vis + active)
local PICKER_COLLAPSED_H = 80 local SIDE_PANEL_W = 160
local PICKER_EXPANDED_W = 320 local LAYER_ROW_H = 22
local PICKER_EXPANDED_H = 240 local LAYER_ROW_GAP = 2
local PICKER_MARGIN = 8 local EYE_BOX = 18
local TILE_THUMB_SIZE = 48
local STATUS_W = 80 -- Status chip (bottom-right, mouse cell + dirty)
local STATUS_H = 24 local STATUS_W = 200
local STATUS_H = 22
local STATUS_MARGIN = 8 local STATUS_MARGIN = 8
-- Layer order (top of screen = top of z-stack). Roof above with divider. -- Cheatsheet modal (centered overlay)
local LAYER_CHIPS = { local CHEAT_W = 380
{ name="roof_toggle", label="Rf", is_roof=true }, local CHEAT_H = 280
{ name="canopy", label="Ca" },
{ name="upper_wall", label="Uw" }, -- Colours
{ name="wall", label="Wa" }, local COL_TOOLBAR_BG = 0x202024
{ name="lower_wall", label="Lw" }, local COL_PANEL_BG = 0x202024
-- entity-slot divider rendered between Lw and Ts local COL_BUTTON_BG = 0x303034
{ name="topsurface", label="Ts" }, local COL_BUTTON_BG_HOVER = 0x40404A
{ name="surface", label="Sf" }, local COL_BUTTON_BG_ACTIVE = 0xE0E0E0
{ name="subsurface", label="Sb" }, local COL_BUTTON_BORDER = 0xFFFFFF
{ name="foundation", label="Fo" }, 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 -- Helpers
@@ -259,198 +327,195 @@ local ui = (function()
return mx >= x and mx < x + w and my >= y and my < y + h return mx >= x and mx < x + w and my >= y and my < y + h
end 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? } -- Layout computers (mode-aware; recomputed per frame for screen
local function chip_layout() -- resize robustness — engine.window.size() is cheap).
local out = {} -- =====================================================================
local x = CHIP_MARGIN
local y = CHIP_MARGIN local function toolbar_layout()
for i, chip in ipairs(LAYER_CHIPS) do local screen_w = engine.window.size()
local entry = { return { x = 0, y = 0, w = screen_w, h = TOOLBAR_H }
name=chip.name, label=chip.label, is_roof=chip.is_roof,
x=x, y=y, w=CHIP_SIZE, h=CHIP_SIZE,
}
-- 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 end
out[i] = entry
y = y + entry.h + entry.gap_after -- 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 end
return out return out
end end
-- Picker layout: collapsed = bottom-left corner; expanded = grows upward+right. local function side_panel_layout()
local function picker_layout()
local screen_w, screen_h = engine.window.size() local screen_w, screen_h = engine.window.size()
if state.is_picker_open() then
return { return {
x = PICKER_MARGIN + CHIP_SIZE + CHIP_MARGIN, -- right of layer-chips x = screen_w - SIDE_PANEL_W,
y = screen_h - PICKER_EXPANDED_H - PICKER_MARGIN, y = TOOLBAR_H,
w = PICKER_EXPANDED_W, w = SIDE_PANEL_W,
h = PICKER_EXPANDED_H, h = screen_h - TOOLBAR_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,
} }
end 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 end
local function status_layout() local function status_layout()
local screen_w, screen_h = engine.window.size() local screen_w, screen_h = engine.window.size()
return { return {
x = screen_w - STATUS_W - STATUS_MARGIN, x = STATUS_MARGIN,
y = screen_h - STATUS_H - STATUS_MARGIN, y = screen_h - STATUS_H - STATUS_MARGIN,
w = STATUS_W, w = STATUS_W,
h = STATUS_H, h = STATUS_H,
} }
end 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 -- Drawing
-- ===================================================================== -- =====================================================================
local function draw_chip(chip, mx, my) -- Decide whether a toolbar button is in the "active" highlighted
-- State decisions -- state — only mode-group buttons match the current mode.
local is_active local function button_is_active(b)
if chip.is_roof then if b.group ~= "mode" then return false end
is_active = state.is_roof_mode() if b.id == "mode_auto" and state.get_mode() == "auto-tile" then return true end
else if b.id == "mode_direct" and state.get_mode() == "direct" then return true end
is_active = (state.get_active_layer() == chip.name) return false
end end
local is_hover = in_rect(mx, my, chip.x, chip.y, chip.w, chip.h)
local alpha 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 if is_active then
alpha = CHIP_ALPHA_ACTIVE 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 elseif is_hover then
alpha = CHIP_ALPHA_HOVER engine.render.draw_rect(row.x, row.y, row.w, row.h, rgba(COL_ROW_HOVER, 0xFF))
else
alpha = CHIP_ALPHA_INACTIVE
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))
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
-- 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)
end
end 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 end
local function draw_layer_chips(mx, my) -- Roof toggle below the layers list
for _, chip in ipairs(chip_layout()) do local last = side_panel_rows_layout()[#LAYER_PANEL_ROWS]
draw_chip(chip, mx, my) local roof_y = last.y + last.h + 12
end local roof_label = state.is_roof_mode() and "Roof: [ON]" or "Roof: [OFF]"
end 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))
-- 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
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
end end
local function draw_status(mx, my) local function draw_status(mx, my)
local s = status_layout() 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(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(0xFFFFFF, 0xFF)) 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 = state.get_mouse_cell()
local cell_str local cell_str = cell and string.format("Mouse: (%d,%d)", cell.x, cell.y) or "Mouse: (-,-)"
if cell then local dirty_str = state.is_dirty() and " * unsaved" or ""
cell_str = string.format("(%d,%d)", cell.x, cell.y) engine.render.draw_text(cell_str .. dirty_str, s.x + 4, s.y + 4, 12,
else rgba(COL_TEXT, 0xFF))
cell_str = "(-,-)" 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
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 end
-- ===================================================================== -- =====================================================================
@@ -459,50 +524,66 @@ local ui = (function()
function M.draw() function M.draw()
local mx, my = engine.input.get_mouse_pos() local mx, my = engine.input.get_mouse_pos()
draw_layer_chips(mx, my) draw_top_toolbar(mx, my)
draw_picker(mx, my) draw_side_panel(mx, my)
draw_status(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 end
-- Hit-test mouse click. Returns true if the click was consumed by UI. -- Hit-test mouse click. Returns true if the click was consumed by UI.
-- button: "left" | "right" -- button: "left" | "right"
function M.handle_click(mx, my, button) function M.handle_click(mx, my, button)
local p = picker_layout() -- Cheatsheet modal absorbs all clicks while open (any click closes it)
-- Picker: check expanded panel first (largest area) if state.is_cheatsheet_open() then
if in_rect(mx, my, p.x, p.y, p.w, p.h) then actions.toggle_cheatsheet()
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 return true
else
actions.toggle_picker()
return true
end
end end
-- Layer chips -- Top toolbar buttons
for _, chip in ipairs(chip_layout()) do for _, b in ipairs(toolbar_buttons_layout()) do
if in_rect(mx, my, chip.x, chip.y, chip.w, chip.h) then if in_rect(mx, my, b.x, b.y, b.w, b.h) then
if button == "left" then if button == "left" then
if chip.is_roof then dispatch_toolbar_button(b.id)
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
end end
return true return true
end end
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() local s = status_layout()
if in_rect(mx, my, s.x, s.y, s.w, s.h) then if in_rect(mx, my, s.x, s.y, s.w, s.h) then
return true -- consumed but no action return true
end end
return false -- click falls through to map-area handler in init.lua return false -- click falls through to map-area handler in init.lua
@@ -533,13 +614,25 @@ input.bind("quit", { "escape" })
input.bind("erase", { "e" }) input.bind("erase", { "e" })
input.bind("rotate", { "r" }) input.bind("rotate", { "r" })
input.bind("save_map", { "s" }) 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 -- Frame hooks
-- ===================================================================== -- =====================================================================
function update(ctx, dt) 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 -- Quit
if input.was_action_pressed("quit") then if input.was_action_pressed("quit") then
engine.exit() engine.exit()
@@ -573,6 +666,14 @@ function update(ctx, dt)
if cell then actions.erase_cell_at(cell.x, cell.y) end if cell then actions.erase_cell_at(cell.x, cell.y) end
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 -- Mouse clicks: hit-test UI first, then map area
if engine.input.was_mouse_pressed(engine.input.MOUSE_LEFT) then if engine.input.was_mouse_pressed(engine.input.MOUSE_LEFT) then
local consumed = ui.handle_click(mx, my, "left") local consumed = ui.handle_click(mx, my, "left")

View File

@@ -1,6 +1,6 @@
{ {
"id": "map-editor", "id": "map-editor",
"version": "0.1.0", "version": "0.2.0a",
"kind": "module", "kind": "module",
"api": "^0.1", "api": "^0.1",
"ci_frames": 30, "ci_frames": 30,
@@ -9,7 +9,7 @@
"fa_terrain_v1": "lib-asset.prototype-fa-starter" "fa_terrain_v1": "lib-asset.prototype-fa-starter"
}, },
"deps": [ "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.render","version":"0.1.0"},
{"id":"lib-core.camera","version":"0.3.0"}, {"id":"lib-core.camera","version":"0.3.0"},
{"id":"lib-core.input","version":"0.4.0"}, {"id":"lib-core.input","version":"0.4.0"},