more work on Animation

This commit is contained in:
Michael Freno
2025-11-18 12:17:12 -05:00
parent 6f3fa0e473
commit 96150e5cf4
10 changed files with 810 additions and 79 deletions

View File

@@ -1,6 +1,9 @@
--- Easing function type
---@alias EasingFunction fun(t: number): number
-- ErrorHandler dependency (injected via initializeErrorHandler)
local ErrorHandler = nil
--- Easing functions for animations
---@type table<string, EasingFunction>
local Easing = {
@@ -76,19 +79,23 @@ Animation.__index = Animation
function Animation.new(props)
-- Validate input
if type(props) ~= "table" then
error("[FlexLove.Animation] Animation.new() requires a table argument")
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
error("[FlexLove.Animation] Animation duration must be a positive number")
ErrorHandler.warn("Animation", "Animation duration must be a positive number. Using 1 second.")
props.duration = 1
end
if type(props.start) ~= "table" then
error("[FlexLove.Animation] Animation start must be a table")
ErrorHandler.warn("Animation", "Animation start must be a table. Using empty table.")
props.start = {}
end
if type(props.final) ~= "table" then
error("[FlexLove.Animation] Animation final must be a table")
ErrorHandler.warn("Animation", "Animation final must be a table. Using empty table.")
props.final = {}
end
local self = setmetatable({}, Animation)
@@ -144,6 +151,14 @@ function Animation:update(dt, element)
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
@@ -180,8 +195,32 @@ function Animation:update(dt, element)
self.elapsed = self.elapsed + dt
if self.elapsed >= self.duration then
self.elapsed = self.duration
self._state = "completed"
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)
@@ -355,8 +394,13 @@ end
---Apply this animation to an element
---@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
error("[FlexLove.Animation] Cannot apply animation to nil or non-table element")
ErrorHandler.warn("Animation", "Cannot apply animation to nil or non-table element. Animation not applied.")
return
end
element.animation = self
end
@@ -464,6 +508,71 @@ function Animation:getProgress()
return math.min(self.elapsed / self.duration, 1)
end
---Chain another animation after this one completes
---@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
---Add delay before animation starts
---@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
---Repeat animation multiple times
---@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
---Enable yoyo mode (animation reverses direction on each repeat)
---@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
--- Create a simple fade animation
---@param duration number Duration in seconds
---@param fromOpacity number Starting opacity (0-1)
@@ -520,4 +629,13 @@ function Animation.scale(duration, fromScale, toScale, easing)
})
end
--- Initialize ErrorHandler dependency
---@param errorHandler table The ErrorHandler module
local function initializeErrorHandler(errorHandler)
ErrorHandler = errorHandler
end
-- Export ErrorHandler initializer
Animation.initializeErrorHandler = initializeErrorHandler
return Animation

327
modules/AnimationGroup.lua Normal file
View File

@@ -0,0 +1,327 @@
--- AnimationGroup module for running multiple animations together
---@class AnimationGroup
local AnimationGroup = {}
AnimationGroup.__index = AnimationGroup
-- ErrorHandler dependency (injected via initializeErrorHandler)
local ErrorHandler = nil
---@class AnimationGroupProps
---@field animations table Array of Animation instances
---@field mode string? "parallel", "sequence", or "stagger" (default: "parallel")
---@field stagger number? Stagger delay in seconds (for stagger mode, default: 0.1)
---@field onComplete function? Called when all animations complete: (group)
---@field onStart function? Called when group starts: (group)
--- Create a new animation group
---@param props AnimationGroupProps
---@return AnimationGroup group
function AnimationGroup.new(props)
if type(props) ~= "table" then
ErrorHandler.warn("AnimationGroup", "AnimationGroup.new() requires a table argument. Using default values.")
props = {animations = {}}
end
if type(props.animations) ~= "table" or #props.animations == 0 then
ErrorHandler.warn("AnimationGroup", "AnimationGroup requires at least one animation. Creating empty group.")
props.animations = {}
end
local self = setmetatable({}, AnimationGroup)
self.animations = props.animations
self.mode = props.mode or "parallel"
self.stagger = props.stagger or 0.1
self.onComplete = props.onComplete
self.onStart = props.onStart
-- Validate mode
if self.mode ~= "parallel" and self.mode ~= "sequence" and self.mode ~= "stagger" then
ErrorHandler.warn("AnimationGroup", string.format("Invalid mode: %s. Using 'parallel'.", tostring(self.mode)))
self.mode = "parallel"
end
-- Internal state
self._currentIndex = 1
self._staggerElapsed = 0
self._startedAnimations = {}
self._hasStarted = false
self._paused = false
self._state = "ready" -- "ready", "playing", "completed", "cancelled"
return self
end
--- Update all animations in parallel
---@param dt number Delta time
---@param element table? Optional element reference for callbacks
---@return boolean finished True if all animations complete
function AnimationGroup:_updateParallel(dt, element)
local allFinished = true
for i, anim in ipairs(self.animations) do
-- Check if animation has isCompleted method or check state
local isCompleted = false
if type(anim.getState) == "function" then
isCompleted = anim:getState() == "completed"
elseif anim._state then
isCompleted = anim._state == "completed"
end
if not isCompleted then
local finished = anim:update(dt, element)
if not finished then
allFinished = false
end
end
end
return allFinished
end
--- Update animations in sequence (one after another)
---@param dt number Delta time
---@param element table? Optional element reference for callbacks
---@return boolean finished True if all animations complete
function AnimationGroup:_updateSequence(dt, element)
if self._currentIndex > #self.animations then
return true
end
local currentAnim = self.animations[self._currentIndex]
local finished = currentAnim:update(dt, element)
if finished then
self._currentIndex = self._currentIndex + 1
if self._currentIndex > #self.animations then
return true
end
end
return false
end
--- Update animations with stagger delay
---@param dt number Delta time
---@param element table? Optional element reference for callbacks
---@return boolean finished True if all animations complete
function AnimationGroup:_updateStagger(dt, element)
self._staggerElapsed = self._staggerElapsed + dt
-- Start animations based on stagger timing
for i, anim in ipairs(self.animations) do
local startTime = (i - 1) * self.stagger
if self._staggerElapsed >= startTime and not self._startedAnimations[i] then
self._startedAnimations[i] = true
end
end
-- Update started animations
local allFinished = true
for i, anim in ipairs(self.animations) do
if self._startedAnimations[i] then
local isCompleted = false
if type(anim.getState) == "function" then
isCompleted = anim:getState() == "completed"
elseif anim._state then
isCompleted = anim._state == "completed"
end
if not isCompleted then
local finished = anim:update(dt, element)
if not finished then
allFinished = false
end
end
else
allFinished = false
end
end
return allFinished
end
--- Update the animation group
---@param dt number Delta time
---@param element table? Optional element reference for callbacks
---@return boolean finished True if group is complete
function AnimationGroup:update(dt, element)
-- Sanitize dt
if type(dt) ~= "number" or dt < 0 or dt ~= dt or dt == math.huge then
dt = 0
end
if self._paused or self._state == "completed" or self._state == "cancelled" then
return self._state == "completed"
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)
if not success then
print(string.format("[AnimationGroup] onStart error: %s", tostring(err)))
end
end
end
local finished = false
if self.mode == "parallel" then
finished = self:_updateParallel(dt, element)
elseif self.mode == "sequence" then
finished = self:_updateSequence(dt, element)
elseif self.mode == "stagger" then
finished = self:_updateStagger(dt, element)
end
if finished then
self._state = "completed"
if self.onComplete and type(self.onComplete) == "function" then
local success, err = pcall(self.onComplete, self)
if not success then
print(string.format("[AnimationGroup] onComplete error: %s", tostring(err)))
end
end
end
return finished
end
--- Pause all animations in the group
function AnimationGroup:pause()
self._paused = true
for _, anim in ipairs(self.animations) do
if type(anim.pause) == "function" then
anim:pause()
end
end
end
--- Resume all animations in the group
function AnimationGroup:resume()
self._paused = false
for _, anim in ipairs(self.animations) do
if type(anim.resume) == "function" then
anim:resume()
end
end
end
--- Check if group is paused
---@return boolean paused
function AnimationGroup:isPaused()
return self._paused
end
--- Reverse all animations in the group
function AnimationGroup:reverse()
for _, anim in ipairs(self.animations) do
if type(anim.reverse) == "function" then
anim:reverse()
end
end
end
--- Set speed for all animations in the group
---@param speed number Speed multiplier
function AnimationGroup:setSpeed(speed)
for _, anim in ipairs(self.animations) do
if type(anim.setSpeed) == "function" then
anim:setSpeed(speed)
end
end
end
--- Cancel all animations in the group
---@param element table? Optional element reference for callbacks
function AnimationGroup:cancel(element)
if self._state ~= "cancelled" and self._state ~= "completed" then
self._state = "cancelled"
for _, anim in ipairs(self.animations) do
if type(anim.cancel) == "function" then
anim:cancel(element)
end
end
end
end
--- Reset the animation group to initial state
function AnimationGroup:reset()
self._currentIndex = 1
self._staggerElapsed = 0
self._startedAnimations = {}
self._hasStarted = false
self._paused = false
self._state = "ready"
for _, anim in ipairs(self.animations) do
if type(anim.reset) == "function" then
anim:reset()
end
end
end
--- Get the current state of the group
---@return string state "ready", "playing", "completed", "cancelled"
function AnimationGroup:getState()
return self._state
end
--- Get the overall progress of the group (0-1)
---@return number progress
function AnimationGroup:getProgress()
if #self.animations == 0 then
return 1
end
if self.mode == "sequence" then
-- For sequence, progress is based on current animation index + current animation progress
local completedAnims = self._currentIndex - 1
local currentProgress = 0
if self._currentIndex <= #self.animations then
local currentAnim = self.animations[self._currentIndex]
if type(currentAnim.getProgress) == "function" then
currentProgress = currentAnim:getProgress()
end
end
return (completedAnims + currentProgress) / #self.animations
else
-- For parallel and stagger, average progress of all animations
local totalProgress = 0
for _, anim in ipairs(self.animations) do
if type(anim.getProgress) == "function" then
totalProgress = totalProgress + anim:getProgress()
else
totalProgress = totalProgress + 1
end
end
return totalProgress / #self.animations
end
end
--- Apply this animation group to an element
---@param element Element The element to apply animations to
function AnimationGroup:apply(element)
if not element or type(element) ~= "table" then
ErrorHandler.warn("AnimationGroup", "Cannot apply animation group to nil or non-table element. Group not applied.")
return
end
element.animationGroup = self
end
--- Initialize ErrorHandler dependency
---@param errorHandler table The ErrorHandler module
local function initializeErrorHandler(errorHandler)
ErrorHandler = errorHandler
end
-- Export ErrorHandler initializer
AnimationGroup.initializeErrorHandler = initializeErrorHandler
return AnimationGroup

View File

@@ -2127,7 +2127,19 @@ function Element:update(dt)
local finished = self.animation:update(dt, self)
if finished then
-- Animation:update() already called onComplete callback
self.animation = nil -- remove finished animation
-- Check for chained animation
if self.animation._next then
self.animation = self.animation._next
elseif self.animation._nextFactory and type(self.animation._nextFactory) == "function" then
local success, nextAnim = pcall(self.animation._nextFactory, self)
if success and nextAnim then
self.animation = nextAnim
else
self.animation = nil
end
else
self.animation = nil
end
else
-- Apply animation interpolation during update
local anim = self.animation:interpolate()
@@ -2968,4 +2980,121 @@ function Element:setTransformOrigin(originX, originY)
self.transform.originY = originY
end
--- Set transition configuration for a property
---@param property string Property name or "all" for all properties
---@param config table Transition config {duration, easing, delay, onComplete}
function Element:setTransition(property, config)
if not self.transitions then
self.transitions = {}
end
if type(config) ~= "table" then
self._deps.ErrorHandler.warn("Element", "setTransition() requires a config table. Using default config.")
config = {}
end
-- Validate config
if config.duration and (type(config.duration) ~= "number" or config.duration < 0) then
self._deps.ErrorHandler.warn("Element", "transition duration must be a non-negative number. Using 0.3 seconds.")
config.duration = 0.3
end
self.transitions[property] = {
duration = config.duration or 0.3,
easing = config.easing or "easeOutQuad",
delay = config.delay or 0,
onComplete = config.onComplete
}
end
--- Set transition configuration for multiple properties
---@param groupName string Name for this transition group
---@param config table Transition config {duration, easing, delay, onComplete}
---@param properties table Array of property names
function Element:setTransitionGroup(groupName, config, properties)
if type(properties) ~= "table" then
self._deps.ErrorHandler.warn("Element", "setTransitionGroup() requires a properties array. No transitions set.")
return
end
for _, prop in ipairs(properties) do
self:setTransition(prop, config)
end
end
--- Remove transition configuration for a property
---@param property string Property name or "all" to remove all
function Element:removeTransition(property)
if not self.transitions then
return
end
if property == "all" then
self.transitions = {}
else
self.transitions[property] = nil
end
end
--- Set property with automatic transition
---@param property string Property name
---@param value any New value
function Element:setProperty(property, value)
-- Check if transitions are enabled for this property
local shouldTransition = false
local transitionConfig = nil
if self.transitions then
transitionConfig = self.transitions[property] or self.transitions["all"]
shouldTransition = transitionConfig ~= nil
end
-- Don't transition if value is the same
if self[property] == value then
return
end
if shouldTransition and transitionConfig then
-- Get current value
local currentValue = self[property]
-- Only transition if we have a valid current value
if currentValue ~= nil then
-- Create animation for the property change
local Animation = require("modules.Animation")
local anim = Animation.new({
duration = transitionConfig.duration,
start = { [property] = currentValue },
final = { [property] = value },
easing = transitionConfig.easing,
onComplete = transitionConfig.onComplete
})
-- Set Color module reference if needed
if self._deps and self._deps.Color then
anim:setColorModule(self._deps.Color)
end
-- Set Transform module reference if needed
if self._deps and self._deps.Transform then
anim:setTransformModule(self._deps.Transform)
end
-- Apply delay if configured
if transitionConfig.delay and transitionConfig.delay > 0 then
anim:delay(transitionConfig.delay)
end
-- Apply animation
anim:apply(self)
else
-- No current value, set directly
self[property] = value
end
else
-- No transition, set directly
self[property] = value
end
end
return Element

View File

@@ -190,10 +190,45 @@ function Renderer:_drawImage(x, y, paddingLeft, paddingTop, contentWidth, conten
if hasCornerRadius then
-- Use stencil to clip image to rounded corners
love.graphics.stencil(function()
self._RoundedRect.draw("fill", x, y, borderBoxWidth, borderBoxHeight, self.cornerRadius)
end, "replace", 1)
love.graphics.setStencilTest("greater", 0)
local success, err = pcall(function()
love.graphics.stencil(function()
self._RoundedRect.draw("fill", x, y, borderBoxWidth, borderBoxHeight, self.cornerRadius)
end, "replace", 1)
love.graphics.setStencilTest("greater", 0)
end)
if not success then
-- Lazy-load ErrorHandler if needed
if not ErrorHandler then
ErrorHandler = require("modules.ErrorHandler")
end
-- Check if it's a stencil buffer error
if err and err:match("stencil") then
ErrorHandler.warn(
"Renderer",
"IMG_001",
"Cannot apply corner radius to image: stencil buffer not available",
{
imagePath = self.imagePath or "unknown",
cornerRadius = string.format(
"TL:%d TR:%d BL:%d BR:%d",
self.cornerRadius.topLeft,
self.cornerRadius.topRight,
self.cornerRadius.bottomLeft,
self.cornerRadius.bottomRight
),
error = tostring(err),
},
"Ensure the active canvas has stencil=true enabled, or remove cornerRadius from images"
)
-- Continue without corner radius
hasCornerRadius = false
else
-- Re-throw if it's a different error
error(err, 2)
end
end
end
-- Draw the image based on repeat mode