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 <noreply@anthropic.com>
This commit is contained in:
Calic
2026-05-24 03:08:03 +02:00
parent 6d2d3ea26d
commit 66997be86b
4 changed files with 513 additions and 510 deletions

517
init.lua
View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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