Add immediate-mode overlay UI module
Vertical layer-chip column on the left edge (Roof up top with divider, entity-slot dashed line between Lw and Ts), collapsible atlas+tile picker bottom-left, status chip bottom-right. Each frame redraws everything and dispatches mouse clicks via priority hit-tests. Tile thumbnails are placeholder gray rects in v0.1.0 because lib-core.maps does not yet expose per-tile UVs to Lua.
This commit is contained in:
324
src/ui.lua
Normal file
324
src/ui.lua
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
-- 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
|
||||||
Reference in New Issue
Block a user