Files
Calic 0ba22230ba initial: world-overlay v0.1.0 — transient world-space UI primitives
Generic world-space transient UI lib. Spawns text, icon, rect, rect_lines,
and composite overlays anchored to entities or fixed world positions.
TTL-based expiry with fade-in/hold/fade-out alpha envelope and optional
upward rise. Camera-aware world-to-screen transform. Entity-destroyed
fallback keeps last_pos snapshot. Five test backdoors for headless testing.
2026-06-14 01:49:24 +02:00

294 lines
9.5 KiB
Lua

-- =====================================================================
-- lib-core.world-overlay v0.1.0 — Generic World-Space Transient UI
-- text + icon + rect + composite; entity or fixed anchor; ttl-fade or
-- manual-dismiss. Camera-aware. Domain-free — consumed by combat-lib,
-- interaction-lib, modules directly. NOT notify-specific.
-- =====================================================================
local M = {}
-- ---------- state ----------
local overlays = {} -- array of overlay-records
local next_id = 1
-- Valid primitive kinds (capability-by-declaration)
local VALID_KINDS = {
text = true,
icon = true,
rect = true,
rect_lines = true,
}
-- ---------- validation ----------
local function validate_primitive(spec, ctx_name)
if not VALID_KINDS[spec.kind] then
error(string.format(
"world-overlay.%s: unknown kind '%s' (valid: text, icon, rect, rect_lines)",
ctx_name, tostring(spec.kind)))
end
if spec.kind == "text" and type(spec.text) ~= "string" then
error(string.format(
"world-overlay.%s: text-kind requires .text string", ctx_name))
end
if spec.kind == "icon" then
if type(spec.atlas) ~= "string" or type(spec.uv) ~= "table" then
error(string.format(
"world-overlay.%s: icon-kind requires .atlas + .uv", ctx_name))
end
end
if spec.kind == "rect" or spec.kind == "rect_lines" then
if type(spec.w) ~= "number" or type(spec.h) ~= "number" then
error(string.format(
"world-overlay.%s: %s-kind requires .w + .h numbers",
ctx_name, spec.kind))
end
end
if spec.kind == "text" or spec.kind == "rect" or spec.kind == "rect_lines" then
if spec.color ~= nil and type(spec.color) ~= "number" then
error(string.format(
"world-overlay.%s: %s-kind .color must be a number (0xRRGGBBAA), got %s",
ctx_name, spec.kind, type(spec.color)))
end
end
end
-- ---------- public: spawn ----------
function M.spawn(spec)
if type(spec) ~= "table" then
error("world-overlay.spawn: spec must be a table")
end
if spec.anchor == nil then
error("world-overlay.spawn: spec.anchor required (entity or {x, y})")
end
-- Composite vs primitive
local is_composite = spec.children ~= nil
if is_composite then
if type(spec.children) ~= "table" or #spec.children == 0 then
error("world-overlay.spawn: children must be a non-empty array")
end
for i, child in ipairs(spec.children) do
validate_primitive(child, string.format("spawn[children[%d]]", i))
end
else
validate_primitive(spec, "spawn")
end
local now = (engine and engine.time and engine.time.now and engine.time.now()) or 0
local id = next_id
next_id = next_id + 1
local record = {
id = id,
spec = spec,
spawn_time = now,
ttl = spec.ttl,
last_pos = nil,
}
-- Snapshot fixed-anchor at spawn time
if type(spec.anchor) == "table" and spec.anchor.x ~= nil then
record.last_pos = { x = spec.anchor.x, y = spec.anchor.y }
end
table.insert(overlays, record)
return { id = id }
end
-- ---------- public: despawn ----------
function M.despawn(handle)
if type(handle) ~= "table" or not handle.id then return end
for i, o in ipairs(overlays) do
if o.id == handle.id then
table.remove(overlays, i)
return
end
end
-- silent idempotent
end
-- ---------- public: update ----------
function M.update(dt)
local now = (engine and engine.time and engine.time.now and engine.time.now()) or 0
local kept = {}
for _, o in ipairs(overlays) do
local a = o.spec.anchor
if type(a) == "table" and a.x ~= nil then
-- Fixed; already snapshotted at spawn; no update needed
elseif a and a.get_property then
-- Entity-handle: refresh position; pcall so entity-destroy
-- doesn't crash overlay-update.
local ok_x, x = pcall(function() return a:get_property("position.x") end)
local ok_y, y = pcall(function() return a:get_property("position.y") end)
if ok_x and ok_y and type(x) == "number" and type(y) == "number" then
o.last_pos = { x = x, y = y }
end
-- If pcall failed (entity destroyed) → last_pos keeps last snapshot
end
-- TTL expiry
if o.ttl then
local progress = (now - o.spawn_time) / o.ttl
if progress >= 1.0 then
-- drop (expired)
else
table.insert(kept, o)
end
else
-- Manual-dismiss: keep
table.insert(kept, o)
end
end
overlays = kept
end
-- ---------- animation + render helpers (module-local) ----------
local function compute_alpha_rise(o, now)
if not o.ttl then return 1.0, 0 end
local progress = (now - o.spawn_time) / o.ttl
local fade_in = o.spec.fade_in_pct or 0.15
local fade_out = o.spec.fade_out_pct or 0.15
local rise_px = o.spec.rise_px or 0
local alpha
if progress < fade_in then
alpha = progress / fade_in
elseif progress > (1.0 - fade_out) then
alpha = (1.0 - progress) / fade_out
else
alpha = 1.0
end
alpha = math.max(0.0, math.min(1.0, alpha))
return alpha, progress * rise_px
end
local function world_to_screen(world_x, world_y, camera)
local zoom = camera.zoom or 1.0
local sx = (world_x - camera.target_x) * zoom + camera.offset_x
local sy = (world_y - camera.target_y) * zoom + camera.offset_y
return sx, sy
end
local function apply_alpha(color_rgba, alpha)
local a = math.floor(math.max(0, math.min(1, alpha)) * 255)
return (color_rgba & 0xFFFFFF00) | a
end
local function render_primitive(spec, screen_x, screen_y, alpha)
if not (engine and engine.render) then return end
local off_x = (spec.offset and spec.offset.x) or 0
local off_y = (spec.offset and spec.offset.y) or 0
local x = screen_x + off_x
local y = screen_y + off_y
if spec.kind == "text" then
local color = apply_alpha(spec.color or 0xFFFFFFFF, alpha)
engine.render.draw_text(spec.text, x, y,
spec.font_size or 14, color)
elseif spec.kind == "icon" then
-- pcall around load_texture so headless/test runs degrade
-- gracefully to placeholder rect instead of crashing.
local ok, tex = pcall(function()
return engine.module.load_texture(spec.atlas)
end)
if ok and tex and engine.render.draw_sprite_transform then
engine.render.draw_sprite_transform(tex, x, y, 0, 1, 1, 0, 0,
apply_alpha(0xFFFFFFFF, alpha),
spec.uv.x, spec.uv.y, spec.uv.w, spec.uv.h)
else
-- Placeholder rect
engine.render.draw_rect(x, y, spec.w or 16, spec.h or 16,
apply_alpha(0x808080FF, alpha))
end
elseif spec.kind == "rect" then
engine.render.draw_rect(x, y, spec.w, spec.h,
apply_alpha(spec.color or 0xFFFFFFFF, alpha))
elseif spec.kind == "rect_lines" then
engine.render.draw_rect_lines(x, y, spec.w, spec.h,
apply_alpha(spec.color or 0xFFFFFFFF, alpha),
spec.thickness or 1)
end
end
-- ---------- public: render ----------
function M.render(camera)
if not camera then return end
local now = (engine and engine.time and engine.time.now and engine.time.now()) or 0
for _, o in ipairs(overlays) do
local pos = o.last_pos
if pos then
local alpha, rise = compute_alpha_rise(o, now)
local screen_x, screen_y = world_to_screen(pos.x, pos.y, camera)
screen_y = screen_y - rise -- rise = upward motion
if o.spec.children then
for _, child in ipairs(o.spec.children) do
render_primitive(child, screen_x, screen_y, alpha)
end
else
render_primitive(o.spec, screen_x, screen_y, alpha)
end
end
-- pos==nil means entity-anchor that never got position read — skip
end
end
-- ---------- public: list ----------
function M.list()
local out = {}
for i, o in ipairs(overlays) do out[i] = o.spec end
return out
end
-- ---------- test backdoors ----------
function M._test_clear_all()
overlays = {}
next_id = 1
end
function M._test_get_world_pos(handle)
for _, o in ipairs(overlays) do
if o.id == handle.id then return o.last_pos end
end
return nil
end
function M._test_get_alpha(handle)
local now = (engine and engine.time and engine.time.now and engine.time.now()) or 0
for _, o in ipairs(overlays) do
if o.id == handle.id then
local alpha, _ = compute_alpha_rise(o, now)
return alpha
end
end
return nil
end
function M._test_child_count(handle)
for _, o in ipairs(overlays) do
if o.id == handle.id then
return o.spec.children and #o.spec.children or 0
end
end
return 0
end
function M._test_overlay_count() return #overlays end
function M._test_get_rise(handle)
local now = (engine and engine.time and engine.time.now and engine.time.now()) or 0
for _, o in ipairs(overlays) do
if o.id == handle.id then
local _, rise = compute_alpha_rise(o, now)
return rise
end
end
return nil
end
return M