mirror of
https://github.com/wisplite/cardstock-system.git
synced 2026-06-27 15:27:09 -05:00
initial commit
This commit is contained in:
+260
@@ -0,0 +1,260 @@
|
||||
-- syslib/anim.lua
|
||||
--
|
||||
-- Small animation helper built around time-based tweens driven by `dt`.
|
||||
--
|
||||
-- Usage:
|
||||
-- local anim = require("anim")
|
||||
-- local a = anim.new()
|
||||
-- local state = { x = 0, alpha = 0 }
|
||||
--
|
||||
-- a:to(state, { x = 100, alpha = 1 }, 0.25, { ease = anim.ease.outCubic })
|
||||
-- a:after(0.25, function() print("done") end)
|
||||
--
|
||||
-- In your tick(dt):
|
||||
-- local changed = a:update(dt)
|
||||
-- if changed then dirty = true end
|
||||
--
|
||||
-- Notes:
|
||||
-- - Tweens interpolate numeric fields only.
|
||||
-- - `update(dt)` returns true if anything advanced/ran this frame (useful for dirty redraw).
|
||||
|
||||
local anim = {}
|
||||
|
||||
local function clamp01(x)
|
||||
if x < 0 then return 0 end
|
||||
if x > 1 then return 1 end
|
||||
return x
|
||||
end
|
||||
|
||||
local function lerp(a, b, t)
|
||||
return a + (b - a) * t
|
||||
end
|
||||
|
||||
anim.ease = {}
|
||||
anim.ease.linear = function(t) return t end
|
||||
anim.ease.inQuad = function(t) return t * t end
|
||||
anim.ease.outQuad = function(t) return 1 - (1 - t) * (1 - t) end
|
||||
anim.ease.inOutQuad = function(t)
|
||||
if t < 0.5 then return 2 * t * t end
|
||||
return 1 - ((-2 * t + 2) ^ 2) / 2
|
||||
end
|
||||
anim.ease.inCubic = function(t) return t * t * t end
|
||||
anim.ease.outCubic = function(t) return 1 - (1 - t) ^ 3 end
|
||||
anim.ease.inOutCubic = function(t)
|
||||
if t < 0.5 then return 4 * t * t * t end
|
||||
return 1 - ((-2 * t + 2) ^ 3) / 2
|
||||
end
|
||||
anim.ease.outBack = function(t)
|
||||
local c1 = 1.70158
|
||||
local c3 = c1 + 1
|
||||
return 1 + c3 * (t - 1) ^ 3 + c1 * (t - 1) ^ 2
|
||||
end
|
||||
|
||||
local Tween = {}
|
||||
Tween.__index = Tween
|
||||
|
||||
function Tween:cancel()
|
||||
self._cancelled = true
|
||||
end
|
||||
|
||||
function Tween:pause()
|
||||
self._paused = true
|
||||
end
|
||||
|
||||
function Tween:resume()
|
||||
self._paused = false
|
||||
end
|
||||
|
||||
function Tween:isDone()
|
||||
return self._done or self._cancelled
|
||||
end
|
||||
|
||||
local Animator = {}
|
||||
Animator.__index = Animator
|
||||
|
||||
function anim.new()
|
||||
return setmetatable({
|
||||
_tweens = {},
|
||||
_timers = {}, -- { t=remaining, fn=function, cancelled=bool }
|
||||
}, Animator)
|
||||
end
|
||||
|
||||
local function snapshotFrom(subject, goal)
|
||||
local from = {}
|
||||
for k, _ in pairs(goal) do
|
||||
local v = subject[k]
|
||||
assert(type(v) == "number", ("anim: subject[%s] must be a number (got %s)"):format(tostring(k), type(v)))
|
||||
from[k] = v
|
||||
end
|
||||
return from
|
||||
end
|
||||
|
||||
-- Tween numeric fields on `subject` to match `goal` over `duration` seconds.
|
||||
-- opts:
|
||||
-- ease (fn): easing function taking t in [0,1]
|
||||
-- delay (number): seconds to wait before starting
|
||||
-- loop (number): how many extra times to repeat after the first run (0 = no repeat, -1 = infinite)
|
||||
-- yoyo (bool): if true, swap from/to each loop
|
||||
-- onStart/onUpdate/onComplete (fn): callbacks
|
||||
function Animator:to(subject, goal, duration, opts)
|
||||
assert(type(subject) == "table", "anim:to subject must be a table")
|
||||
assert(type(goal) == "table", "anim:to goal must be a table")
|
||||
assert(type(duration) == "number" and duration >= 0, "anim:to duration must be a non-negative number")
|
||||
opts = opts or {}
|
||||
|
||||
local tw = setmetatable({
|
||||
subject = subject,
|
||||
goal = goal,
|
||||
from = snapshotFrom(subject, goal),
|
||||
duration = duration,
|
||||
ease = opts.ease or anim.ease.linear,
|
||||
delay = opts.delay or 0,
|
||||
loop = opts.loop or 0,
|
||||
yoyo = opts.yoyo or false,
|
||||
onStart = opts.onStart,
|
||||
onUpdate = opts.onUpdate,
|
||||
onComplete = opts.onComplete,
|
||||
_elapsed = 0,
|
||||
_started = false,
|
||||
_done = false,
|
||||
_cancelled = false,
|
||||
_paused = false,
|
||||
}, Tween)
|
||||
|
||||
table.insert(self._tweens, tw)
|
||||
return tw
|
||||
end
|
||||
|
||||
-- Schedule a callback after `delay` seconds.
|
||||
function Animator:after(delay, fn)
|
||||
assert(type(delay) == "number" and delay >= 0, "anim:after delay must be a non-negative number")
|
||||
assert(type(fn) == "function", "anim:after fn must be a function")
|
||||
local t = { t = delay, fn = fn, cancelled = false }
|
||||
table.insert(self._timers, t)
|
||||
return {
|
||||
cancel = function() t.cancelled = true end
|
||||
}
|
||||
end
|
||||
|
||||
function Animator:clear()
|
||||
self._tweens = {}
|
||||
self._timers = {}
|
||||
end
|
||||
|
||||
function Animator:isActive()
|
||||
return (#self._tweens > 0) or (#self._timers > 0)
|
||||
end
|
||||
|
||||
-- Advances animations by dt seconds.
|
||||
-- Returns true if anything advanced (properties changed or callbacks executed).
|
||||
function Animator:update(dt)
|
||||
if type(dt) ~= "number" or dt <= 0 then
|
||||
return false
|
||||
end
|
||||
|
||||
local changed = false
|
||||
|
||||
-- Timers
|
||||
for i = #self._timers, 1, -1 do
|
||||
local t = self._timers[i]
|
||||
if t.cancelled then
|
||||
table.remove(self._timers, i)
|
||||
else
|
||||
t.t = t.t - dt
|
||||
if t.t <= 0 then
|
||||
-- run once
|
||||
t.cancelled = true
|
||||
table.remove(self._timers, i)
|
||||
t.fn()
|
||||
changed = true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Tweens
|
||||
for i = #self._tweens, 1, -1 do
|
||||
local tw = self._tweens[i]
|
||||
|
||||
if tw._cancelled then
|
||||
table.remove(self._tweens, i)
|
||||
elseif tw._paused then
|
||||
-- no-op
|
||||
else
|
||||
if tw.delay > 0 then
|
||||
tw.delay = tw.delay - dt
|
||||
if tw.delay <= 0 then
|
||||
-- start this frame (carry leftover dt)
|
||||
local carry = -tw.delay
|
||||
tw.delay = 0
|
||||
if not tw._started then
|
||||
tw._started = true
|
||||
if tw.onStart then tw.onStart(tw.subject) end
|
||||
changed = true
|
||||
end
|
||||
if carry > 0 then
|
||||
tw._elapsed = tw._elapsed + carry
|
||||
end
|
||||
end
|
||||
else
|
||||
if not tw._started then
|
||||
tw._started = true
|
||||
if tw.onStart then tw.onStart(tw.subject) end
|
||||
changed = true
|
||||
end
|
||||
tw._elapsed = tw._elapsed + dt
|
||||
end
|
||||
|
||||
if tw._started then
|
||||
local t = (tw.duration == 0) and 1 or (tw._elapsed / tw.duration)
|
||||
local p = clamp01(t)
|
||||
local e = tw.ease(p)
|
||||
|
||||
for k, toV in pairs(tw.goal) do
|
||||
local fromV = tw.from[k]
|
||||
tw.subject[k] = lerp(fromV, toV, e)
|
||||
end
|
||||
|
||||
if tw.onUpdate then tw.onUpdate(tw.subject, p) end
|
||||
changed = true
|
||||
|
||||
if t >= 1 then
|
||||
-- finalize exact goal
|
||||
for k, toV in pairs(tw.goal) do
|
||||
tw.subject[k] = toV
|
||||
end
|
||||
|
||||
if tw.loop == 0 then
|
||||
tw._done = true
|
||||
table.remove(self._tweens, i)
|
||||
if tw.onComplete then tw.onComplete(tw.subject) end
|
||||
changed = true
|
||||
else
|
||||
if tw.loop > 0 then tw.loop = tw.loop - 1 end
|
||||
|
||||
-- Reset for next loop
|
||||
tw._elapsed = 0
|
||||
tw._started = false
|
||||
|
||||
if tw.yoyo then
|
||||
-- swap from/goal
|
||||
local newGoal = {}
|
||||
for k, _ in pairs(tw.goal) do
|
||||
newGoal[k] = tw.from[k]
|
||||
end
|
||||
tw.from = snapshotFrom(tw.subject, newGoal)
|
||||
tw.goal = newGoal
|
||||
else
|
||||
tw.from = snapshotFrom(tw.subject, tw.goal)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return changed
|
||||
end
|
||||
|
||||
return anim
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
-- syslib/doublebuf.lua
|
||||
--
|
||||
-- Optional sprite/backbuffer wrapper.
|
||||
--
|
||||
-- Why:
|
||||
-- On SPI LCDs (like the M5Cardputer's ST7789), direct drawing often flickers because
|
||||
-- the display is showing the framebuffer while you "clear then draw" each frame.
|
||||
-- The fix is to render into an off-screen buffer (a sprite/canvas), then push the
|
||||
-- finished frame to the display in one shot.
|
||||
|
||||
local M = {}
|
||||
|
||||
local function hasSpriteAPI(gfx)
|
||||
return type(gfx) == "table" and type(gfx.newSprite) == "function"
|
||||
end
|
||||
|
||||
function M.new(gfx, w, h)
|
||||
if hasSpriteAPI(gfx) then
|
||||
local sprite = gfx.newSprite(w, h)
|
||||
|
||||
local g = {
|
||||
clear = function(c) return sprite:clear(c) end,
|
||||
setTextColor = function(fg, bg) return sprite:setTextColor(fg, bg) end,
|
||||
setTextSize = function(sz) return sprite:setTextSize(sz) end,
|
||||
drawString = function(text, x, y) return sprite:drawString(text, x, y) end,
|
||||
drawCenterString = function(text, x, y) return sprite:drawCenterString(text, x, y) end,
|
||||
}
|
||||
|
||||
return {
|
||||
g = g,
|
||||
isBuffered = true,
|
||||
present = function()
|
||||
-- Push the completed frame. If your binding supports DMA, this should use it.
|
||||
return sprite:push(0, 0)
|
||||
end,
|
||||
free = function()
|
||||
if sprite and type(sprite.free) == "function" then sprite:free() end
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
-- Unbuffered fallback: proxy directly to gfx's module-level functions.
|
||||
local g = {
|
||||
clear = function(c) return gfx.clear(c) end,
|
||||
setTextColor = function(fg, bg) return gfx.setTextColor(fg, bg) end,
|
||||
setTextSize = function(sz) return gfx.setTextSize(sz) end,
|
||||
drawString = function(text, x, y) return gfx.drawString(text, x, y) end,
|
||||
drawCenterString = function(text, x, y) return gfx.drawCenterString(text, x, y) end,
|
||||
}
|
||||
|
||||
return {
|
||||
g = g,
|
||||
isBuffered = false,
|
||||
present = function() end,
|
||||
free = function() end,
|
||||
}
|
||||
end
|
||||
|
||||
return M
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
-- Convert a normal 24-bit RGB hex color (#RRGGBB or RRGGBB) to RGB565 (0..65535)
|
||||
local function hexConvert(hex)
|
||||
assert(type(hex) == "string", "hexConvert expects a string like '#RRGGBB'")
|
||||
|
||||
hex = hex:gsub("^#", "")
|
||||
assert(#hex == 6, "hex color must be 6 hex digits (RRGGBB)")
|
||||
|
||||
local r = tonumber(hex:sub(1, 2), 16)
|
||||
local g = tonumber(hex:sub(3, 4), 16)
|
||||
local b = tonumber(hex:sub(5, 6), 16)
|
||||
assert(r and g and b, "invalid hex color")
|
||||
|
||||
-- RGB888 -> RGB565 (truncate, common in embedded code)
|
||||
local rgb565 = ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3)
|
||||
return rgb565
|
||||
end
|
||||
|
||||
local function clampInt(x, lo, hi)
|
||||
if x < lo then return lo end
|
||||
if x > hi then return hi end
|
||||
return x
|
||||
end
|
||||
|
||||
-- Split RGB565 into channels:
|
||||
-- r: 0..31, g: 0..63, b: 0..31
|
||||
local function unpack565(c)
|
||||
c = c & 0xFFFF
|
||||
local r = (c >> 11) & 0x1F
|
||||
local g = (c >> 5) & 0x3F
|
||||
local b = c & 0x1F
|
||||
return r, g, b
|
||||
end
|
||||
|
||||
local function pack565(r, g, b)
|
||||
r = r & 0x1F
|
||||
g = g & 0x3F
|
||||
b = b & 0x1F
|
||||
return (r << 11) | (g << 5) | b
|
||||
end
|
||||
|
||||
-- Blend a foreground RGB565 color over a background RGB565 color, returning an RGB565 result.
|
||||
-- opacity:
|
||||
-- - 0..1 (float) OR 0..255 (int)
|
||||
-- - 0 means fully background, 1/255 means fully foreground
|
||||
local function withOpacity(fg565, bg565, opacity)
|
||||
assert(type(fg565) == "number" and type(bg565) == "number", "withOpacity expects (number fg565, number bg565, number opacity)")
|
||||
assert(type(opacity) == "number", "withOpacity opacity must be a number")
|
||||
|
||||
local a
|
||||
if opacity <= 1 then
|
||||
a = math.floor(opacity * 255 + 0.5)
|
||||
else
|
||||
a = math.floor(opacity + 0.5)
|
||||
end
|
||||
a = clampInt(a, 0, 255)
|
||||
|
||||
if a == 0 then return bg565 & 0xFFFF end
|
||||
if a == 255 then return fg565 & 0xFFFF end
|
||||
|
||||
local fr, fg, fb = unpack565(fg565)
|
||||
local br, bg, bb = unpack565(bg565)
|
||||
|
||||
-- out = bg + (fg - bg) * a
|
||||
-- done per-channel, with rounding.
|
||||
local r = math.floor((br * (255 - a) + fr * a + 127) / 255)
|
||||
local g = math.floor((bg * (255 - a) + fg * a + 127) / 255)
|
||||
local b = math.floor((bb * (255 - a) + fb * a + 127) / 255)
|
||||
|
||||
return pack565(
|
||||
clampInt(r, 0, 31),
|
||||
clampInt(g, 0, 63),
|
||||
clampInt(b, 0, 31)
|
||||
)
|
||||
end
|
||||
|
||||
|
||||
return {
|
||||
hexConvert = hexConvert,
|
||||
withOpacity = withOpacity,
|
||||
}
|
||||
Reference in New Issue
Block a user