feat(launcher): hero carousel with auto-rotate + manual nav

panels/carousel.lua holds per-module state (index, timer, manual pause,
texture cache). Auto-rotate at carousel_interval_seconds (5s default,
frontmatter-overrideable). Click on overlay arrows steps and pauses
auto-rotate for 30s. Max 7 indicator dots, then 'X / N' overlay (L-Q4).
Single-teaser hides the arrows and dots. Zero-teaser falls through to
ctx.default_teaser_texture which L.10 ships.
This commit is contained in:
Calic
2026-06-11 02:26:09 +02:00
parent 72fa4d3135
commit 94b55c42f2
3 changed files with 144 additions and 4 deletions

108
panels/carousel.lua Normal file
View File

@@ -0,0 +1,108 @@
local M = {}
local DEFAULT_INTERVAL_S = 5.0
local MANUAL_PAUSE_S = 30.0
local MAX_DOTS = 7
local ARROW_W = 32
local DOT_R = 4
local DOT_GAP = 6
local COLOR_ARROW_BG = 0x00000080
local COLOR_ARROW_FG = 0xE0E0E0FF
local COLOR_DOT = 0xC0C0C060
local COLOR_DOT_ACTIVE = 0xFFFFFFFF
local COLOR_OVERLAY = 0xE0E0E0FF
local COLOR_FALLBACK_BG = 0x303030FF
function M.new_state(module_id, teaser_paths, interval)
return {
module_id = module_id,
teaser_paths = teaser_paths or {},
textures = {}, -- index -> texture or nil if load-failed
loaded = {}, -- index -> true once loaded
index = 1,
timer = 0,
interval = interval or DEFAULT_INTERVAL_S,
manual_pause = 0,
left_rect = nil,
right_rect = nil,
}
end
local function ensure_texture(s, i, default_texture)
if s.loaded[i] then return s.textures[i] end
s.loaded[i] = true
local path = s.teaser_paths[i]
if not path then s.textures[i] = default_texture; return default_texture end
local tex, err = engine.module.load_texture(s.module_id, path)
s.textures[i] = tex or default_texture
return s.textures[i]
end
function M.update(s, dt)
if #s.teaser_paths < 2 then return end
if s.manual_pause > 0 then
s.manual_pause = s.manual_pause - dt
return
end
s.timer = s.timer + dt
if s.timer >= s.interval then
s.timer = 0
s.index = s.index + 1
if s.index > #s.teaser_paths then s.index = 1 end
end
end
function M.render(s, x, y, w, h, default_texture)
-- Background (clipping is up to the parent panel — we just clip-render).
engine.render.draw_rect(x, y, w, h, COLOR_FALLBACK_BG)
local total = math.max(1, #s.teaser_paths)
local tex = ensure_texture(s, s.index, default_texture) or default_texture
if tex then engine.render.draw_texture(tex, x, y) end
if total > 1 then
-- Arrows.
s.left_rect = { x = x, y = y + h / 2 - 16, w = ARROW_W, h = 32 }
s.right_rect = { x = x + w - ARROW_W, y = y + h / 2 - 16, w = ARROW_W, h = 32 }
engine.render.draw_rect(s.left_rect.x, s.left_rect.y, ARROW_W, 32, COLOR_ARROW_BG)
engine.render.draw_rect(s.right_rect.x, s.right_rect.y, ARROW_W, 32, COLOR_ARROW_BG)
engine.render.draw_text("<", s.left_rect.x + 10, s.left_rect.y + 6, 20, COLOR_ARROW_FG)
engine.render.draw_text(">", s.right_rect.x + 10, s.right_rect.y + 6, 20, COLOR_ARROW_FG)
-- Dots.
local dot_count = math.min(total, MAX_DOTS)
local dots_w = dot_count * (DOT_R * 2) + (dot_count - 1) * DOT_GAP
local dot_x = x + (w - dots_w) / 2
local dot_y = y + h - 16
for i = 1, dot_count do
local col = (i == s.index) and COLOR_DOT_ACTIVE or COLOR_DOT
engine.render.draw_rect(dot_x, dot_y, DOT_R * 2, DOT_R * 2, col)
dot_x = dot_x + DOT_R * 2 + DOT_GAP
end
if total > MAX_DOTS then
local label = string.format("%d / %d", s.index, total)
engine.render.draw_text(label, x + w - 80, dot_y - 4, 14, COLOR_OVERLAY)
end
end
end
function M.handle_click(s, mx, my)
if not s or not s.left_rect then return false end
if engine.spatial.aabb_contains_point(s.left_rect, mx, my) then
s.index = s.index - 1
if s.index < 1 then s.index = #s.teaser_paths end
s.timer = 0
s.manual_pause = MANUAL_PAUSE_S
return true
end
if engine.spatial.aabb_contains_point(s.right_rect, mx, my) then
s.index = s.index + 1
if s.index > #s.teaser_paths then s.index = 1 end
s.timer = 0
s.manual_pause = MANUAL_PAUSE_S
return true
end
return false
end
return M