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