Files
FlexLove/modules/AnimationGroup.lua
2025-11-18 19:51:05 -05:00

340 lines
11 KiB
Lua

--- 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)
--- Coordinate multiple animations to play together, in sequence, or staggered for complex choreographed effects
--- Use this to synchronize related UI changes like simultaneous fades or sequential reveals
---@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
--- Advance all animations in the group according to their coordination mode
--- Call this each frame to progress parallel, sequential, or staggered animations
---@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
--- Freeze the entire animation sequence in unison
--- Use this to pause complex multi-part animations during game pauses
function AnimationGroup:pause()
self._paused = true
for _, anim in ipairs(self.animations) do
if type(anim.pause) == "function" then
anim:pause()
end
end
end
--- Continue all paused animations simultaneously from their paused states
--- Use this to unpause coordinated animation sequences
function AnimationGroup:resume()
self._paused = false
for _, anim in ipairs(self.animations) do
if type(anim.resume) == "function" then
anim:resume()
end
end
end
--- Determine if the entire group is currently paused
--- Use this to sync other game logic with animation group state
---@return boolean paused
function AnimationGroup:isPaused()
return self._paused
end
--- Flip all animations to play backwards together
--- Use this to reverse complex transitions like panel opens/closes
function AnimationGroup:reverse()
for _, anim in ipairs(self.animations) do
if type(anim.reverse) == "function" then
anim:reverse()
end
end
end
--- Control the tempo of all animations simultaneously
--- Use this for slow-motion effects or debugging without adjusting individual animations
---@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
--- Abort all animations in the group immediately without completion
--- Use this when UI is dismissed mid-animation or transitions are interrupted
---@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
--- Restart the entire group from the beginning for reuse
--- Use this to replay animation sequences without recreating objects
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
--- Check the overall lifecycle state of the animation group
--- Use this to conditionally trigger follow-up actions or cleanup
---@return string state "ready", "playing", "completed", "cancelled"
function AnimationGroup:getState()
return self._state
end
--- Calculate completion percentage across all animations in the group
--- Use this for progress bars or to synchronize other effects with the group
---@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
--- Attach this group to an element for automatic updates and integration
--- Use this for hands-off animation management within FlexLove's system
---@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