---@alias EasingFunction fun(t: number): number local Easing = {} ---@type EasingFunction function Easing.linear(t) return t end ---@type EasingFunction function Easing.easeInQuad(t) return t * t end ---@type EasingFunction function Easing.easeOutQuad(t) return t * (2 - t) end ---@type EasingFunction function Easing.easeInOutQuad(t) return t < 0.5 and 2 * t * t or -1 + (4 - 2 * t) * t end ---@type EasingFunction function Easing.easeInCubic(t) return t * t * t end ---@type EasingFunction function Easing.easeOutCubic(t) local t1 = t - 1 return t1 * t1 * t1 + 1 end ---@type EasingFunction function Easing.easeInOutCubic(t) return t < 0.5 and 4 * t * t * t or (t - 1) * (2 * t - 2) * (2 * t - 2) + 1 end ---@type EasingFunction function Easing.easeInQuart(t) return t * t * t * t end ---@type EasingFunction function Easing.easeOutQuart(t) local t1 = t - 1 return 1 - t1 * t1 * t1 * t1 end ---@type EasingFunction function Easing.easeInOutQuart(t) if t < 0.5 then return 8 * t * t * t * t else local t1 = t - 1 return 1 - 8 * t1 * t1 * t1 * t1 end end ---@type EasingFunction function Easing.easeInQuint(t) return t * t * t * t * t end ---@type EasingFunction function Easing.easeOutQuint(t) local t1 = t - 1 return 1 + t1 * t1 * t1 * t1 * t1 end ---@type EasingFunction function Easing.easeInOutQuint(t) if t < 0.5 then return 16 * t * t * t * t * t else local t1 = t - 1 return 1 + 16 * t1 * t1 * t1 * t1 * t1 end end ---@type EasingFunction function Easing.easeInExpo(t) return t == 0 and 0 or math.pow(2, 10 * (t - 1)) end ---@type EasingFunction function Easing.easeOutExpo(t) return t == 1 and 1 or 1 - math.pow(2, -10 * t) end ---@type EasingFunction function Easing.easeInOutExpo(t) if t == 0 then return 0 end if t == 1 then return 1 end if t < 0.5 then return 0.5 * math.pow(2, 20 * t - 10) else return 1 - 0.5 * math.pow(2, -20 * t + 10) end end ---@type EasingFunction function Easing.easeInSine(t) return 1 - math.cos(t * math.pi / 2) end ---@type EasingFunction function Easing.easeOutSine(t) return math.sin(t * math.pi / 2) end ---@type EasingFunction function Easing.easeInOutSine(t) return -(math.cos(math.pi * t) - 1) / 2 end ---@type EasingFunction function Easing.easeInCirc(t) return 1 - math.sqrt(1 - t * t) end ---@type EasingFunction function Easing.easeOutCirc(t) local t1 = t - 1 return math.sqrt(1 - t1 * t1) end ---@type EasingFunction function Easing.easeInOutCirc(t) if t < 0.5 then return (1 - math.sqrt(1 - 4 * t * t)) / 2 else local t1 = -2 * t + 2 return (math.sqrt(1 - t1 * t1) + 1) / 2 end end ---@type EasingFunction function Easing.easeInBack(t) local c1 = 1.70158 local c3 = c1 + 1 return c3 * t * t * t - c1 * t * t end ---@type EasingFunction function Easing.easeOutBack(t) local c1 = 1.70158 local c3 = c1 + 1 local t1 = t - 1 return 1 + c3 * t1 * t1 * t1 + c1 * t1 * t1 end ---@type EasingFunction function Easing.easeInOutBack(t) local c1 = 1.70158 local c2 = c1 * 1.525 if t < 0.5 then return (2 * t * 2 * t * ((c2 + 1) * 2 * t - c2)) / 2 else local t1 = 2 * t - 2 return (t1 * t1 * ((c2 + 1) * t1 + c2) + 2) / 2 end end ---@type EasingFunction function Easing.easeInElastic(t) if t == 0 then return 0 end if t == 1 then return 1 end local c4 = (2 * math.pi) / 3 return -math.pow(2, 10 * t - 10) * math.sin((t * 10 - 10.75) * c4) end ---@type EasingFunction function Easing.easeOutElastic(t) if t == 0 then return 0 end if t == 1 then return 1 end local c4 = (2 * math.pi) / 3 return math.pow(2, -10 * t) * math.sin((t * 10 - 0.75) * c4) + 1 end ---@type EasingFunction function Easing.easeInOutElastic(t) if t == 0 then return 0 end if t == 1 then return 1 end local c5 = (2 * math.pi) / 4.5 if t < 0.5 then return -(math.pow(2, 20 * t - 10) * math.sin((20 * t - 11.125) * c5)) / 2 else return (math.pow(2, -20 * t + 10) * math.sin((20 * t - 11.125) * c5)) / 2 + 1 end end ---@type EasingFunction function Easing.easeOutBounce(t) local n1 = 7.5625 local d1 = 2.75 if t < 1 / d1 then return n1 * t * t elseif t < 2 / d1 then local t1 = t - 1.5 / d1 return n1 * t1 * t1 + 0.75 elseif t < 2.5 / d1 then local t1 = t - 2.25 / d1 return n1 * t1 * t1 + 0.9375 else local t1 = t - 2.625 / d1 return n1 * t1 * t1 + 0.984375 end end ---@type EasingFunction function Easing.easeInBounce(t) return 1 - Easing.easeOutBounce(1 - t) end ---@type EasingFunction function Easing.easeInOutBounce(t) if t < 0.5 then return (1 - Easing.easeOutBounce(1 - 2 * t)) / 2 else return (1 + Easing.easeOutBounce(2 * t - 1)) / 2 end end --- Create a custom back easing function with configurable overshoot ---@param overshoot number? Overshoot amount (default: 1.70158) ---@return EasingFunction function Easing.back(overshoot) overshoot = overshoot or 1.70158 local c3 = overshoot + 1 return function(t) return c3 * t * t * t - overshoot * t * t end end --- Create a custom elastic easing function ---@param amplitude number? Amplitude (default: 1) ---@param period number? Period (default: 0.3) ---@return EasingFunction function Easing.elastic(amplitude, period) amplitude = amplitude or 1 period = period or 0.3 return function(t) if t == 0 then return 0 end if t == 1 then return 1 end local s = period / 4 local a = amplitude if a < 1 then a = 1 s = period / 4 else s = period / (2 * math.pi) * math.asin(1 / a) end return a * math.pow(2, -10 * t) * math.sin((t - s) * (2 * math.pi) / period) + 1 end end -- ============================================================================ -- TRANSFORM -- ============================================================================ ---@class Transform ---@field rotate number? Rotation in radians (default: 0) ---@field scaleX number? X-axis scale (default: 1) ---@field scaleY number? Y-axis scale (default: 1) ---@field translateX number? X translation in pixels (default: 0) ---@field translateY number? Y translation in pixels (default: 0) ---@field skewX number? X-axis skew in radians (default: 0) ---@field skewY number? Y-axis skew in radians (default: 0) ---@field originX number? Transform origin X (0-1, default: 0.5) ---@field originY number? Transform origin Y (0-1, default: 0.5) local Transform = {} Transform.__index = Transform --- Create a new transform instance ---@param props Transform? ---@return Transform transform function Transform.new(props) props = props or {} local self = setmetatable({}, Transform) self.rotate = props.rotate or 0 self.scaleX = props.scaleX or 1 self.scaleY = props.scaleY or 1 self.translateX = props.translateX or 0 self.translateY = props.translateY or 0 self.skewX = props.skewX or 0 self.skewY = props.skewY or 0 self.originX = props.originX or 0.5 self.originY = props.originY or 0.5 return self end --- Apply transform to LÖVE graphics context ---@param transform Transform Transform instance ---@param x number Element x position ---@param y number Element y position ---@param width number Element width ---@param height number Element height function Transform.apply(transform, x, y, width, height) if not transform then return end local ox = x + width * transform.originX local oy = y + height * transform.originY love.graphics.push() love.graphics.translate(ox, oy) if transform.rotate ~= 0 then love.graphics.rotate(transform.rotate) end if transform.scaleX ~= 1 or transform.scaleY ~= 1 then love.graphics.scale(transform.scaleX, transform.scaleY) end if transform.skewX ~= 0 or transform.skewY ~= 0 then love.graphics.shear(transform.skewX, transform.skewY) end love.graphics.translate(-ox, -oy) love.graphics.translate(transform.translateX, transform.translateY) end --- Remove transform from LÖVE graphics context function Transform.unapply() love.graphics.pop() end --- Interpolate between two transforms ---@param from Transform Starting transform ---@param to Transform Ending transform ---@param t number Interpolation factor (0-1) ---@return Transform interpolated function Transform.lerp(from, to, t) if type(from) ~= "table" then from = Transform.new() end if type(to) ~= "table" then to = Transform.new() end if type(t) ~= "number" or t ~= t then t = 0 elseif t == math.huge then t = 1 elseif t == -math.huge then t = 0 else t = math.max(0, math.min(1, t)) end return Transform.new({ rotate = (from.rotate or 0) * (1 - t) + (to.rotate or 0) * t, scaleX = (from.scaleX or 1) * (1 - t) + (to.scaleX or 1) * t, scaleY = (from.scaleY or 1) * (1 - t) + (to.scaleY or 1) * t, translateX = (from.translateX or 0) * (1 - t) + (to.translateX or 0) * t, translateY = (from.translateY or 0) * (1 - t) + (to.translateY or 0) * t, skewX = (from.skewX or 0) * (1 - t) + (to.skewX or 0) * t, skewY = (from.skewY or 0) * (1 - t) + (to.skewY or 0) * t, originX = (from.originX or 0.5) * (1 - t) + (to.originX or 0.5) * t, originY = (from.originY or 0.5) * (1 - t) + (to.originY or 0.5) * t, }) end --- Check if transform is identity (no transformation) ---@param transform Transform ---@return boolean isIdentity function Transform.isIdentity(transform) if not transform then return true end return transform.rotate == 0 and transform.scaleX == 1 and transform.scaleY == 1 and transform.translateX == 0 and transform.translateY == 0 and transform.skewX == 0 and transform.skewY == 0 end --- Clone a transform ---@param transform Transform ---@return Transform clone function Transform.clone(transform) if not transform then return Transform.new() end return Transform.new({ rotate = transform.rotate, scaleX = transform.scaleX, scaleY = transform.scaleY, translateX = transform.translateX, translateY = transform.translateY, skewX = transform.skewX, skewY = transform.skewY, originX = transform.originX, originY = transform.originY, }) end -- ============================================================================ -- INTERPOLATION HELPERS -- ============================================================================ --- 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) if not ColorModule or not ColorModule.parse or not ColorModule.lerp then return startColor end local colorA = ColorModule.parse(startColor) local colorB = ColorModule.parse(finalColor) return ColorModule.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 = {} 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 ---@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 ---@field final table Final values ---@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 ---@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 ---@field _Transform table? Reference to Transform module ---@field _ErrorHandler table? Reference to ErrorHandler module local Animation = { _Transform = Transform, } Animation.__index = Animation --- Build smooth, timed transitions between visual states ---@param props AnimationProps Animation properties ---@return Animation animation The new animation instance function Animation.new(props) if type(props) ~= "table" then if Animation._ErrorHandler then Animation._ErrorHandler:warn("Animation", "ANIM_001") end props = { duration = 1, start = {}, final = {} } end if type(props.duration) ~= "number" or props.duration <= 0 then if Animation._ErrorHandler then Animation._ErrorHandler:warn("Animation", "ANIM_002") end props.duration = 1 end if type(props.start) ~= "table" then if Animation._ErrorHandler then Animation._ErrorHandler:warn("Animation", "ANIM_001") end props.start = {} end if type(props.final) ~= "table" then if Animation._ErrorHandler then Animation._ErrorHandler:warn("Animation", "ANIM_001") end 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 self.onStart = props.onStart self.onUpdate = props.onUpdate self.onComplete = props.onComplete self.onCancel = props.onCancel self._hasStarted = false self._paused = false self._reversed = false self._speed = 1.0 self._state = "pending" 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 self._cachedResult = {} self._resultDirty = true return self end --- Advance the animation timeline ---@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) if type(dt) ~= "number" or dt < 0 or dt ~= dt or dt == math.huge then dt = 0 end if self._paused then return false end if self._delay and self._delayElapsed then if self._delayElapsed < self._delay then self._delayElapsed = self._delayElapsed + dt return false end end 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 print(string.format("[Animation] onStart error: %s", tostring(err))) end end end dt = dt * self._speed if self._reversed then self.elapsed = self.elapsed - dt if self.elapsed <= 0 then self.elapsed = 0 self._state = "completed" self._resultDirty = true 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 if self._repeatCount then self._repeatCurrent = (self._repeatCurrent or 0) + 1 if self._repeatCount == 0 or self._repeatCurrent < self._repeatCount then if self._yoyo then self._reversed = not self._reversed if self._reversed then self.elapsed = self.duration else self.elapsed = 0 end else self.elapsed = 0 end return false end end self._state = "completed" 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 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 --- 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 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 = {} local keys = {} for k in pairs(prevFrame.values) do keys[k] = true end for k in pairs(nextFrame.values) do keys[k] = true end local numericSet = { width = true, height = true, opacity = true, x = true, y = true, gap = true, imageOpacity = true, scrollbarWidth = true, borderWidth = true, fontSize = true, lineHeight = true, } local colorSet = { backgroundColor = true, borderColor = true, textColor = true, scrollbarColor = true, scrollbarBackgroundColor = true, imageTint = true, } local tableSet = { padding = true, margin = true, cornerRadius = true, } 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 Animation._ColorModule then if startVal ~= nil and finalVal ~= nil then result[key] = lerpColor(startVal, finalVal, easedT, 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 if type(startVal) == "number" then result[key] = lerpNumber(startVal, finalVal, easedT) else result[key] = finalVal end end end return result end --- Calculate the current animated values ---@return table result Interpolated values function Animation:interpolate() if not self._resultDirty then return self._cachedResult end local t = math.min(self.elapsed / self.duration, 1) if self.keyframes and type(self.keyframes) == "table" and #self.keyframes >= 2 then local prevFrame, nextFrame = self:findKeyframes(t) if prevFrame and nextFrame then local localProgress = 0 if nextFrame.at > prevFrame.at then localProgress = (t - prevFrame.at) / (nextFrame.at - prevFrame.at) end 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 local keyframeResult = self:lerpKeyframes(prevFrame, nextFrame, easedT) 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 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 end local result = self._cachedResult for k in pairs(result) do result[k] = nil end local numericProperties = { "width", "height", "opacity", "x", "y", "gap", "imageOpacity", "scrollbarWidth", "borderWidth", "fontSize", "lineHeight", } local colorProperties = { "backgroundColor", "borderColor", "textColor", "scrollbarColor", "scrollbarBackgroundColor", "imageTint", } local tableProperties = { "padding", "margin", "cornerRadius", } 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 if Animation._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, Animation._ColorModule) end end end 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 if Animation._Transform and self.start.transform and self.final.transform then result.transform = Animation._Transform.lerp(self.start.transform, self.final.transform, easedT) end 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 animation to an element ---@param element table The element to apply animation to function Animation:apply(element) if not element or type(element) ~= "table" then if Animation._ErrorHandler then Animation._ErrorHandler:warn("Animation", "ANIM_003") end return end element.animation = self end --- Pause animation function Animation:pause() if self._state == "playing" or self._state == "pending" then self._paused = true self._state = "paused" end end --- Resume animation function Animation:resume() if self._state == "paused" then self._paused = false self._state = "playing" end end --- Check if paused ---@return boolean paused function Animation:isPaused() return self._paused end --- Reverse animation direction function Animation:reverse() self._reversed = not self._reversed end --- Check if reversed ---@return boolean reversed function Animation:isReversed() return self._reversed end --- Set playback speed ---@param speed number Speed multiplier function Animation:setSpeed(speed) if type(speed) == "number" and speed > 0 then self._speed = speed end end --- Get playback speed ---@return number speed function Animation:getSpeed() return self._speed end --- Seek to specific time ---@param time number Time in seconds function Animation:seek(time) if type(time) == "number" then self.elapsed = math.max(0, math.min(time, self.duration)) self._resultDirty = true end end --- Get animation state ---@return string state function Animation:getState() return self._state end --- Cancel animation ---@param element table? Optional element reference 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 --- Reset animation function Animation:reset() self.elapsed = 0 self._hasStarted = false self._paused = false self._state = "pending" self._resultDirty = true end --- Get animation progress ---@return number progress function Animation:getProgress() return math.min(self.elapsed / self.duration, 1) end --- Chain animations ---@param nextAnimation Animation|function ---@return Animation nextAnimation function Animation:chain(nextAnimation) if type(nextAnimation) == "function" then self._nextFactory = nextAnimation return self elseif type(nextAnimation) == "table" then self._next = nextAnimation return nextAnimation else if Animation._ErrorHandler then Animation._ErrorHandler:warn("Animation", "ANIM_004") end return self end end --- Add delay before animation starts ---@param seconds number Delay duration ---@return Animation self function Animation:delay(seconds) if type(seconds) ~= "number" or seconds < 0 then if Animation._ErrorHandler then Animation._ErrorHandler:warn("Animation", "ANIM_005") end seconds = 0 end self._delay = seconds self._delayElapsed = 0 return self end --- Set repeat count ---@param count number Repeat count (0 = infinite) ---@return Animation self function Animation:repeatCount(count) if type(count) ~= "number" or count < 0 then if Animation._ErrorHandler then Animation._ErrorHandler:warn("Animation", "ANIM_006") end count = 0 end self._repeatCount = count self._repeatCurrent = 0 return self end --- Enable yoyo mode ---@param enabled boolean? Enable yoyo (default: true) ---@return Animation self function Animation:yoyo(enabled) if enabled == nil then enabled = true end self._yoyo = enabled return self end --- Create fade animation ---@param duration number Duration in seconds ---@param fromOpacity number Starting opacity ---@param toOpacity number Ending opacity ---@param easing string? Easing function name ---@return Animation animation function Animation.fade(duration, fromOpacity, toOpacity, easing) 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, }) end --- Create scale animation ---@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 ---@return Animation animation function Animation.scale(duration, fromScale, toScale, easing) 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, }) end --- Create keyframe animation ---@param props {duration:number, keyframes:Keyframe[], onStart:function?, onUpdate:function?, onComplete:function?, onCancel:function?} ---@return Animation animation function Animation.keyframes(props) if type(props) ~= "table" then if Animation._ErrorHandler then Animation._ErrorHandler:warn("Animation", "ANIM_007") end props = { duration = 1, keyframes = {} } end if type(props.duration) ~= "number" or props.duration <= 0 then if Animation._ErrorHandler then Animation._ErrorHandler:warn("Animation", "ANIM_002") end props.duration = 1 end if type(props.keyframes) ~= "table" or #props.keyframes < 2 then if Animation._ErrorHandler then Animation._ErrorHandler:warn("Animation", "ANIM_008") end props.keyframes = { { at = 0, values = {} }, { at = 1, values = {} }, } end 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) 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 return Animation.new({ duration = props.duration, start = {}, final = {}, keyframes = sortedKeyframes, onStart = props.onStart, onUpdate = props.onUpdate, onComplete = props.onComplete, onCancel = props.onCancel, }) end -- ============================================================================ -- ANIMATION GROUP (Utility) -- ============================================================================ ---@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 (default: 0.1) ---@field onComplete function? Called when all animations complete ---@field onStart function? Called when group starts ---@class AnimationGroup ---@field animations table ---@field mode string ---@field stagger number ---@field onComplete function? ---@field onStart function? ---@field _currentIndex number ---@field _staggerElapsed number ---@field _startedAnimations table ---@field _hasStarted boolean ---@field _paused boolean ---@field _state string local AnimationGroup = {} AnimationGroup.__index = AnimationGroup --- Coordinate multiple animations ---@param props AnimationGroupProps ---@return AnimationGroup group function AnimationGroup.new(props) if type(props) ~= "table" then if Animation._ErrorHandler then Animation._ErrorHandler:warn("AnimationGroup", "ANIM_009") end props = { animations = {} } end if type(props.animations) ~= "table" or #props.animations == 0 then if Animation._ErrorHandler then Animation._ErrorHandler:warn("AnimationGroup", "ANIM_010") end 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 if self.mode ~= "parallel" and self.mode ~= "sequence" and self.mode ~= "stagger" then if Animation._ErrorHandler then Animation._ErrorHandler:warn("AnimationGroup", "ANIM_011", { mode = tostring(self.mode), }) end self.mode = "parallel" end self._currentIndex = 1 self._staggerElapsed = 0 self._startedAnimations = {} self._hasStarted = false self._paused = false self._state = "ready" return self end --- Update all animations in parallel ---@param dt number Delta time ---@param element table? Optional element reference ---@return boolean finished function AnimationGroup:_updateParallel(dt, element) local allFinished = true for i, anim in ipairs(self.animations) do 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 ---@param dt number Delta time ---@param element table? Optional element reference ---@return boolean finished 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 ---@param dt number Delta time ---@param element table? Optional element reference ---@return boolean finished function AnimationGroup:_updateStagger(dt, element) self._staggerElapsed = self._staggerElapsed + dt 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 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 ---@param dt number Delta time ---@param element table? Optional element reference ---@return boolean finished function AnimationGroup:update(dt, element) 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 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 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 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 paused ---@return boolean paused function AnimationGroup:isPaused() return self._paused end --- Reverse all animations 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 ---@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 ---@param element table? Optional element reference 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 all animations 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 group state ---@return string state function AnimationGroup:getState() return self._state end --- Get group progress ---@return number progress function AnimationGroup:getProgress() if #self.animations == 0 then return 1 end if self.mode == "sequence" then 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 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 group to element ---@param element table The element to apply animations to function AnimationGroup:apply(element) if not element or type(element) ~= "table" then if Animation._ErrorHandler then Animation._ErrorHandler:warn("AnimationGroup", "ANIM_003") end return end element.animationGroup = self end -- ============================================================================ -- MODULE INITIALIZATION -- ============================================================================ --- Initialize Animation module with dependencies ---@param deps table Dependencies: { ErrorHandler = ErrorHandler, Color = Color? } function Animation.init(deps) if type(deps) == "table" then Animation._ErrorHandler = deps.ErrorHandler Animation._ColorModule = deps.Color end end Animation.Easing = Easing Animation.Transform = Transform Animation.Group = AnimationGroup return Animation