From 66997be86bdd9596ea5169854132d995b5a7ce25 Mon Sep 17 00:00:00 2001 From: Calic Date: Sun, 24 May 2026 03:08:03 +0200 Subject: [PATCH] Inline state/actions/ui into init.lua The engine's Lua sandbox rejects require() for non-declared-dep module names like 'src.state'. Multi-file modules in this codebase either inline everything (spine-prototype, vagrant-skeleton) or use dofile (lib-sporel.launcher). For three modules that share a state singleton, dofile would cause state divergence. Cleanest fix is to inline all three into init.lua via IIFE-wrapped local module tables, preserving the file-internal namespacing while satisfying the sandbox. Co-Authored-By: Claude Sonnet 4.6 --- init.lua | 517 +++++++++++++++++++++++++++++++++++++++++++++++- src/actions.lua | 86 -------- src/state.lua | 96 --------- src/ui.lua | 324 ------------------------------ 4 files changed, 513 insertions(+), 510 deletions(-) delete mode 100644 src/actions.lua delete mode 100644 src/state.lua delete mode 100644 src/ui.lua diff --git a/init.lua b/init.lua index 5c6e87b..6b111ad 100644 --- a/init.lua +++ b/init.lua @@ -1,15 +1,524 @@ -- sporel-module-map-editor v0.1.0 -- Interactive map editor — see docs/superpowers/specs/2026-05-23-sporel-module-map-editor-design.md +-- +-- All editor logic in this single file: engine sandbox only allows +-- require() for declared lib-deps; multi-file modules either use +-- dofile (causes state divergence for shared singletons) or inline +-- everything (vagrant-skeleton pattern). Inlined here via IIFE-wrapped +-- local module tables. local maps = require("lib-core.maps") local camera = require("lib-core.camera") local input = require("lib-core.input") -local state = require("src.state") -local actions = require("src.actions") -local ui = require("src.ui") -- ===================================================================== --- Setup +-- 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 + 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 + } + + -- ===================================================================== + -- 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 + + 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.roof_mode = false + state.dirty = false + state.picker_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 + + return M +end)() + +-- ===================================================================== +-- ui (was src/ui.lua) +-- ===================================================================== +local ui = (function() + local M = {} + + -- ===================================================================== + -- 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 + + 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 + + local STATUS_W = 80 + local STATUS_H = 24 + 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" }, + } + + 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 + -- ===================================================================== + + 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 + + -- 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() + 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, + } + -- 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 + end + return out + end + + -- Picker layout: collapsed = bottom-left corner; expanded = grows upward+right. + local function picker_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, + } + end + end + + local function status_layout() + local screen_w, screen_h = engine.window.size() + return { + x = screen_w - STATUS_W - STATUS_MARGIN, + y = screen_h - STATUS_H - STATUS_MARGIN, + w = STATUS_W, + h = STATUS_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 + if is_active then + alpha = CHIP_ALPHA_ACTIVE + elseif is_hover then + alpha = CHIP_ALPHA_HOVER + 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) + -- NOTE: engine.render.draw_rect_lines does not exist yet (needs engine-binding-task) + 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) + -- NOTE: engine.render.draw_line does not exist yet (needs engine-binding-task) + 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 + -- NOTE: engine.render.draw_line does not exist yet (needs engine-binding-task) + 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 + + 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)) + -- NOTE: engine.render.draw_rect_lines does not exist yet (needs engine-binding-task) + 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 + -- NOTE: engine.render.draw_rect_lines does not exist yet (needs engine-binding-task) + engine.render.draw_rect_lines(tx, ty, TILE_THUMB_SIZE, TILE_THUMB_SIZE, + rgba(0xFFFF00, 0xFF)) + end + 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(COLOR_STATUS_BG, 0xC0)) + -- NOTE: engine.render.draw_rect_lines does not exist yet (needs engine-binding-task) + engine.render.draw_rect_lines(s.x, s.y, s.w, s.h, rgba(0xFFFFFF, 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 = "(-,-)" + 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 + + -- ===================================================================== + -- Public API + -- ===================================================================== + + function M.draw() + local mx, my = engine.input.get_mouse_pos() + draw_layer_chips(mx, my) + draw_picker(mx, my) + draw_status(mx, my) + 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 + end + + -- Layer chips + for _, chip in ipairs(chip_layout()) do + if in_rect(mx, my, chip.x, chip.y, chip.w, chip.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 + end + return true + end + end + + -- Status chip — no-op fallback + local s = status_layout() + if in_rect(mx, my, s.x, s.y, s.w, s.h) then + return true -- consumed but no action + 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) diff --git a/src/actions.lua b/src/actions.lua deleted file mode 100644 index 6efedba..0000000 --- a/src/actions.lua +++ /dev/null @@ -1,86 +0,0 @@ --- src/actions.lua --- High-level edit operations. Combines state mutations with lib-core.maps writes. - -local maps = require("lib-core.maps") -local state = require("src.state") - -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 - -return M diff --git a/src/state.lua b/src/state.lua deleted file mode 100644 index cd3227f..0000000 --- a/src/state.lua +++ /dev/null @@ -1,96 +0,0 @@ --- src/state.lua --- Editor state singleton + accessors. - -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 - 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 -} - --- ===================================================================== --- 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 - -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.roof_mode = false - state.dirty = false - state.picker_open = false - state.mouse_cell = nil - for name in pairs(state.layer_visible) do - state.layer_visible[name] = true - end -end - -return M diff --git a/src/ui.lua b/src/ui.lua deleted file mode 100644 index c93c281..0000000 --- a/src/ui.lua +++ /dev/null @@ -1,324 +0,0 @@ --- src/ui.lua --- Immediate-mode overlay UI: layer-chips, atlas+tile picker, status-chip. --- Renders + hit-tests per frame. No retained widget state — read straight --- from state.lua on every render and dispatch via actions.lua. - -local maps = require("lib-core.maps") -local state = require("src.state") -local actions = require("src.actions") - -local M = {} - --- ===================================================================== --- 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 - -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 - -local STATUS_W = 80 -local STATUS_H = 24 -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" }, -} - -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 --- ===================================================================== - -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 - --- 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() - 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, - } - -- 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 - end - return out -end - --- Picker layout: collapsed = bottom-left corner; expanded = grows upward+right. -local function picker_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, - } - end -end - -local function status_layout() - local screen_w, screen_h = engine.window.size() - return { - x = screen_w - STATUS_W - STATUS_MARGIN, - y = screen_h - STATUS_H - STATUS_MARGIN, - w = STATUS_W, - h = STATUS_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 - if is_active then - alpha = CHIP_ALPHA_ACTIVE - elseif is_hover then - alpha = CHIP_ALPHA_HOVER - 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) - -- NOTE: engine.render.draw_rect_lines does not exist yet (needs engine-binding-task) - 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) - -- NOTE: engine.render.draw_line does not exist yet (needs engine-binding-task) - 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 - -- NOTE: engine.render.draw_line does not exist yet (needs engine-binding-task) - 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 - -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)) - -- NOTE: engine.render.draw_rect_lines does not exist yet (needs engine-binding-task) - 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 - -- NOTE: engine.render.draw_rect_lines does not exist yet (needs engine-binding-task) - engine.render.draw_rect_lines(tx, ty, TILE_THUMB_SIZE, TILE_THUMB_SIZE, - rgba(0xFFFF00, 0xFF)) - end - 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(COLOR_STATUS_BG, 0xC0)) - -- NOTE: engine.render.draw_rect_lines does not exist yet (needs engine-binding-task) - engine.render.draw_rect_lines(s.x, s.y, s.w, s.h, rgba(0xFFFFFF, 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 = "(-,-)" - 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 - --- ===================================================================== --- Public API --- ===================================================================== - -function M.draw() - local mx, my = engine.input.get_mouse_pos() - draw_layer_chips(mx, my) - draw_picker(mx, my) - draw_status(mx, my) -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 - end - - -- Layer chips - for _, chip in ipairs(chip_layout()) do - if in_rect(mx, my, chip.x, chip.y, chip.w, chip.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 - end - return true - end - end - - -- Status chip — no-op fallback - local s = status_layout() - if in_rect(mx, my, s.x, s.y, s.w, s.h) then - return true -- consumed but no action - end - - return false -- click falls through to map-area handler in init.lua -end - -return M