commit 0ba22230ba5bb19b80e38da16d34e8ba4a80db4d Author: Calic Date: Sun Jun 14 01:45:47 2026 +0200 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. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..81c9d65 --- /dev/null +++ b/LICENSE @@ -0,0 +1,24 @@ +Copyright (c) 2026 Calic. All rights reserved. + +This software is part of the Sporel platform — **Tier 1 (Official / +Proprietary)** content per the Three-Tier Licensing Model documented in +`meta/docs/archive/design/vision.md §Licensing Model` (current source; +migration to `meta/docs/architecture/licensing-model.md` pending). + +⚠ **WIP — Legal review required before public launch.** The terms below +reflect design intent only; the formalized license framework will be +finalized through legal counsel before the first public release. Until +then, this notice serves as a placeholder defending the platform owner's +rights against unintentional re-licensing. + +No license is granted to copy, modify, distribute, sublicense, or otherwise +use this software in any form without prior written permission from the +copyright holder. + +References: +- Tier 1 (this file): all rights reserved, proprietary, sold/distributed + via official channels (Steam, etc.) +- Tier 2 (Semi-Commercial Co-Development): bilateral contracts, revenue- + share — see vision.md §Licensing Model +- Tier 3 (Community Content): CC BY-NC-SA 4.0 + asymmetric CLA — applies + to community-uploaded libs/modules/assets, not this repo diff --git a/README.md b/README.md new file mode 100644 index 0000000..6c13595 --- /dev/null +++ b/README.md @@ -0,0 +1,269 @@ +# lib-core.world-overlay + +Generic world-space transient UI layer. Spawns short-lived overlays (text, +icons, rects, composites) anchored to world entities or fixed world-space +positions. Camera-aware: converts world coordinates to screen coordinates +each frame. Domain-free — intended for combat-lib (damage numbers), +interaction-lib (mouseover prompts), and module-level pickup confirmations. + +**Version:** 0.1.0 +**Lib-ID:** lib-core.world-overlay +**Requires:** engine.render (draw_text, draw_rect, draw_rect_lines, draw_sprite_transform), engine.entity (get_property via entity handles), engine.time (optional, for TTL/animation) +**Tags:** world-overlay, ui, hud, transient, world-space, animation, camera + +## Topology + + +```mermaid +graph LR + this["lib-core.world-overlay"] + engine_render["engine.render.*"] + engine_entity["engine.entity (via handle.get_property)"] + engine_time["engine.time.now (optional)"] + this --> engine_render + this -.->|entity-anchor| engine_entity + this -.->|optional| engine_time +``` + + +## Scope (v0.1.0) + +v0.1 ships: + +- **Content types:** text, icon, rect, rect_lines, composite (children array) +- **Lifecycle:** TTL with automatic expiry OR manual-dismiss (no TTL) +- **Anchor modes:** entity-follow (reads `position.x`/`position.y` from entity + each frame) or fixed-position (world-space `{x, y}` snapshotted at spawn) +- **Animation:** fade-in / hold / fade-out alpha envelope + optional upward + rise (pixels over TTL duration) +- **Camera transform:** world → screen conversion via camera handle each render + +**Intentional non-goals (deferred):** +- Predicate-based lifecycle (despawn when condition becomes true) +- Auto-layout for composite children (stacking, centering) +- World-space speech-bubble tails and pointer geometry +- Per-primitive animation curves (easing functions) + +## Spec Schema + +### Primitive spec + +```lua +{ + kind = "text" | "icon" | "rect" | "rect_lines", + anchor = entity_handle | {x = number, y = number}, + -- kind-specific fields: + -- text: text (string), color (RGBA u32), font_size (number) + -- icon: atlas (string), uv = {x,y,w,h}, w (number), h (number) + -- rect: w (number), h (number), color (RGBA u32) + -- rect_lines: w (number), h (number), color (RGBA u32), thickness (number) + -- optional for all: + offset = {x = number, y = number}, -- screen-space offset from anchor + ttl = number, -- seconds; nil = manual-dismiss + fade_in_pct = number, -- fraction of ttl for fade-in (default 0.15) + fade_out_pct = number, -- fraction of ttl for fade-out (default 0.15) + rise_px = number, -- upward screen-px movement over ttl (default 0) +} +``` + +### Composite spec + +```lua +{ + anchor = entity_handle | {x = number, y = number}, + children = { primitive_spec, ... }, -- non-empty array; no anchor on children + -- optional lifecycle fields same as primitive (ttl, fade_in_pct, etc.) +} +``` + +Children inherit the composite's anchor position and animation envelope. +Each child may have its own `offset` for relative positioning. + +## Anchor Modes + +### Entity-follow + +Pass an engine entity handle as `anchor`. Each `update(dt)` call reads +`position.x` and `position.y` from the entity via `get_property`. If the +entity is destroyed between frames, `pcall` absorbs the error and the +overlay keeps rendering at the last known position until it expires or is +manually despawned. + +```lua +local overlay = world_overlay.spawn{ + kind = "text", + text = "-42", + color = 0xFF4040FF, + anchor = some_entity, -- entity handle + ttl = 1.2, +} +``` + +### Fixed-position + +Pass `{x = number, y = number}` as `anchor`. The world-space position is +snapshotted at spawn time and never updated. + +```lua +local overlay = world_overlay.spawn{ + kind = "rect", + w = 32, h = 32, + color = 0x00FF00AA, + anchor = {x = 512, y = 300}, + ttl = 0.8, +} +``` + +## Animation Model + +TTL overlays follow a three-phase alpha envelope: + +``` +alpha + 1 | _______________ + | / \ + | / \ + 0 |_____/___________________\____ + ^ ^ + fade_in_pct * ttl (1 - fade_out_pct) * ttl +``` + +- **fade-in phase** `[0, fade_in_pct)`: alpha ramps 0 → 1 +- **hold phase** `[fade_in_pct, 1-fade_out_pct]`: alpha = 1.0 +- **fade-out phase** `(1-fade_out_pct, 1.0]`: alpha ramps 1 → 0 + +`rise_px` moves the overlay upward by `progress * rise_px` screen pixels over +the full TTL duration. At 50% TTL with `rise_px = 48`, the overlay is 24 px +above its anchor screen position. + +Overlays without TTL (`ttl = nil`) always render at alpha 1.0, no rise. + +## Camera Handle Convention + +`M.render(camera)` expects: + +```lua +camera = { + target_x = number, -- world-space center of view (x) + target_y = number, -- world-space center of view (y) + zoom = number, -- pixels-per-world-unit scale (default 1.0) + offset_x = number, -- screen-space offset of camera center (x) + offset_y = number, -- screen-space offset of camera center (y) +} +``` + +World-to-screen transform: +``` +screen_x = (world_x - camera.target_x) * zoom + camera.offset_x +screen_y = (world_y - camera.target_y) * zoom + camera.offset_y +``` + +## API + +### `world_overlay.spawn(spec) -> handle` + +**Syntax:** `world_overlay.spawn(spec: table) -> { id: number }` + +Validates spec, records anchor position (fixed) or defers to first `update` +(entity), and inserts overlay into the active list. Returns an opaque handle +for `despawn`. Loud-error on missing anchor, unknown kind, or missing +kind-required fields. + +--- + +### `world_overlay.despawn(handle)` + +**Syntax:** `world_overlay.despawn(handle: table) -> void` + +Removes the overlay identified by `handle`. Idempotent — calling with an +already-expired or non-existent handle is a no-op. + +--- + +### `world_overlay.update(dt)` + +**Syntax:** `world_overlay.update(dt: number) -> void` + +Must be called each game-update frame. Refreshes entity-anchor positions, +expires overlays whose TTL has elapsed. + +--- + +### `world_overlay.render(camera)` + +**Syntax:** `world_overlay.render(camera: table) -> void` + +Must be called each render frame (inside the engine render phase). Converts +world positions to screen coordinates using the camera handle, computes +alpha + rise, and draws all active overlays. No-op if `camera` is nil. + +--- + +### `world_overlay.list() -> spec[]` + +**Syntax:** `world_overlay.list() -> table` + +Returns an array of spec tables for all currently active overlays. Intended +for debug/introspection. + +## Glue-Pattern + +### Combat-style damage number + +```lua +local world_overlay = require("lib-core.world-overlay") + +-- In combat hit handler: +local function show_damage(entity, amount) + world_overlay.spawn{ + kind = "text", + text = tostring(amount), + color = 0xFF3030FF, + font_size = 18, + anchor = entity, + ttl = 1.2, + rise_px = 32, + fade_out_pct = 0.4, + } +end + +-- Module update/render wiring: +function M.update(dt) + world_overlay.update(dt) +end + +function M.render() + -- draw world first + world_overlay.render(camera) +end +``` + +### Pickup confirmation (fixed position + composite) + +```lua +local world_overlay = require("lib-core.world-overlay") + +local function show_pickup(world_x, world_y, icon_atlas, icon_uv, label) + world_overlay.spawn{ + anchor = {x = world_x, y = world_y}, + ttl = 1.5, + rise_px = 20, + children = { + { + kind = "icon", + atlas = icon_atlas, + uv = icon_uv, + w = 16, h = 16, + offset = {x = -8, y = -8}, + }, + { + kind = "text", + text = label, + color = 0xFFFFAAFF, + font_size = 14, + offset = {x = 12, y = -4}, + }, + }, + } +end +``` diff --git a/init.lua b/init.lua new file mode 100644 index 0000000..0aaf562 --- /dev/null +++ b/init.lua @@ -0,0 +1,293 @@ +-- ===================================================================== +-- 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 diff --git a/manifest.lib b/manifest.lib new file mode 100644 index 0000000..5129d07 --- /dev/null +++ b/manifest.lib @@ -0,0 +1 @@ +{"id":"lib-core.world-overlay","version":"0.1.0","api_min":"0.1","deps":[]}