more work on Animation
This commit is contained in:
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -194,7 +194,7 @@ jobs:
|
||||
|
||||
## Documentation
|
||||
|
||||
📚 [View Documentation](https://github.com/${{ github.repository }}/tree/main/docs)
|
||||
📚 [View Documentation](https://mikefreno.github.io/FlexLove/)
|
||||
|
||||
## What's Included
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
## Architecture
|
||||
- **Immediate mode**: Elements recreated each frame, layout triggered by `endFrame()` → `layoutChildren()` called on top-level elements
|
||||
- **Retained mode**: Elements persist, must manually update properties (default)
|
||||
- **Dependencies**: Pass via `deps` table parameter in constructors (e.g., `{utils, ErrorHandler, Units}`)
|
||||
- **Dependencies**: Pass via `deps` table parameter in constructors (e.g., `{utils, ErrorHandler, Units}`). Do not use require outside of `FlexLove.lua`.
|
||||
- **Layout flow**: `Element.new()` → `layoutChildren()` on construction → `resize()` on viewport change → `layoutChildren()` again
|
||||
- **CSS positioning**: `top/right/bottom/left` applied via `LayoutEngine:applyPositioningOffsets()` for absolute/relative containers
|
||||
|
||||
|
||||
@@ -31,6 +31,8 @@ local Element = req("Element")
|
||||
-- externals
|
||||
---@type Animation
|
||||
local Animation = req("Animation")
|
||||
---@type AnimationGroup
|
||||
local AnimationGroup = req("AnimationGroup")
|
||||
---@type Color
|
||||
local Color = req("Color")
|
||||
---@type Theme
|
||||
@@ -95,6 +97,12 @@ Color.initializeErrorHandler(ErrorHandler)
|
||||
-- Initialize ErrorHandler for utils
|
||||
utils.initializeErrorHandler(ErrorHandler)
|
||||
|
||||
-- Initialize ErrorHandler for Animation module
|
||||
Animation.initializeErrorHandler(ErrorHandler)
|
||||
|
||||
-- Initialize ErrorHandler for AnimationGroup module
|
||||
AnimationGroup.initializeErrorHandler(ErrorHandler)
|
||||
|
||||
-- Add version and metadata
|
||||
flexlove._VERSION = "0.2.3"
|
||||
flexlove._DESCRIPTION = "0I Library for LÖVE Framework based on flexbox"
|
||||
@@ -1085,6 +1093,7 @@ function flexlove.getStateStats()
|
||||
end
|
||||
|
||||
flexlove.Animation = Animation
|
||||
flexlove.AnimationGroup = AnimationGroup
|
||||
flexlove.Color = Color
|
||||
flexlove.Theme = Theme
|
||||
flexlove.enums = enums
|
||||
|
||||
@@ -19,49 +19,73 @@ function lv.draw()
|
||||
local container = FlexLove.new({
|
||||
width = "100vw",
|
||||
height = "100vh",
|
||||
positioning = "flex",
|
||||
flexDirection = "vertical",
|
||||
padding = { top = 20, right = 20, bottom = 20, left = 20 },
|
||||
gap = 20,
|
||||
backgroundColor = Color.new(0.95, 0.95, 0.95, 1),
|
||||
overflow = "scroll",
|
||||
padding = { top = 20, right = 20, bottom = 20, left = 20 },
|
||||
})
|
||||
|
||||
-- Title
|
||||
local title = FlexLove.new({
|
||||
FlexLove.new({
|
||||
parent = container,
|
||||
text = "FlexLove Image Showcase",
|
||||
textSize = "xxl",
|
||||
textColor = Color.new(0.2, 0.2, 0.2, 1),
|
||||
textAlign = "center",
|
||||
textWrap = "word",
|
||||
width = "100%",
|
||||
z = 1000,
|
||||
padding = { top = 0, right = 0, bottom = 20, left = 0 },
|
||||
})
|
||||
|
||||
-- Section 1: Object-Fit Modes
|
||||
local fitSection = FlexLove.new({
|
||||
parent = container,
|
||||
width = "100%",
|
||||
flexDirection = "vertical",
|
||||
gap = 10,
|
||||
})
|
||||
|
||||
local fitTitle = FlexLove.new({
|
||||
FlexLove.new({
|
||||
parent = fitSection,
|
||||
text = "Object-Fit Modes",
|
||||
textSize = "lg",
|
||||
textColor = Color.new(0.3, 0.3, 0.3, 1),
|
||||
textWrap = "word",
|
||||
width = "100%",
|
||||
z = 1000,
|
||||
padding = { top = 5, right = 0, bottom = 5, left = 0 },
|
||||
})
|
||||
|
||||
local fitRow = FlexLove.new({
|
||||
parent = fitSection,
|
||||
width = "100%",
|
||||
positioning = "flex",
|
||||
flexDirection = "horizontal",
|
||||
gap = 10,
|
||||
justifyContent = "space-around",
|
||||
gap = 15,
|
||||
justifyContent = "space-between",
|
||||
alignItems = "flex-start",
|
||||
padding = { top = 30 },
|
||||
})
|
||||
|
||||
local fitModes = { "fill", "contain", "cover", "scale-down", "none" }
|
||||
for _, mode in ipairs(fitModes) do
|
||||
local fitSizes = {
|
||||
{ width = 200, height = 140, imgWidth = 180, imgHeight = 100 },
|
||||
{ width = 160, height = 120, imgWidth = 140, imgHeight = 80 },
|
||||
{ width = 220, height = 160, imgWidth = 200, imgHeight = 120 },
|
||||
{ width = 180, height = 130, imgWidth = 160, imgHeight = 90 },
|
||||
{ width = 190, height = 150, imgWidth = 170, imgHeight = 110 },
|
||||
}
|
||||
|
||||
for i, mode in ipairs(fitModes) do
|
||||
local size = fitSizes[i]
|
||||
local fitBox = FlexLove.new({
|
||||
parent = fitRow,
|
||||
width = 180,
|
||||
height = 120,
|
||||
width = size.width,
|
||||
height = size.height,
|
||||
positioning = "flex",
|
||||
flexDirection = "vertical",
|
||||
gap = 5,
|
||||
backgroundColor = Color.new(1, 1, 1, 1),
|
||||
@@ -69,51 +93,74 @@ function lv.draw()
|
||||
padding = { top = 10, right = 10, bottom = 10, left = 10 },
|
||||
})
|
||||
|
||||
local fitImage = FlexLove.new({
|
||||
FlexLove.new({
|
||||
parent = fitBox,
|
||||
width = 160,
|
||||
height = 80,
|
||||
width = size.imgWidth,
|
||||
height = size.imgHeight,
|
||||
backgroundColor = Color.new(0.9, 0.9, 0.9, 1),
|
||||
cornerRadius = 4,
|
||||
imagePath = "sample.jpg",
|
||||
objectFit = mode,
|
||||
})
|
||||
|
||||
local fitLabel = FlexLove.new({
|
||||
FlexLove.new({
|
||||
parent = fitBox,
|
||||
text = mode,
|
||||
textSize = "sm",
|
||||
textColor = Color.new(0.4, 0.4, 0.4, 1),
|
||||
textAlign = "center",
|
||||
textWrap = "word",
|
||||
width = "100%",
|
||||
z = 1000,
|
||||
padding = { top = 3, right = 0, bottom = 3, left = 0 },
|
||||
})
|
||||
end
|
||||
|
||||
-- Section 2: Object-Position
|
||||
local posSection = FlexLove.new({
|
||||
parent = container,
|
||||
width = "100%",
|
||||
flexDirection = "vertical",
|
||||
gap = 10,
|
||||
})
|
||||
|
||||
local posTitle = FlexLove.new({
|
||||
FlexLove.new({
|
||||
parent = posSection,
|
||||
text = "Object-Position",
|
||||
textSize = "lg",
|
||||
textColor = Color.new(0.3, 0.3, 0.3, 1),
|
||||
textWrap = "word",
|
||||
width = "100%",
|
||||
z = 1000,
|
||||
padding = { top = 5, right = 0, bottom = 5, left = 0 },
|
||||
})
|
||||
|
||||
local posRow = FlexLove.new({
|
||||
parent = posSection,
|
||||
width = "100%",
|
||||
positioning = "flex",
|
||||
flexDirection = "horizontal",
|
||||
gap = 10,
|
||||
justifyContent = "space-around",
|
||||
gap = 15,
|
||||
justifyContent = "space-between",
|
||||
alignItems = "flex-start",
|
||||
padding = { top = 30 },
|
||||
})
|
||||
|
||||
local positions = { "top left", "center center", "bottom right", "50% 20%", "left center" }
|
||||
for _, pos in ipairs(positions) do
|
||||
local posSizes = {
|
||||
{ width = 170, height = 130, imgWidth = 150, imgHeight = 90 },
|
||||
{ width = 210, height = 150, imgWidth = 190, imgHeight = 110 },
|
||||
{ width = 180, height = 140, imgWidth = 160, imgHeight = 100 },
|
||||
{ width = 195, height = 135, imgWidth = 175, imgHeight = 95 },
|
||||
{ width = 185, height = 145, imgWidth = 165, imgHeight = 105 },
|
||||
}
|
||||
|
||||
for i, pos in ipairs(positions) do
|
||||
local size = posSizes[i]
|
||||
local posBox = FlexLove.new({
|
||||
parent = posRow,
|
||||
width = 180,
|
||||
height = 120,
|
||||
width = size.width,
|
||||
height = size.height,
|
||||
positioning = "flex",
|
||||
flexDirection = "vertical",
|
||||
gap = 5,
|
||||
backgroundColor = Color.new(1, 1, 1, 1),
|
||||
@@ -121,53 +168,74 @@ function lv.draw()
|
||||
padding = { top = 10, right = 10, bottom = 10, left = 10 },
|
||||
})
|
||||
|
||||
local posImage = FlexLove.new({
|
||||
FlexLove.new({
|
||||
parent = posBox,
|
||||
width = 160,
|
||||
height = 80,
|
||||
width = size.imgWidth,
|
||||
height = size.imgHeight,
|
||||
backgroundColor = Color.new(0.9, 0.9, 0.9, 1),
|
||||
cornerRadius = 4,
|
||||
imagePath = "sample.jpg",
|
||||
objectFit = "none",
|
||||
objectPosition = pos,
|
||||
})
|
||||
|
||||
local posLabel = FlexLove.new({
|
||||
FlexLove.new({
|
||||
parent = posBox,
|
||||
text = pos,
|
||||
textSize = "xs",
|
||||
textColor = Color.new(0.4, 0.4, 0.4, 1),
|
||||
textAlign = "center",
|
||||
textWrap = "word",
|
||||
width = "100%",
|
||||
z = 1000,
|
||||
padding = { top = 3, right = 0, bottom = 3, left = 0 },
|
||||
})
|
||||
end
|
||||
|
||||
-- Section 3: Image Tiling/Repeat
|
||||
local tileSection = FlexLove.new({
|
||||
parent = container,
|
||||
width = "100%",
|
||||
flexDirection = "vertical",
|
||||
gap = 10,
|
||||
})
|
||||
|
||||
local tileTitle = FlexLove.new({
|
||||
FlexLove.new({
|
||||
parent = tileSection,
|
||||
text = "Image Tiling (Repeat Modes)",
|
||||
textSize = "lg",
|
||||
textColor = Color.new(0.3, 0.3, 0.3, 1),
|
||||
textWrap = "word",
|
||||
width = "100%",
|
||||
z = 1000,
|
||||
padding = { top = 5, right = 0, bottom = 5, left = 0 },
|
||||
})
|
||||
|
||||
local tileRow = FlexLove.new({
|
||||
parent = tileSection,
|
||||
width = "100%",
|
||||
positioning = "flex",
|
||||
flexDirection = "horizontal",
|
||||
gap = 10,
|
||||
justifyContent = "space-around",
|
||||
gap = 20,
|
||||
justifyContent = "space-between",
|
||||
alignItems = "flex-start",
|
||||
padding = { top = 30 },
|
||||
})
|
||||
|
||||
local repeatModes = { "no-repeat", "repeat", "repeat-x", "repeat-y" }
|
||||
for _, mode in ipairs(repeatModes) do
|
||||
local tileSizes = {
|
||||
{ width = 260, height = 140, imgWidth = 240, imgHeight = 100 },
|
||||
{ width = 240, height = 130, imgWidth = 220, imgHeight = 90 },
|
||||
{ width = 280, height = 150, imgWidth = 260, imgHeight = 110 },
|
||||
{ width = 250, height = 135, imgWidth = 230, imgHeight = 95 },
|
||||
}
|
||||
|
||||
for i, mode in ipairs(repeatModes) do
|
||||
local size = tileSizes[i]
|
||||
local tileBox = FlexLove.new({
|
||||
parent = tileRow,
|
||||
width = 240,
|
||||
height = 120,
|
||||
width = size.width,
|
||||
height = size.height,
|
||||
positioning = "flex",
|
||||
flexDirection = "vertical",
|
||||
gap = 5,
|
||||
backgroundColor = Color.new(1, 1, 1, 1),
|
||||
@@ -175,44 +243,56 @@ function lv.draw()
|
||||
padding = { top = 10, right = 10, bottom = 10, left = 10 },
|
||||
})
|
||||
|
||||
local tileImage = FlexLove.new({
|
||||
FlexLove.new({
|
||||
parent = tileBox,
|
||||
width = 220,
|
||||
height = 80,
|
||||
width = size.imgWidth,
|
||||
height = size.imgHeight,
|
||||
backgroundColor = Color.new(0.9, 0.9, 0.9, 1),
|
||||
cornerRadius = 4,
|
||||
-- imagePath = "assets/pattern.png", -- Uncomment if you have a pattern image
|
||||
imagePath = "sample.jpg",
|
||||
imageRepeat = mode,
|
||||
})
|
||||
|
||||
local tileLabel = FlexLove.new({
|
||||
FlexLove.new({
|
||||
parent = tileBox,
|
||||
text = mode,
|
||||
textSize = "sm",
|
||||
textColor = Color.new(0.4, 0.4, 0.4, 1),
|
||||
textAlign = "center",
|
||||
textWrap = "word",
|
||||
width = "100%",
|
||||
z = 1000,
|
||||
padding = { top = 3, right = 0, bottom = 3, left = 0 },
|
||||
})
|
||||
end
|
||||
|
||||
-- Section 4: Image Tinting and Opacity
|
||||
local tintSection = FlexLove.new({
|
||||
parent = container,
|
||||
width = "100%",
|
||||
flexDirection = "vertical",
|
||||
gap = 10,
|
||||
})
|
||||
|
||||
local tintTitle = FlexLove.new({
|
||||
FlexLove.new({
|
||||
parent = tintSection,
|
||||
text = "Image Tinting & Opacity",
|
||||
textSize = "lg",
|
||||
textColor = Color.new(0.3, 0.3, 0.3, 1),
|
||||
textWrap = "word",
|
||||
width = "100%",
|
||||
z = 1000,
|
||||
padding = { top = 5, right = 0, bottom = 5, left = 0 },
|
||||
})
|
||||
|
||||
local tintRow = FlexLove.new({
|
||||
parent = tintSection,
|
||||
width = "100%",
|
||||
positioning = "flex",
|
||||
flexDirection = "horizontal",
|
||||
gap = 10,
|
||||
justifyContent = "space-around",
|
||||
gap = 15,
|
||||
justifyContent = "space-between",
|
||||
alignItems = "flex-start",
|
||||
padding = { top = 30 },
|
||||
})
|
||||
|
||||
local tints = {
|
||||
@@ -223,11 +303,21 @@ function lv.draw()
|
||||
{ name = "Green + 70%", color = Color.new(0.5, 1, 0.5, 1), opacity = 0.7 },
|
||||
}
|
||||
|
||||
for _, tint in ipairs(tints) do
|
||||
local tintSizes = {
|
||||
{ width = 185, height = 135, imgWidth = 165, imgHeight = 95 },
|
||||
{ width = 200, height = 145, imgWidth = 180, imgHeight = 105 },
|
||||
{ width = 175, height = 130, imgWidth = 155, imgHeight = 90 },
|
||||
{ width = 195, height = 140, imgWidth = 175, imgHeight = 100 },
|
||||
{ width = 190, height = 150, imgWidth = 170, imgHeight = 110 },
|
||||
}
|
||||
|
||||
for i, tint in ipairs(tints) do
|
||||
local size = tintSizes[i]
|
||||
local tintBox = FlexLove.new({
|
||||
parent = tintRow,
|
||||
width = 180,
|
||||
height = 120,
|
||||
width = size.width,
|
||||
height = size.height,
|
||||
positioning = "flex",
|
||||
flexDirection = "vertical",
|
||||
gap = 5,
|
||||
backgroundColor = Color.new(1, 1, 1, 1),
|
||||
@@ -235,34 +325,40 @@ function lv.draw()
|
||||
padding = { top = 10, right = 10, bottom = 10, left = 10 },
|
||||
})
|
||||
|
||||
local tintImage = FlexLove.new({
|
||||
FlexLove.new({
|
||||
parent = tintBox,
|
||||
width = 160,
|
||||
height = 80,
|
||||
width = size.imgWidth,
|
||||
height = size.imgHeight,
|
||||
backgroundColor = Color.new(0.9, 0.9, 0.9, 1),
|
||||
cornerRadius = 4,
|
||||
imagePath = "sample.jpg",
|
||||
imageTint = tint.color,
|
||||
imageOpacity = tint.opacity,
|
||||
})
|
||||
|
||||
local tintLabel = FlexLove.new({
|
||||
FlexLove.new({
|
||||
parent = tintBox,
|
||||
text = tint.name,
|
||||
textSize = "xs",
|
||||
textColor = Color.new(0.4, 0.4, 0.4, 1),
|
||||
textAlign = "center",
|
||||
textWrap = "word",
|
||||
width = "100%",
|
||||
z = 1000,
|
||||
padding = { top = 3, right = 0, bottom = 3, left = 0 },
|
||||
})
|
||||
end
|
||||
|
||||
-- Footer note
|
||||
local note = FlexLove.new({
|
||||
FlexLove.new({
|
||||
parent = container,
|
||||
text = "Note: Uncomment imagePath properties in code to see actual images",
|
||||
text = "Image showcase demonstrating various FlexLove image properties",
|
||||
textSize = "xs",
|
||||
textColor = Color.new(0.5, 0.5, 0.5, 1),
|
||||
textAlign = "center",
|
||||
padding = { top = 10, right = 0, bottom = 0, left = 0 },
|
||||
textWrap = "word",
|
||||
width = "100%",
|
||||
z = 1000,
|
||||
padding = { top = 10, right = 0, bottom = 10, left = 0 },
|
||||
})
|
||||
end
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
--- Easing function type
|
||||
---@alias EasingFunction fun(t: number): number
|
||||
|
||||
-- ErrorHandler dependency (injected via initializeErrorHandler)
|
||||
local ErrorHandler = nil
|
||||
|
||||
--- Easing functions for animations
|
||||
---@type table<string, EasingFunction>
|
||||
local Easing = {
|
||||
@@ -76,19 +79,23 @@ Animation.__index = Animation
|
||||
function Animation.new(props)
|
||||
-- Validate input
|
||||
if type(props) ~= "table" then
|
||||
error("[FlexLove.Animation] Animation.new() requires a table argument")
|
||||
ErrorHandler.warn("Animation", "Animation.new() requires a table argument. Using default values.")
|
||||
props = {duration = 1, start = {}, final = {}}
|
||||
end
|
||||
|
||||
if type(props.duration) ~= "number" or props.duration <= 0 then
|
||||
error("[FlexLove.Animation] Animation duration must be a positive number")
|
||||
ErrorHandler.warn("Animation", "Animation duration must be a positive number. Using 1 second.")
|
||||
props.duration = 1
|
||||
end
|
||||
|
||||
if type(props.start) ~= "table" then
|
||||
error("[FlexLove.Animation] Animation start must be a table")
|
||||
ErrorHandler.warn("Animation", "Animation start must be a table. Using empty table.")
|
||||
props.start = {}
|
||||
end
|
||||
|
||||
if type(props.final) ~= "table" then
|
||||
error("[FlexLove.Animation] Animation final must be a table")
|
||||
ErrorHandler.warn("Animation", "Animation final must be a table. Using empty table.")
|
||||
props.final = {}
|
||||
end
|
||||
|
||||
local self = setmetatable({}, Animation)
|
||||
@@ -144,6 +151,14 @@ function Animation:update(dt, element)
|
||||
return false
|
||||
end
|
||||
|
||||
-- Handle delay
|
||||
if self._delay and self._delayElapsed then
|
||||
if self._delayElapsed < self._delay then
|
||||
self._delayElapsed = self._delayElapsed + dt
|
||||
return false
|
||||
end
|
||||
end
|
||||
|
||||
-- Call onStart on first update
|
||||
if not self._hasStarted then
|
||||
self._hasStarted = true
|
||||
@@ -180,8 +195,32 @@ function Animation:update(dt, element)
|
||||
self.elapsed = self.elapsed + dt
|
||||
if self.elapsed >= self.duration then
|
||||
self.elapsed = self.duration
|
||||
self._state = "completed"
|
||||
self._resultDirty = true
|
||||
|
||||
-- Handle repeat and yoyo
|
||||
if self._repeatCount then
|
||||
self._repeatCurrent = (self._repeatCurrent or 0) + 1
|
||||
|
||||
if self._repeatCount == 0 or self._repeatCurrent < self._repeatCount then
|
||||
-- Continue repeating
|
||||
if self._yoyo then
|
||||
-- Reverse direction for yoyo
|
||||
self._reversed = not self._reversed
|
||||
if self._reversed then
|
||||
self.elapsed = self.duration
|
||||
else
|
||||
self.elapsed = 0
|
||||
end
|
||||
else
|
||||
-- Reset to beginning
|
||||
self.elapsed = 0
|
||||
end
|
||||
return false
|
||||
end
|
||||
end
|
||||
|
||||
-- Animation truly completed
|
||||
self._state = "completed"
|
||||
-- Call onComplete callback
|
||||
if self.onComplete and type(self.onComplete) == "function" then
|
||||
local success, err = pcall(self.onComplete, self, element)
|
||||
@@ -355,8 +394,13 @@ end
|
||||
---Apply this animation to an element
|
||||
---@param element Element The element to apply animation to
|
||||
function Animation:apply(element)
|
||||
if not ErrorHandler then
|
||||
ErrorHandler = require("modules.ErrorHandler")
|
||||
end
|
||||
|
||||
if not element or type(element) ~= "table" then
|
||||
error("[FlexLove.Animation] Cannot apply animation to nil or non-table element")
|
||||
ErrorHandler.warn("Animation", "Cannot apply animation to nil or non-table element. Animation not applied.")
|
||||
return
|
||||
end
|
||||
element.animation = self
|
||||
end
|
||||
@@ -464,6 +508,71 @@ function Animation:getProgress()
|
||||
return math.min(self.elapsed / self.duration, 1)
|
||||
end
|
||||
|
||||
---Chain another animation after this one completes
|
||||
---@param nextAnimation Animation|function Animation instance or factory function that returns an animation
|
||||
---@return Animation nextAnimation The chained animation (for further chaining)
|
||||
function Animation:chain(nextAnimation)
|
||||
if not ErrorHandler then
|
||||
ErrorHandler = require("modules.ErrorHandler")
|
||||
end
|
||||
|
||||
if type(nextAnimation) == "function" then
|
||||
self._nextFactory = nextAnimation
|
||||
return self
|
||||
elseif type(nextAnimation) == "table" then
|
||||
self._next = nextAnimation
|
||||
return nextAnimation
|
||||
else
|
||||
ErrorHandler.warn("Animation", "chain() requires an Animation or function. Chaining not applied.")
|
||||
return self
|
||||
end
|
||||
end
|
||||
|
||||
---Add delay before animation starts
|
||||
---@param seconds number Delay duration in seconds
|
||||
---@return Animation self For chaining
|
||||
function Animation:delay(seconds)
|
||||
if not ErrorHandler then
|
||||
ErrorHandler = require("modules.ErrorHandler")
|
||||
end
|
||||
|
||||
if type(seconds) ~= "number" or seconds < 0 then
|
||||
ErrorHandler.warn("Animation", "delay() requires a non-negative number. Using 0.")
|
||||
seconds = 0
|
||||
end
|
||||
self._delay = seconds
|
||||
self._delayElapsed = 0
|
||||
return self
|
||||
end
|
||||
|
||||
---Repeat animation multiple times
|
||||
---@param count number Number of times to repeat (0 = infinite loop)
|
||||
---@return Animation self For chaining
|
||||
function Animation:repeatCount(count)
|
||||
if not ErrorHandler then
|
||||
ErrorHandler = require("modules.ErrorHandler")
|
||||
end
|
||||
|
||||
if type(count) ~= "number" or count < 0 then
|
||||
ErrorHandler.warn("Animation", "repeatCount() requires a non-negative number. Using 0.")
|
||||
count = 0
|
||||
end
|
||||
self._repeatCount = count
|
||||
self._repeatCurrent = 0
|
||||
return self
|
||||
end
|
||||
|
||||
---Enable yoyo mode (animation reverses direction on each repeat)
|
||||
---@param enabled boolean? Enable yoyo mode (default: true)
|
||||
---@return Animation self For chaining
|
||||
function Animation:yoyo(enabled)
|
||||
if enabled == nil then
|
||||
enabled = true
|
||||
end
|
||||
self._yoyo = enabled
|
||||
return self
|
||||
end
|
||||
|
||||
--- Create a simple fade animation
|
||||
---@param duration number Duration in seconds
|
||||
---@param fromOpacity number Starting opacity (0-1)
|
||||
@@ -520,4 +629,13 @@ function Animation.scale(duration, fromScale, toScale, easing)
|
||||
})
|
||||
end
|
||||
|
||||
--- Initialize ErrorHandler dependency
|
||||
---@param errorHandler table The ErrorHandler module
|
||||
local function initializeErrorHandler(errorHandler)
|
||||
ErrorHandler = errorHandler
|
||||
end
|
||||
|
||||
-- Export ErrorHandler initializer
|
||||
Animation.initializeErrorHandler = initializeErrorHandler
|
||||
|
||||
return Animation
|
||||
|
||||
327
modules/AnimationGroup.lua
Normal file
327
modules/AnimationGroup.lua
Normal file
@@ -0,0 +1,327 @@
|
||||
--- AnimationGroup module for running multiple animations together
|
||||
---@class AnimationGroup
|
||||
local AnimationGroup = {}
|
||||
AnimationGroup.__index = AnimationGroup
|
||||
|
||||
-- ErrorHandler dependency (injected via initializeErrorHandler)
|
||||
local ErrorHandler = nil
|
||||
|
||||
---@class AnimationGroupProps
|
||||
---@field animations table Array of Animation instances
|
||||
---@field mode string? "parallel", "sequence", or "stagger" (default: "parallel")
|
||||
---@field stagger number? Stagger delay in seconds (for stagger mode, default: 0.1)
|
||||
---@field onComplete function? Called when all animations complete: (group)
|
||||
---@field onStart function? Called when group starts: (group)
|
||||
|
||||
--- Create a new animation group
|
||||
---@param props AnimationGroupProps
|
||||
---@return AnimationGroup group
|
||||
function AnimationGroup.new(props)
|
||||
if type(props) ~= "table" then
|
||||
ErrorHandler.warn("AnimationGroup", "AnimationGroup.new() requires a table argument. Using default values.")
|
||||
props = {animations = {}}
|
||||
end
|
||||
|
||||
if type(props.animations) ~= "table" or #props.animations == 0 then
|
||||
ErrorHandler.warn("AnimationGroup", "AnimationGroup requires at least one animation. Creating empty group.")
|
||||
props.animations = {}
|
||||
end
|
||||
|
||||
local self = setmetatable({}, AnimationGroup)
|
||||
|
||||
self.animations = props.animations
|
||||
self.mode = props.mode or "parallel"
|
||||
self.stagger = props.stagger or 0.1
|
||||
self.onComplete = props.onComplete
|
||||
self.onStart = props.onStart
|
||||
|
||||
-- Validate mode
|
||||
if self.mode ~= "parallel" and self.mode ~= "sequence" and self.mode ~= "stagger" then
|
||||
ErrorHandler.warn("AnimationGroup", string.format("Invalid mode: %s. Using 'parallel'.", tostring(self.mode)))
|
||||
self.mode = "parallel"
|
||||
end
|
||||
|
||||
-- Internal state
|
||||
self._currentIndex = 1
|
||||
self._staggerElapsed = 0
|
||||
self._startedAnimations = {}
|
||||
self._hasStarted = false
|
||||
self._paused = false
|
||||
self._state = "ready" -- "ready", "playing", "completed", "cancelled"
|
||||
|
||||
return self
|
||||
end
|
||||
|
||||
--- Update all animations in parallel
|
||||
---@param dt number Delta time
|
||||
---@param element table? Optional element reference for callbacks
|
||||
---@return boolean finished True if all animations complete
|
||||
function AnimationGroup:_updateParallel(dt, element)
|
||||
local allFinished = true
|
||||
|
||||
for i, anim in ipairs(self.animations) do
|
||||
-- Check if animation has isCompleted method or check state
|
||||
local isCompleted = false
|
||||
if type(anim.getState) == "function" then
|
||||
isCompleted = anim:getState() == "completed"
|
||||
elseif anim._state then
|
||||
isCompleted = anim._state == "completed"
|
||||
end
|
||||
|
||||
if not isCompleted then
|
||||
local finished = anim:update(dt, element)
|
||||
if not finished then
|
||||
allFinished = false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return allFinished
|
||||
end
|
||||
|
||||
--- Update animations in sequence (one after another)
|
||||
---@param dt number Delta time
|
||||
---@param element table? Optional element reference for callbacks
|
||||
---@return boolean finished True if all animations complete
|
||||
function AnimationGroup:_updateSequence(dt, element)
|
||||
if self._currentIndex > #self.animations then
|
||||
return true
|
||||
end
|
||||
|
||||
local currentAnim = self.animations[self._currentIndex]
|
||||
local finished = currentAnim:update(dt, element)
|
||||
|
||||
if finished then
|
||||
self._currentIndex = self._currentIndex + 1
|
||||
if self._currentIndex > #self.animations then
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
return false
|
||||
end
|
||||
|
||||
--- Update animations with stagger delay
|
||||
---@param dt number Delta time
|
||||
---@param element table? Optional element reference for callbacks
|
||||
---@return boolean finished True if all animations complete
|
||||
function AnimationGroup:_updateStagger(dt, element)
|
||||
self._staggerElapsed = self._staggerElapsed + dt
|
||||
|
||||
-- Start animations based on stagger timing
|
||||
for i, anim in ipairs(self.animations) do
|
||||
local startTime = (i - 1) * self.stagger
|
||||
|
||||
if self._staggerElapsed >= startTime and not self._startedAnimations[i] then
|
||||
self._startedAnimations[i] = true
|
||||
end
|
||||
end
|
||||
|
||||
-- Update started animations
|
||||
local allFinished = true
|
||||
for i, anim in ipairs(self.animations) do
|
||||
if self._startedAnimations[i] then
|
||||
local isCompleted = false
|
||||
if type(anim.getState) == "function" then
|
||||
isCompleted = anim:getState() == "completed"
|
||||
elseif anim._state then
|
||||
isCompleted = anim._state == "completed"
|
||||
end
|
||||
|
||||
if not isCompleted then
|
||||
local finished = anim:update(dt, element)
|
||||
if not finished then
|
||||
allFinished = false
|
||||
end
|
||||
end
|
||||
else
|
||||
allFinished = false
|
||||
end
|
||||
end
|
||||
|
||||
return allFinished
|
||||
end
|
||||
|
||||
--- Update the animation group
|
||||
---@param dt number Delta time
|
||||
---@param element table? Optional element reference for callbacks
|
||||
---@return boolean finished True if group is complete
|
||||
function AnimationGroup:update(dt, element)
|
||||
-- Sanitize dt
|
||||
if type(dt) ~= "number" or dt < 0 or dt ~= dt or dt == math.huge then
|
||||
dt = 0
|
||||
end
|
||||
|
||||
if self._paused or self._state == "completed" or self._state == "cancelled" then
|
||||
return self._state == "completed"
|
||||
end
|
||||
|
||||
-- Call onStart on first update
|
||||
if not self._hasStarted then
|
||||
self._hasStarted = true
|
||||
self._state = "playing"
|
||||
if self.onStart and type(self.onStart) == "function" then
|
||||
local success, err = pcall(self.onStart, self)
|
||||
if not success then
|
||||
print(string.format("[AnimationGroup] onStart error: %s", tostring(err)))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local finished = false
|
||||
|
||||
if self.mode == "parallel" then
|
||||
finished = self:_updateParallel(dt, element)
|
||||
elseif self.mode == "sequence" then
|
||||
finished = self:_updateSequence(dt, element)
|
||||
elseif self.mode == "stagger" then
|
||||
finished = self:_updateStagger(dt, element)
|
||||
end
|
||||
|
||||
if finished then
|
||||
self._state = "completed"
|
||||
if self.onComplete and type(self.onComplete) == "function" then
|
||||
local success, err = pcall(self.onComplete, self)
|
||||
if not success then
|
||||
print(string.format("[AnimationGroup] onComplete error: %s", tostring(err)))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return finished
|
||||
end
|
||||
|
||||
--- Pause all animations in the group
|
||||
function AnimationGroup:pause()
|
||||
self._paused = true
|
||||
for _, anim in ipairs(self.animations) do
|
||||
if type(anim.pause) == "function" then
|
||||
anim:pause()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--- Resume all animations in the group
|
||||
function AnimationGroup:resume()
|
||||
self._paused = false
|
||||
for _, anim in ipairs(self.animations) do
|
||||
if type(anim.resume) == "function" then
|
||||
anim:resume()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--- Check if group is paused
|
||||
---@return boolean paused
|
||||
function AnimationGroup:isPaused()
|
||||
return self._paused
|
||||
end
|
||||
|
||||
--- Reverse all animations in the group
|
||||
function AnimationGroup:reverse()
|
||||
for _, anim in ipairs(self.animations) do
|
||||
if type(anim.reverse) == "function" then
|
||||
anim:reverse()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--- Set speed for all animations in the group
|
||||
---@param speed number Speed multiplier
|
||||
function AnimationGroup:setSpeed(speed)
|
||||
for _, anim in ipairs(self.animations) do
|
||||
if type(anim.setSpeed) == "function" then
|
||||
anim:setSpeed(speed)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--- Cancel all animations in the group
|
||||
---@param element table? Optional element reference for callbacks
|
||||
function AnimationGroup:cancel(element)
|
||||
if self._state ~= "cancelled" and self._state ~= "completed" then
|
||||
self._state = "cancelled"
|
||||
for _, anim in ipairs(self.animations) do
|
||||
if type(anim.cancel) == "function" then
|
||||
anim:cancel(element)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--- Reset the animation group to initial state
|
||||
function AnimationGroup:reset()
|
||||
self._currentIndex = 1
|
||||
self._staggerElapsed = 0
|
||||
self._startedAnimations = {}
|
||||
self._hasStarted = false
|
||||
self._paused = false
|
||||
self._state = "ready"
|
||||
|
||||
for _, anim in ipairs(self.animations) do
|
||||
if type(anim.reset) == "function" then
|
||||
anim:reset()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--- Get the current state of the group
|
||||
---@return string state "ready", "playing", "completed", "cancelled"
|
||||
function AnimationGroup:getState()
|
||||
return self._state
|
||||
end
|
||||
|
||||
--- Get the overall progress of the group (0-1)
|
||||
---@return number progress
|
||||
function AnimationGroup:getProgress()
|
||||
if #self.animations == 0 then
|
||||
return 1
|
||||
end
|
||||
|
||||
if self.mode == "sequence" then
|
||||
-- For sequence, progress is based on current animation index + current animation progress
|
||||
local completedAnims = self._currentIndex - 1
|
||||
local currentProgress = 0
|
||||
|
||||
if self._currentIndex <= #self.animations then
|
||||
local currentAnim = self.animations[self._currentIndex]
|
||||
if type(currentAnim.getProgress) == "function" then
|
||||
currentProgress = currentAnim:getProgress()
|
||||
end
|
||||
end
|
||||
|
||||
return (completedAnims + currentProgress) / #self.animations
|
||||
else
|
||||
-- For parallel and stagger, average progress of all animations
|
||||
local totalProgress = 0
|
||||
for _, anim in ipairs(self.animations) do
|
||||
if type(anim.getProgress) == "function" then
|
||||
totalProgress = totalProgress + anim:getProgress()
|
||||
else
|
||||
totalProgress = totalProgress + 1
|
||||
end
|
||||
end
|
||||
return totalProgress / #self.animations
|
||||
end
|
||||
end
|
||||
|
||||
--- Apply this animation group to an element
|
||||
---@param element Element The element to apply animations to
|
||||
function AnimationGroup:apply(element)
|
||||
if not element or type(element) ~= "table" then
|
||||
ErrorHandler.warn("AnimationGroup", "Cannot apply animation group to nil or non-table element. Group not applied.")
|
||||
return
|
||||
end
|
||||
element.animationGroup = self
|
||||
end
|
||||
|
||||
--- Initialize ErrorHandler dependency
|
||||
---@param errorHandler table The ErrorHandler module
|
||||
local function initializeErrorHandler(errorHandler)
|
||||
ErrorHandler = errorHandler
|
||||
end
|
||||
|
||||
-- Export ErrorHandler initializer
|
||||
AnimationGroup.initializeErrorHandler = initializeErrorHandler
|
||||
|
||||
return AnimationGroup
|
||||
@@ -2127,7 +2127,19 @@ function Element:update(dt)
|
||||
local finished = self.animation:update(dt, self)
|
||||
if finished then
|
||||
-- Animation:update() already called onComplete callback
|
||||
self.animation = nil -- remove finished animation
|
||||
-- Check for chained animation
|
||||
if self.animation._next then
|
||||
self.animation = self.animation._next
|
||||
elseif self.animation._nextFactory and type(self.animation._nextFactory) == "function" then
|
||||
local success, nextAnim = pcall(self.animation._nextFactory, self)
|
||||
if success and nextAnim then
|
||||
self.animation = nextAnim
|
||||
else
|
||||
self.animation = nil
|
||||
end
|
||||
else
|
||||
self.animation = nil
|
||||
end
|
||||
else
|
||||
-- Apply animation interpolation during update
|
||||
local anim = self.animation:interpolate()
|
||||
@@ -2968,4 +2980,121 @@ function Element:setTransformOrigin(originX, originY)
|
||||
self.transform.originY = originY
|
||||
end
|
||||
|
||||
--- Set transition configuration for a property
|
||||
---@param property string Property name or "all" for all properties
|
||||
---@param config table Transition config {duration, easing, delay, onComplete}
|
||||
function Element:setTransition(property, config)
|
||||
if not self.transitions then
|
||||
self.transitions = {}
|
||||
end
|
||||
|
||||
if type(config) ~= "table" then
|
||||
self._deps.ErrorHandler.warn("Element", "setTransition() requires a config table. Using default config.")
|
||||
config = {}
|
||||
end
|
||||
|
||||
-- Validate config
|
||||
if config.duration and (type(config.duration) ~= "number" or config.duration < 0) then
|
||||
self._deps.ErrorHandler.warn("Element", "transition duration must be a non-negative number. Using 0.3 seconds.")
|
||||
config.duration = 0.3
|
||||
end
|
||||
|
||||
self.transitions[property] = {
|
||||
duration = config.duration or 0.3,
|
||||
easing = config.easing or "easeOutQuad",
|
||||
delay = config.delay or 0,
|
||||
onComplete = config.onComplete
|
||||
}
|
||||
end
|
||||
|
||||
--- Set transition configuration for multiple properties
|
||||
---@param groupName string Name for this transition group
|
||||
---@param config table Transition config {duration, easing, delay, onComplete}
|
||||
---@param properties table Array of property names
|
||||
function Element:setTransitionGroup(groupName, config, properties)
|
||||
if type(properties) ~= "table" then
|
||||
self._deps.ErrorHandler.warn("Element", "setTransitionGroup() requires a properties array. No transitions set.")
|
||||
return
|
||||
end
|
||||
|
||||
for _, prop in ipairs(properties) do
|
||||
self:setTransition(prop, config)
|
||||
end
|
||||
end
|
||||
|
||||
--- Remove transition configuration for a property
|
||||
---@param property string Property name or "all" to remove all
|
||||
function Element:removeTransition(property)
|
||||
if not self.transitions then
|
||||
return
|
||||
end
|
||||
|
||||
if property == "all" then
|
||||
self.transitions = {}
|
||||
else
|
||||
self.transitions[property] = nil
|
||||
end
|
||||
end
|
||||
|
||||
--- Set property with automatic transition
|
||||
---@param property string Property name
|
||||
---@param value any New value
|
||||
function Element:setProperty(property, value)
|
||||
-- Check if transitions are enabled for this property
|
||||
local shouldTransition = false
|
||||
local transitionConfig = nil
|
||||
|
||||
if self.transitions then
|
||||
transitionConfig = self.transitions[property] or self.transitions["all"]
|
||||
shouldTransition = transitionConfig ~= nil
|
||||
end
|
||||
|
||||
-- Don't transition if value is the same
|
||||
if self[property] == value then
|
||||
return
|
||||
end
|
||||
|
||||
if shouldTransition and transitionConfig then
|
||||
-- Get current value
|
||||
local currentValue = self[property]
|
||||
|
||||
-- Only transition if we have a valid current value
|
||||
if currentValue ~= nil then
|
||||
-- Create animation for the property change
|
||||
local Animation = require("modules.Animation")
|
||||
local anim = Animation.new({
|
||||
duration = transitionConfig.duration,
|
||||
start = { [property] = currentValue },
|
||||
final = { [property] = value },
|
||||
easing = transitionConfig.easing,
|
||||
onComplete = transitionConfig.onComplete
|
||||
})
|
||||
|
||||
-- Set Color module reference if needed
|
||||
if self._deps and self._deps.Color then
|
||||
anim:setColorModule(self._deps.Color)
|
||||
end
|
||||
|
||||
-- Set Transform module reference if needed
|
||||
if self._deps and self._deps.Transform then
|
||||
anim:setTransformModule(self._deps.Transform)
|
||||
end
|
||||
|
||||
-- Apply delay if configured
|
||||
if transitionConfig.delay and transitionConfig.delay > 0 then
|
||||
anim:delay(transitionConfig.delay)
|
||||
end
|
||||
|
||||
-- Apply animation
|
||||
anim:apply(self)
|
||||
else
|
||||
-- No current value, set directly
|
||||
self[property] = value
|
||||
end
|
||||
else
|
||||
-- No transition, set directly
|
||||
self[property] = value
|
||||
end
|
||||
end
|
||||
|
||||
return Element
|
||||
|
||||
@@ -190,10 +190,45 @@ function Renderer:_drawImage(x, y, paddingLeft, paddingTop, contentWidth, conten
|
||||
|
||||
if hasCornerRadius then
|
||||
-- Use stencil to clip image to rounded corners
|
||||
local success, err = pcall(function()
|
||||
love.graphics.stencil(function()
|
||||
self._RoundedRect.draw("fill", x, y, borderBoxWidth, borderBoxHeight, self.cornerRadius)
|
||||
end, "replace", 1)
|
||||
love.graphics.setStencilTest("greater", 0)
|
||||
end)
|
||||
|
||||
if not success then
|
||||
-- Lazy-load ErrorHandler if needed
|
||||
if not ErrorHandler then
|
||||
ErrorHandler = require("modules.ErrorHandler")
|
||||
end
|
||||
|
||||
-- Check if it's a stencil buffer error
|
||||
if err and err:match("stencil") then
|
||||
ErrorHandler.warn(
|
||||
"Renderer",
|
||||
"IMG_001",
|
||||
"Cannot apply corner radius to image: stencil buffer not available",
|
||||
{
|
||||
imagePath = self.imagePath or "unknown",
|
||||
cornerRadius = string.format(
|
||||
"TL:%d TR:%d BL:%d BR:%d",
|
||||
self.cornerRadius.topLeft,
|
||||
self.cornerRadius.topRight,
|
||||
self.cornerRadius.bottomLeft,
|
||||
self.cornerRadius.bottomRight
|
||||
),
|
||||
error = tostring(err),
|
||||
},
|
||||
"Ensure the active canvas has stencil=true enabled, or remove cornerRadius from images"
|
||||
)
|
||||
-- Continue without corner radius
|
||||
hasCornerRadius = false
|
||||
else
|
||||
-- Re-throw if it's a different error
|
||||
error(err, 2)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Draw the image based on repeat mode
|
||||
|
||||
@@ -3,6 +3,17 @@ require("testing.loveStub")
|
||||
|
||||
local Animation = require("modules.Animation")
|
||||
local Color = require("modules.Color")
|
||||
local Transform = require("modules.Transform")
|
||||
local ErrorHandler = require("modules.ErrorHandler")
|
||||
local ErrorCodes = require("modules.ErrorCodes")
|
||||
|
||||
-- Initialize ErrorHandler
|
||||
ErrorHandler.init({ ErrorCodes = ErrorCodes })
|
||||
Animation.initializeErrorHandler(ErrorHandler)
|
||||
Color.initializeErrorHandler(ErrorHandler)
|
||||
|
||||
-- Make Color module available to Animation
|
||||
Animation.setColorModule(Color)
|
||||
|
||||
TestAnimationProperties = {}
|
||||
|
||||
|
||||
@@ -2,6 +2,12 @@ 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)
|
||||
|
||||
TestAnimation = {}
|
||||
|
||||
@@ -22,25 +28,25 @@ function TestAnimation:testNewWithNilDuration()
|
||||
end
|
||||
|
||||
function TestAnimation:testNewWithNegativeDuration()
|
||||
-- Should throw an error for invalid duration
|
||||
luaunit.assertErrorMsgContains("duration must be a positive number", function()
|
||||
Animation.new({
|
||||
-- Should warn and use default duration (1 second) for invalid duration
|
||||
local anim = Animation.new({
|
||||
duration = -1,
|
||||
start = { opacity = 0 },
|
||||
final = { opacity = 1 },
|
||||
})
|
||||
end)
|
||||
luaunit.assertNotNil(anim)
|
||||
luaunit.assertEquals(anim.duration, 1) -- Default value
|
||||
end
|
||||
|
||||
function TestAnimation:testNewWithZeroDuration()
|
||||
-- Should throw an error for invalid duration
|
||||
luaunit.assertErrorMsgContains("duration must be a positive number", function()
|
||||
Animation.new({
|
||||
-- Should warn and use default duration (1 second) for invalid duration
|
||||
local anim = Animation.new({
|
||||
duration = 0,
|
||||
start = { opacity = 0 },
|
||||
final = { opacity = 1 },
|
||||
})
|
||||
end)
|
||||
luaunit.assertNotNil(anim)
|
||||
luaunit.assertEquals(anim.duration, 1) -- Default value
|
||||
end
|
||||
|
||||
function TestAnimation:testNewWithInvalidEasing()
|
||||
|
||||
Reference in New Issue
Block a user