883 lines
28 KiB
Lua
883 lines
28 KiB
Lua
local ErrorHandler = nil
|
|
local Easing = nil
|
|
local Color = nil
|
|
---@class Keyframe
|
|
---@field at number Normalized time position (0-1)
|
|
---@field values table Property values at this keyframe
|
|
---@field easing string|EasingFunction? Easing to use between this and next keyframe
|
|
|
|
---@class AnimationProps
|
|
---@field duration number Duration in seconds
|
|
---@field start table Starting values (can contain: width, height, opacity, x, y, gap, imageOpacity, backgroundColor, borderColor, textColor, padding, margin, cornerRadius, etc.)
|
|
---@field final table Final values (same properties as start)
|
|
---@field easing string? Easing function name (default: "linear")
|
|
---@field keyframes Keyframe[]? Array of keyframes for complex animations
|
|
---@field transform table? Additional transform properties
|
|
---@field transition table? Transition properties
|
|
---@field onStart function? Called when animation starts: (animation, element)
|
|
---@field onUpdate function? Called each frame: (animation, element, progress)
|
|
---@field onComplete function? Called when animation completes: (animation, element)
|
|
---@field onCancel function? Called when animation is cancelled: (animation, element)
|
|
|
|
---@class Animation
|
|
---@field duration number Duration in seconds
|
|
---@field start table Starting values
|
|
---@field final table Final values
|
|
---@field elapsed number Elapsed time in seconds
|
|
---@field easing EasingFunction Easing function
|
|
---@field keyframes Keyframe[]? Array of keyframes for complex animations
|
|
---@field transform table? Additional transform properties
|
|
---@field transition table? Transition properties
|
|
---@field _cachedResult table Cached interpolation result
|
|
---@field _resultDirty boolean Whether cached result needs recalculation
|
|
---@field _Color table? Reference to Color module (for lerp)
|
|
local Animation = {}
|
|
Animation.__index = Animation
|
|
|
|
--- Build smooth, timed transitions between visual states to create polished, professional UIs
|
|
--- Use this to animate position, size, opacity, colors, and other properties with customizable easing
|
|
---@param props AnimationProps Animation properties
|
|
---@return Animation animation The new animation instance
|
|
function Animation.new(props)
|
|
-- Validate input
|
|
if type(props) ~= "table" then
|
|
ErrorHandler.warn("Animation", "Animation.new() requires a table argument. Using default values.")
|
|
props = { duration = 1, start = {}, final = {} }
|
|
end
|
|
|
|
if type(props.duration) ~= "number" or props.duration <= 0 then
|
|
ErrorHandler.warn("Animation", "Animation duration must be a positive number. Using 1 second.")
|
|
props.duration = 1
|
|
end
|
|
|
|
if type(props.start) ~= "table" then
|
|
ErrorHandler.warn("Animation", "Animation start must be a table. Using empty table.")
|
|
props.start = {}
|
|
end
|
|
|
|
if type(props.final) ~= "table" then
|
|
ErrorHandler.warn("Animation", "Animation final must be a table. Using empty table.")
|
|
props.final = {}
|
|
end
|
|
|
|
local self = setmetatable({}, Animation)
|
|
self.duration = props.duration
|
|
self.start = props.start
|
|
self.final = props.final
|
|
self.keyframes = props.keyframes
|
|
self.transform = props.transform
|
|
self.transition = props.transition
|
|
self.elapsed = 0
|
|
|
|
-- Lifecycle callbacks
|
|
self.onStart = props.onStart
|
|
self.onUpdate = props.onUpdate
|
|
self.onComplete = props.onComplete
|
|
self.onCancel = props.onCancel
|
|
self._hasStarted = false
|
|
|
|
-- Control state
|
|
self._paused = false
|
|
self._reversed = false
|
|
self._speed = 1.0
|
|
self._state = "pending" -- "pending", "playing", "paused", "completed", "cancelled"
|
|
|
|
-- Validate and set easing function
|
|
local easingName = props.easing or "linear"
|
|
if type(easingName) == "string" then
|
|
self.easing = Easing[easingName] or Easing.linear
|
|
elseif type(easingName) == "function" then
|
|
self.easing = easingName
|
|
else
|
|
self.easing = Easing.linear
|
|
end
|
|
|
|
-- Pre-allocate result table to avoid GC pressure
|
|
self._cachedResult = {}
|
|
self._resultDirty = true
|
|
|
|
return self
|
|
end
|
|
|
|
--- Advance the animation timeline and calculate interpolated values for the current frame
|
|
--- Call this each frame to progress the animation; returns true when complete for cleanup
|
|
---@param dt number Delta time in seconds
|
|
---@param element table? Optional element reference for callbacks
|
|
---@return boolean completed True if animation is complete
|
|
function Animation:update(dt, element)
|
|
-- Sanitize dt
|
|
if type(dt) ~= "number" or dt < 0 or dt ~= dt or dt == math.huge then
|
|
dt = 0
|
|
end
|
|
|
|
-- Don't update if paused
|
|
if self._paused then
|
|
return false
|
|
end
|
|
|
|
-- Handle delay
|
|
if self._delay and self._delayElapsed then
|
|
if self._delayElapsed < self._delay then
|
|
self._delayElapsed = self._delayElapsed + dt
|
|
return false
|
|
end
|
|
end
|
|
|
|
-- Call onStart on first update
|
|
if not self._hasStarted then
|
|
self._hasStarted = true
|
|
self._state = "playing"
|
|
if self.onStart and type(self.onStart) == "function" then
|
|
local success, err = pcall(self.onStart, self, element)
|
|
if not success then
|
|
-- Log error but don't crash
|
|
print(string.format("[Animation] onStart error: %s", tostring(err)))
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Apply speed multiplier
|
|
dt = dt * self._speed
|
|
|
|
-- Update elapsed time (reversed if needed)
|
|
if self._reversed then
|
|
self.elapsed = self.elapsed - dt
|
|
if self.elapsed <= 0 then
|
|
self.elapsed = 0
|
|
self._state = "completed"
|
|
self._resultDirty = true
|
|
-- Call onComplete callback
|
|
if self.onComplete and type(self.onComplete) == "function" then
|
|
local success, err = pcall(self.onComplete, self, element)
|
|
if not success then
|
|
print(string.format("[Animation] onComplete error: %s", tostring(err)))
|
|
end
|
|
end
|
|
return true
|
|
end
|
|
else
|
|
self.elapsed = self.elapsed + dt
|
|
if self.elapsed >= self.duration then
|
|
self.elapsed = self.duration
|
|
self._resultDirty = true
|
|
|
|
-- Handle repeat and yoyo
|
|
if self._repeatCount then
|
|
self._repeatCurrent = (self._repeatCurrent or 0) + 1
|
|
|
|
if self._repeatCount == 0 or self._repeatCurrent < self._repeatCount then
|
|
-- Continue repeating
|
|
if self._yoyo then
|
|
-- Reverse direction for yoyo
|
|
self._reversed = not self._reversed
|
|
if self._reversed then
|
|
self.elapsed = self.duration
|
|
else
|
|
self.elapsed = 0
|
|
end
|
|
else
|
|
-- Reset to beginning
|
|
self.elapsed = 0
|
|
end
|
|
return false
|
|
end
|
|
end
|
|
|
|
-- Animation truly completed
|
|
self._state = "completed"
|
|
-- Call onComplete callback
|
|
if self.onComplete and type(self.onComplete) == "function" then
|
|
local success, err = pcall(self.onComplete, self, element)
|
|
if not success then
|
|
print(string.format("[Animation] onComplete error: %s", tostring(err)))
|
|
end
|
|
end
|
|
return true
|
|
end
|
|
end
|
|
|
|
self._resultDirty = true
|
|
|
|
-- Call onUpdate callback
|
|
if self.onUpdate and type(self.onUpdate) == "function" then
|
|
local progress = self.elapsed / self.duration
|
|
local success, err = pcall(self.onUpdate, self, element, progress)
|
|
if not success then
|
|
print(string.format("[Animation] onUpdate error: %s", tostring(err)))
|
|
end
|
|
end
|
|
|
|
return false
|
|
end
|
|
|
|
--- Helper function to interpolate numeric values
|
|
---@param startValue number Starting value
|
|
---@param finalValue number Final value
|
|
---@param easedT number Eased time (0-1)
|
|
---@return number interpolated Interpolated value
|
|
local function lerpNumber(startValue, finalValue, easedT)
|
|
return startValue * (1 - easedT) + finalValue * easedT
|
|
end
|
|
|
|
--- Helper function to interpolate Color values
|
|
---@param startColor any Starting color (Color instance or parseable color)
|
|
---@param finalColor any Final color (Color instance or parseable color)
|
|
---@param easedT number Eased time (0-1)
|
|
---@param ColorModule table Color module reference
|
|
---@return any interpolated Interpolated Color instance
|
|
local function lerpColor(startColor, finalColor, easedT, ColorModule)
|
|
-- Use provided ColorModule or fall back to module-level Color or static _ColorModule
|
|
local CM = ColorModule or Color or Animation._ColorModule
|
|
|
|
if not CM or not CM.parse or not CM.lerp then
|
|
if ErrorHandler then
|
|
ErrorHandler.warn("Animation", "Color module not properly initialized. Cannot interpolate colors.")
|
|
end
|
|
return startColor -- Return start color as fallback
|
|
end
|
|
|
|
-- Parse colors if needed
|
|
local colorA = CM.parse(startColor)
|
|
local colorB = CM.parse(finalColor)
|
|
|
|
return CM.lerp(colorA, colorB, easedT)
|
|
end
|
|
|
|
--- Helper function to interpolate table values (padding, margin, cornerRadius)
|
|
---@param startTable table Starting table
|
|
---@param finalTable table Final table
|
|
---@param easedT number Eased time (0-1)
|
|
---@return table interpolated Interpolated table
|
|
local function lerpTable(startTable, finalTable, easedT)
|
|
local result = {}
|
|
|
|
-- Iterate through all keys in both tables
|
|
local keys = {}
|
|
for k in pairs(startTable) do
|
|
keys[k] = true
|
|
end
|
|
for k in pairs(finalTable) do
|
|
keys[k] = true
|
|
end
|
|
|
|
for key in pairs(keys) do
|
|
local startVal = startTable[key]
|
|
local finalVal = finalTable[key]
|
|
|
|
if type(startVal) == "number" and type(finalVal) == "number" then
|
|
result[key] = lerpNumber(startVal, finalVal, easedT)
|
|
elseif startVal ~= nil then
|
|
result[key] = startVal
|
|
else
|
|
result[key] = finalVal
|
|
end
|
|
end
|
|
|
|
return result
|
|
end
|
|
|
|
--- Find the two keyframes surrounding the current progress
|
|
---@param progress number Current animation progress (0-1)
|
|
---@return Keyframe prevFrame The keyframe before current progress
|
|
---@return Keyframe nextFrame The keyframe after current progress
|
|
function Animation:findKeyframes(progress)
|
|
if not self.keyframes or #self.keyframes < 2 then
|
|
return nil, nil
|
|
end
|
|
|
|
-- Find surrounding keyframes
|
|
local prevFrame = self.keyframes[1]
|
|
local nextFrame = self.keyframes[#self.keyframes]
|
|
|
|
for i = 1, #self.keyframes - 1 do
|
|
if progress >= self.keyframes[i].at and progress <= self.keyframes[i + 1].at then
|
|
prevFrame = self.keyframes[i]
|
|
nextFrame = self.keyframes[i + 1]
|
|
break
|
|
end
|
|
end
|
|
|
|
return prevFrame, nextFrame
|
|
end
|
|
|
|
--- Interpolate between two keyframes
|
|
---@param prevFrame Keyframe Starting keyframe
|
|
---@param nextFrame Keyframe Ending keyframe
|
|
---@param easedT number Eased time (0-1) for interpolation
|
|
---@return table result Interpolated values
|
|
function Animation:lerpKeyframes(prevFrame, nextFrame, easedT)
|
|
local result = {}
|
|
|
|
-- Get all unique property keys
|
|
local keys = {}
|
|
for k in pairs(prevFrame.values) do
|
|
keys[k] = true
|
|
end
|
|
for k in pairs(nextFrame.values) do
|
|
keys[k] = true
|
|
end
|
|
|
|
-- Define properties that should be animated as numbers
|
|
local numericProperties = {
|
|
"width",
|
|
"height",
|
|
"opacity",
|
|
"x",
|
|
"y",
|
|
"gap",
|
|
"imageOpacity",
|
|
"scrollbarWidth",
|
|
"borderWidth",
|
|
"fontSize",
|
|
"lineHeight",
|
|
}
|
|
|
|
-- Define properties that should be animated as Colors
|
|
local colorProperties = {
|
|
"backgroundColor",
|
|
"borderColor",
|
|
"textColor",
|
|
"scrollbarColor",
|
|
"scrollbarBackgroundColor",
|
|
"imageTint",
|
|
}
|
|
|
|
-- Define properties that should be animated as tables
|
|
local tableProperties = {
|
|
"padding",
|
|
"margin",
|
|
"cornerRadius",
|
|
}
|
|
|
|
-- Create lookup sets for faster property type checking
|
|
local numericSet = {}
|
|
for _, prop in ipairs(numericProperties) do
|
|
numericSet[prop] = true
|
|
end
|
|
|
|
local colorSet = {}
|
|
for _, prop in ipairs(colorProperties) do
|
|
colorSet[prop] = true
|
|
end
|
|
|
|
local tableSet = {}
|
|
for _, prop in ipairs(tableProperties) do
|
|
tableSet[prop] = true
|
|
end
|
|
|
|
-- Interpolate each property
|
|
for key in pairs(keys) do
|
|
local startVal = prevFrame.values[key]
|
|
local finalVal = nextFrame.values[key]
|
|
|
|
if numericSet[key] and type(startVal) == "number" and type(finalVal) == "number" then
|
|
result[key] = lerpNumber(startVal, finalVal, easedT)
|
|
elseif colorSet[key] and (self._Color or Animation._ColorModule) then
|
|
if startVal ~= nil and finalVal ~= nil then
|
|
result[key] = lerpColor(startVal, finalVal, easedT, self._Color or Animation._ColorModule)
|
|
end
|
|
elseif tableSet[key] and type(startVal) == "table" and type(finalVal) == "table" then
|
|
result[key] = lerpTable(startVal, finalVal, easedT)
|
|
elseif type(startVal) == type(finalVal) then
|
|
-- For unknown types, try numeric interpolation if they're numbers
|
|
if type(startVal) == "number" then
|
|
result[key] = lerpNumber(startVal, finalVal, easedT)
|
|
else
|
|
-- Otherwise use the final value
|
|
result[key] = finalVal
|
|
end
|
|
end
|
|
end
|
|
|
|
return result
|
|
end
|
|
|
|
--- Calculate the current animated values between start and end states based on elapsed time
|
|
--- Use this to get the interpolated properties to apply to your element
|
|
---@return table result Interpolated values {width?, height?, opacity?, x?, y?, backgroundColor?, ...}
|
|
function Animation:interpolate()
|
|
-- Return cached result if not dirty (avoids recalculation)
|
|
if not self._resultDirty then
|
|
return self._cachedResult
|
|
end
|
|
|
|
local t = math.min(self.elapsed / self.duration, 1)
|
|
|
|
-- Handle keyframe animations
|
|
if self.keyframes and type(self.keyframes) == "table" and #self.keyframes >= 2 then
|
|
local prevFrame, nextFrame = self:findKeyframes(t)
|
|
|
|
if prevFrame and nextFrame then
|
|
-- Calculate local progress between keyframes
|
|
local localProgress = 0
|
|
if nextFrame.at > prevFrame.at then
|
|
localProgress = (t - prevFrame.at) / (nextFrame.at - prevFrame.at)
|
|
end
|
|
|
|
-- Apply per-keyframe easing
|
|
local easingFn = Easing.linear
|
|
if prevFrame.easing then
|
|
if type(prevFrame.easing) == "string" then
|
|
easingFn = Easing[prevFrame.easing] or Easing.linear
|
|
elseif type(prevFrame.easing) == "function" then
|
|
easingFn = prevFrame.easing
|
|
end
|
|
end
|
|
|
|
local success, easedT = pcall(easingFn, localProgress)
|
|
if not success or type(easedT) ~= "number" or easedT ~= easedT or easedT == math.huge or easedT == -math.huge then
|
|
easedT = localProgress
|
|
end
|
|
|
|
-- Interpolate between keyframes
|
|
local keyframeResult = self:lerpKeyframes(prevFrame, nextFrame, easedT)
|
|
|
|
-- Copy to cached result
|
|
local result = self._cachedResult
|
|
for k in pairs(result) do
|
|
result[k] = nil
|
|
end
|
|
for k, v in pairs(keyframeResult) do
|
|
result[k] = v
|
|
end
|
|
|
|
self._resultDirty = false
|
|
return result
|
|
end
|
|
end
|
|
|
|
-- Standard interpolation (non-keyframe)
|
|
-- Apply easing function with protection
|
|
local success, easedT = pcall(self.easing, t)
|
|
if not success or type(easedT) ~= "number" or easedT ~= easedT or easedT == math.huge or easedT == -math.huge then
|
|
easedT = t -- Fallback to linear if easing fails
|
|
end
|
|
|
|
local result = self._cachedResult -- Reuse existing table
|
|
|
|
-- Clear previous results
|
|
for k in pairs(result) do
|
|
result[k] = nil
|
|
end
|
|
|
|
-- Define properties that should be animated as numbers
|
|
local numericProperties = {
|
|
"width",
|
|
"height",
|
|
"opacity",
|
|
"x",
|
|
"y",
|
|
"gap",
|
|
"imageOpacity",
|
|
"scrollbarWidth",
|
|
"borderWidth",
|
|
"fontSize",
|
|
"lineHeight",
|
|
}
|
|
|
|
-- Define properties that should be animated as Colors
|
|
local colorProperties = {
|
|
"backgroundColor",
|
|
"borderColor",
|
|
"textColor",
|
|
"scrollbarColor",
|
|
"scrollbarBackgroundColor",
|
|
"imageTint",
|
|
}
|
|
|
|
-- Define properties that should be animated as tables
|
|
local tableProperties = {
|
|
"padding",
|
|
"margin",
|
|
"cornerRadius",
|
|
}
|
|
|
|
-- Interpolate numeric properties
|
|
for _, prop in ipairs(numericProperties) do
|
|
local startVal = self.start[prop]
|
|
local finalVal = self.final[prop]
|
|
|
|
if type(startVal) == "number" and type(finalVal) == "number" then
|
|
result[prop] = lerpNumber(startVal, finalVal, easedT)
|
|
end
|
|
end
|
|
|
|
-- Interpolate color properties (if Color module is available)
|
|
local ColorModule = self._Color or Animation._ColorModule
|
|
if ColorModule then
|
|
for _, prop in ipairs(colorProperties) do
|
|
local startVal = self.start[prop]
|
|
local finalVal = self.final[prop]
|
|
|
|
if startVal ~= nil and finalVal ~= nil then
|
|
result[prop] = lerpColor(startVal, finalVal, easedT, ColorModule)
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Interpolate table properties
|
|
for _, prop in ipairs(tableProperties) do
|
|
local startVal = self.start[prop]
|
|
local finalVal = self.final[prop]
|
|
|
|
if type(startVal) == "table" and type(finalVal) == "table" then
|
|
result[prop] = lerpTable(startVal, finalVal, easedT)
|
|
end
|
|
end
|
|
|
|
-- Interpolate transform property (if Transform module is available)
|
|
if self._Transform and self.start.transform and self.final.transform then
|
|
result.transform = self._Transform.lerp(self.start.transform, self.final.transform, easedT)
|
|
end
|
|
|
|
-- Copy transform properties (legacy support)
|
|
if self.transform and type(self.transform) == "table" then
|
|
for key, value in pairs(self.transform) do
|
|
result[key] = value
|
|
end
|
|
end
|
|
|
|
self._resultDirty = false
|
|
return result
|
|
end
|
|
|
|
--- Attach this animation to an element so it automatically updates and applies changes
|
|
--- Use this for hands-off animation that integrates with FlexLove's rendering system
|
|
---@param element Element The element to apply animation to
|
|
function Animation:apply(element)
|
|
if not ErrorHandler then
|
|
ErrorHandler = require("modules.ErrorHandler")
|
|
end
|
|
|
|
if not element or type(element) ~= "table" then
|
|
ErrorHandler.warn("Animation", "Cannot apply animation to nil or non-table element. Animation not applied.")
|
|
return
|
|
end
|
|
element.animation = self
|
|
end
|
|
|
|
--- Set Color module reference for color interpolation
|
|
---@param ColorModule table Color module
|
|
function Animation:setColorModule(ColorModule)
|
|
self._Color = ColorModule
|
|
end
|
|
|
|
--- Set Transform module reference for transform interpolation
|
|
---@param TransformModule table Transform module
|
|
function Animation:setTransformModule(TransformModule)
|
|
self._Transform = TransformModule
|
|
end
|
|
|
|
--- Temporarily halt the animation without losing progress
|
|
--- Use this to freeze animations during pause menus or cutscenes
|
|
function Animation:pause()
|
|
if self._state == "playing" or self._state == "pending" then
|
|
self._paused = true
|
|
self._state = "paused"
|
|
end
|
|
end
|
|
|
|
--- Continue a paused animation from where it left off
|
|
--- Use this to unpause animations when returning from pause menus
|
|
function Animation:resume()
|
|
if self._state == "paused" then
|
|
self._paused = false
|
|
self._state = "playing"
|
|
end
|
|
end
|
|
|
|
--- Query pause state to conditionally handle animation logic
|
|
--- Use this to sync UI behavior with animation state
|
|
---@return boolean paused
|
|
function Animation:isPaused()
|
|
return self._paused
|
|
end
|
|
|
|
--- Flip the animation to play backwards, creating smooth transitions in both directions
|
|
--- Use this for hover effects that reverse on mouse-out or toggleable UI elements
|
|
function Animation:reverse()
|
|
self._reversed = not self._reversed
|
|
end
|
|
|
|
--- Determine current playback direction for conditional animation logic
|
|
--- Use this to track which direction the animation is playing
|
|
---@return boolean reversed
|
|
function Animation:isReversed()
|
|
return self._reversed
|
|
end
|
|
|
|
--- Control animation tempo for slow-motion or fast-forward effects
|
|
--- Use this for bullet-time, game speed multipliers, or debugging
|
|
---@param speed number Speed multiplier (1.0 = normal, 2.0 = double speed, 0.5 = half speed)
|
|
function Animation:setSpeed(speed)
|
|
if type(speed) == "number" and speed > 0 then
|
|
self._speed = speed
|
|
end
|
|
end
|
|
|
|
--- Check current playback speed for debugging or UI display
|
|
--- Use this to show animation speed in dev tools
|
|
---@return number speed Current speed multiplier
|
|
function Animation:getSpeed()
|
|
return self._speed
|
|
end
|
|
|
|
--- Jump to any point in the animation timeline for previewing or state restoration
|
|
--- Use this to skip ahead, rewind, or restore saved animation states
|
|
---@param time number Time in seconds (clamped to 0-duration)
|
|
function Animation:seek(time)
|
|
if type(time) == "number" then
|
|
self.elapsed = math.max(0, math.min(time, self.duration))
|
|
self._resultDirty = true
|
|
end
|
|
end
|
|
|
|
--- Query animation lifecycle state for conditional logic and debugging
|
|
--- Use this to determine if cleanup is needed or to prevent duplicate animations
|
|
---@return string state Current state: "pending", "playing", "paused", "completed", "cancelled"
|
|
function Animation:getState()
|
|
return self._state
|
|
end
|
|
|
|
--- Stop the animation immediately without completing, triggering the onCancel callback
|
|
--- Use this to abort animations when UI elements are removed or user cancels an action
|
|
---@param element table? Optional element reference for callback
|
|
function Animation:cancel(element)
|
|
if self._state ~= "cancelled" and self._state ~= "completed" then
|
|
self._state = "cancelled"
|
|
if self.onCancel and type(self.onCancel) == "function" then
|
|
local success, err = pcall(self.onCancel, self, element)
|
|
if not success then
|
|
print(string.format("[Animation] onCancel error: %s", tostring(err)))
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
--- Return the animation to the beginning for replay
|
|
--- Use this to reuse animation instances without recreating them
|
|
function Animation:reset()
|
|
self.elapsed = 0
|
|
self._hasStarted = false
|
|
self._paused = false
|
|
self._state = "pending"
|
|
self._resultDirty = true
|
|
end
|
|
|
|
--- Get normalized animation progress for progress bars or synchronized effects
|
|
--- Use this to drive secondary animations or display completion percentage
|
|
---@return number progress Progress from 0 to 1
|
|
function Animation:getProgress()
|
|
return math.min(self.elapsed / self.duration, 1)
|
|
end
|
|
|
|
--- Create sequential animation flows that play one after another
|
|
--- Use this to build complex multi-step animations like slide-in-then-fade
|
|
---@param nextAnimation Animation|function Animation instance or factory function that returns an animation
|
|
---@return Animation nextAnimation The chained animation (for further chaining)
|
|
function Animation:chain(nextAnimation)
|
|
if not ErrorHandler then
|
|
ErrorHandler = require("modules.ErrorHandler")
|
|
end
|
|
|
|
if type(nextAnimation) == "function" then
|
|
self._nextFactory = nextAnimation
|
|
return self
|
|
elseif type(nextAnimation) == "table" then
|
|
self._next = nextAnimation
|
|
return nextAnimation
|
|
else
|
|
ErrorHandler.warn("Animation", "chain() requires an Animation or function. Chaining not applied.")
|
|
return self
|
|
end
|
|
end
|
|
|
|
--- Introduce a wait period before animation begins for staggered effects
|
|
--- Use this to create cascading animations or timed sequences
|
|
---@param seconds number Delay duration in seconds
|
|
---@return Animation self For chaining
|
|
function Animation:delay(seconds)
|
|
if not ErrorHandler then
|
|
ErrorHandler = require("modules.ErrorHandler")
|
|
end
|
|
|
|
if type(seconds) ~= "number" or seconds < 0 then
|
|
ErrorHandler.warn("Animation", "delay() requires a non-negative number. Using 0.")
|
|
seconds = 0
|
|
end
|
|
self._delay = seconds
|
|
self._delayElapsed = 0
|
|
return self
|
|
end
|
|
|
|
--- Loop the animation for pulsing effects, loading indicators, or continuous motion
|
|
--- Use this for idle animations and attention-grabbing elements
|
|
---@param count number Number of times to repeat (0 = infinite loop)
|
|
---@return Animation self For chaining
|
|
function Animation:repeatCount(count)
|
|
if not ErrorHandler then
|
|
ErrorHandler = require("modules.ErrorHandler")
|
|
end
|
|
|
|
if type(count) ~= "number" or count < 0 then
|
|
ErrorHandler.warn("Animation", "repeatCount() requires a non-negative number. Using 0.")
|
|
count = 0
|
|
end
|
|
self._repeatCount = count
|
|
self._repeatCurrent = 0
|
|
return self
|
|
end
|
|
|
|
--- Make repeating animations play forwards then backwards for smooth oscillation
|
|
--- Use this for breathing effects, pulsing highlights, or pendulum motions
|
|
---@param enabled boolean? Enable yoyo mode (default: true)
|
|
---@return Animation self For chaining
|
|
function Animation:yoyo(enabled)
|
|
if enabled == nil then
|
|
enabled = true
|
|
end
|
|
self._yoyo = enabled
|
|
return self
|
|
end
|
|
|
|
--- Quickly create fade in/out effects without manually specifying start/end states
|
|
--- Use this convenience method for common opacity transitions in tooltips, notifications, and overlays
|
|
---@param duration number Duration in seconds
|
|
---@param fromOpacity number Starting opacity (0-1)
|
|
---@param toOpacity number Ending opacity (0-1)
|
|
---@param easing string? Easing function name (default: "linear")
|
|
---@return Animation animation The fade animation
|
|
function Animation.fade(duration, fromOpacity, toOpacity, easing)
|
|
-- Sanitize inputs
|
|
if type(duration) ~= "number" or duration <= 0 then
|
|
duration = 1
|
|
end
|
|
if type(fromOpacity) ~= "number" then
|
|
fromOpacity = 1
|
|
end
|
|
if type(toOpacity) ~= "number" then
|
|
toOpacity = 0
|
|
end
|
|
|
|
return Animation.new({
|
|
duration = duration,
|
|
start = { opacity = fromOpacity },
|
|
final = { opacity = toOpacity },
|
|
easing = easing,
|
|
transform = {},
|
|
transition = {},
|
|
})
|
|
end
|
|
|
|
--- Quickly create grow/shrink effects without manually specifying dimensions
|
|
--- Use this convenience method for bounce effects, pop-ups, and attention animations
|
|
---@param duration number Duration in seconds
|
|
---@param fromScale {width:number,height:number} Starting scale
|
|
---@param toScale {width:number,height:number} Ending scale
|
|
---@param easing string? Easing function name (default: "linear")
|
|
---@return Animation animation The scale animation
|
|
function Animation.scale(duration, fromScale, toScale, easing)
|
|
-- Sanitize inputs
|
|
if type(duration) ~= "number" or duration <= 0 then
|
|
duration = 1
|
|
end
|
|
if type(fromScale) ~= "table" then
|
|
fromScale = { width = 1, height = 1 }
|
|
end
|
|
if type(toScale) ~= "table" then
|
|
toScale = { width = 1, height = 1 }
|
|
end
|
|
|
|
return Animation.new({
|
|
duration = duration,
|
|
start = { width = fromScale.width or 0, height = fromScale.height or 0 },
|
|
final = { width = toScale.width or 0, height = toScale.height or 0 },
|
|
easing = easing,
|
|
transform = {},
|
|
transition = {},
|
|
})
|
|
end
|
|
|
|
--- Create a keyframe-based animation with multiple waypoints and per-keyframe easing
|
|
--- Use this for complex multi-step animations like bounce-in effects or CSS-style @keyframes
|
|
---@param props {duration:number, keyframes:Keyframe[], onStart:function?, onUpdate:function?, onComplete:function?, onCancel:function?} Animation properties
|
|
---@return Animation animation The keyframe animation
|
|
function Animation.keyframes(props)
|
|
if not ErrorHandler then
|
|
ErrorHandler = require("modules.ErrorHandler")
|
|
end
|
|
|
|
-- Validate input
|
|
if type(props) ~= "table" then
|
|
ErrorHandler.warn("Animation", "Animation.keyframes() requires a table argument. Using default values.")
|
|
props = { duration = 1, keyframes = {} }
|
|
end
|
|
|
|
if type(props.duration) ~= "number" or props.duration <= 0 then
|
|
ErrorHandler.warn("Animation", "Keyframe animation duration must be a positive number. Using 1 second.")
|
|
props.duration = 1
|
|
end
|
|
|
|
if type(props.keyframes) ~= "table" or #props.keyframes < 2 then
|
|
ErrorHandler.warn("Animation", "Keyframe animation requires at least 2 keyframes. Using empty animation.")
|
|
props.keyframes = {
|
|
{ at = 0, values = {} },
|
|
{ at = 1, values = {} },
|
|
}
|
|
end
|
|
|
|
-- Sort keyframes by 'at' position
|
|
local sortedKeyframes = {}
|
|
for i, kf in ipairs(props.keyframes) do
|
|
if type(kf) == "table" and type(kf.at) == "number" and type(kf.values) == "table" then
|
|
table.insert(sortedKeyframes, kf)
|
|
end
|
|
end
|
|
|
|
table.sort(sortedKeyframes, function(a, b)
|
|
return a.at < b.at
|
|
end)
|
|
|
|
-- Ensure keyframes start at 0 and end at 1
|
|
if #sortedKeyframes > 0 then
|
|
if sortedKeyframes[1].at > 0 then
|
|
table.insert(sortedKeyframes, 1, { at = 0, values = sortedKeyframes[1].values })
|
|
end
|
|
if sortedKeyframes[#sortedKeyframes].at < 1 then
|
|
table.insert(sortedKeyframes, { at = 1, values = sortedKeyframes[#sortedKeyframes].values })
|
|
end
|
|
end
|
|
|
|
-- Create animation with keyframes
|
|
return Animation.new({
|
|
duration = props.duration,
|
|
start = {},
|
|
final = {},
|
|
keyframes = sortedKeyframes,
|
|
onStart = props.onStart,
|
|
onUpdate = props.onUpdate,
|
|
onComplete = props.onComplete,
|
|
onCancel = props.onCancel,
|
|
})
|
|
end
|
|
|
|
--- Initialize dependencies
|
|
---@param deps table Dependencies: { ErrorHandler = ErrorHandler, Easing = Easing, Color = Color? }
|
|
function Animation.init(deps)
|
|
if type(deps) == "table" then
|
|
ErrorHandler = deps.ErrorHandler
|
|
Easing = deps.Easing
|
|
if deps.Color then
|
|
Color = deps.Color
|
|
Animation._ColorModule = deps.Color
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Static method for Color module injection (for per-instance Color override)
|
|
function Animation.setColorModule(ColorModule)
|
|
Animation._ColorModule = ColorModule
|
|
end
|
|
|
|
return Animation
|