easings
This commit is contained in:
@@ -33,6 +33,8 @@ local Element = req("Element")
|
|||||||
local Animation = req("Animation")
|
local Animation = req("Animation")
|
||||||
---@type AnimationGroup
|
---@type AnimationGroup
|
||||||
local AnimationGroup = req("AnimationGroup")
|
local AnimationGroup = req("AnimationGroup")
|
||||||
|
---@type Easing
|
||||||
|
local Easing = req("Easing")
|
||||||
---@type Color
|
---@type Color
|
||||||
local Color = req("Color")
|
local Color = req("Color")
|
||||||
---@type Theme
|
---@type Theme
|
||||||
@@ -1120,6 +1122,7 @@ end
|
|||||||
|
|
||||||
flexlove.Animation = Animation
|
flexlove.Animation = Animation
|
||||||
flexlove.AnimationGroup = AnimationGroup
|
flexlove.AnimationGroup = AnimationGroup
|
||||||
|
flexlove.Easing = Easing
|
||||||
flexlove.Color = Color
|
flexlove.Color = Color
|
||||||
flexlove.Theme = Theme
|
flexlove.Theme = Theme
|
||||||
flexlove.enums = enums
|
flexlove.enums = enums
|
||||||
|
|||||||
@@ -4,54 +4,19 @@
|
|||||||
-- ErrorHandler dependency (injected via initializeErrorHandler)
|
-- ErrorHandler dependency (injected via initializeErrorHandler)
|
||||||
local ErrorHandler = nil
|
local ErrorHandler = nil
|
||||||
|
|
||||||
--- Easing functions for animations
|
-- Easing module for easing functions
|
||||||
---@type table<string, EasingFunction>
|
local Easing = require("modules.Easing")
|
||||||
local Easing = {
|
---@class Keyframe
|
||||||
linear = function(t)
|
---@field at number Normalized time position (0-1)
|
||||||
return t
|
---@field values table Property values at this keyframe
|
||||||
end,
|
---@field easing string|EasingFunction? Easing to use between this and next keyframe
|
||||||
|
|
||||||
easeInQuad = function(t)
|
|
||||||
return t * t
|
|
||||||
end,
|
|
||||||
easeOutQuad = function(t)
|
|
||||||
return t * (2 - t)
|
|
||||||
end,
|
|
||||||
easeInOutQuad = function(t)
|
|
||||||
return t < 0.5 and 2 * t * t or -1 + (4 - 2 * t) * t
|
|
||||||
end,
|
|
||||||
|
|
||||||
easeInCubic = function(t)
|
|
||||||
return t * t * t
|
|
||||||
end,
|
|
||||||
easeOutCubic = function(t)
|
|
||||||
local t1 = t - 1
|
|
||||||
return t1 * t1 * t1 + 1
|
|
||||||
end,
|
|
||||||
easeInOutCubic = function(t)
|
|
||||||
return t < 0.5 and 4 * t * t * t or (t - 1) * (2 * t - 2) * (2 * t - 2) + 1
|
|
||||||
end,
|
|
||||||
|
|
||||||
easeInQuart = function(t)
|
|
||||||
return t * t * t * t
|
|
||||||
end,
|
|
||||||
easeOutQuart = function(t)
|
|
||||||
local t1 = t - 1
|
|
||||||
return 1 - t1 * t1 * t1 * t1
|
|
||||||
end,
|
|
||||||
|
|
||||||
easeInExpo = function(t)
|
|
||||||
return t == 0 and 0 or math.pow(2, 10 * (t - 1))
|
|
||||||
end,
|
|
||||||
easeOutExpo = function(t)
|
|
||||||
return t == 1 and 1 or 1 - math.pow(2, -10 * t)
|
|
||||||
end,
|
|
||||||
}
|
|
||||||
---@class AnimationProps
|
---@class AnimationProps
|
||||||
---@field duration number Duration in seconds
|
---@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 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 final table Final values (same properties as start)
|
||||||
---@field easing string? Easing function name (default: "linear")
|
---@field easing string? Easing function name (default: "linear")
|
||||||
|
---@field keyframes Keyframe[]? Array of keyframes for complex animations
|
||||||
---@field transform table? Additional transform properties
|
---@field transform table? Additional transform properties
|
||||||
---@field transition table? Transition properties
|
---@field transition table? Transition properties
|
||||||
---@field onStart function? Called when animation starts: (animation, element)
|
---@field onStart function? Called when animation starts: (animation, element)
|
||||||
@@ -65,6 +30,7 @@ local Easing = {
|
|||||||
---@field final table Final values
|
---@field final table Final values
|
||||||
---@field elapsed number Elapsed time in seconds
|
---@field elapsed number Elapsed time in seconds
|
||||||
---@field easing EasingFunction Easing function
|
---@field easing EasingFunction Easing function
|
||||||
|
---@field keyframes Keyframe[]? Array of keyframes for complex animations
|
||||||
---@field transform table? Additional transform properties
|
---@field transform table? Additional transform properties
|
||||||
---@field transition table? Transition properties
|
---@field transition table? Transition properties
|
||||||
---@field _cachedResult table Cached interpolation result
|
---@field _cachedResult table Cached interpolation result
|
||||||
@@ -103,6 +69,7 @@ function Animation.new(props)
|
|||||||
self.duration = props.duration
|
self.duration = props.duration
|
||||||
self.start = props.start
|
self.start = props.start
|
||||||
self.final = props.final
|
self.final = props.final
|
||||||
|
self.keyframes = props.keyframes
|
||||||
self.transform = props.transform
|
self.transform = props.transform
|
||||||
self.transition = props.transition
|
self.transition = props.transition
|
||||||
self.elapsed = 0
|
self.elapsed = 0
|
||||||
@@ -304,6 +271,98 @@ local function lerpTable(startTable, finalTable, easedT)
|
|||||||
return result
|
return result
|
||||||
end
|
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 then
|
||||||
|
if startVal ~= nil and finalVal ~= nil then
|
||||||
|
result[key] = lerpColor(startVal, finalVal, easedT, self._Color)
|
||||||
|
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
|
--- 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
|
--- Use this to get the interpolated properties to apply to your element
|
||||||
---@return table result Interpolated values {width?, height?, opacity?, x?, y?, backgroundColor?, ...}
|
---@return table result Interpolated values {width?, height?, opacity?, x?, y?, backgroundColor?, ...}
|
||||||
@@ -315,6 +374,50 @@ function Animation:interpolate()
|
|||||||
|
|
||||||
local t = math.min(self.elapsed / self.duration, 1)
|
local t = math.min(self.elapsed / self.duration, 1)
|
||||||
|
|
||||||
|
-- Handle keyframe animations
|
||||||
|
if self.keyframes 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
|
-- Apply easing function with protection
|
||||||
local success, easedT = pcall(self.easing, t)
|
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
|
if not success or type(easedT) ~= "number" or easedT ~= easedT or easedT == math.huge or easedT == -math.huge then
|
||||||
@@ -651,6 +754,67 @@ function Animation.scale(duration, fromScale, toScale, easing)
|
|||||||
})
|
})
|
||||||
end
|
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 ErrorHandler dependency
|
--- Initialize ErrorHandler dependency
|
||||||
---@param errorHandler table The ErrorHandler module
|
---@param errorHandler table The ErrorHandler module
|
||||||
local function initializeErrorHandler(errorHandler)
|
local function initializeErrorHandler(errorHandler)
|
||||||
|
|||||||
361
modules/Easing.lua
Normal file
361
modules/Easing.lua
Normal file
@@ -0,0 +1,361 @@
|
|||||||
|
--- Easing functions for animations
|
||||||
|
--- Provides 30+ easing functions for smooth animation transitions
|
||||||
|
---
|
||||||
|
--- Easing function type
|
||||||
|
---@alias EasingFunction fun(t: number): number
|
||||||
|
---
|
||||||
|
---@class Easing
|
||||||
|
local Easing = {}
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- Linear
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
---@type EasingFunction
|
||||||
|
function Easing.linear(t)
|
||||||
|
return t
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- Quadratic (Quad)
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
---@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
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- Cubic
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
---@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
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- Quartic (Quart)
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
---@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
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- Quintic (Quint)
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
---@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
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- Exponential (Expo)
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
---@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
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- Sine
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
---@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
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- Circular (Circ)
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
---@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
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- Back (Overshoot)
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
---@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
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- Elastic (Spring)
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
---@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
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- Bounce
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
---@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
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- Configurable Easing Factories
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
--- 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
|
||||||
|
|
||||||
|
--- Get list of all available easing function names
|
||||||
|
---@return string[] names Array of easing function names
|
||||||
|
function Easing.list()
|
||||||
|
return {
|
||||||
|
-- Linear
|
||||||
|
"linear",
|
||||||
|
-- Quad
|
||||||
|
"easeInQuad", "easeOutQuad", "easeInOutQuad",
|
||||||
|
-- Cubic
|
||||||
|
"easeInCubic", "easeOutCubic", "easeInOutCubic",
|
||||||
|
-- Quart
|
||||||
|
"easeInQuart", "easeOutQuart", "easeInOutQuart",
|
||||||
|
-- Quint
|
||||||
|
"easeInQuint", "easeOutQuint", "easeInOutQuint",
|
||||||
|
-- Expo
|
||||||
|
"easeInExpo", "easeOutExpo", "easeInOutExpo",
|
||||||
|
-- Sine
|
||||||
|
"easeInSine", "easeOutSine", "easeInOutSine",
|
||||||
|
-- Circ
|
||||||
|
"easeInCirc", "easeOutCirc", "easeInOutCirc",
|
||||||
|
-- Back
|
||||||
|
"easeInBack", "easeOutBack", "easeInOutBack",
|
||||||
|
-- Elastic
|
||||||
|
"easeInElastic", "easeOutElastic", "easeInOutElastic",
|
||||||
|
-- Bounce
|
||||||
|
"easeInBounce", "easeOutBounce", "easeInOutBounce",
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Get an easing function by name
|
||||||
|
---@param name string Easing function name
|
||||||
|
---@return EasingFunction? easing The easing function, or nil if not found
|
||||||
|
function Easing.get(name)
|
||||||
|
return Easing[name]
|
||||||
|
end
|
||||||
|
|
||||||
|
return Easing
|
||||||
310
testing/__tests__/easing_test.lua
Normal file
310
testing/__tests__/easing_test.lua
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
local luaunit = require("testing.luaunit")
|
||||||
|
require("testing.loveStub")
|
||||||
|
|
||||||
|
local Easing = require("modules.Easing")
|
||||||
|
local ErrorHandler = require("modules.ErrorHandler")
|
||||||
|
local ErrorCodes = require("modules.ErrorCodes")
|
||||||
|
|
||||||
|
-- Initialize ErrorHandler
|
||||||
|
ErrorHandler.init({ ErrorCodes = ErrorCodes })
|
||||||
|
|
||||||
|
TestEasing = {}
|
||||||
|
|
||||||
|
function TestEasing:setUp()
|
||||||
|
-- Reset state before each test
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Test that all easing functions exist
|
||||||
|
function TestEasing:testAllEasingFunctionsExist()
|
||||||
|
local easings = {
|
||||||
|
-- Linear
|
||||||
|
"linear",
|
||||||
|
-- Quad
|
||||||
|
"easeInQuad", "easeOutQuad", "easeInOutQuad",
|
||||||
|
-- Cubic
|
||||||
|
"easeInCubic", "easeOutCubic", "easeInOutCubic",
|
||||||
|
-- Quart
|
||||||
|
"easeInQuart", "easeOutQuart", "easeInOutQuart",
|
||||||
|
-- Quint
|
||||||
|
"easeInQuint", "easeOutQuint", "easeInOutQuint",
|
||||||
|
-- Expo
|
||||||
|
"easeInExpo", "easeOutExpo", "easeInOutExpo",
|
||||||
|
-- Sine
|
||||||
|
"easeInSine", "easeOutSine", "easeInOutSine",
|
||||||
|
-- Circ
|
||||||
|
"easeInCirc", "easeOutCirc", "easeInOutCirc",
|
||||||
|
-- Back
|
||||||
|
"easeInBack", "easeOutBack", "easeInOutBack",
|
||||||
|
-- Elastic
|
||||||
|
"easeInElastic", "easeOutElastic", "easeInOutElastic",
|
||||||
|
-- Bounce
|
||||||
|
"easeInBounce", "easeOutBounce", "easeInOutBounce",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name in ipairs(easings) do
|
||||||
|
luaunit.assertNotNil(Easing[name], "Easing function " .. name .. " should exist")
|
||||||
|
luaunit.assertEquals(type(Easing[name]), "function", name .. " should be a function")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Test that all easing functions accept t parameter (0-1)
|
||||||
|
function TestEasing:testEasingFunctionsAcceptParameter()
|
||||||
|
local result = Easing.linear(0.5)
|
||||||
|
luaunit.assertNotNil(result)
|
||||||
|
luaunit.assertEquals(type(result), "number")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Test linear easing
|
||||||
|
function TestEasing:testLinear()
|
||||||
|
luaunit.assertEquals(Easing.linear(0), 0)
|
||||||
|
luaunit.assertEquals(Easing.linear(0.5), 0.5)
|
||||||
|
luaunit.assertEquals(Easing.linear(1), 1)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Test easeInQuad
|
||||||
|
function TestEasing:testEaseInQuad()
|
||||||
|
luaunit.assertEquals(Easing.easeInQuad(0), 0)
|
||||||
|
luaunit.assertAlmostEquals(Easing.easeInQuad(0.5), 0.25, 0.01)
|
||||||
|
luaunit.assertEquals(Easing.easeInQuad(1), 1)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Test easeOutQuad
|
||||||
|
function TestEasing:testEaseOutQuad()
|
||||||
|
luaunit.assertEquals(Easing.easeOutQuad(0), 0)
|
||||||
|
luaunit.assertAlmostEquals(Easing.easeOutQuad(0.5), 0.75, 0.01)
|
||||||
|
luaunit.assertEquals(Easing.easeOutQuad(1), 1)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Test easeInOutQuad
|
||||||
|
function TestEasing:testEaseInOutQuad()
|
||||||
|
luaunit.assertEquals(Easing.easeInOutQuad(0), 0)
|
||||||
|
luaunit.assertAlmostEquals(Easing.easeInOutQuad(0.5), 0.5, 0.01)
|
||||||
|
luaunit.assertEquals(Easing.easeInOutQuad(1), 1)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Test easeInSine
|
||||||
|
function TestEasing:testEaseInSine()
|
||||||
|
luaunit.assertEquals(Easing.easeInSine(0), 0)
|
||||||
|
local mid = Easing.easeInSine(0.5)
|
||||||
|
luaunit.assertTrue(mid > 0 and mid < 1, "easeInSine(0.5) should be between 0 and 1")
|
||||||
|
luaunit.assertAlmostEquals(Easing.easeInSine(1), 1, 0.01)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Test easeOutSine
|
||||||
|
function TestEasing:testEaseOutSine()
|
||||||
|
luaunit.assertEquals(Easing.easeOutSine(0), 0)
|
||||||
|
local mid = Easing.easeOutSine(0.5)
|
||||||
|
luaunit.assertTrue(mid > 0 and mid < 1, "easeOutSine(0.5) should be between 0 and 1")
|
||||||
|
luaunit.assertAlmostEquals(Easing.easeOutSine(1), 1, 0.01)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Test easeInOutSine
|
||||||
|
function TestEasing:testEaseInOutSine()
|
||||||
|
luaunit.assertEquals(Easing.easeInOutSine(0), 0)
|
||||||
|
luaunit.assertAlmostEquals(Easing.easeInOutSine(0.5), 0.5, 0.01)
|
||||||
|
luaunit.assertAlmostEquals(Easing.easeInOutSine(1), 1, 0.01)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Test easeInQuint
|
||||||
|
function TestEasing:testEaseInQuint()
|
||||||
|
luaunit.assertEquals(Easing.easeInQuint(0), 0)
|
||||||
|
luaunit.assertAlmostEquals(Easing.easeInQuint(0.5), 0.03125, 0.01)
|
||||||
|
luaunit.assertEquals(Easing.easeInQuint(1), 1)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Test easeOutQuint
|
||||||
|
function TestEasing:testEaseOutQuint()
|
||||||
|
luaunit.assertEquals(Easing.easeOutQuint(0), 0)
|
||||||
|
luaunit.assertAlmostEquals(Easing.easeOutQuint(0.5), 0.96875, 0.01)
|
||||||
|
luaunit.assertEquals(Easing.easeOutQuint(1), 1)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Test easeInCirc
|
||||||
|
function TestEasing:testEaseInCirc()
|
||||||
|
luaunit.assertEquals(Easing.easeInCirc(0), 0)
|
||||||
|
local mid = Easing.easeInCirc(0.5)
|
||||||
|
luaunit.assertTrue(mid > 0 and mid < 1, "easeInCirc(0.5) should be between 0 and 1")
|
||||||
|
luaunit.assertAlmostEquals(Easing.easeInCirc(1), 1, 0.01)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Test easeOutCirc
|
||||||
|
function TestEasing:testEaseOutCirc()
|
||||||
|
luaunit.assertEquals(Easing.easeOutCirc(0), 0)
|
||||||
|
local mid = Easing.easeOutCirc(0.5)
|
||||||
|
luaunit.assertTrue(mid > 0 and mid < 1, "easeOutCirc(0.5) should be between 0 and 1")
|
||||||
|
luaunit.assertAlmostEquals(Easing.easeOutCirc(1), 1, 0.01)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Test easeInOutCirc
|
||||||
|
function TestEasing:testEaseInOutCirc()
|
||||||
|
luaunit.assertEquals(Easing.easeInOutCirc(0), 0)
|
||||||
|
luaunit.assertAlmostEquals(Easing.easeInOutCirc(0.5), 0.5, 0.01)
|
||||||
|
luaunit.assertAlmostEquals(Easing.easeInOutCirc(1), 1, 0.01)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Test easeInBack (should overshoot at start)
|
||||||
|
function TestEasing:testEaseInBack()
|
||||||
|
luaunit.assertEquals(Easing.easeInBack(0), 0)
|
||||||
|
local early = Easing.easeInBack(0.3)
|
||||||
|
luaunit.assertTrue(early < 0, "easeInBack should go negative (overshoot) early on")
|
||||||
|
luaunit.assertAlmostEquals(Easing.easeInBack(1), 1, 0.001)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Test easeOutBack (should overshoot at end)
|
||||||
|
function TestEasing:testEaseOutBack()
|
||||||
|
luaunit.assertAlmostEquals(Easing.easeOutBack(0), 0, 0.001)
|
||||||
|
local late = Easing.easeOutBack(0.7)
|
||||||
|
luaunit.assertTrue(late > 0.7, "easeOutBack should overshoot at the end")
|
||||||
|
luaunit.assertAlmostEquals(Easing.easeOutBack(1), 1, 0.01)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Test easeInElastic (should oscillate)
|
||||||
|
function TestEasing:testEaseInElastic()
|
||||||
|
luaunit.assertEquals(Easing.easeInElastic(0), 0)
|
||||||
|
luaunit.assertAlmostEquals(Easing.easeInElastic(1), 1, 0.01)
|
||||||
|
-- Elastic should go negative at some point
|
||||||
|
local hasNegative = false
|
||||||
|
for i = 1, 9 do
|
||||||
|
local t = i / 10
|
||||||
|
if Easing.easeInElastic(t) < 0 then
|
||||||
|
hasNegative = true
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
luaunit.assertTrue(hasNegative, "easeInElastic should have negative values (oscillation)")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Test easeOutElastic (should oscillate)
|
||||||
|
function TestEasing:testEaseOutElastic()
|
||||||
|
luaunit.assertEquals(Easing.easeOutElastic(0), 0)
|
||||||
|
luaunit.assertAlmostEquals(Easing.easeOutElastic(1), 1, 0.01)
|
||||||
|
-- Elastic should go above 1 at some point
|
||||||
|
local hasOvershoot = false
|
||||||
|
for i = 1, 9 do
|
||||||
|
local t = i / 10
|
||||||
|
if Easing.easeOutElastic(t) > 1 then
|
||||||
|
hasOvershoot = true
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
luaunit.assertTrue(hasOvershoot, "easeOutElastic should overshoot 1 (oscillation)")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Test easeInBounce
|
||||||
|
function TestEasing:testEaseInBounce()
|
||||||
|
luaunit.assertEquals(Easing.easeInBounce(0), 0)
|
||||||
|
luaunit.assertAlmostEquals(Easing.easeInBounce(1), 1, 0.01)
|
||||||
|
-- Bounce should have multiple "bounces" (local minima)
|
||||||
|
local result = Easing.easeInBounce(0.5)
|
||||||
|
luaunit.assertTrue(result >= 0 and result <= 1, "easeInBounce should stay within 0-1 range")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Test easeOutBounce
|
||||||
|
function TestEasing:testEaseOutBounce()
|
||||||
|
luaunit.assertEquals(Easing.easeOutBounce(0), 0)
|
||||||
|
luaunit.assertAlmostEquals(Easing.easeOutBounce(1), 1, 0.01)
|
||||||
|
-- Bounce should have bounces
|
||||||
|
local result = Easing.easeOutBounce(0.8)
|
||||||
|
luaunit.assertTrue(result >= 0 and result <= 1, "easeOutBounce should stay within 0-1 range")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Test easeInOutBounce
|
||||||
|
function TestEasing:testEaseInOutBounce()
|
||||||
|
luaunit.assertEquals(Easing.easeInOutBounce(0), 0)
|
||||||
|
luaunit.assertAlmostEquals(Easing.easeInOutBounce(0.5), 0.5, 0.01)
|
||||||
|
luaunit.assertAlmostEquals(Easing.easeInOutBounce(1), 1, 0.01)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Test configurable back() factory
|
||||||
|
function TestEasing:testBackFactory()
|
||||||
|
local customBack = Easing.back(2.5)
|
||||||
|
luaunit.assertEquals(type(customBack), "function")
|
||||||
|
luaunit.assertEquals(customBack(0), 0)
|
||||||
|
luaunit.assertEquals(customBack(1), 1)
|
||||||
|
-- Should overshoot with custom amount
|
||||||
|
local mid = customBack(0.3)
|
||||||
|
luaunit.assertTrue(mid < 0, "Custom back easing should overshoot")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Test configurable elastic() factory
|
||||||
|
function TestEasing:testElasticFactory()
|
||||||
|
local customElastic = Easing.elastic(1.5, 0.4)
|
||||||
|
luaunit.assertEquals(type(customElastic), "function")
|
||||||
|
luaunit.assertEquals(customElastic(0), 0)
|
||||||
|
luaunit.assertAlmostEquals(customElastic(1), 1, 0.01)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Test Easing.list() method
|
||||||
|
function TestEasing:testList()
|
||||||
|
local list = Easing.list()
|
||||||
|
luaunit.assertEquals(type(list), "table")
|
||||||
|
luaunit.assertEquals(#list, 31, "Should have exactly 31 easing functions")
|
||||||
|
|
||||||
|
-- Check that linear is in the list
|
||||||
|
local hasLinear = false
|
||||||
|
for _, name in ipairs(list) do
|
||||||
|
if name == "linear" then
|
||||||
|
hasLinear = true
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
luaunit.assertTrue(hasLinear, "List should contain 'linear'")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Test Easing.get() method
|
||||||
|
function TestEasing:testGet()
|
||||||
|
local linear = Easing.get("linear")
|
||||||
|
luaunit.assertNotNil(linear)
|
||||||
|
luaunit.assertEquals(type(linear), "function")
|
||||||
|
luaunit.assertEquals(linear(0.5), 0.5)
|
||||||
|
|
||||||
|
-- Test non-existent easing
|
||||||
|
local nonExistent = Easing.get("nonExistentEasing")
|
||||||
|
luaunit.assertNil(nonExistent)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Test that all InOut easings are symmetric around 0.5
|
||||||
|
function TestEasing:testInOutSymmetry()
|
||||||
|
local inOutEasings = {
|
||||||
|
"easeInOutQuad", "easeInOutCubic", "easeInOutQuart", "easeInOutQuint",
|
||||||
|
"easeInOutExpo", "easeInOutSine", "easeInOutCirc", "easeInOutBack",
|
||||||
|
"easeInOutElastic", "easeInOutBounce"
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name in ipairs(inOutEasings) do
|
||||||
|
local easing = Easing[name]
|
||||||
|
-- At t=0.5, all InOut easings should be close to 0.5
|
||||||
|
local mid = easing(0.5)
|
||||||
|
luaunit.assertAlmostEquals(mid, 0.5, 0.1, name .. " should be close to 0.5 at t=0.5")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Test boundary conditions for all easings
|
||||||
|
function TestEasing:testBoundaryConditions()
|
||||||
|
local easings = {
|
||||||
|
"linear",
|
||||||
|
"easeInQuad", "easeOutQuad", "easeInOutQuad",
|
||||||
|
"easeInCubic", "easeOutCubic", "easeInOutCubic",
|
||||||
|
"easeInQuart", "easeOutQuart", "easeInOutQuart",
|
||||||
|
"easeInQuint", "easeOutQuint", "easeInOutQuint",
|
||||||
|
"easeInExpo", "easeOutExpo", "easeInOutExpo",
|
||||||
|
"easeInSine", "easeOutSine", "easeInOutSine",
|
||||||
|
"easeInCirc", "easeOutCirc", "easeInOutCirc",
|
||||||
|
"easeInBack", "easeOutBack", "easeInOutBack",
|
||||||
|
"easeInElastic", "easeOutElastic", "easeInOutElastic",
|
||||||
|
"easeInBounce", "easeOutBounce", "easeInOutBounce",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name in ipairs(easings) do
|
||||||
|
local easing = Easing[name]
|
||||||
|
-- All easings should start at 0
|
||||||
|
local start = easing(0)
|
||||||
|
luaunit.assertAlmostEquals(start, 0, 0.01, name .. " should start at 0")
|
||||||
|
|
||||||
|
-- All easings should end at 1
|
||||||
|
local finish = easing(1)
|
||||||
|
luaunit.assertAlmostEquals(finish, 1, 0.01, name .. " should end at 1")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
os.exit(luaunit.LuaUnit.run())
|
||||||
353
testing/__tests__/keyframe_animation_test.lua
Normal file
353
testing/__tests__/keyframe_animation_test.lua
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
local luaunit = require("testing.luaunit")
|
||||||
|
require("testing.loveStub")
|
||||||
|
|
||||||
|
local Animation = require("modules.Animation")
|
||||||
|
local ErrorHandler = require("modules.ErrorHandler")
|
||||||
|
local ErrorCodes = require("modules.ErrorCodes")
|
||||||
|
|
||||||
|
-- Initialize ErrorHandler for Animation module
|
||||||
|
ErrorHandler.init({ ErrorCodes = ErrorCodes })
|
||||||
|
Animation.initializeErrorHandler(ErrorHandler)
|
||||||
|
|
||||||
|
TestKeyframeAnimation = {}
|
||||||
|
|
||||||
|
function TestKeyframeAnimation:setUp()
|
||||||
|
-- Reset state before each test
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Test basic keyframe animation creation
|
||||||
|
function TestKeyframeAnimation:testCreateKeyframeAnimation()
|
||||||
|
local anim = Animation.keyframes({
|
||||||
|
duration = 2,
|
||||||
|
keyframes = {
|
||||||
|
{at = 0, values = {x = 0, opacity = 0}},
|
||||||
|
{at = 1, values = {x = 100, opacity = 1}},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
luaunit.assertNotNil(anim)
|
||||||
|
luaunit.assertEquals(type(anim), "table")
|
||||||
|
luaunit.assertEquals(anim.duration, 2)
|
||||||
|
luaunit.assertNotNil(anim.keyframes)
|
||||||
|
luaunit.assertEquals(#anim.keyframes, 2)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Test keyframe animation with multiple waypoints
|
||||||
|
function TestKeyframeAnimation:testMultipleWaypoints()
|
||||||
|
local anim = Animation.keyframes({
|
||||||
|
duration = 3,
|
||||||
|
keyframes = {
|
||||||
|
{at = 0, values = {x = 0, opacity = 0}},
|
||||||
|
{at = 0.25, values = {x = 50, opacity = 1}},
|
||||||
|
{at = 0.75, values = {x = 150, opacity = 1}},
|
||||||
|
{at = 1, values = {x = 200, opacity = 0}},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
luaunit.assertEquals(#anim.keyframes, 4)
|
||||||
|
luaunit.assertEquals(anim.keyframes[1].at, 0)
|
||||||
|
luaunit.assertEquals(anim.keyframes[2].at, 0.25)
|
||||||
|
luaunit.assertEquals(anim.keyframes[3].at, 0.75)
|
||||||
|
luaunit.assertEquals(anim.keyframes[4].at, 1)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Test keyframe sorting
|
||||||
|
function TestKeyframeAnimation:testKeyframeSorting()
|
||||||
|
local anim = Animation.keyframes({
|
||||||
|
duration = 1,
|
||||||
|
keyframes = {
|
||||||
|
{at = 1, values = {x = 100}},
|
||||||
|
{at = 0, values = {x = 0}},
|
||||||
|
{at = 0.5, values = {x = 50}},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
-- Should be sorted by 'at' position
|
||||||
|
luaunit.assertEquals(anim.keyframes[1].at, 0)
|
||||||
|
luaunit.assertEquals(anim.keyframes[2].at, 0.5)
|
||||||
|
luaunit.assertEquals(anim.keyframes[3].at, 1)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Test keyframe interpolation at start
|
||||||
|
function TestKeyframeAnimation:testInterpolationAtStart()
|
||||||
|
local anim = Animation.keyframes({
|
||||||
|
duration = 1,
|
||||||
|
keyframes = {
|
||||||
|
{at = 0, values = {x = 0, opacity = 0}},
|
||||||
|
{at = 1, values = {x = 100, opacity = 1}},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
anim.elapsed = 0
|
||||||
|
local result = anim:interpolate()
|
||||||
|
|
||||||
|
luaunit.assertNotNil(result.x)
|
||||||
|
luaunit.assertNotNil(result.opacity)
|
||||||
|
luaunit.assertAlmostEquals(result.x, 0, 0.01)
|
||||||
|
luaunit.assertAlmostEquals(result.opacity, 0, 0.01)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Test keyframe interpolation at end
|
||||||
|
function TestKeyframeAnimation:testInterpolationAtEnd()
|
||||||
|
local anim = Animation.keyframes({
|
||||||
|
duration = 1,
|
||||||
|
keyframes = {
|
||||||
|
{at = 0, values = {x = 0, opacity = 0}},
|
||||||
|
{at = 1, values = {x = 100, opacity = 1}},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
anim.elapsed = 1
|
||||||
|
local result = anim:interpolate()
|
||||||
|
|
||||||
|
luaunit.assertAlmostEquals(result.x, 100, 0.01)
|
||||||
|
luaunit.assertAlmostEquals(result.opacity, 1, 0.01)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Test keyframe interpolation at midpoint
|
||||||
|
function TestKeyframeAnimation:testInterpolationAtMidpoint()
|
||||||
|
local anim = Animation.keyframes({
|
||||||
|
duration = 1,
|
||||||
|
keyframes = {
|
||||||
|
{at = 0, values = {x = 0}},
|
||||||
|
{at = 1, values = {x = 100}},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
anim.elapsed = 0.5
|
||||||
|
local result = anim:interpolate()
|
||||||
|
|
||||||
|
luaunit.assertAlmostEquals(result.x, 50, 0.01)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Test per-keyframe easing
|
||||||
|
function TestKeyframeAnimation:testPerKeyframeEasing()
|
||||||
|
local anim = Animation.keyframes({
|
||||||
|
duration = 1,
|
||||||
|
keyframes = {
|
||||||
|
{at = 0, values = {x = 0}, easing = "easeInQuad"},
|
||||||
|
{at = 0.5, values = {x = 50}, easing = "linear"},
|
||||||
|
{at = 1, values = {x = 100}},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
-- At t=0.25 (middle of first segment with easeInQuad)
|
||||||
|
anim.elapsed = 0.25
|
||||||
|
anim._resultDirty = true -- Mark dirty to force recalculation
|
||||||
|
local result1 = anim:interpolate()
|
||||||
|
-- easeInQuad at 0.5 should give 0.25, so x = 0 + (50-0) * 0.25 = 12.5
|
||||||
|
luaunit.assertTrue(result1.x < 25, "easeInQuad should slow start")
|
||||||
|
|
||||||
|
-- At t=0.75 (middle of second segment with linear)
|
||||||
|
anim.elapsed = 0.75
|
||||||
|
anim._resultDirty = true -- Mark dirty to force recalculation
|
||||||
|
local result2 = anim:interpolate()
|
||||||
|
-- linear at 0.5 should give 0.5, so x = 50 + (100-50) * 0.5 = 75
|
||||||
|
luaunit.assertAlmostEquals(result2.x, 75, 1)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Test findKeyframes method
|
||||||
|
function TestKeyframeAnimation:testFindKeyframes()
|
||||||
|
local anim = Animation.keyframes({
|
||||||
|
duration = 1,
|
||||||
|
keyframes = {
|
||||||
|
{at = 0, values = {x = 0}},
|
||||||
|
{at = 0.25, values = {x = 25}},
|
||||||
|
{at = 0.75, values = {x = 75}},
|
||||||
|
{at = 1, values = {x = 100}},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
-- Test finding keyframes at different progress values
|
||||||
|
local prev1, next1 = anim:findKeyframes(0.1)
|
||||||
|
luaunit.assertEquals(prev1.at, 0)
|
||||||
|
luaunit.assertEquals(next1.at, 0.25)
|
||||||
|
|
||||||
|
local prev2, next2 = anim:findKeyframes(0.5)
|
||||||
|
luaunit.assertEquals(prev2.at, 0.25)
|
||||||
|
luaunit.assertEquals(next2.at, 0.75)
|
||||||
|
|
||||||
|
local prev3, next3 = anim:findKeyframes(0.9)
|
||||||
|
luaunit.assertEquals(prev3.at, 0.75)
|
||||||
|
luaunit.assertEquals(next3.at, 1)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Test keyframe animation with update
|
||||||
|
function TestKeyframeAnimation:testKeyframeAnimationUpdate()
|
||||||
|
local anim = Animation.keyframes({
|
||||||
|
duration = 1,
|
||||||
|
keyframes = {
|
||||||
|
{at = 0, values = {opacity = 0}},
|
||||||
|
{at = 1, values = {opacity = 1}},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
-- Update halfway through
|
||||||
|
anim:update(0.5)
|
||||||
|
local result = anim:interpolate()
|
||||||
|
|
||||||
|
luaunit.assertAlmostEquals(result.opacity, 0.5, 0.01)
|
||||||
|
luaunit.assertFalse(anim:update(0)) -- Not complete yet
|
||||||
|
|
||||||
|
-- Update to completion
|
||||||
|
luaunit.assertTrue(anim:update(0.6)) -- Should complete
|
||||||
|
luaunit.assertEquals(anim:getState(), "completed")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Test keyframe animation with callbacks
|
||||||
|
function TestKeyframeAnimation:testKeyframeAnimationCallbacks()
|
||||||
|
local startCalled = false
|
||||||
|
local updateCalled = false
|
||||||
|
local completeCalled = false
|
||||||
|
|
||||||
|
local anim = Animation.keyframes({
|
||||||
|
duration = 1,
|
||||||
|
keyframes = {
|
||||||
|
{at = 0, values = {x = 0}},
|
||||||
|
{at = 1, values = {x = 100}},
|
||||||
|
},
|
||||||
|
onStart = function() startCalled = true end,
|
||||||
|
onUpdate = function() updateCalled = true end,
|
||||||
|
onComplete = function() completeCalled = true end,
|
||||||
|
})
|
||||||
|
|
||||||
|
anim:update(0.5)
|
||||||
|
luaunit.assertTrue(startCalled)
|
||||||
|
luaunit.assertTrue(updateCalled)
|
||||||
|
luaunit.assertFalse(completeCalled)
|
||||||
|
|
||||||
|
anim:update(0.6)
|
||||||
|
luaunit.assertTrue(completeCalled)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Test missing keyframes (error handling)
|
||||||
|
function TestKeyframeAnimation:testMissingKeyframes()
|
||||||
|
-- Should create default keyframes with warning
|
||||||
|
local anim = Animation.keyframes({
|
||||||
|
duration = 1,
|
||||||
|
keyframes = {}
|
||||||
|
})
|
||||||
|
|
||||||
|
luaunit.assertNotNil(anim)
|
||||||
|
luaunit.assertEquals(#anim.keyframes, 2) -- Should have default start and end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Test single keyframe (error handling)
|
||||||
|
function TestKeyframeAnimation:testSingleKeyframe()
|
||||||
|
-- Should create default keyframes with warning
|
||||||
|
local anim = Animation.keyframes({
|
||||||
|
duration = 1,
|
||||||
|
keyframes = {
|
||||||
|
{at = 0.5, values = {x = 50}}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
luaunit.assertNotNil(anim)
|
||||||
|
luaunit.assertTrue(#anim.keyframes >= 2) -- Should have at least 2 keyframes
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Test keyframes without start (at=0)
|
||||||
|
function TestKeyframeAnimation:testKeyframesWithoutStart()
|
||||||
|
local anim = Animation.keyframes({
|
||||||
|
duration = 1,
|
||||||
|
keyframes = {
|
||||||
|
{at = 0.5, values = {x = 50}},
|
||||||
|
{at = 1, values = {x = 100}},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
-- Should auto-add keyframe at 0
|
||||||
|
luaunit.assertEquals(anim.keyframes[1].at, 0)
|
||||||
|
luaunit.assertEquals(anim.keyframes[1].values.x, 50) -- Should copy first keyframe values
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Test keyframes without end (at=1)
|
||||||
|
function TestKeyframeAnimation:testKeyframesWithoutEnd()
|
||||||
|
local anim = Animation.keyframes({
|
||||||
|
duration = 1,
|
||||||
|
keyframes = {
|
||||||
|
{at = 0, values = {x = 0}},
|
||||||
|
{at = 0.5, values = {x = 50}},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
-- Should auto-add keyframe at 1
|
||||||
|
luaunit.assertEquals(anim.keyframes[#anim.keyframes].at, 1)
|
||||||
|
luaunit.assertEquals(anim.keyframes[#anim.keyframes].values.x, 50) -- Should copy last keyframe values
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Test keyframe with invalid props
|
||||||
|
function TestKeyframeAnimation:testInvalidKeyframeProps()
|
||||||
|
-- Should handle gracefully with warnings
|
||||||
|
local anim = Animation.keyframes({
|
||||||
|
duration = 0, -- Invalid
|
||||||
|
keyframes = "not a table" -- Invalid
|
||||||
|
})
|
||||||
|
|
||||||
|
luaunit.assertNotNil(anim)
|
||||||
|
luaunit.assertEquals(anim.duration, 1) -- Should use default
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Test complex multi-property keyframes
|
||||||
|
function TestKeyframeAnimation:testMultiPropertyKeyframes()
|
||||||
|
local anim = Animation.keyframes({
|
||||||
|
duration = 2,
|
||||||
|
keyframes = {
|
||||||
|
{at = 0, values = {x = 0, y = 0, opacity = 0, width = 50}},
|
||||||
|
{at = 0.33, values = {x = 100, y = 50, opacity = 1, width = 100}},
|
||||||
|
{at = 0.66, values = {x = 200, y = 100, opacity = 1, width = 150}},
|
||||||
|
{at = 1, values = {x = 300, y = 150, opacity = 0, width = 200}},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
-- Test interpolation at 0.5 (middle of second segment)
|
||||||
|
anim.elapsed = 1.0 -- t = 0.5
|
||||||
|
local result = anim:interpolate()
|
||||||
|
|
||||||
|
luaunit.assertNotNil(result.x)
|
||||||
|
luaunit.assertNotNil(result.y)
|
||||||
|
luaunit.assertNotNil(result.opacity)
|
||||||
|
luaunit.assertNotNil(result.width)
|
||||||
|
|
||||||
|
-- Should be interpolating between keyframes at 0.33 and 0.66
|
||||||
|
luaunit.assertTrue(result.x > 100 and result.x < 200)
|
||||||
|
luaunit.assertTrue(result.y > 50 and result.y < 100)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Test keyframe with easing function (not string)
|
||||||
|
function TestKeyframeAnimation:testKeyframeWithEasingFunction()
|
||||||
|
local customEasing = function(t) return t * t end
|
||||||
|
|
||||||
|
local anim = Animation.keyframes({
|
||||||
|
duration = 1,
|
||||||
|
keyframes = {
|
||||||
|
{at = 0, values = {x = 0}, easing = customEasing},
|
||||||
|
{at = 1, values = {x = 100}},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
anim.elapsed = 0.5
|
||||||
|
local result = anim:interpolate()
|
||||||
|
|
||||||
|
-- At t=0.5, easing(0.5) = 0.25, so x = 0 + 100 * 0.25 = 25
|
||||||
|
luaunit.assertAlmostEquals(result.x, 25, 1)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Test caching behavior with keyframes
|
||||||
|
function TestKeyframeAnimation:testKeyframeCaching()
|
||||||
|
local anim = Animation.keyframes({
|
||||||
|
duration = 1,
|
||||||
|
keyframes = {
|
||||||
|
{at = 0, values = {x = 0}},
|
||||||
|
{at = 1, values = {x = 100}},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
anim.elapsed = 0.5
|
||||||
|
local result1 = anim:interpolate()
|
||||||
|
local result2 = anim:interpolate() -- Should return cached result
|
||||||
|
|
||||||
|
luaunit.assertEquals(result1, result2) -- Should be same table
|
||||||
|
end
|
||||||
|
|
||||||
|
os.exit(luaunit.LuaUnit.run())
|
||||||
Reference in New Issue
Block a user